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]>
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 plusproducts/[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) —prebuildrunstsc --noEmitfirst - Visual check:
pnpm build && node scripts/shots.mjsagainst a servedout/
Styling — no utility framework
Three layers, in app/globals.css import order, and there is no fourth:
@hanzo/design/tokens/fonts.css— the two faces. Zen and Zen Mono ship inside@hanzo/designas variable woff2 (SIL OFL-1.1) and the@font-faceurl()s resolve relative totokens/, so they come out of node_modules with no config and no request to a host we do not control.@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'sstyles.cssis not imported next to it: that delivers every token twice to say one thing. Dark is:root;.lightis the counterpart, written bynext-themes.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/designpublishes FINISHED colours — hex, and alpha hairlines likergb(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 asvar(--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/uionly NAMES the faces; the host declares them. The computed family cannot tell those two apart — only the font loader can, sodocument.fonts.check('16px Zen')is the proof, andscripts/shots.mjsasks it on every route. Its mono half is asked only where something on the page actually rendersZen Mono: a face loads when a glyph wants it, socheckis 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/uidepends 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/guideclared,@hanzogui/lucide-icons-2resolved to 8.0.1, dragged a second@hanzogui/core, and every@hanzo/uicomponent carrying an icon read a theme context no provider fills — the build died onMissing theme.at prerender. The cure is to declare them, which is whathanzo.aiand the console already do:@hanzogui/config,@hanzogui/lucide-icons-2,@hanzogui/next-themeand@hanzogui/webalongside@hanzo/gui, all on one minor.pnpm why @hanzogui/coremust report ONE version.- A class with no rule is invisible to the compiler.
pnpm buildrunsgui-css-check outaspostbuildfor 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 ingui-css-check.json, each with the source that emits it —btn/btn-*andSelectTriggerare@hanzo/uistable 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.