Files
zeekay 259ecc1411 fix(font): the console asked for Zen and was sent Geist
The kit moved to Zen — @hanzogui/font-zen names the families and @hanzo/font
ships the bytes — but only half of this console followed. src/gui.ts took the
stacks from the kit, so every rule asked for "Zen" and "Zen Mono", while
src/font.ts still fetched Geist from /fonts/geist/1.7.2 and registered those
bytes under the name the kit had just changed. A face installed under a name
the file inside it does not carry is the failure this suite exists to catch,
and font.spec.ts was left asserting Geist on both sides of it.

Delivery now reads the same package the kit points at: the self-hosted build
and the suite both take their bytes from @hanzo/font rather than from `geist`,
and the licence emitted beside them is that package's, which carries Vercel's
notice as well as ours because Zen is drawn from Geist. offline.spec.ts checks
for both holders now, since dropping the upstream line is the same defect as
dropping the licence.

The CDN does not yet publish /fonts/zen/1.9.1. Until it does, ship this with
FONT_ORIGIN= so the bundle serves the two files from the app's own origin.
2026-08-28 08:45:05 -07:00

211 lines
10 KiB
TypeScript

// Whether the kit actually reaches the screens.
//
// "The console is on @hanzo/gui" is easy to say and easy to half-do: swap the
// shell's primitives, leave the six pages on raw HTML, and every other check in
// this suite still passes. It renders, the policy is clean, the types compile.
// So this file asks the two questions those checks cannot.
//
// 1. SOURCE — does a page reach past the kit? A page composes what `src/ui.tsx`
// exports and writes no control and no stylesheet-class arrangement of its
// own. `ui.tsx` is where the exceptions live, each with the reason it is one.
//
// 2. RENDER — did the kit's props mean what they said? Three defects in this
// console got through a build, a typecheck, a CSP run and a render test, and
// were found only by looking at it:
//
// - `size` on a kit Button is a size TOKEN. Asking for a 24px-tall button
// gave a 24px LABEL in it.
// - The kit's Button does not forward `className`. Tone written as a class
// was dropped in silence, so the one action a screen exists for looked
// like every other action, and a segment strip showed no selection.
// - A custom property is substituted where it is DECLARED. The theme bridge
// sat at `:root`, which the kit's `:root .t_dark` can never reach, so a
// control took a white ground and white text and disappeared into itself.
// - The kit's default families are `System` and `Heading`, which are React
// Native names no browser resolves, and the sans stack was declared in
// the theme bridge that `body` is outside of. Both fall back to the
// default serif, so the console shipped set in Times New Roman.
//
// Each is silent, each is a lie about what the screen says, and each has an
// assertion below. They are written as invariants rather than as three
// regression cases, because the next one of these will not be the same bug.
import { expect, test } from '@playwright/test'
import { readFileSync, readdirSync } from 'node:fs'
import { join } from 'node:path'
import { signIn } from './session'
const SRC = new URL('../src', import.meta.url).pathname
const pages = () => {
const dir = join(SRC, 'pages')
return [
...readdirSync(dir).map((f) => ['pages/' + f, readFileSync(join(dir, f), 'utf8')] as const),
['app.tsx', readFileSync(join(SRC, 'app.tsx'), 'utf8')] as const,
]
}
test.describe('the kit reaches the screens', () => {
test('no screen writes a control of its own', () => {
// Every one of these has a kit-backed equivalent exported from ui.tsx. A
// page reaching for the raw element is a page that has left the kit, and
// it takes the console's focus ring, sizing and disabled semantics with it.
for (const [name, src] of pages()) {
const raw = src.match(/<(input|button|select|textarea|option)\b/g) ?? []
expect(raw, `${name} writes ${raw.join(', ')} instead of the kit's`).toEqual([])
}
})
test('no screen arranges itself out of the stylesheet', () => {
// Layout is a value at the use site — Row, Col, Stack, Cols, Tiles, Split,
// Scroll, Body — not a class name that has to be looked up in app.css. The
// three grids the kit cannot express are named components in ui.tsx and are
// the only place these class names appear.
const arrangements = /className="(row|cols|stack|split|tiles|scroll|body|grow|tabs|group|head|kids|cond|tile)"/g
for (const [name, src] of pages()) {
const found = src.match(arrangements) ?? []
expect(found, `${name} arranges itself with ${found.join(', ')}`).toEqual([])
}
})
test('the pages take their vocabulary from one place', () => {
// One import, so there is one answer to "what is a button here".
for (const [name, src] of pages()) {
if (name === 'app.tsx') continue
const imports = src.match(/^import .* from '([^']+)'$/gm) ?? []
const local = imports.filter((i) => /from '\.\./.test(i))
expect(local.length, `${name} imports locally from more than api and ui`).toBeLessThanOrEqual(2)
expect(src, `${name} should compose ../ui`).toContain("from '../ui'")
expect(src, `${name} must not import the kit directly`).not.toContain('@hanzogui/')
}
})
test('a label is the size it was asked for, not the size of its box', async ({ page }) => {
await signIn(page)
await page.goto('/cases')
await expect(page.locator('nav.rail')).toBeVisible()
// The console has two type steps for a control: 13px and 11px. A kit Button
// derives type from its size token, so a button asked for by height comes
// out with type to match unless the two are given separately. Measured on
// the text node, because the button's own font-size is not the label's.
const labels = await page.locator('button').evaluateAll((els) =>
els
.map((el) => {
const span = el.querySelector('span')
return span ? { text: (span.textContent ?? '').trim().slice(0, 20), px: parseFloat(getComputedStyle(span).fontSize) } : null
})
.filter((x): x is { text: string; px: number } => x !== null),
)
expect(labels.length).toBeGreaterThan(3)
for (const l of labels) {
expect(l.px, `"${l.text}" is ${l.px}px — the console's control type is 11 or 13`).toBeLessThanOrEqual(13)
expect(l.px, `"${l.text}" is ${l.px}px`).toBeGreaterThanOrEqual(10)
}
})
test('emphasis is visible, and a pressed segment is visible', async ({ page }) => {
await signIn(page)
await page.goto('/sanctions')
await expect(page.locator('nav.rail')).toBeVisible()
// A tone that does not survive the kit is worse than no tone: the screen
// still reads as though it has one. So the assertion is a comparison, not a
// colour — whatever primary is, it must not be what plain is.
const tones = await page.locator('button').evaluateAll((els) => {
const of = (re: RegExp) => {
const el = els.find((e) => re.test(e.textContent ?? ''))
if (!el) return null
const cs = getComputedStyle(el)
return { bg: cs.backgroundColor, color: cs.color }
}
return { primary: of(/^Search$/i), plain: of(/refresh/i) }
})
expect(tones.primary, 'the primary action was not found').not.toBeNull()
expect(tones.plain, 'a plain action was not found').not.toBeNull()
expect(tones.primary!.bg, 'a primary button looks exactly like a plain one').not.toBe(tones.plain!.bg)
await page.goto('/cases')
const segments = await page.locator('.tabs button').evaluateAll((els) =>
els.map((el) => ({
on: el.getAttribute('aria-pressed') === 'true',
bg: getComputedStyle(el).backgroundColor,
color: getComputedStyle(el).color,
})),
)
const on = segments.filter((s) => s.on)
const off = segments.filter((s) => !s.on)
expect(on.length, 'exactly one segment should be selected').toBe(1)
expect(off.length).toBeGreaterThan(0)
expect(
on[0].bg !== off[0].bg || on[0].color !== off[0].color,
'the selected segment is indistinguishable from the unselected ones',
).toBe(true)
})
test("the type is the console's typeface, not the browser's default", async ({ page }) => {
await signIn(page)
await page.goto('/cases')
await expect(page.locator('nav.rail')).toBeVisible()
// A family the browser cannot resolve is not an error: it silently takes
// the default, which is a serif, and the screen still renders. So the
// assertion names the two stacks this console declares and refuses
// everything else — a kit family that means nothing here (`System`), and a
// variable substituted in a scope that does not hold it, both land outside
// them. It reads the resolved family off elements the kit owns and elements
// it does not, because the two are dressed on opposite sides of the seam.
//
// The stacks are read from the document rather than written out here: they
// are @hanzo/gui's, and a copy of them in a test is one more place to drift
// from the kit. That the console is set in ZEN, and that the faces
// actually arrive and are what the renderer used, is e2e/font.spec.ts.
const { families, sans, mono } = await page
.locator('body, nav.rail, h1, h2, button, input, select, td, .grid .id, code')
.evaluateAll((els) => {
const root = getComputedStyle(document.documentElement)
return {
families: [...new Set(els.map((el) => getComputedStyle(el).fontFamily))].sort(),
sans: root.getPropertyValue('--hz-font-sans').trim(),
mono: root.getPropertyValue('--hz-font-mono').trim(),
}
})
expect(families.length, 'nothing was measured').toBeGreaterThan(0)
expect(sans, 'the console declares no sans stack').not.toBe('')
expect(mono, 'the console declares no mono stack').not.toBe('')
// Compared on the leading family, because the kit and the stylesheet quote
// and space a stack differently while meaning the same list.
const first = (f: string) => (f.split(',')[0] ?? '').trim().replace(/^["']|["']$/g, '')
const wanted = new Set([first(sans), first(mono)])
const stray = families.filter((f) => !wanted.has(first(f)))
expect(stray, `type is set in ${stray.join(' / ')}`).toEqual([])
})
test('nothing is invisible against itself', async ({ page }) => {
await signIn(page)
await page.goto('/relationships')
await expect(page.locator('nav.rail')).toBeVisible()
// The theme-bridge failure mode: a control whose ground and whose text both
// resolve through the same variable, read in a scope where that variable is
// the wrong theme's. It renders, it is styled, it violates no policy, and
// it cannot be read. Every control that paints a ground must differ from
// the ink on it.
const unreadable = await page.locator('select, input, textarea, button').evaluateAll((els) =>
els
.map((el) => {
const cs = getComputedStyle(el)
return { tag: el.tagName, bg: cs.backgroundColor, color: cs.color }
})
// A transparent ground takes the surface underneath, which is the
// surface the rest of the screen was already judged against.
.filter((s) => !/rgba\(0, 0, 0, 0\)|transparent/.test(s.bg))
.filter((s) => s.bg === s.color),
)
expect(unreadable, 'a control paints its text in its own background colour').toEqual([])
})
})