From f6f2f696496f854fb85ae69145d3e2e077594d26 Mon Sep 17 00:00:00 2001 From: Vuong Hoang Date: Mon, 24 Aug 2026 08:15:02 -0700 Subject: [PATCH] fix(homepage): uniform 4-column grid, hold the status gutter, unleak AI Systems MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 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/ --- CLAUDE.md | 21 +- .../2026-08-24-homepage-uniform-grid.md | 118 ++++++++++ persistent-memory.md | 5 +- stacks/homepage/README.md | 56 ++++- stacks/homepage/conf/custom.css | 213 ++++++++++++++++-- stacks/homepage/conf/settings.yaml | 66 ++++-- stacks/homepage/theme/australis.css.in | 213 ++++++++++++++++-- stacks/scriberr/compose.yaml | 8 +- 8 files changed, 623 insertions(+), 77 deletions(-) create mode 100644 persistent-memory.d/2026-08-24-homepage-uniform-grid.md diff --git a/CLAUDE.md b/CLAUDE.md index b735024..a526e4b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -98,15 +98,32 @@ Observed and standardized across servers: - **Named volumes** for service state (pattern: `_`) - **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= - homepage.name= - homepage.icon=mdi- - homepage.description= - homepage.href=http://: ``` + ⚠ **`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 `, not `restart`. - **Healthchecks** on services that expose HTTP ## Servers diff --git a/persistent-memory.d/2026-08-24-homepage-uniform-grid.md b/persistent-memory.d/2026-08-24-homepage-uniform-grid.md new file mode 100644 index 0000000..5fec4c2 --- /dev/null +++ b/persistent-memory.d/2026-08-24-homepage-uniform-grid.md @@ -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 + `` 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 ``, not on ``. +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. diff --git a/persistent-memory.md b/persistent-memory.md index aa660e0..2ebc10f 100644 --- a/persistent-memory.md +++ b/persistent-memory.md @@ -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 `` 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` diff --git a/stacks/homepage/README.md b/stacks/homepage/README.md index 24cffeb..2555a2d 100644 --- a/stacks/homepage/README.md +++ b/stacks/homepage/README.md @@ -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 ``, 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 diff --git a/stacks/homepage/conf/custom.css b/stacks/homepage/conf/custom.css index 7606e02..d887a22 100644 --- a/stacks/homepage/conf/custom.css +++ b/stacks/homepage/conf/custom.css @@ -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 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 too, not on . */ +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 `

` 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). */ diff --git a/stacks/homepage/conf/settings.yaml b/stacks/homepage/conf/settings.yaml index 14472b7..312588c 100644 --- a/stacks/homepage/conf/settings.yaml +++ b/stacks/homepage/conf/settings.yaml @@ -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 diff --git a/stacks/homepage/theme/australis.css.in b/stacks/homepage/theme/australis.css.in index 13b3a99..b06e393 100644 --- a/stacks/homepage/theme/australis.css.in +++ b/stacks/homepage/theme/australis.css.in @@ -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 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 too, not on . */ +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 `

` 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). */ diff --git a/stacks/scriberr/compose.yaml b/stacks/scriberr/compose.yaml index 2fd44cb..d4058b1 100644 --- a/stacks/scriberr/compose.yaml +++ b/stacks/scriberr/compose.yaml @@ -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)