feat(kez-chat/web): light theme + Light/Dark/System toggle

Some users want light. Dark stays the default/brand; light is a
first-class option.

  • app.css: :root[data-theme="light"] overrides the @theme token values
    (deeper cyan accent so it's legible as text on white; light elevation
    ramp + text tiers + semantic colors). Every utility is var(--color-*),
    so flipping the vars flips the whole app — no per-component work.
  • lib/theme.svelte.ts: choice (light/dark/system) persisted to
    localStorage; "system" follows prefers-color-scheme live; sets
    <html data-theme> + syncs the mobile theme-color meta.
  • index.html: inline pre-paint script resolves the theme before first
    render to avoid a flash of the wrong palette.
  • Settings: new Appearance section with a Light/Dark/System segmented
    control + a hint line ("Following your device (dark)").
  • EmojiButton: picker now follows the app theme (was hardcoded white).
  • main.ts: side-effect import so the system-theme listener is always
    live.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Jason Tudisco 2026-05-27 22:31:27 -06:00
parent 0d7e48bed0
commit fc75b27ac6
6 changed files with 154 additions and 4 deletions

View File

@ -29,6 +29,24 @@
<!-- Web App Manifest (generated by vite-plugin-pwa) + Android theme color -->
<link rel="manifest" href="/manifest.webmanifest" />
<meta name="theme-color" content="#0b0c0e" />
<!-- Resolve the theme before first paint to avoid a flash of the wrong
palette. Mirrors lib/theme.svelte.ts. -->
<script>
(function () {
try {
var c = localStorage.getItem("kez-chat:theme") || "system";
var light =
c === "light" ||
(c === "system" &&
window.matchMedia &&
window.matchMedia("(prefers-color-scheme: light)").matches);
document.documentElement.dataset.theme = light ? "light" : "dark";
} catch (e) {
document.documentElement.dataset.theme = "dark";
}
})();
</script>
</head>
<body>
<div id="app"></div>

View File

@ -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);

View File

@ -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);
}
</style>

View File

@ -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 <html> 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<ThemeChoice>(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();

View File

@ -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")!,

View File

@ -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}
<div class="max-w-2xl mx-auto space-y-6">
<!-- Appearance -->
<section class="bg-surface border border-border rounded-xl p-6">
<h2 class="text-sm font-semibold text-text uppercase tracking-wider mb-3">Appearance</h2>
<div class="inline-flex rounded-md border border-border overflow-hidden">
{#each themeOptions as opt, i}
<button
class={`px-4 py-1.5 text-sm transition-colors ${i > 0 ? "border-l border-border" : ""} ${theme.choice === opt.value ? "bg-accent text-accent-contrast font-semibold" : "text-text-secondary hover:bg-elevated hover:text-text"}`}
onclick={() => theme.set(opt.value)}
>
{opt.label}
</button>
{/each}
</div>
<p class="mt-2 text-xs text-text-muted">
{#if theme.choice === "system"}
Following your device ({theme.effective}). Change your OS setting and it tracks automatically.
{:else}
Always {theme.choice}.
{/if}
</p>
</section>
<!-- Security -->
<section class="bg-surface border border-border rounded-xl p-6 space-y-5">
<h2 class="text-sm font-semibold text-text uppercase tracking-wider">Security</h2>