Skip to content

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 (add tokens.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 PaintChannels and const paintThemes: Record<PaintTheme, PaintChannels>.

  • [ ] RED (tokens): add to apps/web/src/__tests__/tokens.test.ts:

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:
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.ts Expected: FAIL — no paint.* tokens; no paintThemes module.
  • [ ] Add the tokens to apps/web/panda.config.ts, under theme.extend.tokens.colors (alongside rarity). paint.shadow is a theme-independent drop-shadow colour; the rest are per-theme ink:
ts
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.ts now carries the paths.
  • [ ] GREEN (data): write apps/web/src/shared/ui/paint/paintThemes.ts:
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.wash to [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, and buildBackground(theme: PaintTheme, params, variant, seed): { background; backgroundSize; backgroundBlendMode; boxShadow }.

  • [ ] RED: write apps/web/src/shared/ui/paint/__tests__/paint.test.ts:

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.ts Expected: FAIL — no paint module.
  • [ ] GREEN: write apps/web/src/shared/ui/paint/paint.ts:
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.ts and watch P4 #2/SC4: no literal colour values fail, naming paint/paint.ts (its rgba( strings). Then, in apps/web/src/__tests__/conventions.test.ts, add beside the SELF constant:
ts
// 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:
ts
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 violation test still passes — proof the exemption did not disarm the guard elsewhere.
  • [ ] Confirm the exemption is narrow. Temporarily add const x = '#abcabc'; to paint/paintThemes.ts (a different file) and watch P4 #2/SC4 fail; 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 (register slotRecipes.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) and type PaintedSurfaceProps (theme?: PaintTheme).

  • [ ] RED: write apps/web/src/shared/ui/paint/__tests__/PaintedSurface.test.tsx:

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.tsx Expected: FAIL — no PaintedSurface.
  • [ ] Write the recipe apps/web/src/shared/ui/paint/paintedSurfaceRecipe.ts. The theme variant surfaces each theme's tokens as --paint-* custom properties on root, which descendants (corners, children) inherit; the drop-shadow colour is a token surfaced the same way (guard-safe var()):
ts
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.ts import { paintedSurfaceRecipe } from './src/shared/ui/paint/paintedSurfaceRecipe' and add paintedSurface: paintedSurfaceRecipe to theme.extend.slotRecipes. Then pnpm --filter @gw2priory/web exec panda codegen.
  • [ ] Re-export the recipe in apps/web/src/shared/ui/paint/styles.ts (one styles.ts per folder that styles; keeps styled-system out of the .tsx):
ts
export { paintedSurface } from '../../../../styled-system/recipes';
  • [ ] GREEN: write apps/web/src/shared/ui/paint/PaintedSurface.tsx:
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:
ts
export { PaintedSurface, type PaintedSurfaceProps } from './PaintedSurface';
  and add to `apps/web/src/shared/ui/index.ts` (public surface stays `shared/ui`):
ts
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 watch P1 #5 / SC7 fail (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; no useMemo) and pnpm --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 range control, reassembled into the grouped props in render (research V5). Children read the surface's published --paint-* custom properties. The story imports no styled-system (R12):

tsx
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, open paint/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.md

  • Modify: 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 are colors.paint.<theme> plain tokens (content-flavour, no _osDark), the SVG-math channels are numeric data in shared/ui/paint/paintThemes.ts; the single literal-colour-guard exemption is the builder shared/ui/paint/paint.ts (it assembles rgba() strings) and not the numeric data (research F1); and shared/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 — each Verified by already 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 intensity control was dead in the playground (the sliders always override the preset; Storybook args can't cascade) → intensity became Restrained/Screenshot/Heavy stories. (2) The four corner-slot hairline ticks were removed from the recipe and component — the in-game look is the painterly surface + torn edge without the brackets. (3) A background select (named CSS colours) was added to the Playground so the dark surface's edges read against light/dark grounds. (4) An edge?: 'none' | 'rim' prop was added: rim is a light inner-perimeter inset (var(--paint-glow), new colors.paint.glow token) so the torn edge reads on dark grounds. (halo/deckle alternatives were prototyped, then dropped.) (5) The recipe declares staticCss for all five themes — theme is runtime-selected and Panda was emitting variant CSS only for the literals it scanned (Bark default + Deep from 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 theme variant use the capitalised theme names (paint.Bark, theme: { Bark }) so the theme prop flows through with no case mapping. If panda codegen rejects capitalised keys, lowercase them and add a Record<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 real panda codegen of paintedSurfaceRecipe (T3) confirms Panda emits them. If a {colors.paint…} reference does not resolve into the custom property, set the vars from token() in paint/styles.ts and read them in the component (still a var() reference, guard-safe) — record the fallback here.
  • Inline style on the paint layer carries the runtime-computed background from buildBackground — the one place styling is inline rather than in the recipe, because it is per-instance computed, not static. No styled-system import is involved, so the styles-in-styles.ts rule is not breached.