diff --git a/kez-chat/web/index.html b/kez-chat/web/index.html index fad9c0d..b729c7f 100644 --- a/kez-chat/web/index.html +++ b/kez-chat/web/index.html @@ -29,6 +29,24 @@ + + +
diff --git a/kez-chat/web/src/app.css b/kez-chat/web/src/app.css index 7ca8b05..d1b3b7d 100644 --- a/kez-chat/web/src/app.css +++ b/kez-chat/web/src/app.css @@ -49,6 +49,31 @@ } /* ─── Base ─────────────────────────────────────────────────────────────── */ +/* Light theme — overrides the dark @theme token values. These utilities + all compile to var(--color-*), so flipping the variable values flips + the whole app. Unlayered + after @theme, so it wins regardless of + specificity. An inline script in index.html sets data-theme before + paint to avoid a flash; the Settings toggle updates it at runtime. */ +:root[data-theme="light"] { + color-scheme: light; + --color-bg: #f6f7f9; + --color-surface: #ffffff; + --color-elevated: #eef0f3; + --color-border: #dce0e6; + --color-text: #15181d; + --color-text-secondary: #5a626d; + --color-text-muted: #8b929c; + --color-text-disabled: #b8bdc5; + /* Deeper cyan so accent-as-text is legible on white; fills still read cyan. */ + --color-accent: #0e90b4; + --color-accent-dim: #0a7290; + --color-accent-contrast: #ffffff; + --color-verified: #15a34a; + --color-danger: #dc2626; + --color-warning: #c2700a; + --color-bubble-recv: #eceff3; +} + :root { font-family: var(--font-sans); color: var(--color-text); diff --git a/kez-chat/web/src/lib/EmojiButton.svelte b/kez-chat/web/src/lib/EmojiButton.svelte index 11ffd78..c614419 100644 --- a/kez-chat/web/src/lib/EmojiButton.svelte +++ b/kez-chat/web/src/lib/EmojiButton.svelte @@ -5,6 +5,7 @@ // clicks are instant (module cached). import { onDestroy } from "svelte"; + import { theme } from "./theme.svelte.js"; interface Props { /** Called with the picked emoji's character. */ @@ -34,8 +35,8 @@ await import("emoji-picker-element"); pickerEl = document.createElement("emoji-picker") as HTMLElement; pickerEl.addEventListener("emoji-click", onEmojiClick as EventListener); - // Match the dark-on-light scheme of the rest of the app. - pickerEl.classList.add("light"); + // Follow the app theme (emoji-picker-element honors .light/.dark). + pickerEl.classList.add(theme.effective); } catch (e) { loadError = (e as Error).message; } finally { @@ -116,7 +117,7 @@ is themeable here. Width comes from the component itself (~340px). */ :global(emoji-picker) { height: 360px; - --background: #fff; - --border-color: #e5e7eb; + --background: var(--color-elevated); + --border-color: var(--color-border); } diff --git a/kez-chat/web/src/lib/theme.svelte.ts b/kez-chat/web/src/lib/theme.svelte.ts new file mode 100644 index 0000000..e5999a9 --- /dev/null +++ b/kez-chat/web/src/lib/theme.svelte.ts @@ -0,0 +1,73 @@ +// Light / dark / system theme switching. +// +// The CSS lives in app.css: dark is the @theme default, and a +// :root[data-theme="light"] block overrides the token values. This +// module just decides which data-theme attribute to set on and +// persists the user's choice. +// +// "system" follows prefers-color-scheme live (re-applies when the OS +// flips). An inline script in index.html sets the initial attribute +// before first paint to avoid a flash of the wrong theme. + +export type ThemeChoice = "light" | "dark" | "system"; + +const KEY = "kez-chat:theme"; + +function systemPrefersLight(): boolean { + return ( + typeof matchMedia !== "undefined" && + matchMedia("(prefers-color-scheme: light)").matches + ); +} + +function effectiveOf(choice: ThemeChoice): "light" | "dark" { + if (choice === "system") return systemPrefersLight() ? "light" : "dark"; + return choice; +} + +function loadChoice(): ThemeChoice { + const v = (typeof localStorage !== "undefined" && localStorage.getItem(KEY)) || ""; + return v === "light" || v === "dark" || v === "system" ? v : "system"; +} + +class ThemeStore { + choice = $state(loadChoice()); + /** The resolved theme actually applied right now. */ + effective = $state<"light" | "dark">(effectiveOf(loadChoice())); + + constructor() { + // Re-apply when the OS theme changes, but only while on "system". + if (typeof matchMedia !== "undefined") { + matchMedia("(prefers-color-scheme: light)").addEventListener("change", () => { + if (this.choice === "system") this.#apply(); + }); + } + } + + set(choice: ThemeChoice) { + this.choice = choice; + try { + localStorage.setItem(KEY, choice); + } catch { + /* private mode / storage disabled — fine, just won't persist */ + } + this.#apply(); + } + + #apply() { + this.effective = effectiveOf(this.choice); + if (typeof document !== "undefined") { + document.documentElement.dataset.theme = this.effective; + // Keep the mobile browser-chrome (status bar) color in sync. + const meta = document.querySelector('meta[name="theme-color"]'); + if (meta) { + meta.setAttribute( + "content", + this.effective === "light" ? "#f6f7f9" : "#0b0c0e", + ); + } + } + } +} + +export const theme = new ThemeStore(); diff --git a/kez-chat/web/src/main.ts b/kez-chat/web/src/main.ts index 2930448..b7300a3 100644 --- a/kez-chat/web/src/main.ts +++ b/kez-chat/web/src/main.ts @@ -1,6 +1,10 @@ import { mount } from "svelte"; import App from "./App.svelte"; import "./app.css"; +// Side-effect: instantiate the theme store so its prefers-color-scheme +// listener is live app-wide (the no-flash initial is set by the inline +// script in index.html). +import "./lib/theme.svelte.js"; const app = mount(App, { target: document.getElementById("app")!, diff --git a/kez-chat/web/src/routes/Settings.svelte b/kez-chat/web/src/routes/Settings.svelte index 5cedf01..893b7be 100644 --- a/kez-chat/web/src/routes/Settings.svelte +++ b/kez-chat/web/src/routes/Settings.svelte @@ -16,6 +16,13 @@ requestNotificationsPermission, fireTestNotification, } from "../lib/inbox-service.svelte.js"; + import { theme, type ThemeChoice } from "../lib/theme.svelte.js"; + + const themeOptions: { value: ThemeChoice; label: string }[] = [ + { value: "light", label: "Light" }, + { value: "dark", label: "Dark" }, + { value: "system", label: "System" }, + ]; let biometricSupported = $state(false); let biometricEnrolled = $state(false); @@ -95,6 +102,28 @@ {#if session.unlocked}
+ +
+

Appearance

+
+ {#each themeOptions as opt, i} + + {/each} +
+

+ {#if theme.choice === "system"} + Following your device ({theme.effective}). Change your OS setting and it tracks automatically. + {:else} + Always {theme.choice}. + {/if} +

+
+

Security