Lou’s Film Diary

Design system

The vocabulary this site is built from

Every value below is read live from the stylesheet at render time, so this page cannot drift from the implementation. Flip the theme and watch the swatches follow.

Tokens live in src/styles/tokens.css

Colour

Two layers: raw ramps, and the role-named tokens components actually use. Components never reference a ramp step directly — that indirection is what makes a re-skin a one-file change.

Warm neutral

The paper-and-ink spine. Every surface, ink and border token resolves into this ramp.

50
100
200
300
400
500
600
700
800
900
950

Rust

The single brand hue — oxidised orange, borrowed from film leader and darkroom safelight.

50
100
200
300
400
500
600
700
800
900

Surfaces

Background planes ordered back to front. Nesting a raised surface inside a sunken one is what creates depth — reach for elevation before you reach for a border.

TokenValueUse it for
--surface-sunkenPage well behind cards; inset areas like a dropzone
--surfaceDefault page background
--surface-raisedCards, panels, popovers sitting above the page
--surface-overlayDialogs and menus floating over everything
--surface-inverseInverted blocks — footer, tooltips, toasts

Ink

Text colours by role, not by lightness. Every pairing listed here clears WCAG AA on its intended surface in both themes.

TokenValueUse it for
--inkPrimary body and heading text
--ink-mutedSecondary text, descriptions, metadata
--ink-subtleTertiary text — placeholders, timestamps, eyebrows
--ink-inverseText on surface-inverse
--ink-on-accentText on a filled accent background

Borders

Hairlines and dividers. `border` is the default for every bounded element.

TokenValueUse it for
--borderDefault hairline on cards, inputs, dividers
--border-strongHover and emphasis states; active drop targets
--border-inverseDividers inside inverted blocks

Accent

One brand hue, used sparingly. If more than a couple of things on a screen are accent-coloured, none of them read as important.

TokenValueUse it for
--accentPrimary buttons, active nav, links
--accent-hoverHover state for accent surfaces
--accent-activePressed state for accent surfaces
--accent-subtleTinted background for selected rows and badges
--accent-subtle-inkText on accent-subtle
--accent-ringFocus ring and selection glow

Status

Reserved for feedback about state. Never decorative. Each has a solid, a tint, and an ink that is legible on the tint.

TokenValueUse it for
--successPublished, upload complete, saved
--success-subtleBackground for a success badge or banner
--success-inkText on success-subtle
--warningDraft, processing, needs attention
--warning-subtleBackground for a warning badge or banner
--warning-inkText on warning-subtle
--dangerErrors, failed uploads, destructive actions
--danger-subtleBackground for an error badge or banner
--danger-inkText on danger-subtle

Scrims

Wash layers that sit between the page and a floating surface.

TokenValueUse it for
--scrimBehind dialogs and sheets
--scrim-heavyBehind the full-screen photo viewer

Typography

Fraunces for display, Inter for interface. A 1.200 minor third gives eleven steps that stay distinguishable next to each other.

text-6xl

Photographs, kept like a diary

text-4xl

Iceland, in February

text-2xl

Recent rolls

text-lg

Portra 400, pushed a stop and a half.

text-base

The body size everything else is measured against.

text-sm

Secondary copy and form help text.

text-xs

Captions, badges, metadata.

Families

Fraunces carries the editorial, printed-diary voice for display type; Inter does the UI work. Both fall back to a well-matched system stack so a blocked font request never reflows the page.

TokenValueUse it for
--font-display-stackHeadings, folder titles, hero type
--font-sans-stackBody copy, UI labels, form controls
--font-mono-stackToken names, file names, dimensions

Scale

A 1.200 minor third off a 16px root — tight enough that adjacent steps stay distinguishable.

TokenValueUse it for
--text-2xsImage dimensions, dense metadata
--text-xsEyebrows, badges, captions
--text-smSecondary copy, form help text
--text-baseBody copy
--text-lgLead paragraphs
--text-xlCard titles
--text-2xlSection headings
--text-3xlPage headings
--text-4xlFolder titles on the public site
--text-5xlHero, desktop
--text-6xlHero, large desktop

