2026.04.19 · 12 min · web, react, vite, architecture
I used Claude to design the LCARS aesthetic, then Claude Code to implement it. Here is what the architecture looked like, what broke, and why each decision got made.
The site started as a conversation with Claude. I described the LCARS terminal aesthetic from Star Trek: Picard, worked through colour token decisions and layout options, then handed the resulting spec to Claude Code to implement. The first version needed iteration, but it was something functional in an afternoon.
I treat Claude as a senior collaborator on the design phase, not a code generator. Before any TypeScript existed, I described the constraints — monospaced secondary font, display font for headers, CSS custom properties for the entire colour system, two modes (Night Ops and Daylight), no icon libraries — and iterated on what came back. Claude produced a token set (`--accent`, `--fg`, `--fg-dim`, `--fg-muted`, `--warm`, `--hairline`) and a CSS Grid layout. The LCARS chrome — cut corners, panel borders, scan animations — came from working through `clip-path` and `box-shadow` values together. The output was a design document I could evaluate, push back on, and hand to an implementation phase.
The value was having something concrete to react to rather than starting from a blank file. I approved the structure and token names before Claude Code wrote a line of TypeScript.
Claude Code scaffolded the Vite + React + TypeScript project and built first versions of every component from the spec. HashRouter from the start — GitHub Pages doesn't support server-side routing, so anything using the history API needs a `404.html` redirect hack that breaks on direct asset loads. HashRouter needs no server configuration and works correctly on a static host. URLs look like `/#/about`.
All content started in a single `src/data/index.ts`: PROJECTS, POSTS, EXPERIENCE, SKILLS, CHANNELS, all TypeScript types. At a few hundred lines it was manageable. It didn't stay that way.
The first real blog post pushed `index.ts` past the point where editing it felt safe. One post body was 50 lines of nested TypeScript objects. That format also makes diffs hard to read and review.
The refactor split everything by concern:
- `types.ts` — all interfaces and union types - `projects.ts` — PROJECTS array - `experience.ts` — EXPERIENCE array - `certifications.ts` — CERTIFICATIONS array - `blog/index.ts` — auto-discovers posts via `import.meta.glob` - `index.ts` — thin barrel re-exporting everything
Page components kept the same imports. The barrel preserves backward compatibility with any consumer that imported from `src/data`.
Writing blog posts as TypeScript objects creates ongoing maintenance friction. Backticks and apostrophes inside template literals need escaping. Multi-paragraph text means manually splitting strings into typed `{ type: 'p', text: '...' }` objects. Adding a second post made this untenable.
The replacement was a custom Markdown parser — no remark, no unified, no new runtime dependencies. About 120 lines of TypeScript mapping standard syntax to the existing `Block[]` type:
- `## heading` → `h2` block - Fenced code block with language tag → `code` block - `- list items` → `list` block - `
- First paragraph promoted to lede automaticallyok Current state Twelve projects and three posts, all in Markdown. The only file that needs editing to publish new content is the `.md` itself. No TypeScript, no imports, no registration steps. :::Vite's `?raw` import loads each `.md` file as a string at build time. No server, no runtime parsing, no extra request.
The block type system also makes the parser easy to extend. Adding video support required one conditional in the `` branch: if the filename ends in `.mp4`, `.webm`, or `.mov`, produce a `video` block instead of an `image` block. Authors write `![caption` — the same syntax as images. The parser dispatches on file extension; the renderer emits `
Once posts worked this way, projects needed the same treatment. Rather than duplicate the parser, the block logic lives in `parseBlocks.ts` — shared between `parsePost` and `parseProject`, both thin wrappers around it. Adding a new content type with detail pages is a 10-line wrapper file.
Cache invalidation and asset co-location
Images started in `public/uploads/`. Two problems: files were disconnected from the content that referenced them, and `public/` assets are copied verbatim to the build output without content fingerprinting. A changed image keeps its old URL, so the browser serves a cached version.
Moving images next to their content fixed both:
src/data/blog/b1/ b1.md index.ts mi50.jpgVite processes anything imported through the module graph — including images — and fingerprints them in the output. `import.meta.glob` handles discovery at compile time:
const imgs = import.meta.glob('./*.{jpg,png,gif,webp,svg}', { eager: true, query: '?url', import: 'default' }) as Record<string, string>; const imageMap = Object.fromEntries( Object.entries(imgs).map(([p, u]) => [p.replace('./', ''), u]) );Drop an image in the folder, reference it with `caption`. No manual imports, no registration.
Diagnosing a browser environment failure
The authoring format still had rough edges: kicker labels, signoff lines, and meta rows were custom body syntax (`KICKER: text`, `META: k|v / END`). These failed silently — forget the closing `END` and the parser consumed the rest of the file.
Moving those fields to frontmatter cleaned up the body:
kicker: INCIDENT REPORT · FILE MSN-016-A signoff: End log · SD 102387 · RAY meta: Stardate|102387, Hardware|MI50 · gfx906, Resolved|2026-03-01With the body now plain Markdown, I added `marked` for inline rendering. `marked.parseInline()` converts bold, italic, inline code, and links at parse time, so the renderer receives HTML strings. No client-side parsing.
I also tried replacing the custom frontmatter parser with `gray-matter`, a standard YAML parsing library. It worked fine under Node.js and failed in the browser with `Uncaught ReferenceError: Buffer is not defined`. `gray-matter` uses Node's `Buffer` API internally. Vite pre-bundles dependencies but does not polyfill `Buffer`, and adding a polyfill would have added bundle weight for no benefit. The fix was removing `gray-matter` and keeping the custom `parseFrontmatter`. The custom implementation handles every frontmatter pattern the posts actually use, and the failure clarified that not every Node.js package is safe to use in a browser build even when Vite successfully bundles it.
Eliminating authoring boilerplate
Every post's `index.ts` was identical: import the `.md` as a raw string, import images, call `parsePost`. Repeating that manually is error-prone and adds steps between deciding to write something and actually writing it.
Two generator scripts — `npm run new-post` and `npm run new-project` — scaffold the folder, pre-fill frontmatter with sensible defaults, and write the boilerplate `index.ts`. Adding a post is one command, then editing one file.
All twelve projects were migrated to the same Markdown format: one `.md` per project, auto-discovered by `import.meta.glob` in `projects.ts`, sorted by year then ID. No TypeScript required for any content.
Making it visible to crawlers and link previews
The early version rendered entirely in the browser. Open the page and JavaScript built the content. That works for a person clicking through, and it fails for anything that reads the page without running scripts. Search crawlers got an empty shell. Pasting the link into Slack or LinkedIn produced no preview card, just a bare URL.
The original hash-based routing made it worse. Everything after the `#` stays on the client, so every route looked identical from the outside. I moved the site to real path-based URLs, with a fallback so deep links still resolve on a static host.
The larger change was generating a static copy of every page at build time. Each project and post now ships as real HTML with its text already in place, so a reader without JavaScript still gets the content. When the page opens in a browser, the app mounts over it and the experience stays the same.
The last piece was social previews. I added the metadata that unfurlers read, so a shared link now shows a title, a short description, and an image.
The lesson: if the content only exists after JavaScript runs, assume crawlers and link previews will never see it. Getting the real text into the served HTML is what made the site shareable.
What the architecture looks like now
Every post and project is a Markdown file in its own folder alongside its assets. Frontmatter carries metadata. The body is standard Markdown, parsed at build time into typed `Block[]` arrays and rendered by shared components. The build pipeline handles fingerprinting, discovery, and URL resolution.
Adding a post: `npm run new-post`, fill in frontmatter, write Markdown.
Adding a project: `npm run new-project`, same process.