Skip to content

feat(tokens): all 21 Mzizi colour families, six drift fixes, Tailwind v4 entrypoint, tokens.json - #9

Open
Bryan Fawcett (bryanfawcett) wants to merge 1 commit into
mainfrom
feat/mzizi-full-token-coverage
Open

feat(tokens): all 21 Mzizi colour families, six drift fixes, Tailwind v4 entrypoint, tokens.json#9
Bryan Fawcett (bryanfawcett) wants to merge 1 commit into
mainfrom
feat/mzizi-full-token-coverage

Conversation

@bryanfawcett

Copy link
Copy Markdown
Contributor

Why

@bundu/ui shipped 7 of the design system's 21 colour families. That is why apps keep
forking around it, and it is why the honest answer to "why are we not using the @bundu/ui
packages?"
was "because it isn't finished". This finishes it.

Four consumers depend on this package today and none of them breaks — proof below.

The family matrix

before (published 0.1.1) after (0.2.0)
--color-* families 7 21
minerals 7 ✓ (hexes exact) 7 ✓
heritage 7, namespaced --heritage-* 7, under --color-* (+ --heritage-* kept as aliases)
experimental 0 — absent 7, with container / on-container / ui steps
surface ladder 0/9 9/9--pitch --void --base --surface --container --overlay --raised --scrim --wash (+ --surface-muted)
status trio 0/3 3/3--syncing --offline --neutral
custom properties in tokens.css 62 113
Tailwind v3 preset only v3 preset kept + native v4 @theme entrypoint
machine-readable tokens.json, incl. every property resolved to a literal hex per mode
brand overlays bundu, nyuchi, mukoko + shamwari, + mzizi
components exported 7 15 (already true at HEAD; only the published tarball lagged)

The six drift fixes

All verified against canon. Measured from the emitted CSS of a real consumer build, not
from the source file:

token mode before after canon
--background light #faf9f4 #f3f3f1 base
--background dark #100f0e #0e0d0c base
--card dark #1a1917 #131211 surface
--muted light #f4f2ec #e5e4e1 container
--secondary light #f4f2ec #e5e4e1 container
--primary both cobalt #0047ab / #00b0ff tanzanite #4b0082 / #b388ff primary

On --primary: canon's own doctrine says "Cobalt is the exceptional mineral for links/info
only — do not use it as --primary."
Every consumer overrode it, so it was harmless and
still wrong. --ring stays cobalt, which canon does mandate.

--canvas and --popover move with --background and --card, as they already did — they
are aliases, not a seventh and eighth fix.

Residual drift found but NOT fixed here

Out of the brief's scope; flagging rather than silently leaving it or silently widening the
blast radius. Say the word and I'll fold these in:

  • --muted / --secondary dark are #2a2927 (equal to --border dark). Canon
    container dark is #1e1d1a. Only the light values were named in the brief.
  • --muted-foreground is #5d5c57 / #a8a6a0. Canon neutral is #55514b / #a09c93.
  • --card light stays #ffffff. Canon has no card; a white card floating on the base
    is the marketing convention and changing it was not asked for.

Generated, not copied

Nobody types a hex. Two scripts, one committed snapshot, 18 generated files.

tokens/canon.snapshot.json          <- scripts/fetch-canon.mjs   (network, on demand)
  |
  +-- packages/{bundu-ui,ui}/styles/tokens.css      <- scripts/generate-tokens.mjs (offline)
  +-- packages/{bundu-ui,ui}/styles/theme.css
  +-- packages/{bundu-ui,ui}/styles/brand-{bundu,nyuchi,mukoko,shamwari,mzizi}.css
  +-- packages/{bundu-ui,ui}/tailwind-palette.mjs
  +-- packages/{bundu-ui,ui}/tokens.json

fetch-canon.mjs reads two sources, because canon has two faces and neither is complete
alone:

source supplies
https://api.mzizi.dev/api/v1/brand the 21 families, semantic tokens, the background ladder, radii, the brand→mineral table
mzizi-dev/mzizi-registrylib/tokens/palette.source.ts (raw, public) the minerals' onContainer pairs — /v1/brand does not project them

and cross-checks them against each other: if the API and the on-disk source of truth
disagree about any mineral hex, the fetch fails rather than quietly preferring one. Four
different terracottas is what happens when nothing does that.

