Design System

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-color The field the section is painted on.
  • --text-color Copy, headings and every icon fill.
  • --link-color Links 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.

PropertyBase(min-width: 56rem)
font-sizeclamp(2.4rem, 5vw, 6rem)clamp(2.4rem, 5vw, 6rem)
line-height1.21.2
font-weight300300
margin-bottom1.5rem1.5rem
max-width100%100%
padding0 1.5rem1rem 7rem

Grid, rhythm and restraint

h2. A section heading. Capped at the 48rem measure so it breaks like prose.

PropertyBase(min-width: 56rem)
font-sizeclamp(1.8rem, 4vw, 2.5rem)clamp(1.8rem, 4vw, 2.5rem)
line-height1.31.3
font-weight300300
margin-bottom1.2rem1.2rem
max-width48rem48rem
padding0 1.5rem1.5rem 7rem

Grid, rhythm and restraint

h3. A heading inside a section. Nearly h2’s size, with a tighter margin under it.

PropertyBase(min-width: 56rem)
font-sizeclamp(1.8rem, 4vw, 2.2rem)clamp(1.8rem, 4vw, 2.2rem)
line-height1.21.2
font-weight300300
margin-bottom0.4rem0.4rem
max-width100%100%
padding0 1.5rem0 7rem

Grid, rhythm and restraint

h4. A label above a block. The first weight step up, and the only one with a top margin.

PropertyBase(min-width: 56rem)
font-sizeclamp(1rem, 4vw, 1.2rem)clamp(1rem, 4vw, 1.2rem)
line-height1.11.1
font-weight400400
margin4rem 0 0 04rem 0 0 0
max-width100%48rem
padding0 1.5rem0 7rem
Grid, rhythm and restraint

h5. Small type set in grey. A caption or a kicker, not a heading in the outline sense.

PropertyBase(min-width: 56rem)
font-sizeclamp(0.8rem, 5vw, 1rem)clamp(0.8rem, 5vw, 1rem)
line-height11.5
font-weight400400
margin-bottom0.6rem0.6rem
max-width100%100%
padding0 1.5rem0.5rem 7rem
Grid, rhythm and restraint

h6. Reserved for the header’s page title. Not a heading in the page body.

PropertyBase(min-width: 56rem)
font-size1rem2rem
font-weight400300

Body copy at its resting size, set to show the leading and the colour it takes from the section around it.

p. Body copy.

PropertyBase(min-width: 56rem)
font-size1rem1.4rem
line-height1.5rem2rem
font-weight300300
margin-bottom0.5rem1.5rem
max-widthnot set48rem
padding0 1.5rem0 7rem
  • An item in a list
  • And the one under it

ul, ol. Lists. Body size, with a deeper left gutter for the markers.

PropertyBase(min-width: 56rem)
font-size1rem1.4rem
line-height1.5rem2rem
margin-bottom1rem1rem
max-widthnot set48rem
padding0 1.5rem 0 3.5rem0 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

ValueAt 20px rootWhere it is used
0.5rem10pxNav link padding, social row gap, the copyright separator.
1rem20pxThe Digiguru panel’s gutter. One step of separation inside a component.
1.5rem30pxThe page gutter on a phone. Also the social icon size.
2rem40pxThe header’s desktop padding.
4rem80pxThe margin above an h4, the largest gap the type scale asks for.
7rem140pxThe desktop text indent, and the header’s height.
11rem220pxThe 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.

QueryActual widthWhat changes
30rem480pxThe newsletter form stops stacking and sits on one row.
40rem640pxThe books grid and the career rows take their wider layout.
56rem896pxThe 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.
85rem1360pxThe in-page section menu appears beside the copy.
94rem1504pxThe footer copy shifts clear of the section menu.

Motion

  • --transition-speed: 0.5s The site’s single duration.
  • cubic-bezier(0.4, 0, 0.2, 1) Anything that travels.
  • ease-in-out Anything 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.

Hover this link to see brightness(1.2)

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.

PropertyBase(min-width: 56rem)
border-radius1rem1.25rem
border0.15rem solid #1b1b270.3rem solid #1b1b27
box-shadow0 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.

Header and navigation

One <nav>, one implementation, on every route. What changes across the breakpoint is where it sits. Its metrics never change.

Anatomy

