Spec 024 — Painted surface
Status: implemented Branch: 024-painted-surface
Status is set by the human, never by the agent. It moves draft → approved → implemented.
Problem
Guild Wars 2's UI is painterly — watercolour washes, pigment pooling at panel edges, torn wet edges — and the design system has none of that vocabulary. apps/web renders flat token-coloured surfaces; the four shared/ui components (spec 019) are functional controls with no house texture. A hand-authored reference (painted-surface-themed.jsx) proves the look is achievable with zero image assets — pure SVG filters (feTurbulence, feDisplacementMap, feColorMatrix) and CSS gradients composited on one element — and that a single "theme" can re-tint the paint itself (wash, macro density, bloom cells, brush drag, edge pooling), not just the text on top.
But the reference is a foreign body to this design system in three ways: it is built entirely on literal colour values, which the literal-colour guard (conventions.test.ts) rejects everywhere in apps/web/src; it themes by a named-palette enum rather than the app's _osDark axis; and the SVG paint math needs raw colour channels (0–1 float triplets for feColorMatrix, "r,g,b" strings for gradients) that a Panda token — a single #rrggbb CSS value — cannot express. This spec brings the technique in as a closed shared/ui foundation component while reconciling those three tensions deliberately, rather than pasting the reference in past the guards.
User stories
Ordered by priority. Each story must be independently testable and shippable — if only P1 ships, there is still something usable.
P1 — A themeable painted surface exists in shared/ui
As the developer, I want a closed PaintedSurface component that wraps content in the GW2 painterly look and re-tints the paint by a content-flavour theme, so a feature can reach for house texture from a small props API instead of hand-rolling SVG filters.
Independent test: with only this story implemented, shared/ui exports a PaintedSurface used purely through props; rendering it with each of the five themes composes a background from that theme's paint channels and wraps its children; the literal-colour and no-memoization guards stay green and pnpm build compiles it clean.
Acceptance scenarios
- Given a consumer that picks one of the five themes and passes
children, when it renders, then the full painterly look composes — wash, macro density, brush drag, watercolour blooms, grain, inset edge pooling and a torn displacement edge — and the consumer imports nothing fromstyled-systemto get it. - Given two different
themevalues, when each renders, then the composed paint background uses different channel values (the paint is re-tinted, not only the text), and a child element reading the surface's published ink colours (--paint-*custom properties) receives that theme's ink. - Given an
intensitypreset (restrained | screenshot | heavy), when it renders, then the paint parameters match that preset; and given a single group override (e.g.blooms=), then only that one parameter changes and the rest of the preset is untouched. - Given the component's source files, when the literal-colour guard runs, then no literal colour appears in the component, its recipe or its story; only the paint-builder module
paint.ts(which assembles thergba()paint strings) is exempt, and the exemption is scoped narrowly enough that a literal added anywhere else underapps/web/src—paintThemes.tsincluded — still fails the guard. - Given two
PaintedSurfaceinstances on one page, when they render, then each gets a unique,url(#…)-valid displacement-filter id, so one panel's torn edge cannot bleed into the other's. - Given the component, when it is built through the React Compiler (the
storybook build, which visits it via the story — the apppnpm buildtree-shakes the still-unreferenced component), then it compiles clean (panicThreshold: 'all_errors'): no hand-writtenuseMemo/useCallback/memo, and no destructured-parameter default.
P2 — A Storybook playground for the full paint
As the developer, I want every paint parameter exposed as a live Storybook control, so I can tune the look the way the reference's slider panel does, without editing code.
Independent test: with this story added, PaintedSurface.stories.tsx renders a Playground with controls for theme, variant, seed and every paint parameter, and switching a control updates the rendered surface; the three intensity presets are their own stories.
Acceptance scenarios
- Given the Playground story, when it is opened in Storybook, then controls exist for
theme,variant,seedand every paint parameter (wash,blooms,drag,grain,pool,tear), reproducing the reference's tuning panel. The threeintensitypresets are separate stories (Restrained/Screenshot/Heavy) — a liveintensitycontrol would do nothing, because the per-parameter sliders always override the preset (Storybook args cannot cascade preset → sliders). - Given a paint-parameter control, when it is moved, then the rendered surface updates that parameter live and leaves the others as they were.
- Given the theme control, when it is switched between the five themes, then the whole paint stack re-tints (not only the text) in the story render.
Requirements
The component
R1 —
PaintedSurfacelives inapps/web/src/shared/ui/, a single named export driven by a props API. It renders the full painterly layer stack described in P1 #1 around itschildren. Props:theme?: 'Bark' | 'Verdant' | 'Deep' | 'Ember' | 'Ash' // default 'Bark' (applied in the body) variant?: 0 | 1 | 2 // sibling-variety, picks a macro layout intensity?: 'restrained' | 'screenshot' | 'heavy' // preset bundle, default 'screenshot' edge?: 'none' | 'rim' // 'rim' (default) = light inner edge for dark grounds; 'none' = unadorned seed?: number wash? blooms? drag? grain? pool? tear? // optional per-group overrides children: ReactNode className?: string // Panda-friendly layout pass-throughR2 — Five themes form the public enum:
Bark,Verdant,Deep,Ember,Ash.themere-tints the paint channels themselves (wash, macro, bloom cells, drag, pooling), not only the text.R3 —
intensityselects a preset paint bundle (the reference's threePRESETS, renamed); each optional group prop shallow-merges over the selected preset, so any single parameter can be overridden in isolation.R4 — The surface publishes its theme's ink colours to its
childrenas CSS custom properties (--paint-text,--paint-text-strong,--paint-accent,--paint-hair), so child content styles withcolor: var(--paint-text-strong)rather than each consumer re-deriving the theme. This is how "the theme re-tints text too" survives — the reference applies those colours to the card content, which ischildrenhere.
The Option-B colour split
- R5 — The plain-colour half of each theme becomes Panda tokens under
theme.extend.tokens.colors.paint.<theme>:text,textStrong,textDim,accent,hair(five roles × five themes). These are plain tokens, notsemanticTokens, and carry no_osDarkcondition — paint themes are content-flavour, chosen per panel by what it depicts, not coupled to the OS light/dark axis. - R6 — The SVG-math half —
wash,macroDark,macroWarm,pool,bloomDark,bloomLight,drag— lives as raw channel data inapps/web/src/shared/ui/paint/paintThemes.ts. These cannot be tokens: the filter math needs float triplets and"r,g,b"strings, not#rrggbb. The channel data is numeric and does not trip the literal-colour guard (which matches only#hex/rgb(/hsl(); the file that does is the builderpaint.ts, which assembles thergba(…)gradient and shadow strings. Sopaint.tsis the only file exempted from the guard, the exemption is scoped to that one path, and — because all colour strings concentrate there — the component.tsx, its recipe and its story emit no colour of their own (the reference's inline drop-shadow and inset pooling move intopaint.tsor become Panda shadow tokens). Confirmed — research.md V1/F1.
Styling / closed component
- R7 —
PaintedSurfaceis a closed component (spec 019 pattern): it owns its rendering internals and only the finished component escapesshared/ui. Its config recipepaintedSurfaceRecipe.tsowns the static frame — the drop-shadow wrapper, positioning, and athemevariant that wires thepaint.<theme>tokens into the--paint-*custom properties. Confirmed — research.md V2. Becausethemeis passed at runtime, the recipe declaresstaticCssfor all five themes — Panda emits recipe-variant CSS only for the literals it statically scans, so a runtime-selected variant ships no CSS without it (seedesign-system.md, Painted surface). - R8 — The dynamic paint background — the layered composite of gradients and SVG data-URIs — is built by pure, React-free functions (
bloomSVG,dragSVG,grainSVG,macroFor,lerp,buildBackground) inapps/web/src/shared/ui/paint/paint.ts, returning a style object applied inline. They import nostyled-systemand are tested as functions, not through a render.edge='rim'adds a light inner-rim inset (var(--paint-glow)) to thatboxShadowso the dark panel's torn edge reads on dark grounds.
The React Compiler
- R9 — No hand-written memoization: the reference's
useMemois removed (the compiler owns memoization; a hand-written one is a guard-test failure). Every destructured-parameter default in the reference (theme = "Bark",wash = { … }, …) is moved into the function body — the exactBuildHIR::lowerAssignmentbail-outreact.mddocuments. The component compiles clean underpanicThreshold: 'all_errors'. Confirmed by construction — research.md V3; the Storybook build is the definitive Step-5 check. - R10 — Each instance gets a unique displacement-filter id that is valid inside
url(#…)and stable across renders.React.useId()returns colon-bearing strings (:r0:) that are invalid as an HTMLidand as a fragment reference, so the mechanism sanitises them:`paint-${useId().replace(/:/g, '')}`. Confirmed — research.md V4.
Storybook
- R11 —
PaintedSurface.stories.tsxexposes a Playground with controls fortheme,variant,seedand every paint parameter, reproducing the reference's tuning panel; the threeintensitypresets are separate stories (Restrained/Screenshot/Heavy), the reference's preset buttons as stories. A liveintensitycontrol is deliberately omitted — the per-parameter sliders always override the preset, so it would have no effect. The Playground also carries abackgroundselect (named CSS colours) to preview the panel against light and dark grounds — the surface is dark, so its torn edge and drop-shadow vanish on a dark preview. Confirmed — research.md V5: each paint parameter is a flattenedrangeargType, reassembled into the grouped props in the story'srender. - R12 — The story imports no
styled-system(the Biome ban binds*.stories.tsxtoo, spec 022 V3); story layout uses plain elements and args.
Documentation
- R13 —
design-system.mdgains a section documenting the paint-theme representation: the Option-B split (plain colours aspaint.<theme>tokens, channel math aspaintThemes.tsdata), why the channels cannot be tokens, the single scoped guard exemption, and that paint themes are content-flavour (no_osDark).
Testing
- R14 — Named tests cover: theme selection composes different channel values (P1 #2); intensity preset plus single-parameter override (P1 #3); the guard exemption is scoped to
paintThemes.ts(P1 #4); unique filter ids across two instances (P1 #5); the pure builders produce expected output for given inputs (deterministic onseed); each shipped theme's tokens exist in the generated token types (extends the spec 012tokens.test.tspattern). Visual fidelity (P1 #1, P2) is human-verified in Storybook — jsdom does not rasterise SVG filters. - R15 — Every acceptance scenario and success criterion maps to a named test (project Definition of Done).
HTTP contract
No endpoint is added, removed or changed by this spec, and apps/api/openapi.json is untouched. The work is confined to apps/web and docs/. pnpm verify:contract must stay green throughout.
Success criteria
Measurable and technology-agnostic — outcomes, not implementation.
- SC1 — A consumer renders a painted panel by choosing one of the five themes and passing
children, and the full painterly look shows (human-verified in Storybook). - SC2 — Switching
themechanges the paint channels themselves (wash, macro, blooms, drag, pooling) — verifiable in the composed background — and re-tints the ink published tochildren, not only the text. - SC3 —
intensityselects a preset look, and any single paint parameter can be overridden without disturbing the others. - SC4 — Every shipped theme's ink colours resolve through a
paint.<theme>token; the component, its recipe and its story carry no literal colour, and only the builderpaint.ts(which assembles thergba(…)paint strings) is exempt from the guard. The exemption is scoped so a literal introduced elsewhere underapps/web/srcstill fails the guard (extends spec 010 SC4). - SC5 — The component adds no hand-written memoization and compiles clean through the React Compiler (exercised on it by the
storybook build, which imports it via the story; the apppnpm buildtree-shakes the still-unreferenced component but stays green). - SC6 — Storybook exposes a Playground with controls for
theme,variantand every paint parameter, plus the threeintensitypresets as their own stories, reproducing the reference playground. - SC7 — Two
PaintedSurfaceinstances on one page get unique displacement-filter ids (no cross-contamination of the torn edge). - SC8 —
pnpm typecheck,pnpm test,pnpm lint,pnpm buildandpnpm verify:contractare all green, and Storybook runs withPaintedSurfacevisible and tunable.
Out of scope
- Ornamental corner ticks — the reference's four hairline corner brackets (
painted-surface-themed.jsx:219) were dropped after human review: the painterly surface plus its torn edge is the desired in-game look, and the ticks read as clutter. Not shipped. (Thehairink token stays published via--paint-hairfor consumers that want a hairline divider.) - The
prefers-reduced-transparency/ flat-surface fallback — deliberately deferred; it is a later one-file@mediaaddition swapping the layered background for a flat wash colour. - Any performance or instance-count budget — there is no page rendering many painted surfaces yet (foundation-only).
- Wiring
PaintedSurfaceinto any feature (e.g. the legendary detail cards). It ships proven by tests and Storybook; adopting it is later work. Bone(the reference's value-inverted palette) — dropped from this spec entirely; the five themes prove the technique. A value-inversion fixture can be added by a later spec if wanted.- The reference
Demo's fake landscape backdrop and hand-rolled slider chrome — Storybook controls replace them. - Animation — the surface is static; there is no motion.
_osDark/ dark-mode variants of the paint themes — paint is content-flavour, not OS-coupled.- Option A (all colours in the data module) — the chosen split is Option B; Option A remains the fallback only if the token half proves too heavy in practice.
Assumptions
- The stack is as pinned: React 19.2 with the React Compiler (
panicThreshold: 'all_errors'), Panda CSS, Storybook (@storybook/react-vite, spec 022), Vitest 4 with Testing Library, Vite 8. shared/uiis the home for deliberately-promoted foundation components — theButton/Input/Dialog/Tooltipprecedent (spec 019) and its closed-component convention apply here.- The literal-colour guard (
apps/web/src/__tests__/conventions.test.ts) and the token guard (apps/web/src/__tests__/tokens.test.ts) are the enforcement points, from specs 010 and 012. - Storybook is the render surface; the
/uigallery was retired in spec 022. painted-surface-themed.jsxis the visual reference: its palettes and filter math are adopted as-is (subject to the representation split above), not redesigned. ItsBonepalette is not used.
Traceability
Each acceptance scenario and success criterion must map to a named test. Filled in during implementation.
| Criterion | Test |
|---|---|
| P1 #1 | Human review (Storybook render) — jsdom does not rasterise SVG filters, so the composed painterly look is not unit-tested |
| P1 #2 | apps/web/src/shared/ui/paint/__tests__/paint.test.ts — "SC2: the theme re-tints the paint — two themes differ in channel values"; human review in Storybook confirms the live re-tint and that children reading --paint-* pick up the new ink |
| P1 #3 | apps/web/src/shared/ui/paint/__tests__/paint.test.ts — "SC3: a single-group override changes only that layer" |
| P1 #4 | apps/web/src/__tests__/conventions.test.ts — "P4 #2/SC4: no literal colour values", green with shared/ui/paint/paint.ts scoped-exempt (research F1) |
| P1 #5 | apps/web/src/shared/ui/paint/__tests__/PaintedSurface.test.tsx — "P1 #5 / SC7: two instances get unique, url(#…)-valid filter ids" |
| P1 #6 | storybook build (React Compiler visits the component via the story's module graph; the app pnpm build tree-shakes the still-unreferenced component), no separate test |
| P2 #1 | Human review (Storybook controls) |
| P2 #2 | Human review (Storybook render) |
| P2 #3 | Human review — switching the theme control in Storybook re-tints the whole paint stack, not only the text, in the story render |
| SC1 | Human review (Storybook render) |
| SC2 | Same as P1 #2: apps/web/src/shared/ui/paint/__tests__/paint.test.ts — "SC2: the theme re-tints the paint — two themes differ in channel values"; human review confirms the ink re-tint too |
| SC3 | Same as P1 #3: apps/web/src/shared/ui/paint/__tests__/paint.test.ts — "SC3: a single-group override changes only that layer" |
| SC4 | Same as P1 #4: apps/web/src/__tests__/conventions.test.ts — "P4 #2/SC4: no literal colour values" |
| SC5 | storybook build (React Compiler — the component is compiler-visible via the story) |
| SC6 | Human review (Storybook controls) |
| SC7 | Same as P1 #5: apps/web/src/shared/ui/paint/__tests__/PaintedSurface.test.tsx — "P1 #5 / SC7: two instances get unique, url(#…)-valid filter ids" |
| SC8 | Full command set at Step 5 |