diff --git a/tests/unit/styles.test.js b/tests/unit/styles.test.js new file mode 100644 index 0000000..d890e51 --- /dev/null +++ b/tests/unit/styles.test.js @@ -0,0 +1,81 @@ +// The stylesheet's two contracts, both of which the popup broke once: +// +// 1. it may only paint with theme tokens — a literal color cannot follow the +// shell's light/dark switch, and a `--dsw-static-*` token is fixed by design; +// 2. the popup must use the same floating-surface recipe as the shipped menus and +// modals (surface + hairline + elevation + label tokens), not the fixed dark +// `--dsw-alias-toast-bg` that made it a black slab on a light page. +import { test } from 'node:test' +import assert from 'node:assert/strict' + +import { CSS } from '../../src/client/ui/styles.js' + +/** Every `property:value` declaration in the sheet, selector-less. */ +function declarations(sheet) { + return [...sheet.matchAll(/([a-z-]+)\s*:\s*([^;{}]+);/g)].map((match) => ({ property: match[1], value: match[2].trim() })) +} + +const COLOR_LITERAL = /#[0-9a-fA-F]{3,8}\b|\b(?:rgb|rgba|hsl|hsla|oklch|lab)\(|\b(?:white|black|red|green|blue|gray|grey)\b/ + +test('the stylesheet paints with tokens and never with a literal color', () => { + const properties = ['color', 'background', 'background-color', 'border', 'border-top', 'border-color', 'box-shadow', 'outline'] + const painted = declarations(CSS).filter(({ property }) => properties.includes(property)) + assert.ok(painted.length > 20, 'expected the sheet to carry its painted declarations') + + for (const { property, value } of painted) { + // Keyword values carry no color of their own. + if (/^(none|transparent|inherit|currentColor)$/.test(value)) continue + assert.ok(!COLOR_LITERAL.test(value), `${property}: ${value} hardcodes a color instead of using a theme token`) + assert.ok(!value.includes('--dsw-static-'), `${property}: ${value} uses a theme-fixed static token that cannot follow dark mode`) + } +}) + +test('the popup is the shipped floating-card recipe, in both themes', () => { + const toast = CSS.match(/\.dsn-toast\{([^}]*)\}/)?.[1] + assert.ok(toast !== undefined, 'the popup rule is missing') + + // The surface, the hairline and the shadow are what DSH's menus and modals use. + assert.ok(toast.includes('background:var(--dsw-alias-bg-layer-2)'), 'the popup must sit on the elevated layer surface') + assert.ok(toast.includes('--dsw-elevation-stroke-color:var(--dsw-alias-border-l1)'), 'the popup must take the menu hairline') + assert.ok(toast.includes('box-shadow:var(--dsw-elevation-prominent)'), 'the popup must take the prominent elevation') + assert.ok(toast.includes('color:var(--dsw-alias-label-primary)'), 'the popup text must be the primary label token') + assert.ok(toast.includes('border-radius:var(--dsw-radius-lg)'), 'the popup must take the large radius') + assert.ok(toast.includes('var(--ds-transition-duration') && toast.includes('var(--ds-ease-in-out'), 'the entrance must use the shell motion tokens') + + // The one thing it must not do: the fixed dark toast slab, or any of its labels. + assert.ok(!toast.includes('--dsw-alias-toast-'), 'the popup must not use the theme-fixed toast surface') + assert.ok(!CSS.includes('--dsw-alias-toast-'), 'no rule may fall back to the fixed toast surface') +}) + +test('the popup reads as one card: title, body and both controls use label tokens', () => { + const rule = (name) => CSS.match(new RegExp(`\\.${name}\\{([^}]*)\\}`))?.[1] ?? '' + assert.ok(rule('dsn-toast-title').includes('var(--dsw-alias-label-primary)')) + assert.ok(rule('dsn-toast-desc').includes('var(--dsw-alias-label-secondary)')) + assert.ok(rule('dsn-toast-close').includes('color:var(--dsw-alias-label-tertiary)')) + assert.ok(rule('dsn-toast-action').includes('var(--dsw-alias-state-business-primary)')) + // Hover feedback comes from the shared interactive token, not a hand-rolled color. + assert.ok(rule('dsn-toast-action:hover').includes('var(--dsw-alias-interactive-bg-hover)')) + assert.ok(rule('dsn-toast-close:hover').includes('var(--dsw-alias-interactive-bg-hover)')) +}) + +test('the three popup glyphs stay distinguishable through state tokens', () => { + assert.ok(CSS.includes('.dsn-toast-icon{') && CSS.includes('var(--dsw-alias-state-warn-label)')) + assert.ok(CSS.includes('.dsn-toast-icon.is-completion{color:var(--dsw-alias-state-success-primary)}')) + assert.ok(CSS.includes('.dsn-toast-icon.is-question{color:var(--dsw-alias-state-business-primary)}')) +}) + +test('every class the components render has a rule, so nothing falls back to defaults', () => { + // Guards the redesign: a renamed class that the sheet no longer styles would + // render as an unstyled block instead of failing loudly. + const expected = [ + 'dsn-stack', 'dsn-toast', 'dsn-toast-icon', 'dsn-toast-icon is-completion', 'dsn-toast-icon is-question', + 'dsn-toast-text', 'dsn-toast-title', 'dsn-toast-desc', 'dsn-toast-actions', 'dsn-toast-action', 'dsn-toast-close', + 'dsn-page', 'dsn-lede', 'dsn-group', 'dsn-group-title', 'dsn-group-note', 'dsn-list', 'dsn-row', 'dsn-row-text', + 'dsn-row-title', 'dsn-row-sub', 'dsn-state', 'dsn-dot', 'dsn-facts', 'dsn-fact-key', 'dsn-fact', 'dsn-fact-value', + 'dsn-fact-title', 'dsn-switch', 'dsn-thumb', 'dsn-button', + ] + for (const className of expected) { + const selector = `.${className.split(' ').join('.')}` + assert.ok(CSS.includes(`${selector}{`) || CSS.includes(`${selector}[`) || CSS.includes(`${selector}:`) || CSS.includes(`${selector}.`), `no rule for ${selector}`) + } +})