Zen draws no Arabic. tokens/fonts.css now declares Noto Kufi Arabic (the default) and Noto Sans Arabic, each under its own family with an Arabic unicode-range, and names --font-arabic first in the sans, mono and serif stacks. Latin passes the companion over without a download, so an English page fetches what it did before, and the first available font a line box is measured from is still Zen. The preference gains an arabic axis (kufi | sans), set by reference to --font-arabic-kufi and --font-arabic-sans. It is its own axis because a face and an Arabic companion are two questions: serif with Kufi is two names, not a pairing enum. check-preference holds the axis and that every stack names the slot first.
23 KiB
@hanzo/design
The Hanzo design system: tokens, components, guidelines and the prompts that
teach it to a model. Published as @hanzo/design on npm. Consumed by every
surface that renders Hanzo — and by studio.hanzo.ai, which reads it to condition
what it generates.
Canon
The derivation runs one way and never back:
@hanzo/logo the mark geometry (MARK_PATHS)
-> @hanzo/brand who a host is, and which mark it gets
-> @hanzo/design how anything built for that brand should look
tokens/*.css is the source of truth. Ten files — colors (copied from
@hanzo/tokens), typography, spacing, grid, radius, elevation, motion, z, fonts,
base — and everything else is derived from them. scripts/gen-tokens.mjs parses all ten in
a fixed order and emits src/tokens.gen.ts and the flattened styles.css. Both
are generated AND committed; never hand-edit either.
The two woff2 under assets/fonts are generated too. @hanzo/font authors
Zen; this package only carries a copy, because tokens/fonts.css must declare the
faces rather than @import them (an @import is dropped when @hanzo/ui folds
that file into the middle of its theme.css — measured). gen-tokens.mjs copies
the two variable faces out of the installed @hanzo/font on every run, so the
copy cannot drift from its author. It was hand-placed once, and the files turned
out to be the upstream binaries renamed on disk only: their internal name table
still read the family Zen was derived from, so every font picker, PDF embed and
design tool reported the wrong typeface while the browser rendered correctly and
hid it.
spacing vs grid is the one boundary worth stating, because both are "layout"
and it would be easy to write a value twice. spacing.css owns DISTANCES and the
field they apply to — the --space-* ramp, --container-*, --gutter*,
--section-y*. grid.css owns only what a grid knows that spacing does not:
how many columns (--grid-columns: 12), how far apart (--grid-gap*, which
REFERENCE --space-* rather than restating rem values), where it reflows
(--breakpoint-*), and the intrinsic card track (--grid-card-min). Nothing is
declared in both. --breakpoint-* deliberately uses the same names AND values as
Tailwind v4's defaults so the utility layer and the tokens resolve one value, not
two that happen to agree.
How this ships
bump `version` in package.json, merge to main on github.com/hanzoai/design
-> .github/workflows/publish.yml on a cluster runner (linux-amd64)
version ahead of npm's latest? pnpm build, git diff the generated
files, pnpm pack, read NPM_TOKEN from KMS, npm publish the tarball
Nothing publishes from a laptop. The workflow is hanzoai/i18n's: it asks the
registry, not the diff, so any other push publishes nothing; the token comes
from KMS at run time (org secrets KMS_CLIENT_ID/KMS_CLIENT_SECRET,
POST /v1/kms/auth/login, GET /v1/kms/secrets/NPM_TOKEN?env=prod) and is
masked. pnpm build is the gate — it regenerates the tokens, runs the suite
and emits dist/; dist/ is gitignored, so a publish without it ships an
empty . export, and the pack step refuses a tarball with no dist/index.js.
GitHub Actions is on for this repo for that reason; the same push runs
cicd.yml, the hanzo.yml checks.
Structure
tokens/ # 10 hand-authored .css files — THE source of truth
src/
tokens.gen.ts # generated from tokens/ by scripts/gen-tokens.mjs; committed
index.ts # re-exports tokens.gen + cssVar(name, fallback?)
components/ # 21 parts in 4 groups (core, forms, layout, feedback),
# each a .jsx + .d.ts + .prompt.md triple
guidelines/ # prose + *.card.html specimens, each headed by a
# <!-- @dsCard group= viewport= name= subtitle= --> line
prompts/ # 5 markdown files; the entry point is the skill, not these
skills/
design-system/SKILL.md # the ONE skill that teaches the system to a model
scripts/
gen-tokens.mjs # tokens/*.css -> src/tokens.gen.ts (deterministic)
check-tokens.mjs# the gate: every token resolves, contrast ratios hold
lint.mjs # the consumer-code gate; `pnpm test` runs it on the WHOLE package
check-lint.mjs # the flex-prop floor, run in a scratch directory against fixtures
styles.css # serves all 10 token files to a consumer, flattened
Commands
pnpm install # install dev deps
pnpm gen # regenerate src/tokens.gen.ts from tokens/
pnpm test # check-tokens + lint every file in the package
pnpm build # gen + test + tsc (the release gate)
Editing
Change a value in tokens/*.css, then pnpm build. The generator is
deterministic, so the same input always yields the same tokens.gen.ts, and
check-tokens.mjs fails the build if a token stops resolving or a contrast
ratio drops below its floor. Everything outside tokens/ is either generated
from it or hand-authored prose.
tokens/colors.css is not authored here. @hanzo/tokens (hanzoai/ui
pkg/tokens/src/theme.ts) owns the colour system; gen copies its
css/colors over this file on every build, the way it copies the faces from
@hanzo/font. A colour token goes in theme.ts, a tokens release, then a bump of
the devDependency here.
A person's preference — src/preference.ts
vars(pref) maps a preference to custom properties and touches no document;
@hanzo/appearance applies it. Axes: type (--type-scale), ratio
(--type-ratio), modular (display rungs), density (--density), font
(--font-sans), arabic (--font-arabic), width (--container-*), radius
(--radius-scale, every --radius-* but the pill), accent, and theme, which
is a class and emits nothing here.
Arabic is its own axis because Zen draws none. tokens/fonts.css declares two
companions from @hanzo/font (Noto Kufi Arabic, the default, and Noto Sans
Arabic), each under its own family with an Arabic unicode-range, and names
--font-arabic FIRST in the sans, mono and serif stacks: Latin passes the
companion over without a download and stays in the chosen face, so one property
sets Arabic under every face but system, which leaves every script to the
platform. The default is also declared under Zen and Zen Mono, which only
composites because its descriptors equal Zen's (weight 100 900): a companion at
another weight wins the weight match for Latin too and sends it to the system
sans, silently.
An accent is a family: --primary, --accent, their -foreground (design's
#0a0a0a or #fafafa, whichever contrasts more — ink()), and their -hover
(color-mix toward that ink). A colour brightness() cannot read — named
colours, wide-gamut spaces — is not an accent. check-preference.mjs asserts
every emitted name is one a token file declares or reads.
Borders — ONE ladder, graded by duty
Every boundary is cut from the alpha ladder, so every boundary composites
correctly on the page, on a card and inside a popover. The rungs are the
reference's own measured values (hanzoai/extension sidebar.css).
| token | value (dark) | owes | spent on |
|---|---|---|---|
--border |
rgb(255 255 255 / .10) |
nothing | the hairline: cards, panels, dividers, rows |
--border-strong |
rgb(255 255 255 / .16) |
nothing | the same hairline on hover / emphasis |
--border-control |
rgb(255 255 255 / .15) |
nothing | a control's resting edge — input, select, textarea |
--border-focus |
rgb(255 255 255 / .22) |
nothing | that control, focused |
--border-selected |
rgb(255 255 255 / .30) |
nothing | the CHOSEN thing — active tab, current tile, featured plan |
--ring |
rgb(255 255 255 / .40) |
3:1 | the focus indicator, and only that |
0.4.1 cut the middle three from the NEUTRAL ladder because a boundary that
clears WCAG 1.4.11's 3:1 on a near-black ground IS a mid-grey and no tuning
changes that. True — and also what a wireframe looks like: a 1px #737373 box
around every field, four times heavier than the hairline beside it, so a form
read as a debug overlay. 0.4.2 spends the contrast budget where it is actually
load-bearing instead. A resting edge is an affordance; a focus ring is a
position. Only the second one is what you navigate by when you cannot see
well, so --ring keeps the gate (check-tokens.mjs pins it at 3:1 in both
themes, and it clears it BECAUSE it is alpha — on a lifted surface the ring
lifts with it, 3.67:1 dark / 3.85:1 light worst case, which a fixed grey cannot
do). Nothing else is gated.
Focus is not one treatment but two, and both are in tokens/base.css:
- a control that already HAS an edge brightens it (
.15→.22) and gains a soft halo (--ring-focus), withoutlinesuppressed — the reference's.composer-box:focus-within. A hard ring around a box that is already a box draws two boxes. - anything else gets
outline: 2px solid var(--ring)atoutline-offset: 2px.
Elevation — light, not shadow
You cannot cast a shadow onto black, so a system that ships only box-shadow
reads flat however heavy the alphas get. Depth here is three separable parts,
composed at the use site (box-shadow: var(--edge-highlight), var(--shadow-lg)):
- a hairline edge —
--border - an inset top highlight —
--edge-highlight, the 1px white line that reads as the surface catching light from above. This is the part that was missing and the part that does most of the work. - a wide, very dark drop —
--shadow-*
Plus --bloom (the glow around a LIT action on hover — in a monochrome brand
this is where "alive" comes from instead of a hue) and --sheen-edge (a top
hairline that dissolves before either corner, so an edge never ends in a hard
stop).
All of them are white, so all of them are restated in .light — including
--edge-highlight, which becomes inset 0 0 0 0 transparent rather than none
because none is not a legal layer in a comma list and would invalidate the
whole declaration, taking the drop shadow with it.
Paper — depth carries state
The parts above are the physics. The paper ramp is the vocabulary built on them, and it is what a product actually reaches for: three levels, each a tint AND the light on it, so a surface asks for a level instead of composing its own shadow.
| level | tint | light | means |
|---|---|---|---|
| 0 | --sheet-0 |
none | the workspace ground |
| 1 | --sheet-1 |
--shadow-sheet-1 |
an active tool — the transcript, a panel |
| 2 | --sheet-2 |
--shadow-sheet-2 |
what opened, or what sits inside |
Plus --fold / --fold-face: a corner turned back, painted as a
background-image on the sheet itself (no element, no pseudo-element), square
that corner, and spend it ONLY where the item expands.
Three properties hold it together:
- Raise before you outline. If two surfaces need separating, lift one. A raised surface does not also get a box drawn round it.
- One light, above. Every shadow is
0 <y> …;check-tokens.mjsfails the build on a sideways offset, because the defect is invisible per-component and only the whole screen stops making sense. - The tints and the fold defer, so they invert for free —
--surface-card*,--glass-strong,--border-strong,--edge-highlightall flip underneath, and check 4 stops expanding at exactly those. The two SHADOW rungs spell their drops out instead, and that is what makes the ladder shareable:--shadow-smis a name this package does not own (@hanzo/branddeclares it at:rootfor a white canvas and load order decides), so a rung built on one is a rung somebody else can flatten.--shadow-sheet-*is ours alone — which is why@hanzo/ui's.elevation-1/2can read it rather than keep a private copy. The numbers ARE rung 1 and rung 2 of the ramp; move one, move both.
The tints are the surface recipes, not new values, so sheets stack the way paper
does: each is an alpha-white wash, one laid on another lands a step lighter, and
the stack converges on #262626 instead of blowing out.
Mobile
Defaults are mobile-first and cost an app nothing:
--space/--section-y/--header-height are authored at PHONE values and scale
up at min-width:48rem; --gutter is max(1rem, safe-area-inset), so a notch
is cleared with no per-app rule (needs viewport-fit=cover on the host page).
Under @media (pointer:coarse) base.css gives every control a 44px
--tap-target floor, expands checkbox/radio/switch hit areas to 44px with a
pseudo-element while the 16px box is untouched, and renders fields at
--text-control = 16px so iOS Safari stops zooming the viewport on focus.
--text-control is a TOKEN and not a rule because component sizes are set
inline, and an inline style outranks any stylesheet — a var() is the only thing
that reaches them.
The four layers, and the gate that holds them
The estate has exactly four layers, and a surface is meant to be pure composition over them — data and layout, no design decisions:
| layer | package | owns |
|---|---|---|
| values | @hanzo/design | tokens, the type ramp, the space ramp, the paper ladder |
| structure | @hanzo/gui | the primitives everything is built from |
| chrome | @hanzogui/shell | header, search, product nav, launcher, footer |
| components | @hanzo/ui | everything assembled from the three above — and the ONE createGui table (@hanzo/ui/gui-config) that binds the ramp to gui's $n |
hanzo-design-lint is the gate. Rules 1-8 catch a surface writing a VALUE it
should have named — and rule 6, inline-style, catches it writing CSS at all:
ANY style={…} object in a .jsx/.tsx file, literal or named, because a
token read through style still bypasses the gui prop or @hanzo/ui component
that should carry it. A value nothing else can express (a per-frame transform on
a raw <canvas>, a custom property a package reads) passes when a comment that
begins inline-style: sits on its line or the line above, saying why. This
package's own components/ are the self-contained parts — plain React, inline
var()s by design — so the repo's hanzo-design.allow.json holds their count.
Rules 9-12 catch a surface OWNING a layer it should have imported, which is the failure that costs a fleet its coherence rather than a component its colour. Each has shipped:
local-token-table— a secondcreateGui/createTokens/createFont. Every@hanzo/uicomponent asks the HOST's table for its sizes, so a second table means one library renders at two sizes depending on the site. hanzo.ai carried a 244-line one on@hanzogui/config/v4while the fleet was on v5, and it disagreed with the canon on nearly every rung:$414 vs 15,$932 vs 26,$1264 vs 48,$14112 vs 64.redeclared-token— a surface declaring a name this package declares. That is a fight decided by LOAD ORDER, which is not a decision anyone made. A NEW name of your own is fine; this is collision only. It is a DECLARATION, never a mention — in CSS--x:at the head of a declaration, in JS a quoted key, because matching the bare name read every comment about which rung to use as a redeclaration of it.second-publisher— a second sheet of the same tokens.@hanzo/brand'sstyles/variables.cssloaded ahead of this one in hanzo.app: 136 names with no knob under any of them and a--shadow-*ramp tuned for a WHITE canvas (.05/.1 against this one's .40/.55), so wherever load order let it win, a panel was flat.shell-owned-chrome— a local header, search control, product menu, org switcher, launcher or footer. One set of controls fleet-wide is what makes them read as one product; a copy drifts by a release. hanzo.ai and hanzo.app drew two different search controls from the same shell.
The ratchet — hanzo-design.allow.json
A gate that can only pass on a perfect tree never gets turned on, and a gate
nobody turns on prevents nothing. So a surface declares what it currently owes,
per rule, in hanzo-design.allow.json at its root (read from the CWD, which is
where every package script runs), and the number may only SHRINK:
{ "raw-color": 412, "redeclared-token": 6 }
Over the allowance FAILS — that violation was written today. Under it is
REPORTED and passes, naming the number to write down. It failed for one
release, and that froze hanzo.app's inside an hour: install, typecheck and 1,641
tests green, the design gate red in one second, build-amd64 skipped, no image
cut for a commit with nothing wrong in it. The count a tree produces is not
identical everywhere — a working copy holds files a fresh checkout does not — so
an allowance that must match EXACTLY turns a difference of environment into a red
release, which is the same shape as the chart gate that stopped hanzo.ai
publishing that morning. A gate that stops the shipping is worth less than the
shipping. It is still a ratchet: the allowance moves only by hand, and the line
says exactly what to move it to. A rule with no entry is allowed ZERO, so a new
rule is live everywhere the day it ships and nobody has to opt in.
The floors — flex-prop, counted per directory, off until recorded
flex-prop (rule 13) counts flex, grow, shrink, flexBasis and
flexWrap (and flexGrow/flexShrink) on JSX elements, as attributes or as
keys of an attribute's object ($sm={{ flex: 1 }}), except on a direct child
of a <Wrap>. It is the layout @hanzo/gui 8.4 replaces: a stack sizes its
children with tracks="auto 1fr auto", and Wrap is the one place
content-sized wrapping lives, where flex still means something on its children.
It reads the element tree with a small JSX reader in lint.mjs (flexProps),
because "a Wrap's child" is not a fact about a line.
It is a migration, not a defect, so it is a FLOOR rather than a rule:
- Off until a surface names it. hanzo.ai alone carries 761; live at zero it would fail every consumer on its next bump of this package.
- Per directory, a file's first two path segments from the CWD — the unit
hanzo.ai's tailwind floor counts by, so a regression names where it landed
and one directory paying down cannot hide another growing:
{ "flex-prop": { "app/(marketing)": 287, "components/home": 66 } }. - Turned on by
--record, which writes today's counts for every floor intohanzo-design.allow.jsonand passes:npx hanzo-design-lint --record components app, then commit the file. From then on a directory over its count fails, under it is reported with the number to lower it to, and a directory the entry does not name is allowed zero. - The plan beyond it (owner): codemod the fleet onto
tracksandWrapwith screenshot diffs, then enforce at zero, then flip the stack default to grid in gui 9.
scripts/check-lint.mjs runs the real script in a scratch directory against
fixtures holding every shape the reader has to get right (a type argument, a
generic arrow, a regex with a quote, a comment before an element, a Wrap's
child through a fragment and a map, its grandchild) and checks off, record,
growth, slack and a misspelt key.
What a surface no longer writes
A surface mounts base.css and roles.css and states no CSS of its own. What used to be restated per site now lives here:
- base.css: no sideways scroll (
overflow-x:clipon html and body), the safe-area insets on body, mono cells (no ligatures, no brand features on code/pre/kbd/samp/.hz-mono), the bare-button reset, the 24px pointer floor and the 44px thumb floor (!important, because gui sizes inline; a switch or checkbox keeps its box), icon-only controls as centred boxes, underline on hover only in running text and never inside nav/aside/header or the shell's slots, the ring's colour at rest,scroll-snap-type: y proximityfrom 1024px, and an<img height>with no width keeps its height. - Brand voice:
data-brand="hanzo|lux"on<html>sets--font-feature-settings(fonts.css). Zoo is the default. - Tokens:
--page-gutter(the shell's inset),--edge, the chrome material (--chrome,--chrome-blur,--chrome-panel), the workspace pane (--pane-*, with light, reduced-transparency and no-blur values) and the page's own z rungs (--z-under…--z-banner, between--z-raisedand--z-sticky).--headerand--dockstay 0 until a surface measures its bars. - roles.css:
.display(book weight, one ink — no gradient clips or dim second lines),.title,.eyebrow,.ledeand their rhythm;.action,.pill,.moreand.leafin both themes;.claim,.quiet,.chrome,.lattice(ruled in--foreground),.run, and ONE.prose— a document by default,.prose[data-compact]for a chat message.
Notes
--ringsits at 3.67:1 (dark) / 3.85:1 (light) against its worst-case neighbour,--secondary. It is the only gated pair in the system.- The
--white-*opacity ladder does NOT invert — it is white-on-white in.light. Any SEMANTIC token built from it must be restated in the.lightblock, and check 4 fails the build if one is missed. It scanscolors.cssANDelevation.css, and matches white-alpha ANYWHERE INSIDE a value, because the light vocabulary buries its colour inside a shadow or a gradient. Expansion stops at any token that is itself restated — deferring to a token that flips IS the fix, so--ring-focus: 0 0 0 3px var(--ring-halo)is theme-safe. Components must paint from--glass/--glass-strong/--surface-*, never from a--white-*rung — and never from a--neutral-*rung either: that is a palette, it does not invert, and a menu item painted--neutral-100was near-white text on the light theme's white popover. - Surfaces are cut from
#262626at.35/.5/.75, not from#171717at.4/.5/.8. The old recipe composited to#101010on a#0a0a0apage — six levels above the page, which is not a surface, and three such cards read as one flat field with hairlines ruled across it.--surface-overlaywasrgb(10 10 10 / .95): the page, at 95% of the page. A floating panel that is the same colour as the page is not floating. --destructiveis red, not a grey: "Delete everything" and "you cannot click this" must not be the same colour. It is a FILL under--destructive-foreground, so it is red-600#dc2626(4.6:1 under#fafafa, 4.8:1 under white), hover red-700#b91c1c;--state-error#ef4444stays the error hue, and white on it is 3.8:1.check-tokensholds every fill and its label at 4.5:1 in both themes.- Every interactive state must be VISIBLE.
--secondary-hoverand--primary-hoverexist because the first resolved to its own resting value and the second dropped 16%, which reads as the button going disabled under the cursor. An unfilled button's hover is the surface arriving, not the edge moving:.15→.16is a state nobody can see, and a state nobody can see is dead code. - Typography is Zen, one family, ours, declared in
tokens/fonts.cssand shipped as binaries inassets/fonts/. The tokens,guidelines/DESIGN.mdandprompts/studio-designer.mdall say so — that last pair used to name Basel Grotesk while the tokens named something else, and studio.hanzo.ai reads those prompts, so the drift was teaching customers to self-host a licensed face. CLAUDE.mdis a symlink to this file.
License
Dual-licensed MIT OR Apache-2.0 (LICENSE-MIT, LICENSE-APACHE), replacing the
previous BSD-3-Clause declaration. Original Hanzo work standardises on this pair per
HIP-0137 "One License" (hanzoai/hips, HIPs/hip-0137-one-license.md); forks keep
their upstream licence unchanged.