diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2216b28..6d681a4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -34,5 +34,8 @@ jobs: - name: Tests working-directory: src-tauri run: cargo test --locked - - name: The first-run page's JavaScript - run: node --check ui/app.js + - name: The pages' JavaScript, and the script the content view runs + run: | + for f in ui/*.js src-tauri/src/*.js; do node --check "$f"; done + - name: The design tokens are the server's, as pinned + run: scripts/fetch-tokens.sh diff --git a/README.md b/README.md index 345dc9b..8a00780 100644 --- a/README.md +++ b/README.md @@ -55,6 +55,7 @@ Needs Rust (stable) and, on Linux, WebKitGTK 4.1 and an AppIndicator library: ```sh sudo apt install libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev libxdo-dev scripts/fetch-cli.sh # the bundled CLI, checked against its release signature +scripts/fetch-tokens.sh # verifies ui/tokens.css against tokens.lock.json cargo install tauri-cli --version "^2" --locked cargo tauri dev # or: cargo tauri build ``` @@ -63,6 +64,13 @@ There is no Node and no bundler: the app's one local page is plain HTML, CSS and `ui/`. Everything else on screen is the server's own web UI. The version of the CLI it carries is in `lembas-cli.version`. +Two files in `ui/` are generated and committed. `ui/tokens.css` is the server's design tokens, +copied by `scripts/fetch-tokens.sh ` from a checkout of the server beside this +repository (`../LLeMbas`, or `LEMBAS_SERVER`) and pinned in `tokens.lock.json`; run it with a +new tag when the server's look changes. `ui/i18n.js` is the pages' words in the server's +languages, written by `scripts/gen-i18n.py` from the server's catalogues and the script's own +table; run it when a string on the page changes. + ## Licence MIT; see [LICENSE](LICENSE). The bundled LLeMbas CLI is MIT too, with its own third-party notices. diff --git a/scripts/fetch-tokens.sh b/scripts/fetch-tokens.sh new file mode 100755 index 0000000..64f9450 --- /dev/null +++ b/scripts/fetch-tokens.sh @@ -0,0 +1,69 @@ +#!/usr/bin/env bash +# Put the LLeMbas server's design tokens where the app's own pages read them from: +# ui/tokens.css (a copy of the server's static/css/tokens.css, never edited by hand) +# +# scripts/fetch-tokens.sh copy the tokens at that ref (a tag such as v1.3.0, +# or HEAD) and write tokens.lock.json +# scripts/fetch-tokens.sh verify: ui/tokens.css matches tokens.lock.json +# +# The server is a git checkout, LLeMbas beside this repository by default (LLEMBAS_SERVER names +# another). The one change made to the copy: the @font-face rules are left out, with the comment +# above them. They name woff2 files the server vendors and the app does not carry; the app's pages +# fall through the same `--font-ui` stack to the system's monospace face, and without the rules +# the webview does not ask for two files that are not there. +# +# tokens.lock.json records the ref, the commit it resolved to, and the SHA-256 of ui/tokens.css +# as written, which is what verify checks. Like fetch-cli.sh and lembas-cli.version: the file in +# the tree says which server version the look comes from, and CI refuses a copy that drifted. +set -euo pipefail +cd "$(dirname "$0")/.." + +SERVER="${LEMBAS_SERVER:-../LLeMbas}" +SOURCE="src/lembas/web/static/css/tokens.css" +OUT="ui/tokens.css" +LOCK="tokens.lock.json" + +sha() { sha256sum "$1" | cut -d' ' -f1; } + +if [ $# -eq 0 ]; then + [ -f "$LOCK" ] || { echo "fetch-tokens: no $LOCK; run with a server ref first" >&2; exit 1; } + [ -f "$OUT" ] || { echo "fetch-tokens: no $OUT; run with a server ref first" >&2; exit 1; } + want="$(sed -n -E 's/^ *"sha256": *"([0-9a-f]{64})".*/\1/p' "$LOCK" | head -n1)" + ref="$(sed -n -E 's/^ *"tag": *"([^"]+)".*/\1/p' "$LOCK" | head -n1)" + have="$(sha "$OUT")" + if [ "$want" != "$have" ]; then + echo "fetch-tokens: $OUT does not match $LOCK (server $ref): $have, expected $want" >&2 + exit 1 + fi + echo "fetch-tokens: $OUT matches $LOCK (server $ref)" + exit 0 +fi + +REF="$1" +[ -d "$SERVER/.git" ] || [ -f "$SERVER/.git" ] \ + || { echo "fetch-tokens: $SERVER is not a git checkout of the LLeMbas server (set LEMBAS_SERVER)" >&2; exit 1; } +commit="$(git -C "$SERVER" rev-parse --verify --quiet "$REF^{commit}")" \ + || { echo "fetch-tokens: $REF is not a commit in $SERVER" >&2; exit 1; } + +work="$(mktemp -d)" +trap 'rm -rf "$work"' EXIT +git -C "$SERVER" show "$commit:$SOURCE" > "$work/tokens.css" \ + || { echo "fetch-tokens: $SOURCE is not in $SERVER at $REF" >&2; exit 1; } + +# A comment immediately before an @font-face goes with it; then every @font-face block. +perl -0pe 's#/\*(?:(?!\*/).)*?\*/\s*(?=\@font-face)##gs; s#\@font-face\s*\{[^}]*\}\s*##g' \ + "$work/tokens.css" > "$work/body.css" +grep -q -- '--border:' "$work/body.css" && grep -q -- '--bg:' "$work/body.css" \ + || { echo "fetch-tokens: $SOURCE at $REF has no --border or --bg: not the tokens file" >&2; exit 1; } +! grep -q '@font-face' "$work/body.css" \ + || { echo "fetch-tokens: an @font-face survived the strip; fix the script" >&2; exit 1; } + +{ + printf '/* LLeMbas %s (%s) static/css/tokens.css, copied by scripts/fetch-tokens.sh: do not edit.\n' "$REF" "${commit:0:12}" + printf ' The font-face rules are left out; the app bundles no font. */\n' + cat "$work/body.css" +} > "$work/out.css" + +cp "$work/out.css" "$OUT" +printf '{\n "tag": "%s",\n "commit": "%s",\n "sha256": "%s"\n}\n' "$REF" "$commit" "$(sha "$OUT")" > "$LOCK" +echo "fetch-tokens: LLeMbas $REF (${commit:0:12}) $SOURCE -> $OUT; $LOCK written" diff --git a/src-tauri/build.rs b/src-tauri/build.rs index d860e1e..2a50830 100644 --- a/src-tauri/build.rs +++ b/src-tauri/build.rs @@ -1,3 +1,72 @@ +//! Besides Tauri's own build step: the two themes' colours the window itself paints, read out +//! of `ui/tokens.css` (the server's tokens, fetched by `scripts/fetch-tokens.sh`) into +//! `$OUT_DIR/tokens.rs`, so no colour is written in Rust. `lib.rs` includes that file. + +use std::env; +use std::fs; +use std::path::Path; + +/// The themes the app knows, by the name `tokens.css` selects them with (`data-theme`), and the +/// Rust names the constants get. +const THEMES: [(&str, &str); 2] = [("moria", "MORIA"), ("shire", "SHIRE")]; +/// The tokens taken from each theme, by the custom property and the constant's suffix. +const TOKENS: [(&str, &str); 2] = [("--bg", "BG"), ("--border", "BORDER")]; + fn main() { + let tokens = Path::new("../ui/tokens.css"); + println!("cargo:rerun-if-changed={}", tokens.display()); + let css = fs::read_to_string(tokens) + .unwrap_or_else(|e| panic!("{}: {e}; run scripts/fetch-tokens.sh", tokens.display())); + let mut out = String::from( + "// Generated by build.rs from ui/tokens.css: the colours the window paints itself.\n", + ); + for (theme, name) in THEMES { + let block = theme_block(&css, theme).unwrap_or_else(|| { + panic!("ui/tokens.css has no `:root[data-theme=\"{theme}\"]` block") + }); + for (token, suffix) in TOKENS { + let (r, g, b) = hex_token(block, token).unwrap_or_else(|| { + panic!( + "ui/tokens.css: `{token}` of the {theme} theme is not a six-digit hex colour" + ) + }); + out.push_str(&format!( + "pub const {name}_{suffix}: (u8, u8, u8) = (0x{r:02X}, 0x{g:02X}, 0x{b:02X});\n" + )); + } + } + let dest = Path::new(&env::var("OUT_DIR").expect("OUT_DIR")).join("tokens.rs"); + fs::write(&dest, out).expect("write tokens.rs"); tauri_build::build() } + +/// The declarations of the theme's block: from the selector naming it (at the start of a line, +/// so a mention in a comment does not count) to the block's closing brace at the start of a +/// line, which is the file's own layout, one block per theme. +fn theme_block<'a>(css: &'a str, theme: &str) -> Option<&'a str> { + let selector = format!("\n:root[data-theme=\"{theme}\"]"); + let at = css.find(&selector)?; + let open = at + css[at..].find('{')?; + let close = open + css[open..].find("\n}")?; + Some(&css[open + 1..close]) +} + +/// `--token: #RRGGBB;` in the block, as bytes. +fn hex_token(block: &str, token: &str) -> Option<(u8, u8, u8)> { + let value = block + .lines() + .map(str::trim) + .find_map(|l| { + l.strip_prefix(token) + .and_then(|r| r.trim_start().strip_prefix(':')) + })? + .trim() + .trim_end_matches(';') + .trim(); + let hex = value.strip_prefix('#')?; + if hex.len() != 6 { + return None; + } + let byte = |i: usize| u8::from_str_radix(&hex[i..i + 2], 16).ok(); + Some((byte(0)?, byte(2)?, byte(4)?)) +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 77a7e14..db1c998 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -8,6 +8,12 @@ //! window; the tray, the title bar's menu or starting the app again bring it back. //! - **The server's pages get no IPC.** They are remote content, and the one capability lists no //! remote URL. Links that leave the server open in the system browser. +//! - **The window follows the server's theme** (Moria or Shire, the account's choice, not the +//! desktop's): a script in the content view reports the page's `data-theme` by trying to +//! navigate to `llembas-theme:`, which the navigation handler notes and refuses (see +//! `THEME_SCRIPT`). The frame, the title bar and the first-run page are painted in it, and it +//! is remembered for the next start. No IPC is granted for this: a navigation attempt is all +//! the page can make, and the only thing it can say is one of two theme names. //! - **The agent is the CLI's**: `lembas login --json` links this computer, and //! `lembas service run` is the device (see `agent.rs`). @@ -38,6 +44,11 @@ use agent::{Agent, State as AgentState}; use remote::Remote; use settings::Settings; +/// The colours the window paints itself, read out of `ui/tokens.css` by `build.rs`. +mod tokens { + include!(concat!(env!("OUT_DIR"), "/tokens.rs")); +} + const MAIN: &str = "main"; const TITLEBAR: &str = "titlebar"; const CONTENT: &str = "content"; @@ -46,6 +57,54 @@ const TITLE_H: f64 = 36.0; /// The frame left around the webviews while the window is not maximized: the window's own /// surface, where the pointer resizes it (a webview over the edge would take the events). const FRAME: f64 = 4.0; +/// The scheme the content page's theme report is a navigation to: `llembas-theme:shire`. +const THEME_SCHEME: &str = "llembas-theme"; +/// Run in the content view on every page: reports the page's theme (see the module's notes). +const THEME_SCRIPT: &str = include_str!("theme_report.js"); + +/// The two looks the server's tokens define, named as `tokens.css` selects them (`data-theme`). +/// A custom theme on the server reports its base, which is one of these. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Theme { + Moria, + Shire, +} + +impl Theme { + fn parse(name: &str) -> Option { + match name { + "moria" => Some(Theme::Moria), + "shire" => Some(Theme::Shire), + _ => None, + } + } + + fn name(self) -> &'static str { + match self { + Theme::Moria => "moria", + Theme::Shire => "shire", + } + } + + /// The frame's colour: the theme's `--border`, which the pages draw their edges in. + fn frame(self) -> Color { + let (r, g, b) = match self { + Theme::Moria => tokens::MORIA_BORDER, + Theme::Shire => tokens::SHIRE_BORDER, + }; + Color(r, g, b, 0xFF) + } + + /// A webview's own colour, `--bg`: what shows where a page has not painted yet, so a + /// server page loading in the dark theme is not a white flash first. + fn background(self) -> Color { + let (r, g, b) = match self { + Theme::Moria => tokens::MORIA_BG, + Theme::Shire => tokens::SHIRE_BG, + }; + Color(r, g, b, 0xFF) + } +} struct App { config_dir: PathBuf, @@ -57,6 +116,8 @@ struct App { update: Mutex>, /// The agent's folders, as `lembas remote` last answered. remote: Mutex>, + /// What the window is painted in: the server page's theme, as last reported. + theme: Mutex, } struct TrayItems { @@ -76,6 +137,10 @@ impl App { .map_err(|e| format!("could not save the settings: {e}"))?; Ok(s.clone()) } + + fn theme(&self) -> Theme { + *self.theme.lock().unwrap() + } } /// What the first-run page shows. @@ -90,6 +155,8 @@ struct Status { update: Option, /// Which folders the agent may open sessions in (None: not known yet). remote: Option, + /// The theme the app's own pages are painted in: `moria` or `shire`, the server page's. + theme: &'static str, } fn status(app: &App) -> Status { @@ -102,6 +169,7 @@ fn status(app: &App) -> Status { agent_wanted: app.agent.wanted(), update: app.update.lock().unwrap().clone(), remote: app.remote.lock().unwrap().clone(), + theme: app.theme().name(), } } @@ -330,19 +398,21 @@ fn open_main(handle: &AppHandle, server: Option<&str>) -> Result<(), String> { } fn build_main(handle: &AppHandle, target: Option) -> tauri::Result<()> { + let theme = handle.state::().theme(); let window = WindowBuilder::new(handle, MAIN) .title("LLeMbas") .inner_size(1280.0, 840.0) .min_inner_size(420.0, 560.0) .decorations(false) - // The frame's colour: the Moria border, as `--border` in the pages. - .background_color(Color(0x26, 0x30, 0x3A, 0xFF)) + // The frame's colour: the theme's border, as the pages draw theirs. + .background_color(theme.frame()) .build()?; let size = window .inner_size()? .to_logical::(window.scale_factor()?); window.add_child( - WebviewBuilder::new(TITLEBAR, WebviewUrl::App("titlebar.html".into())), + WebviewBuilder::new(TITLEBAR, WebviewUrl::App("titlebar.html".into())) + .background_color(theme.background()), LogicalPosition::new(FRAME, FRAME), LogicalSize::new(size.width - 2.0 * FRAME, TITLE_H), )?; @@ -358,6 +428,8 @@ fn build_main(handle: &AppHandle, target: Option) -> tauri::Result<()> { window.add_child( WebviewBuilder::new(CONTENT, url) .user_agent(&user_agent()) + .background_color(theme.background()) + .initialization_script(THEME_SCRIPT) .on_navigation(move |url| allowed(&nav, url)), LogicalPosition::new(FRAME, FRAME + TITLE_H), LogicalSize::new( @@ -430,7 +502,14 @@ fn place(window: &Window, framed: bool) { } /// The window may show the app's own pages and the server; anything else opens in the browser. +/// A `llembas-theme:` "navigation" is the content page reporting its theme: noted, never loaded. fn allowed(handle: &AppHandle, url: &Url) -> bool { + if url.scheme() == THEME_SCHEME { + if let Some(theme) = Theme::parse(url.path()) { + set_theme(handle, theme); + } + return false; + } let local = matches!(url.scheme(), "tauri" | "asset") || url.host_str() == Some("tauri.localhost") || url.scheme() == "about" @@ -449,6 +528,29 @@ fn allowed(handle: &AppHandle, url: &Url) -> bool { same } +/// The server page shows `theme`: paint the frame and the webviews in it, remember it for the +/// next start, and tell the app's own pages (they hear it as `status`). +fn set_theme(handle: &AppHandle, theme: Theme) { + let app = handle.state::(); + { + let mut current = app.theme.lock().unwrap(); + if *current == theme { + return; + } + *current = theme; + } + let _ = app.update(|s| s.theme = Some(theme.name().into())); + if let Some(w) = main_window(handle) { + let _ = w.set_background_color(Some(theme.frame())); + } + for label in [TITLEBAR, CONTENT] { + if let Some(v) = handle.get_webview(label) { + let _ = v.set_background_color(Some(theme.background())); + } + } + let _ = handle.emit("status", status_of(handle)); +} + /// Link this computer: `lembas login --json`, with the approval shown in the window /// (where the user is already signed in to the server), then the agent started. fn start_link(handle: &AppHandle) -> Result<(), String> { @@ -841,6 +943,12 @@ pub fn run() { let _ = notify_handle.run_on_main_thread(move || on_agent_state(&h, &s)); }); let loaded = settings::load(&config_dir); + // Until a server page has said, the server's own default: Moria. + let theme = loaded + .theme + .as_deref() + .and_then(Theme::parse) + .unwrap_or(Theme::Moria); app.manage(App { config_dir, settings: Mutex::new(loaded.clone()), @@ -849,6 +957,7 @@ pub fn run() { linking: Mutex::new(false), update: Mutex::new(None), remote: Mutex::new(None), + theme: Mutex::new(theme), }); let items = build_tray(&handle)?; *handle.state::().tray.lock().unwrap() = Some(items); @@ -891,3 +1000,37 @@ pub fn run() { } }); } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_theme_is_one_of_two_names() { + assert_eq!(Theme::parse("moria"), Some(Theme::Moria)); + assert_eq!(Theme::parse("shire"), Some(Theme::Shire)); + assert_eq!(Theme::parse("Shire"), None); + assert_eq!(Theme::parse(""), None); + for t in [Theme::Moria, Theme::Shire] { + assert_eq!(Theme::parse(t.name()), Some(t)); + } + } + + #[test] + fn the_themes_come_out_of_tokens_css_as_two_different_looks() { + // build.rs read these out of ui/tokens.css: a light and a dark theme cannot share + // either colour, and the frame is not the page. + assert_ne!(Theme::Moria.frame(), Theme::Shire.frame()); + assert_ne!(Theme::Moria.background(), Theme::Shire.background()); + assert_ne!(Theme::Moria.frame(), Theme::Moria.background()); + assert_ne!(Theme::Shire.frame(), Theme::Shire.background()); + } + + #[test] + fn the_theme_report_is_a_navigation_the_handler_can_read() { + let url = Url::parse("llembas-theme:shire").unwrap(); + assert_eq!(url.scheme(), THEME_SCHEME); + assert_eq!(Theme::parse(url.path()), Some(Theme::Shire)); + assert!(THEME_SCRIPT.contains(THEME_SCHEME)); + } +} diff --git a/src-tauri/src/settings.rs b/src-tauri/src/settings.rs index e166897..ec6d951 100644 --- a/src-tauri/src/settings.rs +++ b/src-tauri/src/settings.rs @@ -13,6 +13,10 @@ pub struct Settings { pub server: Option, /// Run the agent while the app runs (once this computer is linked to the server). pub agent: bool, + /// The server page's theme the last time it said (`moria` or `shire`): what the window's + /// frame, the title bar and the first-run page are painted in until the server says again. + /// None until a server page has been shown. + pub theme: Option, } pub fn file(config_dir: &Path) -> PathBuf { @@ -84,6 +88,7 @@ mod tests { let s = Settings { server: Some("https://ai.example".into()), agent: true, + theme: Some("shire".into()), }; save(&dir, &s).unwrap(); assert_eq!(load(&dir), s); diff --git a/src-tauri/src/theme_report.js b/src-tauri/src/theme_report.js new file mode 100644 index 0000000..47e840a --- /dev/null +++ b/src-tauri/src/theme_report.js @@ -0,0 +1,44 @@ +// Run in the content view on every page it shows (an initialization script, before the page's +// own scripts). It reports the theme the page is in -- the server sets `data-theme` and +// `data-base` on (base.html, then app.js:applyTheme when the reader switches) -- so the +// app can paint the frame and the title bar in the same look. +// +// The report is a navigation to `llembas-theme:`, which the app's navigation handler +// (lib.rs:allowed) reads and refuses, so nothing loads and the page is not left. That is the +// whole channel: the server's pages get no IPC, and this gives them none -- a page can say one +// of two theme names, and nothing else. The base, not the theme's id: a custom theme on the +// server is a handful of colours over `moria` or `shire`, and the base is the look the app has. +(function () { + "use strict"; + // Only the server's pages: the app's own first-run page is painted from the app's side. + if (window.top !== window) return; + if (location.protocol !== "http:" && location.protocol !== "https:") return; + if (location.hostname === "tauri.localhost") return; + + var last = ""; + function report() { + var root = document.documentElement; + if (!root) return; + var base = root.getAttribute("data-base") || root.getAttribute("data-theme") || ""; + if (base !== "moria" && base !== "shire") return; + if (base === last) return; + last = base; + try { + location.assign("llembas-theme:" + base); + } catch (e) { /* refused, as intended */ } + } + function watch() { + var root = document.documentElement; + if (!root) return; + new MutationObserver(report).observe(root, { + attributes: true, + attributeFilter: ["data-theme", "data-base"] + }); + report(); + } + if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", watch); + } else { + watch(); + } +})(); diff --git a/tokens.lock.json b/tokens.lock.json new file mode 100644 index 0000000..2878250 --- /dev/null +++ b/tokens.lock.json @@ -0,0 +1,5 @@ +{ + "tag": "HEAD", + "commit": "bae579b69182554a2677b5617f11c11decd280db", + "sha256": "a6ad29c609dca02430edf6cd67c30bc8e5bdd1d23f6dc275a8b3d764e6254d52" +} diff --git a/ui/app.css b/ui/app.css index 5f2aa8c..11606be 100644 --- a/ui/app.css +++ b/ui/app.css @@ -1,6 +1,12 @@ -/* The first-run page. Tokens in tokens.css. */ +/* The first-run page. Every colour, size and space is a token from tokens.css (the server's, + fetched by scripts/fetch-tokens.sh); the one value of this page's own is the card's width. */ * { box-sizing: border-box; } +:root { + /* The card's widest: a layout fact of this page, not a token the server has. */ + --card-max: 40rem; +} + html, body { margin: 0; min-height: 100%; } body { @@ -10,7 +16,7 @@ body { padding: var(--sp-4); background: var(--bg); color: var(--ink); - font: var(--text-base) / 1.6 var(--font-body); + font: var(--text-base) / var(--leading-normal) var(--font-body); } .card { @@ -19,15 +25,26 @@ body { gap: var(--sp-6); padding: var(--sp-8); background: var(--surface); - border: 1px solid var(--border); + border: var(--border-w) solid var(--border); border-radius: var(--radius); - box-shadow: var(--shadow-card); + box-shadow: var(--shadow-block-lg); } -.brand { display: grid; grid-template-columns: 40px 1fr; gap: var(--sp-3); align-items: center; } -h1 { margin: 0; font: 600 var(--text-xl) / 1.2 var(--font-ui); } +.brand { display: grid; grid-template-columns: var(--sp-10) 1fr; gap: var(--sp-3); align-items: center; } +.brand img { width: var(--sp-10); height: var(--sp-10); } +h1 { margin: 0; font: 600 var(--text-xl) / var(--leading-tight) var(--font-ui); } h1 b { color: var(--leaf); font-weight: 700; } -h2 { margin: 0 0 var(--sp-3); font: 600 var(--text-sm) / 1.2 var(--font-ui); letter-spacing: 0.08em; color: var(--accent); } +/* A section's heading, in brackets as on the server's screens: `[ SERVER ]`. The brackets are + drawn here so the words alone are what gets translated. */ +h2 { + margin: 0 0 var(--sp-3); + font: 600 var(--text-sm) / var(--leading-tight) var(--font-ui); + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--accent); +} +h2::before { content: "[ "; } +h2::after { content: " ]"; } p { margin: 0; } .muted { color: var(--ink-muted); font-size: var(--text-sm); } @@ -43,14 +60,17 @@ label, .label { font: var(--text-sm) / 1.4 var(--font-ui); color: var(--ink-mute input { height: var(--control-h); min-width: 0; - padding: 0 var(--sp-3); + padding: 0 var(--control-px); font: var(--text-base) var(--font-ui); color: var(--ink); background: var(--bg); - border: 1px solid var(--border-strong); + border: var(--border-w) solid var(--border-strong); border-radius: var(--radius); } -input:focus-visible, .btn:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; } +input:focus-visible, .btn:focus-visible { + outline: var(--outline-w) solid var(--accent); + outline-offset: var(--outline-w); +} .btn { height: var(--control-h); @@ -58,9 +78,9 @@ input:focus-visible, .btn:focus-visible { outline: 2px solid var(--accent); outl font: 600 var(--text-sm) var(--font-ui); color: var(--ink); background: var(--surface); - border: 1px solid var(--border-strong); + border: var(--border-w) solid var(--border-strong); border-radius: var(--radius); - box-shadow: var(--press) var(--press) 0 rgba(0, 0, 0, 0.45); + box-shadow: var(--shadow-block); cursor: pointer; } .btn:hover { background: var(--surface-hover); } @@ -87,9 +107,9 @@ input:focus-visible, .btn:focus-visible { outline: 2px solid var(--accent); outl gap: var(--sp-2); align-items: center; min-height: var(--control-h); - padding: 0 0 0 var(--sp-3); + padding: 0 0 0 var(--control-px); background: var(--bg); - border: 1px solid var(--border); + border: var(--border-w) solid var(--border); border-radius: var(--radius); } .folders .path { @@ -101,8 +121,12 @@ input:focus-visible, .btn:focus-visible { outline: 2px solid var(--accent); outl font: var(--text-sm) var(--font-ui); } .folders .path bdi { direction: ltr; unicode-bidi: isolate; } -.folders .btn { height: calc(var(--control-h) - 2px); box-shadow: none; border-width: 0 0 0 1px; } -.folders .empty { padding: var(--sp-3); color: var(--warning); font-size: var(--text-sm); } +.folders .btn { + height: calc(var(--control-h) - 2 * var(--border-w)); + box-shadow: none; + border-width: 0 0 0 var(--border-w); +} +.folders .empty { padding: var(--control-px); color: var(--warning); font-size: var(--text-sm); } .settings-grid { display: grid; @@ -118,12 +142,12 @@ input:focus-visible, .btn:focus-visible { outline: 2px solid var(--accent); outl select { height: var(--control-h); min-width: 0; - padding: 0 var(--sp-2); + padding: 0 var(--control-px-sm); font: var(--text-sm) var(--font-ui); color: var(--ink); background: var(--bg); - border: 1px solid var(--border-strong); + border: var(--border-w) solid var(--border-strong); border-radius: var(--radius); } .check { display: flex; align-items: center; gap: var(--sp-2); color: var(--ink); } -.check input { width: 1.1rem; height: 1.1rem; margin: 0; accent-color: var(--accent); } +.check input { width: var(--check-size); height: var(--check-size); margin: 0; accent-color: var(--accent); } diff --git a/ui/theme.js b/ui/theme.js new file mode 100644 index 0000000..8f9c5ba --- /dev/null +++ b/ui/theme.js @@ -0,0 +1,15 @@ +// The app's own pages are painted in the server page's theme, which the app reports in `status` +// (see src-tauri/src/lib.rs: the content view says, the app remembers). tokens.css selects a +// theme by `data-theme` and `data-base` on , as the server's pages do; with nothing set +// it is Moria, the server's own default, so the page needs no script to be right at first. +"use strict" + +window.lembasTheme = { + apply(name) { + if (name !== "moria" && name !== "shire") return + const root = document.documentElement + if (root.dataset.theme === name) return + root.dataset.theme = name + root.dataset.base = name + }, +} diff --git a/ui/titlebar.css b/ui/titlebar.css index f78b39e..70b5749 100644 --- a/ui/titlebar.css +++ b/ui/titlebar.css @@ -1,7 +1,13 @@ /* The title bar: one row, fixed height, every slot a fixed or shared track so nothing in it - moves when the host name or the agent's state changes length. */ + moves when the host name or the agent's state changes length. Colours, type and space are + tokens from tokens.css (the server's); the bar's height is this page's own. */ * { box-sizing: border-box; } +:root { + /* `TITLE_H` in src-tauri/src/lib.rs is this number: the app lays the bar out at it. */ + --titlebar-h: 36px; +} + html, body { margin: 0; height: var(--titlebar-h); @@ -16,10 +22,10 @@ html, body { .bar { display: grid; /* leaf · name · host (takes the rest, and is the drag area) · update · agent · menu · sep · 3 controls */ - grid-template-columns: var(--titlebar-h) auto minmax(0, 1fr) auto auto var(--titlebar-h) 1px repeat(3, 2.875rem); + grid-template-columns: var(--titlebar-h) auto minmax(0, 1fr) auto auto var(--titlebar-h) var(--border-w) repeat(3, 2.875rem); align-items: center; height: var(--titlebar-h); - border-bottom: 1px solid var(--border); + border-bottom: var(--border-w) solid var(--border); font: 600 var(--text-sm) / 1 var(--font-ui); } @@ -49,17 +55,17 @@ button { cursor: pointer; } button:hover { color: var(--ink); background: var(--surface-hover); } -button:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; } +button:focus-visible { outline: var(--outline-w) solid var(--accent); outline-offset: calc(-1 * var(--outline-w)); } .bar__agent { display: grid; - grid-template-columns: 0.5rem auto; + grid-template-columns: var(--sp-2) auto; gap: var(--sp-2); align-items: center; padding: 0 var(--sp-3); font-weight: 400; } -.dot { width: 0.5rem; height: 0.5rem; background: var(--ink-muted); } +.dot { width: var(--sp-2); height: var(--sp-2); background: var(--ink-muted); } .bar__agent[data-state="running"] .dot, .bar__agent[data-state="elsewhere"] .dot { background: var(--leaf); } .bar__agent[data-state="starting"] .dot { background: var(--accent); } @@ -76,11 +82,11 @@ button:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; } /* Hidden keeps its grid track (auto, so zero wide): the slots after it never move. */ .bar__update[hidden] { display: block; width: 0; padding: 0; overflow: hidden; } -.bar__sep { width: 1px; height: 1.25rem; background: var(--border); } +.bar__sep { width: var(--border-w); height: var(--sp-5); background: var(--border); } .bar__btn { display: grid; place-items: center; } .bar__btn svg { width: 16px; height: 16px; } -.bar__btn--close:hover { color: #fff; background: var(--danger); } +.bar__btn--close:hover { color: var(--ink-inverse); background: var(--danger); } #maximize .i-restore { display: none; } #maximize[data-maximized] .i-max { display: none; } diff --git a/ui/tokens.css b/ui/tokens.css index bf49be2..0ec374b 100644 --- a/ui/tokens.css +++ b/ui/tokens.css @@ -1,54 +1,598 @@ -/* LLeMbas Desktop's own pages: one set of tokens (Moria dark, Shire light), as on llembas.eu. */ +/* LLeMbas HEAD (bae579b69182) static/css/tokens.css, copied by scripts/fetch-tokens.sh: do not edit. + The font-face rules are left out; the app bundles no font. */ +/* + Design tokens. + + Every colour, space and radius in the application resolves through a variable + declared here. Component CSS must never hard-code a hex value -- that is what + makes adding a theme a matter of writing one new block rather than auditing + every stylesheet. + + Themes are selected with data-theme on . `moria` is the default and is + declared on :root so the page is styled even before the theme script runs. +*/ + :root { - --font-ui: ui-monospace, "JetBrains Mono", "SF Mono", "Cascadia Code", Menlo, Consolas, monospace; - --font-body: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; + /* --- Type ------------------------------------------------------------- */ + /* The chrome -- buttons, navigation, tabs, labels, headings -- + is set in `--font-ui`, a monospace face: "Middle-earth on a terminal". + What people *read* at length, a reply or a note, stays in `--font-body`, + because eighty columns of mono prose is tiring and the look is not worth + that. `--font-display` follows the chrome. */ + --font-ui: "JetBrains Mono", ui-monospace, "SF Mono", "Cascadia Code", Menlo, + Consolas, monospace; + --font-display: var(--font-ui); + --font-body: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", + Arial, sans-serif; + --font-mono: "JetBrains Mono", ui-monospace, "SF Mono", "Fira Code", + "Cascadia Code", Menlo, Consolas, monospace; + + --text-xs: 0.75rem; --text-sm: 0.8125rem; --text-base: 0.9375rem; - --text-xl: 1.5rem; + --text-md: 1rem; + --text-lg: 1.125rem; + --text-xl: 1.375rem; + --text-2xl: 1.75rem; + + --leading-tight: 1.25; + --leading-normal: 1.6; + --leading-relaxed: 1.75; + + /* --- Space (4px scale) ------------------------------------------------ */ + --sp-1: 0.25rem; --sp-2: 0.5rem; --sp-3: 0.75rem; --sp-4: 1rem; + --sp-5: 1.25rem; --sp-6: 1.5rem; --sp-8: 2rem; - --radius: 2px; - --press: 2px; - --control-h: 2.5rem; - --card-max: 40rem; + --sp-10: 2.5rem; + --sp-12: 3rem; + /* --- Radius & shadow -------------------------------------------------- */ + /* Blocks, not pills. Small on purpose: a corner of 2px reads as + square at reading distance and still stops an edge looking cut. Avatars and + dots keep `--radius-full`. */ + --radius-sm: 2px; + --radius: 2px; + --radius-md: 3px; + --radius-lg: 4px; + --radius-xl: 6px; + --radius-full: 999px; + /* Square, on purpose: the sidebar's Chats/Agents switch, which has + to read as one solid control rather than as two buttons. */ + --radius-none: 0; + + /* How far a block button travels when pressed: into its own shadow, which + is drawn exactly this far out. The two are one number on purpose. */ + --press: 2px; + + /* The load-state dot on a model's avatar in the model menu. Its colours are + tokens of their own, defaulting to the theme's success and warning, so + an instance whose success colour is not green can still say "loaded" in + green -- that is what people read a dot beside a name as. */ + --model-state-size: 0.625rem; + --model-state-loaded: var(--success); + --model-state-loading: var(--warning); + + /* + --- Controls ---------------------------------------------------------- + Every button, input and select resolves its height from these. That is the + whole reason things line up: a row of mixed controls has one height, not + whatever each element's padding and font happened to add up to. + */ + --control-h: 2.25rem; + --control-h-sm: 1.75rem; + --control-h-lg: 2.75rem; + --control-px: 0.75rem; + --control-px-sm: 0.5rem; + + /* --- Layout ----------------------------------------------------------- */ + --sidebar-width: 17.5rem; + --inspector-width: 24rem; + /* Wider than the inspector because the content is not prose: eighty columns + of --font-mono do not fit in 24rem, and a terminal narrower than eighty + re-wraps everything a program prints. */ + --terminal-width: 34rem; + --terminal-width-min: 24rem; + /* Wider again: a source line is longer than eighty columns once nothing is + re-wrapping it, and this one holds prose as well. The minimum is 24rem = + 384px and must equal both `data-resize-min` in chat/_canvas.html and the + lower bound in api/preferences.py:LAYOUT_BOUNDS -- a width outside those + bounds is silently dropped, so the three are pinned equal by a test. */ + --canvas-width: 40rem; + --canvas-width-min: 24rem; + /* The prototype panel. The canvas's width, not wider: the page inside is not + laid out at the panel's width anyway (see --proto-desktop-width), so a wider + panel only buys a larger picture at the cost of the conversation beside it + -- at 52rem a 1280px window had 180px of chat left. The minimum is pinned to + `data-resize-min` and LAYOUT_BOUNDS exactly as the canvas's is. */ + --prototype-width: 40rem; + --prototype-width-min: 24rem; + /* The two views are two viewports, each scaled to fit the panel rather than + squeezed into it. A desktop design shown at 640px is its tablet layout, and + the switch says "desktop". So the page is laid out at a desktop's width and + the picture is shrunk, the way a browser zoomed out would show it -- and a + phone is an iPhone-sized viewport, the width most designs are checked at. + A frame either way, not an emulation: the page's own media queries answer + to these numbers, which is the whole point of the switch. */ + --proto-desktop-width: 80rem; + --proto-phone-width: 24.375rem; + --proto-phone-height: 52.75rem; + /* An inline prototype is as tall as the page it holds, within these. The + floor keeps a frame that has not reported yet from collapsing to nothing; + the ceiling is what the model is told ("under about 600 pixels"), with a + little room, so a page sized in `100vh` cannot grow a frame forever. */ + --proto-inline-min: 6rem; + --proto-inline-start: 16rem; + --proto-inline-max: 40rem; + --thread-max-width: 48rem; + --header-height: 3.5rem; + + /* What `--header-height` does at the top of the shell, this does at the + bottom. The sidebar's footer and the composer sit either side of the same + vertical line and both are content-sized, so without this they end at + different heights -- and neither can be made to match the other by + accident: the footer's height depends on which entries the reader's + permissions allow, and the composer's on how much they have typed. + + This began as the fix for a broken *line*: both carried a top border, and + the two met the sidebar's edge at different heights. The borders are gone + now -- an edge that content scrolls under reads better undrawn than drawn + and aligned -- and the token stays, because the two ends of the shell + sitting at different heights is visible without any border to prove it. + + A calc of the pieces the footer is actually built from -- four rows at + `--control-h`, the gaps between them, and its own padding -- so it stays + true if those tokens move. Applied as a `min-height` to both: it holds the + footer at full height even for somebody who sees fewer entries, and lifts + the composer to meet it. A composer that grows past this as somebody types + is expected; the input is getting bigger, and nothing is pretending the + sidebar should follow it. */ + --footer-height: calc(4 * var(--control-h) + 3 * var(--sp-1) + 2 * var(--sp-2)); + + /* The terminal's own type. xterm holds this as a number rather than reading + it from CSS, so terminal.js parses it back out -- it must stay a plain + pixel value. */ + --terminal-font-size: 13px; + + /* + --- Stacking ---------------------------------------------------------- + These were four bare numbers scattered across two stylesheets, which is + fine until something new has to sit between two of them and nobody can + say what is already there. + */ + --z-raised: 5; + --z-handle: 10; + --z-dropdown: 30; + --z-panel: 40; + --z-toast: 60; + + --transition-fast: 120ms ease; + --transition: 200ms ease; + + /* --- Borders ----------------------------------------------------------- + A hairline was a literal `1px` in about ninety places, which made it the + largest category of hard-coded value left in the codebase -- and the one + thing a theme cannot currently change. */ + --border-w: 1px; + --border-w-thick: 2px; + --border-w-accent: 3px; + + /* The focus outline's own width. Not `--border-w-thick`, though they are the + same number today: an outline is drawn outside the box and takes no space, + a border is part of the box and does. Making one of them follow the other + means a theme that wants a heavier border gets a heavier focus ring too, + which is two decisions tied together by a coincidence. */ + --outline-w: 2px; + + /* --- Touch -------------------------------------------------------------- + A control a thumb has to hit is 44px. `--control-h` is 2.25rem, which is + 36 -- comfortable with a pointer and under every published minimum for a + finger -- so the coarse-pointer block at the foot of this file raises the + control tokens to this rather than patching components one at a time. + Raising the token is the only version that reaches all of them, and it is + what `--control-h` exists for. */ + --tap-min: 2.75rem; + /* A tick box, which does not take its size from `--control-h`: the browser + draws it and only `width`/`height` move it. */ + --check-size: 1rem; + + /* --- Controls as a family ----------------------------------------- + The dropdown's caret is a solid ▾ drawn with two gradients rather than a + glyph -- a