Plan 030 — Materials page, painterly
Status: approved Written in plan mode from spec.md and research.md. Approved by the human before any code is written. 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.
Spec: specs/030-materials-painterly/spec.md · Research: specs/030-materials-painterly/research.md
Goal
/materials stops rendering as the app's default plain grid and instead reads as the in-game material-storage window: the whole list sits in one painted panel, each category is a collapsible painted section whose band carries the category subtotal in coins, and each cell shows the item over its rarity-coloured border with the count and — for tradable stacks — the stack value. No value information is lost; it is the same numbers the page shows today, dressed in the painterly system.
Approach
The feature is a consumer of the existing paint system (024–026), not new infrastructure. MaterialsView keeps its data path unchanged — useMaterials(apiKey) → groupMaterials → render — and only its markup and styling change.
The list is wrapped in one PaintedPanel (Bark theme), which renders exactly one PaintedSurface and a titled header. Its single PaintedPanel.Column holds a grand-total line followed by one PaintedSection per category, in categoryOrder. Putting the sections in a Column is what makes their painted bands cycle through three brushes by position (026 R7). Each section is used as it ships — open by default, collapsible, uncontrolled.
The one component change is the enabling one from research V1: PaintedSection's meta prop widens from string to ReactNode, so the section band can hold a Coins element (icons, not text) for the category subtotal. The recipe already renders meta without clipping and shifts the chevron for any truthy meta, so no recipe or render change is needed beyond the prop type.
Each cell is a filled tile (card background) stacking 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, kept in styles.ts as a cva (research F4) — and the count overlaid top-left. The stack value sits in a cost row below, always present so every cell shares a height. The ring is decoupled from the icon's opacity, so a zero-count item shows a dimmed icon with an undimmed ring, no count or value, and keeps its tile — a uniform grid where unowned materials still read. An item with no sell price shows no value and drops out of its subtotal and the grand total — which groupMaterials/materialValue already do; this plan only stops rendering the old em-dash placeholder (research F5).
The route keeps exactly one h1 (spec R8): the visible storage title is the panel's own h2, and the page h1 ("Materials") is rendered visually hidden so the page still has one top-level heading without a redundant visible one.
Architecture
MaterialsPage (apiKey gate — unchanged)
└─ MaterialsView(apiKey) // rewritten render, same data path
├─ h1 "Materials" (visually hidden) // route heading, R8
└─ PaintedPanel title="Material Storage" // the one painted surface, Bark
└─ PaintedPanel.Column
├─ grand-total line → <Coins value={grandTotal}/>
└─ PaintedSection × category // band cycles by position (026 R7)
title = group.name
meta = <Coins value={group.subtotal}/> // ReactNode, R3
└─ grid → cell (card tile) × item
├─ icon area (rarity ring ::after cva; <img> dims when count 0)
│ └─ count (overlay, top-left) // count > 0
└─ cost row (always present) // <Coins compact/> when value != null && count > 0Dependency direction: features/materials → shared/ui/paint and shared/ui/Coins, shared/lib/{tp,formatAmount} — all already in place. groupMaterials (subtotal + grandTotal) and materialValue are reused unchanged.
Tech stack
- React 19 with the React Compiler (runs in
vite build, not vitest — build must be green). Follow the compiler rules: no destructured-parameter defaults, no hand memoization. - Panda CSS — cell/grid styling as a slot-free
css/cvain the feature'sstyles.ts; tokens only. - Base UI
Collapsible— reached only throughPaintedSection; not imported directly here. - @tanstack/react-query — the existing
useMaterialssuspense query; unchanged. - Vitest + @testing-library/react — unit tests with
useMaterialsmocked.
No new dependency. Every piece above is already installed and used elsewhere in apps/web.
Global Constraints
Copied verbatim from the architecture docs. Every task inherits these; do not summarise or reword them — a test asserts they appear here unchanged.
From docs/architecture/typescript.md:
- No
any. Not in app code, not in tests. Useunknownplus narrowing, or model the type properly. If a third-party type forces it, isolate it behind one typed adapter and comment why. - No non-null assertions (
!) to silence the compiler. - No
@ts-expect-errorwithout a comment explaining what is expected and when it can be removed. - Validate everything crossing a boundary (GW2 API responses, HTTP input) at runtime, not just at the type level.
- Prefer pure functions for domain logic. The optimizer must be testable without a network or a database.
- Match the style of surrounding code. No new dependency without justification in the spec or plan.
moduleResolution: "node"andbaseUrlare removed in TS 7. Use"nodenext"(or"bundler"for Vite/bundler-resolved code) pluspaths: { "*": ["./*"] }in place ofbaseUrl.- TypeScript's default
libincludes DOM. A base tsconfig shared by non-DOM and DOM profiles must pin a non-DOM floor explicitly ("lib": ["ES2023"]), ordocument/windowresolve everywhere, silently defeating profile isolation. A profile that needs DOM adds"DOM","DOM.Iterable"on top of that floor. experimentalDecoratorsandemitDecoratorMetadataare supported for typecheck only —tsc --noEmitaccepts decorator syntax and resolves metadata types, but--noEmitnever emitsdesign:paramtypesat runtime regardless of these options. A decorator-consuming runtime (e.g. NestJS DI) needs a separate emitting compiler for that metadata; seestack.md's api build model.
From docs/architecture/stack.md:
- Monorepo, pnpm workspaces.
apps/api— NestJS (TypeScript).apps/web— React (TypeScript).packages/*— shared code (domain types, the curated Mystic Forge dataset) when sharing is real, not speculative.- Postgres for persistence. In-memory cache for the MVP — no Redis until the caching story earns it.
- Vitest everywhere, both apps.
- Deploy: managed PaaS (Fly.io / Railway). CI: GitHub Actions — lint + typecheck + test + build.
- Static data (items, station recipes) is immutable: cache hard.
- Prices are volatile: short TTL, recomputed live.
- GW2 API rate limit: per-IP token bucket, 300 burst, refill 5/sec,
429on overflow. Batch up to 200 ids per?ids=call. - All GW2 API access goes through the client that budgets this. Never call the GW2 API directly from a service.
- API keys are user secrets: never logged, never persisted server-side, never returned to the client. In the MVP the key is held client-side — the browser's
localStorage— and sent per request asAuthorization: Bearer; the api forwards it to GW2 and stores nothing at rest. Encryption at rest applies only if/when server-side key storage is introduced; no such storage exists today. Client-sidelocalStorageis plaintext and readable by any script on the origin (XSS) — a deliberate MVP limitation, established by spec 016 (client-custody, revisit before adding write-scoped or higher-value keys).
From CLAUDE.md: typecheck clean, tests pass, every acceptance scenario and success criterion covered by a test whose name traces to it, no unexplained escape hatches, the human reviews the diff.
File Structure
Exact paths, and what each file is responsible for. A path here is a commitment; a task that touches a file not listed is a signal the plan missed something.
| Path | Change | Responsibility |
|---|---|---|
apps/web/src/shared/ui/paint/PaintedSection.tsx | modified | Widen PaintedSectionProps.meta from string to ReactNode. No render change ({props.meta} already). |
apps/web/src/shared/ui/paint/__tests__/PaintedSection.test.tsx | modified | Add a test that a ReactNode meta (an element) renders in the band and still sets data-painted-section-meta; keep the existing string-meta test (SC5). |
apps/web/src/features/materials/MaterialsView.tsx | modified | Rewrite render: visually-hidden h1, PaintedPanel/Column, grand-total line, one PaintedSection per category with a Coins subtotal meta, overlay cells. Same data path. |
apps/web/src/features/materials/styles.ts | modified | Replace section/title/grid/slot styles with: overlay grid, cell, count, value/scrim, the rarity cva (kept, adjusted to fill the cell), visuallyHidden. Tokens only. Delete styles the paint components now own. |
apps/web/src/features/materials/__tests__/MaterialsPage.test.tsx | modified | Update assertions to the new structure and cover P1 #1–#7 (see Test strategy). Keep slot-{id}, subtotal-{id}, data-dimmed, "Total value" hooks; flip the null-sell-price case from — to no value. |
groupMaterials.ts, groupMaterials.test.ts, MaterialsPage.tsx, routes.tsx, Coins.tsx, tp.ts, formatAmount.ts are unchanged and not listed as touched.
Data & contracts
None. No type, schema, or API shape changes. PaintedSectionProps.meta widens string → ReactNode (a backward-compatible relaxation of a component prop, not a data contract). The page consumes GET /api/account/materials exactly as today; no controller, DTO, OpenAPI, or generated-client change.
Test strategy
Unit tests only, in the existing MaterialsPage.test.tsx harness (research F3): it renders MaterialsPage in a MemoryRouter with useMaterials and the key store mocked, driving the view with in-memory Material[] — no network, deterministic. Each spec scenario maps to a named test:
- P1 #1 — one painted surface + one route
h1: assert exactly one painted-surface root element and oneheadingat level 1. - P1 #2 — collapsible, open by default: the section trigger is a
buttonreportingaria-expanded="true"; a click toggles it to"false"(Base UI owns the behaviour, so the test drives the real button). - P1 #3 — subtotal coins in the band:
within(getByTestId('subtotal-<cat>'))finds a coinimg. - P1 #4 — cell icon + rarity ring + count + value: a Basic and a Legendary icon's wrapper carries different class names (the rarity
cvaoniconWrapStyles), the icon has the item name, and a tradable stack shows a compact coin. - P1 #5 — zero count: the slot has
data-dimmed="true"and shows neither the count nor a coin. - P1 #6 — one grand total: a single "Total value" line with a coin
img. - P1 #7 — no sell price: the cell shows no coin and no dash, and the item is absent from the subtotal/grand total (asserted by a subtotal that equals only the tradable stacks).
- SC5 —
PaintedSection.test.tsx: aReactNodemetarenders; astringmetastill renders.
Not unit-tested, and why. SC3 (rarity ring visible on all four sides, decoupled from the icon's opacity) is a layout/opacity property; jsdom has no layout, so no assertion can prove it. It is verified by human review of the running page at step 5. SC6 (no colour literal; typecheck/lint/test/build/docs green) is covered by the existing conventions.test.ts guard and CI, not by a new test here.
Alternatives considered
- A
metaCoins/domain-specific prop onPaintedSection— rejected.meta: ReactNodeis the minimal, general widening; a materials-shaped prop would couple the design system to one feature. - Keep the spike's inline
stylecast for rarity and the band coins — rejected. Production needs a real prop type (noas unknownescape hatch) and colour instyles.tsas tokens. - Rarity via inline
styleborderColor— rejected (research F4): acvakeeps colour token-side and keeps rarity in the class name, which P1 #4's assertion relies on. - Grand total in the panel
subtitle— rejected:subtitleis a string, which cannot carry coin icons. The total is aCoinselement in the firstColumnchild instead.
Risks
- Band-index shift from the grand-total line being the first
Columnchild (sections then take indices 1…n). Cosmetic only — bands still cycle three brushes and never repeat a neighbour. Accept it; revisit only if the human dislikes the first section's brush. - Legibility token. The count's shadow needs a dark colour; use the
paint.shadowtoken, not a literal. (The value scrim from the first design was dropped in iteration — the cost now sits on the tile below the icon, no overlay.) - SC3 has no automated proof (layout/opacity in jsdom). Mitigated by human review of the running page at step 5.
Open questions
- Panel max-width. The band caps meta width by truncating the title (research V1 caveat); a game-window-like max width keeps that from biting. Pick a concrete value during implementation (~a few hundred px, matching the reference) — not blocking, reality answers it in the running page.
- Spike cleanup. The throwaway
spike/folder and one-time dump live only in the main working tree (untracked, never committed), so they are not on this branch and there is nothing to delete in this diff — they are removed from the main tree at step 6, alongsidekey.txt.