/* 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 , 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 — 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[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 `
` 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 `

` 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-`, not `status-`. 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; } }