Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
Prev Previous commit
Next Next commit
feat: add theme system with generated landing site color palettes
  • Loading branch information
prem315 committed Oct 13, 2026
commit f9a1abc2bb3e1cceecc33919485c2c572661addd
1 change: 1 addition & 0 deletions packages/tui/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ newline-delimited JSON-RPC.
| `src/action.rs` | Named actions and the keymap, with the `<leader>` prefix. |
| `src/app.rs` | UI state and `update`, the one place state changes. |
| `src/ui.rs` | Rendering: reads state and draws, nothing else. |
| `src/theme.rs` | The palette, generated from the landing site's themes. |
| `src/rpc/` | The transport: WebSocket client and wire protocol. |
| `src/types.rs` | The DTOs the transport speaks in. |
| `src/index.ts` | Thin TypeScript launcher: resolves the platform binary for the CLI. |
Expand Down
145 changes: 145 additions & 0 deletions packages/tui/scripts/themes.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
// Generates `src/theme/palettes.rs` from the landing site's palettes, which are the source of
// truth for the TUI's 16 tokens. The output is committed, so building the TUI never needs this
// script; run it with `--check` to fail when the site has moved and the file is stale.
//
// node scripts/themes.mjs # rewrite src/theme/palettes.rs
// node scripts/themes.mjs --check # exit 1 if it would change
import { readFileSync, writeFileSync } from "node:fs";
import { fileURLToPath } from "node:url";

const site = (path) => fileURLToPath(new URL(`../../../sites/www/src/${path}`, import.meta.url));
const output = fileURLToPath(new URL("../src/theme/palettes.rs", import.meta.url));

const CSS = site("styles/themes.css");
const REGISTRY = site("components/landing/theme.ts");

/** The site token -> the Rust field that carries it, in the order the design doc lists them. */
const TOKENS = [
["bg", "bg"],
["surface", "surface"],
["surface-2", "surface_2"],
["border-subtle", "border_subtle"],
["border-strong", "border_strong"],
["text", "text"],
["text-secondary", "text_secondary"],
["text-muted", "text_muted"],
["brand", "brand"],
["brand-ink", "brand_ink"],
["field-bg", "field_bg"],
["field-dim", "field_dim"],
["field-mid", "field_mid"],
["field-lit", "field_lit"],
["field-hover", "field_hover"],
["field-crest", "field_crest"],
];

const problems = [];
const note = (message) => problems.push(message);

