Skip to content

Research 024 — Painted surface ​

Status: complete

Step 1.5 output, written between the spec draft and the approval gate. One entry per [NEEDS VERIFICATION] marker in spec.md (V1–V5), plus findings discovered alongside (F).

Verified on 2026-08-18 against the worktree at branch 024-painted-surface, with: React 19.2.8, @pandacss/dev 1.11.5, Storybook / @storybook/react-vite 10.5.8, babel-plugin-react-compiler 1.0.0, Vitest 4.1.10. Reference file: ~/Downloads/painted-surface-themed.jsx (line numbers below cite it).

V1 — Can the literal-colour guard be exempted for one file while still failing on a literal elsewhere? (R6) ​

Question. R6 wants a single scoped exemption from the literal-colour guard so the paint layer can exist. Is the guard structured to allow a one-path exemption without blunting it everywhere else?

Verdict. Confirmed — but the exempted file is the builder, not the data (see F1). A one-path exemption mirrors the guard's own SELF exclusion and leaves its teeth intact everywhere else.

Evidence. apps/web/src/__tests__/conventions.test.ts builds repoFiles from walk(webSrc) over .tsx? files (lines 13-25), already excluding one path by identity — SELF (lines 11, 24). The literal-colour assertion is literalColours(repoFiles) (line 104) using /#[0-9a-fA-F]{3,8}\b|\brgba?\(|\bhsla?\(/ (line 63). A second constant (const PAINT_BUILDER = join(webSrc, 'shared', 'ui', 'paint', 'paint.ts')) filtered into only that assertion (literalColours(repoFiles.filter((f) => f.path !== PAINT_BUILDER))) is the whole change. The existing "the rule catches a violation" test (lines 107-116) already proves a literal in any other file still fails, so the exemption cannot silently disarm the guard.

Caveat. Exempting a logic file (not pure data) means a stray hardcoded hex could hide inside paint.ts. Mitigated by keeping paint.ts to string-assembly of channels sourced from paintThemes.ts, and by review — the same "documented, not machine-enforced" honesty the closed-component boundary uses.

F1 — The file that trips the guard is paint.ts, not paintThemes.ts ​

Finding. The guard regex matches only #hex, rgb(, hsl(. The channel data is bare numbers — wash: [38,24,13], macroDark: "8,5,2", bloomDark: [0.075,0.045,0.025] (reference lines 12-25) — with no #, rgb( or hsl(, so paintThemes.ts passes the guard unmodified and needs no exemption. What trips the guard is the builder emitting CSS colour-function strings: the macro gradients radial-gradient(…, rgba(${macroDark},.62), …) (line 145), the wash linear-gradient(rgba(${wash},${op}),…) (line 182), and the inset pooling rgba(${pool},…) (line 213) — each carries the literal substring rgba(. Those live in paint.ts, so paint.ts is the file to exempt.

Why it matters. Corrects R6 and SC4: the exempted file is paint.ts, and the reason is emitted colour-function strings, not the data values. A knock-on constraint: the component .tsx and the recipe .ts must emit no colour strings of their own, or they trip the guard too — so the reference's inline drop-shadow rgba(0,0,0,.36) (line 194) and the inset pooling (line 213) must move into paint.ts (or become a Panda shadow token in the recipe). All runtime colour-string assembly concentrates in the one exempted builder; everything else references tokens. See Refuted claims.

V2 — Can a Panda recipe variant set --paint-* custom properties from paint.<theme> tokens? (R7) ​

Question. R7 wires each theme's ink into CSS custom properties via the recipe's theme variant. Does Panda resolve colors.paint.<theme>.<role> tokens into a custom-property value inside a config recipe?

Verdict. Confirmed — Panda resolves {colors.…} references in any value, custom properties included; the repo already depends on this.

Evidence. Tokens nest arbitrarily (colors.rarity.fine, panda.config.ts:29), so colors.paint.bark.text is a valid token path. Panda's curly token-reference syntax resolves anywhere in a value and is already load-bearing here — every semantic token uses it ({colors.gray.900}, panda.config.ts:43,47,51). A recipe style object takes any CSS property as a key; a known-colour property uses the bare token path (color: 'text.strong', borderColor: 'border', inputRecipe.ts:11,18), while a custom property — which Panda can't type as a colour — takes the reference form '--paint-text': '{colors.paint.bark.text}', which Panda compiles to var(--colors-paint-bark-text). So the theme variant emits the --paint-* block and the corner-tick borderColor: 'paint.<theme>.hair'.

Caveat. Confirmed against Panda 1.11.5's documented reference behaviour plus the repo's existing use, not a fresh panda cssgen of this exact recipe — that codegen check lands when the recipe is written (Step 5). Low risk; the mechanism is already in production in this config.

V3 — Does the un-memoized component compile clean under the React Compiler? (R9) ​

