Skip to content

feat(site): rebuild the leveling page around parsed talent orders - #4

Merged
stevemcqueenz merged 19 commits into
mainfrom
fm/tkb-site-ux
Sep 12, 2026
Merged

stevemcqueenz merged 19 commits into
mainfrom
fm/tkb-site-ux

Conversation

@stevemcqueenz

@stevemcqueenz stevemcqueenz commented Sep 12, 2026 •

Copy link
Copy Markdown
Owner

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.md as 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 the role: leveling matrix 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.md files 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.py now parses them into leveling.talentOrders and they render through the existing LevelingPath timeline - 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.py now parses the talent-order tables out of each leveling.md into leveling.talentOrders (level / talent / tree / points / note steps, one order per build column when a table lists several builds, approximate when a level cell carries ~); check-data.mjs validates the new shape, test_data.py asserts the seven classes that publish such tables parse at least one order, and classes.json / meta.json are regenerated.
  • LevelingPage is rebuilt on the guide layout: class-colored hero with a verdict line derived from the role: leveling matrix 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 the LevelingPath timeline and side prose folded into collapsibles. The new grouping, verdict and order-picking helpers live in src/lib/leveling.ts; the unused SectionNav component is deleted and its NavItem type moved into SectionTabs.
  • LevelingPath tiles now render Markdown cell content inline (new Markdown inline / renderInlineMarkdown), show points and notes, and clamp long tiles behind a Show all/Show less toggle; a spec playbook with no leveling_order of its own shows the class guide's order labelled as borrowed (with SpecCard'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.

  • Live validation: ✅ go - 11 of 11 scenarios driven live against the product
