fix(homepage): uniform 4-column grid, hold the status gutter, unleak AI Systems

The board's card width changed at every group boundary because `columns:` was
being tuned per group under the 2026-08-18 "columns = member count" rule. That
rule is retired: it sets `lg:grid-cols-N` for one group, so it fixes that
group's CARD WIDTH, not its density. Measured on the live board, Notes rendered
a single 1464px card, News and Media 728px, Eval & Retrieval 286px, everything
else 360px. All 20 groups are now `columns: 4` and every card renders at 360.

`.service-name` reserved a 78px status gutter with `padding-right` and relied
on `overflow: hidden` to hold it, but overflow clips at the PADDING box — the
gutter was spill room the title printed straight through. Six cards on the AI
tab rendered their name underneath their own status pill, measured by testing
the title text node's box against the status cluster's. The intended ellipsis
never fired either: it is painted by whichever block's own line overflows, and
that is the anonymous box around the bare title text node, which does not carry
`overflow`. The gutter holds by wrapping now, and the description opts back out
of it with a negative margin since the pill only ever covers the first line.

Scriberr's `homepage.group=AI Systems` named a group absent from `layout:`, so
it had no `tab:` and rendered on all four tabs — the same defect as UltraSeedbox
in 2026-08-18, arriving this time from a container label. Relabelled to
`AI - Audio Tools`, where the other ASR seats already live. The
`homepage.group=AI Systems` sample in the repo-root CLAUDE.md was the source of
the name and now carries the constraint, plus the fact that a labelled
container is discovered from any of the five configured engines and must not
also be listed in services.yaml.

Also: descriptions clamped to three lines so a five-line outlier stops dragging
its row 50px taller than its neighbours; icon ramp overridden off slate-400 ->
slate-700, which was sinking the bottom half of every glyph into the card fill;
bookmark groups and Jellyfin's trailing stream rows brought into the card and
eyebrow vocabulary; group gap 10px -> 22px now that width no longer separates
them.

The icon override sits on `html[class]`, not `:root`: Homepage sets the same
variables on `.theme-slate`, which is on the <html> element, and a class beats
`:root` on the same element.

