Skip to content

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

  1. 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 from styled-system to get it.
  2. Given two different theme values, 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.
  3. Given an intensity preset (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.
  4. 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 the rgba() paint strings) is exempt, and the exemption is scoped narrowly enough that a literal added anywhere else under apps/web/src — paintThemes.ts included — still fails the guard.
  5. Given two PaintedSurface instances 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.
  6. Given the component, when it is built through the React Compiler (the storybook build, which visits it via the story — the app pnpm build tree-shakes the still-unreferenced component), then it compiles clean (panicThreshold: 'all_errors'): no hand-written useMemo/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

  1. Given the Playground story, when it is opened in Storybook, then controls exist for theme, variant, seed and every paint parameter (wash, blooms, drag, grain, pool, tear), reproducing the reference's tuning panel. The three intensity presets are separate stories (Restrained/Screenshot/Heavy) — a live intensity control would do nothing, because the per-parameter sliders always override the preset (Storybook args cannot cascade preset → sliders).
  2. Given a paint-parameter control, when it is moved, then the rendered surface updates that parameter live and leaves the others as they were.
  3. 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 — PaintedSurface lives in apps/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 its children. 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-through
  • R2 — Five themes form the public enum: Bark, Verdant, Deep, Ember, Ash. theme re-tints the paint channels themselves (wash, macro, bloom cells, drag, pooling), not only the text.

  • R3 — intensity selects a preset paint bundle (the reference's three PRESETS, 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 children as CSS custom properties (--paint-text, --paint-text-strong, --paint-accent, --paint-hair), so child content styles with color: 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 is children here.

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, not semanticTokens, and carry no _osDark condition — 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 in apps/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 builder paint.ts, which assembles the rgba(…) gradient and shadow strings. So paint.ts is 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 into paint.ts or become Panda shadow tokens). Confirmed — research.md V1/F1.

Styling / closed component

  • R7 — PaintedSurface is a closed component (spec 019 pattern): it owns its rendering internals and only the finished component escapes shared/ui. Its config recipe paintedSurfaceRecipe.ts owns the static frame — the drop-shadow wrapper, positioning, and a theme variant that wires the paint.<theme> tokens into the --paint-* custom properties. Confirmed — research.md V2. Because theme is passed at runtime, the recipe declares staticCss for 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 (see design-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) in apps/web/src/shared/ui/paint/paint.ts, returning a style object applied inline. They import no styled-system and are tested as functions, not through a render. edge='rim' adds a light inner-rim inset (var(--paint-glow)) to that boxShadow so the dark panel's torn edge reads on dark grounds.

The React Compiler

  • R9 — No hand-written memoization: the reference's useMemo is 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 exact BuildHIR::lowerAssignment bail-out react.md documents. The component compiles clean under panicThreshold: '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 HTML id and as a fragment reference, so the mechanism sanitises them: `paint-${useId().replace(/:/g, '')}`. Confirmed — research.md V4.

Storybook

  • R11 — PaintedSurface.stories.tsx exposes a Playground with controls for theme, variant, seed and every paint parameter, reproducing the reference's tuning panel; the three intensity presets are separate stories (Restrained/Screenshot/Heavy), the reference's preset buttons as stories. A live intensity control is deliberately omitted — the per-parameter sliders always override the preset, so it would have no effect. The Playground also carries a background select (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 flattened range argType, reassembled into the grouped props in the story's render.
  • R12 — The story imports no styled-system (the Biome ban binds *.stories.tsx too, spec 022 V3); story layout uses plain elements and args.

Documentation

  • R13 — design-system.md gains a section documenting the paint-theme representation: the Option-B split (plain colours as paint.<theme> tokens, channel math as paintThemes.ts data), 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 on seed); each shipped theme's tokens exist in the generated token types (extends the spec 012 tokens.test.ts pattern). 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 theme changes the paint channels themselves (wash, macro, blooms, drag, pooling) — verifiable in the composed background — and re-tints the ink published to children, not only the text.
  • SC3 — intensity selects 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 builder paint.ts (which assembles the rgba(…) paint strings) is exempt from the guard. The exemption is scoped so a literal introduced elsewhere under apps/web/src still 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 app pnpm build tree-shakes the still-unreferenced component but stays green).
  • SC6 — Storybook exposes a Playground with controls for theme, variant and every paint parameter, plus the three intensity presets as their own stories, reproducing the reference playground.
  • SC7 — Two PaintedSurface instances on one page get unique displacement-filter ids (no cross-contamination of the torn edge).
  • SC8 — pnpm typecheck, pnpm test, pnpm lint, pnpm build and pnpm verify:contract are all green, and Storybook runs with PaintedSurface visible 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. (The hair ink token stays published via --paint-hair for consumers that want a hairline divider.)
  • The prefers-reduced-transparency / flat-surface fallback — deliberately deferred; it is a later one-file @media addition 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 PaintedSurface into 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/ui is the home for deliberately-promoted foundation components — the Button/Input/Dialog/ Tooltip precedent (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 /ui gallery was retired in spec 022.
  • painted-surface-themed.jsx is the visual reference: its palettes and filter math are adopted as-is (subject to the representation split above), not redesigned. Its Bone palette is not used.

Traceability ​

Each acceptance scenario and success criterion must map to a named test. Filled in during implementation.

CriterionTest
P1 #1Human review (Storybook render) — jsdom does not rasterise SVG filters, so the composed painterly look is not unit-tested
P1 #2apps/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 #3apps/web/src/shared/ui/paint/__tests__/paint.test.ts — "SC3: a single-group override changes only that layer"
P1 #4apps/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 #5apps/web/src/shared/ui/paint/__tests__/PaintedSurface.test.tsx — "P1 #5 / SC7: two instances get unique, url(#…)-valid filter ids"
P1 #6storybook 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 #1Human review (Storybook controls)
P2 #2Human review (Storybook render)
P2 #3Human review — switching the theme control in Storybook re-tints the whole paint stack, not only the text, in the story render
SC1Human review (Storybook render)
SC2Same 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
SC3Same as P1 #3: apps/web/src/shared/ui/paint/__tests__/paint.test.ts — "SC3: a single-group override changes only that layer"
SC4Same as P1 #4: apps/web/src/__tests__/conventions.test.ts — "P4 #2/SC4: no literal colour values"
SC5storybook build (React Compiler — the component is compiler-visible via the story)
SC6Human review (Storybook controls)
SC7Same as P1 #5: apps/web/src/shared/ui/paint/__tests__/PaintedSurface.test.tsx — "P1 #5 / SC7: two instances get unique, url(#…)-valid filter ids"
SC8Full command set at Step 5