Interface guidelines

Foundational rules for every apps/web surface — attendee pages, admin panel, speaker wizard. Adapted from interfaces.dev/cheat-sheet and bound to this repo's own machinery where the two meet (theme tokens, the hex sweep, the a11y bar in CONTRIBUTING). PRs that touch UI are reviewed against this document.

How this connects to the rest of the repo#

  • Colors are theme tokens, never literals. The ESLint hex sweep (spec §7.6) enforces the mechanical half; this document covers the design half. Every color you use must be a custom property from config/theme (spec §7) with a role-based name.
  • Tokens come from design/tokens/. The design system's three tiers, the motif layer, and every scale live in JSON there (design brief §3.5). scripts/lib/tokens.cjs turns that JSON into the custom properties apps/web/src/generated/theme.css carries, and tailwind.config.js maps the utilities onto them. Change a token by editing the JSON and regenerating. Never hand-edit the generated stylesheet.
  • Accessibility is a merge requirement, not a nicety. CONTRIBUTING already requires a keyboard path and visible focus for UI changes; the Accessibility section below is the working checklist behind that line.
  • Event-neutrality applies to design too. No hardcoded brand color, font stack "for this event," or copy that assumes one venue or city. If a design decision varies per client, it belongs in config/theme or CMS content.
  • The reference is the other half. design-reference.md documents what the system contains — every site style, option, illustration set, layout axis, token, and extension point. This document is the rules a review applies; that one is the material those rules apply to.
  • The design brief binds. plans/2026-08-27-design-system-overhaul.md is the design contract; this document is the standing rulebook that carries it into review. Where the two disagree, the brief wins and this document gets fixed in the same pull request.

Vocabulary: The words staff see#

Staff meet this system through words, not through token names. One term per flow (Writing, below), and the staff term is the one that ships in every operator-facing surface: Admin labels and hints, docs/ADMIN_GUIDE.md, the handbook, and any message a person reads.

What staff seeWhat the code calls it
Site stylepreset — config/theme.preset, data-theme
Main brand colourconfig/theme.brandColor, and the steps derived from it
Header stylethe nameplate option group — optionPicks.nameplate; config/theme.header; cmsPages.layout.header; resolveHeader
Schedule stylethe component option group — optionPicks.component
Page templatecmsPages.template, resolved by pageTemplates.js
Illustrationsmotif set — config/theme.motifSet, data-motif-set
Page previewthe proof — ThemeProof, --admin-ground-proof
Advanced colour settingsraw per-mode token override — config/theme.tokens
Admin coloursconfig/theme.adminScheme, resolved by resolveAdminScheme into the admin-action-* and admin-rail-* tokens

The left-hand column is spelled the way the control is spelled. These are the labels the admin renders, so a guide that quotes them matches what a staff member is looking at, down to the spelling of "colour".

The right-hand column is internal. Those are component names, token names, attribute values, and stored document fields. Use them freely in this document, in docs/adr/0001-event-platform-v1.md, in code, and in commit messages: That is architecture, and renaming it would break the thing it names. Where one appears below beside a staff word, it is marked. What must never happen is one of those words reaching a person using the product.

Editorial devices#

Typography and rules do the visual work. Color decorates very little, and shadow decorates nothing (design brief §2.1). Each device below has one job: Use it where its job applies, and never as decoration. The shared implementations live in apps/web/src/components/editorial/, and each one resolves through its tier-3 token contract, so a preset retunes a device by remapping tokens rather than by changing a component.

DeviceComponentWhat it isThe rule
Site headerHeaderThe site identity plus the navigation, in one of four treatments: standard, masthead, compact, minimal (internal values; staff pick a Header style — Vocabulary above).See Headers below. The theme names the default and a page may override it. Compact identity and navigation. Page-owned EventHero is separate from this repeated header. The identity repeats on every page, so it is NEVER a heading: Each page owns its own h1. An unrecognized value renders the base rather than no header at all.
Masthead nameplateNameplateThe rule-bounded title block: Event name, dates, edition line.The device the header treatments draw: compact is the compact header; the full device remains available to editorial compositions. It is no longer the shell's only header, and its shape is unchanged. A theme chooses it; it is never mandatory.
Lead imageLeadImageOne optional picture beside the opening copy, or below it at narrow viewports.Alt text is required or the image does not render. The crop is fixed by token and the editor states the focal point. Text never sits over it. One per page.
FolioFolioA small-caps plain-text label sitting on a hairline rule.Text plus rule. Never a chip, never a pill, never a colored badge. Never directly above a heading — see the eyebrow ban below.
RuleRuleA standalone hairline, strong, or nameplate rule.A rule replaces a card border. A rule never carries brand color and reads only the --rule-* tokens. Where the rule belongs to a row that already exists, put the border on that row instead of adding a node.
Section boundarySectionHeadOne strong rule, then the section heading with the folio beside it on a hairline.Reach for this instead of composing a folio and a heading by hand. Its folio variant makes the folio itself the heading, which is what a schedule day head needs. Its action slot holds one control that belongs to the section, such as the admin's Edit section link. The slot is drawn last in the head row, after the folio, and never above the heading.
Stat and chart anatomyStatBlockA number the site presents as evidence.Four parts, and all four are required (design brief §2.1.1). A large number with a small caption under it is not a stat block.
Grid scheduleScheduleGridA two-axis schedule: Time down the left, lettered tracks across the head.Wide viewports only. It is a real <table> — times are row headers, tracks are column headers — and it degrades to the time-ordered list, which is the accessible baseline and not a lesser view.
Back issuethe .back-issue treatmentThe archival state of a past day or a past event.Reduce the palette to the archive tokens, add the "Back issue" folio, remove the live controls. Never hide the content.
Print viewSchedulePrintThe programme a desk hands out.Its own view, not the screen with the controls hidden. It is display: none outside print media, so it never reaches the accessibility tree twice.

Every stat carries four parts, and the write path enforces it. A stat block states the finding in words (takeaway), says what the number counts and over what period (description), names where the number came from and the date it was read (source), and describes the finding for a screen reader (alt). The block editor asks for all four and refuses to save without them, and cmsCreateContent / cmsUpdateContent reject a stat block that misses one, naming the part. A block already stored in the older { value, label } shape keeps rendering and keeps publishing — what stops is writing one that way.

