# Customizer Rework Spec ## Overview The current customizer (`public/customize.js`) suffers from fundamental state management issues documented in [issue #284](https://github.com/Kpa-clawbot/CoreScope/issues/284). State is scattered across 7 localStorage keys, CSS updates bypass the data layer, and there's no single source of truth for the effective configuration. This spec defines a clean rework based on event-driven state management with a single data flow path. The goal: predictable state, minimal storage footprint, portable config format, and zero ambiguity about which values are active and why. ## Design Decisions These are agreed and final. Do not reinterpret or deviate. 1. **Three state layers:** server defaults (immutable after fetch), user overrides (delta in localStorage), effective config (computed via merge, never stored directly). 2. **Single data flow:** user action → debounce (~300ms) → write delta to localStorage → read back from localStorage → merge with server defaults → apply CSS variables. No shortcuts, no optimistic CSS updates (see Decision #12 for the one exception). 3. **One localStorage key:** `cs-theme-overrides` — replaces the current 7 scattered keys (`meshcore-user-theme`, `meshcore-timestamp-mode`, `meshcore-timestamp-timezone`, `meshcore-timestamp-format`, `meshcore-timestamp-custom-format`, `meshcore-heatmap-opacity`, `meshcore-live-heatmap-opacity`). 4. **Universal format:** same shape as the server's `ThemeResponse` plus additional keys. Works identically for user export, admin `theme.json`, and user import. 5. **User overrides always win** in merge — `merge(serverDefaults, userOverrides)` = effective config. 6. **Override indicator:** shown in customizer panel ONLY when override value differs from current server default. 7. **No silent pruning:** overrides stay in localStorage until the user explicitly resets them (per-field reset or full reset). The delta may contain values that happen to match current server defaults — that's fine. User intent is preserved; nothing silently disappears. 8. **Per-field reset:** remove a single key from the delta → re-merge → re-apply CSS. 9. **Full reset:** `localStorage.removeItem('cs-theme-overrides')` → re-merge (effective = server defaults) → re-apply CSS. 10. **Export = dump delta object as JSON download. Import = validate shape, write to localStorage, trigger re-merge.** 11. **No CSS magic:** CSS variables ONLY update after the localStorage round-trip completes. No optimistic updates (see Decision #12 for the one exception). 12. **Color picker optimistic CSS exception:** For continuous inputs (color pickers, sliders), CSS is updated optimistically during `input` events for visual responsiveness. The localStorage write only happens on `change` event (mouseup/blur). On `change`, the full pipeline runs: write → read → merge → apply (which will match the optimistic state). If the user refreshes mid-drag before `change` fires, the change is lost — this is acceptable. This is the ONLY exception to the localStorage-first rule. ## Dark/Light Mode The customizer treats light and dark mode as separate override sections: - **`theme`** stores light mode color overrides. - **`themeDark`** stores dark mode color overrides. - When the user changes a color in the customizer, it writes to whichever section matches their current mode: `theme` if light, `themeDark` if dark. - The dark/light mode toggle preference (`meshcore-theme` localStorage key) is **separate** from the delta object. It is a view preference, not a customization — it is not stored in `cs-theme-overrides`. - The customizer UI shows color fields for the currently active mode only. Switching modes re-renders the color fields with values from the matching section. ## Presets The existing preset themes are preserved and flow through the standard pipeline: **Available presets:** Default, Ocean, Forest, Sunset, Monochrome. **How presets work:** - Clicking a preset writes its values to localStorage via the same pipeline as any other change: preset data → `writeOverrides()` → read back → merge → apply CSS. - Presets are NOT special — they are pre-built delta objects applied through the standard flow. - Each preset contains both `theme` (light) and `themeDark` (dark) sections, plus any other overrides the preset defines (e.g., `nodeColors`). - **"Reset to Default"** = clear all overrides (equivalent to full reset: `localStorage.removeItem('cs-theme-overrides')` → re-merge → apply). **Preset data format:** Same shape as the delta object. Example: ```json { "theme": { "accent": "#0077b6", "navBg": "#03045e", "background": "#f0f7fa" }, "themeDark": { "accent": "#48cae4", "navBg": "#03045e", "background": "#0a1929" } } ``` Applying a preset **replaces** the entire delta (it's a `writeOverrides(presetData)`, not a merge onto existing overrides). The user can then further customize individual fields on top. ## Data Model ### Delta Object Format The user override delta is a sparse object — it only contains fields the user has explicitly changed. The shape mirrors the server's `ThemeResponse` (from `/api/config/theme`) plus additional client-only sections: ```json { "branding": { "siteName": "string — site name override", "tagline": "string — tagline override", "logoUrl": "string — custom logo URL", "faviconUrl": "string — custom favicon URL" }, "theme": { "accent": "string — CSS color, light mode accent", "accentHover": "string — CSS color, light mode accent hover", "navBg": "string — CSS color, nav background", "navBg2": "string — CSS color, nav secondary background", "navText": "string — CSS color, nav text", "navTextMuted": "string — CSS color, nav muted text", "background": "string — CSS color, page background", "text": "string — CSS color, body text", "textMuted": "string — CSS color, muted text", "border": "string — CSS color, borders", "surface1": "string — CSS color, surface level 1", "surface2": "string — CSS color, surface level 2", "cardBg": "string — CSS color, card backgrounds", "contentBg": "string — CSS color, content area background", "detailBg": "string — CSS color, detail pane background", "inputBg": "string — CSS color, input backgrounds", "rowStripe": "string — CSS color, alternating row stripe", "rowHover": "string — CSS color, row hover highlight", "selectedBg": "string — CSS color, selected row background", "statusGreen": "string — CSS color, healthy status", "statusYellow": "string — CSS color, degraded status", "statusRed": "string — CSS color, critical status", "font": "string — CSS font-family for body text", "mono": "string — CSS font-family for monospace" }, "themeDark": { "/* same keys as theme — dark mode overrides */" }, "nodeColors": { "repeater": "string — CSS color", "companion": "string — CSS color", "room": "string — CSS color", "sensor": "string — CSS color", "observer": "string — CSS color" }, "typeColors": { "ADVERT": "string — CSS color", "GRP_TXT": "string — CSS color", "TXT_MSG": "string — CSS color", "ACK": "string — CSS color", "REQUEST": "string — CSS color", "RESPONSE": "string — CSS color", "TRACE": "string — CSS color", "PATH": "string — CSS color", "ANON_REQ": "string — CSS color" }, "home": { "heroTitle": "string", "heroSubtitle": "string", "steps": "[array of {emoji, title, description}]", "checklist": "[array of strings]", "footerLinks": "[array of {label, url}]" }, "timestamps": { "defaultMode": "string — 'ago' | 'absolute'", "timezone": "string — 'local' | 'utc'", "formatPreset": "string — 'iso' | 'iso-seconds' | 'locale'", "customFormat": "string — custom strftime-style format" }, "heatmapOpacity": "number — 0.0 to 1.0", "liveHeatmapOpacity": "number — 0.0 to 1.0" } ``` **Rules:** - All sections and keys are optional. An empty object `{}` means "no overrides." - The `timestamps`, `heatmapOpacity`, and `liveHeatmapOpacity` keys are client-only extensions — not part of the server's `ThemeResponse`, but included in the universal format for portability. ### localStorage Key **Key:** `cs-theme-overrides` **Value:** JSON string of the delta object above. **Absent key** = no overrides = effective config equals server defaults. ### Dark/Light Mode Preference **Key:** `meshcore-theme` **Value:** `"dark"` or `"light"` (or absent = follow system preference). **This key is NOT part of the delta object.** It controls which mode is active, not which colors are used. The delta stores overrides for both modes independently in `theme` and `themeDark`. ## Data Flow Diagrams ### Page Load ``` ┌─────────────┐ ┌──────────────────┐ ┌─────────────────┐ │ Fetch │ │ Read localStorage │ │ Migration check │ │ /api/config/ │ │ cs-theme-overrides│ │ (one-time) │ │ theme │ └────────┬─────────┘ └────────┬────────┘ └──────┬──────┘ │ │ │ │ ┌────────────────────┘ ▼ ▼ ▼ serverDefaults userOverrides (possibly migrated) │ │ ▼ ▼ ┌──────────────────────────────────────┐ │ computeEffective(server, userOverrides) │ └──────────────┬───────────────────────┘ │ ▼ ┌──────────────────────────────────────┐ │ window.SITE_CONFIG = effective │ ← atomic assignment └──────────────┬───────────────────────┘ │ ▼ ┌──────────────────────┐ │ applyCSS(effective) │ ← sets CSS vars on :root for current mode └──────────────────────┘ │ ▼ ┌──────────────────────────────┐ │ dispatch 'theme-changed' │ ← bare signal, no payload └──────────────────────────────┘ ``` ### User Change (e.g., picks new accent color) ``` User action (input/click) │ ▼ debounce(300ms) │ ▼ setOverride('theme', 'accent', '#ff0000') │ ├─► readOverrides() ← read current delta from localStorage │ │ │ ▼ ├─► update delta object ← set delta.theme.accent = '#ff0000' │ │ │ ▼ ├─► writeOverrides(delta) ← serialize & write to localStorage │ │ │ ▼ ├─► readOverrides() ← read BACK from localStorage (round-trip) │ │ │ ▼ ├─► computeEffective(server, delta) │ │ │ ▼ ├─► window.SITE_CONFIG = effective ← atomic assignment │ │ │ ▼ └─► applyCSS(effective) ← CSS vars updated on :root │ ▼ dispatch 'theme-changed' ``` **Color picker / slider exception:** During continuous `input` events (drag), CSS is updated optimistically (directly setting `--var` on `:root`) without the localStorage round-trip. The full pipeline above only runs on the `change` event (mouseup/blur). ### Per-Field Reset ``` User clicks reset icon on a field │ ▼ clearOverride('theme', 'accent') │ ├─► readOverrides() ├─► delete delta.theme.accent ├─► if delta.theme is empty, delete delta.theme ├─► writeOverrides(delta) ├─► readOverrides() ← round-trip ├─► computeEffective(server, delta) ├─► window.SITE_CONFIG = effective └─► applyCSS(effective) │ ▼ dispatch 'theme-changed' ``` ### Full Reset ``` User clicks "Reset All" │ ▼ localStorage.removeItem('cs-theme-overrides') │ ▼ computeEffective(server, {}) ← no overrides = server defaults │ ▼ window.SITE_CONFIG = effective │ ▼ applyCSS(effective) │ ▼ dispatch 'theme-changed' ``` ### Export ``` User clicks "Export" │ ▼ readOverrides() │ ▼ JSON.stringify(delta, null, 2) │ ▼ trigger download as .json file ``` ### Import ``` User selects .json file │ ▼ parse JSON │ ▼ validateShape(parsed) ← check structure, validate values │ ├─► invalid → show error, abort │ ▼ valid writeOverrides(parsed) │ ▼ readOverrides() ← round-trip │ ▼ computeEffective(server, delta) │ ▼ window.SITE_CONFIG = effective │ ▼ applyCSS(effective) │ ▼ dispatch 'theme-changed' ``` ## Function Signatures ### `readOverrides() → object` Reads `cs-theme-overrides` from localStorage, parses as JSON. Returns empty object `{}` on missing key, parse error, or non-object value. Never throws. ### `writeOverrides(delta: object) → void` Serializes `delta` to JSON and writes to `cs-theme-overrides` in localStorage. If `delta` is empty (`{}`), removes the key entirely. **Validation on write:** - Color values must match: `#hex` (3, 4, 6, or 8 digit), `rgb()`, `rgba()`, `hsl()`, `hsla()`, or CSS named colors. Invalid color values are rejected (not written) with `console.warn`. - Numeric values (`heatmapOpacity`, `liveHeatmapOpacity`) must be finite numbers in the range 0–1. Invalid values are rejected with `console.warn`. - Timestamp enum values are validated against known options (`defaultMode`: `'ago'`/`'absolute'`; `timezone`: `'local'`/`'utc'`; `formatPreset`: `'iso'`/`'iso-seconds'`/`'locale'`). Invalid values are rejected with `console.warn`. **Quota error handling:** - Wrap `localStorage.setItem` in try/catch. - On `QuotaExceededError`: show a visible warning to the user ("Storage full — changes may not be saved"), log to console. - Do NOT silently swallow the error. ### `computeEffective(serverConfig: object, userOverrides: object) → object` Deep merges `userOverrides` onto `serverConfig`. For each section (e.g., `theme`, `nodeColors`), if `userOverrides` has the section, its keys override the corresponding `serverConfig` keys. Top-level non-object keys (e.g., `heatmapOpacity`) are directly overridden. Returns a new object — neither input is mutated. **Merge rules:** - Object sections: shallow merge per section (`Object.assign({}, server.theme, user.theme)`) - Array sections (e.g., `home.steps`): full replacement (user array wins entirely, no element-level merge) - Scalar sections (e.g., `heatmapOpacity`): direct replacement After computing the effective config, writes it to `window.SITE_CONFIG` atomically (single assignment, not piecemeal mutations). ### `applyCSS(effectiveConfig: object) → void` Maps effective config values to CSS custom properties on `:root`. Behavior: 1. Reads the current mode (light/dark) from the `meshcore-theme` localStorage key, falling back to system preference (`prefers-color-scheme`). 2. Applies the matching section's values: `theme` for light mode, `themeDark` for dark mode. 3. Also applies mode-independent values: node colors as `--node-{role}`, type colors as `--type-{name}`, font families as `--font-body` and `--font-mono`. 4. Does NOT generate dual CSS rule blocks — only the current mode's values are applied to `:root`. 5. On dark/light mode toggle, `applyCSS` is called again to re-apply the correct section. Updates the `