Design system

The darkroom system

One token contract, two brands. This site is oxblood ink on a pale blue ground; the studio site is terracotta on warm paper, with a serif display face instead of a grotesque. They are not two stylesheets that resemble each other. They declare the same token names in the same roles, and each one then flips those same names again for its dark scheme, so there are two brands and four schemes running on one vocabulary.

Every value on this page is parsed out of src/styles/tokens.css at build time. Nothing here is retyped. A documentation page that keeps its own copy of the palette is wrong within a month, and the failure is silent: the docs go on looking correct while the site walks away from them. Changing a hex moves the swatch, the ratio and the grade together, which means a pair that stops passing starts failing in public.

Color

Ratios are WCAG 2.1, computed at build time against each scheme's own ground. Translucent tokens are flattened onto that ground first, because a ratio taken from raw rgba channels describes a color nobody ever sees. Text is held to 4.5:1 rather than the large-text 3:1, since the smallest thing wearing these colors is a 10px mono label.

Light, the print in daylight

Text colors on the light ground, with contrast ratios
TokenValueOn groundRatioGrade
--black #2A0E13 Aa 13.76:1 AAA
--text-body #3F4A55 Aa 6.94:1 AA
--muted #515C69 Aa 5.22:1 AA
--sienna #6B1621 Aa 9.14:1 AAA
--sienna-light #8E2A34 Aa 6.37:1 AA
--off-white #D7E3EE
--warm-white #C8D8E6
--placeholder-bg #BFD1E1
--hover-sweep #3A0810
--border rgba(42,14,19,0.17)

Surfaces and the hairline are ungraded on purpose. They carry no text and are not the only signal for any control, so the text thresholds would be the wrong instrument pointed at them.

Dark, back in the darkroom

Text colors on the dark ground, with contrast ratios
TokenValueOn groundRatioGrade
--black #F2EFE9 Aa 14.97:1 AAA
--text-body rgba(242,239,233,0.68) Aa 7.31:1 AAA
--muted #9A8288 Aa 4.85:1 AA
--sienna #A6C8E8 Aa 9.86:1 AAA
--sienna-light #C4DCF2 Aa 12.16:1 AAA
--off-white #3A0810
--warm-white #4A121C
--placeholder-bg #4A121C
--hover-sweep #6B1621
--border rgba(242,239,233,0.14)

Surfaces and the hairline are ungraded on purpose. They carry no text and are not the only signal for any control, so the text thresholds would be the wrong instrument pointed at them.

Two brands, one contract

The studio site is a different brand: terracotta on warm paper, a serif display face, no mono at all. It is not a variation on this one and it is not meant to look related. What the two share is the vocabulary. Every row below is a token name that means the same thing on both sites and holds a different value on each, which is the only reason one set of components could ever wear either skin.

Shared tokens and their value in each brand
Token Role This site Studio
--black Body text, and the strongest thing on the page #2A0E13 #2C1A0E
--off-white The ground everything is measured against #D7E3EE #F7F2EA
--warm-white Secondary surface #C8D8E6 #F1EADC
--sienna The accent, and every link #6B1621 #AC5634
--sienna-light The accent, lifted #8E2A34 #D4845C
--muted Metadata, eyebrows, 10px mono labels #515C69 #7C6C59
--border The hairline, the only rule drawn rgba(42,14,19,0.17) rgba(44,26,14,0.14)
--text-body Long-form copy, quieter than --black #3F4A55 #5A4030
--hover-sweep The fill a link sweeps to #3A0810 #3D2B1A
--on-sweep Text sitting on that sweep #D7E3EE var(--off-white)
--placeholder-bg An image that has not arrived yet #BFD1E1 #DED5BE

Where the contract forks

The interesting half. A shared vocabulary is only worth having if it is allowed to stop: forcing one site to carry the other's tokens would be tidiness bought with meaning.

Only here

  • --print #A6C8E8
  • --print-lt #C4DCF2
  • --font-mono 'Space Mono', ui-monospace, monospace

Only on the studio site

  • --measure 1200px
  • --gutter 48px

The studio's own contrast, recomputed

The studio tokens.css states its ratios in a header comment, including the two colors that failed first and what replaced them. These numbers are computed here from the same values, so they check that comment rather than repeat it, and they agree with it.

--sienna-light is absent from these two tables and present in the ones above. On this site it is link text, so it is held to 4.5:1 and makes it at 6.37:1. On the studio site the same token is only --drip-color, a decorative graphic that carries no text, and measuring it against a text threshold would report a 2.60:1 failure for a job it was never given. Which threshold applies is a property of the role, not of the name, and the role is allowed to differ between brands.

Studio, light

