@etus/seven-skill 0.1.0-beta.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 (42) hide show
  1. package/.claude/skills/seven/SKILL.md +162 -0
  2. package/.claude/skills/seven/reference/audit.md +120 -0
  3. package/.claude/skills/seven/reference/brand.md +24 -0
  4. package/.claude/skills/seven/reference/clarify.md +76 -0
  5. package/.claude/skills/seven/reference/color-and-contrast.md +84 -0
  6. package/.claude/skills/seven/reference/motion-design.md +71 -0
  7. package/.claude/skills/seven/reference/polish.md +55 -0
  8. package/.claude/skills/seven/reference/product.md +45 -0
  9. package/.claude/skills/seven/reference/shape.md +85 -0
  10. package/.claude/skills/seven/reference/spatial-design.md +60 -0
  11. package/.claude/skills/seven/reference/typography.md +43 -0
  12. package/.claude/skills/seven/reference/ux-writing.md +47 -0
  13. package/.claude/skills/seven/scripts/load-context.mjs +84 -0
  14. package/.claude-plugin/marketplace.json +34 -0
  15. package/.claude-plugin/plugin.json +12 -0
  16. package/LICENSE +190 -0
  17. package/NOTICE.md +26 -0
  18. package/README.md +118 -0
  19. package/cli/bin/commands/skills.mjs +664 -0
  20. package/cli/bin/seven.mjs +68 -0
  21. package/cli/engine/browser/injected/index.mjs +84 -0
  22. package/cli/engine/cli/main.mjs +215 -0
  23. package/cli/engine/detect-antipatterns-browser.js +3014 -0
  24. package/cli/engine/detect-antipatterns.mjs +44 -0
  25. package/cli/engine/engines/browser/detect-url.mjs +108 -0
  26. package/cli/engine/engines/regex/detect-text.mjs +508 -0
  27. package/cli/engine/engines/static-html/css-cascade.mjs +957 -0
  28. package/cli/engine/engines/static-html/detect-html.mjs +211 -0
  29. package/cli/engine/engines/visual/screenshot-contrast.mjs +192 -0
  30. package/cli/engine/findings.mjs +28 -0
  31. package/cli/engine/node/file-system.mjs +212 -0
  32. package/cli/engine/profile/profiler.mjs +169 -0
  33. package/cli/engine/registry/seven-antipatterns.mjs +494 -0
  34. package/cli/engine/rules/checks.mjs +1518 -0
  35. package/cli/engine/shared/color.mjs +204 -0
  36. package/cli/engine/shared/constants.mjs +91 -0
  37. package/cli/engine/shared/page.mjs +9 -0
  38. package/cli/engine/shared/tokens.mjs +153 -0
  39. package/cli/engine/token-data.generated.mjs +691 -0
  40. package/docs/detector-rules.md +605 -0
  41. package/docs/figma-token-rule-exploration.md +185 -0
  42. package/package.json +76 -0
