Files
zeekayandHanzo Dev 18c16ecf2c Zen is the face, and the check can prove it
The site rendered in the system fallback: @hanzo/design 0.5.x replaced Geist
with Zen, and this app pinned 0.4.6, so the family every surface asked for was
one nothing declared any more. Bumping design alone strands it — @hanzo/ui
inlines a copy of that token block — so the pair moves together, to
@hanzo/design 0.5.21 and @hanzo/ui 8.3.4.

@hanzo/ui depends on none of its @hanzogui/* peers; it declares them, so this
app installs them. With only @hanzo/gui declared, pnpm resolved
@hanzogui/lucide-icons-2 a generation behind, which dragged a second
@hanzogui/core, and every @hanzo/ui component carrying an icon then read a
theme context no provider fills — prerender died on "Missing theme.". Declaring
the cohort, as hanzo.ai and the console already do, collapses it to one core.

The old font test matched /geist|system-ui|sans-serif/, so it passed on the
fallback: the exact failure it existed to catch. Both checks ask the font
loader now. shots.mjs asks for Zen Mono only where a page renders mono, because
a face loads when a glyph wants it; mutation-checked by aborting **/*.woff2.

CommandPalette was controlled, nothing ever owned it, and its global handler
could only close — so Cmd+K opened nothing and its test had never passed. A
palette is the thing Cmd+K opens, so it owns the shortcut and mounts itself.

51 pages, 414/414 gui classes covered, 36/36 e2e, 60/60 routes across
phone+desktop x light+dark.

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-24 09:51:17 -07:00

9.6 KiB

Hanzo Industries

Defense and enterprise marketing site for Hanzo AI (hanzo.industries). Next.js 15 App Router (React 19, @hanzo/gui + @hanzo/ui), static export to out/, shipped on the Hanzo Sites plane.

Structure

  • app/(marketing)/ — all pages share one layout (Navbar + Footer + GlobalChatWidget). ~31 routes plus products/[slug] (14 dynamic product pages).
  • components/ — Navbar, Hero, Logo, GlobalChatWidget (SSE chat over Zen models), CommandPalette (Cmd+K).
  • lib/data/products.ts — the 14 product definitions.
  • public/llms.txt — LLM site summary.
  • components/Analytics.tsx — the telemetry root (@hanzo/event → api.hanzo.ai/v1/event). The one client; there is no GA, no Plausible, no separate error SDK.

Commands

  • Dev: pnpm dev (http://localhost:8080)
  • Build: pnpm build (static export) — prebuild runs tsc --noEmit first
  • Visual check: pnpm build && node scripts/shots.mjs against a served out/

Styling — no utility framework

Three layers, in app/globals.css import order, and there is no fourth:

  1. @hanzo/design/tokens/fonts.css — the two faces. Zen and Zen Mono ship inside @hanzo/design as variable woff2 (SIL OFL-1.1) and the @font-face url()s resolve relative to tokens/, so they come out of node_modules with no config and no request to a host we do not control.
  2. @hanzo/ui/styles.css — the tokens AND the classes @hanzo/ui's own components render into. Its token block is @hanzo/design's, byte-identical (257 of 257 values, plus 15 gui adds), which is why design's styles.css is not imported next to it: that delivers every token twice to say one thing. Dark is :root; .light is the counterpart, written by next-themes.
  3. app/system.css — a small semantic class vocabulary (hz-*) expressed in those tokens. Roles, not utilities: hz-card, hz-container, hz-section. Mobile-first — the base rule is the phone and @media (min-width: 768px) scales up, so a call site never spells out a breakpoint.

Components come from @hanzo/ui (Button, Input, Select, Toaster, cn), which renders on @hanzo/gui. No Tailwind, no Radix, no shadcn, no PostCSS config — if a value is missing the fix belongs upstream in @hanzo/design, not in a new stylesheet.

Both failure modes here are SILENT — an unknown @hanzo/gui prop is ignored, and an undefined CSS class or var() does nothing. Neither errors and neither fails typecheck, so a green build does not prove a visual change worked. Check pixels (scripts/shots.mjs), and check both themes — see .hz-ink-*, where six of eight partner marks were invisible on dark and two on light while the build was green.

Three silent traps, all of which have already been paid for once:

  • Never wrap a token in hsl(). @hanzo/design publishes FINISHED colours — hex, and alpha hairlines like rgb(255 255 255 / .10). hsl(var(--border)) is invalid at computed-value time, so the browser drops the WHOLE declaration and every colour it touched dies quietly. Consume as var(--border). Full stop.
  • Delete the fonts import and everything still passes. Build green, css-check green, and every surface renders in ui-sans-serif, system-ui. @hanzo/ui only NAMES the faces; the host declares them. The computed family cannot tell those two apart — only the font loader can, so document.fonts.check('16px Zen') is the proof, and scripts/shots.mjs asks it on every route. Its mono half is asked only where something on the page actually renders Zen Mono: a face loads when a glyph wants it, so check is false on a page that uses none. Mutation-checked by aborting **/*.woff2 — both assertions go false.
  • @hanzo/ui's @hanzogui/* peers are a COHORT, not a pair. @hanzo/ui depends on none of them; it declares them as peers, so THIS app installs them, and pnpm resolving one of them a generation behind is enough to break every component. It did: with only @hanzo/gui declared, @hanzogui/lucide-icons-2 resolved to 8.0.1, dragged a second @hanzogui/core, and every @hanzo/ui component carrying an icon read a theme context no provider fills — the build died on Missing theme. at prerender. The cure is to declare them, which is what hanzo.ai and the console already do: @hanzogui/config, @hanzogui/lucide-icons-2, @hanzogui/next-theme and @hanzogui/web alongside @hanzo/gui, all on one minor. pnpm why @hanzogui/core must report ONE version.
  • A class with no rule is invisible to the compiler. pnpm build runs gui-css-check out as postbuild for exactly that: it reads every class in the rendered markup against every selector in every delivered sheet, linked and inline. Zero misses, or the build fails. Allowances live in gui-css-check.json, each with the source that emits it — btn/btn-* and SelectTrigger are @hanzo/ui stable handles whose look is the gui atomic classes on the same element, not rules this app owes.

TypeScript stays on 5.x — tested, not preference

typescript@7 is the native Go compiler and it typechecks this tree in ~1.0s vs ~6.8s. It still cannot be used here: its "." export is lib/version.cjs, which exports version and versionMajorMinor and nothing else. The classic JS API (ts.sys, ts.readConfigFile, ts.parseJsonConfigFileContent) is gone, moved to typescript/unstable/*. Next reads compilerOptions.paths through exactly that API, so on TS7 the @/* alias never reaches webpack and every @/components/... import fails to resolve — next build dies with ~40 module-not-found errors.

It fails twice, at two different depths. With a next.config.ts, Next loads the config itself through that API and dies first: Cannot read properties of undefined (reading 'fileExists'). Renaming the config to .mjs — which this repo has done, for its own reasons — only moves the failure down to the paths read, and the ~40 unresolved @/* imports above.

Revisit when Next ships a TS7-compatible config loader. Do not add @typescript/native-preview to work around it: that package is 7.0.0-dev, behind 7.0.2 stable, and has the same JS-API surface.

How it ships

.hanzo/workflows/deploy.yml on the git.hanzo.ai forge (hanzo-build-linux-amd64): build out/ → POST /v1/projects/hanzo-industries/deploy (202, carrying a presigned upload grant) → POST each file under that grant → POST …/complete with the file manifest as keys. The bytes never pass through the API — BodyLimit is 16 MiB. No GitHub Pages, no Cloudflare Pages, and no image: a static export has no compute to run.

This repo holds NO S3 credential. The grant is confined to this site's prefix and expires in 30 minutes; deletion rides the manifest, because a write-only grant cannot remove a file. The secrets are KMS_CLIENT_ID / KMS_CLIENT_SECRET, set ON THE FORGE; hanzoai/ci's site action reads the deploy key out of KMS with them, so the key is resealed in one place instead of copied into each org's secret store — which is what the action it replaced, sitedeploy, could not do.

Brand policy (load-bearing)

Monochrome only (black/white, no accent colors). Present Zen models as Hanzo's own family — never name upstream models (GLM, Kimi, Qwen, etc.). Keep factual specs accurate.

Full docs: README.md

Design tokens

The palette is @hanzo/design, reaching this app inside @hanzo/ui/styles.css. :root is Hanzo's dark palette and .light retunes it (next-themes writes both classes). Do not declare a local :root/.dark palette — retune upstream in ~/work/hanzo/design.

app/providers.tsx sets defaultTheme="light", so a first visit with no stored preference paints light even under prefers-color-scheme: dark. This page used to claim "dark" and "Hanzo is dark-first"; the code has said light throughout, and both live builds render light. The token base being dark while the default is light is a real open question — decide it in providers.tsx, which is the one place that sets it, and correct this paragraph in the same commit rather than letting the two drift again.

That open question has a measurable cost, and the focus ring is where it shows. @hanzo/ui/styles.css ships the one indicator, :focus-visible{outline:2px solid var(--ring);outline-offset:2px}, and this app adds none of its own — .hz-input:focus-visible restated it at a 1px offset for a class with no call site and is deleted. Tabbing a local build of /: 73 of 81 stops ring at 2px, 8 do not clear 3:1. The page resolves html.t_light, so --ring is rgb(0 0 0/.5) — correct on the white ground, invisible on the sections that paint themselves dark, where it measures 1.03:1 on "Learn more". The five at 1.52:1 are the mirror case, a light rgba(179,179,179,.6) ring inside a t_dark subtree. One ring, one token, two grounds: the fix is at the surfaces that invert, never a second focus rule here.

(An earlier count of 11 was taken 35ms after the Tab and caught three rings mid-interpolation — outline-color animates. Let it settle before reading it.)

A token can be translucent. On dark, --surface-card is rgba(38,38,38,.5): right for a card sitting ON the page, wrong for anything floating OVER it, which will show the page straight through itself. Overlays take hz-bg.

The whole ladder arrives now, not colours only: type, spacing, radius, elevation, motion and the z-index scale as well. The old carve-out existed because Tailwind 4 claimed --text-*, --spacing and --radius-* as theme namespaces and would have restyled every size utility. There are no utilities left to restyle.