Fix round 3 — the opening (index.html, shared.css, img/)

Fixer for the opening group · 2026-10-13 · files owned: prototype/index.html, prototype/shared.css, new prototype/img/ (the baked head stills). Work list: the open items of studio/verify-02-opening.md, in its priority order.

Method: headless Chrome for Testing (chromium-1234) through the Playwright library, ANGLE Metal, DPR 1 (the still render at DPR 3), served by python3 -I -m http.server 8703 --bind 127.0.0.1 from prototype/ (a second scratch server on 8713 served the head-variant copies). Both servers were stopped by exact PID at the end and the ports confirmed free. No headed browser, zero live Claude calls (port 8777 never contacted; mind.html reached only through the static server, where it falls back to demo). Shot prefix r3-opening-; every capture cited below was read as pixels before it was judged. Luminance probe: L = 0.2126 R + 0.7152 G + 0.0722 B on stored values (the round-2 measure), whole-head rectangle [520, 80, 400 x 400] at 1440x900, band under the cut [540, 437, 360 x 48], dark column runs at thresholds 150 (≥ 8 px) and 120 (≥ 4 px), histogram window ±2 levels around the shading floor (0.51 x 255 = 130). Contrast: WCAG relative luminance against the darkest pixel of the realm inside each text element's box (+2 px), over three drift samples with the DOM hidden.

Summary

