Compare commits

..

No commits in common. "08c71ba68dbe0aca09126dd6d94b96d19666b8a3" and "1cc5a247cf97cb3790f166cc2b8ab59c2cdc0bc3" have entirely different histories.

3 changed files with 78 additions and 200 deletions

View file

@ -42,17 +42,6 @@ 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 `<body data-*>`, 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.
@ -67,28 +56,6 @@ 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

View file

@ -131,9 +131,6 @@ 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 {
@ -196,13 +193,13 @@ main {
border-top: 1px solid var(--border);
}
.stream:first-child { border-top: none; padding-top: 0; }
.stream-icon { font-size: var(--fs-lg); flex-shrink: 0; }
.stream-icon { font-size: var(--fs-base); flex-shrink: 0; }
.stream-info { min-width: 0; flex: 1; }
.stream-title {
font-size: var(--fs-base); font-weight: 600;
font-size: var(--fs-sm); font-weight: 600;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.stream-user { font-size: var(--fs-sm); color: var(--text-3); }
.stream-user { font-size: var(--fs-xs); color: var(--text-3); }
.live-dot {
width: 7px; height: 7px; border-radius: 50%;
background: var(--ok); flex-shrink: 0;
@ -245,10 +242,6 @@ 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);
@ -539,68 +532,32 @@ dd.is-accent { color: var(--accent); }
}
.req-sub { font-size: var(--fs-xs); color: var(--text-3); }
/* ── 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;
/* ── 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;
}
.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-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-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); }
@ -638,8 +595,6 @@ 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 {

View file

@ -25,19 +25,6 @@
</div>
{% 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 -%}
<span class="badge idle">Standby</span>
{%- elif dk.temp is not none -%}
<span class="badge temp-{% if dk.temp >= 55 %}danger{% elif dk.temp >= 45 %}warn{% else %}ok{% endif %}">{{ dk.temp }}&deg;C</span>
{%- else -%}
<span class="disk-null">&mdash;</span>
{%- endif -%}
{% endmacro %}
{#- One service status pill in the header strip. -#}
{% macro svc_pill(key, label, url, up, stat) %}
<a class="svc-pill {{ 'up' if up else 'down' }}" id="svc-{{ key }}"
@ -86,10 +73,7 @@
<!-- ══ Right now — the time-sensitive row, above the fold ══ -->
<section class="section hero" aria-labelledby="h-now">
<h2 id="h-now">Right now</h2>
{# Two cards here, not three. Storage carries a per-disk table that no
third-of-a-page column can hold without towering over these two and
hollowing them out — it gets its own full-width row below. #}
<div class="row cols-2">
<div class="row cols-3">
<!-- Now playing -->
<div class="card">
@ -146,7 +130,7 @@
<span id="sab-timeleft-row" style="display:none;"> · ETA <span id="sab-timeleft"></span></span>
</div>
</div>
<dl class="stats">
<dl class="stats" style="margin-top:0.9rem;">
<div><dt>Avg speed (7d)</dt><dd>{{ sabnzbd.avg_speed }}</dd></div>
<div><dt>Grabbed (7d)</dt><dd>{{ sabnzbd.completed_7d }}</dd></div>
<div><dt>Volume (7d)</dt><dd>{{ sabnzbd.week_size }}</dd></div>
@ -157,89 +141,18 @@
</div>
</div>
</div>
{# Storage spans the page so its per-disk table has room to be a table.
Ten disks down a narrow column is a tower; across the full width it
is ten short rows. #}
<div class="row">
<!-- Storage -->
<div class="card">
<div class="card-header">
<h3><a href="{{ service_urls.unraid }}" target="_blank" rel="noopener">Storage</a></h3>
{% if unraid.status == 'ok' %}
<span class="card-meta card-meta-wrap">
{{ unraid.array_used_pct }}% used · {{ unraid.array_free }} free of {{ unraid.array_total }}
{%- if unraid.disk_count %} · {{ unraid.disk_count }} disk{{ 's' if unraid.disk_count != 1 }}{% endif %}
{%- if unraid.array_state %} · {{ unraid.array_state | capitalize }}{% endif %}
</span>
{% if unraid.status == 'ok' and unraid.array_state %}
<span class="card-meta">{{ unraid.array_state | capitalize }}</span>
{% endif %}
</div>
<div class="card-body">
{% if unraid.status == 'ok' %}
{% if unraid.disks or unraid.caches or unraid.parities %}
{# Array total leads, then the disks that make it up. #}
{% set pct = unraid.array_used_pct %}
<div class="meter array-meter">
<div class="meter-fill {% if pct > 85 %}danger{% elif pct > 70 %}warn{% endif %}"
style="width: {{ pct }}%;"
role="progressbar" aria-valuenow="{{ pct }}" aria-valuemin="0" aria-valuemax="100"
aria-label="Array usage"></div>
</div>
<div class="table-scroll">
<table class="disk-table">
<thead>
<tr>
<th scope="col">Disk</th>
<th scope="col" class="num">Used</th>
<th scope="col" class="num">Size</th>
<th scope="col" class="num">Free</th>
<th scope="col" class="usage">Usage</th>
<th scope="col" class="num">Temp</th>
</tr>
</thead>
<tbody>
{% for dk in unraid.disks + unraid.caches %}
{% set dpct = dk.used_pct %}
<tr>
<th scope="row" title="{{ dk.device }}">{{ dk.name }}</th>
<td class="num">{{ dk.used }}</td>
<td class="num">{{ dk.total }}</td>
<td class="num">{{ dk.free }}</td>
<td class="usage">
<div class="usage-cell">
<div class="meter">
<div class="meter-fill {% if dpct > 85 %}danger{% elif dpct > 70 %}warn{% endif %}"
style="width: {{ dpct }}%;"
role="progressbar" aria-valuenow="{{ dpct }}" aria-valuemin="0" aria-valuemax="100"
aria-label="{{ dk.name }} usage"></div>
</div>
<span class="usage-pct">{{ dpct }}%</span>
</div>
</td>
<td class="num">{{ disk_state(dk) }}</td>
</tr>
{% endfor %}
{% for dk in unraid.parities %}
{# Parity mirrors the array rather than holding a filesystem,
so it has no size or usage of its own to report. #}
<tr class="is-parity">
<th scope="row" title="{{ dk.device }}">{{ dk.name }}</th>
<td class="num"><span class="disk-null">&mdash;</span></td>
<td class="num"><span class="disk-null">&mdash;</span></td>
<td class="num"><span class="disk-null">&mdash;</span></td>
<td class="usage"><span class="disk-null">Parity · no filesystem</span></td>
<td class="num">{{ disk_state(dk) }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
{# Fallback: container-visible disk usage, no per-disk detail. #}
{% set pct = unraid.array_used_pct %}
<div class="hero-figure"
<div class="hero-figure {% if pct > 85 %}is-danger{% elif pct > 70 %}is-warn{% endif %}"
style="color: {% if pct > 85 %}var(--danger){% elif pct > 70 %}var(--warn){% else %}var(--text){% endif %};">
{{ pct }}%
</div>
@ -250,10 +163,52 @@
role="progressbar" aria-valuenow="{{ pct }}" aria-valuemin="0" aria-valuemax="100"
aria-label="Array usage"></div>
</div>
<div class="meter-legend"><span>{{ unraid.array_used }} used</span></div>
<div class="meter-legend">
<span>{{ unraid.array_used }} used</span>
{% if unraid.disk_count %}<span>{{ unraid.disk_count }} disk{{ 's' if unraid.disk_count != 1 }}</span>{% endif %}
</div>
{% if unraid.source == "local" %}
<p class="empty" style="margin-bottom:0;">Container disk usage ({{ unraid.mount }}). Set <code>UNRAID_API_KEY</code> for full array stats.</p>
{% endif %}
{% if unraid.disks or unraid.caches %}
<div class="disk-list">
{% for dk in unraid.disks + unraid.caches %}
{% set dpct = dk.used_pct %}
<div class="disk-item">
<div class="disk-info">
<div class="disk-name" title="{{ dk.device }}">{{ dk.name }}</div>
<div class="disk-sub">{{ dk.used }} of {{ dk.total }}</div>
<div class="meter disk-meter">
<div class="meter-fill {% if dpct > 85 %}danger{% elif dpct > 70 %}warn{% endif %}"
style="width: {{ dpct }}%;"
role="progressbar" aria-valuenow="{{ dpct }}" aria-valuemin="0" aria-valuemax="100"
aria-label="{{ dk.name }} usage"></div>
</div>
</div>
{% if dk.spinning == false %}
<span class="badge idle">Standby</span>
{% elif dk.temp is not none %}
<span class="badge temp-{% if dk.temp >= 55 %}danger{% elif dk.temp >= 45 %}warn{% else %}ok{% endif %}">{{ dk.temp }}&deg;C</span>
{% endif %}
</div>
{% endfor %}
</div>
{% endif %}
{% if unraid.parities %}
<div class="disk-list disk-list-compact">
{% for dk in unraid.parities %}
<div class="disk-item disk-item-compact">
<div class="disk-name" title="{{ dk.device }}">{{ dk.name }}</div>
{% if dk.spinning == false %}
<span class="badge idle">Standby</span>
{% elif dk.temp is not none %}
<span class="badge temp-{% if dk.temp >= 55 %}danger{% elif dk.temp >= 45 %}warn{% else %}ok{% endif %}">{{ dk.temp }}&deg;C</span>
{% endif %}
</div>
{% endfor %}
</div>
{% endif %}
{% else %}
@ -261,6 +216,7 @@
{% endif %}
</div>
</div>
</div>
</section>
@ -564,7 +520,7 @@
</div>
{% if tautulli.top_tv_list or tautulli.top_movie_list %}
<div class="row cols-2">
<div class="row cols-2" style="margin-top: var(--gap);">
<div class="card">
<div class="card-header"><h3>Top TV</h3></div>
<div class="card-body">