Skip to content

Legendary detail page — Tasks ​

Status: approved Branch: 021-legendary-detail

Status is set by the human, never by the agent. proposed → approved (opens step 4 · Implement, entered through plan mode) → implemented.

The task half of plan.md. Every task is TDD: write the failing test, watch it fail for the right reason, write the minimal code, watch it pass, refactor green, commit. Commands run from the repo root. node/pnpm live at /opt/homebrew/bin — export it first if the shell can't find them (export PATH="/opt/homebrew/bin:$PATH"). Every commit message is imperative, scoped (web:), and ends with the Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> trailer.

Run a single test file with pnpm vitest run <path>; the whole suite with pnpm test; types with pnpm typecheck; the React-Compiler build gate with pnpm build.

Global Constraints in plan.md apply to every task and are not repeated per task. Dependency order: data (T1–T2) → pure logic (T3–T4) → presentation (T5–T7) → the key gate (T8) → composition + routing (T9–T10) → verify (T11).


T1 — useRecipeTree suspense hook ​

Satisfies: R3.

Files: Create apps/web/src/api/useRecipeTree.ts; Modify apps/web/src/api/index.ts; Test apps/web/src/api/__tests__/useRecipeTree.test.tsx.

  • [ ] Step 1 — Failing test. Create apps/web/src/api/__tests__/useRecipeTree.test.tsx, mirroring useLegendaries.test.tsx (msw + Suspense wrapper):
tsx
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { renderHook, waitFor } from '@testing-library/react';
import { HttpResponse, http } from 'msw';
import { setupServer } from 'msw/node';
import { type ReactNode, Suspense } from 'react';
import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest';
import { useRecipeTree } from '../useRecipeTree';

const ROOT = {
  node: { id: 30704, name: 'Twilight', rarity: 'Legendary', vendorValue: 100, classification: 'buyable', leaf: false, recipes: [] },
  count: 1,
  recipe: { source: 'mystic-forge', outputCount: 1, ingredients: [], provenance: 'wiki' },
  children: [],
  decision: 'craft',
  unitBuyPrice: 26800000,
  craftCost: 34120000,
  unitCost: 26800000,
  lineCost: 26800000,
  summary: { rootId: 30704, totalCraftCost: 34120000, rootBuyPrice: 40500000, netSell: 34425000, profit: 305000 },
};

const server = setupServer(
  http.get('http://localhost:3000/api/recipe-graph/30704', () => HttpResponse.json(ROOT)),
);
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }));
afterEach(() => server.resetHandlers());
afterAll(() => server.close());

function wrapper({ children }: { children: ReactNode }) {
  const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
  return (
    <QueryClientProvider client={queryClient}>
      <Suspense fallback={null}>{children}</Suspense>
    </QueryClientProvider>
  );
}

describe('T1 — useRecipeTree', () => {
  it('returns the validated priced root, not the response envelope', async () => {
    const { result } = renderHook(() => useRecipeTree(30704), { wrapper });
    await waitFor(() => expect(result.current.node.name).toBe('Twilight'));
    expect(result.current.summary?.totalCraftCost).toBe(34120000);
  });
});
  • [ ] Step 2 — Run, expect fail. pnpm vitest run apps/web/src/api/__tests__/useRecipeTree.test.tsx → FAIL (useRecipeTree not found).

  • [ ] Step 3 — Implement the hook. Create apps/web/src/api/useRecipeTree.ts (mirror useLegendaries.ts):

ts
import { useSuspenseQuery } from '@tanstack/react-query';
import type { z } from 'zod';
import { getRecipeGraphControllerGetQueryOptions } from './generated/endpoints/recipe-graph/recipe-graph';
import { RecipeGraphControllerGetResponse } from './generated/endpoints/recipe-graph/recipe-graph.zod';
import { suspenseOptions } from './suspenseOptions';

// The whole recursive priced tree; nodes share this shape (children are the same type).
export type RecipeTree = z.infer<typeof RecipeGraphControllerGetResponse>;

// Orval does not wire its validators into its hooks, so this is where response integrity is
// enforced — a malformed tree throws into QueryBoundary rather than reaching a component
// (same rationale as useLegendaries). The `/recipe-graph/:itemId` route is keyless.
export function useRecipeTree(itemId: number): RecipeTree {
  const query = useSuspenseQuery(
    suspenseOptions(getRecipeGraphControllerGetQueryOptions(itemId)),
  );
  return RecipeGraphControllerGetResponse.parse(query.data.data);
}
  • [ ] Step 4 — Export from the barrel. In apps/web/src/api/index.ts add export { useRecipeTree, type RecipeTree } from './useRecipeTree'; beside the other hook exports.

  • [ ] Step 5 — Run, expect pass. pnpm vitest run apps/web/src/api/__tests__/useRecipeTree.test.tsx → PASS. Then pnpm typecheck.

  • [ ] Step 6 — Teeth. Temporarily break the parse (return query.data.data as RecipeTree without .parse) and confirm the malformed-payload guard would not catch it, then restore. Commit.

bash
git add apps/web/src/api/useRecipeTree.ts apps/web/src/api/index.ts apps/web/src/api/__tests__/useRecipeTree.test.tsx
git commit -m "web: add useRecipeTree suspense hook (021 R3)"

Verified by: apps/web/src/api/__tests__/useRecipeTree.test.tsx.


T2 — useLegendary header selector ​

Satisfies: R2.

Files: Create apps/web/src/features/legendaries/useLegendary.ts; Test apps/web/src/features/legendaries/__tests__/useLegendary.test.tsx.

  • [ ] Step 1 — Failing test.
tsx
import { renderHook } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
import * as api from '../../../api';
import { useLegendary } from '../useLegendary';

const ROWS = [
  { id: 30704, name: 'Twilight', type: 'Weapon', subtype: 'Greatsword', weight: null, rarity: 'Legendary', icon: null, generation: 1 as const },
];

describe('T2 — useLegendary', () => {
  it('finds the row by id', () => {
    vi.spyOn(api, 'useLegendaries').mockReturnValue(ROWS);
    const { result } = renderHook(() => useLegendary(30704));
    expect(result.current?.name).toBe('Twilight');
  });
  it('returns undefined for an id not in the list', () => {
    vi.spyOn(api, 'useLegendaries').mockReturnValue(ROWS);
    const { result } = renderHook(() => useLegendary(99999));
    expect(result.current).toBeUndefined();
  });
});
  • [ ] Step 2 — Run, expect fail. pnpm vitest run apps/web/src/features/legendaries/__tests__/useLegendary.test.tsx → FAIL.

  • [ ] Step 3 — Implement. Create apps/web/src/features/legendaries/useLegendary.ts:

