The rules behind every build.
This is the actual system this site runs on — not a picture of one. Every swatch, ratio and specimen below is rendered by the same stylesheet as the rest of the site, so it cannot fall out of date. Each section says what the rule is and why it exists.
Two layers, and the difference is the whole trick
Colours are defined twice: once as raw paint, once as a job. Components only ever ask for the job.
Palette — raw paint
These never change between themes. Nothing outside the token block is allowed to reference them directly, because a component that names a colour has already decided what it looks like in every theme that will ever exist.
Semantic tokens — the job
Every ratio below is computed in your browser, right now, against the theme you are actually looking at. Switch the theme with the button in the header and watch them recalculate. The threshold shown is the one that applies to how that token is used — 4.5:1 for body-size text, 3:1 for large text and for graphics.
--surface is
already correct in both themes. The alternative, overriding components inside a
[data-theme="dark"] block, means every new component needs a second rule
written for it, and the day someone forgets is the day the site has a white box in the
middle of a dark page.
--border and --border-input are separate tokens.
Hairlines are the main structural device here, and a decorative rule between two blocks is
exempt from contrast rules — it is not carrying information. The rule under a form field
is the opposite: it is the only thing indicating a field is there at all, so it has to clear
3:1 against the page. The same visual idea with two different obligations, so: two tokens.
Three states, not two
Light, dark, and whatever your computer is set to — which is the default.
| State | What is stored | What decides the look |
|---|---|---|
| Auto (default) | nothing |
Your OS setting, through prefers-color-scheme. Change it in Windows or macOS and this page follows without a reload. |
| Light | sx-theme = "light" |
data-theme="light" on <html>, which beats a dark OS. |
| Dark | sx-theme = "dark" |
data-theme="dark", which beats a light OS. |
"auto" as a third value would mean the CSS had to handle a case
the cascade already handles for free, and it would make "follow my system" indistinguishable
from "this visitor has never chosen", which are different facts.
<head>.
It has to run before the first paint, or a visitor who chose dark gets a white flash — the
single most obvious cheap-website tell there is. That rules out putting it with the rest of
the JavaScript, which is deferred. It also cannot be an inline <script>,
because this site sends script-src 'self' with no 'unsafe-inline'
and the browser would refuse to run it. So it is one 30-line same-origin file that
deliberately blocks rendering, and nothing else does.
A serif that sets things, a sans that says them
Instrument Serif carries the display sizes. Inter carries every word anyone actually has to read.
clamp() instead of breakpoints. Every heading interpolates
smoothly between a phone minimum and a desktop maximum, so there is no window width where
type is awkwardly large or suddenly jumps. It also means one declaration per level instead
of the same rule restated inside three media queries, which is three chances to forget one.
text-wrap: balance on every heading. It stops the last line of
a headline being a single orphaned word, which is the difference between a headline that
looks set and one that looks like output.
Rhythm you notice only when it is missing
Corner radius
2px
3px
4px
toggle only
Elevation
resting
hovered
lifted
Section rhythm
| Token | Value | Job |
|---|---|---|
--maxw | 1240px | Reading column ceiling. Wider than this and line length beats layout. |
--gut | 26px | The one horizontal inset. Every full-bleed section still uses it inside .wrap. |
.section | 72 → 152px | Vertical padding, fluid. Sections breathe more on desktop and less on a phone, without a breakpoint. |
.narrow | 800px | Centred prose. Roughly 75 characters at body size, which is where reading speed peaks. |
Three tiers, three different mechanisms
Hover a card below to run its curve.
| Tier | Mechanism | Duration | Used for |
|---|---|---|---|
| Entrance | CSS @keyframes + animation-fill-mode: both | 1150ms | Above-the-fold content on load. Pure CSS, so it cannot leave anything hidden. |
| Reveal | IntersectionObserver adds [data-reveal] | 1050ms | Everything below the fold, staggered up to 5 steps. |
| Interaction | CSS transition | 200–380ms | Hover, focus, press. Anything slower reads as lag, not polish. |
| Ambient | Infinite @keyframes | 26–46s | The drifting hero glow, the floating mock, the marquee. Slow enough to never pull the eye. |
[data-reveal] is applied at runtime, so a visitor with
no JavaScript — or one whose observer never fires — sees a completely normal page. Ship
opacity: 0 in the stylesheet against markup that is already there and one
failed script turns the whole page blank.
Do — animate transform and opacity
Both are composited by the GPU and never trigger layout. The scroll progress bar uses scaleX and is coalesced into requestAnimationFrame, so it costs nothing on a phone.
Don't — animate width, height or top
Every frame forces a reflow of everything below it. It is the difference between a bar that glides and a page that stutters while you scroll.
The pieces, as they actually render
Buttons
Cards
Card with hover
Lifts 4px, deepens its shadow, and tracks a soft highlight under the cursor.
Static card
Same surface and border, no interaction. Used where the card is not a link or target.
Badges
Two states with real meaning on the work page: is this a paying client's site, or mine.
Form fields
:focus-visible outline in the accent colour with 2px of offset. It appears
for keyboard users and stays out of the way for mouse users, which is exactly the behaviour
:focus-visible exists to provide.
Tags
Checked with numbers, not by eye
Do — describe what an image shows
The alt text on the work page says what is in the screenshot, not that it is a screenshot. Decorative marks, like the logo beside the wordmark, take an empty alt so they are skipped entirely.
Don't — label a control with its state
The theme button says "Switch to dark theme", not "Dark theme". A label that names the current state leaves a screen reader user guessing whether it is a description or a promise.
| Check | How it is enforced |
|---|---|
| Colour contrast | A script scores every token pair against the threshold for its real usage, in both themes, before anything ships. |
| Keyboard reach | A skip link first in the DOM; :focus-visible on everything interactive; Escape closes the mobile menu and returns focus to the button that opened it. |
| Reduced motion | One global media query, so it also covers animations added after the fact. |
| Horizontal overflow | Measured at 390, 768, 1280 and 1600 by comparing scrollWidth to clientWidth — the most common mobile defect, and invisible in a desktop browser. |
| Text zoom | Type is set in rem and containers in ch/%, so a visitor at 200% zoom gets bigger text rather than a broken layout. |
This is the standard your site gets
Every build starts from this system — which is why a five-page site can be finished in days without cutting the parts nobody sees.
