Skip to content

Spec 021 — Legendary detail page ​

Status: implemented Branch: 021-legendary-detail

History: this spec was reopened approved → draft once at step 1.5, for two reasons, then re-approved: (1) discovery refuted the "aggregate need is a simple tree sum" premise (research V2 / F3 — fractional outputCount, currencies not tree nodes); (2) a recovered UI sketch (the design chosen in an earlier session, reconstructed from history) was adopted as the MVP visual target and reconciled against the design system in a follow-up brainstorm. The requirements below encode that sketch durably (the sketch file itself is session-ephemeral and not committed).

Post-review refinements (2026-08-18, from live testing). After the whole-branch review, four UI tweaks shipped on top of the requirements below: (a) each tree row now also shows the player's owned count (· have N) when connected — an informational total per line, so the "tree rows = need-only" choice in R5/P2 is relaxed for display; the owned-once accuracy still lives only in the summary / end block; (b) the collapse/expand control was enlarged; (c) each summary figure is rendered in its own card; (d) a "What you already have" figure (= full craft cost − your remaining cost) was added to the Profit goal. These are cosmetic/overlay changes; the data model (aggregateNeed/mergeOwnVsNeed) is unchanged. Direction note: live testing surfaced that the recipe tree re-displays what the wiki / gw2efficiency already show; the durable value is the personalised have / missing diff + buy-vs-craft cost. That reframing is deferred to a new spec (this one ships as the recipe + own-vs-need MVP).

Status is set by the human, never by the agent. It moves draft → approved → implemented.

Problem ​

The legendaries list (spec 019/020 era) already renders one card per legendary and each card is a Link to /legendaries/:id — but that route does not exist, so every card is a dead end. Meanwhile the backend already computes the hard part: GET /recipe-graph/:itemId returns a fully priced crafting tree — every node carries a buy / craft / gated / unknown decision, unit and line costs, and the root carries a cost summary (totalCraftCost, rootBuyPrice, netSell, profit). And spec 018/020 surface the player's holdings via /account/materials and /account/wallet.

Nothing composes these. The project brief names this exact page the flagship MVP centerpiece: "pick a legendary → full dependency tree + step plan + own-vs-need + buy-vs-craft per node," rendered as a nested collapsible list. This spec builds that page as a frontend composition of endpoints that already exist — the list's dead link finally resolves to the tool the project is for.