Rhythm

Line height and tracking. Display type gets tighter tracking; small caps type gets much looser.

TokenValueUse it for
--leading-tightDisplay headings
--leading-snugCard titles, multi-line labels
--leading-normalBody copy
--leading-relaxedLong-form captions
--tracking-tighterHero type at 3rem and up
--tracking-tightHeadings
--tracking-normalBody
--tracking-wideButtons and small UI labels
--tracking-widestUppercase eyebrows

Elevation & shape

Shadows are warm-tinted rather than neutral black — against a paper-coloured surface, a grey shadow reads as dirt.

xs
sm
md
lg
xl
xs
sm
md
lg
xl
2xl

Elevation

Warm-tinted shadows — a neutral black shadow reads as dirt against a warm paper surface. Elevation should track how far something floats above the page, so pick by role rather than by taste.

TokenValueUse it for
--shadow-xsBarely-there lift on inputs and flat buttons
--shadow-smCards at rest
--shadow-mdCards on hover; sticky headers once scrolled
--shadow-lgPopovers, dropdowns, toasts
--shadow-xlDialogs and the photo viewer chrome

Radius

Corner rounding. Photos themselves stay at `md` or less — heavy rounding crops into the frame and reads as a UI element rather than a picture.

TokenValueUse it for
--radius-xsFocus rings, tags
--radius-smInputs, small buttons
--radius-mdButtons, photo thumbnails
--radius-lgCards
--radius-xlFolder cards, dialogs
--radius-2xlHero panels, sheets
--radius-fullPills, avatars, icon buttons

Motion

Two easings and three durations. Everything honours `prefers-reduced-motion`, which the base layer collapses globally.

TokenValueUse it for
--duration-fastHover and colour transitions
--duration-baseDropdowns, toggles, badge swaps
--duration-slowDialogs, viewer open/close, carousel settle
--ease-out-softAnything entering the screen
--ease-in-out-softAnything moving within the screen

Components

Every primitive the dashboard and the public gallery are built from. If something here needs a one-off style, that is a signal the token layer is missing something.

Buttons

Primary is the one action a screen wants you to take. If two primaries are visible at once, one of them is secondary.

Form controls

Label, hint and error share one layout, so a field reads the same whether it holds text, prose or a choice.

Shown as the card title on the home page.

Use lowercase letters, numbers and hyphens.

Badges

Status colour is reserved for state. A badge that is merely decorative should be neutral.

PublishedDraftQueuedProcessing…Failed42 photosUnfiled

Cards

The building block of both the dashboard grid and the public folder carousels.

Iceland, in February

14 photos

Published

Three days of sideways rain and very good light.

Interactive card

Hover me

Lifts on hover — used where the whole card is a link.

Feedback

Progress, spinners, toasts and the empty state that stands in for a screen with nothing on it yet.

38%
Processing photos

No photos here yet

Drag a few JPEGs onto this folder, or use the upload button.

Delete this folder?

Its photos will be moved to Unfiled rather than deleted.

Built on the native <dialog> element, so focus trapping, Escape-to-close and background inerting come from the platform.

Using the system

How to extend it without breaking it.

Re-skinning the site

Edit the primitive ramps and the two semantic blocks in src/styles/tokens.css. Both the light block and the two dark blocks need the same keys — a token defined only inside a media query has no light value to fall back to.

Adding a token

Define it in the semantic layer, mirror it in the @theme inline block in app.css so a Tailwind utility exists, and add it to src/design/tokens.ts so it appears on this page with a note about when to use it.

Reaching for a raw colour

Don’t. If no semantic token fits, the palette is missing a role — add the role rather than hardcoding a hex value in a component. That is what keeps dark mode working without per-component effort.

Dark mode

Three states: an explicit data-theme attribute for light or dark, and no attribute at all for “follow the OS”. Every colour is defined on bare :root first, so a component always has a value even before a media query applies.

Focus and motion

One focus ring is defined globally in the base layer; components should never restyle it. All motion collapses under prefers-reduced-motion, which is handled once, globally, rather than per animation.