I got some interview feedback the other day. It was good, actually. "Really awesome interview." "Super personable and honest, would be a great fit for the team." "Really enjoyed hearing about his side projects and portfolio experience." "Respected the commitment to experimentation on the portfolio site."
They didn't offer me the role.
The reason was fair: they needed someone with more demonstrable ownership experience given where the team is right now. Personality match was there, technical base was there, but the evidence of owning something end-to-end wasn't convincing enough. Which is useful feedback, even if the immediate emotional response is less "useful" and more "staring at the ceiling."
The vault build was already queued up. It wasn't a reaction to the rejection so much as a convenient place to channel the frustration. You can either sit with the feeling or ship something. I chose shipping.
The plan that lasted twenty minutes
This session was the first time I used Claude Code instead of my usual Desktop + MCP setup. The workflow was: plan the approach here on Desktop (hash out decisions, nail down data shapes, write a handoff brief), then switch to Claude Code in the terminal and let it execute.
The planning session produced a detailed brief. Two new routes: /vault/:project for entry lists, /vault/:project/:slug for individual documents. Two new page components mirroring the existing blog pattern. Prerender config updates. New stylesheets. Everything carefully specified.
Claude Code built it all. The prerender step generated thirteen static HTML pages across six projects. The build passed. The pages rendered. I looked at them in the browser and immediately knew the approach was wrong.
The vault is a place. A candlelit library with bookshelves and cats. Clicking a bookshelf should open a book, not teleport you to a different page with a light-themed header and a "Back to Engineering Gym" link floating above a white background. The content needs to render inside the panel that's already open.
The separate routes, their page components, and their prerender configuration all got deleted within the same session they were created. Planning is useful. Plans are disposable.
The seed data
Every vault entry needs two things: metadata and a markdown body. The metadata lives in src/data/vaultContent.js as a lookup object keyed by project slug:
export const VAULT_ENTRIES = {
ironiq: [
{
title: 'IronIQ - Master Plan',
slug: 'readme',
summary: 'A mobile-first workout tracker...',
tags: ['architecture', 'expo', 'offline-first', ...],
},
],
'drew-portfolio': [
{
title: 'drewbs.dev - Project Overview',
slug: 'overview',
// ...
},
{
title: "Mayu's Architecture Vault - How It Works",
slug: 'vault-architecture',
// ...
},
],
}Most projects have one entry. drewbs.dev has two. The array ordering defines which entry loads first when you open a project. This matters because the fetch function looks up the first entry's slug rather than hardcoding readme.md, which would have silently failed for drew-portfolio (whose first entry is overview, not readme). Obviously that broke immediately and needed fixing.
The markdown bodies were copied from Obsidian with frontmatter stripped. Seven files across six projects in public/content/vault/[project]/[slug].md. Wikilinks like [[docs/schema/SCHEMA]] are left as-is. They won't resolve to anything, but stripping them is Phase 2's problem when the n8n pipeline handles resolution at sync time.
The gitignore problem
The entire public/content/ directory was already gitignored because blog content is fetched from Supabase at build time. But vault content in Phase 1 is static. There's no Supabase to fetch from, so these files need to ship with the repo.
The first attempt at a negation pattern didn't work:
public/content/
!public/content/vault/Git's negation patterns can't un-ignore files inside an ignored directory. Once a directory is ignored, git never looks inside it to check for exceptions.
The fix was switching to a glob pattern:
public/content/*
!public/content/vault/public/content/* ignores the contents of the directory rather than the directory itself. The negation then works because git evaluates individual paths against the glob. Blog content stays ignored, vault content is tracked. Verified with git check-ignore -v returning no match for vault files while still catching posts.json. One of those problems that sounds simple until you remember gitignore has its own special relationship with logic.
In-panel rendering
The vault scene already has a two-state panel system. State 1 is the project card (title, subtitle, "Explore this project" button). State 2 is the expanded panel that previously showed a placeholder with a books emoji and the text "Architecture docs for {project} are being catalogued." Charming, but not exactly demonstrating ownership.
The replacement fetches markdown when the panel opens:
async function fetchReadme(slug) {
setExpanded(true)
setPanelContent(null)
const firstEntry = VAULT_ENTRIES[slug]?.[0]
if (!firstEntry) { setPanelContent('<p>No content available.</p>'); return }
try {
const res = await fetch(`/content/vault/${slug}/${firstEntry.slug}.md`)
if (!res.ok) throw new Error(res.status)
const md = await res.text()
setPanelContent(marked.parse(md))
} catch {
setPanelContent('<p>Failed to load content.</p>')
}
}The marked import is the same shared instance from markedConfig.js that the blog uses. One parser, consistent output. The panel body renders the parsed HTML with two classes: post-content inherits the blog's markdown body styles, vault-content scopes the colour overrides.
Vault-themed markdown styling
The content rendered correctly and was completely unreadable. White text assumptions against a dark vault surface. The vault has its own visual language built on warm hsl(35) tones, so the fix was a set of CSS overrides scoped to .vault-content using the same palette as the rest of vault.css:
.vault-content h1,
.vault-content h2,
.vault-content h3,
.vault-content h4,
.vault-content h5,
.vault-content h6 {
color: hsl(35 20% 92%);
border-top-color: hsl(35 20% 88% / 0.08);
}
.vault-content p,
.vault-content li,
.vault-content td,
.vault-content th,
.vault-content dd,
.vault-content dt {
color: hsl(35 20% 72%);
}
.vault-content strong,
.vault-content b {
color: hsl(35 20% 88%);
}
.vault-content a {
color: hsl(35 90% 65%);
border-bottom-color: hsl(35 90% 65% / 0.3);
}
.vault-content a:hover {
color: hsl(35 95% 72%);
}Blockquotes and tables inherit the same palette:
.vault-content blockquote {
border-left-color: hsl(35 90% 55%);
color: hsl(35 10% 60%);
}
.vault-content th,
.vault-content td {
border-color: hsl(35 20% 88% / 0.12);
}
.vault-content thead th {
background: hsl(35 10% 20% / 0.4);
}
.vault-content hr {
border-color: hsl(35 20% 88% / 0.08);
}Code blocks needed the most work. The blog's Meeko highlight theme uses pink and olive-green palettes that look wrong against the vault's warm dark surfaces. Inline code gets its own treatment:
.vault-content code:not(pre code) {
background: hsl(35 10% 18%);
color: hsl(35 90% 65%);
border: 1px solid hsl(35 20% 88% / 0.1);
border-radius: 4px;
padding: 0.15em 0.4em;
}Fenced code blocks get a slightly darker surface. The pre code reset removes the inline styling that would otherwise cascade:
.vault-content pre {
background: hsl(35 8% 12%);
border: 1px solid hsl(35 20% 88% / 0.08);
border-radius: 8px;
}
.vault-content pre code {
background: none;
border: none;
padding: 0;
color: hsl(35 15% 75%);
}The highlight.js token overrides follow the same scoping pattern. Keywords in warm amber, strings in muted green, comments in a barely-visible warm grey, function and class names in a cooler blue for contrast:
.vault-content .hljs-keyword,
.vault-content .hljs-selector-tag,
.vault-content .hljs-operator {
color: hsl(35 90% 65%);
}
.vault-content .hljs-string,
.vault-content .hljs-symbol,
.vault-content .hljs-bullet,
.vault-content .hljs-addition {
color: hsl(150 50% 55%);
}
.vault-content .hljs-comment,
.vault-content .hljs-quote {
color: hsl(35 10% 40%);
}
.vault-content .hljs-title,
.vault-content .hljs-title.function_,
.vault-content .hljs-title.class_ {
color: hsl(200 60% 65%);
}
.vault-content .hljs-number,
.vault-content .hljs-literal,
.vault-content .hljs-type {
color: hsl(25 80% 60%);
}
.vault-content .hljs-built_in {
color: hsl(175 50% 55%);
}
.vault-content .hljs-attr {
color: hsl(35 60% 65%);
}No new highlight.js theme file. No JavaScript changes. The existing Meeko theme continues to work for the blog. The vault just overrides the relevant .hljs classes via CSS specificity. .vault-content .hljs-keyword beats .hljs-keyword and [data-theme="dark"] .hljs-keyword because the extra class in the selector chain wins.
Claude Code: first impressions
This was my first build session using Claude Code instead of the Desktop + MCP workflow I've used for the entire portfolio so far. The difference is significant.
Desktop is better for planning. The conversation history, the project context, the ability to go back and forth challenging ideas. Today's planning session covered frontmatter auditing, content cleanup, data shape decisions, and writing a detailed handoff brief. That kind of thinking work belongs in a conversational interface.
Claude Code is better for execution. It reads files directly, runs commands, writes code, and iterates without the MCP tool-search overhead. The vault display layer went from brief to working code to PR in a single session. It also crashed twice due to API 500 errors because Anthropic was having infrastructure issues, which is the kind of thing that makes you appreciate the "earn your complexity" principle from a slightly different angle.
The workflow going forward: plan on Desktop, execute on Code. The CLAUDE.md in the repo acts as the handoff document between the two. Part A has permanent standing instructions, Part B gets the session brief updated after each planning session.
What's next
The vault display layer works. Real architectural documents render inside the vault scene with proper styling. A visitor clicking a project bookshelf can now read the actual architecture documentation rather than seeing a placeholder emoji.
But Phase 1 is only READMEs. One entry per project (two for drewbs.dev). When Wave 2 lands (ROADMAPs, DECISIONS documents, deep dives), the panel will need a way to switch between entries. A sidebar nav, probably. Not worth building until there's content to justify it.
The interview feedback is going to sit with me for a while. "Demonstrable ownership" is a fair gap to flag, but the vault isn't the answer to it. The vault shows I can reason about architecture. Ownership means showing I can do that in a team context: stakeholder conversations, requirement trade-offs, the whole messy end-to-end of delivering something with other people involved. That's a different problem, and one worth thinking about properly rather than just shipping more solo projects at it.
But today was a good day to ship something. Better than staring at the ceiling.