@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
package/core/ir/dom.ts ADDED
@@ -0,0 +1,352 @@
1
+ import type { RawIr } from '../types.ts';
2
+
3
+ /**
4
+ * Runs inside the page. Stringified and injected, so it must stay self-contained:
5
+ * no imports, no closure over module scope, and nothing that survives type stripping
6
+ * as a runtime construct.
7
+ */
8
+ export function extractInPage(maxNodes: number, selector?: string | null): RawIr {
9
+ const toHex = (css: string): string | null => {
10
+ const m = /^rgba?\(([^)]+)\)$/.exec(css);
11
+ if (!m || !m[1]) return null;
12
+ const parts = m[1].split(',').map((v) => parseFloat(v));
13
+ const [r, g, b, a] = parts as [number, number, number, number?];
14
+ if (a === 0) return null;
15
+ const h = (n: number): string => Math.round(n).toString(16).padStart(2, '0');
16
+ return `#${h(r)}${h(g)}${h(b)}`.toUpperCase();
17
+ };
18
+
19
+ // A design token is a CSS custom property on :root. Inverting value -> name is what
20
+ // lets us tell `background: var(--surface)` apart from a hand-typed #FEFEFE.
21
+ const tokenByValue: Record<string, string> = {};
22
+ const rootStyle = getComputedStyle(document.documentElement);
23
+ for (const prop of Array.from(rootStyle)) {
24
+ if (!prop.startsWith('--')) continue;
25
+ const raw = rootStyle.getPropertyValue(prop).trim();
26
+ const value = raw.startsWith('rgb') ? toHex(raw) : raw.toUpperCase();
27
+ if (value && !(value in tokenByValue)) tokenByValue[prop.slice(2)] = value;
28
+ }
29
+ const nameOf = (value: string): string | null =>
30
+ Object.keys(tokenByValue).find((k) => tokenByValue[k] === value) ?? null;
31
+
32
+ // A browser's default button padding is 1px 6px. Reporting that as an off-grid defect
33
+ // is noise: nobody wrote it. Only properties the author actually declared count as
34
+ // design decisions.
35
+ const authorRules: CSSStyleRule[] = [];
36
+ for (const sheet of Array.from(document.styleSheets)) {
37
+ let rules: CSSRule[];
38
+ try {
39
+ rules = Array.from(sheet.cssRules);
40
+ } catch {
41
+ continue;
42
+ }
43
+ for (const rule of rules) {
44
+ const styleRule = rule as CSSStyleRule;
45
+ if (styleRule.selectorText && styleRule.style) authorRules.push(styleRule);
46
+ }
47
+ }
48
+ const authoredProps = (el: Element): Set<string> => {
49
+ const props = new Set<string>();
50
+ for (const rule of authorRules) {
51
+ let matches = false;
52
+ try {
53
+ matches = el.matches(rule.selectorText);
54
+ } catch {
55
+ matches = false;
56
+ }
57
+ if (matches) for (const p of Array.from(rule.style)) props.add(p);
58
+ }
59
+ for (const p of Array.from((el as HTMLElement).style)) props.add(p);
60
+ return props;
61
+ };
62
+ const declares = (props: Set<string>, prefix: string): boolean =>
63
+ Array.from(props).some((p) => p.startsWith(prefix));
64
+
65
+ const INTERACTIVE = new Set(['A', 'BUTTON', 'INPUT', 'SELECT', 'TEXTAREA', 'SUMMARY']);
66
+ const isInteractive = (el: Element): boolean =>
67
+ INTERACTIVE.has(el.tagName) || el.getAttribute('role') === 'button' || el.hasAttribute('onclick');
68
+
69
+ // NATIVELY_FOCUSABLE: elements whose default tabIndex is 0 (in tab order by spec).
70
+ // <a> without href is NOT focusable by default; <a href="..."> is. We check tabIndex
71
+ // directly rather than duplicating the browser's focusability rules.
72
+ const NATIVELY_FOCUSABLE = new Set(['A', 'BUTTON', 'INPUT', 'SELECT', 'TEXTAREA', 'SUMMARY', 'DETAILS']);
73
+
74
+ /**
75
+ * Returns the focusable state for an element:
76
+ * true — in the tab order (tabIndex >= 0, either natively or via tabindex attr)
77
+ * false — interactive but explicitly removed with tabindex="-1"
78
+ * null — static element, no focusability data to record
79
+ *
80
+ * Cannot capture: dynamic tabindex mutations via JS, focus() calls, or CSS :focus-visible
81
+ * ring visibility (that is measured by the interaction probe in core/render/).
82
+ */
83
+ const focusableState = (el: Element): boolean | null => {
84
+ const htmlEl = el as HTMLElement;
85
+ const tabIdx = htmlEl.tabIndex;
86
+ if (tabIdx >= 0) return true;
87
+ // tabIndex < 0 — only flag as false when the element is otherwise interactive,
88
+ // so we don't record false on every non-interactive div that has no tabindex.
89
+ if (isInteractive(el) || NATIVELY_FOCUSABLE.has(el.tagName)) return false;
90
+ return null;
91
+ };
92
+
93
+ const hasOwnText = (el: Element): boolean =>
94
+ Array.from(el.childNodes).some((n) => n.nodeType === 3 && (n.textContent ?? '').trim().length > 0);
95
+
96
+ // Own text only — a container's descendants speak for themselves as their own nodes.
97
+ const ownText = (el: Element): string | undefined => {
98
+ const joined = Array.from(el.childNodes)
99
+ .filter((n) => n.nodeType === 3)
100
+ .map((n) => n.textContent ?? '')
101
+ .join(' ')
102
+ .replace(/\s+/g, ' ')
103
+ .trim();
104
+ return joined ? joined.slice(0, 200) : undefined;
105
+ };
106
+
107
+ const HEADING_RE = /^H([1-6])$/;
108
+ const headingOf = (el: Element): number | undefined => {
109
+ const m = HEADING_RE.exec(el.tagName);
110
+ return m && m[1] ? Number(m[1]) : undefined;
111
+ };
112
+
113
+ const px = (v: string): number => Math.round(parseFloat(v) || 0);
114
+
115
+ const pathOf = (el: Element): string => {
116
+ const parts: string[] = [];
117
+ for (let n: Element | null = el; n && n !== document.documentElement; n = n.parentElement) {
118
+ const cls = n.classList[0] ? `.${n.classList[0]}` : '';
119
+ parts.unshift(`${n.tagName.toLowerCase()}${cls}`);
120
+ }
121
+ return parts.join('/');
122
+ };
123
+
124
+ const nodes: RawIr['nodes'] = [];
125
+ const walk = (el: Element, parentId: string | null): void => {
126
+ if (nodes.length >= maxNodes) return;
127
+ const rect = el.getBoundingClientRect();
128
+ const cs = getComputedStyle(el);
129
+ if (rect.width === 0 || rect.height === 0 || cs.display === 'none' || cs.visibility === 'hidden') return;
130
+
131
+ const id = `n${nodes.length}`;
132
+ const bg = toHex(cs.backgroundColor);
133
+ const radius = px(cs.borderTopLeftRadius);
134
+ const isText = hasOwnText(el);
135
+ const authored = authoredProps(el);
136
+
137
+ const node: RawIr['nodes'][number] = {
138
+ id,
139
+ name: el.tagName.toLowerCase() + (el.classList[0] ? `.${el.classList[0]}` : ''),
140
+ type: isText ? 'TEXT' : 'FRAME',
141
+ path: pathOf(el),
142
+ parent: parentId,
143
+ box: { x: Math.round(rect.x), y: Math.round(rect.y), w: Math.round(rect.width), h: Math.round(rect.height) },
144
+ children: [],
145
+ };
146
+
147
+ if (bg) node.fill = { value: bg, token: nameOf(bg), authored: declares(authored, 'background') };
148
+ if (isText) {
149
+ const color = toHex(cs.color);
150
+ if (color) node.color = color;
151
+ }
152
+ if (radius > 0) node.radius = { value: radius, token: nameOf(String(radius)) };
153
+ if (isInteractive(el)) node.interactive = true;
154
+ if (cs.display === 'inline') node.inline = true;
155
+
156
+ // Focusability — tab-order membership, one DOM read per node.
157
+ const fs = focusableState(el);
158
+ if (fs !== null) node.focusable = fs;
159
+
160
+ // Explicit ARIA role attribute. Only the declared attribute value — implicit roles
161
+ // derived from tag semantics (e.g. <input> → role='textbox') are NOT captured here.
162
+ const explicitRole = el.getAttribute('role');
163
+ if (explicitRole) node.role = explicitRole;
164
+
165
+ // aria-invalid: present and not "false" → field is in an error state per ARIA spec.
166
+ const ariaInvalid = el.getAttribute('aria-invalid');
167
+ if (ariaInvalid && ariaInvalid !== 'false') node.ariaInvalid = true;
168
+
169
+ if (isText) {
170
+ const family = cs.fontFamily.split(',')[0]?.trim().replace(/^["']|["']$/g, '').toLowerCase();
171
+ if (family) node.fontFamily = family;
172
+ node.fontSize = px(cs.fontSize);
173
+ node.fontWeight = Number(cs.fontWeight) || 0;
174
+ const fontSize = parseFloat(cs.fontSize) || 0;
175
+ if (cs.lineHeight === 'normal') {
176
+ node.lineHeight = 1.2;
177
+ } else {
178
+ const lh = parseFloat(cs.lineHeight);
179
+ node.lineHeight = fontSize > 0 && !Number.isNaN(lh) ? Math.round((lh / fontSize) * 100) / 100 : 1.2;
180
+ }
181
+ // word-break is well-supported; always capture on text nodes.
182
+ if (cs.wordBreak) node.wordBreak = cs.wordBreak;
183
+ // text-wrap (CSS text-wrap shorthand) landed in Chrome 114 / Firefox 121.
184
+ // Access via bracket notation to avoid TypeScript complaints on older lib versions.
185
+ const tw = (cs as unknown as Record<string, string>)['textWrap'];
186
+ if (tw) node.textWrap = tw;
187
+ }
188
+ // Capture overflow when non-default so SYS-TEXT-CLIP can identify clipping parents.
189
+ // Skip 'visible' (the CSS default) to keep the IR sparse.
190
+ if (cs.overflow && cs.overflow !== 'visible') node.overflow = cs.overflow;
191
+
192
+ // Motion: non-zero transition-durations, non-zero animation-durations, and any named
193
+ // animation. `transition-duration`/`transition-timing-function` and
194
+ // `animation-duration`/`animation-timing-function` are each parallel comma-separated
195
+ // lists per the spec. Both duration kinds land in one list — a page's motion rhythm
196
+ // does not care whether a duration came from `transition` or `@keyframes`.
197
+ //
198
+ // The duration and timing-function lists are parallel by index (cycling the shorter
199
+ // one per spec), so a timing function is only "in play" when its own transition's
200
+ // duration survives the >0 filter — a `transition: a .3s, b 0s` must not credit the
201
+ // page with `b`'s easing when `b` never actually transitions (F6).
202
+ const transitionDurationsRaw = cs.transitionDuration
203
+ .split(',')
204
+ .map((v) => Math.round((parseFloat(v) || 0) * 1000));
205
+ const transitionDurations = transitionDurationsRaw.filter((ms) => ms > 0);
206
+ const timingFns = cs.transitionTimingFunction.split(',').map((v) => v.trim()).filter((v) => v.length > 0);
207
+ const transitionEasings = timingFns.length > 0
208
+ ? transitionDurationsRaw
209
+ .map((ms, i) => (ms > 0 ? (timingFns[i % timingFns.length] ?? null) : null))
210
+ .filter((v): v is string => v !== null && v.length > 0)
211
+ : [];
212
+ // CSS properties being transitioned, indexed parallel to transitionDurationsRaw.
213
+ // 'all' and 'none' are excluded so MOTION-LAYOUT-THRASH can match named layout
214
+ // properties (width/height/top/left/margin) without false-positives from 'all'.
215
+ const transitionPropsRaw = cs.transitionProperty
216
+ .split(',')
217
+ .map((v) => v.trim())
218
+ .filter((v) => v.length > 0 && v !== 'none' && v !== 'all');
219
+ const transitionProperties = transitionPropsRaw.length > 0
220
+ ? transitionDurationsRaw
221
+ .map((ms, i) => (ms > 0 ? (transitionPropsRaw[i % transitionPropsRaw.length] ?? null) : null))
222
+ .filter((v): v is string => v !== null && v.length > 0)
223
+ : [];
224
+ const animationNames = cs.animationName
225
+ .split(',')
226
+ .map((v) => v.trim())
227
+ .filter((v) => v.length > 0 && v !== 'none');
228
+ const animationDurations = animationNames.length > 0
229
+ ? cs.animationDuration.split(',').map((v) => Math.round((parseFloat(v) || 0) * 1000)).filter((ms) => ms > 0)
230
+ : [];
231
+ const animationEasings = animationNames.length > 0
232
+ ? cs.animationTimingFunction.split(',').map((v) => v.trim()).filter((v) => v.length > 0)
233
+ : [];
234
+ const allDurations = [...transitionDurations, ...animationDurations];
235
+ const easings = Array.from(new Set([
236
+ ...transitionEasings,
237
+ ...animationEasings,
238
+ ]));
239
+ if (allDurations.length > 0 || animationNames.length > 0) {
240
+ // Renamed from `transitionDurations` (F5): this list holds animation durations too.
241
+ node.motion = {
242
+ durations: allDurations,
243
+ animationNames,
244
+ easings,
245
+ ...(transitionProperties.length > 0 ? { properties: transitionProperties } : {}),
246
+ };
247
+ }
248
+
249
+ const text = ownText(el);
250
+ if (text) node.text = text;
251
+ const heading = headingOf(el);
252
+ if (heading) node.heading = heading;
253
+ if (cs.boxShadow && cs.boxShadow !== 'none') node.shadow = { value: cs.boxShadow, token: nameOf(cs.boxShadow) };
254
+ if (cs.backgroundImage && cs.backgroundImage.includes('gradient')) {
255
+ // The browser reports stops as rgb(); normalise to hex so a rule can read the colour
256
+ // instead of parsing two notations, and so the IR stays byte-stable across engines.
257
+ node.gradient = cs.backgroundImage.replace(/rgba?\([^)]+\)/g, (m) => toHex(m) ?? m);
258
+ }
259
+ // background-clip: text is the gradient-text tell. The browser may prefix this as
260
+ // -webkit-background-clip in some engines; check both. Only set when a gradient is also
261
+ // present — a clipped solid colour is a different pattern entirely.
262
+ const bgClip = cs.backgroundClip || (cs as unknown as Record<string, string>)['webkitBackgroundClip'];
263
+ if (node.gradient && (bgClip === 'text' || bgClip === '-webkit-text')) node.clipText = true;
264
+
265
+ // backdrop-filter is the glassmorphism tell: blur + translucent surface. The browser
266
+ // may prefix this as -webkit-backdrop-filter. Absent when 'none' or not declared.
267
+ const bf = cs.backdropFilter || (cs as unknown as Record<string, string>)['webkitBackdropFilter'];
268
+ if (bf && bf !== 'none') node.backdropFilter = bf;
269
+ if (cs.textAlign === 'left' || cs.textAlign === 'center' || cs.textAlign === 'right' || cs.textAlign === 'justify') {
270
+ node.textAlign = cs.textAlign;
271
+ }
272
+
273
+ const isFlex = cs.display.includes('flex') || cs.display.includes('grid');
274
+ if (isFlex || declares(authored, 'padding')) {
275
+ node.layout = {
276
+ mode: cs.flexDirection === 'column' ? 'VERTICAL' : 'HORIZONTAL',
277
+ gap: px(cs.rowGap === 'normal' ? '0' : cs.rowGap),
278
+ padding: [px(cs.paddingTop), px(cs.paddingRight), px(cs.paddingBottom), px(cs.paddingLeft)],
279
+ };
280
+ }
281
+
282
+ nodes.push(node);
283
+ if (parentId) nodes.find((n) => n.id === parentId)?.children.push(id);
284
+ for (const child of Array.from(el.children)) walk(child, id);
285
+ };
286
+
287
+ let root: Element = document.body;
288
+ if (selector) {
289
+ const found = document.querySelector(selector);
290
+ if (!found) throw new Error(`no element matches selector: ${selector}`);
291
+ root = found;
292
+ }
293
+ walk(root, null);
294
+
295
+ // Contrast needs a real backdrop, not `transparent`. Inherit the nearest painted fill.
296
+ const byId = new Map(nodes.map((n) => [n.id, n]));
297
+ for (const node of nodes) {
298
+ if (node.fill) continue;
299
+ for (let p = node.parent; p; p = byId.get(p)?.parent ?? null) {
300
+ const ancestor = byId.get(p);
301
+ if (ancestor?.fill) {
302
+ node.fill = { ...ancestor.fill, inherited: true };
303
+ break;
304
+ }
305
+ }
306
+ if (!node.fill) {
307
+ // The captured root itself may have no painted fill within the walk (it's
308
+ // transparent, or a scoped selector cut it off from the page's own background).
309
+ // Defaulting to white would poison contrast math with a backdrop nobody painted;
310
+ // walk the root's live-DOM ancestors (its own parent up through documentElement,
311
+ // same climb the whole-page path always used since root there IS document.body)
312
+ // for the first actually-painted backgroundColor.
313
+ let paintedHex: string | null = null;
314
+ for (let anc: Element | null = root.parentElement; anc; anc = anc.parentElement) {
315
+ const ancBg = toHex(getComputedStyle(anc).backgroundColor);
316
+ if (ancBg) {
317
+ paintedHex = ancBg;
318
+ break;
319
+ }
320
+ }
321
+ node.fill = paintedHex
322
+ ? { value: paintedHex, token: nameOf(paintedHex), inherited: true }
323
+ : { value: '#FFFFFF', token: null, inherited: true };
324
+ }
325
+ }
326
+
327
+ // Page-level scroll/viewport dimensions. scrollHeight is the total scrollable height
328
+ // of the document (including content below the fold). viewportHeight is the current
329
+ // window inner height — together they let rules determine whether elements are above
330
+ // or below the fold without needing to know the URL or viewport preset.
331
+ //
332
+ // Cannot capture: viewport dimensions when the page is rendered inside an iframe
333
+ // (window.innerHeight reflects the frame, not the outer viewport). The capture is
334
+ // correct for top-level documents, which is the only case omd check operates on.
335
+ const scrollHeight = document.documentElement.scrollHeight;
336
+ const viewportHeight = window.innerHeight;
337
+ const fontFaces = Array.from(document.fonts).map((face) => ({
338
+ family: face.family,
339
+ status: face.status,
340
+ style: face.style,
341
+ weight: face.weight,
342
+ stretch: face.stretch,
343
+ source: null,
344
+ glyphIdentity: null,
345
+ }));
346
+
347
+ return {
348
+ meta: { source: 'dom', url: location.href, scrollHeight, viewportHeight, fontFaces },
349
+ tokens: tokenByValue,
350
+ nodes,
351
+ };
352
+ }
@@ -0,0 +1,177 @@
1
+ import type { Computed, Hex, Ir, Node, RawIr, RawNode, Stats } from '../types.ts';
2
+
3
+ type Padding = [number, number, number, number];
4
+
5
+ export function contrastRatio(hexA: Hex, hexB: Hex): number {
6
+ const luminance = (hex: Hex): number => {
7
+ const clean = hex.replace('#', '');
8
+ const f = (c: number): number => (c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4);
9
+ const [r, g, b] = [0, 2, 4].map((i) => f(parseInt(clean.slice(i, i + 2), 16) / 255)) as [number, number, number];
10
+ return 0.2126 * r + 0.7152 * g + 0.0722 * b;
11
+ };
12
+ const la = luminance(hexA);
13
+ const lb = luminance(hexB);
14
+ return (Math.max(la, lb) + 0.05) / (Math.min(la, lb) + 0.05);
15
+ }
16
+
17
+ type Index = Map<string, RawNode>;
18
+
19
+ function depthOf(node: RawNode, byId: Index): number {
20
+ let depth = 0;
21
+ let current = node;
22
+ while (current.parent) {
23
+ const parent = byId.get(current.parent);
24
+ if (!parent) break;
25
+ current = parent;
26
+ depth += 1;
27
+ }
28
+ return depth;
29
+ }
30
+
31
+ function contrastWithParent(node: RawNode, byId: Index): number | null {
32
+ if (!node.parent) return null;
33
+ const parentFill = byId.get(node.parent)?.fill?.value;
34
+
35
+ // Text sits on whatever surface it declares, falling back to the parent's. A container
36
+ // is always measured against its parent — measuring it against its own fill would
37
+ // silently return 1.0 and hide a dark card on a dark page.
38
+ const [self, background] =
39
+ node.type === 'TEXT'
40
+ ? [node.color ?? node.fill?.value, node.fill?.value ?? parentFill]
41
+ : [node.fill?.value, parentFill];
42
+
43
+ if (typeof self !== 'string' || typeof background !== 'string') return null;
44
+ return contrastRatio(self, background);
45
+ }
46
+
47
+ function modeOf(values: number[]): number {
48
+ const counts = new Map<number, number>();
49
+ for (const v of values) counts.set(v, (counts.get(v) ?? 0) + 1);
50
+ let best = values[0] ?? 0;
51
+ let bestCount = -1;
52
+ for (const [v, c] of counts) {
53
+ if (c > bestCount || (c === bestCount && v < best)) {
54
+ best = v;
55
+ bestCount = c;
56
+ }
57
+ }
58
+ return best;
59
+ }
60
+
61
+ function siblingPaddingMode(node: RawNode, byId: Index, childrenOf: Map<string, string[]>): Padding | null {
62
+ const own = node.layout?.padding;
63
+ if (!own) return null;
64
+
65
+ const siblings = (node.parent ? childrenOf.get(node.parent) ?? [] : [])
66
+ .map((id) => byId.get(id))
67
+ .filter((s): s is RawNode => Boolean(s) && s!.id !== node.id && Boolean(s!.layout?.padding));
68
+
69
+ if (siblings.length === 0) return null;
70
+ return own.map((_, i) => modeOf(siblings.map((s) => s.layout!.padding[i] ?? 0))) as Padding;
71
+ }
72
+
73
+ const TOKENABLE = ['fill', 'radius'] as const;
74
+
75
+ function tokenCoverage(node: RawNode): number {
76
+ const present = TOKENABLE.filter((p) => node[p] != null);
77
+ if (present.length === 0) return 1;
78
+ return present.filter((p) => node[p]!.token != null).length / present.length;
79
+ }
80
+
81
+ // Same tag(-ish) name, same child count, same radius, same shadow — the feature-card grid
82
+ // every generated landing page reaches for when nobody decided what matters most.
83
+ function shapeSignature(node: RawNode): string {
84
+ const name = node.name.split('.')[0];
85
+ return `${name}|${node.children.length}|${node.radius?.value ?? 'none'}|${node.shadow?.value ?? 'none'}`;
86
+ }
87
+
88
+ function identicalSiblings(node: RawNode, childrenOf: Map<string, string[]>, byId: Index): number {
89
+ if (!node.parent) return 0;
90
+ const sibIds = childrenOf.get(node.parent) ?? [];
91
+ const mySig = shapeSignature(node);
92
+ let count = 0;
93
+ for (const id of sibIds) {
94
+ const sibling = byId.get(id);
95
+ if (sibling && shapeSignature(sibling) === mySig) count += 1;
96
+ }
97
+ return count;
98
+ }
99
+
100
+ function computeStats(nodes: RawNode[]): Stats {
101
+ const spacingHistogram: Record<string, number> = {};
102
+ const colorHistogram: Record<string, number> = {};
103
+ const componentReuse: Record<string, number> = {};
104
+ const radiusHistogram: Record<string, number> = {};
105
+ const shadowHistogram: Record<string, number> = {};
106
+ const bump = (bag: Record<string, number>, key: string | number): void => {
107
+ bag[key] = (bag[key] ?? 0) + 1;
108
+ };
109
+
110
+ const gradients: string[] = [];
111
+ const seenGradients = new Set<string>();
112
+ let textNodes = 0;
113
+ let centeredTextNodes = 0;
114
+
115
+ for (const node of nodes) {
116
+ if (node.layout?.padding) for (const v of node.layout.padding) bump(spacingHistogram, v);
117
+ if (node.layout?.gap != null) bump(spacingHistogram, node.layout.gap);
118
+ if (node.fill) bump(colorHistogram, String(node.fill.value));
119
+ bump(componentReuse, node.name);
120
+ if (node.radius) bump(radiusHistogram, node.radius.value);
121
+ if (node.shadow) bump(shadowHistogram, node.shadow.value);
122
+ if (node.gradient && !seenGradients.has(node.gradient)) {
123
+ seenGradients.add(node.gradient);
124
+ gradients.push(node.gradient);
125
+ }
126
+ if (node.text) {
127
+ textNodes += 1;
128
+ if (node.textAlign === 'center') centeredTextNodes += 1;
129
+ }
130
+ }
131
+
132
+ const orphanStyles = Object.entries(colorHistogram)
133
+ .filter(([, count]) => count === 1)
134
+ .map(([value]) => value)
135
+ .sort();
136
+
137
+ const centeredTextRatio = textNodes === 0 ? 0 : Math.round((centeredTextNodes / textNodes) * 10000) / 10000;
138
+
139
+ return {
140
+ spacingHistogram,
141
+ colorHistogram,
142
+ orphanStyles,
143
+ componentReuse,
144
+ radiusHistogram,
145
+ shadowHistogram,
146
+ centeredTextRatio,
147
+ gradients,
148
+ };
149
+ }
150
+
151
+ export function normalize(rawIr: RawIr): Ir {
152
+ const clone = structuredClone(rawIr);
153
+ const byId: Index = new Map(clone.nodes.map((n) => [n.id, n]));
154
+
155
+ const childrenOf = new Map<string, string[]>();
156
+ for (const n of clone.nodes) {
157
+ if (!n.parent) continue;
158
+ const bucket = childrenOf.get(n.parent);
159
+ if (bucket) bucket.push(n.id);
160
+ else childrenOf.set(n.parent, [n.id]);
161
+ }
162
+
163
+ const nodes: Node[] = clone.nodes.map((node) => {
164
+ const computed: Computed = {
165
+ depth: depthOf(node, byId),
166
+ contrastWithParent: contrastWithParent(node, byId),
167
+ siblingPaddingMode: siblingPaddingMode(node, byId, childrenOf),
168
+ tokenCoverage: tokenCoverage(node),
169
+ hitArea: { w: node.box.w, h: node.box.h },
170
+ isInteractive: Boolean(node.interactive),
171
+ identicalSiblings: identicalSiblings(node, childrenOf, byId),
172
+ };
173
+ return { ...node, computed };
174
+ });
175
+
176
+ return { ...clone, nodes, stats: computeStats(clone.nodes) };
177
+ }
@@ -0,0 +1,151 @@
1
+ # Easing vocabulary — curated token set
2
+
3
+ The keyword easings shipped in CSS (`ease`, `ease-in`, `ease-out`, `ease-in-out`) are
4
+ not a vocabulary — they are placeholders. A design system that stops there has one word
5
+ for every kind of change. This file establishes the curated set this system uses, with
6
+ the semantic reasoning for each curve and the register it belongs to.
7
+
8
+ Cross-reference: `core/theory/motion.md` explains the semantics of ease-out vs. ease-in
9
+ vs. ease-in-out in detail. The tokens below are the *specific curves* — the parameterised
10
+ forms — that belong to each semantic slot. Recipes reference these tokens directly;
11
+ hardcoding cubic-bezier values in a component is the same defect as a hardcoded hex colour.
12
+
13
+ ---
14
+
15
+ ## The token set
16
+
17
+ ### Arrivals (ease-out family)
18
+
19
+ ```css
20
+ :root {
21
+ /* Expo: fast onset, long tail. The object arrives with authority and settles precisely.
22
+ Reads as: the system heard you immediately. Use for: hero entrances, overlay opens,
23
+ drawer slides, any element arriving from outside the viewport.
24
+ Register: showpiece, confident. The snappiest of the out-curves.
25
+ Antonym of 500ms ease-in-out: where that reads generated, this reads decided. */
26
+ --ease-out-expo: cubic-bezier(0.16, 1, 0.3, 1);
27
+
28
+ /* Quint: softer onset than expo, same long tail. Slightly less authoritative,
29
+ slightly more graceful. Use for: content reveals, card entrances, scroll-triggered
30
+ elements that should arrive without demanding attention.
31
+ Register: confident, showpiece. */
32
+ --ease-out-quint: cubic-bezier(0.22, 1, 0.36, 1);
33
+
34
+ /* Back: overshoots the target, rebounds to rest. The object lands with a bounce.
35
+ Use for: playful UI, game-adjacent contexts, elements whose concept includes
36
+ elasticity. Not appropriate for utility UI — the overshoot reads as imprecision in
37
+ a product the user came to operate.
38
+ Register: showpiece only. Use sparingly — one element per page that earns a bounce. */
39
+ --ease-out-back: cubic-bezier(0.34, 1.56, 0.64, 1);
40
+
41
+ /* Circ: extremely crisp deceleration. Starts near-linear, stops hard.
42
+ Use for: elements that need to read as controlled — mechanical precision, technical
43
+ brand registers, UI where snap is a feature.
44
+ Register: confident, quiet (for micro-interactions). */
45
+ --ease-out-circ: cubic-bezier(0, 0.55, 0.45, 1);
46
+ }
47
+ ```
48
+
49
+ ### Departures (ease-in family)
50
+
51
+ ```css
52
+ :root {
53
+ /* Expo: slow pickup, fast exit. The object leaves with finality.
54
+ Use for: dismissals, modals closing, elements exiting the viewport.
55
+ Almost never correct for entrances — a slow start reads as latency.
56
+ See core/theory/motion.md: "ease-in (gradual start, fast finish): an object leaving." */
57
+ --ease-in-expo: cubic-bezier(0.7, 0, 0.84, 0);
58
+
59
+ /* Quint: softer ease-in. Use for exits that should feel deliberate without being
60
+ dramatic — a panel sliding away, a tooltip disappearing.
61
+ Register: confident. */
62
+ --ease-in-quint: cubic-bezier(0.64, 0, 0.78, 0);
63
+ }
64
+ ```
65
+
66
+ ### State transitions (ease-in-out family)
67
+
68
+ ```css
69
+ :root {
70
+ /* Quint in-out: symmetrical, both ends deeply decelerated. For elements moving
71
+ between two significant states — a position change, a layout shift — where
72
+ the before-state and after-state both carry meaning. Neither arrival nor
73
+ departure dominates; the motion reads as a measured pivot.
74
+ Register: quiet, confident. Not for fast micro-interactions. */
75
+ --ease-in-out-quint: cubic-bezier(0.83, 0, 0.17, 1);
76
+
77
+ /* Standard: the Material Design standard easing. Less pronounced deceleration
78
+ than quint in-out. Use for UI state changes that need to feel native and
79
+ predictable — tabs switching, accordion expanding, toggle switching.
80
+ Register: quiet, confident. */
81
+ --ease-standard: cubic-bezier(0.2, 0, 0, 1);
82
+ }
83
+ ```
84
+
85
+ ### Spring approximation
86
+
87
+ ```css
88
+ :root {
89
+ /* Spring via linear(): a physics-approximated spring using the CSS linear()
90
+ function. Produces an overshoot-and-settle that reads as weight and momentum
91
+ rather than the geometric bounce of ease-out-back.
92
+ Fallback: --ease-out-back for browsers without linear() support (check via
93
+ @supports (animation-timing-function: linear(0, 1))).
94
+ Use for: elements whose register is "physical" — draggable cards, shelf items,
95
+ anything that should feel like it has mass.
96
+ Register: showpiece. */
97
+ --ease-spring: linear(
98
+ 0, 0.009, 0.035 2.1%, 0.141, 0.281 6.7%, 0.723 12.9%, 0.938 16.7%,
99
+ 1.017, 1.077, 1.121, 1.149 24.3%, 1.159, 1.163, 1.161, 1.154 29.9%,
100
+ 1.129 33.8%, 1.051 39.6%, 1.017 43.1%, 0.991, 0.977 51%, 0.974 53.8%,
101
+ 0.975 57.1%, 0.997 69.8%, 1.003 76.9%, 1.004 83.8%, 1
102
+ );
103
+ }
104
+ ```
105
+
106
+ ### Constant-velocity (linear)
107
+
108
+ ```css
109
+ :root {
110
+ /* Linear is not a fallback — it is a semantic choice.
111
+ Use for: opacity changes on hover states (eased opacity on hover feels
112
+ sluggish; linear matches the perceived snap of pointer movement);
113
+ looping animations (spinners, progress bars, marquees) where acceleration
114
+ and deceleration would produce visible stuttering on each cycle.
115
+ See core/theory/motion.md: "linear: no easing. Use for opacity changes on
116
+ hover states only." */
117
+ --ease-linear: linear;
118
+ }
119
+ ```
120
+
121
+ ---
122
+
123
+ ## Which register uses which curve
124
+
125
+ | Curve | Register | Canonical use |
126
+ |---|---|---|
127
+ | `--ease-out-expo` | showpiece, confident | Hero entrance, overlay, drawer |
128
+ | `--ease-out-quint` | confident, showpiece | Scroll reveal, card entrance |
129
+ | `--ease-out-back` | showpiece (sparing) | Playful bounce, elastic arrival |
130
+ | `--ease-out-circ` | confident, quiet | Mechanical snap, micro-interaction |
131
+ | `--ease-in-expo` | any | Exit, dismissal, departure |
132
+ | `--ease-in-quint` | confident | Soft exit, panel away |
133
+ | `--ease-in-out-quint` | quiet, confident | State pivot, position change |
134
+ | `--ease-standard` | quiet, confident | Tab switch, toggle, accordion |
135
+ | `--ease-spring` | showpiece | Physical element, draggable, weighty |
136
+ | `--ease-linear` | any | Hover opacity, looping motion |
137
+
138
+ ---
139
+
140
+ ## The 500ms ease-in-out problem
141
+
142
+ `transition: all 500ms ease-in-out` is the single most common signature of generated
143
+ work. It is wrong for three independent reasons (see `core/theory/motion.md`): too slow
144
+ for micro-interactions, too fast for meaningful spatial motion, and the uniform easing
145
+ removes semantic distinction between different kinds of change. The token set above
146
+ provides the antidote: distinct curves for arrivals, departures, pivots, and physical
147
+ motion, each at the duration that earns it.
148
+
149
+ When a hand reaches for `ease-in-out`, the question is: is this an arrival, a departure,
150
+ or a pivot? Arrivals use an out-curve. Departures use an in-curve. Pivots — and only
151
+ pivots — use in-out.