There's a version of this post that starts with the finished product and works backwards, explaining how it was built. That's not this post. This post starts at the beginning — with an idea that seemed straightforward and turned out to have at least four distinct ways to go wrong before it looked right.

The result is live at drewbs.dev/vault. This is the alpha. The content is mostly placeholder. But the interaction layer is real, and the engineering behind it is worth documenting while it's fresh.


What is Mayu's Architecture Vault?

The portfolio has a dark theme, triggered by clicking the cat portrait in the header. In dark mode, Meeko is replaced by Mayu, a slightly more serious cat who, most of the time silently judges me from her prominent perch on the cat tree in the living room. The vault extends her character into a dedicated page.

The concept: an ancient library, rendered as a full-viewport background image, where each bookshelf corresponds to a project in the portfolio. Clicking a shelf opens a card with a project summary. Clicking through expands to a full architecture panel — decision logs, system diagrams, honest post-mortems.

It's also the content delivery mechanism for MayusVault, an Obsidian vault I've been building in parallel that documents every significant engineering decision across all my projects. The sync pipeline to get vault content into Supabase and rendered on the site is a separate post. This post is purely about the UI layer.


The background image

The library scene was AI-generated. The brief was specific: a wizards tower library, spiral staircases, candlelight, two sleeping cats. Meeko asleep on the stairs, Mayu asleep on the floor. The image needed to feel like a place, not a render.

It lives in public/images/ as a static asset rather than Supabase Storage. The reasoning is simple: it's a fixed UI asset with no dynamic variation. Supabase Storage adds a network round-trip, a fetch, and potential cache invalidation complexity for something that will never change. Static assets get edge-cached by Cloudflare automatically. No infrastructure needed.

The vault page uses position: fixed; inset: 0 to escape the layout's max-width container entirely, with object-fit: cover on the image. The first gotcha arrived here.

object-position: center top — the initial value — cut Mayu off entirely. She's in the lower-right foreground and top anchors to the sky. The fix was object-position: center 30%, which shifts the frame down enough to keep both cats visible. Thirty percent isn't magic; it's just what looked right after testing, although I may move this more in the final version as she's still a little cut off on the full page.


The hotspot problem

The interactive areas needed to sit precisely on the bookshelves, which are perspective-distorted in the image. A rectangular div will never align with a trapezoidal bookshelf viewed at an angle.

The solution was SVG polygons with arbitrary corner coordinates — drawn with a custom tool so the coordinates could be placed by eye rather than calculated mathematically.

But before building the editor, there was a coordinate system problem to solve.

Why viewBox="0 0 100 100" with preserveAspectRatio="none"

The SVG overlay sits on top of the background image. If the SVG uses pixel coordinates, they need to be recalculated whenever the viewport resizes. If it uses viewBox with a fixed aspect ratio, the coordinates are correct but the SVG squishes on non-matching viewports.

The right answer: viewBox="0 0 100 100" combined with preserveAspectRatio="none". This makes every coordinate a percentage of the SVG's rendered dimensions. A point at [50, 50] is always the centre of the container, regardless of screen size. The SVG stretches non-uniformly to fill its container — which is exactly what you want when the background image is also stretching via object-fit: cover.

One immediate consequence: all SVG attribute values that look like sizes must be in SVG user units, not CSS pixels. font-size: 10px in a viewBox="0 0 100 100" space renders at 10% of the container height — that's roughly 90px on a typical screen. The first test of the hotspot editor had enormous WordArt-style labels plastered across the image. The fix was setting font-size, stroke-width, and r as SVG attributes with small unitless values (1.8, 0.25, 0.9) rather than CSS pixel declarations.


The hotspot editor

Rather than guess at coordinates and hot-reload repeatedly, I built a standalone HTML tool: scripts/hotspot-editor.html. It's never deployed; it's a dev tool that lives in the repo.

Version one used CSS rect-based divs dragged to position. The problem: CSS rectangles can't be skewed to match perspective. A bookshelf viewed at an angle is a trapezoid, not a rectangle.

Version two switched to SVG polygons with four draggable corner handles. Draw mode places a rectangle; edit mode lets you drag each corner independently to match the perspective of the shelf. The output is an array of [x%, y%] coordinates ready to paste into the component.

The most important thing the editor does is render the background image with the exact same CSS treatment as the live page: object-fit: cover, object-position: center 30%, inside a 16:9 aspect-ratio container. This sounds obvious in retrospect but wasn't the initial approach — the first version just displayed the raw image at natural dimensions. Every coordinate drawn there was wrong on the live page because the crop frame was completely different. The moment the editor matched the live crop, the coordinates transferred correctly.

