luckiest-co 1.0.13 → 1.0.14

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (30) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/package.json +1 -1
  4. package/skills/luckiest-design-website/ATTRIBUTION.md +38 -0
  5. package/skills/luckiest-design-website/CHANGELOG.md +104 -0
  6. package/skills/luckiest-design-website/LICENSE +21 -0
  7. package/skills/luckiest-design-website/SKILL.md +372 -0
  8. package/skills/luckiest-design-website/UPSTREAM-CHANGELOG.md +526 -0
  9. package/skills/luckiest-design-website/engine/scrollcraft.css +432 -0
  10. package/skills/luckiest-design-website/engine/scrollcraft.js +1167 -0
  11. package/skills/luckiest-design-website/evals/evals.json +33 -0
  12. package/skills/luckiest-design-website/references/assets.md +286 -0
  13. package/skills/luckiest-design-website/references/device-diag.html +214 -0
  14. package/skills/luckiest-design-website/references/devices.md +466 -0
  15. package/skills/luckiest-design-website/references/feel.md +277 -0
  16. package/skills/luckiest-design-website/references/registry-examples.md +305 -0
  17. package/skills/luckiest-design-website/references/taste.md +304 -0
  18. package/skills/luckiest-design-website/references/template.html +138 -0
  19. package/skills/luckiest-design-website/references/uniqueness.md +480 -0
  20. package/skills/luckiest-design-website/references/verify.md +381 -0
  21. package/skills/luckiest-design-website/references/worldflight.md +349 -0
  22. package/skills/luckiest-design-website/references/worlds.md +178 -0
  23. package/skills/luckiest-design-website/scripts/doctor.mjs +177 -0
  24. package/skills/luckiest-design-website/scripts/encode.sh +80 -0
  25. package/skills/luckiest-design-website/scripts/kie.mjs +202 -0
  26. package/skills/luckiest-design-website/scripts/serve.mjs +52 -0
  27. package/skills/luckiest-design-website/scripts/shoot.mjs +644 -0
  28. package/skills/luckiest-design-website/scripts/workspace.mjs +106 -0
  29. package/skills/luckiest-design-website/scripts/worldflight-assert.mjs +273 -0
  30. package/skills/luckiest-design-website/templates/FINGERPRINTS.md +65 -0
