Skip to content

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

  1. Given a connected account with materials, when /materials renders, then the whole material list sits inside a single painted surface, under one page-level h1.
  2. 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 reports aria-expanded.
  3. 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.
  4. 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.
  5. 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).
  6. Given the account's materials, when the page renders, then a single grand-total value is shown once for the whole panel.
  7. 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 — MaterialsView renders the material list inside one PaintedPanel (a single painted surface, Bark theme), with its sections inside a PaintedPanel.Column so 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 existing categoryOrder. 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 Coins element (icons), not text. This requires widening PaintedSection's meta prop from string to ReactNode. The change is backward-compatible: existing string callers render unchanged. The paintedSectionRecipe meta slot renders a Coins element — icons and numbers — without clipping or wrapping oddly, and the chevron still shifts (its data-painted-section-meta push still fires), when meta is a ReactNode (confirmed — research V1).
  • R4 — Each cell is a filled tile (card background) that stacks a square icon area over a cost row. The icon area shows the item icon with a faded rarity ring — a ::after overlay coloured by the rarity.* tokens (which a painted scope never re-points — 025/026), visible on all four sides — and the count (formatted via formatAmount) overlaid top-left. The cost row below shows the stack value as Coins 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-dimmed and its tile shape, so the grid stays uniform.
  • R6 — Value math is unchanged and reused: groupMaterials (per-category subtotal and grandTotal), materialValue, and Coins. The net-of-tax TP_TAX_RATE toggle still flows through untouched.
  • R7 — Cell and grid geometry live in the feature's Panda styles.ts (a feature-local recipe / css object), tokens only — no inline <style> block and no colour literal anywhere under apps/web/src (026 R13, the guard). The spike's inline <style> is not carried over.
  • R8 — Heading semantics: the page keeps exactly one h1 for the route (PaintedPanel's title renders as h2 — 026 R9). The panel's visible title is the material-storage heading; the route h1 may be visually hidden to avoid a redundant visible heading.
  • R9 — Scope is MaterialsView only. 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). PaintedSection gains a test that a ReactNode meta renders.
  • R11 — No colour literal exists anywhere under apps/web/src outside paint.ts, stories included (026 R13).

Success criteria ​

Measurable and technology-agnostic — outcomes, not implementation.

  • SC1 — On /materials with 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 — PaintedSection accepts a ReactNode meta and still renders a string meta unchanged.
  • SC6 — No colour literal anywhere under apps/web/src outside paint.ts; and typecheck, lint, the full test suite, the production build (where the React Compiler runs) and docs:build are 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 meta widening in R3.
  • PaintedSection and PaintedPanel are used inside a Bark-themed painted scope, which re-points the ink/hairline tokens the cells and coins read; the rarity.* tokens are deliberately not re-pointed, so rarity borders keep their true colour on paint (026).
  • The web client's /account/materials response already carries category, categoryName, categoryOrder, rarity, icon and sellPrice per item — the fields groupMaterials, materialValue and the cell rendering need. A real /account/materials response carries rarity and (mostly) sellPrice and icon for 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.

CriterionTest
P1 #1MaterialsPage.test.tsx — P1 #1: one route h1 and the Material Storage panel title
P1 #2MaterialsPage.test.tsx — P1 #2: a category section is collapsible, open by default
P1 #3MaterialsPage.test.tsx — P1 #3: a category band shows its subtotal in coins
P1 #4MaterialsPage.test.tsx — P1 #4: a cell shows the icon with a rarity border, count, and stack value
P1 #5MaterialsPage.test.tsx — P1 #5: a zero-count cell is dimmed with no count or value
P1 #6MaterialsPage.test.tsx — P1 #6: the panel shows one grand total
P1 #7MaterialsPage.test.tsx — P1 #7: a no-sell-price cell shows no value and is excluded from totals
SC1MaterialsPage.test.tsx — P1 #1: one route h1 and the Material Storage panel title, P1 #2: a category section is collapsible, open by default
SC2MaterialsPage.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
SC3verified by human review of the running page at step 5 (jsdom has no layout — see Test strategy)
SC4MaterialsPage.test.tsx — P1 #5: a zero-count cell is dimmed with no count or value
SC5PaintedSection.test.tsx — R3/SC5: a ReactNode meta renders in the band
SC6CI — pnpm typecheck, pnpm lint, pnpm test, pnpm build, pnpm docs:build
SC7this table