Question. R9 strips the reference's useMemo and moves destructured defaults to the body. Does the result clear panicThreshold: 'all_errors'?

Verdict. Confirmed by construction — both documented bail-out triggers are removed and no other remains; the Step-5 build is the definitive check.

Evidence. react.md records the only two triggers this repo has hit under panicThreshold:'all_errors': a hand-written useMemo/useCallback/memo, and a destructured-parameter default (BuildHIR::lowerAssignment, react.md:186-195). R9 removes both — the reference's single useMemo (line 175) is deleted (the compiler auto-memoizes the bg computation) and every destructured default (theme="Bark", the group defaults, lines 157-165) moves into the body. The residual code — JSX, an inline style, .map() over the corner array, .filter() over the layer arrays, a pure-builder call, useId() — carries no compiler-hostile construct. design-system.md records the Storybook Vite build runs the compiler too, and the story (P2) is the module-graph entry that makes the component compiler-visible, so pnpm build / the Storybook build is authoritative at Step 5 (babel-plugin-react-compiler 1.0.0).

Caveat. "By construction" — the component does not exist yet, so this is not a run. Unlike spec 019's compiler question (unknown Base UI render-prop patterns, which justified a spike), nothing exotic remains to rule out here, so a spike buys little; the Step-5 build settles it.

V4 — A url(#…)-valid, render-stable, per-instance filter id (R10) ​

Question. Each instance needs a unique displacement-filter id usable in url(#…). The reference's fid (theme+seed+tear+variant, line 171) is not unique — two panels with equal props collide. Does useId() solve it, and is its output valid in a fragment reference?

Verdict. Confirmed — useId() with colons stripped, prefixed so it never starts with a digit.

Evidence. React 19.2.8's useId() returns colon-wrapped ids (e.g. :r0:); React's own docs caveat that these contain the : token and are "not supported ... in CSS selectors or APIs like querySelectorAll." An SVG filter="url(#id)" resolves the fragment against the element id, and colon-bearing ids are unreliable there across engines. `paint-${useId().replace(/:/g, '')}` yields an id that is unique per component instance, stable across renders (and SSR/CSR-consistent), and valid as both an id attribute and a url(#…) fragment. This replaces the reference's non-unique fid.

Caveat. The prefix (paint-) is load-bearing: a stripped id could otherwise begin with a digit, which is invalid for an id/selector.

V5 — Can Storybook drive the grouped paint parameters as live sliders? (R11) ​

Question. P2 wants every paint parameter as a slider "like the jsx file". The props are grouped objects (blooms: {density,size,…}); Storybook args are usually flat scalars. Can the grouped params be sliders?

Verdict. Confirmed — flatten each parameter to a range argType and reassemble in the story's render.

Evidence. Storybook 10.5.8 controls include { type: 'range', min, max, step }, which renders a slider — the reference's exact tuning UX. The existing stories drive only flat scalar args (Button.stories.tsx:7-14). A grouped prop can be exposed two ways: control: 'object' per group (a JSON editor, not sliders) or — the choice here — flatten each field to a top-level range arg (bloomsDensity, bloomsSize, …) with a render that maps the flat args back into the grouped props before passing them to `<PaintedSurface>`. The theme control lists the five public themes.

Caveat. Flattening puts the flat→grouped mapping in the story's render; the component's public prop shape stays the grouped objects (R1). The story imports no styled-system (R12).

Refuted claims ​

R6 / SC4 — "paintThemes.ts is the exempted file, because the channels are literals."

  • Believed: the channel data trips the literal-colour guard, so paintThemes.ts is the file needing the exemption.
  • True (F1): the guard matches only #hex/rgb(/hsl(; the numeric channels match none of them, so paintThemes.ts passes untouched. The builder paint.ts, which assembles rgba(…) gradient and shadow strings, is what trips the guard and needs the exemption.
  • Change to the spec: R6 and SC4 should name paint.ts (the builder that emits colour strings) as the single exempted file, and state that paintThemes.ts needs no exemption. The Option-B approach itself is unchanged — plain colours are still paint.<theme> tokens, channel math is still numeric data — so this refines the requirement rather than sending the whole spec back to step 1. Awaiting the human's nod to fold the wording fix into spec.md before approval.

Graduation ​

Findings that outlive this feature, for docs/architecture/ at step 6:

  • The paint-theme representation → design-system.md: the Option-B split, paint.<theme> tokens vs numeric paintThemes.ts, the single paint.ts guard exemption and why (emitted colour strings, not data), and that paint themes are content-flavour (no _osDark).
  • useId() colons stripped for SVG filter ids → react.md (a compiler/rendering convention the next SVG-heavy component will need).
  • Storybook flatten-to-range pattern for grouped props → design-system.md's Storybook section.