ts
import { type Legendary, useLegendaries } from '../../api';

// The header record is a projection of the already-fetched list — no new endpoint (R2). An id
// absent from the list is not itself a 404; the tree resolution owns not-found (R4).
export function useLegendary(id: number): Legendary | undefined {
  return useLegendaries().find((l) => l.id === id);
}
  • [ ] Step 4 — Run, expect pass. PASS. pnpm typecheck.

  • [ ] Step 5 — Commit.

bash
git add apps/web/src/features/legendaries/useLegendary.ts apps/web/src/features/legendaries/__tests__/useLegendary.test.tsx
git commit -m "web: add useLegendary header selector (021 R2)"

Verified by: apps/web/src/features/legendaries/__tests__/useLegendary.test.tsx.


T3 — aggregateNeed + countGatedLeaves (pure) ​

Satisfies: R8, SC8; the need side of P2 #2/#3.

Files: Create apps/web/src/features/legendaries/aggregateNeed.ts; Test apps/web/src/features/legendaries/__tests__/aggregateNeed.test.ts.

Interfaces — Produces:

  • NeedItem = { qty: number; unitBuyPrice: number | null; name: string; gated: boolean }.

  • NeedTotals = { items: Map<number, NeedItem>; currencies: Map<number, number> }.

  • aggregateNeed(root: RecipeTree): NeedTotals — the outputCount-aware multiplier walk (expected values, not ceiled).

  • countGatedLeaves(node: RecipeTree): number — gated frontier occurrences under a node.

  • [ ] Step 1 — Failing test. A fixture builder + the four core cases:

ts
import { describe, expect, it } from 'vitest';
import type { RecipeTree } from '../../../api';
import { aggregateNeed, countGatedLeaves } from '../aggregateNeed';

// Minimal node builders — only the fields the walk reads.
const buy = (id: number, name: string, count: number, unitBuyPrice: number): RecipeTree =>
  ({ node: { id, name, rarity: 'Fine', vendorValue: 0, classification: 'buyable', leaf: true, recipes: [] },
     count, recipe: null, children: [], decision: 'buy', unitBuyPrice, craftCost: null, unitCost: unitBuyPrice, lineCost: unitBuyPrice * count }) as RecipeTree;
const gated = (id: number, name: string, count: number): RecipeTree =>
  ({ node: { id, name, rarity: 'Ascended', vendorValue: 0, classification: 'gated', leaf: true, recipes: [] },
     count, recipe: null, children: [], decision: 'gated', unitBuyPrice: null, craftCost: null, unitCost: 0, lineCost: null }) as RecipeTree;
const craft = (id: number, name: string, count: number, outputCount: number, children: RecipeTree[], currencies: { currencyId: number; count: number }[] = []): RecipeTree =>
  ({ node: { id, name, rarity: 'Rare', vendorValue: 0, classification: 'buyable', leaf: false, recipes: [] },
     count, decision: 'craft', unitBuyPrice: null, craftCost: 1, unitCost: 1, lineCost: 1,
     recipe: { source: 'mystic-forge', outputCount,
       ingredients: [
         ...children.map((c) => ({ kind: 'item' as const, itemId: c.node.id, count: c.count })),
         ...currencies.map((c) => ({ kind: 'currency' as const, currencyId: c.currencyId, count: c.count })),
       ], provenance: 'wiki' },
     children }) as RecipeTree;

const ECTO = 19721;

describe('T3 — aggregateNeed', () => {
  it('sums a leaf that appears in two branches into one total (duplicate-material)', () => {
    const root = craft(1, 'root', 1, 1, [
      craft(2, 'giftA', 1, 1, [buy(ECTO, 'Ectoplasm', 250, 30)]),
      craft(3, 'giftB', 1, 1, [buy(ECTO, 'Ectoplasm', 250, 30)]),
    ]);
    expect(aggregateNeed(root).items.get(ECTO)?.qty).toBe(500);
  });

  it('divides by a fractional outputCount (Mystic Clover 3.1 -> expected 74.5)', () => {
    // A parent needing 77 clovers, clover recipe yields 3.1 per batch, each batch needs 3 ecto.
    const clover = craft(19675, 'Mystic Clover', 77, 3.1, [buy(ECTO, 'Ectoplasm', 3, 30)]);
    const root = craft(1, 'root', 1, 1, [clover]);
    // batches = 77 / 3.1 = 24.83..., ecto = 3 * batches = 74.51...
    expect(aggregateNeed(root).items.get(ECTO)?.qty).toBeCloseTo(74.516, 2);
  });

  it('collects currency needs from recipe.ingredients, not children', () => {
    const root = craft(1, 'root', 1, 1, [buy(2, 'x', 1, 5)], [{ currencyId: 23, count: 100 }]);
    expect(aggregateNeed(root).currencies.get(23)).toBe(100);
  });

  it('marks gated frontier leaves and counts occurrences', () => {
    const root = craft(1, 'root', 1, 1, [gated(19677, 'Gift of Exploration', 1), gated(19678, 'Gift of Battle', 1)]);
    const need = aggregateNeed(root);
    expect(need.items.get(19677)?.gated).toBe(true);
    expect(countGatedLeaves(root)).toBe(2);
  });
});
  • [ ] Step 2 — Run, expect fail. pnpm vitest run apps/web/src/features/legendaries/__tests__/aggregateNeed.test.ts → FAIL (module missing).

  • [ ] Step 3 — Implement. Create apps/web/src/features/legendaries/aggregateNeed.ts:

ts
import type { RecipeTree } from '../../api';

export interface NeedItem { qty: number; unitBuyPrice: number | null; name: string; gated: boolean; }
export interface NeedTotals { items: Map<number, NeedItem>; currencies: Map<number, number>; }

// Walk the DECIDED tree carrying a running "units required" multiplier (root = 1). At a craft node,
// batches = units / recipe.outputCount (continuous — matches pricing.ts's cost fold, R8); each item
// child recurses with child.count * batches, each currency ingredient adds count * batches. Any
// non-craft node is a frontier: add its running units and stop. Totals are EXPECTED values; ceiling
// is a display step (T4/T7), not here.
export function aggregateNeed(root: RecipeTree): NeedTotals {
  const items = new Map<number, NeedItem>();
  const currencies = new Map<number, number>();

  const walk = (node: RecipeTree, units: number): void => {
    if (node.decision === 'craft' && node.recipe) {
      const batches = units / node.recipe.outputCount;
      for (const ing of node.recipe.ingredients) {
        if (ing.kind === 'currency') {
          currencies.set(ing.currencyId, (currencies.get(ing.currencyId) ?? 0) + ing.count * batches);
        }
      }
      for (const child of node.children) walk(child, child.count * batches);
      return;
    }
    const id = node.node.id;
    const existing = items.get(id);
    if (existing) {
      existing.qty += units;
    } else {
      items.set(id, {
        qty: units,
        unitBuyPrice: node.unitBuyPrice,
        name: node.node.name ?? `item ${id}`,
        gated: node.decision === 'gated',
      });
    }
  };

  walk(root, 1);
  return { items, currencies };
}