Visual target. The layout is the reconstructed sketch, reconciled against the shipped design system (brainstorm 2026-08-17). Its shape: a rarity-coloured header; two goal summaries side-by-side — Craft (make-vs-buy) and Profit (craft-and-sell, measured against the player's own remaining cost); a tree presented as one card per top-level Gift, each an indented collapsible table with guide-lines, decision badges, per-line quantities and line costs; and, at the end of the page, a single full-width block that is a plain shopping list for a visitor and gains own-vs-need columns once an account is connected. Single column throughout. It reuses existing tokens (rarity.*, card, border, muted, primary, surface, text.*) and the spec-020 coin component — no new tokens (design-system.md).

HTTP endpoints (contract-first) ​

This spec is web-only. It adds no endpoint and changes no response shape — a deliberate outcome of the brainstorming decision (frontend composition over a backend aggregation endpoint). Endpoints are first-class spec content even when the answer is "none new," so the reasoning is recorded here rather than left implicit:

  • Why no backend. Own-vs-need is an optional overlay (needs the user's API key) on a public priced tree (/recipe-graph/:itemId is keyless). Merging server-side would force the key onto a currently-public endpoint and give it a two-mode (keyed / keyless) contract — strictly more complex than composing on the web side, where each query keeps its natural auth posture and the "not connected" state falls out for free. The server-side PlanningModule earns its place in a later spec (profit ranking across all Gen-1 legendaries — a batch the browser cannot do), not here.

The page consumes, unchanged:

  • GET /recipe-graph/:itemId (keyless) — the priced tree + per-node decisions + root summary (spec 007/008). Validated web-side by the existing RecipeTreeDto Zod shape.
  • GET /api/account/materials (authenticated) — enriched material-storage rows { id, count, … } (spec 018/020).
  • GET /api/account/wallet (authenticated) — enriched currency rows { id, value, … } (spec 018/020).
  • GET /api/legendaries (keyless) — the legendary metadata list, for the header record (spec 019 era).

User stories ​

Ordered by priority. Each story is independently testable and shippable — if only P1 ships, there is still a usable, valuable page.

P1 — See a legendary's make-vs-buy plan (no account needed) ​

As a player (connected or not), I want to open a legendary and see, in the sketch's layout, its two goal summaries, the full buy-vs-craft tree broken into gift cards, and an end-of-page shopping list — so that I understand what it takes, what it costs to make, and whether it is worth crafting.

Independent test: navigating to /legendaries/:id for a Gen-1 legendary, with no API key stored, renders: a rarity-coloured header (name, Generation N, type/subtype, weight for armor); a Craft goal (craft cost, buy-instead, a "crafting saves you / costs extra" figure whose label follows the sign, and a "can't be bought — N ingredients" count); a Profit goal (material cost = full craft cost, sells-after-tax, profit, margin); a gift-card tree (one card per top-level Gift, each an indented collapsible table of rows showing name in rarity colour, decision badge, per-line ×count, and line cost, with gated rows badge-only); and an end-of-page shopping list of buyable leaves (name, qty, cost, and a total). No account request is issued. An unknown / non-item id renders "not found". A non-Gen-1 legendary opens too, showing the header + buy price + a note that the full crafting breakdown is Gen-1 only.

Acceptance scenarios

  1. Given a Gen-1 legendary id, when /legendaries/:id renders, then the header shows the name in its rarity colour, plus Generation N, type (and subtype / armor weight where present).
  2. Given the root summary, when the page renders, then the Craft goal shows craft cost, buy-instead price, a saves/costs figure = rootBuyPrice − totalCraftCost (label "crafting saves you" when ≥ 0, "crafting costs you extra" when < 0), and a "can't be bought — N ingredients" count of gated leaves; a null figure shows "—", never 0.
  3. Given no stored key, when the Profit goal renders, then it shows material cost = full craft cost, sells-after-tax = summary.netSell, profit = netSell − materialCost, and margin = profit ÷ materialCost (or "—" when the basis is 0/null).
  4. Given a resolved tree, when it renders, then it appears as one card per top-level Gift, each an indented collapsible table whose rows show the item name (rarity colour) + a neutral swatch (no per-node icon — the tree payload carries none), a buy / craft / gated / unknown decision badge, a per-line ×count, and the line cost; a gift-card header shows the gift's decision, its buyable subtotal, and a "N not buyable" count where any descendant is gated.
  5. Given no stored key, when the page renders, then the end-of-page block is a plain shopping list — each buyable leaf as name (rarity colour), quantity, and cost, with a total — and no account request is issued.
  6. Given an id unknown to the GW2 API (null-named root → 404), when the page is opened, then it renders a "not found" state, not an empty or broken tree.
  7. Given a non-Gen-1 legendary (no curated recipe → the root does not resolve to a craft tree), when the page is opened, then it renders the header + buy price and a note that the full crafting breakdown is Gen-1 weapons only for now — not a broken/empty tree.

P2 — Personalise it against my account ​

As a connected player, I want the plan reconciled against my account — the Profit goal's material cost becomes my remaining cost, and the end-of-page block gains own-vs-need columns — so that I see my personal cost, not the generic full cost.

Independent test: with a stored key on a Gen-1 legendary, the end-of-page block gains have and short columns for each buyable leaf material (matched to /account/materials by item id) and currency (matched to /account/wallet by currency id), plus a your remaining cost total; and the Profit goal's material cost switches from full craft cost to that remaining cost (profit and margin recompute). Needs are expected values (fractional yields fold in — research V2), displayed ceiled. Tree rows are unchanged (per-line ×count only — owned is reconciled once, in the end block). With no stored key, the own-vs-need columns and remaining cost are replaced by the shared ConnectAccountPrompt, and P1 is unaffected.

Acceptance scenarios

  1. Given a stored key and a Gen-1 tree, when the end block renders, then each buyable leaf material (owned from /account/materials by item id) and currency (owned from /account/wallet by currency id — currencies read from each craft node's recipe.ingredients, since they are not tree nodes; research F3) shows need (ceiled total across the decided tree), have, and short = max(0, ceil(need) − have), plus a your remaining cost total = Σ over buyable leaves of ceil(short) × unitBuyPrice.
  2. Given a material owned in quantity less than the tree's total need (e.g. own 250 ectoplasm, need 500), when the end block renders, then it shows need 500 / have 250 / short 250 — the owned amount counted once against the aggregate total, never per-branch.
  3. Given a leaf whose expected need is fractional because a fractional-yield recipe (Mystic Clover, outputCount 3.1) sits on its path, when it renders, then the displayed need is the expected value ceiled to a whole shopping number (expected 74.5 → need 75); the expected value drives the cost math, the ceiled value is shown.
  4. Given a stored key, when the Profit goal renders, then its material cost is the remaining cost (Σ ceil(short) × unitBuyPrice), and profit/margin recompute against it.
  5. Given a buyable leaf that is not trackable (absent from material storage and the wallet — e.g. a precursor or an intermediate Gift), when the end block renders, then it shows need only (no have), and its full need counts toward the remaining cost.
  6. Given no stored API key, when the page is opened, then the own-vs-need columns and remaining cost are replaced by the shared ConnectAccountPrompt, the Profit goal falls back to full craft cost, and the P1 view is otherwise unchanged.

Requirements ​

  • R1 — Route (features/legendaries/). Add /legendaries/:id to the feature's routes.tsx, rendering a new LegendaryDetailPage. The list's existing card Link resolves to it. The feature keeps owning its route table (react.md R5).

  • R2 — Header record (useLegendary). A web hook derives the header record for :id from the existing useLegendaries() list (find by id) — no new endpoint. It exposes name, type, subtype, weight, rarity, icon, generation. An id absent from the list is not on its own a 404 (a legendary could in principle be missing metadata); the tree resolution owns the not-found decision (R4).

  • R3 — Priced-tree hook (useRecipeTree). Add useRecipeTree(itemId) in apps/web/src/api, mirroring the existing hooks: useSuspenseQuery over the generated /recipe-graph/:itemId query options, response Zod-parsed with the generated RecipeTreeDto schema (Orval does not wire validators into hooks — parsing here is where a malformed tree throws into QueryBoundary, same rationale as useLegendaries). Keyless (no Authorization header). Exported from api/index.ts.

  • R4 — Not-found state. When /recipe-graph/:itemId responds 404 (the controller 404s a null-named root — an id the GW2 API didn't know — recipe-graph.controller.ts:38-41), the page renders a "not found" state, not an empty tree. The legendary id is the item id recipe-graph resolves (research V1 — confirmed).

  • R5 — Tree view (gift cards, nested collapsible). A LegendaryTree renders the root's top-level children as one card per Gift (Gift of Fortune, Gift of Mastery, …); each card renders that Gift's subtree as an indented, collapsible table. A row shows the item name in its rarity colour + a neutral swatch (the tree payload carries no per-node icon, so none is shown — R11), a decision badge (buy / craft / gated / unknown), a per-line ×count, and its lineCost (spec-020 coin component). Gated rows are badge-only — no acquisition-source text (our data has none). A card header shows the Gift's decision badge, its buyable subtotal (lineCost), and a "N not buyable" count where any descendant is gated. A Gift bought outright (no children) shows a "bought outright — no recipe" line. Default expansion depth is a display choice fixed in the plan.

  • R6 — Two goal summaries (LegendarySummary). Two side-by-side goal blocks off the root summary, null figures as "—" never 0:

    • Craft — craft cost (totalCraftCost), buy-instead (rootBuyPrice), a saves/costs figure rootBuyPrice − totalCraftCost whose label is "crafting saves you" when ≥ 0 and "crafting costs you extra" when < 0 (derived web-side — not a summary field), and a "can't be bought — N ingredients" count of gated leaves (R8).
    • Profit — material cost = the account remaining cost (R9) when connected, else totalCraftCost; sells-after-tax = summary.netSell; profit = netSell − materialCost; margin = profit ÷ materialCost (or "—" when the basis is 0/null). The profit/margin here are derived from the chosen material cost, not read from summary.profit (which is the full-cost variant).
  • R7 — Holdings gate (Suspense-safe). The account-dependent subtree (useMaterials / useWallet + the end block's own-vs-need columns + the Profit goal's remaining cost) is mounted only when useApiKey() is non-null, so the suspending account hooks are never called conditionally (rules of hooks) and the not-connected path renders the shared ConnectAccountPrompt (react.md; reuses spec 020's useApiKey + ConnectAccountPrompt). No key → no account request.

  • R8 — Aggregate need (aggregateNeed), outputCount-aware. A pure function walks the decided tree and returns total expected required quantities, keyed separately by item id and currency id (currencies are not tree nodes — they are read from each visited craft node's recipe.ingredients; research F3). The walk carries a running "units required" multiplier, seeded at 1 for the root:

    • At a craft node needing u units, batches = u ÷ node.recipe.outputCount (continuous — matches the backend cost fold, pricing.ts:66; outputCount is fractional for Mystic Clover, research V2). For each recipe.ingredients edge of count c: a currency edge adds c × batches to that currency's total; an item edge recurses into the matching child with u' = c × batches.
    • At any non-craft node (buy / gated / unknown / leaf) reached with u units, add u to that item's total and stop descending (frontier). The node's own count is not re-read at the frontier — the parent already folded it into u.

    The same walk also tallies gated leaves — the count of distinct gated frontier ids overall (Craft goal's "can't be bought — N") and per top-level Gift (R5's "N not buyable"). Totals are expected values (fractional where a fractional yield is on the path); ceiling to a whole shopping number is a display step (R10), not part of this function. Resolves research V2 — the premise that this was a simple sum was refuted; count is per-edge and outputCount (fractional) must divide, so it is a multiplier walk, not Σ count.

  • R9 — Own-vs-need merge (mergeOwnVsNeed). A pure function takes aggregateNeed(tree) plus the materials and wallet rows and returns, per leaf: { kind: 'item' | 'currency', id, name, need, have, short } where need is the ceiled expected total, have is the owned count (materials by item id, wallet by currency id) or undefined when the id is in neither (untracked — R-P2 #5), and short = max(0, ceil(need) − (have ?? 0)); plus remainingCost = Σ over buyable leaves of short × unitBuyPrice (currencies and gated leaves carry no gold — R10). Owned is counted once (aggregate), never per-branch. Pure and unit-tested, so it ports verbatim to the backend if the later ranking feature needs it.

  • R10 — End-of-page block (shopping list / still-need), full width. One ShoppingList block at the end of the page (the sketch's right rail is dropped; the page is single column). It always lists each buyable leaf — name (rarity colour), quantity (ceiled need), and cost — with a total. When connected (inside the R7 gate) each row gains have and short from mergeOwnVsNeed, and the total becomes your remaining cost; a fully-covered leaf (short === 0) is marked satisfied. Non-buyable gated leaves are shown need-only with a gated tag and contribute no gold. When not connected, the own-vs-need columns are replaced by the shared ConnectAccountPrompt.

  • R11 — Coins, rarity, tokens (reuse only). Costs render through the spec-020 coin component (Coins / formatCoin, official coin icons) — not colour-dot literals. The header/tree/list names use the rarity tokens; accents/hover/active use the shipped primary (teal) token; cards, borders, and muted fills use card / border / muted (all present since spec 019). No new colour tokens (no accent, no coin.*, no head); the tokens-never-literals guard stays green. Repeated variants are colocated cvas in the feature's styles.ts (design-system.md "colocated until promoted").

  • R12 — Non-Gen-1 legendaries. The route opens for any legendary id. A non-Gen-1 legendary has no curated and no station recipe, so /recipe-graph/:id resolves it to a root-only leaf — root.children.length === 0 (research V-gen — confirmed). Detected web-side by that empty-children test (the root's decision is force-set to craft by pricing, so it cannot be the signal), the page renders the header + buy price and a note that the full crafting breakdown is Gen-1 weapons only for now — never a broken or empty tree. No backend change.

  • R13 — Suspense / error ownership. The page renders no loading or error branch of its own; the shell's Suspense + QueryBoundary own both (react.md R10, SC10 pattern). The not-found state (R4), the no-key gate (R7), and the non-Gen-1 note (R12) are render decisions, not error states.

  • R14 — No new docs/superpowers/ artifacts; the tokens guard, the docs/superpowers/ count guard, the web conventions test, and all prior suites stay green, updated only where this spec changes their subject (the legendaries routes.tsx, api/index.ts).

Mark anything unresolved inline rather than assuming an answer. Two markers, split by who can answer:

  • [NEEDS CLARIFICATION: specific question] — only the human can answer. Blocks step 1.5.
  • [NEEDS VERIFICATION: specific question] — only reality can answer, resolved in research.md with cited evidence. Blocks the approval gate.

No [NEEDS CLARIFICATION] remains — the design was agreed in brainstorming: full priced tree + own-vs-need against the connected account, composed on the web side (no new backend); own-vs-need counts only material-storage items + wallet currencies (silent on untracked nodes); precise, aggregate own-vs-need (owned counted once) shown in the end-of-page block with a recalculated remaining cost (also feeding the Profit goal), tree rows per-line only. The UI follows the recovered sketch reconciled against the design system (two goals, gift-card tree, single column, no new tokens). All [NEEDS VERIFICATION] items carry a verdict in research.md (codebase read 2026-08-17):

  • V1 — id mapping & 404. Confirmed (research V1). The legendary id is the item id /recipe-graph/:itemId resolves (legendaries.data.ts:27 lists Bifrost 30698; recipe-graph.service.bifrost.test.ts:137,147 resolves it end-to-end), and a null-named root → 404 (recipe-graph.controller.ts:38-41). Caveat: the 404 is proven by controller code + the integration test, not a live call this session — a dated manual check during implementation closes the last gap.
  • V2 — count semantics. Confirmed per-edge, and it refuted the "simple sum" premise (research V2 / F3). count is per-edge (project-tree.ts:21,47-52), lineCost is per-line not rolled up (pricing.ts:160), and outputCount is fractional (Mystic Clover 3.1, gen1-weapons.json) and divides in the cost fold (pricing.ts:66); currencies are not tree nodes (project-tree.ts:45) but live on recipe.ingredients. R8/R9/P2 were rewritten to an outputCount-aware multiplier walk over items and currencies, producing expected-value quantities displayed ceiled — which is why this spec was reopened to draft.
  • V-gen — non-Gen-1 resolution. Confirmed (research V-gen). Curated legendaryOutputIds is 21 Gen-1 ids only; a Gen-2/3 id is in neither curated nor station data, so resolve() returns a root-only leaf (recipe-graph.service.ts:100, leaf: recipes.length === 0) with empty children. The root's decision is force-set to craft by pricing (pricing.ts:207), so R12 detects the no-tree case by root.children.length === 0, not the decision.

Success criteria ​

Measurable and outcome-focused. Web behaviors are asserted with the existing apps/web stack (Vitest + Testing Library) against stubbed data hooks; the pure functions with unit tests.

  • SC1 — /legendaries/:id renders a header with name in its rarity colour, Generation N, type (+ subtype / armor weight where present). (web test)
  • SC2 — The Craft goal shows craft cost, buy-instead, a saves/costs figure whose label follows the sign of rootBuyPrice − totalCraftCost, and a "can't be bought — N ingredients" gated count; null figures show "—". (web test)
  • SC3 — The Profit goal shows material cost, sells-after-tax, profit = netSell − materialCost, and margin; not connected → material cost is the full craft cost; connected → material cost is the remaining cost and profit/margin recompute. (web test)
  • SC4 — The tree renders as one card per top-level Gift; each card is a collapsible table whose rows show a rarity-coloured name (no per-node icon), a buy / craft / gated / unknown badge, a per-line ×count, and line cost; expand/collapse toggles a subtree; a card header shows the Gift's subtotal and any "N not buyable" count; gated rows are badge-only. (web test)
  • SC5 — The end-of-page block lists each buyable leaf with quantity + cost + a total for a visitor; with a stored key it gains have / short per row and the total becomes your remaining cost. (web test)
  • SC6 — With no key, the whole page (header, both goals, tree, shopping list) renders and no account request is issued; the own-vs-need area shows the shared ConnectAccountPrompt. (web test, incl. a spy asserting the account hooks are not called)
  • SC7 — An unknown / non-item id renders "not found"; a non-Gen-1 legendary renders header + buy price + the "Gen-1 only" note, not a broken tree. (web tests)
  • SC8 — aggregateNeed returns correct expected per-leaf totals over a decided tree: descends only craft nodes, stops at buy / gated / unknown / leaf frontiers, folds a leaf appearing in multiple branches into one total, divides by each craft node's outputCount (Mystic Clover 3.1 → expected 74.5), tallies gated leaves, and collects currency needs from recipe.ingredients (not children). (unit test, incl. duplicate-material, fractional-yield, currency, and gated-count cases)
  • SC9 — mergeOwnVsNeed produces need / have / short per leaf with owned counted once and need ceiled (own 250, need 500 → short 250; expected 74.5 → need 75), have undefined → "—" with full need toward cost, currencies matched to the wallet by id, and remainingCost = Σ ceil(short) × unitBuyPrice over buyable leaves (currencies/gated carry no gold). (unit test)
  • SC10 — The list card Link navigates to the detail page (the dead link resolves). (web / route test)
  • SC11 — Costs render via the spec-020 coin component; the tokens-never-literals guard, the web conventions test, and the docs/superpowers/ count guard stay green with no new colour tokens; all prior suites pass. (existing invariants)
  • SC12 — Every acceptance scenario and success criterion maps to a named test, with no gap; the suite passes.

Out of scope ​

  • A backend aggregation endpoint / PlanningModule — own-vs-need is composed web-side; the server-side optimizer belongs to the later profit-ranking spec (batch across all Gen-1 legendaries), not here.
  • The 3-way profit cost-basis switch (the sketch's Buy-now / I-own-them / I-farmed-them) — folded to a single account-driven remaining cost (Profit goal, R6). The opportunity-cost and farmed bases are a later refinement.
  • Per-node acquisition-source text for gated items ("Karma — …", "WvW Reward Track") — our data has no such field; gated nodes are badge-only (R5). A curated source table is a later addition.
  • Per-node item icons in the tree — the /recipe-graph payload carries no icon per node; rows show a rarity-coloured name + neutral swatch. Only the header icon (from the list) is shown.
  • Full crafting trees for non-Gen-1 legendaries — Gen-2/3, armor, trinkets lack curated recipes; they open to header + buy price + a "Gen-1 only" note (R12), not a full tree.
  • The app shell and the legendaries hub — the header chrome (nav, search, theme toggle) is the spec-012 shell, and the list/hub is the existing spec-019 page; the sketch's versions of both are illustration, not this spec's scope.
  • Holdings beyond material storage and the wallet — bank, character inventories, shared slots, the legendary armory. Nodes not in material storage / the wallet are untracked (need-only), by design.
  • Per-line pool allocation in the tree — owned is reconciled once in the end block; tree rows are per-line ×count only. No ambiguous per-branch splitting of an owned pool.
  • Editing / acquiring — read-only. No "buy on TP", no shopping-cart, no marking items acquired.
  • A step-by-step ordered plan / time estimate — the brief's "step plan" and time cost are a later layer; this spec delivers the goals + tree + own-vs-need, not a sequenced questline.
  • Re-deciding buy-vs-craft from holdings — the tree's buy / craft decisions come from the backend as-is; owning a leaf reduces the quantity to buy (remaining cost), it does not flip a node's decision.
  • Price freshness UI / manual refresh — values reflect whatever the price cache holds; no "as of…" indicator.

Assumptions ​

  • 007 / 008 stand — /recipe-graph/:itemId resolves the full tree and returns per-node buy / craft / gated / unknown decisions, unit/line costs, and a root summary; its 404 semantics (unknown / null-named root) hold. This spec consumes it unchanged.
  • 018 / 020 stand — /account/materials and /account/wallet exist, are enriched (id + count / value, name, icon), keyed by item / currency id, and are cached. This spec reads them unchanged.
  • 019 stands — /api/legendaries returns the metadata list (id, name, type, subtype, weight, rarity, icon, generation) and the list page links each card to /legendaries/:id.
  • 016 / 015 stand — the account key flow (useApiKey, localStorage, disconnect), the /api prefix, and CORS.
  • The Zod-first → Orval pipeline stands — useRecipeTree consumes generated query options + the generated RecipeTreeDto Zod schema; no pipeline change (no response shape changes in this spec).
  • react.md / design-system.md hold — feature-folder layout, Suspense-only data flow, Page/View naming, the useApiKey + ConnectAccountPrompt gate, rarity tokens, tokens-never-literals.
  • The design system already provides the needed tokens — rarity.*, surface, text.*, and (since spec 019) card, border, muted, primary; and spec 020 shipped the coin component. So the sketch realises with no new tokens (design-system.md).
  • The UI target is the recovered sketch, reconciled 2026-08-17 — encoded by these requirements; the sketch file is session-ephemeral and intentionally not committed.

Traceability ​

Each acceptance scenario and success criterion maps to a named test. SC11 asserts no empty cell. Filled in during implementation.

All paths under apps/web/src/.

CriterionTest
P1 #1features/legendaries/__tests__/LegendaryDetailView.test.tsx — "renders the header + goals + tree + shopping list"
P1 #2features/legendaries/__tests__/LegendarySummary.test.tsx — "Craft goal: label says …"
P1 #3features/legendaries/__tests__/LegendarySummary.test.tsx — "Profit goal, not connected: material cost is the full craft cost"
P1 #4features/legendaries/__tests__/LegendaryTree.test.tsx — one-card-per-gift / collapse / gated badge
P1 #5features/legendaries/__tests__/ShoppingList.test.tsx — "visitor: lists buyable leaves with quantity + cost + total"
P1 #6features/legendaries/__tests__/LegendaryDetailPage.test.tsx — the 404 case (a NotFoundError renders the not-found state) + the non-numeric-id case; api/__tests__/useRecipeTree.test.tsx — a backend 404 throws NotFoundError
P1 #7features/legendaries/__tests__/LegendaryDetailPage.test.tsx — "non-Gen-1 (root-only leaf): shows the Gen-1-only note"
P2 #1features/legendaries/__tests__/ShoppingList.test.tsx — "connected: shows have/short and a your-remaining-cost total"; mergeOwnVsNeed.test.ts — "a currency is matched to the wallet by id"
P2 #2features/legendaries/__tests__/mergeOwnVsNeed.test.ts — "ceils need, counts owned once, computes short (own 250 / need 500 -> 250)"
P2 #3features/legendaries/__tests__/mergeOwnVsNeed.test.ts — "ceils an expected fractional need (74.5 -> 75)"; aggregateNeed.test.ts — "divides by a fractional outputCount (Mystic Clover 3.1 -> expected 74.5)"
P2 #4features/legendaries/__tests__/LegendarySummary.test.tsx — "Profit goal, connected: material cost is the remaining cost and profit recomputes"
P2 #5features/legendaries/__tests__/mergeOwnVsNeed.test.ts — "untracked buyable leaf: have undefined, full need to remainingCost"
P2 #6features/legendaries/__tests__/OwnVsNeedProvider.test.tsx — "no key: renders ConnectAccountPrompt, provides null, issues no account request"
SC1features/legendaries/__tests__/LegendaryDetailView.test.tsx
SC2features/legendaries/__tests__/LegendarySummary.test.tsx (Craft goal + gated count)
SC3features/legendaries/__tests__/LegendarySummary.test.tsx (Profit goal, connected + not)
SC4features/legendaries/__tests__/LegendaryTree.test.tsx
SC5features/legendaries/__tests__/ShoppingList.test.tsx (visitor + connected)
SC6features/legendaries/__tests__/OwnVsNeedProvider.test.tsx — "no key … issues no account request"
SC7features/legendaries/__tests__/LegendaryDetailPage.test.tsx (404 → not-found, non-numeric id → not-found, non-Gen-1 → note) + api/__tests__/useRecipeTree.test.tsx (404 → NotFoundError)
SC8features/legendaries/__tests__/aggregateNeed.test.ts (duplicate / fractional-yield / currency / gated cases)
SC9features/legendaries/__tests__/mergeOwnVsNeed.test.ts (ceil / owned-once / untracked / currency)
SC10features/legendaries/__tests__/routes.test.tsx — "T10/SC10: exposes the detail route the list card links to"; LegendariesPage.test.tsx (card Link → /legendaries/:id)
SC11__tests__/conventions.test.ts + __tests__/tokens.test.ts (guards green, no new tokens); shared/ui/__tests__/Coins.test.tsx (costs via the coin component)
SC12this table complete + pnpm test green (487 passed)