mirror of
https://github.com/hanzoai/chat.git
synced 2026-09-30 20:51:17 +00:00
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.
162 lines
6.1 KiB
TypeScript
162 lines
6.1 KiB
TypeScript
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);
|
||
});
|
||
});
|