Colophon
How this site is put together
Every CV I send claims accessibility, responsive design and measurable performance work. A portfolio that failed a contrast check would contradict my own documents in front of the audience most likely to check. So: the receipts.
- Framework
- Vue 3, Composition API, single-file components
- Language
- TypeScript, strict
- Build
- Vite, then prerendered to static HTML
- Runtime deps
- Two: vue and vue-router
- Third-party requests
- Zero
Construction
Vue 3 and TypeScript, prerendered to static HTML.
Single-file components with <script setup> and the Composition API, Vue Router for six routes, and strict TypeScript throughout, including the router's RouteMeta, so a page that forgets its description fails the type check rather than shipping an empty tag.
It does not ship as a single-page app. At build time every route is rendered with Vue's server renderer and written to disk as real HTML, and the client hydrates that same app. The case studies are the point of this site; they should exist as documents a crawler can read and a reader can get to with JavaScript switched off.
There are also no third-party requests at all: no CDN, no analytics, no fonts fetched from Google, no embeds. Nothing on this site phones anywhere, which is a privacy decision as much as a performance one.
Why a framework
What Vue costs here, and what it buys.
The honest accounting first: the previous version of this site was hand-written HTML with no framework and no build step, and it was 56 KB lighter over the wire: 197 KB against 253. That is what the framework costs here, measured rather than estimated, and I am not going to pretend it is free.
What it buys is the part of this site that is actually a program. The route planner on the home page is a composable: reactive stops, a computed tour, computed distance and saving, and a computed sentence for the live region. Push a stop and everything downstream recomputes; the component only has to draw the result. The previous version built those SVG nodes by hand, and that is where its one real bug lived.
The rest of it is the same argument at a smaller scale. The design system is enforced by typed component props rather than by remembering which class goes where. The pipeline diagram is generated from an eleven-item step list, so adding a step moves the boxes, the connectors, the numbering, the viewBox and the screen-reader description together. The build film on the home page makes the same point about process: the front end the client approves and the product that ships are one piece of markup, and a single variable moves it from sample data to live. And, plainly, Vue 3 is what I do all day. A Vue engineer's portfolio should contain Vue you can read.
Type
Four variable faces, subset to what is actually used.
- Anybody for display, set condensed at a width of about 78. It descends from technical wayfinding lettering, which is why it suits a site about equipment and operations, and its 50–150 width axis is what the headline animates along on load.
- Archivo for the interface: a neutral grotesque whose job is to get out of the way of the display face.
- Newsreader for reading, with the optical-size axis tuned per context: tighter for pull quotes, looser for body.
- Martian Mono for labels, figures and anything carrying a state.
All four are self-hosted and subset to the exact glyph set this site uses: 114 characters, derived by scraping the rendered text out of the pages and adding the case-transformed forms, so the uppercase labels have letters to render. The result is 179 KB for four variable families, down from 310 KB unsubset, with every axis intact. Preloaded, with font-display: swap and a real fallback stack behind each one.
Color
One ink, one signal, two states of the same object.
The palette is a chalky sheet, ink, and a single signal orange used only where something has a state: a live badge, the first stop on a route, a human step in the pipeline diagram, the rule that grows under a link you are hovering. There is no second accent. Links are ink with a signal rule under them, because restricting the color to state is what keeps a two-color page from looking like a two-color page.
Light is the default, because it is the harder of the two to get right and most portfolios avoid it. Dark is the same object under panel lighting: blue-black rather than neutral, with a one-pixel bezel on every raised surface. It follows your system setting until you press the toggle, after which it remembers.
Every token is declared on :root before any media query redefines it, so nothing has its only definition behind a preference. Every text pair clears WCAG AA, body text clears AAA in both themes, and prefers-contrast: more collapses the muted grays to full ink.
Motion
Native where possible, and it all switches off.
- Page transitions use the View Transitions API. Navigating from the board into a case study morphs the project title into the page title. The shared
view-transition-nameis bound from the project data, so the board and the case study cannot fall out of step. - Scroll reveals are a custom
v-revealdirective. Where the browser hasanimation-timeline: view()it only opts the element in and CSS does the work; where it does not, it falls back to an IntersectionObserver, and only for elements that start below the fold, so nothing already on screen is hidden and faded back in. - The headline settles along the width axis of the display face as the page loads. Its lines are pre-broken, so the animation never reflows the paragraph, and it is dropped entirely below the mobile breakpoint.
- The build film on the home page is the one place with a scroll listener, and it is deliberate: a camera, a front end that lifts off its chassis, a back end built part by part into its own outlines, a seven-gear train, two springs and a dashboard going from sample data to live all read the same number, and one shared value is simpler than a dozen coordinated CSS timelines. The listener is passive, coalesced into a single frame, and reads one rect per frame. The frame itself is a pure function of that number, with no clocks and no tweens, which is why it scrubs backwards exactly. On phones and under reduced motion it does not pin at all: the same renderer draws four stills at four fixed points.
- The theme toggle wipes the new palette in as a circle expanding from the button you pressed.
- The route planner's stops land through a
TransitionGroup, and its tour draws itself usingpathLength="1". The stroke is normalized in SVG, so the animation needs no measuring code at all.
All of it is disabled under prefers-reduced-motion: reduce, including the page transitions.
Keyboard
and screen reader
Things I would want to be checked on.
- Every page is real HTML on disk. The build renders each route with Vue’s server renderer and writes it out, so the content is there before a byte of JavaScript executes, and stays there if it never does.
- A skip link is the first tab stop on every page, and it is visible when focused.
- Every focusable element has a visible ring (2px signal, 3px offset), and nothing relies on hover alone to be discoverable.
- The route planner works without a pointer. Add stop and Reset do everything clicking the plot does, and the result is announced to a live region driven by a computed property.
- One h1 per page, headings in order, landmark elements throughout, and no heading level skipped to get a font size.
- Alt text describes the screenshot, not the file. If you cannot see the image, the sentence still tells you what is on that screen.
- The screenshot rail is focusable and scrolls with the arrow keys, so a horizontally scrolling region is not a pointer-only region.
- The pipeline diagram has a text description generated from the same step list that draws it, so the two can never drift apart.
- The build film can be driven without scrolling. Each act has a button, and the buttons move the page to that act rather than setting the film directly, so a keyboard user drives exactly the same thing as a scrolling one, not a second, parallel state.
- The conversation on the home page is buttons, not a text box. It is scripted and does not pretend otherwise. Replies arrive in a live region, keyboard focus moves to the next questions once an answer finishes, and without JavaScript the same questions and answers render as a plain list.
- The hiring section is a real tab set. Arrow keys, Home and End move between roles, only the selected tab sits in the tab order, and the choice is kept in the address bar so the link can go to the rest of a hiring panel. Without JavaScript every role renders in full.
- No keyboard traps, no positive tabindex, and the tab order follows the reading order because the DOM order does.
Weight
What actually goes over the wire.
179 KB
Four variable typefaces, subset, and still the largest thing on the page
woff2, preloaded
44.5 KB
Vue, Vue Router and the app shell, gzipped: the whole framework cost
Entry bundle
11.9 KB
This route's own chunk, gzipped. The other five are not downloaded
Code-split per route
8.1 KB
The whole stylesheet, gzipped (38 KB before compression)
One file, no reset library
~253 KB
Everything the home page fetches, cold and uncached, in nine requests
Measured, gzip on
0
Third-party requests, trackers, cookies and consent banners
By construction
Screenshots are WebP, sized to their display width, lazy-loaded below the fold, and carry intrinsic dimensions so nothing shifts as they arrive. Routes are code-split, so a visitor who only reads one case study never downloads the other two.
What I'd change
The fonts are still the whole budget.
179 KB of typefaces against about 65 KB of framework, styles and script combined is not a balanced page. Dropping the serif and setting the case studies in Archivo would cut the type budget by nearly half, and the site would be measurably faster and slightly worse to read.
I chose reading, because the case studies are the point and people spend minutes in them rather than seconds. If this were a product with a conversion rate attached rather than a portfolio, I would have made the other call, and I would want to be asked why in either direction.