@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/README.md +15 -30
- 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/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/git-ship/SKILL.md +1 -1
- package/claude/skills/review-address/SKILL.md +4 -3
- package/claude/skills/task-board/REQUIREMENT.md +1 -1
- package/claude/skills/task-board/SKILL.md +2 -2
- package/docs/agents/capture.md +3 -3
- package/docs/agents/commands.md +2 -2
- package/docs/agents/pr-reads.md +2 -0
- package/docs/agents/tasks.md +10 -8
- package/docs/agents/teach.md +4 -2
- package/docs/workflow/ai-workflow.md +2 -1
- package/package.json +1 -1
- package/scripts/core/regen-hero.sh +2 -34
- package/src/claude/cases/authoring.ts +5 -0
- package/src/claude/cases/setup.ts +1 -1
- package/src/cli.ts +2 -161
- package/src/commands/pr.ts +29 -4
- package/src/commands/tasks.ts +22 -7
- package/src/design/components.ts +382 -90
- package/src/help.ts +196 -0
- package/src/pr/checks.ts +29 -0
- package/src/tasks/label.ts +136 -29
- package/src/teach/nav.ts +406 -56
- package/standards/readme.md +1 -0
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>`
|
|
@@ -312,26 +541,91 @@ function renderBreadcrumb(segments: readonly CrumbSegment[]): string {
|
|
|
312
541
|
.join('')
|
|
313
542
|
}
|
|
314
543
|
|
|
315
|
-
|
|
544
|
+
interface SidebarItem {
|
|
545
|
+
readonly ordinal: string
|
|
546
|
+
readonly label: string
|
|
547
|
+
readonly href: string
|
|
548
|
+
readonly at: boolean
|
|
549
|
+
}
|
|
316
550
|
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
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
|
-
|
|
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
|
-
|
|
620
|
+
sidebar: Sidebar,
|
|
328
621
|
): string {
|
|
329
|
-
return
|
|
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">☰</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,
|
|
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(' · ') : 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
|
|
580
|
-
|
|
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,
|
|
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
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
package/standards/readme.md
CHANGED
|
@@ -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.
|