Spec 012 — App shell & the legendaries endpoint
Status: approved Branch: 012-app-shell
Status is set by the human, never by the agent. It moves draft → approved → implemented.
Problem
apps/web has no shell. App.tsx is a bare <main> holding an <h1>, a separator and the QueryBoundary/Suspense pair — no header, no navigation, no layout. One route exists (health, at /), so there is nowhere to navigate to and nothing establishing how a second feature should look.
The design system has the same gap from the other side. design-system.md records that surface is "defined and available today, but not yet applied anywhere", and the seven rarity.* tokens — the app's core visual vocabulary — have never been rendered. Raised surfaces, outlines and quiet fills have no tokens at all, so the next feature to need a bordered box invents its own grey.
And there is no way to ask what the legendaries are. The GW2 API cannot answer it: /v2/items accepts no rarity or type filter — a live ?rarity=Legendary returns HTTP 200 with all 74,054 ids, silently ignoring the parameter. Any client wanting "the legendaries" must already know their ids, which pushes upstream trivia into whatever asks the question.
User stories
Ordered by priority. Each story must be independently testable and shippable — if only P1 ships, there is still something usable.
P1 — Ask our API for the legendaries
As a frontend, I want one endpoint that returns the legendary items with their names, so that I never learn that the upstream cannot filter, caps batches at 199, or needs its ids known in advance.
Independent test: GET /legendaries returns the full set with names, exercised against a stubbed Gw2Service, with no frontend involved.
Acceptance scenarios
- Given the API is running, when
GET /legendariesis called, then all 198 legendaries are returned, each withid,name,type,subtype,weight,rarity,iconandgeneration. - Given an armour item in the response, when it is inspected, then
weightis one ofHeavy,LightorMedium; and given a weapon, trinket or back item, thenweightisnull. - Given
GET /legendaries?generation=1, when it is called, then only the 21 Generation 1 weapons are returned. - Given
GET /legendaries?type=Weapon, when it is called, then only the 53 items whose upstreamtypeisWeaponare returned. - Given an unknown filter value (
?generation=9), when it is called, then the request is rejected with400by the Zod contract, not silently ignored the way upstream ignoresrarity. - Given upstream returns
206because an id is unknown, when the response is built, then the known items are still returned and the missing id is omitted rather than throwing.
P2 — A shell that says where you are
As a player, I want a persistent header naming the current page and the others that exist, so that moving around the app does not depend on the browser's back button.
Independent test: with only P2, /health renders inside a shell whose header marks Health as current — no second page and no endpoint required.
Acceptance scenarios
- Given the app at
/legendaries, when the page renders, then a header shows the logo and two navigation items, and the Legendaries item is marked as the current page. - Given the app at
/health, when the page renders, then the Health item is marked as current and the Legendaries item is not. - Given a page taller than the viewport, when it is scrolled, then the header stays fixed at the top of the viewport.
- Given the current navigation item, when colour is not perceivable, then it is still identifiable —
aria-current="page"plus a bottom border, never colour alone.
P3 — Colours have names
As a developer, I want raised surfaces, outlines, quiet fills and the app's accent to be named tokens, so that the next feature does not invent its own greys and the literal-colour guard has a vocabulary large enough to be satisfiable.
Independent test: styled-system/tokens/tokens.d.ts contains the four new tokens after panda codegen, independently of any component consuming them.
Acceptance scenarios
- Given
panda codegenhas run, whentokens.d.tsis read, thencard,border,mutedandprimaryare all present. - Given a dark operating-system colour scheme, when the app renders, then surfaces use the
_osDarkvalues, with no user action and nothing persisted. - Given a light operating-system colour scheme, when the app renders, then surfaces use the base values.
- Given all code under
apps/web/src, when the guard suite runs, then no literal#hex,rgb()/rgba()orhsl()/hsla()value is found.
P4 — See the legendaries
As a player, I want /legendaries to list every legendary with its name in its rarity colour, so that the shell and the tokens are proven against real data rather than a placeholder.
Independent test: /legendaries renders a card per item returned by a mocked useLegendaries.
Acceptance scenarios
- Given
/legendaries, when the data resolves, then one card appears per legendary, grouped by generation for weapons. - Given a card, when it renders, then the item's name is coloured by
rarity.legendary. - Given a card, when it is hovered or focused, then a ring in
primaryappears. - Given a card, when it is activated, then the browser navigates to
/legendaries/<id>— deliberately unrouted in this spec. - Given the request is in flight, when the page mounts, then the shell's
Suspensefallback renders — never a loading branch inside the page. - Given the request fails or fails Zod validation, when it settles, then
QueryBoundarycatches it — never an error branch inside the page. - Given a card, when it renders, then a 40px icon appears left of the name, sourced from the item's
iconURL, lazily loaded and with an emptyalt.
Endpoints
First-class spec content, not deferred to implementation.
| Method | Path | Query | Success | Failure |
|---|---|---|---|---|
GET | /legendaries | generation?: 1|2|3, type?: Weapon|Armor|Back|Trinket | 200 LegendaryDto[] | 400 on an invalid filter value |
LegendaryDto:
{
id: number;
name: string;
type: string; // upstream `type` — Weapon | Armor | Back | Trinket
subtype: string | null; // upstream `details.type` — Greatsword, Boots, …
weight: 'Heavy' | 'Light' | 'Medium' | null; // upstream `details.weight_class` — armour only
rarity: string; // upstream `rarity` — always "Legendary" today, not asserted
icon: string | null; // upstream `icon` — a render.guildwars2.com URL, content-addressed
generation: 1 | 2 | 3 | null; // curated; null for armour, trinkets and back items
}The OpenAPI document is generated from the Zod contract as apps/api already does, and pnpm verify:contract regenerates the Orval client — CI fails on any drift between the committed generated files and a fresh run.
Requirements
- R1 —
GET /legendariesis served by a new feature moduleapps/api/src/legendaries/, followingnestjs.md's module-per-folder layout, thin controller, and Zod-first contract. - R2 — The legendary id set is a plain array inside that module, each entry
{ id, generation }. It is not a workspace package, not curated data with per-item provenance, and not exposed to the frontend. It is a private implementation detail behind the endpoint's contract, so replacing it later with an index or a table changes no consumer. A workspace package is for code shared across apps; this data has exactly one consumer, so packaging it would add a build boundary, a schema and a guard test to buy nothing — the human's standing rule is that legendary data lives app-side inapps/api, and a package for it is over-engineering. The array holds 198 entries (research V5). - R3 —
generationis the only curated field.name,type,subtype,weight,rarityandiconare hydrated from/v2/itemsat request time.subtypeisdetails.typefor every class —Greatswordfor a weapon,Bootsfor armour — andweightisdetails.weight_class, which only armour carries and which is thereforenulleverywhere else (research V6). - R20 — Each card renders the item's icon at 40px, left of the name. The icon URL comes from
/v2/items'iconfield in the hydration call already made, so no additional upstream request is issued.iconis optional on the sharedGw2ItemSchemaand nullable onLegendaryDto: that schema parses every item read in the app, so declaring a field one feature needs as required there would let a single icon-less item anywhere in the 74k catalogue fail an unrelated recipe-graph batch. A card with no icon renders no<img>rather than an emptysrc. The images areloading="lazy"(198 on one page) and carryalt=""— the name sits immediately beside the icon, so announcing it twice would be noise, not information. (Human decision, 2026-08-13: real icons in, replacing this spec's original placeholder-tile assumption; 40px chosen over native 64px because it keeps the card at its current height.) - R4 — Hydration goes through the existing
Gw2Service. The 199-id cap, chunking,206/404handling, the rate budget and the no-expiry item cache are already implemented inapps/api/src/gw2/gw2-client.tsand are not reimplemented. - R5 — Query parameters are validated by the Zod contract; an out-of-range
generationor unknowntypeyields400. The endpoint never silently ignores a filter, which is the upstream behaviour this endpoint exists to correct. - R6 — The frontend reaches the endpoint through one facade hook in
src/apiwrappinguseSuspenseQueryand parsing the response with the generated Zod schema, perreact.md. No component holds a loading or error branch. - R7 —
App.tsxstays the single layout route and holds the shell markup directly. NoAppHeaderis extracted intoshared/ui:react.mdadmits that folder at two consumers; the header has one. - R8 — The navigation item's active/inactive styling is a colocated
cvainApp.tsx, not a config recipe —design-system.mdties recipes to promotion intoshared/ui, which R7 declines. - R9 — Navigation uses
react-router'sNavLink; the active state is never hand-computed fromuseLocation, whichNavLinkalready does (research V3). - R10 — Four semantic tokens are added to
panda.config.ts, each with an_osDarkcondition:card,border,muted,primary. The first three reference Panda's grey scale assurfaceandtext.*already do;primaryis#0f766ebase /#2dd4bf_osDark._darkis not used — see R12. - R11 — Hover states derive from
primary. No separateaccenttoken exists, so a hover state cannot drift from the active state it belongs to. - R12 — The colour scheme follows the operating system through Panda's
_osDarkcondition, which compiles to@media (prefers-color-scheme: dark). Panda's_darkcompiles to.dark &— a class selector — and is not used anywhere in this repo. No toggle, no persisted preference, soreact.md's three state homes are unchanged and no fourth is introduced. - R19 — The five existing semantic tokens (
surface,text.strong,text.muted,rarity.basic,rarity.legendary) migrate from_darkto_osDarkin the same change. Their dark values are unreachable today because nothing applies a.darkclass (research F1), so this is a defect fix, not a refactor: it is the first time those values can render.design-system.md's reasoning about them stays correct; only the condition changes. - R13 — Web routes are
/legendaries→LegendariesPageand/health→HealthPage(moved off/)./is left unrouted; a dashboard is a later spec. What renders there is react-router’s default error component, accepted deliberately (research V4). - R14 —
features/legendaries/contains aroutes.tsxfrom the moment the folder exists: the guard suite reads that file unconditionally and errors rather than failing cleanly without it. - R15 — Cards link to
/legendaries/<id>, deliberately unrouted in this spec. - R16 — No right rail and no search. Neither has content or a destination until a later spec.
- R17 — No new guard test;
tokens.test.tsis extended for the four new tokens using its existingtokens.d.tstechnique.react.mdrequires one owner per convention. - R18 —
design-system.md,react.mdandnestjs.mdare updated within this branch to record the tokens, the web routes and the new endpoint, so docs and code merge together.
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. A product decision, a scope boundary, a preference. Blocks step 1.5.[NEEDS VERIFICATION: specific question]— only reality can answer. Whether the codebase works that way, whether an endpoint returns that field, whether that number is achievable. Answered inresearch.mdwith cited evidence, never by assumption. Blocks the approval gate.
All six verification items are closed — see research.md for evidence and dates. Summary:
| Question | Verdict | |
|---|---|---|
| V1 | Does _dark follow prefers-color-scheme? | Refuted — it is .dark &. Resolved as _osDark (R12, R19) |
| V2 | Is {colors.white} a valid token? | Confirmed |
| V3 | Does NavLink set aria-current? | Confirmed |
| V4 | What renders at an unmatched path? | Confirmed — react-router's error component; accepted as-is |
| V5 | The complete legendary id set? | Confirmed — 198 items |
| V6 | Armour subtype from /v2/items? | Confirmed — details.type + details.weight_class |
The original questions, retained for the record:
- V1 — Does Panda's
_darkcondition respond toprefers-color-scheme, or does it require a.darkclass /data-themeattribute on an ancestor? R12's "no new state" is free only if the media query works alone. - V2 — Is
{colors.white}a valid token reference in Panda's default preset?card's base value depends on it; the existing config references onlygray.*. - V3 — Does
NavLinksetaria-current="page"itself? P2 #1 and P2 #2 rest on it. - V4 — What does React Router 8 render for an unmatched path?
/is unrouted and is both the URL people type and the logo's destination, so this is the front door, not an edge case. If the default is unacceptable, whether a catch-all enters scope returns as a clarification. - V5 — What is the complete legendary id set, and how many are there? Gen 1's 21 ids already exist as
legendaryOutputIdsinpackages/legendary-recipes; Generations 2 and 3, armour, trinkets and back items do not. The wiki's category pages count articles (58 legendary weapon pages) rather than items, and gw2efficiency's/itemsis 87,930 bare ids with no rarity, so neither answers it directly. Proposed method: a discovery spike sweeping/v2/itemsthrough the project's own rate-limited client, filtering onrarity === "Legendary"and an equipmenttype, since gift items share the Legendary rarity and must not be collected. - V6 — Does
/v2/itemsexpose a usablesubtypefor legendary armour (details.typefor the slot,details.weight_classfor the weight)? Verified for weapons only.
Any success criterion stating a number carries a [NEEDS VERIFICATION] until a measurement in research.md backs it. An unbacked number is a guess wearing a criterion's clothes.
Success criteria
Measurable and technology-agnostic — outcomes, not implementation.
- SC1 — A caller can retrieve all 198 legendaries, with names, in one request, knowing no item ids. (198 backed by research V5; the number is asserted in a test, so a legendary added upstream fails loudly rather than silently shrinking the list.)
- SC2 — Filtering by generation and by type each narrow the result correctly; an invalid filter value is rejected rather than ignored.
- SC3 — The endpoint's contract is published in the OpenAPI document, and the committed generated client matches a fresh generation.
- SC4 — From either page, the other is reachable in one interaction with the header.
- SC5 — The current page is identifiable from the header without relying on colour perception.
- SC6 — All four new tokens resolve to a value in both the light and the dark colour scheme.
- SC7 — The app matches the operating system's colour scheme on first load, with no user action and nothing written to storage.
- SC8 — No literal colour value appears anywhere under
apps/web/src. - SC9 — The production build completes with the React Compiler's
panicThreshold: 'all_errors'in force — no component bails out. - SC10 — The legendaries page contains no loading branch and no error branch; both are served by the shell's boundaries.
- SC11 — The header remains visible while the page is scrolled.
Out of scope
- The planner, and everything at
/legendaries/<id>— cards link there, nothing serves it. - A general item endpoint.
/legendariesis a resource, not/items?rarity=, because we cannot honestly answer any other rarity. - Persisting the item index.
stack.mdcommits to Postgres, and no database exists yet; the id array under R2 defers that until something needs it. - The right rail / shopping list, and search.
- A light/dark toggle and any persisted theme preference.
- A dashboard at
/. - A catch-all/404 route — pending V4.
- Icon sizing and layout refinement. Icons ship at 40px, left of the name, which keeps the card at its existing height. Whether 64px (native resolution, a taller card) reads better is deferred — human decision, 2026-08-13: "the size is a detail we can crack later".
- A mobile navigation drawer. Two navigation items fit a small viewport without one.
Assumptions
primary's teal is provisional and a single-token change; the human has said the value is a detail to settle later, and nothing else depends on the hue.generationapplies to weapons only and isnullelsewhere — confirmed with the human; armour sets and trinkets are not organised into generations by ArenaNet.- Item data is immutable, so the existing no-expiry item cache is correct and this endpoint needs no invalidation story.
- The 198 ids fit one upstream batch today, one below the 199 cap (research F2). No requirement depends on that.
gw2-client.tschunks regardless, and the next legendary released makes it two requests with no code change. - The id list is a snapshot of the game as of 2026-08-12. New legendaries need the array updated by hand; nothing detects them automatically, and SC1's asserted count is what surfaces the drift.
- The health feature keeps its behaviour; only its path changes.
packages/legendary-recipesis untouched. ItslegendaryOutputIds(21 gen-1 ids) overlaps this spec's list, and that overlap is tolerated for now rather than accepted as correct: the field feeds onlyCuratedRecipeService.legendaries(), whose sole caller is its own test, so nothing live reads it and the two lists cannot drift into disagreeing behaviour. It is not removed here because it is spec 006's acceptance evidence — 006 scenario P1 #4 and traceability rows P1 #4 / P1 #5 point at those tests — and deleting it from a shell spec would leave animplementedspec asserting something no test proves. Removing it belongs with 006, together with amending its scenario and table.
Traceability
Each acceptance scenario and success criterion must map to a named test. Titles below are transcribed verbatim from the shipped tests. One row (P4 #5) and one success criterion (SC10) have no test that genuinely proves them; marked — none rather than stretched onto a test that doesn't check the claim — see Gaps behind the — none rows below the table for why.
| Criterion | Test |
|---|---|
| P1 #1 | apps/api/src/legendaries/legendaries.service.test.ts — "P1 #1: maps every hydrated item to the DTO shape"; apps/api/src/legendaries/legendaries.data.test.ts — "SC1: holds all 198 legendaries" (count) |
| P1 #2 | apps/api/src/legendaries/legendaries.service.test.ts — "P1 #2: weight is set for armour and null elsewhere" |
| P1 #3 | apps/api/src/legendaries/legendaries.service.test.ts — "P1 #3: filters by generation"; apps/api/src/legendaries/legendaries.data.test.ts — "012 T2: generations are 21 / 16 / 16, and null for the other 145" (the 21 gen-1 count) |
| P1 #4 | apps/api/src/legendaries/legendaries.service.test.ts — "P1 #4: filters by type"; apps/api/src/legendaries/legendaries.data.test.ts — "012 T2: generations are 21 / 16 / 16, and null for the other 145" (21+16+16=53 weapons — the only items with a non-null generation) |
| P1 #5 | apps/api/src/legendaries/legendaries.controller.test.ts — "P1 #5: the query contract rejects an out-of-range generation"; "P1 #5: GET /legendaries?generation=9 is rejected with 400"; "P1 #5: GET /legendaries?type=Sandwich is rejected with 400" |
| P1 #6 | apps/api/src/legendaries/legendaries.service.test.ts — "P1 #6: a 206 partial response yields the known items, not a throw" |
| P2 #1 | apps/web/src/__tests__/App.test.tsx — "P2 #1: marks Legendaries as the current page at /legendaries" |
| P2 #2 | apps/web/src/__tests__/App.test.tsx — "P2 #2: marks Health, and not Legendaries, at /health" |
| P2 #3 | apps/web/src/__tests__/App.test.tsx — "P2 #3: the header wrapper is sticky" |
| P2 #4 | apps/web/src/__tests__/App.test.tsx — "P2 #4: the current item is marked by more than colour"; "P2 #4: the active nav item carries the border-colour class, not just a different colour" |
| P3 #1 | apps/web/src/__tests__/tokens.test.ts — "012 P3 #1: the shell tokens exist" |
| P3 #2 | apps/web/src/__tests__/tokens.test.ts — "012 R12/R19: dark values use _osDark, never _dark" |
| P3 #3 | apps/web/src/__tests__/tokens.test.ts — "012 R12/R19: dark values use _osDark, never _dark" (same assertion covers both directions — Panda's semantic tokens here have exactly two states, base and _osDark) |
| P3 #4 | apps/web/src/__tests__/conventions.test.ts — "P4 #2/SC4: no literal colour values" (spec 010's guard, extended per R17; still the rule this scenario needs) |
| P4 #1 | apps/web/src/features/legendaries/__tests__/groupLegendaries.test.ts — "P4 #1: groups weapons by generation, then the rest by type"; apps/web/src/features/legendaries/__tests__/LegendariesPage.test.tsx — "P4 #1/#2/#4: renders a linked card per legendary" |
| P4 #2 | apps/web/src/features/legendaries/__tests__/LegendariesPage.test.tsx — "P4 #1/#2/#4: renders a linked card per legendary" (the c_rarity.legendary class assertion) |
| P4 #3 | apps/web/src/features/legendaries/__tests__/LegendariesPage.test.tsx — "P4 #3: the card carries a primary ring on both hover and keyboard focus" |
| P4 #4 | apps/web/src/features/legendaries/__tests__/LegendariesPage.test.tsx — "P4 #1/#2/#4: renders a linked card per legendary" (the href="/legendaries/30704" assertion) |
| P4 #5 | — none |
| P4 #6 | apps/web/src/api/__tests__/useLegendaries.test.tsx — "a malformed payload fails a test rather than reaching a component" |
| P4 #7 | apps/web/src/features/legendaries/__tests__/LegendariesPage.test.tsx — "P4 #7: the card renders the item icon at 40px, lazily loaded, with an empty alt" |
| SC1 | apps/api/src/legendaries/legendaries.data.test.ts — "SC1: holds all 198 legendaries"; apps/api/src/legendaries/legendaries.service.test.ts — "012 T3: hydrates all 198 ids in one call" |
| SC2 | apps/api/src/legendaries/legendaries.service.test.ts — "P1 #3: filters by generation", "P1 #4: filters by type"; apps/api/src/legendaries/legendaries.controller.test.ts — "P1 #5: GET /legendaries?generation=9 is rejected with 400", "P1 #5: GET /legendaries?type=Sandwich is rejected with 400" |
| SC3 | tests/contract/no-drift.test.ts — "P3 #2 / SC4: verify:contract exits 0 — regenerating the contract produces no diff" |
| SC4 | apps/web/src/__tests__/App.test.tsx — "SC4: both destinations are reachable from the header" |
| SC5 | apps/web/src/__tests__/App.test.tsx — "P2 #4: the current item is marked by more than colour", "P2 #4: the active nav item carries the border-colour class, not just a different colour" |
| SC6 | apps/web/src/__tests__/tokens.test.ts — "012 P3 #1: the shell tokens exist", "012 R12/R19: dark values use _osDark, never _dark" |
| SC7 | apps/web/src/__tests__/tokens.test.ts — "012 R12/R19: dark values use _osDark, never _dark" (config-level: no _dark/class mechanism exists to toggle or persist; research V1 notes a rendered browser honouring the media query was not itself observed) |
| SC8 | apps/web/src/__tests__/conventions.test.ts — "P4 #2/SC4: no literal colour values" |
| SC9 | apps/web/src/__tests__/viteProxy.test.ts — "R17: a bail-out fails the build rather than passing silently" (asserts the detector is wired; the build itself completing with no bail-out is confirmed by a green pnpm build, not a persisted test) |
| SC10 | — none |
| SC11 | apps/web/src/__tests__/App.test.tsx — "P2 #3: the header wrapper is sticky" |
Gaps behind the — none rows
- P4 #5 — no test exercises Suspense fallback timing during an in-flight request; every facade and page test awaits resolution or rejection directly rather than observing the pending state.
- SC10 — the "no error branch" half is proven (
useLegendaries.test.tsx's malformed-payload test). The "no loading branch" half is not: this project has no general enforcement layer for "a component contains no loading/error branch" (spec 010's P1 #6 already established that gap), so nothing would catch one being added back.