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 <[email protected]>
This commit is contained in:
@@ -408,6 +408,26 @@ fn resolve_save(app: State<'_, App>, id: String, accept: bool) -> CmdResult<()>
|
|||||||
Ok(())
|
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]
|
#[tauri::command]
|
||||||
fn pending_passkeys(app: State<'_, App>) -> Vec<PasskeyView> {
|
fn pending_passkeys(app: State<'_, App>) -> Vec<PasskeyView> {
|
||||||
app.broker.pending_passkeys()
|
app.broker.pending_passkeys()
|
||||||
@@ -730,6 +750,8 @@ fn main() {
|
|||||||
pending_saves,
|
pending_saves,
|
||||||
pending_passkeys,
|
pending_passkeys,
|
||||||
set_tags,
|
set_tags,
|
||||||
|
app_theme,
|
||||||
|
set_app_theme,
|
||||||
sign_in_providers,
|
sign_in_providers,
|
||||||
resolve_passkey,
|
resolve_passkey,
|
||||||
resolve_save,
|
resolve_save,
|
||||||
|
|||||||
+92
-1
@@ -111,6 +111,64 @@ function setTheme(t: Theme) {
|
|||||||
|
|
||||||
applyTheme(storedTheme());
|
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
|
// ------------------------------------------------------------------ chrome
|
||||||
|
|
||||||
/// The custom title bar in `bg`: native window controls (macOS traffic lights sit over its left edge).
|
/// 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("role", "group");
|
||||||
theme.setAttribute("aria-label", "Appearance");
|
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");
|
const list = h("div", "kv-card rows");
|
||||||
for (const pr of pairings) {
|
for (const pr of pairings) {
|
||||||
list.append(h("div", "kv-frow",
|
list.append(h("div", "kv-frow",
|
||||||
@@ -784,7 +871,10 @@ async function renderSettings(target: HTMLElement, state: AppState) {
|
|||||||
h("h2", "kv-title-2", "Settings"),
|
h("h2", "kv-title-2", "Settings"),
|
||||||
section("General",
|
section("General",
|
||||||
h("div", "kv-card rows",
|
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))),
|
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("Sync", syncSection),
|
||||||
section("Devices", devicesSection),
|
section("Devices", devicesSection),
|
||||||
@@ -1157,6 +1247,7 @@ void listen<SyncStatus>("sync-status", (e) => {
|
|||||||
showSync(s);
|
showSync(s);
|
||||||
if (lastChanges !== -1 && s.changes !== lastChanges && refreshList && syncBox?.isConnected) void refreshList();
|
if (lastChanges !== -1 && s.changes !== lastChanges && refreshList && syncBox?.isConnected) void refreshList();
|
||||||
lastChanges = s.changes;
|
lastChanges = s.changes;
|
||||||
|
void adoptSyncedTheme();
|
||||||
if (s.integrity_alarm && !alarmShown) {
|
if (s.integrity_alarm && !alarmShown) {
|
||||||
alarmShown = true;
|
alarmShown = true;
|
||||||
openModal(integrityBanner(), h("div", "actions end", button("OK", "primary", closeModal)));
|
openModal(integrityBanner(), h("div", "actions end", button("OK", "primary", closeModal)));
|
||||||
|
|||||||
@@ -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 { 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; }
|
.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; }
|
.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; }
|
||||||
|
|||||||
@@ -122,3 +122,116 @@
|
|||||||
--shadow: 0 1px 2px #00000059, 0 8px 24px -8px #00000080;
|
--shadow: 0 1px 2px #00000059, 0 8px 24px -8px #00000080;
|
||||||
--shadow-lg: 0 24px 64px -16px #000000b3;
|
--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;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user