// Gated frontier occurrences under a node — the Craft goal's "can't be bought — N" (whole tree) and
// a gift card's "N not buyable" (subtree). Occurrences, matching the sketch, not distinct ids.
export function countGatedLeaves(node: RecipeTree): number {
  if (node.decision === 'craft' && node.recipe) {
    return node.children.reduce((n, c) => n + countGatedLeaves(c), 0);
  }
  return node.decision === 'gated' ? 1 : 0;
}
  • [ ] Step 4 — Run, expect pass. PASS. pnpm typecheck.

  • [ ] Step 5 — Teeth. Change child.count * batches to child.count (drop the multiplier); the fractional-yield and duplicate cases must fail. Restore.

  • [ ] Step 6 — Commit.

bash
git add apps/web/src/features/legendaries/aggregateNeed.ts apps/web/src/features/legendaries/__tests__/aggregateNeed.test.ts
git commit -m "web: add aggregateNeed outputCount multiplier walk (021 R8)"

Verified by: apps/web/src/features/legendaries/__tests__/aggregateNeed.test.ts.


T4 — mergeOwnVsNeed + needRows (pure) ​

Satisfies: R9, SC9; P2 #1/#2/#3/#5.

Files: Create apps/web/src/features/legendaries/mergeOwnVsNeed.ts; Test apps/web/src/features/legendaries/__tests__/mergeOwnVsNeed.test.ts.

Interfaces — Produces:

  • OwnVsNeedRow = { kind: 'item' | 'currency'; id: number; name: string; need: number; have?: number; short?: number; cost: number | null; gated: boolean }.

  • OwnVsNeed = { rows: OwnVsNeedRow[]; remainingCost: number }.

  • needRows(need: NeedTotals): { rows: OwnVsNeedRow[]; total: number } — the visitor shopping list (buyable items only, need + cost, no have/short).

  • mergeOwnVsNeed(need, materials, wallet): OwnVsNeed — buyable items with have/short/cost, currencies with have/short, gated items need-only.

  • [ ] Step 1 — Failing test.

ts
import { describe, expect, it } from 'vitest';
import type { NeedTotals } from '../aggregateNeed';
import { mergeOwnVsNeed, needRows } from '../mergeOwnVsNeed';

const ECTO = 19721;
const need: NeedTotals = {
  items: new Map([
    [ECTO, { qty: 500, unitBuyPrice: 30, name: 'Ectoplasm', gated: false }],
    [19675, { qty: 74.5, unitBuyPrice: null, name: 'Mystic Clover', gated: false }], // buyable? no price -> treated as untracked-ish
    [30703, { qty: 1, unitBuyPrice: 10200000, name: 'Dusk', gated: false }],          // precursor: untracked
    [19677, { qty: 1, unitBuyPrice: null, name: 'Gift of Exploration', gated: true }],// gated
  ]),
  currencies: new Map([[23, 250]]), // Spirit Shards etc.
};

describe('T4 — mergeOwnVsNeed', () => {
  it('ceils need, counts owned once, computes short (own 250 / need 500 -> 250)', () => {
    const { rows } = mergeOwnVsNeed(need, [{ id: ECTO, count: 250 } as never], []);
    const ecto = rows.find((r) => r.id === ECTO);
    expect(ecto).toMatchObject({ need: 500, have: 250, short: 250 });
  });

  it('ceils an expected fractional need (74.5 -> 75)', () => {
    const { rows } = mergeOwnVsNeed(need, [], []);
    expect(rows.find((r) => r.id === 19675)?.need).toBe(75);
  });

  it('untracked buyable leaf: have undefined, full need to remainingCost', () => {
    const { rows, remainingCost } = mergeOwnVsNeed(need, [], []);
    const dusk = rows.find((r) => r.id === 30703);
    expect(dusk?.have).toBeUndefined();
    expect(dusk?.short).toBe(1);
    expect(remainingCost).toBe(500 * 30 + 1 * 10200000); // ecto (own 0) + dusk; clover/gated/currency carry no gold
  });

  it('a gated leaf is need-only (no cost, no have)', () => {
    const { rows } = mergeOwnVsNeed(need, [], []);
    const gift = rows.find((r) => r.id === 19677);
    expect(gift).toMatchObject({ need: 1, cost: null, gated: true });
    expect(gift?.have).toBeUndefined();
  });

  it('a currency is matched to the wallet by id (no gold)', () => {
    const { rows } = mergeOwnVsNeed(need, [], [{ id: 23, value: 100, name: 'Spirit Shard' } as never]);
    expect(rows.find((r) => r.kind === 'currency' && r.id === 23)).toMatchObject({ name: 'Spirit Shard', have: 100, short: 150, cost: null });
  });

  it('needRows (visitor) lists only buyable items with cost', () => {
    const { rows, total } = needRows(need);
    expect(rows.map((r) => r.id)).toEqual([ECTO, 30703]); // no-price clover, gated, currency excluded
    expect(total).toBe(500 * 30 + 1 * 10200000);
  });
});
  • [ ] Step 2 — Run, expect fail. FAIL (module missing).

  • [ ] Step 3 — Implement. Create apps/web/src/features/legendaries/mergeOwnVsNeed.ts:

ts
import type { Material, WalletEntry } from '../../api';
import type { NeedTotals } from './aggregateNeed';

export interface OwnVsNeedRow {
  kind: 'item' | 'currency';
  id: number;
  name: string;
  need: number;            // ceiled shopping integer
  have?: number;           // undefined = untracked (shown "—")
  short?: number;
  cost: number | null;     // buyable: short*price (merge) / need*price (needRows); null otherwise
  gated: boolean;
}
export interface OwnVsNeed { rows: OwnVsNeedRow[]; remainingCost: number; }

