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.
| 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.
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:
r3-opening-head-variants.png — round 2 for reference, then the verifier's numbers applied literally (0.11 wide, centre 0.25, k 0.10, floor 0.51, bounce 0.20) and brackets around them. Result: narrowing the cutter without making it shallower turns each dish into a small dark hole (min 145, 1,737 px under 150) — a ball with holes, which the owner ruled out. The flatness was never the floor alone; it was the depth.r3-opening-head-variants-2.png — the same width, parametrised by real penetration depth (0.035 to 0.08 into the z 0.88 surface). At 0.045-0.06 the dishes are soft and keep a gradient (min 161-168). The no-bounce control (vN) falls to min 144 with 1,343 px under 150: the up-facing wall under the brow needs light from below, and the white deck is exactly that light. Two variants showed a 1-2 px dark speck at the bridge-cut junction and were dropped.r3-opening-head-variants-3.png — almond widths 0.12-0.13, heights 0.07-0.08, with and without the direction's 10° downward pitch. Two front-runners went through a yaw sweep (r3-opening-head-yaw-sweep.png, -9 / -4.5 / 0 / 4.5 / 9): the pitched one (vW) showed a 2 px speck at L 147 at yaw +9; the unpitched one (vR) stayed at 167-171 everywhere. vR is locked.After (index.html, sdHeadLocal and shadeHead).
sdEllipsoid(q - vec3(0.25, 0.20, 0.93), vec3(0.12, 0.08, 0.10)), ssub(..., 0.12). Achieved depth along y 0.20 (SDF port): 0.047 at x 0.15, 0.066 at 0.20-0.25, 0.049 at 0.30, 0.023 at 0.35. Width 0.24; the outer edge at x 0.37 of the 0.72 half-width (round 2: 0.41 plus a 0.16 blend, which reached the temples); bottom at y 0.12, above the fade.calcAO 1 − 0.65·occ (was 0.70); a ground-bounce term albedo · (1.0, 0.995, 0.985) · max(−n.y, 0) · 0.24 · ao after the sheen; floor max(col, 0.51) (was 0.60). The floor is now a guard the shading never reaches.CUT_EXT, headAlpha, the fogged dissolve, the deck's haze and collar, the camera lift.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.
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.
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):
slow = samples < 4 || median > 45 ms || mean > 135 ms (probeMinSamples, slowFrameMs, slowMeanFactor 3). The mean rule exists because a 4-sample window with one giant hitch still has a fast median (seen on SwiftShader: median 10 ms, mean 315 ms).hopelessFactor) go straight to the CSS realm; 0.3x the pixels rescues nothing that slow, and skipping the half-scale resize avoids a multi-second render-target rebuild on software GL. Otherwise one step to render scale 0.55, a second window, then the CSS realm.lagAfterMs, lagMaxMs). On a timer, a frame that takes seconds cannot delay the check unless the main thread itself is blocked.firstFrameMs).visibilitychange shifts the window and drops the next sample; the guard skips while hidden).window.__ets.probe now also reports meanMs and slow.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 |
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).
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.
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 |
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.
--ets-sun, --ets-ember, --ets-glow deleted from shared.css after a grep of index, mind and standard (no use of any of them, nor of --ets-sun-halo, which existed only to feed the --ets-glow alias, so it went too). The header comment now states that the retired ember exists in no token; the realm's sun glow is a shader constant. Live read: all four resolve empty, no var() on the opening is undefined.boot() first calls canvas.getContext('webgl2', { failIfMajorPerformanceCaveat: true }) on a throwaway canvas (released through WEBGL_lose_context) and falls back before any renderer exists. --disable-gpu: zero console errors, one warning [ets27] CSS realm: WebGL2 unavailable or software-rendered (bare probe), doors at opacity 1 (r3-opening-nogpu-1440.png; round 2 logged two three.js console.error lines here).@media (hover: none) and (pointer: coarse) { .js:not(.has-keys) #shortcutsToggle { display: none } }; the first keydown anywhere adds has-keys. Measured at 390x844 with mobile emulation and touch: toggle display: none, nudge display: none, door tags at opacity 1, the hint a single 19 px line (r3-opening-390x844-touch.png); after one Shift keydown the toggle is block.::before height 1 px at top −1 px, rgb(193, 17, 90), scaleX(1) on hover — r3-opening-hover-mind-1440.png). The eyebrow is mono-m: 14 px, +0.02 em, line-height 1.35 so the step costs no height. The door-B socket glow on hover (§3.3) stays skipped: it needs a dish-mask uniform and reduced motion would drop it anyway; recorded here as in round 2.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.
1440x900, 390x844, head-1440, head-390, hover-mind-1440, focus-map-1440, nojs-1440, nowebgl-1440, still-1440, head-still-1440, reduced-motion-1440, nudge-1440.head-variants, head-variants-2, head-variants-3, head-yaw-sweep, head-yaw-9, head-yaw+9.still-composite (1x over #ffffff | over #fafaf9), still-edge-zoom.steady400-1440, first1800-1440, swiftshader-1440, nogpu-1440.vp-1024x680, vp-1024x680-nudge, vp-1280x720, vp-1280x720-nudge, vp-1366x768, vp-1366x768-nudge, vp-375x667, vp-375x667-nudge, vp-360x640, vp-360x640-nudge.handoff-who-mind-1440, 390x844-touch.Earlier rounds' shots and reports are untouched.