Skip to content

Spec 026 — Painted section and panel ​

Status: implemented Branch: 026-painted-section

Status is set by the human, never by the agent. It moves draft → approved → implemented.

Problem ​

The paint subsystem can paint a container (024) and re-tint everything inside it (025), but it cannot divide one. A painted panel is a single undifferentiated wash: there is no way to give a region a title, mark where one region ends and the next begins, or let a reader collapse a region they do not care about. A plain heading over the wash reads as a text label dropped onto a painting rather than as part of it.

The mechanism is missing as much as the component. buildBackground fills an area — wash, macro density, blooms, drag, grain. Nothing in the subsystem paints a stroke: 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.

User stories ​

Ordered by priority. Each story must be independently testable and shippable — if only P1 ships, there is still something usable.

P1 — A titled, collapsible painted section ​

As a reader of a dense painted panel, I want each region to carry a painted title band I can collapse, so that I can fold away what I am not looking at and keep what I am in view.

Independent test: render a PaintedSection inside any PaintedSurface in Storybook, with no panel around it. The band paints (the first of the three, since nothing positions it — R7), the title and its optional meta text read, activating the header collapses the children, and the chevron turns.

Acceptance scenarios

  1. Given a section rendered with defaultOpen, when the page renders, then its children are visible and its trigger reports aria-expanded="true".
  2. Given an open section, when its header is activated by click or by Enter/Space, then the children are hidden and the trigger reports aria-expanded="false".
  3. Given a closed section, when its header is focused, then a visible focus indicator is drawn, and the header is reachable by Tab alone.
  4. Given a section with meta text, when it renders, then the meta reads at the right of the band, on ground the band's paint does not reach.
  5. Given several sections stacked in one column, when they render, then no two consecutive sections carry the same brush pattern, and the patterns used come from a set of three.
  6. Given a user who prefers reduced motion, when a section is collapsed, then the height change is not animated.

P2 — A painted panel frame ​

As a reader, I want a group of sections to sit inside one painted panel with its own title, so that the whole reads as a single painted surface rather than a stack of separate boxes.

Independent test: render a PaintedPanel with a title, a subtitle and two columns in Storybook. The panel paints its own surface, the header row reads above a hairline that fades out to the right, and a divider fades downward between the columns.

Acceptance scenarios

  1. Given a PaintedPanel with title and subtitle, when it renders, then it paints one painted surface and renders both in a header row above a hairline that fades out toward the right.
  2. Given two columns with divider set on the first, when they render, then a vertical hairline runs between them, strongest at the top and fading to nothing before the bottom.
  3. Given a column marked scroll, when its content exceeds the height the caller sets, then that column scrolls on its own and the panel does not.
  4. Given a panel, when a section inside it renders, then the section paints no surface of its own — the panel is the only painted level.

