{{ t('portal.device.title') }}
-
-
- {{ t('portal.device.status') }}
- {{ t('portal.device.last_handshake') }}
- {{ t('portal.device.vpn_address') }}
- {{ t('portal.device.dns') }}
-
-
-
-
- {{ t('portal.device.received') }}
{{ t('portal.device.sent') }}
{{ t('portal.traffic.title') }} - {{ t('portal.traffic.legend_rx') }}{{ t('portal.traffic.legend_tx') }} - - - - - -
- -
- {{ t('portal.traffic.total') }}:
- {{ t('portal.traffic.avg_day') }}:
- {{ t('portal.traffic.peak') }}:
-
+ {% if widgets.midea %}
+
+ {{ t('portal.midea.title') }}
+ +{{ t('portal.services.title') }}{{ t('portal.services.sub') }}
- + {% if widgets.smarthome %} + +{{ t('portal.smarthome.title') }}
{{ t('portal.midea.title') }}
- - + {% if widgets.traffic %} + +{{ t('portal.traffic.title') }} + {{ t('portal.traffic.legend_rx') }}{{ t('portal.traffic.legend_tx') }} + + + + + +
+ +
+ {{ t('portal.traffic.total') }}:
+ {{ t('portal.traffic.avg_day') }}:
+ {{ t('portal.traffic.peak') }}:
+
{{ t('portal.smarthome.title') }}
{{ t('portal.services.title') }}{{ t('portal.services.sub') }}
+
-
+
{{ t('portal.device.vpn_address') }}
{{ t('portal.device.dns') }}
{{ t('portal.device.last_handshake') }}
From 8333a1ebea2c839d0c8a1dd8f486a506460afba5 Mon Sep 17 00:00:00 2001
From: CallMeTechie <34693633+CallMeTechie@users.noreply.github.com>
Date: Fri, 3 Jul 2026 22:40:06 +0200
Subject: [PATCH 3/9] feat(portal): enrich Pi-hole widget with block-rate donut
---
public/css/portal.css | 15 +++++++--------
public/js/portal.js | 9 +++++++--
templates/portal/portal.njk | 6 ++++--
tests/portal_css_smoke.test.js | 7 +++----
tests/portal_redesign_layout.test.js | 10 ++++++++++
5 files changed, 31 insertions(+), 16 deletions(-)
diff --git a/public/css/portal.css b/public/css/portal.css
index e4b59579..2c40b3c4 100644
--- a/public/css/portal.css
+++ b/public/css/portal.css
@@ -296,14 +296,13 @@ body::before{
/* ============================================================
PI-HOLE WIDGET
============================================================ */
-.c-pihole .pihole-body{display:flex;flex-direction:column;gap:10px}
-.pihole-rate{display:flex;align-items:baseline;gap:3px}
-.pihole-rate b{font-size:2.2rem;font-weight:700;color:var(--teal, #3ec8b0);line-height:1}
-.pihole-rate span{font-size:1.1rem;color:var(--muted)}
-.pihole-bar{height:6px;border-radius:3px;background:var(--border);overflow:hidden}
-.pihole-bar i{display:block;height:100%;border-radius:3px;background:var(--teal, #3ec8b0);
- transition:width .4s ease;width:0}
-.pihole-nums{display:flex;gap:12px;flex-wrap:wrap}
+.c-pihole .pihole-body{display:flex;align-items:center;gap:20px}
+.donut{position:relative;width:132px;height:132px;flex:0 0 auto}
+.donut svg{transform:rotate(-90deg)}
+.donut .dc{position:absolute;inset:0;display:grid;place-items:center;text-align:center}
+.donut .dc b{font-size:1.9rem;font-weight:700;color:var(--coral);line-height:1;font-family:var(--font-mono, inherit)}
+.donut .dc span{font-size:1rem;color:var(--muted)}
+.pihole-nums{display:flex;flex-direction:column;gap:12px;flex:1}
.pihole-nums>div{display:flex;flex-direction:column;gap:2px;min-width:60px}
.pihole-nums b{font-size:1.1rem;font-weight:600;color:var(--text)}
.pihole-nums span{font-size:11px;color:var(--muted);text-transform:uppercase;letter-spacing:.04em}
diff --git a/public/js/portal.js b/public/js/portal.js
index c61a33b9..63b9887a 100644
--- a/public/js/portal.js
+++ b/public/js/portal.js
@@ -400,8 +400,13 @@
// ── Stats common to all scopes ──────────────────────────────────────
var pctEl = document.getElementById('piPct');
if (pctEl) pctEl.textContent = String(d.blockedPct);
- var bar = document.getElementById('piBar');
- if (bar) bar.style.width = d.blockedPct + '%';
+ var arc = document.getElementById('piDonut');
+ if (arc) {
+ var r = 50, C = 2 * Math.PI * r; // r MUSS zum im Donut-Markup passen
+ var frac = Math.max(0, Math.min(1, (Number(d.blockedPct) || 0) / 100)); // d.blockedPct = dieselbe Block-Rate, die auch #piPct setzt
+ arc.setAttribute('stroke-dasharray', C.toFixed(1));
+ arc.setAttribute('stroke-dashoffset', (C - frac * C).toFixed(1));
+ }
var totalEl = document.getElementById('piTotal');
if (totalEl) totalEl.textContent = String(d.total);
var blockedEl = document.getElementById('piBlocked');
diff --git a/templates/portal/portal.njk b/templates/portal/portal.njk
index d6a7f52a..d89e8c27 100644
--- a/templates/portal/portal.njk
+++ b/templates/portal/portal.njk
@@ -157,8 +157,10 @@
' +
'' + escHtml(statusTxt) + '' +
'
' +
- '
-
';
+ // Temperature ring (target-based, NOT a 0–100% gauge): r=52 circle, 16–30 °C mapped to the arc.
+ var r = 52, C = 2 * Math.PI * r;
+ var MIN = 16, MAX = 30;
+ var frac = hasTgt ? Math.max(0, Math.min(1, (tgt - MIN) / (MAX - MIN))) : 0;
+ var dash = C.toFixed(1);
+ var dashOff = (C - frac * C).toFixed(1);
+ var ringC = offline ? 'var(--faint)' : ((st.mode === 'heat') ? 'var(--coral)' : 'var(--teal)'); // heat → coral, else teal
+ var ringBig = hasTgt ? String(Math.round(tgt)) : '—';
var modeBtns = MIDEA_MODES.map(function (m) {
- var active = (!offline && st.mode === m) ? ' active' : '';
+ var active = !offline && st.mode === m;
+ var cls = 'modeseg-opt' + (active ? ' on' : '') + (active && m === 'heat' ? ' heat' : '');
var lbl = escHtml(mideaModeLabel(m));
- return '';
+ return '';
}).join('');
var meta = d.transport ? '–%
-
+
+
+
+
–%
–{{ t('portal.pihole.total') }}
–{{ t('portal.pihole.blocked') }}
diff --git a/tests/portal_css_smoke.test.js b/tests/portal_css_smoke.test.js
index 97368198..f95da45a 100644
--- a/tests/portal_css_smoke.test.js
+++ b/tests/portal_css_smoke.test.js
@@ -23,13 +23,12 @@ test('portal.css contains dark and light theme token blocks', async () => {
assert.ok(res.text.includes('[data-theme="light"]'), 'missing light theme block');
});
-test('portal.css contains the TP2a per-device DNS-protection widget, not the old donut styling', async () => {
+test('portal.css contains the TP2a per-device DNS-protection widget, with the enriched donut widget', async () => {
const res = await supertest(app).get('/css/portal.css').expect(200);
// TP2a re-adds the per-device Pi-hole widget (the "DNS-Schutz" card) deliberately.
assert.ok(res.text.includes('.c-pihole'), 'missing .c-pihole — TP2a per-device DNS widget styles');
- // It uses a block-rate bar, NOT the old donut/pi-wrap implementation, which stays removed.
- assert.ok(!res.text.includes('.donut'), 'found .donut — old Pi-hole donut styling must not return');
- assert.ok(!res.text.includes('.pi-wrap'), 'found .pi-wrap — old Pi-hole donut styling must not return');
+ // Task 2 replaces the bar with a block-rate donut.
+ assert.ok(res.text.includes('.donut'), 'missing .donut — enriched Pi-hole donut styling');
});
test('portal.css contains JS-state rules (moved from portal.js inline injector for CSP safety)', async () => {
diff --git a/tests/portal_redesign_layout.test.js b/tests/portal_redesign_layout.test.js
index acc36c34..468922c5 100644
--- a/tests/portal_redesign_layout.test.js
+++ b/tests/portal_redesign_layout.test.js
@@ -34,3 +34,13 @@ test('portal.css uses full-width wrapper 1560', async () => {
const res = await supertest(app).get('/css/portal.css').expect(200);
assert.ok(res.text.includes('max-width:1560px') || res.text.includes('max-width: 1560px'), '.wrap widened to 1560');
});
+
+test('pi-hole widget renders a donut svg with piDonut id and keeps scope IDs', async () => {
+ const h = await portalHtml();
+ assert.match(h, /class="[^"]*\bdonut\b[^"]*"/, '.donut present');
+ assert.ok(h.includes('id="piDonut"'), 'donut arc has id piDonut');
+ for (const id of ['piPct', 'piTotal', 'piBlocked', 'piAllowed', 'piAllowedWrap', 'piOwnerExtra', 'piholeSeg']) {
+ assert.ok(h.includes('id="' + id + '"') || h.includes(id), 'kept ' + id);
+ }
+ assert.match(h, /class="[^"]*\bpihole-widget\b[^"]*"/, '.pihole-widget retained');
+});
From b737164a908ff65ad233195e67422832e6abd0c3 Mon Sep 17 00:00:00 2001
From: CallMeTechie <34693633+CallMeTechie@users.noreply.github.com>
Date: Fri, 3 Jul 2026 22:43:53 +0200
Subject: [PATCH 4/9] fix(portal): define --track theme var for donut/ring
background
---
public/css/portal.css | 2 ++
1 file changed, 2 insertions(+)
diff --git a/public/css/portal.css b/public/css/portal.css
index 2c40b3c4..6473f15c 100644
--- a/public/css/portal.css
+++ b/public/css/portal.css
@@ -74,6 +74,7 @@
--surface:#111a24; --surface-2:#16212e; --surface-3:#1b2836;
--chip:rgba(255,255,255,.03);
--line:rgba(255,255,255,.07); --line-2:rgba(255,255,255,.12);
+ --track:rgba(255,255,255,.08);
--text:#e9eff6; --muted:#90a1b3; --faint:#5f6f7e;
--teal:#34dcc6; --teal-dim:#1f9c8e; --coral:#ff9d6c; --coral-dim:#cf6f45;
--amber:#f5c451; --green:#4ade80; --green-bg:rgba(74,222,128,.10); --green-bd:rgba(74,222,128,.25);
@@ -86,6 +87,7 @@
--surface:#fffdf8; --surface-2:#f8f3ea; --surface-3:#f0e9db;
--chip:rgba(60,45,25,.04);
--line:rgba(60,45,25,.10); --line-2:rgba(60,45,25,.17);
+ --track:rgba(60,45,25,.10);
--text:#2c2720; --muted:#6e6456; --faint:#766b59;
--teal:#0e9b8a; --teal-dim:#0b7d70; --coral:#d9602f; --coral-dim:#b14d24;
--amber:#b5811f; --green:#15924f; --green-bg:rgba(21,146,79,.10); --green-bd:rgba(21,146,79,.28);
From 3523ff1aa7f2abf8af92f9c6b6c2d32645541176 Mon Sep 17 00:00:00 2001
From: CallMeTechie <34693633+CallMeTechie@users.noreply.github.com>
Date: Fri, 3 Jul 2026 22:52:33 +0200
Subject: [PATCH 5/9] =?UTF-8?q?feat(portal):=20redesign=20Klima=20card=20(?=
=?UTF-8?q?ring/stepper/mode)=20=E2=80=94=20controls=20preserved?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Target-temp SVG ring (r=52, 16-30 range) + text mode-segment + i18n
stepper aria-labels, replacing the old conic-gradient ring and icon
mode buttons. All existing data-act/data-mode controls (fan, fan-auto,
turbo, eco, outdoor) keep their markup and event wiring unchanged.
---
public/css/portal.css | 40 +++++++++++++++++---------------
public/js/portal.js | 46 ++++++++++++++++++++++---------------
src/i18n/de.json | 2 ++
src/i18n/en.json | 2 ++
templates/portal/portal.njk | 2 ++
5 files changed, 54 insertions(+), 38 deletions(-)
diff --git a/public/css/portal.css b/public/css/portal.css
index 6473f15c..681f295b 100644
--- a/public/css/portal.css
+++ b/public/css/portal.css
@@ -344,16 +344,21 @@ body::before{
.midea-card .ac-status.off{color:var(--muted);background:var(--chip);border:1px solid var(--line)}
.midea-card .ac-status.off-line{color:var(--coral);background:var(--chip);border:1px solid var(--line-2)}
-/* Klima-Block: Ring-Gauge + Stell-Spalte */
-.midea-card .ac-climate{display:flex;align-items:center;gap:16px;margin:16px 0;padding:16px 0;
+/* Klima-Block: Temperatur-Ring (SVG, Soll-basiert) + Stell-Spalte */
+.midea-card .ac-climate.ac-ring{display:flex;align-items:center;gap:16px;margin:16px 0;padding:16px 0;
border-top:1px solid var(--line);border-bottom:1px solid var(--line)}
-.midea-card .ring{width:92px;height:92px;border-radius:50%;flex:0 0 auto;display:grid;place-items:center;
- background:conic-gradient(var(--ring-c,var(--teal)) var(--ring-val,0%), var(--line-2) 0)} /* full-circle gauge; fill color per mode (heat→coral) */
-.midea-card .ring-in{width:72px;height:72px;border-radius:50%;background:var(--surface);display:flex;
- flex-direction:column;align-items:center;justify-content:center;gap:1px}
-.midea-card .ring-v{font-family:var(--font-display);font-weight:800;font-size:25px;line-height:1;color:var(--text)}
-.midea-card .ring-l{font-size:9px;text-transform:uppercase;letter-spacing:.07em;color:var(--faint);font-weight:700}
-.midea-card .ac-set{flex:1;min-width:0;display:flex;flex-direction:column;gap:13px}
+.midea-card .ring{position:relative;width:106px;height:106px;flex:0 0 auto}
+.midea-card .ring svg{transform:rotate(-90deg)} /* start the arc at 12 o'clock */
+.midea-card .ring svg circle{transition:stroke-dashoffset .3s, stroke .15s} /* fill color per mode (heat→coral) */
+.midea-card .ring .rc{position:absolute;inset:0;display:grid;place-items:center;text-align:center}
+.midea-card .ring .rc .t{font-family:var(--font-display);font-weight:800;font-size:25px;line-height:1;color:var(--text)}
+.midea-card .ring .rc .t sup{font-size:12px;position:relative;top:-.6em}
+.midea-card .ring .rc .c{font-size:9px;text-transform:uppercase;letter-spacing:.07em;color:var(--faint);
+ font-weight:700;margin-top:3px}
+.midea-card .ac-side{flex:1;min-width:0;display:flex;flex-direction:column;gap:8px}
+.midea-card .ac-row{display:flex;align-items:center;justify-content:space-between;font-size:12.5px;color:var(--muted)}
+.midea-card .ac-row b{color:var(--text);font-weight:600;font-family:var(--font-mono)}
+.midea-card .ac-set{flex:1;min-width:0;display:flex;flex-direction:column;gap:13px;margin-top:13px}
.midea-card .set-lbl{font-size:10.5px;text-transform:uppercase;letter-spacing:.07em;color:var(--faint);
font-weight:700;margin-bottom:7px}
@@ -368,14 +373,13 @@ body::before{
.midea-card .stepper.pending .v{color:var(--amber)} /* command in flight (optimistic) */
.midea-card .stepper.confirmed .v{color:var(--green)} /* device confirmed the setpoint */
-/* Modus-Segment: an die Admin-/midea-Karte angeglichen (.toggle-group/.mode-group):
- gepolsterter Inset-Track mit Lücken statt Trennlinien, abgesetzte aktive Pille. */
-.midea-card .modes{display:flex;width:100%;gap:2px;padding:3px;border:1px solid var(--line);border-radius:10px;background:var(--bg-2)}
-.midea-card .modes .m{flex:1 1 0;min-width:0;padding:7px 0;display:flex;align-items:center;justify-content:center;
- border:0;border-radius:7px;background:transparent;color:var(--muted);cursor:pointer;transition:.12s}
-.midea-card .modes .m svg{width:16px;height:16px}
-.midea-card .modes .m:hover:not([disabled]):not(.active){color:var(--text)}
-.midea-card .modes .m.active{background:var(--surface-3);color:var(--text);box-shadow:0 2px 8px -4px rgba(0,0,0,.35)}
+/* Modus-Segment: Text-Chips wie im Redesign-Mockup (.modeseg), aktiv = Teal-Tönung, Heizen = Coral. */
+.midea-card .modeseg{display:flex;gap:5px;flex-wrap:wrap}
+.midea-card .modeseg-opt{font-size:11px;padding:6px 10px;border-radius:8px;border:1px solid var(--line);
+ color:var(--muted);background:var(--chip);font-family:var(--font-body);font-weight:600;cursor:pointer;transition:.12s}
+.midea-card .modeseg-opt:hover:not([disabled]):not(.on){color:var(--text)}
+.midea-card .modeseg-opt.on{background:rgba(52,220,198,.14);color:var(--teal);border-color:transparent}
+.midea-card .modeseg-opt.on.heat{background:rgba(255,157,108,.16);color:var(--coral)}
/* Power-Aktion */
.midea-card .ac-foot{display:flex;gap:9px;align-items:center}
@@ -389,8 +393,6 @@ body::before{
/* zweistufig: ungeloggt/offline → Steuerelemente gesperrt */
.midea-card [disabled]{opacity:.4;cursor:not-allowed;pointer-events:none}
-/* Ring + Außentemp gestapelt */
-.midea-card .ring-wrap{display:flex;flex-direction:column;align-items:center;gap:7px;flex:0 0 auto}
.midea-card .ac-outdoor{font-size:11px;color:var(--muted);font-weight:600;font-family:var(--font-mono)}
/* Toggle-Chips (Turbo/Eco/Auto) */
diff --git a/public/js/portal.js b/public/js/portal.js
index 63b9887a..2f71aaa5 100644
--- a/public/js/portal.js
+++ b/public/js/portal.js
@@ -500,19 +500,12 @@
var FAN_STEPS = [1, 20, 40, 60, 80, 100]; // Prozent-Stufen wie Midea-App (1–100, 100=Max); Auto=102 außerhalb der Skala
function fanIndex(v) { return FAN_STEPS.reduce(function (b, val, i, a) { return Math.abs(val - v) < Math.abs(a[b] - v) ? i : b; }, 0); }
// Static icon strings (no user data → safe to inline). Mirrors the admin /midea card.
- var MIDEA_MODE_ICONS = { // same icon set as the admin /midea card
- auto: '',
- cool: '',
- heat: '',
- dry: '',
- fan: '',
- };
var MIDEA_AC_ICON = '';
var MIDEA_POWER_ICON = '';
function mideaModeLabel(m) { return PT['mideaMode' + m.charAt(0).toUpperCase() + m.slice(1)] || m; }
- // Redesign: card grid with conic-gradient temp-ring + stepper + icon mode-segment + status pill
- // (modeled on the admin /midea card, in the portal design system). Data contract unchanged:
+ // Redesign: card grid with SVG target-temp ring (r=52, 16–30 °C arc) + stepper + text mode-segment + status pill
+ // (mirrors the portal-redesign mockup's .ac/.ac-ring/.stepper/.modeseg). Data contract unchanged:
// .midea-card[data-id], .midea-status[data-power], button[data-act=power]/[data-step]/[data-mode], .midea-target[data-val].
function renderMideaCard(d) {
var st = d.state || {};
@@ -520,8 +513,6 @@
var powered = !offline && !!st.power;
var indoor = Number(st.indoorTemp);
var hasIndoor = !offline && !isNaN(indoor);
- var pct = hasIndoor ? Math.max(0, Math.min(100, ((indoor - 16) / 14) * 100)) : 0; // ring fill, 16–30 °C span
- var ringC = (!offline && st.mode === 'heat') ? 'var(--coral)' : 'var(--teal)'; // heat → coral, else teal
var temp = hasIndoor ? Math.round(indoor) + '°' : '—';
var tgt = Number(st.targetTemp);
var hasTgt = !offline && !isNaN(tgt);
@@ -548,10 +539,19 @@
'' +
'' +
'' + escHtml(d.transport === 'cloud' ? 'Cloud' : 'LAN') + '
' : '';
var powerBtn = offline
@@ -565,14 +565,22 @@
'' + escHtml(d.name) + '
' + meta + '' +
- '' + outdoor + '' +
- '
-
-
+
+
{% endif %}
diff --git a/tests/portal_redesign_layout.test.js b/tests/portal_redesign_layout.test.js
index 468922c5..b9d2da93 100644
--- a/tests/portal_redesign_layout.test.js
+++ b/tests/portal_redesign_layout.test.js
@@ -1,4 +1,5 @@
'use strict';
+const fs = require('fs');
const crypto = require('crypto');
process.env.GC_ENCRYPTION_KEY = process.env.GC_ENCRYPTION_KEY || crypto.randomBytes(32).toString('hex');
const { test, beforeEach, afterEach } = require('node:test');
@@ -44,3 +45,12 @@ test('pi-hole widget renders a donut svg with piDonut id and keeps scope IDs', a
}
assert.match(h, /class="[^"]*\bpihole-widget\b[^"]*"/, '.pihole-widget retained');
});
+
+test('smarthome renders toggle tiles + sensor row (css + a11y switch role)', async () => {
+ const res = await supertest(app).get('/css/portal.css').expect(200);
+ assert.ok(res.text.includes('.sh-tile'), '.sh-tile styling present');
+ assert.ok(res.text.includes('.toggle'), '.toggle styling present');
+ assert.ok(res.text.includes('.sh-sensors'), '.sh-sensors row present');
+ const js = fs.readFileSync('public/js/portal.js', 'utf8');
+ assert.ok(js.includes('role="switch"') || js.includes("role='switch'"), 'smart-home toggle exposes role=switch');
+});
From 95a40049fed7cb370fbec2fcc6139ea2a400b740 Mon Sep 17 00:00:00 2001
From: CallMeTechie <34693633+CallMeTechie@users.noreply.github.com>
Date: Fri, 3 Jul 2026 23:08:47 +0200
Subject: [PATCH 7/9] feat(portal): compact services launcher strip
---
public/css/portal.css | 28 ++++++++++++++--------------
public/js/portal.js | 28 ++++++++++------------------
templates/portal/portal.njk | 2 +-
tests/portal_redesign_layout.test.js | 6 ++++++
4 files changed, 31 insertions(+), 33 deletions(-)
diff --git a/public/css/portal.css b/public/css/portal.css
index 34f6fc77..b6354e54 100644
--- a/public/css/portal.css
+++ b/public/css/portal.css
@@ -227,19 +227,19 @@ body::before{
.legend i{width:9px;height:9px;border-radius:3px;display:inline-block}
/* ============================================================
- SERVICES CARD (.c-services — now full-width span 12)
- Tiles use auto-fill / minmax(200px) so they don't render as 2 giant tiles
- at full 12-column width. Still collapses to 2-up on narrow screens.
+ SERVICES CARD (.c-services — full-width span 12, compact launcher strip)
============================================================ */
-.tiles{display:grid; grid-template-columns:repeat(auto-fill, minmax(200px, 1fr)); gap:11px; margin-top:18px}
-.tile{display:flex; flex-direction:column; gap:10px; padding:14px; border-radius:var(--r-sm); cursor:pointer;
- background:var(--surface-3); border:1px solid var(--line); text-decoration:none; color:var(--text); transition:.16s}
-.tile:hover{transform:translateY(-2px); border-color:var(--line-2); background:var(--surface-2)}
-.tile .ti{width:34px;height:34px;border-radius:10px;display:grid;place-items:center; color:#fff}
-.tile .ti svg{width:18px;height:18px}
-.tile .tn{font-weight:600; font-size:13.5px}
-.tile .td{font-size:11.5px; color:var(--faint); font-family:var(--font-mono)}
-.tile.add{align-items:center; justify-content:center; border-style:dashed; color:var(--faint); background:transparent}
+.svc-strip{display:grid; grid-template-columns:repeat(auto-fill, minmax(150px, 1fr)); gap:11px; margin-top:18px}
+.svc{display:flex; align-items:center; gap:11px; padding:11px 13px; border:1px solid var(--line); border-radius:12px;
+ background:var(--chip); text-decoration:none; color:inherit; transition:.15s; cursor:pointer}
+.svc:hover{border-color:var(--teal); transform:translateY(-2px)}
+.svc .si{width:32px;height:32px;border-radius:9px;display:grid;place-items:center;flex:0 0 auto;
+ background:var(--surface-3); color:var(--teal); font-family:var(--font-mono); font-weight:700; font-size:13px}
+.svc .st{min-width:0}
+.svc .st b{display:block; font-size:13px; font-weight:600; white-space:nowrap; overflow:hidden; text-overflow:ellipsis}
+.svc .st span{color:var(--faint); font-size:11px}
+.svc .live{width:7px;height:7px;border-radius:50%;background:var(--green);margin-left:auto;flex:0 0 auto}
+.svc:focus-visible{outline:2px solid var(--teal); outline-offset:2px}
/* ============================================================
FOOTER
@@ -254,7 +254,7 @@ body::before{
.grid > .card{grid-column:span 12 !important}
.devpill .ip{display:none}
/* Keep service tiles 2-up on narrow screens (thumb-friendly) */
- .tiles{grid-template-columns:1fr 1fr}
+ .svc-strip{grid-template-columns:1fr 1fr}
.sh-sensors{grid-template-columns:repeat(4,1fr)}
}
@@ -295,7 +295,7 @@ body::before{
.portal-empty{padding:24px 0;color:var(--faint);font-size:13px;text-align:center;grid-column:1/-1}
/* Reserve height for services card while tiles load */
-.c-services.loading{min-height:200px}
+.c-services.loading{min-height:110px}
/* ============================================================
PI-HOLE WIDGET
diff --git a/public/js/portal.js b/public/js/portal.js
index 40a36713..ae0fcfee 100644
--- a/public/js/portal.js
+++ b/public/js/portal.js
@@ -292,18 +292,11 @@
}
// ─── Services widget ────────────────────────────────────────────────────────
- const TILE_COLORS = [
- 'linear-gradient(145deg,#ffb27a,#e8763f)',
- 'linear-gradient(145deg,#7ae0d2,#28b3a2)',
- 'linear-gradient(145deg,#9db8ff,#5b76d6)',
- 'linear-gradient(145deg,var(--teal),var(--teal-dim))',
- ];
- const TILE_ICON =
- '';
+ // Two-letter monogram derived from the service name for the compact launcher tile.
+ function serviceMonogram(name) {
+ const trimmed = String(name || '').trim();
+ return (trimmed.slice(0, 1).toUpperCase() + trimmed.slice(1, 2).toLowerCase()) || '?';
+ }
function hydrateServices() {
const card = document.querySelector('.c-services');
@@ -330,13 +323,12 @@
tilesEl.replaceChildren(emptyDiv);
return;
}
- tilesEl.innerHTML = services.map(function (s, i) {
- const color = TILE_COLORS[i % TILE_COLORS.length];
- return '' +
- '' + TILE_ICON + '' +
- '' + escHtml(s.name) + '' +
- '' + escHtml(s.domain) + '' +
+ '' + escHtml(serviceMonogram(s.name)) + '' +
+ '' + escHtml(s.name) + '' + escHtml(s.domain) + '' +
+ '' +
'';
}).join('');
})
diff --git a/templates/portal/portal.njk b/templates/portal/portal.njk
index f7ab88b7..dcf61941 100644
--- a/templates/portal/portal.njk
+++ b/templates/portal/portal.njk
@@ -202,7 +202,7 @@
{% endif %}
diff --git a/tests/portal_redesign_layout.test.js b/tests/portal_redesign_layout.test.js
index b9d2da93..af3df65f 100644
--- a/tests/portal_redesign_layout.test.js
+++ b/tests/portal_redesign_layout.test.js
@@ -54,3 +54,9 @@ test('smarthome renders toggle tiles + sensor row (css + a11y switch role)', asy
const js = fs.readFileSync('public/js/portal.js', 'utf8');
assert.ok(js.includes('role="switch"') || js.includes("role='switch'"), 'smart-home toggle exposes role=switch');
});
+
+test('services render as compact launcher (svc-strip + svc styling in css)', async () => {
+ const res = await supertest(app).get('/css/portal.css').expect(200);
+ assert.ok(res.text.includes('.svc-strip'), '.svc-strip grid present');
+ assert.ok(res.text.includes('.svc{') || res.text.includes('.svc '), '.svc tile styling present');
+});
From f5d5748d28fa18a52424b81bae5c7bcdefb7d093 Mon Sep 17 00:00:00 2001
From: CallMeTechie <34693633+CallMeTechie@users.noreply.github.com>
Date: Fri, 3 Jul 2026 23:12:46 +0200
Subject: [PATCH 8/9] chore(portal): i18n parity + changelog for redesign
---
CHANGELOG.md | 7 +++++++
1 file changed, 7 insertions(+)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index ed3554da..dca0c04b 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,12 @@
# Changelog
+## [Unreleased]
+
+### Änderungen
+- Portal: vollbreites Dashboard-Layout (Klima & SmartHome oben, Pi-hole mit Donut, Dienste als kompakte Startleiste)
+
+---
+
## [1.116.0] — 2026-07-03
### Features
From 4989ee0471638db46071d74f44c633d1539a6c80 Mon Sep 17 00:00:00 2001
From: CallMeTechie <34693633+CallMeTechie@users.noreply.github.com>
Date: Fri, 3 Jul 2026 23:18:18 +0200
Subject: [PATCH 9/9] fix(portal): visible device fallback, mobile AC column,
switch aria-checked + label
---
public/css/portal.css | 2 +-
public/js/portal.js | 7 ++++---
templates/portal/portal.njk | 2 +-
3 files changed, 6 insertions(+), 5 deletions(-)
diff --git a/public/css/portal.css b/public/css/portal.css
index b6354e54..8720bede 100644
--- a/public/css/portal.css
+++ b/public/css/portal.css
@@ -260,7 +260,7 @@ body::before{
@media (max-width:620px){
.wrap{padding:16px 16px 46px}
- .ac-grid{grid-template-columns:1fr}
+ .c-midea .midea-list{grid-template-columns:1fr}
.sh-tiles{grid-template-columns:1fr}
.sh-sensors{grid-template-columns:1fr 1fr}
}
diff --git a/public/js/portal.js b/public/js/portal.js
index ae0fcfee..9179d16d 100644
--- a/public/js/portal.js
+++ b/public/js/portal.js
@@ -726,17 +726,18 @@
}
sub.textContent = shStatusText(st, caps);
el.classList.toggle('on', !!st.on);
- // a11y: toggle pill exposes role="switch" + aria-pressed reflecting on/off state (WAI-ARIA switch pattern)
+ // a11y: toggle pill exposes role="switch" + aria-checked reflecting on/off state (WAI-ARIA switch pattern)
var sw = document.createElement('span');
sw.className = 'toggle';
sw.setAttribute('role', 'switch');
sw.setAttribute('tabindex', '0');
- sw.setAttribute('aria-pressed', st.on ? 'true' : 'false');
+ sw.setAttribute('aria-checked', st.on ? 'true' : 'false');
+ sw.setAttribute('aria-label', d.name || '');
el.appendChild(sw);
var flip = function () {
var on = !el.classList.contains('on');
el.classList.toggle('on', on);
- sw.setAttribute('aria-pressed', on ? 'true' : 'false');
+ sw.setAttribute('aria-checked', on ? 'true' : 'false');
sub.textContent = shStatusText({ on: on, bri: st.bri }, caps);
shControl(d.id, { on: on }, el);
};
diff --git a/templates/portal/portal.njk b/templates/portal/portal.njk
index dcf61941..759c56fb 100644
--- a/templates/portal/portal.njk
+++ b/templates/portal/portal.njk
@@ -124,7 +124,7 @@
{{ t('portal.device.vpn_address') }}
{{ t('portal.device.dns') }}
{{ t('portal.device.last_handshake') }}
-
+
{% endif %}
' + escHtml(temp) + '' + escHtml(PT.mideaCurrent || '') + '
' +
- '' +
- '' + fan + extras +
+ '
';
}
diff --git a/src/i18n/de.json b/src/i18n/de.json
index 071c6928..9ba3515e 100644
--- a/src/i18n/de.json
+++ b/src/i18n/de.json
@@ -2195,6 +2195,8 @@
"portal.midea.turbo": "Turbo",
"portal.midea.eco": "Eco",
"portal.midea.outdoor": "Außen",
+ "portal.midea.warmer": "wärmer",
+ "portal.midea.cooler": "kälter",
"portal.smarthome.title": "Smart Home",
"portal.smarthome.power": "Ein/Aus",
"portal.smarthome.brightness": "Helligkeit",
diff --git a/src/i18n/en.json b/src/i18n/en.json
index 2eb4ab12..9c75fe84 100644
--- a/src/i18n/en.json
+++ b/src/i18n/en.json
@@ -2251,6 +2251,8 @@
"portal.midea.turbo": "Turbo",
"portal.midea.eco": "Eco",
"portal.midea.outdoor": "Outdoor",
+ "portal.midea.warmer": "warmer",
+ "portal.midea.cooler": "cooler",
"portal.smarthome.title": "Smart Home",
"portal.smarthome.power": "Power",
"portal.smarthome.brightness": "Brightness",
diff --git a/templates/portal/portal.njk b/templates/portal/portal.njk
index d89e8c27..168c9b31 100644
--- a/templates/portal/portal.njk
+++ b/templates/portal/portal.njk
@@ -58,6 +58,8 @@
mideaTurbo: t('portal.midea.turbo'),
mideaEco: t('portal.midea.eco'),
mideaOutdoor: t('portal.midea.outdoor'),
+ mideaWarmer: t('portal.midea.warmer'),
+ mideaCooler: t('portal.midea.cooler'),
smarthomePower: t('portal.smarthome.power'),
smarthomeBrightness: t('portal.smarthome.brightness'),
smarthomeActivate: t('portal.smarthome.activate'),
From 18ce23234a6e64a529db6737500c27bfad1d2508 Mon Sep 17 00:00:00 2001
From: CallMeTechie <34693633+CallMeTechie@users.noreply.github.com>
Date: Fri, 3 Jul 2026 23:03:12 +0200
Subject: [PATCH 6/9] feat(portal): smart-home toggle tiles + sensor row
Restyle the SmartHome widget markup to the mockup's .sh-tile/.toggle/
.sh-sensors/.sensor language (device toggles as role=switch pills,
sensors as a plain tile row) while keeping container IDs, data
fields, and the control/state endpoints unchanged.
---
public/css/portal.css | 32 +++++++++------
public/js/portal.js | 59 +++++++++++++++++++++-------
src/i18n/de.json | 4 +-
src/i18n/en.json | 4 +-
templates/portal/portal.njk | 8 ++--
tests/portal_redesign_layout.test.js | 10 +++++
6 files changed, 87 insertions(+), 30 deletions(-)
diff --git a/public/css/portal.css b/public/css/portal.css
index 681f295b..34f6fc77 100644
--- a/public/css/portal.css
+++ b/public/css/portal.css
@@ -255,12 +255,14 @@ body::before{
.devpill .ip{display:none}
/* Keep service tiles 2-up on narrow screens (thumb-friendly) */
.tiles{grid-template-columns:1fr 1fr}
+ .sh-sensors{grid-template-columns:repeat(4,1fr)}
}
@media (max-width:620px){
.wrap{padding:16px 16px 46px}
.ac-grid{grid-template-columns:1fr}
.sh-tiles{grid-template-columns:1fr}
+ .sh-sensors{grid-template-columns:1fr 1fr}
}
/* ============================================================
@@ -423,18 +425,26 @@ body::before{
/* ============================================================
SMART HOME WIDGET
============================================================ */
-.c-sh-list{display:grid;grid-template-columns:repeat(auto-fill,minmax(160px,1fr));gap:10px}
-.c-sh-card{background:var(--surface-2,#16212e);border:1px solid var(--line,rgba(255,255,255,.08));border-radius:12px;padding:12px}
-.c-sh-name{font-weight:600;font-size:14px;margin-bottom:10px}
-.c-sh-sw{border:1px solid var(--line-2,rgba(255,255,255,.14));border-radius:8px;padding:6px 12px;cursor:pointer;background:var(--surface-3,#1b2836);color:inherit}
-.c-sh-sw.on{background:linear-gradient(145deg,var(--green,#4ade80),#15924f);border-color:transparent;color:#fff}
-.c-sh-bri{width:100%;margin-top:10px}
+.sh-tiles{display:grid;grid-template-columns:1fr 1fr;gap:11px}
+.sh-tile{display:flex;flex-wrap:wrap;align-items:center;gap:12px;padding:13px;border:1px solid var(--line);border-radius:13px;background:var(--chip)}
+.sh-ic{width:38px;height:38px;border-radius:10px;display:grid;place-items:center;flex:0 0 auto;
+ background:var(--surface-3,rgba(255,255,255,.06));color:var(--muted)}
+.sh-ic svg{width:19px;height:19px}
+.sh-tile.on .sh-ic{background:rgba(245,196,81,.16);color:var(--amber)}
+.sh-tile .nm{flex:1;min-width:0}
+.sh-tile .nm b{display:block;font-size:13px;font-weight:600}
+.sh-tile .nm span{color:var(--faint);font-size:11.5px}
+.toggle{width:42px;height:24px;border-radius:999px;background:var(--track);position:relative;flex:0 0 auto;cursor:pointer;transition:.18s;border:0;padding:0}
+.toggle::after{content:"";position:absolute;top:3px;left:3px;width:18px;height:18px;border-radius:50%;background:#fff;transition:.18s}
+.sh-tile.on .toggle{background:var(--teal)}
+.sh-tile.on .toggle::after{left:21px}
+.sh-tile .c-sh-bri{flex:0 0 100%;margin:2px 0 0}
+.sh-tile .btn-sm{flex:0 0 auto}
+.sh-sensors{display:grid;grid-template-columns:repeat(4,1fr);gap:10px;margin-top:12px}
+.sensor{padding:11px;border:1px solid var(--line);border-radius:12px;background:var(--chip);text-align:center}
+.sensor .v{font-family:var(--font-mono);font-size:19px;font-weight:700}
+.sensor .l{color:var(--faint);font-size:11px;margin-top:3px}
.c-sh-msg{margin-top:10px;font-size:13px;color:var(--muted,#90a1b3)}
-.c-sh-sensors{margin-top:12px;display:grid;grid-template-columns:repeat(auto-fill,minmax(140px,1fr));gap:8px}
-.c-sh-sensor-head{grid-column:1/-1;font-size:12px;font-weight:600;color:var(--muted,#90a1b3);margin-top:6px}
-.c-sh-sensor-card{background:var(--surface-2,#16212e);border:1px solid var(--line,rgba(255,255,255,.08));border-radius:10px;padding:10px}
-.c-sh-sensor-name{font-size:13px;margin-bottom:4px}
-.c-sh-sensor-val{font-size:16px;font-weight:600}
/* ============================================================
REDUCED MOTION
diff --git a/public/js/portal.js b/public/js/portal.js
index 2f71aaa5..40a36713 100644
--- a/public/js/portal.js
+++ b/public/js/portal.js
@@ -705,22 +705,54 @@
var msg = document.getElementById('smarthomeMsg');
if (msg) { msg.style.display = 'block'; msg.textContent = PT.smarthomeLoginToControl || 'Login required'; }
}
+ // Static, hard-coded SVG per device kind — no user data interpolated, safe as innerHTML.
+ var SH_ICONS = {
+ light: '',
+ group: '',
+ plug: '',
+ switch: '',
+ scene: ''
+ };
+ function shStatusText(st, caps) {
+ var onLabel = st.on ? (PT.smarthomeOn || 'On') : (PT.smarthomeOff || 'Off');
+ if (st.on && caps.bri && st.bri != null) return onLabel + ' · ' + Math.round(Number(st.bri)) + ' %';
+ return onLabel;
+ }
function renderSmarthomeCard(d) {
- var el = document.createElement('div'); el.className = 'c-sh-card';
+ var el = document.createElement('div'); el.className = 'sh-tile';
var st = d.state || {}, caps = d.capabilities || {};
- var name = document.createElement('div'); name.className = 'c-sh-name'; name.textContent = d.name || ''; el.appendChild(name);
+ var ic = document.createElement('span'); ic.className = 'sh-ic'; ic.innerHTML = SH_ICONS[d.kind] || SH_ICONS.light; el.appendChild(ic);
+ var nm = document.createElement('div'); nm.className = 'nm';
+ var b = document.createElement('b'); b.textContent = d.name || ''; nm.appendChild(b);
+ var sub = document.createElement('span'); nm.appendChild(sub);
+ el.appendChild(nm);
if (d.kind === 'scene') {
- var b = document.createElement('button'); b.className = 'btn btn-sm'; b.textContent = PT.smarthomeActivate || 'Activate';
- b.addEventListener('click', function () { shControl(d.id, {}, el); });
- el.appendChild(b); return el;
+ var btn = document.createElement('button'); btn.className = 'btn btn-sm'; btn.textContent = PT.smarthomeActivate || 'Activate';
+ btn.addEventListener('click', function () { shControl(d.id, {}, el); });
+ el.appendChild(btn);
+ return el;
}
- var sw = document.createElement('button'); sw.className = 'c-sh-sw' + (st.on ? ' on' : '');
- sw.textContent = PT.smarthomePower || 'Power';
- sw.addEventListener('click', function () { var on = !sw.classList.contains('on'); sw.classList.toggle('on', on); shControl(d.id, { on: on }, el); });
+ sub.textContent = shStatusText(st, caps);
+ el.classList.toggle('on', !!st.on);
+ // a11y: toggle pill exposes role="switch" + aria-pressed reflecting on/off state (WAI-ARIA switch pattern)
+ var sw = document.createElement('span');
+ sw.className = 'toggle';
+ sw.setAttribute('role', 'switch');
+ sw.setAttribute('tabindex', '0');
+ sw.setAttribute('aria-pressed', st.on ? 'true' : 'false');
el.appendChild(sw);
+ var flip = function () {
+ var on = !el.classList.contains('on');
+ el.classList.toggle('on', on);
+ sw.setAttribute('aria-pressed', on ? 'true' : 'false');
+ sub.textContent = shStatusText({ on: on, bri: st.bri }, caps);
+ shControl(d.id, { on: on }, el);
+ };
+ sw.addEventListener('click', flip);
+ sw.addEventListener('keydown', function (ev) { if (ev.key === ' ' || ev.key === 'Enter') { ev.preventDefault(); flip(); } });
if (caps.bri) {
var range = document.createElement('input'); range.type = 'range'; range.min = 0; range.max = 100; range.value = (st.bri != null ? st.bri : 0); range.className = 'c-sh-bri';
- range.addEventListener('change', function () { shControl(d.id, { bri: Number(range.value) }, el); });
+ range.addEventListener('change', function () { st.bri = Number(range.value); sub.textContent = shStatusText({ on: el.classList.contains('on'), bri: st.bri }, caps); shControl(d.id, { bri: Number(range.value) }, el); });
el.appendChild(range);
}
return el;
@@ -740,10 +772,10 @@
}
}
function renderSensorCard(s) {
- var el = document.createElement('div'); el.className = 'c-sh-sensor-card';
+ var el = document.createElement('div'); el.className = 'sensor';
var st = s.state || {};
- var name = document.createElement('div'); name.className = 'c-sh-sensor-name'; name.textContent = s.name || ''; el.appendChild(name);
- var val = document.createElement('div'); val.className = 'c-sh-sensor-val'; val.textContent = formatSensor(st.type, st.value); el.appendChild(val);
+ var val = document.createElement('div'); val.className = 'v'; val.textContent = formatSensor(st.type, st.value); el.appendChild(val);
+ var lbl = document.createElement('div'); lbl.className = 'l'; lbl.textContent = s.name || ''; el.appendChild(lbl);
return el;
}
function hydrateSmarthome() {
@@ -759,9 +791,8 @@
if (sensorBox) {
sensorBox.innerHTML = '';
var sensors = (j && j.data && j.data.sensors) || [];
+ sensorBox.setAttribute('aria-label', PT.smarthomeSensors || 'Sensors');
if (sensors.length) {
- var head = document.createElement('div'); head.className = 'c-sh-sensor-head'; head.textContent = PT.smarthomeSensors || 'Sensors';
- sensorBox.appendChild(head);
sensors.forEach(function (s) { sensorBox.appendChild(renderSensorCard(s)); });
sensorBox.style.display = '';
} else {
diff --git a/src/i18n/de.json b/src/i18n/de.json
index 9ba3515e..b575d068 100644
--- a/src/i18n/de.json
+++ b/src/i18n/de.json
@@ -2208,5 +2208,7 @@
"portal.smarthome.motion": "Bewegung",
"portal.smarthome.no_motion": "keine Bewegung",
"portal.smarthome.wet": "nass",
- "portal.smarthome.dry": "trocken"
+ "portal.smarthome.dry": "trocken",
+ "portal.smarthome.on": "An",
+ "portal.smarthome.off": "Aus"
}
diff --git a/src/i18n/en.json b/src/i18n/en.json
index 9c75fe84..f0f9b99a 100644
--- a/src/i18n/en.json
+++ b/src/i18n/en.json
@@ -2264,5 +2264,7 @@
"portal.smarthome.motion": "Motion",
"portal.smarthome.no_motion": "No motion",
"portal.smarthome.wet": "Wet",
- "portal.smarthome.dry": "Dry"
+ "portal.smarthome.dry": "Dry",
+ "portal.smarthome.on": "On",
+ "portal.smarthome.off": "Off"
}
diff --git a/templates/portal/portal.njk b/templates/portal/portal.njk
index 168c9b31..f7ab88b7 100644
--- a/templates/portal/portal.njk
+++ b/templates/portal/portal.njk
@@ -70,7 +70,9 @@
smarthomeMotion: t('portal.smarthome.motion'),
smarthomeNoMotion: t('portal.smarthome.no_motion'),
smarthomeWet: t('portal.smarthome.wet'),
- smarthomeDry: t('portal.smarthome.dry')
+ smarthomeDry: t('portal.smarthome.dry'),
+ smarthomeOn: t('portal.smarthome.on'),
+ smarthomeOff: t('portal.smarthome.off')
} | dump | safe }}
@@ -142,8 +144,8 @@
' + escHtml(PT.mideaTarget || '') + '
' +
- '' + escHtml(tgtDisp) + '
' + escHtml(PT.mideaMode || '') + '
' + modeBtns + '
' +
+ '' +
+ '
' +
+ '' + escHtml(ringBig) + '°
' + escHtml(PT.mideaTarget || '') + '
' +
+ '
' +
'' + escHtml(PT.mideaCurrent || '') + '' + escHtml(temp) + '
' +
+ outdoor +
+ '' + escHtml(PT.mideaTarget || '') + '' +
+ '
' +
+ '' +
+ '' + escHtml(tgtDisp) + '' +
+ '
' + modeBtns + '
' +
'' + fan + extras + '
' +
'' + powerBtn + '
' + login +
'