Skip to content

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 meta regardless of the prop's type):
tsx
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 the meta={<span…>} prop. (The existing string-meta test — "P1 #4: the meta text renders" — stays and keeps string covered; SC5 is the two together.)
  • [ ] GREEN: in PaintedSection.tsx, change meta?: string; to meta?: ReactNode; in PaintedSectionProps. ReactNode is already imported (import { type ReactNode, useContext } …). No render change — the JSX already renders {props.meta}.
  • [ ] GREEN: run pnpm typecheck (passes) and pnpm test for the paint suite (passes).
  • [ ] Teeth: revert the type to string; pnpm typecheck fails 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.tsx to the new structure (names below), keeping the renderConnected helper, the mat({...}) 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 }) has aria-expanded="true"; after fireEvent.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') has title="Flax"; the Basic and Legendary icons have different className (the rarity cva); a tradable slot (count 2 × 250) shows getByAltText('silver') within it.
    • "P1 #5: a zero-count cell is dimmed with no count or value" — getByTestId('slot-2') has data-dimmed="true"; queryByText('0') is null; the slot has no coin img.
    • "P1 #6: the panel shows one grand total" — one /total value/i line; a coin img within 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-6 still shows a coin (from the tradable row only).
    • keep "bug fix: a count >= 1000 renders with en-US comma grouping…" unchanged in intent (count via formatAmount).
    • 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.shadow for the scrim/shadow, transparent keyword for the gradient stop):
ts
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.tsx to this shape (keep the existing RARITIES/asRarity helper and the useMaterials/groupMaterials data path; add the imports for PaintedPanel, PaintedSection, visuallyHidden, gridStyles, slotStyles, countStyles, valueStyles, totalStyles):
tsx
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) and pnpm typecheck.
  • [ ] REFACTOR: with tests green — no colour literal (run pnpm lint; the guard test in conventions.test.ts also runs under pnpm test); no useMemo/useCallback/memo; no destructured-parameter defaults (React Compiler). Confirm data-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 a null sellPrice → "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.md with 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). Record SC3 as verified by human review of the running page at step 5 (jsdom has no layout — plan Test strategy), and SC6 as 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 moves spec.md status to implemented inside 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 a card tile → square icon area with a faded ::after rarity ring (rarity cva on the wrapper) + count overlay → an always-present cost row below. Reconciled into spec.md R4/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.shadow in token(...) — if Panda rejects token(colors.paint.shadow) in textShadow/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 and key.txt from the main tree at step 6.