Plan 026 — Painted section and panel
Status: approved Written in plan mode from spec.md (approved) and research.md (complete). Status is set by the human, never by the agent: proposed → approved.
Produced alone — tasks.md stays untouched until this plan is approved in turn.
Context
Spec 024 gave the design system a painted surface; spec 025 made that surface a theme scope, so anything inside it re-tints through the tokens it already uses. Neither can divide a panel. A painted container is one undifferentiated wash: no way to title a region, mark where one ends, or fold one away.
The missing mechanism is a stroke. buildBackground fills an area; nothing paints a band that starts loaded with pigment and runs dry across the width, which is how the in-game material-storage headers read and the only painterly way to mark a section without drawing a box around it. This plan adds that builder and the two components that use it, so the material-storage view — the planner's first dense data surface — has something to be built out of.
Goal
A painted panel can be divided into titled, collapsible sections whose headers are painted bands, with no domain knowledge anywhere in the design system.
Approach
Three pieces, each landing on the one before it.
buildBand joins buildBackground in paint.ts. Same shape — a pure function from theme, parameters and seed to CSS background properties — and the same reason for living in that file: it assembles rgba()/gradient strings, and paint.ts is the literal-colour guard's single exemption. It introduces no theme data: the band's pigment is each theme's existing bloomLight channel, and the hairline rule is one shared near-white constant, theme-independent for the same reason paint.glow is. Its dissolve multiplies the left-to-right ramp into the turbulence rather than adding to it, which is what makes the far end of the stroke provably clean (research V1) rather than merely faint.
PaintedSection wraps Base UI Collapsible. The primitive owns everything stateful: aria-expanded, aria-controls, the panel id, the open/closed state attributes, and the --collapsible-panel-height custom property the height transition reads. The component adds three painted elements — the band, its hairline rule, and the left rail that fades downward — and nothing else. Its panel CSS copies Base UI's documented shape including the [hidden]:not([hidden='until-found']) rule the spike omitted (research F5).
PaintedPanel owns the surface and the cycle. It renders PaintedSurface itself, so a call site cannot get the theme or the square-edge rule wrong, and puts the title/subtitle row above a hairline that fades right. PaintedPanel.Column walks its direct children with Children.map and supplies each child's index through a context; PaintedSection reads it and picks BAND_SEEDS[index % 3]. That is the whole of R7: even by construction, never repeating a neighbour, three rasters for a panel of any size. A section rendered outside a Column reads the context default of 0 and takes the first band, which is what makes the component testable and storyable on its own.
Nothing hand-rolls a counter. A variable incremented during render is impure and would break under StrictMode's double invocation; Children.map is the pure way to know a child's position.
The spike is reverted first. Nothing from discovery survives as code — only its numbers, as the BAND constant.
Architecture
A section is four boxes, of which the caller sees one:
<Collapsible.Root class=root> margin, position: relative
<span class=rail aria-hidden /> the vertical hairline, fading downward
<h3 class=heading> h3 is fixed (R9); a button takes phrasing content
<Collapsible.Trigger class=trigger> the <button> — Base UI's aria + state attributes
<span class=band aria-hidden inline style: buildBand()'s background properties,
style={bandBackground} /> the one thing a recipe cannot express (runtime URI)
<span class=title>{title}</span> --paint-text-strong
<span class=meta>{meta}</span> text.muted → the theme's textDim (research V2)
<span class=chevron aria-hidden /> rotates on [data-panel-open]
</Collapsible.Trigger>
</h3>
<Collapsible.Panel class=panel> height: var(--collapsible-panel-height), transition
<div class=body>{children}</div> the indent after the rail
</Collapsible.Panel>
</Collapsible.Root>A panel wraps sections in the surface and hands out positions:
<PaintedPanel theme="Bark" title="…" subtitle="…">
└ PaintedSurface (theme scope: --paint-* and the six --colors-* re-points)
├ <header> title (h2) · subtitle · hairline fading right
└ <div class=columns>{children}</div>
└ PaintedPanel.Column ── Children.map ──▶ <BandIndexContext value={i}>
└ PaintedSection ── useContext ──▶ BAND_SEEDS[i % 3]Dependencies run one way and no cycle is possible: bandIndex.ts defines the context, PaintedSection consumes it, PaintedPanel provides it. The panel never imports the section.
Tech stack
No new dependency. Everything is already pinned:
- Base UI 1.6.0 —
Collapsible.Root/Trigger/Panel.Accordionwas rejected on a fact, not a preference: 1.6.0 removed roving focus following the APG guidance update (research F6), leaving only shared value state, which R3 does not want. - Panda CSS 1.11.5 — two
defineSlotRecipes, one file each, registered inpanda.config.ts. - React 19.2.8 with the React Compiler (
panicThreshold: 'all_errors') — no hand-written memoization, no destructured-parameter defaults,Children.mapfor positions. - Storybook 10.5.8 — the catalogue home; stories use plain elements and inline styles.
File structure
Reverted first
| Path | Change |
|---|---|
apps/web/src/shared/ui/paint/PaintedSection.spike.stories.tsx | deleted — spike (R14) |
apps/web/src/shared/ui/paint/paint.ts | spike block reverted; buildBand re-lands through TDD |
Created
| Path | Responsibility |
|---|---|
apps/web/src/shared/ui/paint/bandIndex.ts | the position context and its default of 0 — one tiny file so neither component imports the other |
apps/web/src/shared/ui/paint/PaintedSection.tsx | the closed component: Base UI Collapsible + the three painted elements |
apps/web/src/shared/ui/paint/paintedSectionRecipe.ts | its slot recipe — root, rail, heading, trigger, band, title, meta, chevron, panel, body |
apps/web/src/shared/ui/paint/PaintedPanel.tsx | the closed component: PaintedSurface + header + Column, which hands out positions |
apps/web/src/shared/ui/paint/paintedPanelRecipe.ts | its slot recipe — root, header, title, subtitle, hairline, columns, column, divider; theme variant in staticCss |
apps/web/src/shared/ui/paint/PaintedSection.stories.tsx | catalogue: states, themes, a stack showing the three bands |
apps/web/src/shared/ui/paint/PaintedPanel.stories.tsx | catalogue: the two-column panel |
apps/web/src/shared/ui/paint/__tests__/paintBand.test.ts | the builder: determinism, per-theme re-tint, the three seeds differ, base: 0 drops the coat |
apps/web/src/shared/ui/paint/__tests__/PaintedSection.test.tsx | open/close, keyboard, aria, meta, the band cycle |
apps/web/src/shared/ui/paint/__tests__/PaintedPanel.test.tsx | header, columns, divider, no nested surface |
Modified
| Path | Change |
|---|---|
apps/web/src/shared/ui/paint/paint.ts | Band, BAND, BAND_SEEDS, buildBand |
apps/web/src/shared/ui/paint/styles.ts | re-export paintedSection, paintedPanel — a .tsx may not import styled-system |
apps/web/src/shared/ui/paint/index.ts | export both components and their prop types |
apps/web/src/shared/ui/index.ts | re-export both, beside PaintedSurface |
apps/web/panda.config.ts | register both slot recipes in theme.extend.slotRecipes |
docs/architecture/design-system.md | the new section: the stroke builder, the position cycle, the paint-aware secondary ink |
specs/026-painted-section/spec.md | traceability table filled; status moved to implemented inside the branch |
Global constraints
Copied from the spec and the architecture docs. Every task's requirements implicitly include these.
- No
any, no unexplained escape hatches (docs/architecture/typescript.md). - No hand-written
useMemo/useCallback/memo— a guard test scans every file underapps/web/src. - No literal colour —
#hex,rgb(/rgba(,hsl(/hsla(— anywhere underapps/web/srcoutsidepaint.ts, including comments and stories. Named CSS colours are not matched by the guard and are how stories set backdrops. - A component
.tsxmay not importstyled-system; recipes reach it throughpaint/styles.ts. A*.stories.tsxmay not import it either — a story is not astyles.ts. - Runtime-selected recipe variants must be listed in
staticCss, or Panda ships only the literals it happens to scan (024).PaintedPanel'sthemeis passed as a variable, so all five values are listed. - Square edges — no border radius on either component (025 R7).
- No nested painted surfaces — the panel is the only painted level (025 R6).
- Defaults live in the function body, never in the destructure — the React Compiler bails on a destructured-parameter default.
- Spec artifacts are VitePress-compiled by CI — fence multi-line code, keep generics in backticks.
Verification
Beyond pnpm typecheck && pnpm lint && pnpm test && pnpm build && pnpm docs:build:
- The band's clean far end (SC4) is re-checked with research V1's method, which is the only one that has caught a real defect here: render the section, crop the region where the meta and chevron sit, hash it, and compare against a control rendered with the band switched off. The negative control (
reachextended) must differ — a sweep whose negative control passes is measuring the harness, not the subject. Two traps already found: Storybook falling back to port 6007 when another tree holds 6006, andpadshifting the row so a fixed crop catches moved text. - The three bands (SC2) are asserted twice: as data in the builder test (three seeds, three distinct background strings) and in the DOM (a five-section column has three distinct band URIs, and no two consecutive sections share one).
- Aria (SC1) is asserted with Testing Library, keyboard-first —
Tabto the trigger,EnterandSpaceto toggle. Note that Base UI omitsaria-controlsentirely while the panel is closed (research F4), so the assertion isaria-expandedalways,aria-controlsonly when open. - Storybook is the human's visual gate:
pnpm --filter @gw2priory/web storybook, thenpaint/PaintedPanelfor the whole frame against the in-game screenshot.
Self-review against the spec
Every requirement maps to a file above: R1/R2 → PaintedSection.tsx; R3 → its props; R4/R5/R6 → paint.ts; R7 → bandIndex.ts + PaintedPanel.tsx + PaintedSection.tsx; R8 → the recipe's meta slot; R9 → the h3/h2 in both components; R10 → both recipes; R11 → the two recipe files plus styles.ts; R12 → paintedPanelRecipe's staticCss; R13 → the guard test, unchanged; R14 → the revert; R15 → the two story files. SC1–SC7 map to the verification section above.
One spec line has no code: SC5's "no spike file survives the merge" is checked with git log --stat over the branch at step 5, not by a test.