The header and navigation at both breakpoints. Below 896px the logo sits top left, the Digiguru sparkle top right, and the four nav links run along a fixed bar at the bottom of the window. Above 896px the logo and page title sit top left and the links and sparkle sit together at the top right. Below 896pxlogosparklenav, fixed to the bottom896px and abovelogo, page titlenav and sparklesection menu, 1360px up

  • header The frame. Fixed and full-height, with pointer-events off so only its children are clickable.
  • .logo The CR—G mark. Anchored to the page gutter and never moved by anything.
  • header h6 The page title beside the mark. Absent on the home page, where the hero carries the first heading.
  • nav The link row. A fixed bar at the bottom of the window on a phone, in the header above 896px.
  • nav a One link. Four of them, plus Home, which is hidden above the breakpoint.
  • .nav-newsletter The one link with two labels. Below 396px it reads Letters so the row cannot overflow; the accessible name stays Newsletter.
  • .nav-ask The Digiguru sparkle. A button, because it opens a panel rather than going anywhere, and the busy indicator while a question is in flight.

States

StateDeclarationWhy
Restcolor: var(--text-color) !importantTakes the section in view, so the row recolours as the reader scrolls.
Hoverfilter: brightness(1.2)A filter, so one rule works on every field without a hover colour per section.
Dimmedopacity: 0.4Every link while another is hovered. The row fades back rather than the hovered one lighting up.
Heldopacity: 1The link being hovered, and the one for the page you are on.
Focusoutline: 2px solid currentColorThe site ring, on :focus-visible. Keyboard only.

Specification

PropertyBase(min-width: 56rem)
positionfixedstatic
height2rem3rem
justify-contentspace-evenlycenter
max-widthnone16rem
font-size0.8rem1rem
padding0.5rem0 0.8rem
transitionopacity 0.45s cubic-bezier(0.4, 0, 0.2, 1), color var(--transition-speed) ease-in-out, filter var(--transition-speed) ease-in-outopacity 0.45s cubic-bezier(0.4, 0, 0.2, 1), color var(--transition-speed) ease-in-out

Behaviour

Hovering any link dims every link in the row to 0.4 and holds the hovered one at 1. The row fades back rather than the target lighting up, which is why the transition is on opacity and runs at 0.45s rather than the site’s 0.5s.

Home is in the markup at every width and hidden above the breakpoint, where the logo already goes home. Newsletter is the one label that changes: below 396px it reads Letters, because the row had no slack left and was clipping the sparkle. Both spans are aria-hidden and the link carries its own aria-label, so the accessible name is Newsletter at every width.

The nav and the social row never change width or spacing. Not when the panel opens, not at any breakpoint. 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.

In-page section menu

The second level of navigation, inside one page. It is the rail on the left of this page, and it appears at 1360px, where there is room for it beside the copy. Below that width it is not shown, and the page is complete without it.

Anatomy

The section menu: two group headings each with a chevron, the first one open showing its indented entries, and the current entry marked by a short rule to its left. group heading, openentry at rest, 0.45entry current, 1, with its markerentry at restgroup heading, shut, 0.6its panel, clipped to 0fr and inertrail: fixed, left 7rem, top 11remwidth clamp(10rem, 13vw, 20rem)

  • .section-menu The rail. Fixed at the page gutter, scrolls inside itself when the list is longer than the space.
  • .section-menu-group An accordion group. One open at a time.
  • .section-menu-toggle A group heading. A button, because it opens the group as well as going to it.
  • .section-menu-chevron Two borders of a square, turned. Points right when the group is shut and down when it is open.
  • .section-menu-panel The drawer. A grid whose single row animates from 0fr to 1fr, which is what height: auto cannot do.
  • .section-menu a One entry, pointing at one section.
  • .section-menu-rule The current marker. A rule that grows out of nothing and pushes the label across with it.
  • .section-menu-label The word. It carries the focus ring, so the ring does not box the full-width row.

States

StateDeclarationWhy
Entry at restopacity: 0.45Present but quiet. The page is the thing being read.
Entry hoveredopacity: 0.8Not full strength, so hover never reads as current.
Entry currentopacity: 1Set by JavaScript on the section crossing the middle of the viewport.
Marker at restwidth: 0Zero, so it takes no space until it is earned.
Marker currentwidth: 1.5remGrows and carries the label across, so it reads as one movement rather than two.
Group shutgrid-template-rows: 0frClipped to nothing, and marked inert so its links leave the tab order with it.
Group opengrid-template-rows: 1frAnimatable, which height: auto is not.
Chevron shuttransform: rotate(-45deg)Pointing right.
Chevron opentransform: rotate(45deg)Pointing down, on the same curve as the drawer.
Heading shutopacity: 0.6Three weights of heading: shut, open, and open with the reader inside it.
Heading openopacity: 0.8Open, but the reader is somewhere else.
Heading activeopacity: 1The group holding the section in view.

