Skip to content

Research 025 — Painterly containers ​

Status: complete Step 1.5 output, written between the spec draft and the approval gate. open until every [NEEDS VERIFICATION] marker in spec.md has a verdict here; complete once they do.

Verified against: branch 025-painterly-containers at c8333e1, on 2026-08-19. React 19.2.8 with the React Compiler, Panda CSS 1.11.5, Storybook 10.5.8 (@storybook/react-vite), Vitest 4.1.10 (jsdom), Vite 8.1.5. Visual verdicts are human review in Storybook — jsdom rasterises no SVG filter and this repo has no browser test runner, so nothing below claims a machine checked a colour.

V1 — Does every role in R1's scope compile to a custom property a painted container can re-point? ​

Question. The whole spec rests on Panda emitting var(--colors-<role>) for a semantic token reference, so re-declaring that property on a painted root re-tints every descendant that uses the token. R1 names six roles and two that must not move. If any role compiled to a literal instead, the mechanism would silently skip it.

Verdict. Confirmed — all six re-pointed roles resolve through their own custom property, and rarity.* resolves through a different one that no override names.

Evidence. apps/web/styled-system/tokens/index.mjs, generated by panda codegen:

Tokenvariable in the generated map
colors.text.strongvar(--colors-text-strong) (:918)
colors.text.mutedvar(--colors-text-muted) (:1874)
colors.cardvar(--colors-card) (:1878)
colors.bordervar(--colors-border) (:1882)
colors.mutedvar(--colors-muted) (:1886)
colors.primaryvar(--colors-primary) (:1890)
colors.rarity.legendaryvar(--colors-rarity-legendary) (:1862)

And the emitted rule, from npx panda cssgen:

.painted-surface__root--theme_Verdant {
  --paint-text: var(--colors-paint--verdant-text);
  --paint-text-strong: var(--colors-paint--verdant-text-strong);
  --paint-accent: var(--colors-paint--verdant-accent);
  --paint-hair: var(--colors-paint--verdant-hair);
  --colors-text-strong: var(--colors-paint--verdant-text-strong);
  --colors-text-muted: var(--colors-paint--verdant-text-dim);
  --colors-primary: var(--colors-paint--verdant-accent);
  --colors-border: var(--colors-paint--verdant-hair);
  --colors-card: var(--colors-paint-fill);
  --colors-muted: var(--colors-paint-fill-muted);
}

Caveat. This confirms the mechanism, not that every descendant re-tints. color inherits its computed value, so an element that sets no colour of its own inherits whatever body resolved and never consults the override — which is why R3 requires the surface to set the base ink on its own content. That was reproduced during design: unclassed markup stayed dark until the content slot set color.

V2 — Do two shared translucent fills read correctly over all five theme washes? (spec R2) ​

Question. R2 claims card and muted can be re-pointed to shared, theme-independent translucent overlays rather than a fill colour per theme — 2 tokens instead of 10. That holds only if a darkening reads as "a sub-panel of this paint" over Bark, Verdant, Deep, Ember and Ash alike.

Verdict. Confirmed at paint.fill = 22% black and paint.fillMuted = 12% black. No theme needed its own value; the spec keeps two shared tokens.

Evidence. Spike story paint/spike-ThemeScope → EveryTheme: all five washes side by side, each wrapping an identical sample that names only app tokens (card, muted, border, text.strong, text.muted, primary, rarity.legendary) and nothing about paint. Story Unpainted is the same sample with no surface, as the control. Human review, 2026-08-19: the fills read as darker patches of the same paint in all five, and rarity.legendary is unchanged between the two stories.

Caveat. Reviewed at one content density — a short block of copy in a ~20rem column. The one place a denser layout was tried (the crafting tree, F3) is where the treatment stopped working, so this verdict should not be read as "fills work at any density".

F1 — Storybook has no environment configuration, and any story reaching src/api dies on import ​

