diff --git a/docs/specs/customizer-rework.md b/docs/specs/customizer-rework.md index ded2d15d..02e0ac37 100644 --- a/docs/specs/customizer-rework.md +++ b/docs/specs/customizer-rework.md @@ -11,22 +11,64 @@ This spec defines a clean rework based on event-driven state management with a s 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. +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. **Silent pruning:** if an override value matches the server default, remove it from the delta. Prune on page load (after fetching server config) and during each merge cycle. Keeps delta minimal. +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. +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 that differ from server defaults. The shape mirrors the server's `ThemeResponse` (from `/api/config/theme`) plus additional client-only sections: +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 { @@ -103,7 +145,6 @@ The user override delta is a sparse object — it only contains fields the user **Rules:** - All sections and keys are optional. An empty object `{}` means "no overrides." -- Only keys that differ from server defaults are stored (enforced by pruning). - 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 @@ -112,6 +153,12 @@ The user override delta is a sparse object — it only contains fields the user **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 @@ -127,19 +174,24 @@ The user override delta is a sparse object — it only contains fields the user serverDefaults userOverrides (possibly migrated) │ │ ▼ ▼ - ┌──────────────────────────────┐ - │ pruneOverrides(server, user) │ ← remove keys matching defaults - └──────────────┬───────────────┘ - │ - ▼ (pruned delta written back) ┌──────────────────────────────────────┐ - │ computeEffective(server, prunedUser) │ + │ computeEffective(server, userOverrides) │ + └──────────────┬───────────────────────┘ + │ + ▼ + ┌──────────────────────────────────────┐ + │ window.SITE_CONFIG = effective │ ← atomic assignment └──────────────┬───────────────────────┘ │ ▼ ┌──────────────────────┐ - │ applyCSS(effective) │ ← sets CSS vars on :root + │ 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) @@ -159,9 +211,6 @@ The user override delta is a sparse object — it only contains fields the user ├─► update delta object ← set delta.theme.accent = '#ff0000' │ │ │ ▼ - ├─► pruneOverrides(server, delta) ← if value matches server default, remove it - │ │ - │ ▼ ├─► writeOverrides(delta) ← serialize & write to localStorage │ │ │ ▼ @@ -171,9 +220,17 @@ The user override delta is a sparse object — it only contains fields the user ├─► 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 ``` @@ -188,7 +245,11 @@ The user override delta is a sparse object — it only contains fields the user ├─► writeOverrides(delta) ├─► readOverrides() ← round-trip ├─► computeEffective(server, delta) + ├─► window.SITE_CONFIG = effective └─► applyCSS(effective) + │ + ▼ + dispatch 'theme-changed' ``` ### Full Reset @@ -203,7 +264,13 @@ The user override delta is a sparse object — it only contains fields the user computeEffective(server, {}) ← no overrides = server defaults │ ▼ + window.SITE_CONFIG = effective + │ + ▼ applyCSS(effective) + │ + ▼ + dispatch 'theme-changed' ``` ### Export @@ -230,7 +297,7 @@ The user override delta is a sparse object — it only contains fields the user parse JSON │ ▼ - validateShape(parsed) ← check structure, reject unknown top-level keys + validateShape(parsed) ← check structure, validate values │ ├─► invalid → show error, abort │ @@ -241,16 +308,16 @@ The user override delta is a sparse object — it only contains fields the user readOverrides() ← round-trip │ ▼ - pruneOverrides(server, delta) ← prune matches + computeEffective(server, delta) │ ▼ - writeOverrides(prunedDelta) - │ - ▼ - computeEffective(server, prunedDelta) + window.SITE_CONFIG = effective │ ▼ applyCSS(effective) + │ + ▼ + dispatch 'theme-changed' ``` ## Function Signatures @@ -263,6 +330,16 @@ Reads `cs-theme-overrides` from localStorage, parses as JSON. Returns empty obje 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. @@ -274,41 +351,41 @@ Returns a new object — neither input is mutated. - 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` using the existing `THEME_CSS_MAP` mapping. Also applies: -- Node colors as `--node-{role}` variables -- Type colors as `--type-{name}` variables -- Font families as `--font-body` and `--font-mono` -- Dark mode values via the existing media query / class mechanism +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 `