From bb377d9acb9ac04494b2404aa13a726a460e7bfc Mon Sep 17 00:00:00 2001 From: Brian Love Date: Tue, 8 Sep 2026 14:19:34 -0700 Subject: [PATCH 01/16] docs(website): spec the compatibility band as an airport diagram The band keeps the pre-ATC treatment: three hairline-separated groups, twelve integrations as 14px names beside 17px marks, plus a footer link and a disclaimer. It becomes a full-bleed aviation-yellow FAA-style airport diagram, where the chart vocabulary carries the argument that the group labels and the aside paragraph were carrying as prose. Verifying against /docs/choosing-an-adapter shaped the design twice: there are exactly two adapters, not three equal-weight groups; and Threadplane never talks to a model provider at all, so those five marks move outside the neat line rather than sitting at integration weight. Co-Authored-By: Claude Opus 5 --- ...08-compatibility-airport-diagram-design.md | 194 ++++++++++++++++++ 1 file changed, 194 insertions(+) create mode 100644 docs/superpowers/specs/2026-09-08-compatibility-airport-diagram-design.md diff --git a/docs/superpowers/specs/2026-09-08-compatibility-airport-diagram-design.md b/docs/superpowers/specs/2026-09-08-compatibility-airport-diagram-design.md new file mode 100644 index 000000000..be9a7145f --- /dev/null +++ b/docs/superpowers/specs/2026-09-08-compatibility-airport-diagram-design.md @@ -0,0 +1,194 @@ +# Compatibility becomes an airport diagram + +Date: 2026-09-08 +Status: approved, ready for a plan +Component: `apps/website/src/components/landing/Compatibility.tsx` + +## 1. Why + +The compatibility band was split out of the dark proof band in #1067 and kept the +old treatment: three hairline-separated groups, twelve integrations as 14px names +beside 17px logos, a footer link and a disclaimer. Too many rules, too much prose, +nothing big enough to see, and the one section on the homepage that never got the +ATC/aviation theme. + +The job of the section is narrow: a visitor recognises a mark from their own stack +and concludes that Threadplane works with it. Nothing else in the section earns +its space. + +Two facts from `/docs/choosing-an-adapter` shape the redesign: + +- There are exactly **two** adapters. `@threadplane/langgraph` for LangGraph + Platform; `@threadplane/ag-ui` for every AG-UI backend (CrewAI, Mastra, + Microsoft Agent Framework, AG2, Pydantic AI, AWS Strands). The old three + equal-weight groups flattened that. +- **Threadplane never talks to a model provider.** The adapters call your + LangGraph or AG-UI endpoint; the model call happens server-side in your agent. + So the five provider marks were never an integration claim, and rendering them + at the same weight as LangGraph said otherwise. + +## 2. What it becomes + +An FAA-style airport diagram, full-bleed on aviation yellow. The chart vocabulary +carries the argument, so the group labels and the aside paragraph are deleted +rather than reworded: + +| Chart element | What it is | +| --- | --- | +| Main terminal, solid ink | `` — Threadplane | +| Concourse A, hatched | `@threadplane/langgraph` | +| Concourse B, hatched | `@threadplane/ag-ui` | +| Gates A1, B1–B6 | the seven runtimes, each at a white stand | +| Outside the neat line | the five model providers | + +### 2.1 Surface + +`surface` flips `tinted` → `signal` (full-bleed `--color-signal` `#FFAF00`), an +existing token-mapped section surface. + +This is what lets every mark render **bare, with no chip**: Anthropic's is +`#181818`, CrewAI's and Pydantic's `#111827`, LangGraph's `#1C3C3C`, Bedrock's +`#252f3e` — all dark marks drawn for a light ground, and aviation yellow is a +light ground. It was the invisibility of these same marks on the dark band that +forced the split in #1067. + +Page rhythm becomes yellow hero → dark preflight → yellow plate → architecture. + +### 2.2 The plate + +A single SVG, `viewBox="0 0 1000 536"`. + +- **Neat line** at `8,8,984,444` with graticule ticks every 88 (x) and 84 (y). + The frame does **not** rotate. +- **Airfield group rotated −3.5°** about `(500, 230)`. Nothing on a real plate is + axis-aligned, and this is the single cheapest signal that it is a chart rather + than a flowchart. Extents are held to x 44–944, y 58–419 so no rotated corner + crosses the neat line. +- **Runways** 09L-27R (y 58, h 11) and 09R-27L (y 408, h 11): solid ink bars with + knocked-out yellow designators. +- **Taxiways** N (y 100), S (y 386), E (x 930), each with a yellow letter disc. +- **Main terminal** `56,190 → 188,288`, solid `#0A0A0A`, carrying the `PlaneMark` + glyph at 27px in yellow, `` in Archivo Black 20, and `MAIN TERMINAL` in + mono. Its contents counter-rotate so they sit upright. +- **Concourses** A (`204,190 → 432,224`) and B (`204,254 → 900,288`): 45° hatch + fill, ink outline, a yellow knockout label box, and the package name below it. +- **Connectors** at y 206 and y 270 from the terminal's east face. +- **Stands**: 38px white boxes, ink hairline, rx 3, each with an ink gate-number + tab welded to its top-left corner. Gate A1 at x 318; B1–B6 at 268, 380, 492, + 604, 716, 828. Each stand and its label counter-rotate about the stand centre. +- **Aprons**: dashed hairline outlines behind each gate row. +- **Chart furniture**: scale bar and north arrow live in the **margin below the + neat line**, not on the field. They were drawn over runway 09R first. + +Two values carry the whole meaning and no legend explains it: **white is somebody +else's, ink is ours.** + +### 2.3 Gate table + +| Gate | Mark | Name | Size | +| --- | --- | --- | --- | +| A1 | `/logos/langgraph.svg` | LANGGRAPH | 21 | +| B1 | `/logos/ag-ui.svg` | AG-UI | 19 | +| B2 | `/logos/runtimes/crewai.svg` | CREWAI | 22 | +| B3 | `/logos/runtimes/mastra.svg` | MASTRA | 16 | +| B4 | `/logos/runtimes/pydantic.svg` | PYDANTIC AI | 21 | +| B5 | `/logos/runtimes/microsoft.svg` | MS AGENT FWK | 19 | +| B6 | `/logos/providers/bedrock.svg` | AWS STRANDS | 12 × 30 | + +**Sizes are per-mark and non-negotiable.** One shared `height` reads wrong: Mastra +is wide and heavy, Anthropic is a narrow wedge, Microsoft is a dense square. These +belong in the geometry module beside the src, not as a CSS rule. + +**B6 is the only width-sized mark.** `bedrock.svg` is the AWS wordmark at 1.67:1, +so it takes an explicit `w` and every other mark takes `s`. Using it for AWS +Strands is honest — Strands is an AWS project, so the mark denotes the actual +thing. The alternative considered and rejected was rendering the word "AWS" in +Archivo Black, which out-weighed every real logo on the plate. + +Note the asset appears twice in the band: gate B6, and Bedrock in the margin. + +### 2.4 Off airport + +Below the neat line, at 20px: + +> `OFF AIRPORT — BEHIND YOUR BACKEND. THREADPLANE NEVER TALKS TO THEM.` +> OpenAI · Anthropic · Google · Azure · Bedrock + +The neat line is the airport boundary, so marks outside it are outside the +airport. The claim is rendered as geometry rather than asserted in a sentence. + +**The wording is "never talks to them", not "never sees it."** Never-*sees* is a +data claim not supported anywhere in the docs, and is the same shape as the three +claims #1067 had to kill. Never-*talks-to* is structural and true. + +This band knowingly repeats marks that `EnterpriseArchitecture` renders one +screen below as its `ANY MODEL` strip (`MODEL_STRIP` in +`src/lib/architecture-diagram.ts`, same five marks, caption "your choice"). The +duplication is accepted in exchange for putting the two most recognisable marks +in the set high on the page. Do not "fix" it by editing the architecture +diagram — its geometry is measured by `e2e/home-architecture.spec.ts`. + +## 3. Files + +Follows `EnterpriseArchitecture` exactly, because that component already solved +this problem shape. + +- **`src/lib/airport-diagram.ts`** (new) — every coordinate, the rotation, the + gate table with per-mark sizes, and the provider list. One module, read by the + component *and* by both specs. Nothing hand-written twice. +- **`src/components/landing/Compatibility.tsx`** — stays a server component. No + interactivity, so no `'use client'`. Renders the SVG and the phone stack from + the same exported data. +- **`src/styles/landing.css`** — the `.compatibility-*` block (currently ~lines + 2007–2075) is replaced. + +## 4. Mobile + +Copies the `arch-stack` precedent verbatim: at `@media (max-width: 767px)` the +SVG figure is hidden and an HTML gate list is shown, grouped by concourse, driven +by the same exported gate table. Never a sideways scroll. + +A seven-stand rotated airfield has no 390px form. This is a real share of the +work, not a detail. + +## 5. Guards + +`Compatibility.spec.tsx` is rewritten. It currently pins three groups, twelve +items and one accessible-named list per group — none of which will exist. + +**Must survive, and each has a reason on the record:** + +- `id="compatibility"` and `compatibility-heading` — `e2e/website.spec.ts` asserts + homepage spine order by heading id. +- Every mark `alt="" aria-hidden="true"` beside a visible name. +- `Choose an adapter →` → `/docs/choosing-an-adapter`, keeping the + `home_adapter_guide` analytics id (`AdapterGuideLink` replaces `className` + rather than appending, so the new stylesheet must supply its own rule). +- The visible endorsement disclaimer and the `trusted by|customers|our + clients|powered by` scan. + +**New:** + +- A unit spec over `airport-diagram.ts`: every stand inside its apron, every + stand's stub actually meeting its concourse, the rotated field's four corners + inside the neat line, and the gate table non-empty per concourse. +- An overflow e2e modelled on `home-architecture.spec.ts`: measure every rendered + `` and `` bbox against the structure that owns it. This session + found four separate collisions by eye — gate numbers over package labels, a + taxiway drawn through the main terminal, sub-labels rendering outside a 26px + concourse, the scale bar on top of runway 09R. Eyes do not scale; that e2e is + the thing that keeps the geometry honest. + +## 6. Decisions recorded, not silently dropped + +- **`AL-0059 (FAA)` is cut.** It is an invented reference to a real government + numbering system on a commercial page. +- **`ELEV 0 · RWY 09/27 · ALL TRAFFIC ACCEPTED` is cut.** Decorative prose in a + redesign whose whole purpose is shedding it, and "all traffic accepted" edges + toward a claim. Runway designators stay — they are geometry, not assertions. +- **AG2 is not on the plate.** It is named in `/docs/choosing-an-adapter` but not + in today's homepage set, and there is no mark for it. Today's set is kept. +- **The band gets taller** than the 634px it replaces, and sits directly above + `EnterpriseArchitecture` — two large technical figures back to back. Accepted. +- **The control tower symbol is not built.** It was drawn and offered (T2); T1 + was chosen. Available if the plate later reads as under-furnished. From 18f36d693666c5881c771d1ccf1a39d475635d88 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Tue, 8 Sep 2026 14:31:57 -0700 Subject: [PATCH 02/16] docs(website): plan the compatibility airport diagram MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Six tasks: the geometry module and its unit spec, the plate, the styles, the phone form, an in-browser overflow spec, and a verification gate. The overflow spec is not optional ceremony. Designing this section surfaced four separate collisions that only an eye caught — gate numbers over package labels, a taxiway drawn through the main terminal, sub-labels rendering outside a 26px concourse, and the scale bar on top of runway 09R. Eyes do not scale. Co-Authored-By: Claude Opus 5 --- ...026-09-08-compatibility-airport-diagram.md | 1281 +++++++++++++++++ 1 file changed, 1281 insertions(+) create mode 100644 docs/superpowers/plans/2026-09-08-compatibility-airport-diagram.md diff --git a/docs/superpowers/plans/2026-09-08-compatibility-airport-diagram.md b/docs/superpowers/plans/2026-09-08-compatibility-airport-diagram.md new file mode 100644 index 000000000..a3e4d73c8 --- /dev/null +++ b/docs/superpowers/plans/2026-09-08-compatibility-airport-diagram.md @@ -0,0 +1,1281 @@ +# Compatibility Airport Diagram Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Replace the homepage compatibility band's three hairline-separated logo groups with a full-bleed aviation-yellow FAA-style airport diagram, where the two adapters are concourses and the seven runtimes are marks parked at numbered gates. + +**Architecture:** All geometry and content live in one new module, `apps/website/src/lib/airport-diagram.ts`, read by three consumers: the server component that draws the SVG, a unit spec that proves the geometry closes, and a Playwright spec that measures the rendered result. This is exactly the shape `src/lib/architecture-diagram.ts` already uses for the diagram in the next section — follow it rather than inventing a second pattern. + +**Tech Stack:** Next.js App Router (React server components), inline SVG, vanilla CSS in `src/styles/landing.css`, Vitest + Testing Library for units, Playwright for e2e. + +**Spec:** `docs/superpowers/specs/2026-09-08-compatibility-airport-diagram-design.md` + +--- + +## File Structure + +| File | Responsibility | +| --- | --- | +| `apps/website/src/lib/airport-diagram.ts` (create) | Every coordinate, the rotation, the gate table with per-mark sizes, the provider list. No JSX. | +| `apps/website/src/lib/airport-diagram.spec.ts` (create) | Proves the geometry closes: rotated field inside the neat line, stands inside aprons, stubs meeting concourses, mark files present on disk. | +| `apps/website/src/components/landing/Compatibility.tsx` (rewrite) | Server component. Draws the plate and the phone stack from the module. | +| `apps/website/src/components/landing/Compatibility.spec.tsx` (rewrite) | The band's public contract: ids, decorative marks, adapter link, disclaimer, no-customer-claim scan. | +| `apps/website/src/styles/landing.css` (modify, ~2007–2075) | Replace the `.compatibility-*` block. | +| `apps/website/e2e/home-airport.spec.ts` (create) | Measures every rendered `` and `` against the structure that owns it. | + +Nothing else on the homepage changes. In particular **do not touch `src/lib/architecture-diagram.ts`** — its `MODEL_STRIP` deliberately shows the same five provider marks, and its geometry is measured by `e2e/home-architecture.spec.ts`. + +--- + +### Task 1: Geometry module + +**Files:** +- Create: `apps/website/src/lib/airport-diagram.ts` +- Test: `apps/website/src/lib/airport-diagram.spec.ts` + +- [ ] **Step 1: Write the failing test** + +Create `apps/website/src/lib/airport-diagram.spec.ts`: + +```ts +import { existsSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { describe, expect, it } from 'vitest'; +import { + APRON_A, + APRON_B, + CONC_A, + CONC_B, + FIELD, + GATES_A, + GATES_B, + MAIN, + NEAT, + PROVIDERS, + ROW1, + ROW2, + STAND, + rotate, +} from './airport-diagram'; + +const WEBSITE = resolve(__dirname, '../..'); + +describe('airport diagram geometry', () => { + it('keeps all four rotated field corners inside the neat line', () => { + const corners = [ + [FIELD.x0, FIELD.y0], + [FIELD.x1, FIELD.y0], + [FIELD.x0, FIELD.y1], + [FIELD.x1, FIELD.y1], + ] as const; + for (const [x, y] of corners) { + const p = rotate(x, y); + expect(p.x, `corner ${x},${y} left`).toBeGreaterThan(NEAT.x); + expect(p.x, `corner ${x},${y} right`).toBeLessThan(NEAT.x + NEAT.width); + expect(p.y, `corner ${x},${y} top`).toBeGreaterThan(NEAT.y); + expect(p.y, `corner ${x},${y} bottom`).toBeLessThan(NEAT.y + NEAT.height); + } + }); + + it('parks every stand inside its apron', () => { + const r = STAND / 2; + const rows = [ + { xs: GATES_A.map((g) => g.x), cy: ROW1.box, apron: APRON_A }, + { xs: GATES_B.map((g) => g.x), cy: ROW2.box, apron: APRON_B }, + ]; + for (const { xs, cy, apron } of rows) { + for (const x of xs) { + expect(x - r, `stand at ${x} left`).toBeGreaterThanOrEqual(apron.x0); + expect(x + r, `stand at ${x} right`).toBeLessThanOrEqual(apron.x1); + expect(cy - r, `stand at ${x} top`).toBeGreaterThanOrEqual(apron.y0); + expect(cy + r, `stand at ${x} bottom`).toBeLessThanOrEqual(apron.y1); + } + } + }); + + it('lands every gate stub on the concourse it belongs to', () => { + // Row 1 hangs above concourse A, row 2 below concourse B. If either stub + // stops short the gates float, which reads as a drawing error. + expect(ROW1.stubBot).toBe(CONC_A.y0); + expect(ROW2.stubTop).toBe(CONC_B.y1); + expect(ROW1.stubTop).toBeLessThan(ROW1.stubBot); + expect(ROW2.stubTop).toBeLessThan(ROW2.stubBot); + }); + + it('keeps every gate within the span of its concourse', () => { + for (const g of GATES_A) { + expect(g.x, `${g.gate} x`).toBeGreaterThan(CONC_A.x0); + expect(g.x, `${g.gate} x`).toBeLessThan(CONC_A.x1); + } + for (const g of GATES_B) { + expect(g.x, `${g.gate} x`).toBeGreaterThan(CONC_B.x0); + expect(g.x, `${g.gate} x`).toBeLessThan(CONC_B.x1); + } + }); + + it('gives both concourses gates, so neither adapter can silently empty out', () => { + // The two-adapter story IS the diagram. A concourse with no gates would + // still render as a building and the section would quietly stop arguing. + expect(GATES_A.length).toBeGreaterThan(0); + expect(GATES_B.length).toBeGreaterThan(0); + }); + + it('sizes each mark individually, and only the AWS wordmark by width', () => { + // One shared height reads wrong: Mastra is wide and heavy, Anthropic is a + // narrow wedge. `w` is the escape hatch for the one 1.67:1 wordmark. + const all = [...GATES_A, ...GATES_B]; + for (const g of all) expect(g.s, `${g.gate} size`).toBeGreaterThan(0); + expect(all.filter((g) => g.w !== undefined).map((g) => g.gate)).toEqual(['B6']); + }); + + it('points every mark at a file that exists', () => { + for (const src of [...GATES_A, ...GATES_B].map((g) => g.src).concat(PROVIDERS.map((p) => p.src))) { + expect(existsSync(resolve(WEBSITE, 'public', src.slice(1))), src).toBe(true); + } + }); + + it('places the main terminal west of both concourses', () => { + expect(MAIN.x1).toBeLessThanOrEqual(CONC_A.x0); + expect(MAIN.x1).toBeLessThanOrEqual(CONC_B.x0); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npx nx test website -- airport-diagram` +Expected: FAIL — `Failed to resolve import "./airport-diagram"`. + +- [ ] **Step 3: Write the module** + +Create `apps/website/src/lib/airport-diagram.ts`: + +```ts +/** + * Geometry and content for the homepage compatibility band, drawn as an + * FAA-style airport diagram + * (spec: docs/superpowers/specs/2026-09-08-compatibility-airport-diagram-design.md). + * + * One table, three readers: Compatibility.tsx draws it, airport-diagram.spec.ts + * checks the geometry closes, and e2e/home-airport.spec.ts measures the + * rendered type against these same boxes. Coordinates are viewBox units. + * + * The chart vocabulary carries the argument, which is why the section has no + * group labels and no explanatory paragraph: the main terminal is , + * concourse A is @threadplane/langgraph, concourse B is @threadplane/ag-ui, + * and each supported runtime is a mark parked at a numbered gate. + * + * Two values carry the meaning and nothing labels it: partner stands are + * WHITE, the one structure that is Threadplane is solid INK. + */ + +export const VIEW = { width: 1000, height: 536 } as const; + +/** The chart frame. It does NOT rotate — only the airfield inside it does. */ +export const NEAT = { x: 8, y: 8, width: 984, height: 444 } as const; +export const TICK_X = 88; +export const TICK_Y = 84; + +/** + * Airfield heading. Nothing on a real plate is axis-aligned, and this is the + * cheapest single signal that this is a chart and not a flowchart. Marks and + * their labels counter-rotate by -ROT so they stay upright. + */ +export const ROT = -3.5; +export const PIVOT = { x: 500, y: 230 } as const; + +export const rotate = (x: number, y: number): { x: number; y: number } => { + const a = (ROT * Math.PI) / 180; + const dx = x - PIVOT.x; + const dy = y - PIVOT.y; + return { + x: PIVOT.x + dx * Math.cos(a) - dy * Math.sin(a), + y: PIVOT.y + dx * Math.sin(a) + dy * Math.cos(a), + }; +}; + +/** Outer extent of everything that rotates. Held so no corner leaves NEAT. */ +export const FIELD = { x0: 56, x1: 944, y0: 58, y1: 419 } as const; + +export const RWY_N = { y: 58, h: 11, left: '09L', right: '27R' } as const; +export const RWY_S = { y: 408, h: 11, left: '09R', right: '27L' } as const; +export const TWY_N = 100; +export const TWY_S = 386; +export const TWY_E = 930; + +export const MAIN = { x0: 56, x1: 188, y0: 190, y1: 288 } as const; +export const CONC_A = { x0: 204, x1: 432, y0: 190, y1: 224 } as const; +export const CONC_B = { x0: 204, x1: 900, y0: 254, y1: 288 } as const; +export const LINK_A_Y = 206; +export const LINK_B_Y = 270; + +export const APRON_A = { x0: 232, x1: 440, y0: 112, y1: 186 } as const; +export const APRON_B = { x0: 232, x1: 910, y0: 292, y1: 372 } as const; + +/** Stand box side, and the two gate rows that hang off the concourses. */ +export const STAND = 38; +export const ROW1 = { box: 140, name: 172, stubTop: 178, stubBot: 190 } as const; +export const ROW2 = { box: 332, name: 364, stubTop: 288, stubBot: 306 } as const; + +export interface Gate { + readonly gate: string; + readonly src: string; + readonly name: string; + /** Optical height. Deliberately per-mark; see the spec test. */ + readonly s: number; + /** Optical width, for wordmarks that are not square. B6 only. */ + readonly w?: number; + readonly x: number; +} + +export const GATES_A: readonly Gate[] = [ + { gate: 'A1', src: '/logos/langgraph.svg', name: 'LANGGRAPH', s: 21, x: 318 }, +]; + +export const GATES_B: readonly Gate[] = [ + { gate: 'B1', src: '/logos/ag-ui.svg', name: 'AG-UI', s: 19, x: 268 }, + { gate: 'B2', src: '/logos/runtimes/crewai.svg', name: 'CREWAI', s: 22, x: 380 }, + { gate: 'B3', src: '/logos/runtimes/mastra.svg', name: 'MASTRA', s: 16, x: 492 }, + { gate: 'B4', src: '/logos/runtimes/pydantic.svg', name: 'PYDANTIC AI', s: 21, x: 604 }, + { gate: 'B5', src: '/logos/runtimes/microsoft.svg', name: 'MS AGENT FWK', s: 19, x: 716 }, + // The AWS wordmark is 1.67:1, so it is the one mark sized by width. Using it + // for Strands is honest — Strands is an AWS project. The rejected + // alternative was the word "AWS" in Archivo Black, which out-weighed every + // real logo on the plate. + { gate: 'B6', src: '/logos/providers/bedrock.svg', name: 'AWS STRANDS', s: 12, w: 30, x: 828 }, +]; + +export const CONCOURSES = [ + { id: 'A', label: 'CONCOURSE A', pkg: '@threadplane/langgraph', box: CONC_A, gates: GATES_A }, + { id: 'B', label: 'CONCOURSE B', pkg: '@threadplane/ag-ui', box: CONC_B, gates: GATES_B }, +] as const; + +/** + * Outside the neat line is outside the airport. The claim is rendered as + * geometry rather than asserted in prose. + * + * "never talks to them", NOT "never sees it": never-sees is a data claim the + * docs do not support. The adapters call your LangGraph or AG-UI endpoint, + * never a model API — that is structural and true. + * + * These five marks knowingly repeat MODEL_STRIP in architecture-diagram.ts one + * screen below. Accepted trade. Do not "fix" it there. + */ +export const OFF_AIRPORT_LABEL = + 'OFF AIRPORT — BEHIND YOUR BACKEND. THREADPLANE NEVER TALKS TO THEM.'; +export const PROVIDERS = [ + { src: '/logos/providers/openai.svg', name: 'OpenAI' }, + { src: '/logos/providers/anthropic.svg', name: 'Anthropic' }, + { src: '/logos/providers/google.svg', name: 'Google' }, + { src: '/logos/providers/azure.svg', name: 'Azure OpenAI' }, + { src: '/logos/providers/bedrock.svg', name: 'Amazon Bedrock' }, +] as const; +export const PROVIDER_ROW = { y: 518, size: 20, x0: 30, step: 76, labelY: 492 } as const; +/** The AWS mark again, in the margin. Same 1.67:1 ratio. */ +export const WIDE_RATIO = 1.67; + +/** Chart furniture lives in the margin, never on the field. */ +export const SCALE_BAR = { x0: 742, x1: 842, y: 512 } as const; +export const NORTH = { x: 960, y: 498 } as const; + +/** The Threadplane glyph, identical to the path in ui/PlaneMark.tsx (64x64). */ +export const PLANE_PATH = 'M4 34.5 58 6 40 58l-11.5-16.5L36 22 20 37.5z'; + +export const EYEBROW = 'AIRPORT DIAGRAM'; +export const HEADLINE = 'Every stack has a gate.'; +export const CHART_ID = ['THREADPLANE INTL (TPL)', 'ANGULAR · LANGGRAPH & AG-UI'] as const; +export const DISCLAIMER = + 'Compatibility, not endorsement — no company here is claimed as a customer.'; +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `npx nx test website -- airport-diagram` +Expected: PASS, 8 tests. + +- [ ] **Step 5: Commit** + +```bash +git add apps/website/src/lib/airport-diagram.ts apps/website/src/lib/airport-diagram.spec.ts +git commit -m "feat(website): geometry module for the compatibility airport diagram" +``` + +--- + +### Task 2: The plate + +**Files:** +- Modify: `apps/website/src/components/landing/Compatibility.tsx` (full rewrite) +- Test: `apps/website/src/components/landing/Compatibility.spec.tsx` (full rewrite) + +- [ ] **Step 1: Write the failing test** + +Replace the entire contents of `apps/website/src/components/landing/Compatibility.spec.tsx`: + +```tsx +import { render, screen } from '@testing-library/react'; +import { describe, it, expect } from 'vitest'; +import { Compatibility } from './Compatibility'; +import { GATES_A, GATES_B, PROVIDERS } from '../../lib/airport-diagram'; + +describe('Compatibility', () => { + it('renders the signal surface with the ids the homepage spine depends on', () => { + // e2e/website.spec.ts asserts homepage order by heading id. Renaming + // either of these turns that spec red for a reason nobody will guess. + const { container } = render(); + const section = container.querySelector('[data-ui="section"]'); + expect(section?.getAttribute('data-surface')).toBe('signal'); + expect(section?.getAttribute('id')).toBe('compatibility'); + expect(section?.getAttribute('aria-labelledby')).toBe('compatibility-heading'); + expect(container.querySelector('#compatibility-heading')?.textContent).toBe( + 'Every stack has a gate.', + ); + }); + + it('names every gate and every provider in text, not only as a picture', () => { + // The marks are decorative, so the accessible content is these names. If + // the SVG were the only carrier the section would be empty to a reader. + render(); + for (const g of [...GATES_A, ...GATES_B]) { + expect(screen.getAllByText(g.name).length).toBeGreaterThan(0); + } + for (const p of PROVIDERS) { + expect(screen.getAllByText(p.name).length).toBeGreaterThan(0); + } + }); + + it('shows both adapters as the two concourses', () => { + render(); + expect(screen.getAllByText('@threadplane/langgraph').length).toBeGreaterThan(0); + expect(screen.getAllByText('@threadplane/ag-ui').length).toBeGreaterThan(0); + }); + + it('marks every logo decorative, since the visible name carries the meaning', () => { + const { container } = render(); + const marks = container.querySelectorAll('image, img.airport-mark'); + expect(marks.length).toBeGreaterThan(0); + for (const m of Array.from(marks)) { + expect(m.getAttribute('aria-hidden')).toBe('true'); + } + }); + + it('states compatibility in words and never implies a customer', () => { + const { container } = render(); + expect(screen.getByText(/Compatibility, not endorsement/)).toBeTruthy(); + expect(container.textContent).not.toMatch(/trusted by|customers|our clients|powered by/i); + }); + + it('says Threadplane never talks to model providers, not that it never sees them', () => { + // never-SEES is a data claim the docs do not support; never-TALKS-TO is + // structural. This is the same failure mode #1067 had to correct. + const { container } = render(); + expect(container.textContent).toMatch(/never talks to them/i); + expect(container.textContent).not.toMatch(/never sees/i); + }); + + it('links to the adapter guide', () => { + render(); + expect( + screen.getByRole('link', { name: 'Choose an adapter →' }).getAttribute('href'), + ).toBe('/docs/choosing-an-adapter'); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npx nx test website -- Compatibility` +Expected: FAIL — `data-surface` is `"tinted"`, and the heading is `"Your backend, your models, your runtime."`. + +- [ ] **Step 3: Write the component** + +Replace the entire contents of `apps/website/src/components/landing/Compatibility.tsx`: + +```tsx +import { Container } from '../ui/Container'; +import { Section } from '../ui/Section'; +import { AdapterGuideLink } from './AdapterGuideLink'; +import { + APRON_A, + APRON_B, + CHART_ID, + CONCOURSES, + DISCLAIMER, + EYEBROW, + FIELD, + GATES_A, + GATES_B, + HEADLINE, + LINK_A_Y, + LINK_B_Y, + MAIN, + NEAT, + NORTH, + OFF_AIRPORT_LABEL, + PIVOT, + PLANE_PATH, + PROVIDERS, + PROVIDER_ROW, + ROT, + ROW1, + ROW2, + RWY_N, + RWY_S, + SCALE_BAR, + STAND, + TICK_X, + TICK_Y, + TWY_E, + TWY_N, + TWY_S, + VIEW, + WIDE_RATIO, + type Gate, +} from '../../lib/airport-diagram'; + +const R = STAND / 2; + +function Runway({ y, h, left, right }: typeof RWY_N | typeof RWY_S) { + return ( + + + {left} + {right} + + ); +} + +function TaxiwayLetter({ x, y, ch }: { x: number; y: number; ch: string }) { + return ( + + + {ch} + + ); +} + +/** A stand: the stub off the concourse, the white box, the mark, the callsign. */ +function Stand({ gate, up }: { gate: Gate; up: boolean }) { + const row = up ? ROW1 : ROW2; + const cy = row.box; + const tick = up ? row.stubTop : row.stubBot; + const iw = gate.w ?? gate.s; + return ( + + + + {/* Counter-rotated so the mark and its callsign stay upright while the + airfield sits at its heading. */} + + + + + + {gate.gate} + + + {gate.name} + + + + ); +} + +function Plate() { + const ticks: string[] = []; + for (let x = TICK_X; x < VIEW.width; x += TICK_X) { + ticks.push(`M${x} ${NEAT.y} V${NEAT.y + 7}`, `M${x} ${NEAT.y + NEAT.height} V${NEAT.y + NEAT.height - 7}`); + } + for (let y = TICK_Y; y < NEAT.y + NEAT.height; y += TICK_Y) { + ticks.push(`M${NEAT.x} ${y} H${NEAT.x + 7}`, `M${NEAT.x + NEAT.width} ${y} H${NEAT.x + NEAT.width - 7}`); + } + const mx = (MAIN.x0 + MAIN.x1) / 2; + const my = (MAIN.y0 + MAIN.y1) / 2; + const glyph = 27; + + return ( + + + + + + + + + + + + {[APRON_A, APRON_B].map((a) => ( + + ))} + + + + + + + + + + + + {/* The one structure that IS Threadplane: solid ink. Partner stands are + white, so the two values carry the meaning with no legend. */} + + + + + + <chat> + MAIN TERMINAL + + + + + + {CONCOURSES.map((c) => ( + + + + {c.label} + {c.pkg} + + ))} + + {GATES_A.map((g) => )} + {GATES_B.map((g) => )} + + + {/* Below the neat line is outside the airport. */} + {OFF_AIRPORT_LABEL} + {PROVIDERS.map((p, i) => { + const wide = p.src.endsWith('bedrock.svg'); + const w = wide ? PROVIDER_ROW.size * WIDE_RATIO : PROVIDER_ROW.size; + const x = PROVIDER_ROW.x0 + i * PROVIDER_ROW.step; + return ( + + ); + })} + + + + + 0 + 2000 FT + + N + + + ); +} + +/** + * The band is a chart, so the accessible content is a plain list beside it — + * the same data, never a second source of truth. + */ +export function Compatibility() { + return ( +
+ +
+
+

