Design System
The rules this site is built on, rendered by the site itself. Every specimen below is a real element under a real rule, and every value beside it is read out of the stylesheet when the site is built.
Each component is taken apart into its parts, its states and its specification. The last section lists what is not clean.
Colour
Three colours, one value each. Red carries emphasis, links and the signature fields. White is the light ground, off-white rather than stark white so pages read as editorial. Black is the dark ground and the text on the light one. There is no fourth accent, no gradient, and no second shade of any of the three.
The stylesheet does not yet hold to this. Four values are in use where there should be three, and the audit at the end of this page lists every one.
- Red
#CD2C2CEmphasis, links, and the signature fields. - Black
#080808Dark fields, and text on the light one. The footer's black. - White
#f5f5f7The light field, and text on red or black.
How colour is applied
There is no global scheme. A section declares three custom properties, and everything inside and around it takes its colour from them.
--bg-colorThe field the section is painted on.--text-colorCopy, headings and every icon fill.--link-colorLinks inside the section’s own content.
theme/script.js reads those three off whichever section is crossing the middle of the viewport and applies them to the page, the header, the nav and the footer. The section owns the colour and the page borrows it, so the chrome recolours as the reader scrolls.
That is why the chrome is written in currentColor and inherit. The focus ring, the icons and the section menu’s marker have to work on red and on off-white without a second set of rules for each. The change runs over 0.5s.
The site’s moods
- The red field. The home hero and the work index open on it.
#CD2C2C·#f5f5f7·#f5f5f54.83:1 text · 4.82:1 link - The default page. Every long-form read sits on it: About, Blog, Books, Career.
#f5f5f7·#1b1b27·#CD2C2C15.65:1 text · 4.83:1 link - The darkest field. The footer, and the screens that need to go quiet.
#0f0f0f·#f5f5f7·#CD2C2C17.6:1 text · 3.64:1 link - The softer dark. Near-black rather than black, for a warmer field.
#1d1d1f·#f5f5f7·#CD2C2C15.46:1 text · 3.2:1 link - Red again, with off-white text. Used where the hero’s link colour is not wanted.
#CD2C2C·#f5f5f5·#CD2C2C4.82:1 text · 1:1 link
A client’s colours are not this system
A case study is painted in its client’s own brand for as long as it is on screen, so the work sits in the client’s world. Those colours belong to the client. They are content the work pages carry, 135 of them, and they are no part of this brand.
The system owns the mechanism. A new case study declares the same three properties rather than introducing styling of its own.
Typography
One family and two weights. The stack starts at ui-sans-serif and falls through the platform’s own faces, so the page renders as San Francisco on a Mac with no font file to load and no third party to contact. Designed work uses Suisse BP Int’l; this is a web decision only.
h1 to h3 are 300. h4, h5, h6 and the nav are 400. The weight rises as the type gets smaller, and nothing on the site is bold.
:root sets font-size: 20px, so every rem in the page is that number. Media queries are the exception: they are measured against the browser’s initial 16px and are unaffected by that declaration.
The scale
Each specimen is the real element. Only its padding and margin are removed, so the type sits against the column edge rather than at the page gutter. The h1specimen is the page title above, because there is one per page.
See the page title above.
h1. One per page. The page’s own name, or the opening statement.
| Property | Base | (min-width: 56rem) |
|---|---|---|
font-size | clamp(2.4rem, 5vw, 6rem) | clamp(2.4rem, 5vw, 6rem) |
line-height | 1.2 | 1.2 |
font-weight | 300 | 300 |
margin-bottom | 1.5rem | 1.5rem |
max-width | 100% | 100% |
padding | 0 1.5rem | 1rem 7rem |
Grid, rhythm and restraint
h2. A section heading. Capped at the 48rem measure so it breaks like prose.
| Property | Base | (min-width: 56rem) |
|---|---|---|
font-size | clamp(1.8rem, 4vw, 2.5rem) | clamp(1.8rem, 4vw, 2.5rem) |
line-height | 1.3 | 1.3 |
font-weight | 300 | 300 |
margin-bottom | 1.2rem | 1.2rem |
max-width | 48rem | 48rem |
padding | 0 1.5rem | 1.5rem 7rem |
Grid, rhythm and restraint
h3. A heading inside a section. Nearly h2’s size, with a tighter margin under it.
| Property | Base | (min-width: 56rem) |
|---|---|---|
font-size | clamp(1.8rem, 4vw, 2.2rem) | clamp(1.8rem, 4vw, 2.2rem) |
line-height | 1.2 | 1.2 |
font-weight | 300 | 300 |
margin-bottom | 0.4rem | 0.4rem |
max-width | 100% | 100% |
padding | 0 1.5rem | 0 7rem |
Grid, rhythm and restraint
h4. A label above a block. The first weight step up, and the only one with a top margin.
| Property | Base | (min-width: 56rem) |
|---|---|---|
font-size | clamp(1rem, 4vw, 1.2rem) | clamp(1rem, 4vw, 1.2rem) |
line-height | 1.1 | 1.1 |
font-weight | 400 | 400 |
margin | 4rem 0 0 0 | 4rem 0 0 0 |
max-width | 100% | 48rem |
padding | 0 1.5rem | 0 7rem |
Grid, rhythm and restraint
h5. Small type set in grey. A caption or a kicker, not a heading in the outline sense.
| Property | Base | (min-width: 56rem) |
|---|---|---|
font-size | clamp(0.8rem, 5vw, 1rem) | clamp(0.8rem, 5vw, 1rem) |
line-height | 1 | 1.5 |
font-weight | 400 | 400 |
margin-bottom | 0.6rem | 0.6rem |
max-width | 100% | 100% |
padding | 0 1.5rem | 0.5rem 7rem |
Grid, rhythm and restraint
h6. Reserved for the header’s page title. Not a heading in the page body.
| Property | Base | (min-width: 56rem) |
|---|---|---|
font-size | 1rem | 2rem |
font-weight | 400 | 300 |
Body copy at its resting size, set to show the leading and the colour it takes from the section around it.
p. Body copy.
| Property | Base | (min-width: 56rem) |
|---|---|---|
font-size | 1rem | 1.4rem |
line-height | 1.5rem | 2rem |
font-weight | 300 | 300 |
margin-bottom | 0.5rem | 1.5rem |
max-width | not set | 48rem |
padding | 0 1.5rem | 0 7rem |
- An item in a list
- And the one under it
ul, ol. Lists. Body size, with a deeper left gutter for the markers.
| Property | Base | (min-width: 56rem) |
|---|---|---|
font-size | 1rem | 1.4rem |
line-height | 1.5rem | 2rem |
margin-bottom | 1rem | 1rem |
max-width | not set | 48rem |
padding | 0 1.5rem 0 3.5rem | 0 1.5rem 0 9rem |
Four of the six headings use clamp(), so they scale with the viewport rather than stepping at a breakpoint. What the desktop breakpoint changes is the gutter, 1.5rem to 7rem, and the size of body copy and lists, which go from 1rem to 1.4rem.
Space and measure
There is no spacing scale in the token sense. There is a gutter, a measure, and a small set of values that recur.
The gutter
1.5rem below the desktop breakpoint and 7rem above it. The two are not a scaled pair. On a phone the screen is the column; on a laptop the type is set into a full-bleed field rather than filling it.
Lists indent further, to 0 1.5rem 0 9rem, so their markers hang outside the text column instead of pushing it.
The measure
48rem. Body copy, h2, h4 and lists are capped at it, so a line never runs the width of a wide screen.
The values that recur
| Value | At 20px root | Where it is used |
|---|---|---|
0.5rem | 10px | Nav link padding, social row gap, the copyright separator. |
1rem | 20px | The Digiguru panel’s gutter. One step of separation inside a component. |
1.5rem | 30px | The page gutter on a phone. Also the social icon size. |
2rem | 40px | The header’s desktop padding. |
4rem | 80px | The margin above an h4, the largest gap the type scale asks for. |
7rem | 140px | The desktop text indent, and the header’s height. |
11rem | 220px | The footer’s bottom row, and where the section menu starts. |
The section
A full-bleed section is min-height: 100vh with scroll-snap-align: start, so the page advances one screen at a time. Every section-* class must also carry section, or it loses both and script.js cannot find it for the background fade.
Long-form pages carry snap-scroll-disabled on the body, which turns the snapping off so they scroll as one continuous read: this one, /blog/, /books/ and /career/.
Breakpoints
A media query’s rem is 16px, not the 20px this site sets on :root. Media queries are measured against the browser’s initial font size and are unaffected by that declaration, so 56rem is 896px and not 1120px. The pixel column below is the real width.
| Query | Actual width | What changes |
|---|---|---|
30rem | 480px | The newsletter form stops stacking and sits on one row. |
40rem | 640px | The books grid and the career rows take their wider layout. |
56rem | 896px | The theme’s own breakpoint. Nav moves to the header, the gutter goes to 7rem, type goes up a step, Digiguru becomes a panel instead of a page. |
85rem | 1360px | The in-page section menu appears beside the copy. |
94rem | 1504px | The footer copy shifts clear of the section menu. |
Motion
--transition-speed: 0.5sThe site’s single duration.cubic-bezier(0.4, 0, 0.2, 1)Anything that travels.ease-in-outAnything that only changes colour.
Hover is filter: brightness(1.2) rather than a second colour, so a link brightens against whatever field it is sitting in and needs no hover value per section.
Anything travelling with the Digiguru panel animates transform. The panel slides on the compositor, while right, margin and width are recalculated on the main thread and fall behind it as the page reflows, even at an identical duration and curve.
Section imagery is driven by GSAP rather than CSS. theme/script.js holds the page at zero on load and staggers it in, then fades each section’s media as it reaches the middle of the viewport, writing opacity inline. Several rules therefore carry no transition of their own on purpose, because a CSS transition on top would chase values GSAP is already tweening frame by frame.
prefers-reduced-motion: reduce keeps the fades and drops the travel.
Edges, focus and elevation
Focus
One ring for the whole site: outline: 2px solid currentColor at outline-offset: 2px, on :focus-visible, so it appears for the keyboard and never on a mouse click. currentColor is what lets it track the field behind it. It is never removed, only redrawn.
Tab to this link to see the ring
Elevation
The site has one raised surface and it is a picture frame. figure.media-surface takes a border, a radius and a shadow. Nothing else is lifted off the page, and there are no cards, pills or containers with fills of their own.
| Property | Base | (min-width: 56rem) |
|---|---|---|
border-radius | 1rem | 1.25rem |
border | 0.15rem solid #1b1b27 | 0.3rem solid #1b1b27 |
box-shadow | 0 0.5rem 1.5rem rgba(0,0,0,0.35) | 0 1rem 2.5rem rgba(0,0,0,0.35) |
Hairlines
Everywhere else a division is a hairline in the ink colour at low opacity, never a border box or a fill. Form controls take 1px solid rgba(27, 27, 39, 0.18) at 0.25rem, and the Digiguru panel marks its edge with a 1px column of rgba(29, 29, 31, 0.12). There is no elevation scale and no second border weight.
Links and buttons
A link takes --link-color from the section it is in, drops its underline, and brightens on hover. There is no button component in the visual sense and nothing is boxed.
States
| State | Declaration | Why |
|---|---|---|
| Rest | color: var(--link-color) | Inherited from the section, so one rule covers every field. |
| Hover | filter: brightness(1.2) | A filter rather than a second colour, for the same reason. |
| Focus | outline: 2px solid currentColor | The site ring, on :focus-visible. |
| Emphasis | font-size: 1.4rem | .work-link on a case study. Weight comes from scale, never a box. |
A link at rest. Hover it, then tab to it.
Icon controls
An icon with no visible label carries an aria-label for its accessible name and the site’s data-tooltip for sighted users. That pair is used by the social row, the back-to-top arrow and the Digiguru sparkle. Icons are 1.5rem square and filled with var(--text-color), which is what lets one SVG work on every field.
Form controls
One shape for every control: the same height, hairline, radius and type. The specimens below carry the real classes from /books/, where the pattern is defined.
Anatomy
Live specimens. Hover them, then tab to them. The working pair is on /books/.
.books-fieldLabel and control in one column, so the label always sits with what it names..books-field-labelA visible label, not a placeholder standing in for one..books-search-fieldThe text input..books-selectThe select’s positioning context, so the arrow can be drawn over it..books-topic-selectA real<select>withappearance: none, keeping its native keyboard behaviour..books-select-arrowTwo borders of a square, turned.pointer-events: none, so the control still opens on a click anywhere.
States
| State | Declaration | Why |
|---|---|---|
| Rest | border: 1px solid rgba(27, 27, 39, 0.18) | A hairline in the ink colour. No fill, so the control sits on the page rather than on a card. |
| Hover | border-color: rgba(27, 27, 39, 0.4) | The hairline darkens. The only thing that changes. |
| Focus | border-color: rgba(27, 27, 39, 0.55) | Darker again, so the control reads as live before any ring is drawn. |
| Focus visible | outline: 2px solid #1b1b27 | The ring, for the keyboard only. A mouse click gets the border change and nothing else. |
Specification
| Property | Value |
|---|---|
padding | 0.6rem 0.75rem |
border | 1px solid rgba(27, 27, 39, 0.18) |
border-radius | 0.25rem |
font-size | 1rem |
font-weight | 300 |
line-height | 1.5 |
background | transparent |
Behaviour
Safari colours a select’s options from the control’s own background, so a transparent background produces unreadable options in dark UI. The option colours are therefore named explicitly rather than inherited.
The newsletter form is Kit’s embed rather than the site’s markup, the one place the site styles someone else’s HTML. Its field and button are reshaped to these same values rather than to a second pattern.
Media surfaces
figure.media-surface is how work imagery is presented: contained rather than cropped, so the whole frame is visible at any viewport shape.

