@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.
- package/claude/.claude-plugin/plugin.json +1 -1
- package/claude/skills/{canon-feedback-file → canon-feedback}/REQUIREMENT.md +7 -6
- package/claude/skills/{canon-feedback-file → canon-feedback}/SKILL.md +9 -19
- package/claude/skills/canon-feedback-triage/REQUIREMENT.md +1 -1
- package/claude/skills/canon-feedback-triage/SKILL.md +1 -1
- package/claude/skills/canon-operator/REQUIREMENT.md +2 -2
- package/claude/skills/canon-operator/SKILL.md +3 -4
- package/claude/skills/draft-wiki/REQUIREMENT.md +42 -0
- package/claude/skills/draft-wiki/SKILL.md +83 -0
- package/claude/skills/git-issue/REQUIREMENT.md +1 -1
- package/claude/skills/git-issue/SKILL.md +1 -1
- package/claude/skills/target-check/references/domains.md +2 -2
- package/docs/agents/install-and-sync.md +4 -13
- package/docs/agents/teach.md +4 -2
- package/docs/target-projects.md +0 -2
- package/docs/workflow/ai-workflow.md +8 -11
- package/package.json +1 -1
- package/scripts/core/capture-home.sh +59 -0
- package/src/claude/cases/authoring.ts +5 -0
- package/src/claude/cases/setup.ts +3 -24
- package/src/commands/sync.ts +0 -16
- package/src/design/components.ts +459 -147
- package/src/shipped/references.ts +1 -2
- package/src/sync/reverse.ts +3 -74
- package/src/teach/nav.ts +410 -58
- package/claude/skills/migration-claude-md/REQUIREMENT.md +0 -40
- package/claude/skills/migration-claude-md/SKILL.md +0 -79
- package/claude/skills/migration-context/REQUIREMENT.md +0 -36
- package/claude/skills/migration-context/SKILL.md +0 -97
- package/claude/skills/migration-standards-drop/REQUIREMENT.md +0 -55
- package/claude/skills/migration-standards-drop/SKILL.md +0 -113
- package/claude/skills/migration-superseded/REQUIREMENT.md +0 -44
- 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 {
|
|
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="
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
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
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
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
|
-
|
|
202
|
-
|
|
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
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
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
|
-
|
|
220
|
-
|
|
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
|
|
265
|
-
|
|
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
|
-
|
|
546
|
+
interface SidebarItem {
|
|
547
|
+
readonly ordinal: string
|
|
548
|
+
readonly label: string
|
|
549
|
+
readonly href: string
|
|
550
|
+
readonly at: boolean
|
|
551
|
+
}
|
|
316
552
|
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
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
|
-
|
|
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
|
-
|
|
622
|
+
sidebar: Sidebar,
|
|
328
623
|
): string {
|
|
329
|
-
return
|
|
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">☰</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,
|
|
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(' · ') : 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
|
|
580
|
-
|
|
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,
|
|
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
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|