At a certain point during the vault's development, Vault.jsx was 700 lines long. It contained the static project and cat data, pure utility functions, canvas atmosphere rendering, SVG hotspot components, mobile card and footer components, page state, URL sync, and the JSX tree that assembled it all. Opening the file to change anything meant scanning past everything else first.
This post is about how it got refactored down to ~190 lines of pure page orchestration, and why each piece ended up where it did. The decisions are the interesting part -- the code is straightforward once you know where things belong.
The directory conventions
The project has explicit rules about what goes where, and they're worth stating upfront because the refactor followed them precisely:
src/data/-- inert static configuration arrays with no runtime behavioursrc/utils/-- pure functions with no React, no side effects, fully testable in isolationsrc/lib/-- modules with runtime behaviour (Supabase client, marked config, data fetching)src/hooks/-- stateful browser behaviour encapsulated as React hookssrc/components/vault/-- sub-components specific to the vault that aren't shared elsewhere
The test for any piece of code is: which of these does it match? If it's an array of objects with no functions, it's data/. If it takes inputs and returns outputs with no side effects, it's utils/. If it has useState or useEffect, it's a hook or a component.
src/data/vault.js
The first extraction was the static data. PROJECTS, CATS, LIGHT_SOURCES, LANTERNS, and ROSE_WINDOW were all living at the top of Vault.jsx as const declarations. None of them have behaviour. They're configuration.
Moving them to src/data/vault.js means Vault.jsx imports what it needs:
import { PROJECTS, CATS } from '../data/vault.js'And VaultAtmosphere.jsx imports what it needs:
import { LANTERNS, ROSE_WINDOW } from '../../data/vault.js'Nothing in either file needs to know about the rest of the data. PROJECTS doesn't know LANTERNS exists. This is correct -- the data is genuinely separate concerns that happen to live in the same file for convenience.
One consideration: vault.js is in data/ not lib/ precisely because it has no runtime behaviour. The moment a function is added (say, a helper that derives something from a project entry at runtime), that logic would move to utils/ or lib/ as appropriate. Data and behaviour don't mix in this project.
src/utils/vaultHelpers.js
Two functions came out of Vault.jsx that are pure transformations:
export function resolveCardAnchor(anchor) { ... }
export function toSVGPoints(points) { ... }toSVGPoints converts [[x, y], [x, y]] coordinate pairs to the space-separated string format SVG <polygon> expects. Pure input/output, no side effects, deterministic.
resolveCardAnchor takes a card anchor config object and returns the correct CSS style object and className. The logic: if the left percentage is greater than 65%, the card would overflow the right viewport edge, so it flips to right-anchored.
export function resolveCardAnchor(anchor) {
const leftVal = anchor.left ? parseFloat(anchor.left) : null
if (leftVal !== null && leftVal > 65) {
const rightPct = (100 - leftVal).toFixed(2)
return {
style: { right: `${rightPct}%`, top: anchor.top },
className: 'vault-card vault-card--right-anchored',
}
}
return {
style: anchor,
className: 'vault-card',
}
}Both functions are documented with JSDoc comments and live in utils/ because they have no React dependency and no side effects. They could be unit-tested with no setup at all. That's the signal -- if a function could live in a plain .js file with no imports and be tested with node, it belongs in utils/.
src/components/vault/VaultAtmosphere.jsx
The canvas atmosphere renderer was the most complex extraction. It contains the full RAF loop, four drawing passes (god rays, lantern glows, embers, dust motes), the resize handler, and all particle state.
It also exports VaultFilterDefs -- the SVG filter definitions for the hotspot glows. These two are co-located in the same file because both are pure rendering infrastructure with no interactive logic. Neither accepts props beyond what it needs to render. Neither drives page state. Splitting them into separate files would create an import relationship with no real benefit.
// VaultAtmosphere.jsx exports two things:
export default function VaultAtmosphere() { ... } // canvas RAF loop
export function VaultFilterDefs() { ... } // SVG <defs> blockVaultFilterDefs renders inside the vault SVG, above all polygon elements. It must be there before any polygon references a filter ID. Co-locating it with the atmosphere isn't just convenient -- it correctly signals that these are both "things that make the vault look like itself" rather than interactive pieces.
The canvas itself has no React state. All particle state lives inside the useEffect closure. The effect runs once on mount, sets up the RAF loop, and cleans up on unmount. Nothing about the particles needs to be accessible outside the canvas.
src/components/vault/ProjectHotspot.jsx and CatHotspot.jsx
Both hotspot types were previously inline JSX inside the vault's SVG map calls. Extracting them into components has a clear benefit: each one owns its own hover state.
ProjectHotspot manages hovered locally and derives a lit boolean from hovered || isActive. This means the shelf stays highlighted while its card is open, even after the mouse moves away -- which is correct behaviour that would be awkward to implement from the parent.
export default function ProjectHotspot({ project, isActive, onClick, onTooltip }) {
const [hovered, setHovered] = useState(false)
const lit = hovered || isActive
// ...
}CatHotspot deliberately has no hover state at all:
export default function CatHotspot({ cat, onAffirmation, disabled }) {
// No hovered state -- cats are easter eggs, not signposted interactions
return (
<g
className="vault-cat-hotspot"
onClick={() => { if (!disabled) onAffirmation(cat) }}
// ...
>The absence of hover state is a design decision, not an oversight. The cats are meant to be discovered. Hover glow would immediately signal "this area is interactive" to anyone moving a cursor across the image. pointer: cursor on hover is subtle enough to reward exploration without spoiling it.
src/components/vault/VaultMobileCard.jsx and VaultMobileFooter.jsx
Both are presentational components that receive props or have no props at all. Extracting them is mostly about keeping Vault.jsx readable -- the mobile layout is a self-contained UI mode that shouldn't pollute the main file.
VaultMobileCard gets a project and an onOpen callback:
export default function VaultMobileCard({ project, onOpen }) {
return (
<button className="vault-mobile-card" onClick={() => onOpen(project.slug)}>
{/* ... */}
</button>
)
}VaultMobileFooter has no props -- it renders the vault title and static contact links.
Both are in src/components/vault/ rather than src/components/ because they're specific to the vault page. They're not reusable outside it and never will be.
What Vault.jsx does now
After the refactor, Vault.jsx is ~190 lines. It has four responsibilities:
- State management (
activeSlug,expanded, bubble active/dots states per cat) - URL sync (
useEffectwatchingactiveSlug,useEffectrestoring from URL on mount) - Event handlers (
handleHotspotClick,handleCatClick,handleDismiss,handleExpand,handleMobileOpen) - JSX structure -- assembling the extracted components with the right props
It imports data from data/, utility functions from utils/, sub-components from components/vault/, and hooks from hooks/. The page file knows the shape of the page. It doesn't know how a hotspot renders, how a cat bubble animates, or how particles are drawn.
Why this matters beyond tidiness
The architectural cleanliness of this refactor is genuinely portfolio-relevant. It demonstrates:
The distinction between data and behaviour. Moving PROJECTS to data/vault.js isn't just about where a file lives -- it's a claim that this data has no runtime behaviour, which means it can be reasoned about in isolation and potentially replaced or extended without touching any rendering logic.
The distinction between pure functions and components. toSVGPoints and resolveCardAnchor in utils/ are independently testable. If the card anchor logic ever breaks, you can write a test for just that function. You can't do that when the logic is buried inline in JSX.
The distinction between "this component manages its own concerns" and "this component is told what to do." ProjectHotspot owns its hover state because hover is a local concern. It exposes onClick and onTooltip callbacks for things the parent needs to know about. The boundary between local state and shared state is explicit and justified.
These are the kinds of distinctions a hiring manager is looking for when they read a portfolio codebase. Not just "it works" but "the person writing this understood why each piece is where it is."