Skip to content

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

  1. Given a painted container, when a descendant styles itself with text.strong, text.muted, primary or border, then it renders in that theme's ink, accent or hair — with no change to the descendant's own source.
  2. Given a painted container, when a descendant styles itself with card or muted, then it renders as a translucent fill over the paint rather than the app's opaque surface colour.
  3. 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.
  4. 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.
  5. Given Dialog and Tooltip, when they render, then the paint provides their background, shadow and edge, their own bg/boxShadow/borderRadius rules 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

  1. 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.
  2. 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 — PaintedSurface re-points the semantic colour tokens for its subtree. The scope is exactly:

    RoleRe-pointed to
    text.strongthe theme's textStrong
    text.mutedthe theme's textDim
    primarythe theme's accent
    borderthe theme's hair
    cardpaint.fill
    mutedpaint.fillMuted

    rarity.* and surface are never re-pointed: rarity is domain vocabulary that must read identically everywhere, and surface is the page ground, which by definition lies outside every painted container.

  • R2 — paint.fill and paint.fillMuted are shared, theme-independent tokens, in the same category as the existing paint.shadow and paint.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 took LegendaryTree out of scope (research F3).

  • R3 — The surface publishes its base ink to its own content, so unclassed markup inherits it. color inherits 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 use Bark in this spec.
  • R6 — Wiring a container to paint consists of wrapping its internals in PaintedSurface and removing that container's own bg, boxShadow and borderRadius — 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.popup loses borderRadius: 'lg' and tooltipRecipe.popup loses borderRadius: '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 — Tooltip uses a new chip entry in PRESETS, a fourth intensity alongside restrained/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. intensity is an object lookup, not a recipe variant, so a new preset emits no CSS and needs no staticCss entry.
  • 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 — ConnectAccountPrompt gains its first story. Dialog and Tooltip keep 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 LegendaryTree boundary spike that took the tree out of scope. All spike stories and their fixtures are deleted before the branch merges; their findings live in research.md. Tuned parameters that survive do so as preset values or component constants, not as spike code.

Documentation

  • R14 — design-system.md gains 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; chip exists as an intensity and its parameters differ from the panel presets; the literal-colour guard stays scoped to paint.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, Tooltip and ConnectAccountPrompt compiles 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/src outside paint.ts, spike stories included, and no spike story or fixture survives the merge.
  • SC8 — pnpm typecheck, pnpm test, pnpm lint, pnpm build, pnpm docs:build and pnpm verify:contract are green, and Storybook shows all three containers painted.

Out of scope ​

  • Painted controls. Button and Input keep 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. surface stays flat. Painted panels need an unpainted ground to read against; painting the shell would remove every panel's silhouette.
  • Cards. LegendarySummary and 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 _osDark condition 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 theme prop. 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, the paint.<theme>.* ink tokens and the paint.ts guard 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.strong during discovery of this spec's design; research.md confirms it for every role in R1.
  • Dialog and Tooltip have 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, with paint.ts the single scoped exemption.

Traceability ​

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

CriterionTest
P1 #1paintedSurfaceRecipe.test.ts — "R1/P1 #1-#2: every theme re-points the six semantic roles"
P1 #2paintedSurfaceRecipe.test.ts — "R1/P1 #1-#2: every theme re-points the six semantic roles" (the card/muted → paint.fill/paint.fillMuted assertions)
P1 #3paintedSurfaceRecipe.test.ts — "P1 #3/SC2: the scope re-points exactly six roles — rarity and surface are never among them"
P1 #4paintedSurfaceRecipe.test.ts — "R3/P1 #4: unclassed children inherit the theme ink from the content slot"
P1 #5Dialog.test.tsx — "025 R5/P1 #5: the popup is a painted surface", plus the three pre-existing tests passing unmodified
P2 #1ConnectAccountPrompt.test.tsx — "025 P2 #1: the prompt renders as a painted surface"
P2 #2Source review — ConnectAccountPrompt.tsx names no colour and no paint parameter; promptStyles/promptLinkStyles unchanged in the diff
SC1Human review (Storybook) — jsdom rasterises no SVG filter; the wiring half is the container tests above
SC2Same as P1 #3: paintedSurfaceRecipe.test.ts — "P1 #3/SC2: the scope re-points exactly six roles — rarity and surface are never among them"
SC3Dialog.test.tsx, Tooltip.test.tsx, ConnectAccountPrompt.test.tsx — every pre-existing test passes unmodified; the five prompt call-site tests untouched
SC4Diff review — no descendant styling changed; the only deletions are the containers’ own bg/boxShadow/borderRadius
SC5dialogRecipe.ts / tooltipRecipe.ts carry no borderRadius, bg or boxShadow (diff review)
SC7conventions.test.ts — "P4 #2/SC4: no literal colour values"; git log --stat shows no spike file on the branch
SC8Full command set at Step 5