The schedule reads twice, and both readings are first-class. At wide viewports the day is a grid: Time down the left in the data face, the event's lettered lines across the head in config/event.tracks order. A session on no line spans the width, which is what a plenary is. Everywhere else the day is a time-ordered list with a fixed column order and tabular figures. Exactly one of the two is in the document at a time, so nobody meets a session twice, and the list is what a viewport that cannot be measured gets. A parent session's children are calling points in both: Under the parent, inside its time, never rows of their own, with the relationship stated in words rather than carried by the indent alone.

Four directories, four compositions — on one token system. Speakers, attendees, sponsors and updates are not the same kind of list, and reading each of them is a different job, so each gets the shape its job needs rather than one ruled row under four different headings. Speakers is a shelf of portraits: The headshot leads at a size a face survives, framed on the alternate ground, so a speaker with no photo is an empty frame holding its place rather than a hole in the shelf. Attendees is an index, not a list of profiles — one compact line per person under sticky letter heads, because a reader here is searching for a name they already know; a name this alphabet does not cover files under #, never under A. Sponsors is a tiered logo wall whose mark size carries the tier's weight, and standing comes from the operator's own ordering, never from ranking the words they typed. Updates is a chronological feed with a spine: A hairline down the leading edge, a tick per entry, and standing heads for Pinned (which is not a date), each month, and Undated. Every one is built from the same spacing scale, rules, ink, radius and density remaps, and none of them is a card.

A movement is stated only from a record, and only where the reader stated the journey. Walking between two rooms is a fact about a building, so it lives with the building: config/event.venue.places[] names the rooms a session points at by placeId, and config/event.venue.movements[] records one move — from, to, walkingMinutes, and optionally accessibleRoute in the operator's own words (shared/venue.cjs, validated by validateEventConfig; a session's placeId is checked against the venue's places the way its track letter is checked against the event's tracks). A movement is one-way and is never reversed, chained, or estimated; a recorded 0 means "across the corridor" and reads as "under a minute"; an absent accessibleRoute is silence, never "there isn't one". A room label in location is never matched against a place name — that is the string comparison the whole model exists to remove.

And the record says what a move costs, never that this reader is making it. So TransferLine renders in exactly two places, both carrying a stated sequence: Between two sessions the reader bookmarked (my schedule), and from a parent session to a calling point inside it in another place. Never between two consecutive rows of the full programme — that reader skipped a session, or is following one line out of five. The line is not preset-gated: A surveyed walking time is a fact, and a fact five presets hide is a fact withheld from the reader who needed it most.

A back issue keeps every word. A day whose last minute has passed on the event's clock, and every day of an event the operator archived, drops to the archive tokens. The device reduces a palette rather than introducing one: Inside a back issue the accents resolve to the archive ink, while the ink, the rules, and the grounds are untouched, so nothing reads at less than the contrast it had. The live controls leave the document rather than sitting there disabled — bookmarking a session that has finished is an act on nothing — and the materials it left behind stay, because those are content.

The eyebrow ban is absolute. No text sits directly above a heading. Not an eyebrow label, not a chip, not a small line of description copy, and not a plain small-caps folio — a folio stacked above a headline is still an eyebrow, and a reviewer rejects it. The rule holds at every size, on every surface, in every preset. A folio lives beside a rule at a section boundary, in a margin, or in a running header. Where a label must sit near a title, put it below the title or beside it.

Two things are not eyebrows and must never be "fixed" to satisfy this rule:

  • Metadata inside the rule-bounded nameplate block. The dates and the edition line are the nameplate device, wherever they sit inside it.
  • A form <label> above its own input. That is a control label.

Cards, boxes, and chrome: A rule replaces a card border wherever a rule can do the job. Elevation is tint, not shadow — where a surface must sit above another, shift its tone with a low-opacity overlay of the theme's ink or accent.

Headers#

A public page renders one of four headers (design brief §2.5.1). config/theme.header names the deployment's default and a page may state its own; standard is the base, and a value outside the four resolves to it rather than to no header at all.

TreatmentWhat it draws
standardThe event name at heading size, the dates and place beside it, the navigation under a hairline. The base.
mastheadA compact event identity beside the navigation.
compactThe nameplate device at running-header size: Short name and dates on one baseline.
minimalThe client's mark and the navigation, and nothing else.
  • Every treatment carries the site identity and the navigation. Dropping either fails review.
  • The identity repeats on every page, so it is never a heading. Every page owns its own <h1>.
  • masthead places a compact event name beside navigation. The home page keeps its visible h1 in its own hero.
  • No treatment puts text over an image, and none of them is a hero banner.
  • Never make one treatment mandatory across every deployment.

The base and the theme#

The base is what a deployment renders before its theme states anything, and it is neutral. The theme is where the character lives (design brief §2.5).

  • Flat is the base. The page ground paints no texture on its own. config/theme.texture opts into one, and only then. Keep texture where it builds atmosphere or separates one plane from another; never add it as decoration.
  • The seeded palette is grey. Every brand slot in a fresh deployment is achromatic, so the first hue a reader sees is the client's own. Semantic slots keep their hues, because a status color that is grey states nothing.
  • The seeded type is one neutral face for every role. A theme brings the pairing.
  • Client identity leads. This system supplies type, spacing, rules, focus, and accessibility. It never makes a deployment read as Event Runner first and the client's event second.
  • Neutral is a default, not a ceiling. A theme may be very expressive. The masthead, texture, folios, drop caps, and unusual typography stay fully developed choices, and a neutral fallback always exists beneath them. Control complexity with defaults and contracts, never by deleting a capability.

User interface#

  • Apply concentric border radius across nested elements (inner radius = outer radius − padding).
  • Prioritize optical alignment over geometric alignment.
  • Add a 1px outline to images, offset by -1px: Black at 8% opacity in light mode, white at 8% in dark mode.
  • Portraits and avatars are square on the brand radius (rounded-brand), never a circle (rounded-full) — a circular crop is a generic-template tell.

Interaction states#

Every control and every interactive row defines these ten states, in both modes, on both surfaces. A state is a word, an ink change, a weight change, or a rule change. A state is never colour alone and never a pill. The public shapes carry them through one shared class string (components/controlClasses.js); the admin carries them through admin/components/formControls.jsx. Neither imports the other.

