eleven / ocho Public

web: extract themes + text size into theme.js — first shell.js module slice (#913)

Verbatim move of the two sections; the one outward call (pushTheme, the
sandbox re-tint) becomes an onThemeApplied() hook so the preference
module stays dependency-light. Applies saved prefs at import time, same
as before (slightly earlier in boot; the <head> pre-paint already covers
first paint). shell.js drops the five ui.js imports the module now owns.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell 0ef936d6c5bf5562b73b8226d8a111eb50921bc7 parent 3466b00
3 files changed, +98 −76
  • CLAUDE.md +3 −0
  • web/static/shell.js +4 −76
  • web/static/theme.js +91 −0
diff --git a/CLAUDE.md b/CLAUDE.md
index 2faf19b..fc0ec31 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -174,6 +174,9 @@ Frontend module layout:
it's the live header-avatar zoom.)
- `format.js`, `ui.js` — **pure, framework-free helpers kept separate so they're
unit-testable in Node** (`tokenizeText`, `formatBytes`, `shouldSend`).
+- `theme.js` — colour theme / appearance / desktop text size (local-only
+ prefs; applies them at import time). First slice of breaking `shell.js`
+ into modules — extract sections verbatim, leaf-most first.
- `store.js` — the local message store (#457): IndexedDB cache of RAW server
responses (ciphertext rows + wrapped keys, never plaintext) that boot and
thread-opens hydrate from before reconciling over the network. Its pure
diff --git a/web/static/shell.js b/web/static/shell.js
index ce7e447..874040a 100644
--- a/web/static/shell.js
+++ b/web/static/shell.js
@@ -33,12 +33,7 @@ import {
fileAllowed,
fileMime,
fileIcon,
- normalizeTheme,
- normalizeAppearance,
- colorScheme,
- normalizeTextSize,
TEXT_SIZES,
- DEFAULT_TEXT_SIZE,
composerToken,
pinnedFirst,
ownMessage,
@@ -140,6 +135,8 @@ import { openMemoryPanel } from "/static/apps/memory.js"; // /memory — the AI-
import { setHost as setChatAppHost, setConnected as setChatAppConnected, appendNotice, setNoticeTyping, feedGlideToBottom, scrollToMessage } from "/static/apps/chat.js";
import { SearchIndex, snippetFor } from "/static/search.js"; // message search over the local store (#586)
import { loadMemberApps, setMemberSeeder, setSurfaceRefresh, setMemberAdmin, recentLocalDeploy, pushTheme } from "/static/sandbox.js"; // member-built apps, run sandboxed (docs/member-apps.md)
+import { THEME_META, THEME_ORDER, currentTheme, currentAppearance, setTheme, setAppearance, currentTextSize, setTextSize, onThemeApplied } from "/static/theme.js"; // applies the saved theme/text-size on import
+
import { parseInviteFragment, buildInviteFragment } from "/static/invite.js";
import { qrCanvas } from "/static/qrcode.js";
import { initBackupBanner, canRestore, launchRestore, checkRestoreReturn, launchEnrol, isEnrolled, deferBackupNudges } from "/static/backup.js";
@@ -176,6 +173,8 @@ import {
} from "/static/store.js";
import { anyComposerHasText, formatAsYouTypePref, setFormatAsYouType, classicComposerPref, setClassicComposer } from "/static/composer.js";
+onThemeApplied(pushTheme); // live-retint mounted sandbox frames on theme change
+
const SESSION_KEY = "lchat_session";
const ACTIVE_KEY = "lchat_active"; // last-open thread id, so a refresh lands back in it
const isCoarse = matchMedia("(pointer: coarse)").matches;
@@ -186,77 +185,6 @@ const MAX_UPLOAD = 25 * 1024 * 1024; // matches the default upload cap (main.go)
const MAX_VIDEO_UPLOAD = 50 * 1024 * 1024;
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"]);
- // Live-push the new theme to any mounted sandbox frames so they re-tint
- // in place, rather than waiting to be re-displayed.
- pushTheme();
-}
-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());
-
-// ---- text size ------------------------------------------------------------
-// Desktop-only, like the Mac iMessage slider: the picked size drives the
-// --msg-font-size variable (bubbles + composer). Touch devices skip all of
-// this — they follow the system text size via -apple-system-body in style.css.
-// Local preference only, same as the theme. The native Mac app sets the same
-// variable from its own View-menu / Settings pref instead of this key.
-const TEXT_SIZE_KEY = "lchat_text_size";
-const currentTextSize = () => normalizeTextSize(localStorage.getItem(TEXT_SIZE_KEY));
-function applyTextSize(px) {
- if (isCoarse) return;
- // At the default, clear the override so the stylesheet's value (and any
- // future retune of it) rules; only a deliberate choice pins an inline size.
- if (px === DEFAULT_TEXT_SIZE) document.documentElement.style.removeProperty("--msg-font-size");
- else document.documentElement.style.setProperty("--msg-font-size", px + "px");
-}
-function setTextSize(px) {
- localStorage.setItem(TEXT_SIZE_KEY, String(px));
- applyTextSize(px);
-}
-applyTextSize(currentTextSize());
-
// ---- split view divider ----------------------------------------------------
// Desktop-only Chat|Interface split (see the [data-app-view="split"] CSS):
// #app-split-divider lets a member drag the boundary between #app-pane and
diff --git a/web/static/theme.js b/web/static/theme.js
new file mode 100644
index 0000000..90abc35
--- /dev/null
+++ b/web/static/theme.js
@@ -0,0 +1,91 @@
+// Colour theme, appearance (light/dark/system), and desktop text size — purely
+// local preferences (localStorage), never sent to the server. Valid values +
+// defaults live in ui.js; the palettes live in style.css; a tiny copy of the
+// theme list runs pre-paint in the page <head>. Applying happens at import
+// time, so importing this module is enough to paint the saved preference.
+// (Extracted verbatim from shell.js — the first slice of breaking the shell
+// into modules.)
+import {
+ normalizeTheme,
+ normalizeAppearance,
+ colorScheme,
+ normalizeTextSize,
+ DEFAULT_TEXT_SIZE,
+} from "/static/ui.js";
+
+// ---- themes -------------------------------------------------------------
+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.
+export 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" } },
+};
+export const THEME_ORDER = ["litroom", "lagoon", "sunny", "plum", "linen", "classic"];
+
+export const currentTheme = () => normalizeTheme(localStorage.getItem(THEME_KEY));
+export const currentAppearance = () => normalizeAppearance(localStorage.getItem(APPEARANCE_KEY));
+
+// Whoever hosts live-retintable surfaces (the sandbox frames) registers here;
+// applyTheme pings it after each change. A hook instead of an import keeps this
+// module dependency-light and usable from any page.
+let themeSink = null;
+export function onThemeApplied(fn) {
+ themeSink = fn;
+}
+
+// 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"]);
+ // Live-push the new theme to any mounted sandbox frames so they re-tint
+ // in place, rather than waiting to be re-displayed.
+ if (themeSink) themeSink();
+}
+export function setTheme(theme) {
+ localStorage.setItem(THEME_KEY, theme);
+ applyTheme(theme, currentAppearance());
+}
+export 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());
+
+// ---- text size ------------------------------------------------------------
+// Desktop-only, like the Mac iMessage slider: the picked size drives the
+// --msg-font-size variable (bubbles + composer). Touch devices skip all of
+// this — they follow the system text size via -apple-system-body in style.css.
+// Local preference only, same as the theme. The native Mac app sets the same
+// variable from its own View-menu / Settings pref instead of this key.
+const TEXT_SIZE_KEY = "lchat_text_size";
+const isCoarse = matchMedia("(pointer: coarse)").matches;
+export const currentTextSize = () => normalizeTextSize(localStorage.getItem(TEXT_SIZE_KEY));
+function applyTextSize(px) {
+ if (isCoarse) return;
+ // At the default, clear the override so the stylesheet's value (and any
+ // future retune of it) rules; only a deliberate choice pins an inline size.
+ if (px === DEFAULT_TEXT_SIZE) document.documentElement.style.removeProperty("--msg-font-size");
+ else document.documentElement.style.setProperty("--msg-font-size", px + "px");
+}
+export function setTextSize(px) {
+ localStorage.setItem(TEXT_SIZE_KEY, String(px));
+ applyTextSize(px);
+}
+applyTextSize(currentTextSize());