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)