StatePublic siteAdmin
RestThe style's own ink and groundThe desk's ink and ground
HoverThe control's own ink mixed into its own ground at --state-hover-share, inside @media (hover: hover). A control set as text takes no tint: its hover is the ink and the underlineThe same device, with the desk's tints
Focus-visibleThe 3px ring in the accent, outside the element, never removedThe 3px action ring; the light ring on the rail
Active (pressed)Scale 0.98 at --motion-slow on transform, inside motion-safe:, plus the firmer tint at --state-pressed-shareThe pressed action ground; no scale
Selected or currentWeight, plus one of: the tint at --state-selected-share on aria-pressed, the filled ground on aria-checked, the strong rule on aria-selected, aria-current on a linkThe rail's four signals; a filled row marker elsewhere
Disableddisabled where the control must not act; aria-disabled="true" where it must stay focusable to explain itself, and then its handler refuses every activation path. The disabled ink on the alternate ground, the pointer unchanged. Never a removed controlThe same
Busyaria-busy="true" and a stated word inside the control ("Saving…"); never a spinner, never a shimmerThe same, and the result stays in place after
Erroraria-invalid="true", the alarm rule, a message under the field named by aria-describedby, and focus moved to the first error on submit (lib/focusFirstError.js)The same
SuccessA stated line in place, in a role="status" region, that stays. A toast may repeat it, and a repeat carries no live role, so one result is announced onceThe same
EmptyA sentence that names what was searched or filtered, plus one actionThe desk's empty state

A submit control is never disabled for a validation state. A disabled control announces nothing, so a person who presses it learns nothing. Keep it enabled until the request starts, mark the fields, and move focus to the first one that refused.

A hover state never transitions. Colour is repainted on every frame, and a state a reader caused has to land at once. Only transform and opacity animate.

A tap is not a hover. A touch screen reports a tap as a hover and holds it until the reader taps elsewhere, so an unguarded hover tint looks like a selection nobody made. Tailwind's hoverOnlyWhenSupported puts every hover: utility inside the query; a raw :hover rule in index.css carries @media (hover: hover) and (pointer: fine) by hand.

A new tab says so. A link with target="_blank" uses the shared ExternalLink, which puts "(opens in a new tab)" inside the link's own name, or composes its NewTabNote where the call site needs its own element. A note beside the link is read after the reader has already followed it.

The radio is the one round shape the system draws. Full rounding as decoration is refused everywhere else (design brief §2.4, and the refusal is a test). The one other full round is a compact chip in a listed file (see Chips under the admin rules), and .control-choice--radio keeps its border-radius: 50% because a circle is the radio's universal form and the reason a reader can tell "pick one" from "pick any" before reading a word.

A field takes no hover tint. The Hover row names one exception, the text register; a field is the second. inputClass and .control-choice compose none of the shared tint, so an input, a select, a checkbox, a radio, a search field and a filter group draw no hover state at all, and their only state changes are the focus ring and the error rule. The specimen book prints that register and its reason under every field it draws (apps/web/src/pages/specimen/controls/states.js).

A dialog is a native <dialog> opened with showModal(). That one call traps focus, makes the page behind it inert, sends Escape as cancel, and puts the dialog in the top layer. The component returns focus to the opener itself, because React removes the dialog on close and an element removed while it holds focus drops focus to the body. The scrim is tinted ink at low alpha, never a blur.

The shared controls#

Each one is keyboard-first, reads the tier-2 tokens, and lives in apps/web/src/components/forms/.

ControlOne jobThe rule
SwitchA setting that is on or offrole="switch", aria-checked, the state as a word beside the label. The label names the ENABLED state. No sliding knob.
SegmentedControlOne choice from a short setA role="radiogroup" set as one ruled row. The chosen word takes the filled ground and the bold weight. One tab stop; arrow keys, Home and End move inside it. Never a pill.
TabsOne panel from a short setThe ARIA tab pattern with a roving tab index. Selection follows focus. The selected tab carries the strong rule. Never a pill, never a filled tab.
Checkbox, RadioPick any; pick oneA real input with appearance: none, redrawn from the tokens: the boundary in --color-border-control, the checked fill in the accent, the mark an inline SVG reading currentColor. The keyboard, the label and the group stay the platform's.
SearchFieldNarrow a list by wordsA labelled type="search" input, a stated clear control, and the result count in a role="status" line the caller feeds.
SortControlOrder a listA labelled select. The label names what is being ordered.
FilterGroupNarrow a list by a facetA <fieldset> with a <legend>, the active count in the legend, and one clear control for the whole group.
DropzoneSend a fileA ruled region around a real type="file" input, which stays the keyboard's; the region shows the focus ring while the input has focus and says it is busy while files go. A stated progress line with a <progress> element and a sent list with a state word per file, in one status region. A refusal draws the strong rule in the danger ink as well as its sentence.
RepeaterA short list of rows a person adds and removesA <fieldset> with a <legend>, every row's fields labelled, one remove control per row that names its row, one add control under the list, which stays at the cap, unavailable, with the limit in its name. Focus follows the change: a new row's first field, or the row before a removed one.

An unavailable tab or segment stays in its row. Tab and a SegmentedControl option take disabled: the control carries aria-disabled="true" rather than disabled, so the arrow keys can still land on it and a screen reader hears that it is unavailable and why (hint is read as part of its name), and its handler refuses every activation path — a click, Enter, Space, and the arrow keys, which move focus onto it without selecting it. A sighted reader sees the state too: The word takes a dashed rule in place of the strong rule or the filled ground, and the hint is drawn under the row while the word has focus or the pointer. The row's one tab stop follows focus, so Tab and Shift+Tab from an unavailable word leave the row rather than landing back inside it, and it returns to the selected word when focus leaves. Tabs.test.jsx and SegmentedControl.test.jsx prove all of it. A tab or a set with nothing behind it at all is not rendered; this state is for a panel or a choice that exists and cannot be taken yet.

Animation#

