@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,9 @@
1
+ id: SPACING-001
2
+ layer: 1
3
+ category: system
4
+ severity: warn
5
+ when: "Boolean(node.layout && node.layout.padding)"
6
+ value: "node.layout.padding"
7
+ assert: "value.every((v) => v % 8 === 0 || v === 4)"
8
+ message: "Padding {value} is off the 8pt grid (4 excepted)"
9
+ fix: normalize-spacing
@@ -0,0 +1,42 @@
1
+ # System-level correctness rules that do not fit the existing per-category files.
2
+ # Category 'system' means something is measurably wrong in the implementation — not a
3
+ # design-mean convergence failure (slop), not a user-facing access barrier (a11y), and not
4
+ # an animation craft defect (motion).
5
+
6
+ # SYS-TEXT-CLIP: detects a text node whose rendered layout box extends below its parent's
7
+ # bottom edge when the parent clips overflow (overflow: hidden or clip). The canonical case
8
+ # is a clamp()-sized display heading whose last-line descender is cut off at the container.
9
+ #
10
+ # Measurement basis: getBoundingClientRect() returns the full CSS layout rect of the text
11
+ # element, not the visually clipped rect — so node.box.h reflects the unclipped text height
12
+ # even when the parent hides it. If text.box.bottom > parent.box.bottom + 4px and the
13
+ # parent clips, the descender IS visually cut off and the user sees incomplete text.
14
+ #
15
+ # Guard on overflow value:
16
+ # overflow: hidden / clip → clips without a scroll escape → rule fires
17
+ # overflow: visible → text bleeds out intentionally → silent (not stored, treated as absent)
18
+ # overflow: scroll / auto → clips but provides a scrollbar → silent (content accessible)
19
+ #
20
+ # Vertical-only check: only bottom-edge overflow is tested. Horizontal overflow is excluded
21
+ # to avoid false positives from marquee tracks, which extend rightward inside an
22
+ # overflow: hidden container by design (see core/motion/recipes/marquee.md).
23
+ #
24
+ # Threshold: 4px. Sub-pixel rounding and browser differences can produce 1–2px mismatches
25
+ # on legitimate layouts; 4px filters those without masking real descender clips (which
26
+ # are typically 8–20px depending on font and size).
27
+ #
28
+ # Known limitation: position: absolute / fixed children that legitimately overflow a
29
+ # clipping ancestor will false-positive here. If the false-positive rate proves significant
30
+ # in practice, extend dom.ts to capture cs.position on each node and add the guard:
31
+ # !node.position || (node.position !== 'absolute' && node.position !== 'fixed')
32
+ #
33
+ # Positive test: text.box.y + text.box.h > parent.box.y + parent.box.h + 4, parent.overflow === 'hidden' → fires
34
+ # Negative tests: parent.overflow absent (visible default) → silent; overflow within threshold → silent
35
+ - id: SYS-TEXT-CLIP
36
+ layer: 1
37
+ category: system
38
+ severity: warn
39
+ when: "Boolean(node.text) && node.parent !== null"
40
+ value: "(() => { const parent = ir.nodes.find(n => n.id === node.parent); if (!parent) return null; if (parent.overflow !== 'hidden' && parent.overflow !== 'clip') return null; const overflowPx = (node.box.y + node.box.h) - (parent.box.y + parent.box.h); return overflowPx > 4 ? overflowPx : null; })()"
41
+ assert: "value === null"
42
+ message: "Text overflows its clipping parent by {value}px (bottom edge). The parent has overflow: hidden — the descender or last line is visually cut off. Fix: remove the explicit height from the parent and let it size to its content, add padding-bottom to accommodate the text at the smallest clamp() value, or verify that the clamp() minimum renders without clipping at 375px viewport width."
@@ -0,0 +1,8 @@
1
+ id: TOKEN-003
2
+ layer: 1
3
+ category: system
4
+ severity: warn
5
+ when: "Boolean(node.fill) && !node.fill.inherited && node.fill.authored !== false"
6
+ value: "node.fill.token"
7
+ assert: "value !== null"
8
+ message: "Fill is not tokenised (got {value})"
@@ -0,0 +1,125 @@
1
+ # UX rules — task-first structural correctness.
2
+ #
3
+ # These rules target measurable UX violations detectable from the IR alone.
4
+ # They do NOT require viewport height, motion probes, or any runtime state
5
+ # beyond the static IR snapshot.
6
+ #
7
+ # Category 'slop' is used here because UX convergence-to-the-mean (two identical
8
+ # primary buttons, no exits from error states) is in the same family as visual
9
+ # convergence-to-the-mean — correct, competent, anonymously generated. Each rule
10
+ # is a warning; a deliberate choice can be overridden with an omd decision record.
11
+ #
12
+ # Candidate studied and dropped:
13
+ # UX-ACTION-BELOW-FOLD — the IR records node.box.y but does NOT store the
14
+ # viewport height at check time (ir.meta has motion and interaction probe data
15
+ # but no viewport dimensions). Without a reliable viewport-height anchor the
16
+ # threshold is undefined and the false-positive rate is unacceptable. The
17
+ # eye agent's task-first UX pass walks this check visually instead.
18
+ #
19
+ # Candidate retained:
20
+ # UX-TWO-PRIMARIES — two or more sibling interactive buttons sharing the same
21
+ # authored fill colour in the same parent container. This pattern cancels the
22
+ # hierarchy signal: the user cannot determine which action the design recommends.
23
+ # Measurable because: node.computed.isInteractive (set by the DOM reader),
24
+ # node.fill.authored (explicitly set by the author, not inherited), node.fill.value
25
+ # (computed hex), node.box.h >= 36 (button-sized, excludes icon buttons and inline
26
+ # links), node.parent (sibling grouping — only fires when the competing buttons
27
+ # share a container, not when the same colour CTA repeats across different sections).
28
+ #
29
+ # Theory basis: theory/ux.md §Task-first framing; theory/components.md §Buttons.
30
+ # Material Design and Carbon Design System both specify single primary-per-view as an
31
+ # explicit constraint, not a preference.
32
+
33
+ # UX-ACTION-BELOW-FOLD
34
+ #
35
+ # Fires on the root node when the page has interactive controls but NONE of them are
36
+ # within the first viewport at mobile dimensions. "Within the first viewport" means the
37
+ # element's top edge (box.y) is less than viewportHeight — at least the top of the
38
+ # control is visible without scrolling.
39
+ #
40
+ # Revived from the candidate-dropped list now that dom.ts captures scrollHeight and
41
+ # viewportHeight in ir.meta. The earlier drop reason ("viewport height undefined,
42
+ # threshold unanchored") is eliminated: viewportHeight is the actual window height
43
+ # measured at capture time, so the fold is the real browser fold, not an assumed constant.
44
+ #
45
+ # False-positive guard: "long content pages legitimately push a CTA down."
46
+ # The rule fires ONLY when there is NO interactive element within the first viewport.
47
+ # A page with a nav link at y=20 and a CTA at y=1200 does not fire (nav is interactive
48
+ # and within fold). Only a page where every control — including nav items — is below
49
+ # the fold triggers the rule. That condition is never a deliberate choice; it is always
50
+ # a layout bug or a missed mobile viewport regression.
51
+ #
52
+ # Positive test: one interactive element at box.y=900 with viewportHeight=812 → fires
53
+ # Negative tests:
54
+ # — interactive element at box.y=100 (within fold at 812) → silent
55
+ # — no interactive elements at all → silent (when guard)
56
+ # — interactive at y=900 AND nav at y=0 (two elements) → silent (nav is in fold)
57
+ #
58
+ # The rule reads ir.meta.viewportHeight (set by dom.ts). On IRs captured without it
59
+ # (before dom.ts was updated) the when condition is false and the rule is silent —
60
+ # backward compatible.
61
+ - id: UX-ACTION-BELOW-FOLD
62
+ layer: 1
63
+ category: ux
64
+ severity: warn
65
+ when: "node.parent === null && typeof (ir.meta && ir.meta.viewportHeight) === 'number' && ir.nodes.some(n => n.computed.isInteractive)"
66
+ value: "(() => { const vh = Number(ir.meta.viewportHeight); return ir.nodes.filter(n => n.computed.isInteractive && n.box.y < vh).length; })()"
67
+ assert: "value > 0"
68
+ message: "No interactive element is visible within the first viewport (viewportHeight={value}px measured at capture). The primary action is entirely below the fold. Place at least one interactive control — a primary button, a nav item, or a search field — within the first screen of content. See theory/ux.md §Task-first framing."
69
+
70
+ # UX-NO-KEYBOARD-PATH
71
+ #
72
+ # Fires on the root node when the page has interactive controls but every one of them
73
+ # has been explicitly removed from the tab order with tabindex="-1". A page in this
74
+ # state is not keyboard-navigable: no interactive element is reachable by pressing Tab.
75
+ #
76
+ # Data source: node.focusable captured by dom.ts from el.tabIndex per node.
77
+ # true — element is in the tab order (tabIndex >= 0)
78
+ # false — element is interactive but carries tabindex="-1" (removed)
79
+ # absent — static element, no focusability data
80
+ #
81
+ # Guard on when: the rule fires only when at least one interactive node has a recorded
82
+ # focusable field. On pages where no focusable data was captured at all (old IRs, or
83
+ # pages where the extractor produced no interactive nodes with focusable set), the when
84
+ # condition is false and the rule is silent — preventing false positives from missing data.
85
+ #
86
+ # Deliberate exemption: pages that implement custom focus management (e.g., a single-page
87
+ # app routed with a focus trap) will use tabindex="-1" on most elements intentionally.
88
+ # This rule fires a warning; override with `omd decision` and document the focus management
89
+ # strategy. A page with zero keyboard-reachable interactive controls is always worth
90
+ # flagging even if the custom management turns out to be correctly implemented.
91
+ #
92
+ # Positive test: button with focusable:false (tabindex="-1"), no other interactive nodes → fires
93
+ # Negative tests:
94
+ # — button with focusable:true → silent
95
+ # — no interactive elements at all → silent (when guard)
96
+ # — mix: one focusable:false, one focusable:true → silent (one keyboard path exists)
97
+ - id: UX-NO-KEYBOARD-PATH
98
+ layer: 1
99
+ category: ux
100
+ severity: warn
101
+ when: "node.parent === null && ir.nodes.some(n => n.computed.isInteractive && typeof n.focusable === 'boolean')"
102
+ value: "ir.nodes.filter(n => n.computed.isInteractive && n.focusable !== false).length"
103
+ assert: "value > 0"
104
+ message: "Every interactive control on this page has been removed from the keyboard tab order (tabindex=\"-1\"). Users who navigate by keyboard cannot reach any interactive element. Either remove the tabindex=\"-1\" attribute to restore native focus, or implement and document a custom keyboard focus strategy. WCAG 2.1 §2.1.1 requires all functionality to be keyboard-operable. See theory/ux.md §Accessibility as a UX floor."
105
+
106
+ # UX-TWO-PRIMARIES
107
+ #
108
+ # Fires on the root node when any parent container has 2+ interactive, button-sized
109
+ # children with the same authored fill colour. The value is the count of competing
110
+ # sibling groups (1+ means at least one container has this problem).
111
+ #
112
+ # Positive test: two <button> siblings with identical background-color → fires
113
+ # Negative tests:
114
+ # — one primary + one secondary (different fill/no-fill) → silent
115
+ # — two primaries in different parent containers → silent
116
+ # — two small icon buttons (h < 36) with same fill → silent
117
+ # — two buttons where fill.authored is false (inherited) → silent
118
+ - id: UX-TWO-PRIMARIES
119
+ layer: 1
120
+ category: slop
121
+ severity: warn
122
+ when: "node.parent === null"
123
+ value: "(() => { const grouped = {}; ir.nodes.filter(n => n.computed.isInteractive && n.fill && n.fill.authored && n.box.h >= 36).forEach(n => { const key = (n.parent ?? 'root') + ':' + String(n.fill.value); grouped[key] = (grouped[key] || 0) + 1; }); return Object.entries(grouped).filter(([,c]) => c >= 2).length; })()"
124
+ assert: "value === 0"
125
+ message: "{value} container(s) on this page have two or more interactive buttons sharing the same primary fill colour. Two filled buttons of equal visual weight cancel the hierarchy signal — the user cannot determine which action the design recommends. Demote one to secondary (outlined) or tertiary (text-only) per view. See theory/ux.md and theory/components.md."
@@ -0,0 +1,88 @@
1
+ import { readFileSync, readdirSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { parse } from 'yaml';
4
+ import type { Category, Ir, Layer, Node, Rule, RuleValue, Violation } from '../types.ts';
5
+
6
+ export function loadRules(dirPath: string): Rule[] {
7
+ const rules: Rule[] = [];
8
+ const seen = new Set<string>();
9
+
10
+ for (const file of readdirSync(dirPath).filter((f) => f.endsWith('.yaml'))) {
11
+ const parsed: unknown = parse(readFileSync(join(dirPath, file), 'utf8'));
12
+ for (const rule of (Array.isArray(parsed) ? parsed : [parsed]) as Rule[]) {
13
+ if (seen.has(rule.id)) throw new Error(`duplicate rule id "${rule.id}" found in ${file}`);
14
+ seen.add(rule.id);
15
+ if (!rule.category) throw new Error(`rule "${rule.id}" is missing category`);
16
+ rules.push(rule);
17
+ }
18
+ }
19
+ return rules;
20
+ }
21
+
22
+ type Compiled = (node: Node, ir: Ir, value: RuleValue) => unknown;
23
+
24
+ const cache = new Map<string, Compiled>();
25
+
26
+ function compile(ruleId: string, expr: string): Compiled {
27
+ const key = `${ruleId} ${expr}`;
28
+ let fn = cache.get(key);
29
+ if (!fn) {
30
+ // Rules are local, authored files, not user input.
31
+ fn = new Function('node', 'ir', 'value', `return (${expr})`) as Compiled;
32
+ cache.set(key, fn);
33
+ }
34
+ return fn;
35
+ }
36
+
37
+ function evalExpr(ruleId: string, expr: string, node: Node, ir: Ir, value: RuleValue): unknown {
38
+ try {
39
+ return compile(ruleId, expr)(node, ir, value);
40
+ } catch (err) {
41
+ const message = err instanceof Error ? err.message : String(err);
42
+ throw new Error(`rule ${ruleId}: error evaluating "${expr}": ${message}`);
43
+ }
44
+ }
45
+
46
+ function formatValue(v: unknown): string {
47
+ if (v === null || v === undefined) return 'null';
48
+ if (typeof v === 'number') return Number.isInteger(v) ? String(v) : v.toFixed(2);
49
+ if (typeof v === 'object') return JSON.stringify(v);
50
+ return String(v);
51
+ }
52
+
53
+ function interpolate(template: string, ctx: Record<string, unknown>): string {
54
+ return template.replace(/\{(\w+)\}/g, (_, key: string) => formatValue(ctx[key]));
55
+ }
56
+
57
+ export function check(ir: Ir, rules: Rule[], opts: { layers?: Layer[]; categories?: Category[] } = {}): Violation[] {
58
+ const violations: Violation[] = [];
59
+
60
+ for (const rule of rules) {
61
+ if (opts.layers && !opts.layers.includes(rule.layer)) continue;
62
+ if (opts.categories && !opts.categories.includes(rule.category)) continue;
63
+
64
+ for (const node of ir.nodes) {
65
+ if (!evalExpr(rule.id, rule.when, node, ir, null)) continue;
66
+
67
+ const value = (rule.value ? evalExpr(rule.id, rule.value, node, ir, null) : null) as RuleValue;
68
+ if (evalExpr(rule.id, rule.assert, node, ir, value)) continue;
69
+
70
+ violations.push({
71
+ id: rule.id,
72
+ severity: rule.severity,
73
+ layer: rule.layer,
74
+ category: rule.category,
75
+ nodeId: node.id,
76
+ path: node.path,
77
+ value,
78
+ message: interpolate(rule.message, { id: rule.id, value }),
79
+ });
80
+ }
81
+ }
82
+
83
+ // Code-unit order, not localeCompare: the same rules must yield byte-identical output
84
+ // under any ICU locale, on any host. That equivalence is the product.
85
+ const cmp = (a: string, b: string): number => (a < b ? -1 : a > b ? 1 : 0);
86
+ violations.sort((a, b) => cmp(a.path, b.path) || cmp(a.id, b.id));
87
+ return violations;
88
+ }
@@ -0,0 +1,72 @@
1
+ import type { Ir, Violation } from '../types.ts';
2
+
3
+ const MIN_NODE_TOKENS = 6;
4
+ const WINDOW = 5;
5
+
6
+ /**
7
+ * Lowercase, strip markdown/frontmatter punctuation, collapse whitespace, tokenize on
8
+ * whitespace. Korean uses spaces between words, so whitespace tokenization still works —
9
+ * a CJK source with no spaces (e.g. Chinese) would need character n-grams instead; that
10
+ * case is out of scope here.
11
+ *
12
+ * F4: the leading YAML frontmatter block (`---\n...\n---`) is stripped entirely before
13
+ * tokenizing — previously only its `---` fences were removed by the punctuation strip
14
+ * below, leaving frontmatter keys (`why:`, `generator:`) and values as live grams. The
15
+ * body text (which conventionally repeats the same rationale in prose) still matches.
16
+ */
17
+ function tokenize(text: string): string[] {
18
+ return text
19
+ .replace(/^---\n[\s\S]*?\n---\n?/, '')
20
+ .toLowerCase()
21
+ .replace(/[#*`>|-]/g, ' ')
22
+ .replace(/\s+/g, ' ')
23
+ .trim()
24
+ .split(' ')
25
+ .filter(Boolean);
26
+ }
27
+
28
+ function windows(tokens: string[], size: number): string[] {
29
+ const out: string[] = [];
30
+ for (let i = 0; i + size <= tokens.length; i++) out.push(tokens.slice(i, i + size).join(' '));
31
+ return out;
32
+ }
33
+
34
+ /**
35
+ * Design rationale belongs in .omd/, never in the shipped copy. This is the deterministic
36
+ * subset of that failure: a node's text quotes five or more consecutive tokens verbatim
37
+ * from a frame/decision record. Paraphrase is fine — only literal overlap fires, checked
38
+ * against a precomputed Set of record 5-grams so the whole scan stays O(text + records)
39
+ * rather than comparing every node window against every record window.
40
+ */
41
+ export function findLeakedRationale(ir: Ir, records: string[]): Violation[] {
42
+ const recordGrams = new Set<string>();
43
+ for (const record of records) {
44
+ if (!record) continue;
45
+ const tokens = tokenize(record);
46
+ for (const w of windows(tokens, WINDOW)) recordGrams.add(w);
47
+ }
48
+ if (recordGrams.size === 0) return [];
49
+
50
+ const violations: Violation[] = [];
51
+ for (const node of ir.nodes) {
52
+ if (!node.text) continue;
53
+ const tokens = tokenize(node.text);
54
+ if (tokens.length < MIN_NODE_TOKENS) continue;
55
+
56
+ for (const w of windows(tokens, WINDOW)) {
57
+ if (!recordGrams.has(w)) continue;
58
+ violations.push({
59
+ id: 'SLOP-LEAKED-RATIONALE',
60
+ severity: 'warn',
61
+ layer: 1,
62
+ category: 'slop',
63
+ nodeId: node.id,
64
+ path: node.path,
65
+ value: w,
66
+ message: `Design rationale leaked into shipped copy: "${w}". The frame explains the work; the page must never quote it.`,
67
+ });
68
+ break; // one violation per node — first match
69
+ }
70
+ }
71
+ return violations;
72
+ }
@@ -0,0 +1,204 @@
1
+ import type { EnergyCurve, Ir, Violation } from '../types.ts';
2
+
3
+ // ── Motion spec parser ───────────────────────────────────────────────────────
4
+ //
5
+ // motion-spec.md format (written by omd-hand before the build):
6
+ //
7
+ // ## Scene name
8
+ // - trigger: load | scroll | hover
9
+ // - target: .selector
10
+ // - properties: opacity, transform
11
+ // - duration: 400ms (ref: motion-study-1)
12
+ // - easing: cubic-bezier(0.2,0,0,1)
13
+ // - stagger: 40ms between .card siblings ← optional
14
+ //
15
+ // The parser splits on ## headers, then looks for `trigger:` in each section.
16
+
17
+ type Trigger = 'load' | 'scroll' | 'hover' | 'unknown';
18
+
19
+ interface SpecScene {
20
+ name: string;
21
+ trigger: Trigger;
22
+ hasStagger: boolean;
23
+ }
24
+
25
+ function parseMotionSpec(md: string): SpecScene[] {
26
+ // Split on h2 headers; slice(1) discards any preamble before the first ##.
27
+ const sections = md.split(/^##\s+/m).slice(1);
28
+ const scenes: SpecScene[] = [];
29
+
30
+ for (const section of sections) {
31
+ const lines = section.split('\n');
32
+ const name = lines[0]?.trim() ?? '';
33
+ if (!name) continue;
34
+
35
+ let trigger: Trigger = 'unknown';
36
+ const m = /(?:^|\s)trigger\s*:\s*(load|scroll|hover)/im.exec(section);
37
+ if (m) trigger = m[1]!.toLowerCase() as 'load' | 'scroll' | 'hover';
38
+
39
+ const hasStagger = /stagger/i.test(section);
40
+ scenes.push({ name, trigger, hasStagger });
41
+ }
42
+
43
+ return scenes;
44
+ }
45
+
46
+ function isShowpiece(frameMd: string | null): boolean {
47
+ if (!frameMd) return false;
48
+ return /showpiece/i.test(frameMd);
49
+ }
50
+
51
+ // ── Rule implementations ─────────────────────────────────────────────────────
52
+ //
53
+ // MOTION-SPEC-DRIFT
54
+ // Fires when the spec and the live probe disagree about whether motion exists:
55
+ // (a) spec describes load/scroll scenes → probe detected nothing, OR
56
+ // (b) probe detected animations → spec has no scenes.
57
+ // Rationale: the spec is a contract ("build will contain exactly these animations");
58
+ // a gap between spec and reality means either an unimplemented scene or an
59
+ // undocumented animation.
60
+ //
61
+ // GSAP note: getAnimations() cannot see rAF-driven motion (GSAP, Anime.js). A
62
+ // GSAP-only page may legitimately appear as "probe detected nothing" even when the
63
+ // filmstrip energy curve shows motion. When energyCurve is present and peakEnergy > 0,
64
+ // it is used as a fallback signal for probe detection.
65
+ //
66
+ // MOTION-NO-ENTRANCE
67
+ // Fires only when: (1) frame.md commits to showpiece register, AND (2) motion-spec
68
+ // has at least one load-triggered scene, AND (3) neither the probe nor the energy
69
+ // curve detected any entrance animation. Rationale: a showpiece page that promised
70
+ // an entrance scene and delivered nothing is a broken contract between spec and build.
71
+ //
72
+ // MOTION-NO-STAGGER (dropped — unmeasurable with current probe data)
73
+ // Plan v2 M5 specified: fires when spec names a stagger but probe start-times of
74
+ // the scene's siblings are identical. DROPPED: the motion snapshots (t=0/500/1500ms)
75
+ // record animation count, duration, easing, and properties — but NOT per-element start
76
+ // times or delays. Without individual element start times there is no deterministic path
77
+ // to distinguish simultaneous-fire from staggered-fire. Implement when per-element
78
+ // start times become available from the probe.
79
+
80
+ /**
81
+ * Compare `.omd/motion-spec.md` against live probe + energy data and return any
82
+ * MOTION-SPEC-DRIFT or MOTION-NO-ENTRANCE violations.
83
+ *
84
+ * Only called when `.omd/motion-spec.md` exists (the caller guards that).
85
+ *
86
+ * @param ir Normalized IR from the current check run, with ir.meta.motion
87
+ * populated when the check ran against a live page target.
88
+ * @param motionSpecMd Content of .omd/motion-spec.md.
89
+ * @param frameMd Content of .omd/frame.md, or null if absent.
90
+ * @param energyCurve Energy curve from .omd/.cache/*-energy.json, or null if absent.
91
+ * Used as a fallback signal when getAnimations() cannot see the
92
+ * motion library in use (e.g. GSAP).
93
+ */
94
+ export function checkMotionSpec(
95
+ ir: Ir,
96
+ motionSpecMd: string,
97
+ frameMd: string | null,
98
+ energyCurve: EnergyCurve | null,
99
+ ): Violation[] {
100
+ const violations: Violation[] = [];
101
+ const scenes = parseMotionSpec(motionSpecMd);
102
+
103
+ // Probe data may be absent (pre-probe IRs, or check run without a live page target).
104
+ // Skip all spec-vs-probe comparisons when it is: a ghost probe would produce false
105
+ // positives against any spec.
106
+ const motionProbe = ir.meta?.['motion'] as
107
+ | {
108
+ snapshots?: Array<{ t: number; animations: unknown[] }>;
109
+ animatedProperties?: string[];
110
+ scrollChoreography?: Array<{ step: number; fired: number; entered: number }>;
111
+ }
112
+ | null
113
+ | undefined;
114
+
115
+ if (!motionProbe) return violations;
116
+
117
+ // ── Whether the probe/energy detected any motion ─────────────────────────
118
+
119
+ const hasProbeAnimations = (motionProbe.animatedProperties?.length ?? 0) > 0;
120
+ const hasScrollFired = (motionProbe.scrollChoreography ?? []).some((s) => s.fired > 0);
121
+ // Energy > 0.5% pixel change: a negligible anti-alias flicker stays below this floor.
122
+ const hasEnergyMotion = energyCurve !== null && energyCurve.peakEnergy > 0.005;
123
+ const probeDetectedMotion = hasProbeAnimations || hasScrollFired || hasEnergyMotion;
124
+
125
+ // ── MOTION-SPEC-DRIFT ────────────────────────────────────────────────────
126
+
127
+ const loadScrollScenes = scenes.filter((s) => s.trigger === 'load' || s.trigger === 'scroll');
128
+
129
+ if (loadScrollScenes.length > 0 && !probeDetectedMotion) {
130
+ violations.push({
131
+ id: 'MOTION-SPEC-DRIFT',
132
+ severity: 'warn',
133
+ layer: 1,
134
+ category: 'motion',
135
+ nodeId: 'page',
136
+ path: 'motion-spec',
137
+ value: `spec:${loadScrollScenes.length},probe:0`,
138
+ message:
139
+ `motion-spec.md describes ${loadScrollScenes.length} load/scroll scene`
140
+ + `${loadScrollScenes.length === 1 ? '' : 's'} but the live probe detected no`
141
+ + ` animations (getAnimations returned nothing; filmstrip energy also absent or zero).`
142
+ + ` Either the scenes were not implemented, or they use GSAP/rAF which is invisible`
143
+ + ` to getAnimations() — run \`omd render --filmstrip\` and check the energy curve.`,
144
+ });
145
+ }
146
+
147
+ if (scenes.length === 0 && probeDetectedMotion) {
148
+ violations.push({
149
+ id: 'MOTION-SPEC-DRIFT',
150
+ severity: 'warn',
151
+ layer: 1,
152
+ category: 'motion',
153
+ nodeId: 'page',
154
+ path: 'motion-spec',
155
+ value: 'spec:0,probe:detected',
156
+ message:
157
+ 'The live probe detected animations but motion-spec.md describes no scenes.'
158
+ + ' Animations not in the spec were not planned — add a scene entry for every'
159
+ + ' animation in the build, or remove the unplanned animations.',
160
+ });
161
+ }
162
+
163
+ // ── MOTION-NO-ENTRANCE ───────────────────────────────────────────────────
164
+
165
+ // Only fires when the page committed to showpiece AND the spec promised an entrance.
166
+ const hasEntranceScene = scenes.some((s) => s.trigger === 'load');
167
+ const showpiece = isShowpiece(frameMd);
168
+
169
+ if (showpiece && hasEntranceScene) {
170
+ // Probe snapshots at t=0ms and t=500ms cover the entrance window.
171
+ const snap0Anims = motionProbe.snapshots?.[0]?.animations.length ?? 0;
172
+ const snap500Anims = motionProbe.snapshots?.[1]?.animations.length ?? 0;
173
+ const probeEntranceDetected = snap0Anims > 0 || snap500Anims > 0;
174
+
175
+ // Energy from the first frame pair (t=0→t+interval) covers the same window.
176
+ const entranceEnergy = energyCurve !== null && energyCurve.pairs.length > 0
177
+ ? energyCurve.pairs[0]!.changedFraction
178
+ : null;
179
+ const energyEntranceDetected = entranceEnergy !== null && entranceEnergy > 0.005;
180
+
181
+ const entranceMeasured = probeEntranceDetected || energyEntranceDetected;
182
+
183
+ if (!entranceMeasured) {
184
+ violations.push({
185
+ id: 'MOTION-NO-ENTRANCE',
186
+ severity: 'warn',
187
+ layer: 1,
188
+ category: 'motion',
189
+ nodeId: 'page',
190
+ path: 'motion-spec',
191
+ value: 'showpiece+entrance-spec+no-measured-entrance',
192
+ message:
193
+ 'This page commits to the showpiece register and motion-spec.md describes a'
194
+ + ' load-triggered entrance scene, but no entrance animation was detected'
195
+ + ' (getAnimations reported zero at t=0 and t=500ms; filmstrip energy is also'
196
+ + ' absent or zero). Either the entrance animation is not running, or it uses'
197
+ + ' GSAP/rAF — in that case run `omd render --filmstrip` to confirm via the'
198
+ + ' energy curve.',
199
+ });
200
+ }
201
+ }
202
+
203
+ return violations;
204
+ }
@@ -0,0 +1,100 @@
1
+ import type { Invariants } from '../types.ts';
2
+
3
+ /**
4
+ * A cross-page consistency finding. Not a per-node violation — these have no
5
+ * single node to anchor to. The `pages` array names every page involved.
6
+ */
7
+ export interface SiteViolation {
8
+ id: 'SITE-TOKEN-DRIFT' | 'SITE-LADDER-DRIFT';
9
+ severity: 'warn';
10
+ /** All page paths involved in the comparison. */
11
+ pages: string[];
12
+ message: string;
13
+ }
14
+
15
+ /**
16
+ * One page's extracted data for site-level comparison.
17
+ *
18
+ * `invariants` must already be extracted (via extractInvariants in
19
+ * core/ref/invariants.ts). Keeping the comparison logic pure — no Ir, no
20
+ * browser — lets it be tested against synthetic fixtures without Playwright.
21
+ *
22
+ * `tokens` is the raw token map from RawIr.tokens (CSS custom property names
23
+ * → values). Absent for pages loaded from pre-computed IR files that pre-date
24
+ * the tokens field; treated as an empty set in that case.
25
+ */
26
+ export interface SitePage {
27
+ path: string;
28
+ invariants: Invariants;
29
+ tokens?: Record<string, string>;
30
+ }
31
+
32
+ /**
33
+ * Cross-page consistency check. Pure function: no filesystem, no browser.
34
+ *
35
+ * SITE-LADDER-DRIFT fires when any of the three design ladders (type scale,
36
+ * spacing, radius) has a step count that differs by more than one across pages.
37
+ * A difference of one step is tolerated — a detail page may legitimately use
38
+ * one fewer text size than the index. A difference of two or more means the
39
+ * pages were built from different scales.
40
+ *
41
+ * SITE-TOKEN-DRIFT fires when token coverage varies by more than 0.3 across
42
+ * pages. Token coverage is the fraction of tokenable nodes (fill, radius) that
43
+ * cite a CSS custom property rather than an inline value. A page at 0.9 and
44
+ * another at 0.1 were built from different design-system disciplines.
45
+ *
46
+ * Returns an empty array for a single page — a site has no cross-page
47
+ * comparison to make.
48
+ */
49
+ export function checkSite(pages: SitePage[]): SiteViolation[] {
50
+ if (pages.length < 2) return [];
51
+
52
+ const violations: SiteViolation[] = [];
53
+ const allPaths = pages.map((p) => p.path);
54
+
55
+ // ── Ladder drift ────────────────────────────────────────────────────────────
56
+
57
+ const ladderChecks: Array<{ name: string; get: (inv: Invariants) => number[] }> = [
58
+ { name: 'type scale', get: (inv) => inv.typeScale },
59
+ { name: 'spacing ladder', get: (inv) => inv.spacingLadder },
60
+ { name: 'radius ladder', get: (inv) => inv.radiusLadder },
61
+ ];
62
+
63
+ for (const { name, get } of ladderChecks) {
64
+ const entries = pages.map((p) => ({ path: p.path, steps: get(p.invariants).length }));
65
+ const stepCounts = entries.map((e) => e.steps);
66
+ const min = Math.min(...stepCounts);
67
+ const max = Math.max(...stepCounts);
68
+
69
+ if (max - min > 1) {
70
+ const detail = entries.map((e) => `${e.path}: ${e.steps}`).join(', ');
71
+ violations.push({
72
+ id: 'SITE-LADDER-DRIFT',
73
+ severity: 'warn',
74
+ pages: allPaths,
75
+ message: `${name} step count disagrees across pages (${detail})`,
76
+ });
77
+ }
78
+ }
79
+
80
+ // ── Token-coverage drift ────────────────────────────────────────────────────
81
+
82
+ const coverages = pages.map((p) => p.invariants.tokenCoverage);
83
+ const minCoverage = Math.min(...coverages);
84
+ const maxCoverage = Math.max(...coverages);
85
+
86
+ // Round to 4dp before comparing to avoid 0.9 - 0.6 = 0.30000000000000004 false-positives.
87
+ if (Math.round((maxCoverage - minCoverage) * 10000) / 10000 > 0.3) {
88
+ const detail = pages
89
+ .map((p) => `${p.path}: ${p.invariants.tokenCoverage.toFixed(2)}`)
90
+ .join(', ');
91
+ violations.push({
92
+ id: 'SITE-TOKEN-DRIFT',
93
+ severity: 'warn',
94
+ pages: allPaths,
95
+ message: `token coverage varies by ${(maxCoverage - minCoverage).toFixed(2)} across pages (${detail})`,
96
+ });
97
+ }
98
+
99
+ return violations;
100
+ }