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/:itemIdis 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-sidePlanningModuleearns 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 + rootsummary(spec 007/008). Validated web-side by the existingRecipeTreeDtoZod 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
- Given a Gen-1 legendary id, when
/legendaries/:idrenders, then the header shows the name in its rarity colour, plusGeneration N, type (and subtype / armor weight where present). - 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; anullfigure shows "—", never0. - 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 is0/null). - 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/unknowndecision 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. - 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.
- 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. - 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
- Given a stored key and a Gen-1 tree, when the end block renders, then each buyable leaf material (owned from
/account/materialsby item id) and currency (owned from/account/walletby currency id — currencies read from each craft node'srecipe.ingredients, since they are not tree nodes; research F3) showsneed(ceiled total across the decided tree),have, andshort=max(0, ceil(need) − have), plus a your remaining cost total = Σ over buyable leaves ofceil(short) × unitBuyPrice. - 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. - 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 displayedneedis 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. - 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. - 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
needonly (nohave), and its full need counts toward the remaining cost. - 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/:idto the feature'sroutes.tsx, rendering a newLegendaryDetailPage. The list's existing cardLinkresolves to it. The feature keeps owning its route table (react.md R5).R2 — Header record (
useLegendary). A web hook derives the header record for:idfrom the existinguseLegendaries()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). AdduseRecipeTree(itemId)inapps/web/src/api, mirroring the existing hooks:useSuspenseQueryover the generated/recipe-graph/:itemIdquery options, response Zod-parsed with the generatedRecipeTreeDtoschema (Orval does not wire validators into hooks — parsing here is where a malformed tree throws intoQueryBoundary, same rationale asuseLegendaries). Keyless (noAuthorizationheader). Exported fromapi/index.ts.R4 — Not-found state. When
/recipe-graph/:itemIdresponds404(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 legendaryidis the item id recipe-graph resolves (research V1 — confirmed).R5 — Tree view (gift cards, nested collapsible). A
LegendaryTreerenders 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 itslineCost(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 rootsummary,nullfigures as "—" never0:- Craft — craft cost (
totalCraftCost), buy-instead (rootBuyPrice), a saves/costs figurerootBuyPrice − totalCraftCostwhose label is "crafting saves you" when ≥ 0 and "crafting costs you extra" when < 0 (derived web-side — not asummaryfield), 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 is0/null). The profit/margin here are derived from the chosen material cost, not read fromsummary.profit(which is the full-cost variant).
- Craft — craft cost (
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 whenuseApiKey()is non-null, so the suspending account hooks are never called conditionally (rules of hooks) and the not-connected path renders the sharedConnectAccountPrompt(react.md; reuses spec 020'suseApiKey+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'srecipe.ingredients; research F3). The walk carries a running "units required" multiplier, seeded at1for the root:- At a
craftnode needinguunits,batches = u ÷ node.recipe.outputCount(continuous — matches the backend cost fold,pricing.ts:66;outputCountis fractional for Mystic Clover, research V2). For eachrecipe.ingredientsedge of countc: a currency edge addsc × batchesto that currency's total; an item edge recurses into the matching child withu' = c × batches. - At any non-
craftnode (buy/gated/unknown/ leaf) reached withuunits, adduto that item's total and stop descending (frontier). The node's owncountis not re-read at the frontier — the parent already folded it intou.
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;
countis per-edge andoutputCount(fractional) must divide, so it is a multiplier walk, notΣ count.- At a
R9 — Own-vs-need merge (
mergeOwnVsNeed). A pure function takesaggregateNeed(tree)plus the materials and wallet rows and returns, per leaf:{ kind: 'item' | 'currency', id, name, need, have, short }whereneedis the ceiled expected total,haveis the owned count (materials by item id, wallet by currency id) or undefined when the id is in neither (untracked — R-P2 #5), andshort = max(0, ceil(need) − (have ?? 0)); plusremainingCost= Σ over buyable leaves ofshort × 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
ShoppingListblock 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 (ceiledneed), and cost — with a total. When connected (inside the R7 gate) each row gainshaveandshortfrommergeOwnVsNeed, 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 agatedtag and contribute no gold. When not connected, the own-vs-need columns are replaced by the sharedConnectAccountPrompt.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 shippedprimary(teal) token; cards, borders, and muted fills usecard/border/muted(all present since spec 019). No new colour tokens (noaccent, nocoin.*, nohead); the tokens-never-literals guard stays green. Repeated variants are colocatedcvas in the feature'sstyles.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/:idresolves it to a root-only leaf —root.children.length === 0(research V-gen — confirmed). Detected web-side by that empty-children test (the root'sdecisionis force-set tocraftby 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+QueryBoundaryown 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, thedocs/superpowers/count guard, the web conventions test, and all prior suites stay green, updated only where this spec changes their subject (the legendariesroutes.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 inresearch.mdwith 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
idis the item id/recipe-graph/:itemIdresolves (legendaries.data.ts:27lists Bifrost30698;recipe-graph.service.bifrost.test.ts:137,147resolves 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 —
countsemantics. Confirmed per-edge, and it refuted the "simple sum" premise (research V2 / F3).countis per-edge (project-tree.ts:21,47-52),lineCostis per-line not rolled up (pricing.ts:160), andoutputCountis fractional (Mystic Clover3.1,gen1-weapons.json) and divides in the cost fold (pricing.ts:66); currencies are not tree nodes (project-tree.ts:45) but live onrecipe.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 todraft. - V-gen — non-Gen-1 resolution. Confirmed (research V-gen). Curated
legendaryOutputIdsis 21 Gen-1 ids only; a Gen-2/3 id is in neither curated nor station data, soresolve()returns a root-only leaf (recipe-graph.service.ts:100,leaf: recipes.length === 0) with emptychildren. The root'sdecisionis force-set tocraftby pricing (pricing.ts:207), so R12 detects the no-tree case byroot.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/:idrenders 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;nullfigures 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/unknownbadge, 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/shortper 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 —
aggregateNeedreturns correct expected per-leaf totals over a decided tree: descends onlycraftnodes, stops atbuy/gated/unknown/ leaf frontiers, folds a leaf appearing in multiple branches into one total, divides by each craft node'soutputCount(Mystic Clover3.1→ expected 74.5), tallies gated leaves, and collects currency needs fromrecipe.ingredients(notchildren). (unit test, incl. duplicate-material, fractional-yield, currency, and gated-count cases) - SC9 —
mergeOwnVsNeedproducesneed / have / shortper leaf with owned counted once and need ceiled (own 250, need 500 → short 250; expected 74.5 → need 75),haveundefined → "—" with full need toward cost, currencies matched to the wallet by id, andremainingCost= Σceil(short) × unitBuyPriceover buyable leaves (currencies/gated carry no gold). (unit test) - SC10 — The list card
Linknavigates 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-graphpayload 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
×countonly. 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/craftdecisions 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/:itemIdresolves the full tree and returns per-nodebuy/craft/gated/unknowndecisions, unit/line costs, and a rootsummary; its404semantics (unknown / null-named root) hold. This spec consumes it unchanged. - 018 / 020 stand —
/account/materialsand/account/walletexist, are enriched (id+count/value, name, icon), keyed by item / currency id, and are cached. This spec reads them unchanged. - 019 stands —
/api/legendariesreturns 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/apiprefix, and CORS. - The Zod-first → Orval pipeline stands —
useRecipeTreeconsumes generated query options + the generatedRecipeTreeDtoZod 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/Viewnaming, theuseApiKey+ConnectAccountPromptgate, 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/.
| Criterion | Test |
|---|---|
| P1 #1 | features/legendaries/__tests__/LegendaryDetailView.test.tsx — "renders the header + goals + tree + shopping list" |
| P1 #2 | features/legendaries/__tests__/LegendarySummary.test.tsx — "Craft goal: label says …" |
| P1 #3 | features/legendaries/__tests__/LegendarySummary.test.tsx — "Profit goal, not connected: material cost is the full craft cost" |
| P1 #4 | features/legendaries/__tests__/LegendaryTree.test.tsx — one-card-per-gift / collapse / gated badge |
| P1 #5 | features/legendaries/__tests__/ShoppingList.test.tsx — "visitor: lists buyable leaves with quantity + cost + total" |
| P1 #6 | features/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 #7 | features/legendaries/__tests__/LegendaryDetailPage.test.tsx — "non-Gen-1 (root-only leaf): shows the Gen-1-only note" |
| P2 #1 | features/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 #2 | features/legendaries/__tests__/mergeOwnVsNeed.test.ts — "ceils need, counts owned once, computes short (own 250 / need 500 -> 250)" |
| P2 #3 | features/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 #4 | features/legendaries/__tests__/LegendarySummary.test.tsx — "Profit goal, connected: material cost is the remaining cost and profit recomputes" |
| P2 #5 | features/legendaries/__tests__/mergeOwnVsNeed.test.ts — "untracked buyable leaf: have undefined, full need to remainingCost" |
| P2 #6 | features/legendaries/__tests__/OwnVsNeedProvider.test.tsx — "no key: renders ConnectAccountPrompt, provides null, issues no account request" |
| SC1 | features/legendaries/__tests__/LegendaryDetailView.test.tsx |
| SC2 | features/legendaries/__tests__/LegendarySummary.test.tsx (Craft goal + gated count) |
| SC3 | features/legendaries/__tests__/LegendarySummary.test.tsx (Profit goal, connected + not) |
| SC4 | features/legendaries/__tests__/LegendaryTree.test.tsx |
| SC5 | features/legendaries/__tests__/ShoppingList.test.tsx (visitor + connected) |
| SC6 | features/legendaries/__tests__/OwnVsNeedProvider.test.tsx — "no key … issues no account request" |
| SC7 | features/legendaries/__tests__/LegendaryDetailPage.test.tsx (404 → not-found, non-numeric id → not-found, non-Gen-1 → note) + api/__tests__/useRecipeTree.test.tsx (404 → NotFoundError) |
| SC8 | features/legendaries/__tests__/aggregateNeed.test.ts (duplicate / fractional-yield / currency / gated cases) |
| SC9 | features/legendaries/__tests__/mergeOwnVsNeed.test.ts (ceil / owned-once / untracked / currency) |
| SC10 | features/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) |
| SC12 | this table complete + pnpm test green (487 passed) |