import { readFile } from 'node:fs/promises'; import { describe, expect, it } from 'vitest'; type Rule = { selector: string; declarations: Record }; /** * The stylesheet is layered by years of incremental patches, so asserting on raw substrings * proves nothing about what the browser finally paints. These helpers resolve the cascade. */ function parseDeclarations(body: string): Record { const declarations: Record = {}; for (const part of body.split(';')) { const separator = part.indexOf(':'); if (separator === -1) continue; const property = part.slice(0, separator).trim(); const value = part.slice(separator + 1).trim(); if (property && value) declarations[property] = value; } return declarations; } function collectRules(css: string, rules: Rule[] = []): Rule[] { let index = 0; let preludeStart = 0; while (index < css.length) { if (css[index] !== '{') { index += 1; continue; } const prelude = css.slice(preludeStart, index).trim(); let cursor = index + 1; let depth = 1; while (cursor < css.length && depth > 0) { if (css[cursor] === '{') depth += 1; else if (css[cursor] === '}') depth -= 1; cursor += 1; } const body = css.slice(index + 1, cursor - 1); if (prelude.startsWith('@')) { collectRules(body, rules); } else if (prelude) { for (const selector of prelude.split(',')) { const trimmed = selector.replace(/\s+/g, ' ').trim(); if (trimmed) rules.push({ selector: trimmed, declarations: parseDeclarations(body) }); } } preludeStart = cursor; index = cursor; } return rules; } async function loadStylesheet() { const css = await readFile(new URL('../styles.css', import.meta.url), 'utf8'); const rules = collectRules(css.replace(/\/\*[\s\S]*?\*\//g, ''), []); const tokens: Record = {}; for (const rule of rules) { if (rule.selector !== ':root') { continue; } for (const [property, value] of Object.entries(rule.declarations)) { if (property.startsWith('--')) tokens[property] = value; } } const wins = (target: string, property: string) => { let winner: string | undefined; for (const rule of rules) { if (!rule.selector.split(',').some((entry) => entry.replace(/\s+/g, ' ').trim() === target)) continue; const value = rule.declarations[property]; if (value !== undefined) winner = value; } return winner; }; const resolve = (value: string | undefined): string | undefined => { if (value === undefined) return undefined; return value.replace(/var\((--[a-z0-9-]+)\)/gi, (_, name: string) => tokens[name] ?? ''); }; const exact = (selector: string, property: string) => rules.find((rule) => rule.selector === selector && rule.declarations[property] !== undefined) ?.declarations[property]; return { rules, tokens, wins, resolve, exact }; } function channel(value: number): number { const ratio = value / 255; return ratio <= 0.04045 ? ratio / 12.92 : ((ratio + 0.055) / 1.055) ** 2.4; } function contrastRatio(foreground: string, background: string): number { const parse = (input: string) => { const hex = input.trim().match(/^#([0-9a-f]{6})$/i); if (!hex) throw new Error(`expected a 6 digit hex colour, got "${input}"`); const int = Number.parseInt(hex[1], 16); return [int >> 16, (int >> 8) & 0xff, int & 0xff] as const; }; const luminance = (input: string) => { const [red, green, blue] = parse(input); return 0.2126 * channel(red) + 0.7152 * channel(green) + 0.0722 * channel(blue); }; const lighter = Math.max(luminance(foreground), luminance(background)); const darker = Math.min(luminance(foreground), luminance(background)); return (lighter + 0.05) / (darker + 0.05); } describe('button system', () => { it('exposes one compact height and radius scale', async () => { const { tokens } = await loadStylesheet(); expect(tokens['--button-height']).toBe('2.375rem'); expect(tokens['--button-height-compact']).toBe('2.125rem'); expect(tokens['--button-height-large']).toBe('2.625rem'); expect(tokens['--icon-button-size']).toBe('2.5rem'); expect(tokens['--button-radius']).toBe('10px'); }); it('paints the primary action with the accessible token pair, not the old teal pill', async () => { const { rules, wins, resolve } = await loadStylesheet(); // A bare `.primary-action` selector has specificity (0,1,0) and loses to the generic // `button:not([class*='animal-'])` surface rule at (0,1,1). That is what turned the label // white on cream, so the winning declarations must live on an element-qualified selector. const bare = rules.filter( (rule) => rule.declarations.background !== undefined && rule.selector.split(',').some((entry) => entry.trim() === '.primary-action'), ); expect(bare.map((rule) => rule.selector)).toEqual([]); expect(wins('button.primary-action', 'border-radius')).toBe('var(--button-radius)'); const background = resolve(wins('button.primary-action', 'background')); const color = resolve(wins('button.primary-action', 'color')); expect(background).toMatch(/^#[0-9a-f]{6}$/i); expect(color).toMatch(/^#[0-9a-f]{6}$/i); expect(contrastRatio(color as string, background as string)).toBeGreaterThanOrEqual(4.5); }); it('keeps disabled actions legible instead of fading them out', async () => { const { wins, resolve, tokens } = await loadStylesheet(); expect(wins('button.primary-action:disabled', 'opacity')).toBe('1'); expect(wins('button:disabled', 'opacity')).toBe('1'); expect( contrastRatio( resolve(tokens['--button-disabled-text']) as string, resolve(tokens['--button-disabled-bg']) as string, ), ).toBeGreaterThanOrEqual(3); }); it('uses one accessible field scale shared by inputs, selects and textareas', async () => { const { tokens, exact } = await loadStylesheet(); expect(tokens['--field-height']).toBe('2.375rem'); expect(tokens['--field-radius']).toBe('10px'); expect(tokens['--field-border']).toBe('var(--line-strong)'); expect(tokens['--field-focus-border']).toBe('var(--button-focus-ring)'); for (const selector of ['button', 'input', 'select', 'textarea']) { expect(exact(selector, 'border')).toBe('1px solid var(--field-border)'); expect(exact(selector, 'background')).toBe('var(--field-bg)'); } for (const selector of ['input:focus', 'select:focus', 'textarea:focus']) { expect(exact(selector, 'border-color')).toBe('var(--field-focus-border)'); } }); it('normalises semantic surfaces in notification, log and message centres', async () => { const { tokens, rules } = await loadStylesheet(); const semanticNames = [ '--workbench-mint-border', '--workbench-warning-border', '--workbench-warning-text', '--workbench-warning-soft', '--workbench-danger-border', ]; expect(tokens['--workbench-warning-text']).toBe('#825f0c'); expect(semanticNames.every((name) => /^#[0-9a-f]{6}$/i.test(tokens[name] ?? ''))).toBe(true); expect( contrastRatio(tokens['--workbench-warning-text'], tokens['--workbench-warning-soft']), ).toBeGreaterThanOrEqual(4.5); expect( rules .filter((rule) => [ '.fleet-notifications-retry-all', '.fleet-messages-delete', '.fleet-log-filters select', ].some((selector) => rule.selector.split(',').some((entry) => entry.trim() === selector)), ) .every((rule) => !Object.values(rule.declarations).join(' ').includes('rgba(')), ).toBe(true); const css = (await readFile(new URL('../styles.css', import.meta.url), 'utf8')).replace( /\/\*[\s\S]*?\*\//g, '', ); expect(css).not.toMatch( /#8a6722|#fff8e8|#fffafa|rgba\(17,\s*119,\s*110|rgba\(170,\s*120,\s*35|rgba\(182,\s*77,\s*72/, ); }); it('keeps disabled and unavailable states legible without fading controls', async () => { const { rules } = await loadStylesheet(); const stateSelectors = [ '.risk-confirm:has(input:disabled)', '.fleet-messages-device-item[data-unavailable]', '.fleet-log-retention-value:disabled', '.fleet-notification-entry-button:disabled', '[class*=animal-btn-]:disabled', ]; const faded = rules .filter( (rule) => stateSelectors.some((selector) => rule.selector.includes(selector)) && rule.declarations.opacity !== undefined && rule.declarations.opacity !== '1', ) .map((rule) => rule.selector); expect(faded).toEqual([]); }); it('does not leave stale compact command-control sizes in later overrides', async () => { const { rules } = await loadStylesheet(); const compactControls = [ '.global-navigation a', '.fleet-sidebar-toggle', '.fleet-cross-health-item', '.fleet-select-all button', '.batch-entry-actions button', '.fleet-card-context-trigger', '.fleet-group-tabs button', '.fleet-subnavigation a', '.automation-workspace .row-actions button', '.fleet-log-retention-actions .primary-action', ]; const stale = rules .filter((rule) => compactControls.some((selector) => rule.selector.replace(/\s+/g, ' ').includes(selector)), ) .filter((rule) => { const heights = [rule.declarations['min-height'], rule.declarations.height].filter( (value): value is string => value !== undefined, ); return heights.some((value) => ['1.9rem', '2rem', '2.15rem', '2.2rem', '2.25rem'].includes(value), ); }) .map((rule) => rule.selector); expect(stale).toEqual([]); }); it('keeps primitive actions aligned with the standard text-button scale', async () => { const { rules, wins } = await loadStylesheet(); const standardControls = [ '.instance-context nav a', '.dense-table th button', '.organization-add', '.fleet-card-admin-actions button', '.fleet-messages-device-item', '.maintenance-backup-actions a', ]; const stale = rules .filter((rule) => standardControls.some((selector) => rule.selector.replace(/\s+/g, ' ').includes(selector)), ) .filter((rule) => { const declarations = Object.values(rule.declarations).join(' '); const sizes = [rule.declarations['min-height'], rule.declarations.height].filter( (value): value is string => value !== undefined, ); return ( sizes.some((value) => ['1.8rem', '2.1rem', '2.3rem', '2.35rem', '2.4rem'].includes(value), ) || declarations.includes('border-radius: 999px') ); }) .map((rule) => rule.selector); expect(stale).toEqual([]); for (const selector of [ 'button.primary-action', 'a.primary-action', "[class*='animal-btn-middle']", ]) { expect(wins(selector, 'min-height')).toBe('var(--button-height)'); expect(wins(selector, 'padding')).toBe('0.45rem 0.9rem'); expect(wins(selector, 'font-size')).toBe('0.82rem'); } for (const selector of ['.organization-add', '.maintenance-backup-actions a']) { expect(wins(selector, 'min-height')).toBe('var(--button-height-compact)'); expect(wins(selector, 'padding')).toBe('0.3rem 0.7rem'); expect(wins(selector, 'font-size')).toBe('0.76rem'); expect(wins(selector, 'border-radius')).toBe('var(--button-radius-compact)'); } }); it('keeps grouped, segmented and workbench controls on the shared scale', async () => { const { exact, wins } = await loadStylesheet(); const compactControls = [ '.fleet-group-tabs button', '.segmented-control button', '.identity-actions button', '.fleet-notifications-retry-all', '.fleet-messages-filter-chip', '.fleet-messages-delete', '.fleet-switch', ]; for (const selector of compactControls) { expect(exact(selector, 'min-height')).toBe('var(--button-height-compact)'); expect(exact(selector, 'padding')).toBe('0.3rem 0.7rem'); expect(exact(selector, 'font-size')).toBe('0.76rem'); } // Small touch surfaces deliberately promote panel controls to the standard height. expect(wins('.identity-actions button', 'min-height')).toBe('var(--button-height)'); expect(wins('.fleet-group-tabs button', 'border-radius')).toBe('var(--button-radius-compact)'); expect(wins('.workbench-tabs button', 'min-height')).toBe('var(--button-height-large)'); }); it('keeps privacy toggles on the shared icon-button target', async () => { const { wins } = await loadStylesheet(); expect(wins('.fleet-phone-privacy-toggle', 'width')).toBe('var(--icon-button-size)'); expect(wins('.fleet-phone-privacy-toggle', 'height')).toBe('var(--icon-button-size)'); expect(wins('.fleet-phone-privacy-toggle', 'border-radius')).toBe('var(--button-radius)'); }); it('keeps workbench text and focus indicators on the accessible brand scale', async () => { const { tokens, resolve } = await loadStylesheet(); expect(tokens['--workbench-mint']).toBe('#0f6e66'); expect(tokens['--workbench-muted']).toBe('#6f6350'); expect(tokens['--button-focus-ring']).toBe('#0f6e66'); expect(tokens['--animal-primary-color-hover']).toBe('#0f6e66'); expect(tokens['--animal-text-color-muted']).toBe('#6f6350'); expect( contrastRatio( resolve(tokens['--workbench-muted']) as string, resolve(tokens['--animal-canvas-fallback'] ?? '#e9f4df') as string, ), ).toBeGreaterThanOrEqual(4.5); }); it('does not resurrect low-contrast legacy accents', async () => { const css = (await readFile(new URL('../styles.css', import.meta.url), 'utf8')).replace( /\/\*[\s\S]*?\*\//g, '', ); expect(css).not.toMatch(/#19b8aa|#178f84|#109d92|rgba\(25,\s*184,\s*170/i); expect(css).not.toMatch(/#d95757|rgba\(217,\s*87,\s*87/i); expect(css).not.toMatch(/#8f806b|#81745f|#8d816e|#766a58|#7f725f/); }); it('normalises Animal UI buttons through attribute selectors, not hashed classes', async () => { const { rules, wins } = await loadStylesheet(); const selectors = rules.map((rule) => rule.selector); expect(selectors.some((selector) => selector.includes("[class*='animal-btn-']"))).toBe(true); expect(selectors.some((s) => s.includes("[class*='animal-btn-small']"))).toBe(true); // Hashed module classes such as animal-btn-QoKe1 are regenerated on every library upgrade // and silently stop matching, so only the semantic suffixes may appear here. const semantic = 'small|middle|large|icon|block|primary|default|dashed|text|link|ghost|danger|loading'; expect(selectors.join(' ')).not.toMatch( new RegExp(`animal-btn-(?!${semantic}\\b)[A-Za-z0-9]{4,}`), ); expect(wins("[class*='animal-btn-']", 'border-radius')).toBe('var(--button-radius)'); }); it('gives icon buttons a square target that matches the text scale', async () => { const { wins } = await loadStylesheet(); expect(wins('button.icon-button', 'width')).toBe('var(--icon-button-size)'); expect(wins('button.icon-button', 'border-radius')).toBe('var(--button-radius)'); expect(wins('button.fleet-card-menu-trigger', 'border-radius')).toBe('var(--button-radius)'); expect(wins('button.row-action-trigger', 'min-height')).toBe('var(--icon-button-size)'); }); it('drops the capsule radius from every primary action variant', async () => { const { rules } = await loadStylesheet(); const offenders = rules .filter((rule) => rule.selector .split(',') .some((entry) => /(^|[\s>+~])\.primary-action(?![-\w])/.test(entry.trim())), ) .filter((rule) => /999px|50%/.test(Object.values(rule.declarations).join(' '))); expect(offenders.map((rule) => rule.selector)).toEqual([]); }); });