The system has two motion classes and there is no third (design brief §2.2).

  • Functional motion shows a state change: Menus, filters, tabs, disclosure, toasts. Keep it between 120ms and 200ms, animate transform and opacity only, and use ease-out. The duration-fast / duration-base / duration-slow and ease-motion utilities map to the motion tokens.
  • Expressive motion is one designed signature interaction per surface. The schedule grid holds that slot on the public site: A track column comes forward when a reader presses or focuses its head. Focus previews it, the press keeps it, and aria-pressed is the record. The state is a tint of the column's own ground and the motion is a small lift, so the state lands in both motion settings and only the lift is animated — under prefers-reduced-motion the column still comes forward and nothing moves. No other column is dimmed, so no text drops below its contrast bar at any frame.

Both classes: Never trigger motion from scroll position, never run ambient animation, and keep wayfinding instant — navigation, route changes, and focus moves never animate. Support prefers-reduced-motion in full; the reduced state is truly static, and a shortened animation is not a fallback.

Enter and exit are the two moves, and there is no third. An element that arrives fades up over at most 8px at --motion-base; an element that leaves fades back down it at --motion-fast. Both take --motion-ease: there is ONE curve, and the asymmetry is carried by duration alone. An enter never opens on an ease-in, which would delay the response at the exact moment the reader is watching. The utilities are .motion-enter and .motion-exit in index.css, both inside @media (prefers-reduced-motion: no-preference), and the reduced state is the END state, not a shorter animation. Enter, exit and press are public only: the admin has no expressive motion and a state change in the room is instant, so the shared toast carries no motion class there.

The refusals are a test, not a paragraph. apps/web/src/lib/motionContract.test.js parses index.css with PostCSS and scans every shipped .js and .jsx. It fails on transition: all or transition-all; on a numeric or arbitrary duration-* or delay-* utility and on any millisecond value outside the four duration tokens; on an animated property other than transform and opacity; on a :hover rule outside the hover query; on animate-pulse, animate-spin, animate-bounce and animate-ping; on any keyframe run infinite; on a transition or animation declaration outside a no-preference block, the global reduce block and the mode swap excepted; on rounded-full outside a file listed in its CHIP_FILES, and on a listed chip that lacks a remove button named for its value at the full control height; on box-shadow and backdrop-filter; and on a gradient whose colour stops differ. Flat paint written as a one-colour gradient, or two colours meeting at one exact position, is a rule, a frame, a grid, or a dot pattern, and it passes. When it fails, fix the source. A new exception belongs in the binding brief before it belongs in the test.

  • Never transition: all; list the exact properties that change.
  • Scale pressed buttons to 0.95–0.98 with transition: scale 200ms ease-out.
  • Cross-fade swapped icons on opacity and scale only: Entering scales 0.9→1 with opacity 0→1; exiting reverses. No blur — a filter repaints on every frame and filter is not on the animated-property list.
  • CSS transitions for interruptible interactions; keyframes for one-time sequences.
  • Disable transitions while switching between light and dark themes.
  • will-change only on properties that actually change: transform, opacity, filter. Add will-change: transform to elements that jitter 1–2px mid-animation (iOS Safari especially).
  • Stagger entrance animations by group or element.
  • Don't animate frequent interactions (e.g. color changes on list hover).

Typography#

  • Ship only .woff2 — never TTF or OTF on the web. Fonts are self-hosted in apps/web/public/fonts/ (spec §7.4).
  • Declare every bundled set; download only what renders. The design brief's "a deployed site loads only the faces its active preset and its picked options use" is a statement about downloads, and a downloaded face is not a declared one. An @font-face block is lazy by specification: The browser fetches the file only once a rendered element resolves to that family, so declaring a family nothing renders costs the CSS bytes of the block and not one request. scripts/lib/tokens.cjs therefore declares the whole of FONT_SETS plus the two fixed admin faces. It has to, because the type map is live: config/theme arrives over onSnapshot, a picked heading-face option remaps --font-heading, config/theme.fonts may name any bundled set outright, and the admin's page preview renders a candidate document in a frame. All four go through buildRuntimeThemeCss, which writes a --font-* stack — and a stack naming a family with no @font-face block renders the fallback. Declaring only the build-time preset's faces meant every one of those switches silently degraded to a system serif.
  • Set type from the eight-step fluid scale: text-nameplate, text-h1, text-h2, text-h3, text-lead, text-body, text-caption, text-folio (design brief §3.7). Each step carries its own line height and tracking. Never pick a default Tailwind size instead.
  • Name a font by its role, never by its family: font-heading, font-body, font-data, font-mono (design brief §3.2). There is no font-accent: PR2 removed the retired role. Zine's handwritten callout runs on the --callout-font component token, which is a component contract and not a fifth role.
  • font-variant-numeric: tabular-nums on all dynamic values: Timers, counters, prices, schedule columns.
  • Long-form text: 60–75 characters per line.
  • text-wrap: balance on headings, text-wrap: pretty on descriptions, neither on long-form. Reach for the text-balance and text-pretty utilities, never an inline style — a class is what the scanner, the reviewer, and a preset can all see.
  • overflow-wrap: break-word wherever long words, links, or IDs might overflow; white-space: nowrap for labels and badges.
  • Set -webkit-font-smoothing: antialiased and -moz-osx-font-smoothing: grayscale once, on the root.
  • Store copy in natural case; control presentation with text-transform. (This matches the email rule in spec §6 — content and presentation stay separate.)
  • Smart punctuation: Curly quotes, en dashes for ranges, em dashes for asides, the single ellipsis character.
  • text-underline-position: from-font with text-decoration-skip-ink: auto.
  • Truncated text stays fully accessible via tooltip or an expanded view.

