Tasks 024 — Painted surface
Execution skill: superpowers:subagent-driven-development — one implementer per task, then a two-stage review (spec compliance, then code quality). superpowers:test-driven-development applies inside every task: no production code before a failing test that demands it. Reach for superpowers:systematic-debugging on any surprise rather than guessing. Steps use - [ ] for tracking.
Derived from plan.md (approved, 2026-08-18). Each task is small, independently verifiable, and reviewed as its own diff. A task is done only when it satisfies the Definition of Done in CLAUDE.md: typecheck clean, tests pass, the named test traces to its criterion, no any / no unexplained escape hatches, the human has reviewed the diff.
Global Constraints live in plan.md (copied from the architecture docs) and apply to every task — not repeated per task. Load-bearing here: tokens never literals (conventions.test.ts rejects a literal #hex/rgb(/hsl( under src/ — the one exemption this spec adds is shared/ui/paint/paint.ts), no hand-written memoization (the React Compiler owns it), no default on a destructured parameter (apply defaults in the body), no any, and no new dependency.
Conventions (from the existing suite): tests import { describe, it, expect } (and vi where needed) from vitest; render/screen from @testing-library/react; jest-dom matchers via the existing testSetup.ts. After editing panda.config.ts, regenerate with pnpm --filter @gw2priory/web exec panda codegen before running a test that imports a token or recipe. panda.config.ts sits outside src/, so token colour values there are not scanned by the literal-colour guard.
Build order follows plan.md §Approach. Tasks are drawn so a reviewer can accept one without the next.
T1 — Paint palette: tokens + channel data
Satisfies: R2, R5, R6 (data half).
Files:
- Modify:
apps/web/panda.config.ts(addtokens.colors.paint) - Create:
apps/web/src/shared/ui/paint/paintThemes.ts - Modify:
apps/web/src/__tests__/tokens.test.ts - Test:
apps/web/src/shared/ui/paint/__tests__/paintThemes.test.ts
Interfaces produced:
type PaintTheme = 'Bark' | 'Verdant' | 'Deep' | 'Ember' | 'Ash'.type PaintChannelsandconst paintThemes: Record<PaintTheme, PaintChannels>.[ ] RED (tokens): add to
apps/web/src/__tests__/tokens.test.ts:
it('024 R5: every paint theme has its ink tokens', () => {
for (const theme of ['Bark', 'Verdant', 'Deep', 'Ember', 'Ash']) {
for (const role of ['text', 'textStrong', 'textDim', 'accent', 'hair']) {
expect(tokenTypes).toContain(`paint.${theme}.${role}`);
}
}
});- [ ] RED (data): write
apps/web/src/shared/ui/paint/__tests__/paintThemes.test.ts:
import { describe, expect, it } from 'vitest';
import { paintThemes } from '../paintThemes';
describe('024 T1 — paintThemes', () => {
it('R6: carries every theme with the seven channel fields', () => {
for (const theme of ['Bark', 'Verdant', 'Deep', 'Ember', 'Ash'] as const) {
const c = paintThemes[theme];
expect(c.wash).toHaveLength(3);
expect(c.bloomDark).toHaveLength(3);
expect(c.bloomLight).toHaveLength(3);
expect(c.drag).toHaveLength(3);
expect(typeof c.macroDark).toBe('string');
expect(typeof c.macroWarm).toBe('string');
expect(typeof c.pool).toBe('string');
}
});
it('R6: channels match the reference (locks the data against accidental edits)', () => {
expect(paintThemes.Bark.wash).toEqual([38, 24, 13]);
expect(paintThemes.Deep.bloomLight).toEqual([0.4, 0.54, 0.68]);
});
});- [ ] Run both, watch them fail.
pnpm --filter @gw2priory/web exec vitest run src/__tests__/tokens.test.ts src/shared/ui/paint/__tests__/paintThemes.test.tsExpected: FAIL — nopaint.*tokens; nopaintThemesmodule. - [ ] Add the tokens to
apps/web/panda.config.ts, undertheme.extend.tokens.colors(alongsiderarity).paint.shadowis a theme-independent drop-shadow colour; the rest are per-theme ink:
paint: {
shadow: { value: 'rgba(0,0,0,0.36)' },
Bark: { text: { value: '#e2d4b8' }, textStrong: { value: '#f7efdc' }, textDim: { value: '#ac9a7c' }, accent: { value: '#f2a01e' }, hair: { value: 'rgba(214,192,150,0.22)' } },
Verdant: { text: { value: '#d6dfc4' }, textStrong: { value: '#eef4e2' }, textDim: { value: '#9aa886' }, accent: { value: '#9ccf52' }, hair: { value: 'rgba(190,214,160,0.22)' } },
Deep: { text: { value: '#c9d6e2' }, textStrong: { value: '#e8f1f8' }, textDim: { value: '#8496a8' }, accent: { value: '#59b0e0' }, hair: { value: 'rgba(170,200,224,0.22)' } },
Ember: { text: { value: '#e6cbbf' }, textStrong: { value: '#f9e6dd' }, textDim: { value: '#b08d80' }, accent: { value: '#e5563a' }, hair: { value: 'rgba(224,180,160,0.22)' } },
Ash: { text: { value: '#d8d3c8' }, textStrong: { value: '#f2eee6' }, textDim: { value: '#9a958c' }, accent: { value: '#d8c184' }, hair: { value: 'rgba(210,204,190,0.22)' } },
},- [ ] Regenerate.
pnpm --filter @gw2priory/web exec panda codegen—tokens.d.tsnow carries the paths. - [ ] GREEN (data): write
apps/web/src/shared/ui/paint/paintThemes.ts:
// The SVG-math half of each paint theme: raw colour CHANNELS, not tokens — feColorMatrix needs 0–1
// float triplets and gradients need "r,g,b" strings, neither expressible as a hex token. Numeric, so it
// does not trip the literal-colour guard, which matches only hex codes and rgb/hsl function calls (F1).
export type PaintTheme = 'Bark' | 'Verdant' | 'Deep' | 'Ember' | 'Ash';
export type PaintChannels = {
wash: [number, number, number];
macroDark: string;
macroWarm: string;
pool: string;
bloomDark: [number, number, number];
bloomLight: [number, number, number];
drag: [number, number, number];
};
export const paintThemes: Record<PaintTheme, PaintChannels> = {
Bark: { wash: [38, 24, 13], macroDark: '8,5,2', macroWarm: '122,88,48', pool: '12,7,3', bloomDark: [0.075, 0.045, 0.025], bloomLight: [0.62, 0.5, 0.34], drag: [0.06, 0.038, 0.02] },
Verdant: { wash: [18, 33, 21], macroDark: '4,9,5', macroWarm: '78,118,54', pool: '5,12,6', bloomDark: [0.035, 0.065, 0.03], bloomLight: [0.46, 0.6, 0.34], drag: [0.03, 0.055, 0.026] },
Deep: { wash: [13, 21, 36], macroDark: '2,5,11', macroWarm: '52,88,128', pool: '3,7,14', bloomDark: [0.026, 0.042, 0.072], bloomLight: [0.4, 0.54, 0.68], drag: [0.022, 0.036, 0.06] },
Ember: { wash: [42, 16, 14], macroDark: '12,3,2', macroWarm: '138,62,40', pool: '14,4,3', bloomDark: [0.085, 0.03, 0.024], bloomLight: [0.68, 0.42, 0.32], drag: [0.07, 0.026, 0.02] },
Ash: { wash: [30, 28, 26], macroDark: '7,7,6', macroWarm: '96,90,80', pool: '9,9,8', bloomDark: [0.06, 0.057, 0.052], bloomLight: [0.56, 0.54, 0.5], drag: [0.05, 0.048, 0.044] },
};- [ ] Run both, watch them pass.
- [ ] Confirm teeth. Temporarily change
paintThemes.Bark.washto[0,0,0]and watch the reference test fail; restore. - [ ] Commit (
web: paint palette tokens + channel data (024 T1)).
Verified by: 024 T1 — paintThemes › R6: carries every theme… and › R6: channels match the reference; T6 — design tokens › 024 R5: every paint theme has its ink tokens.
T2 — Paint builders (paint.ts) + the guard exemption
Satisfies: R6 (builder half), R8, P1 #3, SC2, SC3.
Files:
- Create:
apps/web/src/shared/ui/paint/paint.ts - Modify:
apps/web/src/__tests__/conventions.test.ts(scoped exemption) - Test:
apps/web/src/shared/ui/paint/__tests__/paint.test.ts
Interfaces produced:
type Wash/Blooms/Drag/Grain/Pool/Tear,type PaintParams,type Intensity,const PRESETS: Record<Intensity, PaintParams>,lerp, andbuildBackground(theme: PaintTheme, params, variant, seed): { background; backgroundSize; backgroundBlendMode; boxShadow }.[ ] RED: write
apps/web/src/shared/ui/paint/__tests__/paint.test.ts:
import { describe, expect, it } from 'vitest';
import { buildBackground, lerp, PRESETS } from '../paint';
const P = PRESETS.screenshot;
describe('024 T2 — paint builders', () => {
it('lerp interpolates channelwise', () => {
expect(lerp([0, 0, 0], [1, 1, 1], 0.5)).toEqual([0.5, 0.5, 0.5]);
});
it('SC2: the theme re-tints the paint — two themes differ in channel values', () => {
const bark = buildBackground('Bark', P, 0, 3);
const deep = buildBackground('Deep', P, 0, 3);
expect(bark.background).not.toBe(deep.background);
expect(bark.background).toContain('8,5,2'); // Bark macroDark
expect(deep.background).toContain('2,5,11'); // Deep macroDark
});
it('SC3: a single-group override changes only that layer', () => {
const base = buildBackground('Bark', P, 0, 3);
const moreBloom = buildBackground('Bark', { ...P, blooms: { ...P.blooms, opacity: 0.9 } }, 0, 3);
expect(moreBloom.background).not.toBe(base.background); // the bloom layer changed…
expect(moreBloom.boxShadow).toBe(base.boxShadow); // …the untouched pool layer did not.
});
it('is deterministic for the same inputs', () => {
expect(buildBackground('Ember', P, 1, 7)).toEqual(buildBackground('Ember', P, 1, 7));
});
});- [ ] Run it, watch it fail.
pnpm --filter @gw2priory/web exec vitest run src/shared/ui/paint/__tests__/paint.test.tsExpected: FAIL — nopaintmodule. - [ ] GREEN: write
apps/web/src/shared/ui/paint/paint.ts:
// The ONE file that assembles CSS colour-function strings (rgba()/gradients) from channel data — so it
// is the single literal-colour-guard exemption (conventions.test.ts PAINT_BUILDER, research F1). Colours
// come from paintThemes.ts; nothing is hardcoded here.
import { type PaintChannels, type PaintTheme, paintThemes } from './paintThemes';
export type Wash = { opacity: number; macro: number };
export type Blooms = { density: number; size: number; hardness: number; opacity: number; lift: number };
export type Drag = { strength: number; angle: number };
export type Grain = { opacity: number };
export type Pool = { strength: number };
export type Tear = { scale: number };
export type PaintParams = { wash: Wash; blooms: Blooms; drag: Drag; grain: Grain; pool: Pool; tear: Tear };
export type Intensity = 'restrained' | 'screenshot' | 'heavy';
export const PRESETS: Record<Intensity, PaintParams> = {
restrained: {
wash: { opacity: 0.84, macro: 1 },
blooms: { density: 0.24, size: 40, hardness: 0.5, opacity: 0.34, lift: 0 },
drag: { strength: 0.16, angle: -6 }, grain: { opacity: 0.38 }, pool: { strength: 0.45 }, tear: { scale: 8 },
},
screenshot: {
wash: { opacity: 0.74, macro: 1 },
blooms: { density: 0.46, size: 52, hardness: 0.78, opacity: 0.62, lift: 0 },
drag: { strength: 0.34, angle: -9 }, grain: { opacity: 0.5 }, pool: { strength: 0.62 }, tear: { scale: 13 },
},
heavy: {
wash: { opacity: 0.62, macro: 1 },
blooms: { density: 0.78, size: 74, hardness: 0.92, opacity: 0.8, lift: 0.15 },
drag: { strength: 0.2, angle: -14 }, grain: { opacity: 0.6 }, pool: { strength: 0.75 }, tear: { scale: 17 },
},
};
const enc = (svg: string) => `url("data:image/svg+xml,${encodeURIComponent(svg)}")`;
export const lerp = (a: readonly number[], b: readonly number[], t: number): number[] =>
a.map((v, i) => Number((v + ((b[i] ?? 0) - v) * t).toFixed(4)));
function bloomSVG(o: { seed: number; size: number; density: number; hardness: number; opacity: number; tint: number[] }) {
const freq = (1 / o.size).toFixed(4);
const table = o.hardness > 0.72 ? '0 0 0 0 1 1 1' : o.hardness > 0.4 ? '0 0 0 .3 .7 1 1' : '0 .1 .28 .5 .72 .88 1';
const k4 = (-(1 - o.density) * 0.62).toFixed(3);
return `<svg xmlns='http://www.w3.org/2000/svg' width='512' height='384'>
<filter id='b' x='0' y='0' width='100%' height='100%' color-interpolation-filters='sRGB'>
<feTurbulence type='fractalNoise' baseFrequency='0.0032' numOctaves='2' seed='${o.seed}' stitchTiles='stitch' result='w'/>
<feTurbulence type='turbulence' baseFrequency='${freq}' numOctaves='3' seed='${o.seed + 5}' stitchTiles='stitch' result='c'/>
<feComponentTransfer in='c' result='h'><feFuncA type='discrete' tableValues='${table}'/></feComponentTransfer>
<feComposite in='h' in2='w' operator='arithmetic' k1='1.55' k2='0' k3='0' k4='${k4}' result='g'/>
<feColorMatrix in='g' type='matrix' values='0 0 0 0 ${o.tint[0]} 0 0 0 0 ${o.tint[1]} 0 0 0 0 ${o.tint[2]} 0 0 0 ${o.opacity} 0'/>
</filter>
<rect width='512' height='384' filter='url(#b)'/></svg>`;
}
function dragSVG(o: { seed: number; strength: number; angle: number; tint: number[] }) {
return `<svg xmlns='http://www.w3.org/2000/svg' width='512' height='384'>
<filter id='d' x='0' y='0' width='100%' height='100%' color-interpolation-filters='sRGB'>
<feTurbulence type='fractalNoise' baseFrequency='0.004 0.075' numOctaves='3' seed='${o.seed}' stitchTiles='stitch' result='t'/>
<feColorMatrix in='t' type='matrix' values='0 0 0 0 ${o.tint[0]} 0 0 0 0 ${o.tint[1]} 0 0 0 0 ${o.tint[2]} 0 0 0 ${o.strength} -0.12'/>
</filter>
<g transform='rotate(${o.angle} 256 192)'><rect x='-160' y='-160' width='832' height='704' filter='url(#d)'/></g></svg>`;
}
const grainSVG = (o: { opacity: number }) =>
`<svg xmlns='http://www.w3.org/2000/svg' width='160' height='160'>
<filter id='g' color-interpolation-filters='sRGB'><feTurbulence type='fractalNoise' baseFrequency='0.82' numOctaves='4' stitchTiles='stitch'/><feColorMatrix type='saturate' values='0'/></filter>
<rect width='160' height='160' filter='url(#g)' opacity='${o.opacity}'/></svg>`;
const macroFor = (v: number, t: PaintChannels): string =>
[
`radial-gradient(72% 58% at 84% 88%, rgba(${t.macroDark},.62), transparent 62%),radial-gradient(46% 40% at 68% 30%, rgba(${t.macroDark},.34), transparent 68%),radial-gradient(58% 50% at 14% 20%, rgba(${t.macroWarm},.30), transparent 72%)`,
`radial-gradient(66% 62% at 16% 86%, rgba(${t.macroDark},.58), transparent 64%),radial-gradient(44% 38% at 30% 26%, rgba(${t.macroDark},.30), transparent 66%),radial-gradient(60% 52% at 86% 24%, rgba(${t.macroWarm},.28), transparent 70%)`,
`radial-gradient(70% 54% at 90% 22%, rgba(${t.macroDark},.56), transparent 60%),radial-gradient(48% 44% at 40% 78%, rgba(${t.macroDark},.36), transparent 66%),radial-gradient(54% 48% at 10% 46%, rgba(${t.macroWarm},.26), transparent 72%)`,
][v] ?? '';
export type PaintBackground = { background: string; backgroundSize: string; backgroundBlendMode: string; boxShadow: string };
export function buildBackground(
theme: PaintTheme,
params: { wash: Wash; blooms: Blooms; drag: Drag; grain: Grain; pool: Pool },
variant: 0 | 1 | 2,
seed: number,
): PaintBackground {
const t = paintThemes[theme];
const v = (variant % 3) as 0 | 1 | 2;
const { wash, blooms, drag, grain, pool } = params;
const bloomTint = lerp(t.bloomDark, t.bloomLight, blooms.lift);
const layers = [
grain.opacity > 0 && enc(grainSVG(grain)),
blooms.opacity > 0 && enc(bloomSVG({ ...blooms, seed: seed + v * 7, tint: bloomTint })),
drag.strength > 0 && enc(dragSVG({ ...drag, seed: seed + v * 3, tint: t.drag })),
wash.macro > 0 && macroFor(v, t),
`linear-gradient(rgba(${t.wash},${wash.opacity}),rgba(${t.wash},${wash.opacity}))`,
];
const on = [grain.opacity > 0, blooms.opacity > 0, drag.strength > 0, wash.macro > 0, true];
const sizes = ['160px 160px', '512px 384px', '512px 384px', '100% 100%', '100% 100%'];
const blend = ['overlay', 'normal', 'normal', 'normal', 'normal'];
return {
background: layers.filter(Boolean).join(','),
backgroundSize: sizes.filter((_, i) => on[i]).join(','),
backgroundBlendMode: blend.filter((_, i) => on[i]).join(','),
boxShadow: pool.strength
? `inset 0 0 38px rgba(${t.pool},${pool.strength}), inset 0 0 10px rgba(${t.pool},${pool.strength * 0.8})`
: 'none',
};
}- [ ] Run the test, watch it pass.
- [ ] The guard now goes red — add the scoped exemption. Run
pnpm --filter @gw2priory/web exec vitest run src/__tests__/conventions.test.tsand watchP4 #2/SC4: no literal colour valuesfail, namingpaint/paint.ts(itsrgba(strings). Then, inapps/web/src/__tests__/conventions.test.ts, add beside theSELFconstant:
// paint/paint.ts assembles rgba()/gradient colour strings from paintThemes.ts channels (research F1);
// its colours are never hardcoded. Scoped exemption — the literal-colour scan only.
const PAINT_BUILDER = join(webSrc, 'shared', 'ui', 'paint', 'paint.ts'); and change the literal-colour assertion to exclude it:
it('P4 #2/SC4: no literal colour values', () => {
expect(literalColours(repoFiles.filter((f) => f.path !== PAINT_BUILDER))).toEqual([]);
});- [ ] Re-run conventions, watch it pass. The existing
P3 #4: the rule catches a violationtest still passes — proof the exemption did not disarm the guard elsewhere. - [ ] Confirm the exemption is narrow. Temporarily add
const x = '#abcabc';topaint/paintThemes.ts(a different file) and watchP4 #2/SC4fail; remove it. - [ ] Commit (
web: paint builders + scoped literal-guard exemption (024 T2)).
Verified by: 024 T2 — paint builders › SC2…, › SC3…, › lerp…, › is deterministic; and T7 — conventions › P4 #2/SC4: no literal colour values green with paint/paint.ts exempted.
T3 — Recipe + PaintedSurface component
Satisfies: R1, R2, R3, R4, R7, R9, R10, P1 #1, P1 #2, P1 #5, P1 #6, SC4, SC5, SC7.
Files:
- Create:
apps/web/src/shared/ui/paint/paintedSurfaceRecipe.ts - Modify:
apps/web/panda.config.ts(registerslotRecipes.paintedSurface) - Create:
apps/web/src/shared/ui/paint/styles.ts - Create:
apps/web/src/shared/ui/paint/PaintedSurface.tsx - Create:
apps/web/src/shared/ui/paint/index.ts - Modify:
apps/web/src/shared/ui/index.ts - Test:
apps/web/src/shared/ui/paint/__tests__/PaintedSurface.test.tsx
Interfaces produced:
PaintedSurface(props: PaintedSurfaceProps)andtype PaintedSurfaceProps(theme?: PaintTheme).[ ] RED: write
apps/web/src/shared/ui/paint/__tests__/PaintedSurface.test.tsx:
import { render, screen } from '@testing-library/react';
import { describe, expect, it } from 'vitest';
import { PaintedSurface } from '../index';
describe('024 T3 — PaintedSurface', () => {
it('P1 #1: renders its children inside the painted surface', () => {
render(<PaintedSurface theme="Bark">hello grove</PaintedSurface>);
expect(screen.getByText('hello grove')).toBeInTheDocument();
});
it('P1 #5 / SC7: two instances get unique, url(#…)-valid filter ids', () => {
const { container } = render(
<>
<PaintedSurface theme="Bark">a</PaintedSurface>
<PaintedSurface theme="Deep">b</PaintedSurface>
</>,
);
const ids = [...container.querySelectorAll('filter')].map((f) => f.id);
expect(ids).toHaveLength(2);
expect(new Set(ids).size).toBe(2);
for (const id of ids) expect(id).not.toContain(':');
});
});- [ ] Run it, watch it fail.
pnpm --filter @gw2priory/web exec vitest run src/shared/ui/paint/__tests__/PaintedSurface.test.tsxExpected: FAIL — noPaintedSurface. - [ ] Write the recipe
apps/web/src/shared/ui/paint/paintedSurfaceRecipe.ts. Thethemevariant surfaces each theme's tokens as--paint-*custom properties onroot, which descendants (corners, children) inherit; the drop-shadow colour is a token surfaced the same way (guard-safevar()):
import { defineSlotRecipe } from '@pandacss/dev';
const themeVars = (name: string) => ({
root: {
'--paint-text': `{colors.paint.${name}.text}`,
'--paint-text-strong': `{colors.paint.${name}.textStrong}`,
'--paint-accent': `{colors.paint.${name}.accent}`,
'--paint-hair': `{colors.paint.${name}.hair}`,
},
});
export const paintedSurfaceRecipe = defineSlotRecipe({
className: 'painted-surface',
slots: ['root', 'paint', 'corner', 'content'],
base: {
root: {
position: 'relative',
'--paint-shadow': '{colors.paint.shadow}',
filter: 'drop-shadow(0 5px 13px var(--paint-shadow))',
},
paint: { position: 'absolute', inset: '0' },
corner: {
position: 'absolute',
width: '14px',
height: '14px',
borderColor: 'var(--paint-hair)',
borderStyle: 'solid',
borderWidth: '0',
'&[data-corner=tl]': { top: '-1px', left: '-1px', borderTopWidth: '1px', borderLeftWidth: '1px' },
'&[data-corner=tr]': { top: '-1px', right: '-1px', borderTopWidth: '1px', borderRightWidth: '1px' },
'&[data-corner=bl]': { bottom: '-1px', left: '-1px', borderBottomWidth: '1px', borderLeftWidth: '1px' },
'&[data-corner=br]': { bottom: '-1px', right: '-1px', borderBottomWidth: '1px', borderRightWidth: '1px' },
},
content: { position: 'relative' },
},
variants: {
theme: {
Bark: themeVars('Bark'), Verdant: themeVars('Verdant'), Deep: themeVars('Deep'),
Ember: themeVars('Ember'), Ash: themeVars('Ash'),
},
},
defaultVariants: { theme: 'Bark' },
});- [ ] Register + regenerate. In
apps/web/panda.config.tsimport{ paintedSurfaceRecipe } from './src/shared/ui/paint/paintedSurfaceRecipe'and addpaintedSurface: paintedSurfaceRecipetotheme.extend.slotRecipes. Thenpnpm --filter @gw2priory/web exec panda codegen. - [ ] Re-export the recipe in
apps/web/src/shared/ui/paint/styles.ts(onestyles.tsper folder that styles; keepsstyled-systemout of the.tsx):
export { paintedSurface } from '../../../../styled-system/recipes';- [ ] GREEN: write
apps/web/src/shared/ui/paint/PaintedSurface.tsx:
import { type ReactNode, useId } from 'react';
import {
type Blooms, type Drag, type Grain, type Intensity, type Pool, type Tear, type Wash,
buildBackground, PRESETS,
} from './paint';
import type { PaintTheme } from './paintThemes';
import { paintedSurface } from './styles';
export interface PaintedSurfaceProps {
theme?: PaintTheme;
variant?: 0 | 1 | 2;
intensity?: Intensity;
seed?: number;
wash?: Partial<Wash>;
blooms?: Partial<Blooms>;
drag?: Partial<Drag>;
grain?: Partial<Grain>;
pool?: Partial<Pool>;
tear?: Partial<Tear>;
className?: string;
children: ReactNode;
}
const CORNERS = ['tl', 'tr', 'bl', 'br'] as const;
// Closed component. Defaults live in the body, never in the destructure — the React Compiler bails on a
// destructured-parameter default (react.md). No useMemo: the compiler owns memoization.
export function PaintedSurface(props: PaintedSurfaceProps) {
const theme = props.theme ?? 'Bark';
const variant = props.variant ?? 0;
const seed = props.seed ?? 3;
const preset = PRESETS[props.intensity ?? 'screenshot'];
const params = {
wash: { ...preset.wash, ...props.wash },
blooms: { ...preset.blooms, ...props.blooms },
drag: { ...preset.drag, ...props.drag },
grain: { ...preset.grain, ...props.grain },
pool: { ...preset.pool, ...props.pool },
};
const tear = { ...preset.tear, ...props.tear };
const bg = buildBackground(theme, params, variant, seed);
const filterId = `paint-${useId().replace(/:/g, '')}`;
const classes = paintedSurface({ theme });
const rootClass = props.className ? `${classes.root} ${props.className}` : classes.root;
return (
<div className={rootClass}>
<svg aria-hidden="true" width="0" height="0" style={{ position: 'absolute' }}>
<defs>
<filter id={filterId} x="-9%" y="-9%" width="118%" height="118%">
<feTurbulence type="fractalNoise" baseFrequency="0.035 0.11" numOctaves={5} seed={seed + (variant % 3)} result="n" />
<feDisplacementMap in="SourceGraphic" in2="n" scale={tear.scale} xChannelSelector="R" yChannelSelector="G" />
</filter>
</defs>
</svg>
<div
aria-hidden="true"
className={classes.paint}
style={{ ...bg, filter: tear.scale > 0 ? `url(#${filterId})` : undefined }}
/>
{CORNERS.map((c) => (
<span key={c} aria-hidden="true" className={classes.corner} data-corner={c} />
))}
<div className={classes.content}>{props.children}</div>
</div>
);
}- [ ] Barrels. Write
apps/web/src/shared/ui/paint/index.ts:
export { PaintedSurface, type PaintedSurfaceProps } from './PaintedSurface'; and add to `apps/web/src/shared/ui/index.ts` (public surface stays `shared/ui`):
export { PaintedSurface, type PaintedSurfaceProps } from './paint';- [ ] Run the test, watch it pass.
- [ ] Confirm teeth. Temporarily replace the
useId()-derived id with a constant string and watchP1 #5 / SC7fail (ids no longer unique); restore. - [ ] Guards + build green (P1 #6/SC5):
pnpm --filter @gw2priory/web exec vitest run src/__tests__/conventions.test.ts(no literal colour in the component/recipe; nouseMemo) andpnpm --filter @gw2priory/web build(React Compiler clean — the story in T4 makes it compiler-visible; the component itself must build clean). - [ ] Commit (
web: PaintedSurface closed component + recipe (024 T3)).
Verified by: 024 T3 — PaintedSurface › P1 #1: renders its children…, › P1 #5 / SC7: two instances get unique…; pnpm build (SC5); conventions.test.ts green (SC4).
T4 — Storybook playground
Satisfies: R11, R12, P2 #1, P2 #2, P2 #3, SC6 (and P1 #1 / SC1 / SC2 as the human-verified render surface).
Files:
Create:
apps/web/src/shared/ui/paint/PaintedSurface.stories.tsx[ ] Write the story. Every paint parameter is a flattened
rangecontrol, reassembled into the grouped props inrender(research V5). Children read the surface's published--paint-*custom properties. The story imports nostyled-system(R12):
import type { Meta, StoryObj } from '@storybook/react-vite';
import { PaintedSurface, type PaintedSurfaceProps } from './PaintedSurface';
type StoryArgs = Pick<PaintedSurfaceProps, 'theme' | 'variant' | 'intensity' | 'seed'> & {
washOpacity: number; washMacro: number;
bloomsDensity: number; bloomsSize: number; bloomsHardness: number; bloomsOpacity: number; bloomsLift: number;
dragStrength: number; dragAngle: number;
grainOpacity: number; poolStrength: number; tearScale: number;
};
const range = (min: number, max: number, step: number) => ({ control: { type: 'range' as const, min, max, step } });
const meta: Meta<StoryArgs> = {
title: 'paint/PaintedSurface',
args: {
theme: 'Bark', variant: 0, intensity: 'screenshot', seed: 3,
washOpacity: 0.74, washMacro: 1,
bloomsDensity: 0.46, bloomsSize: 52, bloomsHardness: 0.78, bloomsOpacity: 0.62, bloomsLift: 0,
dragStrength: 0.34, dragAngle: -9, grainOpacity: 0.5, poolStrength: 0.62, tearScale: 13,
},
argTypes: {
theme: { control: 'inline-radio', options: ['Bark', 'Verdant', 'Deep', 'Ember', 'Ash'] },
variant: { control: 'inline-radio', options: [0, 1, 2] },
intensity: { control: 'inline-radio', options: ['restrained', 'screenshot', 'heavy'] },
seed: { control: { type: 'number', min: 1, max: 99, step: 1 } },
washOpacity: range(0.3, 1, 0.01), washMacro: range(0, 1, 1),
bloomsDensity: range(0, 1, 0.01), bloomsSize: range(20, 130, 1), bloomsHardness: range(0, 1, 0.01),
bloomsOpacity: range(0, 1, 0.01), bloomsLift: range(0, 1, 0.01),
dragStrength: range(0, 1, 0.01), dragAngle: range(-45, 45, 1),
grainOpacity: range(0, 1, 0.01), poolStrength: range(0, 1, 0.01), tearScale: range(0, 26, 1),
},
render: (a) => (
<PaintedSurface
theme={a.theme}
variant={a.variant}
intensity={a.intensity}
seed={a.seed}
wash={{ opacity: a.washOpacity, macro: a.washMacro }}
blooms={{ density: a.bloomsDensity, size: a.bloomsSize, hardness: a.bloomsHardness, opacity: a.bloomsOpacity, lift: a.bloomsLift }}
drag={{ strength: a.dragStrength, angle: a.dragAngle }}
grain={{ opacity: a.grainOpacity }}
pool={{ strength: a.poolStrength }}
tear={{ scale: a.tearScale }}
>
<div style={{ maxWidth: 460, padding: '20px 24px 22px' }}>
<div style={{ color: 'var(--paint-accent)', textTransform: 'uppercase', letterSpacing: '.14em', fontSize: 11, marginBottom: 8 }}>Wash</div>
<h3 style={{ color: 'var(--paint-text-strong)', margin: 0, fontSize: 21 }}>Whisper of the Grove</h3>
<p style={{ color: 'var(--paint-text)', margin: '12px 0 0', lineHeight: 1.62 }}>
The theme re-tints the paint itself — wash, macro density, bloom cells, brush drag and edge
pooling all follow.
</p>
</div>
</PaintedSurface>
),
};
export default meta;
type Story = StoryObj<StoryArgs>;
export const Default: Story = {};- [ ] Typecheck.
pnpm --filter @gw2priory/web exec tsc --noEmit(stories are typechecked;src/**/*.tsx). - [ ] Build (compiler-visible).
pnpm --filter @gw2priory/web build— the Storybook build runs the React Compiler on the story's module graph (design-system.md); it must stay green. - [ ] Human review in Storybook (P2 #1/#2/#3, SC6):
pnpm --filter @gw2priory/web storybook, openpaint/PaintedSurface, confirm the theme/variant/intensity/seed controls and every paint-parameter slider are present, that moving a slider updates the surface live, and that switching themes re-tints the whole paint stack — the reference's tuning panel, reproduced. - [ ] Commit (
web: PaintedSurface Storybook playground (024 T4)).
Verified by: typecheck + pnpm build (automated floor); human review of the Storybook controls and live re-tint (P2, SC6) — jsdom does not rasterise SVG filters, so appearance is not unit-tested (plan §Test strategy).
T5 — Documentation + traceability
Satisfies: R13, and records the human-review evidence for P1 #1/#2, P2, SC1/SC2/SC6.
Files:
Modify:
docs/architecture/design-system.mdModify:
specs/024-painted-surface/spec.md(fill the traceability table)[ ]
design-system.md— add a Painted surface section documenting: the paint-theme representation is the Option-B split — plain ink colours arecolors.paint.<theme>plain tokens (content-flavour, no_osDark), the SVG-math channels are numeric data inshared/ui/paint/paintThemes.ts; the single literal-colour-guard exemption is the buildershared/ui/paint/paint.ts(it assemblesrgba()strings) and not the numeric data (research F1); andshared/ui/paint/is the first grouped subsystem folder, anticipating reuse of the paint primitives (buildBackground+ the tokens) by other components ([[painterly-design-system-goal]]).[ ] Validate docs compile.
pnpm docs:build.[ ] Fill
spec.md's traceability table with the test names from T1–T4 (transcription — eachVerified byalready names them) and the honest human-review rows: P1 #1 / SC1 / SC6 → Storybook render + controls; P1 #2 / SC2 →paint.test.ts(builder) plus Storybook re-tint; P2 #1–#3 → Storybook review.[ ] Confirm no
docs/superpowers/files and the workflow invariants pass:pnpm exec vitest run tests/workflow(the templates/invariants suite).[ ] Commit (
docs: 024 painted-surface representation + traceability (024 T5)).
Verified by: pnpm docs:build green; human review that design-system.md describes the repository (SC-style); the traceability table filled.
Notes
Staging area for decisions and surprises found during implementation. Move each into spec.md, research.md, or docs/ before closing the feature — this section is not a home.
- Post-review changes (human Storybook review). Several changes landed after the tasks below were built, so the current code diverges from the T3/T4 code blocks above (kept as the historical record; spec.md carries the current truth): (1) the
intensitycontrol was dead in the playground (the sliders always override the preset; Storybook args can't cascade) → intensity becameRestrained/Screenshot/Heavystories. (2) The fourcorner-slot hairline ticks were removed from the recipe and component — the in-game look is the painterly surface + torn edge without the brackets. (3) Abackgroundselect (named CSS colours) was added to the Playground so the dark surface's edges read against light/dark grounds. (4) Anedge?: 'none' | 'rim'prop was added:rimis a light inner-perimeter inset (var(--paint-glow), newcolors.paint.glowtoken) so the torn edge reads on dark grounds. (halo/decklealternatives were prototyped, then dropped.) (5) The recipe declaresstaticCssfor all five themes —themeis runtime-selected and Panda was emitting variant CSS only for the literals it scanned (Barkdefault +Deepfrom a test), so Verdant/Ember/Ash silently never re-tinted: a real correctness bug, not a Storybook artifact (documented in design-system.md, Painted surface). - Capitalised token/recipe keys. Tokens and the recipe
themevariant use the capitalised theme names (paint.Bark,theme: { Bark }) so thethemeprop flows through with no case mapping. Ifpanda codegenrejects capitalised keys, lowercase them and add aRecord<PaintTheme, string>map in the component (systematic-debugging; record the outcome here). --paint-*custom properties from a recipe variant are verified on paper (research V2); the first realpanda codegenofpaintedSurfaceRecipe(T3) confirms Panda emits them. If a{colors.paint…}reference does not resolve into the custom property, set the vars fromtoken()inpaint/styles.tsand read them in the component (still avar()reference, guard-safe) — record the fallback here.- Inline
styleon the paint layer carries the runtime-computed background frombuildBackground— the one place styling is inline rather than in the recipe, because it is per-instance computed, not static. Nostyled-systemimport is involved, so the styles-in-styles.tsrule is not breached.