Plan 024 — Painted surface
Status: approved Written in plan mode from spec.md (approved) and research.md (complete). Approved by the human before any code is written. Status is set by the human, never by the agent: proposed → approved.
Produced alone — tasks.md stays untouched until this plan is approved in turn.
Context
Guild Wars 2's UI is painterly and the design system has none of that vocabulary — apps/web renders flat token-coloured surfaces. A hand-authored reference (~/Downloads/painted-surface-themed.jsx) proves the look with zero image assets — SVG filters plus CSS gradients composited on one element — and that a named theme can re-tint the paint itself, not just the text. This plan brings that in as a closed shared/ui foundation component while reconciling the three tensions discovery pinned down: the literal-colour guard (exempt one builder file, paint/paint.ts, not the numeric data — research F1); the token layer cannot hold the SVG-math channels (they stay numeric data; the plain colours become paint.<theme> tokens — Option B); and the paint theme is content-flavour, not _osDark.
The direction beyond this spec is for the painterly treatment to reach every component in the design system. That does not widen this spec's scope — it stays foundation-only, one component — but it does shape the structure: the paint machinery is a subsystem, not a leaf, so it lives in its own shared/ui/paint/ folder and exposes reusable primitives (the pure buildBackground builder and the paint.<theme> tokens) that a future component adopts without a rewrite.
Goal
shared/ui gains PaintedSurface: a single props-driven export that wraps content in the GW2 painterly look and re-tints the whole paint stack by a content-flavour theme, tunable by an intensity preset and per-parameter overrides, with a Storybook playground that reproduces the reference's slider panel. A feature can reach for house texture from a small props API instead of hand-rolling SVG filters; and design-system.md documents the paint-theme representation and the paint/ subsystem the component establishes.
Approach
Five tasks, sequenced so each lands on a green tree, TDD inside each (the bite-sized steps are tasks.md). Everything lives under a new shared/ui/paint/ subsystem folder; the public surface stays shared/ui (re-exported through the paint/ barrel). The data and the pure builders come first because everything else consumes them and they are the most testable; the recipe and component next; the story and docs last, describing what the earlier tasks made true.
1 · Paint palette — tokens + channel data. Add the plain-colour half as 25 Panda plain tokens under theme.extend.tokens.colors.paint.{bark,verdant,deep,ember,ash}.{text,textStrong,textDim,accent,hair} (no _osDark — content-flavour, spec R5), sourced from the reference's cream/creamBright/creamDim/ accent/hair. Create shared/ui/paint/paintThemes.ts holding the SVG-math half as numeric data per theme (wash, macroDark, macroWarm, pool, bloomDark, bloomLight, drag — spec R6). The channel data is numeric, so it does not trip the literal-colour guard and needs no exemption (research F1). Extend tokens.test.ts to assert the paint.<theme> tokens exist in the generated types; a shape test asserts paintThemes carries all five themes with the seven channel fields.
2 · Paint builders paint/paint.ts + the guard exemption. Port the reference's pure, React-free functions — bloomSVG, dragSVG, grainSVG, macroFor, lerp, enc — and add buildBackground(theme, params, variant, seed) returning the composed { background, backgroundSize, backgroundBlendMode, boxShadow } style object. This is the one file that emits rgba(…)/gradient strings, so it is the single literal-guard exemption: add a scoped PAINT_BUILDER exclusion (shared/ui/paint/paint.ts) to conventions.test.ts's literal-colour assertion, mirroring its existing SELF exclusion (research V1). The existing "the rule catches a violation" test already proves a literal elsewhere still fails, so no new negative test is needed. Unit-test the builders: buildBackground for two themes differs in channel values (SC2); a single-group override changes only that layer (SC3); lerp and the bloom hardness table are deterministic on seed.
3 · Recipe + component. Add shared/ui/paint/paintedSurfaceRecipe.ts — a slot recipe (defineSlotRecipe, slots root/paint/corner/content) owning the static frame: the drop-shadow wrapper (a Panda shadow token, not a literal), positioning, the four hairline corner ticks, and a theme variant that wires the paint.<theme> tokens into the --paint-* custom properties and the tick border colour (research V2). Register it in panda.config.ts and re-export it from shared/ui/paint/styles.ts (the styled-system import rule). Create shared/ui/paint/PaintedSurface.tsx: merge the intensity preset with the per-group overrides (all defaults applied in the body, never in the destructure — research V3), compute a paint-${useId().replace(/:/g, '')} filter id (research V4), render the layer stack (the buildBackground style + the inline <filter> tear def + corner ticks + a content wrapper that publishes --paint-*), export it through shared/ui/paint/index.ts and re-export it from shared/ui/index.ts. Test: children render; theme re-tints (the composed background carries that theme's channels — P1 #2/SC2); intensity + single override (P1 #3/SC3); two instances get unique filter ids (P1 #5/SC7); the existing literal-colour and no-memoization guards stay green (SC4/SC5).
4 · Storybook playground. Create shared/ui/paint/PaintedSurface.stories.tsx (spec R11/R12, P2): a meta whose argTypes flatten every paint parameter to a range control (bloomsDensity, bloomsSize, … — research V5) and a render that reassembles the flat args into the grouped props; controls for theme (the five themes), variant, intensity and seed. The story imports no styled-system (R12). Verified by typecheck and the Storybook build (which runs the React Compiler — design-system.md) plus human review; stories are not a test tier.
5 · Documentation. Add a design-system.md section documenting the paint-theme representation: the Option-B split (plain colours as paint.<theme> tokens, channel math as numeric paint/paintThemes.ts), the single paint/paint.ts guard exemption and why it is the builder and not the data (research F1/V1), that paint themes are content-flavour (no _osDark), and that shared/ui/paint/ is the first grouped subsystem folder (anticipating reuse by other components). Written last, so SC-style "true of the repo" claims are checkable by reading. useId()-strip-colons for SVG ids and the Storybook flatten-to-range pattern are noted as step-6 graduation candidates (research.md), not added to react.md in this spec.
Architecture
apps/web/
panda.config.ts + tokens.colors.paint.<theme>.<role> (25 plain); + slotRecipes.paintedSurface
src/
shared/ui/
index.ts + re-export PaintedSurface from ./paint (public surface stays shared/ui)
paint/ new — the paint subsystem (grouped, not a leaf; reused by future components)
index.ts barrel — export { PaintedSurface }
paintThemes.ts numeric channel data per theme (no colour literals; not guard-exempt)
paint.ts pure builders + buildBackground(); emits rgba() → the ONE guard-exempt file
paintedSurfaceRecipe.ts slot recipe: static frame + theme variant → --paint-* + tick border
PaintedSurface.tsx closed component: preset+override merge, useId filter id, layer stack
PaintedSurface.stories.tsx playground: flattened range controls
styles.ts re-export the paintedSurface recipe from styled-system/recipes
__tests__/
paint.test.ts builder units (theme re-tint, single-param isolation, determinism)
PaintedSurface.test.tsx render, theme, intensity+override, unique filter ids
__tests__/
conventions.test.ts + scoped PAINT_BUILDER exclusion (shared/ui/paint/paint.ts)
tokens.test.ts + assert paint.<theme> tokens exist in generated types
../../docs/architecture/design-system.md + paint-theme representation section; + paint/ subsystem noteWhy paint/ is a folder, not flat placement. The four existing shared/ui components each drop the standard component+recipe+story trio in the root. Paint carries more — the theme tokens' data half (paintThemes.ts) and the builders (paint.ts) are shared infrastructure — and the design system's direction is for this treatment to reach other components. So paint is a subsystem consumed through its pure buildBackground and the paint.<theme> tokens, not a one-off; grouping it keeps the flat DS legible as it grows. The public import path is unchanged (shared/ui re-exports through the barrel); only the implementation is grouped. This is the first subfolder in shared/ui — noted in design-system.md (task 5).
The colour split (Option B, spec R5/R6, research F1). Plain colours → paint.<theme> tokens (referenced by the recipe). SVG-math channels → numeric paint/paintThemes.ts (fed to the builders). The only file emitting CSS colour strings is paint/paint.ts, so it is the single literal-guard exemption; the component, its recipe and its story carry no colour of their own.
The closed boundary (spec R7, spec 019 convention). PaintedSurface owns its recipe, its builders and its SVG layer construction internally; only the finished component escapes shared/ui. The recipe is imported through shared/ui/paint/styles.ts, never into the .tsx directly.
The paint stack composites, bottom-to-top: wash linear-gradient → macro radials (variant-lopsided) → drag turbulence → bloom turbulence → grain, with inset pooling boxShadow and a feDisplacementMap tear warping the whole panel. The theme re-tints every layer because each layer reads its channels from the active theme (paintThemes.ts), not a fixed palette.
Tech stack
React 19.2.8 with the React Compiler via @rolldown/plugin-babel (panicThreshold: 'all_errors', babel-plugin-react-compiler 1.0.0), Panda 1.11.5, Storybook / @storybook/react-vite 10.5.8, Vite 8, Biome 2.5.5, Vitest 4.1.10 + Testing Library, TypeScript 7. Node ≥ 22.18, pnpm 11.15.1.
No new dependencies — runtime or dev. The technique is pure React + SVG + CSS; every tool it needs is already installed (specs 004/019/022). React's useId is a built-in.
Global Constraints
Copied from the architecture docs and the approved spec. Every task inherits these.
From docs/architecture/typescript.md:
- No
any. Not in app code, not in tests. Useunknownplus narrowing, or model the type properly. If a third-party type forces it, isolate it behind one typed adapter and comment why. - No non-null assertions (
!) to silence the compiler. - No
@ts-expect-errorwithout a comment explaining what is expected and when it can be removed. - Validate everything crossing a boundary (GW2 API responses, HTTP input) at runtime, not just at the type level.
- Prefer pure functions for domain logic. The optimizer must be testable without a network or a database.
- Match the style of surrounding code. No new dependency without justification in the spec or plan.
moduleResolution: "node"andbaseUrlare removed in TS 7. Use"nodenext"(or"bundler"for Vite/bundler-resolved code) pluspaths: { "*": ["./*"] }in place ofbaseUrl.
From docs/architecture/react.md and design-system.md (the conventions this spec extends):
- Tokens, never literals. A style references a token by name; a literal hex/
rgb/hslanywhere inapps/web/srcis rejected byconventions.test.ts. The one exemption this spec adds is scoped topaint/paint.ts. - No hand-written memoization. No
useMemo/useCallback/React.memo; the React Compiler owns it. - Styling lives beside the component, never in the
.tsx. Promoted components use config recipes inpanda.config.ts; the component file references the generated recipe through astyles.ts. - No default value on a destructured parameter — the React Compiler bail-out; apply defaults in the body.
- Closed components (spec 019). One export with a props API; the recipe and internals never escape the file;
styled-systemis imported only inside astyles.ts.
From the approved spec.md:
- Content-flavour theme (R2/R5). The paint theme is chosen per panel; the
paint.<theme>tokens are plain, notsemanticTokens, and carry no_osDark. - One guard exemption, scoped to
paint/paint.ts(R6/SC4). The component, recipe and story emit no colour of their own; a literal elsewhere underapps/web/srcstill fails the guard. - No API endpoint (HTTP contract).
apps/api/openapi.jsonuntouched;pnpm verify:contractstays green.
From CLAUDE.md: typecheck clean, tests pass, every acceptance scenario and success criterion covered by a test whose name traces to it, no unexplained escape hatches, the human reviews the diff.
File Structure
| Path | Change | Responsibility |
|---|---|---|
apps/web/panda.config.ts | modified | tokens.colors.paint.<theme>.<role> (25 plain tokens); slotRecipes.paintedSurface |
apps/web/src/shared/ui/paint/paintThemes.ts | new | Numeric paint channel data per theme; no colour literals, not guard-exempt |
apps/web/src/shared/ui/paint/paint.ts | new | Pure builders + buildBackground(); emits rgba() — the one guard-exempt file |
apps/web/src/shared/ui/paint/paintedSurfaceRecipe.ts | new | Slot recipe: static frame + theme variant → --paint-* + tick border; tokens only |
apps/web/src/shared/ui/paint/PaintedSurface.tsx | new | Closed component: preset+override merge, useId filter id, layer stack, children |
apps/web/src/shared/ui/paint/PaintedSurface.stories.tsx | new | Playground: flattened range argTypes + render |
apps/web/src/shared/ui/paint/styles.ts | new | Re-export the paintedSurface recipe from styled-system/recipes |
apps/web/src/shared/ui/paint/index.ts | new | Barrel — export { PaintedSurface } |
apps/web/src/shared/ui/index.ts | modified | Re-export PaintedSurface from ./paint (public surface stays shared/ui) |
apps/web/src/shared/ui/paint/__tests__/paint.test.ts | new | Builder units: theme re-tint, single-param isolation, determinism |
apps/web/src/shared/ui/paint/__tests__/PaintedSurface.test.tsx | new | Render, theme, intensity + override, unique filter ids |
apps/web/src/__tests__/conventions.test.ts | modified | Scoped PAINT_BUILDER exclusion (shared/ui/paint/paint.ts) in the literal-colour assertion |
apps/web/src/__tests__/tokens.test.ts | modified | Assert paint.<theme> tokens exist in generated types |
docs/architecture/design-system.md | modified | Paint-theme representation section; the paint/ subsystem note |
Data & contracts
No HTTP contract change. The new type surface — the shapes later tasks and consumers rely on (exported from shared/ui/paint/):
type PaintTheme = 'Bark' | 'Verdant' | 'Deep' | 'Ember' | 'Ash';
type Intensity = 'restrained' | 'screenshot' | 'heavy';
type Wash = { opacity: number; macro: number };
type Blooms = { density: number; size: number; hardness: number; opacity: number; lift: number };
type Drag = { strength: number; angle: number };
type Grain = { opacity: number };
type Pool = { strength: number };
type Tear = { scale: number };
// paint/paintThemes.ts — the numeric channel half (Option B)
type PaintChannels = {
wash: [number, number, number]; // rgb 0–255, fed to rgba() in the wash gradient
macroDark: string; macroWarm: string; // "r,g,b" strings for the macro radials
pool: string; // "r,g,b" string for the inset pooling
bloomDark: [number, number, number]; // 0–1 triplet for feColorMatrix (lerped with bloomLight by `lift`)
bloomLight: [number, number, number];
drag: [number, number, number]; // 0–1 triplet for feColorMatrix
};
const paintThemes: Record<PaintTheme, PaintChannels>;
// paint/paint.ts — the builder (the guard-exempt file)
function buildBackground(
theme: PaintTheme, params: { wash: Wash; blooms: Blooms; drag: Drag; grain: Grain; pool: Pool },
variant: 0 | 1 | 2, seed: number,
): { background: string; backgroundSize: string; backgroundBlendMode: string; boxShadow: string };
// paint/PaintedSurface.tsx
type PaintedSurfaceProps = {
theme?: PaintTheme; // body-default 'Bark'
variant?: 0 | 1 | 2; // body-default 0
intensity?: Intensity; // body-default 'screenshot'
seed?: number; // body-default 3
wash?: Partial<Wash>; blooms?: Partial<Blooms>; drag?: Partial<Drag>;
grain?: Partial<Grain>; pool?: Partial<Pool>; tear?: Partial<Tear>;
className?: string;
children: React.ReactNode;
};intensity resolves to a preset { wash, blooms, drag, grain, pool, tear }; each optional group prop shallow-merges over its preset group, so any single parameter overrides in isolation (spec R3).
Test strategy
| Spec criterion | How it becomes a test |
|---|---|
| P1 #2 · SC2 | PaintedSurface.test.tsx — rendering theme="Deep" vs theme="Bark" produces different channel values in the composed background; a child reading --paint-text-strong gets the theme's ink. Builder-level echo in paint.test.ts (buildBackground differs by theme) |
| P1 #3 · SC3 | paint.test.ts — a blooms= override changes only the bloom layer; the other layers match the preset. Component-level check that intensity selects the preset |
| P1 #5 · SC7 | PaintedSurface.test.tsx — two instances rendered together expose two distinct <filter id> values; neither contains : |
| P1 #4 · SC4 | conventions.test.ts literal-colour guard stays green with paint/paint.ts exempted; the component, recipe and story add no literal; the existing "catches a violation" test proves elsewhere still fails |
| P1 #6 · SC5 | conventions.test.ts no-memoization guard stays green (no useMemo); pnpm build compiles clean under the React Compiler |
| R5 (tokens) | tokens.test.ts — each paint.<theme>.<role> token exists in styled-system/tokens/tokens.d.ts |
| SC8 | The full command set at step 5: pnpm typecheck, test, lint, build, verify:contract, and the Storybook run |
Not directly testable, and what stands in (honest gaps):
- P1 #1 · SC1 · SC6 (the full painterly look; Storybook controls) — jsdom does not rasterise SVG filters, so appearance and the slider playground are human-verified in Storybook. The automated floor is the story's typecheck and the Storybook build; the component test asserts structure (layers present, ids unique), not pixels.
- P2 #1–#3 (controls exist, move live, themes re-tint) — human review in Storybook; stories are a compile-time + manual tier (react.md), not run by Vitest.
Alternatives considered
- Flat placement in
shared/ui(like the four leaf components) — rejected: paint brings six files plus infrastructure the leaves don't have, and is headed for reuse across components, so apaint/subsystem folder keeps the flat DS legible. The public import path is unchanged. - Exempt
paintThemes.ts(the data) from the guard — rejected: discovery (F1) showed the numeric data never trips the regex; the builderpaint/paint.tsdoes. Exempting the data would be a no-op that leaves the real offender failing. - Option A (all colours in one data module) / Option C (all tokens + runtime hex→channel) — rejected in brainstorming: A over-widens the exemption to plain colours that can be tokens; C clutters the token namespace with ~42 single-consumer entries and fights the tool. Option B splits by real usage.
control: 'object'for the grouped paint params — rejected: it renders a JSON editor, not the sliders the reference has. Flattening torangeargTypes gives the slider UX the human asked for (V5).- A colocated
cvainstead of a config recipe — rejected:PaintedSurfaceis a deliberately-promoted foundation component, so a config recipe is the correct form (spec 019 carve-out). - A
Bonevalue-inversion fixture theme — dropped from this spec (human call): the five themes prove the technique; a fixture can be added by a later spec if wanted. - The
prefers-reduced-transparencyfallback — deferred (spec Out of scope); a later one-file@mediaaddition.
Risks
- The recipe's
themevariant emitting--paint-*custom properties is verified on paper (V2), not yet built. Mitigation: task 3 runspnpm build/ the Storybook build to confirm Panda emits the custom properties; if a{colors.paint…}reference does not resolve into a custom property, the fallback is to set the vars fromtoken()inpaint/styles.ts(still a var reference, not a literal — guard-safe). - SVG filters are not rasterised in jsdom. Appearance is human-verified in Storybook; the component test asserts structure only. Accepted, like spec 019's visual bits.
- Corner-tick geometry in a slot recipe. The four ticks have per-corner border sides; resolved in tasks by rendering four
cornerspans with data attributes and compound variants (or inline positioning) — an implementation detail, not a design choice. - Drop-shadow colour. The reference's
rgba(0,0,0,.36)becomes a Panda shadow token in the recipe (an existingshadowstoken if the look holds, else one added) — never a literal. - React Compiler bail-out /
useIdcolons — both de-risked in discovery (V3/V4); the Storybook build is the definitive compiler check.
Open questions
- None blocking.
research.mdiscomplete(five verdicts, one refutation folded into the spec). The minor implementation choices above (corner-tick geometry, which shadow token) are resolved inside the tasks, not by the human.