Skip to content

Research 030 — Materials page, painterly ​

Status: complete

Step 1.5 output. Both [NEEDS VERIFICATION] markers in spec.md have a verdict below. Verified against this worktree at commit 451f5c7 (branch 030-materials-painterly), apps/web as it stands on this branch, on 2026-08-20.

V1 — Does the PaintedSection meta slot render a Coins element (icons + numbers) cleanly, with the chevron still shifting, when meta is a ReactNode rather than a string? ​

Question. R3 widens meta from string to ReactNode and passes <Coins> (an element that renders coin <img>s and numbers). For that to work the meta slot must not clip or ellipsis its content, and the chevron's "meta present" shift must fire for a non-string child.

Verdict. Confirmed — the widening is safe and renders as intended; no recipe change is needed beyond the prop type.

Evidence.

  • apps/web/src/shared/ui/paint/paintedSectionRecipe.ts:95-101 — the meta slot is { position: relative; marginLeft: auto; flexShrink: 0; fontSize: 12px; color: text.muted }. It carries no overflow, whiteSpace: nowrap, or textOverflow: ellipsis; those clipping props sit only on the title slot (:90-92) and the collapsible panel (:119). So a Coins element in meta is not clipped, and flexShrink: 0 keeps it from being squeezed — a long category title truncates instead.
  • apps/web/src/shared/ui/paint/paintedSectionRecipe.ts:111 — the chevron drops its auto-margin via '[data-painted-section-meta] ~ &': { marginLeft: '0' }. PaintedSection.tsx sets data-painted-section-meta on the meta span whenever props.meta is truthy; a React element is truthy, so the shift fires for a ReactNode exactly as for a string.
  • Empirical: the discovery spike rendered <Coins> in meta (through a throwaway cast) and the result — coin icons right-aligned in the band, chevron following — was reviewed and is the look the human chose (the Overlay variant).

Caveat. Coins full form for a large subtotal is wider than a short "types collected" string; the title truncates to yield the space (it has minWidth: 0 + ellipsis), so nothing overflows, but a very long title shrinks to very little on a narrow panel. Acceptable for the target panel width; the plan keeps the panel at a game-window-like max width.

V2 — Does a real /account/materials response carry rarity, sellPrice and icon per item, so per-cell rarity borders and stack values render against live data? ​

Question. R4/R6 and P1 scenarios 4 and 7 assume every item has a rarity and an icon, and that sellPrice is present for tradable items (and legitimately null otherwise).

Verdict. Confirmed — live data carries all three; sellPrice is null for a known minority, which the design already handles (no value shown, excluded from totals — scenario 7).

Evidence. One-time fetch of GET /api/account/materials (through the local API with a real key, 2026-08-20), the same dump the spike used:

  • 680 items across 9 categories; 0 items with a null icon; rarities present span the full set (Basic, Fine, Masterwork, Rare, Exotic, Ascended, Legendary).
  • 223 of 680 items have sellPrice: null (untradable materials) — these render no value and drop out of subtotals and the grand total, which is exactly scenario 7.
  • Sample item: { id: 19697, name: "Copper Ore", rarity: "Basic", sellPrice: 36, icon: "https://render.guildwars2.com/…/65925.png" }.

Caveat. The counts are one account's snapshot; the shape (fields present, sellPrice nullable) is what matters and matches apps/api/src/account/account.schema.ts and the generated web model.

F3 — The materials tests live in MaterialsPage.test.tsx, not a MaterialsView.test.tsx ​

Spec R10 refers to "MaterialsView's existing tests". They are actually in apps/web/src/features/materials/__tests__/MaterialsPage.test.tsx (+ groupMaterials.test.ts). The harness renders MaterialsPage inside a MemoryRouter with useMaterials and the key store's getSnapshot mocked (renderConnected(rows)), so it drives the view with in-memory Material[] and no network.

Why it matters. The plan targets that file. The harness is reusable as-is; the reskin edits the assertions, it does not need a new test setup.

F4 — Keep rarity as a cva variant, not the spike's inline style ​

The spike set the rarity border with an inline style object — borderColor: 'var(--colors-rarity-…)'. The feature already has a slotIcon cva in apps/web/src/features/materials/styles.ts whose variants map each rarity to a borderColor token. Reusing that pattern (a) keeps all colour in styles.ts as tokens (R7), and (b) keeps rarity encoded in the class name, which the existing test "P1 #3: slot icon carries the item name and a rarity-specific border" relies on (it asserts a Basic and a Legendary icon have different class names — inline styles would make the classes identical and break it).

Why it matters. A plan decision: rarity styling stays a cva, not inline. Cheaper (reuse), token-clean, and keeps a passing test passing.

F5 — Two behaviour changes to reconcile against existing tests ​

The reskin deliberately changes two behaviours the current suite asserts:

  • No-sell-price cell. Today the cell shows an em dash — (MaterialsPage.test.tsx:99-102, "P2 #4: null sellPrice shows a dash…"). Spec scenario 7 / R4 render no value at all in that case. The plan rewrites that test to assert the cell shows no coin and no dash (and remains excluded from the total, which is unchanged).
  • Everything else keeps its hooks: the plan retains the slot-{id} and subtotal-{id} test ids, the data-dimmed attribute, and the "Total value" text, so the remaining assertions survive with light edits rather than a rewrite.

Why it matters. Names the exact test churn up front, so the plan's task list is honest about what changes and TDD has a target.

Refuted claims ​

None. Both verifications confirmed; the spec stands.

Graduation ​

None. This feature consumes the design system rather than adding infrastructure; no finding here outlives it. (The meta→ReactNode widening is a component change captured in the spec, not a cross-cutting doc.)