Files
zeekay 96c95b31ed Arabic: the companions from @hanzo/font 1.9.4, --font-arabic first in every stack, and an arabic axis; 0.5.37
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.
2026-10-22 19:34:37 -07:00

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), with outline suppressed — 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) at outline-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)):

  1. a hairline edge — --border
  2. 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.
  3. 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.mjs fails 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-highlight all 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-sm is a name this package does not own (@hanzo/brand declares it at :root for 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/2 can 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 second createGui/createTokens/createFont. Every @hanzo/ui component 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/v4 while the fleet was on v5, and it disagreed with the canon on nearly every rung: $4 14 vs 15, $9 32 vs 26, $12 64 vs 48, $14 112 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's styles/variables.css loaded 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 into hanzo-design.allow.json and 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 tracks and Wrap with 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:clip on 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 proximity from 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-raised and --z-sticky). --header and --dock stay 0 until a surface measures its bars.
  • roles.css: .display (book weight, one ink — no gradient clips or dim second lines), .title, .eyebrow, .lede and their rhythm; .action, .pill, .more and .leaf in 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

  • --ring sits 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 .light block, and check 4 fails the build if one is missed. It scans colors.css AND elevation.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-100 was near-white text on the light theme's white popover.
  • Surfaces are cut from #262626 at .35/.5/.75, not from #171717 at .4/.5/.8. The old recipe composited to #101010 on a #0a0a0a page — 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-overlay was rgb(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.
  • --destructive is 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 #ef4444 stays the error hue, and white on it is 3.8:1. check-tokens holds every fill and its label at 4.5:1 in both themes.
  • Every interactive state must be VISIBLE. --secondary-hover and --primary-hover exist 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 → .16 is a state nobody can see, and a state nobody can see is dead code.
  • Typography is Zen, one family, ours, declared in tokens/fonts.css and shipped as binaries in assets/fonts/. The tokens, guidelines/DESIGN.md and prompts/studio-designer.md all 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.md is 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.