| 1 | // Colour theme, appearance (light/dark/system), and desktop text size — purely |
| 2 | // local preferences (localStorage), never sent to the server. Valid values + |
| 3 | // defaults live in ui.js; the palettes live in style.css; a tiny copy of the |
| 4 | // theme list runs pre-paint in the page <head>. Applying happens at import |
| 5 | // time, so importing this module is enough to paint the saved preference. |
| 6 | // (Extracted verbatim from shell.js — the first slice of breaking the shell |
| 7 | // into modules.) |
| 8 | import { |
| 9 | normalizeTheme, |
| 10 | normalizeAppearance, |
| 11 | colorScheme, |
| 12 | normalizeTextSize, |
| 13 | DEFAULT_TEXT_SIZE, |
| 14 | } from "/static/ui.js"; |
| 15 | |
| 16 | // ---- themes ------------------------------------------------------------- |
| 17 | const THEME_KEY = "lchat_theme"; |
| 18 | const APPEARANCE_KEY = "lchat_appearance"; |
| 19 | // Picker swatch colour + the light/dark surface colour (the mobile status-bar |
| 20 | // tint). Keep keys in sync with THEMES in ui.js. |
| 21 | export const THEME_META = { |
| 22 | litroom: { name: "Lit Room", disc: "#ffb23e", panel: { light: "#fff8f0", dark: "#241a1e" } }, |
| 23 | lagoon: { name: "Lagoon", disc: "#16a4a0", panel: { light: "#f3fbfa", dark: "#0c2a29" } }, |
| 24 | sunny: { name: "Sunny", disc: "#ffc62e", panel: { light: "#fffaf0", dark: "#241d12" } }, |
| 25 | plum: { name: "Plum", disc: "#9d5cd6", panel: { light: "#fdf8fd", dark: "#221829" } }, |
| 26 | linen: { name: "Linen", disc: "#7d94a8", panel: { light: "#f5f1ea", dark: "#211f1b" } }, |
| 27 | classic: { name: "Classic", disc: "#5b6cff", panel: { light: "#ffffff", dark: "#1d2026" } }, |
| 28 | }; |
| 29 | export const THEME_ORDER = ["litroom", "lagoon", "sunny", "plum", "linen", "classic"]; |
| 30 | |
| 31 | export const currentTheme = () => normalizeTheme(localStorage.getItem(THEME_KEY)); |
| 32 | export const currentAppearance = () => normalizeAppearance(localStorage.getItem(APPEARANCE_KEY)); |
| 33 | |
| 34 | // Whoever hosts live-retintable surfaces (the sandbox frames) registers here; |
| 35 | // applyTheme pings it after each change. A hook instead of an import keeps this |
| 36 | // module dependency-light and usable from any page. |
| 37 | let themeSink = null; |
| 38 | export function onThemeApplied(fn) { |
| 39 | themeSink = fn; |
| 40 | } |
| 41 | |
| 42 | // Reflect the choice onto <html>: data-theme drives the palette, color-scheme |
| 43 | // drives light-dark(), and the meta tag keeps the status bar in step. |
| 44 | function applyTheme(theme, appearance) { |
| 45 | const root = document.documentElement; |
| 46 | root.dataset.theme = theme; |
| 47 | root.style.colorScheme = colorScheme(appearance); |
| 48 | const dark = |
| 49 | appearance === "dark" || |
| 50 | (appearance !== "light" && matchMedia("(prefers-color-scheme: dark)").matches); |
| 51 | const meta = document.querySelector('meta[name="theme-color"]'); |
| 52 | if (meta) meta.setAttribute("content", THEME_META[theme].panel[dark ? "dark" : "light"]); |
| 53 | // Live-push the new theme to any mounted sandbox frames so they re-tint |
| 54 | // in place, rather than waiting to be re-displayed. |
| 55 | if (themeSink) themeSink(); |
| 56 | } |
| 57 | export function setTheme(theme) { |
| 58 | localStorage.setItem(THEME_KEY, theme); |
| 59 | applyTheme(theme, currentAppearance()); |
| 60 | } |
| 61 | export function setAppearance(appearance) { |
| 62 | localStorage.setItem(APPEARANCE_KEY, appearance); |
| 63 | applyTheme(currentTheme(), appearance); |
| 64 | } |
| 65 | // Re-tint the status bar when the OS flips light/dark and we're following it. |
| 66 | matchMedia("(prefers-color-scheme: dark)").addEventListener("change", () => { |
| 67 | if (currentAppearance() === "system") applyTheme(currentTheme(), "system"); |
| 68 | }); |
| 69 | applyTheme(currentTheme(), currentAppearance()); |
| 70 | |
| 71 | // ---- text size ------------------------------------------------------------ |
| 72 | // Desktop-only, like the Mac iMessage slider: the picked size drives the |
| 73 | // --msg-font-size variable (bubbles + composer). Touch devices skip all of |
| 74 | // this — they follow the system text size via -apple-system-body in style.css. |
| 75 | // Local preference only, same as the theme. The native Mac app sets the same |
| 76 | // variable from its own View-menu / Settings pref instead of this key. |
| 77 | const TEXT_SIZE_KEY = "lchat_text_size"; |
| 78 | const isCoarse = matchMedia("(pointer: coarse)").matches; |
| 79 | export const currentTextSize = () => normalizeTextSize(localStorage.getItem(TEXT_SIZE_KEY)); |
| 80 | function applyTextSize(px) { |
| 81 | if (isCoarse) return; |
| 82 | // At the default, clear the override so the stylesheet's value (and any |
| 83 | // future retune of it) rules; only a deliberate choice pins an inline size. |
| 84 | if (px === DEFAULT_TEXT_SIZE) document.documentElement.style.removeProperty("--msg-font-size"); |
| 85 | else document.documentElement.style.setProperty("--msg-font-size", px + "px"); |
| 86 | } |
| 87 | export function setTextSize(px) { |
| 88 | localStorage.setItem(TEXT_SIZE_KEY, String(px)); |
| 89 | applyTextSize(px); |
| 90 | } |
| 91 | applyTextSize(currentTextSize()); |
| 92 | |