Appearance
Theming
The theme system (issue #264) makes the app's look-and-feel pluggable, Drupal/WordPress-style: a theme is one self-contained CSS file defining the full design-token contract, and the active theme per surface is a D1 settings row an admin flips at /admin/themes — no rebuild, no code change.
Architecture
app_settings (D1) src/lib/themes/<slug>.css components
theme.public = "nanawall" /* @theme {...} */ color: var(--ns-color-text);
theme.admin = "admin" → html[data-theme='<slug>'] { → background: var(--ns-color-surface);
--ns-color-*: …; border-radius: var(--ns-radius-md);
hooks.server.ts }
resolves + validates slug,
stamps <html data-theme=…>- Per-request resolution —
src/hooks.server.tsreads the surface's settings row (theme.publicfor buyer pages,theme.adminunder/admin), validates it against the registry, and stamps the slug into<html data-theme="…">viatransformPageChunk. Unknown, absent, or surface-mismatched values resolve to the surface default; a failed read never breaks rendering. - Token flow — components reference
var(--ns-*)names only. Tailwind v4's@theme inlineblock insrc/routes/layout.cssmaps the contract into utility names (bg-accent,text-muted,rounded-md, …). - Brand source — the
nanawalltheme aliases@nanawallweb/design-tokens(GitHub Packages; scoped registry in.npmrc). The published package's semantic tier (--nw-semantic-*) is imported globally inlayout.css; the flat--nw-*contract isn't published yet (upstream nanawall-design-tokens#162). Values the package lacks (state colors, app radii, shadows) are defined app-side and the gaps filed upstream — never silently forked.
The token contract
THEME_CONTRACT in src/lib/themes/index.ts is the single source of truth — 50 names covering surfaces (bg, surface, surface-muted/-inset/-inverse/-inverse-hover), text roles (text, -muted, -subtle, -faint, -heading, -inverse), borders, accent (accent, -hover, -strong, -soft, on-accent), state trios (danger/success/warning + -soft/-border), focus, overlay/on-overlay, scrim, fonts (sans/serif/mono, size-base, plus the voice registers display/display-weight/numeric), radii (sm/md/lg/full), shadows (sm/md/lg), motion (motion-duration/motion-ease), and imagery treatment (media-aspect/media-frame-pad, #844 — see art-direction.md).
Two gates keep it honest:
src/lib/themes/themes.spec.ts— every registered theme defines the entire contract and nothing outside it, and every theme's scrim is measured: composited over a worst-case white photo, the theme'son-overlaytext must stay ≥ 4.5:1 (AA).scripts/check-theme-tokens.mjs(runs innpm run checkascheck:tokens, and in CI via.github/workflows/ci.yml) — no hex/rgb/hsl literals, raw font stacks, or raw radii anywhere outsidesrc/lib/themes/, and everyvar(--ns-*)reference insrc/must name a contract token (a typo'd custom property fails silently in CSS; here it fails the build).
The AA contrast gate (#845)
check:tokens also computes WCAG contrast numerically for every registered theme — 13 meaningful pairs per theme: text/text-muted on bg and surface, text-heading on surface (3:1, large text), text-inverse on surface-inverse, on-accent on accent, focus against both surfaces (3:1, non-text UI), each state trio (danger/success/warning) on its -soft fill, and on-overlay over the scrim composited onto a worst-case white photograph. Floors are 4.5:1 for body text and 3:1 for large text and UI.
Reading a failure: each line names the theme, the exact pair, and the computed ratio —
graphite: --ns-color-text-inverse on --ns-color-surface-inverse — 1.08:1 (needs 4.5:1)Fix the theme value (usually by deepening the foreground or lightening the fill) and re-run npm run check:tokens. Values resolve through var() chains — a theme may alias the @nanawallweb/design-tokens package and still be measured. A pair whose token a theme doesn't define is reported as pair skipped: token undefined and does not fail the gate (tolerance for themes predating a token's introduction); an alpha color is composited over its pairing background before measuring.
Per-theme voice (#843)
Beyond palette, each theme declares a voice through three token groups:
- Type registers —
--ns-font-display+--ns-font-display-weightset the display face for headings (home hero h1,SectionHeading, the recommendation payoff name,FieldGrouplegends): Source Serif 400 in NanaWall (regular, matching nanawall.com's live heading weight — #978), serif 600 in Linen, the self-hosted Source Serif Pro at 700 in Pressroom (the sharpest serif display of the set — a masthead register), sans 700 in Graphite.--ns-font-numericsets the face for value cells (the compare matrix): mono in Graphite — the spec register — body sans elsewhere. Reading-register serif (pull-quote citations, stepper education prose) stays on--ns-font-serifin every theme. - Motion —
--ns-motion-duration/--ns-motion-easegive each theme a motion personality: the house 0.15s ease-in-out (NanaWall), near-instant 0.05s linear (Graphite), an unhurried 0.22s soft ease (Linen), a slow 0.4s cinematic expo-out (Pressroom — the boldest of the set), a 0.12s utilitarian snap (Admin). All public transitions/animations consume the pair with their old literals asvar()fallbacks.prefers-reduced-motionzeroes the duration globally inlayout.cssvia!important— no theme can opt back in. - Scrim —
--ns-color-scrimis the legibility layer for text over full-bleed photography (art direction bans duotones/grading, so themes differentiate imagery via crop, scrim weight, and frame). Depth ordering: Linen softest (4.67:1 over worst-case white), Admin 4.66:1, NanaWall 5.38:1, Pressroom 6.5:1, Graphite deepest (9.23:1). The spec enforces the AA floor per theme.
These are proposed contract additions per the brand-tokens process — defined app-side, filed upstream as nanawall-design-tokens#164, never silently forked.
Authoring a theme
Adding a theme is adding one file — src/lib/themes/<slug>.css. The filename is the slug (the data-theme value and registry key). No other code changes: the registry and the CSS bundle both discover files by glob.
css
/* @theme {
"name": "Display name",
"description": "One line shown in the admin picker.",
"surface": "public"
} */
html[data-theme='<slug>'] {
--ns-color-bg: #16181c;
/* …all 50 contract tokens — the spec fails on any omission… */
}- Manifest — the
/* @theme {...} */JSON comment is required:name,description, andsurface(public,admin, orboth). A malformed manifest skips that file (logged by the spec), never breaks the app. - Selector — use
html[data-theme='<slug>']. Its specificity beats the:rootfallback block innanawall.css, so import order can't produce a wrong cascade. - Surface —
publicthemes appear in the public picker,adminin the admin picker,bothin both. The server rejects persisting a slug to a surface its manifest doesn't allow. - Defaults —
DEFAULT_THEMES(nanawall/admin) are the fallbacks; if a default's file is ever deleted, resolution falls back to any theme registered for the surface.
Shipped themes
| Slug | Surface | Notes |
|---|---|---|
nanawall | public (default) | Brand theme — aliases @nanawallweb/design-tokens semantic tier; flat radii; also the :root rendering fallback. |
admin | admin (default) | Dense, utilitarian back-office: system font stack, blue accent, compact radii. |
graphite | public | Dark charcoal + amber alternate; proves a switch completely restyles the app with zero component changes. |
linen | public | Warm off-white linen surfaces, clay accent, softly rounded corners — a light editorial alternate (third selectable public theme, #542). |
pressroom | public | High-contrast editorial palette — cool gallery paper, vermillion masthead accent, high-contrast Source Serif Pro display, truly square corners, deep scrim. Pairs naturally with the Magazine/Spread layouts but renders under any arrangement. Art direction consulted via Stitch (#979). |
Settings & audit
app_settings is a generic key/value table (drizzle/0025_app-settings.sql); the theme keys are theme.public and theme.admin. Writes go through saveThemeSetting (src/lib/server/admin/themes.ts), which validates against the registry and is audited like every admin write (entity theme). An absent row is the canonical default state — nothing needs seeding.
The same table also backs page-chrome media (#412): media.<page>.<slot> keys (e.g. media.home.hero) whose value is a media_assets.id. The slot registry lives in src/lib/page-media, writes go through savePageMediaSetting (src/lib/server/admin/page-media.ts, audited as entity page_media), and adding a slot is a registry entry — no schema change. See managing-page-media.
Layout variants (#543)
Alongside the colour theme, a public surface also has a structural layout variant — an orthogonal dimension that controls arrangement (container width, vertical rhythm, hero columns/alignment) rather than palette. The two never reference each other: a theme sets data-theme, a layout sets data-layout, each resolves from its own app_settings key, and either can change without touching the other.
The layout registry (src/lib/layouts/index.ts) mirrors the theme registry exactly: one CSS file per variant with a /* @layout {...} */ manifest and an html[data-layout='<slug>'] block defining the full --ns-layout-* arrangement contract (--ns-layout-max-width, --ns-layout-gutter, --ns-layout-section-gap, --ns-layout-hero-columns, --ns-layout-hero-media-order, --ns-layout-content-align, and — since #828/#829 — the bleed permissions --ns-layout-hero-bleed / --ns-layout-band-bleed and the fluid type ramps --ns-layout-type-display / --ns-layout-type-title, per-layout clamp() scales consumed by the hero h1 and section headings). Adding a variant is adding one file. resolveLayout(requested, surface) validates against the registry and falls back to DEFAULT_LAYOUTS (standard); hooks.server.ts resolves it in the same D1 read as the theme and injects %ns.layout% onto <html>. The public surface consumes the --ns-layout-* props (each with a literal fallback, so it renders identically when no variant CSS is present).
| Slug | Surface | Width | Notes |
|---|---|---|---|
standard | both | 72rem | The established look — single centred column, media stacked over copy. Default + neutral fallback. No bleed. |
editorial | public | 90rem | Wider, asymmetric magazine spread — hero splits into a two-column spread (media to the right, 1.1/0.9), left-aligned, denser. Hero and bands may bleed. |
spotlight | public | 60rem | Narrow, gallery-style — a focused centred column with generous whitespace. Imagery bands may bleed; text never does. |
magazine | public | 100rem | The cover-story — one dramatic full-bleed column, hero photo edge-to-edge as a cover above the copy, grandest type ramp (~4.6rem). Everything bleeds. The immersive single-column cover, distinct from Editorial's split (#979). |
spread | public | 84rem | A dense two-page spread — balanced symmetric two-column hero (media left), tight printed-page rhythm. Hero bleeds; bands stay inside the grid. Gridded and packed, distinct from Editorial's roomy asymmetric split (#979). |
Every public page consumes the contract (#828): home, /compare, /select, and CMS pages read --ns-layout-max-width/-gutter/-section-gap, so a layout switch restructures the whole public surface. Two deliberate invariants opt out: the stepper's question column stays a focused ~44rem in every layout (one decision per screen), and a four-system comparison matrix may exceed the container up to 96rem (tables want width).
The bleed pattern. --ns-layout-hero-bleed / --ns-layout-band-bleed are 0/1 flags declaring whether the hero and photography/story bands may run edge-to-edge in that layout. A consuming section renders a full-width wrapper only when its flag is 1 and keeps its inner content on the container grid; text over a bled photograph must sit on the theme's scrim token. Bleed is a layout property, the scrim is a theme property — the seam stays clean. (Consumers land with the home restructure, #840/#842.)
Bespoke compositions (#543 / #1005). Beyond consuming the arrangement tokens, a layout may register its own composition for a surface — a larger structural change than re-spacing. The home page does this via src/routes/home-compositions.ts (layout slug → a whole-page component, e.g. HomeBroadsheet); the results (Recommendations) view does it via src/routes/select/results-compositions.ts (resultsTreatment(layout) → a treatment class that opts the payoff into the layout's look & feel — Broadsheet widens results to its editorial measure). Both fall back to the default markup when a layout isn't registered, so adding a bespoke composition is additive and regresses no other layout. Colour stays orthogonal — a composition references --ns-* theme tokens only. The stepper's question column keeps its focused ~44rem invariant either way; only the results payoff takes the layout's voice.
The layout keys are layout.public and layout.admin; an absent row is the default (standard). An admin selects the active theme and public layout from the Presentation section (/admin/themes, #544): the theme cards and the layout card are independent forms, writing through saveThemeSetting / saveLayoutSetting respectively (each validated against its registry and audited as entity theme / layout). Only the public surface offers a layout choice — the admin surface has the single standard variant.