feat(homepage): rebuild the theme on canonical Australis tokens

The predecessor theme was ugly for two structural reasons, not one.

It did not use the design system's colours. It built a parallel OKLCH
palette "derived from the Australis philosophy" and swapped the canonical
typeface for Supreme -- a fork, not a theme. Every hex here is now copied
verbatim from ~/.claude/skills/australis-design/colors_and_type.css, and
build.py re-checks all 19 against that file at build time and warns on
drift so it cannot quietly fork again. Type is the canonical stack: Space
Grotesk / Inter / JetBrains Mono, vendored as latin-subset VARIABLE woff2
(one file per family, 102 KB total against 56 KB for three static Supreme
cuts, and no Google Fonts request at page load).

It also carried a generated full-bleed aurora image behind the entire
dashboard. Canon forbids exactly that -- "solid fills only on chrome, no
full-bleed photography, no decorative gradients", and the aurora motif
"never as a background fill behind text". The predecessor knew, said so in
its own header, and dialled the opacity down rather than dropping it. The
image is gone; the aurora survives as a 1px accent edge under the tab bar,
which is where canon sanctions it. The asset stays in images/ in case it
is ever revisited.

Direction is instrument panel. Group headings become the Australis mono
eyebrow with a hairline to the right edge -- canon calls the eyebrow a
system signature, and it turns the groups into register bands instead of
headings floating over a grid. Status stops shouting: the filled emerald
chips read louder than the service names they annotated, so they are now a
semantic dot plus a mono micro-label at tertiary contrast. Cards are
bordered and opaque, per canon's border-over-shadow rule for chrome.

Alignment, per operator feedback that pills and cards did not line up:

- The status cluster is centred on the service name's line rather than
  parked in the card's top-right corner, where Homepage's `absolute top-0`
  left it floating ~7px above the title's optical centre. The offsets
  reconstruct the title line box and are documented as moving together.
- Descriptions get a two-line minimum, so the common one-line/two-line mix
  bottom-aligns across a row. This is what made the grid look ragged.
  useEqualHeights stays false: it inflated short cards to match a widget
  card twice their height, which was the worse failure.
- The status dot is flex-centred rather than nudged with vertical-align,
  so it stays centred if the type scale changes.

