@3xhaust/oh-my-design 0.16.1

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 (131) hide show
  1. package/LICENSE +21 -0
  2. package/README.ko.md +282 -0
  3. package/README.md +282 -0
  4. package/adapters/build.ts +184 -0
  5. package/adapters/claude.ts +111 -0
  6. package/adapters/codex.ts +68 -0
  7. package/adapters/tokens.ts +21 -0
  8. package/adapters/tool-map.json +7 -0
  9. package/bin/omd-install.ts +76 -0
  10. package/bin/omd.ts +1774 -0
  11. package/core/asset-sourcing/index.ts +187 -0
  12. package/core/coach/index.ts +98 -0
  13. package/core/composition/app-shell-workbench.md +168 -0
  14. package/core/composition/asymmetric-diagonal-grid.md +178 -0
  15. package/core/composition/bento-grid.md +223 -0
  16. package/core/composition/editorial-index-labels.md +173 -0
  17. package/core/composition/form-wizard-stepper.md +132 -0
  18. package/core/composition/master-detail-flow.md +129 -0
  19. package/core/composition/section-inversion.md +182 -0
  20. package/core/composition/sidebar-margin-annotation.md +184 -0
  21. package/core/composition/split-screen-hero.md +224 -0
  22. package/core/composition/sticky-sidebar-scroll.md +219 -0
  23. package/core/composition/typographic-hero.md +100 -0
  24. package/core/composition-contract/index.ts +233 -0
  25. package/core/composition-contract/visual-richness.ts +101 -0
  26. package/core/config/index.ts +27 -0
  27. package/core/copy/index.ts +296 -0
  28. package/core/craft/finish-pass.md +356 -0
  29. package/core/craft/index.ts +33 -0
  30. package/core/design/index.ts +581 -0
  31. package/core/design/interaction-states.ts +189 -0
  32. package/core/eval-harness/index.ts +225 -0
  33. package/core/evidence/final.ts +444 -0
  34. package/core/evidence/task.ts +459 -0
  35. package/core/figma/client.ts +312 -0
  36. package/core/figma/diff.ts +261 -0
  37. package/core/figma/responsive.ts +310 -0
  38. package/core/figma/system.ts +466 -0
  39. package/core/figma/types.ts +185 -0
  40. package/core/frame/check-ux.ts +180 -0
  41. package/core/frame/index.ts +39 -0
  42. package/core/frame/write.ts +168 -0
  43. package/core/graphics/css-illustration-primitives.md +189 -0
  44. package/core/graphics/duotone-image-presets.md +173 -0
  45. package/core/graphics/gradient-mesh.md +178 -0
  46. package/core/graphics/noise-grain-texture.md +150 -0
  47. package/core/graphics/placeholder-policy.md +185 -0
  48. package/core/graphics/svg-geometric-patterns.md +145 -0
  49. package/core/history/index.ts +39 -0
  50. package/core/install/detect.ts +32 -0
  51. package/core/install/install.ts +321 -0
  52. package/core/install/patch-claude.ts +109 -0
  53. package/core/install/patch-codex.ts +82 -0
  54. package/core/interaction/index.ts +101 -0
  55. package/core/interaction/recipes/signature-lighting.md +203 -0
  56. package/core/ir/dom.ts +352 -0
  57. package/core/ir/normalize.ts +177 -0
  58. package/core/motion/easing.md +151 -0
  59. package/core/motion/energy.ts +188 -0
  60. package/core/motion/recipes/image-hover-distortion.md +198 -0
  61. package/core/motion/recipes/magnetic-hover.md +229 -0
  62. package/core/motion/recipes/marquee.md +202 -0
  63. package/core/motion/recipes/number-counter.md +242 -0
  64. package/core/motion/recipes/page-loader.md +240 -0
  65. package/core/motion/recipes/parallax.md +233 -0
  66. package/core/motion/recipes/scroll-reveal.md +210 -0
  67. package/core/motion/recipes/section-color-inversion.md +217 -0
  68. package/core/motion/recipes/split-text-entrance.md +222 -0
  69. package/core/motion/recipes/stagger-orchestrator.md +227 -0
  70. package/core/motion/recipes/sticky-scene-transition.md +246 -0
  71. package/core/motion/recipes/view-transitions.md +239 -0
  72. package/core/probe/index.ts +186 -0
  73. package/core/probe/schema.json +46 -0
  74. package/core/protocol/composition-contract.md +192 -0
  75. package/core/protocol/copy-deck.md +96 -0
  76. package/core/protocol/human-design-loop.md +447 -0
  77. package/core/protocol/slop-review.md +71 -0
  78. package/core/ref/batch.ts +94 -0
  79. package/core/ref/blueprint.ts +199 -0
  80. package/core/ref/distance.ts +225 -0
  81. package/core/ref/invariants.ts +162 -0
  82. package/core/ref/signal.ts +37 -0
  83. package/core/ref/store.ts +122 -0
  84. package/core/render/index.ts +607 -0
  85. package/core/rules/attribution.ts +159 -0
  86. package/core/rules/builtin/contrast.yaml +8 -0
  87. package/core/rules/builtin/focus.yaml +8 -0
  88. package/core/rules/builtin/hit-area.yaml +8 -0
  89. package/core/rules/builtin/ko.yaml +6 -0
  90. package/core/rules/builtin/motion.yaml +80 -0
  91. package/core/rules/builtin/slop.yaml +365 -0
  92. package/core/rules/builtin/spacing.yaml +9 -0
  93. package/core/rules/builtin/system.yaml +42 -0
  94. package/core/rules/builtin/token.yaml +8 -0
  95. package/core/rules/builtin/ux.yaml +125 -0
  96. package/core/rules/engine.ts +88 -0
  97. package/core/rules/leakage.ts +72 -0
  98. package/core/rules/motion-spec.ts +204 -0
  99. package/core/site/index.ts +100 -0
  100. package/core/slop/index.ts +427 -0
  101. package/core/slop/text-slop.ts +220 -0
  102. package/core/source-seal/index.ts +174 -0
  103. package/core/stack/index.ts +53 -0
  104. package/core/target/index.ts +183 -0
  105. package/core/theory/color.md +285 -0
  106. package/core/theory/components.md +272 -0
  107. package/core/theory/craft.md +270 -0
  108. package/core/theory/expressive.md +213 -0
  109. package/core/theory/imagegen.md +105 -0
  110. package/core/theory/layout.md +159 -0
  111. package/core/theory/motion.md +283 -0
  112. package/core/theory/typography.md +134 -0
  113. package/core/theory/ux.md +580 -0
  114. package/core/theory/voice.md +595 -0
  115. package/core/types.ts +684 -0
  116. package/package.json +40 -0
  117. package/src/agents/composer.agent.yaml +174 -0
  118. package/src/agents/eye.agent.yaml +208 -0
  119. package/src/agents/framer.agent.yaml +52 -0
  120. package/src/agents/glance.agent.yaml +19 -0
  121. package/src/agents/hand.agent.yaml +200 -0
  122. package/src/agents/scout.agent.yaml +71 -0
  123. package/src/agents/sketch.agent.yaml +64 -0
  124. package/src/agents/typesetter.agent.yaml +52 -0
  125. package/src/agents/writer.agent.yaml +63 -0
  126. package/src/skills/omd-coach/SKILL.md +40 -0
  127. package/src/skills/omd-critique/SKILL.md +77 -0
  128. package/src/skills/omd-figma/SKILL.md +256 -0
  129. package/src/skills/omd-humanize/SKILL.md +160 -0
  130. package/src/skills/omd-scout/SKILL.md +77 -0
  131. package/src/skills/omd-ultradesign/SKILL.md +521 -0