@@ -0,0 +1,381 @@
1
+ # Verify
2
+
3
+ A scroll page cannot be checked by looking at it. It has no single state: every
4
+ scroll position is a different frame, and the failures live between the two you
5
+ happened to look at. So walk it mechanically.
6
+
7
+ ```bash
8
+ cd <build project>
9
+ npm i playwright-core # once
10
+
11
+ node <skill>/scripts/serve.mjs --root . --port 4500 &
12
+ node <skill>/scripts/shoot.mjs --url http://localhost:4500 --out lab/shots
13
+ node <skill>/scripts/shoot.mjs --url http://localhost:4500 --out lab/mobile --width 390 --height 844
14
+ node <skill>/scripts/shoot.mjs --url http://localhost:4500 --out lab/reduced --reduced-motion
15
+ ```
16
+
17
+ Then **read `sheet.png`**. The whole point of shooting contiguously is looking
18
+ at the frames side by side; a folder of PNGs does not get looked at that way.
19
+
20
+ Two setup facts that will otherwise waste a pass:
21
+
22
+ - **Serve it.** `file://` blocks the Blob fetch the engine uses for clips, so
23
+ the page silently falls back to posters and proves nothing.
24
+ - **Real Chrome, not bundled Chromium.** Chromium ships without an h264
25
+ decoder, so every clip fails to paint and the run "passes" against posters.
26
+ `shoot.mjs` already resolves installed Chrome; override with
27
+ `SCROLLCRAFT_CHROME`.
28
+
29
+ ---
30
+
31
+ ## What the harness reports
32
+
33
+ It samples **within each act** (default 6 positions per act) rather than
34
+ uniformly down the document. Uniform sampling moves every position whenever you
35
+ change any section's height, so findings appear and vanish with unrelated edits.
36
+
37
+ **DEAD SCROLL**: consecutive positions where nothing changed: no cue moved, no
38
+ clip time advanced, no rail travelled, no wipe progressed, no stage shifted.
39
+ Real dead scroll means the reader is turning the wheel and being given nothing.
40
+ Fix by shortening the act's span or adding a cue.
41
+
42
+ **Bespoke fixed stages must report their visible state.** A split stage, live
43
+ canvas, or other page-local system can use ordinary `flow` acts only as scroll
44
+ markers while every visible change happens on a fixed layer outside the engine.
45
+ The harness cannot infer that layer's semantics. Put `data-sc-verify-state` on
46
+ the fixed stage and update its value to a compact signature of the values that
47
+ actually paint: divider position, scene opacity, canvas phase, custom film
48
+ time, or similar. The detector then checks those flow spans too.
49
+
50
+ Do not publish raw scroll progress just to make the check green. If progress is
51
+ changing while the composition is not, that is the exact failure this path is
52
+ meant to catch. Round and publish the rendered values. For an intentional
53
+ resolved hold, set `data-sc-verify-hold="true"` only while the hold is active.
54
+ Reduced-motion fixed stages may use the same attribute for deliberately stable
55
+ frames, which still require manual contact-sheet review.
56
+
57
+ **FROZEN CLIP**: a scrub stage is on screen, the reader is scrolling, and the
58
+ clip's playhead is not moving. Dead scroll cannot see this, because the stage
59
+ itself *is* moving: a still photograph is sliding up the page, which is the
60
+ worst-looking failure this kit can produce and the one that most reliably makes
61
+ a page feel broken.
62
+
63
+ The harness samples each scrub act's **entry and exit slides**, not only its
64
+ pinned travel. That gap is why this went undetected for four builds: a pinned
65
+ act's samples were taken at `top + (h - vh) * p`, which never visits the viewport
66
+ of scroll on either side where the stage is visible and the clip is parked. A
67
+ hold on the first or last frame is always reported. A hold in the middle is only
68
+ reported once it outlasts any plausible `data-sc-dwell` settle, since that settle
69
+ is a deliberate effect. The check is skipped under reduced motion, where no clip
70
+ is ever fetched on purpose.
71
+
72
+ The fix is almost never per-page: the engine maps clip time across the stage's
73
+ whole visible life by default. A page that reports this has usually opted out
74
+ with `data-sc-clip-map="travel"`, or is running an engine copy from before that
75
+ default existed. See [devices.md §1](devices.md).
76
+
77
+ **CUES THAT NEVER PEAK**: an element that never reaches full opacity anywhere.
78
+ Usually a cue window too narrow for its act, or ramps that eat the whole window.
79
+ Widen the window or set explicit ramps. A kinetic heading is read through its
80
+ line units, not through the element: the engine forces the element itself to
81
+ opacity 1 and carries the real value on `.sc-split__i`, so reading the element
82
+ reports every kinetic headline as fully present even on frames where every line
83
+ is at 0.
84
+
85
+ **CONTRAST**: measured on the **composited page**, not on the source video. The
86
+ harness hides the text, re-shoots the same frame, and samples the real
87
+ background under each line, so scrims, gradients and blends are all included.
88
+ Elements with their own opaque background are graded against that fill instead.
89
+
90
+ Three things it gets right that a hand-rolled version usually does not:
91
+
92
+ - **The direction is picked per line.** Light type on a dark page fails on the
93
+ brightest patch under it; dark type on a light page fails on the *darkest*
94
+ one, and grading that against the brightest patch is the most lenient reading
95
+ available, so a high-key page can report clean over text that is failing. The
96
+ harness compares the ink to the mean background and grades against whichever
97
+ extreme is on the ink's own side.
98
+ - **The sampled rect is clamped to the viewport.** The part of a pinned act's
99
+ copy that has scrolled above the fold is not on screen, so what sits in those
100
+ pixels is not behind anything the reader can see.
101
+ - **Fixed chrome is hidden with the text.** A fixed bar paints in *front* of
102
+ what scrolls under it, so its own mark is not the background behind a headline
103
+ passing beneath it.
104
+
105
+ This is the check no static audit can do: the frame under a headline changes as
106
+ the clip scrubs, so text can clear 4.5:1 against the poster and fail badly three
107
+ hundred pixels later.
108
+
109
+ ### The scrim has to be a SIBLING of the copy, never a child
110
+
111
+ The pass hides `[data-sc-cue],[data-sc-cue] *,[data-sc-copy],[data-sc-copy] *`
112
+ before photographing the frame underneath a line. `visibility: hidden` hides an
113
+ element's pseudo-elements too, so a scrim written as `.mycopy::before` is hidden
114
+ along with the text it exists to protect, and the pass grades the line against
115
+ the raw film every time.
116
+
117
+ The tell is unmistakable once you know it: **you strengthen the scrim and the
118
+ reported numbers do not move at all.** Not "improve slightly", not "move by a
119
+ tenth": byte-identical, because the thing you changed was never in the
120
+ measurement. If a contrast number is unchanged to two decimals after a real
121
+ change, stop tuning and check what is actually being composited.
122
+
123
+ A very high mean against a very low worst (`1.21:1 (mean 12.83)`) is the same
124
+ finding seen from the other side: the type is fine almost everywhere and there
125
+ is a bright patch under it that nothing is covering.
126
+
127
+ Two shapes that work:
128
+
129
+ - `.sc-world__scrim` in the copy layer, which is what worldflight.md ships and
130
+ which survives the hide because it carries no `data-sc-copy`.
131
+ - One plate per block, mounted as a sibling and driven from the page's own JS.
132
+ `orrery` sizes each plate off its block's untransformed box (set
133
+ `transform:'none'`, read the rect, put it back, so the engine's ±2vh copy
134
+ drift does not skew the measurement) and each frame copies the block's own
135
+ inline opacity onto its plate, so the plate tracks the engine's window with no
136
+ duplicated window maths.
137
+
138
+ ### Known limitations of the contrast pass
139
+
140
+ Real, and worth knowing before you trust a green run:
141
+
142
+ - **Cues are keyed by their text.** Two cues that share a string, which
143
+ taste.md's "one label per intent" rule actively encourages, are collapsed into
144
+ one row, and the reported worst frame is the worse of the two.
145
+ - **Lines under 0.85 opacity are skipped.** A headline parked at 0.6 over a
146
+ bright frame is never graded, so "contrast clean" can still hide a legibility
147
+ problem. Look at the sheet for anything that reads washed out.
148
+
149
+ **Author the fade-outs to land between sample positions.** The harness samples
150
+ a fixed number of positions per act, so a ramp-out that happens to straddle one
151
+ puts a half-faded headline on the sheet: graded by nobody, and read by eye as
152
+ ghost type over the frame. **Fix the ramp, not the sampling.** Shorten
153
+ `rampOut` so the cue is at full opacity at one sample and gone by the next,
154
+ rather than sitting at 0.5 on the sample in between. Widening the sample count
155
+ only finds more half-faded frames; it does not make the page look better,
156
+ because a real reader stopping on that pixel sees exactly what the sheet
157
+ shows. A cue caught mid-fade over a bright frame is a real defect, not a
158
+ sampling artefact.
159
+ - **The floor is not size-aware.** It reports below 3:1 as a failure and 3:1 to
160
+ 4.5:1 as thin. WCAG allows 3:1 for large text, so a display headline in the
161
+ thin band is usually fine and a 16px caption in it is not.
162
+ - **Acts with no `[data-sc-cue]` elements are not graded at all.** Copy on plain
163
+ canvas is a static case, but it is unmeasured.
164
+ - **Ordinary `flow` acts are excluded from dead-scroll checks.** Static flow is
165
+ normally correct. A bespoke fixed experience built over flow markers must use
166
+ `data-sc-verify-state`, or the harness will skip its visible timeline and can
167
+ report a dead opening as healthy.
168
+ - **A `pan` act whose rail does not overflow is reported as healthy.** The
169
+ `pigment` build ran a rail measuring 1368px inside a 1440px viewport, so it
170
+ travelled zero for its entire 2.1vh span, and every pass printed `no dead
171
+ scroll detected`. Measure `rail.scrollWidth - innerWidth` yourself; a green run
172
+ does not cover it. See devices.md §3.
173
+
174
+ **Console errors and failed requests**: a 404 on a clip degrades to a poster
175
+ silently, which looks fine and is not.
176
+
177
+ ---
178
+
179
+ ## What the harness cannot tell you
180
+
181
+ Read the sheet for these. They are the ones that matter most.
182
+
183
+ - **Whether the composition is any good.** Copy landing on the busiest part of
184
+ the frame, a subject cropped at an unfortunate point, an act whose end frame
185
+ is a dark empty corner.
186
+ - **Whether the motion is smooth.** Contiguous frames prove the clip advances;
187
+ they do not prove it advances evenly. Watch the contact sheet for a move that
188
+ lurches, reverses, or stalls in the middle.
189
+ - **Whether the page means anything.** Six acts that each work and together say
190
+ nothing is the most expensive failure available here.
191
+
192
+ ---
193
+
194
+ ## The manual passes
195
+
196
+ **Reduced motion.** Clips are never fetched, posters hold, copy still cues. The
197
+ page must remain comprehensible, not merely not-crash. This doubles as the
198
+ low-bandwidth check.
199
+
200
+ Comprehensible includes **reachable**: check that no content was deleted rather
201
+ than merely stilled. A `pan` rail is the case that bites, because zeroing its
202
+ transform parks it on its first screenful. The engine now hands the stage back
203
+ as a native scroll region, so confirm on the sheet that the rail shows real
204
+ content and that items past the fold can still be got to. Nothing in the harness
205
+ reports this; it reads as a page behaving correctly.
206
+
207
+ **Credit accounting.** `kie.mjs probe` reports a balance, not a delta, so a
208
+ build's spend is a before-and-after subtraction. That subtraction is only valid
209
+ if nothing else is generating against the same key. When builds run in parallel,
210
+ or when a settlement lands late, the deltas overlap and each build will claim
211
+ some of another's spend (three parallel builds each read the same 7597 → 7067 and
212
+ each reported 530). Either serialise generation, or cost the build from the
213
+ per-call model prices in [assets.md](assets.md) against the calls you actually
214
+ made, and treat the probe delta as a ceiling.
215
+
216
+ **And the per-call sum overstates real spend in the other direction.** Two
217
+ reconciliations against the account ledger, each with no other consumer, put
218
+ actual debits at roughly **0.4x** the documented unit rates: a fleet whose
219
+ per-call sums came to ~1447 credits was debited 530, and a three-build run whose
220
+ per-call sums came to 2252 was debited 856. Both land near the same ratio. So a
221
+ build report should say what the per-call sum is *and* that it is a planning
222
+ ceiling rather than the amount billed. Reporting the sum as the cost is the
223
+ honest default, because it never under-claims; reporting it as *measured* spend
224
+ is wrong. Neither number is the other's substitute: the probe delta bounds a
225
+ parallel run from above, the per-call sum bounds a serial one from above, and
226
+ only a ledger read with a single consumer settles it.
227
+
228
+ **Mobile.** Pinned stages use `100svh` so the URL bar does not cause a jump.
229
+ Copy reflows and does not collide with the fixed bar. Confirm the phone encodes
230
+ actually load. Check the portrait crop of every clip: a 16:9 move composed
231
+ around left-hand negative space loses exactly that space at 9:16
232
+ (see [assets.md](assets.md)). Mobile is a first-class target, not a check at
233
+ the end: the phone clips are cut portrait, the lerp is retuned for touch, tap
234
+ targets are grown, and every one of those is authored, not inherited.
235
+
236
+ ### The phone is a different machine
237
+
238
+ Headless Chrome on the build box cannot reproduce an iPhone's video decoder,
239
+ its autoplay policy, Low Power Mode, or touch scrolling. On one build every
240
+ probe reported the hero clip scrubbing perfectly for **four consecutive
241
+ rounds while the real phone showed a frozen frame**. A green harness run says
242
+ the page is correct where the harness runs. It says nothing about iOS video.
243
+
244
+ What iOS does to a scrub clip, and what the engine now handles for you:
245
+
246
+ - iOS will not *paint* a muted video that has never been played. Seeks land,
247
+ `seeked` fires, and the picture stays on one frame. The decoder has to be
248
+ primed with one `play()`/`pause()`.
249
+ - The engine primes each clip at `loadedmetadata` (a muted inline `play()`
250
+ needs no gesture outside Low Power Mode) and retries on `touchstart`,
251
+ `touchend`, `pointerdown`, `click` and `scroll`. `touchend` matters: the
252
+ HTML spec's activation-triggering events include `touchend` but **not**
253
+ `touchstart`, so a Low Power Mode phone that rejects the touchstart attempt
254
+ gets a valid one when the finger lifts.
255
+ - A prime must be re-attemptable per clip. A one-shot prime on first touch
256
+ loses a race: the reader touches to scroll within the first second, while
257
+ the hero's megabytes are still downloading, and the shot is spent on a
258
+ sourceless element. The tell is exactly "the first clip is frozen and every
259
+ later one works".
260
+ - iOS may leave a `play()` promise pending forever, and may leave `seeking`
261
+ true forever. Both were permanent silent freezes; the engine now releases
262
+ the priming flag on a timer and re-issues any seek stuck past 700ms. The
263
+ reveal also fires on a 2.5s timeout, never only on `seeked`.
264
+
265
+ Do not re-implement any of that in page JS, and do not strip it when copying
266
+ the engine. If a phone still shows a frozen clip, the cause is past what this
267
+ machine can measure, which is what the next section is for.
268
+
269
+ ### Ship the diagnostic with the site
270
+
271
+ You get one question per round with a real device, so make the round count.
272
+ `references/device-diag.html` is a standalone page that scrubs the suspect
273
+ clip two ways (blob URL, exactly as the engine loads it, and direct file src)
274
+ beside a known-good clip, prints a MOVING / FROZEN verdict over each pane,
275
+ and reports prime results, seek counts and distinct painted frames. Edit its
276
+ `TESTS` array to point at the build's own clips, deploy it next to the site,
277
+ and one screenshot from the phone isolates the layer: blob loading, the file,
278
+ the device's decode policy, or the engine's lifecycle. Deploy it **with** the
279
+ first mobile fix, not after the fourth.
280
+
281
+ ### Ask what differs before asking what's broken
282
+
283
+ The debugging lesson that cost three wasted rounds: "desktop works, the phone
284
+ does not" reads as a platform difference and invites platform theories
285
+ (codecs, keyframes, resolution). **"One clip works and another does not, on
286
+ the same device"** cannot be a platform difference. Before theorising, write
287
+ down every way the working case differs from the broken one; the bug lives in
288
+ that list. On the build above the list had one entry: the hero is first, so
289
+ it loads while the first touch is being spent.
290
+
291
+ **Keyboard.** Tab through. Focus order matches visual order, the focus ring is
292
+ visible against every ground it crosses, and nothing reachable is parked at
293
+ opacity 0. Cues set `pointer-events: none` when faded, but a focusable element
294
+ inside a faded cue is still a trap.
295
+
296
+ The engine helps here but does not finish the job, and the gap is specific:
297
+
298
+ - **It handles the ordinary case.** On `focusin`, if the focused element is
299
+ inside a `[data-sc-act]` and its own cue computes under 0.85, the engine
300
+ scrolls it to the centre of the viewport with `behavior: 'instant'`
301
+ (`smooth` would animate a multi-screen glide with focus off screen the whole
302
+ way). On a `flow` act, centring the element also opens its cue, because the
303
+ element's viewport position and the act's progress move together.
304
+ - **It does not fix a pinned act, and cannot with this approach.** A pinned
305
+ stage is `position: sticky`, so the control holds *one* viewport position for
306
+ the entire act. Centring it is then only achievable by scrolling backwards out
307
+ of the act, which parks progress at 0 and leaves the cue dark. Measured: a CTA
308
+ cued at 0.75 on a 3vh pinned act sits at viewport y=70 from progress 0 to
309
+ 0.875; `scrollIntoView({block:'center'})` from inside the act lands *before*
310
+ the act's top, at progress 0, cue opacity 0. The control is on screen and
311
+ still invisible.
312
+
313
+ **On a pinned act, park the act at the progress where the focused element's own
314
+ cue is open.** That is page-local work, because only the page knows which cue
315
+ belongs to which control, and because act progress runs through `dwell()` when
316
+ the act has any, so the scroll target is not a straight inverse of the cue
317
+ window. The descent build does exactly this. If a pinned act carries a focusable
318
+ control, write that handler and assert it; do not assume the engine covered you.
319
+
320
+ **Fresh eyes.** Look again later. Timing you tuned for twenty minutes reads
321
+ differently when you have forgotten what it is supposed to do.
322
+
323
+ ---
324
+
325
+ ## Failures worth knowing about
326
+
327
+ Each of these shipped once during this skill's own build, and each looked fine
328
+ until it was measured.
329
+
330
+ | Symptom | Cause |
331
+ |---|---|
332
+ | A hero headline wrapped to six lines | `max-width` in `ch` on a **container**: `ch` resolves against the container's font-size, not the display size of the heading inside it |
333
+ | Centred copy hanging off the left edge | `inset-inline` declared **after** `left: 50%`; the shorthand resets `left` to auto |
334
+ | An act that never pins, silently | An author rule setting `position` on the stage. The engine now warns in the console |
335
+ | A stray headline painted over a later section | Cues frozen at their last value when their act scrolled out of range |
336
+ | A clip stuck on its poster at the top of its act | The reveal waits for a `seeked` event, and a clip already at time 0 never seeks |
337
+ | A closing CTA that fades out before the page ends | A two-value cue on the last act, plus a tall section after it |
338
+ | Copied headings reading "even whenbreakfast" | Line-split spans abutting with no whitespace between them |
339
+ | A headline from act 2 overlapping act 3, failing contrast on the way | A one-value hold cue on a middle act. Only the last act may hold |
340
+ | A phone-only contrast failure on a trail-anchored act | The trail scrim aimed at the corner the copy leaves below 860px. The engine now switches it to a band |
341
+ | A rail act that shows one frozen screenful under reduced motion | `[data-sc-pan] { transform: none }` deleting the navigation. The engine now falls back to a scroll region |
342
+ | Two washed-out video acts no scrim tuning could rescue | Flat supplied footage with no white point. Grade the intermediate, not the CSS |
343
+ | A blank stage for the first viewport of a pinned act | A two-value first cue with no ground. Ground or greet |
344
+ | A rail heading dragged off-screen under reduced motion | The scroll-region fallback snap-centres a single wide track; keep the act heading outside the region, or give the rail multiple snap stops |
345
+ | Keyboard focus landing on a control nobody can see | The browser's scroll-into-view parks the element barely on screen, which is where its cue has not opened, and the opacity check still passes. **The engine now centres it on `focusin`** when the element is inside a `[data-sc-act]` and its cue is under 0.85. That fixes the off-screen half. See the note below for what it does not fix |
346
+ | A figure or drop numeral rendering as a plain bar | `data-sc-reveal` on type with `line-height` below 1. `clip-path` is relative to the border box, so the wipe eats the ascender and descender. See devices.md §4 |
347
+ | An image three times too tall, pushing its own label off the fold | `width` overridden in CSS while `height` still resolves to the HTML attribute. Override both or neither. See taste.md |
348
+ | An inverted section rendering its old ink, graded in the wrong direction | `--sc-ink` redefined on the subtree without restating `color`. See taste.md |
349
+ | A ground colour arriving a section late | `drift` on a page of short acts; several are part-way through at once. Paint grounds per section. See devices.md §10 |
350
+ | Every cue and reveal in a quiet act snapping 0 to 1 | A pinned act at `data-sc-span` ≤ 1, which is one pixel of travel. Minimum useful pinned span is ~1.2 |
351
+ | A clip that scrubs beautifully, stops, and then slides up the page as a still photograph | The clip was mapped to the act's pinned travel, which is 0 through the entire entry slide and 1 through the entire exit slide. The engine now maps clip time across the stage's whole visible life by default. See devices.md §1 |
352
+ | A custom fixed stage passing while its first screens do nothing | The page used `flow` markers, which are intentionally excluded from ordinary dead-scroll checks, but published no `data-sc-verify-state`. Report the actual rendered state and declare only genuine resolved holds |
353
+ | The hero clip frozen on a real iPhone, later clips fine, every probe green | iOS never paints an unplayed muted video, and the one-shot gesture prime was spent while the hero was still downloading. The engine now primes per clip at `loadedmetadata` and retries on every gesture, including `touchend` |
354
+ | A phone clip soft and stuttering while the same file is smooth on desktop | A landscape mobile encode in a portrait viewport: cover-fit decoded the full frame and threw three quarters of it away. Cut the phone clips portrait from the masters (see assets.md) |
355
+ | Four rounds of mobile fixes verified green, phone still broken | Headless Chrome cannot reproduce the iOS decoder, Low Power Mode, or touch. Deploy `references/device-diag.html` beside the site on the first mobile report and let the phone answer |
356
+
357
+ The first three are invisible to every check except looking at rendered output.
358
+ That is the argument for this whole pass.
359
+
360
+ Operational note: a `shoot.mjs` run can take the background server process down
361
+ with it when it finishes. Check the port before the next pass and restart
362
+ `serve.mjs` if it dropped.
363
+
364
+
365
+ ## The harness will photograph the wrong site without telling you
366
+
367
+ `serve.mjs` fails with `EADDRINUSE` if something already holds the port. When
368
+ that server was started in the background, the failure is in a log nobody is
369
+ reading, and `shoot.mjs` then gets a perfectly good `200` from **whatever else
370
+ is on that port**. It walks that page, finds its worldflight, and writes a full
371
+ contact sheet and a clean report for a site you did not build.
372
+
373
+ Confirm the port is serving YOUR build before trusting any run:
374
+
375
+ ```bash
376
+ curl -s http://localhost:45XX | grep -o "<title>.*</title>"
377
+ curl -s -o /dev/null -w "%{http_code}
378
+ " http://localhost:45XX/assets/leg01.mp4
379
+ ```
380
+
381
+ A 404 on an asset you know exists is the fastest tell.