Files
chat/e2e/specs/tokens.spec.ts
zooqueen b08ff6a233 chat: take the token layer from @hanzo/design, and stop speaking a type nothing else understands
chat had two token vocabularies and no arbiter. It imported
@hanzo/brand/styles/variables.css and then declared 45 of the same names again
itself, 26 of them with different values, so what a shared component resolved
depended on which rule the cascade reached last. Worse, chat's half of the
overlap was shadcn HSL CHANNELS — `--border: 0 0% 11%` — a value type nothing
outside this app speaks.

That is why the `var(--token, <literal>)` fallback every distributed component
relies on does not save you here: a fallback only fires when a name is
UNDEFINED. A name defined with the wrong type is worse than a missing one,
because the declaration is parsed, found invalid, and dropped. Measured in a
browser against live hanzo.chat, @hanzo/iam 0.21.1's own menu rule
(`border: 1px solid var(--border, #1f1f1f)`) computed to `border: 0px none`.
The account menu had no edge, on production, and nothing reported it.

  - @hanzo/design's eight token files replace @hanzo/brand's variables.css.
    base.css is deliberately not imported: its element defaults would fight
    tailwind's preflight. Measured first — of the 67 names brand declares that
    design does not, chat reads exactly --hanzo-accent, --border-light (chat
    declares both itself) and --hanzo-mono-50..950, whose eleven values are
    byte-identical to design's --neutral-*. Nothing else in client/src,
    packages/ or the config touches a brand-only name, so this is a rename plus
    a swap, not a repaint. design's `:root`(dark)/`.light` split already matches
    the class ThemeProvider puts on <html>, so the theme follows with no mapping.

  - The eighteen semantic colour names now come from @hanzo/design instead of
    being redeclared here as channels, and the 27 `hsl(var(--x))` call sites in
    style.css and tailwind.config.cjs become `var(--x)`. --destructive maps onto
    the system's own --state-error rather than a local red literal. --chart-1..5
    are deleted: declared as channels, read nowhere.

  - chat's own token block moves from `html` to `:root`. A type selector scores
    (0,0,1) and an imported `:root` scores (0,1,0), so chat's answer lost every
    tie to the package it imported. Two live costs: --radius resolved to .25rem
    instead of chat's .5rem in BOTH themes, and tailwind maps rounded-lg/md/sm
    to var(--radius), so every rounded-lg in the app painted 4px and rounded-sm
    painted 0; and in light theme `hsl(var(--border))` got the package's hex,
    which is invalid, so border-border fell through to currentColor and drew
    BLACK edges where a hairline was meant.

  - color-scheme is declared here now. It used to arrive from brand's sheet;
    @hanzo/design ships none, and without it the UA paints form fields and
    scrollbars for the wrong theme. Which theme is live is the host's fact.

  - tailwind's colour map declared `destructive` twice. The later literal won,
    so --destructive was unreachable and `text-destructive-foreground` — used in
    four places — was a class with no rule. Collapsed to one entry.

  - `.popover-ui:focus-visible { outline: var(--bg-surface-hover) }` referenced a
    tailwind CLASS name as a custom property. It was never defined, so those
    popovers had no keyboard focus indicator at all. Now `2px solid var(--ring)`,
    which clears 3:1 in both themes since @hanzo/design 0.3.0.

  - @hanzo/iam 0.21.0 -> ^0.21.1 (package.json and the lockfile pin), for the
    paint fix.

e2e/specs/tokens.spec.ts asserts the layer the only way that counts — at
runtime, in a browser, in both themes: every `var(--x)` in the served CSS that
carries no fallback must resolve; the iam menu rule must compute a real border,
a non-transparent box and a non-zero radius; and the z ladder must be ordered
with --z-tooltip/--z-notification landing on the same rungs as
--z-popover/--z-toast. Against the pre-change build it reports one unresolved
token (--bg-surface-hover) and FAILS the menu assertion; after, zero and pass.

Verified in headless Chromium at 1440x900 and 390x844, dark and light, against
CSS compiled through chat's real postcss pipeline. Menu, dark: border
`0px none` -> `1px solid rgb(31,31,31)`, radius 8px -> 12px, group dividers now
paint. Menu, light: box rgb(10,10,10) with black text -> rgb(255,255,255) with
rgb(10,10,10) text — it was a black-on-black panel. App-wide: rounded-lg 4px ->
8px, rounded-sm 0px -> 4px, input radius 2px -> 6px.
2026-07-27 18:01:31 -07:00

