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

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

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

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

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

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

Verified with Playwright against the live board — per-group card width, card
height spread, and a geometric title-vs-status collision check, before and
after. Before/after captures: http://10.100.10.50:8090/b/homepage-relayout/
This commit is contained in:
vh
2026-08-24 08:15:02 -07:00
parent 32349b7653
commit f6f2f69649
8 changed files with 623 additions and 77 deletions
+191 -22
View File
@@ -121,6 +121,39 @@
--shadow-1: 0 1px 2px rgba(10, 12, 18, 0.4);
--dur-fast: 120ms;
--ease-out: cubic-bezier(0.2, 0.8, 0.2, 1);
/* Width the status cluster is allowed to occupy in a card's top-right.
Load-bearing in two places that must agree: the right inset reserved on
the title line, and the negative margin the description uses to opt back
out of it. Sized for the widest real cluster — a latency badge plus a
status word ("21 ms ● RUNNING") — plus the 14px card inset. */
--status-gutter: 96px;
}
/* ---- Icon colour ---------------------------------------------------------
Homepage draws every mdi and simple-icons glyph as a MASK over a gradient
fill, so this ramp IS the icon colour — `color:` does nothing to them, and
a filter only muddies them. Stock is slate-400 -> slate-700, and that dark
stop sinks the bottom half of every glyph into the card fill: at a glance
the rail reads as a column of grey smudges rather than as icons. Ice ->
aurora blue keeps both ends clear of --bg-1 and ties the rail to the tab
accent. Space-separated channels, not hex — Homepage consumes them as
`rgb(var(--color-logo-start))`.
⚠ NOT `:root`. Homepage sets these same two variables on `.theme-slate`,
and that class sits on the <html> element — the very element `:root`
matches. Same element, so the cascade decides, and `.theme-slate` (0,1,0)
outranks `:root` (0,0,1): a `:root` override here is simply ignored, which
is how the first attempt at this silently did nothing. `html[class]` is
(0,1,1) and wins on the same element without hard-coding which `theme-*`
class is active, so it survives a change to `color:` in settings.yaml.
Raising specificity is not enough on its own either — a custom property is
resolved from the NEAREST ancestor that sets it, so the override has to
land on <html> too, not on <body>. */
html[class] {
--color-logo-start: 179 203 207; /* #b3cbcf aus-bright-80 */
--color-logo-stop: 99 136 216; /* #6388d8 aus-blue */
}
/* ---- Canvas --------------------------------------------------------------
@@ -158,17 +191,38 @@ body {
use it often. It replaces the oversized sans heading, and a hairline runs
from the label to the right edge so the groups read as register bands
rather than as titles floating over a grid. */
.services-group > button.group {
/* BOOKMARK GROUPS GET THE SAME TREATMENT. A bookmark group is a different
component with a parallel class family (`.bookmark-group`,
`h2.bookmark-group-name`, `.bookmark-group-icon`) and none of the selectors
below matched it, so UltraSeedbox rendered with Homepage's stock 20px sans
heading and a full-colour icon while every service group above it wore the
mono eyebrow. One un-themed heading in a column of themed ones is more
conspicuous than the whole page being un-themed. */
/* 22px, not 10px. Now that every group is the same four-across grid (see the
columns note in settings.yaml), the only thing separating one band from the
next is this gap — with card width no longer changing at the boundary, a
10px gap let two groups read as one long run of cards. */
.services-group > button.group,
.bookmark-group > button.group {
gap: 0;
margin-bottom: 4px;
margin-top: 10px;
margin-top: 22px;
}
.service-group-icon {
/* …except the first group on a tab, which should sit up against the tab bar
rather than floating 22px below it. */
.services-group:first-of-type > button.group {
margin-top: 6px;
}
.service-group-icon,
.bookmark-group-icon {
display: none !important;
}
h2.service-group-name {
h2.service-group-name,
h2.bookmark-group-name {
font-family: var(--font-mono) !important;
font-size: 11px !important;
font-weight: 500 !important;
@@ -177,9 +231,11 @@ h2.service-group-name {
letter-spacing: var(--tracking-caps);
color: var(--aus-cyan) !important;
white-space: nowrap;
display: flex;
}
.services-group > button.group::after {
.services-group > button.group::after,
.bookmark-group > button.group::after {
content: "";
flex: 1;
height: 1px;
@@ -188,7 +244,8 @@ h2.service-group-name {
}
/* The chevron sits after the rule, muted. */
.services-group > button.group svg {
.services-group > button.group svg,
.bookmark-group > button.group svg {
color: var(--fg-muted);
margin-left: 8px;
width: 14px;
@@ -220,7 +277,14 @@ li.service > .service-card:hover {
border-color: var(--border-default);
}
/* Service name — display face, tight. Description — body face, secondary. */
/* Service name — display face, tight. Description — body face, secondary.
*
* ⚠ `.service-name` IS NOT THE TITLE. Homepage puts the title in as a bare
* text node and then nests `<p class="service-description">` as a sibling
* inside the SAME div, so every rule here lands on a box containing both. The
* title has no element of its own and cannot be selected — which is why it
* cannot be given its own truncation, and why the description needs an
* explicit escape hatch from anything the title needs. */
.service-name {
font-family: var(--font-display) !important;
font-size: 14px !important;
@@ -231,12 +295,24 @@ li.service > .service-card:hover {
line-height: 17px !important;
letter-spacing: -0.015em;
color: var(--fg-0) !important;
/* Reserve the status gutter. Matches --status-gutter below; a long name
ellipsises into it instead of colliding with the pill. */
padding-right: 78px !important;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
/* THE GUTTER MUST BE HELD BY WRAPPING, NOT BY CLIPPING.
This rule used to read `padding-right: 78px; overflow: hidden;
text-overflow: ellipsis; white-space: nowrap` and it did not work: CSS
clips overflow at the PADDING box, not the content box, so the reserved
78px was spill room the title printed straight through. Every long name on
the AI tab rendered underneath its own status pill — "char-rp (MeroMero-v2
NVFP4, multimodal)" collided with HEALTHY, "char-rp-reasoning (Heretic2
NVFP4+MTP)" with EXITED. The ellipsis never appeared either: it is painted
by the block whose own line overflows, and that block here is the
anonymous box around the text node, which does not carry the `overflow`.
Wrapping has neither problem — line breaking respects the content box, so
the gutter genuinely holds. Long names take two lines instead of one; on
the AI tab nearly every name in a group is long, so those rows stay flush
with each other. */
padding-right: var(--status-gutter) !important;
white-space: normal;
overflow: visible;
}
p.service-description {
@@ -246,13 +322,31 @@ p.service-description {
line-height: 17px !important;
color: var(--fg-3) !important;
margin-top: 3px;
/* Two lines minimum. Cards in a row now bottom-align for the common
one-line/two-line mix, which is what made the grid look ragged, without
bringing back equal-height stretching — that inflated short cards to
match a widget card twice their height, which was far worse. Descriptions
longer than two lines still grow; nothing is truncated. */
min-height: 34px;
white-space: normal;
/* Opt back out of the title's gutter. The status cluster is absolutely
positioned over the FIRST line only (top: 13px, height: 17px, so it
occupies y 13-30); the description never starts above y 33. Nothing can
collide, so it takes the full card width back and the gutter costs no
description space at all. */
margin-right: calc(8px - var(--status-gutter));
/* Floor of two lines, ceiling of three.
The floor keeps the common one-line/two-line mix bottom-aligned, which is
most of what made rows look ragged. The ceiling is the other half: a
handful of four- and five-line descriptions (The Booth, Waterland Studio,
the vLLM seats) were dragging their entire row 30-50px taller than every
card beside them, and at four cards across that gap is the most visible
defect on the page. Three lines truncates almost nothing in practice — it
bites only the few entries whose description had already outgrown a
dashboard card and belongs in the service's README instead.
Not equal-heights: that inflates short cards into hollow boxes to match a
widget card twice their height, which is worse than a ragged edge. */
min-height: 34px;
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
}
.service-icon {
@@ -366,11 +460,15 @@ p.service-description {
gap: 0 !important;
}
/* Padding trimmed from `8px 4px 6px` on 2026-08-24. Every stat strip adds its
full height to its card and to nothing else in the row, so at four cards
across one AdGuard card opens a void beside three ordinary ones. The strip
cannot be made free, but it can be made small. */
.service-container > div {
background: transparent !important;
border-radius: 0 !important;
margin: 0 !important;
padding: 8px 4px 6px !important;
padding: 6px 4px 5px !important;
border-left: 1px solid var(--border-subtle);
}
@@ -379,10 +477,12 @@ p.service-description {
}
/* value — nowrap because a long one (AdGuard's "32.767 ms") otherwise wraps
mid-cell and drags the whole card taller than its row-mates. */
mid-cell and drags the whole card taller than its row-mates.
15px, down from 17px: at four cards across, AdGuard's four-cell strip has
~72px per cell and "130,253" no longer fits at 17px. */
.service-container > div > div:not(.uppercase) {
font-family: var(--font-display) !important;
font-size: 17px !important;
font-size: 15px !important;
font-weight: 600 !important;
letter-spacing: -0.015em;
line-height: 1.2 !important;
@@ -405,6 +505,75 @@ p.service-description {
margin-top: 2px;
}
/* Trailing widget rows — Jellyfin's "No Active Streams" strip and its empty
second row, which Homepage emits AFTER .service-container as its own block
of Tailwind-tinted pills. Untouched they broke out of the card's language:
two light grey bars with their own radius hanging under an otherwise
hairline-ruled footer. Same footer vocabulary, so the card stays one object.
Not hidden — "no active streams" is the answer to the question the card is
there to answer. */
li.service .service-container ~ div > div {
background: transparent !important;
border-radius: 0 !important;
border-top: 1px solid var(--border-subtle);
height: auto !important;
min-height: 16px;
margin-top: 0 !important;
}
li.service .service-container ~ div span {
position: static !important;
display: block;
padding: 3px 4px 2px !important;
margin: 0 !important;
font-family: var(--font-mono) !important;
font-size: 9px !important;
letter-spacing: var(--tracking-caps);
text-transform: uppercase;
color: var(--fg-3) !important;
}
/* ---- Bookmarks -----------------------------------------------------------
Same card language as a service: opaque fill, hairline, one soft shadow. The
name takes the display face and the description drops to mono metadata at
tertiary contrast, so a bookmark row reads as a quieter sibling of a service
card rather than as a different widget that wandered in. */
li.bookmark > a {
background: var(--bg-1) !important;
border: 1px solid var(--border-subtle);
border-radius: var(--radius-lg) !important;
box-shadow: var(--shadow-1) !important;
margin-bottom: 8px !important;
overflow: hidden;
transition: background-color var(--dur-fast) var(--ease-out),
border-color var(--dur-fast) var(--ease-out);
}
li.bookmark > a:hover {
background: var(--bg-2) !important;
border-color: var(--border-default);
}
.bookmark-icon {
background: transparent !important;
border-radius: 0 !important;
border-right: 1px solid var(--border-subtle);
}
.bookmark-name {
font-family: var(--font-display) !important;
font-size: 13px !important;
font-weight: 500 !important;
color: var(--fg-0) !important;
}
.bookmark-description {
font-family: var(--font-mono) !important;
font-size: 10px !important;
letter-spacing: 0.02em;
color: var(--fg-3) !important;
}
/* ---- Tab bar -------------------------------------------------------------
The filled pill is replaced by a 2px top edge in aurora blue — canon's one
sanctioned accent-border pattern (and explicitly never a left border). */