@talelk/newui-tailwind 0.0.0-stage → 1.0.0

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.
@@ -0,0 +1,141 @@
1
+ import { readFileSync } from 'node:fs'
2
+ import path from 'node:path'
3
+ import {
4
+ colors,
5
+ duration,
6
+ easing,
7
+ fontSize,
8
+ radius,
9
+ semanticColors,
10
+ shadows,
11
+ spacing,
12
+ tracking,
13
+ zIndex,
14
+ } from '@talelk/newui-tokens'
15
+ import { describe, expect, it } from 'vitest'
16
+
17
+ /**
18
+ * Generator parity: `@talelk/newui-tokens` data ↔ generated `@theme`.
19
+ *
20
+ * `scripts/build-css.ts` derives `theme.css` from the token data, so the
21
+ * expected vocabulary is derived here the same way instead of hand-listed.
22
+ * A renamed generator branch or a dropped token family then fails here
23
+ * instead of silently compiling a component's utility to nothing.
24
+ *
25
+ * This is the data→CSS direction only. The reverse direction (every token a
26
+ * component *references* exists) is `tokens.test.ts`; nothing is asserted
27
+ * twice.
28
+ */
29
+ const themeCss = readFileSync(
30
+ path.join(process.cwd(), 'packages/tailwind/src/generated/theme.css'),
31
+ 'utf8',
32
+ )
33
+
34
+ // Every custom property theme.css declares (`--spacing-0.5` needs the dot).
35
+ const defined = new Set<string>(
36
+ [...themeCss.matchAll(/^\s*(--[a-z0-9.-]+):/gm)].flatMap((m) =>
37
+ m[1] ? [m[1]] : [],
38
+ ),
39
+ )
40
+
41
+ // Mirrors build-css.ts: semantic categories, primitive + dark ramps, overlay
42
+ // ramps, then the hand-added chart series (build-css.ts chart branch).
43
+ function expectedColors(): Set<string> {
44
+ const names = new Set<string>()
45
+ for (const [category, entries] of Object.entries(semanticColors.light)) {
46
+ for (const name of Object.keys(entries)) {
47
+ names.add(`--color-${category}-${name}`)
48
+ }
49
+ }
50
+ for (const [family, shades] of Object.entries(colors.light)) {
51
+ for (const shade of Object.keys(shades)) {
52
+ names.add(`--color-${family}-${shade}`)
53
+ }
54
+ }
55
+ for (const [family, shades] of Object.entries(colors.dark)) {
56
+ for (const shade of Object.keys(shades)) {
57
+ names.add(`--color-dark-${family}-${shade}`)
58
+ }
59
+ }
60
+ for (const [name, shades] of Object.entries(colors.overlay)) {
61
+ if (typeof shades !== 'object') continue
62
+ for (const shade of Object.keys(shades)) {
63
+ names.add(`--color-${name}-overlay-${shade}`)
64
+ }
65
+ }
66
+ for (let index = 1; index <= 8; index += 1) {
67
+ names.add(`--color-chart-${index}`)
68
+ }
69
+ names.add('--color-chart-grid')
70
+ names.add('--color-chart-axis')
71
+ return names
72
+ }
73
+
74
+ function expectedShadows(): Set<string> {
75
+ const names = new Set<string>()
76
+ for (const key of Object.keys(shadows)) {
77
+ if (key === 'none') continue
78
+ names.add(key === 'DEFAULT' ? '--shadow' : `--shadow-${key}`)
79
+ }
80
+ return names
81
+ }
82
+
83
+ const WEIGHTS = ['medium', 'semibold', 'bold'] as const
84
+
85
+ // `--text-<size>` always; `--text-<size>-<weight>` only where the tracking
86
+ // table has an entry for that size+weight (build-css.ts typography branch).
87
+ function expectedText(): Set<string> {
88
+ const names = new Set<string>()
89
+ for (const key of Object.keys(fontSize)) {
90
+ names.add(`--text-${key}`)
91
+ const isParagraph = key.startsWith('p-')
92
+ const group = isParagraph ? 'paragraph' : 'text'
93
+ const size = isParagraph ? key.slice(2) : key
94
+ const byWeight = tracking[group]?.[size]
95
+ if (!byWeight) continue
96
+ for (const weight of WEIGHTS) {
97
+ if (byWeight[weight] !== undefined) names.add(`--text-${key}-${weight}`)
98
+ }
99
+ }
100
+ return names
101
+ }
102
+
103
+ describe('generated theme parity with @talelk/newui-tokens', () => {
104
+ it('emits every derived --color-* token', () => {
105
+ const missing = [...expectedColors()].filter((name) => !defined.has(name))
106
+ expect(missing).toEqual([])
107
+ })
108
+
109
+ it('defines no --color-* token the data does not derive', () => {
110
+ const expected = expectedColors()
111
+ const extra = [...defined].filter(
112
+ (name) => name.startsWith('--color-') && !expected.has(name),
113
+ )
114
+ expect(extra).toEqual([])
115
+ })
116
+
117
+ it('emits every radius, spacing and shadow token', () => {
118
+ const expected = [
119
+ ...Object.keys(radius).map((key) => `--radius-${key}`),
120
+ ...Object.keys(spacing).map((key) => `--spacing-${key}`),
121
+ ...expectedShadows(),
122
+ ]
123
+ const missing = expected.filter((name) => !defined.has(name))
124
+ expect(missing).toEqual([])
125
+ })
126
+
127
+ it('emits every derived text-size token', () => {
128
+ const missing = [...expectedText()].filter((name) => !defined.has(name))
129
+ expect(missing).toEqual([])
130
+ })
131
+
132
+ it('emits the stacking and motion scales', () => {
133
+ const expected = [
134
+ ...Object.keys(zIndex).map((key) => `--z-${key}`),
135
+ ...Object.keys(duration).map((key) => `--duration-${key}`),
136
+ ...Object.keys(easing).map((key) => `--ease-${key}`),
137
+ ]
138
+ const missing = expected.filter((name) => !defined.has(name))
139
+ expect(missing).toEqual([])
140
+ })
141
+ })
@@ -0,0 +1,67 @@
1
+ import { readdirSync, readFileSync, statSync } from 'node:fs'
2
+ import path from 'node:path'
3
+ import { describe, expect, it } from 'vitest'
4
+
5
+ /**
6
+ * Referenced-but-undefined animation guard.
7
+ *
8
+ * A component can reference `animate-[name_…]` while no `@keyframes name`
9
+ * exists anywhere — the class compiles, the utility is emitted, and the element
10
+ * simply never animates. That is a silent no-op no other test catches (it is how
11
+ * the indeterminate Progress bar shipped dead once). This asserts every custom
12
+ * animation a component names is actually defined.
13
+ */
14
+ const TAILWIND_SRC = path.join(process.cwd(), 'packages/tailwind/src')
15
+ const VISUAL_SRCS = [
16
+ 'atoms',
17
+ 'molecules',
18
+ 'organisms',
19
+ 'templates',
20
+ 'charts',
21
+ 'editor',
22
+ 'code-editor',
23
+ ].map((pkg) => path.join(process.cwd(), `packages/${pkg}/src`))
24
+ const TAILWIND_BUILTINS = new Set(['spin', 'pulse', 'ping', 'bounce'])
25
+
26
+ function walk(dir: string, out: string[] = []): string[] {
27
+ for (const entry of readdirSync(dir)) {
28
+ const full = path.join(dir, entry)
29
+ if (statSync(full).isDirectory()) walk(full, out)
30
+ else if (/\.(tsx?|css)$/.test(entry)) out.push(full)
31
+ }
32
+ return out
33
+ }
34
+
35
+ const defined = new Set<string>()
36
+ for (const file of walk(TAILWIND_SRC)) {
37
+ const source = readFileSync(file, 'utf8')
38
+ for (const match of source.matchAll(/@keyframes\s+([\w-]+)/g)) {
39
+ const name = match[1]
40
+ if (name) defined.add(name)
41
+ }
42
+ }
43
+
44
+ const referenced = new Map<string, string>()
45
+ for (const file of VISUAL_SRCS.flatMap((dir) => walk(dir))) {
46
+ const source = readFileSync(file, 'utf8')
47
+ for (const match of source.matchAll(/animate-\[([\w-]+?)_/g)) {
48
+ const name = match[1]
49
+ if (name) referenced.set(name, path.relative(process.cwd(), file))
50
+ }
51
+ }
52
+
53
+ describe('animation keyframes', () => {
54
+ it('defines every custom animation a component references', () => {
55
+ const missing: string[] = []
56
+ for (const [name, file] of referenced) {
57
+ if (TAILWIND_BUILTINS.has(name)) continue
58
+ if (!defined.has(name)) missing.push(`${name} (referenced in ${file})`)
59
+ }
60
+ expect(missing).toEqual([])
61
+ })
62
+
63
+ it('respects prefers-reduced-motion', () => {
64
+ const css = readFileSync(path.join(TAILWIND_SRC, 'utilities.css'), 'utf8')
65
+ expect(css).toContain('@media (prefers-reduced-motion: reduce)')
66
+ })
67
+ })
@@ -0,0 +1,87 @@
1
+ import fs from 'node:fs'
2
+ import path from 'node:path'
3
+ import plugin from 'tailwindcss/plugin'
4
+
5
+ /**
6
+ * Exposes every SVG in `iconsDir` as a `<prefix>-<name>` utility that masks the
7
+ * icon with the current text color:
8
+ *
9
+ * <span class="lucide-menu size-4 text-ink-gray-6" />
10
+ *
11
+ * Tailwind v4 removed `matchComponents`, so this registers as a normal utility
12
+ * (`matchUtilities`). The icon utility deliberately sets NO width/height — the
13
+ * call site always pairs it with a `size-*` — which dodges the layer-order race
14
+ * v3 solved by putting icons in the components layer.
15
+ *
16
+ * @param {object} options
17
+ * @param {string} options.prefix
18
+ * @param {string} options.iconsDir
19
+ * @param {number} [options.normalizeStrokeWidth]
20
+ * @param {string} [options.defaultColor]
21
+ */
22
+ export function iconPackPlugin({
23
+ prefix,
24
+ iconsDir,
25
+ normalizeStrokeWidth,
26
+ defaultColor,
27
+ }) {
28
+ if (!prefix) throw new Error('iconPackPlugin: `prefix` is required')
29
+ if (!iconsDir) throw new Error('iconPackPlugin: `iconsDir` is required')
30
+
31
+ const cache = new Map()
32
+
33
+ function encode(name) {
34
+ if (cache.has(name)) return cache.get(name)
35
+ const file = path.join(iconsDir, `${name}.svg`)
36
+ let uri = null
37
+ if (fs.existsSync(file)) {
38
+ let svg = fs.readFileSync(file, 'utf8')
39
+ if (normalizeStrokeWidth != null) {
40
+ svg = svg.replace(
41
+ /stroke-width="[^"]+"/g,
42
+ `stroke-width="${normalizeStrokeWidth}"`,
43
+ )
44
+ }
45
+ svg = svg.replace(/\s+/g, ' ').trim()
46
+ uri = `url("data:image/svg+xml;utf8,${encodeURIComponent(svg)}")`
47
+ }
48
+ cache.set(name, uri)
49
+ return uri
50
+ }
51
+
52
+ function available() {
53
+ try {
54
+ return fs
55
+ .readdirSync(iconsDir)
56
+ .filter((f) => f.endsWith('.svg'))
57
+ .map((f) => f.slice(0, -4))
58
+ } catch {
59
+ return []
60
+ }
61
+ }
62
+
63
+ return plugin(({ matchUtilities }) => {
64
+ const values = Object.fromEntries(available().map((n) => [n, n]))
65
+ matchUtilities(
66
+ {
67
+ [prefix]: (value) => {
68
+ const mask = encode(value)
69
+ if (!mask) return {}
70
+ return {
71
+ display: 'block',
72
+ 'background-color': defaultColor ?? 'currentColor',
73
+ '-webkit-mask-image': mask,
74
+ 'mask-image': mask,
75
+ '-webkit-mask-repeat': 'no-repeat',
76
+ 'mask-repeat': 'no-repeat',
77
+ '-webkit-mask-position': 'center',
78
+ 'mask-position': 'center',
79
+ '-webkit-mask-size': 'contain',
80
+ 'mask-size': 'contain',
81
+ }
82
+ },
83
+ },
84
+ { values },
85
+ )
86
+ })
87
+ }
@@ -0,0 +1,28 @@
1
+ import { createRequire } from 'node:module'
2
+ import path from 'node:path'
3
+ import plugin from 'tailwindcss/plugin'
4
+ import { iconPackPlugin } from './icon-pack.js'
5
+
6
+ // Resolve lucide-static's icons directory once. Guarded: a consumer that does
7
+ // not install lucide-static should still get a working stylesheet, just with
8
+ // no `lucide-*` utilities.
9
+ let iconsDir = null
10
+ try {
11
+ const nodeRequire = createRequire(import.meta.url)
12
+ iconsDir = path.join(
13
+ path.dirname(nodeRequire.resolve('lucide-static/package.json')),
14
+ 'icons',
15
+ )
16
+ } catch {
17
+ iconsDir = null
18
+ }
19
+
20
+ // Lucide ships every icon at stroke-width="2"; 1.5 matches the rest of the
21
+ // design system. No defaultColor — icons inherit the parent's text color.
22
+ export default iconsDir
23
+ ? iconPackPlugin({
24
+ prefix: 'lucide',
25
+ iconsDir,
26
+ normalizeStrokeWidth: 1.5,
27
+ })
28
+ : plugin(() => {})
@@ -0,0 +1,89 @@
1
+ import plugin from 'tailwindcss/plugin'
2
+
3
+ /**
4
+ * Base layer for the design system.
5
+ *
6
+ * In the v3 layer (frappe-ui's `plugin.js`) this also minted the color theme,
7
+ * text styles and focus-ring components. In v4 all of those come from
8
+ * `@theme` (generated) and `@utility` (utilities.css), so this plugin only
9
+ * carries what genuinely has to live in the base layer.
10
+ */
11
+ export default plugin(({ addBase }) => {
12
+ addBase({
13
+ // `@tailwindcss/forms` is loaded before this plugin and resets every native
14
+ // text control: a 1px `gray-500` border, a white fill, `padding: 0.5rem
15
+ // 0.75rem`, a 16px font and a blue focus ring. Our recipes own all of that,
16
+ // so the reset only ever leaks into the controls that deliberately want
17
+ // none of it — the bare `<input>` inside Combobox / MultiSelect /
18
+ // DatePicker / TimePicker / DateRangePicker / DateTimePicker, which
19
+ // otherwise renders as a bordered 16px box inside the field's own 14px
20
+ // surface. Neutralised once, here, rather than restated in every recipe;
21
+ // utilities still win, so a recipe that asks for a border, padding or a
22
+ // size gets it. Checkboxes, radios and file inputs are left to the plugin —
23
+ // those *are* its widgets.
24
+ "input:not([type='checkbox'], [type='radio'], [type='file']), textarea, select":
25
+ {
26
+ 'border-width': '0',
27
+ 'border-radius': '0',
28
+ 'background-color': 'transparent',
29
+ padding: '0',
30
+ 'font-size': 'inherit',
31
+ 'line-height': 'inherit',
32
+ },
33
+ // The plugin's `:focus` rule paints a 1px blue ring on the control itself.
34
+ // Focus indication belongs to the field (`focus-within:focus-ring`), so the
35
+ // inner control must not draw a second, clipped ring.
36
+ "input:not([type='checkbox'], [type='radio'], [type='file']):focus, textarea:focus, select:focus":
37
+ {
38
+ 'box-shadow': 'none',
39
+ },
40
+ body: {
41
+ 'font-size': 'var(--text-interface)',
42
+ 'line-height': 'var(--text-interface--line-height)',
43
+ },
44
+ html: {
45
+ 'font-family': 'InterVar, ui-sans-serif, system-ui, sans-serif',
46
+ 'font-optical-sizing': 'auto',
47
+ },
48
+ 'html, body, button, p, span, div': {
49
+ fontVariationSettings: "'opsz' 24, 'cv11' 1",
50
+ WebkitFontSmoothing: 'antialiased',
51
+ MozOsxFontSmoothing: 'grayscale',
52
+ },
53
+ // A bare <p> reads as body copy. At element specificity (0,0,1) with
54
+ // :where() so any explicit text-* / text-p-* line-height wins.
55
+ ':where(p)': {
56
+ 'line-height': '1.6',
57
+ },
58
+ // The baseline focus ring, keyboard-only. `:where()` gives it zero
59
+ // specificity, so a component's own `focus-ring` utility (or an explicit
60
+ // `outline-none`) still wins — but every interactive element that forgot
61
+ // to opt in gets a visible ring instead of the browser default.
62
+ ':where(:focus-visible)': {
63
+ outline: 'var(--focus-outline-default)',
64
+ 'outline-offset': '0px',
65
+ },
66
+ // Tell the browser which scheme native UI (scrollbars, form controls,
67
+ // autofill) should render. Without this, a dark page keeps light
68
+ // scrollbars and light form widgets.
69
+ ':root': {
70
+ 'color-scheme': 'light',
71
+ },
72
+ '[data-theme="dark"]': {
73
+ 'color-scheme': 'dark',
74
+ },
75
+ // Selection reads as a tinted brand highlight rather than the OS default.
76
+ '::selection': {
77
+ 'background-color':
78
+ 'color-mix(in oklab, var(--surface-blue-6) 28%, transparent)',
79
+ },
80
+ // `useColorScheme` stamps this for the two frames around a light/dark swap
81
+ // so every transitioning surface does not cross-fade at once (a flash).
82
+ '.no-transition, .no-transition *': {
83
+ transition: 'none !important',
84
+ },
85
+ '.no-transition *::before, .no-transition *::after': {
86
+ transition: 'none !important',
87
+ },
88
+ })
89
+ })
@@ -0,0 +1 @@
1
+ export declare const source: string[]
package/src/source.ts ADDED
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Source globs that emit Tailwind classes in newui. A consuming app spreads
3
+ * these into its own `@source` list (Tailwind v4 scans sources declared in
4
+ * CSS, not a JS config):
5
+ *
6
+ * @source '@talelk/newui-tailwind/source';
7
+ */
8
+ import path from 'node:path'
9
+ import { fileURLToPath } from 'node:url'
10
+
11
+ const packageRoot = path.resolve(
12
+ path.dirname(fileURLToPath(import.meta.url)),
13
+ '../..',
14
+ )
15
+
16
+ function glob(pattern: string) {
17
+ return path.join(packageRoot, pattern).split(path.sep).join('/')
18
+ }
19
+
20
+ export const source = [
21
+ glob('ui/src/**/*.{ts,tsx}'),
22
+ glob('icons/src/**/*.{ts,tsx}'),
23
+ ]
package/src/style.css ADDED
@@ -0,0 +1,41 @@
1
+ /* The single stylesheet an app imports.
2
+ *
3
+ * @import '@talelk/newui-tailwind/style.css';
4
+ *
5
+ * Everything below is generated or framework-neutral; nothing here is
6
+ * app-specific. The consuming app adds its own `@source` globs. */
7
+ @import "./fonts/Inter/inter.css";
8
+ @import "tailwindcss";
9
+ @import "./generated/tokens.css";
10
+ @import "./generated/theme.css";
11
+ @import "./utilities.css";
12
+
13
+ @plugin "@tailwindcss/forms";
14
+ @plugin "@tailwindcss/typography";
15
+ @plugin "./plugins/theme.js";
16
+ @plugin "./plugins/lucide.js";
17
+
18
+ /* Dark mode is the document switch, not a per-component variant. Color
19
+ * utilities already flip through the runtime tokens; this variant exists for
20
+ * the occasional `dark:` edge case. */
21
+ @custom-variant dark (&:where([data-theme="dark"], [data-theme="dark"] *));
22
+
23
+ /* Scan every atomic visual package so a component's classes always emit.
24
+ * `not` excludes the merge config (vocabulary, not usage) and each package's
25
+ * test fixtures, whose strings are not component markup. */
26
+ @source "../../atoms/src/**/*.{ts,tsx}";
27
+ @source not "../../atoms/src/lib/tw-merge.config.ts";
28
+ @source not "../../atoms/src/**/*.test.{ts,tsx}";
29
+ @source "../../molecules/src/**/*.{ts,tsx}";
30
+ @source not "../../molecules/src/**/*.test.{ts,tsx}";
31
+ @source "../../icons/src/**/*.{ts,tsx}";
32
+ @source "../../organisms/src/**/*.{ts,tsx}";
33
+ @source not "../../organisms/src/**/*.test.{ts,tsx}";
34
+ @source "../../templates/src/**/*.{ts,tsx}";
35
+ @source not "../../templates/src/**/*.test.{ts,tsx}";
36
+ @source "../../charts/src/**/*.{ts,tsx}";
37
+ @source not "../../charts/src/**/*.test.{ts,tsx}";
38
+ @source "../../editor/src/**/*.{ts,tsx}";
39
+ @source not "../../editor/src/**/*.test.{ts,tsx}";
40
+ @source "../../code-editor/src/**/*.{ts,tsx}";
41
+ @source not "../../code-editor/src/**/*.test.{ts,tsx}";
@@ -0,0 +1,145 @@
1
+ import { readdirSync, readFileSync, statSync } from 'node:fs'
2
+ import path from 'node:path'
3
+ import { layoutSpacing, radiusRoles } from '@talelk/newui-tokens'
4
+ import { describe, expect, it } from 'vitest'
5
+
6
+ /**
7
+ * Token discipline.
8
+ *
9
+ * Two failure modes this catches, both of which have shipped here before:
10
+ *
11
+ * 1. A component referencing a semantic token that does not exist
12
+ * (`bg-surface-modal`) — the utility silently compiles to nothing and the
13
+ * element renders transparent.
14
+ * 2. A component reaching for a raw Tailwind palette colour
15
+ * (`bg-red-500`) instead of the semantic vocabulary (P: semantic tokens
16
+ * only). Brand customization and dark mode both depend on this.
17
+ */
18
+ const REPO = process.cwd()
19
+ const VISUAL_SRCS = [
20
+ 'atoms',
21
+ 'molecules',
22
+ 'organisms',
23
+ 'templates',
24
+ 'charts',
25
+ 'editor',
26
+ 'code-editor',
27
+ ].map((pkg) => path.join(REPO, `packages/${pkg}/src`))
28
+ const THEME_CSS = path.join(REPO, 'packages/tailwind/src/generated/theme.css')
29
+
30
+ const themeCss = readFileSync(THEME_CSS, 'utf8')
31
+ const definedColors = new Set(
32
+ [...themeCss.matchAll(/--color-([a-z0-9-]+):/g)].map((m) => m[1]),
33
+ )
34
+
35
+ const TOKENS_CSS = path.join(REPO, 'packages/tailwind/src/generated/tokens.css')
36
+ const tokensCss = readFileSync(TOKENS_CSS, 'utf8')
37
+
38
+ /**
39
+ * Every custom property tokens.css *declares* — both sides of the `name: value`.
40
+ * A pointer at an undeclared property is the chart-palette bug: `--chart-1:
41
+ * var(--blue-6)` named a shade that does not exist, so the property was
42
+ * guaranteed-invalid, every `var(--chart-1)` fell back to `transparent`, and
43
+ * charts and the palette swatches rendered invisible with no error anywhere.
44
+ */
45
+ const declaredVars = new Set(
46
+ [...tokensCss.matchAll(/^\s*(--[a-z0-9-]+):/gm)].map((m) => m[1]),
47
+ )
48
+
49
+ const SEMANTIC_PREFIX = '(?:surface|ink|outline|black-overlay|white-overlay)'
50
+ const COLOR_UTILITIES =
51
+ '(?:bg|text|border|outline|fill|stroke|divide|ring|decoration|placeholder|caret|accent|from|via|to|shadow)'
52
+
53
+ // A raw palette ramp, e.g. `bg-red-500`, `text-gray-700`. Semantic uses are
54
+ // always prefixed (`bg-surface-red-2`), so a bare family+shade is the smell.
55
+ const PALETTE_FAMILIES =
56
+ '(?:gray|slate|zinc|neutral|stone|red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)'
57
+ const RAW_PALETTE = new RegExp(
58
+ `${COLOR_UTILITIES}-${PALETTE_FAMILIES}-\\d{2,3}\\b`,
59
+ 'g',
60
+ )
61
+
62
+ // Colours with no semantic equivalent that are legitimately used raw.
63
+ const RAW_ALLOWLIST = new Set(['text-white', 'text-black'])
64
+
65
+ function walk(dir: string, out: string[] = []): string[] {
66
+ for (const entry of readdirSync(dir)) {
67
+ const full = path.join(dir, entry)
68
+ if (statSync(full).isDirectory()) walk(full, out)
69
+ else if (/\.tsx?$/.test(entry) && !/\.test\.tsx?$/.test(entry))
70
+ out.push(full)
71
+ }
72
+ return out
73
+ }
74
+
75
+ const files = VISUAL_SRCS.flatMap((dir) => walk(dir))
76
+
77
+ function scan(regex: RegExp): Map<string, string> {
78
+ const found = new Map<string, string>()
79
+ for (const file of files) {
80
+ const source = readFileSync(file, 'utf8')
81
+ for (const match of source.matchAll(regex)) {
82
+ found.set(match[0], path.relative(REPO, file))
83
+ }
84
+ }
85
+ return found
86
+ }
87
+
88
+ describe('token discipline', () => {
89
+ it('emits the semantic layout spacing and radius roles', () => {
90
+ for (const name of Object.keys(layoutSpacing))
91
+ expect(themeCss).toContain(`--spacing-${name}:`)
92
+ for (const name of Object.keys(radiusRoles))
93
+ expect(themeCss).toContain(`--radius-${name}:`)
94
+ })
95
+
96
+ it('only references semantic tokens that exist in the theme', () => {
97
+ const semanticUse = new RegExp(
98
+ `${COLOR_UTILITIES}-(${SEMANTIC_PREFIX}-[a-z0-9-]+)`,
99
+ 'g',
100
+ )
101
+ const missing: string[] = []
102
+ for (const file of files) {
103
+ const source = readFileSync(file, 'utf8')
104
+ for (const match of source.matchAll(semanticUse)) {
105
+ const token = match[1]
106
+ if (!definedColors.has(token)) {
107
+ missing.push(
108
+ `${match[0]} → --color-${token} (in ${path.relative(REPO, file)})`,
109
+ )
110
+ }
111
+ }
112
+ }
113
+ expect(missing).toEqual([])
114
+ })
115
+
116
+ it('does not use raw Tailwind palette colours', () => {
117
+ const offenders = [...scan(RAW_PALETTE)].filter(
118
+ ([token]) => !RAW_ALLOWLIST.has(token),
119
+ )
120
+ expect(offenders).toEqual([])
121
+ })
122
+
123
+ it('does not hard-code colours in class strings', () => {
124
+ // A literal hex/rgb/hsl value cannot follow the theme, so it breaks dark
125
+ // mode and brand customization. Colours come from tokens, always.
126
+ const HARDCODED =
127
+ /(?:bg|text|border|ring|fill|stroke|outline|shadow|from|via|to|decoration|caret|accent)-\[(?:#|rgba?\(|hsla?\()/g
128
+ const offenders = [...scan(HARDCODED)]
129
+ expect(offenders).toEqual([])
130
+ })
131
+
132
+ it('every runtime token pointer names a token that is declared', () => {
133
+ // `--x: var(--y)` where `--y` is declared nowhere is worse than a typo: it
134
+ // is silently valid CSS that makes every consumer transparent. Catches the
135
+ // whole class, not just the chart ramp that shipped broken.
136
+ const missing: string[] = []
137
+ for (const match of tokensCss.matchAll(
138
+ /^\s*(--[a-z0-9-]+):\s*var\((--[a-z0-9-]+)\)/gm,
139
+ )) {
140
+ const [, name, target] = match
141
+ if (!declaredVars.has(target)) missing.push(`${name} → ${target}`)
142
+ }
143
+ expect(missing).toEqual([])
144
+ })
145
+ })