@erclx/canon 5.14.1 → 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.
- package/README.md +3 -2
- package/claude/.claude-plugin/plugin.json +1 -1
- package/claude/skills/draft-screencast/REQUIREMENT.md +2 -1
- package/claude/skills/draft-screencast/SKILL.md +14 -9
- package/claude/skills/draft-wiki/SKILL.md +1 -1
- package/claude/skills/plan-groundwork/SKILL.md +1 -1
- package/claude/skills/read-frames/SKILL.md +3 -2
- package/claude/skills/record-screencast/REQUIREMENT.md +3 -0
- package/claude/skills/record-screencast/SKILL.md +28 -5
- package/claude/skills/search-craft/REQUIREMENT.md +57 -0
- package/claude/skills/search-craft/SKILL.md +83 -0
- package/docs/agents/canvas.md +36 -0
- package/docs/agents/commands.md +3 -0
- package/docs/agents/demo.md +34 -7
- package/docs/agents/index.md +1 -0
- package/docs/agents/migrate.md +1 -1
- package/docs/agents/sandbox.md +25 -0
- package/docs/target/migrations.md +1 -1
- package/docs/workflow/ai-workflow.md +2 -3
- package/docs/workflow/skill-map.md +2 -1
- package/governance/renames.toml +7 -5
- package/governance/rules/canon/630-requirements.md +1 -1
- package/package.json +5 -2
- 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 +87 -0
- package/src/canvas/client/inspector.tsx +121 -0
- package/src/canvas/client/layers.tsx +118 -0
- package/src/canvas/client/main.tsx +62 -0
- package/src/canvas/client/pages-panel.tsx +157 -0
- package/src/canvas/client/shell.css +449 -0
- package/src/canvas/client/state.ts +372 -0
- package/src/canvas/client/surface.tsx +465 -0
- package/src/canvas/content.ts +617 -0
- package/src/canvas/index.html +14 -0
- package/src/canvas/server.ts +471 -0
- package/src/canvas/tokens.ts +71 -0
- package/src/claude/cases/misc.ts +5 -0
- package/src/claude/seeds.ts +0 -1
- package/src/cli.ts +2 -0
- package/src/commands/canvas.ts +522 -0
- package/src/commands/claude.ts +9 -14
- package/src/commands/demo.ts +6 -6
- package/src/commands/migrate.ts +1 -1
- package/src/commands/sandbox.ts +108 -0
- package/src/context/classify/prompts.ts +1 -1
- package/src/context/folders.ts +0 -1
- package/src/demo/compile.ts +8 -4
- package/src/demo/container.ts +1 -1
- package/src/gov/renames.ts +3 -2
- package/src/help.ts +1 -0
- package/src/migrate/rename.ts +5 -4
- package/src/record-root.ts +1 -0
- package/src/sandbox/equivalence.ts +847 -0
- package/src/serve/static.ts +25 -11
- package/src/surface-root.ts +0 -1
- 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/standards/architecture.md +1 -1
- package/standards/context.md +1 -1
- package/standards/design.md +1 -1
- package/standards/index.md +0 -1
- package/standards/requirements.md +4 -6
- package/standards/wireframes.md +2 -2
- package/tooling/claude/reference.md +0 -1
- package/tooling/claude/seeds/canon/REQUIREMENTS.md +0 -2
- package/governance/rules/canon/645-decisions.md +0 -12
- package/standards/decisions.md +0 -105
- package/tooling/claude/seeds/canon/decisions/index.md +0 -8
package/src/serve/static.ts
CHANGED
|
@@ -13,7 +13,7 @@ export const SERVE_HOST = '127.0.0.1'
|
|
|
13
13
|
export const DEFAULT_PORT = 8787
|
|
14
14
|
|
|
15
15
|
/** How far past the requested port to look before refusing. */
|
|
16
|
-
const PORT_ATTEMPTS = 20
|
|
16
|
+
export const PORT_ATTEMPTS = 20
|
|
17
17
|
|
|
18
18
|
const DEFAULT_ENTRY = 'index.html'
|
|
19
19
|
|
|
@@ -216,23 +216,23 @@ export function shouldWalkPast(error: unknown): boolean {
|
|
|
216
216
|
return (error as NodeJS.ErrnoException | null)?.code === 'EADDRINUSE'
|
|
217
217
|
}
|
|
218
218
|
|
|
219
|
+
type BoundServer = ReturnType<typeof Bun.serve>
|
|
220
|
+
|
|
219
221
|
/**
|
|
220
222
|
* Picks a listening port, starting at the requested one and walking forward.
|
|
221
223
|
* A busy port is the ordinary case rather than a failure, since a preview of
|
|
222
224
|
* one workspace is routinely open while another is started.
|
|
225
|
+
*
|
|
226
|
+
* Takes the bind as a parameter so every surface that serves walks ports the
|
|
227
|
+
* same way, whatever routes it binds.
|
|
223
228
|
*/
|
|
224
|
-
function
|
|
225
|
-
root: string,
|
|
229
|
+
export function bindFirstFree(
|
|
226
230
|
first: number,
|
|
227
|
-
|
|
228
|
-
): { server:
|
|
231
|
+
bind: (port: number) => BoundServer,
|
|
232
|
+
): { server: BoundServer; port: number } | undefined {
|
|
229
233
|
for (let port = first; port < first + PORT_ATTEMPTS; port++) {
|
|
230
234
|
try {
|
|
231
|
-
const server =
|
|
232
|
-
hostname: SERVE_HOST,
|
|
233
|
-
port,
|
|
234
|
-
fetch: (request) => respond(root, request, index),
|
|
235
|
-
})
|
|
235
|
+
const server = bind(port)
|
|
236
236
|
/**
|
|
237
237
|
* The bound port rather than the requested one. Port 0 asks the OS to
|
|
238
238
|
* choose, so reporting the request builds a URL pointing at nothing.
|
|
@@ -245,7 +245,7 @@ function listen(
|
|
|
245
245
|
* Contention is the one cause worth walking past. A permission failure
|
|
246
246
|
* or an unavailable interface swallowed here would be retried twenty
|
|
247
247
|
* times and then reported as a port range being full, which names a
|
|
248
|
-
* cause nothing checked.
|
|
248
|
+
* cause nothing checked. The caller turns the rethrow into a refusal.
|
|
249
249
|
*/
|
|
250
250
|
if (!shouldWalkPast(error)) throw error
|
|
251
251
|
}
|
|
@@ -253,6 +253,20 @@ function listen(
|
|
|
253
253
|
return undefined
|
|
254
254
|
}
|
|
255
255
|
|
|
256
|
+
function listen(
|
|
257
|
+
root: string,
|
|
258
|
+
first: number,
|
|
259
|
+
index: boolean,
|
|
260
|
+
): { server: BoundServer; port: number } | undefined {
|
|
261
|
+
return bindFirstFree(first, (port) =>
|
|
262
|
+
Bun.serve({
|
|
263
|
+
hostname: SERVE_HOST,
|
|
264
|
+
port,
|
|
265
|
+
fetch: (request) => respond(root, request, index),
|
|
266
|
+
}),
|
|
267
|
+
)
|
|
268
|
+
}
|
|
269
|
+
|
|
256
270
|
export async function respond(
|
|
257
271
|
root: string,
|
|
258
272
|
request: Request,
|
package/src/surface-root.ts
CHANGED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
type BrowserFunction = (...args: never[]) => unknown
|
|
2
|
+
|
|
3
|
+
const transpiler = new Bun.Transpiler({ loader: 'ts' })
|
|
4
|
+
|
|
5
|
+
const MODULE_SYNTAX = /^\s*(import|export)\b/m
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* `</script>` inside a serialized argument would end the block early, and
|
|
9
|
+
* `<` is the same character to the JavaScript parser.
|
|
10
|
+
*/
|
|
11
|
+
function serialize(args: readonly unknown[]): string {
|
|
12
|
+
return JSON.stringify(args).slice(1, -1).replace(/</g, '\\u003c')
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Turns a browser module's function into the `<script>` a page carries. The
|
|
17
|
+
* transpiler does not bundle, so a module is self-contained: whatever it
|
|
18
|
+
* calls but does not define arrives as a named function in `helpers`, and a
|
|
19
|
+
* value it needs arrives in `args`. Output holding module syntax throws, since
|
|
20
|
+
* a script block with an `import` fails in the browser with nothing in the
|
|
21
|
+
* type checker to say so.
|
|
22
|
+
*/
|
|
23
|
+
export function compileScript(
|
|
24
|
+
run: BrowserFunction,
|
|
25
|
+
args: readonly unknown[] = [],
|
|
26
|
+
helpers: readonly BrowserFunction[] = [],
|
|
27
|
+
): string {
|
|
28
|
+
const source = helpers.length
|
|
29
|
+
? `(function () {\n${helpers.map(String).join('\n')}\nreturn ${run};\n})()`
|
|
30
|
+
: String(run)
|
|
31
|
+
// A bare expression statement is dead code to the transpiler and comes back
|
|
32
|
+
// empty, so the source rides an export that it must keep.
|
|
33
|
+
const code = transpiler
|
|
34
|
+
.transformSync(`export default ${source}`)
|
|
35
|
+
.trim()
|
|
36
|
+
.replace(/^export default /, '')
|
|
37
|
+
.replace(/;$/, '')
|
|
38
|
+
|
|
39
|
+
if (MODULE_SYNTAX.test(code)) {
|
|
40
|
+
throw new Error(`Browser module ${run.name} left import or export syntax`)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return `<script>(${code})(${serialize(args)})</script>`
|
|
44
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
export function dismiss(): void {
|
|
2
|
+
var MENUS = 'details.jump[open], details.sb-ws[open]'
|
|
3
|
+
|
|
4
|
+
function close(except: Element | null) {
|
|
5
|
+
document.querySelectorAll<HTMLDetailsElement>(MENUS).forEach(function (d) {
|
|
6
|
+
if (d !== except) d.open = false
|
|
7
|
+
})
|
|
8
|
+
}
|
|
9
|
+
document.addEventListener('click', function (e) {
|
|
10
|
+
var inside = (e.target as Element).closest('details.jump, details.sb-ws')
|
|
11
|
+
close(inside)
|
|
12
|
+
})
|
|
13
|
+
document.addEventListener('keydown', function (e) {
|
|
14
|
+
if (e.key !== 'Escape') return
|
|
15
|
+
var open = document.querySelector<HTMLDetailsElement>(MENUS)
|
|
16
|
+
if (!open) return
|
|
17
|
+
open.open = false
|
|
18
|
+
var s = open.querySelector('summary')
|
|
19
|
+
if (s) s.focus()
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
/* Hover intent on the breadcrumb only. The sidebar's workspace switcher stays
|
|
23
|
+
click-only on purpose: its panel opens directly over the lesson list, which
|
|
24
|
+
is where the pointer is headed, so hovering it would cover the thing
|
|
25
|
+
being reached for. The breadcrumb's panel drops over body text instead. */
|
|
26
|
+
var OPEN = 120,
|
|
27
|
+
SHUT = 260
|
|
28
|
+
|
|
29
|
+
document
|
|
30
|
+
.querySelectorAll<HTMLDetailsElement>('details.jump')
|
|
31
|
+
.forEach(function (d) {
|
|
32
|
+
var host = d.closest('.crumb-item') || d
|
|
33
|
+
var timer: ReturnType<typeof setTimeout> | undefined
|
|
34
|
+
var openedByHover = false
|
|
35
|
+
|
|
36
|
+
function arm(want: boolean) {
|
|
37
|
+
clearTimeout(timer)
|
|
38
|
+
timer = setTimeout(
|
|
39
|
+
function () {
|
|
40
|
+
if (want) close(d)
|
|
41
|
+
d.open = want
|
|
42
|
+
},
|
|
43
|
+
want ? OPEN : SHUT,
|
|
44
|
+
)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
host.addEventListener('mouseenter', function () {
|
|
48
|
+
if (!d.open) openedByHover = true
|
|
49
|
+
arm(true)
|
|
50
|
+
})
|
|
51
|
+
host.addEventListener('mouseleave', function () {
|
|
52
|
+
openedByHover = false
|
|
53
|
+
arm(false)
|
|
54
|
+
})
|
|
55
|
+
/* Keeps it open while the pointer is inside the panel, without cancelling a
|
|
56
|
+
pending open, which is what silently disabled this on one of two menus. */
|
|
57
|
+
d.addEventListener('mouseenter', function () {
|
|
58
|
+
if (d.open) clearTimeout(timer)
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
/* Hover and click were wired to one disclosure and fought: hover opened the
|
|
62
|
+
panel, then the summary's native click toggled what hover had already
|
|
63
|
+
opened, so reaching for an item shut it. A click on a panel hover opened
|
|
64
|
+
keeps it open, and a second click closes. The click path stays live for
|
|
65
|
+
touch, where no hover exists, and for the keyboard, where Enter is the
|
|
66
|
+
only way in. */
|
|
67
|
+
var summary = d.querySelector('summary')
|
|
68
|
+
if (summary)
|
|
69
|
+
summary.addEventListener('click', function (e) {
|
|
70
|
+
if (d.open && openedByHover) {
|
|
71
|
+
e.preventDefault()
|
|
72
|
+
openedByHover = false
|
|
73
|
+
}
|
|
74
|
+
})
|
|
75
|
+
})
|
|
76
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The outline rail's focus-line ramp. It runs from near the top at scroll 0 to
|
|
3
|
+
* the viewport's bottom edge at max scroll, so the last heading is reachable
|
|
4
|
+
* however little content trails it. A ramp ending 120px short of the edge left
|
|
5
|
+
* a heading with under 120px after it permanently unmarked, since its top never
|
|
6
|
+
* fell below the line even at max scroll.
|
|
7
|
+
*
|
|
8
|
+
* The sidebar script embeds this function's own source, so a test exercises
|
|
9
|
+
* what the browser runs.
|
|
10
|
+
*/
|
|
11
|
+
export function focusLine(
|
|
12
|
+
scrollY: number,
|
|
13
|
+
max: number,
|
|
14
|
+
innerHeight: number,
|
|
15
|
+
): number {
|
|
16
|
+
if (max <= 0) return innerHeight
|
|
17
|
+
var progress = Math.min(1, Math.max(0, scrollY / max))
|
|
18
|
+
return 120 + progress * Math.max(0, innerHeight - 120)
|
|
19
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
export function glossaryFilter(): void {
|
|
2
|
+
var input = document.getElementById('gfilter') as HTMLInputElement | null
|
|
3
|
+
var list = document.getElementById('gloss')
|
|
4
|
+
var count = document.getElementById('gloss-count')
|
|
5
|
+
if (!input || !list) return
|
|
6
|
+
var field = input
|
|
7
|
+
var terms = list
|
|
8
|
+
function updateGroups() {
|
|
9
|
+
terms.querySelectorAll('.gloss-group').forEach(function (heading) {
|
|
10
|
+
var el = heading.nextElementSibling as HTMLElement | null
|
|
11
|
+
var any = false
|
|
12
|
+
while (el && !el.classList.contains('gloss-group')) {
|
|
13
|
+
if (el.style.display !== 'none') any = true
|
|
14
|
+
el = el.nextElementSibling as HTMLElement | null
|
|
15
|
+
}
|
|
16
|
+
;(heading as HTMLElement).style.display = any ? '' : 'none'
|
|
17
|
+
})
|
|
18
|
+
}
|
|
19
|
+
field.addEventListener('input', function () {
|
|
20
|
+
var q = field.value.toLowerCase()
|
|
21
|
+
var n = 0
|
|
22
|
+
terms.querySelectorAll<HTMLElement>('.gterm').forEach(function (entry) {
|
|
23
|
+
var match = (entry.textContent as string).toLowerCase().includes(q)
|
|
24
|
+
entry.style.display = match ? '' : 'none'
|
|
25
|
+
if (match) n++
|
|
26
|
+
})
|
|
27
|
+
terms.classList.toggle('none', n === 0)
|
|
28
|
+
updateGroups()
|
|
29
|
+
if (count) {
|
|
30
|
+
var total = terms.querySelectorAll('.gterm').length
|
|
31
|
+
count.textContent =
|
|
32
|
+
(n === total ? total : n + ' of ' + total) +
|
|
33
|
+
(total === 1 ? ' term' : ' terms')
|
|
34
|
+
}
|
|
35
|
+
})
|
|
36
|
+
var clear = terms.querySelector('.clear')
|
|
37
|
+
if (clear) {
|
|
38
|
+
clear.addEventListener('click', function () {
|
|
39
|
+
field.value = ''
|
|
40
|
+
field.dispatchEvent(new Event('input'))
|
|
41
|
+
field.focus()
|
|
42
|
+
})
|
|
43
|
+
}
|
|
44
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The panel's state, settled before the first paint rather than after it. An
|
|
3
|
+
* arm that set the narrow default afterwards slid the panel in and back out on
|
|
4
|
+
* every load, and a restored custom width animated in from the default.
|
|
5
|
+
*
|
|
6
|
+
* A listing page starts shut because the body already lists what the panel
|
|
7
|
+
* would, and a lesson starts open because there the panel is the only
|
|
8
|
+
* cross-lesson navigation on the page. A stored preference beats both. A count
|
|
9
|
+
* threshold on its own was built and reverted, since it hides the panel on a
|
|
10
|
+
* lesson too.
|
|
11
|
+
*/
|
|
12
|
+
export function head(page: 'index' | 'lesson', breakpoint: number): void {
|
|
13
|
+
var r = document.documentElement
|
|
14
|
+
var s: string | null = null
|
|
15
|
+
r.dataset.page = page
|
|
16
|
+
if (matchMedia('(max-width: ' + breakpoint + 'px)').matches) {
|
|
17
|
+
r.classList.add('sb-shut')
|
|
18
|
+
return
|
|
19
|
+
}
|
|
20
|
+
try {
|
|
21
|
+
s = localStorage.getItem('teach-sb')
|
|
22
|
+
} catch (e) {}
|
|
23
|
+
var idx = r.dataset.page === 'index'
|
|
24
|
+
if (s === 'shut' || (s === null && idx)) r.classList.add('sb-shut')
|
|
25
|
+
var w: string | null = null
|
|
26
|
+
try {
|
|
27
|
+
w = localStorage.getItem('teach-sb-w')
|
|
28
|
+
} catch (e) {}
|
|
29
|
+
if (w) r.style.setProperty('--sb-w', w + 'px')
|
|
30
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What reveals feedback in a lesson written with button options. The radio
|
|
3
|
+
* shape needs none of it.
|
|
4
|
+
*/
|
|
5
|
+
export function quiz(): void {
|
|
6
|
+
document.querySelectorAll<HTMLElement>('.q').forEach(function (q) {
|
|
7
|
+
var f = q.querySelector('.fb') as HTMLElement
|
|
8
|
+
q.querySelectorAll<HTMLElement>('.opt').forEach(function (b) {
|
|
9
|
+
b.addEventListener('click', function () {
|
|
10
|
+
if (f.classList.contains('show')) return
|
|
11
|
+
q.querySelectorAll<HTMLElement>('.opt').forEach(function (o) {
|
|
12
|
+
o.dataset.state =
|
|
13
|
+
o.dataset.a === '1' ? 'right' : o === b ? 'chosen' : 'wrong'
|
|
14
|
+
})
|
|
15
|
+
f.classList.add('show')
|
|
16
|
+
})
|
|
17
|
+
})
|
|
18
|
+
})
|
|
19
|
+
}
|
|
@@ -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
|
+
}
|