// Visitor list: buyable items only (a price, not gated), need + full cost. Currencies and gated
// items are account-only concerns, absent here.
export function needRows(need: NeedTotals): { rows: OwnVsNeedRow[]; total: number } {
  const rows: OwnVsNeedRow[] = [];
  let total = 0;
  for (const [id, item] of need.items) {
    if (item.gated || item.unitBuyPrice === null) continue;
    const n = Math.ceil(item.qty);
    const cost = n * item.unitBuyPrice;
    total += cost;
    rows.push({ kind: 'item', id, name: item.name, need: n, cost, gated: false });
  }
  return { rows, total };
}

// Connected overlay: buyable items gain have/short/cost and feed remainingCost; currencies gain
// have/short (no gold); gated items are need-only. Owned is counted ONCE (aggregate).
export function mergeOwnVsNeed(need: NeedTotals, materials: Material[], wallet: WalletEntry[]): OwnVsNeed {
  const matById = new Map(materials.map((m) => [m.id, m]));
  const walById = new Map(wallet.map((w) => [w.id, w]));
  const rows: OwnVsNeedRow[] = [];
  let remainingCost = 0;

  for (const [id, item] of need.items) {
    const n = Math.ceil(item.qty);
    if (item.gated) {
      rows.push({ kind: 'item', id, name: item.name, need: n, cost: null, gated: true });
      continue;
    }
    const have = matById.get(id)?.count;              // undefined = untracked
    const short = Math.max(0, n - (have ?? 0));
    const cost = item.unitBuyPrice === null ? null : short * item.unitBuyPrice;
    if (item.unitBuyPrice !== null) remainingCost += short * item.unitBuyPrice;
    rows.push({ kind: 'item', id, name: item.name, need: n, have, short, cost, gated: false });
  }

  for (const [id, qty] of need.currencies) {
    const n = Math.ceil(qty);
    const w = walById.get(id);
    const have = w?.value;
    const short = Math.max(0, n - (have ?? 0));
    rows.push({ kind: 'currency', id, name: w?.name ?? `currency ${id}`, need: n, have, short, cost: null, gated: false });
  }

  return { rows, remainingCost };
}
  • [ ] Step 4 — Run, expect pass. PASS. pnpm typecheck.

  • [ ] Step 5 — Teeth. Replace Math.max(0, n - (have ?? 0)) with n - (have ?? 0); the own-250 case still passes but add a transient check that a fully-owned item gives short: 0 not negative — confirm it fails, restore.

  • [ ] Step 6 — Commit.

bash
git add apps/web/src/features/legendaries/mergeOwnVsNeed.ts apps/web/src/features/legendaries/__tests__/mergeOwnVsNeed.test.ts
git commit -m "web: add mergeOwnVsNeed + needRows (021 R9)"

Verified by: apps/web/src/features/legendaries/__tests__/mergeOwnVsNeed.test.ts.


T5 — LegendarySummary (two goals) ​

Satisfies: R6; SC2, SC3; P1 #2/#3, P2 #4.

Files: Create apps/web/src/features/legendaries/OwnVsNeedProvider.tsx (context + hook only, this task) and LegendarySummary.tsx; Modify styles.ts; Test apps/web/src/features/legendaries/__tests__/LegendarySummary.test.tsx.

Prerequisite — do first, in this task: create OwnVsNeedProvider.tsx with only the context and hook so consumers (this task, T6, T7) can import them; T8 completes the same file with the OwnVsNeedProvider component + Reconcile:

ts
import { createContext, use } from 'react';
import type { OwnVsNeed } from './mergeOwnVsNeed';
export const OwnVsNeedContext = createContext<OwnVsNeed | null>(null);
export const useOwnVsNeed = (): OwnVsNeed | null => use(OwnVsNeedContext);
  • [ ] Step 1 — Failing test.
tsx
import { render, screen } from '@testing-library/react';
import { describe, expect, it } from 'vitest';
import { LegendarySummary } from '../LegendarySummary';
import { OwnVsNeedContext } from '../OwnVsNeedProvider';

const summary = { rootId: 30704, totalCraftCost: 34120000, rootBuyPrice: 40500000, netSell: 34425000, profit: 305000 };

const renderWith = (remaining: number | null) =>
  render(
    <OwnVsNeedContext value={remaining === null ? null : { rows: [], remainingCost: remaining }}>
      <LegendarySummary summary={summary} gatedCount={6} />
    </OwnVsNeedContext>,
  );

