Colour themes, synced across devices (beta)
ci / build (ubuntu-22.04) (push) Canceled after 0s
ci / build (windows-2025) (push) Canceled after 0s
ci / deny (push) Canceled after 0s

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 <[email protected]>
This commit is contained in:
2026-10-27 00:23:42 +07:00
co-authored by Claude Opus 5.5
parent fb53b7d907
commit 10a9507aa2
4 changed files with 241 additions and 1 deletions
+22
View File
@@ -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<String>,
appearance: Option<String>,
}
#[tauri::command]
fn app_theme(app: State<'_, App>) -> CmdResult<ThemeView> {
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<String>, appearance: Option<String>) -> 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<PasskeyView> {
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,
+92 -1
View File
@@ -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<SyncStatus>("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)));
+14
View File
@@ -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; }
+113
View File
@@ -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;
}