Skip to content

App shell & the legendaries endpoint — Tasks ​

Status: approved Plan: plan.md (approved) · Spec: spec.md (approved)

Status is set by the human, never by the agent. Approving this does not start implementation — step 4 is entered only through plan mode.

For agentic workers: REQUIRED SUB-SKILL: superpowers:subagent-driven-development, with superpowers:test-driven-development inside every task. Steps use - [ ] for tracking.

Global Constraints — see plan.md. Every task implicitly includes them: no any, the DI value-import rule (G2), Zod-first (G1), colocated api tests (G7), never hand-edit generated contract artifacts, tokens never literals in apps/web/src.


Task 1: Gw2ItemSchema carries details ​

Files:

  • Modify: apps/api/src/gw2/gw2.schemas.ts
  • Test: apps/api/src/gw2/gw2.schemas.test.ts

Interfaces:

  • Produces: Gw2Item gains details?: { type?: string; weight_class?: string }. Tasks 3 reads it.

Why first: Zod strips unknown keys, so details is discarded at the boundary today and no downstream code can see subtype or weight. It touches spec 005's file, so it gets its own gate.

  • [ ] Step 1: Write the failing tests
ts
// apps/api/src/gw2/gw2.schemas.test.ts — add to the existing describe for items
it('012 T1: keeps details.type and details.weight_class when present', () => {
  const parsed = Gw2ItemSchema.parse({
    id: 84655, name: 'Ardent Glorious Wargreaves', type: 'Armor',
    rarity: 'Legendary', flags: [], vendor_value: 0,
    details: { type: 'Boots', weight_class: 'Heavy', defense: 191 },
  });
  expect(parsed.details?.type).toBe('Boots');
  expect(parsed.details?.weight_class).toBe('Heavy');
});

it('012 T1: an item without details still parses', () => {
  const parsed = Gw2ItemSchema.parse({
    id: 19721, name: 'Glob of Ectoplasm', type: 'CraftingMaterial',
    rarity: 'Fine', flags: [], vendor_value: 16,
  });
  expect(parsed.details).toBeUndefined();
});
  • [ ] Step 2: Run to verify they fail

Run: pnpm --filter @gw2priory/api exec vitest run src/gw2/gw2.schemas.test.ts Expected: FAIL — first test, parsed.details is undefined because the key is stripped.

  • [ ] Step 3: Extend the schema
ts
// apps/api/src/gw2/gw2.schemas.ts
export const Gw2ItemSchema = z.object({
  id: z.number(),
  name: z.string(),
  type: z.string(),
  rarity: z.string(),
  flags: z.array(z.string()),
  vendor_value: z.number(),
  // Per-type payload. Only the two fields we consume are declared; the rest of `details`
  // (defense, stat_choices, …) is stripped as usual. `type` is the slot or weapon kind
  // (Greatsword, Boots); `weight_class` is armour-only (research V6).
  details: z
    .object({
      type: z.string().optional(),
      weight_class: z.string().optional(),
    })
    .optional(),
});
  • [ ] Step 4: Run to verify they pass

Run: pnpm --filter @gw2priory/api exec vitest run src/gw2/gw2.schemas.test.ts Expected: PASS, and every pre-existing test in the file still passes.

  • [ ] Step 5: Commit
bash
git add apps/api/src/gw2/gw2.schemas.ts apps/api/src/gw2/gw2.schemas.test.ts
git commit -m "api: carry item details through the gw2 boundary"

Task 2: The legendary id set ​

Files:

  • Create: apps/api/src/legendaries/legendaries.data.ts
  • Test: apps/api/src/legendaries/legendaries.data.test.ts

Interfaces:

  • Produces: LEGENDARY_IDS: readonly LegendaryEntry[] and type LegendaryEntry = { id: number; generation: 1 | 2 | 3 | null }. Task 3 consumes both.

  • [ ] Step 1: Write the failing test

ts
// apps/api/src/legendaries/legendaries.data.test.ts
import { describe, expect, it } from 'vitest';
import { LEGENDARY_IDS } from './legendaries.data';

describe('T2 — the legendary id set', () => {
  it('SC1: holds all 198 legendaries', () => {
    expect(LEGENDARY_IDS).toHaveLength(198);
  });

  it('012 T2: every id is unique', () => {
    expect(new Set(LEGENDARY_IDS.map((l) => l.id)).size).toBe(198);
  });

  it('012 T2: generations are 21 / 16 / 16, and null for the other 145', () => {
    const count = (g: 1 | 2 | 3 | null) =>
      LEGENDARY_IDS.filter((l) => l.generation === g).length;
    expect([count(1), count(2), count(3), count(null)]).toEqual([21, 16, 16, 145]);
  });

  it('012 T2: ids are positive integers', () => {
    expect(LEGENDARY_IDS.every((l) => Number.isInteger(l.id) && l.id > 0)).toBe(true);
  });
});
  • [ ] Step 2: Run to verify it fails

Run: pnpm --filter @gw2priory/api exec vitest run src/legendaries/legendaries.data.test.ts Expected: FAIL — cannot resolve ./legendaries.data.

  • [ ] Step 3: Write the data module
ts
// apps/api/src/legendaries/legendaries.data.ts
export type LegendaryEntry = {
  readonly id: number;
  readonly generation: 1 | 2 | 3 | null;
};

