The vault is a full-screen interactive scene built on a fixed background image with SVG hotspots mapped to percentage coordinates. That last part -- SVG coordinates as percentages of the image -- is what makes everything else complicated. Change how the image renders and you've silently broken every hotspot. The layout has to protect the coordinate mapping at every viewport size.
There are three distinct viewport ranges, each with a completely different approach:
≥1536px-- full desktop experience, scene fills the viewport769px–1535px-- scroll-hack: scene runs at native resolution, viewport becomes a scrollable window≤768px-- mobile: scene is a background image, SVG is hidden, list UI takes over
These ranges don't overlap. Each is self-contained. Here's why each one works the way it does.
Desktop (≥1536px): the coordinate space problem
The vault background image is 1536×1024px. At desktop widths, the scene needs to fill the viewport regardless of whether it's 1536px, 1920px, or wider. object-fit: cover handles this for the <img> element -- but the SVG hotspot overlay needs to sit in exactly the same coordinate space as the image it covers.
The solution is a shared container: .vault-canvas.
.vault-canvas {
position: absolute;
top: 50%; left: 50%;
transform: translate(-50%, -50%);
aspect-ratio: 3 / 2;
width: 100%;
height: auto;
min-height: 100%;
min-width: calc(100vh * 3 / 2);
}This is object-fit: cover reimplemented as a block element. The 3/2 aspect ratio matches the image. width: 100% lets it fill the viewport width. min-height: 100% ensures it covers the full viewport height even when the image is wider than it is tall. min-width: calc(100vh * 3/2) prevents it squashing on very tall narrow viewports.
The SVG overlay (viewBox="0 0 100 100" preserveAspectRatio="none") and the background image both fill .vault-canvas with position: absolute; inset: 0. They share the exact same box. A hotspot polygon point at [50, 50] in SVG space maps to the pixel at the visual centre of the image, at every viewport size.
preserveAspectRatio="none" is doing real work here -- without it the SVG would letterbox, and the coordinate mapping would break the moment the viewport aspect ratio diverged from 3:2.
The scroll-hack (769px–1535px)
At viewport widths below 1536px, object-fit: cover would start cropping the image. The SVG hotspots are calibrated to the uncropped image composition. If the image is cropped, the hotspots float into the wrong areas.
The fix: stop trying to cover the viewport. Instead, run the scene at its native 1536×1024 resolution and let the user scroll to see all of it.
@media (min-width: 769px) and (max-width: 1535px) {
body[data-page="vault"],
body:has(.vault-scene) {
overflow: auto !important;
width: 1536px;
min-height: max(1024px, 100vh);
}
.vault-scene {
position: absolute;
width: 1536px;
height: max(1024px, 100vh);
}
.vault-canvas {
width: 1536px !important;
height: max(1024px, 100vh) !important;
min-width: unset !important;
min-height: unset !important;
aspect-ratio: unset !important;
top: 0 !important;
left: 0 !important;
transform: none !important;
}
}The body becomes the scroll container at exactly 1536px wide. The scene and canvas fill it. Two-finger drag on mobile Safari and standard touch scroll on Android work through overflow: auto natively -- no JS drag library needed.
The breakpoint numbers are not arbitrary. 1536px is the pixel width of the background image -- the exact resolution below which object-fit: cover would start cropping. 769px is where the mobile layout takes over. These are image-dimension constraints, not device breakpoints.
.vault-intro and .vault-hint switch to position: fixed in this range so they stay anchored to the visible viewport rather than scrolling with the scene.
Mobile (≤768px): ditch the scene
At 768px and below, the SVG interaction model doesn't work. The hotspot polygons are too small for reliable touch targets. Hover doesn't exist. The tooltip is pointer-only. The whole premise of clicking precise areas on a detailed image is wrong for this form factor.
The solution is to drop the scene entirely and replace it with a conventional list UI, while keeping the background image as a decorative backdrop.
@media (max-width: 768px) {
.vault-hint,
.vault-intro,
.vault-tooltip,
.vault-dim,
.vault-svg,
.vault-card,
.vault-cat-bubble { display: none; }
.vault-scene {
position: fixed !important;
inset: 0 !important;
width: 100vw !important;
height: 100vh !important;
}
.vault-canvas {
position: absolute !important;
inset: 0 !important;
width: 100% !important;
height: 100% !important;
}
}The mobile-specific components -- .vault-mobile-list and .vault-mobile-footer -- are hidden on desktop via display: none and shown only in this range. They render from the same PROJECTS data array that drives the desktop hotspots, so adding a project in one place adds it everywhere.
Tapping a mobile card calls handleMobileOpen(slug) which sets activeSlug and expanded: true simultaneously, skipping the desktop card state (state 1) and going straight to the expanded panel (state 2). On mobile there's no room for a floating card over the list -- full-screen panel is the right treatment.
The --header-h CSS custom property is set via useEffect in Vault.jsx and updated on resize. The mobile list and panel both use it to position themselves below the header correctly regardless of the actual rendered header height:
useEffect(() => {
function syncHeaderHeight() {
const h = document.querySelector('header')?.offsetHeight ?? 0
document.documentElement.style.setProperty('--header-h', `${h}px`)
}
syncHeaderHeight()
window.addEventListener('resize', syncHeaderHeight)
return () => window.removeEventListener('resize', syncHeaderHeight)
}, [])The stacking context issue (and its fix)
The vault has two layers that need to be independently stackable: the dim overlay (which darkens the scene when a card is open) and the card itself. The card needs to sit above the dim. Both were position: absolute elements.
The problem: the dim was a direct child of .vault-scene, while the card was nested inside .vault-canvas. .vault-canvas had no explicit z-index, so it formed no stacking context -- the dim at z-index: 2 painted over the entire canvas including the card at z-index: 3 inside it.
Fix: move the dim inside .vault-canvas, making it a sibling of the card in the same stacking context. Now z-index: 2 vs z-index: 3 is a fair comparison and the card wins.
The mobile panel had a related but different problem. .vault-panel was position: absolute inside .vault-scene which has z-index: 0. The mobile list and footer are position: fixed in the root stacking context at z-index: 300. An element inside a z-index: 0 stacking context cannot escape above elements in the root context regardless of its own z-index value.
Fix: on mobile, the panel becomes position: fixed; z-index: 350, which pulls it out of .vault-scene's stacking context entirely and into the root context where it can compete correctly.
The FOUC prevention pattern
body[data-page="vault"] drives a large number of vault-specific styles: the fixed header, the transparent footer, the hidden copyright notice, the whole header colour scheme. That attribute is set in a useEffect -- after paint.
On a hard refresh, the browser paints one frame with the default styles before the effect fires. The header and footer flash.
The fix is to duplicate every body[data-page="vault"] rule with body:has(.vault-scene). .vault-scene is present in the DOM from the first paint. The :has() selector fires immediately, so vault styles are active from frame one.
body[data-page="vault"] .site-header,
body:has(.vault-scene) .site-header {
position: fixed;
background: hsl(30 5% 2% / 0.45);
/* ... */
}data-page still gets set on mount as the canonical mechanism. :has(.vault-scene) is the FOUC (Flash Of Unstyled Content) guard only.
What the layout protects
The key invariant is: the SVG coordinate space must always describe the same image content, at every viewport size. The three breakpoint ranges are three different ways of maintaining that invariant given different constraints. Desktop covers the viewport while preserving the full image. The scroll-hack runs the image at native resolution. Mobile drops the SVG entirely. Each approach is the right trade-off for its context -- there's no universal solution that works cleanly across all three.