Item Before (verifier's numbers) After
Decision 5, head hollows 0.28 wide at k 0.16, flat interiors, 8,096 px spike at L 165-166 (the 0.60 floor), goggles / sleep-mask read dishes 0.24 wide, 0.05 deep, k 0.12, ground-bounce term, floor 0.51 never reached: min L 170, 0 px under 150, 0 px within ±2 of the floor, dark runs 0, same at 390, at the still and at yaw -9…+9
Baked still none img/head-still.png 800x924 and img/head-still@2x.png 1600x1848, straight alpha, verified over #ffffff and #fafaf9: no halo, no rectangle, no fringe
N1 probe fails open 400 ms frames: 3 samples, passes; 1.8 s frame: 0 samples, passes; SwiftShader never falls back CSS realm at 3.4 s, 5.3 s and 6.2 s of wall time respectively
N3 nudge overflow bottoms 684 / 727 / 771 at 1024x680 / 1280x720 / 1366x768 reserved line inside the hint block; bottoms 665 / 708 / 753 / 882, scrollHeight = viewport at all four
N4 small phones 375x667 +14 px, 360x640 +29 px 667 / 640, also with the nudge; 390x844 unchanged at 844
N5 burst typing who at 40 ms → w who arrives whole; Enter, Backspace, /, door click, shortcuts-off all measured
N6 muted contrast 4.47:1 (this round measured 4.18:1 over a darker drifting bank) --muted #585753: 5.03:1 worst case, 7.3:1 on white
N7, N8, N9, N11 two three.js console.error lines without a GPU; dead tokens; toggle on touch; 2 px hairline, 12 px eyebrow bare WebGL probe, zero errors; four tokens deleted; toggle hidden until a keydown; 1 px hairline, 14 px eyebrow

Console: zero errors on every index.html load in the run (1440, 390, hover and focus, ?still=1, reduced motion, no-JS, ?nowebgl=1, ?yaw=, --disable-gpu, the seven viewports, the eight key cases, touch, the three slow-device emulations). The only warnings are the deliberate [ets27] CSS realm: … notices and, under forced SwiftShader, Chrome's own GL driver messages. The no-JS run shows the two modulepreload fetches blocked by Playwright's script disabling, as in round 2.

1. Decision 5 — the head

Before. Round 2 had removed the darkness (min 164, nothing under 150) but the two hollows read as grey goggles at 1440 and as a sleep mask in the crop. Measuring the round-2 SDF in a Python port of sdHeadLocal showed why: the brow surface at the socket sits at z 0.88, and the cutter at (0.27, 0.20, 0.75) r (0.14, 0.09, 0.07) bit 0.20 deep with a flat back face at z 0.68. The dish was a flat-bottomed pit a quarter of the head wide, and the 0.60 floor clamped its interior into one value.

Method. Three contact sheets of 8 variants each, rendered from scratch copies of index.html at the still frame (?still=1, uTime 11.3, yaw 0.07 rad), crop [520, 80, 400 x 400], read as pixels and probed:

After (index.html, sdHeadLocal and shadeHead).

Probe, final frames.

Frame min L p1 (non-sky) px < 150 px < 120 px within ±2 of the floor (128-132) dark runs under the cut, 150/≥8 and 120/≥4
r3-opening-1440x900.png, live settled (crop r3-opening-head-1440.png) 170.1 178 0 0 0 0 columns, longest run 0 / 0
r3-opening-still-1440.png (?still=1, crop r3-opening-head-still-1440.png) 170.4 179 0 0 0 —
r3-opening-390x844.png, live settled (crop r3-opening-head-390.png, [45, 95, 300 x 272], band [70, 335, 250 x 33]) 169.2 178 0 0 0 0 / 0
r3-opening-head-yaw-9.png / r3-opening-head-yaw+9.png 170.1 / 171.4 176 / 180 0 0 0 —
fine sweep, yaw -7.5 … +7.5 in 1.5° steps (scratch) 167.2-171.1 176-179 0 0 0 —

Histogram of the 1440 crop: bins 128-132 hold 0 px, bins 165-170 hold 1 px, and the darkest 2 % of non-sky pixels spread over levels 170-181 (round 2: 8,096 px in two bins). No spike anywhere near the floor; the dish interiors are a gradient.

Turned profile at ±9° (r3-opening-head-yaw-9.png, -yaw+9.png): the socket no longer meets the temple cutter, so there is no notch at hollow height. What remains on the turned side is a soft inflection where round 2's brow swelling meets the temple plane; the brief said to keep cranium, brow and bridge as they are, so it stays and is noted here.

Read as pixels: r3-opening-1440x900.png, r3-opening-head-1440.png, r3-opening-390x844.png, r3-opening-head-390.png show a smooth matte display-mannequin cranium from the bridge up, two shallow soft hollows well inside the silhouette with a visible gradient, the brow and bridge, and the white fogged dissolve. Not a skull, not a helmet, not a ball with holes, not goggles.

A ?yaw=<deg> parameter was added (fixed turn, like the existing ?debug=) so the profile can be checked deterministically.

2. The baked still (prototype/img/)

Render mode ?headonly=1 (implies the still frame): renderer alpha: true, premultipliedAlpha: false, preserveDrawingBuffer: true; the fragment shader returns the head alone as premultiplied (hc · ha, ha) — no deck, no sky, no waking mix — where hc is the shaded head already fogged toward white by the dissolve and by distance, exactly as the live composite uses it; the accumulation averages all four channels (premultiplied, so a 16-frame average stays correct); the copy pass un-premultiplies at half-float precision and writes white where nothing was drawn; a sub-pixel ray jitter per frame makes the 16-frame accumulation antialias the silhouette. Framing: the camera does not move — a lens shift of (0, 0.096) tan units and a 1.7x zoom (CONFIG.scene.headOnlyZoom, headOnlyShiftY), which is mathematically a crop of the settled 1440x900 frame, so the still is lit exactly as that frame. Pixel-ratio cap 3 in this mode (headOnlyPixelRatio); the DOM stage is hidden. For the live realm the accumulation and copy changes are no-ops (alpha is 1 there).

Capture. 1440x900 at DPR 3 → canvas.toDataURL → 4320x2700 straight RGBA. Alpha bbox 1825 x 2128 at (1193, 306); margins to the frame edges 1193 / 306 / 1302 / 266 px, so nothing is clipped. Cropped with a 64 px pad (1953 x 2256), then resampled to 1600 and 800 wide through an area filter on premultiplied float channels (uv run --script with Pillow + NumPy). Lanczos was tried first and rejected: it rang an opaque silhouette pixel down to L 162, a faint ring.

Outputs.

File Size Head extent (alpha > 0) Opaque down to (centre column) Dissolve reaches alpha 0 by
/Users/user/Projects/~CLIENT/~ZPRYME/ets27-site/prototype/img/head-still.png 800 x 924, 155 KB x 26-773 (748 wide), y 26-896 row 742 row 896
/Users/user/Projects/~CLIENT/~ZPRYME/ets27-site/prototype/img/head-still@2x.png 1600 x 1848, 432 KB x 52-1547 (1496 wide), y 52-1794 row 1486 row 1794

Transparent margin 26 px (1x) / 52 px (2x) on every side. The head occupies 93.5 % of the width: a display width w shows the head 0.935 · w wide. At the 1440x900 live framing the head is about 358 CSS px wide, so the still at width: 383px matches it (the @2x file serves retina). Still frame pose: yaw 0.07 rad (4°), no breath, intro 1 — the same pose as ?still=1 and reduced motion.

Checks from pixels (still.py, both sizes and the 3x crop): border alpha max 0 on all four edges; every partially covered pixel has straight luminance ≥ 170.2, equal to the darkest fully covered pixel (no dark fringe at any alpha); composited over #ffffff and over #fafaf9, zero edge pixels are darker than the darkest interior pixel (min edge luminance 170.5-170.6) and the margin composites to the background exactly (max deviation 0). r3-opening-still-composite.png (1x over white | over #fafaf9) and r3-opening-still-edge-zoom.png (left silhouette, 4x nearest) read as pixels: no halo, no rectangle edge, no premultiplied fringe.

3. N1 — the frame-time probe never fails open

Before (production as shipped by round 2, re-measured this round with rAF throttling): steady 400 ms frames → 3 samples, median 403 ms, passes, stays WebGL with the realm clock at 1.45 s after 12.4 s of wall time; a 1.8 s second frame then 120 ms frames → 0 samples, passes, stays WebGL at ~8 fps; forced SwiftShader (--use-angle=swiftshader --enable-unsafe-swiftshader, which passes failIfMajorPerformanceCaveat with no caveat) never fell back.

After (loop, CONFIG.budget):

Re-test (wall time from navigation; each run's capture shows the CSS realm with the doors visible):

Emulation Probe CSS realm at Capture
Steady 400 ms frames 3 frames in 1500 ms, median 403, mean 403 → hopeless 3.4 s r3-opening-steady400-1440.png
1.8 s second frame, then 120 ms 12 samples, median 122 → scale 0.55 at 4.3 s; lag guard: clock 1914 ms behind after 3164 ms 5.3 s r3-opening-first1800-1440.png
Forced SwiftShader 4 frames, median 10, mean 315 → hopeless 6.2 s (about 4.5 s of it is SwiftShader's shader compile before any frame; the DOM revealed at 1.5 s) r3-opening-swiftshader-1440.png
This machine, no throttle 178 samples, median 8.3, mean 8.4, slow false stays WebGL at scale 1 r3-opening-1440x900.png

4. N3 — the 10 s nudge has a reserved line

Before: .nudge was absolutely positioned under the hint; its bottom landed at 684 (1024x680), 727 (1280x720) and 771 (1366x768), so those pages scrolled after 10 s; 1440x900 fit by 1 px.

After: the nudge is the second line of the hint block (.hint is a two-row grid: .hint__line with the note and the toggle, then .nudge with min-height: 1.55em, 4 px apart). The line is reserved from the first frame, so nothing moves or overflows when the text fades in; under (hover: none) the line is not reserved because the nudge never fires there. To pay for the line on short laptops: stage row-gap: min(1rem, 1.5vh) (was 1.8vh), door padding min(1.75rem, 2.8vh) (was 3.1vh), fork-wrap gap min(12px, 1.4vh).

Measured after the nudge appears (scrollHeight equals the viewport in every case): nudge bottom 665 at 1024x680, 708 at 1280x720, 753 at 1366x768, 882 at 1440x900. Captures: r3-opening-vp-1024x680-nudge.png, r3-opening-vp-1280x720-nudge.png, r3-opening-vp-1366x768-nudge.png, r3-opening-nudge-1440.png (and the settled frames without the nudge, r3-opening-vp-<size>.png).

5. N4 — small phones fit

Before: 375x667 scrollHeight 681 (+14), 360x640 669 (+29); the crown sat under the corner tags.

After: @media (max-width: 560px) and (max-height: 700px): stage gaps 0.5rem and padding 0.75rem / 0.25rem, mark clamp(2.5rem, min(1.2rem + 6vw, 9vh), 6rem), lockup gap 0.25rem and no theme margin, fork gap 10 px, fork-wrap gap 6 px, door padding 0.75rem, hint line-height 1.3. In the framing, a shortTall factor (0 at 700 px tall, 1 at 580; CONFIG.scene.shortTallBelowPx / shortTallLift 0.20 / shortTallShrink 0.22) raises the look-at and enlarges the visible half height, so the head shrinks and its cut rises: at 375x667 the cut is at 246 px (was 250), --head-bottom 273 (276), the crown at about 47 px, clear of the tags at 29; at 360x640 cut 233 (240), head-bottom 259 (265).

Measured: 375x667 scrollHeight 667 at settle and with the nudge (bottom 643); 360x640 640 / 640 (628); 390x844 844 / 844 (801) — the 390 frame is above the 700 px threshold and keeps its round-2 framing. Captures: r3-opening-vp-375x667.png, -375x667-nudge.png, -360x640.png, -360x640-nudge.png, r3-opening-390x844.png. Read as pixels: the stack fits, the lockup sits under the fading chin, the crown is clear of the tags.

6. N5 — burst typing arrives whole

Before: who at 40 ms per key left w in the composer.

After: printable keys are buffered on the fork; a 350 ms pause (CONFIG.handoff.draftPauseMs) writes the whole buffer to sessionStorage['ets27.draft'] and navigates to mind.html#compose. Enter flushes at once; Backspace edits the buffer; / with an empty buffer opens the composer immediately (inside a burst it is a character); pointerdown on a door drops the buffer so a click never races the timer; nothing typed travels in a URL. mind.html is untouched (it already reads and removes the draft, fills #q, puts the caret at the end, never sends).

Measured, all with 0 turns sent and the draft removed on read:

Case Result
who at 40 ms per key /mind.html#compose, #q = who, caret 3, focused — r3-opening-handoff-who-mind-1440.png
/ #q empty, focused
w then a pause navigates 432 ms after the key, #q = w
wh, Backspace, o #q = wo
who + Enter navigates 193 ms after the first key, #q = who
w then a click on door A 60 ms later /standard.html, no draft left
shortcuts off (localStorage ets27.shortcuts = off), who URL unchanged, no draft
Meta+K, Ctrl+K, Tab, Tab, Space no navigation from the modifiers, Tab native, Space opens the focused door A

7. N6 — muted text over the clouds

Before: the verifier measured 4.47:1; this round's probe (three drift samples, DOM hidden) found a darker bank under door A's explainer: #64635f at 4.18:1 at both 1440x900 and 1280x720, the hint at 4.27-4.44, the eyebrow at 4.35-4.65.

After (shared.css): --ets-ash → #585753 (--muted). Measured against the darkest adjacent cloud pixel: explainer A 5.04:1 (1440) / 5.03:1 (1280), explainer B 5.27 / 5.23, hint 5.34 / 5.17, eyebrow 5.59 / 5.27, toggle 5.64 / 5.54, corners 6.8; 7.3:1 on white. #5c5b57 was measured first and gave 4.73:1 at the worst spot — too close, given that the clouds drift. --muted-strong (#3a3836) and --muted-quiet (#8a8a86) are unchanged and still ordered quiet < muted < strong. The token is shared: mind.html and standard.html (7 and 8 uses of var(--muted)) get the same darker grey.

8. N8, N7, N9, N11

9. Kept from round 2, re-verified on the final build

Exact fork copy and aria names (DOM read), the keyboard contract (Space, Tab, modifiers, arrows), the dated lockup (// MAY 3-6, 2027 // AUSTIN, TX, also in the no-JS frame), the raspberry-only palette (focus ring rgb(193, 17, 90) at 4 px offset — r3-opening-focus-map-1440.png), self-hosted three.js (vendor/ untouched), the [ skip ] intro skip and the realm clock jump, the no-JS CSS realm (r3-opening-nojs-1440.png), the forced CSS realm (r3-opening-nowebgl-1440.png), the reduced-motion still with the doors present from the first frame (r3-opening-reduced-motion-1440.png), the ?still=1 frame (r3-opening-still-1440.png), the phone stack with full-width doors 12 px apart (r3-opening-390x844.png). Context loss, the stall test and the 30 fps cap were not changed by this round and were not re-run.

10. Captures (prototype/shots/, prefix r3-opening-)

Earlier rounds' shots and reports are untouched.

11. Not applied, and notes