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.
This commit is contained in:
chick
2026-09-08 19:58:10 +08:00
parent bfe5025943
commit 001659ed91
7 changed files with 237 additions and 113 deletions
+97 -14
View File
@@ -28,7 +28,7 @@ function collectRules(css: string, rules: Rule[] = []): Rule[] {
index += 1;
continue;
}
const selector = css.slice(preludeStart, index).trim();
const prelude = css.slice(preludeStart, index).trim();
let cursor = index + 1;
let depth = 1;
while (cursor < css.length && depth > 0) {
@@ -37,10 +37,13 @@ function collectRules(css: string, rules: Rule[] = []): Rule[] {
cursor += 1;
}
const body = css.slice(index + 1, cursor - 1);
if (selector.startsWith('@')) {
if (prelude.startsWith('@')) {
collectRules(body, rules);
} else if (selector) {
rules.push({ selector, declarations: parseDeclarations(body) });
} 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;
@@ -75,9 +78,8 @@ async function loadStylesheet() {
return value.replace(/var\((--[a-z0-9-]+)\)/gi, (_, name: string) => tokens[name] ?? '');
};
const exact = (selector: string, property: string) =>
rules.filter((rule) => rule.selector.replace(/\s+/g, ' ') === selector).at(-1)?.declarations[
property
];
rules.find((rule) => rule.selector === selector && rule.declarations[property] !== undefined)
?.declarations[property];
return { rules, tokens, wins, resolve, exact };
}
@@ -155,13 +157,14 @@ describe('button system', () => {
expect(tokens['--field-radius']).toBe('10px');
expect(tokens['--field-border']).toBe('var(--line-strong)');
expect(tokens['--field-focus-border']).toBe('var(--button-focus-ring)');
expect(exact('button, input, select, textarea', 'border')).toBe(
'1px solid var(--field-border)',
);
expect(exact('button, input, select, textarea', 'background')).toBe('var(--field-bg)');
expect(exact('input:focus, select:focus, textarea:focus', 'border-color')).toBe(
'var(--field-focus-border)',
);
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 () => {
@@ -252,6 +255,86 @@ describe('button system', () => {
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();