Skip to content

Commit d1f495d

Browse files
committed
Merge remote-tracking branch 'origin/main' into blove/stage-explore-local
2 parents 8f4fc22 + f9c649b commit d1f495d

30 files changed

Lines changed: 421 additions & 471 deletions

apps/website/e2e/home-airport.spec.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -289,7 +289,9 @@ test.describe('homepage airport diagram', () => {
289289
CONCOURSES.length
290290
);
291291
await expect(page.locator(`${PLATE} [data-main-terminal]`)).toHaveCount(1);
292-
await expect(page.locator(`${PLATE} .ap-taxiway`)).toHaveCount(3);
292+
// The N/S/E taxiways were removed on request. Asserted as absent rather
293+
// than dropped, so re-adding them is a deliberate act and not a drift.
294+
await expect(page.locator(`${PLATE} .ap-taxiway`)).toHaveCount(0);
293295
await expect(page.locator(`${PLATE} .ap-furniture`)).toHaveCount(1);
294296
// The off-airport row is the section's central argument rendered as
295297
// geometry — the five providers sit OUTSIDE the neat line because

apps/website/e2e/nav-panels.spec.ts

Lines changed: 33 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -102,13 +102,43 @@ test.describe('desktop nav panels', () => {
102102
);
103103
await expect(page.locator('.nav-panel')).toBeVisible({ timeout: 2000 });
104104

105-
// The dead zone between the trigger row and the panel belongs to neither
106-
// element; crossing it schedules a close that entering the panel must cancel.
105+
// Read the resting geometry: the entrance animation translates the panel
106+
// by 4px, so an un-settled box would move the waypoints below.
107+
await page.locator('.nav-panel').evaluate((el) =>
108+
Promise.all(el.getAnimations().map((animation) => animation.finished)),
109+
);
110+
const rowBox = await page.locator('.nav-desktop').boundingBox();
111+
const panelBox = await page.locator('.nav-panel').boundingBox();
107112
const itemBox = await page
108113
.locator('.nav-panel .nav-panel-item')
109114
.first()
110115
.boundingBox();
111-
if (!itemBox) throw new Error('Panel item has no box');
116+
if (!rowBox || !panelBox || !itemBox)
117+
throw new Error('Nav geometry has no box');
118+
119+
// The band between the trigger row's bottom edge and the panel's top edge
120+
// is the row's own `py-4 md:py-5` padding. It is real space the pointer
121+
// has to cross, and it used to belong to neither element: leaving the row
122+
// scheduled a close, and only arriving at the panel could cancel it.
123+
const gapTop = rowBox.y + rowBox.height;
124+
const gapBottom = panelBox.y;
125+
expect(gapBottom).toBeGreaterThan(gapTop);
126+
127+
// Cross that band DELIBERATELY SLOWLY — three stops of 120ms, ~360ms in
128+
// total, comfortably past NavDesktop's CLOSE_DELAY_MS of 150ms. A quick
129+
// traverse merely outruns the close timer, so it passes on fast hardware
130+
// whether or not the gap is bridged (that is how this shipped red on CI
131+
// and green locally). Dwelling longer than the grace asserts the thing
132+
// that actually keeps the panel open: .nav-panel-shell's transparent
133+
// top padding makes the band part of the shell, so the pointer never
134+
// leaves the panel's own subtree and no close is ever scheduled.
135+
const x = triggerBox.x + triggerBox.width / 2;
136+
for (const y of [gapTop + 1, (gapTop + gapBottom) / 2, gapBottom - 1]) {
137+
await page.mouse.move(x, y, { steps: 5 });
138+
await page.waitForTimeout(120);
139+
}
140+
await expect(page.locator('.nav-panel')).toBeVisible();
141+
112142
await page.mouse.move(
113143
itemBox.x + itemBox.width / 2,
114144
itemBox.y + itemBox.height / 2,

apps/website/e2e/nav-surface.spec.ts

Lines changed: 99 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -1,44 +1,111 @@
1-
import { test, expect } from '@playwright/test';
2-
import { HERO_ROUTES } from '../src/components/shared/nav-config';
1+
import { test, expect, type Page } from '@playwright/test';
32

43
/**
5-
* A hand-maintained hero-route list drifts. A unit test over the list cannot
6-
* catch a page that stopped rendering a hero, so the guard has to visit the
7-
* page and read the computed background.
4+
* The bar is one CSS surface: a translucent white over a backdrop blur, on
5+
* every route at every scroll position. There is no route list, no sentinel,
6+
* no observer and no `data-surface` attribute any more, so there is nothing
7+
* here to assert about state — only that the single surface is actually the
8+
* one that renders.
89
*
9-
* The background is asserted with `toHaveCSS` rather than a one-shot
10-
* `getComputedStyle` read: `.nav-bar` transitions `background` over 200ms, so
11-
* the attribute flips a fifth of a second before the colour finishes moving,
12-
* and a single read lands mid-fade on a partial alpha. `toHaveCSS` retries,
13-
* which is what makes this assert the resting surface instead of the timing.
10+
* Two things can silently take that away, which is why this suite reads the
11+
* computed style out of a real browser rather than trusting the source:
12+
*
13+
* 1. The blur is prefixed by Lightning CSS, not by hand. Writing
14+
* `-webkit-backdrop-filter` in chrome.css makes Lightning collapse the pair
15+
* down to the prefixed property alone, and Chromium does not implement
16+
* `-webkit-backdrop-filter` at all — the bar keeps its 72% alpha and loses
17+
* the blur, which is an unreadable smear rather than a visible failure.
18+
* 2. Anything that reintroduces a scroll- or route-dependent surface brings
19+
* back the hydration flash this replaced.
1420
*/
15-
for (const route of HERO_ROUTES) {
16-
test(`the nav is transparent at rest on ${route}`, async ({ page }) => {
17-
await page.setViewportSize({ width: 1440, height: 900 });
18-
await page.goto(route);
19-
const nav = page.locator('nav').first();
20-
await expect(nav).toHaveAttribute('data-surface', 'transparent');
21-
await expect(nav).toHaveCSS('background-color', 'rgba(0, 0, 0, 0)');
21+
22+
const DOCS_ROUTE = '/docs/langgraph/getting-started/introduction';
23+
24+
interface BarSurface {
25+
readonly background: string;
26+
readonly backdropFilter: string;
27+
readonly boxShadow: string;
28+
readonly borderBottomColor: string;
29+
}
30+
31+
async function readBarSurface(page: Page): Promise<BarSurface> {
32+
return page.evaluate(() => {
33+
const bar = document.querySelector('.nav-bar');
34+
if (!bar) throw new Error('no .nav-bar on the page');
35+
const style = getComputedStyle(bar);
36+
return {
37+
background: style.backgroundColor,
38+
backdropFilter: style.backdropFilter,
39+
boxShadow: style.boxShadow,
40+
borderBottomColor: style.borderBottomColor,
41+
};
2242
});
43+
}
44+
45+
/** The alpha of an `rgb()`/`rgba()` computed colour; 1 when none is present. */
46+
function alphaOf(color: string): number {
47+
const parts = color.match(/-?[\d.]+/g);
48+
if (!parts) throw new Error(`unparseable colour: ${color}`);
49+
return parts.length >= 4 ? Number(parts[3]) : 1;
50+
}
2351

24-
test(`the nav solidifies once ${route} is scrolled`, async ({ page }) => {
52+
for (const [label, route] of [
53+
['the marketing hero', '/'],
54+
['a docs page', DOCS_ROUTE],
55+
] as const) {
56+
test(`the nav bar is translucent and blurred on ${label}`, async ({
57+
page,
58+
}) => {
2559
await page.setViewportSize({ width: 1440, height: 900 });
2660
await page.goto(route);
27-
await page.mouse.wheel(0, 600);
61+
await expect(page.locator('nav').first()).toBeVisible();
62+
63+
const surface = await readBarSurface(page);
64+
65+
// Strictly between 0 and 1: fully opaque is the old solid bar, fully
66+
// transparent is the old hero state. Neither exists any more.
67+
const alpha = alphaOf(surface.background);
68+
expect(alpha, `background was ${surface.background}`).toBeGreaterThan(0);
69+
expect(alpha, `background was ${surface.background}`).toBeLessThan(1);
2870

29-
const nav = page.locator('nav').first();
30-
await expect(nav).toHaveAttribute('data-surface', 'solid');
31-
await expect(nav).not.toHaveCSS('background-color', 'rgba(0, 0, 0, 0)');
71+
// `none` here is the Lightning-CSS prefix trap in the header comment: the
72+
// translucency survives it, so only this read catches it.
73+
expect(surface.backdropFilter).not.toBe('none');
74+
expect(surface.backdropFilter).toContain('blur');
75+
76+
// The redesign removed the shadow deliberately; the hairline is the edge.
77+
expect(surface.boxShadow).toBe('none');
78+
expect(surface.borderBottomColor).not.toBe('rgba(0, 0, 0, 0)');
3279
});
3380
}
3481

35-
test('the nav is solid on a route with no hero', async ({ page }) => {
82+
/**
83+
* The guard for the whole simplification. `useNavSurface`, its 8px sentinel and
84+
* its IntersectionObserver existed only to change this value on scroll; if any
85+
* of that comes back — or a scroll listener, or a route-conditional class —
86+
* these two reads stop matching.
87+
*
88+
* Proved non-vacuous by mutation: adding a rule that repaints `.nav-bar` once
89+
* the page is scrolled fails this case on the background line.
90+
*/
91+
test('the nav bar surface does not change when the page is scrolled', async ({
92+
page,
93+
}) => {
3694
await page.setViewportSize({ width: 1440, height: 900 });
37-
await page.goto('/docs/langgraph/getting-started/introduction');
38-
await expect(page.locator('nav').first()).toHaveAttribute(
39-
'data-surface',
40-
'solid',
41-
);
95+
await page.goto('/');
96+
await expect(page.locator('nav').first()).toBeVisible();
97+
98+
const atTop = await readBarSurface(page);
99+
100+
await page.mouse.wheel(0, 900);
101+
await page.waitForFunction(() => window.scrollY > 400);
102+
// Long enough that a reintroduced 200ms surface transition would have
103+
// finished, so a difference here is a real difference and not a fade caught
104+
// mid-flight.
105+
await page.waitForTimeout(600);
106+
const scrolled = await readBarSurface(page);
107+
108+
expect(scrolled).toEqual(atTop);
42109
});
43110

44111
/**
@@ -48,11 +115,9 @@ test('the nav is solid on a route with no hero', async ({ page }) => {
48115
* also result from a filled button that happened to be 25px tall, so nothing
49116
* else asserts the surface actually changed.
50117
*
51-
* Marketing is asserted as "has a fill", not "has a yellow fill": at rest
52-
* (scroll 0) `/` is a HERO_ROUTES page with a transparent `.nav-bar`, and the
53-
* transparent-surface rule inverts the CTA to a navy fill rather than leaving
54-
* it in its normal yellow — asserting a specific colour here would encode
55-
* that scroll-position inversion and break the moment either theme changes.
118+
* Marketing is asserted as "has a fill", not "has a yellow fill", so the case
119+
* survives a retheme; the docs side can name its colour because the demotion is
120+
* specifically to `--color-accent` as a text link.
56121
*
57122
* Both reads use `toHaveCSS`, not a one-shot `getComputedStyle`: the button
58123
* itself transitions `background-color`/`color` over 120ms on mount, so an
@@ -73,7 +138,7 @@ test('the nav CTA is a filled button on marketing but a text link on docs', asyn
73138
'rgba(0, 0, 0, 0)',
74139
);
75140

76-
await page.goto('/docs/langgraph/getting-started/introduction');
141+
await page.goto(DOCS_ROUTE);
77142
const docsCta = page
78143
.locator('nav')
79144
.first()

apps/website/e2e/website.spec.ts

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,12 @@ import { test, expect } from '@playwright/test';
22

33
// Mirrored from apps/website/src/lib/growth/form-policy.ts, which is server-only
44
// and therefore cannot be imported into a Playwright spec.
5-
const GROWTH_FORM_POLICY_VERSION = 'growth_v1.2026-09-01';
5+
// Hardcoded rather than imported: lib/growth/form-policy.ts is `server-only`
6+
// and cannot be pulled into a Playwright process. It must be updated whenever
7+
// GROWTH_FORM_POLICY_VERSION is bumped -- this literal is compared against what
8+
// the running app actually posts, so a stale copy fails the suite rather than
9+
// passing silently.
10+
const GROWTH_FORM_POLICY_VERSION = 'growth_v1.2026-09-09';
611
const UUID_V4 =
712
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/iu;
813

@@ -52,8 +57,8 @@ test('landing page renders the spine in order (live-stage spec §3)', async ({ p
5257
'proof-heading',
5358
'compatibility-heading',
5459
'architecture-heading',
55-
'stage-heading',
5660
'open-source-heading',
61+
'stage-heading',
5762
'field-report-heading',
5863
'faq-heading',
5964
];
@@ -548,17 +553,21 @@ test('representative docs pages do not create page-level horizontal overflow', a
548553
}
549554
});
550555

551-
test('marketing pages link to downloadable whitepaper PDFs', async ({ page }) => {
552-
const expectedDownloads: Record<string, string> = {
556+
test('marketing pages gate the whitepaper PDFs behind the form', async ({ page }) => {
557+
// These pages used to link their PDF directly. Every direct-download escape
558+
// was removed on request, so the guide is now reachable only by submitting
559+
// the form -- the files stay served (see the next test), they are just not
560+
// linked. Asserted as absent so re-adding a link is a deliberate act.
561+
const gated: Record<string, string> = {
553562
'/ag-ui': '/whitepaper.pdf',
554563
'/langgraph': '/whitepapers/angular.pdf',
555564
'/render': '/whitepapers/render.pdf',
556565
'/chat': '/whitepapers/chat.pdf',
557566
};
558567

559-
for (const [route, href] of Object.entries(expectedDownloads)) {
568+
for (const [route, href] of Object.entries(gated)) {
560569
await page.goto(route);
561-
await expect(page.locator(`a[href="${href}"]`).first(), `${route} links ${href}`).toBeVisible();
570+
await expect(page.locator(`a[href="${href}"]`), `${route} still links ${href}`).toHaveCount(0);
562571
}
563572
});
564573

apps/website/src/app/page.tsx

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -35,14 +35,15 @@ export default function HomePage() {
3535
<Compatibility />
3636
<EnterpriseArchitecture />
3737

38+
{/* The open-source full stop: a loud dark band with one fork CTA. Copy
39+
lives in OPEN_SOURCE_STRIP (positioning.ts). */}
40+
<OpenSourceStrip />
41+
3842
{/* The four capability beats (stream, persist, approve, render): stills
3943
by default, the pinned live act on wide, motion-tolerant viewports
4044
(live-stage spec §3, §8). Copy lives in STAGE_RAIL (positioning.ts). */}
4145
<Stage proof={STAGE_PROOF} />
4246

43-
{/* The open-source full stop: a loud dark band with one fork CTA. Copy
44-
lives in OPEN_SOURCE_STRIP (positioning.ts). */}
45-
<OpenSourceStrip />
4647
<TeamsBlock formPolicy={formPolicy} />
4748
<HomeFAQ />
4849
<RecentArticles />

apps/website/src/components/landing/Compatibility.spec.tsx

Lines changed: 14 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -103,26 +103,26 @@ describe('Compatibility', () => {
103103
}
104104
});
105105

106-
it('states compatibility in words and never implies a customer', () => {
106+
it('never implies a customer, and no longer needs a disclaimer to say so', () => {
107+
// The "Compatibility, not endorsement" line was removed on request. The
108+
// claim it guarded against still matters, so the negative assertion stays:
109+
// nothing in the band may read as an endorsement in the first place.
107110
const { container } = render(<Compatibility />);
108-
expect(screen.getByText(/Compatibility, not endorsement/)).toBeTruthy();
111+
expect(screen.queryByText(/Compatibility, not endorsement/)).toBeNull();
109112
expect(container.textContent).not.toMatch(/trusted by|customers|our clients|powered by/i);
110113
});
111114

112-
it('says Threadplane never talks to model providers, not that it never sees them', () => {
113-
// never-SEES is a data claim the docs do not support; never-TALKS-TO is
114-
// structural. This is the same failure mode #1067 had to correct.
115+
it('never claims Threadplane cannot see model providers', () => {
116+
// The strip used to read "OFF AIRPORT - BEHIND YOUR BACKEND. THREADPLANE
117+
// NEVER TALKS TO THEM." and this guard held the positive half of that
118+
// claim in place. The label is now "All AI Models supported" on request,
119+
// so the structural never-TALKS-TO claim is off the homepage entirely.
115120
//
116-
// The positive half is scoped to .airport-stack, for the same reason as
117-
// the gate-name test above: the plate carries this sentence too, as an
118-
// aria-hidden <text>, so an unscoped read of container.textContent stays
119-
// green while the claim disappears from the phone form and from every
120-
// accessible surface the band has. The negative half stays unscoped —
121-
// "never sees" must not appear anywhere in the section, drawn or spoken.
121+
// The negative half stays, and matters more: never-SEES is a data claim
122+
// the docs do not support, and it is the overclaim #1067 had to correct.
123+
// It must not appear anywhere in the band, drawn or spoken.
122124
const { container } = render(<Compatibility />);
123-
const stack = container.querySelector('.airport-stack');
124-
expect(stack, 'the accessible stack is gone').toBeTruthy();
125-
expect(within(stack as HTMLElement).getByText(/never talks to them/i)).toBeTruthy();
125+
expect(container.querySelector('.airport-stack'), 'the accessible stack is gone').toBeTruthy();
126126
expect(container.textContent).not.toMatch(/never sees/i);
127127
});
128128

apps/website/src/components/landing/Compatibility.tsx

Lines changed: 0 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@ import { AdapterGuideLink } from './AdapterGuideLink';
44
import {
55
CHART_ID,
66
CONCOURSES,
7-
DISCLAIMER,
87
EYEBROW,
98
FIELD,
109
HEADLINE,
@@ -23,9 +22,6 @@ import {
2322
STAND,
2423
TICK_X,
2524
TICK_Y,
26-
TWY_E,
27-
TWY_N,
28-
TWY_S,
2925
VIEW,
3026
type Gate,
3127
type Row,
@@ -48,17 +44,6 @@ function Runway({ y, h, left, right }: RunwayGeometry) {
4844
);
4945
}
5046

51-
function TaxiwayLetter({ x, y, ch }: { x: number; y: number; ch: string }) {
52-
return (
53-
<g>
54-
<circle className="ap-twy-disc" cx={x} cy={y} r={7.5} />
55-
<text className="ap-twy-letter" x={x} y={y + 3.4} textAnchor="middle">
56-
{ch}
57-
</text>
58-
</g>
59-
);
60-
}
61-
6247
/** A stand: the stub off the concourse, the white box, the mark, the callsign. */
6348
function Stand({ gate, row, above }: { gate: Gate; row: Row; above: boolean }) {
6449
const cy = row.standCy;
@@ -168,12 +153,6 @@ function Plate() {
168153
<Runway {...RWY_N} />
169154
<Runway {...RWY_S} />
170155

171-
<path className="ap-taxiway" d={`M${FIELD.x0} ${TWY_N} H${FIELD.x1}`} />
172-
<path className="ap-taxiway" d={`M${FIELD.x0} ${TWY_S} H${FIELD.x1}`} />
173-
<path className="ap-taxiway" d={`M${TWY_E} ${TWY_N} V${TWY_S}`} />
174-
<TaxiwayLetter x={PIVOT.x} y={TWY_N} ch="N" />
175-
<TaxiwayLetter x={PIVOT.x} y={TWY_S} ch="S" />
176-
<TaxiwayLetter x={TWY_E} y={PIVOT.y} ch="E" />
177156

178157
{/* The one structure that IS Threadplane: solid ink. Partner stands are
179158
white, so the two values carry the meaning with no legend. */}
@@ -359,7 +338,6 @@ export function Compatibility() {
359338

360339
<div className="airport-footer">
361340
<AdapterGuideLink className="compatibility-link" />
362-
<p className="compatibility-disclaimer">{DISCLAIMER}</p>
363341
</div>
364342
</Container>
365343
</Section>

apps/website/src/components/landing/EnterpriseArchitecture.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ export const ARCHITECTURE_EYEBROW = 'Architecture';
2626
export const ARCHITECTURE_HEADLINE =
2727
'The UI layer between your users and your agents.';
2828
export const ARCHITECTURE_BODY =
29-
'Threadplane lives inside your Angular application and talks to your agents through the LangGraph SDK or AG-UI. Everything on the right is yours.';
29+
'Threadplane lives inside your Angular application and talks to your agents through the LangGraph SDK or AG-UI.';
3030
export const ARCHITECTURE_LABEL =
3131
'Threadplane is the UI layer between your users and your agents: it lives inside your Angular application, reaches LangGraph agents first-class through the LangGraph SDK and any AG-UI server through the AG-UI protocol, and leaves the model choice to your runtime.';
3232

0 commit comments

Comments
 (0)