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 -->
|
<!-- Web App Manifest (generated by vite-plugin-pwa) + Android theme color -->
|
||||||
<link rel="manifest" href="/manifest.webmanifest" />
|
<link rel="manifest" href="/manifest.webmanifest" />
|
||||||
<meta name="theme-color" content="#0b0c0e" />
|
<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>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="app"></div>
|
<div id="app"></div>
|
||||||
|
|||||||
@ -49,6 +49,31 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* ─── Base ─────────────────────────────────────────────────────────────── */
|
/* ─── 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 {
|
:root {
|
||||||
font-family: var(--font-sans);
|
font-family: var(--font-sans);
|
||||||
color: var(--color-text);
|
color: var(--color-text);
|
||||||
|
|||||||
@ -5,6 +5,7 @@
|
|||||||
// clicks are instant (module cached).
|
// clicks are instant (module cached).
|
||||||
|
|
||||||
import { onDestroy } from "svelte";
|
import { onDestroy } from "svelte";
|
||||||
|
import { theme } from "./theme.svelte.js";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
/** Called with the picked emoji's character. */
|
/** Called with the picked emoji's character. */
|
||||||
@ -34,8 +35,8 @@
|
|||||||
await import("emoji-picker-element");
|
await import("emoji-picker-element");
|
||||||
pickerEl = document.createElement("emoji-picker") as HTMLElement;
|
pickerEl = document.createElement("emoji-picker") as HTMLElement;
|
||||||
pickerEl.addEventListener("emoji-click", onEmojiClick as EventListener);
|
pickerEl.addEventListener("emoji-click", onEmojiClick as EventListener);
|
||||||
// Match the dark-on-light scheme of the rest of the app.
|
// Follow the app theme (emoji-picker-element honors .light/.dark).
|
||||||
pickerEl.classList.add("light");
|
pickerEl.classList.add(theme.effective);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
loadError = (e as Error).message;
|
loadError = (e as Error).message;
|
||||||
} finally {
|
} finally {
|
||||||
@ -116,7 +117,7 @@
|
|||||||
is themeable here. Width comes from the component itself (~340px). */
|
is themeable here. Width comes from the component itself (~340px). */
|
||||||
:global(emoji-picker) {
|
:global(emoji-picker) {
|
||||||
height: 360px;
|
height: 360px;
|
||||||
--background: #fff;
|
--background: var(--color-elevated);
|
||||||
--border-color: #e5e7eb;
|
--border-color: var(--color-border);
|
||||||
}
|
}
|
||||||
</style>
|
</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 { mount } from "svelte";
|
||||||
import App from "./App.svelte";
|
import App from "./App.svelte";
|
||||||
import "./app.css";
|
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, {
|
const app = mount(App, {
|
||||||
target: document.getElementById("app")!,
|
target: document.getElementById("app")!,
|
||||||
|
|||||||
@ -16,6 +16,13 @@
|
|||||||
requestNotificationsPermission,
|
requestNotificationsPermission,
|
||||||
fireTestNotification,
|
fireTestNotification,
|
||||||
} from "../lib/inbox-service.svelte.js";
|
} 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 biometricSupported = $state(false);
|
||||||
let biometricEnrolled = $state(false);
|
let biometricEnrolled = $state(false);
|
||||||
@ -95,6 +102,28 @@
|
|||||||
|
|
||||||
{#if session.unlocked}
|
{#if session.unlocked}
|
||||||
<div class="max-w-2xl mx-auto space-y-6">
|
<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 -->
|
<!-- Security -->
|
||||||
<section class="bg-surface border border-border rounded-xl p-6 space-y-5">
|
<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>
|
<h2 class="text-sm font-semibold text-text uppercase tracking-wider">Security</h2>
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user