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, mirroringuseLegendaries.test.tsx(msw + Suspense wrapper):
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 (useRecipeTreenot found).[ ] Step 3 — Implement the hook. Create
apps/web/src/api/useRecipeTree.ts(mirroruseLegendaries.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.tsaddexport { 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. Thenpnpm typecheck.[ ] Step 6 — Teeth. Temporarily break the parse (return
query.data.data as RecipeTreewithout.parse) and confirm the malformed-payload guard would not catch it, then restore. Commit.
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.
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:
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.
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:
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:
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 * batchestochild.count(drop the multiplier); the fractional-yield and duplicate cases must fail. Restore.[ ] Step 6 — Commit.
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.
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:
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))withn - (have ?? 0); the own-250 case still passes but add a transient check that a fully-owned item givesshort: 0not negative — confirm it fails, restore.[ ] Step 6 — Commit.
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:
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.
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 thegoal*/stat*styles tostyles.ts):
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.
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/craftbuilders from T3 — copy them into the test or a shared__tests__/fixtures.ts; a shared fixtures module is preferable — createapps/web/src/features/legendaries/__tests__/fixtures.tsexportingbuy,gated,craftand import it in T3, T6, T7, T9.)
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}>perroot.children, each with a heading (rarity colour), the gift decision badge,countGatedLeaves(gift)"N not buyable", the giftlineCostvia<Coins>, and a recursive<table>of descendant rows (rarity name + swatch, decision badge,×count,lineCost; gated rows: badge, no price). Collapse viauseState<Set<string>>keyed by a per-row id; a parent row is a<button aria-label="toggle {name}">. Rarity class from a`r-${rarity}`→ therarityText(rarity)css helper (add tostyles.ts, keyed on the seven tiers, tokens only). Keep the guide-line CSS instyles.ts.Reference the sketch's
renderRows/gift-card structure (recoveredsketch.html) for the exact markup; port it to Pandacss()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
countGatedLeavescall (hardcode 0); the "1 not buyable" case fails. Restore. Commit.
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.
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: readuseOwnVsNeed(). If present, renderown.rowswith columns name / need / have (have ?? '—') / short /<Coins value={cost}/>and adata-testid="shopping-total"=<Coins value={own.remainingCost}/>labelled "Your remaining cost". If null, renderneedRows(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 instyles.ts(tokens only).[ ] Step 4 — Run, expect pass. PASS.
pnpm build.pnpm typecheck.[ ] Step 5 — Teeth. Make the connected branch ignore
ownand always callneedRows; the have/short case fails. Restore. Commit.
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 → providesnull(via the T5 context) and rendersConnectAccountPromptbeside children; key → an innerReconcilecomponent suspends onuseMaterials/useWallet, computesmergeOwnVsNeed, provides it. (OwnVsNeedContext/useOwnVsNeedcome 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.
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(theOwnVsNeedContext+useOwnVsNeedfrom T5 stay; add below them):
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/useWalletup intoOwnVsNeedProvider(called unconditionally); the "no account request" assertion fails (and the compiler build flags the conditional return). Restore. Commit.
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.
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:
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.
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.
T10 — LegendaryDetailPage + route (not-found, non-Gen-1, dead-link)
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 cardLinkresolves to the page. Not-found (404 → error boundary) is exercised via the QueryBoundary, so the page test focuses on the non-Gen-1 branch:
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:idviauseParams, 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'sQueryBoundary); ifroot.children.length === 0→ header + buy price (root.summary.rootBuyPricevia<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 theuseRecipeTreecall inside a smallLegendaryDetailBodythat both branches share, or letLegendaryDetailViewearly-return the note whenchildren.length === 0. Keep the parse/NaNguard in the Page.Add to
routes.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 cardLinkalready 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.
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 typecheckpnpm testpnpm 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.tsandtokens.test.tsare green (no colour literals added — every cost goes through<Coins>; no new tokens) and thedocs/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 observed404with 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 viasuperpowers:receiving-code-review. - [ ] Step 6 — Status. On the human's explicit word, transcribe
spec.mdstatus →implementedinside the branch (part of the PR diff), and say that it was transcribed on their instruction. Commit.
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
OwnVsNeedProviderdrops the unusedtreeprop (need is always precomputed by the View), updateplan.md's File Structure row. - The recovered
sketch.htmlis 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.