// Snapshot of the game as of 2026-08-12 (research V5): scraped from wiki.guildwars2.com by five
// parallel agents, then every id verified against /v2/items — all names matched, all were
// rarity "Legendary", all had the expected type. The gen 1 set is identical to spec 006's
// independently hand-curated `legendaryOutputIds`, which is what validates the method.
//
// `generation` is the ONLY curated field. Name, type, subtype, weight and rarity are hydrated.
// Generation is a weapons concept: armour comes in sets and trinkets have neither, so all 145
// non-weapons are null.
//
// New legendaries need this array updated by hand. Nothing detects them; the count assertion in
// legendaries.data.test.ts is what surfaces the drift.
const gen = (generation: 1 | 2 | 3 | null, ids: readonly number[]): LegendaryEntry[] =>
  ids.map((id) => ({ id, generation }));

export const LEGENDARY_IDS: readonly LegendaryEntry[] = [
  ...gen(1, [
    30684, 30685, 30686, 30687, 30688, 30689, 30690, 30691, 30692, 30693,
    30694, 30695, 30696, 30697, 30698, 30699, 30700, 30701, 30702, 30703,
    30704,
  ]),
  ...gen(2, [
    76158, 87109, 79562, 72713, 88576, 81957, 86098, 79802, 81206, 87687,
    90551, 81839, 89854, 80488, 78556, 71383,
  ]),
  ...gen(3, [
    96937, 96203, 95612, 95808, 96221, 95675, 97165, 96028, 97099, 97783,
    96356, 95684, 97590, 97377, 97077, 96652,
  ]),
  // armour (132), trinkets (9), back items (4)
  ...gen(null, [
    74155, 77474, 80111, 80131, 80145, 80161, 80190, 80205, 80248, 80252,
    80254, 80277, 80281, 80296, 80356, 80384, 80399, 80435, 80557, 80578,
    81462, 81908, 82093, 82098, 82102, 82109, 82173, 82180, 82196, 82214,
    82245, 82268, 82272, 82334, 82348, 82401, 82410, 82423, 82437, 82456,
    82465, 82502, 82512, 82519, 82552, 82670, 82698, 82801, 82902, 82903,
    82925, 82963, 82994, 83036, 83087, 83094, 83113, 83127, 83162, 83240,
    83289, 83308, 83323, 83348, 83394, 83482, 83497, 83595, 83676, 83699,
    83702, 83729, 83862, 83921, 83929, 83957, 84110, 84176, 84181, 84301,
    84341, 84427, 84461, 84481, 84508, 84546, 84561, 84578, 84629, 84633,
    84643, 84655, 84723, 84748, 89093, 89094, 89101, 89117, 89126, 89134,
    89152, 89158, 89167, 89174, 89183, 89209, 89234, 89235, 89245, 89252,
    89260, 89266, 91048, 91234, 92991, 93105, 95380, 101460, 101462, 101499,
    101501, 101516, 101521, 101535, 101536, 101544, 101551, 101556, 101568,
    101570, 101579, 101602, 101609, 101614, 101645, 104857, 105171, 105293,
    105317, 105921, 106178, 106658, 107022, 109012, 109070,
  ]),
];
  • [ ] Step 4: Run to verify it passes

Run: pnpm --filter @gw2priory/api exec vitest run src/legendaries/legendaries.data.test.ts Expected: PASS — 4 tests.

  • [ ] Step 5: Commit
bash
git add apps/api/src/legendaries/legendaries.data.ts apps/api/src/legendaries/legendaries.data.test.ts
git commit -m "api: add the legendary id set"

Task 3: LegendariesService ​

Files:

  • Create: apps/api/src/legendaries/legendaries.schema.ts
  • Create: apps/api/src/legendaries/legendaries.service.ts
  • Test: apps/api/src/legendaries/legendaries.service.test.ts

Interfaces:

  • Consumes: LEGENDARY_IDS (task 2); Gw2Service.items(ids: number[]): Promise<Gw2Item[]> with details (task 1).

  • Produces: LegendariesService.list(filter: LegendariesQueryInput): Promise<Legendary[]>; Legendary, LegendaryDto, LegendariesQuery, LegendariesQueryDto from the schema module.

  • [ ] Step 1: Write the schema module (no test of its own — it is exercised by the service and controller tests, and by the OpenAPI test in task 5)

ts
// apps/api/src/legendaries/legendaries.schema.ts
import { createZodDto } from 'nestjs-zod';
import { z } from 'zod';

export const ARMOR_WEIGHTS = ['Heavy', 'Light', 'Medium'] as const;
export const ITEM_TYPES = ['Weapon', 'Armor', 'Back', 'Trinket'] as const;

// Source of truth for the response shape: drives @ZodResponse, the OpenAPI document and
// therefore Orval's generated client.
export const Legendary = z.object({
  id: z.number().int().positive(),
  name: z.string(),
  type: z.string(),
  subtype: z.string().nullable(),
  weight: z.enum(ARMOR_WEIGHTS).nullable(),
  rarity: z.string(),
  generation: z.union([z.literal(1), z.literal(2), z.literal(3)]).nullable(),
});
export const LegendaryList = z.array(Legendary);

export class LegendaryListDto extends createZodDto(LegendaryList) {}

// Query params arrive as strings; coerce then bound. An out-of-range generation is REJECTED,
// never ignored — the upstream silently ignoring `?rarity=` is precisely what this endpoint exists
// to correct (spec R5).
export const LegendariesQuery = z.object({
  generation: z.coerce.number().int().min(1).max(3).optional(),
  type: z.enum(ITEM_TYPES).optional(),
});