Retires the Skyfall sources and the Supreme faces; theme/ now has one
source of truth.
This commit is contained in:
vh
2026-08-19 09:14:13 -07:00
parent b92097688c
commit b271db1f44
14 changed files with 1076 additions and 1369 deletions
-357
View File
@@ -1,357 +0,0 @@
/* Homepage — Australis Skyfall theme.
*
* SOURCE OF TRUTH: this file plus `theme/colors.css`, `theme/layout.css` and
* `theme/typography.css`, which are vendored VERBATIM from the Australis
* Skyfall handoff bundle. Do not hand-edit `conf/custom.css` — it is
* generated. Run `theme/build.py` after changing anything here.
*
* WHY A BUILD STEP AT ALL: Homepage serves exactly two files out of its
* config directory, `/api/config/custom.css` and `/api/config/custom.js`.
* There is no static route for anything else, so a `@font-face` pointing at
* `theme/fonts/Supreme-400.woff2` would 404 — the face has to arrive inside
* the stylesheet as a data: URI. The build inlines it.
*
* WHICH FACES, AND WHY NOT ALL THREE: Skyfall specifies Bespoke Sans
* (display), Supreme (body/UI) and Victor Mono (code/data). Only Supreme is
* embedded. A link dashboard has no display type — nothing here is a headline
* or a hero number — and Victor Mono ships as 2.4 MB TTF statics per cut,
* which is 30x this whole stylesheet for the handful of latency figures on
* the cards. Both are named in the stacks below so they resolve if they ever
* get vendored.
*
* DEVIATIONS FROM THE SYSTEM, STATED PLAINLY:
*
* 1. Skyfall says "no photography, no textures" for backgrounds, and permits
* exactly one decorative motif: a subtle aurora gradient, on hero and
* empty-state areas ONLY, "never behind body text blocks". This dashboard
* IS a body-text block, and it carries a generated aurora image behind it.
* That is a real departure, taken on the operator's explicit instruction
* to generate a background with Arbo. It is mitigated, not waved away:
* the image is abstract with no subject (Arbo's `t2i-ui-background`
* workflow), strictly cool-temperature per the imagery rule, and pinned
* to low opacity so card contrast is never the thing that suffers.
* If it still reads as busy, the honest fix is to drop the image and let
* `--surface-sunken` be the canvas, which is what the system actually asks
* for.
*
* 2. Homepage renders service icons as full-colour vendor logos. Skyfall
* wants one cool, technical icon family. Not fixable from CSS without
* desaturating every logo into illegibility, so the icons stay as they
* are.
*
* SPECIFICITY: Homepage is Tailwind-generated, so utilities win on equal
* specificity. Every `!important` here is load-bearing; removing one silently
* reverts that rule to the stock theme.
*/
/* @@FONTS@@ */
/* ============================================================
Skyfall tokens — vendored verbatim from the handoff bundle.
============================================================ */
/* @@TOKENS@@ */
/* ============================================================
Homepage bindings — the only part of this file that is ours.
Everything above is Skyfall; everything below maps Skyfall's
semantic layer onto Homepage's DOM. Never hardcode a hex here:
the system's rule is that UI consumes --surface-*/--text-*/
--border-*/--accent*/status tokens, not raw values.
============================================================ */
body,
#page_wrapper,
#page_container {
background-color: var(--surface-sunken) !important;
color: var(--text-body) !important;
font-family: var(--font-body) !important;
font-size: var(--text-base) !important;
line-height: var(--leading-normal) !important;
}
::selection {
background: var(--selection-bg);
color: var(--selection-fg);
}
/* ---------- header chrome ----------
Compact chrome per the negative-space rule: --pad-chrome, not --pad-content. */
#information-widgets .widget-container,
#information-widgets .information-widget-search {
background: var(--surface-card) !important;
border: 1px solid var(--border-subtle) !important;
border-radius: var(--radius-lg) !important;
box-shadow: var(--shadow-sm) !important;
padding: var(--pad-chrome) !important;
}
#information-widgets .information-widget-search input {
background: transparent !important;
color: var(--text-body) !important;
font-family: var(--font-body) !important;
}
#information-widgets .information-widget-search input::placeholder {
color: var(--text-faint) !important;
}
/* Telemetry is data — the system says data is always mono. */
.information-widget-resource .resource-value,
.service-block,
.service-stats {
font-family: var(--font-mono) !important;
font-variant-numeric: tabular-nums !important;
}
/* ---------- widget stat wells (Plex, Jellyfin, Paperless, Uptime Kuma) ----------
Stock Homepage builds each stat as:
<div class="font-thin text-sm">353</div> <- the VALUE
<div class="font-bold text-xs uppercase">Movies</div> <- the LABEL
which is exactly backwards. The number you actually came to read is
weight 100 at 13px; the label shouting beside it is weight 700. Skyfall's
rule is that hierarchy comes emphatically from weight AND size, and that
numbers are the data — so this inverts the pair: the value goes big, bold
and bright, the label drops to a quiet eyebrow.
The well itself is --surface-input, one step DOWN from the card it sits on,
so stats read as inset data rather than as another floating surface. Depth
here is a recess, so it takes the hairline without the shadow. */
.service-block {
background: var(--surface-input) !important;
border: 1px solid var(--border-subtle) !important;
border-radius: var(--radius-sm) !important;
padding: var(--space-2) var(--space-1) !important;
}
/* Sizing note (operator, 2026-08-19): the first pass at this went from
font-thin-13px to bold-22px-in-heading-white, which overshot from "whisper"
straight to "shout". The number only has to out-rank its own label, not the
service name above it. --text-md at medium weight in cyan clears the label
comfortably while sitting BELOW the card title in the hierarchy, which is
where a stat belongs. Resist re-raising this. */
.service-block > div:first-child {
color: var(--secondary-text) !important;
font-family: var(--font-mono) !important;
font-size: var(--text-md) !important;
font-weight: var(--weight-medium) !important;
line-height: var(--leading-tight) !important;
font-variant-numeric: tabular-nums !important;
}
.service-block > div:last-child {
color: var(--text-faint) !important;
font-family: var(--font-body) !important;
font-size: var(--text-2xs) !important;
font-weight: var(--weight-medium) !important;
letter-spacing: var(--tracking-caps) !important;
line-height: var(--leading-snug) !important;
}
/* ---------- tabs ----------
The active rail is one of only two sanctioned accent expressions:
a 2px accent bar plus an --accent-soft fill. No glow — glows are
reserved for hero moments, and a tab is not one. */
#myTab li button {
color: var(--text-muted) !important;
font-family: var(--font-body) !important;
font-weight: var(--weight-medium) !important;
border-radius: var(--radius-md) !important;
transition: color var(--duration-fast) var(--ease-out),
background var(--duration-fast) var(--ease-out) !important;
}
#myTab li button:hover {
color: var(--text-body) !important;
background: var(--accent-soft) !important;
}
#myTab li button[aria-selected="true"] {
background: var(--accent-soft) !important;
color: var(--text-heading) !important;
box-shadow: var(--rail-active) !important;
}
/* ---------- group headers ----------
ALL-CAPS is permitted only for tiny eyebrow labels — this is that case. */
.service-group-name,
.bookmark-group-name {
color: var(--text-faint) !important;
font-family: var(--font-body) !important;
font-size: var(--text-xs) !important;
font-weight: var(--weight-semibold) !important;
letter-spacing: var(--tracking-caps) !important;
text-transform: uppercase !important;
}
/* ---------- the aurora rotation ----------
This is where the colour on this dashboard comes from, and it is sanctioned
rather than smuggled in: Skyfall names Aurora (blue, cyan, green) the
PRIMARY families, "used generously, in that order". What is restricted is
Dawn — amber, red, violet — which stays semantic-only (warning, danger, AI)
and is therefore never used decoratively anywhere in this file.
Section markers cycle blue -> cyan -> green down the page, so a group reads
as a coloured band rather than another row of grey. The icons carry it at
full strength; the names take the same hue at 0.72 so the eyebrow stays an
eyebrow.
Homepage renders mdi icons as a gradient behind an SVG mask, which is why
these are `background` rules and not `color` — setting color does nothing.
Vendor logos (si-*) are real images and keep their brand colours; that mix
is inherent to Homepage's icon model, not a choice made here. */
.services-group:nth-of-type(3n + 1) .service-group-icon > div {
background: linear-gradient(180deg, var(--blue-bright), var(--blue-base)) !important;
}
.services-group:nth-of-type(3n + 2) .service-group-icon > div {
background: linear-gradient(180deg, var(--cyan-bright), var(--cyan-base)) !important;
}
.services-group:nth-of-type(3n + 3) .service-group-icon > div {
background: linear-gradient(180deg, var(--green-bright), var(--green-base)) !important;
}
.services-group:nth-of-type(3n + 1) .service-group-name {
color: var(--blue-bright) !important;
opacity: 0.72 !important;
}
.services-group:nth-of-type(3n + 2) .service-group-name {
color: var(--cyan-bright) !important;
opacity: 0.72 !important;
}
.services-group:nth-of-type(3n + 3) .service-group-name {
color: var(--green-bright) !important;
opacity: 0.72 !important;
}
/* Per-service mdi icons take a single cool wash rather than joining the
rotation — 60-odd cards cycling three hues would be noise, and the card
grid is not the thing being sectioned. */
.service-icon > div {
background: linear-gradient(180deg, var(--sea-80), var(--cyan-base)) !important;
}
/* Header resource icons (CPU / RAM / disk). */
.information-widget-resource .resource-icon {
color: var(--cyan-bright) !important;
}
/* ---------- cards ----------
The depth recipe, and it is not optional: crisp 1px hairline AND a
two-layer soft shadow. Never one without the other on a floating
surface — a card with only a border reads flat, one with only a
shadow reads smudged. */
.service-card,
.bookmark-list li > a {
background: var(--surface-card) !important;
border: 1px solid var(--border-subtle) !important;
border-radius: var(--radius-lg) !important;
box-shadow: var(--shadow-sm) !important;
transition: background var(--duration-fast) var(--ease-out),
border-color var(--duration-fast) var(--ease-out),
box-shadow var(--duration-base) var(--ease-out) !important;
}
/* Hover: surfaces lighten one step, interactive cards lift sm -> md. */
.service-card:hover,
.bookmark-list li > a:hover {
background: var(--surface-raised) !important;
border-color: var(--border-default) !important;
box-shadow: var(--shadow-md) !important;
}
.service-name,
.bookmark-name {
color: var(--text-heading) !important;
font-weight: var(--weight-semibold) !important;
letter-spacing: var(--tracking-normal) !important;
}
.service-description,
.bookmark-description {
color: var(--text-muted) !important;
font-size: var(--text-sm) !important;
line-height: var(--leading-snug) !important;
}
/* ---------- status ----------
Semantic colour is the whole point: the system communicates status with
colour and icons, never emoji, and accents are semantic-ONLY. Stock
Homepage paints every pill the same slate, which throws away the signal. */
/* .service-block is deliberately NOT in this list — the stat wells get their
own treatment above, and folding them in here is what made every number
render at --text-2xs. */
.service-tag,
.service-stats {
background: var(--surface-raised) !important;
border: 1px solid var(--border-subtle) !important;
border-radius: var(--radius-md) !important;
color: var(--text-muted) !important;
font-size: var(--text-2xs) !important;
}
/* ⚠️ The real class Homepage emits is `docker-status-<state>`, NOT
`status-<state>`. The bare `.status-*` selectors below were written from a
guess and matched nothing — the green pills in the first screenshots were
stock Homepage colouring, not this file. They are kept only because the
site-monitor path does use them; the docker-status ones are what actually
bind on container cards. Verify against the DOM before adding more. */
.docker-status-healthy,
.docker-status-running,
.docker-status-started,
.status-online,
.status-healthy,
.status-running {
color: var(--success-text) !important;
background: var(--success-soft) !important;
}
.docker-status-unhealthy,
.docker-status-dead,
.status-offline,
.status-unhealthy,
.status-error {
color: var(--danger-text) !important;
background: var(--danger-soft) !important;
}
.docker-status-exited,
.docker-status-paused,
.docker-status-created,
.status-exited,
.status-paused,
.status-partial,
.status-unknown,
.status-not-found {
color: var(--warning-text) !important;
background: var(--warning-soft) !important;
}
/* Latency / reachability readings are telemetry, not status — giving them the
info family keeps "how fast" visually separate from "is it alive", which
otherwise both render as the same pill in the same corner. */
.service-tag.service-site-monitor,
.site-monitor-status,
.service-tag.service-ping {
color: var(--info-text) !important;
background: var(--info-soft) !important;
}
/* ---------- focus ---------- */
a:focus-visible,
button:focus-visible,
input:focus-visible {
outline: none !important;
box-shadow: var(--focus-ring) !important;
border-radius: var(--radius-sm) !important;
}
/* ---------- motion ----------
"Respect reduced-motion" is in the system's motion rule, not an extra. */
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
transition-duration: 0.01ms !important;
animation-duration: 0.01ms !important;
}
}