A note in patterns.md now says: if you change object-position in vault.css, update the editor too. They must stay in sync or all coordinates break silently.

The editor was extended this session to support two hotspot types: shelves (amber) and cats (violet). A type toggle switches between pools, auto-advancing to the next undrawn item. The output generates separate PROJECTS and CATS array sections.


The glow approach

Early versions used CSS box-shadow on div elements for the glow effect. Two problems: box-shadow doesn't apply to SVG elements, and rectangular glows over trapezoidal hotspots look wrong regardless.

The SVG approach uses feGaussianBlur filters defined once in a <defs> block at the top of the SVG and referenced by all polygons. Two blur passes — tight (stdDeviation="1.2") and wide (stdDeviation="3.5") — are merged with feMerge to create a layered halo: a bright core surrounded by a softer ambient spread. Hover adds a third pass at an even wider deviation for noticeably more intensity without changing the polygon fill at all.

The initial implementation set fill="hsl(35 90% 62%)" on each polygon and animated opacity on the element. This produced the correct glow effect — but it also produced clearly visible amber rectangles over the bookshelves, because the opaque fill was visible underneath the blur. The solution is:

fill="transparent" (not fill="none").

This is a distinction that matters. fill="none" tells the browser there's no fill, which means pointer events only register on the stroke path — clicking the interior of the polygon does nothing. fill="transparent" renders an invisible fill that still participates in hit testing. The interior becomes clickable.

With fill="transparent", the polygon has no visible colour. The stroke (strokeWidth="1.5", coloured amber or violet) is the only source of pixels. The feGaussianBlur filter spreads that hairline stroke outward into a soft glow. At the filter's blur radius, the original stroke is no longer visible as a line — it reads as diffuse light emanating from the bookshelf boundary.

SVG filter defs

All four filters live in a VaultFilterDefs component rendered once at the top of the SVG. The critical detail is the filter region: x="-120%" y="-120%" width="340%" height="340%". Without this oversized bounding box, the blur gets hard-clipped at the polygon boundary and you lose the soft fade-out at the edges.

function VaultFilterDefs() {
  return (
    <defs>
      <filter id="shelf-idle" x="-120%" y="-120%" width="340%" height="340%">
        <feGaussianBlur in="SourceGraphic" stdDeviation="1.2" result="b1" />
        <feGaussianBlur in="SourceGraphic" stdDeviation="3.5" result="b2" />
        <feMerge>
          <feMergeNode in="b2" />
          <feMergeNode in="b1" />
        </feMerge>
      </filter>

      <filter id="shelf-hover" x="-150%" y="-150%" width="400%" height="400%">
        <feGaussianBlur in="SourceGraphic" stdDeviation="1.5" result="b1" />
        <feGaussianBlur in="SourceGraphic" stdDeviation="4"   result="b2" />
        <feGaussianBlur in="SourceGraphic" stdDeviation="9"   result="b3" />
        <feMerge>
          <feMergeNode in="b3" />
          <feMergeNode in="b2" />
          <feMergeNode in="b1" />
        </feMerge>
      </filter>
      {/* cat-idle and cat-hover follow the same pattern in violet */}
    </defs>
  )
}

The feMerge node order matters: wider blurs go first so they render behind tighter ones. The result is a broad ambient halo with a brighter core rather than a single uniform smear. Hover adds a third very wide pass (stdDeviation="9") for noticeably more presence.

Hotspot components

ProjectHotspot tracks its own hover state and derives a lit boolean from hover OR active. Active means the card is currently open — the shelf stays bright even after the mouse moves away.

function ProjectHotspot({ project, isActive, onClick }) {
  const [hovered, setHovered] = useState(false)
  const lit = hovered || isActive

  return (
    <g
      className={`vault-project-hotspot${lit ? ' is-hovered' : ''}`}
      onClick={() => onClick(project.slug)}
      onMouseEnter={() => setHovered(true)}
      onMouseLeave={() => setHovered(false)}
      style={{ cursor: 'pointer' }}
    >
      <polygon
        className={`vault-hotspot-poly vault-hotspot-poly--shelf${lit ? ' is-hovered' : ''}`}
        points={toSVGPoints(project.hotspot.points)}
        fill="transparent"
        stroke="hsl(35 90% 62%)"
        strokeWidth="1.5"
        filter={lit ? 'url(#shelf-hover)' : 'url(#shelf-idle)'}
      />
    </g>
  )
}

fill="transparent" is doing real work here — not aesthetic. fill="none" means the browser only registers pointer events on the stroke path itself, making the interior of the polygon unclickable. fill="transparent" is invisible but participates in hit testing normally. One attribute, big difference.

