6 Commits
Author SHA1 Message Date
zeekay e8e4ea81e4 fix(type): the console carries its own faces, because the CDN never had them
The shipped default fetched Zen from cdn.hanzo.ai. Nothing publishes it there:
every /fonts/zen/* path answers 404, while /fonts/geist/1.7.2/ still answers 200
because those bytes were put there by hand. So every first paint of this console
asked for two files that are not there and fell to the system stack — in a
compliance product whose own tests assert it is set in Zen.

The self-hosting path already existed as FONT_ORIGIN=, and it is now the
default: vite emits the two faces out of @hanzo/font at the same paths, served
same-origin. The CDN stays reachable through FONT_ORIGIN for the day the fleet
publishes there, and the URL contract is still pinned so a rename cannot pass
unnoticed.

Three things follow, and each is a tightening rather than a trade:

font-src drops its host. A cross-origin face has to be named in the policy; a
face we carry does not, so the cheapest possible widening of a CSP is now no
widening. csp.txt and the chart's copy move together, as they must.

The cross-origin assertion gets stronger. It used to allow exactly one origin,
ours; it now expects the set to be EMPTY — no subresource loaded from anywhere
but here. An origin nothing fetches from cannot serve the wrong bytes, go down,
or be asked for a beacon.

And the pin moves to @hanzo/font 1.9.2. 1.9.0 and 1.9.1 shipped a zen.css whose
:root block was missing, so the two custom properties a non-Next consumer reads
were undefined — which computes to an invalid value and falls to the OS stack,
silently.
2026-08-28 11:14:32 -07:00
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
zeekayandHanzo Dev 00bd8f8103 ui: the console is set in Geist, served from our own CDN
The serif is gone, but what replaced it was the machine's own sans — whatever
system-ui happens to resolve to, which is a different face on every desktop the
console is opened on. Geist Sans is the UI face and Geist Mono the monospace one
across every Hanzo property, so this console is set in them too, and fetches
them from cdn.hanzo.ai: one copy, one version, one cache entry shared with every
other property, and no request to a font host that is not ours.

The stacks are NOT written down here. They are read from @hanzo/gui's font
packages, because a stack copied into an app is a second source of truth that
does not fail when the kit's changes — it quietly stops matching the other
hundred properties. src/font.ts owns only DELIVERY: which release, which files,
which origin, and the @font-face rules. The face is registered under the family
its own stack leads with rather than under a name repeated beside it, so the
face installed and the family asked for cannot drift apart.

Three faults were found on the way and are each fixed and each pinned:

  - The kit config had no `mono`, and ui.tsx asks it for `$mono`. A font token
    the config does not define resolves to nothing.
  - `.mono` lost to the kit. The kit dresses its own with
    `.font_body, … { font-family: var(--f-family) }` — one class, in an adopted
    stylesheet the cascade places after app.css — so a bare `.mono` tied on
    specificity and lost on order, and machine text asked for through the kit
    came out in the UI face: an id or a rate in proportional digits, in a
    column, which is exactly where it shows. `:root :is(…)` settles it on
    specificity, which no ordering can undo.
  - The preload needed `crossorigin` even where the origin is our own. A font is
    always fetched in CORS mode, and a preload that disagrees is a second
    download rather than a saved one.

The policy gains one host in one directive — `font-src 'self'
https://cdn.hanzo.ai` — and is byte-identical otherwise. No unsafe-inline, no
unsafe-eval, no second directive, and the CDN may serve this console faces and
not code, style, or a place to send data. The faces go on through the CSSOM with
the kit's stylesheet, so style-src still sees no stylesheet parsed from markup.

An install that cannot reach us builds with `FONT_ORIGIN=` and serves the same
two files, at the same paths, from its own origin, with the OFL text beside
them — a build argument, not a fork. It is built and driven in the suite rather
than reasoned about, because an escape hatch nobody exercises is found broken by
a customer, on an install, with no network to debug it over.

Geist 1.7.2, Vercel and basement.studio, SIL Open Font License 1.1 — confirmed
against the licence shipped with the exact files hosted, which are the pinned
`geist` package's and not a copy of them.

Every assertion is mutation-proven: put back the serif-era selector, the old
policy, a drifted filename, proportional figures or a bare preload, and it is
the named test that fails. 22 green. Bundle +1,808 B; the two variable faces are
141 kB off-bundle, fetched once and cached for a year.

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-01 17:47:44 -07:00
zeekayandHanzo Dev 9f1ea316b9 ui: the console is set in a typeface a browser has
It shipped in Times New Roman. Two independent causes, both silent, both found
by looking at the running console rather than by any check it passes:

The kit's default config names its families `System` and `Heading`. Those are
React Native names — that platform resolves them and this one does not — and the
kit writes the name straight into `--f-family`, which every component reads. A
family no browser knows is not an error; it takes the default, which is a serif.
So every control, heading and cell the kit owns was serif. The families are now
the web's own stack, given at createGui, so the kit emits it and nothing has to
be overridden downstream.

`--hz-font-sans` was declared in the theme bridge, which is on `.shell` and
`.gate`. `body` is outside both, so `font-family: var(--hz-font-sans)` there
substituted nothing, the declaration was invalid, and the document root fell
back to the same serif. A typeface answers to no theme — one stack serves dark
and light — so it belongs with the type scale at :root, which is where it is.

The invariant is in the kit suite, and it is mutation-proven: put either cause
back and it is the test that fails. It names the two stacks the console declares
and refuses every other resolved family, so the next family that means nothing
here fails the same way rather than needing its own case. 13 green.

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-01 15:06:10 -07:00
zeekayandHanzo Dev 2a7a9175ca ui: the kit reaches the screens, and what it renders has been looked at
v0.3.4 put @hanzo/gui behind the shell's primitives and left the six pages on
raw HTML: 58 controls and 90 arrangement divs that the kit's Input, Button,
TextArea and stacks were sitting there unused for. The pages compiled against
gui-backed wrappers, so every check passed and the console still wore a
lookalike. They are converted now — zero <input>, <button>, <select> or
<textarea> outside ui.tsx, and layout is a value at the use site rather than a
class to look up in app.css.

ui.tsx is the screens' whole vocabulary and the only place an exception lives.
Four are kept, each because the kit's answer would be wrong: the four state
colours (a state must mean the same in a screenshot), the meter (geometry is an
SVG attribute, not a style), the three auto-fit grids (the kit's stacks are
flexbox and flex-wrap strands the last item), and one native Select (the kit's
needs a react-native pan responder this bundle does not carry).

Looking at it found three defects that a build, a typecheck, a CSP run and a
render test had all passed:

  - `size` on a kit Button is a size TOKEN, so every label in the console was
    rendering at its button's height — 24px type in a 24px box.
  - Button does not forward className, so `tone` was dropped in silence: the one
    action a screen exists for looked like every other action, and the segment
    strips showed no selection at all.
  - The theme bridge sat at :root. A custom property is substituted where it is
    DECLARED, and the kit publishes themes as `:root .t_dark` — a descendant
    selector that can never reach :root — so the bridge read the kit's
    OS-preference default and handed every select a white ground and white ink.
    Also drops the Button sub-theme placeholders (`--t0: dark`) on the way: they
    are names, not colours, so tone reads the bridge instead.

Also found by looking: the panel body could not scroll, so a long case timeline
was unreachable, and the sign-in gate had lost its centring — both because CSS
addressed classes the components stopped rendering.

ui/e2e/kit.spec.ts is the guard, and it is a boundary AND a rendering check: it
reads these sources and fails on a raw control or a stylesheet arrangement in a
page, then measures a label, compares a primary button against a plain one and a
pressed segment against an unpressed one, and refuses any control that paints
its text in its own background colour. Written as invariants, not as three
regression cases, because the next one will not be the same bug. All six
mutation-proven: reintroduce the defect and the matching assertion fails.

12/12 browser tests green under the deployed CSP, signed in. The policy is
unchanged and still byte-identical to the chart. Go untouched: build, vet,
gofmt clean, 366 tests green under -race across 18 packages.

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-01 12:05:56 -07:00
zeekayandHanzo Dev 826ff1a773 ui: the console is the kit's, and the policy is proven rather than asserted
The console now renders through @hanzo/gui — the Hanzo design system — instead
of a set of primitives written here over the raw tokens. Layout, surfaces,
controls and type are the kit's. Two things deliberately are not: the four state
colours, which must not change with a theme because a supervisor's copy of a
screenshot has to mean what the screen meant, and the meter, which is drawn in
SVG so a width that varies with data is an attribute rather than a style.

The kit is imported at the part rather than at the barrel. @hanzo/gui's index
re-exports every component it has, including ones built on react-native
primitives this bundle never renders — Sheet's pan responder and the spinner's
ActivityIndicator are the two that pulled a native runtime into a browser build.
It is still one kit at one version.

The whole CSP question is now in ui/src/gui.ts, stated once. GuiProvider would
write the config's stylesheet as an inline <style>, which style-src governs and
which would need 'unsafe-inline' or a hash that moves whenever the theme does.
disableInjectCSS turns that off and the same CSS goes on through a constructed
stylesheet — CSP does not govern the CSSOM, because the script that reached it
had already passed script-src. The kit's per-component rules take the same route
into empty <style> holders, which the SHA-256 of the empty string allows and
which allows nothing else.

ui/e2e is the part that was previously a claim. It builds the bundle, serves it
under ui/csp.txt — byte-identical to the chart's HANZO_STATIC_CSP — seeds a
session before the first script runs, and drives Chromium through every screen
the rail offers. It carries a positive control: a style attribute parsed from
markup is asserted to be REFUSED, because "no violations" means nothing without
evidence that a violation would have been noticed. Writing it found two things a
claim would not have: the kit's theme root does carry a style attribute (React
writes it through element.style, so it applies and raises nothing — which is the
distinction that matters and is now written down), and a stray server on the
default preview port had been answering for a build nobody was testing.

origin() is now apiOrigin(protocol, host), a pure function with a table for a
test. It requires the first label to be `aml`, so evil.aml.hanzo.ai — a
four-label host the old rule read as a domain to derive from, and one that
resolves inside *.hanzo.ai — derives nothing. The certificate and the ingress
host rule were the only things standing there before.

@hanzo/iam is 0.21.5: logout ends the session (credentials + RFC 7009
revocation) and the callback error branch checks state before it reads the query
string. The console-side proof of the second is in the harness.

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-01 11:19:55 -07:00