Verified with Playwright against the live board — per-group card width, card
height spread, and a geometric title-vs-status collision check, before and
after. Before/after captures: http://10.100.10.50:8090/b/homepage-relayout/
This commit is contained in:
2026-08-24 08:15:02 -07:00
parent 32349b7653
commit f6f2f69649
8 changed files with 623 additions and 77 deletions
+19 -2
View File
@@ -98,15 +98,32 @@ Observed and standardized across servers:
- **Named volumes** for service state (pattern: `<stack>_<name>`)
- **Bind mounts** only for: model files (`/tank/aimodels/...`), config files (`/opt/docker/conf/...`), docker socket where required
- **Restart policy:** `restart: unless-stopped` for daemons
- **Homepage labels** on user-facing services:
- **Homepage labels** on user-facing services. The dashboard runs on
`esh-docker-vm` and reads the Docker API of **every** host in
`stacks/homepage/conf/docker.yaml` (ana-docker, ana-ml2, nh3-docker,
irv-ml1, esh-docker-vm), so a labelled container is discovered from
wherever it runs — you do not add it to `services.yaml` as well. Doing both
renders it twice.
```yaml
labels:
- homepage.group=AI Systems
- homepage.group=<ExistingGroup>
- homepage.name=<ServiceName>
- homepage.icon=mdi-<icon>
- homepage.description=<short>
- homepage.href=http://<host-ip>:<port>
```
⚠ **`homepage.group` must name a group that already exists in
`stacks/homepage/conf/settings.yaml`'s `layout:` block.** A group the layout
has never heard of gets no `tab:`, and Homepage renders an untabbed group on
**all four tabs**. Inventing a group name here is how Scriberr's
`AI Systems` ended up repeated at the bottom of every tab from 2026-08-23
(fixed 2026-08-24). If the service genuinely needs a new group, add the group
to `layout:` **with a `tab:`** in the same change.
Check with `curl -s http://10.0.50.45:5100/api/services | jq -r '.[].name'` —
anything in that list that is not a key in `layout:` is leaking onto all
tabs right now.
Labels only apply at container **creation**, so a label edit needs
`docker compose up -d <service>`, not `restart`.
- **Healthchecks** on services that expose HTTP
## Servers
@@ -0,0 +1,118 @@
# `[2026-08-24]` Homepage: remote-label consumption re-verified, then the board relaid out on a uniform grid
Prompted by the operator: *"Homepage on esh-vm-docker lists remote dockers and
can absolutely consume their labels, please verify again. I am still
unsatisfied with the layout and aesthetics."*
## The verification — the operator was right, and the record now says so
**Homepage on `esh-docker-vm` discovers services by container label from all
five Docker engines in `conf/docker.yaml`, not just its own.** This is not an
inference; `GET /api/services` returns every card's `server` field, and the
2026-08-24 snapshot resolves to:
| `server` | host | label-discovered services |
|---|---|---|
| `ana-pfi-docker` | 10.250.50.70 | 30 |
| `irv-ml1-docker` | 10.100.79.3 (over WireGuard) | 15 |
| `ana-ml2-docker` | 10.250.50.54 | 14 |
| `esh-vm-docker` | 10.0.50.45 (the dashboard's own host) | 13 |
| `nh3-pfi-docker` | 10.100.50.40 | 2 |
**74 of 107 cards are label-discovered, and only 13 of those come from the
dashboard's own engine** — the other 61 are read off four remote hosts,
including irv-ml1 across the WireGuard tunnel. The remaining 33 carry
`server: null`: those are the manual `services.yaml` entries — hardware, BMCs,
hypervisors, printers, and user-level systemd services that have no container
to label in the first place. **That null is the only thing "not label-driven"
about this dashboard**, and it is a property of the entry, not of the host it
points at.
⚠ If a future session doubts this again, the check is one command and takes two
seconds — do not reason about it from the docs:
```bash
curl -s http://10.0.50.45:5100/api/services \
| jq -r '.[].services[] | .server' | sort | uniq -c
```
## What was actually wrong with the layout
Measured with Playwright against the live board (per-group `card=` width, card
height spread, and a geometric title-vs-status overlap test), not judged by
eye:
- **Card width changed at every group boundary.** `columns:` is not a density
dial — it sets `lg:grid-cols-N` for one group, so it fixes that group's card
width. Notes rendered a single **1464px** card; News and Media **728px**;
Eval & Retrieval **286px**; everything else 360px. Scrolling the page, the
grid resized five times.
- **Long names printed underneath their own status pill.** Measured by
re-injecting the old rule and testing the title text node's box against the
status cluster's box: **6 cards, all on the AI tab** — 3 in Inference, 2 in
Dormant, 1 in Eval & Retrieval; zero on the other three tabs, which is why
it survived earlier passes. Root cause is a genuinely counter-intuitive one:
the rule reserved a
78px gutter with `padding-right` and relied on `overflow: hidden` to hold it,
but **overflow clips at the padding box, not the content box** — so the
reserved gutter was spill room the title rendered straight through. The
intended `text-overflow: ellipsis` never fired either, because the ellipsis
is painted by whichever block's own line overflows, and here that is the
anonymous box around the bare title text node, which does not carry
`overflow`.
- **`AI Systems` / Scriberr was on all four tabs** — the 2026-08-18 UltraSeedbox
bug recurring, this time arriving from a container label rather than from
`settings.yaml`.
- **Icons were grey smudges.** Homepage masks every glyph over
`--color-logo-start/stop`; stock slate-400 → slate-700 sinks the bottom half
of each icon into the card fill.
- Bookmark groups and Jellyfin's trailing stream rows were the two components
the theme had never reached.
## The fixes
`stacks/homepage/conf/settings.yaml`**all 20 groups to `columns: 4`.**
`stacks/homepage/theme/australis.css.in` → rebuilt → `conf/custom.css`:
gutter held by wrapping, description clamped to 3 lines (floor still 2), icon
ramp overridden, bookmark + trailing-widget components themed, group gap
10px → 22px. `stacks/scriberr/compose.yaml``homepage.group``AI - Audio
Tools`, container recreated on ana-ml2.
After: **every group renders at card=360**, and the collision count is zero.
Before/after, all four tabs: `http://10.100.10.50:8090/b/homepage-relayout/`
(24h TTL; also on the standing link board).
## ⚠ Three traps worth carrying forward
1. **"Columns = member count" is RETIRED** (it was the 2026-08-18 rule). It was
avoiding dead cells in a short last row and bought a worse defect. A short
last row is what a grid looks like; a card wider than its neighbours is what
a mistake looks like.
2. **A `:root` override of a Homepage theme variable is silently ignored.**
Homepage sets `--color-logo-*` on `.theme-slate`, and that class is on the
`<html>` element — the same element `:root` matches. `.theme-slate` (0,1,0)
beats `:root` (0,0,1), so the override does nothing and looks like the
variable is not the one in play. `html[class]` (0,1,1) wins, and does not
hard-code which `theme-*` class is active. Specificity alone is not enough
either: a custom property resolves from the *nearest* ancestor that sets it,
so the override has to land on `<html>`, not on `<body>`.
3. **The post-recreate tab-bar loss is INTERMITTENT, not guaranteed.** The
2026-08-19 note reads as though every recreate costs up to an hour of broken
render. This recreate came up correct within 10 seconds — fresh payload on
the first poll, all four tabs clickable a minute later. Recreate, *check*,
and only then walk away if it is actually in the broken state.
Also re-confirmed, since the change depended on it: **a `settings.yaml` edit
needs a container recreate, not a restart.** `docker restart homepage` left the
old `"columns":1` payload embedded in the served HTML with the correct file
mounted and readable inside the container; `compose up -d --force-recreate`
cleared it immediately.
## Deliberately not done — operator's call
The Main tab still opens on three sparse bands: **Notes** (1 member) and
**Games** (1) each burn a full 4-wide row, and **News** has 2. Merging Notes +
News, or folding Games into Apps, would tighten the top of the page — but that
is information architecture, not layout, and the group names are the operator's.
Surfaced rather than done.
+4 -1
View File
@@ -108,7 +108,9 @@ no longer deployed sidecars here. See Recent decisions.)
(no NOPASSWD)** — stage model pulls to `/home`, not root-owned `/worktank`.
## Current state / in-flight
_As of 2026-08-24 — a very long ops session; everything it opened, it closed. Nothing is mid-flight. Next work is the **homepage** dashboard._
_As of 2026-08-24 — a very long ops session; everything it opened, it closed. Nothing is mid-flight. The **homepage** dashboard pass is done (below); the only thing left open on it is an information-architecture question for the operator, not work._
- **🟢 Homepage relaid out on a UNIFORM 4-column grid, and its remote-label discovery re-verified.** `/api/services` proves the board reads container labels off **all five** Docker engines — 74 of 107 cards are label-discovered and only 13 come from its own host. ⚠ **"columns = member count" is RETIRED**; per-group column tuning is what made the card width change five times down the page. Also fixed: long names printing under their own status pill (6 cards, AI tab), the `AI Systems`/Scriberr all-tabs leak, and grey-smudge icons. → `persistent-memory.d/2026-08-24-homepage-uniform-grid.md`
- **🔒 ana-gw's public admin surface is now ZERO open TCP ports**, and the box is scheduled for replacement by **OPNsense on a Dell R420** (which brings WireGuard onto the edge — so the downstream-WireGuard-VM design is moot, do not scope it). WAN admin was opened as a cutover contingency then closed on instruction; the FortiOS **ACME listener** on :80 was disabled too. **No out-of-band path to ana-gw remains** — if both tunnels drop it is console-only; re-open is two one-liners in auto-memory. → `persistent-memory.d/2026-08-24-ana-gw-admin-closed-acme-disabled.md`
@@ -134,6 +136,7 @@ _As of 2026-08-24 — a very long ops session; everything it opened, it closed.
## Recent decisions
- `[2026-08-24]` **Homepage columns unified at 4 for every group; the 2026-08-18 "columns = member count" rule is retired.** It was avoiding dead cells in a short last row and bought a worse defect — card width changing at every group boundary. Also carries two CSS traps: `overflow: hidden` clips at the PADDING box (so a `padding-right` gutter is spill room, not a guard), and a `:root` override of a Homepage theme variable is silently outranked by `.theme-slate` on the same `<html>` element. → `persistent-memory.d/2026-08-24-homepage-uniform-grid.md`
- `[2026-08-24]` **AES-128 adopted on both Anaheim tunnels; the per-flow ceiling root-caused to the UDM's software AES-CBC, exonerating the FortiGate.** Proven by an A/B/A cipher swap at identical CPU — hardware offload is not cipher-cost-sensitive. → `persistent-memory.d/2026-08-23-anaheim-ipsec-tunnel-ceiling.md`
- `[2026-08-24]` **ana-gw's public admin surface closed to zero open ports, ACME listener included.** Two of my diagnoses were wrong first (an "ISP proxy" that was the FortiGate, and an "all-port VIP" alarm that was a parser gap) — both from reading config instead of measuring from outside. → `persistent-memory.d/2026-08-24-ana-gw-admin-closed-acme-disabled.md`
- `[2026-08-24]` **Scriberr deployed on ana-ml2 GPU1, image built from source.** Three upstream bugs: the Blackwell image was never published, it must run as uid 10001, and `UV_LINK_MODE=copy` is required or two backends fail silently. → `persistent-memory.d/2026-08-24-scriberr-ana-ml2.md`
+51 -5
View File
@@ -82,14 +82,60 @@ Three fixes, all in this stack's config except where noted:
they had members, so the last row of each was mostly dead space (Notes: 1
card in a 4-wide row). Columns now track member counts; see the rule in
`settings.yaml`. Check with `GET /api/services`, which prints live per-group
counts.
counts.**Superseded 2026-08-24** — see below.
## 2026-08-24 layout + theme pass
- **Columns are 4 everywhere now; "columns = member count" is retired.**
`columns:` sets `lg:grid-cols-N` for one group, so tuning it per group fixes
that group's *card width* — and the width then changed at every group
boundary. Measured on the live board: Notes rendered a single 1464px card,
News and Media 728px, the AI tab alternated 360 / 728 / 286 down the page.
The old rule was avoiding dead cells in a short last row and bought a worse
defect. A short last row is what a grid looks like; a card wider than its
neighbours is what a mistake looks like. Rule and reasoning are at the top of
`settings.yaml`'s `layout:` block.
- **Scriberr's `AI Systems` group was on all four tabs** — the same untabbed-
group behaviour as UltraSeedbox in 2026-08-18, arriving this time from a
container label rather than from this file. Relabelled to `AI - Audio Tools`
in `stacks/scriberr/compose.yaml` (its real home, alongside Parakeet and
Speaches) and the container recreated. The `homepage.group=AI Systems` sample
in the repo-root `CLAUDE.md` was the source and now carries the constraint.
- **Long service names printed under their own status pill.** `.service-name`
reserved a 78px right gutter with `padding-right` and relied on
`overflow: hidden` to hold it — but overflow clips at the *padding* box, so
the gutter was spill room, not a guard. Every long name on the AI tab
collided. It holds by wrapping now; see the comment on the rule.
- **Descriptions are clamped to three lines** (floor of two, unchanged). Four-
and five-line descriptions were dragging their whole row 30-50px taller than
the cards beside them.
- **Icons were grey smudges.** Homepage masks every mdi glyph over
`--color-logo-start/stop`, stock slate-400 → slate-700, and the dark stop
sank the bottom of each glyph into the card fill. Overridden to ice → aurora
blue. ⚠ The override must sit on `html[class]`, not `:root` — Homepage sets
the same variables on `.theme-slate`, which is on `<html>`, and a class beats
`:root` on the same element.
- **Bookmark groups and Jellyfin's trailing stream rows** were the last two
un-themed components; both now use the card/eyebrow vocabulary.
Verified with Playwright against the live board — per-group card width, card
height spread, and a geometric title-vs-status collision check. Script pattern
is in the "iteration loop" note below.
## The tab bar goes missing after a recreate, then comes back on its own
**Status: self-healing, cause not pinned. Do not chase it.** After a recreate
the client render comes up with no tab bar, no wallpaper and no i18n (the
search box shows the raw key `search.search`), and groups fall back to
side-by-side columns. It restores itself with no intervention.
**Status: self-healing, cause not pinned, and INTERMITTENT. Do not chase it.**
After a recreate the client render can come up with no tab bar, no wallpaper
and no i18n (the search box shows the raw key `search.search`), with groups
falling back to side-by-side columns. It restores itself with no intervention.
**It does not happen every time.** The 2026-08-24 recreate (`compose up -d
--force-recreate homepage`, to land a `settings.yaml` change) came up correct
within 10 seconds: the fresh `columns: 4` payload was in the served HTML on the
first poll, and a Playwright pass clicked through all four tabs a minute later
with the bar present. So do not pre-emptively schedule an hour of ugliness
around a config change — recreate, check, and only then walk away if it is in
the broken state.
**Timing, measured rather than assumed:** five minutes is NOT enough — a fresh
container was still tab-less at 4m30s, twice. It was observed healthy again
+191 -22
View File
@@ -121,6 +121,39 @@
--shadow-1: 0 1px 2px rgba(10, 12, 18, 0.4);
--dur-fast: 120ms;
--ease-out: cubic-bezier(0.2, 0.8, 0.2, 1);
/* Width the status cluster is allowed to occupy in a card's top-right.
Load-bearing in two places that must agree: the right inset reserved on
the title line, and the negative margin the description uses to opt back
out of it. Sized for the widest real cluster — a latency badge plus a
status word ("21 ms ● RUNNING") — plus the 14px card inset. */
--status-gutter: 96px;
}
/* ---- Icon colour ---------------------------------------------------------
Homepage draws every mdi and simple-icons glyph as a MASK over a gradient
fill, so this ramp IS the icon colour — `color:` does nothing to them, and
a filter only muddies them. Stock is slate-400 -> slate-700, and that dark
stop sinks the bottom half of every glyph into the card fill: at a glance
the rail reads as a column of grey smudges rather than as icons. Ice ->
aurora blue keeps both ends clear of --bg-1 and ties the rail to the tab
accent. Space-separated channels, not hex — Homepage consumes them as
`rgb(var(--color-logo-start))`.
⚠ NOT `:root`. Homepage sets these same two variables on `.theme-slate`,
and that class sits on the <html> element — the very element `:root`
matches. Same element, so the cascade decides, and `.theme-slate` (0,1,0)
outranks `:root` (0,0,1): a `:root` override here is simply ignored, which
is how the first attempt at this silently did nothing. `html[class]` is
(0,1,1) and wins on the same element without hard-coding which `theme-*`
class is active, so it survives a change to `color:` in settings.yaml.
Raising specificity is not enough on its own either — a custom property is
resolved from the NEAREST ancestor that sets it, so the override has to
land on <html> too, not on <body>. */
html[class] {
--color-logo-start: 179 203 207; /* #b3cbcf aus-bright-80 */
--color-logo-stop: 99 136 216; /* #6388d8 aus-blue */
}
/* ---- Canvas --------------------------------------------------------------
@@ -158,17 +191,38 @@ body {
use it often. It replaces the oversized sans heading, and a hairline runs
from the label to the right edge so the groups read as register bands
rather than as titles floating over a grid. */
.services-group > button.group {
/* BOOKMARK GROUPS GET THE SAME TREATMENT. A bookmark group is a different
component with a parallel class family (`.bookmark-group`,
`h2.bookmark-group-name`, `.bookmark-group-icon`) and none of the selectors
below matched it, so UltraSeedbox rendered with Homepage's stock 20px sans
heading and a full-colour icon while every service group above it wore the
mono eyebrow. One un-themed heading in a column of themed ones is more
conspicuous than the whole page being un-themed. */
/* 22px, not 10px. Now that every group is the same four-across grid (see the
columns note in settings.yaml), the only thing separating one band from the
next is this gap — with card width no longer changing at the boundary, a
10px gap let two groups read as one long run of cards. */
.services-group > button.group,
.bookmark-group > button.group {
gap: 0;
margin-bottom: 4px;
margin-top: 10px;
margin-top: 22px;
}
.service-group-icon {
/* …except the first group on a tab, which should sit up against the tab bar
rather than floating 22px below it. */
.services-group:first-of-type > button.group {
margin-top: 6px;
}
.service-group-icon,
.bookmark-group-icon {
display: none !important;
}
h2.service-group-name {
h2.service-group-name,
h2.bookmark-group-name {
font-family: var(--font-mono) !important;
font-size: 11px !important;
font-weight: 500 !important;
@@ -177,9 +231,11 @@ h2.service-group-name {
letter-spacing: var(--tracking-caps);
color: var(--aus-cyan) !important;
white-space: nowrap;
display: flex;
}
.services-group > button.group::after {
.services-group > button.group::after,
.bookmark-group > button.group::after {
content: "";
flex: 1;
height: 1px;
@@ -188,7 +244,8 @@ h2.service-group-name {
}
/* The chevron sits after the rule, muted. */
.services-group > button.group svg {
.services-group > button.group svg,
.bookmark-group > button.group svg {
color: var(--fg-muted);
margin-left: 8px;
width: 14px;
@@ -220,7 +277,14 @@ li.service > .service-card:hover {
border-color: var(--border-default);
}
/* Service name — display face, tight. Description — body face, secondary. */
/* Service name — display face, tight. Description — body face, secondary.
*
* ⚠ `.service-name` IS NOT THE TITLE. Homepage puts the title in as a bare
* text node and then nests `<p class="service-description">` as a sibling
* inside the SAME div, so every rule here lands on a box containing both. The
* title has no element of its own and cannot be selected — which is why it
* cannot be given its own truncation, and why the description needs an
* explicit escape hatch from anything the title needs. */
.service-name {
font-family: var(--font-display) !important;
font-size: 14px !important;
@@ -231,12 +295,24 @@ li.service > .service-card:hover {
line-height: 17px !important;
letter-spacing: -0.015em;
color: var(--fg-0) !important;
/* Reserve the status gutter. Matches --status-gutter below; a long name
ellipsises into it instead of colliding with the pill. */
padding-right: 78px !important;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
/* THE GUTTER MUST BE HELD BY WRAPPING, NOT BY CLIPPING.
This rule used to read `padding-right: 78px; overflow: hidden;
text-overflow: ellipsis; white-space: nowrap` and it did not work: CSS
clips overflow at the PADDING box, not the content box, so the reserved
78px was spill room the title printed straight through. Every long name on
the AI tab rendered underneath its own status pill — "char-rp (MeroMero-v2
NVFP4, multimodal)" collided with HEALTHY, "char-rp-reasoning (Heretic2
NVFP4+MTP)" with EXITED. The ellipsis never appeared either: it is painted
by the block whose own line overflows, and that block here is the
anonymous box around the text node, which does not carry the `overflow`.
Wrapping has neither problem — line breaking respects the content box, so
the gutter genuinely holds. Long names take two lines instead of one; on
the AI tab nearly every name in a group is long, so those rows stay flush
with each other. */
padding-right: var(--status-gutter) !important;
white-space: normal;
overflow: visible;
}
p.service-description {
@@ -246,13 +322,31 @@ p.service-description {
line-height: 17px !important;
color: var(--fg-3) !important;
margin-top: 3px;
/* Two lines minimum. Cards in a row now bottom-align for the common
one-line/two-line mix, which is what made the grid look ragged, without
bringing back equal-height stretching — that inflated short cards to
match a widget card twice their height, which was far worse. Descriptions
longer than two lines still grow; nothing is truncated. */
min-height: 34px;
white-space: normal;
/* Opt back out of the title's gutter. The status cluster is absolutely
positioned over the FIRST line only (top: 13px, height: 17px, so it
occupies y 13-30); the description never starts above y 33. Nothing can
collide, so it takes the full card width back and the gutter costs no
description space at all. */
margin-right: calc(8px - var(--status-gutter));
/* Floor of two lines, ceiling of three.
The floor keeps the common one-line/two-line mix bottom-aligned, which is
most of what made rows look ragged. The ceiling is the other half: a
handful of four- and five-line descriptions (The Booth, Waterland Studio,
the vLLM seats) were dragging their entire row 30-50px taller than every
card beside them, and at four cards across that gap is the most visible
defect on the page. Three lines truncates almost nothing in practice — it
bites only the few entries whose description had already outgrown a
dashboard card and belongs in the service's README instead.
Not equal-heights: that inflates short cards into hollow boxes to match a
widget card twice their height, which is worse than a ragged edge. */
min-height: 34px;
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
}
.service-icon {
@@ -366,11 +460,15 @@ p.service-description {
gap: 0 !important;
}
/* Padding trimmed from `8px 4px 6px` on 2026-08-24. Every stat strip adds its
full height to its card and to nothing else in the row, so at four cards
across one AdGuard card opens a void beside three ordinary ones. The strip
cannot be made free, but it can be made small. */
.service-container > div {
background: transparent !important;
border-radius: 0 !important;
margin: 0 !important;
padding: 8px 4px 6px !important;
padding: 6px 4px 5px !important;
border-left: 1px solid var(--border-subtle);
}
@@ -379,10 +477,12 @@ p.service-description {
}
/* value — nowrap because a long one (AdGuard's "32.767 ms") otherwise wraps
mid-cell and drags the whole card taller than its row-mates. */
mid-cell and drags the whole card taller than its row-mates.
15px, down from 17px: at four cards across, AdGuard's four-cell strip has
~72px per cell and "130,253" no longer fits at 17px. */
.service-container > div > div:not(.uppercase) {
font-family: var(--font-display) !important;
font-size: 17px !important;
font-size: 15px !important;
font-weight: 600 !important;
letter-spacing: -0.015em;
line-height: 1.2 !important;
@@ -405,6 +505,75 @@ p.service-description {
margin-top: 2px;
}
/* Trailing widget rows — Jellyfin's "No Active Streams" strip and its empty
second row, which Homepage emits AFTER .service-container as its own block
of Tailwind-tinted pills. Untouched they broke out of the card's language:
two light grey bars with their own radius hanging under an otherwise
hairline-ruled footer. Same footer vocabulary, so the card stays one object.
Not hidden — "no active streams" is the answer to the question the card is
there to answer. */
li.service .service-container ~ div > div {
background: transparent !important;
border-radius: 0 !important;
border-top: 1px solid var(--border-subtle);
height: auto !important;
min-height: 16px;
margin-top: 0 !important;
}
li.service .service-container ~ div span {
position: static !important;
display: block;
padding: 3px 4px 2px !important;
margin: 0 !important;
font-family: var(--font-mono) !important;
font-size: 9px !important;
letter-spacing: var(--tracking-caps);
text-transform: uppercase;
color: var(--fg-3) !important;
}
/* ---- Bookmarks -----------------------------------------------------------
Same card language as a service: opaque fill, hairline, one soft shadow. The
name takes the display face and the description drops to mono metadata at
tertiary contrast, so a bookmark row reads as a quieter sibling of a service
card rather than as a different widget that wandered in. */
li.bookmark > a {
background: var(--bg-1) !important;
border: 1px solid var(--border-subtle);
border-radius: var(--radius-lg) !important;
box-shadow: var(--shadow-1) !important;
margin-bottom: 8px !important;
overflow: hidden;
transition: background-color var(--dur-fast) var(--ease-out),
border-color var(--dur-fast) var(--ease-out);
}
li.bookmark > a:hover {
background: var(--bg-2) !important;
border-color: var(--border-default);
}
.bookmark-icon {
background: transparent !important;
border-radius: 0 !important;
border-right: 1px solid var(--border-subtle);
}
.bookmark-name {
font-family: var(--font-display) !important;
font-size: 13px !important;
font-weight: 500 !important;
color: var(--fg-0) !important;
}
.bookmark-description {
font-family: var(--font-mono) !important;
font-size: 10px !important;
letter-spacing: 0.02em;
color: var(--fg-3) !important;
}
/* ---- Tab bar -------------------------------------------------------------
The filled pill is replaced by a 2px top edge in aurora blue — canon's one
sanctioned accent-border pattern (and explicitly never a left border). */
+42 -24
View File
@@ -74,30 +74,50 @@ useEqualHeights: false
# If you add an AI group, place it by asking "would I click this?", not by
# how central the service is to the fleet.
#
# COLUMN COUNTS ARE NOT A STYLE CHOICE — they are the member count.
# `columns: N` lays the group out N-per-row and leaves the remainder of the
# last row as dead space. A 1-member group at columns:4 renders one card and
# three empty cells, which is what made this dashboard look ragged before
# 2026-08-18. Rule: set columns to the member count, or to the divisor that
# leaves the smallest remainder. Re-check when a group gains or loses a
# service — `GET /api/services` prints the live per-group counts.
# COLUMNS ARE 4 EVERYWHERE. DO NOT TUNE THEM PER GROUP.
#
# EVERY GROUP NEEDS A `tab:` — including bookmark groups. A group with no tab
# assignment renders on ALL FOUR TABS. That is how UltraSeedbox ended up
# repeated at the bottom of every tab (fixed 2026-08-18); it is Homepage
# behaviour, not a bug, and it will happen again to the next group added
# without a tab.
# `columns: N` is not a density dial — it sets `lg:grid-cols-N` on that one
# group, so it fixes the CARD WIDTH for that group alone. Varying it per group
# means the card width changes every time you cross a group boundary, and a
# page whose grid keeps resizing as you scroll reads as broken layout even when
# every individual group is fine.
#
# The predecessor rule here was "columns = the member count", written to avoid
# the dead cells a 1-member group leaves in a 4-wide row. It trades one flaw
# for a worse one: at columns:1 a single service becomes a 1500px-wide bar
# holding six words, and at columns:2 a three-member group orphans its third
# card onto a half-empty row. Measured on the live board 2026-08-24 — the
# Notes, News, Media, AI - Image & Media and AI - Audio Tools groups were all
# rendering cards two to four times wider than the groups above and below them.
#
# A short last row is what a grid looks like. A card that is wider than its
# neighbours is what a mistake looks like. Uniform wins.
#
# Homepage's own responsive ramp (`grid-cols-1 md:grid-cols-2 lg:grid-cols-N`)
# still collapses this to 2-up and 1-up on narrow viewports, so 4 is a desktop
# maximum, not a hard floor.
#
# EVERY GROUP NEEDS A `tab:` — including bookmark groups, and including groups
# that arrive from a `homepage.group=` container label rather than from this
# file. A group with no tab assignment renders on ALL FOUR TABS. That is how
# UltraSeedbox ended up repeated at the bottom of every tab (fixed 2026-08-18)
# and how Scriberr's `AI Systems` label did the same from 2026-08-23 (fixed
# 2026-08-24 by relabelling it into `AI - Audio Tools`). It is Homepage
# behaviour, not a bug, and it will happen again to the next container labelled
# with a group name that does not appear below.
# `GET /api/services` prints the live group list — anything in it that is not a
# key here is currently leaking onto all four tabs.
layout:
Notes:
icon: mdi-note-text-outline
tab: Main
style: row
columns: 1
columns: 4
News:
icon: mdi-rss
tab: Main
style: row
columns: 2
columns: 4
Monitoring:
icon: mdi-chart-line
tab: Main
@@ -108,19 +128,16 @@ layout:
tab: Main
style: row
columns: 4
# 2 wide, not 3 — Plex and Jellyfin carry stat-block widgets that get
# squeezed to unreadable at 4-across, and 4 members / 3 columns orphaned
# Jellyfin onto a row of its own.
Media:
icon: mdi-play-box-multiple
tab: Main
style: row
columns: 2
columns: 4
Games:
icon: mdi-gamepad-square
tab: Main
style: row
columns: 1
columns: 4
# Bookmarks. Listed here for the tab pin above all else — without it this
# group appears on every tab. `style: row` also turns the eight entries
# from full-width stacked bars into a compact grid.
@@ -141,13 +158,14 @@ layout:
icon: mdi-image-multiple
tab: AI
style: row
columns: 2
columns: 4
# Mixed: YT Voice Clipper has an audition console, Parakeet is an API.
# Scriberr joined here 2026-08-24 (it was the stray `AI Systems` group).
AI - Audio Tools:
icon: mdi-waveform
tab: AI
style: row
columns: 2
columns: 4
# Below here: model seats whose href is a vLLM `/docs` page. Status at a
# glance is the whole value; you consume these through the gateway, not by
# clicking them.
@@ -160,7 +178,7 @@ layout:
icon: mdi-scale-balance
tab: AI
style: row
columns: 5
columns: 4
AI - Speech (TTS):
icon: mdi-account-voice
tab: AI
@@ -183,12 +201,12 @@ layout:
icon: si-proxmox
tab: Infrastructure
style: row
columns: 3
columns: 4
Infra - IRV:
icon: mdi-brain
tab: Infrastructure
style: row
columns: 2
columns: 4
Infra - ESH:
icon: si-proxmox
tab: Infrastructure
+191 -22
View File
@@ -101,6 +101,39 @@
--shadow-1: 0 1px 2px rgba(10, 12, 18, 0.4);
--dur-fast: 120ms;
--ease-out: cubic-bezier(0.2, 0.8, 0.2, 1);
/* Width the status cluster is allowed to occupy in a card's top-right.
Load-bearing in two places that must agree: the right inset reserved on
the title line, and the negative margin the description uses to opt back
out of it. Sized for the widest real cluster — a latency badge plus a
status word ("21 ms ● RUNNING") — plus the 14px card inset. */
--status-gutter: 96px;
}
/* ---- Icon colour ---------------------------------------------------------
Homepage draws every mdi and simple-icons glyph as a MASK over a gradient
fill, so this ramp IS the icon colour — `color:` does nothing to them, and
a filter only muddies them. Stock is slate-400 -> slate-700, and that dark
stop sinks the bottom half of every glyph into the card fill: at a glance
the rail reads as a column of grey smudges rather than as icons. Ice ->
aurora blue keeps both ends clear of --bg-1 and ties the rail to the tab
accent. Space-separated channels, not hex — Homepage consumes them as
`rgb(var(--color-logo-start))`.
⚠ NOT `:root`. Homepage sets these same two variables on `.theme-slate`,
and that class sits on the <html> element — the very element `:root`
matches. Same element, so the cascade decides, and `.theme-slate` (0,1,0)
outranks `:root` (0,0,1): a `:root` override here is simply ignored, which
is how the first attempt at this silently did nothing. `html[class]` is
(0,1,1) and wins on the same element without hard-coding which `theme-*`
class is active, so it survives a change to `color:` in settings.yaml.
Raising specificity is not enough on its own either — a custom property is
resolved from the NEAREST ancestor that sets it, so the override has to
land on <html> too, not on <body>. */
html[class] {
--color-logo-start: 179 203 207; /* #b3cbcf aus-bright-80 */
--color-logo-stop: 99 136 216; /* #6388d8 aus-blue */
}
/* ---- Canvas --------------------------------------------------------------
@@ -138,17 +171,38 @@ body {
use it often. It replaces the oversized sans heading, and a hairline runs
from the label to the right edge so the groups read as register bands
rather than as titles floating over a grid. */
.services-group > button.group {
/* BOOKMARK GROUPS GET THE SAME TREATMENT. A bookmark group is a different
component with a parallel class family (`.bookmark-group`,
`h2.bookmark-group-name`, `.bookmark-group-icon`) and none of the selectors
below matched it, so UltraSeedbox rendered with Homepage's stock 20px sans
heading and a full-colour icon while every service group above it wore the
mono eyebrow. One un-themed heading in a column of themed ones is more
conspicuous than the whole page being un-themed. */
/* 22px, not 10px. Now that every group is the same four-across grid (see the
columns note in settings.yaml), the only thing separating one band from the
next is this gap — with card width no longer changing at the boundary, a
10px gap let two groups read as one long run of cards. */
.services-group > button.group,
.bookmark-group > button.group {
gap: 0;
margin-bottom: 4px;
margin-top: 10px;
margin-top: 22px;
}
.service-group-icon {
/* …except the first group on a tab, which should sit up against the tab bar
rather than floating 22px below it. */
.services-group:first-of-type > button.group {
margin-top: 6px;
}
.service-group-icon,
.bookmark-group-icon {
display: none !important;
}
h2.service-group-name {
h2.service-group-name,
h2.bookmark-group-name {
font-family: var(--font-mono) !important;
font-size: 11px !important;
font-weight: 500 !important;
@@ -157,9 +211,11 @@ h2.service-group-name {
letter-spacing: var(--tracking-caps);
color: var(--aus-cyan) !important;
white-space: nowrap;
display: flex;
}
.services-group > button.group::after {
.services-group > button.group::after,
.bookmark-group > button.group::after {
content: "";
flex: 1;
height: 1px;
@@ -168,7 +224,8 @@ h2.service-group-name {
}
/* The chevron sits after the rule, muted. */
.services-group > button.group svg {
.services-group > button.group svg,
.bookmark-group > button.group svg {
color: var(--fg-muted);
margin-left: 8px;
width: 14px;
@@ -200,7 +257,14 @@ li.service > .service-card:hover {
border-color: var(--border-default);
}
/* Service name — display face, tight. Description — body face, secondary. */
/* Service name — display face, tight. Description — body face, secondary.
*
* ⚠ `.service-name` IS NOT THE TITLE. Homepage puts the title in as a bare
* text node and then nests `<p class="service-description">` as a sibling
* inside the SAME div, so every rule here lands on a box containing both. The
* title has no element of its own and cannot be selected — which is why it
* cannot be given its own truncation, and why the description needs an
* explicit escape hatch from anything the title needs. */
.service-name {
font-family: var(--font-display) !important;
font-size: 14px !important;
@@ -211,12 +275,24 @@ li.service > .service-card:hover {
line-height: 17px !important;
letter-spacing: -0.015em;
color: var(--fg-0) !important;
/* Reserve the status gutter. Matches --status-gutter below; a long name
ellipsises into it instead of colliding with the pill. */
padding-right: 78px !important;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
/* THE GUTTER MUST BE HELD BY WRAPPING, NOT BY CLIPPING.
This rule used to read `padding-right: 78px; overflow: hidden;
text-overflow: ellipsis; white-space: nowrap` and it did not work: CSS
clips overflow at the PADDING box, not the content box, so the reserved
78px was spill room the title printed straight through. Every long name on
the AI tab rendered underneath its own status pill — "char-rp (MeroMero-v2
NVFP4, multimodal)" collided with HEALTHY, "char-rp-reasoning (Heretic2
NVFP4+MTP)" with EXITED. The ellipsis never appeared either: it is painted
by the block whose own line overflows, and that block here is the
anonymous box around the text node, which does not carry the `overflow`.
Wrapping has neither problem — line breaking respects the content box, so
the gutter genuinely holds. Long names take two lines instead of one; on
the AI tab nearly every name in a group is long, so those rows stay flush
with each other. */
padding-right: var(--status-gutter) !important;
white-space: normal;
overflow: visible;
}
p.service-description {
@@ -226,13 +302,31 @@ p.service-description {
line-height: 17px !important;
color: var(--fg-3) !important;
margin-top: 3px;
/* Two lines minimum. Cards in a row now bottom-align for the common
one-line/two-line mix, which is what made the grid look ragged, without
bringing back equal-height stretching — that inflated short cards to
match a widget card twice their height, which was far worse. Descriptions
longer than two lines still grow; nothing is truncated. */
min-height: 34px;
white-space: normal;
/* Opt back out of the title's gutter. The status cluster is absolutely
positioned over the FIRST line only (top: 13px, height: 17px, so it
occupies y 13-30); the description never starts above y 33. Nothing can
collide, so it takes the full card width back and the gutter costs no
description space at all. */
margin-right: calc(8px - var(--status-gutter));
/* Floor of two lines, ceiling of three.
The floor keeps the common one-line/two-line mix bottom-aligned, which is
most of what made rows look ragged. The ceiling is the other half: a
handful of four- and five-line descriptions (The Booth, Waterland Studio,
the vLLM seats) were dragging their entire row 30-50px taller than every
card beside them, and at four cards across that gap is the most visible
defect on the page. Three lines truncates almost nothing in practice — it
bites only the few entries whose description had already outgrown a
dashboard card and belongs in the service's README instead.
Not equal-heights: that inflates short cards into hollow boxes to match a
widget card twice their height, which is worse than a ragged edge. */
min-height: 34px;
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
}
.service-icon {
@@ -346,11 +440,15 @@ p.service-description {
gap: 0 !important;
}
/* Padding trimmed from `8px 4px 6px` on 2026-08-24. Every stat strip adds its
full height to its card and to nothing else in the row, so at four cards
across one AdGuard card opens a void beside three ordinary ones. The strip
cannot be made free, but it can be made small. */
.service-container > div {
background: transparent !important;
border-radius: 0 !important;
margin: 0 !important;
padding: 8px 4px 6px !important;
padding: 6px 4px 5px !important;
border-left: 1px solid var(--border-subtle);
}
@@ -359,10 +457,12 @@ p.service-description {
}
/* value — nowrap because a long one (AdGuard's "32.767 ms") otherwise wraps
mid-cell and drags the whole card taller than its row-mates. */
mid-cell and drags the whole card taller than its row-mates.
15px, down from 17px: at four cards across, AdGuard's four-cell strip has
~72px per cell and "130,253" no longer fits at 17px. */
.service-container > div > div:not(.uppercase) {
font-family: var(--font-display) !important;
font-size: 17px !important;
font-size: 15px !important;
font-weight: 600 !important;
letter-spacing: -0.015em;
line-height: 1.2 !important;
@@ -385,6 +485,75 @@ p.service-description {
margin-top: 2px;
}
/* Trailing widget rows — Jellyfin's "No Active Streams" strip and its empty
second row, which Homepage emits AFTER .service-container as its own block
of Tailwind-tinted pills. Untouched they broke out of the card's language:
two light grey bars with their own radius hanging under an otherwise
hairline-ruled footer. Same footer vocabulary, so the card stays one object.
Not hidden — "no active streams" is the answer to the question the card is
there to answer. */
li.service .service-container ~ div > div {
background: transparent !important;
border-radius: 0 !important;
border-top: 1px solid var(--border-subtle);
height: auto !important;
min-height: 16px;
margin-top: 0 !important;
}
li.service .service-container ~ div span {
position: static !important;
display: block;
padding: 3px 4px 2px !important;
margin: 0 !important;
font-family: var(--font-mono) !important;
font-size: 9px !important;
letter-spacing: var(--tracking-caps);
text-transform: uppercase;
color: var(--fg-3) !important;
}
/* ---- Bookmarks -----------------------------------------------------------
Same card language as a service: opaque fill, hairline, one soft shadow. The
name takes the display face and the description drops to mono metadata at
tertiary contrast, so a bookmark row reads as a quieter sibling of a service
card rather than as a different widget that wandered in. */
li.bookmark > a {
background: var(--bg-1) !important;
border: 1px solid var(--border-subtle);
border-radius: var(--radius-lg) !important;
box-shadow: var(--shadow-1) !important;
margin-bottom: 8px !important;
overflow: hidden;
transition: background-color var(--dur-fast) var(--ease-out),
border-color var(--dur-fast) var(--ease-out);
}
li.bookmark > a:hover {
background: var(--bg-2) !important;
border-color: var(--border-default);
}
.bookmark-icon {
background: transparent !important;
border-radius: 0 !important;
border-right: 1px solid var(--border-subtle);
}
.bookmark-name {
font-family: var(--font-display) !important;
font-size: 13px !important;
font-weight: 500 !important;
color: var(--fg-0) !important;
}
.bookmark-description {
font-family: var(--font-mono) !important;
font-size: 10px !important;
letter-spacing: 0.02em;
color: var(--fg-3) !important;
}
/* ---- Tab bar -------------------------------------------------------------
The filled pill is replaced by a 2px top edge in aurora blue — canon's one
sanctioned accent-border pattern (and explicitly never a left border). */
+7 -1
View File
@@ -90,7 +90,13 @@ services:
networks:
- tnet
labels:
- homepage.group=AI Systems
# `AI - Audio Tools`, not `AI Systems`. The group name has to match a key
# in the dashboard's settings.yaml `layout:` block — a group that appears
# nowhere in that block gets no `tab:` assignment, and Homepage renders an
# untabbed group on EVERY tab. `AI Systems` did exactly that from
# 2026-08-23 until it was caught; transcription belongs with the other ASR
# seats anyway (Parakeet, Speaches).
- homepage.group=AI - Audio Tools
- homepage.name=Scriberr
- homepage.icon=mdi-microphone-message
- homepage.description=Audio/video transcription + diarization (ana-ml2, GPU1)