apps/web/src/api/apiBase.ts:16 evaluates resolveApiOrigin(import.meta.env.VITE_APP_ENV) at module load and throws Invalid VITE_APP_ENV: undefined for anything but dev/prod. apps/web/vitest.config.ts:25 supplies it for tests (env: { VITE_APP_ENV: 'dev' }); .storybook/main.ts supplies nothing, and there is no .env file. Any story that reaches the api barrel — directly, or transitively through a provider, as the tree spike did via OwnVsNeedProvider — fails to render.

Worked around during discovery by starting Storybook as VITE_APP_ENV=dev pnpm storybook, which is not a fix: it only works when the person starting the server remembers.

Why it matters. None of 025's three containers reaches src/api, so this blocks nothing here. It blocks the next feature story, and the fix belongs in .storybook/main.ts (a viteFinal define) rather than in a shell. Recorded so it is a known gap rather than a surprise.

F2 — The ConnectAccountPrompt story needs a router decorator ​

The prompt renders a react-router Link, which throws outside a router context. Its spike story wraps the render in MemoryRouter, and the shipping story R12 asks for needs the same decorator.

Why it matters. R12 is a task in the plan; without this it is a task that fails on first run for a reason unrelated to paint.

F3 — LegendaryTree was cut from the spec on evidence ​

Both candidate boundaries were built and reviewed rather than argued about: OneSurface (one painted panel around the whole tree, gift cards becoming translucent fills on it) and SurfacePerBranch (a painted panel per top-level gift, with the gift card's own box — margin, border, radius, fill — stripped so the paint replaces it rather than sitting around it, which is what a real implementation would do inside GiftCard). A third story, Hairlines, compared three treatments of the tree's row rules: the theme's hair as-is, a softer darkening (paint.fillMuted), and no row rules at all.

Human verdict, 2026-08-19: OneSurface is the only boundary that reads, and it is still visually heavy. The reviewer described the heaviness as hard to articulate and did not attribute it to rendering cost — no performance measurement was taken, and none is claimed here.

The contributing factor that is identifiable: the tree carries three independent kinds of chrome — a rule under every row (rowStyles), a box around every gift (giftCardStyles), and a rarity-coloured outline around every decision badge (decisionBadgeStyles, which the scope deliberately never re-points). Over a wash that reads as competing textures, and the badge outlines cannot be fixed by any token or scope change without breaking the rule that rarity colours are identical everywhere.

Why it matters. The tree is out of 025's scope (Out of scope, with this section cited). It is MVP chrome the human expects to redesign, so painting it is blocked on that redesign, not on the paint. The finding that generalises: the theme scope handles a container's own surfaces, but it cannot thin out a component's chrome — a component whose density fights the wash needs its chrome reconsidered first.

F4 — chip is a small-container preset, but the prompt does not want it ​

The tooltip spike established the small-scale parameters (tear: 0, 14px blooms, grain and pooling carrying the texture) because the paint's rasters are 512×384 and 160×160, so an element smaller than a tile shows one flat crop of it. paint/spike-ConnectAccountPrompt offered the same prompt at both scales (Painted vs PaintedChipScale).

Human verdict, 2026-08-19: the prompt ships with the panel preset, not chip. So chip stays what R9 says it is — the preset for elements smaller than a raster tile — and a short block of copy on a page is not one of those. Tooltip remains its only consumer in this spec.

Refuted claims ​

None. The spec's one [NEEDS VERIFICATION] (R2) was confirmed. LegendaryTree's boundary question (R11 in the draft) was not refuted but removed: discovery answered it with "neither, yet" (F3), so the tree left the spec rather than the requirement being patched.

Graduation ​

Findings that outlive this feature, for step 6:

  • The theme-scope rule — a painted container re-points ink, hairline and fill roles for its subtree; domain colours never move; nesting is out. Already required by R14 for design-system.md.
  • The raster-tile scale rule — paint parameters are tied to the 512×384 / 160×160 tiles, so any element smaller than a tile needs the chip treatment. Belongs beside the paint documentation.
  • F1's Storybook env gap — either fixed in .storybook/main.ts during 025 or recorded in docs/gaps/ so the next feature story does not rediscover it.
  • F3's chrome observation — the theme scope cannot thin a component's chrome. Worth a line in design-system.md so the next container to be painted is assessed for density first.