Colors#

  • Every palette step must earn its place — no unused steps.
  • Use semantic tokens (--color-text-secondary), never primitives (blue-500). This is the same rule the hex sweep enforces in code: Colors resolve through config/theme custom properties (spec §7.2).
  • Name tokens by role, never by appearance or first use. Reserve accent for the brand color so primary doesn't mean both brand and body text.
  • A client sets one color, and the supporting colors are derived from it: The darker and lighter steps, the three rule weights and the control boundary (ink mixed into the ground at fixed shares), and the whole dark palette (authored from the light one, never inverted). Semantic colors come from the site style and are not part of the brand decision — a client's color does not get to redefine what "warning" means. A raw per-mode override is an EXCEPTIONAL control for a brand guide that names an exact value: Every use takes one token out of the derivation permanently, so it stops moving when the brand color moves. There is no second brand field: adminAccent is gone, and the admin's marker takes this same resolved colour rather than one of its own.
  • Don't borrow a token from another role; when a role changes color, mint a new token.
  • Measure contrast against the actual rendered background, not the page background.
  • Dark mode is its own palette, not light mode reversed. Every color token is defined under both :root[data-mode='light'] and :root[data-mode='dark'], and a token missing from either block is a bug (design brief §8.2).
  • The mode comes from data-mode on the root element. config/theme.mode sets the policy — light, dark, or system — and lib/modeRuntime.js writes the attribute. Never add a .dark class of your own.
  • The preset comes from data-theme on the root element, and its values are the six preset ids (design brief §3.4). A theme remaps the same custom properties. It never introduces a property name and never introduces a class. EventConfigProvider writes the attribute from config/theme.preset.
  • The motif set comes from data-motif-set on the root element (design brief §3.8). A custom property cannot rewrite the asset a second custom property points at, so the set switch is an attribute for the same reason the mode and the theme are. Render a motif as a mask-image painted with --color-ink-motif, or inline it as an SVG symbol reading currentColor — never as an <img>, never as a url() fill.
  • The admin has its own palette: The admin-* tokens (design brief §5.2). The admin obeys data-mode and ignores data-theme, so the admin-* blocks are emitted once per mode and never once per theme. There is no separate admin marker colour to set. The config/theme.adminAccent field is gone; --admin-client-accent appears in exactly one place — the mark beside the page title — and it takes the resolved brand colour for the mode, which is the same value the site paints. A mark is non-text user interface, so it holds 3:1 against --admin-ground-raised, the title band it sits on; below that it falls back to --admin-ink and the editor says so in words. Nothing is clamped, and the site keeps painting the client's colour either way. The action family and the rail grounds are the one part of the palette that moves per deployment: resolveAdminScheme (packages/shared/src/theme.cjs) rewrites them from the main brand colour, or from the house scheme config/theme.adminScheme names, stepping every value to its bar in both modes. A component reads the tokens and never the seed.
  • Every palette block matches a scoped element as well as :root (scripts/lib/tokens.cjs). That is what lets the page preview render the client's pages inside the admin: Its frame carries data-theme, data-mode, and data-motif-set (internal names — Vocabulary above) for the draft and resolves the whole set from its own declarations. The admin-* blocks stay root-only, because the admin never renders inside that frame.
  • Use the rule tokens for rules: --rule-hairline-*, --rule-strong-*, --rule-nameplate-* (design brief §3.7). A rule never borrows an ink step and never carries brand color.
  • A form control's boundary (input, select, textarea) uses --color-border-control, never --rule-hairline. WCAG 1.4.11 needs 3:1 against the ground it renders on; a rule is tuned for low-contrast structure and falls well short of that bar.
  • One theme mechanism, used consistently. This repo's choice: The config/theme-driven custom-property chain (spec §7.2) — not ad-hoc .dark classes per component.
  • Define gradient interpolation: in oklab for even brightness, in oklch for vivid midtones.

The admin surface#

