## 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 <you@example.com>
27 KiB
Customizer Rework Spec
Overview
The current customizer (public/customize.js) suffers from fundamental state management issues documented in issue #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.
- Three state layers: server defaults (immutable after fetch), user overrides (delta in localStorage), effective config (computed via merge, never stored directly).
- 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).
- 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). - Universal format: same shape as the server's
ThemeResponseplus additional keys. Works identically for user export, admintheme.json, and user import. - User overrides always win in merge —
merge(serverDefaults, userOverrides)= effective config. - Override indicator: shown in customizer panel ONLY when override value differs from current server default.
- 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.
- Per-field reset: remove a single key from the delta → re-merge → re-apply CSS.
- Full reset:
localStorage.removeItem('cs-theme-overrides')→ re-merge (effective = server defaults) → re-apply CSS. - Export = dump delta object as JSON download. Import = validate shape, write to localStorage, trigger re-merge.
- No CSS magic: CSS variables ONLY update after the localStorage round-trip completes. No optimistic updates (see Decision #12 for the one exception).
- Color picker optimistic CSS exception: For continuous inputs (color pickers, sliders), CSS is updated optimistically during
inputevents for visual responsiveness. The localStorage write only happens onchangeevent (mouseup/blur). Onchange, the full pipeline runs: write → read → merge → apply (which will match the optimistic state). If the user refreshes mid-drag beforechangefires, 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:
themestores light mode color overrides.themeDarkstores dark mode color overrides.- When the user changes a color in the customizer, it writes to whichever section matches their current mode:
themeif light,themeDarkif dark. - The dark/light mode toggle preference (
meshcore-themelocalStorage key) is separate from the delta object. It is a view preference, not a customization — it is not stored incs-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) andthemeDark(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:
{
"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:
{
"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, andliveHeatmapOpacitykeys are client-only extensions — not part of the server'sThemeResponse, 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) withconsole.warn. - Numeric values (
heatmapOpacity,liveHeatmapOpacity) must be finite numbers in the range 0–1. Invalid values are rejected withconsole.warn. - Timestamp enum values are validated against known options (
defaultMode:'ago'/'absolute';timezone:'local'/'utc';formatPreset:'iso'/'iso-seconds'/'locale'). Invalid values are rejected withconsole.warn.
Quota error handling:
- Wrap
localStorage.setItemin 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:
- Reads the current mode (light/dark) from the
meshcore-themelocalStorage key, falling back to system preference (prefers-color-scheme). - Applies the matching section's values:
themefor light mode,themeDarkfor dark mode. - Also applies mode-independent values: node colors as
--node-{role}, type colors as--type-{name}, font families as--font-bodyand--font-mono. - Does NOT generate dual CSS rule blocks — only the current mode's values are applied to
:root. - On dark/light mode toggle,
applyCSSis called again to re-apply the correct section.
Updates the <style> element (create if absent, reuse if present). Dispatches a theme-changed CustomEvent on window after applying.
theme-changed Event
theme-changedis a bareCustomEventwith no payload (matches current behavior).- After each merge cycle, the effective config is written to
window.SITE_CONFIGatomically (single assignment). window.SITE_CONFIGis the canonical readable source for effective config throughout the app. All existing listeners that read fromSITE_CONFIGcontinue to work without changes.
setOverride(section: string, key: string, value: any) → void
Sets a single override. For nested sections (e.g., section='theme', key='accent'), sets delta[section][key] = value. For top-level scalars (e.g., section=null, key='heatmapOpacity'), sets delta[key] = value.
Follows the full data flow: read → update → write → read-back → merge → apply CSS → dispatch theme-changed. Debounced at ~300ms (the debounce wraps the write-through-to-CSS portion).
clearOverride(section: string, key: string) → void
Removes a single key from the delta. If the section becomes empty after removal, removes the section too. Triggers the full data flow (no debounce — resets should feel instant).
migrateOldKeys() → object | null
One-time migration. Checks for any of the 7 legacy localStorage keys. If found:
- Reads all legacy values
- Maps them into the new delta format (see Migration Plan)
- Writes the merged delta to
cs-theme-overrides - Removes all 7 legacy keys
- Returns the migrated delta
Returns null if no legacy keys found.
validateShape(obj: any) → { valid: boolean, errors: string[] }
Validates that an imported object conforms to the expected shape:
- Must be a plain object
- Top-level keys must be from the known set:
branding,theme,themeDark,nodeColors,typeColors,home,timestamps,heatmapOpacity,liveHeatmapOpacity - Section values must be objects (where expected) or correct scalar types
- Color values are validated: must match
#hex(3, 4, 6, or 8 digit),rgb(),rgba(),hsl(),hsla(), or CSS named colors - Numeric values (
heatmapOpacity,liveHeatmapOpacity) must be finite numbers in range 0–1 - Timestamp enum values validated against known options
Unknown top-level keys cause a warning but don't fail validation (forward compatibility).
Migration Plan
On first page load, before the normal init flow:
- Check if
cs-theme-overridesalready exists → if yes, skip migration. - Check if ANY of the 7 legacy keys exist in localStorage.
- If legacy keys found, build a delta object using the exact mapping below:
Field-by-Field Migration Mapping
meshcore-user-theme (JSON) → parse, map directly:
.branding → delta.branding
.theme → delta.theme
.themeDark → delta.themeDark
.nodeColors → delta.nodeColors
.typeColors → delta.typeColors
.home → delta.home
(any other keys are dropped)
meshcore-timestamp-mode → delta.timestamps.defaultMode
meshcore-timestamp-timezone → delta.timestamps.timezone
meshcore-timestamp-format → delta.timestamps.formatPreset
meshcore-timestamp-custom-format → delta.timestamps.customFormat
meshcore-heatmap-opacity → delta.heatmapOpacity (parseFloat)
meshcore-live-heatmap-opacity → delta.liveHeatmapOpacity (parseFloat)
- Write the assembled delta to
cs-theme-overrides. - Delete all 7 legacy keys.
- Continue with normal init.
Edge cases:
- If
meshcore-user-themecontains invalid JSON, skip it (log a warning to console). - If a legacy value is empty string or null, skip that field.
- Migration runs exactly once — the presence of
cs-theme-overrides(even as{}) prevents re-migration.
allowCustomFormat — User Preferences Trump
The server-side allowCustomFormat gate is not enforced client-side. If a user imports a delta with a custom format, it's applied regardless. The server controls what formats are available in the UI (whether the custom format input field is shown), but does not block stored preferences.
Override Indicator UX
In the customizer panel, each field that has an active override (value differs from server default) shows a visual indicator:
- Indicator: A small dot or icon (e.g.,
●or a reset arrow↺) adjacent to the field label. - Color: Use the accent color to draw attention without being noisy.
- Behavior: Clicking the indicator resets that single field (calls
clearOverride). - Tooltip: "Reset to server default" or "This value differs from the server default."
- Absence: Fields matching the server default show no indicator — clean and minimal.
Section-level indicator: If any field in a section (e.g., "Theme Colors") is overridden, the tab/section header shows a count badge (e.g., "Theme Colors (3)").
"Reset All" button: Always visible at bottom of panel. Confirms before executing (localStorage.removeItem + re-merge).
UX Requirements
Browser-Local Banner
The customizer panel must display a persistent, always-visible notice:
"These settings are saved in your browser only and don't affect other users."
This is NOT a tooltip, NOT a dismissible popup — it must be always visible in the panel header or footer area. Users must understand at a glance that their changes are local.
Auto-Save Indicator
Show a persistent status in the customizer panel footer, Google Docs style — subtle but always present:
- Default state: "All changes saved" (muted text)
- During debounce: "Saving..." (muted text)
- On quota error: "⚠️ Storage full — changes may not be saved" (red text, persistent until resolved)
The indicator reflects the actual state of the localStorage write, not just the UI action.
Server Compatibility
The delta format is intentionally shaped to be a valid subset of the server's theme.json admin config file. This means:
- User export → admin import: An admin can take a user's exported JSON and drop it into
theme.jsonas server defaults. Thetimestamps,heatmapOpacity, andliveHeatmapOpacitykeys are ignored by the current server (it doesn't read them fromtheme.json), but they don't cause errors. - Admin config → user import: A
theme.jsonfile can be imported as user overrides. Unknown server-only keys are ignored by the client. - Round-trip safe: Export → import produces identical delta (assuming no server default changes between operations).
The server's ThemeResponse struct currently returns: branding, theme, themeDark, nodeColors, typeColors, home. The client-only extensions (timestamps, heatmapOpacity, liveHeatmapOpacity) are additive — they extend the format without conflicting.
Testing Requirements
Unit Tests (Node.js, no browser required)
-
readOverrides- Returns
{}when key is absent - Returns
{}when key contains invalid JSON - Returns
{}when key contains a non-object (string, array, number) - Returns parsed object when key contains valid JSON object
- Returns
-
writeOverrides- Writes serialized JSON to localStorage
- Removes key when delta is empty
{} - Round-trips correctly (write → read = identical object)
- Rejects invalid color values with console.warn
- Rejects out-of-range numeric values with console.warn
- Rejects invalid timestamp enum values with console.warn
- Handles QuotaExceededError gracefully (warns user, does not throw)
-
computeEffective- Returns server defaults when overrides is
{} - Overrides a single key in a section
- Overrides multiple keys across sections
- Does not mutate either input
- Handles missing sections in overrides gracefully
- Array values (e.g.,
home.steps) are fully replaced, not merged - Top-level scalars (
heatmapOpacity) are directly replaced
- Returns server defaults when overrides is
-
setOverride/clearOverride- Setting a value stores it in the delta
- Clearing a key removes it from delta
- Clearing the last key in a section removes the section
- Full data flow executes (CSS vars updated)
-
migrateOldKeys- Migrates all 7 keys correctly using exact field mapping
- Handles partial migration (only some keys present)
- Handles invalid JSON in
meshcore-user-theme - Removes all legacy keys after migration
- Skips migration if
cs-theme-overridesalready exists - Returns null when no legacy keys found
- Drops unknown keys from
meshcore-user-theme
-
validateShape- Accepts valid delta objects
- Accepts empty object
- Rejects non-objects (string, array, null)
- Warns on unknown top-level keys (doesn't reject)
- Validates section types (object vs scalar)
- Rejects invalid color values
- Rejects out-of-range opacity values
- Rejects invalid timestamp enum values
Browser/E2E Tests (Playwright)
- Customizer opens and shows current values — fields reflect effective config.
- Changing a color updates CSS variable — after debounce,
:roothas new value. - Override indicator appears when value differs from server default.
- Per-field reset removes override, reverts to server default, indicator disappears.
- Full reset clears all overrides, all fields show server defaults.
- Export downloads a JSON file with current delta.
- Import applies overrides from uploaded JSON file.
- Migration — set legacy keys, reload, verify they're migrated and removed.
- Preset application — clicking a preset applies its colors, fields update.
- Dark/light mode toggle — switching mode re-applies correct section's CSS vars.
- Browser-local banner — verify persistent notice is visible in customizer panel.
- Auto-save indicator — verify status text updates during and after changes.
What's NOT In Scope
- Undo/redo stack — could be added as P2. For v1, per-field reset to server default is the only revert mechanism.
- Cross-tab synchronization — two tabs editing simultaneously may clobber each other's changes. Acceptable for v1.
- Server-side timestamp config (
allowCustomFormatgate) — remains server-only, not exposed in the customizer delta. The server controls UI availability but does not block stored preferences (seeallowCustomFormatsection above). - Admin import endpoint — no server API for uploading
theme.jsonvia the UI. Admins edit the file directly. Future work. - Map config overrides (
mapDefaults.center,mapDefaults.zoom) — separate concern, not part of theme. Future work. - Geo-filter config — server-only. Not in scope.
- Per-page layout preferences (column widths, sort orders) — separate from theming. Future work.