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.tsis the file needing the exemption. - True (F1): the guard matches only
#hex/rgb(/hsl(; the numeric channels match none of them, sopaintThemes.tspasses untouched. The builderpaint.ts, which assemblesrgba(…)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 thatpaintThemes.tsneeds no exemption. The Option-B approach itself is unchanged — plain colours are stillpaint.<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 intospec.mdbefore 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 numericpaintThemes.ts, the singlepaint.tsguard 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-
rangepattern for grouped props →design-system.md's Storybook section.