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
Open
Conversation
…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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Why
@bundu/uishipped 7 of the design system's 21 colour families. That is why apps keepforking around it, and it is why the honest answer to "why are we not using the
@bundu/uipackages?" 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
--color-*families--heritage-*--color-*(+--heritage-*kept as aliases)container/on-container/uisteps--pitch --void --base --surface --container --overlay --raised --scrim --wash(+--surface-muted)--syncing --offline --neutraltokens.css@themeentrypointtokens.json, incl. every property resolved to a literal hex per modeThe six drift fixes
All verified against canon. Measured from the emitted CSS of a real consumer build, not
from the source file:
--background#faf9f4#f3f3f1base--background#100f0e#0e0d0cbase--card#1a1917#131211surface--muted#f4f2ec#e5e4e1container--secondary#f4f2ec#e5e4e1container--primary#0047ab/#00b0ff#4b0082/#b388ffprimaryOn
--primary: canon's own doctrine says "Cobalt is the exceptional mineral for links/infoonly — do not use it as
--primary." Every consumer overrode it, so it was harmless andstill wrong.
--ringstays cobalt, which canon does mandate.--canvasand--popovermove with--backgroundand--card, as they already did — theyare 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/--secondarydark are#2a2927(equal to--borderdark). Canoncontainerdark is#1e1d1a. Only the light values were named in the brief.--muted-foregroundis#5d5c57/#a8a6a0. Canonneutralis#55514b/#a09c93.--cardlight stays#ffffff. Canon has nocard; a white card floating on the baseis 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.
fetch-canon.mjsreads two sources, because canon has two faces and neither is completealone:
https://api.mzizi.dev/api/v1/brandmzizi-dev/mzizi-registry→lib/tokens/palette.source.ts(raw, public)onContainerpairs —/v1/branddoes not project themand 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/brandis tried first and falls back,so this starts using the canonical form the moment
mzizi-registry#335merges.)Why a committed snapshot rather than a live fetch at build time:
tokens:checkhas to runon every CI job and in
prepack, offline, with no credential; and a build that reaches thenetwork to learn what colour cobalt is can produce two different tarballs from one commit.
Three gates, none of which can pass vacuously
pnpm tokens:checkprepackpnpm canon:paritypnpm canon:fetchpalette.source.tsdisagree about a hexcanon:parityrefuses to run outside CI without--force(exit 2), so it cannot creepinto a build, a
prepack, apostinstallor a runtime path. A token package that phones hometo render a page is a page that goes blank the afternoon the API is unwell.
No
.prettierignore: *. No gate that cannot go red. Demonstrated: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— includingshamwari/site, the only direct importer of@bundu/ui/tailwind-preset. So the preset is kept, working, and unchanged in shape; noconsumer is migrated in this PR.
What is new is
styles/theme.css, the native v4 path for anything built from here on:— and no
tailwind.config.mjsat all. Palette families are declared in@themewith theirlight value as a fallback;
tokens.css's unlayered:root/.darkrules 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 familyreaches v3 and v4 in the same command and they cannot fall behind each other.
Verified standalone against the packed tarball with
@tailwindcss/cliv4.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-serifall generate.Proof the four consumers still work
Built from a packed 0.2.0 tarball (
npm pack), installed over the realnpm citree, inboth
bundu-labs/marketing(turbo, three apps) andshamwari-ai/shamwari/site. Both reposrestored to pristine afterwards —
git statusclean in each.Builds
marketing/apps/bundumarketing/apps/mukokomarketing/apps/nyuchimarketing(all 6 turbo tasks)6 successful, 6 totalnpm test: token-consistency 201 files clean · CSP in sync · check-build-output 3 apps ok · 52/52 vitestshamwari/sitenpm run check: astro check 0 errors, 0 warnings, 0 hints +check.mjsokEmitted CSS — structural diff of the real build output, before vs after
Emitted CSS — resolved value diff (every
var()chain followed to a literal). Identicalacross all four apps, and it is exactly the six fixes plus their two aliases:
Cascade order is unchanged — the overlays and the app-level overrides still win, so no
app's effective primary moves:
The heritage rename is non-breaking:
--heritage-savannais still declared, now asvar(--color-savanna), resolving to the identical hex in both modes.@nyuchi/uiwas a second palette inside this repopackages/ui/styles/globals.csscarried its own hand-written copy of the seven mineralsand the semantic tokens. It had drifted exactly the same way (
--background: #faf9f4,--muted: #f4f2ec, cobalt--primary) — and it disagreed with@bundu/uiabout Bundu'sown brand mineral: its
brand-bundu.csssaid 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.mjsandbrand-*.cssfrom the one generator — two outputs,not two hand-maintained files.
globals.csskeeps only what is genuinely@nyuchi/ui's: the@layercomponent/utility rules its Svelte components lean on. Its preset's safelist, whichlisted 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
LintandCodeQLboth green onmainat7bcd48f(2026-09-11 19:02, after the move).repository/homepage/bugsin both package.json files and the root package.jsonmoved
nyuchi/packages-ui→mukoko-dev/packages-ui. Both published tarballs still point atthe old path, which now redirects; the next publish fixes that.
NPM_TOKENexists as an organisation secret onmukoko-devandGET /repos/mukoko-dev/packages-ui/actions/organization-secretslists it as visible to thisrepository. No repository-level secret is needed and the publish workflow keeps working.
(For the record:
nyuchiandmzizi-devalso have one.)Also fixed
tokens.css:19-20claimed "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.mjshard-codesconst 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.csscan be deleted and replaced with@import '@bundu/ui/styles/brand-shamwari.css'.mukoko-weather-mobile(Expo) can consumetokens.jsondirectly.Open question for the owner
brand-mzizi.cssuseshematite. Canon'secosystemtable has a brand→mineral row forbundu, 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'sbrand.source.ts— the overlay isgenerated from that table.
Also deliberately not done: the overlays repoint
--primaryand--ringonly, exactlyas briefed. That leaves
--brand-accent(which--washmixes into--surface) at thecanonical tanzanite for every brand. Worth a follow-up.
Release — NOT published
@bundu/ui0.1.1 → 0.2.0.@nyuchi/ui0.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 publishwasnot 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-checksOr, the way CI would do it — publish a GitHub Release (or push a
v*tag) and.github/workflows/publish.ymlruns exactly that, withpnpm tokens:checkgating it, usingthe
mukoko-devorgNPM_TOKEN:Merge
Not merged — merges are frozen except CI work. Rebase-only when it is time.