feat(homepage): rebuild on Australis Skyfall — dual theme, light mode shipped

The board was on the Australis TERMINAL palette, which is dark-only by design
("Always dark first. No light mode in this system"). Skyfall is the dual-theme
web derivative of the same science, and its bundle turned out to be sitting in
this repo's own git history: a predecessor vendored it on 2026-08-19 and a
later commit deleted it. `git show 45c1995:...` returns colors.css with both
`:root` (dark) and `[data-theme="light"]` (Skyfall Day) intact, plus the
calm-depth layout tokens, the typography scale and Supreme 400/500/700. So the
light ramp is canonical rather than derived, which was the entire objection to
building one.

The visual language moves with the palette. Depth is now the recipe and not a
choice — every elevated surface carries a 1px hairline AND a two-layer shadow,
never one without the other. Radii move to Skyfall's scale, cards at
--radius-lg. Widget stat values move from the display face to mono, because
Skyfall is explicit that numbers and telemetry are always --font-mono. The
full-width aurora ribbon under the tab bar is gone: Skyfall sanctions exactly
two accent expressions, the active rail and hero-only glows, and a decorative
gradient across the chrome is neither — so the colour it carried now lands on
the active tab as a 2px accent bar plus an --accent-soft fill, which is the
rail. Every binding is written against the semantic layer; there are no raw
family tokens and no colour literals left in our own file.

build.py now guards the vendoring instead of advising it. The three token files
are hashed and a mismatch FAILS the build — a vendored file is either
byte-identical to the bundle or it is a fork wearing the bundle's name, and the
theme this one replaces had to be torn out twice for exactly that.

⚠ Homepage's own theme toggle is unreachable, and reaching for it breaks the
dashboard. It renders only when settings.yaml leaves `theme:` unpinned, and
with the key absent the page's data loader throws and its catch branch serves
`initialSettings: {}` — no tab bar, no layout, no i18n. Six force-recreates
over seven minutes all came up empty; restoring `theme: dark` rendered
correctly on the next recreate in 12 seconds, while /api/services returned 200
with fully correct content the whole time. That is the first confirmed cause of
the long-running "tab bar goes missing after a recreate" symptom, and it also
retires the homepage.log-size lead recorded earlier today: rolling the log
aside did nothing during this episode, so that coincidence was intermittency.

So the toggle is ours. conf/custom.js renders it and stores the choice;
build.py re-emits each vendored light block twice, once for an explicit
`data-theme` and once inside a prefers-color-scheme media query scoped to
`html:not([data-theme="dark"]):not([data-theme="light"])` — that :not() pair is
what lets a stored dark choice survive a light-mode OS. Verified against both
OS preferences: load, click, click back, reload, all four correct. `data-theme`
is the control surface; Homepage's own `dark` class stays on <html> and does
not fight, because our rules carry !important on the surfaces Tailwind's
`dark:` variants would otherwise claim.

Two font substitutions, both documented rather than silent: Space Grotesk for
Bespoke Sans and JetBrains Mono for Victor Mono. Only Supreme was ever vendored
here and Skyfall's own notes call Victor Mono user-supplied, so this is a
two-line swap when the real faces arrive.

