Skip to content

Commit ea9f7cb

Browse files
bloveclaude
andcommitted
fix(website): make the airport band readable on a tablet and guard what it claims
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 <noreply@anthropic.com>
1 parent 0997619 commit ea9f7cb

7 files changed

Lines changed: 180 additions & 20 deletions

File tree

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

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import {
55
GATES_B,
66
MAIN,
77
NEAT,
8+
PROVIDERS,
89
VIEW,
910
} from '../src/lib/airport-diagram';
1011

@@ -290,6 +291,13 @@ test.describe('homepage airport diagram', () => {
290291
await expect(page.locator(`${PLATE} [data-main-terminal]`)).toHaveCount(1);
291292
await expect(page.locator(`${PLATE} .ap-taxiway`)).toHaveCount(3);
292293
await expect(page.locator(`${PLATE} .ap-furniture`)).toHaveCount(1);
294+
// The off-airport row is the section's central argument rendered as
295+
// geometry — the five providers sit OUTSIDE the neat line because
296+
// Threadplane never talks to them. Check 6 below measures where they are
297+
// drawn, and a walk over an empty NodeList reports no issues, so without
298+
// these two counts deleting the whole row leaves both suites green.
299+
await expect(page.locator(`${PLATE} .ap-off`)).toHaveCount(1);
300+
await expect(page.locator(`${PLATE} > image`)).toHaveCount(PROVIDERS.length);
293301

294302
await plate.scrollIntoViewIfNeeded();
295303
await expect(plate).toBeVisible();
@@ -324,6 +332,41 @@ test.describe('homepage airport diagram', () => {
324332
expect(drawn.bottom).toBeLessThanOrEqual(VIEW.height);
325333
});
326334

335+
test('hands a tablet the gate list rather than a plate at 0.7 scale', async ({ page }) => {
336+
// `.ap-svg` is width:100%/height:auto, so the 1000-unit plate scales with
337+
// its container: at a 768px viewport that container is ~707px, the plate
338+
// renders at 0.71, and callsigns land at 6.0px with gate ids at 5.3px.
339+
// The other two cases here test 1440 and 390 and straddle the hole
340+
// entirely, which is how it survived review. The stack therefore takes
341+
// over at 1023px, not the usual 767px — and never as a sideways scroll,
342+
// which the spec rules out for this band.
343+
await page.setViewportSize({ width: 900, height: 900 });
344+
await page.goto('/');
345+
await page.locator('#compatibility').scrollIntoViewIfNeeded();
346+
347+
await expect(page.locator('.airport-figure')).toBeHidden();
348+
349+
// toBeVisible() is not enough on its own: on desktop the stack is hidden
350+
// by clip-path at 1px square, which Playwright still calls visible. Its
351+
// laid-out width is what says the list is the form a tablet actually gets.
352+
const stack = page.locator('.airport-stack');
353+
await expect(stack).toBeVisible();
354+
const box = await stack.boundingBox();
355+
expect(box, 'the accessible stack is not laid out at all').not.toBeNull();
356+
expect(
357+
box?.width ?? 0,
358+
'the gate list is still clipped to its 1px visually-hidden box at 900px'
359+
).toBeGreaterThan(200);
360+
361+
await expect(stack.locator('.airport-stack-gates li')).toHaveCount(
362+
STAND_COUNT
363+
);
364+
const wide = await page.evaluate(
365+
() => document.documentElement.scrollWidth > window.innerWidth
366+
);
367+
expect(wide, 'no horizontal page scroll on a tablet').toBe(false);
368+
});
369+
327370
test('lists the same gates on a phone instead of scrolling the drawing sideways', async ({
328371
page,
329372
}) => {

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

Lines changed: 34 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ describe('Compatibility', () => {
2828
expect(stack, 'the accessible stack is gone').toBeTruthy();
2929
const list = within(stack as HTMLElement);
3030
for (const g of [...GATES_A, ...GATES_B]) {
31-
expect(list.getAllByText(g.name).length).toBeGreaterThan(0);
31+
expect(list.getAllByText(g.long ?? g.name).length).toBeGreaterThan(0);
3232
}
3333
for (const p of PROVIDERS) {
3434
expect(list.getAllByText(p.name).length).toBeGreaterThan(0);
@@ -47,6 +47,26 @@ describe('Compatibility', () => {
4747
expect(container.querySelectorAll('[data-concourse]').length).toBe(CONCOURSES.length);
4848
});
4949

50+
it('spells out in the list the name the stand had to abbreviate', () => {
51+
// `MS AGENT FWK` exists because a 38px stand has room for nothing longer.
52+
// The list has room, and it is what a screen reader hears, so the two
53+
// surfaces get different strings on purpose — which is the whole reason
54+
// `Gate.long` exists and the only thing that keeps it from rotting.
55+
const { container } = render(<Compatibility />);
56+
const stack = container.querySelector('.airport-stack');
57+
expect(stack, 'the accessible stack is gone').toBeTruthy();
58+
const abbreviated = [...GATES_A, ...GATES_B].filter((g) => g.long);
59+
expect(abbreviated.length, 'no gate carries a long form any more').toBeGreaterThan(0);
60+
for (const g of abbreviated) {
61+
const list = within(stack as HTMLElement);
62+
expect(list.getAllByText(g.long as string).length).toBeGreaterThan(0);
63+
expect(list.queryByText(g.name), `the stack still shows "${g.name}"`).toBeNull();
64+
// ...and the plate still draws the short one, or the abbreviation was
65+
// simply a bug rather than a constraint.
66+
expect(container.querySelector(`[data-stand="${g.gate}"]`)?.textContent).toContain(g.name);
67+
}
68+
});
69+
5070
it('shows both adapters as the two concourses', () => {
5171
// Read off the stack, for the reason above — the plate is aria-hidden, so
5272
// matching the package names there proves nothing about what is announced.
@@ -92,8 +112,17 @@ describe('Compatibility', () => {
92112
it('says Threadplane never talks to model providers, not that it never sees them', () => {
93113
// never-SEES is a data claim the docs do not support; never-TALKS-TO is
94114
// structural. This is the same failure mode #1067 had to correct.
115+
//
116+
// The positive half is scoped to .airport-stack, for the same reason as
117+
// the gate-name test above: the plate carries this sentence too, as an
118+
// aria-hidden <text>, so an unscoped read of container.textContent stays
119+
// green while the claim disappears from the phone form and from every
120+
// accessible surface the band has. The negative half stays unscoped —
121+
// "never sees" must not appear anywhere in the section, drawn or spoken.
95122
const { container } = render(<Compatibility />);
96-
expect(container.textContent).toMatch(/never talks to them/i);
123+
const stack = container.querySelector('.airport-stack');
124+
expect(stack, 'the accessible stack is gone').toBeTruthy();
125+
expect(within(stack as HTMLElement).getByText(/never talks to them/i)).toBeTruthy();
97126
expect(container.textContent).not.toMatch(/never sees/i);
98127
});
99128

@@ -107,8 +136,9 @@ describe('Compatibility', () => {
107136

108137
it('ships a phone form driven by the same gate table as the plate', () => {
109138
// A seven-stand rotated airfield has no 390px form. The precedent is
110-
// .arch-stack: hide the figure under 768px and show an HTML list built
111-
// from the same data, never a sideways scroll.
139+
// .arch-stack: hide the figure below the breakpoint (1024px here, not the
140+
// usual 768px — see landing.css) and show an HTML list built from the same
141+
// data, never a sideways scroll.
112142
const { container } = render(<Compatibility />);
113143
expect(container.querySelector('.airport-figure')).toBeTruthy();
114144
const stack = container.querySelector('.airport-stack');

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

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -330,7 +330,10 @@ export function Compatibility() {
330330
loading="lazy"
331331
decoding="async"
332332
/>
333-
<span>{g.name}</span>
333+
{/* The plate draws `name` because a 38px stand has room for
334+
nothing longer. This list has room, and it is what a
335+
screen reader hears, so it spells the name out. */}
336+
<span>{g.long ?? g.name}</span>
334337
</li>
335338
))}
336339
</ul>

apps/website/src/lib/airport-diagram.ts

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,13 @@ export interface Gate {
107107
readonly gate: string;
108108
readonly src: string;
109109
readonly name: string;
110+
/**
111+
* The unabbreviated name, when the 38px stand forced a short one. The plate
112+
* always draws `name`; the HTML stack — the band's only accessible content,
113+
* and its whole phone form — draws `long ?? name`, so a screen reader is
114+
* never handed an abbreviation that exists purely for the drawing.
115+
*/
116+
readonly long?: string;
110117
/** Optical height. Deliberately per-mark; see the spec test. */
111118
readonly s: number;
112119
/** Optical width, for a wordmark that is not square: always `s * WIDE_RATIO`. */
@@ -123,7 +130,14 @@ export const GATES_B: readonly Gate[] = [
123130
{ gate: 'B2', src: '/logos/runtimes/crewai.svg', name: 'CREWAI', s: 22, x: 380 },
124131
{ gate: 'B3', src: '/logos/runtimes/mastra.svg', name: 'MASTRA', s: 16, x: 492 },
125132
{ gate: 'B4', src: '/logos/runtimes/pydantic.svg', name: 'PYDANTIC AI', s: 21, x: 604 },
126-
{ gate: 'B5', src: '/logos/runtimes/microsoft.svg', name: 'MS AGENT FWK', s: 19, x: 716 },
133+
{
134+
gate: 'B5',
135+
src: '/logos/runtimes/microsoft.svg',
136+
name: 'MS AGENT FWK',
137+
long: 'MICROSOFT AGENT FRAMEWORK',
138+
s: 19,
139+
x: 716,
140+
},
127141
// The AWS wordmark is not square, so it is the one mark sized by width. Both
128142
// numbers are written literally like every other value in this table; the
129143
// 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';
210224

211225
export const EYEBROW = 'AIRPORT DIAGRAM';
212226
export const HEADLINE = 'Every stack has a gate.';
213-
export const CHART_ID = ['THREADPLANE INTL (TPL)', 'ANGULAR · LANGGRAPH & AG-UI'] as const;
227+
export const CHART_ID = ['THREADPLANE INTL (TPL)', 'ANGULAR · LANGGRAPH & AG-UI'] as const;
214228
export const DISCLAIMER =
215229
'Compatibility, not endorsement — no company here is claimed as a customer.';

apps/website/src/styles/landing.css

Lines changed: 18 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2096,7 +2096,8 @@
20962096
}
20972097
.ap-apron {
20982098
fill: none;
2099-
stroke: rgba(10, 10, 10, 0.3);
2099+
stroke: var(--color-ink);
2100+
stroke-opacity: 0.3;
21002101
stroke-width: 1;
21012102
stroke-dasharray: 3 4;
21022103
}
@@ -2123,7 +2124,8 @@
21232124
font-size: 8px;
21242125
font-weight: 700;
21252126
letter-spacing: 0.14em;
2126-
fill: rgba(255, 175, 0, 0.66);
2127+
fill: var(--color-signal);
2128+
fill-opacity: 0.66;
21272129
}
21282130
.ap-conc {
21292131
fill: url(#ap-hatch);
@@ -2151,7 +2153,8 @@
21512153
font-size: 8px;
21522154
font-weight: 700;
21532155
letter-spacing: 0.06em;
2154-
fill: rgba(10, 10, 10, 0.78);
2156+
fill: var(--color-ink);
2157+
fill-opacity: 0.78;
21552158
paint-order: stroke;
21562159
stroke: var(--color-signal);
21572160
stroke-width: 3px;
@@ -2178,7 +2181,8 @@
21782181
font-size: 8.5px;
21792182
font-weight: 700;
21802183
letter-spacing: 1px;
2181-
fill: rgba(10, 10, 10, 0.74);
2184+
fill: var(--color-ink);
2185+
fill-opacity: 0.74;
21822186
}
21832187

21842188
/* Margin */
@@ -2239,14 +2243,15 @@
22392243
margin: 0;
22402244
}
22412245

2242-
/* Phone form: the same gates as an HTML list, driven by the same data.
2246+
/* Narrow form: the same gates as an HTML list, driven by the same data.
22432247
* The plate is hidden here instead of scrolled sideways — the .arch-stack
22442248
* precedent from the architecture diagram.
22452249
*
22462250
* VISUALLY hidden on desktop, never `display: none`. The plate is
22472251
* aria-hidden, and the five provider names exist ONLY in this list, so
22482252
* display:none would leave the whole band with no accessible content on
2249-
* desktop. Same idiom as .stage-skip above. */
2253+
* desktop. Same idiom as .stage-skip above, and pinned by a style contract
2254+
* in style-contracts.spec.ts because nothing else can see it break. */
22502255
.airport-stack {
22512256
position: absolute;
22522257
width: 1px;
@@ -2256,7 +2261,13 @@
22562261
clip-path: inset(50%);
22572262
white-space: nowrap;
22582263
}
2259-
@media (max-width: 767px) {
2264+
/* 1023px, not the 767px this band's siblings use. `.ap-svg` is width:100%, so
2265+
* the 1000-unit plate scales with its container: at a 768px viewport that
2266+
* container is ~707px and the plate renders at 0.71, putting callsigns at 6.0px
2267+
* and gate ids at 5.3px. Everything from 768 to ~1000px was a chart nobody
2268+
* could read. A min-width plus a scrolling container is the other way out and
2269+
* the spec rules it out for this band, so tablets get the list. */
2270+
@media (max-width: 1023px) {
22602271
.airport-figure {
22612272
display: none;
22622273
}

apps/website/src/styles/style-contracts.spec.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -470,6 +470,48 @@ describe('style contracts', () => {
470470
});
471471
});
472472

473+
/**
474+
* The compatibility band's accessibility rests entirely on one CSS idiom,
475+
* and nothing else in the repo can see it break. The plate is
476+
* `aria-hidden="true"`, so `.airport-stack` is the section's only accessible
477+
* content — and the five model-provider names and the "never talks to them"
478+
* claim exist nowhere else in the DOM. Switch the desktop rule to
479+
* `display: none` and every unit test and every e2e case stays green while a
480+
* screen reader hears an empty section. A review of this band already caught
481+
* exactly that once.
482+
*/
483+
describe('landing.css airport stack is hidden visually, never removed', () => {
484+
const css = loadStylesheet('landing.css');
485+
const desktop = baseDeclarationsFor(css, '.airport-stack');
486+
const narrow = mediaBlock(css, '(max-width: 1023px)');
487+
488+
it('hides the desktop stack with the clip-path idiom', () => {
489+
// Asserted positively first so the `not.toMatch` below cannot pass
490+
// against an empty string if the rule is ever renamed away.
491+
expect(desktop, '.airport-stack has no rule outside a media query').not.toBe('');
492+
expect(desktop).toMatch(/position:\s*absolute/);
493+
expect(desktop).toMatch(/clip-path:\s*inset\(50%\)/);
494+
expect(desktop).toMatch(/width:\s*1px/);
495+
});
496+
497+
it('never takes the stack out of the accessibility tree', () => {
498+
expect(desktop).not.toMatch(/display:\s*none/);
499+
expect(desktop).not.toMatch(/visibility:\s*hidden/);
500+
});
501+
502+
/**
503+
* The plate and the stack must swap at the same width, or one viewport
504+
* band gets both or neither. 1023px rather than the usual 767px is
505+
* measured: `.ap-svg` is `width: 100%`, so at 768px the 1000-unit plate
506+
* renders at ~0.71 and its callsigns land at 6px.
507+
*/
508+
it('swaps the plate for the stack at one breakpoint', () => {
509+
expect(declarationsFor(narrow, '.airport-figure')).toMatch(/display:\s*none/);
510+
expect(declarationsFor(narrow, '.airport-stack')).toMatch(/position:\s*static/);
511+
expect(declarationsFor(narrow, '.airport-stack')).toMatch(/clip-path:\s*none/);
512+
});
513+
});
514+
473515
describe('landing.css stage act', () => {
474516
const css = loadStylesheet('landing.css');
475517

docs/superpowers/specs/2026-09-08-compatibility-airport-diagram-design.md

Lines changed: 23 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,7 @@ A single SVG, `viewBox="0 0 1000 536"`.
6262
The frame does **not** rotate.
6363
- **Airfield group rotated −3.5°** about `(500, 230)`. Nothing on a real plate is
6464
axis-aligned, and this is the single cheapest signal that it is a chart rather
65-
than a flowchart. Extents are held to x 44–944, y 58–419 so no rotated corner
65+
than a flowchart. Extents are held to x 56–944, y 58–419 so no rotated corner
6666
crosses the neat line.
6767
- **Runways** 09L-27R (y 58, h 11) and 09R-27L (y 408, h 11): solid ink bars with
6868
knocked-out yellow designators.
@@ -93,7 +93,12 @@ else's, ink is ours.**
9393
| B3 | `/logos/runtimes/mastra.svg` | MASTRA | 16 |
9494
| B4 | `/logos/runtimes/pydantic.svg` | PYDANTIC AI | 21 |
9595
| B5 | `/logos/runtimes/microsoft.svg` | MS AGENT FWK | 19 |
96-
| B6 | `/logos/providers/bedrock.svg` | AWS STRANDS | 12 × 30 |
96+
| B6 | `/logos/providers/bedrock.svg` | AWS STRANDS | 12 × 20 |
97+
98+
`MS AGENT FWK` is an abbreviation the 38px stand forces. It is the plate's
99+
label only: `Gate.long` carries `MICROSOFT AGENT FRAMEWORK` and the HTML stack
100+
renders `long ?? name`, so the phone list and every screen reader get the whole
101+
name — the one the band this replaced used.
97102

98103
**Sizes are per-mark and non-negotiable.** One shared `height` reads wrong: Mastra
99104
is wide and heavy, Anthropic is a narrow wedge, Microsoft is a dense square. These
@@ -142,11 +147,19 @@ this problem shape.
142147
- **`src/styles/landing.css`** — the `.compatibility-*` block (currently ~lines
143148
2007–2075) is replaced.
144149

145-
## 4. Mobile
150+
## 4. Narrow viewports
151+
152+
Copies the `arch-stack` precedent, but at `@media (max-width: 1023px)` rather
153+
than the usual 767px: the SVG figure is hidden and an HTML gate list is shown,
154+
grouped by concourse, driven by the same exported gate table. Never a sideways
155+
scroll.
146156

147-
Copies the `arch-stack` precedent verbatim: at `@media (max-width: 767px)` the
148-
SVG figure is hidden and an HTML gate list is shown, grouped by concourse, driven
149-
by the same exported gate table. Never a sideways scroll.
157+
The wider breakpoint is measured, not a preference. `.ap-svg` is `width: 100%`,
158+
so the 1000-unit plate scales with its container: at a 768px viewport the
159+
container is ~707px and the plate renders at 0.71, putting callsigns at 6.0px and
160+
gate ids at 5.3px. Everything between 768 and ~1000px is a chart nobody can read,
161+
so tablets get the list instead. A `min-width` plus a scrolling container is
162+
ruled out above.
150163

151164
A seven-stand rotated airfield has no 390px form. This is a real share of the
152165
work, not a detail.
@@ -190,5 +203,9 @@ items and one accessible-named list per group — none of which will exist.
190203
in today's homepage set, and there is no mark for it. Today's set is kept.
191204
- **The band gets taller** than the 634px it replaces, and sits directly above
192205
`EnterpriseArchitecture` — two large technical figures back to back. Accepted.
206+
- **`TPL` is a real assigned IATA code** (Draughon-Miller Central Texas
207+
Regional, Temple, TX), borrowed for the fictional `THREADPLANE INTL` because
208+
the initials fit; it is recorded here rather than left to be discovered, the
209+
same way `AL-0059` was.
193210
- **The control tower symbol is not built.** It was drawn and offered (T2); T1
194211
was chosen. Available if the plate later reads as under-furnished.

0 commit comments

Comments
 (0)