Build guide · Teaching artifact
How a résumé became a transit map
Everything on the map is drawn in code: five SVG lines on an octilinear grid, a hand-rolled pan/zoom camera, a train that rides getPointAtLength, a ⌘K command palette, and a print stylesheet that folds the whole network back into one classic A4 page. This page explains each piece well enough that you could rebuild it.
Concept & creative direction
STOP 01A résumé is a list pretending to be a life. The honest shape of a career is closer to a metro system: several lines running in parallel (what you were paid to do, what you learnt, what you wrote), transfers where one journey unexpectedly feeds another, and a terminus that is really just the place you're standing now. So this CV is drawn as a network map — the piece argues its own thesis: milestones are stations, domains are lines, and the interchanges are the interesting part. Design Thinking is literally where the Writing Line meets the AI Practice Line, because that's where it happened in real life.
The visual grammar borrows deliberately from classic transit cartography — Beck's octilinear London diagram by way of every metro map since: 0°/45°/90° track only, generous corner radii, white-filled station dots, ink labels with a paper halo, interchange rings drawn heavier than ordinary stops. The palette is five saturated line colours (#E4572E career, #17BEBB skills, #FFC914 writing, #76B041 education, #6C5CE7 AI practice) against near-white #FAFBFC, with a single dark surface — the departure board — for contrast.
Type is Overpass and Overpass Mono, and the choice is not decorative: Overpass is a digitisation of Highway Gothic, the U.S. federal signage alphabet. It is a typeface that has spent seventy years telling people where they are and where they can go next — exactly this page's job.
Toolchain
STOP 02Directed by Hannah Kwakye; engineered with Fable 5 working as a designer-engineer. The output is deliberately old-fashioned: static, hand-authored HTML, CSS and JavaScript. No framework, no build step, no bundler, and zero runtime libraries — the map, camera, train, palette and board together are two plain JS files. The heaviest dependencies on the page are two self-hosted variable font files (about 60 KB combined).
- SVG built from data — the DOM for the map is generated at load from a declarative network description (
assets/js/data.js), so geometry, copy and rendering stay cleanly separated. - Native platform APIs only — Pointer Events for pan/pinch,
<dialog>for the palette and about panel,SVGGeometryElementpath methods for the train,@media printfor the paper CV. - Everything code-drawn — there is not a single raster image on this site. The favicon is an inline SVG data URI; the "photography" is geometry. That's an environment constraint turned creative thesis, shared by all 26 sites in this collection.
The map as data: octilinear geometry
STOP 03Each line is a polyline on a 64-px grid, hand-placed under one rule: consecutive points may differ only horizontally, vertically, or on a perfect 45° diagonal. Stations are a dictionary keyed by id; interchanges are simply stations that list more than one line. The entire résumé is this one structure:
// data.js — a line is points + an ordered station list
{
id: 'skills', color: '#17BEBB', toward: 'Accra Central',
points: [[3,12],[9,12],[12,9],[12,8],[13,7],[16,7],[18,9]],
stations: ['sk-htmlcss', 'sk-js', 'sk-a11y',
'x-designsystems', 'sk-svg', 'sk-aiwf', 'x-now']
}
// an interchange is just a station with two lines
'x-designthinking': { name: 'Design Thinking', lines: ['writing','ai'], pos: [16,3], … }
Sharp corners would read as circuit diagram, not metro map, so every interior vertex is rounded: back off a radius r along the incoming segment, stop r early on the outgoing one, and bridge the gap with a quadratic Bézier whose control point is the corner itself. Because the tracks are octilinear, this cheap trick is indistinguishable from a true arc:
function roundedPath(points, r) {
// M start … for each interior vertex C between P and N:
const a = C - unit(C-P) * rr; // enter the corner rr early
const b = C + unit(N-C) * rr; // leave it rr late
d += ` L ${a} Q ${C} ${b}`; // corner itself is the control point
}
Rendering order matters. Every line is drawn twice — first a fat "casing" stroke in the background colour, then the coloured track on top. When a later line crosses an earlier one its casing erases a neat gap, which is how printed metro maps have handled crossings for a century. Station labels get the same treatment in miniature via paint-order: stroke with a 4-px paper-coloured text stroke.
The camera: pan, zoom, pinch, fit
STOP 04The whole network lives inside one <g id="world">, and the camera is three numbers: {x, y, k}, applied as translate(x y) scale(k). A world point w lands on screen at s = w·k + t. Every camera feature is a rearrangement of that equation:
// zoom about the cursor p: keep p's world point fixed while k changes
function zoomAt(p, factor) {
const k2 = clamp(k * factor, kMin, kMax);
t = p - (p - t) * (k2 / k); // applied to x and y separately
k = k2;
}
- Pan — Pointer Events with capture; drag adds the pointer delta to
t. A 4-px threshold distinguishes a drag from a station tap. - Pinch — with two live pointers, the scale factor is the ratio of current to initial finger distance, and
zoomAtruns about the midpoint. Same math, two fingers. - Wheel —
Math.exp(-deltaY · 0.0016)gives a smooth, device-independent zoom curve (trackpads emit many small deltas, mice a few big ones; the exponential treats them uniformly). - Fit —
getBBox()on the world group once, thenk = min(w/bw, h/bh)and center. Camera moves ease with cubic in-out over ~500 ms, or jump instantly underprefers-reduced-motion.
On phones the initial view doesn't fit the whole network (labels would be map-of-the-world small); it opens zoomed into the heart of the map and lets you pan — with a FIT button one thumb away.
The train: riding a line
STOP 05"Ride the Career Line" animates a glowing dot from stop to stop while the departure board narrates each station. The train needs to know where along the path each station sits — but stations are grid coordinates and the rendered path has rounded corners, so their arc-lengths can't be computed directly. Instead the path is sampled once:
// sample the rendered path, then snap each station to its nearest sample
const total = path.getTotalLength();
const samples = range(721).map(i => path.getPointAtLength(i / 720 * total));
offsets = line.stations.map(st => argminDist(samples, st.pos) / 720 * total);
Between consecutive offsets the dot eases with the same cubic in-out as the camera, at roughly 0.32 px/ms with a floor and ceiling so short hops don't teleport and long hauls don't bore. While the train moves, the camera chases it with a simple exponential follow — cam += (target − cam) · 0.09 per frame — which produces that slightly-behind documentary-drone feel for free. At each stop the train dwells about five seconds, the board flips to the station's story, and a polite aria-live region reads it to screen-reader passengers.
Under prefers-reduced-motion the ride still works but nothing glides: the dot places itself at each station, the camera jumps, auto-advance is off, and the board tells you to press Next. Same journey, no motion.
The ⌘K palette
STOP 06The command palette is a native <dialog> — free focus trap, free Escape handling, free backdrop — wrapped around an input with role="combobox" and a listbox it controls via aria-activedescendant. Its commands are generated from the same data as the map: one "Route to:" entry per station, one "Ride the…" entry per line, plus actions (print, classic view, fit, guide, process, email). Matching is a tiny subsequence scorer — about ten lines — that rewards consecutive runs:
function fuzzy(q, s) {
let qi = 0, score = 0, streak = 0;
for (let i = 0; i < s.length && qi < q.length; i++) {
if (s[i] === q[qi]) { qi++; streak++; score += 2 + streak; }
else streak = 0;
}
return qi < q.length ? -1 : score - s.length * 0.05; // -1 = no match
}
Typing dsgt finds "Route to: Design Thinking"; typing print finds the paper CV. The − length·0.05 term nudges ties toward shorter labels. No index, no library — with thirty-seven commands, brute force is instant.
Keyboard model & accessibility
STOP 07A pannable SVG is exactly the kind of interface that usually locks keyboard users out, so the keyboard model was designed first, not retrofitted:
- Tab order is chronological. Stations are focusable elements inserted in a curated order — roughly the order the milestones happened — so tabbing through the map reads the CV start to finish.
- Arrow keys follow track. ←/→ move to the previous/next station on the current line; at an interchange, ↑/↓ switch which line you're travelling, announced via the live region ("Now on the AI Practice Line…").
- Focus drives the board. Focusing a station opens its departure-board entry; Enter zooms to it. The camera auto-pans whenever a focused station would be off-screen — focus is never invisible.
- Everything else is standard. Semantic landmarks, one
h1, a skip link, dashed:focus-visiblerings (including inside the SVG, drawn as a dashed circle), AA-checked contrast on both the paper map and the dark board, and reduced-motion handling in every animated subsystem.
The fallback for no JavaScript is the same as the fallback for paper: the classic CV, a complete semantic document that lives in the page and needs nothing.
The print stylesheet
STOP 08Hit print anywhere on the map and what comes out of the printer is not a screenshot of trains — it's a classic, quietly typeset one-page CV. The trick is that the classic CV is real markup that is always in the document: on screen it doubles as the "Classic CV" overlay (and the <noscript> view); in print it is the only thing visible.
@media print {
@page { size: A4; margin: 12mm; }
.top, .app, .foot, dialog, .zoomctl { display: none !important; }
#classic, #classic[hidden] { display: block !important; position: static; }
.cv-sheet, .cv-entry { break-inside: avoid-page; }
.cv-stripes, .cv-profile { print-color-adjust: exact; }
}
Print design is design: the sheet keeps the five-line colour stripe as its only ornament, sets the name in Overpass 800, runs a two-column grid (experience and writing left; skills, education, certifications right), and swaps interactive affordances for a small note pointing back to the interactive edition. break-inside: avoid-page keeps entries whole; colours that carry meaning get print-color-adjust: exact, and nothing on the sheet depends on colour surviving a grayscale office printer.
Iteration log
STOP 09Per the collection's protocol, three passes ran before shipping — screenshots at mobile/tablet/desktop, read honestly, acted on:
Pass 1 Design critique
- The mobile departure board's peek bar was rendered underneath the footer (a fixed-vs-flow z-index clash) — raised the sheet above the chrome and gave the footer clearance so the amber DEPARTURES bar is always visible.
- Whole-network fit on a 390-px portrait screen made labels map-of-the-world small; phones now open zoomed into the network's heart with FIT one tap away.
- "Design Thinking" and "Always Learning" labels grazed the AI Practice diagonal; added per-station label nudges to the data model and re-aimed both.
- The departure board listed lines in draw order (Education first); reordered to lead with the Career trunk line.
Pass 2 Elevation
- Added the Gulf of Guinea: a pale code-drawn water band with a coastline stroke anchoring the map's south edge — transit maps need geography to feel real, and the empty lower-right corner needed weight.
- Gave the terminus its dignity: "Accra Central — Now" gained a triple-ring grand-interchange badge, and an arrival animation now eases the whole network in on load.
- Deepened the departure-board fiction: split-flap letter animation on station names, a live GMT clock (Accra runs on it), and per-line "toward" destinations in the home table.
- Zoom-responsive detail: below a threshold the year sub-labels fade out so the wide view reads as pure diagram; they return as you approach.
Pass 3 Ship quality
- Zero console errors across every route and viewport in the screenshot runs; every link on all three pages resolves (routes, hub, mailto).
- Keyboard walk-through: Tab order reads the CV chronologically, arrows follow track, ↑/↓ switch lines at every interchange, ⌘K → "route to" → Enter lands with visible focus.
- Print emulation verified: one A4 page, entries unbroken, stripe and skill dots colour-exact, no interactive chrome leaking onto paper.
- Reduced-motion emulation verified: no arrival ease, instant camera, flap animation off, train steps station-to-station with manual Next.
Deploy
STOP 10Static files on Netlify, deployed from the collection's repository by CI. netlify.toml publishes the site root, marks /assets/* immutable for a year (fonts and scripts are content-stable), and sets the usual protective headers (X-Frame-Options, X-Content-Type-Options, Referrer-Policy). First view is five files — one HTML document, one stylesheet, two scripts, two fonts — about 150 KB on disk and roughly 100 KB over the wire once the text compresses, well under half the collection's 250 KB budget.
Questions about any of it? kwakyehannah@gmail.com — or go ride a line.