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.
7.7 KiB
Hanzo Design System
Monochrome. Dark by default. One hue rendered through an opacity ladder.
The single source of truth for how every Hanzo surface looks — tokens, components, brand assets, and the guidelines that hold them together.
@hanzo/design
If you are an agent
Read skills/design-system/SKILL.md. It is the
one way an AI surface learns this design system — chat, hanzo.app and Claude
Code all load the same file, so there is a single author and nothing to drift.
It ends by running the gate below, which is what makes the rules enforceable
rather than advisory:
npx hanzo-design-lint <paths…>
prompts/ holds the longer material the skill points at. It is reference, not a
second entry point.
Use it
One import pulls in the whole token layer — fonts, colour, type, spacing, radius, elevation, motion, z, base:
@import "@hanzo/design/styles.css";
Import the whole file. Cherry-picking tokens/*.css is how surfaces ended up
without the z ladder or the elevation ramp. The fonts are self-hosted, so this
makes no third-party request — there is no longer a reason to skip a part of it.
Programmatic tokens (control look & feel from code)
The same tokens are exposed to TypeScript — generated from the CSS, so the two
can never drift. Edit a token in tokens/*.css, run npm run build, and both the
stylesheet and the code API update together. This is the one place to drive Hanzo's
look & feel programmatically (the @hanzogui/shell theme, Tamagui, any TS surface):
import { colors, spacing, radius, zIndex, cssVar } from '@hanzo/design'
spacing['space-4'] // "1rem"
radius['radius-full'] // "9999px"
zIndex['z-header'] // 300
// Prefer cssVar() so a value resolves through the live cascade (honors light/dark):
element.style.background = cssVar('--background') // "var(--background)"
element.style.color = cssVar('--foreground', '#fff') // with a fallback
Groups: colors, typography, spacing, radius, elevation, motion, zIndex,
fonts, base (semantic aliases), plus cssVars (every token by its literal
--name).
Colours come from @hanzo/tokens. tokens/colors.css is
NOT hand-authored here — it is that package's generated bundle, pulled in by
scripts/gen-tokens.mjs and flattened into styles.css. To change a colour, edit
@hanzo/tokens src/theme.ts, run pnpm --filter @hanzo/tokens gen-css, then
npm run gen here to re-pull and re-flatten. The other token files (typography,
spacing, radius, elevation, motion, z, grid, fonts, base) are
authored here — they carry runtime knobs and element behaviour a flat token cannot.
Everything below is expressed as CSS custom properties, so code copies over 1:1 — the semantic names match hanzo.ai's variables exactly.
import { Button } from "@hanzo/design/components/core/Button.jsx";
import { HanzoLogo } from "@hanzo/design/components/core/HanzoLogo.jsx";
<Button pill>Try Hanzo</Button>
What's inside
| Path | What |
|---|---|
styles.css |
The one entry point — imports every token file below. |
tokens/ |
The palette. colors (monochrome opacity ladder, dark-default), typography, spacing, radius, elevation, motion, z, fonts, base. |
components/ |
core (Button, Card, Badge, Icon, HanzoLogo, Avatar, ChromeText…), forms, overlays, navigation — each as .jsx + .d.ts + a .prompt.md usage guide. |
prompts/ |
System-level generation guidance — the "make it look Hanzo" system prompt, do/don't rules, page prompts. |
content/ |
The words — brand voice and taglines. |
docs/ |
How to use the system — integrate the tokens, theme, extend. |
guidelines/ |
Specimen cards — color, type, spacing, brand, iconography — the visual reference. |
assets/fonts/ |
Zen + Zen Mono, self-hosted (two variable .woff2, 141 KB, SIL OFL-1.1), and the Arabic companions Noto Kufi Arabic (95 KB) and Noto Sans Arabic (133 KB), fetched only by a page with Arabic on it. No Google Fonts request. |
assets/ |
The mark, wordmark, favicon, provider + partner logos, brand imagery. |
scripts/check-tokens.mjs |
The gate: every token file is served, every internal var() resolves, and the contrast floors hold. Runs on npm run build. |
ui_kits/ |
Composed surfaces (e.g. SiteChrome) assembled from the components. |
Two kinds of boundary
Not interchangeable, and the difference is a conformance requirement rather than a matter of taste:
| Token | Kind | Contrast |
|---|---|---|
--border, --border-hairline, --border-card |
decorative — separates content | none required |
--border-strong |
perceivable — identifies a control (input edge, switch, checkbox) | ≥ 3:1, WCAG 1.4.11 |
--ring |
the focus indicator | ≥ 3:1, WCAG 2.4.11 |
Reach for --border-strong whenever the boundary is the affordance. Both
conformant tokens resolve to --neutral-500, the only rung on this ladder that
clears 3:1 on every surface in both themes — scripts/check-tokens.mjs
measures this on every build and fails if it ever stops being true.
Note that the --white-* opacity ladder does not invert in light theme, so
--white-40 is white-on-white there. Anything needing a visible edge in both
themes must use --border-strong.
Principles
- Monochrome by construction — one neutral ladder plus an opacity ladder is the entire palette. Color appears only as genuine semantics (live/error/warning).
- Dark is the default theme — surfaces mount dark-first; light is the override.
- A token that is referenced must resolve — an undefined custom property paints
nothing, silently.
cssVar()therefore accepts only real token names (a typo is a compile error) and emits the authored literal as a fallback, so a reference still paints on a host that has not loaded the CSS. - White-label by fork —
@luxfi/designand@zooai/designcarry the same token names over their own values. A change here must be measured against all three before it ships; one that fixes Hanzo and breaks Zoo is a regression. - Self-contained components — inline styles, no CSS-framework coupling, so a component drops into any host (Next, Vite, Tamagui, none) and renders identically.
- Every component ships its own
.prompt.md— a one-screen usage guide for humans and AI alike.
Source of truth
Tokens track hanzo.ai (app/globals.css, tailwind.config.ts, DESIGN.md) and the press kit. When the brand moves, it moves here first.
License
MIT OR Apache-2.0, per HIP-0137. Brand marks (the Hanzo logo, partner and provider logos) are the property of their respective owners and are provided for identification.
Using it in a Tailwind app
One import, after Tailwind's:
@import "tailwindcss";
@import "@hanzo/design/tailwind.css";
That carries the tokens and maps them onto Tailwind's namespace, so
bg-background, text-muted-foreground and border-border resolve to the
system's values. There is no @theme block to hand-write and keep in sync.
Not using Tailwind? @import "@hanzo/design/styles.css" is the same tokens
without the bridge.
Two rules, and the build enforces both
- Import it after
tailwindcss.@themeextends the framework, so it has to follow it. - Don't re-declare tokens in your app. An app that states its own palette
drifts from every other surface, and any token it forgets resolves to nothing
—
border-borderbecomescurrentColor, which paints a white hairline on black and reads as a design choice rather than a missing line.