@etus/seven-skill 0.1.0-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/.claude/skills/seven/SKILL.md +162 -0
  2. package/.claude/skills/seven/reference/audit.md +120 -0
  3. package/.claude/skills/seven/reference/brand.md +24 -0
  4. package/.claude/skills/seven/reference/clarify.md +76 -0
  5. package/.claude/skills/seven/reference/color-and-contrast.md +84 -0
  6. package/.claude/skills/seven/reference/motion-design.md +71 -0
  7. package/.claude/skills/seven/reference/polish.md +55 -0
  8. package/.claude/skills/seven/reference/product.md +45 -0
  9. package/.claude/skills/seven/reference/shape.md +85 -0
  10. package/.claude/skills/seven/reference/spatial-design.md +60 -0
  11. package/.claude/skills/seven/reference/typography.md +43 -0
  12. package/.claude/skills/seven/reference/ux-writing.md +47 -0
  13. package/.claude/skills/seven/scripts/load-context.mjs +84 -0
  14. package/.claude-plugin/marketplace.json +34 -0
  15. package/.claude-plugin/plugin.json +12 -0
  16. package/LICENSE +190 -0
  17. package/NOTICE.md +26 -0
  18. package/README.md +118 -0
  19. package/cli/bin/commands/skills.mjs +664 -0
  20. package/cli/bin/seven.mjs +68 -0
  21. package/cli/engine/browser/injected/index.mjs +84 -0
  22. package/cli/engine/cli/main.mjs +215 -0
  23. package/cli/engine/detect-antipatterns-browser.js +3014 -0
  24. package/cli/engine/detect-antipatterns.mjs +44 -0
  25. package/cli/engine/engines/browser/detect-url.mjs +108 -0
  26. package/cli/engine/engines/regex/detect-text.mjs +508 -0
  27. package/cli/engine/engines/static-html/css-cascade.mjs +957 -0
  28. package/cli/engine/engines/static-html/detect-html.mjs +211 -0
  29. package/cli/engine/engines/visual/screenshot-contrast.mjs +192 -0
  30. package/cli/engine/findings.mjs +28 -0
  31. package/cli/engine/node/file-system.mjs +212 -0
  32. package/cli/engine/profile/profiler.mjs +169 -0
  33. package/cli/engine/registry/seven-antipatterns.mjs +494 -0
  34. package/cli/engine/rules/checks.mjs +1518 -0
  35. package/cli/engine/shared/color.mjs +204 -0
  36. package/cli/engine/shared/constants.mjs +91 -0
  37. package/cli/engine/shared/page.mjs +9 -0
  38. package/cli/engine/shared/tokens.mjs +153 -0
  39. package/cli/engine/token-data.generated.mjs +691 -0
  40. package/docs/detector-rules.md +605 -0
  41. package/docs/figma-token-rule-exploration.md +185 -0
  42. package/package.json +76 -0
