Skip to content

Style Guide

Version 2.1.0 — Status: SUPERSEDED by 2.2.0 (2026-08-09). Archived verbatim.

This file is a frozen copy kept so a decision made under 2.1.0 can be read against the rules that were actually in force at the time. Do not edit it, and do not build from it — style-guide.md is always the live guide.

2.1.0 ratifies 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 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. The header markup is consistently <Container size="xl" py="xl"> wrapping a <Group justify="space-between"> (title + <PageBreadcrumbs/> on the left, action buttons on the right); see CampaignList / CharacterList.

This common UI should be achieved through a common component that encapsulates the header markup, breadcrumb, and action buttons, ensuring consistency and reducing duplication across different pages.

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). Instead of Save and Cancel buttons, Undo and Redo are required here.
  • 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)

Top level nav is a left sidebar, wide enough only for icon buttons, expanding to full width and adding labels on hover.

Second level nav:

  • Currently, /play has a top menu, and some specific routes like /systems/:id and the routeless Edit Campaign modal have left-side 2nd level nav
  • Should we standardize? Play mode isn't going to give up that much width for 2nd level nav, it's not worth it. Top-level nav for all routes? Could be useful on a big list page (campaigns, characters, maybe library). Wouldn't work great for systems and campaigns that have a ton of options, unless it's breadcrumb style (like the Character Context mini-nav), which works but is pretty non-standard.

Established pattern (2.1.0). The primary global nav is a top navigation bar rendered on all routes. It supersedes SideNav as the desktop L1 nav; SideNav's icon-rail L1 role is retired, but the mobile drawer (MobileNavDrawer) is retained for small screens. The "breadcrumb style" mini-nav referenced above is CharacterContextBreadcrumb — a sticky, dropdown-enabled in-view section navigator distinct from the page-level <PageBreadcrumbs>. (See Pages.)

Top-bar layout (see the full matrix in 2-global-nav-and-routing-overhaul/requirements.md):

  • Left → right: logo / Home; a Campaign section (context-aware label + dropdown); a Map/Character section (only under /campaign/:id-or-slug); on /library and /system routes the first section names the area and the second names the current subroute.
  • Right → left: Profile (Clerk UserButton), Connectivity (WS status + Invite Players), Help menu, Notifications, Search (the global command palette), and a ... overflow (Library / Systems).
  • On /play, the Help / Library / Systems items open their content in a sidebar rather than navigating away, so the map stays mounted.

This resolves the prior "should we standardize 2nd-level nav?" open question: the top bar carries both levels (area section + entity section) and is the single standard across routes.

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 and the top-level nav on the left edge of the viewport. 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_BAR 800 mobile context bar
TOP_BAR 700 top navigation bar
SIDEBAR 600 AppShell sidebars
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.

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.

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.