From 9305d85d29a9949ff256a31fcf72b313d8923af9 Mon Sep 17 00:00:00 2001 From: Brent Rager Date: Thu, 20 Aug 2026 14:09:17 -0400 Subject: [PATCH] Add CI, generate tokens from tokens.json, and gate shared/ against client-shared MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This repo had NO CI — `.github/` held only banner images — so a change could land unbuilt and `shared/` could diverge from SmooAI/client-shared with nothing to say so. It already had: 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 its styles.css lost the whole `.input` family. Both repos were green the entire time, because neither ran anything. - **CI** (`.github/workflows/rust.yml`): fmt, clippy `--all-targets -D warnings`, tests, the module-tree guard, and the shared/ drift gate. - **`shared/` drift gate.** SmooAI/client-shared owns the design system (it is the declared successor and what `th` ships); this repo keeps a copy for observability-studio and smooblue, and CI fails if the copies differ. The gate compares git blob SHAs from the contents API against `git ls-tree`, so adds and deletes are caught too — no filename list to fall behind. Deliberately one-directional: client-shared is ungated so a change lands there and this repo follows; a bidirectional gate deadlocks, with neither PR able to green until the other merges. Chosen over a cargo dependency on client-shared because both crates are git deps, and depending across would put two independently rev-pinned git deps in one graph for any consumer wanting both. - **`shared/tokens.json` was inert** — read by no code in any language while the Rust constants hand-mirrored the same values. `rust/build.rs` now generates the whole `tokens` module from it via `shared/tokens_codegen.rs`, so a token cannot exist in the design system and be missing from Rust. That surfaced six tokens the CSS had and Rust omitted (MUTED, MUTED_FOREGROUND, ACCENT, INPUT, RING, SIDEBAR) plus the radius/space/font scales. `RADIUS_PX` stays as an alias of the generated `RADIUS_MD_PX` so observability-studio keeps compiling. - **`tokens_match_css` could not fail in the direction that mattered.** It asserted each constant appeared *somewhere* in the CSS as a substring: it passes with a constant bound to the wrong property, and nothing walked the CSS, so a token the CSS had and Rust lacked was invisible. `shared/tokens_css_check.rs` parses `:root`, resolves `var()`, and checks both directions. Verified red by drifting a token value and by adding an untracked `--surprise` colour. - **Module-tree guard** (`scripts/check-module-tree.py`): fails if any `.rs` file under `rust/src/` is unreachable from a `mod` declaration — the class that let client-shared ship 174 lines of `auth/refresh.rs` that never compiled. shared/ is byte-identical to SmooAI/client-shared@main (7c5b948); all five blob SHAs verified equal. Tests 6/6, fmt + clippy clean. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0152bbE1veqfG1SVJdyLCBxC --- .github/workflows/rust.yml | 80 ++++++++++++ README.md | 41 ++++-- rust/Cargo.toml | 10 ++ rust/build.rs | 17 +++ rust/src/lib.rs | 108 +++++----------- scripts/check-module-tree.py | 82 ++++++++++++ shared/tokens.json | 161 ++++++++++++++++++------ shared/tokens_codegen.rs | 234 +++++++++++++++++++++++++++++++++++ shared/tokens_css_check.rs | 140 +++++++++++++++++++++ 9 files changed, 748 insertions(+), 125 deletions(-) create mode 100644 .github/workflows/rust.yml create mode 100644 rust/build.rs create mode 100644 scripts/check-module-tree.py create mode 100644 shared/tokens_codegen.rs create mode 100644 shared/tokens_css_check.rs 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() + ); +}