Critique 02 - interaction, accessibility and mobile

Round 2, lens c2i. Prototype files audited as they stood at 01:55-02:20 PDT on 2026-10-13 (sha256 prefixes, unchanged from start to end of the audit): index.html 55c4a6802c46, mind.html d15ecdb58696, standard.html 08aa4694ea58, shared.css 102415012919, server.py c702f7cc85e6, data/ets.sample.json ddf32a919d17.

Method. Static server on 127.0.0.1:8702 inside prototype/ (so /api/health is a 404 and both routes run their offline demo). The live streaming path was exercised without any model call by stubbing /api/health and /api/mind in Playwright and replaying the round-1 SSE captures (x1 speakers, x3 venue plus tickets), an error event and a request that never answers. Headless Chrome for Testing 1234 (GPU on, sRGB), viewports 1440x900 at DPR 1 and 390x844 at DPR 2 with isMobile and hasTouch. Contrast was measured from pixels: every visible text run was located in the DOM, the page was re-shot with all text made transparent, and the background under each run was sampled (median and 10th percentile), then combined with the computed text colour and the cumulative opacity of its ancestors. axe-core 4.14.0 was injected per state. Every screenshot listed at the end was read before it was judged. No server.py, no live calls, no secrets touched.

Verdict in one paragraph. The opening is keyboard-operable at a basic level (Tab order map then mind, arrows, Enter) and every fallback path renders, but none of the owner decisions for the fork have landed yet (copy, link names, the slash and typing behaviours, the dates line). The interactive route has one blocker: on a phone the page lays out 726 px wide inside a 390 px screen, so the send button and the right half of every answer are off-screen; a two-selector CSS fix was verified. Beyond that the interactive route lacks escape hatches on components, lets the head show through content, hides each new answer behind the composer, has no abort or timeout, exposes chips as list items rather than buttons, and has no visible focus on the composer input. The standard route's dock is a dialog in name only (focusable while closed, aria-modal without a trap), and on phones the merged answers land entirely behind the sheet. The muted grey token fails AA everywhere it carries information, including every sample-data marker.


Findings, ranked

Severity scale: blocker (a primary task cannot be completed), major (an owner decision is not met, or a WCAG A/AA failure on a core path), minor (WCAG or design gap with a workaround), nit.

Blocker

B1 - mind.html - on a phone the interactive route is laid out 726 px wide. Facts: at 390x844 with isMobile, innerWidth 726, documentElement.scrollWidth 726, .stage computed grid-template-columns 709.781px, #send right edge at x=726. The brand is clipped at the left, the H1 and every answer clipped at the right, the composer prompt and the send button off-screen (c2i-mind-empty-390.png, c2i-mind-answer-390.png). Without isMobile (a narrow desktop window) body overflow-x:hidden clips instead, and #send sits at x=688, unreachable. The round-1 capture mind-empty-390x844.png shows the same defect. Cause: .stage, .turn and .ui are grids with an implicit auto column; at max-width 560px .chips switches to flex-wrap:nowrap, so the min-content contribution of the chip row (about 710 px) sets the track width. Every answer type then overflows again inside .turn/.ui (measured 572-680 px with only the stage fixed). Fix (verified by injection: innerWidth 390 and scrollWidth 390 for who's speaking, day 2, where is it, tickets and partners; c2i-mind-390-with-grid-fix.png, c2i-mind-answer-390-fullfix.png):

.stage, .turn, .ui { grid-template-columns: minmax(0, 1fr); }

Major

M1 - index.html - the fork does not yet follow decision 1 (copy, link names, keys). DOM facts: labels ENTER THE SITE and ASK THE MIND, explainers The familiar way: pages, agenda, people. and Say what you need. Only that appears.; no // two ways in eyebrow, no // you can switch any time. line, no corner tags. Accessible names are the whole card text including the index: link // 01 ENTER THE SITE The familiar way: pages, agenda, people. (read aloud as slash slash zero one ...). Keyboard, measured on ?still=1: Tab order skip link, door A, door B (correct); ArrowRight and ArrowLeft move and wrap (correct); Enter on door B opens mind.html with #q focused and empty (correct); Space on a focused door does nothing (anchors ignore Space); slash, w, 1 and 2 pressed on the fork do nothing (URL unchanged, no composer). Fix: - Copy per section 3.2; make the index decorative (aria-hidden) and give each door its 3.2 name, which starts with the visible label so label-in-name holds: aria-label="Open the map: the full site with pages and navigation" and aria-label="Ask the mind: a conversational guide that shows only what you ask for". Corner tags [ browse ] / [ ask ] as aria-hidden spans shown on :hover and :focus-visible (always shown under hover:none). - One keydown handler on document, and pass the draft through sessionStorage, not the URL (see m5):

const typing = (el) => el && (el.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(el.tagName));
document.addEventListener('keydown', (e) => {
  if (e.defaultPrevented || e.isComposing || e.ctrlKey || e.metaKey || e.altKey || typing(e.target)) return;
  const door = e.target.closest && e.target.closest('.door');
  if (door && e.key === ' ') { e.preventDefault(); door.click(); return; }
  if (e.key === '/') { e.preventDefault(); openMind(''); return; }
  if (e.key.length === 1 && e.key !== ' ') { e.preventDefault(); openMind(e.key); }
});
function openMind(draft) { try { sessionStorage.setItem('ets27.draft', draft); } catch {} location.href = './mind.html'; }

