Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
80 changes: 80 additions & 0 deletions .github/workflows/rust.yml
Original file line number Diff line number Diff line change
@@ -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"
41 changes: 29 additions & 12 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -44,10 +44,11 @@ flowchart LR
subgraph SRC["shared/ — canonical source"]
CSS["styles.css<br/>OKLCH tokens + base CSS"]
SVG["monogram.svg"]
JSON["tokens.json<br/>(no consumer yet)"]
JSON["tokens.json<br/>the token source"]
end
CSS -->|"include_str!"| RS["rust/ — smooai-ui crate<br/>STYLES · MONOGRAM_SVG · tokens::*"]
SVG -->|"include_str!"| RS
JSON -->|"build.rs codegen"| RS
RS -->|"git dependency"| BLUE["smooblue<br/>(Dioxus desktop)"]
CSS -.->|"planned bindings"| FUT["TS · .NET · Python · Go"]

Expand Down Expand Up @@ -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.

---

Expand Down Expand Up @@ -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

Expand All @@ -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

Expand Down
10 changes: 10 additions & 0 deletions rust/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -16,12 +16,22 @@ authors = ["SmooAI <brent@smoo.ai>"]
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"
17 changes: 17 additions & 0 deletions rust/build.rs
Original file line number Diff line number Diff line change
@@ -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");
}
108 changes: 33 additions & 75 deletions rust/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)]
Expand All @@ -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}");
}
}
Expand Down
Loading
Loading