Skip to content

Tasks 026 — Painted section and panel ​

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.

Derived from plan.md (approved). Each task is small, independently verifiable, and reviewed as its own diff. Split where a reviewer could reject one task while approving its neighbour — not where the work merely changes subject. A task is done only when it satisfies the definition of done in CLAUDE.md.

Global Constraints in plan.md apply to every task and are not repeated per task.

Two traps that will cost an hour each if hit blind. They are called out again in the tasks that meet them, but they belong at the top because neither is guessable:

  • Storybook falls back to another port. If another working tree already holds 6006, this one silently serves on 6007 and every screenshot is Storybook's "couldn't find story" error page — which compares equal to everything. Check lsof -ti tcp:6006 and the server's own banner before believing a screenshot. See research.md V1.
  • jsdom mangles the band's inline background. Read the band's style attribute with getAttribute('style'); do not read element.style.background, which jsdom's CSSOM may drop for a multi-layer value containing a data URI.

T1 — The branch carries no spike code ​

Satisfies: R14

Discovery left one story file and a marked SPIKE block in paint.ts. All of it goes; the builder re-lands through TDD in T2. The numbers survive in T2 below, which is committed, and the SVG's filter math survives in a patch kept outside the repo for reference while re-landing.

  • [ ] Save the current builder for reference before reverting: git diff apps/web/src/shared/ui/paint/paint.ts > "$SCRATCH/026-band.patch", where $SCRATCH is the session scratchpad. This is a convenience copy, not a deliverable — T2 states every number that matters.
  • [ ] Delete apps/web/src/shared/ui/paint/PaintedSection.spike.stories.tsx.
  • [ ] git checkout -- apps/web/src/shared/ui/paint/paint.ts — back to the committed state.
  • [ ] Run git status --short: the only changes left are under specs/. Run pnpm test — green, and green for the pre-026 reasons (637 passed, 1 expected fail).
  • [ ] Commit.

Verified by: git status --short showing no apps/web changes; pnpm test green.


T2 — buildBand paints a stroke that runs dry ​

Satisfies: R4, R5, R6, SC3 (builder half)

The tuned values, which are the whole of what discovery produced. BAND in apps/web/src/shared/ui/paint/paint.ts:

ts
export const BAND: Band = {
  reach: 70,      // % of width where the ramp hits zero
  breakup: 0.22,  // 0 smooth fade · 1 skips and flecks — raises the dissolve threshold
  wobble: 2,      // edge displacement, px
  brush: 0.12,    // streak density — 0 a flat wash, 1 a fine hatch of lines
  opacity: 0.5,   // band tint strength
  rule: 0.55,     // underline strength
  base: 0.3,      // smooth coat under the brush, gives the band body
};
export const BAND_SEEDS = [5, 23, 47];

Structure of the SVG the builder returns, 560×(34 + 2·pad), preserveAspectRatio='none', three rects drawn back to front: the smooth coat (a pigment ramp, displaced and vertically masked so it has no ruler-straight edges), the brushed band, then the 1.5px hairline rule. Pigment is paintThemes[theme].bloomLight, scaled to a "r,g,b" string for the coat; the rule's tint is one shared near-white constant. Both dissolve filters share one chain whose composite is multiplicative:

feComposite operator='arithmetic' k1='2.2' k2='0' k3='0' k4='-(0.06 + breakup*0.22)'

k2/k3 are zero on purpose — that is the whole of R6. With the ramp as a factor, every pixel past reach evaluates to k4, which is negative and clamps to nothing. Added instead (k2·noise + ramp), noise alone clears the threshold and the far end of the stroke never goes clean.

  • [ ] RED: create apps/web/src/shared/ui/paint/__tests__/paintBand.test.ts, following paint.test.ts's pattern of asserting the builder's output string. Five tests, named for what they guard: "R4: is deterministic for the same inputs"; "R5: the band takes its pigment from the theme — two themes differ" (Bark's bloomLight[0.62,0.5,0.34] → 158,128,87; Verdant's [0.46,0.6,0.34] → 117,153,87; assert each appears in its own theme's output and not the other's); "R6: the ramp multiplies the noise rather than adding to it" (assert the output contains k2='0' k3='0' and a non-zero k1 — the property, asserted as data, the way paintThemes is); "R7/SC2: the three seeds produce three distinct bands" (map BAND_SEEDS through buildBand, assert new Set(...).size === 3); "R4: base 0 drops the smooth coat" (the coat rect is absent from the output). Watch each fail because buildBand does not exist.
  • [ ] GREEN: implement Band, BAND, BAND_SEEDS and buildBand(theme, band, seed, pad): BandBackground in paint.ts, inside the file's existing exemption comment, returning { background, backgroundSize: '100% 100%', backgroundRepeat: 'no-repeat' }. One background layer — the coat belongs inside the SVG, not as a second CSS layer, because a 90deg linear-gradient has no vertical falloff and reads as a rectangle.
  • [ ] Confirm the tests have teeth — change k2='0' to k2='0.9' and watch the R6 test fail; set two themes to the same pigment and watch R5 fail; restore.
  • [ ] Commit.