@@ -0,0 +1,169 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // ETUS Digital — Seven Design System. Detector architecture adapted from
3
+ // impeccable (Paul Bakaus, Apache-2.0). See packages/seven-skill/NOTICE.md.
4
+ function profileNow() {
5
+ return typeof performance !== 'undefined' && performance.now
6
+ ? performance.now()
7
+ : Date.now();
8
+ }
9
+
10
+ function createDetectorProfile() {
11
+ return { events: [] };
12
+ }
13
+
14
+ function recordProfileEvent(profile, event) {
15
+ if (!profile) return;
16
+ const normalized = {
17
+ engine: event.engine || 'unknown',
18
+ phase: event.phase || 'unknown',
19
+ ruleId: event.ruleId || 'unknown',
20
+ target: event.target || '',
21
+ ms: Number.isFinite(event.ms) ? event.ms : 0,
22
+ findings: Number.isFinite(event.findings) ? event.findings : 0,
23
+ };
24
+ if (event.detail) normalized.detail = event.detail;
25
+ if (Array.isArray(event.findingIds) && event.findingIds.length) {
26
+ normalized.findingIds = event.findingIds;
27
+ }
28
+ if (typeof profile === 'function') {
29
+ profile(normalized);
30
+ } else if (typeof profile.record === 'function') {
31
+ profile.record(normalized);
32
+ } else if (Array.isArray(profile.events)) {
33
+ profile.events.push(normalized);
34
+ } else if (Array.isArray(profile)) {
35
+ profile.push(normalized);
36
+ }
37
+ }
38
+
39
+ function extractFindingIds(findings) {
40
+ if (!Array.isArray(findings) || findings.length === 0) return [];
41
+ return [...new Set(findings.map(f => f?.id || f?.type || f?.antipattern).filter(Boolean))];
42
+ }
43
+
44
+ function profileFindings(profile, meta, callback) {
45
+ if (!profile) return callback();
46
+ const started = profileNow();
47
+ const findings = callback();
48
+ recordProfileEvent(profile, {
49
+ ...meta,
50
+ ms: profileNow() - started,
51
+ findings: Array.isArray(findings) ? findings.length : 0,
52
+ findingIds: extractFindingIds(findings),
53
+ });
54
+ return findings;
55
+ }
56
+
57
+ function profileStep(profile, meta, callback) {
58
+ if (!profile) return callback();
59
+ const started = profileNow();
60
+ try {
61
+ return callback();
62
+ } finally {
63
+ recordProfileEvent(profile, {
64
+ ...meta,
65
+ ms: profileNow() - started,
66
+ findings: 0,
67
+ });
68
+ }
69
+ }
70
+
71
+ async function profileFindingsAsync(profile, meta, callback) {
72
+ if (!profile) return callback();
73
+ const started = profileNow();
74
+ const findings = await callback();
75
+ recordProfileEvent(profile, {
76
+ ...meta,
77
+ ms: profileNow() - started,
78
+ findings: Array.isArray(findings) ? findings.length : 0,
79
+ findingIds: extractFindingIds(findings),
80
+ });
81
+ return findings;
82
+ }
83
+
84
+ async function profileStepAsync(profile, meta, callback) {
85
+ if (!profile) return callback();
86
+ const started = profileNow();
87
+ try {
88
+ return await callback();
89
+ } finally {
90
+ recordProfileEvent(profile, {
91
+ ...meta,
92
+ ms: profileNow() - started,
93
+ findings: 0,
94
+ });
95
+ }
96
+ }
97
+
98
+ function percentile(sortedValues, pct) {
99
+ if (!sortedValues.length) return 0;
100
+ const idx = Math.min(
101
+ sortedValues.length - 1,
102
+ Math.max(0, Math.ceil((pct / 100) * sortedValues.length) - 1),
103
+ );
104
+ return sortedValues[idx];
105
+ }
106
+
107
+ function summarizeDetectorProfile(profile) {
108
+ const events = Array.isArray(profile)
109
+ ? profile
110
+ : (Array.isArray(profile?.events) ? profile.events : []);
111
+ const groups = new Map();
112
+ for (const event of events) {
113
+ const key = [
114
+ event.engine || 'unknown',
115
+ event.phase || 'unknown',
116
+ event.ruleId || 'unknown',
117
+ event.target || '',
118
+ ].join('\u0000');
119
+ let group = groups.get(key);
120
+ if (!group) {
121
+ group = {
122
+ engine: event.engine || 'unknown',
123
+ phase: event.phase || 'unknown',
124
+ ruleId: event.ruleId || 'unknown',
125
+ target: event.target || '',
126
+ calls: 0,
127
+ totalMs: 0,
128
+ findings: 0,
129
+ samples: [],
130
+ };
131
+ groups.set(key, group);
132
+ }
133
+ const ms = Number.isFinite(event.ms) ? event.ms : 0;
134
+ group.calls += 1;
135
+ group.totalMs += ms;
136
+ group.findings += Number.isFinite(event.findings) ? event.findings : 0;
137
+ group.samples.push(ms);
138
+ }
139
+ return [...groups.values()]
140
+ .map(group => {
141
+ const samples = group.samples.sort((a, b) => a - b);
142
+ return {
143
+ engine: group.engine,
144
+ phase: group.phase,
145
+ ruleId: group.ruleId,
146
+ target: group.target,
147
+ calls: group.calls,
148
+ totalMs: Number(group.totalMs.toFixed(3)),
149
+ avgMs: Number((group.totalMs / group.calls).toFixed(3)),
150
+ p50: Number(percentile(samples, 50).toFixed(3)),
151
+ p95: Number(percentile(samples, 95).toFixed(3)),
152
+ findings: group.findings,
153
+ };
154
+ })
155
+ .sort((a, b) => b.totalMs - a.totalMs);
156
+ }
157
+
158
+ export {
159
+ profileNow,
160
+ createDetectorProfile,
161
+ recordProfileEvent,
162
+ extractFindingIds,
163
+ profileFindings,
164
+ profileStep,
165
+ profileFindingsAsync,
166
+ profileStepAsync,
167
+ percentile,
168
+ summarizeDetectorProfile,
169
+ };
@@ -0,0 +1,494 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // ETUS Digital — Seven Design System. Detector architecture adapted from
3
+ // impeccable (Paul Bakaus, Apache-2.0). See packages/seven-skill/NOTICE.md.
4
+
5
+ import {
6
+ SEVEN_INTERNAL_PACKAGES,
7
+ TOKEN_DEFINITION_PATH_SEGMENTS,
8
+ } from '../shared/constants.mjs'
9
+
10
+ /**
11
+ * Seven anti-pattern catalog. The registry shape (id, category, name,
12
+ * description, skill cross-reference) mirrors the upstream detector, but the
13
+ * rules are Seven's own — tuned to the Seven Design System. `category` is
14
+ * 'quality' (real design / a11y / drift) or 'slop' (an AI tell, Seven-flavored).
15
+ *
16
+ * Three tiers of rule:
17
+ * - Text rules: regex-on-source. Need no computed-style cascade.
18
+ * - Element rules: per-element computed-style checks (static-html + browser).
19
+ * - Page rules: whole-document checks (static-html + browser).
20
+ */
21
+ const ANTIPATTERNS = [
22
+ // ── Seven text rules (Phase 1) ───────────────────────────────────────────
23
+ {
24
+ id: 'non-inter-font',
25
+ category: 'quality',
26
+ name: 'Non-Inter font family',
27
+ description:
28
+ 'Font family is not Inter, JetBrains Mono, or a system fallback. Seven is Inter-only for sans and JetBrains Mono for code. Substitute Inter at the same size and surface the swap.',
29
+ skillReference: 'reference/typography.md',
30
+ },
31
+ {
32
+ id: 'hardcoded-color-not-token',
33
+ category: 'quality',
34
+ name: 'Hardcoded color literal',
35
+ description:
36
+ 'Hex, rgb, hsl, or OKLCH color literal where an @etus/tokens CSS variable exists. Runtime code consumes var(--token); raw color literals belong only in packages/tokens/sources. When the literal matches a token value, the finding names the exact token.',
37
+ skillReference: 'reference/color-and-contrast.md',
38
+ },
39
+ {
40
+ id: 'raw-tailwind-palette-color',
41
+ category: 'quality',
42
+ name: 'Raw Tailwind palette color',
43
+ description:
44
+ 'A Tailwind default-palette color utility (bg-green-500, text-neutral-900, border-red-200, ring-blue-500). The palette paints a color that bypasses @etus/tokens exactly as a hex literal does. Use a Seven color token via bg-[color:var(--*)] or the equivalent utility.',
45
+ skillReference: 'reference/color-and-contrast.md',
46
+ },
47
+ {
48
+ id: 'missing-color-type-hint',
49
+ category: 'quality',
50
+ name: 'Missing Tailwind v4 color type hint',
51
+ description:
52
+ 'A Tailwind v4 arbitrary value referencing a color token is missing the color: hint (e.g. bg-[var(--primary)] instead of bg-[color:var(--primary)]). Lightning CSS drops the unhinted value silently.',
53
+ skillReference: 'reference/color-and-contrast.md',
54
+ },
55
+ {
56
+ id: 'missing-length-type-hint',
57
+ category: 'quality',
58
+ name: 'Missing Tailwind v4 length type hint',
59
+ description:
60
+ 'A Tailwind v4 text-[var(--*)] arbitrary value resolving to a length is missing the length: hint. Lightning CSS drops the unhinted value silently.',
61
+ skillReference: 'reference/color-and-contrast.md',
62
+ },
63
+ {
64
+ id: 'leading-none-with-length-var',
65
+ category: 'quality',
66
+ name: 'leading-none with arbitrary length var',
67
+ description:
68
+ 'leading-none combined with text-[length:var(--*)] zeroes line-height in Tailwind v4. Use leading-tight, leading-snug, a literal leading-[1.1], or inline style.',
69
+ skillReference: 'reference/typography.md',
70
+ },
71
+ {
72
+ id: 'fuchsia-in-ui-chrome',
73
+ category: 'slop',
74
+ name: 'Fuchsia on UI chrome',
75
+ description:
76
+ 'Fuchsia is reserved for charts in Seven. On UI chrome (buttons, inputs, borders) it is an anti-pattern. Allowed inside <svg> and on elements tagged data-role="chart".',
77
+ skillReference: 'reference/color-and-contrast.md',
78
+ },
79
+ {
80
+ id: 'pt-pt-vocab',
81
+ category: 'quality',
82
+ name: 'European Portuguese vocabulary',
83
+ description:
84
+ 'European Portuguese vocabulary in pt-BR chrome. Seven product copy is Brazilian Portuguese; use the pt-BR equivalent.',
85
+ skillReference: 'reference/ux-writing.md',
86
+ },
87
+ {
88
+ id: 'pure-black-or-white',
89
+ category: 'quality',
90
+ name: 'Pure black or white',
91
+ description:
92
+ 'Pure #000 or #fff. Seven uses tinted neutrals from the OKLCH ramp; pure black and white read as harsh and unrefined.',
93
+ skillReference: 'reference/color-and-contrast.md',
94
+ },
95
+
96
+ // ── Inverted / retuned for Seven ─────────────────────────────────────────
97
+ {
98
+ id: 'side-stripe-border',
99
+ category: 'slop',
100
+ name: 'Side-stripe accent border',
101
+ description:
102
+ 'A border-left or border-right thicker than 1px used as a colored accent stripe — the most recognizable tell of AI-generated UIs and banned in Seven. Use a full 1px var(--border), a background tint, a leading icon, or restructure the element.',
103
+ skillReference: 'reference/spatial-design.md',
104
+ },
105
+ {
106
+ id: 'gradient-text-decorative',
107
+ category: 'slop',
108
+ name: 'Decorative gradient text',
109
+ description:
110
+ 'Decorative gradient text (background-clip: text + gradient). Seven text is solid color from the OKLCH ramp; gradient fills on headings and metrics are banned.',
111
+ skillReference: 'reference/color-and-contrast.md',
112
+ },
113
+ {
114
+ id: 'shadows-not-borders',
115
+ category: 'slop',
116
+ name: 'Glow shadow instead of border',
117
+ description:
118
+ 'A colored, blurred box-shadow on a dark surface (the AI "dark glow" look) — or a shadow standing in for card chrome. Seven cards carry hierarchy with 1px borders; shadows are reserved for overlays.',
119
+ skillReference: 'reference/spatial-design.md',
120
+ },
121
+
122
+ // ── Universal design-quality / a11y rules ────────────────────────────────
123
+ {
124
+ id: 'low-contrast',
125
+ category: 'quality',
126
+ name: 'Low contrast text',
127
+ description:
128
+ 'Text does not meet WCAG 2.1 AA contrast (4.5:1 for body, 3:1 for large text). Increase the contrast between text and background using Seven token pairs.',
129
+ skillReference: 'reference/color-and-contrast.md',
130
+ },
131
+ {
132
+ id: 'gray-on-color',
133
+ category: 'quality',
134
+ name: 'Gray text on colored background',
135
+ description:
136
+ 'Gray text looks washed out on a colored background. Use a darker shade of the background hue, or a near-white foreground token, for legible contrast.',
137
+ skillReference: 'reference/color-and-contrast.md',
138
+ },
139
+ {
140
+ id: 'nested-cards',
141
+ category: 'slop',
142
+ name: 'Nested cards',
143
+ description:
144
+ 'A card inside a card. Seven flattens hierarchy — use a section divider, a labeled list, or a Tabs view instead of nesting bordered containers.',
145
+ skillReference: 'reference/spatial-design.md',
146
+ },
147
+ {
148
+ id: 'monotonous-spacing',
149
+ category: 'quality',
150
+ name: 'Monotonous spacing',
151
+ description:
152
+ 'The same spacing value used everywhere — no rhythm. Seven rhythm is intentional: tighter inside a card, looser between cards, looser still between sections.',
153
+ skillReference: 'reference/spatial-design.md',
154
+ },
155
+ {
156
+ id: 'flat-type-hierarchy',
157
+ category: 'quality',
158
+ name: 'Flat type hierarchy',
159
+ description:
160
+ 'Font sizes too close together — no clear hierarchy. Walk the Seven Text/Heading ramp; pair scale with weight contrast.',
161
+ skillReference: 'reference/typography.md',
162
+ },
163
+ {
164
+ id: 'layout-transition',
165
+ category: 'quality',
166
+ name: 'Layout property animation',
167
+ description:
168
+ 'Animating width, height, padding, or margin triggers reflow and looks janky. Seven motion animates transform and opacity only.',
169
+ skillReference: 'reference/motion-design.md',
170
+ },
171
+ {
172
+ id: 'line-length',
173
+ category: 'quality',
174
+ name: 'Line length too long',
175
+ description:
176
+ 'Text lines wider than ~80 characters are hard to read. Add a max-width (65ch to 75ch) to body text containers.',
177
+ skillReference: 'reference/spatial-design.md',
178
+ },
179
+ {
180
+ id: 'cramped-padding',
181
+ category: 'quality',
182
+ name: 'Cramped padding',
183
+ description:
184
+ 'Text sits too close to the edge of its container. Add padding scaled to the font size inside bordered or tinted containers.',
185
+ skillReference: 'reference/spatial-design.md',
186
+ },
187
+ {
188
+ id: 'body-text-viewport-edge',
189
+ category: 'quality',
190
+ name: 'Body text touching viewport edge',
191
+ description:
192
+ 'Body paragraphs bleed flush against the viewport edge with no container padding. Wrap content in a padded container or apply max-width with mx-auto.',
193
+ skillReference: 'reference/spatial-design.md',
194
+ },
195
+ {
196
+ id: 'tight-leading',
197
+ category: 'quality',
198
+ name: 'Tight line height',
199
+ description:
200
+ 'Line height below 1.3x the font size makes multi-line text hard to read. Seven body text uses 1.5 leading.',
201
+ skillReference: 'reference/typography.md',
202
+ },
203
+ {
204
+ id: 'skipped-heading',
205
+ category: 'quality',
206
+ name: 'Skipped heading level',
207
+ description:
208
+ 'Heading levels skip (e.g. h1 then h3 with no h2). Screen readers use heading hierarchy for navigation; skipping levels breaks the document outline.',
209
+ skillReference: 'reference/typography.md',
210
+ },
211
+ {
212
+ id: 'justified-text',
213
+ category: 'quality',
214
+ name: 'Justified text',
215
+ description:
216
+ 'Justified text without hyphenation creates rivers of uneven word spacing. Use text-align: left for body copy.',
217
+ skillReference: 'reference/typography.md',
218
+ },
219
+ {
220
+ id: 'tiny-text',
221
+ category: 'quality',
222
+ name: 'Tiny body text',
223
+ description:
224
+ 'Body text below 12px is hard to read. Seven body content uses --text-base (16px); labels use --text-xs (12px) at minimum.',
225
+ skillReference: 'reference/typography.md',
226
+ },
227
+ {
228
+ id: 'all-caps-body',
229
+ category: 'quality',
230
+ name: 'All-caps body text',
231
+ description:
232
+ 'Long passages in uppercase are hard to read. Seven reserves uppercase for short monospaced data-table column headers; body copy is sentence case.',
233
+ skillReference: 'reference/typography.md',
234
+ },
235
+ {
236
+ id: 'wide-tracking',
237
+ category: 'quality',
238
+ name: 'Wide letter spacing on body text',
239
+ description:
240
+ 'Letter spacing above 0.05em on body text slows reading. Seven labels use Inter at default tracking; wide tracking is a Display-only choice.',
241
+ skillReference: 'reference/typography.md',
242
+ },
243
+ {
244
+ id: 'bounce-easing',
245
+ category: 'slop',
246
+ name: 'Bounce or elastic easing',
247
+ description:
248
+ 'Bounce, elastic, or spring easing. Seven motion is decelerated and stops — no overshoot in chrome. Use --ease-default or --ease-out-quart.',
249
+ skillReference: 'reference/motion-design.md',
250
+ },
251
+ {
252
+ id: 'everything-centered',
253
+ category: 'slop',
254
+ name: 'Everything centered',
255
+ description:
256
+ 'Every text element is center-aligned. Seven chrome is left-aligned with intentional asymmetry; center only deliberate display surfaces.',
257
+ skillReference: 'reference/spatial-design.md',
258
+ },
259
+
260
+ // ── Seven element-level rules ────────────────────────────────────────────
261
+ {
262
+ id: 'missing-tactile-press',
263
+ category: 'quality',
264
+ name: 'Clickable without tactile press',
265
+ description:
266
+ 'A clickable affordance (button, role="button", interactive list item) with no transform-based micro-press transition. Seven gives every affordance the system-wide tactile press (hover translateY -1px, active 0.5px).',
267
+ skillReference: 'reference/motion-design.md',
268
+ },
269
+ {
270
+ id: 'pill-on-button',
271
+ category: 'slop',
272
+ name: 'Pill radius on a button',
273
+ description:
274
+ 'A button with border-radius 9999 / rounded-full. Buttons stay on the component radius scale (md 8). Pill radius is for badges, status indicators, and avatar triggers only.',
275
+ skillReference: 'reference/spatial-design.md',
276
+ },
277
+ {
278
+ id: 'radius-mixed-scales',
279
+ category: 'quality',
280
+ name: 'Mixed radius scales',
281
+ description:
282
+ 'A component-scale radius (4 / 8 / 12) mixed with a surface-scale radius (16 / 20 / 24 / 32) on the same element. The two radius stacks are not interchangeable; mixing them produces "almost looks right" drift.',
283
+ skillReference: 'reference/spatial-design.md',
284
+ },
285
+ {
286
+ id: 'non-lucide-icon',
287
+ category: 'slop',
288
+ name: 'Non-Lucide icon',
289
+ description:
290
+ 'An inline <svg> that is not a Lucide icon (Lucide uses 24x24 viewBox, 2px stroke, round line caps and joins, no fill). Seven uses the Lucide set exclusively.',
291
+ skillReference: 'reference/typography.md',
292
+ },
293
+ {
294
+ id: 'emoji-in-chrome',
295
+ category: 'slop',
296
+ name: 'Emoji in UI chrome',
297
+ description:
298
+ 'An emoji character inside button, label, or heading text. Seven chrome uses Lucide icons, not emoji glyphs, which render inconsistently across platforms.',
299
+ skillReference: 'reference/ux-writing.md',
300
+ },
301
+ {
302
+ id: 'forbidden-em-dash',
303
+ category: 'quality',
304
+ name: 'Em-dash or en-dash in copy',
305
+ description:
306
+ 'An em-dash or en-dash in user-facing copy. Seven copy uses a comma, a colon, or a sentence break; the long dash reads as machine-generated prose.',
307
+ skillReference: 'reference/ux-writing.md',
308
+ },
309
+ ]
310
+
311
+ /**
312
+ * Which engines can detect each rule.
313
+ * - regex: source text scan. Catches text rules and the regex-matchable
314
+ * CSS/Tailwind shapes of element/page rules.
315
+ * - static-html: parsed-document computed-style cascade.
316
+ * - browser: rendered-page text + stylesheet extraction.
317
+ */
318
+ const TEXT_RULE_IDS = new Set([
319
+ 'non-inter-font',
320
+ 'hardcoded-color-not-token',
321
+ 'raw-tailwind-palette-color',
322
+ 'missing-color-type-hint',
323
+ 'missing-length-type-hint',
324
+ 'leading-none-with-length-var',
325
+ 'fuchsia-in-ui-chrome',
326
+ 'pt-pt-vocab',
327
+ 'pure-black-or-white',
328
+ 'emoji-in-chrome',
329
+ 'forbidden-em-dash',
330
+ ])
331
+
332
+ /** Element/page rules the static-html cascade can resolve. */
333
+ const STATIC_HTML_RULE_IDS = new Set([
334
+ 'side-stripe-border',
335
+ 'gradient-text-decorative',
336
+ 'shadows-not-borders',
337
+ 'low-contrast',
338
+ 'gray-on-color',
339
+ 'nested-cards',
340
+ 'monotonous-spacing',
341
+ 'flat-type-hierarchy',
342
+ 'layout-transition',
343
+ 'line-length',
344
+ 'cramped-padding',
345
+ 'body-text-viewport-edge',
346
+ 'tight-leading',
347
+ 'skipped-heading',
348
+ 'justified-text',
349
+ 'tiny-text',
350
+ 'all-caps-body',
351
+ 'wide-tracking',
352
+ 'bounce-easing',
353
+ 'everything-centered',
354
+ 'missing-tactile-press',
355
+ 'pill-on-button',
356
+ 'radius-mixed-scales',
357
+ 'non-lucide-icon',
358
+ ])
359
+
360
+ const RULE_ENGINE_SUPPORT = {
361
+ // Every rule is reachable by the regex engine on raw source.
362
+ regex: new Set(ANTIPATTERNS.map((r) => r.id)),
363
+ // Text rules + element/page rules the static cascade can resolve.
364
+ 'static-html': new Set([...TEXT_RULE_IDS, ...STATIC_HTML_RULE_IDS]),
365
+ // The browser engine extracts rendered markup + stylesheet text and runs
366
+ // the same text + static-html checks against the rendered document.
367
+ browser: new Set([...TEXT_RULE_IDS, ...STATIC_HTML_RULE_IDS]),
368
+ }
369
+
370
+ /**
371
+ * Per-rule exemptions. Resolved against the file being scanned:
372
+ * - skipPackages: skip files whose owning package.json name is in the set.
373
+ * - skipPathSegments: skip files whose path contains any segment.
374
+ * Test fixtures bypass exemptions entirely (see detect facade).
375
+ */
376
+ const NO_EXEMPTION = { skipPackages: new Set(), skipPathSegments: [] }
377
+ const DOC_PATH_SEGMENTS = ['docs/audits/', 'docs/proposals/', 'docs/plans/']
378
+
379
+ const RULE_EXEMPTIONS = {
380
+ // Text rules.
381
+ 'non-inter-font': { skipPackages: SEVEN_INTERNAL_PACKAGES, skipPathSegments: [] },
382
+ 'hardcoded-color-not-token': { skipPackages: SEVEN_INTERNAL_PACKAGES, skipPathSegments: TOKEN_DEFINITION_PATH_SEGMENTS },
383
+ 'raw-tailwind-palette-color': { skipPackages: SEVEN_INTERNAL_PACKAGES, skipPathSegments: TOKEN_DEFINITION_PATH_SEGMENTS },
384
+ 'missing-color-type-hint': NO_EXEMPTION,
385
+ 'missing-length-type-hint': NO_EXEMPTION,
386
+ 'leading-none-with-length-var': NO_EXEMPTION,
387
+ 'fuchsia-in-ui-chrome': { skipPackages: SEVEN_INTERNAL_PACKAGES, skipPathSegments: [] },
388
+ 'pt-pt-vocab': { skipPackages: SEVEN_INTERNAL_PACKAGES, skipPathSegments: [] },
389
+ 'pure-black-or-white': { skipPackages: SEVEN_INTERNAL_PACKAGES, skipPathSegments: TOKEN_DEFINITION_PATH_SEGMENTS },
390
+ // Inverted / retuned.
391
+ 'side-stripe-border': NO_EXEMPTION,
392
+ 'gradient-text-decorative': NO_EXEMPTION,
393
+ 'shadows-not-borders': NO_EXEMPTION,
394
+ // Universal design-quality / a11y.
395
+ 'low-contrast': NO_EXEMPTION,
396
+ 'gray-on-color': NO_EXEMPTION,
397
+ 'nested-cards': NO_EXEMPTION,
398
+ 'monotonous-spacing': NO_EXEMPTION,
399
+ 'flat-type-hierarchy': NO_EXEMPTION,
400
+ 'layout-transition': NO_EXEMPTION,
401
+ 'line-length': NO_EXEMPTION,
402
+ 'cramped-padding': NO_EXEMPTION,
403
+ 'body-text-viewport-edge': NO_EXEMPTION,
404
+ 'tight-leading': NO_EXEMPTION,
405
+ 'skipped-heading': NO_EXEMPTION,
406
+ 'justified-text': NO_EXEMPTION,
407
+ 'tiny-text': NO_EXEMPTION,
408
+ 'all-caps-body': NO_EXEMPTION,
409
+ 'wide-tracking': NO_EXEMPTION,
410
+ 'bounce-easing': NO_EXEMPTION,
411
+ 'everything-centered': NO_EXEMPTION,
412
+ // Seven element-level rules.
413
+ 'missing-tactile-press': { skipPackages: SEVEN_INTERNAL_PACKAGES, skipPathSegments: [] },
414
+ 'pill-on-button': { skipPackages: SEVEN_INTERNAL_PACKAGES, skipPathSegments: [] },
415
+ 'radius-mixed-scales': { skipPackages: SEVEN_INTERNAL_PACKAGES, skipPathSegments: [] },
416
+ 'non-lucide-icon': { skipPackages: SEVEN_INTERNAL_PACKAGES, skipPathSegments: [] },
417
+ // Emoji and the long dash legitimately appear in audit/proposal prose.
418
+ 'emoji-in-chrome': { skipPackages: SEVEN_INTERNAL_PACKAGES, skipPathSegments: DOC_PATH_SEGMENTS },
419
+ 'forbidden-em-dash': { skipPackages: new Set(), skipPathSegments: [...TOKEN_DEFINITION_PATH_SEGMENTS, ...DOC_PATH_SEGMENTS] },
420
+ }
421
+
422
+ /**
423
+ * Grounding audit. Every rule is either 'token' — its data comes from the
424
+ * @etus/tokens snapshot, so it tracks the design system automatically — or
425
+ * 'heuristic' — it enforces a language fact, a CSS/tooling mechanic, an a11y
426
+ * standard, or a structural pattern that no token defines. The audit is a
427
+ * durable artifact: a rule's verdict is inspectable, and 'heuristic' is a
428
+ * deliberate verdict, not an unfinished one.
429
+ */
430
+ const RULE_GROUNDING = {
431
+ // Token-grounded — data sourced from @etus/tokens.
432
+ 'non-inter-font': { grounding: 'token', reason: 'Allowed families read from the fontFamily tokens.' },
433
+ 'hardcoded-color-not-token': { grounding: 'token', reason: 'Literal colors resolved to the exact token via the color snapshot.' },
434
+ 'raw-tailwind-palette-color': { grounding: 'token', reason: 'Exists to enforce the @etus/tokens boundary against the Tailwind palette.' },
435
+ 'pure-black-or-white': { grounding: 'token', reason: 'Anchored on the base-black / base-white token values, which it names.' },
436
+ 'tiny-text': { grounding: 'token', reason: 'Floor is the smallest font-size token.' },
437
+ 'radius-mixed-scales': { grounding: 'token', reason: 'Component and surface radius tiers derived from the radius tokens.' },
438
+ 'pill-on-button': { grounding: 'token', reason: 'Pill threshold anchored on the full-radius token.' },
439
+ 'bounce-easing': { grounding: 'token', reason: 'Exempts cubic-beziers that match a sanctioned easing token.' },
440
+ // Heuristic — no token defines the rule.
441
+ 'missing-color-type-hint': { grounding: 'heuristic', reason: 'Tailwind v4 / Lightning CSS mechanic.' },
442
+ 'missing-length-type-hint': { grounding: 'heuristic', reason: 'Tailwind v4 / Lightning CSS mechanic.' },
443
+ 'leading-none-with-length-var': { grounding: 'heuristic', reason: 'Tailwind v4 line-height interaction bug.' },
444
+ 'fuchsia-in-ui-chrome': { grounding: 'heuristic', reason: 'Hue-band detection plus svg/chart context — no token lookup.' },
445
+ 'pt-pt-vocab': { grounding: 'heuristic', reason: 'Language vocabulary — a linguistic fact, not a token.' },
446
+ 'side-stripe-border': { grounding: 'heuristic', reason: 'Structural pattern: thick one-sided colored border.' },
447
+ 'gradient-text-decorative': { grounding: 'heuristic', reason: 'CSS shape: background-clip:text with a gradient.' },
448
+ 'shadows-not-borders': { grounding: 'heuristic', reason: 'Detects a colored blurred shadow standing in for chrome.' },
449
+ 'low-contrast': { grounding: 'heuristic', reason: 'WCAG 2.1 AA ratio — an accessibility standard.' },
450
+ 'gray-on-color': { grounding: 'heuristic', reason: 'Gray-on-colored-background legibility heuristic.' },
451
+ 'nested-cards': { grounding: 'heuristic', reason: 'Structural: a bordered container inside another.' },
452
+ 'monotonous-spacing': { grounding: 'heuristic', reason: 'Spacing-variety judgment — no token threshold.' },
453
+ 'flat-type-hierarchy': { grounding: 'heuristic', reason: 'Type-scale spread judgment — no token threshold.' },
454
+ 'layout-transition': { grounding: 'heuristic', reason: 'CSS fact: animating layout properties causes reflow.' },
455
+ 'line-length': { grounding: 'heuristic', reason: 'Readability heuristic (~80 characters).' },
456
+ 'cramped-padding': { grounding: 'heuristic', reason: 'Text-to-edge proximity heuristic.' },
457
+ 'body-text-viewport-edge': { grounding: 'heuristic', reason: 'Layout heuristic: body text flush to the viewport.' },
458
+ 'tight-leading': { grounding: 'heuristic', reason: 'Readability ratio (1.3x) — no token threshold.' },
459
+ 'skipped-heading': { grounding: 'heuristic', reason: 'Document-structure / a11y outline rule.' },
460
+ 'justified-text': { grounding: 'heuristic', reason: 'CSS fact: justify without hyphenation.' },
461
+ 'all-caps-body': { grounding: 'heuristic', reason: 'Readability heuristic for long uppercase runs.' },
462
+ 'wide-tracking': { grounding: 'heuristic', reason: 'Body-context letter-spacing threshold (0.05em).' },
463
+ 'everything-centered': { grounding: 'heuristic', reason: 'Layout-distribution heuristic.' },
464
+ 'missing-tactile-press': { grounding: 'heuristic', reason: 'Detects presence of a transform-press transition.' },
465
+ 'non-lucide-icon': { grounding: 'heuristic', reason: 'SVG attribute shape (24x24 viewBox, 2px stroke).' },
466
+ 'emoji-in-chrome': { grounding: 'heuristic', reason: 'Unicode emoji ranges in chrome text.' },
467
+ 'forbidden-em-dash': { grounding: 'heuristic', reason: 'Character rule — em-dash / en-dash in copy.' },
468
+ }
469
+
470
+ export function getRuleGrounding(id) {
471
+ return RULE_GROUNDING[id] || { grounding: 'heuristic', reason: 'Unclassified.' }
472
+ }
473
+
474
+ export function getAntipattern(id) {
475
+ return ANTIPATTERNS.find((rule) => rule.id === id)
476
+ }
477
+
478
+ export function getRulesForCategory(category) {
479
+ return ANTIPATTERNS.filter((rule) => rule.category === category)
480
+ }
481
+
482
+ export function getRuleEngineSupport(engine) {
483
+ return RULE_ENGINE_SUPPORT[engine] || new Set()
484
+ }
485
+
486
+ export function getRuleExemption(id) {
487
+ return RULE_EXEMPTIONS[id] || { skipPackages: new Set(), skipPathSegments: [] }
488
+ }
489
+
490
+ export function getRuleIds() {
491
+ return ANTIPATTERNS.map((r) => r.id)
492
+ }
493
+
494
+ export { ANTIPATTERNS, RULE_ENGINE_SUPPORT, RULE_EXEMPTIONS, RULE_GROUNDING }