toSVGPoints maps the [x, y] pairs into the space-separated format <polygon> expects:

function toSVGPoints(points) {
  return points.map(([x, y]) => `${x},${y}`).join(' ')
}

CatHotspot is structurally identical but fires onAffirmation on click rather than opening a card, and references the violet filter pair.


The pulse animation

The glows needed to pulse — a slow breath that signals interactivity without being aggressive about it. The mechanism is CSS @keyframes animating stroke-opacity between a dim value and a brighter one:

@keyframes shelfPulse {
  0%, 100% { stroke-opacity: 0.25; }
  50%       { stroke-opacity: 0.75; }
}

Critically, this animates stroke-opacity, not opacity. Animating element opacity would scale the entire element including the (transparent) fill, but animating stroke-opacity only affects the stroke — which is the glow source. The filter output scales proportionally.

Five shelves with animation-delay staggered at 0.55s intervals means they breathe out of phase with each other. A library where everything pulses in unison feels mechanical. Staggered feels organic.

Cats use a 4-second cycle instead of 3 — slightly slower, more appropriate for something sleeping.

Hover kills the animation entirely (animation: none) and snaps stroke-opacity to 1 via a fast transition. The snap is deliberate — a gradual hover-in would make the interaction feel sluggish. The contrast between the idle pulse and the immediate hover response is part of the affordance signal.

CSS pulse and stagger

The entire pulse system is CSS — no JavaScript timers. stroke-opacity is what gets animated rather than opacity on the element; animating element opacity would affect the invisible fill too and produce no visible change:

.vault-hotspot-poly--shelf {
  animation: shelfPulse 3s ease-in-out infinite;
}

.vault-project-hotspot:nth-of-type(1) .vault-hotspot-poly--shelf { animation-delay: 0s; }
.vault-project-hotspot:nth-of-type(2) .vault-hotspot-poly--shelf { animation-delay: 0.55s; }
.vault-project-hotspot:nth-of-type(3) .vault-hotspot-poly--shelf { animation-delay: 1.1s; }
.vault-project-hotspot:nth-of-type(4) .vault-hotspot-poly--shelf { animation-delay: 1.65s; }
.vault-project-hotspot:nth-of-type(5) .vault-hotspot-poly--shelf { animation-delay: 2.2s; }

.vault-hotspot-poly--shelf.is-hovered {
  animation: none;
  stroke-opacity: 1;
  transition: stroke-opacity 0.15s ease;
}

is-hovered is applied in React when lit is true. Killing the animation and snapping stroke-opacity to 1 gives the instant-bright hover response. prefers-reduced-motion gets explicit handling — 0.01ms duration rather than 0s, which avoids quirks with animation-fill-mode: forwards:

@media (prefers-reduced-motion: reduce) {
  .vault-hotspot-poly--shelf,
  .vault-hotspot-poly--cat {
    animation-duration: 0.01ms !important;
    stroke-opacity: 0.5;
  }
}

The cat affirmations

The two sleeping cats in the image are also interactive. Clicking Meeko (grey tabby on the stairs) pulls a quote from the meeko_affirmations Supabase table. Clicking Mayu (fluffy ginger on the floor) pulls from mayu_affirmations. This is independent of the global theme toggle — Mayu is always Mayu in the vault, regardless of which theme is active on the rest of the site.

The affirmation logic was already extracted into a useAffirmation hook during the dark theme work. The vault cats just instantiate two separate hook instances — one per character — and call fetchAffirmation() on click. The quote appears in a small frosted glass bubble near the cat, auto-dismisses after 5 seconds, and uses a violet colour palette to feel distinct from the amber bookshelf glows.

The header-portrait-group (the cat portrait and speech bubble in the nav) is conditionally not rendered when on the vault page. The cats in the scene take over that role. The header on vault is nav-only, pushed to the right.

Page-level state

const [activeSlug, setActiveSlug]         = useState(null)
const [expanded, setExpanded]             = useState(false)
const [catAffirmation, setCatAffirmation] = useState(null)

const meeko = useAffirmation('light')
const mayu  = useAffirmation('dark')

Two useAffirmation instances — one per character, entirely independent. URL sync watches activeSlug and uses history.replaceState rather than React Router's navigate so switching shelves doesn't push history entries:

useEffect(() => {
  const url = new URL(window.location.href)
  if (activeSlug) { url.searchParams.set('project', activeSlug) }
  else            { url.searchParams.delete('project') }
  window.history.replaceState({}, '', url.toString())
}, [activeSlug])

A mirroring effect on mount reads ?project= from the URL so direct links work on first load.

Cat affirmation flow

The click handler clears the previous quote before showing the new one, otherwise the old text lingers while the fetch is in flight:

