import { describe, it, expect } from 'vitest'; import fs from 'node:fs'; import path from 'node:path'; const dir = path.join(process.cwd(), 'src/tokens'); const read = (f: string) => fs.readFileSync(path.join(dir, f), 'utf8'); const names = (css: string) => new Set([...css.matchAll(/^\s*(--[a-z0-9-]+)\s*:/gm)].map((m) => m[1])); describe('token layers', () => { it('ships the layers plus the palette and a single entry point, and no light theme', () => { for (const f of [ 'tokens.css', 'fonts.css', 'palette.css', 'theme.css', 'dark.css', 'responsive.css', 'motion.css', 'utilities.css', ]) { expect(fs.existsSync(path.join(dir, f)), f).toBe(true); } // Rule 3: dark is the only theme. Asserted as an absence so re-adding a light layer fails. expect(fs.existsSync(path.join(dir, 'light.css'))).toBe(false); }); it('tokens.css imports the layers in cascade order', () => { // Dark carries the bare `:root` and is the only theme, so it must come after `@theme`, // whose values it overrides. // `utilities.css` last because its `@utility` bodies read `@theme` values. const order = [ 'fonts.css', 'palette.css', 'theme.css', 'dark.css', 'responsive.css', 'motion.css', 'utilities.css', ]; // Matched against the `@import` lines, not the whole file: the banner comment above them // names the layers too, so `indexOf` would score the prose rather than the cascade. const src = read('tokens.css'); const seen = order.map((f) => src.indexOf(`@import "./${f}"`)); expect(seen.every((i) => i !== -1)).toBe(true); expect([...seen]).toEqual([...seen].sort((a, b) => a - b)); }); it('declares every dark semantic color in @theme too', () => { // `@theme` is what registers a token with Tailwind and generates its utility. A name that // only `dark.css` declares emits no utility at all, so it renders as an unset custom // property with no error anywhere. Derived from the emitted CSS rather than the generator's // tables: the generator's own version of this check missed seven names, because // `FIGMA_NEW_ROLES` writes through a different table than the rest. const names = (css: string) => new Set([...css.matchAll(/^\s{2}(--color-[a-z0-9-]+):/gm)].map((m) => m[1])); const dark = names(read('dark.css')); const base = names(read('theme.css')); expect(dark.size).toBeGreaterThan(200); expect([...dark].filter((n) => !base.has(n))).toEqual([]); }); it('gives dark the bare :root with color-scheme: dark, and data-theme="light" only sets color-scheme: light', () => { // This is what makes dark the default, and it is checked here rather than inferred from // DEFAULT_THEME: a consumer who renders no provider gets whatever `:root` says, and the // JavaScript constant has no bearing on that. const dark = read('dark.css'); expect(dark).toMatch(/^:root,\n\[data-theme="dark"\] \{/m); expect(dark).toContain('color-scheme: dark;'); expect(dark).toContain('[data-theme="light"] {\n color-scheme: light;\n}'); for (const f of ['tokens.css', 'theme.css', 'responsive.css', 'palette.css']) { expect(read(f), f).not.toContain('data-theme="light"'); } // Every rule in dark.css, not just the first: CORRECTIONS, the Figma re-points and the // accessibility corrections are separate blocks, and one left at bare // `[data-theme="dark"]` would not apply to the default theme — which is how five WCAG // fixes would stop applying to the theme almost everyone renders. const selectors = [...dark.matchAll(/^(:root,\n)?\[data-theme="dark"\] \{/gm)]; expect(selectors.length).toBeGreaterThanOrEqual(4); expect(selectors.filter((m) => m[1] === undefined)).toEqual([]); }); it('declares every token required by the v1 primitives', () => { const declared = names(read('theme.css')); const required = fs .readFileSync(path.join(process.cwd(), '../../scripts/required-tokens.txt'), 'utf8') .split('\n') .filter(Boolean); const missing = required.filter((t) => !declared.has(t)); expect(missing).toEqual([]); }); it('applies the font-serif to font-sans reconciliation', () => { const all = read('theme.css') + read('palette.css'); expect(all).not.toMatch(/--font-serif\b/); expect(all).toMatch(/--font-sans:[^;]*Whitney SSm/); expect(all).toMatch(/--font-display:[^;]*Whitney SSm/); // Figma names no monospace family, so mono is a gap in the authority rather than a // deviation from it — and Whitney has no mono cut to fill it with. expect(all).toMatch(/--font-mono:[^;]*Twilio Sans Mono/); expect(all).not.toMatch(/Twilio Sans (Text|Display)/); }); /** * Every weight the 43 typography roles ask for must have a real Whitney cut behind it, or * the browser synthesises one and the text renders at a weight nobody designed. The roles * use 400, 500, 600 and 800 — plus 420 for the two eyebrow-regular roles, which CSS font * matching resolves to 500 because 400 and 500 are both declared. */ it('declares a real face for every weight the typography roles use', () => { const fonts = read('fonts.css'); const declared = new Set( [...fonts.matchAll(/font-family: "Whitney SSm";[^}]*font-weight: (\d+)/g)].map((m) => m[1]) ); const wanted = new Set([...read('theme.css').matchAll(/--font-weight-[a-z0-9-]+:\s*(\d+)/g)].map((m) => m[1])); // 420 is not a cut and is not meant to be; it resolves to the 500 above it. expect([...wanted].filter((w) => w !== '420' && !declared.has(w))).toEqual([]); // 6 cuts x upright/italic, so italics are never synthesised either. expect(fonts.match(/font-family: "Whitney SSm"/g)).toHaveLength(12); expect(fonts.match(/font-style: italic/g)?.filter(Boolean).length).toBeGreaterThanOrEqual(6); }); it('anchors the responsive step-up at 1344px, not 1280px', () => { const src = read('responsive.css'); expect(src).toContain('1344px'); expect(src).not.toContain('1280px'); }); it('defines the cr-web-demo breakpoint set', () => { const src = read('theme.css'); for (const [n, v] of [ ['sm', '481px'], ['md', '768px'], ['lg', '992px'], ['xl', '1344px'], ['2xl', '1600px'], ]) { expect(src).toMatch(new RegExp(`--breakpoint-${n}:\\s*${v}`)); } }); it('promotes #ef223a to a named token', () => { expect(read('palette.css')).toMatch(/#ef223a/i); }); it('defines semantic tokens as var() references, not literal hex', () => { // Spec 6.3: the semantic layer points into the palette. Scoped to the generated block — // the CORRECTIONS block below it is registry-authored and states its five literals on // purpose, which the provenance test above covers by name. // Split on the block's own heading, not the bare word — the file's header banner mentions // CORRECTIONS too, and splitting on that discards the entire generated block. const generated = read('dark.css').split('maker-ds dark-theme CORRECTIONS')[0]; const semantic = [ ...generated.matchAll(/^\s*(--color-(?:surface|text|border|icon|bg-button)-[a-z0-9-]+)\s*:\s*([^;]+);/gm), ]; expect(semantic.length).toBeGreaterThan(50); const literal = semantic.filter(([, , v]) => /#[0-9a-f]{3,8}\b/i.test(v)); expect(literal.map(([, n]) => n)).toEqual([]); }); it('carries the dark CORRECTIONS from ui-registry PR #117', () => { const dark = read('dark.css'); const d = names(dark); for (const t of [ '--color-text-button-primary', '--color-surface-accent-ultra-soft', '--color-surface-accent-soft', '--color-surface-accent-semi-extra-soft', '--color-surface-success', '--color-accent-lavender-200', ]) { expect(d.has(t), t).toBe(true); } // Provenance must survive so the next re-sync does not silently drop them. expect(dark).toMatch(/CORRECTIONS/); }); it('drops the data-visualisation families', () => { const declared = [...names(read('theme.css'))]; const viz = declared.filter((t) => /(divergent|qualitative|sequential|conditional)/.test(t)); expect(viz).toEqual([]); }); describe('scrollbar utilities', () => { // `@utility` is only registered when it is top-level. Nested inside `@theme` or a `@layer` it // parses without error and generates no class, so `scrollbar-pill` would silently do nothing // while every class-name assertion in tabs and chat-log still passed. it('declares both at the top level', () => { const src = read('utilities.css'); for (const name of ['scrollbar-pill', 'scrollbar-hidden']) { expect(src, name).toMatch(new RegExp(`^@utility ${name} \\{$`, 'm')); } }); it('draws the thumb from Figma 22:27958 — 8px, gray-700, radius/50', () => { const src = read('utilities.css'); expect(src).toMatch(/&::-webkit-scrollbar \{\s*width: 8px;/); expect(src).toContain('background: var(--color-surface-scrollbar-thumb)'); expect(src).toContain('border-radius: var(--radius-2x-large)'); expect(read('theme.css')).toMatch(/--color-surface-scrollbar-thumb: var\(--palette-gray-700\)/); expect(read('palette.css')).toMatch(/--palette-gray-700: #38425e/); expect(read('theme.css')).toMatch(/--radius-2x-large:\s*50px/); }); it('hides the bar in both vocabularies', () => { // Safari implements neither `scrollbar-width` nor `scrollbar-color`, so dropping the // pseudo-element leaves the bar drawn there and nowhere else. const hidden = read('utilities.css').split('@utility scrollbar-hidden')[1]; expect(hidden).toContain('scrollbar-width: none'); expect(hidden).toContain('&::-webkit-scrollbar'); expect(hidden).toContain('display: none'); }); }); it('leaves no semantic color unresolvable', () => { // The 20 names `@theme` declares and dark does not are theme-invariant by design (the // `--color-code-*` set, the fixed accent pales, `--color-highlight`, `--color-white-100`, and // both `--color-alpha-black-alpha-*` scrim steps — an alpha over `brand/ink`, which is what the // one theme is drawn on). Counted rather than listed, so a 21st has to be justified here. const base = [...names(read('theme.css'))].filter((t) => t.startsWith('--color-')); const d = names(read('dark.css')); expect(base.filter((t) => !d.has(t))).toHaveLength(20); }); });