The admin CMS is the seventh design surface and it is not a preset. The full spec is docs/plans/2026-08-27-admin-identity-story.md as amended by docs/plans/2026-09-10-admin-editorial-desk.md; these are the rules a review checks.

  • It reads admin-* tokens only. A brand-* utility, a font-heading, or a rounded-brand inside the admin shell is a bug: The room would restyle itself every time a client changed their palette. The public site's own controls live in components/forms/publicForm.jsx and the admin's in admin/components/formControls.jsx; neither imports the other.
  • Navigation is the rail: A grouped standing list of words on the dark rail ground down the leading edge — content, program, registration and tickets, communications, operations, settings — with group heads as small uppercase folios. The Overview stands above the six groups, alone and with no folio, because it is the page the admin opens on and it reports on every group below it. Each group head is a button with aria-expanded and aria-controls that folds its list. The group that holds the current page opens on every navigation, and a closed group that holds it names the current page under its head. On a wide screen Hide menu narrows the rail to the job mark and a Show menu button: one control, which keeps the focus, with aria-expanded and aria-controls. The groups' open state and the hidden rail are remembered per browser (admin/layoutPreference.js), and nothing animates. On a phone the event mark and a native Menu disclosure form the top band; the disclosure names the current section and opens the same folding groups and account controls. No icon rail, no collapse to glyphs, no counts in bubbles. The groups and items are one list (DOCKET in admin/AdminLayout.jsx); a new section is one line in its group. The current item carries four signals, never color alone: The leading-edge marker, the bold weight, the filled action-blue ground, and aria-current="page". The client's mark sits at the head of the rail on a small white tile.
  • A page header is the title band: The page's name at the title step on the raised ground, running to the edges of the work surface, with the mark in the client's colour beside the name, the record's state badge and identifiers beside the name or under it — never above it — and the page's own actions at the trailing end. The band holds the top of the viewport while the page scrolls from the 640px breakpoint upward. On a phone it scrolls with the work so its wrapped actions do not cover the form. The description is a paragraph under the band.
  • Three state words, everywhere: Draft, Live, and Live with unpublished changes (admin/recordState.js), each set as a word in a badge whose tinted ground and ink agree with it (StatusBadge; seven tones, never a pill). A record with a draft also sits on --admin-ground-proof (an internal token name — Vocabulary above), and a successful publish resolves that tint to the base ground over 160ms on opacity — instantly under prefers-reduced-motion. The tint is the second signal; the word is always present.
  • One action colour. --admin-action carries the filled primary button, the current rail item, the link ink, and the focus ring. It is authored navy and at runtime follows the main brand colour, worked to its bars, unless Admin colours names a house scheme; it is never the raw client accent, and a component never reads the brand colour to paint an admin control. One filled action per surface; the secondary control is white inside a strong rule, and the destructive control sits on the alarm ground and fills only under the pointer.
  • Controls hold one height. A field and a button are --admin-control-height (2.75rem) tall on every pointer and set at the base step of the admin scale. Density comes from the gutters, never from the targets. A quiet in-row link may drop to the 24px minimum on a pointer surface and holds 44px on touch.
  • A bulk action sits in a row above its table: A role="status" count of what is selected ("No files selected", "3 files selected"), the actions, and one Clear selection control. A row that can be selected carries a labelled checkbox with a 44px target; the tick is the first signal, and the soft action ground on the row is the second. The head checkbox selects every row shown and turns indeterminate part way. A change to a filter clears the selection, so an action never reaches a row that is not on screen. The action is never disabled: With nothing or too much selected it says why, and while it runs it carries aria-busy and aria-disabled and ignores a press. A sortable column head holds a button that states the order in words ("A to Z", "Newest first"), and the sorted head carries aria-sort.
  • A destructive action stands still and states what it costs (DestructiveConfirm): The alarm ground inside the alarm rule, a sentence naming what is removed and whether anything survives, and a confirm button that repeats the consequence at normal size. Nothing animates in a destructive moment.
  • Two faces, fixed, on the admin's own scale: Source Sans 3 carries everything a person reads as language, across its 400 to 700 weights, on the six-step --admin-text-* scale (13px to a fluid 26–32px title, base 15px). IBM Plex Mono with tabular figures carries everything the machine owns. If an operator would ever copy it, paste it, or compare it character by character, it is set in the mono.
  • A bar between two panes is a window splitter (admin/components/PaneSplitter.jsx): A focusable role="separator" with aria-orientation="vertical", aria-valuenow, aria-valuemin, aria-valuemax, a value in words, and aria-controls on the leading pane. The Left and Right arrow keys move it, Shift moves it further, Home and End go to the limits, and Enter or a double-click resets it. A visible reset button sits beside the panes. The bar is a 24px target, and its grip widens and fills on focus, so the state is not colour alone. Limits keep each pane usable, and below the side-by-side width the panes stack and the bar is gone. It changes only the room each pane has: never a frame's CSS width, a draft, or a published value.
  • Unpublished changes have one count. The banner above the stone and the Unpublished changes page read one source (admin/PendingChangesContext.jsx): The dirty drafts of every publishable collection, never the publish run rows. The banner is one sentence and one link, set in --admin-ink on --admin-ground-proof above the stone, with no live role, no dismiss control, and never a count on the rail.
  • The tour is a panel, not an overlay. The editor tour (admin/components/AdminTour.jsx) is an aside at the head of the stone, on the panel ground inside a hairline rule. It has no scrim, no motion, and no anchored overlay, so the admin stays usable behind it. Its steps come from the docket the tier can reach. Each step's heading takes the focus, and the step line sits under the heading, never above it. Back, Next (Finish tour on the last step), End tour and the Escape key are the whole keyboard path. Ending it returns the focus to Take the tour when the rail is open, to Show menu when the wide rail is hidden, or to the visible Menu disclosure when the phone rail is closed.
  • Stored mail HTML renders only in the preview frame. Stored email HTML reaches the screen only through admin/emailPreview.js buildPreviewDoc, as the srcdoc of an <iframe> with an empty sandbox. That document opens with a content policy that blocks script and every fetch, and its links show as their words only. The plain-text body renders only as a React text node. Neither one goes through innerHTML, and the frame never takes a sandbox token.
  • A date and time staff enter is on the event's clock. EventDateTimeField (admin/components/EventDateTimeField.jsx) is a fieldset: The legend names the instant, a native Date and Time input sit under it, the event's zone is named in words, and a status line states the result with the zone and the UTC offset for the chosen date. It stores the RFC3339 instant the contract already takes (lib/eventDateTime.js). It never moves a time across a clock change: A skipped time is refused with the reason, and a repeated time asks which one, with two radios. Staff never type an offset or a UTC time.
  • The event's time zone is chosen, not typed. TimeZoneField (admin/components/TimeZoneField.jsx) is the WAI-ARIA editable combobox with list autocomplete. Staff search by city, region, the zone's usual name, or a UTC offset, and each option shows the city, the region, the zone's name, and its current offset (lib/timeZoneOptions.js, from Intl.supportedValuesOf('timeZone') with a fixed fallback list). Focus stays in the field: Down and Up open and move the list, Page Down and Page Up move ten, Enter chooses, and Escape closes and puts the saved zone back. Leaving the field without a choice changes nothing. The saved zone is marked Current in words. The list sits in the flow under the field, so it wraps at any width and zoom. The stored value is still the IANA name, stated once under the field. A line under it names the offset on the event days, and names both offsets when the clocks change during the event.
  • A list of records has one add control and a remove control inside each record. The add control sits in the panel heading as a plus and a word (+ Add day). Each record's heading carries its own × Remove control, whose name says which record it removes ("Remove day 2, Workshops"), so × never reaches an unrelated record. Both keep the full control height (admin/components/listItemControls.jsx). Focus follows the change: The new record's first field, or the remove control now in a removed record's place. A record that sessions point at says so under its fields, and its remove control opens a stated consequence first (RemovalWarning), with Remove … anyway and Keep …. Nothing goes until the form is saved. Until every list that can point at a record has loaded, and again while one of them is retrying after an error, the add and remove controls stay focusable but unavailable (aria-disabled), and the panel says why. A warning's consequence is the focused group's description, so it is read as focus lands. A record with several fields, such as an event day, stays a row; it never becomes a chip.
  • A stored id is not a field. A new event day takes the next free day-N that no day and no live or draft session uses, when it is added. The id stays with the row through a refused save and a retry, and a saved day keeps its id for good (admin/components/EventDaysEditor.jsx).
  • Chips. A compact chip is allowed for a list of short single values, such as tags. It is one value and its own remove button, named for the value ("Remove Practice", never a bare ×), at the full control height, so the shape never shrinks the target. It shows no state: selected, current, and error stay words and rules. A chip may take the full round. The file that draws it is listed in CHIP_FILES in lib/motionContract.test.js, with a test of its own beside it, and the contract test holds it to these rules. Decorative pills, status pills, and pill tabs stay refused.
  • A version list is newest first. Each version names its number, its time on the event's clock with the zone, and the account that published it, then a what-changed table (field, before, after) that breaks a long word and never scrolls sideways. A stored value is text, never markup: Rich text shows its tags, and a stored URL is not a link.
  • Elevation is the step from the canvas to the paper. A panel is a white surface inside a hairline rule with the panel radius; no --admin-shadow-* family ships. Three radii: A control, a panel, and the small one a badge takes. A full round belongs to a listed chip only.

The project site#