In mind.html boot: read and remove ets27.draft, put it in #q, focus, and move the caret to the end. Tab is never intercepted. If the 1/2 shortcuts from section 3.3 are kept they must be excluded from the typing rule; I recommend dropping them (see m19).

M2 - index.html - the lockup dates line is not decision 2. DOM: .lockup__place reads // AUSTIN, TX // 2027 (desktop and 390). Required: // MAY 3-6, 2027 // AUSTIN, TX. At 390 the required string is about 275 px in 12 px Geist Mono with 0.14 em tracking, so it fits on one line in the 358 px column. Fix: CONFIG.copy.place and when, and the static markup, to the decision string (keep it real text for screen readers).

M3 - index.html - keyboard focus lands on invisible doors during the intro. Facts: at t=0.95 s two Tab presses focus door A while its computed opacity is 0 and filter blur(8px); the focus outline is drawn on the transparent element, so nothing at all is visible (c2i-fork-focus-before-reveal-1440.png). The doors reveal between 3.6 s and 5.3 s (the hint line by 5.8 s); no key, click or scroll fast-forwards the intro and there is no [ skip ]. A keyboard user can press Enter on a control they cannot see. Under reduced motion the doors are present at about 490 ms with 0 animations, which is correct. Fix: section 2 already specifies it. On the first keydown, pointerdown, wheel or touchstart, and on focusin inside #fork, add a class that completes the intro:

.intro-done .reveal { animation: none !important; opacity: 1; transform: none; filter: none; }

and set the shader clock to the settled state (elapsed = riseMs). Add the bottom-right [ skip ] link from section 2.

M4 - mind.html - the mind shows through rendered content (decision 6). Facts: #head is a fixed CSS egg in #realm (z-index 0, opacity 0.92) at the top centre; the stage content above it has no background, so every turn that scrolls past shows a grey disc behind it: behind Jeffrey Banks and Janie Gonzalez in receded grids, behind session titles and the tickets list (c2i-mind-after2-1440.png, c2i-mind-live-venue-1440.png, c2i-mind-error-1440.png, c2i-mind-headbleed-turn-nth-child-2-slist-1440.png, c2i-mind-headbleed-390-fullfix.png). Measured: session meta text over the disc down to 2.56:1 (worst 10th percentile), names stay readable. Fix, either option the decision allows:

#head { transition: opacity 480ms var(--ease-out); }
body:not([data-state="empty"]) #head { opacity: 0; }   /* fade fully once the first turn exists */

or move a 24 px head glyph into an opaque .topbar and remove #head from #realm after the first component.

M5 - mind.html - no rendered component carries an escape hatch (decision 7). Facts: across offline answers and replayed live answers, links to standard.html inside components = 0 for speaker_grid, session_list, venue_card, sponsor_wall; ticket_cta links only to Eventbrite. The only ways to the map are the topbar link and the error card. Fix: every renderer appends a plain link, deep-linked where an anchor exists:

const HATCH = { speaker_grid: ['./standard.html#lineup', '[ open the lineup ]'], session_list: (c) => [c.day ? './standard.html#panel-' + c.day : './standard.html#agenda', '[ open the agenda ]'],
  venue_card: ['./standard.html#venue', '[ open venue & travel ]'], ticket_cta: ['./standard.html#register', '[ open tickets ]'], sponsor_wall: ['./standard.html#partners', '[ open partners ]'] };

The day panels already have ids (panel-d1 to panel-d4) but are display:none unless selected, so standard.html must select the day named in the hash on load and on hashchange.

M6 - mind.html - each new answer lands behind the sticky composer. Facts (1440): after the second and third questions the first component of the newest turn starts at y=787 while .composer-wrap starts at y=789: 1 px visible, the rest below the fold (c2i-mind-after2-1440.png, c2i-mind-live-venue-1440.png). newTurn() scrolls the empty turn into view before it has height, and nothing scrolls after the components render. Focus then returns to #q, so a keyboard user cannot PageDown to the answer without leaving the input. Fix: after the done event (and after the offline script) scroll the turn so its ask line sits under the top bar, and keep content clear of the composer:

.turn { scroll-margin-top: 96px; }
.thread { padding-bottom: 8rem; }       /* at least the composer-wrap height */
html { scroll-padding-bottom: 8rem; }
requestAnimationFrame(() => ctx.turn.scrollIntoView({ block: 'start', behavior: reduce ? 'auto' : 'smooth' }));

Only auto-scroll if the visitor has not scrolled away during the stream.

M7 - mind.html and standard.html - the live region announces too much, too often, and never the state. Facts: mind.html puts aria-live=polite on the whole #thread and toggles aria-busy on it; when busy clears, the new turn's caption and every speaker name, title and session row become one announcement (in screen readers that ignore aria-busy, every delta is announced). standard.html puts aria-live=polite on #dockThread with no aria-busy at all while the offline script rewrites the caption every 10 ms in 2-character steps. Neither page has a status for thinking, waiting or errors. Fix: remove aria-live from both thread containers; add one visually hidden status per page and write to it three times per turn:

