Plan 025 — Painterly containers
Status: approved Written in plan mode from spec.md and research.md. 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.
Goal
A component dropped inside a painted container renders correctly on the paint without being told it is on paint. Today that is only true of markup written against the --paint-* properties by hand; after this plan it is true of anything styled with the app's own semantic tokens, and three containers — Dialog, Tooltip, ConnectAccountPrompt — are painted to prove it.
Approach
PaintedSurface becomes a theme scope. Its theme variant already publishes four --paint-* properties; it gains six more declarations that re-point the app's own semantic token variables (--colors-text-strong, --colors-text-muted, --colors-primary, --colors-border, --colors-card, --colors-muted) at paint values for the subtree. Panda compiles color: 'text.strong' to var(--colors-text-strong) (research V1), so a descendant that never heard of paint resolves its own token against the surface's value. rarity.* is left alone by construction — it is a different property name, so no override can reach it.
Two of those six point at new shared tokens, paint.fill and paint.fillMuted — translucent darkenings rather than colours, so one pair serves all five themes (research V2). They join paint.shadow and paint.glow, which are already theme-independent for the same reason.
Because color inherits its computed value, the re-pointing alone never reaches markup that sets no colour of its own: a bare p inherits whatever body resolved. The surface therefore also sets the base ink on its own content slot, inside the scope, so unclassed markup inherits paint ink.
Wiring a container is then mechanical and identical in all three cases: wrap the internals in PaintedSurface, delete the container's own bg/boxShadow/borderRadius, and move its padding to a new paint slot passed through as className. No descendant is touched. Tooltip additionally selects a new chip intensity, because a popup shorter than the paint's displacement and smaller than its raster tiles needs different numbers, not different code.
No container passes seed, variant or per-group paint overrides beyond the ones chip bundles, so every instance of a given container composes an identical background string — one browser raster shared across all of them, however many render (R10). That is a rule about what the wiring omits, which is why it appears here rather than as a line of code.
The spike code currently in the worktree is reverted first. Nothing from discovery survives as code — only its numbers, as preset values and token values.
Architecture
A painted container is three nested boxes, and the scope boundary is the outermost one:
<Dialog.Popup className={classes.popup}> positioning only — fixed, centred, sized
<PaintedSurface theme="Bark" ← the theme scope starts here
className={classes.paint}> padding, max-width
root: --paint-* ×4 explicit opt-in (unchanged, spec 024)
--colors-* ×6 implicit path — descendants re-tint through
drop-shadow, position: relative the tokens they already use
paint: the composited wash, inset 0 aria-hidden, carries the torn-edge filter
content: color: var(--paint-text) so unclassed children inherit ink
<Dialog.Title className={classes.title}> unchanged — asks for text.strong, gets paint ink
{children} unchanged — whatever the caller passes
</PaintedSurface>
</Dialog.Popup>Dependencies run one way: panda.config.ts defines the tokens, paintedSurfaceRecipe.ts consumes them and defines the scope, the three containers consume the scope. Nothing in features/ changes.
Dialog and Tooltip render through portals, so they inherit no scope from their opener and each carries its own — which is why the theme is a constant in the component rather than a prop threaded from a call site.
Tech stack
No new dependency. Everything is already pinned:
- Panda CSS 1.11.5 —
defineSlotRecipe, token re-pointing via custom properties,staticCss. - React 19.2.8 with the React Compiler (
panicThreshold: 'all_errors') — no hand-written memoization, no destructured-parameter defaults. - Base UI 1.6.0 —
Dialog/Tooltipprimitives, unchanged. - react-router 8.3.0 —
MemoryRouter, needed as a story decorator forConnectAccountPrompt(research F2). - Storybook 10.5.8, Vitest 4.1.10 (jsdom), Vite 8.1.5.
Global Constraints
Copied verbatim from the architecture docs. Every task inherits these; do not summarise or reword them — a test asserts they appear here unchanged.
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.- TypeScript's default
libincludes DOM. A base tsconfig shared by non-DOM and DOM profiles must pin a non-DOM floor explicitly ("lib": ["ES2023"]), ordocument/windowresolve everywhere, silently defeating profile isolation. A profile that needs DOM adds"DOM","DOM.Iterable"on top of that floor. experimentalDecoratorsandemitDecoratorMetadataare supported for typecheck only —tsc --noEmitaccepts decorator syntax and resolves metadata types, but--noEmitnever emitsdesign:paramtypesat runtime regardless of these options. A decorator-consuming runtime (e.g. NestJS DI) needs a separate emitting compiler for that metadata; seestack.md's api build model.
From docs/architecture/stack.md:
- Monorepo, pnpm workspaces.
apps/api— NestJS (TypeScript).apps/web— React (TypeScript).packages/*— shared code (domain types, the curated Mystic Forge dataset) when sharing is real, not speculative.- Postgres for persistence. In-memory cache for the MVP — no Redis until the caching story earns it.
- Vitest everywhere, both apps.
- Deploy: managed PaaS (Fly.io / Railway). CI: GitHub Actions — lint + typecheck + test + build.
- Static data (items, station recipes) is immutable: cache hard.
- Prices are volatile: short TTL, recomputed live.
- GW2 API rate limit: per-IP token bucket, 300 burst, refill 5/sec,
429on overflow. Batch up to 200 ids per?ids=call. - All GW2 API access goes through the client that budgets this. Never call the GW2 API directly from a service.
- API keys are user secrets: never logged, never persisted server-side, never returned to the client. In the MVP the key is held client-side — the browser's
localStorage— and sent per request asAuthorization: Bearer; the api forwards it to GW2 and stores nothing at rest. Encryption at rest applies only if/when server-side key storage is introduced; no such storage exists today. Client-sidelocalStorageis plaintext and readable by any script on the origin (XSS) — a deliberate MVP limitation, established by spec 016 (client-custody, revisit before adding write-scoped or higher-value keys).
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
Exact paths, and what each file is responsible for. A path here is a commitment; a task that touches a file not listed is a signal the plan missed something.
| Path | Change | Responsibility |
|---|---|---|
apps/web/panda.config.ts | modified | adds paint.fill / paint.fillMuted beside paint.shadow / paint.glow — shared translucent fills, no per-theme values |
apps/web/src/shared/ui/paint/paintedSurfaceRecipe.ts | modified | the theme scope: six --colors-* re-points per theme variant, plus base ink on the content slot |
apps/web/src/shared/ui/paint/paint.ts | modified | chip added to PRESETS; Intensity gains 'chip' |
apps/web/src/shared/ui/dialogRecipe.ts | modified | popup becomes positioning-only; new paint slot carries padding and max-width; loses bg, boxShadow, borderRadius |
apps/web/src/shared/ui/Dialog.tsx | modified | wraps its internals in PaintedSurface; THEME constant |
apps/web/src/shared/ui/tooltipRecipe.ts | modified | popup keeps maxW; new paint slot carries padding and font size; loses bg, color, boxShadow, borderRadius |
apps/web/src/shared/ui/Tooltip.tsx | modified | wraps its popup content in PaintedSurface at intensity="chip"; THEME constant |
apps/web/src/shared/ui/ConnectAccountPrompt.tsx | modified | wraps its block in PaintedSurface (panel preset); THEME constant |
apps/web/src/shared/ui/styles.ts | modified | promptPaintStyles — the prompt's padding, passed to the surface |
apps/web/src/shared/ui/ConnectAccountPrompt.stories.tsx | new | first story for the prompt; MemoryRouter decorator (research F2) |
apps/web/src/shared/ui/paint/__tests__/paintedSurfaceRecipe.test.ts | new | asserts the scope: six roles re-pointed for every theme, and only those six |
apps/web/src/shared/ui/paint/__tests__/paint.test.ts | modified | chip preset assertions |
apps/web/src/shared/ui/__tests__/Dialog.test.tsx | modified | painted root; existing behaviour untouched |
apps/web/src/shared/ui/__tests__/Tooltip.test.tsx | modified | painted root |
apps/web/src/shared/ui/__tests__/ConnectAccountPrompt.test.tsx | modified | painted root; message and link unchanged |
apps/web/src/__tests__/tokens.test.ts | modified | paint.fill / paint.fillMuted exist as tokens |
docs/architecture/design-system.md | modified | R14: the theme-scope rule, no nesting, portals, square edges, fixed seeds, the chrome-density caveat |
docs/gaps/storybook-env.md | new | research F1, recorded rather than fixed — see Alternatives |
apps/web/src/shared/ui/paint/PaintedTooltip.spike.stories.tsx | deleted | spike |
apps/web/src/shared/ui/paint/PaintedDialog.spike.stories.tsx | deleted | spike |
apps/web/src/shared/ui/paint/ThemeScope.spike.stories.tsx | deleted | spike |
apps/web/src/shared/ui/ConnectAccountPrompt.spike.stories.tsx | deleted | spike |
apps/web/src/shared/ui/paint/styles.ts | modified | spike classes removed; back to the recipe re-export |
Data & contracts
No HTTP contract changes — apps/api/openapi.json is untouched and pnpm verify:contract stays green.
Two type-level changes, both additive:
// paint.ts
export type Intensity = 'restrained' | 'screenshot' | 'heavy' | 'chip';
// panda.config.ts — tokens.colors.paint, the values discovery confirmed (research V2)
fill: { value: 'rgba(0,0,0,0.22)' };
fillMuted: { value: 'rgba(0,0,0,0.12)' };The public props of Dialog, Tooltip and ConnectAccountPrompt do not change. Every existing caller compiles and behaves identically; only their rendering does.
Test strategy
The spec's criteria split cleanly into what a machine can assert and what it cannot.
Asserted by tests. The scope is a data structure before it is CSS, so it is tested as one — the same call the repo already makes for paintThemes (paintThemes.test.ts). A new paintedSurfaceRecipe.test.ts imports the recipe definition and asserts, for each of the five themes, that the root slot declares all six --colors-* re-points with that theme's values — and that the set of re-pointed properties is exactly those six. That second assertion is the one that protects P1 #3: if someone later adds --colors-rarity-legendary or --colors-surface, the test fails rather than the rarity palette silently drifting inside painted panels.
Container tests assert the wiring, not the look: each of the three renders a painted-surface__root element (queried from document.body for the two portalled ones), and every existing behavioural test in those files must still pass untouched — that is what proves "public props unchanged" (SC3). chip is asserted in paint.test.ts as data: tear.scale === 0 and a bloom size below the panel presets'. tokens.test.ts gains the two new tokens, following its existing "assert against the generated tokens.d.ts" pattern.
Not asserted, and why. Whether the paint looks right — SC1's "renders correctly on the paint" — is human review in Storybook. jsdom rasterises no SVG filter and there is no browser runner in this repo (spec, Out of scope). Discovery already collected those verdicts (research V2, F4); implementation re-checks them once on the real components. Writing a proxy test that asserts a class name and calling it proof of appearance would be worse than admitting the gap.
Regression risk covered deliberately. The literal-colour guard and the token guard already run over everything; the new tokens are declared in panda.config.ts, which the guard does not scan, and the recipe references them by token name, so no colour literal enters src.
Alternatives considered
- Per-theme fill tokens (
paint.<theme>.fill, 10 tokens) — rejected: research V2 confirmed two shared translucent darkenings read over all five washes, and per-theme values would be ten decisions to make and maintain for no observed benefit. - Ink-only scope, deleting each child's
bg— rejected: it makes children know they are inside paint, which is the exact cost this plan exists to remove. - A
render/asChildmerge instead of a wrapper element — rejected: it exists to paint elements you do not control, and all three containers here are ours. The extra wrapper div is free. - Fixing the Storybook env gap (research F1) in this spec — rejected: none of the three containers reaches
src/api, so nothing here needs it. It goes todocs/gaps/as a record, per the project's rule that findings outside the current spec are recorded rather than opportunistically fixed. - Painting
LegendaryTree— cut during discovery on evidence (research F3); see the spec's Out of scope.
Risks
- A re-pointed role has a consumer nobody looked at.
--colors-primaryalso drives_focusVisibleoutlines inbuttonRecipe/inputRecipe; inside a painted container those become the paint accent. That is intended, but it is the kind of change that shows up somewhere unexpected. Mitigation: the three containers are the only painted scopes, and noButton/Inputrenders inside any of them today — verified during design. staticCssand runtime variants. The theme is a constant in each component, so Panda scans it as a literal; the existingstaticCssentry covers all five themes regardless. Mitigation: none needed, but the rule stays documented (R14) because the next painted container may pass a variable.- Removing
borderRadiusfromDialog/Tooltipis a visible change to components with existing tests. Mitigation: those tests assert behaviour (roles, focus, escape, accessible name), not geometry; they must pass unmodified, and that is itself the check. - The prompt is painted at all five of its call sites at once. Mitigation: it takes the panel preset (research F4) and its own padding; the pages themselves are untouched, so a regression is confined to the prompt's own box.
Open questions
None blocking. Both [NEEDS VERIFICATION] markers are resolved in research.md (V2 confirmed; the tree boundary question left the spec with the tree). The exact alpha values for paint.fill / paint.fillMuted are carried over from discovery and may be nudged during the human's Storybook review — a token value change, not a design change.