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; +}