{EYEBROW}

+

{HEADLINE}

+
+

+ {CHART_ID[0]} +
+ {CHART_ID[1]} +

+
+ +
+ +
+ +
+ {CONCOURSES.map((c) => ( +
+

+ {c.label} — {c.pkg} +

+
    + {c.gates.map((g) => ( +
  • + {g.gate} + + {g.name} +
  • + ))} +
+
+ ))} +

{OFF_AIRPORT_LABEL}

+
    + {PROVIDERS.map((p) => ( +
  • + + {p.name} +
  • + ))} +
+
+ +
+ +

{DISCLAIMER}

+
+
+
+ ); +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `npx nx test website -- Compatibility` +Expected: PASS, 7 tests. + +- [ ] **Step 5: Commit** + +```bash +git add apps/website/src/components/landing/Compatibility.tsx apps/website/src/components/landing/Compatibility.spec.tsx +git commit -m "feat(website): draw the compatibility band as an airport diagram" +``` + +--- + +### Task 3: Styles + +**Files:** +- Modify: `apps/website/src/styles/landing.css` (replace the `.compatibility-*` block, currently ~2007–2075) + +- [ ] **Step 1: Delete the old block** + +Delete every rule from `.compatibility-groups` through `.compatibility-disclaimer` inclusive, along with the `/* Compatibility — light ground on purpose... */` comment above it. That comment is now wrong: the ground is deliberately aviation yellow, which is what makes the marks legible bare. + +Keep `.compatibility-link` and `.compatibility-disclaimer` as class *names* — `AdapterGuideLink` replaces its `className` rather than appending, so without a rule the anchor renders as plain body text. + +- [ ] **Step 2: Add the new block** + +Insert in the same position: + +```css +/* Compatibility — an FAA-style airport diagram on the signal surface + * (spec: docs/superpowers/specs/2026-09-08-compatibility-airport-diagram-design.md). + * + * The yellow ground is load-bearing, not decoration: every mark here is a dark + * logo drawn for a light ground (Anthropic #181818, CrewAI and Pydantic + * #111827, LangGraph #1C3C3C), so they render bare with no chip. That is why + * these marks were invisible on the dark band before #1067 split them out. + * + * Two values carry the meaning with no legend: partner stands are white, + * the main terminal — Threadplane — is ink. */ +.airport-head { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 24px; + padding-bottom: 18px; + margin-bottom: 40px; + border-bottom: 1.5px solid var(--color-border-strong); +} +.airport-eyebrow { + font-family: var(--font-mono); + font-size: 10px; + font-weight: 700; + letter-spacing: 0.17em; + color: var(--color-text-muted); + margin: 0 0 13px; +} +.airport-heading { + font-family: var(--font-display); + font-size: clamp(30px, 5vw, 46px); + line-height: 1.02; + letter-spacing: -0.018em; + margin: 0; + max-width: 12ch; +} +.airport-chart-id { + text-align: right; + font-family: var(--font-mono); + font-size: 9.5px; + font-weight: 700; + letter-spacing: 0.13em; + line-height: 2; + white-space: nowrap; + color: var(--color-text-muted); + margin: 0; +} +.airport-figure { + margin: 0; +} +.ap-svg { + display: block; + width: 100%; + height: auto; +} + +/* Airfield */ +.ap-neat { + fill: none; + stroke: var(--color-ink); + stroke-width: 1.4; +} +.ap-tick { + fill: none; + stroke: var(--color-ink); + stroke-width: 1; + opacity: 0.6; +} +.ap-pavement { + fill: var(--color-ink); +} +.ap-rwy-id { + font-family: var(--font-mono); + font-size: 8.5px; + font-weight: 700; + letter-spacing: 0.08em; + fill: var(--color-signal); +} +.ap-taxiway, +.ap-stub, +.ap-link { + fill: none; + stroke: var(--color-ink); +} +.ap-taxiway { + stroke-width: 1.6; +} +.ap-stub { + stroke-width: 1.4; +} +.ap-link { + stroke-width: 3.5; +} +.ap-twy-disc { + fill: var(--color-signal); + stroke: var(--color-ink); + stroke-width: 1.2; +} +.ap-twy-letter { + font-family: var(--font-mono); + font-size: 8.5px; + font-weight: 700; + fill: var(--color-ink); +} +.ap-apron { + fill: none; + stroke: rgba(10, 10, 10, 0.3); + stroke-width: 1; + stroke-dasharray: 3 4; +} +.ap-hatch-line { + stroke: var(--color-ink); + stroke-width: 0.9; + opacity: 0.7; +} + +/* Buildings */ +.ap-main { + fill: var(--color-ink); +} +.ap-plane { + fill: var(--color-signal); +} +.ap-main-title { + font-family: var(--font-display); + font-size: 20px; + fill: var(--color-signal); +} +.ap-main-sub { + font-family: var(--font-mono); + font-size: 8px; + font-weight: 700; + letter-spacing: 0.14em; + fill: rgba(255, 175, 0, 0.66); +} +.ap-conc { + fill: url(#ap-hatch); + stroke: var(--color-ink); + stroke-width: 1.4; +} +.ap-conc-plate { + fill: var(--color-signal); +} +.ap-conc-label { + font-family: var(--font-mono); + font-size: 9px; + font-weight: 700; + letter-spacing: 0.1em; + fill: var(--color-ink); +} +.ap-conc-pkg { + font-family: var(--font-mono); + font-size: 8px; + font-weight: 700; + letter-spacing: 0.06em; + fill: rgba(10, 10, 10, 0.78); +} + +/* Stands */ +.ap-stand-box { + fill: #ffffff; + stroke: var(--color-ink); + stroke-width: 1.5; +} +.ap-gate-tab { + fill: var(--color-ink); +} +.ap-gate-id { + font-family: var(--font-mono); + font-size: 7.5px; + font-weight: 700; + fill: var(--color-signal); +} +.ap-callsign { + font-family: var(--font-mono); + font-size: 8.5px; + font-weight: 700; + letter-spacing: 1px; + fill: rgba(10, 10, 10, 0.74); +} + +/* Margin */ +.ap-off { + font-family: var(--font-mono); + font-size: 9px; + font-weight: 700; + letter-spacing: 0.11em; + fill: var(--color-text-muted); +} +.ap-furniture { + opacity: 0.7; +} +.ap-furniture path { + fill: none; + stroke: var(--color-ink); + stroke-width: 1.2; +} +.ap-furniture .ap-north { + fill: var(--color-ink); + stroke: none; +} +.ap-furniture text { + font-family: var(--font-mono); + font-size: 7.5px; + font-weight: 700; + fill: var(--color-ink); +} + +/* Footer */ +.airport-footer { + display: flex; + align-items: center; + gap: 18px; + flex-wrap: wrap; + border-top: 1.5px solid var(--color-border-strong); + margin-top: 26px; + padding-top: 18px; +} +/* AdapterGuideLink REPLACES its className rather than appending, so without a + * rule here the anchor renders as plain body text. On the signal surface + * --color-accent resolves to ink, which is 10.73:1 on the yellow ground. */ +.compatibility-link { + font-family: var(--font-mono); + font-size: 11px; + font-weight: 600; + letter-spacing: 0.05em; + text-transform: uppercase; + color: var(--color-accent); +} +.compatibility-link:hover, +.compatibility-link:focus-visible { + color: var(--color-accent-hover); +} +.compatibility-disclaimer { + font-size: 12.5px; + color: var(--color-text-muted); + margin: 0; +} +``` + +- [ ] **Step 3: Verify the styles compile and the suite still passes** + +Run: `npx nx test website -- Compatibility` +Expected: PASS, 7 tests. (CSS is not under test here; this confirms nothing regressed.) + +- [ ] **Step 4: Commit** + +```bash +git add apps/website/src/styles/landing.css +git commit -m "style(website): airport-diagram styles for the compatibility band" +``` + +--- + +### Task 4: Phone form + +**Files:** +- Modify: `apps/website/src/styles/landing.css` (append to the block from Task 3) +- Test: `apps/website/src/components/landing/Compatibility.spec.tsx` (add one test) + +The markup already exists from Task 2 (`.airport-stack`). This task makes the swap real and guards it. + +- [ ] **Step 1: Write the failing test** + +Append inside the existing `describe('Compatibility', ...)` in `apps/website/src/components/landing/Compatibility.spec.tsx`: + +```tsx + it('ships a phone form driven by the same gate table as the plate', () => { + // A seven-stand rotated airfield has no 390px form. The precedent is + // .arch-stack: hide the figure under 768px and show an HTML list built + // from the same data, never a sideways scroll. + const { container } = render(); + expect(container.querySelector('.airport-figure')).toBeTruthy(); + const stack = container.querySelector('.airport-stack'); + expect(stack).toBeTruthy(); + const items = stack!.querySelectorAll('.airport-stack-gates li'); + expect(items).toHaveLength(GATES_A.length + GATES_B.length); + expect(screen.getByRole('list', { name: /CONCOURSE A/ })).toBeTruthy(); + expect(screen.getByRole('list', { name: /CONCOURSE B/ })).toBeTruthy(); + }); +``` + +- [ ] **Step 2: Run test to verify it passes or fails** + +Run: `npx nx test website -- Compatibility` +Expected: PASS — the markup landed in Task 2. If it FAILS, the Task 2 markup was altered; restore `.airport-stack` before continuing. + +- [ ] **Step 3: Add the breakpoint** + +Append to the block added in Task 3 in `apps/website/src/styles/landing.css`: + +```css +/* Phone form: the same gates as an HTML list, driven by the same data. + * The plate is hidden here instead of scrolled sideways — the .arch-stack + * precedent from the architecture diagram. */ +.airport-stack { + display: none; +} +@media (max-width: 767px) { + .airport-figure { + display: none; + } + .airport-stack { + display: block; + margin-top: 8px; + } + .airport-head { + display: block; + } + .airport-chart-id { + text-align: left; + margin-top: 16px; + } +} +.airport-stack-label { + font-family: var(--font-mono); + font-size: 10px; + font-weight: 700; + letter-spacing: 0.12em; + color: var(--color-text-secondary); + margin: 22px 0 10px; +} +.airport-stack-gates, +.airport-stack-providers { + list-style: none; + margin: 0; + padding: 0; + display: grid; + gap: 10px; +} +.airport-stack-providers { + grid-template-columns: repeat(2, 1fr); +} +.airport-stack-gates li, +.airport-stack-providers li { + display: flex; + align-items: center; + gap: 10px; + font-size: 14px; + font-weight: 500; + color: var(--color-text-primary); +} +.airport-stack-gate { + font-family: var(--font-mono); + font-size: 9px; + font-weight: 700; + letter-spacing: 0.08em; + background: var(--color-ink); + color: var(--color-signal); + border-radius: 2px; + padding: 3px 5px; +} +.airport-mark { + width: 22px; + height: 22px; + object-fit: contain; +} +``` + +- [ ] **Step 4: Run the whole website unit suite** + +Run: `npx nx test website` +Expected: PASS. No other spec references `COMPATIBILITY_GROUPS` — if one does, it is a stale import and should be removed, not re-exported. + +- [ ] **Step 5: Commit** + +```bash +git add apps/website/src/styles/landing.css apps/website/src/components/landing/Compatibility.spec.tsx +git commit -m "feat(website): phone form for the compatibility airport diagram" +``` + +--- + +### Task 5: Overflow e2e + +**Files:** +- Create: `apps/website/e2e/home-airport.spec.ts` + +This session found four separate collisions by eye — gate numbers over package labels, a taxiway drawn through the main terminal, sub-labels rendering outside a 26px concourse, the scale bar on top of runway 09R. Eyes do not scale. + +- [ ] **Step 1: Write the failing test** + +Create `apps/website/e2e/home-airport.spec.ts`: + +```ts +import { test, expect, type Page } from '@playwright/test'; +import { CONC_A, CONC_B } from '../src/lib/airport-diagram'; + +const PLATE = '[data-diagram="airport"]'; +const concAJson = { x0: CONC_A.x0, x1: CONC_A.x1, y0: CONC_A.y0, y1: CONC_A.y1 }; +const concBJson = { x0: CONC_B.x0, x1: CONC_B.x1, y0: CONC_B.y0, y1: CONC_B.y1 }; + +/** + * getBBox reports SVG user space, so these numbers compare directly with + * lib/airport-diagram.ts. The unit spec proves the boxes close; this proves + * the type set inside and beside them does not collide or escape the frame. + */ +async function report(page: Page) { + return page.evaluate( + ({ sel, concA, concB }) => { + const issues: string[] = []; + const box = (el: SVGGraphicsElement) => el.getBBox(); + + // Concourse labels must stay inside the building they label. The first + // draft put an 8.5px sub-label 33px down a 26px-tall concourse, so it + // rendered below the building entirely. + for (const [id, b] of [ + ['A', concA], + ['B', concB], + ] as const) { + const g = document.querySelector(`${sel} [data-concourse="${id}"]`); + if (!g) { + issues.push(`concourse ${id} missing`); + continue; + } + for (const t of g.querySelectorAll('text')) { + const r = box(t); + if (r.y < b.y0 || r.y + r.height > b.y1 || r.x < b.x0 || r.x + r.width > b.x1) { + issues.push(`concourse ${id} label "${t.textContent}" escapes ${b.x0},${b.y0}-${b.x1},${b.y1}`); + } + } + } + + // Every mark must sit inside its own stand box. + for (const s of document.querySelectorAll(`${sel} [data-stand]`)) { + const rect = s.querySelector('[data-stand-box]'); + const img = s.querySelector('image'); + if (!rect || !img) { + issues.push(`${s.dataset['stand']}: missing box or mark`); + continue; + } + const b = box(rect); + const m = box(img); + if (m.x < b.x || m.y < b.y || m.x + m.width > b.x + b.width || m.y + m.height > b.y + b.height) { + issues.push(`${s.dataset['stand']}: mark escapes its stand`); + } + } + + // No two callsigns may overlap horizontally. + const calls = Array.from(document.querySelectorAll(`${sel} .ap-callsign`)) + .map((t) => ({ t: t.textContent ?? '', r: box(t) })) + .sort((a, b2) => a.r.x - b2.r.x); + for (let i = 1; i < calls.length; i += 1) { + const prev = calls[i - 1]; + const cur = calls[i]; + if (prev.r.x + prev.r.width > cur.r.x) { + issues.push(`callsigns overlap: "${prev.t}" / "${cur.t}"`); + } + } + return issues; + }, + { sel: PLATE, concA: concAJson, concB: concBJson }, + ); +} + +test('airport plate renders every label inside the structure that owns it', async ({ page }) => { + await page.goto('/'); + await page.locator(PLATE).scrollIntoViewIfNeeded(); + await expect(page.locator(PLATE)).toBeVisible(); + await page.evaluate(() => document.fonts.ready); + expect(await report(page)).toEqual([]); +}); + +test('airport plate keeps the whole drawing inside its viewBox', async ({ page }) => { + await page.goto('/'); + await page.locator(PLATE).scrollIntoViewIfNeeded(); + await page.evaluate(() => document.fonts.ready); + const b = await page.evaluate((sel) => { + const r = document.querySelector(sel)!.getBBox(); + return { left: r.x, top: r.y, right: r.x + r.width, bottom: r.y + r.height }; + }, PLATE); + expect(b.left).toBeGreaterThanOrEqual(0); + expect(b.top).toBeGreaterThanOrEqual(0); + expect(b.right).toBeLessThanOrEqual(1000); + expect(b.bottom).toBeLessThanOrEqual(536); +}); + +test('the compatibility band does not scroll sideways on a phone', async ({ page }) => { + await page.setViewportSize({ width: 390, height: 844 }); + await page.goto('/'); + const section = page.locator('#compatibility'); + await section.scrollIntoViewIfNeeded(); + await expect(page.locator('.airport-stack')).toBeVisible(); + await expect(page.locator('.airport-figure')).toBeHidden(); + const overflows = await page.evaluate( + () => document.documentElement.scrollWidth > document.documentElement.clientWidth, + ); + expect(overflows).toBe(false); +}); +``` + +- [ ] **Step 2: Run the e2e to verify it passes** + +Run: `npx nx e2e website -- home-airport.spec.ts` +Expected: PASS, 3 tests. + +If the first test reports issues, **fix the geometry in `airport-diagram.ts`, not the assertion.** The whole point of this spec is that it is the arbiter. + +- [ ] **Step 3: Run the homepage spine e2e, which must be untouched** + +Run: `npx nx e2e website -- website.spec.ts` +Expected: PASS. `compatibility-heading` must still appear between `proof-heading` and `architecture-heading`. + +- [ ] **Step 4: Commit** + +```bash +git add apps/website/e2e/home-airport.spec.ts +git commit -m "test(website): measure the airport plate's geometry in the browser" +``` + +--- + +### Task 6: Verify in the browser + +**Files:** none — this is a verification gate. + +- [ ] **Step 1: Start the dev server and open the band** + +Use the Browser pane's `preview_start` with the website's `.claude/launch.json` entry. Never run the dev server through Bash. + +- [ ] **Step 2: Check the console and network are clean** + +Read console messages and network requests. Every mark in the gate table and the provider row must return 200 — a 404 renders as nothing at all inside an otherwise correct-looking stand, which is invisible in a screenshot. + +- [ ] **Step 3: Check both forms** + +Screenshot at desktop width, then `resize_window` to the mobile preset and reload. The plate must be hidden and the gate list visible, with no horizontal scroll. + +- [ ] **Step 4: Run lint and the full website suite** + +```bash +npx nx lint website +npx nx test website +``` + +Expected: both PASS. Strip ANSI before grepping lint output, and note that lint **warnings** are not failures — only errors are. + +- [ ] **Step 5: Commit any fixes and open the PR** + +```bash +git add -A +git commit -m "fix(website): airport diagram review fixes" +``` + +Open the PR against `main`. Auto-merge should only be armed after the branch is rebased on current `origin/main` — an all-green PR that is BEHIND sits open indefinitely. + +--- + +## Notes for the implementer + +- **Do not reintroduce `COMPATIBILITY_GROUPS`.** The old export is deleted; the gate table replaces it. +- **Do not edit `src/lib/architecture-diagram.ts`.** Its `MODEL_STRIP` shows the same five provider marks one screen below, and that duplication is a recorded decision, not a bug. Its geometry is measured by `e2e/home-architecture.spec.ts`. +- **Per-mark sizes are data, not styling.** They live in `airport-diagram.ts` and are guarded. Do not collapse them into one CSS `height`. +- **`--font-display` is Archivo Black: single weight, no italic.** Never emit `font-weight` or `font-style: italic` on it. +- **`font-vars.spec.ts` fails if a stylesheet references a `--font-*` var `layout.tsx` does not supply.** The new CSS uses only `--font-mono`, `--font-display` and `--font-sans`, all of which are supplied. From 28439c9bd2334a2a43ee14d12ec7c6be67af3071 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Tue, 8 Sep 2026 15:31:02 -0700 Subject: [PATCH 03/16] feat(website): geometry module for the compatibility airport diagram Co-Authored-By: Claude Opus 5 --- apps/website/src/lib/airport-diagram.spec.ts | 101 ++++++++++++++ apps/website/src/lib/airport-diagram.ts | 135 +++++++++++++++++++ 2 files changed, 236 insertions(+) create mode 100644 apps/website/src/lib/airport-diagram.spec.ts create mode 100644 apps/website/src/lib/airport-diagram.ts diff --git a/apps/website/src/lib/airport-diagram.spec.ts b/apps/website/src/lib/airport-diagram.spec.ts new file mode 100644 index 000000000..d3a6c19ea --- /dev/null +++ b/apps/website/src/lib/airport-diagram.spec.ts @@ -0,0 +1,101 @@ +import { existsSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { describe, expect, it } from 'vitest'; +import { + APRON_A, + APRON_B, + CONC_A, + CONC_B, + FIELD, + GATES_A, + GATES_B, + MAIN, + NEAT, + PROVIDERS, + ROW1, + ROW2, + STAND, + rotate, +} from './airport-diagram'; + +const WEBSITE = resolve(__dirname, '../..'); + +describe('airport diagram geometry', () => { + it('keeps all four rotated field corners inside the neat line', () => { + const corners = [ + [FIELD.x0, FIELD.y0], + [FIELD.x1, FIELD.y0], + [FIELD.x0, FIELD.y1], + [FIELD.x1, FIELD.y1], + ] as const; + for (const [x, y] of corners) { + const p = rotate(x, y); + expect(p.x, `corner ${x},${y} left`).toBeGreaterThan(NEAT.x); + expect(p.x, `corner ${x},${y} right`).toBeLessThan(NEAT.x + NEAT.width); + expect(p.y, `corner ${x},${y} top`).toBeGreaterThan(NEAT.y); + expect(p.y, `corner ${x},${y} bottom`).toBeLessThan(NEAT.y + NEAT.height); + } + }); + + it('parks every stand inside its apron', () => { + const r = STAND / 2; + const rows = [ + { xs: GATES_A.map((g) => g.x), cy: ROW1.box, apron: APRON_A }, + { xs: GATES_B.map((g) => g.x), cy: ROW2.box, apron: APRON_B }, + ]; + for (const { xs, cy, apron } of rows) { + for (const x of xs) { + expect(x - r, `stand at ${x} left`).toBeGreaterThanOrEqual(apron.x0); + expect(x + r, `stand at ${x} right`).toBeLessThanOrEqual(apron.x1); + expect(cy - r, `stand at ${x} top`).toBeGreaterThanOrEqual(apron.y0); + expect(cy + r, `stand at ${x} bottom`).toBeLessThanOrEqual(apron.y1); + } + } + }); + + it('lands every gate stub on the concourse it belongs to', () => { + // Row 1 hangs above concourse A, row 2 below concourse B. If either stub + // stops short the gates float, which reads as a drawing error. + expect(ROW1.stubBot).toBe(CONC_A.y0); + expect(ROW2.stubTop).toBe(CONC_B.y1); + expect(ROW1.stubTop).toBeLessThan(ROW1.stubBot); + expect(ROW2.stubTop).toBeLessThan(ROW2.stubBot); + }); + + it('keeps every gate within the span of its concourse', () => { + for (const g of GATES_A) { + expect(g.x, `${g.gate} x`).toBeGreaterThan(CONC_A.x0); + expect(g.x, `${g.gate} x`).toBeLessThan(CONC_A.x1); + } + for (const g of GATES_B) { + expect(g.x, `${g.gate} x`).toBeGreaterThan(CONC_B.x0); + expect(g.x, `${g.gate} x`).toBeLessThan(CONC_B.x1); + } + }); + + it('gives both concourses gates, so neither adapter can silently empty out', () => { + // The two-adapter story IS the diagram. A concourse with no gates would + // still render as a building and the section would quietly stop arguing. + expect(GATES_A.length).toBeGreaterThan(0); + expect(GATES_B.length).toBeGreaterThan(0); + }); + + it('sizes each mark individually, and only the AWS wordmark by width', () => { + // One shared height reads wrong: Mastra is wide and heavy, Anthropic is a + // narrow wedge. `w` is the escape hatch for the one 1.67:1 wordmark. + const all = [...GATES_A, ...GATES_B]; + for (const g of all) expect(g.s, `${g.gate} size`).toBeGreaterThan(0); + expect(all.filter((g) => g.w !== undefined).map((g) => g.gate)).toEqual(['B6']); + }); + + it('points every mark at a file that exists', () => { + for (const src of [...GATES_A, ...GATES_B].map((g) => g.src).concat(PROVIDERS.map((p) => p.src))) { + expect(existsSync(resolve(WEBSITE, 'public', src.slice(1))), src).toBe(true); + } + }); + + it('places the main terminal west of both concourses', () => { + expect(MAIN.x1).toBeLessThanOrEqual(CONC_A.x0); + expect(MAIN.x1).toBeLessThanOrEqual(CONC_B.x0); + }); +}); diff --git a/apps/website/src/lib/airport-diagram.ts b/apps/website/src/lib/airport-diagram.ts new file mode 100644 index 000000000..f7ecef462 --- /dev/null +++ b/apps/website/src/lib/airport-diagram.ts @@ -0,0 +1,135 @@ +/** + * Geometry and content for the homepage compatibility band, drawn as an + * FAA-style airport diagram + * (spec: docs/superpowers/specs/2026-09-08-compatibility-airport-diagram-design.md). + * + * One table, three readers: Compatibility.tsx draws it, airport-diagram.spec.ts + * checks the geometry closes, and e2e/home-airport.spec.ts measures the + * rendered type against these same boxes. Coordinates are viewBox units. + * + * The chart vocabulary carries the argument, which is why the section has no + * group labels and no explanatory paragraph: the main terminal is , + * concourse A is @threadplane/langgraph, concourse B is @threadplane/ag-ui, + * and each supported runtime is a mark parked at a numbered gate. + * + * Two values carry the meaning and nothing labels it: partner stands are + * WHITE, the one structure that is Threadplane is solid INK. + */ + +export const VIEW = { width: 1000, height: 536 } as const; + +/** The chart frame. It does NOT rotate — only the airfield inside it does. */ +export const NEAT = { x: 8, y: 8, width: 984, height: 444 } as const; +export const TICK_X = 88; +export const TICK_Y = 84; + +/** + * Airfield heading. Nothing on a real plate is axis-aligned, and this is the + * cheapest single signal that this is a chart and not a flowchart. Marks and + * their labels counter-rotate by -ROT so they stay upright. + */ +export const ROT = -3.5; +export const PIVOT = { x: 500, y: 230 } as const; + +export const rotate = (x: number, y: number): { x: number; y: number } => { + const a = (ROT * Math.PI) / 180; + const dx = x - PIVOT.x; + const dy = y - PIVOT.y; + return { + x: PIVOT.x + dx * Math.cos(a) - dy * Math.sin(a), + y: PIVOT.y + dx * Math.sin(a) + dy * Math.cos(a), + }; +}; + +/** Outer extent of everything that rotates. Held so no corner leaves NEAT. */ +export const FIELD = { x0: 56, x1: 944, y0: 58, y1: 419 } as const; + +export const RWY_N = { y: 58, h: 11, left: '09L', right: '27R' } as const; +export const RWY_S = { y: 408, h: 11, left: '09R', right: '27L' } as const; +export const TWY_N = 100; +export const TWY_S = 386; +export const TWY_E = 930; + +export const MAIN = { x0: 56, x1: 188, y0: 190, y1: 288 } as const; +export const CONC_A = { x0: 204, x1: 432, y0: 190, y1: 224 } as const; +export const CONC_B = { x0: 204, x1: 900, y0: 254, y1: 288 } as const; +export const LINK_A_Y = 206; +export const LINK_B_Y = 270; + +export const APRON_A = { x0: 232, x1: 440, y0: 112, y1: 186 } as const; +export const APRON_B = { x0: 232, x1: 910, y0: 292, y1: 372 } as const; + +/** Stand box side, and the two gate rows that hang off the concourses. */ +export const STAND = 38; +export const ROW1 = { box: 140, name: 172, stubTop: 178, stubBot: 190 } as const; +export const ROW2 = { box: 332, name: 364, stubTop: 288, stubBot: 306 } as const; + +export interface Gate { + readonly gate: string; + readonly src: string; + readonly name: string; + /** Optical height. Deliberately per-mark; see the spec test. */ + readonly s: number; + /** Optical width, for wordmarks that are not square. B6 only. */ + readonly w?: number; + readonly x: number; +} + +export const GATES_A: readonly Gate[] = [ + { gate: 'A1', src: '/logos/langgraph.svg', name: 'LANGGRAPH', s: 21, x: 318 }, +]; + +export const GATES_B: readonly Gate[] = [ + { gate: 'B1', src: '/logos/ag-ui.svg', name: 'AG-UI', s: 19, x: 268 }, + { gate: 'B2', src: '/logos/runtimes/crewai.svg', name: 'CREWAI', s: 22, x: 380 }, + { gate: 'B3', src: '/logos/runtimes/mastra.svg', name: 'MASTRA', s: 16, x: 492 }, + { gate: 'B4', src: '/logos/runtimes/pydantic.svg', name: 'PYDANTIC AI', s: 21, x: 604 }, + { gate: 'B5', src: '/logos/runtimes/microsoft.svg', name: 'MS AGENT FWK', s: 19, x: 716 }, + // The AWS wordmark is 1.67:1, so it is the one mark sized by width. Using it + // for Strands is honest — Strands is an AWS project. The rejected + // alternative was the word "AWS" in Archivo Black, which out-weighed every + // real logo on the plate. + { gate: 'B6', src: '/logos/providers/bedrock.svg', name: 'AWS STRANDS', s: 12, w: 30, x: 828 }, +]; + +export const CONCOURSES = [ + { id: 'A', label: 'CONCOURSE A', pkg: '@threadplane/langgraph', box: CONC_A, gates: GATES_A }, + { id: 'B', label: 'CONCOURSE B', pkg: '@threadplane/ag-ui', box: CONC_B, gates: GATES_B }, +] as const; + +/** + * Outside the neat line is outside the airport. The claim is rendered as + * geometry rather than asserted in prose. + * + * "never talks to them", NOT "never sees it": never-sees is a data claim the + * docs do not support. The adapters call your LangGraph or AG-UI endpoint, + * never a model API — that is structural and true. + * + * These five marks knowingly repeat MODEL_STRIP in architecture-diagram.ts one + * screen below. Accepted trade. Do not "fix" it there. + */ +export const OFF_AIRPORT_LABEL = + 'OFF AIRPORT — BEHIND YOUR BACKEND. THREADPLANE NEVER TALKS TO THEM.'; +export const PROVIDERS = [ + { src: '/logos/providers/openai.svg', name: 'OpenAI' }, + { src: '/logos/providers/anthropic.svg', name: 'Anthropic' }, + { src: '/logos/providers/google.svg', name: 'Google' }, + { src: '/logos/providers/azure.svg', name: 'Azure OpenAI' }, + { src: '/logos/providers/bedrock.svg', name: 'Amazon Bedrock' }, +] as const; +export const PROVIDER_ROW = { y: 518, size: 20, x0: 30, step: 76, labelY: 492 } as const; +/** The AWS mark again, in the margin. Same 1.67:1 ratio. */ +export const WIDE_RATIO = 1.67; + +/** Chart furniture lives in the margin, never on the field. */ +export const SCALE_BAR = { x0: 742, x1: 842, y: 512 } as const; +export const NORTH = { x: 960, y: 498 } as const; + +/** The Threadplane glyph, identical to the path in ui/PlaneMark.tsx (64x64). */ +export const PLANE_PATH = 'M4 34.5 58 6 40 58l-11.5-16.5L36 22 20 37.5z'; + +export const EYEBROW = 'AIRPORT DIAGRAM'; +export const HEADLINE = 'Every stack has a gate.'; +export const CHART_ID = ['THREADPLANE INTL (TPL)', 'ANGULAR · LANGGRAPH & AG-UI'] as const; +export const DISCLAIMER = + 'Compatibility, not endorsement — no company here is claimed as a customer.'; From ef9fa9e25ff99d42511af2e622f9d42f59e2fa2c Mon Sep 17 00:00:00 2001 From: Brian Love Date: Tue, 8 Sep 2026 15:50:50 -0700 Subject: [PATCH 04/16] refactor(website): make the airport diagram's geometry module say what it means MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review follow-ups on the geometry module, all naming/derivation/guards — no coordinate moves except B6's width. FIELD now actually bounds the drawing. It was decoupled: widening APRON_B past the viewBox left all 8 tests green, because nothing compared a structure to FIELD at all. A new test contains MAIN, both concourses, both aprons, both runways, all three taxiways and every stand's rotated extent. ROW1/ROW2 are renamed to what they hold: `box` was the stand's centre y (`standCy`), `name` the callsign's baseline (`labelY`). A new per-row ordering assertion catches a box slid down onto its own label — a visibly broken plate that the old suite passed. CONCOURSES carries the row, the apron and `gatesAbove`, so "A is the up row" is stated once instead of re-encoded in the spec's local table, in the ROW1/ ROW2 names and again in the component to come. The stub, apron, gate-span and gate-count tests now loop over it. B6's width becomes `round(s * WIDE_RATIO)` = 20, not the hand-typed 30 that was 2.5:1 under a comment stating 1.67. The sizing test now asserts the property (every gate has s > 0; any `w` is > s and on the ratio) instead of listing B6 by name, so a second wordmark can join without a red suite. Also: the apron test uses the stand's true rotated half-extent, since each stand counter-rotates about its own centre; the margin band (provider row, scale bar, north arrow) is guarded against the neat line and the view the way architecture-diagram.spec.ts guards its model strip; PLANE_PATH's duplication of ui/PlaneMark.tsx is enforced by reading that file rather than asserted in a comment; `MAIN.x1 < CONC.x0` is strict, since equality means zero-length connectors; and LINK_A_Y/LINK_B_Y must fall inside both the terminal's and their concourse's y span. Co-Authored-By: Claude Opus 5 --- apps/website/src/lib/airport-diagram.spec.ts | 230 ++++++++++++++++--- apps/website/src/lib/airport-diagram.ts | 71 +++++- 2 files changed, 255 insertions(+), 46 deletions(-) diff --git a/apps/website/src/lib/airport-diagram.spec.ts b/apps/website/src/lib/airport-diagram.spec.ts index d3a6c19ea..742db3e6a 100644 --- a/apps/website/src/lib/airport-diagram.spec.ts +++ b/apps/website/src/lib/airport-diagram.spec.ts @@ -1,4 +1,4 @@ -import { existsSync } from 'node:fs'; +import { existsSync, readFileSync } from 'node:fs'; import { resolve } from 'node:path'; import { describe, expect, it } from 'vitest'; import { @@ -6,20 +6,44 @@ import { APRON_B, CONC_A, CONC_B, + CONCOURSES, FIELD, GATES_A, GATES_B, + LINK_A_Y, + LINK_B_Y, MAIN, NEAT, + NORTH, + PLANE_PATH, PROVIDERS, - ROW1, - ROW2, + PROVIDER_ROW, + ROT, + RWY_N, + RWY_S, + SCALE_BAR, STAND, + TWY_E, + TWY_N, + TWY_S, + VIEW, + WIDE_RATIO, rotate, } from './airport-diagram'; const WEBSITE = resolve(__dirname, '../..'); +/** + * Each stand counter-rotates by -ROT about its own centre, so the square it + * sweeps in field coordinates is wider than the square itself: STAND / 2 is + * the half-side, this is the half-extent. Using STAND / 2 lets a stand poke + * ~1.1 units past an apron edge with the suite green. + */ +const RAD = (ROT * Math.PI) / 180; +const STAND_HALF = (STAND / 2) * (Math.abs(Math.cos(RAD)) + Math.abs(Math.sin(RAD))); + +const NEAT_BOTTOM = NEAT.y + NEAT.height; + describe('airport diagram geometry', () => { it('keeps all four rotated field corners inside the neat line', () => { const corners = [ @@ -33,43 +57,114 @@ describe('airport diagram geometry', () => { expect(p.x, `corner ${x},${y} left`).toBeGreaterThan(NEAT.x); expect(p.x, `corner ${x},${y} right`).toBeLessThan(NEAT.x + NEAT.width); expect(p.y, `corner ${x},${y} top`).toBeGreaterThan(NEAT.y); - expect(p.y, `corner ${x},${y} bottom`).toBeLessThan(NEAT.y + NEAT.height); + expect(p.y, `corner ${x},${y} bottom`).toBeLessThan(NEAT_BOTTOM); + } + }); + + it('holds every drawn structure inside the field that claims to bound them', () => { + // FIELD is only worth testing corners for if it actually bounds the + // drawing. Without this, a concourse or an apron can run off the plate — + // past the viewBox, even — while the corner test above stays green, + // because nothing else compares a structure against FIELD at all. + // Comparisons are inclusive: the runways ARE the field's top and bottom + // edges and the main terminal ITS west edge, by design. + const inside = (label: string, x0: number, x1: number, y0: number, y1: number) => { + expect(x0, `${label} left`).toBeGreaterThanOrEqual(FIELD.x0); + expect(x1, `${label} right`).toBeLessThanOrEqual(FIELD.x1); + expect(y0, `${label} top`).toBeGreaterThanOrEqual(FIELD.y0); + expect(y1, `${label} bottom`).toBeLessThanOrEqual(FIELD.y1); + }; + + const boxes = [ + ['MAIN', MAIN], + ['CONC_A', CONC_A], + ['CONC_B', CONC_B], + ['APRON_A', APRON_A], + ['APRON_B', APRON_B], + ] as const; + for (const [label, b] of boxes) inside(label, b.x0, b.x1, b.y0, b.y1); + + for (const [label, r] of [ + ['RWY_N', RWY_N], + ['RWY_S', RWY_S], + ] as const) { + inside(label, FIELD.x0, FIELD.x1, r.y, r.y + r.h); + } + + // The two parallel taxiways run the full width; the east one connects them. + for (const [label, y] of [ + ['TWY_N', TWY_N], + ['TWY_S', TWY_S], + ] as const) { + inside(label, FIELD.x0, FIELD.x1, y, y); + } + inside('TWY_E', TWY_E, TWY_E, TWY_N, TWY_S); + + for (const c of CONCOURSES) { + for (const g of c.gates) { + inside( + `stand ${g.gate}`, + g.x - STAND_HALF, + g.x + STAND_HALF, + c.row.standCy - STAND_HALF, + c.row.standCy + STAND_HALF + ); + } } }); it('parks every stand inside its apron', () => { - const r = STAND / 2; - const rows = [ - { xs: GATES_A.map((g) => g.x), cy: ROW1.box, apron: APRON_A }, - { xs: GATES_B.map((g) => g.x), cy: ROW2.box, apron: APRON_B }, - ]; - for (const { xs, cy, apron } of rows) { - for (const x of xs) { - expect(x - r, `stand at ${x} left`).toBeGreaterThanOrEqual(apron.x0); - expect(x + r, `stand at ${x} right`).toBeLessThanOrEqual(apron.x1); - expect(cy - r, `stand at ${x} top`).toBeGreaterThanOrEqual(apron.y0); - expect(cy + r, `stand at ${x} bottom`).toBeLessThanOrEqual(apron.y1); + for (const { gates, row, apron, id } of CONCOURSES) { + for (const g of gates) { + expect(g.x - STAND_HALF, `${g.gate} left of apron ${id}`).toBeGreaterThanOrEqual(apron.x0); + expect(g.x + STAND_HALF, `${g.gate} right of apron ${id}`).toBeLessThanOrEqual(apron.x1); + expect(row.standCy - STAND_HALF, `${g.gate} top of apron ${id}`).toBeGreaterThanOrEqual( + apron.y0 + ); + expect(row.standCy + STAND_HALF, `${g.gate} bottom of apron ${id}`).toBeLessThanOrEqual( + apron.y1 + ); + } + } + }); + + it('stacks each row so the box, the callsign and the stub never collide', () => { + // Every value in a row is a y, and only their order makes the row legible: + // the callsign hangs below the stand box, and the stub runs from the + // concourse to the far side of the pair. Slide the box down onto its own + // label and every other geometry test here still passes. + for (const { row, gatesAbove, id } of CONCOURSES) { + const top = row.standCy - STAND_HALF; + const bottom = row.standCy + STAND_HALF; + expect(row.labelY, `${id} callsign clears the stand box`).toBeGreaterThan(bottom); + expect(row.stubTop, `${id} stub`).toBeLessThan(row.stubBot); + if (gatesAbove) { + // Gates sit above the concourse, so the stub starts below the callsign. + expect(row.stubTop, `${id} stub clears the callsign`).toBeGreaterThan(row.labelY); + } else { + // Gates sit below the concourse, so the stub ends above the stand box. + expect(row.stubBot, `${id} stub clears the stand box`).toBeLessThan(top); } } }); it('lands every gate stub on the concourse it belongs to', () => { - // Row 1 hangs above concourse A, row 2 below concourse B. If either stub - // stops short the gates float, which reads as a drawing error. - expect(ROW1.stubBot).toBe(CONC_A.y0); - expect(ROW2.stubTop).toBe(CONC_B.y1); - expect(ROW1.stubTop).toBeLessThan(ROW1.stubBot); - expect(ROW2.stubTop).toBeLessThan(ROW2.stubBot); + // If either stub stops short the gates float, which reads as a drawing + // error. Which side the row hangs on comes from CONCOURSES, not from the + // ROW1/ROW2 names. + for (const { row, box, gatesAbove, id } of CONCOURSES) { + if (gatesAbove) expect(row.stubBot, `${id} stub meets the concourse`).toBe(box.y0); + else expect(row.stubTop, `${id} stub meets the concourse`).toBe(box.y1); + expect(row.stubTop, `${id} stub`).toBeLessThan(row.stubBot); + } }); it('keeps every gate within the span of its concourse', () => { - for (const g of GATES_A) { - expect(g.x, `${g.gate} x`).toBeGreaterThan(CONC_A.x0); - expect(g.x, `${g.gate} x`).toBeLessThan(CONC_A.x1); - } - for (const g of GATES_B) { - expect(g.x, `${g.gate} x`).toBeGreaterThan(CONC_B.x0); - expect(g.x, `${g.gate} x`).toBeLessThan(CONC_B.x1); + for (const { gates, box, id } of CONCOURSES) { + for (const g of gates) { + expect(g.x, `${g.gate} x in concourse ${id}`).toBeGreaterThan(box.x0); + expect(g.x, `${g.gate} x in concourse ${id}`).toBeLessThan(box.x1); + } } }); @@ -78,14 +173,25 @@ describe('airport diagram geometry', () => { // still render as a building and the section would quietly stop arguing. expect(GATES_A.length).toBeGreaterThan(0); expect(GATES_B.length).toBeGreaterThan(0); + for (const c of CONCOURSES) expect(c.gates.length, `concourse ${c.id}`).toBeGreaterThan(0); }); - it('sizes each mark individually, and only the AWS wordmark by width', () => { + it('sizes each mark individually, and any wordmark at the wide ratio', () => { // One shared height reads wrong: Mastra is wide and heavy, Anthropic is a - // narrow wedge. `w` is the escape hatch for the one 1.67:1 wordmark. + // narrow wedge. `w` is the escape hatch for a wordmark that is not square, + // and it is derived from `s` so the ratio is stated once. A second + // wordmark is allowed to join; an off-ratio one is not. const all = [...GATES_A, ...GATES_B]; - for (const g of all) expect(g.s, `${g.gate} size`).toBeGreaterThan(0); - expect(all.filter((g) => g.w !== undefined).map((g) => g.gate)).toEqual(['B6']); + for (const g of all) { + expect(g.s, `${g.gate} size`).toBeGreaterThan(0); + if (g.w !== undefined) { + expect(g.w, `${g.gate} width`).toBeGreaterThan(g.s); + expect( + Math.abs(g.w - g.s * WIDE_RATIO), + `${g.gate} w ${g.w} vs s ${g.s} x ${WIDE_RATIO} = ${g.s * WIDE_RATIO}` + ).toBeLessThanOrEqual(1); + } + } }); it('points every mark at a file that exists', () => { @@ -94,8 +200,62 @@ describe('airport diagram geometry', () => { } }); - it('places the main terminal west of both concourses', () => { - expect(MAIN.x1).toBeLessThanOrEqual(CONC_A.x0); - expect(MAIN.x1).toBeLessThanOrEqual(CONC_B.x0); + it('places the main terminal west of both concourses, and connects it to each', () => { + // Strictly west: at equality the two connector paths would be zero-length + // and the terminal would read as fused to the concourses. + expect(MAIN.x1).toBeLessThan(CONC_A.x0); + expect(MAIN.x1).toBeLessThan(CONC_B.x0); + for (const [label, y, box] of [ + ['LINK_A_Y', LINK_A_Y, CONC_A], + ['LINK_B_Y', LINK_B_Y, CONC_B], + ] as const) { + // A connector leaves the terminal wall and lands on the concourse wall, + // so it has to fall inside both y spans. Move a concourse up or down + // without moving its link and the line detaches at one end. + expect(y, `${label} leaves the terminal`).toBeGreaterThan(MAIN.y0); + expect(y, `${label} leaves the terminal`).toBeLessThan(MAIN.y1); + expect(y, `${label} lands on the concourse`).toBeGreaterThan(box.y0); + expect(y, `${label} lands on the concourse`).toBeLessThan(box.y1); + } + }); + + it('keeps the margin band below the neat line and inside the view', () => { + // Chart furniture is off-airport by construction, not by prose: the + // provider row, the scale bar and the north arrow all live between the + // neat line and the bottom of the viewBox. PROVIDER_ROW.y is the marks' + // centre line, so the row's own height has to be counted at both edges. + const markTop = PROVIDER_ROW.y - PROVIDER_ROW.size / 2; + const markBottom = PROVIDER_ROW.y + PROVIDER_ROW.size / 2; + expect(PROVIDER_ROW.labelY, 'off-airport label').toBeGreaterThan(NEAT_BOTTOM); + expect(PROVIDER_ROW.labelY, 'off-airport label').toBeLessThan(markTop); + expect(markTop, 'provider marks').toBeGreaterThan(NEAT_BOTTOM); + expect(markBottom, 'provider marks').toBeLessThanOrEqual(VIEW.height); + expect(SCALE_BAR.y, 'scale bar').toBeGreaterThan(NEAT_BOTTOM); + expect(SCALE_BAR.y, 'scale bar').toBeLessThanOrEqual(VIEW.height); + expect(SCALE_BAR.x0, 'scale bar').toBeLessThan(SCALE_BAR.x1); + expect(SCALE_BAR.x1, 'scale bar').toBeLessThanOrEqual(VIEW.width); + expect(NORTH.y, 'north arrow').toBeGreaterThan(NEAT_BOTTOM); + expect(NORTH.y, 'north arrow').toBeLessThanOrEqual(VIEW.height); + expect(NORTH.x, 'north arrow').toBeLessThanOrEqual(VIEW.width); + + // The widest mark is a wordmark, so the row's right edge is ratio-scaled. + const rowRight = + PROVIDER_ROW.x0 + + (PROVIDERS.length - 1) * PROVIDER_ROW.step + + PROVIDER_ROW.size * WIDE_RATIO; + expect(rowRight, 'provider row fits the view').toBeLessThanOrEqual(VIEW.width); + expect(rowRight, 'provider row clears the scale bar').toBeLessThan(SCALE_BAR.x0); + }); +}); + +describe('airport diagram marks', () => { + it('draws the same plane the shared PlaneMark draws', () => { + // PLANE_PATH is a second copy of the `d` in ui/PlaneMark.tsx, because the + // plate needs the raw path inside a transform rather than the component. + // PlaneMark is used across the whole site and is not this module's to + // change, so the duplication is checked here instead of asserted in a + // comment. + const mark = readFileSync(resolve(WEBSITE, 'src/components/ui/PlaneMark.tsx'), 'utf8'); + expect(mark, 'PlaneMark.tsx no longer draws PLANE_PATH').toContain(PLANE_PATH); }); }); diff --git a/apps/website/src/lib/airport-diagram.ts b/apps/website/src/lib/airport-diagram.ts index f7ecef462..11c885b01 100644 --- a/apps/website/src/lib/airport-diagram.ts +++ b/apps/website/src/lib/airport-diagram.ts @@ -59,10 +59,23 @@ export const LINK_B_Y = 270; export const APRON_A = { x0: 232, x1: 440, y0: 112, y1: 186 } as const; export const APRON_B = { x0: 232, x1: 910, y0: 292, y1: 372 } as const; -/** Stand box side, and the two gate rows that hang off the concourses. */ +/** + * Stand box side, and the two gate rows that hang off the concourses. + * + * `standCy` is the stand square's centre y, `labelY` the callsign's baseline — + * both in field coordinates, before the counter-rotation each stand applies + * about its own centre. The stub is the leader line back to the concourse. + */ export const STAND = 38; -export const ROW1 = { box: 140, name: 172, stubTop: 178, stubBot: 190 } as const; -export const ROW2 = { box: 332, name: 364, stubTop: 288, stubBot: 306 } as const; +export const ROW1 = { standCy: 140, labelY: 172, stubTop: 178, stubBot: 190 } as const; +export const ROW2 = { standCy: 332, labelY: 364, stubTop: 288, stubBot: 306 } as const; + +/** + * A mark that is not square is sized by width at this ratio; the AWS wordmark + * is the only one so far, and it appears twice — at gate B6 and again in the + * margin provider row. Stated once so the two cannot drift apart. + */ +export const WIDE_RATIO = 1.67; export interface Gate { readonly gate: string; @@ -70,7 +83,7 @@ export interface Gate { readonly name: string; /** Optical height. Deliberately per-mark; see the spec test. */ readonly s: number; - /** Optical width, for wordmarks that are not square. B6 only. */ + /** Optical width, for a wordmark that is not square: always `s * WIDE_RATIO`. */ readonly w?: number; readonly x: number; } @@ -79,22 +92,59 @@ export const GATES_A: readonly Gate[] = [ { gate: 'A1', src: '/logos/langgraph.svg', name: 'LANGGRAPH', s: 21, x: 318 }, ]; +/** B6's optical height, named so its width can be derived from it in place. */ +const B6_H = 12; + export const GATES_B: readonly Gate[] = [ { gate: 'B1', src: '/logos/ag-ui.svg', name: 'AG-UI', s: 19, x: 268 }, { gate: 'B2', src: '/logos/runtimes/crewai.svg', name: 'CREWAI', s: 22, x: 380 }, { gate: 'B3', src: '/logos/runtimes/mastra.svg', name: 'MASTRA', s: 16, x: 492 }, { gate: 'B4', src: '/logos/runtimes/pydantic.svg', name: 'PYDANTIC AI', s: 21, x: 604 }, { gate: 'B5', src: '/logos/runtimes/microsoft.svg', name: 'MS AGENT FWK', s: 19, x: 716 }, - // The AWS wordmark is 1.67:1, so it is the one mark sized by width. Using it - // for Strands is honest — Strands is an AWS project. The rejected + // The AWS wordmark is not square, so it is the one mark sized by width — and + // that width is derived from WIDE_RATIO rather than measured a second time. + // Using it for Strands is honest — Strands is an AWS project. The rejected // alternative was the word "AWS" in Archivo Black, which out-weighed every // real logo on the plate. - { gate: 'B6', src: '/logos/providers/bedrock.svg', name: 'AWS STRANDS', s: 12, w: 30, x: 828 }, + { + gate: 'B6', + src: '/logos/providers/bedrock.svg', + name: 'AWS STRANDS', + s: B6_H, + w: Math.round(B6_H * WIDE_RATIO), + x: 828, + }, ]; +/** + * The whole pairing, stated once: each concourse owns a gate row, an apron and + * a side. `gatesAbove` is which side of the concourse its gates hang on — row 1 + * sits above concourse A, row 2 below concourse B — which the component needs + * to aim the stub tick and which the spec needs to read the row's ordering. + * Anything that re-derives "A is the up row" from the constant names is a + * second copy of this table. + */ export const CONCOURSES = [ - { id: 'A', label: 'CONCOURSE A', pkg: '@threadplane/langgraph', box: CONC_A, gates: GATES_A }, - { id: 'B', label: 'CONCOURSE B', pkg: '@threadplane/ag-ui', box: CONC_B, gates: GATES_B }, + { + id: 'A', + label: 'CONCOURSE A', + pkg: '@threadplane/langgraph', + box: CONC_A, + gates: GATES_A, + row: ROW1, + apron: APRON_A, + gatesAbove: true, + }, + { + id: 'B', + label: 'CONCOURSE B', + pkg: '@threadplane/ag-ui', + box: CONC_B, + gates: GATES_B, + row: ROW2, + apron: APRON_B, + gatesAbove: false, + }, ] as const; /** @@ -117,9 +167,8 @@ export const PROVIDERS = [ { src: '/logos/providers/azure.svg', name: 'Azure OpenAI' }, { src: '/logos/providers/bedrock.svg', name: 'Amazon Bedrock' }, ] as const; +/** `y` is the marks' centre line; each is `size` tall and WIDE_RATIO wide if wide. */ export const PROVIDER_ROW = { y: 518, size: 20, x0: 30, step: 76, labelY: 492 } as const; -/** The AWS mark again, in the margin. Same 1.67:1 ratio. */ -export const WIDE_RATIO = 1.67; /** Chart furniture lives in the margin, never on the field. */ export const SCALE_BAR = { x0: 742, x1: 842, y: 512 } as const; From c294f7ed0b3177616af811ee75bb31fd48d31567 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Tue, 8 Sep 2026 15:51:54 -0700 Subject: [PATCH 05/16] docs(website): retarget task 2 at the module API that actually landed Task 1's code review renamed ROW.box/ROW.name to standCy/labelY and moved the row, apron and gates-side pairing onto CONCOURSES, so the component code in task 2 now loops CONCOURSES instead of restating which row goes with which concourse. Co-Authored-By: Claude Opus 5 --- ...026-09-08-compatibility-airport-diagram.md | 36 +++++++++---------- 1 file changed, 17 insertions(+), 19 deletions(-) diff --git a/docs/superpowers/plans/2026-09-08-compatibility-airport-diagram.md b/docs/superpowers/plans/2026-09-08-compatibility-airport-diagram.md index a3e4d73c8..bd7a24607 100644 --- a/docs/superpowers/plans/2026-09-08-compatibility-airport-diagram.md +++ b/docs/superpowers/plans/2026-09-08-compatibility-airport-diagram.md @@ -396,15 +396,11 @@ import { Container } from '../ui/Container'; import { Section } from '../ui/Section'; import { AdapterGuideLink } from './AdapterGuideLink'; import { - APRON_A, - APRON_B, CHART_ID, CONCOURSES, DISCLAIMER, EYEBROW, FIELD, - GATES_A, - GATES_B, HEADLINE, LINK_A_Y, LINK_B_Y, @@ -417,8 +413,6 @@ import { PROVIDERS, PROVIDER_ROW, ROT, - ROW1, - ROW2, RWY_N, RWY_S, SCALE_BAR, @@ -455,10 +449,11 @@ function TaxiwayLetter({ x, y, ch }: { x: number; y: number; ch: string }) { } /** A stand: the stub off the concourse, the white box, the mark, the callsign. */ -function Stand({ gate, up }: { gate: Gate; up: boolean }) { - const row = up ? ROW1 : ROW2; - const cy = row.box; - const tick = up ? row.stubTop : row.stubBot; +type Row = (typeof CONCOURSES)[number]['row']; + +function Stand({ gate, row, above }: { gate: Gate; row: Row; above: boolean }) { + const cy = row.standCy; + const tick = above ? row.stubTop : row.stubBot; const iw = gate.w ?? gate.s; return ( @@ -489,7 +484,7 @@ function Stand({ gate, up }: { gate: Gate; up: boolean }) { {gate.gate} - + {gate.name} @@ -527,14 +522,14 @@ function Plate() { - {[APRON_A, APRON_B].map((a) => ( + {CONCOURSES.map(({ id, apron }) => ( ))} @@ -592,8 +587,11 @@ function Plate() { ))} - {GATES_A.map((g) => )} - {GATES_B.map((g) => )} + {CONCOURSES.flatMap((c) => + c.gates.map((g) => ( + + )), + )} {/* Below the neat line is outside the airport. */} From 091309eb709f9df066d31d706c6d81234422db3c Mon Sep 17 00:00:00 2001 From: Brian Love Date: Tue, 8 Sep 2026 16:09:02 -0700 Subject: [PATCH 06/16] test(website): tie the airport diagram's wide ratio to the artwork WIDE_RATIO had no coverage: B6's `w` was computed as `B6_H * WIDE_RATIO`, so the spec's `w ~= s * WIDE_RATIO` check compared the constant with itself and could not fail. Setting WIDE_RATIO to 2.5 left all 12 tests green while stretching the AWS wordmark at gate B6 and in the provider row. Write both of B6's numbers as literals like every other value in the table (the derived B6_H is gone; `w` is still 20), and add a test that reads the wordmark's own viewBox off disk and holds WIDE_RATIO to it. The literal `w` makes the ratio check compare two independent values, and the new test pins the ratio to something outside the module. Also close the CONCOURSES single-sourcing at the spec boundary: the field bounds list is built from CONCOURSES instead of naming the boxes by hand, and LINK_A_Y / LINK_B_Y join the table as `link` so the connector test stops re-pairing them itself. A third concourse now gets its box, apron and link checked the moment it is declared. Co-Authored-By: Claude Opus 5 --- apps/website/src/lib/airport-diagram.spec.ts | 67 ++++++++++++-------- apps/website/src/lib/airport-diagram.ts | 41 ++++++------ 2 files changed, 63 insertions(+), 45 deletions(-) diff --git a/apps/website/src/lib/airport-diagram.spec.ts b/apps/website/src/lib/airport-diagram.spec.ts index 742db3e6a..480255525 100644 --- a/apps/website/src/lib/airport-diagram.spec.ts +++ b/apps/website/src/lib/airport-diagram.spec.ts @@ -2,16 +2,10 @@ import { existsSync, readFileSync } from 'node:fs'; import { resolve } from 'node:path'; import { describe, expect, it } from 'vitest'; import { - APRON_A, - APRON_B, - CONC_A, - CONC_B, CONCOURSES, FIELD, GATES_A, GATES_B, - LINK_A_Y, - LINK_B_Y, MAIN, NEAT, NORTH, @@ -75,13 +69,15 @@ describe('airport diagram geometry', () => { expect(y1, `${label} bottom`).toBeLessThanOrEqual(FIELD.y1); }; + // Read off CONCOURSES rather than listed by hand: a third concourse is + // bounded the moment it is declared, instead of the day someone remembers + // to add its box and its apron to a list over here. const boxes = [ - ['MAIN', MAIN], - ['CONC_A', CONC_A], - ['CONC_B', CONC_B], - ['APRON_A', APRON_A], - ['APRON_B', APRON_B], - ] as const; + ['MAIN', MAIN] as const, + ...CONCOURSES.flatMap( + (c) => [[`CONC_${c.id}`, c.box], [`APRON_${c.id}`, c.apron]] as const + ), + ]; for (const [label, b] of boxes) inside(label, b.x0, b.x1, b.y0, b.y1); for (const [label, r] of [ @@ -176,11 +172,33 @@ describe('airport diagram geometry', () => { for (const c of CONCOURSES) expect(c.gates.length, `concourse ${c.id}`).toBeGreaterThan(0); }); + it('takes the wide ratio from the artwork, not from taste', () => { + // WIDE_RATIO decides how wide the AWS wordmark is drawn in two places, so + // it has to be the shape of the actual file or the mark is stretched. This + // is the only assertion here that ties the constant to something outside + // the module, and it is what stops the `w` vs `s * WIDE_RATIO` check below + // from closing back on itself. + for (const g of [...GATES_A, ...GATES_B]) { + if (g.w === undefined) continue; + const svg = readFileSync(resolve(WEBSITE, 'public', g.src.slice(1)), 'utf8'); + const viewBox = /viewBox="([^"]+)"/.exec(svg)?.[1]; + expect(viewBox, `${g.src} has no viewBox to measure`).toBeDefined(); + const [, , vbW, vbH] = (viewBox as string).trim().split(/[\s,]+/).map(Number); + expect(vbW, `${g.src} viewBox width`).toBeGreaterThan(0); + expect(vbH, `${g.src} viewBox height`).toBeGreaterThan(0); + expect( + Math.abs(WIDE_RATIO - vbW / vbH), + `${g.gate}: WIDE_RATIO ${WIDE_RATIO} vs ${g.src} ${vbW}/${vbH} = ${vbW / vbH}` + ).toBeLessThanOrEqual(0.02); + } + }); + it('sizes each mark individually, and any wordmark at the wide ratio', () => { // One shared height reads wrong: Mastra is wide and heavy, Anthropic is a - // narrow wedge. `w` is the escape hatch for a wordmark that is not square, - // and it is derived from `s` so the ratio is stated once. A second - // wordmark is allowed to join; an off-ratio one is not. + // narrow wedge. `w` is the escape hatch for a wordmark that is not square. + // Both numbers are literal, so this compares two independent values against + // a ratio the test above pins to the file itself. A second wordmark is + // allowed to join; an off-ratio one is not. const all = [...GATES_A, ...GATES_B]; for (const g of all) { expect(g.s, `${g.gate} size`).toBeGreaterThan(0); @@ -203,19 +221,16 @@ describe('airport diagram geometry', () => { it('places the main terminal west of both concourses, and connects it to each', () => { // Strictly west: at equality the two connector paths would be zero-length // and the terminal would read as fused to the concourses. - expect(MAIN.x1).toBeLessThan(CONC_A.x0); - expect(MAIN.x1).toBeLessThan(CONC_B.x0); - for (const [label, y, box] of [ - ['LINK_A_Y', LINK_A_Y, CONC_A], - ['LINK_B_Y', LINK_B_Y, CONC_B], - ] as const) { + for (const { id, box, link } of CONCOURSES) { + expect(MAIN.x1, `main terminal west of concourse ${id}`).toBeLessThan(box.x0); // A connector leaves the terminal wall and lands on the concourse wall, // so it has to fall inside both y spans. Move a concourse up or down - // without moving its link and the line detaches at one end. - expect(y, `${label} leaves the terminal`).toBeGreaterThan(MAIN.y0); - expect(y, `${label} leaves the terminal`).toBeLessThan(MAIN.y1); - expect(y, `${label} lands on the concourse`).toBeGreaterThan(box.y0); - expect(y, `${label} lands on the concourse`).toBeLessThan(box.y1); + // without moving its link and the line detaches at one end. The pairing + // comes from CONCOURSES, so it cannot be got wrong here. + expect(link, `link ${id} leaves the terminal`).toBeGreaterThan(MAIN.y0); + expect(link, `link ${id} leaves the terminal`).toBeLessThan(MAIN.y1); + expect(link, `link ${id} lands on the concourse`).toBeGreaterThan(box.y0); + expect(link, `link ${id} lands on the concourse`).toBeLessThan(box.y1); } }); diff --git a/apps/website/src/lib/airport-diagram.ts b/apps/website/src/lib/airport-diagram.ts index 11c885b01..a372348f3 100644 --- a/apps/website/src/lib/airport-diagram.ts +++ b/apps/website/src/lib/airport-diagram.ts @@ -53,6 +53,11 @@ export const TWY_E = 930; export const MAIN = { x0: 56, x1: 188, y0: 190, y1: 288 } as const; export const CONC_A = { x0: 204, x1: 432, y0: 190, y1: 224 } as const; export const CONC_B = { x0: 204, x1: 900, y0: 254, y1: 288 } as const; +/** + * Connector centre lines, terminal wall to concourse wall. CONCOURSES pairs + * each with its concourse as `link`; the names exist so each value can be + * written beside the two boxes its line runs between. + */ export const LINK_A_Y = 206; export const LINK_B_Y = 270; @@ -74,6 +79,10 @@ export const ROW2 = { standCy: 332, labelY: 364, stubTop: 288, stubBot: 306 } as * A mark that is not square is sized by width at this ratio; the AWS wordmark * is the only one so far, and it appears twice — at gate B6 and again in the * margin provider row. Stated once so the two cannot drift apart. + * + * The number is not a taste call: it is the aspect of the artwork itself + * (public/logos/providers/bedrock.svg, viewBox 0 0 256 153), and the spec reads + * that file off disk to hold it there. Change it and the wordmark stretches. */ export const WIDE_RATIO = 1.67; @@ -92,37 +101,29 @@ export const GATES_A: readonly Gate[] = [ { gate: 'A1', src: '/logos/langgraph.svg', name: 'LANGGRAPH', s: 21, x: 318 }, ]; -/** B6's optical height, named so its width can be derived from it in place. */ -const B6_H = 12; - export const GATES_B: readonly Gate[] = [ { gate: 'B1', src: '/logos/ag-ui.svg', name: 'AG-UI', s: 19, x: 268 }, { gate: 'B2', src: '/logos/runtimes/crewai.svg', name: 'CREWAI', s: 22, x: 380 }, { gate: 'B3', src: '/logos/runtimes/mastra.svg', name: 'MASTRA', s: 16, x: 492 }, { gate: 'B4', src: '/logos/runtimes/pydantic.svg', name: 'PYDANTIC AI', s: 21, x: 604 }, { gate: 'B5', src: '/logos/runtimes/microsoft.svg', name: 'MS AGENT FWK', s: 19, x: 716 }, - // The AWS wordmark is not square, so it is the one mark sized by width — and - // that width is derived from WIDE_RATIO rather than measured a second time. + // The AWS wordmark is not square, so it is the one mark sized by width. Both + // numbers are written literally like every other value in this table; the + // spec checks the pair against WIDE_RATIO, and WIDE_RATIO against the file. // Using it for Strands is honest — Strands is an AWS project. The rejected // alternative was the word "AWS" in Archivo Black, which out-weighed every // real logo on the plate. - { - gate: 'B6', - src: '/logos/providers/bedrock.svg', - name: 'AWS STRANDS', - s: B6_H, - w: Math.round(B6_H * WIDE_RATIO), - x: 828, - }, + { gate: 'B6', src: '/logos/providers/bedrock.svg', name: 'AWS STRANDS', s: 12, w: 20, x: 828 }, ]; /** - * The whole pairing, stated once: each concourse owns a gate row, an apron and - * a side. `gatesAbove` is which side of the concourse its gates hang on — row 1 - * sits above concourse A, row 2 below concourse B — which the component needs - * to aim the stub tick and which the spec needs to read the row's ordering. - * Anything that re-derives "A is the up row" from the constant names is a - * second copy of this table. + * The whole pairing, stated once: each concourse owns a gate row, an apron, a + * connector back to the main terminal and a side. `gatesAbove` is which side of + * the concourse its gates hang on — row 1 sits above concourse A, row 2 below + * concourse B — which the component needs to aim the stub tick and which the + * spec needs to read the row's ordering. Anything that re-derives "A is the up + * row", or re-pairs a concourse with its apron or its link, from the constant + * names is a second copy of this table. */ export const CONCOURSES = [ { @@ -133,6 +134,7 @@ export const CONCOURSES = [ gates: GATES_A, row: ROW1, apron: APRON_A, + link: LINK_A_Y, gatesAbove: true, }, { @@ -143,6 +145,7 @@ export const CONCOURSES = [ gates: GATES_B, row: ROW2, apron: APRON_B, + link: LINK_B_Y, gatesAbove: false, }, ] as const; From ba6f253e6ba4cad7a78daeb67d39b9d027826adf Mon Sep 17 00:00:00 2001 From: Brian Love Date: Tue, 8 Sep 2026 16:09:42 -0700 Subject: [PATCH 07/16] docs(website): task 2 reaches the connectors through CONCOURSES.link Co-Authored-By: Claude Opus 5 --- .../plans/2026-09-08-compatibility-airport-diagram.md | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/docs/superpowers/plans/2026-09-08-compatibility-airport-diagram.md b/docs/superpowers/plans/2026-09-08-compatibility-airport-diagram.md index bd7a24607..76decafe3 100644 --- a/docs/superpowers/plans/2026-09-08-compatibility-airport-diagram.md +++ b/docs/superpowers/plans/2026-09-08-compatibility-airport-diagram.md @@ -402,8 +402,6 @@ import { EYEBROW, FIELD, HEADLINE, - LINK_A_Y, - LINK_B_Y, MAIN, NEAT, NORTH, @@ -562,8 +560,9 @@ function Plate() { MAIN TERMINAL - - + {CONCOURSES.map((c) => ( + + ))} {CONCOURSES.map((c) => ( From 167008d1026d50e2f7045e0c19e456f9f949c6e6 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Tue, 8 Sep 2026 16:14:24 -0700 Subject: [PATCH 08/16] feat(website): draw the compatibility band as an airport diagram Co-Authored-By: Claude Opus 5 --- .../components/landing/Compatibility.spec.tsx | 69 ++- .../src/components/landing/Compatibility.tsx | 420 ++++++++++++++---- 2 files changed, 360 insertions(+), 129 deletions(-) diff --git a/apps/website/src/components/landing/Compatibility.spec.tsx b/apps/website/src/components/landing/Compatibility.spec.tsx index fd6492572..14128bd65 100644 --- a/apps/website/src/components/landing/Compatibility.spec.tsx +++ b/apps/website/src/components/landing/Compatibility.spec.tsx @@ -1,64 +1,61 @@ import { render, screen } from '@testing-library/react'; import { describe, it, expect } from 'vitest'; -import { Compatibility, COMPATIBILITY_GROUPS } from './Compatibility'; +import { Compatibility } from './Compatibility'; +import { GATES_A, GATES_B, PROVIDERS } from '../../lib/airport-diagram'; describe('Compatibility', () => { - it('renders a light section with a stable id', () => { + it('renders the signal surface with the ids the homepage spine depends on', () => { + // e2e/website.spec.ts asserts homepage order by heading id. Renaming + // either of these turns that spec red for a reason nobody will guess. const { container } = render(); const section = container.querySelector('[data-ui="section"]'); - expect(section?.getAttribute('data-surface')).toBe('tinted'); + expect(section?.getAttribute('data-surface')).toBe('signal'); expect(section?.getAttribute('id')).toBe('compatibility'); expect(section?.getAttribute('aria-labelledby')).toBe('compatibility-heading'); + expect(container.querySelector('#compatibility-heading')?.textContent).toBe( + 'Every stack has a gate.', + ); }); - it('lists twelve integrations across three groups', () => { + it('names every gate and every provider in text, not only as a picture', () => { + // The marks are decorative, so the accessible content is these names. If + // the SVG were the only carrier the section would be empty to a reader. render(); - // The suite otherwise iterates the same constant the component renders - // from, so it cannot see content disappear: a reviewer deleted the whole - // Protocols group — LangGraph and AG-UI, the two with first-party - // adapters — and every other test stayed green. - expect(COMPATIBILITY_GROUPS).toHaveLength(3); - expect(COMPATIBILITY_GROUPS.map((g) => g.label)).toEqual([ - 'Model providers', - 'Agent runtimes', - 'Protocols', - ]); - expect(COMPATIBILITY_GROUPS.flatMap((g) => g.items)).toHaveLength(12); - for (const name of ['LangGraph', 'AG-UI', 'OpenAI', 'Anthropic']) { - expect(screen.getByText(name)).toBeTruthy(); + for (const g of [...GATES_A, ...GATES_B]) { + expect(screen.getAllByText(g.name).length).toBeGreaterThan(0); + } + for (const p of PROVIDERS) { + expect(screen.getAllByText(p.name).length).toBeGreaterThan(0); } }); - it('groups every item under a labelled heading', () => { + it('shows both adapters as the two concourses', () => { render(); - for (const group of COMPATIBILITY_GROUPS) { - expect(screen.getByText(group.label)).toBeTruthy(); - for (const item of group.items) expect(screen.getByText(item.name)).toBeTruthy(); - } - // The label is a bare