<p id="mind-status" class="visually-hidden" role="status" aria-live="polite"></p>

At send: Thinking. On done: the final say text plus a one-line summary of what appeared (Showing 8 speakers, sample data. / Pulled speakers into the lineup section.). On error: the mapped error line. Set aria-busy=true on the streaming .turn only.

M8 - mind.html - no timeout, no abort. Facts: with /api/mind never answering, after 2.5 s and 8 s the UI is unchanged: send disabled, head pulsing, a raspberry block cursor, no // one moment, no stop control, Escape does nothing (c2i-mind-hang-8s-1440.png). The code has no AbortController and no timer; the only end is the server's own 90 s client timeout plus one retry. Escape during an offline answer also does nothing. Fix (section 4.7 already specifies the timings):

const ctl = new AbortController(); const t20 = setTimeout(() => ctl.abort('timeout'), 20000);
const t2 = setTimeout(() => setStatus('// one moment'), 2000);
fetch('/api/mind', { method: 'POST', signal: ctl.signal, ... })

Add a [ stop ] button next to send while busy (and Escape in the composer) that calls ctl.abort(); on timeout show the unavailable state. Same in the standard dock.

M9 - mind.html - chips are not exposed as buttons. Facts: every chip is button role=listitem inside role=list, so the role overrides the button: the accessibility tree shows listitem: // who's speaking? and axe reports aria-allowed-role (4 nodes on the empty state, 8 after two answers). Chips are focusable and Enter/Space work, but a screen reader user hears list items, not actions. Fix: real list semantics around real buttons, and keep the slashes out of the name (see m23):

const ul = el('ul', 'chips'); for (const o of options) { const li = el('li'); const b = el('button', 'chip', o); b.type = 'button'; li.append(b); ul.append(li); }

M10 - mind.html and standard.html - the composer input has no visible focus. Facts: .composer input:focus{outline:none} (mind.html line 115) and .dock .composer input:focus{outline:none} (standard.html line 159); computed outline-style none, and nothing else changes (the 2 px rule is identical focused and unfocused). Pixel-confirmed at 1440 and 390 (c2i-mind-composer-focus-1440.png, c2i-mind-composer-focus-390-gridfix.png) and in the dock (c2i-standard-merge-1440.png, focus in #dq). WCAG 2.4.7. Fix: style the field, not the input:

.composer:focus-within { border-bottom-color: var(--accent); box-shadow: 0 1px 0 0 var(--accent); }

M11 - mind.html - the page loses its only heading after the first question. Facts: the sole heading is the opener h1, which is display:none once data-state is live; axe page-has-heading-one after two answers; components have no headings except the venue card's h4. Screen reader users cannot move between answers or components. Fix: a persistent visually hidden h1 (Ask the mind, ETS27); an h2 per turn (visually hidden, e.g. Answer: who's speaking); h3 for component titles (venue name, day label, speaker names in grids as h3 or a list with names in strong).

M12 - shared.css - the muted grey fails AA wherever it carries information. Facts: --muted is --ets-ash #8f8a82. It is used for the fork corner tags and hint line (the only instruction text), door index, every source line and kit head (which is where the sample markers live), session meta, fact labels, ticket details, key-value labels, the hero sample note and the composer placeholder. Measured: 3.23:1 on #faf8f4, 3.43:1 on #ffffff, 3.32:1 on the fork sky, 2.88:1 (worst 2.46:1) for the fork hint over cloud, 2.73:1 for the speaker-initials placeholder on mist #e9e5de, 2.36:1 in a receded turn (opacity 0.74). axe color-contrast serious: 54 nodes on standard.html (18 initials placeholders 2.73, 13 session meta 3.23, 6 ticket details 3.31, 6 partner tier labels 3.23, 5 dt 3.31, 4 price small 3.31, 1 day summary, 1 footer span 3.33) and 9 on mind.html after two answers. Fix: darken the token and keep the light grey only for purely decorative glyphs:

--ets-ash: #67625b;   /* 5.70 on #faf8f4, 6.04 on #fff, 5.31 on #f3f0ea, 4.81 on #e9e5de, 4.64 on the darkest fork sky #e4e1dd */
--ets-ash-deco: #8f8a82;   /* only for the // glyph itself */