docs/ is the GitHub Pages surface — the landing page and every generated documentation page. It is not an apps/web surface, but it is Event Runner's own front door, so it is held to the same design language and reviewed against this document.

  • It mints no token. docs/tokens.css is generated by scripts/build-pages.cjs from design/tokens/, through the same generator that writes apps/web/src/generated/theme.css, narrowed to the tokens the site uses. build-pages --check fails on a stale copy, so the site cannot drift from the product's palette. docs/styles.css is the handwritten half: The three faces the site serves from docs/fonts/, the device contracts it composes, and its measure. Never hand-declare a name the generated file owns.
  • The mark is four strokes: A stem, then the three rule weights the system ships. It is inline SVG taking the masthead's own ink, and it sits beside the wordmark, never behind it. docs/favicon.svg carries the same geometry in ink, because a favicon has no page color to inherit, and scripts/dev/build-social-cards.mjs draws the two social cards from it. No stacked cards, no tinted panels, no drawing behind the name.
  • A boundary states its own weight, and only one device draws it. A major boundary opens on the strong rule and takes more air above it than anything else on the page. A secondary one is the heading with a hairline running out beside it. A subsection heading takes no rule at all — the heading face, the step, and the space are the signal. Both devices stay in the vocabulary; what is banned is stacking them, because a strong rule with a second rule two lines under it is what turns a page of boundaries into ruling.
  • Where the layout already separates, nothing is drawn. A row of entries takes one hairline across the row and lets the column gap do the rest; the hairline returns between the items only when the row stacks and the gap is gone.
  • The folio floor is caption size. --text-folio bottoms out at 11px, which suits a running head a reader glances past. Every folio this site renders is a functional label somebody has to read — the docket's group heads, a table's column heads, the status words, the pager's direction labels — so --folio-size resolves to --text-caption here. The tracking, the leading, and the case stay the folio's.
  • No page scrolls sideways, at 320px or at 200% zoom on a 1440px screen. Wide content — tables, code blocks — scrolls inside its own container, and every text container breaks a long word. Inside a flex row, breaking the word is not enough: break-words draws the break but leaves the row's minimum at the whole word, so the row still refuses to fit its box. Use .wrap-anywhere (index.css), which lowers the minimum as well and still breaks only a word that cannot fit.
  • Paper has no dark mode here either. Print a documentation page from a dark screen and you get the light edition: docs/tokens.css re-points the ink, the ground, and the rules at the light values under print media, and no print rule asks what mode the screen was in. What prints is the document — the masthead, the breadcrumbs, the contents, the article. The docket, the site nav, and the previous/next pager are ways of reaching another page, and paper cannot follow a link, so they leave. Nothing scrolls on paper, so a table and a code block let their content out of the box instead of clipping it.
  • Comments state the local contract, not the philosophy. The rules live in this document; a comment in docs/styles.css or a generator says what the code next to it must hold to and why that specific choice, and points here for the rest.

Accessibility#

  • Semantically correct native elements: button for buttons, a for links.
  • Style :focus-visible; never remove outlines without replacing them.
  • Only tabindex="0" and tabindex="-1" — positive values break tab order.
  • Icon-only buttons get a descriptive aria-label; never aria-hidden="true" on a focusable element.
  • Alt text describes purpose ("Search", not "magnifying glass"); decorative images get alt="".
  • Every input has a real label, correct type, and inputmode. (The OTP input is the first test of this: inputmode="numeric", autocomplete="one-time-code".)
  • Never block paste — especially not on the OTP code field.
  • Tooltips on disabled controls don't open via keyboard or touch; use visible text or aria-disabled="true".
  • Keep submit buttons enabled until the request starts; validate on submit with aria-invalid="true" and move focus to the error.
  • Hit areas: 24×24px minimum, 44×44px on touch, 40×40px desktop where possible.
  • pointer-events: none on decorative elements (glows, texture overlays).
  • Wrap hover styles in @media (hover: hover); wrap motion in @media (prefers-reduced-motion: no-preference).
  • role="status" for routine updates, role="alert" for urgent errors only.
  • Never color alone for a status — pair it with an icon, label, or underline.
  • Skip-to-content link is the first focusable element; anchored headings get scroll-margin-top.

Layout#

The frame keeps a small side margin. The reading column fills that frame.

  • The stage (--stage-max) is the frame. The header, the navigation, the schedule grid, the four directories, the logo wall, the footer, and every section head run to it. It is a maximum inline size of 95 percent, centered, with a gutter from the spacing scale. It is never a fixed width.
  • The measure (--measure-text) is running text. Paragraphs, lists, rich text, descriptions and answers fill the same content box as the section rule. max-w-prose resolves to this token too.
  • The stage split uses one full-width track without an empty margin column. Folios, including margin-style folios, stay inside the stage at the end of the rule.
  • The frame token defaults to 95% and the measure token defaults to 100%. They are declared in the page contract (design/tokens/components.json) over the tier-2 stage family. At viewports of 1024px and wider, the stage content box after its gutter uses at least 90 percent of the viewport.
  • The arrangement variant maps onto the stage. grid gives a section the stage's columns; list sets it on the measure. The section head runs to the stage either way, because a section boundary is the width of the page it opens.
  • The stage gutter holds every device drawn outside it. The Atlas coordinate mark sits --space-sm plus --space-xs outside the title block, so the gutter is --space-lg. A narrower gutter pushes the mark past the viewport and the whole page scrolls sideways.
  • The gap between groups is at least twice the gap inside one: 8px within, 16px+ between. Use the named spacing steps (--space-3xs through --space-3xl), which are built to that rule: xs pairs with md, sm with lg, md with xl.
  • Logical properties (margin-inline-start, padding-inline-end), not directional values.
  • No fixed widths or heights on text containers.
  • A system page states its own shape in cmsPages.layout: header (nameplate or nameplate-compact — there is no headerless page), arrangement, and density. Nothing migrates: A page that states nothing keeps the shape it already had. That is why two variants read what the page STATES rather than what it resolves to — an unstated header keeps the shell's own rule (the home page opens with the full masthead, every other page takes the running header), and an unstated density keeps the active preset's, which the shell mirrors onto the document element.
  • The operator does not assemble that shape out of parts. cmsPages.template names one of six tasks — Standard page, Feature first, Directory with introduction, Long read, Schedule, Landing page — and each resolves to a bundle of the three values above (apps/web/src/lib/pageTemplates.js, mirrored in functions/src/cms/pageTemplates.cjs and pinned by pageDoc.test.js). The layout object stays the storage format; the template records which task was meant. A template is never inferred from the values: A page whose three variants happen to match one has not chosen it. The individual axes stay reachable behind the editor's disclosure, and moving one clears the template, because a page that no longer matches its template should not go on claiming it.
  • Where the navigation sits is a SITE setting with a page-level exception. The site's answer is config/theme.navPlacement (top or side) and it covers every page that says nothing: A reader who meets a top nav on the home page and a rail on the schedule by accident has been handed two sites. A page may overrule it with layout.navPlacement, offered in the page editor's Advanced disclosure, and the shell reads the page FIRST, then the site, then the default — an exception the site setting could overrule would not be an exception. Reading the page first is also what keeps deployments that set it per page, before the site setting existed, rendering what they rendered. side moves the same navigation to the leading edge at wide viewports: Same landmark, same items, same place in the document, and below lg it is the top nav again.
  • density is a set of token remaps, never a set of raw values. Each step maps the page and session contracts onto the spacing scale, and the block nearest an element wins — which is how a page overrides the preset for its own subtree and nowhere else.
  • Sections carry a slot. The order down a system page is nameplate, above sections, the page's own core content, main sections, below sections. A section with no slot is main, which is where it has always rendered. A custom page has no core content, so it ignores the slot.
  • The slot keys are storage, not words the operator reads. The editor offers two insertion points — "Before the main feature" (above) and "After the main feature" (main) — because that is the choice being made: Where a section goes relative to the page's built-in feature. below is a third stored position with no third name; it renders after every main, the control reads it as "After the main feature", and a section stored that way keeps it unless the operator moves the control.

