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-sunken—Page well behind cards; inset areas like a dropzone
--surface—Default page background
--surface-raised—Cards, panels, popovers sitting above the page
--surface-overlay—Dialogs and menus floating over everything
--surface-inverse—Inverted 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
--ink—Primary body and heading text
--ink-muted—Secondary text, descriptions, metadata
--ink-subtle—Tertiary text — placeholders, timestamps, eyebrows
--ink-inverse—Text on surface-inverse
--ink-on-accent—Text on a filled accent background

Borders

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

TokenValueUse it for
--border—Default hairline on cards, inputs, dividers
--border-strong—Hover and emphasis states; active drop targets
--border-inverse—Dividers 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
--accent—Primary buttons, active nav, links
--accent-hover—Hover state for accent surfaces
--accent-active—Pressed state for accent surfaces
--accent-subtle—Tinted background for selected rows and badges
--accent-subtle-ink—Text on accent-subtle
--accent-ring—Focus 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
--success—Published, upload complete, saved
--success-subtle—Background for a success badge or banner
--success-ink—Text on success-subtle
--warning—Draft, processing, needs attention
--warning-subtle—Background for a warning badge or banner
--warning-ink—Text on warning-subtle
--danger—Errors, failed uploads, destructive actions
--danger-subtle—Background for an error badge or banner
--danger-ink—Text on danger-subtle

Scrims

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

TokenValueUse it for
--scrim—Behind dialogs and sheets
--scrim-heavy—Behind 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-stack—Headings, folder titles, hero type
--font-sans-stack—Body copy, UI labels, form controls
--font-mono-stack—Token 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-2xs—Image dimensions, dense metadata
--text-xs—Eyebrows, badges, captions
--text-sm—Secondary copy, form help text
--text-base—Body copy
--text-lg—Lead paragraphs
--text-xl—Card titles
--text-2xl—Section headings
--text-3xl—Page headings
--text-4xl—Folder titles on the public site
--text-5xl—Hero, desktop
--text-6xl—Hero, large desktop

Rhythm

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

TokenValueUse it for
--leading-tight—Display headings
--leading-snug—Card titles, multi-line labels
--leading-normal—Body copy
--leading-relaxed—Long-form captions
--tracking-tighter—Hero type at 3rem and up
--tracking-tight—Headings
--tracking-normal—Body
--tracking-wide—Buttons and small UI labels
--tracking-widest—Uppercase 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-xs—Barely-there lift on inputs and flat buttons
--shadow-sm—Cards at rest
--shadow-md—Cards on hover; sticky headers once scrolled
--shadow-lg—Popovers, dropdowns, toasts
--shadow-xl—Dialogs 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-xs—Focus rings, tags
--radius-sm—Inputs, small buttons
--radius-md—Buttons, photo thumbnails
--radius-lg—Cards
--radius-xl—Folder cards, dialogs
--radius-2xl—Hero panels, sheets
--radius-full—Pills, avatars, icon buttons

Motion

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

TokenValueUse it for
--duration-fast—Hover and colour transitions
--duration-base—Dropdowns, toggles, badge swaps
--duration-slow—Dialogs, viewer open/close, carousel settle
--ease-out-soft—Anything entering the screen
--ease-in-out-soft—Anything 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.