Scenario Result Live Evidence
From the home page a player clicks "Level as Arms (2H)" on the Warrior card and lands on the Warrior leveling guide ✅ pass live Clicked the link in headless Chrome; location.hash became #/class/warrior/leveling and h1 read "Warrior · Leveling 1–60". All 9 class cards expose a /leveling link. Screenshot 01-home-level-as-links.p…
The rebuilt leveling page groups the guide's own sections into player-first bands with working section tabs ✅ pass live Warrior leveling page rendered bands lv-spec, lv-talents, lv-press, lv-stats, lv-dont, lv-route, lv-hardcore, lv-more, lv-sources with matching tabs. Clicking the "Don't" tab preserved the hash route…
The talent order parsed out of the class leveling markdown renders as a level-by-level timeline matching the source table ✅ pass live Warrior Build A rendered 12 tiles (~14 Cruelty (Fury) 5/5 … 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…
A long tile clamps to four lines and the "Show all" toggle expands it in place, then collapses again ✅ pass live Hunter step 10–14: clamped box 70.75px against 186px of content; clicking "Show all" grew the tile 110.75px -> 226.25px with the box at full 186.25px and the label flipping to "Show less"; clicking ag…
Adversarial: the expand toggle is reachable and operable by keyboard and shows a visible focus ring ✅ pass live A real Space keypress on the focused control expanded the tile; the input then matched :focus-visible and the label's computed outline was "solid 2px rgb(77, 208, 225)" at 2px offset. Screenshot 06-hu…
Adversarial: a cell carrying a hedging marker is never truncated away, whatever the delimiter or position ✅ pass live Hunter 10–14 renders "… Rinarch · player · 2024-11-19 — contested" and warlock level-31 renders "… so Siphon Life is &#34;mana wasteful&#34;, contested/YMMV)" in full (comma-delimited, inside parentheses, t…
Adversarial: if the talent-order parser stops matching, the acceptance harness fails instead of silently shipping blank timelines ✅ pass live Broke LEVEL_COL in build-data.py, regenerated the data, and test_data.py exited 1 with "7 classes publish a talent-order table but parsed none: ['hunter','paladin','priest','rogue','shaman','warlock',…
Leveling spec cards lead to the right spec guide, and fall back to the class page when the pick names an ambiguous spec ✅ pass live Across all 9 leveling pages: 8 single-match picks link to spec guides (e.g. #/class/paladin/retribution-melee-dps) and 5 ambiguous picks link to the class page with a "See the … spec guides" label; fo…
A spec guide with no leveling order of its own shows the class guide's order, keeping notes and citations ✅ pass live #/class/hunter/survival-melee-dps renders "Leveling path — From the Hunter leveling guide" with 17 steps, per-step notes and 5 forum citation chips, and no borrowed 1.18.1 patch chip. Screenshot 07-sp…
An order whose source gives no levels falls back to numbered tiles with an explanatory footnote ✅ pass live #/class/warrior/arms-melee-dps renders #1..#12 badges and "Tiles numbered #1, #2 … come from sources that give the order of the picks but not the level they land on". Screenshot 09-playbook-order-unnu…
The site stays offline-capable and error-free: classes whose orders are prose still render, no console errors, no external network calls ✅ pass live All 9 leveling pages visited with zero console messages; mage renders the Talent order band from prose with no timeline and no crash; the network log shows only 3 same-origin requests (document, JS, C…

Home page: every class card's "Level as X" is now a link to the leveling guide
Warrior leveling page: hero, sourced verdict line, section tabs, Which spec cards
Warrior talent-order timeline parsed from the synthesis table, with the ~ approximate footnote and per-build folded prose
Hunter tile clamped to four lines with the "Show all" toggle
Same tile expanded in place after activating the toggle
Keyboard focus ring on the expand toggle after a real Space keypress; the "— contested" marker is fully visible
Hunter Survival spec guide borrowing the class-guide order ("From the Hunter leveling guide") with notes and citations
Narrow viewport (500px, below the sm: breakpoint): single-column tiles, clamp toggles, no horizontal overflow
Playbook order without levels: #1..#12 badges plus the explanatory footnote
Warlock level-31 Siphon Life expanded: the contested/YMMV qualifier survives in full

Evidence: Adversarial parser break trips the new acceptance guard
$ sed -i 's/LEVEL_COL = .../LEVEL_COL = re.compile(r"^levelZZZ\\b", re.I)/' site/scripts/build-data.py
$ python3 scripts/build-data.py && python3 scripts/test_data.py
FAILED: 1 check(s) failed
- 7 classes publish a talent-order table but parsed none: ['hunter', 'paladin', 'priest', 'rogue', 'shaman', 'warlock', 'warrior']
(exit 1)
$ git checkout -- site/scripts/build-data.py site/src/data/ && python3 site/scripts/test_data.py
OK: all data-contract acceptance checks passed
classes=9 playbooks=47 leveling=9 matrixRows=106 glossaryTerms=313

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 from playbook?.yaml?.patch_validity?.valid_for_1181 with 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)". The contested chip 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 (the clamped prop 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 the clamped prop, 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 the clamped prop, 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 carries block line-clamp-4 peer-checked:line-clamp-none; Tailwind emits .line-clamp-4 (which sets display:-webkit-box) BEFORE .block (which sets display:block) — verified by generating this project's own CSS with ./node_modules/.bin/tailwindcss -c tailwind.config.js -i src/index.css: .line-clamp-4 lands at line 792, .block at line 806. Equal specificity, later rule wins, so the element is display:block and -webkit-line-clamp:4 never applies (it only works on display:-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: drop block from the long branch — line-clamp-4 already sets display, which is why GuideHero.tsx:13 (line-clamp-5, no block) clamps correctly today.
  • ℹ️ site/src/components/guide/LevelingPath.tsx:51 - long = talent.length + note.length &gt; 120 measures 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 an sr-only checkbox (line 61) driven by two &lt;label htmlFor&gt; elements (lines 74/77). Tailwind's .sr-only sets position: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 any peer-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. append peer-focus-visible:outline peer-focus-visible:outline-2 peer-focus-visible:outline-offset-2 peer-focus-visible:outline-[rgb(var(--c-accent))] to the shared toggleClass at 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 — and split_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 as leveling-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: delete plain_heading() and build names directly from header (the cells are already stripped). Note the raw header[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?\b without 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 assertion if (route === &#39;#/class/warrior/protection-tank&#39;) expect(&#39;Leveling path&#39;) breaks the documented fixtures mode. scripts/smoke.mjs's own header documents node scripts/smoke.mjs [--fixtures], which sets VITE_USE_FIXTURES=1 so src/data/index.ts loads src/data/fixtures/*.json. In that bundle warrior's leveling.talentOrders is absent (the fixtures legitimately omit the key, as types.ts:179 and check-data.mjs:139 both acknowledge) and warrior/protection-tank's yaml.talents.leveling_order is [] — I read both out of site/src/data/fixtures/classes.json. So in PlaybookPage levelingOrder.length === 0 and pickTalentOrder(entry) returns null, neither GuideSection id=&#34;leveling&#34; branch renders, and the run prints MISSING &#34;Leveling path&#34; and exits non-zero. CI is unaffected (.github/workflows/ci.yml:32 runs npm run smoke without --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 &amp;&amp; route === &#39;#/class/warrior/protection-tank&#39;) — isFixture already exists on SiteData (types.ts:310) and siteData is 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 home href=&#34;#/class/warrior/leveling&#34;) all still pass against the fixture bundle, so this one assertion is the only break.
  • ⚠️ site/src/components/guide/LevelingPath.tsx:26 - Simplification pass. levelLabel is level.replace(/^(~?)\s*(?:levels?|lvl)\.?\s*/i, &#39;$1&#39;), 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 by levels?. Zero cells start with lvl (0 hits) and zero carry a trailing period, so |lvl and \.? 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 as leveling-rules-dead-alternatives and 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 future lvl 14 spelling) is your call, hence ask-user.
  • ⚠️ site/scripts/build-data.py:252 - Simplification pass. approximate has two independent triggers: re.search(r&#34;approx|arithmetic&#34;, names[level], re.I) on the header, OR any(&#34;~&#34; 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, approx additionally matches no header in any of the nine files; only arithmetic does. No intent requirement needs the header branch. Narrower form: keep only any(&#34;~&#34; in cell(r, level) for r in rows), which is also the signal levelLabel preserves and the LevelingPath footnote explains, so the ~ in the tile and the approximate flag 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 &lt;h3&gt;{cleanHeading(order.title)}&lt;/h3&gt;, and order.title is 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. render order.title only 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 "approximate is 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 now approximate = any(&#34;~&#34; 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 &gt; 120 measures raw Markdown, but the rendered text is much shorter: enhanceMarkdownDom rewrites [Ataika (player), 2025-01-07](https://forum.turtlecraft.gg/...) into the chip label Ataika · player · 2025-01-07, so a ~70-char URL counts toward the clamp decision and then disappears from the layout. Concrete: hunter's Bestial Swiftness 1 step is 254 raw chars but ~184 visible; paladin's Blessing of Kings 1, then raid: … step is 123 chars with no links at all. Both render line-clamp-4 plus 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 fix plainText(talent, Infinity).length + plainText(note, Infinity).length &gt; 120 does 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's Bestial Swiftness 1 step measures 183 rendered chars and paladin's Blessing 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 plain talent.length + note.length &gt; 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.

  • Live validation: ✅ go - 11 of 11 scenarios driven live against the product
Scenario Result Live Evidence
From the home page a player clicks "Level as Arms (2H)" on the Warrior card and lands on the Warrior leveling guide ✅ pass live Clicked the link in headless Chrome; location.hash became #/class/warrior/leveling and h1 read "Warrior · Leveling 1–60". All 9 class cards expose a /leveling link. Screenshot 01-home-level-as-links.p…
The rebuilt leveling page groups the guide's own sections into player-first bands with working section tabs ✅ pass live Warrior leveling page rendered bands lv-spec, lv-talents, lv-press, lv-stats, lv-dont, lv-route, lv-hardcore, lv-more, lv-sources with matching tabs. Clicking the "Don't" tab preserved the hash route…
The talent order parsed out of the class leveling markdown renders as a level-by-level timeline matching the source table ✅ pass live Warrior Build A rendered 12 tiles (~14 Cruelty (Fury) 5/5 … 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…
A long tile clamps to four lines and the "Show all" toggle expands it in place, then collapses again ✅ pass live Hunter step 10–14: clamped box 70.75px against 186px of content; clicking "Show all" grew the tile 110.75px -> 226.25px with the box at full 186.25px and the label flipping to "Show less"; clicking ag…
Adversarial: the expand toggle is reachable and operable by keyboard and shows a visible focus ring ✅ pass live A real Space keypress on the focused control expanded the tile; the input then matched :focus-visible and the label's computed outline was "solid 2px rgb(77, 208, 225)" at 2px offset. Screenshot 06-hu…
Adversarial: a cell carrying a hedging marker is never truncated away, whatever the delimiter or position ✅ pass live Hunter 10–14 renders "… Rinarch · player · 2024-11-19 — contested" and warlock level-31 renders "… so Siphon Life is &#34;mana wasteful&#34;, contested/YMMV)" in full (comma-delimited, inside parentheses, t…
Adversarial: if the talent-order parser stops matching, the acceptance harness fails instead of silently shipping blank timelines ✅ pass live Broke LEVEL_COL in build-data.py, regenerated the data, and test_data.py exited 1 with "7 classes publish a talent-order table but parsed none: ['hunter','paladin','priest','rogue','shaman','warlock',…
Leveling spec cards lead to the right spec guide, and fall back to the class page when the pick names an ambiguous spec ✅ pass live Across all 9 leveling pages: 8 single-match picks link to spec guides (e.g. #/class/paladin/retribution-melee-dps) and 5 ambiguous picks link to the class page with a "See the … spec guides" label; fo…
A spec guide with no leveling order of its own shows the class guide's order, keeping notes and citations ✅ pass live #/class/hunter/survival-melee-dps renders "Leveling path — From the Hunter leveling guide" with 17 steps, per-step notes and 5 forum citation chips, and no borrowed 1.18.1 patch chip. Screenshot 07-sp…
An order whose source gives no levels falls back to numbered tiles with an explanatory footnote ✅ pass live #/class/warrior/arms-melee-dps renders #1..#12 badges and "Tiles numbered #1, #2 … come from sources that give the order of the picks but not the level they land on". Screenshot 09-playbook-order-unnu…
The site stays offline-capable and error-free: classes whose orders are prose still render, no console errors, no external network calls ✅ pass live All 9 leveling pages visited with zero console messages; mage renders the Talent order band from prose with no timeline and no crash; the network log shows only 3 same-origin requests (document, JS, C…
  • python3 scrape/validate.py && python3 site/scripts/test_data.py && cd site && npm ci && npm run check && npm run smoke
  • npm run build + npx vite preview --port 4319 (real static site served the way it ships)
  • Chrome 131 headless driven via chrome-devtools-axi at 1440x900 and at 500px (below the sm: breakpoint)
  • Clicked home card "Level as Arms (2H)" link -> #/class/warrior/leveling
  • Clicked "Show all" / "Show less" on hunter Endurance Training 5; measured tile height 110.75px -> 226.25px -> 110.75px
  • chrome-devtools-axi press Space on the focused clamp toggle; asserted :focus-visible and computed outline solid 2px rgb(77,208,225)
  • Visited all 9 #/class/&lt;slug&gt;/leveling routes; chrome-devtools-axi console reported zero messages
  • chrome-devtools-axi network on 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-dps borrowed "Leveling path" (17 steps, notes + forum citation chips)
  • #/class/warrior/arms-melee-dps playbook order with no levels -> #1..#12 badges + footnote
  • Adversarial: patched LEVEL_COL in site/scripts/build-data.py, re-ran python3 scripts/build-data.py, then python3 scripts/test_data.py (failed as intended), then git checkout + byte-diff of classes.json
  • npm run smoke (78 SSR routes, 0 failures)
✅ **Document** - passed

✅ No issues found.

✅ **Lint** - passed

✅ No issues found.

✅ **Push** - passed

✅ No issues found.

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.
@stevemcqueenz
stevemcqueenz merged commit 7a71d7d into main Sep 12, 2026
1 check passed
@stevemcqueenz
stevemcqueenz deleted the fm/tkb-site-ux branch September 16, 2026 22:28
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant