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>
3 files changed,
+98
−76
CLAUDE.md+3 −0web/static/shell.js+4 −76web/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()); |