Dark and light, all four tabs: http://10.100.10.50:8090/b/homepage-skyfall/
This commit is contained in:
vh
2026-08-24 09:44:45 -07:00
parent 39da1d4a97
commit 35adc4a043
15 changed files with 2306 additions and 1105 deletions
+84 -10
View File
@@ -202,12 +202,25 @@ Two consequences worth knowing:
was observed on 2026-08-24: catch branch demonstrably taken, and not one
`index`-tagged line in `docker logs` or `conf/homepage/logs/homepage.log`.
### ONE CAUSE IS NOW KNOWN: a missing `theme:` key
**Removing `theme:` from `settings.yaml` reproduces this deterministically.**
Six force-recreates over seven minutes all served `initialSettings":{}` with the
key absent; restoring `theme: dark` rendered correctly on the next recreate in
12 seconds (2026-08-24). So the loader really can be thrown by config — just not
by the parts you would suspect, and never with a message.
That does **not** explain every occurrence: the same symptom has appeared with
`theme:` present and correct. Treat the missing key as one confirmed trigger,
not the whole story.
### What it is NOT — ruled out by measurement, don't re-run these
- **Not the config.** `/api/services`, `/api/bookmarks`, `/api/widgets` and
`/api/hash` all return **200 with fully correct content** while the page
serves `initialSettings":{}` — including the brand-new group structure, in the
right order. Every input the loader awaits works when called directly.
- **Not a downstream data failure.** `/api/services`, `/api/bookmarks`,
`/api/widgets` and `/api/hash` all return **200 with fully correct content**
while the page serves `initialSettings":{}` — including the brand-new group
structure, in the right order. Every input the loader awaits works when called
directly.
- **Not the 2026-08-24 layout rewrite.** Restoring the *previous, known-good*
`settings.yaml` and recreating reproduced the empty payload identically. (This
matches the 2026-08-19 finding that the pre-adoption backup config reproduces
@@ -215,6 +228,10 @@ Two consequences worth knowing:
- **Not `/api/validate`,** which returns `[]` throughout.
- **Not disk, not permissions.** 206 GB free; the container runs as root and a
write test into `/app/config/logs` succeeds.
- **Probably not the log file.** Rolling the 8.6 MB `homepage.log` aside once
coincided with an immediate recovery, which looked like a lead — but the same
move did nothing during the `theme:`-key episode. Recorded so nobody chases
it twice; the coincidence was almost certainly just the intermittency.
### Timing, measured rather than assumed
@@ -231,12 +248,11 @@ got spent in 2026-08-19 ruling out four causes that were never the cause (the
config, the v2.0.0 release, `PUID`/`PGID` and Docker discovery, and the server
side). Every one of those remains ruled out.
**Next lead, for whoever picks this up:** move the `logger("index")` hypothesis
forward. The winston file logger writes to `conf/homepage/logs/homepage.log`,
which had grown to 8.6 MB and stopped being appended to at the same time the
render started failing. Rolling it aside is a one-liner and is the cheapest
thing left to try:
`sudo mv /opt/docker/conf/homepage/logs/homepage.log{,.rolled}` then recreate.
**First thing to check, now that one cause is confirmed:** diff `settings.yaml`
against the last version that rendered. A key that Homepage's loader needs and
cannot find will do this silently — `theme:` is the one we know about, and
there may be others. `git log -p -- stacks/homepage/conf/settings.yaml` is
faster than any amount of container archaeology.
**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
@@ -316,6 +332,64 @@ theme/build.py inlines the fonts + tokens -> conf/custom.css
**Do not hand-edit `conf/custom.css`.** Change `skyfall.css.in`, run
`python3 stacks/homepage/theme/build.py`, then deploy.
**Do not hand-edit the three vendored files either.** `build.py` records their
SHA-256 and **fails the build** on a mismatch — a vendored file is either
byte-identical to the bundle or it is a fork wearing the bundle's name. Put the
override in `skyfall.css.in`, which is expressed entirely through Skyfall's
semantic layer (`--surface-*`, `--text-*`, `--border-*`, `--success/--danger/
--warning`) and never against a raw family token or a literal colour. That is
not fussiness: the theme this one replaced built a parallel palette "derived
from the philosophy" and had to be torn out twice.
### Light and dark (2026-08-24)
Both themes are first-class. Skyfall Day is the bundle's own light ramp —
surfaces at `--sea-94/96/98`, text at `--sea-15`, and every chromatic family
dropping to its `-deep` (L 0.48) step. Nothing about it was derived here.
Precedence, highest first:
1. **an explicit choice** — the toggle in the header strip, stored in
`localStorage` under `skyfall-theme`;
2. **the OS preference** — `@media (prefers-color-scheme: light)`, applied only
while no explicit choice exists;
3. **dark** — Skyfall's default.
Two pieces of plumbing make that work, and both are load-bearing:
- **`build.py` re-emits the vendored `[data-theme="light"]` blocks** in both
forms — `[data-theme="light"], html.light` for an explicit choice, and a
copy inside the media query scoped to
`html:not([data-theme="dark"]):not([data-theme="light"])`. That `:not()` pair
is what lets a stored *dark* choice survive a light-mode OS.
- **`conf/custom.js` renders the toggle**, because Homepage will not give us
its own.
⚠ **Homepage's built-in theme toggle is unreachable, and reaching for it breaks
the dashboard.** The toggle renders only when `settings.yaml` leaves `theme:`
unpinned — but with the key absent, the page's data loader throws and serves
`initialSettings: {}` (no tab bar, no layout, no i18n). Measured 2026-08-24:
six force-recreates over seven minutes all came up empty with the key removed;
restoring `theme: dark` rendered correctly on the next recreate in 12 seconds.
`/api/services` stays 200 and correct the whole time, which is exactly why this
looks like a caching problem and is not one. **Leave `theme: dark` pinned.**
### Type: one canonical face, two documented substitutions
Skyfall names Bespoke Sans (display) / Supreme (body, UI) / Victor Mono Nerd
Font (data, code). Only **Supreme** was ever vendored into this repo, and
Skyfall's own notes call Victor Mono "user-supplied", so the other two are
stand-ins rather than deviations:
| role | Skyfall | here |
|---|---|---|
| display | Bespoke Sans | **Space Grotesk** (variable, latin subset) |
| body / UI | Supreme | **Supreme 400/500/700** — canonical |
| data / mono | Victor Mono Nerd Font | **JetBrains Mono** (variable) |
Swapping in the real faces is a two-line change: `FONTS_*` in `build.py` and
the `--font-display` / `--font-mono` overrides at the top of `skyfall.css.in`.
### Iterating on the theme — do NOT recreate the container
`custom.css` is fetched per request from `/api/config/custom.css`, so a CSS