@@ -202,9 +202,70 @@ function validateDocFilenames(
202202 * paths surface all failures at once so editing errors in every doc
203203 * show up in one build, not one-per-build.
204204 */
205+ /**
206+ * SVG home icon used in every page's nav. Defined once so the docs
207+ * renderer and the part-page post-processor emit the same bytes (CSP
208+ * hash stable across both). Single path, hard-coded stroke — no
209+ * external sprite or font dep.
210+ */
211+ const HOME_ICON_SVG =
212+ '<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2.25" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 9.5 12 3l9 6.5V20a2 2 0 0 1-2 2h-4v-7h-6v7H5a2 2 0 0 1-2-2z"/></svg>'
213+
214+ const HOME_LINK_HTML =
215+ `<a class="wt-home-link" href="/" aria-label="Back to the table of contents" title="Back to the table of contents">${ HOME_ICON_SVG } </a>`
216+
217+ /**
218+ * Render the "Parts: 1 2 3 … 8" pill row. Same HTML shape meander
219+ * emits for its own part-nav — classes and hrefs match so the CSS +
220+ * post-process pill-enrichment paths in this file apply uniformly.
221+ * Emitted at the top of every doc page so doc readers can jump into
222+ * any part with one click.
223+ */
224+ function renderPartsPillRow (
225+ slug : string ,
226+ partFilenames : ReadonlyMap < number , string > ,
227+ ) : string {
228+ const ids = [ ...partFilenames . keys ( ) ] . sort ( ( a , b ) => a - b )
229+ const pills = ids
230+ . map ( id => `<a href="/${ slug } /part/${ id } ">Part ${ id } </a>` )
231+ . join ( '\n ' )
232+ return (
233+ ` <div class="part-nav">${ HOME_LINK_HTML } <span class="wt-parts-label">Parts:</span>\n` +
234+ ` ${ pills } \n` +
235+ ` </div>`
236+ )
237+ }
238+
239+ /**
240+ * Render the "Topics: A B C … Z" pill row. Mirrors the parts row
241+ * shape so the same CSS styles both. `activeFilename` marks the
242+ * current doc's pill with class="active"; pass `undefined` from part
243+ * pages so no pill is active.
244+ */
245+ function renderTopicsPillRow (
246+ docs : ReadonlyMap < string , DocEntry > ,
247+ activeFilename : string | undefined ,
248+ ) : string {
249+ const pills = [ ...docs . values ( ) ]
250+ . map ( d => {
251+ const cls = d . filename === activeFilename ? 'active' : ''
252+ const ariaLabel = d . summary
253+ ? ` aria-label="${ escapeHtml ( d . title ) } : ${ escapeHtml ( d . summary ) } "`
254+ : ''
255+ return `<a class="${ cls } " href="/${ d . filename } .html" title="${ escapeHtml ( d . title ) } "${ ariaLabel } >${ escapeHtml ( d . title ) } </a>`
256+ } )
257+ . join ( '\n ' )
258+ return (
259+ ` <div class="part-nav wt-topics-nav"><span class="wt-parts-label">Topics:</span>\n` +
260+ ` ${ pills } \n` +
261+ ` </div>`
262+ )
263+ }
264+
205265async function renderDocs (
206266 docs : ReadonlyMap < string , DocEntry > ,
207267 slug : string ,
268+ partFilenames : ReadonlyMap < number , string > ,
208269 repoRoot : string ,
209270 tourDir : string ,
210271) : Promise < void > {
@@ -217,12 +278,9 @@ async function renderDocs(
217278
218279 const entries = [ ...docs . values ( ) ]
219280
220- // Build the topic-nav fragment once per build. Each doc swaps in
221- // class="active" for its own anchor before emitting.
222- const navLink = ( d : DocEntry , active : boolean ) : string => {
223- const cls = active ? 'active' : ''
224- return `<a class="${ cls } " href="/${ slug } /${ d . filename } .html" title="${ escapeHtml ( d . title ) } "${ d . summary ? ` aria-label="${ escapeHtml ( d . title ) } : ${ escapeHtml ( d . summary ) } "` : '' } >${ escapeHtml ( d . title ) } </a>`
225- }
281+ // Precompute the two pill rows once per build — same bytes on every
282+ // doc page except the "active" marker on the current Topics pill.
283+ const partsRow = renderPartsPillRow ( slug , partFilenames )
226284
227285 const renderOne = async ( doc : DocEntry ) : Promise < void > => {
228286 const sourcePath = path . join ( repoRoot , doc . source )
@@ -233,9 +291,7 @@ async function renderDocs(
233291 }
234292 const markdown = await fs . readFile ( sourcePath , 'utf8' )
235293 const body = await marked . parse ( markdown )
236- const navPills = entries
237- . map ( d => navLink ( d , d . filename === doc . filename ) )
238- . join ( '\n ' )
294+ const topicsRow = renderTopicsPillRow ( docs , doc . filename )
239295 const summaryLine = doc . summary
240296 ? ` <p>${ escapeHtml ( doc . summary ) } </p>\n`
241297 : ''
@@ -258,10 +314,8 @@ async function renderDocs(
258314 ` <header class="topbar">\n` +
259315 ` <h1>${ escapeHtml ( doc . title ) } </h1>\n` +
260316 summaryLine +
261- ` <div class="topic-nav">\n` +
262- ` <span class="wt-topics-label">Topics:</span>\n` +
263- ` ${ navPills } \n` +
264- ` </div>\n` +
317+ `${ partsRow } \n` +
318+ `${ topicsRow } \n` +
265319 ` </header>\n` +
266320 `\n` +
267321 ` <main class="doc-body">\n` +
@@ -349,25 +403,28 @@ async function rewriteIndexContents(
349403
350404 // Build unified rows in stable order: parts 1..N first, then docs
351405 // in manifest order. Each row is a flat <div> carrying the same
352- // shape — title link + muted description. Parts also get a lighter
353- // "N sections" bonus annotation appended inline. Using <div>s (not
354- // <ul>/<li>) sidesteps the browser-default bullet rendering that
355- // made the old list look off-tempo.
406+ // shape — title link + muted description on the left (capped reading
407+ // width via CSS), optional section-count badge on the right. Using
408+ // <div>s (not < ul>/<li>) sidesteps the browser-default bullet
409+ // rendering that made the old list look off-tempo.
356410 const renderRow = (
357411 href : string ,
358412 title : string ,
359413 description : string ,
360- bonus ?: string ,
414+ badge ?: string ,
361415 ) : string => {
362- const bonusHtml = bonus
363- ? ` <span class="wt-contents-bonus">· ${ escapeHtml ( bonus ) } </span>`
416+ const badgeHtml = badge
417+ ? ` <span class="wt-contents-badge"> ${ escapeHtml ( badge ) } </span>\n `
364418 : ''
365419 return (
366420 ` <div class="wt-contents-row">\n` +
367- ` <a class="wt-contents-title" href="${ href } ">${ escapeHtml ( title ) } </a>\n` +
421+ ` <div class="wt-contents-main">\n` +
422+ ` <a class="wt-contents-title" href="${ href } ">${ escapeHtml ( title ) } </a>\n` +
368423 ( description
369- ? ` <p class="wt-contents-summary">${ escapeHtml ( description ) } ${ bonusHtml } </p>\n`
424+ ? ` <p class="wt-contents-summary">${ escapeHtml ( description ) } </p>\n`
370425 : '' ) +
426+ ` </div>\n` +
427+ badgeHtml +
371428 ` </div>`
372429 )
373430 }
@@ -378,8 +435,8 @@ async function rewriteIndexContents(
378435 const title = partTitles . get ( id ) ?? `Part ${ id } `
379436 const description = partObjectives . get ( id ) ?? ''
380437 const count = partCounts . get ( id )
381- const bonus = count !== undefined ? `${ count } sections` : undefined
382- rows . push ( renderRow ( `/${ filename } .html` , title , description , bonus ) )
438+ const badge = count !== undefined ? `${ count } sections` : undefined
439+ rows . push ( renderRow ( `/${ filename } .html` , title , description , badge ) )
383440 }
384441 for ( const d of docs . values ( ) ) {
385442 rows . push ( renderRow ( `/${ d . filename } .html` , d . title , d . summary ?? '' ) )
@@ -923,7 +980,7 @@ async function generate(
923980 partFilenames ,
924981 configPath ? path . resolve ( configPath ) : '<config>' ,
925982 )
926- await renderDocs ( docFilenames , slug , repoRoot , tourDir )
983+ await renderDocs ( docFilenames , slug , partFilenames , repoRoot , tourDir )
927984 // Extend index.html with a Topics section pointing at each doc. Runs
928985 // after docs are rendered (no ordering dependency — the section just
929986 // links by filename) and before post-process so any hrefs get the
0 commit comments