diff --git a/CLAUDE.md b/CLAUDE.md index 25ce703..d3e8212 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -42,6 +42,17 @@ the user's Unraid home server. Server-side project directory is still named `SCRIPT_NAME` subpath. Server-rendered config (timezone, script root) reaches JS through `
`, never string-interpolated into a script block. + - **No `style="..."` attributes for anything static.** A one-off tweak + (button height, a margin, a font size) belongs in the matching + `static/css/*.css` file, scoped with a structural selector (an + adjacent-sibling or `:last-child` rule tied to the surrounding markup) + or a small modifier class — not inline on the element. The only + legitimate inline `style=` left in `dashboard.html` are per-request + computed values Jinja has to interpolate (a meter's `width: {{ pct + }}%`, a colour chosen by threshold) and JS-toggled initial visibility + (`display:none` on something `dashboard.js` reveals) — those can't + live in a static stylesheet. Everything else should read as a class + name, not a value. - **`static/css/base.css`** — the single source of truth for the design tokens, shared by all three templates: surfaces, a 6-step `--fs-*` type scale, semantic colours, and `--tap: 44px` for minimum touch targets. @@ -56,6 +67,28 @@ the user's Unraid home server. Server-side project directory is still named tiers in `app.py`: a "Right now" strip (live streams, download queue, array usage) sits above the fold, with library, discovery, requests and watch insights below it. + - **Cards in a row must earn their height — never leave dead space.** A + `.row` is a CSS grid, so every card stretches to the tallest one in that + row. A card whose content outgrows its neighbours doesn't just get tall, + it hollows out everything beside it. This has bitten once already: the + per-disk Storage list stacked ten disks at `--row-h` each, turning one + third of the "Right now" strip into a ~950px tower and leaving ~1500px of + empty card across Now playing and Downloads. + - Before adding a repeating list to a card in a multi-column row, check + what the row's other cards hold at their **quietest** — one stream, an + idle queue, zero pending requests — and keep the tallest card within + roughly 1.5× of that. + - When the content genuinely won't fit, the fix is **more width**: give + the card its own full-width row (`grid-template-columns: 1fr`) and lay + its list out in columns inside, or use more columns within the existing + card. Ten items down one narrow column is three tidy rows of tiles once + the card spans the page. + - The fix is **never** an inner scroll area, a truncated list, or content + hidden behind a toggle the user has to find — see the "nothing clipped + or hidden" rule the header pills already follow. + - Where a short card still can't be avoided, centre its content + vertically rather than reaching for `align-items: start`; equal-height + cards are a settled preference. - Degraded services render through the `service_error()` macro, which pairs `friendly_error` (human cause) with the raw exception behind a disclosure, passed through `redact_secrets` — SABnzbd and Tautulli put diff --git a/static/css/dashboard.css b/static/css/dashboard.css index 88d82b6..94925e4 100644 --- a/static/css/dashboard.css +++ b/static/css/dashboard.css @@ -131,6 +131,9 @@ main { } .row.cols-3 { grid-template-columns: repeat(3, 1fr); } .row.cols-2 { grid-template-columns: repeat(2, 1fr); } +/* A section with two stacked rows (Watch insights) needs the same gap + between them as the grid uses between columns. */ +.row + .row { margin-top: var(--gap); } /* ── Card ───────────────────────────────────────────── */ .card { @@ -193,13 +196,13 @@ main { border-top: 1px solid var(--border); } .stream:first-child { border-top: none; padding-top: 0; } -.stream-icon { font-size: var(--fs-base); flex-shrink: 0; } +.stream-icon { font-size: var(--fs-lg); flex-shrink: 0; } .stream-info { min-width: 0; flex: 1; } .stream-title { - font-size: var(--fs-sm); font-weight: 600; + font-size: var(--fs-base); font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } -.stream-user { font-size: var(--fs-xs); color: var(--text-3); } +.stream-user { font-size: var(--fs-sm); color: var(--text-3); } .live-dot { width: 7px; height: 7px; border-radius: 50%; background: var(--ok); flex-shrink: 0; @@ -242,6 +245,10 @@ dl.stats { margin: 0; grid-template-columns: repeat(auto-fit, minmax(110px, 1fr)); } +/* Only the Downloads card stacks stats below the hero figure/sub block — + give that pairing breathing room without affecting stats blocks that + open a card (Sonarr/Radarr put dl.stats right after the search bar). */ +.card-body > [aria-live="polite"] + dl.stats { margin-top: 0.9rem; } dl.stats > div { background: var(--surface-2); border: 1px solid var(--border); @@ -532,32 +539,68 @@ dd.is-accent { color: var(--accent); } } .req-sub { font-size: var(--fs-xs); color: var(--text-3); } -/* ── Storage — per-disk breakdown ──────────────────────── */ -/* Same row anatomy as indexer/request rows: name/sub on the left, a status - badge on the right. Data + cache disks get a usage meter; parity disks - (no filesystem) are rendered compact — name and temp only. */ -.disk-list { display: flex; flex-direction: column; gap: 0.5rem; margin-top: 0.85rem; } -.disk-item { - display: flex; - align-items: center; - gap: 0.7rem; - min-height: var(--row-h); - padding: 0.6rem 0.7rem; - background: var(--surface-2); - border: 1px solid var(--border); - border-radius: var(--radius-sm); - min-width: 0; +/* ── Storage — per-disk table ──────────────────────────── */ +/* Storage owns a full-width row of its own, so the per-disk breakdown is a + real table rather than a stack of cards: columns line up, digits are + tabular, and a new metric is a new column instead of a redesign. Stacking + these as rows in a third-of-a-page column is what hollowed out the two + cards beside it — see the dead-space rule in CLAUDE.md. */ +/* Storage's header meta is a sentence, not a badge. .card-meta is nowrap so + short status words never break; this one has to wrap or it pushes the whole + page sideways on a phone. */ +.card-meta.card-meta-wrap { white-space: normal; text-align: right; } +.array-meter.meter { margin-top: 0; margin-bottom: 1.1rem; } +.table-scroll { overflow-x: auto; } +.disk-table { + width: 100%; + border-collapse: collapse; + font-variant-numeric: tabular-nums; } -.disk-info { min-width: 0; flex: 1; } -.disk-name { - font-size: var(--fs-sm); font-weight: 600; - white-space: nowrap; overflow: hidden; text-overflow: ellipsis; +.disk-table th, .disk-table td { padding: 0.45rem 0.6rem; text-align: left; } +.disk-table th:first-child, .disk-table td:first-child { padding-left: 0; } +.disk-table th:last-child, .disk-table td:last-child { padding-right: 0; } +.disk-table thead th { + font-size: var(--fs-xs); + font-weight: 500; + color: var(--text-3); + white-space: nowrap; + padding-bottom: 0.5rem; + border-bottom: 1px solid var(--border); +} +.disk-table tbody th { + font-size: var(--fs-sm); + font-weight: 600; + color: var(--text); + white-space: nowrap; +} +.disk-table tbody td { + font-size: var(--fs-sm); + color: var(--text-2); + border-bottom: 1px solid var(--border); +} +.disk-table tbody th { border-bottom: 1px solid var(--border); } +.disk-table tbody tr:last-child th, +.disk-table tbody tr:last-child td { border-bottom: none; } +.disk-table .num { text-align: right; white-space: nowrap; } +/* The bar and its percentage share one cell, so the bar flexes with the + table while the number stays a fixed, right-aligned column of digits. */ +.disk-table .usage { width: 32%; min-width: 140px; } +.usage-cell { display: flex; align-items: center; gap: 0.6rem; } +.usage-cell .meter { margin-top: 0; flex: 1; height: 5px; } +.usage-pct { + font-size: var(--fs-xs); + color: var(--text-3); + width: 2.6em; + text-align: right; + flex-shrink: 0; +} +.disk-null { color: var(--text-3); } +.disk-table tr.is-parity th { color: var(--text-2); } +@media (max-width: 720px) { + /* Below this the six columns stop fitting; the table scrolls in its own + container rather than squeezing the sizes into unreadable wraps. */ + .table-scroll .disk-table { min-width: 540px; } } -.disk-sub { font-size: var(--fs-xs); color: var(--text-3); } -.disk-meter.meter { margin-top: 0.4rem; height: 4px; } -.disk-list-compact { margin-top: 0.5rem; } -.disk-item-compact { justify-content: space-between; padding: 0.5rem 0.7rem; min-height: auto; } -.disk-item-compact .disk-name { flex: 1; } .badge.temp-ok { background: var(--ok-dim); color: var(--ok); } .badge.temp-warn { background: var(--warn-dim); color: var(--warn); } .badge.temp-danger { background: var(--danger-dim); color: var(--danger); } @@ -595,6 +638,8 @@ dd.is-accent { color: var(--accent); } } .empty { color: var(--text-3); font-size: var(--fs-sm); } +/* Storage card's usage note trails the meter with no room to spare below it. */ +.meter-legend + .empty { margin-bottom: 0; } /* ── Errors ─────────────────────────────────────────── */ .error { diff --git a/templates/dashboard.html b/templates/dashboard.html index d509b23..c32dd32 100644 --- a/templates/dashboard.html +++ b/templates/dashboard.html @@ -25,6 +25,19 @@ {% endmacro %} +{#- Disk temperature / spin state, as one badge. A spun-down disk reports no + usable temperature, so Standby replaces the reading rather than showing + a stale or zero degree value. -#} +{% macro disk_state(dk) %} + {%- if dk.spinning == false -%} + Standby + {%- elif dk.temp is not none -%} + {{ dk.temp }}°C + {%- else -%} + — + {%- endif -%} +{% endmacro %} + {#- One service status pill in the header strip. -#} {% macro svc_pill(key, label, url, up, stat) %}| Disk | +Used | +Size | +Free | +Usage | +Temp | +
|---|---|---|---|---|---|
| {{ dk.name }} | +{{ dk.used }} | +{{ dk.total }} | +{{ dk.free }} | +
+
+
+
+
+
+ {{ dpct }}%
+ |
+ {{ disk_state(dk) }} | +
| {{ dk.name }} | +— | +— | +— | +Parity · no filesystem | +{{ disk_state(dk) }} | +
Container disk usage ({{ unraid.mount }}). Set UNRAID_API_KEY for full array stats.