Studio light text colors with contrast ratios
TokenValueOn groundRatioGrade
--black #2C1A0E Aa 14.94:1 AAA
--text-body #5A4030 Aa 8.52:1 AAA
--muted #7C6C59 Aa 4.54:1 AA
--sienna #AC5634 Aa 4.56:1 AA

Studio, dark

Studio dark text colors with contrast ratios
TokenValueOn groundRatioGrade
--black #faf8f5 Aa 17.73:1 AAA
--text-body rgba(237,229,208,0.62) Aa 6.26:1 AA
--muted #8E7966 Aa 4.54:1 AA
--sienna #C4683F Aa 4.83:1 AA

Studio values read from the committed snapshot. Netlify checks out one repo, so a deployed build reads the committed snapshot instead. `npm run sync:studio-tokens -- --check` fails if the two have drifted. Needing a snapshot at all is the plainest argument for extracting both palettes into one versioned package, which is the next stage of this work.

Type
Aa
--font-display

Bricolage Grotesque. Variable, and the only face still served from Google, because its optical-size axis has to stay live.

Aa
--font-body

Instrument Sans. Self-hosted.

Aa
--font-mono

Space Mono. Carries every eyebrow, label and piece of metadata on the site.

The scale

Numbered, not named after a role, because the same rung carries mono metadata and body copy alike and a --fs-body-* / --fs-mono-* split would need two names for one value. In rem, so a reader who has raised their browser font size gets larger type here too; the pixel figure beside each rung is its size at a default 16px root.

  • --fs-100 0.5625rem Grade the ratio, not the swatch 9px, card tags
  • --fs-150 0.625rem Grade the ratio, not the swatch 10px, eyebrows, section nav
  • --fs-200 0.6875rem Grade the ratio, not the swatch 11px
  • --fs-250 0.75rem Grade the ratio, not the swatch 12px, cue pill, contact
  • --fs-300 0.8125rem Grade the ratio, not the swatch 13px
  • --fs-350 0.875rem Grade the ratio, not the swatch 14px, card copy, links
  • --fs-400 0.9375rem Grade the ratio, not the swatch 15px, body
  • --fs-450 1rem Grade the ratio, not the swatch 16px
  • --fs-500 1.0625rem Grade the ratio, not the swatch 17px
  • --fs-550 1.125rem Grade the ratio, not the swatch 18px
  • --fs-600 1.25rem Grade the ratio, not the swatch 20px
  • --fs-700 1.375rem Grade the ratio, not the swatch 22px

Display sizes

These stay role-named rather than numbered, because they fluid-scale and each one belongs to a specific piece of the page rather than to a rung. Resize the window and they move.

  • --fs-title-sm clamp(1.25rem, 2.4vw, 1.75rem) Aa
  • --fs-title-md clamp(1.5rem, 2.5vw, 2.25rem) Aa
  • --fs-title-lg clamp(1.625rem, 3vw, 2.5rem) Aa
  • --fs-page clamp(2.75rem, 6vw, 5.375rem) Aa
  • --fs-hero clamp(3.375rem, 13.6vw, 13rem) Aa

Tracking

Three registers. Display type pulls tight, mono metadata opens up.

  • --track-display -0.038em Karen Dettmar
  • --track-tight -0.028em Karen Dettmar
  • --track-meta 0.14em Karen Dettmar
  • --track-meta-wide 0.24em Karen Dettmar
Space

A 4px base. These are not an idealized ramp imposed afterwards; they are the values the layouts had already landed on, collected and named.

  • --space-1 4px
  • --space-2 8px
  • --space-3 12px
  • --space-4 16px
  • --space-5 24px
  • --space-6 32px
  • --space-7 48px
  • --space-8 56px
  • --space-9 72px
  • --space-10 100px
  • --space-11 140px
Line

The whole site draws exactly two: a hairline and a pill. There is no radius scale, because a scale of radii nobody uses is inventory, not a system.

--hairline
--radius-pill
Components

The honest shape of this system is that most of it is classes, not components. Seventeen .astro files exist; the vocabulary that actually holds the site together is a handful of shared class names in global.css that every page reaches for. That is a legitimate answer for a site this size. A component library built ahead of a second consumer is inventory, and the second consumer here is the studio site, which is what the next stage is for.

The shared vocabulary

Design system
.page-eyebrow

Mono, uppercase, widest tracking. Labels every section on every page, and is always the accessible name for the section it opens.

Aa Bb
.page-title

Bricolage at --fs-page, optical size pinned to 72. Emphasis is color, not italic, because Bricolage has no true italic and a synthesized one is a slant, not a face.

:focus-visible

Two-pixel accent ring, offset so it never sits on the glyphs. Never removed anywhere, and never replaced with an outline of none plus a color change.

A pill
--radius-pill

The only curve on the site. Everything else is a square corner and a hairline.

LoadingState

