@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/types.ts ADDED
@@ -0,0 +1,684 @@
1
+ export type Hex = string;
2
+
3
+ /** A style value paired with the token it came from. `token: null` *is* the report. */
4
+ export interface Styled {
5
+ value: Hex | number;
6
+ token: string | null;
7
+ /** Set by the DOM reader: false when the browser, not the author, chose this. */
8
+ authored?: boolean;
9
+ /** Set by the DOM reader: the nearest painted ancestor fill, borrowed for contrast. */
10
+ inherited?: boolean;
11
+ }
12
+
13
+ export interface Box { x: number; y: number; w: number; h: number }
14
+
15
+ export type LayoutMode = 'VERTICAL' | 'HORIZONTAL';
16
+
17
+ export interface Layout {
18
+ mode: LayoutMode;
19
+ gap: number;
20
+ /** top, right, bottom, left */
21
+ padding: [number, number, number, number];
22
+ }
23
+
24
+ export interface RawNode {
25
+ id: string;
26
+ name: string;
27
+ type: 'FRAME' | 'TEXT';
28
+ path: string;
29
+ parent: string | null;
30
+ box: Box;
31
+ children: string[];
32
+ layout?: Layout;
33
+ fill?: Styled;
34
+ radius?: Styled;
35
+ color?: Hex;
36
+ interactive?: boolean;
37
+ inline?: boolean;
38
+
39
+ // Read only so that slop can be detected. Convergence to the mean is invisible
40
+ // node-by-node; it only shows up in the distribution.
41
+ /** The element's own text, trimmed, capped. Empty for containers. */
42
+ text?: string;
43
+ /** Heading level, 1-6. Set only for h1..h6. */
44
+ heading?: number;
45
+ shadow?: Styled;
46
+ /** The raw `background-image` when it is a gradient. */
47
+ gradient?: string;
48
+ textAlign?: 'left' | 'center' | 'right' | 'justify';
49
+
50
+ // Typography, set only on text-bearing nodes.
51
+ /** The first font-family in the stack, unquoted and lowercased ("inter", "georgia"). */
52
+ fontFamily?: string;
53
+ /** Computed font-size in px, rounded. */
54
+ fontSize?: number;
55
+ /** Computed font-weight, numeric. */
56
+ fontWeight?: number;
57
+ /** line-height as a ratio to font-size, 2dp. 'normal' resolves to 1.2. */
58
+ lineHeight?: number;
59
+
60
+ /**
61
+ * Computed word-break value, captured on text-bearing nodes.
62
+ * Korean may use word- or character-based breaking under KLREQ; acceptability depends on
63
+ * the actual copy, face, and container and is reviewed in typography proof.
64
+ * Set only on text-bearing nodes (where `type === 'TEXT'`).
65
+ */
66
+ wordBreak?: string;
67
+ /**
68
+ * Computed text-wrap value, captured on text-bearing nodes when the browser supports it
69
+ * (Chrome 114+, Firefox 121+). 'balance' is the recommended value for headings to prevent
70
+ * orphaned syllables. Absent when the browser returns an empty or unsupported value.
71
+ */
72
+ textWrap?: string;
73
+ /**
74
+ * Computed overflow value. Absent when 'visible' (the CSS default) to save space.
75
+ * Used by SYS-TEXT-CLIP to identify parents that visually clip their overflowing children.
76
+ * Only 'hidden' and 'clip' cut off content without providing a scrollbar escape.
77
+ */
78
+ overflow?: string;
79
+
80
+ /**
81
+ * Set when background-clip: text is combined with a gradient on this node — the
82
+ * gradient-text tell: a gradient clipped to the text shape, sacrificing legibility
83
+ * for an effect that every generated landing page now shows.
84
+ */
85
+ clipText?: boolean;
86
+
87
+ /**
88
+ * Whether this element is in the document tab order.
89
+ *
90
+ * true — natively focusable tag (a[href], button, input, select, textarea, summary)
91
+ * OR tabindex >= 0 — element is reachable by keyboard navigation.
92
+ * false — element is interactive (has role=button, onclick, or an INTERACTIVE tag) but
93
+ * carries tabindex="-1", explicitly removing it from the tab order.
94
+ * absent — static non-interactive element with no tabindex attribute.
95
+ *
96
+ * Cannot capture: dynamic tabindex mutations via JS, focus management via .focus() calls,
97
+ * CSS :focus-visible visibility (measured separately by the interaction probe in render/).
98
+ */
99
+ focusable?: boolean;
100
+
101
+ /**
102
+ * Explicit ARIA role attribute value. Absent when no role="" attribute is set.
103
+ * Values of interest for rules: 'alert' (error message container), 'alertdialog',
104
+ * 'button' (non-button element acting as a button).
105
+ *
106
+ * Cannot capture: implicit/computed ARIA roles derived from HTML semantics (e.g.
107
+ * <input type="checkbox"> has an implicit role of 'checkbox'). Only explicit role=
108
+ * attribute values are recorded here.
109
+ */
110
+ role?: string;
111
+
112
+ /**
113
+ * True when aria-invalid is present and not "false". Absent otherwise.
114
+ * Signals a field that has failed validation per ARIA spec.
115
+ *
116
+ * Cannot capture: HTML5 constraint-validation state set via the Constraint Validation
117
+ * API (:invalid pseudo-class) without a corresponding aria-invalid attribute.
118
+ */
119
+ ariaInvalid?: boolean;
120
+
121
+ /**
122
+ * Computed backdrop-filter value. Absent when 'none' or not declared.
123
+ * Captured to detect glassmorphism: backdrop-blur combined with a translucent surface,
124
+ * the frozen 2021 Dribbble aesthetic that became the new default.
125
+ */
126
+ backdropFilter?: string;
127
+
128
+ /** Set on any node with a non-zero transition-duration or a named animation. */
129
+ motion?: {
130
+ /** ms, rounded. Transition AND animation durations both land here; zero-length transitions are dropped. */
131
+ durations: number[];
132
+ /** Named (i.e. not `none`) CSS animations running on this node. */
133
+ animationNames: string[];
134
+ /** Timing functions in play on this node, e.g. "ease-out", "cubic-bezier(...)". */
135
+ easings: string[];
136
+ /**
137
+ * CSS properties being transitioned (from transition-property). Only specific property
138
+ * names are included; 'all' and 'none' are excluded so MOTION-LAYOUT-THRASH can check
139
+ * for named layout properties (width/height/top/left/margin) deterministically without
140
+ * false-positives from the 'all' shorthand.
141
+ */
142
+ properties?: string[];
143
+ };
144
+ }
145
+
146
+ /**
147
+ * Everything a critic would otherwise have to count. Precomputed on purpose: a language
148
+ * model asked to measure a contrast ratio will be right most of the time, and a designer
149
+ * only has to catch it being wrong once to stop trusting the tool.
150
+ */
151
+ export interface Computed {
152
+ depth: number;
153
+ /** null at the root. Text sits on its own surface; a container is judged against its parent. */
154
+ contrastWithParent: number | null;
155
+ /** Element-wise mode across siblings, excluding self — an outlier cannot vote for itself. */
156
+ siblingPaddingMode: [number, number, number, number] | null;
157
+ tokenCoverage: number;
158
+ hitArea: { w: number; h: number };
159
+ isInteractive: boolean;
160
+ /**
161
+ * How many siblings share this node's shape: same tag, same child count, same radius
162
+ * and shadow. Three of them in a row is the feature-card grid every generated landing
163
+ * page reaches for when nobody decided what matters most.
164
+ */
165
+ identicalSiblings: number;
166
+ }
167
+
168
+ export type Node = RawNode & { computed: Computed };
169
+
170
+ export interface Stats {
171
+ spacingHistogram: Record<string, number>;
172
+ colorHistogram: Record<string, number>;
173
+ orphanStyles: Hex[];
174
+ componentReuse: Record<string, number>;
175
+
176
+ /** Distributions, because a monoculture is a property of the whole, not of any part. */
177
+ radiusHistogram: Record<string, number>;
178
+ shadowHistogram: Record<string, number>;
179
+ /** Fraction of text-bearing nodes that are centred. 0..1 */
180
+ centeredTextRatio: number;
181
+ /** Every gradient on the page, verbatim. */
182
+ gradients: string[];
183
+ }
184
+
185
+ export interface DeclaredFontFaceEvidence {
186
+ /** Browser-declared CSS FontFace family; this does not identify a painted glyph's face. */
187
+ family: string;
188
+ status: string;
189
+ style: string;
190
+ weight: string;
191
+ stretch: string;
192
+ /** The CSS Font Loading API does not expose the face's source URL. */
193
+ source: null;
194
+ /** Browsers do not expose a per-glyph physical-face identity through this API. */
195
+ glyphIdentity: null;
196
+ }
197
+
198
+ export interface RawIrMeta extends Record<string, unknown> {
199
+ fontFaces?: DeclaredFontFaceEvidence[];
200
+ }
201
+
202
+ export interface RawIr {
203
+ meta?: RawIrMeta;
204
+ tokens?: Record<string, string>;
205
+ nodes: RawNode[];
206
+ }
207
+
208
+ export interface Ir extends RawIr {
209
+ nodes: Node[];
210
+ stats: Stats;
211
+ }
212
+
213
+ export type Severity = 'error' | 'warn';
214
+
215
+ /** Layer 1 is universal and free. Layer 2 is the team's own frame. Layer 3 never lives here. */
216
+ export type Layer = 1 | 2;
217
+
218
+ /**
219
+ * `a11y` and `system` are defects: something is measurably wrong.
220
+ * `slop` is different. Nothing here is broken — the design has simply converged on the
221
+ * mean of everything the model has seen. Each rule is a heuristic and can be wrong about
222
+ * a deliberate choice, which is why every one of them is a warning and none is an error.
223
+ * `motion` covers craft defects in animation execution: missing reduced-motion support,
224
+ * layout thrash, uniform rhythm. Separate from `slop` because these are implementation
225
+ * correctness issues, not design-mean convergence failures.
226
+ * `ux` covers structural UX violations: primary action buried below the fold, no keyboard
227
+ * path into the interactive controls, frame not interrogated. These are correctness issues
228
+ * about task structure, not visual mean-convergence, which is why they are their own family.
229
+ */
230
+ export type Category = 'a11y' | 'system' | 'slop' | 'motion' | 'ux';
231
+
232
+ export interface Rule {
233
+ id: string;
234
+ layer: Layer;
235
+ category: Category;
236
+ severity: Severity;
237
+ /** JS expression over `node`, `ir`. Truthy selects the node. */
238
+ when: string;
239
+ /** JS expression over `node`, `ir`. The quantity under test. */
240
+ value?: string;
241
+ /** JS expression over `node`, `ir`, `value`. Falsy raises a violation. */
242
+ assert: string;
243
+ message: string;
244
+ fix?: string;
245
+ }
246
+
247
+ export type RuleValue = string | number | boolean | null | number[] | { w: number; h: number };
248
+
249
+ export interface Violation {
250
+ id: string;
251
+ severity: Severity;
252
+ layer: Layer;
253
+ category: Category;
254
+ nodeId: string;
255
+ path: string;
256
+ value: RuleValue;
257
+ message: string;
258
+ }
259
+
260
+ /** A record of what the problem is believed to be. The loop rewrites it; nobody signs it. */
261
+ export interface Frame {
262
+ why?: string;
263
+ generator?: string;
264
+ revision?: number;
265
+ writtenAt?: string;
266
+ reframedAt?: string;
267
+ body: string;
268
+
269
+ /**
270
+ * UX interrogation answers. Set by `omd frame set --task --frequent-action --costliest-error`.
271
+ * Backward compatible: frames written before these fields were introduced load without them
272
+ * (absence is treated as incomplete by FRAME-UX-INCOMPLETE, not as an error).
273
+ *
274
+ * Scope boundary: these fields validate that the frame ARTIFACT is complete — that the
275
+ * three UX questions were answered before building. The harness cannot verify whether the
276
+ * rendered build actually serves the named task; that requires a human or the eye agent.
277
+ */
278
+ uxTask?: string;
279
+ uxFrequentAction?: string;
280
+ uxCostliestError?: string;
281
+ /**
282
+ * Surface classification. Set by `omd frame set --surface`. One of `marketing`,
283
+ * `product`, `editorial`, or `mixed` (free text is accepted; those four are the
284
+ * documented taxonomy — see theory/ux.md §Surface types). It selects the
285
+ * composition grammar downstream: a `product` work surface composes as a task
286
+ * loop over screen regions, never as a marketing message ladder.
287
+ */
288
+ uxSurface?: string;
289
+
290
+ [key: string]: unknown;
291
+ }
292
+
293
+ /**
294
+ * What a reference actually is: not a picture, a set of measurements.
295
+ *
296
+ * Jansson & Smith (1991) showed that designers reproduce the features of an example even
297
+ * after its flaws are pointed out — across four separate tasks. Hand a model a screenshot
298
+ * and it will produce a knockoff, which is anonymity wearing a different coat. Hand it the
299
+ * numbers and the reasoning behind them, and it has to think.
300
+ */
301
+ export interface Invariants {
302
+ /** Spacing values carrying the design, smallest first. Zero is excluded: on a real page it is ~80% of all values. */
303
+ spacingLadder: number[];
304
+ /** Corner radii carrying the design. A single rung is a monoculture. */
305
+ radiusLadder: number[];
306
+ /**
307
+ * Distinct box-shadows that are actually elevation. Hairline shadows (`0 0 0 1px`) are
308
+ * borders drawn as shadows and are not counted — Linear has five distinct shadows and
309
+ * four of them are hairlines.
310
+ */
311
+ elevationLevels: number;
312
+ /** Fraction of text-bearing nodes that are centred. 0..1 */
313
+ centeredRatio: number;
314
+ /** Of the styles that could carry a token, the fraction that do. 0..1 */
315
+ tokenCoverage: number;
316
+ /** Mean sum of the four paddings on nodes that declare any. Higher is airier. */
317
+ paddingWeight: number;
318
+
319
+ // Measurements, not vibes: a generic serif-heading dark blog is invisible to spacing
320
+ // and radius ladders alone. These six catch the two things that used to slip through —
321
+ // typography and motion.
322
+ /** Distinct text font sizes carrying the design, ladder-cut, ascending. */
323
+ typeScale: number[];
324
+ /** Distinct first-families found on text, sorted. */
325
+ fontFamilies: string[];
326
+ /** Distinct text font weights carrying the design, ladder-cut, ascending. */
327
+ weightLadder: number[];
328
+ /** Distinct transition/animation durations (ms) carrying the design, ladder-cut, ascending. */
329
+ motionDurations: number[];
330
+ /** Distinct timing functions in use, sorted. */
331
+ easingVocab: string[];
332
+ /** Fraction of all nodes carrying any motion (transition or animation). 0..1, 4dp. */
333
+ animatedShare: number;
334
+
335
+ // Interaction states are unmeasured elsewhere: a dead-on-hover button and a missing
336
+ // focus ring both look identical in a static screenshot. These read the page's actual
337
+ // behaviour, probed live (core/render/index.ts), not its resting state.
338
+ /** hoverResponsive / probed, 2dp. 0 when probed is 0 or the probe failed (unmeasured). */
339
+ hoverCoverage: number;
340
+ /** focusVisible / tabStops, 2dp. 0 when tabStops is 0 or the probe failed (unmeasured). */
341
+ focusCoverage: number;
342
+
343
+ /**
344
+ * CSS properties actually animated, collected from the live motion probe via
345
+ * document.getAnimations({subtree:true}). Sorted, deduplicated.
346
+ *
347
+ * NOTE: rAF-driven libraries (GSAP, Anime.js) animate via requestAnimationFrame and
348
+ * do not register with the browser's Animation API. A GSAP-only page reads as [] here —
349
+ * that is a correct measurement of what getAnimations() can see, not a probe failure.
350
+ * Only CSS animations, CSS transitions (WAAPI path), and explicit WAAPI calls (including
351
+ * Framer Motion's WAAPI path) appear here.
352
+ */
353
+ animatedProperties: string[];
354
+ /**
355
+ * True when at least one stylesheet contains a @media (prefers-reduced-motion) block.
356
+ * False when the probe was not run or found no such block.
357
+ */
358
+ hasReducedMotion: boolean;
359
+ /**
360
+ * Scroll choreography sampled at 25%, 50%, 75%, 100% of viewport height.
361
+ * step: 1-indexed scroll step; fired: running animations at that position;
362
+ * entered: in-viewport elements with a non-zero transition or animation at that position.
363
+ * Empty when the probe was not run.
364
+ */
365
+ scrollChoreography: Array<{ step: number; fired: number; entered: number }>;
366
+ }
367
+
368
+ /**
369
+ * Energy for a single adjacent-frame pair: how much of the image changed.
370
+ * Part of EnergyCurve; defined here so Reference (which embeds EnergyCurve) stays
371
+ * in the same file as all other core record types.
372
+ */
373
+ export interface FrameEnergy {
374
+ /** 0-indexed index of the earlier frame in the pair. */
375
+ pairIndex: number;
376
+ /**
377
+ * Fraction of pixels whose max RGB channel difference exceeds the diff threshold.
378
+ * 0..1, 4dp. 0 = identical frames; 1 = every pixel changed beyond threshold.
379
+ */
380
+ changedFraction: number;
381
+ /**
382
+ * Fraction of changed pixels per vertical third of the frame: [top, mid, bottom].
383
+ * Answers "where did the motion happen" without requiring layout data.
384
+ */
385
+ regionFractions: [number, number, number];
386
+ }
387
+
388
+ /**
389
+ * Pixel-diff motion energy measured from a filmstrip's frame sequence.
390
+ *
391
+ * Unlike the getAnimations() probe (MotionMeasurement), this is computed from actual
392
+ * rendered pixels and therefore captures ALL motion including GSAP/rAF — closing the
393
+ * getAnimations() blind spot documented on MotionMeasurement and Invariants.animatedProperties.
394
+ */
395
+ export interface EnergyCurve {
396
+ /** Number of source frames (pairs.length === frames - 1). */
397
+ frames: number;
398
+ /** Per-interval pixel-difference scores, one entry per adjacent frame pair. */
399
+ pairs: FrameEnergy[];
400
+ /** Maximum changedFraction across all pairs. 0 when pairs is empty. */
401
+ peakEnergy: number;
402
+ }
403
+
404
+ /**
405
+ * Live motion probe data attached to ir.meta.motion by probeMotion() in
406
+ * core/render/index.ts. Absent on IRs captured before this probe was added;
407
+ * all consuming code reads defensively (presence check before access).
408
+ *
409
+ * PROBE LIMIT: rAF-driven libraries (GSAP, Anime.js) animate via requestAnimationFrame
410
+ * and do not register with document.getAnimations(). Their effects are invisible to this
411
+ * probe — a GSAP-only page reports animatedProperties: []. Only CSS animations, CSS
412
+ * transitions exposed via WAAPI, and explicit WAAPI calls (Framer Motion's WAAPI path)
413
+ * are captured. This limit is surfaced in Invariants.animatedProperties.
414
+ *
415
+ * To close this blind spot, use the pixel-diff energy curve (EnergyCurve / computeEnergy
416
+ * in core/motion/energy.ts): it measures frame-to-frame pixel differences and sees all
417
+ * motion including GSAP/rAF.
418
+ */
419
+ export interface MotionMeasurement {
420
+ /** Animation states sampled at t=0ms, t=500ms, t=1500ms after load. */
421
+ snapshots: Array<{
422
+ t: number;
423
+ animations: Array<{ duration: number; easing: string; properties: string[]; playState: string }>;
424
+ }>;
425
+ /** Union of all animated CSS properties across all snapshots, sorted. */
426
+ animatedProperties: string[];
427
+ /** True when at least one stylesheet has a @media (prefers-reduced-motion) block. */
428
+ hasReducedMotion: boolean;
429
+ /**
430
+ * Scroll-triggered choreography: per 25%vh step, animation count and animated-element
431
+ * count visible at that scroll position.
432
+ */
433
+ scrollChoreography: Array<{ step: number; fired: number; entered: number }>;
434
+ }
435
+
436
+ /**
437
+ * How closely a reference was looked at.
438
+ *
439
+ * `page` the whole thing — its overall feel, its rhythm
440
+ * `component` one part of it, scoped by a CSS selector: a search bar, a single button
441
+ * `image` a picture. A screenshot, a Pinterest pin, a photo of a book cover.
442
+ */
443
+ export type RefKind = 'page' | 'component' | 'image';
444
+
445
+ // ── blueprint ────────────────────────────────────────────────────────────────
446
+
447
+ /**
448
+ * Semantic color role assigned to a node's fill or text color.
449
+ * Literal hex values never appear in a blueprint; only roles do.
450
+ */
451
+ export type ColorRole = 'bg' | 'surface' | 'fg' | 'muted' | 'accent';
452
+
453
+ /** Structural role of a blueprint node, derived from the IR node type and computed fields. */
454
+ export type NodeRole = 'container' | 'heading' | 'text' | 'interactive' | 'image';
455
+
456
+ /**
457
+ * Length class of a text node's content. Copy is never transplanted, but knowing whether
458
+ * a slot holds a single word, a headline, or a paragraph guides reconstruction.
459
+ */
460
+ export type TextLengthClass = 'label' | 'phrase' | 'paragraph';
461
+
462
+ /**
463
+ * One node in a blueprint — a full-resolution structural measurement with the skin
464
+ * abstracted to color roles. Literal hex values, token names, and text content are
465
+ * absent; only what is measurable and transferable remains.
466
+ *
467
+ * Fields are present only when the IR extractor actually measured them — no invented
468
+ * values for fields the extractor cannot fill.
469
+ */
470
+ export interface BlueprintNode {
471
+ id: string;
472
+ role: NodeRole;
473
+ /** IDs of direct children, in document order. */
474
+ children: string[];
475
+ /** Bounding box dimensions. Position is intentionally omitted (component-relative). */
476
+ box: { w: number; h: number };
477
+ // layout — present when the node declared flex/grid or any padding
478
+ padding?: [number, number, number, number];
479
+ /** Non-zero gap between children. Absent when 0 or unmeasured. */
480
+ gap?: number;
481
+ /** Flex/grid main axis direction. */
482
+ direction?: 'VERTICAL' | 'HORIZONTAL';
483
+ // typography — present only on text and heading nodes
484
+ fontSize?: number;
485
+ fontWeight?: number;
486
+ lineHeight?: number;
487
+ // surface geometry
488
+ /** Border-radius in px. Absent when 0. */
489
+ radius?: number;
490
+ /** True when the node has a non-none box-shadow. */
491
+ hasShadow?: boolean;
492
+ // color roles — no literal hex values
493
+ /** Semantic role of the node's background fill. Absent for transparent/inherited fills. */
494
+ fillRole?: ColorRole;
495
+ /** Semantic role of the node's text color. Absent for non-text nodes. */
496
+ textRole?: ColorRole;
497
+ // motion
498
+ motionDurations?: number[];
499
+ motionEasings?: string[];
500
+ // text length signal — no actual copy
501
+ /** Length class of the node's own text content. Absent for non-text nodes. */
502
+ textLength?: TextLengthClass;
503
+ }
504
+
505
+ /**
506
+ * Full-resolution structural measurement of one component, with the skin abstracted to
507
+ * color roles. The blueprint captures what the component IS structurally, not what it
508
+ * looks like. Transplanting a blueprint means rebuilding its structure and metrics nearly
509
+ * verbatim, remapping color roles to the project's own tokens, and writing fresh copy.
510
+ */
511
+ export interface Blueprint {
512
+ /** The CSS selector that scoped this capture. */
513
+ selector: string;
514
+ capturedAt: string;
515
+ nodes: BlueprintNode[];
516
+ }
517
+
518
+ export interface Reference {
519
+ source: string;
520
+ component: string;
521
+ kind: RefKind;
522
+ capturedAt: string;
523
+ /** The CSS selector the measurements were taken from. Absent for `page` and `image`. */
524
+ selector?: string;
525
+ /**
526
+ * Null for an image, and the type says so on purpose. Pixels cannot be measured this way:
527
+ * there is no spacing ladder to read out of a JPEG. An image reference carries reasoning
528
+ * and nothing else, which also means `ref distance` cannot check it for cloning.
529
+ */
530
+ invariants: Invariants | null;
531
+ /** Why it was built that way. Written by a model that looked, then closed the tab. */
532
+ principles: string[];
533
+ /**
534
+ * Number of slop-category rule violations found at capture time. Absent on references
535
+ * captured before this field was introduced — treat absence as 0 at the usage site.
536
+ * Two or more findings is a contamination signal: board only as an anti-reference.
537
+ */
538
+ slopCount?: number;
539
+ /**
540
+ * Who provided this URL. `'user'` when the URL came directly from the brief and was added
541
+ * with `--from-user`; `'scout'` when the scout found it autonomously. Absent on records
542
+ * captured before this field was introduced — treat absence as `'scout'` at usage sites.
543
+ */
544
+ origin?: 'user' | 'scout';
545
+ /**
546
+ * Pixel-diff motion energy curve measured during reference capture. Absent when the
547
+ * reference was captured before energy measurement was introduced, or when kind is
548
+ * `'image'` (no rendered page to film). Treat absence as null at usage sites.
549
+ *
550
+ * Unlike `ir.meta.motion` (which only sees getAnimations()), this curve captures ALL
551
+ * pixel-level motion including GSAP/rAF — closing the getAnimations() blind spot.
552
+ */
553
+ energyCurve?: EnergyCurve | null;
554
+ /**
555
+ * Full-resolution structural measurement of one component, skin abstracted to color
556
+ * roles. Present only when `--blueprint` was passed at capture time and a `--selector`
557
+ * was given. Absent on references captured before this field was introduced — treat
558
+ * absence as undefined at usage sites.
559
+ *
560
+ * Transplanting a blueprint means rebuilding its structure and metrics nearly verbatim,
561
+ * remapping colorRoles to the project's own tokens, re-fitting type sizes to the
562
+ * project's scale (keep hierarchy ratios, not absolute px), and writing fresh copy in
563
+ * the project's voice. The page-distance guard still fires on a clone.
564
+ */
565
+ blueprint?: Blueprint;
566
+ /**
567
+ * Path (relative to cwd) of the scoped component screenshot captured with
568
+ * `omd ref add --selector … --shot`. Pairs the component's pixels with its
569
+ * blueprint/invariants on one record so image-first art direction can seed
570
+ * from both the picture and the structural data — see `core/theory/imagegen.md`.
571
+ * The kinship gate (`ref distance <= 0.6`) still gates the shipped build.
572
+ */
573
+ imagePath?: string;
574
+ }
575
+
576
+ /** How close a page sits to a reference. 1 is identical; the warning threshold is 0.6. */
577
+ export interface RefDistance {
578
+ reference: string;
579
+ similarity: number;
580
+ /** Which invariants drove the score, most-similar first. */
581
+ drivers: string[];
582
+ }
583
+
584
+ /**
585
+ * One `omd check` run, appended to `.omd/history.jsonl`.
586
+ *
587
+ * Coach cannot exist without this. "You were told about contrast forty-one times" is a
588
+ * claim about the past, and until now nothing remembered the past: check printed and exited.
589
+ */
590
+ export interface Run {
591
+ ts: string;
592
+ page: string;
593
+ /** ruleId -> how many nodes violated it in this run. */
594
+ counts: Record<string, number>;
595
+ total: number;
596
+ }
597
+
598
+ export interface Recurring {
599
+ rule: string;
600
+ category: Category;
601
+ /** Violations across every run. */
602
+ total: number;
603
+ /** How many distinct runs it showed up in. */
604
+ runs: number;
605
+ trend: 'improving' | 'worsening' | 'flat' | 'insufficient';
606
+ /** Percent change between the first and second half of the runs. Null when insufficient. */
607
+ changePct: number | null;
608
+ }
609
+
610
+ /**
611
+ * What the user keeps getting wrong — skill, not taste.
612
+ *
613
+ * Taste ("you prefer dense layouts") has no right answer: professional designers agree at
614
+ * α = 0.248. Skill ("you keep missing contrast") has one. The two must never be mixed, so
615
+ * Coach never reads `.omd/taste/`.
616
+ */
617
+ export interface CoachReport {
618
+ runs: number;
619
+ span: { from: string; to: string } | null;
620
+ recurring: Recurring[];
621
+ /** Slop rules the user overruled in decisions.md, and how often. */
622
+ overrules: { rule: string; count: number }[];
623
+ /** False when there is too little history to claim a trend. Say so rather than invent one. */
624
+ confident: boolean;
625
+ }
626
+
627
+ export interface Choice {
628
+ ts: string;
629
+ /** Legacy records omit actor/kind and normalize to unknown, never user. */
630
+ actor?: 'user' | 'agent' | 'unknown';
631
+ kind?: 'selection' | 'praise' | 'rejection' | 'overrule';
632
+ subject?: string;
633
+ evidence?: string;
634
+ among?: string[];
635
+ chose?: string;
636
+ why?: string | null;
637
+ generator?: string | null;
638
+ }
639
+
640
+ // ── adapters ──
641
+
642
+ export type Host = 'codex' | 'claude';
643
+
644
+ export interface AbstractHook {
645
+ id: string;
646
+ event: string;
647
+ matcher: string;
648
+ command: string;
649
+ timeout: number;
650
+ statusMessage: string;
651
+ codexFileName: string;
652
+ }
653
+
654
+ export interface AbstractAgent {
655
+ name: string;
656
+ description: string;
657
+ reasoning: string;
658
+ /** No longer used by the adapters — agents inherit the session model. */
659
+ model?: string;
660
+ allow?: string[];
661
+ deny?: string[];
662
+ instructions: string;
663
+ }
664
+
665
+ export interface HookCommand {
666
+ type: 'command';
667
+ command: string;
668
+ timeout: number;
669
+ statusMessage?: string;
670
+ }
671
+
672
+ export interface HookEntry {
673
+ matcher: string;
674
+ hooks: HookCommand[];
675
+ }
676
+
677
+ /** What a host actually loads: events -> matchers -> commands. */
678
+ export interface HookFile {
679
+ hooks: Record<string, HookEntry[]>;
680
+ }
681
+
682
+ export interface Emitted {
683
+ files: Record<string, unknown>;
684
+ }