From 64745f89b10e765db7048c641e968d307b40f207 Mon Sep 17 00:00:00 2001 From: Kpa-clawbot Date: Thu, 2 Apr 2026 21:14:38 -0700 Subject: [PATCH] =?UTF-8?q?feat:=20customizer=20v2=20=E2=80=94=20event-dri?= =?UTF-8?q?ven=20state=20management=20(#502)=20(#503)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Implements the customizer v2 per the [approved spec](docs/specs/customizer-rework.md), replacing the v1 customizer's scattered state management with a clean event-driven architecture. Resolves #502. ## What Changed ### New: `public/customize-v2.js` Complete rewrite of the customizer as a self-contained IIFE with: - **Single localStorage key** (`cs-theme-overrides`) replacing 7 scattered keys - **Three state layers:** server defaults (immutable) → user overrides (delta) → effective config (computed) - **Full data flow pipeline:** `write → read-back → merge → atomic SITE_CONFIG assign → apply CSS → dispatch theme-changed` - **Color picker optimistic CSS** (Decision #12): `input` events update CSS directly for responsiveness; `change` events trigger the full pipeline - **Override indicator dots** (●) on each field — click to reset individual values - **Section-level override count badges** on tabs - **Browser-local banner** in panel header: "These settings are saved in your browser only" - **Auto-save status indicator** in footer: "All changes saved" / "Saving..." / "⚠️ Storage full" - **Export/Import** with full shape validation (`validateShape()`) - **Presets** flow through the standard pipeline (`writeOverrides(presetData) → pipeline`) - **One-time migration** from 7 legacy localStorage keys (exact field mapping per spec) - **Validation** on all writes: color format, opacity range, timestamp enum values - **QuotaExceededError handling** with visible user warning ### Modified: `public/app.js` Replaced ~80 lines of inline theme application code with a 15-line `_customizerV2.init(cfg)` call. The customizer v2 handles all merging, CSS application, and global state updates. ### Modified: `public/index.html` Swapped `customize.js` → `customize-v2.js` script tag. ### Added: `docs/specs/customizer-rework.md` The full approved spec, included in the repo for reference. ## Migration On first page load: 1. Checks if `cs-theme-overrides` already exists → skip if yes 2. Reads all 7 legacy keys (`meshcore-user-theme`, `meshcore-timestamp-*`, `meshcore-heatmap-opacity`, `meshcore-live-heatmap-opacity`) 3. Maps them to the new delta format per the spec's field-by-field mapping 4. Writes to `cs-theme-overrides`, removes all legacy keys 5. Continues with normal init Users with existing customizations will see them preserved automatically. ## Dark/Light Mode - `theme` section stores light mode overrides, `themeDark` stores dark mode overrides - `meshcore-theme` localStorage key remains **separate** (view preference, not customization) - Switching modes re-runs the full pipeline with the correct section ## Testing - All existing tests pass (`test-packet-filter.js`, `test-aging.js`, `test-frontend-helpers.js`) - Old `customize.js` is NOT modified — left in place for reference but no longer loaded ## Not in Scope (per spec) - Undo/redo stack - Cross-tab synchronization - Server-side admin import endpoint - Map config / geo-filter overrides --------- Co-authored-by: you --- docs/specs/customizer-rework.md | 568 ++++++++++++ public/app.js | 101 +-- public/customize-v2.js | 1503 +++++++++++++++++++++++++++++++ public/index.html | 2 +- test-customizer-v2.js | 408 +++++++++ test-e2e-playwright.js | 256 +++++- test-frontend-helpers.js | 321 ++----- 7 files changed, 2803 insertions(+), 356 deletions(-) create mode 100644 docs/specs/customizer-rework.md create mode 100644 public/customize-v2.js create mode 100644 test-customizer-v2.js diff --git a/docs/specs/customizer-rework.md b/docs/specs/customizer-rework.md new file mode 100644 index 00000000..02e0ac37 --- /dev/null +++ b/docs/specs/customizer-rework.md @@ -0,0 +1,568 @@ +# 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 `