web: in-app colour themes + light/dark (#26)
* web: in-app colour themes + light/dark
Adds a Theme picker (kebab → Theme) with five colour directions — Lit Room
(default), Lagoon, Sunny, Linen, and Classic (the original blue) — plus a
Light / System / Dark appearance toggle. The choice is a local preference
(localStorage) only; nothing about it ever reaches the server.
- style.css: rework :root into per-theme [data-theme] blocks with every
colour as light-dark(LIGHT, DARK); the appearance sets color-scheme on
<html> ("light dark" = follow the OS). Variabilised the remaining hardcoded
colours (status pills, danger/warn, shadows, code/reaction tints, me-bubble
ink) so dark mode is correct everywhere. New --sidebar + --font-display.
- ui.js: pure THEMES/APPEARANCES + normalizeTheme/normalizeAppearance/
colorScheme helpers (single source of truth) + unit tests.
- chat.js: applyTheme/setTheme/setAppearance + THEME_META; the picker sheet;
re-tints the status bar if the OS flips light/dark while on "system".
- index/join/admin <head>: a tiny pre-paint script applies the saved theme
before first paint (no flash). It mirrors ui.js — keep the two in sync.
Default theme is Lit Room. To keep Classic as the default, change
DEFAULT_THEME in ui.js and the fallback in the three <head> scripts.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* web: add Plum theme, grey the Classic sidebar, rename picker label to "Scheme"
- Six themes now (adds Plum, a soft grape) so the picker grid is balanced 2×3.
- Classic light mode gets a slightly grey sidebar (was pure white, same as panel).
- Picker section label "Colour" → "Scheme".
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* web: default to the Classic theme
Themes are opt-in: keep the original blue as the out-of-the-box look so
existing instances don't change appearance unprompted. The other five
(Lit Room, Lagoon, Sunny, Plum, Linen) are one tap away in the picker.
Flips DEFAULT_THEME in ui.js and the pre-paint <head> fallbacks.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* web: fix theme contrast to WCAG AA (all 6 themes, light + dark)
Audited every theme with WCAG contrast ratios and fixed the failures —
all concentrated in light mode:
- Muted text (timestamps, chat-list previews, names, subtitles) was
2.4–3.8:1 in every theme; darkened each theme's light --muted to ≥4.7.
- "Your bubble" (white on the me-colour) failed in Classic/Lagoon/Sunny/
Plum/Linen (Sunny worst at 2.8); deepened the light me-bubble to ≥4.6.
Also fixed Classic's dark me-bubble (was 4.2).
- Button labels (white on accent) failed Lagoon (2.6) and Linen (3.1);
deepened those light accents.
- Added a --link token: a darkened accent for accent-coloured *text*
(the "N replies" tag, link-style buttons, reply/swipe glyphs) so bright
warm accents (Lit Room honey, Sunny yellow) stay vivid as fills but are
legible as text. Wired reply-count, linkish, picker-action, reply-bar
and swipe icons, and the drop-overlay label to it.
Every theme now passes AA (>=4.5) for body text, bubble text, muted text,
buttons and links in both light and dark. Dark mode already passed and is
unchanged apart from Classic's me-bubble.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* web: clearer selected-chat state + lighter Classic sidebar
The grey Classic sidebar was nearly identical to the row highlight
(--bubble-them), so the selected chat vanished into it, and the grey read
as too heavy.
- Lighten Classic's light-mode sidebar (#eef0f5 -> #f4f6fb).
- Split thread-row hover vs active: the selected chat keeps the neutral
highlight and gains an accent bar down its left edge, drawn with --link
(the contrast-safe accent) so it's clearly visible in every theme and in
both light/dark — without tinting the row text (an accent-tinted fill
pushed the muted preview below AA).
- Nudge Lit Room's muted darker (#776a5e -> #736657) so preview text still
clears AA on the row-highlight background too.
All themes still pass AA across both modes.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* web: selected chat = a darker row background (drop the accent bar)
Replace the left-edge accent bar (which curved with the row's radius) with
a simple darker background for the active row, via a new --row-active token
(defaults to the hover grey; Classic overrides it to a clearly darker grey).
Classic's --muted is deepened a touch so the preview text still clears AA
on the darker selected row.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* web: tune Classic — lighter sidebar, darker selected row
Lighten the light-mode sidebar (#f4f6fb -> #f8f9fc) and deepen the active
row (#e3e7f1 -> #d9dfec) for a clearer selection. --muted nudged to #5a6170
so the preview text still clears AA on the darker selected row.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
7 files changed,
+429
−43
test/ui.test.mjs+37 −1web/pages/admin.html+16 −0web/pages/index.html+27 −0web/pages/join.html+24 −0web/static/chat.js+131 −1web/static/style.css+168 −41web/static/ui.js+26 −0
diff --git a/test/ui.test.mjs b/test/ui.test.mjs| index 6e1bfac..00114a8 100644 |
| --- a/test/ui.test.mjs |
| +++ b/test/ui.test.mjs |
| @@ -1,6 +1,17 @@ |
| // Tests for composer key handling. node test/ui.test.mjs |
| import assert from "node:assert/strict"; |
| -import { shouldSend, fileExt, fileAllowed, fileMime, fileIcon } from "../web/static/ui.js"; |
| +import { |
| + shouldSend, |
| + fileExt, |
| + fileAllowed, |
| + fileMime, |
| + fileIcon, |
| + normalizeTheme, |
| + normalizeAppearance, |
| + colorScheme, |
| + DEFAULT_THEME, |
| + DEFAULT_APPEARANCE, |
| +} from "../web/static/ui.js"; |
| let failures = 0; |
| async function test(name, fn) { |
| @@ -66,5 +77,30 @@ await test("fileIcon: mapped emoji, 📎 fallback", () => { |
| assert.equal(fileIcon("d.unknown"), "📎"); |
| }); |
| +await test("normalizeTheme: valid passes through, junk falls back", () => { |
| + for (const ok of ["litroom", "lagoon", "sunny", "plum", "linen", "classic"]) { |
| + assert.equal(normalizeTheme(ok), ok); |
| + } |
| + assert.equal(normalizeTheme("neon"), DEFAULT_THEME); |
| + assert.equal(normalizeTheme(null), DEFAULT_THEME); |
| + assert.equal(normalizeTheme(undefined), DEFAULT_THEME); |
| + assert.equal(normalizeTheme(""), DEFAULT_THEME); |
| +}); |
| + |
| +await test("normalizeAppearance: valid passes through, junk falls back", () => { |
| + for (const ok of ["light", "dark", "system"]) { |
| + assert.equal(normalizeAppearance(ok), ok); |
| + } |
| + assert.equal(normalizeAppearance("sepia"), DEFAULT_APPEARANCE); |
| + assert.equal(normalizeAppearance(null), DEFAULT_APPEARANCE); |
| +}); |
| + |
| +await test("colorScheme: system maps to 'light dark' so light-dark() follows the OS", () => { |
| + assert.equal(colorScheme("light"), "light"); |
| + assert.equal(colorScheme("dark"), "dark"); |
| + assert.equal(colorScheme("system"), "light dark"); |
| + assert.equal(colorScheme("anything-else"), "light dark"); |
| +}); |
| + |
| console.log(failures === 0 ? "\nALL PASSED" : `\n${failures} FAILED`); |
| process.exit(failures === 0 ? 0 : 1); |
diff --git a/web/pages/admin.html b/web/pages/admin.html| index 0bd2680..6ad8fec 100644 |
| --- a/web/pages/admin.html |
| +++ b/web/pages/admin.html |
| @@ -3,6 +3,22 @@ |
| <title>lchat · admin</title> |
| <link rel="stylesheet" href="/static/style.css" /> |
| +<!-- Apply the saved theme before first paint (see web/static/ui.js — the canonical |
| + list; this copy must stay in sync). A local preference, never sent to the server. --> |
| +<script> |
| + (function () { |
| + try { |
| + var THEMES = ["litroom", "lagoon", "sunny", "plum", "linen", "classic"]; |
| + var t = localStorage.getItem("lchat_theme"); |
| + if (THEMES.indexOf(t) < 0) t = "classic"; |
| + var a = localStorage.getItem("lchat_appearance"); |
| + var root = document.documentElement; |
| + root.dataset.theme = t; |
| + root.style.colorScheme = a === "light" ? "light" : a === "dark" ? "dark" : "light dark"; |
| + } catch (e) {} |
| + })(); |
| +</script> |
| + |
| <div class="admin" id="admin"> |
| <h1>lchat admin</h1> |
| <p class="sub">Create secret invite links. Each one lets someone pick a screen name and join the chat.</p> |
diff --git a/web/pages/index.html b/web/pages/index.html| index 9ef6ebc..2eb431d 100644 |
| --- a/web/pages/index.html |
| +++ b/web/pages/index.html |
| @@ -10,6 +10,32 @@ |
| <meta name="apple-mobile-web-app-capable" content="yes" /> |
| <meta name="apple-mobile-web-app-title" content="__GROUP_NAME__" /> |
| +<!-- Apply the saved theme + appearance before first paint (no flash of the |
| + default). The canonical list + defaults live in web/static/ui.js; this small |
| + copy must stay in sync. The choice is a local preference only — it is never |
| + sent to the server. --> |
| +<script> |
| + (function () { |
| + try { |
| + var THEMES = ["litroom", "lagoon", "sunny", "plum", "linen", "classic"]; |
| + var PANEL = { |
| + litroom: ["#fff8f0", "#241a1e"], lagoon: ["#f3fbfa", "#0c2a29"], |
| + sunny: ["#fffaf0", "#241d12"], plum: ["#fdf8fd", "#221829"], |
| + linen: ["#f5f1ea", "#211f1b"], classic: ["#ffffff", "#1d2026"], |
| + }; |
| + var t = localStorage.getItem("lchat_theme"); |
| + if (THEMES.indexOf(t) < 0) t = "classic"; |
| + var a = localStorage.getItem("lchat_appearance"); |
| + var root = document.documentElement; |
| + root.dataset.theme = t; |
| + root.style.colorScheme = a === "light" ? "light" : a === "dark" ? "dark" : "light dark"; |
| + var dark = a === "dark" || (a !== "light" && matchMedia("(prefers-color-scheme: dark)").matches); |
| + var m = document.querySelector('meta[name="theme-color"]'); |
| + if (m) m.setAttribute("content", PANEL[t][dark ? 1 : 0]); |
| + } catch (e) {} |
| + })(); |
| +</script> |
| + |
| <div id="app"></div> |
| <div id="toast" class="toast"></div> |
| @@ -29,6 +55,7 @@ |
| <button class="icon-btn" id="menu-btn" aria-label="Menu" aria-haspopup="true">⋮</button> |
| <div class="menu hidden" id="menu" role="menu"> |
| <button class="menu-item" id="menu-notif" role="menuitem">Notifications</button> |
| + <button class="menu-item" id="menu-theme" role="menuitem">Theme</button> |
| <button class="menu-item" id="menu-add-device" role="menuitem">Add a device</button> |
| <button class="menu-item" id="menu-rename" role="menuitem">Edit profile</button> |
| <button class="menu-item danger" id="menu-leave" role="menuitem">Leave</button> |
diff --git a/web/pages/join.html b/web/pages/join.html| index f5dc36a..45c1f4c 100644 |
| --- a/web/pages/join.html |
| +++ b/web/pages/join.html |
| @@ -11,6 +11,30 @@ |
| <meta name="apple-mobile-web-app-capable" content="yes" /> |
| <meta name="apple-mobile-web-app-title" content="__GROUP_NAME__" /> |
| +<!-- Apply the saved theme before first paint (see web/static/ui.js — the canonical |
| + list; this copy must stay in sync). A local preference, never sent to the server. --> |
| +<script> |
| + (function () { |
| + try { |
| + var THEMES = ["litroom", "lagoon", "sunny", "plum", "linen", "classic"]; |
| + var PANEL = { |
| + litroom: ["#fff8f0", "#241a1e"], lagoon: ["#f3fbfa", "#0c2a29"], |
| + sunny: ["#fffaf0", "#241d12"], plum: ["#fdf8fd", "#221829"], |
| + linen: ["#f5f1ea", "#211f1b"], classic: ["#ffffff", "#1d2026"], |
| + }; |
| + var t = localStorage.getItem("lchat_theme"); |
| + if (THEMES.indexOf(t) < 0) t = "classic"; |
| + var a = localStorage.getItem("lchat_appearance"); |
| + var root = document.documentElement; |
| + root.dataset.theme = t; |
| + root.style.colorScheme = a === "light" ? "light" : a === "dark" ? "dark" : "light dark"; |
| + var dark = a === "dark" || (a !== "light" && matchMedia("(prefers-color-scheme: dark)").matches); |
| + var m = document.querySelector('meta[name="theme-color"]'); |
| + if (m) m.setAttribute("content", PANEL[t][dark ? 1 : 0]); |
| + } catch (e) {} |
| + })(); |
| +</script> |
| + |
| <div class="center"> |
| <div class="card" id="card"> |
| <h1>Loading…</h1> |
diff --git a/web/static/chat.js b/web/static/chat.js| index 0d993ed..611a652 100644 |
| --- a/web/static/chat.js |
| +++ b/web/static/chat.js |
| @@ -19,7 +19,15 @@ import { |
| randomSecret, |
| deriveInvite, |
| } from "/static/crypto.js"; |
| -import { shouldSend, fileAllowed, fileMime, fileIcon } from "/static/ui.js"; |
| +import { |
| + shouldSend, |
| + fileAllowed, |
| + fileMime, |
| + fileIcon, |
| + normalizeTheme, |
| + normalizeAppearance, |
| + colorScheme, |
| +} from "/static/ui.js"; |
| import { richTokens, typingSummary, replySnippet, formatBytes } from "/static/format.js"; |
| import { emojify } from "/static/emoji.js"; |
| import { MESSAGE_TYPE_TYPING } from "/static/message.js"; |
| @@ -41,6 +49,127 @@ const isCoarse = matchMedia("(pointer: coarse)").matches; |
| const MAX_UPLOAD = 25 * 1024 * 1024; // matches maxUploadBytes (main.go) |
| const MAX_IMAGE_DIM = 2048; // long-edge cap; bigger images are downscaled in-browser |
| +// ---- themes ------------------------------------------------------------- |
| +// The colour theme + appearance are a purely local preference (localStorage), |
| +// never sent to the server. Valid values + defaults live in ui.js; the palettes |
| +// live in style.css; a tiny copy of this list runs pre-paint in the page <head>. |
| +const THEME_KEY = "lchat_theme"; |
| +const APPEARANCE_KEY = "lchat_appearance"; |
| +// Picker swatch colour + the light/dark surface colour (the mobile status-bar |
| +// tint). Keep keys in sync with THEMES in ui.js. |
| +const THEME_META = { |
| + litroom: { name: "Lit Room", disc: "#ffb23e", panel: { light: "#fff8f0", dark: "#241a1e" } }, |
| + lagoon: { name: "Lagoon", disc: "#16a4a0", panel: { light: "#f3fbfa", dark: "#0c2a29" } }, |
| + sunny: { name: "Sunny", disc: "#ffc62e", panel: { light: "#fffaf0", dark: "#241d12" } }, |
| + plum: { name: "Plum", disc: "#9d5cd6", panel: { light: "#fdf8fd", dark: "#221829" } }, |
| + linen: { name: "Linen", disc: "#7d94a8", panel: { light: "#f5f1ea", dark: "#211f1b" } }, |
| + classic: { name: "Classic", disc: "#5b6cff", panel: { light: "#ffffff", dark: "#1d2026" } }, |
| +}; |
| +const THEME_ORDER = ["litroom", "lagoon", "sunny", "plum", "linen", "classic"]; |
| + |
| +const currentTheme = () => normalizeTheme(localStorage.getItem(THEME_KEY)); |
| +const currentAppearance = () => normalizeAppearance(localStorage.getItem(APPEARANCE_KEY)); |
| + |
| +// Reflect the choice onto <html>: data-theme drives the palette, color-scheme |
| +// drives light-dark(), and the meta tag keeps the status bar in step. |
| +function applyTheme(theme, appearance) { |
| + const root = document.documentElement; |
| + root.dataset.theme = theme; |
| + root.style.colorScheme = colorScheme(appearance); |
| + const dark = |
| + appearance === "dark" || |
| + (appearance !== "light" && matchMedia("(prefers-color-scheme: dark)").matches); |
| + const meta = document.querySelector('meta[name="theme-color"]'); |
| + if (meta) meta.setAttribute("content", THEME_META[theme].panel[dark ? "dark" : "light"]); |
| +} |
| +function setTheme(theme) { |
| + localStorage.setItem(THEME_KEY, theme); |
| + applyTheme(theme, currentAppearance()); |
| +} |
| +function setAppearance(appearance) { |
| + localStorage.setItem(APPEARANCE_KEY, appearance); |
| + applyTheme(currentTheme(), appearance); |
| +} |
| +// Re-tint the status bar when the OS flips light/dark and we're following it. |
| +matchMedia("(prefers-color-scheme: dark)").addEventListener("change", () => { |
| + if (currentAppearance() === "system") applyTheme(currentTheme(), "system"); |
| +}); |
| +applyTheme(currentTheme(), currentAppearance()); |
| + |
| +// Theme picker sheet (kebab → Theme): an appearance segmented control + a grid |
| +// of colour swatches. Both apply live and persist; nothing leaves the browser. |
| +function showThemePicker() { |
| + const backdrop = modal(); |
| + const card = backdrop.querySelector(".sheet"); |
| + |
| + const h = document.createElement("h2"); |
| + h.textContent = "Theme"; |
| + card.appendChild(h); |
| + |
| + const apLabel = document.createElement("label"); |
| + apLabel.className = "field-label"; |
| + apLabel.textContent = "Appearance"; |
| + card.appendChild(apLabel); |
| + |
| + const seg = document.createElement("div"); |
| + seg.className = "appearance-seg"; |
| + const appBtns = {}; |
| + for (const [val, lab] of [ |
| + ["light", "Light"], |
| + ["system", "System"], |
| + ["dark", "Dark"], |
| + ]) { |
| + const b = document.createElement("button"); |
| + b.type = "button"; |
| + b.textContent = lab; |
| + b.setAttribute("aria-pressed", String(currentAppearance() === val)); |
| + b.onclick = () => { |
| + setAppearance(val); |
| + for (const [v, el] of Object.entries(appBtns)) el.setAttribute("aria-pressed", String(v === val)); |
| + }; |
| + appBtns[val] = b; |
| + seg.appendChild(b); |
| + } |
| + card.appendChild(seg); |
| + |
| + const thLabel = document.createElement("label"); |
| + thLabel.className = "field-label"; |
| + thLabel.textContent = "Scheme"; |
| + thLabel.style.marginTop = "14px"; |
| + card.appendChild(thLabel); |
| + |
| + const grid = document.createElement("div"); |
| + grid.className = "theme-grid"; |
| + const swatches = {}; |
| + for (const id of THEME_ORDER) { |
| + const b = document.createElement("button"); |
| + b.type = "button"; |
| + b.className = "theme-swatch" + (currentTheme() === id ? " active" : ""); |
| + const disc = document.createElement("span"); |
| + disc.className = "disc"; |
| + disc.style.background = THEME_META[id].disc; |
| + const nm = document.createElement("span"); |
| + nm.textContent = THEME_META[id].name; |
| + b.appendChild(disc); |
| + b.appendChild(nm); |
| + b.onclick = () => { |
| + setTheme(id); |
| + for (const [v, el] of Object.entries(swatches)) el.classList.toggle("active", v === id); |
| + }; |
| + swatches[id] = b; |
| + grid.appendChild(b); |
| + } |
| + card.appendChild(grid); |
| + |
| + const foot = document.createElement("div"); |
| + foot.className = "sheet-foot"; |
| + const done = document.createElement("button"); |
| + done.textContent = "Done"; |
| + done.onclick = () => backdrop.remove(); |
| + foot.appendChild(done); |
| + card.appendChild(foot); |
| +} |
| + |
| let pending = []; // attachments staged in the composer, to send with the next message |
| let attachSeq = 0; // local id counter for staged attachments (drives per-chip removal) |
| const blobURLs = {}; // blob id -> decrypted object URL (cache) |
| @@ -445,6 +574,7 @@ function renderApp() { |
| closeMenu(); |
| fn(); |
| }); |
| + wireItem("menu-theme", showThemePicker); |
| wireItem("menu-add-device", addDevice); |
| wireItem("menu-rename", editProfile); |
| wireItem("menu-leave", leave); |
diff --git a/web/static/style.css b/web/static/style.css| index 74b2dfa..d151b30 100644 |
| --- a/web/static/style.css |
| +++ b/web/static/style.css |
| @@ -1,15 +1,126 @@ |
| -:root { |
| - --bg: #f6f7fb; |
| - --panel: #ffffff; |
| - --ink: #1c2333; |
| - --muted: #7a8295; |
| - --accent: #5b6cff; |
| - --accent-ink: #ffffff; |
| - --bubble-me: #5b6cff; |
| - --bubble-them: #eef0f6; |
| +/* ---- themes ---------------------------------------------------------------- |
| + Every colour is light-dark(LIGHT, DARK); the chosen appearance sets |
| + `color-scheme` on <html> (light / dark / "light dark" = follow the OS), so a |
| + single set of tokens drives both modes. Themes are selected with a |
| + data-theme attribute on <html>. The app sets it to "classic" by default (see |
| + DEFAULT_THEME in ui.js); the bare :root values below double as the Lit Room |
| + theme and the no-JS fallback. Structural tokens (radius, fonts, shadow, pills, |
| + status colours) live here once; theme blocks only override what differs. Order |
| + matters: themes come after this block so they win on equal specificity. */ |
| +:root, |
| +[data-theme="litroom"] { |
| --radius: 18px; |
| - --border: #e7e9f0; |
| -} |
| + --font-display: ui-rounded, "SF Pro Rounded", "Avenir Next", -apple-system, system-ui, sans-serif; |
| + /* depth + status colours are theme-agnostic */ |
| + --shadow: light-dark(rgba(20, 30, 80, 0.10), rgba(0, 0, 0, 0.55)); |
| + --danger: light-dark(#c2410c, #f0936b); |
| + --warn: light-dark(#b4530a, #e29457); |
| + --pill-bg: light-dark(#23252e, #eef0f6); |
| + --pill-ink: light-dark(#ffffff, #1c2333); |
| + /* selected-chat row background; defaults to the hover grey, themes may override */ |
| + --row-active: var(--bubble-them); |
| + |
| + /* Lit Room — warm dusk, honey glow */ |
| + --bg: light-dark(#efe3d6, #1b1418); |
| + --panel: light-dark(#fff8f0, #241a1e); |
| + --sidebar: light-dark(#fbf1e6, #1f161a); |
| + --ink: light-dark(#2a211b, #f4e9dc); |
| + --muted: light-dark(#736657, #b09a89); |
| + --border: light-dark(#ece2d4, rgba(255, 255, 255, 0.10)); |
| + --accent: light-dark(#f0a93c, #ffb23e); |
| + --accent-ink: light-dark(#3a2412, #2a1808); |
| + --link: light-dark(#956925, #ffb23e); |
| + --bubble-me: light-dark(#ffb23e, #ffb23e); |
| + --bubble-me-ink: light-dark(#3a2412, #2a1808); |
| + --bubble-them: light-dark(#f1ece4, #2f2329); |
| +} |
| + |
| +/* Classic — the original lchat blue (now with a dark mode) */ |
| +[data-theme="classic"] { |
| + --font-display: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; |
| + --bg: light-dark(#f6f7fb, #15171c); |
| + --panel: light-dark(#ffffff, #1d2026); |
| + --sidebar: light-dark(#f8f9fc, #1a1d22); |
| + --ink: light-dark(#1c2333, #e7e9f0); |
| + --muted: light-dark(#5a6170, #9aa1b3); |
| + --border: light-dark(#e7e9f0, rgba(255, 255, 255, 0.10)); |
| + --accent: light-dark(#5666f0, #7d8bff); |
| + --accent-ink: light-dark(#ffffff, #0a0e2a); |
| + --link: light-dark(#5666f0, #7d8bff); |
| + --bubble-me: light-dark(#5666f0, #5666f0); |
| + --bubble-me-ink: light-dark(#ffffff, #ffffff); |
| + --bubble-them: light-dark(#eef0f6, #2a2e38); |
| + --row-active: light-dark(#d9dfec, #2a2e38); |
| +} |
| + |
| +/* Lagoon — cool, fresh; a coral accent that pops */ |
| +[data-theme="lagoon"] { |
| + --bg: light-dark(#d7ecea, #06201f); |
| + --panel: light-dark(#f3fbfa, #0c2a29); |
| + --sidebar: light-dark(#e7f6f4, #082523); |
| + --ink: light-dark(#123a3a, #d8efec); |
| + --muted: light-dark(#4c7271, #6fa6a3); |
| + --border: light-dark(#d2e8e6, rgba(255, 255, 255, 0.10)); |
| + --accent: light-dark(#b85840, #ff8a6a); |
| + --accent-ink: light-dark(#ffffff, #2a0f08); |
| + --link: light-dark(#b2553e, #ff8a6a); |
| + --bubble-me: light-dark(#0e827e, #16b3ad); |
| + --bubble-me-ink: light-dark(#ffffff, #042220); |
| + --bubble-them: light-dark(#ffffff, #123634); |
| +} |
| + |
| +/* Sunny — bright-side daytime */ |
| +[data-theme="sunny"] { |
| + --bg: light-dark(#ffeecb, #1c1710); |
| + --panel: light-dark(#fffaf0, #241d12); |
| + --sidebar: light-dark(#fff4df, #1f190f); |
| + --ink: light-dark(#3a2f1c, #f6ecd6); |
| + --muted: light-dark(#796d57, #b6a079); |
| + --border: light-dark(#f0e6d0, rgba(255, 255, 255, 0.10)); |
| + --accent: light-dark(#ffc62e, #ffc62e); |
| + --accent-ink: light-dark(#3a2f1c, #2a2106); |
| + --link: light-dark(#8a6b19, #ffc62e); |
| + --bubble-me: light-dark(#2c7ab0, #3aa0e8); |
| + --bubble-me-ink: light-dark(#ffffff, #04243a); |
| + --bubble-them: light-dark(#ffffff, #2e2616); |
| +} |
| + |
| +/* Linen — quiet, grown-up; warmth through restraint */ |
| +[data-theme="linen"] { |
| + --font-display: "Avenir Next", "Helvetica Neue", -apple-system, system-ui, sans-serif; |
| + --bg: light-dark(#e7e1d6, #181714); |
| + --panel: light-dark(#f5f1ea, #211f1b); |
| + --sidebar: light-dark(#ece6dc, #1c1b17); |
| + --ink: light-dark(#33302a, #ece7dd); |
| + --muted: light-dark(#69655c, #9c958a); |
| + --border: light-dark(#e2dccf, rgba(255, 255, 255, 0.10)); |
| + --accent: light-dark(#647686, #93a8ba); |
| + --accent-ink: light-dark(#ffffff, #0c1217); |
| + --link: light-dark(#5c6e7c, #93a8ba); |
| + --bubble-me: light-dark(#647686, #7d94a8); |
| + --bubble-me-ink: light-dark(#ffffff, #0c1217); |
| + --bubble-them: light-dark(#fbf9f4, #2a2823); |
| +} |
| + |
| +/* Plum — soft grape; playful but not childish, light or deep-aubergine dark */ |
| +[data-theme="plum"] { |
| + --bg: light-dark(#ece2f0, #1a1320); |
| + --panel: light-dark(#fdf8fd, #221829); |
| + --sidebar: light-dark(#f4ecf6, #1e1525); |
| + --ink: light-dark(#2f2436, #f0e6f4); |
| + --muted: light-dark(#6f6578, #ab9bb5); |
| + --border: light-dark(#e7dced, rgba(255, 255, 255, 0.10)); |
| + --accent: light-dark(#9456c9, #b57ae8); |
| + --accent-ink: light-dark(#ffffff, #20102e); |
| + --link: light-dark(#9456c9, #b57ae8); |
| + --bubble-me: light-dark(#9456c9, #b57ae8); |
| + --bubble-me-ink: light-dark(#ffffff, #20102e); |
| + --bubble-them: light-dark(#f1e8f5, #2e2238); |
| +} |
| + |
| +/* Default appearance: follow the OS. The app overrides this on <html> per the |
| + saved preference (light / dark / system). */ |
| +html { color-scheme: light dark; } |
| * { box-sizing: border-box; } |
| @@ -50,7 +161,7 @@ input, textarea { |
| border: 1px solid var(--border); |
| border-radius: 12px; |
| padding: 12px 14px; |
| - background: #fff; |
| + background: var(--panel); |
| color: var(--ink); |
| width: 100%; |
| caret-color: var(--accent); /* the typing cursor picks up the accent (à la iMessage) */ |
| @@ -74,9 +185,9 @@ input:focus, textarea:focus { outline: 2px solid var(--accent); border-color: tr |
| padding: 28px; |
| width: 100%; |
| max-width: 420px; |
| - box-shadow: 0 12px 40px rgba(20, 30, 80, 0.06); |
| + box-shadow: 0 12px 40px var(--shadow); |
| } |
| -.card h1 { margin: 0 0 6px; font-size: 24px; } |
| +.card h1 { margin: 0 0 6px; font-size: 24px; font-family: var(--font-display); } |
| .card p { margin: 0 0 18px; color: var(--muted); } |
| .card .row { display: flex; gap: 10px; margin-top: 14px; } |
| @@ -104,7 +215,7 @@ input:focus, textarea:focus { outline: 2px solid var(--accent); border-color: tr |
| background: var(--panel); |
| border: 1px solid var(--border); |
| border-radius: 14px; |
| - box-shadow: 0 12px 40px rgba(20, 30, 80, 0.14); |
| + box-shadow: 0 12px 40px var(--shadow); |
| min-width: 230px; |
| padding: 6px; |
| z-index: 20; |
| @@ -121,7 +232,7 @@ input:focus, textarea:focus { outline: 2px solid var(--accent); border-color: tr |
| white-space: normal; |
| } |
| .menu-item:hover { background: var(--bubble-them); } |
| -.menu-item.danger { color: #c2410c; } |
| +.menu-item.danger { color: var(--danger); } |
| .menu-item.disabled, |
| .menu-item.disabled:hover { color: var(--muted); cursor: default; background: transparent; } |
| @@ -157,7 +268,7 @@ input:focus, textarea:focus { outline: 2px solid var(--accent); border-color: tr |
| top: 50%; |
| left: 0; |
| transform: translate(-130%, -50%); |
| - color: var(--accent); |
| + color: var(--link); |
| opacity: 0; |
| pointer-events: none; |
| display: inline-flex; |
| @@ -177,13 +288,13 @@ input:focus, textarea:focus { outline: 2px solid var(--accent); border-color: tr |
| .msg .bubble code { |
| font-family: ui-monospace, SFMono-Regular, Menlo, monospace; |
| font-size: 0.92em; |
| - background: rgba(0, 0, 0, 0.06); |
| + background: color-mix(in srgb, var(--ink) 8%, transparent); |
| padding: 1px 5px; |
| border-radius: 5px; |
| } |
| -.msg.me .bubble code { background: rgba(255, 255, 255, 0.22); } |
| +.msg.me .bubble code { background: color-mix(in srgb, var(--bubble-me-ink) 22%, transparent); } |
| .msg.me { align-self: flex-end; align-items: flex-end; } |
| -.msg.me .bubble { background: var(--bubble-me); color: #fff; border-bottom-right-radius: 6px; } |
| +.msg.me .bubble { background: var(--bubble-me); color: var(--bubble-me-ink); border-bottom-right-radius: 6px; } |
| .msg.them .bubble { border-bottom-left-radius: 6px; } |
| .msg .bubble a { color: inherit; text-decoration: underline; text-underline-offset: 2px; } |
| @@ -296,7 +407,7 @@ input:focus, textarea:focus { outline: 2px solid var(--accent); border-color: tr |
| position: absolute; |
| inset: 0; |
| z-index: 40; |
| - background: rgba(91, 108, 255, 0.12); |
| + background: color-mix(in srgb, var(--accent) 12%, transparent); |
| border: 3px dashed var(--accent); |
| border-radius: 12px; |
| display: flex; |
| @@ -304,7 +415,7 @@ input:focus, textarea:focus { outline: 2px solid var(--accent); border-color: tr |
| justify-content: center; |
| font-weight: 700; |
| font-size: 18px; |
| - color: var(--accent); |
| + color: var(--link); |
| pointer-events: none; |
| } |
| .file-chip { |
| @@ -320,7 +431,7 @@ input:focus, textarea:focus { outline: 2px solid var(--accent); border-color: tr |
| max-width: 260px; |
| word-break: break-word; |
| } |
| -.msg.me .file-chip { background: var(--bubble-me); color: #fff; } |
| +.msg.me .file-chip { background: var(--bubble-me); color: var(--bubble-me-ink); } |
| .file-chip .file-icon { font-size: 22px; line-height: 1; flex: none; } |
| .file-chip .file-meta { display: flex; flex-direction: column; min-width: 0; } |
| .file-chip .file-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } |
| @@ -386,7 +497,7 @@ input:focus, textarea:focus { outline: 2px solid var(--accent); border-color: tr |
| top: 50%; |
| transform: translateY(-50%); |
| background: var(--ink); |
| - color: #fff; |
| + color: var(--panel); |
| width: 22px; |
| height: 22px; |
| padding: 0; |
| @@ -413,7 +524,7 @@ input:focus, textarea:focus { outline: 2px solid var(--accent); border-color: tr |
| font-weight: 500; |
| line-height: 1.35; |
| } |
| -.reaction-chip.mine { border-color: var(--accent); background: #e7eaff; } |
| +.reaction-chip.mine { border-color: var(--accent); background: color-mix(in srgb, var(--accent) 16%, var(--panel)); } |
| .emoji-picker { |
| position: fixed; |
| display: flex; |
| @@ -422,7 +533,7 @@ input:focus, textarea:focus { outline: 2px solid var(--accent); border-color: tr |
| border: 1px solid var(--border); |
| border-radius: 999px; |
| padding: 4px 6px; |
| - box-shadow: 0 12px 40px rgba(20, 30, 80, 0.18); |
| + box-shadow: 0 12px 40px var(--shadow); |
| z-index: 60; |
| } |
| .emoji-opt { |
| @@ -436,7 +547,7 @@ input:focus, textarea:focus { outline: 2px solid var(--accent); border-color: tr |
| /* the "Reply" action in the message menu, set off from the emoji row */ |
| .picker-action { |
| background: transparent; |
| - color: var(--accent); |
| + color: var(--link); |
| padding: 4px 8px; |
| margin-left: 2px; |
| border-radius: 50%; |
| @@ -478,7 +589,7 @@ input:focus, textarea:focus { outline: 2px solid var(--accent); border-color: tr |
| .reply-count { |
| font-size: 11px; |
| font-weight: 600; |
| - color: var(--accent); |
| + color: var(--link); |
| margin: 3px 6px 0; |
| cursor: pointer; |
| } |
| @@ -487,7 +598,7 @@ input:focus, textarea:focus { outline: 2px solid var(--accent); border-color: tr |
| .msg.flash { animation: flash-msg 1.2s ease; border-radius: var(--radius); } |
| @keyframes flash-msg { |
| 0%, 100% { background: transparent; } |
| - 25% { background: rgba(91, 108, 255, 0.14); } |
| + 25% { background: color-mix(in srgb, var(--accent) 16%, transparent); } |
| } |
| /* composer "Replying to …" / "Editing message" banners, above the composer |
| @@ -503,7 +614,7 @@ input:focus, textarea:focus { outline: 2px solid var(--accent); border-color: tr |
| .composer-edit { display: flex; align-items: center; justify-content: space-between; color: var(--muted); font-size: 14px; } |
| .banner-x { background: transparent; color: var(--muted); font-size: 20px; line-height: 1; padding: 0 6px; } |
| .reply-bar { display: flex; align-items: center; gap: 10px; } |
| -.reply-bar-icon { flex: none; color: var(--accent); display: inline-flex; } |
| +.reply-bar-icon { flex: none; color: var(--link); display: inline-flex; } |
| .reply-bar-icon svg { width: 18px; height: 18px; } |
| .reply-bar-body { |
| flex: 1; |
| @@ -596,7 +707,7 @@ input:focus, textarea:focus { outline: 2px solid var(--accent); border-color: tr |
| align-items: center; |
| justify-content: center; |
| background: var(--accent); |
| - color: #fff; |
| + color: var(--accent-ink); |
| } |
| .send-btn[hidden] { display: none; } |
| .send-btn:disabled { opacity: 0.45; cursor: default; } |
| @@ -663,7 +774,7 @@ input:focus, textarea:focus { outline: 2px solid var(--accent); border-color: tr |
| color: var(--muted); |
| padding: 6px; |
| } |
| -.banner.warn { color: #b4530a; } |
| +.banner.warn { color: var(--warn); } |
| /* ---- admin ---- */ |
| @@ -700,7 +811,7 @@ input:focus, textarea:focus { outline: 2px solid var(--accent); border-color: tr |
| .hidden { display: none !important; } |
| .toast { |
| position: fixed; left: 50%; bottom: 24px; transform: translateX(-50%); |
| - background: var(--ink); color: #fff; padding: 10px 16px; border-radius: 999px; |
| + background: var(--pill-bg); color: var(--pill-ink); padding: 10px 16px; border-radius: 999px; |
| font-size: 14px; opacity: 0; transition: opacity .2s; pointer-events: none; |
| z-index: 100; /* above full-screen overlays (qr-screen) so it's never hidden */ |
| } |
| @@ -731,23 +842,23 @@ input:focus, textarea:focus { outline: 2px solid var(--accent); border-color: tr |
| left: 50%; |
| transform: translateX(-50%); |
| z-index: 90; |
| - background: var(--ink); |
| - color: #fff; |
| + background: var(--pill-bg); |
| + color: var(--pill-ink); |
| font-size: 13px; |
| font-weight: 600; |
| padding: 6px 14px; |
| border-radius: 999px; |
| - box-shadow: 0 6px 20px rgba(20, 30, 80, 0.25); |
| + box-shadow: 0 6px 20px var(--shadow); |
| } |
| .conn-status.hidden { display: none; } |
| -.sidebar { width: 320px; flex: none; display: flex; flex-direction: column; border-right: 1px solid var(--border); min-height: 0; } |
| +.sidebar { width: 320px; flex: none; display: flex; flex-direction: column; background: var(--sidebar); border-right: 1px solid var(--border); min-height: 0; } |
| /* Both headers share a height so the thread pane's header lines up with the |
| sidebar's. Only the thread header carries a divider — the sidebar's "Chats" |
| header flows straight into the chat list (with the search field below it). */ |
| .side-head, .thread-head { box-sizing: border-box; min-height: 62px; padding: 8px 16px; } |
| .thread-head { border-bottom: 1px solid var(--border); } |
| .side-head { display: flex; align-items: center; gap: 8px; } |
| -.side-head .title { flex: 1; min-width: 0; font-weight: 700; font-size: 17px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } |
| +.side-head .title { flex: 1; min-width: 0; font-family: var(--font-display); font-weight: 700; font-size: 17px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } |
| .head-actions { display: flex; align-items: center; gap: 2px; flex: none; } |
| .avatar { |
| display: inline-flex; align-items: center; justify-content: center; flex: none; |
| @@ -774,7 +885,8 @@ input:focus, textarea:focus { outline: 2px solid var(--accent); border-color: tr |
| display: flex; align-items: center; gap: 12px; width: 100%; text-align: left; |
| background: transparent; color: var(--ink); border-radius: 14px; padding: 9px 14px; |
| } |
| -.thread-row:hover, .thread-row.active { background: var(--bubble-them); } |
| +.thread-row:hover { background: var(--bubble-them); } |
| +.thread-row.active { background: var(--row-active); } |
| /* Bigger avatar in the chat list (à la WhatsApp/iMessage); scoped to the list so |
| header/profile/reaction avatars keep their sizes. */ |
| .thread-row .avatar { width: 52px; height: 52px; font-size: 21px; } |
| @@ -803,7 +915,7 @@ input:focus, textarea:focus { outline: 2px solid var(--accent); border-color: tr |
| .thread-view { flex: 1; display: flex; flex-direction: column; min-height: 0; min-width: 0; width: 100%; position: relative; } |
| .thread-head { display: flex; align-items: center; gap: 6px; } |
| .thread-head-title { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 1px; } |
| -.thread-title { font-weight: 700; font-size: 16px; line-height: 1.2; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } |
| +.thread-title { font-family: var(--font-display); font-weight: 700; font-size: 16px; line-height: 1.2; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } |
| .thread-sub { font-size: 12px; color: var(--muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } |
| .back-btn { display: none; font-size: 24px; padding: 0 8px; } |
| .feed { flex: 1; overflow-y: auto; overflow-x: hidden; min-height: 0; min-width: 0; padding: 16px; display: flex; flex-direction: column; gap: 4px; } |
| @@ -818,7 +930,7 @@ input:focus, textarea:focus { outline: 2px solid var(--accent); border-color: tr |
| .sheet { max-width: 460px; width: 100%; max-height: 82vh; overflow-y: auto; } |
| .sheet-head { display: flex; align-items: center; justify-content: space-between; gap: 10px; margin-bottom: 10px; } |
| .sheet-head h2 { margin: 0; } |
| -.linkish { background: transparent; color: var(--accent); padding: 6px 8px; font-weight: 600; } |
| +.linkish { background: transparent; color: var(--link); padding: 6px 8px; font-weight: 600; } |
| .sheet-input { margin-bottom: 10px; } |
| /* full-bleed list inside the sheet: rows span the card edges (which pads 28px) */ |
| .pick-list { margin: 4px -28px 0; max-height: 56vh; overflow-y: auto; } |
| @@ -832,6 +944,21 @@ input:focus, textarea:focus { outline: 2px solid var(--accent); border-color: tr |
| .list-row input { width: auto; } |
| .sheet-foot { display: flex; justify-content: flex-end; gap: 10px; margin-top: 16px; } |
| +/* theme picker (kebab → Theme) */ |
| +.appearance-seg { display: flex; gap: 6px; background: var(--bubble-them); border-radius: 12px; padding: 4px; } |
| +.appearance-seg button { flex: 1; background: transparent; color: var(--muted); border-radius: 9px; padding: 9px; font-weight: 600; } |
| +.appearance-seg button[aria-pressed="true"] { background: var(--panel); color: var(--ink); box-shadow: 0 1px 4px var(--shadow); } |
| +.theme-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 10px; } |
| +.theme-swatch { |
| + display: flex; align-items: center; gap: 11px; text-align: left; |
| + background: var(--panel); color: var(--ink); |
| + border: 1px solid var(--border); border-radius: 14px; padding: 12px; |
| + font-weight: 600; font-family: var(--font-display); |
| +} |
| +.theme-swatch:hover { background: var(--bubble-them); } |
| +.theme-swatch.active { border-color: var(--accent); box-shadow: inset 0 0 0 1px var(--accent); } |
| +.theme-swatch .disc { width: 26px; height: 26px; border-radius: 50%; flex: none; box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.12); } |
| + |
| /* mobile: show one pane at a time, toggled by .show-thread */ |
| @media (max-width: 720px) { |
| .sidebar { width: 100%; border-right: none; } |
diff --git a/web/static/ui.js b/web/static/ui.js| index 20228c8..a0461c5 100644 |
| --- a/web/static/ui.js |
| +++ b/web/static/ui.js |
| @@ -1,5 +1,31 @@ |
| // Small, framework-free UI helpers, kept here so they can be unit-tested in Node. |
| +// ---- themes ------------------------------------------------------------- |
| +// Colour themes + appearance live entirely client-side (a localStorage pref, |
| +// never the server). These pure helpers are the single source of truth for the |
| +// valid values + defaults; chat.js applies them and the pre-paint script in the |
| +// page <head> mirrors this list (keep them in sync). |
| +export const THEMES = ["litroom", "lagoon", "sunny", "plum", "linen", "classic"]; |
| +export const APPEARANCES = ["light", "dark", "system"]; |
| +export const DEFAULT_THEME = "classic"; |
| +export const DEFAULT_APPEARANCE = "system"; |
| + |
| +// Coerce a stored (possibly stale/garbage) value to a valid theme/appearance. |
| +export function normalizeTheme(v) { |
| + return THEMES.includes(v) ? v : DEFAULT_THEME; |
| +} |
| +export function normalizeAppearance(v) { |
| + return APPEARANCES.includes(v) ? v : DEFAULT_APPEARANCE; |
| +} |
| + |
| +// The CSS `color-scheme` value for an appearance choice. "system" yields |
| +// "light dark" so light-dark() in the stylesheet follows the OS setting. |
| +export function colorScheme(appearance) { |
| + if (appearance === "light") return "light"; |
| + if (appearance === "dark") return "dark"; |
| + return "light dark"; |
| +} |
| + |
| // shouldSend decides whether a keydown in the composer should send the message. |
| // |
| // Desktop (precise pointer): Enter sends, Shift+Enter inserts a newline. |