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.cjsturns that JSON into the custom propertiesapps/web/src/generated/theme.csscarries, andtailwind.config.jsmaps 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/themeor CMS content. - The reference is the other half.
design-reference.mddocuments 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.mdis 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 see | What the code calls it |
|---|---|
| Site style | preset — config/theme.preset, data-theme |
| Main brand colour | config/theme.brandColor, and the steps derived from it |
| Header style | the nameplate option group — optionPicks.nameplate; config/theme.header; cmsPages.layout.header; resolveHeader |
| Schedule style | the component option group — optionPicks.component |
| Page template | cmsPages.template, resolved by pageTemplates.js |
| Illustrations | motif set — config/theme.motifSet, data-motif-set |
| Page preview | the proof — ThemeProof, --admin-ground-proof |
| Advanced colour settings | raw per-mode token override — config/theme.tokens |
| Admin colours | config/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.
| Device | Component | What it is | The rule |
|---|---|---|---|
| Site header | Header | The 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 nameplate | Nameplate | The 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 image | LeadImage | One 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. |
| Folio | Folio | A 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. |
| Rule | Rule | A 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 boundary | SectionHead | One 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 anatomy | StatBlock | A 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 schedule | ScheduleGrid | A 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 issue | the .back-issue treatment | The 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 view | SchedulePrint | The 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.
| Treatment | What it draws |
|---|---|
standard | The event name at heading size, the dates and place beside it, the navigation under a hairline. The base. |
masthead | A compact event identity beside the navigation. |
compact | The nameplate device at running-header size: Short name and dates on one baseline. |
minimal | The 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>. mastheadplaces 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.textureopts 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
1pxoutline 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.
| State | Public site | Admin |
|---|---|---|
| Rest | The style's own ink and ground | The desk's ink and ground |
| Hover | The 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 underline | The same device, with the desk's tints |
| Focus-visible | The 3px ring in the accent, outside the element, never removed | The 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-share | The pressed action ground; no scale |
| Selected or current | Weight, 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 link | The rail's four signals; a filled row marker elsewhere |
| Disabled | disabled 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 control | The same |
| Busy | aria-busy="true" and a stated word inside the control ("Saving…"); never a spinner, never a shimmer | The same, and the result stays in place after |
| Error | aria-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 |
| Success | A 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 once | The same |
| Empty | A sentence that names what was searched or filtered, plus one action | The 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/.
| Control | One job | The rule |
|---|---|---|
Switch | A setting that is on or off | role="switch", aria-checked, the state as a word beside the label. The label names the ENABLED state. No sliding knob. |
SegmentedControl | One choice from a short set | A 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. |
Tabs | One panel from a short set | The 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, Radio | Pick any; pick one | A 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. |
SearchField | Narrow a list by words | A labelled type="search" input, a stated clear control, and the result count in a role="status" line the caller feeds. |
SortControl | Order a list | A labelled select. The label names what is being ordered. |
FilterGroup | Narrow a list by a facet | A <fieldset> with a <legend>, the active count in the legend, and one clear control for the whole group. |
Dropzone | Send a file | A 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. |
Repeater | A short list of rows a person adds and removes | A <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
transformandopacityonly, and useease-out. Theduration-fast/duration-base/duration-slowandease-motionutilities 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-pressedis 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 — underprefers-reduced-motionthe 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
filteris 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-changeonly on properties that actually change:transform,opacity,filter. Addwill-change: transformto 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 inapps/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-faceblock 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.cjstherefore declares the whole ofFONT_SETSplus the two fixed admin faces. It has to, because the type map is live:config/themearrives overonSnapshot, a picked heading-face option remaps--font-heading,config/theme.fontsmay name any bundled set outright, and the admin's page preview renders a candidate document in a frame. All four go throughbuildRuntimeThemeCss, which writes a--font-*stack — and a stack naming a family with no@font-faceblock 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 nofont-accent: PR2 removed the retired role. Zine's handwritten callout runs on the--callout-fontcomponent token, which is a component contract and not a fifth role. font-variant-numeric: tabular-numson all dynamic values: Timers, counters, prices, schedule columns.- Long-form text: 60–75 characters per line.
text-wrap: balanceon headings,text-wrap: prettyon descriptions, neither on long-form. Reach for thetext-balanceandtext-prettyutilities, never an inlinestyle— a class is what the scanner, the reviewer, and a preset can all see.overflow-wrap: break-wordwherever long words, links, or IDs might overflow;white-space: nowrapfor labels and badges.- Set
-webkit-font-smoothing: antialiasedand-moz-osx-font-smoothing: grayscaleonce, 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-fontwithtext-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 throughconfig/themecustom properties (spec §7.2). - Name tokens by role, never by appearance or first use. Reserve
accentfor the brand color soprimarydoesn'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:
adminAccentis 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-modeon the root element.config/theme.modesets the policy —light,dark, orsystem— andlib/modeRuntime.jswrites the attribute. Never add a.darkclass of your own. - The preset comes from
data-themeon 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.EventConfigProviderwrites the attribute fromconfig/theme.preset. - The motif set comes from
data-motif-seton 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 amask-imagepainted with--color-ink-motif, or inline it as an SVG symbol readingcurrentColor— never as an<img>, never as aurl()fill. - The admin has its own palette: The
admin-*tokens (design brief §5.2). The admin obeysdata-modeand ignoresdata-theme, so theadmin-*blocks are emitted once per mode and never once per theme. There is no separate admin marker colour to set. Theconfig/theme.adminAccentfield is gone;--admin-client-accentappears 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-inkand 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 schemeconfig/theme.adminSchemenames, 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 carriesdata-theme,data-mode, anddata-motif-set(internal names — Vocabulary above) for the draft and resolves the whole set from its own declarations. Theadmin-*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.darkclasses per component. - Define gradient interpolation:
in oklabfor even brightness,in oklchfor 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. Abrand-*utility, afont-heading, or arounded-brandinside 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 incomponents/forms/publicForm.jsxand the admin's inadmin/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-expandedandaria-controlsthat 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, witharia-expandedandaria-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 (DOCKETinadmin/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, andaria-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, andLive 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 onopacity— instantly underprefers-reduced-motion. The tint is the second signal; the word is always present. - One action colour.
--admin-actioncarries 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 neverdisabled: With nothing or too much selected it says why, and while it runs it carriesaria-busyandaria-disabledand 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 carriesaria-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 focusablerole="separator"witharia-orientation="vertical",aria-valuenow,aria-valuemin,aria-valuemax, a value in words, andaria-controlson 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-inkon--admin-ground-proofabove 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 anasideat 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.jsbuildPreviewDoc, as thesrcdocof an<iframe>with an emptysandbox. 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 throughinnerHTML, 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, fromIntl.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-Nthat 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_FILESinlib/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.cssis generated byscripts/build-pages.cjsfromdesign/tokens/, through the same generator that writesapps/web/src/generated/theme.css, narrowed to the tokens the site uses.build-pages --checkfails on a stale copy, so the site cannot drift from the product's palette.docs/styles.cssis the handwritten half: The three faces the site serves fromdocs/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.svgcarries the same geometry in ink, because a favicon has no page color to inherit, andscripts/dev/build-social-cards.mjsdraws 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-foliobottoms 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-sizeresolves to--text-captionhere. 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-wordsdraws 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.cssre-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.cssor 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:
buttonfor buttons,afor links. - Style
:focus-visible; never remove outlines without replacing them. - Only
tabindex="0"andtabindex="-1"— positive values break tab order. - Icon-only buttons get a descriptive
aria-label; neveraria-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, andinputmode. (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: noneon 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-proseresolves 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 to100%. They are declared in thepagecontract (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.
gridgives a section the stage's columns;listsets 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-smplus--space-xsoutside 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-3xsthrough--space-3xl), which are built to that rule:xspairs withmd,smwithlg,mdwithxl. - 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(nameplateornameplate-compact— there is no headerless page),arrangement, anddensity. 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 unstatedheaderkeeps the shell's own rule (the home page opens with the full masthead, every other page takes the running header), and an unstateddensitykeeps the active preset's, which the shell mirrors onto the document element. - The operator does not assemble that shape out of parts.
cmsPages.templatenames 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 infunctions/src/cms/pageTemplates.cjsand pinned bypageDoc.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(toporside) 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 withlayout.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.sidemoves the same navigation to the leading edge at wide viewports: Same landmark, same items, same place in the document, and belowlgit is the top nav again. densityis 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,abovesections, the page's own core content,mainsections,belowsections. A section with no slot ismain, 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.belowis a third stored position with no third name; it renders after everymain, 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/03motifs. - 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: balanceis 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.