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
File diff suppressed because one or more lines are too long
+124 -2
View File
@@ -1,2 +1,124 @@
// Custom JS for homepage. Empty placeholder — keep file present so
// homepage doesn't 404 on the asset.
// Homepage — Australis Skyfall theme switch.
//
// WHY THIS EXISTS INSTEAD OF HOMEPAGE'S OWN TOGGLE
// Homepage renders a built-in light/dark switch only when settings.yaml does
// NOT pin `theme:`. We cannot unpin it: removing the key makes the page's data
// loader throw, and its catch branch serves `initialSettings: {}` — a dashboard
// with no tab bar, no layout and no i18n. Measured on 2026-08-24: with
// `theme: dark` present the payload is correct within ~12s of a recreate;
// with the key removed, six recreates over seven minutes all came up empty,
// and putting the key back fixed it on the next try. So the key stays, and the
// switch is ours.
//
// HOW IT WORKS
// Skyfall keys its light theme off `[data-theme="light"]` on <html>. Homepage
// keeps its own `dark` class there regardless — that is fine and was verified:
// with `class="dark scheme-dark theme-slate"` AND `data-theme="light"`, every
// themed surface resolves to Skyfall Day, because our rules carry !important
// on the surfaces Tailwind's `dark:` variants would otherwise claim.
//
// Precedence, highest first:
// 1. an explicit choice stored here in localStorage
// 2. the OS preference, via @media (prefers-color-scheme) in custom.css
// 3. dark — Skyfall's first-class default
//
// The stylesheet handles 2 and 3 on its own, so this file is only responsible
// for 1. It writes `data-theme` ONLY when there is a stored choice; leaving the
// attribute absent is what lets the media query take over.
(() => {
"use strict";
const KEY = "skyfall-theme"; // "light" | "dark" | absent = follow the OS
const root = document.documentElement;
const stored = () => {
try {
const v = localStorage.getItem(KEY);
return v === "light" || v === "dark" ? v : null;
} catch {
return null; // private mode / storage disabled — fall through to the OS
}
};
const systemPrefersLight = () =>
window.matchMedia?.("(prefers-color-scheme: light)").matches ?? false;
const apply = (mode) => {
if (mode) root.setAttribute("data-theme", mode);
else root.removeAttribute("data-theme");
};
// Run before first paint where possible, so a stored light choice does not
// flash dark on load.
apply(stored());
const effective = () => stored() ?? (systemPrefersLight() ? "light" : "dark");
const ICON = {
// Lucide sun / moon, 1.75 stroke per Skyfall's iconography note.
light:
'<circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M6.34 17.66l-1.41 1.41M19.07 4.93l-1.41 1.41"/>',
dark: '<path d="M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9Z"/>',
};
const button = document.createElement("button");
button.type = "button";
button.id = "skyfall-theme-toggle";
button.setAttribute("aria-label", "Toggle light and dark theme");
const paint = () => {
const mode = effective();
// Show the icon for the mode you would switch TO, which is the convention
// every OS theme switch uses.
const next = mode === "light" ? "dark" : "light";
button.title = `Switch to ${next} theme`;
button.setAttribute("aria-pressed", String(mode === "light"));
button.innerHTML =
'<svg viewBox="0 0 24 24" width="16" height="16" fill="none" ' +
'stroke="currentColor" stroke-width="1.75" stroke-linecap="round" ' +
`stroke-linejoin="round">${ICON[next]}</svg>`;
};
button.addEventListener("click", () => {
const next = effective() === "light" ? "dark" : "light";
try {
localStorage.setItem(KEY, next);
} catch {
/* storage unavailable — the choice just will not survive a reload */
}
apply(next);
paint();
});
// Track the OS while no explicit choice is stored, so the icon stays honest.
window
.matchMedia?.("(prefers-color-scheme: light)")
.addEventListener?.("change", () => {
if (!stored()) paint();
});
// Homepage renders client-side and rebuilds its header, so a one-shot
// querySelector at load usually finds nothing and would silently no-op.
// Watch until the header strip exists, then stop watching.
const mount = () => {
if (document.getElementById("skyfall-theme-toggle")?.isConnected) return true;
const host = document.querySelector(
"div.flex.flex-row.self-center.flex-wrap.justify-between",
);
if (!host) return false;
paint();
host.appendChild(button);
return true;
};
if (!mount()) {
const observer = new MutationObserver(() => {
if (mount()) observer.disconnect();
});
observer.observe(document.body, { childList: true, subtree: true });
// Backstop: never leave an observer running forever on a page that will
// not produce the host element.
setTimeout(() => observer.disconnect(), 30000);
}
})();
+28 -12
View File
@@ -1,10 +1,28 @@
---
# https://gethomepage.dev/latest/configs/settings
title: ... all my base ...
# ⚠ `theme:` MUST STAY PINNED. REMOVING IT BREAKS THE WHOLE DASHBOARD.
#
# Unpinning this is the documented way to make Homepage render its own
# light/dark toggle, and it was tried on 2026-08-24 for exactly that reason.
# It does not work here: with the key absent, the page's data loader throws and
# its catch branch serves `initialSettings: {}` — no tab bar, no layout, no
# i18n, just a flat list of every group at once. Measured, not inferred: six
# force-recreates over seven minutes all came up empty with the key removed,
# and putting it back rendered correctly on the next recreate in 12 seconds.
# `/api/services` stays 200 and correct throughout, which is what makes this so
# easy to misdiagnose as a caching or warm-up problem.
#
# Light/dark still works — it just is not Homepage's switch. The stylesheet is
# dual-theme (Australis Skyfall ships both), driven by `data-theme` on <html>:
# conf/custom.js adds a toggle that writes it and remembers the choice, and
# theme/build.py also emits an OS-preference copy so an unset choice follows
# `prefers-color-scheme`. Dark stays Skyfall's first-class default.
#
# `color:` is pinned too, for a different reason: unpinning it adds a
# colour-ramp picker, and the ramp is not ours to choose — conf/custom.css
# carries the real palette. slate is the nearest cool neutral underneath it.
theme: dark
# `color:` only accepts Homepage's built-in Tailwind ramps, and none of them is
# the Australis Sea ramp. slate is the nearest cool neutral and acts as the
# base under conf/custom.css, which is where the real palette lives.
color: slate
# NO `background:` BLOCK — DELIBERATE, DO NOT RE-ADD WITHOUT ASKING.
@@ -14,15 +32,13 @@ color: slate
# for an Arbo-generated background and came out on 2026-08-19 when the
# operator called the result ugly.
#
# The image was also against canon on its own terms: Australis says "solid
# fills only on chrome — no full-bleed photography, no decorative gradients",
# and permits the aurora motif "never as a background fill behind text". A
# whole dashboard is text. The predecessor knew and dialled the opacity down
# instead of dropping it, which is how you end up with a quiet version of the
# wrong thing. The asset is still in `images/` if this is ever revisited.
#
# The aurora now appears exactly where canon sanctions it: a 1px accent edge
# under the tab bar, in conf/custom.css.
# The image was also against canon on its own terms: Skyfall says "flat
# semantic surfaces; no photography, no textures", and permits its one
# decorative motif — a subtle aurora gradient — on hero and empty-state areas
# only, "never behind body text blocks". A whole dashboard is a body text
# block. The predecessor knew and dialled the opacity down instead of dropping
# it, which is how you end up with a quiet version of the wrong thing. The
# asset is still in `images/` if this is ever revisited.
headerStyle: boxedWidgets
providers:
-697
View File
@@ -1,697 +0,0 @@
/* Homepage — Australis.
*
* SOURCE OF TRUTH: this file. Do not hand-edit `conf/custom.css`; it is
* generated. Run `theme/build.py` after changing anything here, then
* `scripts/deploy-stack.sh esh-docker-vm homepage --conf`.
*
* WHY A BUILD STEP: 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/*.woff2` would 404 — the faces have to arrive inside the
* stylesheet as data: URIs. The build inlines them.
*
* TOKENS ARE CANONICAL, NOT DERIVED. Every hex below is copied verbatim from
* `~/.claude/skills/australis-design/colors_and_type.css`. The predecessor
* theme built a parallel OKLCH palette "derived from the Australis
* philosophy" rather than using the system's own values, which is how a
* design system quietly forks. `build.py` re-checks these against the skill
* file at build time when it is present and warns on drift.
*
* DIRECTION: instrument panel. This is engineering chrome, not a page with
* wallpaper. Canon: "the screen is the polar sky — empty, with light coming
* through it." Three moves carry it:
*
* 1. Group headings become the Australis eyebrow (mono / uppercase / 11px /
* 0.08em, cyan) with a hairline running to the right edge. Canon names
* the eyebrow a system signature and says to use it often; here it turns
* the groups into register bands instead of headings floating over a
* grid.
* 2. Status stops shouting. The filled emerald chips read louder than the
* service names they annotate; they become a semantic dot plus a mono
* micro-label at tertiary contrast.
* 3. Cards are bordered and opaque, sized to their own content. Canon puts
* border above shadow for grouping in chrome, and equal-height rows turn
* short cards into hollow boxes.
*
* WHAT WAS REMOVED, AND WHY IT IS NOT A REGRESSION: the previous theme
* carried a generated full-bleed aurora image behind the whole dashboard,
* added on the operator explicitly asking for an Arbo-generated background.
* 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 operator called the result ugly on
* 2026-08-19 and asked for a canonical-colors rebuild, so the image is gone.
* The aurora survives where canon actually sanctions it: a single 1px accent
* edge under the tab bar. If a background is ever wanted again, that is the
* conversation to reopen — not a quiet re-add.
*
* TYPE: Space Grotesk (display) / Inter (UI, body) / JetBrains Mono (all
* metadata) — canonical per the system README. Vendored as latin-subset
* VARIABLE woff2, one file per family covering the whole weight axis: 102 KB
* for three families against 56 KB for the three static Supreme cuts the
* predecessor shipped, and no Google Fonts request at page load.
*/
/* @@FONTS@@ */
:root {
/* Ice */
--aus-black: #222531;
--aus-white: #a9bcc3;
--aus-bright-white: #cce7ec;
/* Sea */
--aus-bright-black: #373b46;
--aus-dark-30: #414751;
--aus-dark-40: #565f69;
--aus-dark-50: #6e7882;
--aus-dark-60: #86929d;
--aus-bright-70: #9daeb6;
--aus-bright-80: #b3cbcf;
/* Aurora */
--aus-blue: #6388d8;
--aus-bright-blue: #a4c4ff;
--aus-cyan: #00b1a8;
--aus-bright-cyan: #42dcd1;
--aus-green: #16b866;
--aus-bright-green: #51e08a;
/* Dawn */
--aus-red: #ff491a;
--aus-yellow: #e1c631;
--aus-magenta: #9d78ff;
--bg-0: var(--aus-black);
--bg-1: var(--aus-bright-black);
--bg-2: var(--aus-dark-30);
--border-subtle: var(--aus-dark-30);
--border-default: var(--aus-dark-40);
--fg-0: var(--aus-bright-white);
--fg-1: var(--aus-white);
--fg-2: var(--aus-bright-70);
--fg-3: var(--aus-dark-60);
--fg-muted: var(--aus-dark-50);
--font-display: "Space Grotesk", ui-sans-serif, system-ui, sans-serif;
--font-sans: "Inter", ui-sans-serif, system-ui, sans-serif;
--font-mono: "JetBrains Mono", ui-monospace, "SF Mono", Menlo, monospace;
--tracking-caps: 0.08em;
--radius-sm: 4px;
--radius-md: 6px;
--radius-lg: 10px;
--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 --------------------------------------------------------------
Solid fill. Canon forbids full-bleed imagery and decorative gradient fills
behind text. The single sanctioned aurora moment is the hairline under the
header chrome, further down. */
html,
body,
#page_wrapper,
#background,
body > div {
background-color: var(--bg-0) !important;
background-image: none !important;
}
/* `#background` above is load-bearing, not defensive tidiness. Homepage
server-renders the wallpaper as an INLINE style on `<div id="background">`,
and Next.js caches that rendered page — so removing the `background:` block
from settings.yaml is not enough on its own. A restart does not clear it;
only a full recreate does, and recreating this container has its own cost
(the tab bar and i18n go missing for up to an hour and then heal on their
own). Enforcing it in the stylesheet is deterministic, immediate, and also
means the canvas stays correct if the setting is ever re-added by accident.
The deliberate absence of the config block is documented in settings.yaml. */
body {
font-family: var(--font-sans) !important;
color: var(--fg-1);
font-size: 15px;
-webkit-font-smoothing: antialiased;
}
/* ---- Group heading — the Australis eyebrow --------------------------------
Canon calls mono/uppercase/11px/0.08em the system's signature and says to
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. */
/* 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: 22px;
}
/* …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.bookmark-group-name {
font-family: var(--font-mono) !important;
font-size: 11px !important;
font-weight: 500 !important;
line-height: 1 !important;
text-transform: uppercase;
letter-spacing: var(--tracking-caps);
color: var(--aus-cyan) !important;
white-space: nowrap;
display: flex;
}
.services-group > button.group::after,
.bookmark-group > button.group::after {
content: "";
flex: 1;
height: 1px;
margin-left: 12px;
background: var(--border-subtle);
}
/* The chevron sits after the rule, muted. */
.services-group > button.group svg,
.bookmark-group > button.group svg {
color: var(--fg-muted);
margin-left: 8px;
width: 14px;
height: 14px;
}
/* ---- Service cards -------------------------------------------------------
Border > shadow for grouping in chrome (canon). Opaque fill, 1px border,
one soft shadow. Height is content-driven — equal-height stretching turns
short cards into hollow boxes. */
ul.services-list {
align-items: start !important;
row-gap: 8px;
}
li.service > .service-card {
height: auto !important;
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: 0 !important;
transition: background-color var(--dur-fast) var(--ease-out),
border-color var(--dur-fast) var(--ease-out);
}
li.service > .service-card:hover {
background: var(--bg-2) !important;
border-color: var(--border-default);
}
/* 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;
font-weight: 500 !important;
/* Pinned so the status cluster can be centred on this exact line rather
than floating against the card's top edge. Changing one without the
other breaks the alignment. */
line-height: 17px !important;
letter-spacing: -0.015em;
color: var(--fg-0) !important;
/* 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 {
font-family: var(--font-sans) !important;
font-size: 12.5px !important;
font-weight: 400 !important;
line-height: 17px !important;
color: var(--fg-3) !important;
margin-top: 3px;
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 {
opacity: 0.9;
}
/* ---- Status — dots, not chips --------------------------------------------
The filled emerald pills read louder than the service names they annotate.
Replaced with a semantic dot plus a mono micro-label at tertiary contrast:
present when you look for it, silent when you are not. */
/* The status cluster is CENTRED ON THE SERVICE NAME'S LINE, not parked in the
card's top-right corner. Homepage positions it `absolute top-0 right-0`,
which floats it ~7px above the title's optical centre and reads as a
misalignment on every card. The offsets below reconstruct the title line's
geometry: .service-name sits 5px below the card's top edge and carries 8px
of its own top padding, so its 17px line box starts at 13px and centres at
21.5px. Giving the cluster that top and that height, and centring inside it,
puts the pill and the name on one optical line.
These three numbers move together — if the name's font-size or line-height
changes, recompute all of them. */
.service-tags {
top: 13px !important;
height: 17px;
align-items: center !important;
gap: 12px !important;
margin-top: 0 !important;
/* 14px right inset, against the 8px of text padding on the left — the
optical match, since the pill has no glyph hard against its edge. */
margin-right: 14px !important;
}
/* Every child of the cluster shares one baseline: status pills, latency
badges and container-stat buttons alike. */
.service-tag {
display: flex !important;
align-items: center !important;
height: 17px;
line-height: 17px !important;
}
.service-tag,
.docker-status,
.site-monitor-status,
.service-tag > div:not(.sr-only) {
background: transparent !important;
background-color: transparent !important;
padding: 0 !important;
border-radius: 0 !important;
box-shadow: none !important;
}
.docker-status > div,
.service-tag .text-\[8px\] {
display: flex !important;
align-items: center !important;
font-family: var(--font-mono) !important;
font-size: 9px !important;
font-weight: 500 !important;
line-height: 1 !important;
letter-spacing: var(--tracking-caps);
color: var(--fg-3) !important;
}
/* Flex-centred rather than nudged with vertical-align: the dot is an optical
round on a cap-height uppercase run, so it needs true centring, not a magic
pixel offset that only looks right at one font size. */
.docker-status > div::before {
content: "\25CF"; /* ● */
display: inline-block;
margin-right: 6px;
font-size: 7px;
line-height: 1;
color: var(--aus-dark-50);
}
.docker-status-healthy > div::before,
.docker-status-running > div::before {
color: var(--aus-green);
}
.docker-status-unhealthy > div::before,
.docker-status-exited > div::before,
.docker-status-dead > div::before {
color: var(--aus-red);
}
.docker-status-starting > div::before,
.docker-status-paused > div::before {
color: var(--aus-yellow);
}
/* Ping / latency — mono metadata, tabular, no chip. */
.site-monitor-status > div {
font-family: var(--font-mono) !important;
font-size: 9px !important;
font-weight: 400 !important;
letter-spacing: 0.04em;
text-transform: lowercase !important;
font-variant-numeric: tabular-nums;
color: var(--fg-muted) !important;
}
/* ---- Widget stat strip ---------------------------------------------------
Was nested boxes-in-a-box. Now a footer strip: hairline above, cells
divided by hairlines, value in the display face, label as a mono eyebrow.
The card stays one object instead of three. */
.service-container {
margin-top: 8px;
padding-top: 2px;
border-top: 1px solid var(--border-subtle);
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: 6px 4px 5px !important;
border-left: 1px solid var(--border-subtle);
}
.service-container > div:first-child {
border-left: 0;
}
/* 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.
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: 15px !important;
font-weight: 600 !important;
letter-spacing: -0.015em;
line-height: 1.2 !important;
white-space: nowrap;
font-variant-numeric: tabular-nums;
color: var(--fg-0) !important;
}
.service-container > div {
min-width: 0; /* let a long value shrink the cell instead of overflowing */
}
/* label */
.service-container > div > div.uppercase {
font-family: var(--font-mono) !important;
font-size: 9px !important;
font-weight: 400 !important;
letter-spacing: var(--tracking-caps);
color: var(--fg-3) !important;
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). */
ul.sm\:flex {
position: relative;
background: transparent !important;
background-color: transparent !important;
border-radius: 0 !important;
gap: 0;
overflow: visible;
}
/* The one moment of colour on the page. Canon sanctions the aurora glow —
bright-blue → bright-cyan → green — as an accent EDGE, never as a fill
behind text. A 1px rule under the tab bar is that edge, and it is the only
place the full aurora appears. */
ul.sm\:flex::after {
content: "";
position: absolute;
left: 0;
right: 0;
bottom: 0;
height: 1px;
background: linear-gradient(
90deg,
var(--aus-blue) 0%,
var(--aus-cyan) 42%,
var(--aus-green) 68%,
var(--border-subtle) 100%
);
opacity: 0.9;
}
ul.sm\:flex > li {
height: 40px !important;
border-radius: 0 !important;
background: transparent !important;
overflow: visible;
}
ul.sm\:flex > li > button {
margin: 0 !important;
height: 100%;
border-radius: 0 !important;
background: transparent !important;
background-color: transparent !important;
box-shadow: none !important;
border-top: 2px solid transparent;
font-family: var(--font-mono) !important;
font-size: 11px !important;
font-weight: 500 !important;
text-transform: uppercase;
letter-spacing: var(--tracking-caps);
color: var(--fg-3) !important;
transition: color var(--dur-fast) var(--ease-out);
}
ul.sm\:flex > li > button:hover {
color: var(--fg-1) !important;
background: transparent !important;
}
ul.sm\:flex > li > button.dark\:bg-white\/10 {
border-top-color: var(--aus-blue) !important;
color: var(--fg-0) !important;
background: linear-gradient(
180deg,
rgba(99, 136, 216, 0.12) 0%,
rgba(99, 136, 216, 0) 72%
) !important;
}
/* ---- Header chrome: resource widget + search -----------------------------
Same card treatment as everything else, so the header belongs to the page
instead of hovering above it. */
.information-widget-resource,
div.flex.flex-col.justify-center.mt-2 {
background: var(--bg-1) !important;
border: 1px solid var(--border-subtle);
border-radius: var(--radius-lg) !important;
box-shadow: var(--shadow-1) !important;
}
.information-widget-resource {
border: 0 !important;
border-radius: 0 !important;
box-shadow: none !important;
}
/* The header strip that holds the resource widgets. */
div.flex.flex-row.self-center.flex-wrap.justify-between {
background: var(--bg-1) !important;
border: 1px solid var(--border-subtle);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-1);
}
.information-widget-resource .text-xs,
.information-widget-resource span {
font-family: var(--font-mono) !important;
font-size: 11px !important;
letter-spacing: 0.02em;
color: var(--fg-2) !important;
}
.resource-usage,
[class*="resource-usage"] {
background: var(--aus-black) !important;
height: 3px !important;
border-radius: 2px !important;
}
.resource-usage > div,
[class*="resource-usage"] > div {
background: var(--aus-cyan) !important;
border-radius: 2px !important;
}
/* Search input */
input[type="text"],
input[type="search"] {
font-family: var(--font-sans) !important;
font-size: 14px !important;
color: var(--fg-1) !important;
}
input::placeholder {
color: var(--fg-muted) !important;
font-family: var(--font-mono) !important;
font-size: 12px !important;
letter-spacing: 0.04em;
}
/* ---- Focus — the aurora glow, canon's signature interaction motif -------- */
a:focus-visible,
button:focus-visible,
input:focus-visible {
outline: none !important;
box-shadow: 0 0 0 3px rgba(99, 136, 216, 0.35) !important;
border-radius: var(--radius-sm);
}
+157 -56
View File
@@ -1,46 +1,126 @@
#!/usr/bin/env python3
"""Generate conf/custom.css from the Australis source in this directory.
"""Generate conf/custom.css from the Australis Skyfall source in this directory.
Homepage serves exactly two files out of its config dir — custom.css and
custom.js — with no static route for anything alongside them. A @font-face
pointing at a vendored .woff2 would therefore 404, so the faces have to be
inlined as data: URIs. That is the whole reason this build step exists.
pointing at a vendored .woff2 would therefore 404, and an @import of a sibling
token file would 404 too. So this build does three things that cannot be done
in plain CSS:
1. inlines the font faces as data: URIs;
2. concatenates the vendored token files into one sheet;
3. bridges Skyfall's `[data-theme="light"]` selector to the `light` CLASS
Homepage actually sets on <html>.
Usage: python3 stacks/homepage/theme/build.py
Then: scripts/deploy-stack.sh esh-docker-vm homepage --conf
(a settings.yaml change needs a container recreate; a custom.css
change needs only a browser reload — custom.css is served per request)
"""
import base64
import hashlib
import pathlib
import re
import sys
HERE = pathlib.Path(__file__).resolve().parent
SRC = HERE / "australis.css.in"
OUT = HERE.parent / "conf" / "custom.css"
# The canonical token file. Present on a box with the australis-design skill
# installed; absent elsewhere, in which case the drift check is skipped rather
# than failing the build.
CANON = pathlib.Path.home() / ".claude/skills/australis-design/colors_and_type.css"
# ---------------------------------------------------------------------------
# VENDORED, VERBATIM, FROM THE SKYFALL BUNDLE. Concatenated in this order:
# families before semantics, tokens before bindings.
#
# The hashes are the anti-fork guard. The theme this one replaces built a
# parallel palette "derived from the Australis philosophy" instead of using the
# system's own values, and it had to be torn out twice. A vendored file is
# either byte-identical to the bundle or it is a fork wearing the bundle's
# name; there is no third state, so a mismatch FAILS the build rather than
# warning. To legitimately update one, drop in the new file and record its new
# hash in the same commit.
# ---------------------------------------------------------------------------
VENDORED = {
"colors.css": "75f78b674dd7fd87ac414cf04165f0e99b016e54d3b546f0e96378c08fdd7b88",
"layout.css": "a3826169b7a6b604d9c456c073ba3788297ae88bb703862a95171e3858f94c56",
"typography.css": "2ec1a667ced0653292eed0f724b8bc294765d740531ee10e48ebda5f5ca6f2f8",
}
# One VARIABLE face per family — the whole 100-900 axis in a single file, so
# there is no per-weight fan-out. Latin subset only.
FONTS = [
("Inter", "Inter-Variable.woff2"),
("Space Grotesk", "SpaceGrotesk-Variable.woff2"),
("JetBrains Mono", "JetBrainsMono-Variable.woff2"),
# Our own bindings — the only file in this directory that is ours to edit.
BINDINGS = "skyfall.css.in"
# ---------------------------------------------------------------------------
# Skyfall keys light mode off `[data-theme="light"]` on <html>. Homepage never
# touches data attributes, and — this is the constraint that shapes everything
# below — it will not give us its own theme toggle either: rendering that
# toggle requires settings.yaml to leave `theme:` unpinned, and an unpinned
# `theme:` makes the page's data loader throw (measured 2026-08-24; see
# conf/custom.js for the evidence). So `theme: dark` stays pinned, Homepage's
# class is always `dark`, and light mode is driven by `data-theme` written by
# our own toggle in custom.js, or by the OS preference when nothing is stored.
#
# Editing the vendored file to add those selectors would fork it; hand-copying
# its light block into the bindings would drift the moment the bundle updates.
# So re-emit the block here, mechanically, in three forms:
#
# [data-theme="light"], html.light explicit choice (our toggle; also the
# spelling a real Skyfall app would use,
# kept so the sheet stays portable)
# @media (prefers-color-scheme: light)
# html:not([data-theme=...]) the OS preference, and ONLY while no
# explicit choice is present — the :not()
# pair is what makes a stored "dark"
# survive a light-mode OS.
# ---------------------------------------------------------------------------
LIGHT_BLOCK = re.compile(r'^\[data-theme="light"\]\s*\{\n(.*?)^\}', re.M | re.S)
def expand_light(match: "re.Match[str]") -> str:
body = match.group(1).rstrip("\n")
return (
'[data-theme="light"],\nhtml.light {\n' + body + "\n}\n\n"
"@media (prefers-color-scheme: light) {\n"
' html:not([data-theme="dark"]):not([data-theme="light"]) {\n'
+ body
+ "\n }\n}"
)
# One VARIABLE face per stand-in family — the whole 100-900 axis in a single
# file — plus Supreme's three static cuts, which is how Supreme ships. Latin
# subset only. See the type note in skyfall.css.in for why two of the three
# families are substitutions.
FONTS_VARIABLE = [
("Space Grotesk", "SpaceGrotesk-Variable.woff2"), # stands in for Bespoke Sans
("JetBrains Mono", "JetBrainsMono-Variable.woff2"), # stands in for Victor Mono
]
FONTS_STATIC = [
("Supreme", "Supreme-400.woff2", 400),
("Supreme", "Supreme-500.woff2", 500),
# Supreme ships no 600 cut; 600 and 700 both resolve to this file.
("Supreme", "Supreme-700.woff2", "600 700"),
]
# Tokens the generated sheet cannot work without. A vendoring mistake that
# drops the :root block produces a stylesheet that parses fine and renders
# nothing, so assert on the values rather than trusting the copy.
REQUIRED_TOKENS = ["--aus-black", "--aus-cyan", "--bg-1", "--font-mono"]
# drops a block produces a stylesheet that parses fine and renders nothing, so
# assert on the values rather than trusting the copy. One from each vendored
# file, plus proof the light theme survived.
REQUIRED = [
"--sea-15", # colors.css, family layer
"--surface-card", # colors.css, semantic layer
"--shadow-sm", # layout.css
"--tracking-caps", # typography.css
"html.light", # the explicit-choice selector was emitted
"prefers-color-scheme: light", # the OS-preference copy was emitted
"#skyfall-theme-toggle", # the toggle has styling, not just behaviour
]
def sha256(path: pathlib.Path) -> str:
return hashlib.sha256(path.read_bytes()).hexdigest()
def font_faces() -> str:
out = []
for family, filename in FONTS:
for family, filename in FONTS_VARIABLE:
path = HERE / "fonts" / filename
if not path.exists():
sys.exit(f"missing font: {path}")
@@ -54,58 +134,79 @@ def font_faces() -> str:
f" font-display: swap;\n"
f"}}"
)
for family, filename, weight in FONTS_STATIC:
path = HERE / "fonts" / filename
if not path.exists():
sys.exit(f"missing font: {path}")
b64 = base64.b64encode(path.read_bytes()).decode("ascii")
out.append(
f"@font-face {{\n"
f' font-family: "{family}";\n'
f" src: url(data:font/woff2;base64,{b64}) format('woff2');\n"
f" font-weight: {weight};\n"
f" font-style: normal;\n"
f" font-display: swap;\n"
f"}}"
)
return "\n".join(out)
def check_canon_drift(css: str) -> None:
"""Warn if a token here no longer matches the Australis skill's value.
def vendored_css() -> str:
"""Concatenate the vendored token files, refusing to build if one moved."""
parts, drift = [], []
for name, expected in VENDORED.items():
path = HERE / name
if not path.exists():
sys.exit(
f"missing vendored token file: {path}\n"
f"Recover it with: git show 45c1995:stacks/homepage/theme/{name}"
)
actual = sha256(path)
if actual != expected:
drift.append((name, actual, expected))
parts.append(f"/* ==== vendored: {name} ({actual[:12]}) ==== */\n{path.read_text()}")
Non-fatal on purpose: the skill is a per-workstation install, so a missing
file is normal and must not break a build on a box that lacks it. A *value*
mismatch is worth shouting about — that is the palette forking, which is
exactly how the theme this one replaced went wrong.
"""
if not CANON.exists():
print(f"note: {CANON} not present — canonical drift check skipped")
return
canon = dict(re.findall(r"(--aus-[a-z0-9-]+):\s*(#[0-9a-fA-F]{6});", CANON.read_text()))
ours = dict(re.findall(r"(--aus-[a-z0-9-]+):\s*(#[0-9a-fA-F]{6});", css))
if not canon:
print(f"note: parsed no --aus-* tokens from {CANON} — drift check skipped")
return
drift = [(k, v, canon[k]) for k, v in ours.items() if k in canon and v.lower() != canon[k].lower()]
missing = sorted(set(ours) - set(canon))
for key, mine, theirs in drift:
print(f"DRIFT: {key} is {mine} here, {theirs} in the skill", file=sys.stderr)
for key in missing:
print(f"note: {key} is not a canonical token", file=sys.stderr)
if drift:
print(
f"WARNING: {len(drift)} token(s) diverge from the canonical palette. "
"Reconcile before deploying — a derived palette is how the previous "
"theme forked from the design system.",
file=sys.stderr,
for name, actual, expected in drift:
print(f"VENDOR DRIFT: {name}", file=sys.stderr)
print(f" on disk : {actual}", file=sys.stderr)
print(f" expected: {expected}", file=sys.stderr)
sys.exit(
"A vendored Skyfall token file has been modified. These are copied "
"verbatim from the bundle and are not ours to edit — put the "
"override in skyfall.css.in instead. If the bundle itself was "
"legitimately updated, record the new hash in VENDORED in the same "
"commit as the file."
)
else:
print(f"canonical palette check: {len(ours)} tokens match {CANON.name}")
print(f"vendored token files verified: {', '.join(VENDORED)}")
return "\n\n".join(parts)
def main() -> None:
if not SRC.exists():
sys.exit(f"missing source: {SRC}")
src = SRC.read_text()
src_path = HERE / BINDINGS
if not src_path.exists():
sys.exit(f"missing source: {src_path}")
src = src_path.read_text()
if "/* @@FONTS@@ */" not in src:
sys.exit(f"{SRC.name} lost its @@FONTS@@ placeholder")
sys.exit(f"{BINDINGS} lost its @@FONTS@@ placeholder")
css = src.replace("/* @@FONTS@@ */", font_faces())
tokens = vendored_css()
for token in REQUIRED_TOKENS:
bridged, n = LIGHT_BLOCK.subn(expand_light, tokens)
if not n:
sys.exit(
'no [data-theme="light"] rule found in the vendored tokens — the '
"light theme would silently never apply"
)
print(f'expanded {n} [data-theme="light"] block(s): explicit + OS-preference')
css = bridged + "\n\n" + src.replace("/* @@FONTS@@ */", font_faces())
for token in REQUIRED:
if token not in css:
sys.exit(f"generated css has no {token} — the token block did not survive")
sys.exit(f"generated css has no {token} — a token block did not survive")
check_canon_drift(css)
OUT.write_text(css)
print(f"wrote {OUT} ({len(css) / 1024:.0f} KB)")
+208
View File
@@ -0,0 +1,208 @@
/* ============================================================
Australis Skyfall — Color Tokens v2
A ground-up web palette derived from the Australis Dark
PHILOSOPHY (not its ANSI values). Built in OKLCH so the laws
are explicit and every family stays perceptually consistent:
- All hues are cooler than neutral; nothing warmer than amber.
- One lightness law across every chromatic family:
deep L 0.48 — light-theme fills & colored text
base L 0.66 — dark-theme fills (the working color)
bright L 0.80 — dark-theme colored text & highlights
- Chroma is tuned per hue so no family shouts over another.
- Neutrals (Sea ramp) drift in hue from ice-blue (265°) toward
ocean green (195°) as they brighten — the aurora signature.
- Medium / medium-high contrast for eye comfort.
Families: Aurora (blue, cyan, green — use generously, in that
order) and Dawn (amber, red, violet — semantic accents only:
warning, danger, AI).
============================================================ */
:root {
/* ---- Sea — neutral ramp (hue 265° → 195° as L rises) ---- */
--sea-10: oklch(0.23 0.02 265);
--sea-15: oklch(0.27 0.024 268); /* dark canvas — the Australis anchor */
--sea-20: oklch(0.31 0.022 262);
--sea-25: oklch(0.35 0.02 258);
--sea-30: oklch(0.4 0.018 252);
--sea-40: oklch(0.48 0.016 245);
--sea-50: oklch(0.56 0.015 235);
--sea-60: oklch(0.64 0.014 228);
--sea-70: oklch(0.72 0.014 220);
--sea-75: oklch(0.78 0.015 212);
--sea-80: oklch(0.83 0.017 205);
--sea-90: oklch(0.9 0.02 198);
--sea-94: oklch(0.94 0.014 195);
--sea-96: oklch(0.96 0.01 195);
--sea-98: oklch(0.98 0.006 195);
/* ---- Ice — main colors (aliases into the ramp) ---- */
--ice-black: var(--sea-15);
--ice-white: var(--sea-75);
--ice-bright-white: var(--sea-90);
/* ---- Aurora — primary families (blue > cyan > green) ---- */
--blue-deep: oklch(0.48 0.12 262);
--blue-base: oklch(0.66 0.12 262);
--blue-bright: oklch(0.8 0.09 262);
--cyan-deep: oklch(0.48 0.08 200);
--cyan-base: oklch(0.66 0.1 200);
--cyan-bright: oklch(0.8 0.1 200);
--green-deep: oklch(0.48 0.11 158);
--green-base: oklch(0.66 0.13 158);
--green-bright: oklch(0.8 0.13 158);
/* ---- Dawn — accent families (semantic use only) ---- */
--amber-deep: oklch(0.52 0.1 75);
--amber-base: oklch(0.7 0.12 78);
--amber-bright: oklch(0.82 0.13 82);
--red-deep: oklch(0.5 0.15 25);
--red-base: oklch(0.62 0.16 25);
--red-bright: oklch(0.78 0.11 28);
--violet-deep: oklch(0.5 0.15 292);
--violet-base: oklch(0.66 0.14 292);
--violet-bright: oklch(0.8 0.1 292);
}
/* ============================================================
Semantic aliases — DARK (default, first-class)
============================================================ */
:root {
color-scheme: dark;
/* Surfaces */
--surface-sunken: var(--sea-10);
--surface-base: var(--sea-15);
--surface-raised: var(--sea-20);
--surface-overlay: oklch(0.34 0.022 260);
--surface-card: var(--sea-20);
--surface-input: var(--sea-10);
--surface-scrim: oklch(0.17 0.02 265 / 0.72);
/* Text */
--text-heading: var(--sea-90);
--text-body: var(--sea-75);
--text-muted: var(--sea-60);
--text-faint: var(--sea-50);
--text-inverse: var(--sea-15);
--text-link: var(--blue-bright);
--text-link-hover: var(--sea-90);
/* Borders */
--border-subtle: var(--sea-25);
--border-default: var(--sea-30);
--border-strong: var(--sea-40);
--border-focus: var(--blue-base);
/* Accent (primary = aurora blue) */
--accent: var(--blue-base);
--accent-hover: oklch(0.71 0.11 262);
--accent-active: oklch(0.6 0.13 262);
--accent-bright: var(--blue-bright);
--accent-text: var(--blue-bright);
--accent-contrast: var(--sea-10);
--accent-soft: color-mix(in oklab, var(--blue-base) 16%, transparent);
--accent-soft-hover: color-mix(in oklab, var(--blue-base) 26%, transparent);
/* Secondary accent (aurora cyan) */
--secondary: var(--cyan-base);
--secondary-bright: var(--cyan-bright);
--secondary-text: var(--cyan-bright);
--secondary-soft: color-mix(in oklab, var(--cyan-base) 14%, transparent);
/* Semantic status */
--success: var(--green-base);
--success-text: var(--green-bright);
--success-soft: color-mix(in oklab, var(--green-base) 14%, transparent);
--warning: var(--amber-base);
--warning-text: var(--amber-bright);
--warning-soft: color-mix(in oklab, var(--amber-base) 13%, transparent);
--danger: var(--red-base);
--danger-hover: oklch(0.67 0.15 25);
--danger-text: var(--red-bright);
--danger-contrast: var(--sea-10);
--danger-soft: color-mix(in oklab, var(--red-base) 13%, transparent);
--info: var(--blue-base);
--info-text: var(--blue-bright);
--info-soft: color-mix(in oklab, var(--blue-base) 14%, transparent);
--ai: var(--violet-base);
--ai-text: var(--violet-bright);
--ai-soft: color-mix(in oklab, var(--violet-base) 13%, transparent);
/* Selection */
--selection-bg: var(--blue-base);
--selection-fg: var(--sea-10);
}
/* ============================================================
Semantic aliases — LIGHT ("Skyfall Day")
Same laws, inverted: surfaces at L 0.94–0.98, text at L 0.27–0.45,
chromatic fills & colored text drop to the deep (L 0.48) step.
============================================================ */
[data-theme="light"] {
color-scheme: light;
/* Surfaces */
--surface-sunken: var(--sea-94);
--surface-base: var(--sea-96);
--surface-raised: var(--sea-98);
--surface-overlay: #ffffff;
--surface-card: var(--sea-98);
--surface-input: #ffffff;
--surface-scrim: oklch(0.27 0.024 268 / 0.4);
/* Text */
--text-heading: var(--sea-15);
--text-body: oklch(0.38 0.02 255);
--text-muted: var(--sea-50);
--text-faint: var(--sea-60);
--text-inverse: var(--sea-90);
--text-link: var(--blue-deep);
--text-link-hover: var(--sea-15);
/* Borders */
--border-subtle: oklch(0.89 0.014 210);
--border-default: oklch(0.84 0.016 215);
--border-strong: var(--sea-75);
--border-focus: var(--blue-deep);
/* Accent */
--accent: var(--blue-deep);
--accent-hover: oklch(0.53 0.12 262);
--accent-active: oklch(0.44 0.12 262);
--accent-bright: var(--blue-base);
--accent-text: var(--blue-deep);
--accent-contrast: #ffffff;
--accent-soft: color-mix(in oklab, var(--blue-deep) 10%, transparent);
--accent-soft-hover: color-mix(in oklab, var(--blue-deep) 18%, transparent);
/* Secondary accent */
--secondary: var(--cyan-deep);
--secondary-bright: var(--cyan-base);
--secondary-text: var(--cyan-deep);
--secondary-soft: color-mix(in oklab, var(--cyan-deep) 9%, transparent);
/* Semantic status */
--success: var(--green-deep);
--success-text: var(--green-deep);
--success-soft: color-mix(in oklab, var(--green-deep) 10%, transparent);
--warning: var(--amber-deep);
--warning-text: var(--amber-deep);
--warning-soft: color-mix(in oklab, var(--amber-base) 16%, transparent);
--danger: var(--red-deep);
--danger-hover: oklch(0.55 0.16 25);
--danger-text: var(--red-deep);
--danger-contrast: #ffffff;
--danger-soft: color-mix(in oklab, var(--red-deep) 8%, transparent);
--info: var(--blue-deep);
--info-text: var(--blue-deep);
--info-soft: color-mix(in oklab, var(--blue-deep) 9%, transparent);
--ai: var(--violet-deep);
--ai-text: var(--violet-deep);
--ai-soft: color-mix(in oklab, var(--violet-deep) 9%, transparent);
/* Selection */
--selection-bg: var(--blue-base);
--selection-fg: #ffffff;
}
Binary file not shown.
Binary file not shown.
Binary file not shown.
+72
View File
@@ -0,0 +1,72 @@
/* Australis Skyfall — spacing, radii, shadows, motion (v3 "calm depth") */
:root {
/* Spacing (4px base) */
--space-1: 4px;
--space-2: 8px;
--space-3: 12px;
--space-4: 16px;
--space-5: 20px;
--space-6: 24px;
--space-8: 32px;
--space-10: 40px;
--space-12: 48px;
--space-16: 64px;
--space-20: 80px;
--space-24: 96px;
--space-32: 128px;
/* Semantic spacing — airy content, compact chrome */
--pad-chrome: var(--space-2); /* app chrome: rails, toolbars, list rows */
--pad-content: var(--space-8); /* work surfaces: page bodies, card interiors breathe */
--gap-pane: var(--space-4); /* gutter between panes/cards on the canvas */
--measure: 68ch; /* long-form reading width */
/* Radii — moderate, calm */
--radius-xs: 6px;
--radius-sm: 8px;
--radius-md: 10px; /* buttons, inputs, menu items */
--radius-lg: 14px; /* cards, panes, popovers */
--radius-xl: 16px; /* dialogs, drawers, command palette */
--radius-full: 999px;
/* Depth — crisp 1px hairline + layered soft shadow underneath.
Every elevated surface pairs border: 1px solid var(--border-subtle)
with one of these. Shadows are cool-tinted and two-layer:
a tight contact shadow + a wide ambient falloff. */
--shadow-xs: 0 1px 2px oklch(0.13 0.02 265 / 0.22);
--shadow-sm: 0 1px 2px oklch(0.13 0.02 265 / 0.2), 0 2px 8px -2px oklch(0.13 0.02 265 / 0.24);
--shadow-md: 0 2px 4px oklch(0.13 0.02 265 / 0.22), 0 10px 28px -6px oklch(0.13 0.02 265 / 0.36);
--shadow-lg: 0 4px 8px oklch(0.13 0.02 265 / 0.26), 0 28px 64px -12px oklch(0.13 0.02 265 / 0.5);
--shadow-drawer: 0 8px 16px oklch(0.13 0.02 265 / 0.28), 0 32px 80px -8px oklch(0.13 0.02 265 / 0.55);
/* Glows — hero moments ONLY: empty states, featured cards, command palette */
--glow-accent: 0 0 72px -16px color-mix(in oklab, var(--blue-base) 42%, transparent);
--glow-secondary: 0 0 72px -16px color-mix(in oklab, var(--cyan-base) 38%, transparent);
--glow-ai: 0 0 72px -16px color-mix(in oklab, var(--violet-base) 40%, transparent);
/* Active rail — left accent bar on active nav / selected items */
--rail-active: inset 2px 0 0 0 var(--accent);
/* Focus ring */
--focus-ring: 0 0 0 2px var(--surface-base), 0 0 0 4px var(--border-focus);
/* Motion — calm, no bounce */
--ease-out: cubic-bezier(0.22, 1, 0.36, 1); /* @kind other */
--ease-in-out: cubic-bezier(0.65, 0, 0.35, 1); /* @kind other */
--duration-fast: 120ms; /* @kind other */
--duration-base: 180ms; /* @kind other */
--duration-slow: 300ms; /* @kind other */
--duration-drawer: 260ms; /* @kind other */
}
[data-theme="light"] {
--shadow-xs: 0 1px 2px oklch(0.3 0.02 255 / 0.07);
--shadow-sm: 0 1px 2px oklch(0.3 0.02 255 / 0.06), 0 2px 8px -2px oklch(0.3 0.02 255 / 0.08);
--shadow-md: 0 2px 4px oklch(0.3 0.02 255 / 0.06), 0 10px 28px -6px oklch(0.3 0.02 255 / 0.12);
--shadow-lg: 0 4px 8px oklch(0.3 0.02 255 / 0.07), 0 28px 64px -12px oklch(0.3 0.02 255 / 0.18);
--shadow-drawer: 0 8px 16px oklch(0.3 0.02 255 / 0.08), 0 32px 80px -8px oklch(0.3 0.02 255 / 0.22);
--glow-accent: 0 0 72px -16px color-mix(in oklab, var(--blue-deep) 26%, transparent);
--glow-secondary: 0 0 72px -16px color-mix(in oklab, var(--cyan-deep) 24%, transparent);
--glow-ai: 0 0 72px -16px color-mix(in oklab, var(--violet-deep) 25%, transparent);
}
+718
View File
@@ -0,0 +1,718 @@
/* Homepage — Australis Skyfall, dual theme.
*
* SOURCE OF TRUTH: this file plus the three VENDORED token files beside it.
* Do not hand-edit `conf/custom.css`; it is generated. Run `theme/build.py`
* after changing anything here, then
* `scripts/deploy-stack.sh esh-docker-vm homepage --conf`.
*
* ── WHAT IS VENDORED, AND WHY IT IS UNTOUCHABLE ──────────────────────────
* colors.css Skyfall v2 palette — Sea/Ice/Aurora/Dawn in OKLCH, plus
* the semantic layer for BOTH themes.
* layout.css v3 "calm depth" — spacing, radii, the shadow system,
* glows, the active rail, motion.
* typography.css families, scale, weights, leading, tracking.
*
* These three are copied VERBATIM from the Skyfall bundle and must stay that
* way. `build.py` hashes them and fails the build if a byte moves. Everything
* this dashboard needs on top of them lives in THIS file, expressed through
* the semantic layer — never against a raw family token, and never a literal
* colour. That rule is not fussiness: the theme this one replaces built its
* own parallel palette "derived from the philosophy", which is how a design
* system quietly forks, and it had to be torn out twice.
*
* ── DUAL THEME, AND THE ONE PIECE OF PLUMBING IT NEEDS ───────────────────
* Skyfall keys light mode off `[data-theme="light"]`. Homepage keys it off a
* `light` / `dark` CLASS on <html>, and does not touch data attributes. Rather
* than edit the vendored file (fork) or duplicate its light block by hand
* (drift), `build.py` rewrites the selector at build time so both spellings
* are honoured. See BRIDGE_SELECTOR there.
*
* Dark is `:root` and remains first-class. Light is Skyfall Day. Both are
* reachable from Homepage's own theme toggle, which appears because
* settings.yaml no longer pins `theme:`.
*
* ── TYPE: 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 substituting is
* expected rather than a deviation:
* Supreme 400/500/700 — CANONICAL, the body and UI face.
* Space Grotesk variable — stands in for Bespoke Sans (display).
* JetBrains Mono variable — stands in for Victor Mono (data, metadata).
* Both stand-ins are neutral grotesques in the right register. Swap them the
* moment the real faces arrive: it is a two-line change in build.py's FONTS
* and the --font-display / --font-mono overrides below.
*
* ── WHERE THIS DEPARTS FROM THE PREVIOUS THEME, DELIBERATELY ─────────────
* 1. Depth is now the RECIPE, not a choice: every elevated surface carries a
* 1px hairline AND a two-layer shadow, never one without the other.
* 2. Radii move to Skyfall's scale — cards at --radius-lg (14px), controls at
* --radius-md (10px).
* 3. 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. The colour it was
* carrying now lands where canon puts it: a 2px accent bar plus an
* --accent-soft fill on the active tab. If you want the ribbon back, that
* is a deliberate documented deviation, not an oversight.
* 4. Widget stat values move from the display face to mono. Skyfall is
* explicit: numbers and telemetry are always --font-mono.
*/
/* @@FONTS@@ */
/* ---- Family substitutions ------------------------------------------------
Overriding typography.css's declarations rather than editing it, so the
vendored file stays byte-identical and the swap is visible in one place. */
:root {
--font-display: "Space Grotesk", "Bespoke Sans", ui-sans-serif, sans-serif;
--font-body: "Supreme", ui-sans-serif, system-ui, sans-serif;
--font-mono: "JetBrains Mono", "VictorMono Nerd Font", ui-monospace, monospace;
/* Width the status cluster occupies in a card's top-right. Load-bearing in
two places that must agree: the 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. */
--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.
⚠ These must be space-separated sRGB CHANNELS, because Homepage consumes
them as `rgb(var(--color-logo-start))`. An oklch() token cannot be fed
through that, so the four values below are the exact sRGB conversions of
real Skyfall tokens rather than colours anyone picked by eye:
dark --sea-80 oklch(0.83 0.017 205) -> 187 203 204
--blue-base oklch(0.66 0.12 262) -> 105 145 220
light --sea-40 oklch(0.48 0.016 245) -> 86 95 102
--blue-deep oklch(0.48 0.12 262) -> 54 91 161
Recompute with theme/oklch_to_srgb.py if a token ever changes.
Light uses the deep (L 0.48) step for both stops, per Skyfall's lightness
law: deep is the light-theme value for fills and coloured marks.
⚠ NOT `:root`. Homepage sets these same two variables on `.theme-slate`,
and that class sits on <html> — 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 silently ignored. `html[class]`
is (0,1,1) and wins without hard-coding which `theme-*` class is active.
Specificity alone is not enough either — a custom property resolves from
the NEAREST ancestor that sets it, so this has to land on <html>. */
html[class] {
--color-logo-start: 187 203 204;
--color-logo-stop: 105 145 220;
}
html[data-theme="light"],
html.light {
--color-logo-start: 86 95 102;
--color-logo-stop: 54 91 161;
}
/* ---- Canvas --------------------------------------------------------------
The app canvas is --surface-sunken and work floats on it as cards. Flat
semantic fill only: Skyfall forbids full-bleed imagery and decorative
gradients behind text.
`#background` is load-bearing, not defensive tidiness. Homepage
server-renders the wallpaper as an INLINE style on `<div id="background">`
and Next.js caches that rendered page, so removing the `background:` block
from settings.yaml is not enough on its own — a restart does not clear it,
only a full recreate does. Enforcing it here is deterministic and immediate,
and keeps the canvas correct if the setting is ever re-added by accident. */
html,
body,
#page_wrapper,
#background,
body > div {
background-color: var(--surface-sunken) !important;
background-image: none !important;
}
body {
font-family: var(--font-body) !important;
font-size: var(--text-base);
line-height: var(--leading-normal);
color: var(--text-body);
-webkit-font-smoothing: antialiased;
}
/* ---- Group heading — the Skyfall eyebrow ---------------------------------
Mono, uppercase, --text-2xs, --tracking-caps, in the secondary accent, with
a hairline running to the right edge so groups read as register bands
rather than titles floating over a grid. ALL-CAPS is sanctioned here
specifically: Skyfall allows it for tiny tracked eyebrow labels and table
headers, and nowhere else.
Bookmark groups get the identical treatment. They are a separate component
with a parallel class family, and none of these selectors matched it before
— so UltraSeedbox rendered with Homepage's stock 20px sans heading while
every service group above it wore the eyebrow. One un-themed heading in a
column of themed ones is more conspicuous than the whole page being
un-themed. */
.services-group > button.group,
.bookmark-group > button.group {
gap: 0;
margin-bottom: var(--space-1);
margin-top: var(--space-6);
}
/* …except the first group on a tab, which should sit up against the tab bar
rather than floating below it. */
.services-group:first-of-type > button.group {
margin-top: var(--space-2);
}
.service-group-icon,
.bookmark-group-icon {
display: none !important;
}
h2.service-group-name,
h2.bookmark-group-name {
font-family: var(--font-mono) !important;
font-size: var(--text-2xs) !important;
font-weight: var(--weight-medium) !important;
line-height: 1 !important;
text-transform: uppercase;
letter-spacing: var(--tracking-caps);
color: var(--secondary-text) !important;
white-space: nowrap;
display: flex;
}
.services-group > button.group::after,
.bookmark-group > button.group::after {
content: "";
flex: 1;
height: 1px;
margin-left: var(--space-3);
background: var(--border-subtle);
}
/* The chevron sits after the rule, muted. */
.services-group > button.group svg,
.bookmark-group > button.group svg {
color: var(--text-faint);
margin-left: var(--space-2);
width: 14px;
height: 14px;
}
/* ---- Service cards — the calm-depth recipe -------------------------------
HAIRLINE + TWO-LAYER SHADOW, NEVER ONE WITHOUT THE OTHER. That pairing is
Skyfall's defining move; a card with a border and no shadow, or a shadow
and no border, is off-system. Height stays content-driven — equal-height
stretching turns short cards into hollow boxes. */
ul.services-list {
align-items: start !important;
row-gap: var(--space-2);
}
li.service > .service-card {
height: auto !important;
background: var(--surface-card) !important;
border: 1px solid var(--border-subtle);
border-radius: var(--radius-lg) !important;
box-shadow: var(--shadow-sm) !important;
margin-bottom: 0 !important;
transition: background-color var(--duration-fast) var(--ease-out),
border-color var(--duration-fast) var(--ease-out),
box-shadow var(--duration-fast) var(--ease-out);
}
/* Hover: surface lifts a step and the shadow deepens sm -> md, per canon.
No scale, no translate — Skyfall has no bounce anywhere. */
li.service > .service-card:hover {
background: var(--surface-raised) !important;
border-color: var(--border-default);
box-shadow: var(--shadow-md) !important;
}
/* Service name — display face, tight. Description — body face, muted.
*
* ⚠ `.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: var(--text-base) !important;
font-weight: var(--weight-medium) !important;
/* Pinned so the status cluster can be centred on this exact line rather
than floating against the card's top edge. Changing one without the
other breaks the alignment. */
line-height: 17px !important;
letter-spacing: var(--tracking-tight);
color: var(--text-heading) !important;
/* 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. Six cards on the
AI tab rendered their name underneath their own status pill. 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. */
padding-right: var(--status-gutter) !important;
white-space: normal;
overflow: visible;
}
p.service-description {
font-family: var(--font-body) !important;
font-size: var(--text-xs) !important;
font-weight: var(--weight-regular) !important;
line-height: 17px !important;
color: var(--text-muted) !important;
margin-top: 3px;
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(var(--space-2) - 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 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. Not equal-heights — that inflates short cards
into hollow boxes to match a widget card twice their height. */
min-height: 34px;
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
}
/* ---- Status — semantic dots, not chips -----------------------------------
The stock filled emerald pills read louder than the service names they
annotate. A semantic dot plus a mono micro-label at faint contrast: present
when you look for it, silent when you are not. The dot colours come from
--success/--danger/--warning-text, so they take the deep step automatically
in Skyfall Day rather than staying dark-theme bright on a white card.
The cluster is CENTRED ON THE SERVICE NAME'S LINE, not parked in the card's
top-right corner. Homepage positions it `absolute top-0 right-0`, which
floats it ~7px above the title's optical centre and reads as a misalignment
on every card. The offsets below reconstruct the title line's geometry:
.service-name sits 5px below the card's top edge and carries 8px of its own
top padding, so its 17px line box starts at 13px and centres at 21.5px.
These three numbers move together — if the name's font-size or line-height
changes, recompute all of them. */
.service-tags {
top: 13px !important;
height: 17px;
align-items: center !important;
gap: var(--space-3) !important;
margin-top: 0 !important;
/* 14px right inset, against the 8px of text padding on the left — the
optical match, since the pill has no glyph hard against its edge. */
margin-right: 14px !important;
}
/* Every child of the cluster shares one baseline: status pills, latency
badges and container-stat buttons alike. */
.service-tag {
display: flex !important;
align-items: center !important;
height: 17px;
line-height: 17px !important;
}
.service-tag,
.docker-status,
.site-monitor-status,
.service-tag > div:not(.sr-only) {
background: transparent !important;
background-color: transparent !important;
padding: 0 !important;
border-radius: 0 !important;
box-shadow: none !important;
}
.docker-status > div,
.service-tag .text-\[8px\] {
display: flex !important;
align-items: center !important;
font-family: var(--font-mono) !important;
font-size: 9px !important;
font-weight: var(--weight-medium) !important;
line-height: 1 !important;
letter-spacing: var(--tracking-caps);
color: var(--text-muted) !important;
}
/* Flex-centred rather than nudged with vertical-align: the dot is an optical
round on a cap-height uppercase run, so it needs true centring, not a magic
pixel offset that only looks right at one font size. */
.docker-status > div::before {
content: "\25CF"; /* ● */
display: inline-block;
margin-right: 6px;
font-size: 7px;
line-height: 1;
color: var(--text-faint);
}
/* ⚠ Homepage emits `docker-status-<state>`, not `status-<state>`. Selectors
matching the latter silently hit nothing — which is how an earlier pass
shipped "themed" status pills that were still stock green. */
.docker-status-healthy > div::before,
.docker-status-running > div::before {
color: var(--success-text);
}
.docker-status-unhealthy > div::before,
.docker-status-exited > div::before,
.docker-status-dead > div::before {
color: var(--danger-text);
}
.docker-status-starting > div::before,
.docker-status-paused > div::before {
color: var(--warning-text);
}
/* Ping / latency — mono metadata, tabular, no chip. Deliberately the info
family and not a status colour: "how fast" must not read as "is it alive". */
.site-monitor-status > div {
font-family: var(--font-mono) !important;
font-size: 9px !important;
font-weight: var(--weight-regular) !important;
letter-spacing: var(--tracking-wide);
text-transform: lowercase !important;
font-variant-numeric: tabular-nums;
color: var(--text-faint) !important;
}
/* ---- Widget stat strip ---------------------------------------------------
A footer strip inside the card: hairline above, cells divided by hairlines,
value then a mono eyebrow label. The card stays one object instead of three
nested boxes.
Values are MONO, not the display face. Skyfall is explicit that numbers and
telemetry always take --font-mono; hierarchy against the label comes from
weight and size, which is the same tool the type system uses everywhere
else. Padding is tight because a stat strip adds its full height to its own
card and to nothing else in the row — at four cards across, one AdGuard
card opens a void beside three ordinary ones. It cannot be made free, but
it can be made small. */
.service-container {
margin-top: var(--space-2);
padding-top: 2px;
border-top: 1px solid var(--border-subtle);
gap: 0 !important;
}
.service-container > div {
background: transparent !important;
border-radius: 0 !important;
margin: 0 !important;
padding: 6px 4px 5px !important;
border-left: 1px solid var(--border-subtle);
min-width: 0; /* let a long value shrink the cell instead of overflowing */
}
.service-container > div:first-child {
border-left: 0;
}
/* 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. */
.service-container > div > div:not(.uppercase) {
font-family: var(--font-mono) !important;
font-size: var(--text-sm) !important;
font-weight: var(--weight-bold) !important;
letter-spacing: var(--tracking-tight);
line-height: 1.2 !important;
white-space: nowrap;
font-variant-numeric: tabular-nums;
color: var(--text-heading) !important;
}
/* label */
.service-container > div > div.uppercase {
font-family: var(--font-mono) !important;
font-size: 9px !important;
font-weight: var(--weight-regular) !important;
letter-spacing: var(--tracking-caps);
color: var(--text-muted) !important;
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. Not hidden — "no active streams" is the answer to
the question the card exists 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(--text-muted) !important;
}
/* ---- Bookmarks -----------------------------------------------------------
Same depth recipe as a service card. The name takes the display face and
the description drops to mono metadata, so a bookmark row reads as a
quieter sibling of a service card rather than a different widget that
wandered in. */
li.bookmark > a {
background: var(--surface-card) !important;
border: 1px solid var(--border-subtle);
border-radius: var(--radius-lg) !important;
box-shadow: var(--shadow-sm) !important;
margin-bottom: var(--space-2) !important;
overflow: hidden;
transition: background-color var(--duration-fast) var(--ease-out),
border-color var(--duration-fast) var(--ease-out),
box-shadow var(--duration-fast) var(--ease-out);
}
li.bookmark > a:hover {
background: var(--surface-raised) !important;
border-color: var(--border-default);
box-shadow: var(--shadow-md) !important;
}
.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: var(--text-sm) !important;
font-weight: var(--weight-medium) !important;
color: var(--text-heading) !important;
}
.bookmark-description {
font-family: var(--font-mono) !important;
font-size: 10px !important;
letter-spacing: var(--tracking-wide);
color: var(--text-muted) !important;
}
/* ---- Tab bar — the active rail -------------------------------------------
Skyfall sanctions exactly two accent expressions: the active rail (a 2px
accent bar on the active item, paired with an --accent-soft fill) and
hero-only glows. This is the rail, turned through 90° for a horizontal bar.
The previous theme ran a full-width blue -> cyan -> green aurora gradient
under this bar. It was the nicest thing on the page and it is gone on
purpose: a decorative gradient across the chrome is neither of the two
sanctioned expressions, and Skyfall permits the aurora motif only on
hero/empty-state areas, never behind body text. A plain hairline separates
chrome from content instead, and the colour lands on the active tab. */
ul.sm\:flex {
position: relative;
background: transparent !important;
background-color: transparent !important;
border-radius: 0 !important;
gap: 0;
overflow: visible;
}
ul.sm\:flex::after {
content: "";
position: absolute;
left: 0;
right: 0;
bottom: 0;
height: 1px;
background: var(--border-subtle);
}
ul.sm\:flex > li {
height: 40px !important;
border-radius: 0 !important;
background: transparent !important;
overflow: visible;
}
ul.sm\:flex > li > button {
margin: 0 !important;
height: 100%;
border-radius: 0 !important;
background: transparent !important;
background-color: transparent !important;
box-shadow: none !important;
border-top: 2px solid transparent;
font-family: var(--font-mono) !important;
font-size: var(--text-2xs) !important;
font-weight: var(--weight-medium) !important;
text-transform: uppercase;
letter-spacing: var(--tracking-caps);
color: var(--text-muted) !important;
transition: color var(--duration-fast) var(--ease-out),
background-color var(--duration-fast) var(--ease-out);
}
ul.sm\:flex > li > button:hover {
color: var(--text-body) !important;
background: var(--accent-soft) !important;
}
/* The active rail: 2px accent bar + --accent-soft fill. `dark:bg-white/10` is
the class Homepage puts on the selected tab; it is the only handle it
gives. */
ul.sm\:flex > li > button.dark\:bg-white\/10 {
border-top-color: var(--accent) !important;
color: var(--text-heading) !important;
background: var(--accent-soft) !important;
}
/* ---- Header chrome: resource widget + search -----------------------------
Same depth recipe as everything else, so the header belongs to the page
instead of hovering above it. --pad-chrome, not --pad-content: this is app
chrome, and Skyfall's negative-space rule is compact chrome / airy content. */
div.flex.flex-col.justify-center.mt-2,
div.flex.flex-row.self-center.flex-wrap.justify-between {
background: var(--surface-card) !important;
border: 1px solid var(--border-subtle);
border-radius: var(--radius-lg) !important;
box-shadow: var(--shadow-sm) !important;
}
/* The resource widget is INSIDE that strip, so it must not repeat the recipe
— nested hairline-and-shadow reads as a box in a box. */
.information-widget-resource {
border: 0 !important;
border-radius: 0 !important;
box-shadow: none !important;
background: transparent !important;
}
/* The CPU / memory / disk glyphs. Homepage colours them from its own Tailwind
theme, which lands almost invisible on Skyfall Day's near-white chrome. */
.information-widget-resource svg {
color: var(--text-muted) !important;
opacity: 1 !important;
}
.information-widget-resource .text-xs,
.information-widget-resource span {
font-family: var(--font-mono) !important;
font-size: var(--text-2xs) !important;
letter-spacing: var(--tracking-wide);
font-variant-numeric: tabular-nums;
color: var(--text-muted) !important;
}
.resource-usage,
[class*="resource-usage"] {
background: var(--surface-input) !important;
height: 3px !important;
border-radius: var(--radius-full) !important;
}
.resource-usage > div,
[class*="resource-usage"] > div {
background: var(--secondary) !important;
border-radius: var(--radius-full) !important;
}
/* Search input */
input[type="text"],
input[type="search"] {
font-family: var(--font-body) !important;
font-size: var(--text-base) !important;
color: var(--text-body) !important;
background: transparent !important;
}
input::placeholder {
color: var(--text-faint) !important;
font-family: var(--font-mono) !important;
font-size: var(--text-xs) !important;
letter-spacing: var(--tracking-wide);
}
/* ---- Theme toggle --------------------------------------------------------
Our own switch, injected by conf/custom.js. Homepage's built-in one is
unavailable: rendering it needs `theme:` unpinned in settings.yaml, and an
unpinned `theme:` makes the page's data loader throw — see the note in
custom.js for the measurement. Sized and shaped like an IconButton from the
Skyfall component set: --radius-md, chrome padding, hairline, and the same
accent-soft hover every interactive surface uses. */
#skyfall-theme-toggle {
display: inline-flex;
align-items: center;
justify-content: center;
width: 30px;
height: 30px;
margin-left: auto;
align-self: center;
flex: 0 0 auto;
border: 1px solid var(--border-subtle);
border-radius: var(--radius-md);
background: transparent;
color: var(--text-muted);
cursor: pointer;
transition: color var(--duration-fast) var(--ease-out),
background-color var(--duration-fast) var(--ease-out),
border-color var(--duration-fast) var(--ease-out);
}
#skyfall-theme-toggle:hover {
color: var(--text-heading);
background: var(--accent-soft);
border-color: var(--border-default);
}
#skyfall-theme-toggle:active {
background: var(--accent-soft-hover);
}
/* ---- Focus — Skyfall's ring ---------------------------------------------- */
a:focus-visible,
button:focus-visible,
input:focus-visible {
outline: none !important;
box-shadow: var(--focus-ring) !important;
border-radius: var(--radius-md);
}
/* ---- Version / footer ---------------------------------------------------- */
#version,
#version * {
font-family: var(--font-mono) !important;
font-size: var(--text-2xs) !important;
color: var(--text-faint) !important;
}
/* ---- Reduced motion — canon requires honouring it ------------------------ */
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
transition-duration: 0.01ms !important;
animation-duration: 0.01ms !important;
}
}
+40
View File
@@ -0,0 +1,40 @@
/* Australis Skyfall — Typography tokens */
:root {
/* Families */
--font-display: "Bespoke Sans", "Avenir Next", "Segoe UI", sans-serif;
--font-body: "Supreme", "Helvetica Neue", "Segoe UI", sans-serif;
--font-mono: "VictorMono Nerd Font", "SF Mono", "Cascadia Code", monospace;
/* Scale (web-app oriented; 14px UI base) */
--text-2xs: 11px;
--text-xs: 12px;
--text-sm: 13px;
--text-base: 14px;
--text-md: 16px;
--text-lg: 18px;
--text-xl: 22px;
--text-2xl: 28px;
--text-3xl: 36px;
--text-4xl: 48px;
--text-5xl: 64px;
/* Weights */
--weight-regular: 400;
--weight-medium: 500;
--weight-semibold: 600;
--weight-bold: 700;
--weight-extrabold: 800;
/* Line heights */
--leading-tight: 1.15;
--leading-snug: 1.35;
--leading-normal: 1.55;
--leading-relaxed: 1.7;
/* Tracking */
--tracking-tight: -0.02em;
--tracking-normal: 0;
--tracking-wide: 0.06em;
--tracking-caps: 0.1em;
}