Skip to content

Research 012 — App shell & the legendaries endpoint ​

Status: complete Step 1.5 output, written between the spec draft and the approval gate. open until every [NEEDS VERIFICATION] marker in spec.md has a verdict here; complete once they do. All six have one: V2, V3, V5 and V6 confirmed; V4 confirmed and accepted as-is by the human; V1 refuted, which sends R12 back to step 1 rather than being patched in place.

Verified against, 2026-08-12, in the 012-app-shell worktree at commit 03a0439: @pandacss/preset-base 1.11.5 · react-router 8.3.0 · vitepress 1.6.4 · the live GW2 API v2 · api.gw2efficiency.com · wiki.guildwars2.com MediaWiki API.

V5 and V6 are not yet answered — they need an enumeration pass that has not been run.

V1 — Does Panda's _dark condition respond to prefers-color-scheme? ​

Question. R12 states the colour scheme follows the operating system with no toggle and no persisted state. That is free only if _dark — the condition every existing semantic token already uses — is the prefers-color-scheme media query. If it is a class or attribute selector, something has to apply that class, and "no new state" stops being true.

Verdict. Refuted. _dark is a class selector. The media query is a different condition, _osDark, which this repo does not use anywhere.

Evidence. node_modules/.pnpm/@pandacss+preset-base@1.11.5/node_modules/@pandacss/preset-base/dist/index.mjs defines the four conditions as:

dark      -> ".dark &"
light     -> ".light &"
osDark    -> "@media (prefers-color-scheme: dark)"
osLight   -> "@media (prefers-color-scheme: light)"

Both are also present in the generated condition list at apps/web/styled-system/css/conditions.mjs:3, which includes _dark,_light,_osDark,_osLight.

Resolution — _osDark, per Panda's own documentation. The docs offer both and prescribe neither, but describe _osDark/_osLight as the modifiers to "style an element based on the user's color scheme preference", which is exactly R12's stated outcome. Redefining the dark condition was considered and rejected: it would make _dark mean something different from every other Panda project.

Verified by spike (config temporarily edited, panda cssgen run, config restored — git status clean):

  • With _dark, the emitted CSS is .dark { --colors-surface: var(--colors-gray-900); … } and contains zero occurrences of prefers-color-scheme.

  • With _osDark, it is:

    css
    @media (prefers-color-scheme: dark) {
      :where(:root, :host) {
        --colors-rarity-basic: #ffffff;
        --colors-rarity-legendary: #974EFF;
        --colors-surface: var(--colors-gray-900);
        --colors-text-strong: var(--colors-gray-50);
        --colors-text-muted: var(--colors-gray-300)
      }
    }

So _osDark is valid inside semanticTokens — which the documentation does not show — and produces OS-following dark mode with no class, no JavaScript and no persisted state.

Caveat. panda cssgen output, not a rendered browser. The media query is emitted; that a browser honours it is assumed.

V2 — Is {colors.white} a valid token reference in Panda's default preset? ​

Question. R10 gives card a base value of {colors.white}, and the existing config references only gray.*. If white is not a token, card needs a different base.

Verdict. Confirmed. white, black and the full gray.* scale are all valid ColorToken members.

Evidence. apps/web/styled-system/tokens/tokens.d.ts, ColorToken union — contains "white", "black", "gray.50", "gray.100", "gray.200", "gray.700", "gray.800", "gray.900", alongside the project's own "surface", "text.strong", "text.muted" and the seven "rarity.*" entries.

Question. P2 #1 and P2 #2 assert the current navigation item is marked, and P2 #4 requires that marking to be perceivable without colour. R9 forbids hand-computing it from useLocation.

Verdict. Confirmed. NavLink defaults the attribute to "page" and forwards it to the rendered Link when active. Nothing needs to be written by hand.

Evidence. react-router@8.3.0, dist/development/lib/dom/lib.js. The component signature destructures the prop with a default:

