Skip to content

Design system — apps/web ​

The visual vocabulary for apps/web: the token layer panda.config.ts defines, and the rules for using it. Code shape and layout live in react.md; this file is about colours and surfaces.

Rarity ​

GW2 item rarity has seven named tiers, each with its own colour, used to colour-code an item's name wherever it appears. panda.config.ts defines a token per tier under theme.extend.tokens.colors.rarity (five of them) and theme.extend.semanticTokens.colors.rarity (basic and legendary, see below) — not domain.md, which records gameplay and pricing facts and does not carry visual vocabulary. The values are sourced from the Guild Wars 2 Wiki's own CSS documentation, which ships two skins with their own .rarity-* rules under the Template:Rarity section:

  • Guild_Wars_2_Wiki:Projects/CSS_documentation/template_misc-_minerva — the wiki's light-background skin.
  • Guild_Wars_2_Wiki:Projects/CSS_documentation/template_misc-_vector — the wiki's dark-background skin.

Both verified against the live pages on 2026-08-07:

RarityTokenMinerva (light)Vector (dark)This app's value
Basicrarity.basic#000000#ffffffsemantic — {colors.gray.900} base, #ffffff _osDark
Finerarity.fine#62a4da#62a4da#62a4da
Masterworkrarity.masterwork#1a9306#1a9306#1a9306
Rarerarity.rare#fcd00b#fcd00b#fcd00b
Exoticrarity.exotic#ffa405#ffa405#ffa405
Ascendedrarity.ascended#fb3e8d#fb3e8d#fb3e8d
Legendaryrarity.legendary#4C139D#974EFFsemantic — #4C139D base, #974EFF _osDark

Why exactly two rarities are semantic tokens ​

The rule is one line, and it comes straight from the source: a rarity is a semantic token if and only if the wiki's two skins disagree on it. Five of the seven — fine, masterwork, rare, exotic, ascended — are byte-identical between Minerva and Vector; they're saturated enough to read against either background, and the wiki itself treats them as skin-independent. Two are not: Minerva's .rarity-basic is #000000 and Vector's is #ffffff; Minerva's .rarity-legendary is #4C139D and Vector's is #974EFF. The wiki hit this app's exact problem — a colour that reads on a light background needs a different value on a dark one — and solved it the same way this app now does: per-skin overrides for the two rarities that need them, plain values for the five that don't.

This app's own surface token is light in base mode ({colors.gray.50}) and dark in _osDark mode ({colors.gray.900}), which is exactly Minerva-vs-Vector's situation. So both basic and legendary are semanticTokens entries with an _osDark condition, using the wiki's own light-skin value as the base and its dark-skin value as _osDark — not a guess at "the" colour, but the two colours the wiki already proved are both correct, one per background:

ts
basic: { value: { base: '{colors.gray.900}', _osDark: '#ffffff' } },
legendary: { value: { base: '#4C139D', _osDark: '#974EFF' } },

