Files
Wiring-Designer/frontend/js/theme.js
T
Kyle cca03cd2f3 feat: standard parts library, fuse box configurator, and theming
Standard parts (partsLibrary.js)
- ACDC TXL palette: 15 solids plus the 16 stocked stripe combinations
- 29 wire functions fix colour, stripe and gauge per signal, so a wire
  drawn from a given pin comes out identical on every build
- 16 pre-labelled parts: Skudak VCU (GRAY/BLACK), openinverter LDU
  23-pin, Dilong OBC/DCDC, BMW pedal, Honeywell CSSV1500, Bender
  ISO175, Bosch iBooster, Prius EPS, Volvo PS pump, cluster, DNR
  switch, 32-way enclosure bulkhead, contactors, VW MEB BMS
- OEM colours override the in-house standard where we splice into a
  factory loom (VW MEB LV connector, recovered from the VW bms diagram)
- Brown is ground, matching the MEB loom and European practice; orange
  is reserved for AC mains and OEM high-voltage runs

Fuse box configurator (pdmLibrary.js)
- GEP FRH-A12/A24 and the 48-way PDM as 280-footprint cavity grids
- Place, drag and rotate fuses, relays, diodes, breakers and bus bars
- Bus bars occupy their own collision layer and feed the blades they
  cross, suppressing those inputs so one wire supplies the run
- Cavity capacity is tracked; Apply is blocked while a placement
  overlaps or overhangs
- Naming a circuit renames its pins: "VCU" gives "VCU in" / "VCU out"

Theming (theme.js)
- Light and dark modes; canvas background, grid dots, part fill and
  part outline are configurable and persisted
- CSS converted to custom properties with property-aware light-mode
  pairs, since a colour used as text and as a surface must flip
  differently; no rule falls below 3:1 contrast in either theme
- Canvas devices, pins, labels, cable rows and the harness view are
  themed too, since Konva bakes colours in at draw time and cannot
  read CSS variables

Also
- Pass-through bulkheads: one device covering both connector faces,
  with a single centred label per circuit
- Pin labels scale with the device font instead of a fixed 8px
- Keyboard handling is modal-scoped, so Delete no longer removes the
  canvas device while the fuse box editor is open

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-19 10:54:49 -04:00

189 lines
7.0 KiB
JavaScript

// ─────────────────────────────────────────────────────────────────────────────
// 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 <html data-theme>;
// 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();