Verified by: paintBand.test.ts — all five tests above.


T3 — A section renders, collapses, and is operable from the keyboard ​

Satisfies: R1, R2, R3, R9, R11, P1 #1, P1 #2, P1 #3, P1 #6, SC1

On keyboard testing: @testing-library/user-event is not a dependency and this task does not add one. Enter/Space activation is a native <button> guarantee, not something this component implements, so the test asserts the trigger is a button (making activation the platform's job) and that clicking it toggles. fireEvent.keyDown would prove less than that, since jsdom does not synthesize the click a real button would fire.

  • [ ] RED: create apps/web/src/shared/ui/paint/__tests__/PaintedSection.test.tsx with "P1 #1: a section rendered defaultOpen shows its children and reports aria-expanded"; "P1 #2: activating the header collapses the section" (click, then assert the children are gone and aria-expanded is "false"); "P1 #3/R9/SC1: the trigger is a button inside an h3" (getByRole('button'), and its closest('h3') is not null — this is the keyboard guarantee); "P1 #4: the meta text renders". Watch them fail because PaintedSection does not exist.
  • [ ] GREEN: create paintedSectionRecipe.ts (defineSlotRecipe, className: 'painted-section', slots root, rail, heading, trigger, band, title, meta, chevron, panel, body), register it in panda.config.ts under theme.extend.slotRecipes, re-export it from paint/styles.ts, and run pnpm --filter @gw2priory/web prepare so codegen writes it into styled-system/recipes. Then create PaintedSection.tsx with the structure in plan.md's Architecture block, props { title: string; meta?: string; defaultOpen?: boolean; children: ReactNode }, defaults read in the body rather than the destructure.
  • [ ] The panel slot copies Base UI's documented CSS including the rule the spike omitted: height: var(--collapsible-panel-height), overflow: hidden, transition: height 150ms ease-out, &[hidden]:not([hidden='until-found']) → display: none, and &[data-starting-style], &[data-ending-style] → height: 0 (research.md F5).
  • [ ] Add "P1 #6: the height transition is disabled under reduced motion" asserting the recipe's panel slot carries a @media (prefers-reduced-motion: reduce) entry setting transition: none — jsdom cannot evaluate media queries, so this is asserted as recipe data, the way paintedSurfaceRecipe.test.ts asserts the scope.
  • [ ] Export PaintedSection and PaintedSectionProps from paint/index.ts and re-export from shared/ui/index.ts.
  • [ ] Confirm the tests have teeth — remove the h3 wrapper and watch P1 #3 fail; hardcode aria-expanded and watch P1 #2 fail; restore.
  • [ ] Commit.

Verified by: PaintedSection.test.tsx — the five tests above.


T4 — The band cycles by position, three patterns, never twice running ​

Satisfies: R7, R8, R10, P1 #4, P1 #5

  • [ ] RED: add to PaintedSection.test.tsx: "R7/P1 #5: consecutive positions take different bands" — render four sections, each wrapped in <BandIndexContext value={i}>, read each band's getAttribute('style') (see the trap at the top of this file), and assert positions 0/1/2 differ pairwise while position 3 equals position 0; and "R7: a section outside a Column takes the first band" — a bare section's band equals the one rendered at index 0. Watch both fail because bandIndex.ts does not exist.
  • [ ] GREEN: create apps/web/src/shared/ui/paint/bandIndex.ts exporting export const BandIndexContext = createContext(0) with a comment on why it defaults to 0. In PaintedSection.tsx, read it with use/useContext and pass BAND_SEEDS[index % BAND_SEEDS.length] to buildBand, applying the result as the band span's inline style — the URI is computed at runtime, which is the one thing a recipe cannot express.
  • [ ] The meta slot asks for color: 'text.muted' and nothing paint-specific: inside the scope that token already resolves to the theme's textDim, measured across two themes (research.md V2). Add "R8: the meta asks for the token the painted scope re-points" asserting the recipe's meta slot is exactly text.muted — this is the test that fails the day someone reintroduces a --paint-text-dim stub.
  • [ ] Confirm the test has teeth — make the seed constant and watch the cycle test fail; restore.
  • [ ] Commit.

Verified by: PaintedSection.test.tsx — "R7/P1 #5: consecutive positions take different bands", "R7: a section outside a Column takes the first band", "R8: the meta asks for the token the painted scope re-points".


T5 — A panel frames columns of sections and hands out their positions ​

Satisfies: R1, R9, R10, R12, P2 #1, P2 #2, P2 #3, P2 #4, SC2

  • [ ] RED: create apps/web/src/shared/ui/paint/__tests__/PaintedPanel.test.tsx with "P2 #1: the panel renders its title as an h2 with its subtitle"; "P2 #4: a panel of sections paints exactly one surface" (count the painted-surface roots, or the <filter> elements PaintedSurface emits — expect 1, whatever the section count); "R7/SC2: five sections in a column use three bands and never repeat a neighbour" (collect the five band style attributes: new Set(...).size === 3, and no two consecutive entries equal); "R12: every theme the panel accepts is listed in staticCss" (assert the recipe's staticCss names all five themes — the 024 rule, and the test that catches a sixth theme added without it). Watch them fail because PaintedPanel does not exist.
  • [ ] GREEN: create paintedPanelRecipe.ts (slots root, header, title, subtitle, hairline, columns, column, divider; theme variant with staticCss: [{ theme: ['Bark', 'Verdant', 'Deep', 'Ember', 'Ash'] }]), register it in panda.config.ts, re-export from paint/styles.ts, run pnpm --filter @gw2priory/web prepare.
  • [ ] GREEN: create PaintedPanel.tsx — props { theme?: PaintTheme; title: string; subtitle?: string; children: ReactNode }, rendering PaintedSurface itself, the header row (h2 title, subtitle, the hairline that fades right), and a columns row. Attach PaintedPanel.Column, props { divider?: boolean; scroll?: boolean; className?: string; children: ReactNode }, which walks its direct children with Children.map and wraps each in <BandIndexContext value={i}>. Width stays the caller's, through className.
  • [ ] Export both from paint/index.ts and shared/ui/index.ts.
  • [ ] Confirm the tests have teeth — drop the Children.map wrapper so every section reads the default 0, and watch the neighbour test fail; restore.
  • [ ] Commit.

Verified by: PaintedPanel.test.tsx — the four tests above.


T6 — Both components are in the catalogue ​

Satisfies: R15

  • [ ] Create PaintedSection.stories.tsx: a Default story (open, with meta), a Collapsed story, and a Stack story of four sections showing the three-band cycle. Backdrops use named CSS colours only — the literal-colour guard matches hex and rgb()/hsl(), never names.
  • [ ] Create PaintedPanel.stories.tsx: the two-column frame with a scrolling column, in the shape the material-storage view will take, plus a Themes story rendering the same panel in all five themes — which is also the visual proof of R12.
  • [ ] No paint sliders: the band is house chrome (R4), so retuning means editing BAND. Neither story imports styled-system — a story is not a styles.ts.
  • [ ] Run pnpm --filter @gw2priory/web storybook and look at both. Check the port: if another tree holds 6006 this one serves on 6007 and every story 404s (research.md V1).
  • [ ] Commit.

Verified by: both stories rendering in Storybook; human visual review against the in-game screenshot.


T7 — The far end of the band is measured, not assumed ​

Satisfies: SC4

The band's clean far end is the defect that discovery caught and the one a future tuning pass is most likely to reintroduce. Re-run research.md V1's method against the shipped component rather than the spike.

  • [ ] Render the PaintedSection Default story headless at a fixed window size; crop the region where the meta and chevron sit (past the band's reach); hash it.
  • [ ] Compare against a control rendered with the band suppressed. They must be identical.
  • [ ] Run the negative control: a build with BAND.reach temporarily at 100 must produce a different hash. A sweep whose negative control passes is measuring the harness, not the subject — that is exactly how the first sweep in discovery came back green while serving the wrong tree.
  • [ ] Record the three hashes and the window/crop geometry in research.md under V1 as a second measurement, dated, against the component.
  • [ ] Commit.

Verified by: the recorded hashes in research.md V1 — control equal, negative control different.


T8 — The decisions leave the branch as documentation ​

Satisfies: SC5, SC6, SC7, and CLAUDE.md's definition of done

  • [ ] Add a Painted section and panel section to docs/architecture/design-system.md covering the three findings flagged for graduation in research.md: the stroke builder and why its dissolve is multiplicative; the position cycle and why it is Children.map rather than a counter; and the rule that inside a painted scope you ask for the semantic token rather than adding a --paint-* property for a role the scope already re-points.
  • [ ] Fill spec.md's traceability table from the test names in T2–T7 — a transcription, not an excavation.
  • [ ] Confirm no spike file survives: git log --stat 026-painted-section ^main | grep spike returns only the deletion from T1.
  • [ ] Run the full gate: pnpm typecheck && pnpm lint && pnpm test && pnpm build && pnpm docs:build. pnpm build is where the React Compiler runs, and docs:build is where a spec's markdown is compiled through Vue.
  • [ ] Move spec.md's status to implemented inside the branch, once the human says so — part of the PR diff, never a later edit to main.
  • [ ] Commit, push, open the PR.

Verified by: the full gate green; spec.md's traceability table complete; the PR diff carrying the status transition.


Notes ​

Staging area for decisions and surprises found during implementation — including anything that turned out differently from what plan.md assumed. Move each one into spec.md, research.md, or docs/ before closing the feature; this section is not a home.

  • @testing-library/user-event was deliberately not added (T3). If a future component needs real keyboard simulation — a menu, a combobox, anything where activation is not the platform's — that is the moment to add it, and the reason will be that component, not this one.