@erclx/canon 4.108.0 → 4.110.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/teach/nav.ts CHANGED
@@ -1,7 +1,10 @@
1
1
  import { existsSync } from 'node:fs'
2
2
  import { readFile, writeFile } from 'node:fs/promises'
3
3
  import { join, relative } from 'node:path'
4
- import { TEACH_STYLESHEET_COMPONENTS } from '@/design/components'
4
+ import {
5
+ TEACH_SIDEBAR_BREAKPOINT,
6
+ TEACH_STYLESHEET_COMPONENTS,
7
+ } from '@/design/components'
5
8
  import { buildDesignCss } from '@/design/css'
6
9
  import { parseFrontmatter, readField } from '@/indexes/frontmatter'
7
10
  import { TEACH_FONT_FACES } from '@/teach/fonts'
@@ -22,8 +25,15 @@ import {
22
25
 
23
26
  const FAVICON = `<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='10 10 80 80'%3E%3Cpath d='M34,20 L15,28 L15,72 L34,80 Z M66,20 L85,28 L85,72 L66,80 Z' fill='rgb(224,114,75)' /%3E%3Crect x='44' y='15' width='12' height='70' rx='2' fill='rgb(224,114,75)' /%3E%3C/svg%3E" />`
24
27
 
28
+ /**
29
+ * Narrower and steeper than the mark it replaces, with round caps and joins.
30
+ * The old one painted a 5.4 by 2.7 pixel butt-capped dash whose only claim to
31
+ * pointing was the mitre at its vertex, and the path is shifted so its ink
32
+ * centres in its own viewBox rather than sitting a pixel below the cap band of
33
+ * the label beside it.
34
+ */
25
35
  const CARET =
26
- '<svg class="caret" width="9" height="9" viewBox="0 0 10 10" fill="none" stroke="currentColor" stroke-width="1.6" aria-hidden="true"><path d="M2 4l3 3 3-3"/></svg>'
36
+ '<svg class="caret" width="10" height="10" viewBox="0 0 10 10" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M2.6 3.7l2.4 2.6 2.4-2.6"/></svg>'
27
37
 
28
38
  const THEME_BUTTON =
29
39
  '<button class="theme" type="button" aria-label="Switch between light and dark"><svg class="sun" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M2 12h2M20 12h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4"/></svg><svg class="moon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z"/></svg></button>'
@@ -33,23 +43,68 @@ const THEME_SCRIPT =
33
43
 
34
44
  const CLOSE_OUTSIDE_CLICK_SCRIPT = `<script>
35
45
  (function () {
46
+ var MENUS = "details.jump[open], details.sb-ws[open]";
47
+
36
48
  function close(except) {
37
- document.querySelectorAll("details.jump[open]").forEach(function (d) {
49
+ document.querySelectorAll(MENUS).forEach(function (d) {
38
50
  if (d !== except) d.open = false;
39
51
  });
40
52
  }
41
53
  document.addEventListener("click", function (e) {
42
- var inside = e.target.closest("details.jump");
54
+ var inside = e.target.closest("details.jump, details.sb-ws");
43
55
  close(inside);
44
56
  });
45
57
  document.addEventListener("keydown", function (e) {
46
58
  if (e.key !== "Escape") return;
47
- var open = document.querySelector("details.jump[open]");
59
+ var open = document.querySelector(MENUS);
48
60
  if (!open) return;
49
61
  open.open = false;
50
62
  var s = open.querySelector("summary");
51
63
  if (s) s.focus();
52
64
  });
65
+
66
+ /* Hover intent on the breadcrumb only. The sidebar's workspace switcher stays
67
+ click-only on purpose: its panel opens directly over the lesson list, which
68
+ is where the pointer is headed, so hovering it would cover the thing
69
+ being reached for. The breadcrumb's panel drops over body text instead. */
70
+ var OPEN = 120, SHUT = 260;
71
+
72
+ document.querySelectorAll("details.jump").forEach(function (d) {
73
+ var host = d.closest(".crumb-item") || d;
74
+ var timer = null;
75
+ var openedByHover = false;
76
+
77
+ function arm(want) {
78
+ clearTimeout(timer);
79
+ timer = setTimeout(function () {
80
+ if (want) close(d);
81
+ d.open = want;
82
+ }, want ? OPEN : SHUT);
83
+ }
84
+
85
+ host.addEventListener("mouseenter", function () {
86
+ if (!d.open) openedByHover = true;
87
+ arm(true);
88
+ });
89
+ host.addEventListener("mouseleave", function () {
90
+ openedByHover = false;
91
+ arm(false);
92
+ });
93
+ /* Keeps it open while the pointer is inside the panel, without cancelling a
94
+ pending open, which is what silently disabled this on one of two menus. */
95
+ d.addEventListener("mouseenter", function () { if (d.open) clearTimeout(timer); });
96
+
97
+ /* Hover and click were wired to one disclosure and fought: hover opened the
98
+ panel, then the summary's native click toggled what hover had already
99
+ opened, so reaching for an item shut it. A click on a panel hover opened
100
+ keeps it open, and a second click closes. The click path stays live for
101
+ touch, where no hover exists, and for the keyboard, where Enter is the
102
+ only way in. */
103
+ var summary = d.querySelector("summary");
104
+ if (summary) summary.addEventListener("click", function (e) {
105
+ if (d.open && openedByHover) { e.preventDefault(); openedByHover = false; }
106
+ });
107
+ });
53
108
  })();
54
109
  </script>`
55
110
 
@@ -178,47 +233,213 @@ export function focusLine(
178
233
  return compiled(scrollY, max, innerHeight)
179
234
  }
180
235
 
181
- const OUTLINE_SCRIPT = `<script>
236
+ /**
237
+ * The panel's state, settled before the first paint rather than after it. An
238
+ * arm that set the narrow default afterwards slid the panel in and back out on
239
+ * every load, and a restored custom width animated in from the default.
240
+ *
241
+ * A listing page starts shut because the body already lists what the panel
242
+ * would, and a lesson starts open because there the panel is the only
243
+ * cross-lesson navigation on the page. A stored preference beats both. A count
244
+ * threshold on its own was built and reverted, since it hides the panel on a
245
+ * lesson too.
246
+ */
247
+ function headScript(page: 'index' | 'lesson'): string {
248
+ return `<script>(function(){var r=document.documentElement,s=null;
249
+ r.dataset.page="${page}";
250
+ if(matchMedia("(max-width: ${TEACH_SIDEBAR_BREAKPOINT}px)").matches){r.classList.add("sb-shut");return}
251
+ try{s=localStorage.getItem("teach-sb")}catch(e){}
252
+ var idx=r.dataset.page==="index";
253
+ if(s==="shut"||(s===null&&idx))r.classList.add("sb-shut");
254
+ var w=null;try{w=localStorage.getItem("teach-sb-w")}catch(e){}
255
+ if(w)r.style.setProperty("--sb-w",w+"px")})();</script>`
256
+ }
257
+
258
+ const SIDEBAR_SCRIPT = `<script>
182
259
  (function () {
183
- var hs = Array.prototype.slice.call(document.querySelectorAll("main h2"));
184
- if (hs.length < 3) return;
185
- var nav = document.createElement("nav");
186
- nav.className = "outline";
187
- nav.innerHTML = '<button class="to-top" type="button">On this page</button>';
188
- nav.querySelector(".to-top").addEventListener("click", function () {
189
- scrollTo({ top: 0, behavior: "smooth" });
260
+ var root = document.documentElement;
261
+ var panel = document.querySelector(".sb");
262
+ if (!panel) return;
263
+
264
+ var fold = document.querySelector(".sb-fold");
265
+ var narrow = matchMedia("(max-width: ${TEACH_SIDEBAR_BREAKPOINT}px)");
266
+
267
+ var FOCUSABLE = "a[href], button:not([disabled]), summary, input, [tabindex]:not([tabindex='-1'])";
268
+
269
+ function panelStops() {
270
+ return Array.prototype.slice.call(panel.querySelectorAll(FOCUSABLE))
271
+ .filter(function (el) { return el.offsetParent !== null || el === document.activeElement; });
272
+ }
273
+
274
+ /* The focus return belongs here rather than on the close control, because all
275
+ three routes out land here and only one of them used to move focus. The
276
+ shut panel takes visibility: hidden 180ms later, so a reader who pressed
277
+ Escape kept focus on a control that then disappeared under them and the
278
+ browser dropped them at the top of the document. */
279
+ function shut() {
280
+ var inside = panel.contains(document.activeElement);
281
+ root.classList.add("sb-shut");
282
+ try { localStorage.setItem("teach-sb", "shut"); } catch (e) {}
283
+ if (inside && fold) fold.focus();
284
+ }
285
+
286
+ if (fold) fold.addEventListener("click", function () {
287
+ root.classList.toggle("sb-shut");
288
+ try {
289
+ localStorage.setItem("teach-sb", root.classList.contains("sb-shut") ? "shut" : "open");
290
+ } catch (e) {}
291
+ /* An overlay takes focus with it. Without this a keyboard reader opens the
292
+ panel and goes on tabbing through the lesson behind the scrim. */
293
+ if (!narrow.matches || root.classList.contains("sb-shut")) return;
294
+ var first = panel.querySelector("a, button, summary, input");
295
+ if (first) first.focus();
190
296
  });
191
- hs.forEach(function (h, i) {
192
- if (!h.id) h.id = "s" + i;
193
- var a = document.createElement("a");
194
- a.href = "#" + h.id;
195
- a.textContent = h.textContent.trim();
196
- nav.appendChild(a);
297
+
298
+ var scrim = document.createElement("div");
299
+ scrim.className = "sb-scrim";
300
+ document.body.appendChild(scrim);
301
+ scrim.addEventListener("click", shut);
302
+
303
+ document.addEventListener("keydown", function (e) {
304
+ if (e.key === "Escape" && narrow.matches) { shut(); return; }
305
+
306
+ /* The scrim says the lesson is unavailable, so Tab must not reach it.
307
+ Moving focus into the panel on open only defers this by however many
308
+ controls the panel holds. */
309
+ if (e.key !== "Tab") return;
310
+ if (!narrow.matches || root.classList.contains("sb-shut")) return;
311
+
312
+ var stops = panelStops();
313
+ if (!stops.length) return;
314
+
315
+ var first = stops[0];
316
+ var last = stops[stops.length - 1];
317
+
318
+ if (!panel.contains(document.activeElement)) {
319
+ (e.shiftKey ? last : first).focus();
320
+ e.preventDefault();
321
+ } else if (e.shiftKey && document.activeElement === first) {
322
+ last.focus();
323
+ e.preventDefault();
324
+ } else if (!e.shiftKey && document.activeElement === last) {
325
+ first.focus();
326
+ e.preventDefault();
327
+ }
197
328
  });
198
- document.body.appendChild(nav);
199
- var links = Array.prototype.slice.call(nav.querySelectorAll("a"));
200
329
 
201
- function mark(i) {
202
- links.forEach(function (l, j) { l.classList.toggle("on", j === i); });
330
+ /* The panel covers the masthead and the toggle that opened it, so it carries
331
+ its own way out rather than leaving the scrim as the only route. */
332
+ var close = document.createElement("button");
333
+ close.type = "button";
334
+ close.className = "sb-close";
335
+ close.setAttribute("aria-label", "Close the course panel");
336
+ close.textContent = "\\u00d7";
337
+ close.addEventListener("click", shut);
338
+ panel.appendChild(close);
339
+
340
+ var MIN = 208, MAX = 296, DEF = 256;
341
+ var grip = document.querySelector(".sb-grip");
342
+ var lastWidth = parseInt(root.style.getPropertyValue("--sb-w"), 10) || DEF;
343
+
344
+ function setWidth(px, save) {
345
+ var w = Math.max(MIN, Math.min(MAX, Math.round(px)));
346
+ root.style.setProperty("--sb-w", w + "px");
347
+ lastWidth = w;
348
+ if (save) { try { localStorage.setItem("teach-sb-w", String(w)); } catch (e) {} }
203
349
  }
204
350
 
351
+ if (grip) {
352
+ var dragging = false;
353
+ grip.addEventListener("pointerdown", function (e) {
354
+ dragging = true;
355
+ grip.setPointerCapture(e.pointerId);
356
+ root.classList.add("sb-drag");
357
+ e.preventDefault();
358
+ });
359
+ grip.addEventListener("pointermove", function (e) {
360
+ if (dragging) setWidth(e.clientX, false);
361
+ });
362
+ grip.addEventListener("pointerup", function (e) {
363
+ if (!dragging) return;
364
+ dragging = false;
365
+ root.classList.remove("sb-drag");
366
+ grip.releasePointerCapture(e.pointerId);
367
+ setWidth(lastWidth, true);
368
+ });
369
+ grip.addEventListener("dblclick", function () { setWidth(DEF, true); });
370
+ grip.addEventListener("keydown", function (e) {
371
+ var step = e.shiftKey ? 32 : 8;
372
+ if (e.key === "ArrowLeft") { setWidth(lastWidth - step, true); e.preventDefault(); }
373
+ else if (e.key === "ArrowRight") { setWidth(lastWidth + step, true); e.preventDefault(); }
374
+ else if (e.key === "Home") { setWidth(DEF, true); e.preventDefault(); }
375
+ });
376
+ }
377
+
378
+ var filter = document.querySelector(".sb-filter input");
379
+ if (filter) filter.addEventListener("input", function () {
380
+ var q = filter.value.trim().toLowerCase();
381
+ document.querySelectorAll(".sb-list > li").forEach(function (li) {
382
+ li.classList.toggle("hide", q !== "" && li.textContent.toLowerCase().indexOf(q) === -1);
383
+ });
384
+ });
385
+
386
+ var slot = document.querySelector(".sb-out-slot");
387
+ var hs = Array.prototype.slice.call(document.querySelectorAll("main h2"));
388
+ var links = [];
389
+
390
+ if (slot && hs.length) {
391
+ var list = document.createElement("ul");
392
+ list.className = "sb-out";
393
+ hs.forEach(function (h, i) {
394
+ if (!h.id) h.id = "s" + i;
395
+ var li = document.createElement("li");
396
+ var a = document.createElement("a");
397
+ a.href = "#" + h.id;
398
+ a.textContent = h.textContent.trim();
399
+ li.appendChild(a);
400
+ list.appendChild(li);
401
+ });
402
+ slot.parentNode.replaceChild(list, slot);
403
+ links = Array.prototype.slice.call(list.querySelectorAll("a"));
404
+ }
405
+
406
+ /* The bar reports position inside the lesson, which nothing reported before
407
+ the segmented track retired. The breadcrumb's last segment carries the
408
+ lesson title once the real one has scrolled off. */
409
+ var bar = document.querySelector(".bar");
410
+ var here = document.querySelector(".crumb-here");
411
+ var h1 = document.querySelector("main h1");
412
+ var counter = here ? here.textContent.trim() : "";
413
+ var title = h1 ? h1.textContent.trim() : "";
414
+
205
415
  function focusLine() {
206
416
  var max = document.documentElement.scrollHeight - innerHeight;
207
417
  ${FOCUS_LINE_BODY}
208
418
  }
209
419
 
210
420
  function sync() {
211
- var best = 0;
212
- var line = focusLine();
213
- for (var i = 0; i < hs.length; i++) {
214
- if (hs[i].getBoundingClientRect().top <= line) best = i;
421
+ if (bar) {
422
+ var max = document.documentElement.scrollHeight - innerHeight;
423
+ var pct = max > 0 ? Math.min(100, Math.max(0, (scrollY / max) * 100)) : 0;
424
+ bar.style.setProperty("--read", pct.toFixed(1) + "%");
215
425
  }
216
- mark(best);
217
- }
218
426
 
219
- links.forEach(function (l, i) {
220
- l.addEventListener("click", function () { mark(i); });
221
- });
427
+ if (here && h1 && title && counter) {
428
+ var want = h1.getBoundingClientRect().bottom < 56 ? title : counter;
429
+ if (here.textContent !== want) here.textContent = want;
430
+ }
431
+
432
+ if (links.length) {
433
+ /* Nothing is marked until a heading has actually passed the line, so the
434
+ first section is not reported as current while the title is on screen. */
435
+ var best = -1;
436
+ var line = focusLine();
437
+ for (var i = 0; i < hs.length; i++) {
438
+ if (hs[i].getBoundingClientRect().top <= line) best = i;
439
+ }
440
+ links.forEach(function (l, j) { l.classList.toggle("on", j === best); });
441
+ }
442
+ }
222
443
 
223
444
  addEventListener("scroll", sync, { passive: true });
224
445
  addEventListener("resize", sync);
@@ -254,15 +475,23 @@ interface JumpEntry {
254
475
  readonly ordinal: string
255
476
  readonly label: string
256
477
  readonly href: string | undefined
257
- readonly done: boolean
258
478
  readonly at: boolean
479
+ /**
480
+ * The row's third column. The workspace menu puts a lesson count here and the
481
+ * lesson menu leaves it empty, which is what retired the status dot that used
482
+ * to sit there reading the same value on every row.
483
+ */
484
+ readonly trailing?: string
259
485
  }
260
486
 
261
487
  function renderJumpList(entries: readonly JumpEntry[]): string {
262
488
  return entries
263
489
  .map((entry) => {
264
- const dot = `<span class="dot${entry.done ? ' done' : ''}"></span>`
265
- const body = `<span class="n">${entry.ordinal}</span><span>${escapeHtml(entry.label)}</span>${dot}`
490
+ const trailing =
491
+ entry.trailing === undefined
492
+ ? ''
493
+ : `<span class="ct">${escapeHtml(entry.trailing)}</span>`
494
+ const body = `<span class="n">${entry.ordinal}</span><span>${escapeHtml(entry.label)}</span>${trailing}`
266
495
 
267
496
  if (entry.href === undefined) {
268
497
  return `<li class="soon"><a href="#" aria-disabled="true" tabindex="-1">${body}</a></li>`
@@ -312,26 +541,91 @@ function renderBreadcrumb(segments: readonly CrumbSegment[]): string {
312
541
  .join('')
313
542
  }
314
543
 
315
- type TrackState = 'done' | 'here' | ''
544
+ interface SidebarItem {
545
+ readonly ordinal: string
546
+ readonly label: string
547
+ readonly href: string
548
+ readonly at: boolean
549
+ }
316
550
 
317
- function renderTrack(states: readonly TrackState[]): string {
318
- const dots = states
319
- .map((state) => `<i${state ? ` class="${state}"` : ''}></i>`)
551
+ interface Sidebar {
552
+ /** The switcher's own label: the workspace on a page inside one, else the root. */
553
+ readonly heading: string
554
+ readonly switcher: readonly JumpEntry[]
555
+ readonly meta: string
556
+ readonly items: readonly SidebarItem[]
557
+ readonly foot: string | undefined
558
+ }
559
+
560
+ /**
561
+ * Past this many rows a reader scans rather than reads, which is where a filter
562
+ * starts earning the width it takes from the list.
563
+ */
564
+ const FILTER_FLOOR = 8
565
+
566
+ /**
567
+ * The course, as a column. It replaces a segmented progress strip that gave
568
+ * each lesson one `flex: 1` segment and rendered fifty of them as a row of
569
+ * dots, and the right-hand outline rail that only existed above 1420px. The
570
+ * outline folds under the lesson being read, so one column reports position in
571
+ * the course and position inside the lesson at every window width.
572
+ */
573
+ function renderSidebar(sidebar: Sidebar): string {
574
+ const filter =
575
+ sidebar.items.length > FILTER_FLOOR
576
+ ? '<div class="sb-filter"><input type="search" placeholder="Filter lessons" aria-label="Filter lessons"></div>'
577
+ : ''
578
+
579
+ const rows = sidebar.items
580
+ .map((item) => {
581
+ const slot = item.at ? '<div class="sb-out-slot"></div>' : ''
582
+ return `<li><a class="sb-l${item.at ? ' sb-on' : ''}" href="${item.href}"><span class="sb-n">${item.ordinal}</span>${escapeHtml(item.label)}</a>${slot}</li>`
583
+ })
320
584
  .join('')
321
585
 
322
- return `<div class="track" role="presentation">${dots}</div>`
586
+ const body = rows
587
+ ? `<ol class="sb-list">${rows}</ol>`
588
+ : '<p class="sb-empty">No lessons yet.</p>'
589
+
590
+ const foot =
591
+ sidebar.foot === undefined
592
+ ? ''
593
+ : `<div class="sb-foot">${sidebar.foot}</div>`
594
+
595
+ return `<aside class="sb">
596
+ <div class="sb-top">
597
+ <details class="sb-ws" name="mast"><summary><span class="ws-name">${escapeHtml(sidebar.heading)}</span><span class="car">${CARET}</span></summary><div class="sb-wl"><ul class="jump-list">${renderJumpList(sidebar.switcher)}</ul></div></details>
598
+ </div>
599
+ <div class="sb-scroll">
600
+ <div class="sb-meta">${escapeHtml(sidebar.meta)}</div>
601
+ ${filter}
602
+ <nav class="sb-nav">${body}</nav>
603
+ </div>
604
+ ${foot}
605
+ <button class="sb-grip" type="button" role="separator" aria-orientation="vertical" aria-label="Resize the course panel"></button>
606
+ </aside>`
323
607
  }
324
608
 
609
+ /**
610
+ * Everything but the sidebar sits inside one pane, so the two lay out as a flex
611
+ * row and the sticky sidebar's containing block is the flex container rather
612
+ * than a body carrying bottom padding it could never travel into. The opening
613
+ * tag rides with the header and `PANE_CLOSE` shuts it after the footer
614
+ * navigation, which on a lesson is a separate spliced region.
615
+ */
616
+ const PANE_CLOSE = '</div>'
617
+
325
618
  function renderHeader(
326
619
  segments: readonly CrumbSegment[],
327
- track: readonly TrackState[],
620
+ sidebar: Sidebar,
328
621
  ): string {
329
- return `<header class="bar">
622
+ return `${renderSidebar(sidebar)}
623
+ <div class="pane">
624
+ <header class="bar">
330
625
  <div class="mast">
331
- <span class="mast-left">${renderBreadcrumb(segments)}</span>
626
+ <span class="mast-left"><button class="sb-fold" type="button" aria-label="Toggle the course panel">&#9776;</button>${renderBreadcrumb(segments)}</span>
332
627
  <span class="mast-right">${THEME_BUTTON}</span>
333
628
  </div>
334
- ${renderTrack(track)}
335
629
  </header>`
336
630
  }
337
631
 
@@ -339,7 +633,7 @@ function renderScripts(
339
633
  includeQuiz: boolean,
340
634
  includeGlossaryFilter: boolean,
341
635
  ): string {
342
- const scripts = [THEME_SCRIPT, CLOSE_OUTSIDE_CLICK_SCRIPT, OUTLINE_SCRIPT]
636
+ const scripts = [THEME_SCRIPT, CLOSE_OUTSIDE_CLICK_SCRIPT, SIDEBAR_SCRIPT]
343
637
  if (includeGlossaryFilter) scripts.push(GLOSSARY_FILTER_SCRIPT)
344
638
  if (includeQuiz) scripts.push(QUIZ_SCRIPT)
345
639
  return scripts.join('\n')
@@ -363,6 +657,7 @@ function pageHead(
363
657
  <title>${escapeHtml(title)}</title>
364
658
  ${FAVICON}
365
659
  ${style}
660
+ ${headScript('index')}
366
661
  </head>
367
662
  `
368
663
  }
@@ -396,11 +691,29 @@ function workspaceJumpEntries(
396
691
  ordinal: ordinalOf(workspace),
397
692
  label: titleCase(workspace.topic),
398
693
  href: hrefForWorkspace(workspace, prefix),
399
- done: workspace.lessons > 0,
400
694
  at: workspace.slug === currentSlug,
695
+ trailing: String(workspace.lessons),
401
696
  }))
402
697
  }
403
698
 
699
+ /**
700
+ * What the workspace holds beyond its lessons, for the foot of the sidebar.
701
+ * Undefined where it holds neither, so the panel closes on the list rather than
702
+ * on an empty rule.
703
+ */
704
+ function workspaceFoot(detail: WorkspaceDetail): string | undefined {
705
+ const bits: string[] = []
706
+
707
+ if (detail.terms > 0) {
708
+ bits.push(`${detail.terms} term${detail.terms === 1 ? '' : 's'}`)
709
+ }
710
+ if (detail.records > 0) {
711
+ bits.push(`${detail.records} session${detail.records === 1 ? '' : 's'}`)
712
+ }
713
+
714
+ return bits.length > 0 ? bits.join(' &middot; ') : undefined
715
+ }
716
+
404
717
  interface LessonMeta {
405
718
  readonly file: string
406
719
  readonly title: string
@@ -436,7 +749,6 @@ function lessonJumpEntries(
436
749
  ordinal: String(index + 1).padStart(2, '0'),
437
750
  label: meta.title,
438
751
  href: `${workspacePrefix}${TEACH_LESSONS}/${meta.file}`,
439
- done: true,
440
752
  at: meta.file === currentFile,
441
753
  }))
442
754
  }
@@ -576,9 +888,18 @@ function renderRootPage(workspaces: readonly WorkspaceSummary[]): string {
576
888
  },
577
889
  ]
578
890
 
579
- const track = workspaces.map<TrackState>((workspace) =>
580
- workspace.lessons > 0 ? 'done' : '',
581
- )
891
+ const sidebar: Sidebar = {
892
+ heading: 'Workspaces',
893
+ switcher: workspaceJumpEntries(workspaces, '', undefined),
894
+ meta: `${workspaces.length} workspace${workspaces.length === 1 ? '' : 's'}`,
895
+ items: workspaces.map((workspace) => ({
896
+ ordinal: ordinalOf(workspace),
897
+ label: titleCase(workspace.topic),
898
+ href: hrefForWorkspace(workspace, '') ?? '#',
899
+ at: false,
900
+ })),
901
+ foot: undefined,
902
+ }
582
903
 
583
904
  const rows = workspaces
584
905
  .map((workspace) => {
@@ -598,7 +919,7 @@ function renderRootPage(workspaces: readonly WorkspaceSummary[]): string {
598
919
  .join('')
599
920
 
600
921
  return `${pageHead('Learning workspaces', TEACH_STYLESHEET, undefined)}<body>
601
- ${renderHeader(segments, track)}
922
+ ${renderHeader(segments, sidebar)}
602
923
  <main class="wide-body">
603
924
 
604
925
 
@@ -608,6 +929,7 @@ ${renderHeader(segments, track)}
608
929
  <ul class="toc">${rows}</ul>
609
930
 
610
931
  </main>
932
+ ${PANE_CLOSE}
611
933
  ${renderScripts(false, false)}
612
934
  </body>
613
935
  </html>
@@ -671,7 +993,18 @@ async function renderContentsPage(
671
993
  },
672
994
  ]
673
995
 
674
- const track = metas.map<TrackState>(() => 'done')
996
+ const sidebar: Sidebar = {
997
+ heading: titleCase(detail.topic),
998
+ switcher: workspaceJumpEntries(workspaces, '../', detail.slug),
999
+ meta: `${metas.length} lesson${metas.length === 1 ? '' : 's'}`,
1000
+ items: metas.map((meta, index) => ({
1001
+ ordinal: String(index + 1).padStart(2, '0'),
1002
+ label: meta.title,
1003
+ href: `${TEACH_LESSONS}/${meta.file}`,
1004
+ at: false,
1005
+ })),
1006
+ foot: workspaceFoot(detail),
1007
+ }
675
1008
 
676
1009
  const lessonRows = metas
677
1010
  .map(
@@ -711,7 +1044,7 @@ async function renderContentsPage(
711
1044
  .join('\n\n')
712
1045
 
713
1046
  return `${pageHead(`${title}, contents`, `${TEACH_ASSETS}/${TEACH_STYLESHEET}`, undefined)}<body>
714
- ${renderHeader(segments, track)}
1047
+ ${renderHeader(segments, sidebar)}
715
1048
  <main class="wide-body">
716
1049
 
717
1050
 
@@ -719,6 +1052,7 @@ ${renderHeader(segments, track)}
719
1052
  ${sections}
720
1053
 
721
1054
  </main>
1055
+ ${PANE_CLOSE}
722
1056
  ${renderScripts(false, true)}
723
1057
  </body>
724
1058
  </html>
@@ -814,7 +1148,18 @@ async function rewriteLesson(
814
1148
  },
815
1149
  { label: `Lesson ${index + 1} of ${metas.length}` },
816
1150
  ],
817
- metas.map<TrackState>((_, i) => (i === index ? 'here' : 'done')),
1151
+ {
1152
+ heading: titleCase(detail.topic),
1153
+ switcher: workspaceJumpEntries(workspaces, teachPrefix, detail.slug),
1154
+ meta: `${metas.length} lesson${metas.length === 1 ? '' : 's'}`,
1155
+ items: metas.map((meta, i) => ({
1156
+ ordinal: String(i + 1).padStart(2, '0'),
1157
+ label: meta.title,
1158
+ href: meta.file,
1159
+ at: i === index,
1160
+ })),
1161
+ foot: workspaceFoot(detail),
1162
+ },
818
1163
  )
819
1164
 
820
1165
  // One detector decides both mechanisms, because a lesson carries one quiz
@@ -827,9 +1172,14 @@ async function rewriteLesson(
827
1172
  const regions: ReadonlyArray<readonly [Region, string]> = [
828
1173
  // The stepper follows the workspace stylesheet so it wins the cascade at
829
1174
  // equal specificity, which is what reaches a workspace seeded before it.
830
- ['style', `<style>\n${legacy ? css : `${css}\n${QUIZ_CSS}`}\n</style>`],
1175
+ // The head script settles the panel before first paint, so it rides in the
1176
+ // one region that sits inside `<head>`.
1177
+ [
1178
+ 'style',
1179
+ `<style>\n${legacy ? css : `${css}\n${QUIZ_CSS}`}\n</style>\n${headScript('lesson')}`,
1180
+ ],
831
1181
  ['header', header],
832
- ['footnav', renderFootNav(metas, index)],
1182
+ ['footnav', `${renderFootNav(metas, index)}\n${PANE_CLOSE}`],
833
1183
  ['scripts', renderScripts(legacy, false)],
834
1184
  ]
835
1185
 
@@ -63,6 +63,7 @@ The opening of a root README is a block of up to six elements in a fixed order.
63
63
  The description of 2-3 sentences follows the block and expands the claim rather than repeating it.
64
64
 
65
65
  - A project with no page omits the link and the screenshot. A library or a CLI reads as a complete block with a title, badges, and a claim.
66
+ - A project whose own page carries the screenshot omits it here and keeps the live link, which answers the slot rather than skipping it. Say so in the pull request that drops it.
66
67
  - The order is the block's rather than the page's. The screenshot sits last inside the block, which puts it in the first row of content a reader meets below the title, badges, and claim.
67
68
  - A themed light and dark pair counts as one element in the slot rather than two. The block does not require a pair and leaves the handling of themes to the project.
68
69
  - Centering the block in an HTML wrapper is optional. Plain markdown reaches every host a README renders on, and a wrapper is styling a project can add.