The look from the server's tokens, pinned; the frame and bar follow the page's theme

ui/tokens.css is no longer a hand copy that drifts: scripts/fetch-tokens.sh
takes the server's tokens.css at a git ref and tokens.lock.json pins it, as
fetch-cli.sh does for the CLI. build.rs reads the two themes' --bg and --border
out of it, so no colour is written in Rust. The content view reports the page's
theme by a navigation the app refuses (llembas-theme:<name>), and the window,
the title bar and the first-run page are painted in it and remember it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
HomerandClaude Fable 5.1 committed 2026-10-18 03:38:31 +02:00
1 parent 5028dd7ca1
commit 881a222f5f
12 files changed
+982 -47

No files matched your search

+5 -2
View File
@@ -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
+8
View File
@@ -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 <server tag>` 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.
+69
View File
@@ -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 <server-ref> 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"
+69
View File
@@ -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)?))
}
+146 -3
View File
@@ -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:<name>`, 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<Theme> {
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<Option<String>>,
/// The agent's folders, as `lembas remote` last answered.
remote: Mutex<Option<Remote>>,
/// What the window is painted in: the server page's theme, as last reported.
theme: Mutex<Theme>,
}
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<String>,
/// Which folders the agent may open sessions in (None: not known yet).
remote: Option<Remote>,
/// 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<Url>) -> tauri::Result<()> {
let theme = handle.state::<App>().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::<f64>(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<Url>) -> 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::<App>();
{
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 <server> --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::<App>().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));
}
}
+5
View File
@@ -13,6 +13,10 @@ pub struct Settings {
pub server: Option<String>,
/// 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<String>,
}
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);
+44
View File
@@ -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 <html> (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:<base>`, 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();
}
})();
+5
View File
@@ -0,0 +1,5 @@
{
"tag": "HEAD",
"commit": "bae579b69182554a2677b5617f11c11decd280db",
"sha256": "a6ad29c609dca02430edf6cd67c30bc8e5bdd1d23f6dc275a8b3d764e6254d52"
}
+43 -19
View File
@@ -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); }
+15
View File
@@ -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 <html>, 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
},
}
+14 -8
View File
@@ -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; }
+559 -15
View File
@@ -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 <html>. `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 <select> takes no pseudo-element -- and it sits behind a rule,
`[ value │ ▾ ]`, so a select reads as a field with a handle rather than as a
button. `--caret-gutter` is everything to the right of the value: the rule,
the caret, and the padding between them, so `padding-right` and the
background positions are computed from one number. */
--caret-w: 0.6rem;
--caret-h: 0.3rem;
--caret-gutter: 2.25rem;
/* --- The window's own edges ---------------------------------------------
Installed on a phone, the page runs under the notch and the home
indicator: base.html asks iOS for `black-translucent`, which is what puts
it there, and `viewport-fit=cover` is what lets these resolve to anything
but zero. Declared here so no component spells `env()` out -- and so a
desktop browser, where all four are 0, costs nothing. */
--safe-top: env(safe-area-inset-top, 0px);
--safe-right: env(safe-area-inset-right, 0px);
--safe-bottom: env(safe-area-inset-bottom, 0px);
--safe-left: env(safe-area-inset-left, 0px);
/* --- Breakpoints --------------------------------------------------------
A media query cannot read a custom property, so these cannot be *used*
here. They are declared anyway so the numbers have one home and a grep for
one lands somewhere that says what it means -- and
`tests/test_layout_bounds.py` refuses a width in any stylesheet that is not
declared here, so a fourth breakpoint invented in passing fails the suite
rather than joining the set unannounced.
--bp-admin 44rem 704px a two-column reference row stacks
--bp-narrow 48rem 768px the sidebar becomes a drawer, and controls
grow to a thumb's size
--bp-wide 64rem 1024px the right-hand panels become overlays */
--bp-admin: 44rem;
--bp-narrow: 48rem;
--bp-wide: 64rem;
/* --- Motion -------------------------------------------------------------
Durations and curves, so the `prefers-reduced-motion` block at the foot of
this file keeps covering everything by construction: a literal `1.6s` in a
component is a value that block can still neutralise, but one nobody can
tune. `--ease-out` is the one to reach for -- something arriving should
decelerate; `--ease-spring` overshoots slightly and belongs on a thing
that appears, never on a thing that moves under the pointer. */
--ease-out: cubic-bezier(0.22, 0.61, 0.36, 1);
--ease-in-out: cubic-bezier(0.65, 0.05, 0.36, 1);
--ease-spring: cubic-bezier(0.34, 1.56, 0.64, 1);
--dur-1: 120ms;
--dur-2: 200ms;
--dur-3: 320ms;
--dur-slow: 1.6s;
/* --- Panel minimums -----------------------------------------------------
`api/preferences.py:LAYOUT_BOUNDS` allows four panels' widths to be stored
against an account and only two of them -- the two with a drag handle --
had a `-min` token or a `min-width` to clamp with. The other two are not
draggable, so nothing in the interface could produce a bad value; but the
endpoint takes one from anybody signed in, `base.html` applies stored
widths to <html> before first paint, and with no clamp a stored 800px
sidebar is one nothing in the application can drag back. */
--sidebar-width-min: 12.5rem;
--inspector-width-min: 17.5rem;
/* The focus treatment, written once. Three components spelled it out. It
resolves --accent-soft at the point of use, so it follows the theme even
though it is declared above them. */
--ring: 0 0 0 3px var(--accent-soft);
}
/*
---------------------------------------------------------------------------
MORIA (default, dark)
The halls under the mountain on a terminal screen: stone-black surfaces kept
near-neutral so the ithildin and mallorn accents carry all the colour, and
the doors themselves drawn in moon-letter pixels on the empty screens.
---------------------------------------------------------------------------
*/
:root,
:root[data-theme="moria"] {
color-scheme: dark;
/* "Ithildin phosphor": stone-black, with the moon-letters' silver
cyan for focus and the primary action and a phosphor leaf for the brand.
Contrast measured, not eyeballed -- ink is 15.7:1 on bg, ink-faint 5.4:1 on
a card, border-strong 3.3:1. */
--bg: #0B0D10;
--bg-sunken: #07090B;
--surface: #11151A;
--surface-raised: #171C22;
--surface-hover: #1D232B;
--surface-active: #232B34;
--border: #26303A;
/* Bright enough to be an edge (3:1 on a card): the blocky look is drawn in
edges, and a 1.9:1 hairline disappears on a dim screen. */
--border-strong: #5C6976;
--ink: #E2E8EC;
--ink-muted: #A6B2BC;
--ink-faint: #808D98;
--ink-inverse: #07090B;
/* Ithildin: the cool primary, focus and interactive accents. */
--accent: #8FD8E8;
--accent-hover: #B4E6F1;
--accent-ink: #061418;
--accent-soft: rgba(143, 216, 232, 0.12);
/* Mallorn: the brand, the assistant's mark, the leaf badges -- phosphor
green on the dark, the colour of an old terminal's text. */
--leaf: #7EE081;
--leaf-hover: #A2EAA3;
--leaf-soft: rgba(126, 224, 129, 0.12);
--danger: #F0735F;
--danger-hover: #F4907F;
--danger-soft: rgba(240, 115, 95, 0.13);
/* Teal, a hue away from the leaf: a dot beside a brand badge has to be
tellable from it at a glance. */
--success: #5FD6B0;
--success-soft: rgba(95, 214, 176, 0.13);
/* Forge amber. */
--warning: #F0A840;
--shadow-card: 4px 4px 0 rgba(0, 0, 0, 0.55);
--danger: #E5534B;
--titlebar-h: 36px;
--warning-soft: rgba(240, 168, 64, 0.13);
--bubble-user: #161C23;
--bubble-assistant: transparent;
--code-bg: #07090B;
--code-border: #26303A;
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.5);
--shadow: 0 4px 14px rgba(0, 0, 0, 0.5);
--shadow-lg: 0 12px 34px rgba(0, 0, 0, 0.6);
/* Blocks: a hard offset shadow, `--press` out, that a button presses into.
On the dark the accent one also glows -- ithildin shows by its light. */
--shadow-block: var(--press) var(--press) 0 rgba(0, 0, 0, 0.55);
--shadow-block-accent: var(--press) var(--press) 0
color-mix(in srgb, var(--accent) 28%, transparent),
0 0 22px -6px color-mix(in srgb, var(--accent) 55%, transparent);
--shadow-block-lg: 6px 6px 0 rgba(0, 0, 0, 0.6),
0 0 40px -12px color-mix(in srgb, var(--accent) 30%, transparent);
/* The two mostly-empty screens -- a new chat, signing in -- sit on a
screen: fine scanlines over a grid that fades out from the middle, lit
faintly from the top. Mixed from the accent so a custom theme on this base
gets its own colour. Nowhere else: a conversation is read for hours. */
--texture-screen:
repeating-linear-gradient(0deg, rgba(0, 0, 0, 0.22) 0 1px, transparent 1px 3px),
radial-gradient(ellipse 60% 45% at 50% 18%,
color-mix(in srgb, var(--accent) 10%, transparent), transparent 70%),
radial-gradient(ellipse 75% 65% at 50% 35%, transparent 45%, var(--bg) 92%),
linear-gradient(color-mix(in srgb, var(--accent) 5%, transparent) 1px, transparent 1px)
0 0 / 24px 24px,
linear-gradient(90deg, color-mix(in srgb, var(--accent) 5%, transparent) 1px,
transparent 1px) 0 0 / 24px 24px,
var(--bg);
--glow-title: 0 0 18px color-mix(in srgb, var(--accent) 35%, transparent);
/* The pixel artwork's colours. Each drawing is a set of named roles
(scripts/build_artwork.py); a theme only supplies these. */
--art-leaf: #7EE081;
--art-leaf-dark: #4FAE5A;
--art-leaf-light: #B6F2A6;
--art-vein: #D8FFD0;
--art-line: #8FD8E8;
--art-faint: #4F8794;
--art-star: #EAFCFF;
--art-glow: rgba(143, 216, 232, 0.55);
--scrim: rgba(5, 7, 9, 0.7);
/*
The sixteen ANSI colours, for the terminal panel.
A shell picks its own colours -- `ls --color`, a git diff, htop's meters --
and until these existed it got xterm's defaults, which are a different
palette from this one and read as a foreign window pasted into the
application. Where a slot has an obvious counterpart above it takes it, so
an error in the terminal is the same ember as an error anywhere else.
"black" is not #000: it is what a program picks for a dim background or
faint text, and on a near-black surface a true black is invisible.
*/
--ansi-black: #1A1F26;
--ansi-red: #F0735F;
--ansi-green: #7EE081;
--ansi-yellow: #F0A840;
--ansi-blue: #7AA7E8;
--ansi-magenta: #C79BE0;
--ansi-cyan: #8FD8E8;
--ansi-white: #C6CDD4;
--ansi-bright-black: #5C6976;
--ansi-bright-red: #F4907F;
--ansi-bright-green: #A2EAA3;
--ansi-bright-yellow: #F5C06E;
--ansi-bright-blue: #9CC0F0;
--ansi-bright-magenta: #DBBBEB;
--ansi-bright-cyan: #B4E6F1;
--ansi-bright-white: #E2E8EC;
}
@media (prefers-color-scheme: light) {
:root {
/*
---------------------------------------------------------------------------
SHIRE (light)
Parchment, ink and moss, printed in one bit: warm and easy to read for a
long time, with hard ink edges where Moria glows. Backgrounds are
deliberately off-white -- pure #FFF next to the leaf accent reads as
clinical rather than as paper.
---------------------------------------------------------------------------
*/
/*
`[data-base="shire"]` as well as `[data-theme="shire"]`, and that second
selector is what makes a custom theme possible. A custom theme is a handful of
token overrides served from /branding.css; everything it does not set has to
come from somewhere, and Moria's block above matches bare `:root` so it always
applies. Without this a custom *light* theme would be four light colours on
near-black surfaces. `<html>` carries both attributes -- see base.html and
app.js:applyTheme.
*/
:root[data-theme="shire"],
:root[data-base="shire"] {
color-scheme: light;
/* "1-bit almanac": parchment and ink, a door-green primary, moss
for the brand, brass for warnings -- and ink-black block shadows, which is
what makes it read as printed. Ink is 13.9:1 on bg, ink-faint 5.6:1. */
--bg: #F1EAD6;
--bg-sunken: #E7DEC6;
--surface: #F8F3E5;
--surface-raised: #FCF9F0;
--surface-hover: #ECE4CF;
--surface-active: #E2D8BD;
--border: #CBBF9D;
/* Ink, not a darker parchment: the blocks are drawn in it. */
--border-strong: #2A2E22;
--ink: #1F2418;
--ink-muted: #474D3B;
--ink-faint: #5E6250;
--ink-inverse: #F8F3E5;
/* The round door's green: the primary. */
--accent: #285F3C;
--accent-hover: #1F4D30;
--accent-ink: #F8F3E5;
--accent-soft: rgba(40, 95, 60, 0.1);
/* The same mallorn as moss, dark enough to be text on parchment. */
--leaf: #3D6B1C;
--leaf-hover: #2F5615;
--leaf-soft: rgba(61, 107, 28, 0.1);
--danger: #A3321F;
--danger-hover: #862818;
--danger-soft: rgba(163, 50, 31, 0.1);
--success: #2B6A55;
--success-soft: rgba(43, 106, 85, 0.1);
/* Brass. */
--warning: #7E5200;
--shadow-card: 4px 4px 0 var(--border-strong);
--danger: #B3261E;
--warning-soft: rgba(126, 82, 0, 0.1);
--bubble-user: #ECE4CF;
--bubble-assistant: transparent;
--code-bg: #F1EAD6;
--code-border: #CBBF9D;
--shadow-sm: 0 1px 2px rgba(42, 46, 34, 0.1);
--shadow: 0 4px 14px rgba(42, 46, 34, 0.12);
--shadow-lg: 0 12px 34px rgba(42, 46, 34, 0.18);
/* On paper the block shadow is solid ink: one bit, no blur. */
--shadow-block: var(--press) var(--press) 0 var(--border-strong);
--shadow-block-accent: var(--press) var(--press) 0 var(--border-strong);
--shadow-block-lg: 6px 6px 0 var(--border-strong);
/* A fine dot dither in place of Moria's scanlines, fading at the edges. */
--texture-screen:
radial-gradient(ellipse 80% 70% at 50% 30%, transparent 50%, var(--bg) 95%),
radial-gradient(color-mix(in srgb, var(--ink) 14%, transparent) 0.8px, transparent 1.1px)
0 0 / 6px 6px,
var(--bg);
--glow-title: none;
--art-leaf: #4F8A2A;
--art-leaf-dark: #2F5E1A;
--art-leaf-light: #86B553;
--art-vein: #E9F2C8;
--art-grass: #5D9A3A;
--art-grass-light: #86B553;
--art-grass-dark: #3F7428;
--art-sky: #CFE3E8;
--art-sun: #E8B84A;
--art-frame: #5A4425;
--art-door: #2F6B3E;
--art-door-dark: #24553A;
--art-brass: #E8B84A;
--art-window: #F3D27A;
--art-path: #B89A6A;
--art-flower-a: #D9534F;
--art-flower-b: #F0C94A;
--scrim: rgba(31, 36, 24, 0.4);
/*
The same sixteen on parchment, and the reason they are not the Moria set
lightened: every one of these has to be readable as *text* on the code
background, so the whole palette is darkened rather than brightened.
"bright" therefore means more emphatic here, not lighter -- a light
terminal theme that made bright yellow actually bright would render it
invisible.
*/
--ansi-black: #1F2418;
--ansi-red: #A3321F;
--ansi-green: #3D6B1C;
--ansi-yellow: #7E5200;
--ansi-blue: #2F5D86;
--ansi-magenta: #74336A;
--ansi-cyan: #2B6A55;
--ansi-white: #474D3B;
--ansi-bright-black: #6F6A55;
--ansi-bright-red: #862818;
--ansi-bright-green: #2F5615;
--ansi-bright-yellow: #634000;
--ansi-bright-blue: #244A6C;
--ansi-bright-magenta: #5C2853;
--ansi-bright-cyan: #1F5443;
--ansi-bright-white: #2A2E22;
}
/*
--- Touch -----------------------------------------------------------------
A pointer is precise and a finger is about 9mm across, so the same control
cannot be the right size for both. `--control-h` is 36px, which is comfortable
with a mouse and under every published minimum for a thumb; `--control-h-sm`
is 28px, which is a target most people miss.
Raised here rather than patched per component, because there are upwards of
forty of them and the next one added would be 36px again. `--control-h` is
what every button, input and select resolves its height from, so one block
moves all of them -- which is the reason that token exists.
Two conditions, either of which is enough.
`(pointer: coarse)` is the honest one: it is the input device that decides how
big a target has to be, and a touchscreen laptop at 1440px has the same thumb
as a phone. But a layout below the phone breakpoint is a one-column, drawer-
navigated layout whatever is pointing at it -- there is room for bigger
controls and every reason to use it -- and that half is also the half a
headless browser can be made to prove, which is not nothing: a rule that can
only be checked by holding a phone is a rule that quietly rots.
*/
@media (pointer: coarse), (max-width: 48rem) {
:root {
--control-h: var(--tap-min);
/* 40px, not the 36 a comfortable pointer gets. A `.btn--sm` is a secondary
action, not an unimportant one -- Edit, Enable and Use default are all
`.btn--sm`, and on a phone they are the whole interaction. */
--control-h-sm: 2.5rem;
--control-px: var(--sp-4);
--control-px-sm: var(--sp-3);
/* A native checkbox is 13-16px whatever the surrounding type is, and no
amount of padding on its label changes the box itself. It is the
smallest target in the application on a phone by some margin. */
--check-size: 1.375rem;
}
}
/* Respect a stated preference for reduced motion everywhere, at once. */
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
/* The motion tokens too, for anything that composes a duration rather than
declaring one -- a `transition: transform var(--dur-3)` is neutralised by
the rule above, but an `animation-delay` built from one is not. */
:root {
--dur-1: 0.01ms;
--dur-2: 0.01ms;
--dur-3: 0.01ms;
--dur-slow: 0.01ms;
--transition-fast: 0.01ms;
--transition: 0.01ms;
--transition-slow: 0.01ms;
}
}