@@ -0,0 +1,204 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // ETUS Digital — Seven Design System. Detector architecture adapted from
3
+ // impeccable (Paul Bakaus, Apache-2.0). See packages/seven-skill/NOTICE.md.
4
+
5
+ // ─── Color utilities ────────────────────────────────────────────────────────
6
+
7
+ export function isNeutralColor(color) {
8
+ if (!color || color === 'transparent') return true
9
+
10
+ // rgb/rgba — channel spread. Threshold 30 is ~11.7% of the 0-255 range.
11
+ const rgb = color.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)/)
12
+ if (rgb) {
13
+ return Math.max(+rgb[1], +rgb[2], +rgb[3]) - Math.min(+rgb[1], +rgb[2], +rgb[3]) < 30
14
+ }
15
+
16
+ // oklch()/lch() — chroma is the second numeric component. Seven's tokens are
17
+ // OKLCH throughout, so this path matters: chroma >= 0.02 reads as tinted.
18
+ const oklch = color.match(/oklch\(\s*[\d.]+%?\s*([\d.-]+)/i)
19
+ if (oklch) return parseFloat(oklch[1]) < 0.02
20
+ const lch = color.match(/lch\(\s*[\d.]+%?\s*([\d.-]+)/i)
21
+ if (lch) return parseFloat(lch[1]) < 3
22
+
23
+ // oklab()/lab() — a and b are signed axes; chroma = hypot(a, b).
24
+ const oklab = color.match(/oklab\(\s*[\d.]+%?\s*([\d.-]+)\s+([\d.-]+)/i)
25
+ if (oklab) return Math.hypot(parseFloat(oklab[1]), parseFloat(oklab[2])) < 0.02
26
+ const lab = color.match(/lab\(\s*[\d.]+%?\s*([\d.-]+)\s+([\d.-]+)/i)
27
+ if (lab) return Math.hypot(parseFloat(lab[1]), parseFloat(lab[2])) < 3
28
+
29
+ // hsl/hsla — saturation is the second numeric component (percent).
30
+ const hsl = color.match(/hsla?\(\s*[\d.-]+\s*,?\s*([\d.]+)%/i)
31
+ if (hsl) return parseFloat(hsl[1]) < 10
32
+
33
+ // hwb(hue whiteness% blackness%) — gray when whiteness + blackness >= 100.
34
+ const hwb = color.match(/hwb\(\s*[\d.-]+\s+([\d.]+)%\s+([\d.]+)%/i)
35
+ if (hwb) {
36
+ const w = parseFloat(hwb[1])
37
+ const b = parseFloat(hwb[2])
38
+ return 1 - Math.min(100, w + b) / 100 < 0.1
39
+ }
40
+
41
+ // Unknown format — err on the side of DETECTING rather than skipping.
42
+ return false
43
+ }
44
+
45
+ export function parseRgb(color) {
46
+ if (!color || color === 'transparent') return null
47
+ const m = color.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)(?:,\s*([\d.]+))?\)/)
48
+ if (!m) return null
49
+ return { r: +m[1], g: +m[2], b: +m[3], a: m[4] === undefined ? 1 : +m[4] }
50
+ }
51
+
52
+ export function parseHex(hex) {
53
+ if (!hex) return null
54
+ const m = hex.match(/^#([0-9a-f]{3,8})$/i)
55
+ if (!m) return null
56
+ const h = m[1]
57
+ if (h.length === 3) {
58
+ return { r: parseInt(h[0] + h[0], 16), g: parseInt(h[1] + h[1], 16), b: parseInt(h[2] + h[2], 16), a: 1 }
59
+ }
60
+ if (h.length === 6 || h.length === 8) {
61
+ return {
62
+ r: parseInt(h.slice(0, 2), 16),
63
+ g: parseInt(h.slice(2, 4), 16),
64
+ b: parseInt(h.slice(4, 6), 16),
65
+ a: h.length === 8 ? parseInt(h.slice(6, 8), 16) / 255 : 1,
66
+ }
67
+ }
68
+ return null
69
+ }
70
+
71
+ export function relativeLuminance({ r, g, b }) {
72
+ const [rs, gs, bs] = [r / 255, g / 255, b / 255].map((c) =>
73
+ c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4
74
+ )
75
+ return 0.2126 * rs + 0.7152 * gs + 0.0722 * bs
76
+ }
77
+
78
+ export function contrastRatio(c1, c2) {
79
+ const l1 = relativeLuminance(c1)
80
+ const l2 = relativeLuminance(c2)
81
+ return (Math.max(l1, l2) + 0.05) / (Math.min(l1, l2) + 0.05)
82
+ }
83
+
84
+ export function hasChroma(c, threshold = 30) {
85
+ if (!c) return false
86
+ return Math.max(c.r, c.g, c.b) - Math.min(c.r, c.g, c.b) >= threshold
87
+ }
88
+
89
+ export function getHue(c) {
90
+ if (!c) return 0
91
+ const r = c.r / 255
92
+ const g = c.g / 255
93
+ const b = c.b / 255
94
+ const max = Math.max(r, g, b)
95
+ const min = Math.min(r, g, b)
96
+ if (max === min) return 0
97
+ const d = max - min
98
+ let h
99
+ if (max === r) h = ((g - b) / d + (g < b ? 6 : 0)) / 6
100
+ else if (max === g) h = ((b - r) / d + 2) / 6
101
+ else h = ((r - g) / d + 4) / 6
102
+ return Math.round(h * 360)
103
+ }
104
+
105
+ export function colorToHex(c) {
106
+ if (!c) return '?'
107
+ return '#' + [c.r, c.g, c.b].map((v) => v.toString(16).padStart(2, '0')).join('')
108
+ }
109
+
110
+ /**
111
+ * Fuchsia / magenta detection. Seven reserves fuchsia for charts; on UI chrome
112
+ * it is an anti-pattern. A color is "fuchsia" when it has real chroma and its
113
+ * hue sits in the magenta band (roughly 280-340 degrees).
114
+ */
115
+ export function isFuchsia(c) {
116
+ if (!c || !hasChroma(c, 40)) return false
117
+ const hue = getHue(c)
118
+ return hue >= 280 && hue <= 340
119
+ }
120
+
121
+ /**
122
+ * OKLCH -> sRGB. Seven's color tokens are OKLCH; matching them to hex/rgb
123
+ * literals in code needs this conversion. Follows the CSS Color 4 / Björn
124
+ * Ottosson reference. L is 0-1, C is chroma, H is degrees.
125
+ */
126
+ export function oklchToRgb(L, C, H) {
127
+ const hRad = (H * Math.PI) / 180
128
+ const a = C * Math.cos(hRad)
129
+ const b = C * Math.sin(hRad)
130
+ const l_ = L + 0.3963377774 * a + 0.2158037573 * b
131
+ const m_ = L - 0.1055613458 * a - 0.0638541728 * b
132
+ const s_ = L - 0.0894841775 * a - 1.291485548 * b
133
+ const l = l_ ** 3
134
+ const m = m_ ** 3
135
+ const s = s_ ** 3
136
+ const lr = 4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s
137
+ const lg = -1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s
138
+ const lb = -0.0041960863 * l - 0.7034186147 * m + 1.707614701 * s
139
+ const gamma = (x) => (x <= 0.0031308 ? 12.92 * x : 1.055 * Math.pow(x, 1 / 2.4) - 0.055)
140
+ return [gamma(lr), gamma(lg), gamma(lb)].map((v) => Math.round(Math.max(0, Math.min(1, v)) * 255))
141
+ }
142
+
143
+ /** Canonical form for an exact-string token match: trimmed, lowercased,
144
+ * whitespace-collapsed. Shared by the token-data build step and the runtime
145
+ * accessor. */
146
+ export function normalizeRaw(value) {
147
+ return String(value).trim().toLowerCase().replace(/\s+/g, ' ')
148
+ }
149
+
150
+ /**
151
+ * Reduce any color literal (oklch, hex, rgb/rgba) to a canonical `r,g,b,a`
152
+ * key. Shared by the token-data build step and the runtime token accessor so
153
+ * a literal in consumer code matches a Seven token regardless of format.
154
+ * Returns null when the value is not a recognized color.
155
+ */
156
+ export function colorToRgbaKey(value) {
157
+ if (!value || typeof value !== 'string') return null
158
+ const v = value.trim().toLowerCase()
159
+
160
+ const oklch = v.match(/oklch\(\s*([\d.]+)(%?)\s+([\d.]+)\s+([\d.]+)(?:\s*\/\s*([\d.]+)(%?))?\s*\)/)
161
+ if (oklch) {
162
+ const L = oklch[2] === '%' ? parseFloat(oklch[1]) / 100 : parseFloat(oklch[1])
163
+ const C = parseFloat(oklch[3])
164
+ const H = parseFloat(oklch[4])
165
+ let alpha = 1
166
+ if (oklch[5] !== undefined) {
167
+ alpha = oklch[6] === '%' ? parseFloat(oklch[5]) / 100 : parseFloat(oklch[5])
168
+ }
169
+ const [r, g, b] = oklchToRgb(L, C, H)
170
+ return `${r},${g},${b},${alpha.toFixed(2)}`
171
+ }
172
+
173
+ const hex = v.match(/^#([0-9a-f]{3,8})$/)
174
+ if (hex) {
175
+ const h = hex[1]
176
+ let r
177
+ let g
178
+ let b
179
+ let a = 1
180
+ if (h.length === 3 || h.length === 4) {
181
+ r = parseInt(h[0] + h[0], 16)
182
+ g = parseInt(h[1] + h[1], 16)
183
+ b = parseInt(h[2] + h[2], 16)
184
+ if (h.length === 4) a = parseInt(h[3] + h[3], 16) / 255
185
+ } else if (h.length === 6 || h.length === 8) {
186
+ r = parseInt(h.slice(0, 2), 16)
187
+ g = parseInt(h.slice(2, 4), 16)
188
+ b = parseInt(h.slice(4, 6), 16)
189
+ if (h.length === 8) a = parseInt(h.slice(6, 8), 16) / 255
190
+ } else {
191
+ return null
192
+ }
193
+ return `${r},${g},${b},${a.toFixed(2)}`
194
+ }
195
+
196
+ const rgb = v.match(/rgba?\(\s*(\d+)\s*[, ]\s*(\d+)\s*[, ]\s*(\d+)\s*(?:[,/]\s*([\d.]+)(%?))?\s*\)/)
197
+ if (rgb) {
198
+ let a = 1
199
+ if (rgb[4] !== undefined) a = rgb[5] === '%' ? parseFloat(rgb[4]) / 100 : parseFloat(rgb[4])
200
+ return `${+rgb[1]},${+rgb[2]},${+rgb[3]},${a.toFixed(2)}`
201
+ }
202
+
203
+ return null
204
+ }
@@ -0,0 +1,91 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // ETUS Digital — Seven Design System. Detector architecture adapted from
3
+ // impeccable (Paul Bakaus, Apache-2.0). See packages/seven-skill/NOTICE.md.
4
+
5
+ // ─── Seven-specific catalogs ────────────────────────────────────────────────
6
+
7
+ /**
8
+ * The only sans face allowed in Seven. Note this INVERTS impeccable's
9
+ * `OVERUSED_FONTS` rule: impeccable flags Inter as overused, Seven mandates it.
10
+ */
11
+ export const INTER_FAMILY_NAMES = new Set([
12
+ 'inter',
13
+ 'inter-variable',
14
+ 'intervariable',
15
+ 'inter var',
16
+ ])
17
+
18
+ /** The only mono face allowed in Seven. */
19
+ export const JETBRAINS_MONO_FAMILY_NAMES = new Set([
20
+ 'jetbrains mono',
21
+ 'jetbrains-mono',
22
+ 'jetbrainsmono',
23
+ ])
24
+
25
+ /** Generic / system fallbacks that never count as a non-Inter face. */
26
+ export const SYSTEM_FONT_FALLBACK_TOKENS = new Set([
27
+ 'system-ui',
28
+ '-apple-system',
29
+ 'blinkmacsystemfont',
30
+ 'segoe ui',
31
+ 'roboto',
32
+ 'helvetica',
33
+ 'helvetica neue',
34
+ 'arial',
35
+ 'sans-serif',
36
+ 'serif',
37
+ 'monospace',
38
+ 'ui-monospace',
39
+ 'ui-sans-serif',
40
+ 'ui-serif',
41
+ 'ui-rounded',
42
+ 'menlo',
43
+ 'consolas',
44
+ 'courier new',
45
+ 'inherit',
46
+ 'initial',
47
+ 'unset',
48
+ 'revert',
49
+ ])
50
+
51
+ /** HTML tags whose own styling never trips element-level rules. */
52
+ export const SAFE_TAGS = new Set([
53
+ 'html', 'head', 'body', 'script', 'style', 'link', 'meta', 'title', 'br',
54
+ 'svg', 'path', 'circle', 'rect', 'line', 'polyline', 'polygon', 'g', 'defs', 'use',
55
+ ])
56
+
57
+ /** European Portuguese vocabulary that must not appear in pt-BR chrome. */
58
+ export const PT_PT_VOCAB_PAIRS = [
59
+ { ptPt: 'ficheiro', ptBr: 'arquivo' },
60
+ { ptPt: 'ficheiros', ptBr: 'arquivos' },
61
+ { ptPt: 'telemóvel', ptBr: 'celular' },
62
+ { ptPt: 'telemóveis', ptBr: 'celulares' },
63
+ { ptPt: 'ecrã', ptBr: 'tela' },
64
+ { ptPt: 'ecrãs', ptBr: 'telas' },
65
+ { ptPt: 'rato', ptBr: 'mouse' },
66
+ { ptPt: 'autocarro', ptBr: 'ônibus' },
67
+ { ptPt: 'gestor', ptBr: 'gerente' },
68
+ { ptPt: 'utilizador', ptBr: 'usuário' },
69
+ { ptPt: 'utilizadores', ptBr: 'usuários' },
70
+ { ptPt: 'sítio', ptBr: 'site' },
71
+ { ptPt: 'apagar', ptBr: 'excluir' },
72
+ ]
73
+
74
+ /**
75
+ * Package names whose source is exempt from rules that would mis-fire on
76
+ * token-defining or skill-defining code. Resolved by walking up to the
77
+ * nearest package.json — works inside the monorepo and inside a consumer's
78
+ * node_modules alike.
79
+ */
80
+ export const SEVEN_INTERNAL_PACKAGES = new Set(['@etus/ui', '@etus/tokens', '@etus/seven-skill'])
81
+
82
+ /** Path segments whose files define tokens and may carry raw color literals. */
83
+ export const TOKEN_DEFINITION_PATH_SEGMENTS = [
84
+ 'packages/tokens/sources/',
85
+ 'packages/tokens/sd/',
86
+ 'packages/tokens/dist/',
87
+ 'docs/tokens-json/',
88
+ ]
89
+
90
+ /** Test fixtures are always scanned regardless of the package they sit in. */
91
+ export const FIXTURES_PATH_SEGMENT = '/tests/fixtures/'
@@ -0,0 +1,9 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // ETUS Digital — Seven Design System. Detector architecture adapted from
3
+ // impeccable (Paul Bakaus, Apache-2.0). See packages/seven-skill/NOTICE.md.
4
+
5
+ /** True when the content looks like a full HTML page, not a component partial. */
6
+ export function isFullPage(content) {
7
+ const stripped = content.replace(/<!--[\s\S]*?-->/g, '')
8
+ return /<!doctype\s|<html[\s>]|<head[\s>]/i.test(stripped)
9
+ }
@@ -0,0 +1,153 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // ETUS Digital — Seven Design System. Detector architecture adapted from
3
+ // impeccable (Paul Bakaus, Apache-2.0). See packages/seven-skill/NOTICE.md.
4
+
5
+ /*
6
+ * Token accessor. The single API the rule layer uses to consult Seven's
7
+ * design tokens. It wraps the build-time snapshot (token-data.generated.mjs,
8
+ * derived from @etus/tokens) behind semantic accessors so the rules never see
9
+ * the snapshot's raw shape — the snapshot can change without touching a rule.
10
+ */
11
+
12
+ import { TOKEN_DATA } from '../token-data.generated.mjs'
13
+ import { colorToRgbaKey, normalizeRaw } from './color.mjs'
14
+
15
+ /** True when the snapshot carries usable data. Rules fall back to heuristics
16
+ * if this is ever false (defensive — not expected in a built package). */
17
+ export function tokenDataAvailable() {
18
+ return Boolean(TOKEN_DATA && Object.keys(TOKEN_DATA.colorByRgba || {}).length > 0)
19
+ }
20
+
21
+ // ─── Color ──────────────────────────────────────────────────────────────────
22
+
23
+ /**
24
+ * Resolve a literal color (hex, rgb, oklch) to the Seven token cssVar that
25
+ * declares it, or null when no token matches. Format-insensitive: a hex
26
+ * literal resolves the same OKLCH token as the OKLCH form.
27
+ */
28
+ export function colorToCssVar(rawValue) {
29
+ if (!rawValue) return null
30
+ const key = colorToRgbaKey(rawValue)
31
+ if (key && TOKEN_DATA.colorByRgba[key]) return TOKEN_DATA.colorByRgba[key]
32
+ const raw = normalizeRaw(rawValue)
33
+ return TOKEN_DATA.colorByRaw[raw] || null
34
+ }
35
+
36
+ /** True when the literal color is a declared Seven token value. */
37
+ export function isKnownTokenColor(rawValue) {
38
+ return colorToCssVar(rawValue) !== null
39
+ }
40
+
41
+ // ─── Typography ─────────────────────────────────────────────────────────────
42
+
43
+ /**
44
+ * The sans/mono families Seven sanctions. Derived from the `fontFamily`
45
+ * tokens, plus JetBrains Mono — Seven's documented code face, which the
46
+ * current token catalog does not yet declare as a `fontFamily` token.
47
+ */
48
+ export function allowedFontFamilies() {
49
+ return [...new Set([...TOKEN_DATA.fontFamilies, 'jetbrains mono'])]
50
+ }
51
+
52
+ export function isAllowedFontFamily(name) {
53
+ if (!name) return false
54
+ return allowedFontFamilies().includes(String(name).trim().toLowerCase())
55
+ }
56
+
57
+ export function fontSizesPx() {
58
+ return TOKEN_DATA.fontSizesPx
59
+ }
60
+
61
+ /** The smallest declared font size — the floor `tiny-text` enforces. */
62
+ export function minFontSizePx() {
63
+ return TOKEN_DATA.fontSizesPx.length > 0 ? TOKEN_DATA.fontSizesPx[0] : 12
64
+ }
65
+
66
+ export function lineHeights() {
67
+ return TOKEN_DATA.lineHeights
68
+ }
69
+
70
+ export function letterSpacings() {
71
+ return TOKEN_DATA.letterSpacings
72
+ }
73
+
74
+ // ─── Shape ──────────────────────────────────────────────────────────────────
75
+
76
+ export function radiusScale() {
77
+ return TOKEN_DATA.radii
78
+ }
79
+
80
+ export function componentRadiiPx() {
81
+ return TOKEN_DATA.radii.component
82
+ }
83
+
84
+ export function surfaceRadiiPx() {
85
+ return TOKEN_DATA.radii.surface
86
+ }
87
+
88
+ export function pillRadiiPx() {
89
+ return TOKEN_DATA.radii.pill
90
+ }
91
+
92
+ export function spacingScalePx() {
93
+ return TOKEN_DATA.spacingPx
94
+ }
95
+
96
+ // ─── Motion ─────────────────────────────────────────────────────────────────
97
+
98
+ /** Motion duration tokens as `{ cssVar: value }`. */
99
+ export function motionDurations() {
100
+ return TOKEN_DATA.durations
101
+ }
102
+
103
+ /** Easing tokens as `{ cssVar: cubic-bezier-value }`. Includes `--ease-bounce`
104
+ * and `--ease-spring` — Seven's catalog does declare them, so a bounce easing
105
+ * is drift only when it is NOT one of these sanctioned tokens. */
106
+ export function easingTokens() {
107
+ return TOKEN_DATA.easings
108
+ }
109
+
110
+ /** The set of sanctioned cubic-bezier values, normalized for comparison. */
111
+ export function sanctionedEasingValues() {
112
+ return new Set(Object.values(TOKEN_DATA.easings).map((v) => normalizeRaw(v)))
113
+ }
114
+
115
+ /** Sanctioned easing tokens parsed to [x1, y1, x2, y2] tuples. A cubic-bezier
116
+ * in code that matches one of these is a Seven easing token value, not drift —
117
+ * even when its curve overshoots (the catalog declares --ease-bounce). */
118
+ export function sanctionedEasingBeziers() {
119
+ const out = []
120
+ for (const value of Object.values(TOKEN_DATA.easings)) {
121
+ const m = String(value).match(
122
+ /cubic-bezier\(\s*([-\d.]+)\s*,\s*([-\d.]+)\s*,\s*([-\d.]+)\s*,\s*([-\d.]+)\s*\)/
123
+ )
124
+ if (m) out.push([+m[1], +m[2], +m[3], +m[4]])
125
+ }
126
+ return out
127
+ }
128
+
129
+ // ─── Elevation ──────────────────────────────────────────────────────────────
130
+
131
+ export function shadowTokens() {
132
+ return TOKEN_DATA.shadows
133
+ }
134
+
135
+ // ─── Semantic roles ─────────────────────────────────────────────────────────
136
+
137
+ /**
138
+ * The Seven semantic-color token for a role (success, destructive, warning,
139
+ * info, muted, accent, ...). Prefers a fill/background variant, then the bare
140
+ * role token. Returns null when no semantic token carries that role — used to
141
+ * point a Tailwind palette utility at its Seven equivalent.
142
+ */
143
+ export function suggestSemanticToken(role) {
144
+ const vars = TOKEN_DATA.semanticColorVars || []
145
+ const matches = vars.filter((v) => v.includes(role))
146
+ if (matches.length === 0) return null
147
+ return (
148
+ matches.find((v) => v.endsWith('-background')) ||
149
+ matches.find((v) => v === `--${role}`) ||
150
+ matches.find((v) => !v.includes('-foreground') && !v.includes('-border')) ||
151
+ matches[0]
152
+ )
153
+ }