Skip to content

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/Tooltip primitives, unchanged.
  • react-router 8.3.0 — MemoryRouter, needed as a story decorator for ConnectAccountPrompt (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. Use unknown plus 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-error without 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" and baseUrl are removed in TS 7. Use "nodenext" (or "bundler" for Vite/bundler-resolved code) plus paths: { "*": ["./*"] } in place of baseUrl.
  • TypeScript's default lib includes DOM. A base tsconfig shared by non-DOM and DOM profiles must pin a non-DOM floor explicitly ("lib": ["ES2023"]), or document/window resolve everywhere, silently defeating profile isolation. A profile that needs DOM adds "DOM", "DOM.Iterable" on top of that floor.
  • experimentalDecorators and emitDecoratorMetadata are supported for typecheck only — tsc --noEmit accepts decorator syntax and resolves metadata types, but --noEmit never emits design:paramtypes at runtime regardless of these options. A decorator-consuming runtime (e.g. NestJS DI) needs a separate emitting compiler for that metadata; see stack.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, 429 on 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 as Authorization: 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-side localStorage is 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.

PathChangeResponsibility
apps/web/panda.config.tsmodifiedadds paint.fill / paint.fillMuted beside paint.shadow / paint.glow — shared translucent fills, no per-theme values
apps/web/src/shared/ui/paint/paintedSurfaceRecipe.tsmodifiedthe theme scope: six --colors-* re-points per theme variant, plus base ink on the content slot
apps/web/src/shared/ui/paint/paint.tsmodifiedchip added to PRESETS; Intensity gains 'chip'
apps/web/src/shared/ui/dialogRecipe.tsmodifiedpopup becomes positioning-only; new paint slot carries padding and max-width; loses bg, boxShadow, borderRadius
apps/web/src/shared/ui/Dialog.tsxmodifiedwraps its internals in PaintedSurface; THEME constant
apps/web/src/shared/ui/tooltipRecipe.tsmodifiedpopup keeps maxW; new paint slot carries padding and font size; loses bg, color, boxShadow, borderRadius
apps/web/src/shared/ui/Tooltip.tsxmodifiedwraps its popup content in PaintedSurface at intensity="chip"; THEME constant
apps/web/src/shared/ui/ConnectAccountPrompt.tsxmodifiedwraps its block in PaintedSurface (panel preset); THEME constant
apps/web/src/shared/ui/styles.tsmodifiedpromptPaintStyles — the prompt's padding, passed to the surface
apps/web/src/shared/ui/ConnectAccountPrompt.stories.tsxnewfirst story for the prompt; MemoryRouter decorator (research F2)
apps/web/src/shared/ui/paint/__tests__/paintedSurfaceRecipe.test.tsnewasserts the scope: six roles re-pointed for every theme, and only those six
apps/web/src/shared/ui/paint/__tests__/paint.test.tsmodifiedchip preset assertions
apps/web/src/shared/ui/__tests__/Dialog.test.tsxmodifiedpainted root; existing behaviour untouched
apps/web/src/shared/ui/__tests__/Tooltip.test.tsxmodifiedpainted root
apps/web/src/shared/ui/__tests__/ConnectAccountPrompt.test.tsxmodifiedpainted root; message and link unchanged
apps/web/src/__tests__/tokens.test.tsmodifiedpaint.fill / paint.fillMuted exist as tokens
docs/architecture/design-system.mdmodifiedR14: the theme-scope rule, no nesting, portals, square edges, fixed seeds, the chrome-density caveat
docs/gaps/storybook-env.mdnewresearch F1, recorded rather than fixed — see Alternatives
apps/web/src/shared/ui/paint/PaintedTooltip.spike.stories.tsxdeletedspike
apps/web/src/shared/ui/paint/PaintedDialog.spike.stories.tsxdeletedspike
apps/web/src/shared/ui/paint/ThemeScope.spike.stories.tsxdeletedspike
apps/web/src/shared/ui/ConnectAccountPrompt.spike.stories.tsxdeletedspike
apps/web/src/shared/ui/paint/styles.tsmodifiedspike 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:

ts
// 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/asChild merge 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 to docs/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-primary also drives _focusVisible outlines in buttonRecipe/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 no Button/Input renders inside any of them today — verified during design.
  • staticCss and runtime variants. The theme is a constant in each component, so Panda scans it as a literal; the existing staticCss entry covers all five themes regardless. Mitigation: none needed, but the rule stays documented (R14) because the next painted container may pass a variable.
  • Removing borderRadius from Dialog/Tooltip is 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.