// ── Theme presets & the closed sets an admin may choose from ─────────────── // // The single authority for admin-configurable theming (docs/website/THEMING_AND_NAV.md // §5-§6). Everything an admin can pick is enumerated here; nothing is free text. // // Why the server owns this rather than theme.css: // The effective token set is resolved server-side and returned by // settings.getPublic() as `theme`, which the SPA writes onto the document as // CSS custom properties. That keeps ONE authority for the override merge // (:root ← preset ← custom), lets brand.accent — a cross-repo contract the // Android app themes itself from — report the same accent the website paints, // and avoids the precedence trap of `[data-theme]` blocks losing to the inline // `--accent` SiteContext already sets on . // // theme.css's `:root` remains the default and is NOT duplicated here beyond // the runic-gateway preset. An instance with no `theme_visual` row gets no // `theme` block at all and renders from :root exactly as it does today. // // Security note: these values end up as CSS custom property values. Every one is // picked from a closed set (a preset id, a shortlist stack, a bounded px length, // a hex color) — see utils/themeResolve.js, which both the write path and the // read path validate through. // The three color tokens that are semantic rather than decorative. They mean // "live" and "maintenance" and stay fixed across every preset — green is not a // brand choice. Deliberately absent from every preset block below. const FIXED_TOKENS = ['--mode-live', '--mode-maint'] // Full palettes. A preset must carry EVERY color token, not just the eight the // admin form exposes: a partial palette leaves e.g. --line and --blue at their // dark-blue :root values, which reads as broken on a warm background. // // --panel-grad is deliberately absent: it is derived (`linear-gradient(180deg, // var(--panel-a), var(--panel-b))`) and must stay derived, or a future light // preset silently inherits a dark gradient. const PRESETS = { // Today's :root, verbatim. Declared as a preset so that switching back to it // after trying another is the same code path as any other choice. 'runic-gateway': { label: 'Runic Gateway', tokens: { '--bg': '#0e1318', '--bg-deep': '#0b0f14', '--panel-a': '#192231', '--panel-b': '#141a21', '--panel-flat': '#11161d', '--line': '#2a3544', '--line-soft': '#1d2733', '--accent': '#7f99bd', '--accent-bright': '#cdd9e8', '--ink': '#eef3f8', '--head': '#e6edf6', '--text': '#c4cdd8', '--muted': '#aeb8c4', '--dim': '#6f7d8e', '--blue': '#13243c', '--radius-pill': '999px', '--radius-panel': '12px', '--radius-card': '10px', '--radius-input': '8px', '--shadow-card': '0 14px 34px rgba(0, 0, 0, 0.3)', '--serif': 'Georgia, "Times New Roman", serif', '--display': 'Cinzel, Georgia, serif', '--sans': '"Helvetica Neue", Arial, sans-serif', }, }, // Flatter, cooler, sans-heavy. Reads as a SaaS dashboard, not fantasy. modern: { label: 'Modern', tokens: { '--bg': '#101114', '--bg-deep': '#0a0a0c', '--panel-a': '#1c1d22', '--panel-b': '#17181c', '--panel-flat': '#141519', '--line': '#2b2d34', '--line-soft': '#212329', '--accent': '#4f8ef7', '--accent-bright': '#a8c8ff', '--ink': '#f2f3f5', '--head': '#f7f8fa', '--text': '#b8bcc4', '--muted': '#a9aeb8', '--dim': '#71767f', '--blue': '#1b2c47', '--radius-pill': '8px', '--radius-panel': '8px', '--radius-card': '6px', '--radius-input': '6px', '--shadow-card': '0 8px 20px rgba(0, 0, 0, 0.25)', '--serif': 'Inter, Arial, sans-serif', '--display': "'Work Sans', Arial, sans-serif", '--sans': 'Inter, Arial, sans-serif', }, }, // Warmer, higher contrast, carved corners; leans into UO harder. fantasy: { label: 'Fantasy', tokens: { '--bg': '#1a120b', '--bg-deep': '#120c07', '--panel-a': '#2c1f14', '--panel-b': '#241a10', '--panel-flat': '#1f160d', '--line': '#4a3721', '--line-soft': '#33251a', '--accent': '#c9973f', '--accent-bright': '#e8c374', '--ink': '#f3e8d4', '--head': '#f7efe0', '--text': '#d3bfa0', '--muted': '#bfa985', '--dim': '#8a7454', '--blue': '#382613', '--radius-pill': '4px', '--radius-panel': '3px', '--radius-card': '2px', '--radius-input': '2px', '--shadow-card': '0 16px 38px rgba(0, 0, 0, 0.45)', '--serif': "'EB Garamond', Georgia, serif", '--display': 'Cinzel, Georgia, serif', '--sans': "'EB Garamond', Georgia, serif", }, }, } // 'custom' is a valid stored preset meaning "no preset base" — :root plus // whatever custom fields are set. It has no palette of its own. const CUSTOM_PRESET = 'custom' const PRESET_IDS = [...Object.keys(PRESETS), CUSTOM_PRESET] // The colors the admin form exposes, mapped to their CSS token. Deliberately // the eight of §6.1 rather than all fifteen: the rest are supporting shades a // preset sets coherently but that are not worth (or safe to) hand-picking. const COLOR_FIELDS = { bg: '--bg', bgDeep: '--bg-deep', panelA: '--panel-a', panelB: '--panel-b', accent: '--accent', accentBright: '--accent-bright', ink: '--ink', text: '--text', } const RADIUS_FIELDS = { radiusPill: '--radius-pill', radiusPanel: '--radius-panel', radiusCard: '--radius-card', radiusInput: '--radius-input', } const FONT_FIELDS = { serif: '--serif', display: '--display', sans: '--sans', } // The curated Google Fonts shortlist (§5.1). The dropdown's VALUE is the full // stack exactly as applied, so no string is ever built from admin input and no // Google Fonts URL is ever assembled at runtime — the combined css2? request in // client/index.html is static and covers all eight web families. // // One addition to §5.1's twelve: Georgia in the serif list. The shortlist as // drafted gave the sans role a "current default" option (Arial, byte-identical // to today's --sans) but left the serif role with no way back to today's // `Georgia, "Times New Roman", serif` short of resetting the whole theme. It // pulls in no web family, so §5.2's URL is unchanged. const FONT_OPTIONS = { serif: [ { value: "'EB Garamond', Georgia, serif", label: 'EB Garamond — strongest fantasy/historic' }, { value: 'Merriweather, Georgia, serif', label: 'Merriweather — excellent readability' }, { value: "'Playfair Display', Georgia, serif", label: 'Playfair Display — elegant/editorial' }, { value: "'IM Fell English', Georgia, serif", label: 'IM Fell English — old-world (no bold weight)' }, { value: 'Georgia, "Times New Roman", serif', label: 'Georgia — the shipped default' }, ], display: [ { value: 'Cinzel, Georgia, serif', label: 'Cinzel — current Runic Gateway identity' }, { value: "'Playfair Display', Georgia, serif", label: 'Playfair Display — elegant alternative' }, { value: "'EB Garamond', Georgia, serif", label: 'EB Garamond — softer/classic' }, { value: "'IM Fell English', Georgia, serif", label: 'IM Fell English — very strong fantasy (no bold weight)' }, ], sans: [ { value: 'Inter, Arial, sans-serif', label: 'Inter — default modern UI choice' }, { value: "'Work Sans', Arial, sans-serif", label: 'Work Sans — slightly more character' }, { value: "'Source Sans 3', Arial, sans-serif", label: 'Source Sans 3 — extremely readable' }, { value: '"Helvetica Neue", Arial, sans-serif', label: 'Arial — no webfont; the shipped default' }, ], } // Shadow depth, as a closed set for the same reason fonts are: the stored value // is applied verbatim as --shadow-card. const SHADOW_OPTIONS = [ { value: 'none', label: 'None — flat' }, { value: '0 8px 20px rgba(0, 0, 0, 0.25)', label: 'Soft' }, { value: '0 14px 34px rgba(0, 0, 0, 0.3)', label: 'Default' }, { value: '0 18px 44px rgba(0, 0, 0, 0.45)', label: 'Deep' }, ] // Corner radius is a number, not a shortlist, so it is bounded instead: an // integer count of px from 0 to 999 (999 being the pill). const RADIUS_MAX_PX = 999 module.exports = { PRESETS, PRESET_IDS, CUSTOM_PRESET, FIXED_TOKENS, COLOR_FIELDS, RADIUS_FIELDS, FONT_FIELDS, FONT_OPTIONS, SHADOW_OPTIONS, RADIUS_MAX_PX, }