, so the list only carries an accessible name if - // aria-labelledby actually points at it. - for (const group of COMPATIBILITY_GROUPS) { - expect(screen.getByRole('list', { name: group.label })).toBeTruthy(); + expect(screen.getAllByText('@threadplane/langgraph').length).toBeGreaterThan(0); + expect(screen.getAllByText('@threadplane/ag-ui').length).toBeGreaterThan(0); + }); + + it('marks every logo decorative, since the visible name carries the meaning', () => { + const { container } = render(); + const marks = container.querySelectorAll('image, img.airport-mark'); + expect(marks.length).toBeGreaterThan(0); + for (const m of Array.from(marks)) { + expect(m.getAttribute('aria-hidden')).toBe('true'); } }); it('states compatibility in words and never implies a customer', () => { const { container } = render(); - // The claim used to exist only as alt="" plus a spec comment. A reader - // could not see it. Now it is on the page. expect(screen.getByText(/Compatibility, not endorsement/)).toBeTruthy(); expect(container.textContent).not.toMatch(/trusted by|customers|our clients|powered by/i); }); - it('marks every logo decorative, since the visible name carries the meaning', () => { + it('says Threadplane never talks to model providers, not that it never sees them', () => { + // never-SEES is a data claim the docs do not support; never-TALKS-TO is + // structural. This is the same failure mode #1067 had to correct. const { container } = render(); - const logos = container.querySelectorAll('img.compatibility-logo'); - const withLogos = COMPATIBILITY_GROUPS.flatMap((g) => g.items).filter((i) => i.logoSrc); - expect(logos).toHaveLength(withLogos.length); - for (const img of Array.from(logos)) { - expect(img.getAttribute('aria-hidden')).toBe('true'); - expect(img.getAttribute('alt')).toBe(''); - } + expect(container.textContent).toMatch(/never talks to them/i); + expect(container.textContent).not.toMatch(/never sees/i); }); it('links to the adapter guide', () => { diff --git a/apps/website/src/components/landing/Compatibility.tsx b/apps/website/src/components/landing/Compatibility.tsx index e6999ee84..fc7c416f0 100644 --- a/apps/website/src/components/landing/Compatibility.tsx +++ b/apps/website/src/components/landing/Compatibility.tsx @@ -1,121 +1,355 @@ import { Container } from '../ui/Container'; import { Section } from '../ui/Section'; -import { SectionHeader } from '../ui/SectionHeader'; import { AdapterGuideLink } from './AdapterGuideLink'; +import { + CHART_ID, + CONCOURSES, + DISCLAIMER, + EYEBROW, + FIELD, + HEADLINE, + MAIN, + NEAT, + NORTH, + OFF_AIRPORT_LABEL, + PIVOT, + PLANE_PATH, + PROVIDERS, + PROVIDER_ROW, + ROT, + RWY_N, + RWY_S, + SCALE_BAR, + STAND, + TICK_X, + TICK_Y, + TWY_E, + TWY_N, + TWY_S, + VIEW, + WIDE_RATIO, + type Gate, +} from '../../lib/airport-diagram'; -interface CompatibilityItem { - readonly name: string; - /** null for an entry we support but have no mark for. */ - readonly logoSrc: string | null; +const R = STAND / 2; + +function Runway({ y, h, left, right }: typeof RWY_N | typeof RWY_S) { + return ( + + + + {left} + + + {right} + + + ); } -interface CompatibilityGroup { - readonly label: string; - readonly items: readonly CompatibilityItem[]; +function TaxiwayLetter({ x, y, ch }: { x: number; y: number; ch: string }) { + return ( + + + + {ch} + + + ); } -/** - * Grouped rather than a flat run: the previous single row put a model provider - * beside a protocol as though they were the same kind of thing, which is not - * the information someone evaluating this needs. - * - * This list is COMPLETE — every integration is named. It used to stop at nine - * and close the runtimes group with a "+ 3 more" badge, which mis-filed Azure - * OpenAI (a model provider) as a runtime and left a count that only stayed - * honest if an editor remembered to decrement it. There is no hidden count to - * keep in step now; add an entry to the group it actually belongs to. - * - * "Works with" is a compatibility claim, never a customer claim: logos are - * `alt="" aria-hidden` beside visible names, and no wording may imply these - * companies use Threadplane. Compatibility.spec.tsx guards both halves. - * - * This section is LIGHT on purpose. These marks are drawn for light grounds — - * Anthropic's is #181818 — and on the dark band they were invisible. Moving - * them here is the fix; no CSS filter is involved. - */ -export const COMPATIBILITY_GROUPS: readonly CompatibilityGroup[] = [ - { - label: 'Model providers', - items: [ - { name: 'OpenAI', logoSrc: '/logos/providers/openai.svg' }, - { name: 'Anthropic', logoSrc: '/logos/providers/anthropic.svg' }, - { name: 'Gemini', logoSrc: '/logos/providers/google.svg' }, - { name: 'Bedrock', logoSrc: '/logos/providers/bedrock.svg' }, - { name: 'Azure OpenAI', logoSrc: '/logos/providers/azure.svg' }, - ], - }, - { - label: 'Agent runtimes', - items: [ - { name: 'Mastra', logoSrc: '/logos/runtimes/mastra.svg' }, - { name: 'CrewAI', logoSrc: '/logos/runtimes/crewai.svg' }, - { name: 'Pydantic AI', logoSrc: '/logos/runtimes/pydantic.svg' }, - { name: 'Microsoft Agent Framework', logoSrc: '/logos/runtimes/microsoft.svg' }, - { name: 'AWS Strands', logoSrc: null }, - ], - }, - { - label: 'Protocols', - items: [ - { name: 'LangGraph', logoSrc: '/logos/langgraph.svg' }, - { name: 'AG-UI', logoSrc: '/logos/ag-ui.svg' }, - ], - }, -]; +/** A stand: the stub off the concourse, the white box, the mark, the callsign. */ +type Row = (typeof CONCOURSES)[number]['row']; + +function Stand({ gate, row, above }: { gate: Gate; row: Row; above: boolean }) { + const cy = row.standCy; + const tick = above ? row.stubTop : row.stubBot; + const iw = gate.w ?? gate.s; + return ( + + + + {/* Counter-rotated so the mark and its callsign stay upright while the + airfield sits at its heading. */} + + + + + + {gate.gate} + + + {gate.name} + + + + ); +} + +function Plate() { + const ticks: string[] = []; + for (let x = TICK_X; x < VIEW.width; x += TICK_X) { + ticks.push( + `M${x} ${NEAT.y} V${NEAT.y + 7}`, + `M${x} ${NEAT.y + NEAT.height} V${NEAT.y + NEAT.height - 7}`, + ); + } + for (let y = TICK_Y; y < NEAT.y + NEAT.height; y += TICK_Y) { + ticks.push( + `M${NEAT.x} ${y} H${NEAT.x + 7}`, + `M${NEAT.x + NEAT.width} ${y} H${NEAT.x + NEAT.width - 7}`, + ); + } + const mx = (MAIN.x0 + MAIN.x1) / 2; + const my = (MAIN.y0 + MAIN.y1) / 2; + const glyph = 27; + + return ( + + + + + + + + + + + + {CONCOURSES.map(({ id, apron }) => ( + + ))} + + + + + + + + + + + + {/* The one structure that IS Threadplane: solid ink. Partner stands are + white, so the two values carry the meaning with no legend. */} + + + + + + + <chat> + + + MAIN TERMINAL + + + + {CONCOURSES.map((c) => ( + + ))} + + {CONCOURSES.map((c) => ( + + + + + {c.label} + + + {c.pkg} + + + ))} + + {CONCOURSES.flatMap((c) => + c.gates.map((g) => ), + )} + + + {/* Below the neat line is outside the airport. */} + + {OFF_AIRPORT_LABEL} + + {PROVIDERS.map((p, i) => { + const wide = p.src.endsWith('bedrock.svg'); + const w = wide ? PROVIDER_ROW.size * WIDE_RATIO : PROVIDER_ROW.size; + const x = PROVIDER_ROW.x0 + i * PROVIDER_ROW.step; + return ( + + ); + })} + + + + + + 0 + + + 2000 FT + + + + N + + + + ); +} /** - * Derived, never hand-written: each group's `

    ` takes its accessible name - * from the sibling label through this id, so rewording a label cannot leave - * the list unnamed. + * The band is a chart, so the accessible content is a plain list beside it — + * the same data, never a second source of truth. */ -const groupId = (label: string) => `compatibility-${label.toLowerCase().replace(/\s+/g, '-')}`; - export function Compatibility() { return ( -
    +
    - -
    - {COMPATIBILITY_GROUPS.map((group) => ( -
    -

    - {group.label} +

    +
    +

    {EYEBROW}

    +

    + {HEADLINE} +

    +
    +

    + {CHART_ID[0]} +
    + {CHART_ID[1]} +

    +
    + +
    + +
    + +
    + {CONCOURSES.map((c) => ( +
    +

    + {c.label} — {c.pkg}

      - {group.items.map((item) => ( -
    • - {item.logoSrc ? ( - - ) : null} - {item.name} + {c.gates.map((g) => ( +
    • + {g.gate} + + {g.name}
    • ))}
    ))} +

    {OFF_AIRPORT_LABEL}

    +
      + {PROVIDERS.map((p) => ( +
    • + + {p.name} +
    • + ))} +
    -
    + +
    -

    - Compatibility, not endorsement — no company here is claimed as a customer. -

    +

    {DISCLAIMER}

    From 9e60874534ce251f3d32f6592d0d6d09d6d381d4 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Tue, 8 Sep 2026 16:28:20 -0700 Subject: [PATCH 09/16] docs(website): the phone stack is visually hidden, not display:none Code review of task 2 found the band would have had no accessible content on desktop at all: the plate is decorative and the five provider names live only in the stack, so display:none removed them from the tree entirely. Co-Authored-By: Claude Opus 5 --- ...026-09-08-compatibility-airport-diagram.md | 23 ++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) diff --git a/docs/superpowers/plans/2026-09-08-compatibility-airport-diagram.md b/docs/superpowers/plans/2026-09-08-compatibility-airport-diagram.md index 76decafe3..33e5f4aa3 100644 --- a/docs/superpowers/plans/2026-09-08-compatibility-airport-diagram.md +++ b/docs/superpowers/plans/2026-09-08-compatibility-airport-diagram.md @@ -1015,16 +1015,33 @@ Append to the block added in Task 3 in `apps/website/src/styles/landing.css`: ```css /* Phone form: the same gates as an HTML list, driven by the same data. * The plate is hidden here instead of scrolled sideways — the .arch-stack - * precedent from the architecture diagram. */ + * precedent from the architecture diagram. + * + * VISUALLY hidden on desktop, never `display: none`. The plate is + * aria-hidden, and the five provider names exist ONLY in this list, so + * display:none would leave the whole band with no accessible content on + * desktop. Same idiom as .stage-skip above. */ .airport-stack { - display: none; + position: absolute; + width: 1px; + height: 1px; + overflow: hidden; + clip: rect(0 0 0 0); + clip-path: inset(50%); + white-space: nowrap; } @media (max-width: 767px) { .airport-figure { display: none; } .airport-stack { - display: block; + position: static; + width: auto; + height: auto; + overflow: visible; + clip: auto; + clip-path: none; + white-space: normal; margin-top: 8px; } .airport-head { From bfbfd282e69202ebfca55f581da90afc3c15654b Mon Sep 17 00:00:00 2001 From: Brian Love Date: Tue, 8 Sep 2026 16:41:13 -0700 Subject: [PATCH 10/16] fix(website): make the airport plate accessible-by-omission and actually tested MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit review fixes on 474f085f: - the SVG is aria-hidden, not role="presentation". presentation is not inherited, so every on the plate ("09L", "2000 FT") still reached the accessibility tree as unnamed chart noise while the provider names, which exist only in the HTML stack, were the band's real content. - the plate had no test at all: swapping for an empty
    left 6 of 7 specs green. A stand per gate and a concourse per adapter are now asserted against the tables. - the name test is scoped to .airport-stack. Unscoped, getAllByText also matched the SVG's own , so deleting the accessible list would have left half of it passing. Same for the concourse test, which matched only the plate. - the CTA test asserts the [data-cta] hook; the copy and the href belong to AdapterGuideLink.spec.tsx. - PROVIDERS carries the optional `w` the gate table already has, so the component no longer asks whether a filename ends in bedrock.svg to know a mark is a wordmark. The spec's two sizing tests walk both tables, which is the coverage the margin row never had. - Row and Runway are exported from the data module instead of derived in the component from the two constants that happen to exist today. - the x tick loop bounds on the frame it decorates, like the y loop. Two intended one-unit shifts: the Bedrock wordmark is 33 wide rather than 33.4 (its x moves 0.2), and each vertical tick moves 8 units right, from 88..968 to 96..976 — still inside the neat line. Co-Authored-By: Claude Opus 5 --- .../components/landing/Compatibility.spec.tsx | 62 ++++++++++++++----- .../src/components/landing/Compatibility.tsx | 29 +++++---- apps/website/src/lib/airport-diagram.spec.ts | 38 +++++++----- apps/website/src/lib/airport-diagram.ts | 38 ++++++++++-- 4 files changed, 121 insertions(+), 46 deletions(-) diff --git a/apps/website/src/components/landing/Compatibility.spec.tsx b/apps/website/src/components/landing/Compatibility.spec.tsx index 14128bd65..a0878ce77 100644 --- a/apps/website/src/components/landing/Compatibility.spec.tsx +++ b/apps/website/src/components/landing/Compatibility.spec.tsx @@ -1,7 +1,7 @@ -import { render, screen } from '@testing-library/react'; +import { render, screen, within } from '@testing-library/react'; import { describe, it, expect } from 'vitest'; import { Compatibility } from './Compatibility'; -import { GATES_A, GATES_B, PROVIDERS } from '../../lib/airport-diagram'; +import { CONCOURSES, GATES_A, GATES_B, PROVIDERS } from '../../lib/airport-diagram'; describe('Compatibility', () => { it('renders the signal surface with the ids the homepage spine depends on', () => { @@ -17,22 +17,51 @@ describe('Compatibility', () => { ); }); - it('names every gate and every provider in text, not only as a picture', () => { - // The marks are decorative, so the accessible content is these names. If - // the SVG were the only carrier the section would be empty to a reader. - render(); + it('names every gate and every provider in the HTML stack, not only on the plate', () => { + // The marks are decorative and the plate itself is aria-hidden, so this + // list is the section's ONLY accessible content. Scoped to .airport-stack + // on purpose: an unscoped getAllByText also matches the SVG's own , + // so deleting the whole stack would leave the gate half of this green + // while a screen reader heard nothing. + const { container } = render(); + const stack = container.querySelector('.airport-stack'); + expect(stack, 'the accessible stack is gone').toBeTruthy(); + const list = within(stack as HTMLElement); for (const g of [...GATES_A, ...GATES_B]) { - expect(screen.getAllByText(g.name).length).toBeGreaterThan(0); + expect(list.getAllByText(g.name).length).toBeGreaterThan(0); } for (const p of PROVIDERS) { - expect(screen.getAllByText(p.name).length).toBeGreaterThan(0); + expect(list.getAllByText(p.name).length).toBeGreaterThan(0); } }); + it('draws a stand for every gate and a concourse for every adapter', () => { + // Without this the plate is untestable furniture: replace with + // an empty
    and every other test here still passes, because the + // stack alone carries all the names. + const { container } = render(); + expect(container.querySelectorAll('[data-diagram="airport"]').length).toBe(1); + expect(container.querySelectorAll('[data-stand]').length).toBe( + GATES_A.length + GATES_B.length, + ); + expect(container.querySelectorAll('[data-concourse]').length).toBe(CONCOURSES.length); + }); + it('shows both adapters as the two concourses', () => { - render(); - expect(screen.getAllByText('@threadplane/langgraph').length).toBeGreaterThan(0); - expect(screen.getAllByText('@threadplane/ag-ui').length).toBeGreaterThan(0); + // Read off the stack, for the reason above — the plate is aria-hidden, so + // matching the package names there proves nothing about what is announced. + // Each name is paired with its concourse: an adapter labelled with the + // other one's package would otherwise pass. + const { container } = render(); + const labels = Array.from( + container.querySelectorAll('.airport-stack .airport-stack-label'), + ).map((el) => el.textContent ?? ''); + for (const c of CONCOURSES) { + expect( + labels.some((t) => t.includes(c.label) && t.includes(c.pkg)), + `${c.label} is not labelled ${c.pkg}`, + ).toBe(true); + } }); it('marks every logo decorative, since the visible name carries the meaning', () => { @@ -58,10 +87,11 @@ describe('Compatibility', () => { expect(container.textContent).not.toMatch(/never sees/i); }); - it('links to the adapter guide', () => { - render(); - expect( - screen.getByRole('link', { name: 'Choose an adapter →' }).getAttribute('href'), - ).toBe('/docs/choosing-an-adapter'); + it('carries the adapter-guide CTA', () => { + // By its stable hook, not its copy: the label and the href belong to + // AdapterGuideLink and are asserted in AdapterGuideLink.spec.tsx. All this + // band owns is that the link is here. + const { container } = render(); + expect(container.querySelectorAll('[data-cta="home_adapter_guide"]').length).toBe(1); }); }); diff --git a/apps/website/src/components/landing/Compatibility.tsx b/apps/website/src/components/landing/Compatibility.tsx index fc7c416f0..e479568e5 100644 --- a/apps/website/src/components/landing/Compatibility.tsx +++ b/apps/website/src/components/landing/Compatibility.tsx @@ -27,13 +27,14 @@ import { TWY_N, TWY_S, VIEW, - WIDE_RATIO, type Gate, + type Row, + type Runway as RunwayGeometry, } from '../../lib/airport-diagram'; const R = STAND / 2; -function Runway({ y, h, left, right }: typeof RWY_N | typeof RWY_S) { +function Runway({ y, h, left, right }: RunwayGeometry) { return ( @@ -59,8 +60,6 @@ function TaxiwayLetter({ x, y, ch }: { x: number; y: number; ch: string }) { } /** A stand: the stub off the concourse, the white box, the mark, the callsign. */ -type Row = (typeof CONCOURSES)[number]['row']; - function Stand({ gate, row, above }: { gate: Gate; row: Row; above: boolean }) { const cy = row.standCy; const tick = above ? row.stubTop : row.stubBot; @@ -111,7 +110,7 @@ function Stand({ gate, row, above }: { gate: Gate; row: Row; above: boolean }) { function Plate() { const ticks: string[] = []; - for (let x = TICK_X; x < VIEW.width; x += TICK_X) { + for (let x = NEAT.x + TICK_X; x < NEAT.x + NEAT.width; x += TICK_X) { ticks.push( `M${x} ${NEAT.y} V${NEAT.y + 7}`, `M${x} ${NEAT.y + NEAT.height} V${NEAT.y + NEAT.height - 7}`, @@ -132,7 +131,11 @@ function Plate() { className="ap-svg" data-diagram="airport" viewBox={`0 0 ${VIEW.width} ${VIEW.height}`} - role="presentation" + // The whole plate leaves the accessibility tree, not just its root: + // role="presentation" is not inherited, so every on it ("09L", + // "2000 FT") would otherwise read out as unnamed chart noise. The + // .airport-stack list below is the band's accessible content. + aria-hidden="true" focusable="false" > @@ -168,8 +171,8 @@ function Plate() { - - + + {/* The one structure that IS Threadplane: solid ink. Partner stands are @@ -235,8 +238,9 @@ function Plate() { {OFF_AIRPORT_LABEL} {PROVIDERS.map((p, i) => { - const wide = p.src.endsWith('bedrock.svg'); - const w = wide ? PROVIDER_ROW.size * WIDE_RATIO : PROVIDER_ROW.size; + // Which marks are wordmarks is the table's fact to state, never the + // component's to re-derive from a filename. + const w = p.w ?? PROVIDER_ROW.size; const x = PROVIDER_ROW.x0 + i * PROVIDER_ROW.step; return ( ({ label: g.gate, src: g.src, s: g.s, w: g.w })), + ...PROVIDERS.map((p) => ({ label: p.name, src: p.src, s: PROVIDER_ROW.size, w: p.w })), +]; + describe('airport diagram geometry', () => { it('keeps all four rotated field corners inside the neat line', () => { const corners = [ @@ -178,17 +189,17 @@ describe('airport diagram geometry', () => { // is the only assertion here that ties the constant to something outside // the module, and it is what stops the `w` vs `s * WIDE_RATIO` check below // from closing back on itself. - for (const g of [...GATES_A, ...GATES_B]) { - if (g.w === undefined) continue; - const svg = readFileSync(resolve(WEBSITE, 'public', g.src.slice(1)), 'utf8'); + for (const m of MARKS) { + if (m.w === undefined) continue; + const svg = readFileSync(resolve(WEBSITE, 'public', m.src.slice(1)), 'utf8'); const viewBox = /viewBox="([^"]+)"/.exec(svg)?.[1]; - expect(viewBox, `${g.src} has no viewBox to measure`).toBeDefined(); + expect(viewBox, `${m.src} has no viewBox to measure`).toBeDefined(); const [, , vbW, vbH] = (viewBox as string).trim().split(/[\s,]+/).map(Number); - expect(vbW, `${g.src} viewBox width`).toBeGreaterThan(0); - expect(vbH, `${g.src} viewBox height`).toBeGreaterThan(0); + expect(vbW, `${m.src} viewBox width`).toBeGreaterThan(0); + expect(vbH, `${m.src} viewBox height`).toBeGreaterThan(0); expect( Math.abs(WIDE_RATIO - vbW / vbH), - `${g.gate}: WIDE_RATIO ${WIDE_RATIO} vs ${g.src} ${vbW}/${vbH} = ${vbW / vbH}` + `${m.label}: WIDE_RATIO ${WIDE_RATIO} vs ${m.src} ${vbW}/${vbH} = ${vbW / vbH}` ).toBeLessThanOrEqual(0.02); } }); @@ -199,14 +210,13 @@ describe('airport diagram geometry', () => { // Both numbers are literal, so this compares two independent values against // a ratio the test above pins to the file itself. A second wordmark is // allowed to join; an off-ratio one is not. - const all = [...GATES_A, ...GATES_B]; - for (const g of all) { - expect(g.s, `${g.gate} size`).toBeGreaterThan(0); - if (g.w !== undefined) { - expect(g.w, `${g.gate} width`).toBeGreaterThan(g.s); + for (const m of MARKS) { + expect(m.s, `${m.label} size`).toBeGreaterThan(0); + if (m.w !== undefined) { + expect(m.w, `${m.label} width`).toBeGreaterThan(m.s); expect( - Math.abs(g.w - g.s * WIDE_RATIO), - `${g.gate} w ${g.w} vs s ${g.s} x ${WIDE_RATIO} = ${g.s * WIDE_RATIO}` + Math.abs(m.w - m.s * WIDE_RATIO), + `${m.label} w ${m.w} vs s ${m.s} x ${WIDE_RATIO} = ${m.s * WIDE_RATIO}` ).toBeLessThanOrEqual(1); } } diff --git a/apps/website/src/lib/airport-diagram.ts b/apps/website/src/lib/airport-diagram.ts index a372348f3..23d8ef606 100644 --- a/apps/website/src/lib/airport-diagram.ts +++ b/apps/website/src/lib/airport-diagram.ts @@ -44,6 +44,14 @@ export const rotate = (x: number, y: number): { x: number; y: number } => { /** Outer extent of everything that rotates. Held so no corner leaves NEAT. */ export const FIELD = { x0: 56, x1: 944, y0: 58, y1: 419 } as const; +/** A runway strip: its top edge, its width, and the callsign at each threshold. */ +export interface Runway { + readonly y: number; + readonly h: number; + readonly left: string; + readonly right: string; +} + export const RWY_N = { y: 58, h: 11, left: '09L', right: '27R' } as const; export const RWY_S = { y: 408, h: 11, left: '09R', right: '27L' } as const; export const TWY_N = 100; @@ -72,13 +80,22 @@ export const APRON_B = { x0: 232, x1: 910, y0: 292, y1: 372 } as const; * about its own centre. The stub is the leader line back to the concourse. */ export const STAND = 38; + +export interface Row { + readonly standCy: number; + readonly labelY: number; + readonly stubTop: number; + readonly stubBot: number; +} + export const ROW1 = { standCy: 140, labelY: 172, stubTop: 178, stubBot: 190 } as const; export const ROW2 = { standCy: 332, labelY: 364, stubTop: 288, stubBot: 306 } as const; /** * A mark that is not square is sized by width at this ratio; the AWS wordmark * is the only one so far, and it appears twice — at gate B6 and again in the - * margin provider row. Stated once so the two cannot drift apart. + * margin provider row. Both entries carry their own literal `w`, and the spec + * checks each against this ratio, so the two cannot drift apart. * * The number is not a taste call: it is the aspect of the artwork itself * (public/logos/providers/bedrock.svg, viewBox 0 0 256 153), and the spec reads @@ -150,6 +167,17 @@ export const CONCOURSES = [ }, ] as const; +export interface Provider { + readonly src: string; + readonly name: string; + /** + * Optical width, for a wordmark that is not square: always + * `PROVIDER_ROW.size * WIDE_RATIO`. Same escape hatch as `Gate.w`, so the + * component never has to ask which file a mark points at to know its shape. + */ + readonly w?: number; +} + /** * Outside the neat line is outside the airport. The claim is rendered as * geometry rather than asserted in prose. @@ -163,14 +191,14 @@ export const CONCOURSES = [ */ export const OFF_AIRPORT_LABEL = 'OFF AIRPORT — BEHIND YOUR BACKEND. THREADPLANE NEVER TALKS TO THEM.'; -export const PROVIDERS = [ +export const PROVIDERS: readonly Provider[] = [ { src: '/logos/providers/openai.svg', name: 'OpenAI' }, { src: '/logos/providers/anthropic.svg', name: 'Anthropic' }, { src: '/logos/providers/google.svg', name: 'Google' }, { src: '/logos/providers/azure.svg', name: 'Azure OpenAI' }, - { src: '/logos/providers/bedrock.svg', name: 'Amazon Bedrock' }, -] as const; -/** `y` is the marks' centre line; each is `size` tall and WIDE_RATIO wide if wide. */ + { src: '/logos/providers/bedrock.svg', name: 'Amazon Bedrock', w: 33 }, +]; +/** `y` is the marks' centre line; each is `size` tall and `w` wide if it is a wordmark. */ export const PROVIDER_ROW = { y: 518, size: 20, x0: 30, step: 76, labelY: 492 } as const; /** Chart furniture lives in the margin, never on the field. */ From 38d35a5fc6b095f2bce6bfa5bd8669b5b86fc30e Mon Sep 17 00:00:00 2001 From: Brian Love Date: Tue, 8 Sep 2026 16:51:42 -0700 Subject: [PATCH 11/16] test(website): guard that the plate stays out of the accessibility tree MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Removing aria-hidden from the SVG passed all 21 tests — the one contract the previous commit exists to establish was the one with no guard. role="presentation" does not inherit to descendants, so the plate's own text leaked to screen readers while the provider names, which live only in the stack, would have gone missing. Also starts both graticule loops at the frame they decorate; the x loop had been moved and the y loop left behind. Co-Authored-By: Claude Opus 5 --- .../src/components/landing/Compatibility.spec.tsx | 10 ++++++++++ apps/website/src/components/landing/Compatibility.tsx | 2 +- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/apps/website/src/components/landing/Compatibility.spec.tsx b/apps/website/src/components/landing/Compatibility.spec.tsx index a0878ce77..e1ff6d79c 100644 --- a/apps/website/src/components/landing/Compatibility.spec.tsx +++ b/apps/website/src/components/landing/Compatibility.spec.tsx @@ -64,6 +64,16 @@ describe('Compatibility', () => { } }); + it('keeps the whole plate out of the accessibility tree', () => { + // role="presentation" does NOT inherit to descendants, so the plate's own + // — runway ids, taxiway letters, "2000 FT" — leaked to screen + // readers as unnamed chart noise. aria-hidden takes the subtree with it, + // which is what leaves .airport-stack as the band's accessible content. + const { container } = render(); + const plate = container.querySelector('[data-diagram="airport"]'); + expect(plate?.getAttribute('aria-hidden')).toBe('true'); + }); + it('marks every logo decorative, since the visible name carries the meaning', () => { const { container } = render(); const marks = container.querySelectorAll('image, img.airport-mark'); diff --git a/apps/website/src/components/landing/Compatibility.tsx b/apps/website/src/components/landing/Compatibility.tsx index e479568e5..66f34bfc1 100644 --- a/apps/website/src/components/landing/Compatibility.tsx +++ b/apps/website/src/components/landing/Compatibility.tsx @@ -116,7 +116,7 @@ function Plate() { `M${x} ${NEAT.y + NEAT.height} V${NEAT.y + NEAT.height - 7}`, ); } - for (let y = TICK_Y; y < NEAT.y + NEAT.height; y += TICK_Y) { + for (let y = NEAT.y + TICK_Y; y < NEAT.y + NEAT.height; y += TICK_Y) { ticks.push( `M${NEAT.x} ${y} H${NEAT.x + 7}`, `M${NEAT.x + NEAT.width} ${y} H${NEAT.x + NEAT.width - 7}`, From 3a76385db8ffc5e6e8e900207050000620325e58 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Tue, 8 Sep 2026 17:03:40 -0700 Subject: [PATCH 12/16] feat(website): style the compatibility airport diagram and its phone form MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the `.compatibility-*` block with the airport-diagram styles: the plate's airfield, buildings, stands and margin furniture, plus the head, footer and the CTA rule that `AdapterGuideLink` needs because it replaces its className rather than appending to it. The yellow ground is load-bearing rather than decoration — every mark on the plate is a dark logo drawn for a light ground, which is why they render bare with no chip. Two values carry the diagram's meaning with no legend: partner stands are white, the main terminal is ink. `.airport-stack` is the band's only accessible content: the plate is aria-hidden and the five model-provider names exist nowhere else. So it is VISUALLY hidden on desktop with the `.stage-skip` idiom (absolute + 1px + clip-path) and never `display: none` — taking it out of the tree would leave the section empty to a screen reader. Under 768px the plate is hidden instead and the same gate table renders as an HTML list, the `.arch-stack` precedent. Co-Authored-By: Claude Opus 5 --- .../components/landing/Compatibility.spec.tsx | 14 + apps/website/src/styles/landing.css | 323 ++++++++++++++++-- 2 files changed, 300 insertions(+), 37 deletions(-) diff --git a/apps/website/src/components/landing/Compatibility.spec.tsx b/apps/website/src/components/landing/Compatibility.spec.tsx index e1ff6d79c..9932e8fcc 100644 --- a/apps/website/src/components/landing/Compatibility.spec.tsx +++ b/apps/website/src/components/landing/Compatibility.spec.tsx @@ -104,4 +104,18 @@ describe('Compatibility', () => { const { container } = render(); expect(container.querySelectorAll('[data-cta="home_adapter_guide"]').length).toBe(1); }); + + it('ships a phone form driven by the same gate table as the plate', () => { + // A seven-stand rotated airfield has no 390px form. The precedent is + // .arch-stack: hide the figure under 768px and show an HTML list built + // from the same data, never a sideways scroll. + const { container } = render(); + expect(container.querySelector('.airport-figure')).toBeTruthy(); + const stack = container.querySelector('.airport-stack'); + expect(stack).toBeTruthy(); + const items = stack!.querySelectorAll('.airport-stack-gates li'); + expect(items).toHaveLength(GATES_A.length + GATES_B.length); + expect(screen.getByRole('list', { name: /CONCOURSE A/ })).toBeTruthy(); + expect(screen.getByRole('list', { name: /CONCOURSE B/ })).toBeTruthy(); + }); }); diff --git a/apps/website/src/styles/landing.css b/apps/website/src/styles/landing.css index 841fed395..4877cde05 100644 --- a/apps/website/src/styles/landing.css +++ b/apps/website/src/styles/landing.css @@ -1991,60 +1991,226 @@ text-align: center; } -/* Compatibility — light ground on purpose: these marks are drawn for light - * backgrounds and were invisible on the dark band. */ -.compatibility-groups { - margin-top: 26px; -} -.compatibility-group { - padding: 16px 0; - border-top: 1px solid var(--color-border); +/* Compatibility — an FAA-style airport diagram on the signal surface + * (spec: docs/superpowers/specs/2026-09-08-compatibility-airport-diagram-design.md). + * + * The yellow ground is load-bearing, not decoration: every mark here is a dark + * logo drawn for a light ground (Anthropic #181818, CrewAI and Pydantic + * #111827, LangGraph #1C3C3C), so they render bare with no chip. That is why + * these marks were invisible on the dark band before #1067 split them out. + * + * Two values carry the meaning with no legend: partner stands are white, + * the main terminal — Threadplane — is ink. */ +.airport-head { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 24px; + padding-bottom: 18px; + margin-bottom: 40px; + border-bottom: 1.5px solid var(--color-border-strong); } -.compatibility-group-label { +.airport-eyebrow { font-family: var(--font-mono); font-size: 10px; font-weight: 700; - letter-spacing: 0.12em; - text-transform: uppercase; + letter-spacing: 0.17em; color: var(--color-text-muted); - margin: 0; + margin: 0 0 13px; } -.compatibility-items { - display: flex; - flex-wrap: wrap; - gap: 10px 26px; - list-style: none; - margin: 10px 0 0; - padding: 0; +.airport-heading { + font-family: var(--font-display); + font-size: clamp(30px, 5vw, 46px); + line-height: 1.02; + letter-spacing: -0.018em; + margin: 0; + max-width: 12ch; } -.compatibility-item { - display: inline-flex; - align-items: center; - gap: 8px; +.airport-chart-id { + text-align: right; + font-family: var(--font-mono); + font-size: 9.5px; + font-weight: 700; + letter-spacing: 0.13em; + line-height: 2; white-space: nowrap; - font-size: 14px; - font-weight: 500; - color: var(--color-text-primary); + color: var(--color-text-muted); + margin: 0; } -.compatibility-logo { - width: 17px; - height: 17px; - object-fit: contain; +.airport-figure { + margin: 0; +} +.ap-svg { + display: block; + width: 100%; + height: auto; +} + +/* Airfield */ +.ap-neat { + fill: none; + stroke: var(--color-ink); + stroke-width: 1.4; +} +.ap-tick { + fill: none; + stroke: var(--color-ink); + stroke-width: 1; + opacity: 0.6; +} +.ap-pavement { + fill: var(--color-ink); +} +.ap-rwy-id { + font-family: var(--font-mono); + font-size: 8.5px; + font-weight: 700; + letter-spacing: 0.08em; + fill: var(--color-signal); +} +.ap-taxiway, +.ap-stub, +.ap-link { + fill: none; + stroke: var(--color-ink); +} +.ap-taxiway { + stroke-width: 1.6; +} +.ap-stub { + stroke-width: 1.4; +} +.ap-link { + stroke-width: 3.5; +} +.ap-twy-disc { + fill: var(--color-signal); + stroke: var(--color-ink); + stroke-width: 1.2; +} +.ap-twy-letter { + font-family: var(--font-mono); + font-size: 8.5px; + font-weight: 700; + fill: var(--color-ink); +} +.ap-apron { + fill: none; + stroke: rgba(10, 10, 10, 0.3); + stroke-width: 1; + stroke-dasharray: 3 4; +} +.ap-hatch-line { + stroke: var(--color-ink); + stroke-width: 0.9; + opacity: 0.7; +} + +/* Buildings */ +.ap-main { + fill: var(--color-ink); +} +.ap-plane { + fill: var(--color-signal); +} +.ap-main-title { + font-family: var(--font-display); + font-size: 20px; + fill: var(--color-signal); +} +.ap-main-sub { + font-family: var(--font-mono); + font-size: 8px; + font-weight: 700; + letter-spacing: 0.14em; + fill: rgba(255, 175, 0, 0.66); +} +.ap-conc { + fill: url(#ap-hatch); + stroke: var(--color-ink); + stroke-width: 1.4; +} +.ap-conc-plate { + fill: var(--color-signal); +} +.ap-conc-label { + font-family: var(--font-mono); + font-size: 9px; + font-weight: 700; + letter-spacing: 0.1em; + fill: var(--color-ink); +} +.ap-conc-pkg { + font-family: var(--font-mono); + font-size: 8px; + font-weight: 700; + letter-spacing: 0.06em; + fill: rgba(10, 10, 10, 0.78); +} + +/* Stands */ +.ap-stand-box { + fill: #ffffff; + stroke: var(--color-ink); + stroke-width: 1.5; +} +.ap-gate-tab { + fill: var(--color-ink); +} +.ap-gate-id { + font-family: var(--font-mono); + font-size: 7.5px; + font-weight: 700; + fill: var(--color-signal); +} +.ap-callsign { + font-family: var(--font-mono); + font-size: 8.5px; + font-weight: 700; + letter-spacing: 1px; + fill: rgba(10, 10, 10, 0.74); +} + +/* Margin */ +.ap-off { + font-family: var(--font-mono); + font-size: 9px; + font-weight: 700; + letter-spacing: 0.11em; + fill: var(--color-text-muted); +} +.ap-furniture { + opacity: 0.7; +} +.ap-furniture path { + fill: none; + stroke: var(--color-ink); + stroke-width: 1.2; +} +.ap-furniture .ap-north { + fill: var(--color-ink); + stroke: none; +} +.ap-furniture text { + font-family: var(--font-mono); + font-size: 7.5px; + font-weight: 700; + fill: var(--color-ink); } -.compatibility-footer { + +/* Footer */ +.airport-footer { display: flex; align-items: center; gap: 18px; flex-wrap: wrap; - border-top: 1px solid var(--color-border); + border-top: 1.5px solid var(--color-border-strong); + margin-top: 26px; padding-top: 18px; } /* AdapterGuideLink REPLACES its className rather than appending, so without a - * rule here the anchor renders as plain body text. The mono-uppercase - * treatment came from the dark band, where --color-accent was amber against - * white; on this light ground it resolves to navy against near-black ink — a - * very small hue step at 11px. The weight bump and the hover colour are what - * make it read as a CTA, not the case and tracking alone. */ + * rule here the anchor renders as plain body text. On the signal surface + * --color-accent resolves to ink, which is 10.73:1 on the yellow ground. */ .compatibility-link { font-family: var(--font-mono); font-size: 11px; @@ -2063,6 +2229,89 @@ margin: 0; } +/* Phone form: the same gates as an HTML list, driven by the same data. + * The plate is hidden here instead of scrolled sideways — the .arch-stack + * precedent from the architecture diagram. + * + * VISUALLY hidden on desktop, never `display: none`. The plate is + * aria-hidden, and the five provider names exist ONLY in this list, so + * display:none would leave the whole band with no accessible content on + * desktop. Same idiom as .stage-skip above. */ +.airport-stack { + position: absolute; + width: 1px; + height: 1px; + overflow: hidden; + clip: rect(0 0 0 0); + clip-path: inset(50%); + white-space: nowrap; +} +@media (max-width: 767px) { + .airport-figure { + display: none; + } + .airport-stack { + position: static; + width: auto; + height: auto; + overflow: visible; + clip: auto; + clip-path: none; + white-space: normal; + margin-top: 8px; + } + .airport-head { + display: block; + } + .airport-chart-id { + text-align: left; + margin-top: 16px; + } +} +.airport-stack-label { + font-family: var(--font-mono); + font-size: 10px; + font-weight: 700; + letter-spacing: 0.12em; + color: var(--color-text-secondary); + margin: 22px 0 10px; +} +.airport-stack-gates, +.airport-stack-providers { + list-style: none; + margin: 0; + padding: 0; + display: grid; + gap: 10px; +} +.airport-stack-providers { + grid-template-columns: repeat(2, 1fr); +} +.airport-stack-gates li, +.airport-stack-providers li { + display: flex; + align-items: center; + gap: 10px; + font-size: 14px; + font-weight: 500; + color: var(--color-text-primary); +} +.airport-stack-gate { + font-family: var(--font-mono); + font-size: 9px; + font-weight: 700; + letter-spacing: 0.08em; + background: var(--color-ink); + color: var(--color-signal); + border-radius: 2px; + padding: 3px 5px; +} +.airport-mark { + width: 22px; + height: 22px; + object-fit: contain; +} + /* Preflight checklist. * * Plain flex rows: every row starts at its column's left edge and its first From f024521bfd52a4b021bbfe7049d76d59497446c4 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Tue, 8 Sep 2026 17:08:25 -0700 Subject: [PATCH 13/16] fix(website): cut the hatch out from under the concourse package name The label's knockout plate covers only the line above it, so hatch strokes ran straight through the glyphs of @threadplane/langgraph. A painted-under halo cuts the hatch per glyph, which beats widening the plate: the plate is sized by a font-metric estimate, so anything depending on it breaks silently when the type changes. Co-Authored-By: Claude Opus 5 --- apps/website/src/styles/landing.css | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/apps/website/src/styles/landing.css b/apps/website/src/styles/landing.css index 4877cde05..b7a8367f0 100644 --- a/apps/website/src/styles/landing.css +++ b/apps/website/src/styles/landing.css @@ -2140,12 +2140,22 @@ letter-spacing: 0.1em; fill: var(--color-ink); } +/* The package name sits on the concourse's 45-degree hatch, and the label's + * knockout plate only covers the line above it — hatch strokes ran straight + * through the glyphs. A painted-under halo cuts the hatch per-glyph, which + * beats widening the plate: the plate is sized by a font-metric estimate + * (6.6 * length), so anything that depends on it breaks silently when the + * type changes. This does not. */ .ap-conc-pkg { font-family: var(--font-mono); font-size: 8px; font-weight: 700; letter-spacing: 0.06em; fill: rgba(10, 10, 10, 0.78); + paint-order: stroke; + stroke: var(--color-signal); + stroke-width: 3px; + stroke-linejoin: round; } /* Stands */ From bb118c639e654b26914056dc3a1c41234b8a2a25 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Tue, 8 Sep 2026 17:26:38 -0700 Subject: [PATCH 14/16] test(website): measure the airport plate's geometry in the browser MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Designing the compatibility band surfaced four label collisions that only a human eye caught: gate numbers over package labels, a taxiway drawn through the main terminal, sub-labels rendering outside a 26px concourse, and the scale bar on top of runway 09R. Eyes do not scale. This measures the rendered plate against lib/airport-diagram.ts so the next one fails a check instead of shipping. Six checks, each one of those four generalised: concourse type stays inside the building that names it; the concourse name plate — sized by a font-metric estimate, so nothing else holds it to reality — actually backs its label; every mark sits inside its stand; no two text runs on the plate overlap; no pavement crosses the main terminal; chart furniture and the off-airport row stay in the margin below the neat line. Counts come first. Every measurement walks a NodeList, and a walk over an empty list passes, so an unrendered plate would otherwise report no issues at all. Overlap between elements that do not share a coordinate system is the one comparison that cannot use getBBox directly: the airfield sits at a heading and each stand counter-rotates about its own centre, so an axis-aligned bound around tilted type reports collisions that are not there. Those transform each bbox's four corners into the plate's user space and separate the quads properly. Co-Authored-By: Claude Opus 5 --- apps/website/e2e/home-airport.spec.ts | 347 ++++++++++++++++++++++++++ 1 file changed, 347 insertions(+) create mode 100644 apps/website/e2e/home-airport.spec.ts diff --git a/apps/website/e2e/home-airport.spec.ts b/apps/website/e2e/home-airport.spec.ts new file mode 100644 index 000000000..8ea1e187a --- /dev/null +++ b/apps/website/e2e/home-airport.spec.ts @@ -0,0 +1,347 @@ +import { test, expect, type Page } from '@playwright/test'; +import { + CONCOURSES, + GATES_A, + GATES_B, + MAIN, + NEAT, + VIEW, +} from '../src/lib/airport-diagram'; + +const PLATE = '[data-diagram="airport"]'; +const STAND_COUNT = GATES_A.length + GATES_B.length; +const NEAT_BOTTOM = NEAT.y + NEAT.height; + +/** + * The concourse table, flattened to something structured-cloneable so the + * browser side can compare rendered type against the very constants the + * component drew from. Only the fields the measurements need. + */ +const CONCOURSE_BOXES = CONCOURSES.map((c) => ({ + id: c.id, + label: c.label, + x0: c.box.x0, + x1: c.box.x1, + y0: c.box.y0, + y1: c.box.y1, +})); + +/** + * Measures the rendered plate against lib/airport-diagram.ts. getBBox reports + * user space, so a containment check inside one coordinate system — text + * inside the concourse that owns it, a mark inside its stand, a taxiway + * against the main terminal — compares directly with the module's numbers. + * + * Overlap between two elements that do NOT share a coordinate system is the + * exception: the airfield sits at a heading and every stand counter-rotates + * about its own centre, so an axis-aligned box in one space is a tilted + * quadrilateral in another. Those comparisons transform each bbox's four + * corners into the root's user space and separate the quads properly, because + * an axis-aligned bound around tilted type reports collisions that are not + * there. + * + * Designing this band produced four collisions that only a human eye caught: + * gate numbers over package labels, a taxiway through the main terminal, + * sub-labels below a 26px concourse, and the scale bar on top of runway 09R. + * Every check here is one of those, generalised. + */ +async function overflowReport(page: Page) { + return page.evaluate( + ({ sel, concourses, main, neatBottom }) => { + const issues: string[] = []; + const svg = document.querySelector(sel); + if (!svg) return ['the airport plate is not on the page']; + + const r2 = (n: number) => Math.round(n * 10) / 10; + const box = (el: SVGGraphicsElement) => el.getBBox(); + const rect = (b: DOMRect | SVGRect) => ({ + x0: b.x, + y0: b.y, + x1: b.x + b.width, + y1: b.y + b.height, + }); + const label = (el: Element) => { + const cls = el.getAttribute('class'); + return cls ? `${el.tagName}.${cls}` : el.tagName; + }; + + type Pt = { x: number; y: number }; + + /** The element's bbox as four corners in the plate's own user space. */ + const rootCTM = svg.getScreenCTM(); + if (!rootCTM) return ['the airport plate is not being rendered']; + const toRoot = rootCTM.inverse(); + const quad = (el: SVGGraphicsElement): Pt[] | null => { + const ctm = el.getScreenCTM(); + if (!ctm) return null; + const m = toRoot.multiply(ctm); + const b = el.getBBox(); + const p = (x: number, y: number) => + new DOMPoint(x, y).matrixTransform(m); + return [ + p(b.x, b.y), + p(b.x + b.width, b.y), + p(b.x + b.width, b.y + b.height), + p(b.x, b.y + b.height), + ]; + }; + + /** Separating-axis test on two convex quads. Touching is not overlapping. */ + const quadsOverlap = (a: Pt[], b: Pt[]) => { + for (const poly of [a, b]) { + for (let i = 0; i < poly.length; i += 1) { + const p0 = poly[i]; + const p1 = poly[(i + 1) % poly.length]; + const nx = -(p1.y - p0.y); + const ny = p1.x - p0.x; + let aMin = Infinity; + let aMax = -Infinity; + let bMin = Infinity; + let bMax = -Infinity; + for (const v of a) { + const d = v.x * nx + v.y * ny; + aMin = Math.min(aMin, d); + aMax = Math.max(aMax, d); + } + for (const v of b) { + const d = v.x * nx + v.y * ny; + bMin = Math.min(bMin, d); + bMax = Math.max(bMax, d); + } + if (aMax <= bMin || bMax <= aMin) return false; + } + } + return true; + }; + + // 1. Concourse type stays inside the building that names it. The first + // draft put an 8.5px sub-label 33 units down a 26-unit-tall concourse, + // so it rendered below the building entirely. + for (const c of concourses) { + const g = document.querySelector( + `${sel} [data-concourse="${c.id}"]` + ); + if (!g) { + issues.push(`concourse ${c.id}: not rendered`); + continue; + } + for (const t of g.querySelectorAll('text')) { + const b = rect(box(t)); + if (b.x0 < c.x0 || b.x1 > c.x1 || b.y0 < c.y0 || b.y1 > c.y1) { + issues.push( + `concourse ${c.id}: "${t.textContent}" at ${r2(b.x0)},${r2( + b.y0 + )}..${r2(b.x1)},${r2(b.y1)} escapes ${c.x0},${c.y0}..${c.x1},${ + c.y1 + }` + ); + } + } + + // 2. The name plate is sized by a font-metric estimate + // (6.6 * label.length + 13), so nothing but this holds it to the + // type it is supposed to knock out. A type change breaks it + // silently otherwise. + const plate = g.querySelector('.ap-conc-plate'); + const name = g.querySelector('.ap-conc-label'); + if (!plate || !name) { + issues.push(`concourse ${c.id}: missing name plate or label`); + continue; + } + const p = rect(box(plate)); + const n = rect(box(name)); + if (n.x0 < p.x0 || n.x1 > p.x1 || n.y0 < p.y0 || n.y1 > p.y1) { + issues.push( + `concourse ${c.id}: "${name.textContent}" at ${r2(n.x0)},${r2( + n.y0 + )}..${r2(n.x1)},${r2(n.y1)} is not backed by its plate ${r2( + p.x0 + )},${r2(p.y0)}..${r2(p.x1)},${r2(p.y1)}` + ); + } + } + + // 3. Every mark sits inside the stand box it is parked on. Both live in + // the stand's counter-rotated group, so the bboxes share a space. + for (const s of document.querySelectorAll( + `${sel} [data-stand]` + )) { + const gate = s.dataset['stand']; + const b = s.querySelector('[data-stand-box]'); + const img = s.querySelector('image'); + if (!b || !img) { + issues.push(`stand ${gate}: missing box or mark`); + continue; + } + const bb = rect(box(b)); + const mm = rect(box(img)); + if (mm.x0 < bb.x0 || mm.y0 < bb.y0 || mm.x1 > bb.x1 || mm.y1 > bb.y1) { + issues.push( + `stand ${gate}: mark ${r2(mm.x0)},${r2(mm.y0)}..${r2(mm.x1)},${r2( + mm.y1 + )} escapes its box ${r2(bb.x0)},${r2(bb.y0)}..${r2(bb.x1)},${r2( + bb.y1 + )}` + ); + } + } + + // 4. No two text runs anywhere on the plate may overlap — the gate + // numbers over the package labels, generalised to every pair. + const texts: { el: SVGTextElement; q: Pt[] }[] = []; + for (const t of document.querySelectorAll( + `${sel} text` + )) { + const q = quad(t); + if (!q) { + issues.push(`${label(t)} "${t.textContent}" is not rendered`); + continue; + } + texts.push({ el: t, q }); + } + for (let i = 0; i < texts.length; i += 1) { + for (let j = i + 1; j < texts.length; j += 1) { + if (quadsOverlap(texts[i].q, texts[j].q)) { + issues.push( + `type collides: "${texts[i].el.textContent}" (${label( + texts[i].el + )}) over "${texts[j].el.textContent}" (${label(texts[j].el)})` + ); + } + } + } + + // 5. No pavement is drawn through the main terminal. An early draft ran + // taxiway N straight across the building. + for (const pave of document.querySelectorAll( + `${sel} .ap-taxiway, ${sel} .ap-pavement` + )) { + const b = rect(box(pave)); + if ( + b.x0 < main.x1 && + b.x1 > main.x0 && + b.y0 < main.y1 && + b.y1 > main.y0 + ) { + issues.push( + `pavement ${label(pave)} at ${r2(b.x0)},${r2(b.y0)}..${r2( + b.x1 + )},${r2(b.y1)} crosses the main terminal ${main.x0},${main.y0}..${ + main.x1 + },${main.y1}` + ); + } + } + + // 6. Chart furniture and the off-airport row live in the margin, below + // the neat line. The scale bar was once drawn on top of runway 09R. + for (const m of document.querySelectorAll( + `${sel} .ap-furniture, ${sel} .ap-off, ${sel} > image` + )) { + const b = rect(box(m)); + if (b.y0 < neatBottom) { + issues.push( + `margin ${label(m)} reaches y=${r2( + b.y0 + )}, above the neat line at ${neatBottom}` + ); + } + } + + return issues; + }, + { + sel: PLATE, + concourses: CONCOURSE_BOXES, + main: { x0: MAIN.x0, x1: MAIN.x1, y0: MAIN.y0, y1: MAIN.y1 }, + neatBottom: NEAT_BOTTOM, + } + ); +} + +test.describe('homepage airport diagram', () => { + test('draws every structure and keeps the type set inside the one that owns it', async ({ + page, + }) => { + await page.setViewportSize({ width: 1440, height: 900 }); + await page.goto('/'); + // Count before measuring, and before waiting on the plate to scroll into + // view. Every measurement below walks a NodeList, and a walk over an empty + // list passes: without these an unrendered plate reports no issues at all. + // Counting first also means a missing plate fails saying so, rather than + // timing out on a locator that never resolves. + const plate = page.locator(PLATE); + await expect(plate).toHaveCount(1); + await expect(page.locator(`${PLATE} [data-stand]`)).toHaveCount( + STAND_COUNT + ); + await expect(page.locator(`${PLATE} [data-stand-box]`)).toHaveCount( + STAND_COUNT + ); + await expect(page.locator(`${PLATE} .ap-callsign`)).toHaveCount( + STAND_COUNT + ); + await expect(page.locator(`${PLATE} [data-concourse]`)).toHaveCount( + CONCOURSES.length + ); + await expect(page.locator(`${PLATE} .ap-conc-plate`)).toHaveCount( + CONCOURSES.length + ); + await expect(page.locator(`${PLATE} [data-main-terminal]`)).toHaveCount(1); + await expect(page.locator(`${PLATE} .ap-taxiway`)).toHaveCount(3); + await expect(page.locator(`${PLATE} .ap-furniture`)).toHaveCount(1); + + await plate.scrollIntoViewIfNeeded(); + await expect(plate).toBeVisible(); + + // Fonts must be loaded before measuring, or a fallback face lies about + // widths — the same trap as the architecture diagram's spec. + await page.evaluate(() => document.fonts.ready); + const issues = await overflowReport(page); + expect(issues, issues.join('\n')).toEqual([]); + }); + + test('keeps the whole drawing inside its viewBox', async ({ page }) => { + await page.setViewportSize({ width: 1440, height: 900 }); + await page.goto('/'); + await expect(page.locator(PLATE)).toHaveCount(1); + await page.locator(PLATE).scrollIntoViewIfNeeded(); + await page.evaluate(() => document.fonts.ready); + const drawn = await page.evaluate((sel) => { + const svg = document.querySelector(sel); + if (!svg) throw new Error(`nothing on the page matches ${sel}`); + const b = svg.getBBox(); + return { + left: b.x, + top: b.y, + right: b.x + b.width, + bottom: b.y + b.height, + }; + }, PLATE); + expect(drawn.left).toBeGreaterThanOrEqual(0); + expect(drawn.top).toBeGreaterThanOrEqual(0); + expect(drawn.right).toBeLessThanOrEqual(VIEW.width); + expect(drawn.bottom).toBeLessThanOrEqual(VIEW.height); + }); + + test('lists the same gates on a phone instead of scrolling the drawing sideways', async ({ + page, + }) => { + await page.setViewportSize({ width: 390, height: 844 }); + await page.goto('/'); + const stack = page.locator('.airport-stack'); + await page.locator('#compatibility').scrollIntoViewIfNeeded(); + await expect(stack).toBeVisible(); + await expect(page.locator('.airport-figure')).toBeHidden(); + await expect(stack.locator('.airport-stack-gates li')).toHaveCount( + STAND_COUNT + ); + await expect(stack.locator('.airport-stack-group')).toHaveCount( + CONCOURSES.length + ); + const wide = await page.evaluate( + () => document.documentElement.scrollWidth > window.innerWidth + ); + expect(wide, 'no horizontal page scroll on a phone').toBe(false); + }); +}); From 09976194fc8f726ba1f44493842654931127c8a0 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Tue, 8 Sep 2026 17:27:43 -0700 Subject: [PATCH 15/16] docs(website): correct the e2e commands the plan hands the implementer nx mangles the -- passthrough into 'unknown option --_=', so both task 5 steps failed before playwright even started. Co-Authored-By: Claude Opus 5 --- .../plans/2026-09-08-compatibility-airport-diagram.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/docs/superpowers/plans/2026-09-08-compatibility-airport-diagram.md b/docs/superpowers/plans/2026-09-08-compatibility-airport-diagram.md index 33e5f4aa3..02f9dd3df 100644 --- a/docs/superpowers/plans/2026-09-08-compatibility-airport-diagram.md +++ b/docs/superpowers/plans/2026-09-08-compatibility-airport-diagram.md @@ -1231,14 +1231,16 @@ test('the compatibility band does not scroll sideways on a phone', async ({ page - [ ] **Step 2: Run the e2e to verify it passes** -Run: `npx nx e2e website -- home-airport.spec.ts` +Run: `npx playwright test --config=apps/website/playwright.config.ts e2e/home-airport.spec.ts` + +(Do NOT use `npx nx e2e website -- ` — this Nx version mangles the `--` passthrough into `unknown option '--_='`. Also free ports 4308/4399/4506 first; a failing run leaves orphaned servers and the next run dies on `already used`.) Expected: PASS, 3 tests. If the first test reports issues, **fix the geometry in `airport-diagram.ts`, not the assertion.** The whole point of this spec is that it is the arbiter. - [ ] **Step 3: Run the homepage spine e2e, which must be untouched** -Run: `npx nx e2e website -- website.spec.ts` +Run: `npx playwright test --config=apps/website/playwright.config.ts e2e/website.spec.ts` Expected: PASS. `compatibility-heading` must still appear between `proof-heading` and `architecture-heading`. - [ ] **Step 4: Commit** From ea9f7cbd3e0d948dcb5d862df119271f6aeeb92e Mon Sep 17 00:00:00 2001 From: Brian Love Date: Tue, 8 Sep 2026 17:52:14 -0700 Subject: [PATCH 16/16] fix(website): make the airport band readable on a tablet and guard what it claims MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Final-review pass over the compatibility airport diagram. The plate was unreadable between 768px and ~1000px. `.ap-svg` is `width: 100%`, so the 1000-unit drawing scales with its container: at a 768px viewport that container is ~707px, the plate renders at 0.71, and callsigns land at 6.0px with gate ids at 5.3px. The HTML gate list now takes over at `max-width: 1023px` rather than 767px, so tablets get the readable list instead of a shrunken chart — not a sideways scroll, which the spec rules out for this band. A new e2e case at 900px proves it, and fails against the old breakpoint. Three guards that could not see their own subject: - The band's whole accessibility rests on `.airport-stack` being hidden visually (clip-path) and never with `display: none` — the plate is aria-hidden, so the five provider names and the "never talks to them" claim exist nowhere else in the DOM. Nothing asserted it. A style contract now pins the desktop rule, positively and negatively. - The "never talks to them" test read `container.textContent` unscoped, so the aria-hidden plate satisfied it. Deleting the stack's off-airport label left all ten tests green while the claim vanished from the phone form. It is scoped to `.airport-stack` now, like its neighbour. - The off-airport row itself was uncounted, and the e2e's margin check walks a NodeList that passes when empty. Deleting the whole provider row left both suites green. The counts sit beside the other `toHaveCount` assertions. `MS AGENT FWK` is an abbreviation a 38px stand forces, but it was also what the phone list and every screen reader got. `Gate.long` carries the whole name; the plate keeps the short one. Also: the chart id lost a double space, the spec doc's field extents and B6 size now match the code, `TPL` is recorded as a real assigned IATA code the way `AL-0059` was, and four plate fills stop hardcoding `--color-ink` / `--color-signal` as literals. Every rendered colour is unchanged — verified in the browser. Co-Authored-By: Claude Opus 5 --- apps/website/e2e/home-airport.spec.ts | 43 +++++++++++++++++++ .../components/landing/Compatibility.spec.tsx | 38 ++++++++++++++-- .../src/components/landing/Compatibility.tsx | 5 ++- apps/website/src/lib/airport-diagram.ts | 18 +++++++- apps/website/src/styles/landing.css | 25 ++++++++--- .../src/styles/style-contracts.spec.ts | 42 ++++++++++++++++++ ...08-compatibility-airport-diagram-design.md | 29 ++++++++++--- 7 files changed, 180 insertions(+), 20 deletions(-) diff --git a/apps/website/e2e/home-airport.spec.ts b/apps/website/e2e/home-airport.spec.ts index 8ea1e187a..9069f8327 100644 --- a/apps/website/e2e/home-airport.spec.ts +++ b/apps/website/e2e/home-airport.spec.ts @@ -5,6 +5,7 @@ import { GATES_B, MAIN, NEAT, + PROVIDERS, VIEW, } from '../src/lib/airport-diagram'; @@ -290,6 +291,13 @@ test.describe('homepage airport diagram', () => { await expect(page.locator(`${PLATE} [data-main-terminal]`)).toHaveCount(1); await expect(page.locator(`${PLATE} .ap-taxiway`)).toHaveCount(3); await expect(page.locator(`${PLATE} .ap-furniture`)).toHaveCount(1); + // The off-airport row is the section's central argument rendered as + // geometry — the five providers sit OUTSIDE the neat line because + // Threadplane never talks to them. Check 6 below measures where they are + // drawn, and a walk over an empty NodeList reports no issues, so without + // these two counts deleting the whole row leaves both suites green. + await expect(page.locator(`${PLATE} .ap-off`)).toHaveCount(1); + await expect(page.locator(`${PLATE} > image`)).toHaveCount(PROVIDERS.length); await plate.scrollIntoViewIfNeeded(); await expect(plate).toBeVisible(); @@ -324,6 +332,41 @@ test.describe('homepage airport diagram', () => { expect(drawn.bottom).toBeLessThanOrEqual(VIEW.height); }); + test('hands a tablet the gate list rather than a plate at 0.7 scale', async ({ page }) => { + // `.ap-svg` is width:100%/height:auto, so the 1000-unit plate scales with + // its container: at a 768px viewport that container is ~707px, the plate + // renders at 0.71, and callsigns land at 6.0px with gate ids at 5.3px. + // The other two cases here test 1440 and 390 and straddle the hole + // entirely, which is how it survived review. The stack therefore takes + // over at 1023px, not the usual 767px — and never as a sideways scroll, + // which the spec rules out for this band. + await page.setViewportSize({ width: 900, height: 900 }); + await page.goto('/'); + await page.locator('#compatibility').scrollIntoViewIfNeeded(); + + await expect(page.locator('.airport-figure')).toBeHidden(); + + // toBeVisible() is not enough on its own: on desktop the stack is hidden + // by clip-path at 1px square, which Playwright still calls visible. Its + // laid-out width is what says the list is the form a tablet actually gets. + const stack = page.locator('.airport-stack'); + await expect(stack).toBeVisible(); + const box = await stack.boundingBox(); + expect(box, 'the accessible stack is not laid out at all').not.toBeNull(); + expect( + box?.width ?? 0, + 'the gate list is still clipped to its 1px visually-hidden box at 900px' + ).toBeGreaterThan(200); + + await expect(stack.locator('.airport-stack-gates li')).toHaveCount( + STAND_COUNT + ); + const wide = await page.evaluate( + () => document.documentElement.scrollWidth > window.innerWidth + ); + expect(wide, 'no horizontal page scroll on a tablet').toBe(false); + }); + test('lists the same gates on a phone instead of scrolling the drawing sideways', async ({ page, }) => { diff --git a/apps/website/src/components/landing/Compatibility.spec.tsx b/apps/website/src/components/landing/Compatibility.spec.tsx index 9932e8fcc..0053ae388 100644 --- a/apps/website/src/components/landing/Compatibility.spec.tsx +++ b/apps/website/src/components/landing/Compatibility.spec.tsx @@ -28,7 +28,7 @@ describe('Compatibility', () => { expect(stack, 'the accessible stack is gone').toBeTruthy(); const list = within(stack as HTMLElement); for (const g of [...GATES_A, ...GATES_B]) { - expect(list.getAllByText(g.name).length).toBeGreaterThan(0); + expect(list.getAllByText(g.long ?? g.name).length).toBeGreaterThan(0); } for (const p of PROVIDERS) { expect(list.getAllByText(p.name).length).toBeGreaterThan(0); @@ -47,6 +47,26 @@ describe('Compatibility', () => { expect(container.querySelectorAll('[data-concourse]').length).toBe(CONCOURSES.length); }); + it('spells out in the list the name the stand had to abbreviate', () => { + // `MS AGENT FWK` exists because a 38px stand has room for nothing longer. + // The list has room, and it is what a screen reader hears, so the two + // surfaces get different strings on purpose — which is the whole reason + // `Gate.long` exists and the only thing that keeps it from rotting. + const { container } = render(); + const stack = container.querySelector('.airport-stack'); + expect(stack, 'the accessible stack is gone').toBeTruthy(); + const abbreviated = [...GATES_A, ...GATES_B].filter((g) => g.long); + expect(abbreviated.length, 'no gate carries a long form any more').toBeGreaterThan(0); + for (const g of abbreviated) { + const list = within(stack as HTMLElement); + expect(list.getAllByText(g.long as string).length).toBeGreaterThan(0); + expect(list.queryByText(g.name), `the stack still shows "${g.name}"`).toBeNull(); + // ...and the plate still draws the short one, or the abbreviation was + // simply a bug rather than a constraint. + expect(container.querySelector(`[data-stand="${g.gate}"]`)?.textContent).toContain(g.name); + } + }); + it('shows both adapters as the two concourses', () => { // Read off the stack, for the reason above — the plate is aria-hidden, so // matching the package names there proves nothing about what is announced. @@ -92,8 +112,17 @@ describe('Compatibility', () => { it('says Threadplane never talks to model providers, not that it never sees them', () => { // never-SEES is a data claim the docs do not support; never-TALKS-TO is // structural. This is the same failure mode #1067 had to correct. + // + // The positive half is scoped to .airport-stack, for the same reason as + // the gate-name test above: the plate carries this sentence too, as an + // aria-hidden , so an unscoped read of container.textContent stays + // green while the claim disappears from the phone form and from every + // accessible surface the band has. The negative half stays unscoped — + // "never sees" must not appear anywhere in the section, drawn or spoken. const { container } = render(); - expect(container.textContent).toMatch(/never talks to them/i); + const stack = container.querySelector('.airport-stack'); + expect(stack, 'the accessible stack is gone').toBeTruthy(); + expect(within(stack as HTMLElement).getByText(/never talks to them/i)).toBeTruthy(); expect(container.textContent).not.toMatch(/never sees/i); }); @@ -107,8 +136,9 @@ describe('Compatibility', () => { it('ships a phone form driven by the same gate table as the plate', () => { // A seven-stand rotated airfield has no 390px form. The precedent is - // .arch-stack: hide the figure under 768px and show an HTML list built - // from the same data, never a sideways scroll. + // .arch-stack: hide the figure below the breakpoint (1024px here, not the + // usual 768px — see landing.css) and show an HTML list built from the same + // data, never a sideways scroll. const { container } = render(); expect(container.querySelector('.airport-figure')).toBeTruthy(); const stack = container.querySelector('.airport-stack'); diff --git a/apps/website/src/components/landing/Compatibility.tsx b/apps/website/src/components/landing/Compatibility.tsx index 66f34bfc1..aa0f1894f 100644 --- a/apps/website/src/components/landing/Compatibility.tsx +++ b/apps/website/src/components/landing/Compatibility.tsx @@ -330,7 +330,10 @@ export function Compatibility() { loading="lazy" decoding="async" /> - {g.name} + {/* The plate draws `name` because a 38px stand has room for + nothing longer. This list has room, and it is what a + screen reader hears, so it spells the name out. */} + {g.long ?? g.name} ))}
diff --git a/apps/website/src/lib/airport-diagram.ts b/apps/website/src/lib/airport-diagram.ts index 23d8ef606..19ab18ba1 100644 --- a/apps/website/src/lib/airport-diagram.ts +++ b/apps/website/src/lib/airport-diagram.ts @@ -107,6 +107,13 @@ export interface Gate { readonly gate: string; readonly src: string; readonly name: string; + /** + * The unabbreviated name, when the 38px stand forced a short one. The plate + * always draws `name`; the HTML stack — the band's only accessible content, + * and its whole phone form — draws `long ?? name`, so a screen reader is + * never handed an abbreviation that exists purely for the drawing. + */ + readonly long?: string; /** Optical height. Deliberately per-mark; see the spec test. */ readonly s: number; /** Optical width, for a wordmark that is not square: always `s * WIDE_RATIO`. */ @@ -123,7 +130,14 @@ export const GATES_B: readonly Gate[] = [ { gate: 'B2', src: '/logos/runtimes/crewai.svg', name: 'CREWAI', s: 22, x: 380 }, { gate: 'B3', src: '/logos/runtimes/mastra.svg', name: 'MASTRA', s: 16, x: 492 }, { gate: 'B4', src: '/logos/runtimes/pydantic.svg', name: 'PYDANTIC AI', s: 21, x: 604 }, - { gate: 'B5', src: '/logos/runtimes/microsoft.svg', name: 'MS AGENT FWK', s: 19, x: 716 }, + { + gate: 'B5', + src: '/logos/runtimes/microsoft.svg', + name: 'MS AGENT FWK', + long: 'MICROSOFT AGENT FRAMEWORK', + s: 19, + x: 716, + }, // The AWS wordmark is not square, so it is the one mark sized by width. Both // numbers are written literally like every other value in this table; the // spec checks the pair against WIDE_RATIO, and WIDE_RATIO against the file. @@ -210,6 +224,6 @@ export const PLANE_PATH = 'M4 34.5 58 6 40 58l-11.5-16.5L36 22 20 37.5z'; export const EYEBROW = 'AIRPORT DIAGRAM'; export const HEADLINE = 'Every stack has a gate.'; -export const CHART_ID = ['THREADPLANE INTL (TPL)', 'ANGULAR · LANGGRAPH & AG-UI'] as const; +export const CHART_ID = ['THREADPLANE INTL (TPL)', 'ANGULAR · LANGGRAPH & AG-UI'] as const; export const DISCLAIMER = 'Compatibility, not endorsement — no company here is claimed as a customer.'; diff --git a/apps/website/src/styles/landing.css b/apps/website/src/styles/landing.css index b7a8367f0..67d321c00 100644 --- a/apps/website/src/styles/landing.css +++ b/apps/website/src/styles/landing.css @@ -2096,7 +2096,8 @@ } .ap-apron { fill: none; - stroke: rgba(10, 10, 10, 0.3); + stroke: var(--color-ink); + stroke-opacity: 0.3; stroke-width: 1; stroke-dasharray: 3 4; } @@ -2123,7 +2124,8 @@ font-size: 8px; font-weight: 700; letter-spacing: 0.14em; - fill: rgba(255, 175, 0, 0.66); + fill: var(--color-signal); + fill-opacity: 0.66; } .ap-conc { fill: url(#ap-hatch); @@ -2151,7 +2153,8 @@ font-size: 8px; font-weight: 700; letter-spacing: 0.06em; - fill: rgba(10, 10, 10, 0.78); + fill: var(--color-ink); + fill-opacity: 0.78; paint-order: stroke; stroke: var(--color-signal); stroke-width: 3px; @@ -2178,7 +2181,8 @@ font-size: 8.5px; font-weight: 700; letter-spacing: 1px; - fill: rgba(10, 10, 10, 0.74); + fill: var(--color-ink); + fill-opacity: 0.74; } /* Margin */ @@ -2239,14 +2243,15 @@ margin: 0; } -/* Phone form: the same gates as an HTML list, driven by the same data. +/* Narrow form: the same gates as an HTML list, driven by the same data. * The plate is hidden here instead of scrolled sideways — the .arch-stack * precedent from the architecture diagram. * * VISUALLY hidden on desktop, never `display: none`. The plate is * aria-hidden, and the five provider names exist ONLY in this list, so * display:none would leave the whole band with no accessible content on - * desktop. Same idiom as .stage-skip above. */ + * desktop. Same idiom as .stage-skip above, and pinned by a style contract + * in style-contracts.spec.ts because nothing else can see it break. */ .airport-stack { position: absolute; width: 1px; @@ -2256,7 +2261,13 @@ clip-path: inset(50%); white-space: nowrap; } -@media (max-width: 767px) { +/* 1023px, not the 767px this band's siblings use. `.ap-svg` is width:100%, so + * the 1000-unit plate scales with its container: at a 768px viewport that + * container is ~707px and the plate renders at 0.71, putting callsigns at 6.0px + * and gate ids at 5.3px. Everything from 768 to ~1000px was a chart nobody + * could read. A min-width plus a scrolling container is the other way out and + * the spec rules it out for this band, so tablets get the list. */ +@media (max-width: 1023px) { .airport-figure { display: none; } diff --git a/apps/website/src/styles/style-contracts.spec.ts b/apps/website/src/styles/style-contracts.spec.ts index 0418d5fda..03936f98d 100644 --- a/apps/website/src/styles/style-contracts.spec.ts +++ b/apps/website/src/styles/style-contracts.spec.ts @@ -470,6 +470,48 @@ describe('style contracts', () => { }); }); + /** + * The compatibility band's accessibility rests entirely on one CSS idiom, + * and nothing else in the repo can see it break. The plate is + * `aria-hidden="true"`, so `.airport-stack` is the section's only accessible + * content — and the five model-provider names and the "never talks to them" + * claim exist nowhere else in the DOM. Switch the desktop rule to + * `display: none` and every unit test and every e2e case stays green while a + * screen reader hears an empty section. A review of this band already caught + * exactly that once. + */ + describe('landing.css airport stack is hidden visually, never removed', () => { + const css = loadStylesheet('landing.css'); + const desktop = baseDeclarationsFor(css, '.airport-stack'); + const narrow = mediaBlock(css, '(max-width: 1023px)'); + + it('hides the desktop stack with the clip-path idiom', () => { + // Asserted positively first so the `not.toMatch` below cannot pass + // against an empty string if the rule is ever renamed away. + expect(desktop, '.airport-stack has no rule outside a media query').not.toBe(''); + expect(desktop).toMatch(/position:\s*absolute/); + expect(desktop).toMatch(/clip-path:\s*inset\(50%\)/); + expect(desktop).toMatch(/width:\s*1px/); + }); + + it('never takes the stack out of the accessibility tree', () => { + expect(desktop).not.toMatch(/display:\s*none/); + expect(desktop).not.toMatch(/visibility:\s*hidden/); + }); + + /** + * The plate and the stack must swap at the same width, or one viewport + * band gets both or neither. 1023px rather than the usual 767px is + * measured: `.ap-svg` is `width: 100%`, so at 768px the 1000-unit plate + * renders at ~0.71 and its callsigns land at 6px. + */ + it('swaps the plate for the stack at one breakpoint', () => { + expect(declarationsFor(narrow, '.airport-figure')).toMatch(/display:\s*none/); + expect(declarationsFor(narrow, '.airport-stack')).toMatch(/position:\s*static/); + expect(declarationsFor(narrow, '.airport-stack')).toMatch(/clip-path:\s*none/); + }); + }); + describe('landing.css stage act', () => { const css = loadStylesheet('landing.css'); diff --git a/docs/superpowers/specs/2026-09-08-compatibility-airport-diagram-design.md b/docs/superpowers/specs/2026-09-08-compatibility-airport-diagram-design.md index be9a7145f..5347ad2e6 100644 --- a/docs/superpowers/specs/2026-09-08-compatibility-airport-diagram-design.md +++ b/docs/superpowers/specs/2026-09-08-compatibility-airport-diagram-design.md @@ -62,7 +62,7 @@ A single SVG, `viewBox="0 0 1000 536"`. The frame does **not** rotate. - **Airfield group rotated −3.5°** about `(500, 230)`. Nothing on a real plate is axis-aligned, and this is the single cheapest signal that it is a chart rather - than a flowchart. Extents are held to x 44–944, y 58–419 so no rotated corner + than a flowchart. Extents are held to x 56–944, y 58–419 so no rotated corner crosses the neat line. - **Runways** 09L-27R (y 58, h 11) and 09R-27L (y 408, h 11): solid ink bars with knocked-out yellow designators. @@ -93,7 +93,12 @@ else's, ink is ours.** | B3 | `/logos/runtimes/mastra.svg` | MASTRA | 16 | | B4 | `/logos/runtimes/pydantic.svg` | PYDANTIC AI | 21 | | B5 | `/logos/runtimes/microsoft.svg` | MS AGENT FWK | 19 | -| B6 | `/logos/providers/bedrock.svg` | AWS STRANDS | 12 × 30 | +| B6 | `/logos/providers/bedrock.svg` | AWS STRANDS | 12 × 20 | + +`MS AGENT FWK` is an abbreviation the 38px stand forces. It is the plate's +label only: `Gate.long` carries `MICROSOFT AGENT FRAMEWORK` and the HTML stack +renders `long ?? name`, so the phone list and every screen reader get the whole +name — the one the band this replaced used. **Sizes are per-mark and non-negotiable.** One shared `height` reads wrong: Mastra is wide and heavy, Anthropic is a narrow wedge, Microsoft is a dense square. These @@ -142,11 +147,19 @@ this problem shape. - **`src/styles/landing.css`** — the `.compatibility-*` block (currently ~lines 2007–2075) is replaced. -## 4. Mobile +## 4. Narrow viewports + +Copies the `arch-stack` precedent, but at `@media (max-width: 1023px)` rather +than the usual 767px: the SVG figure is hidden and an HTML gate list is shown, +grouped by concourse, driven by the same exported gate table. Never a sideways +scroll. -Copies the `arch-stack` precedent verbatim: at `@media (max-width: 767px)` the -SVG figure is hidden and an HTML gate list is shown, grouped by concourse, driven -by the same exported gate table. Never a sideways scroll. +The wider breakpoint is measured, not a preference. `.ap-svg` is `width: 100%`, +so the 1000-unit plate scales with its container: at a 768px viewport the +container is ~707px and the plate renders at 0.71, putting callsigns at 6.0px and +gate ids at 5.3px. Everything between 768 and ~1000px is a chart nobody can read, +so tablets get the list instead. A `min-width` plus a scrolling container is +ruled out above. A seven-stand rotated airfield has no 390px form. This is a real share of the work, not a detail. @@ -190,5 +203,9 @@ items and one accessible-named list per group — none of which will exist. in today's homepage set, and there is no mark for it. Today's set is kept. - **The band gets taller** than the 634px it replaces, and sits directly above `EnterpriseArchitecture` — two large technical figures back to back. Accepted. +- **`TPL` is a real assigned IATA code** (Draughon-Miller Central Texas + Regional, Temple, TX), borrowed for the fictional `THREADPLANE INTL` because + the initials fit; it is recorded here rather than left to be discovered, the + same way `AL-0059` was. - **The control tower symbol is not built.** It was drawn and offered (T2); T1 was chosen. Available if the plate later reads as under-furnished.