@erclx/canon 5.15.0 → 5.16.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 (35) hide show
  1. package/claude/.claude-plugin/plugin.json +1 -1
  2. package/claude/skills/draft-wiki/SKILL.md +1 -1
  3. package/claude/skills/plan-groundwork/SKILL.md +1 -1
  4. package/claude/skills/search-craft/REQUIREMENT.md +57 -0
  5. package/claude/skills/search-craft/SKILL.md +83 -0
  6. package/docs/agents/canvas.md +36 -0
  7. package/docs/agents/commands.md +3 -3
  8. package/docs/agents/index.md +1 -0
  9. package/docs/workflow/skill-map.md +1 -0
  10. package/package.json +1 -1
  11. package/scripts/lib/sandbox-git.sh +2 -2
  12. package/src/canvas/address.ts +184 -0
  13. package/src/canvas/capture.ts +172 -0
  14. package/src/canvas/client/app.tsx +2 -0
  15. package/src/canvas/client/inspector.tsx +121 -0
  16. package/src/canvas/client/layers.tsx +118 -0
  17. package/src/canvas/client/pages-panel.tsx +47 -13
  18. package/src/canvas/client/shell.css +131 -1
  19. package/src/canvas/client/state.ts +186 -0
  20. package/src/canvas/client/surface.tsx +272 -20
  21. package/src/canvas/content.ts +307 -16
  22. package/src/canvas/server.ts +182 -13
  23. package/src/claude/cases/misc.ts +5 -0
  24. package/src/commands/canvas.ts +200 -3
  25. package/src/commands/migrate.ts +1 -1
  26. package/src/migrate/rename.ts +5 -4
  27. package/src/teach/browser/compile.ts +44 -0
  28. package/src/teach/browser/dismiss.ts +76 -0
  29. package/src/teach/browser/focus-line.ts +19 -0
  30. package/src/teach/browser/glossary-filter.ts +44 -0
  31. package/src/teach/browser/head.ts +30 -0
  32. package/src/teach/browser/quiz.ts +19 -0
  33. package/src/teach/browser/sidebar.ts +233 -0
  34. package/src/teach/browser/theme.ts +18 -0
  35. package/src/teach/nav.ts +19 -374
