Spec 030 — Materials page, painterly
Status: implemented Branch: 030-materials-painterly
Status is set by the human, never by the agent. It moves draft → approved → implemented.
Problem
The painterly design system exists — PaintedSurface (024), PaintedPanel and PaintedSection (025/026) — and it was tuned against the in-game material-storage window. But nothing in a real page consumes it yet: the components live only in their Storybook catalogue. Meanwhile /materials, the page that most obviously is a material-storage view, still renders in the app's default plain style — a bare grid of bordered icons with counts and coin values, and a plain header per category. The value information that is the whole point of the page (per stack, per category, and in total) reads as flat rows rather than as the game's painted storage panel. Spec 026 explicitly deferred adopting these components in a page to "a later spec"; this is that spec, for the materials page.
User stories
Ordered by priority. Each story must be independently testable and shippable.
P1 — The materials page as a painted storage panel
As a player reviewing my materials and their trading-post value, I want /materials to read as the in-game material-storage window, so that quantity and value sit in a familiar, legible painted panel instead of a plain grid.
Independent test: connect an account and open /materials. The material list renders inside one painted panel titled for material storage; each category is a collapsible section, open on load, with its subtotal shown as coins in the section band; each populated cell shows the item icon with its rarity-coloured border, the count, and — when the item has a sell price — its stack value as coins.
Acceptance scenarios
- Given a connected account with materials, when
/materialsrenders, then the whole material list sits inside a single painted surface, under one page-levelh1. - Given materials spanning several categories, when the page renders, then each category is its own section in category order, open by default, collapsible by click or
Enter/Space, and its trigger reportsaria-expanded. - Given a category whose materials carry a positive total value, when its section renders, then the section band shows that category's subtotal as coin icons (gold/silver/copper) in place of any "types collected" text.
- Given a material with a count greater than zero, when its cell renders, then the cell shows the item icon with a rarity-coloured border visible on all four sides, the count, and — when the item has a sell price — its stack value as compact coins.
- Given a material with a count of zero, when its cell renders, then its icon is dimmed and the cell shows neither a count nor a value (the tile stays, so the grid is even).
- Given the account's materials, when the page renders, then a single grand-total value is shown once for the whole panel.
- Given a material with no trading-post sell price, when the page renders, then its cell shows no value and the material is excluded from its section subtotal and the grand total.
Requirements
- R1 —
MaterialsViewrenders the material list inside onePaintedPanel(a single painted surface, Bark theme), with its sections inside aPaintedPanel.Columnso the bands cycle by position (026 R7). No second painted surface is nested inside (025 R6 / 026 R10). - R2 — Each material category becomes one
PaintedSection, in the existingcategoryOrder. Sections are open by default and collapsible, used uncontrolled as the component ships (026 R3) — no collapsed-state persistence. - R3 — The section band shows the category subtotal as a
Coinselement (icons), not text. This requires wideningPaintedSection'smetaprop fromstringtoReactNode. The change is backward-compatible: existing string callers render unchanged. ThepaintedSectionRecipemetaslot renders aCoinselement — icons and numbers — without clipping or wrapping oddly, and the chevron still shifts (itsdata-painted-section-metapush still fires), whenmetais aReactNode(confirmed — research V1). - R4 — Each cell is a filled tile (
cardbackground) that stacks a square icon area over a cost row. The icon area shows the item icon with a faded rarity ring — a::afteroverlay coloured by therarity.*tokens (which a painted scope never re-points — 025/026), visible on all four sides — and the count (formatted viaformatAmount) overlaid top-left. The cost row below shows the stack value asCoins variant="compact", and is always present (empty when the item has no value) so every cell is the same height. The ring is decoupled from the icon's opacity: a dimmed unowned icon keeps its ring, and a bright owned icon keeps a muted ring. - R5 — Zero-count items render their icon dimmed and show no count and no value; the cell keeps
data-dimmedand its tile shape, so the grid stays uniform. - R6 — Value math is unchanged and reused:
groupMaterials(per-categorysubtotalandgrandTotal),materialValue, andCoins. The net-of-taxTP_TAX_RATEtoggle still flows through untouched. - R7 — Cell and grid geometry live in the feature's Panda
styles.ts(a feature-local recipe /cssobject), tokens only — no inline<style>block and no colour literal anywhere underapps/web/src(026 R13, the guard). The spike's inline<style>is not carried over. - R8 — Heading semantics: the page keeps exactly one
h1for the route (PaintedPanel's title renders ash2— 026 R9). The panel's visible title is the material-storage heading; the routeh1may be visually hidden to avoid a redundant visible heading. - R9 — Scope is
MaterialsViewonly. The not-connected prompt (ConnectAccountPrompt) and the loading / Suspense fallback are unchanged. - R10 —
MaterialsView's existing tests are updated to the new structure, and the new behaviour is covered by named tests (below).PaintedSectiongains a test that aReactNodemetarenders. - R11 — No colour literal exists anywhere under
apps/web/srcoutsidepaint.ts, stories included (026 R13).
Success criteria
Measurable and technology-agnostic — outcomes, not implementation.
- SC1 — On
/materialswith a connected account, the material list renders inside exactly one painted surface, one collapsible section per category, each open on load and collapsible by keyboard. - SC2 — Each category subtotal and the grand total render as coin values, and each populated stack shows its value in its cell; every one of these numbers equals what the current page computes (value math is unchanged).
- SC3 — Every populated cell's icon carries a rarity-coloured ring, faded but visible on all four sides, independent of the icon's own opacity (a dimmed unowned icon keeps its ring; a bright owned icon keeps a muted ring).
- SC4 — Zero-count items are de-emphasised and show no count and no value.
- SC5 —
PaintedSectionaccepts aReactNodemetaand still renders astringmetaunchanged. - SC6 — No colour literal anywhere under
apps/web/srcoutsidepaint.ts; and typecheck, lint, the full test suite, the production build (where the React Compiler runs) anddocs:buildare all green. - SC7 — Every acceptance scenario above is covered by a named test in the traceability table.
Out of scope
- The not-connected prompt and the loading fallback — unchanged (R9). Painting those is a later choice.
- Search, filter, or sort of materials. The page has none today; none is added here.
- Theme switching or per-category themes. One Bark theme for the whole panel (YAGNI).
- The wallet / currencies — a separate feature (020).
- Any API or contract change. The page consumes the existing
GET /api/account/materials; no controller, DTO, OpenAPI document or generated client changes, and none is needed to demonstrate the feature. - Persisting which sections a reader collapsed (uncontrolled, 026 R3).
- The throwaway spike — the
spike/folder and its one-time material dump are deleted, not shipped.
Assumptions
- The painterly components (024–026) are shipped and stable; this feature only consumes them, and their public API is sufficient except for the one
metawidening in R3. PaintedSectionandPaintedPanelare used inside a Bark-themed painted scope, which re-points the ink/hairline tokens the cells and coins read; therarity.*tokens are deliberately not re-pointed, so rarity borders keep their true colour on paint (026).- The web client's
/account/materialsresponse already carriescategory,categoryName,categoryOrder,rarity,iconandsellPriceper item — the fieldsgroupMaterials,materialValueand the cell rendering need. A real/account/materialsresponse carriesrarityand (mostly)sellPriceandiconfor its items, so per-cell rarity borders and stack values render against live data (confirmed — research V2).
Traceability
Each acceptance scenario and success criterion maps to a named test. Filled in during implementation. Materials tests live under apps/web/src/features/materials/; the meta test under apps/web/src/shared/ui/paint/__tests__/PaintedSection.test.tsx.
| Criterion | Test |
|---|---|
| P1 #1 | MaterialsPage.test.tsx — P1 #1: one route h1 and the Material Storage panel title |
| P1 #2 | MaterialsPage.test.tsx — P1 #2: a category section is collapsible, open by default |
| P1 #3 | MaterialsPage.test.tsx — P1 #3: a category band shows its subtotal in coins |
| P1 #4 | MaterialsPage.test.tsx — P1 #4: a cell shows the icon with a rarity border, count, and stack value |
| P1 #5 | MaterialsPage.test.tsx — P1 #5: a zero-count cell is dimmed with no count or value |
| P1 #6 | MaterialsPage.test.tsx — P1 #6: the panel shows one grand total |
| P1 #7 | MaterialsPage.test.tsx — P1 #7: a no-sell-price cell shows no value and is excluded from totals |
| SC1 | MaterialsPage.test.tsx — P1 #1: one route h1 and the Material Storage panel title, P1 #2: a category section is collapsible, open by default |
| SC2 | MaterialsPage.test.tsx — P1 #3: a category band shows its subtotal in coins, P1 #4: a cell shows the icon with a rarity border, count, and stack value, P1 #6: the panel shows one grand total, P1 #7: a no-sell-price cell shows no value and is excluded from totals |
| SC3 | verified by human review of the running page at step 5 (jsdom has no layout — see Test strategy) |
| SC4 | MaterialsPage.test.tsx — P1 #5: a zero-count cell is dimmed with no count or value |
| SC5 | PaintedSection.test.tsx — R3/SC5: a ReactNode meta renders in the band |
| SC6 | CI — pnpm typecheck, pnpm lint, pnpm test, pnpm build, pnpm docs:build |
| SC7 | this table |