(basic uses {colors.gray.900} rather than the wiki's literal #000000 for its base value — a deliberate softening consistent with text.strong's own base value below, not a departure from the wiki's rule, which is "dark-on-light, light-on-dark.") The other five stay plain tokens. Promoting all seven "for consistency" would be solving a problem five of them don't have — the wiki's own CSS is the evidence that exactly two of them do.

Semantic tokens ​

All semantic colour tokens are conditioned on _osDark, Panda's @media (prefers-color-scheme: dark) condition — never _dark, which compiles to the CSS selector .dark & and only applies when an ancestor element carries a dark class. Nothing in this app ever applies that class, so _dark values would be unreachable dead weight; _osDark needs no JavaScript or class-toggling mechanism, it just follows the OS.

Verified against panda cssgen's emitted output (spec 012 research V1): before this migration, the CSS contained zero occurrences of prefers-color-scheme; after, one — the @media (prefers-color-scheme: dark) block carrying the semantic tokens' dark values. That's the proof dark mode was previously unreachable, not just untested.

Beyond rarity.basic and rarity.legendary:

  • surface — the app's background: {colors.gray.50} in base mode, {colors.gray.900} in _osDark mode.
  • text.strong — primary text colour: {colors.gray.900} base, {colors.gray.50} dark.
  • text.muted — secondary text colour: {colors.gray.700} base, {colors.gray.300} dark.
  • card — raised-surface background: {colors.white} base, {colors.gray.800} dark.
  • border — outline colour: {colors.gray.200} base, {colors.gray.700} dark.
  • muted — muted background fill: {colors.gray.100} base, {colors.gray.800} dark.
  • primary — accent colour: #0f766e base, #2dd4bf dark. Provisional teal, deliberately not derived from any rarity colour.

Tokens, never literals ​

A component's styles reference a token by name (css({ color: 'text.muted' })); a literal colour value — a hex code, rgb()/rgba(), or hsl()/hsla() — anywhere in apps/web/src is rejected. This is a guard test (apps/web/src/__tests__/conventions.test.ts), enforced at pnpm test, not a Biome rule; the enforcement table in react.md names the owner.

Colocated until promoted ​

A repeated style variant starts as a cva in the styles.ts beside the component — colocated with the component, never inside its .tsx file (react.md, Styling). It becomes a config recipe in panda.config.ts only when the component itself is promoted to shared/ui for a second feature to use — the same trigger react.md's cross-feature-import rule uses for promoting a component or hook. Until then, two components with a similar-looking variant each keep their own colocated cva rather than sharing one prematurely.

Amendment (spec 019). That trigger — promotion on a second consumer — describes feature components. Foundation / design-system components are promoted to shared/ui deliberately, ahead of a second consumer: the closed-component shape below is worth having before two features duplicate a control, not after one already has. Button, Input, Dialog and Tooltip are the first application of this carve-out — each shipped as a shared/ui component backed by a panda.config.ts config recipe with zero other consumers at the time (only the /ui gallery renders them). Feature components are unaffected by the carve-out: they still colocate a cva until a second feature needs them, exactly as above. No feature component has reached that trigger yet; the health feature has no repeated variant to promote.

Closed components ​

Each shared/ui component (Button, Input, Dialog, Tooltip) is one props-driven export that owns its Base UI primitive and its Panda styling internally. Neither ever leaves the file — only the finished component escapes shared/ui. This is a documented convention, not a guard test (spec 019 R2, deliberately — see react.md's enforcement table for where this rule sits and why it has no machine check).

Styling. The recipe is a Panda config recipe — recipes.button, and a slot recipe (slotRecipes.{input,dialog,tooltip}) for every multi-part component — each authored in its own buttonRecipe.ts/inputRecipe.ts/dialogRecipe.ts/tooltipRecipe.ts beside its component and registered in panda.config.ts, which stays a thin manifest. Config recipes over colocated cva because they're JIT (only the variants actually used are emitted, keeping the generated CSS lean) — Panda's own documented approach for design-system components — and the one-file-per-recipe split is Panda's recommended organization too, so a 30th component doesn't grow the config file. Generated into styled-system/recipes. react.md's styling rule forbids importing styled-system outside a styles.ts, so a component .tsx cannot import the generated recipe directly; shared/ui/styles.ts re-exports the four (export { button, dialog, input, tooltip } from '../../../styled-system/recipes') and each component imports its recipe from ./styles instead.

Wrap Base UI only where a native element falls short. Base UI is used where its behaviour is not free from a native element: Dialog (focus trap, scroll lock, Escape/outside-press dismissal), Tooltip (positioning, open-on-hover-and-focus), Input (Base UI Field's label↔control association and aria-describedby error wiring). Button is a native <button> — focusable, Enter/Space activation, disabled — already for free, so it is pure Panda with no Base UI underneath.

Tooltip's accessibility contract. Base UI's Tooltip does no aria wiring: the popup is visual-only and is never announced to assistive tech (verified against the installed package — research.md V3, a refutation of the spec's first assumption). The closed Tooltip compensates by requiring an accessible name on the trigger — a label prop applied as aria-label, defaulted from content when it is a string. Information that must be announced does not belong in a Tooltip; reach for a Popover (with openOnHover) instead — not built here, a future component.

Base UI is headless. The package ships no stylesheet — the Panda recipes above are the only visual styling any of the four components has. A few Base UI parts inject functional inline <style>/<script> tags (not a theme sheet); if a strict Content-Security-Policy is added later, wrap the app root in Base UI's CSPProvider and supply a nonce or disableStyleElements — not needed today, and not wired.

Storybook workbench. Storybook (@storybook/react-vite, its Vite builder) is the catalogue home for these four components — configured in apps/web/.storybook/ and run locally with pnpm --filter @gw2priory/web storybook. The /ui gallery (apps/web/src/features/ui-gallery/) has been retired; Storybook is the only render surface.

Three integration facts, none obvious from the framework's own docs:

  • PostCSS is in array-plugin syntax in apps/web/postcss.config.cjs (plugins: [...], not a keyed object) — the Storybook Vite builder requires the array form, and the app's own vite build accepts it too, so one config serves both (research 022 F1).
  • .storybook/preview.ts imports src/index.css, which is what carries Panda's generated tokens, recipes and the _osDark layer into the preview — without it, every story renders unstyled (research 022 V2).
  • The React Compiler runs in the Storybook build too — the Storybook Vite builder loads the app's own vite.config.ts, so a story is subject to the same panicThreshold: 'all_errors' bail-out as any route component (research 022 V4; see Memoization in react.md).

Dark mode is viewed via prefers-color-scheme emulation — the OS setting, or DevTools' "Emulate CSS media feature prefers-color-scheme" — never a class-based toggle. _osDark is media-query-driven (see Semantic tokens above), and a _dark class variant is unreachable for the same reason it is in the app itself; no theme-switcher addon is installed (research 022 V2).

A *.stories.tsx cannot import styled-system. The Biome ban that confines styled-system imports to a styles.ts (react.md, Styling) binds to every file under apps/web/src, stories included, since a story is not itself a styles.ts. Story layout — a Dialog's trigger button, a Tooltip's wrapped button — uses plain elements and useState for the ephemeral open/hover state, never a recipe import (research 022 V3).

Painted surface ​

shared/ui/paint/ (spec 024) is the first shared/ui component grouped into its own subfolder, rather than dropped flat in shared/ui/ alongside Button/Input/Dialog/Tooltip. PaintedSurface wraps children in the GW2 painterly look — watercolour wash, macro density, brush drag, blooms, grain, inset edge pooling, a torn SVG-displacement edge — re-tinted by a content-flavour theme (Bark, Verdant, Deep, Ember, Ash). The folder exists because the paint machinery is infrastructure other components are expected to reuse — the pure buildBackground builder and the paint.<theme> tokens — not because one component needed extra files; a future component can adopt the same primitives without a rewrite.

The Option-B colour split. Each theme's colour data splits across two representations, chosen by what each half needs:

  • Plain ink colours — text, textStrong, textDim, accent, hair — are Panda plain tokens under theme.extend.tokens.colors.paint.<theme>.<role> (paint.Bark.text, and so on for the other four themes). They are not semanticTokens and carry no _osDark condition: a paint theme is chosen per panel by what the panel depicts (bark, foliage, deep water, embers, ash), not by the OS light/dark setting, so there is nothing for _osDark to condition on — paint themes are content-flavour.
  • SVG-math channels — wash, macroDark, macroWarm, pool, bloomDark, bloomLight, drag — are numeric data in shared/ui/paint/paintThemes.ts, not tokens. A Panda token is a single #rrggbb/rgb() CSS value; the filter math needs 0–1 float triplets for feColorMatrix and "r,g,b" strings for gradient interpolation, neither expressible as one token value.

The literal-colour guard exemption is the builder, not the data (research 024 F1). The guard (conventions.test.ts's literalColours) matches only #hex, rgb(/rgba(, hsl(/hsla(. The channel data in paintThemes.ts is bare numbers — wash: [38, 24, 13], macroDark: '8,5,2' — so it trips nothing and needs no exemption. What trips the guard is shared/ui/paint/paint.ts, which assembles CSS colour-function strings from those channels (the macro gradients, the wash linear-gradient, the inset-pooling boxShadow, all built with rgba(…)), so paint.ts is the single exemption (PAINT_BUILDER in conventions.test.ts), scoped to that one path — a literal added anywhere else under apps/web/src, paintThemes.ts included, still fails the guard. The component, its recipe and its story carry no colour of their own; every colour string this feature emits concentrates in the one exempted builder.

A subsystem folder, not a leaf. The four earlier shared/ui components (Button, Input, Dialog, Tooltip, spec 019) each drop a component+recipe+story trio flat in shared/ui/. paint/ carries more: the theme data (paintThemes.ts) and the pure builders (paint.ts) are shared infrastructure, and the design system's direction is for the painterly treatment to reach other components later. Grouping it keeps the flat shared/ui/ legible as that happens — the public import path is unchanged (shared/ui re-exports PaintedSurface through the paint/ barrel), only the implementation is grouped.

The edge prop. The painted surface is dark, so on a dark background both its black drop-shadow and its torn edge vanish — the panel loses its silhouette. edge='rim' adds a light inner rim (a var(--paint-glow) inset built in buildBackground) so the torn edge reads against any ground; edge defaults to 'rim', with 'none' for the unadorned surface. (halo and deckle alternatives — an outer glow and a displaced outline — were prototyped in Storybook and dropped.)

Runtime-selected recipe variants need staticCss — and this is not a Storybook artifact. theme is passed to the recipe as a variable (paintedSurface({ theme })), never a literal. Panda has no runtime: it emits a recipe variant's CSS only for values it finds as literals while statically scanning src. With a variable it cannot tell which values are used, so it ships only whatever literals happen to appear anywhere in scanned source — Bark from defaultVariants, and (by accident) Deep from a test's <PaintedSurface theme="Deep"> — leaving Verdant/Ember/Ash with no CSS and no re-tint. A feature passing a dynamic theme would hit the identical missing-CSS in production, not just in Storybook. The recipe therefore declares staticCss: [{ theme: ['Bark', 'Verdant', 'Deep', 'Ember', 'Ash'] }] so every variant ships regardless of how it is called. Rule for the painterly-everywhere direction: any recipe variant chosen at runtime must be listed in staticCss, or Panda ships only the literals it happens to scan.

A painted surface is a theme scope (spec 025) ​

Adopting the paint used to mean re-styling everything inside it: a dark wash under text coloured text.strong (near-black in light mode) is unreadable, so each feature would re-derive the paint's ink for itself. That cost is why spec 024 shipped PaintedSurface and nothing adopted it.

The mechanism that removes it is already in Panda: every semantic token reference compiles to a CSS custom property — color: 'text.strong' emits var(--colors-text-strong) — so a painted surface can re-point those properties for its own subtree. A descendant keeps asking for the token it already asked for and receives a paint-appropriate value. Children stay agnostic; no feature style file is edited to adopt the look.

What is re-pointed, and what is never. Exactly six roles, in paintedSurfaceRecipe's theme variant:

RoleBecomesWhy
text.strongthe theme's textStrongbody and heading ink
text.mutedthe theme's textDimsecondary copy
primarythe theme's accentlinks, focus rings
borderthe theme's hairhairlines
cardpaint.filla sub-panel is a darkening of the wash, not a colour
mutedpaint.fillMutedthe subtler version of the same

rarity.* is never re-pointed — a legendary is the same purple on paint and off it, and the palette is domain vocabulary rather than decoration. surface is never re-pointed either: it is the page ground, which lies outside every painted container by definition. paintedSurfaceRecipe.test.ts asserts the re-pointed set is exactly those six, so adding a seventh fails a test rather than silently drifting the rarity palette inside painted panels.

The fills are shared, not per-theme. paint.fill and paint.fillMuted are translucent darkenings, theme-independent in the same way — and for the same reason — as paint.shadow and paint.glow. On a painted ground a "card" is not a colour; it is less light. One pair reads correctly over all five washes (spec 025 research V2), so five themes need two tokens rather than ten.

Ink also has to be set, not only re-pointed. color inherits its computed value, so an element that sets no colour of its own inherits whatever body resolved and never consults the re-pointed variables. The content slot therefore sets color: var(--paint-text) inside the scope; that is what makes an unclassed p re-tint.

Wiring a container is three moves. Wrap its internals in PaintedSurface, delete the container's own bg/boxShadow/borderRadius, move its padding to a paint slot passed through as className. Nothing below it changes. Dialog, Tooltip and ConnectAccountPrompt are all wired identically.

Rules that come with it:

  • Container-level, never nested. A painted surface inside another doubles the wash and stacks drop-shadows, and the in-game look has no such nesting. Paint one level.
  • Portals carry their own theme. Dialog and Tooltip render outside their opener's subtree, so they inherit no scope. Each declares its theme as a module constant.
  • Square edges. The torn displacement edge is a defining feature of the style; a ragged edge on a rounded box reads as a rendering fault. Painted containers carry no border radius.
  • Fixed seed. Every distinct seed/parameter combination is a distinct data-URI and therefore a distinct browser raster. Repeated containers must share one — do not expose seed on them.
  • Scale below the tiles needs chip. The bloom and drag rasters are 512×384 and the grain 160×160, so an element smaller than a tile renders one flat crop of it, and the tear's 8–17px displacement can exceed the element's own height. intensity: 'chip' is the sub-tile treatment. It is an object lookup, not a recipe variant, so it emits no CSS and needs no staticCss entry.
  • The scope cannot thin a component's chrome. It handles a container's own surfaces, not the density of what is inside it. LegendaryTree was cut from spec 025 for exactly this: a rule under every row, a box per gift and rarity-coloured badge outlines read as competing textures over a wash, and no token change fixes that (025 research F3). Assess a dense component's chrome before painting it.

Why the token guard asserts against generated types, not CSS ​

apps/web/src/__tests__/tokens.test.ts checks that a rarity or semantic token exists by reading styled-system/tokens/tokens.d.ts (Panda's generated type declarations), not by parsing CSS output. Two things are true about the CSS side that make types the right artifact to assert against:

  • There is no fixed-path CSS file to assert against. This repo has no Vite plugin for Panda (panda.config.ts's own comment: "Panda has no Vite plugin") — the integration is @pandacss/dev/postcss in postcss.config.cjs, which folds Panda's output into src/index.css and then Vite bundles it into a content-hashed file (dist/assets/index-<hash>.css) that only exists after a full vite build. styled-system/tokens/tokens.d.ts, by contrast, is written directly by panda codegen — a fast step that already runs in prepare — at a stable path.
  • A token's CSS custom property is emitted unconditionally either way. Verified directly against a production build: --colors-rarity-basic, --colors-surface, and every unused base-palette colour (--colors-rose-50, etc.) all appear in :root of dist/assets/index-<hash>.css regardless of whether any component references them — Panda's token layer is not usage-scoped; only the generated utility classes are. So asserting against CSS output would not even test what a naive reading of "does this token exist" implies — a token can be present in :root with no component ever consuming it. The type declaration is both faster to check and the artifact that actually reflects "this token is defined," which is the guard's real claim.

Painted sections and panels (spec 026) ​

PaintedSurface paints a container and paintedSurfaceRecipe makes it a theme scope, but neither can divide one. PaintedSection and PaintedPanel do: a panel owns the painted surface and a title row, a section is a titled band that collapses, and a panel's Column holds a stack of them. Both are domain-agnostic — what a section contains is the caller's business.

The paint subsystem has two builders, and they paint different things. buildBackground fills an area: wash, macro density, blooms, drag, grain. buildBand paints a stroke — a band that starts loaded with pigment and runs dry across the width, which is how the in-game material-storage headers read and the only painterly way to mark a section without drawing a box around it. Both live in paint.ts, the literal-colour guard's single exemption, and neither introduces theme data: the band's pigment is each theme's existing bloomLight channel, and its hairline rule is one shared near-white constant, theme-independent for the same reason paint.glow is.

The dissolve multiplies; it does not add. This is the difference between a stroke that ends and one that only gets fainter. The band's feComposite uses the left-to-right ramp as a factor on the turbulence — k1 non-zero, k2 and k3 zero — so past the ramp's reach every pixel evaluates to a negative k4 and clamps to nothing. Added instead (k2·noise + ramp − t, which is the obvious way to write it), noise alone still clears the threshold where the ramp has already reached zero, so flecks survive across the full width and the far end never goes clean. The symptom is subtle and reads as a tuning problem: paint appears next to content that should sit on bare wash. It is not tuning — no parameter value fixes an additive dissolve. paintBand.test.ts asserts the composite's shape as data, and spec 026 research.md V1 measures the rendered result.

How to test a claim about paint. Crop-hash against a control, and never trust a sweep whose negative control passes. The method: render, crop the region the claim is about, hash it, and compare against a render with the paint suppressed — plus a second crop where the paint certainly does land, to prove the control's suppression took effect. Both halves are needed. Spec 026's discovery sweep came back green while every screenshot was Storybook's "couldn't find story" error page, because another working tree held port 6006 and this one had silently fallen back to 6007; error pages compare equal to everything. A parameter that moves the subject (the band's pad moves the whole row) needs a control that moves with it.

A band cycles by position, not by hash. Repeated bands would read as a stamped texture, so a section takes one of three patterns — BAND_SEEDS[index % 3] — and PaintedPanel.Column supplies the index by walking its direct children with Children.map. Position rather than a hash of the title because a hash cannot see its neighbours: measured over thirteen real section titles it gave a 7/5/1 spread with four adjacent pairs sharing a band. A counter incremented during render would be impure and drift under StrictMode's double invocation; Children.map is the pure way to know a child's position. A section outside a Column takes the first band, which is what makes it testable and storyable alone. This amends spec 025's fixed-seed rule — three rasters for a panel of any size, rather than one.

The band is the one thing the CSS theme scope cannot reach. Every other painted element learns its theme through the re-pointed custom properties. The band cannot: buildBand picks its pigment in JavaScript, and the result is a data: URI — a separate document, where var(--paint-*) does not resolve. So PaintedSurface publishes its theme through BandContext as well as through CSS. Before it did, a section inside a bare <PaintedSurface theme="Deep"> took its ink from the scope and its pigment from the default, and the two halves of one component disagreed on four themes out of five. The rule: the surface owns the theme and publishes it once — a component that needs the theme in JavaScript reads that context rather than taking a prop that can drift from the surface it sits on.

Ask for the semantic token, not a new --paint-* property. A painted scope already re-points text.strong, text.muted, primary, border, card and muted (025). The section's secondary ink — its meta text and chevron — uses text.muted and gets the theme's textDim, verified by computed style across two themes (026 research.md V2). Adding a --paint-text-dim property for it would be a second mechanism for a role the scope already covers. PaintedSection.test.tsx asserts the meta slot is exactly text.muted, so a reintroduced stub fails a test.

Base UI notes that cost time to find. Collapsible's panel CSS needs &[hidden]:not([hidden='until-found']) { display: none } — without it a panel that sets its own display stays visible when closed, because a display declaration beats the user agent's [hidden] rule. And the trigger carries no aria-controls while the panel is closed: Base UI omits it rather than pointing at an unmounted id, so a test asserting it unconditionally fails on a closed section. Accordion was considered and rejected: version 1.6.0 removed roving focus following the APG guidance update, leaving only shared value state, which per-section open state does not want.