@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,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: 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,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."
|