
*{box-sizing:border-box}
:root{
 /* Brand-aligned slate palette — mirrors site/src/styles/heatsync.css
    so the docs site, captive portal and device UI all live in the
    same world. Tailwind slate scale. */
 --bg:#020617;                /* slate-950 — page */
 --surface:#0f172a;           /* slate-900 — secondary surface */
 --surface-2:#1e293b;         /* slate-800 — elevated card */
 --card:#0f172a;              /* default card */
 --border:#1e293b;
 --border-hi:#334155;         /* slate-700 */
 --text:#f8fafc;              /* slate-50 */
 --text-2:#cbd5e1;            /* slate-300 */
 --text-3:#94a3b8;            /* slate-400 — lifted from slate-500 for WCAG AA */
 /* Brand accent — cyan-400. Used for links, slider fill, tabs,
    segmented-control active, focus rings. */
 --accent:#22d3ee; --accent-2:#0891b2;
 --accent-soft:rgba(34,211,238,.12); --accent-dim:#155e75;
 /* Semantic warmth — only used as a state cue for "this is heating
    right now". Not the brand colour any more. */
 --hot:#fb923c; --hot-soft:rgba(251,146,60,.12); --hot-dim:#7c2d12;
 --ok:#4ade80; --ok-soft:rgba(74,222,128,.08);
 --warn:#fbbf24; --warn-soft:rgba(251,191,36,.08);
 --bad:#f87171; --bad-soft:rgba(248,113,113,.08);
 /* Cool differentiated from --accent (previously identical #22d3ee).
    Cyan-300 sits one rung cooler and lighter — enough that
    cool-mode UI reads as distinct from the brand accent's active
    states, small enough that any element mid-migration doesn't
    jump. Only user today is body[data-mode='cool'] .dial-arc. */
 --cool:#67e8f9;
 --r-sm:8px; --r:12px; --r-md:12px; --r-lg:14px; --r-full:999px;

 /* Spacing scale — 4 px base. Existing rules use raw rem/px values;
    new work should reach for these tokens so the vertical rhythm
    stays consistent as pages grow. Not migrated in this commit. */
 --space-1:4px; --space-2:8px; --space-3:12px; --space-4:16px;
 --space-5:24px; --space-6:32px;

 /* Type scale — six rungs from smallest chip text to hero number.
    Existing 25+ ad-hoc font-size values will migrate to these
    opportunistically. Ratio ~1.20 between rungs. */
 --fs-eyebrow:.72rem; --fs-caption:.82rem; --fs-body:.95rem;
 --fs-lead:1.05rem;   --fs-title:1.35rem;  --fs-hero:1.95rem;

 /* Elevation tokens — replaces the handful of hardcoded
    box-shadow strings scattered through cards, modals and the
    skip-link. Three rungs cover the design's actual elevation
    range; more felt like premature scale. */
 --elev-1:0 1px 2px rgba(0,0,0,.35);
 --elev-2:0 4px 12px rgba(0,0,0,.4);
 --elev-3:0 12px 40px rgba(0,0,0,.5);

 /* Chart palette — trends.cpp currently hardcodes Tailwind hexes
    inline. Extracted here so trends + any future chart page share
    a coherent 8-slot palette that survives theme changes. Slate-
    friendly hues, high enough contrast against --card that a
    legend chip on top of a card body is legible. */
 --chart-1:#22d3ee; --chart-2:#fb923c; --chart-3:#4ade80;
 --chart-4:#a78bfa; --chart-5:#fbbf24; --chart-6:#f472b6;
 --chart-7:#38bdf8; --chart-8:#facc15;

 --sans:'Geist',-apple-system,'Segoe UI',Roboto,sans-serif;
 --serif:'Fraunces','Newsreader',Georgia,serif;
 --mono:'Geist Mono',ui-monospace,Menlo,monospace;
}
html,body{margin:0;padding:0}
body{color:var(--text);font:15px/1.5 var(--sans);
 font-weight:400;min-height:100vh;-webkit-font-smoothing:antialiased;
 letter-spacing:-.005em;
 background:
  radial-gradient(at 80% -20%, var(--mode-tint,rgba(34,211,238,.06)) 0%, transparent 55%),
  radial-gradient(at 20% 110%, var(--mode-tint-2,transparent) 0%, transparent 50%),
  var(--bg);
 transition:background 1.5s ease}
/* Mode tints: heat → warm orange wash; cool → cyan wash; auto → both;
   idle → neutral. State signalling, not branding. */
body[data-mode='heat']{--mode-tint:rgba(251,146,60,.14);--mode-tint-2:rgba(251,146,60,.04)}
body[data-mode='cool']{--mode-tint:rgba(34,211,238,.12);--mode-tint-2:rgba(34,211,238,.04)}
body[data-mode='auto']{--mode-tint:rgba(251,146,60,.08);--mode-tint-2:rgba(34,211,238,.04)}
body[data-mode='idle']{--mode-tint:rgba(148,163,184,.04);--mode-tint-2:transparent}
.app{max-width:32rem;margin:0 auto;padding:0 1.1rem 3rem}
/* Simple masthead used by wizard / login / captive portal — single row,
   no tabs. The richer `.app-bar` below replaces it on authenticated pages. */
.brand{display:flex;align-items:center;gap:.65rem;padding:1.5rem 0 1.4rem;
 font-weight:500;letter-spacing:-.01em;font-size:.95rem;
 position:relative;z-index:60}
/* Inline brand mark — mirrors site/src/assets/heatsync-mark.svg.
   Slate rounded square + two cyan sync-wave curves + a centred dot.
   Single visual identity across docs, captive portal, device UI. */
.brand-mark{width:26px;height:26px;flex-shrink:0;border-radius:7px;
 background:#0f172a;display:inline-flex;align-items:center;justify-content:center;
 box-shadow:0 0 0 1px rgba(34,211,238,.12)}
.brand-mark svg{width:22px;height:22px;display:block}
.brand .brand-mark{margin-right:-.05rem}
.brand .name{color:var(--text)}
.brand .sub{color:var(--text-3);font-size:.82rem;margin-left:auto;font-weight:400;
 font-family:var(--mono);letter-spacing:0}

/* ─── App bar (authenticated pages) ───────────────────────────────────────
   Sticky two-row header. Row 1: brand-mark + identity subtitle (filled by
   JS from productCapaKw) + outdoor temp chip + overflow ⋯. Row 2: tabs
   with a coral underline on the active page. Backdrop-blurred warm
   gradient surface, breaks out of .app's horizontal padding to span the
   whole column. */
.app-bar{position:sticky;top:0;z-index:55;
 margin:0 -1.1rem 1.1rem;padding:.8rem 1.1rem .15rem;
 background:linear-gradient(180deg,rgba(15,23,42,.94) 0%,rgba(2,6,23,.82) 100%);
 backdrop-filter:saturate(160%) blur(14px);
 -webkit-backdrop-filter:saturate(160%) blur(14px);
 border-bottom:1px solid var(--border);
 box-shadow:0 1px 0 rgba(0,0,0,.4), 0 12px 24px -16px rgba(0,0,0,.5)}
.app-bar-row{display:flex;align-items:center;gap:.6rem;padding-bottom:.6rem}
.app-brand{display:flex;align-items:center;gap:.55rem;text-decoration:none;
 color:var(--text);flex:1;min-width:0}
