@erclx/canon 4.109.0 → 4.111.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.
Files changed (33) hide show
  1. package/claude/.claude-plugin/plugin.json +1 -1
  2. package/claude/skills/{canon-feedback-file → canon-feedback}/REQUIREMENT.md +7 -6
  3. package/claude/skills/{canon-feedback-file → canon-feedback}/SKILL.md +9 -19
  4. package/claude/skills/canon-feedback-triage/REQUIREMENT.md +1 -1
  5. package/claude/skills/canon-feedback-triage/SKILL.md +1 -1
  6. package/claude/skills/canon-operator/REQUIREMENT.md +2 -2
  7. package/claude/skills/canon-operator/SKILL.md +3 -4
  8. package/claude/skills/draft-wiki/REQUIREMENT.md +42 -0
  9. package/claude/skills/draft-wiki/SKILL.md +83 -0
  10. package/claude/skills/git-issue/REQUIREMENT.md +1 -1
  11. package/claude/skills/git-issue/SKILL.md +1 -1
  12. package/claude/skills/target-check/references/domains.md +2 -2
  13. package/docs/agents/install-and-sync.md +4 -13
  14. package/docs/agents/teach.md +4 -2
  15. package/docs/target-projects.md +0 -2
  16. package/docs/workflow/ai-workflow.md +8 -11
  17. package/package.json +1 -1
  18. package/scripts/core/capture-home.sh +59 -0
  19. package/src/claude/cases/authoring.ts +5 -0
  20. package/src/claude/cases/setup.ts +3 -24
  21. package/src/commands/sync.ts +0 -16
  22. package/src/design/components.ts +459 -147
  23. package/src/shipped/references.ts +1 -2
  24. package/src/sync/reverse.ts +3 -74
  25. package/src/teach/nav.ts +410 -58
  26. package/claude/skills/migration-claude-md/REQUIREMENT.md +0 -40
  27. package/claude/skills/migration-claude-md/SKILL.md +0 -79
  28. package/claude/skills/migration-context/REQUIREMENT.md +0 -36
  29. package/claude/skills/migration-context/SKILL.md +0 -97
  30. package/claude/skills/migration-standards-drop/REQUIREMENT.md +0 -55
  31. package/claude/skills/migration-standards-drop/SKILL.md +0 -113
  32. package/claude/skills/migration-superseded/REQUIREMENT.md +0 -44
  33. package/claude/skills/migration-superseded/SKILL.md +0 -115
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>`
@@ -288,14 +517,16 @@ interface CrumbSegment {
288
517
 
289
518
  /**
290
519
  * Every ancestor renders as a link, and the current page renders as plain
291
- * text, whether or not it still carries a jump widget of its own.
520
+ * text, whether or not it still carries a jump widget of its own. A link's
521
+ * label sits in its own span because `text-box` is ignored on the
522
+ * `inline-flex` link, so the cap trim needs a plain box to land on.
292
523
  */
293
524
  function renderBreadcrumb(segments: readonly CrumbSegment[]): string {
294
525
  return segments
295
526
  .map((segment, index) => {
296
527
  const text =
297
528
  segment.href !== undefined
298
- ? `<a class="crumb" href="${segment.href}">${escapeHtml(segment.label)}</a>`
529
+ ? `<a class="crumb" href="${segment.href}"><span class="crumb-t">${escapeHtml(segment.label)}</span></a>`
299
530
  : `<span class="crumb crumb-here">${escapeHtml(segment.label)}</span>`
300
531
 
301
532
  const jump = segment.jump
@@ -312,26 +543,91 @@ function renderBreadcrumb(segments: readonly CrumbSegment[]): string {
312
543
  .join('')
313
544
  }
314
545
 
315
- type TrackState = 'done' | 'here' | ''
546
+ interface SidebarItem {
547
+ readonly ordinal: string
548
+ readonly label: string
549
+ readonly href: string
550
+ readonly at: boolean
551
+ }
316
552
 
317
- function renderTrack(states: readonly TrackState[]): string {
318
- const dots = states
319
- .map((state) => `<i${state ? ` class="${state}"` : ''}></i>`)
553
+ interface Sidebar {
554
+ /** The switcher's own label: the workspace on a page inside one, else the root. */
555
+ readonly heading: string
556
+ readonly switcher: readonly JumpEntry[]
557
+ readonly meta: string
558
+ readonly items: readonly SidebarItem[]
559
+ readonly foot: string | undefined
560
+ }
561
+
562
+ /**
563
+ * Past this many rows a reader scans rather than reads, which is where a filter
564
+ * starts earning the width it takes from the list.
565
+ */
566
+ const FILTER_FLOOR = 8
567
+
568
+ /**
569
+ * The course, as a column. It replaces a segmented progress strip that gave
570
+ * each lesson one `flex: 1` segment and rendered fifty of them as a row of
571
+ * dots, and the right-hand outline rail that only existed above 1420px. The
572
+ * outline folds under the lesson being read, so one column reports position in
573
+ * the course and position inside the lesson at every window width.
574
+ */
575
+ function renderSidebar(sidebar: Sidebar): string {
576
+ const filter =
577
+ sidebar.items.length > FILTER_FLOOR
578
+ ? '<div class="sb-filter"><input type="search" placeholder="Filter lessons" aria-label="Filter lessons"></div>'
579
+ : ''
580
+
581
+ const rows = sidebar.items
582
+ .map((item) => {
583
+ const slot = item.at ? '<div class="sb-out-slot"></div>' : ''
584
+ 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>`
585
+ })
320
586
  .join('')
