This document describes the actual current implementation of the site's visual system ("the galaxy theme"), verified directly against source as of 2026-08-24. It is a record of what exists, not a spec for a redesign — including the known inconsistencies, which are called out explicitly rather than glossed over.
Source files this document is derived from: src/styles/theme.css, src/styles/stars.css, src/styles/global.css, src/layouts/SiteLayout.astro, and representative page style blocks (src/pages/index.astro, src/pages/projects/[slug].astro).
Colors are stored as space-separated RGB triplets with no rgb() wrapper — e.g. --accent: 168 85 247;, not --accent: rgb(168, 85, 247);. This is intentional: it lets every consumer choose rgb(var(--x)) for a solid color or blend in alpha at the point of use.
:root {
--bg: 3 11 20;
--text: 236 239 244;
--muted: 180 188 200;
--card: 14 16 22;
--border: 36 42 54;
--accent: 168 85 247; /* pluto, the default accent */
--starTint: 168 85 247;
--r: 18px; /* the site's one shared border-radius token */
--card-bg: rgba(255, 255, 255, 0.02);
}--card-bg is the one exception to the triplet convention — it's a literal rgba() string, not a component triplet, because it's a pre-composed low-opacity surface tint rather than a color meant to be re-blended.
Controlled by an attribute on <html>, not a media query: :root[data-theme="dark"] / :root[data-theme="light"] (theme.css:18-37). Light mode redefines --bg, --text, --muted, --card, --border, and --card-bg (rgba(0,0,0,0.02) instead of the dark default rgba(255,255,255,0.02)).
--accent and --starTint are not redefined per light/dark — they're set by the accent (planet) system independently of theme brightness.
data-accent="<planet>" on <html> selects one of 8 accent colors (theme.css:39-98), each setting both --accent and --starTint:
| Planet | RGB triplet |
|---|---|
| mercury | 156 163 175 |
| venus | 244 114 182 |
| earth | 34 197 94 |
| mars | 239 68 68 |
| jupiter | 249 115 22 |
| saturn | 234 179 8 |
| uranus | 6 182 212 |
| neptune | 59 130 246 |
| pluto | 168 85 247 (default) |
Two legacy back-compat attribute values (teal, purple) also exist in CSS but aren't wired into any UI control — dead code, not part of the live design language.
- Solid color:
rgb(var(--accent))— the dominant pattern, used pervasively. - Alpha-blended color: two different idioms coexist in the codebase:
color-mix(in srgb, rgb(var(--accent)) N%, transparent)— the more common idiom (global.css, SiteLayout.astro, index.astro).rgb(var(--accent) / 0.NN)— space-separated-var-with-slash form, used directly in several places instead (e.g. the comet glow,.chip.featured).
This is a real, unresolved inconsistency — the codebase does not pick one alpha idiom and stick to it. Either form works correctly; a future cleanup pass could standardize on one, but as of this writing both are live and functioning.
Do not use rgba(var(--accent), N) — this was a real historical bug (UI-006): rgba() cannot consume a CSS custom property that expands to a space-separated triplet. Always use one of the two forms above.
--card-bg is meant to be owned solely by theme.css and consumed everywhere else via var(--card-bg). It's used this way 29+ times across 8 files. However, hardcoded rgba(255,255,255,0.0N) / rgba(0,0,0,0.0N) literals still exist in a handful of places that should arguably use the token instead (global.css card-hover wash, SiteLayout.astro drawer/popover insets, a page-local .weTab token set in index.astro, ImageCarousel.astro). Not a bug, but a known incomplete migration — see CQ-006 in the roadmap for the one instance (a no-op self-reference) that was cleaned up during the 2026-08-24 audit.
Three independent layers, composited inside a single fixed .bgfx container (global.css:152-240), explicit z-index stack:
.nebula z-index: 0 (ambient glow, slowest)
.starfield z-index: 1 (two star fields)
.comets z-index: 2 (three streaking comets, foreground)
.bgfx itself: position: fixed; inset: 0; z-index: 0; pointer-events: none; will-change: transform; contain: paint; — sits behind the header/main/footer, which are explicitly promoted to position: relative; z-index: 1.
4 stacked radial-gradients — two accent-tinted glows via color-mix, one white wash, and one fixed, non-accent-driven purple wash (rgba(90,40,140,0.07)) that stays constant regardless of the chosen accent planet. Slow drift via @keyframes nebulaDrift (70s, translate3d + scale). A film-grain texture overlay (.nebula::after, two repeating-radial-gradients at very low opacity, mix-blend-mode: overlay) is layered on top.
Two pseudo-element star layers, each a stack of radial-gradient() dots at fixed positions inside one CSS background-size tile:
.starfield::before— 283×217px tile (~1,250 dots on a 1080p screen),animation: drift1 22s linear infinite..starfield::after— 401×263px tile (~400 dots + 2 brighter "feature stars"),animation: drift2 34s linear infinite.
Why two different, oddly-specific tile sizes: they're deliberately non-matching ("prime-ish") so the two grids never visually align into a repeating pattern. The drift animations move background-position by exactly one full tile period (0 0 → -283px 217px, and 0 0 → 401px -263px), which is what makes the loop seamless — frame 100% is pixel-identical to frame 0%. This is a background-position animation, not transform: translate3d, specifically because a tiled background can loop natively that way; moving a positioned element can't loop without a visible jump (this was the exact bug UI-003 fixed).
Three instances (.c1/.c2/.c3), each a thin gradient streak (240px × 2px) plus a radial-gradient glow on ::after (not a CSS filter: blur() — filters are more GPU-expensive and don't composite as cleanly on integrated graphics; this was AUD2-007). Each comet has a distinct rotation, starting position, duration (14s/20s/26s), and animation delay so they don't move in lockstep. Driven by @keyframes cometFly in stars.css, defined separately from the comet element styles in global.css.
The whole galaxy system is not simply hidden in light mode — it's re-tuned: nebula switches to mix-blend-mode: multiply at lower opacity (0.32 vs 0.65 dark), starfield switches to multiply blend so accent-tinted dots read against a light background, and comets are hidden entirely (opacity: 0) since thin bright streaks don't read well on a light background.
Two layers of protection:
stars.css:151-164— a scoped rule specifically for the galaxy: stars stop animating (remain static, still visually present) and comets aredisplay: noneentirely.global.css:322-338— a global, universal rule collapsing every animation/transition on the page to near-instant (0.01ms) underprefers-reduced-motion: reduce. This also covers UI micro-interactions outside the galaxy (hover states, focus rings still change, just instantly).
One piece of reduced-motion handling lives outside CSS entirely: the About page's travel-gallery auto-rotation checks the media query in its own JS and skips setInterval accordingly (this is why a visible Pause/Play button also exists — WCAG 2.2 SC 2.2.2, fixed under UI/AUD phase 4).
<html data-theme="dark" data-accent="pluto" data-bgfx="on"> — these three data attributes drive the entire visual system and are set two ways: an inline pre-paint script reads localStorage and applies them before first paint (avoids a flash of the wrong theme), and a body-end script wires up the actual toggle controls (theme popover, accent swatches, mobile drawer) and persists changes back to localStorage.
Container system: .wrap { max-width: 1180px; margin: 0 auto; padding: 28px 44px; } — this is the outer shell (header/main/footer all get .wrap). Individual pages additionally use a var(--content, 1100px) fallback for their own inner content width — note --content is never actually declared anywhere as a real custom property; every page just relies on the 1100px fallback. It functions as a phantom token today; if it's ever meant to vary per-page, it would need an actual declaration.
Glass/panel treatment — deliberately not stacked:
.nav,header.wrap,.popoverusebackdrop-filter: blur(10px)— true glass.main.wrap.main(the big content panel most pages render inside) uses a solid, tinted background with nobackdrop-filter(rgb(var(--bg) / 0.88)dark,rgb(var(--card) / 0.94)light). This is intentional — stacking blur inside a fixed background element re-composites every scroll frame and was the root cause of a prior scroll-jank regression (fixed 2026-06-25). No component should add its ownbackdrop-filterinside.main— see UI-005 (a project detail page previously stacked an opaque dark layer on top of this panel and produced a near-black surface).
Accessibility baked into the shell: a real skip-link (#main-content, keyboard-focusable, visually hidden until focused), aria-expanded/aria-pressed/aria-label/aria-controls/role="group" on every interactive chrome control (theme popover, accent swatches, mobile menu), and explicit accent-colored :focus-visible outlines throughout — never relying on the browser default outline alone.
The layout's own breakpoint: exactly one value, 720px, used twice (popover full-width, nav → mobile drawer). This is the only breakpoint the shell itself defines; individual pages define their own separately (see §5).
There is no single global type-scale token list (no --fs-* custom properties) — sizes are set per-component, but a consistent rhythm emerges:
| Context | Size |
|---|---|
Hero h1 (homepage) |
clamp(52px, 5vw, 68px) |
Project detail h1 |
44px, font-weight: 800, accent-colored |
Research detail h1 |
clamp(26px, 4vw, 38px) — smaller and fluid, default text color (a deliberate contrast with project's bold accent treatment — see §6) |
Section header (.rHead, .cvSectionTitle, .serviceTitle, block h2) |
13–18px range, font-weight: 700, tight/negative letter-spacing (-0.01em to -0.03em) |
| Body/prose text | 13–15.5px |
| Small labels/chips/badges | 11–13px |
Global rule: h1, h2, h3 { letter-spacing: -0.02em; text-wrap: balance; } applies sitewide (global.css:37-42) — headings never wrap awkwardly on the last word. Not applied to h4–h6 or p.
There is no shared breakpoint token or variable — every page hardcodes its own pixel values. The actual distinct values in use, site-wide: 480, 600, 620, 640, 720, 760, 860, 980. They cluster loosely (small-mobile fixes around 620–640px, tablet/2-column splits around 760–980px) but are not standardized — e.g. the homepage uses 620/860 where the About and project-detail pages use 760/980. This is a known area of drift, not a deliberate two-tier or three-tier system. New pages should prefer the closest existing value rather than introducing a new one.
These were real regressions found and fixed in past audits (Phase 4, AUD2 sweep, UI sweep). Do not reintroduce them:
- Uppercase + wide-tracking "eyebrow" headers. Confirmed zero instances of
text-transform: uppercaseanywhere insrc/as of this audit. Section headers use weight + color + tight (negative) letter-spacing instead. - Side-stripe accent borders on cards (
::before { border-left: 3px }) — removed site-wide (was on.weCard). rgba(var(--x), N)— invalid CSS; custom properties expanding to space-separated triplets cannot be consumed by thergba()function. Usergb(var(--x))orrgb(var(--x) / N)orcolor-mix(...).backdrop-filterstacked on top of anotherbackdrop-filterpanel — causes visible near-black/over-dark surfaces and scroll-jank. The shell's main panel is deliberately solid, not glass, for exactly this reason.- Hardcoded
rgba(255,255,255,0.02)-style surface tints instead ofvar(--card-bg)— mostly migrated (29+ call sites), a few holdouts remain (see §1). Prefer the token for any new surface. - CSS
filter: blur()for soft glows on frequently-animated elements — replaced withradial-gradientglows (comet trails), which composite more cheaply.
Documented here so future work doesn't "rediscover" these as new bugs:
- Two coexisting alpha-blend idioms (
color-mix(...)vsrgb(var(--x) / N)) — both valid, not unified. --contentcustom property is referenced everywhere viavar(--content, 1100px)but never actually declared — the fallback is the only value that ever applies.- Breakpoint values are not standardized across pages (§5).
- A handful of hardcoded
rgba()surface tints remain outside--card-bg(§1). .nebula's purple wash layer is hardcoded and does not follow the selected accent planet, unlike the rest of the nebula gradients.
Last verified against source: 2026-08-24 (roadmap UI-013).