Skip to main content
YK logo
← Back to projects

terminal-cv — An Interactive CV Built as a Terminal Emulator

A keyboard-first browser TUI presenting a CV as a command-line interface, built in phased, spec-first slices with Claude Code and held to the same accessibility bar as any other content

Overview

terminal-cv presents my CV as a browser-based terminal emulator — type help, experience, skills, projects, or cv and get the corresponding section back, the way you'd interrogate a real shell. It's a small, self-contained project, but the interesting part isn't the gimmick — it's that a terminal interface and an accessible interface pull in opposite directions by default, and making both true at once was the actual brief I set myself.

It's also a project I built as a deliberately phased, spec-first exercise with Claude Code as pair-programmer, rather than a loose back-and-forth — the process is as much the point of this one as the terminal aesthetic is.


The problem

A terminal UI is inherently a novelty — fun the first time, a barrier the second time for anyone who doesn't think in command syntax, and a real obstacle for anyone relying on a screen reader if it's built carelessly. Most "terminal portfolio" projects lean entirely into the gimmick and quietly fail every accessibility check going: fake contenteditable inputs instead of real form controls, no live region so a screen reader has no idea new content appeared, focus traps, animation that ignores prefers-reduced-motion. I wanted the aesthetic without any of that being true.


Building it as a spec, in phases, with Claude Code

Before writing UI code, I scoped the project into explicit phases rather than one open-ended "build me a terminal CV" prompt — Phase 0 and Phase 1 as a single vertical slice covering the full stack end to end: Vite/React/TypeScript scaffolding with a strict tsconfig, every command wired (help, whoami, cv, experience, skills, projects, contact, theme, clear, download), the plain-HTML fallback view built as an equal-status option from the start rather than bolted on later, all four themes including high-contrast, a Vitest suite, a Playwright E2E suite, and CI wired up in GitHub Actions — all landing together as one reviewed, working slice rather than a string of half-finished commits. prefers-reduced-motion was honoured from that first commit, not patched in once someone noticed its absence, which is the kind of thing a spec with accessibility as a named requirement catches before it ships rather than after.

That first commit is co-authored with Claude Code, and the README still points at what's coming — a screen-reader test notes doc "coming in Phase 3" — which is a small but honest marker of the same phased discipline: the roadmap is written down, including the parts not built yet, rather than implied or left to be rediscovered later. Working this way with an AI pair-programmer meant each session had a bounded, well-defined unit of work to implement and verify against, rather than an ambient sense of "add some more terminal stuff" — the difference between AI-assisted development that compounds cleanly and AI-assisted development that produces a pile of plausible-looking but disconnected commits.


What I built

A real command parser, not a lookup table

Commands (whoami/about, cv, experience [filter], skills [filter], projects, contact, theme [name], download, clear) go through a proper parser with tab-completion for command names and up/down arrow history, living in src/terminal/commands/. experience and skills both take an optional filter argument, so experience frontend or skills react narrows the output — a small feature, but the one that made it feel like an actual shell rather than a menu wearing a monospace font.

Accessibility as the load-bearing constraint, not an afterthought

The output region uses role="log" with aria-live="polite" so a screen reader announces new content as it appears, the same way a real terminal's output would be perceivable. Input is a real <input> element, not a styled contenteditable div pretending to be one — that one decision avoids a whole category of screen-reader and IME bugs that fake terminal inputs are prone to. There are no focus traps: Tab reaches the "View as plain HTML" link and every interactive element in natural order. prefers-reduced-motion disabled animations from the first commit, not retrofitted after a complaint.

An equal-status plain HTML view

The title bar has an always-visible "View as plain HTML" link — not a tucked-away accessibility statement page, but the actual CV content in a plain, ordinary HTML document, presented as an equal option rather than a grudging fallback. Someone who doesn't want to play with a terminal, or can't, gets the same information with zero friction.

Themes, audited properly

Four colour themes (default, solarized, dracula, high-contrast), each checked against WCAG AA, with high-contrast exceeding AAA. Building a themeable terminal is easy; building one where every theme combination still passes a contrast check is the part that takes actual verification rather than eyeballing it.


Engineering decisions

Vanilla CSS custom properties over a styling library

No Tailwind, no CSS-in-JS — theming runs entirely on CSS custom properties. For a project this size, with four themes that all need to swap consistently, custom properties are simpler to reason about than a utility framework or a runtime styling solution, and they keep the bundle small, which matters for something meant to load instantly as a CV.

Testing both layers

Vitest covers the command parser and terminal logic at the unit level; Playwright covers the end-to-end experience — typing a command, seeing the right output, tabbing through focus order. Testing an interactive terminal UI only at the unit level would miss exactly the class of bug (focus order, live-region announcements, keyboard flow) that the accessibility work was meant to prevent, so the E2E layer isn't optional here.


Stack

Layer Choice
Framework React · TypeScript · Vite
Styling Vanilla CSS + custom properties
Font JetBrains Mono, self-hosted via @fontsource
Testing Vitest (unit) · Playwright (E2E)
Linting ESLint + eslint-plugin-jsx-a11y
Process Phased spec (Phase 0 → Phase 1 vertical slice, Phase 3 roadmapped) · Claude Code pair-programming

Reflections

The honest test of this project wasn't "does it look like a terminal" — that part's easy. It was "can someone using a screen reader, or who just doesn't want to type commands, get the same CV with the same ease." Building the plain HTML view as an equal-status option rather than a fallback, and getting the live region and focus order right rather than approximating them, is what makes the novelty defensible rather than a liability. A terminal-styled CV that fails basic accessibility would be a worse portfolio piece than no terminal at all.

The process is worth calling out on its own terms too. Scoping the first commit as a complete, working vertical slice — every command, both view modes, all four themes, tests, and CI, together — rather than trickling features in one at a time is what let an AI pair-programmer contribute to something this small without the usual drift toward scope creep or unreviewable diffs. It's the same discipline I'd apply to a much larger, team-based codebase, just visible here at a scale small enough to see the whole shape of it in one place.