321
587
 
322
- return `<div class="track" role="presentation">${dots}</div>`
588
+ const body = rows
589
+ ? `<ol class="sb-list">${rows}</ol>`
590
+ : '<p class="sb-empty">No lessons yet.</p>'
591
+
592
+ const foot =
593
+ sidebar.foot === undefined
594
+ ? ''
595
+ : `<div class="sb-foot">${sidebar.foot}</div>`
596
+
597
+ return `<aside class="sb">
598
+ <div class="sb-top">
599
+ <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>
600
+ </div>
601
+ <div class="sb-scroll">
602
+ <div class="sb-meta">${escapeHtml(sidebar.meta)}</div>
603
+ ${filter}
604
+ <nav class="sb-nav">${body}</nav>
605
+ </div>
606
+ ${foot}
607
+ <button class="sb-grip" type="button" role="separator" aria-orientation="vertical" aria-label="Resize the course panel"></button>
608
+ </aside>`
323
609
  }
324
610
 
611
+ /**
612
+ * Everything but the sidebar sits inside one pane, so the two lay out as a flex
613
+ * row and the sticky sidebar's containing block is the flex container rather
614
+ * than a body carrying bottom padding it could never travel into. The opening
615
+ * tag rides with the header and `PANE_CLOSE` shuts it after the footer
616
+ * navigation, which on a lesson is a separate spliced region.
617
+ */
618
+ const PANE_CLOSE = '</div>'
619
+
325
620
  function renderHeader(
326
621
  segments: readonly CrumbSegment[],
327
- track: readonly TrackState[],
622
+ sidebar: Sidebar,
328
623
  ): string {
329
- return `<header class="bar">
624
+ return `${renderSidebar(sidebar)}
625
+ <div class="pane">
626
+ <header class="bar">
330
627
  <div class="mast">
331
- <span class="mast-left">${renderBreadcrumb(segments)}</span>
628
+ <span class="mast-left"><button class="sb-fold" type="button" aria-label="Toggle the course panel">&#9776;</button>${renderBreadcrumb(segments)}</span>
332
629
  <span class="mast-right">${THEME_BUTTON}</span>
333
630
  </div>
334
- ${renderTrack(track)}
335
631
  </header>`
336
632
  }
337
633
 
@@ -339,7 +635,7 @@ function renderScripts(
339
635
  includeQuiz: boolean,
340
636
  includeGlossaryFilter: boolean,
341
637
  ): string {
342
- const scripts = [THEME_SCRIPT, CLOSE_OUTSIDE_CLICK_SCRIPT, OUTLINE_SCRIPT]
638
+ const scripts = [THEME_SCRIPT, CLOSE_OUTSIDE_CLICK_SCRIPT, SIDEBAR_SCRIPT]
343
639
  if (includeGlossaryFilter) scripts.push(GLOSSARY_FILTER_SCRIPT)
344
640
  if (includeQuiz) scripts.push(QUIZ_SCRIPT)
345
641
  return scripts.join('\n')
@@ -363,6 +659,7 @@ function pageHead(
363
659
  <title>${escapeHtml(title)}</title>
364
660
  ${FAVICON}
365
661
  ${style}
662
+ ${headScript('index')}
366
663
  </head>
367
664
  `
368
665
  }