(#6F6F6B, the design's gray-700, reaches only 4.76 on #faf8f4 and 4.44 on #f3f0ea.)

M13 - standard.html - the dock is a dialog in name only. Facts: closed, the aside sits at x=1440 with visibility visible and no inert, so its 7 controls stay in the tab order: after the footer links Tab goes dock trigger, close (x=1801), four chips, #dq, send, all off-screen (inViewport false). Open, aria-modal flips to true but there is no focus trap and nothing is inert: two Tabs from #dq went to the skip link at y=-3952. Because aria-modal=true tells screen readers the page is unavailable, the content the dock just merged into the page becomes unreachable for them. axe also flags role=dialog on aside (aria-allowed-role). Focus returns to the dock trigger even when the dock was opened from the hero Ask the mind button. Fix: closed: dock.inert = true and visibility:hidden after the slide. Desktop open: a non-modal complementary region (drop role=dialog and aria-modal; aside aria-label=Ask the mind), since the page stays usable beside it. Phone sheet: either a true modal (trap focus, inert the rest, aria-modal) or, better for merging, non-modal with a collapse control (see M14). Remember the invoking control and return focus to it.

M14 - standard.html - on a phone the merged answer is completely hidden behind the sheet. Facts at 390x844: the sheet is 76vh (641 px) from y=203; after who is speaking the page scrolls the lineup up and inserts the merged grid at y=289, so 0 px of it is visible (c2i-standard-merge-390.png). The only feedback is the dock note shown on the page, jump there. Section 8 asks for a 70 percent sheet that can be dragged down; there is no handle and no keyboard alternative. Fix: after a merge, collapse the sheet to a peek (about 96 px: caption line plus [ expand ]) and scroll the merged block to just under the 56-72 px nav; give the sheet [ collapse ] / [ expand ] buttons as the keyboard and switch-access equivalent of dragging.

M15 - standard.html - on desktop the dock covers part of the merge and its [ clear ]. Facts at 1440: the merged block spans the full content column (about 1180 px) but only 872 px of it are clear of the 440 px dock; the merge bar's clear button sits at x=1254 under the dock. Playwright could not click it (a dock chip intercepts the pointer) and when focused by keyboard its ring is fully covered (c2i-standard-clear-focus-obscured-1440.png): WCAG 2.4.11. The agenda merge also cuts session titles at the dock edge (c2i-standard-merge-agenda-1440.png). Fix: reserve the dock's width while it is open, e.g. body.dock-open main { padding-right: 440px } at widths above 820 px, and put [ clear ] at the start of the bar. Escape should clear a merge before it closes the dock (see m14).

M16 - standard.html - no main landmark, a banner in the middle, headings and lists missing. Facts: nav, header#about (the hero, which becomes a banner), sections with no main, footer, aside. axe: landmark-one-main, region (8 nodes), heading-order (h2 Venue and travel then h4 cards). Lineup cards and session rows are divs (no list), speaker names are divs (no heading), so 77 speakers and 50 sessions are one undifferentiated run of text for a screen reader. The skip link says Skip to the lineup. Fix: wrap hero and sections in main id=main; make the hero a section aria-labelledby its h1; venue card titles h3; lineup as ul/li with names as h3; sessions as ol/li with titles as h3 (or a table with time, title, speakers); skip link Skip to content pointing at #main.

M17 - standard.html - sample-data markers are missing where the sample data is. Facts: the fixture marks all 77 speakers, 50 sessions and 39 sponsors placeholder true and its usage_warning asks for a visible sample marker wherever placeholder is true. The page has one marker, the hero sample note (3.23:1, and at 390 the Ask pill covers 26 percent of it, c2i-standard-top-390.png). The Lineup, Agenda and Partners sections carry none (innerText checks: lineup false, agenda false, partners false). The dock's own components do say sample. Fix: add the marker to each section's eyebrow, e.g. // the lineup . sample: ets26 stand-ins, rendered at the new --ets-ash; keep the hero note; make the merge bar say sample when it pulls sample data.

Minor

m1 - mind.html - the retired ember is still a hover and focus fill. .cta:hover and .cta:focus-visible set background #e8a06a behind white text: 2.17:1, pixel-confirmed on focus (c2i-mind-cta-focus-1440.png). #head.think pulses an rgba(232,160,106) ring, a mark rather than sun glow. Fix: hover/focus fill --signal-ink #8F0C43 (white text 9.12:1) or keep ink and change only the ring; think pulse uses var(--accent) at low alpha or no colour at all.

m2 - standard.html - ember and black-on-black issues in hover states. .btn-ink:hover sets #e8a06a behind white text (2.17:1); footer .links a:hover is ember (9.06:1 but the colour is retired); the merge highlight keyframes flashlight are ember. Note for the fix: raw #C1115A on the black footer is only 3.27:1. Fix: register hover #8F0C43; footer hover white plus underline, or an on-dark raspberry such as #EC5C93 (6.11:1); reveal highlight as design 5.6 says, a 1 px var(--accent) outline for 2 s (static under reduced motion).

m3 - mind.html - error states show internal text and miss the designed state. With an error event the caption reads The mind is not configured (auth). verbatim, followed by a dashed box with open the full site (c2i-mind-error-1440.png). showError prefers ev.message over the copy map; bad_request is not mapped. Fix: map by code only, design copy // the mind is quiet right now - the map still works. with a door-style Open the map card; announce it through the status region (M7).

m4 - mind.html - the fixed top bar lets content overprint the escape link. Scrolling a thread at 390 puts session text under the translucent gradient bar; the brand and open the full site collide with FIRESIDE CHAT and the titles (c2i-mind-headbleed-390-fullfix.png). Fix: an opaque band behind the bar text, e.g. background: var(--bg) for the bar's own height with a 24 px fade below it.

m5 - mind.html - visitor text in URLs. The q deep link puts the question in the URL (and therefore in server, CDN and analytics logs and Referer headers); without JS, or when the fixture fails, the form submits GET to mind.html?q=... and nothing happens (D.nojs.submit: url /mind.html?q=test, 0 turns). Fix: pass drafts by sessionStorage (M1), strip q with history.replaceState after reading it, give the form method=post or hide it under .no-js, and attach the submit handler before the fixture loads.

m6 - mind.html - no-JS and data-failure states say nothing. No noscript line; chips are empty; the composer looks live (c2i-mind-nojs-390.png). Fix: noscript with // the mind needs JavaScript - the map works without it. plus a link to standard.html; .no-js .composer-wrap { display: none }.

m7 - mind.html - the phone keyboard is not handled. The viewport meta has no interactive-widget and there is no visualViewport handling. Under emulated resizes-content (viewport 390x500) and with B1 fixed, the composer stays visible at y=413-463 (c2i-mind-kbd-390-fullfix.png), but iOS Safari and Chrome Android default to overlaying the keyboard. Fix: add interactive-widget=resizes-content to the viewport meta and, for iOS, a visualViewport resize listener that sets a --kb inset and pads the sticky composer up by it. Verify on devices.

m8 - mind.html - touch targets below 44 px. At 390: chips 30 px tall, send 51x30, open the full site 168x22, brand 142x19 (input 32 px). They pass the 24 px AA minimum but not the 44 px target this audit was asked to hold. Fix: @media (pointer: coarse) { .chip, .composer button, .switch { min-height: 44px; } } with padding rather than font size.

m9 - mind.html - receded turns drop below legibility and never come back. At step 1 (opacity 0.74) muted text is 2.36:1 and graphite 5.25:1; step 2 is 0.40 and blurred (muted 1.54:1). Section 4.2 says hovering a receded step brings it forward; nothing does, and keyboard focus inside a receded turn (its chips) leaves it faded. Fix: .turn:hover, .turn:focus-within { opacity: 1; filter: none; transform: none; } and do not fade text below the step-1 value.

m10 - mind.html - the session list's header does not say sample. speaker_grid says 8 speakers . sample lineup and sponsor_wall says partners . sample, but session_list says Day 2 . Tuesday May 4; sample appears only in the low-contrast source line. Fix: append . sample to the kit head for any placeholder data.

m11 - standard.html - phone menu collisions and dismissal. The absolutely positioned Register overprints the Partners link (c2i-standard-menu-390.png, c2i-standard-menu-after-link-390.png), so Partners is partly covered; Escape does not close the menu; tapping a link scrolls but leaves the menu open over the page; the button has aria-expanded but no aria-controls; links are 16 px tall. Fix: put Register in the list flow as the last item, close on link activation and on Escape (returning focus to the button), add aria-controls, and give links 44 px rows.

m12 - standard.html - day tabs are half a tabs pattern. role=tablist and role=tab with aria-selected, but no aria-controls, no role=tabpanel or labelling on panels, no roving tabindex and no arrow keys (ArrowRight left focus on Mon 3; Tab moved to Tue 4). Fix: either the full pattern (arrows, Home/End, tabindex -1 on unselected tabs, panels role=tabpanel aria-labelledby) or drop the roles and use buttons with aria-pressed plus aria-controls. Day tabs are about 31 px tall (computed from CSS); give them 44 px on coarse pointers.

m13 - standard.html - the nav wordmark is styled as a partner chip. Two rules share .logo: the nav brand link and the partner chips (standard.html line 116). The later one wins, so the wordmark renders as a 12 px boxed mono chip (c2i-standard-top-1440.png, c2i-standard-top-390.png). Fix: rename the partner chip class to .partner.

m14 - standard.html - merges are not reversible from the keyboard as designed. Escape only closes the dock; merged content stays. Clicking [ clear ] removes the focused button, so focus falls to body. Fix: Escape with a merge present clears it first (second Escape closes), and after clearing move focus to the dock input or the section heading.

m15 - standard.html - without JavaScript the page is empty and has dead controls. Lineup, agenda, venue, tickets and partners render no content; both Register links are href=#; the Ask pill and the hero Ask the mind button are visible and do nothing; the countdown says loading (c2i-standard-nojs-1440.png). Fix for the prototype: real hrefs in the markup (Eventbrite URL; hero Ask the mind as a link to mind.html that JS upgrades to open the dock), .no-js .dock-trigger { display: none }, a noscript line; the build is server-rendered per section 7.4.

m16 - standard.html - the Ask pill covers content on phones. At 390 the pill (92x56 at x=282, y=772) overlaps 26 percent of the hero sample note; the body has no bottom padding for it. Fix: body { padding-bottom: calc(56px + 2rem + env(safe-area-inset-bottom)); } and keep the pill clear of the footer link row.

m17 - index.html - the phone fork is not the section 8 layout. At 390 the doors are side by side, 173x150 each, the explainers drop to 11 px and the second door's explainer sinks to the bottom of its card (c2i-fork-390.png). Section 8 asks for full-width stacked cards (minimum 64 px tall, map above mind) with corner tags always shown. The current stack fits 844 px with 11 px to spare (hint line bottom 833); the decision copy adds an eyebrow, the under-note and corner tags, so re-check the fit after M1, shrinking --head-bottom on tall screens if needed.

m18 - index.html - the no-JS opening is a blank sky. With JS off the canvas is displayed but never drawn and the CSS realm stays hidden, so the upper half is empty white (c2i-fork-nojs-1440.png, c2i-fork-nojs-390.png). The doors work as plain links. Fix: .no-js #realm { display: none } .no-js .css-realm { display: block } (or the poster still from section 7.3).

m19 - general - single-character shortcuts need an off switch or a scope (WCAG 2.1.4, level A). Slash already opens the dock from anywhere on standard.html (it also fires with Ctrl or Cmd held and from select or contenteditable targets), and decision 1 adds slash and any printable character on the fork. Speech-input users dictating can trigger these. Fix: ignore modified keys, IME composition and all editable targets; and provide a [ shortcuts off ] toggle in the fork hint line and the dock, remembered in localStorage. Drop the 1/2 shortcuts.

m20 - shared.css - interactive outlines are below 3:1. --line rgba(11,11,12,0.26) is 1.82:1 on #faf8f4 and draws the boundary of every chip, the send and close buttons, the doors and the day tabs (WCAG 1.4.11 for the affordance). Fix: a separate --line-ui: rgba(11,11,12,0.45) (3.12:1) for interactive outlines; keep 0.26 for decorative rules.

m21 - data/ets.sample.json - an internal research note is shown to visitors. venue.note (The live site shows only 'Austin, Tx'. Eventbrite lists the location name 'Austin' with a street pin; treat that pin as unconfirmed.) is rendered as visitor copy in the mind's venue card and in the standard venue card (c2i-mind-answer-390-fullfix.png). Fix: add venue.public_note (for example: Venue to be announced. Austin, Texas, May 3-6, 2027.) and render only that; keep note for the team.

m22 - standard.html - dock placeholder colour is the browser default. No ::placeholder rule in the dock, so it renders #757575: 4.34:1 on #faf8f4. Fix: .dock .composer input::placeholder { color: var(--ets-ash); } once M12 lands (5.70:1).

m23 - mind.html - the decorative slashes are part of every chip's name. CSS content "// " on .chip::before is included in the button's accessible name (accessibility tree: // who's speaking?), and the .ask line's content ">" is read as greater than. Same in the standard dock chips. Fix: content: "// " / ""; and content: ">" / ""; (alt-text syntax) so the glyphs stay visual.

Nits

n1 - shared.css - focus ring colour. --focus is ink, not the signal ring in section 3.3. Ink measures 18.55:1 on #faf8f4 and 13.98:1 on the darkest fork sky; raspberry would be 5.66:1 and 4.27:1. Keep ink (it is the more robust ring over the realm) and record the deviation in the design direction, or use raspberry with a 2 px white halo.

n2 - shared.css - tokens that invite a second accent. --ets-signal #1b4bd6 is defined but unused; --ets-ember is still a general token. Delete the blue; rename ember to --sun-glow so it is only used where decision 3 allows.

n3 - mind.html - photo alt duplicates the name. img alt is Portrait of and the visible name follows immediately, so screen readers hear each name twice; initials placeholders (18 speakers, in both routes) read as letters. Fix: alt="" on card portraits and aria-hidden on initials; keep descriptive alt only on a standalone speaker_card.

n4 - standard.html - small naming and focus details. The pill's name is Ask (aria-label Ask the mind keeps label-in-name); the hero button's name includes the CSS arrow (Ask the mind right arrow); close could be Close the mind; return focus to whichever control opened the dock (hero or pill).

n5 - server.py - error messages carry internal reasons. error events include messages such as The mind is not configured (auth). and the client shows them (m3). Fix: keep code specific and message generic (The mind is quiet right now.), and log the reason server-side only.


Fork keyboard matrix (index.html, measured)

Input Required (decision 1, design 3.3) Actual
Tab / Shift+Tab skip link, map, mind skip link, map, mind (correct)
Enter on a door opens it opens it; door B lands on mind.html with #q focused, empty
Space on a door opens it nothing (anchor)
ArrowLeft / ArrowRight move between doors move and wrap (correct)
ArrowUp/Down, Home/End not specified nothing
/ (no focus) door B, composer focused nothing
printable, e.g. w door B with w in the composer nothing
1 / 2 map / mind (3.3) nothing (recommend dropping, m19)
Tab before 3.6 s (focus moves to door A at 5.9 s per section 2) focuses an invisible door, no visible ring (M3)
Focus ring 2 px signal, 4 px offset 2 px ink, 4 px offset, plus a 3 px lift; clearly visible (c2i-fork-focus-map-1440.png, c2i-fork-focus-390.png)

Contrast, every pair in use

Measured from pixels (median background, worst 10th percentile in brackets) and by token arithmetic. AA needs 4.5:1 for text under 24 px (18.66 px bold), 3:1 above, 3:1 for UI boundaries and focus rings.

Pair Where Ratio AA
#8f8a82 on fork sky #fdfbf7 fork corner tags, 12 px 3.32 (3.28) fail
#8f8a82 on cloud #edebe8 fork hint line, 12 px 2.88 (2.46) fail
#8f8a82 on door glass door index // 01, 11 px 2.83-3.01 fail
#3a3836 on door glass door explainers, 12 px 10.25-10.92 pass
#0b0b0c on fork ETS27 108 px, door labels 19.2 px 800 16.7-18.1 pass
#3a3836 on cloud dates line and theme line (1440 and 390) 8.8-9.8 pass
#8f8a82 on #faf8f4 mind foot, standard eyebrows' //, fact labels, session meta, kv dt, sample note 3.23 fail
#8f8a82 on #ffffff mind source lines and kit heads on white 3.43 fail
#8f8a82 on #e9e5de speaker initials placeholder, 25.6 px bold 2.73 fail (needs 3)
#8f8a82 at 0.74 on white receded turn: kit head, source line (incl. sample marker) 2.36 fail
#8f8a82 over the head disc session meta passing over #head 3.18 (2.56) fail
#3a3836 on #faf8f4 chips, explainers, nav links, body copy 11.01 pass
#3a3836 at 0.74 on white receded turn text 5.25 pass
#3a3836 at 0.40 on #fbf9f6 disabled send 2.15 exempt (inactive)
#0b0b0c on #faf8f4 headings, names 18.55 pass
#C1115A on #faf8f4 / #fff prompt >, demo mode tag, jump there link 5.66 / 6.01 pass
#C1115A on #0b0b0c (design's footer hover, not yet built) 3.27 fail for small text
#fff on #e8a06a mind .cta hover and focus, standard .btn-ink hover 2.17 fail
#e8a06a on #0b0b0c standard footer link hover 9.06 pass, but retired colour
#8f8a82 on #0b0b0c footer countdown, copyright 5.74 pass
#8f8a82 at 0.70 on #0b0b0c footer prototype on sample data 3.34 fail
#f3f0ea on #0b0b0c footer links 17.30 pass
#757575 on #faf8f4 dock placeholder (browser default) 4.34 fail
#8f8a82 on #fbf9f6 mind placeholder 3.26 fail (placeholders are best kept at 4.5)
ink ring on #faf8f4 / darkest fork sky focus ring 18.55 / 13.98 pass
rgba(11,11,12,0.26) on #faf8f4 chip, door, send, close outlines 1.82 below 3:1 (m20)
rgba(11,11,12,0.88) on #faf8f4 selected day tab, composer rule 14.03 pass

Proposed replacements: --ets-ash #67625b (5.70 on #faf8f4, 6.04 on white, 5.31 on #f3f0ea, 4.81 on #e9e5de, 4.64 on #e4e1dd); hover fill #8F0C43 with white (9.12); on-dark accent #EC5C93 (6.11 on #0b0b0c); --line-ui at 0.45 alpha (3.12).

Remaining uses of the old ember #e8a06a / rgba(232,160,106): mind.html line 58 (think pulse ring), line 176 (.cta hover and focus-visible); standard.html line 58 (.btn-ink:hover), line 125 (footer link hover), line 180 (merge flash keyframes). The sun glow rgba(255,217,184) remains in the realm gradients (allowed) and as a 1 px door hover and focus halo on index.html line 119 (nearly invisible; fine as glow).

Colour-only meaning: none found. The selected day tab differs by border weight and text darkness only (border 14.03:1 vs 1.82:1), which is a luminance difference, not hue; a non-colour cue (bracketing or a filled pill) would make it sturdier. The demo tag, error prefix and accent glyphs all carry text.

Touch targets (390x844, CSS px)

Control Size 44 px
Fork doors 173x150 yes
Skip links 175-202 x 35 no (only visible on focus)
mind: chips 123-208 x 30 no
mind: send 51x30 no
mind: open the full site 168x22 no
mind: brand link 142x19 no
mind: input 272x32 (after B1 fix) no
standard: wordmark, Menu 57x33, 54x33 no
standard: hero Register, Ask the mind 105x43, 159x43 1 px short
standard: dock pill 92x56 yes
dock: close, send 55x29, 45x29 no
dock: chips 138-207 x 30 no
dock: input 271x20 no
merge bar clear 46x23 no
menu links 41-65 x 16 no
day tabs about 31 tall no

All of these meet the 24 px AA minimum or its spacing exception; the 44 px goal set for this audit is met only by the doors and the dock pill.

Landmarks and headings (DOM)

Fallbacks and motion (measured)

Path index.html mind.html standard.html
prefers-reduced-motion still frame, doors present at about 490 ms, 0 animations (c2i-fork-reduced-early-1440.png) components 1e-06 s, cursor static, head and banks still scroll-behavior auto, dock transition 1e-06 s, flash suppressed
?still=1 still frame, doors present; 8 no-op reveal animations still run (the JS flag does not match the media-query rule's specificity) n/a n/a
?nowebgl=1 CSS realm; the reveal still runs, so at 3.5 s door A is at opacity 0.6 and blurred and door B is invisible (c2i-fork-nowebgl-1440.png) n/a (CSS only) n/a
WebGL disabled at launch three.js throws, page falls back to the CSS realm and settles (c2i-fork-nowebgl-real-1440.png) n/a n/a
JavaScript off blank sky, doors work as links (m18) composer looks live, does nothing (m6) empty sections, dead controls (m15)

Streaming, error and offline states (mind.html)

State Send Input Status for AT Visible Notes
In flight (offline script) disabled enabled thread aria-busy true streaming caption, raspberry cursor (c2i-mind-midstream-1440.png) Escape does nothing
In flight (stubbed live, 3 s) disabled enabled aria-busy true head pulse, empty caption with cursor (c2i-mind-inflight-1440.png) no // one moment
Hang, 8 s disabled enabled aria-busy true unchanged (c2i-mind-hang-8s-1440.png) no timeout, no stop (M8)
Error event unavailable enabled enabled announced only as thread text raw server message + open the full site (c2i-mind-error-1440.png) m3
Fetch aborted re-enabled enabled - falls back to the offline demo, demo mode tag shown good
Proxy absent - - - demo mode tag in raspberry (5.66:1) good

Standard dock: send disabled in flight, no aria-busy, no abort; the caption is rewritten every 10 ms inside an aria-live region (M7).

Escape hatches and sample markers per component (mind.html renderers)

Component Link to the map Sample marker
speaker_grid none kit head N speakers . sample lineup; source lineup sample . ets26 stand-ins (both 3.23-3.43, 2.36 when receded)
session_list none source line only: agenda sample . date (m10)
venue_card none kit head venue . to be announced; rows say TBD; internal note leaks (m21)
ticket_cta Eventbrite only not placeholder (verified 2026-10-13); source line says so
sponsor_wall none kit head partners . sample
text_block, choices n/a n/a

Merge into the standard page (measured)

Screenshots (prototype/shots, prefix c2i-)

Opening: c2i-fork-1440, c2i-fork-focus-map-1440, c2i-fork-focus-mind-1440, c2i-fork-skiplink-1440 (crop), c2i-fork-focus-before-reveal-1440 (crop, M3), c2i-fork-390, c2i-fork-focus-390, c2i-fork-reduced-early-1440, c2i-fork-reduced-1440, c2i-fork-still-1440, c2i-fork-nowebgl-1440 (3.5 s, mid-reveal), c2i-fork-nowebgl-real-1440, c2i-fork-nojs-1440, c2i-fork-nojs-390. Mind, desktop: c2i-mind-empty-1440, c2i-mind-composer-focus-1440 (crop, M10), c2i-mind-chip-focus-1440 (crop), c2i-mind-midstream-1440, c2i-mind-answer-1440, c2i-mind-after2-1440 (M4, M6), c2i-mind-headbleed-turn-nth-child-1-sg-1440, c2i-mind-headbleed-turn-nth-child-1-say-1440, c2i-mind-headbleed-turn-nth-child-2-slist-1440 (crops, M4), c2i-mind-inflight-1440, c2i-mind-live-answer-1440, c2i-mind-live-venue-1440 (M6), c2i-mind-cta-focus-1440 (crop, m1), c2i-mind-error-1440 (m3), c2i-mind-hang-8s-1440 (M8). Mind, phone, as shipped (B1 visible): c2i-mind-empty-390, c2i-mind-answer-390, c2i-mind-headbleed-390, c2i-mind-kbd-390, c2i-mind-nojs-390 (no chips rendered, so no overflow). Mind, phone, with fixes injected for evaluation only: c2i-mind-390-with-grid-fix (stage fix), c2i-mind-answer-390-gridfix, c2i-mind-kbd-390-gridfix, c2i-mind-headbleed-390-gridfix (stage fix only: answers still overflow), c2i-mind-composer-focus-390-gridfix (M10), c2i-mind-answer-390-fullfix (B1 fixed; m21 visible), c2i-mind-kbd-390-fullfix (m7), c2i-mind-headbleed-390-fullfix (M4, m4). Standard: c2i-standard-top-1440 (m13), c2i-standard-focus-nav-1440 (crop), c2i-standard-daytabs-focus-1440 (crop; shows initials placeholders and the wordmark collision, the tabs themselves are below the crop), c2i-standard-merge-1440 (M15), c2i-standard-merge-agenda-1440, c2i-standard-clear-focus-obscured-1440 (crop, M15), c2i-standard-register-focus-1440 (crop: keyboard focus keeps the ink fill; only hover turns ember), c2i-standard-top-390 (m16), c2i-standard-menu-390, c2i-standard-menu-after-link-390 (m11), c2i-standard-dock-390, c2i-standard-merge-390 (M14), c2i-standard-dock-kbd-390, c2i-standard-nojs-1440 (full page, m15).

What works and should be kept

Tab order and arrow keys on the fork; Enter into the mind lands with the composer focused; 2 px ink focus rings on doors, chips, nav links and buttons are clearly visible; every reduced-motion path is correct; the real no-WebGL fallback engages on its own; send is disabled while a turn is in flight; a failed fetch falls back to the offline demo and says so; Escape closes the dock and slash opens it; the dock pill is a 56 px target; merges respect the sticky nav; photos carry alt text; the demo-mode tag is legible.

Limits of this audit

No live model calls were made; streaming was replayed from round-1 captures, so token timing and long-answer behaviour were not observed. The software keyboard was emulated by shrinking the viewport (resizes-content); iOS Safari and Chrome Android overlay behaviour must be checked on devices. Screen-reader output was inferred from the accessibility tree and ARIA, not from a running screen reader.