/** `[data-theme="id"] { --t-x: #hex; … color-scheme: dark|light }` blocks. */
function palettes() {
const css = readFileSync(CSS, "utf8");
const found = new Map();
for (const block of css.matchAll(/\[data-theme="([a-z0-9-]+)"\]\s*\{([^}]*)\}/g)) {
const [, id, body] = block;
const tokens = {};
for (const token of body.matchAll(/--t-([a-z0-9-]+):\s*(#[0-9a-fA-F]{6})/g)) tokens[token[1]] = token[2];
const scheme = body.match(/color-scheme:\s*(dark|light)/)?.[1];

if (Object.keys(tokens).length !== TOKENS.length) {
note(`${id}: ${Object.keys(tokens).length} tokens, expected ${TOKENS.length}`);
}
for (const [token] of TOKENS) if (tokens[token] === undefined) note(`${id}: missing --t-${token}`);
if (scheme === undefined) note(`${id}: no color-scheme`);
if (found.has(id)) note(`${id}: appears twice`);
found.set(id, { tokens, light: scheme === "light" });
}
return found;
}

/** The `THEMES` list and `DEFAULT_THEME`, which own the ids, labels, order and scheme. */
function registry() {
const source = readFileSync(REGISTRY, "utf8");
const themes = [];
for (const entry of source.matchAll(/\{\s*id:\s*"([^"]+)",\s*name:\s*"([^"]+)"(,\s*light:\s*true)?\s*\}/g)) {
themes.push({ id: entry[1], name: entry[2], light: entry[3] !== undefined });
}
const fallback = source.match(/DEFAULT_THEME\s*=\s*"([^"]+)"/)?.[1];
if (themes.length === 0) note("registry: no themes parsed");
if (fallback === undefined) note("registry: no DEFAULT_THEME");
return { themes, fallback };
}

const found = palettes();
const { themes, fallback } = registry();

// The two files have to agree both ways, or a theme would render as nothing.
for (const theme of themes) if (!found.has(theme.id)) note(`registry has ${theme.id}, no palette`);
for (const id of found.keys()) if (!themes.some((t) => t.id === id)) note(`palette ${id} is not in the registry`);
if (fallback !== undefined && !themes.some((t) => t.id === fallback))
note(`DEFAULT_THEME ${fallback} is not in the registry`);

for (const theme of themes) {
const palette = found.get(theme.id);
if (palette !== undefined && palette.light !== theme.light) {
note(`${theme.id}: registry says light=${theme.light}, color-scheme says light=${palette.light}`);
}
}

if (problems.length > 0) {
console.error(`themes.mjs: the site's palettes and registry disagree:\n ${problems.join("\n ")}`);
process.exit(1);
}

const rgb = (hex) => {
const [r, g, b] = [hex.slice(1, 3), hex.slice(3, 5), hex.slice(5, 7)];
return `Color::Rgb(0x${r.toLowerCase()}, 0x${g.toLowerCase()}, 0x${b.toLowerCase()})`;
};

const body = themes
.map((theme) => {
const { tokens } = found.get(theme.id);
const fields = TOKENS.map(([token, field]) => ` ${field}: ${rgb(tokens[token])},`).join("\n");
return ` Palette {
id: "${theme.id}",
name: ${JSON.stringify(theme.name)},
scheme: Scheme::${theme.light ? "Light" : "Dark"},
${fields}
},`;
})
.join("\n");

const header = [
"// @generated by scripts/themes.mjs from the landing site's palettes. Do not edit.",
"//",
"// Source: sites/www/src/styles/themes.css (the 16 tokens) and",
"// sites/www/src/components/landing/theme.ts (ids, labels, scheme, default).",
"// Re-run `node scripts/themes.mjs` after either moves; `--check` fails on drift.",
].join("\n");

const defaultLine = `/// The site's \`DEFAULT_THEME\`, so the two do not drift.\npub const DEFAULT: &str = "${fallback}";`;

const generated = `${header}

use ratatui::style::Color;

use super::{Palette, Scheme};

pub static PALETTES: &[Palette] = &[
${body}
];

${defaultLine}
`;

if (process.argv.includes("--check")) {
if (readFileSync(output, "utf8") !== generated) {
console.error("themes.mjs: src/theme/palettes.rs is stale — run `node scripts/themes.mjs`");
process.exit(1);
}
console.log(`themes.mjs: ${themes.length} palettes are up to date`);
process.exit(0);
}

writeFileSync(output, generated);
console.log(`themes.mjs: wrote ${themes.length} palettes to src/theme/palettes.rs`);
5 changes: 5 additions & 0 deletions packages/tui/src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ use crossterm::event::KeyEvent;

use crate::action::{Action, Keymap};
use crate::event::AppEvent;
use crate::theme::{self, Palette};

/// The screens the TUI can show. One, for now.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
Expand All @@ -17,6 +18,9 @@ pub enum Screen {

pub struct App {
pub screen: Screen,
/// The palette every screen draws with. Held here rather than read from a global, so a frame
/// is a pure function of the app and can be reproduced from a test backend.
pub theme: &'static Palette,
pub should_quit: bool,
keymap: Keymap,
}
Expand All @@ -31,6 +35,7 @@ impl App {
pub fn new() -> Self {
Self {
screen: Screen::Welcome,
theme: theme::default_palette(),
should_quit: false,
keymap: Keymap::new(),
}
Expand Down
7 changes: 5 additions & 2 deletions packages/tui/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ mod event;
// session screen will stream prompts over. Nothing on the welcome screen uses it.
#[allow(dead_code, unused_imports)]
mod rpc;
mod theme;
mod tui;
#[allow(dead_code)]
mod types;
Expand All @@ -18,9 +19,11 @@ use tui::TerminalGuard;

#[tokio::main]
async fn main() -> Result<(), Box<dyn Error>> {
// Dropping the guard restores the terminal, including on an early return.
let mut terminal = TerminalGuard::init(ui::BG)?;
// The app first: it owns the palette, and the terminal's own background is set from it so the
// ground beneath the frames matches the ground inside them.
let mut app = App::new();
// Dropping the guard restores the terminal, including on an early return.
let mut terminal = TerminalGuard::init(app.theme.bg)?;
let mut events = EventHandler::new();

terminal.draw(|frame| ui::view(&app, frame))?;
Expand Down
73 changes: 73 additions & 0 deletions packages/tui/src/theme.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
//! The active palette.
//!
//! The landing site owns the colours. It ships 22 palettes that each fill the same 16 tokens, and
//! `scripts/themes.mjs` turns them into [`palettes`] so the two surfaces cannot drift. Widgets read
//! roles off these tokens; the semantic layer — feedback colours, diff colours, syntax hues — needs
//! colours the site does not have, and arrives with the widgets that draw them.

mod palettes;

pub use palettes::{DEFAULT, PALETTES};

use ratatui::style::Color;

/// Whether a palette is drawn for a light or a dark terminal. The landing canvas leans on this to
/// decide how it is lit, and the TUI will need it the first time a role has to be nudged for
/// contrast.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Scheme {
Dark,
Light,
}

/// One palette: the site's 16 tokens, and how it is named.
///
/// The tokens are the whole contract, so a palette carries all 16 even while the UI draws with a
/// few of them — that is what keeps a new widget from having to re-pull every theme. Every id here
/// is generated from the site and cross-checked against its registry.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Palette {
pub id: &'static str,
pub name: &'static str,
pub scheme: Scheme,
/// The page background: the app's own ground.
pub bg: Color,
/// Cards, popovers, prose blocks.
pub surface: Color,
/// A muted or secondary surface; also the hover and accent fill.
pub surface_2: Color,
/// Hairlines, card borders, dividers.
pub border_subtle: Color,
/// Emphasised borders.
pub border_strong: Color,
/// Primary text.
pub text: Color,
/// Secondary text.
pub text_secondary: Color,
/// Muted text and captions.
pub text_muted: Color,
/// The accent: links, the primary action, the focus ring.
pub brand: Color,
/// Ink drawn on top of a `brand` fill.
pub brand_ink: Color,
/// The accent ramp: five steps through `brand`, plus the ground they sit on. The landing
/// canvas reads these at runtime, and they are the natural source for a banner or a spinner.
pub field_bg: Color,
pub field_dim: Color,
pub field_mid: Color,
pub field_lit: Color,
pub field_hover: Color,
pub field_crest: Color,
}

/// The palette the TUI opens on, which is the site's own default.
pub fn default_palette() -> &'static Palette {
// `DEFAULT` is generated from the site and checked against `PALETTES` by `scripts/themes.mjs`,
// so a miss here means the generated file is out of date.
by_id(DEFAULT).expect("DEFAULT is a generated palette id")
}

/// The palette with this id, if it is one of the site's.
pub fn by_id(id: &str) -> Option<&'static Palette> {
PALETTES.iter().find(|palette| palette.id == id)
}
Loading
Loading