@@ -396,11 +693,29 @@ function workspaceJumpEntries(
396
693
  ordinal: ordinalOf(workspace),
397
694
  label: titleCase(workspace.topic),
398
695
  href: hrefForWorkspace(workspace, prefix),
399
- done: workspace.lessons > 0,
400
696
  at: workspace.slug === currentSlug,
697
+ trailing: String(workspace.lessons),
401
698
  }))
402
699
  }
403
700
 
701
+ /**
702
+ * What the workspace holds beyond its lessons, for the foot of the sidebar.
703
+ * Undefined where it holds neither, so the panel closes on the list rather than
704
+ * on an empty rule.
705
+ */
706
+ function workspaceFoot(detail: WorkspaceDetail): string | undefined {
707
+ const bits: string[] = []
708
+
709
+ if (detail.terms > 0) {
710
+ bits.push(`${detail.terms} term${detail.terms === 1 ? '' : 's'}`)
711
+ }
712
+ if (detail.records > 0) {
713
+ bits.push(`${detail.records} session${detail.records === 1 ? '' : 's'}`)
714
+ }
715
+
716
+ return bits.length > 0 ? bits.join(' &middot; ') : undefined
717
+ }
718
+
404
719
  interface LessonMeta {
405
720
  readonly file: string
406
721
  readonly title: string
@@ -436,7 +751,6 @@ function lessonJumpEntries(
436
751
  ordinal: String(index + 1).padStart(2, '0'),
437
752
  label: meta.title,
438
753
  href: `${workspacePrefix}${TEACH_LESSONS}/${meta.file}`,
439
- done: true,
440
754
  at: meta.file === currentFile,
441
755
  }))
442
756
  }
@@ -576,9 +890,18 @@ function renderRootPage(workspaces: readonly WorkspaceSummary[]): string {
576
890
  },
577
891
  ]
578
892
 
579
- const track = workspaces.map<TrackState>((workspace) =>
580
- workspace.lessons > 0 ? 'done' : '',
581
- )
893
+ const sidebar: Sidebar = {
894
+ heading: 'Workspaces',
895
+ switcher: workspaceJumpEntries(workspaces, '', undefined),
896
+ meta: `${workspaces.length} workspace${workspaces.length === 1 ? '' : 's'}`,
897
+ items: workspaces.map((workspace) => ({
898
+ ordinal: ordinalOf(workspace),
899
+ label: titleCase(workspace.topic),
900
+ href: hrefForWorkspace(workspace, '') ?? '#',
901
+ at: false,
902
+ })),
903
+ foot: undefined,
904
+ }
582
905
 
583
906
  const rows = workspaces
