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:6006and the server's own banner before believing a screenshot. Seeresearch.mdV1. - jsdom mangles the band's inline
background. Read the band'sstyleattribute withgetAttribute('style'); do not readelement.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$SCRATCHis 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 underspecs/. Runpnpm 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:
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, followingpaint.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'sbloomLight[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 containsk2='0' k3='0'and a non-zerok1— the property, asserted as data, the waypaintThemesis);"R7/SC2: the three seeds produce three distinct bands"(mapBAND_SEEDSthroughbuildBand, assertnew Set(...).size === 3);"R4: base 0 drops the smooth coat"(the coat rect is absent from the output). Watch each fail becausebuildBanddoes not exist. - [ ] GREEN: implement
Band,BAND,BAND_SEEDSandbuildBand(theme, band, seed, pad): BandBackgroundinpaint.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 90deglinear-gradienthas no vertical falloff and reads as a rectangle. - [ ] Confirm the tests have teeth — change
k2='0'tok2='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.tsxwith"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 andaria-expandedis"false");"P1 #3/R9/SC1: the trigger is a button inside an h3"(getByRole('button'), and itsclosest('h3')is not null — this is the keyboard guarantee);"P1 #4: the meta text renders". Watch them fail becausePaintedSectiondoes not exist. - [ ] GREEN: create
paintedSectionRecipe.ts(defineSlotRecipe,className: 'painted-section', slotsroot,rail,heading,trigger,band,title,meta,chevron,panel,body), register it inpanda.config.tsundertheme.extend.slotRecipes, re-export it frompaint/styles.ts, and runpnpm --filter @gw2priory/web prepareso codegen writes it intostyled-system/recipes. Then createPaintedSection.tsxwith the structure inplan.md's Architecture block, props{ title: string; meta?: string; defaultOpen?: boolean; children: ReactNode }, defaults read in the body rather than the destructure. - [ ] The
panelslot 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.mdF5). - [ ] Add
"P1 #6: the height transition is disabled under reduced motion"asserting the recipe'spanelslot carries a@media (prefers-reduced-motion: reduce)entry settingtransition: none— jsdom cannot evaluate media queries, so this is asserted as recipe data, the waypaintedSurfaceRecipe.test.tsasserts the scope. - [ ] Export
PaintedSectionandPaintedSectionPropsfrompaint/index.tsand re-export fromshared/ui/index.ts. - [ ] Confirm the tests have teeth — remove the
h3wrapper and watch P1 #3 fail; hardcodearia-expandedand 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'sgetAttribute('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 becausebandIndex.tsdoes not exist. - [ ] GREEN: create
apps/web/src/shared/ui/paint/bandIndex.tsexportingexport const BandIndexContext = createContext(0)with a comment on why it defaults to 0. InPaintedSection.tsx, read it withuse/useContextand passBAND_SEEDS[index % BAND_SEEDS.length]tobuildBand, applying the result as the band span's inlinestyle— the URI is computed at runtime, which is the one thing a recipe cannot express. - [ ] The
metaslot asks forcolor: 'text.muted'and nothing paint-specific: inside the scope that token already resolves to the theme'stextDim, measured across two themes (research.mdV2). Add"R8: the meta asks for the token the painted scope re-points"asserting the recipe'smetaslot is exactlytext.muted— this is the test that fails the day someone reintroduces a--paint-text-dimstub. - [ ] 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.tsxwith"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>elementsPaintedSurfaceemits — expect 1, whatever the section count);"R7/SC2: five sections in a column use three bands and never repeat a neighbour"(collect the five bandstyleattributes:new Set(...).size === 3, and no two consecutive entries equal);"R12: every theme the panel accepts is listed in staticCss"(assert the recipe'sstaticCssnames all five themes — the 024 rule, and the test that catches a sixth theme added without it). Watch them fail becausePaintedPaneldoes not exist. - [ ] GREEN: create
paintedPanelRecipe.ts(slotsroot,header,title,subtitle,hairline,columns,column,divider;themevariant withstaticCss: [{ theme: ['Bark', 'Verdant', 'Deep', 'Ember', 'Ash'] }]), register it inpanda.config.ts, re-export frompaint/styles.ts, runpnpm --filter @gw2priory/web prepare. - [ ] GREEN: create
PaintedPanel.tsx— props{ theme?: PaintTheme; title: string; subtitle?: string; children: ReactNode }, renderingPaintedSurfaceitself, the header row (h2title, subtitle, the hairline that fades right), and a columns row. AttachPaintedPanel.Column, props{ divider?: boolean; scroll?: boolean; className?: string; children: ReactNode }, which walks its direct children withChildren.mapand wraps each in<BandIndexContext value={i}>. Width stays the caller's, throughclassName. - [ ] Export both from
paint/index.tsandshared/ui/index.ts. - [ ] Confirm the tests have teeth — drop the
Children.mapwrapper 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: aDefaultstory (open, with meta), aCollapsedstory, and aStackstory of four sections showing the three-band cycle. Backdrops use named CSS colours only — the literal-colour guard matches hex andrgb()/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 aThemesstory rendering the same panel in all five themes — which is also the visual proof ofR12. - [ ] No paint sliders: the band is house chrome (
R4), so retuning means editingBAND. Neither story importsstyled-system— a story is not astyles.ts. - [ ] Run
pnpm --filter @gw2priory/web storybookand look at both. Check the port: if another tree holds 6006 this one serves on 6007 and every story 404s (research.mdV1). - [ ] 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
PaintedSectionDefaultstory 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.reachtemporarily 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.mdunder 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.mdcovering the three findings flagged for graduation inresearch.md: the stroke builder and why its dissolve is multiplicative; the position cycle and why it isChildren.maprather 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 spikereturns only the deletion from T1. - [ ] Run the full gate:
pnpm typecheck && pnpm lint && pnpm test && pnpm build && pnpm docs:build.pnpm buildis where the React Compiler runs, anddocs:buildis where a spec's markdown is compiled through Vue. - [ ] Move
spec.md's status toimplementedinside the branch, once the human says so — part of the PR diff, never a later edit tomain. - [ ] 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-eventwas 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.