// ───────────────────────────────────────────────────────────────────────────── // Theme — light / dark plus user overrides for the colours that make wires hard // to read: canvas background, grid dots, device fill and device outline. // // Loaded FIRST, before canvas.js, so the canvas can ask for its colours as it // draws. App chrome is themed by CSS custom properties on ; // Konva cannot read CSS variables, so device colours come from here instead. // ───────────────────────────────────────────────────────────────────────────── const THEME_KEY = "wiredraw.theme"; const THEME_PRESETS = { dark: { canvasBg: "#131320", gridDot: "#2e2e50", deviceStroke: "#5a5a8a", deviceText: "#dde0f5", deviceSubtext: "#8890b8", deviceRef: "#99aaee", // reference designator on the device body pinFill: "#0a0f1a", pinStroke: "#5566aa", notch: "#333355", // connector key notch rowBg: "#0e0e1a", // cable conductor rows wireLabel: "#aabbcc", // wire tag, drawn on the canvas background harnessBg: "#12122a", harnessEdge: "#4466cc", harnessText: "#99bbff", loomShadow: "#0a0a18", // Device fills are tinted per type so you can tell a relay from a fuse at a // glance. Light mode derives pastel equivalents from these same hues rather // than keeping a second hand-tuned table in sync. lightness: null, }, light: { canvasBg: "#f7f8fa", gridDot: "#c3c9d8", deviceStroke: "#8089a8", deviceText: "#1b2030", deviceSubtext: "#5d657e", deviceRef: "#2c4b96", pinFill: "#ffffff", pinStroke: "#5a6ba8", notch: "#b9bed4", rowBg: "#eceef5", wireLabel: "#41506b", harnessBg: "#dfe4f3", harnessEdge: "#4466cc", harnessText: "#26418f", loomShadow: "#b9c0d6", lightness: 0.88, }, }; const DEVICE_HUES = { connector: "#12253a", terminal_block: "#122a1a", component: "#1e1230", splice: "#2a1e10", label: "#22220e", fuse: "#2a1c08", relay: "#0a1628", switch: "#0a2218", bulb: "#24220a", motor: "#1a0a28", diode: "#28081a", resistor: "#1a1a08", capacitor: "#081a1a", ground: "#0e140e", power: "#1a0808", cable: "#1a1a1a", pdm: "#2a1408", group: "rgba(40,40,80,0.35)", }; // ── colour maths ───────────────────────────────────────────────────────────── function _hexToRgb(hex) { const h = hex.replace("#", ""); const s = h.length === 3 ? h.split("").map((c) => c + c).join("") : h; return { r: parseInt(s.slice(0, 2), 16), g: parseInt(s.slice(2, 4), 16), b: parseInt(s.slice(4, 6), 16), }; } function _rgbToHsl({ r, g, b }) { r /= 255; g /= 255; b /= 255; const max = Math.max(r, g, b), min = Math.min(r, g, b); const l = (max + min) / 2; let h = 0, s = 0; if (max !== min) { const d = max - min; s = l > 0.5 ? d / (2 - max - min) : d / (max + min); if (max === r) h = ((g - b) / d + (g < b ? 6 : 0)) / 6; else if (max === g) h = ((b - r) / d + 2) / 6; else h = ((r - g) / d + 4) / 6; } return { h, s, l }; } // Re-light a colour to a target lightness, keeping its hue so the per-type // distinction survives the theme switch. function _relight(hex, targetL, satScale = 0.55) { if (!hex || hex.startsWith("rgba")) return hex; const { h, s } = _rgbToHsl(_hexToRgb(hex)); return `hsl(${Math.round(h * 360)}, ${Math.round(Math.min(1, s * satScale) * 100)}%, ${Math.round(targetL * 100)}%)`; } // ── state ──────────────────────────────────────────────────────────────────── const Theme = { _state: { mode: "dark", canvasBg: null, // null = follow the mode preset gridDot: null, deviceStroke: null, deviceFill: null, // null = tint per device type }, _listeners: [], load() { try { const raw = localStorage.getItem(THEME_KEY); if (raw) Object.assign(this._state, JSON.parse(raw)); } catch { /* corrupt or unavailable storage — fall back to defaults */ } if (!THEME_PRESETS[this._state.mode]) this._state.mode = "dark"; return this._state; }, save() { try { localStorage.setItem(THEME_KEY, JSON.stringify(this._state)); } catch { /* private mode — theme just will not persist */ } }, get() { return { ...this._state }; }, preset() { return THEME_PRESETS[this._state.mode]; }, set(patch) { Object.assign(this._state, patch); this.save(); this.apply(); this._listeners.forEach((fn) => fn(this._state)); }, reset() { this._state = { mode: this._state.mode, canvasBg: null, gridDot: null, deviceStroke: null, deviceFill: null }; this.save(); this.apply(); this._listeners.forEach((fn) => fn(this._state)); }, onChange(fn) { this._listeners.push(fn); }, // Push the theme into CSS. The app chrome reads data-theme; the canvas // background and grid are plain custom properties so a user override is a // one-line change with no repaint logic. apply() { const p = this.preset(); const root = document.documentElement; root.setAttribute("data-theme", this._state.mode); root.style.setProperty("--bg-canvas", this._state.canvasBg || p.canvasBg); root.style.setProperty("--grid-dot", this._state.gridDot || p.gridDot); }, // ── canvas colours (Konva cannot read CSS variables) ── deviceFill(type) { if (this._state.deviceFill) return this._state.deviceFill; const base = DEVICE_HUES[type] || "#1e1e2e"; const p = this.preset(); return p.lightness == null ? base : _relight(base, p.lightness); }, deviceStroke() { return this._state.deviceStroke || this.preset().deviceStroke; }, deviceText() { return this.preset().deviceText; }, deviceSubtext() { return this.preset().deviceSubtext; }, deviceRef() { return this.preset().deviceRef; }, pinFill() { return this.preset().pinFill; }, pinStroke() { return this.preset().pinStroke; }, notch() { return this.preset().notch; }, rowBg() { return this.preset().rowBg; }, wireLabel() { return this.preset().wireLabel; }, harnessBg() { return this.preset().harnessBg; }, harnessEdge() { return this.preset().harnessEdge; }, harnessText() { return this.preset().harnessText; }, loomShadow() { return this.preset().loomShadow; }, canvasBg() { return this._state.canvasBg || this.preset().canvasBg; }, }; Theme.load(); Theme.apply();