(The /api/ prefix is deliberate — the canonical /v1/brand is tried first and falls back,
so this starts using the canonical form the moment mzizi-registry#335 merges.)

Why a committed snapshot rather than a live fetch at build time: tokens:check has to run
on every CI job and in prepack, offline, with no credential; and a build that reaches the
network to learn what colour cobalt is can produce two different tarballs from one commit.

Three gates, none of which can pass vacuously

command network runs fails when
pnpm tokens:check no every CI job, prepack a generated file disagrees with the generator
pnpm canon:parity yes CI only the committed snapshot has drifted from live canon
pnpm canon:fetch yes on demand the API and palette.source.ts disagree about a hex

canon:parity refuses to run outside CI without --force (exit 2), so it cannot creep
into a build, a prepack, a postinstall or a runtime path. A token package that phones home
to render a page is a page that goes blank the afternoon the API is unwell.

No .prettierignore: *. No gate that cannot go red. Demonstrated:

$ pnpm tokens:check
tokens up to date (18 generated files)                                  exit 0

$ sed -i '' 's/--color-terracotta: #a0522d;/--color-terracotta: #a0522e;/' \
      packages/bundu-ui/styles/tokens.css
$ pnpm tokens:check
DRIFT  packages/bundu-ui/styles/tokens.css
       differs from the generator — hand edits to generated files are not kept
1 generated file(s) drifted. Run `pnpm tokens:build` and commit.           exit 1
$ CI=true pnpm canon:parity
canon parity OK — tokens/canon.snapshot.json matches https://api.mzizi.dev/api/v1/brand
                                                                            exit 0
$ sed -i '' 's/"lightHex": "#A0522D"/"lightHex": "#A0522E"/' tokens/canon.snapshot.json
$ CI=true pnpm canon:parity
CANON PARITY FAILED — the committed snapshot has drifted from https://api.mzizi.dev/api/v1/brand
  minerals.4.lightHex
    committed: #A0522E
    canon:     #A0522D
Fix: `pnpm canon:fetch && pnpm tokens:build`, then commit both.              exit 1
$ pnpm canon:parity              # outside CI
check-canon-parity is a CI-only gate — it reaches the network.              exit 2

The v3 / v4 decision: keep both

Every consumer is on Tailwind v4, and every one of them loads the v3-shape preset
through v4's @config — including shamwari/site, the only direct importer of
@bundu/ui/tailwind-preset. So the preset is kept, working, and unchanged in shape; no
consumer is migrated in this PR.

What is new is styles/theme.css, the native v4 path for anything built from here on:

@import "tailwindcss";
@import "@bundu/ui/styles/theme.css";   /* @imports tokens.css, adds @theme */
@import "@bundu/ui/styles/brand-mzizi.css";

— and no tailwind.config.mjs at all. Palette families are declared in @theme with their
light value as a fallback; tokens.css's unlayered :root / .dark rules win at runtime,
which is what makes dark mode work. Ladder and semantic tokens that alias a bare variable use
@theme inline, so nothing is defined twice.

Both surfaces share one generated colour map (tailwind-palette.mjs), so a new canon family
reaches v3 and v4 in the same command and they cannot fall behind each other.

Verified standalone against the packed tarball with @tailwindcss/cli v4.3.3:
bg-cobalt, text-savanna, border-ember-ui, bg-protea-container, bg-base,
bg-raised, bg-scrim, bg-wash, bg-syncing, text-offline, text-neutral,
bg-storm-on-container, rounded-xl, font-serif all generate.

Proof the four consumers still work

Built from a packed 0.2.0 tarball (npm pack), installed over the real npm ci tree, in
both bundu-labs/marketing (turbo, three apps) and shamwari-ai/shamwari/site. Both repos
restored to pristine afterwards — git status clean in each.

Builds

consumer build its own test suite
marketing/apps/bundu
marketing/apps/mukoko
marketing/apps/nyuchi
marketing (all 6 turbo tasks) 6 successful, 6 total npm test: token-consistency 201 files clean · CSP in sync · check-build-output 3 apps ok · 52/52 vitest
shamwari/site ✅ 4 pages npm run check: astro check 0 errors, 0 warnings, 0 hints + check.mjs ok

Emitted CSS — structural diff of the real build output, before vs after

app custom properties class selectors tokens lost classes lost
bundu 168 → 219 (+51) 329 → 329 0 0
mukoko 162 → 213 (+51) 304 → 304 0 0
nyuchi 176 → 227 (+51) 402 → 402 0 0
shamwari 131 → 182 (+51) 249 → 249 0 0

Emitted CSS — resolved value diff (every var() chain followed to a literal). Identical
across all four apps, and it is exactly the six fixes plus their two aliases:

light: +51 added, -0 lost, 5 changed
  --background  #faf9f4 -> #f3f3f1
  --canvas      #faf9f4 -> #f3f3f1      (alias of --background)
  --muted       #f4f2ec -> #e5e4e1
  --secondary   #f4f2ec -> #e5e4e1
  --primary     #0047ab -> #4b0082      (cobalt -> tanzanite; Lightning CSS
                                         minifies #4b0082 to the name `indigo`)
dark:  +51 added, -0 lost, 4 changed
  --background  #100f0e -> #0e0d0c
  --canvas      #100f0e -> #0e0d0c
  --card        #1a1917 -> #131211
  --popover     #1a1917 -> #131211      (alias of --card)

Cascade order is unchanged — the overlays and the app-level overrides still win, so no
app's effective primary moves:

bundu    AFTER:  --primary:var(--color-tanzanite) → var(--color-copper) → #b4532f
bundu    BEFORE: --primary:var(--color-cobalt)    → var(--color-copper) → #b4532f
shamwari AFTER:  --primary:var(--color-tanzanite) → var(--color-sodalite)
shamwari BEFORE: --primary:var(--color-cobalt)    → var(--color-sodalite)

The heritage rename is non-breaking: --heritage-savanna is still declared, now as
var(--color-savanna), resolving to the identical hex in both modes.

@nyuchi/ui was a second palette inside this repo

packages/ui/styles/globals.css carried its own hand-written copy of the seven minerals
and the semantic tokens. It had drifted exactly the same way (--background: #faf9f4,
--muted: #f4f2ec, cobalt --primary) — and it disagreed with @bundu/ui about Bundu's
own brand mineral
: its brand-bundu.css said terracotta, @bundu/ui's said copper,
canon says copper. Two sources in one repo, already producing two answers.

That copy is deleted. Both packages now emit byte-identical tokens.css, theme.css,
tokens.json, tailwind-palette.mjs and brand-*.css from the one generator — two outputs,
not two hand-maintained files. globals.css keeps only what is genuinely @nyuchi/ui's: the
@layer component/utility rules its Svelte components lean on. Its preset's safelist, which
listed only the 7 minerals, is now built from the generated family list, so a new canon family
is safelisted the moment it lands.

Repo transfer

  • CI still runs post-transfer — Lint and CodeQL both green on main at
    7bcd48f (2026-09-11 19:02, after the move).
  • repository / homepage / bugs in both package.json files and the root package.json
    moved nyuchi/packages-uimukoko-dev/packages-ui. Both published tarballs still point at
    the old path, which now redirects; the next publish fixes that.
  • NPM_TOKEN exists as an organisation secret on mukoko-dev and
    GET /repos/mukoko-dev/packages-ui/actions/organization-secrets lists it as visible to this
    repository. No repository-level secret is needed and the publish workflow keeps working.
    (For the record: nyuchi and mzizi-dev also have one.)

Also fixed

tokens.css:19-20 claimed "Do NOT hand-edit hex values — they are the DB's source of truth."
That is false — no Mzizi data lives in a database. The regenerated header says so explicitly,
names the real on-disk source of truth, and says what to run instead.

What this replaces downstream (follow-ups, not in this PR)

  • shamwari/site/scripts/brand-assets/generate-og-images.mjs hard-codes
    const HERITAGE = ['#7986cb', '#e5c158', '#a1887f', '#ff7043', '#4dd0e1', '#90a4ae', '#e8d9b5']
    — seven hand-mirrored dark-mode heritage hexes. That is now
    tokens.resolved.dark['--color-savanna'] etc.
  • shamwari/site/src/styles/brand-shamwari.css can be deleted and replaced with
    @import '@bundu/ui/styles/brand-shamwari.css'.
  • mukoko-weather-mobile (Expo) can consume tokens.json directly.

Open question for the owner

brand-mzizi.css uses hematite. Canon's ecosystem table has a brand→mineral row for
bundu, nyuchi, mukoko and shamwari — but none for mzizi. hematite is the nearest
canon-grounded fit: its symbolism is literally "Foundation, endurance, the substrate", its
usage "Neutral anchor", and mzizi.dev's own shell already runs a near-neutral primary rather
than a saturated mineral. It is the one value in this PR that is a judgement call rather than a
lookup, and it is marked as such in the generated file. If you'd rather it were something else,
say which and the fix is one line in mzizi-registry's brand.source.ts — the overlay is
generated from that table.

Also deliberately not done: the overlays repoint --primary and --ring only, exactly
as briefed. That leaves --brand-accent (which --wash mixes into --surface) at the
canonical tanzanite for every brand. Worth a follow-up.

Release — NOT published

@bundu/ui 0.1.1 → 0.2.0. @nyuchi/ui 0.1.2 published / 0.2.0 in tree → 0.2.0.

Publishing is the owner's call, like crates.io. Nothing was published and npm publish was
not run.
The exact command, from the repo root after this merges:

pnpm install --frozen-lockfile
pnpm tokens:check                        # must print "tokens up to date (18 generated files)"
pnpm -r publish --access public --no-git-checks

Or, the way CI would do it — publish a GitHub Release (or push a v* tag) and
.github/workflows/publish.yml runs exactly that, with pnpm tokens:check gating it, using
the mukoko-dev org NPM_TOKEN:

gh release create v0.2.0 --repo mukoko-dev/packages-ui --generate-notes

Merge

Not merged — merges are frozen except CI work. Rebase-only when it is time.

…values

`@bundu/ui` shipped 7 of the design system's 21 colour families, and apps
forked around the gap. This closes it, and closes the two second-sources
that made the gap self-repairing in the wrong direction.

WHAT LANDS

  - styles/tokens.css carries all 21 families under one --color-* namespace:
    7 minerals, 7 heritage (moved off --heritage-*, old names kept as
    aliases), 7 experimental. Plus the nine-step surface ladder
    (--pitch --void --base --surface --container --overlay --raised --scrim
    --wash) and the status trio (--syncing --offline --neutral).
    113 custom properties, up from 62.

  - styles/theme.css is a native Tailwind v4 @theme entrypoint. The v3
    preset stays and still works — all four current consumers reach it
    through v4's @config, and shamwari/site imports it directly. Its colour
    map is now generated too (tailwind-palette.mjs), so v3 and v4 cannot
    drift apart.

  - tokens.json is the same values machine-readable, including every custom
    property resolved to a literal hex per mode. mukoko-weather-mobile is
    Expo and cannot consume CSS; Satori cannot resolve a CSS variable. This
    replaces the hand-mirrored hex literals in those generators.

  - styles/brand-mzizi.css and styles/brand-shamwari.css join the three
    existing overlays. brand-shamwari.css was living in shamwari/site with
    a comment saying it was "kept locally until it's contributed upstream
    to @bundu/ui"; this is that contribution.

SIX DRIFT FIXES, all against canon

  --background  light  #faf9f4 -> #f3f3f1   (canon `base`)
  --background  dark   #100f0e -> #0e0d0c   (canon `base`)
  --card        dark   #1a1917 -> #131211   (canon `surface`)
  --muted       light  #f4f2ec -> #e5e4e1   (canon `container`)
  --secondary   light  #f4f2ec -> #e5e4e1   (canon `container`)
  --primary            cobalt  -> tanzanite

Canon's own doctrine: "Cobalt is the exceptional mineral for links/info
only — do not use it as --primary." Every consumer overrode it, so it was
harmless and still wrong. --canvas and --popover follow --background and
--card as they already did.

GENERATED, NOT TRANSCRIBED

Nobody types a hex. scripts/fetch-canon.mjs writes tokens/canon.snapshot.json
from two sources and cross-checks them against each other:

  api.mzizi.dev/api/v1/brand         the 21 families, semantics, the ladder,
                                     radii, the brand->mineral table
  mzizi-registry palette.source.ts   the minerals' onContainer pairs, which
                                     /v1/brand does not project

If the two disagree about a hex the fetch fails rather than preferring one.
Four different terracottas is what happens when it does not.

scripts/generate-tokens.mjs then emits 18 files from that one snapshot.

THREE GATES, NONE VACUOUS

  pnpm tokens:check   offline, every CI job + prepack. Fails when a
                      generated file disagrees with the generator.
  pnpm canon:parity   network, CI ONLY — refuses to run without CI set
                      unless given --force, so it cannot creep into a build
                      or a runtime path. Fails when the snapshot has
                      drifted from live canon, naming the exact value.
  pnpm canon:fetch    fails when the API and palette.source.ts disagree.

Both of the first two were demonstrated failing on a deliberately wrong hex
and passing once corrected. No .prettierignore: * and no check that cannot
go red.

@nyuchi/ui WAS A SECOND PALETTE

packages/ui/styles/globals.css carried its own hand-written copy of the
seven minerals and the semantic tokens — a second source inside one repo,
already drifted the same way (--background #faf9f4, --muted #f4f2ec, cobalt
--primary) and disagreeing with @bundu/ui about Bundu's own brand mineral
(terracotta there, copper here; canon says copper). That copy is gone; both
packages now emit byte-identical token files from the one generator.

CONSUMERS

Both packages' repository/homepage/bugs fields moved from nyuchi/packages-ui
to mukoko-dev/packages-ui. NPM_TOKEN is an org secret on mukoko-dev and is
visible to this repo, so the publish workflow keeps working post-transfer.

@bundu/ui 0.1.1 -> 0.2.0, @nyuchi/ui 0.1.2 (published) / 0.2.0 (tree) ->
0.2.0. Not published — that is the owner's call.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant