Skip to content

App shell & the legendaries endpoint — Implementation Plan ​

Status: approved Spec: spec.md (approved) · Discovery: research.md (complete)

Status is set by the human, never by the agent. draft → approved. Approval here is what unlocks tasks.md; it does not unlock implementation, which is entered only through plan mode.

Goal: Ship GET /legendaries over a hardcoded id set, and an app shell with a real token vocabulary that renders those legendaries at /legendaries.

Architecture: The API owns every upstream limitation — the hardcoded 198-id array, hydration through the shared Gw2Service, and filtering — so the frontend asks one question and learns nothing about batch caps or the fact that /v2/items cannot filter. The web app gains a shell in App.tsx (the existing layout route, grown rather than replaced) and four semantic tokens, and reaches the endpoint through the Suspense facade pattern react.md already prescribes.

Tech Stack: NestJS 11 on Fastify (SWC), Zod + nestjs-zod, Vitest · React 19.2, React Router 8, TanStack Query 5, Panda CSS, Orval, Vite 8.


Global Constraints ​

Project-wide rules. Every task's requirements implicitly include this section.

  • No any, no unexplained escape hatches (Definition of Done).
  • DI value-import rule — a constructor-injected class is a value import carrying // biome-ignore lint/style/useImportType: value import — Nest DI needs the runtime reference. Guard G2 fails CI otherwise.
  • Zod-first contract — no class-validator / class-transformer (guard G1).
  • GW2 network access only inside src/gw2/ (guard G4).
  • Nest HTTP exceptions constructed only in *.controller.ts (guard G5).
  • Every feature folder has a *.module.ts; app.module.ts declares only imports (guard G6).
  • Every @Injectable/@Controller file has a colocated *.test.ts (guard G7).
  • Never hand-edit generated contract artifacts — regenerate; pnpm verify:contract fails CI on drift.
  • Tokens, never literals — no #hex, rgb(), hsl() anywhere under apps/web/src.
  • No hand-written useMemo/useCallback/React.memo — the React Compiler owns memoization.
  • Test placement differs per app — api: colocated <name>.test.ts; web: __tests__/ folders.
  • Every acceptance scenario and success criterion maps to a named test, recorded in the spec's traceability table.
  • Commits: imperative, scoped (api:, web:, specs:).

File Structure ​

apps/api — create ​

FileResponsibility
src/legendaries/legendaries.data.tsThe 198 { id, generation } entries and nothing else. Plain module, no Nest decorators, unit-testable as data.
src/legendaries/legendaries.schema.tsThe Zod contract: LegendaryDto and the query schema. Source of truth for OpenAPI and therefore for Orval.
src/legendaries/legendaries.service.ts@Injectable(). Hydrates ids through Gw2Service.items, maps upstream shapes to LegendaryDto, applies filters.
src/legendaries/legendaries.controller.ts@Controller('legendaries'), one @Get(). Query validation and @ZodResponse only.
src/legendaries/legendaries.module.tsimports: [Gw2Module], declares the controller and provider. No exports — nothing injects this service.
src/legendaries/*.test.ts (4)Colocated per G7, one per unit above.

apps/api — modify ​

FileChange
src/gw2/gw2.schemas.tsAdd optional details: { type?, weight_class? } to Gw2ItemSchema. Without this the feature cannot work — Zod strips unknown keys, so details is discarded at the boundary today. Additive: the schema is not .strict(), no test asserts an exact key set, and consumers read named fields.
src/app.module.tsAdd LegendariesModule to imports. Nothing else — G6.

apps/web — create ​

FileResponsibility
src/api/useLegendaries.tsThe facade hook. useSuspenseQuery via the existing suspenseOptions adapter, response parsed with the generated Zod schema before returning.
src/features/legendaries/LegendariesPage.tsxThe page. Renders grouped sections of cards. Holds the card as a colocated component — one consumer, so shared/ui is not earned. Markup and behaviour only; no css() call.
src/features/legendaries/styles.tsThe page's styles, imported by name. Styling never lives in the component file (react.md, Styling).
src/features/legendaries/groupLegendaries.tsPure helper: LegendaryDto[] → ordered groups. No React import, tested as a function.
src/features/legendaries/routes.tsx[{ path: '/legendaries', element: <LegendariesPage /> }]. Created with the folder — the guard suite reads this file unconditionally and errors without it.
src/features/legendaries/__tests__/* (3)Page, helper, routes.

apps/web — modify ​

FileChange
panda.config.tsAdd card, border, muted, primary under _osDark; migrate the five existing tokens from _dark to _osDark (R19 — a defect fix, not a refactor).
src/App.tsxGrows from bare <main> into the shell: sticky wrapper, header, container, then the existing QueryBoundary/Suspense/<Outlet/> untouched.
src/styles.tsNew. The shell's styles, including the nav-item cva — colocated with App.tsx as a sibling module, not inside it.
src/features/health/styles.tsNew. The one line of styling HealthPage.tsx carried, moved out for the same rule.
biome.jsonTwo noRestrictedImports groups: styled-system only inside a styles.ts, react-query/api/generated only inside src/api — the latter replacing two hand-rolled scanners (conventions.test.ts's and boundary.test.ts's, which duplicated each other).
src/__tests__/conventions.test.tsLoses the react-query scanner and its violation-catching case; keeps cross-feature, memoization, colour and route-naming.
src/api/__tests__/boundary.test.tsDeleted. Both its scanners are Biome's now; its R9: the facade exports a health hook case moved to useHealth.test.tsx, which is why that file imports from ../index.
src/main.tsxAssemble both feature route tables. No route of its own.
src/features/health/routes.tsxPath / → /health.
src/api/index.tsRe-export useLegendaries.
src/api/generated/**Regenerated by Orval. Never hand-edited.
src/__tests__/tokens.test.tsExtend for the four new tokens, same tokens.d.ts technique.
Existing health/App testsUpdated for the moved path and the new shell.

Docs — modify (step 6, in this branch) ​

design-system.md (the four tokens; _osDark vs _dark, the single most re-discoverable fact here), react.md (routes; NavLink owns aria-current), nestjs.md (the new module), gw2-api.md (that ?rarity= is silently ignored, beside the existing off-by-one cap note).

Plus the styling rule below: react.md (Styling, the layout and naming sections, the enforcement table and its scoping note) and design-system.md (Colocated until promoted now means the sibling styles.ts).

Corrected after implementation. The rows above naming styles.ts, biome.json and the styling docs were added once the styles-out-of-components rule was agreed mid-branch — it is not something the approved plan foresaw. Recorded here rather than left stale, so the file map matches the branch it describes.

The same pass deleted two test files this plan never mentioned, both duplicating a check another layer already owned: apps/web/src/api/__tests__/boundary.test.ts (Biome's noRestrictedImports) and tests/contract/no-drift.test.ts (CI's own pnpm verify:contract step). Specs 004, 008 and 010 carry the traceability amendments.


Components ​

What each new unit is, and why it earns its place.

legendaries.data.ts — a readonly array of { id: number; generation: 1|2|3|null }. Not a class, not a service, not a package. It is data behind a contract, so replacing it later with an index or a table changes no consumer.

LegendariesService — one public method, list(filter): Promise<LegendaryDto[]>. It hydrates the full id set through Gw2Service.items, maps each upstream item to a DTO, and filters. Filtering happens after hydration rather than by slicing ids first: hydration is cache-warm and shared, so narrowing early would save nothing and would make ?type=Weapon depend on curated data instead of the upstream type field.

LegendariesController — the query schema, @ZodResponse, and a call to the service. No mapping, no filtering. It is where 400 comes from, and nothing else.

useLegendaries — the only way the page reaches the server, per react.md. Wraps useSuspenseQuery, composes suspenseOptions, parses through the generated Zod schema so a malformed payload throws to QueryBoundary rather than reaching a component.

The shell in App.tsx — sticky wrapper, dark bar, container, then the untouched boundary/outlet stack. A NavItem is not extracted to shared/ui: that folder is admitted at two consumers and the header has one. Its active/inactive styling is a cva in src/styles.ts — colocated with the shell, outside the component file; active is a bottom border plus aria-current, so it is legible without colour.

groupLegendaries — pure. Takes the flat DTO list, returns ordered groups (Gen 1/2/3, then armour, trinkets, back items). Kept out of the component so grouping is tested as a function, not through a render — react.md's rule for algorithmic code.

The card — colocated in LegendariesPage.tsx, its styles in the feature's styles.ts. One consumer, so promotion is not earned. It is a link to /legendaries/<id>, deliberately unrouted.


Task boundaries ​

Boundaries and order only. The step-by-step, with code and test bodies, is tasks.md — a separately gated artifact.

#DeliverableWhy it is its own task
1Gw2ItemSchema carries detailsEverything downstream needs it, and it touches spec 005's shipped boundary — a reviewer should be able to reject this alone.
2legendaries.data.ts + its test198 ids asserted at the right count and shape. Pure data; no reason to entangle with the service.
3LegendariesServiceThe mapping and filtering, against a stubbed Gw2Service.
4LegendariesController + module + app.module.tsThe HTTP surface, 400 behaviour, and wiring. Ends with P1 fully testable.
5Contract regenerationopenapi.json + Orval output. Mechanical, but it is the CI-drift risk and deserves its own gate.
6Panda tokens (4 new + 5 migrated)P3. Independent of everything else; the migration is a defect fix that should be reviewable on its own.
7The shell + route movesP2. Depends on task 6 for tokens, on nothing else.
8useLegendaries + LegendariesPage + groupLegendariesP4. Depends on 5 for the generated client and 7 for the shell.
9Docs + traceability tableStep 6 capture, inside this branch so docs and code merge together.

Order rationale. 1→4 is the API, shippable and testable with no frontend. 5 is the seam. 6→8 is the web app; 6 before 7 because the shell consumes the tokens, 7 before 8 because the page renders inside the shell. 9 last, when there is something true to write down.

Task 1 is the risk. It modifies a file spec 005 covers. It is additive and the evidence says it is safe, but if a gw2 test does turn out to pin the item shape, that surfaces in task 1 rather than three tasks later inside unrelated work.


What this plan does not do ​

  • No catch-all route. / renders react-router's default error component, accepted deliberately (research V4).
  • No right rail, no search, no theme toggle.
  • No planner, and nothing served at /legendaries/<id>.
  • No workspace package for the id list, and no change to packages/legendary-recipes.
  • Nothing depends on the 198 ids fitting one upstream batch. The client chunks at 199 regardless.