diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml
new file mode 100644
index 0000000..0544bd8
--- /dev/null
+++ b/.github/workflows/rust.yml
@@ -0,0 +1,80 @@
+name: Rust
+
+# This repo shipped with no CI at all — `.github/` held only banner images — so
+# a change could land unbuilt, and `shared/` could drift from SmooAI/client-shared
+# without anything saying so. It did: SmooAI/client-shared spent weeks serving the
+# pre-f230808 monogram (no inner 'S', no dot) because that fix never crossed.
+
+on:
+ pull_request:
+ branches: [main]
+ push:
+ branches: [main]
+
+env:
+ CARGO_TERM_COLOR: always
+
+jobs:
+ rust:
+ runs-on: ubuntu-latest
+ defaults:
+ run:
+ working-directory: rust
+ steps:
+ - uses: actions/checkout@v4
+ - uses: dtolnay/rust-toolchain@stable
+ with:
+ components: rustfmt, clippy
+
+ - name: Format check
+ run: cargo fmt --check
+
+ # `--all-targets` so the test code is linted too; without it clippy
+ # silently skips everything behind `#[cfg(test)]`.
+ - name: Clippy
+ run: cargo clippy --all-targets -- -D warnings
+
+ # Includes the generated-token cross-check against shared/styles.css,
+ # in both directions.
+ - name: Test
+ run: cargo test
+
+ module-tree:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ # Catches a .rs file on disk that no `mod` declaration reaches, so it
+ # never compiles and no other check can see it.
+ - name: Every .rs file is reachable from the module tree
+ run: python3 scripts/check-module-tree.py
+
+ shared-drift:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ # SmooAI/client-shared owns the design system; this repo carries a copy for
+ # its own consumers. The gate is deliberately ONE-directional — client-shared
+ # is ungated, so a design change lands there first and this repo follows.
+ # A bidirectional gate would deadlock: neither PR could go green until the
+ # other merged.
+ #
+ # Compares git blob SHAs from the GitHub contents API against `git ls-tree`,
+ # so added and deleted files are caught as well as edited ones — no
+ # hand-maintained filename list to fall behind.
+ - name: shared/ must match SmooAI/client-shared
+ run: |
+ set -euo pipefail
+ curl -fsSL "https://api.github.com/repos/SmooAI/client-shared/contents/shared?ref=main" \
+ | python3 -c 'import json,sys; [print(e["sha"], e["name"]) for e in sorted(json.load(sys.stdin), key=lambda e: e["name"])]' \
+ > /tmp/upstream.txt
+ git ls-tree HEAD shared/ --format='%(objectname) %(path)' \
+ | sed 's| shared/| |' | sort -k2 > /tmp/local.txt
+ if ! diff -u --label "SmooAI/client-shared@main" /tmp/upstream.txt --label "this repo" /tmp/local.txt; then
+ echo "::error::shared/ has drifted from SmooAI/client-shared, which owns the design system."
+ echo "Land the change in SmooAI/client-shared first, then copy shared/ across:"
+ echo " for f in \$(git ls-tree --name-only HEAD shared/); do"
+ echo " curl -fsSL \"https://raw.githubusercontent.com/SmooAI/client-shared/main/\$f\" -o \"\$f\""
+ echo " done"
+ exit 1
+ fi
+ echo "✓ shared/ matches SmooAI/client-shared@main"
diff --git a/README.md b/README.md
index b9cc6e3..dbb03d8 100644
--- a/README.md
+++ b/README.md
@@ -32,7 +32,7 @@ This repo is that source of truth:
- [`shared/styles.css`](shared/styles.css) — the canonical OKLCH tokens + base component CSS (~425 lines). **This file is the design system.**
- [`shared/monogram.svg`](shared/monogram.svg) — the smoo monogram, `fill="currentColor"`.
-- [`shared/tokens.json`](shared/tokens.json) — the tokens as plain JSON. *Honest note: no code reads this file today* — it exists so a future binding in any language can import tokens without parsing CSS. The only drift guard that runs is the Rust crate's `tokens_match_css` test.
+- [`shared/tokens.json`](shared/tokens.json) — the tokens as plain JSON, and the **input the Rust constants are generated from**: `rust/build.rs` runs [`shared/tokens_codegen.rs`](shared/tokens_codegen.rs) over it at build time, so a token cannot exist in the design system and be missing from Rust. [`shared/tokens_css_check.rs`](shared/tokens_css_check.rs) then asserts it agrees with `styles.css` in both directions. A future binding in any language reads the same file.
- [`rust/`](rust/) — the `smooai-ui` crate: `include_str!` constants over the shared files, plus a mirrored `tokens::*` module for non-DOM frameworks. Zero dependencies, `no_std`.
```mermaid
@@ -44,10 +44,11 @@ flowchart LR
subgraph SRC["shared/ — canonical source"]
CSS["styles.css
OKLCH tokens + base CSS"]
SVG["monogram.svg"]
- JSON["tokens.json
(no consumer yet)"]
+ JSON["tokens.json
the token source"]
end
CSS -->|"include_str!"| RS["rust/ — smooai-ui crate
STYLES · MONOGRAM_SVG · tokens::*"]
SVG -->|"include_str!"| RS
+ JSON -->|"build.rs codegen"| RS
RS -->|"git dependency"| BLUE["smooblue
(Dioxus desktop)"]
CSS -.->|"planned bindings"| FUT["TS · .NET · Python · Go"]
@@ -124,15 +125,19 @@ rsx! {
### 🧪 Drift detection
-The Rust crate ships tests that fail if the mirrored constants and `shared/styles.css` ever diverge, or if a public BEM class is renamed out from under consumers:
+The `tokens` constants are generated from `shared/tokens.json`, so they cannot fall behind it. What still needs checking is the CSS, and the check runs in **both** directions:
```bash
cd rust && cargo test
-# tokens_match_css — every tokens::* value must appear in the CSS
-# semantic_classes_exist — .btn, .btn--primary, .card, .rail, .brand-badge, …
+# tokens_match_css — each token equals the RESOLVED value of its custom
+# property in :root (var() references followed), not
+# merely "appears somewhere in the file"
+# css_colors_are_all_tokens — every colour :root declares has a token, so a new
+# colour can't reach the CSS and no language binding
+# semantic_classes_exist — .btn, .btn--primary, .card, .rail, .brand-badge, …
```
-There is no CI in this repo yet — run the test locally before merging a token change.
+CI (`.github/workflows/rust.yml`) runs `cargo fmt --check`, `clippy --all-targets -D warnings`, the tests, a module-tree check (no `.rs` file unreachable from a `mod` declaration), and the `shared/` drift gate below.
---
@@ -163,13 +168,25 @@ The honest per-language picture — one binding exists, the rest are direction,
## Relationship to client-shared
-[`SmooAI/client-shared`](https://github.com/SmooAI/client-shared) carries this repo's `shared/` files and `ui` surface **byte-for-byte** as its `ui` module, alongside `auth` (Supabase OAuth / M2M / credential storage) — and its README describes it as absorbing and superseding this crate. In practice today:
+**[`SmooAI/client-shared`](https://github.com/SmooAI/client-shared) owns the design system. This repo carries a gated copy.**
-- **This repo** is the design-system-only home; smooblue consumes `smooai-ui` from here.
-- **client-shared** is the "everything a Smoo Rust client needs" home; the [`th` CLI](https://github.com/SmooAI/smooth) consumes `smooai-client-shared` from there.
-- Neither crate is on crates.io; both are consumed as git dependencies. A change to `shared/styles.css` currently has to be mirrored in both repos by hand.
+client-shared declares itself this crate's successor and is what the [`th` CLI](https://github.com/SmooAI/smooth) ships in production. This repo keeps `shared/` for its own consumers (`observability-studio`, smooblue), and CI **fails if the two diverge** — `shared-drift` compares every blob in `shared/` against `SmooAI/client-shared@main`.
-If you need only the design system, either works — the `ui` surface is identical (`smooai_ui::STYLES` ⇄ `smooai_client_shared::ui::STYLES`).
+The gate is deliberately **one-directional**: client-shared is ungated, so a design change lands there first and this repo follows. A bidirectional gate would deadlock, with neither repo's PR able to go green until the other merged.
+
+Why a gate and not a cargo dependency on client-shared? Both crates are git dependencies rather than crates.io publishes, so depending across would put two independently rev-pinned git deps in one graph for any consumer that wants both. The gate closes the silent-divergence hole without the coupling.
+
+> This is not hypothetical. The two copies **had** already diverged: the monogram fix in `f230808` ("restore the inner 'S' curve and the dot") never crossed, so client-shared served a monogram with no S and no dot, and `styles.css` lost the whole `.input` family. Nothing was red. That is the defect this gate exists to prevent.
+
+To sync after a change lands upstream:
+
+```bash
+for f in $(git ls-tree --name-only HEAD shared/); do
+ curl -fsSL "https://raw.githubusercontent.com/SmooAI/client-shared/main/$f" -o "$f"
+done
+```
+
+If you need only the design system, either crate works — the `ui` surface is identical (`smooai_ui::STYLES` ⇄ `smooai_client_shared::ui::STYLES`).
## Versioning
@@ -190,7 +207,7 @@ Per-language packages share the same semver line so consumers can correlate vers
## 🤝 Contributing
-PRs welcome. Keep this surface narrow — only add a token or class when at least two apps need it. Run `cargo test` in `rust/` to validate the Rust constants match `shared/styles.css`; future language bindings should add an equivalent drift-detector test.
+PRs welcome. Keep this surface narrow — only add a token or class when at least two apps need it. **Design-system changes land in [`SmooAI/client-shared`](https://github.com/SmooAI/client-shared) first**; this repo's `shared/` is a gated copy and CI rejects a divergent one. Add tokens to `shared/tokens.json` (the Rust constants generate from it) rather than to the CSS alone.
## 📄 License
diff --git a/rust/Cargo.toml b/rust/Cargo.toml
index 0f9d876..db5734d 100644
--- a/rust/Cargo.toml
+++ b/rust/Cargo.toml
@@ -16,12 +16,22 @@ authors = ["SmooAI "]
include = [
"Cargo.toml",
"README.md",
+ "build.rs",
"src/**/*.rs",
"../shared/styles.css",
"../shared/monogram.svg",
+ "../shared/tokens.json",
+ "../shared/tokens_codegen.rs",
+ "../shared/tokens_css_check.rs",
"../LICENSE",
]
[dependencies]
# Deliberately empty — this crate is a `pub const &'static str` carrier so
# consumers don't inherit a UI-framework version pin.
+
+[build-dependencies]
+# Build-script only: parses shared/tokens.json to generate the `tokens`
+# module. Build dependencies are host-side and never reach consumers, so the
+# runtime tree stays empty and `no_std`.
+serde_json = "1"
diff --git a/rust/build.rs b/rust/build.rs
new file mode 100644
index 0000000..a44518c
--- /dev/null
+++ b/rust/build.rs
@@ -0,0 +1,17 @@
+//! Generate the `tokens` module from `shared/tokens.json`.
+//!
+//! The generator itself lives in `shared/tokens_codegen.rs` so SmooAI/ui and
+//! SmooAI/client-shared generate identically from the identical input — the
+//! `shared/**` drift gate keeps those files byte-for-byte equal. This build
+//! script is only the shim that feeds it.
+
+include!("../shared/tokens_codegen.rs");
+
+fn main() {
+ println!("cargo:rerun-if-changed=../shared/tokens.json");
+ println!("cargo:rerun-if-changed=../shared/tokens_codegen.rs");
+
+ let json = std::fs::read_to_string("../shared/tokens.json").expect("read shared/tokens.json");
+ let out = std::path::Path::new(&std::env::var("OUT_DIR").expect("OUT_DIR")).join("tokens.rs");
+ std::fs::write(out, generate_tokens_rs(&json)).expect("write tokens.rs");
+}
diff --git a/rust/src/lib.rs b/rust/src/lib.rs
index 8a298c1..6e19d08 100644
--- a/rust/src/lib.rs
+++ b/rust/src/lib.rs
@@ -64,52 +64,23 @@ pub const STYLES: &str = include_str!("../../shared/styles.css");
/// ```
pub const MONOGRAM_SVG: &str = include_str!("../../shared/monogram.svg");
-/// Brand + semantic token *values* as `&'static str`, for code paths that
-/// need a colour outside of CSS (custom-painted egui widgets, native menu
-/// chrome, chart libraries, etc.). The single source of truth is the CSS in
-/// [`STYLES`]; these constants are mirrored from it and validated by the
-/// `tokens_match_css` test in this crate.
+/// Brand + semantic token *values*, for code paths that need a colour,
+/// radius, spacing step, or font stack outside of CSS (custom-painted egui
+/// widgets, native menu chrome, chart libraries, etc.).
+///
+/// **Generated** at build time from
+/// [`shared/tokens.json`](https://github.com/SmooAI/ui/blob/main/shared/tokens.json)
+/// by `shared/tokens_codegen.rs` — nobody hand-writes these, so a token cannot
+/// exist in the design system and be missing here. `shared/tokens_css_check.rs`
+/// then asserts each one equals the resolved value of its custom property in
+/// [`STYLES`], and that every colour the CSS declares has a token.
pub mod tokens {
- /// Brand orange — primary CTA stop, accent.
- pub const SMOOAI_ORANGE: &str = "oklch(0.769 0.164 71)";
- /// Brand red — destructive, "like" affordance.
- pub const SMOOAI_RED: &str = "oklch(0.712 0.181 22.4)";
- /// Brand green — confirm / "repost" affordance / focus ring.
- pub const SMOOAI_GREEN: &str = "oklch(0.657 0.112 194.8)";
- /// Lightest brand blue — reply hover.
- pub const SMOOAI_BLUE_300: &str = "oklch(0.803 0.074 230.9)";
- /// Mid brand blue.
- pub const SMOOAI_BLUE_400: &str = "oklch(0.725 0.102 233.4)";
- /// Active nav-rail item background.
- pub const SMOOAI_BLUE_500: &str = "oklch(0.55 0.13 233)";
- /// Brand dark blue — base background.
- pub const SMOOAI_DARK_BLUE: &str = "oklch(0.13 0.043 265.1)";
- /// Sidebar tint.
- pub const SMOOAI_DARK_BLUE_850: &str = "oklch(0.177 0.074 266)";
- /// Hover on rail items.
- pub const SMOOAI_DARK_BLUE_700: &str = "oklch(0.303 0.154 265.8)";
- /// Brand white.
- pub const SMOOAI_WHITE: &str = "oklch(0.984 0.003 247.9)";
- /// Muted text.
- pub const SMOOAI_GRAY_400: &str = "oklch(0.715 0 89.9)";
-
- /// Default page background.
- pub const BACKGROUND: &str = "oklch(0.145 0.014 265)";
- /// Default text color.
- pub const FOREGROUND: &str = SMOOAI_WHITE;
- /// Card surface background.
- pub const CARD: &str = "oklch(0.205 0.015 265)";
- /// Border / divider.
- pub const BORDER: &str = "oklch(0.3 0.008 260)";
+ include!(concat!(env!("OUT_DIR"), "/tokens.rs"));
- /// Signature brand gradient — gradient-as-string for `background: …;`.
- /// Note that CSS doesn't accept gradients in inline `color:` — this is
- /// for `background` and SVG paint only.
- pub const GRADIENT_BRAND: &str =
- "linear-gradient(135deg, oklch(0.769 0.164 71) 0%, oklch(0.712 0.181 22.4) 100%)";
-
- /// Default radius for cards + buttons (matches `--radius` in CSS).
- pub const RADIUS_PX: u16 = 10;
+ /// The default corner radius, as used by cards and buttons. Retained as an
+ /// alias of [`RADIUS_MD_PX`] so consumers pinned to the pre-codegen name
+ /// keep compiling.
+ pub const RADIUS_PX: u16 = RADIUS_MD_PX;
}
#[cfg(test)]
@@ -133,42 +104,29 @@ mod tests {
assert!(MONOGRAM_SVG.contains("fill=\"currentColor\""));
}
- /// Validate that every token value in the `tokens` module is also present
- /// in the CSS, so the two sources of truth can't silently drift.
- #[test]
- fn tokens_match_css() {
- let css = STYLES;
- for (name, value) in [
- ("SMOOAI_ORANGE", tokens::SMOOAI_ORANGE),
- ("SMOOAI_RED", tokens::SMOOAI_RED),
- ("SMOOAI_GREEN", tokens::SMOOAI_GREEN),
- ("SMOOAI_BLUE_300", tokens::SMOOAI_BLUE_300),
- ("SMOOAI_BLUE_400", tokens::SMOOAI_BLUE_400),
- ("SMOOAI_BLUE_500", tokens::SMOOAI_BLUE_500),
- ("SMOOAI_DARK_BLUE", tokens::SMOOAI_DARK_BLUE),
- ("SMOOAI_DARK_BLUE_850", tokens::SMOOAI_DARK_BLUE_850),
- ("SMOOAI_DARK_BLUE_700", tokens::SMOOAI_DARK_BLUE_700),
- ("SMOOAI_WHITE", tokens::SMOOAI_WHITE),
- ("SMOOAI_GRAY_400", tokens::SMOOAI_GRAY_400),
- ("BACKGROUND", tokens::BACKGROUND),
- ("CARD", tokens::CARD),
- ("BORDER", tokens::BORDER),
- ] {
- assert!(
- css.contains(value),
- "token {name} = {value:?} not found in shared/styles.css — Rust \
- constants and CSS have drifted",
- );
- }
- }
+ // The token <-> CSS cross-check (both directions) lives in `shared/`, so
+ // SmooAI/ui and SmooAI/client-shared run the identical assertions.
+ include!("../../shared/tokens_css_check.rs");
#[test]
fn semantic_classes_exist() {
// Smoke check the public BEM classes consumers reach for. If anyone
// renames `.btn--primary` they'll break consumers, so this test fails.
- for cls in [".btn", ".btn--primary", ".btn--ghost", ".card", ".fab",
- ".modal__sheet", ".rail", ".rail__btn", ".brand-badge",
- ".input", ".input--lg", ".input-error", ".input-hint"] {
+ for cls in [
+ ".btn",
+ ".btn--primary",
+ ".btn--ghost",
+ ".card",
+ ".fab",
+ ".modal__sheet",
+ ".rail",
+ ".rail__btn",
+ ".brand-badge",
+ ".input",
+ ".input--lg",
+ ".input-error",
+ ".input-hint",
+ ] {
assert!(STYLES.contains(cls), "missing class {cls}");
}
}
diff --git a/scripts/check-module-tree.py b/scripts/check-module-tree.py
new file mode 100644
index 0000000..8cb8024
--- /dev/null
+++ b/scripts/check-module-tree.py
@@ -0,0 +1,82 @@
+#!/usr/bin/env python3
+"""Fail if any .rs file under rust/src/ is unreachable from the module tree.
+
+An orphaned file compiles to nothing and is invisible to every other check:
+`cargo build`, `clippy`, `cargo test` and code coverage all skip it, because as
+far as rustc is concerned it does not exist. Reviewers see a diff full of real
+code and assume it ships.
+
+SmooAI/client-shared shipped exactly that: 174 lines of auth token-refresh code
+in `rust/src/auth/refresh.rs` that `auth/mod.rs` never declared, so it never
+compiled once (fixed upstream in a21c06b). This is the check that would have
+caught it the same day.
+
+Deliberately textual rather than a real parse: a `mod foo;` behind a `#[cfg(...)]`
+still counts as declared, which is what we want — the question is whether the
+file is wired in at all, not whether it is wired in for every feature set.
+"""
+
+import re
+import sys
+from pathlib import Path
+
+SRC = Path(__file__).resolve().parent.parent / "rust" / "src"
+
+
+def declares(parent: Path, name: str) -> bool:
+ """Whether `parent` contains a `mod ;` / `mod { … }` declaration."""
+ if not parent.is_file():
+ return False
+ return re.search(rf"\bmod\s+{re.escape(name)}\s*[;{{]", parent.read_text()) is not None
+
+
+ROOTS = [SRC / "lib.rs", SRC / "main.rs"]
+
+
+def module_of(rs: Path) -> tuple[str, list[Path]]:
+ """The module name `rs` defines, and the file(s) that could declare it."""
+ if rs.name == "mod.rs":
+ # `src/a/mod.rs` defines module `a`, declared one level up.
+ name, parent_dir = rs.parent.name, rs.parent.parent
+ else:
+ # `src/a/b.rs` defines module `b`, declared in `src/a`.
+ name, parent_dir = rs.stem, rs.parent
+
+ if parent_dir == SRC:
+ return name, ROOTS
+ # Either module style: `src/a/mod.rs` or the 2018-edition `src/a.rs`.
+ return name, [parent_dir / "mod.rs", parent_dir.with_suffix(".rs")]
+
+
+def main() -> int:
+ if not SRC.is_dir():
+ print(f"no {SRC} — nothing to check")
+ return 0
+
+ repo = SRC.parent.parent
+ orphans = []
+
+ for rs in sorted(SRC.rglob("*.rs")):
+ if rs in ROOTS:
+ continue
+ name, candidates = module_of(rs)
+ if not any(declares(c, name) for c in candidates):
+ where = " or ".join(str(c.relative_to(repo)) for c in candidates)
+ orphans.append(f" {rs.relative_to(repo)} — no `mod {name};` in {where}")
+
+ if orphans:
+ print("Orphaned module files (present on disk, never compiled):", file=sys.stderr)
+ print("\n".join(orphans), file=sys.stderr)
+ print(
+ "\nDeclare each with `mod ;` / `pub mod ;` in its parent, "
+ "or delete the file.",
+ file=sys.stderr,
+ )
+ return 1
+
+ print(f"✓ every .rs file under {SRC.relative_to(repo)} is in the module tree")
+ return 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/shared/tokens.json b/shared/tokens.json
index be5fa2f..8bcc72b 100644
--- a/shared/tokens.json
+++ b/shared/tokens.json
@@ -1,55 +1,140 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
- "name": "@smooai/ui design tokens",
- "version": "0.1.0",
- "description": "Language-agnostic source of truth for SmooAI brand + semantic tokens. Per-language bindings (smooai-ui Rust crate, @smooai/ui npm package, etc.) mirror these values and assert at test time that they match shared/styles.css.",
+ "name": "SmooAI design tokens",
+ "description": "Language-agnostic source of truth for SmooAI brand + semantic tokens. This file is not documentation — it is CODE INPUT: the Rust crate's `tokens` module is generated from it at build time (rust/build.rs + shared/tokens_codegen.rs), and shared/tokens_css_check.rs asserts every value here matches the corresponding custom property in shared/styles.css, in both directions. Add a token here and the bindings follow; add one only to the CSS and the check fails.",
+ "conventions": {
+ "cssVar": "color.brand. -> --color-smooai-; color.semantic. -> --; gradient. -> --gradient-; radius. -> --radius-, except radius.md -> --radius; space. -> --space-; font. -> --font-. Underscores become hyphens.",
+ "rustConst": "color.brand. -> SMOOAI_; color.semantic. -> ; gradient. -> GRADIENT_; radius. -> RADIUS__PX (u16); space. -> SPACE__PX (u16); font. -> FONT_.",
+ "ref": "A token whose value is another token's, by dotted path. Generates a Rust alias and is expected to be `var(--other)` in the CSS.",
+ "doc": "Required on every token — it becomes the Rust doc comment, and the crate denies missing docs."
+ },
"color": {
"brand": {
- "orange": { "oklch": "oklch(0.769 0.164 71)", "hex": "#f49f0a" },
- "red": { "oklch": "oklch(0.712 0.181 22.4)", "hex": "#ff6b6c" },
- "green": { "oklch": "oklch(0.657 0.112 194.8)", "hex": "#00a6a6" },
- "blue_300": { "oklch": "oklch(0.803 0.074 230.9)" },
- "blue_400": { "oklch": "oklch(0.725 0.102 233.4)" },
- "blue_500": { "oklch": "oklch(0.55 0.13 233)" },
- "dark_blue": { "oklch": "oklch(0.13 0.043 265.1)", "hex": "#020618" },
- "dark_blue_700": { "oklch": "oklch(0.303 0.154 265.8)" },
- "dark_blue_850": { "oklch": "oklch(0.177 0.074 266)" },
- "white": { "oklch": "oklch(0.984 0.003 247.9)" },
- "gray_400": { "oklch": "oklch(0.715 0 89.9)" }
+ "orange": {
+ "oklch": "oklch(0.769 0.164 71)",
+ "hex": "#f49f0a",
+ "doc": "Brand orange — primary CTA stop, accent."
+ },
+ "red": {
+ "oklch": "oklch(0.712 0.181 22.4)",
+ "hex": "#ff6b6c",
+ "doc": "Brand red — destructive, \"like\" affordance."
+ },
+ "green": {
+ "oklch": "oklch(0.657 0.112 194.8)",
+ "hex": "#00a6a6",
+ "doc": "Brand green — confirm / \"repost\" affordance / focus ring."
+ },
+ "blue_300": {
+ "oklch": "oklch(0.803 0.074 230.9)",
+ "doc": "Lightest brand blue — reply hover."
+ },
+ "blue_400": {
+ "oklch": "oklch(0.725 0.102 233.4)",
+ "doc": "Mid brand blue."
+ },
+ "blue_500": {
+ "oklch": "oklch(0.55 0.13 233)",
+ "doc": "Active nav-rail item background."
+ },
+ "dark_blue": {
+ "oklch": "oklch(0.13 0.043 265.1)",
+ "hex": "#020618",
+ "doc": "Brand dark blue — base background."
+ },
+ "dark_blue_700": {
+ "oklch": "oklch(0.303 0.154 265.8)",
+ "doc": "Hover on rail items."
+ },
+ "dark_blue_850": {
+ "oklch": "oklch(0.177 0.074 266)",
+ "doc": "Sidebar tint."
+ },
+ "white": {
+ "oklch": "oklch(0.984 0.003 247.9)",
+ "doc": "Brand white."
+ },
+ "gray_400": {
+ "oklch": "oklch(0.715 0 89.9)",
+ "doc": "Muted text."
+ }
},
"semantic": {
- "background": { "oklch": "oklch(0.145 0.014 265)" },
- "foreground": { "ref": "color.brand.white" },
- "card": { "oklch": "oklch(0.205 0.015 265)" },
- "muted": { "oklch": "oklch(0.27 0.01 260)" },
- "muted_foreground": { "ref": "color.brand.gray_400" },
- "accent": { "ref": "color.brand.orange" },
- "border": { "oklch": "oklch(0.3 0.008 260)" },
- "input": { "oklch": "oklch(0.38 0.006 260)" },
- "ring": { "ref": "color.brand.green" },
- "sidebar": { "ref": "color.brand.dark_blue_850" }
+ "background": {
+ "oklch": "oklch(0.145 0.014 265)",
+ "doc": "Default page background."
+ },
+ "foreground": {
+ "ref": "color.brand.white",
+ "doc": "Default text color."
+ },
+ "card": {
+ "oklch": "oklch(0.205 0.015 265)",
+ "doc": "Card surface background."
+ },
+ "muted": {
+ "oklch": "oklch(0.27 0.01 260)",
+ "doc": "Muted surface — inactive chips, disabled fills."
+ },
+ "muted_foreground": {
+ "ref": "color.brand.gray_400",
+ "doc": "Muted text on any surface."
+ },
+ "accent": {
+ "ref": "color.brand.orange",
+ "doc": "Accent — highlights, active affordances."
+ },
+ "border": {
+ "oklch": "oklch(0.3 0.008 260)",
+ "doc": "Border / divider."
+ },
+ "input": {
+ "oklch": "oklch(0.38 0.006 260)",
+ "doc": "Text-input / textarea fill."
+ },
+ "ring": {
+ "oklch": "oklch(0.657 0.112 194.8)",
+ "doc": "Focus ring."
+ },
+ "sidebar": {
+ "ref": "color.brand.dark_blue_850",
+ "doc": "Sidebar / nav-rail surface."
+ },
+ "sidebar_border": {
+ "ref": "color.semantic.border",
+ "doc": "Sidebar edge — split from `border` so a rail can be retinted alone."
+ }
}
},
"gradient": {
- "brand": "linear-gradient(135deg, oklch(0.769 0.164 71) 0%, oklch(0.712 0.181 22.4) 100%)"
+ "brand": {
+ "value": "linear-gradient(135deg, oklch(0.769 0.164 71) 0%, oklch(0.712 0.181 22.4) 100%)",
+ "doc": "Signature brand gradient — gradient-as-string for `background: …;`. CSS does not accept gradients in inline `color:`; this is for `background` and SVG paint only."
+ }
},
"radius": {
- "sm": "6px",
- "md": "10px",
- "lg": "14px",
- "xl": "18px"
+ "sm": { "px": 6, "doc": "Tight radius — chips, inline controls." },
+ "md": { "px": 10, "doc": "Default radius for cards + buttons (the bare `--radius`)." },
+ "lg": { "px": 14, "doc": "Large radius — panels, sheets." },
+ "xl": { "px": 18, "doc": "Extra-large radius — modals, hero surfaces." }
},
"space": {
- "1": "4px",
- "2": "8px",
- "3": "12px",
- "4": "16px",
- "5": "20px",
- "6": "24px",
- "8": "32px"
+ "1": { "px": 4, "doc": "Spacing step 1 — 4px." },
+ "2": { "px": 8, "doc": "Spacing step 2 — 8px." },
+ "3": { "px": 12, "doc": "Spacing step 3 — 12px." },
+ "4": { "px": 16, "doc": "Spacing step 4 — 16px." },
+ "5": { "px": 20, "doc": "Spacing step 5 — 20px." },
+ "6": { "px": 24, "doc": "Spacing step 6 — 24px." },
+ "8": { "px": 32, "doc": "Spacing step 8 — 32px." }
},
"font": {
- "sans": "-apple-system, BlinkMacSystemFont, \"Inter\", \"Segoe UI\", system-ui, sans-serif",
- "mono": "\"SF Mono\", \"JetBrains Mono\", \"Menlo\", \"Consolas\", ui-monospace, monospace"
+ "sans": {
+ "value": "-apple-system, BlinkMacSystemFont, \"Inter\", \"Segoe UI\", system-ui, sans-serif",
+ "doc": "UI font stack."
+ },
+ "mono": {
+ "value": "\"SF Mono\", \"JetBrains Mono\", \"Menlo\", \"Consolas\", ui-monospace, monospace",
+ "doc": "Monospace font stack — code, logs, IDs."
+ }
}
}
diff --git a/shared/tokens_codegen.rs b/shared/tokens_codegen.rs
new file mode 100644
index 0000000..7f34cbf
--- /dev/null
+++ b/shared/tokens_codegen.rs
@@ -0,0 +1,234 @@
+// Generator for the Rust `tokens` module, from `shared/tokens.json`.
+//
+// This file lives in `shared/` — not in either crate's `src/` — because both
+// SmooAI/ui and SmooAI/client-shared carry the same design system, and the
+// `shared/**` drift gate keeps them byte-identical. Generating the constants
+// rather than hand-mirroring them is the whole point: a token can no longer
+// exist in `tokens.json` and be missing from Rust, because nobody types the
+// constants at all.
+//
+// `include!`d by each crate's `rust/build.rs`. Host-side only (build script),
+// so `std` and `serde_json` are available here even though the crate itself
+// is `no_std` with no runtime dependencies.
+
+use serde_json::Value;
+use std::fmt::Write as _;
+
+/// A token as it lands in generated Rust: the constant name, the CSS custom
+/// property it must agree with, and the rendered Rust literal.
+struct Token {
+ rust: String,
+ css_var: String,
+ /// The Rust expression — a quoted string, an integer, or an alias to
+ /// another constant.
+ expr: String,
+ /// The Rust type. `&str` for strings, `u16` for pixel values.
+ ty: &'static str,
+ /// The value as it should appear in the CSS, once `var(--x)` references
+ /// are resolved. `None` for aliases, which the CSS states as `var(--x)`
+ /// and whose resolved value is the target's — checked via the target.
+ css_value: String,
+ doc: String,
+}
+
+fn screaming(key: &str) -> String {
+ key.to_uppercase().replace('-', "_")
+}
+
+fn kebab(key: &str) -> String {
+ key.replace('_', "-")
+}
+
+fn obj<'a>(root: &'a Value, path: &str) -> &'a serde_json::Map {
+ let mut node = root;
+ for part in path.split('.') {
+ node = node
+ .get(part)
+ .unwrap_or_else(|| panic!("tokens.json: missing section {path:?}"));
+ }
+ node.as_object()
+ .unwrap_or_else(|| panic!("tokens.json: section {path:?} is not an object"))
+}
+
+fn require_str<'a>(entry: &'a Value, field: &str, path: &str) -> &'a str {
+ entry
+ .get(field)
+ .and_then(Value::as_str)
+ .unwrap_or_else(|| panic!("tokens.json: {path} is missing a string {field:?}"))
+}
+
+/// Resolve a dotted `ref` path (e.g. `color.brand.white`) to the constant name
+/// it maps to under the naming conventions.
+fn ref_to_const(path: &str) -> String {
+ let (section, key) = path
+ .rsplit_once('.')
+ .unwrap_or_else(|| panic!("tokens.json: malformed ref {path:?}"));
+ match section {
+ "color.brand" => format!("SMOOAI_{}", screaming(key)),
+ "color.semantic" => screaming(key),
+ other => panic!("tokens.json: ref into unsupported section {other:?}"),
+ }
+}
+
+fn collect(root: &Value) -> Vec {
+ let mut tokens = Vec::new();
+
+ // Colours. Brand tokens are literal OKLCH; semantic tokens are either
+ // literal or a `ref` to another token (rendered as a Rust alias and
+ // expected to be `var(--other)` in the CSS).
+ for (section, prefix) in [("color.brand", "SMOOAI_"), ("color.semantic", "")] {
+ for (key, entry) in obj(root, section) {
+ let path = format!("{section}.{key}");
+ let doc = require_str(entry, "doc", &path).to_string();
+ let rust = format!("{prefix}{}", screaming(key));
+ let css_var = if section == "color.brand" {
+ format!("--color-smooai-{}", kebab(key))
+ } else {
+ format!("--{}", kebab(key))
+ };
+ let (expr, css_value) = match entry.get("ref").and_then(Value::as_str) {
+ Some(target) => {
+ let alias = ref_to_const(target);
+ // A ref's CSS value is its target's, so record the target's
+ // literal for the cross-check by resolving it here.
+ let (t_section, t_key) = target.rsplit_once('.').unwrap();
+ let literal = obj(root, t_section)
+ .get(t_key)
+ .and_then(|t| t.get("oklch"))
+ .and_then(Value::as_str)
+ .map(str::to_string)
+ .unwrap_or_else(|| {
+ // A ref to a ref — resolve one more hop.
+ let next = obj(root, t_section)
+ .get(t_key)
+ .and_then(|t| t.get("ref"))
+ .and_then(Value::as_str)
+ .unwrap_or_else(|| {
+ panic!("tokens.json: {target:?} is neither oklch nor ref")
+ });
+ let (n_section, n_key) = next.rsplit_once('.').unwrap();
+ require_str(&obj(root, n_section)[n_key], "oklch", next).to_string()
+ });
+ (alias, literal)
+ }
+ None => {
+ let literal = require_str(entry, "oklch", &path).to_string();
+ (format!("{literal:?}"), literal)
+ }
+ };
+ tokens.push(Token {
+ rust,
+ css_var,
+ expr,
+ ty: "&str",
+ css_value,
+ doc,
+ });
+ }
+ }
+
+ for (key, entry) in obj(root, "gradient") {
+ let path = format!("gradient.{key}");
+ let value = require_str(entry, "value", &path).to_string();
+ tokens.push(Token {
+ rust: format!("GRADIENT_{}", screaming(key)),
+ css_var: format!("--gradient-{}", kebab(key)),
+ expr: format!("{value:?}"),
+ ty: "&str",
+ css_value: value,
+ doc: require_str(entry, "doc", &path).to_string(),
+ });
+ }
+
+ for (key, entry) in obj(root, "radius") {
+ let path = format!("radius.{key}");
+ let px = entry
+ .get("px")
+ .and_then(Value::as_u64)
+ .unwrap_or_else(|| panic!("tokens.json: {path} is missing an integer \"px\""));
+ tokens.push(Token {
+ rust: format!("RADIUS_{}_PX", screaming(key)),
+ // The default radius is the bare `--radius`, not `--radius-md`.
+ css_var: if key == "md" {
+ "--radius".to_string()
+ } else {
+ format!("--radius-{}", kebab(key))
+ },
+ expr: px.to_string(),
+ ty: "u16",
+ css_value: format!("{px}px"),
+ doc: require_str(entry, "doc", &path).to_string(),
+ });
+ }
+
+ for (key, entry) in obj(root, "space") {
+ let path = format!("space.{key}");
+ let px = entry
+ .get("px")
+ .and_then(Value::as_u64)
+ .unwrap_or_else(|| panic!("tokens.json: {path} is missing an integer \"px\""));
+ tokens.push(Token {
+ rust: format!("SPACE_{}_PX", screaming(key)),
+ css_var: format!("--space-{}", kebab(key)),
+ expr: px.to_string(),
+ ty: "u16",
+ css_value: format!("{px}px"),
+ doc: require_str(entry, "doc", &path).to_string(),
+ });
+ }
+
+ for (key, entry) in obj(root, "font") {
+ let path = format!("font.{key}");
+ let value = require_str(entry, "value", &path).to_string();
+ tokens.push(Token {
+ rust: format!("FONT_{}", screaming(key)),
+ css_var: format!("--font-{}", kebab(key)),
+ expr: format!("{value:?}"),
+ ty: "&str",
+ css_value: value,
+ doc: require_str(entry, "doc", &path).to_string(),
+ });
+ }
+
+ tokens
+}
+
+/// Render `shared/tokens.json` as the body of the crate's `tokens` module.
+///
+/// Emits one `pub const` per token plus `ALL`, the complete
+/// `(rust_name, css_var, css_value)` table that `shared/tokens_css_check.rs`
+/// walks. `ALL` is generated alongside the constants, so it cannot fall behind
+/// them — that is what makes the "CSS has a token Rust omitted" case
+/// detectable, which the old hand-written list could not do.
+pub fn generate_tokens_rs(json: &str) -> String {
+ let root: Value = serde_json::from_str(json).expect("shared/tokens.json is not valid JSON");
+ let tokens = collect(&root);
+
+ let mut out = String::from(
+ "// @generated by shared/tokens_codegen.rs from shared/tokens.json — do not edit.\n\
+ // Add or change a token in shared/tokens.json; this file follows.\n\n",
+ );
+
+ for t in &tokens {
+ let _ = writeln!(out, "/// {}", t.doc);
+ let _ = writeln!(out, "pub const {}: {} = {};", t.rust, t.ty, t.expr);
+ }
+
+ let _ = writeln!(
+ out,
+ "\n/// Every generated token as `(rust_name, css_var, css_value)`, for the\n\
+ /// `shared/tokens_css_check.rs` cross-check against `shared/styles.css`.\n\
+ /// Generated with the constants above, so it is complete by construction.\n\
+ pub const ALL: &[(&str, &str, &str)] = &["
+ );
+ for t in &tokens {
+ let _ = writeln!(
+ out,
+ " ({:?}, {:?}, {:?}),",
+ t.rust, t.css_var, t.css_value
+ );
+ }
+ let _ = writeln!(out, "];");
+
+ out
+}
diff --git a/shared/tokens_css_check.rs b/shared/tokens_css_check.rs
new file mode 100644
index 0000000..0c78cf3
--- /dev/null
+++ b/shared/tokens_css_check.rs
@@ -0,0 +1,140 @@
+// Cross-check the generated `tokens` constants against `shared/styles.css`.
+//
+// `include!`d from each crate's `#[cfg(test)] mod tests`. Lives in `shared/`
+// so SmooAI/ui and SmooAI/client-shared run the identical check — the
+// `shared/**` drift gate keeps the copies byte-identical.
+//
+// What this replaces: the old `tokens_match_css` asserted only that each
+// hand-written Rust constant appeared *somewhere* in the CSS as a substring.
+// That passes even when the constant is attached to the wrong custom property,
+// and it is structurally blind to a token the CSS declares and Rust omits —
+// nothing walks the CSS. This parses `:root`, resolves `var(--x)` references,
+// and checks BOTH directions.
+//
+// Scope of the completeness half: every **colour-valued** custom property (one
+// that resolves to `oklch(...)`) must be a generated token. Non-colour geometry
+// vars that `tokens.json` does not claim to cover (`--rail-width`,
+// `--status-bar-height`) are deliberately out of scope.
+
+// The `ui` slice compiles `no_std`, so `String`/`Vec`/`format!` are not in the
+// prelude here. The enclosing test module has already done `extern crate std`.
+use std::{
+ string::{String, ToString},
+ vec::Vec,
+};
+
+/// Strip `/* … */` comments so a trailing hex annotation can't be read as value text.
+fn strip_css_comments(css: &str) -> String {
+ let mut out = String::with_capacity(css.len());
+ let mut rest = css;
+ while let Some(start) = rest.find("/*") {
+ out.push_str(&rest[..start]);
+ match rest[start..].find("*/") {
+ Some(end) => rest = &rest[start + end + 2..],
+ None => return out,
+ }
+ }
+ out.push_str(rest);
+ out
+}
+
+/// Collapse every run of whitespace to a single space and trim, so a
+/// multi-line CSS value compares equal to its single-line JSON twin.
+fn normalize(value: &str) -> String {
+ value.split_whitespace().collect::>().join(" ")
+}
+
+/// Parse the `:root { … }` block into `(--custom-property, raw value)` pairs.
+fn parse_root_vars(css: &str) -> Vec<(String, String)> {
+ let css = strip_css_comments(css);
+ let start = css.find(":root").expect("shared/styles.css has no :root block");
+ let body_start = start + css[start..].find('{').expect(":root has no opening brace") + 1;
+ let body_len = css[body_start..]
+ .find('}')
+ .expect(":root has no closing brace");
+ let body = &css[body_start..body_start + body_len];
+
+ body.split(';')
+ .filter_map(|decl| {
+ let (name, value) = decl.split_once(':')?;
+ let name = name.trim();
+ name.starts_with("--")
+ .then(|| (name.to_string(), normalize(value)))
+ })
+ .collect()
+}
+
+/// Resolve `var(--x)` references against the parsed table, to a fixed point.
+/// Bounded by the number of variables, so a reference cycle errors rather than
+/// hanging.
+fn resolve(name: &str, vars: &[(String, String)], depth: usize) -> String {
+ assert!(
+ depth <= vars.len(),
+ "shared/styles.css: var() reference cycle reaching {name}"
+ );
+ let raw = vars
+ .iter()
+ .find(|(n, _)| n == name)
+ .map(|(_, v)| v.clone())
+ .unwrap_or_else(|| panic!("shared/styles.css: :root does not define {name}"));
+
+ let mut out = String::with_capacity(raw.len());
+ let mut rest = raw.as_str();
+ while let Some(at) = rest.find("var(") {
+ out.push_str(&rest[..at]);
+ let after = &rest[at + 4..];
+ let close = after
+ .find(')')
+ .unwrap_or_else(|| panic!("shared/styles.css: unclosed var() in {name}"));
+ out.push_str(&resolve(after[..close].trim(), vars, depth + 1));
+ rest = &after[close + 1..];
+ }
+ out.push_str(rest);
+ normalize(&out)
+}
+
+/// Every generated token's value must equal the resolved value of the CSS
+/// custom property it claims to mirror — not merely appear somewhere in the file.
+#[test]
+fn tokens_match_css() {
+ let vars = parse_root_vars(STYLES);
+ for (rust_name, css_var, expected) in tokens::ALL {
+ let actual = resolve(css_var, &vars, 0);
+ assert_eq!(
+ &actual,
+ &normalize(expected),
+ "token {rust_name} ({css_var}) disagrees with shared/styles.css — \
+ tokens.json says {expected:?}, the CSS resolves to {actual:?}",
+ );
+ }
+}
+
+/// The direction the old substring check could not see: a colour the CSS
+/// declares that `tokens.json` never mentions, so no binding in any language
+/// ever gets it.
+#[test]
+fn css_colors_are_all_tokens() {
+ let vars = parse_root_vars(STYLES);
+ for (name, _) in &vars {
+ if !resolve(name, &vars, 0).starts_with("oklch(") {
+ continue;
+ }
+ assert!(
+ tokens::ALL.iter().any(|(_, css_var, _)| *css_var == name.as_str()),
+ "shared/styles.css declares the colour {name} but shared/tokens.json \
+ has no token for it — add it to tokens.json so every language binding \
+ gets it (the Rust constants are generated from that file)",
+ );
+ }
+}
+
+/// `ALL` is generated alongside the constants; if that ever stops being true
+/// the two checks above go quietly vacuous, so assert it is populated.
+#[test]
+fn token_table_is_populated() {
+ assert!(
+ tokens::ALL.len() >= 20,
+ "tokens::ALL has only {} entries — codegen produced an near-empty table",
+ tokens::ALL.len()
+ );
+}