The one genuinely reusable component, in both variants: a stitch for a wait with nothing to report, and a progress form with a skeleton for one that has a message. All three tools use it, which is why the three of them wait in the same voice.

variant="stitch"
variant="progress"

ThemeSwitch

The safelight, up in the header. It is not duplicated here on purpose: it carries id="themeSwitch" and the layout script binds to that id, so a second copy would be a duplicate id and a control that only half works. A gallery that breaks the thing it documents is worse than a gallery with a gap in it. Use the one in the header.

Inventory

Counted at build time by scanning the source, not kept by hand, because a hand-kept inventory is a second source of truth about the first one and rots the same way a hand-copied palette does. This page excludes itself from the count, since demonstrating a component is not using one.

Every component, with how many files reference it
ComponentPathReferenced by
LoadingState components/LoadingState.astro 3
Footer components/global/Footer.astro 2
ThemeSwitch components/global/ThemeSwitch.astro 2
GarmentDecoderTool components/tools/GarmentDecoderTool.astro 1
Header components/global/Header.astro 1
ImageSlider components/ImageSlider.astro 1
MuseumHeaderVisual components/tools/MuseumHeaderVisual.astro 1
MuseumLabelTool components/tools/MuseumLabelTool.astro 1
PasswordGate components/PasswordGate.astro 1
Shutter components/global/Shutter.astro 1
TextilePatternGenerator components/tools/TextilePatternGenerator.astro 1
TrackSelector components/TrackSelector.astro 1

Every component in the tree is referenced. The count above is scanned rather than maintained, so that either stays true or stops being true in public.

Motion

This is the part of the system that is not a system yet, and the page would be worth less if it pretended otherwise. Color, type, space and line are tokens with one definition each. Motion is a convention held by hand across the stylesheets, and the counts below are scanned from the source at build time for the same reason every other number here is: a claim about consistency should not be the one thing taken on trust.

Two curves, and how they are spelled

cubic-bezier(0.2, 0.8, 0.3, 1)

The default. Hovers, toggles, anything that answers a pointer.

cubic-bezier(0.16, 1, 0.3, 1)

The reveal. Longer, later settle, for something arriving rather than responding.

There are 3 distinct easing curves in the source, written 6 different ways. The gap between those two numbers is the whole argument for tokenizing motion: nothing below is a different intention, only a different set of keystrokes, and no search will ever find all of one curve.

Easing curves and every spelling of each
CurveUsesSpelled as
cubic-bezier(0.2,0.8,0.3,1) 9
cubic-bezier(0.2, 0.8, 0.3, 1) ×4
cubic-bezier(0.2,0.8,0.3,1) ×4
cubic-bezier(.2,.8,.3,1) ×1
cubic-bezier(0.16,1,0.3,1) 5
cubic-bezier(0.16, 1, 0.3, 1) ×4
cubic-bezier(0.16,1,0.3,1) ×1
cubic-bezier(0.25,0.46,0.45,0.94) 1
cubic-bezier(0.25, 0.46, 0.45, 0.94) ×1

Durations

28 distinct durations, against eleven rungs of type and eleven of space. The type scale earned its rungs by collecting what the layouts already landed on; the same pass has never been run on time. The ten most used:

  • 0.2s 44 uses
  • 0.3s 34 uses
  • 0.4s 14 uses
  • 0.35s 13 uses
  • 0.25s 9 uses
  • 0.5s 7 uses
  • 0.45s 6 uses
  • 0.18s 5 uses
  • 1.4s 4 uses
  • 0.8s 3 uses

Reduced motion

One rule, stated in global.css: reduced motion gets the content, not a slower entrance. Nothing is retimed and nothing fades in late. The animation is removed and the thing it was going to reveal is simply there.

It is honored in 11 places, 3 in CSS and 8 in script, and it has to be both. A media query cannot reach inside a requestAnimationFrame loop drawing to a canvas, so every loop tests the preference itself, on every frame rather than once at startup, which is why toggling the setting takes effect without a reload.

In CSS

  • pages/about.module.css
  • styles/darkroom.css
  • styles/global.css

In script

  • components/global/Shutter.astro
  • components/global/ThemeSwitch.astro
  • components/LoadingState.astro
  • components/tools/TextilePatternGenerator.astro
  • layouts/MainLayout.astro
  • scripts/darkroom.ts
  • scripts/face-cloud.ts
  • scripts/shutter.ts

One of those was missing until recently. The textile pattern generator ran three animation loops with no check at all, on the same site as a case study about accessibility. Fixing it was not the one branch it looked like: with no loop running, nothing was left to pick up a state change, so the density slider and the color inputs went dead the moment the motion stopped. Both had always relied on the next frame arriving to render their change. Turning an animation off is rarely one branch, and the control that depended on the loop is the part that breaks quietly.