From 800496d6ec0855135bde0df06b4f4e51b37d5c1d Mon Sep 17 00:00:00 2001 From: Bryan Fawcett Date: Sat, 12 Sep 2026 03:50:29 +0800 Subject: [PATCH] feat(tokens): generate all 21 Mzizi colour families, fix six drifted values MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `@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) --- .github/workflows/publish.yml | 7 + .github/workflows/tokens.yml | 67 ++ README.md | 43 +- package.json | 17 +- packages/bundu-ui/BUILDING.md | 8 +- packages/bundu-ui/README.md | 76 +- packages/bundu-ui/package.json | 23 +- packages/bundu-ui/styles/brand-bundu.css | 8 +- packages/bundu-ui/styles/brand-mukoko.css | 8 +- packages/bundu-ui/styles/brand-mzizi.css | 15 + packages/bundu-ui/styles/brand-nyuchi.css | 8 +- packages/bundu-ui/styles/brand-shamwari.css | 13 + packages/bundu-ui/styles/theme.css | 142 +++ packages/bundu-ui/styles/tokens.css | 388 +++++-- packages/bundu-ui/tailwind-palette.mjs | 192 ++++ packages/bundu-ui/tailwind-preset.mjs | 89 +- packages/bundu-ui/tokens.json | 567 ++++++++++ packages/ui/README.md | 52 +- packages/ui/package.json | 19 +- packages/ui/styles/brand-bundu.css | 19 +- packages/ui/styles/brand-mukoko.css | 11 +- packages/ui/styles/brand-mzizi.css | 15 + packages/ui/styles/brand-nyuchi.css | 11 +- packages/ui/styles/brand-shamwari.css | 13 + packages/ui/styles/globals.css | 170 +-- packages/ui/styles/theme.css | 142 +++ packages/ui/styles/tokens.css | 374 +++++++ packages/ui/tailwind-palette.mjs | 192 ++++ packages/ui/tailwind-preset.mjs | 96 +- packages/ui/tokens.json | 567 ++++++++++ pnpm-lock.yaml | 13 +- scripts/check-canon-parity.mjs | 82 ++ scripts/fetch-canon.mjs | 245 +++++ scripts/generate-tokens.mjs | 1102 +++++++++++++++++++ tokens/canon.snapshot.json | 612 ++++++++++ 35 files changed, 4917 insertions(+), 489 deletions(-) create mode 100644 .github/workflows/tokens.yml create mode 100644 packages/bundu-ui/styles/brand-mzizi.css create mode 100644 packages/bundu-ui/styles/brand-shamwari.css create mode 100644 packages/bundu-ui/styles/theme.css create mode 100644 packages/bundu-ui/tailwind-palette.mjs create mode 100644 packages/bundu-ui/tokens.json create mode 100644 packages/ui/styles/brand-mzizi.css create mode 100644 packages/ui/styles/brand-shamwari.css create mode 100644 packages/ui/styles/theme.css create mode 100644 packages/ui/styles/tokens.css create mode 100644 packages/ui/tailwind-palette.mjs create mode 100644 packages/ui/tokens.json create mode 100644 scripts/check-canon-parity.mjs create mode 100644 scripts/fetch-canon.mjs create mode 100644 scripts/generate-tokens.mjs create mode 100644 tokens/canon.snapshot.json diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 241a47b..d9e872d 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -33,8 +33,15 @@ jobs: - name: Install dependencies run: pnpm install --frozen-lockfile + # Offline. A tarball whose styles/tokens.css does not match the + # generator is a tarball nobody can reproduce from this commit. + - name: Verify generated token artifacts + run: pnpm tokens:check + # Publish to npm only. Both scopes are hosted on npm: @nyuchi/* and # @bundu/* (@bundu/ui). NPM_TOKEN must have publish access to both orgs. + # NPM_TOKEN is an ORG secret on mukoko-dev (verified 2026-09-12, after + # the repo moved from nyuchi), so this step keeps working post-transfer. - name: Publish to npmjs run: pnpm -r publish --access public --no-git-checks env: diff --git a/.github/workflows/tokens.yml b/.github/workflows/tokens.yml new file mode 100644 index 0000000..8a2012e --- /dev/null +++ b/.github/workflows/tokens.yml @@ -0,0 +1,67 @@ +# Token gates. +# +# Two jobs, deliberately separate, because they fail for different reasons +# and only one of them is allowed to touch the network. +# +# tokens OFFLINE. Regenerates every token artifact from +# tokens/canon.snapshot.json and fails if the working tree +# disagrees. This is the gate that makes hand-editing a +# generated file a build failure. It needs no credential and +# no third party, so it runs on every PR and every push. +# +# canon-parity NETWORK. Asks whether the committed snapshot still matches +# live canon (api.mzizi.dev/api/v1/brand + mzizi-registry's +# palette.source.ts). It runs HERE ONLY — never in a package +# build, never in prepack, never at runtime. A design-token +# package that phones home at build time renders a blank page +# the afternoon the API is unwell. +# +# Neither job can pass vacuously: `tokens` compares bytes it just generated, +# `canon-parity` compares against an upstream this repo does not control. +# Both are demonstrated failing on a wrong hex in the PR that added them. + +name: Tokens + +on: + pull_request: + push: + branches: [main] + merge_group: + schedule: + # Weekly. Canon moving is not urgent, but it must not be invisible. + - cron: "17 6 * * 1" + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +permissions: + contents: read + +jobs: + tokens: + name: generated files match the generator + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 + - uses: actions/setup-node@v4 + with: + node-version: "lts/*" + cache: pnpm + - run: pnpm install --frozen-lockfile + - run: pnpm tokens:check + + canon-parity: + name: snapshot matches live canon + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 + - uses: actions/setup-node@v4 + with: + node-version: "lts/*" + cache: pnpm + - run: pnpm install --frozen-lockfile + - run: pnpm canon:parity diff --git a/README.md b/README.md index d37f1ab..be32a71 100644 --- a/README.md +++ b/README.md @@ -9,10 +9,36 @@ additional UI packages over time. ## Packages -| Package | Description | -| ---------------------------------- | --------------------------------------------------------------------------------------------- | -| [`@nyuchi/ui`](./packages/ui) | Svelte 5 / SvelteKit component library on the seven-mineral tokens | -| [`@bundu/ui`](./packages/bundu-ui) | Marketing UI kit — Astro marketing components + shadcn primitives on the seven-mineral tokens | +| Package | Description | +| ---------------------------------- | ------------------------------------------------------------------------------------ | +| [`@nyuchi/ui`](./packages/ui) | Svelte 5 / SvelteKit component library on the Mzizi tokens | +| [`@bundu/ui`](./packages/bundu-ui) | Marketing UI kit — Astro marketing components + shadcn primitives on the same tokens | + +Both packages ship **byte-identical** `styles/tokens.css`, `styles/theme.css`, +`tokens.json`, `tailwind-palette.mjs` and `styles/brand-*.css`. They are two outputs of +one generator, not two hand-maintained files. + +## Tokens + +All 21 Mzizi colour families (7 minerals, 7 heritage, 7 experimental) under one +`--color-*` namespace, plus the nine-step surface ladder and the connectivity status +trio. Everything is **generated** from `tokens/canon.snapshot.json`: + +```sh +pnpm canon:fetch # refresh the snapshot from canon (network, on demand) +pnpm tokens:build # regenerate every artifact from the snapshot (offline) +pnpm tokens:check # CI gate: fail if any generated file was hand-edited (offline) +pnpm canon:parity # CI gate: fail if the snapshot has drifted from canon (network) +``` + +The snapshot is machine-written from two sources that are cross-checked against each +other — `https://api.mzizi.dev/api/v1/brand` and `mzizi-dev/mzizi-registry`'s +`lib/tokens/palette.source.ts`. Nobody types a hex. The values do **not** live in a +database; Mzizi holds no brand or primitive token data in one. + +`canon:parity` reaches the network and therefore runs in **CI only** — it refuses to run +without `CI` set unless given `--force`. It is never in a build, a `prepack`, a +`postinstall` or a runtime path. ## Development @@ -26,8 +52,13 @@ Packages are published to [npm](https://www.npmjs.com) automatically by the [`publish` workflow](./.github/workflows/publish.yml) when a GitHub Release is published — `@nyuchi/*` under the [`@nyuchi`](https://www.npmjs.com/org/nyuchi) org and `@bundu/*` under the [`@bundu`](https://www.npmjs.com/org/bundu) org. -The workflow requires an `NPM_TOKEN` repository secret with publish access to -both orgs. +The workflow requires an `NPM_TOKEN` with publish access to both orgs. +`NPM_TOKEN` is an **organisation** secret on `mukoko-dev` and is visible to this +repository (verified 2026-09-12, after the transfer from `nyuchi`), so no repository-level +secret is needed. + +Publishing is the owner's call. CI does not publish on a branch push — only on a +published GitHub Release or a `v*` tag. ## License diff --git a/package.json b/package.json index f5721e1..8d563a9 100644 --- a/package.json +++ b/package.json @@ -2,16 +2,27 @@ "name": "@nyuchi/packages-ui", "version": "0.1.0", "private": true, - "description": "Nyuchi Design System — shared, publishable UI packages. Nyuchi's implementation of the Mzizi architecture.", + "description": "Nyuchi Design System \u2014 shared, publishable UI packages. Nyuchi's implementation of the Mzizi architecture.", "type": "module", "license": "MIT", "author": "Nyuchi Africa (Pvt) Ltd", "repository": { "type": "git", - "url": "git+https://github.com/nyuchi/packages-ui.git" + "url": "git+https://github.com/mukoko-dev/packages-ui.git" }, "bugs": { - "url": "https://github.com/nyuchi/packages-ui/issues" + "url": "https://github.com/mukoko-dev/packages-ui/issues" + }, + "scripts": { + "canon:fetch": "node scripts/fetch-canon.mjs", + "canon:parity": "node scripts/check-canon-parity.mjs", + "tokens:build": "node scripts/generate-tokens.mjs", + "tokens:check": "node scripts/generate-tokens.mjs --check", + "format": "prettier --write \"**/*.{md,mdx,json,jsonc}\" --config .prettierrc --ignore-path .prettierignore --no-error-on-unmatched-pattern", + "format:check": "prettier --check \"**/*.{md,mdx,json,jsonc}\" --config .prettierrc --ignore-path .prettierignore --no-error-on-unmatched-pattern" + }, + "devDependencies": { + "prettier": "3.9.4" }, "engines": { "node": ">=20", diff --git a/packages/bundu-ui/BUILDING.md b/packages/bundu-ui/BUILDING.md index 5c6adaf..2585e57 100644 --- a/packages/bundu-ui/BUILDING.md +++ b/packages/bundu-ui/BUILDING.md @@ -68,7 +68,13 @@ them with a client directive in Astro (``). ## 2. The design-system tools `@bundu/ui` is downstream of the **Mzizi** registry. The registry — not this package — is -the source of truth for tokens and net-new components. +the source of truth for tokens and net-new components, and since 0.2.0 that is mechanical +rather than aspirational: `styles/tokens.css`, `styles/theme.css`, `tokens.json`, +`tailwind-palette.mjs` and every `styles/brand-*.css` are generated from +`tokens/canon.snapshot.json`, which `scripts/fetch-canon.mjs` writes from +`api.mzizi.dev/api/v1/brand` and `mzizi-registry`'s `lib/tokens/palette.source.ts`. +Hand-editing any of them fails `pnpm tokens:check`. The token values are **not** stored in +a database — Mzizi holds no brand or primitive token data in one. ### mzizi MCP diff --git a/packages/bundu-ui/README.md b/packages/bundu-ui/README.md index cf6458d..d74b686 100644 --- a/packages/bundu-ui/README.md +++ b/packages/bundu-ui/README.md @@ -12,13 +12,53 @@ semantic token system. - **shadcn CVA + `cn()` React primitives** — `Button`, `Card`, `Badge`, `Input`, `Textarea`, `Select`, `Label`, `Alert`, `Avatar`, `Separator`, `Skeleton`, `Switch`, `Checkbox`, `Tabs`, `Tooltip`. -- **Option-A styling** — one canonical `styles/globals.css` (all seven minerals, light + - dark, semantic tokens, component/utility classes) plus tiny `brand-*.css` overlays that - swap the brand primary, and a `tailwind-preset.mjs` that exposes the utility classes. +- **The whole Mzizi palette** — `styles/tokens.css` carries all **21 colour families** + under one `--color-*` namespace: 7 minerals, 7 heritage tones, 7 experimental tones. + Plus the nine-step surface ladder (`--pitch --void --base --surface --container +--overlay --raised --scrim --wash`) and the connectivity status trio (`--syncing +--offline --neutral`). +- **Tailwind v3 and v4** — `styles/theme.css` is a native v4 `@theme` entrypoint; + `tailwind-preset.mjs` is the v3-shape preset, still shipped and still working (v4 loads + it through `@config`). +- **Brand overlays** — `brand-bundu`, `brand-nyuchi`, `brand-mukoko`, `brand-shamwari`, + `brand-mzizi`. Each repoints `--primary` and `--ring` and nothing else. +- **`tokens.json`** — the same values machine-readable, including every custom property + resolved to a literal hex per mode, for the surfaces that cannot consume CSS at all: + Expo (`mukoko-weather-mobile`) and Satori-based OG-image / email / PDF generators. + +## Generated, not transcribed + +`styles/tokens.css`, `styles/theme.css`, `tokens.json`, `tailwind-palette.mjs` and every +`styles/brand-*.css` are **generated** by `scripts/generate-tokens.mjs` from +`tokens/canon.snapshot.json`, which is itself machine-written by `scripts/fetch-canon.mjs` +from canon: + +| 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-registry` → `lib/tokens/palette.source.ts` | the minerals' `onContainer` pairs, which `/v1/brand` does not project | + +The two are cross-checked against each other on every fetch: if they disagree about a hex, +the fetch fails rather than silently preferring one. That disagreement is how an estate +ends up with four different terracottas. + +The hex values do **not** live in a database. Mzizi holds no brand or primitive token data +in Supabase or anywhere else — 0.1.1's `tokens.css` header said otherwise and was wrong. + +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 | + +`canon:parity` refuses to run outside CI without `--force`, so it cannot creep into a build +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. Every value flows through CSS custom properties / Tailwind tokens — **never a raw hex in -source**. All seven minerals are valid tokens: `cobalt`, `tanzanite`, `malachite`, `gold`, -`terracotta`, `sodalite`, `copper`. +source**. ## Install @@ -30,16 +70,22 @@ pnpm add react react-dom ## Quick usage -**1. Styles** — import the canonical tokens, then the brand overlay for your site, into -your global stylesheet (after `@import "tailwindcss";`): +**1a. Tailwind v4 (recommended)** — one stylesheet, no `tailwind.config.mjs` at all: ```css @import "tailwindcss"; -@import "@bundu/ui/styles/globals.css"; -@import "@bundu/ui/styles/brand-nyuchi.css"; /* or brand-bundu / brand-mukoko */ +@import "@bundu/ui/styles/theme.css"; /* @imports tokens.css, adds @theme */ +@import "@bundu/ui/styles/brand-nyuchi.css"; ``` -**2. Tailwind** — add the preset so the mineral + semantic utility classes exist: +**1b. Tailwind v3, or v4 via `@config`** — unchanged from 0.1.x, still supported: + +```css +@import "tailwindcss"; +@import "@bundu/ui/styles/tokens.css"; /* or globals.css for the @layer rules */ +@import "@bundu/ui/styles/brand-nyuchi.css"; +@config "../../tailwind.config.mjs"; +``` ```js // tailwind.config.mjs @@ -51,6 +97,16 @@ export default { }; ``` +**2. Outside the browser** — Expo, Satori, PDF: + +```js +import tokens from "@bundu/ui/tokens.json" with { type: "json" }; + +tokens.resolved.dark["--color-sodalite"]; // "#3d5afe" +tokens.color.heritage.savanna.dark; // "#e5c158" +tokens.surface.base.light; // "#f3f3f1" +``` + **3. Components:** ```astro diff --git a/packages/bundu-ui/package.json b/packages/bundu-ui/package.json index e96442b..91fc9c3 100644 --- a/packages/bundu-ui/package.json +++ b/packages/bundu-ui/package.json @@ -1,18 +1,18 @@ { "name": "@bundu/ui", - "version": "0.1.1", - "description": "The marketing UI kit for the Bundu Ecosystem — Nyuchi's implementation of the Mzizi design system. Astro marketing components (Hero, Section, MineralStrip, Container, Breadcrumb with valid BreadcrumbList JSON-LD, …) plus shadcn CVA + cn() React primitives mapped onto the seven-African-mineral tokens, a framework-agnostic tokens.css, a canonical globals.css, tiny brand overrides, and a Tailwind preset.", + "version": "0.2.0", + "description": "The marketing UI kit for the Bundu Ecosystem — Nyuchi's implementation of the Mzizi design system. All 21 Mzizi colour families (7 minerals, 7 heritage, 7 experimental) under one --color-* namespace, the nine-step surface ladder, the connectivity status trio, a Tailwind v4 @theme entrypoint and the v3 preset, brand overlays for bundu/nyuchi/mukoko/shamwari/mzizi, machine-readable tokens.json for Expo and Satori, plus Astro marketing components and shadcn CVA + cn() React primitives. Generated from canon — nothing in it is hand-copied.", "type": "module", "license": "MIT", "author": "Nyuchi Africa (Pvt) Ltd", - "homepage": "https://github.com/nyuchi/packages-ui#readme", + "homepage": "https://github.com/mukoko-dev/packages-ui#readme", "repository": { "type": "git", - "url": "git+https://github.com/nyuchi/packages-ui.git", + "url": "git+https://github.com/mukoko-dev/packages-ui.git", "directory": "packages/bundu-ui" }, "bugs": { - "url": "https://github.com/nyuchi/packages-ui/issues" + "url": "https://github.com/mukoko-dev/packages-ui/issues" }, "main": "./src/index.ts", "types": "./src/index.ts", @@ -46,11 +46,16 @@ "./Breadcrumb.astro": "./src/Breadcrumb.astro", "./lib/utils": "./src/lib/utils.ts", "./styles/tokens.css": "./styles/tokens.css", + "./styles/theme.css": "./styles/theme.css", "./styles/globals.css": "./styles/globals.css", "./styles/brand-bundu.css": "./styles/brand-bundu.css", "./styles/brand-nyuchi.css": "./styles/brand-nyuchi.css", "./styles/brand-mukoko.css": "./styles/brand-mukoko.css", - "./tailwind-preset": "./tailwind-preset.mjs" + "./tailwind-preset": "./tailwind-preset.mjs", + "./styles/brand-shamwari.css": "./styles/brand-shamwari.css", + "./styles/brand-mzizi.css": "./styles/brand-mzizi.css", + "./tokens.json": "./tokens.json", + "./tailwind-palette": "./tailwind-palette.mjs" }, "dependencies": { "class-variance-authority": "^0.7.1", @@ -64,7 +69,9 @@ "files": [ "src", "styles", - "tailwind-preset.mjs" + "tailwind-preset.mjs", + "tailwind-palette.mjs", + "tokens.json" ], "publishConfig": { "access": "public" @@ -73,6 +80,8 @@ "bundu", "mzizi", "design-system", + "design-tokens", + "tailwind", "marketing", "astro", "ui" diff --git a/packages/bundu-ui/styles/brand-bundu.css b/packages/bundu-ui/styles/brand-bundu.css index 01707ec..2626ba0 100644 --- a/packages/bundu-ui/styles/brand-bundu.css +++ b/packages/bundu-ui/styles/brand-bundu.css @@ -1,7 +1,7 @@ -/* brand-bundu — Bundu Foundation primary: copper. - Copper is Bundu's canonical brand→mineral in the seven-mineral system - (stewardship; the ecosystem commons). Import AFTER globals.css (or - tokens.css) to select this brand's primary + ring. */ +/* brand-bundu — bundu primary: copper (mineral). Canon (/v1/brand -> ecosystem) + maps bundu to copper — the ecosystem. GENERATED by + scripts/generate-tokens.mjs; edit the canon ecosystem table, not this file. + Import AFTER tokens.css (or globals.css / theme.css) so it wins. */ :root { --primary: var(--color-copper); --ring: var(--color-copper); diff --git a/packages/bundu-ui/styles/brand-mukoko.css b/packages/bundu-ui/styles/brand-mukoko.css index 60e9011..c00c0fb 100644 --- a/packages/bundu-ui/styles/brand-mukoko.css +++ b/packages/bundu-ui/styles/brand-mukoko.css @@ -1,7 +1,7 @@ -/* brand-mukoko — Mukoko primary: tanzanite. - Tanzanite is Mukoko's canonical brand→mineral (identity; Africa's super - app). Import AFTER globals.css (or tokens.css) to select this brand's - primary + ring. */ +/* brand-mukoko — mukoko primary: tanzanite (mineral). Canon (/v1/brand -> + ecosystem) maps mukoko to tanzanite — africa's super app. GENERATED by + scripts/generate-tokens.mjs; edit the canon ecosystem table, not this file. + Import AFTER tokens.css (or globals.css / theme.css) so it wins. */ :root { --primary: var(--color-tanzanite); --ring: var(--color-tanzanite); diff --git a/packages/bundu-ui/styles/brand-mzizi.css b/packages/bundu-ui/styles/brand-mzizi.css new file mode 100644 index 0000000..d9406f2 --- /dev/null +++ b/packages/bundu-ui/styles/brand-mzizi.css @@ -0,0 +1,15 @@ +/* brand-mzizi — mzizi primary: hematite (heritage tone). Canon carries no mzizi + -> mineral row; hematite is the nearest canon-grounded fit (symbolism: + foundation, endurance, the substrate). Pending confirmation — see + mukoko-dev/packages-ui PR. GENERATED by scripts/generate-tokens.mjs; edit the + canon ecosystem table, not this file. Import AFTER tokens.css (or globals.css + / theme.css) so it wins. */ +:root { + --primary: var(--color-hematite); + --ring: var(--color-hematite); +} +.dark, +[data-theme="dark"] { + --primary: var(--color-hematite); + --ring: var(--color-hematite); +} diff --git a/packages/bundu-ui/styles/brand-nyuchi.css b/packages/bundu-ui/styles/brand-nyuchi.css index 4635481..409b9eb 100644 --- a/packages/bundu-ui/styles/brand-nyuchi.css +++ b/packages/bundu-ui/styles/brand-nyuchi.css @@ -1,7 +1,7 @@ -/* brand-nyuchi — Nyuchi primary: gold. - Gold is Nyuchi's canonical brand→mineral (value; infrastructure & - enterprise). Import AFTER globals.css (or tokens.css) to select this - brand's primary + ring. */ +/* brand-nyuchi — nyuchi primary: gold (mineral). Canon (/v1/brand -> ecosystem) + maps nyuchi to gold — infrastructure & enterprise. GENERATED by + scripts/generate-tokens.mjs; edit the canon ecosystem table, not this file. + Import AFTER tokens.css (or globals.css / theme.css) so it wins. */ :root { --primary: var(--color-gold); --ring: var(--color-gold); diff --git a/packages/bundu-ui/styles/brand-shamwari.css b/packages/bundu-ui/styles/brand-shamwari.css new file mode 100644 index 0000000..30287b9 --- /dev/null +++ b/packages/bundu-ui/styles/brand-shamwari.css @@ -0,0 +1,13 @@ +/* brand-shamwari — shamwari primary: sodalite (mineral). Canon (/v1/brand -> + ecosystem) maps shamwari to sodalite — sovereign ai companion. GENERATED by + scripts/generate-tokens.mjs; edit the canon ecosystem table, not this file. + Import AFTER tokens.css (or globals.css / theme.css) so it wins. */ +:root { + --primary: var(--color-sodalite); + --ring: var(--color-sodalite); +} +.dark, +[data-theme="dark"] { + --primary: var(--color-sodalite); + --ring: var(--color-sodalite); +} diff --git a/packages/bundu-ui/styles/theme.css b/packages/bundu-ui/styles/theme.css new file mode 100644 index 0000000..c9425d6 --- /dev/null +++ b/packages/bundu-ui/styles/theme.css @@ -0,0 +1,142 @@ +/* @bundu/ui — Tailwind v4 entrypoint. GENERATED FILE — DO NOT EDIT. + + Every consumer in the estate is on Tailwind v4. Until 0.2.0 this package + shipped only a v3-shape preset, which v4 can still load through `@config` + — that is how all four current consumers work today, and it keeps working. + This file is the native path for new consumers: + + @import "tailwindcss"; + @import "@bundu/ui/styles/theme.css"; + @import "@bundu/ui/styles/brand-mzizi.css"; + + and no tailwind.config.mjs at all. + + Palette families are declared with their light-mode value as the @theme + fallback; tokens.css's own :root / .dark rules are unlayered and therefore + win at runtime, which is what makes dark mode work. Tokens that alias a + bare ladder variable use `@theme inline` so no second definition is + emitted. */ + +@import "./tokens.css"; + +/* Palette — bg-cobalt, text-savanna, border-ember-ui, ... */ +@theme { + --color-cobalt: #0047ab; + --color-cobalt-container: #e3f2fd; + --color-cobalt-on-container: #002966; + --color-tanzanite: #4b0082; + --color-tanzanite-container: #f3e5f5; + --color-tanzanite-on-container: #2e004d; + --color-malachite: #004d40; + --color-malachite-container: #e0f2f1; + --color-malachite-on-container: #00332b; + --color-gold: #5d4037; + --color-gold-container: #fff8e1; + --color-gold-on-container: #3e2723; + --color-terracotta: #a0522d; + --color-terracotta-container: #f5e6d3; + --color-terracotta-on-container: #5d2906; + --color-sodalite: #283593; + --color-sodalite-container: #e8eaf6; + --color-sodalite-on-container: #141a5c; + --color-copper: #bf5a36; + --color-copper-container: #fbe4da; + --color-copper-on-container: #5c2410; + + --color-indigo: #4527a0; + --color-savanna: #8d6e1a; + --color-baobab: #4e342e; + --color-sunset: #d84315; + --color-river: #006064; + --color-hematite: #546e7a; + --color-kalahari: #c9b589; + + --color-ember: #843d20; + --color-ember-container: #ebdfdb; + --color-ember-on-container: #7a3115; + --color-ember-ui: #cd5f33; + --color-acacia: #4d5615; + --color-acacia-container: #e9ebdb; + --color-acacia-on-container: #48510e; + --color-acacia-ui: #7e8c22; + --color-fern: #175e17; + --color-fern-container: #dbebdb; + --color-fern-on-container: #0f570f; + --color-fern-ui: #259725; + --color-lagoon: #165b51; + --color-lagoon-container: #dbebe9; + --color-lagoon-on-container: #0e554b; + --color-lagoon-ui: #249383; + --color-storm: #284ca6; + --color-storm-container: #dbe0eb; + --color-storm-on-container: #1a409b; + --color-storm-ui: #577bd6; + --color-dusk: #742aad; + --color-dusk-container: #e4dbeb; + --color-dusk-on-container: #661b9e; + --color-dusk-ui: #a35dd8; + --color-protea: #932464; + --color-protea-container: #ebdbe4; + --color-protea-on-container: #841656; + --color-protea-ui: #d34998; + + /* Type, radius and easing — same scale the v3 preset ships. */ + --font-sans: "Noto Sans", system-ui, sans-serif; + --font-serif: "Noto Serif", Georgia, serif; + --font-mono: "JetBrains Mono", ui-monospace, monospace; + --radius-sm: 7px; + --radius-md: 12px; + --radius-lg: 14px; + --radius-xl: 17px; + --radius-2xl: 17px; + --radius-full: 9999px; + --radius-pill: 9999px; + --ease-soft: cubic-bezier(0.4, 0, 0.2, 1); +} + +/* Ladder, status and semantic tokens alias bare variables, so they are + inlined rather than redefined — no duplicate declaration, one source. */ +@theme inline { + --color-pitch: var(--pitch); + --color-void: var(--void); + --color-base: var(--base); + --color-surface: var(--surface); + --color-container: var(--container); + --color-overlay: var(--overlay); + --color-raised: var(--raised); + --color-scrim: var(--scrim); + --color-wash: var(--wash); + --color-surface-muted: var(--surface-muted); + + --color-syncing: var(--syncing); + --color-offline: var(--offline); + --color-neutral: var(--neutral); + + --color-background: var(--background); + --color-foreground: var(--foreground); + --color-canvas: var(--canvas); + --color-ink: var(--ink); + --color-card: var(--card); + --color-card-foreground: var(--card-foreground); + --color-popover: var(--popover); + --color-popover-foreground: var(--popover-foreground); + --color-primary: var(--primary); + --color-primary-foreground: var(--primary-foreground); + --color-secondary: var(--secondary); + --color-secondary-foreground: var(--secondary-foreground); + --color-muted: var(--muted); + --color-muted-foreground: var(--muted-foreground); + --color-accent: var(--accent); + --color-accent-foreground: var(--accent-foreground); + --color-destructive: var(--destructive); + --color-destructive-foreground: var(--destructive-foreground); + --color-border: var(--border); + --color-input: var(--input); + --color-ring: var(--ring); + --color-success: var(--success); + --color-warning: var(--warning); + --color-error: var(--error); + --color-info: var(--info); + --color-brand-accent: var(--brand-accent); + --color-brand-accent-foreground: var(--brand-accent-foreground); +} diff --git a/packages/bundu-ui/styles/tokens.css b/packages/bundu-ui/styles/tokens.css index e22e61e..6af05b9 100644 --- a/packages/bundu-ui/styles/tokens.css +++ b/packages/bundu-ui/styles/tokens.css @@ -1,188 +1,374 @@ -/* @bundu/ui — design tokens (framework-agnostic). +/* @bundu/ui — design tokens. GENERATED FILE — DO NOT EDIT. - The seven African Minerals + seven heritage tones + semantic tokens, - canonical from the Mzizi design system (mzizi.dev / styling-minerals, - styling-heritage-colors). This file is PURE custom properties — no - @layer, no @apply, no Tailwind — so it can be imported by ANY site to - pull the ecosystem palette from one central source instead of - hardcoding hex values per page. + Source tokens/canon.snapshot.json (brand v4.0.31, 2026-07-13) + Generator scripts/generate-tokens.mjs (`pnpm tokens:build`) + Gate `pnpm tokens:check` fails if this file and the generator disagree - - Tailwind / shadcn sites: import ../styles/globals.css (it @imports - this file, then adds the component + utility layers). - - Plain-CSS / Astro Starlight docs sites: @import this file directly and - map the mineral vars onto your own theme variables. + All 21 colour families of the Mzizi design system under one --color-* + namespace: seven minerals, seven heritage tones, seven experimental tones. + Plus the nine-step surface ladder, the connectivity status trio, and the + semantic (shadcn) contract. - Dark mode is supported under both conventions: the .dark class - (shadcn / Tailwind toggle) and [data-theme="dark"] (data-attribute - toggle, e.g. Starlight). + The hex values do NOT live in a database. Mzizi holds no brand or primitive + token data in Supabase or anywhere else — the on-disk source of truth is + mzizi-dev/mzizi-registry -> lib/tokens/palette.source.ts (+ brand.source.ts), + projected over https://api.mzizi.dev/api/v1/brand and snapshotted into this + repo by `pnpm canon:fetch`. (0.1.1's header claimed "they are the DB's + source of truth". That was already wrong and is corrected here.) - Do NOT hand-edit hex values — they are the DB's source of truth. Refresh - from the design system (get_brand_tokens / styling-minerals) if they move. */ + PURE custom properties — no @layer, no @apply, no Tailwind — so any site + can @import it. Dark mode under both conventions: the .dark class + (shadcn/Tailwind) and [data-theme="dark"] (Starlight and friends). + + Tailwind v4 users: import ./theme.css instead, which @imports this file and + adds the @theme entrypoint. The v3 preset (./tailwind-preset.mjs) keeps + working unchanged. */ :root { - /* === Seven African Minerals — light mode === */ - --color-cobalt: #0047ab; + /* === Seven African Minerals — the deep-earth + hand families */ + --color-cobalt: #0047ab; /* Primary blue, links, CTAs */ --color-cobalt-container: #e3f2fd; --color-cobalt-on-container: #002966; - --color-tanzanite: #4b0082; + --color-tanzanite: #4b0082; /* Purple accent, brand/logo, social features */ --color-tanzanite-container: #f3e5f5; --color-tanzanite-on-container: #2e004d; - --color-malachite: #004d40; + --color-malachite: #004d40; /* Success states, positive actions */ --color-malachite-container: #e0f2f1; --color-malachite-on-container: #00332b; - --color-gold: #5d4037; + --color-gold: #5d4037; /* Achievements, rewards, highlights */ --color-gold-container: #fff8e1; --color-gold-on-container: #3e2723; - --color-terracotta: #a0522d; + --color-terracotta: #a0522d; /* Community features, warmth */ --color-terracotta-container: #f5e6d3; --color-terracotta-on-container: #5d2906; - --color-sodalite: #283593; + --color-sodalite: #283593; /* AI/Shamwari surfaces, deep-reasoning states */ --color-sodalite-container: #e8eaf6; --color-sodalite-on-container: #141a5c; - --color-copper: #bf5a36; + --color-copper: #bf5a36; /* Bundu ecosystem identity, the commons */ --color-copper-container: #fbe4da; --color-copper-on-container: #5c2410; - /* === Seven heritage tones — light mode (atmosphere colours) === */ - --heritage-indigo: #4527a0; - --heritage-savanna: #8d6e1a; - --heritage-baobab: #4e342e; - --heritage-sunset: #d84315; - --heritage-river: #006064; - --heritage-hematite: #546e7a; - --heritage-kalahari: #c9b589; - - /* === Semantic tokens — light === */ - --background: #faf9f4; /* warm cream canvas */ - --canvas: #faf9f4; - --foreground: #1a1a17; /* near-black ink */ - --ink: #1a1a17; - + /* === Seven heritage tones — atmospheric anchors. Canon carries light/dark + only for these; there is no container step. */ + /* Twilight surfaces, deep atmosphere, mini-app moods */ + --color-indigo: #4527a0; + + --color-savanna: #8d6e1a; /* Warm grassland surfaces, daylight atmosphere */ + + /* Earthy surfaces, grounded atmosphere, bark tones */ + --color-baobab: #4e342e; + + --color-sunset: #d84315; /* Warm accent surfaces, golden-hour atmosphere */ + + --color-river: #006064; /* Cool surfaces, flowing atmosphere, water tones */ + + --color-hematite: #546e7a; /* Neutral anchor, mini-app surfaces, atmosphere */ + + /* Light anchor, warm backgrounds, mini-app surfaces */ + --color-kalahari: #c9b589; + + /* === Seven experimental tones — the computed heptagon (hues offset 17°, + prime saturations). `-ui` is the solved interactive step. */ + --color-ember: #843d20; + --color-ember-container: #ebdfdb; + --color-ember-on-container: #7a3115; + --color-ember-ui: #cd5f33; + + --color-acacia: #4d5615; + --color-acacia-container: #e9ebdb; + --color-acacia-on-container: #48510e; + --color-acacia-ui: #7e8c22; + + --color-fern: #175e17; + --color-fern-container: #dbebdb; + --color-fern-on-container: #0f570f; + --color-fern-ui: #259725; + + --color-lagoon: #165b51; + --color-lagoon-container: #dbebe9; + --color-lagoon-on-container: #0e554b; + --color-lagoon-ui: #249383; + + --color-storm: #284ca6; + --color-storm-container: #dbe0eb; + --color-storm-on-container: #1a409b; + --color-storm-ui: #577bd6; + + --color-dusk: #742aad; + --color-dusk-container: #e4dbeb; + --color-dusk-on-container: #661b9e; + --color-dusk-ui: #a35dd8; + + --color-protea: #932464; + --color-protea-container: #ebdbe4; + --color-protea-on-container: #841656; + --color-protea-ui: #d34998; + + /* === Nine-step surface ladder, shallow to deep. Prime steps P2..P17. */ + --pitch: #fafafa; /* Deepest surface — media wells, splash (prime step P2) */ + --void: #f8f8f7; /* App shell behind base (prime step P3) */ + --base: #f3f3f1; /* Page background — ambient base surface (prime step P5) */ + --surface: #eeeeec; /* Card / panel surface (prime step P7) */ + /* Neutral containers, grouped content (prime step P11) */ + --container: #e5e4e1; + --overlay: #e0dfdc; /* Overlays and dialogs (prime step P13) */ + /* Raised elements above overlay — menus, toasts (prime step P17) */ + --raised: #d6d5d1; + --scrim: rgba(0, 0, 0, 0.4); /* Semi-transparent backdrop behind overlays */ + /* Cover-colour page wash — surface tinted with the active brand accent (~7% + light / ~12% dark) */ + --wash: color-mix(in oklab, var(--surface) 93%, var(--brand-accent)); + /* Deepest fill — inset sections, metadata rows, maximum text contrast. Canon + calls this background "muted"; the shadcn semantic --muted already owns + that name below. */ + --surface-muted: #faf9f5; + + /* === Per-app brand mineral. --wash mixes it into --surface; the brand-*.css + overlays repoint it alongside --primary. */ + /* Per-app/per-context saturated brand mineral for accent fills and CTAs. + Defaults to tanzanite — the Mukoko/Nyuchi brand mineral. Swappable per app + or per-event/category (nhimbe). Distinct from the semantic --accent (a pale + container for hover/selected states). */ + --brand-accent: #4b0082; + --brand-accent-foreground: #ffffff; + + /* === Connectivity status trio. */ + --syncing: #1c5962; /* In-progress sync / pending state */ + --offline: #674c32; /* Offline / disconnected state */ + --neutral: #55514b; /* Neutral / inactive status, secondary data series */ + + /* === Semantic tokens — the shadcn/Tailwind contract. */ + /* Canon `base`. Was #faf9f4 / #100f0e before 0.2.0 — drift fixes 1 and 2. */ + --background: var(--base); + --canvas: var(--background); + --foreground: #1a1a17; + --ink: var(--foreground); + /* Dark was #1a1917 before 0.2.0 — drift fix 3. Light stays white: a marketing + card floats above the base by design. */ --card: #ffffff; - --card-foreground: #1a1a17; - --popover: #ffffff; - --popover-foreground: #1a1a17; - - /* Brand primary: cobalt is the canonical Mzizi default. Each brand site - overrides --primary and --ring to its own mineral via the brand-*.css - overlay imported after this file. */ - --primary: var(--color-cobalt); + --card-foreground: var(--foreground); + --popover: var(--card); + --popover-foreground: var(--card-foreground); + /* Drift fix 6. Canon: "Cobalt is the exceptional mineral for links/info only + — do not use it as --primary." Every brand-*.css overlay repoints this; + tanzanite is the unbranded default. */ + --primary: var(--color-tanzanite); --primary-foreground: #ffffff; - - --secondary: #f4f2ec; - --secondary-foreground: #1a1a17; - - --muted: #f4f2ec; + /* Light was #f4f2ec before 0.2.0 — drift fix 5. */ + --secondary: var(--container); + --secondary-foreground: var(--foreground); + --muted: var(--container); /* Light was #f4f2ec before 0.2.0 — drift fix 4. */ --muted-foreground: #5d5c57; - --accent: var(--color-cobalt-container); --accent-foreground: var(--color-cobalt-on-container); - --destructive: #b3261e; --destructive-foreground: #ffffff; --destructive-container: #fdeded; - - --border: #e7e5e0; /* warm stone */ + --border: #e7e5e0; --input: #ffffff; + /* Focus ring color — cobalt. 2px width, 2px offset (see + brand_meta.accessibility). */ --ring: var(--color-cobalt); - --success: var(--color-malachite); --warning: #7a5c00; - --error: #b3261e; - --info: var(--color-cobalt); - - /* Touch targets */ + --error: var(--destructive); + --info: var(--color-cobalt); /* Informational states */ + + /* === Legacy aliases. 0.1.1 namespaced the heritage tones --heritage-*; they + are --color-* now, like every other family. These keep the four existing + consumers compiling unchanged. Do not use in new code. */ + --heritage-indigo: var(--color-indigo); + --heritage-savanna: var(--color-savanna); + --heritage-baobab: var(--color-baobab); + --heritage-sunset: var(--color-sunset); + --heritage-river: var(--color-river); + --heritage-hematite: var(--color-hematite); + --heritage-kalahari: var(--color-kalahari); + + /* === Touch targets — accessibility mandate, 56px comfortable. */ --touch-target: 56px; --touch-target-sm: 48px; - /* Radius system — derived from the ecosystem --radius-unit (7px). */ + /* === Radius scale. Ecosystem numbers 7/12/14/17; buttons are always pill. */ --radius-unit: 7px; --radius-sm: 7px; --radius-md: 12px; --radius-lg: 14px; --radius-xl: 17px; + --radius-2xl: 17px; --radius-full: 9999px; } .dark, [data-theme="dark"] { - /* === Seven African Minerals — dark mode === */ - --color-cobalt: #00b0ff; + /* === Seven African Minerals — the deep-earth + hand families */ + --color-cobalt: #00b0ff; /* Primary blue, links, CTAs */ --color-cobalt-container: #001f3f; --color-cobalt-on-container: #b3e5fc; - --color-tanzanite: #b388ff; + --color-tanzanite: #b388ff; /* Purple accent, brand/logo, social features */ --color-tanzanite-container: #1a0033; --color-tanzanite-on-container: #e1bee7; - --color-malachite: #64ffda; + --color-malachite: #64ffda; /* Success states, positive actions */ --color-malachite-container: #00251a; --color-malachite-on-container: #a7ffeb; - --color-gold: #ffd740; + --color-gold: #ffd740; /* Achievements, rewards, highlights */ --color-gold-container: #332200; --color-gold-on-container: #ffecb3; - --color-terracotta: #e1b07e; + --color-terracotta: #e1b07e; /* Community features, warmth */ --color-terracotta-container: #3e2817; --color-terracotta-on-container: #f5e6d3; - --color-sodalite: #3d5afe; + --color-sodalite: #3d5afe; /* AI/Shamwari surfaces, deep-reasoning states */ --color-sodalite-container: #0d1442; --color-sodalite-on-container: #c5cae9; - --color-copper: #ff8a65; + --color-copper: #ff8a65; /* Bundu ecosystem identity, the commons */ --color-copper-container: #3a1a0e; --color-copper-on-container: #ffd3c2; - /* === Seven heritage tones — dark mode === */ - --heritage-indigo: #7986cb; - --heritage-savanna: #e5c158; - --heritage-baobab: #a1887f; - --heritage-sunset: #ff7043; - --heritage-river: #4dd0e1; - --heritage-hematite: #90a4ae; - --heritage-kalahari: #e8d9b5; - - /* === Semantic tokens — dark === */ - --background: #100f0e; - --canvas: #100f0e; + /* === Seven heritage tones — atmospheric anchors. Canon carries light/dark + only for these; there is no container step. */ + /* Twilight surfaces, deep atmosphere, mini-app moods */ + --color-indigo: #7986cb; + + --color-savanna: #e5c158; /* Warm grassland surfaces, daylight atmosphere */ + + /* Earthy surfaces, grounded atmosphere, bark tones */ + --color-baobab: #a1887f; + + --color-sunset: #ff7043; /* Warm accent surfaces, golden-hour atmosphere */ + + --color-river: #4dd0e1; /* Cool surfaces, flowing atmosphere, water tones */ + + --color-hematite: #90a4ae; /* Neutral anchor, mini-app surfaces, atmosphere */ + + /* Light anchor, warm backgrounds, mini-app surfaces */ + --color-kalahari: #e8d9b5; + + /* === Seven experimental tones — the computed heptagon (hues offset 17°, + prime saturations). `-ui` is the solved interactive step. */ + --color-ember: #da8766; + --color-ember-container: #352721; + --color-ember-on-container: #eba68a; + --color-ember-ui: #bb562d; + + --color-acacia: #93a528; + --color-acacia-container: #333521; + --color-acacia-on-container: #b6ce23; + --color-acacia-ui: #768420; + + --color-fern: #2cb42b; + --color-fern-container: #213521; + --color-fern-on-container: #28db28; + --color-fern-ui: #228d22; + + --color-lagoon: #2aae9b; + --color-lagoon-container: #213532; + --color-lagoon-on-container: #24d6bc; + --color-lagoon-ui: #218a7a; + + --color-storm: #7e9be0; + --color-storm-container: #212735; + --color-storm-on-container: #99b2ee; + --color-storm-ui: #426cd1; + + --color-dusk: #ba87e2; + --color-dusk-container: #2d2135; + --color-dusk-on-container: #cc9fef; + --color-dusk-ui: #9749d3; + + --color-protea: #df7bb4; + --color-protea-container: #35212d; + --color-protea-on-container: #ed98c9; + --color-protea-ui: #ca3188; + + /* === Nine-step surface ladder, shallow to deep. Prime steps P2..P17. */ + --pitch: #050505; /* Deepest surface — media wells, splash (prime step P2) */ + --void: #080807; /* App shell behind base (prime step P3) */ + --base: #0e0d0c; /* Page background — ambient base surface (prime step P5) */ + --surface: #131211; /* Card / panel surface (prime step P7) */ + /* Neutral containers, grouped content (prime step P11) */ + --container: #1e1d1a; + --overlay: #23221f; /* Overlays and dialogs (prime step P13) */ + /* Raised elements above overlay — menus, toasts (prime step P17) */ + --raised: #2e2c29; + --scrim: rgba(0, 0, 0, 0.6); /* Semi-transparent backdrop behind overlays */ + /* Cover-colour page wash — surface tinted with the active brand accent (~7% + light / ~12% dark) */ + --wash: color-mix(in oklab, var(--surface) 88%, var(--brand-accent)); + /* Deepest fill — inset sections, metadata rows, maximum text contrast. Canon + calls this background "muted"; the shadcn semantic --muted already owns + that name below. */ + --surface-muted: #050504; + + /* === Per-app brand mineral. --wash mixes it into --surface; the brand-*.css + overlays repoint it alongside --primary. */ + /* Per-app/per-context saturated brand mineral for accent fills and CTAs. + Defaults to tanzanite — the Mukoko/Nyuchi brand mineral. Swappable per app + or per-event/category (nhimbe). Distinct from the semantic --accent (a pale + container for hover/selected states). */ + --brand-accent: #b388ff; + --brand-accent-foreground: #0e0d0c; + + /* === Connectivity status trio. */ + --syncing: #36abba; /* In-progress sync / pending state */ + --offline: #ba9570; /* Offline / disconnected state */ + --neutral: #a09c93; /* Neutral / inactive status, secondary data series */ + + /* === Semantic tokens — the shadcn/Tailwind contract. */ + /* Canon `base`. Was #faf9f4 / #100f0e before 0.2.0 — drift fixes 1 and 2. */ + --background: var(--base); + --canvas: var(--background); --foreground: #f0efe9; - --ink: #f0efe9; - - --card: #1a1917; - --card-foreground: #f0efe9; - --popover: #1a1917; - --popover-foreground: #f0efe9; - - --primary: var(--color-cobalt); + --ink: var(--foreground); + /* Dark was #1a1917 before 0.2.0 — drift fix 3. Light stays white: a marketing + card floats above the base by design. */ + --card: var(--surface); + --card-foreground: var(--foreground); + --popover: var(--card); + --popover-foreground: var(--card-foreground); + /* Drift fix 6. Canon: "Cobalt is the exceptional mineral for links/info only + — do not use it as --primary." Every brand-*.css overlay repoints this; + tanzanite is the unbranded default. */ + --primary: var(--color-tanzanite); --primary-foreground: #1a1917; - - --secondary: #2a2927; - --secondary-foreground: #f0efe9; - - --muted: #2a2927; + --secondary: #2a2927; /* Light was #f4f2ec before 0.2.0 — drift fix 5. */ + --secondary-foreground: var(--foreground); + --muted: #2a2927; /* Light was #f4f2ec before 0.2.0 — drift fix 4. */ --muted-foreground: #a8a6a0; - --accent: var(--color-cobalt-container); --accent-foreground: var(--color-cobalt-on-container); - --destructive: #f2b8b5; --destructive-foreground: #1a1917; --destructive-container: #3e1818; - --border: #2a2927; --input: #100f0e; + /* Focus ring color — cobalt. 2px width, 2px offset (see + brand_meta.accessibility). */ --ring: var(--color-cobalt); - --success: var(--color-malachite); --warning: #ffd866; - --error: #f2b8b5; - --info: var(--color-cobalt); + --error: var(--destructive); + --info: var(--color-cobalt); /* Informational states */ + + /* === Legacy aliases. 0.1.1 namespaced the heritage tones --heritage-*; they + are --color-* now, like every other family. These keep the four existing + consumers compiling unchanged. Do not use in new code. */ + --heritage-indigo: var(--color-indigo); + --heritage-savanna: var(--color-savanna); + --heritage-baobab: var(--color-baobab); + --heritage-sunset: var(--color-sunset); + --heritage-river: var(--color-river); + --heritage-hematite: var(--color-hematite); + --heritage-kalahari: var(--color-kalahari); } diff --git a/packages/bundu-ui/tailwind-palette.mjs b/packages/bundu-ui/tailwind-palette.mjs new file mode 100644 index 0000000..af74772 --- /dev/null +++ b/packages/bundu-ui/tailwind-palette.mjs @@ -0,0 +1,192 @@ +/* @bundu/ui — Tailwind v3 colour map. GENERATED FILE — DO NOT EDIT. + + Generator scripts/generate-tokens.mjs (`pnpm tokens:build`) + Gate `pnpm tokens:check` + + Spread into `theme.extend.colors` by ./tailwind-preset.mjs. Every value + is a var() into styles/tokens.css — there is no hex in this file, so + light/dark and the brand-*.css overlays all keep working. */ + +export const colors = { + /* Seven African Minerals. */ + cobalt: { + DEFAULT: "var(--color-cobalt)", + container: "var(--color-cobalt-container)", + "on-container": "var(--color-cobalt-on-container)", + }, + tanzanite: { + DEFAULT: "var(--color-tanzanite)", + container: "var(--color-tanzanite-container)", + "on-container": "var(--color-tanzanite-on-container)", + }, + malachite: { + DEFAULT: "var(--color-malachite)", + container: "var(--color-malachite-container)", + "on-container": "var(--color-malachite-on-container)", + }, + gold: { + DEFAULT: "var(--color-gold)", + container: "var(--color-gold-container)", + "on-container": "var(--color-gold-on-container)", + }, + terracotta: { + DEFAULT: "var(--color-terracotta)", + container: "var(--color-terracotta-container)", + "on-container": "var(--color-terracotta-on-container)", + }, + sodalite: { + DEFAULT: "var(--color-sodalite)", + container: "var(--color-sodalite-container)", + "on-container": "var(--color-sodalite-on-container)", + }, + copper: { + DEFAULT: "var(--color-copper)", + container: "var(--color-copper-container)", + "on-container": "var(--color-copper-on-container)", + }, + + /* Seven heritage tones. NOTE: `indigo` shadows Tailwind's built-in + indigo scale — `bg-indigo-500` becomes unavailable, `bg-indigo` is + the heritage tone. The estate forbids default-palette utilities + anyway (bundu-labs/marketing scripts/check-token-consistency.mjs). */ + indigo: "var(--color-indigo)", + savanna: "var(--color-savanna)", + baobab: "var(--color-baobab)", + sunset: "var(--color-sunset)", + river: "var(--color-river)", + hematite: "var(--color-hematite)", + kalahari: "var(--color-kalahari)", + + /* Seven experimental tones. */ + ember: { + DEFAULT: "var(--color-ember)", + container: "var(--color-ember-container)", + "on-container": "var(--color-ember-on-container)", + ui: "var(--color-ember-ui)", + }, + acacia: { + DEFAULT: "var(--color-acacia)", + container: "var(--color-acacia-container)", + "on-container": "var(--color-acacia-on-container)", + ui: "var(--color-acacia-ui)", + }, + fern: { + DEFAULT: "var(--color-fern)", + container: "var(--color-fern-container)", + "on-container": "var(--color-fern-on-container)", + ui: "var(--color-fern-ui)", + }, + lagoon: { + DEFAULT: "var(--color-lagoon)", + container: "var(--color-lagoon-container)", + "on-container": "var(--color-lagoon-on-container)", + ui: "var(--color-lagoon-ui)", + }, + storm: { + DEFAULT: "var(--color-storm)", + container: "var(--color-storm-container)", + "on-container": "var(--color-storm-on-container)", + ui: "var(--color-storm-ui)", + }, + dusk: { + DEFAULT: "var(--color-dusk)", + container: "var(--color-dusk-container)", + "on-container": "var(--color-dusk-on-container)", + ui: "var(--color-dusk-ui)", + }, + protea: { + DEFAULT: "var(--color-protea)", + container: "var(--color-protea-container)", + "on-container": "var(--color-protea-on-container)", + ui: "var(--color-protea-ui)", + }, + + /* Nine-step surface ladder. */ + pitch: "var(--pitch)", + void: "var(--void)", + base: "var(--base)", + surface: "var(--surface)", + container: "var(--container)", + overlay: "var(--overlay)", + raised: "var(--raised)", + scrim: "var(--scrim)", + wash: "var(--wash)", + "surface-muted": "var(--surface-muted)", + + /* Connectivity status trio. */ + syncing: "var(--syncing)", + offline: "var(--offline)", + neutral: "var(--neutral)", + + /* Semantic tokens — the shadcn/Tailwind contract. */ + background: "var(--background)", + foreground: "var(--foreground)", + canvas: "var(--canvas)", + ink: "var(--ink)", + primary: { + DEFAULT: "var(--primary)", + foreground: "var(--primary-foreground)", + }, + secondary: { + DEFAULT: "var(--secondary)", + foreground: "var(--secondary-foreground)", + }, + muted: { + DEFAULT: "var(--muted)", + foreground: "var(--muted-foreground)", + }, + accent: { + DEFAULT: "var(--accent)", + foreground: "var(--accent-foreground)", + }, + destructive: { + DEFAULT: "var(--destructive)", + foreground: "var(--destructive-foreground)", + }, + card: { + DEFAULT: "var(--card)", + foreground: "var(--card-foreground)", + }, + popover: { + DEFAULT: "var(--popover)", + foreground: "var(--popover-foreground)", + }, + border: "var(--border)", + input: "var(--input)", + ring: "var(--ring)", + success: "var(--success)", + warning: "var(--warning)", + error: "var(--error)", + info: "var(--info)", + "brand-accent": { + DEFAULT: "var(--brand-accent)", + foreground: "var(--brand-accent-foreground)", + }, +}; + +/* The 21 colour family names, in canon order. Used for the v3 preset's + safelist: mineral utilities are often composed from data + (`bg-${mineral}`), which Tailwind's scanner cannot see. */ +export const families = [ + "cobalt", + "tanzanite", + "malachite", + "gold", + "terracotta", + "sodalite", + "copper", + "indigo", + "savanna", + "baobab", + "sunset", + "river", + "hematite", + "kalahari", + "ember", + "acacia", + "fern", + "lagoon", + "storm", + "dusk", + "protea", +]; diff --git a/packages/bundu-ui/tailwind-preset.mjs b/packages/bundu-ui/tailwind-preset.mjs index 57cf6e0..f56ed7d 100644 --- a/packages/bundu-ui/tailwind-preset.mjs +++ b/packages/bundu-ui/tailwind-preset.mjs @@ -21,90 +21,21 @@ * class names dynamically. * * @type {import('tailwindcss').Config} + * + * The colour map is GENERATED — ./tailwind-palette.mjs, built by + * scripts/generate-tokens.mjs from tokens/canon.snapshot.json. It carries + * all 21 colour families, the nine-step surface ladder and the status trio, + * not just the seven minerals. Everything else here is hand-authored + * package policy. Regenerate with `pnpm tokens:build`. */ + +import { colors } from "./tailwind-palette.mjs"; + export default { darkMode: "class", theme: { extend: { - colors: { - // === Seven African Minerals (canonical Nyuchi Design System palette) === - cobalt: { - DEFAULT: "var(--color-cobalt)", - container: "var(--color-cobalt-container)", - "on-container": "var(--color-cobalt-on-container)", - }, - tanzanite: { - DEFAULT: "var(--color-tanzanite)", - container: "var(--color-tanzanite-container)", - "on-container": "var(--color-tanzanite-on-container)", - }, - malachite: { - DEFAULT: "var(--color-malachite)", - container: "var(--color-malachite-container)", - "on-container": "var(--color-malachite-on-container)", - }, - gold: { - DEFAULT: "var(--color-gold)", - container: "var(--color-gold-container)", - "on-container": "var(--color-gold-on-container)", - }, - terracotta: { - DEFAULT: "var(--color-terracotta)", - container: "var(--color-terracotta-container)", - "on-container": "var(--color-terracotta-on-container)", - }, - sodalite: { - DEFAULT: "var(--color-sodalite)", - container: "var(--color-sodalite-container)", - "on-container": "var(--color-sodalite-on-container)", - }, - copper: { - DEFAULT: "var(--color-copper)", - container: "var(--color-copper-container)", - "on-container": "var(--color-copper-on-container)", - }, - - // === Semantic tokens === - background: "var(--background)", - foreground: "var(--foreground)", - canvas: "var(--canvas)", - ink: "var(--ink)", - primary: { - DEFAULT: "var(--primary)", - foreground: "var(--primary-foreground)", - }, - secondary: { - DEFAULT: "var(--secondary)", - foreground: "var(--secondary-foreground)", - }, - muted: { - DEFAULT: "var(--muted)", - foreground: "var(--muted-foreground)", - }, - accent: { - DEFAULT: "var(--accent)", - foreground: "var(--accent-foreground)", - }, - destructive: { - DEFAULT: "var(--destructive)", - foreground: "var(--destructive-foreground)", - }, - card: { - DEFAULT: "var(--card)", - foreground: "var(--card-foreground)", - }, - popover: { - DEFAULT: "var(--popover)", - foreground: "var(--popover-foreground)", - }, - border: "var(--border)", - input: "var(--input)", - ring: "var(--ring)", - success: "var(--success)", - warning: "var(--warning)", - error: "var(--error)", - info: "var(--info)", - }, + colors, fontFamily: { sans: ['"Noto Sans"', "system-ui", "sans-serif"], serif: ['"Noto Serif"', "Georgia", "serif"], diff --git a/packages/bundu-ui/tokens.json b/packages/bundu-ui/tokens.json new file mode 100644 index 0000000..e3ee23e --- /dev/null +++ b/packages/bundu-ui/tokens.json @@ -0,0 +1,567 @@ +{ + "$comment": "GENERATED by scripts/generate-tokens.mjs from tokens/canon.snapshot.json. Do not edit. `pnpm tokens:check` fails if you do.", + "package": "@bundu/ui", + "canon": { + "brandSource": "https://api.mzizi.dev/api/v1/brand", + "paletteSource": "https://raw.githubusercontent.com/mzizi-dev/mzizi-registry/main/lib/tokens/palette.source.ts", + "version": "4.0.31", + "lastUpdated": "2026-07-13", + "onDiskSourceOfTruth": "mzizi-dev/mzizi-registry -> lib/tokens/palette.source.ts + brand.source.ts" + }, + "color": { + "minerals": { + "cobalt": { + "light": "#0047ab", + "dark": "#00b0ff", + "cssVar": "--color-cobalt", + "containerLight": "#e3f2fd", + "containerDark": "#001f3f", + "onContainerLight": "#002966", + "onContainerDark": "#b3e5fc", + "origin": "Katanga (DRC) and Zambian Copperbelt", + "symbolism": "Digital future, trust, knowledge", + "usage": "Primary blue, links, CTAs" + }, + "tanzanite": { + "light": "#4b0082", + "dark": "#b388ff", + "cssVar": "--color-tanzanite", + "containerLight": "#f3e5f5", + "containerDark": "#1a0033", + "onContainerLight": "#2e004d", + "onContainerDark": "#e1bee7", + "origin": "Merelani Hills, Tanzania", + "symbolism": "Premium, creativity, connection", + "usage": "Purple accent, brand/logo, social features" + }, + "malachite": { + "light": "#004d40", + "dark": "#64ffda", + "cssVar": "--color-malachite", + "containerLight": "#e0f2f1", + "containerDark": "#00251a", + "onContainerLight": "#00332b", + "onContainerDark": "#a7ffeb", + "origin": "Congo Copper Belt", + "symbolism": "Growth, nature, success", + "usage": "Success states, positive actions" + }, + "gold": { + "light": "#5d4037", + "dark": "#ffd740", + "cssVar": "--color-gold", + "containerLight": "#fff8e1", + "containerDark": "#332200", + "onContainerLight": "#3e2723", + "onContainerDark": "#ffecb3", + "origin": "Ghana, South Africa, Mali", + "symbolism": "Honey, rewards, warmth", + "usage": "Achievements, rewards, highlights" + }, + "terracotta": { + "light": "#a0522d", + "dark": "#e1b07e", + "cssVar": "--color-terracotta", + "containerLight": "#f5e6d3", + "containerDark": "#3e2817", + "onContainerLight": "#5d2906", + "onContainerDark": "#f5e6d3", + "origin": "Pan-African Sahel", + "symbolism": "Earth, community, grounding", + "usage": "Community features, warmth" + }, + "sodalite": { + "light": "#283593", + "dark": "#3d5afe", + "cssVar": "--color-sodalite", + "containerLight": "#e8eaf6", + "containerDark": "#0d1442", + "onContainerLight": "#141a5c", + "onContainerDark": "#c5cae9", + "origin": "Kunene River, Namibia & South Africa", + "symbolism": "Intelligence, depth, reasoning", + "usage": "AI/Shamwari surfaces, deep-reasoning states" + }, + "copper": { + "light": "#bf5a36", + "dark": "#ff8a65", + "cssVar": "--color-copper", + "containerLight": "#fbe4da", + "containerDark": "#3a1a0e", + "onContainerLight": "#5c2410", + "onContainerDark": "#ffd3c2", + "origin": "Central African Copperbelt, Zambia & DRC", + "symbolism": "Connection, foundation, stewardship", + "usage": "Bundu ecosystem identity, the commons" + } + }, + "heritage": { + "indigo": { + "light": "#4527a0", + "dark": "#7986cb", + "cssVar": "--color-indigo", + "origin": "Indigofera, West Africa textile tradition", + "symbolism": "Dusk, depth, the dyer's craft", + "usage": "Twilight surfaces, deep atmosphere, mini-app moods" + }, + "savanna": { + "light": "#8d6e1a", + "dark": "#e5c158", + "cssVar": "--color-savanna", + "origin": "Sub-Saharan grasslands", + "symbolism": "Sun-dried grass, open land, the dry season", + "usage": "Warm grassland surfaces, daylight atmosphere" + }, + "baobab": { + "light": "#4e342e", + "dark": "#a1887f", + "cssVar": "--color-baobab", + "origin": "Adansonia, across the African continent", + "symbolism": "The tree of life, age, shelter", + "usage": "Earthy surfaces, grounded atmosphere, bark tones" + }, + "sunset": { + "light": "#d84315", + "dark": "#ff7043", + "cssVar": "--color-sunset", + "origin": "The African horizon at dusk", + "symbolism": "Day's end, warmth, the gathering hour", + "usage": "Warm accent surfaces, golden-hour atmosphere" + }, + "river": { + "light": "#006064", + "dark": "#4dd0e1", + "cssVar": "--color-river", + "origin": "The great African rivers — Zambezi, Nile, Congo", + "symbolism": "Flow, life, the journey", + "usage": "Cool surfaces, flowing atmosphere, water tones" + }, + "hematite": { + "light": "#546e7a", + "dark": "#90a4ae", + "cssVar": "--color-hematite", + "origin": "Sishen & Thabazimbi, South Africa", + "symbolism": "Foundation, endurance, the substrate", + "usage": "Neutral anchor, mini-app surfaces, atmosphere" + }, + "kalahari": { + "light": "#c9b589", + "dark": "#e8d9b5", + "cssVar": "--color-kalahari", + "origin": "Kalahari & Namib, Southern Africa", + "symbolism": "Openness, space, the light pole", + "usage": "Light anchor, warm backgrounds, mini-app surfaces" + } + }, + "experimental": { + "ember": { + "light": "#843d20", + "dark": "#da8766", + "cssVar": "--color-ember", + "containerLight": "#ebdfdb", + "containerDark": "#352721", + "onContainerLight": "#7a3115", + "onContainerDark": "#eba68a", + "uiLight": "#cd5f33", + "uiDark": "#bb562d", + "heptagonIndex": 0 + }, + "acacia": { + "light": "#4d5615", + "dark": "#93a528", + "cssVar": "--color-acacia", + "containerLight": "#e9ebdb", + "containerDark": "#333521", + "onContainerLight": "#48510e", + "onContainerDark": "#b6ce23", + "uiLight": "#7e8c22", + "uiDark": "#768420", + "heptagonIndex": 1 + }, + "fern": { + "light": "#175e17", + "dark": "#2cb42b", + "cssVar": "--color-fern", + "containerLight": "#dbebdb", + "containerDark": "#213521", + "onContainerLight": "#0f570f", + "onContainerDark": "#28db28", + "uiLight": "#259725", + "uiDark": "#228d22", + "heptagonIndex": 2 + }, + "lagoon": { + "light": "#165b51", + "dark": "#2aae9b", + "cssVar": "--color-lagoon", + "containerLight": "#dbebe9", + "containerDark": "#213532", + "onContainerLight": "#0e554b", + "onContainerDark": "#24d6bc", + "uiLight": "#249383", + "uiDark": "#218a7a", + "heptagonIndex": 3 + }, + "storm": { + "light": "#284ca6", + "dark": "#7e9be0", + "cssVar": "--color-storm", + "containerLight": "#dbe0eb", + "containerDark": "#212735", + "onContainerLight": "#1a409b", + "onContainerDark": "#99b2ee", + "uiLight": "#577bd6", + "uiDark": "#426cd1", + "heptagonIndex": 4 + }, + "dusk": { + "light": "#742aad", + "dark": "#ba87e2", + "cssVar": "--color-dusk", + "containerLight": "#e4dbeb", + "containerDark": "#2d2135", + "onContainerLight": "#661b9e", + "onContainerDark": "#cc9fef", + "uiLight": "#a35dd8", + "uiDark": "#9749d3", + "heptagonIndex": 5 + }, + "protea": { + "light": "#932464", + "dark": "#df7bb4", + "cssVar": "--color-protea", + "containerLight": "#ebdbe4", + "containerDark": "#35212d", + "onContainerLight": "#841656", + "onContainerDark": "#ed98c9", + "uiLight": "#d34998", + "uiDark": "#ca3188", + "heptagonIndex": 6 + } + } + }, + "surface": { + "pitch": { + "light": "#fafafa", + "dark": "#050505", + "cssVar": "--pitch", + "usage": "Deepest surface — media wells, splash (prime step P2)" + }, + "void": { + "light": "#f8f8f7", + "dark": "#080807", + "cssVar": "--void", + "usage": "App shell behind base (prime step P3)" + }, + "base": { + "light": "#f3f3f1", + "dark": "#0e0d0c", + "cssVar": "--base", + "usage": "Page background — ambient base surface (prime step P5)" + }, + "surface": { + "light": "#eeeeec", + "dark": "#131211", + "cssVar": "--surface", + "usage": "Card / panel surface (prime step P7)" + }, + "container": { + "light": "#e5e4e1", + "dark": "#1e1d1a", + "cssVar": "--container", + "usage": "Neutral containers, grouped content (prime step P11)" + }, + "overlay": { + "light": "#e0dfdc", + "dark": "#23221f", + "cssVar": "--overlay", + "usage": "Overlays and dialogs (prime step P13)" + }, + "raised": { + "light": "#d6d5d1", + "dark": "#2e2c29", + "cssVar": "--raised", + "usage": "Raised elements above overlay — menus, toasts (prime step P17)" + }, + "scrim": { + "light": "rgba(0, 0, 0, 0.4)", + "dark": "rgba(0, 0, 0, 0.6)", + "cssVar": "--scrim", + "usage": "Semi-transparent backdrop behind overlays" + }, + "wash": { + "light": "color-mix(in oklab, var(--surface) 93%, var(--brand-accent))", + "dark": "color-mix(in oklab, var(--surface) 88%, var(--brand-accent))", + "cssVar": "--wash", + "usage": "Cover-colour page wash — surface tinted with the active brand accent (~7% light / ~12% dark)" + }, + "muted": { + "light": "#faf9f5", + "dark": "#050504", + "cssVar": "--surface-muted", + "usage": "Deepest fill — inset sections, metadata rows, maximum text contrast" + } + }, + "status": { + "syncing": { + "light": "#1c5962", + "dark": "#36abba", + "cssVar": "--syncing", + "usage": "In-progress sync / pending state" + }, + "offline": { + "light": "#674c32", + "dark": "#ba9570", + "cssVar": "--offline", + "usage": "Offline / disconnected state" + }, + "neutral": { + "light": "#55514b", + "dark": "#a09c93", + "cssVar": "--neutral", + "usage": "Neutral / inactive status, secondary data series" + } + }, + "radius": { + "2xl": "17px", + "base": "14px", + "full": "9999px", + "lg": "14px", + "md": "12px", + "sm": "7px", + "system": "Ecosystem numbers: 7, 12, 14, 17. Buttons are always pill (rounded-full).", + "xl": "17px" + }, + "resolved": { + "light": { + "--color-cobalt": "#0047ab", + "--color-cobalt-container": "#e3f2fd", + "--color-cobalt-on-container": "#002966", + "--color-tanzanite": "#4b0082", + "--color-tanzanite-container": "#f3e5f5", + "--color-tanzanite-on-container": "#2e004d", + "--color-malachite": "#004d40", + "--color-malachite-container": "#e0f2f1", + "--color-malachite-on-container": "#00332b", + "--color-gold": "#5d4037", + "--color-gold-container": "#fff8e1", + "--color-gold-on-container": "#3e2723", + "--color-terracotta": "#a0522d", + "--color-terracotta-container": "#f5e6d3", + "--color-terracotta-on-container": "#5d2906", + "--color-sodalite": "#283593", + "--color-sodalite-container": "#e8eaf6", + "--color-sodalite-on-container": "#141a5c", + "--color-copper": "#bf5a36", + "--color-copper-container": "#fbe4da", + "--color-copper-on-container": "#5c2410", + "--color-indigo": "#4527a0", + "--color-savanna": "#8d6e1a", + "--color-baobab": "#4e342e", + "--color-sunset": "#d84315", + "--color-river": "#006064", + "--color-hematite": "#546e7a", + "--color-kalahari": "#c9b589", + "--color-ember": "#843d20", + "--color-ember-container": "#ebdfdb", + "--color-ember-on-container": "#7a3115", + "--color-ember-ui": "#cd5f33", + "--color-acacia": "#4d5615", + "--color-acacia-container": "#e9ebdb", + "--color-acacia-on-container": "#48510e", + "--color-acacia-ui": "#7e8c22", + "--color-fern": "#175e17", + "--color-fern-container": "#dbebdb", + "--color-fern-on-container": "#0f570f", + "--color-fern-ui": "#259725", + "--color-lagoon": "#165b51", + "--color-lagoon-container": "#dbebe9", + "--color-lagoon-on-container": "#0e554b", + "--color-lagoon-ui": "#249383", + "--color-storm": "#284ca6", + "--color-storm-container": "#dbe0eb", + "--color-storm-on-container": "#1a409b", + "--color-storm-ui": "#577bd6", + "--color-dusk": "#742aad", + "--color-dusk-container": "#e4dbeb", + "--color-dusk-on-container": "#661b9e", + "--color-dusk-ui": "#a35dd8", + "--color-protea": "#932464", + "--color-protea-container": "#ebdbe4", + "--color-protea-on-container": "#841656", + "--color-protea-ui": "#d34998", + "--pitch": "#fafafa", + "--void": "#f8f8f7", + "--base": "#f3f3f1", + "--surface": "#eeeeec", + "--container": "#e5e4e1", + "--overlay": "#e0dfdc", + "--raised": "#d6d5d1", + "--scrim": "rgba(0, 0, 0, 0.4)", + "--wash": "color-mix(in oklab, var(--surface) 93%, var(--brand-accent))", + "--surface-muted": "#faf9f5", + "--brand-accent": "#4b0082", + "--brand-accent-foreground": "#ffffff", + "--syncing": "#1c5962", + "--offline": "#674c32", + "--neutral": "#55514b", + "--background": "#f3f3f1", + "--canvas": "#f3f3f1", + "--foreground": "#1a1a17", + "--ink": "#1a1a17", + "--card": "#ffffff", + "--card-foreground": "#1a1a17", + "--popover": "#ffffff", + "--popover-foreground": "#1a1a17", + "--primary": "#4b0082", + "--primary-foreground": "#ffffff", + "--secondary": "#e5e4e1", + "--secondary-foreground": "#1a1a17", + "--muted": "#e5e4e1", + "--muted-foreground": "#5d5c57", + "--accent": "#e3f2fd", + "--accent-foreground": "#002966", + "--destructive": "#b3261e", + "--destructive-foreground": "#ffffff", + "--destructive-container": "#fdeded", + "--border": "#e7e5e0", + "--input": "#ffffff", + "--ring": "#0047ab", + "--success": "#004d40", + "--warning": "#7a5c00", + "--error": "#b3261e", + "--info": "#0047ab", + "--heritage-indigo": "#4527a0", + "--heritage-savanna": "#8d6e1a", + "--heritage-baobab": "#4e342e", + "--heritage-sunset": "#d84315", + "--heritage-river": "#006064", + "--heritage-hematite": "#546e7a", + "--heritage-kalahari": "#c9b589", + "--touch-target": "56px", + "--touch-target-sm": "48px", + "--radius-unit": "7px", + "--radius-sm": "7px", + "--radius-md": "12px", + "--radius-lg": "14px", + "--radius-xl": "17px", + "--radius-2xl": "17px", + "--radius-full": "9999px" + }, + "dark": { + "--color-cobalt": "#00b0ff", + "--color-cobalt-container": "#001f3f", + "--color-cobalt-on-container": "#b3e5fc", + "--color-tanzanite": "#b388ff", + "--color-tanzanite-container": "#1a0033", + "--color-tanzanite-on-container": "#e1bee7", + "--color-malachite": "#64ffda", + "--color-malachite-container": "#00251a", + "--color-malachite-on-container": "#a7ffeb", + "--color-gold": "#ffd740", + "--color-gold-container": "#332200", + "--color-gold-on-container": "#ffecb3", + "--color-terracotta": "#e1b07e", + "--color-terracotta-container": "#3e2817", + "--color-terracotta-on-container": "#f5e6d3", + "--color-sodalite": "#3d5afe", + "--color-sodalite-container": "#0d1442", + "--color-sodalite-on-container": "#c5cae9", + "--color-copper": "#ff8a65", + "--color-copper-container": "#3a1a0e", + "--color-copper-on-container": "#ffd3c2", + "--color-indigo": "#7986cb", + "--color-savanna": "#e5c158", + "--color-baobab": "#a1887f", + "--color-sunset": "#ff7043", + "--color-river": "#4dd0e1", + "--color-hematite": "#90a4ae", + "--color-kalahari": "#e8d9b5", + "--color-ember": "#da8766", + "--color-ember-container": "#352721", + "--color-ember-on-container": "#eba68a", + "--color-ember-ui": "#bb562d", + "--color-acacia": "#93a528", + "--color-acacia-container": "#333521", + "--color-acacia-on-container": "#b6ce23", + "--color-acacia-ui": "#768420", + "--color-fern": "#2cb42b", + "--color-fern-container": "#213521", + "--color-fern-on-container": "#28db28", + "--color-fern-ui": "#228d22", + "--color-lagoon": "#2aae9b", + "--color-lagoon-container": "#213532", + "--color-lagoon-on-container": "#24d6bc", + "--color-lagoon-ui": "#218a7a", + "--color-storm": "#7e9be0", + "--color-storm-container": "#212735", + "--color-storm-on-container": "#99b2ee", + "--color-storm-ui": "#426cd1", + "--color-dusk": "#ba87e2", + "--color-dusk-container": "#2d2135", + "--color-dusk-on-container": "#cc9fef", + "--color-dusk-ui": "#9749d3", + "--color-protea": "#df7bb4", + "--color-protea-container": "#35212d", + "--color-protea-on-container": "#ed98c9", + "--color-protea-ui": "#ca3188", + "--pitch": "#050505", + "--void": "#080807", + "--base": "#0e0d0c", + "--surface": "#131211", + "--container": "#1e1d1a", + "--overlay": "#23221f", + "--raised": "#2e2c29", + "--scrim": "rgba(0, 0, 0, 0.6)", + "--wash": "color-mix(in oklab, var(--surface) 88%, var(--brand-accent))", + "--surface-muted": "#050504", + "--brand-accent": "#b388ff", + "--brand-accent-foreground": "#0e0d0c", + "--syncing": "#36abba", + "--offline": "#ba9570", + "--neutral": "#a09c93", + "--background": "#0e0d0c", + "--canvas": "#0e0d0c", + "--foreground": "#f0efe9", + "--ink": "#f0efe9", + "--card": "#131211", + "--card-foreground": "#f0efe9", + "--popover": "#131211", + "--popover-foreground": "#f0efe9", + "--primary": "#b388ff", + "--primary-foreground": "#1a1917", + "--secondary": "#2a2927", + "--secondary-foreground": "#f0efe9", + "--muted": "#2a2927", + "--muted-foreground": "#a8a6a0", + "--accent": "#001f3f", + "--accent-foreground": "#b3e5fc", + "--destructive": "#f2b8b5", + "--destructive-foreground": "#1a1917", + "--destructive-container": "#3e1818", + "--border": "#2a2927", + "--input": "#100f0e", + "--ring": "#00b0ff", + "--success": "#64ffda", + "--warning": "#ffd866", + "--error": "#f2b8b5", + "--info": "#00b0ff", + "--heritage-indigo": "#7986cb", + "--heritage-savanna": "#e5c158", + "--heritage-baobab": "#a1887f", + "--heritage-sunset": "#ff7043", + "--heritage-river": "#4dd0e1", + "--heritage-hematite": "#90a4ae", + "--heritage-kalahari": "#e8d9b5", + "--touch-target": "56px", + "--touch-target-sm": "48px", + "--radius-unit": "7px", + "--radius-sm": "7px", + "--radius-md": "12px", + "--radius-lg": "14px", + "--radius-xl": "17px", + "--radius-2xl": "17px", + "--radius-full": "9999px" + } + } +} diff --git a/packages/ui/README.md b/packages/ui/README.md index 59b2bac..3dbb942 100644 --- a/packages/ui/README.md +++ b/packages/ui/README.md @@ -1,7 +1,7 @@ # @nyuchi/ui Svelte 5 / SvelteKit component library for the **Nyuchi Design System** — -accessible primitives built on the seven African-mineral tokens. Nyuchi's +accessible primitives built on the Mzizi tokens. Nyuchi's implementation of the [Mzizi](https://mzizi.dev) architecture. This is the **app-UI layer** for Nyuchi apps, which are built on SvelteKit. @@ -33,19 +33,49 @@ import components anywhere: ```ts // app.css / root layout -import "@nyuchi/ui/styles/globals.css"; // 7 minerals + semantic tokens -import "@nyuchi/ui/styles/brand-nyuchi.css"; // gold primary (or brand-bundu / brand-mukoko) +import "@nyuchi/ui/styles/tokens.css"; // all 21 colour families + semantic tokens +import "@nyuchi/ui/styles/brand-nyuchi.css"; // gold primary ``` -`globals.css` ships the seven minerals (light + dark), the semantic tokens, -and `@layer` component/utility classes. The canonical `--primary` / `--ring` -mineral is **cobalt**; a brand overlay remaps them: +`tokens.css` carries the whole Mzizi palette — **21 colour families** under one +`--color-*` namespace (7 minerals, 7 heritage tones, 7 experimental tones), the +nine-step surface ladder (`--pitch --void --base --surface --container --overlay +--raised --scrim --wash`), the connectivity status trio (`--syncing --offline +--neutral`) and the semantic (shadcn) contract, in light and dark. + +It is **generated** — see the [repo README](../../README.md#tokens) — and is +byte-identical to `@bundu/ui`'s. Until 0.2.0 this package carried its own +hand-written copy of the palette inside `globals.css`, which had drifted and +disagreed with `@bundu/ui` about Bundu's own brand mineral. That copy is gone. + +`globals.css` now just `@import`s `tokens.css` and adds the `@layer` +component/utility classes the Svelte components lean on. Tailwind v4 users +should import `theme.css` instead — it `@import`s `tokens.css` and adds a +native `@theme` entrypoint, so no `tailwind.config.mjs` is needed. + +The unbranded `--primary` / `--ring` default is **tanzanite** / **cobalt** +(canon: "Cobalt is the exceptional mineral for links/info only — do not use it +as `--primary`"). A brand overlay remaps `--primary` and `--ring`, and nothing +else: + +| Overlay | Primary mineral | +| -------------------- | ------------------- | +| `brand-bundu.css` | copper | +| `brand-nyuchi.css` | gold | +| `brand-mukoko.css` | tanzanite | +| `brand-shamwari.css` | sodalite | +| `brand-mzizi.css` | hematite (heritage) | + +### Outside the browser + +`tokens.json` ships the same values machine-readable, including every custom +property resolved to a literal hex per mode — for Expo and for Satori-based +OG-image / email / PDF generators, neither of which can resolve a CSS variable. -| Overlay | Primary mineral | -| ------------------ | --------------- | -| `brand-bundu.css` | terracotta | -| `brand-nyuchi.css` | gold | -| `brand-mukoko.css` | tanzanite | +```js +import tokens from "@nyuchi/ui/tokens.json" with { type: "json" }; +tokens.resolved.dark["--color-savanna"]; // "#e5c158" +``` ### Tailwind consumers diff --git a/packages/ui/package.json b/packages/ui/package.json index 7a24328..6648adb 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -5,14 +5,14 @@ "type": "module", "license": "MIT", "author": "Nyuchi Africa (Pvt) Ltd", - "homepage": "https://github.com/nyuchi/packages-ui#readme", + "homepage": "https://github.com/mukoko-dev/packages-ui#readme", "repository": { "type": "git", - "url": "git+https://github.com/nyuchi/packages-ui.git", + "url": "git+https://github.com/mukoko-dev/packages-ui.git", "directory": "packages/ui" }, "bugs": { - "url": "https://github.com/nyuchi/packages-ui/issues" + "url": "https://github.com/mukoko-dev/packages-ui/issues" }, "svelte": "./dist/index.js", "types": "./dist/index.d.ts", @@ -21,16 +21,24 @@ "types": "./dist/index.d.ts", "svelte": "./dist/index.js" }, + "./styles/tokens.css": "./styles/tokens.css", + "./styles/theme.css": "./styles/theme.css", "./styles/globals.css": "./styles/globals.css", "./styles/brand-bundu.css": "./styles/brand-bundu.css", "./styles/brand-nyuchi.css": "./styles/brand-nyuchi.css", "./styles/brand-mukoko.css": "./styles/brand-mukoko.css", - "./tailwind-preset": "./tailwind-preset.mjs" + "./tailwind-preset": "./tailwind-preset.mjs", + "./styles/brand-shamwari.css": "./styles/brand-shamwari.css", + "./styles/brand-mzizi.css": "./styles/brand-mzizi.css", + "./tokens.json": "./tokens.json", + "./tailwind-palette": "./tailwind-palette.mjs" }, "files": [ "dist", "styles", - "tailwind-preset.mjs" + "tailwind-preset.mjs", + "tailwind-palette.mjs", + "tokens.json" ], "scripts": { "build": "svelte-package", @@ -55,6 +63,7 @@ "nyuchi", "mzizi", "design-system", + "design-tokens", "svelte", "sveltekit", "ui" diff --git a/packages/ui/styles/brand-bundu.css b/packages/ui/styles/brand-bundu.css index 72ad1ab..2626ba0 100644 --- a/packages/ui/styles/brand-bundu.css +++ b/packages/ui/styles/brand-bundu.css @@ -1,12 +1,13 @@ -/** - * brand-bundu.css — Bundu Foundation overlay (terracotta primary). - * Import AFTER globals.css to remap the primary mineral. - */ +/* brand-bundu — bundu primary: copper (mineral). Canon (/v1/brand -> ecosystem) + maps bundu to copper — the ecosystem. GENERATED by + scripts/generate-tokens.mjs; edit the canon ecosystem table, not this file. + Import AFTER tokens.css (or globals.css / theme.css) so it wins. */ :root { - --primary: var(--color-terracotta); - --ring: var(--color-terracotta); + --primary: var(--color-copper); + --ring: var(--color-copper); } -.dark { - --primary: var(--color-terracotta); - --ring: var(--color-terracotta); +.dark, +[data-theme="dark"] { + --primary: var(--color-copper); + --ring: var(--color-copper); } diff --git a/packages/ui/styles/brand-mukoko.css b/packages/ui/styles/brand-mukoko.css index ccd4600..c00c0fb 100644 --- a/packages/ui/styles/brand-mukoko.css +++ b/packages/ui/styles/brand-mukoko.css @@ -1,12 +1,13 @@ -/** - * brand-mukoko.css — Mukoko overlay (tanzanite primary). - * Import AFTER globals.css to remap the primary mineral. - */ +/* brand-mukoko — mukoko primary: tanzanite (mineral). Canon (/v1/brand -> + ecosystem) maps mukoko to tanzanite — africa's super app. GENERATED by + scripts/generate-tokens.mjs; edit the canon ecosystem table, not this file. + Import AFTER tokens.css (or globals.css / theme.css) so it wins. */ :root { --primary: var(--color-tanzanite); --ring: var(--color-tanzanite); } -.dark { +.dark, +[data-theme="dark"] { --primary: var(--color-tanzanite); --ring: var(--color-tanzanite); } diff --git a/packages/ui/styles/brand-mzizi.css b/packages/ui/styles/brand-mzizi.css new file mode 100644 index 0000000..d9406f2 --- /dev/null +++ b/packages/ui/styles/brand-mzizi.css @@ -0,0 +1,15 @@ +/* brand-mzizi — mzizi primary: hematite (heritage tone). Canon carries no mzizi + -> mineral row; hematite is the nearest canon-grounded fit (symbolism: + foundation, endurance, the substrate). Pending confirmation — see + mukoko-dev/packages-ui PR. GENERATED by scripts/generate-tokens.mjs; edit the + canon ecosystem table, not this file. Import AFTER tokens.css (or globals.css + / theme.css) so it wins. */ +:root { + --primary: var(--color-hematite); + --ring: var(--color-hematite); +} +.dark, +[data-theme="dark"] { + --primary: var(--color-hematite); + --ring: var(--color-hematite); +} diff --git a/packages/ui/styles/brand-nyuchi.css b/packages/ui/styles/brand-nyuchi.css index 694d888..409b9eb 100644 --- a/packages/ui/styles/brand-nyuchi.css +++ b/packages/ui/styles/brand-nyuchi.css @@ -1,12 +1,13 @@ -/** - * brand-nyuchi.css — Nyuchi overlay (gold primary). - * Import AFTER globals.css to remap the primary mineral. - */ +/* brand-nyuchi — nyuchi primary: gold (mineral). Canon (/v1/brand -> ecosystem) + maps nyuchi to gold — infrastructure & enterprise. GENERATED by + scripts/generate-tokens.mjs; edit the canon ecosystem table, not this file. + Import AFTER tokens.css (or globals.css / theme.css) so it wins. */ :root { --primary: var(--color-gold); --ring: var(--color-gold); } -.dark { +.dark, +[data-theme="dark"] { --primary: var(--color-gold); --ring: var(--color-gold); } diff --git a/packages/ui/styles/brand-shamwari.css b/packages/ui/styles/brand-shamwari.css new file mode 100644 index 0000000..30287b9 --- /dev/null +++ b/packages/ui/styles/brand-shamwari.css @@ -0,0 +1,13 @@ +/* brand-shamwari — shamwari primary: sodalite (mineral). Canon (/v1/brand -> + ecosystem) maps shamwari to sodalite — sovereign ai companion. GENERATED by + scripts/generate-tokens.mjs; edit the canon ecosystem table, not this file. + Import AFTER tokens.css (or globals.css / theme.css) so it wins. */ +:root { + --primary: var(--color-sodalite); + --ring: var(--color-sodalite); +} +.dark, +[data-theme="dark"] { + --primary: var(--color-sodalite); + --ring: var(--color-sodalite); +} diff --git a/packages/ui/styles/globals.css b/packages/ui/styles/globals.css index 31a02ca..58bf505 100644 --- a/packages/ui/styles/globals.css +++ b/packages/ui/styles/globals.css @@ -1,161 +1,25 @@ -/** - * @nyuchi/ui — globals.css - * - * Framework-agnostic design tokens for the Nyuchi Design System — the - * seven African minerals (light + dark) plus semantic tokens, and the - * @layer component/utility classes the components lean on. - * - * Nyuchi's implementation of the Mzizi architecture (mzizi.dev). The - * canonical primary mineral is COBALT; brand overlays (brand-bundu.css → - * terracotta, brand-nyuchi.css → gold, brand-mukoko.css → tanzanite) - * remap --primary / --ring on top of this file. - * - * The :root / .dark custom properties resolve standalone. The @layer - * component/utility rules use Tailwind @apply and are picked up when the - * consumer processes this file through Tailwind alongside the shipped - * tailwind-preset.mjs. - */ +/* @nyuchi/ui — globals.css. -@layer base { - :root { - /* === Seven African Minerals — light mode (canonical from mzizi.dev) === */ - --color-cobalt: #0047ab; - --color-cobalt-container: #e3f2fd; - --color-cobalt-on-container: #002966; - - --color-tanzanite: #4b0082; - --color-tanzanite-container: #f3e5f5; - --color-tanzanite-on-container: #2e004d; - - --color-malachite: #004d40; - --color-malachite-container: #e0f2f1; - --color-malachite-on-container: #00332b; - - --color-gold: #5d4037; - --color-gold-container: #fff8e1; - --color-gold-on-container: #3e2723; - - --color-terracotta: #a0522d; - --color-terracotta-container: #f5e6d3; - --color-terracotta-on-container: #5d2906; - - --color-sodalite: #283593; - --color-sodalite-container: #e8eaf6; - --color-sodalite-on-container: #141a5c; - - --color-copper: #bf5a36; - --color-copper-container: #fbe4da; - --color-copper-on-container: #5c2410; - - /* === Semantic tokens — light (canonical from mzizi.dev) === */ - --background: #faf9f4; /* warm cream canvas */ - --canvas: #faf9f4; - --foreground: #1a1a17; /* near-black ink */ - --ink: #1a1a17; - - --card: #ffffff; - --card-foreground: #1a1a17; - --popover: #ffffff; - --popover-foreground: #1a1a17; - - /* Brand primary: cobalt is the canonical Design MCP primary. Each - brand overlay (brand-*.css) overrides --primary and --ring to its - own mineral. */ - --primary: var(--color-cobalt); - --primary-foreground: #ffffff; - - --secondary: #f4f2ec; - --secondary-foreground: #1a1a17; - - --muted: #f4f2ec; - --muted-foreground: #5d5c57; - - --accent: var(--color-cobalt-container); - --accent-foreground: var(--color-cobalt-on-container); - - --destructive: #b3261e; - --destructive-foreground: #ffffff; - --destructive-container: #fdeded; - - --border: #e7e5e0; /* warm stone */ - --input: #ffffff; - --ring: var(--color-cobalt); - - --success: var(--color-malachite); - --warning: #7a5c00; - --error: #b3261e; - --info: var(--color-cobalt); - - /* Touch targets */ - --touch-target: 56px; - --touch-target-sm: 48px; - } - - .dark { - --color-cobalt: #00b0ff; - --color-cobalt-container: #001f3f; - --color-cobalt-on-container: #b3e5fc; + The tokens themselves live in ./tokens.css, which is GENERATED from + tokens/canon.snapshot.json by scripts/generate-tokens.mjs and is + byte-identical to @bundu/ui's. Until 0.2.0 this file carried its own + hand-written copy of the seven minerals and the semantic tokens — a second + palette inside one repository, already drifted the same way @bundu/ui's had + (--background #faf9f4, --muted #f4f2ec, a cobalt --primary) and disagreeing + with @bundu/ui about Bundu's own brand mineral (terracotta here, copper + there; canon says copper). That copy is gone. - --color-tanzanite: #b388ff; - --color-tanzanite-container: #1a0033; - --color-tanzanite-on-container: #e1bee7; + What remains here is what is genuinely @nyuchi/ui's: the @layer + component/utility classes its Svelte components lean on. They use Tailwind + @apply, so this file has to be processed by Tailwind alongside the shipped + tailwind-preset.mjs. Sites that only want the custom properties should + @import ./tokens.css instead — it is pure CSS with no build step. - --color-malachite: #64ffda; - --color-malachite-container: #00251a; - --color-malachite-on-container: #a7ffeb; + Tailwind v4 consumers: @import ./theme.css. */ - --color-gold: #ffd740; - --color-gold-container: #332200; - --color-gold-on-container: #ffecb3; - - --color-terracotta: #e1b07e; - --color-terracotta-container: #3e2817; - --color-terracotta-on-container: #f5e6d3; - - --color-sodalite: #3d5afe; - --color-sodalite-container: #0d1442; - --color-sodalite-on-container: #c5cae9; - - --color-copper: #ff8a65; - --color-copper-container: #3a1a0e; - --color-copper-on-container: #ffd3c2; - - --background: #100f0e; - --canvas: #100f0e; - --foreground: #f0efe9; - --ink: #f0efe9; - - --card: #1a1917; - --card-foreground: #f0efe9; - --popover: #1a1917; - --popover-foreground: #f0efe9; - - --primary: var(--color-cobalt); - --primary-foreground: #1a1917; - - --secondary: #2a2927; - --secondary-foreground: #f0efe9; - - --muted: #2a2927; - --muted-foreground: #a8a6a0; - - --accent: var(--color-cobalt-container); - --accent-foreground: var(--color-cobalt-on-container); - - --destructive: #f2b8b5; - --destructive-foreground: #1a1917; - --destructive-container: #3e1818; - - --border: #2a2927; - --input: #100f0e; - --ring: var(--color-cobalt); - - --success: var(--color-malachite); - --warning: #ffd866; - --error: #f2b8b5; - --info: var(--color-cobalt); - } +@import "./tokens.css"; +@layer base { html { scroll-behavior: smooth; } diff --git a/packages/ui/styles/theme.css b/packages/ui/styles/theme.css new file mode 100644 index 0000000..8d61ac4 --- /dev/null +++ b/packages/ui/styles/theme.css @@ -0,0 +1,142 @@ +/* @nyuchi/ui — Tailwind v4 entrypoint. GENERATED FILE — DO NOT EDIT. + + Every consumer in the estate is on Tailwind v4. Until 0.2.0 this package + shipped only a v3-shape preset, which v4 can still load through `@config` + — that is how all four current consumers work today, and it keeps working. + This file is the native path for new consumers: + + @import "tailwindcss"; + @import "@bundu/ui/styles/theme.css"; + @import "@bundu/ui/styles/brand-mzizi.css"; + + and no tailwind.config.mjs at all. + + Palette families are declared with their light-mode value as the @theme + fallback; tokens.css's own :root / .dark rules are unlayered and therefore + win at runtime, which is what makes dark mode work. Tokens that alias a + bare ladder variable use `@theme inline` so no second definition is + emitted. */ + +@import "./tokens.css"; + +/* Palette — bg-cobalt, text-savanna, border-ember-ui, ... */ +@theme { + --color-cobalt: #0047ab; + --color-cobalt-container: #e3f2fd; + --color-cobalt-on-container: #002966; + --color-tanzanite: #4b0082; + --color-tanzanite-container: #f3e5f5; + --color-tanzanite-on-container: #2e004d; + --color-malachite: #004d40; + --color-malachite-container: #e0f2f1; + --color-malachite-on-container: #00332b; + --color-gold: #5d4037; + --color-gold-container: #fff8e1; + --color-gold-on-container: #3e2723; + --color-terracotta: #a0522d; + --color-terracotta-container: #f5e6d3; + --color-terracotta-on-container: #5d2906; + --color-sodalite: #283593; + --color-sodalite-container: #e8eaf6; + --color-sodalite-on-container: #141a5c; + --color-copper: #bf5a36; + --color-copper-container: #fbe4da; + --color-copper-on-container: #5c2410; + + --color-indigo: #4527a0; + --color-savanna: #8d6e1a; + --color-baobab: #4e342e; + --color-sunset: #d84315; + --color-river: #006064; + --color-hematite: #546e7a; + --color-kalahari: #c9b589; + + --color-ember: #843d20; + --color-ember-container: #ebdfdb; + --color-ember-on-container: #7a3115; + --color-ember-ui: #cd5f33; + --color-acacia: #4d5615; + --color-acacia-container: #e9ebdb; + --color-acacia-on-container: #48510e; + --color-acacia-ui: #7e8c22; + --color-fern: #175e17; + --color-fern-container: #dbebdb; + --color-fern-on-container: #0f570f; + --color-fern-ui: #259725; + --color-lagoon: #165b51; + --color-lagoon-container: #dbebe9; + --color-lagoon-on-container: #0e554b; + --color-lagoon-ui: #249383; + --color-storm: #284ca6; + --color-storm-container: #dbe0eb; + --color-storm-on-container: #1a409b; + --color-storm-ui: #577bd6; + --color-dusk: #742aad; + --color-dusk-container: #e4dbeb; + --color-dusk-on-container: #661b9e; + --color-dusk-ui: #a35dd8; + --color-protea: #932464; + --color-protea-container: #ebdbe4; + --color-protea-on-container: #841656; + --color-protea-ui: #d34998; + + /* Type, radius and easing — same scale the v3 preset ships. */ + --font-sans: "Noto Sans", system-ui, sans-serif; + --font-serif: "Noto Serif", Georgia, serif; + --font-mono: "JetBrains Mono", ui-monospace, monospace; + --radius-sm: 7px; + --radius-md: 12px; + --radius-lg: 14px; + --radius-xl: 17px; + --radius-2xl: 17px; + --radius-full: 9999px; + --radius-pill: 9999px; + --ease-soft: cubic-bezier(0.4, 0, 0.2, 1); +} + +/* Ladder, status and semantic tokens alias bare variables, so they are + inlined rather than redefined — no duplicate declaration, one source. */ +@theme inline { + --color-pitch: var(--pitch); + --color-void: var(--void); + --color-base: var(--base); + --color-surface: var(--surface); + --color-container: var(--container); + --color-overlay: var(--overlay); + --color-raised: var(--raised); + --color-scrim: var(--scrim); + --color-wash: var(--wash); + --color-surface-muted: var(--surface-muted); + + --color-syncing: var(--syncing); + --color-offline: var(--offline); + --color-neutral: var(--neutral); + + --color-background: var(--background); + --color-foreground: var(--foreground); + --color-canvas: var(--canvas); + --color-ink: var(--ink); + --color-card: var(--card); + --color-card-foreground: var(--card-foreground); + --color-popover: var(--popover); + --color-popover-foreground: var(--popover-foreground); + --color-primary: var(--primary); + --color-primary-foreground: var(--primary-foreground); + --color-secondary: var(--secondary); + --color-secondary-foreground: var(--secondary-foreground); + --color-muted: var(--muted); + --color-muted-foreground: var(--muted-foreground); + --color-accent: var(--accent); + --color-accent-foreground: var(--accent-foreground); + --color-destructive: var(--destructive); + --color-destructive-foreground: var(--destructive-foreground); + --color-border: var(--border); + --color-input: var(--input); + --color-ring: var(--ring); + --color-success: var(--success); + --color-warning: var(--warning); + --color-error: var(--error); + --color-info: var(--info); + --color-brand-accent: var(--brand-accent); + --color-brand-accent-foreground: var(--brand-accent-foreground); +} diff --git a/packages/ui/styles/tokens.css b/packages/ui/styles/tokens.css new file mode 100644 index 0000000..cc92012 --- /dev/null +++ b/packages/ui/styles/tokens.css @@ -0,0 +1,374 @@ +/* @nyuchi/ui — design tokens. GENERATED FILE — DO NOT EDIT. + + Source tokens/canon.snapshot.json (brand v4.0.31, 2026-07-13) + Generator scripts/generate-tokens.mjs (`pnpm tokens:build`) + Gate `pnpm tokens:check` fails if this file and the generator disagree + + All 21 colour families of the Mzizi design system under one --color-* + namespace: seven minerals, seven heritage tones, seven experimental tones. + Plus the nine-step surface ladder, the connectivity status trio, and the + semantic (shadcn) contract. + + The hex values do NOT live in a database. Mzizi holds no brand or primitive + token data in Supabase or anywhere else — the on-disk source of truth is + mzizi-dev/mzizi-registry -> lib/tokens/palette.source.ts (+ brand.source.ts), + projected over https://api.mzizi.dev/api/v1/brand and snapshotted into this + repo by `pnpm canon:fetch`. (0.1.1's header claimed "they are the DB's + source of truth". That was already wrong and is corrected here.) + + PURE custom properties — no @layer, no @apply, no Tailwind — so any site + can @import it. Dark mode under both conventions: the .dark class + (shadcn/Tailwind) and [data-theme="dark"] (Starlight and friends). + + Tailwind v4 users: import ./theme.css instead, which @imports this file and + adds the @theme entrypoint. The v3 preset (./tailwind-preset.mjs) keeps + working unchanged. */ + +:root { + /* === Seven African Minerals — the deep-earth + hand families */ + --color-cobalt: #0047ab; /* Primary blue, links, CTAs */ + --color-cobalt-container: #e3f2fd; + --color-cobalt-on-container: #002966; + + --color-tanzanite: #4b0082; /* Purple accent, brand/logo, social features */ + --color-tanzanite-container: #f3e5f5; + --color-tanzanite-on-container: #2e004d; + + --color-malachite: #004d40; /* Success states, positive actions */ + --color-malachite-container: #e0f2f1; + --color-malachite-on-container: #00332b; + + --color-gold: #5d4037; /* Achievements, rewards, highlights */ + --color-gold-container: #fff8e1; + --color-gold-on-container: #3e2723; + + --color-terracotta: #a0522d; /* Community features, warmth */ + --color-terracotta-container: #f5e6d3; + --color-terracotta-on-container: #5d2906; + + --color-sodalite: #283593; /* AI/Shamwari surfaces, deep-reasoning states */ + --color-sodalite-container: #e8eaf6; + --color-sodalite-on-container: #141a5c; + + --color-copper: #bf5a36; /* Bundu ecosystem identity, the commons */ + --color-copper-container: #fbe4da; + --color-copper-on-container: #5c2410; + + /* === Seven heritage tones — atmospheric anchors. Canon carries light/dark + only for these; there is no container step. */ + /* Twilight surfaces, deep atmosphere, mini-app moods */ + --color-indigo: #4527a0; + + --color-savanna: #8d6e1a; /* Warm grassland surfaces, daylight atmosphere */ + + /* Earthy surfaces, grounded atmosphere, bark tones */ + --color-baobab: #4e342e; + + --color-sunset: #d84315; /* Warm accent surfaces, golden-hour atmosphere */ + + --color-river: #006064; /* Cool surfaces, flowing atmosphere, water tones */ + + --color-hematite: #546e7a; /* Neutral anchor, mini-app surfaces, atmosphere */ + + /* Light anchor, warm backgrounds, mini-app surfaces */ + --color-kalahari: #c9b589; + + /* === Seven experimental tones — the computed heptagon (hues offset 17°, + prime saturations). `-ui` is the solved interactive step. */ + --color-ember: #843d20; + --color-ember-container: #ebdfdb; + --color-ember-on-container: #7a3115; + --color-ember-ui: #cd5f33; + + --color-acacia: #4d5615; + --color-acacia-container: #e9ebdb; + --color-acacia-on-container: #48510e; + --color-acacia-ui: #7e8c22; + + --color-fern: #175e17; + --color-fern-container: #dbebdb; + --color-fern-on-container: #0f570f; + --color-fern-ui: #259725; + + --color-lagoon: #165b51; + --color-lagoon-container: #dbebe9; + --color-lagoon-on-container: #0e554b; + --color-lagoon-ui: #249383; + + --color-storm: #284ca6; + --color-storm-container: #dbe0eb; + --color-storm-on-container: #1a409b; + --color-storm-ui: #577bd6; + + --color-dusk: #742aad; + --color-dusk-container: #e4dbeb; + --color-dusk-on-container: #661b9e; + --color-dusk-ui: #a35dd8; + + --color-protea: #932464; + --color-protea-container: #ebdbe4; + --color-protea-on-container: #841656; + --color-protea-ui: #d34998; + + /* === Nine-step surface ladder, shallow to deep. Prime steps P2..P17. */ + --pitch: #fafafa; /* Deepest surface — media wells, splash (prime step P2) */ + --void: #f8f8f7; /* App shell behind base (prime step P3) */ + --base: #f3f3f1; /* Page background — ambient base surface (prime step P5) */ + --surface: #eeeeec; /* Card / panel surface (prime step P7) */ + /* Neutral containers, grouped content (prime step P11) */ + --container: #e5e4e1; + --overlay: #e0dfdc; /* Overlays and dialogs (prime step P13) */ + /* Raised elements above overlay — menus, toasts (prime step P17) */ + --raised: #d6d5d1; + --scrim: rgba(0, 0, 0, 0.4); /* Semi-transparent backdrop behind overlays */ + /* Cover-colour page wash — surface tinted with the active brand accent (~7% + light / ~12% dark) */ + --wash: color-mix(in oklab, var(--surface) 93%, var(--brand-accent)); + /* Deepest fill — inset sections, metadata rows, maximum text contrast. Canon + calls this background "muted"; the shadcn semantic --muted already owns + that name below. */ + --surface-muted: #faf9f5; + + /* === Per-app brand mineral. --wash mixes it into --surface; the brand-*.css + overlays repoint it alongside --primary. */ + /* Per-app/per-context saturated brand mineral for accent fills and CTAs. + Defaults to tanzanite — the Mukoko/Nyuchi brand mineral. Swappable per app + or per-event/category (nhimbe). Distinct from the semantic --accent (a pale + container for hover/selected states). */ + --brand-accent: #4b0082; + --brand-accent-foreground: #ffffff; + + /* === Connectivity status trio. */ + --syncing: #1c5962; /* In-progress sync / pending state */ + --offline: #674c32; /* Offline / disconnected state */ + --neutral: #55514b; /* Neutral / inactive status, secondary data series */ + + /* === Semantic tokens — the shadcn/Tailwind contract. */ + /* Canon `base`. Was #faf9f4 / #100f0e before 0.2.0 — drift fixes 1 and 2. */ + --background: var(--base); + --canvas: var(--background); + --foreground: #1a1a17; + --ink: var(--foreground); + /* Dark was #1a1917 before 0.2.0 — drift fix 3. Light stays white: a marketing + card floats above the base by design. */ + --card: #ffffff; + --card-foreground: var(--foreground); + --popover: var(--card); + --popover-foreground: var(--card-foreground); + /* Drift fix 6. Canon: "Cobalt is the exceptional mineral for links/info only + — do not use it as --primary." Every brand-*.css overlay repoints this; + tanzanite is the unbranded default. */ + --primary: var(--color-tanzanite); + --primary-foreground: #ffffff; + /* Light was #f4f2ec before 0.2.0 — drift fix 5. */ + --secondary: var(--container); + --secondary-foreground: var(--foreground); + --muted: var(--container); /* Light was #f4f2ec before 0.2.0 — drift fix 4. */ + --muted-foreground: #5d5c57; + --accent: var(--color-cobalt-container); + --accent-foreground: var(--color-cobalt-on-container); + --destructive: #b3261e; + --destructive-foreground: #ffffff; + --destructive-container: #fdeded; + --border: #e7e5e0; + --input: #ffffff; + /* Focus ring color — cobalt. 2px width, 2px offset (see + brand_meta.accessibility). */ + --ring: var(--color-cobalt); + --success: var(--color-malachite); + --warning: #7a5c00; + --error: var(--destructive); + --info: var(--color-cobalt); /* Informational states */ + + /* === Legacy aliases. 0.1.1 namespaced the heritage tones --heritage-*; they + are --color-* now, like every other family. These keep the four existing + consumers compiling unchanged. Do not use in new code. */ + --heritage-indigo: var(--color-indigo); + --heritage-savanna: var(--color-savanna); + --heritage-baobab: var(--color-baobab); + --heritage-sunset: var(--color-sunset); + --heritage-river: var(--color-river); + --heritage-hematite: var(--color-hematite); + --heritage-kalahari: var(--color-kalahari); + + /* === Touch targets — accessibility mandate, 56px comfortable. */ + --touch-target: 56px; + --touch-target-sm: 48px; + + /* === Radius scale. Ecosystem numbers 7/12/14/17; buttons are always pill. */ + --radius-unit: 7px; + --radius-sm: 7px; + --radius-md: 12px; + --radius-lg: 14px; + --radius-xl: 17px; + --radius-2xl: 17px; + --radius-full: 9999px; +} + +.dark, +[data-theme="dark"] { + /* === Seven African Minerals — the deep-earth + hand families */ + --color-cobalt: #00b0ff; /* Primary blue, links, CTAs */ + --color-cobalt-container: #001f3f; + --color-cobalt-on-container: #b3e5fc; + + --color-tanzanite: #b388ff; /* Purple accent, brand/logo, social features */ + --color-tanzanite-container: #1a0033; + --color-tanzanite-on-container: #e1bee7; + + --color-malachite: #64ffda; /* Success states, positive actions */ + --color-malachite-container: #00251a; + --color-malachite-on-container: #a7ffeb; + + --color-gold: #ffd740; /* Achievements, rewards, highlights */ + --color-gold-container: #332200; + --color-gold-on-container: #ffecb3; + + --color-terracotta: #e1b07e; /* Community features, warmth */ + --color-terracotta-container: #3e2817; + --color-terracotta-on-container: #f5e6d3; + + --color-sodalite: #3d5afe; /* AI/Shamwari surfaces, deep-reasoning states */ + --color-sodalite-container: #0d1442; + --color-sodalite-on-container: #c5cae9; + + --color-copper: #ff8a65; /* Bundu ecosystem identity, the commons */ + --color-copper-container: #3a1a0e; + --color-copper-on-container: #ffd3c2; + + /* === Seven heritage tones — atmospheric anchors. Canon carries light/dark + only for these; there is no container step. */ + /* Twilight surfaces, deep atmosphere, mini-app moods */ + --color-indigo: #7986cb; + + --color-savanna: #e5c158; /* Warm grassland surfaces, daylight atmosphere */ + + /* Earthy surfaces, grounded atmosphere, bark tones */ + --color-baobab: #a1887f; + + --color-sunset: #ff7043; /* Warm accent surfaces, golden-hour atmosphere */ + + --color-river: #4dd0e1; /* Cool surfaces, flowing atmosphere, water tones */ + + --color-hematite: #90a4ae; /* Neutral anchor, mini-app surfaces, atmosphere */ + + /* Light anchor, warm backgrounds, mini-app surfaces */ + --color-kalahari: #e8d9b5; + + /* === Seven experimental tones — the computed heptagon (hues offset 17°, + prime saturations). `-ui` is the solved interactive step. */ + --color-ember: #da8766; + --color-ember-container: #352721; + --color-ember-on-container: #eba68a; + --color-ember-ui: #bb562d; + + --color-acacia: #93a528; + --color-acacia-container: #333521; + --color-acacia-on-container: #b6ce23; + --color-acacia-ui: #768420; + + --color-fern: #2cb42b; + --color-fern-container: #213521; + --color-fern-on-container: #28db28; + --color-fern-ui: #228d22; + + --color-lagoon: #2aae9b; + --color-lagoon-container: #213532; + --color-lagoon-on-container: #24d6bc; + --color-lagoon-ui: #218a7a; + + --color-storm: #7e9be0; + --color-storm-container: #212735; + --color-storm-on-container: #99b2ee; + --color-storm-ui: #426cd1; + + --color-dusk: #ba87e2; + --color-dusk-container: #2d2135; + --color-dusk-on-container: #cc9fef; + --color-dusk-ui: #9749d3; + + --color-protea: #df7bb4; + --color-protea-container: #35212d; + --color-protea-on-container: #ed98c9; + --color-protea-ui: #ca3188; + + /* === Nine-step surface ladder, shallow to deep. Prime steps P2..P17. */ + --pitch: #050505; /* Deepest surface — media wells, splash (prime step P2) */ + --void: #080807; /* App shell behind base (prime step P3) */ + --base: #0e0d0c; /* Page background — ambient base surface (prime step P5) */ + --surface: #131211; /* Card / panel surface (prime step P7) */ + /* Neutral containers, grouped content (prime step P11) */ + --container: #1e1d1a; + --overlay: #23221f; /* Overlays and dialogs (prime step P13) */ + /* Raised elements above overlay — menus, toasts (prime step P17) */ + --raised: #2e2c29; + --scrim: rgba(0, 0, 0, 0.6); /* Semi-transparent backdrop behind overlays */ + /* Cover-colour page wash — surface tinted with the active brand accent (~7% + light / ~12% dark) */ + --wash: color-mix(in oklab, var(--surface) 88%, var(--brand-accent)); + /* Deepest fill — inset sections, metadata rows, maximum text contrast. Canon + calls this background "muted"; the shadcn semantic --muted already owns + that name below. */ + --surface-muted: #050504; + + /* === Per-app brand mineral. --wash mixes it into --surface; the brand-*.css + overlays repoint it alongside --primary. */ + /* Per-app/per-context saturated brand mineral for accent fills and CTAs. + Defaults to tanzanite — the Mukoko/Nyuchi brand mineral. Swappable per app + or per-event/category (nhimbe). Distinct from the semantic --accent (a pale + container for hover/selected states). */ + --brand-accent: #b388ff; + --brand-accent-foreground: #0e0d0c; + + /* === Connectivity status trio. */ + --syncing: #36abba; /* In-progress sync / pending state */ + --offline: #ba9570; /* Offline / disconnected state */ + --neutral: #a09c93; /* Neutral / inactive status, secondary data series */ + + /* === Semantic tokens — the shadcn/Tailwind contract. */ + /* Canon `base`. Was #faf9f4 / #100f0e before 0.2.0 — drift fixes 1 and 2. */ + --background: var(--base); + --canvas: var(--background); + --foreground: #f0efe9; + --ink: var(--foreground); + /* Dark was #1a1917 before 0.2.0 — drift fix 3. Light stays white: a marketing + card floats above the base by design. */ + --card: var(--surface); + --card-foreground: var(--foreground); + --popover: var(--card); + --popover-foreground: var(--card-foreground); + /* Drift fix 6. Canon: "Cobalt is the exceptional mineral for links/info only + — do not use it as --primary." Every brand-*.css overlay repoints this; + tanzanite is the unbranded default. */ + --primary: var(--color-tanzanite); + --primary-foreground: #1a1917; + --secondary: #2a2927; /* Light was #f4f2ec before 0.2.0 — drift fix 5. */ + --secondary-foreground: var(--foreground); + --muted: #2a2927; /* Light was #f4f2ec before 0.2.0 — drift fix 4. */ + --muted-foreground: #a8a6a0; + --accent: var(--color-cobalt-container); + --accent-foreground: var(--color-cobalt-on-container); + --destructive: #f2b8b5; + --destructive-foreground: #1a1917; + --destructive-container: #3e1818; + --border: #2a2927; + --input: #100f0e; + /* Focus ring color — cobalt. 2px width, 2px offset (see + brand_meta.accessibility). */ + --ring: var(--color-cobalt); + --success: var(--color-malachite); + --warning: #ffd866; + --error: var(--destructive); + --info: var(--color-cobalt); /* Informational states */ + + /* === Legacy aliases. 0.1.1 namespaced the heritage tones --heritage-*; they + are --color-* now, like every other family. These keep the four existing + consumers compiling unchanged. Do not use in new code. */ + --heritage-indigo: var(--color-indigo); + --heritage-savanna: var(--color-savanna); + --heritage-baobab: var(--color-baobab); + --heritage-sunset: var(--color-sunset); + --heritage-river: var(--color-river); + --heritage-hematite: var(--color-hematite); + --heritage-kalahari: var(--color-kalahari); +} diff --git a/packages/ui/tailwind-palette.mjs b/packages/ui/tailwind-palette.mjs new file mode 100644 index 0000000..b7ccecf --- /dev/null +++ b/packages/ui/tailwind-palette.mjs @@ -0,0 +1,192 @@ +/* @nyuchi/ui — Tailwind v3 colour map. GENERATED FILE — DO NOT EDIT. + + Generator scripts/generate-tokens.mjs (`pnpm tokens:build`) + Gate `pnpm tokens:check` + + Spread into `theme.extend.colors` by ./tailwind-preset.mjs. Every value + is a var() into styles/tokens.css — there is no hex in this file, so + light/dark and the brand-*.css overlays all keep working. */ + +export const colors = { + /* Seven African Minerals. */ + cobalt: { + DEFAULT: "var(--color-cobalt)", + container: "var(--color-cobalt-container)", + "on-container": "var(--color-cobalt-on-container)", + }, + tanzanite: { + DEFAULT: "var(--color-tanzanite)", + container: "var(--color-tanzanite-container)", + "on-container": "var(--color-tanzanite-on-container)", + }, + malachite: { + DEFAULT: "var(--color-malachite)", + container: "var(--color-malachite-container)", + "on-container": "var(--color-malachite-on-container)", + }, + gold: { + DEFAULT: "var(--color-gold)", + container: "var(--color-gold-container)", + "on-container": "var(--color-gold-on-container)", + }, + terracotta: { + DEFAULT: "var(--color-terracotta)", + container: "var(--color-terracotta-container)", + "on-container": "var(--color-terracotta-on-container)", + }, + sodalite: { + DEFAULT: "var(--color-sodalite)", + container: "var(--color-sodalite-container)", + "on-container": "var(--color-sodalite-on-container)", + }, + copper: { + DEFAULT: "var(--color-copper)", + container: "var(--color-copper-container)", + "on-container": "var(--color-copper-on-container)", + }, + + /* Seven heritage tones. NOTE: `indigo` shadows Tailwind's built-in + indigo scale — `bg-indigo-500` becomes unavailable, `bg-indigo` is + the heritage tone. The estate forbids default-palette utilities + anyway (bundu-labs/marketing scripts/check-token-consistency.mjs). */ + indigo: "var(--color-indigo)", + savanna: "var(--color-savanna)", + baobab: "var(--color-baobab)", + sunset: "var(--color-sunset)", + river: "var(--color-river)", + hematite: "var(--color-hematite)", + kalahari: "var(--color-kalahari)", + + /* Seven experimental tones. */ + ember: { + DEFAULT: "var(--color-ember)", + container: "var(--color-ember-container)", + "on-container": "var(--color-ember-on-container)", + ui: "var(--color-ember-ui)", + }, + acacia: { + DEFAULT: "var(--color-acacia)", + container: "var(--color-acacia-container)", + "on-container": "var(--color-acacia-on-container)", + ui: "var(--color-acacia-ui)", + }, + fern: { + DEFAULT: "var(--color-fern)", + container: "var(--color-fern-container)", + "on-container": "var(--color-fern-on-container)", + ui: "var(--color-fern-ui)", + }, + lagoon: { + DEFAULT: "var(--color-lagoon)", + container: "var(--color-lagoon-container)", + "on-container": "var(--color-lagoon-on-container)", + ui: "var(--color-lagoon-ui)", + }, + storm: { + DEFAULT: "var(--color-storm)", + container: "var(--color-storm-container)", + "on-container": "var(--color-storm-on-container)", + ui: "var(--color-storm-ui)", + }, + dusk: { + DEFAULT: "var(--color-dusk)", + container: "var(--color-dusk-container)", + "on-container": "var(--color-dusk-on-container)", + ui: "var(--color-dusk-ui)", + }, + protea: { + DEFAULT: "var(--color-protea)", + container: "var(--color-protea-container)", + "on-container": "var(--color-protea-on-container)", + ui: "var(--color-protea-ui)", + }, + + /* Nine-step surface ladder. */ + pitch: "var(--pitch)", + void: "var(--void)", + base: "var(--base)", + surface: "var(--surface)", + container: "var(--container)", + overlay: "var(--overlay)", + raised: "var(--raised)", + scrim: "var(--scrim)", + wash: "var(--wash)", + "surface-muted": "var(--surface-muted)", + + /* Connectivity status trio. */ + syncing: "var(--syncing)", + offline: "var(--offline)", + neutral: "var(--neutral)", + + /* Semantic tokens — the shadcn/Tailwind contract. */ + background: "var(--background)", + foreground: "var(--foreground)", + canvas: "var(--canvas)", + ink: "var(--ink)", + primary: { + DEFAULT: "var(--primary)", + foreground: "var(--primary-foreground)", + }, + secondary: { + DEFAULT: "var(--secondary)", + foreground: "var(--secondary-foreground)", + }, + muted: { + DEFAULT: "var(--muted)", + foreground: "var(--muted-foreground)", + }, + accent: { + DEFAULT: "var(--accent)", + foreground: "var(--accent-foreground)", + }, + destructive: { + DEFAULT: "var(--destructive)", + foreground: "var(--destructive-foreground)", + }, + card: { + DEFAULT: "var(--card)", + foreground: "var(--card-foreground)", + }, + popover: { + DEFAULT: "var(--popover)", + foreground: "var(--popover-foreground)", + }, + border: "var(--border)", + input: "var(--input)", + ring: "var(--ring)", + success: "var(--success)", + warning: "var(--warning)", + error: "var(--error)", + info: "var(--info)", + "brand-accent": { + DEFAULT: "var(--brand-accent)", + foreground: "var(--brand-accent-foreground)", + }, +}; + +/* The 21 colour family names, in canon order. Used for the v3 preset's + safelist: mineral utilities are often composed from data + (`bg-${mineral}`), which Tailwind's scanner cannot see. */ +export const families = [ + "cobalt", + "tanzanite", + "malachite", + "gold", + "terracotta", + "sodalite", + "copper", + "indigo", + "savanna", + "baobab", + "sunset", + "river", + "hematite", + "kalahari", + "ember", + "acacia", + "fern", + "lagoon", + "storm", + "dusk", + "protea", +]; diff --git a/packages/ui/tailwind-preset.mjs b/packages/ui/tailwind-preset.mjs index 848d665..280e86e 100644 --- a/packages/ui/tailwind-preset.mjs +++ b/packages/ui/tailwind-preset.mjs @@ -13,98 +13,32 @@ * presets: [nyuchiPreset], * content: ["./src/**\/*.{svelte,ts,html}"], * }; + * + * The colour map is GENERATED — ./tailwind-palette.mjs, built by + * scripts/generate-tokens.mjs from tokens/canon.snapshot.json. It carries + * all 21 colour families, the nine-step surface ladder and the status trio, + * not just the seven minerals. Everything else here is hand-authored + * package policy. Regenerate with `pnpm tokens:build`. */ +import { colors, families } from "./tailwind-palette.mjs"; + /** @type {import('tailwindcss').Config} */ export default { darkMode: "class", + // Mineral utilities are often composed from data (`bg-${mineral}`), which + // Tailwind's scanner cannot see. Built from the generated colour map so a + // new canon family is safelisted the moment it lands. safelist: [ { - pattern: - /^(bg|text|border|ring)-(cobalt|tanzanite|malachite|gold|terracotta|sodalite|copper)(-container|-on-container)?$/, + pattern: new RegExp( + `^(bg|text|border|ring)-(${families.join("|")})(-container|-on-container|-ui)?$`, + ), }, ], theme: { extend: { - colors: { - // === Seven African Minerals === - cobalt: { - DEFAULT: "var(--color-cobalt)", - container: "var(--color-cobalt-container)", - "on-container": "var(--color-cobalt-on-container)", - }, - tanzanite: { - DEFAULT: "var(--color-tanzanite)", - container: "var(--color-tanzanite-container)", - "on-container": "var(--color-tanzanite-on-container)", - }, - malachite: { - DEFAULT: "var(--color-malachite)", - container: "var(--color-malachite-container)", - "on-container": "var(--color-malachite-on-container)", - }, - gold: { - DEFAULT: "var(--color-gold)", - container: "var(--color-gold-container)", - "on-container": "var(--color-gold-on-container)", - }, - terracotta: { - DEFAULT: "var(--color-terracotta)", - container: "var(--color-terracotta-container)", - "on-container": "var(--color-terracotta-on-container)", - }, - sodalite: { - DEFAULT: "var(--color-sodalite)", - container: "var(--color-sodalite-container)", - "on-container": "var(--color-sodalite-on-container)", - }, - copper: { - DEFAULT: "var(--color-copper)", - container: "var(--color-copper-container)", - "on-container": "var(--color-copper-on-container)", - }, - - // === Semantic tokens === - background: "var(--background)", - foreground: "var(--foreground)", - canvas: "var(--canvas)", - ink: "var(--ink)", - primary: { - DEFAULT: "var(--primary)", - foreground: "var(--primary-foreground)", - }, - secondary: { - DEFAULT: "var(--secondary)", - foreground: "var(--secondary-foreground)", - }, - muted: { - DEFAULT: "var(--muted)", - foreground: "var(--muted-foreground)", - }, - accent: { - DEFAULT: "var(--accent)", - foreground: "var(--accent-foreground)", - }, - destructive: { - DEFAULT: "var(--destructive)", - foreground: "var(--destructive-foreground)", - }, - card: { - DEFAULT: "var(--card)", - foreground: "var(--card-foreground)", - }, - popover: { - DEFAULT: "var(--popover)", - foreground: "var(--popover-foreground)", - }, - border: "var(--border)", - input: "var(--input)", - ring: "var(--ring)", - success: "var(--success)", - warning: "var(--warning)", - error: "var(--error)", - info: "var(--info)", - }, + colors, fontFamily: { sans: ['"Noto Sans"', "system-ui", "sans-serif"], serif: ['"Noto Serif"', "Georgia", "serif"], diff --git a/packages/ui/tokens.json b/packages/ui/tokens.json new file mode 100644 index 0000000..b883fab --- /dev/null +++ b/packages/ui/tokens.json @@ -0,0 +1,567 @@ +{ + "$comment": "GENERATED by scripts/generate-tokens.mjs from tokens/canon.snapshot.json. Do not edit. `pnpm tokens:check` fails if you do.", + "package": "@nyuchi/ui", + "canon": { + "brandSource": "https://api.mzizi.dev/api/v1/brand", + "paletteSource": "https://raw.githubusercontent.com/mzizi-dev/mzizi-registry/main/lib/tokens/palette.source.ts", + "version": "4.0.31", + "lastUpdated": "2026-07-13", + "onDiskSourceOfTruth": "mzizi-dev/mzizi-registry -> lib/tokens/palette.source.ts + brand.source.ts" + }, + "color": { + "minerals": { + "cobalt": { + "light": "#0047ab", + "dark": "#00b0ff", + "cssVar": "--color-cobalt", + "containerLight": "#e3f2fd", + "containerDark": "#001f3f", + "onContainerLight": "#002966", + "onContainerDark": "#b3e5fc", + "origin": "Katanga (DRC) and Zambian Copperbelt", + "symbolism": "Digital future, trust, knowledge", + "usage": "Primary blue, links, CTAs" + }, + "tanzanite": { + "light": "#4b0082", + "dark": "#b388ff", + "cssVar": "--color-tanzanite", + "containerLight": "#f3e5f5", + "containerDark": "#1a0033", + "onContainerLight": "#2e004d", + "onContainerDark": "#e1bee7", + "origin": "Merelani Hills, Tanzania", + "symbolism": "Premium, creativity, connection", + "usage": "Purple accent, brand/logo, social features" + }, + "malachite": { + "light": "#004d40", + "dark": "#64ffda", + "cssVar": "--color-malachite", + "containerLight": "#e0f2f1", + "containerDark": "#00251a", + "onContainerLight": "#00332b", + "onContainerDark": "#a7ffeb", + "origin": "Congo Copper Belt", + "symbolism": "Growth, nature, success", + "usage": "Success states, positive actions" + }, + "gold": { + "light": "#5d4037", + "dark": "#ffd740", + "cssVar": "--color-gold", + "containerLight": "#fff8e1", + "containerDark": "#332200", + "onContainerLight": "#3e2723", + "onContainerDark": "#ffecb3", + "origin": "Ghana, South Africa, Mali", + "symbolism": "Honey, rewards, warmth", + "usage": "Achievements, rewards, highlights" + }, + "terracotta": { + "light": "#a0522d", + "dark": "#e1b07e", + "cssVar": "--color-terracotta", + "containerLight": "#f5e6d3", + "containerDark": "#3e2817", + "onContainerLight": "#5d2906", + "onContainerDark": "#f5e6d3", + "origin": "Pan-African Sahel", + "symbolism": "Earth, community, grounding", + "usage": "Community features, warmth" + }, + "sodalite": { + "light": "#283593", + "dark": "#3d5afe", + "cssVar": "--color-sodalite", + "containerLight": "#e8eaf6", + "containerDark": "#0d1442", + "onContainerLight": "#141a5c", + "onContainerDark": "#c5cae9", + "origin": "Kunene River, Namibia & South Africa", + "symbolism": "Intelligence, depth, reasoning", + "usage": "AI/Shamwari surfaces, deep-reasoning states" + }, + "copper": { + "light": "#bf5a36", + "dark": "#ff8a65", + "cssVar": "--color-copper", + "containerLight": "#fbe4da", + "containerDark": "#3a1a0e", + "onContainerLight": "#5c2410", + "onContainerDark": "#ffd3c2", + "origin": "Central African Copperbelt, Zambia & DRC", + "symbolism": "Connection, foundation, stewardship", + "usage": "Bundu ecosystem identity, the commons" + } + }, + "heritage": { + "indigo": { + "light": "#4527a0", + "dark": "#7986cb", + "cssVar": "--color-indigo", + "origin": "Indigofera, West Africa textile tradition", + "symbolism": "Dusk, depth, the dyer's craft", + "usage": "Twilight surfaces, deep atmosphere, mini-app moods" + }, + "savanna": { + "light": "#8d6e1a", + "dark": "#e5c158", + "cssVar": "--color-savanna", + "origin": "Sub-Saharan grasslands", + "symbolism": "Sun-dried grass, open land, the dry season", + "usage": "Warm grassland surfaces, daylight atmosphere" + }, + "baobab": { + "light": "#4e342e", + "dark": "#a1887f", + "cssVar": "--color-baobab", + "origin": "Adansonia, across the African continent", + "symbolism": "The tree of life, age, shelter", + "usage": "Earthy surfaces, grounded atmosphere, bark tones" + }, + "sunset": { + "light": "#d84315", + "dark": "#ff7043", + "cssVar": "--color-sunset", + "origin": "The African horizon at dusk", + "symbolism": "Day's end, warmth, the gathering hour", + "usage": "Warm accent surfaces, golden-hour atmosphere" + }, + "river": { + "light": "#006064", + "dark": "#4dd0e1", + "cssVar": "--color-river", + "origin": "The great African rivers — Zambezi, Nile, Congo", + "symbolism": "Flow, life, the journey", + "usage": "Cool surfaces, flowing atmosphere, water tones" + }, + "hematite": { + "light": "#546e7a", + "dark": "#90a4ae", + "cssVar": "--color-hematite", + "origin": "Sishen & Thabazimbi, South Africa", + "symbolism": "Foundation, endurance, the substrate", + "usage": "Neutral anchor, mini-app surfaces, atmosphere" + }, + "kalahari": { + "light": "#c9b589", + "dark": "#e8d9b5", + "cssVar": "--color-kalahari", + "origin": "Kalahari & Namib, Southern Africa", + "symbolism": "Openness, space, the light pole", + "usage": "Light anchor, warm backgrounds, mini-app surfaces" + } + }, + "experimental": { + "ember": { + "light": "#843d20", + "dark": "#da8766", + "cssVar": "--color-ember", + "containerLight": "#ebdfdb", + "containerDark": "#352721", + "onContainerLight": "#7a3115", + "onContainerDark": "#eba68a", + "uiLight": "#cd5f33", + "uiDark": "#bb562d", + "heptagonIndex": 0 + }, + "acacia": { + "light": "#4d5615", + "dark": "#93a528", + "cssVar": "--color-acacia", + "containerLight": "#e9ebdb", + "containerDark": "#333521", + "onContainerLight": "#48510e", + "onContainerDark": "#b6ce23", + "uiLight": "#7e8c22", + "uiDark": "#768420", + "heptagonIndex": 1 + }, + "fern": { + "light": "#175e17", + "dark": "#2cb42b", + "cssVar": "--color-fern", + "containerLight": "#dbebdb", + "containerDark": "#213521", + "onContainerLight": "#0f570f", + "onContainerDark": "#28db28", + "uiLight": "#259725", + "uiDark": "#228d22", + "heptagonIndex": 2 + }, + "lagoon": { + "light": "#165b51", + "dark": "#2aae9b", + "cssVar": "--color-lagoon", + "containerLight": "#dbebe9", + "containerDark": "#213532", + "onContainerLight": "#0e554b", + "onContainerDark": "#24d6bc", + "uiLight": "#249383", + "uiDark": "#218a7a", + "heptagonIndex": 3 + }, + "storm": { + "light": "#284ca6", + "dark": "#7e9be0", + "cssVar": "--color-storm", + "containerLight": "#dbe0eb", + "containerDark": "#212735", + "onContainerLight": "#1a409b", + "onContainerDark": "#99b2ee", + "uiLight": "#577bd6", + "uiDark": "#426cd1", + "heptagonIndex": 4 + }, + "dusk": { + "light": "#742aad", + "dark": "#ba87e2", + "cssVar": "--color-dusk", + "containerLight": "#e4dbeb", + "containerDark": "#2d2135", + "onContainerLight": "#661b9e", + "onContainerDark": "#cc9fef", + "uiLight": "#a35dd8", + "uiDark": "#9749d3", + "heptagonIndex": 5 + }, + "protea": { + "light": "#932464", + "dark": "#df7bb4", + "cssVar": "--color-protea", + "containerLight": "#ebdbe4", + "containerDark": "#35212d", + "onContainerLight": "#841656", + "onContainerDark": "#ed98c9", + "uiLight": "#d34998", + "uiDark": "#ca3188", + "heptagonIndex": 6 + } + } + }, + "surface": { + "pitch": { + "light": "#fafafa", + "dark": "#050505", + "cssVar": "--pitch", + "usage": "Deepest surface — media wells, splash (prime step P2)" + }, + "void": { + "light": "#f8f8f7", + "dark": "#080807", + "cssVar": "--void", + "usage": "App shell behind base (prime step P3)" + }, + "base": { + "light": "#f3f3f1", + "dark": "#0e0d0c", + "cssVar": "--base", + "usage": "Page background — ambient base surface (prime step P5)" + }, + "surface": { + "light": "#eeeeec", + "dark": "#131211", + "cssVar": "--surface", + "usage": "Card / panel surface (prime step P7)" + }, + "container": { + "light": "#e5e4e1", + "dark": "#1e1d1a", + "cssVar": "--container", + "usage": "Neutral containers, grouped content (prime step P11)" + }, + "overlay": { + "light": "#e0dfdc", + "dark": "#23221f", + "cssVar": "--overlay", + "usage": "Overlays and dialogs (prime step P13)" + }, + "raised": { + "light": "#d6d5d1", + "dark": "#2e2c29", + "cssVar": "--raised", + "usage": "Raised elements above overlay — menus, toasts (prime step P17)" + }, + "scrim": { + "light": "rgba(0, 0, 0, 0.4)", + "dark": "rgba(0, 0, 0, 0.6)", + "cssVar": "--scrim", + "usage": "Semi-transparent backdrop behind overlays" + }, + "wash": { + "light": "color-mix(in oklab, var(--surface) 93%, var(--brand-accent))", + "dark": "color-mix(in oklab, var(--surface) 88%, var(--brand-accent))", + "cssVar": "--wash", + "usage": "Cover-colour page wash — surface tinted with the active brand accent (~7% light / ~12% dark)" + }, + "muted": { + "light": "#faf9f5", + "dark": "#050504", + "cssVar": "--surface-muted", + "usage": "Deepest fill — inset sections, metadata rows, maximum text contrast" + } + }, + "status": { + "syncing": { + "light": "#1c5962", + "dark": "#36abba", + "cssVar": "--syncing", + "usage": "In-progress sync / pending state" + }, + "offline": { + "light": "#674c32", + "dark": "#ba9570", + "cssVar": "--offline", + "usage": "Offline / disconnected state" + }, + "neutral": { + "light": "#55514b", + "dark": "#a09c93", + "cssVar": "--neutral", + "usage": "Neutral / inactive status, secondary data series" + } + }, + "radius": { + "2xl": "17px", + "base": "14px", + "full": "9999px", + "lg": "14px", + "md": "12px", + "sm": "7px", + "system": "Ecosystem numbers: 7, 12, 14, 17. Buttons are always pill (rounded-full).", + "xl": "17px" + }, + "resolved": { + "light": { + "--color-cobalt": "#0047ab", + "--color-cobalt-container": "#e3f2fd", + "--color-cobalt-on-container": "#002966", + "--color-tanzanite": "#4b0082", + "--color-tanzanite-container": "#f3e5f5", + "--color-tanzanite-on-container": "#2e004d", + "--color-malachite": "#004d40", + "--color-malachite-container": "#e0f2f1", + "--color-malachite-on-container": "#00332b", + "--color-gold": "#5d4037", + "--color-gold-container": "#fff8e1", + "--color-gold-on-container": "#3e2723", + "--color-terracotta": "#a0522d", + "--color-terracotta-container": "#f5e6d3", + "--color-terracotta-on-container": "#5d2906", + "--color-sodalite": "#283593", + "--color-sodalite-container": "#e8eaf6", + "--color-sodalite-on-container": "#141a5c", + "--color-copper": "#bf5a36", + "--color-copper-container": "#fbe4da", + "--color-copper-on-container": "#5c2410", + "--color-indigo": "#4527a0", + "--color-savanna": "#8d6e1a", + "--color-baobab": "#4e342e", + "--color-sunset": "#d84315", + "--color-river": "#006064", + "--color-hematite": "#546e7a", + "--color-kalahari": "#c9b589", + "--color-ember": "#843d20", + "--color-ember-container": "#ebdfdb", + "--color-ember-on-container": "#7a3115", + "--color-ember-ui": "#cd5f33", + "--color-acacia": "#4d5615", + "--color-acacia-container": "#e9ebdb", + "--color-acacia-on-container": "#48510e", + "--color-acacia-ui": "#7e8c22", + "--color-fern": "#175e17", + "--color-fern-container": "#dbebdb", + "--color-fern-on-container": "#0f570f", + "--color-fern-ui": "#259725", + "--color-lagoon": "#165b51", + "--color-lagoon-container": "#dbebe9", + "--color-lagoon-on-container": "#0e554b", + "--color-lagoon-ui": "#249383", + "--color-storm": "#284ca6", + "--color-storm-container": "#dbe0eb", + "--color-storm-on-container": "#1a409b", + "--color-storm-ui": "#577bd6", + "--color-dusk": "#742aad", + "--color-dusk-container": "#e4dbeb", + "--color-dusk-on-container": "#661b9e", + "--color-dusk-ui": "#a35dd8", + "--color-protea": "#932464", + "--color-protea-container": "#ebdbe4", + "--color-protea-on-container": "#841656", + "--color-protea-ui": "#d34998", + "--pitch": "#fafafa", + "--void": "#f8f8f7", + "--base": "#f3f3f1", + "--surface": "#eeeeec", + "--container": "#e5e4e1", + "--overlay": "#e0dfdc", + "--raised": "#d6d5d1", + "--scrim": "rgba(0, 0, 0, 0.4)", + "--wash": "color-mix(in oklab, var(--surface) 93%, var(--brand-accent))", + "--surface-muted": "#faf9f5", + "--brand-accent": "#4b0082", + "--brand-accent-foreground": "#ffffff", + "--syncing": "#1c5962", + "--offline": "#674c32", + "--neutral": "#55514b", + "--background": "#f3f3f1", + "--canvas": "#f3f3f1", + "--foreground": "#1a1a17", + "--ink": "#1a1a17", + "--card": "#ffffff", + "--card-foreground": "#1a1a17", + "--popover": "#ffffff", + "--popover-foreground": "#1a1a17", + "--primary": "#4b0082", + "--primary-foreground": "#ffffff", + "--secondary": "#e5e4e1", + "--secondary-foreground": "#1a1a17", + "--muted": "#e5e4e1", + "--muted-foreground": "#5d5c57", + "--accent": "#e3f2fd", + "--accent-foreground": "#002966", + "--destructive": "#b3261e", + "--destructive-foreground": "#ffffff", + "--destructive-container": "#fdeded", + "--border": "#e7e5e0", + "--input": "#ffffff", + "--ring": "#0047ab", + "--success": "#004d40", + "--warning": "#7a5c00", + "--error": "#b3261e", + "--info": "#0047ab", + "--heritage-indigo": "#4527a0", + "--heritage-savanna": "#8d6e1a", + "--heritage-baobab": "#4e342e", + "--heritage-sunset": "#d84315", + "--heritage-river": "#006064", + "--heritage-hematite": "#546e7a", + "--heritage-kalahari": "#c9b589", + "--touch-target": "56px", + "--touch-target-sm": "48px", + "--radius-unit": "7px", + "--radius-sm": "7px", + "--radius-md": "12px", + "--radius-lg": "14px", + "--radius-xl": "17px", + "--radius-2xl": "17px", + "--radius-full": "9999px" + }, + "dark": { + "--color-cobalt": "#00b0ff", + "--color-cobalt-container": "#001f3f", + "--color-cobalt-on-container": "#b3e5fc", + "--color-tanzanite": "#b388ff", + "--color-tanzanite-container": "#1a0033", + "--color-tanzanite-on-container": "#e1bee7", + "--color-malachite": "#64ffda", + "--color-malachite-container": "#00251a", + "--color-malachite-on-container": "#a7ffeb", + "--color-gold": "#ffd740", + "--color-gold-container": "#332200", + "--color-gold-on-container": "#ffecb3", + "--color-terracotta": "#e1b07e", + "--color-terracotta-container": "#3e2817", + "--color-terracotta-on-container": "#f5e6d3", + "--color-sodalite": "#3d5afe", + "--color-sodalite-container": "#0d1442", + "--color-sodalite-on-container": "#c5cae9", + "--color-copper": "#ff8a65", + "--color-copper-container": "#3a1a0e", + "--color-copper-on-container": "#ffd3c2", + "--color-indigo": "#7986cb", + "--color-savanna": "#e5c158", + "--color-baobab": "#a1887f", + "--color-sunset": "#ff7043", + "--color-river": "#4dd0e1", + "--color-hematite": "#90a4ae", + "--color-kalahari": "#e8d9b5", + "--color-ember": "#da8766", + "--color-ember-container": "#352721", + "--color-ember-on-container": "#eba68a", + "--color-ember-ui": "#bb562d", + "--color-acacia": "#93a528", + "--color-acacia-container": "#333521", + "--color-acacia-on-container": "#b6ce23", + "--color-acacia-ui": "#768420", + "--color-fern": "#2cb42b", + "--color-fern-container": "#213521", + "--color-fern-on-container": "#28db28", + "--color-fern-ui": "#228d22", + "--color-lagoon": "#2aae9b", + "--color-lagoon-container": "#213532", + "--color-lagoon-on-container": "#24d6bc", + "--color-lagoon-ui": "#218a7a", + "--color-storm": "#7e9be0", + "--color-storm-container": "#212735", + "--color-storm-on-container": "#99b2ee", + "--color-storm-ui": "#426cd1", + "--color-dusk": "#ba87e2", + "--color-dusk-container": "#2d2135", + "--color-dusk-on-container": "#cc9fef", + "--color-dusk-ui": "#9749d3", + "--color-protea": "#df7bb4", + "--color-protea-container": "#35212d", + "--color-protea-on-container": "#ed98c9", + "--color-protea-ui": "#ca3188", + "--pitch": "#050505", + "--void": "#080807", + "--base": "#0e0d0c", + "--surface": "#131211", + "--container": "#1e1d1a", + "--overlay": "#23221f", + "--raised": "#2e2c29", + "--scrim": "rgba(0, 0, 0, 0.6)", + "--wash": "color-mix(in oklab, var(--surface) 88%, var(--brand-accent))", + "--surface-muted": "#050504", + "--brand-accent": "#b388ff", + "--brand-accent-foreground": "#0e0d0c", + "--syncing": "#36abba", + "--offline": "#ba9570", + "--neutral": "#a09c93", + "--background": "#0e0d0c", + "--canvas": "#0e0d0c", + "--foreground": "#f0efe9", + "--ink": "#f0efe9", + "--card": "#131211", + "--card-foreground": "#f0efe9", + "--popover": "#131211", + "--popover-foreground": "#f0efe9", + "--primary": "#b388ff", + "--primary-foreground": "#1a1917", + "--secondary": "#2a2927", + "--secondary-foreground": "#f0efe9", + "--muted": "#2a2927", + "--muted-foreground": "#a8a6a0", + "--accent": "#001f3f", + "--accent-foreground": "#b3e5fc", + "--destructive": "#f2b8b5", + "--destructive-foreground": "#1a1917", + "--destructive-container": "#3e1818", + "--border": "#2a2927", + "--input": "#100f0e", + "--ring": "#00b0ff", + "--success": "#64ffda", + "--warning": "#ffd866", + "--error": "#f2b8b5", + "--info": "#00b0ff", + "--heritage-indigo": "#7986cb", + "--heritage-savanna": "#e5c158", + "--heritage-baobab": "#a1887f", + "--heritage-sunset": "#ff7043", + "--heritage-river": "#4dd0e1", + "--heritage-hematite": "#90a4ae", + "--heritage-kalahari": "#e8d9b5", + "--touch-target": "56px", + "--touch-target-sm": "48px", + "--radius-unit": "7px", + "--radius-sm": "7px", + "--radius-md": "12px", + "--radius-lg": "14px", + "--radius-xl": "17px", + "--radius-2xl": "17px", + "--radius-full": "9999px" + } + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index def6e02..66aeb6d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -6,7 +6,11 @@ settings: importers: - .: {} + .: + devDependencies: + prettier: + specifier: 3.9.4 + version: 3.9.4 packages/bundu-ui: dependencies: @@ -496,6 +500,11 @@ packages: resolution: {integrity: sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==} engines: {node: ^10 || ^12 || >=14} + prettier@3.9.4: + resolution: {integrity: sha512-yWG/o/4oJfo036EKAfK6ACAoDOfHeRHx4tuxkfBZiauURiaSmYwlpOr5LQqKtIkRD2z1PLteme2WoxEnj4tHTg==} + engines: {node: '>=14'} + hasBin: true + react-dom@19.2.7: resolution: {integrity: sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==} peerDependencies: @@ -935,6 +944,8 @@ snapshots: picocolors: 1.1.1 source-map-js: 1.2.1 + prettier@3.9.4: {} + react-dom@19.2.7(react@19.2.7): dependencies: react: 19.2.7 diff --git a/scripts/check-canon-parity.mjs b/scripts/check-canon-parity.mjs new file mode 100644 index 0000000..6a26d87 --- /dev/null +++ b/scripts/check-canon-parity.mjs @@ -0,0 +1,82 @@ +#!/usr/bin/env node +/** + * check-canon-parity — CI-ONLY gate: does the committed snapshot still match + * live canon? + * + * This is the ONLY script in the repo that touches the network, and it is + * deliberately not wired into any build, prepack, postinstall or runtime path. + * A token package that phones home to render a page is a page that goes blank + * when api.mzizi.dev has a bad afternoon. + * + * The build-time gate is `pnpm tokens:check`, which is offline and compares + * the generated files to the generator. This one compares the snapshot to the + * upstream it was taken from, so a canon change shows up as a red CI job on a + * scheduled run rather than as a surprise the next time someone regenerates. + * + * Refuses to run outside CI unless --force, so it cannot quietly creep into + * somebody's build script. + * + * pnpm canon:parity (in CI) + * pnpm canon:parity -- --force (locally, on purpose) + */ + +import { readFile } from "node:fs/promises"; +import { buildSnapshot, serialize, SNAPSHOT_PATH } from "./fetch-canon.mjs"; + +const force = process.argv.includes("--force"); +if (!process.env.CI && !force) { + console.error( + "check-canon-parity is a CI-only gate — it reaches the network.\n" + + " In CI: set CI=true (GitHub Actions does this for you)\n" + + " Locally: pnpm canon:parity -- --force", + ); + process.exit(2); +} + +const [{ url, snapshot }, committed] = await Promise.all([ + buildSnapshot(), + readFile(SNAPSHOT_PATH, "utf8"), +]); + +const live = serialize(snapshot); +if (live === committed) { + console.log(`canon parity OK — tokens/canon.snapshot.json matches ${url}`); + process.exit(0); +} + +/* Report the actual differing values, not just "files differ". */ +const a = JSON.parse(committed); +const b = JSON.parse(live); +const flat = (obj, prefix = "", out = {}) => { + for (const [k, v] of Object.entries(obj)) { + const key = prefix ? `${prefix}.${k}` : k; + if (v && typeof v === "object") flat(v, key, out); + else out[key] = v; + } + return out; +}; +const fa = flat(a); +const fb = flat(b); +const keys = [...new Set([...Object.keys(fa), ...Object.keys(fb)])].sort(); + +console.error( + `CANON PARITY FAILED — the committed snapshot has drifted from ${url}`, +); +let shown = 0; +for (const k of keys) { + if (fa[k] === fb[k]) continue; + if (k.startsWith("_meta.")) continue; + console.error( + ` ${k}\n committed: ${fa[k] ?? "(absent)"}\n canon: ${fb[k] ?? "(absent)"}`, + ); + shown++; +} +if (!shown) { + console.error( + " (only _meta differs — canon was republished with no value change)", + ); +} +console.error( + "\nFix: `pnpm canon:fetch && pnpm tokens:build`, then commit both.", +); +process.exit(1); diff --git a/scripts/fetch-canon.mjs b/scripts/fetch-canon.mjs new file mode 100644 index 0000000..a255c34 --- /dev/null +++ b/scripts/fetch-canon.mjs @@ -0,0 +1,245 @@ +#!/usr/bin/env node +/** + * fetch-canon — refresh `tokens/canon.snapshot.json` from canon. + * + * The snapshot is the ONLY place a hex value enters this repository, and this + * script is the only thing that writes it. Everything a consumer sees — + * styles/tokens.css, styles/theme.css and tokens.json, in BOTH packages — is + * generated from it by scripts/generate-tokens.mjs. Nobody types a colour. + * + * TWO SOURCES, because canon has two faces and neither is complete alone: + * + * 1. https://api.mzizi.dev/api/v1/brand + * The 21 colour families, the semantic tokens, the background ladder and + * the radius scale. + * + * 2. mzizi-dev/mzizi-registry -> lib/tokens/palette.source.ts (raw, public) + * The named on-disk source of truth, and the only place the minerals' + * `onContainer` pairs exist — /v1/brand does not project them, and + * @bundu/ui has shipped them since 0.1.0. Fetched, not transcribed. + * + * The two are cross-checked against each other here: if the API and the + * on-disk source disagree about any mineral hex, this script fails rather than + * silently preferring one. That disagreement is precisely how an estate ends + * up with four different terracottas. + * + * Why a COMMITTED snapshot rather than a fetch at build time: + * - `pnpm tokens:check` must run on every CI job and in prepack, offline, + * with no credential and no flaky third party. + * - A package build that reaches the network to learn what colour cobalt is + * can produce two different tarballs from one commit. + * Staleness is caught instead by scripts/check-canon-parity.mjs, which does + * hit the network and runs in CI only — never at build or runtime. + * + * Usage: node scripts/fetch-canon.mjs (pnpm canon:fetch) + * node scripts/fetch-canon.mjs --check (pnpm canon:parity) + */ + +import { writeFile, readFile } from "node:fs/promises"; +import { resolve, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; + +const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +export const SNAPSHOT_PATH = resolve(ROOT, "tokens/canon.snapshot.json"); + +/** The canonical form. 404s until mzizi-registry#335 merges. */ +export const BRAND_URL_CANONICAL = "https://api.mzizi.dev/v1/brand"; +/** The form that works today, with the `/api/` prefix. */ +export const BRAND_URL = "https://api.mzizi.dev/api/v1/brand"; + +export const PALETTE_SOURCE_URL = + "https://raw.githubusercontent.com/mzizi-dev/mzizi-registry/main/lib/tokens/palette.source.ts"; + +const SECTIONS = [ + "minerals", + "heritage", + "experimental", + "semanticColors", + "backgrounds", + "radii", + // brand -> mineral, which drives the generated styles/brand-*.css overlays. + "ecosystem", +]; + +function sortDeep(value) { + if (Array.isArray(value)) return value.map(sortDeep); + if (value && typeof value === "object") { + return Object.fromEntries( + Object.keys(value) + .sort() + .map((k) => [k, sortDeep(value[k])]), + ); + } + return value; +} + +async function getJson(url) { + const res = await fetch(url, { headers: { accept: "application/json" } }); + if (!res.ok) throw new Error(`${url} -> HTTP ${res.status}`); + return res.json(); +} + +export async function fetchBrand() { + let res; + try { + res = await fetch(BRAND_URL_CANONICAL, { + headers: { accept: "application/json" }, + }); + } catch { + res = null; + } + if (res?.ok) return { url: BRAND_URL_CANONICAL, body: await res.json() }; + return { url: BRAND_URL, body: await getJson(BRAND_URL) }; +} + +/** + * Pull the `minerals` array out of palette.source.ts. + * + * A regex rather than a TS parse: the file is a flat list of string-valued + * object literals with no computation in it, and adding a TypeScript + * dependency to a CSS package to read seven objects would be worse. If the + * shape ever stops being flat this throws on the family count instead of + * quietly returning less. + */ +export function parseMinerals(ts) { + const start = ts.indexOf("export const minerals"); + if (start < 0) + throw new Error("palette.source.ts: no `export const minerals`"); + const end = ts.indexOf("\n]", start); + if (end < 0) + throw new Error("palette.source.ts: unterminated minerals array"); + const body = ts.slice(start, end); + + const out = []; + for (const [, block] of body.matchAll(/\{([^{}]*)\}/g)) { + const fields = Object.fromEntries( + [...block.matchAll(/(\w+):\s*"((?:[^"\\]|\\.)*)"/g)].map(([, k, v]) => [ + k, + v, + ]), + ); + if (fields.name) out.push(fields); + } + if (out.length !== 7) { + throw new Error(`palette.source.ts: parsed ${out.length} minerals, want 7`); + } + return out; +} + +/** Fail loudly if the API and the on-disk source disagree about a hex. */ +function crossCheck(apiMinerals, diskMinerals) { + const disk = Object.fromEntries(diskMinerals.map((m) => [m.name, m])); + const disagreements = []; + const pairs = [ + ["lightHex", "lightHex"], + ["darkHex", "darkHex"], + ["containerLight", "containerLight"], + ["containerDark", "containerDark"], + ]; + for (const m of apiMinerals) { + const d = disk[m.name]; + if (!d) { + disagreements.push(`${m.name}: absent from palette.source.ts`); + continue; + } + for (const [a, b] of pairs) { + if (m[a].toLowerCase() !== d[b].toLowerCase()) { + disagreements.push(`${m.name}.${a}: api ${m[a]} vs disk ${d[b]}`); + } + } + } + if (disagreements.length) { + throw new Error( + "canon disagrees with itself — /v1/brand and palette.source.ts differ:\n " + + disagreements.join("\n "), + ); + } +} + +export function project(brand, brandUrl, diskMinerals) { + for (const section of SECTIONS) { + if (!(section in brand)) throw new Error(`canon is missing "${section}"`); + } + const counts = { + minerals: brand.minerals.length, + heritage: brand.heritage.length, + experimental: brand.experimental.length, + }; + const total = Object.values(counts).reduce((a, b) => a + b, 0); + if (total !== 21) { + throw new Error( + `expected 21 colour families, canon served ${total} (${JSON.stringify(counts)})`, + ); + } + + crossCheck(brand.minerals, diskMinerals); + + const disk = Object.fromEntries(diskMinerals.map((m) => [m.name, m])); + const minerals = brand.minerals.map((m) => { + const { onContainerLight, onContainerDark } = disk[m.name]; + if (!onContainerLight || !onContainerDark) { + throw new Error(`palette.source.ts: ${m.name} has no onContainer pair`); + } + return { ...m, onContainerLight, onContainerDark }; + }); + + return sortDeep({ + _meta: { + brandSource: brandUrl, + paletteSource: PALETTE_SOURCE_URL, + canonVersion: brand.version, + canonLastUpdated: brand.lastUpdated, + onDiskSourceOfTruth: + "mzizi-dev/mzizi-registry -> lib/tokens/palette.source.ts + brand.source.ts", + note: "Machine-written by scripts/fetch-canon.mjs. Do not hand-edit.", + }, + ...Object.fromEntries(SECTIONS.map((s) => [s, brand[s]])), + minerals, + }); +} + +export const serialize = (snapshot) => `${JSON.stringify(snapshot, null, 2)}\n`; + +export async function buildSnapshot() { + const [{ url, body }, ts] = await Promise.all([ + fetchBrand(), + fetch(PALETTE_SOURCE_URL).then((r) => { + if (!r.ok) throw new Error(`${PALETTE_SOURCE_URL} -> HTTP ${r.status}`); + return r.text(); + }), + ]); + return { url, brand: body, snapshot: project(body, url, parseMinerals(ts)) }; +} + +async function main() { + const check = process.argv.includes("--check"); + const { url, brand, snapshot } = await buildSnapshot(); + const next = serialize(snapshot); + + if (check) { + const current = await readFile(SNAPSHOT_PATH, "utf8").catch(() => ""); + if (current !== next) { + console.error( + "CANON PARITY FAILED — tokens/canon.snapshot.json no longer matches canon.\n" + + ` brand: ${url}\n` + + ` palette: ${PALETTE_SOURCE_URL}\n` + + " Fix: `pnpm canon:fetch && pnpm tokens:build`, then commit both.", + ); + process.exit(1); + } + console.log( + `canon parity OK — snapshot matches ${url} and palette.source.ts`, + ); + return; + } + + await writeFile(SNAPSHOT_PATH, next); + const n = + brand.minerals.length + brand.heritage.length + brand.experimental.length; + console.log( + `wrote tokens/canon.snapshot.json from ${url} + palette.source.ts ` + + `(brand v${brand.version}, ${n} families)`, + ); +} + +if (import.meta.url === `file://${process.argv[1]}`) await main(); diff --git a/scripts/generate-tokens.mjs b/scripts/generate-tokens.mjs new file mode 100644 index 0000000..bcf0884 --- /dev/null +++ b/scripts/generate-tokens.mjs @@ -0,0 +1,1102 @@ +#!/usr/bin/env node +/** + * generate-tokens — emit every token artifact in this repo from ONE input. + * + * Input tokens/canon.snapshot.json (machine-written by scripts/fetch-canon.mjs) + * + * Output packages/bundu-ui/styles/tokens.css packages/ui/styles/tokens.css + * packages/bundu-ui/styles/theme.css packages/ui/styles/theme.css + * packages/bundu-ui/tokens.json packages/ui/tokens.json + * + * Both packages emit byte-identical token files on purpose. Until this change + * @nyuchi/ui carried its own hand-written copy of the seven minerals inside + * styles/globals.css — a second palette inside one repository, already drifted + * the same way @bundu/ui's had. Two artifacts from one generator is not a + * second source; two hand-maintained files are. + * + * Usage: node scripts/generate-tokens.mjs (pnpm tokens:build) + * node scripts/generate-tokens.mjs --check (pnpm tokens:check) + * + * `--check` regenerates in memory and diffs against the working tree. It needs + * no network and no credential, so it runs on every CI job, in the publish + * workflow's prepack, and locally. It is the gate that makes hand-editing a + * generated file a build failure rather than a slow-motion estate-wide bug. + */ + +import { readFile, writeFile } from "node:fs/promises"; +import { resolve, dirname, relative } from "node:path"; +import { fileURLToPath } from "node:url"; +import prettier from "prettier"; + +const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const SNAPSHOT = resolve(ROOT, "tokens/canon.snapshot.json"); +const PACKAGES = ["packages/bundu-ui", "packages/ui"]; + +const byName = (list) => Object.fromEntries(list.map((e) => [e.name, e])); + +/* ------------------------------------------------------------------ * + * Package-local semantics. + * + * Canon (/v1/brand) is authoritative for every colour it carries. These are + * the handful it does not carry, each with the reason it is local. Nothing + * else in this file contains a literal hex. + * ------------------------------------------------------------------ */ +const LOCAL = { + // Canon has no ink token; the brand document treats foreground as a + // per-surface pairing rather than a palette entry. These are the values + // @bundu/ui has shipped since 0.1.0 and all four consumers render against. + foreground: { light: "#1a1a17", dark: "#f0efe9" }, + // Canon has no "card": a card is `surface` in the registry's own shell. + // Marketing sites float a WHITE card on the light base for editorial + // contrast, which is why light stays #ffffff while dark adopts canon + // `surface` (this is drift fix 3 — dark was #1a1917). + cardLight: "#ffffff", + // Ink used on a saturated fill (primary / destructive) in dark mode. + onBrightDark: "#1a1917", + // Secondary/muted foreground. Canon `neutral` (#55514B/#A09C93) is close + // but not identical; changing it was not in scope for this pass. See + // RESIDUAL-DRIFT in the PR body. + mutedForeground: { light: "#5d5c57", dark: "#a8a6a0" }, + // Canon `container` dark is #1E1D1A; @bundu/ui has shipped #2a2927 (equal to + // `border` dark) since 0.1.0. Only the LIGHT value was in scope for this + // pass. See RESIDUAL-DRIFT in the PR body. + secondaryDark: "#2a2927", + white: "#ffffff", + // Touch targets — an accessibility mandate, not a colour. + touchTarget: "56px", + touchTargetSm: "48px", +}; + +/* ------------------------------------------------------------------ * + * Model + * ------------------------------------------------------------------ */ + +function buildModel(canon) { + const bg = byName(canon.backgrounds); + const sem = byName(canon.semanticColors); + + const minerals = canon.minerals.map((m) => ({ + name: m.name, + light: m.lightHex, + dark: m.darkHex, + containerLight: m.containerLight, + containerDark: m.containerDark, + // /v1/brand does not project on-container for minerals. fetch-canon + // merges it in from palette.source.ts, the named on-disk source of truth. + onContainerLight: m.onContainerLight, + onContainerDark: m.onContainerDark, + origin: m.origin, + symbolism: m.symbolism, + usage: m.usage, + })); + + const heritage = canon.heritage.map((h) => ({ + name: h.name, + light: h.lightHex, + dark: h.darkHex, + origin: h.origin, + symbolism: h.symbolism, + usage: h.usage, + })); + + const experimental = canon.experimental.map((e) => ({ + name: e.name, + light: e.lightHex, + dark: e.darkHex, + containerLight: e.containerLight, + containerDark: e.containerDark, + onContainerLight: e.onContainerLight, + onContainerDark: e.onContainerDark, + uiLight: e.uiLight, + uiDark: e.uiDark, + heptagonIndex: e.heptagonIndex, + })); + + // The nine-step surface ladder, in depth order. `muted` is the tenth + // background in canon; it is emitted as --surface-muted because the + // semantic --muted (a shadcn token, mapped to canon `container`) already + // owns that name in every consumer's Tailwind config. + const LADDER = [ + "pitch", + "void", + "base", + "surface", + "container", + "overlay", + "raised", + "scrim", + "wash", + ]; + const ladder = LADDER.map((name) => { + if (!bg[name]) throw new Error(`canon has no background "${name}"`); + return { name, cssVar: `--${name}`, ...bg[name] }; + }); + const surfaceMuted = { + name: "muted", + cssVar: "--surface-muted", + ...bg.muted, + }; + + const STATUS = ["syncing", "offline", "neutral"]; + const status = STATUS.map((name) => { + if (!sem[name]) throw new Error(`canon has no semantic "${name}"`); + return { name, cssVar: `--${name}`, ...sem[name] }; + }); + + return { + canon, + bg, + sem, + minerals, + heritage, + experimental, + ladder, + surfaceMuted, + status, + }; +} + +/* ------------------------------------------------------------------ * + * Declaration list + * + * One ordered list of { var, light, dark, comment, section } drives both the + * CSS and the JSON, so the two can never disagree. + * ------------------------------------------------------------------ */ + +const v = (name) => ({ ref: name }); +const lit = (value) => ({ lit: value }); + +function declarations(m) { + const out = []; + const push = (section, name, light, dark, comment) => + out.push({ section, name, light, dark, comment }); + const heading = (section, text) => out.push({ section, heading: text }); + + /* --- 7 minerals ------------------------------------------------- */ + heading( + "minerals", + "Seven African Minerals — the deep-earth + hand families", + ); + for (const x of m.minerals) { + push("minerals", `--color-${x.name}`, lit(x.light), lit(x.dark), x.usage); + push( + "minerals", + `--color-${x.name}-container`, + lit(x.containerLight), + lit(x.containerDark), + ); + push( + "minerals", + `--color-${x.name}-on-container`, + lit(x.onContainerLight), + lit(x.onContainerDark), + ); + } + + /* --- 7 heritage ------------------------------------------------- */ + heading( + "heritage", + "Seven heritage tones — atmospheric anchors. Canon carries light/dark only for these; there is no container step.", + ); + for (const x of m.heritage) { + push("heritage", `--color-${x.name}`, lit(x.light), lit(x.dark), x.usage); + } + + /* --- 7 experimental --------------------------------------------- */ + heading( + "experimental", + "Seven experimental tones — the computed heptagon (hues offset 17°, prime saturations). `-ui` is the solved interactive step.", + ); + for (const x of m.experimental) { + push("experimental", `--color-${x.name}`, lit(x.light), lit(x.dark)); + push( + "experimental", + `--color-${x.name}-container`, + lit(x.containerLight), + lit(x.containerDark), + ); + push( + "experimental", + `--color-${x.name}-on-container`, + lit(x.onContainerLight), + lit(x.onContainerDark), + ); + push("experimental", `--color-${x.name}-ui`, lit(x.uiLight), lit(x.uiDark)); + } + + /* --- surface ladder --------------------------------------------- */ + heading( + "ladder", + "Nine-step surface ladder, shallow to deep. Prime steps P2..P17.", + ); + for (const s of m.ladder) { + push("ladder", s.cssVar, lit(s.light), lit(s.dark), s.usage); + } + push( + "ladder", + m.surfaceMuted.cssVar, + lit(m.surfaceMuted.light), + lit(m.surfaceMuted.dark), + `${m.surfaceMuted.usage}. Canon calls this background "muted"; the ` + + "shadcn semantic --muted already owns that name below.", + ); + + /* --- brand accent (input to --wash) ------------------------------ */ + heading( + "brand-accent", + "Per-app brand mineral. --wash mixes it into --surface; the brand-*.css overlays repoint it alongside --primary.", + ); + push( + "brand-accent", + "--brand-accent", + lit(m.sem["brand-accent"].light), + lit(m.sem["brand-accent"].dark), + m.sem["brand-accent"].usage, + ); + push( + "brand-accent", + "--brand-accent-foreground", + lit(LOCAL.white), + lit(m.bg.base.dark), + ); + + /* --- status trio ------------------------------------------------- */ + heading("status", "Connectivity status trio."); + for (const s of m.status) { + push("status", s.cssVar, lit(s.light), lit(s.dark), s.usage); + } + + /* --- semantic (shadcn surface) ----------------------------------- */ + heading("semantic", "Semantic tokens — the shadcn/Tailwind contract."); + push( + "semantic", + "--background", + v("--base"), + v("--base"), + "Canon `base`. Was #faf9f4 / #100f0e before 0.2.0 — drift fixes 1 and 2.", + ); + push("semantic", "--canvas", v("--background"), v("--background")); + push( + "semantic", + "--foreground", + lit(LOCAL.foreground.light), + lit(LOCAL.foreground.dark), + ); + push("semantic", "--ink", v("--foreground"), v("--foreground")); + + push( + "semantic", + "--card", + lit(LOCAL.cardLight), + v("--surface"), + "Dark was #1a1917 before 0.2.0 — drift fix 3. Light stays white: a " + + "marketing card floats above the base by design.", + ); + push("semantic", "--card-foreground", v("--foreground"), v("--foreground")); + push("semantic", "--popover", v("--card"), v("--card")); + push( + "semantic", + "--popover-foreground", + v("--card-foreground"), + v("--card-foreground"), + ); + + push( + "semantic", + "--primary", + v("--color-tanzanite"), + v("--color-tanzanite"), + 'Drift fix 6. Canon: "Cobalt is the exceptional mineral for links/info ' + + 'only — do not use it as --primary." Every brand-*.css overlay ' + + "repoints this; tanzanite is the unbranded default.", + ); + push( + "semantic", + "--primary-foreground", + lit(LOCAL.white), + lit(LOCAL.onBrightDark), + ); + + push( + "semantic", + "--secondary", + v("--container"), + lit(LOCAL.secondaryDark), + "Light was #f4f2ec before 0.2.0 — drift fix 5.", + ); + push( + "semantic", + "--secondary-foreground", + v("--foreground"), + v("--foreground"), + ); + + push( + "semantic", + "--muted", + v("--container"), + lit(LOCAL.secondaryDark), + "Light was #f4f2ec before 0.2.0 — drift fix 4.", + ); + push( + "semantic", + "--muted-foreground", + lit(LOCAL.mutedForeground.light), + lit(LOCAL.mutedForeground.dark), + ); + + push( + "semantic", + "--accent", + v("--color-cobalt-container"), + v("--color-cobalt-container"), + ); + push( + "semantic", + "--accent-foreground", + v("--color-cobalt-on-container"), + v("--color-cobalt-on-container"), + ); + + push( + "semantic", + "--destructive", + lit(m.sem.error.light), + lit(m.sem.error.dark), + ); + push( + "semantic", + "--destructive-foreground", + lit(LOCAL.white), + lit(LOCAL.onBrightDark), + ); + push( + "semantic", + "--destructive-container", + lit(m.sem["destructive-container"].light), + lit(m.sem["destructive-container"].dark), + ); + + push("semantic", "--border", lit(m.sem.border.light), lit(m.sem.border.dark)); + push("semantic", "--input", lit(m.sem.input.light), lit(m.sem.input.dark)); + push( + "semantic", + "--ring", + v("--color-cobalt"), + v("--color-cobalt"), + m.sem.ring.usage, + ); + + push("semantic", "--success", v("--color-malachite"), v("--color-malachite")); + push( + "semantic", + "--warning", + lit(m.sem.warning.light), + lit(m.sem.warning.dark), + ); + push("semantic", "--error", v("--destructive"), v("--destructive")); + push( + "semantic", + "--info", + v("--color-cobalt"), + v("--color-cobalt"), + m.sem.info.usage, + ); + + /* --- legacy aliases ---------------------------------------------- */ + heading( + "legacy", + "Legacy aliases. 0.1.1 namespaced the heritage tones --heritage-*; they are --color-* now, like every other family. These keep the four existing consumers compiling unchanged. Do not use in new code.", + ); + for (const x of m.heritage) { + push( + "legacy", + `--heritage-${x.name}`, + v(`--color-${x.name}`), + v(`--color-${x.name}`), + ); + } + + return out; +} + +/** Non-colour scalars — same in both modes, so they live in :root only. */ +function scalars(m) { + const r = m.canon.radii; + return [ + { heading: "Touch targets — accessibility mandate, 56px comfortable." }, + { name: "--touch-target", value: LOCAL.touchTarget }, + { name: "--touch-target-sm", value: LOCAL.touchTargetSm }, + { + heading: + "Radius scale. Ecosystem numbers 7/12/14/17; buttons are always pill.", + }, + { name: "--radius-unit", value: r.sm }, + { name: "--radius-sm", value: r.sm }, + { name: "--radius-md", value: r.md }, + { name: "--radius-lg", value: r.lg }, + { name: "--radius-xl", value: r.xl }, + { name: "--radius-2xl", value: r["2xl"] }, + { name: "--radius-full", value: r.full }, + ]; +} + +/* ------------------------------------------------------------------ * + * Emitters + * ------------------------------------------------------------------ */ + +/** Lowercase hex; canon serves #RRGGBB uppercase, the estate's CSS is lower. */ +const norm = (value) => + String(value) + .replace(/#[0-9A-Fa-f]{3,8}\b/g, (h) => h.toLowerCase()) + .replace( + /rgba\(([^)]*)\)/g, + (_, inner) => + `rgba(${inner + .split(",") + .map((n) => n.trim().replace(/^(0?\.\d*?)0+$/, "$1")) + .join(", ")})`, + ); + +const expr = (e) => (e.ref ? `var(${e.ref})` : norm(e.lit)); + +const WIDTH = 80; + +/** Wrap a prose comment to WIDTH at the given indent, as a CSS block comment. */ +function wrapComment(text, indent, lead = "") { + const pad = " ".repeat(indent); + const body = `${lead}${text}`.replace(/\s+/g, " ").trim(); + const budget = WIDTH - indent - 3; + const lines = [[]]; + let len = 0; + for (const word of body.split(" ")) { + if (len && len + 1 + word.length > budget) { + lines.push([]); + len = 0; + } + lines.at(-1).push(word); + len += (len ? 1 : 0) + word.length; + } + const rendered = lines.map((l) => l.join(" ")); + if (rendered.length === 1) return `${pad}/* ${rendered[0]} */`; + return [ + `${pad}/* ${rendered[0]}`, + ...rendered.slice(1).map((l) => `${pad} ${l}`), + ] + .join("\n") + .concat(" */"); +} + +function emitBlock(decls, mode, indent = 2) { + const pad = " ".repeat(indent); + const lines = []; + let first = true; + let lastGroup = null; + for (const d of decls) { + if (d.heading) { + if (!first) lines.push(""); + lines.push(wrapComment(d.heading, indent, "=== ")); + first = false; + lastGroup = null; + continue; + } + first = false; + // Blank line between families within a section (--color-ember... then + // --color-acacia...), so 84 declarations stay readable. + const group = d.name.replace(/^(--color-[a-z]+).*$/, "$1"); + if (lastGroup && group !== lastGroup && group.startsWith("--color-")) { + lines.push(""); + } + lastGroup = group.startsWith("--color-") ? group : null; + + const decl = `${pad}${d.name}: ${expr(d[mode])};`; + if (!d.comment) { + lines.push(decl); + continue; + } + const inline = `${decl} /* ${d.comment.replace(/\s+/g, " ")} */`; + if (inline.length <= WIDTH) { + lines.push(inline); + } else { + lines.push(wrapComment(d.comment, indent)); + lines.push(decl); + } + } + return lines.join("\n"); +} + +const HEADER = ( + pkg, + meta, +) => `/* ${pkg} — design tokens. GENERATED FILE — DO NOT EDIT. + + Source tokens/canon.snapshot.json (brand v${meta.canonVersion}, ${meta.canonLastUpdated}) + Generator scripts/generate-tokens.mjs (\`pnpm tokens:build\`) + Gate \`pnpm tokens:check\` fails if this file and the generator disagree + + All 21 colour families of the Mzizi design system under one --color-* + namespace: seven minerals, seven heritage tones, seven experimental tones. + Plus the nine-step surface ladder, the connectivity status trio, and the + semantic (shadcn) contract. + + The hex values do NOT live in a database. Mzizi holds no brand or primitive + token data in Supabase or anywhere else — the on-disk source of truth is + mzizi-dev/mzizi-registry -> lib/tokens/palette.source.ts (+ brand.source.ts), + projected over https://api.mzizi.dev/api/v1/brand and snapshotted into this + repo by \`pnpm canon:fetch\`. (0.1.1's header claimed "they are the DB's + source of truth". That was already wrong and is corrected here.) + + PURE custom properties — no @layer, no @apply, no Tailwind — so any site + can @import it. Dark mode under both conventions: the .dark class + (shadcn/Tailwind) and [data-theme="dark"] (Starlight and friends). + + Tailwind v4 users: import ./theme.css instead, which @imports this file and + adds the @theme entrypoint. The v3 preset (./tailwind-preset.mjs) keeps + working unchanged. */ +`; + +function emitTokensCss(pkg, m, decls, meta) { + return [ + HEADER(pkg, meta), + ":root {", + emitBlock(decls, "light"), + "", + emitBlock( + scalars(m).map((s) => + s.heading + ? { heading: s.heading } + : { name: s.name, light: lit(s.value) }, + ), + "light", + ), + "}", + "", + ".dark,", + '[data-theme="dark"] {', + emitBlock(decls, "dark"), + "}", + "", + ].join("\n"); +} + +const THEME_HEADER = ( + pkg, +) => `/* ${pkg} — Tailwind v4 entrypoint. GENERATED FILE — DO NOT EDIT. + + Every consumer in the estate is on Tailwind v4. Until 0.2.0 this package + shipped only a v3-shape preset, which v4 can still load through \`@config\` + — that is how all four current consumers work today, and it keeps working. + This file is the native path for new consumers: + + @import "tailwindcss"; + @import "@bundu/ui/styles/theme.css"; + @import "@bundu/ui/styles/brand-mzizi.css"; + + and no tailwind.config.mjs at all. + + Palette families are declared with their light-mode value as the @theme + fallback; tokens.css's own :root / .dark rules are unlayered and therefore + win at runtime, which is what makes dark mode work. Tokens that alias a + bare ladder variable use \`@theme inline\` so no second definition is + emitted. */ +`; + +function emitThemeCss(pkg, m) { + const lines = []; + const push = (s) => lines.push(s); + + push(THEME_HEADER(pkg)); + push('@import "./tokens.css";'); + push(""); + push("/* Palette — bg-cobalt, text-savanna, border-ember-ui, ... */"); + push("@theme {"); + for (const x of m.minerals) { + push(` --color-${x.name}: ${norm(x.light)};`); + push(` --color-${x.name}-container: ${norm(x.containerLight)};`); + push(` --color-${x.name}-on-container: ${norm(x.onContainerLight)};`); + } + push(""); + for (const x of m.heritage) push(` --color-${x.name}: ${norm(x.light)};`); + push(""); + for (const x of m.experimental) { + push(` --color-${x.name}: ${norm(x.light)};`); + push(` --color-${x.name}-container: ${norm(x.containerLight)};`); + push(` --color-${x.name}-on-container: ${norm(x.onContainerLight)};`); + push(` --color-${x.name}-ui: ${norm(x.uiLight)};`); + } + push(""); + push(" /* Type, radius and easing — same scale the v3 preset ships. */"); + push(' --font-sans: "Noto Sans", system-ui, sans-serif;'); + push(' --font-serif: "Noto Serif", Georgia, serif;'); + push(' --font-mono: "JetBrains Mono", ui-monospace, monospace;'); + const r = m.canon.radii; + push(` --radius-sm: ${r.sm};`); + push(` --radius-md: ${r.md};`); + push(` --radius-lg: ${r.lg};`); + push(` --radius-xl: ${r.xl};`); + push(` --radius-2xl: ${r["2xl"]};`); + push(` --radius-full: ${r.full};`); + push(` --radius-pill: ${r.full};`); + push(" --ease-soft: cubic-bezier(0.4, 0, 0.2, 1);"); + push("}"); + push(""); + push( + "/* Ladder, status and semantic tokens alias bare variables, so they are\n" + + " inlined rather than redefined — no duplicate declaration, one source. */", + ); + push("@theme inline {"); + for (const s of m.ladder) push(` --color-${s.name}: var(${s.cssVar});`); + push(` --color-surface-muted: var(${m.surfaceMuted.cssVar});`); + push(""); + for (const s of m.status) push(` --color-${s.name}: var(${s.cssVar});`); + push(""); + for (const name of [ + "background", + "foreground", + "canvas", + "ink", + "card", + "card-foreground", + "popover", + "popover-foreground", + "primary", + "primary-foreground", + "secondary", + "secondary-foreground", + "muted", + "muted-foreground", + "accent", + "accent-foreground", + "destructive", + "destructive-foreground", + "border", + "input", + "ring", + "success", + "warning", + "error", + "info", + "brand-accent", + "brand-accent-foreground", + ]) { + push(` --color-${name}: var(--${name});`); + } + push("}"); + push(""); + return lines.join("\n"); +} + +/** + * Flatten every declaration to a literal value per mode, following var() + * chains. This is the half of tokens.json that mukoko-weather-mobile (Expo, + * no CSS) and the OG-image / email / PDF generators (Satori cannot resolve + * CSS variables) consume instead of mirroring hexes by hand. + */ +function flatten(decls, scalarList, mode) { + const raw = {}; + for (const d of decls) if (!d.heading) raw[d.name] = d[mode]; + for (const s of scalarList) if (!s.heading) raw[s.name] = lit(s.value); + + const seen = new Set(); + const resolveOne = (name) => { + if (seen.has(name)) throw new Error(`cycle resolving ${name}`); + const e = raw[name]; + if (!e) throw new Error(`dangling reference ${name}`); + if (!e.ref) return e.lit; + seen.add(name); + const out = resolveOne(e.ref); + seen.delete(name); + return out; + }; + + const out = {}; + for (const name of Object.keys(raw)) out[name] = norm(resolveOne(name)); + return out; +} + +function emitTokensJson(pkg, m, decls, meta) { + const scalarList = scalars(m); + // Hex is lowercased throughout, matching the emitted CSS, so a consumer can + // string-compare tokens.json against tokens.css. Canon serves uppercase. + const family = (x, extra = {}) => + Object.fromEntries( + Object.entries({ + light: x.light, + dark: x.dark, + cssVar: `--color-${x.name}`, + ...extra, + }).map(([k, val]) => [ + k, + typeof val === "string" && val.startsWith("#") ? norm(val) : val, + ]), + ); + + return { + $comment: + "GENERATED by scripts/generate-tokens.mjs from tokens/canon.snapshot.json. " + + "Do not edit. `pnpm tokens:check` fails if you do.", + package: pkg, + canon: { + brandSource: meta.brandSource, + paletteSource: meta.paletteSource, + version: meta.canonVersion, + lastUpdated: meta.canonLastUpdated, + onDiskSourceOfTruth: meta.onDiskSourceOfTruth, + }, + color: { + minerals: Object.fromEntries( + m.minerals.map((x) => [ + x.name, + family(x, { + containerLight: x.containerLight, + containerDark: x.containerDark, + onContainerLight: x.onContainerLight, + onContainerDark: x.onContainerDark, + origin: x.origin, + symbolism: x.symbolism, + usage: x.usage, + }), + ]), + ), + heritage: Object.fromEntries( + m.heritage.map((x) => [ + x.name, + family(x, { + origin: x.origin, + symbolism: x.symbolism, + usage: x.usage, + }), + ]), + ), + experimental: Object.fromEntries( + m.experimental.map((x) => [ + x.name, + family(x, { + containerLight: x.containerLight, + containerDark: x.containerDark, + onContainerLight: x.onContainerLight, + onContainerDark: x.onContainerDark, + uiLight: x.uiLight, + uiDark: x.uiDark, + heptagonIndex: x.heptagonIndex, + }), + ]), + ), + }, + surface: Object.fromEntries( + [...m.ladder, m.surfaceMuted].map((s) => [ + s.name, + { + light: norm(s.light), + dark: norm(s.dark), + cssVar: s.cssVar, + usage: s.usage, + }, + ]), + ), + status: Object.fromEntries( + m.status.map((s) => [ + s.name, + { + light: norm(s.light), + dark: norm(s.dark), + cssVar: s.cssVar, + usage: s.usage, + }, + ]), + ), + radius: m.canon.radii, + /* Every custom property resolved to a literal value, var() chains + followed. Satori / Expo / PDF read this. --wash stays a color-mix() + expression: it is defined relative to the live brand accent and has no + single literal. */ + resolved: { + light: flatten(decls, scalarList, "light"), + dark: flatten(decls, scalarList, "dark"), + }, + }; +} + +/* ------------------------------------------------------------------ * + * Brand overlays + * + * One file per brand, each repointing --primary and --ring and NOTHING else. + * The brand -> mineral mapping comes from canon's own `ecosystem` table, so + * these are generated too. Before this change the two packages disagreed: + * @bundu/ui's brand-bundu.css said copper (canon), @nyuchi/ui's said + * terracotta. Generating both from one table is how that stops recurring. + * ------------------------------------------------------------------ */ + +/** + * Brands that ship an overlay. Canon's `ecosystem` table supplies the mineral + * for every one of these except mzizi. + */ +const OVERLAY_BRANDS = ["bundu", "nyuchi", "mukoko", "shamwari", "mzizi"]; + +/** + * mzizi is NOT in canon's `ecosystem` table — the design system has no + * brand->mineral row of its own. hematite is the nearest canon-grounded fit: + * its symbolism is literally "Foundation, endurance, the substrate" and its + * usage "Neutral anchor", which is also what mzizi.dev's own shell does today + * (a near-neutral primary rather than a saturated mineral). Flagged in the PR + * for the owner to confirm and, if confirmed, to add to + * mzizi-registry -> lib/tokens/brand.source.ts so this stops being local. + */ +const LOCAL_BRAND_MINERALS = { + mzizi: { + mineral: "hematite", + family: "heritage", + note: + "Canon carries no mzizi -> mineral row; hematite is the nearest " + + "canon-grounded fit (symbolism: foundation, endurance, the substrate). " + + "Pending confirmation — see mukoko-dev/packages-ui PR.", + }, +}; + +function brandOverlays(m) { + const eco = byName(m.canon.ecosystem); + const families = { + ...Object.fromEntries(m.minerals.map((x) => [x.name, "mineral"])), + ...Object.fromEntries(m.heritage.map((x) => [x.name, "heritage tone"])), + }; + + return OVERLAY_BRANDS.map((brand) => { + const local = LOCAL_BRAND_MINERALS[brand]; + const mineral = local ? local.mineral : eco[brand]?.mineral; + if (!mineral) { + throw new Error(`canon has no ecosystem row for brand "${brand}"`); + } + if (!families[mineral]) { + throw new Error(`brand ${brand} points at unknown family "${mineral}"`); + } + const meta = eco[brand]; + const provenance = local + ? local.note + : `Canon (/v1/brand -> ecosystem) maps ${brand} to ${mineral}` + + (meta?.role ? ` — ${meta.role.toLowerCase()}.` : "."); + + const body = [ + wrapComment( + `brand-${brand} — ${brand} primary: ${mineral} (${families[mineral]}). ` + + `${provenance} GENERATED by scripts/generate-tokens.mjs; ` + + "edit the canon ecosystem table, not this file. Import AFTER " + + "tokens.css (or globals.css / theme.css) so it wins.", + 0, + ), + ":root {", + ` --primary: var(--color-${mineral});`, + ` --ring: var(--color-${mineral});`, + "}", + ".dark,", + '[data-theme="dark"] {', + ` --primary: var(--color-${mineral});`, + ` --ring: var(--color-${mineral});`, + "}", + "", + ].join("\n"); + + return [`styles/brand-${brand}.css`, body, mineral]; + }); +} + +/* ------------------------------------------------------------------ * + * Tailwind v3 palette module + * + * The v3 preset stays — shamwari/site imports it and all four consumers reach + * it through v4's `@config`. Only its colour map is generated, so adding a + * family to canon reaches both the v3 and the v4 surface in one command and + * neither can quietly fall behind the other. + * ------------------------------------------------------------------ */ + +function emitPaletteModule(pkg, m) { + /** Quote an object key only when it is not a bare identifier. */ + const key = (k) => (/^[A-Za-z_$][\w$]*$/.test(k) ? k : JSON.stringify(k)); + const entry = (name, keys) => + keys.length === 0 + ? ` ${key(name)}: "var(--color-${name})",` + : [ + ` ${key(name)}: {`, + ` DEFAULT: "var(--color-${name})",`, + ...keys.map((k) => ` ${key(k)}: "var(--color-${name}-${k})",`), + " },", + ].join("\n"); + + const lines = []; + lines.push( + `/* ${pkg} — Tailwind v3 colour map. GENERATED FILE — DO NOT EDIT.`, + ); + lines.push(""); + lines.push( + " Generator scripts/generate-tokens.mjs (`pnpm tokens:build`)", + ); + lines.push(" Gate `pnpm tokens:check`"); + lines.push(""); + lines.push( + " Spread into `theme.extend.colors` by ./tailwind-preset.mjs. Every value", + ); + lines.push( + " is a var() into styles/tokens.css — there is no hex in this file, so", + ); + lines.push(" light/dark and the brand-*.css overlays all keep working. */"); + lines.push(""); + lines.push("export const colors = {"); + lines.push(" /* Seven African Minerals. */"); + for (const x of m.minerals) + lines.push(entry(x.name, ["container", "on-container"])); + lines.push(""); + lines.push( + " /* Seven heritage tones. NOTE: `indigo` shadows Tailwind's built-in", + ); + lines.push( + " indigo scale — `bg-indigo-500` becomes unavailable, `bg-indigo` is", + ); + lines.push( + " the heritage tone. The estate forbids default-palette utilities", + ); + lines.push( + " anyway (bundu-labs/marketing scripts/check-token-consistency.mjs). */", + ); + for (const x of m.heritage) lines.push(entry(x.name, [])); + lines.push(""); + lines.push(" /* Seven experimental tones. */"); + for (const x of m.experimental) { + lines.push(entry(x.name, ["container", "on-container", "ui"])); + } + lines.push(""); + lines.push(" /* Nine-step surface ladder. */"); + for (const sfc of m.ladder) { + lines.push(` ${key(sfc.name)}: "var(${sfc.cssVar})",`); + } + lines.push(` "surface-muted": "var(${m.surfaceMuted.cssVar})",`); + lines.push(""); + lines.push(" /* Connectivity status trio. */"); + for (const st of m.status) { + lines.push(` ${key(st.name)}: "var(${st.cssVar})",`); + } + lines.push(""); + lines.push(" /* Semantic tokens — the shadcn/Tailwind contract. */"); + const semantic = [ + ["background", null], + ["foreground", null], + ["canvas", null], + ["ink", null], + ["primary", "foreground"], + ["secondary", "foreground"], + ["muted", "foreground"], + ["accent", "foreground"], + ["destructive", "foreground"], + ["card", "foreground"], + ["popover", "foreground"], + ["border", null], + ["input", null], + ["ring", null], + ["success", null], + ["warning", null], + ["error", null], + ["info", null], + ["brand-accent", "foreground"], + ]; + for (const [name, sub] of semantic) { + if (!sub) { + lines.push(` ${key(name)}: "var(--${name})",`); + } else { + lines.push(` ${key(name)}: {`); + lines.push(` DEFAULT: "var(--${name})",`); + lines.push(` ${key(sub)}: "var(--${name}-${sub})",`); + lines.push(" },"); + } + } + lines.push("};"); + lines.push(""); + lines.push( + "/* The 21 colour family names, in canon order. Used for the v3 preset's", + ); + lines.push(" safelist: mineral utilities are often composed from data"); + lines.push(" (`bg-${mineral}`), which Tailwind's scanner cannot see. */"); + lines.push("export const families = ["); + for (const x of [...m.minerals, ...m.heritage, ...m.experimental]) { + lines.push(` ${JSON.stringify(x.name)},`); + } + lines.push("];"); + lines.push(""); + return lines.join("\n"); +} + +/* ------------------------------------------------------------------ * + * Main + * ------------------------------------------------------------------ */ + +async function format(source, parser) { + return prettier.format(source, { + ...(await prettier.resolveConfig(resolve(ROOT, ".prettierrc"))), + parser, + }); +} + +async function build() { + const canon = JSON.parse(await readFile(SNAPSHOT, "utf8")); + const meta = canon._meta; + const m = buildModel(canon); + const decls = declarations(m); + + const files = []; + for (const dir of PACKAGES) { + const pkgJson = JSON.parse( + await readFile(resolve(ROOT, dir, "package.json"), "utf8"), + ); + const pkg = pkgJson.name; + for (const [rel, body] of brandOverlays(m)) { + files.push([`${dir}/${rel}`, body]); + } + files.push( + [`${dir}/tailwind-palette.mjs`, emitPaletteModule(pkg, m)], + [`${dir}/styles/tokens.css`, emitTokensCss(pkg, m, decls, meta)], + [`${dir}/styles/theme.css`, emitThemeCss(pkg, m)], + [ + `${dir}/tokens.json`, + await format( + JSON.stringify(emitTokensJson(pkg, m, decls, meta)), + "json", + ), + ], + ); + } + return files; +} + +async function main() { + const check = process.argv.includes("--check"); + const files = await build(); + let drifted = 0; + + for (const [rel, content] of files) { + const path = resolve(ROOT, rel); + const current = await readFile(path, "utf8").catch(() => null); + if (current === content) continue; + if (check) { + drifted++; + console.error( + `DRIFT ${rel}\n ${ + current === null + ? "missing — run `pnpm tokens:build`" + : "differs from the generator — hand edits to generated files are not kept" + }`, + ); + } else { + await writeFile(path, content); + console.log(`wrote ${relative(ROOT, path)}`); + } + } + + if (check) { + if (drifted) { + console.error( + `\n${drifted} generated file(s) drifted. Run \`pnpm tokens:build\` and commit.`, + ); + process.exit(1); + } + console.log(`tokens up to date (${files.length} generated files)`); + } +} + +await main(); diff --git a/tokens/canon.snapshot.json b/tokens/canon.snapshot.json new file mode 100644 index 0000000..2a4db5e --- /dev/null +++ b/tokens/canon.snapshot.json @@ -0,0 +1,612 @@ +{ + "_meta": { + "brandSource": "https://api.mzizi.dev/api/v1/brand", + "canonLastUpdated": "2026-07-13", + "canonVersion": "4.0.31", + "note": "Machine-written by scripts/fetch-canon.mjs. Do not hand-edit.", + "onDiskSourceOfTruth": "mzizi-dev/mzizi-registry -> lib/tokens/palette.source.ts + brand.source.ts", + "paletteSource": "https://raw.githubusercontent.com/mzizi-dev/mzizi-registry/main/lib/tokens/palette.source.ts" + }, + "backgrounds": [ + { + "dark": "#0E0D0C", + "light": "#F3F3F1", + "name": "base", + "usage": "Page background — ambient base surface (prime step P5)" + }, + { + "dark": "#1E1D1A", + "light": "#E5E4E1", + "name": "container", + "usage": "Neutral containers, grouped content (prime step P11)" + }, + { + "dark": "#050504", + "light": "#FAF9F5", + "name": "muted", + "usage": "Deepest fill — inset sections, metadata rows, maximum text contrast" + }, + { + "dark": "#23221F", + "light": "#E0DFDC", + "name": "overlay", + "usage": "Overlays and dialogs (prime step P13)" + }, + { + "dark": "#050505", + "light": "#FAFAFA", + "name": "pitch", + "usage": "Deepest surface — media wells, splash (prime step P2)" + }, + { + "dark": "#2E2C29", + "light": "#D6D5D1", + "name": "raised", + "usage": "Raised elements above overlay — menus, toasts (prime step P17)" + }, + { + "dark": "rgba(0,0,0,0.60)", + "light": "rgba(0,0,0,0.40)", + "name": "scrim", + "usage": "Semi-transparent backdrop behind overlays" + }, + { + "dark": "#131211", + "light": "#EEEEEC", + "name": "surface", + "usage": "Card / panel surface (prime step P7)" + }, + { + "dark": "#080807", + "light": "#F8F8F7", + "name": "void", + "usage": "App shell behind base (prime step P3)" + }, + { + "dark": "color-mix(in oklab, var(--surface) 88%, var(--brand-accent))", + "light": "color-mix(in oklab, var(--surface) 93%, var(--brand-accent))", + "name": "wash", + "usage": "Cover-colour page wash — surface tinted with the active brand accent (~7% light / ~12% dark)" + } + ], + "ecosystem": [ + { + "description": "The complete ecosystem built by Nyuchi Africa. Three pillars — mukoko (consumer super app), nyuchi (enterprise layer), and sister brands (specialist verticals) — all connected through one identity, one design system, and one open data commons.", + "language": "Shona", + "meaning": "Wilderness", + "mineral": "copper", + "name": "bundu", + "role": "The ecosystem", + "url": "https://www.bundu.org", + "voice": "Visionary, grounded, inclusive" + }, + { + "description": "Seven enterprise products, each with its own consumer interface and business-facing tools. API platform, web services, learning, medical, rentals, tools, and SEO manager. Every nyuchi product is a standalone product, a door into the mukoko platform, and a professional surface for the same ecosystem.", + "language": "Shona", + "meaning": "Bee", + "mineral": "gold", + "name": "nyuchi", + "role": "Infrastructure & enterprise", + "url": "https://nyuchi.com", + "voice": "Technical, reliable, industrious" + }, + { + "description": "A growing net of mini-apps — founded seventeen, four substrate components, one unified identity — grown under the Bundu Order. The digital home where a billion Africans' social, creative, commercial, civic, and economic lives belong.", + "language": "Shona", + "meaning": "Beehive", + "mineral": "tanzanite", + "name": "mukoko", + "role": "Africa's super app", + "url": "https://mukoko.com", + "voice": "Welcoming, structured, protective" + }, + { + "description": "The Digital Twin's conversational interface. Three layers of intelligence — personal (your pod data), community (anonymised platform data), and platform (base mukoko knowledge). A friend that serves; a friend that does not control.", + "language": "Shona", + "meaning": "Friend", + "mineral": "sodalite", + "name": "shamwari", + "role": "Sovereign AI companion", + "url": "https://shamwari.ai", + "voice": "Helpful, warm, intelligent" + }, + { + "description": "Community events and cultural gatherings. Standalone brand calling the same platform API. Edge-first check-in via geographic Durable Objects for sub-10ms ticket validation at venue doors.", + "language": "Shona", + "meaning": "Gathering", + "mineral": "malachite", + "name": "nhimbe", + "role": "Events & gatherings", + "url": "https://nhimbe.com", + "voice": "Celebratory, communal, vibrant" + }, + { + "description": "Rentals-first marketplace for Zimbabwe and beyond. Business verification through the platform's unified submissions pipeline. Escrow-backed payments via mukoko wallet. Seller conversations flow through Campfire.", + "language": "English/Shona", + "meaning": "Bush trade", + "mineral": "gold", + "name": "bushtrade", + "role": "Marketplace", + "url": "https://bushtrade.co.zw", + "voice": "Practical, trustworthy, local" + }, + { + "description": "African language learning. Shona and Ndebele as primaries, English, French, Portuguese, and travel phrases. The product expression of mukoko's commitment to treating African languages as first-class citizens.", + "language": "English", + "meaning": "Language", + "mineral": "cobalt", + "name": "lingo", + "role": "Language learning", + "url": "https://lingo.mukoko.com", + "voice": "Encouraging, cultural, playful" + }, + { + "description": "All messaging and cross-app communications. The architectural anchor of the ecosystem.", + "language": "English", + "meaning": "Campfire", + "mineral": "malachite", + "name": "campfire", + "role": "Platform messaging anchor", + "url": "https://campfire.mukoko.com", + "voice": "Direct, warm, always present" + }, + { + "description": "African creator video platform competing with TikTok.", + "language": "English", + "meaning": "Bytes", + "mineral": "tanzanite", + "name": "bytes", + "role": "Short-form creator video", + "url": "https://bytes.mukoko.com", + "voice": "Energetic, creative, youthful" + }, + { + "description": "Long-form publishing with 10 work types, co-authorship, and revenue splitting.", + "language": "English", + "meaning": "Novels", + "mineral": "malachite", + "name": "novels", + "role": "Publishing platform", + "url": "https://novels.mukoko.com", + "voice": "Literary, thoughtful, immersive" + }, + { + "description": "Africa's rival to Google Business Profile + Maps for formal and informal economies.", + "language": "English", + "meaning": "Places", + "mineral": "gold", + "name": "places", + "role": "Geographic knowledge graph", + "url": "https://places.mukoko.com", + "voice": "Authoritative, helpful, discoverable" + }, + { + "description": "Public transit routing, vehicle booking, commute planning for African cities.", + "language": "English", + "meaning": "Transport", + "mineral": "gold", + "name": "transport", + "role": "Public transit and booking", + "url": "https://transport.mukoko.com", + "voice": "Efficient, reliable, practical" + }, + { + "description": "Calendar, tasks, notes, bookings — single source of truth for scheduling.", + "language": "English", + "meaning": "Planner", + "mineral": "cobalt", + "name": "planner", + "role": "Productivity hub", + "url": "https://planner.mukoko.com", + "voice": "Organised, clear, supportive" + }, + { + "description": "MUKOKO tokens, mobile money, bank transfers. No bank account required.", + "language": "English", + "meaning": "Wallet", + "mineral": "gold", + "name": "wallet", + "role": "Payments and tokens", + "url": "https://wallet.mukoko.com", + "voice": "Trustworthy, precise, secure" + }, + { + "description": "Personalised content feed and agentic dashboard.", + "language": "English", + "meaning": "Pulse", + "mineral": "tanzanite", + "name": "pulse", + "role": "Feed and Mukoko Home", + "url": "https://pulse.mukoko.com", + "voice": "Personal, adaptive, ambient" + }, + { + "description": "Health info, telemedicine booking, medication reminders. Connected to Nyuchi Medical.", + "language": "English", + "meaning": "Health", + "mineral": "malachite", + "name": "health", + "role": "Wellness and telemedicine", + "url": "https://health.mukoko.com", + "voice": "Caring, accurate, private" + }, + { + "description": "Community channels — WhatsApp groups, Discord servers for Africa.", + "language": "English", + "meaning": "Circles", + "mineral": "terracotta", + "name": "circles", + "role": "Community messaging", + "url": "https://circles.mukoko.com", + "voice": "Inclusive, moderated, community-driven" + } + ], + "experimental": [ + { + "containerDark": "#352721", + "containerLight": "#EBDFDB", + "cssVar": "--color-ember", + "darkHex": "#DA8766", + "heptagonIndex": 0, + "hex": "#DA8766", + "lightHex": "#843D20", + "name": "ember", + "onContainerDark": "#EBA68A", + "onContainerLight": "#7A3115", + "uiDark": "#BB562D", + "uiLight": "#CD5F33" + }, + { + "containerDark": "#333521", + "containerLight": "#E9EBDB", + "cssVar": "--color-acacia", + "darkHex": "#93A528", + "heptagonIndex": 1, + "hex": "#93A528", + "lightHex": "#4D5615", + "name": "acacia", + "onContainerDark": "#B6CE23", + "onContainerLight": "#48510E", + "uiDark": "#768420", + "uiLight": "#7E8C22" + }, + { + "containerDark": "#213521", + "containerLight": "#DBEBDB", + "cssVar": "--color-fern", + "darkHex": "#2CB42B", + "heptagonIndex": 2, + "hex": "#2CB42B", + "lightHex": "#175E17", + "name": "fern", + "onContainerDark": "#28DB28", + "onContainerLight": "#0F570F", + "uiDark": "#228D22", + "uiLight": "#259725" + }, + { + "containerDark": "#213532", + "containerLight": "#DBEBE9", + "cssVar": "--color-lagoon", + "darkHex": "#2AAE9B", + "heptagonIndex": 3, + "hex": "#2AAE9B", + "lightHex": "#165B51", + "name": "lagoon", + "onContainerDark": "#24D6BC", + "onContainerLight": "#0E554B", + "uiDark": "#218A7A", + "uiLight": "#249383" + }, + { + "containerDark": "#212735", + "containerLight": "#DBE0EB", + "cssVar": "--color-storm", + "darkHex": "#7E9BE0", + "heptagonIndex": 4, + "hex": "#7E9BE0", + "lightHex": "#284CA6", + "name": "storm", + "onContainerDark": "#99B2EE", + "onContainerLight": "#1A409B", + "uiDark": "#426CD1", + "uiLight": "#577BD6" + }, + { + "containerDark": "#2D2135", + "containerLight": "#E4DBEB", + "cssVar": "--color-dusk", + "darkHex": "#BA87E2", + "heptagonIndex": 5, + "hex": "#BA87E2", + "lightHex": "#742AAD", + "name": "dusk", + "onContainerDark": "#CC9FEF", + "onContainerLight": "#661B9E", + "uiDark": "#9749D3", + "uiLight": "#A35DD8" + }, + { + "containerDark": "#35212D", + "containerLight": "#EBDBE4", + "cssVar": "--color-protea", + "darkHex": "#DF7BB4", + "heptagonIndex": 6, + "hex": "#DF7BB4", + "lightHex": "#932464", + "name": "protea", + "onContainerDark": "#ED98C9", + "onContainerLight": "#841656", + "uiDark": "#CA3188", + "uiLight": "#D34998" + } + ], + "heritage": [ + { + "cssVar": "--color-indigo", + "darkHex": "#7986CB", + "hex": "#7986CB", + "lightHex": "#4527A0", + "name": "indigo", + "origin": "Indigofera, West Africa textile tradition", + "symbolism": "Dusk, depth, the dyer's craft", + "usage": "Twilight surfaces, deep atmosphere, mini-app moods" + }, + { + "cssVar": "--color-savanna", + "darkHex": "#E5C158", + "hex": "#E5C158", + "lightHex": "#8D6E1A", + "name": "savanna", + "origin": "Sub-Saharan grasslands", + "symbolism": "Sun-dried grass, open land, the dry season", + "usage": "Warm grassland surfaces, daylight atmosphere" + }, + { + "cssVar": "--color-baobab", + "darkHex": "#A1887F", + "hex": "#A1887F", + "lightHex": "#4E342E", + "name": "baobab", + "origin": "Adansonia, across the African continent", + "symbolism": "The tree of life, age, shelter", + "usage": "Earthy surfaces, grounded atmosphere, bark tones" + }, + { + "cssVar": "--color-sunset", + "darkHex": "#FF7043", + "hex": "#FF7043", + "lightHex": "#D84315", + "name": "sunset", + "origin": "The African horizon at dusk", + "symbolism": "Day's end, warmth, the gathering hour", + "usage": "Warm accent surfaces, golden-hour atmosphere" + }, + { + "cssVar": "--color-river", + "darkHex": "#4DD0E1", + "hex": "#4DD0E1", + "lightHex": "#006064", + "name": "river", + "origin": "The great African rivers — Zambezi, Nile, Congo", + "symbolism": "Flow, life, the journey", + "usage": "Cool surfaces, flowing atmosphere, water tones" + }, + { + "cssVar": "--color-hematite", + "darkHex": "#90A4AE", + "hex": "#90A4AE", + "lightHex": "#546E7A", + "name": "hematite", + "origin": "Sishen & Thabazimbi, South Africa", + "symbolism": "Foundation, endurance, the substrate", + "usage": "Neutral anchor, mini-app surfaces, atmosphere" + }, + { + "cssVar": "--color-kalahari", + "darkHex": "#E8D9B5", + "hex": "#E8D9B5", + "lightHex": "#C9B589", + "name": "kalahari", + "origin": "Kalahari & Namib, Southern Africa", + "symbolism": "Openness, space, the light pole", + "usage": "Light anchor, warm backgrounds, mini-app surfaces" + } + ], + "minerals": [ + { + "containerDark": "#001F3F", + "containerLight": "#E3F2FD", + "cssVar": "--color-cobalt", + "darkHex": "#00B0FF", + "hex": "#0047AB", + "lightHex": "#0047AB", + "name": "cobalt", + "onContainerDark": "#B3E5FC", + "onContainerLight": "#002966", + "origin": "Katanga (DRC) and Zambian Copperbelt", + "symbolism": "Digital future, trust, knowledge", + "usage": "Primary blue, links, CTAs" + }, + { + "containerDark": "#1A0033", + "containerLight": "#F3E5F5", + "cssVar": "--color-tanzanite", + "darkHex": "#B388FF", + "hex": "#B388FF", + "lightHex": "#4B0082", + "name": "tanzanite", + "onContainerDark": "#E1BEE7", + "onContainerLight": "#2E004D", + "origin": "Merelani Hills, Tanzania", + "symbolism": "Premium, creativity, connection", + "usage": "Purple accent, brand/logo, social features" + }, + { + "containerDark": "#00251A", + "containerLight": "#E0F2F1", + "cssVar": "--color-malachite", + "darkHex": "#64FFDA", + "hex": "#64FFDA", + "lightHex": "#004D40", + "name": "malachite", + "onContainerDark": "#A7FFEB", + "onContainerLight": "#00332B", + "origin": "Congo Copper Belt", + "symbolism": "Growth, nature, success", + "usage": "Success states, positive actions" + }, + { + "containerDark": "#332200", + "containerLight": "#FFF8E1", + "cssVar": "--color-gold", + "darkHex": "#FFD740", + "hex": "#FFD740", + "lightHex": "#5D4037", + "name": "gold", + "onContainerDark": "#FFECB3", + "onContainerLight": "#3E2723", + "origin": "Ghana, South Africa, Mali", + "symbolism": "Honey, rewards, warmth", + "usage": "Achievements, rewards, highlights" + }, + { + "containerDark": "#3E2817", + "containerLight": "#F5E6D3", + "cssVar": "--color-terracotta", + "darkHex": "#E1B07E", + "hex": "#A0522D", + "lightHex": "#A0522D", + "name": "terracotta", + "onContainerDark": "#F5E6D3", + "onContainerLight": "#5D2906", + "origin": "Pan-African Sahel", + "symbolism": "Earth, community, grounding", + "usage": "Community features, warmth" + }, + { + "containerDark": "#0D1442", + "containerLight": "#E8EAF6", + "cssVar": "--color-sodalite", + "darkHex": "#3D5AFE", + "hex": "#283593", + "lightHex": "#283593", + "name": "sodalite", + "onContainerDark": "#C5CAE9", + "onContainerLight": "#141A5C", + "origin": "Kunene River, Namibia & South Africa", + "symbolism": "Intelligence, depth, reasoning", + "usage": "AI/Shamwari surfaces, deep-reasoning states" + }, + { + "containerDark": "#3A1A0E", + "containerLight": "#FBE4DA", + "cssVar": "--color-copper", + "darkHex": "#FF8A65", + "hex": "#BF5A36", + "lightHex": "#BF5A36", + "name": "copper", + "onContainerDark": "#FFD3C2", + "onContainerLight": "#5C2410", + "origin": "Central African Copperbelt, Zambia & DRC", + "symbolism": "Connection, foundation, stewardship", + "usage": "Bundu ecosystem identity, the commons" + } + ], + "radii": { + "2xl": "17px", + "base": "14px", + "full": "9999px", + "lg": "14px", + "md": "12px", + "sm": "7px", + "system": "Ecosystem numbers: 7, 12, 14, 17. Buttons are always pill (rounded-full).", + "xl": "17px" + }, + "semanticColors": [ + { + "dark": "#001F3F", + "light": "#E3F2FD", + "name": "accent", + "usage": "Hover fills, selected rows, soft highlights. Cobalt container." + }, + { + "dark": "#2A2927", + "light": "#E7E5E0", + "name": "border", + "usage": "Canonical border color — cards, inputs, dividers, tables. Warm stone, not cool gray." + }, + { + "dark": "#B388FF", + "light": "#4B0082", + "name": "brand-accent", + "usage": "Per-app/per-context saturated brand mineral for accent fills and CTAs. Defaults to tanzanite — the Mukoko/Nyuchi brand mineral. Swappable per app or per-event/category (nhimbe). Distinct from the semantic --accent (a pale container for hover/selected states)." + }, + { + "dark": "#3E1818", + "light": "#FDEDED", + "name": "destructive-container", + "usage": "Soft background behind destructive content. Follows mineral container pattern." + }, + { + "dark": "#F2B8B5", + "light": "#B3261E", + "name": "error", + "usage": "Error states, destructive actions" + }, + { + "dark": "#00B0FF", + "light": "#0047AB", + "name": "info", + "usage": "Informational states" + }, + { + "dark": "#100F0E", + "light": "#FFFFFF", + "name": "input", + "usage": "Form input background — same as surface. Pill-shape provides visual distinction." + }, + { + "dark": "#A09C93", + "light": "#55514B", + "name": "neutral", + "usage": "Neutral / inactive status, secondary data series" + }, + { + "dark": "#BA9570", + "light": "#674C32", + "name": "offline", + "usage": "Offline / disconnected state" + }, + { + "dark": "#B388FF", + "light": "#4B0082", + "name": "primary", + "usage": "Primary brand action color — tanzanite (the Mukoko/Nyuchi brand mineral). Maps to --color-tanzanite. Cobalt is the exceptional mineral for links/info only (see the info token) — do not use it as --primary." + }, + { + "dark": "#00B0FF", + "light": "#0047AB", + "name": "ring", + "usage": "Focus ring color — cobalt. 2px width, 2px offset (see brand_meta.accessibility)." + }, + { + "dark": "#64FFDA", + "light": "#004D40", + "name": "success", + "usage": "Success states, positive actions" + }, + { + "dark": "#36ABBA", + "light": "#1C5962", + "name": "syncing", + "usage": "In-progress sync / pending state" + }, + { + "dark": "#FFD866", + "light": "#7A5C00", + "name": "warning", + "usage": "Warning states, caution" + } + ] +}