.app-brand .brand-mark{width:24px;height:24px;border-radius:6px}
.app-brand .brand-mark svg{width:20px;height:20px}
.app-brand .brand-text{display:flex;flex-direction:column;line-height:1.15;min-width:0}
.app-brand .brand-name{font-weight:500;letter-spacing:-.01em;font-size:.96rem;color:var(--text)}
.app-brand .brand-sub{font-size:.74rem;color:var(--text-3);font-weight:500;
 margin-top:.12rem;letter-spacing:.01em;
 overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.app-bar-actions{display:flex;align-items:center;gap:.5rem;flex-shrink:0}
.app-outside[hidden]{display:none}
.app-brand .brand-sub[hidden]{display:none}
.app-outside{display:inline-flex;align-items:center;gap:.32rem;
 padding:.3rem .65rem .3rem .55rem;border-radius:var(--r-full);
 background:rgba(255,255,255,.035);border:1px solid var(--border);
 font-size:.82rem;color:var(--text);font-variant-numeric:tabular-nums;
 line-height:1}
.app-outside .ico{color:var(--text-3);width:.95em;height:.95em;flex-shrink:0;
 vertical-align:-.12em;display:inline-block}
/* .nav-more (the ⋯ overflow button) removed in v0.8.0 — everything
   that used to live in it now lives under /config/* subpages. */
.app-bar-tabs{display:flex;gap:1.4rem;font-size:.86rem}
.app-bar-tabs a{color:var(--text-3);text-decoration:none;
 padding:.4rem 0 .65rem;font-weight:500;letter-spacing:-.005em;
 border-bottom:2px solid transparent;
 transition:color .15s,border-color .15s}
.app-bar-tabs a:hover{color:var(--text-2)}
.app-bar-tabs a.active,.app-bar-tabs a[aria-current=page]{color:var(--text);border-bottom-color:var(--accent)}
/* Bottom navigation — hidden on desktop, replaces .app-bar-tabs on mobile.
   Four primary destinations (Home / Insights / Engineer / Settings) with
   icon + label. Overflow stays in the top-bar ⋯ menu. */
.bottom-nav{display:none}
@media (max-width:720px){
 .app-bar-tabs{display:none}
 .bottom-nav{display:flex;position:fixed;left:0;right:0;bottom:0;z-index:60;
  background:rgba(15,18,22,.92);backdrop-filter:blur(10px);
  border-top:1px solid var(--border);
  padding:.4rem .25rem calc(.4rem + env(safe-area-inset-bottom,0));
  justify-content:space-around;gap:.15rem}
 .bottom-nav a{flex:1;display:flex;flex-direction:column;align-items:center;
  gap:.18rem;padding:.35rem .25rem;border-radius:10px;
  text-decoration:none;color:var(--text-3);font-size:.7rem;font-weight:500;
  letter-spacing:-.005em;line-height:1;
  transition:color .15s,background .15s}
 .bottom-nav a .ico{width:1.35rem;height:1.35rem}
 .bottom-nav a:hover{color:var(--text-2)}
 .bottom-nav a.active,.bottom-nav a[aria-current=page]{color:var(--accent);background:rgba(34,211,238,.10)}
 .app{padding-bottom:calc(4.6rem + env(safe-area-inset-bottom,0))}
}
/* Engineer-view sub-tabs — sit inside the page below the app bar.
   Slimmer than the primary tabs, share the same active-underline cue. */
.subtabs{display:flex;gap:1.1rem;font-size:.8rem;margin:0 0 1.1rem;
 padding-bottom:.1rem;border-bottom:1px solid var(--border)}
.subtabs a{color:var(--text-3);text-decoration:none;padding:.35rem 0 .5rem;
 font-weight:500;border-bottom:2px solid transparent;margin-bottom:-1px;
 transition:color .15s,border-color .15s}
.subtabs a:hover{color:var(--text-2)}
.subtabs a.active,.subtabs a[aria-current=page]{color:var(--text);border-bottom-color:var(--accent)}

/* Skip-to-content link — first focusable element in <body>. Positioned
   off-screen; on :focus it slides back into view as a top-left pinned
   button. Meets WCAG 2.4.1 keyboard-navigation bypass. Only visible
   when tabbed to; sighted mouse users never see it. */
.skip-link{position:absolute;left:.5rem;top:-3rem;z-index:200;
 background:var(--accent);color:#0b1220;padding:.55rem .9rem;
 border-radius:var(--r);font-weight:600;text-decoration:none;
 font-size:.85rem;transition:top .12s ease-out;
 box-shadow:0 6px 18px rgba(0,0,0,.4)}
.skip-link:focus{top:.5rem;outline:2px solid var(--text);outline-offset:2px}
/* main landmark is a document-level container — no visual style needed;
   the pages inside carry their own padding via .app. Reset any default
   margin/padding the UA might apply and drop the focus outline (the
   tabindex=-1 is programmatic focus only, from the skip link). */
main#main-content{display:block;outline:none}
main#main-content:focus{outline:none}

/* One-line "why" banner at the top of the home page. Combines the
   current system action ("Heating to 21°") with the outdoor temp.
   The pulsing dot signals live data. */
.why-banner{display:flex;align-items:center;gap:.55rem;
 padding:.55rem .85rem;margin:0 0 1rem;
 background:rgba(34,211,238,.06);border:1px solid rgba(34,211,238,.18);
 border-radius:var(--r-md);
 font-size:.85rem;color:var(--text-2);letter-spacing:-.005em}
.why-banner[hidden]{display:none}
.why-banner .dot{width:7px;height:7px;border-radius:50%;background:var(--accent);
 box-shadow:0 0 0 3px rgba(34,211,238,.22);
 flex-shrink:0;animation:why-pulse 2.5s ease-in-out infinite}
@keyframes why-pulse{0%,100%{opacity:1}50%{opacity:.45}}

/* Trust line at the bottom of the home page. Quiet OK state by default,
   loud red when a fault is broadcasting. */
.trust-line{display:flex;align-items:center;gap:.55rem;
 padding:.7rem 1rem;margin:1rem 0 0;
 background:var(--surface);border:1px solid var(--border);
 border-radius:var(--r-md);
 font-size:.85rem;color:var(--text-2);letter-spacing:-.005em}
.trust-line[hidden]{display:none}
.trust-line.bad{background:var(--bad-soft);border-color:rgba(248,113,113,.3);
 color:var(--bad)}
.trust-line .trust-extra{margin-left:auto;color:var(--text-3);font-size:.78rem;
 font-variant-numeric:tabular-nums}

/* 7-day energy widget on the home page — bar chart of kWh consumed
   per day with a single avg-COP label. The bar's height is normalised
   to the busiest day so trend (not absolute scale) is what reads. */
.week-panel{padding:.95rem 1.1rem 1rem;margin-top:1rem;
 background:var(--card);border:1px solid var(--border);border-radius:var(--r-lg)}
.week-panel[hidden]{display:none}
.week-head{display:flex;align-items:baseline;justify-content:space-between;margin-bottom:.7rem}
.week-label{font-size:.72rem;font-weight:500;color:var(--text-3);
 text-transform:uppercase;letter-spacing:.1em}
.week-cop{font-size:.82rem;font-weight:500;color:var(--text-2);
 font-variant-numeric:tabular-nums}
.week-bars-wrap{position:relative;height:78px}
.week-weather{position:absolute;left:1rem;right:1rem;top:0;bottom:14px;
 width:calc(100% - 2rem);height:64px;pointer-events:none}
.week-bars{display:flex;align-items:flex-end;justify-content:space-between;
 gap:.4rem;height:78px;padding-bottom:.1rem;position:relative;z-index:1}
.week-bar{flex:1;display:flex;flex-direction:column;align-items:center;
 gap:.3rem;min-width:0}
.week-bar-fill{width:100%;max-width:18px;background:var(--accent-soft);
 border-radius:3px 3px 0 0;transition:height .4s ease}
.week-bar.today .week-bar-fill{background:var(--accent);
 box-shadow:0 0 8px rgba(34,211,238,.35)}
.week-bar-day{font-size:.68rem;color:var(--text-3);font-weight:500;
 font-family:var(--mono);line-height:1}
.week-bar.today .week-bar-day{color:var(--accent)}
.week-controls{display:flex;justify-content:space-between;gap:.5rem;margin-top:.85rem}
.week-toggle{display:inline-flex;gap:.18rem;background:var(--surface);
 border:1px solid var(--border);border-radius:var(--r-sm);padding:.18rem}
.week-toggle button{background:transparent;border:0;color:var(--text-3);font:inherit;
 font-size:.74rem;font-weight:500;padding:.22rem .55rem;border-radius:4px;cursor:pointer;
 transition:background .12s,color .12s}
.week-toggle button:hover:not(.active){color:var(--text-2)}
.week-toggle button.active{background:var(--accent);color:#042f3a;font-weight:600}

/* Away button in the why-banner. Subtle by default, deeper cyan when
   Away is active so the banner clearly signals "we're in setback". */
.away-btn{margin-left:auto;background:transparent;border:1px solid rgba(34,211,238,.3);
 color:var(--accent);font:inherit;font-size:.78rem;font-weight:500;
 padding:.22rem .65rem;border-radius:var(--r-full);cursor:pointer;
 transition:background .12s,border-color .12s,color .12s}
.away-btn:hover{background:rgba(34,211,238,.1)}
.why-banner.away{background:var(--accent-soft);border-color:rgba(34,211,238,.36)}
.why-banner.away .dot{background:var(--accent)}
.why-banner.away .away-btn{background:var(--accent);color:#042f3a;border-color:var(--accent)}

/* Boost button on detail pages. Sits below the slider. */
.boost-btn{margin-top:.85rem;width:100%;background:transparent;
 border:1px dashed rgba(34,211,238,.36);color:var(--accent);
 font:inherit;font-size:.86rem;font-weight:500;
 padding:.55rem 1rem;border-radius:var(--r-md);cursor:pointer;
 transition:background .12s,border-color .12s,color .12s}
.boost-btn:hover:not(:disabled){background:rgba(34,211,238,.08);border-style:solid}
.boost-btn.active{border-style:solid;background:rgba(34,211,238,.12);color:var(--text)}
.boost-btn:disabled{cursor:default}

/* Engineer-view fault rows. Compact list of recent WARN/ERROR events
   from the persistent event_log ring. */
.fault-row{display:flex;align-items:center;gap:.55rem;padding:.45rem 0;
 border-bottom:1px dashed var(--border);font-size:.84rem}
.fault-row:last-child{border-bottom:0}
.fault-row .chip{flex-shrink:0}
.fault-row .chip.bad{color:var(--bad);border-color:rgba(248,113,113,.35);
 background:rgba(248,113,113,.08)}
.fault-row .chip.warn{color:var(--warn);border-color:rgba(251,191,36,.35);
 background:rgba(251,191,36,.08)}
.fault-row .fault-msg{flex:1;color:var(--text-2);min-width:0;
 overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.fault-row .fault-when{color:var(--text-3);font-size:.75rem;font-variant-numeric:tabular-nums;
 flex-shrink:0}

/* Tariff multi-window editor (Settings → Tariffs). Three rows of
   (start, end) with a small clear-X to zero a row out (== disabled). */
.tariff-row{display:grid;grid-template-columns:auto 1fr 1fr auto;gap:.55rem;
 align-items:end;margin-bottom:.5rem}
.tariff-row .tariff-row-label{font-size:.74rem;color:var(--text-3);font-weight:500;
 padding-bottom:.55rem;letter-spacing:.02em;text-transform:uppercase}
.tariff-row label{font-size:.78rem;color:var(--text-2);margin:0;
 display:flex;flex-direction:column;gap:.2rem;font-weight:500}
.tariff-row input{margin:0}
.tariff-row .tariff-clear{background:transparent;border:1px solid var(--border);
 color:var(--text-3);width:36px;height:36px;border-radius:var(--r-sm);cursor:pointer;
 display:grid;place-items:center;transition:color .12s,border-color .12s}
.tariff-row .tariff-clear:hover{color:var(--bad);border-color:rgba(248,113,113,.35)}

/* Heating-schedule editor — block rows with day chips + time pickers. */
.sched-block{padding:.85rem .95rem;background:var(--surface);
 border:1px solid var(--border);border-radius:var(--r-md);margin-bottom:.55rem;
 transition:opacity .15s}
.sched-block.off{opacity:.5}
.sched-block-head{display:flex;align-items:center;justify-content:space-between;
 gap:.55rem;margin-bottom:.55rem}
.day-chips{display:flex;gap:.22rem}
.day-chip{background:transparent;border:1px solid var(--border);color:var(--text-3);
 font:inherit;font-size:.74rem;font-weight:600;width:26px;height:26px;
 border-radius:50%;cursor:pointer;display:grid;place-items:center;
 transition:background .12s,color .12s,border-color .12s}
.day-chip:hover{color:var(--text-2);border-color:var(--border-hi)}
.day-chip.on{background:var(--accent);color:#042f3a;border-color:var(--accent)}
.sched-block-row{display:flex;align-items:center;gap:.55rem;margin-bottom:.4rem;
 font-size:.84rem;color:var(--text-2)}
.sched-block-row:last-child{margin-bottom:0}
.sched-block-row .sched-label{color:var(--text-3);min-width:2.5rem;font-weight:500}
.sched-block-row input{margin:0;flex:1;max-width:6.5rem;padding:.4rem .55rem}
.sched-block-row input[type='number']{max-width:5rem}

/* Skeleton placeholders — shown until the first /api/live or /api/config
   returns. Keeps the page structurally complete from frame 1 so widgets
   don't pop in. .skel is an inline-block coloured shimmer roughly sized
   to the eventual value; callers set the width via style="width: 4em". */
.skel{display:inline-block;height:1em;min-width:2em;border-radius:4px;
 vertical-align:-.12em;
 background:linear-gradient(90deg, rgba(255,255,255,.04) 0%,
   rgba(255,255,255,.10) 50%, rgba(255,255,255,.04) 100%);
 background-size:200% 100%;animation:skel-shimmer 1.6s ease-in-out infinite;
 color:transparent}
.skel.lg{height:1.6em}
.skel.xl{height:2.1em}
/* Block-level skeleton variants — for placeholders that stand in
   for a whole row / card / chart, not just an inline text token.
   Same shimmer as the inline .skel, sized for larger regions.
   Callers apply via hs.skeletonize(container, msg) in
   UI_HELPERS_JS. */
.skel-row{display:block;height:1.4rem;border-radius:6px;margin:.4rem 0;
 background:linear-gradient(90deg, rgba(255,255,255,.04) 0%,
   rgba(255,255,255,.10) 50%, rgba(255,255,255,.04) 100%);
 background-size:200% 100%;animation:skel-shimmer 1.6s ease-in-out infinite}
.skel-card{display:block;height:5.6rem;border-radius:var(--r);margin:.6rem 0;
 background:linear-gradient(90deg, rgba(255,255,255,.04) 0%,
   rgba(255,255,255,.09) 50%, rgba(255,255,255,.04) 100%);
 background-size:200% 100%;animation:skel-shimmer 1.6s ease-in-out infinite}
.skel-chart{display:block;height:12rem;border-radius:var(--r);margin:.6rem 0;
 background:linear-gradient(90deg, rgba(255,255,255,.03) 0%,
   rgba(255,255,255,.07) 50%, rgba(255,255,255,.03) 100%);
 background-size:200% 100%;animation:skel-shimmer 1.8s ease-in-out infinite}
/* Screen-reader-only text — used by hs.skeletonize() to announce
   "Loading X" without visual noise. Also usable directly for any
   AT-only labels a page needs. */
.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;
 overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0}
@keyframes skel-shimmer{
 0%,100%{background-position:200% 0}
 50%    {background-position:-200% 0}
}
h1.hero{font-size:1.95rem;font-weight:600;letter-spacing:-.03em;
 margin:0 0 .25rem;line-height:1.1}
.hero-sub{color:var(--text-2);font-size:.95rem;margin:0 0 1.5rem;line-height:1.5}

/* ─── Heat-pump hero dial ──────────────────────────────────────────────── */
.dial-card{padding:1.4rem 1.15rem 1.2rem;position:relative;overflow:hidden}
.dial-card::before{content:'';position:absolute;inset:0;border-radius:inherit;
 background:linear-gradient(135deg,rgba(255,255,255,.03) 0%,transparent 35%);pointer-events:none}
.dial-head{display:flex;justify-content:space-between;align-items:center;margin-bottom:.25rem;font-size:.78rem;color:var(--text-2);font-weight:500}
.dial-head .addr{font-family:var(--mono);color:var(--text-3);font-weight:400}
.dial-wrap{position:relative;width:min(82vw,18rem);aspect-ratio:1;margin:.6rem auto 0}
.dial-svg{width:100%;height:100%;display:block;overflow:visible}
.dial-bg{stroke:rgba(255,255,255,.05);stroke-width:14;fill:none;stroke-linecap:round}
.dial-arc{stroke:var(--accent);stroke-width:14;fill:none;stroke-linecap:round;
 transition:stroke-dasharray 1.2s cubic-bezier(.22,1,.36,1), stroke .8s;
 filter:drop-shadow(0 0 12px rgba(34,211,238,.25))}
body[data-mode='cool'] .dial-arc{stroke:var(--cool);filter:drop-shadow(0 0 12px rgba(103,232,249,.22))}
body[data-mode='auto'] .dial-arc{stroke:var(--accent)}
body[data-mode='idle'] .dial-arc{stroke:var(--text-3);filter:none}
.dial-target-mark{fill:var(--text);stroke:var(--bg);stroke-width:3;
 transition:cx 1.2s cubic-bezier(.22,1,.36,1), cy 1.2s cubic-bezier(.22,1,.36,1)}
.dial-center{position:absolute;inset:0;display:flex;flex-direction:column;
 align-items:center;justify-content:center;text-align:center;pointer-events:none}
.dial-temp{font-size:clamp(2.8rem,12vw,4rem);font-weight:300;letter-spacing:-.06em;
 line-height:1;color:var(--text);font-variant-numeric:tabular-nums}
.dial-temp .deg{font-size:.55em;color:var(--text-2);vertical-align:.6em;margin-left:.05em}
.dial-target-text{margin-top:.5rem;font-size:.85rem;color:var(--text-2);font-variant-numeric:tabular-nums}
.dial-target-text .arrow{color:var(--text-3);margin:0 .3em}
.dial-modeline{margin-top:.85rem;display:flex;justify-content:center;gap:.45rem;flex-wrap:wrap}
.dial-row{margin-top:1.2rem;display:grid;grid-template-columns:1fr 1fr;gap:.65rem .9rem;font-size:.85rem;padding-top:1rem;border-top:1px solid var(--border)}
.dial-row .k{color:var(--text-2);font-weight:400}
.dial-row .v{color:var(--text);font-variant-numeric:tabular-nums;font-weight:500}

@keyframes valuePulse{0%{background:rgba(34,211,238,.22)}100%{background:transparent}}
.pulsed{animation:valuePulse 1.4s ease-out;border-radius:.4rem;
 padding:0 .25em;margin:0 -.25em}

@keyframes dialEntry{from{stroke-dashoffset:1000;opacity:.6}to{opacity:1}}
.dial-arc.entry{animation:dialEntry 1.4s ease-out}

/* ─── FSV detail (expandable explainers) ─────────────────────────────── */
.fsv-has-detail{cursor:pointer;transition:background .12s}
.fsv-has-detail:hover{background:rgba(255,255,255,.02)}
.fsv-detail{padding:.55rem 0 .85rem .25rem;
 border-bottom:1px dashed var(--border);
 border-left:2px solid var(--accent-dim);padding-left:.85rem;margin-left:.05rem}
.fsv-detail p{margin:.25rem 0;font-size:.85rem;line-height:1.5;color:var(--text-2)}
.fsv-detail p strong{color:var(--text);font-weight:500}
.fsv-caret{display:inline-block;transform-origin:center}

/* ─── Diagnostics page ────────────────────────────────────────────────── */
.diag-device{margin-bottom:2rem}
.diag-h2{font-family:var(--sans);font-weight:600;font-size:1.05rem;
 letter-spacing:-.02em;margin:.5rem 0 .75rem;color:var(--text);
 padding-left:.75rem;border-left:3px solid var(--accent);line-height:1.3}
.diag-h2 .addr{color:var(--text-3);font-family:var(--mono);font-size:.78rem;font-weight:400;margin-left:.5rem}
.diag-group{padding:.95rem 1.1rem;margin-bottom:.55rem}
.diag-group .card-title{margin-bottom:.55rem}
.diag-group.empty{opacity:.55}
.diag-row{display:grid;grid-template-columns:1fr auto;gap:1rem;
 padding:.35rem 0;border-bottom:1px dashed var(--border);font-size:.88rem;align-items:baseline}
.diag-row:last-child{border-bottom:0}
.diag-row .k{color:var(--text-2)}
.diag-row .v{color:var(--text);font-variant-numeric:tabular-nums;text-align:right;font-weight:500}
.diag-row .v .u{color:var(--text-3);font-weight:400;font-size:.85em;margin-left:.15em}

/* ─── Inline sparklines (history) ─────────────────────────────────────── */
.spark-wrap{margin-top:.85rem;padding-top:.6rem;border-top:1px solid var(--border)}
.spark{display:block;width:100%;height:32px;overflow:visible}
.spark path{transition:d .4s ease}
.spark-meta{display:flex;justify-content:space-between;margin-top:.2rem;
 font-size:.7rem;color:var(--text-3);font-family:var(--mono);letter-spacing:.02em}
.card{background:var(--card);border:1px solid var(--border);border-radius:var(--r-lg);
 padding:1.15rem 1.15rem 1rem;margin-bottom:.7rem}
.card-title{font-size:.78rem;font-weight:500;color:var(--text-2);
 margin:0 0 .75rem;display:flex;align-items:center;justify-content:space-between;gap:.5rem}
.stat{display:flex;align-items:baseline;gap:.45rem;margin:.1rem 0}
.stat .num{font-size:2.4rem;font-weight:600;letter-spacing:-.04em;line-height:1;
 color:var(--text);font-variant-numeric:tabular-nums}
.stat .unit{font-size:.92rem;color:var(--text-3);font-weight:400}
.stat .num.bad{color:var(--bad)} .stat .num.warn{color:var(--warn)} .stat .num.ok{color:var(--ok)}
.status{display:inline-flex;align-items:center;gap:.5rem;font-size:.82rem;
 color:var(--text-2);padding:.3rem .7rem .3rem .55rem;background:var(--surface);
 border:1px solid var(--border);border-radius:var(--r-full)}
.status .dot{width:7px;height:7px;border-radius:50%;flex-shrink:0}
.status .dot.ok{background:var(--ok);box-shadow:0 0 0 3px rgba(74,222,128,.15)}
.status .dot.warn{background:var(--warn);box-shadow:0 0 0 3px rgba(251,191,36,.15)}
.status .dot.bad{background:var(--bad);box-shadow:0 0 0 3px rgba(248,113,113,.15)}
.status .dot.idle{background:var(--text-3)}
.row{display:flex;justify-content:space-between;align-items:center;
 padding:.55rem 0;font-size:.9rem;border-bottom:1px solid var(--border)}
.row:last-child{border-bottom:0}
.row .label{color:var(--text-2)}
.row .value{color:var(--text);font-variant-numeric:tabular-nums}
.row .value.ok{color:var(--ok)} .row .value.warn{color:var(--warn)} .row .value.bad{color:var(--bad)}
.chips{display:flex;flex-wrap:wrap;gap:.35rem;margin-top:.6rem}
.chip{background:var(--surface);border:1px solid var(--border);border-radius:var(--r-sm);
 padding:.25rem .6rem;font-size:.8rem;font-family:var(--mono);color:var(--text)}
.chip.warn{color:var(--bad);border-color:rgba(248,113,113,.3);background:rgba(248,113,113,.08)}
.chip.ok{color:var(--ok);border-color:rgba(74,222,128,.3)}
.chip.muted{color:var(--text-3);background:transparent}
.btn{display:inline-flex;align-items:center;justify-content:center;gap:.4rem;
 padding:.78rem 1.25rem;background:var(--surface);border:1px solid var(--border);
 border-radius:var(--r);color:var(--text);font:inherit;font-weight:500;font-size:.92rem;
 cursor:pointer;transition:all .15s;-webkit-appearance:none}
.btn:hover:not(:disabled){border-color:var(--border-hi);background:var(--surface-2)}
.btn:disabled{opacity:.35;cursor:not-allowed}
.btn.primary{background:var(--accent);border-color:var(--accent);color:#042f3a}
.btn.primary:hover:not(:disabled){filter:brightness(1.08);background:var(--accent)}
.btn.ghost{background:transparent}
.btn.danger{color:var(--bad);border-color:rgba(248,113,113,.3)}
.btn.danger:hover:not(:disabled){background:rgba(248,113,113,.08);border-color:var(--bad)}
/* Compact variant — was 12+ inline `style='padding:.45rem 1.1rem;
   font-size:.85rem'` overrides on Save / Look up / Apply buttons.
   Extracted so downstream visual tweaks (e.g. touch-target work in
   a later weekend) can target one class instead of grepping every
   route. */
.btn.sm{padding:.45rem 1.1rem;font-size:.85rem}
/* Section eyebrow — the small ALL-CAPS label above a stat or card
   title ('Heat loss', 'Time constant', 'This month — Jul', etc.).
   Was ~15 sites of inline `font-size:.72-.74rem;color:var(--text-3);
   letter-spacing:.05em;text-transform:uppercase` overrides. Now
   .section-eyebrow. */
.section-eyebrow{font-size:var(--fs-eyebrow);color:var(--text-3);
 letter-spacing:.06em;text-transform:uppercase;font-weight:500}
.btn-row{display:flex;gap:.5rem;margin-top:1.1rem}
.btn-row .btn{flex:1}
.btn-row.right{justify-content:flex-end}
.btn-row.right .btn{flex:0 0 auto}
input,select{background:var(--surface);border:1px solid var(--border);border-radius:var(--r);
 padding:.78rem .95rem;color:var(--text);font:inherit;font-size:.95rem;width:100%;outline:none;
 transition:border-color .15s,box-shadow .15s;margin-top:.35rem;-webkit-appearance:none}
/* Restore native rendering for radios + checkboxes — the rule above is
   meant for text/number/select inputs but matches everything. Without
   this reset, `appearance:none` strips the browser's checked-state dot
   so accent-color (cyan) has nothing to paint, leaving the selected
   option looking identical to unselected ones. Spotted via Puppeteer:
   computed `appearance` was `none` on .ms-mode + .dhw-mode radios
   despite the scoped CSS only intending to tint via accent-color. */
input[type=radio],input[type=checkbox]{
 -webkit-appearance:auto;appearance:auto;
 width:1.1rem;height:1.1rem;padding:0;margin:0;background:transparent;
 border:0;border-radius:0;accent-color:var(--accent);cursor:pointer;
 flex-shrink:0}
input:focus,select:focus{border-color:var(--accent);box-shadow:0 0 0 3px var(--accent-soft)}
input::placeholder{color:var(--text-3)}
/* iOS Safari zooms the viewport (and doesn't zoom back out) whenever a
   focused input renders at <16px. .95rem × 16px root = 15.2px, which
   trips this. Bump text-shaped inputs to 16px on coarse-pointer devices
   so tapping any config form doesn't trap the user in a magnified
   viewport. Preserves desktop density where hover:hover is available. */
@media (hover:none) and (pointer:coarse){
 input,textarea,select{font-size:16px}
}
select{background:url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='10' height='6' viewBox='0 0 10 6'><path fill='%2394a3b8' d='M0 0l5 6 5-6z'/></svg>") no-repeat right .95rem center,var(--surface);padding-right:2.2rem}
label{display:block;font-size:.78rem;color:var(--text-2);font-weight:500;margin-top:.95rem}
.scan-list{display:flex;flex-direction:column;gap:.2rem;margin-top:.5rem;
 max-height:13.5rem;overflow:auto;-webkit-overflow-scrolling:touch}
.scan-item{display:flex;justify-content:space-between;align-items:center;width:100%;
 padding:.65rem .8rem;background:var(--surface);border:1px solid var(--border);
 border-radius:var(--r);cursor:pointer;font-size:.9rem;font-family:inherit;color:var(--text);
 transition:border-color .15s,background .15s;text-align:left}
.scan-item:hover{border-color:var(--border-hi)}
.scan-item.sel{border-color:var(--accent);background:rgba(34,211,238,.12)}
.scan-item .ssid-name{font-weight:500}
.scan-item .meta{color:var(--text-3);font-size:.78rem;display:flex;gap:.5rem;align-items:center}
.bars{display:inline-flex;gap:1.5px;align-items:end;height:13px}
.bars span{width:3px;background:var(--text-3);border-radius:1px;display:block}
.bars span:nth-child(1){height:4px}
.bars span:nth-child(2){height:7px}
.bars span:nth-child(3){height:10px}
.bars span:nth-child(4){height:13px}
.bars span.on{background:var(--text)}
.callout{border-radius:var(--r);padding:.7rem .85rem;font-size:.87rem;margin-top:.8rem;
 background:rgba(34,211,238,.12);border:1px solid rgba(34,211,238,.25);color:var(--accent)}
.callout.bad{background:rgba(248,113,113,.08);border-color:rgba(248,113,113,.25);color:var(--bad)}
.callout.ok{background:rgba(74,222,128,.08);border-color:rgba(74,222,128,.25);color:var(--ok)}
.callout.warn{background:rgba(251,191,36,.08);border-color:rgba(251,191,36,.25);color:var(--warn)}
.callout.muted{background:var(--surface);border-color:var(--border);color:var(--text-2)}
.callout a{color:inherit;text-decoration:underline;text-underline-offset:3px}
a{color:var(--accent);text-decoration:none}
a:hover{text-decoration:underline;text-underline-offset:3px}
/* Card-shaped links (today panel, install-link, hero-link) keep their
   styling and never get the inline underline-on-hover treatment. */
a.today-panel, a.today-panel:hover,
a.install-link, a.install-link:hover,
a.back-link, a.back-link:hover{text-decoration:none}
.divider{border:0;border-top:1px solid var(--border);margin:1.5rem 0}
/* Wizard step indicator. Each step is a pill that fills with cyan when
   active and gets a checkmark when done. Designed for first-touch — a
   small but visible "this is a real product" detail. */
.steps{display:flex;gap:.4rem;margin:0 0 1.5rem;font-size:.72rem;align-items:stretch;position:relative}
.steps .s{flex:1;padding:.45rem .55rem;background:var(--surface);border:1px solid var(--border);
 border-radius:var(--r-sm);color:var(--text-3);text-align:center;font-weight:500;
 letter-spacing:-.005em;transition:all .25s ease;
 display:flex;align-items:center;justify-content:center;gap:.32rem;position:relative}
.steps .s::before{content:'';width:6px;height:6px;border-radius:50%;background:var(--text-3);
 transition:background .25s,box-shadow .25s;flex-shrink:0}
.steps .s.active{background:rgba(34,211,238,.10);border-color:var(--accent);color:var(--text)}
.steps .s.active::before{background:var(--accent);box-shadow:0 0 0 3px rgba(34,211,238,.2)}
.steps .s.done{background:transparent;border-color:rgba(74,222,128,.3);color:var(--ok)}
.steps .s.done::before{background:var(--ok);box-shadow:0 0 0 3px rgba(74,222,128,.18)}
@keyframes fade-in{from{opacity:0;transform:translateY(8px)}to{opacity:1;transform:translateY(0)}}
.app > *{animation:fade-in .42s ease-out both}
.app > *:nth-child(1){animation-delay:0ms}
.app > *:nth-child(2){animation-delay:40ms}
.app > *:nth-child(3){animation-delay:90ms}
.app > *:nth-child(4){animation-delay:140ms}
.app > *:nth-child(5){animation-delay:190ms}
.app > *:nth-child(6){animation-delay:240ms}
@keyframes pulse{0%,100%{opacity:1}50%{opacity:.45}}
.pulsing{animation:pulse 1.6s infinite}
/* iOS / Material-3 style switch. Sized 52×32 with a 24×24 thumb so the
   switch alone clears WCAG 2.5.8 AA (24×24 minimum), and the parent
   label's text+gap brings the practical hit target above the AAA 44×44
   bar. State is communicated by BOTH colour (track tint) AND position
   (thumb left/right) AND icon (✓ inside the thumb when on) so colour-
   blind users have a non-colour cue per WCAG 1.4.1. The thumb's icon
   colour matches the accent so it's high-contrast against the white
   thumb fill. */
.toggle{display:inline-flex;align-items:center;gap:.6rem;cursor:pointer;user-select:none}
.toggle input{display:none}
.toggle .sw{width:52px;height:32px;background:var(--surface);border:1px solid var(--border);
 border-radius:16px;position:relative;transition:all .18s;flex-shrink:0}
.toggle .sw::after{content:"";position:absolute;top:3px;left:3px;width:24px;height:24px;
 background:var(--text-2);border-radius:50%;transition:all .18s;
 display:flex;align-items:center;justify-content:center;
 color:transparent;font-size:14px;font-weight:700;line-height:1}
.toggle input:checked + .sw{background:var(--accent);border-color:var(--accent)}
.toggle input:checked + .sw::after{left:24px;background:#fff;content:"\2713";color:var(--accent)}
/* Segmented control — slim default used outside heroes (settings, etc.) */
.seg{display:flex;gap:.25rem;background:var(--surface);border:1px solid var(--border);
 border-radius:var(--r-md);padding:.2rem;margin:.65rem 0 .8rem}
.seg button{flex:1;background:transparent;border:none;color:var(--text-2);
 padding:.42rem .35rem;border-radius:calc(var(--r-md) - .25rem);font:inherit;
 font-size:.83rem;font-weight:500;cursor:pointer;transition:all .15s;
 white-space:nowrap}
.seg button:hover:not(.active){background:rgba(255,255,255,.04);color:var(--text)}
.seg button.active{background:var(--accent);color:#042f3a;font-weight:600;
 box-shadow:0 1px 0 rgba(0,0,0,.25),inset 0 1px 0 rgba(255,255,255,.18)}
/* Inside heroes the picker is the second-most-interacted control after
   the slider — give it more breathing room, more rounded pills, and a
   subtle inner highlight on the active state. */
.hero-card .seg{background:rgba(255,255,255,.025);border-color:rgba(255,255,255,.06);
 padding:.28rem;border-radius:var(--r-full);gap:.18rem}
.hero-card .seg button{padding:.55rem .65rem;font-size:.84rem;
 border-radius:var(--r-full);letter-spacing:-.005em}
/* Hero card — SmartThings-inspired layout. Big state label as the
   headline, round power button, label/value sections for mode +
   temperature, slider with min/max range and ± buttons.
   Generous vertical breathing room throughout. */
.hero-card{padding:1.6rem 1.6rem 1.4rem;position:relative;overflow:hidden;
 transition:background .6s ease, border-color .6s ease}
/* Whole-card tap target. Cursor + a subtle border lift on hover signal
   it's interactive; click handler in web_server.cpp navigates to the
   per-hero detail page (skipping clicks on slider / power / etc). */
/* Whole-card tap target — cursor + chevron on hover signal it's
   interactive on desktop; on touch we keep the chevron always visible
   plus a faint hover ring that survives the brief touch state. */
.hero-card[data-hero-link]{cursor:pointer;-webkit-tap-highlight-color:rgba(34,211,238,.12)}
.hero-card[data-hero-link]:hover{border-color:var(--border-hi)}
.hero-card[data-hero-link]:active{background:linear-gradient(160deg,rgba(34,211,238,.04) 0%,transparent 60%),var(--card);
 transform:scale(.998);transition:transform .12s}
.hero-card[data-hero-link]:focus-visible{outline:2px solid var(--accent);outline-offset:2px}
.hero-chev{color:var(--text-3);align-self:flex-start;margin-top:.45rem;
 transition:transform .15s,color .15s;font-size:1rem;display:inline-flex;align-items:center}
.hero-card[data-hero-link]:hover .hero-chev{color:var(--accent);transform:translateX(3px)}
/* Modes card row — global heat-pump modes (quiet, away, etc.) live
   here in their own dashboard row, between the heroes and the today
   summary. Each row is label + one-line explanation on the left,
   .toggle switch on the right; same shape as a settings row but in
   card chrome so it reads as "actions you can take right now" rather
   than "config you'll come back to". */
.mode-row{display:flex;align-items:center;justify-content:space-between;
 gap:1rem;padding:.3rem 0}
.mode-row + .mode-row{border-top:1px solid var(--border);margin-top:.4rem;padding-top:.65rem}
.mode-row-text{flex:1;min-width:0}
.mode-row-label{font-size:.95rem;font-weight:500;
 display:inline-flex;align-items:center;gap:.55rem}
.mode-row-hint{color:var(--text-3);font-size:.78rem;margin-top:.2rem;line-height:1.4}

/* Footer "Details →" affordance — replaces the top-right .hero-chev
   so the header has room for an in-hero control (DHW power switch).
   The whole card is still the clickable target via data-hero-link;
   this is purely a visual affordance + hover cue. */
.hero-footer-link{display:flex;align-items:center;justify-content:flex-end;
 gap:.35rem;color:var(--text-3);font-size:.78rem;font-weight:500;
 margin-top:.85rem;transition:color .15s}
.hero-footer-link .ico{transition:transform .15s}
.hero-card[data-hero-link]:hover .hero-footer-link{color:var(--accent)}
.hero-card[data-hero-link]:hover .hero-footer-link .ico{transform:translateX(3px)}
/* Active-automation hint, sits below the slider. Picks up the room
   ambience colour when the hero is active. */
.hero-hint{margin-top:.8rem;padding:.5rem .75rem;border-radius:var(--r-sm);
 background:rgba(255,255,255,.04);color:var(--text-2);font-size:.78rem;
 display:flex;align-items:center;gap:.45rem;letter-spacing:-.005em}
.hero-card.active .hero-hint{background:rgba(251,146,60,.12);color:var(--text)}
/* Detail pages — chrome around a focused hero (heating, hot-water). */
.back-link{display:inline-flex;align-items:center;gap:.35rem;color:var(--text-3);
 font-size:.85rem;text-decoration:none;margin:.5rem 0 .85rem;font-weight:500;
 transition:color .12s}
.back-link:hover{color:var(--text)}
.detail-title{font-family:var(--sans);font-size:1.55rem;font-weight:600;
 letter-spacing:-.025em;margin:.2rem 0 .15rem;color:var(--text)}
.detail-sub{color:var(--text-2);font-size:1.05rem;margin:0 0 1.1rem;font-weight:500}
.install-link{display:flex;align-items:center;justify-content:space-between;
 gap:1rem;padding:.95rem 1.15rem;margin:1rem 0 0;
 background:var(--surface);border:1px solid var(--border);border-radius:var(--r-md);
 color:var(--text-2);font-size:.85rem;text-decoration:none;
 transition:border-color .15s,background .15s,color .15s}
.install-link:hover{border-color:var(--accent);color:var(--text);background:var(--surface-2)}
.install-link strong{color:var(--text);font-weight:500}
.install-link .ico{color:var(--text-3);flex-shrink:0}
/* Active heroes (device currently doing something) get a subtle warm
   tint baked into the slate base + a hot-orange radial overlay below.
   The "page room temperature" rises when the heat pump is actually
   heating, without compromising the cool slate brand identity. */
.hero-card.active{
 background:linear-gradient(158deg,rgba(251,146,60,.06) 0%,rgba(34,211,238,.02) 60%,transparent 100%),var(--card);
 border-color:#3a2c1f}
.hero-card.active::before{content:"";position:absolute;inset:0;pointer-events:none;
 background:radial-gradient(ellipse at top left,rgba(251,146,60,.16),transparent 60%);
 border-radius:inherit}

/* Headline row: small icon-label + giant state + round power button */
.hero-head{display:flex;align-items:flex-start;justify-content:space-between;
 gap:1rem;margin-bottom:1.4rem}
.hero-head-left{flex:1;min-width:0}
.hero-head-label{font-size:.78rem;font-weight:500;color:var(--text-3);
 text-transform:uppercase;letter-spacing:.08em;display:flex;align-items:center;
 gap:.4rem;margin-bottom:.35rem}
.hero-head-label .icon{font-size:.95rem;line-height:1}
.hero-state{font-size:1.7rem;font-weight:500;line-height:1.15;
 letter-spacing:-.015em;color:var(--text)}
.hero-now{color:var(--text-3);font-size:.92rem;font-weight:400;
 margin-top:.2rem;line-height:1.2}
/* Detail-page power button gets its own row above the slider so it
   doesn't dangle off the "SET TEMPERATURE" label. */
.detail-power-row{display:flex;align-items:center;justify-content:space-between;
 gap:.8rem;margin-bottom:.65rem}
.hero-state.muted{color:var(--text-3);font-weight:400}

/* Round power button (mimics SmartThings master on/off) */
.power-btn{width:48px;height:48px;border-radius:50%;border:none;cursor:pointer;
 display:grid;place-items:center;flex-shrink:0;
 background:var(--surface);color:var(--text-2);
 font-size:1.2rem;transition:background .15s,color .15s,transform .08s}
.power-btn:hover{background:rgba(255,255,255,.08);color:var(--text)}
.power-btn:active{transform:scale(.96)}
.power-btn.on{background:#f8fafc;color:#0f172a;
 box-shadow:0 0 0 5px rgba(251,146,60,.20), 0 2px 10px rgba(0,0,0,.3)}
.power-btn.on:hover{background:#fff;
 box-shadow:0 0 0 6px rgba(251,146,60,.26), 0 2px 14px rgba(0,0,0,.35)}
.power-btn:disabled{opacity:.4;cursor:not-allowed}

/* Card sections with label/value pattern */
.hero-section{margin-bottom:1.25rem}
.hero-section:last-child{margin-bottom:0}
.hero-section-label{font-size:.78rem;font-weight:500;color:var(--text-3);
 text-transform:uppercase;letter-spacing:.06em;margin-bottom:.35rem}
.hero-section-value{font-size:1.15rem;font-weight:500;color:var(--text);
 margin-bottom:.55rem}
/* The one editorial moment in the UI — set temperature gets the serif
   display face. Single eccentric beat against the otherwise SaaS-clean
   Geist; everywhere else stays sans. */
.hero-section-value-big{font-family:var(--serif);font-size:2.2rem;font-weight:400;
 color:var(--text);margin-bottom:.55rem;font-variant-numeric:tabular-nums;
 letter-spacing:-.02em;line-height:1;font-optical-sizing:auto}
.hero-section-value-big .unit{font-family:var(--sans);font-size:.6em;
 font-weight:400;color:var(--text-2);margin-left:.18em;letter-spacing:-.01em}

/* Slider row — min label, slider, max label, ± buttons either side */
.slider-row{display:flex;align-items:center;gap:.6rem}
.slider-row .slider-btn{width:34px;height:34px;border-radius:50%;border:none;
 background:rgba(255,255,255,.06);color:var(--text);cursor:pointer;
 font-size:1rem;font-weight:600;line-height:1;display:grid;place-items:center;
 transition:background .12s;flex-shrink:0}
.slider-row .slider-btn:hover:not(:disabled){background:rgba(255,255,255,.12)}
.slider-row .slider-btn:disabled{opacity:.3;cursor:not-allowed}
.slider-row .slider-range{flex:1;display:flex;flex-direction:column;gap:.2rem}
.slider-row .slider-labels{display:flex;justify-content:space-between;
 font-size:.72rem;color:var(--text-3);font-variant-numeric:tabular-nums;
 padding:0 .15rem}
.slider-row input[type=range]{width:100%;height:6px;background:transparent;
 -webkit-appearance:none;appearance:none;cursor:pointer}
.slider-row input[type=range]::-webkit-slider-runnable-track{
 height:6px;border-radius:3px;
 background:linear-gradient(to right,var(--accent) var(--range-pct,50%),
                            rgba(255,255,255,.10) var(--range-pct,50%))}
.slider-row input[type=range]::-moz-range-track{
 height:6px;border-radius:3px;background:rgba(255,255,255,.10)}
.slider-row input[type=range]::-moz-range-progress{
 height:6px;border-radius:3px;background:var(--accent)}
.slider-row input[type=range]::-webkit-slider-thumb{
 -webkit-appearance:none;appearance:none;width:18px;height:18px;border-radius:50%;
 background:#fff;border:2px solid var(--accent);margin-top:-6px;cursor:grab;
 box-shadow:0 1px 4px rgba(0,0,0,.3)}
.slider-row input[type=range]::-moz-range-thumb{
 width:18px;height:18px;border-radius:50%;background:#fff;border:2px solid var(--accent);
 cursor:grab;box-shadow:0 1px 4px rgba(0,0,0,.3)}

/* Footer line for schedule + secondary chips. .settings-link sits on
   the right of the row via margin-left:auto — opens the per-hero
   modal (DHW or heating advanced settings). */
.hero-footer{margin-top:.6rem;padding-top:.85rem;border-top:1px solid var(--border);
 font-size:.78rem;color:var(--text-3);display:flex;align-items:center;
 gap:.6rem;flex-wrap:wrap}
.hero-footer .pill{padding:.18rem .55rem;border-radius:.6rem;
 background:rgba(255,255,255,.05);color:var(--text-2);font-weight:500;
 display:inline-flex;align-items:center;gap:.3rem}
.settings-link{margin-left:auto;background:transparent;border:0;cursor:pointer;
 color:var(--text-3);font:inherit;font-size:.78rem;font-weight:500;
 padding:.25rem .5rem;border-radius:var(--r-sm);display:inline-flex;
 align-items:center;gap:.32rem;transition:color .12s,background .12s}
.settings-link:hover{color:var(--text);background:rgba(255,255,255,.05)}

/* Inline mode picker — used inside the Mode section */
.hero-card .seg{margin:0}

/* "Today" panel — sits below the device heroes. Three glanceable
   numbers (cost / kWh / SCOP) in the editorial serif, small caption
   above each. Whole panel is a link into /diagnostics. */
.today-panel{display:block;padding:1.05rem 1.15rem 1.15rem;margin-top:1rem;
 background:var(--card);border:1px solid var(--border);border-radius:var(--r-lg);
 text-decoration:none;color:inherit;
 transition:border-color .15s,background .15s}
.today-panel:hover{border-color:var(--border-hi);background:var(--surface-2)}
.today-panel .today-head{display:flex;align-items:center;justify-content:space-between;
 margin-bottom:.75rem}
.today-panel .today-head-label{font-size:.72rem;font-weight:500;color:var(--text-3);
 text-transform:uppercase;letter-spacing:.1em}
.today-panel .today-head-arrow{color:var(--text-3);font-size:1rem;transition:color .15s,transform .2s}
.today-panel:hover .today-head-arrow{color:var(--accent);transform:translateX(2px)}
.today-panel .today-cells{display:grid;grid-template-columns:repeat(3,1fr);gap:.75rem}
.today-panel .today-cell{display:flex;flex-direction:column;gap:.18rem;min-width:0}
.today-panel .today-cell-label{font-size:.7rem;color:var(--text-3);font-weight:500;
 letter-spacing:.02em}
.today-panel .today-cell-val{font-family:var(--serif);font-size:1.7rem;font-weight:400;
 color:var(--text);line-height:1;letter-spacing:-.02em;font-variant-numeric:tabular-nums;
 font-optical-sizing:auto;
 white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.today-panel .today-cell-unit{font-family:var(--sans);font-size:.55em;font-weight:400;
 color:var(--text-3);margin-left:.22em;letter-spacing:-.005em}
/* Breakdown bar — flex segments coloured per energy bucket. Sits
   below the three big numbers; widths are proportional to kWh used. */
.today-bar{display:flex;height:6px;border-radius:3px;overflow:hidden;
 margin-top:.95rem;background:rgba(255,255,255,.04)}
.today-bar > span{display:block;height:100%;transition:flex .35s ease}
.today-legend{display:flex;flex-wrap:wrap;gap:.15rem .85rem;margin-top:.55rem;
 font-size:.74rem;color:var(--text-3)}
.today-leg{display:inline-flex;align-items:center;gap:.32rem;font-variant-numeric:tabular-nums}
.today-leg .dot{width:7px;height:7px;border-radius:50%;flex-shrink:0}

/* Inline ± stepper (used for DHW target on the home page) */
.step{display:inline-flex;align-items:center;gap:.2rem;background:var(--surface);
 border:1px solid var(--border);border-radius:var(--r-md);padding:.15rem;
 flex-shrink:0}
.step button{width:1.75rem;height:1.75rem;background:transparent;border:none;
 color:var(--text);font-size:1.05rem;font-weight:600;line-height:1;
 cursor:pointer;padding:0;border-radius:calc(var(--r-md) - .2rem);
 transition:background .12s}
.step button:hover:not(:disabled){background:rgba(255,255,255,.06)}
.step button:disabled{opacity:.32;cursor:not-allowed}
.step-val{display:inline-block;padding:0 .45rem;font-size:.92rem;font-weight:500;
 font-variant-numeric:tabular-nums;min-width:3.6rem;text-align:center;
 color:var(--text);line-height:1.75rem;white-space:nowrap}
/* Modal overlay (used by the DHW settings popup on the dashboard) */
.modal{position:fixed;inset:0;z-index:80;display:grid;place-items:center;
 padding:1.25rem;animation:modal-in .2s ease-out}
.modal[hidden]{display:none}
.modal-backdrop{position:absolute;inset:0;background:rgba(2,6,18,.62);
 backdrop-filter:blur(6px);-webkit-backdrop-filter:blur(6px)}
.modal-card{position:relative;background:var(--card);border:1px solid var(--border);
 border-radius:var(--r-lg);padding:1.4rem 1.5rem 1.25rem;width:min(94vw,28rem);
 max-height:88vh;overflow-y:auto;box-shadow:0 24px 64px -16px rgba(0,0,0,.55)}
.modal-head{display:flex;align-items:center;justify-content:space-between;margin-bottom:.85rem}
.modal-head h3{margin:0;font-size:1.02rem;font-weight:600;letter-spacing:-.005em}
.modal-close{background:transparent;border:none;color:var(--text-2);
 font-size:1.25rem;cursor:pointer;padding:.2rem .4rem;border-radius:6px}
.modal-close:hover{background:rgba(255,255,255,.06);color:var(--text)}
.modal-body{display:flex;flex-direction:column;gap:.75rem}
.modal-foot{margin-top:1.15rem;display:flex;justify-content:flex-end;gap:.5rem}
@keyframes modal-in{from{opacity:0;transform:translateY(8px)}to{opacity:1;transform:none}}
/* Small icon button used inline in card titles (settings cog etc.) */
.icon-btn{background:transparent;border:none;color:var(--text-3);cursor:pointer;
 padding:.25rem .35rem;border-radius:6px;font-size:1rem;line-height:1;
 display:inline-flex;align-items:center;justify-content:center;
 transition:color .12s,background .12s}
.icon-btn:hover{color:var(--text);background:rgba(255,255,255,.06)}

/* ─── Lucide icon system ──────────────────────────────────────────────────
   All UI icons are Lucide SVG paths baked into a single sprite at
   /icons.svg (see ICON_SPRITE_SVG). Use sites just reference the symbol
   by id, e.g. `<svg class='ico'><use href='/icons.svg#flame'/></svg>`.
   .ico sets size + stroke style; the actual paths inherit currentColor
   via CSS so they tint with the surrounding text. */
.ico{width:1em;height:1em;display:inline-block;vertical-align:-.14em;
 flex-shrink:0;fill:none;stroke:currentColor;stroke-width:2;
 stroke-linecap:round;stroke-linejoin:round;overflow:visible}
.ico-lg{width:1.4em;height:1.4em}
.ico-xl{width:1.8em;height:1.8em}

/* ─── Toast notifications ───────────────────────────────────────────
   Replaces alert() across the device UI. Host element is injected
   into <body> on first toast() call; toasts stack bottom-up so the
   newest sits closest to the user's thumb on mobile. Tap any toast
   to dismiss early; otherwise auto-dismiss (4 s default, 6 s for
   error type). Implementation in toast() in UI_HEAD. */
#hs-toasts{position:fixed;left:0;right:0;
 bottom:calc(1.1rem + env(safe-area-inset-bottom,0));
 display:flex;flex-direction:column-reverse;align-items:center;
 gap:.45rem;padding:0 1rem;pointer-events:none;z-index:9999}
/* Mobile: clear the fixed bottom nav (4.6rem on <=720px viewports). */
@media (max-width:720px){
 #hs-toasts{bottom:calc(5.4rem + env(safe-area-inset-bottom,0))}
}
.hs-toast{pointer-events:auto;max-width:min(34rem,92vw);
 background:rgba(15,23,42,.97);color:var(--text);
 border:1px solid var(--border);border-left:3px solid var(--text-3);
 padding:.65rem .95rem;border-radius:8px;
 font-size:.9rem;line-height:1.4;
 box-shadow:0 10px 30px rgba(0,0,0,.45);
 cursor:pointer;
 animation:hs-toast-in .18s ease-out}
.hs-toast.error{border-left-color:var(--bad)}
.hs-toast.success{border-left-color:var(--ok)}
.hs-toast.info{border-left-color:var(--accent)}
.hs-toast.leaving{animation:hs-toast-out .22s ease-in forwards}
@keyframes hs-toast-in{from{opacity:0;transform:translateY(8px)}
 to{opacity:1;transform:translateY(0)}}
@keyframes hs-toast-out{to{opacity:0;transform:translateY(8px)}}

/* ─── Interactive line charts ───────────────────────────────────────
   Any <svg class='chart' data-chart-points='[[x,"label"],...]'>
   becomes tap-and-drag interactive: dots highlight on the nearest
   point, a vertical guide tracks the cursor, and a fixed tooltip
   pill above the chart shows the bound label. Wiring lives in
   UI_CHART_INTERACT inside UI_HEAD. Permanent faint dots at every
   data point telegraph "this line is tappable" — they brighten
   when active. Mobile-essential: the old <rect><title> hovers
   never fired on touch. */
svg.chart{cursor:crosshair;touch-action:pan-y}
svg.chart .chart-dot{fill:currentColor;opacity:.45;
 transition:opacity .12s}
svg.chart .chart-dot.active{opacity:1}
svg.chart .chart-guide{stroke:var(--text-3);stroke-width:.6;
 stroke-dasharray:2 2;opacity:0;pointer-events:none;
 transition:opacity .12s}
svg.chart.active .chart-guide{opacity:.55}
#hs-chart-tip{position:fixed;background:rgba(15,23,42,.97);
 color:var(--text);border:1px solid var(--border);
 padding:.35rem .6rem;border-radius:6px;
 font-size:.78rem;line-height:1.3;white-space:nowrap;
 pointer-events:none;box-shadow:0 4px 14px rgba(0,0,0,.4);
 z-index:9998;transform:translate(-50%,calc(-100% - 8px));
 opacity:0;transition:opacity .12s,transform .12s}
#hs-chart-tip.visible{opacity:1;transform:translate(-50%,calc(-100% - 4px))}

/* ─── Connection-loss banner ────────────────────────────────────────
   Slim red-tinted banner that appears below the app bar when the
   page hasn't had a successful /api/* response in >10 s. Lets the
   user know their data may be stale rather than silently freezing
   the dashboard. Behaviour in UI_CONNECTION (UI_HEAD). */
#hs-conn-banner{display:none;align-items:center;gap:.55rem;
 padding:.5rem .9rem;font-size:.82rem;color:var(--text);
 background:linear-gradient(180deg,rgba(248,113,113,.10),rgba(248,113,113,.03));
 border-bottom:1px solid rgba(248,113,113,.25)}
#hs-conn-banner.visible{display:flex}
/* Escalated state — 60 s+ of continuous failure OR browser offline.
   Louder gradient + solid border + darker text so the user notices
   this isn't just a routine 1-2 s blip anymore. */
#hs-conn-banner.warn{
 background:linear-gradient(180deg,rgba(248,113,113,.24),rgba(248,113,113,.10));
 border-bottom:1px solid rgba(248,113,113,.55);
 font-weight:500}
#hs-conn-banner .dot{width:.5rem;height:.5rem;border-radius:50%;
 background:var(--bad);flex-shrink:0;
 animation:hs-conn-pulse 1.2s ease-in-out infinite}
@keyframes hs-conn-pulse{50%{opacity:.35}}

/* ─── Landscape / tablet layout ─────────────────────────────────────
   The phone-first .app column is too narrow on iPads, landscape
   phones, and any desktop browser — wastes 80% of horizontal space.
   At >=900px the column widens to ~58rem and the dashboard's two
   hero cards (DHW + heating) sit side by side via .hero-row. Other
   stacked content (faults, today's summary, perf panel, install
   banners) stays full-width so it doesn't look stretched. */
@media (min-width:900px){
 .app{max-width:58rem}
 .hero-row{display:grid;grid-template-columns:1fr 1fr;gap:1rem;
  margin-bottom:1rem}
 .hero-row .hero-card{margin-bottom:0}
}
@media (max-width:899px){
 /* Phone + small-tablet: stack hero cards as before. No-op
    selector here documents the breakpoint; default styling
    handles the stack. */
 .hero-row{display:block}
}

/* Section header label — small uppercase divider between card
   groups. Used on the /config landing page (Configuration /
   Maintenance / Engineer tools). Originally inline on the landing
   page; promoted here so reusing it on other pages doesn't
   duplicate bytes per-PROGMEM-blob. */
.set-section{margin-block:1.2rem .4rem;color:var(--text-3);font-size:.72rem;
 letter-spacing:.08em;text-transform:uppercase;padding-left:.25rem}
.set-section:first-child{margin-block-start:.4rem}

/* ─── Modal primitive ────────────────────────────────────────────
   Backdrop + centered card with role=dialog aria-modal=true.
   Focus trap + Escape close + focus restoration are wired by
   hs.modal() in UI_HELPERS_JS. Body gets .modal-open class while
   the modal is up to disable page scroll. */
.modal-backdrop{position:fixed;inset:0;z-index:200;
 background:rgba(2,6,23,.72);backdrop-filter:blur(4px);
 display:flex;align-items:center;justify-content:center;
 padding:1rem;animation:hs-modal-fade-in .18s ease-out}
.modal{background:var(--card);border:1px solid var(--border-hi);
 border-radius:var(--r-lg);padding:1.1rem 1.15rem;
 max-width:min(28rem,94vw);width:100%;
 box-shadow:var(--elev-3);
 animation:hs-modal-pop-in .18s ease-out}
.modal-title{font-size:1.05rem;font-weight:600;color:var(--text);
 margin:0 0 .5rem;line-height:1.3}
.modal-body{color:var(--text-2);font-size:.9rem;line-height:1.5;
 margin:0 0 1.1rem}
.modal-actions{display:flex;gap:.55rem;justify-content:flex-end;
 flex-wrap:wrap}
.modal-actions .btn{flex:0 0 auto}
@keyframes hs-modal-fade-in{from{opacity:0}to{opacity:1}}
@keyframes hs-modal-pop-in{from{opacity:0;transform:scale(.96) translateY(4px)}
 to{opacity:1;transform:none}}
body.modal-open{overflow:hidden}

/* ─── Reduced-motion accessibility ──────────────────────────────────
   Honour the user's OS-level "reduce motion" preference. Disables
   the toast slide, chart tooltip fade, connection-banner pulse,
   chart-dot opacity transition, and any future animation/transition
   added under this stylesheet. Vestibular-sensitive users get a
   still UI; everyone else gets the polish. Canonical override
   pattern — single block at the end so it wins on specificity. */
@media (prefers-reduced-motion:reduce){
 *,*::before,*::after{
  animation-duration:0.01ms !important;
  animation-iteration-count:1 !important;
  transition-duration:0.01ms !important;
  scroll-behavior:auto !important;
 }
}
