Repository navigation
feat(site): rebuild the leveling page around parsed talent orders - #4
Merged
Merged
Conversation
The leveling guide was a flat stack of the Markdown sections in file order, headed by the source path, so a level-1 player had to read an Introduction and a Sources table before reaching the advice. It now uses the same guide layout as the spec pages: a hero carrying the class's leveling verdict from the `role: leveling` matrix rows, sticky section tabs, and the sections grouped by heading keyword in the order a player needs them - Which spec, Talent order, What to press, Stats & gear, Don't, Route, Hardcore, then More and Sources folded away. No prose is rewritten; it is only ordered and folded. Talent orders are now real data. `build-data.py` parses every pipe table in `synthesis/classes/<class>/leveling.md` that has a level column into `leveling.talentOrders` (one entry per table, or one per build column when the header names builds instead of talents). Cells stay verbatim Markdown and any column that is not level/tree/points/talent is joined into the step's note, so no cited text is dropped; `approximate` is set when the source gives the order rather than the levels, and those tiles are labelled as approximate. Seven of the nine classes yield an order; mage and druid publish none. `LevelingPath` renders those steps as well as the playbook YAML ones, so the "Leveling path" widget on a spec guide with no `talents.leveling_order` of its own now shows the class guide's order, labelled as borrowed and linked back. The home class card's "Level as X" footer is now a link to the leveling guide. check-data validates the new shape; smoke asserts the new leveling headings, the absence of the source-path line, the home card link and the borrowed path.
…e rules, dedupe order titles
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Intent
The original request was that the published site, though built over an extensive archive, is still too hard to digest for a player who wants to learn their class and how to play it best - which rotation, how to level, and so on. The player goals named were: learn the class, learn to play it best, the rotation, and levelling.
Context for that request: this repository archives the Turtle WoW community (official site, phpBB forums in English and German, the Fandom wiki), captured 2026-09-10 before the forums close on 2026-10-16. The servers are already offline; the last patch was 1.18.1.
synthesis/classes/**holds per-class, per-spec-role playbooks plus leveling and gear guides, every claim cited to a forum post, wiki page or staff statement.site/is a static Vite + React + TypeScript + Tailwind site over that synthesis, published to GitHub Pages. A redesign of the home, class and spec guide pages already landed; the request is about what remains hard after it.A UX investigation diagnosed why and produced ranked recommendations. This branch ships the leveling slice of them, using data that already ships:
The leveling guide page. It rendered the sections of
synthesis/classes/<class>/leveling.mdas a flat stack in file order, headed by the source file path, so a level-1 player asking "which spec do I level as, and what do I press?" read an Introduction and a Sources table before reaching any advice. It is now rebuilt on the same guide layout the spec pages use: a hero carrying the class's leveling verdict derived from therole: levelingmatrix rows, sticky section tabs, and the guide's own sections regrouped by heading keyword into the order a player needs them - Which spec, Talent order, What to press, Stats & gear, Don't, Route, Hardcore - with the archival material (Introduction, Sources) folded into collapsibles rather than dropped. The prose itself is not rewritten, only ordered and folded.The talent-order tables. The nine
leveling.mdfiles already contained them, but nothing read them: the "Leveling path" widget was blank on the 33 spec guides that show it, and the leveling page rendered the tables as raw Markdown.build-data.pynow parses them intoleveling.talentOrdersand they render through the existingLevelingPathtimeline - on the leveling page, and on a spec guide that publishes no talent order of its own, where the class guide's order is shown and labelled as borrowed. Where a source gives the order of the picks rather than the level each one lands on, those levels are labelled approximate. Mage and druid write their orders as prose rather than tables and are unchanged here; their guides continue to state plainly that no level-by-level order is published.The home class cards. The "Level as X" footer line is now a real link to that class's leveling guide.
Not touched here: the spec guide hero, the matrix page, the phone header, the class page, the glossary data, and the synthesis content itself.
Constraints: the site stays static and offline-capable - no backend, no runtime network calls, no external fonts or CDN scripts, no Blizzard artwork, hash routing. No game facts may be invented; every claim a player reads keeps its citation back to the archived sources. UI labels are the only hand-written strings; prose is generated from the repo.
What Changed
build-data.pynow parses the talent-order tables out of eachleveling.mdintoleveling.talentOrders(level / talent / tree / points / note steps, one order per build column when a table lists several builds,approximatewhen a level cell carries~);check-data.mjsvalidates the new shape,test_data.pyasserts the seven classes that publish such tables parse at least one order, andclasses.json/meta.jsonare regenerated.LevelingPageis rebuilt on the guide layout: class-colored hero with a verdict line derived from therole: levelingmatrix rows, sticky section tabs, and sections regrouped by heading keyword into Which spec → Talent order → What to press → Stats & gear → Don't → Route → Hardcore → More → Sources, with talent orders rendered as theLevelingPathtimeline and side prose folded into collapsibles. The new grouping, verdict and order-picking helpers live insrc/lib/leveling.ts; the unusedSectionNavcomponent is deleted and itsNavItemtype moved intoSectionTabs.LevelingPathtiles now render Markdown cell content inline (newMarkdown inline/renderInlineMarkdown), show points and notes, and clamp long tiles behind a Show all/Show less toggle; a spec playbook with noleveling_orderof its own shows the class guide's order labelled as borrowed (withSpecCard's 1.18.1 chip suppressed where the patch check does not apply), and the home class card's "Level as …" line links to the leveling guide.Risk Assessment
✅ Low: Every new code path — the talent-order parser, the heading router, the spec-link resolver, and the borrowed-order fallback — was traced against the real source Markdown and the generated data for all nine classes and produced correct output in each case, with the remaining cosmetic clamp artefact already accepted by explicit user decision.
Testing
The configured baseline (validate.py, test_data.py, npm ci, check, smoke) had already passed. On top of that I built the site and served it with vite preview, then drove eleven scenarios in a real headless Chrome: the new home "Level as X" link, the rebuilt leveling page bands and section tabs, the talent-order timeline parsed from the synthesis tables (verified against synthesis/classes/warrior/leveling.md), the clamp/"Show all" toggle by mouse and by keyboard including its focus ring, the two hedged cells the author called out (hunter "— contested", warlock level-31 "contested/YMMV") both rendering in full, spec-card links on both the spec-guide and class-page-fallback branches, the borrowed order on a spec guide with its notes and citations, the "#1, #2 …" fallback for orders without levels, the mage/druid prose fallback, a narrow-viewport pass with no horizontal overflow, and a network check confirming no external requests. Adversarially I broke the parser's level-column regex, rebuilt the data and confirmed the new test_data.py guard fails naming all seven tabled classes, then restored and byte-compared classes.json. Ten screenshots of the real rendered pages are attached. One caveat: section-tab clicks preserve the hash route and move focus to the target band, but the smooth-scroll animation stalls in this headless build - the same stall reproduces on the pre-existing playbook page and with a raw scrollIntoView, so it is a headless artifact rather than a defect in this change. Everything passed; worktree left clean and servers stopped.
40 Master of Arms (Arms) 5/5) identical to the pipe table at synthesis/classes/warrior/leveling.md:21-32, with the "are approximate" foot…Evidence: Adversarial parser break trips the new acceptance guard
Pipeline
Updates from git push no-mistakes
✅ **intent** - passed
✅ No issues found.
✅ **Rebase** - passed
✅ No issues found.
🔧 **Review** - 3 issues found → auto-fixed (7) ✅
site/src/pages/LevelingPage.tsx:106- The new "Which spec" band reuses SpecCard with the playbook returned by levelingPlaybook(), which is deliberately a different role's guide (Arms melee-DPS, Protection tank, Frost ranged-DPS …). SpecCard renders the 1.18.1 chip fromplaybook?.yaml?.patch_validity?.valid_for_1181with the tooltip "The guide has been checked against patch 1.18.1" (site/src/components/SpecCard.tsx:29,57-64). On a leveling page that chip therefore asserts patch validity for a claim it never covered. Concrete: #/class/warrior/leveling, the "Arms (2H) — favored" card shows the 1.18.1 chip taken from structured arms-melee-dps.yaml, which validates the raid rotation, not the leveling recommendation; 11 cards are affected today (warrior ×3, rogue Subtlety, priest Discipline, mage ×3, warlock ×2, druid Balance). Paladin makes the contradiction visible: its talent-order H2 is literally "Talent orders (guide, pre-1.18.1, single source)". Thecontestedchip on the same card is fine — it reads row.agreement, the leveling row's own field. This is user-visible provenance labelling, and the intent requires that "every claim a player reads keeps its citation back to the archived sources", so the remedy (drop the chip on leveling cards, or gate it on the leveling row rather than the borrowed playbook) needs your call rather than an auto-fix.site/src/components/guide/LevelingPath.tsx:22- clamp() cuts at 90 characters any cell that carries no hedging marker. Several leveling guides put a whole ordered chain of picks in one talent cell, so the flagship timeline silently shows a partial order. Concrete: #/class/shaman/leveling, tile "10–33" — the source cell lists 11 picks (Shield Specialization 5 → Totemic Alignment 2 → Stable Shields 3 → Ancestral Guardian 3 → Lightning Strike → Thundering Strikes 1 → Spirit Armor 2 → Thundering Strikes 4 → Elemental Weapons 3 → Stormstrike) and the tile renders only the first four before "…", i.e. the widget whose whole purpose is "the order the sources give" shows 4/11 of it; the 34–44 and 51–60 tiles are cut the same way. #/class/paladin/leveling tile "55–60" truncates to "Blessing of Kings 1, then raid: Improved Hand of Reckoning 2 + Righteous Defense 3, or…", dropping the alternative branch. This lives inside the component the change introduced (theclampedprop plus the clamp()/HEDGED regex pipeline). The smallest honest remedy is to remove that component rather than tune the threshold: render every cell through <Markdown inline> as the borrowed-order path already does, and get scannability from CSS line-clamp on the tile. That deletes clamp(), HEDGED and theclampedprop, keeps the full text and its links in the DOM for copy, search and screen readers, and makes the round-10 "never cut a hedged cell" rule unnecessary because nothing is cut from the text at all. It is a user-visible product trade you have ruled on before, hence ask-user.site/src/pages/LevelingPage.tsx:1- Intent conformance, flagged for confirmation only. The supplied User intent scopes this change to the matrix page, the phone header and the class page, and states: "The guide hero, the leveling page, build-data.py and the glossary data belong to other concurrent slices and are not touched here." Every commit in d006a1a..7ea6b26 touches exactly those excluded areas: LevelingPage.tsx is rebuilt, build-data.py gains parse_talent_orders, and no matrix page, header or class page file is in the diff. The recorded decisions from ten prior review rounds on this branch are all about this leveling work and explicitly direct it, and the rules say a recorded decision supersedes conflicting intent wording — so this is almost certainly the intent text of the sibling slice (PR fix(site): make the matrix, phone header and class page scannable #1) pasted against the leveling slice, not a real scope breach. No code change is implied; confirm the intent text and the review scope line up.🔧 Fix applied.
1 warning still open:
site/src/components/guide/LevelingPath.tsx:40- The authorized remedy for clamp-drops-picks-from-multi-talent-cells had three parts: "delete clamp(), the HEDGED regex and theclampedprop, render every cell through <Markdown inline> exactly as the borrowed-order path already does, and get scannability from CSS line-clamp on the tile." The first two shipped; the third did not — no element in LevelingPath.tsx carries a line-clamp or any height cap (grep over site/src finds line-clamp only at GuideHero.tsx:13). Concrete result: on #/class/hunter/leveling the step-1 tile's note is 566 characters (the Ataika / Noyland1 / Rinarch contested argument plus three citation chips), roughly 12-14 lines at text-xs in an lg:grid-cols-3 column. Because the <ol> at line 37 is a CSS grid, the entire first row stretches to that height, so the neighbouring "15–19" and "20–25" tiles render as near-empty boxes of equal height. Same shape on #/class/shaman/leveling (talent cells of 322 and 222 chars in the "10–33" and "51–60" tiles) and #/class/paladin/leveling (123/124-char build cells). The full text being in the DOM is the intended, correct outcome; what is missing is the visual cap that was meant to accompany it. Marked ask-user rather than auto-fix because which spans get clamped and at how many lines is a user-visible product choice, not a mechanical correction.🔧 Fix applied.
2 issues (1 error, 1 info) still open:
site/src/components/guide/LevelingPath.tsx:62- The authorized tile clamp is inert. The span carriesblock line-clamp-4 peer-checked:line-clamp-none; Tailwind emits.line-clamp-4(which setsdisplay:-webkit-box) BEFORE.block(which setsdisplay:block) — verified by generating this project's own CSS with./node_modules/.bin/tailwindcss -c tailwind.config.js -i src/index.css:.line-clamp-4lands at line 792,.blockat line 806. Equal specificity, later rule wins, so the element isdisplay:blockand-webkit-line-clamp:4never applies (it only works ondisplay:-webkit-box). Concrete result: on #/class/hunter/leveling the step "10–14" tile (586 chars of talent+note per src/data/classes.json) renders at full height and stretches its entire lg:grid-cols-3 row, i.e. the exact layout defect round 2 reported as tile-line-clamp-missing is still present; and 19 of 112 tiles (hunter ×6, rogue ×3, shaman ×4, warlock ×4, paladin ×2) now additionally render a "Show all" link whose checkbox changes nothing visible. The toggle labels themselves are wired correctly (the.peer:checked ~ .peer-checked\:*rules are emitted after the base utilities and win on specificity); only the clamp is dead. Smallest fix: dropblockfrom the long branch —line-clamp-4already sets display, which is why GuideHero.tsx:13 (line-clamp-5, noblock) clamps correctly today.site/src/components/guide/LevelingPath.tsx:51-long = talent.length + note.length > 120measures raw Markdown including link URLs, while enhanceMarkdownDom collapses each[Author (player), date](https://...)citation into a compact chip. hunter step "31" (127 raw chars) and warlock step "24" (129 raw) render ~125 visible chars, roughly three lines in an lg:grid-cols-3 column — so once the clamp actually works they get a "Show all" toggle that expands nothing. Every currently-long tile is also >120 chars after rendering, so the mismatch is narrow and not worth new measuring machinery; recording the tradeoff, no action needed.🔧 Fix applied.
2 warnings still open:
site/src/components/guide/LevelingPath.tsx:61- The new expand control is keyboard-operable but its focus is invisible. The toggle is ansr-onlycheckbox (line 61) driven by two<label htmlFor>elements (lines 74/77). Tailwind's.sr-onlysetsposition:absolute; width:1px; height:1px; overflow:hidden; clip:rect(0,0,0,0), so the global:focus-visible { outline: 2px solid rgb(var(--c-accent)) }rule in src/index.css:48 is painted around a 1px clipped box and is not visible; neither label carries anypeer-focus-visible:style. Concrete sequence: on #/class/hunter/leveling a sighted keyboard user tabs through the timeline and focus stops on the hidden checkbox of the "10–14" tile (586 chars of talent+note, one of ~19 long tiles across hunter, rogue, shaman, warlock and paladin) with nothing on screen changing — the user cannot tell where focus is or that Space will expand the tile (WCAG 2.4.7). The round-2 instruction asked for "a native, keyboard-accessible way to expand in place"; operability shipped, discoverability did not. Smallest fix: add a focus ring to both labels via the existing peer, e.g. appendpeer-focus-visible:outline peer-focus-visible:outline-2 peer-focus-visible:outline-offset-2 peer-focus-visible:outline-[rgb(var(--c-accent))]to the sharedtoggleClassat line 20. (The round-2 PRIMARY suggestion, <details>/<summary>, would have been natively focus-visible; the checkbox pattern is fine but needs this one class.) This is a repair inside authorized machinery, not new scope.site/scripts/build-data.py:190- Simplification pass.plain_heading()normalizes header cells by stripping Markdown links,**and backticks before the column-role regexes run. I parsed every pipe-table header row in all nine synthesis/classes/*/leveling.md files: zero header cells contain a link,**or a backtick, so all three substitutions are no-ops and only the trailing.strip()does anything — andsplit_table_row()already strips each cell. No intent requirement needs it: the intent covers parsing the talent-order tables that exist today, and new synthesis content is out of scope. This is the same shape asleveling-rules-dead-alternatives, which you already chose to trim on this branch: tolerance for header spellings that no source uses is untested code, and it cannot be relied on to normalize correctly later. Narrower form: deleteplain_heading()and buildnamesdirectly fromheader(the cells are already stripped). Note the rawheader[col]is separately used for the build-column subtitle, so removing this does not affect what the reader sees. Counter-argument — a future bolded| **Levels** |header would stop matching^levels?\bwithout the strip — is your call, hence ask-user.🔧 Fix applied.
4 issues (3 warnings, 1 info) still open:
site/src/smoke-entry.tsx:45- The new assertionif (route === '#/class/warrior/protection-tank') expect('Leveling path')breaks the documented fixtures mode. scripts/smoke.mjs's own header documentsnode scripts/smoke.mjs [--fixtures], which sets VITE_USE_FIXTURES=1 so src/data/index.ts loads src/data/fixtures/*.json. In that bundle warrior'sleveling.talentOrdersis absent (the fixtures legitimately omit the key, as types.ts:179 and check-data.mjs:139 both acknowledge) and warrior/protection-tank'syaml.talents.leveling_orderis[]— I read both out of site/src/data/fixtures/classes.json. So in PlaybookPagelevelingOrder.length === 0andpickTalentOrder(entry)returns null, neitherGuideSection id="leveling"branch renders, and the run printsMISSING "Leveling path"and exits non-zero. CI is unaffected (.github/workflows/ci.yml:32 runsnpm run smokewithout --fixtures), so this only bites a developer using the fixtures path, but it turns a supported command into a spurious failure. Smallest fix: guard the assertion on the real data, e.g.if (!siteData.isFixture && route === '#/class/warrior/protection-tank')—isFixturealready exists on SiteData (types.ts:310) andsiteDatais already imported at line 3. I verified the other four new assertions ('Leveling 1–60', 'Which spec', 'What to press', the absent 'Source file:', and the homehref="#/class/warrior/leveling") all still pass against the fixture bundle, so this one assertion is the only break.site/src/components/guide/LevelingPath.tsx:26- Simplification pass.levelLabelislevel.replace(/^(~?)\s*(?:levels?|lvl)\.?\s*/i, '$1'), introducing two alternatives no source reaches. I ran the real parser over every level cell of all nine talent-order tables in synthesis/classes/*/leveling.md: exactly one cell carries a word prefix, warrior's~level 14, matched bylevels?. Zero cells start withlvl(0 hits) and zero carry a trailing period, so|lvland\.?never fire. No intent requirement needs them — the intent covers the leveling tables that ship today, and new synthesis content is out of scope. This is the same shape asleveling-rules-dead-alternativesand the RULES trim you already chose on this branch, where the reasoning was that a rule which never fires cannot be relied on to fire correctly later. Narrower form that satisfies the intent:/^(~?)\s*levels?\s+/i. Recommending removal of the two unreached alternatives rather than keeping and documenting them; the counter-argument (tolerance for a futurelvl 14spelling) is your call, hence ask-user.site/scripts/build-data.py:252- Simplification pass.approximatehas two independent triggers:re.search(r"approx|arithmetic", names[level], re.I)on the header, ORany("~" in cell(r, level) for r in rows). Only one table in the whole corpus is approximate — warrior's, header "Reaches at (5 pts/row logic; levels are the reader's arithmetic — the post gives order only)" with cells like~level 14— and it satisfies BOTH triggers, so the header test is fully redundant with the cell test on the only input that exists. Within that header test,approxadditionally matches no header in any of the nine files; onlyarithmeticdoes. No intent requirement needs the header branch. Narrower form: keep onlyany("~" in cell(r, level) for r in rows), which is also the signallevelLabelpreserves and the LevelingPath footnote explains, so the~in the tile and theapproximateflag then come from one source of truth instead of two. Same class of finding as the RULES and header-normalization trims already raised on this branch, hence ask-user rather than a mechanical edit.site/src/pages/LevelingPage.tsx:132- Each order renders<h3>{cleanHeading(order.title)}</h3>, andorder.titleis the H2 the table sat under — so when one section holds several tables the same heading repeats. Concrete: on #/class/rogue/leveling the "Talent order" band shows three timelines each titled "Talent orders (10–60)" (distinguished only by the subtitle below), followed by a ProseDetails collapsible titled "Talent orders (10–60)" again — four identical headings under a band already titled "Talent order". Paladin is the same with two ("Talent orders (guide, pre-1.18.1, single source)" ×2 plus the collapsible). Warrior/priest are fine because their H2s name the build ("Build A — 2H Arms …"). This is scanning noise in the page whose whole purpose is to be easier to digest, not a wrong claim, and the remedy is a product call: e.g. renderorder.titleonly for the first order of a given section, and rely on the subtitle for the rest. Not auto-fixing because it changes what the reader sees.🔧 Fix applied.
2 infos still open:
site/PLAN.md:38- The schema doc still says "approximateis true when the header says the levels are arithmetic or a level cell carries~", but the final fix commit (171e95a) removed the header trigger: build-data.py:252 is nowapproximate = any("~" in cell(r, level) for r in rows). A reader (or the concurrent build-data slice) following PLAN.md would expect a table headed "levels are the reader's arithmetic" with no tilde in its cells to come out approximate; it comes out false. Drop the header clause from the sentence so the doc names the tilde as the single source of truth, matching types.ts and the LevelingPath footnote.site/src/components/guide/LevelingPath.tsx:52-const long = talent.length + note.length > 120measures raw Markdown, but the rendered text is much shorter: enhanceMarkdownDom rewrites[Ataika (player), 2025-01-07](https://forum.turtlecraft.gg/...)into the chip labelAtaika · player · 2025-01-07, so a ~70-char URL counts toward the clamp decision and then disappears from the layout. Concrete: hunter'sBestial Swiftness 1step is 254 raw chars but ~184 visible; paladin'sBlessing of Kings 1, then raid: …step is 123 chars with no links at all. Both renderline-clamp-4plus a "Show all" label even though the content fits inside four lines at desktop and phone tile widths, so clicking the toggle changes nothing. I counted 11 such steps in the current data. No text is lost either way, so this is cosmetic; the smallest remedies are raising the threshold or deciding the toggle may show spuriously. Flagged as ask-user rather than auto-fix because the clamp threshold is a reader-visible scannability call you have tuned across several rounds.🔧 Fix applied.
1 info still open:
site/src/components/guide/LevelingPath.tsx:53- The round-6 fixplainText(talent, Infinity).length + plainText(note, Infinity).length > 120does not change the toggle decision for a single step in the shipped data. I ran the same normalization over all 112 parsed steps in site/src/data/classes.json: exactly 19 tiles offer "Show all" before the change and the same 19 after it — zero flips. Only 5 steps contain a Markdown link at all (hunter 10–14, 20, 26–30; rogue 10–11, 12–16), and stripping their URLs leaves them at 393/183/214/183/249 rendered chars, all still far above 120. Both cases the fix was asked to verify still fail: hunter'sBestial Swiftness 1step measures 183 rendered chars and paladin'sBlessing of Kings 1, then raid: …step has no links at all (123 chars raw and rendered), so both still render line-clamp-4 plus a toggle. The prescribed remedy was arithmetically unreachable — stripping URLs cannot bring a 183-char step under a 120 threshold — so the call added machinery without moving the outcome. This needs a decision, not a repair: either accept that ~19 tiles show a possibly-inert toggle and revert line 53 to the plaintalent.length + note.length > 120(removal of the component is the smallest honest remedy, since it buys nothing), or raise the threshold, which the earlier instruction ruled out. No text is lost either way, so this is cosmetic only.🔧 Fix applied.
✅ Re-checked - no issues remain.
✅ **Test** - passed
✅ No issues found.
40 Master of Arms (Arms) 5/5) identical to the pipe table at synthesis/classes/warrior/leveling.md:21-32, with the "are approximate" foot…python3 scrape/validate.py && python3 site/scripts/test_data.py && cd site && npm ci && npm run check && npm run smokenpm run build+npx vite preview --port 4319(real static site served the way it ships)Chrome 131 headless driven viachrome-devtools-axiat 1440x900 and at 500px (below the sm: breakpoint)Clicked home card "Level as Arms (2H)" link ->#/class/warrior/levelingClicked "Show all" / "Show less" on hunterEndurance Training 5; measured tile height 110.75px -> 226.25px -> 110.75pxchrome-devtools-axi press Spaceon the focused clamp toggle; asserted:focus-visibleand computed outlinesolid 2px rgb(77,208,225)Visited all 9#/class/<slug>/levelingroutes;chrome-devtools-axi consolereported zero messageschrome-devtools-axi networkon a guide page (3 same-origin requests, no external calls)Clicked spec cards on every leveling page: 8 spec-guide targets and 5 class-page fallbacks, followed one of each#/class/hunter/survival-melee-dpsborrowed "Leveling path" (17 steps, notes + forum citation chips)#/class/warrior/arms-melee-dpsplaybook order with no levels ->#1..#12badges + footnoteAdversarial: patchedLEVEL_COLinsite/scripts/build-data.py, re-ranpython3 scripts/build-data.py, thenpython3 scripts/test_data.py(failed as intended), thengit checkout+ byte-diff ofclasses.jsonnpm run smoke(78 SSR routes, 0 failures)✅ **Document** - passed
✅ No issues found.
✅ **Lint** - passed
✅ No issues found.
✅ **Push** - passed
✅ No issues found.