figure.media-surfaceThe box. Centres its contents and takes the section’s field behind it.img,videoSized by one shared rule, so swapping a still for a clip involves no movement.object-fit: containContained, never cropped. The whole frame stays visible at any viewport shape.
The border steps up at the desktop breakpoint, from 0.15rem solid #1b1b27 to 0.3rem solid #1b1b27, because the frame is bigger and a hairline stops holding it. Its colour is #1b1b27 rather than currentColor, because it frames the picture and should not change with the section.
Nothing autoplays from a third party. Videos and X embeds are click-to-load facades with locally served posters, so opening a page contacts nobody.
The Digiguru panel
Digiguru opens from the sparkle in the navigation. It is on this page, so open it and the rest of this section is happening beside you.
Anatomy
.nav-askThe trigger. A button in the nav, and the busy indicator while a question is in flight..ask-craigThe panel. Fixed, full height, and translated ontransformso it slides on the compositor.--ask-craig-panel-widthOne property sizing the panel and narrowing the body by the same amount, so the two cannot disagree.body.ask-craig-openThe single state class. Nothing is measured in JavaScript.
Specification
| Token | Value | Note |
|---|---|---|
--ask-bg | #f5f5f7 | .section-light’s field, verbatim. |
--ask-ink | #1d1d1f | Its text. |
--ask-accent | #cd2c2c | The signature red. |
--ask-muted | rgba(29, 29, 31, 0.62) | Derived from the ink, so the panel has no colours of its own. |
--ask-line | rgba(29, 29, 31, 0.12) | The one divider in the panel. |
--ask-gutter | 1rem | Its internal spacing. |
Behaviour
The panel pushes the page. It never overlays it. The body narrows by exactly the panel’s width and the page reflows into the remaining column, so the site stays wholly visible and usable. The two regions scroll independently, and the page’s scrollbar sits beside the panel rather than at the window edge. Below 1200px the panel becomes its own full-screen page instead. This is the standing rule for any panel, drawer or sidebar on this site.
The panel holds one fixed palette rather than tracking the section beside it, because text you are part-way through reading should not change colour underneath you. Those values are .section-light verbatim.
Width rather than padding on the body: a padding-right would keep the body the full width of the window and its scrollbar with it. Narrowing the box is what brings the scrollbar inside.
The laws
Decisions, taken once and binding on everything after. They are what make the site behave as one thing rather than a set of pages that resemble each other.
- A section owns the colour. The page borrows it.Nothing is painted with a fixed colour. A section sets --bg-color, --text-color and --link-color; theme/script.js reads them off whichever section is crossing the middle of the viewport and applies them to the page, the header, the nav and the footer. That is why chrome uses currentColor and inherit rather than a value of its own. It has to work on a red hero and a white page without a second set of rules.
- Panels push content. They never overlay it.Digiguru narrows <body> by its own width and the page reflows into the remaining column, scrolling inside that box so its scrollbar sits beside the panel rather than at the window edge. Below 1200px the panel becomes its own full-screen page instead. This is the standing rule for any panel, drawer or sidebar on this site.
- Anything that travels with the panel animates transform.The panel slides on the compositor. right, margin and width are recalculated on the main thread and visibly fall behind it while the page reflows, even at the same duration and curve. Same property, same pipeline, or it desyncs.
- The nav and the social row never change width or spacing.Not when the panel opens, not at any breakpoint. Both keep the theme’s own metrics everywhere; what moves is their position, translated by the panel’s width. If a layout needs a wider minimum to hold them at full size, it gets one. Neither row is ever squashed to make something else fit.
- Nothing on the left edge moves when the panel opens.The logo and the copyright are anchored to the page gutter and stay there.
- Repeated elements are site-wide components.The header, navigation, footer, footer media, Digiguru and the page transitions have one shared implementation and behave the same on every route. A page-specific variation is a defect, not a feature.
- One stylesheet, and it is public/theme/style.css.The theme’s own rules and everything the site adds on top of them, in one file, organised by what each section owns. There is no override sheet. If a rule needs to beat an earlier one it says so in a comment and sits below it, in the section that owns it. src/styles/bundle.css compiles it with three others into the single asset the pages link.
- Every section-* class also carries `section`.Without it the section loses min-height: 100vh and scroll-snap-align, and script.js cannot find the element for the background fade.
- A focus ring is never removed, only redrawn.2px solid currentColor at 2px offset, on :focus-visible so it appears for the keyboard and not for a mouse click. currentColor is what lets it track the section behind it. Controls that suppress it draw their own: the section menu moves the ring onto the label so it does not box an empty rail.
- Nothing contacts a third party on page load.Videos and X embeds are click-to-load facades with locally served posters. Fonts are the system stack, so there is nothing to fetch.
What is not clean
What the stylesheet actually contains, generated rather than remembered. The counts move when the sheet does.
- The palette is three colours. The site’s own moods use 7.Every value below is a shade of one of the three, close enough that none of them is distinguishable from it on screen. Each is cost with no effect.
In use Should be Apart Where #0f0f0f#0808081.04:1 .section-dark#1b1b27#0808081.18:1 .section-light#1d1d1f#0808081.19:1 .section-home-about#f5f5f5#f5f5f71:1 .section-hero.section-medium .section-mediumsets its link colour to its own background.#CD2C2C on #CD2C2C is 1:1, so a link there is invisible. It has already caused a real defect: the footer copyright link carriescolor: inheritto escape it.- Red on a dark field is below AA for body copy..section-dark at 3.64:1 and .section-home-about at 3.2:1. Both clear the 3:1 large-text threshold, so a heading is fine and a sentence is not. Off-white links on those fields would close it.
- Lists are a weight heavier than the copy around them.
pdeclaresfont-weight: 300. No rule anywhere declares one forolorul, so a list falls back to the browser’s 400 at the same size as the paragraph above it. One declaration closes it. - There is no semantic layer.A mood names its own hex values rather than resolving through
--surface,--inkand--accent, so changing the red is several edits rather than one. The system is small enough that this has not cost anything yet.