162 lines
6.1 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { expect, test } from '@playwright/test';
/**
* The token layer, asserted the only way that counts: at runtime, in a browser.
*
* An undefined custom property paints NOTHING and reports NOTHING, and a property
* defined with the wrong VALUE TYPE is worse — the literal fallback in
* `var(--border, #1f1f1f)` only fires when the name is undefined, so a name
* declared as shadcn HSL channels (`--border: 0 0% 11%`) makes
* `1px solid var(--border, #1f1f1f)` an invalid shorthand and the whole
* declaration is dropped. That is exactly how @hanzo/iam's account menu rendered
* with `border: 0px none` on this surface while every grep and every type-check
* said it was fine. "It is declared" is not evidence; resolution is.
*/
const HOST = 'http://localhost:3080/';
/**
* Names a library sets on a DESCENDANT at runtime, so they are legitimately absent
* from <html>: tailwind's internals, Radix and Ariakit's positioning properties,
* Sandpack's whole theme (--sp-*, --test-*), and shiki/streamdown/sonner/vaul.
* `--f` is a @supports feature probe. Everything else must resolve.
*/
const RUNTIME_PREFIXES = [
'--tw-',
'--radix-',
'--shiki-',
'--streamdown-',
'--sonner-',
'--vaul-',
'--hz-',
'--sp-',
'--test-',
'--popover-available-',
];
/** `--f` is a @supports feature probe in a vendored stylesheet, not a token. */
const RUNTIME_NAMES = ['--f'];
test.describe('Design tokens', () => {
for (const theme of ['dark', 'light'] as const) {
test(`every token the served CSS references resolves (${theme})`, async ({ page }) => {
await page.goto(HOST, { timeout: 15000 });
await page.evaluate((t) => {
document.documentElement.classList.remove('dark', 'light');
document.documentElement.classList.add(t);
}, theme);
const unresolved = await page.evaluate(
({ prefixes, names }) => {
const declared = new Set<string>();
const referenced = new Set<string>();
const collect = (text: string) => {
for (const m of text.matchAll(/(--[a-zA-Z0-9-]+)\s*:/g)) declared.add(m[1]);
// Only names used WITHOUT a literal fallback can fail silently to nothing.
for (const m of text.matchAll(/var\((--[a-zA-Z0-9-]+)\s*([,)])/g)) {
if (m[2] === ')') referenced.add(m[1]);
}
};
for (const sheet of Array.from(document.styleSheets)) {
let rules: CSSRuleList;
try {
rules = (sheet as CSSStyleSheet).cssRules;
} catch {
continue; // cross-origin
}
for (const rule of Array.from(rules)) collect(rule.cssText);
}
const cs = getComputedStyle(document.documentElement);
return Array.from(referenced)
.filter((n) => !prefixes.some((p: string) => n.startsWith(p)) && !names.includes(n))
.filter((n) => !declared.has(n))
.filter((n) => cs.getPropertyValue(n).trim() === '');
},
{ prefixes: RUNTIME_PREFIXES, names: RUNTIME_NAMES },
);
expect(unresolved, `custom properties referenced with no fallback and never defined`).toEqual(
[],
);
});
test(`the @hanzo/iam account menu paints (${theme})`, async ({ page }) => {
await page.goto(HOST, { timeout: 15000 });
await page.evaluate((t) => {
document.documentElement.classList.remove('dark', 'light');
document.documentElement.classList.add(t);
}, theme);
// The rule @hanzo/iam >=0.21.1 injects for `.hz-iam-menu`, verbatim.
const painted = await page.evaluate(() => {
const el = document.createElement('div');
el.style.cssText = [
'position:fixed;top:-9999px;left:0;width:240px;height:120px',
'color: var(--foreground, #ededed)',
'background: var(--popover, #0a0a0a)',
'border: 1px solid var(--border, #1f1f1f)',
'border-radius: var(--radius-lg, 0.75rem)',
'box-shadow: var(--shadow-floating, 0 25px 50px -12px rgb(0 0 0 / .25))',
'font-size: var(--text-sm, 0.8125rem)',
].join(';');
document.body.appendChild(el);
const s = getComputedStyle(el);
const out = {
borderStyle: s.borderTopStyle,
borderWidth: s.borderTopWidth,
borderColor: s.borderTopColor,
background: s.backgroundColor,
radius: s.borderTopLeftRadius,
shadow: s.boxShadow,
color: s.color,
fontSize: s.fontSize,
};
el.remove();
return out;
});
expect(painted.borderStyle, 'menu border was dropped').toBe('solid');
expect(painted.borderWidth).toBe('1px');
expect(painted.borderColor).not.toBe('rgba(0, 0, 0, 0)');
expect(painted.background, 'menu box is transparent').not.toBe('rgba(0, 0, 0, 0)');
expect(parseFloat(painted.radius)).toBeGreaterThan(0);
expect(painted.shadow).not.toBe('none');
expect(parseFloat(painted.fontSize)).toBeGreaterThan(0);
});
}
test('the stacking ladder is @hanzo/design’s, and it is ordered', async ({ page }) => {
await page.goto(HOST, { timeout: 15000 });
const z = await page.evaluate(() => {
const cs = getComputedStyle(document.documentElement);
const n = (k: string) => Number(cs.getPropertyValue('--z-' + k).trim());
return {
raised: n('raised'),
sticky: n('sticky'),
header: n('header'),
dropdown: n('dropdown'),
overlay: n('overlay'),
modal: n('modal'),
popover: n('popover'),
toast: n('toast'),
tooltip: n('tooltip'),
notification: n('notification'),
};
});
const ladder = [
z.raised,
z.sticky,
z.header,
z.dropdown,
z.overlay,
z.modal,
z.popover,
z.toast,
];
expect(ladder.every((v) => Number.isFinite(v))).toBe(true);
expect(ladder).toEqual([...ladder].sort((a, b) => a - b));
// @hanzo/brand spelled the top two rungs differently; both must land on one rung.
expect(z.tooltip).toBe(z.popover);
expect(z.notification).toBe(z.toast);
});
});