@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,3014 @@
1
+ /**
2
+ * Seven Design System — in-browser anti-pattern detector.
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ *
5
+ * GENERATED — do not edit. Rebuild: node build/build-browser-detector.mjs
6
+ * Source modules: cli/engine/shared/constants.mjs, cli/engine/registry/seven-antipatterns.mjs, cli/engine/shared/color.mjs, cli/engine/token-data.generated.mjs, cli/engine/shared/tokens.mjs, cli/engine/rules/checks.mjs, cli/engine/browser/injected/index.mjs
7
+ *
8
+ * Detector architecture adapted from impeccable (Paul Bakaus, Apache-2.0).
9
+ */
10
+ (function () {
11
+ if (typeof window === 'undefined') return
12
+ // ── cli/engine/shared/constants.mjs ──
13
+ // SPDX-License-Identifier: Apache-2.0
14
+ // ETUS Digital — Seven Design System. Detector architecture adapted from
15
+ // impeccable (Paul Bakaus, Apache-2.0). See packages/seven-skill/NOTICE.md.
16
+
17
+ // ─── Seven-specific catalogs ────────────────────────────────────────────────
18
+
19
+ /**
20
+ * The only sans face allowed in Seven. Note this INVERTS impeccable's
21
+ * `OVERUSED_FONTS` rule: impeccable flags Inter as overused, Seven mandates it.
22
+ */
23
+ const INTER_FAMILY_NAMES = new Set([
24
+ 'inter',
25
+ 'inter-variable',
26
+ 'intervariable',
27
+ 'inter var',
28
+ ])
29
+
30
+ /** The only mono face allowed in Seven. */
31
+ const JETBRAINS_MONO_FAMILY_NAMES = new Set([
32
+ 'jetbrains mono',
33
+ 'jetbrains-mono',
34
+ 'jetbrainsmono',
35
+ ])
36
+
37
+ /** Generic / system fallbacks that never count as a non-Inter face. */
38
+ const SYSTEM_FONT_FALLBACK_TOKENS = new Set([
39
+ 'system-ui',
40
+ '-apple-system',
41
+ 'blinkmacsystemfont',
42
+ 'segoe ui',
43
+ 'roboto',
44
+ 'helvetica',
45
+ 'helvetica neue',
46
+ 'arial',
47
+ 'sans-serif',
48
+ 'serif',
49
+ 'monospace',
50
+ 'ui-monospace',
51
+ 'ui-sans-serif',
52
+ 'ui-serif',
53
+ 'ui-rounded',
54
+ 'menlo',
55
+ 'consolas',
56
+ 'courier new',
57
+ 'inherit',
58
+ 'initial',
59
+ 'unset',
60
+ 'revert',
61
+ ])
62
+
63
+ /** HTML tags whose own styling never trips element-level rules. */
64
+ const SAFE_TAGS = new Set([
65
+ 'html', 'head', 'body', 'script', 'style', 'link', 'meta', 'title', 'br',
66
+ 'svg', 'path', 'circle', 'rect', 'line', 'polyline', 'polygon', 'g', 'defs', 'use',
67
+ ])
68
+
69
+ /** European Portuguese vocabulary that must not appear in pt-BR chrome. */
70
+ const PT_PT_VOCAB_PAIRS = [
71
+ { ptPt: 'ficheiro', ptBr: 'arquivo' },
72
+ { ptPt: 'ficheiros', ptBr: 'arquivos' },
73
+ { ptPt: 'telemóvel', ptBr: 'celular' },
74
+ { ptPt: 'telemóveis', ptBr: 'celulares' },
75
+ { ptPt: 'ecrã', ptBr: 'tela' },
76
+ { ptPt: 'ecrãs', ptBr: 'telas' },
77
+ { ptPt: 'rato', ptBr: 'mouse' },
78
+ { ptPt: 'autocarro', ptBr: 'ônibus' },
79
+ { ptPt: 'gestor', ptBr: 'gerente' },
80
+ { ptPt: 'utilizador', ptBr: 'usuário' },
81
+ { ptPt: 'utilizadores', ptBr: 'usuários' },
82
+ { ptPt: 'sítio', ptBr: 'site' },
83
+ { ptPt: 'apagar', ptBr: 'excluir' },
84
+ ]
85
+
86
+ /**
87
+ * Package names whose source is exempt from rules that would mis-fire on
88
+ * token-defining or skill-defining code. Resolved by walking up to the
89
+ * nearest package.json — works inside the monorepo and inside a consumer's
90
+ * node_modules alike.
91
+ */
92
+ const SEVEN_INTERNAL_PACKAGES = new Set(['@etus/ui', '@etus/tokens', '@etus/seven-skill'])
93
+
94
+ /** Path segments whose files define tokens and may carry raw color literals. */
95
+ const TOKEN_DEFINITION_PATH_SEGMENTS = [
96
+ 'packages/tokens/sources/',
97
+ 'packages/tokens/sd/',
98
+ 'packages/tokens/dist/',
99
+ 'docs/tokens-json/',
100
+ ]
101
+
102
+ /** Test fixtures are always scanned regardless of the package they sit in. */
103
+ const FIXTURES_PATH_SEGMENT = '/tests/fixtures/'
104
+
105
+ // ── cli/engine/registry/seven-antipatterns.mjs ──
106
+ const ANTIPATTERNS = [
107
+ // ── Seven text rules (Phase 1) ───────────────────────────────────────────
108
+ {
109
+ id: 'non-inter-font',
110
+ category: 'quality',
111
+ name: 'Non-Inter font family',
112
+ description:
113
+ '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.',
114
+ skillReference: 'reference/typography.md',
115
+ },
116
+ {
117
+ id: 'hardcoded-color-not-token',
118
+ category: 'quality',
119
+ name: 'Hardcoded color literal',
120
+ description:
121
+ '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.',
122
+ skillReference: 'reference/color-and-contrast.md',
123
+ },
124
+ {
125
+ id: 'raw-tailwind-palette-color',
126
+ category: 'quality',
127
+ name: 'Raw Tailwind palette color',
128
+ description:
129
+ '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.',
130
+ skillReference: 'reference/color-and-contrast.md',
131
+ },
132
+ {
133
+ id: 'missing-color-type-hint',
134
+ category: 'quality',
135
+ name: 'Missing Tailwind v4 color type hint',
136
+ description:
137
+ '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.',
138
+ skillReference: 'reference/color-and-contrast.md',
139
+ },
140
+ {
141
+ id: 'missing-length-type-hint',
142
+ category: 'quality',
143
+ name: 'Missing Tailwind v4 length type hint',
144
+ description:
145
+ 'A Tailwind v4 text-[var(--*)] arbitrary value resolving to a length is missing the length: hint. Lightning CSS drops the unhinted value silently.',
146
+ skillReference: 'reference/color-and-contrast.md',
147
+ },
148
+ {
149
+ id: 'leading-none-with-length-var',
150
+ category: 'quality',
151
+ name: 'leading-none with arbitrary length var',
152
+ description:
153
+ '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.',
154
+ skillReference: 'reference/typography.md',
155
+ },
156
+ {
157
+ id: 'fuchsia-in-ui-chrome',
158
+ category: 'slop',
159
+ name: 'Fuchsia on UI chrome',
160
+ description:
161
+ '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".',
162
+ skillReference: 'reference/color-and-contrast.md',
163
+ },
164
+ {
165
+ id: 'pt-pt-vocab',
166
+ category: 'quality',
167
+ name: 'European Portuguese vocabulary',
168
+ description:
169
+ 'European Portuguese vocabulary in pt-BR chrome. Seven product copy is Brazilian Portuguese; use the pt-BR equivalent.',
170
+ skillReference: 'reference/ux-writing.md',
171
+ },
172
+ {
173
+ id: 'pure-black-or-white',
174
+ category: 'quality',
175
+ name: 'Pure black or white',
176
+ description:
177
+ 'Pure #000 or #fff. Seven uses tinted neutrals from the OKLCH ramp; pure black and white read as harsh and unrefined.',
178
+ skillReference: 'reference/color-and-contrast.md',
179
+ },
180
+
181
+ // ── Inverted / retuned for Seven ─────────────────────────────────────────
182
+ {
183
+ id: 'side-stripe-border',
184
+ category: 'slop',
185
+ name: 'Side-stripe accent border',
186
+ description:
187
+ '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.',
188
+ skillReference: 'reference/spatial-design.md',
189
+ },
190
+ {
191
+ id: 'gradient-text-decorative',
192
+ category: 'slop',
193
+ name: 'Decorative gradient text',
194
+ description:
195
+ 'Decorative gradient text (background-clip: text + gradient). Seven text is solid color from the OKLCH ramp; gradient fills on headings and metrics are banned.',
196
+ skillReference: 'reference/color-and-contrast.md',
197
+ },
198
+ {
199
+ id: 'shadows-not-borders',
200
+ category: 'slop',
201
+ name: 'Glow shadow instead of border',
202
+ description:
203
+ '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.',
204
+ skillReference: 'reference/spatial-design.md',
205
+ },
206
+
207
+ // ── Universal design-quality / a11y rules ────────────────────────────────
208
+ {
209
+ id: 'low-contrast',
210
+ category: 'quality',
211
+ name: 'Low contrast text',
212
+ description:
213
+ '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.',
214
+ skillReference: 'reference/color-and-contrast.md',
215
+ },
216
+ {
217
+ id: 'gray-on-color',
218
+ category: 'quality',
219
+ name: 'Gray text on colored background',
220
+ description:
221
+ '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.',
222
+ skillReference: 'reference/color-and-contrast.md',
223
+ },
224
+ {
225
+ id: 'nested-cards',
226
+ category: 'slop',
227
+ name: 'Nested cards',
228
+ description:
229
+ 'A card inside a card. Seven flattens hierarchy — use a section divider, a labeled list, or a Tabs view instead of nesting bordered containers.',
230
+ skillReference: 'reference/spatial-design.md',
231
+ },
232
+ {
233
+ id: 'monotonous-spacing',
234
+ category: 'quality',
235
+ name: 'Monotonous spacing',
236
+ description:
237
+ 'The same spacing value used everywhere — no rhythm. Seven rhythm is intentional: tighter inside a card, looser between cards, looser still between sections.',
238
+ skillReference: 'reference/spatial-design.md',
239
+ },
240
+ {
241
+ id: 'flat-type-hierarchy',
242
+ category: 'quality',
243
+ name: 'Flat type hierarchy',
244
+ description:
245
+ 'Font sizes too close together — no clear hierarchy. Walk the Seven Text/Heading ramp; pair scale with weight contrast.',
246
+ skillReference: 'reference/typography.md',
247
+ },
248
+ {
249
+ id: 'layout-transition',
250
+ category: 'quality',
251
+ name: 'Layout property animation',
252
+ description:
253
+ 'Animating width, height, padding, or margin triggers reflow and looks janky. Seven motion animates transform and opacity only.',
254
+ skillReference: 'reference/motion-design.md',
255
+ },
256
+ {
257
+ id: 'line-length',
258
+ category: 'quality',
259
+ name: 'Line length too long',
260
+ description:
261
+ 'Text lines wider than ~80 characters are hard to read. Add a max-width (65ch to 75ch) to body text containers.',
262
+ skillReference: 'reference/spatial-design.md',
263
+ },
264
+ {
265
+ id: 'cramped-padding',
266
+ category: 'quality',
267
+ name: 'Cramped padding',
268
+ description:
269
+ 'Text sits too close to the edge of its container. Add padding scaled to the font size inside bordered or tinted containers.',
270
+ skillReference: 'reference/spatial-design.md',
271
+ },
272
+ {
273
+ id: 'body-text-viewport-edge',
274
+ category: 'quality',
275
+ name: 'Body text touching viewport edge',
276
+ description:
277
+ '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.',
278
+ skillReference: 'reference/spatial-design.md',
279
+ },
280
+ {
281
+ id: 'tight-leading',
282
+ category: 'quality',
283
+ name: 'Tight line height',
284
+ description:
285
+ 'Line height below 1.3x the font size makes multi-line text hard to read. Seven body text uses 1.5 leading.',
286
+ skillReference: 'reference/typography.md',
287
+ },
288
+ {
289
+ id: 'skipped-heading',
290
+ category: 'quality',
291
+ name: 'Skipped heading level',
292
+ description:
293
+ 'Heading levels skip (e.g. h1 then h3 with no h2). Screen readers use heading hierarchy for navigation; skipping levels breaks the document outline.',
294
+ skillReference: 'reference/typography.md',
295
+ },
296
+ {
297
+ id: 'justified-text',
298
+ category: 'quality',
299
+ name: 'Justified text',
300
+ description:
301
+ 'Justified text without hyphenation creates rivers of uneven word spacing. Use text-align: left for body copy.',
302
+ skillReference: 'reference/typography.md',
303
+ },
304
+ {
305
+ id: 'tiny-text',
306
+ category: 'quality',
307
+ name: 'Tiny body text',
308
+ description:
309
+ 'Body text below 12px is hard to read. Seven body content uses --text-base (16px); labels use --text-xs (12px) at minimum.',
310
+ skillReference: 'reference/typography.md',
311
+ },
312
+ {
313
+ id: 'all-caps-body',
314
+ category: 'quality',
315
+ name: 'All-caps body text',
316
+ description:
317
+ 'Long passages in uppercase are hard to read. Seven reserves uppercase for short monospaced data-table column headers; body copy is sentence case.',
318
+ skillReference: 'reference/typography.md',
319
+ },
320
+ {
321
+ id: 'wide-tracking',
322
+ category: 'quality',
323
+ name: 'Wide letter spacing on body text',
324
+ description:
325
+ 'Letter spacing above 0.05em on body text slows reading. Seven labels use Inter at default tracking; wide tracking is a Display-only choice.',
326
+ skillReference: 'reference/typography.md',
327
+ },
328
+ {
329
+ id: 'bounce-easing',
330
+ category: 'slop',
331
+ name: 'Bounce or elastic easing',
332
+ description:
333
+ 'Bounce, elastic, or spring easing. Seven motion is decelerated and stops — no overshoot in chrome. Use --ease-default or --ease-out-quart.',
334
+ skillReference: 'reference/motion-design.md',
335
+ },
336
+ {
337
+ id: 'everything-centered',
338
+ category: 'slop',
339
+ name: 'Everything centered',
340
+ description:
341
+ 'Every text element is center-aligned. Seven chrome is left-aligned with intentional asymmetry; center only deliberate display surfaces.',
342
+ skillReference: 'reference/spatial-design.md',
343
+ },
344
+
345
+ // ── Seven element-level rules ────────────────────────────────────────────
346
+ {
347
+ id: 'missing-tactile-press',
348
+ category: 'quality',
349
+ name: 'Clickable without tactile press',
350
+ description:
351
+ '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).',
352
+ skillReference: 'reference/motion-design.md',
353
+ },
354
+ {
355
+ id: 'pill-on-button',
356
+ category: 'slop',
357
+ name: 'Pill radius on a button',
358
+ description:
359
+ '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.',
360
+ skillReference: 'reference/spatial-design.md',
361
+ },
362
+ {
363
+ id: 'radius-mixed-scales',
364
+ category: 'quality',
365
+ name: 'Mixed radius scales',
366
+ description:
367
+ '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.',
368
+ skillReference: 'reference/spatial-design.md',
369
+ },
370
+ {
371
+ id: 'non-lucide-icon',
372
+ category: 'slop',
373
+ name: 'Non-Lucide icon',
374
+ description:
375
+ '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.',
376
+ skillReference: 'reference/typography.md',
377
+ },
378
+ {
379
+ id: 'emoji-in-chrome',
380
+ category: 'slop',
381
+ name: 'Emoji in UI chrome',
382
+ description:
383
+ 'An emoji character inside button, label, or heading text. Seven chrome uses Lucide icons, not emoji glyphs, which render inconsistently across platforms.',
384
+ skillReference: 'reference/ux-writing.md',
385
+ },
386
+ {
387
+ id: 'forbidden-em-dash',
388
+ category: 'quality',
389
+ name: 'Em-dash or en-dash in copy',
390
+ description:
391
+ '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.',
392
+ skillReference: 'reference/ux-writing.md',
393
+ },
394
+ ]
395
+
396
+ // ── cli/engine/shared/color.mjs ──
397
+ // SPDX-License-Identifier: Apache-2.0
398
+ // ETUS Digital — Seven Design System. Detector architecture adapted from
399
+ // impeccable (Paul Bakaus, Apache-2.0). See packages/seven-skill/NOTICE.md.
400
+
401
+ // ─── Color utilities ────────────────────────────────────────────────────────
402
+
403
+ function isNeutralColor(color) {
404
+ if (!color || color === 'transparent') return true
405
+
406
+ // rgb/rgba — channel spread. Threshold 30 is ~11.7% of the 0-255 range.
407
+ const rgb = color.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)/)
408
+ if (rgb) {
409
+ return Math.max(+rgb[1], +rgb[2], +rgb[3]) - Math.min(+rgb[1], +rgb[2], +rgb[3]) < 30
410
+ }
411
+
412
+ // oklch()/lch() — chroma is the second numeric component. Seven's tokens are
413
+ // OKLCH throughout, so this path matters: chroma >= 0.02 reads as tinted.
414
+ const oklch = color.match(/oklch\(\s*[\d.]+%?\s*([\d.-]+)/i)
415
+ if (oklch) return parseFloat(oklch[1]) < 0.02
416
+ const lch = color.match(/lch\(\s*[\d.]+%?\s*([\d.-]+)/i)
417
+ if (lch) return parseFloat(lch[1]) < 3
418
+
419
+ // oklab()/lab() — a and b are signed axes; chroma = hypot(a, b).
420
+ const oklab = color.match(/oklab\(\s*[\d.]+%?\s*([\d.-]+)\s+([\d.-]+)/i)
421
+ if (oklab) return Math.hypot(parseFloat(oklab[1]), parseFloat(oklab[2])) < 0.02
422
+ const lab = color.match(/lab\(\s*[\d.]+%?\s*([\d.-]+)\s+([\d.-]+)/i)
423
+ if (lab) return Math.hypot(parseFloat(lab[1]), parseFloat(lab[2])) < 3
424
+
425
+ // hsl/hsla — saturation is the second numeric component (percent).
426
+ const hsl = color.match(/hsla?\(\s*[\d.-]+\s*,?\s*([\d.]+)%/i)
427
+ if (hsl) return parseFloat(hsl[1]) < 10
428
+
429
+ // hwb(hue whiteness% blackness%) — gray when whiteness + blackness >= 100.
430
+ const hwb = color.match(/hwb\(\s*[\d.-]+\s+([\d.]+)%\s+([\d.]+)%/i)
431
+ if (hwb) {
432
+ const w = parseFloat(hwb[1])
433
+ const b = parseFloat(hwb[2])
434
+ return 1 - Math.min(100, w + b) / 100 < 0.1
435
+ }
436
+
437
+ // Unknown format — err on the side of DETECTING rather than skipping.
438
+ return false
439
+ }
440
+
441
+ function parseRgb(color) {
442
+ if (!color || color === 'transparent') return null
443
+ const m = color.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)(?:,\s*([\d.]+))?\)/)
444
+ if (!m) return null
445
+ return { r: +m[1], g: +m[2], b: +m[3], a: m[4] === undefined ? 1 : +m[4] }
446
+ }
447
+
448
+ function parseHex(hex) {
449
+ if (!hex) return null
450
+ const m = hex.match(/^#([0-9a-f]{3,8})$/i)
451
+ if (!m) return null
452
+ const h = m[1]
453
+ if (h.length === 3) {
454
+ return { r: parseInt(h[0] + h[0], 16), g: parseInt(h[1] + h[1], 16), b: parseInt(h[2] + h[2], 16), a: 1 }
455
+ }
456
+ if (h.length === 6 || h.length === 8) {
457
+ return {
458
+ r: parseInt(h.slice(0, 2), 16),
459
+ g: parseInt(h.slice(2, 4), 16),
460
+ b: parseInt(h.slice(4, 6), 16),
461
+ a: h.length === 8 ? parseInt(h.slice(6, 8), 16) / 255 : 1,
462
+ }
463
+ }
464
+ return null
465
+ }
466
+
467
+ function relativeLuminance({ r, g, b }) {
468
+ const [rs, gs, bs] = [r / 255, g / 255, b / 255].map((c) =>
469
+ c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4
470
+ )
471
+ return 0.2126 * rs + 0.7152 * gs + 0.0722 * bs
472
+ }
473
+
474
+ function contrastRatio(c1, c2) {
475
+ const l1 = relativeLuminance(c1)
476
+ const l2 = relativeLuminance(c2)
477
+ return (Math.max(l1, l2) + 0.05) / (Math.min(l1, l2) + 0.05)
478
+ }
479
+
480
+ function hasChroma(c, threshold = 30) {
481
+ if (!c) return false
482
+ return Math.max(c.r, c.g, c.b) - Math.min(c.r, c.g, c.b) >= threshold
483
+ }
484
+
485
+ function getHue(c) {
486
+ if (!c) return 0
487
+ const r = c.r / 255
488
+ const g = c.g / 255
489
+ const b = c.b / 255
490
+ const max = Math.max(r, g, b)
491
+ const min = Math.min(r, g, b)
492
+ if (max === min) return 0
493
+ const d = max - min
494
+ let h
495
+ if (max === r) h = ((g - b) / d + (g < b ? 6 : 0)) / 6
496
+ else if (max === g) h = ((b - r) / d + 2) / 6
497
+ else h = ((r - g) / d + 4) / 6
498
+ return Math.round(h * 360)
499
+ }
500
+
501
+ function colorToHex(c) {
502
+ if (!c) return '?'
503
+ return '#' + [c.r, c.g, c.b].map((v) => v.toString(16).padStart(2, '0')).join('')
504
+ }
505
+
506
+ /**
507
+ * Fuchsia / magenta detection. Seven reserves fuchsia for charts; on UI chrome
508
+ * it is an anti-pattern. A color is "fuchsia" when it has real chroma and its
509
+ * hue sits in the magenta band (roughly 280-340 degrees).
510
+ */
511
+ function isFuchsia(c) {
512
+ if (!c || !hasChroma(c, 40)) return false
513
+ const hue = getHue(c)
514
+ return hue >= 280 && hue <= 340
515
+ }
516
+
517
+ /**
518
+ * OKLCH -> sRGB. Seven's color tokens are OKLCH; matching them to hex/rgb
519
+ * literals in code needs this conversion. Follows the CSS Color 4 / Björn
520
+ * Ottosson reference. L is 0-1, C is chroma, H is degrees.
521
+ */
522
+ function oklchToRgb(L, C, H) {
523
+ const hRad = (H * Math.PI) / 180
524
+ const a = C * Math.cos(hRad)
525
+ const b = C * Math.sin(hRad)
526
+ const l_ = L + 0.3963377774 * a + 0.2158037573 * b
527
+ const m_ = L - 0.1055613458 * a - 0.0638541728 * b
528
+ const s_ = L - 0.0894841775 * a - 1.291485548 * b
529
+ const l = l_ ** 3
530
+ const m = m_ ** 3
531
+ const s = s_ ** 3
532
+ const lr = 4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s
533
+ const lg = -1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s
534
+ const lb = -0.0041960863 * l - 0.7034186147 * m + 1.707614701 * s
535
+ const gamma = (x) => (x <= 0.0031308 ? 12.92 * x : 1.055 * Math.pow(x, 1 / 2.4) - 0.055)
536
+ return [gamma(lr), gamma(lg), gamma(lb)].map((v) => Math.round(Math.max(0, Math.min(1, v)) * 255))
537
+ }
538
+
539
+ /** Canonical form for an exact-string token match: trimmed, lowercased,
540
+ * whitespace-collapsed. Shared by the token-data build step and the runtime
541
+ * accessor. */
542
+ function normalizeRaw(value) {
543
+ return String(value).trim().toLowerCase().replace(/\s+/g, ' ')
544
+ }
545
+
546
+ /**
547
+ * Reduce any color literal (oklch, hex, rgb/rgba) to a canonical `r,g,b,a`
548
+ * key. Shared by the token-data build step and the runtime token accessor so
549
+ * a literal in consumer code matches a Seven token regardless of format.
550
+ * Returns null when the value is not a recognized color.
551
+ */
552
+ function colorToRgbaKey(value) {
553
+ if (!value || typeof value !== 'string') return null
554
+ const v = value.trim().toLowerCase()
555
+
556
+ const oklch = v.match(/oklch\(\s*([\d.]+)(%?)\s+([\d.]+)\s+([\d.]+)(?:\s*\/\s*([\d.]+)(%?))?\s*\)/)
557
+ if (oklch) {
558
+ const L = oklch[2] === '%' ? parseFloat(oklch[1]) / 100 : parseFloat(oklch[1])
559
+ const C = parseFloat(oklch[3])
560
+ const H = parseFloat(oklch[4])
561
+ let alpha = 1
562
+ if (oklch[5] !== undefined) {
563
+ alpha = oklch[6] === '%' ? parseFloat(oklch[5]) / 100 : parseFloat(oklch[5])
564
+ }
565
+ const [r, g, b] = oklchToRgb(L, C, H)
566
+ return `${r},${g},${b},${alpha.toFixed(2)}`
567
+ }
568
+
569
+ const hex = v.match(/^#([0-9a-f]{3,8})$/)
570
+ if (hex) {
571
+ const h = hex[1]
572
+ let r
573
+ let g
574
+ let b
575
+ let a = 1
576
+ if (h.length === 3 || h.length === 4) {
577
+ r = parseInt(h[0] + h[0], 16)
578
+ g = parseInt(h[1] + h[1], 16)
579
+ b = parseInt(h[2] + h[2], 16)
580
+ if (h.length === 4) a = parseInt(h[3] + h[3], 16) / 255
581
+ } else if (h.length === 6 || h.length === 8) {
582
+ r = parseInt(h.slice(0, 2), 16)
583
+ g = parseInt(h.slice(2, 4), 16)
584
+ b = parseInt(h.slice(4, 6), 16)
585
+ if (h.length === 8) a = parseInt(h.slice(6, 8), 16) / 255
586
+ } else {
587
+ return null
588
+ }
589
+ return `${r},${g},${b},${a.toFixed(2)}`
590
+ }
591
+
592
+ const rgb = v.match(/rgba?\(\s*(\d+)\s*[, ]\s*(\d+)\s*[, ]\s*(\d+)\s*(?:[,/]\s*([\d.]+)(%?))?\s*\)/)
593
+ if (rgb) {
594
+ let a = 1
595
+ if (rgb[4] !== undefined) a = rgb[5] === '%' ? parseFloat(rgb[4]) / 100 : parseFloat(rgb[4])
596
+ return `${+rgb[1]},${+rgb[2]},${+rgb[3]},${a.toFixed(2)}`
597
+ }
598
+
599
+ return null
600
+ }
601
+
602
+ // ── cli/engine/token-data.generated.mjs ──
603
+ // SPDX-License-Identifier: Apache-2.0
604
+ // ETUS Digital — Seven Design System.
605
+ //
606
+ // GENERATED — do not edit. Rebuild: node build/build-token-data.mjs
607
+ // Source: @etus/tokens (dist/tokens.meta.json). When @etus/tokens rebuilds
608
+ // from a Figma sync, regenerate this snapshot so the detector stays a mirror
609
+ // of the design system.
610
+
611
+ const TOKEN_DATA = {
612
+ "colorByRgba": {
613
+ "0,0,0,1.00": "--base-black",
614
+ "0,0,0,0.20": "--base-black-20",
615
+ "0,0,0,0.40": "--base-black-40",
616
+ "0,0,0,0.60": "--base-black-60",
617
+ "0,0,0,0.80": "--base-black-80",
618
+ "0,0,0,0.00": "--base-transparent",
619
+ "255,255,255,1.00": "--base-white",
620
+ "219,234,254,1.00": "--blue-100",
621
+ "191,219,254,1.00": "--blue-200",
622
+ "147,197,253,1.00": "--blue-300",
623
+ "96,165,250,1.00": "--blue-400",
624
+ "239,246,255,1.00": "--blue-50",
625
+ "59,130,246,1.00": "--blue-500",
626
+ "37,99,235,1.00": "--blue-600",
627
+ "29,78,216,1.00": "--blue-700",
628
+ "30,64,175,1.00": "--blue-800",
629
+ "30,58,138,1.00": "--blue-900",
630
+ "23,37,84,1.00": "--blue-950",
631
+ "6,110,62,1.00": "--brand-green-dark",
632
+ "178,250,153,1.00": "--brand-green-light",
633
+ "141,247,104,1.00": "--brand-green-primary",
634
+ "59,228,118,1.00": "--brand-green-secondary",
635
+ "218,253,206,1.00": "--brand-green-soft",
636
+ "21,21,20,1.00": "--brand-neutral-black",
637
+ "62,62,60,1.00": "--brand-neutral-dark",
638
+ "110,109,104,1.00": "--brand-neutral-gray-01",
639
+ "144,142,137,1.00": "--brand-neutral-gray-02",
640
+ "190,187,183,1.00": "--brand-neutral-gray-03",
641
+ "214,212,209,1.00": "--brand-neutral-gray-04",
642
+ "235,233,229,1.00": "--brand-neutral-gray-05",
643
+ "243,242,239,1.00": "--brand-neutral-gray-06",
644
+ "251,250,249,1.00": "--brand-neutral-off-white",
645
+ "209,250,235,1.00": "--brand-primary-100",
646
+ "163,245,214,1.00": "--brand-primary-200",
647
+ "117,240,194,1.00": "--brand-primary-300",
648
+ "71,235,173,1.00": "--brand-primary-400",
649
+ "232,253,245,1.00": "--brand-primary-50",
650
+ "25,230,153,1.00": "--brand-primary-500",
651
+ "20,184,122,1.00": "--brand-primary-600",
652
+ "15,138,92,1.00": "--brand-primary-700",
653
+ "10,92,61,1.00": "--brand-primary-800",
654
+ "5,46,31,1.00": "--brand-primary-900",
655
+ "2,23,15,1.00": "--brand-primary-950",
656
+ "238,254,234,1.00": "--brand-secondary-100",
657
+ "210,252,199,1.00": "--brand-secondary-200",
658
+ "189,251,171,1.00": "--brand-secondary-300",
659
+ "152,250,115,1.00": "--brand-secondary-400",
660
+ "247,254,245,1.00": "--brand-secondary-50",
661
+ "143,245,102,1.00": "--brand-secondary-500",
662
+ "110,190,77,1.00": "--brand-secondary-600",
663
+ "80,141,55,1.00": "--brand-secondary-700",
664
+ "50,92,34,1.00": "--brand-secondary-800",
665
+ "25,50,15,1.00": "--brand-secondary-900",
666
+ "12,30,6,1.00": "--brand-secondary-950",
667
+ "160,227,243,1.00": "--brand-secondary-blue-01",
668
+ "191,237,248,1.00": "--brand-secondary-blue-02",
669
+ "210,244,252,1.00": "--brand-secondary-blue-03",
670
+ "197,240,122,1.00": "--brand-secondary-green-01",
671
+ "215,244,164,1.00": "--brand-secondary-green-02",
672
+ "228,251,189,1.00": "--brand-secondary-green-03",
673
+ "245,179,216,1.00": "--brand-secondary-pink-01",
674
+ "250,209,232,1.00": "--brand-secondary-pink-02",
675
+ "253,222,239,1.00": "--brand-secondary-pink-03",
676
+ "240,238,122,1.00": "--brand-secondary-yellow-01",
677
+ "247,245,166,1.00": "--brand-secondary-yellow-02",
678
+ "253,251,190,1.00": "--brand-secondary-yellow-03",
679
+ "244,222,252,1.00": "--fuchsia-100",
680
+ "236,191,250,1.00": "--fuchsia-200",
681
+ "227,156,247,1.00": "--fuchsia-300",
682
+ "219,116,244,1.00": "--fuchsia-400",
683
+ "250,240,254,1.00": "--fuchsia-50",
684
+ "212,68,241,1.00": "--fuchsia-500",
685
+ "172,43,197,1.00": "--fuchsia-600",
686
+ "131,30,151,1.00": "--fuchsia-700",
687
+ "93,18,107,1.00": "--fuchsia-800",
688
+ "53,7,62,1.00": "--fuchsia-900",
689
+ "37,4,44,1.00": "--fuchsia-950",
690
+ "220,252,231,1.00": "--green-100",
691
+ "187,247,208,1.00": "--green-200",
692
+ "134,239,172,1.00": "--green-300",
693
+ "74,222,128,1.00": "--green-400",
694
+ "240,253,244,1.00": "--green-50",
695
+ "34,197,94,1.00": "--green-500",
696
+ "22,163,74,1.00": "--green-600",
697
+ "21,128,61,1.00": "--green-700",
698
+ "22,101,52,1.00": "--green-800",
699
+ "20,83,45,1.00": "--green-900",
700
+ "5,46,22,1.00": "--green-950",
701
+ "245,245,245,1.00": "--neutral-100",
702
+ "229,229,229,1.00": "--neutral-200",
703
+ "212,212,212,1.00": "--neutral-300",
704
+ "163,163,163,1.00": "--neutral-400",
705
+ "250,250,250,1.00": "--neutral-50",
706
+ "115,115,115,1.00": "--neutral-500",
707
+ "82,82,82,1.00": "--neutral-600",
708
+ "64,64,64,1.00": "--neutral-700",
709
+ "38,38,38,1.00": "--neutral-800",
710
+ "23,23,23,1.00": "--neutral-900",
711
+ "10,10,10,1.00": "--neutral-950",
712
+ "254,226,226,1.00": "--red-100",
713
+ "254,202,202,1.00": "--red-200",
714
+ "252,165,165,1.00": "--red-300",
715
+ "248,113,113,1.00": "--red-400",
716
+ "254,242,242,1.00": "--red-50",
717
+ "239,68,68,1.00": "--red-500",
718
+ "220,38,38,1.00": "--red-600",
719
+ "185,28,28,1.00": "--red-700",
720
+ "153,27,27,1.00": "--red-800",
721
+ "127,29,29,1.00": "--red-900",
722
+ "69,10,10,1.00": "--red-950",
723
+ "254,249,195,1.00": "--yellow-100",
724
+ "254,240,138,1.00": "--yellow-200",
725
+ "253,224,71,1.00": "--yellow-300",
726
+ "250,204,21,1.00": "--yellow-400",
727
+ "254,252,232,1.00": "--yellow-50",
728
+ "234,179,8,1.00": "--yellow-500",
729
+ "202,138,4,1.00": "--yellow-600",
730
+ "161,98,7,1.00": "--yellow-700",
731
+ "133,77,14,1.00": "--yellow-800",
732
+ "113,63,18,1.00": "--yellow-900",
733
+ "66,32,6,1.00": "--yellow-950",
734
+ "148,163,184,1.00": "--asset-palette-status-draft-color",
735
+ "100,116,139,1.00": "--asset-palette-type-archive-color",
736
+ "100,116,139,0.18": "--asset-palette-type-archive-tint",
737
+ "6,182,212,1.00": "--asset-palette-type-audio-color",
738
+ "6,182,212,0.16": "--asset-palette-type-audio-tint",
739
+ "236,72,153,1.00": "--asset-palette-type-code-css-color",
740
+ "236,72,153,0.14": "--asset-palette-type-code-css-tint",
741
+ "234,179,8,0.16": "--asset-palette-type-code-js-tint",
742
+ "167,139,250,1.00": "--asset-palette-type-code-json-color",
743
+ "167,139,250,0.16": "--asset-palette-type-code-json-tint",
744
+ "59,130,246,0.14": "--asset-palette-type-code-ts-tint",
745
+ "56,189,248,1.00": "--asset-palette-type-code-tsx-color",
746
+ "56,189,248,0.14": "--asset-palette-type-code-tsx-tint",
747
+ "167,139,250,0.18": "--asset-palette-type-design-figma-tint",
748
+ "234,179,8,0.18": "--asset-palette-type-design-illustrator-tint",
749
+ "59,130,246,0.16": "--asset-palette-type-design-raster-tint",
750
+ "220,38,38,0.14": "--asset-palette-type-doc-pdf-tint",
751
+ "37,99,235,0.16": "--asset-palette-type-doc-text-tint",
752
+ "249,115,22,1.00": "--asset-palette-type-image-color",
753
+ "249,115,22,0.16": "--asset-palette-type-image-tint",
754
+ "25,230,153,0.18": "--asset-palette-type-vector-tint",
755
+ "239,68,68,0.16": "--asset-palette-type-video-tint",
756
+ "0,0,0,0.08": "--avatar-border-contrast",
757
+ "217,119,6,1.00": "--border-semantic-warning",
758
+ "255,255,255,0.95": "--feed-item-background-highlight",
759
+ "255,255,255,0.90": "--lightbox-button-background",
760
+ "255,255,255,0.80": "--lightbox-button-background-hover",
761
+ "255,255,255,0.10": "--lightbox-overlay-background",
762
+ "255,215,168,1.00": "--priority-high-bg",
763
+ "144,53,0,1.00": "--priority-high-text",
764
+ "234,239,245,1.00": "--priority-low-bg",
765
+ "105,115,125,1.00": "--priority-low-text",
766
+ "253,232,154,1.00": "--priority-medium-bg",
767
+ "112,81,0,1.00": "--priority-medium-text",
768
+ "255,212,205,1.00": "--priority-urgent-bg",
769
+ "162,5,25,1.00": "--priority-urgent-text",
770
+ "0,0,0,0.30": "--sheet-overlay-bg-blur",
771
+ "255,255,255,0.50": "--sheet-overlay-bg-light",
772
+ "199,247,199,1.00": "--status-approved-bg",
773
+ "0,139,29,1.00": "--status-approved-dot",
774
+ "0,90,0,1.00": "--status-approved-text",
775
+ "149,160,171,1.00": "--status-cancelled-dot",
776
+ "134,144,155,1.00": "--status-cancelled-text",
777
+ "255,227,153,1.00": "--status-changes-requested-bg",
778
+ "209,146,0,1.00": "--status-changes-requested-dot",
779
+ "122,74,0,1.00": "--status-changes-requested-text",
780
+ "230,98,0,1.00": "--status-escalated-dot",
781
+ "119,129,140,1.00": "--status-expired-text",
782
+ "192,237,255,1.00": "--status-in-review-bg",
783
+ "0,130,230,1.00": "--status-in-review-dot",
784
+ "0,86,164,1.00": "--status-in-review-text",
785
+ "212,9,36,1.00": "--status-rejected-dot",
786
+ "194,154,0,1.00": "--task-status-in-review-dot",
787
+ "224,229,235,1.00": "--task-status-todo-bg",
788
+ "133,97,221,1.00": "--tip",
789
+ "244,241,255,1.00": "--tip-bg"
790
+ },
791
+ "colorByRaw": {
792
+ "oklch(0.00% 0.0000 0)": "--base-black",
793
+ "oklch(0.00% 0.0000 0 / 20.0%)": "--base-black-20",
794
+ "oklch(0.00% 0.0000 0 / 40.0%)": "--base-black-40",
795
+ "oklch(0.00% 0.0000 0 / 60.0%)": "--sheet-overlay-bg-default",
796
+ "oklch(0.00% 0.0000 0 / 80.0%)": "--overlay",
797
+ "oklch(0.00% 0.0000 0 / 0.0%)": "--base-transparent",
798
+ "oklch(100.00% 0.0000 0)": "--tooltip-content-foreground",
799
+ "oklch(93.19% 0.0316 255.59)": "--blue-100",
800
+ "oklch(88.23% 0.0571 254.13)": "--alert-variant-info-border",
801
+ "oklch(80.91% 0.0956 251.81)": "--status-info-subtle-foreground",
802
+ "oklch(71.37% 0.1434 254.62)": "--blue-400",
803
+ "oklch(97.05% 0.0142 254.60)": "--featured-icon-color-info-bg",
804
+ "oklch(62.31% 0.1880 259.81)": "--color-firefox",
805
+ "oklch(54.61% 0.2152 262.88)": "--status-indicator-dot-color-info",
806
+ "oklch(48.82% 0.2172 264.38)": "--feature-item-tone-info-fg",
807
+ "oklch(42.44% 0.1809 265.64)": "--status-info-border",
808
+ "oklch(37.91% 0.1378 265.52)": "--blue-900",
809
+ "oklch(28.23% 0.0874 267.94)": "--status-info-subtle",
810
+ "oklch(47.37% 0.1147 155.11)": "--brand-green-dark",
811
+ "oklch(91.50% 0.1458 137.94)": "--brand-green-light",
812
+ "oklch(88.23% 0.2057 138.41)": "--brand-green-primary",
813
+ "oklch(81.06% 0.2032 150.12)": "--brand-green-secondary",
814
+ "oklch(95.70% 0.0719 137.74)": "--brand-green-soft",
815
+ "oklch(19.53% 0.0020 106.58)": "--brand-neutral-black",
816
+ "oklch(36.32% 0.0034 106.57)": "--brand-neutral-dark",
817
+ "oklch(53.41% 0.0078 97.45)": "--brand-neutral-gray-01",
818
+ "oklch(64.69% 0.0077 88.67)": "--brand-neutral-gray-02",
819
+ "oklch(79.33% 0.0065 75.40)": "--brand-neutral-gray-03",
820
+ "oklch(87.07% 0.0047 78.30)": "--brand-neutral-gray-04",
821
+ "oklch(93.45% 0.0058 84.57)": "--brand-neutral-gray-05",
822
+ "oklch(96.11% 0.0041 91.45)": "--brand-neutral-gray-06",
823
+ "oklch(98.56% 0.0017 67.80)": "--brand-neutral-off-white",
824
+ "oklch(95.21% 0.0467 171.12)": "--toggle-variant-default-bg-on",
825
+ "oklch(90.88% 0.0912 168.55)": "--primary-border",
826
+ "oklch(87.18% 0.1296 166.48)": "--primary-subtle-foreground",
827
+ "oklch(84.11% 0.1609 163.31)": "--sidebar-ring",
828
+ "oklch(97.65% 0.0243 170.99)": "--surface-selected",
829
+ "oklch(81.75% 0.1821 159.79)": "--toggle-variant-outline-bg-on",
830
+ "oklch(69.20% 0.1533 159.90)": "--spinner-gradient-end",
831
+ "oklch(56.08% 0.1221 160.56)": "--feature-item-tone-subtle-fg",
832
+ "oklch(42.11% 0.0897 161.06)": "--primary-border",
833
+ "oklch(26.88% 0.0527 163.85)": "--select-item-focus-fg",
834
+ "oklch(18.34% 0.0338 166.67)": "--toggle-variant-outline-color-on",
835
+ "oklch(97.98% 0.0312 139.44)": "--secondary-foreground",
836
+ "oklch(94.80% 0.0824 139.21)": "--secondary-border",
837
+ "oklch(92.69% 0.1227 138.76)": "--secondary-subtle-foreground",
838
+ "oklch(89.58% 0.1950 137.90)": "--brand-secondary-background",
839
+ "oklch(98.93% 0.0139 138.71)": "--secondary-subtle",
840
+ "oklch(87.82% 0.2037 137.75)": "--tags-input-tag-background",
841
+ "oklch(72.57% 0.1684 137.62)": "--button-secondary-color-border-hover",
842
+ "oklch(58.24% 0.1354 137.66)": "--focus-ring-secondary-color",
843
+ "oklch(42.97% 0.0993 137.99)": "--secondary-hover",
844
+ "oklch(28.73% 0.0661 137.75)": "--secondary",
845
+ "oklch(21.23% 0.0502 138.22)": "--tags-input-tag-foreground",
846
+ "oklch(87.65% 0.0705 214.23)": "--brand-secondary-blue-01",
847
+ "oklch(91.74% 0.0496 213.90)": "--brand-secondary-blue-02",
848
+ "oklch(94.53% 0.0369 213.51)": "--brand-secondary-blue-03",
849
+ "oklch(89.98% 0.1534 125.85)": "--brand-secondary-green-01",
850
+ "oklch(92.74% 0.1074 124.65)": "--brand-secondary-green-02",
851
+ "oklch(95.61% 0.0839 124.49)": "--brand-secondary-green-03",
852
+ "oklch(83.87% 0.0897 344.52)": "--brand-secondary-pink-01",
853
+ "oklch(90.22% 0.0546 343.48)": "--brand-secondary-pink-02",
854
+ "oklch(93.10% 0.0406 343.74)": "--brand-secondary-pink-03",
855
+ "oklch(92.74% 0.1387 107.93)": "--brand-secondary-yellow-01",
856
+ "oklch(95.38% 0.0993 106.91)": "--brand-secondary-yellow-02",
857
+ "oklch(97.51% 0.0778 106.19)": "--brand-secondary-yellow-03",
858
+ "oklch(92.74% 0.0464 317.08)": "--fuchsia-100",
859
+ "oklch(86.29% 0.0932 318.07)": "--fuchsia-200",
860
+ "oklch(79.32% 0.1454 318.64)": "--fuchsia-300",
861
+ "oklch(72.33% 0.2034 319.49)": "--fuchsia-400",
862
+ "oklch(96.70% 0.0213 316.46)": "--fuchsia-50",
863
+ "oklch(65.95% 0.2608 320.32)": "--color-safari",
864
+ "oklch(55.67% 0.2346 320.38)": "--fuchsia-600",
865
+ "oklch(45.62% 0.1930 320.21)": "--fuchsia-700",
866
+ "oklch(35.68% 0.1512 320.61)": "--fuchsia-800",
867
+ "oklch(24.61% 0.1035 320.37)": "--fuchsia-900",
868
+ "oklch(19.86% 0.0826 320.14)": "--fuchsia-950",
869
+ "oklch(96.24% 0.0434 156.74)": "--green-100",
870
+ "oklch(92.50% 0.0806 155.99)": "--alert-variant-success-border",
871
+ "oklch(87.12% 0.1363 154.45)": "--success-subtle-foreground",
872
+ "oklch(80.03% 0.1821 151.71)": "--green-400",
873
+ "oklch(98.19% 0.0181 155.83)": "--featured-icon-color-success-bg",
874
+ "oklch(72.27% 0.1920 149.58)": "--success",
875
+ "oklch(62.71% 0.1699 149.21)": "--timeline-marker-color-success",
876
+ "oklch(52.73% 0.1371 150.07)": "--alert-variant-success-fg",
877
+ "oklch(44.79% 0.1083 151.33)": "--status-success-border",
878
+ "oklch(39.25% 0.0896 152.54)": "--green-900",
879
+ "oklch(26.64% 0.0628 152.93)": "--success-subtle",
880
+ "oklch(97.02% 0.0000 0)": "--topbar-root-background",
881
+ "oklch(92.19% 0.0000 0)": "--topbar-root-border-color",
882
+ "oklch(86.99% 0.0000 0)": "--text-disabled",
883
+ "oklch(71.55% 0.0000 0)": "--border-semantic-strong",
884
+ "oklch(98.51% 0.0000 0)": "--surface-sunken",
885
+ "oklch(55.55% 0.0000 0)": "--toolbar-button-foreground-muted",
886
+ "oklch(43.86% 0.0000 0)": "--status-disabled-foreground",
887
+ "oklch(37.15% 0.0000 0)": "--status-disabled-border",
888
+ "oklch(26.86% 0.0000 0)": "--status-disabled-disabled",
889
+ "oklch(20.46% 0.0000 0)": "--slider-tooltip-fg",
890
+ "oklch(14.48% 0.0000 0)": "--tooltip-content-background",
891
+ "oklch(93.56% 0.0309 17.72)": "--red-100",
892
+ "oklch(88.45% 0.0593 18.33)": "--alert-variant-destructive-border",
893
+ "oklch(80.77% 0.1035 19.57)": "--status-destructive-subtle-foreground",
894
+ "oklch(71.06% 0.1661 22.22)": "--status-destructive-hover",
895
+ "oklch(97.05% 0.0129 17.38)": "--featured-icon-color-destructive-bg",
896
+ "oklch(63.68% 0.2078 25.33)": "--timeline-marker-color-error",
897
+ "oklch(57.71% 0.2152 27.33)": "--asset-palette-type-doc-pdf-color",
898
+ "oklch(50.54% 0.1905 27.52)": "--alert-variant-destructive-fg",
899
+ "oklch(44.37% 0.1613 26.90)": "--status-destructive-border",
900
+ "oklch(39.58% 0.1331 25.72)": "--red-900",
901
+ "oklch(25.75% 0.0886 26.04)": "--status-destructive-subtle",
902
+ "oklch(97.29% 0.0693 103.19)": "--yellow-100",
903
+ "oklch(94.51% 0.1243 101.54)": "--alert-variant-warning-border",
904
+ "oklch(90.52% 0.1657 98.11)": "--warning-subtle-foreground",
905
+ "oklch(86.06% 0.1731 91.94)": "--timeline-marker-color-warning",
906
+ "oklch(98.73% 0.0262 102.21)": "--featured-icon-color-warning-bg",
907
+ "oklch(79.52% 0.1617 86.05)": "--asset-palette-type-design-illustrator-color",
908
+ "oklch(68.06% 0.1423 75.83)": "--text-warning",
909
+ "oklch(55.38% 0.1207 66.44)": "--feature-item-tone-warning-fg",
910
+ "oklch(47.62% 0.1034 61.91)": "--status-warning-border",
911
+ "oklch(42.10% 0.0897 57.71)": "--yellow-900",
912
+ "oklch(28.57% 0.0639 53.81)": "--badge-color-warning-fg",
913
+ "transparent": "--toggle-variant-outline-bg-default",
914
+ "oklch(71.07% 0.0351 256.79)": "--asset-palette-status-draft-color",
915
+ "oklch(55.44% 0.0407 257.42)": "--asset-palette-type-code-env-color",
916
+ "rgb(100 116 139 / 0.18)": "--asset-palette-type-code-env-tint",
917
+ "oklch(71.48% 0.1257 215.22)": "--asset-palette-type-audio-color",
918
+ "rgb(6 182 212 / 0.16)": "--asset-palette-type-audio-tint",
919
+ "oklch(65.59% 0.2118 354.31)": "--asset-palette-type-code-css-color",
920
+ "rgb(236 72 153 / 0.14)": "--asset-palette-type-code-css-tint",
921
+ "rgb(234 179 8 / 0.16)": "--asset-palette-type-code-js-tint",
922
+ "oklch(70.90% 0.1592 293.54)": "--asset-palette-type-design-figma-color",
923
+ "rgb(167 139 250 / 0.16)": "--asset-palette-type-code-json-tint",
924
+ "rgb(59 130 246 / 0.14)": "--asset-palette-type-code-ts-tint",
925
+ "oklch(75.35% 0.1390 232.66)": "--asset-palette-type-code-tsx-color",
926
+ "rgb(56 189 248 / 0.14)": "--asset-palette-type-code-tsx-tint",
927
+ "rgb(167 139 250 / 0.18)": "--asset-palette-type-design-figma-tint",
928
+ "rgb(234 179 8 / 0.18)": "--asset-palette-type-design-illustrator-tint",
929
+ "rgb(59 130 246 / 0.16)": "--asset-palette-type-design-raster-tint",
930
+ "rgb(220 38 38 / 0.14)": "--asset-palette-type-doc-pdf-tint",
931
+ "rgb(37 99 235 / 0.16)": "--asset-palette-type-doc-text-tint",
932
+ "oklch(70.49% 0.1867 47.60)": "--asset-palette-type-image-color",
933
+ "rgb(249 115 22 / 0.16)": "--asset-palette-type-image-tint",
934
+ "rgb(25 230 153 / 0.18)": "--asset-palette-type-vector-tint",
935
+ "rgb(239 68 68 / 0.16)": "--asset-palette-type-video-tint",
936
+ "rgba(0, 0, 0, 0.08)": "--avatar-border-contrast",
937
+ "oklch(66.58% 0.1574 58.32)": "--border-semantic-warning",
938
+ "oklch(100.00% 0.0000 0 / 94.9%)": "--feed-item-background-highlight",
939
+ "9999px": "--image-rounded-circle",
940
+ "0.375rem": "--image-rounded-rounded",
941
+ "oklch(100.00% 0.0000 0 / 90.2%)": "--lightbox-button-background",
942
+ "oklch(100.00% 0.0000 0 / 80.0%)": "--split-button-divider-color-contrast",
943
+ "oklch(100.00% 0.0000 0 / 10.2%)": "--lightbox-overlay-background",
944
+ "oklch(93% 0.10 55)": "--status-escalated-bg",
945
+ "oklch(45% 0.15 55)": "--status-escalated-text",
946
+ "oklch(95% 0.01 250)": "--task-status-backlog-bg",
947
+ "oklch(55% 0.02 250)": "--task-status-todo-text",
948
+ "oklch(93% 0.10 95)": "--task-status-in-review-bg",
949
+ "oklch(45% 0.15 95)": "--task-status-in-review-text",
950
+ "oklch(93% 0.08 25)": "--task-status-blocked-bg",
951
+ "oklch(45% 0.18 25)": "--task-status-blocked-text",
952
+ "oklch(0% 0 0 / 30%)": "--sheet-overlay-bg-blur",
953
+ "oklch(100% 0 0 / 50%)": "--sheet-overlay-bg-light",
954
+ "oklch(93% 0.08 145)": "--task-status-done-bg",
955
+ "oklch(55% 0.18 145)": "--task-status-done-dot",
956
+ "oklch(40% 0.15 145)": "--task-status-done-text",
957
+ "oklch(70% 0.02 250)": "--task-status-todo-dot",
958
+ "oklch(65% 0.02 250)": "--status-pending-dot",
959
+ "oklch(93% 0.10 85)": "--status-changes-requested-bg",
960
+ "oklch(70% 0.18 85)": "--status-changes-requested-dot",
961
+ "oklch(45% 0.15 85)": "--status-changes-requested-text",
962
+ "oklch(65% 0.20 55)": "--status-escalated-dot",
963
+ "oklch(60% 0.02 250)": "--task-status-backlog-text",
964
+ "oklch(93% 0.08 250)": "--task-status-in-progress-bg",
965
+ "oklch(60% 0.18 250)": "--task-status-in-progress-dot",
966
+ "oklch(45% 0.15 250)": "--task-status-in-progress-text",
967
+ "oklch(55% 0.22 25)": "--task-status-blocked-dot",
968
+ "oklch(70% 0.18 95)": "--task-status-in-review-dot",
969
+ "oklch(92% 0.01 250)": "--task-status-todo-bg",
970
+ "oklch(58.97% 0.1820 293.54)": "--tip",
971
+ "oklch(96.50% 0.0250 293.54)": "--tip-bg"
972
+ },
973
+ "fontFamilies": [
974
+ "inter"
975
+ ],
976
+ "radii": {
977
+ "all": [
978
+ 0,
979
+ 1,
980
+ 1.5,
981
+ 2,
982
+ 3,
983
+ 4,
984
+ 5,
985
+ 6,
986
+ 7,
987
+ 8,
988
+ 10,
989
+ 12,
990
+ 13,
991
+ 14,
992
+ 16,
993
+ 20,
994
+ 24,
995
+ 32,
996
+ 999,
997
+ 9999
998
+ ],
999
+ "component": [
1000
+ 1,
1001
+ 1.5,
1002
+ 2,
1003
+ 3,
1004
+ 4,
1005
+ 5,
1006
+ 6,
1007
+ 7,
1008
+ 8,
1009
+ 10,
1010
+ 12
1011
+ ],
1012
+ "surface": [
1013
+ 13,
1014
+ 14,
1015
+ 16,
1016
+ 20,
1017
+ 24,
1018
+ 32
1019
+ ],
1020
+ "pill": [
1021
+ 999,
1022
+ 9999
1023
+ ]
1024
+ },
1025
+ "fontSizesPx": [
1026
+ 10,
1027
+ 12,
1028
+ 13,
1029
+ 14,
1030
+ 16,
1031
+ 18,
1032
+ 20,
1033
+ 24,
1034
+ 30,
1035
+ 32,
1036
+ 36,
1037
+ 48,
1038
+ 60,
1039
+ 72,
1040
+ 96
1041
+ ],
1042
+ "lineHeights": [
1043
+ "1",
1044
+ "1.25",
1045
+ "1.375",
1046
+ "1.5",
1047
+ "1.55",
1048
+ "1.625",
1049
+ "104px",
1050
+ "14px",
1051
+ "16px",
1052
+ "2",
1053
+ "20px",
1054
+ "24px",
1055
+ "28px",
1056
+ "32px",
1057
+ "36px",
1058
+ "40px",
1059
+ "44px",
1060
+ "56px",
1061
+ "68px",
1062
+ "80px"
1063
+ ],
1064
+ "letterSpacings": [
1065
+ "-0.025em",
1066
+ "-0.05em",
1067
+ "0.025em",
1068
+ "0.05em",
1069
+ "0.1em",
1070
+ "0em"
1071
+ ],
1072
+ "durations": {
1073
+ "--duration-fast": "100ms",
1074
+ "--duration-instant": "0ms",
1075
+ "--duration-moderate": "300ms",
1076
+ "--duration-normal": "200ms",
1077
+ "--duration-slow": "300ms",
1078
+ "--duration-slower": "500ms",
1079
+ "--duration-slowest": "700ms",
1080
+ "--accordion-icon-transition-duration": "200ms",
1081
+ "--button-transition-duration": "150ms"
1082
+ },
1083
+ "easings": {
1084
+ "--ease-bounce": "cubic-bezier(0.68, -0.55, 0.265, 1.55)",
1085
+ "--ease-default": "cubic-bezier(0.4, 0, 0.2, 1)",
1086
+ "--ease-emphasized": "cubic-bezier(0.2, 0, 0, 1)",
1087
+ "--ease-in": "cubic-bezier(0.4, 0, 1, 1)",
1088
+ "--ease-in-out": "cubic-bezier(0.4, 0, 0.2, 1)",
1089
+ "--ease-linear": "cubic-bezier(0, 0, 1, 1)",
1090
+ "--ease-out": "cubic-bezier(0, 0, 0.2, 1)",
1091
+ "--ease-spring": "cubic-bezier(0.34, 1.56, 0.64, 1)"
1092
+ },
1093
+ "shadows": [
1094
+ "--button-shadow-default",
1095
+ "--button-shadow-sm",
1096
+ "--button-shadow-xs",
1097
+ "--card-root-shadow-lg",
1098
+ "--card-root-shadow-sm",
1099
+ "--card-root-shadow-xl",
1100
+ "--collection-batchbar-shadow",
1101
+ "--filetree-footer-dot-ring",
1102
+ "--focus-ring-destructive-shadow",
1103
+ "--focus-ring-ghost-shadow",
1104
+ "--focus-ring-outline-shadow",
1105
+ "--focus-ring-primary-shadow",
1106
+ "--focus-ring-secondary-shadow",
1107
+ "--shadow-lg",
1108
+ "--shadow-md",
1109
+ "--shadow-sm",
1110
+ "--shadow-xl",
1111
+ "--shadow-xs",
1112
+ "--slider-thumb-drop-shadow"
1113
+ ],
1114
+ "spacingPx": [
1115
+ 0,
1116
+ 1,
1117
+ 2,
1118
+ 4,
1119
+ 8,
1120
+ 12,
1121
+ 16,
1122
+ 20,
1123
+ 24,
1124
+ 32,
1125
+ 40,
1126
+ 48,
1127
+ 56,
1128
+ 64,
1129
+ 80,
1130
+ 96,
1131
+ 128,
1132
+ 160,
1133
+ 192,
1134
+ 224,
1135
+ 256
1136
+ ],
1137
+ "semanticColorVars": [
1138
+ "--accent",
1139
+ "--accent-foreground",
1140
+ "--background",
1141
+ "--border",
1142
+ "--brand-accent-background",
1143
+ "--brand-accent-foreground",
1144
+ "--brand-primary-active",
1145
+ "--brand-primary-background",
1146
+ "--brand-primary-border",
1147
+ "--brand-primary-foreground",
1148
+ "--brand-primary-hover",
1149
+ "--brand-primary-subtle",
1150
+ "--brand-primary-subtle-foreground",
1151
+ "--brand-secondary-active",
1152
+ "--brand-secondary-background",
1153
+ "--brand-secondary-border",
1154
+ "--brand-secondary-foreground",
1155
+ "--brand-secondary-hover",
1156
+ "--brand-secondary-subtle",
1157
+ "--brand-secondary-subtle-foreground",
1158
+ "--card",
1159
+ "--card-foreground",
1160
+ "--chart-1",
1161
+ "--chart-2",
1162
+ "--chart-3",
1163
+ "--chart-4",
1164
+ "--chart-5",
1165
+ "--core-background-background",
1166
+ "--core-background-card",
1167
+ "--core-background-card-foreground",
1168
+ "--core-background-foreground",
1169
+ "--core-background-inversed-foreground",
1170
+ "--core-background-muted",
1171
+ "--core-background-muted-foreground",
1172
+ "--core-background-overlay",
1173
+ "--core-background-popover",
1174
+ "--core-background-popover-foreground",
1175
+ "--core-background-surface",
1176
+ "--core-background-surface-foreground",
1177
+ "--core-border-border",
1178
+ "--core-border-input",
1179
+ "--core-border-ring",
1180
+ "--destructive",
1181
+ "--destructive-border",
1182
+ "--destructive-foreground",
1183
+ "--destructive-hover",
1184
+ "--destructive-subtle",
1185
+ "--destructive-subtle-foreground",
1186
+ "--disabled",
1187
+ "--disabled-background",
1188
+ "--disabled-border",
1189
+ "--disabled-foreground",
1190
+ "--extended-chart-1",
1191
+ "--extended-chart-2",
1192
+ "--extended-chart-3",
1193
+ "--extended-chart-4",
1194
+ "--extended-chart-5",
1195
+ "--foreground",
1196
+ "--info",
1197
+ "--info-border",
1198
+ "--info-foreground",
1199
+ "--info-subtle",
1200
+ "--info-subtle-foreground",
1201
+ "--input",
1202
+ "--inversed-foreground",
1203
+ "--kbd-variant-default-background",
1204
+ "--kbd-variant-default-color",
1205
+ "--kbd-variant-ghost-background",
1206
+ "--kbd-variant-ghost-color",
1207
+ "--kbd-variant-outline-background",
1208
+ "--kbd-variant-outline-border-color",
1209
+ "--kbd-variant-outline-color",
1210
+ "--label-optional-color",
1211
+ "--label-required-color",
1212
+ "--link-color-default",
1213
+ "--link-color-muted",
1214
+ "--link-color-nav",
1215
+ "--link-color-nav-hover",
1216
+ "--link-color-subtle",
1217
+ "--muted",
1218
+ "--muted-foreground",
1219
+ "--overlay",
1220
+ "--paragraph-color-default",
1221
+ "--paragraph-color-destructive",
1222
+ "--paragraph-color-muted",
1223
+ "--paragraph-color-primary",
1224
+ "--paragraph-color-success",
1225
+ "--popover",
1226
+ "--popover-bg",
1227
+ "--popover-border",
1228
+ "--popover-content-background",
1229
+ "--popover-content-foreground",
1230
+ "--popover-fg",
1231
+ "--popover-foreground",
1232
+ "--primary",
1233
+ "--primary-active",
1234
+ "--primary-border",
1235
+ "--primary-foreground",
1236
+ "--primary-hover",
1237
+ "--primary-subtle",
1238
+ "--primary-subtle-foreground",
1239
+ "--ring",
1240
+ "--ring-offset",
1241
+ "--secondary",
1242
+ "--secondary-border",
1243
+ "--secondary-foreground",
1244
+ "--secondary-hover",
1245
+ "--secondary-subtle",
1246
+ "--secondary-subtle-foreground",
1247
+ "--sidebar",
1248
+ "--sidebar-accent",
1249
+ "--sidebar-accent-foreground",
1250
+ "--sidebar-background",
1251
+ "--sidebar-border",
1252
+ "--sidebar-foreground",
1253
+ "--sidebar-ring",
1254
+ "--status-destructive-background",
1255
+ "--status-destructive-border",
1256
+ "--status-destructive-foreground",
1257
+ "--status-destructive-hover",
1258
+ "--status-destructive-subtle",
1259
+ "--status-destructive-subtle-foreground",
1260
+ "--status-disabled-border",
1261
+ "--status-disabled-disabled",
1262
+ "--status-disabled-foreground",
1263
+ "--status-info-background",
1264
+ "--status-info-border",
1265
+ "--status-info-foreground",
1266
+ "--status-info-subtle",
1267
+ "--status-info-subtle-foreground",
1268
+ "--status-success-background",
1269
+ "--status-success-border",
1270
+ "--status-success-foreground",
1271
+ "--status-success-subtle",
1272
+ "--status-success-subtle-foreground",
1273
+ "--status-warning-background",
1274
+ "--status-warning-border",
1275
+ "--status-warning-foreground",
1276
+ "--status-warning-subtle",
1277
+ "--status-warning-subtle-foreground",
1278
+ "--success",
1279
+ "--success-border",
1280
+ "--success-foreground",
1281
+ "--success-subtle",
1282
+ "--success-subtle-foreground",
1283
+ "--surface",
1284
+ "--surface-foreground",
1285
+ "--warning",
1286
+ "--warning-border",
1287
+ "--warning-foreground",
1288
+ "--warning-subtle",
1289
+ "--warning-subtle-foreground"
1290
+ ]
1291
+ }
1292
+
1293
+ Object.freeze(TOKEN_DATA)
1294
+
1295
+ // ── cli/engine/shared/tokens.mjs ──
1296
+ // SPDX-License-Identifier: Apache-2.0
1297
+ // ETUS Digital — Seven Design System. Detector architecture adapted from
1298
+ // impeccable (Paul Bakaus, Apache-2.0). See packages/seven-skill/NOTICE.md.
1299
+
1300
+ /*
1301
+ * Token accessor. The single API the rule layer uses to consult Seven's
1302
+ * design tokens. It wraps the build-time snapshot (token-data.generated.mjs,
1303
+ * derived from @etus/tokens) behind semantic accessors so the rules never see
1304
+ * the snapshot's raw shape — the snapshot can change without touching a rule.
1305
+ */
1306
+
1307
+
1308
+ /** True when the snapshot carries usable data. Rules fall back to heuristics
1309
+ * if this is ever false (defensive — not expected in a built package). */
1310
+ function tokenDataAvailable() {
1311
+ return Boolean(TOKEN_DATA && Object.keys(TOKEN_DATA.colorByRgba || {}).length > 0)
1312
+ }
1313
+
1314
+ // ─── Color ──────────────────────────────────────────────────────────────────
1315
+
1316
+ /**
1317
+ * Resolve a literal color (hex, rgb, oklch) to the Seven token cssVar that
1318
+ * declares it, or null when no token matches. Format-insensitive: a hex
1319
+ * literal resolves the same OKLCH token as the OKLCH form.
1320
+ */
1321
+ function colorToCssVar(rawValue) {
1322
+ if (!rawValue) return null
1323
+ const key = colorToRgbaKey(rawValue)
1324
+ if (key && TOKEN_DATA.colorByRgba[key]) return TOKEN_DATA.colorByRgba[key]
1325
+ const raw = normalizeRaw(rawValue)
1326
+ return TOKEN_DATA.colorByRaw[raw] || null
1327
+ }
1328
+
1329
+ /** True when the literal color is a declared Seven token value. */
1330
+ function isKnownTokenColor(rawValue) {
1331
+ return colorToCssVar(rawValue) !== null
1332
+ }
1333
+
1334
+ // ─── Typography ─────────────────────────────────────────────────────────────
1335
+
1336
+ /**
1337
+ * The sans/mono families Seven sanctions. Derived from the `fontFamily`
1338
+ * tokens, plus JetBrains Mono — Seven's documented code face, which the
1339
+ * current token catalog does not yet declare as a `fontFamily` token.
1340
+ */
1341
+ function allowedFontFamilies() {
1342
+ return [...new Set([...TOKEN_DATA.fontFamilies, 'jetbrains mono'])]
1343
+ }
1344
+
1345
+ function isAllowedFontFamily(name) {
1346
+ if (!name) return false
1347
+ return allowedFontFamilies().includes(String(name).trim().toLowerCase())
1348
+ }
1349
+
1350
+ function fontSizesPx() {
1351
+ return TOKEN_DATA.fontSizesPx
1352
+ }
1353
+
1354
+ /** The smallest declared font size — the floor `tiny-text` enforces. */
1355
+ function minFontSizePx() {
1356
+ return TOKEN_DATA.fontSizesPx.length > 0 ? TOKEN_DATA.fontSizesPx[0] : 12
1357
+ }
1358
+
1359
+ function lineHeights() {
1360
+ return TOKEN_DATA.lineHeights
1361
+ }
1362
+
1363
+ function letterSpacings() {
1364
+ return TOKEN_DATA.letterSpacings
1365
+ }
1366
+
1367
+ // ─── Shape ──────────────────────────────────────────────────────────────────
1368
+
1369
+ function radiusScale() {
1370
+ return TOKEN_DATA.radii
1371
+ }
1372
+
1373
+ function componentRadiiPx() {
1374
+ return TOKEN_DATA.radii.component
1375
+ }
1376
+
1377
+ function surfaceRadiiPx() {
1378
+ return TOKEN_DATA.radii.surface
1379
+ }
1380
+
1381
+ function pillRadiiPx() {
1382
+ return TOKEN_DATA.radii.pill
1383
+ }
1384
+
1385
+ function spacingScalePx() {
1386
+ return TOKEN_DATA.spacingPx
1387
+ }
1388
+
1389
+ // ─── Motion ─────────────────────────────────────────────────────────────────
1390
+
1391
+ /** Motion duration tokens as `{ cssVar: value }`. */
1392
+ function motionDurations() {
1393
+ return TOKEN_DATA.durations
1394
+ }
1395
+
1396
+ /** Easing tokens as `{ cssVar: cubic-bezier-value }`. Includes `--ease-bounce`
1397
+ * and `--ease-spring` — Seven's catalog does declare them, so a bounce easing
1398
+ * is drift only when it is NOT one of these sanctioned tokens. */
1399
+ function easingTokens() {
1400
+ return TOKEN_DATA.easings
1401
+ }
1402
+
1403
+ /** The set of sanctioned cubic-bezier values, normalized for comparison. */
1404
+ function sanctionedEasingValues() {
1405
+ return new Set(Object.values(TOKEN_DATA.easings).map((v) => normalizeRaw(v)))
1406
+ }
1407
+
1408
+ /** Sanctioned easing tokens parsed to [x1, y1, x2, y2] tuples. A cubic-bezier
1409
+ * in code that matches one of these is a Seven easing token value, not drift —
1410
+ * even when its curve overshoots (the catalog declares --ease-bounce). */
1411
+ function sanctionedEasingBeziers() {
1412
+ const out = []
1413
+ for (const value of Object.values(TOKEN_DATA.easings)) {
1414
+ const m = String(value).match(
1415
+ /cubic-bezier\(\s*([-\d.]+)\s*,\s*([-\d.]+)\s*,\s*([-\d.]+)\s*,\s*([-\d.]+)\s*\)/
1416
+ )
1417
+ if (m) out.push([+m[1], +m[2], +m[3], +m[4]])
1418
+ }
1419
+ return out
1420
+ }
1421
+
1422
+ // ─── Elevation ──────────────────────────────────────────────────────────────
1423
+
1424
+ function shadowTokens() {
1425
+ return TOKEN_DATA.shadows
1426
+ }
1427
+
1428
+ // ─── Semantic roles ─────────────────────────────────────────────────────────
1429
+
1430
+ /**
1431
+ * The Seven semantic-color token for a role (success, destructive, warning,
1432
+ * info, muted, accent, ...). Prefers a fill/background variant, then the bare
1433
+ * role token. Returns null when no semantic token carries that role — used to
1434
+ * point a Tailwind palette utility at its Seven equivalent.
1435
+ */
1436
+ function suggestSemanticToken(role) {
1437
+ const vars = TOKEN_DATA.semanticColorVars || []
1438
+ const matches = vars.filter((v) => v.includes(role))
1439
+ if (matches.length === 0) return null
1440
+ return (
1441
+ matches.find((v) => v.endsWith('-background')) ||
1442
+ matches.find((v) => v === `--${role}`) ||
1443
+ matches.find((v) => !v.includes('-foreground') && !v.includes('-border')) ||
1444
+ matches[0]
1445
+ )
1446
+ }
1447
+
1448
+ // ── cli/engine/rules/checks.mjs ──
1449
+ // SPDX-License-Identifier: Apache-2.0
1450
+ // ETUS Digital — Seven Design System. Detector architecture adapted from
1451
+ // impeccable (Paul Bakaus, Apache-2.0). See packages/seven-skill/NOTICE.md.
1452
+
1453
+
1454
+ // Detection runs in two worlds: Node (regex + static-html) and a real browser.
1455
+ // `window` only exists in the browser; the *-DOM adapters key off this flag.
1456
+ const DETECTOR_IS_BROWSER = typeof window !== 'undefined'
1457
+
1458
+ // ─── Section 1: Seven text checks (regex-on-source) ─────────────────────────
1459
+ // These eight checks scan raw file text and need no computed-style cascade.
1460
+ // Every engine runs them: the regex engine over source text, the static-html
1461
+ // and browser engines over the markup + stylesheet text they extract.
1462
+
1463
+ function lineOf(text, index) {
1464
+ let line = 1
1465
+ for (let i = 0; i < index && i < text.length; i++) {
1466
+ if (text.charCodeAt(i) === 10) line++
1467
+ }
1468
+ return line
1469
+ }
1470
+
1471
+ function snippetAt(text, index, padding = 40) {
1472
+ const start = Math.max(0, index - padding)
1473
+ const end = Math.min(text.length, index + padding + 20)
1474
+ return text.slice(start, end).replace(/\s+/g, ' ').trim()
1475
+ }
1476
+
1477
+ function parseFontFamilyValue(raw) {
1478
+ return raw
1479
+ .split(',')
1480
+ .map((s) => s.trim().toLowerCase().replace(/^['"]/, '').replace(/['"]$/, ''))
1481
+ }
1482
+
1483
+ /**
1484
+ * Classify a CSS variable name as 'color', 'length', or 'unknown' from Seven's
1485
+ * naming conventions. Length wins when both signals appear (e.g.
1486
+ * --button-border-width is a length even though `border` is a color signal).
1487
+ */
1488
+ function classifyVarName(name) {
1489
+ const hasColorSignal =
1490
+ /(?:^|-)(?:color|fg|bg|primary|secondary|accent|background|foreground|muted|destructive|warning|success|info|brand|surface|ring|border|stroke|fill|outline)(?:$|-)/i.test(name)
1491
+ const hasLengthSignal =
1492
+ /(?:^|-)(?:width|height|radius|padding|margin|gap|inset|offset|space|font-size|line-height|leading|tracking|letter-spacing|fs|size|weight|duration|delay)(?:$|-)/i.test(name)
1493
+ if (hasLengthSignal) return 'length'
1494
+ if (hasColorSignal) return 'color'
1495
+ if (/-text$/i.test(name)) return 'length'
1496
+ return 'unknown'
1497
+ }
1498
+
1499
+ /** A value that is exactly `var(--something)` with optional whitespace. */
1500
+ function isSingleVarReference(value) {
1501
+ return /^\s*var\(\s*--[a-zA-Z0-9-]+\s*\)\s*$/.test(value)
1502
+ }
1503
+
1504
+ function isWithinSvgOrChartTag(text, index) {
1505
+ const before = text.slice(Math.max(0, index - 800), index).toLowerCase()
1506
+ const lastSvgOpen = before.lastIndexOf('<svg')
1507
+ const lastSvgClose = before.lastIndexOf('</svg>')
1508
+ if (lastSvgOpen > lastSvgClose) return true
1509
+ return /data-role\s*=\s*['"]chart['"]/i.test(before)
1510
+ }
1511
+
1512
+ const FONT_FAMILY_RE = /font-family\s*:\s*([^;{}\n]+)/gi
1513
+
1514
+ function checkNonInterFont(text) {
1515
+ const findings = []
1516
+ let match
1517
+ while ((match = FONT_FAMILY_RE.exec(text)) !== null) {
1518
+ const parts = parseFontFamilyValue(match[1])
1519
+ const primary = parts[0] ?? ''
1520
+ if (!primary || primary.startsWith('var(') || primary.startsWith('inherit')) continue
1521
+ // Token-grounded: the allowed families come from the @etus/tokens
1522
+ // fontFamily tokens (plus JetBrains Mono and the system fallbacks).
1523
+ if (isAllowedFontFamily(primary) || SYSTEM_FONT_FALLBACK_TOKENS.has(primary)) {
1524
+ continue
1525
+ }
1526
+ findings.push({ id: 'non-inter-font', line: lineOf(text, match.index), snippet: snippetAt(text, match.index) })
1527
+ }
1528
+ return findings
1529
+ }
1530
+
1531
+ const HEX_COLOR_RE = /#(?:[0-9a-fA-F]{8}|[0-9a-fA-F]{6}|[0-9a-fA-F]{3,4})\b/g
1532
+ const RGB_COLOR_RE = /\brgba?\s*\(\s*\d+\s*,\s*\d+\s*,\s*\d+\s*[,/)]/g
1533
+ const HSL_COLOR_RE = /\bhsla?\s*\(\s*\d+(?:deg)?\s*,?\s*\d+%/g
1534
+ const OKLCH_LITERAL_RE = /\boklch\s*\(\s*[\d.]+/g
1535
+
1536
+ /**
1537
+ * Extract the full color value starting at `index` — the regexes above match a
1538
+ * prefix for rgb/hsl/oklch, so the closing paren has to be read back to get a
1539
+ * value the token lookup can resolve.
1540
+ */
1541
+ function fullColorAt(text, index) {
1542
+ const rest = text.slice(index, index + 80)
1543
+ if (rest[0] === '#') return (rest.match(/^#[0-9a-fA-F]{3,8}/) || [''])[0]
1544
+ const fn = rest.match(/^(?:rgba?|hsla?|oklch)\s*\([^)]*\)/i)
1545
+ return fn ? fn[0] : ''
1546
+ }
1547
+
1548
+ function checkHardcodedColorNotToken(text) {
1549
+ const findings = []
1550
+ const seen = new Set()
1551
+ const add = (match) => {
1552
+ const key = `${match.index}:${match[0]}`
1553
+ if (seen.has(key)) return
1554
+ seen.add(key)
1555
+ // Token-grounded: when the literal is a Seven token value, name the token
1556
+ // so the fix is precise instead of a generic "use a variable".
1557
+ const cssVar = colorToCssVar(fullColorAt(text, match.index))
1558
+ const base = snippetAt(text, match.index)
1559
+ findings.push({
1560
+ id: 'hardcoded-color-not-token',
1561
+ line: lineOf(text, match.index),
1562
+ snippet: cssVar ? `${base} (use var(${cssVar}))` : base,
1563
+ })
1564
+ }
1565
+ let m
1566
+ while ((m = HEX_COLOR_RE.exec(text)) !== null) add(m)
1567
+ while ((m = RGB_COLOR_RE.exec(text)) !== null) add(m)
1568
+ while ((m = HSL_COLOR_RE.exec(text)) !== null) add(m)
1569
+ while ((m = OKLCH_LITERAL_RE.exec(text)) !== null) add(m)
1570
+ return findings
1571
+ }
1572
+
1573
+ // Tailwind default palette colors. A `bg-green-500` / `text-neutral-900` /
1574
+ // `border-red-200` utility paints a color that bypasses @etus/tokens exactly
1575
+ // as a hex literal does — the token scale never sees it.
1576
+ const TW_PALETTE_HUES =
1577
+ 'slate|gray|zinc|neutral|stone|red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose'
1578
+ const TW_COLOR_UTILITIES =
1579
+ 'bg|text|border|ring|ring-offset|from|to|via|fill|stroke|outline|decoration|divide|placeholder|caret|accent|shadow'
1580
+ const RAW_TW_PALETTE_RE = new RegExp(
1581
+ `\\b(?:${TW_COLOR_UTILITIES})-(${TW_PALETTE_HUES})-\\d{2,3}\\b`,
1582
+ 'g'
1583
+ )
1584
+
1585
+ // Each Tailwind hue family maps onto a Seven semantic role. The actual token
1586
+ // name for that role is then resolved from the @etus/tokens snapshot, so the
1587
+ // finding points at the real Seven equivalent — not a generic "use a token".
1588
+ const TW_HUE_TO_ROLE = {
1589
+ red: 'destructive', rose: 'destructive',
1590
+ green: 'success', emerald: 'success', lime: 'success', teal: 'success',
1591
+ amber: 'warning', yellow: 'warning', orange: 'warning',
1592
+ blue: 'info', sky: 'info', cyan: 'info', indigo: 'info',
1593
+ violet: 'accent', purple: 'accent', fuchsia: 'accent', pink: 'accent',
1594
+ slate: 'muted', gray: 'muted', zinc: 'muted', neutral: 'muted', stone: 'muted',
1595
+ }
1596
+
1597
+ function checkRawTailwindPaletteColor(text) {
1598
+ const findings = []
1599
+ let match
1600
+ RAW_TW_PALETTE_RE.lastIndex = 0
1601
+ while ((match = RAW_TW_PALETTE_RE.exec(text)) !== null) {
1602
+ const role = TW_HUE_TO_ROLE[match[1]]
1603
+ const token = role ? suggestSemanticToken(role) : null
1604
+ const suggestion = token
1605
+ ? `use the Seven ${role} token: bg-[color:var(${token})]`
1606
+ : 'use a Seven color token: bg-[color:var(--...)]'
1607
+ findings.push({
1608
+ id: 'raw-tailwind-palette-color',
1609
+ line: lineOf(text, match.index),
1610
+ snippet: `${match[0]}: Tailwind palette color bypasses @etus/tokens; ${suggestion}`,
1611
+ })
1612
+ }
1613
+ return findings
1614
+ }
1615
+
1616
+ const COLOR_PROP_RE = /(?:bg|text|border|ring)-\[(?:([a-z]+):)?([^\]]+)\]/g
1617
+
1618
+ function checkMissingColorTypeHint(text) {
1619
+ const findings = []
1620
+ let match
1621
+ while ((match = COLOR_PROP_RE.exec(text)) !== null) {
1622
+ const hint = match[1]
1623
+ const value = match[2] ?? ''
1624
+ if (hint) continue
1625
+ if (!isSingleVarReference(value)) continue
1626
+ const varName = value.match(/var\(\s*(--[a-zA-Z0-9-]+)/)?.[1] ?? ''
1627
+ if (classifyVarName(varName) !== 'color') continue
1628
+ findings.push({
1629
+ id: 'missing-color-type-hint',
1630
+ line: lineOf(text, match.index),
1631
+ snippet: snippetAt(text, match.index),
1632
+ })
1633
+ }
1634
+ return findings
1635
+ }
1636
+
1637
+ const LENGTH_PROP_RE = /\btext-\[(?:([a-z]+):)?([^\]]+)\]/g
1638
+
1639
+ function checkMissingLengthTypeHint(text) {
1640
+ const findings = []
1641
+ let match
1642
+ while ((match = LENGTH_PROP_RE.exec(text)) !== null) {
1643
+ const hint = match[1]
1644
+ const value = match[2] ?? ''
1645
+ if (hint) continue
1646
+ if (!isSingleVarReference(value)) continue
1647
+ const varName = value.match(/var\(\s*(--[a-zA-Z0-9-]+)/)?.[1] ?? ''
1648
+ if (classifyVarName(varName) !== 'length') continue
1649
+ findings.push({
1650
+ id: 'missing-length-type-hint',
1651
+ line: lineOf(text, match.index),
1652
+ snippet: snippetAt(text, match.index),
1653
+ })
1654
+ }
1655
+ return findings
1656
+ }
1657
+
1658
+ const CLASS_ATTR_RE = /\b(?:className|class)\s*=\s*["'`]([^"'`]+)["'`]/g
1659
+
1660
+ function checkLeadingNoneWithLengthVar(text) {
1661
+ const findings = []
1662
+ let match
1663
+ while ((match = CLASS_ATTR_RE.exec(text)) !== null) {
1664
+ const classes = match[1] ?? ''
1665
+ if (!/\bleading-none\b/.test(classes)) continue
1666
+ if (!/text-\[length:var\(/.test(classes)) continue
1667
+ findings.push({
1668
+ id: 'leading-none-with-length-var',
1669
+ line: lineOf(text, match.index),
1670
+ snippet: snippetAt(text, match.index),
1671
+ })
1672
+ }
1673
+ return findings
1674
+ }
1675
+
1676
+ const FUCHSIA_OKLCH_RE = /oklch\(\s*[0-9.]+%?\s+[0-9.]+\s+(2[89][0-9]|3[0-3][0-9])(?:\.[0-9]+)?\s*[/)]/gi
1677
+ const FUCHSIA_HEX_RE = /#(?:d9[0-9a-f]{1,2}[0-9a-f]ef|d946ef|e879f9|c026d3|a21caf|86198f)\b/gi
1678
+
1679
+ function checkFuchsiaInUiChrome(text) {
1680
+ const findings = []
1681
+ let match
1682
+ while ((match = FUCHSIA_HEX_RE.exec(text)) !== null) {
1683
+ if (isWithinSvgOrChartTag(text, match.index)) continue
1684
+ findings.push({ id: 'fuchsia-in-ui-chrome', line: lineOf(text, match.index), snippet: snippetAt(text, match.index) })
1685
+ }
1686
+ while ((match = FUCHSIA_OKLCH_RE.exec(text)) !== null) {
1687
+ if (isWithinSvgOrChartTag(text, match.index)) continue
1688
+ findings.push({ id: 'fuchsia-in-ui-chrome', line: lineOf(text, match.index), snippet: snippetAt(text, match.index) })
1689
+ }
1690
+ return findings
1691
+ }
1692
+
1693
+ function checkPtPtVocab(text) {
1694
+ const findings = []
1695
+ for (const pair of PT_PT_VOCAB_PAIRS) {
1696
+ const re = new RegExp(`\\b${pair.ptPt}\\b`, 'giu')
1697
+ let match
1698
+ while ((match = re.exec(text)) !== null) {
1699
+ findings.push({
1700
+ id: 'pt-pt-vocab',
1701
+ line: lineOf(text, match.index),
1702
+ snippet: `${snippetAt(text, match.index)} (use "${pair.ptBr}")`,
1703
+ })
1704
+ }
1705
+ }
1706
+ return findings
1707
+ }
1708
+
1709
+ const PURE_BLACK_WHITE_RE =
1710
+ /(?:#(?:0{3,4}|0{6}|0{8})\b|#(?:f{3,4}|f{6}|f{8})\b|\brgba?\s*\(\s*0\s*,\s*0\s*,\s*0\s*[,/)]|\brgba?\s*\(\s*255\s*,\s*255\s*,\s*255\s*[,/)])/gi
1711
+
1712
+ function checkPureBlackOrWhite(text) {
1713
+ const findings = []
1714
+ let match
1715
+ while ((match = PURE_BLACK_WHITE_RE.exec(text)) !== null) {
1716
+ // Token-grounded: pure black/white are primitives; name the base token.
1717
+ const cssVar = colorToCssVar(fullColorAt(text, match.index))
1718
+ const base = snippetAt(text, match.index)
1719
+ findings.push({
1720
+ id: 'pure-black-or-white',
1721
+ line: lineOf(text, match.index),
1722
+ snippet: cssVar ? `${base} (use var(${cssVar}))` : base,
1723
+ })
1724
+ }
1725
+ return findings
1726
+ }
1727
+
1728
+ // Emoji characters render as multicolor glyphs regardless of CSS `color` and
1729
+ // have no place in Seven chrome copy. The class covers flags, pictographs,
1730
+ // symbols, dingbats, variation selectors, ZWJ sequences, and skin-tone
1731
+ // modifiers — the same range the upstream detector used to exclude emoji-only
1732
+ // text nodes from contrast checks.
1733
+ const EMOJI_CHAR_RE = /[\u{1F1E6}-\u{1F1FF}\u{1F300}-\u{1F9FF}\u{1FA00}-\u{1FAFF}\u{2600}-\u{27BF}\u{2300}-\u{23FF}\u{FE0F}\u{200D}\u{1F3FB}-\u{1F3FF}]/u
1734
+ const EMOJI_CHARS_GLOBAL = /[\u{1F1E6}-\u{1F1FF}\u{1F300}-\u{1F9FF}\u{1FA00}-\u{1FAFF}\u{2600}-\u{27BF}\u{2300}-\u{23FF}\u{FE0F}\u{200D}\u{1F3FB}-\u{1F3FF}]/gu
1735
+
1736
+ function isEmojiOnlyText(text) {
1737
+ if (!text) return false
1738
+ if (!EMOJI_CHAR_RE.test(text)) return false
1739
+ return text.replace(EMOJI_CHARS_GLOBAL, '').trim() === ''
1740
+ }
1741
+
1742
+ // Chrome elements whose text content must not carry emoji glyphs.
1743
+ const CHROME_TEXT_TAG_RE =
1744
+ /<(button|h[1-6]|label|a)\b[^>]*>([\s\S]*?)<\/\1>/gi
1745
+
1746
+ function checkEmojiInChrome(text) {
1747
+ const findings = []
1748
+ let match
1749
+ CHROME_TEXT_TAG_RE.lastIndex = 0
1750
+ while ((match = CHROME_TEXT_TAG_RE.exec(text)) !== null) {
1751
+ // Strip nested tags so we only test the rendered text run.
1752
+ const inner = match[2].replace(/<[^>]+>/g, '')
1753
+ if (!EMOJI_CHAR_RE.test(inner)) continue
1754
+ findings.push({
1755
+ id: 'emoji-in-chrome',
1756
+ line: lineOf(text, match.index),
1757
+ snippet: snippetAt(text, match.index),
1758
+ })
1759
+ }
1760
+ return findings
1761
+ }
1762
+
1763
+ // Em-dash (U+2014) and en-dash (U+2013) in user-facing copy. JSX/HTML text
1764
+ // runs and string literals carry copy; the long dash there reads as
1765
+ // machine-generated prose.
1766
+ const LONG_DASH_RE = /[–—]/g
1767
+
1768
+ function checkForbiddenEmDash(text) {
1769
+ // Mask JS/CSS block and line comments before scanning. JSDoc and `//`
1770
+ // explanatory comments are developer prose, not user-facing copy, and they
1771
+ // routinely use the long dash as rhetorical punctuation. Newlines are kept
1772
+ // so line offsets stay accurate for the surviving matches. The `[^:]` guard
1773
+ // on `//` keeps `https://` from being treated as a comment start.
1774
+ const masked = text
1775
+ .replace(/\/\*[\s\S]*?\*\//g, (m) => m.replace(/[^\n]/g, ' '))
1776
+ .replace(/(^|[^:])\/\/[^\n]*/g, (m, lead) => lead + m.slice(lead.length).replace(/./g, ' '))
1777
+ const findings = []
1778
+ let match
1779
+ LONG_DASH_RE.lastIndex = 0
1780
+ while ((match = LONG_DASH_RE.exec(masked)) !== null) {
1781
+ findings.push({
1782
+ id: 'forbidden-em-dash',
1783
+ line: lineOf(text, match.index),
1784
+ snippet: snippetAt(text, match.index),
1785
+ })
1786
+ }
1787
+ return findings
1788
+ }
1789
+
1790
+ /**
1791
+ * The Seven text-check set. Every engine iterates this map: the regex engine
1792
+ * runs each check against source text; the static-html and browser engines
1793
+ * run them against the markup and stylesheet text extracted from the
1794
+ * parsed/rendered document. These checks are deliberately text-based.
1795
+ */
1796
+ const CHECKS = {
1797
+ 'non-inter-font': checkNonInterFont,
1798
+ 'hardcoded-color-not-token': checkHardcodedColorNotToken,
1799
+ 'raw-tailwind-palette-color': checkRawTailwindPaletteColor,
1800
+ 'missing-color-type-hint': checkMissingColorTypeHint,
1801
+ 'missing-length-type-hint': checkMissingLengthTypeHint,
1802
+ 'leading-none-with-length-var': checkLeadingNoneWithLengthVar,
1803
+ 'fuchsia-in-ui-chrome': checkFuchsiaInUiChrome,
1804
+ 'pt-pt-vocab': checkPtPtVocab,
1805
+ 'pure-black-or-white': checkPureBlackOrWhite,
1806
+ 'emoji-in-chrome': checkEmojiInChrome,
1807
+ 'forbidden-em-dash': checkForbiddenEmDash,
1808
+ }
1809
+
1810
+ /** Run every text check against a text blob, returning raw `{ id, line, snippet }`. */
1811
+ function runChecks(text, ruleIds = null) {
1812
+ const findings = []
1813
+ for (const [id, check] of Object.entries(CHECKS)) {
1814
+ if (ruleIds && !ruleIds.includes(id)) continue
1815
+ findings.push(...check(text))
1816
+ }
1817
+ return findings
1818
+ }
1819
+
1820
+ // ─── Section 2: pure element checks (computed-style inputs) ──────────────────
1821
+ // These run on extracted style props and DOM-only inputs, so they work in both
1822
+ // the static-html cascade and a real browser. They take numbers and resolved
1823
+ // colors, never raw DOM nodes — the adapters in Section 4/5 do the extraction.
1824
+
1825
+ const BORDER_SAFE_TAGS = new Set([
1826
+ 'html', 'head', 'body', 'script', 'style', 'link', 'meta', 'title', 'br',
1827
+ 'svg', 'path', 'circle', 'rect', 'line', 'polyline', 'polygon', 'g', 'defs', 'use',
1828
+ 'table', 'thead', 'tbody', 'tfoot', 'tr', 'th', 'td', 'hr', 'input', 'textarea', 'select',
1829
+ ])
1830
+ const HEADING_TAGS = new Set(['h1', 'h2', 'h3', 'h4', 'h5', 'h6'])
1831
+
1832
+ // Seven's two radius stacks, token-grounded from @etus/tokens. Component scale
1833
+ // is for buttons/inputs/badges; surface scale is for cards/dialogs/popovers.
1834
+ // Mixing them on one element is the `radius-mixed-scales` drift.
1835
+ const COMPONENT_RADII = new Set(componentRadiiPx())
1836
+ const SURFACE_RADII = new Set(surfaceRadiiPx())
1837
+ // The pill/full-radius floor: any radius at or above this is a pill.
1838
+ const PILL_RADIUS_MIN = pillRadiiPx()[0] ?? 9999
1839
+
1840
+ /**
1841
+ * Side-stripe border check. A border-left or border-right thicker than 1px,
1842
+ * carrying a non-neutral (accent) color, is the banned Seven side stripe.
1843
+ * Top/bottom accents are not the side-stripe pattern and are left alone.
1844
+ */
1845
+ function checkBorders(tag, widths, colors) {
1846
+ if (BORDER_SAFE_TAGS.has(tag)) return []
1847
+ const findings = []
1848
+ for (const side of ['Left', 'Right']) {
1849
+ const w = widths[side]
1850
+ if (w <= 1 || isNeutralColor(colors[side])) continue
1851
+ const otherSides = ['Top', 'Right', 'Bottom', 'Left'].filter((s) => s !== side)
1852
+ const maxOther = Math.max(...otherSides.map((s) => widths[s]))
1853
+ // Only a stripe if this side stands out: the others are hairline-or-absent
1854
+ // or this side is at least twice as thick.
1855
+ if (!(maxOther <= 1 || w >= maxOther * 2)) continue
1856
+ findings.push({
1857
+ id: 'side-stripe-border',
1858
+ snippet: `border-${side.toLowerCase()}: ${w}px accent stripe`,
1859
+ })
1860
+ }
1861
+ return findings
1862
+ }
1863
+
1864
+ /**
1865
+ * Radius-mixed-scales check. A single element resolving border-radius corners
1866
+ * that fall in both the component scale and the surface scale is drift.
1867
+ */
1868
+ function checkRadiusMixedScales(tag, cornerRadii) {
1869
+ if (BORDER_SAFE_TAGS.has(tag)) return []
1870
+ const rounded = cornerRadii.map((r) => Math.round(r)).filter((r) => r > 0)
1871
+ const hasComponent = rounded.some((r) => COMPONENT_RADII.has(r))
1872
+ const hasSurface = rounded.some((r) => SURFACE_RADII.has(r))
1873
+ if (hasComponent && hasSurface) {
1874
+ return [{
1875
+ id: 'radius-mixed-scales',
1876
+ snippet: `mixed radius scales: ${[...new Set(rounded)].join('px / ')}px on one element`,
1877
+ }]
1878
+ }
1879
+ return []
1880
+ }
1881
+
1882
+ const BUTTON_TAGS = new Set(['button'])
1883
+
1884
+ /** Pill radius on a button. Buttons stay on the component radius scale. */
1885
+ function checkPillOnButton(tag, role, radiusPx, classList) {
1886
+ const isButton = BUTTON_TAGS.has(tag) || role === 'button'
1887
+ if (!isButton) return []
1888
+ const classStr = typeof classList === 'string' ? classList : ''
1889
+ if (radiusPx >= PILL_RADIUS_MIN || /\brounded-full\b/.test(classStr)) {
1890
+ return [{ id: 'pill-on-button', snippet: `pill radius on <${tag}>` }]
1891
+ }
1892
+ return []
1893
+ }
1894
+
1895
+ const INTERACTIVE_ROLES = new Set(['button', 'link', 'menuitem', 'tab', 'option'])
1896
+
1897
+ /**
1898
+ * Tactile-press check. Every Seven affordance gets the system-wide micro-press
1899
+ * — a transform transition that the hover/active states drive. A clickable
1900
+ * element whose transition-property covers neither `transform` nor `all` has
1901
+ * opted out.
1902
+ */
1903
+ function checkTactilePress(tag, role, transitionProperty, classList, hasClickHandler) {
1904
+ const isClickable =
1905
+ tag === 'button' ||
1906
+ (tag === 'a' && role === 'button') ||
1907
+ INTERACTIVE_ROLES.has(role) ||
1908
+ hasClickHandler
1909
+ if (!isClickable) return []
1910
+ const props = (transitionProperty || '')
1911
+ .split(',')
1912
+ .map((p) => p.trim().toLowerCase())
1913
+ .filter(Boolean)
1914
+ const classStr = typeof classList === 'string' ? classList : ''
1915
+ const hasTransformTransition =
1916
+ props.includes('transform') ||
1917
+ props.includes('all') ||
1918
+ /\btransition(?:-transform)?\b/.test(classStr)
1919
+ if (hasTransformTransition) return []
1920
+ return [{ id: 'missing-tactile-press', snippet: `clickable <${tag}> with no tactile-press transition` }]
1921
+ }
1922
+
1923
+ /**
1924
+ * Non-Lucide icon check. Lucide icons use a 24x24 viewBox, 2px stroke, round
1925
+ * caps and joins, and no fill. An inline <svg> that visibly deviates from that
1926
+ * contract is a non-Lucide icon.
1927
+ */
1928
+ function checkLucideIcon(viewBox, strokeWidth, strokeLinecap, fill) {
1929
+ // No identifying attributes at all — likely a decorative shape, not an icon.
1930
+ if (!viewBox && !strokeWidth) return []
1931
+ const vb = (viewBox || '').trim()
1932
+ const isLucideViewBox = vb === '' || vb === '0 0 24 24'
1933
+ const sw = strokeWidth != null && strokeWidth !== '' ? parseFloat(strokeWidth) : null
1934
+ const isLucideStroke = sw == null || sw === 2
1935
+ const cap = (strokeLinecap || '').toLowerCase()
1936
+ const isLucideCap = cap === '' || cap === 'round'
1937
+ const f = (fill || '').toLowerCase()
1938
+ const isLucideFill = f === '' || f === 'none'
1939
+ if (isLucideViewBox && isLucideStroke && isLucideCap && isLucideFill) return []
1940
+ const reasons = []
1941
+ if (!isLucideViewBox) reasons.push(`viewBox ${vb}`)
1942
+ if (!isLucideStroke) reasons.push(`stroke-width ${strokeWidth}`)
1943
+ if (!isLucideCap) reasons.push(`stroke-linecap ${cap}`)
1944
+ if (!isLucideFill) reasons.push(`fill ${f}`)
1945
+ return [{ id: 'non-lucide-icon', snippet: `non-Lucide <svg> (${reasons.join(', ')})` }]
1946
+ }
1947
+
1948
+ /**
1949
+ * Color checks: low-contrast, gray-on-color, gradient-text-decorative, plus a
1950
+ * pure-black background flag and fuchsia chroma on chrome. Background-dependent
1951
+ * checks run against a solid bg or, for gradients, every gradient stop.
1952
+ */
1953
+ function checkColors(opts) {
1954
+ const {
1955
+ tag, textColor, bgColor, effectiveBg, fontSize, fontWeight,
1956
+ hasDirectText, isEmojiOnly, bgClip, bgImage, classList,
1957
+ } = opts
1958
+ if (SAFE_TAGS.has(tag)) {
1959
+ const isStyledButton =
1960
+ (tag === 'a' || tag === 'button') && hasDirectText && bgColor && bgColor.a > 0.5
1961
+ if (!isStyledButton) return []
1962
+ }
1963
+ const findings = []
1964
+
1965
+ // Pure black background (solid or near-solid only).
1966
+ if (bgColor && bgColor.a >= 0.9 && bgColor.r === 0 && bgColor.g === 0 && bgColor.b === 0) {
1967
+ findings.push({ id: 'pure-black-or-white', snippet: '#000000 background' })
1968
+ }
1969
+
1970
+ if (hasDirectText && textColor && !isEmojiOnly && effectiveBg) {
1971
+ // Gray text on a colored background.
1972
+ const textLum = relativeLuminance(textColor)
1973
+ const isGray = !hasChroma(textColor, 20) && textLum > 0.05 && textLum < 0.85
1974
+ if (isGray && hasChroma(effectiveBg, 40)) {
1975
+ findings.push({
1976
+ id: 'gray-on-color',
1977
+ snippet: `text ${colorToHex(textColor)} on bg ${colorToHex(effectiveBg)}`,
1978
+ })
1979
+ }
1980
+
1981
+ // Low contrast (WCAG 2.1 AA).
1982
+ const ratio = contrastRatio(textColor, effectiveBg)
1983
+ const isLargeText = fontSize >= 24 || (fontSize >= 18.66 && fontWeight >= 700)
1984
+ const threshold = isLargeText ? 3.0 : 4.5
1985
+ if (ratio < threshold) {
1986
+ findings.push({
1987
+ id: 'low-contrast',
1988
+ snippet: `${ratio.toFixed(1)}:1 (need ${threshold}:1) — text ${colorToHex(textColor)} on ${colorToHex(effectiveBg)}`,
1989
+ })
1990
+ }
1991
+
1992
+ // Fuchsia chroma on UI chrome text.
1993
+ if (isFuchsia(textColor) && tag !== 'svg') {
1994
+ findings.push({ id: 'fuchsia-in-ui-chrome', snippet: `fuchsia text ${colorToHex(textColor)} on chrome` })
1995
+ }
1996
+ }
1997
+
1998
+ // Decorative gradient text.
1999
+ if (bgClip === 'text' && bgImage && bgImage.includes('gradient')) {
2000
+ findings.push({ id: 'gradient-text-decorative', snippet: 'background-clip: text + gradient' })
2001
+ }
2002
+
2003
+ // Tailwind class checks.
2004
+ if (classList) {
2005
+ const classStr = typeof classList === 'string' ? classList : Array.from(classList).join(' ')
2006
+ if (/\bbg-black\b(?!\/)/.test(classStr) || /\bbg-white\b(?!\/)/.test(classStr)) {
2007
+ findings.push({ id: 'pure-black-or-white', snippet: /\bbg-white\b/.test(classStr) ? 'bg-white' : 'bg-black' })
2008
+ }
2009
+ const grayMatch = classStr.match(/\btext-(?:gray|slate|zinc|neutral|stone)-\d+\b/)
2010
+ const colorBgMatch = classStr.match(
2011
+ /\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+\b/
2012
+ )
2013
+ if (grayMatch && colorBgMatch) {
2014
+ findings.push({ id: 'gray-on-color', snippet: `${grayMatch[0]} on ${colorBgMatch[0]}` })
2015
+ }
2016
+ if (/\bbg-clip-text\b/.test(classStr) && /\bbg-gradient-to-/.test(classStr)) {
2017
+ findings.push({ id: 'gradient-text-decorative', snippet: 'bg-clip-text + bg-gradient (Tailwind)' })
2018
+ }
2019
+ if (/\bfuchsia-\d/.test(classStr) && /\b(?:bg|text|border|ring)-fuchsia-\d/.test(classStr)) {
2020
+ findings.push({ id: 'fuchsia-in-ui-chrome', snippet: 'fuchsia utility on chrome' })
2021
+ }
2022
+ }
2023
+
2024
+ return findings
2025
+ }
2026
+
2027
+ function isCardLikeFromProps(hasShadow, hasBorder, hasRadius, hasBg) {
2028
+ if (!hasShadow && !hasBorder) return false
2029
+ return hasRadius || hasBg
2030
+ }
2031
+
2032
+ const LAYOUT_TRANSITION_PROPS = new Set([
2033
+ 'width', 'height', 'padding', 'margin',
2034
+ 'max-height', 'max-width', 'min-height', 'min-width',
2035
+ 'padding-top', 'padding-right', 'padding-bottom', 'padding-left',
2036
+ 'margin-top', 'margin-right', 'margin-bottom', 'margin-left',
2037
+ ])
2038
+
2039
+ /**
2040
+ * True when a cubic-bezier matches a sanctioned Seven easing token. The
2041
+ * catalog declares --ease-bounce and --ease-spring, so an overshooting curve
2042
+ * that IS one of those token values is on-system, not drift.
2043
+ */
2044
+ function isSanctionedBezier(a, b, c, d) {
2045
+ return sanctionedEasingBeziers().some(
2046
+ ([x1, y1, x2, y2]) =>
2047
+ Math.abs(x1 - a) < 0.01 &&
2048
+ Math.abs(y1 - b) < 0.01 &&
2049
+ Math.abs(x2 - c) < 0.01 &&
2050
+ Math.abs(y2 - d) < 0.01
2051
+ )
2052
+ }
2053
+
2054
+ /** Motion checks: bounce/elastic easing and layout-property transitions. */
2055
+ function checkMotion(opts) {
2056
+ const { tag, transitionProperty, animationName, timingFunctions, classList } = opts
2057
+ if (SAFE_TAGS.has(tag)) return []
2058
+ const findings = []
2059
+
2060
+ if (animationName && animationName !== 'none' && /bounce|elastic|wobble|jiggle|spring/i.test(animationName)) {
2061
+ findings.push({ id: 'bounce-easing', snippet: `animation: ${animationName}` })
2062
+ }
2063
+ if (classList && /\banimate-bounce\b/.test(classList)) {
2064
+ findings.push({ id: 'bounce-easing', snippet: 'animate-bounce (Tailwind)' })
2065
+ }
2066
+ if (timingFunctions) {
2067
+ const bezierRe = /cubic-bezier\(\s*([\d.-]+)\s*,\s*([\d.-]+)\s*,\s*([\d.-]+)\s*,\s*([\d.-]+)\s*\)/g
2068
+ let m
2069
+ while ((m = bezierRe.exec(timingFunctions)) !== null) {
2070
+ const x1 = parseFloat(m[1])
2071
+ const y1 = parseFloat(m[2])
2072
+ const x2 = parseFloat(m[3])
2073
+ const y2 = parseFloat(m[4])
2074
+ const overshoots = y1 < -0.1 || y1 > 1.1 || y2 < -0.1 || y2 > 1.1
2075
+ if (overshoots && !isSanctionedBezier(x1, y1, x2, y2)) {
2076
+ findings.push({ id: 'bounce-easing', snippet: `cubic-bezier(${m[1]}, ${m[2]}, ${m[3]}, ${m[4]})` })
2077
+ break
2078
+ }
2079
+ }
2080
+ }
2081
+
2082
+ if (transitionProperty && transitionProperty !== 'all' && transitionProperty !== 'none') {
2083
+ const props = transitionProperty.split(',').map((p) => p.trim().toLowerCase())
2084
+ const layoutFound = props.filter((p) => LAYOUT_TRANSITION_PROPS.has(p))
2085
+ if (layoutFound.length > 0) {
2086
+ findings.push({ id: 'layout-transition', snippet: `transition: ${layoutFound.join(', ')}` })
2087
+ }
2088
+ }
2089
+
2090
+ return findings
2091
+ }
2092
+
2093
+ /**
2094
+ * Glow check. A colored, blurred box-shadow on a dark surface is the AI "dark
2095
+ * glow" look; in Seven, shadows are reserved for overlays and cards carry
2096
+ * hierarchy with borders — so the finding is `shadows-not-borders`.
2097
+ */
2098
+ function checkGlow(opts) {
2099
+ const { boxShadow, effectiveBg } = opts
2100
+ if (!boxShadow || boxShadow === 'none') return []
2101
+ if (!effectiveBg) return []
2102
+ const bgLum = relativeLuminance(effectiveBg)
2103
+ if (bgLum >= 0.1) return []
2104
+
2105
+ const parts = boxShadow.split(/,(?![^(]*\))/)
2106
+ for (const shadow of parts) {
2107
+ const colorMatch = shadow.match(/rgba?\([^)]+\)/)
2108
+ if (!colorMatch) continue
2109
+ const color = parseRgb(colorMatch[0])
2110
+ if (!color || !hasChroma(color, 30)) continue
2111
+ const afterColor = shadow.substring(shadow.indexOf(colorMatch[0]) + colorMatch[0].length)
2112
+ const beforeColor = shadow.substring(0, shadow.indexOf(colorMatch[0]))
2113
+ const pxVals = [...beforeColor.matchAll(/([\d.]+)px/g), ...afterColor.matchAll(/([\d.]+)px/g)]
2114
+ .map((m) => parseFloat(m[1]))
2115
+ if (pxVals.length >= 3 && pxVals[2] > 4) {
2116
+ return [{ id: 'shadows-not-borders', snippet: `colored glow (${colorToHex(color)}) on dark surface` }]
2117
+ }
2118
+ }
2119
+ return []
2120
+ }
2121
+
2122
+ const QUALITY_TEXT_TAGS = new Set(['p', 'li', 'td', 'th', 'dd', 'blockquote', 'figcaption'])
2123
+
2124
+ /**
2125
+ * Quality checks on text-bearing elements: line-length, cramped-padding,
2126
+ * body-text-viewport-edge, tight-leading, justified-text, tiny-text,
2127
+ * all-caps-body, wide-tracking. Two checks (line-length, cramped-padding,
2128
+ * body-text-viewport-edge) need element rects; pass `rect: null` to skip them.
2129
+ */
2130
+ function checkQuality(opts) {
2131
+ const {
2132
+ el, tag, style, hasDirectText, textLen, fontSize, lineHeightPx, letterSpacingPx,
2133
+ rect, lineMax = 80, viewportWidth = 0,
2134
+ } = opts
2135
+ const findings = []
2136
+
2137
+ if (rect && hasDirectText && QUALITY_TEXT_TAGS.has(tag) && rect.width > 0 && textLen > lineMax) {
2138
+ const charsPerLine = rect.width / (fontSize * 0.5)
2139
+ if (charsPerLine > lineMax + 5) {
2140
+ findings.push({ id: 'line-length', snippet: `~${Math.round(charsPerLine)} chars/line (aim for <${lineMax})` })
2141
+ }
2142
+ }
2143
+
2144
+ if (rect && hasDirectText && textLen > 20 && rect.width > 100 && rect.height > 30) {
2145
+ const borders = {
2146
+ top: parseFloat(style.borderTopWidth) || 0,
2147
+ right: parseFloat(style.borderRightWidth) || 0,
2148
+ bottom: parseFloat(style.borderBottomWidth) || 0,
2149
+ left: parseFloat(style.borderLeftWidth) || 0,
2150
+ }
2151
+ const borderCount = Object.values(borders).filter((w) => w > 0).length
2152
+ const hasBg = style.backgroundColor && style.backgroundColor !== 'rgba(0, 0, 0, 0)'
2153
+ if (borderCount >= 2 || hasBg) {
2154
+ const vPads = []
2155
+ const hPads = []
2156
+ if (hasBg || borders.top > 0) vPads.push(parseFloat(style.paddingTop) || 0)
2157
+ if (hasBg || borders.bottom > 0) vPads.push(parseFloat(style.paddingBottom) || 0)
2158
+ if (hasBg || borders.left > 0) hPads.push(parseFloat(style.paddingLeft) || 0)
2159
+ if (hasBg || borders.right > 0) hPads.push(parseFloat(style.paddingRight) || 0)
2160
+ const vMin = vPads.length ? Math.min(...vPads) : Infinity
2161
+ const hMin = hPads.length ? Math.min(...hPads) : Infinity
2162
+ const vThresh = Math.max(4, fontSize * 0.3)
2163
+ const hThresh = Math.max(8, fontSize * 0.5)
2164
+ if (vMin < vThresh) {
2165
+ findings.push({ id: 'cramped-padding', snippet: `${vMin}px vertical padding (need >=${vThresh.toFixed(1)}px for ${fontSize}px text)` })
2166
+ } else if (hMin < hThresh) {
2167
+ findings.push({ id: 'cramped-padding', snippet: `${hMin}px horizontal padding (need >=${hThresh.toFixed(1)}px for ${fontSize}px text)` })
2168
+ }
2169
+ }
2170
+ }
2171
+
2172
+ if (rect && hasDirectText && textLen > 40 && ['P', 'LI'].includes(tag.toUpperCase()) && viewportWidth > 0) {
2173
+ const inNavHeader = el.closest && (el.closest('nav') || el.closest('header'))
2174
+ const hasOwnBg =
2175
+ style.backgroundColor && style.backgroundColor !== 'rgba(0, 0, 0, 0)' && style.backgroundColor !== 'transparent'
2176
+ const isPositioned = ['fixed', 'absolute'].includes(style.position || '')
2177
+ const widthRatio = rect.width / viewportWidth
2178
+ const leftClose = rect.left < 16
2179
+ const rightClose = rect.right > viewportWidth - 16
2180
+ if (!inNavHeader && !hasOwnBg && !isPositioned && widthRatio > 0.5 && (leftClose || rightClose)) {
2181
+ const which =
2182
+ leftClose && rightClose
2183
+ ? `left ${Math.round(rect.left)}px / right ${Math.round(viewportWidth - rect.right)}px`
2184
+ : leftClose
2185
+ ? `left ${Math.round(rect.left)}px`
2186
+ : `right ${Math.round(viewportWidth - rect.right)}px`
2187
+ findings.push({
2188
+ id: 'body-text-viewport-edge',
2189
+ snippet: `<${tag.toLowerCase()}> with ${textLen}-char body bleeds to viewport edge (${which})`,
2190
+ })
2191
+ }
2192
+ }
2193
+
2194
+ if (hasDirectText && textLen > 50 && !HEADING_TAGS.has(tag)) {
2195
+ if (lineHeightPx != null && fontSize > 0) {
2196
+ const ratio = lineHeightPx / fontSize
2197
+ if (ratio > 0 && ratio < 1.3) {
2198
+ findings.push({ id: 'tight-leading', snippet: `line-height ${ratio.toFixed(2)}x (need >=1.3)` })
2199
+ }
2200
+ }
2201
+ }
2202
+
2203
+ if (hasDirectText && style.textAlign === 'justify') {
2204
+ const hyphens = style.hyphens || style.webkitHyphens || ''
2205
+ if (hyphens !== 'auto') {
2206
+ findings.push({ id: 'justified-text', snippet: 'text-align: justify without hyphens: auto' })
2207
+ }
2208
+ }
2209
+
2210
+ // Token-grounded: the floor is the smallest @etus/tokens font-size token.
2211
+ if (hasDirectText && textLen > 20 && fontSize < minFontSizePx()) {
2212
+ const skipTags = ['sub', 'sup', 'code', 'kbd', 'samp', 'var', 'caption', 'figcaption']
2213
+ const inUIContext =
2214
+ el.closest &&
2215
+ el.closest(
2216
+ 'button, a, label, summary, [role="button"], [role="link"], [role="tab"], [role="menuitem"], [role="option"], nav, footer, [class*="badge" i], [class*="chip" i], [class*="pill" i], [class*="tag" i], [class*="label" i], [class*="caption" i]'
2217
+ )
2218
+ const isUppercase = style.textTransform === 'uppercase'
2219
+ if (!skipTags.includes(tag) && !inUIContext && !isUppercase) {
2220
+ findings.push({ id: 'tiny-text', snippet: `${fontSize}px body text` })
2221
+ }
2222
+ }
2223
+
2224
+ if (hasDirectText && textLen > 30 && style.textTransform === 'uppercase' && !HEADING_TAGS.has(tag)) {
2225
+ findings.push({ id: 'all-caps-body', snippet: `text-transform: uppercase on ${textLen} chars of body text` })
2226
+ }
2227
+
2228
+ if (hasDirectText && textLen > 20 && style.textTransform !== 'uppercase') {
2229
+ if (letterSpacingPx != null && letterSpacingPx > 0 && fontSize > 0) {
2230
+ const trackingEm = letterSpacingPx / fontSize
2231
+ if (trackingEm > 0.05) {
2232
+ findings.push({ id: 'wide-tracking', snippet: `letter-spacing: ${trackingEm.toFixed(2)}em on body text` })
2233
+ }
2234
+ }
2235
+ }
2236
+
2237
+ return findings
2238
+ }
2239
+
2240
+ // ─── Section 3: regex-on-HTML page checks ───────────────────────────────────
2241
+
2242
+ /**
2243
+ * Page-level checks that read the raw HTML string with no DOM access. Shared
2244
+ * by the static-html and browser engines.
2245
+ */
2246
+ function checkHtmlPatterns(html) {
2247
+ const findings = []
2248
+
2249
+ // Pure black background.
2250
+ if (/background(?:-color)?\s*:\s*(?:#000000|#000|rgb\(\s*0,\s*0,\s*0\s*\))\b/gi.test(html)) {
2251
+ findings.push({ id: 'pure-black-or-white', snippet: 'Pure #000 background' })
2252
+ }
2253
+
2254
+ // Decorative gradient text.
2255
+ const gradientRe = /(?:-webkit-)?background-clip\s*:\s*text/gi
2256
+ let gm
2257
+ while ((gm = gradientRe.exec(html)) !== null) {
2258
+ const start = Math.max(0, gm.index - 200)
2259
+ const context = html.substring(start, gm.index + gm[0].length + 200)
2260
+ if (/gradient/i.test(context)) {
2261
+ findings.push({ id: 'gradient-text-decorative', snippet: 'background-clip: text + gradient' })
2262
+ break
2263
+ }
2264
+ }
2265
+ if (/\bbg-clip-text\b/.test(html) && /\bbg-gradient-to-/.test(html)) {
2266
+ findings.push({ id: 'gradient-text-decorative', snippet: 'bg-clip-text + bg-gradient (Tailwind)' })
2267
+ }
2268
+
2269
+ // Monotonous spacing.
2270
+ const spacingValues = []
2271
+ let sm
2272
+ const spacingRe = /(?:padding|margin)(?:-(?:top|right|bottom|left))?\s*:\s*(\d+)px/gi
2273
+ while ((sm = spacingRe.exec(html)) !== null) {
2274
+ const v = parseInt(sm[1], 10)
2275
+ if (v > 0 && v < 200) spacingValues.push(v)
2276
+ }
2277
+ const gapRe = /gap\s*:\s*(\d+)px/gi
2278
+ while ((sm = gapRe.exec(html)) !== null) spacingValues.push(parseInt(sm[1], 10))
2279
+ const twSpaceRe = /\b(?:p|px|py|pt|pb|pl|pr|m|mx|my|mt|mb|ml|mr|gap)-(\d+)\b/g
2280
+ while ((sm = twSpaceRe.exec(html)) !== null) spacingValues.push(parseInt(sm[1], 10) * 4)
2281
+ const remSpacingRe = /(?:padding|margin)(?:-(?:top|right|bottom|left))?\s*:\s*([\d.]+)rem/gi
2282
+ while ((sm = remSpacingRe.exec(html)) !== null) {
2283
+ const v = Math.round(parseFloat(sm[1]) * 16)
2284
+ if (v > 0 && v < 200) spacingValues.push(v)
2285
+ }
2286
+ const roundedSpacing = spacingValues.map((v) => Math.round(v / 4) * 4)
2287
+ if (roundedSpacing.length >= 10) {
2288
+ const counts = {}
2289
+ for (const v of roundedSpacing) counts[v] = (counts[v] || 0) + 1
2290
+ const maxCount = Math.max(...Object.values(counts))
2291
+ const dominantPct = maxCount / roundedSpacing.length
2292
+ const unique = [...new Set(roundedSpacing)].filter((v) => v > 0)
2293
+ if (dominantPct > 0.6 && unique.length <= 3) {
2294
+ const dominant = Object.entries(counts).sort((a, b) => b[1] - a[1])[0][0]
2295
+ findings.push({
2296
+ id: 'monotonous-spacing',
2297
+ snippet: `~${dominant}px used ${maxCount}/${roundedSpacing.length} times (${Math.round(dominantPct * 100)}%)`,
2298
+ })
2299
+ }
2300
+ }
2301
+
2302
+ // Bounce / elastic animation names.
2303
+ if (/animation(?:-name)?\s*:\s*[^;]*\b(bounce|elastic|wobble|jiggle|spring)\b/gi.test(html)) {
2304
+ findings.push({ id: 'bounce-easing', snippet: 'Bounce/elastic animation in CSS' })
2305
+ }
2306
+ const bezierRe = /cubic-bezier\(\s*([\d.-]+)\s*,\s*([\d.-]+)\s*,\s*([\d.-]+)\s*,\s*([\d.-]+)\s*\)/g
2307
+ let bm
2308
+ while ((bm = bezierRe.exec(html)) !== null) {
2309
+ const x1 = parseFloat(bm[1])
2310
+ const y1 = parseFloat(bm[2])
2311
+ const x2 = parseFloat(bm[3])
2312
+ const y2 = parseFloat(bm[4])
2313
+ const overshoots = y1 < -0.1 || y1 > 1.1 || y2 < -0.1 || y2 > 1.1
2314
+ if (overshoots && !isSanctionedBezier(x1, y1, x2, y2)) {
2315
+ findings.push({ id: 'bounce-easing', snippet: `cubic-bezier(${bm[1]}, ${bm[2]}, ${bm[3]}, ${bm[4]})` })
2316
+ break
2317
+ }
2318
+ }
2319
+
2320
+ // Layout-property transitions.
2321
+ const transRe = /transition(?:-property)?\s*:\s*([^;{}]+)/gi
2322
+ let tm
2323
+ while ((tm = transRe.exec(html)) !== null) {
2324
+ const val = tm[1].toLowerCase()
2325
+ if (/\ball\b/.test(val)) continue
2326
+ const found = val.match(/\b(?:(?:max|min)-)?(?:width|height)\b|\bpadding(?:-(?:top|right|bottom|left))?\b|\bmargin(?:-(?:top|right|bottom|left))?\b/gi)
2327
+ if (found) {
2328
+ findings.push({ id: 'layout-transition', snippet: `transition: ${found.join(', ')}` })
2329
+ break
2330
+ }
2331
+ }
2332
+
2333
+ // Glow on a dark surface.
2334
+ const darkBgRe = /background(?:-color)?\s*:\s*(?:#(?:0[0-9a-f]|1[0-9a-f]|2[0-3])[0-9a-f]{4}\b|#(?:0|1)[0-9a-f]{2}\b|rgb\(\s*(\d{1,2})\s*,\s*(\d{1,2})\s*,\s*(\d{1,2})\s*\))/gi
2335
+ const twDarkBg = /\bbg-(?:gray|slate|zinc|neutral|stone)-(?:9\d{2}|800)\b/
2336
+ if (darkBgRe.test(html) || twDarkBg.test(html)) {
2337
+ const shadowRe = /box-shadow\s*:\s*([^;{}]+)/gi
2338
+ let shm
2339
+ while ((shm = shadowRe.exec(html)) !== null) {
2340
+ const val = shm[1]
2341
+ const colorMatch = val.match(/rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/)
2342
+ if (!colorMatch) continue
2343
+ const [r, g, b] = [+colorMatch[1], +colorMatch[2], +colorMatch[3]]
2344
+ if (Math.max(r, g, b) - Math.min(r, g, b) < 30) continue
2345
+ const pxVals = [...val.matchAll(/(\d+)px|(?<![.\d])\b(0)\b(?![.\d])/g)].map((p) => +(p[1] || p[2]))
2346
+ if (pxVals.length >= 3 && pxVals[2] > 4) {
2347
+ findings.push({ id: 'shadows-not-borders', snippet: `colored glow (rgb(${r},${g},${b})) on dark page` })
2348
+ break
2349
+ }
2350
+ }
2351
+ }
2352
+
2353
+ return findings
2354
+ }
2355
+
2356
+ // ─── Section 4: background resolution ───────────────────────────────────────
2357
+
2358
+ function readOwnBackgroundColor(el, computedStyle) {
2359
+ const bg = parseRgb(computedStyle.backgroundColor)
2360
+ if (DETECTOR_IS_BROWSER || (bg && bg.a >= 0.1)) return bg
2361
+ const rawStyle = el.getAttribute?.('style') || ''
2362
+ const bgMatch = rawStyle.match(/background(?:-color)?\s*:\s*([^;]+)/i)
2363
+ const inlineBg = bgMatch ? bgMatch[1].trim() : ''
2364
+ if (!inlineBg) return bg
2365
+ if (/gradient/i.test(inlineBg) || /url\s*\(/i.test(inlineBg)) return bg
2366
+ const fromRgb = parseRgb(inlineBg)
2367
+ if (fromRgb) return fromRgb
2368
+ const hexMatch = inlineBg.match(/#([0-9a-f]{6}|[0-9a-f]{3})\b/i)
2369
+ if (hexMatch) {
2370
+ const h = hexMatch[1]
2371
+ if (h.length === 6) {
2372
+ return { r: parseInt(h.slice(0, 2), 16), g: parseInt(h.slice(2, 4), 16), b: parseInt(h.slice(4, 6), 16), a: 1 }
2373
+ }
2374
+ return { r: parseInt(h[0] + h[0], 16), g: parseInt(h[1] + h[1], 16), b: parseInt(h[2] + h[2], 16), a: 1 }
2375
+ }
2376
+ return bg
2377
+ }
2378
+
2379
+ /**
2380
+ * Walk the ancestor chain for the first opaque background color. Body/html
2381
+ * gradients are treated as decorative and resolve to white; on other elements
2382
+ * a gradient with no underlying solid color bails to null.
2383
+ */
2384
+ function resolveBackground(el, win) {
2385
+ let current = el
2386
+ while (current && current.nodeType === 1) {
2387
+ const style = DETECTOR_IS_BROWSER ? getComputedStyle(current) : win.getComputedStyle(current)
2388
+ const bgImage = style.backgroundImage || ''
2389
+ const hasGradientOrUrl =
2390
+ bgImage && bgImage !== 'none' && (/gradient/i.test(bgImage) || /url\s*\(/i.test(bgImage))
2391
+ const bg = parseRgb(style.backgroundColor)
2392
+ if (bg && bg.a > 0.1) {
2393
+ if (DETECTOR_IS_BROWSER || bg.a >= 0.5) return bg
2394
+ }
2395
+ if (hasGradientOrUrl) {
2396
+ if (current.tagName === 'BODY' || current.tagName === 'HTML') {
2397
+ return { r: 255, g: 255, b: 255, a: 1 }
2398
+ }
2399
+ return null
2400
+ }
2401
+ current = current.parentElement
2402
+ }
2403
+ return { r: 255, g: 255, b: 255, a: 1 }
2404
+ }
2405
+
2406
+ // Parse a single CSS length token to pixels.
2407
+ function parseRadiusToPx(value, widthPx) {
2408
+ if (!value || typeof value !== 'string') return null
2409
+ const trimmed = value.trim()
2410
+ if (!trimmed) return null
2411
+ const first = trimmed.split(/\s+/)[0]
2412
+ const num = parseFloat(first)
2413
+ if (Number.isNaN(num)) return null
2414
+ if (/%$/.test(first)) {
2415
+ if (widthPx && widthPx > 0) return (num / 100) * widthPx
2416
+ return num
2417
+ }
2418
+ return num
2419
+ }
2420
+
2421
+ function resolveBorderRadiusPx(el, style, widthPx) {
2422
+ const fromComputed = parseRadiusToPx(style.borderRadius, widthPx)
2423
+ if (fromComputed !== null) return fromComputed
2424
+ return 0
2425
+ }
2426
+
2427
+ // Resolve var(--X[, fallback]) refs in a value string against a custom-prop
2428
+ // map. Recurses up to 8 levels for chained refs. Returns the original string
2429
+ // when no refs are present or the chain does not resolve. The static CSS
2430
+ // cascade depends on this to resolve Tailwind v4 token wrappers.
2431
+ function resolveVarRefs(raw, customPropMap, depth = 0) {
2432
+ if (typeof raw !== 'string' || !raw.includes('var(')) return raw
2433
+ if (depth > 8) return raw
2434
+ return raw.replace(/var\(\s*(--[a-zA-Z0-9_-]+)\s*(?:,\s*([^)]+))?\)/g, (full, name, fallback) => {
2435
+ const v = customPropMap && (customPropMap.get ? customPropMap.get(name) : customPropMap[name])
2436
+ if (v != null) return resolveVarRefs(v, customPropMap, depth + 1)
2437
+ return fallback ? resolveVarRefs(fallback.trim(), customPropMap, depth + 1) : full
2438
+ })
2439
+ }
2440
+
2441
+ // OKLCH -> sRGB conversion (Bjorn Ottosson's matrices). L in 0..1 (or %),
2442
+ // C typically 0..~0.4, H in degrees. Returns clamped {r,g,b,a:1} in 0..255.
2443
+ // Needed because the static cascade cannot compute oklch() — and Seven's
2444
+ // token ramp is OKLCH throughout, so without this the palette is invisible
2445
+ // to the contrast and color checks.
2446
+ function oklchToRgb(L, C, H) {
2447
+ const hRad = (H * Math.PI) / 180
2448
+ const a = C * Math.cos(hRad)
2449
+ const b = C * Math.sin(hRad)
2450
+ const l_ = L + 0.3963377774 * a + 0.2158037573 * b
2451
+ const m_ = L - 0.1055613458 * a - 0.0638541728 * b
2452
+ const s_ = L - 0.0894841775 * a - 1.291485548 * b
2453
+ const lc = l_ * l_ * l_
2454
+ const mc = m_ * m_ * m_
2455
+ const sc = s_ * s_ * s_
2456
+ const rLin = 4.0767416621 * lc - 3.3077115913 * mc + 0.2309699292 * sc
2457
+ const gLin = -1.2684380046 * lc + 2.6097574011 * mc - 0.3413193965 * sc
2458
+ const bLin = -0.0041960863 * lc - 0.7034186147 * mc + 1.707614701 * sc
2459
+ const enc = (x) => {
2460
+ const c = Math.max(0, Math.min(1, x))
2461
+ return c <= 0.0031308 ? 12.92 * c : 1.055 * Math.pow(c, 1 / 2.4) - 0.055
2462
+ }
2463
+ return {
2464
+ r: Math.round(enc(rLin) * 255),
2465
+ g: Math.round(enc(gLin) * 255),
2466
+ b: Math.round(enc(bLin) * 255),
2467
+ a: 1,
2468
+ }
2469
+ }
2470
+
2471
+ // Extended color parser: rgb/rgba/hex/oklch. Returns null on no match. Used
2472
+ // by the static cascade where a value might be any CSS color form.
2473
+ function parseAnyColor(s) {
2474
+ if (!s || typeof s !== 'string') return null
2475
+ const str = s.trim()
2476
+ if (str === 'transparent' || str === 'currentcolor' || str === 'inherit') return null
2477
+ let m
2478
+ m = str.match(/rgba?\(\s*(\d+(?:\.\d+)?)\s*,?\s*(\d+(?:\.\d+)?)\s*,?\s*(\d+(?:\.\d+)?)(?:\s*[,/]\s*([\d.]+))?\s*\)/)
2479
+ if (m) {
2480
+ return { r: Math.round(+m[1]), g: Math.round(+m[2]), b: Math.round(+m[3]), a: m[4] !== undefined ? +m[4] : 1 }
2481
+ }
2482
+ m = str.match(/^#([0-9a-f]{3,8})$/i)
2483
+ if (m) {
2484
+ const h = m[1]
2485
+ if (h.length === 3 || h.length === 4) {
2486
+ return {
2487
+ r: parseInt(h[0] + h[0], 16),
2488
+ g: parseInt(h[1] + h[1], 16),
2489
+ b: parseInt(h[2] + h[2], 16),
2490
+ a: h.length === 4 ? parseInt(h[3] + h[3], 16) / 255 : 1,
2491
+ }
2492
+ }
2493
+ if (h.length === 6 || h.length === 8) {
2494
+ return {
2495
+ r: parseInt(h.slice(0, 2), 16),
2496
+ g: parseInt(h.slice(2, 4), 16),
2497
+ b: parseInt(h.slice(4, 6), 16),
2498
+ a: h.length === 8 ? parseInt(h.slice(6, 8), 16) / 255 : 1,
2499
+ }
2500
+ }
2501
+ }
2502
+ // OKLCH — the CSS minifier may drop the space after `%`, so the L/C
2503
+ // separator can be absent. Match L (optional %), then C and H permissively.
2504
+ m = str.match(/oklch\(\s*([\d.]+)(%?)\s*[\s,]*\s*([\d.]+)\s*[\s,]+\s*([-\d.]+)(?:deg)?\s*\)/i)
2505
+ if (m) {
2506
+ const Lnum = parseFloat(m[1])
2507
+ const L = m[2] === '%' ? Lnum / 100 : Lnum
2508
+ return oklchToRgb(L, parseFloat(m[3]), parseFloat(m[4]))
2509
+ }
2510
+ return null
2511
+ }
2512
+
2513
+ // Resolve a CSS length value (line-height, letter-spacing, etc.) given a
2514
+ // font-size context. Returns null for "normal" / unparseable values.
2515
+ function resolveLengthPx(value, fontSizePx) {
2516
+ if (!value || value === 'normal' || value === 'auto' || value === 'inherit') return null
2517
+ const num = parseFloat(value)
2518
+ if (Number.isNaN(num)) return null
2519
+ if (value.endsWith('px')) return num
2520
+ if (value.endsWith('rem')) return num * 16
2521
+ if (value.endsWith('em')) return num * fontSizePx
2522
+ if (value.endsWith('%')) return (num / 100) * fontSizePx
2523
+ return num * fontSizePx
2524
+ }
2525
+
2526
+ // Resolve a CSS font-size value to pixels by walking up the parent chain.
2527
+ function resolveFontSizePx(el, win) {
2528
+ const chain = []
2529
+ let cur = el
2530
+ while (cur && cur.nodeType === 1) {
2531
+ const fs = (win ? win.getComputedStyle(cur) : getComputedStyle(cur)).fontSize
2532
+ chain.push(fs || '')
2533
+ cur = cur.parentElement
2534
+ }
2535
+ let px = 16
2536
+ for (let i = chain.length - 1; i >= 0; i--) {
2537
+ const v = chain[i]
2538
+ if (!v || v === 'inherit') continue
2539
+ const num = parseFloat(v)
2540
+ if (Number.isNaN(num)) continue
2541
+ if (v.endsWith('px')) px = num
2542
+ else if (v.endsWith('rem')) px = num * 16
2543
+ else if (v.endsWith('em')) px = num * px
2544
+ else if (v.endsWith('%')) px = (num / 100) * px
2545
+ else px = num
2546
+ }
2547
+ return px
2548
+ }
2549
+
2550
+ // Collect every corner radius a style resolves. A real browser exposes each
2551
+ // corner as a longhand; the static cascade stores `border-radius` verbatim,
2552
+ // which may be a multi-value shorthand (`8px 8px 24px 24px`). Both forms are
2553
+ // handled so the mixed-scales check sees all four corners.
2554
+ function collectCornerRadii(style, widthPx) {
2555
+ const corners = [
2556
+ style.borderTopLeftRadius,
2557
+ style.borderTopRightRadius,
2558
+ style.borderBottomLeftRadius,
2559
+ style.borderBottomRightRadius,
2560
+ ].filter((v) => v != null && v !== '')
2561
+ if (corners.length > 0) {
2562
+ return corners.map((v) => parseRadiusToPx(v, widthPx) || 0)
2563
+ }
2564
+ // Shorthand string: split on `/` (horizontal / vertical radii) then on
2565
+ // whitespace, parsing each token independently.
2566
+ const raw = typeof style.borderRadius === 'string' ? style.borderRadius.trim() : ''
2567
+ if (!raw) return [0]
2568
+ const tokens = raw
2569
+ .split('/')
2570
+ .join(' ')
2571
+ .split(/\s+/)
2572
+ .filter(Boolean)
2573
+ if (tokens.length <= 1) {
2574
+ return [parseRadiusToPx(raw, widthPx) || 0]
2575
+ }
2576
+ return tokens.map((t) => parseRadiusToPx(t, widthPx) || 0)
2577
+ }
2578
+
2579
+ // ─── Section 5: browser element adapters (live DOM) ─────────────────────────
2580
+
2581
+ function checkElementBordersDOM(el) {
2582
+ const tag = el.tagName.toLowerCase()
2583
+ if (BORDER_SAFE_TAGS.has(tag)) return []
2584
+ const rect = el.getBoundingClientRect()
2585
+ if (rect.width < 20 || rect.height < 20) return []
2586
+ const style = getComputedStyle(el)
2587
+ const widths = {}
2588
+ const colors = {}
2589
+ for (const s of ['Top', 'Right', 'Bottom', 'Left']) {
2590
+ widths[s] = parseFloat(style[`border${s}Width`]) || 0
2591
+ colors[s] = style[`border${s}Color`] || ''
2592
+ }
2593
+ const findings = checkBorders(tag, widths, colors)
2594
+ findings.push(...checkRadiusMixedScales(tag, collectCornerRadii(style, rect.width)))
2595
+ return findings
2596
+ }
2597
+
2598
+ function checkElementColorsDOM(el) {
2599
+ const tag = el.tagName.toLowerCase()
2600
+ const rect = el.getBoundingClientRect()
2601
+ if (rect.width < 10 || rect.height < 10) return []
2602
+ const style = getComputedStyle(el)
2603
+ const directText = [...el.childNodes].filter((n) => n.nodeType === 3).map((n) => n.textContent).join('')
2604
+ const hasDirectText = directText.trim().length > 0
2605
+ return checkColors({
2606
+ tag,
2607
+ textColor: parseRgb(style.color),
2608
+ bgColor: readOwnBackgroundColor(el, style),
2609
+ effectiveBg: resolveBackground(el),
2610
+ fontSize: parseFloat(style.fontSize) || 16,
2611
+ fontWeight: parseInt(style.fontWeight) || 400,
2612
+ hasDirectText,
2613
+ isEmojiOnly: isEmojiOnlyText(directText),
2614
+ bgClip: style.webkitBackgroundClip || style.backgroundClip || '',
2615
+ bgImage: style.backgroundImage || '',
2616
+ classList: el.getAttribute('class') || '',
2617
+ })
2618
+ }
2619
+
2620
+ function checkElementGlowDOM(el) {
2621
+ const tag = el.tagName.toLowerCase()
2622
+ const style = getComputedStyle(el)
2623
+ if (!style.boxShadow || style.boxShadow === 'none') return []
2624
+ const parentBg = el.parentElement ? resolveBackground(el.parentElement) : resolveBackground(el)
2625
+ return checkGlow({ tag, boxShadow: style.boxShadow, effectiveBg: parentBg })
2626
+ }
2627
+
2628
+ function checkElementMotionDOM(el) {
2629
+ const tag = el.tagName.toLowerCase()
2630
+ if (SAFE_TAGS.has(tag)) return []
2631
+ const style = getComputedStyle(el)
2632
+ return checkMotion({
2633
+ tag,
2634
+ transitionProperty: style.transitionProperty || '',
2635
+ animationName: style.animationName || '',
2636
+ timingFunctions: [style.animationTimingFunction, style.transitionTimingFunction].filter(Boolean).join(' '),
2637
+ classList: el.getAttribute('class') || '',
2638
+ })
2639
+ }
2640
+
2641
+ function checkElementTactilePressDOM(el) {
2642
+ const tag = el.tagName.toLowerCase()
2643
+ const style = getComputedStyle(el)
2644
+ return checkTactilePress(
2645
+ tag,
2646
+ (el.getAttribute('role') || '').toLowerCase(),
2647
+ style.transitionProperty || '',
2648
+ el.getAttribute('class') || '',
2649
+ el.hasAttribute && el.hasAttribute('onclick')
2650
+ )
2651
+ }
2652
+
2653
+ function checkElementPillOnButtonDOM(el) {
2654
+ const tag = el.tagName.toLowerCase()
2655
+ const style = getComputedStyle(el)
2656
+ const rect = el.getBoundingClientRect()
2657
+ return checkPillOnButton(
2658
+ tag,
2659
+ (el.getAttribute('role') || '').toLowerCase(),
2660
+ resolveBorderRadiusPx(el, style, rect.width),
2661
+ el.getAttribute('class') || ''
2662
+ )
2663
+ }
2664
+
2665
+ function checkElementLucideIconDOM(el) {
2666
+ if (el.tagName.toLowerCase() !== 'svg') return []
2667
+ return checkLucideIcon(
2668
+ el.getAttribute('viewBox') || '',
2669
+ el.getAttribute('stroke-width') || '',
2670
+ el.getAttribute('stroke-linecap') || '',
2671
+ el.getAttribute('fill') || ''
2672
+ )
2673
+ }
2674
+
2675
+ function checkElementQualityDOM(el) {
2676
+ const tag = el.tagName.toLowerCase()
2677
+ const style = getComputedStyle(el)
2678
+ const hasDirectText = [...el.childNodes].some((n) => n.nodeType === 3 && n.textContent.trim().length > 10)
2679
+ const textLen = el.textContent?.trim().length || 0
2680
+ const fontSize = parseFloat(style.fontSize) || 16
2681
+ const lineHeightPx = resolveLengthPx(style.lineHeight, fontSize)
2682
+ const letterSpacingPx = resolveLengthPx(style.letterSpacing, fontSize)
2683
+ const rect = el.getBoundingClientRect()
2684
+ const viewportWidth = typeof window !== 'undefined' ? window.innerWidth || 0 : 0
2685
+ return checkQuality({
2686
+ el, tag, style, hasDirectText, textLen, fontSize, lineHeightPx, letterSpacingPx, rect, viewportWidth,
2687
+ })
2688
+ }
2689
+
2690
+ function checkPageQualityDOM() {
2691
+ return checkPageQualityFromDoc(document).map((f) => ({ type: f.id, detail: f.snippet }))
2692
+ }
2693
+
2694
+ // ─── Section 6: Node element adapters (static cascade) ──────────────────────
2695
+
2696
+ function checkElementBorders(tag, style, overrides) {
2697
+ const sides = ['Top', 'Right', 'Bottom', 'Left']
2698
+ const widths = {}
2699
+ const colors = {}
2700
+ for (const s of sides) {
2701
+ widths[s] = parseFloat(style[`border${s}Width`]) || 0
2702
+ colors[s] = style[`border${s}Color`] || ''
2703
+ // The static cascade may drop a var()-bearing border shorthand; the
2704
+ // pre-pass override fills the missing side so the side-stripe check runs.
2705
+ if (widths[s] === 0 && overrides && overrides[s]) {
2706
+ widths[s] = overrides[s].width
2707
+ colors[s] = overrides[s].color
2708
+ } else if (colors[s] && colors[s].startsWith('var(') && overrides && overrides[s]) {
2709
+ colors[s] = overrides[s].color
2710
+ }
2711
+ }
2712
+ const findings = checkBorders(tag, widths, colors)
2713
+ findings.push(...checkRadiusMixedScales(tag, collectCornerRadii(style, parseFloat(style.width) || 0)))
2714
+ return findings
2715
+ }
2716
+
2717
+ function checkElementColors(el, style, tag, window) {
2718
+ const directText = [...el.childNodes].filter((n) => n.nodeType === 3).map((n) => n.textContent).join('')
2719
+ const hasDirectText = directText.trim().length > 0
2720
+ return checkColors({
2721
+ tag,
2722
+ textColor: parseRgb(style.color),
2723
+ bgColor: readOwnBackgroundColor(el, style),
2724
+ effectiveBg: resolveBackground(el, window),
2725
+ fontSize: parseFloat(style.fontSize) || 16,
2726
+ fontWeight: parseInt(style.fontWeight) || 400,
2727
+ hasDirectText,
2728
+ isEmojiOnly: isEmojiOnlyText(directText),
2729
+ bgClip: style.webkitBackgroundClip || style.backgroundClip || '',
2730
+ bgImage: style.backgroundImage || '',
2731
+ classList: el.getAttribute?.('class') || el.className || '',
2732
+ })
2733
+ }
2734
+
2735
+ function checkElementGlow(tag, style, effectiveBg) {
2736
+ if (!style.boxShadow || style.boxShadow === 'none') return []
2737
+ return checkGlow({ tag, boxShadow: style.boxShadow, effectiveBg })
2738
+ }
2739
+
2740
+ function checkElementMotion(tag, style) {
2741
+ return checkMotion({
2742
+ tag,
2743
+ transitionProperty: style.transitionProperty || '',
2744
+ animationName: style.animationName || '',
2745
+ timingFunctions: [style.animationTimingFunction, style.transitionTimingFunction].filter(Boolean).join(' '),
2746
+ classList: '',
2747
+ })
2748
+ }
2749
+
2750
+ function checkElementTactilePress(el, style, tag) {
2751
+ return checkTactilePress(
2752
+ tag,
2753
+ (el.getAttribute?.('role') || '').toLowerCase(),
2754
+ style.transitionProperty || '',
2755
+ el.getAttribute?.('class') || '',
2756
+ !!el.getAttribute?.('onclick')
2757
+ )
2758
+ }
2759
+
2760
+ function checkElementPillOnButton(el, style, tag) {
2761
+ return checkPillOnButton(
2762
+ tag,
2763
+ (el.getAttribute?.('role') || '').toLowerCase(),
2764
+ resolveBorderRadiusPx(el, style, parseFloat(style.width) || 0),
2765
+ el.getAttribute?.('class') || ''
2766
+ )
2767
+ }
2768
+
2769
+ function checkElementLucideIcon(el, tag) {
2770
+ if (tag !== 'svg') return []
2771
+ return checkLucideIcon(
2772
+ el.getAttribute?.('viewBox') || '',
2773
+ el.getAttribute?.('stroke-width') || '',
2774
+ el.getAttribute?.('stroke-linecap') || '',
2775
+ el.getAttribute?.('fill') || ''
2776
+ )
2777
+ }
2778
+
2779
+ function checkElementQuality(el, style, tag, window) {
2780
+ const hasDirectText = [...el.childNodes].some((n) => n.nodeType === 3 && n.textContent.trim().length > 10)
2781
+ const textLen = el.textContent?.trim().length || 0
2782
+ const fontSize = resolveFontSizePx(el, window)
2783
+ const lineHeightPx = resolveLengthPx(style.lineHeight, fontSize)
2784
+ const letterSpacingPx = resolveLengthPx(style.letterSpacing, fontSize)
2785
+ // The static cascade does no layout — pass rect: null to skip the three
2786
+ // rect-dependent rules (line-length, cramped-padding, body-text-viewport-edge).
2787
+ return checkQuality({ el, tag, style, hasDirectText, textLen, fontSize, lineHeightPx, letterSpacingPx, rect: null })
2788
+ }
2789
+
2790
+ // ─── Section 7: page-level checks ───────────────────────────────────────────
2791
+
2792
+ /** Skipped-heading walk. Works on any Document (browser or static). */
2793
+ function checkPageQualityFromDoc(doc) {
2794
+ const findings = []
2795
+ const headings = doc.querySelectorAll('h1, h2, h3, h4, h5, h6')
2796
+ let prevLevel = 0
2797
+ let prevText = ''
2798
+ for (const h of headings) {
2799
+ const level = parseInt(h.tagName[1])
2800
+ const text = (h.textContent || '').trim().replace(/\s+/g, ' ').slice(0, 60)
2801
+ if (prevLevel > 0 && level > prevLevel + 1) {
2802
+ findings.push({
2803
+ id: 'skipped-heading',
2804
+ snippet: `<h${prevLevel}> "${prevText}" followed by <h${level}> "${text}" (missing h${prevLevel + 1})`,
2805
+ })
2806
+ }
2807
+ prevLevel = level
2808
+ prevText = text
2809
+ }
2810
+ return findings
2811
+ }
2812
+
2813
+ /** Flat-type-hierarchy page check. */
2814
+ function checkPageTypography(doc, win) {
2815
+ const findings = []
2816
+ const sizes = new Set()
2817
+ for (const el of doc.querySelectorAll('h1, h2, h3, h4, h5, h6, p, span, a, li, td, th, label, button, div')) {
2818
+ const fontSize = parseFloat(win.getComputedStyle(el).fontSize)
2819
+ if (fontSize >= 8 && fontSize < 200) sizes.add(Math.round(fontSize * 10) / 10)
2820
+ }
2821
+ if (sizes.size >= 3) {
2822
+ const sorted = [...sizes].sort((a, b) => a - b)
2823
+ const ratio = sorted[sorted.length - 1] / sorted[0]
2824
+ if (ratio < 2.0) {
2825
+ findings.push({
2826
+ id: 'flat-type-hierarchy',
2827
+ snippet: `Sizes: ${sorted.map((s) => s + 'px').join(', ')} (ratio ${ratio.toFixed(1)}:1)`,
2828
+ })
2829
+ }
2830
+ }
2831
+ return findings
2832
+ }
2833
+
2834
+ function isCardLike(el, win) {
2835
+ const tag = el.tagName.toLowerCase()
2836
+ if (SAFE_TAGS.has(tag) || ['input', 'select', 'textarea', 'img', 'video', 'canvas', 'picture'].includes(tag)) {
2837
+ return false
2838
+ }
2839
+ const style = win.getComputedStyle(el)
2840
+ const rawStyle = el.getAttribute?.('style') || ''
2841
+ const cls = el.getAttribute?.('class') || ''
2842
+ const hasShadow =
2843
+ (style.boxShadow && style.boxShadow !== 'none') ||
2844
+ /\bshadow(?:-sm|-md|-lg|-xl|-2xl)?\b/.test(cls) ||
2845
+ /box-shadow/i.test(rawStyle)
2846
+ const hasBorder = /\bborder\b/.test(cls) || (parseFloat(style.borderTopWidth) || 0) > 0
2847
+ const widthPx = parseFloat(style.width) || 0
2848
+ const hasRadius =
2849
+ resolveBorderRadiusPx(el, style, widthPx) > 0 ||
2850
+ /\brounded(?:-sm|-md|-lg|-xl|-2xl|-full)?\b/.test(cls) ||
2851
+ /border-radius/i.test(rawStyle)
2852
+ const hasBg =
2853
+ /\bbg-(?:white|gray-\d+|slate-\d+)\b/.test(cls) ||
2854
+ /background(?:-color)?\s*:\s*(?!transparent)/i.test(rawStyle) ||
2855
+ (style.backgroundColor && style.backgroundColor !== 'rgba(0, 0, 0, 0)')
2856
+ return isCardLikeFromProps(hasShadow, hasBorder, hasRadius, hasBg)
2857
+ }
2858
+
2859
+ /** Nested-cards and everything-centered page checks. */
2860
+ function checkPageLayout(doc, win) {
2861
+ const findings = []
2862
+
2863
+ const allEls = doc.querySelectorAll('*')
2864
+ const flaggedEls = new Set()
2865
+ for (const el of allEls) {
2866
+ if (!isCardLike(el, win)) continue
2867
+ if (flaggedEls.has(el)) continue
2868
+ const tag = el.tagName.toLowerCase()
2869
+ const cls = el.getAttribute?.('class') || ''
2870
+ const rawStyle = el.getAttribute?.('style') || ''
2871
+ if (['pre', 'code'].includes(tag)) continue
2872
+ if (/\b(?:absolute|fixed)\b/.test(cls) || /position\s*:\s*(?:absolute|fixed)/i.test(rawStyle)) continue
2873
+ if ((el.textContent?.trim().length || 0) < 10) continue
2874
+ if (/\b(?:dropdown|popover|tooltip|menu|modal|dialog)\b/i.test(cls)) continue
2875
+ let parent = el.parentElement
2876
+ while (parent) {
2877
+ if (isCardLike(parent, win)) {
2878
+ flaggedEls.add(el)
2879
+ break
2880
+ }
2881
+ parent = parent.parentElement
2882
+ }
2883
+ }
2884
+ for (const el of flaggedEls) {
2885
+ let isAncestorOfFlagged = false
2886
+ for (const other of flaggedEls) {
2887
+ if (other !== el && el.contains(other)) {
2888
+ isAncestorOfFlagged = true
2889
+ break
2890
+ }
2891
+ }
2892
+ if (!isAncestorOfFlagged) {
2893
+ findings.push({ id: 'nested-cards', snippet: `Card inside card (${el.tagName.toLowerCase()})` })
2894
+ }
2895
+ }
2896
+
2897
+ const textEls = doc.querySelectorAll('h1, h2, h3, h4, h5, h6, p, li, div, button')
2898
+ let centeredCount = 0
2899
+ let totalText = 0
2900
+ for (const el of textEls) {
2901
+ const hasDirectText = [...el.childNodes].some((n) => n.nodeType === 3 && n.textContent.trim().length >= 3)
2902
+ if (!hasDirectText) continue
2903
+ totalText++
2904
+ let cur = el
2905
+ let isCentered = false
2906
+ while (cur && cur.nodeType === 1) {
2907
+ const rawStyle = cur.getAttribute?.('style') || ''
2908
+ const cls = cur.getAttribute?.('class') || ''
2909
+ if (/text-align\s*:\s*center/i.test(rawStyle) || /\btext-center\b/.test(cls)) {
2910
+ isCentered = true
2911
+ break
2912
+ }
2913
+ if (cur.tagName === 'BODY') break
2914
+ cur = cur.parentElement
2915
+ }
2916
+ if (isCentered) centeredCount++
2917
+ }
2918
+ if (totalText >= 5 && centeredCount / totalText > 0.7) {
2919
+ findings.push({
2920
+ id: 'everything-centered',
2921
+ snippet: `${centeredCount}/${totalText} text elements centered (${Math.round((centeredCount / totalText) * 100)}%)`,
2922
+ })
2923
+ }
2924
+
2925
+ return findings
2926
+ }
2927
+
2928
+ // ── cli/engine/browser/injected/index.mjs ──
2929
+ // SPDX-License-Identifier: Apache-2.0
2930
+ // ETUS Digital — Seven Design System. Detector architecture adapted from
2931
+ // impeccable (Paul Bakaus, Apache-2.0). See packages/seven-skill/NOTICE.md.
2932
+
2933
+ /*
2934
+ * In-browser detector. This file is NOT a standalone module — it is
2935
+ * concatenated by build/build-browser-detector.mjs into a single IIFE bundle
2936
+ * (cli/engine/detect-antipatterns-browser.js) together with the registry,
2937
+ * color helpers, constants, and the rule checks. It therefore references
2938
+ * those symbols (`checkElement*DOM`, `checkPageQualityDOM`,
2939
+ * `checkHtmlPatterns`) as free identifiers resolved within the bundle scope.
2940
+ *
2941
+ * The browser engine (engines/browser/detect-url.mjs) injects the bundle into
2942
+ * a Puppeteer page and calls `window.sevenDetect()`. Each element is checked
2943
+ * with the live `getComputedStyle`, so cascade-, layout-, and runtime-CSS-
2944
+ * dependent drift surfaces — the per-element work the static-html engine does
2945
+ * against a resolved cascade, here against the real browser.
2946
+ *
2947
+ * Impeccable's injected script also carries an inspector overlay, decoration,
2948
+ * visual-contrast capture, and the Chrome-extension bridge. Those are
2949
+ * extension UI, not detection — Seven has no extension, so they are omitted.
2950
+ */
2951
+
2952
+ ;(function sevenInstallBrowserDetector() {
2953
+ if (typeof window === 'undefined') return
2954
+
2955
+ function collectFindings() {
2956
+ const out = []
2957
+ const push = (arr) => {
2958
+ if (!arr) return
2959
+ for (const f of arr) out.push({ type: f.id, detail: f.snippet })
2960
+ }
2961
+
2962
+ for (const el of document.querySelectorAll('*')) {
2963
+ if (el === document.body || el === document.documentElement) continue
2964
+ const tag = el.tagName ? el.tagName.toLowerCase() : ''
2965
+ if (tag === 'script' || tag === 'style' || tag === 'head' || tag === 'meta' || tag === 'link') {
2966
+ continue
2967
+ }
2968
+ try {
2969
+ push(checkElementBordersDOM(el))
2970
+ push(checkElementColorsDOM(el))
2971
+ push(checkElementGlowDOM(el))
2972
+ push(checkElementMotionDOM(el))
2973
+ push(checkElementTactilePressDOM(el))
2974
+ push(checkElementPillOnButtonDOM(el))
2975
+ push(checkElementLucideIconDOM(el))
2976
+ push(checkElementQualityDOM(el))
2977
+ } catch {
2978
+ // A malformed element must not abort the whole scan.
2979
+ }
2980
+ }
2981
+
2982
+ try {
2983
+ push(checkPageQualityDOM())
2984
+ } catch {
2985
+ /* page-quality check failed — continue */
2986
+ }
2987
+
2988
+ try {
2989
+ // Regex-on-markup checks over the fully rendered HTML.
2990
+ push(checkHtmlPatterns(document.documentElement.outerHTML))
2991
+ } catch {
2992
+ /* html-pattern check failed — continue */
2993
+ }
2994
+
2995
+ return out
2996
+ }
2997
+
2998
+ /**
2999
+ * Public entry the browser engine calls. Returns a flat array of
3000
+ * { type, detail } findings; detect-url.mjs maps it to normalized findings.
3001
+ */
3002
+ window.sevenDetect = function sevenDetect() {
3003
+ try {
3004
+ return collectFindings()
3005
+ } catch {
3006
+ return []
3007
+ }
3008
+ }
3009
+
3010
+ // Alias kept for parity with the impeccable browser API surface.
3011
+ window.sevenScan = window.sevenDetect
3012
+ })()
3013
+
3014
+ })();