Rejected patterns#

The list the design brief settles in §2.4, recorded here so a review has one place to look. This is holistic guidance, not a checklist to game: A reviewer may reject a pattern this list does not name, and may accept a listed pattern only where the brief grants an exception.

  • Eyebrow furniture. Nothing sits directly above a heading. See Editorial devices above for the full rule and its two non-exceptions.
  • Decorative gradients. No gradient blobs, no purple-heavy gradients, no gradient as a background event.
  • Glassmorphism. No frosted panels, no blurred translucent overlays used as style.
  • Zero-padded decorative numbers. No 01 / 02 / 03 motifs.
  • Headline tricks. No italicized or underlined single word in a headline.
  • Carousels. No sliding text carousels, no auto-advancing content.
  • The tan-canvas look. No warm-tan canvas paired with an oversized primary action.
  • Colored card edges. No arbitrary colored left edge on a card, button, or block. No decorative colored rule across the top of a card.
  • Tiny low-contrast subtext. Body-adjacent text meets the contrast bar in both modes.
  • Pulse dots. No glowing dots, no pulsing status dots.
  • Fake 3D stacking. No box-behind-box layering, no pointless depth.
  • Generic SaaS framing. No startup marketing voice, no self-justifying subheads, no insider-facing copy a visitor does not need.
  • Pill-shaped everything. No fully rounded decoration across the interface. A listed compact chip is a control, not decoration (see Chips).
  • Default-typeface tells. Never ship Inter alone, Space Grotesk, or Geist Mono as the design default. Never use the stereotypical AI-site serif treatment.
  • Dead calls to action. Every action has a stated workflow behind it.
  • Sectionless scroll. Every page has named, meaningful sections.
  • Pointless forms. No newsletter slot without a real purpose and a real backend.
  • Inconsistent dark mode. A half-applied mode is a bug, not a polish item.
  • Semantic color conflicts. Never use a color against its familiar meaning, and never use color alone for status.
  • Corner errors. Never let a rounded-box stroke shrink at the corner — apply the concentric radius rule.
  • Cursor and reveal effects. No oversized cursor animations, no reveal-on-scroll.
  • Repetition. No verbose sections, no repeated promotional copy.

The brief names exactly two exceptions, both narrow: A bento grid that passes all five tests in §2.4, and the Zine preset's off-register stamp. Nothing else gets one, and a preset may not grant itself one.

Writing#

  • Button labels start with verbs: "Save draft", "Delete project".
  • Confirmation buttons repeat the consequence: "Delete project" / "Cancel" — never "Yes" / "No".
  • One term per flow: "Continue" or "Next", not both.
  • Links describe their destination: "Read the schedule", never "Click here".
  • Consistent capitalization everywhere; sentence case is the default.
  • Capitalize the first word after a colon, wherever the colon falls — after a standing label ("Risk: What could break", "Illustrations: None") and inside a sentence alike ("the day reads twice: A grid and a list"). Code spans, paths, URLs, and token names keep their own case, so a colon that introduces one leaves it exactly as it is: text-wrap: balance is written the way the property is written.
  • Toggles are labeled by their enabled state: "Send read receipts", not "Disable read receipts".
  • Empty states orient the reader and offer exactly one next action.
  • Address readers as "you", not "the user".

Event hero and demo preview#

The September 11 update in the binding design brief defines EventHero as the shared home and schedule hero. Home owns the h1 and all configured hero actions. Schedule uses a paragraph for the event identity before its own h1. Schedule uses the compact interior treatment at every width: It keeps the event name, dateline, meaningful image text, and caption, removes the repeated tagline, and shortens the art crop so the programme starts sooner. The component preserves safe image URLs, required alt text, focal points, and captions. From 640px up, the copy has its own column and the artwork fills the rest of the hero, so no live text sits on the picture. The column is never narrower than --hero-copy-min-inline-size, so the copy wins when a preset asks for a wide photograph. The title size is capped by --hero-title-fit, a share of the column's width, so a long word fits its line. A surface-color veil is allowed only over the leading edge of the artwork, to soften it; phone layouts place artwork below the copy. The hero is a coloured band, so its copy keeps the --hero-inset-inline inset from the band's edge at every width, with or without artwork. Dense rows such as session type badges keep their own tighter padding on purpose. Each preset owns its composition through component tokens.

Demo preview hides the disclosure and style controls until Exit preview or Escape. It preserves theme, mode, and route. Client builds have no preview controls. Uploaded client marks remain supported, with no fallback square for an absent mark.

This page is generated from docs/interface-guidelines.md. Edit the Markdown, not the HTML.