Spec 025 — Painterly containers
Status: implemented Branch: 025-painterly-containers
Status is set by the human, never by the agent. It moves draft → approved → implemented.
Problem
Spec 024 shipped PaintedSurface as a foundation component: the GW2 painterly look, five themes, proven by tests and a Storybook playground, and adopted by nothing. Every container in the app is still a flat token-coloured box — Dialog and Tooltip render on card, ConnectAccountPrompt on the page ground, the crafting tree on card panels with border hairlines.
Adopting it naively means every child of a painted container has to be re-styled to suit the paint: a dark wash under text coloured text.strong (near-black in light mode) is unreadable, and each feature would re-derive the paint's ink for itself. That is the cost that has kept adoption at zero, and it scales with every component painted.
The mechanism that removes the cost already exists in the stack: Panda compiles every semantic token to a CSS custom property (color: 'text.strong' emits var(--colors-text-strong)), so a painted container can re-point those properties for its own subtree. Children keep asking for the tokens they already ask for and receive paint-appropriate values. This spec establishes that rule and wires the first three containers.
User stories
Ordered by priority. Each story must be independently testable and shippable — if only P1 ships, there is still something usable.
P1 — A painted container re-themes its subtree
As the developer, I want a painted container to re-point the semantic colour tokens for everything inside it, so that a child component renders correctly on paint without knowing it is on paint.
Independent test: with only this story implemented, Dialog and Tooltip render painted; their titles, descriptions and hairlines take the theme's ink and hair through the tokens they already use; no child of either names a paint colour; the literal-colour guard stays green.
Acceptance scenarios
- Given a painted container, when a descendant styles itself with
text.strong,text.muted,primaryorborder, then it renders in that theme's ink, accent or hair — with no change to the descendant's own source. - Given a painted container, when a descendant styles itself with
cardormuted, then it renders as a translucent fill over the paint rather than the app's opaque surface colour. - Given a painted container, when a descendant styles itself with any
rarity.*colour, then that colour is unchanged — a legendary reads identically on paint and off it. - Given a painted container, when it holds unclassed markup (a bare
p), then that markup inherits the theme's base ink rather than the colour the document body resolved. - Given
DialogandTooltip, when they render, then the paint provides their background, shadow and edge, their ownbg/boxShadow/borderRadiusrules are gone, and their public props are unchanged for every existing caller.
P2 — The app's own containers are painted
As a player, I want the prompts and panels I actually see to carry the house style, so the app looks like Guild Wars 2 rather than a generic dashboard.
Independent test: with this story added, ConnectAccountPrompt renders painted at all five of its call sites, and its "Go to Account" link takes the theme's accent through the primary token it already uses.
Acceptance scenarios
- Given any page that renders
ConnectAccountPrompt(materials, wallet, ranking, own-vs-need, reconnect-scopes), when the prompt shows, then it is a painted panel and its body and link are legible against the wash. - Given the prompt component's source, when it is read, then it names no paint colour and no paint parameter beyond choosing to be a painted container.
Requirements
The theme scope
R1 —
PaintedSurfacere-points the semantic colour tokens for its subtree. The scope is exactly:Role Re-pointed to text.strongthe theme's textStrongtext.mutedthe theme's textDimprimarythe theme's accentborderthe theme's haircardpaint.fillmutedpaint.fillMutedrarity.*andsurfaceare never re-pointed: rarity is domain vocabulary that must read identically everywhere, andsurfaceis the page ground, which by definition lies outside every painted container.R2 —
paint.fillandpaint.fillMutedare shared, theme-independent tokens, in the same category as the existingpaint.shadowandpaint.glow— translucent neutral overlays that darken whatever wash is underneath, not per-theme colours. A sub-panel inside a painted container therefore reads as a darker patch of the same paint. Confirmed — research V2: both fills read over all five washes at 22% and 12% black, and no theme needed its own value. Verified at one content density; the denser case that failed tookLegendaryTreeout of scope (research F3).R3 — The surface publishes its base ink to its own content, so unclassed markup inherits it.
colorinherits its computed value, so the token re-pointing alone never reaches an element that sets no colour of its own.R4 — The existing
--paint-*custom properties stay published as the explicit opt-in for content that wants paint ink by name. The token re-pointing is the implicit path; neither replaces the other.
Painted containers
- R5 — Three containers ship painted:
Dialog,Tooltip,ConnectAccountPrompt. Each declares its theme as a constant in the component. All three useBarkin this spec. - R6 — Wiring a container to paint consists of wrapping its internals in
PaintedSurfaceand removing that container's ownbg,boxShadowandborderRadius— the paint supplies all three. That removal is the only style edit the wiring may make: no descendant's styling is touched to accommodate paint, which is what R1 exists to prevent. - R7 — Painted containers carry no border radius. The torn displacement edge is a defining feature of the style, and a ragged edge on a rounded box reads as a rendering fault.
dialogRecipe.popuplosesborderRadius: 'lg'andtooltipRecipe.popuplosesborderRadius: 'md'. - R8 — A portalled container (
Dialog,Tooltip) renders outside its opener's DOM subtree and so inherits no theme scope. It carries its own painted surface and its own theme constant. - R9 —
Tooltipuses a newchipentry inPRESETS, a fourthintensityalongsiderestrained/screenshot/heavy, tuned for elements smaller than the paint's raster tiles: no tear (the displacement exceeds the popup's own height) and small hard bloom cells.intensityis an object lookup, not a recipe variant, so a new preset emits no CSS and needs nostaticCssentry. - R10 — Every painted surface in this spec uses a fixed seed. Each distinct seed/parameter combination produces a distinct data-URI and therefore a distinct browser raster; repeated containers must share one.
Storybook
- R12 —
ConnectAccountPromptgains its first story.DialogandTooltipkeep theirs, which now show the painted components. - R13 — Discovery spikes are Storybook-only and throwaway: painted tooltip, painted dialog, painted prompt, the five-theme role matrix, and the
LegendaryTreeboundary spike that took the tree out of scope. All spike stories and their fixtures are deleted before the branch merges; their findings live inresearch.md. Tuned parameters that survive do so as preset values or component constants, not as spike code.
Documentation
- R14 —
design-system.mdgains the rules this spec establishes: paint is container-level and never nested; ink, hairline and fill roles travel by token re-pointing while domain colours never do; portals carry their own theme; painted containers are square-edged; repeated surfaces share a seed.
Testing
- R15 — Named tests cover: every theme variant emits the full re-pointing block (all six roles); a descendant using
rarity.*inside a painted container is unaffected; each of the three containers renders a painted root;chipexists as an intensity and its parameters differ from the panel presets; the literal-colour guard stays scoped topaint.ts. - R16 — Every acceptance scenario and success criterion maps to a named test (project Definition of Done). Visual fidelity is human-verified in Storybook — jsdom rasterises no SVG filter, and this repo has no browser test runner.
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 component placed inside a painted container renders correctly on the paint with no edit to its own source: ink, hairlines and fills all follow the theme.
- SC2 — Rarity colours are byte-identical inside and outside a painted container.
- SC3 — All three containers render painted, and every existing caller of
Dialog,TooltipandConnectAccountPromptcompiles and behaves unchanged. - SC4 — No descendant's styling is changed to make it legible on paint. The only style rules this spec deletes are the painted containers' own background, shadow and radius.
- SC5 — No painted container carries a border radius, and every painted container's own background, shadow and radius rules are gone from its recipe.
- SC7 — No colour literal exists anywhere under
apps/web/srcoutsidepaint.ts, spike stories included, and no spike story or fixture survives the merge. - SC8 —
pnpm typecheck,pnpm test,pnpm lint,pnpm build,pnpm docs:buildandpnpm verify:contractare green, and Storybook shows all three containers painted.
Out of scope
- Painted controls.
ButtonandInputkeep their own tokens and their own flat rendering. A painted control is a different treatment — background layers only, states modulating one fixed paint rather than recomposing it — and it is a later spec, not a variant of this one. - The page shell and its ground.
surfacestays flat. Painted panels need an unpainted ground to read against; painting the shell would remove every panel's silhouette. - Cards.
LegendarySummaryand the other feature cards are not painted here. They inherit the whole mechanism once it exists. LegendaryTree. Cut during discovery, not deferred by guesswork: both boundaries were built and reviewed (research F3). One surface around the whole tree is the only one that reads, and it is still visually heavy, while the tree's own density — a rule under every row, a box per gift, rarity-coloured badge outlines — fights the wash. Painting it needs a chrome rethink that is not a paint problem, so the tree stays flat until that is its own spec.- Nested painted surfaces. Ruled out by design, not deferred: a painted container inside another one doubles the wash and stacks drop-shadows, and the in-game look has no such nesting.
- Light-mode paint. Painted regions are dark regardless of the OS colour scheme. Paint themes carry no
_osDarkcondition and gain none here. - State styling on painted surfaces. Containers are static; no hover, focus or active treatment is added to
PaintedSurface. - A per-call-site
themeprop. Each container's theme is a constant. Adding an optional prop later is additive and breaks nothing. - Automated visual regression. No browser runner is introduced; the look stays human-verified.
Assumptions
- The stack is as pinned by spec 024: React 19.2 with the React Compiler (
panicThreshold: 'all_errors'), Panda CSS, Storybook (@storybook/react-vite), Vitest 4 with jsdom, Vite 8. PaintedSurface, its five themes, thepaint.<theme>.*ink tokens and thepaint.tsguard exemption ship as spec 024 left them; this spec extends that foundation rather than reworking it.- Panda compiles semantic token references to CSS custom properties, which is what makes re-pointing work. Confirmed for
colors.text.strongduring discovery of this spec's design;research.mdconfirms it for every role in R1. DialogandTooltiphave no call sites in the app today — they are exercised through Storybook and tests. Painting them is design-system work whose visible effect arrives when a feature adopts them.- The literal-colour guard (
conventions.test.ts) and the token guard (tokens.test.ts) remain the enforcement points, withpaint.tsthe single scoped exemption.
Traceability
Each acceptance scenario and success criterion must map to a named test. Filled in during implementation.
| Criterion | Test |
|---|---|
| P1 #1 | paintedSurfaceRecipe.test.ts — "R1/P1 #1-#2: every theme re-points the six semantic roles" |
| P1 #2 | paintedSurfaceRecipe.test.ts — "R1/P1 #1-#2: every theme re-points the six semantic roles" (the card/muted → paint.fill/paint.fillMuted assertions) |
| P1 #3 | paintedSurfaceRecipe.test.ts — "P1 #3/SC2: the scope re-points exactly six roles — rarity and surface are never among them" |
| P1 #4 | paintedSurfaceRecipe.test.ts — "R3/P1 #4: unclassed children inherit the theme ink from the content slot" |
| P1 #5 | Dialog.test.tsx — "025 R5/P1 #5: the popup is a painted surface", plus the three pre-existing tests passing unmodified |
| P2 #1 | ConnectAccountPrompt.test.tsx — "025 P2 #1: the prompt renders as a painted surface" |
| P2 #2 | Source review — ConnectAccountPrompt.tsx names no colour and no paint parameter; promptStyles/promptLinkStyles unchanged in the diff |
| SC1 | Human review (Storybook) — jsdom rasterises no SVG filter; the wiring half is the container tests above |
| SC2 | Same as P1 #3: paintedSurfaceRecipe.test.ts — "P1 #3/SC2: the scope re-points exactly six roles — rarity and surface are never among them" |
| SC3 | Dialog.test.tsx, Tooltip.test.tsx, ConnectAccountPrompt.test.tsx — every pre-existing test passes unmodified; the five prompt call-site tests untouched |
| SC4 | Diff review — no descendant styling changed; the only deletions are the containers’ own bg/boxShadow/borderRadius |
| SC5 | dialogRecipe.ts / tooltipRecipe.ts carry no borderRadius, bg or boxShadow (diff review) |
| SC7 | conventions.test.ts — "P4 #2/SC4: no literal colour values"; git log --stat shows no spike file on the branch |
| SC8 | Full command set at Step 5 |