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:
parent
0d7e48bed0
commit
fc75b27ac6
@ -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>
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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>
|
||||
|
||||
73
kez-chat/web/src/lib/theme.svelte.ts
Normal file
73
kez-chat/web/src/lib/theme.svelte.ts
Normal 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();
|
||||
@ -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")!,
|
||||
|
||||
@ -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>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user