js
const NavLink = React.forwardRef(function NavLinkWithRef({
  "aria-current": ariaCurrentProp = "page", caseSensitive = false, ...

and the render forwards the computed value:

js
return React.createElement(Link, { ...rest, "aria-current": ariaCurrent, className, ref, style, to, viewTransition }, ...)

The same file's doc comment states it "Automatically applies aria-current="page" to the link when the link is active."

V4 — What does React Router 8 render for an unmatched path? ​

Question. R13 leaves / unrouted. / is both the URL people type and the logo's destination, so whatever renders there is the app's front door, not an edge case.

Verdict. Confirmed, and unacceptable as a front door. An unmatched path falls through to react-router's built-in DefaultErrorComponent.

Evidence. react-router@8.3.0, dist/development/lib/hooks.js. defaultErrorElement renders:

js
React.createElement("h2", null, "Unexpected Application Error!"),
React.createElement("h3", { style: { fontStyle: "italic" } }, message),
stack ? React.createElement("pre", { style: preStyles }, stack) : null,
devInfo

preceded by console.error("Error handled by React Router default ErrorBoundary:", error). In development it also renders a "💿 Hey developer 👋" block telling the reader to supply their own ErrorBoundary or errorElement.

So visiting / would show unstyled black-on-white text reading "Unexpected Application Error!", log an error to the console, and — in development — instructions addressed to the developer. The spec anticipated this outcome and routed it back as a clarification rather than letting a catch-all be added quietly.

Caveat. Read from the development bundle. The production bundle omits devInfo and the stack, but the h2/h3 pair is not conditional.

Human decision, 2026-08-12: accepted as-is. No catch-all route, no not-found page. The app has no users, so an ugly front door costs nothing today. R13 stands unchanged and / continues to render react-router's default error component until a dashboard occupies it. Recorded so the next reader knows this was chosen rather than missed.

V5 — What is the complete legendary id set, and how many are there? ​

Verdict. Confirmed — 198 items. Obtained by scraping the wiki, then verified against the live GW2 API in two batched calls (not the 373 a full sweep would have cost).

ClassCountDetail
Weapons53gen 1 · 21, gen 2 · 16, gen 3 · 16
Armour1327 full 18-piece sets + 2 single-slot legendaries × 3 weights
Trinkets9
Back items4
Total198

Method. Five parallel subagents scraped wiki.guildwars2.com; the armour agent used the wiki's Semantic MediaWiki action=ask endpoint ([[Has item rarity::Legendary]][[Has equipment supertype::Armor]][[Has game id::+]]) and cross-checked with a second query keyed on Has armor weight class, which returned the same 132. Every id was then verified in one GET /v2/items?ids=… call per batch: all names matched, all were rarity: "Legendary", all had the expected type. No id was missing and none was a duplicate.

Independent validation of the method. The scraped gen 1 set is identical to spec 006's hand-curated legendaryOutputIds — 21 ids, nothing extra, nothing absent. A method that reproduces a list curated by hand with per-item wiki citations in an earlier spec is trustworthy for the other four categories, where no prior list exists to check against.

Weapon coverage is internally consistent. Read from details.type:

gen1 (21): 19 weapon types, including all three aquatic (Harpoon, Speargun, Trident)
gen2 (16): the 16 land types, no aquatic
gen3 (16): the 16 land types, no aquatic

Gen 1's 21 items over 19 types is explained by Sunrise, Twilight and Eternity all being greatswords. Gens 2 and 3 having no aquatic weapons is a real property of those sets, not a scraping gap.

Armour coverage is internally consistent. details.weight_class splits 44 Heavy / 44 Light / 44 Medium — an exactly balanced three-way split, which a lossy scrape would be unlikely to preserve. details.type gives slots: Boots 21, Coat 21, Gloves 24, Helm 21, Leggings 21, Shoulders 21, HelmAquatic 3. Gloves is 24 rather than 21 because Eikasia, Mists-Grasper is a gloves-only legendary in all three weights; HelmAquatic 3 is Selachimorpha, likewise.

Known exclusion. Suffused Obsidian armour (and its Slumbering variant) is a skin upgrade applied to the Obsidian items, not separate items — it has no item ids of its own and is correctly absent.

What the module needs. Only { id, generation } per R2: generation is 1, 2 or 3 for the 53 weapons and null for the other 145. Names, types, subtypes and weights all arrive from hydration, so none of the scraped names need transcribing into the codebase.

Superseded — how it was not done. Three sources were eliminated first:

  • The GW2 API cannot filter. GET /v2/items?rarity=Legendary returns HTTP 200 and 74,054 ids — identical to a bare GET /v2/items. The parameter is silently ignored, not rejected.
  • gw2efficiency cannot answer it. https://api.gw2efficiency.com/items returns HTTP 200 with an array of 87,930 bare integers — ids only, no rarity or type. (/tradingpost/prices returns 404.)
  • The wiki counts articles, not items. Via its MediaWiki API, Category:Legendary weapons has 58 members, Legendary armor 3, Legendary trinkets 1, Legendary back items 6. The 58 is a usable weapon count; the others are overview pages, not per-item entries — legendary armour is six slots across three weights per set.

The rejected alternative was a sweep of all 74,054 ids through the project's own rate-limited client: 74,054 ÷ the 199-id cap = 373 batched requests. Bounded and one-off, but real traffic against a public API to learn ~200 facts. Human decision, 2026-08-12: not acceptable; scrape the wiki instead.

Had the sweep been run, a rarity-only filter would have been wrong: gift items (Gift of Exploration, Gift of Battle) carry Legendary rarity and are ingredients, not equipment. Scraping by equipment category sidesteps that entirely.

V6 — Does /v2/items expose a usable subtype for legendary armour? ​

Verdict. Confirmed. Armour carries both a slot and a weight, so R3 holds for armour as well as weapons and subtype needs no curation.

Evidence. From the 132-id verification call, e.g. id 84655:

json
{"id":84655,"name":"Ardent Glorious Wargreaves","type":"Armor",
 "details":{"type":"Boots","weight_class":"Heavy","defense":191, …}}

Across all 132: details.type ∈ {Boots, Coat, Gloves, Helm, Leggings, Shoulders, HelmAquatic} and details.weight_class ∈ {Heavy, Light, Medium}.

Consequence for LegendaryDto. subtype maps to details.type for every class — Greatsword for a weapon, Boots for armour. Weight is a second armour-only axis with no weapon equivalent, so it does not fit the existing subtype field. The DTO either gains a nullable weight, or drops the information. Not decided here — it is a contract question for the spec, not a fact discovery can settle.

Also confirmed for weapons, from the 66-id call: 30704 Twilight → details.type = Greatsword, 30699 Bolt → details.type = Sword.

F2 — 198 items against a 199-id cap: one id of headroom ​

Finding. The complete legendary set is 198 ids. gw2-api.md records the effective batch cap as 199. So GET /legendaries hydrates in exactly one upstream request today — with a margin of one.

Why it matters. It confirms the endpoint's runtime cost is one upstream call (then zero, from the no-expiry item cache), which is what the design assumed. But the margin is a single item: the next legendary ArenaNet ships makes it two chunks.

Functionally that is a non-event — apps/api/src/gw2/gw2-client.ts already partitions and chunks at MAX_IDS_PER_REQUEST = 199 — so nothing needs building for it. It is recorded because "it fits in one call" is the kind of fact that gets promoted to an assumption and then silently relied upon. It should not be: the correct statement is that the client chunks, and the count happens to be under the cap today. No requirement should depend on one-call behaviour.

F1 — The app's dark mode has never been able to activate ​

Finding. No .dark class is ever applied in apps/web, so every _dark token value in panda.config.ts is currently unreachable.

Evidence. grep -rn "dark" apps/web/src apps/web/index.html returns nothing. apps/web/src/index.css contains zero occurrences of prefers-color-scheme. Combined with V1 — _dark resolves to .dark & — the dark values for surface, text.strong, text.muted, rarity.basic and rarity.legendary can never match a rendered element.

Why it matters. This predates spec 012; it arrived with spec 010's token work and is not caused by anything in this spec. It is invisible today only because design-system.md records that surface "is not yet applied anywhere" — no component consumes the affected tokens, so nothing looks wrong yet. The first component to use surface would render light-mode colours on a dark-mode machine.

design-system.md currently reasons at length about rarity.basic and rarity.legendary being semantic tokens because the wiki's two skins disagree, and about surface being light in base mode and dark in _dark mode. That reasoning is sound; the mechanism it relies on is not wired.

Refuted claims ​

A refuted claim sends the spec back to step 1 rather than being patched in place.

R12 — "The colour scheme follows prefers-color-scheme. No toggle, no persisted preference." The intent is achievable, but not by the mechanism the spec assumed. _dark does not read the media query. Reaching the stated outcome requires one of:

  1. Use _osDark for the new tokens and migrate the existing five. True OS-following, no JS, no state. Touches spec 010's panda.config.ts and design-system.md.
  2. Redefine the dark condition in panda.config.ts to the media query, so every existing _dark usage starts working with no token edits. Smallest diff. Cost: _dark then permanently means "the OS is dark", so a manual toggle later would have to unpick it.
  3. Apply a .dark class from JavaScript reading matchMedia. Contradicts R12's "no new state" and is the only option that introduces a fourth state home.

Resolved: option 1, _osDark. Human instruction, 2026-08-12 — follow what Panda's documentation suggests. The docs offer both mechanisms and prescribe neither, but describe _osDark/_osLight as the modifiers for styling "based on the user's color scheme preference", which is precisely R12's stated outcome. Option 2 was rejected because redefining dark would make _dark mean something different in this repo than in every other Panda project.

This also disposes of F1: migrating the five existing tokens from _dark to _osDark is what makes their dark values reachable for the first time. The fix lands here rather than as a separate change, because it is the same one-word edit on the same lines.

Spec changes required. R12 must name _osDark rather than prefers-color-scheme generically, and R10 must specify _osDark for the four new tokens. A third change is needed for an unrelated reason: V6 established that armour carries a weight_class with no weapon equivalent, so LegendaryDto either gains a nullable weight field or discards that information — a contract decision for the human.

Graduation ​

Findings that outlive this feature move to docs/architecture/ at step 6.

  • V1 + F1 → design-system.md. The _dark vs _osDark distinction, and whichever wiring is chosen. This is the single most re-discoverable fact in this document: the next person to add a semantic token will assume _dark follows the OS, exactly as this spec did.
  • V3 → react.md. NavLink owns aria-current; it is never hand-written.
  • V4 → react.md. What an unmatched path renders, and therefore why a catch-all exists (or does not).
  • V5's eliminations → gw2-api.md. That ?rarity= is silently ignored rather than rejected belongs beside the existing note that the 199-id cap is off by one — same class of trap.