/* 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 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 too, not on
. */ 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 `` 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); }