@@ -0,0 +1,233 @@
1
+ import { focusLine } from '@/teach/browser/focus-line'
2
+
3
+ /**
4
+ * `focusLine` is declared in the page ahead of this function by `compile`, so
5
+ * the call below reaches the one source the unit test exercises.
6
+ */
7
+ export function sidebar(breakpoint: number): void {
8
+ var root = document.documentElement
9
+ var found = document.querySelector<HTMLElement>('.sb')
10
+ if (!found) return
11
+ var panel = found
12
+
13
+ var fold = document.querySelector<HTMLElement>('.sb-fold')
14
+ var narrow = matchMedia('(max-width: ' + breakpoint + 'px)')
15
+
16
+ var FOCUSABLE =
17
+ "a[href], button:not([disabled]), summary, input, [tabindex]:not([tabindex='-1'])"
18
+
19
+ function panelStops() {
20
+ return Array.prototype.slice
21
+ .call(panel.querySelectorAll(FOCUSABLE))
22
+ .filter(function (el: HTMLElement) {
23
+ return el.offsetParent !== null || el === document.activeElement
24
+ }) as HTMLElement[]
25
+ }
26
+
27
+ /* The focus return belongs here rather than on the close control, because all
28
+ three routes out land here and only one of them used to move focus. The
29
+ shut panel takes visibility: hidden 180ms later, so a reader who pressed
30
+ Escape kept focus on a control that then disappeared under them and the
31
+ browser dropped them at the top of the document. */
32
+ function shut() {
33
+ var inside = panel.contains(document.activeElement)
34
+ root.classList.add('sb-shut')
35
+ try {
36
+ localStorage.setItem('teach-sb', 'shut')
37
+ } catch (e) {}
38
+ if (inside && fold) fold.focus()
39
+ }
40
+
41
+ if (fold)
42
+ fold.addEventListener('click', function () {
43
+ root.classList.toggle('sb-shut')
44
+ try {
45
+ localStorage.setItem(
46
+ 'teach-sb',
47
+ root.classList.contains('sb-shut') ? 'shut' : 'open',
48
+ )
49
+ } catch (e) {}
50
+ /* An overlay takes focus with it. Without this a keyboard reader opens the
51
+ panel and goes on tabbing through the lesson behind the scrim. */
52
+ if (!narrow.matches || root.classList.contains('sb-shut')) return
53
+ var first = panel.querySelector<HTMLElement>('a, button, summary, input')
54
+ if (first) first.focus()
55
+ })
56
+
57
+ var scrim = document.createElement('div')
58
+ scrim.className = 'sb-scrim'
59
+ document.body.appendChild(scrim)
60
+ scrim.addEventListener('click', shut)
61
+
62
+ document.addEventListener('keydown', function (e) {
63
+ if (e.key === 'Escape' && narrow.matches) {
64
+ shut()
65
+ return
66
+ }
67
+
68
+ /* The scrim says the lesson is unavailable, so Tab must not reach it.
69
+ Moving focus into the panel on open only defers this by however many
70
+ controls the panel holds. */
71
+ if (e.key !== 'Tab') return
72
+ if (!narrow.matches || root.classList.contains('sb-shut')) return
73
+
74
+ var stops = panelStops()
75
+ if (!stops.length) return
76
+
77
+ var first = stops[0]
78
+ var last = stops[stops.length - 1]
79
+
80
+ if (!panel.contains(document.activeElement)) {
81
+ ;(e.shiftKey ? last : first).focus()
82
+ e.preventDefault()
83
+ } else if (e.shiftKey && document.activeElement === first) {
84
+ last.focus()
85
+ e.preventDefault()
86
+ } else if (!e.shiftKey && document.activeElement === last) {
87
+ first.focus()
88
+ e.preventDefault()
89
+ }
90
+ })
91
+
92
+ /* The panel covers the masthead and the toggle that opened it, so it carries
93
+ its own way out rather than leaving the scrim as the only route. */
94
+ var close = document.createElement('button')
95
+ close.type = 'button'
96
+ close.className = 'sb-close'
97
+ close.setAttribute('aria-label', 'Close the course panel')
98
+ close.textContent = '×'
99
+ close.addEventListener('click', shut)
100
+ panel.appendChild(close)
101
+
102
+ var MIN = 208,
103
+ MAX = 296,
104
+ DEF = 256
105
+ var grip = document.querySelector<HTMLElement>('.sb-grip')
106
+ var lastWidth = parseInt(root.style.getPropertyValue('--sb-w'), 10) || DEF
107
+
108
+ function setWidth(px: number, save: boolean) {
109
+ var w = Math.max(MIN, Math.min(MAX, Math.round(px)))
110
+ root.style.setProperty('--sb-w', w + 'px')
111
+ lastWidth = w
112
+ if (save) {
113
+ try {
114
+ localStorage.setItem('teach-sb-w', String(w))
115
+ } catch (e) {}
116
+ }
117
+ }
118
+
119
+ if (grip) {
120
+ var handle = grip
121
+ var dragging = false
122
+ handle.addEventListener('pointerdown', function (e) {
123
+ dragging = true
124
+ handle.setPointerCapture(e.pointerId)
125
+ root.classList.add('sb-drag')
126
+ e.preventDefault()
127
+ })
128
+ handle.addEventListener('pointermove', function (e) {
129
+ if (dragging) setWidth(e.clientX, false)
130
+ })
131
+ handle.addEventListener('pointerup', function (e) {
132
+ if (!dragging) return
133
+ dragging = false
134
+ root.classList.remove('sb-drag')
135
+ handle.releasePointerCapture(e.pointerId)
136
+ setWidth(lastWidth, true)
137
+ })
138
+ handle.addEventListener('dblclick', function () {
139
+ setWidth(DEF, true)
140
+ })
141
+ handle.addEventListener('keydown', function (e) {
142
+ var step = e.shiftKey ? 32 : 8
143
+ if (e.key === 'ArrowLeft') {
144
+ setWidth(lastWidth - step, true)
145
+ e.preventDefault()
146
+ } else if (e.key === 'ArrowRight') {
147
+ setWidth(lastWidth + step, true)
148
+ e.preventDefault()
149
+ } else if (e.key === 'Home') {
150
+ setWidth(DEF, true)
151
+ e.preventDefault()
152
+ }
153
+ })
154
+ }
155
+
156
+ var filter = document.querySelector<HTMLInputElement>('.sb-filter input')
157
+ if (filter) {
158
+ var box = filter
159
+ box.addEventListener('input', function () {
160
+ var q = box.value.trim().toLowerCase()
161
+ document
162
+ .querySelectorAll<HTMLElement>('.sb-list > li')
163
+ .forEach(function (li) {
164
+ li.classList.toggle(
165
+ 'hide',
166
+ q !== '' &&
167
+ (li.textContent as string).toLowerCase().indexOf(q) === -1,
168
+ )
169
+ })
170
+ })
171
+ }
172
+
173
+ var slot = document.querySelector('.sb-out-slot')
174
+ var hs = Array.prototype.slice.call(
175
+ document.querySelectorAll('main h2'),
176
+ ) as HTMLElement[]
177
+ var links: HTMLElement[] = []
178
+
179
+ if (slot && hs.length) {
180
+ var list = document.createElement('ul')
181
+ list.className = 'sb-out'
182
+ hs.forEach(function (h, i) {
183
+ if (!h.id) h.id = 's' + i
184
+ var li = document.createElement('li')
185
+ var a = document.createElement('a')
186
+ a.href = '#' + h.id
187
+ a.textContent = (h.textContent as string).trim()
188
+ li.appendChild(a)
189
+ list.appendChild(li)
190
+ })
191
+ ;(slot.parentNode as Node).replaceChild(list, slot)
192
+ links = Array.prototype.slice.call(list.querySelectorAll('a'))
193
+ }
194
+
195
+ /* The bar reports position inside the lesson, which nothing reported before
196
+ the segmented track retired. The breadcrumb's last segment carries the
197
+ lesson title once the real one has scrolled off. */
198
+ var bar = document.querySelector<HTMLElement>('.bar')
199
+ var here = document.querySelector('.crumb-here')
200
+ var h1 = document.querySelector('main h1')
201
+ var counter = here ? (here.textContent as string).trim() : ''
202
+ var title = h1 ? (h1.textContent as string).trim() : ''
203
+
204
+ function sync() {
205
+ var max = document.documentElement.scrollHeight - innerHeight
206
+ if (bar) {
207
+ var pct = max > 0 ? Math.min(100, Math.max(0, (scrollY / max) * 100)) : 0
208
+ bar.style.setProperty('--read', pct.toFixed(1) + '%')
209
+ }
210
+
211
+ if (here && h1 && title && counter) {
212
+ var want = h1.getBoundingClientRect().bottom < 56 ? title : counter
213
+ if (here.textContent !== want) here.textContent = want
214
+ }
215
+
216
+ if (links.length) {
217
+ /* Nothing is marked until a heading has actually passed the line, so the
218
+ first section is not reported as current while the title is on screen. */
219
+ var best = -1
220
+ var line = focusLine(scrollY, max, innerHeight)
221
+ for (var i = 0; i < hs.length; i++) {
222
+ if (hs[i].getBoundingClientRect().top <= line) best = i
223
+ }
224
+ links.forEach(function (l, j) {
225
+ l.classList.toggle('on', j === best)
226
+ })
227
+ }
228
+ }
229
+
230
+ addEventListener('scroll', sync, { passive: true })
231
+ addEventListener('resize', sync)
232
+ sync()
233
+ }
@@ -0,0 +1,18 @@
1
+ export function theme(): void {
2
+ var r = document.documentElement
3
+ try {
4
+ var s = localStorage.getItem('course-theme')
5
+ if (s) r.dataset.theme = s
6
+ } catch (e) {}
7
+ document.addEventListener('click', function (e) {
8
+ var b = (e.target as Element).closest('.theme')
9
+ if (!b) return
10
+ var d =
11
+ r.dataset.theme === 'dark' ||
12
+ (!r.dataset.theme && matchMedia('(prefers-color-scheme: dark)').matches)
13
+ r.dataset.theme = d ? 'light' : 'dark'
14
+ try {
15
+ localStorage.setItem('course-theme', r.dataset.theme)
16
+ } catch (e) {}
17
+ })
18
+ }
package/src/teach/nav.ts CHANGED
@@ -10,6 +10,14 @@ import { FAVICON_COLORS, faviconLink, renderFavicon } from '@/design/favicon'
10
10
  import { stripFrontmatter } from '@/frontmatter'
