Files
multi-simadmin/apps/web/src/ui/button-system.test.ts
T
chick 001659ed91 style(web): unify panel, group and state controls
Standardize panel actions, group filters, segmented modes, workbench tabs, message filters, identity controls and privacy toggles on the shared button scale. Add cascade-aware tests so stale compact sizes and low-contrast states cannot return.
2026-09-08 19:58:10 +08:00

404 lines
16 KiB
TypeScript

import { readFile } from 'node:fs/promises';
import { describe, expect, it } from 'vitest';
type Rule = { selector: string; declarations: Record<string, string> };
/**
* 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<string, string> {
const declarations: Record<string, string> = {};
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<string, string> = {};
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([]);
});
});