From 10a9507aa2ce5d79f0782dfe308ddabd40ac0179 Mon Sep 17 00:00:00 2001 From: samialabri Date: Tue, 27 Oct 2026 00:23:42 +0700 Subject: [PATCH] Colour themes, synced across devices (beta) Seven themes (Spruce, Ocean, Violet, Rose, Crimson, Amber, Graphite) that change only the brand colour, all AA in light and dark. "Same theme on all my devices" saves the choice in the encrypted vault and adopts it after unlocking and syncing. Co-Authored-By: Claude Opus 5.5 --- src-tauri/src/main.rs | 22 ++++++++ src/main.ts | 93 +++++++++++++++++++++++++++++++++- src/style.css | 14 ++++++ src/ui/tokens.css | 113 ++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 241 insertions(+), 1 deletion(-) diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index ddc7a1a..80c1148 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -408,6 +408,26 @@ fn resolve_save(app: State<'_, App>, id: String, accept: bool) -> CmdResult<()> Ok(()) } +/// The colour theme and appearance saved for all the account's devices (docs/02 "App settings"). +#[derive(Serialize)] +struct ThemeView { + theme: Option, + appearance: Option, +} + +#[tauri::command] +fn app_theme(app: State<'_, App>) -> CmdResult { + let t = app.core.app_theme().map_err(err)?; + Ok(ThemeView { theme: t.theme, appearance: t.appearance }) +} + +#[tauri::command] +fn set_app_theme(app: State<'_, App>, theme: Option, appearance: Option) -> CmdResult<()> { + app.core.set_app_theme(theme, appearance).map_err(err)?; + app.sync.request_sync(); + Ok(()) +} + #[tauri::command] fn pending_passkeys(app: State<'_, App>) -> Vec { app.broker.pending_passkeys() @@ -730,6 +750,8 @@ fn main() { pending_saves, pending_passkeys, set_tags, + app_theme, + set_app_theme, sign_in_providers, resolve_passkey, resolve_save, diff --git a/src/main.ts b/src/main.ts index 2e0764b..040609d 100644 --- a/src/main.ts +++ b/src/main.ts @@ -111,6 +111,64 @@ function setTheme(t: Theme) { applyTheme(storedTheme()); +// ------------------------------------------------------------------ colour theme (kestrove-spec/design/themes.json) + +const ACCENTS = [ + ["spruce", "Spruce"], ["ocean", "Ocean"], ["violet", "Violet"], ["rose", "Rose"], + ["crimson", "Crimson"], ["amber", "Amber"], ["graphite", "Graphite"], +] as const; +type Accent = (typeof ACCENTS)[number][0]; +const isAccent = (s: unknown): s is Accent => ACCENTS.some(([id]) => id === s); + +function stored(key: string): string | null { + try { + return localStorage.getItem(key); + } catch { + return null; + } +} + +function store(key: string, value: string) { + try { + localStorage.setItem(key, value); + } catch { + // Not persisted; still applied for this run. + } +} + +function storedAccent(): Accent { + const a = stored("accent"); + return isAccent(a) ? a : "spruce"; +} + +function applyAccent(a: Accent) { + if (a === "spruce") delete document.documentElement.dataset.accent; + else document.documentElement.dataset.accent = a; +} + +/** "Same theme on all my devices": follow the theme saved in the vault (docs/02 "App settings"). */ +const syncsTheme = () => stored("syncTheme") === "1"; + +/** After unlocking and after each sync: take the account's theme, if this computer follows it. */ +async function adoptSyncedTheme() { + if (!syncsTheme()) return; + const t = await invoke<{ theme: string | null; appearance: string | null }>("app_theme").catch(() => null); + if (!t) return; + if (isAccent(t.theme) && t.theme !== storedAccent()) { + store("accent", t.theme); + applyAccent(t.theme); + } + if ((t.appearance === "system" || t.appearance === "light" || t.appearance === "dark") && t.appearance !== storedTheme()) setTheme(t.appearance); +} + +/** The theme changed here: save it for the other devices, when this computer follows the account's theme. */ +async function publishTheme() { + if (!syncsTheme()) return; + await invoke("set_app_theme", { theme: storedAccent(), appearance: storedTheme() }).catch(() => undefined); +} + +applyAccent(storedAccent()); + // ------------------------------------------------------------------ chrome /// The custom title bar in `bg`: native window controls (macOS traffic lights sit over its left edge). @@ -767,6 +825,35 @@ async function renderSettings(target: HTMLElement, state: AppState) { })); theme.setAttribute("role", "group"); theme.setAttribute("aria-label", "Appearance"); + theme.querySelectorAll("button").forEach((b) => b.addEventListener("click", () => void publishTheme())); + const swatches = h("div", "swatches", ...ACCENTS.map(([id, name]) => { + const b = h("button", "swatch", h("span", "dot"), h("span", "", name)); + b.type = "button"; + b.dataset.swatch = id; + b.setAttribute("role", "radio"); + b.setAttribute("aria-checked", String(storedAccent() === id)); + b.onclick = () => { + store("accent", id); + applyAccent(id); + swatches.querySelectorAll("button").forEach((x) => x.setAttribute("aria-checked", String(x === b))); + void publishTheme(); + }; + return b; + })); + swatches.setAttribute("role", "radiogroup"); + swatches.setAttribute("aria-label", "Colour"); + const syncTheme = input({ type: "checkbox", checked: syncsTheme() }); + syncTheme.className = "switch"; + syncTheme.setAttribute("aria-label", "Same theme on all my devices"); + syncTheme.onchange = async () => { + store("syncTheme", syncTheme.checked ? "1" : "0"); + if (!syncTheme.checked) return; + // Adopt the account's theme if it has one, else share this computer's. + const t = await invoke<{ theme: string | null; appearance: string | null }>("app_theme").catch(() => null); + if (t && (t.theme || t.appearance)) await adoptSyncedTheme(); + else await publishTheme(); + await renderSettings(target, state); + }; const list = h("div", "kv-card rows"); for (const pr of pairings) { list.append(h("div", "kv-frow", @@ -784,7 +871,10 @@ async function renderSettings(target: HTMLElement, state: AppState) { h("h2", "kv-title-2", "Settings"), section("General", h("div", "kv-card rows", - h("div", "kv-frow", h("div", "val", h("span", "", "Appearance")), theme), + h("div", "kv-frow", h("div", "val", h("span", "", "Colour")), swatches), + h("div", "kv-frow", h("div", "val", h("span", "", "Light or dark")), theme), + h("div", "kv-frow", h("div", "val", h("span", "", "Same theme on all my devices"), + h("span", "kv-hint", "Saved in your encrypted vault, and used on every device where this is on.")), syncTheme), h("div", "kv-frow", h("div", "val", h("span", "", "Lock after idle for"), h("span", "kv-hint", "Kestrove also locks on screen lock and sleep.")), idle))), section("Sync", syncSection), section("Devices", devicesSection), @@ -1157,6 +1247,7 @@ void listen("sync-status", (e) => { showSync(s); if (lastChanges !== -1 && s.changes !== lastChanges && refreshList && syncBox?.isConnected) void refreshList(); lastChanges = s.changes; + void adoptSyncedTheme(); if (s.integrity_alarm && !alarmShown) { alarmShown = true; openModal(integrityBanner(), h("div", "actions end", button("OK", "primary", closeModal))); diff --git a/src/style.css b/src/style.css index a8cab6c..a8fb30e 100644 --- a/src/style.css +++ b/src/style.css @@ -104,3 +104,17 @@ summary { cursor: pointer; color: var(--text-2); } .tag-row { display: flex; flex-wrap: wrap; align-items: center; gap: var(--space-1\.5); margin: 0 0 var(--space-3); } .tag-row .tag { font: 500 12px/16px var(--font-sans); color: var(--text-2); background: var(--surface-2); border-radius: var(--radius-sm); padding: 4px 8px; } .rail-tags { display: contents; } + +/* Colour theme swatches (Settings). Fixed colours: each dot shows its theme, whatever theme is active. */ +.swatches { display: flex; flex-wrap: wrap; gap: var(--space-2); } +.swatch { display: flex; flex-direction: column; align-items: center; gap: var(--space-1); padding: var(--space-1); border: 0; border-radius: var(--radius-sm); background: transparent; color: var(--text-2); font: 500 12px/16px var(--font-sans); cursor: pointer; } +.swatch[aria-checked="true"] { color: var(--text); } +.swatch .dot { width: 26px; height: 26px; border-radius: 50%; box-shadow: 0 0 0 2px var(--surface), 0 0 0 4px transparent; } +.swatch[aria-checked="true"] .dot { box-shadow: 0 0 0 2px var(--surface), 0 0 0 4px var(--text-2); } +.swatch[data-swatch="spruce"] .dot { background: #19604f; } +.swatch[data-swatch="ocean"] .dot { background: #1d5a9e; } +.swatch[data-swatch="violet"] .dot { background: #5a43ad; } +.swatch[data-swatch="rose"] .dot { background: #a3305f; } +.swatch[data-swatch="crimson"] .dot { background: #ad2828; } +.swatch[data-swatch="amber"] .dot { background: #8f4a00; } +.swatch[data-swatch="graphite"] .dot { background: #3d4a52; } diff --git a/src/ui/tokens.css b/src/ui/tokens.css index 65633a4..9379cf0 100644 --- a/src/ui/tokens.css +++ b/src/ui/tokens.css @@ -122,3 +122,116 @@ --shadow: 0 1px 2px #00000059, 0 8px 24px -8px #00000080; --shadow-lg: 0 24px 64px -16px #000000b3; } + +/* Colour themes (kestrove-spec/design/themes.json): only the brand colour changes; Spruce is the default above. + html[data-accent] comes from Settings; the dark rules are more specific, so they win whenever dark applies. */ +:root[data-accent="ocean"] { + --primary: #1d5a9e; + --primary-hover: #174a83; + --primary-soft: #dde9f7; + --on-primary: #ffffff; +} +:root[data-accent="violet"] { + --primary: #5a43ad; + --primary-hover: #4a378f; + --primary-soft: #e8e3f7; + --on-primary: #ffffff; +} +:root[data-accent="rose"] { + --primary: #a3305f; + --primary-hover: #86274e; + --primary-soft: #f7e1eb; + --on-primary: #ffffff; +} +:root[data-accent="crimson"] { + --primary: #ad2828; + --primary-hover: #8f2121; + --primary-soft: #f8e0de; + --on-primary: #ffffff; +} +:root[data-accent="amber"] { + --primary: #8f4a00; + --primary-hover: #753d00; + --primary-soft: #f8e8d6; + --on-primary: #ffffff; +} +:root[data-accent="graphite"] { + --primary: #3d4a52; + --primary-hover: #2f3a40; + --primary-soft: #e3e8eb; + --on-primary: #ffffff; +} +@media (prefers-color-scheme: dark) { + :root[data-accent="ocean"]:not([data-theme="light"]) { + --primary: #7ab4f0; + --primary-hover: #9cc8f5; + --primary-soft: #162a40; + --on-primary: #061527; + } + :root[data-accent="violet"]:not([data-theme="light"]) { + --primary: #b3a2f0; + --primary-hover: #c7baf5; + --primary-soft: #272042; + --on-primary: #140d2b; + } + :root[data-accent="rose"]:not([data-theme="light"]) { + --primary: #f28fbd; + --primary-hover: #f6aecf; + --primary-soft: #3f1c2d; + --on-primary: #2a0a19; + } + :root[data-accent="crimson"]:not([data-theme="light"]) { + --primary: #f3908a; + --primary-hover: #f6aea9; + --primary-soft: #401b1a; + --on-primary: #2b0807; + } + :root[data-accent="amber"]:not([data-theme="light"]) { + --primary: #f2a65a; + --primary-hover: #f6bd84; + --primary-soft: #3d2711; + --on-primary: #241200; + } + :root[data-accent="graphite"]:not([data-theme="light"]) { + --primary: #b9c6ce; + --primary-hover: #cfd9df; + --primary-soft: #232c31; + --on-primary: #10171b; + } +} +:root[data-accent="ocean"][data-theme="dark"] { + --primary: #7ab4f0; + --primary-hover: #9cc8f5; + --primary-soft: #162a40; + --on-primary: #061527; +} +:root[data-accent="violet"][data-theme="dark"] { + --primary: #b3a2f0; + --primary-hover: #c7baf5; + --primary-soft: #272042; + --on-primary: #140d2b; +} +:root[data-accent="rose"][data-theme="dark"] { + --primary: #f28fbd; + --primary-hover: #f6aecf; + --primary-soft: #3f1c2d; + --on-primary: #2a0a19; +} +:root[data-accent="crimson"][data-theme="dark"] { + --primary: #f3908a; + --primary-hover: #f6aea9; + --primary-soft: #401b1a; + --on-primary: #2b0807; +} +:root[data-accent="amber"][data-theme="dark"] { + --primary: #f2a65a; + --primary-hover: #f6bd84; + --primary-soft: #3d2711; + --on-primary: #241200; +} +:root[data-accent="graphite"][data-theme="dark"] { + --primary: #b9c6ce; + --primary-hover: #cfd9df; + --primary-soft: #232c31; + --on-primary: #10171b; +}