11
11
  import { parseFrontmatter, readField } from '@/indexes/frontmatter'
12
12
  import { PROJECT_ROOT } from '@/project-root'
13
+ import { compileScript } from '@/teach/browser/compile'
14
+ import { dismiss } from '@/teach/browser/dismiss'
15
+ import { focusLine } from '@/teach/browser/focus-line'
16
+ import { glossaryFilter } from '@/teach/browser/glossary-filter'
17
+ import { head } from '@/teach/browser/head'
18
+ import { quiz } from '@/teach/browser/quiz'
19
+ import { sidebar } from '@/teach/browser/sidebar'
20
+ import { theme } from '@/teach/browser/theme'
13
21
  import { linkLessonReferences } from '@/teach/cross-references'
14
22
  import { TEACH_FONT_FACES } from '@/teach/fonts'
15
23
  import { LESSON_NUMBER } from '@/teach/lesson'
@@ -82,76 +90,6 @@ const CARET =
82
90
  const THEME_BUTTON =
83
91
  '<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>'
84
92
 
85
- const THEME_SCRIPT =
86
- '<script>(function(){var r=document.documentElement;try{var s=localStorage.getItem("course-theme");if(s)r.dataset.theme=s;}catch(e){}document.addEventListener("click",function(e){var b=e.target.closest(".theme");if(!b)return;var d=r.dataset.theme==="dark"||(!r.dataset.theme&&matchMedia("(prefers-color-scheme: dark)").matches);r.dataset.theme=d?"light":"dark";try{localStorage.setItem("course-theme",r.dataset.theme);}catch(e){}});})();</script>'
87
-
88
- const CLOSE_OUTSIDE_CLICK_SCRIPT = `<script>
89
- (function () {
90
- var MENUS = "details.jump[open], details.sb-ws[open]";
91
-
92
- function close(except) {
93
- document.querySelectorAll(MENUS).forEach(function (d) {
94
- if (d !== except) d.open = false;
95
- });
96
- }
97
- document.addEventListener("click", function (e) {
98
- var inside = e.target.closest("details.jump, details.sb-ws");
99
- close(inside);
100
- });
101
- document.addEventListener("keydown", function (e) {
102
- if (e.key !== "Escape") return;
103
- var open = document.querySelector(MENUS);
104
- if (!open) return;
105
- open.open = false;
106
- var s = open.querySelector("summary");
107
- if (s) s.focus();
108
- });
109
-
110
- /* Hover intent on the breadcrumb only. The sidebar's workspace switcher stays
111
- click-only on purpose: its panel opens directly over the lesson list, which
112
- is where the pointer is headed, so hovering it would cover the thing
113
- being reached for. The breadcrumb's panel drops over body text instead. */
114
- var OPEN = 120, SHUT = 260;
115
-
116
- document.querySelectorAll("details.jump").forEach(function (d) {
117
- var host = d.closest(".crumb-item") || d;
118
- var timer = null;
119
- var openedByHover = false;
120
-
121
- function arm(want) {
122
- clearTimeout(timer);
123
- timer = setTimeout(function () {
124
- if (want) close(d);
125
- d.open = want;
126
- }, want ? OPEN : SHUT);
127
- }
128
-
129
- host.addEventListener("mouseenter", function () {
130
- if (!d.open) openedByHover = true;
131
- arm(true);
132
- });
133
- host.addEventListener("mouseleave", function () {
134
- openedByHover = false;
135
- arm(false);
136
- });
137
- /* Keeps it open while the pointer is inside the panel, without cancelling a
138
- pending open, which is what silently disabled this on one of two menus. */
139
- d.addEventListener("mouseenter", function () { if (d.open) clearTimeout(timer); });
140
-
141
- /* Hover and click were wired to one disclosure and fought: hover opened the
142
- panel, then the summary's native click toggled what hover had already
143
- opened, so reaching for an item shut it. A click on a panel hover opened
144
- keeps it open, and a second click closes. The click path stays live for
145
- touch, where no hover exists, and for the keyboard, where Enter is the
146
- only way in. */
147
- var summary = d.querySelector("summary");
148
- if (summary) summary.addEventListener("click", function (e) {
149
- if (d.open && openedByHover) { e.preventDefault(); openedByHover = false; }
150
- });
151
- });
152
- })();
153
- </script>`
154
-
155
93
  /**
156
94
  * The stepper, as CSS over the radio inputs a lesson's quiz is written from.
157
95
  *
@@ -195,307 +133,6 @@ const QUIZ_CSS = `@supports selector(:has(*)) {
195
133
  */