@@ -0,0 +1,233 @@
1
+ # Parallax
2
+
3
+ A parallax effect moves background and foreground elements at different rates as the
4
+ user scrolls, creating an illusion of depth. The CSS scroll-driven implementation
5
+ (`animation-timeline: scroll()`) moves an element by a fixed offset over the full
6
+ scroll range of its container, running off the main thread with no JavaScript per frame.
7
+
8
+ Read the restraint clause below before deciding whether to use this recipe. The evidence
9
+ for not using parallax is substantial, and the recipe's conditions are narrow.
10
+
11
+ ## When it earns its place / When it does not
12
+
13
+ Condition: the content is a scroll-narrative — a case study, a product demo page, a
14
+ campaign microsite — and the concept explicitly requires a sense of depth or layering.
15
+ The parallax element contributes to the narrative; removing it would remove meaning, not
16
+ just decoration. The movement is subtle: the `--parallax-offset` is small (20–40px over
17
+ the full scroll range), producing depth rather than motion-sickness. `core/theory/
18
+ motion.md`: "use scroll-linked animation only when the content is itself a
19
+ scroll-narrative and the concept explicitly requires it."
20
+
21
+ Condition against: product UI. Navigation elements. Any page where the user is scanning
22
+ for content rather than experiencing a narrative. `core/theory/motion.md` cites the
23
+ NN/g evidence directly: users scroll quickly to scan for keywords; parallax requires slow
24
+ scrolling to read; users who scroll at normal speed miss the effect entirely, and users
25
+ with vestibular disorders are harmed by it. The NN/g research ("What Parallax Lacks",
26
+ 2013) documents three failures — invisible to fast scrollers, classified as decorative
27
+ noise by experienced users, and a vestibular trigger for sensitive users. The 2024
28
+ performance data adds a fourth: parallax-heavy pages showed LCP exceeding 8 seconds and
29
+ poor INP scores, constituting Core Web Vitals failures.
30
+
31
+ If you are reaching for parallax because the page needs more visual interest, the correct
32
+ answer is a better layout or a stronger type decision — not depth simulation. Apply this
33
+ recipe only when the brief explicitly names the concept as depth-dependent and the scroll
34
+ narrative test is met.
35
+
36
+ ## Parameters
37
+
38
+ ```css
39
+ :root {
40
+ /* How far the element moves over the full scroll range of its container.
41
+ Keep small. 20–40px creates depth without visible motion-sickness.
42
+ Negative values move the element against the scroll direction (classic parallax).
43
+ Positive values move it with scroll (subtle, less disorienting). */
44
+ --parallax-offset: -30px;
45
+
46
+ /* Whether to apply the effect. Set to 'none' to disable without removing CSS. */
47
+ --parallax-enabled: block; /* unused in current implementation; see JS approach */
48
+ }
49
+ ```
50
+
51
+ ## Implementation
52
+
53
+ ```html
54
+ <section class="parallax-section">
55
+ <!-- The background element moves at the slower rate. -->
56
+ <div class="parallax-bg" aria-hidden="true">
57
+ <!-- decorative image or gradient -->
58
+ </div>
59
+
60
+ <!-- Foreground content scrolls at normal speed. -->
61
+ <div class="parallax-content">
62
+ <h2>The infrastructure layer</h2>
63
+ </div>
64
+ </section>
65
+ ```
66
+
67
+ ```css
68
+ /* ── CSS scroll-driven (preferred; off-thread) ────────────────────────────── */
69
+
70
+ .parallax-section {
71
+ /* Establish a scroll timeline scoped to this container. */
72
+ overflow: hidden; /* contain the background overflow */
73
+ position: relative;
74
+ }
75
+
76
+ .parallax-bg {
77
+ position: absolute;
78
+ inset: calc(var(--parallax-offset) * -1) 0; /* compensate so bg fills section at start */
79
+ background-image: var(--parallax-bg-image);
80
+ background-size: cover;
81
+ background-position: center;
82
+
83
+ /* The animation moves translateY from 0 to --parallax-offset over the scroll range. */
84
+ animation: parallax-drift linear both;
85
+ animation-timeline: scroll(root block); /* tracks document scroll, not section scroll */
86
+ animation-range: contain 0% contain 100%;
87
+ }
88
+
89
+ @keyframes parallax-drift {
90
+ from { transform: translateY(0); }
91
+ to { transform: translateY(var(--parallax-offset)); }
92
+ }
93
+
94
+ /* ── IntersectionObserver + requestAnimationFrame fallback ───────────────── */
95
+ /* For browsers without scroll-driven support; also gives per-frame control. */
96
+ ```
97
+
98
+ ```js
99
+ // Only use the JS path when CSS scroll-driven is unavailable.
100
+ if (!CSS.supports('animation-timeline', 'scroll()')) {
101
+ document.querySelectorAll('.parallax-bg').forEach(attachParallax);
102
+ }
103
+
104
+ function attachParallax(el) {
105
+ const section = el.closest('.parallax-section');
106
+ if (!section) return;
107
+
108
+ const offset = parseFloat(
109
+ getComputedStyle(document.documentElement).getPropertyValue('--parallax-offset')
110
+ ) || -30;
111
+
112
+ let ticking = false;
113
+
114
+ function update() {
115
+ const rect = section.getBoundingClientRect();
116
+ const viewH = window.innerHeight;
117
+
118
+ // Progress: 0 = section top at bottom of viewport, 1 = section bottom at top.
119
+ const progress = (viewH - rect.top) / (viewH + rect.height);
120
+ const clamped = Math.max(0, Math.min(1, progress));
121
+
122
+ // Apply only transform — compositor-only property.
123
+ el.style.transform = `translateY(${clamped * offset}px)`;
124
+ ticking = false;
125
+ }
126
+
127
+ window.addEventListener('scroll', () => {
128
+ if (!ticking) {
129
+ requestAnimationFrame(update);
130
+ ticking = true;
131
+ }
132
+ }, { passive: true });
133
+ }
134
+ ```
135
+
136
+ ## React
137
+
138
+ ```tsx
139
+ import { motion, useScroll, useTransform } from 'framer-motion';
140
+ import { useRef } from 'react';
141
+
142
+ function ParallaxSection({
143
+ children,
144
+ bgContent,
145
+ offset = -30,
146
+ }: {
147
+ children: React.ReactNode;
148
+ bgContent: React.ReactNode;
149
+ offset?: number; // px; negative = against scroll direction
150
+ }) {
151
+ const sectionRef = useRef<HTMLDivElement>(null);
152
+
153
+ const { scrollYProgress } = useScroll({
154
+ target: sectionRef,
155
+ offset: ['start end', 'end start'],
156
+ });
157
+
158
+ const y = useTransform(scrollYProgress, [0, 1], [0, offset]);
159
+
160
+ return (
161
+ <div ref={sectionRef} style={{ position: 'relative', overflow: 'hidden' }}>
162
+ {/* Background moves at a different rate */}
163
+ <motion.div
164
+ style={{
165
+ position: 'absolute',
166
+ inset: `${Math.abs(offset)}px 0`,
167
+ y,
168
+ }}
169
+ aria-hidden
170
+ >
171
+ {bgContent}
172
+ </motion.div>
173
+
174
+ {/* Foreground at normal scroll speed */}
175
+ <div style={{ position: 'relative' }}>
176
+ {children}
177
+ </div>
178
+ </div>
179
+ );
180
+ }
181
+ ```
182
+
183
+ ## Reduced-motion variant
184
+
185
+ In reduced-motion context, the parallax background is static — no scroll-linked
186
+ movement. The section renders identically to a non-parallax section with a normal
187
+ background image.
188
+
189
+ ```css
190
+ @media (prefers-reduced-motion: reduce) {
191
+ .parallax-bg {
192
+ animation: none;
193
+ transform: none;
194
+ /* Restore to full-bleed static background. */
195
+ inset: 0;
196
+ }
197
+ }
198
+ ```
199
+
200
+ The JS path must also check:
201
+
202
+ ```js
203
+ const prefersReduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
204
+ if (!prefersReduced && !CSS.supports('animation-timeline', 'scroll()')) {
205
+ document.querySelectorAll('.parallax-bg').forEach(attachParallax);
206
+ }
207
+ ```
208
+
209
+ This is one of the few recipes where the reduced-motion variant genuinely improves the
210
+ experience for a broader audience — not just the audience that has set the preference.
211
+ As `core/theory/motion.md` notes, many users who experience vestibular discomfort from
212
+ parallax have not discovered or set the `prefers-reduced-motion` preference. The
213
+ restraint clause above (applying this recipe narrowly) is the stronger intervention.
214
+
215
+ ## Performance note
216
+
217
+ The CSS scroll-driven path runs entirely off the main thread in Chromium. The element's
218
+ layer is promoted by the animation; the scroll position update drives the transform
219
+ without touching JavaScript. This is measurably lighter than the JS `requestAnimationFrame`
220
+ path, which fires a callback, computes a value, and writes a style on every scroll frame.
221
+
222
+ The JS path uses the `ticking` pattern to batch updates to one `requestAnimationFrame`
223
+ per scroll event burst — standard debounce for scroll handlers. The `{ passive: true }`
224
+ flag is required; without it the browser cannot optimise scroll performance.
225
+
226
+ `will-change: transform` on the parallax element is applied implicitly by the scroll-
227
+ driven animation and the Framer Motion `useTransform` hook. Do not add it manually on
228
+ top; double promotion provides no benefit.
229
+
230
+ Keep `--parallax-offset` small. A 30px offset over a full-viewport-height section
231
+ produces a 3% depth differential — perceptible as depth without producing foreground-
232
+ background collision. At 100px on a 400px-tall section, foreground and background
233
+ visibly separate, causing the page to look broken rather than deep.
@@ -0,0 +1,210 @@
1
+ # Scroll reveal
2
+
3
+ A scroll reveal makes content visible as it enters the viewport — opacity rising from
4
+ zero, a small translate completing to zero — so that each section announces itself as
5
+ the user scrolls toward it rather than sitting pre-exposed on a long static page.
6
+
7
+ The implementation hierarchy is: CSS scroll-driven animations first (`animation-timeline:
8
+ view()`), IntersectionObserver as the fallback for broader browser support. The static
9
+ layout — what the page shows when no animation fires — must be complete and legible.
10
+
11
+ ## When it earns its place / When it does not
12
+
13
+ Condition: the content is a scroll narrative — sections have a sequential logic, and the
14
+ reveal of each section reinforces the order of reading. A portfolio case study where
15
+ chapters arrive one by one, a product page where features emerge in the sequence the
16
+ concept requires. The animation confirms what the layout already implies: read this, then
17
+ this, then this.
18
+
19
+ Condition against: product UI that the user came to operate. Navigation elements. Any
20
+ content the user is scrolling to find — when the destination is below the fold and the
21
+ user is scanning for it, a reveal that delays visibility is a usability failure dressed
22
+ as an aesthetic choice. `expressive.md`: "A reveal that delays reading is a usability
23
+ failure regardless of how well-timed it is." Also: `core/theory/motion.md` — entrance
24
+ animations that replay on scroll turn scrolling into a performance the user must watch.
25
+ Scroll-triggered reveal belongs on presentation pages, not on tools.
26
+
27
+ ## Parameters
28
+
29
+ ```css
30
+ :root {
31
+ /* How far the element travels upward on entrance. 20–32px is the legible range;
32
+ larger values read as falling rather than arriving. */
33
+ --reveal-y: 28px;
34
+
35
+ /* Duration of the reveal animation. Slightly longer than a load entrance because
36
+ scroll-triggered context gives the user more time to process. 280–380ms. */
37
+ --duration-reveal: 340ms;
38
+
39
+ /* Easing from core/motion/easing.md. ease-out-quint for graceful arrival;
40
+ ease-out-expo for decisive arrival. */
41
+ --ease-reveal: var(--ease-out-quint);
42
+
43
+ /* For staggered groups (see stagger-orchestrator.md), this is the per-item delay. */
44
+ --stagger-reveal: 60ms;
45
+ }
46
+ ```
47
+
48
+ ## Implementation
49
+
50
+ ```css
51
+ /* ── CSS scroll-driven (preferred; Chromium 115+, Safari 18+) ─────────────── */
52
+
53
+ .reveal {
54
+ animation: reveal-up var(--duration-reveal) var(--ease-reveal) both;
55
+ animation-timeline: view();
56
+ /* entry 0% = element starts entering viewport; 25% = comfortably inside. */
57
+ animation-range: entry 0% entry 25%;
58
+ }
59
+
60
+ @keyframes reveal-up {
61
+ from {
62
+ opacity: 0;
63
+ transform: translateY(var(--reveal-y));
64
+ }
65
+ to {
66
+ opacity: 1;
67
+ transform: translateY(0);
68
+ }
69
+ }
70
+
71
+ /* ── IntersectionObserver fallback for browsers without scroll-driven support ── */
72
+ /* Start hidden; JS adds .is-visible when the element intersects. */
73
+ @supports not (animation-timeline: view()) {
74
+ .reveal {
75
+ opacity: 0;
76
+ transform: translateY(var(--reveal-y));
77
+ transition: opacity var(--duration-reveal) var(--ease-reveal),
78
+ transform var(--duration-reveal) var(--ease-reveal);
79
+ }
80
+
81
+ .reveal.is-visible {
82
+ opacity: 1;
83
+ transform: translateY(0);
84
+ }
85
+ }
86
+ ```
87
+
88
+ ```js
89
+ // Only runs in browsers that lack scroll-driven animation support.
90
+ if (!CSS.supports('animation-timeline', 'view()')) {
91
+ const observer = new IntersectionObserver(
92
+ (entries) => {
93
+ entries.forEach((entry) => {
94
+ if (entry.isIntersecting) {
95
+ entry.target.classList.add('is-visible');
96
+ // Reveal fires once. Unobserve after first intersection.
97
+ observer.unobserve(entry.target);
98
+ }
99
+ });
100
+ },
101
+ { threshold: 0.15 } // 15% of element visible triggers reveal
102
+ );
103
+
104
+ document.querySelectorAll('.reveal').forEach((el) => observer.observe(el));
105
+ }
106
+ ```
107
+
108
+ ## React
109
+
110
+ ```tsx
111
+ import { motion, useInView } from 'framer-motion';
112
+ import { useRef } from 'react';
113
+
114
+ const revealVariants = {
115
+ hidden: { opacity: 0, y: 28 },
116
+ visible: {
117
+ opacity: 1,
118
+ y: 0,
119
+ transition: {
120
+ duration: 0.34,
121
+ ease: [0.22, 1, 0.36, 1], // --ease-out-quint
122
+ },
123
+ },
124
+ };
125
+
126
+ function Reveal({ children, className }: { children: React.ReactNode; className?: string }) {
127
+ const ref = useRef<HTMLDivElement>(null);
128
+ // once: true — reveals fire once, not on every scroll pass.
129
+ const isInView = useInView(ref, { once: true, margin: '-10% 0px' });
130
+
131
+ return (
132
+ <motion.div
133
+ ref={ref}
134
+ className={className}
135
+ variants={revealVariants}
136
+ initial="hidden"
137
+ animate={isInView ? 'visible' : 'hidden'}
138
+ >
139
+ {children}
140
+ </motion.div>
141
+ );
142
+ }
143
+
144
+ // For staggered groups, wrap in a container with staggerChildren:
145
+ const groupVariants = {
146
+ hidden: {},
147
+ visible: { transition: { staggerChildren: 0.06 } },
148
+ };
149
+
150
+ function RevealGroup({ children }: { children: React.ReactNode }) {
151
+ const ref = useRef<HTMLDivElement>(null);
152
+ const isInView = useInView(ref, { once: true, margin: '-10% 0px' });
153
+
154
+ return (
155
+ <motion.div
156
+ ref={ref}
157
+ variants={groupVariants}
158
+ initial="hidden"
159
+ animate={isInView ? 'visible' : 'hidden'}
160
+ >
161
+ {children}
162
+ </motion.div>
163
+ );
164
+ }
165
+ ```
166
+
167
+ ## Reduced-motion variant
168
+
169
+ The reduced-motion state must be a complete, legible layout — not an empty page waiting
170
+ for JavaScript, not hidden content. In reduced-motion context, elements are visible from
171
+ the start. No fade, no translate, no delay.
172
+
173
+ ```css
174
+ @media (prefers-reduced-motion: reduce) {
175
+ .reveal {
176
+ /* Override both the scroll-driven animation and the IO fallback. */
177
+ animation: none !important;
178
+ opacity: 1 !important;
179
+ transform: none !important;
180
+ transition: none !important;
181
+ }
182
+ }
183
+ ```
184
+
185
+ The IntersectionObserver JS should also check before adding opacity:
186
+
187
+ ```js
188
+ const prefersReduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
189
+ if (!prefersReduced && !CSS.supports('animation-timeline', 'view()')) {
190
+ // ... observer setup
191
+ }
192
+ ```
193
+
194
+ ## Performance note
195
+
196
+ `animation-timeline: view()` runs off the main thread in Chromium — the browser's
197
+ compositor advances the animation as scroll position changes, with no JavaScript
198
+ involvement per frame. This is why it is preferred over IntersectionObserver: IO fires
199
+ a JS callback, which schedules a style change, which runs on the main thread. Both are
200
+ acceptable; the scroll-driven path is measurably lighter.
201
+
202
+ Animate only `opacity` and `transform`. The `translateY` in `--reveal-y` is a
203
+ composited property; it does not trigger layout. If you are tempted to reveal by changing
204
+ `height`, `padding`, or `margin`, the answer is no — use `transform: scaleY()` from a
205
+ `transform-origin: top` if a size change is genuinely required.
206
+
207
+ Do not apply `will-change: transform` to every `.reveal` element on the page. On a page
208
+ with twenty reveal elements, this promotes twenty compositor layers simultaneously and
209
+ consumes GPU memory on mobile hardware. The browser promotes elements as needed during
210
+ the animation; pre-promoting them does not help and costs memory.
@@ -0,0 +1,217 @@
1
+ # Section colour inversion
2
+
3
+ A section colour inversion swaps the page's colour scheme — dark background to light,
4
+ or light to dark — at a specific scroll threshold, marking a conceptual pivot in the
5
+ content. The background and text colours switch together so the palette inverts as a
6
+ unit rather than changing element by element.
7
+
8
+ ## When it earns its place / When it does not
9
+
10
+ Condition: the scroll narrative has a turn — a moment where the register shifts to mark
11
+ a conceptual pivot. The inversion must correspond to a narrative event: a before/after
12
+ split, a transition from problem to solution, a shift from the user's world to the
13
+ product's. The colour change makes the pivot felt as well as read. `expressive.md`:
14
+ "The inversion makes the change visible and felt, not just read. One inversion per page
15
+ is usually correct; more than one is a rhythm, not a pivot."
16
+
17
+ Condition against: arbitrary decoration. Using two inversions because the page has three
18
+ sections reads as a pattern, not a moment — the technique has been applied to a grid
19
+ rather than a concept. Also: inversion without a corresponding narrative event is visual
20
+ noise. If you cannot complete the sentence "This is where the page pivots from __ to __",
21
+ the inversion does not belong.
22
+
23
+ The cost: users who have accessibility settings that invert colours or use high-contrast
24
+ modes will double-invert. Test in Windows High Contrast and macOS Invert Colours modes.
25
+
26
+ ## Parameters
27
+
28
+ ```css
29
+ :root {
30
+ /* The two poles of the inversion. Board-sourced tokens — never raw hex here. */
31
+ --color-bg-light: var(--surface-primary); /* light section background */
32
+ --color-text-light: var(--text-primary); /* light section text */
33
+ --color-bg-dark: var(--surface-inverse); /* dark section background */
34
+ --color-text-dark: var(--text-inverse); /* dark section text */
35
+
36
+ /* Duration of the transition when the inversion fires.
37
+ Faster than a typical content transition — the switch should read as a cut,
38
+ not a dissolve. 200–280ms. */
39
+ --duration-invert: 240ms;
40
+ --ease-invert: var(--ease-out-quint);
41
+ }
42
+ ```
43
+
44
+ ## Implementation
45
+
46
+ The cleanest approach: a `data-theme` attribute on the section toggles between two
47
+ CSS variable sets. One IntersectionObserver or scroll-driven animation flips the
48
+ attribute. The body or a persistent header that spans sections must respond to the
49
+ active section's theme.
50
+
51
+ ```html
52
+ <section class="section" data-theme="light">
53
+ <!-- ... light section content ... -->
54
+ </section>
55
+
56
+ <section class="section" data-theme="dark">
57
+ <!-- ... pivot section — the narrative turn ... -->
58
+ </section>
59
+
60
+ <section class="section" data-theme="light">
61
+ <!-- ... resolution section ... -->
62
+ </section>
63
+
64
+ <!-- A persistent header tracks the active section's theme -->
65
+ <header class="site-header" data-theme="light"></header>
66
+ ```
67
+
68
+ ```css
69
+ /* Define both poles as CSS variable sets. */
70
+ [data-theme="light"] {
71
+ --bg: var(--color-bg-light);
72
+ --text: var(--color-text-light);
73
+ --border: var(--color-border-light, rgba(0,0,0,0.08));
74
+ }
75
+
76
+ [data-theme="dark"] {
77
+ --bg: var(--color-bg-dark);
78
+ --text: var(--color-text-dark);
79
+ --border: var(--color-border-dark, rgba(255,255,255,0.12));
80
+ }
81
+
82
+ .section {
83
+ background-color: var(--bg);
84
+ color: var(--text);
85
+ /* Instant switch — no transition on section background itself. The transition
86
+ is on the header and any sticky element that spans sections. */
87
+ }
88
+
89
+ /* Optional: a very short opacity dip on direct section children softens the
90
+ colour inversion by briefly fading content as the palette switches. This is
91
+ a compositor-only property (opacity) — no layout cost. */
92
+ .section > * {
93
+ transition: opacity var(--duration-invert) var(--ease-invert);
94
+ }
95
+
96
+ [data-theme] .section > * {
97
+ opacity: 1;
98
+ }
99
+
100
+ /* The sticky header transitions between themes smoothly. */
101
+ .site-header {
102
+ position: sticky;
103
+ top: 0;
104
+ background-color: var(--bg);
105
+ color: var(--text);
106
+ transition:
107
+ background-color var(--duration-invert) var(--ease-invert),
108
+ color var(--duration-invert) var(--ease-invert);
109
+ }
110
+ ```
111
+
112
+ ```js
113
+ // Track which section is currently dominant in the viewport and update the header.
114
+ function trackSectionThemes() {
115
+ const header = document.querySelector('.site-header');
116
+ if (!header) return;
117
+
118
+ const sections = document.querySelectorAll('.section[data-theme]');
119
+
120
+ const observer = new IntersectionObserver(
121
+ (entries) => {
122
+ entries.forEach((entry) => {
123
+ if (entry.isIntersecting && entry.intersectionRatio >= 0.5) {
124
+ const theme = entry.target.getAttribute('data-theme');
125
+ if (theme) header.setAttribute('data-theme', theme);
126
+ }
127
+ });
128
+ },
129
+ { threshold: 0.5 }
130
+ );
131
+
132
+ sections.forEach((s) => observer.observe(s));
133
+ }
134
+
135
+ document.addEventListener('DOMContentLoaded', trackSectionThemes);
136
+ ```
137
+
138
+ ## React
139
+
140
+ ```tsx
141
+ import { useEffect, useRef, useState } from 'react';
142
+ import { motion, AnimatePresence } from 'framer-motion';
143
+
144
+ type Theme = 'light' | 'dark';
145
+
146
+ function useActiveTheme(sections: React.RefObject<HTMLElement>[]) {
147
+ const [activeTheme, setActiveTheme] = useState<Theme>('light');
148
+
149
+ useEffect(() => {
150
+ const observer = new IntersectionObserver(
151
+ (entries) => {
152
+ entries.forEach((entry) => {
153
+ if (entry.isIntersecting && entry.intersectionRatio >= 0.5) {
154
+ const theme = (entry.target as HTMLElement).dataset.theme as Theme;
155
+ if (theme) setActiveTheme(theme);
156
+ }
157
+ });
158
+ },
159
+ { threshold: 0.5 }
160
+ );
161
+
162
+ sections.forEach((ref) => {
163
+ if (ref.current) observer.observe(ref.current);
164
+ });
165
+
166
+ return () => observer.disconnect();
167
+ }, [sections]);
168
+
169
+ return activeTheme;
170
+ }
171
+
172
+ function SiteHeader({ theme }: { theme: Theme }) {
173
+ return (
174
+ <motion.header
175
+ animate={{
176
+ backgroundColor: theme === 'dark' ? 'var(--color-bg-dark)' : 'var(--color-bg-light)',
177
+ color: theme === 'dark' ? 'var(--color-text-dark)' : 'var(--color-text-light)',
178
+ }}
179
+ transition={{ duration: 0.24, ease: [0.22, 1, 0.36, 1] }}
180
+ style={{ position: 'sticky', top: 0 }}
181
+ >
182
+ {/* header content */}
183
+ </motion.header>
184
+ );
185
+ }
186
+ ```
187
+
188
+ ## Reduced-motion variant
189
+
190
+ The sections retain their different background colours — the inversion is a layout
191
+ choice, not purely a motion effect. The transition on the header is removed so the
192
+ switch is instant rather than animated.
193
+
194
+ ```css
195
+ @media (prefers-reduced-motion: reduce) {
196
+ .site-header {
197
+ transition: none;
198
+ }
199
+ }
200
+ ```
201
+
202
+ The IntersectionObserver continues to run and update the `data-theme` attribute (correct
203
+ behaviour — the sections genuinely have different themes). Only the animated transition
204
+ is removed.
205
+
206
+ ## Performance note
207
+
208
+ Background colour transitions on a sticky header trigger the paint step but not the
209
+ layout step — the dimensions of the header do not change. On modern browsers this is
210
+ GPU-composited for elements with `will-change: transform` already applied (a sticky
211
+ element implicitly gets this in most implementations). The transition is not on
212
+ `transform` or `opacity`, so it is not off-thread, but the paint cost for a single
213
+ header background colour change is negligible.
214
+
215
+ Do not animate `color` on large blocks of text — at scale this triggers text-specific
216
+ paint operations on every frame. The inversion pattern here transitions only the header's
217
+ colour; section backgrounds switch instantly (no transition on `.section` itself).