Skip to content

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. Accordion was 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 in panda.config.ts.
  • React 19.2.8 with the React Compiler (panicThreshold: 'all_errors') — no hand-written memoization, no destructured-parameter defaults, Children.map for positions.
  • Storybook 10.5.8 — the catalogue home; stories use plain elements and inline styles.

File structure ​

Reverted first

PathChange
apps/web/src/shared/ui/paint/PaintedSection.spike.stories.tsxdeleted — spike (R14)
apps/web/src/shared/ui/paint/paint.tsspike block reverted; buildBand re-lands through TDD

Created

PathResponsibility
apps/web/src/shared/ui/paint/bandIndex.tsthe position context and its default of 0 — one tiny file so neither component imports the other
apps/web/src/shared/ui/paint/PaintedSection.tsxthe closed component: Base UI Collapsible + the three painted elements
apps/web/src/shared/ui/paint/paintedSectionRecipe.tsits slot recipe — root, rail, heading, trigger, band, title, meta, chevron, panel, body
apps/web/src/shared/ui/paint/PaintedPanel.tsxthe closed component: PaintedSurface + header + Column, which hands out positions
apps/web/src/shared/ui/paint/paintedPanelRecipe.tsits slot recipe — root, header, title, subtitle, hairline, columns, column, divider; theme variant in staticCss
apps/web/src/shared/ui/paint/PaintedSection.stories.tsxcatalogue: states, themes, a stack showing the three bands
apps/web/src/shared/ui/paint/PaintedPanel.stories.tsxcatalogue: the two-column panel
apps/web/src/shared/ui/paint/__tests__/paintBand.test.tsthe builder: determinism, per-theme re-tint, the three seeds differ, base: 0 drops the coat
apps/web/src/shared/ui/paint/__tests__/PaintedSection.test.tsxopen/close, keyboard, aria, meta, the band cycle
apps/web/src/shared/ui/paint/__tests__/PaintedPanel.test.tsxheader, columns, divider, no nested surface

Modified

PathChange
apps/web/src/shared/ui/paint/paint.tsBand, BAND, BAND_SEEDS, buildBand
apps/web/src/shared/ui/paint/styles.tsre-export paintedSection, paintedPanel — a .tsx may not import styled-system
apps/web/src/shared/ui/paint/index.tsexport both components and their prop types
apps/web/src/shared/ui/index.tsre-export both, beside PaintedSurface
apps/web/panda.config.tsregister both slot recipes in theme.extend.slotRecipes
docs/architecture/design-system.mdthe new section: the stroke builder, the position cycle, the paint-aware secondary ink
specs/026-painted-section/spec.mdtraceability 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 under apps/web/src.
  • No literal colour — #hex, rgb(/rgba(, hsl(/hsla( — anywhere under apps/web/src outside paint.ts, including comments and stories. Named CSS colours are not matched by the guard and are how stories set backdrops.
  • A component .tsx may not import styled-system; recipes reach it through paint/styles.ts. A *.stories.tsx may not import it either — a story is not a styles.ts.
  • Runtime-selected recipe variants must be listed in staticCss, or Panda ships only the literals it happens to scan (024). PaintedPanel's theme is 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 (reach extended) 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, and pad shifting 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 — Tab to the trigger, Enter and Space to toggle. Note that Base UI omits aria-controls entirely while the panel is closed (research F4), so the assertion is aria-expanded always, aria-controls only when open.
  • Storybook is the human's visual gate: pnpm --filter @gw2priory/web storybook, then paint/PaintedPanel for 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.