@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.
- package/.claude/skills/seven/SKILL.md +162 -0
- package/.claude/skills/seven/reference/audit.md +120 -0
- package/.claude/skills/seven/reference/brand.md +24 -0
- package/.claude/skills/seven/reference/clarify.md +76 -0
- package/.claude/skills/seven/reference/color-and-contrast.md +84 -0
- package/.claude/skills/seven/reference/motion-design.md +71 -0
- package/.claude/skills/seven/reference/polish.md +55 -0
- package/.claude/skills/seven/reference/product.md +45 -0
- package/.claude/skills/seven/reference/shape.md +85 -0
- package/.claude/skills/seven/reference/spatial-design.md +60 -0
- package/.claude/skills/seven/reference/typography.md +43 -0
- package/.claude/skills/seven/reference/ux-writing.md +47 -0
- package/.claude/skills/seven/scripts/load-context.mjs +84 -0
- package/.claude-plugin/marketplace.json +34 -0
- package/.claude-plugin/plugin.json +12 -0
- package/LICENSE +190 -0
- package/NOTICE.md +26 -0
- package/README.md +118 -0
- package/cli/bin/commands/skills.mjs +664 -0
- package/cli/bin/seven.mjs +68 -0
- package/cli/engine/browser/injected/index.mjs +84 -0
- package/cli/engine/cli/main.mjs +215 -0
- package/cli/engine/detect-antipatterns-browser.js +3014 -0
- package/cli/engine/detect-antipatterns.mjs +44 -0
- package/cli/engine/engines/browser/detect-url.mjs +108 -0
- package/cli/engine/engines/regex/detect-text.mjs +508 -0
- package/cli/engine/engines/static-html/css-cascade.mjs +957 -0
- package/cli/engine/engines/static-html/detect-html.mjs +211 -0
- package/cli/engine/engines/visual/screenshot-contrast.mjs +192 -0
- package/cli/engine/findings.mjs +28 -0
- package/cli/engine/node/file-system.mjs +212 -0
- package/cli/engine/profile/profiler.mjs +169 -0
- package/cli/engine/registry/seven-antipatterns.mjs +494 -0
- package/cli/engine/rules/checks.mjs +1518 -0
- package/cli/engine/shared/color.mjs +204 -0
- package/cli/engine/shared/constants.mjs +91 -0
- package/cli/engine/shared/page.mjs +9 -0
- package/cli/engine/shared/tokens.mjs +153 -0
- package/cli/engine/token-data.generated.mjs +691 -0
- package/docs/detector-rules.md +605 -0
- package/docs/figma-token-rule-exploration.md +185 -0
- 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
|
+
})();
|