Specification

PropertyBase(min-width: 85rem)
displaynoneblock
positionnot setfixed
topnot set11rem
leftnot set7rem
widthnot setclamp(10rem, 13vw, 20rem)
max-heightnot setcalc(100vh - 16rem)
font-sizenot set1rem
font-weightnot set300

Behaviour

The current entry is the one whose section is crossing the middle of the viewport, not the nearest by centre. A section taller than the window has its centre off screen, so comparing centres would mark a neighbour a screen away. theme/script.jsresolves the section behind the chrome the same way.

Clicking an entry slides to it over 620ms on an ease-out curve rather than jumping, and lifts scroll-snap-type for the duration, because mandatory snapping would otherwise arrive at the destination before the animation could run. A reader who has asked for reduced motion gets a jump instead.

A group heading opens its own drawer and shuts the others. The first heading on a page goes to the top rather than to a section of its own, because everything above it is the way in. Clicking a heading never shuts it, since the reader asked to go somewhere.

When the reader is on a screen the menu does not name, nothing is marked and every group closes, rather than a drawer standing open claiming otherwise.

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-field Label and control in one column, so the label always sits with what it names.
  • .books-field-label A visible label, not a placeholder standing in for one.
  • .books-search-field The text input.
  • .books-select The select’s positioning context, so the arrow can be drawn over it.
  • .books-topic-select A real <select> with appearance: none, keeping its native keyboard behaviour.
  • .books-select-arrow Two borders of a square, turned. pointer-events: none, so the control still opens on a click anywhere.

States

StateDeclarationWhy
Restborder: 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.
Hoverborder-color: rgba(27, 27, 39, 0.4)The hairline darkens. The only thing that changes.
Focusborder-color: rgba(27, 27, 39, 0.55)Darker again, so the control reads as live before any ring is drawn.
Focus visibleoutline: 2px solid #1b1b27The ring, for the keyboard only. A mouse click gets the border change and nothing else.

Specification

PropertyValue
padding0.6rem 0.75rem
border1px solid rgba(27, 27, 39, 0.18)
border-radius0.25rem
font-size1rem
font-weight300
line-height1.5
backgroundtransparent

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.

CR—G set in white on red, with a KitKat bar in place of the dash and the words Have a break.
  • figure.media-surface The box. Centres its contents and takes the section’s field behind it.
  • img, video Sized by one shared rule, so swapping a still for a clip involves no movement.
  • object-fit: contain Contained, 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

The panel closed and open. Closed, the page fills the window. Open, the body narrows by exactly the panel's width and the page reflows into the remaining column, with the panel beside it rather than over it. Closedpage fills the windowOpenpanelpage reflows, never covered

  • .nav-ask The trigger. A button in the nav, and the busy indicator while a question is in flight.
  • .ask-craig The panel. Fixed, full height, and translated on transform so it slides on the compositor.
  • --ask-craig-panel-width One property sizing the panel and narrowing the body by the same amount, so the two cannot disagree.
  • body.ask-craig-open The single state class. Nothing is measured in JavaScript.

Specification

TokenValueNote
--ask-bg#f5f5f7.section-light’s field, verbatim.
--ask-ink#1d1d1fIts text.
--ask-accent#cd2c2cThe signature red.
--ask-mutedrgba(29, 29, 31, 0.62)Derived from the ink, so the panel has no colours of its own.
--ask-linergba(29, 29, 31, 0.12)The one divider in the panel.
--ask-gutter1remIts 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.

  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. Nothing on the left edge moves when the panel opens.The logo and the copyright are anchored to the page gutter and stay there.
  6. 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.
  7. 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.
  8. 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.
  9. 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.
  10. 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.

  1. 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 useShould beApartWhere
    #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
  2. .section-medium sets 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 carries color: inherit to escape it.
  3. 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.
  4. Lists are a weight heavier than the copy around them.p declares font-weight: 300. No rule anywhere declares one for ol or ul, so a list falls back to the browser’s 400 at the same size as the paragraph above it. One declaration closes it.
  5. There is no semantic layer.A mood names its own hex values rather than resolving through --surface, --ink and --accent, so changing the red is several edits rather than one. The system is small enough that this has not cost anything yet.