@mthanh/vespercli 2.2.0 → 2.3.0

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 (157) hide show
  1. package/README.md +6 -3
  2. package/assets/CATALOG.md +9 -2
  3. package/assets/claude/.claude-plugin/marketplace.json +9 -9
  4. package/assets/claude/.claude-plugin/plugin.json +1 -1
  5. package/assets/claude/agents/code-planner.md +1 -1
  6. package/assets/claude/agents/code-reviewer.md +1 -1
  7. package/assets/claude/agents/code-tester.md +1 -1
  8. package/assets/claude/agents/copywriter.md +1 -1
  9. package/assets/claude/agents/feature-wrap-document.md +1 -1
  10. package/assets/claude/agents/janitor.md +1 -1
  11. package/assets/claude/agents/modernization.md +1 -1
  12. package/assets/claude/agents/principal-software-engineer.md +1 -1
  13. package/assets/claude/agents/refine-issue.md +1 -1
  14. package/assets/claude/agents/se-system-architecture-reviewer.md +1 -1
  15. package/assets/claude/agents/se-technical-writer.md +1 -1
  16. package/assets/claude/agents/skill-creator.md +1 -1
  17. package/assets/claude/agents/task-researcher.md +1 -1
  18. package/assets/claude/agents/technical-product-manager.md +1 -1
  19. package/assets/claude/agents/vision-analyst.md +1 -1
  20. package/assets/claude/agents/vitest.md +1 -1
  21. package/assets/claude/agents/workspace-learner.md +2 -2
  22. package/assets/claude/packs/backend/.claude-plugin/plugin.json +1 -1
  23. package/assets/claude/packs/backend/agents/firebase-developer.md +1 -1
  24. package/assets/claude/packs/backend/agents/nodejs-express-developer.md +1 -1
  25. package/assets/claude/packs/docs/.claude-plugin/plugin.json +1 -1
  26. package/assets/claude/packs/frontend/.claude-plugin/plugin.json +1 -1
  27. package/assets/claude/packs/frontend/agents/expert-nextjs-developer.md +1 -1
  28. package/assets/claude/packs/frontend/agents/expert-react-frontend-engineer.md +1 -1
  29. package/assets/claude/packs/frontend/agents/jest-react-component-analyzer.md +1 -1
  30. package/assets/claude/packs/frontend/agents/jest-react-scenario-scanner.md +1 -1
  31. package/assets/claude/packs/frontend/agents/jest-react-test-generator.md +1 -1
  32. package/assets/claude/packs/frontend/agents/jest-react-test-planner.md +1 -1
  33. package/assets/claude/packs/frontend/skills/design-taste-frontend/LICENSE +21 -0
  34. package/assets/claude/packs/frontend/skills/design-taste-frontend/SKILL.md +529 -0
  35. package/assets/claude/packs/frontend/skills/design-taste-frontend/UPSTREAM.json +7 -0
  36. package/assets/claude/packs/frontend/skills/design-taste-frontend/assets/app.css +216 -0
  37. package/assets/claude/packs/frontend/skills/design-taste-frontend/assets/base.css +105 -0
  38. package/assets/claude/packs/frontend/skills/design-taste-frontend/assets/base.js +37 -0
  39. package/assets/claude/packs/frontend/skills/design-taste-frontend/assets/charts.js +617 -0
  40. package/assets/claude/packs/frontend/skills/design-taste-frontend/assets/scenes/stage.css +28 -0
  41. package/assets/claude/packs/frontend/skills/design-taste-frontend/assets/scenes/stage.js +133 -0
  42. package/assets/claude/packs/frontend/skills/design-taste-frontend/evals/evals.json +76 -0
  43. package/assets/claude/packs/frontend/skills/design-taste-frontend/library/app.json +1327 -0
  44. package/assets/claude/packs/frontend/skills/design-taste-frontend/library/directions.json +1078 -0
  45. package/assets/claude/packs/frontend/skills/design-taste-frontend/library/heroes.json +1086 -0
  46. package/assets/claude/packs/frontend/skills/design-taste-frontend/library/motions.json +92 -0
  47. package/assets/claude/packs/frontend/skills/design-taste-frontend/library/palettes.json +1485 -0
  48. package/assets/claude/packs/frontend/skills/design-taste-frontend/library/photos.json +7240 -0
  49. package/assets/claude/packs/frontend/skills/design-taste-frontend/library/sections.json +4429 -0
  50. package/assets/claude/packs/frontend/skills/design-taste-frontend/library/sites.json +8350 -0
  51. package/assets/claude/packs/frontend/skills/design-taste-frontend/library/type.json +2024 -0
  52. package/assets/claude/packs/frontend/skills/design-taste-frontend/references/app-screens.md +1199 -0
  53. package/assets/claude/packs/frontend/skills/design-taste-frontend/references/colour.md +141 -0
  54. package/assets/claude/packs/frontend/skills/design-taste-frontend/references/copy.md +177 -0
  55. package/assets/claude/packs/frontend/skills/design-taste-frontend/references/critique.md +182 -0
  56. package/assets/claude/packs/frontend/skills/design-taste-frontend/references/imagery.md +791 -0
  57. package/assets/claude/packs/frontend/skills/design-taste-frontend/references/layout.md +292 -0
  58. package/assets/claude/packs/frontend/skills/design-taste-frontend/references/motion.md +980 -0
  59. package/assets/claude/packs/frontend/skills/design-taste-frontend/references/research.md +356 -0
  60. package/assets/claude/packs/frontend/skills/design-taste-frontend/references/scenes.md +174 -0
  61. package/assets/claude/packs/frontend/skills/design-taste-frontend/references/tells.md +115 -0
  62. package/assets/claude/packs/frontend/skills/design-taste-frontend/references/type.md +170 -0
  63. package/assets/claude/packs/frontend/skills/design-taste-frontend/scripts/assets.mjs +865 -0
  64. package/assets/claude/packs/frontend/skills/design-taste-frontend/scripts/draw.mjs +584 -0
  65. package/assets/claude/packs/frontend/skills/design-taste-frontend/scripts/inspect.mjs +303 -0
  66. package/assets/claude/packs/frontend/skills/design-taste-frontend/scripts/lib/app-draw.mjs +203 -0
  67. package/assets/claude/packs/frontend/skills/design-taste-frontend/scripts/lib/cdp.mjs +248 -0
  68. package/assets/claude/packs/frontend/skills/design-taste-frontend/scripts/lib/probe.mjs +909 -0
  69. package/assets/claude/packs/frontend/skills/design-taste-frontend/scripts/lib/rules.mjs +243 -0
  70. package/assets/claude/packs/frontend/skills/design-taste-frontend/scripts/lint.mjs +119 -0
  71. package/assets/claude/packs/frontend/skills/design-taste-frontend/scripts/scenes.mjs +124 -0
  72. package/assets/claude/packs/graphify/.claude-plugin/plugin.json +1 -1
  73. package/assets/claude/packs/integrations/.claude-plugin/plugin.json +1 -1
  74. package/assets/claude/packs/playwright/.claude-plugin/plugin.json +1 -1
  75. package/assets/claude/packs/playwright/agents/playwright-component-analyzer.md +1 -1
  76. package/assets/claude/packs/playwright/agents/playwright-scenario-scanner.md +1 -1
  77. package/assets/claude/packs/playwright/agents/playwright-test-generator.md +1 -1
  78. package/assets/claude/packs/playwright/agents/playwright-test-planner.md +1 -1
  79. package/assets/claude/packs/playwright/agents/playwright-tester.md +1 -1
  80. package/assets/claude/packs/polyglot/.claude-plugin/plugin.json +1 -1
  81. package/assets/claude/packs/styles/.claude-plugin/plugin.json +1 -1
  82. package/assets/copilot/agents/code-planner.agent.md +1 -1
  83. package/assets/copilot/agents/code-reviewer.agent.md +1 -1
  84. package/assets/copilot/agents/code-tester.agent.md +1 -1
  85. package/assets/copilot/agents/copywriter.agent.md +1 -1
  86. package/assets/copilot/agents/feature-wrap-document.agent.md +1 -1
  87. package/assets/copilot/agents/janitor.agent.md +1 -1
  88. package/assets/copilot/agents/modernization.agent.md +1 -1
  89. package/assets/copilot/agents/principal-software-engineer.agent.md +1 -1
  90. package/assets/copilot/agents/refine-issue.agent.md +1 -1
  91. package/assets/copilot/agents/se-system-architecture-reviewer.agent.md +1 -1
  92. package/assets/copilot/agents/se-technical-writer.agent.md +1 -1
  93. package/assets/copilot/agents/skill-creator.agent.md +1 -1
  94. package/assets/copilot/agents/task-researcher.agent.md +1 -1
  95. package/assets/copilot/agents/technical-product-manager.agent.md +1 -1
  96. package/assets/copilot/agents/vision-analyst.agent.md +1 -1
  97. package/assets/copilot/agents/vitest.agent.md +1 -1
  98. package/assets/copilot/agents/workspace-learner.agent.md +2 -2
  99. package/assets/copilot/packs/backend/agents/firebase-developer.agent.md +1 -1
  100. package/assets/copilot/packs/backend/agents/nodejs-express-developer.agent.md +1 -1
  101. package/assets/copilot/packs/frontend/agents/expert-nextjs-developer.agent.md +1 -1
  102. package/assets/copilot/packs/frontend/agents/expert-react-frontend-engineer.agent.md +1 -1
  103. package/assets/copilot/packs/frontend/agents/jest-react-component-analyzer.agent.md +1 -1
  104. package/assets/copilot/packs/frontend/agents/jest-react-scenario-scanner.agent.md +1 -1
  105. package/assets/copilot/packs/frontend/agents/jest-react-test-generator.agent.md +1 -1
  106. package/assets/copilot/packs/frontend/agents/jest-react-test-planner.agent.md +1 -1
  107. package/assets/copilot/packs/frontend/skills/design-taste-frontend/LICENSE +21 -0
  108. package/assets/copilot/packs/frontend/skills/design-taste-frontend/SKILL.md +529 -0
  109. package/assets/copilot/packs/frontend/skills/design-taste-frontend/UPSTREAM.json +7 -0
  110. package/assets/copilot/packs/frontend/skills/design-taste-frontend/assets/app.css +216 -0
  111. package/assets/copilot/packs/frontend/skills/design-taste-frontend/assets/base.css +105 -0
  112. package/assets/copilot/packs/frontend/skills/design-taste-frontend/assets/base.js +37 -0
  113. package/assets/copilot/packs/frontend/skills/design-taste-frontend/assets/charts.js +617 -0
  114. package/assets/copilot/packs/frontend/skills/design-taste-frontend/assets/scenes/stage.css +28 -0
  115. package/assets/copilot/packs/frontend/skills/design-taste-frontend/assets/scenes/stage.js +133 -0
  116. package/assets/copilot/packs/frontend/skills/design-taste-frontend/evals/evals.json +76 -0
  117. package/assets/copilot/packs/frontend/skills/design-taste-frontend/library/app.json +1327 -0
  118. package/assets/copilot/packs/frontend/skills/design-taste-frontend/library/directions.json +1078 -0
  119. package/assets/copilot/packs/frontend/skills/design-taste-frontend/library/heroes.json +1086 -0
  120. package/assets/copilot/packs/frontend/skills/design-taste-frontend/library/motions.json +92 -0
  121. package/assets/copilot/packs/frontend/skills/design-taste-frontend/library/palettes.json +1485 -0
  122. package/assets/copilot/packs/frontend/skills/design-taste-frontend/library/photos.json +7240 -0
  123. package/assets/copilot/packs/frontend/skills/design-taste-frontend/library/sections.json +4429 -0
  124. package/assets/copilot/packs/frontend/skills/design-taste-frontend/library/sites.json +8350 -0
  125. package/assets/copilot/packs/frontend/skills/design-taste-frontend/library/type.json +2024 -0
  126. package/assets/copilot/packs/frontend/skills/design-taste-frontend/references/app-screens.md +1199 -0
  127. package/assets/copilot/packs/frontend/skills/design-taste-frontend/references/colour.md +141 -0
  128. package/assets/copilot/packs/frontend/skills/design-taste-frontend/references/copy.md +177 -0
  129. package/assets/copilot/packs/frontend/skills/design-taste-frontend/references/critique.md +182 -0
  130. package/assets/copilot/packs/frontend/skills/design-taste-frontend/references/imagery.md +791 -0
  131. package/assets/copilot/packs/frontend/skills/design-taste-frontend/references/layout.md +292 -0
  132. package/assets/copilot/packs/frontend/skills/design-taste-frontend/references/motion.md +980 -0
  133. package/assets/copilot/packs/frontend/skills/design-taste-frontend/references/research.md +356 -0
  134. package/assets/copilot/packs/frontend/skills/design-taste-frontend/references/scenes.md +174 -0
  135. package/assets/copilot/packs/frontend/skills/design-taste-frontend/references/tells.md +115 -0
  136. package/assets/copilot/packs/frontend/skills/design-taste-frontend/references/type.md +170 -0
  137. package/assets/copilot/packs/frontend/skills/design-taste-frontend/scripts/assets.mjs +865 -0
  138. package/assets/copilot/packs/frontend/skills/design-taste-frontend/scripts/draw.mjs +584 -0
  139. package/assets/copilot/packs/frontend/skills/design-taste-frontend/scripts/inspect.mjs +303 -0
  140. package/assets/copilot/packs/frontend/skills/design-taste-frontend/scripts/lib/app-draw.mjs +203 -0
  141. package/assets/copilot/packs/frontend/skills/design-taste-frontend/scripts/lib/cdp.mjs +248 -0
  142. package/assets/copilot/packs/frontend/skills/design-taste-frontend/scripts/lib/probe.mjs +909 -0
  143. package/assets/copilot/packs/frontend/skills/design-taste-frontend/scripts/lib/rules.mjs +243 -0
  144. package/assets/copilot/packs/frontend/skills/design-taste-frontend/scripts/lint.mjs +119 -0
  145. package/assets/copilot/packs/frontend/skills/design-taste-frontend/scripts/scenes.mjs +124 -0
  146. package/assets/copilot/packs/playwright/agents/playwright-component-analyzer.agent.md +1 -1
  147. package/assets/copilot/packs/playwright/agents/playwright-scenario-scanner.agent.md +1 -1
  148. package/assets/copilot/packs/playwright/agents/playwright-test-generator.agent.md +1 -1
  149. package/assets/copilot/packs/playwright/agents/playwright-test-planner.agent.md +1 -1
  150. package/assets/copilot/packs/playwright/agents/playwright-tester.agent.md +1 -1
  151. package/assets/manifest.json +2 -0
  152. package/dist/cli.min.js +102 -102
  153. package/package.json +1 -1
  154. package/scripts/__snapshots__/generate-claude-assets.test.js.snap +1 -1
  155. package/scripts/generate-claude-assets.strict.test.js +1 -1
  156. package/scripts/generate-claude-assets.test.js +6 -6
  157. package/scripts/packs-layout.test.js +2 -1