Requirements ​

  • R1 — PaintedSection and PaintedPanel are closed components in the sense of spec 019: each is one props-driven export owning its Base UI primitive and its Panda styling internally. Neither the primitive nor the recipe leaves the file.
  • R2 — PaintedSection's behaviour comes from Base UI Collapsible (Root/Trigger/Panel): the aria-expanded/aria-controls wiring, the panel id, the open/closed state attributes and the --collapsible-panel-height custom property the height transition reads. No open state, keyboard handler or aria attribute is hand-rolled. The panel's CSS follows Base UI's documented shape including its [hidden]:not([hidden='until-found']) rule, which the spike omitted (research.md F5).
  • R3 — A section's open state is uncontrolled: defaultOpen only. No open/onOpenChange.
  • R4 — The band is house chrome, not a per-call-site choice (025 R5): no paint parameter reaches either component's public API. The tuned values live as one BAND constant in paint.ts.
  • R5 — buildBand joins buildBackground as a pure builder in shared/ui/paint/paint.ts, taking a PaintTheme, the band parameters, a seed and the vertical bleed, and returning CSS background properties. It introduces no new theme data: the band's pigment is the theme's existing bloomLight channel, and its hairline rule is one shared near-white constant, theme-independent for the same reason paint.glow is.
  • R6 — The band's dissolve is multiplicative: the left-to-right ramp multiplies the turbulence rather than being added to it, so past the ramp's reach the result is zero for every pixel and the far end of the band is clean. Confirmed by measurement over the parameter space — research.md V1.
  • R7 — Band variety is bounded to three brush patterns, cycled by position: PaintedPanel.Column walks its children with Children.map and supplies each section's index through context, so bands run 0, 1, 2, 0, … A section rendered outside a Column receives no index and takes the first band. No seed prop is exposed, and no counter is mutated during render — a render-time counter would be impure. Position rather than a hash of the title, because hashing cannot see a section's neighbours: measured over the thirteen real section titles it gave a 7/5/1 spread with four adjacent pairs sharing a band (research.md V3). Cycling by position is even by construction and never repeats a neighbour. This is an amendment to 025's fixed-seed rule — that rule says repeated containers share one seed, and a stack of identical bands reads as a stamped texture. Three is the same bounded-variant answer PaintedSurface's variant: 0|1|2 already gives, and caps a panel of any size at three band rasters. The cycle counts a Column's direct children: sections wrapped in intervening markup fall back to the first band, which is a documented constraint of the API, not a bug to work around.
  • R8 — The section's secondary ink (meta, chevron) uses the existing text.muted token, which a painted scope already re-points to the theme's textDim (025). No new token and no new --paint-* custom property; the spike's --paint-text-dim stub is deleted. Confirmed by computed-style measurement across two themes — research.md V2.
  • R9 — Heading semantics are fixed and documented: PaintedPanel's title renders as h2, a section's trigger is wrapped in h3. No headingLevel prop.
  • R10 — Both components carry square edges and no border radius (025 R7), and a section paints no surface of its own — a painted surface inside another doubles the wash (025 R6).
  • R11 — Styling is config slot recipes (019): paintedSectionRecipe.ts and paintedPanelRecipe.ts beside their components, registered in panda.config.ts, re-exported through paint/styles.ts because a component .tsx may not import styled-system.
  • R12 — PaintedPanel's theme is passed as a runtime variable, so every theme value it can take is declared in the recipe's staticCss (024's rule for runtime-selected variants).
  • R13 — No colour literal exists anywhere under apps/web/src outside paint.ts, stories included.
  • R14 — The discovery spike is throwaway: PaintedSection.spike.stories.tsx and every spike edit to paint.ts are deleted before the branch merges, and the implementation re-lands through TDD. Only tuned numbers survive, as constants. Findings live in research.md.
  • R15 — Each component ships one *.stories.tsx in Storybook, the catalogue home for shared/ui (022). Stories use plain elements and inline styles for layout: a story is not a styles.ts and may not import styled-system.

Success criteria ​

Measurable and technology-agnostic — outcomes, not implementation.

  • SC1 — A section can be opened and closed using only the keyboard, and its state is reported to assistive technology at every point.
  • SC2 — A panel of ten sections issues at most three distinct band images, and no two consecutive sections in a column share one.
  • SC3 — Every one of the five paint themes re-tints the band, when the theme is chosen at runtime rather than written as a literal.
  • SC4 — The region of a band past its reach — where the meta text and the chevron sit — carries no paint, for every value the band parameters can take.
  • SC5 — No colour literal exists anywhere under apps/web/src outside paint.ts, and no spike file or spike edit survives the merge.
  • SC6 — Typecheck, lint, the full test suite and the production build (which is where the React Compiler runs) are all green.
  • SC7 — Every acceptance scenario above is covered by a named test in the traceability table.

Out of scope ​

  • The material list. What a section contains is the caller's business; both components are domain-agnostic and take children. Rarity colours, item icons and counts belong to the materials feature, not the design system.
  • Wiring any feature to these components. This spec ships the design-system pieces and their Storybook catalogue entries. Adopting them in a page is a later spec.
  • Controlled open state and persistence (R3). Remembering which sections a reader left open is a feature concern, and the upgrade is forwarding two props to the same primitive.
  • hiddenUntilFound. Base UI can let browser find-in-page expand a collapsed panel, but it forces the content to stay mounted — for a panel of hundreds of items that is a real cost for a small win.
  • A close button on the panel. The reference has one because it is a game window; a panel in a page does not close.
  • Paint parameters on the public API (R4), and therefore a slider playground in the component's own story. Retuning the band means editing the BAND constant.
  • HTTP endpoints and OpenAPI. This feature adds no server surface: it is apps/web only, touches no controller, contract or generated client, and needs no API change to be demonstrable.

Assumptions ​

  • A PaintedSection always renders inside a painted scope. It reads that scope's ink and hairline through the tokens the scope re-points; on unpainted ground it would still function, but it is not designed for, tested on, or supported there.
  • PaintedPanel is the painted level. Nothing inside it paints a second surface.
  • Base UI 1.6.0's Collapsible is the primitive. Its Accordion was considered and rejected: this version dropped roving focus following the APG guidance change, so grouping would buy only shared value state, which R3 does not want.
  • The spike's tuned band values are the starting point, not a verified result. They were tuned against the in-game material-storage screenshot and reviewed by the human.

Traceability ​

Each acceptance scenario and success criterion maps to a named test. Test files are under apps/web/src/shared/ui/paint/__tests__/: PaintedSection.test.tsx (section), PaintedPanel.test.tsx (panel), PaintedSurface.test.tsx (surface), paintBand.test.ts (builder).

CriterionTest
P1 #1section — "P1 #1: a section rendered defaultOpen shows its children and reports aria-expanded"
P1 #2section — "P1 #2: activating the header collapses the section"
P1 #3section — "P1 #3/R9/SC1: the trigger is a button inside an h3"
P1 #4section — "P1 #4: the meta text renders"
P1 #5section — "R7/P1 #5: consecutive positions take different bands"; panel — "R7/SC2: five sections in a column use three bands and never repeat a neighbour"
P1 #6section — "P1 #6: the height transition is disabled under reduced motion"
P2 #1panel — "P2 #1: the panel renders its title as an h2 with its subtitle"
P2 #2panel — "P2 #2: a column marked divider renders the fading divider"
P2 #3panel — "P2 #3: a column marked scroll scrolls on its own"
P2 #4panel — "P2 #4: a panel of sections paints exactly one surface"
SC1section — "P1 #3/R9/SC1: the trigger is a button inside an h3" (activation is the native button's, so the assertion is that the trigger is one) and "P1 #2: activating the header collapses the section"
SC2builder — "R7/SC2: the three seeds produce three distinct bands"; section — "R7/SC2: the cycle wraps at three"; panel — "R7/SC2: five sections in a column use three bands and never repeat a neighbour"
SC3builder — "R5: the band takes its pigment from the theme — two themes differ"; surface — "R5/R7: a section takes the theme of the surface it sits in"; panel — "R12: every paint theme is shipped by the surface staticCss"
SC4builder — "R6: the ramp multiplies the noise rather than adding to it" (the property), and the measurement in research.md V1's Second measurement (the rendered result)
SC5apps/web/src/__tests__/conventions.test.ts — "P4 #2/SC4: no literal colour values"; and git log over the branch, which carries no spike file at all (see below)
SC6CI — pnpm typecheck, pnpm lint, pnpm test, pnpm build, pnpm docs:build
SC7this table

A note on SC5. tasks.md T1 expected git log --stat to show the spike's deletion commit. It shows nothing at all: the spike lived only in the working tree and never reached a commit on this branch, so there was nothing to delete in history. That is the stronger result — no spike file ever entered the branch — but it is not what the check as written was looking for.