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,466 @@
1
+ # The device kit
2
+
3
+ Nine ways for scroll to change the page. Each one is a different answer to "what
4
+ does the visitor's hand actually do here."
5
+
6
+ Pick per beat, never per page. The variety law from SKILL.md Step 2 applies:
7
+ four or more families, never the same one twice in a row.
8
+
9
+ Every act publishes `--sc-p` (0 to 1) on its own element, so anything you want
10
+ to drive that the kit does not cover, you can drive from CSS with `calc()`
11
+ against that variable. Reach for that before asking for a new device.
12
+
13
+ ---
14
+
15
+ ## 1. `scrub`: the wheel is a scrubber
16
+
17
+ The anchor device. A pre-rendered camera move plays under the reader's hand,
18
+ one frame per notch. This is the thing people screenshot and send to each other,
19
+ so spend it on the open.
20
+
21
+ ```html
22
+ <section data-sc-act="scrub" data-sc-span="2.6" data-sc-dwell="0.35"
23
+ data-sc-drift="#0A0806">
24
+ <div data-sc-stage>
25
+ <img class="sc-stage__poster" src="assets/01-hero.webp" alt="">
26
+ <video data-sc-scrub data-sc-src="assets/01.mp4"
27
+ data-sc-src-mobile="assets/01-m.mp4" playsinline muted></video>
28
+ <div class="sc-scrim"></div>
29
+
30
+ <div class="sc-copy sc-copy--lead" data-sc-cue="0.08 0.62 0.34">
31
+ <h1 class="sc-display sc-display--xl" data-sc-kinetic="lines">
32
+ Your morning shouldn't need two drinks.
33
+ </h1>
34
+ </div>
35
+ </div>
36
+ </section>
37
+ ```
38
+
39
+ - `data-sc-span` is the act's scroll length in viewport-heights. 2.2 to 3.0 for
40
+ a hero. Below 1.8 the clip flies past; above 3.5 the reader starts wondering
41
+ whether the page is broken.
42
+ - `data-sc-dwell` (0 to 0.6) remaps time so the camera settles mid-act, exactly
43
+ where the copy peaks, and moves quicker at the edges. It is the difference
44
+ between a clip that plays and a shot that lands. Keep it at or below 0.6.
45
+ - `data-sc-src` (not `src`) is deliberate: the engine fetches the clip as a Blob
46
+ so it seeks without needing HTTP range support, and skips the fetch entirely
47
+ under reduced motion.
48
+ - The poster is a live frame-holder. It stays up until a real video frame has
49
+ painted, because iOS keeps a seeked-but-never-played muted video blank and
50
+ hiding the poster on metadata alone flashes an empty stage.
51
+
52
+ **At most two scrub acts per page.** The third one is no longer a surprise, and
53
+ it is the heaviest thing on the page.
54
+
55
+ ### Clip time is not cue time
56
+
57
+ The single most damaging bug this device has, and it is invisible in every
58
+ screenshot taken one at a time.
59
+
60
+ A pinned stage is on screen for **one viewport before** its pinned travel begins,
61
+ sliding up into view, and **one viewport after** it ends, sliding off the top.
62
+ The act's progress `p` is 0 through the whole entry and 1 through the whole exit.
63
+ So a clip driven by `p` sits frozen on its first frame while it slides in, and
64
+ frozen on its last frame while it slides out. The reader has been scrubbing a
65
+ film with their hand, the film stops, and then the whole page slides a still
66
+ photograph past them. It reads as the site breaking, and it is the fastest way to
67
+ make an expensive page feel cheap.
68
+
69
+ The engine therefore maps the clip across the stage's **entire visible life**, not
70
+ across its pinned travel, and this is the **default**. Both ends are clamped to
71
+ scroll that actually exists, so a hero at the top of the document still starts on
72
+ frame one and an act near the bottom still reaches its last frame. Cues keep
73
+ using `p`, because cues belong to the pin.
74
+
75
+ **Pair it with `data-sc-dwell`.** Dwell moves quickly at the edges and settles in
76
+ the middle, which is exactly the shape this mapping wants: the fast motion lands
77
+ on the two slides, and the settle lands inside the pin where the copy is. The two
78
+ were built for each other.
79
+
80
+ `data-sc-clip-map="travel"` restores the old pinned-travel mapping. There is
81
+ almost no reason to reach for it, and reaching for it reintroduces the freeze.
82
+
83
+ The harness checks this now (see [verify.md](verify.md)), so a frozen clip fails
84
+ verification instead of shipping. Do not rely on noticing it by eye: every
85
+ individual frame of a frozen clip looks completely correct.
86
+
87
+ ### The playhead is lerped
88
+
89
+ Scroll never writes `currentTime`. It writes a target, and a standalone rAF loop
90
+ walks the clip toward that target at a fixed fraction per frame. Wheel events do
91
+ not arrive at a constant rate, so a 1:1 write reproduces every gap in them and
92
+ the clip reads as a stutter rather than a glide. Three mechanisms, all on by
93
+ default:
94
+
95
+ - **Lerp 0.18 per frame.** `data-sc-lerp` overrides it, on the mount root for the
96
+ whole page or on one `<video>`. Clamped to 0.02 to 1, and never read as 0, since
97
+ a 0 lerp is a playhead that never moves. Reach for it only when a page's clips
98
+ are short enough that 0.18 visibly lags the hand. Under reduced motion the
99
+ rate is 1.0, which is no smoothing at all.
100
+ - **Deadband** of 8ms desktop, 20ms mobile. A write smaller than that costs a
101
+ seek and shows nothing; on a phone it costs more than it shows.
102
+ - **Seek coalescing.** No seek is queued while the decoder is still resolving the
103
+ last one. A fast flick otherwise piles seeks up and freezes the clip.
104
+
105
+ An offscreen clip that has already reached its target stops being touched at all.
106
+
107
+ This applies to every scrub clip on the page, act or worldflight. It is also why
108
+ `shoot.mjs` waits for the playhead to arrive before each shot: a frame captured
109
+ mid-lerp is a frame the page never actually holds.
110
+
111
+ ---
112
+
113
+ ## 2. `pin`: the frame holds, the content advances
114
+
115
+ The workhorse, and the cheapest premium effect there is. The stage sticks for a
116
+ few viewport-heights while copy states cross over inside it. Use it when the
117
+ beat is an argument rather than an image.
118
+
119
+ ```html
120
+ <section data-sc-act="pin" data-sc-span="3" data-sc-drift="#12100E">
121
+ <div data-sc-stage class="pf-argument">
122
+ <p class="sc-lede" data-sc-cue="0.02 0.34">A cup of coffee. Then a shake.</p>
123
+ <p class="sc-lede" data-sc-cue="0.30 0.66">Two things to buy, carry and wash.</p>
124
+ <p class="sc-lede" data-sc-cue="0.62">Or one can.</p>
125
+ </div>
126
+ </section>
127
+ ```
128
+
129
+ **Minimum useful span is about 1.2.** A pinned act's travel is
130
+ `max(height - viewport, 1)`, so at a span of 1 or below that is one pixel:
131
+ progress jumps 0 to 1 between two scroll notches and every cue, reveal and
132
+ `--sc-p`-driven animation inside the act snaps instead of running. A short quiet
133
+ act is exactly when an author reaches for a small span, which is exactly when
134
+ this bites. If the beat genuinely wants less than a screen of travel, it is a
135
+ `flow` act, not a pinned one.
136
+
137
+ Cue windows overlap by design: the outgoing line is still fading while the next
138
+ arrives, so the reader never faces empty space. A gap between cues reads as a
139
+ loading failure. Overlap by roughly 15% of the act.
140
+
141
+ The last cue takes one value and holds, so the act ends on a statement rather
142
+ than fading to nothing before the next section arrives.
143
+
144
+ ### The cue contract
145
+
146
+ `data-sc-cue="from [to [rampIn [rampOut]]]"`, all in act progress (0 to 1).
147
+
148
+ | Form | Behaviour |
149
+ |---|---|
150
+ | `"0.2"` | fades in at 0.2 and **holds to the end of the act** |
151
+ | `"0.1 0.6"` | in, plateau, out. Ramps default to 30% of the window each |
152
+ | `"0 0.78 0"` | **greet**: already at full opacity when the act begins, then fades |
153
+ | `"0.1 0.9 0.15 0.4"` | fast in, long slow out |
154
+ | `"0 1 0 0"` | **greet and hold**: full at p = 0, no ramp at either end |
155
+
156
+ The plateau is the point. Without one a cue is a triangle that touches full
157
+ opacity for a single instant, so the reader has to stop on exactly the right
158
+ pixel to see the line at full strength and every heading reads slightly faded.
159
+
160
+ Rules the verification pass will catch you on:
161
+
162
+ - **A hero cue needs the greet form.** `"0 0.7"` ramps up from nothing, which
163
+ means the landing view, the one screen every visitor sees, has no headline on
164
+ it. Use a third value of `0`.
165
+ - **The last act's cue must hold.** Give it one value. A closing CTA on a
166
+ two-value cue fades out before the page ends, and the final screen is empty.
167
+ - **Only the last act may hold.** This is the inverse of the rule above and it
168
+ is the one that bites. The engine parks a cue only once its act is a viewport
169
+ and a quarter out of range, so a one-value cue on a *middle* pinned act stays
170
+ lit through the entire un-pin slide: the line travels a full viewport upward,
171
+ crosses any fixed header, and overlaps the section that follows. It is
172
+ invisible until you measure it, and it shows up as a contrast failure on a
173
+ headline nobody meant to still be on screen. Every act except the last closes
174
+ its final cue with a two-value window ending at 1.
175
+ - **Ground or greet.** A pinned stage becomes fully visible roughly a viewport
176
+ *before* its own progress leaves 0, so any pinned act whose first content is a
177
+ plain two-value cue shows an empty stage for that whole travel. Give the act
178
+ either a ground that is already there (an image, a held frame, a colour that
179
+ is doing work) or a first cue in the greet form. The closing act needs a
180
+ ground, because its hold cue cannot also greet unless you use `"0 1 0 0"`.
181
+ The rule covers *any* progress-gated content on a pinned act, not just engine
182
+ cues: a bespoke panel that populates from scroll state has the same empty-stage
183
+ window and needs the same ground.
184
+
185
+ ---
186
+
187
+ ## 3. `pan`: vertical scroll, lateral travel
188
+
189
+ Sideways movement reads as *breadth* where vertical reads as *argument*. Use it
190
+ for a range, a lineup, a timeline. Do not use it for a hierarchy: the first item
191
+ in a rail is not read as the most important one.
192
+
193
+ ```html
194
+ <section data-sc-act="pan" data-sc-span="3.2">
195
+ <div data-sc-stage>
196
+ <div class="pf-rail" data-sc-pan="0.08">
197
+ <article class="pf-flavour">…</article>
198
+ <article class="pf-flavour">…</article>
199
+ <article class="pf-flavour">…</article>
200
+ </div>
201
+ </div>
202
+ </section>
203
+ ```
204
+
205
+ The engine measures `scrollWidth` against the viewport and travels exactly the
206
+ overflow, so the last item lands flush at the right edge. `data-sc-pan="0.08"`
207
+ adds 8% overshoot if you want a breath after the final card.
208
+
209
+ Span rule: roughly 1 viewport-height per item, plus 1. Four cards want ~5.
210
+
211
+ **Measure the overflow, do not assume it.** The engine travels exactly
212
+ `scrollWidth - viewport`, so a rail narrower than the viewport travels **zero**
213
+ and the act becomes a pinned stage holding one motionless screen for its whole
214
+ span. Three cards at `clamp(16rem, 26vw, 24rem)` measured 1368px against a
215
+ 1440px viewport: overflow **-72px**, travel 0, and the reader turns the wheel
216
+ through two viewport-heights of nothing. It is width-dependent, so it can be
217
+ correct on a phone and dead on a desktop at the same time, which is exactly how
218
+ it survives review: the mobile sheet pans and the desktop sheet looks like a
219
+ still. **The harness did not catch it** and reported `no dead scroll detected`
220
+ on every pass, so this is a manual measurement, not something a green run
221
+ covers:
222
+
223
+ ```js
224
+ const rail = document.querySelector(".rail");
225
+ rail.scrollWidth - innerWidth // must be a healthy positive number
226
+ ```
227
+
228
+ Aim for at least half a viewport of overflow. If three items do not reach it,
229
+ the fix is not wider cards, it is **more rail**: put the act's heading in as the
230
+ first item and a closing note as the last. Both earn their place (the heading
231
+ stops competing with fixed chrome, the note gives the rail a resolution instead
232
+ of an end), and they add the width the travel needs.
233
+
234
+ **Give the rail's items a staggered settle driven from `--sc-p`.** Lateral
235
+ travel alone reads as a slideshow on rails; items that arrive in sequence read
236
+ as a drawer being pulled. Exempt the first item, because a pan act needs its
237
+ opening content already present, and floor the opacity around 0.55 so a
238
+ not-yet-settled card reads as arriving rather than as failing to load. Gate the
239
+ whole thing to `prefers-reduced-motion: no-preference`, or the scroll-region
240
+ fallback inherits a page of half-faded items.
241
+
242
+ **Card copy is read while cropped.** Items enter and leave through the viewport
243
+ edges, so a heading is half-shown for most of its life and two half-headings
244
+ side by side can read as a third word (`TRACE` beside `EVALUATE` becoming
245
+ `RE EVALUATE`). Keep card headings to one short word or two, and expect every
246
+ line of card copy to be read partially cut off.
247
+
248
+ **Reduced motion.** The rail's transform is not decoration, it is the
249
+ navigation, so it cannot simply be zeroed the way parallax is: that parks the
250
+ act on its first screenful and makes every item past the fold unreachable. The
251
+ engine handles the floor for you, turning the stage into a native
252
+ `overflow-x: auto` scroll region with proximity snapping, so the same items stay
253
+ gettable without motion. If your rail reads better stacked or as a grid at that
254
+ point, override it in your own CSS under `prefers-reduced-motion` (the agency
255
+ reference build relays its three phases out as a grid at desktop widths).
256
+
257
+ ---
258
+
259
+ ## 4. `reveal`: a wipe is a change of state
260
+
261
+ `clip-path` eating in from an edge. It costs nothing and it reads as
262
+ transformation, which makes it right for the beat where something becomes
263
+ something else. Wrong for merely introducing an image, where a cue is enough.
264
+
265
+ ```html
266
+ <figure data-sc-reveal="up" data-sc-reveal-at="0.15 0.55">
267
+ <img src="assets/03-carry.webp" alt="…">
268
+ </figure>
269
+ ```
270
+
271
+ `up` `down` `left` `right` `iris`. Reach for `iris` roughly once per page; it is
272
+ the loudest of the five and stops reading as intentional if repeated.
273
+
274
+ A wipe that runs edge to edge across a full-bleed image is a transition. A wipe
275
+ on a small element is a fidget. Use it big.
276
+
277
+ **`clip-path` is relative to the border box, not to the ink.** A reveal on type
278
+ set with `line-height` below 1 has a border box shorter than the glyphs, so the
279
+ wipe clips the ascenders off the top and the descenders off the bottom and every
280
+ figure renders as a plain bar. Display numerals and drop figures are the usual
281
+ casualties, because those are the ones set tight. Either give the reveal element
282
+ room (`line-height: 1` and padding to cover the overshoot) or put
283
+ `data-sc-reveal` on a wrapper and leave the type's own box alone. No static
284
+ audit catches this; only a rendered screenshot does.
285
+
286
+ ---
287
+
288
+ ## 5. `kinetic`: type that assembles
289
+
290
+ Splits a heading into lines, words or characters and staggers them across the
291
+ cue window. Lines are almost always right; words for a short punch line;
292
+ characters approximately never, because it turns reading into waiting.
293
+
294
+ ```html
295
+ <h2 class="sc-display sc-display--lg" data-sc-cue="0.1 0.7" data-sc-kinetic="lines">
296
+ Coffee that pulls its weight.
297
+ </h2>
298
+ ```
299
+
300
+ Each unit slides up from behind a mask, so it enters from a clean edge rather
301
+ than simply fading. Line masks reserve room for descenders; a mask clipped to
302
+ the line box shears the tails off g, y, p and j, and that is the single most
303
+ common way this effect looks broken.
304
+
305
+ Line splitting measures real line boxes, so it re-runs after `document.fonts.ready`.
306
+ Do not call it on text that is still loading its face.
307
+
308
+ **One kinetic headline per act, at most.** Two competing for attention is noise,
309
+ and every heading assembling the same way is the templated rhythm this skill
310
+ exists to avoid.
311
+
312
+ ---
313
+
314
+ ## 6. `parallax`: layers at different rates
315
+
316
+ Depth from differential movement. Subtle or nothing: past roughly 200px of total
317
+ travel it stops reading as depth and starts reading as a bug.
318
+
319
+ ```html
320
+ <div class="pf-layer pf-layer--back" data-sc-parallax="-1.4">…</div>
321
+ <div class="pf-layer pf-layer--mid" data-sc-parallax="-0.6">…</div>
322
+ <div class="pf-layer pf-layer--front" data-sc-parallax="0.35">…</div>
323
+ ```
324
+
325
+ **The rate is in hundreds of pixels, not viewport fractions.** The engine writes
326
+ `rate * (p - 0.5) * 100` px, so the total travel across a whole act is
327
+ `rate * 100` px regardless of screen height. At 0.35 that is 35px across three
328
+ viewport-heights of scroll, which is invisible: usable values are roughly 0.3 to
329
+ 1.5 for a layer inside a frame and 1 to 2 for a full-bleed bed.
330
+
331
+ Negative moves up faster than the scroll, which pushes an element back. Three
332
+ layers is plenty; five is a diorama.
333
+
334
+ Never put body copy on a parallax layer. Text the reader is trying to read
335
+ should not move relative to the thing they are reading it against.
336
+
337
+ ---
338
+
339
+ ## 7. `count`: numbers that land
340
+
341
+ ```html
342
+ <span class="sc-nums" data-sc-count="0 4200" data-sc-count-at="0.1 0.5">0</span>
343
+ ```
344
+
345
+ Formatting is inferred from the target: decimals from its decimal places,
346
+ thousands separators above 10,000 **or whenever the target itself is written
347
+ with one**. Write the target exactly as it should render, commas included
348
+ (`data-sc-count="0 3,500"`); the engine strips them before parsing. The element
349
+ gets `tabular-nums` so the layout does not jitter while the digits change.
350
+
351
+ **Only real numbers.** A counter is a truth claim with motion attached, which is
352
+ what makes it persuasive and what makes an invented one a liability. If the
353
+ brand has no verified figure, there is no counter. Check the brand's rules
354
+ first; several forbid this outright.
355
+
356
+ **A concept, fictional or pre-launch brand has no verified figures, so it has no
357
+ counters.** The device suits a SaaS or agency page and it will look good in the
358
+ score table, which is exactly the trap: every number you could put in it would
359
+ be invented. Decide this before you design an act around a number, not after.
360
+ Real brand, real stats, or a different device.
361
+
362
+ ---
363
+
364
+ ## 8. `flow` + `in`: ordinary sections, done well
365
+
366
+ Not everything should be pinned. A page of nothing but pinned acts is
367
+ exhausting, and the contrast is what makes the pinned ones land. Normal
368
+ document sections with a reveal-on-entry are the rest of the page.
369
+
370
+ ```html
371
+ <section class="sc-section">
372
+ <div class="sc-wrap sc-stack" data-sc-stagger="70">
373
+ <h2 class="sc-display sc-display--md">What is actually in it</h2>
374
+ <p class="sc-body">…</p>
375
+ </div>
376
+ </section>
377
+ ```
378
+
379
+ This fires **once**, on entry, via IntersectionObserver. Content that re-hides
380
+ when the reader scrolls back up is a defect, not an effect. Stagger between 30
381
+ and 80ms; longer feels slow.
382
+
383
+ **A flow section directly after a pinned act takes reduced padding.** The pinned
384
+ stage needs a full viewport to scroll off, and full `--sc-section` padding on
385
+ top of that delays the flow section's first content by another screen. The
386
+ harness will not call it dead scroll, correctly, because the stage is moving.
387
+ The reader still sees a near-empty screen. Cut the block padding there and start
388
+ the first reveal near `p = 0`.
389
+
390
+ ---
391
+
392
+ ## 9. Pointer devices: interactivity that is not scroll
393
+
394
+ Scroll is a one-dimensional input. A page that only responds to scroll is a
395
+ film. These make it respond to the reader being *present*.
396
+
397
+ ```html
398
+ <div class="pf-card" data-sc-tilt="7">…</div>
399
+ <a class="pf-cta" data-sc-magnet="0.28">Find a stockist</a>
400
+ <section data-sc-spotlight>…</section>
401
+ ```
402
+
403
+ - `tilt`: 3D rotation toward the pointer. 5 to 9 degrees. Past 12 it is a toy.
404
+ - `magnet`: the element drifts toward the pointer. 0.2 to 0.35. Primary CTA
405
+ only; a page of magnetic elements is unusable.
406
+ - `spotlight`: publishes `--sc-mx` / `--sc-my` for a light that follows the
407
+ pointer across a surface.
408
+
409
+ **`magnet`, `parallax` and `cue` all write `transform`, so they cannot share an
410
+ element.** The magnet writes every frame in its own rAF loop, so it silently
411
+ wins and the cue's entrance rise is discarded. On a magnetic CTA, set
412
+ `data-sc-rise="0"` so the cue writes a no-op instead of losing a visible
413
+ animation to a race. A cued element that wants its own continuous transform
414
+ should drive an inner wrapper from `--sc-p` in CSS rather than stacking a second
415
+ device on the same node.
416
+
417
+ All three interpolate toward the pointer rather than tracking it directly.
418
+ Direct tracking reads as artificial because it carries no momentum. All three
419
+ are gated to `(hover: hover) and (pointer: fine)` and disabled under reduced
420
+ motion, so touch never fires a false hover.
421
+
422
+ ---
423
+
424
+ ## 10. `drift`: the ground moves with you
425
+
426
+ Not an act. A property of acts, and the thing that makes a page feel like one
427
+ continuous place rather than a stack of slides.
428
+
429
+ ```html
430
+ <section data-sc-act="scrub" data-sc-drift="#0A0806"> …
431
+ <section data-sc-act="pin" data-sc-drift="#161210"> …
432
+ <section data-sc-act="pan" data-sc-drift="#0E1412"> …
433
+ ```
434
+
435
+ The page ground interpolates between the values as each act takes over. Keep the
436
+ whole set inside one theme family. Drifting from near-black to cream mid-page
437
+ is not atmosphere, it is the reader wondering whether they clicked something.
438
+
439
+ Three to five stops across a page. Small steps. The effect should be invisible
440
+ frame to frame and obvious top to bottom.
441
+
442
+ **Scoping: drift belongs to the first act whose progress is strictly between 0
443
+ and 1.** That is the right pick when acts are long enough that only one is ever
444
+ part-way through, and it is wrong the moment several short acts satisfy it at
445
+ once. On a page of twelve short cuts the ground shown belongs to a section the
446
+ visitor left a screen ago, so a colour arrives late and reads as a bug rather
447
+ than as a slow lag. The advice above ("three to five stops") is written for six
448
+ long acts.
449
+
450
+ **If several acts can be part-way through at the same time, paint grounds per
451
+ section instead of drifting.** Set an opaque background on each section and let
452
+ the change land on a hard edge. That is also what a cutlist or a chaptered page
453
+ wants on its own terms: a cut is not an interpolation, and interpolating between
454
+ two chapter grounds is precisely the softness those grammars exist to refuse.
455
+ Drift is for pages that are one continuous place.
456
+
457
+ ---
458
+
459
+ ## Composing an act
460
+
461
+ Devices stack inside one act. A pinned stage can hold a scrubbing clip, a
462
+ parallax layer, a kinetic headline and a spotlight at once. The limit is
463
+ attention, not the engine: **one thing should be the reason each act exists**,
464
+ and everything else in it is support.
465
+
466
+ If you cannot say in one sentence what an act's moment is, it does not have one.