Skip to content

Style Guide

Version 3.1.0 — Status: Ratified (2026-09-20). Supersedes 2.2.0, archived verbatim at style-guide-2.2.0.md (and 2.1.0 at style-guide-2.1.0.md). Full history in Changelog.

3.1.0 ratifies the Navigation refactor, part two — the mobile play shell (v0.98.0): on a touch device the rail is a drawer behind a bottom bar's Menu, and /play is three tabs on that bar, each owning the screen, with up to two sheets per tab. A minor bump: a new section, no reversed rule. See Mobile. The earliest sketch of the interface, from before any of this was ratified, is kept at template.md — it describes the left-side icon strip that 2.1.0 retired and 3.0.0, in a wider form, brought back; it is useful only for reading old decisions against what was in force at the time. How the guide is implemented — tokens, skins, and the cascade behind them — is styling-architecture.md.

3.0.0 ratifies the Navigation refactor, part one (v0.97.0): the global top bar goes, a left rail wide enough for labels returns and carries the second level beneath the active item, the page header's CTA slot carries Share, and /play gets a three-column play bar with the map's name in a nub. It is a major bump because it reverses 2.1.0's central navigation rule. See Navigation.

2.1.0 ratified the Global Navigation & Routing Overhaul: a global top navigation bar as the primary nav (retiring SideNav's desktop L1 role), campaign-scoped character routes, /new and /delete route patterns, URL-driven /help and /library subroutes, and the Purple Cat VTT Archive (.cat) export format. It is tracked task-by-task in 2-global-nav-and-routing-overhaul/todo.md.

2.0.0 ratified the current, client-side design language: skins and --vtt-* tokens, semantic colors, the async-action patterns shipped in v0.35.0, and single-step, client-side Undo/Redo. The deferred "robust server-side state" work — arbitrary state restoration and Undo/Redo across every autosaved form — remains out of scope and is now targeted for a future 2.2.0 (see Undo / Redo and Deferred → Server-Side Undo/Redo in CHANGELOG.md).

App-wide implementation of 2.0.0 is tracked task-by-task in todo/1-ui-overhaul.md. This document is the design reference; that file holds the actionable, one-commit-each tasks.

Mini Style Guide

This is the at-a-glance summary. Each item is stated verbosely — with the as-implemented pattern and open questions — in the matching Full Style Guide section below; keep the two in sync.

  • Modals:
    • If you can't see the entire modal on a small screen with room to spare, it doesn't get to be a modal.
  • Call to Action buttons
    • Should never be invisible or require scrolling to know they exist
    • Primary action button in upper right, max 1, secondary actions on a caret menu
    • Secondary action or cancel button left of primary button
    • Share button left of secondary/cancel, if present
  • Async actions
    • Every async action must provide immediate in-browser feedback as soon as the operation starts (in-button spinner, etc).
    • When duration is expected to be more than a second or two, give the user an estimate or progress bar.
    • When an async action fails or times out, show a notification toast, but also show an explanation of the current status near the UI element that instigated the action
  • Buttons
    • All buttons must either have a text label or a tooltip. No icon-only buttons without tooltips.
  • Layers (z-index, global stacking context, top to bottom)
    • Notifications (toasts)
    • Tooltips inside modals
    • Modals
    • Modal backdrop
    • Top and side menus and their submenus
    • Context Sidebars
    • Palette and Sub-palettes
    • The map / other main UI

Full Style Guide

Theming & Skins

The app ships three visual skins, selected via document.documentElement.dataset.skin and persisted in skinStore (apps/web/src/stores/skinStore.ts — SkinName union + AVAILABLE_SKINS):

Skin Family Scheme Feel
retro (default) retro dark 70s/80s tabletop — warm browns, parchment, serif (Cinzel/Crimson Text)
modern-dark modern dark cool indigo accents, sans-serif
modern-light modern light cool accents, light surfaces

Styling flows through two coordinated layers — understand both before styling anything:

  1. Mantine theme (apps/web/src/theme/mantineTheme.ts = retroTheme; modernTheme.ts = the two modern skins). Defines primaryColor, named color tuples (saddleBrown, walnut, sienna, oxblood, felt), and component defaultProps/styles (e.g. retro buttons are uppercase Cinzel, radius: 'sm'; modals default to centered, radius: 'md').
  2. CSS custom-property tokens (apps/web/src/styles/tokens/_base.css = neutral defaults; then retro.css / modern-dark.css / modern-light.css override on :root[data-skin="…"]). Naming: --vtt-<category>-<property> where category ∈ bg, text, color, border, font, shadow, radius, z.

Rule: style custom components from --vtt-* tokens (skin-adaptive); let Mantine components inherit the theme. Never hardcode a skin-specific value like --mantine-color-saddleBrown-* (see Buttons). Raw hex or a one-off Mantine named color is a last resort — if you reach for one often, that's a missing token, not a license.

Typography casing (2.0.0 change). The retro theme previously forced text-transform: uppercase on Buttons, Modal titles, Accordion controls, Tabs, and Badges (five styles blocks in mantineTheme.ts). 2.0.0 removes uppercasing: render every label in its authored case. Keep the Cinzel face and letter-spacing: 0.05em, drop the text-transform. No component may set text-transform: uppercase in theme styles or component CSS going forward — author labels in the case you want displayed.

Color & semantic tokens

Skin-aware semantic tokens (from the token CSS files):

Token Retro Modern (base/dark) Meaning
--vtt-color-primary #8b4513 saddle brown #6366f1 indigo brand / primary action
--vtt-color-accent #a0522d sienna #f59e0b amber accent / emphasis (the "orange-family" color)
--vtt-color-secondary #5c4a3a walnut #64748b slate secondary surfaces
--vtt-color-danger #8b1a1a oxblood #ef4444 red destructive
--vtt-color-success #2d8b47 felt #22c55e green success / positive

Mantine primaryColor is saddleBrown (shade 6) in retro and indigo in modern, so a bare <Button> already renders the per-skin brand color without any extra work. The skin-aware CSS equivalent for custom components is --vtt-color-primary.

Design requirements:

  • Always use semantic colors such as "success" or "accent", not color descriptions like "green" or "red". Use CSS vars to map these semantic colors to the appropriate skin-aware tokens (--vtt-color-success, --vtt-color-accent, etc.) rather than hard-coding color values.

Pages

Every route but /play uses a standard Page layout, which features:

  • A top-left header with the name of the entity or the name of the entity list (e.g. 'My Characters')
  • A breadcrumb beneath the header that shows the user's current location within the entity hierarchy, allowing for easy navigation back to parent entities or lists.
  • An optional primary Call to Action button in the upper right
  • An optional secondary Call to Action button left of the primary
  • An optional Share icon button left of the secondary

Established pattern. The header is <PageHeader> (apps/web/src/components/UI/PageHeader): title, breadcrumb, and the CTA row in its fixed order — share, secondary, primary, menu. The breadcrumb is the reusable <PageBreadcrumbs> (apps/web/src/components/UI/PageBreadcrumbs.tsx) — it derives the trail from the URL, resolves entity names, and returns null on /play/*. "Standard breadcrumb" means use this component, don't hand-roll a trail — and it renders in the page header only (3.0.0): the top bar's copy is gone, and noDoubleBreadcrumbs.guard.test.ts fails a route that renders two.

Share (3.0.0). The share slot is the one place a page's Share lives. It shows the verb (IconShare2, "Share") to whoever may share the entity — the permissions editor's own share — and the noun (IconShare, "Who has access") to everyone else who can see the page, opening the same modal with the editor read-only. Never absent for a viewer, never disabled: CampaignShareButton and SheetShareButton are the two speakers.

Pages come in one of six flavors:

  • List page: typically /${entity name}s — displays a list of entities of a particular type as cards with a common layout and grouping, sort, filter, and pagination controls, with the ability to navigate to individual entity pages or perform bulk actions. The primary CTA is usually for creating a new entity of that type. Grouping, sorting, and filtering UI should be encapsulated in a common component.
  • Detail page: typically /${entity name}/${id or slug} - displays the details of a single entity, with sections for different types of information and actions related to that entity. Use cases of this page:
    • Most commonly, this page displays detailed information, with the option to edit (see below)
    • Another use case is displaying related entities or linked information, providing a comprehensive view of the entity in context without navigating away from the detail page. This is the case with /campaigns/:id which shows mini list-pages for the campaign's maps and characters, with an Edit button to navigate to the edit page for that campaign.
  • Edit page
    • Typically /${entity name}/${id or slug}/edit — provides a form or interface for editing the details of a single entity. This is not an entirely different page, but rather an "edit mode" for the view version of the page, enabling fields to be edited. Editing fields on such a page persists on blur of each component, or upon any clicks such as a numeric stepper (with debouncing). There is no page-level Save or Cancel button; Undo and Redo are required here. Individual fields do carry their own inset commit / revert controls while dirty — see Committing an edit explicitly.
  • New page (2.1.0)
    • Typically /${entity name}/new (or a campaign-scoped variant, e.g. /campaign/:id/characters/new). It is the same UI as the Edit page with all fields blank — not a separate form. A name is required before any other field activates; the first persistable change POSTs the entity, then the URL redirects to the entity's edit/detail route without unmounting the form (navigate(..., { replace: true })), so the user keeps typing uninterrupted. Maps are the exception: the "new" route is /play/:campaign-id/new, which shows a blank default-texture map with the Edit Map modal open.
  • Delete page (2.1.0)
    • Typically /${entity name}/${id or slug}/delete. A confirmation page (not a bare modal) that details the entity and its dependents (e.g. a campaign's map/character/token counts), and offers three recourses: Download archive (the .cat export), Hide (remove from the user's view without deleting), and Delete permanently — gated behind typing the entity's exact name, which performs the existing soft delete (30-day purge), not a hard delete.
  • Misc page
    • Some pages do not fit neatly into the list, detail, or edit categories. Help and Settings are such examples (which may perhaps be standardized at a later date for compliance). The most notable exception is the lack of a /campaign/:id/maps/:id page, as navigating to a "map detail page" switches to /play/:campaign-id/:map-id, which is not a Page at all and works in an entirely unique way (although it contains an Edit Map context which accomplishes what a Map detail page would)

Three levels, one model (3.0.0). apps/web/src/components/Layout/navModel.ts is the one table from a route to everything the chrome needs — the area and the ids in scope, the rail item it lights, the children under that item, whether the page carries a third level, what the page's Share is about. Every navigation surface reads it; none keeps a path matcher of its own.

Level one — the rail

The primary navigation is the left rail (LeftRail): one column, wide enough for labels by default, carrying Home, Play (when a campaign is current or a last map is known), Campaigns, Characters, Library, Systems and Admin, each an icon and a label. The user may collapse it to icons anywhere, and the choice persists; on /play it is always collapsed (N-D1) — the map wants the width, and the icons keep Search, Notifications and Profile one click away. Collapsed, every item is a tooltip.

The rail's foot holds the account-level controls the top bar used to: Search (⌘/Ctrl+P, the global command palette), Help (the menu), connectivity (the dot, the connected players, Invite Players), Notifications, the impersonation indicator, Profile, and — off /play — the collapse toggle. Each opens to the right. The bell is immediately above the profile (v0.62.0 §5a, turned ninety degrees).

There is no global top bar (T.3). Pages start at the top of the viewport beside the rail.

Level two — the children

Under the active rail item, its children render indented beneath it, in the same column — Characters → Characters / Parties / Factions; Admin → its pages; and when a campaign is in scope, that campaign's own pages (Overview / Maps / Characters / Edit) headed by its name, under Campaigns. Children show only when the rail is wide: a child is a label, and there is no room for one collapsed. The former per-page second-level strips (CharacterSecondLevelNav, MapSecondLevelNav) are gone; the mobile Characters strip reads the same children (3.1.0: it stays beside the phone's drawer, which shows them too — the strip is the faster switch).

Level three — sections of a page

A large detail page has sections — Edit Campaign has fifteen — and moves between them with SectionNav (apps/web/src/components/UI/SectionNav): a 200px column of NavLinks on the page's left with a rule on its right, one per section with its icon, the active one filled. It is in-page state, not a route — the URL does not change. The routes that carry one are declared in navModel.THIRD_LEVEL_AREAS (campaignEdit, system), and a guard fails any component that builds the shape by hand.

/play — the play bar and the nub

/play is not a Page and has its own chrome. Across the top, from the rail's edge to the viewport's, is the play bar (PlayBar, PLAY_BAR_HEIGHT = 48): three columns (N-D2, the evaluation's Proposal One).

  • Left column — icon buttons for what the left sidebar can show: Me (the impersonated token's sheet; disabled until something is impersonated), System, Library, Map, Campaign.
  • Centre — the tool palette, embedded (not floating), with its overflow planner (v0.88.0) told the width it really has: the viewport minus the rail and both side columns.
  • Right column — icon buttons for what the right sidebar can show: Chat, Initiative, Combat Log, Transcript — four views of the one chat panel.

Each side column is exactly the width of the sidebar it opens, so an open sidebar sits directly under its buttons and the bar reads as the sidebars' header row. Every button goes through layoutStore (toggleContext / openContext), so the one-panel-per-side rule and the resize handle are unchanged; a second click on the chat view already showing collapses the panel (the chat is always present on a desktop map).

Hanging from the bar's bottom edge, centred over its span, is the nub (MapNub): the connection dot, the map's name, and how many people are here. Click it and it opens the only map switcher — the campaign's maps, cached list first and a refetch behind it — above the connected-players panel with the GM's session tools and Invite Players. While a token or a drawing is being dragged the nub fades to a thin line, so nothing is dragged under it.

Mobile

Chosen by input, not width (3.1.0). The mobile shell is what a pointer that cannot hover gets, at any width — the desktop chrome is built on hover — and what a pointer that can gets only below a phone's landscape width (BREAKPOINTS.small = 768), where a sidebar has nowhere to go. useScreenSize's small IS the shell, so the layout store, AppShell and the map's chrome cannot disagree. A tablet with a trackpad reports hover and gets the desktop; without one it gets the shell.

No top bar. MobileTopNav and its breadcrumb sections are gone (v0.98.0). The page header carries the breadcrumb; on a map the nub carries the map's name, from the viewport's top edge.

The bar (MobileContextBar, MOBILE_CONTEXT_BAR_HEIGHT = 56) is fixed above the footer on every route, and publishes its height as --vtt-bottom-chrome while mounted (Q.8). Off /play it is one slot, Menu, leftmost, opening the rail's drawer (MobileNavDrawer: LeftRail variant="drawer", full screen, always wide, closing on any navigation). On /play it is the play shell:

  • Three tabs, left — Map, Me, Chat — with tab semantics (role="tablist", one selected). The selected tab owns the whole screen above the bar: Map is the map itself; Me and Chat are TabBody screens (fixed, portalled, from the top to the bar, MOBILE_OVERLAY). Nothing else owns the screen — a guard holds it.
  • Up to two slots, right, belonging to the tab showing, from slotsFor in mobileShellStore — the one place the rules live. A caret slot opens a sheet (MobileDrawer: a bottom drawer in MOBILE_MENU that stops at the bar, as tall as its content up to 70vh, outside tap and the back gesture dismiss it — one history entry per open sheet); an action slot does something. Map → Actions (the impersonated token's actions grouped by power) and Palette (the tool palette as a labelled column; a pick closes the sheet). Me → Change (the chooser without ending the impersonation) and Stop (ends it, returns to Map). Chat → View (the chat views as a list, built for chat scope's channels to append to).
  • Me shows the chooser when nobody is impersonated, otherwise the character sheet at full width as the one scroller (never wrapped in a ScrollArea). Stop Impersonating returns to Map. Targeting mode resets the shell to Map with nothing open.
  • No swipe between tabs: the map owns horizontal drag.

Everything bottom-anchored reads the chrome — aboveBottomChrome(extraPx) = calc(var(--vtt-footer-height) + var(--vtt-bottom-chrome, 0px) + Npx) — never the bar's constant, so it is right whether or not the bar is mounted. Every popup opens above the shell: the theme defaults Modal, Drawer, Popover, Combobox, Menu and Tooltip above MOBILE_BAR; a modal covers the bar, as on every platform, and a guard holds the bands (mobileShellBands.test.ts).

20rem wide, adjustable. Certain actions within the sidebar can auto-resize.

Sidebars contain "Context" components, which are full-featured UIs from other parts of the app that are squeezed into the small width, such as a Character Detail Page or the Library browser.

Some Context views are "large", such as the Edit Campaign context. Large context views are set to min-width 40rem, up from the "small" standard of 20rem.

Sidebars change the "Map Pane", which is the remaining visible map minus visible sidebars, the rail on the left edge of the viewport, and the play bar along the top (3.0.0). If an element is centered in the Map Pane, it is centered between the apparent left and right edges of the Map Pane, which could be the viewport right edge, the top-level nav right edge, or one or more sidebar edges.

Established pattern. Widths live in layoutStore (apps/web/src/stores/layoutStore.ts): default 320px (= 20rem), range 200–720px, persisted per side. The "large" 40rem step is already used by the Character Context Build tab (expands to ~640px) — reuse setSidebarWidth for the Edit Campaign large context rather than inventing a container.

Constants to create:

  • SIDEBAR_WIDTH_SMALL = 20rem
  • SIDEBAR_WIDTH_MEDIUM = 30rem
  • SIDEBAR_WIDTH_LARGE = 40rem

Setting sidebar size should be a function call in the form sizeName => cssValue, e.g. 'medium' => '30rem'.

Modals

A modal is acceptable only when it fits comfortably on a small screen with room to spare. If the content is tall or dense enough that a phone-sized viewport would clip it or push the primary action below the fold, it does not get to be a modal — promote it to a standalone Page (with breadcrumbs and a header) or a context Sidebar. This is exactly why the UI Overhaul moves Edit Campaign to a page and folds Edit Token into the context sidebars.

Established pattern. Mantine <Modal> used directly — no shared wrapper. The retro theme sets centered: true, radius: 'md', and styles the title (uppercase Cinzel). Modals carry Z_INDEX.MODAL (2000); any Select/combobox inside a modal needs comboboxProps={{ withinPortal: true, zIndex: Z_INDEX.MODAL_DROPDOWN }} or the popup hides behind the modal (see Layers). Destructive confirmations (see Buttons) are currently hand-rolled <Modal>s.

Modal size:

  • The general rule is to use the smallest Mantine modal size that fits the content, and if it doesn't fit in xl, it's not a modal
  • A basic confirmation modal (e.g., "Are you sure you want to delete this?") typically fits in the small (sm) modal size. Example: the Delete Confirmation modal.
  • A modal with input fields (e.g., a form) typically fits in the medium (md) or large (lg) modal size. Example: the Heal/Harm modal.
  • A modal with multiple sub-tabs may need the extra space of the extra-large (xl) modal size to comfortably fit the content without clipping or excessive scrolling. Example: Share Campaign modal from the /play route.

Modal implementation:

  • Most modals can be implement using the Mantine Modal component directly. Ensure that the modal size is set appropriately based on the content, and call the functions described in Layers to get the right z-index.
  • Dangerous confirmation modals should use a common component that encapsulates the standard destructive confirmation modal pattern, ensuring consistency and reducing boilerplate code.

Calls to Action

  • A call-to-action must never be invisible or require scrolling to discover.
  • One primary action per surface, in the upper-right. Additional actions go into a caret ("⋯" / chevron) menu, never a row of competing buttons.
  • The secondary action or Cancel sits immediately left of the primary button.
  • A Share action, if present, sits left of secondary/Cancel.
  • Destructive actions (Delete) belong in the caret menu, styled per the destructive rule in Buttons.

Page-level placement is specified in Pages; button styling in Buttons. Per-item secondary actions use a <Menu> whose target is <ActionIcon variant="subtle"> with IconDotsVertical.

Buttons

  • Every button has a text label or a tooltip. No icon-only buttons without a <Tooltip> — including the loading/failed states of async buttons (an icon-only spinner still needs its tooltip).
  • Primary CTA = a plain Mantine filled <Button> (theme primary). Don't set an explicit color; inheriting the theme is what keeps it skin-adaptive — saddle brown in retro, indigo in modern.

In retro, the primary (filled) button resolves to the saddleBrown palette as its background; other skins resolve to their own primary (modern → indigo). We should define our own "primary button" variable that changes based on skin — it can in turn point to a Mantine var — but no button should be styled with a hard-coded value like --mantine-color-saddleBrown. (Good news: the skin-aware token already exists — --vtt-color-primary — so the work is "make buttons use it," not "create it.")

Destructive actions, such as deleting a Character or Campaign, should use a "danger" color and always require confirmation, despite the system featuring "soft delete" for all user-facing entities.

Non-standard buttons (open). Play Mode Character Context uses non-standard buttons, and the Character Sheet Overhaul is going to magnify this. Whenever a LinkButton-style button is called for, use a <Button> with variant="subtle" and no background/border until hovered. Ensure the text baseline lines up as a text link normally would, adjusting margins and button position as needed.

Established color/variant conventions (codebase survey — note these use Mantine named colors, see the contradiction below):

Intent Variant + color Notes
Primary CTA <Button> (no color) theme primary; e.g. "Create Campaign", "New Character"
Positive / Save / confirm variant="filled" color="green" token equivalent: --vtt-color-success (felt)
Destructive / Delete variant="light" color="red" + confirm de-emphasized to avoid mis-clicks; token: oxblood
Cancel / neutral variant="light" color="gray" or variant="subtle"
Secondary themed (e.g. Edit) variant="light" color="violet" common in token UIs

Icon buttons use <ActionIcon>: variant="subtle" for neutral (close, "more", focus), variant="light" for themed (edit=violet, delete=red, accept=teal), variant="filled" for emphasis/confirm. Always wrapped in a <Tooltip>. Async buttons use <AsyncButton> / <AsyncActionIcon> (apps/web/src/components/UI/AsyncButton/) — see Async Actions.

Design requirements:

  • Per the color requirements, buttons should use semantic colors (e.g., "success", "danger", "accent") rather than specific color names like "green" or "red". These semantic colors should be mapped to the appropriate skin-aware CSS tokens (--vtt-color-success, --vtt-color-danger, --vtt-color-accent, etc.) to ensure consistency across different skins and components.

Collapsing Sections

Collapsing Sections appear in several parts of the UI. These use the Mantine Accordion component, chevrons left, default properties.

Established pattern (and cleanup needed). Usage is currently split: most multi-section panels use <Accordion> (per this rule), but there are also <Collapse> one-offs and one hand-rolled chevron SectionHeader (ExpandedTokenContent). New sections (e.g. "Advanced Token Settings", "Versions") must use <Accordion> (chevrons left, default props).

Design requirements:

  • Retroactively use Accordion instead of Collapse
  • Collapse is allowed for future one-offs but the default remains Accordion

Layers

Modals, popovers, tooltips, and the like share a single global stacking order. Always use the Z_INDEX constants (apps/web/src/constants/zIndex.ts) — never hardcode a z-index.

Stacking order, high → low: toasts → popups inside modals → modals → modal backdrop → context menus → top/side bars → token-hover overlay → context sidebars → palette → map.

Constant Value Use
NOTIFICATION 5000 toasts (<Notifications position="bottom-center" zIndex={Z_INDEX.NOTIFICATION}>)
MODAL_OVERLAY 3000 highest in-modal overlays (e.g. TokenModal menus)
MODAL_TOOLTIP 2200 tooltips/popovers inside modals (above in-modal dropdowns)
MODAL_DROPDOWN 2100 dropdowns/combobox inside modals
MODAL 2000 all modals
CONTEXT_MENU / CONTEXT_SUBMENU 1000 / 1001 right-click menus
TOKEN_HOVER 900 token hover/selection overlay
MOBILE_NAV_DRAWER 960 the rail's mobile drawer (3.1.0)
MOBILE_BAR 800 mobile bar
MOBILE_MENU 750 a sheet over a tab (3.1.0)
MOBILE_OVERLAY 700 a tab's screen (3.1.0); TOP_BAR's old band, released in 3.0.0
SIDEBAR 600 AppShell sidebars and the rail
PALETTE 500 palette and sub-palette overlays
TOOLTIPS 300 hover tooltips
MAP_CONTROLS 100 map control buttons
MAP_UI 10 map canvas overlays

The Select-in-modal gotcha (called out in CLAUDE.md): any non-browser-default dropdown (Mantine Select, Menu, combobox) needs its popup z-index raised above its container or it renders behind. Inside a modal use Z_INDEX.MODAL_DROPDOWN; elsewhere withinPortal: true + an appropriate value.

Design requirements:

  • Always use the layer system, superceding any previous z-index maps.

Saving Data

In most cases, data is persisted to the database as soon as the input is blurred, rather than waiting for an explicit Save button to be pressed.

Because of this, we need to ensure all inputs that lead to peristence obey the Async Actions pattern, meaning the input should visually show states like loading, load successful, load failed, etc. Example pattern:

  • User enters a new value into a numeric input and tabs out
  • We update the input's visual state to "loading"
  • We send the API call
  • API call times out
  • We update the input's visual state to "load failed"
  • User corrects the value and tabs out again
  • We update the input's visual state to "loading"
  • We send the API call
  • API call succeeds
  • We update the input's visual state to "load successful"
  • After 3 seconds, the input is set to default state

Undo/Redo recourse (scoped in 2.0.0). Because there is no Save button, the user needs a way to walk back a mistaken edit. In 2.0.0 that recourse is the client-side, single-step Undo/Redo system, and it is wired only to map and token changes (see Undo / Redo). Autosaving surfaces outside that scope — Edit Campaign, the Character Sheet, and other detail/edit pages — autosave per field but do not ship an Undo/Redo affordance yet; their recourse is to re-edit the field. The richer model from earlier drafts (a time-ordered log of every persisted change, queued multi-undo, and a server "jump back to a previous point" replay) is deferred to 2.1.0 and depends on the robust server-side state store tracked under Deferred → Server-Side Undo/Redo in CHANGELOG.md.

Implementation. The visual states are the Async Actions spec, already built in v0.35.0: useAsyncFieldSave + the <AsyncTextInput> / <AsyncNumberInput> / <AsyncTextarea> / <AsyncSelect> wrappers (apps/web/src/components/UI/AsyncField/). The Undo/Redo requirement is its own section — see Undo / Redo.

Design requirements:

  • Retroactively, all inputs that work this way should use standardized components, instead of one-off implementations

Async Actions

Every async action must give immediate in-browser feedback the instant it starts (in-button spinner, field indicator). If it's expected to take more than a second or two, show an estimate or progress. On failure or timeout, show a toast and a status explanation next to the control that started it. The detailed state machines for the three async surfaces follow. The reusable infrastructure shipped in v0.35.0 (apps/web/src/utils/asyncFieldSave.ts, utils/asyncAction.ts; components under components/UI/AsyncButton/, AsyncField/, CardSkeleton/) and is partially adopted.

Auto-persisting form fields

Applies to any Mantine input that saves to the server on edit with no Save button (TextInput, NumberInput, Textarea, Select, Switch, Slider, …). Mantine ships no built-in for this — only the error prop (red border + label) and an empty rightSection slot — so we own the lifecycle.

Visual reference (open in a browser): async-field-states.html.

State Trigger Inline treatment Motion
Idle no pending change normal field, no indicator —
Saving request in flight <Loader size="xs"> in rightSection, neutral 1px --vtt-color-primary border, aria-busy, live "Saving…" spinner
Saved resolved OK check icon in rightSection (--vtt-color-success), 1px --vtt-color-success border, live "Saved" hold 1600ms → fade 400ms → Idle
Failed reject / timeout red border + label via Mantine's error prop (--vtt-color-danger / oxblood) explaining what happened, IconAlertCircle, retry affordance, + toast persists until next edit or successful retry

When to commit:

  • Text / number / textarea → on blur (optionally debounced 300–500ms while typing). Never fire a no-op save (value unchanged).
  • Switch / checkbox / segmented / Select → immediately on change.
  • Slider → on change-end, not per tick.

Rules:

  • Feedback is immediate — the spinner appears the instant the request starts.
  • Anti-flicker — keep "Saving…" visible ≥~350ms even if the response beats it, so fast saves don't strobe.
  • Last-write-wins — a newer edit aborts/ignores the older in-flight save (AbortController + request token).
  • Optimistic — the field shows the user's typed value; it never snaps back to a server round-trip mid-edit.
  • Success is quiet — inline check only, no toast (autosave fires constantly; toasts would spam). This is the one place we don't toast, unlike async buttons.
  • Failure is loud and local — inline red error label with a human-readable reason and a toast; keep the user's value so they can retry; clear the error on next edit or successful retry.
  • Never color-only — every state pairs color with an icon and text.
  • Respect prefers-reduced-motion — skip the fade; just show/hide.
  • Tokens — success --vtt-color-success (felt), failure --vtt-color-danger (oxblood), neutral/saving --vtt-color-primary (saddle brown in retro, indigo in modern); driven by a data-async-status attribute on the input wrapper so it adapts to skin. (The v0.35.0 build hardcodes saddleBrown for the neutral border; migrate it to --vtt-color-primary.)

Implementation — a shared useAsyncFieldSave hook (owns the state machine, timings, and abort) plus a <FieldSaveIndicator> (the rightSection icon + aria-live text), with thin AsyncTextInput / AsyncNumberInput / AsyncTextarea / AsyncSelect wrappers (components/UI/AsyncField/). Built on Mantine's error / rightSection, @mantine/hooks (use-debounced-callback, use-timeout, use-reduced-motion), @mantine/notifications, and @tabler/icons-react. It replaces the ad-hoc copies already in TokenModal (saveStatus) and PropertiesTab (debounce).

useAsyncFieldSave shape:

  • in: { initialValue, onSave(value, signal), debounceMs?, minSavingMs? = 350, successHoldMs? = 1600, equals?, errorToast? = true }
  • out: { value, setValue, commit, status: 'idle' | 'saving' | 'saved' | 'failed', error, retry, inputProps } — where inputProps spreads rightSection, error, aria-busy, and data-async-status.

Tasks: todo/design/async-actions.md.

Committing an edit explicitly

Ratified in 2.2.0. Blur-to-save is right, and it is also invisible. A user who types into a field and looks away has no way to tell whether the change was taken, and nothing on screen says how to commit it or how to back out. The observed failure is mundane and repeatable: edit a name in Build mode, switch to Play without blurring, and the old name is still there — the edit was never wrong, it was never asked for.

So an auto-persisting field that is dirty (edited, not yet committed) shows two inset controls in its rightSection:

Control Role Treatment
Commit Persists now, without waiting for blur Primary CTA color, check icon, aria-label="Save"
Revert Restores the last committed value and discards the edit Secondary CTA color, x icon, aria-label="Discard change"

Rules:

  • Blur still saves. These are an addition, never a replacement. A user who ignores both and clicks away gets exactly today's behaviour. Nothing becomes modal, and no edit is lost by looking away.
  • Only while dirty. Idle fields show nothing; Saving / Saved / Failed keep their existing indicator, which replaces the pair. A field never shows a commit control and a status icon at the same time.
  • Enter commits, Escape reverts — the controls are the discoverable form of shortcuts that should work anyway. On a Textarea, Escape reverts and Enter inserts a newline.
  • Revert is not Undo. It discards an uncommitted edit and touches no history. Once a value is committed, walking it back is Undo / Redo's job. The two must never be confused in copy: the revert control says "Discard change", never "Undo".
  • One implementation. This belongs to the shared AsyncField wrappers, never to a call site. A field that hand-rolls its own pair is a bug, for the same reason the one-off save indicators were.
  • Never color-only, and never icon-only without a label — both controls carry an aria-label and are reachable by keyboard before focus leaves the field.

Implementation. useAsyncFieldSave already owns the value, the committed baseline and commit(); it gains a dirty flag and a revert(), and mergeRightSection renders the pair when dirty and the <FieldSaveIndicator> otherwise. Because every auto-persisting input already routes through AsyncTextInput / AsyncNumberInput / AsyncTextarea / AsyncSelect, adopting this is one change in the wrappers plus the outstanding adoption sweep — not a change at each call site.

Scope note. Controls that commit immediately on change (Switch, Checkbox, SegmentedControl, Select, Slider on change-end) are never dirty, and so never show the pair. This rule is about typed input.

Async buttons

Applies to any button that fires an async action (save, generate, delete, reload, …). Mantine's Button already gives us the loading half — loading shows an in-button spinner and disables the button — so we add the success/failure layer and the label/icon swap on top.

State Label button Icon button Motion
Idle normal label normal icon —
Loading in-button spinner + label swap ("Save" → "Saving…"); disabled to block double-submit icon → spinner spinner
Success label → "Saved!" + check check icon hold a few seconds → fade back to Idle
Failed label → "Failed" + alert; re-enabled X / alert icon persists until dismissed or another action

Rules:

  • Feedback is immediate — the spinner appears the instant the action starts.
  • Block double-submit — loading disables the button while in flight.
  • Success fades to Idle after a few seconds; failure persists until the user dismisses it or takes another action.
  • Failure is explained twice — a Tooltip on the button and a toast, with the same message. (Per the global Buttons rule, the loading/failed icon states still need that tooltip — no icon-only buttons without one.)
  • Success is quiet — the in-button "Saved!" / check is enough; no success toast (matches form fields).
  • Long actions (>1–2s) — show an estimate or progress, per the global async rule.
  • Tokens/icons — spinner <Loader> (via loaderProps), success check --vtt-color-success, failure X/alert --vtt-color-danger.

Implementation — <AsyncButton> / <AsyncActionIcon> (or the useAsyncAction hook, utils/asyncAction.ts) over Mantine Button's loading / loaderProps, swapping label/leftSection for the success/failure icon with a success-hold timer, wiring failure to Tooltip + @mantine/notifications. Same state vocabulary as useAsyncFieldSave.

Loading-state cards (skeletons)

Applies to list/grid pages while their data loads.

  • Render placeholder cards identical in size and shape to the real cards, so the grid doesn't reflow when data arrives (no layout shift). Text → animated gray bars; images/thumbnails → animated gray blocks.
  • The shimmer is subtle and concrete: Mantine <Skeleton>'s built-in pulse over a neutral surface (--vtt-bg-surface-highlight), opacity oscillating ~0.4 ↔ 1.0 on a ~1.5s ease-in-out loop. No semantic color and no movement beyond that opacity pulse — it signals "loading" without competing for attention.
  • Show a sensible count (match the expected page size, else ~6–8) so the grid looks populated, not empty.
  • Respect prefers-reduced-motion — drop the looping shimmer to a static muted block (Skeleton with animate={false}).
  • Neutral surface only — no semantic colors on skeletons.

Implementation — <CardSkeleton> + <CardSkeletonGrid> (components/UI/CardSkeleton/) over Mantine's <Skeleton>, mirroring each real card's footprint and sharing the card's size constants so they can't drift; the list renders <CardSkeleton> × N while isLoading, swapping to real cards on load.

Undo / Redo

Scope in 2.0.0: client-side, single-step, map and token changes only. Undo/Redo today is the existing client-side system: undoStore (apps/web/src/stores/undoStore.ts — capped history, scope cleared on context switch), surfaced via <UndoRedoToolbar> (components/UI/UndoRedoToolbar/ — floating buttons whose tooltips describe each action), with useUndoable* hooks recording old/new state. It is generic at the store/toolbar layer (UndoActionType is extensible), and in 2.0.0 it is wired only to map and token mutations. Keep using it for those, and extend UndoActionType for any new map/token action.

Do not, in 2.0.0:

  • Build server-side "jump to an arbitrary point" replay. The multi-step server replay described in earlier drafts (send every undo/redo action needed to reach a chosen point, executed in sequence on the server) is deferred — it depends on a robust, server-side state store for arbitrary state restoration that is not yet designed. Tracked as "Server-Side Undo/Redo" under Deferred in CHANGELOG.md. Do not start it until that scope and design are defined.
  • Extend Undo/Redo to autosaved forms. Edit Campaign, the Character Sheet, and other autosaved detail/edit surfaces autosave per field (see Saving Data) but do not get an Undo/Redo affordance until the robust state system lands. Until then, the recourse on those surfaces is to re-edit the field.

Why this is versioned. The split is deliberate and is the reason this document carries a version: 2.0.0 specifies the current client-side, map/token-scoped design; a future 2.1.0 will re-spec Undo/Redo (and the autosave recourse) once the robust server-side state store is designed and the "jump-to-point" feature is in scope.

Notifications / Toasts

@mantine/notifications, surfaced through the notify() helper (see Implementation below) rather than raw notifications.show(). The provider is global and bottom-center (<Notifications position="bottom-center" zIndex={Z_INDEX.NOTIFICATION} />, moved there in v0.35.0; newest toast at the bottom). Toasts are transient feedback; they never replace the local inline explanation required by the Async Actions rules.

A toast carries three orthogonal properties — intent (what kind of message), criticality (how loud), and domain (route-scoped or global). All three are concrete and map to exact Mantine props / token values below.

Provider defaults (the global <Notifications>): position="bottom-center", zIndex={Z_INDEX.NOTIFICATION} (5000), limit={5}, containerWidth={420}, default autoClose={4000}, newest at the bottom.

Intent (semantic color)

Use a semantic intent, never a raw color name. Each intent resolves to a skin-aware token (so it adapts across retro/modern) and a default icon:

Intent Token Retro Modern Default icon Default criticality
success --vtt-color-success #2d8b47 felt #22c55e green IconCheck Normal
error --vtt-color-danger #8b1a1a oxblood #ef4444 red IconAlertTriangle Loud
time-critical --vtt-color-accent #a0522d sienna #f59e0b amber IconClock Loud (Maximum if it blocks play)
informational --vtt-color-secondary #5c4a3a walnut #64748b slate IconInfoCircle Low

The intent's token drives the icon color, the left accent, and (for Loud/Maximum) the border. Until semantic Mantine colors are registered (see Buttons and the TODO), pass the nearest legacy Mantine color for back-compat — success→green, error→red, time-critical→yellow, informational→gray — but author the call with the intent, not the color.

Criticality (loudness)

Criticality is the superset property; it fixes autoClose, the border, the close button, and the type ramp. "Calls attention to itself" and "subtle/smaller" are now exact:

Criticality autoClose Close button Border Title Body Icon Mount motion
Maximum false (sticky) yes 2px solid intent token md (16px) / weight 700 / intent token sm (14px) / --vtt-text-primary required, intent token, 20px one 300ms pulse (scale 1→1.03→1); skipped under prefers-reduced-motion
Loud 8000ms yes 1px solid intent token sm (14px) / weight 700 / intent token sm (14px) / --vtt-text-primary required, intent token, 18px none
Normal (default) 4000ms yes none sm (14px) / weight 600 / --vtt-text-primary sm (14px) / --vtt-text-secondary optional, intent token, 18px none
Low 2500ms no none xs (12px) / weight 600 / --vtt-text-secondary xs (12px) / --vtt-text-muted none none; reduced padding (py="xs")

Font sizes are Mantine size tokens (--mantine-font-size-{xs,sm,md} = 12/14/16px); colors are --vtt-* tokens so they stay skin-aware.

Domain (route scope)

  • Specific — scoped to the current route (e.g. /play/:cid/:mid). Tag it with a stable id derived from the route; a route-change effect calls notifications.hide(id) (or a scoped clean()) on unmount so it disappears when the user navigates away. Use for play-route-only conditions (turn-start notices, map invites).
  • Global (default) — not route-tagged; persists across navigation until it auto-closes or is dismissed.

Rules

  • A toast is transient feedback; it never replaces the local inline explanation required by the Async Actions rules.
  • Every call specifies intent (required) and optionally criticality / domain; omitted criticality/domain fall back to the intent's default criticality and Global.
  • Never pass a raw color; pass an intent. Colors are tokens, not hex or Mantine named colors.

Implementation. notifications.show() is currently called raw in ~17 sites with ad-hoc color. Wrap it in a single notify({ intent, criticality?, domain?, title, message, icon? }) helper (apps/web/src/utils/notify.ts) that maps intent→token color + default icon and criticality→the prop bundle above, applies the styling via classNames/styles keyed off the token vars, and handles route-scoped cleanup for domain: 'specific'. Migrate the existing call sites to it.


Component cheat-sheet

Use these unless there's a reason not to:

Use case Component Convention / key props Reference
Primary CTA <Button> no explicit color (theme primary) CampaignList, CharacterList
Save / destructive / cancel <Button> filled green / light red + confirm / light gray TokenContext
Async button <AsyncButton> / <AsyncActionIcon> loading→success→fail components/UI/AsyncButton/
Icon button <ActionIcon> subtle neutral / light themed; always tooltip widespread
Caret "more" menu <Menu> + <ActionIcon><IconDotsVertical/> position="bottom-end", withinPortal, zIndex PlayTopBar, card menus
Modal <Modal> centered; Z_INDEX.MODAL; small-screen rule AddToMapModal, TokenModal
Confirm dialog (bespoke <Modal> — see open Q) always confirm destructive actions —
Tabs <Tabs> — Character Context, TokenModal
Collapsible group <Accordion> chevrons left, default props; <Collapse> for one-offs CharacterPanel
Tooltip <Tooltip> withArrow (theme default); wrap all icon buttons widespread
Dropdown input <Select> comboboxProps={{ withinPortal: true, zIndex }} ← gotcha GeneralTab, LayerSelector
Loading grid <CardSkeleton> / <CardSkeletonGrid> mirror real card footprint components/UI/CardSkeleton/
Auto-save input <AsyncTextInput> / …NumberInput / …Textarea / …Select data-async-status components/UI/AsyncField/
Breadcrumbs <PageBreadcrumbs> URL-derived; null on /play components/UI/PageBreadcrumbs.tsx
Toast notify() intent + criticality + domain; bottom-center utils/notify.ts (wraps @mantine/notifications)
Undo/redo <UndoRedoToolbar> + undoStore required on no-Save surfaces components/UI/UndoRedoToolbar/

Suggestions — ratification status (2.0.0)

These were the open recommendations from review. 2.0.0 resolves them: items 1–8 are Ratified and now tracked task-by-task in todo/1-ui-overhaul.md; items 9–10 are Deferred / Open as noted.

# Recommendation Status (2.0.0)
1 Map Mantine semantic colors to skin tokens — register success/danger/accent/info Mantine colors that resolve to the felt/oxblood/sienna/slate tuples so buttons can use color="danger" and match token-styled surfaces. (Resolves the two-color-vocabulary contradiction.) Ratified → TODO Theming
2 Skin-driven primary-button var — point Button at --vtt-color-primary; purge --mantine-color-saddleBrown hardcodes. Ratified → TODO Theming
3 Delete the legacy --vtt-z-* CSS scale so there's one z-index source of truth. Ratified → TODO Layers
4 Extract a <PageHeader> component to enforce CTA placement structurally. Ratified → TODO Pages
5 Standardize confirm dialogs on one helper (<ConfirmDestructive> / @mantine/modals). Ratified → TODO Modals
6 Retire the custom SectionHeader; <Accordion> is the collapsible default. Ratified → TODO Collapsing Sections
7 Name the sidebar width steps (20/30/40rem) as constants. Ratified → TODO Sidebars
8 Add a MODAL_TOOLTIP z-index (or document the portal approach) so in-modal tooltips sit above the modal. Ratified → TODO Layers
9 Server-replay Undo ("jump to point"). Deferred → 2.1.0 — depends on the robust server-side state store; see Undo / Redo.
10 Non-standard-button standard (LinkButton-style hit zones for ability scores/attacks) and 2nd-level-nav standardization. Partly resolved — 2nd-level-nav standardized in 2.1.0 (top bar carries both levels, see Navigation). Non-standard-button standard remains Open, tied to the Character Sheet overhaul.

Changelog

Versioning for this document (semver): major = a breaking change to an already-ratified rule (re-spec something implemented), minor = new ratified rules or sections, patch = wording, clarifications, or fixes that don't change a rule.

3.1.0 — 2026-09-20

Navigation refactor, part two — the mobile play shell (v0.98.0). A minor bump: a new section.

  • Ratified the mobile shell: chosen by input; the bar on every route with the rail as its Menu drawer off /play; on /play three tabs each owning the screen, up to two slots per tab, sheets that stop at the bar and yield to the back gesture; Me's chooser / sheet rule; Stop returns to Map; no swipe. See Mobile.
  • Retired MobileTopNav and its breadcrumb sections; the nub names the map on a phone.
  • Bottom-anchored elements read --vtt-bottom-chrome through aboveBottomChrome, never the bar's constant; every popup the theme defaults opens above the shell's bands.
  • The Layers table names the shell's three bands and drops TOP_BAR, released in 3.0.0.

3.0.0 — 2026-09-20

Navigation refactor, part one (v0.97.0). A major bump: it reverses 2.1.0's central rule.

  • Retired the global top navigation bar as the primary nav. The left rail is the primary nav again, wide with labels by default, collapsed to icons on /play and by choice elsewhere; its foot carries what the bar held (Search, Help, connectivity, Notifications, Profile). See Navigation.
  • Ratified three levels: the rail, its children indented beneath the active item, and SectionNav as the named in-page third level — one route model (navModel.ts) for all of them.
  • Breadcrumbs render in the page header only; a guard fails a route that renders two.
  • Share lives in the page header's CTA slot, verb for whoever may share and noun for everyone else, never absent for a viewer. See Pages.
  • /play gets the play bar and the nub: three columns, each side the width of the sidebar it opens, the palette centred; the map's name in a nub that is the only map switcher.
  • Archived 2.2.0 verbatim at style-guide-2.2.0.md.

2.2.0 — 2026-08-09

Explicit commit / revert affordances for auto-persisting fields.

  • Ratified Committing an edit explicitly: a dirty auto-persisting field shows an inset Commit (primary) and Revert (secondary) pair, with Enter/Escape as their keyboard equivalents. Blur-to-save is unchanged.
  • Amended the Edit-page rule under Pages, which read "Instead of Save and Cancel buttons, Undo and Redo are required here". This is why 2.2.0 is a minor rather than a major bump: the ban is on page-level Save/Cancel, and a per-field commit/revert pair does not reinstate those. The substance is unchanged — what changed is that the sentence now says so, rather than reading as a ban on any commit affordance at all.
  • Deferred → 2.2.0 does not clear. The server-replay Undo/Redo re-spec that 2.1.0 moved to this version is still deferred and still depends on the server-side state store. It is unrelated to this release and keeps its own entry below.

2.1.0 — 2026-07-10

Global Navigation & Routing Overhaul. App-wide implementation is tracked in 2-global-nav-and-routing-overhaul/todo.md.

  • Ratified the top navigation bar as the primary global nav (see Navigation), superseding SideNav's desktop L1 role and resolving the prior "2nd-level nav" open question — the top bar carries both the area section and the entity section.
  • Added New and Delete page flavors (see Pages): a New page is the Edit page with blank, name-gated fields and a non-disruptive POST-then-redirect; a Delete page is a confirmation page offering archive / hide / type-name soft-delete.
  • Established campaign-scoped character routes, /help and /library URL-driven subroutes, and the Purple Cat VTT Archive (.cat) export format.
  • Moved the deferred server-side Undo/Redo re-spec from a future 2.1.0 to 2.2.0 (2.1.0 is now this overhaul).

2.0.0 — 2026-06-24

First fully ratified, specification-grade revision. App-wide implementation is tracked in todo/1-ui-overhaul.md.

  • Expanded every Mini Style Guide bullet into a full, as-implemented Full Style Guide section (Theming & Skins, Color & semantic tokens, Pages, Navigation, Sidebars, Modals, Calls to Action, Buttons, Collapsing Sections, Layers, Saving Data, Async Actions, Undo/Redo, Notifications).
  • Converted qualitative guidance into concrete, testable specs. Notification criticality (Maximum / Loud / Normal / Low) now fixes exact autoClose, border, font size/weight, color token, icon, and motion; notification intent maps to --vtt-* tokens; skeleton shimmer, async-field, and async-button states cite concrete tokens and timings.
  • Ratified the semantic-color rule end to end: components and toasts use intent tokens (--vtt-color-success / -danger / -accent / -secondary), never raw color names or --mantine-color-saddleBrown-* hardcodes.
  • Scoped Undo/Redo to client-side, single-step, map + token only; deferred the server "jump-to-point" replay and Undo/Redo for autosaved forms (Edit Campaign, Character Sheet) to 2.1.0.
  • Retro theme: removed text-transform: uppercase across Buttons, Modal titles, Accordion, Tabs, and Badges.
  • Resolved the review "Suggestions" backlog into Ratified (→ TODO) vs Deferred/Open items.

1.0.0 — initial

The original at-a-glance Mini Style Guide plus first-draft Full Style Guide notes: high-level rules for modals, calls to action, async actions, buttons, and the layer (z-index) order, with open questions and rewrite prompts left inline.

Deferred → 2.2.0

  • A robust server-side state store for arbitrary state restoration, and a re-spec of Undo/Redo and the autosave recourse on top of it (Edit Campaign, Character Sheet, all autosaved forms). Tracked under Deferred → Server-Side Undo/Redo in CHANGELOG.md. (Previously earmarked 2.1.0; renumbered because 2.1.0 became the Global Nav & Routing Overhaul.)
  • Owner decision still open (see Suggestions #10): the non-standard-button standard. (The 2nd-level-nav question is resolved in 2.1.0 — see Navigation.)

TODOs

Actionable tasks to implement this guide live in todo/1-ui-overhaul.md (full Style Guide 2.0.0 compliance, one commit each). Async Actions infrastructure shipped in v0.35.0; its tasks are in todo/design/async-actions.md. This document remains the design reference only.