eleven / ocho Public

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>
Paul Campbell 06459d2cc5354741d11c9d16d53b7300a5c5f40a parent 6429a70
7 files changed, +429 −43
  • test/ui.test.mjs +37 −1
  • web/pages/admin.html +16 −0
  • web/pages/index.html +27 −0
  • web/pages/join.html +24 −0
  • web/static/chat.js +131 −1
  • web/static/style.css +168 −41
  • web/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.