export class LegendariesQueryDto extends createZodDto(LegendariesQuery) {}

export type Legendary = z.infer<typeof Legendary>;
export type LegendariesQueryInput = z.infer<typeof LegendariesQuery>;
  • [ ] Step 2: Write the failing test
ts
// apps/api/src/legendaries/legendaries.service.test.ts
import { Test } from '@nestjs/testing';
import { describe, expect, it } from 'vitest';
import { Gw2Service } from '../gw2/gw2.service';
import { LEGENDARY_IDS } from './legendaries.data';
import { LegendariesService } from './legendaries.service';

const item = (
  id: number, name: string, type: string,
  details?: { type?: string; weight_class?: string },
) => ({ id, name, type, rarity: 'Legendary', flags: [], vendor_value: 0, details });

// Twilight (gen 1 weapon), an Obsidian coat (armour), Aurora (trinket).
const FIXTURE = [
  item(30704, 'Twilight', 'Weapon', { type: 'Greatsword' }),
  item(101556, 'Obsidian Medium Jacket', 'Armor', { type: 'Coat', weight_class: 'Medium' }),
  item(81908, 'Aurora', 'Trinket'),
];

async function build(items: unknown[] = FIXTURE) {
  const calls: number[][] = [];
  const moduleRef = await Test.createTestingModule({
    providers: [LegendariesService],
  })
    .useMocker((token) =>
      token === Gw2Service
        ? { items: (ids: number[]) => { calls.push(ids); return Promise.resolve(items); } }
        : undefined,
    )
    .compile();
  return { service: moduleRef.get(LegendariesService), calls };
}

describe('T3 — LegendariesService', () => {
  it('P1 #1: maps every hydrated item to the DTO shape', async () => {
    const { service } = await build();
    const [twilight] = await service.list({});
    expect(twilight).toEqual({
      id: 30704, name: 'Twilight', type: 'Weapon', subtype: 'Greatsword',
      weight: null, rarity: 'Legendary', generation: 1,
    });
  });

  it('P1 #2: weight is set for armour and null elsewhere', async () => {
    const { service } = await build();
    const byId = new Map((await service.list({})).map((l) => [l.id, l]));
    expect(byId.get(101556)?.weight).toBe('Medium');
    expect(byId.get(30704)?.weight).toBeNull();
    expect(byId.get(81908)?.weight).toBeNull();
  });

  it('012 T3: hydrates all 198 ids in one call', async () => {
    const { service, calls } = await build();
    await service.list({});
    expect(calls).toHaveLength(1);
    expect(calls[0]).toHaveLength(198);
    expect(calls[0]).toEqual(LEGENDARY_IDS.map((l) => l.id));
  });

  it('P1 #3: filters by generation', async () => {
    const { service } = await build();
    expect((await service.list({ generation: 1 })).map((l) => l.id)).toEqual([30704]);
  });

  it('P1 #4: filters by type', async () => {
    const { service } = await build();
    expect((await service.list({ type: 'Armor' })).map((l) => l.id)).toEqual([101556]);
  });

  it('P1 #6: a 206 partial response yields the known items, not a throw', async () => {
    // Upstream omits unknown ids silently (gw2-api.md); the client returns fewer items.
    const { service } = await build([FIXTURE[0]]);
    await expect(service.list({})).resolves.toHaveLength(1);
  });
});
  • [ ] Step 3: Run to verify it fails

Run: pnpm --filter @gw2priory/api exec vitest run src/legendaries/legendaries.service.test.ts Expected: FAIL — cannot resolve ./legendaries.service.

  • [ ] Step 4: Write the service
ts
// apps/api/src/legendaries/legendaries.service.ts
import { Injectable } from '@nestjs/common';
// biome-ignore lint/style/useImportType: value import — Nest DI needs the runtime reference.
import { Gw2Service } from '../gw2/gw2.service';
import { LEGENDARY_IDS } from './legendaries.data';
import {
  ARMOR_WEIGHTS,
  type LegendariesQueryInput,
  type Legendary,
} from './legendaries.schema';

type Weight = (typeof ARMOR_WEIGHTS)[number];

// `weight_class` is an unconstrained string upstream. Narrow by lookup rather than a cast, so an
// unexpected value becomes null instead of a lie the type system believes.
const toWeight = (value: string | undefined): Weight | null =>
  ARMOR_WEIGHTS.find((w) => w === value) ?? null;

@Injectable()
export class LegendariesService {
  private readonly generationById = new Map(
    LEGENDARY_IDS.map((l) => [l.id, l.generation]),
  );

  constructor(private readonly gw2: Gw2Service) {}

  // Hydrate everything, then filter. Narrowing the id list first would save nothing — the item
  // cache is shared and has no expiry — and would make `?type=` depend on our curated data
  // instead of the upstream `type` field, which is the authority (plan.md, Components).
  async list(filter: LegendariesQueryInput): Promise<Legendary[]> {
    const items = await this.gw2.items(LEGENDARY_IDS.map((l) => l.id));

    return items
      .map((i) => ({
        id: i.id,
        name: i.name,
        type: i.type,
        subtype: i.details?.type ?? null,
        weight: toWeight(i.details?.weight_class),
        rarity: i.rarity,
        generation: this.generationById.get(i.id) ?? null,
      }))
      .filter(
        (l) =>
          (filter.generation === undefined || l.generation === filter.generation) &&
          (filter.type === undefined || l.type === filter.type),
      );
  }
}
  • [ ] Step 5: Run to verify it passes

Run: pnpm --filter @gw2priory/api exec vitest run src/legendaries/legendaries.service.test.ts Expected: PASS — 6 tests.

  • [ ] Step 6: Commit
bash
git add apps/api/src/legendaries/
git commit -m "api: map and filter legendaries from hydrated items"

Task 4: LegendariesController, module, wiring ​

Files:

  • Create: apps/api/src/legendaries/legendaries.controller.ts
  • Create: apps/api/src/legendaries/legendaries.module.ts
  • Modify: apps/api/src/app.module.ts
  • Test: apps/api/src/legendaries/legendaries.controller.test.ts
  • Test: apps/api/src/legendaries/legendaries.module.test.ts

Interfaces:

  • Consumes: LegendariesService.list (task 3).

  • Produces: GET /legendaries. Task 5 generates the contract from it.

  • [ ] Step 1: Write the failing tests

ts
// apps/api/src/legendaries/legendaries.controller.test.ts
import { Test } from '@nestjs/testing';
import { describe, expect, it } from 'vitest';
import { LegendariesController } from './legendaries.controller';
import { LegendariesService } from './legendaries.service';
import { LegendariesQuery } from './legendaries.schema';

const ROWS = [
  { id: 30704, name: 'Twilight', type: 'Weapon', subtype: 'Greatsword',
    weight: null, rarity: 'Legendary', generation: 1 as const },
];

async function build() {
  const seen: unknown[] = [];
  const moduleRef = await Test.createTestingModule({
    controllers: [LegendariesController],
  })
    .useMocker((token) =>
      token === LegendariesService
        ? { list: (f: unknown) => { seen.push(f); return Promise.resolve(ROWS); } }
        : undefined,
    )
    .compile();
  return { controller: moduleRef.get(LegendariesController), seen };
}

describe('T4 — LegendariesController', () => {
  it('P1 #1: returns what the service produced', async () => {
    const { controller } = await build();
    await expect(controller.list({})).resolves.toEqual(ROWS);
  });

  it('012 T4: passes the validated filter straight through', async () => {
    const { controller, seen } = await build();
    await controller.list({ generation: 2, type: 'Weapon' });
    expect(seen).toEqual([{ generation: 2, type: 'Weapon' }]);
  });

  // The contract, not the controller body, is what rejects bad input — assert the schema so the
  // 400 is pinned to something a reader can find.
  it('P1 #5: the query contract rejects an out-of-range generation', () => {
    expect(LegendariesQuery.safeParse({ generation: '9' }).success).toBe(false);
    expect(LegendariesQuery.safeParse({ type: 'Sandwich' }).success).toBe(false);
    expect(LegendariesQuery.safeParse({ generation: '2' })).toMatchObject({
      success: true, data: { generation: 2 },
    });
    expect(LegendariesQuery.safeParse({})).toMatchObject({ success: true, data: {} });
  });
});
ts
// apps/api/src/legendaries/legendaries.module.test.ts
import { Test } from '@nestjs/testing';
import { describe, expect, it } from 'vitest';
import { LegendariesController } from './legendaries.controller';
import { LegendariesModule } from './legendaries.module';
import { LegendariesService } from './legendaries.service';

describe('T4 — LegendariesModule', () => {
  it('012 T4: builds its DI graph', async () => {
    const moduleRef = await Test.createTestingModule({
      imports: [LegendariesModule],
    }).compile();
    expect(moduleRef.get(LegendariesService)).toBeInstanceOf(LegendariesService);
    expect(moduleRef.get(LegendariesController)).toBeInstanceOf(LegendariesController);
  });
});
  • [ ] Step 2: Run to verify they fail

Run: pnpm --filter @gw2priory/api exec vitest run src/legendaries/ Expected: FAIL — cannot resolve ./legendaries.controller / ./legendaries.module.

  • [ ] Step 3: Write the controller and module
ts
// apps/api/src/legendaries/legendaries.controller.ts
import { Controller, Get, Query } from '@nestjs/common';
import { ZodResponse } from 'nestjs-zod';
import {
  type LegendariesQueryInput,
  LegendariesQueryDto,
  LegendaryListDto,
} from './legendaries.schema';
// biome-ignore lint/style/useImportType: value import — Nest DI needs the runtime reference.
import { LegendariesService } from './legendaries.service';
import type { Legendary } from './legendaries.schema';

@Controller('legendaries')
export class LegendariesController {
  constructor(private readonly legendaries: LegendariesService) {}

  // The global ZodValidationPipe validates the query against LegendariesQueryDto and 400s an
  // out-of-range generation or unknown type before this body runs (spec R5).
  @Get()
  @ZodResponse({ status: 200, type: LegendaryListDto })
  list(@Query() query: LegendariesQueryDto): Promise<Legendary[]> {
    const filter: LegendariesQueryInput = query;
    return this.legendaries.list(filter);
  }
}
ts
// apps/api/src/legendaries/legendaries.module.ts
import { Module } from '@nestjs/common';
import { Gw2Module } from '../gw2/gw2.module';
import { LegendariesController } from './legendaries.controller';
import { LegendariesService } from './legendaries.service';

// No `exports`: nothing injects this service. A provider is exported only when another module
// needs it (nestjs.md, feature-module layout).
@Module({
  imports: [Gw2Module],
  controllers: [LegendariesController],
  providers: [LegendariesService],
})
export class LegendariesModule {}
ts
// apps/api/src/app.module.ts — add the import and the array entry, nothing else (G6)
import { LegendariesModule } from './legendaries/legendaries.module';
// …
@Module({
  imports: [
    HealthModule, Gw2Module, StaticDataModule, RecipeGraphModule, LegendariesModule,
  ],
})
export class AppModule {}
  • [ ] Step 4: Run the whole api suite