196
134
  const LEGACY_OPTION = '<button class="opt"'
197
135
 
198
- /**
199
- * What reveals feedback in a lesson written against `LEGACY_OPTION`. The radio
200
- * shape needs none of it, so a lesson carrying no button option gets no script,
201
- * and removing this outright would leave the lessons already written showing no
202
- * feedback at all.
203
- */
204
- const QUIZ_SCRIPT =
205
- '<script>document.querySelectorAll(".q").forEach(function(q){var f=q.querySelector(".fb");q.querySelectorAll(".opt").forEach(function(b){b.addEventListener("click",function(){if(f.classList.contains("show"))return;q.querySelectorAll(".opt").forEach(function(o){o.dataset.state=o.dataset.a==="1"?"right":(o===b?"chosen":"wrong");});f.classList.add("show");});});});</script>'
206
-
207
- const GLOSSARY_FILTER_SCRIPT = `<script>
208
- (function () {
209
- var input = document.getElementById("gfilter");
210
- var list = document.getElementById("gloss");
211
- var count = document.getElementById("gloss-count");
212
- if (!input || !list) return;
213
- function updateGroups() {
214
- list.querySelectorAll(".gloss-group").forEach(function (heading) {
215
- var el = heading.nextElementSibling;
216
- var any = false;
217
- while (el && !el.classList.contains("gloss-group")) {
218
- if (el.style.display !== "none") any = true;
219
- el = el.nextElementSibling;
220
- }
221
- heading.style.display = any ? "" : "none";
222
- });
223
- }
224
- input.addEventListener("input", function () {
225
- var q = input.value.toLowerCase();
226
- var n = 0;
227
- list.querySelectorAll(".gterm").forEach(function (entry) {
228
- var match = entry.textContent.toLowerCase().includes(q);
229
- entry.style.display = match ? "" : "none";
230
- if (match) n++;
231
- });
232
- list.classList.toggle("none", n === 0);
233
- updateGroups();
234
- if (count) {
235
- var total = list.querySelectorAll(".gterm").length;
236
- count.textContent = (n === total ? total : n + " of " + total) + (total === 1 ? " term" : " terms");
237
- }
238
- });
239
- var clear = list.querySelector(".clear");
240
- if (clear) {
241
- clear.addEventListener("click", function () {
242
- input.value = "";
243
- input.dispatchEvent(new Event("input"));
244
- input.focus();
245
- });
246
- }
247
- })();
248
- </script>`
249
-
250
- /**
251
- * The outline rail's focus-line ramp, as the JavaScript source `OUTLINE_SCRIPT`
252
- * embeds verbatim, so there is one copy of the formula rather than a TS
253
- * reimplementation that could drift from what a browser actually runs.
254
- *
255
- * It ramps the line from near the top at scroll 0 to the viewport's bottom
256
- * edge at max scroll, so the last heading is reachable regardless of how
257
- * little content trails it. The prior formula ended the ramp 120px short of
258
- * the edge, which left a heading followed by under 120px of trailing content
259
- * permanently unmarked, since its top never fell below the line even at max
260
- * scroll.
261
- */
262
- const FOCUS_LINE_BODY = `if (max <= 0) return innerHeight;
263
- var progress = Math.min(1, Math.max(0, scrollY / max));
264
- return 120 + progress * Math.max(0, innerHeight - 120);`
265
-
266
- /**
267
- * Compiles and runs `FOCUS_LINE_BODY`, so a test exercises the exact source
268
- * the browser runs rather than a parallel copy of it.
269
- */
270
- export function focusLine(
271
- scrollY: number,
272
- max: number,
273
- innerHeight: number,
274
- ): number {
275
- const compiled = new Function(
276
- 'scrollY',
277
- 'max',
278
- 'innerHeight',
279
- FOCUS_LINE_BODY,
280
- ) as (scrollY: number, max: number, innerHeight: number) => number
281
-
282
- return compiled(scrollY, max, innerHeight)
283
- }
284
-
285
- /**
286
- * The panel's state, settled before the first paint rather than after it. An
287
- * arm that set the narrow default afterwards slid the panel in and back out on
288
- * every load, and a restored custom width animated in from the default.
289
- *
290
- * A listing page starts shut because the body already lists what the panel
291
- * would, and a lesson starts open because there the panel is the only
292
- * cross-lesson navigation on the page. A stored preference beats both. A count
293
- * threshold on its own was built and reverted, since it hides the panel on a
294
- * lesson too.
295
- */
296
- function headScript(page: 'index' | 'lesson'): string {
297
- return `<script>(function(){var r=document.documentElement,s=null;
298
- r.dataset.page="${page}";
299
- if(matchMedia("(max-width: ${TEACH_SIDEBAR_BREAKPOINT}px)").matches){r.classList.add("sb-shut");return}
300
- try{s=localStorage.getItem("teach-sb")}catch(e){}
301
- var idx=r.dataset.page==="index";
302
- if(s==="shut"||(s===null&&idx))r.classList.add("sb-shut");
303
- var w=null;try{w=localStorage.getItem("teach-sb-w")}catch(e){}
304
- if(w)r.style.setProperty("--sb-w",w+"px")})();</script>`
305
- }
306
-
307
- const SIDEBAR_SCRIPT = `<script>
308
- (function () {
309
- var root = document.documentElement;
310
- var panel = document.querySelector(".sb");
311
- if (!panel) return;
312
-
313
- var fold = document.querySelector(".sb-fold");
314
- var narrow = matchMedia("(max-width: ${TEACH_SIDEBAR_BREAKPOINT}px)");
315
-
316
- var FOCUSABLE = "a[href], button:not([disabled]), summary, input, [tabindex]:not([tabindex='-1'])";
317
-
318
- function panelStops() {
319
- return Array.prototype.slice.call(panel.querySelectorAll(FOCUSABLE))
320
- .filter(function (el) { return el.offsetParent !== null || el === document.activeElement; });
321
- }
322
-
323
- /* The focus return belongs here rather than on the close control, because all
324
- three routes out land here and only one of them used to move focus. The
325
- shut panel takes visibility: hidden 180ms later, so a reader who pressed
326
- Escape kept focus on a control that then disappeared under them and the
327
- browser dropped them at the top of the document. */
328
- function shut() {
329
- var inside = panel.contains(document.activeElement);
330
- root.classList.add("sb-shut");
331
- try { localStorage.setItem("teach-sb", "shut"); } catch (e) {}
332
- if (inside && fold) fold.focus();
333
- }
334
-
335
- if (fold) fold.addEventListener("click", function () {
336
- root.classList.toggle("sb-shut");
337
- try {
338
- localStorage.setItem("teach-sb", root.classList.contains("sb-shut") ? "shut" : "open");
339
- } catch (e) {}
340
- /* An overlay takes focus with it. Without this a keyboard reader opens the
341
- panel and goes on tabbing through the lesson behind the scrim. */
342
- if (!narrow.matches || root.classList.contains("sb-shut")) return;
343
- var first = panel.querySelector("a, button, summary, input");
344
- if (first) first.focus();
345
- });
346
-
347
- var scrim = document.createElement("div");
348
- scrim.className = "sb-scrim";
349
- document.body.appendChild(scrim);
350
- scrim.addEventListener("click", shut);
351
-
352
- document.addEventListener("keydown", function (e) {
353
- if (e.key === "Escape" && narrow.matches) { shut(); return; }
354
-
355
- /* The scrim says the lesson is unavailable, so Tab must not reach it.
356
- Moving focus into the panel on open only defers this by however many
357
- controls the panel holds. */
358
- if (e.key !== "Tab") return;
359
- if (!narrow.matches || root.classList.contains("sb-shut")) return;
360
-
361
- var stops = panelStops();
362
- if (!stops.length) return;
363
-
364
- var first = stops[0];
365
- var last = stops[stops.length - 1];
366
-
367
- if (!panel.contains(document.activeElement)) {
368
- (e.shiftKey ? last : first).focus();
369
- e.preventDefault();
370
- } else if (e.shiftKey && document.activeElement === first) {
371
- last.focus();
372
- e.preventDefault();
373
- } else if (!e.shiftKey && document.activeElement === last) {
374
- first.focus();
375
- e.preventDefault();
376
- }
377
- });
378
-
379
- /* The panel covers the masthead and the toggle that opened it, so it carries
380
- its own way out rather than leaving the scrim as the only route. */
381
- var close = document.createElement("button");
382
- close.type = "button";
383
- close.className = "sb-close";
384
- close.setAttribute("aria-label", "Close the course panel");
385
- close.textContent = "\\u00d7";
386
- close.addEventListener("click", shut);
387
- panel.appendChild(close);
388
-
389
- var MIN = 208, MAX = 296, DEF = 256;
390
- var grip = document.querySelector(".sb-grip");
391
- var lastWidth = parseInt(root.style.getPropertyValue("--sb-w"), 10) || DEF;
392
-
393
- function setWidth(px, save) {
394
- var w = Math.max(MIN, Math.min(MAX, Math.round(px)));
395
- root.style.setProperty("--sb-w", w + "px");
396
- lastWidth = w;
397
- if (save) { try { localStorage.setItem("teach-sb-w", String(w)); } catch (e) {} }
398
- }
399
-
400
- if (grip) {
401
- var dragging = false;
402
- grip.addEventListener("pointerdown", function (e) {
403
- dragging = true;
404
- grip.setPointerCapture(e.pointerId);
405
- root.classList.add("sb-drag");
406
- e.preventDefault();
407
- });
408
- grip.addEventListener("pointermove", function (e) {
409
- if (dragging) setWidth(e.clientX, false);
410
- });
411
- grip.addEventListener("pointerup", function (e) {
412
- if (!dragging) return;
413
- dragging = false;
414
- root.classList.remove("sb-drag");
415
- grip.releasePointerCapture(e.pointerId);
416
- setWidth(lastWidth, true);
417
- });
418
- grip.addEventListener("dblclick", function () { setWidth(DEF, true); });
419
- grip.addEventListener("keydown", function (e) {
420
- var step = e.shiftKey ? 32 : 8;
421
- if (e.key === "ArrowLeft") { setWidth(lastWidth - step, true); e.preventDefault(); }
422
- else if (e.key === "ArrowRight") { setWidth(lastWidth + step, true); e.preventDefault(); }
423
- else if (e.key === "Home") { setWidth(DEF, true); e.preventDefault(); }
424
- });
425
- }
426
-
427
- var filter = document.querySelector(".sb-filter input");
428
- if (filter) filter.addEventListener("input", function () {
429
- var q = filter.value.trim().toLowerCase();
430
- document.querySelectorAll(".sb-list > li").forEach(function (li) {
431
- li.classList.toggle("hide", q !== "" && li.textContent.toLowerCase().indexOf(q) === -1);
432
- });
433
- });
434
-
435
- var slot = document.querySelector(".sb-out-slot");
436
- var hs = Array.prototype.slice.call(document.querySelectorAll("main h2"));
437
- var links = [];
438
-
439
- if (slot && hs.length) {
440
- var list = document.createElement("ul");
441
- list.className = "sb-out";
442
- hs.forEach(function (h, i) {
443
- if (!h.id) h.id = "s" + i;
444
- var li = document.createElement("li");
445
- var a = document.createElement("a");
446
- a.href = "#" + h.id;
447
- a.textContent = h.textContent.trim();
448
- li.appendChild(a);
449
- list.appendChild(li);
450
- });
451
- slot.parentNode.replaceChild(list, slot);
452
- links = Array.prototype.slice.call(list.querySelectorAll("a"));
453
- }
454
-
455
- /* The bar reports position inside the lesson, which nothing reported before
456
- the segmented track retired. The breadcrumb's last segment carries the
457
- lesson title once the real one has scrolled off. */
458
- var bar = document.querySelector(".bar");
459
- var here = document.querySelector(".crumb-here");
460
- var h1 = document.querySelector("main h1");
461
- var counter = here ? here.textContent.trim() : "";
462
- var title = h1 ? h1.textContent.trim() : "";
463
-
464
- function focusLine() {
465
- var max = document.documentElement.scrollHeight - innerHeight;
466
- ${FOCUS_LINE_BODY}
467
- }
468
-
469
- function sync() {
470
- if (bar) {
471
- var max = document.documentElement.scrollHeight - innerHeight;
472
- var pct = max > 0 ? Math.min(100, Math.max(0, (scrollY / max) * 100)) : 0;
473
- bar.style.setProperty("--read", pct.toFixed(1) + "%");
474
- }
475
-
476
- if (here && h1 && title && counter) {
477
- var want = h1.getBoundingClientRect().bottom < 56 ? title : counter;
478
- if (here.textContent !== want) here.textContent = want;
479
- }
480
-
481
- if (links.length) {
482
- /* Nothing is marked until a heading has actually passed the line, so the
483
- first section is not reported as current while the title is on screen. */
484
- var best = -1;
485
- var line = focusLine();
486
- for (var i = 0; i < hs.length; i++) {
487
- if (hs[i].getBoundingClientRect().top <= line) best = i;
488
- }
489
- links.forEach(function (l, j) { l.classList.toggle("on", j === best); });
490
- }
491
- }
492
-
493
- addEventListener("scroll", sync, { passive: true });
494
- addEventListener("resize", sync);
495
- sync();
496
- })();
497
- </script>`
498
-
499
136
  function escapeHtml(text: string): string {
500
137
  return text
501
138
  .replace(/&/g, '&amp;')
@@ -685,13 +322,21 @@ function renderHeader(
685
322
  </header>`
686
323
  }
687
324
 
325
+ function headScript(page: 'index' | 'lesson'): string {
326
+ return compileScript(head, [page, TEACH_SIDEBAR_BREAKPOINT])
327
+ }
328
+
688
329
  function renderScripts(
689
330
  includeQuiz: boolean,
690
331
  includeGlossaryFilter: boolean,
691
332
  ): string {
692
- const scripts = [THEME_SCRIPT, CLOSE_OUTSIDE_CLICK_SCRIPT, SIDEBAR_SCRIPT]
693
- if (includeGlossaryFilter) scripts.push(GLOSSARY_FILTER_SCRIPT)
694
- if (includeQuiz) scripts.push(QUIZ_SCRIPT)
333
+ const scripts = [
334
+ compileScript(theme),
335
+ compileScript(dismiss),
336
+ compileScript(sidebar, [TEACH_SIDEBAR_BREAKPOINT], [focusLine]),
337
+ ]
338
+ if (includeGlossaryFilter) scripts.push(compileScript(glossaryFilter))
339
+ if (includeQuiz) scripts.push(compileScript(quiz))
695
340
  return scripts.join('\n')
696
341
  }
697
342