@@ -0,0 +1,980 @@
1
+ # Motion
2
+
3
+ Read this before adding any animation. The static page must already be finished and passing
4
+ `inspect.mjs`; motion is the last pass, never a way to rescue a weak layout.
5
+
6
+ ## Contents
7
+
8
+ 1. The budget
9
+ 2. What strong pages do with motion
10
+ 3. Four questions before any animation
11
+ 4. Tokens
12
+ 5. Scroll behaviours, ranked
13
+ 6. Recipes (copy, then adapt)
14
+ 7. Signature moves
15
+ 8. Reduced motion and performance
16
+ 9. What gives generated motion away
17
+ 10. Rules for motion
18
+
19
+ ## 1. The budget
20
+
21
+ A page that does not move reads as a mock-up; the inspector counts what moves while the page loads and
22
+ scrolls, and fails it under eight elements. The motion layer has five parts:
23
+
24
+ - **Two or three scroll scenes:** the big, scroll-driven moments award sites are remembered for (a
25
+ camera flying through a field of pictures, a picture that grows from a window to the whole screen,
26
+ type that crosses a held stage, cards that deal out and stack). The direction sheet deals them from
27
+ `library/scenes.json` when the library has scenes for the brief; `references/scenes.md` says how to
28
+ fill and fit them. They are where the page is bold; everything else stays quiet so they land.
29
+
30
+ - **One signature moment:** the thing a visitor would describe an hour later: the headline that rises
31
+ out of a mask, the image that opens like a shutter, the wordmark that scales with the scroll. The
32
+ direction sheet from `draw.mjs` names one. Build that one well; it is the only move allowed to be
33
+ loud.
34
+ - **Section entrances, choreographed per section:** the parts of each section arrive in reading order
35
+ (heading, text, picture, details), staggered 60–90ms, rising 16–32px with opacity, once. Vary the
36
+ manner with the content instead of stamping one preset everywhere: a list arrives row by row, a
37
+ picture opens from a mask or settles from a slight scale, a figure counts up once, a statement rises
38
+ line by line.
39
+ - **Feedback under the hand:** buttons that give on press, links that answer the pointer, cards and
40
+ pictures that respond on hover, a menu that opens from where it was clicked.
41
+ - **One moving picture:** a found clip as a muted loop with a poster frame, a slow drift or scale on
42
+ a large photograph, or a scroll-linked move on the hero picture.
43
+
44
+ What is still banned: the same fade-and-slide-up with the same timing on every block, which is the most
45
+ cited motion tell of generated pages; motion that hides content until a script runs, with no fallback;
46
+ bounce and elastic easing; anything that animates layout properties. Uniform motion says nobody decided
47
+ anything; choreographed motion with one protected moment says somebody did.
48
+
49
+ ## 2. What strong pages do with motion
50
+
51
+ Measured on the latest 99 Awwwards Sites of the Day (July to October 2026), at 1440 and 390, with
52
+ the transitions, tweens and scroll triggers recorded while each page loaded and was scrolled. Where
53
+ the gallery does something the house rules ban, the house version follows.
54
+
55
+ - **The stack.** Two in three use a smooth-scroll library and three in five ship Three.js; GSAP is
56
+ named on 56. The quiet layer below (reveals, line rises, the ink fill) needs none of it: CSS scroll
57
+ timelines and view transitions do that work, though only 3 and 6 of the sites use them. The big
58
+ scroll scenes are built on GSAP ScrollTrigger, as on the award sites, through the shared stage in
59
+ `assets/scenes/` (`references/scenes.md`).
60
+ - **Curves.** The curves that really ran are strong ease-outs: expo out (`.16,1,.3,1` and
61
+ `.19,1,.22,1`) ran on 14 sites, cubic out on 13, quint out on 7, and by number of transitions the
62
+ quint and expo outs lead every chosen curve. In-out curves are kept for wipes and things that
63
+ travel (cubic and quart in-out, about 15 sites each). Ease-in appears only for exits, short, before
64
+ a slow arrival. Colour changes use plain `ease` (46 sites). Overshoot sits in 19 stylesheets and
65
+ ran on 2.
66
+ - **Durations.** Hover colour 200-300 ms; small interface moves 300-450 ms; reveals and line rises
67
+ 600-1,000 ms; picture openings and wipes 1,100-1,600 ms. The median of everything that ran on a
68
+ site is 420 ms.
69
+ - **Staggers.** 20-40 ms between characters, 75-120 ms between lines, 50-100 ms between list items.
70
+ - **Scroll-linked motion is locked to the scrollbar.** Of the sites that scrub, 14 of 18 lock it with
71
+ no smoothing; the other four smooth it by 0.1-3 s. Reveals fire when the element's top is 80-95% of the way
72
+ down the viewport, once.
73
+ - **How much moves.** A median of 130 elements are moved by script on a page, but they come from a
74
+ few gestures: one split heading is 20-60 elements. The inspector's floor (8, warning under 16) is a
75
+ floor, not a target.
76
+ - **Waiting is the most common cost.** 60 of 99 open on a loading screen, 26 of them with a counter.
77
+ Half the usable pages were still unreadable 2.5 s after the browser said the page had loaded, a
78
+ third at 4.5 s. The preloaders were not covering slow pages (their documents loaded faster than the
79
+ rest) and the readers' design marks were the same whatever the wait; only usability fell. House
80
+ version: no preloader, the message readable on the first frame, the load sequence over by 1.2 s.
81
+ - **Pins cost length.** Two in three scrolling pages hold something sticky or pinned. Those pages
82
+ are twice as long (a median 16.6 screens against 7.5) for fewer words per screen (27 against 46).
83
+ House version: at most two held stages, about one screen per step, three screens per stage.
84
+ - **Reduced motion is mostly missing.** 35 of 99 have any `prefers-reduced-motion` rule, 16 have
85
+ three or more; 5 of 26 scroll-scrubbed scenes have one. Every recipe here keeps the finished state
86
+ as the reduced state.
87
+
88
+ ## 3. Four questions before any animation
89
+
90
+ 1. **How often will someone see it?** Once per visit (hero load, a section reveal): it can take its
91
+ time. Many times per visit (hover, menu, accordion): fast and small. Hundreds of times (keyboard
92
+ actions, typing): no animation.
93
+ 2. **What is it for?** Valid answers: it shows where something came from, it confirms an action, it
94
+ keeps a change from being jarring, it explains how the product works, it is the signature moment.
95
+ "It looks nice" is only valid for the signature moment.
96
+ 3. **Which easing?** Entering or leaving: ease-out, so movement starts the instant the eye arrives.
97
+ Moving between two places on screen: ease-in-out. Colour and opacity on hover: `ease`. Constant
98
+ travel (marquee, progress): `linear`. `ease-in` makes interfaces feel slow because the first frames
99
+ barely move; do not use it. The CSS keyword curves are weak, so use the tokens below.
100
+ 4. **How long?** Press feedback 100–160ms. Hover 150–200ms. Menus and popovers 150–250ms. Dialogs and
101
+ drawers 250–450ms. Section and hero reveals 500–900ms. Leaving is quicker than arriving (about 70%).
102
+
103
+ ## 4. Tokens
104
+
105
+ ```css
106
+ :root {
107
+ --ease-out: cubic-bezier(0.22, 1, 0.36, 1); /* entrances, moving hovers, UI under 600ms */
108
+ --ease-expo: cubic-bezier(0.16, 1, 0.3, 1); /* new: reveals of 800ms and longer, line rises, openings */
109
+ --ease-in-out: cubic-bezier(0.76, 0, 0.24, 1); /* wipes, panels, things that travel while visible */
110
+ --ease-soft: cubic-bezier(0.33, 1, 0.68, 1); /* slow scale and drift on photographs */
111
+ --ease-exit: cubic-bezier(0.55, 0.055, 0.675, 0.19); /* new: something leaving for good, short */
112
+ --t-press: 140ms;
113
+ --t-hover: 180ms;
114
+ --t-ui: 240ms;
115
+ --t-reveal: 800ms;
116
+ --t-open: 1200ms; /* new: picture openings and clip wipes */
117
+ --stagger-line: 90ms; /* new */
118
+ --stagger-item: 70ms; /* new */
119
+ }
120
+ ```
121
+
122
+ How the study bears on each:
123
+
124
+ - `--ease-out` (quint out) is in 28 stylesheets with its near twin and is the single most frequent
125
+ chosen curve among transitions that ran. Keep it.
126
+ - `--ease-expo` is new. Expo out (`.16,1,.3,1` and `.19,1,.22,1`) is in 28 stylesheets and ran on 14
127
+ sites, more than any other chosen family, and it is the curve the long reveals use: line rises,
128
+ picture openings, card entrances of 0.9-1.6 s. Use it for anything at `--t-reveal` or longer; it
129
+ replaces `--ease-out` in the masked headline and the reveal-on-scroll recipes.
130
+ - `--ease-in-out` (quart in-out) matches its family (14 stylesheets, 7 ran). Keep it for wipes,
131
+ shutters, menus and page transitions.
132
+ - `--ease-soft` (cubic out) matches the cubic-out family (21 stylesheets, 13 ran), but the study uses it
133
+ for interface moves of 0.3-0.8 s, where `--ease-out` already serves. Narrow it to slow scale and
134
+ drift on photographs.
135
+ - `--ease-exit` is new. Ease-in is right for exactly one job: something that leaves for good (an
136
+ outgoing caption, a replaced slide, a closing panel), short, at about 60-70% of the matching
137
+ entrance. Never for anything the visitor is waiting to see.
138
+ - Not adopted: `.4,0,.2,1` (38 stylesheets) is a framework default, and overshoot curves (19) stay
139
+ banned on interface elements.
140
+ - `--t-hover` stays at 180ms. The study's colour hovers run 200-300ms; a little quicker reads as
141
+ more exact. A hover that moves something (a label roll, an arrow) takes `--t-ui` to 400ms.
142
+
143
+ ## 5. Scroll behaviours, ranked
144
+
145
+ | Behaviour | Pages of 99 | What it serves | House version |
146
+ | --- | --- | --- | --- |
147
+ | Smooth-scroll library | 67 | A feeling of weight; makes scrubbed scenes easier to build | Optional, through `Stage.smooth()`: Lenis at lerp 0.1-0.14, off for touch and reduced motion |
148
+ | Held (sticky or pinned) stage | 44 | One picture or object carries several claims; chapters | Yes: the scroll scenes (`references/scenes.md`), two or three per page, one to four screens each; other sticky stages three screens at most |
149
+ | Split text | 40 | Lines and words arriving as they are read; hover rolls | Lines for headings and statements; words only for the ink fill; characters only for a heading of one to three words |
150
+ | Colour by chapter | 34 | Tells chapters apart without padding bands | Yes, as hard cuts at section edges; the header ink follows in 300ms |
151
+ | Scroll-scrubbed scene (camera, object, image sequence) | 26 | Explains a product by moving it | Yes, from the scene library, built on the shared stage with a finished composition for reduced motion |
152
+ | Parallax depth | 24 | Depth in collages; pictures drifting inside oversized frames | Travel under 8% of the element, pictures held at 106-110% in their frames |
153
+ | Marquee | 21 | Life on a static band | Existing "Edge-to-edge marquee": one per page, pausable |
154
+ | Masked line or word rise | 18 | Headings that arrive like set type | Yes: "Masked line reveal" on load, "Lines that rise on scroll" below the fold |
155
+ | Wheel drives a scene, no page scroll | 16 | Browsing one object | Not used (see 2.6) |
156
+ | Blur-in | 15 | Soft arrival | At most 8px of blur, 600ms, gone once settled; never on body text |
157
+ | Picture from inset frame to full-bleed | 14 | The hand-over from a type screen to the big picture | Yes: "Opening window", about one screen of scroll |
158
+ | Grey-to-ink text fill | 14 | Paces one statement | Yes, once per page, starting at 45% ink: "Ink fill" |
159
+ | Sheet slides over a held layer | 11 | Page-turn rhythm between chapters | Yes: new move "Sheet over a held picture" |
160
+ | Scramble or decode | 11 | A "code" voice | Not used (see 2.6) |
161
+ | Header ink by chapter | 10 | The fixed header stays legible | Yes, with colour by chapter |
162
+ | Horizontal track pinned to vertical scroll | 8 | Walking through rooms or a collection | Native drag row first; pinned only when the row is under three screens long, with keyboard support: "Sideways gallery" |
163
+ | Curtain footer | 8 | The page ends as a scene | Existing move |
164
+ | Rolling digits | 6 | Figures that arrive | Yes, for pages whose case is a handful of numbers: "Rolling figures" |
165
+ | Index with hover preview | 5 | Navigating work by name | Existing move, upgraded recipe |
166
+
167
+ Text, split by unit: characters on about 20 pages, lines on about 14, words on about 11. Character
168
+ splits serve short headings and button rolls; on long statements they leave fragments mid-scroll.
169
+
170
+ ## 6. Recipes
171
+
172
+ All recipes keep the content visible when JavaScript is off, put every hidden state inside
173
+ `prefers-reduced-motion: no-preference`, and use the tokens above. Two notes that apply to several:
174
+
175
+ - **Scroll timelines need a scroller to watch.** `animation-timeline: view()` takes the nearest scroll
176
+ container, and `overflow: hidden` makes one. A picture inside an `overflow: hidden` frame therefore
177
+ never animates. Use `overflow: clip` on frames, or name the timeline on the section
178
+ (`view-timeline-name: --hero`) and point the children at that name. This also applies to the
179
+ existing "Scroll-linked progress" recipe.
180
+ - **Scroll-linked means linear.** The scroll is the easing. 14 of the 18 scrubbing sites in the study
181
+ lock motion to the scrollbar, which is what CSS scroll timelines do.
182
+
183
+ All recipes keep content visible when JavaScript is off or slow: the hidden state only applies under
184
+ `html.js`, which a one-line inline script sets.
185
+
186
+ ```html
187
+ <script>document.documentElement.classList.add('js')</script>
188
+ ```
189
+
190
+ ### Load sequence in 1.2 seconds
191
+
192
+ Everything is in the HTML and painted on the first frame; the sequence only adds arrival. Nothing
193
+ waits for the load event, and nothing in the navigation animates.
194
+
195
+ ```html
196
+ <header class="hero">
197
+ <h1 class="display lines">
198
+ <span class="line"><span data-line style="--i:0">Stools for</span></span>
199
+ <span class="line"><span data-line style="--i:1">small kitchens</span></span>
200
+ </h1>
201
+ <p class="lead" data-load style="--d:420ms">…</p>
202
+ <a class="button" data-load style="--d:480ms" href="#stools">See the stools</a>
203
+ <figure class="hero__picture" data-open><img src="stool.jpg" alt="…" fetchpriority="high"></figure>
204
+ </header>
205
+ ```
206
+
207
+ ```css
208
+ .hero__picture { overflow: clip; margin: 0; }
209
+ @media (prefers-reduced-motion: no-preference) {
210
+ .js .hero [data-line] { transform: translateY(105%); animation: load-line 800ms var(--ease-expo) forwards; animation-delay: calc(100ms + var(--i) * var(--stagger-line)); }
211
+ .js .hero [data-load] { opacity: 0; transform: translateY(12px); animation: load-rise 500ms var(--ease-out) forwards; animation-delay: var(--d); }
212
+ .js .hero [data-open] { clip-path: inset(0 0 100% 0); animation: load-open 1000ms var(--ease-in-out) 150ms forwards; }
213
+ .js .hero [data-open] img { transform: scale(1.08); animation: load-settle 1100ms var(--ease-expo) 100ms forwards; }
214
+ }
215
+ @keyframes load-line { to { transform: none; } }
216
+ @keyframes load-rise { to { opacity: 1; transform: none; } }
217
+ @keyframes load-open { to { clip-path: inset(0); } }
218
+ @keyframes load-settle { to { transform: none; } }
219
+ ```
220
+
221
+ The timeline: the headline's lines rise from 100 to 990 ms, the picture opens from 150 to 1,150 ms,
222
+ the sentence and the button arrive from 420 to 980 ms. All settled by 1.2 s, four things at most.
223
+ Preload the display font (`<link rel="preload" as="font" type="font/woff2" crossorigin>`) so the lines
224
+ rise in the real face, and give the picture `fetchpriority="high"` and a ground colour sampled from it,
225
+ so a slow image opens onto its own colour rather than a blank box. Study: 22 of 91 pages were readable
226
+ at the first intro frame and 11 more by 1.2 s; the intros the readers called respectful finished in
227
+ about 1-1.2 s; a third of the pages were still unreadable at 4.5 s.
228
+
229
+ ### Masked line reveal (the classic gallery headline)
230
+
231
+ Each line slides up from behind its own baseline. Because headlines are capped at two lines, you set
232
+ the line breaks yourself and wrap each line.
233
+
234
+ ```html
235
+ <h1 class="lines">
236
+ <span class="line"><span data-line style="--i:0">Stools for</span></span>
237
+ <span class="line"><span data-line style="--i:1">small kitchens</span></span>
238
+ </h1>
239
+ ```
240
+
241
+ ```css
242
+ .lines .line { display: block; overflow: hidden; padding-bottom: 0.08em; margin-bottom: -0.08em; }
243
+ .lines [data-line] { display: block; }
244
+ @media (prefers-reduced-motion: no-preference) {
245
+ .js .lines [data-line] {
246
+ transform: translateY(105%);
247
+ animation: line-up 900ms var(--ease-out) forwards;
248
+ animation-delay: calc(var(--i) * 110ms + 150ms);
249
+ }
250
+ }
251
+ @keyframes line-up { to { transform: none; } }
252
+ ```
253
+
254
+ The `padding-bottom` / negative margin pair stops descenders (g, y, p) being clipped by the mask.
255
+ On a narrow phone the two authored lines may each wrap; check the mobile screenshot, and if they do,
256
+ author a shorter mobile break with a `<br class="only-wide">` pattern or reduce the size.
257
+
258
+ ### Reveal on scroll (once)
259
+
260
+ For the two or three sections that deserve it, not for every block.
261
+
262
+ ```css
263
+ @media (prefers-reduced-motion: no-preference) {
264
+ .js [data-reveal] { opacity: 0; transform: translateY(24px); transition: opacity 700ms var(--ease-out), transform 900ms var(--ease-out); }
265
+ .js [data-reveal].is-in { opacity: 1; transform: none; }
266
+ }
267
+ ```
268
+
269
+ ```js
270
+ const io = new IntersectionObserver((entries) => {
271
+ for (const e of entries) if (e.isIntersecting) { e.target.classList.add('is-in'); io.unobserve(e.target); }
272
+ }, { rootMargin: '0px 0px -12% 0px', threshold: 0.1 });
273
+ document.querySelectorAll('[data-reveal]').forEach((el) => io.observe(el));
274
+ ```
275
+
276
+ Never attach reveals with a scroll listener, never replay them when the element re-enters, and never
277
+ leave anything at `opacity: 0` if the observer fails to fire (the `html.js` gate handles no-JS; the
278
+ `unobserve` handles replays).
279
+
280
+ ### Image shutter
281
+
282
+ An image opens from one edge while scaling down slightly inside its frame. Gallery staple; use it for
283
+ the one hero or showcase image.
284
+
285
+ ```css
286
+ .shutter { overflow: hidden; }
287
+ @media (prefers-reduced-motion: no-preference) {
288
+ .js .shutter { clip-path: inset(0 0 100% 0); transition: clip-path 1100ms var(--ease-in-out); }
289
+ .js .shutter img { transform: scale(1.12); transition: transform 1500ms var(--ease-soft); }
290
+ .js .shutter.is-in { clip-path: inset(0); }
291
+ .js .shutter.is-in img { transform: none; }
292
+ }
293
+ ```
294
+
295
+ ### Press and hover
296
+
297
+ ```css
298
+ .button { transition: transform var(--t-press) var(--ease-out), background-color var(--t-hover) ease, color var(--t-hover) ease; }
299
+ .button:active { transform: scale(0.97); }
300
+ .link { text-decoration: underline; text-decoration-color: transparent; text-underline-offset: 0.2em; text-decoration-thickness: 0.07em; transition: text-decoration-color var(--t-hover) ease; }
301
+ @media (hover: hover) and (pointer: fine) {
302
+ .button:hover { background-color: var(--accent-strong); }
303
+ .link:hover { text-decoration-color: currentColor; }
304
+ }
305
+ ```
306
+
307
+ Hover styles go behind `(hover: hover)` because touch devices fire hover on tap and leave it stuck.
308
+ Hover should raise contrast, never lower it. Do not change font weight on hover (the text reflows).
309
+ Do not lift or scale every card on hover; one consistent, small response is enough.
310
+
311
+ ### Scroll-linked progress without JavaScript
312
+
313
+ For a wordmark that scales as the hero leaves, an image that drifts a few percent, or a progress bar.
314
+ Wrap in `@supports` so browsers without scroll timelines get the static state.
315
+
316
+ ```css
317
+ @supports (animation-timeline: view()) {
318
+ @media (prefers-reduced-motion: no-preference) {
319
+ .drift { animation: drift linear both; animation-timeline: view(); animation-range: entry 0% exit 100%; }
320
+ @keyframes drift { from { transform: translateY(-4%); } to { transform: translateY(4%); } }
321
+ }
322
+ }
323
+ ```
324
+
325
+ Keep the travel small (under 8% of the element). Large parallax makes text hard to read and looks
326
+ dated.
327
+
328
+ ### Pinned story
329
+
330
+ A section whose heading stays put while items pass. Use `position: sticky`; do not hijack scrolling.
331
+
332
+ ```css
333
+ .story { display: grid; grid-template-columns: 5fr 7fr; gap: var(--gutter); align-items: start; }
334
+ .story__title { position: sticky; top: 18vh; }
335
+ @media (max-width: 800px) { .story { grid-template-columns: 1fr; } .story__title { position: static; } }
336
+ ```
337
+
338
+ ### Index with preview, anchored to the row
339
+
340
+ Rows of names; pointing at a row or tabbing to it opens one frame level with that row. The frame
341
+ follows the row, never the pointer.
342
+
343
+ ```html
344
+ <section class="index">
345
+ <ul class="index__list">
346
+ <li><a class="index__row" href="/work/harbour-house" data-preview="/img/harbour-house.jpg">
347
+ <span class="index__name">Harbour House</span><span class="index__year">2025</span>
348
+ <img class="index__thumb" src="/img/harbour-house-small.jpg" alt="" loading="lazy" width="640" height="400">
349
+ </a></li>
350
+ <!-- … -->
351
+ </ul>
352
+ <div class="index__frame" aria-hidden="true"><img alt=""></div>
353
+ </section>
354
+ ```
355
+
356
+ ```css
357
+ .index { position: relative; }
358
+ .index__list { list-style: none; margin: 0; padding: 0; }
359
+ .index__row { display: flex; flex-wrap: wrap; justify-content: space-between; align-items: baseline; gap: 0 var(--gutter); padding-block: 0.3em; font-size: var(--step-head); color: inherit; text-decoration: none; transition: opacity var(--t-hover) ease; }
360
+ .index__thumb { order: -1; flex-basis: 100%; width: 100%; height: auto; margin-bottom: 0.4rem; }
361
+ .index__frame { display: none; }
362
+ @media (hover: hover) and (pointer: fine) {
363
+ .index__list { max-width: 62%; }
364
+ .index__thumb { display: none; }
365
+ .index__list:has(.index__row:hover, .index__row:focus-visible) .index__row:not(:hover, :focus-visible) { opacity: 0.4; }
366
+ .index__frame { display: block; position: absolute; top: 0; right: var(--margin); width: 30%; aspect-ratio: 4 / 3; z-index: 1; pointer-events: none; clip-path: inset(100% 0 0 0); transition: clip-path 320ms var(--ease-out), transform 360ms var(--ease-out); }
367
+ .index__frame.is-open { clip-path: inset(0); }
368
+ .index__frame img { width: 100%; height: 100%; object-fit: cover; }
369
+ }
370
+ @media (prefers-reduced-motion: reduce) { .index__frame { transition: none; } }
371
+ ```
372
+
373
+ ```js
374
+ (() => {
375
+ const root = document.querySelector('.index');
376
+ if (!root) return;
377
+ const frame = root.querySelector('.index__frame');
378
+ const picture = frame.querySelector('img');
379
+ const fine = matchMedia('(hover: hover) and (pointer: fine)');
380
+ let warmed = false;
381
+ const show = (row) => {
382
+ if (!fine.matches) return;
383
+ if (!warmed) { warmed = true; root.querySelectorAll('[data-preview]').forEach((a) => { new Image().src = a.dataset.preview; }); }
384
+ picture.src = row.dataset.preview;
385
+ const y = row.offsetTop + row.offsetHeight / 2 - frame.offsetHeight / 2;
386
+ const max = root.offsetHeight - frame.offsetHeight;
387
+ const opening = !frame.classList.contains('is-open');
388
+ if (opening) frame.style.transitionProperty = 'clip-path'; // open in place, do not slide in from the top
389
+ frame.style.transform = `translateY(${Math.min(max, Math.max(0, y))}px)`;
390
+ if (opening) { frame.getBoundingClientRect(); frame.style.transitionProperty = ''; }
391
+ frame.classList.add('is-open');
392
+ };
393
+ const hide = () => frame.classList.remove('is-open');
394
+ root.querySelectorAll('.index__row').forEach((row) => {
395
+ row.addEventListener('pointerenter', () => show(row));
396
+ row.addEventListener('focus', () => show(row));
397
+ });
398
+ root.addEventListener('pointerleave', hide);
399
+ root.addEventListener('focusout', (e) => { if (!root.contains(e.relatedTarget)) hide(); });
400
+ })();
401
+ ```
402
+
403
+ On touch screens each row shows its own picture above the name. Study: 5 pages navigate work this way;
404
+ frames from about 470x270 to large plates behind the list; the other rows dimmed to about 0.3 (0.4 here, the floor for dimmed text). The
405
+ image trails and cursor-borne previews seen elsewhere in the study are the gimmick version.
406
+
407
+ ### Lines that rise on scroll
408
+
409
+ For a section heading or a statement whose line breaks you do not control. Splits after the fonts
410
+ load, reveals once, then gives the plain text back so it rewraps normally. Plain text only (no links or
411
+ emphasis inside).
412
+
413
+ ```html
414
+ <h2 class="head" data-lines>Turned by hand in one small workshop</h2>
415
+ ```
416
+
417
+ ```css
418
+ [data-lines] .line { display: block; overflow: clip; padding-bottom: 0.12em; margin-bottom: -0.12em; }
419
+ [data-lines] .line > span { display: block; }
420
+ @media (prefers-reduced-motion: no-preference) {
421
+ .is-split .line > span { transform: translateY(110%); }
422
+ .is-split.is-in .line > span { transform: none; transition: transform 900ms var(--ease-expo) calc(var(--i) * var(--stagger-line)); }
423
+ }
424
+ ```
425
+
426
+ ```js
427
+ (() => {
428
+ const els = [...document.querySelectorAll('[data-lines]')];
429
+ if (!els.length || !('IntersectionObserver' in window) || matchMedia('(prefers-reduced-motion: reduce)').matches) return;
430
+ const restore = (el) => { el.textContent = el.dataset.text; el.classList.remove('is-split', 'is-in'); };
431
+ const split = (el) => {
432
+ const text = el.textContent.trim().replace(/\s+/g, ' ');
433
+ el.dataset.text = text;
434
+ el.textContent = '';
435
+ const words = text.split(' ').map((word) => {
436
+ const s = document.createElement('span');
437
+ s.style.display = 'inline-block';
438
+ s.textContent = word;
439
+ el.append(s, ' ');
440
+ return s;
441
+ });
442
+ const lines = []; // group the words by the line they landed on
443
+ for (const w of words) {
444
+ const last = lines.at(-1);
445
+ if (last && Math.abs(last.top - w.offsetTop) < 4) last.words.push(w.textContent);
446
+ else lines.push({ top: w.offsetTop, words: [w.textContent] });
447
+ }
448
+ el.textContent = '';
449
+ lines.forEach((line, i) => {
450
+ const mask = document.createElement('span');
451
+ const inner = document.createElement('span');
452
+ mask.className = 'line';
453
+ inner.style.setProperty('--i', i);
454
+ inner.textContent = line.words.join(' ');
455
+ mask.append(inner);
456
+ el.append(mask, ' ');
457
+ });
458
+ el.classList.add('is-split');
459
+ };
460
+ const io = new IntersectionObserver((entries) => {
461
+ for (const e of entries) {
462
+ if (!e.isIntersecting) continue;
463
+ io.unobserve(e.target);
464
+ e.target.classList.add('is-in');
465
+ e.target.querySelector('.line:last-of-type > span')
466
+ .addEventListener('transitionend', () => restore(e.target), { once: true });
467
+ }
468
+ }, { rootMargin: '0px 0px -12% 0px' });
469
+ document.fonts.ready.then(() => els.forEach((el) => { split(el); io.observe(el); }));
470
+ let width = innerWidth; // a width change before the reveal: give the text back
471
+ addEventListener('resize', () => {
472
+ if (innerWidth === width) return;
473
+ width = innerWidth;
474
+ els.filter((el) => el.matches('.is-split:not(.is-in)')).forEach((el) => { io.unobserve(el); restore(el); });
475
+ });
476
+ })();
477
+ ```
478
+
479
+ Study: masked rises from 100-120% below the line, 0.6-1.2 s, 75-120 ms between lines, triggered when
480
+ the block's top is 80-95% down the viewport. Reduced motion: the script does not run; the text is plain.
481
+
482
+ ### Opening window
483
+
484
+ The hand-over from a type screen to the one big picture: the picture first shows as a window in the
485
+ middle of a held stage and opens to full-bleed over about one screen of scroll.
486
+
487
+ ```html
488
+ <section class="opening">
489
+ <div class="opening__stage">
490
+ <img src="workshop-wide.jpg" alt="The workshop at first light, benches cleared" width="2400" height="1500">
491
+ </div>
492
+ </section>
493
+ ```
494
+
495
+ ```css
496
+ .opening__stage { height: 100svh; overflow: clip; }
497
+ .opening__stage img { display: block; width: 100%; height: 100%; object-fit: cover; }
498
+ @supports (animation-timeline: view()) {
499
+ @media (prefers-reduced-motion: no-preference) {
500
+ .opening { height: 200svh; view-timeline-name: --opening; --inset-y: 20%; --inset-x: 28%; }
501
+ .opening__stage { position: sticky; top: 0; animation: opening-window linear both; animation-timeline: --opening; animation-range: contain 0% contain 75%; }
502
+ .opening__stage img { animation: opening-settle linear both; animation-timeline: --opening; animation-range: contain 0% contain 75%; }
503
+ }
504
+ }
505
+ @media (max-width: 700px) { .opening { --inset-y: 18%; --inset-x: 8%; } }
506
+ @keyframes opening-window { from { clip-path: inset(var(--inset-y) var(--inset-x)); } to { clip-path: inset(0); } }
507
+ @keyframes opening-settle { from { transform: scale(1.15); } to { transform: none; } }
508
+ ```
509
+
510
+ The section is two screens tall; the stage is held for one, and the window is fully open after three
511
+ quarters of it, so the full-bleed picture rests for a moment before the page moves on. Study: 14 pages;
512
+ start windows 38-60% of the width; runways 600-1,800 px, and the readers asked for one to two screens.
513
+ Without scroll timelines, and under reduced motion, the picture is a full-bleed screen.
514
+
515
+ ### Held picture, passing captions
516
+
517
+ The pinned story built for photographs: the picture holds on one side while short captions pass on
518
+ the other; the caption at the middle of the screen is lit and its picture is shown.
519
+
520
+ ```html
521
+ <section class="steps">
522
+ <figure class="steps__media">
523
+ <div class="steps__pictures">
524
+ <img src="seasoning.jpg" alt="Ash boards stacked to season" class="is-on">
525
+ <img src="turning.jpg" alt="A leg being turned on the lathe">
526
+ <img src="finish.jpg" alt="Oil rubbed into a finished seat">
527
+ </div>
528
+ <figcaption class="steps__count" aria-hidden="true"></figcaption>
529
+ </figure>
530
+ <ol class="steps__list">
531
+ <li class="steps__item"><h3>Seasoned for a year</h3><p>…</p></li>
532
+ <li class="steps__item"><h3>Turned by hand</h3><p>…</p></li>
533
+ <li class="steps__item"><h3>Finished with oil</h3><p>…</p></li>
534
+ </ol>
535
+ </section>
536
+ ```
537
+
538
+ ```css
539
+ .steps { display: grid; grid-template-columns: 7fr 5fr; column-gap: var(--gutter); }
540
+ .steps__media { margin: 0; align-self: start; } /* a stretched grid item cannot stick */
541
+ .steps__pictures img { display: block; width: 100%; height: auto; }
542
+ .steps__list { list-style: none; margin: 0; padding: 0; }
543
+ .steps__item { min-height: 80svh; display: flex; flex-direction: column; justify-content: center; }
544
+ .js .steps__media { position: sticky; top: 0; height: 100svh; display: flex; flex-direction: column; justify-content: center; gap: 0.75rem; }
545
+ .js .steps__pictures { position: relative; aspect-ratio: 4 / 5; max-height: 80svh; }
546
+ .js .steps__pictures img { position: absolute; inset: 0; height: 100%; object-fit: cover; opacity: 0; transition: opacity 500ms ease; }
547
+ .js .steps__pictures img.is-on { opacity: 1; }
548
+ .js .steps__item { opacity: 0.4; transition: opacity var(--t-ui) ease; }
549
+ .js .steps__item.is-active { opacity: 1; }
550
+ @media (max-width: 800px) {
551
+ .steps { display: block; } /* the section, not a grid area, contains the sticky picture */
552
+ .js .steps__media { height: auto; z-index: 1; background: var(--bg); padding-block: 0.5rem; }
553
+ .js .steps__pictures { aspect-ratio: 3 / 2; max-height: 42svh; }
554
+ .steps__item { min-height: 60svh; }
555
+ }
556
+ @media (prefers-reduced-motion: reduce) { .js .steps__pictures img, .js .steps__item { transition: none; } }
557
+ ```
558
+
559
+ ```js
560
+ (() => {
561
+ const root = document.querySelector('.steps');
562
+ if (!root || !('IntersectionObserver' in window)) return;
563
+ const items = [...root.querySelectorAll('.steps__item')];
564
+ const pictures = [...root.querySelectorAll('.steps__pictures img')];
565
+ const count = root.querySelector('.steps__count');
566
+ const show = (i) => {
567
+ items.forEach((el, k) => el.classList.toggle('is-active', k === i));
568
+ pictures.forEach((el, k) => el.classList.toggle('is-on', k === i));
569
+ if (count) count.textContent = `${i + 1} of ${items.length}`;
570
+ };
571
+ const io = new IntersectionObserver((entries) => {
572
+ for (const e of entries) if (e.isIntersecting) show(items.indexOf(e.target));
573
+ }, { rootMargin: '-45% 0px -45% 0px' }); // a band across the middle of the screen
574
+ items.forEach((el) => io.observe(el));
575
+ show(0);
576
+ })();
577
+ ```
578
+
579
+ Without JavaScript the pictures and captions are two plain columns. Study: inactive items at 20-40%
580
+ (dims of 10-15% read as missing text, so the floor here is 40%), crossfades of 400-600 ms, about one
581
+ screen per step; the readers asked for a visible count on any held sequence of more than two steps.
582
+ The count is a plain sentence in the body face, not a zero-padded numeral.
583
+
584
+ ### Sideways gallery
585
+
586
+ A row of pictures that the vertical scroll moves sideways while the stage is held, for a short
587
+ collection (eight items or fewer). The native drag row is the base; the pinned version is added only
588
+ on wide screens with motion allowed, and only when the row is under three screens long.
589
+
590
+ ```html
591
+ <section class="gallery">
592
+ <div class="gallery__stage">
593
+ <h2 class="head">Eight kitchens, one stool</h2>
594
+ <ul class="gallery__track">
595
+ <li class="gallery__item"><a href="/kitchens/leith"><img src="leith.jpg" alt="A narrow galley kitchen in Leith" width="1200" height="1500"><span>Leith</span></a></li>
596
+ <!-- … -->
597
+ </ul>
598
+ </div>
599
+ </section>
600
+ ```
601
+
602
+ ```css
603
+ .gallery__track { display: flex; gap: var(--gutter); list-style: none; margin: 0; padding: 0 var(--margin); overflow-x: auto; scroll-snap-type: x mandatory; scroll-padding-inline: var(--margin); }
604
+ .gallery__item { flex: 0 0 min(72vw, 28rem); scroll-snap-align: start; }
605
+ .gallery__item img { display: block; width: 100%; height: auto; aspect-ratio: 4 / 5; object-fit: cover; }
606
+ .gallery.is-pinned .gallery__stage { position: sticky; top: 0; height: 100svh; display: flex; flex-direction: column; justify-content: center; gap: 2rem; overflow: clip; }
607
+ .gallery.is-pinned .gallery__track { overflow: visible; scroll-snap-type: none; }
608
+ .gallery.is-pinned .gallery__item { flex-basis: calc(62svh * 0.8); }
609
+ ```
610
+
611
+ ```js
612
+ (() => {
613
+ const root = document.querySelector('.gallery');
614
+ if (!root) return;
615
+ const track = root.querySelector('.gallery__track');
616
+ const wide = matchMedia('(min-width: 900px) and (prefers-reduced-motion: no-preference)');
617
+ let dx = 0, queued = false;
618
+ const place = () => {
619
+ queued = false;
620
+ if (!root.classList.contains('is-pinned')) return;
621
+ const p = Math.min(1, Math.max(0, -root.getBoundingClientRect().top / dx));
622
+ track.style.transform = `translate3d(${(-p * dx).toFixed(1)}px, 0, 0)`;
623
+ };
624
+ const measure = () => {
625
+ root.classList.add('is-pinned'); // measure the row as it would be when pinned
626
+ track.style.transform = '';
627
+ dx = track.scrollWidth - track.clientWidth;
628
+ const pin = wide.matches && dx > 0 && dx < innerHeight * 3;
629
+ root.classList.toggle('is-pinned', pin);
630
+ root.style.height = pin ? `${innerHeight + dx}px` : ''; // one pixel of scroll moves the row one pixel
631
+ place();
632
+ };
633
+ track.addEventListener('focusin', (e) => { // keyboard: bring the focused item into view
634
+ if (!root.classList.contains('is-pinned')) return;
635
+ const item = e.target.closest('.gallery__item');
636
+ const x = Math.min(dx, Math.max(0, item.offsetLeft - parseFloat(getComputedStyle(track).paddingLeft)));
637
+ scrollTo({ top: root.getBoundingClientRect().top + scrollY + x, behavior: 'instant' });
638
+ });
639
+ addEventListener('scroll', () => { if (!queued) { queued = true; requestAnimationFrame(place); } }, { passive: true });
640
+ addEventListener('resize', measure);
641
+ addEventListener('load', measure);
642
+ wide.addEventListener('change', measure);
643
+ measure();
644
+ })();
645
+ ```
646
+
647
+ Study: 8 pages pinned a horizontal track; their pages ran a median 25 screens, and the readers' worst
648
+ cases spent 9 screens on three cards and 10,000-23,000 px of wheel on one track. Here the speed is 1:1
649
+ and the hold ends when the last picture reaches the margin. Phones, narrow windows, reduced motion and
650
+ long rows all get the native row with snapping, which also works with touch, trackpads and the
651
+ keyboard. Never put the only copy of a call to action inside the moving row.
652
+
653
+ ### Receding hero
654
+
655
+ As the first screen leaves, its picture sinks a little and darkens while the words lift away a little
656
+ faster, so the hero recedes instead of simply scrolling off.
657
+
658
+ ```html
659
+ <header class="hero">
660
+ <img class="hero__picture" src="kitchen.jpg" alt="…" width="2400" height="1600" fetchpriority="high">
661
+ <div class="hero__copy"><h1 class="display">…</h1><p class="lead">…</p></div>
662
+ </header>
663
+ ```
664
+
665
+ ```css
666
+ .hero { position: relative; min-height: 100svh; overflow: clip; display: grid; align-items: end; view-timeline-name: --hero; }
667
+ .hero__picture { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; }
668
+ .hero::after { content: ''; position: absolute; inset: 0; background: var(--ink); opacity: 0; pointer-events: none; }
669
+ .hero__copy { position: relative; z-index: 1; padding: var(--margin); }
670
+ @supports (animation-timeline: view()) {
671
+ @media (prefers-reduced-motion: no-preference) {
672
+ .hero__picture { animation: hero-sink linear both; animation-timeline: --hero; animation-range: exit 0% exit 100%; }
673
+ .hero::after { animation: hero-dim linear both; animation-timeline: --hero; animation-range: exit 0% exit 100%; }
674
+ .hero__copy { animation: hero-lift linear both; animation-timeline: --hero; animation-range: exit 0% exit 70%; }
675
+ }
676
+ }
677
+ @keyframes hero-sink { to { transform: translateY(8%) scale(1.06); } }
678
+ @keyframes hero-dim { to { opacity: 0.35; } }
679
+ @keyframes hero-lift { to { transform: translateY(-6%); opacity: 0; } }
680
+ ```
681
+
682
+ The `exit` range runs from the moment the hero's top meets the top of the screen until its bottom
683
+ does, which for a first screen is the first screen of scroll. Study: hero pictures that sink and dim as
684
+ they leave, a heading scrubbed from 140 to 60 px, a title card that scales down on the phone; the
685
+ readers called it quiet and the page "one sheet". Travel stays under 8%; the overlay is a flat ink at
686
+ up to 35%, not a gradient. Static where unsupported or reduced.
687
+
688
+
689
+
690
+
691
+
692
+ ### Page to page
693
+
694
+ Navigation between pages of the site with the browser's own view transitions: the old page fades
695
+ quickly, the new one rises in, and the picture the visitor clicked travels into place as the new
696
+ page's hero. No library, no full-screen curtain.
697
+
698
+ ```css
699
+ /* on every page */
700
+ @view-transition { navigation: auto; }
701
+ @media (prefers-reduced-motion: reduce) { @view-transition { navigation: none; } }
702
+ ::view-transition-old(root) { animation: vt-leave 200ms var(--ease-exit) both; }
703
+ ::view-transition-new(root) { animation: vt-arrive 380ms var(--ease-out) 60ms both; }
704
+ ::view-transition-group(*) { animation-duration: 480ms; animation-timing-function: var(--ease-in-out); }
705
+ ::view-transition-image-pair(*.picture) { overflow: clip; }
706
+ ::view-transition-old(*.picture), ::view-transition-new(*.picture) { height: 100%; object-fit: cover; }
707
+ @keyframes vt-leave { to { opacity: 0; } }
708
+ @keyframes vt-arrive { from { opacity: 0; transform: translateY(16px); } }
709
+ ```
710
+
711
+ ```html
712
+ <!-- on the index: the card picture of one project -->
713
+ <img src="/img/leith.jpg" alt="…" style="view-transition-name: kitchen-leith; view-transition-class: picture">
714
+ <!-- on that project's page: its hero picture -->
715
+ <img src="/img/leith-wide.jpg" alt="…" style="view-transition-name: kitchen-leith; view-transition-class: picture">
716
+ ```
717
+
718
+ Each name may appear once per page. Browsers without cross-document view transitions simply navigate.
719
+ Study: 16 pages load a page-transition library and run full-screen curtains of 0.7-2.4 s; 6 use view
720
+ transitions. The new page should be readable within half a second.
721
+
722
+ ### Ink fill
723
+
724
+ One statement that fills from part-ink to full ink as it rises through the screen, line by line. It is
725
+ readable before it fills; the fill only sets the pace.
726
+
727
+ ```html
728
+ <p class="statement head" data-fill>Nine people, one workshop, no catalogue: every stool is made for the kitchen it goes to.</p>
729
+ ```
730
+
731
+ ```css
732
+ [data-fill] { --fill: var(--ink); color: var(--fill); }
733
+ @supports (animation-timeline: view()) {
734
+ @media (prefers-reduced-motion: no-preference) {
735
+ .is-filling > span { display: inline-block; color: color-mix(in oklab, var(--fill) 45%, transparent); animation: ink-fill linear both; animation-timeline: view(); animation-range: cover 20% cover 45%; }
736
+ }
737
+ }
738
+ @keyframes ink-fill { to { color: var(--fill); } }
739
+ ```
740
+
741
+ ```js
742
+ (() => {
743
+ if (!CSS.supports('animation-timeline: view()') || matchMedia('(prefers-reduced-motion: reduce)').matches) return;
744
+ document.querySelectorAll('[data-fill]').forEach((el) => {
745
+ const words = el.textContent.trim().split(/\s+/);
746
+ el.textContent = '';
747
+ words.forEach((word, i) => {
748
+ const s = document.createElement('span');
749
+ s.textContent = word;
750
+ el.append(s, i < words.length - 1 ? ' ' : '');
751
+ });
752
+ el.classList.add('is-filling');
753
+ });
754
+ })();
755
+ ```
756
+
757
+ Each word watches its own position, so the words of a line fill together as the line moves from three
758
+ quarters of the way down the screen to just past half way. Set `--fill` on a dark chapter to its ink.
759
+ Study: 14 pages scrub a statement from grey to ink; unread words started at about 25-30% and settled
760
+ frames still caught statements half pale, which reads as disabled text. Here: one statement per page,
761
+ three to five lines, 32px or larger, starting at 45% (about 3:1 against the ground, enough for large
762
+ text). Full ink without scroll timelines or under reduced motion.
763
+
764
+ ### Rolling figures
765
+
766
+ For pages whose case is a handful of numbers: each digit rolls up to its value once, the columns a
767
+ beat apart. The real number stays in the text for screen readers and search.
768
+
769
+ ```html
770
+ <p class="figure"><span class="display" data-roll>1987</span> the year the first stool was sold</p>
771
+ ```
772
+
773
+ ```css
774
+ .roll__digits { display: inline-flex; font-variant-numeric: tabular-nums; line-height: 1; }
775
+ .roll__col { display: inline-block; height: 1em; overflow: clip; }
776
+ .roll__strip { display: flex; flex-direction: column; transform: translateY(calc(var(--d) * -1em)); }
777
+ .roll__strip > span { height: 1em; }
778
+ @media (prefers-reduced-motion: no-preference) {
779
+ .roll:not(.is-in) .roll__strip { transform: none; }
780
+ .roll.is-in .roll__strip { transition: transform 1100ms var(--ease-expo) calc(var(--i) * var(--stagger-item)); }
781
+ }
782
+ ```
783
+
784
+ ```js
785
+ (() => {
786
+ const els = [...document.querySelectorAll('[data-roll]')];
787
+ if (!els.length) return;
788
+ els.forEach((el) => {
789
+ const value = el.textContent.trim();
790
+ const said = Object.assign(document.createElement('span'), { className: 'sr-only', textContent: value });
791
+ const shown = Object.assign(document.createElement('span'), { className: 'roll__digits' });
792
+ shown.setAttribute('aria-hidden', 'true');
793
+ let i = 0;
794
+ for (const ch of value) {
795
+ if (!/\d/.test(ch)) { shown.append(ch); continue; }
796
+ const col = Object.assign(document.createElement('span'), { className: 'roll__col' });
797
+ const strip = Object.assign(document.createElement('span'), { className: 'roll__strip' });
798
+ strip.style.setProperty('--d', ch);
799
+ strip.style.setProperty('--i', i++);
800
+ for (let n = 0; n < 10; n++) strip.append(Object.assign(document.createElement('span'), { textContent: n }));
801
+ col.append(strip);
802
+ shown.append(col);
803
+ }
804
+ el.classList.add('roll');
805
+ el.replaceChildren(said, shown);
806
+ });
807
+ if (!('IntersectionObserver' in window)) { els.forEach((el) => el.classList.add('is-in')); return; }
808
+ const io = new IntersectionObserver((entries) => {
809
+ for (const e of entries) if (e.isIntersecting) { e.target.classList.add('is-in'); io.unobserve(e.target); }
810
+ }, { rootMargin: '0px 0px -15% 0px' });
811
+ els.forEach((el) => io.observe(el));
812
+ })();
813
+ ```
814
+
815
+ Study: 6 pages roll digit columns (stats, a year, coordinates), about 0.9-1.1 s per column on an
816
+ expo-out, staggered by column. Use it for three or four figures at display size, never in running text
817
+ and never for a value that changes after load. Reduced motion: the figure is set at its value.
818
+
819
+ ### Accordion
820
+
821
+ ```css
822
+ .acc__panel { display: grid; grid-template-rows: 0fr; transition: grid-template-rows 320ms var(--ease-out); }
823
+ .acc__panel > div { overflow: hidden; }
824
+ .acc[open] .acc__panel, .acc__item.is-open .acc__panel { grid-template-rows: 1fr; }
825
+ ```
826
+
827
+ Animating `grid-template-rows` from `0fr` to `1fr` opens to the content's real height without measuring
828
+ it in JavaScript.
829
+
830
+ ### Menu or dialog
831
+
832
+ ```css
833
+ .menu { transform-origin: top right; transition: opacity var(--t-ui) var(--ease-out), transform var(--t-ui) var(--ease-out); }
834
+ .menu[hidden], .menu.is-closed { opacity: 0; transform: scale(0.97) translateY(-6px); pointer-events: none; }
835
+ ```
836
+
837
+ Things open from where they were triggered (`transform-origin` at the trigger). Nothing enters from
838
+ `scale(0)`; start at 0.95–0.98. A centred dialog is the one case that scales from its centre.
839
+
840
+ ## 7. Signature moves
841
+
842
+ The direction sheet names one. Each of these was seen repeatedly on the studied gallery sites.
843
+
844
+ | Move | What happens | Build with |
845
+ | --- | --- | --- |
846
+ | Masked headline | Lines rise from behind their baselines on load | Masked line reveal recipe |
847
+ | Shutter image | Hero or showcase image opens from an edge, settling from 1.12 scale | Image shutter recipe |
848
+ | Scaling wordmark | A giant wordmark shrinks or grows a few percent as the hero scrolls out | Scroll-linked recipe on `scale` |
849
+ | Pinned story | A heading holds while its items scroll past | Sticky recipe |
850
+ | Index with preview | Text rows; the hovered row shows its image | Index with preview, anchored to the row |
851
+ | Curtain footer | The last section is revealed from under the page as you reach the end | `position: sticky; bottom: 0` footer under a main with a background and `z-index` |
852
+ | Slow crossfade | Hero image crossfades through 3–4 photographs, 5–6s each | Stacked images, opacity keyframes, pause when the tab is hidden |
853
+ | Edge-to-edge marquee | One line of very large type travels sideways, slowly | Duplicate the content, `translateX(-50%)` linear loop, pause on hover and under reduced motion |
854
+ | Drag row | A horizontally scrollable row of large images with scroll snapping | `overflow-x: auto; scroll-snap-type: x mandatory`, no library |
855
+ | Colour by chapter | Each chapter is its own full-bleed colour field, so the ground changes at section edges; a fixed header takes the ink of the chapter beneath | Section backgrounds in CSS; `base.js` sets `--header-ink` from `data-ink`. Never recolour the whole page from an observer |
856
+ | Opening window | A type screen hands over to the big picture: a window in the middle of a held stage opens to full-bleed over about one screen | Opening window recipe |
857
+ | Ink-fill statement | One statement fills from 45% to full ink as it rises through the screen | Ink fill recipe |
858
+ | Sheet over a held picture | A full-bleed photograph holds for one screen; the next chapter slides up over it as an opaque sheet | A 100svh `position: sticky; top: 0` figure followed by a section with its own background and a higher `z-index`; no script |
859
+ | Sideways gallery | A short row of pictures moves sideways while the vertical scroll holds the stage | Sideways gallery recipe |
860
+ | Pushed line | One line of very large type is pushed sideways by the scroll, reading whole at the middle of its run | `animation-timeline: view()` on the line, `translateX` from 12% to -12%, linear; static where unsupported |
861
+ | Rolling figures | The few figures that make the case roll up to their values once | Rolling figures recipe |
862
+ | Carried picture | The picture clicked in a list travels into place as the next page's hero | Page to page recipe |
863
+
864
+ Pick by subject. A portfolio wants the index with preview; a restaurant wants the slow crossfade; a
865
+ product wants the shutter on its hero object. If the move says nothing about the subject, it is
866
+ decoration and the page is better without it.
867
+
868
+ Pick by subject, as before: the opening window for a product or a place with one great photograph; the
869
+ sheet for chapters that each own a picture; the sideways gallery for a short set of rooms, kitchens or
870
+ pieces; the pushed line for a brand whose claim is a word; rolling figures for a report, a fund or a
871
+ practice that leads with numbers; the carried picture for a portfolio whose index and case pages are
872
+ both built.
873
+
874
+ ## 8. Reduced motion and performance
875
+
876
+ - `prefers-reduced-motion: reduce` means fewer and gentler, not none: keep opacity and colour changes,
877
+ drop travel, scale, parallax and autoplay. All recipes above are written inside
878
+ `no-preference` blocks so the reduced state is simply the finished state.
879
+ - Animate `transform`, `opacity` and `clip-path`. Animating `width`, `height`, `top`, `left`, `margin`
880
+ or `padding` forces layout on every frame. `grid-template-rows` for accordions is the accepted
881
+ exception because it is short and rare.
882
+ - Do not drive many children from one changing CSS variable on a parent; set the transform on the
883
+ element that moves.
884
+ - `will-change` only on an element that is about to animate, removed afterwards.
885
+ - Pause loops when off screen or when the tab is hidden.
886
+ - No scroll hijacking and no custom cursor. They cost usability, which is 30% of an Awwwards score and
887
+ the line most winners score lowest on. Smoothing only through `Stage.smooth()`, which stays off for
888
+ touch and reduced motion.
889
+
890
+ ## 9. What gives generated motion away
891
+
892
+ - The same fade-up on every block.
893
+ - A dozen staggered entrances in the hero.
894
+ - Hover lift or zoom on every card and image.
895
+ - Bounce or elastic easing on interface elements.
896
+ - Infinite loops that mean nothing: pulsing dots, floating shapes, shimmering text, rotating badges.
897
+ - A reveal that never fires and leaves a blank band in the page. Run `inspect.mjs` after the motion
898
+ pass and look at every screenshot for missing content.
899
+
900
+ ### Seen in the latest Sites of the Day
901
+
902
+ Seen in the study, with how often, and what the house does instead.
903
+
904
+ - **Preloaders and counters (60 of 99; 26 with a counter).** Half the usable pages were unreadable
905
+ 2.5 s after load, a third at 4.5 s; the preloaders sat mostly on pages that had already loaded, and
906
+ 25 of the 29 that loaded in under 3 s still showed nothing readable at 4.5 s. Counters stalled,
907
+ outlasted the load or were tweens with nothing behind them. House: no preloader. If a scene must
908
+ load, the HTML first screen (headline, picture, action) is there at once and the scene fades in
909
+ behind it when ready.
910
+ - **Gates (10 of 99): enter, sound, age or gesture before any content.** House: none. Sound is a
911
+ control on the page, off by default.
912
+ - **Scroll hijacking (16 wheel-driven scenes, 9 of them endless, plus 14 single screens where the wheel
913
+ does nothing).** The visitor cannot tell where they are, how much is left or how to get out, and the
914
+ keyboard and screen readers get nothing. Usability 6.01 against 6.50 for pages that scroll. House:
915
+ native scroll; any scene is driven by scroll position on a sticky stage that ends, with a count.
916
+ - **Smooth-scroll libraries (67 of 99).** They make the scrubbed scenes feel weighted, and they also
917
+ put a delay between the hand and the page; with a long ease the page keeps sliding after the wheel
918
+ stops.
919
+ - **Custom cursors (28 of 99).** Followers, image trails, a lens that smears the headline it sits on,
920
+ labels that only exist on hover; 10 of the 28 have any reduced-motion rule. House: the system cursor;
921
+ labels are on the page.
922
+ - **Scramble and typewriter text (11 and 5).** The headline unreadable for four seconds, body copy
923
+ garbled mid-scroll, actions held back until the typing ends. House: not used. Real words arrive whole.
924
+ - **Long held runways.** Pages with pins average twice the length for fewer words per screen; 17 pages
925
+ are 20 screens or longer, some with whole screens that hold only a slow rotation. House: about one
926
+ screen per step, three per stage, every held screen carries a line of text.
927
+ - **Content held at zero opacity.** Reveals that keep text invisible until the scroll settles, inactive
928
+ items dimmed to 10-15%, statements left half pale. House: reveals only below the fold, once, never
929
+ longer than 900 ms in view; dims no lower than 40%; fills start at 45%.
930
+ - **Motion over words.** Marquees and drifting objects that cross statements, a sliding line that crops
931
+ its own words, blur left on text mid-move. House: nothing moves across text the visitor is reading;
932
+ a moving line must read whole at some point of its run.
933
+ - **Overshoot and bounce (19 stylesheets).** House: still banned on interface elements.
934
+ - **Endless loops with no off switch.** Grain, pulsing dots, equalisers, spinning badges, running with no
935
+ reduced-motion rule on most pages. House: loops pause off screen and stop under reduced motion.
936
+
937
+ Where the study's work does something the house bans, the house version: hairline progress bars and
938
+ tick rails on held stages become a plain count in the body face ("2 of 4"); zero-padded counters
939
+ ("01/08") become a plain count or nothing; crossfading gradient grounds become flat chapter colours with
940
+ hard cuts; monospace HUD labels become small text in the text face.
941
+
942
+ ## 10. Rules for motion
943
+
944
+ 1. **First frame.** The headline, the sentence and the picture are in the HTML and painted on the first
945
+ frame. The load sequence starts without waiting for the load event and is settled by 1.2 s, with
946
+ four elements at most.
947
+ 2. **No preloader, no counter, no gate, no custom cursor, no wheel capture.** Smoothing only through
948
+ `Stage.smooth()` (Lenis at lerp 0.1-0.14, off for touch and reduced motion): weight, never a page
949
+ that keeps sliding after the hand stops.
950
+ 3. **Curves.** `--ease-out` for anything under 600ms; `--ease-expo` for reveals of 800ms or more;
951
+ `--ease-in-out` for wipes and things that travel; `--ease-exit` only for things leaving for good;
952
+ `linear` for scroll-linked motion and loops; plain `ease` for colour. No overshoot.
953
+ 4. **Durations.** Press 140ms; hover colour 180-200ms; hover that moves 300-400ms; menus 240ms; reveals
954
+ 700-900ms; picture openings 1,100-1,300ms; exits 60-70% of the matching entrance.
955
+ 5. **Staggers.** Lines 80-110ms; list items 50-90ms; characters 20-40ms and only in a heading of one to
956
+ three words; four steps at most in any one sequence.
957
+ 6. **Reveals.** Fire once, when the element's top is 85-90% of the way down the viewport; rise 12-32px
958
+ or from a line mask; nothing in view stays hidden for more than 900ms; nothing above the fold waits
959
+ for scroll.
960
+ 7. **Scroll scenes are where the page is bold.** The big moves come from the scene library
961
+ (`references/scenes.md`): the direction sheet deals two or three per page, each built on
962
+ `assets/scenes/stage.js`, each held for one to four screens, each with a phone version and a
963
+ finished composition. Inside a scene the scene's own numbers apply (a picture may grow from a
964
+ window to the whole screen, type may cross the stage, a camera may fly through a field of
965
+ pictures); rules 8 and 10 still hold for the text a visitor is reading.
966
+ 8. **Outside the scenes, scroll-linked travel stays quiet.** Linear and locked to the scrollbar;
967
+ parallax under 8% of the element; scale between 0.94 and 1.15; built with scroll timelines inside
968
+ `@supports`, static without them. Other held stages: sticky, about one screen per step, three
969
+ screens at most, a line of text on every held screen.
970
+ 9. **One of each.** One signature move (a dealt scene can be it); one moving picture; one marquee at
971
+ most. A quiet section between two scenes, so each one lands.
972
+ 10. **Text stays readable.** Fills start at 45% ink; dims no lower than 40%; blur at most 8px and gone
973
+ once settled; nothing moves across text being read.
974
+ 11. **Colour by chapter** as hard cuts at section edges; the fixed header's ink follows in 300ms.
975
+ 12. **Horizontal motion.** The native drag row first; the pinned sideways gallery only for rows under
976
+ three screens long, with keyboard support.
977
+ 13. **Page to page.** View transitions, 480ms at most, off under reduced motion.
978
+ 14. **Reduced motion.** Every hidden state lives inside `prefers-reduced-motion: no-preference`; the
979
+ reduced page is the finished page; loops stop. Two in three award pages skip this, which is a
980
+ reason to do it, not a licence to skip it.