@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,159 @@
1
+ import type { Ir, Violation } from '../types.ts';
2
+
3
+ /**
4
+ * Token group names that attribution.md must cover.
5
+ *
6
+ * A group is "present" when the page's token map contains at least one key whose
7
+ * prefix belongs to that group. Two formats are supported:
8
+ *
9
+ * Flat CSS custom property map (runtime, from dom.ts):
10
+ * { "color-brand-primary": "#FF5A1F", "spacing-md": "16", ... }
11
+ * — keys are CSS variable names with the leading `--` stripped.
12
+ *
13
+ * Nested Figma-style map (fixture / hand-written):
14
+ * { color: { "brand/primary": "#FF5A1F" }, spacing: { md: 16 } }
15
+ * — top-level key is already the group name.
16
+ */
17
+ const GROUP_PREFIXES: Record<string, string[]> = {
18
+ color: ['color', 'fill', 'bg', 'surface', 'foreground', 'background'],
19
+ type: ['font', 'type', 'heading', 'body'],
20
+ spacing: ['spacing', 'space', 'gap', 'padding', 'margin', 'inset'],
21
+ radius: ['radius', 'corner', 'rounded'],
22
+ motion: ['motion', 'duration', 'ease', 'transition', 'timing', 'animation', 'delay'],
23
+ };
24
+
25
+ const ALL_GROUPS = Object.keys(GROUP_PREFIXES);
26
+
27
+ /** Returns the set of token groups that are actually present in the IR. */
28
+ function tokenGroups(tokens: Record<string, unknown>): Set<string> {
29
+ const groups = new Set<string>();
30
+
31
+ for (const [key, value] of Object.entries(tokens)) {
32
+ const lower = key.toLowerCase();
33
+
34
+ // Nested format: the top-level key IS the group name.
35
+ if (typeof value === 'object' && value !== null && ALL_GROUPS.includes(lower)) {
36
+ groups.add(lower);
37
+ continue;
38
+ }
39
+
40
+ // Flat CSS custom property format: match by prefix.
41
+ for (const [group, prefixes] of Object.entries(GROUP_PREFIXES)) {
42
+ if (prefixes.some((p) => lower === p || lower.startsWith(`${p}-`) || lower.startsWith(`${p}/`))) {
43
+ groups.add(group);
44
+ break;
45
+ }
46
+ }
47
+ }
48
+
49
+ return groups;
50
+ }
51
+
52
+ /** One row from the attribution.md table: { group, source }. */
53
+ interface AttributionRow {
54
+ group: string;
55
+ source: string;
56
+ }
57
+
58
+ /**
59
+ * Parse an attribution.md Markdown table.
60
+ *
61
+ * Expected format (header name is case-insensitive; leading/trailing whitespace ignored):
62
+ *
63
+ * | Group | Source |
64
+ * |---|---|
65
+ * | color | linear-com.hero |
66
+ * | type | theory/typography |
67
+ */
68
+ function parseAttributionRows(md: string): AttributionRow[] {
69
+ const rows: AttributionRow[] = [];
70
+ for (const line of md.split('\n')) {
71
+ const trimmed = line.trim();
72
+ if (!trimmed.startsWith('|') || !trimmed.endsWith('|')) continue;
73
+ const cells = trimmed.slice(1, -1).split('|').map((c) => c.trim());
74
+ if (cells.length < 2) continue;
75
+ const [group, source] = cells as [string, string];
76
+ if (!group || !source) continue;
77
+ // Skip the header row and separator rows.
78
+ if (group.toLowerCase() === 'group' || /^[-: ]+$/.test(group)) continue;
79
+ rows.push({ group: group.toLowerCase(), source });
80
+ }
81
+ return rows;
82
+ }
83
+
84
+ /**
85
+ * Verify that a row's source is a known reference: either a capture slug stored in
86
+ * `.omd/refs/` or a theory-pack file referenced as `theory/<name>`.
87
+ *
88
+ * @param source The source string from the attribution row.
89
+ * @param captureNames Slugs (filenames without .json) found in .omd/refs/.
90
+ * @param theoryNames Base names (without .md) of files in the theory pack.
91
+ */
92
+ function isKnownSource(source: string, captureNames: string[], theoryNames: string[]): boolean {
93
+ if (captureNames.includes(source)) return true;
94
+ if (source.startsWith('theory/')) {
95
+ const name = source.slice('theory/'.length);
96
+ return theoryNames.includes(name);
97
+ }
98
+ return false;
99
+ }
100
+
101
+ /**
102
+ * Attribution audit — the deterministic complement to the attribution.md prompt contract.
103
+ *
104
+ * Only called when `.omd/attribution.md` exists (the caller is responsible for that guard).
105
+ *
106
+ * Two violation codes:
107
+ *
108
+ * ATTR-MISSING A token group is present on the page but has no row in
109
+ * attribution.md. The designer borrowed a token family and did not
110
+ * say where it came from.
111
+ *
112
+ * ATTR-UNKNOWN-SOURCE A row's source is neither a capture name in .omd/refs/ nor a
113
+ * theory/<name> reference. The attribution points to something the
114
+ * tool cannot verify — a typo, a deleted reference, or a free-form
115
+ * note that slipped in where a slug belongs.
116
+ */
117
+ export function checkAttribution(
118
+ ir: Ir,
119
+ attributionMd: string,
120
+ captureNames: string[],
121
+ theoryNames: string[],
122
+ ): Violation[] {
123
+ const violations: Violation[] = [];
124
+ const rows = parseAttributionRows(attributionMd);
125
+ const attributedGroups = new Set(rows.map((r) => r.group));
126
+ const presentGroups = tokenGroups((ir.tokens ?? {}) as Record<string, unknown>);
127
+
128
+ // ATTR-MISSING: token group on the page, no row in attribution.md.
129
+ for (const group of [...presentGroups].sort()) {
130
+ if (attributedGroups.has(group)) continue;
131
+ violations.push({
132
+ id: 'ATTR-MISSING',
133
+ severity: 'warn',
134
+ layer: 1,
135
+ category: 'system',
136
+ nodeId: 'page',
137
+ path: 'attribution',
138
+ value: group,
139
+ message: `Token group "${group}" is used on this page but has no row in .omd/attribution.md. Record where this ${group} system came from before the decision disappears.`,
140
+ });
141
+ }
142
+
143
+ // ATTR-UNKNOWN-SOURCE: row exists, but the source cannot be verified.
144
+ for (const { group, source } of rows) {
145
+ if (isKnownSource(source, captureNames, theoryNames)) continue;
146
+ violations.push({
147
+ id: 'ATTR-UNKNOWN-SOURCE',
148
+ severity: 'warn',
149
+ layer: 1,
150
+ category: 'system',
151
+ nodeId: 'page',
152
+ path: 'attribution',
153
+ value: `${group}: ${source}`,
154
+ message: `Attribution row for "${group}" names source "${source}", which is neither a capture slug in .omd/refs/ nor a theory/<name> reference. Fix the slug or re-run omd ref add for that source.`,
155
+ });
156
+ }
157
+
158
+ return violations;
159
+ }
@@ -0,0 +1,8 @@
1
+ id: CONTRAST-001
2
+ layer: 1
3
+ category: a11y
4
+ severity: error
5
+ when: "node.type === 'TEXT'"
6
+ value: "node.computed.contrastWithParent"
7
+ assert: "value >= 4.5"
8
+ message: "Contrast ratio {value} is below the WCAG AA minimum of 4.5"
@@ -0,0 +1,8 @@
1
+ id: FOCUS-001
2
+ layer: 1
3
+ category: a11y
4
+ severity: warn
5
+ when: "node.parent === null && ir.meta && ir.meta.interaction && ir.meta.interaction.tabStops >= 3"
6
+ value: "Math.round((ir.meta.interaction.focusVisible / ir.meta.interaction.tabStops) * 100)"
7
+ assert: "value >= 50"
8
+ message: "Only {value}% of tab stops show a visible focus indicator. Keyboard users navigate blind."
@@ -0,0 +1,8 @@
1
+ id: HIT-002
2
+ layer: 1
3
+ category: a11y
4
+ severity: error
5
+ when: "node.computed.isInteractive && !node.inline"
6
+ value: "node.computed.hitArea"
7
+ assert: "value.w >= 44 && value.h >= 44"
8
+ message: "Hit area {value} is smaller than the 44x44 minimum"
@@ -0,0 +1,6 @@
1
+ # Korean line breaking is intentionally not a deterministic rule. W3C KLREQ documents
2
+ # word-based and character-based Hangul breaking practices, and the IR cannot judge whether
3
+ # a contextual wrap is acceptable from `word-break` alone. `KO-KEEP-ALL` was therefore
4
+ # removed. Route Korean wrapping to the actual-copy desktop/mobile typography proof, where
5
+ # visible breaks, container width, copy, face, and hierarchy can be reviewed together.
6
+ []
@@ -0,0 +1,80 @@
1
+ # MOTION-* rules: deterministic checks for motion implementation quality.
2
+ # Every rule warns — never errors. A deliberate choice can overrule any with `omd decision`.
3
+ #
4
+ # Category 'motion' is separate from 'slop': these are craft defects in animation
5
+ # execution (missing reduced-motion support, layout thrash, uniform rhythm), not
6
+ # design-mean convergence failures. Separate category means `omd check --category motion`
7
+ # runs only these, leaving slop and a11y unaffected.
8
+ #
9
+ # Data sources available to rules:
10
+ # node.motion.properties — CSS transition-property values (static IR, from dom.ts)
11
+ # ir.meta.motion — live probe data (probeMotion in core/render/index.ts)
12
+ #
13
+ # PROBE LIMIT: GSAP and rAF-driven animation libraries are NOT visible to
14
+ # document.getAnimations(). A GSAP-only page reads as zero live animations here —
15
+ # that is a correct measurement of what the API can see, not a probe failure.
16
+ # Rules that read ir.meta.motion check defensively for probe presence (ir.meta.motion
17
+ # exists and has the expected fields) before asserting. They skip gracefully when the
18
+ # probe was not run or returned null.
19
+ #
20
+ # BLIND SPOT CLOSURE: core/motion/energy.ts (computeEnergy) closes this gap. It
21
+ # computes pixel-diff scores between filmstrip frames and sees ALL motion including
22
+ # GSAP/rAF — a non-zero EnergyCurve.peakEnergy confirms motion occurred even when
23
+ # ir.meta.motion.animatedProperties is []. The MOTION-SPEC-DRIFT and MOTION-NO-ENTRANCE
24
+ # rules in core/rules/motion-spec.ts use the energy curve as a supplementary signal.
25
+ #
26
+ # MOTION-SLOW-HOVER was studied and dropped: node.motion.durations lists ALL transition
27
+ # durations (entrance animations + hover transitions) without distinguishing between them.
28
+ # A rule firing on any interactive node with duration > 200ms would have false positives
29
+ # from entrance animations. No deterministic path exists; the rule is omitted rather than
30
+ # faked. See Plan.md #11c for the analysis.
31
+
32
+ # A page with live animations but no prefers-reduced-motion media query exposes every
33
+ # user who prefers reduced motion to the full animation load. The fix is a single media
34
+ # query block wrapping the entire motion layer. The rule only fires when the live probe
35
+ # measured at least one animated property — a page with no live animations is fine either
36
+ # way, and a pre-probe IR (no ir.meta.motion) cannot be checked.
37
+ - id: MOTION-NO-REDUCED
38
+ layer: 1
39
+ category: motion
40
+ severity: warn
41
+ when: "node.parent === null && ir.meta && ir.meta.motion && Array.isArray(ir.meta.motion.animatedProperties) && ir.meta.motion.animatedProperties.length > 0"
42
+ assert: "ir.meta.motion.hasReducedMotion === true"
43
+ message: "Animations present but no prefers-reduced-motion media query found. Users who opt out of motion see the full animation load. Wrap the entire motion layer: @media (prefers-reduced-motion: reduce) { *, *::before, *::after { transition-duration: 0.01ms !important; animation-duration: 0.01ms !important; animation-iteration-count: 1 !important; } }"
44
+
45
+ # Animating layout properties (width, height, top, left, margin, padding) forces the
46
+ # browser to recalculate layout on every frame — a full layout pass that triggers
47
+ # cascading reflow through the subtree. Animate transform and opacity instead: they run
48
+ # on the compositor thread, never touch layout, and are the only two properties that can
49
+ # be GPU-accelerated without a layout recalculation.
50
+ #
51
+ # The rule reads node.motion.properties (static IR) which lists the CSS transition-property
52
+ # values on this node, excluding 'all' and 'none'. If transition-property is 'all', the
53
+ # specific properties are unknown and the rule does not fire — this is the conservative
54
+ # (non-faking) path. Only explicitly named layout properties are flagged.
55
+ - id: MOTION-LAYOUT-THRASH
56
+ layer: 1
57
+ category: motion
58
+ severity: warn
59
+ when: "node.motion && Array.isArray(node.motion.properties) && node.motion.properties.length > 0"
60
+ value: "node.motion.properties.filter(p => ['width','height','top','right','bottom','left','margin','margin-top','margin-right','margin-bottom','margin-left','padding','padding-top','padding-right','padding-bottom','padding-left'].includes(p))"
61
+ assert: "value.length === 0"
62
+ message: "Layout property animated: {value}. Animating width/height/top/left/margin/padding forces a full layout recalculation on every frame. Animate transform (translate, scale) and opacity instead — they run on the compositor thread and never touch layout."
63
+
64
+ # Three or more animated nodes, every one with the same duration, and the easing
65
+ # vocabulary contains only CSS keyword defaults (ease, ease-in, ease-out, ease-in-out,
66
+ # linear). This is the signature of generated work: "500ms ease-in-out on everything."
67
+ # A considered motion system varies duration by role (entrance is slower than hover;
68
+ # exit is faster than entrance) and uses a measured easing curve from the motion study.
69
+ #
70
+ # Fires on the root node (once per page). Null return from the IIFE means "condition not
71
+ # met" — either fewer than 3 animated nodes, or durations vary, or a real cubic-bezier
72
+ # is in use. Any of those is sufficient to silence the rule.
73
+ - id: MOTION-UNIFORM
74
+ layer: 1
75
+ category: motion
76
+ severity: warn
77
+ when: "node.parent === null"
78
+ value: "(() => { const animated = ir.nodes.filter(n => n.motion && n.motion.durations && n.motion.durations.length > 0); if (animated.length < 3) return null; const allDurations = animated.flatMap(n => n.motion.durations); const unique = [...new Set(allDurations)]; if (unique.length !== 1) return null; const allEasings = [...new Set(animated.flatMap(n => n.motion.easings))]; const defaults = new Set(['ease','linear','ease-in','ease-out','ease-in-out','step-start','step-end']); if (!allEasings.every(e => defaults.has(e))) return null; return { count: animated.length, duration: unique[0], easings: allEasings }; })()"
79
+ assert: "value === null"
80
+ message: "Motion uniform signature: {value}. Every animated node shares one duration and only CSS keyword easings. This is the generated-work signature. Vary duration by role (entrance ≠ hover ≠ exit) and use a measured easing curve (cubic-bezier) from the motion study rather than keyword defaults."
@@ -0,0 +1,365 @@
1
+ # Nothing in this file describes a defect. Every rule here describes work that a model
2
+ # produced from pattern alone, without the position of an author who built the thing and
3
+ # speaks the language. Two families live here:
4
+ #
5
+ # 1. Visual convergence-to-the-mean — the gradient band, the identical radius, the triple
6
+ # card grid. Correct, competent, indistinguishable from ten thousand others. That is the
7
+ # failure this tool was first built for.
8
+ #
9
+ # 2. Prose that could not have been written by a person who speaks the language natively
10
+ # and owns the product — register drift, translation punctuation (spaced em-dash inside
11
+ # Korean copy), speech-level mixing within a paragraph, explainer cadence on a product
12
+ # page. These are not grammar errors. A fluent reader knows, immediately, that no native
13
+ # speaker wrote them. The IR cannot always see layout-level signals (a hard <br> inside
14
+ # a sentence is invisible once the DOM collapses text nodes), but it can see the text
15
+ # itself — and the text is where generated work gives itself away first.
16
+ #
17
+ # Each is a heuristic and each can be wrong about a deliberate choice. So they warn, they
18
+ # never error, and every message says what to do instead rather than merely what is wrong.
19
+ #
20
+ # Whole-page rules fire once, on the root node, and read ir.stats.
21
+
22
+ # Matched by hue, not by a list of hex codes: a blocklist of Tailwind's indigo-500 misses
23
+ # the same idea expressed one shade over. Both stops must sit in the 230–300° band at real
24
+ # saturation — that covers indigo (239°) through violet (258°) to purple (271°) while
25
+ # leaving an honest blue brand (#3B82F6, 217°) alone.
26
+ #
27
+ # The band was first written as 240–300 and missed #6366F1 by one degree — the exact colour
28
+ # it was written to catch. Hand-picked thresholds are brittle; widen only with a case.
29
+ - id: SLOP-GRADIENT
30
+ layer: 1
31
+ category: slop
32
+ severity: warn
33
+ when: "node.parent === null"
34
+ value: "ir.stats.gradients.filter(g => (g.match(/#[0-9a-f]{6}/gi) ?? []).map(x => { const r = parseInt(x.slice(1,3),16)/255, gr = parseInt(x.slice(3,5),16)/255, b = parseInt(x.slice(5,7),16)/255; const mx = Math.max(r,gr,b), mn = Math.min(r,gr,b), d = mx - mn; if (d === 0 || mx === 0 || d/mx < 0.35) return -1; let h = mx === r ? ((gr-b)/d) % 6 : mx === gr ? (b-r)/d + 2 : (r-gr)/d + 4; h *= 60; return h < 0 ? h + 360 : h; }).filter(h => h >= 230 && h <= 300).length >= 2)"
35
+ assert: "value.length === 0"
36
+ message: "Indigo-to-violet gradient: {value}. It is the signature of every landing page generated since 2023. Unless the brand really is violet, this is not a point of view — it is a default."
37
+
38
+ - id: SLOP-RADIUS-MONOCULTURE
39
+ layer: 1
40
+ category: slop
41
+ severity: warn
42
+ when: "node.parent === null"
43
+ value: "Object.entries(ir.stats.radiusHistogram).sort((a,b) => b[1]-a[1])[0] ?? null"
44
+ assert: "value === null || value[1] < 8 || Object.keys(ir.stats.radiusHistogram).length > 1"
45
+ message: "Every corner on the page shares one radius ({value}). Without hierarchy the eye cannot rank anything: a card, a button and an input should not feel like the same material."
46
+
47
+ - id: SLOP-SHADOW-MONOCULTURE
48
+ layer: 1
49
+ category: slop
50
+ severity: warn
51
+ when: "node.parent === null"
52
+ value: "Object.entries(ir.stats.shadowHistogram).filter(([s, n]) => s !== 'none' && n >= 5)"
53
+ assert: "value.length === 0"
54
+ message: "One shadow repeated: {value}. A shadow means elevation. If everything floats at the same height, nothing floats."
55
+
56
+ - id: SLOP-EVERYTHING-CENTERED
57
+ layer: 1
58
+ category: slop
59
+ severity: warn
60
+ when: "node.parent === null && ir.stats.centeredTextRatio > 0"
61
+ value: "Math.round(ir.stats.centeredTextRatio * 100)"
62
+ assert: "value < 60"
63
+ message: "{value}% of text blocks are centred. Centring is emphasis, not a default. Emphasise everything and you emphasise nothing — and only a left-aligned paragraph gives the eye somewhere to return to on each line."
64
+
65
+ # Widened from headings-only to also cover interactive nodes (buttons,
66
+ # links). A CTA that opens with 🚀 borrows excitement it has not earned as much as a heading
67
+ # that does. The check fires only on the starting emoji — a functional arrow or chevron
68
+ # (Unicode Arrows block U+2190–U+21FF) is NOT in either emoji range and does not fire.
69
+ - id: SLOP-EMOJI-HEADING
70
+ layer: 1
71
+ category: slop
72
+ severity: warn
73
+ when: "(Boolean(node.heading) || Boolean(node.interactive)) && Boolean(node.text)"
74
+ value: "node.text"
75
+ assert: "!/^\\s*[\\u{1F300}-\\u{1FAFF}\\u{2600}-\\u{27BF}]/u.test(value)"
76
+ message: "Heading or button opens with an emoji: {value}. An emoji borrows emotional charge it has not earned. If it is doing a job your typography or copy failed to do, fix those instead."
77
+
78
+ - id: SLOP-COPY
79
+ layer: 1
80
+ category: slop
81
+ severity: warn
82
+ when: "Boolean(node.text) && node.text.length > 8"
83
+ value: "node.text"
84
+ # F2: removed four trigger-happy branches that fired on ordinary prose — `~?를 통해 알 수 있`
85
+ # (matches mid-word inside the common neutral construction "이를 통해 알 수 있다"), `매우 중요한`
86
+ # ("very important" — everyday, not slop), `we (?:don't|do not|never) (?:use|do|write)`
87
+ # (fires on legitimate privacy/policy copy like "we don't use trackers"; the self-negating
88
+ # meta-brag case it was meant for is already covered by `no (?:fluff|jargon|nonsense) here`),
89
+ # and the unanchored `이 (?:페이지|사이트|글)(?:에서)?는 .*(?:없습니다|않습니다)` (its greedy `.*`
90
+ # swallowed ordinary 404s, empty states, and privacy copy). F3: moved the narrow Korean
91
+ # self-negating pattern `이런 내용은 없` to SLOP-PINK-ELEPHANT, which covers the full family.
92
+ # F2 widening: added three phrases with near-zero false-positive risk.
93
+ # "say goodbye to" — textbook AI marketing opener; never appears in honest product copy.
94
+ # "it['’]s not just.*it['’]s" — the classic AI antithesis formula "It's not just X — it's Y".
95
+ # "blazing.?fast" — stock performance adjective; real benchmarks use numbers.
96
+ assert: "!/\\b(seamlessly|effortlessly|unlock the power|elevate your|supercharge|game.?chang|take .* to the next level|revolutioniz|cutting.?edge|unleash|no (?:fluff|jargon|nonsense) here|say goodbye to|blazing.?fast)\\b|it[\\u2019']s not just|결론적으로|시사하는 바가 크|주목할 만하|첫째[,,]/i.test(value)"
97
+ message: "Marketing filler or AI stock phrase: \"{value}\". It fits any product and says nothing about this one. Write what this product specifically gives, costs, or does."
98
+
99
+ # The pink-elephant failure: a page told "no clutter" that writes "No clutter here."
100
+ # Copy must state what IS — the positive fact the negation was hiding. This rule catches
101
+ # the structural pattern: announcing what the page refuses to do, which is always the model
102
+ # quoting its own instructions back to the user. Narrow patterns only; broad negation ("we
103
+ # don't use") hits privacy and 404 copy that are entirely legitimate. Negative tests are
104
+ # mandatory: "we don't use trackers", empty states, 404s, out-of-stock must never fire.
105
+ - id: SLOP-PINK-ELEPHANT
106
+ layer: 1
107
+ category: slop
108
+ severity: warn
109
+ when: "Boolean(node.text) && node.text.length > 8"
110
+ value: "node.text"
111
+ assert: "!/you won't find [^.]{0,40} here|we (?:won't|will never) (?:bore|spam|waste)|\\b(?:no|zero) (?:ads|clutter|distractions|gimmicks)[,.]? just\\b|이런 (?:내용|것)[은는] 없|(?:여기|이 페이지)에는 [가-힣 ]{0,15}(?:광고|잡동사니|군더더기)[가는은]? 없/i.test(value)"
112
+ message: "Self-negating meta-copy: \"{value}\". The page is announcing what it refuses to do — the model quoting its own brief. Delete this sentence and write the positive fact it was hiding: what the reader gets, what it costs, what happens next."
113
+
114
+ # Korean AI writing patterns promoted from the im-not-ai taxonomy.
115
+ #
116
+ # Three classes, all S1 (deterministically AI-flavoured in UI copy):
117
+ #
118
+ # C-11 — a sentence-opening connective directly followed by a comma (그러나, / 하지만, /
119
+ # 또한, / 따라서, / 즉,). Standard Korean prose does not punctuate these with a comma;
120
+ # the comma is a direct trace of English "However," and "Therefore," translated by a model.
121
+ # Narrow by design: only the exact five connectives fire; the comma+space pair is required so
122
+ # "또한 그날 밤" (ordinary adverb) passes cleanly.
123
+ #
124
+ # D류 — 알아보겠습니다 / 살펴보겠습니다. The "let us now examine" structural openers that
125
+ # appear in AI-generated explanatory content. On a product page they expose the model's
126
+ # essay skeleton. A human copywriter writes what the product does, not that we will look at
127
+ # what the product does.
128
+ #
129
+ # F2 rule: drop a pattern rather than ship one that fires on legitimate prose. A-19 겹조사
130
+ # (-에서의/-으로의) was studied and dropped: "서울에서의 하루" is unambiguous legitimate Korean
131
+ # so the pattern is too trigger-happy to promote. "둘째,/셋째," was also studied and retained
132
+ # in this rule rather than SLOP-COPY because it completes the 첫째-둘째-셋째 enumeration
133
+ # family already flagged by SLOP-COPY.
134
+ - id: SLOP-COPY-KO
135
+ layer: 1
136
+ category: slop
137
+ severity: warn
138
+ when: "Boolean(node.text) && node.text.length > 8"
139
+ value: "node.text"
140
+ assert: "!/(?:그러나|하지만|또한|따라서|즉),[ \\t]|살펴보겠습니다|알아보겠습니다|둘째[,,]|셋째[,,]/u.test(value)"
141
+ message: "Korean AI writing pattern: \"{value}\". A connective followed by a comma (그러나,/또한,), a structural 살펴보겠습니다/알아보겠습니다opener, or a 둘째/셋째 enumeration all trace the model's essay skeleton, not the product's actual claim. Write the specific fact this sentence was hiding."
142
+
143
+ # Korean translation-punctuation tell: a spaced em-dash ( — ) or en-dash ( – ) inside a
144
+ # sentence that contains Hangul. The spaced em-dash is standard English typography and is
145
+ # legitimate in English copy; it is not standard in Korean product writing. It appears in
146
+ # Korean AI output because the model learned the pattern from English text and transfers it
147
+ # directly. A native Korean copywriter uses a comma, a colon, a dash without surrounding
148
+ # spaces, or starts a new sentence — never the spaced European em-dash construction.
149
+ #
150
+ # Gated on Hangul presence in the same node so English copy is never touched.
151
+ - id: SLOP-KO-EMDASH
152
+ layer: 1
153
+ category: slop
154
+ severity: warn
155
+ when: "Boolean(node.text) && node.text.length > 8 && /[가-힣]/u.test(node.text)"
156
+ value: "node.text"
157
+ assert: "!/ [—–] /.test(value)"
158
+ message: "Spaced em-dash or en-dash inside Korean copy: \"{value}\". The spaced em-dash ( — ) is an English typography convention; it appears in Korean AI output as a direct transfer from English training data. Rewrite as a comma, colon, or a new sentence — the construction Korean product copy actually uses."
159
+
160
+ # Korean speech-level mixing within a single text block. Korean has two formal registers
161
+ # that must not alternate in the same paragraph outside of quoted dialogue:
162
+ #
163
+ # 해요체 — ALL forms end in 요 followed by sentence-final punctuation (. ! ?):
164
+ # 아요, 어요, 예요, 에요, 해요, 봐요, 줘요, 쉬워요, and every other contracted form.
165
+ # 합니다체 — ALL forms end in 니다 followed by sentence-final punctuation (. ! ?):
166
+ # 습니다, and the ㅂ-absorbed forms 합니다, 됩니다, 만듭니다, 갑니다, etc.
167
+ # (In Unicode, the ㅂ attaches to the preceding syllable as a 받침 —
168
+ # "만듭니다" is 만+듭+니+다, not 만+들+ㅂ+니+다. The text "ㅂ니다" never
169
+ # appears; 니다[.!?] is the correct detector for the full family.)
170
+ #
171
+ # A native speaker writing product copy commits to one level and holds it. A model assembles
172
+ # sentences across registers because each sentence is generated independently. The result
173
+ # reads immediately as machine-made to any fluent reader — not as a grammar error but as
174
+ # a sign that no single human voice was present.
175
+ #
176
+ # False positive guard: if the node contains quotation marks (straight or curly, Korean
177
+ # angle brackets), stay silent — dialogue attribution legitimately mixes registers.
178
+ #
179
+ # Positive test: "모델은 정답을 만들지 않아요. 훈련 데이터의 평균을 만듭니다." (해요체 + 합니다체)
180
+ # Negatives: all-해요체 block, all-합니다체 block, any block containing 따옴표.
181
+ - id: SLOP-KO-REGISTER-MIX
182
+ layer: 1
183
+ category: slop
184
+ severity: warn
185
+ when: "Boolean(node.text) && node.text.length > 8 && /[가-힣]/u.test(node.text)"
186
+ value: "node.text"
187
+ assert: "!(/요[.!?]/.test(value) && /니다[.!?]/.test(value)) || /[\"'\\u201C\\u201D\\u2018\\u2019\\u300C\\u300D]/.test(value)"
188
+ message: "Mixed speech levels in one text block: \"{value}\". 해요체 endings (요.) and 합니다체 endings (니다.) alternate inside one paragraph — a sign that sentences were assembled across generation passes rather than written by one voice. Choose one level, held for the whole page, and state which in the voice study."
189
+
190
+ - id: SLOP-TRIPLE-CARD
191
+ layer: 1
192
+ category: slop
193
+ severity: warn
194
+ when: "node.computed.identicalSiblings >= 3 && Boolean(node.shadow) && Boolean(node.radius)"
195
+ value: "node.computed.identicalSiblings"
196
+ assert: "false"
197
+ message: "{value} structurally identical cards sit side by side. Three feature cards are not an information architecture; they are a confession that nobody decided what matters most. Decide, then make that one bigger."
198
+
199
+ # Korean document-structure narration. These phrases announce the document's own organisation
200
+ # rather than communicating what the product does: "아래는 그 기록이에요" (below is the record),
201
+ # "다음은 기능 목록입니다" (the following is the feature list). On a product page, this cadence
202
+ # exposes the model's essay or README skeleton — the AI introducing its own bullet list instead
203
+ # of writing the bullet list. A human copywriter presents the content; they do not narrate
204
+ # that content is about to appear.
205
+ #
206
+ # Two patterns, both gated on Korean text presence:
207
+ #
208
+ # Pattern 1: 아래는 <words> <기록|내용|목록|정리>
209
+ # "아래는 그 기록이에요" → fires
210
+ # "아래는 변경 내용이에요" → fires
211
+ # "아래 버튼을 누르세요" → passes (아래 without 는 particle; spatial adverb not a structure marker)
212
+ # "아래에서 다운로드하세요" → passes (아래에서, not 아래는)
213
+ #
214
+ # Pattern 2: 다음은 <words> <입니다|이에요>
215
+ # "다음은 기능 목록입니다" → fires
216
+ # "다음은 그 이유이에요" → fires
217
+ # "다음에 다시 시도해주세요" → passes (다음에, not 다음은)
218
+ #
219
+ # The .{0,30} span is deliberately short: a sentence that says "아래는 그 기록이에요" has
220
+ # the structure word within 30 characters of 아래는. A longer span risks matching sentences
221
+ # that happen to contain 기록 as part of an unrelated clause.
222
+ - id: SLOP-KO-SIGNPOST
223
+ layer: 1
224
+ category: slop
225
+ severity: warn
226
+ when: "Boolean(node.text) && node.text.length > 8 && /[가-힣]/u.test(node.text)"
227
+ value: "node.text"
228
+ assert: "!/아래는\\s+.{0,30}(?:기록|내용|목록|정리)|다음은\\s+.{0,30}(?:입니다|이에요)/u.test(value)"
229
+ message: "Document-structure narration copy: \"{value}\". '아래는 … 기록/내용/목록' and '다음은 … 입니다/이에요' introduce the document's structure in essay cadence. On a product page, write what the product does — not that we will now look at what the product does. Delete the announcing sentence and replace it with the specific fact or action it was narrating."
230
+
231
+ # ── the machine-default aesthetic, measured ────────────────────────────────────────────
232
+ #
233
+ # These rules remain only where rendered IR supports a narrow, repeatable warning. Source
234
+ # compounds and contextual visual judgments follow `protocol/slop-review.md`: `omd slop scan`
235
+ # reports candidates for triage, not linter verdicts, severity, score, history, coach data, or
236
+ # an AI-authorship claim. Unsafe source cues do not become broad YAML regexes. Rendered IR is
237
+ # authoritative when source and render evidence overlap.
238
+
239
+ # Gradient-clipped text: background-clip: text combined with a gradient. The effect
240
+ # sacrifices legibility for an appearance that every generated landing page now shows.
241
+ # The IR sets node.clipText when the browser reports background-clip:text (or the
242
+ # -webkit- prefix) alongside a gradient on the same node.
243
+ # Gradient headline text.
244
+ - id: SLOP-GRADIENT-TEXT
245
+ layer: 1
246
+ category: slop
247
+ severity: warn
248
+ when: "Boolean(node.clipText)"
249
+ assert: "false"
250
+ message: "Gradient-clipped text on this node. background-clip:text renders text in a gradient, sacrificing legibility for an effect that every generated landing page now shares. Establish hierarchy through size, weight, and position; use a single legible text colour. Use omd decision to record a brand reason if this hue band is genuinely the brand's."
251
+
252
+ # Invented stat row: round heroic numbers (10k+, 99.9%, 24/7) clustered on a page.
253
+ # Real metrics are odd and specific — "847 teams migrated in Q1" carries more weight than
254
+ # any 10× claim because nobody lies that specifically. The rule fires when two or more
255
+ # text nodes each independently match an invented-stat pattern.
256
+ # The invented stat row.
257
+ - id: SLOP-FAKE-STAT
258
+ layer: 1
259
+ category: slop
260
+ severity: warn
261
+ when: "node.parent === null"
262
+ value: "(() => { const p = [/\\b\\d+[kK]\\+/, /\\b9[89](?:\\.\\d)?%/, /\\b24\\/7\\b/]; const hits = ir.nodes.filter(n => n.text && p.some(r => r.test(n.text))).map(n => n.text.slice(0, 40)); return hits.length >= 2 ? hits : null; })()"
263
+ assert: "value === null"
264
+ message: "Invented stat row: {value}. Round heroic numbers (10k+, 99.9%, 24/7) on a new product are decoration — the user has no reason to believe them. Write odd, specific, sourced numbers ('847 teams migrated in Q1') or nothing."
265
+
266
+ # Oversized drop shadow: shadow blur ≥40px on a node whose smaller dimension is less than
267
+ # or equal to the blur. A shadow that large maps to no real surface — it is fog. Elevation
268
+ # shadows have blur proportional to their step: a card 2dp high gets ~4px, not 40px.
269
+ # Parsed from the computed box-shadow string: browser returns offsets in px order after
270
+ # the colour (Chrome/Firefox: "rgba(…) Xpx Ypx BLURpx SPREADpx").
271
+ # The oversized drop shadow.
272
+ - id: SLOP-OVERSIZED-SHADOW
273
+ layer: 1
274
+ category: slop
275
+ severity: warn
276
+ when: "Boolean(node.shadow) && node.shadow.value !== 'none'"
277
+ value: "(() => { const nums = (node.shadow.value.match(/-?\\d+(?:\\.\\d+)?(?=px)/g) || []).map(Number); const blur = nums[2] ?? 0; const smaller = Math.min(node.box.w, node.box.h); return blur >= 40 && smaller > 0 && blur >= smaller ? { blur, size: smaller } : null; })()"
278
+ assert: "value === null"
279
+ message: "Oversized shadow: {value}. A blur radius ≥40px on an element this small is fog, not elevation. Shadow blur should stay below half the element's smaller dimension. Use a tight elevation scale: 0/2/4/8/16px blur where each step is a real layer."
280
+
281
+ # Card inside a card: a surfaced node (radius + shadow) whose nearest ancestor is also
282
+ # surfaced. Every nested container adds material depth without information. The fix is
283
+ # one surface per region; group related content with spacing and hairline dividers.
284
+ # Cards inside cards.
285
+ - id: SLOP-NESTED-CARDS
286
+ layer: 1
287
+ category: slop
288
+ severity: warn
289
+ when: "Boolean(node.radius) && Boolean(node.shadow)"
290
+ value: "(() => { let p = node.parent; while (p) { const par = ir.nodes.find(n => n.id === p); if (!par) break; if (par.radius && par.shadow) return true; p = par.parent; } return false; })()"
291
+ assert: "value !== true"
292
+ message: "Card nested inside a card: a surfaced node (radius + shadow) sits inside another surfaced ancestor. Every nested container adds material depth without information. Use one surface per region; group related content with spacing and hairline dividers instead."
293
+
294
+ # Non-concentric corners: child radius ≥ parent radius when the child is inset by padding.
295
+ # The rule is inner = outer − padding. When inner ≥ outer, the corners do not nest
296
+ # optically — they arc at the same rate and the containment looks wrong. A card with
297
+ # 16px radius and 16px padding should hold children with radius 0 to ~12px, not 16px+.
298
+ # Only fires when parent radius > 4px (not micro-radii) and parent has non-zero padding
299
+ # (confirming the child is genuinely inset, not floating adjacent to the parent).
300
+ # Corners that don't nest.
301
+ - id: SLOP-NESTED-RADIUS
302
+ layer: 1
303
+ category: slop
304
+ severity: warn
305
+ when: "Boolean(node.radius) && Boolean(node.parent)"
306
+ value: "(() => { const par = ir.nodes.find(n => n.id === node.parent); if (!par?.radius) return null; const outer = typeof par.radius.value === 'number' ? par.radius.value : 0; const inner = typeof node.radius.value === 'number' ? node.radius.value : 0; const pads = par.layout?.padding ? par.layout.padding.filter(v => v > 0) : []; const minPad = pads.length > 0 ? Math.min(...pads) : 0; return outer > 4 && inner >= outer && minPad > 0 ? { outer, inner, padding: minPad } : null; })()"
307
+ assert: "value === null"
308
+ message: "Non-concentric corners: {value}. When a child is inset by padding, its radius should be outer minus padding. Inner radius ≥ outer: the surfaces do not nest — the inner element appears to float rather than sit inside. Correct inner radius = outer − min(padding)."
309
+
310
+ # Spacing monoculture: one spacing value dominates ≥80% of all padding and gap on the
311
+ # page. When proximity carries no information, nothing is grouped — the layout is a grid
312
+ # of equidistant objects. Fires on pages with ≥20 non-zero spacing samples.
313
+ # Zero is excluded: on a real page it represents ~80% of all values and is not a decision.
314
+ # One gap everywhere.
315
+ - id: SLOP-MONO-SPACING
316
+ layer: 1
317
+ category: slop
318
+ severity: warn
319
+ when: "node.parent === null"
320
+ value: "(() => { const h = ir.stats.spacingHistogram; const entries = Object.entries(h).filter(([k]) => Number(k) !== 0); const total = entries.reduce((s, [,n]) => s + n, 0); if (total < 20) return null; const top = entries.sort((a, b) => b[1] - a[1])[0]; if (!top) return null; const pct = Math.round(top[1] / total * 100); return pct >= 80 ? { value: top[0], pct } : null; })()"
321
+ assert: "value === null"
322
+ message: "Spacing monoculture: {value}. One spacing value accounts for 80%+ of all padding and gap. When proximity carries no information, nothing is grouped. Use a scale with at least three non-zero distinct steps (e.g. 8/16/32/64px) applied asymmetrically — tight within groups, generous between."
323
+
324
+ # Glassmorphism monoculture: three or more nodes use backdrop-filter: blur, the frozen
325
+ # 2021 Dribbble aesthetic. A single frosted-glass surface can be a deliberate choice;
326
+ # applying it to every card is a pattern inherited from training data.
327
+ # The IR captures node.backdropFilter when the computed backdrop-filter is not 'none'.
328
+ # Max radius + glassmorphism.
329
+ - id: SLOP-GLASSMORPHISM
330
+ layer: 1
331
+ category: slop
332
+ severity: warn
333
+ when: "node.parent === null"
334
+ value: "ir.nodes.filter(n => n.backdropFilter && /blur/.test(n.backdropFilter)).length"
335
+ assert: "value < 3"
336
+ message: "{value} nodes use backdrop-filter:blur (glassmorphism). A single frosted-glass surface is a deliberate choice; {value} of them is a pattern from 2021 Dribbble. Replace repeated blur surfaces with opaque ones — one material, defined. Use omd decision to record why multiple blur surfaces are required."
337
+
338
+ # Manufactured status badges: small text nodes whose entire content is a badge-like term
339
+ # (New / Beta / Hot / Popular / Trending / Soon / Pro) with optional leading emoji.
340
+ # Real badges carry time-bounded or count-backed information; decoration badges erode
341
+ # trust by the third one. Gated to short text (≤30 chars) on small nodes (height ≤36px).
342
+ # Badge and pill spam.
343
+ - id: SLOP-BADGE-SPAM
344
+ layer: 1
345
+ category: slop
346
+ severity: warn
347
+ when: "Boolean(node.text) && node.text.length <= 30 && node.box.h <= 36"
348
+ value: "node.text"
349
+ assert: "!/^[\\s\\u{1F300}-\\u{1FAFF}\\u{2600}-\\u{27BF}]*\\b(?:new|beta|hot|popular|trending|pro|soon)\\b[\\s!]*$/iu.test(value)"
350
+ message: "Manufactured badge: \"{value}\". New / Beta / Hot / Popular tags manufacture buzz. Use badge treatments only for genuine, time-bounded status: a feature launched this week, an actual version number, a real availability flag. Decoration badges erode trust by the third one."
351
+
352
+ # Flat type hierarchy: when three or more distinct font sizes cluster between 13–19px, or
353
+ # adjacent steps differ by less than 1.15×, size stops carrying hierarchy. The eye cannot
354
+ # rank headings from body copy when the sizes are too close to distinguish.
355
+ # Fires at the page level; requires ≥3 distinct sizes (a two-size page may be intentionally
356
+ # minimal). A scale of 14/18/24px or 12/16/28px passes; 13/14/15/17px fails.
357
+ # The flat type hierarchy.
358
+ - id: SLOP-FLAT-TYPE
359
+ layer: 1
360
+ category: slop
361
+ severity: warn
362
+ when: "node.parent === null"
363
+ value: "(() => { const sizes = [...new Set(ir.nodes.filter(n => n.fontSize && n.fontSize > 0).map(n => n.fontSize))].sort((a, b) => a - b); if (sizes.length < 3) return null; const allFlat = sizes.every(s => s >= 13 && s <= 19); const ratios = sizes.slice(1).map((s, i) => s / sizes[i]); const minR = ratios.reduce((a, b) => Math.min(a, b), 99); const flatCount = ratios.filter(r => r < 1.15).length; return (allFlat || flatCount >= 2) ? { sizes, minRatio: Math.round(minR * 100) / 100 } : null; })()"
364
+ assert: "value === null"
365
+ message: "Flat type hierarchy: {value}. Font sizes all cluster between 13–19px or adjacent steps differ by less than 1.15×. Size stops carrying hierarchy. Establish at least three steps at 1.25× minimum (e.g. 14/18/24px). Use weight — not colour shade — to differentiate content at the same size."