describe('T5 — LegendarySummary', () => {
  it('Craft goal: label says "saves" when buy > craft, and shows the gated count', () => {
    renderWith(null);
    expect(screen.getByText(/crafting saves you/i)).toBeInTheDocument();
    expect(screen.getByText(/can.t be bought/i)).toHaveTextContent('6');
  });

  it('Profit goal, not connected: material cost is the full craft cost', () => {
    renderWith(null);
    const profit = screen.getByTestId('profit-material-cost');
    expect(profit).toHaveTextContent('3,412'); // 34120000 copper -> 3,412g (Coins full)
  });

  it('Profit goal, connected: material cost is the remaining cost and profit recomputes', () => {
    renderWith(20500000); // your remaining 2,050g
    expect(screen.getByTestId('profit-material-cost')).toHaveTextContent('2,050');
    // profit = netSell(3,442g) - remaining(2,050g) -> positive
    expect(screen.getByTestId('profit-profit')).toHaveTextContent('1,392');
  });
});
  • [ ] Step 2 — Run, expect fail. FAIL (component + context missing — do T8's context export first).

  • [ ] Step 3 — Implement. Create apps/web/src/features/legendaries/LegendarySummary.tsx (add the goal*/stat* styles to styles.ts):

tsx
import { Coins } from '../../shared/ui';
import type { RecipeTree } from '../../api';
import { goalStyles, statLabelStyles, statStyles, statValueStyles } from './styles';
import { useOwnVsNeed } from './OwnVsNeedProvider';

type Summary = NonNullable<RecipeTree['summary']>;

function Stat({ label, children, testId }: { label: string; children: React.ReactNode; testId?: string }) {
  return (
    <div className={statStyles}>
      <span className={statLabelStyles}>{label}</span>
      <span className={statValueStyles} data-testid={testId}>{children}</span>
    </div>
  );
}

const dashOr = (c: number | null) => (c === null ? <span>—</span> : <Coins value={c} />);

export function LegendarySummary({ summary, gatedCount }: { summary: Summary; gatedCount: number }) {
  const own = useOwnVsNeed();
  const craft = summary.totalCraftCost;
  const buy = summary.rootBuyPrice;
  const saves = craft !== null && buy !== null ? buy - craft : null;

  const materialCost = own ? own.remainingCost : craft;
  const profit = summary.netSell !== null && materialCost !== null ? summary.netSell - materialCost : null;
  const margin = profit !== null && materialCost ? `${((profit / materialCost) * 100).toFixed(1)}%` : '—';

  return (
    <div className={goalStyles}>
      <section aria-label="Craft">
        <h2>Goal: Craft</h2>
        <Stat label="Craft cost">{dashOr(craft)}</Stat>
        <Stat label="Buy it instead">{dashOr(buy)}</Stat>
        <Stat label={saves !== null && saves < 0 ? 'Crafting costs you extra' : 'Crafting saves you'}>
          {saves === null ? <span>—</span> : <Coins value={Math.abs(saves)} />}
        </Stat>
        <Stat label="Can't be bought">{gatedCount} ingredients</Stat>
      </section>
      <section aria-label="Profit">
        <h2>Goal: Profit</h2>
        <Stat label={own ? 'Your remaining cost' : 'Material cost'} testId="profit-material-cost">{dashOr(materialCost)}</Stat>
        <Stat label="Sells for, after tax">{dashOr(summary.netSell)}</Stat>
        <Stat label="Profit" testId="profit-profit">{dashOr(profit)}</Stat>
        <Stat label="Margin">{margin}</Stat>
      </section>
    </div>
  );
}

Add to styles.ts: goalStyles (grid, 2 cols), statStyles, statLabelStyles (color: 'text.muted'), statValueStyles (fontWeight: 'semibold') — all tokens, no literals.

  • [ ] Step 4 — Run, expect pass. PASS. Then pnpm build (React-Compiler gate — avoid destructured-default + inline-type props, per the compiler memo). pnpm typecheck.

  • [ ] Step 5 — Teeth. Flip the saves label condition to always "saves you"; the negative-saves case must fail. Restore. Commit.

bash
git add apps/web/src/features/legendaries/LegendarySummary.tsx apps/web/src/features/legendaries/styles.ts apps/web/src/features/legendaries/__tests__/LegendarySummary.test.tsx
git commit -m "web: add LegendarySummary two-goal block (021 R6)"

Verified by: apps/web/src/features/legendaries/__tests__/LegendarySummary.test.tsx.


T6 — LegendaryTree (gift cards, collapsible) ​

Satisfies: R5; SC4; P1 #4.

Files: Create apps/web/src/features/legendaries/LegendaryTree.tsx; Modify styles.ts; Test apps/web/src/features/legendaries/__tests__/LegendaryTree.test.tsx.

  • [ ] Step 1 — Failing test. (reuse the buy/gated/craft builders from T3 — copy them into the test or a shared __tests__/fixtures.ts; a shared fixtures module is preferable — create apps/web/src/features/legendaries/__tests__/fixtures.ts exporting buy, gated, craft and import it in T3, T6, T7, T9.)
tsx
import { fireEvent, render, screen, within } from '@testing-library/react';
import { describe, expect, it } from 'vitest';
import { LegendaryTree } from '../LegendaryTree';
import { buy, craft, gated } from './fixtures';

const tree = craft(30704, 'Twilight', 1, 1, [
  craft(19626, 'Gift of Fortune', 1, 1, [buy(19721, 'Ectoplasm', 250, 30)]),
  craft(19674, 'Gift of Mastery', 1, 1, [gated(19678, 'Gift of Battle', 1)]),
]);

describe('T6 — LegendaryTree', () => {
  it('renders one card per top-level gift', () => {
    render(<LegendaryTree root={tree} />);
    expect(screen.getByRole('heading', { name: /Gift of Fortune/ })).toBeInTheDocument();
    expect(screen.getByRole('heading', { name: /Gift of Mastery/ })).toBeInTheDocument();
  });

  it('shows a "not buyable" count on a gift with a gated descendant', () => {
    render(<LegendaryTree root={tree} />);
    const mastery = screen.getByRole('region', { name: /Gift of Mastery/ });
    expect(within(mastery).getByText(/1 not buyable/)).toBeInTheDocument();
  });

  it('a gated row shows the gated badge and no price', () => {
    render(<LegendaryTree root={tree} />);
    expect(screen.getByText('Gift of Battle').closest('tr')).toHaveTextContent(/Gated/);
  });

  it('collapsing a parent hides its children', () => {
    render(<LegendaryTree root={tree} />);
    expect(screen.getByText('Ectoplasm')).toBeVisible();
    fireEvent.click(screen.getByRole('button', { name: /toggle Gift of Fortune/i }));
    expect(screen.queryByText('Ectoplasm')).not.toBeVisible();
  });
});
  • [ ] Step 2 — Run, expect fail. FAIL.

  • [ ] Step 3 — Implement. Create LegendaryTree.tsx — one <section role="region" aria-label={gift.name}> per root.children, each with a heading (rarity colour), the gift decision badge, countGatedLeaves(gift) "N not buyable", the gift lineCost via <Coins>, and a recursive <table> of descendant rows (rarity name + swatch, decision badge, ×count, lineCost; gated rows: badge, no price). Collapse via useState<Set<string>> keyed by a per-row id; a parent row is a <button aria-label="toggle {name}">. Rarity class from a `r-${rarity}` → the rarityText(rarity) css helper (add to styles.ts, keyed on the seven tiers, tokens only). Keep the guide-line CSS in styles.ts.

    Reference the sketch's renderRows/gift-card structure (recovered sketch.html) for the exact markup; port it to Panda css() classes (no literals) and <Coins> for every cost.

  • [ ] Step 4 — Run, expect pass. PASS. pnpm build (compiler gate). pnpm typecheck.

  • [ ] Step 5 — Teeth. Remove the countGatedLeaves call (hardcode 0); the "1 not buyable" case fails. Restore. Commit.

bash
git add apps/web/src/features/legendaries/LegendaryTree.tsx apps/web/src/features/legendaries/styles.ts apps/web/src/features/legendaries/__tests__/{LegendaryTree.test.tsx,fixtures.ts}
git commit -m "web: add LegendaryTree gift-card view (021 R5)"

Verified by: apps/web/src/features/legendaries/__tests__/LegendaryTree.test.tsx.


T7 — ShoppingList (end-of-page block) ​

Satisfies: R10; SC5; P2 #1.

Files: Create apps/web/src/features/legendaries/ShoppingList.tsx; Modify styles.ts; Test apps/web/src/features/legendaries/__tests__/ShoppingList.test.tsx.

  • [ ] Step 1 — Failing test.
tsx
import { render, screen, within } from '@testing-library/react';
import { describe, expect, it } from 'vitest';
import type { NeedTotals } from '../aggregateNeed';
import { ShoppingList } from '../ShoppingList';
import { OwnVsNeedContext } from '../OwnVsNeedProvider';

const need: NeedTotals = { items: new Map([[19721, { qty: 500, unitBuyPrice: 30, name: 'Ectoplasm', gated: false }]]), currencies: new Map() };

describe('T7 — ShoppingList', () => {
  it('visitor: lists buyable leaves with quantity + cost + total, no have/short', () => {
    render(
      <OwnVsNeedContext value={null}>
        <ShoppingList need={need} />
      </OwnVsNeedContext>,
    );
    const row = screen.getByText('Ectoplasm').closest('tr')!;
    expect(within(row).getByText('×500')).toBeInTheDocument();
    expect(screen.queryByText(/have/i)).not.toBeInTheDocument();
  });

  it('connected: shows have/short and a your-remaining-cost total', () => {
    render(
      <OwnVsNeedContext value={{ rows: [{ kind: 'item', id: 19721, name: 'Ectoplasm', need: 500, have: 250, short: 250, cost: 250 * 30, gated: false }], remainingCost: 250 * 30 }}>
        <ShoppingList need={need} />
      </OwnVsNeedContext>,
    );
    const row = screen.getByText('Ectoplasm').closest('tr')!;
    expect(within(row).getByText('250')).toBeInTheDocument(); // have
    expect(screen.getByTestId('shopping-total')).toHaveTextContent('75'); // 7,500 copper -> 75s
  });
});
  • [ ] Step 2 — Run, expect fail. FAIL.

  • [ ] Step 3 — Implement. Create ShoppingList.tsx: read useOwnVsNeed(). If present, render own.rows with columns name / need / have (have ?? '—') / short / <Coins value={cost}/> and a data-testid="shopping-total" = <Coins value={own.remainingCost}/> labelled "Your remaining cost". If null, render needRows(need) with name / ×need / <Coins value={cost}/> and total <Coins value={total}/>. A gated row (gated: true) shows a "Gated" tag and no cost. Full-width block at page end; styles in styles.ts (tokens only).

  • [ ] Step 4 — Run, expect pass. PASS. pnpm build. pnpm typecheck.

  • [ ] Step 5 — Teeth. Make the connected branch ignore own and always call needRows; the have/short case fails. Restore. Commit.

bash
git add apps/web/src/features/legendaries/ShoppingList.tsx apps/web/src/features/legendaries/styles.ts apps/web/src/features/legendaries/__tests__/ShoppingList.test.tsx
git commit -m "web: add ShoppingList end-of-page block (021 R10)"

Verified by: apps/web/src/features/legendaries/__tests__/ShoppingList.test.tsx.


T8 — OwnVsNeedProvider + context (key gate) ​

Satisfies: R7; SC6; P2 #6.

Files: Modify apps/web/src/features/legendaries/OwnVsNeedProvider.tsx (add OwnVsNeedProvider + Reconcile; OwnVsNeedContext and useOwnVsNeed already exist from T5); Test apps/web/src/features/legendaries/__tests__/OwnVsNeedProvider.test.tsx.

Interfaces — Produces:

  • <OwnVsNeedProvider need={NeedTotals}>{children}</OwnVsNeedProvider> — no key → provides null (via the T5 context) and renders ConnectAccountPrompt beside children; key → an inner Reconcile component suspends on useMaterials/useWallet, computes mergeOwnVsNeed, provides it. (OwnVsNeedContext / useOwnVsNeed come from T5.)

  • [ ] Step 1 — Failing test. Assert the no-key path renders the prompt and issues no account request (SC6), and the keyed path provides a computed value.

tsx
import { render, screen } from '@testing-library/react';
import { Suspense } from 'react';
import { describe, expect, it, vi } from 'vitest';
import * as api from '../../../api';
import * as keyMod from '../../../shared/lib/useApiKey';
import { OwnVsNeedProvider, useOwnVsNeed } from '../OwnVsNeedProvider';
import { craft, buy } from './fixtures';

const tree = craft(1, 'root', 1, 1, [buy(19721, 'Ectoplasm', 250, 30)]);
const need = { items: new Map([[19721, { qty: 250, unitBuyPrice: 30, name: 'Ectoplasm', gated: false }]]), currencies: new Map() };

function Probe() {
  const own = useOwnVsNeed();
  return <p>remaining {own ? own.remainingCost : 'none'}</p>;
}

describe('T8 — OwnVsNeedProvider', () => {
  it('no key: renders ConnectAccountPrompt, provides null, issues no account request', () => {
    vi.spyOn(keyMod, 'useApiKey').mockReturnValue(null);
    const mats = vi.spyOn(api, 'useMaterials');
    const wal = vi.spyOn(api, 'useWallet');
    render(<OwnVsNeedProvider need={need}><Probe /></OwnVsNeedProvider>);
    expect(screen.getByText(/remaining none/)).toBeInTheDocument();
    expect(screen.getByRole('link', { name: /account/i })).toBeInTheDocument();
    expect(mats).not.toHaveBeenCalled();
    expect(wal).not.toHaveBeenCalled();
  });

  it('with a key: computes and provides the merged own-vs-need', () => {
    vi.spyOn(keyMod, 'useApiKey').mockReturnValue('KEY');
    vi.spyOn(api, 'useMaterials').mockReturnValue([{ id: 19721, count: 100 } as never]);
    vi.spyOn(api, 'useWallet').mockReturnValue([]);
    render(<Suspense fallback={null}><OwnVsNeedProvider need={need}><Probe /></OwnVsNeedProvider></Suspense>);
    expect(screen.getByText(/remaining 4500/)).toBeInTheDocument(); // short 150 * 30
  });
});
  • [ ] Step 2 — Run, expect fail. FAIL.

  • [ ] Step 3 — Implement. Complete OwnVsNeedProvider.tsx (the OwnVsNeedContext + useOwnVsNeed from T5 stay; add below them):

tsx
import { type Material, type WalletEntry, useMaterials, useWallet } from '../../api';
import { ConnectAccountPrompt } from '../../shared/ui';
import { useApiKey } from '../../shared/lib/useApiKey';
import type { NeedTotals } from './aggregateNeed';
import { mergeOwnVsNeed } from './mergeOwnVsNeed';
// OwnVsNeedContext is already declared above (T5).

// The account hooks live in Reconcile, mounted only when a key is present — so they are never
// called conditionally (rules of hooks) and no request is issued for a visitor (R7 / SC6).
function Reconcile({ apiKey, need, children }: { apiKey: string; need: NeedTotals; children: React.ReactNode }) {
  const materials: Material[] = useMaterials(apiKey); // suspends
  const wallet: WalletEntry[] = useWallet(apiKey);    // suspends
  const value = mergeOwnVsNeed(need, materials, wallet);
  return <OwnVsNeedContext value={value}>{children}</OwnVsNeedContext>;
}

// `need` is precomputed by the View, so the provider itself does no walking.
export function OwnVsNeedProvider({ need, children }: { need: NeedTotals; children: React.ReactNode }) {
  const apiKey = useApiKey();
  if (apiKey === null) {
    return (
      <OwnVsNeedContext value={null}>
        {children}
        <ConnectAccountPrompt message="to see what you already own" />
      </OwnVsNeedContext>
    );
  }
  return <Reconcile apiKey={apiKey} need={need}>{children}</Reconcile>;
}
  • [ ] Step 4 — Run, expect pass. PASS. pnpm build (compiler gate — plain destructured params, no inline-default pitfalls). pnpm typecheck.

  • [ ] Step 5 — Teeth. Move useMaterials/useWallet up into OwnVsNeedProvider (called unconditionally); the "no account request" assertion fails (and the compiler build flags the conditional return). Restore. Commit.

bash
git add apps/web/src/features/legendaries/OwnVsNeedProvider.tsx apps/web/src/features/legendaries/__tests__/OwnVsNeedProvider.test.tsx
git commit -m "web: add OwnVsNeedProvider key gate + context (021 R7)"

Verified by: apps/web/src/features/legendaries/__tests__/OwnVsNeedProvider.test.tsx.


T9 — LegendaryDetailView (compose + header) ​

Satisfies: SC1, P1 #1; composition of T5–T8.

Files: Create apps/web/src/features/legendaries/LegendaryDetailView.tsx; Modify styles.ts; Test apps/web/src/features/legendaries/__tests__/LegendaryDetailView.test.tsx.

  • [ ] Step 1 — Failing test. Stub useRecipeTree + useLegendary + useApiKey(null); assert the header (name in rarity colour, Generation 1, Weapon · Greatsword), and that summary, tree, and shopping list all render.
tsx
import { render, screen } from '@testing-library/react';
import { MemoryRouter } from 'react-router';
import { describe, expect, it, vi } from 'vitest';
import * as api from '../../../api';
import * as leg from '../useLegendary';
import * as keyMod from '../../../shared/lib/useApiKey';
import { LegendaryDetailView } from '../LegendaryDetailView';
import { buy, craft } from './fixtures';

const tree = craft(30704, 'Twilight', 1, 1, [craft(19626, 'Gift of Fortune', 1, 1, [buy(19721, 'Ectoplasm', 250, 30)])]);

describe('T9 — LegendaryDetailView', () => {
  it('renders the header + goals + tree + shopping list', () => {
    vi.spyOn(api, 'useRecipeTree').mockReturnValue(tree as never);
    vi.spyOn(leg, 'useLegendary').mockReturnValue({ id: 30704, name: 'Twilight', type: 'Weapon', subtype: 'Greatsword', weight: null, rarity: 'Legendary', icon: null, generation: 1 });
    vi.spyOn(keyMod, 'useApiKey').mockReturnValue(null);
    render(<MemoryRouter><LegendaryDetailView id={30704} /></MemoryRouter>);
    expect(screen.getByRole('heading', { name: 'Twilight', level: 1 })).toHaveClass('c_rarity.legendary');
    expect(screen.getByText(/Generation 1/)).toBeInTheDocument();
    expect(screen.getByText('Goal: Craft')).toBeInTheDocument();
    expect(screen.getByRole('heading', { name: /Gift of Fortune/ })).toBeInTheDocument();
    expect(screen.getByText('Ectoplasm')).toBeInTheDocument();
  });
});
  • [ ] Step 2 — Run, expect fail. FAIL.

  • [ ] Step 3 — Implement. Create LegendaryDetailView.tsx:

tsx
import { useMemo } from 'react';
import { useRecipeTree } from '../../api';
import { aggregateNeed, countGatedLeaves } from './aggregateNeed';
import { LegendarySummary } from './LegendarySummary';
import { LegendaryTree } from './LegendaryTree';
import { OwnVsNeedProvider } from './OwnVsNeedProvider';
import { ShoppingList } from './ShoppingList';
import { useLegendary } from './useLegendary';
import { detailHeaderStyles, detailTitleStyles } from './styles';

export function LegendaryDetailView({ id }: { id: number }) {
  const tree = useRecipeTree(id);
  const legendary = useLegendary(id);
  const need = useMemo(() => aggregateNeed(tree), [tree]);
  const gen = legendary?.generation;

  return (
    <>
      <header className={detailHeaderStyles}>
        <h1 className={detailTitleStyles}>{legendary?.name ?? tree.node.name}</h1>
        <p>
          {legendary?.type}
          {legendary?.subtype ? ` · ${legendary.subtype}` : ''}
          {legendary?.weight ? ` · ${legendary.weight}` : ''}
          {gen ? ` · Generation ${gen}` : ''}
        </p>
      </header>
      {tree.summary && (
        <OwnVsNeedProvider need={need}>
          <LegendarySummary summary={tree.summary} gatedCount={countGatedLeaves(tree)} />
          <LegendaryTree root={tree} />
          <ShoppingList need={need} />
        </OwnVsNeedProvider>
      )}
    </>
  );
}

detailTitleStyles uses color: 'rarity.legendary' (all legendaries are legendary rarity). Add both styles to styles.ts.

  • [ ] Step 4 — Run, expect pass. PASS. pnpm build. pnpm typecheck.

  • [ ] Step 5 — Commit.

bash
git add apps/web/src/features/legendaries/LegendaryDetailView.tsx apps/web/src/features/legendaries/styles.ts apps/web/src/features/legendaries/__tests__/LegendaryDetailView.test.tsx
git commit -m "web: compose LegendaryDetailView (021 SC1/P1)"

Verified by: apps/web/src/features/legendaries/__tests__/LegendaryDetailView.test.tsx.


Satisfies: R4, R12, R13; SC7, SC10; P1 #6/#7.

Files: Create apps/web/src/features/legendaries/LegendaryDetailPage.tsx; Modify apps/web/src/features/legendaries/routes.tsx; Test apps/web/src/features/legendaries/__tests__/LegendaryDetailPage.test.tsx, and extend __tests__/routes.test.tsx.

  • [ ] Step 1 — Failing tests. (a) an id whose tree resolves to a root-only leaf (children: []) shows the "Gen-1 only" note, not a tree; (b) the route table exposes /legendaries/:id; (c) SC10 — a list card Link resolves to the page. Not-found (404 → error boundary) is exercised via the QueryBoundary, so the page test focuses on the non-Gen-1 branch:
tsx
import { render, screen } from '@testing-library/react';
import { MemoryRouter, Routes, Route } from 'react-router';
import { describe, expect, it, vi } from 'vitest';
import * as api from '../../../api';
import * as leg from '../useLegendary';
import { LegendaryDetailPage } from '../LegendaryDetailPage';
import { routes } from '../routes';

const leafRoot = { node: { id: 76158, name: 'Astralaria', rarity: 'Legendary', vendorValue: 0, classification: 'buyable', leaf: true, recipes: [] }, count: 1, recipe: null, children: [], decision: 'craft', unitBuyPrice: 20000000, craftCost: null, unitCost: 20000000, lineCost: 20000000, summary: { rootId: 76158, totalCraftCost: null, rootBuyPrice: 20000000, netSell: 17000000, profit: null } };

describe('T10 — LegendaryDetailPage', () => {
  it('non-Gen-1 (root-only leaf): shows the Gen-1-only note, not a tree', () => {
    vi.spyOn(api, 'useRecipeTree').mockReturnValue(leafRoot as never);
    vi.spyOn(leg, 'useLegendary').mockReturnValue({ id: 76158, name: 'Astralaria', type: 'Weapon', subtype: 'Axe', weight: null, rarity: 'Legendary', icon: null, generation: 2 });
    render(<MemoryRouter initialEntries={['/legendaries/76158']}><Routes><Route path="/legendaries/:id" element={<LegendaryDetailPage />} /></Routes></MemoryRouter>);
    expect(screen.getByText(/Gen-1 weapons only/i)).toBeInTheDocument();
    expect(screen.queryByText('Goal: Craft')).not.toBeInTheDocument();
  });

  it('registers the /legendaries/:id route', () => {
    expect(routes.some((r) => r.path === '/legendaries/:id')).toBe(true);
  });
});
  • [ ] Step 2 — Run, expect fail. FAIL.

  • [ ] Step 3 — Implement. Create LegendaryDetailPage.tsx — read :id via useParams, parse to a number (a non-numeric param → the not-found state). It renders the header always (via a small shared header or by delegating), then branches: useRecipeTree(id) (suspends; a 404 throws into the shell's QueryBoundary); if root.children.length === 0 → header + buy price (root.summary.rootBuyPrice via <Coins>) + a note "The full crafting breakdown is available for Gen-1 weapons only for now." (R12, research V-gen); else render <LegendaryDetailView id={id} />. To keep the branch above the suspending hook clean, do the useRecipeTree call inside a small LegendaryDetailBody that both branches share, or let LegendaryDetailView early-return the note when children.length === 0. Keep the parse/NaN guard in the Page.

    Add to routes.tsx:

tsx
import { LegendaryDetailPage } from './LegendaryDetailPage';
// ...
export const routes: RouteObject[] = [
  { path: '/legendaries', element: <LegendariesPage /> },
  { path: '/legendaries/:id', element: <LegendaryDetailPage /> },
];
  • [ ] Step 4 — Run, expect pass. PASS. Run the existing routes.test.tsx + LegendariesPage.test.tsx (the card Link already points at /legendaries/:id — SC10 is the existing link assertion now resolving to a real route). pnpm build. pnpm typecheck.

  • [ ] Step 5 — Teeth. Change the guard to root.children.length >= 0 (always note); the Gen-1 case (with children) would wrongly show the note — confirm the T9 fixture path still renders the tree by re-running T9. Restore. Commit.

bash
git add apps/web/src/features/legendaries/LegendaryDetailPage.tsx apps/web/src/features/legendaries/routes.tsx apps/web/src/features/legendaries/__tests__/LegendaryDetailPage.test.tsx
git commit -m "web: add LegendaryDetailPage + :id route (021 R4/R12/SC10)"

Verified by: apps/web/src/features/legendaries/__tests__/LegendaryDetailPage.test.tsx, routes.test.tsx, LegendariesPage.test.tsx.


T11 — Verify, traceability, status ​

Satisfies: R14; SC11, SC12; CLAUDE.md definition of done.

Files: Modify specs/021-legendary-detail/spec.md (fill the traceability table; transcribe status → implemented only on the human's word).

  • [ ] Step 1 — Full green. Run, in order, and paste the passing output into the PR:
    • pnpm typecheck
    • pnpm test
    • pnpm build (React-Compiler gate — the whole app compiles)
    • pnpm docs:build (VitePress — specs still compile)
  • [ ] Step 2 — Invariants (SC11). Confirm apps/web/src/__tests__/conventions.test.ts and tokens.test.ts are green (no colour literals added — every cost goes through <Coins>; no new tokens) and the docs/superpowers/ count guard is still zero.
  • [ ] Step 3 — Traceability (SC12). Fill every cell of spec.md's table with the now-existing test name/id (transcription, not excavation): P1 #1→T9, P1 #2/#3→T5, P1 #4→T6, P1 #6/#7→T10, P2 #1→T7, P2 #2/#3→T3/T4, P2 #4→T5, P2 #5→T4, P2 #6→T8; SC1→T9, SC2/SC3→T5, SC4→T6, SC5→T7, SC6→T8, SC7→T10, SC8→T3, SC9→T4, SC10→T10 (existing link test), SC11→this task, SC12→this task. Confirm no empty cell.
  • [ ] Step 4 — Live 404 (research V1 caveat). If a running api + a real item id are available, hit /api/recipe-graph/<unknown-id> and record the observed 404 with the date in the PR description (a dated manual record, not a code gate). Else note it outstanding.
  • [ ] Step 5 — Review. superpowers:requesting-code-review; address findings via superpowers:receiving-code-review.
  • [ ] Step 6 — Status. On the human's explicit word, transcribe spec.md status → implemented inside the branch (part of the PR diff), and say that it was transcribed on their instruction. Commit.
bash
git add specs/021-legendary-detail/spec.md
git commit -m "specs: 021 traceability filled; status -> implemented"

Verified by: the four commands green, the traceability table complete, the review resolved.


Notes ​

Staging area for decisions and surprises found during implementation — including anything that turned out differently from what plan.md assumed. Move each into spec.md, research.md, or docs/ before closing the feature; this section is not a home.

  • If OwnVsNeedProvider drops the unused tree prop (need is always precomputed by the View), update plan.md's File Structure row.
  • The recovered sketch.html is the visual reference for T6's markup (gift-card + guide-lines); it is session-ephemeral, so port its structure into the Panda-styled component rather than linking it.