async function handleCatClick(cat) {
  const hook = cat.affirmationTheme === 'dark' ? mayu : meeko
  setCatAffirmation(null)       // clear immediately
  await hook.fetchAffirmation() // wait for fetch + typewriter to initialise
  setCatAffirmation({ cat, hook })
}

Auto-dismiss resets on each new catAffirmation value:

useEffect(() => {
  if (!catAffirmation) return
  const t = setTimeout(() => setCatAffirmation(null), 5000)
  return () => clearTimeout(t)
}, [catAffirmation])

useAffirmation hook

The hook uses raw fetch rather than the Supabase JS client — .schema() fails to send Accept-Profile in browser context, so all cross-schema queries bypass the client entirely:

export function useAffirmation(theme) {
  const [displayed, setDisplayed] = useState('')
  const [loading, setLoading]     = useState(false)

  const fetchAffirmation = useCallback(async () => {
    setLoading(true)
    setDisplayed('')
    const table = theme === 'dark' ? 'mayu_affirmations' : 'meeko_affirmations'

    const res = await fetch(
      `${import.meta.env.VITE_SUPABASE_URL}/rest/v1/${table}?select=text&active=is.true`,
      {
        headers: {
          'Accept-Profile': 'drew_portfolio',
          'apikey': import.meta.env.VITE_SUPABASE_JWT_ANON_KEY,
          'Authorization': `Bearer ${import.meta.env.VITE_SUPABASE_JWT_ANON_KEY}`,
        },
      }
    )
    const data = await res.json()
    const text = data[Math.floor(Math.random() * data.length)].text
    setLoading(false)

    // Typewriter: build displayed one character at a time
    let i = 0
    const interval = setInterval(() => {
      i++
      setDisplayed(text.slice(0, i))
      if (i >= text.length) clearInterval(interval)
    }, 35)
  }, [theme])

  return { displayed, loading, fetchAffirmation }
}

The component renders displayed rather than the full quote, so the bubble builds character by character as the interval fires.


The layout

Rather than a separate layout route, a useEffect sets data-page="vault" on the body and removes it on cleanup:

useEffect(() => {
  document.body.setAttribute('data-page', 'vault')
  return () => document.body.removeAttribute('data-page')
}, [])

The cleanup ensures no other page inherits the vault styles. The CSS targets site-level elements directly. justify-content: flex-end on .header-inner is what right-aligns the nav once the portrait stops rendering — flexbox collapses naturally when the left-hand element is gone:

body[data-page="vault"] .header-inner {
  justify-content: flex-end;
}

Header.jsx conditionally skips rendering header-portrait-group when the path is /vault, handled locally with useLocation — no prop threading required.


The data structure

The page has two static arrays at the top of Vault.jsx. No API call, no database — shelf positions and project metadata are configuration, not content. Content comes later.

const PROJECTS = [
  {
    slug: 'ironiq',
    title: 'IronIQ',
    subtitle: 'Offline-first workout tracking. The most technically ambitious project in the vault.',
    hotspot: { points: [[23.25, 36.04], [28.55, 37.88], [28.82, 43.14], [23.39, 42.28]] },
    cardAnchor: { left: '26%', top: '39.84%' },
  },
  // ...4 more projects
]

const CATS = [
  {
    id: 'meeko',
    affirmationTheme: 'light',
    points: [[30.2, 53.17], [39.97, 53.54], [39.97, 61.36], [30.27, 59.77]],
    bubbleAnchor: { left: '24.1%', top: '44.96%' },
  },
  {
    id: 'mayu',
    affirmationTheme: 'dark',
    points: [[69.21, 89.74], [85.1, 87.78], [93.08, 99.53], [64.8, 99.53]],
    bubbleAnchor: { left: '67.05%', top: '82.14%' },
  },
]

The points arrays are [x%, y%] pairs — four corners clockwise from top-left. Those numbers came directly from the hotspot editor. cardAnchor and bubbleAnchor are inline style objects that position the floating UI near each hotspot.


What's alpha about this alpha

The interaction layer is complete. Every bookshelf is clickable and opens a project preview card. Clicking through opens an expanded panel. The cats respond to clicks. The glow animations run. Deep-linking via ?project=slug works.

What doesn't exist yet: actual content. The expanded panel shows a placeholder. That's because the content pipeline — vault_entries table in Supabase, sync tool from MayusVault, admin panel for manual seeding — hasn't been built yet. The UI is waiting for its data layer.

Shipping the alpha anyway is the right call. The design is worth showing. The placeholder is honest about the state of things. And "an interactive library that documents architectural decisions" is a stronger portfolio statement as a live URL than as a Figma mock.

More to come.