From 7efb2658c3aafad3cc0e4fc90d3f99bbb19c0b6b Mon Sep 17 00:00:00 2001 From: "Claude Opus 4.8" Date: Fri, 26 Jun 2026 14:13:22 +0200 Subject: [PATCH 01/19] fix(ui): theme-aware charts + heat-pump card auto re-discovery MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The live charts hard-coded dark-theme colors for canvas chrome (axis text, gridlines, both tooltips, the neutral load line), so they went invisible/wrong on a light background. Add a resolver that reads the CSS theme tokens into concrete canvas colors (cached, invalidated on data-theme change) and use it for all chart chrome; saturated series hues are unchanged. heating.js cached heat-pump discovery once and never re-checked (and an empty [] is truthy, so it stuck) — a pump added while the dashboard was open never appeared without a reload. Re-discover on first load and every 5 min. Co-Authored-By: HuggeK <48095810+HuggeK@users.noreply.github.com> --- .changeset/dashboard-light-dark.md | 9 +++ web/app.js | 95 +++++++++++++++++++++--------- web/heating.js | 16 ++++- 3 files changed, 90 insertions(+), 30 deletions(-) create mode 100644 .changeset/dashboard-light-dark.md diff --git a/.changeset/dashboard-light-dark.md b/.changeset/dashboard-light-dark.md new file mode 100644 index 00000000..f2bee8b6 --- /dev/null +++ b/.changeset/dashboard-light-dark.md @@ -0,0 +1,9 @@ +--- +"forty-two-watts": patch +--- + +Dashboard light-mode + heat-pump card fixes. + +The live power/energy **charts now render correctly in light mode**. The canvas chrome (axis labels, gridlines, zero/now/hover lines, both tooltips' background+border+text, and the neutral "Load" line + legend swatch) was hard-coded for the dark theme, so it went invisible or wrong on a light background. The charts now resolve the CSS theme tokens (`--fg`, `--fg-dim`, `--fg-muted`, `--line`, `--ink-raised`, `--accent-e`) into concrete canvas colors per draw — cached and re-read when `data-theme` changes. Saturated data-series hues are unchanged. + +The **heat-pump card now re-discovers newly-added drivers** without a manual reload. `heating.js` cached discovery once and never re-checked — and an empty result is truthy, so a site that discovered before its pump reported `hp_power_w` stayed blank. It now re-scans on first load and every 5 minutes, while steady-state polling still only touches already-known heat-pump drivers. diff --git a/web/app.js b/web/app.js index b16cbaea..0f24e04e 100644 --- a/web/app.js +++ b/web/app.js @@ -504,6 +504,8 @@ ctx.setTransform(dpr, 0, 0, dpr, 0, 0); } + var C = chartColors(); // theme-aware chrome colors, re-read each draw + var pad = { top: 20, right: 10, bottom: 25, left: 55 }; var plotW = w - pad.left - pad.right; var plotH = h - pad.top - pad.bottom; @@ -529,13 +531,13 @@ { data: toKwh(chartHistory.e_pv), color: "#10b981", width: 2, dash: [], name: "PV", fill: true }, { data: toKwh(chartHistory.e_charged), color: "#3b82f6", width: 2, dash: [], name: "Charged", fill: false }, { data: toKwh(chartHistory.e_discharged), color: "#f59e0b", width: 2, dash: [], name: "Discharged", fill: false }, - { data: toKwh(chartHistory.e_load), color: "#e2e8f0", width: 2, dash: [], name: "Load", fill: false }, + { data: toKwh(chartHistory.e_load), color: C.load, width: 2, dash: [], name: "Load", fill: false }, ]; } else { series = [ { data: chartHistory.grid, color: "#ef4444", width: 2, dash: [], name: "Grid", fill: true, toggle: "grid" }, { data: chartHistory.pv, color: "#22c55e", width: 2, dash: [], name: "PV", fill: true, toggle: "pv" }, - { data: chartHistory.load, color: "#e2e8f0", width: 1.5, dash: [], name: "Load", fill: false, toggle: "load" }, + { data: chartHistory.load, color: C.load, width: 1.5, dash: [], name: "Load", fill: false, toggle: "load" }, ]; // Append one actual/target pair per discovered battery driver. // Stable order so chart colors don't jump as the driver set grows. @@ -567,7 +569,7 @@ if (visibleVals.length === 0) { // Empty state — draw axes + "waiting for data" hint ctx.clearRect(0, 0, w, h); - ctx.fillStyle = "#666"; + ctx.fillStyle = C.muted; ctx.font = "12px monospace"; ctx.textAlign = "center"; ctx.fillText("waiting for data...", w / 2, h / 2); @@ -615,7 +617,7 @@ // Grid lines (drawn inside clip so they only appear in the plot area). // Walk yMin..yMax in yStep increments so every line lands on a round // number — that's what lets the y-axis labels stay readable. - ctx.strokeStyle = "#2a2a2a"; + ctx.strokeStyle = C.grid; ctx.lineWidth = 0.5; ctx.font = "11px monospace"; var steps = Math.round(yRange / yStep); @@ -630,7 +632,7 @@ // Zero line if (yMin < 0 && yMax > 0) { var zeroY = pad.top + plotH * (1 - (0 - yMin) / yRange); - ctx.strokeStyle = "#444"; + ctx.strokeStyle = C.muted; ctx.lineWidth = 1; ctx.setLineDash([4, 4]); ctx.beginPath(); @@ -795,7 +797,7 @@ // Subtle vertical "now" line — anchor point so the eye knows where present is var nowX = pad.left + plotW; - ctx.strokeStyle = "rgba(255,255,255,0.12)"; + ctx.strokeStyle = C.grid; ctx.lineWidth = 1; ctx.beginPath(); ctx.moveTo(nowX, pad.top); @@ -803,7 +805,7 @@ ctx.stroke(); // Y-axis labels (outside clip so they're fully visible) - ctx.fillStyle = "#888"; + ctx.fillStyle = C.dim; ctx.font = "11px monospace"; for (var i2 = 0; i2 <= steps; i2++) { var yVal = yMin + (yRange * i2 / steps); @@ -812,7 +814,7 @@ } // Time labels - ctx.fillStyle = "#666"; + ctx.fillStyle = C.muted; ctx.fillText(chartRange + " ago", pad.left, h - 5); ctx.textAlign = "right"; ctx.fillText("now", w - pad.right, h - 5); @@ -838,7 +840,7 @@ ctx.arc(w - pad.right - 78, pad.top + 4, 2.5, 0, Math.PI * 2); ctx.fill(); ctx.font = "10px monospace"; - ctx.fillStyle = fresh ? "#aaa" : "#f59e0b"; + ctx.fillStyle = fresh ? C.dim : "#f59e0b"; ctx.fillText(ageStr, w - pad.right - 70, pad.top + 8); } @@ -870,8 +872,46 @@ return "rgba(" + r + "," + g + "," + b + "," + alpha + ")"; } + // Resolve a CSS custom property to a concrete color string for , + // which can't read var(). Re-read per draw so the charts follow the active + // theme (html[data-theme]) without needing an explicit redraw on toggle — + // a hidden probe element inherits :root, and getComputedStyle resolves the + // var() + oklch token to a value canvas can paint. + var _colorProbe = null; + function cssColor(name, fallback) { + if (!_colorProbe) { + _colorProbe = document.createElement("span"); + _colorProbe.style.cssText = "position:absolute;visibility:hidden;pointer-events:none"; + document.body.appendChild(_colorProbe); + } + _colorProbe.style.color = "var(" + name + ", " + (fallback || "#888") + ")"; + return getComputedStyle(_colorProbe).color || fallback || "#888"; + } + // Theme-aware chart chrome colors. Series hues stay fixed (they read on + // both themes); only text / gridlines / tooltip surface / the neutral + // "load" line flip — those are what go invisible on a light background. + // Cached so the animation loop doesn't getComputedStyle every frame — + // only recomputed when the active theme actually changes. + var _chartColors = null, _chartColorsTheme = null; + function chartColors() { + var theme = document.documentElement.getAttribute("data-theme") || ""; + if (_chartColors && _chartColorsTheme === theme) return _chartColors; + _chartColorsTheme = theme; + _chartColors = { + text: cssColor("--fg", "#e6e6e6"), + dim: cssColor("--fg-dim", "#aaaaaa"), + muted: cssColor("--fg-muted", "#888888"), + grid: cssColor("--line", "#2a2a2a"), + surface: cssColor("--ink-raised", "#14141f"), + accent: cssColor("--accent-e", "#fbbf24"), + load: cssColor("--fg", "#e2e8f0"), + }; + return _chartColors; + } + function drawHoverOverlay(ctx) { if (!chartLayout) return; + var C = chartColors(); var l = chartLayout; var i = hoverIndex; // Map by timestamp (matches the time-anchored line drawing) @@ -880,7 +920,7 @@ var x = l.pad.left + l.plotW * (ts - l.windowStart) / l.totalMs; // Vertical line - ctx.strokeStyle = "rgba(255,255,255,0.3)"; + ctx.strokeStyle = C.muted; ctx.lineWidth = 1; ctx.setLineDash([2, 2]); ctx.beginPath(); @@ -906,12 +946,12 @@ { name: "PV", data: chartHistory.e_pv, color: "#10b981" }, { name: "Charged", data: chartHistory.e_charged, color: "#3b82f6" }, { name: "Discharged", data: chartHistory.e_discharged, color: "#f59e0b" }, - { name: "Load", data: chartHistory.e_load, color: "#e2e8f0" }, + { name: "Load", data: chartHistory.e_load, color: C.load }, ] : (function () { var rows = [ { name: "Grid", data: chartHistory.grid, color: "#ef4444" }, { name: "PV", data: chartHistory.pv, color: "#22c55e" }, - { name: "Load", data: chartHistory.load, color: "#e2e8f0" }, + { name: "Load", data: chartHistory.load, color: C.load }, ]; // Battery rows render their target inline as "actual W (→ target W)" // so it's visually obvious the two numbers are the same metric — one @@ -934,14 +974,14 @@ if (boxX + boxW > l.w - 5) boxX = x - boxW - 10; var boxY = l.pad.top + 5; - ctx.fillStyle = "rgba(20,20,35,0.95)"; - ctx.strokeStyle = "#444"; + ctx.fillStyle = C.surface; + ctx.strokeStyle = C.grid; ctx.lineWidth = 1; ctx.fillRect(boxX, boxY, boxW, boxH); ctx.strokeRect(boxX, boxY, boxW, boxH); ctx.font = "10px monospace"; - ctx.fillStyle = "#888"; + ctx.fillStyle = C.dim; ctx.fillText(timeStr, boxX + 6, boxY + lineHeight - 2); labels.forEach(function (lab, idx) { @@ -949,21 +989,21 @@ var y = boxY + (idx + 2) * lineHeight - 4; ctx.fillStyle = lab.color; ctx.fillRect(boxX + 6, y - 8, 8, 8); - ctx.fillStyle = lab.dim ? "#888" : "#ddd"; + ctx.fillStyle = lab.dim ? C.muted : C.text; ctx.fillText(lab.name, boxX + 18, y); ctx.textAlign = "right"; if (chartView === "energy") { - ctx.fillStyle = "#fff"; + ctx.fillStyle = C.text; ctx.fillText(lab.data[i].toFixed(2) + " kWh", boxX + boxW - 6, y); } else { var actual = formatW(lab.data[i]); - ctx.fillStyle = "#fff"; + ctx.fillStyle = C.text; ctx.fillText(actual, boxX + boxW - 6, y); // Inline target as dim "(→ -674 W)" so user sees commanded vs actual // in one glance. Skip when target is 0 to reduce visual noise. if (lab.target && i < lab.target.length && Math.abs(lab.target[i]) > 1) { var actualW = ctx.measureText(actual).width; - ctx.fillStyle = "#888"; + ctx.fillStyle = C.dim; ctx.font = "9px monospace"; ctx.fillText("→ " + formatW(lab.target[i]), boxX + boxW - 10 - actualW, y); ctx.font = "10px monospace"; @@ -975,6 +1015,7 @@ function drawForecastHoverOverlay(ctx) { if (!chartLayout || !hoverForecast) return; + var C = chartColors(); var l = chartLayout; var a = hoverForecast.action; var ts = hoverForecast.ts; @@ -1007,8 +1048,8 @@ if (boxX + boxW > l.w - 5) boxX = x - boxW - 10; var boxY = l.pad.top + 5; - ctx.fillStyle = "rgba(20,20,35,0.96)"; - ctx.strokeStyle = "rgba(251,191,36,0.6)"; + ctx.fillStyle = C.surface; + ctx.strokeStyle = C.accent; ctx.lineWidth = 1; ctx.fillRect(boxX, boxY, boxW, boxH); ctx.strokeRect(boxX, boxY, boxW, boxH); @@ -1016,16 +1057,16 @@ ctx.font = "10px monospace"; var d = new Date(ts); var hh = d.getHours().toString().padStart(2, "0") + ":" + d.getMinutes().toString().padStart(2, "0"); - ctx.fillStyle = "#fbbf24"; + ctx.fillStyle = C.accent; ctx.fillText(hh + " predicted", boxX + 6, boxY + lineHeight - 2); labels.forEach(function (lab, idx) { var y = boxY + (idx + 2) * lineHeight - 4; ctx.fillStyle = lab.color; ctx.fillRect(boxX + 6, y - 8, 8, 8); - ctx.fillStyle = "#ddd"; + ctx.fillStyle = C.text; ctx.fillText(lab.name, boxX + 18, y); - ctx.fillStyle = "#fff"; + ctx.fillStyle = C.text; ctx.textAlign = "right"; var val = lab.literal ? lab.val : formatW(lab.val); ctx.fillText(val, boxX + boxW - 6, y); @@ -1034,7 +1075,7 @@ if (a.reason) { var ry = boxY + (labels.length + 2) * lineHeight + 2; - ctx.fillStyle = "#86efac"; + ctx.fillStyle = C.dim; ctx.font = "italic 10px monospace"; // Truncate if too long for box var reason = a.reason.length > 28 ? a.reason.substring(0, 27) + "…" : a.reason; @@ -2078,9 +2119,9 @@ if (!legend) return; var items = chartView === "energy" ? [ ["#ef4444", "Import"], ["#22c55e", "Export"], ["#10b981", "PV"], - ["#3b82f6", "Charged"], ["#f59e0b", "Discharged"], ["#e2e8f0", "Load"], + ["#3b82f6", "Charged"], ["#f59e0b", "Discharged"], ["var(--fg)", "Load"], ] : [ - ["#ef4444", "Grid"], ["#22c55e", "PV"], ["#e2e8f0", "Load"], + ["#ef4444", "Grid"], ["#22c55e", "PV"], ["var(--fg)", "Load"], ["#f59e0b", "Ferroamp"], ["#8b5cf6", "Sungrow"], ]; legend.innerHTML = items.map(function(it) { diff --git a/web/heating.js b/web/heating.js index 91c49535..5233a5fe 100644 --- a/web/heating.js +++ b/web/heating.js @@ -15,6 +15,8 @@ var REFRESH_MS = 30000; var timer = null; var heatPumpDrivers = null; // cached after discovery: array of driver names + var lastDiscoverMs = 0; // Date.now() of the last discovery scan + var DISCOVER_EVERY_MS = 300000; // re-scan for newly-added heat pumps (5 min) // Route reads over the owner/P2P transport when present (remote home // route), else plain fetch (LAN / tests). Mirrors twins.js. @@ -151,9 +153,17 @@ var grid = document.getElementById('heating-grid'); if (!section || !grid) return; - var ready = heatPumpDrivers - ? Promise.resolve(heatPumpDrivers) - : discover().then(function (names) { heatPumpDrivers = names; return names; }); + // Re-run discovery on first call, then periodically — so a heat-pump + // driver added while the dashboard is open shows up without a manual + // reload. (The old code cached forever; an empty result is also truthy, + // so a site that discovered before its pump reported hp_power_w stayed + // blank.) Steady-state stays cheap: between scans we only touch the + // already-known heat-pump drivers. + var nowMs = Date.now(); + var rediscover = heatPumpDrivers === null || (nowMs - lastDiscoverMs) >= DISCOVER_EVERY_MS; + var ready = rediscover + ? discover().then(function (names) { heatPumpDrivers = names; lastDiscoverMs = nowMs; return names; }) + : Promise.resolve(heatPumpDrivers); ready.then(function (names) { if (!names || names.length === 0) { From 6e306815d38ccd94e6bd582fd271198f612d8b12 Mon Sep 17 00:00:00 2001 From: "Claude Opus 4.8" Date: Fri, 26 Jun 2026 14:44:02 +0200 Subject: [PATCH 02/19] fix(ui): theme the LIVE dashboard charts (next-app.js) for light mode + cache-bust MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The earlier fix only touched app.js, but index.html loads next-app.js — app.js renders only on /legacy.html. So the live dashboard's charts kept their hard-coded dark chrome and stayed dark/invisible in light mode. Mirror the theme resolver (cssColor + cached chartColors) into next-app.js and apply it to all canvas chrome: the power/energy chart (renderChart), both hover tooltips, and the 24h battery+SoC live-history chart — axis text, gridlines, zero/now/hover lines, tooltip bg+border+text, and the neutral Load line. Saturated data-series hues are unchanged. Bump the cache-bust tokens (next-app.js next19->next20, heating.js heat1->heat2) so browsers actually re-fetch the updated next-app.js and the already-shipped heating.js re-discovery fix instead of serving the stale cached copies. Co-Authored-By: HuggeK <48095810+HuggeK@users.noreply.github.com> --- web/index.html | 4 +- web/next-app.js | 98 +++++++++++++++++++++++++++++++++++-------------- 2 files changed, 72 insertions(+), 30 deletions(-) diff --git a/web/index.html b/web/index.html index 263b3ce8..c08d36b8 100644 --- a/web/index.html +++ b/web/index.html @@ -780,7 +780,7 @@

Price bars (top of the chart)

- + @@ -798,7 +798,7 @@

Price bars (top of the chart)

- + diff --git a/web/next-app.js b/web/next-app.js index 6aa480db..395358ed 100644 --- a/web/next-app.js +++ b/web/next-app.js @@ -1176,6 +1176,8 @@ ctx.setTransform(dpr, 0, 0, dpr, 0, 0); } + var C = chartColors(); // theme-aware chrome colors, re-read each draw + var pad = { top: 20, right: 10, bottom: 25, left: 55 }; var plotW = w - pad.left - pad.right; var plotH = h - pad.top - pad.bottom; @@ -1204,7 +1206,7 @@ { data: toKwh(chartHistory.e_pv), color: "#10b981", width: 2, dash: [], name: "PV", fill: true }, { data: toKwh(chartHistory.e_charged), color: "#3b82f6", width: 2, dash: [], name: "Charged", fill: false }, { data: toKwh(chartHistory.e_discharged), color: "#f59e0b", width: 2, dash: [], name: "Discharged", fill: false }, - { data: toKwh(chartHistory.e_load), color: "#e2e8f0", width: 2, dash: [], name: "Load", fill: false }, + { data: toKwh(chartHistory.e_load), color: C.load, width: 2, dash: [], name: "Load", fill: false }, ]; } else { var smoothedGridSeries = smoothSeriesForChart(chartHistory.grid, chartHistory.timestamps, chartSmoothMs); @@ -1213,7 +1215,7 @@ series = [ { data: smoothedGridSeries, color: "#ef4444", width: 2, dash: [], name: "Grid", fill: true, toggle: "grid" }, { data: smoothedPVSeries, color: "#22c55e", width: 2, dash: [], name: "PV", fill: true, toggle: "pv" }, - { data: smoothedLoadSeries, color: "#e2e8f0", width: 1.5, dash: [], name: "Load", fill: false, toggle: "load" }, + { data: smoothedLoadSeries, color: C.load, width: 1.5, dash: [], name: "Load", fill: false, toggle: "load" }, ]; // Append one actual/target pair per discovered battery driver. // Stable order so chart colors don't jump as the driver set grows. @@ -1257,7 +1259,7 @@ if (visibleVals.length === 0) { // Empty state — draw axes + "waiting for data" hint ctx.clearRect(0, 0, w, h); - ctx.fillStyle = "#666"; + ctx.fillStyle = C.muted; ctx.font = chartFontWaiting; ctx.textAlign = "center"; ctx.fillText("waiting for data...", w / 2, h / 2); @@ -1305,7 +1307,7 @@ // Grid lines (drawn inside clip so they only appear in the plot area). // Walk yMin..yMax in yStep increments so every line lands on a round // number — that's what lets the y-axis labels stay readable. - ctx.strokeStyle = "#2a2a2a"; + ctx.strokeStyle = C.grid; ctx.lineWidth = 0.5; ctx.font = chartFontAxis; var steps = Math.round(yRange / yStep); @@ -1320,7 +1322,7 @@ // Zero line if (yMin < 0 && yMax > 0) { var zeroY = pad.top + plotH * (1 - (0 - yMin) / yRange); - ctx.strokeStyle = "#444"; + ctx.strokeStyle = C.muted; ctx.lineWidth = 1; ctx.setLineDash([4, 4]); ctx.beginPath(); @@ -1487,7 +1489,7 @@ // where present is. Stroke width bumps on small screens so the // marker stays visible alongside the larger axis labels. var nowX = pad.left + plotW; - ctx.strokeStyle = smallScreen ? "rgba(255,255,255,0.30)" : "rgba(255,255,255,0.12)"; + ctx.strokeStyle = C.grid; ctx.lineWidth = chartNowStrokeW; ctx.beginPath(); ctx.moveTo(nowX, pad.top); @@ -1495,7 +1497,7 @@ ctx.stroke(); // Y-axis labels (outside clip so they're fully visible) - ctx.fillStyle = "#888"; + ctx.fillStyle = C.dim; ctx.font = chartFontAxis; for (var i2 = 0; i2 <= steps; i2++) { var yVal = yMin + (yRange * i2 / steps); @@ -1504,7 +1506,7 @@ } // Time labels - ctx.fillStyle = "#666"; + ctx.fillStyle = C.muted; ctx.fillText(chartRange + " ago", pad.left, h - 5); ctx.textAlign = "right"; ctx.fillText("now", w - pad.right, h - 5); @@ -1530,7 +1532,7 @@ ctx.arc(w - pad.right - 78, pad.top + 4, 2.5, 0, Math.PI * 2); ctx.fill(); ctx.font = chartFontTooltip; - ctx.fillStyle = fresh ? "#aaa" : "#f59e0b"; + ctx.fillStyle = fresh ? C.dim : "#f59e0b"; ctx.fillText(ageStr, w - pad.right - 70, pad.top + 8); } @@ -1564,8 +1566,46 @@ return "rgba(" + r + "," + g + "," + b + "," + alpha + ")"; } + // Resolve a CSS custom property to a concrete color string for , + // which can't read var(). Re-read per draw so the charts follow the active + // theme (html[data-theme]) without needing an explicit redraw on toggle — + // a hidden probe element inherits :root, and getComputedStyle resolves the + // var() + oklch token to a value canvas can paint. + var _colorProbe = null; + function cssColor(name, fallback) { + if (!_colorProbe) { + _colorProbe = document.createElement("span"); + _colorProbe.style.cssText = "position:absolute;visibility:hidden;pointer-events:none"; + document.body.appendChild(_colorProbe); + } + _colorProbe.style.color = "var(" + name + ", " + (fallback || "#888") + ")"; + return getComputedStyle(_colorProbe).color || fallback || "#888"; + } + // Theme-aware chart chrome colors. Series hues stay fixed (they read on + // both themes); only text / gridlines / tooltip surface / the neutral + // "load" line flip — those are what go invisible on a light background. + // Cached so the animation loop doesn't getComputedStyle every frame — + // only recomputed when the active theme actually changes. + var _chartColors = null, _chartColorsTheme = null; + function chartColors() { + var theme = document.documentElement.getAttribute("data-theme") || ""; + if (_chartColors && _chartColorsTheme === theme) return _chartColors; + _chartColorsTheme = theme; + _chartColors = { + text: cssColor("--fg", "#e6e6e6"), + dim: cssColor("--fg-dim", "#aaaaaa"), + muted: cssColor("--fg-muted", "#888888"), + grid: cssColor("--line", "#2a2a2a"), + surface: cssColor("--ink-raised", "#14141f"), + accent: cssColor("--accent-e", "#fbbf24"), + load: cssColor("--fg", "#e2e8f0"), + }; + return _chartColors; + } + function drawHoverOverlay(ctx) { if (!chartLayout) return; + var C = chartColors(); var l = chartLayout; var i = hoverIndex; var fontTooltip = l.fontTooltip || "10px monospace"; @@ -1576,7 +1616,7 @@ var x = l.pad.left + l.plotW * (ts - l.windowStart) / l.totalMs; // Vertical line - ctx.strokeStyle = "rgba(255,255,255,0.3)"; + ctx.strokeStyle = C.muted; ctx.lineWidth = 1; ctx.setLineDash([2, 2]); ctx.beginPath(); @@ -1602,12 +1642,12 @@ { name: "PV", data: chartHistory.e_pv, color: "#10b981" }, { name: "Charged", data: chartHistory.e_charged, color: "#3b82f6" }, { name: "Discharged", data: chartHistory.e_discharged, color: "#f59e0b" }, - { name: "Load", data: chartHistory.e_load, color: "#e2e8f0" }, + { name: "Load", data: chartHistory.e_load, color: C.load }, ] : (function () { var rows = [ { name: "Grid", data: chartHistory.grid, color: "#ef4444" }, { name: "PV", data: chartHistory.pv, color: "#22c55e" }, - { name: "Load", data: chartHistory.load, color: "#e2e8f0" }, + { name: "Load", data: chartHistory.load, color: C.load }, ]; // Battery rows render their target inline as "actual W (→ target W)" // so it's visually obvious the two numbers are the same metric — one @@ -1634,14 +1674,14 @@ if (boxX + boxW > l.w - 5) boxX = x - boxW - 10; var boxY = l.pad.top + 5; - ctx.fillStyle = "rgba(20,20,35,0.95)"; - ctx.strokeStyle = "#444"; + ctx.fillStyle = C.surface; + ctx.strokeStyle = C.grid; ctx.lineWidth = 1; ctx.fillRect(boxX, boxY, boxW, boxH); ctx.strokeRect(boxX, boxY, boxW, boxH); ctx.font = fontTooltip; - ctx.fillStyle = "#888"; + ctx.fillStyle = C.dim; ctx.fillText(timeStr, boxX + 6, boxY + lineHeight - 2); labels.forEach(function (lab, idx) { @@ -1649,21 +1689,21 @@ var y = boxY + (idx + 2) * lineHeight - 4; ctx.fillStyle = lab.color; ctx.fillRect(boxX + 6, y - 8, 8, 8); - ctx.fillStyle = lab.dim ? "#888" : "#ddd"; + ctx.fillStyle = lab.dim ? C.muted : C.text; ctx.fillText(lab.name, boxX + 18, y); ctx.textAlign = "right"; if (chartView === "energy") { - ctx.fillStyle = "#fff"; + ctx.fillStyle = C.text; ctx.fillText(lab.data[i].toFixed(2) + " kWh", boxX + boxW - 6, y); } else { var actual = formatW(lab.data[i]); - ctx.fillStyle = "#fff"; + ctx.fillStyle = C.text; ctx.fillText(actual, boxX + boxW - 6, y); // Inline target as dim "(→ -674 W)" so user sees commanded vs actual // in one glance. Skip when target is 0 to reduce visual noise. if (lab.target && i < lab.target.length && Math.abs(lab.target[i]) > 1) { var actualW = ctx.measureText(actual).width; - ctx.fillStyle = "#888"; + ctx.fillStyle = C.dim; ctx.font = fontTooltipS; ctx.fillText("→ " + formatW(lab.target[i]), boxX + boxW - 10 - actualW, y); ctx.font = fontTooltip; @@ -1675,6 +1715,7 @@ function drawForecastHoverOverlay(ctx) { if (!chartLayout || !hoverForecast) return; + var C = chartColors(); var l = chartLayout; var a = hoverForecast.action; var ts = hoverForecast.ts; @@ -1708,8 +1749,8 @@ if (boxX + boxW > l.w - 5) boxX = x - boxW - 10; var boxY = l.pad.top + 5; - ctx.fillStyle = "rgba(20,20,35,0.96)"; - ctx.strokeStyle = "rgba(251,191,36,0.6)"; + ctx.fillStyle = C.surface; + ctx.strokeStyle = C.accent; ctx.lineWidth = 1; ctx.fillRect(boxX, boxY, boxW, boxH); ctx.strokeRect(boxX, boxY, boxW, boxH); @@ -1717,16 +1758,16 @@ ctx.font = fontTooltip; var d = new Date(ts); var hh = d.getHours().toString().padStart(2, "0") + ":" + d.getMinutes().toString().padStart(2, "0"); - ctx.fillStyle = "#fbbf24"; + ctx.fillStyle = C.accent; ctx.fillText(hh + " predicted", boxX + 6, boxY + lineHeight - 2); labels.forEach(function (lab, idx) { var y = boxY + (idx + 2) * lineHeight - 4; ctx.fillStyle = lab.color; ctx.fillRect(boxX + 6, y - 8, 8, 8); - ctx.fillStyle = "#ddd"; + ctx.fillStyle = C.text; ctx.fillText(lab.name, boxX + 18, y); - ctx.fillStyle = "#fff"; + ctx.fillStyle = C.text; ctx.textAlign = "right"; var val = lab.literal ? lab.val : formatW(lab.val); ctx.fillText(val, boxX + boxW - 6, y); @@ -1735,7 +1776,7 @@ if (a.reason) { var ry = boxY + (labels.length + 2) * lineHeight + 2; - ctx.fillStyle = "#86efac"; + ctx.fillStyle = C.dim; ctx.font = "italic 10px monospace"; // Truncate if too long for box var reason = a.reason.length > 28 ? a.reason.substring(0, 27) + "…" : a.reason; @@ -3811,6 +3852,7 @@ ctx.setTransform(dpr, 0, 0, dpr, 0, 0); ctx.clearRect(0, 0, cssW, cssH); + var C = chartColors(); var pad = { left: 36, right: 36, top: 8, bottom: 18 }; var plotW = cssW - pad.left - pad.right; var plotH = cssH - pad.top - pad.bottom; @@ -3837,7 +3879,7 @@ }; // Zero baseline for battery - ctx.strokeStyle = "rgba(255,255,255,0.08)"; + ctx.strokeStyle = C.grid; ctx.lineWidth = 1; ctx.beginPath(); ctx.moveTo(pad.left, batMid); @@ -3882,7 +3924,7 @@ ctx.stroke(); // Axis labels — small, mono, dim. - ctx.fillStyle = "rgba(255,255,255,0.5)"; + ctx.fillStyle = C.dim; ctx.font = "10px ui-monospace, SFMono-Regular, Menlo, Monaco, monospace"; ctx.textAlign = "right"; ctx.textBaseline = "middle"; @@ -3894,7 +3936,7 @@ ctx.fillText("100%", pad.left + plotW + 4, socTop + 6); ctx.fillText("0%", pad.left + plotW + 4, socTop + socH - 6); // time axis: 24h ago / now - ctx.fillStyle = "rgba(255,255,255,0.4)"; + ctx.fillStyle = C.muted; ctx.textAlign = "left"; ctx.fillText("24h ago", pad.left, cssH - 4); ctx.textAlign = "right"; From 62c0d4b23b6640c794cd27667e6c13276ceb65fe Mon Sep 17 00:00:00 2001 From: "Claude Opus 4.8" Date: Fri, 26 Jun 2026 15:07:34 +0200 Subject: [PATCH 03/19] feat(ui): add a Register column to the heat-pump all-signals detail view MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each row in the per-driver 'all signals' drill-in now shows the signal's source Modbus register id (m.register from the driver detail metrics, supplied by the NIBE driver / emit_metric register plumbing). Signals with no Modbus mapping render '—'. Bumps heating.js cache token heat2->heat3 so browsers pick up the new column. Co-Authored-By: HuggeK <48095810+HuggeK@users.noreply.github.com> --- .changeset/dashboard-light-dark.md | 2 ++ web/heating.js | 4 +++- web/index.html | 2 +- 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/.changeset/dashboard-light-dark.md b/.changeset/dashboard-light-dark.md index f2bee8b6..fc82e0ec 100644 --- a/.changeset/dashboard-light-dark.md +++ b/.changeset/dashboard-light-dark.md @@ -7,3 +7,5 @@ Dashboard light-mode + heat-pump card fixes. The live power/energy **charts now render correctly in light mode**. The canvas chrome (axis labels, gridlines, zero/now/hover lines, both tooltips' background+border+text, and the neutral "Load" line + legend swatch) was hard-coded for the dark theme, so it went invisible or wrong on a light background. The charts now resolve the CSS theme tokens (`--fg`, `--fg-dim`, `--fg-muted`, `--line`, `--ink-raised`, `--accent-e`) into concrete canvas colors per draw — cached and re-read when `data-theme` changes. Saturated data-series hues are unchanged. The **heat-pump card now re-discovers newly-added drivers** without a manual reload. `heating.js` cached discovery once and never re-checked — and an empty result is truthy, so a site that discovered before its pump reported `hp_power_w` stayed blank. It now re-scans on first load and every 5 minutes, while steady-state polling still only touches already-known heat-pump drivers. + +The heat-pump **"all signals" detail view now shows a Register column** — each signal's source Modbus register id (read from the metric snapshot the driver reports). Signals with no Modbus mapping show "—". diff --git a/web/heating.js b/web/heating.js index 5233a5fe..0e95253b 100644 --- a/web/heating.js +++ b/web/heating.js @@ -232,8 +232,9 @@ '.ftw-hpd-title{font-family:var(--mono);font-size:0.74rem;letter-spacing:0.18em;text-transform:uppercase;color:var(--fg-muted)}', '.ftw-hpd-close{background:none;border:1px solid var(--line);color:var(--fg);border-radius:6px;cursor:pointer;font-size:1rem;line-height:1;padding:4px 9px}', '.ftw-hpd-group{margin:16px 0 4px;font-family:var(--mono);font-size:0.68rem;letter-spacing:0.14em;text-transform:uppercase;color:var(--accent-e)}', - '.ftw-hpd-row{display:grid;grid-template-columns:1fr auto 120px;gap:10px 14px;align-items:center;padding:5px 0;border-bottom:1px solid var(--line-soft,var(--line))}', + '.ftw-hpd-row{display:grid;grid-template-columns:1fr auto auto 120px;gap:10px 14px;align-items:center;padding:5px 0;border-bottom:1px solid var(--line-soft,var(--line))}', '.ftw-hpd-label{color:var(--fg-muted);font-size:0.85rem}', + '.ftw-hpd-reg{font-family:var(--mono);font-variant-numeric:tabular-nums;font-size:0.76rem;color:var(--fg-muted);text-align:right;opacity:0.8}', '.ftw-hpd-val{font-family:var(--mono);font-variant-numeric:tabular-nums;color:var(--fg);text-align:right}', '.ftw-hpd-spark svg{width:120px;height:24px;display:block}', '.ftw-hpd-empty{color:var(--fg-muted);font-family:var(--mono);font-size:0.82rem}', @@ -287,6 +288,7 @@ rows.forEach(function (m) { html += '
' + '' + escapeHtml(prettyLabel(m.name)) + '' + + '' + (m.register ? escapeHtml(String(m.register)) : '—') + '' + '' + escapeHtml(fmtValue(m.value, m.unit)) + '' + '' + '
'; diff --git a/web/index.html b/web/index.html index c08d36b8..bfae953a 100644 --- a/web/index.html +++ b/web/index.html @@ -798,7 +798,7 @@

Price bars (top of the chart)

- + From 39e99ec1a5b2ed781c4a475ca903fea0c4c93fcf Mon Sep 17 00:00:00 2001 From: "Claude Opus 4.8" Date: Mon, 29 Jun 2026 10:48:42 +0200 Subject: [PATCH 04/19] fix(ui): theme the heat-pump card in light mode The heat-pump card renders into a bare .card, whose base rule (style.css) sets background:var(--surface). --surface is hard-coded dark in :root and is NOT flipped by html[data-theme=light], so the card stayed dark on a light page while the type-specific cards (.summary-card etc., themed via --ink-raised) went light. Add a scoped next.css rule putting .heating-row .card on the --ink-raised palette, and bump next.css cache token next6->next7. Co-Authored-By: HuggeK <48095810+HuggeK@users.noreply.github.com> --- .changeset/dashboard-light-dark.md | 2 ++ web/index.html | 2 +- web/next.css | 11 +++++++++++ 3 files changed, 14 insertions(+), 1 deletion(-) diff --git a/.changeset/dashboard-light-dark.md b/.changeset/dashboard-light-dark.md index fc82e0ec..3428cc7b 100644 --- a/.changeset/dashboard-light-dark.md +++ b/.changeset/dashboard-light-dark.md @@ -9,3 +9,5 @@ The live power/energy **charts now render correctly in light mode**. The canvas The **heat-pump card now re-discovers newly-added drivers** without a manual reload. `heating.js` cached discovery once and never re-checked — and an empty result is truthy, so a site that discovered before its pump reported `hp_power_w` stayed blank. It now re-scans on first load and every 5 minutes, while steady-state polling still only touches already-known heat-pump drivers. The heat-pump **"all signals" detail view now shows a Register column** — each signal's source Modbus register id (read from the metric snapshot the driver reports). Signals with no Modbus mapping show "—". + +The **heat-pump card itself now themes in light mode**. It renders into a bare `.card`, whose base style uses the `--surface` token — which the light theme doesn't flip — so the card stayed dark on a light page while every other dashboard card themed correctly. It now uses the same `--ink-raised` palette as the rest. diff --git a/web/index.html b/web/index.html index bfae953a..5c08019b 100644 --- a/web/index.html +++ b/web/index.html @@ -50,7 +50,7 @@ - +