Tasks 030 — Materials page, painterly
Execution skill: superpowers:subagent-driven-development — one implementer per task, then a two-stage review (spec compliance, then code quality). superpowers:test-driven-development applies inside every task: no production code before a failing test that demands it. Reach for superpowers:systematic-debugging on any surprise rather than guessing.
Derived from plan.md (approved). Each task is small, independently verifiable, and reviewed as its own diff. Global Constraints in plan.md apply to every task and are not repeated per task.
Ordering: T1 before T2 — MaterialsView passes a ReactNode meta, which only typechecks once T1 has widened the prop. T3 closes out after both.
Test-harness facts (research F3): apps/web has no @testing-library/user-event — drive the collapse with fireEvent.click from @testing-library/react. The materials tests render MaterialsPage in a MemoryRouter with useMaterials and the key store mocked (renderConnected(rows) helper).
T1 — Widen PaintedSection.meta to ReactNode
Satisfies: R3, SC5.
Files: modify apps/web/src/shared/ui/paint/PaintedSection.tsx; modify apps/web/src/shared/ui/paint/__tests__/PaintedSection.test.tsx.
- [ ] RED: add the test below. The RED signal is typecheck, not the runtime (jsdom renders an element as
metaregardless of the prop's type):
it('R3/SC5: a ReactNode meta renders in the band', () => {
render(
<PaintedSurface>
<PaintedSection title="Basic" meta={<span data-testid="meta-node">x</span>} defaultOpen>
body
</PaintedSection>
</PaintedSurface>,
);
const node = screen.getByTestId('meta-node');
expect(node).toBeInTheDocument();
// still flagged as meta, so the chevron shift fires (recipe line 111)
expect(node.closest('[data-painted-section-meta]')).not.toBeNull();
});- [ ] RED: run
pnpm typecheck. Expected FAIL:Type 'Element' is not assignable to type 'string'at themeta={<span…>}prop. (The existing string-meta test —"P1 #4: the meta text renders"— stays and keepsstringcovered; SC5 is the two together.) - [ ] GREEN: in
PaintedSection.tsx, changemeta?: string;tometa?: ReactNode;inPaintedSectionProps.ReactNodeis already imported (import { type ReactNode, useContext } …). No render change — the JSX already renders{props.meta}. - [ ] GREEN: run
pnpm typecheck(passes) andpnpm testfor the paint suite (passes). - [ ] Teeth: revert the type to
string;pnpm typecheckfails again; restore. - [ ] Commit:
web: widen PaintedSection.meta to ReactNode (030 R3).
Verified by: PaintedSection.test.tsx — "R3/SC5: a ReactNode meta renders in the band", plus pnpm typecheck green with a ReactNode meta in use.
T2 — Reskin MaterialsView into the painted panel with overlay cells
Satisfies: R1, R2, R4, R5, R6, R7, R8, R9, R10, R11; P1 #1–#7; SC1, SC2, SC4. (SC3 is human-reviewed at step 5; SC6 is CI/guard.)
Files: modify apps/web/src/features/materials/styles.ts, apps/web/src/features/materials/MaterialsView.tsx, apps/web/src/features/materials/__tests__/MaterialsPage.test.tsx.
- [ ] RED: rewrite the assertions in
MaterialsPage.test.tsxto the new structure (names below), keeping therenderConnectedhelper, themat({...})factory, the locale bug-fix test, and renaming the connect-prompt test. Several fail against the current view (no painted surface, no collapsible trigger,—instead of no-value). Target tests and their essential assertions:"P1 #1: one route h1 and the Material Storage panel title"—getAllByRole('heading', { level: 1 })has length 1 (name/materials/i);getByRole('heading', { level: 2, name: /material storage/i })."P1 #2: a category section is collapsible, open by default"—getByRole('button', { name: /basic crafting materials/i })hasaria-expanded="true"; afterfireEvent.click(button),aria-expanded="false"."P1 #3: a category band shows its subtotal in coins"— two tradable rows in category 6;within(screen.getByTestId('subtotal-6')).getByAltText('silver')is present."P1 #4: a cell shows the icon with a rarity border, count, and stack value"— a Basic and a Legendary item;within(getByTestId('slot-1')).getByAltText('Flax')hastitle="Flax"; the Basic and Legendary icons have differentclassName(the raritycva); a tradable slot (count 2 × 250) showsgetByAltText('silver')within it."P1 #5: a zero-count cell is dimmed with no count or value"—getByTestId('slot-2')hasdata-dimmed="true";queryByText('0')is null; the slot has no coinimg."P1 #6: the panel shows one grand total"— one/total value/iline; a coinimgwithin it."P1 #7: a no-sell-price cell shows no value and is excluded from totals"— two rows in category 6:{count 2, sellPrice 250}and{count 5, sellPrice null}. The null slot shows no coin and no—;subtotal-6still shows a coin (from the tradable row only).- keep
"bug fix: a count >= 1000 renders with en-US comma grouping…"unchanged in intent (count viaformatAmount). - rename the connect test to
"R9/scope: no stored key shows the connect prompt and skips useMaterials"(behaviour unchanged).
- [ ] RED:
pnpm test -- materials(or the file path). Confirm the new-structure tests fail for the right reason (missing painted surface / collapsible / value change), not a setup mistake. - [ ] GREEN: rewrite
styles.ts. Delete the styles the paint components now own (titleStyles,sectionStyles,sectionHeaderStyles,sectionTitleStyles,totalStyles's old form as needed) and define the overlay set — tokens only, no hex/rgb/hsl (paint.shadowfor the scrim/shadow,transparentkeyword for the gradient stop):
export const visuallyHidden = css({
position: 'absolute', width: '1px', height: '1px', padding: '0', margin: '-1px',
overflow: 'hidden', clip: 'rect(0,0,0,0)', whiteSpace: 'nowrap', borderWidth: '0',
});
export const totalStyles = css({
display: 'flex', justifyContent: 'flex-end', alignItems: 'center',
gap: '2', pb: '3', color: 'text.muted', fontSize: 'sm',
});
export const gridStyles = css({
display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(58px, 1fr))', gap: '1',
});
export const slotStyles = css({ position: 'relative', aspectRatio: '1', borderRadius: 'sm' });
export const slotIcon = cva({
base: {
display: 'block', width: 'full', height: 'full', objectFit: 'cover',
borderRadius: 'sm', borderWidth: '2px', borderStyle: 'solid', boxSizing: 'border-box',
},
variants: {
rarity: {
Basic: { borderColor: 'rarity.basic' }, Fine: { borderColor: 'rarity.fine' },
Masterwork: { borderColor: 'rarity.masterwork' }, Rare: { borderColor: 'rarity.rare' },
Exotic: { borderColor: 'rarity.exotic' }, Ascended: { borderColor: 'rarity.ascended' },
Legendary: { borderColor: 'rarity.legendary' },
},
dimmed: { true: { opacity: '0.28' } },
},
});
export const countStyles = css({
position: 'absolute', top: '0', left: '1', fontSize: 'xs', fontWeight: 'bold',
color: 'text.strong', textShadow: '0 1px 2px token(colors.paint.shadow)', pointerEvents: 'none',
});
// Inset by the 2px border so the rarity frame stays visible on all four sides (SC3).
export const valueStyles = css({
position: 'absolute', insetInline: '2px', bottom: '2px',
display: 'flex', justifyContent: 'flex-end', pt: '2', px: '1',
fontSize: '2xs', color: 'text.strong', pointerEvents: 'none',
background: 'linear-gradient(to top, token(colors.paint.shadow), transparent)',
'& img': { width: '13px', height: '13px' },
});- [ ] GREEN: rewrite
MaterialsView.tsxto this shape (keep the existingRARITIES/asRarityhelper and theuseMaterials/groupMaterialsdata path; add the imports forPaintedPanel,PaintedSection,visuallyHidden,gridStyles,slotStyles,countStyles,valueStyles,totalStyles):
export function MaterialsView({ apiKey }: { apiKey: string }) {
const { groups, grandTotal } = groupMaterials(useMaterials(apiKey));
return (
<>
<h1 className={visuallyHidden}>Materials</h1>
<PaintedPanel title="Material Storage">
<PaintedPanel.Column>
{grandTotal > 0 && (
<p className={totalStyles}>
Total value <Coins value={grandTotal} />
</p>
)}
{groups.map((group) => (
<PaintedSection
key={group.id}
title={group.name}
meta={
group.subtotal > 0 ? (
<span data-testid={`subtotal-${group.id}`}>
<Coins value={group.subtotal} />
</span>
) : undefined
}
defaultOpen
>
<div className={gridStyles}>
{group.items.map((item) => {
const value = materialValue(item.count, item.sellPrice);
return (
<div
key={item.id}
data-testid={`slot-${item.id}`}
data-dimmed={item.count === 0}
className={slotStyles}
title={item.name}
>
{item.icon !== null && (
<img
src={item.icon}
alt={item.name}
title={item.name}
loading="lazy"
className={slotIcon({ rarity: asRarity(item.rarity), dimmed: item.count === 0 })}
/>
)}
{item.count > 0 && (
<span className={countStyles}>{formatAmount(item.count)}</span>
)}
{value !== null && item.count > 0 && (
<span className={valueStyles}>
<Coins value={value} variant="compact" />
</span>
)}
</div>
);
})}
</div>
</PaintedSection>
))}
</PaintedPanel.Column>
</PaintedPanel>
</>
);
}- [ ] GREEN: run
pnpm test(materials + paint green) andpnpm typecheck. - [ ] REFACTOR: with tests green — no colour literal (run
pnpm lint; the guard test inconventions.test.tsalso runs underpnpm test); nouseMemo/useCallback/memo; no destructured-parameter defaults (React Compiler). Confirmdata-dimmed={item.count === 0}still renders"true"/omitted as the test expects. - [ ] Teeth: break two guards and watch them fail, then restore — (a) drop the rarity
cva(hardcode one border) →"P1 #4"fails on identical class names; (b) render a value for anullsellPrice →"P1 #7"fails. - [ ] Verify the compiler:
pnpm --filter @gw2priory/web build(the React Compiler only runs here). - [ ] Commit:
web: render /materials as a painted storage panel (030).
Verified by: MaterialsPage.test.tsx — "P1 #1"…"P1 #7", the locale bug-fix test, and "R9/scope: no stored key…"; plus pnpm --filter @gw2priory/web build green.
T3 — Trace and verify
Satisfies: SC6, SC7, and the definition of done's traceability requirement.
- [ ] Fill the traceability table in
spec.mdwith the exact test names from T1 and T2 (P1 #1→"P1 #1: …", …,SC5→"R3/SC5: a ReactNode meta renders in the band",SC1/SC2/SC4→the P1 tests that cover them). RecordSC3as verified by human review of the running page at step 5 (jsdom has no layout — plan Test strategy), andSC6as the CI commands. - [ ] Run the full gate green, in order:
pnpm typecheck,pnpm lint,pnpm test,pnpm --filter @gw2priory/web build,pnpm docs:build. - [ ] Commit:
specs: 030 fill traceability table. - [ ] Hand to step 5 (
verification-before-completion) and the human's diff review. The human movesspec.mdstatus toimplementedinside this branch as part of the PR (agent transcribes on the human's word — CLAUDE.md).
Verified by: the five gate commands green, and every row of the spec.md traceability table naming a real, passing test.
Notes
Staging area for decisions and surprises found during implementation — move each into spec.md, research.md, or docs/ before closing the feature.
- Cell redesigned after implementation (human-directed iteration on the running page). The T2 code block above shows the original overlay cell (scrim + rarity border on the
<img>). The shipped cell is acardtile → square icon area with a faded::afterrarity ring (raritycvaon the wrapper) + count overlay → an always-present cost row below. Reconciled intospec.mdR4/R5/SC3 and the P1 #4 test; the T2 block here is left as the historical task record. - SC3 has no jsdom test (layout/opacity). The rarity ring is confirmed by human review of the running page.
paint.shadowintoken(...)— if Panda rejectstoken(colors.paint.shadow)intextShadow/gradient raw strings, fall back to{colors.paint.shadow}curly-token syntax; both stay token-pure (no literal).- Spike cleanup is not on this branch (the
spike/folder was never committed; it lives only in the main working tree). Remove it andkey.txtfrom the main tree at step 6.