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:
12 files changed
+994
-59
No files matched your search
@@ -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
|
||||
@@ -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.
|
||||
Executable
+69
@@ -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"
|
||||
@@ -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
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
})();
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"tag": "HEAD",
|
||||
"commit": "bae579b69182554a2677b5617f11c11decd280db",
|
||||
"sha256": "a6ad29c609dca02430edf6cd67c30bc8e5bdd1d23f6dc275a8b3d764e6254d52"
|
||||
}
|
||||
+43
-19
@@ -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
@@ -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
@@ -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; }
|
||||
|
||||
+571
-27
@@ -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) {
|
||||
/*
|
||||
---------------------------------------------------------------------------
|
||||
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;
|
||||
--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 {
|
||||
color-scheme: light;
|
||||
--bg: #F1EAD6;
|
||||
--surface: #F8F3E5;
|
||||
--surface-hover: #ECE4CF;
|
||||
--border: #CBBF9D;
|
||||
--border-strong: #2A2E22;
|
||||
--ink: #1F2418;
|
||||
--ink-muted: #474D3B;
|
||||
--accent: #285F3C;
|
||||
--accent-hover: #1F4D30;
|
||||
--accent-ink: #F8F3E5;
|
||||
--leaf: #3D6B1C;
|
||||
--warning: #7E5200;
|
||||
--shadow-card: 4px 4px 0 var(--border-strong);
|
||||
--danger: #B3261E;
|
||||
--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;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user