584
907
  .map((workspace) => {
@@ -598,7 +921,7 @@ function renderRootPage(workspaces: readonly WorkspaceSummary[]): string {
598
921
  .join('')
599
922
 
600
923
  return `${pageHead('Learning workspaces', TEACH_STYLESHEET, undefined)}<body>
601
- ${renderHeader(segments, track)}
924
+ ${renderHeader(segments, sidebar)}
602
925
  <main class="wide-body">
603
926
 
604
927
 
@@ -608,6 +931,7 @@ ${renderHeader(segments, track)}
608
931
  <ul class="toc">${rows}</ul>
609
932
 
610
933
  </main>
934
+ ${PANE_CLOSE}
611
935
  ${renderScripts(false, false)}
612
936
  </body>
613
937
  </html>
@@ -671,7 +995,18 @@ async function renderContentsPage(
671
995
  },
672
996
  ]
673
997
 
674
- const track = metas.map<TrackState>(() => 'done')
998
+ const sidebar: Sidebar = {
999
+ heading: titleCase(detail.topic),
1000
+ switcher: workspaceJumpEntries(workspaces, '../', detail.slug),
1001
+ meta: `${metas.length} lesson${metas.length === 1 ? '' : 's'}`,
1002
+ items: metas.map((meta, index) => ({
1003
+ ordinal: String(index + 1).padStart(2, '0'),
1004
+ label: meta.title,
1005
+ href: `${TEACH_LESSONS}/${meta.file}`,
1006
+ at: false,
1007
+ })),
1008
+ foot: workspaceFoot(detail),
1009
+ }
675
1010
 
676
1011
  const lessonRows = metas
677
1012
  .map(
@@ -711,7 +1046,7 @@ async function renderContentsPage(
711
1046
  .join('\n\n')
712
1047
 
713
1048
  return `${pageHead(`${title}, contents`, `${TEACH_ASSETS}/${TEACH_STYLESHEET}`, undefined)}<body>
714
- ${renderHeader(segments, track)}
1049
+ ${renderHeader(segments, sidebar)}
715
1050
  <main class="wide-body">
716
1051
 
717
1052
 
@@ -719,6 +1054,7 @@ ${renderHeader(segments, track)}
719
1054
  ${sections}
720
1055
 
721
1056
  </main>
1057
+ ${PANE_CLOSE}
722
1058
  ${renderScripts(false, true)}
723
1059
  </body>
724
1060
  </html>
@@ -814,7 +1150,18 @@ async function rewriteLesson(
814
1150
  },
815
1151
  { label: `Lesson ${index + 1} of ${metas.length}` },
816
1152
  ],
817
- metas.map<TrackState>((_, i) => (i === index ? 'here' : 'done')),
1153
+ {
1154
+ heading: titleCase(detail.topic),
1155
+ switcher: workspaceJumpEntries(workspaces, teachPrefix, detail.slug),
1156
+ meta: `${metas.length} lesson${metas.length === 1 ? '' : 's'}`,
1157
+ items: metas.map((meta, i) => ({
1158
+ ordinal: String(i + 1).padStart(2, '0'),
1159
+ label: meta.title,
1160
+ href: meta.file,
1161
+ at: i === index,
1162
+ })),
1163
+ foot: workspaceFoot(detail),
1164
+ },
818
1165
  )
819
1166
 
820
1167
  // One detector decides both mechanisms, because a lesson carries one quiz
@@ -827,9 +1174,14 @@ async function rewriteLesson(
827
1174
  const regions: ReadonlyArray<readonly [Region, string]> = [
828
1175
  // The stepper follows the workspace stylesheet so it wins the cascade at
829
1176
  // equal specificity, which is what reaches a workspace seeded before it.
830
- ['style', `<style>\n${legacy ? css : `${css}\n${QUIZ_CSS}`}\n</style>`],
1177
+ // The head script settles the panel before first paint, so it rides in the
1178
+ // one region that sits inside `<head>`.
1179
+ [
1180
+ 'style',
1181
+ `<style>\n${legacy ? css : `${css}\n${QUIZ_CSS}`}\n</style>\n${headScript('lesson')}`,
1182
+ ],
831
1183
  ['header', header],
832
- ['footnav', renderFootNav(metas, index)],
1184
+ ['footnav', `${renderFootNav(metas, index)}\n${PANE_CLOSE}`],
833
1185
  ['scripts', renderScripts(legacy, false)],
834
1186
  ]
835
1187