@erclx/canon 5.15.0 → 5.17.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/draft-figure/REQUIREMENT.md +8 -2
- package/claude/skills/draft-figure/SKILL.md +19 -4
- package/claude/skills/draft-wiki/SKILL.md +1 -1
- package/claude/skills/draft-wireframes/SKILL.md +1 -1
- package/claude/skills/plan-groundwork/SKILL.md +1 -1
- package/claude/skills/search-craft/REQUIREMENT.md +57 -0
- package/claude/skills/search-craft/SKILL.md +83 -0
- package/claude/skills/teach-workspace/SKILL.md +1 -1
- package/claude/skills/teach-workspace/references/lesson-craft.md +1 -1
- package/docs/agents/canvas.md +36 -0
- package/docs/agents/commands.md +3 -3
- package/docs/agents/demo.md +3 -1
- package/docs/agents/index.md +1 -0
- package/docs/agents/teach.md +2 -2
- package/docs/workflow/skill-map.md +1 -0
- package/docs/workflow/visual-design-workflow.md +8 -15
- package/governance/rules/claude/566-output.md +3 -5
- package/governance/rules/claude/575-hooks.md +1 -1
- package/package.json +1 -1
- package/scripts/lib/sandbox-git.sh +2 -2
- package/src/canvas/address.ts +184 -0
- package/src/canvas/capture.ts +172 -0
- package/src/canvas/client/app.tsx +2 -0
- package/src/canvas/client/inspector.tsx +121 -0
- package/src/canvas/client/layers.tsx +118 -0
- package/src/canvas/client/pages-panel.tsx +47 -13
- package/src/canvas/client/shell.css +131 -1
- package/src/canvas/client/state.ts +186 -0
- package/src/canvas/client/surface.tsx +272 -20
- package/src/canvas/content.ts +307 -16
- package/src/canvas/server.ts +182 -13
- package/src/claude/cases/misc.ts +5 -0
- package/src/commands/canvas.ts +200 -3
- package/src/commands/demo.ts +2 -0
- package/src/commands/migrate.ts +1 -1
- package/src/commands/teach.ts +3 -1
- package/src/demo/drive.ts +36 -7
- package/src/demo/timeline.ts +64 -0
- package/src/migrate/rename.ts +5 -4
- package/src/teach/browser/compile.ts +44 -0
- package/src/teach/browser/dismiss.ts +76 -0
- package/src/teach/browser/focus-line.ts +19 -0
- package/src/teach/browser/glossary-filter.ts +44 -0
- package/src/teach/browser/head.ts +30 -0
- package/src/teach/browser/quiz.ts +19 -0
- package/src/teach/browser/sidebar.ts +233 -0
- package/src/teach/browser/theme.ts +18 -0
- package/src/teach/nav.ts +19 -374
- package/src/teach/workspace.ts +61 -6
- package/standards/figures.md +1 -0
- package/tooling/claude/reference.md +1 -1
- package/tooling/claude/seeds/.claude/hooks/path-form.sh +2 -4
- package/tooling/claude/seeds/.claude/hooks/search-reminder.sh +35 -0
- package/tooling/claude/seeds/.claude/settings.json +9 -0
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, '&')
|
|
@@ -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 = [
|
|
693
|
-
|
|
694
|
-
|
|
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
|
|
package/src/teach/workspace.ts
CHANGED
|
@@ -885,12 +885,60 @@ async function openFile(
|
|
|
885
885
|
return path
|
|
886
886
|
}
|
|
887
887
|
|
|
888
|
+
/** The entry lines of the file, or of one heading's section. */
|
|
889
|
+
function listedEntries(text: string, heading?: string): string[] {
|
|
890
|
+
const body = unfenced(text)
|
|
891
|
+
const section = heading
|
|
892
|
+
? sectionRange(body, heading, text.split('\n').length)
|
|
893
|
+
: undefined
|
|
894
|
+
if (heading && !section) return []
|
|
895
|
+
|
|
896
|
+
return body
|
|
897
|
+
.filter(
|
|
898
|
+
(line) =>
|
|
899
|
+
isEntry(line.text) &&
|
|
900
|
+
(!section ||
|
|
901
|
+
(line.number - 1 >= section.start && line.number - 1 < section.end)),
|
|
902
|
+
)
|
|
903
|
+
.map((line) => line.text)
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
function lists(entries: readonly string[], source: Source): boolean {
|
|
907
|
+
return entries.some((line) => line.includes(`(${source.url})`))
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
/**
|
|
911
|
+
* Drops the lead entries naming the given URLs, wrapped continuation lines
|
|
912
|
+
* included, and restores the placeholder when the section empties.
|
|
913
|
+
*/
|
|
914
|
+
function withoutLeads(text: string, urls: ReadonlySet<string>): string {
|
|
915
|
+
const lines = text.split('\n')
|
|
916
|
+
const section = sectionRange(unfenced(text), LEADS_HEADING, lines.length)
|
|
917
|
+
if (!section) return text
|
|
918
|
+
|
|
919
|
+
const bullets = bulletRange(unfenced(text), section)
|
|
920
|
+
if (!bullets) return text
|
|
921
|
+
|
|
922
|
+
const kept = bulletBlocks(lines.slice(bullets.start, bullets.end)).filter(
|
|
923
|
+
(block) => ![...urls].some((url) => block[0].includes(`(${url})`)),
|
|
924
|
+
)
|
|
925
|
+
|
|
926
|
+
return [
|
|
927
|
+
...lines.slice(0, bullets.start),
|
|
928
|
+
...(kept.length > 0 ? kept.flat() : [PLACEHOLDER]),
|
|
929
|
+
...lines.slice(bullets.end),
|
|
930
|
+
].join('\n')
|
|
931
|
+
}
|
|
932
|
+
|
|
888
933
|
/**
|
|
889
934
|
* Appends sources to `RESOURCES.md`, keeping what was read apart from what was
|
|
890
935
|
* only found.
|
|
891
936
|
*
|
|
892
937
|
* A URL already listed under either heading is refused rather than repeated,
|
|
893
938
|
* since a second entry for one source splits what rests on it across two lines.
|
|
939
|
+
* The one exception is a read source found under `## Leads`, which moves to
|
|
940
|
+
* `## Read` under its new title, since opening a lead is how it becomes read.
|
|
941
|
+
* Any refusal leaves the file untouched, so a batch lands whole or not at all.
|
|
894
942
|
*/
|
|
895
943
|
export async function recordSources(
|
|
896
944
|
root: string,
|
|
@@ -905,13 +953,16 @@ export async function recordSources(
|
|
|
905
953
|
if (typeof path !== 'string') return path
|
|
906
954
|
|
|
907
955
|
let text = await readFile(path, 'utf8')
|
|
908
|
-
const listed =
|
|
909
|
-
|
|
910
|
-
.filter(isEntry)
|
|
956
|
+
const listed = listedEntries(text)
|
|
957
|
+
const leadEntries = listedEntries(text, LEADS_HEADING)
|
|
911
958
|
|
|
912
|
-
const
|
|
913
|
-
|
|
914
|
-
|
|
959
|
+
const isPromotion = (source: Source) => lists(leadEntries, source)
|
|
960
|
+
const promoted = read.filter(isPromotion)
|
|
961
|
+
|
|
962
|
+
const repeated = [
|
|
963
|
+
...read.filter((source) => !isPromotion(source)),
|
|
964
|
+
...leads,
|
|
965
|
+
].filter((source) => lists(listed, source))
|
|
915
966
|
|
|
916
967
|
if (repeated.length > 0) {
|
|
917
968
|
return refuse(
|
|
@@ -921,6 +972,10 @@ export async function recordSources(
|
|
|
921
972
|
)
|
|
922
973
|
}
|
|
923
974
|
|
|
975
|
+
if (promoted.length > 0) {
|
|
976
|
+
text = withoutLeads(text, new Set(promoted.map((source) => source.url)))
|
|
977
|
+
}
|
|
978
|
+
|
|
924
979
|
for (const [heading, sources] of [
|
|
925
980
|
[READ_HEADING, read],
|
|
926
981
|
[LEADS_HEADING, leads],
|
package/standards/figures.md
CHANGED
|
@@ -38,6 +38,7 @@ A figure failing these is non-conforming even when it satisfies every shape rule
|
|
|
38
38
|
|
|
39
39
|
- Route a graph-shaped subject, one whose relationship, boundary, or path a flowchart already expresses, through Mermaid's own hand-drawn look rather than through model-authored SVG coordinates. `mermaid.md` governs the fence itself.
|
|
40
40
|
- Reserve freehand inline SVG, plain shapes and lines authored directly in the markup, for a subject that is not graph-shaped. A figure that cannot express its subject through the deterministic path is a worse failure than an inconsistent one drawn by hand, so the hatch stays open rather than forcing every figure through the renderer.
|
|
41
|
+
- Draw a figure in a teach lesson freehand whatever its shape. A lesson letters its figures in a hand font the renderer cannot load, so Mermaid measures every label in a narrower fallback face, and the page then clips the wider text it actually shows.
|
|
41
42
|
- State this as a render-first policy rather than a ban on a tool-exported drawing. Routing a graph-shaped figure through Mermaid is itself running it through an external tool, so a rule banning any diagram "exported from a diagramming tool" would forbid the path this policy requires.
|
|
42
43
|
|
|
43
44
|
## Wrapping and captioning
|
|
@@ -91,6 +91,6 @@ The first entry covers the whole record root, so a record folder added later nee
|
|
|
91
91
|
|
|
92
92
|
## settings.json
|
|
93
93
|
|
|
94
|
-
- Project seed at `tooling/claude/seeds/.claude/settings.json` ships
|
|
94
|
+
- Project seed at `tooling/claude/seeds/.claude/settings.json` ships three PreToolUse hooks (pairing with `.claude/hooks/index-reminder.sh`, `.claude/hooks/search-reminder.sh`, and `.claude/hooks/scratch-guard.sh`) and a PostToolUse hook (pairs with `.claude/hooks/standards-audit.sh`). Project settings layer over user settings, so per-project files only carry what is genuinely project-specific.
|
|
95
95
|
- User-level template at `tooling/claude/user/settings.template.json` carries `attribution.commit` and `attribution.pr` (empty strings to suppress co-author tags), `permissions.allow` with `Bash(bun run *)`, and `permissions.deny` with `Read(**/.env)` and `Read(**/.env.*)` to block accidental secret exposure across any project. Installed once per machine via `canon claude setup`.
|
|
96
96
|
- The deprecated `includeCoAuthoredBy` key is not used.
|
|
@@ -8,10 +8,8 @@
|
|
|
8
8
|
# and no error appears. This computes the fact a session was asking itself
|
|
9
9
|
# to infer on every response and hands the answer back instead.
|
|
10
10
|
#
|
|
11
|
-
# The
|
|
12
|
-
#
|
|
13
|
-
# the environment variables Claude Code documents as passed to a hook
|
|
14
|
-
# subprocess, unlike CLAUDE_PROJECT_DIR, so a hook cannot read it reliably.
|
|
11
|
+
# The link form a response uses comes from the harness's own instruction for
|
|
12
|
+
# its surface, not from this hook or the output rule.
|
|
15
13
|
#
|
|
16
14
|
# The worktree branch comes from the path rather than from `git rev-parse`,
|
|
17
15
|
# the way `tasks-index.sh` and `memory-index.sh` already derive their main
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
|
|
3
|
+
# Claude Code sends a payload and closes stdin. A bare read with nothing feeding
|
|
4
|
+
# it blocks forever and holds the session open, so the read is bounded. `read`
|
|
5
|
+
# rather than `timeout cat`, which macOS does not ship.
|
|
6
|
+
IFS= read -r -d '' -t 2 input
|
|
7
|
+
[ -n "$input" ] || {
|
|
8
|
+
printf '%s reads a Claude Code hook payload on stdin and cannot be run by hand.\n' "${0##*/}" >&2
|
|
9
|
+
exit 1
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
tool=$(printf '%s' "$input" | jq -r '.tool_name // empty')
|
|
13
|
+
[ "$tool" = "WebSearch" ] || exit 0
|
|
14
|
+
|
|
15
|
+
# Without a session id there is no stable key to dedupe on, and a shared marker
|
|
16
|
+
# would silence every later session, so stay quiet rather than guess one.
|
|
17
|
+
session=$(printf '%s' "$input" | jq -r '.session_id // empty')
|
|
18
|
+
[ -n "$session" ] || exit 0
|
|
19
|
+
|
|
20
|
+
key=$(printf '%s' "$session" | tr -c 'A-Za-z0-9' '_')
|
|
21
|
+
# The marker is scratch, so it follows the scratch folder to whichever record
|
|
22
|
+
# root the project carries rather than creating a second one beside it.
|
|
23
|
+
project="${CLAUDE_PROJECT_DIR:-.}"
|
|
24
|
+
if [ -d "$project/.canon" ]; then
|
|
25
|
+
marker_dir="$project/.canon/tmp/hooks/search-reminder"
|
|
26
|
+
else
|
|
27
|
+
marker_dir="$project/.claude/.tmp/hooks/search-reminder"
|
|
28
|
+
fi
|
|
29
|
+
marker="$marker_dir/$key"
|
|
30
|
+
[ -f "$marker" ] && exit 0
|
|
31
|
+
mkdir -p "$marker_dir"
|
|
32
|
+
: >"$marker"
|
|
33
|
+
|
|
34
|
+
msg='Load the canon:search-craft skill before this web search. Report it rather than proceeding silently when the skill does not resolve.'
|
|
35
|
+
jq -nc --arg msg "$msg" '{hookSpecificOutput:{hookEventName:"PreToolUse",additionalContext:$msg}}'
|