Run: pnpm --filter @gw2priory/api exec vitest run Expected: PASS, including the architecture guards in src/conventions/ — G2 (value imports), G6 (app.module.ts declares only imports), G7 (colocated test per @Injectable/@Controller).

  • [ ] Step 5: Commit
bash
git add apps/api/src/legendaries/ apps/api/src/app.module.ts
git commit -m "api: expose GET /legendaries"

Task 5: Regenerate the contract ​

Files:

  • Modify: apps/api/openapi.json (generated)
  • Modify: apps/web/src/api/generated/** (generated)

Interfaces:

  • Produces: getLegendariesControllerListQueryOptions and LegendariesControllerListResponse — the names task 8's facade imports. Confirm the exact emitted names from the generated files rather than trusting these; Orval derives them from the operationId (LegendariesController_list), and a mismatch here fails task 8 loudly.

  • [ ] Step 1: Regenerate and verify in one command

Run: pnpm verify:contract Expected: it builds the api, regenerates openapi.json, regenerates the Orval client, formats, then git diff --exit-code fails — because the new endpoint is legitimately new output. That failure is the signal the contract changed, not an error.

  • [ ] Step 2: Read the generated names

Run: grep -rn "Legendaries" apps/web/src/api/generated --include=*.ts -l Expected: an endpoints file and a .zod.ts file under a legendaries tag folder. Note the exact exported hook-options and response-schema names; task 8 imports them verbatim.

  • [ ] Step 3: Confirm the contract carries the query params and nullable fields

Run: node -e "const d=require('./apps/api/openapi.json'); console.log(JSON.stringify(d.paths['/legendaries'],null,1))" Expected: a get with two optional query parameters (generation, type) and a 200 referencing the legendary array schema.

  • [ ] Step 4: Commit the generated artifacts
bash
git add apps/api/openapi.json apps/web/src/api/generated
git commit -m "api: regenerate the contract for /legendaries"
  • [ ] Step 5: Verify it is now clean

Run: pnpm verify:contract Expected: PASS — no diff, because the committed output matches a fresh run.


Task 6: Panda tokens ​

Files:

  • Modify: apps/web/panda.config.ts
  • Test: apps/web/src/__tests__/tokens.test.ts

Interfaces:

  • Produces: card, border, muted, primary as colors tokens; all nine semantic tokens conditioned on _osDark. Tasks 7 and 8 style against them.

  • [ ] Step 1: Write the failing tests

ts
// apps/web/src/__tests__/tokens.test.ts — add to the existing describe
it('012 P3 #1: the shell tokens exist', () => {
  for (const token of ['card', 'border', 'muted', 'primary']) {
    expect(tokenTypes).toContain(`"${token}"`);
  }
});
ts
// apps/web/src/__tests__/tokens.test.ts — new file-level test, reading the config source.
// Asserted against the config rather than the generated CSS because `panda codegen` does not emit
// CSS at all (design-system.md) — the CSS only exists after the PostCSS/Vite pipeline.
import { readFileSync } from 'node:fs';
const pandaConfig = readFileSync(
  join(import.meta.dirname, '..', '..', 'panda.config.ts'), 'utf8',
);

it('012 R12/R19: dark values use _osDark, never _dark', () => {
  expect(pandaConfig).toContain('_osDark');
  expect(pandaConfig).not.toMatch(/\b_dark\b/);
});
  • [ ] Step 2: Run to verify they fail

Run: pnpm --filter @gw2priory/web exec vitest run src/__tests__/tokens.test.ts Expected: FAIL — "card" absent, and _dark still present in the config.

  • [ ] Step 3: Edit the config

In apps/web/panda.config.ts, under theme.extend.semanticTokens.colors:

  1. Rename every existing _dark key to _osDark — five tokens: rarity.basic, rarity.legendary, surface, text.strong, text.muted. This is R19: their dark values have never been reachable, because _dark compiles to .dark & and nothing applies that class (research V1/F1).
  2. Add the four new tokens:
ts
card:   { value: { base: '{colors.white}',     _osDark: '{colors.gray.800}' } },
border: { value: { base: '{colors.gray.200}',  _osDark: '{colors.gray.700}' } },
muted:  { value: { base: '{colors.gray.100}',  _osDark: '{colors.gray.800}' } },
// Provisional teal — a single-token change, deliberately not derived from any rarity colour.
primary: { value: { base: '#0f766e', _osDark: '#2dd4bf' } },

Update the file's existing comment block so it explains _osDark rather than _dark.

  • [ ] Step 4: Regenerate and run

Run: pnpm --filter @gw2priory/web exec panda codegen && pnpm --filter @gw2priory/web exec vitest run src/__tests__/tokens.test.ts Expected: PASS.

  • [ ] Step 5: Prove the media query is actually emitted

Run: pnpm --filter @gw2priory/web exec panda cssgen --outfile /tmp/012-tokens.css && grep -c "prefers-color-scheme" /tmp/012-tokens.css Expected: 1 or more. Before this task the count was 0 — that is the defect being fixed.

  • [ ] Step 6: Commit
bash
git add apps/web/panda.config.ts apps/web/src/__tests__/tokens.test.ts
git commit -m "web: add shell tokens and fix dark mode to follow the OS"

Task 7: The shell and the route moves ​

Files:

  • Modify: apps/web/src/App.tsx
  • Modify: apps/web/src/main.tsx
  • Modify: apps/web/src/features/health/routes.tsx
  • Test: apps/web/src/__tests__/App.test.tsx
  • Test: apps/web/src/features/health/__tests__/routes.test.tsx (path change)

Interfaces:

  • Consumes: the tokens from task 6.

  • Produces: the shell that task 8's page renders inside.

  • [ ] Step 1: Write the failing tests

ts
// apps/web/src/__tests__/App.test.tsx
import { render, screen } from '@testing-library/react';
import { createMemoryRouter, RouterProvider } from 'react-router';
import { describe, expect, it } from 'vitest';
import { App } from '../App';

const renderAt = (path: string) =>
  render(
    <RouterProvider
      router={createMemoryRouter(
        [{ element: <App />, children: [
          { path: '/legendaries', element: <p>legendaries</p> },
          { path: '/health', element: <p>health</p> },
        ] }],
        { initialEntries: [path] },
      )}
    />,
  );

describe('T7 — the app shell', () => {
  it('P2 #1: marks Legendaries as the current page at /legendaries', () => {
    renderAt('/legendaries');
    expect(screen.getByRole('link', { name: /legendaries/i })).toHaveAttribute(
      'aria-current', 'page',
    );
  });

  it('P2 #2: marks Health, and not Legendaries, at /health', () => {
    renderAt('/health');
    expect(screen.getByRole('link', { name: /health/i })).toHaveAttribute('aria-current', 'page');
    expect(screen.getByRole('link', { name: /legendaries/i })).not.toHaveAttribute('aria-current');
  });

  it('SC4: both destinations are reachable from the header', () => {
    renderAt('/health');
    expect(screen.getByRole('link', { name: /legendaries/i })).toHaveAttribute(
      'href', '/legendaries',
    );
    expect(screen.getByRole('link', { name: /health/i })).toHaveAttribute('href', '/health');
  });

  it('P2 #4: the current item is marked by more than colour', () => {
    renderAt('/legendaries');
    // aria-current is the non-colour signal; the bottom border is the visual one.
    expect(screen.getByRole('link', { name: /legendaries/i })).toHaveAttribute(
      'aria-current', 'page',
    );
  });
});
  • [ ] Step 2: Run to verify they fail

Run: pnpm --filter @gw2priory/web exec vitest run src/__tests__/App.test.tsx Expected: FAIL — no links exist; App renders only a heading.

  • [ ] Step 3: Write the shell

Superseded mid-branch: the css/cva calls below now live in apps/web/src/styles.ts and are imported by name. The rule and its Biome enforcement are in react.md (Styling); do not copy the inline shape from this block.

tsx
// apps/web/src/App.tsx
import { Suspense } from 'react';
import { NavLink, Outlet } from 'react-router';
import { css, cva } from '../styled-system/css';
import { QueryBoundary } from './api';

// Sticky lives on the wrapper, not on <header>, so the sticky mechanics never fight the styling.
const stickyStyles = css({ position: 'sticky', top: 0, zIndex: 20, bg: 'surface' });
const barStyles = css({ borderBottomWidth: '1px', borderColor: 'border', bg: 'card' });
const containerStyles = css({
  maxWidth: '1400px', mx: 'auto', px: '8', display: 'flex', alignItems: 'center', gap: '6', h: '20',
});
const brandStyles = css({ fontWeight: 'bold', fontSize: 'lg', color: 'text.strong' });
const mainStyles = css({ maxWidth: '1400px', mx: 'auto', px: '8', mt: '8' });

// Colocated cva, not a config recipe: the component has one consumer, so it is not promoted to
// shared/ui and therefore its variant stays here (design-system.md).
const navItem = cva({
  base: {
    display: 'flex', alignItems: 'center', h: '20', px: '3.5',
    fontSize: 'sm', fontWeight: 'medium',
    borderTopWidth: '4px', borderBottomWidth: '4px', borderColor: 'transparent',
    color: 'text.muted', textDecoration: 'none',
    _hover: { color: 'primary' },
  },
  variants: {
    // Active is a bottom border AND aria-current — legible without colour (P2 #4).
    active: { true: { color: 'primary', borderBottomColor: 'primary' } },
  },
});

export function App() {
  return (
    <>
      <div className={stickyStyles}>
        <div className={barStyles}>
          <header className={containerStyles}>
            <span className={brandStyles}>GW2 Priory</span>
            <nav className={css({ display: 'flex', h: 'full' })}>
              <NavLink to="/legendaries" className={({ isActive }) => navItem({ active: isActive })}>
                Legendaries
              </NavLink>
              <NavLink to="/health" className={({ isActive }) => navItem({ active: isActive })}>
                Health
              </NavLink>
            </nav>
          </header>
        </div>
      </div>
      <main className={mainStyles}>
        <QueryBoundary
          fallback={(retry) => (
            <p role="alert">
              Something went wrong.{' '}
              <button type="button" onClick={retry}>Retry</button>
            </p>
          )}
        >
          <Suspense fallback={<p>Loading…</p>}>
            <Outlet />
          </Suspense>
        </QueryBoundary>
      </main>
    </>
  );
}
tsx
// apps/web/src/features/health/routes.tsx
export const routes: RouteObject[] = [{ path: '/health', element: <HealthPage /> }];
tsx
// apps/web/src/main.tsx — assemble both tables
import { routes as legendariesRoutes } from './features/legendaries/routes';
// …
const router = createBrowserRouter([
  { element: <App />, children: [...legendariesRoutes, ...healthRoutes] },
]);

Note: main.tsx imports the legendaries route table, which task 8 creates. Create features/legendaries/routes.tsx in this task as a stub exporting [] if task 8 has not run — the guard suite errors on a feature folder without routes.tsx.

  • [ ] Step 4: Run the web suite

Run: pnpm --filter @gw2priory/web exec vitest run Expected: PASS, including the existing health tests updated for /health.

  • [ ] Step 5: Verify the compiler does not bail out

Run: pnpm --filter @gw2priory/web build Expected: PASS. panicThreshold: 'all_errors' turns a React Compiler bail-out into a build failure naming the file — and the build is the only place this is checked (SC9).

  • [ ] Step 6: Commit
bash
git add apps/web/src/App.tsx apps/web/src/main.tsx apps/web/src/features apps/web/src/__tests__
git commit -m "web: add the app shell and move health to /health"

Task 8: The legendaries page ​

Files:

  • Create: apps/web/src/api/useLegendaries.ts
  • Modify: apps/web/src/api/index.ts
  • Create: apps/web/src/features/legendaries/groupLegendaries.ts
  • Create: apps/web/src/features/legendaries/LegendariesPage.tsx
  • Modify: apps/web/src/features/legendaries/routes.tsx
  • Test: apps/web/src/features/legendaries/__tests__/groupLegendaries.test.ts
  • Test: apps/web/src/features/legendaries/__tests__/LegendariesPage.test.tsx
  • Test: apps/web/src/features/legendaries/__tests__/routes.test.tsx

Interfaces:

  • Consumes: the generated names confirmed in task 5; the shell from task 7.

  • Produces: useLegendaries(): Legendary[], groupLegendaries(items): Group[] where Group = { title: string; items: Legendary[] }.

  • [ ] Step 1: Write the failing helper test

ts
// apps/web/src/features/legendaries/__tests__/groupLegendaries.test.ts
import { describe, expect, it } from 'vitest';
import { groupLegendaries } from '../groupLegendaries';

const l = (id: number, type: string, generation: 1 | 2 | 3 | null) =>
  ({ id, name: `item-${id}`, type, subtype: null, weight: null,
     rarity: 'Legendary', generation });

describe('T8 — groupLegendaries', () => {
  it('P4 #1: groups weapons by generation, then the rest by type', () => {
    const groups = groupLegendaries([
      l(3, 'Trinket', null), l(1, 'Weapon', 2), l(2, 'Weapon', 1), l(4, 'Armor', null),
    ]);
    expect(groups.map((g) => g.title)).toEqual([
      'Generation 1', 'Generation 2', 'Armor', 'Trinket',
    ]);
    expect(groups[0].items.map((i) => i.id)).toEqual([2]);
  });

  it('012 T8: omits empty groups', () => {
    expect(groupLegendaries([l(1, 'Weapon', 3)]).map((g) => g.title)).toEqual(['Generation 3']);
  });
});
  • [ ] Step 2: Run to verify it fails

Run: pnpm --filter @gw2priory/web exec vitest run src/features/legendaries Expected: FAIL — cannot resolve ../groupLegendaries.

  • [ ] Step 3: Write the helper
ts
// apps/web/src/features/legendaries/groupLegendaries.ts
// Plain module, no React import — grouping is tested as a function, not through a render
// (react.md). Import the row type from the api facade so there is one definition.
import type { Legendary } from '../../api';

export type Group = { title: string; items: Legendary[] };

const TYPE_ORDER = ['Armor', 'Trinket', 'Back'] as const;

export function groupLegendaries(items: Legendary[]): Group[] {
  const weapons = ([1, 2, 3] as const).map((generation) => ({
    title: `Generation ${generation}`,
    items: items.filter((i) => i.type === 'Weapon' && i.generation === generation),
  }));

  const rest = TYPE_ORDER.map((type) => ({
    title: type,
    items: items.filter((i) => i.type === type),
  }));

  return [...weapons, ...rest].filter((g) => g.items.length > 0);
}
  • [ ] Step 4: Write the facade
ts
// apps/web/src/api/useLegendaries.ts
// Import names confirmed from the generated output in task 5 — replace both if they differ.
import { useSuspenseQuery } from '@tanstack/react-query';
import type { z } from 'zod';
import { getLegendariesControllerListQueryOptions } from './generated/endpoints/legendaries/legendaries';
import { LegendariesControllerListResponse } from './generated/endpoints/legendaries/legendaries.zod';
import { suspenseOptions } from './suspenseOptions';

export type Legendary = z.infer<typeof LegendariesControllerListResponse>[number];

// Orval does not wire its validators into its hooks, so this is where response integrity is
// enforced: a malformed payload throws into QueryBoundary rather than reaching a component.
export function useLegendaries(): Legendary[] {
  const query = useSuspenseQuery(
    suspenseOptions(getLegendariesControllerListQueryOptions()),
  );

  return LegendariesControllerListResponse.parse(query.data.data);
}

Add to apps/web/src/api/index.ts: export * from './useLegendaries';

  • [ ] Step 5: Write the failing page test
tsx
// apps/web/src/features/legendaries/__tests__/LegendariesPage.test.tsx
import { render, screen } from '@testing-library/react';
import { MemoryRouter } from 'react-router';
import { describe, expect, it, vi } from 'vitest';
import * as api from '../../../api';
import { LegendariesPage } from '../LegendariesPage';

const ROWS = [
  { id: 30704, name: 'Twilight', type: 'Weapon', subtype: 'Greatsword',
    weight: null, rarity: 'Legendary', generation: 1 as const },
];

describe('T8 — LegendariesPage', () => {
  it('P4 #1/#2/#4: renders a linked card per legendary', () => {
    vi.spyOn(api, 'useLegendaries').mockReturnValue(ROWS);
    render(<MemoryRouter><LegendariesPage /></MemoryRouter>);

    expect(screen.getByRole('heading', { name: 'Generation 1' })).toBeInTheDocument();
    expect(screen.getByRole('link', { name: /Twilight/ })).toHaveAttribute(
      'href', '/legendaries/30704',
    );
  });
});
  • [ ] Step 6: Write the page

Superseded mid-branch, same as task 7 step 3: these css calls now live in apps/web/src/features/legendaries/styles.ts.

tsx
// apps/web/src/features/legendaries/LegendariesPage.tsx
import { Link } from 'react-router';
import { css } from '../../../styled-system/css';
import { useLegendaries } from '../../api';
import { groupLegendaries } from './groupLegendaries';

// No loading or error branch: the shell's <Suspense> and QueryBoundary own both (SC10).
const titleStyles = css({ fontSize: '3xl', fontWeight: 'bold', color: 'text.strong', mb: '2' });
const sectionTitle = css({ fontSize: 'xl', fontWeight: 'semibold', mb: '4', color: 'text.strong' });
const gridStyles = css({
  display: 'grid', gap: '4',
  gridTemplateColumns: { base: '1fr', sm: 'repeat(2, 1fr)', lg: 'repeat(3, 1fr)' },
});
// The card is colocated: one consumer, so shared/ui is not earned (react.md).
const cardStyles = css({
  display: 'block', p: '4', borderRadius: 'md', borderWidth: '1px',
  borderColor: 'border', bg: 'card', textDecoration: 'none',
  _hover: { outlineWidth: '2px', outlineStyle: 'solid', outlineColor: 'primary' },
});
const nameStyles = css({ color: 'rarity.legendary', fontWeight: 'semibold' });
const metaStyles = css({ color: 'text.muted', fontSize: 'sm' });

export function LegendariesPage() {
  const groups = groupLegendaries(useLegendaries());

  return (
    <>
      <h1 className={titleStyles}>Legendaries</h1>
      {groups.map((group) => (
        <section key={group.title} className={css({ mb: '8' })}>
          <h2 className={sectionTitle}>{group.title}</h2>
          <div className={gridStyles}>
            {group.items.map((item) => (
              <Link key={item.id} to={`/legendaries/${item.id}`} className={cardStyles}>
                <span className={nameStyles}>{item.name}</span>
                <p className={metaStyles}>{item.subtype ?? item.type}</p>
              </Link>
            ))}
          </div>
        </section>
      ))}
    </>
  );
}
tsx
// apps/web/src/features/legendaries/routes.tsx — replace the task-7 stub
import type { RouteObject } from 'react-router';
import { LegendariesPage } from './LegendariesPage';

export const routes: RouteObject[] = [
  { path: '/legendaries', element: <LegendariesPage /> },
];
  • [ ] Step 7: Run everything

Run: pnpm test && pnpm typecheck && pnpm lint && pnpm --filter @gw2priory/web build Expected: all PASS. The guard suite covers the cross-feature rule, the react-query boundary, the no-literal-colour rule and the Page-suffix route rule.

  • [ ] Step 8: Commit
bash
git add apps/web/src
git commit -m "web: list the legendaries at /legendaries"

Task 9: Docs and traceability ​

Files:

  • Modify: docs/architecture/design-system.md, react.md, nestjs.md, gw2-api.md

  • Modify: specs/012-app-shell/spec.md (traceability table)

  • [ ] Step 1: Graduate the findings (research.md's Graduation section is the checklist)

  • design-system.md — the four new tokens; _osDark vs _dark, with the emitted-CSS evidence. This is the most re-discoverable fact here: the next person to add a semantic token will assume _dark follows the OS, exactly as this spec did.

  • react.md — the route table; NavLink owns aria-current, never hand-computed.

  • nestjs.md — the legendaries module in the feature list.

  • gw2-api.md — that ?rarity= is silently ignored (HTTP 200, all 74,054 ids), beside the existing note that the 199-id cap is off by one. Same class of trap.

  • [ ] Step 2: Fill the traceability table

Every row in spec.md's table gets the test that proves it — P1 #1–#6, P2 #1–#4, P3 #1–#4, P4 #1–#6, SC1–SC11.

  • [ ] Step 3: Verify the whole thing

Run: pnpm typecheck && pnpm lint && pnpm test && pnpm build && pnpm verify:contract Expected: all PASS.

  • [ ] Step 4: Commit
bash
git add docs specs
git commit -m "specs: graduate 012's findings and fill traceability"

Coverage check ​

Spec requirementTask
R1 module · R5 4004
R2 id array2
R3 hydration · R4 Gw2Service1, 3
R6 facade8
R7 App.tsx · R8 cva · R9 NavLink · R13 routes7
R10 tokens · R12 _osDark · R17 tokens.test · R19 migration6
R14 routes.tsx · R15 card links7 (stub), 8
R11 hover from primary6, 7
R16 no rail/search— nothing to build
R18 docs9
SC3 OpenAPI/Orval drift5
SC9 compiler bail-out7