@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.
- package/LICENSE +21 -0
- package/README.ko.md +282 -0
- package/README.md +282 -0
- package/adapters/build.ts +184 -0
- package/adapters/claude.ts +111 -0
- package/adapters/codex.ts +68 -0
- package/adapters/tokens.ts +21 -0
- package/adapters/tool-map.json +7 -0
- package/bin/omd-install.ts +76 -0
- package/bin/omd.ts +1774 -0
- package/core/asset-sourcing/index.ts +187 -0
- package/core/coach/index.ts +98 -0
- package/core/composition/app-shell-workbench.md +168 -0
- package/core/composition/asymmetric-diagonal-grid.md +178 -0
- package/core/composition/bento-grid.md +223 -0
- package/core/composition/editorial-index-labels.md +173 -0
- package/core/composition/form-wizard-stepper.md +132 -0
- package/core/composition/master-detail-flow.md +129 -0
- package/core/composition/section-inversion.md +182 -0
- package/core/composition/sidebar-margin-annotation.md +184 -0
- package/core/composition/split-screen-hero.md +224 -0
- package/core/composition/sticky-sidebar-scroll.md +219 -0
- package/core/composition/typographic-hero.md +100 -0
- package/core/composition-contract/index.ts +233 -0
- package/core/composition-contract/visual-richness.ts +101 -0
- package/core/config/index.ts +27 -0
- package/core/copy/index.ts +296 -0
- package/core/craft/finish-pass.md +356 -0
- package/core/craft/index.ts +33 -0
- package/core/design/index.ts +581 -0
- package/core/design/interaction-states.ts +189 -0
- package/core/eval-harness/index.ts +225 -0
- package/core/evidence/final.ts +444 -0
- package/core/evidence/task.ts +459 -0
- package/core/figma/client.ts +312 -0
- package/core/figma/diff.ts +261 -0
- package/core/figma/responsive.ts +310 -0
- package/core/figma/system.ts +466 -0
- package/core/figma/types.ts +185 -0
- package/core/frame/check-ux.ts +180 -0
- package/core/frame/index.ts +39 -0
- package/core/frame/write.ts +168 -0
- package/core/graphics/css-illustration-primitives.md +189 -0
- package/core/graphics/duotone-image-presets.md +173 -0
- package/core/graphics/gradient-mesh.md +178 -0
- package/core/graphics/noise-grain-texture.md +150 -0
- package/core/graphics/placeholder-policy.md +185 -0
- package/core/graphics/svg-geometric-patterns.md +145 -0
- package/core/history/index.ts +39 -0
- package/core/install/detect.ts +32 -0
- package/core/install/install.ts +321 -0
- package/core/install/patch-claude.ts +109 -0
- package/core/install/patch-codex.ts +82 -0
- package/core/interaction/index.ts +101 -0
- package/core/interaction/recipes/signature-lighting.md +203 -0
- package/core/ir/dom.ts +352 -0
- package/core/ir/normalize.ts +177 -0
- package/core/motion/easing.md +151 -0
- package/core/motion/energy.ts +188 -0
- package/core/motion/recipes/image-hover-distortion.md +198 -0
- package/core/motion/recipes/magnetic-hover.md +229 -0
- package/core/motion/recipes/marquee.md +202 -0
- package/core/motion/recipes/number-counter.md +242 -0
- package/core/motion/recipes/page-loader.md +240 -0
- package/core/motion/recipes/parallax.md +233 -0
- package/core/motion/recipes/scroll-reveal.md +210 -0
- package/core/motion/recipes/section-color-inversion.md +217 -0
- package/core/motion/recipes/split-text-entrance.md +222 -0
- package/core/motion/recipes/stagger-orchestrator.md +227 -0
- package/core/motion/recipes/sticky-scene-transition.md +246 -0
- package/core/motion/recipes/view-transitions.md +239 -0
- package/core/probe/index.ts +186 -0
- package/core/probe/schema.json +46 -0
- package/core/protocol/composition-contract.md +192 -0
- package/core/protocol/copy-deck.md +96 -0
- package/core/protocol/human-design-loop.md +447 -0
- package/core/protocol/slop-review.md +71 -0
- package/core/ref/batch.ts +94 -0
- package/core/ref/blueprint.ts +199 -0
- package/core/ref/distance.ts +225 -0
- package/core/ref/invariants.ts +162 -0
- package/core/ref/signal.ts +37 -0
- package/core/ref/store.ts +122 -0
- package/core/render/index.ts +607 -0
- package/core/rules/attribution.ts +159 -0
- package/core/rules/builtin/contrast.yaml +8 -0
- package/core/rules/builtin/focus.yaml +8 -0
- package/core/rules/builtin/hit-area.yaml +8 -0
- package/core/rules/builtin/ko.yaml +6 -0
- package/core/rules/builtin/motion.yaml +80 -0
- package/core/rules/builtin/slop.yaml +365 -0
- package/core/rules/builtin/spacing.yaml +9 -0
- package/core/rules/builtin/system.yaml +42 -0
- package/core/rules/builtin/token.yaml +8 -0
- package/core/rules/builtin/ux.yaml +125 -0
- package/core/rules/engine.ts +88 -0
- package/core/rules/leakage.ts +72 -0
- package/core/rules/motion-spec.ts +204 -0
- package/core/site/index.ts +100 -0
- package/core/slop/index.ts +427 -0
- package/core/slop/text-slop.ts +220 -0
- package/core/source-seal/index.ts +174 -0
- package/core/stack/index.ts +53 -0
- package/core/target/index.ts +183 -0
- package/core/theory/color.md +285 -0
- package/core/theory/components.md +272 -0
- package/core/theory/craft.md +270 -0
- package/core/theory/expressive.md +213 -0
- package/core/theory/imagegen.md +105 -0
- package/core/theory/layout.md +159 -0
- package/core/theory/motion.md +283 -0
- package/core/theory/typography.md +134 -0
- package/core/theory/ux.md +580 -0
- package/core/theory/voice.md +595 -0
- package/core/types.ts +684 -0
- package/package.json +40 -0
- package/src/agents/composer.agent.yaml +174 -0
- package/src/agents/eye.agent.yaml +208 -0
- package/src/agents/framer.agent.yaml +52 -0
- package/src/agents/glance.agent.yaml +19 -0
- package/src/agents/hand.agent.yaml +200 -0
- package/src/agents/scout.agent.yaml +71 -0
- package/src/agents/sketch.agent.yaml +64 -0
- package/src/agents/typesetter.agent.yaml +52 -0
- package/src/agents/writer.agent.yaml +63 -0
- package/src/skills/omd-coach/SKILL.md +40 -0
- package/src/skills/omd-critique/SKILL.md +77 -0
- package/src/skills/omd-figma/SKILL.md +256 -0
- package/src/skills/omd-humanize/SKILL.md +160 -0
- package/src/skills/omd-scout/SKILL.md +77 -0
- 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,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
|
+
}
|