Skip to content

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 > 0

Dependency 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/cva in the feature's styles.ts; tokens only.
  • Base UI Collapsible — reached only through PaintedSection; not imported directly here.
  • @tanstack/react-query — the existing useMaterials suspense query; unchanged.
  • Vitest + @testing-library/react — unit tests with useMaterials mocked.

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. Use unknown plus 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-error without 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" and baseUrl are removed in TS 7. Use "nodenext" (or "bundler" for Vite/bundler-resolved code) plus paths: { "*": ["./*"] } in place of baseUrl.
  • TypeScript's default lib includes DOM. A base tsconfig shared by non-DOM and DOM profiles must pin a non-DOM floor explicitly ("lib": ["ES2023"]), or document/window resolve everywhere, silently defeating profile isolation. A profile that needs DOM adds "DOM", "DOM.Iterable" on top of that floor.
  • experimentalDecorators and emitDecoratorMetadata are supported for typecheck only — tsc --noEmit accepts decorator syntax and resolves metadata types, but --noEmit never emits design:paramtypes at runtime regardless of these options. A decorator-consuming runtime (e.g. NestJS DI) needs a separate emitting compiler for that metadata; see stack.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, 429 on 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 as Authorization: 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-side localStorage is 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.

PathChangeResponsibility
apps/web/src/shared/ui/paint/PaintedSection.tsxmodifiedWiden PaintedSectionProps.meta from string to ReactNode. No render change ({props.meta} already).
apps/web/src/shared/ui/paint/__tests__/PaintedSection.test.tsxmodifiedAdd 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.tsxmodifiedRewrite 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.tsmodifiedReplace 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.tsxmodifiedUpdate 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 one heading at level 1.
  • P1 #2 — collapsible, open by default: the section trigger is a button reporting aria-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 coin img.
  • P1 #4 — cell icon + rarity ring + count + value: a Basic and a Legendary icon's wrapper carries different class names (the rarity cva on iconWrapStyles), 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: a ReactNode meta renders; a string meta still 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 on PaintedSection — rejected. meta: ReactNode is the minimal, general widening; a materials-shaped prop would couple the design system to one feature.
  • Keep the spike's inline style cast for rarity and the band coins — rejected. Production needs a real prop type (no as unknown escape hatch) and colour in styles.ts as tokens.
  • Rarity via inline style borderColor — rejected (research F4): a cva keeps colour token-side and keeps rarity in the class name, which P1 #4's assertion relies on.
  • Grand total in the panel subtitle — rejected: subtitle is a string, which cannot carry coin icons. The total is a Coins element in the first Column child instead.

Risks ​

  • Band-index shift from the grand-total line being the first Column child (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.shadow token, 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, alongside key.txt.