@erclx/canon 4.111.0 → 4.113.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.
@@ -0,0 +1,10 @@
1
+ <!-- A bound ledger: two pages fanned open around a spine, drawn as three
2
+ solid masses rather than thin strokes, so the shape survives being
3
+ rendered at favicon size instead of averaging into grey. The 10-unit
4
+ gap between each page and the spine, against an 80-unit frame, is what
5
+ keeps the split a visible channel at 16px; a first draft at half that
6
+ gap closed into an unreadable blob at the same size. -->
7
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="10 10 80 80">
8
+ <path d="M34,20 L15,28 L15,72 L34,80 Z M66,20 L85,28 L85,72 L66,80 Z" fill="currentColor" />
9
+ <rect x="44" y="15" width="12" height="70" rx="2" fill="currentColor" />
10
+ </svg>
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "canon",
3
3
  "description": "Automated governance, versioning, and discovery tools for Claude Code.",
4
- "version": "4.111.0",
4
+ "version": "4.113.0",
5
5
  "author": {
6
6
  "name": "Eric Le",
7
7
  "url": "https://github.com/erclx"
@@ -21,6 +21,7 @@ Review is the step that varies most. It gets skipped on a diff that needed one,
21
21
  - Own the review receipt's lifetime, since this chain writes it, cites it in its own closing block, and is the only body that can read whether the step keeping it is still using it
22
22
  - Delegate the ship sequence to `git-ship` rather than restating it, and name only what this chain adds to it
23
23
  - Open the pull request as a draft before the continuous integration watch begins, since a pull request marked after it is mergeable for the length of the run
24
+ - Prove the draft target's head is the current branch and its state is open before marking it, and stop with nothing marked on a mismatch, since the number is retyped between steps and a draft mark on a release pull request holds that release
24
25
  - Name the recovery for the stop it took, since the value of stopping is that the user knows where to resume
25
26
 
26
27
  ## Must not
@@ -190,14 +190,23 @@ This chain owns the receipt's lifetime, which is what makes the Output block's c
190
190
 
191
191
  Invoke `canon:git-ship`. That body owns the sequence, being the verify gate, memory capture, both doc syncs, staging, the commit grouping, the branch rename, the pull request, and the CI watch, along with the reason each step sits where it does. This step used to restate that list and the two drifted apart with nothing comparing them, so read the order there and never here.
192
192
 
193
- One thing this chain adds. Mark the pull request as a draft as soon as `git-ship`'s pull request step returns, ahead of its CI watch, then read the flag back:
193
+ One thing this chain adds. Mark the pull request as a draft as soon as `git-ship`'s pull request step returns, ahead of its CI watch. Run it as one command, where the shell proves the target before the mark and reads the flag back after it:
194
194
 
195
195
  ```bash
196
+ target=$(gh pr view <number> --json headRefName,state --jq '"\(.headRefName) \(.state)"') || exit 1
197
+ if [ "$target" != "$(git branch --show-current) OPEN" ]; then
198
+ printf '❌ #%s reads "%s", not "%s OPEN". The draft mark was not written.\n' "<number>" "$target" "$(git branch --show-current)" >&2
199
+ exit 1
200
+ fi
196
201
  gh pr ready --undo <number>
197
202
  gh pr view <number> --json isDraft
198
203
  ```
199
204
 
200
- Name the number `git-ship`'s pull request step returned on both calls rather than leaving either to resolve by branch. `${CLAUDE_SKILL_DIR}/../git-pr/REQUIREMENT.md` states why: a lookup that resolves by branch alone can return a closed pull request sharing that head, so the number is resolved once and reused rather than re-derived.
205
+ Name the number `git-ship`'s pull request step returned in every position rather than leaving any to resolve by branch. `${CLAUDE_SKILL_DIR}/../git-pr/REQUIREMENT.md` states why: a lookup that resolves by branch alone can return a closed pull request sharing that head, so the number is resolved once and reused rather than re-derived.
206
+
207
+ A refusal is a stop. Report the line the shell printed, then resolve the number from the `number=` and `head=` lines of `git-pr`'s output and re-run the command by hand. The comparison sits in the shell rather than in the session's reading of a printed value, which is the same split `git-pr`'s `assert_own_pr` makes, because the judgment that picked a wrong number is the one that would read the check.
208
+
209
+ The number crosses from that step's output into a command this session types, which is the one place a number gets authored rather than derived. A number misread there, or inferred from the newest pull request in view, would draft a stranger's pull request. A release pull request is the worst such target, since the mark holds the release and this role may not lift it again.
201
210
 
202
211
  Report what the read returned rather than what the command printed, since the exit says the call ran and says nothing about the state. A `true` reports a draft. A `false` reports the pull request as opened ready and unsupervised, and the chain stops there. Never re-issue the undo on a disagreeing read, which fights whoever readied it instead of guarding anything.
203
212
 
@@ -243,3 +252,4 @@ Every stop point leaves recoverable state. The user resumes manually from the ap
243
252
  | Inherited review findings | Fix findings, run `/git-ship` |
244
253
  | Self-introduced finding survived | Read the receipt for what the one repair pass left open, fix it, run `/git-ship` |
245
254
  | git-ship fails | Inspect hook or remote error, run again |
255
+ | Draft target is not this branch's | Nothing was marked. Read the number and `head=` from `git-pr`'s output, confirm they match, then run the mark by hand |
@@ -5,7 +5,7 @@ description: Format a report about something broken, missing, or off in canon an
5
5
 
6
6
  # Canon feedback
7
7
 
8
- Format a `## Toolkit feedback` block from the current session, then open it as a GitHub issue labeled `feedback` on the toolkit repo. `canon-feedback-triage` reads that queue.
8
+ Format a `## Toolkit feedback` block from the current session, then open it as a GitHub issue on the toolkit repo, labeled `feedback` plus the domain label its surface type names. `canon-feedback-triage` reads that queue.
9
9
 
10
10
  ## Guards
11
11
 
@@ -18,7 +18,7 @@ Format a `## Toolkit feedback` block from the current session, then open it as a
18
18
  From the conversation so far, identify:
19
19
 
20
20
  - Target project name or kind, never its full path. The path names a folder on one machine and says nothing a triage session can route on, where the project's own name does. Name a private project by its kind instead, such as `a Next.js app`, since the report leaves the machine.
21
- - Toolkit surface and its type (plugin skill, snippet, tooling config, governance rule, seed, or CLI)
21
+ - Toolkit surface and its type (plugin skill, snippet, tooling config, governance rule, seed, or CLI). The type leads the `### Surface` field, ahead of the first comma, because the CLI picks the issue's domain labels from that text alone.
22
22
  - Specific toolkit file or name when the session cites one
23
23
  - Observed behavior
24
24
  - Expected behavior, or `unclear`
@@ -81,7 +81,7 @@ cat <<'EOF' | canon feedback --github
81
81
  EOF
82
82
  ```
83
83
 
84
- The CLI opens a GitHub issue labeled `feedback` on the toolkit repo and prints the issue URL, which this skill reports back on its own line. It needs `gh` authenticated. `git-issue` is not the route, since it files on the current repository and this report belongs on the toolkit's.
84
+ The CLI opens a GitHub issue on the toolkit repo, labeled `feedback` and each domain label the surface type names, such as `skills` for a plugin skill, and prints the issue URL, which this skill reports back on its own line. It needs `gh` authenticated. `git-issue` is not the route, since it files on the current repository and this report belongs on the toolkit's.
85
85
 
86
86
  From a toolkit checkout, a call that produces no URL falls back to `.canon/feedback/feedback-<slug>-<ts>.md` and prints the reason on stderr. Say so in the same reply, naming the file and the reason, since a silent fallback leaves the report where nothing reads it. A target project has no such folder, so a failed call there prints the block in chat.
87
87
 
@@ -23,6 +23,8 @@ A lookup that resolves by head branch alone carries its own failure. A branch na
23
23
  - Detect an open pull request and edit it in place, so a follow-up push keeps the body in sync instead of failing
24
24
  - Scope that detection to an open pull request on the current head and the default base, so neither a reused branch name nor a second base resolves the wrong one
25
25
  - Resolve the pull request once and reuse what that resolution returned, so the number recorded never depends on how a lookup ranks two pull requests sharing a head
26
+ - Prove before every write to an existing pull request that its head is the current branch and its state is open, and refuse with nothing written on a mismatch, so a wrong number reaching a write never lands on another branch's pull request
27
+ - Print the head branch beside the number, so a caller writing to the pull request later has a value to check the number against
26
28
  - Label from the paths the branch changed, against a map the project declares, so the label set belongs to the project rather than to the skill
27
29
  - Apply labels after the pull request exists, so a label the remote does not carry costs a warning rather than the pull request
28
30
  - Report a refused label, since a warning nothing surfaces leaves the run indistinguishable from one that labelled
@@ -135,23 +135,41 @@ This command reuses `.canon/tmp/pr/body.md`, which the pre-publish scan above al
135
135
 
136
136
  ```bash
137
137
  pr_labels="<comma-separated labels, empty when the map resolves to nothing>"
138
+ head_branch=$(git branch --show-current)
138
139
  git push -u origin HEAD || exit 1
139
140
  base_branch=$(gh repo view --json defaultBranchRef --jq .defaultBranchRef.name) || exit 1
140
- pr_number=$(gh pr list --head "$(git branch --show-current)" --base "$base_branch" --state open --json number --jq '.[0].number // empty')
141
+ assert_own_pr() {
142
+ target=$(gh pr view "$1" --json headRefName,state --jq '"\(.headRefName) \(.state)"') || exit 1
143
+ if [ "$target" != "$head_branch OPEN" ]; then
144
+ printf 'Refused: #%s reads "%s", not "%s OPEN". Nothing was written to it.\n' "$1" "$target" "$head_branch" >&2
145
+ exit 1
146
+ fi
147
+ }
148
+ pr_number=$(gh pr list --head "$head_branch" --base "$base_branch" --state open --json number --jq '.[0].number // empty')
141
149
  if [ -n "$pr_number" ]; then
150
+ assert_own_pr "$pr_number"
142
151
  pr_url=$(gh pr edit "$pr_number" --title "<title>" --body-file .canon/tmp/pr/body.md) || exit 1
143
152
  else
144
153
  pr_url=$(gh pr create --title "<title>" --body-file .canon/tmp/pr/body.md) || exit 1
145
154
  pr_number=${pr_url##*/}
155
+ assert_own_pr "$pr_number"
146
156
  fi
147
157
  if [ -n "$pr_labels" ]; then
148
158
  gh pr edit "$pr_number" --add-label "$pr_labels" >/dev/null ||
149
159
  printf 'Label apply failed. Create a missing label with: gh label create <name>\n' >&2
150
160
  fi
151
161
  rm -rf .canon/tmp/pr/body
152
- printf 'number=%s\nurl=%s\n' "$pr_number" "$pr_url"
162
+ printf 'number=%s\nurl=%s\nhead=%s\n' "$pr_number" "$pr_url" "$head_branch"
153
163
  ```
154
164
 
165
+ ### Binding every write to this branch's pull request
166
+
167
+ `assert_own_pr` runs ahead of both writes this command makes to a pull request that already exists, the title and body edit and the label edit. It reads the target's head branch and state and refuses unless they are this branch and `OPEN`. A refusal exits before anything is written, so a wrong number costs the run rather than a stranger's pull request. The function lives only inside this command, so the steps below that post comments or record the task are covered by taking `<number>` from this output rather than by the check itself.
168
+
169
+ The check compares a number against a branch and never derives a number from one, so it adds no lookup of the kind `### Resolving the pull request` retired. It holds whichever way a wrong number arrives. A number a session retyped by hand, or inferred from the newest pull request in view, reads as a foreign head here and stops.
170
+
171
+ The last output line carries `head=` so a caller relaying the number holds a branch to compare it against rather than a bare integer. A caller that writes to the pull request itself, such as a draft mark, runs the same comparison in the shell first, reading `gh pr view <number> --json headRefName,state` and refusing unless it matches `head` and `OPEN`.
172
+
155
173
  ### Find the UI checklist
156
174
 
157
175
  `ui-checklist` writes a visual checklist to `.canon/tmp/handoff/ui-checklist/<slug>.md` at the main worktree root when a change needs visual verification, with `<slug>` derived per `${CLAUDE_SKILL_DIR}/../../standards/slug.md`. This skill is the file's sole consumer. Resolve the main root the way `session-worktree` does (`git worktree list --porcelain | grep -m 1 '^worktree ' | cut -d' ' -f2-`, falling back to `pwd`) and check for the file there. A missing file means no checklist was produced, and the two steps below each skip their checklist half.
@@ -238,7 +256,7 @@ canon pr evidence <number> --preview <url> --json
238
256
 
239
257
  ### Record the number on the task
240
258
 
241
- Write the `number` the final command printed onto the task the branch is closing. Do not resolve it again. `${CLAUDE_SKILL_DIR}/REQUIREMENT.md` states why: a lookup that resolves by branch alone can return a closed pull request sharing that head, so the number is resolved once and reused rather than re-derived.
259
+ Write the `number` the final command printed onto the task the branch is closing. Do not resolve it again. A final command that refused on a head mismatch printed no number, so this step writes nothing on that run. `${CLAUDE_SKILL_DIR}/REQUIREMENT.md` states why: a lookup that resolves by branch alone can return a closed pull request sharing that head, so the number is resolved once and reused rather than re-derived.
242
260
 
243
261
  The task is the one whose `Plan:` line names the plan this branch implemented. Name that plan by its file, which is `.canon/plans/feature-<slug>.md` at the main worktree root with `<slug>` derived per `${CLAUDE_SKILL_DIR}/../../standards/slug.md`. `plan-feature` writes the plan under the branch slug, so the two correspond on any branch that came through the plan-to-execute path. When the session already knows which plan it implemented, because a caller read it earlier in the chain, use that filename instead of re-deriving.
244
262
 
@@ -18,7 +18,7 @@ The shape of the workspace is fixed by `${CLAUDE_SKILL_DIR}/../../standards/teac
18
18
  - Write nothing outside the workspace folder, apart from the one handoff file Step 6 names. A durable page stays in `reference/` and is copied out by the skill that owns the destination, never by this one.
19
19
  - Do not open a second workspace on a subject one already covers. Resume that one.
20
20
  - Never promote a lesson. It is generated markup carrying a quiz and a learner, and no request makes it promotable.
21
- - Carry the toolkit's favicon on every page this skill writes, index and lesson alike, in the `<head>` verbatim: `<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" />`
21
+ - Never write a favicon link by hand. `canon teach nav` puts the toolkit's icon on every page it generates and inside a lesson's `style` region, so a hand-written one in a lesson's `<head>` gives the page two.
22
22
 
23
23
  ## Step 0: let the CLI resolve the workspace root
24
24
 
@@ -172,7 +172,9 @@ canon teach nav regular-expressions --json
172
172
 
173
173
  With no topic it rewrites every workspace. The teach-root listing is always rewritten regardless, since it reports on every workspace and a scoped run leaving it stale would be a second kind of drift this verb exists to end.
174
174
 
175
- A lesson carries its chrome as four marker pairs the authoring skill writes empty: `canon:teach:style`, `canon:teach:header`, `canon:teach:footnav`, and `canon:teach:scripts`. This verb splices each one from the current workspace state, embedding the shared stylesheet and the script that settles the course panel before the page paints, rebuilding the course sidebar, the breadcrumb and jump menus, the prev/next footer nav, and the behavior scripts, and leaves the authored `<h1>`, lede, body, and quiz between the header and the footnav untouched.
175
+ A lesson carries its chrome as four marker pairs the authoring skill writes empty: `canon:teach:style`, `canon:teach:header`, `canon:teach:footnav`, and `canon:teach:scripts`. This verb splices each one from the current workspace state, embedding the toolkit's favicon, the shared stylesheet, and the script that settles the course panel before the page paints, rebuilding the course sidebar, the breadcrumb and jump menus, the prev/next footer nav, and the behavior scripts, and leaves the authored `<h1>`, lede, body, and quiz between the header and the footnav untouched.
176
+
177
+ It also deletes the one icon link the authoring skill used to have a session write by hand into a lesson's `<head>`, matched as that exact string, so the page keeps only the icon the `style` region carries. Any other link an author wrote stays.
176
178
 
177
179
  A workspace carrying no `course.css` gets its stylesheet pair seeded first, so the contents page it links is never unstyled, and an existing `course.css` is never overwritten. The embedded copy drops the seed's `@import` line, which resolved against the lesson's own folder and never loaded anything.
178
180
 
@@ -196,6 +198,8 @@ No `canon teach` verb serves the workspace. `canon serve` does, taking the teach
196
198
  canon serve .canon/teach --entry 03-fde-system-design/index.html --json
197
199
  ```
198
200
 
201
+ `canon teach list <topic>` ends its human output with this line for that workspace, with the teach folder written relative to the cwd, or absolute when it sits outside it, since `canon serve` resolves its directory against the cwd while the list verb reads the main worktree root. The JSON record carries no such field.
202
+
199
203
  It stays general rather than becoming `canon teach serve`, because nothing about serving a directory is specific to a learning workspace, and the same verb carries a slide render and a design preview.
200
204
 
201
205
  Read `url` off the record rather than building one from the port that was asked for. The verb walks forward past a port already in use, which is routine when a second workspace is already open, and the port it took is the one thing a composed URL gets wrong.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@erclx/canon",
3
3
  "type": "module",
4
- "version": "4.111.0",
4
+ "version": "4.113.0",
5
5
  "description": "Infrastructure and quality tooling for developer workflows",
6
6
  "license": "MIT",
7
7
  "bin": {
@@ -17,6 +17,7 @@
17
17
  "claude",
18
18
  "docs",
19
19
  "tsconfig.json",
20
+ "assets/brand/mark.svg",
20
21
  "!scripts/sandbox",
21
22
  "!scripts/eval",
22
23
  "!**/*.test.ts",
@@ -48,6 +49,8 @@
48
49
  "web:previews": "bun scripts/core/regen-web-previews.ts",
49
50
  "web:build": "bun run web:tokens && bun run web:favicon && bun run web:previews && cd web && astro check && astro build",
50
51
  "web:gallery": "cd web && astro build --config gallery.config.mjs",
52
+ "web:card": "cd web && astro dev --config card.config.mjs",
53
+ "web:card:check": "PROJECT_ROOT=web bash tooling/astro/configs/scripts/check-card-exclusion.sh",
51
54
  "web:preview": "cd web && astro preview",
52
55
  "web:e2e": "cd web && playwright test"
53
56
  },
@@ -1,10 +1,6 @@
1
1
  import { existsSync } from 'node:fs'
2
2
  import { dirname, join } from 'node:path'
3
- import {
4
- type Frontmatter,
5
- parseFrontmatter,
6
- readField,
7
- } from '@/indexes/frontmatter'
3
+ import { parseFrontmatter, readField } from '@/indexes/frontmatter'
8
4
 
9
5
  /** Returned when a skill folder carries no `REQUIREMENT.md`, the gating check. */
10
6
  export const EXIT_MISSING_REQUIREMENT = 2
@@ -149,7 +145,7 @@ async function readSkill(
149
145
  : Promise.resolve(undefined),
150
146
  ])
151
147
 
152
- const fields = readFields(body)
148
+ const fields = parseFrontmatter(body)
153
149
 
154
150
  return {
155
151
  rel: join(rel, folder),
@@ -162,19 +158,6 @@ async function readSkill(
162
158
  }
163
159
  }
164
160
 
165
- /**
166
- * Returns undefined rather than throwing on unparseable YAML, so one malformed
167
- * body reports as missing its required fields instead of ending the audit
168
- * before it reaches the rest of the corpus.
169
- */
170
- function readFields(body: string): Frontmatter | undefined {
171
- try {
172
- return parseFrontmatter(body)
173
- } catch {
174
- return undefined
175
- }
176
- }
177
-
178
161
  /**
179
162
  * A key carrying an empty value declares nothing, so blank reads as absent
180
163
  * rather than as a name that disagrees with every folder.
@@ -89,6 +89,30 @@ export function deriveSlug(body: string): string {
89
89
  return slug || SLUG_FALLBACK
90
90
  }
91
91
 
92
+ /**
93
+ * Each label must exist on the toolkit repository, because `gh issue create`
94
+ * refuses the whole issue on an unknown label rather than dropping it.
95
+ */
96
+ const DOMAIN_LABELS: readonly { pattern: RegExp; label: string }[] = [
97
+ { pattern: /\bskills?\b/, label: 'skills' },
98
+ { pattern: /\b(?:cli)s?\b/, label: 'cli' },
99
+ { pattern: /\b(?:tooling|seeds?)\b/, label: 'tooling' },
100
+ { pattern: /\b(?:governance)s?\b/, label: 'governance' },
101
+ { pattern: /\bsnippets?\b/, label: 'snippets' },
102
+ ]
103
+
104
+ /**
105
+ * Reads only the surface type, the text before the first comma, so a path or
106
+ * name after it never adds a label.
107
+ */
108
+ export function deriveDomainLabels(body: string): string[] {
109
+ const type = (surfaceField(body) ?? '').split(',')[0]?.toLowerCase() ?? ''
110
+ const labels = DOMAIN_LABELS.filter(({ pattern }) => pattern.test(type)).map(
111
+ ({ label }) => label,
112
+ )
113
+ return [...new Set(labels)]
114
+ }
115
+
92
116
  export function deriveTitle(body: string): string {
93
117
  const surface = surfaceField(body)
94
118
  const title = surface ? `feedback: ${surface}` : 'toolkit feedback'
@@ -2,6 +2,7 @@ import { existsSync, mkdirSync, writeFileSync } from 'node:fs'
2
2
  import { join } from 'node:path'
3
3
  import type { Command } from 'commander'
4
4
  import {
5
+ deriveDomainLabels,
5
6
  deriveSlug,
6
7
  deriveTitle,
7
8
  missingField,
@@ -107,7 +108,7 @@ export function register(program: Command): void {
107
108
  const result = await createGithubIssue({
108
109
  title: deriveTitle(body),
109
110
  body,
110
- labels: ['feedback'],
111
+ labels: ['feedback', ...deriveDomainLabels(body)],
111
112
  })
112
113
  if (result.ok) {
113
114
  frameSuccess('canon feedback', result.url)
@@ -1,4 +1,4 @@
1
- import { relative } from 'node:path'
1
+ import { isAbsolute, relative, resolve, sep } from 'node:path'
2
2
  import type { Command } from 'commander'
3
3
  import { type LessonOutcome, planLesson } from '@/teach/lesson'
4
4
  import { type NavOutcome, generateNav } from '@/teach/nav'
@@ -18,6 +18,7 @@ import {
18
18
  type TeachRefused,
19
19
  type Term,
20
20
  type TermOutcome,
21
+ teachDir,
21
22
  type WorkspaceSummary,
22
23
  writeStylesheet,
23
24
  } from '@/teach/workspace'
@@ -91,6 +92,18 @@ export function register(program: Command): void {
91
92
  .command('teach')
92
93
  .description('Manage learning workspaces in .canon/teach/')
93
94
  .helpOption('-h, --help', 'Show this help message')
95
+ .addHelpText(
96
+ 'after',
97
+ [
98
+ '',
99
+ 'Opening a workspace:',
100
+ ' canon serve .canon/teach --entry <nn>-<topic>/index.html',
101
+ '',
102
+ 'canon teach list <topic> prints the exact line, with the teach folder',
103
+ 'written from where you stand rather than as this literal.',
104
+ '',
105
+ ].join('\n'),
106
+ )
94
107
 
95
108
  teach
96
109
  .command('list')
@@ -118,6 +131,9 @@ export function register(program: Command): void {
118
131
  'A workspace not named NN-<topic> is still listed. It sorts last and',
119
132
  'moves no ordinal, since dropping it hides the folder needing a fix.',
120
133
  '',
134
+ 'The human output ends with the canon serve line that opens a',
135
+ 'workspace, a template with no topic and the exact line with one.',
136
+ '',
121
137
  'Examples:',
122
138
  ' canon teach list',
123
139
  ' canon teach list regular-expressions --json',
@@ -852,6 +868,22 @@ function describe(workspace: WorkspaceSummary): string {
852
868
  return `${workspace.slug}: ${workspace.lessons} lesson(s), ${workspace.records} record(s), ${workspace.reference} reference page(s), ${workspace.terms} term(s)`
853
869
  }
854
870
 
871
+ /**
872
+ * `canon serve` resolves its folder against the caller's cwd, while the list
873
+ * verb reads the teach folder at the main worktree root, so the line carries
874
+ * the folder actually read. A literal `.canon/teach` serves an absent folder
875
+ * from a linked worktree.
876
+ */
877
+ function serveLine(root: string, entry: string): string {
878
+ const dir = resolve(teachDir(root))
879
+ const fromCwd = relative(process.cwd(), dir)
880
+ const isOutsideCwd =
881
+ fromCwd === '..' || fromCwd.startsWith(`..${sep}`) || isAbsolute(fromCwd)
882
+ const printed = fromCwd === '' ? '.' : isOutsideCwd ? dir : fromCwd
883
+
884
+ return `canon serve ${printed} --entry ${entry}`
885
+ }
886
+
855
887
  function reportList(
856
888
  outcome: ListOutcome,
857
889
  emitJson: boolean,
@@ -890,6 +922,12 @@ function reportList(
890
922
 
891
923
  logStep('Next ordinal')
892
924
  logInfo(outcome.next)
925
+
926
+ if (outcome.workspaces.length > 0) {
927
+ logStep('Open')
928
+ logInfo(serveLine(root, '<nn>-<topic>/index.html'))
929
+ }
930
+
893
931
  outro()
894
932
 
895
933
  return 0
@@ -940,6 +978,8 @@ function reportWorkspace(
940
978
  logWarn(`no ${workspace.missing.join(' and no ')}`)
941
979
  }
942
980
 
981
+ logStep('Open')
982
+ logInfo(serveLine(root, `${workspace.slug}/index.html`))
943
983
  outro()
944
984
 
945
985
  return 0
@@ -291,7 +291,7 @@ body {
291
291
  color: var(--color-text-body);
292
292
  font-family: var(--teach-sans);
293
293
  font-size: var(--t3);
294
- line-height: 1.62;
294
+ line-height: 1.55;
295
295
  -webkit-font-smoothing: antialiased;
296
296
  }
297
297
 
@@ -393,10 +393,9 @@ html.sb-shut .pane { border-left: 0; }
393
393
 
394
394
  .nav .lbl {
395
395
  display: block;
396
- font-size: 0.6563rem;
396
+ font-size: var(--t6);
397
397
  font-weight: 700;
398
- letter-spacing: 0.09em;
399
- text-transform: uppercase;
398
+ letter-spacing: 0;
400
399
  color: var(--color-muted);
401
400
  margin-bottom: 0.15rem;
402
401
  }
@@ -991,6 +990,9 @@ const TEACH_ARTICLE: Component = {
991
990
  '--color-accent',
992
991
  '--teach-hand',
993
992
  '--teach-mono',
993
+ '--t1',
994
+ '--t2',
995
+ '--t3',
994
996
  '--t4',
995
997
  '--t5',
996
998
  '--t6',
@@ -998,8 +1000,8 @@ const TEACH_ARTICLE: Component = {
998
1000
  rules: `/* ---- Type ---- */
999
1001
 
1000
1002
  h1 {
1001
- font-size: 2.15rem;
1002
- line-height: 1.14;
1003
+ font-size: var(--t1);
1004
+ line-height: 1.1;
1003
1005
  font-weight: 800;
1004
1006
  letter-spacing: -0.022em;
1005
1007
  margin: 0 0 0.55rem;
@@ -1007,19 +1009,20 @@ h1 {
1007
1009
  }
1008
1010
 
1009
1011
  h2 {
1010
- font-size: 1.3rem;
1012
+ font-size: var(--t2);
1013
+ line-height: 1.3;
1011
1014
  font-weight: 700;
1012
1015
  letter-spacing: -0.012em;
1013
1016
  margin: 3rem 0 0.85rem;
1014
1017
  }
1015
1018
 
1016
- h3 { font-size: 1.03rem; font-weight: 700; margin: 1.9rem 0 0.4rem; }
1019
+ h3 { font-size: var(--t3); line-height: 1.55; font-weight: 700; margin: 1.9rem 0 0.4rem; }
1017
1020
 
1018
1021
  p { margin: 0 0 1.15rem; }
1019
1022
 
1020
1023
  .lede {
1021
- font-size: 1.1875rem;
1022
- line-height: 1.5;
1024
+ font-size: var(--t3);
1025
+ line-height: 1.55;
1023
1026
  color: var(--color-text-secondary);
1024
1027
  margin-bottom: 2rem;
1025
1028
  }
@@ -1031,8 +1034,8 @@ em { font-style: italic; }
1031
1034
  /* ---- Panels ---- */
1032
1035
 
1033
1036
  .assumes, .progress {
1034
- font-size: 0.8438rem;
1035
- line-height: 1.7;
1037
+ font-size: var(--t5);
1038
+ line-height: 1.6;
1036
1039
  color: var(--color-text-secondary);
1037
1040
  background: var(--color-surface);
1038
1041
  border-radius: 7px;
@@ -1044,8 +1047,7 @@ em { font-style: italic; }
1044
1047
  display: block;
1045
1048
  color: var(--color-muted);
1046
1049
  font-weight: 700;
1047
- letter-spacing: 0.06em;
1048
- text-transform: uppercase;
1050
+ letter-spacing: 0;
1049
1051
  font-size: var(--t6);
1050
1052
  margin-bottom: 0.2rem;
1051
1053
  }
@@ -1063,7 +1065,7 @@ code {
1063
1065
  pre {
1064
1066
  font-family: var(--teach-mono);
1065
1067
  font-size: var(--t5);
1066
- line-height: 1.7;
1068
+ line-height: 1.6;
1067
1069
  background: var(--color-surface);
1068
1070
  padding: 1.15rem 1.3rem;
1069
1071
  border-radius: 7px;
@@ -1084,7 +1086,7 @@ pre code { background: none; padding: 0; font-size: 1em; }
1084
1086
 
1085
1087
  .hard-label {
1086
1088
  font-family: var(--teach-hand);
1087
- font-size: 1.0625rem;
1089
+ font-size: var(--t3);
1088
1090
  color: var(--color-accent);
1089
1091
  display: block;
1090
1092
  margin-bottom: 0.35rem;
@@ -1094,15 +1096,14 @@ pre code { background: none; padding: 0; font-size: 1em; }
1094
1096
 
1095
1097
  /* ---- Tables ---- */
1096
1098
 
1097
- table { width: 100%; border-collapse: collapse; font-size: 0.875rem; margin: 0 0 1.5rem; }
1099
+ table { width: 100%; border-collapse: collapse; font-size: var(--t4); margin: 0 0 1.5rem; }
1098
1100
  th, td { text-align: right; padding: 0.55rem 0.7rem; border-bottom: 1px solid var(--color-border); }
1099
1101
  th:first-child, td:first-child { text-align: left; }
1100
1102
 
1101
1103
  th {
1102
1104
  font-weight: 700;
1103
- font-size: 0.75rem;
1104
- letter-spacing: 0.04em;
1105
- text-transform: uppercase;
1105
+ font-size: var(--t5);
1106
+ letter-spacing: 0;
1106
1107
  color: var(--color-muted);
1107
1108
  border-bottom-color: var(--color-border);
1108
1109
  }
@@ -1127,26 +1128,22 @@ tbody tr.mark td { background: var(--color-teach-accent-bg); }
1127
1128
  }
1128
1129
 
1129
1130
  .toc a:hover { background: var(--color-surface); }
1130
- .toc .num { font-family: var(--teach-mono); font-size: var(--t5); color: var(--color-accent); }
1131
- .toc b { font-size: 1.0625rem; font-weight: 700; letter-spacing: -0.012em; }
1131
+ .toc .num { font-family: var(--teach-mono); font-size: var(--t5); color: var(--color-muted); }
1132
+ .toc b { font-size: var(--t3); font-weight: 700; letter-spacing: -0.012em; }
1132
1133
 
1133
1134
  .toc .blurb {
1134
1135
  grid-column: 2;
1135
1136
  font-size: var(--t4);
1136
- line-height: 1.5;
1137
+ line-height: 1.55;
1137
1138
  color: var(--color-text-secondary);
1138
1139
  margin-top: 0.1rem;
1139
1140
  }
1140
1141
 
1141
- /* A dot and a word, not a pill. The jump menus already say written and
1142
- planned with a filled or hollow dot, so the bordered uppercase badge was a
1143
- second vocabulary for a fact the design system already had one for. Sentence
1144
- case and no tracking, per the UI copy rule. */
1142
+ /* A word, not a pill and not a dot. A mark reading the same on every live row
1143
+ reports nothing, so the dot retired here as it did from the jump menus.
1144
+ Sentence case and no tracking, per the UI copy rule. */
1145
1145
  .toc .state {
1146
- display: inline-flex;
1147
- align-items: center;
1148
- gap: 0.42rem;
1149
- font-size: 0.75rem;
1146
+ font-size: var(--t5);
1150
1147
  font-weight: 500;
1151
1148
  letter-spacing: 0;
1152
1149
  text-transform: none;
@@ -1154,24 +1151,10 @@ tbody tr.mark td { background: var(--color-teach-accent-bg); }
1154
1151
  white-space: nowrap;
1155
1152
  }
1156
1153
 
1157
- .toc .state::before {
1158
- content: "";
1159
- width: 6px;
1160
- height: 6px;
1161
- border-radius: 99px;
1162
- background: var(--color-border);
1163
- flex: none;
1164
- }
1165
-
1166
- .toc .state.done { color: var(--color-text-secondary); }
1167
- .toc .state.done::before { background: var(--color-accent); }
1168
- .toc .state.next { color: var(--color-text-secondary); }
1169
- .toc .state.next::before { background: var(--color-accent); }
1170
-
1171
1154
  /* A link leaving the page says so with a mark beside it rather than with a
1172
1155
  badge spelling out the sentence. */
1173
1156
  .toc .ext {
1174
- font-size: 0.875rem;
1157
+ font-size: var(--t4);
1175
1158
  color: var(--color-muted);
1176
1159
  margin-left: 0.35rem;
1177
1160
  }
@@ -1182,7 +1165,7 @@ ol.succ {
1182
1165
  margin: 0 0 2.5rem;
1183
1166
  padding-left: 1.35rem;
1184
1167
  font-size: var(--t4);
1185
- line-height: 1.6;
1168
+ line-height: 1.55;
1186
1169
  color: var(--color-text-secondary);
1187
1170
  }
1188
1171
 
@@ -1205,10 +1188,6 @@ ol.succ li { margin-bottom: 0.4rem; }
1205
1188
  accent. Suppressing it only where the token above replaces it. */
1206
1189
  :where(a, button, summary, input, .opt):focus:not(:focus-visible) { outline: none; }
1207
1190
 
1208
- @media (max-width: 640px) {
1209
- body { font-size: 1.0625rem; }
1210
- }
1211
-
1212
1191
  /* The week plan. Its markup shipped with no rule matching any of its five
1213
1192
  classes, so it rendered as a run of inline spans with the ordinal, the
1214
1193
  title, the description, and the duration all touching. */
@@ -1238,7 +1217,7 @@ ol.succ li { margin-bottom: 0.4rem; }
1238
1217
 
1239
1218
  .road-t {
1240
1219
  font-family: var(--teach-mono);
1241
- font-size: 0.75rem;
1220
+ font-size: var(--t5);
1242
1221
  color: var(--color-muted);
1243
1222
  white-space: nowrap;
1244
1223
  }
@@ -1273,6 +1252,7 @@ const TEACH_QUIZ: Component = {
1273
1252
  '--color-teach-accent-bg',
1274
1253
  '--t4',
1275
1254
  '--t5',
1255
+ '--t6',
1276
1256
  ],
1277
1257
  rules: `/* ---- Quiz ---- */
1278
1258
 
@@ -1304,7 +1284,7 @@ const TEACH_QUIZ: Component = {
1304
1284
  .opt::before {
1305
1285
  content: attr(data-k);
1306
1286
  font-family: var(--teach-mono);
1307
- font-size: 0.7188rem;
1287
+ font-size: var(--t6);
1308
1288
  color: var(--color-muted);
1309
1289
  border: 1px solid var(--color-border);
1310
1290
  border-radius: 5px;
@@ -1329,23 +1309,21 @@ const TEACH_QUIZ: Component = {
1329
1309
  }
1330
1310
 
1331
1311
  .opt[data-state="chosen"]::after {
1332
- content: "your answer";
1312
+ content: "Your answer";
1333
1313
  margin-left: auto;
1334
- font-size: 0.6563rem;
1314
+ font-size: var(--t6);
1335
1315
  font-weight: 700;
1336
- letter-spacing: 0.07em;
1337
- text-transform: uppercase;
1316
+ letter-spacing: 0;
1338
1317
  color: var(--color-muted);
1339
1318
  align-self: center;
1340
1319
  }
1341
1320
 
1342
1321
  .opt[data-state="right"]::after {
1343
- content: "correct";
1322
+ content: "Correct";
1344
1323
  margin-left: auto;
1345
- font-size: 0.6563rem;
1324
+ font-size: var(--t6);
1346
1325
  font-weight: 700;
1347
- letter-spacing: 0.07em;
1348
- text-transform: uppercase;
1326
+ letter-spacing: 0;
1349
1327
  color: var(--color-accent);
1350
1328
  align-self: center;
1351
1329
  }
@@ -1394,34 +1372,37 @@ const TEACH_GLOSSARY: Component = {
1394
1372
  ],
1395
1373
  rules: `/* ---- Glossary view, generated from the markdown source ---- */
1396
1374
 
1397
- h2 .count {
1398
- font-family: var(--teach-mono);
1399
- font-size: 0.75rem;
1400
- font-weight: 400;
1401
- color: var(--color-muted);
1402
- border: 1px solid var(--color-border);
1403
- border-radius: 99px;
1404
- padding: 0.1rem 0.5rem;
1405
- margin-left: 0.5rem;
1406
- vertical-align: middle;
1375
+ /* No border, sitting on the page ground, with the count beside it. The border and the
1376
+ reading surface are what made the control look like something to fill in,
1377
+ and the count is what says it filters the list under it. */
1378
+ .filter-row {
1379
+ display: flex;
1380
+ align-items: baseline;
1381
+ gap: 1rem;
1382
+ margin: 0 0 1rem;
1407
1383
  }
1408
1384
 
1409
1385
  .filter {
1410
- width: 100%;
1386
+ flex: 1;
1387
+ min-width: 0;
1411
1388
  font-family: var(--teach-sans);
1412
1389
  font-size: var(--t4);
1413
1390
  color: var(--color-text-body);
1414
- background: var(--color-background);
1415
- border: 1px solid var(--color-border);
1416
- border-radius: 7px;
1417
- padding: 0.55rem 0.8rem;
1418
- margin: 0 0 1rem;
1391
+ background: transparent;
1392
+ border: 0;
1393
+ padding: 0.3rem 0;
1419
1394
  }
1420
1395
 
1421
- /* Scoped to pointer focus. Written as \`.filter:focus\` it outranked the
1422
- \`:where(...)\` focus token, whose specificity is zero, and the filter was the
1423
- one control on the page that a keyboard reached with no ring at all. */
1424
- .filter:focus { border-color: var(--color-accent); }
1396
+ .filter-count {
1397
+ font-family: var(--teach-mono);
1398
+ font-size: var(--t5);
1399
+ color: var(--color-muted);
1400
+ white-space: nowrap;
1401
+ }
1402
+
1403
+ /* The \`:where(...)\` focus token has zero specificity, so a \`.filter:focus\`
1404
+ rule outranks it and leaves a keyboard reaching the control with no ring.
1405
+ The ring stays the token's and only the pointer ring is dropped. */
1425
1406
  .filter:focus:not(:focus-visible) { outline: none; }
1426
1407
  .filter::placeholder { color: var(--color-muted); }
1427
1408
 
@@ -1469,8 +1450,7 @@ h2 .count {
1469
1450
  font-size: var(--t5);
1470
1451
  font-weight: 600;
1471
1452
  color: var(--color-muted);
1472
- text-transform: uppercase;
1473
- letter-spacing: 0.02em;
1453
+ letter-spacing: 0;
1474
1454
  }
1475
1455
 
1476
1456
  .gloss-group:first-of-type { margin-top: 0; }
@@ -1489,7 +1469,7 @@ ol.succ {
1489
1469
  margin: 0 0 2.5rem;
1490
1470
  padding-left: 1.35rem;
1491
1471
  font-size: var(--t4);
1492
- line-height: 1.6;
1472
+ line-height: 1.55;
1493
1473
  color: var(--color-text-secondary);
1494
1474
  }
1495
1475
 
@@ -1499,7 +1479,7 @@ ol.succ li { margin-bottom: 0.4rem; }
1499
1479
  /* A filter that matches nothing needs a way back, not just a sentence. */
1500
1480
  .gloss .empty .clear {
1501
1481
  font-family: var(--teach-sans);
1502
- font-size: 0.875rem;
1482
+ font-size: var(--t4);
1503
1483
  color: var(--color-accent);
1504
1484
  background: none;
1505
1485
  border: 0;
@@ -1554,6 +1534,7 @@ const TEACH_REFERENCES: Component = {
1554
1534
  '--teach-mono',
1555
1535
  '--color-teach-accent-bg',
1556
1536
  '--t5',
1537
+ '--t6',
1557
1538
  ],
1558
1539
  rules: `footer {
1559
1540
  margin-top: 4rem;
@@ -1568,7 +1549,7 @@ const TEACH_REFERENCES: Component = {
1568
1549
 
1569
1550
  sup.cite {
1570
1551
  font-family: var(--teach-mono);
1571
- font-size: 0.6875em;
1552
+ font-size: var(--t6);
1572
1553
  line-height: 0;
1573
1554
  margin-left: 0.1em;
1574
1555
  }
@@ -1589,7 +1570,7 @@ ol.refs {
1589
1570
  padding: 0;
1590
1571
  margin: 0.9rem 0 1.1rem;
1591
1572
  font-size: var(--t5);
1592
- line-height: 1.55;
1573
+ line-height: 1.6;
1593
1574
  }
1594
1575
 
1595
1576
  ol.refs li {
@@ -31,3 +31,11 @@ export function renderFavicon(mark: string, colors: FaviconColors): string {
31
31
  </svg>
32
32
  `
33
33
  }
34
+
35
+ /**
36
+ * The `<link rel="icon">` a page carries the rendered favicon in, inlined so a
37
+ * generated page needs no sibling file and no href that varies with its depth.
38
+ */
39
+ export function faviconLink(svg: string): string {
40
+ return `<link rel="icon" href="data:image/svg+xml,${encodeURIComponent(svg)}">`
41
+ }
@@ -381,7 +381,7 @@ export const TOKENS: DesignTokens = {
381
381
  iconography: [
382
382
  'No icon library is installed. `assets/brand/mark.svg` is the one authored icon, embedded inline in the hero topbar, and the surfaces otherwise draw literal glyph characters: `│ ├ ✓ ! ✗ + - ◆ ◇ ❯` for the terminal framing.',
383
383
  '',
384
- 'The same mark ships in six independently-maintained copies that carry different colors by design rather than by drift, each fitted to the chrome it renders on: the dark accent for a dark-chrome surface, the light accent for a light-chrome one, and the pair the web favicon takes from `src/design/favicon.ts`. Unifying them or repairing the one that looks drifted would break the fit each was chosen for. `canon/context/design.md` carries which file holds each copy.',
384
+ 'The same mark ships in five independently-maintained copies that carry different colors by design rather than by drift, each fitted to the chrome it renders on: the dark accent for a dark-chrome surface, the light accent for a light-chrome one, and the pair the web favicon takes from `src/design/favicon.ts`, which teach pages also take by rendering the live mark. Unifying them or repairing the one that looks drifted would break the fit each was chosen for. `canon/context/design.md` carries which file holds each copy.',
385
385
  ].join('\n'),
386
386
  }
387
387
 
@@ -11,12 +11,21 @@ export interface Frontmatter {
11
11
  * `raw` keeps the delimiters so an index can be rewritten with its own
12
12
  * frontmatter untouched, including comments and key order that a parse and
13
13
  * re-emit would lose.
14
+ *
15
+ * A block that will not parse reads as absent rather than throwing, so one
16
+ * malformed file reports through the path a caller already takes for a
17
+ * missing block instead of ending the caller's whole run.
14
18
  */
15
19
  export function parseFrontmatter(source: string): Frontmatter | undefined {
16
20
  const match = source.match(FRONTMATTER)
17
21
  if (!match) return undefined
18
22
 
19
- const parsed = Bun.YAML.parse(match[1])
23
+ let parsed: unknown
24
+ try {
25
+ parsed = Bun.YAML.parse(match[1])
26
+ } catch {
27
+ return undefined
28
+ }
20
29
  if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
21
30
  return undefined
22
31
  }
@@ -106,8 +106,10 @@ export async function groupEvidence(
106
106
  return { kind: 'read', states }
107
107
  }
108
108
 
109
+ // The raw host serves nothing to a private repository's browser, while the
110
+ // blob form redirects to the bytes carrying the viewer's session.
109
111
  function rawUrl(repo: string, sha: string, path: string): string {
110
- return `https://raw.githubusercontent.com/${repo}/${sha}/${path}`
112
+ return `https://github.com/${repo}/blob/${sha}/${path}?raw=true`
111
113
  }
112
114
 
113
115
  export function evidenceMarker(head: string): string {
package/src/teach/nav.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { existsSync } from 'node:fs'
1
+ import { existsSync, readFileSync } from 'node:fs'
2
2
  import { readFile, writeFile } from 'node:fs/promises'
3
3
  import { join, relative } from 'node:path'
4
4
  import {
@@ -6,7 +6,9 @@ import {
6
6
  TEACH_STYLESHEET_COMPONENTS,
7
7
  } from '@/design/components'
8
8
  import { buildDesignCss } from '@/design/css'
9
+ import { FAVICON_COLORS, faviconLink, renderFavicon } from '@/design/favicon'
9
10
  import { parseFrontmatter, readField } from '@/indexes/frontmatter'
11
+ import { PROJECT_ROOT } from '@/project-root'
10
12
  import { TEACH_FONT_FACES } from '@/teach/fonts'
11
13
  import {
12
14
  listWorkspaces,
@@ -23,7 +25,33 @@ import {
23
25
  writeStylesheet,
24
26
  } from '@/teach/workspace'
25
27
 
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" />`
28
+ const BRAND_MARK = 'assets/brand/mark.svg'
29
+
30
+ /**
31
+ * The link `teach-workspace` once had a session write by hand into every page.
32
+ * A lesson keeps whatever sits outside its marked regions, so the old icon
33
+ * would stay beside the spliced one. Matching the exact string the skill
34
+ * mandated removes it without touching a link an author chose on purpose.
35
+ */
36
+ const HAND_WRITTEN_ICON = `<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" />`
37
+
38
+ function dropHandWrittenIcon(html: string): string {
39
+ return html
40
+ .split('\n')
41
+ .filter((line) => line.trim() !== HAND_WRITTEN_ICON)
42
+ .join('\n')
43
+ .replaceAll(HAND_WRITTEN_ICON, '')
44
+ }
45
+
46
+ /**
47
+ * Built from the brand mark and the favicon's own pair, the same two inputs
48
+ * `web/public/favicon.svg` is generated from, so every teach page carries the
49
+ * icon the landing page does rather than a copy of its own.
50
+ */
51
+ function teachFavicon(): string {
52
+ const mark = readFileSync(join(PROJECT_ROOT, BRAND_MARK), 'utf8')
53
+ return faviconLink(renderFavicon(mark, FAVICON_COLORS))
54
+ }
27
55
 
28
56
  /**
29
57
  * Narrower and steeper than the mark it replaces, with round caps and joins.
@@ -164,6 +192,7 @@ const GLOSSARY_FILTER_SCRIPT = `<script>
164
192
  (function () {
165
193
  var input = document.getElementById("gfilter");
166
194
  var list = document.getElementById("gloss");
195
+ var count = document.getElementById("gloss-count");
167
196
  if (!input || !list) return;
168
197
  function updateGroups() {
169
198
  list.querySelectorAll(".gloss-group").forEach(function (heading) {
@@ -186,6 +215,10 @@ const GLOSSARY_FILTER_SCRIPT = `<script>
186
215
  });
187
216
  list.classList.toggle("none", n === 0);
188
217
  updateGroups();
218
+ if (count) {
219
+ var total = list.querySelectorAll(".gterm").length;
220
+ count.textContent = (n === total ? total : n + " of " + total) + (total === 1 ? " term" : " terms");
221
+ }
189
222
  });
190
223
  var clear = list.querySelector(".clear");
191
224
  if (clear) {
@@ -657,7 +690,7 @@ function pageHead(
657
690
  <meta charset="utf-8">
658
691
  <meta name="viewport" content="width=device-width, initial-scale=1">
659
692
  <title>${escapeHtml(title)}</title>
660
- ${FAVICON}
693
+ ${teachFavicon()}
661
694
  ${style}
662
695
  ${headScript('index')}
663
696
  </head>
@@ -861,6 +894,12 @@ function renderGlossaryGroup(group: GlossaryGroup): string {
861
894
  return `<h3 class="gloss-group">${escapeHtml(group.heading)}</h3>${entries}`
862
895
  }
863
896
 
897
+ function termCount(shown: number, total: number): string {
898
+ const noun = total === 1 ? 'term' : 'terms'
899
+
900
+ return shown === total ? `${total} ${noun}` : `${shown} of ${total} ${noun}`
901
+ }
902
+
864
903
  function renderGlossarySection(
865
904
  entries: readonly string[],
866
905
  metas: readonly LessonMeta[],
@@ -869,8 +908,8 @@ function renderGlossarySection(
869
908
  .map(renderGlossaryGroup)
870
909
  .join('')
871
910
 
872
- return `<h2>Glossary <span class="count">${entries.length}</span></h2>
873
- <input class="filter" type="search" id="gfilter" aria-label="Filter glossary terms" aria-controls="gloss" placeholder="term">
911
+ return `<h2>Glossary</h2>
912
+ <div class="filter-row"><input class="filter" type="search" id="gfilter" aria-label="Filter glossary terms" aria-controls="gloss" placeholder="Filter terms"><span class="filter-count" id="gloss-count" aria-live="polite">${termCount(entries.length, entries.length)}</span></div>
874
913
  <div class="gloss" id="gloss"><p class="empty">No term matches that. <button type="button" class="clear">Clear the filter</button></p>${rendered}</div>
875
914
  `
876
915
  }
@@ -912,7 +951,7 @@ function renderRootPage(workspaces: readonly WorkspaceSummary[]): string {
912
951
  ? 'Live'
913
952
  : 'Open'
914
953
  const blurb = `${workspace.lessons} lesson(s) &middot; ${workspace.reference} reference page(s) &middot; ${workspace.terms} term(s)`
915
- const inner = `<span class="num">${ordinalOf(workspace)}</span><b>${escapeHtml(titleCase(workspace.topic))}</b><span class="state${state === 'Live' ? ' done' : ''}">${state}</span><span class="blurb">${blurb}</span>`
954
+ const inner = `<span class="num">${ordinalOf(workspace)}</span><b>${escapeHtml(titleCase(workspace.topic))}</b><span class="state">${state}</span><span class="blurb">${blurb}</span>`
916
955
 
917
956
  return href === undefined
918
957
  ? `<li class="soon"><a href="#" aria-disabled="true" tabindex="-1">${inner}</a></li>`
@@ -1011,7 +1050,7 @@ async function renderContentsPage(
1011
1050
  const lessonRows = metas
1012
1051
  .map(
1013
1052
  (meta, index) =>
1014
- `<li><a href="${TEACH_LESSONS}/${meta.file}"><span class="num">${String(index + 1).padStart(2, '0')}</span><b>${escapeHtml(meta.title)}</b><span class="state done">Written</span><span class="blurb">${escapeHtml(meta.lede)}</span></a></li>`,
1053
+ `<li><a href="${TEACH_LESSONS}/${meta.file}"><span class="num">${String(index + 1).padStart(2, '0')}</span><b>${escapeHtml(meta.title)}</b><span class="blurb">${escapeHtml(meta.lede)}</span></a></li>`,
1015
1054
  )
1016
1055
  .join('')
1017
1056
 
@@ -1125,7 +1164,7 @@ async function rewriteLesson(
1125
1164
  ): Promise<LessonRewritten | LessonRefused> {
1126
1165
  const file = metas[index].file
1127
1166
  const path = join(root, detail.path, TEACH_LESSONS, file)
1128
- let html = await readFile(path, 'utf8')
1167
+ let html = dropHandWrittenIcon(await readFile(path, 'utf8'))
1129
1168
 
1130
1169
  const teachPrefix = '../../'
1131
1170
  const workspacePrefix = '../'
@@ -1174,11 +1213,12 @@ async function rewriteLesson(
1174
1213
  const regions: ReadonlyArray<readonly [Region, string]> = [
1175
1214
  // The stepper follows the workspace stylesheet so it wins the cascade at
1176
1215
  // equal specificity, which is what reaches a workspace seeded before it.
1177
- // The head script settles the panel before first paint, so it rides in the
1178
- // one region that sits inside `<head>`.
1216
+ // The icon and the head script both belong in `<head>`, so they ride in the
1217
+ // one region that sits there rather than a fifth marker every seeded
1218
+ // lesson would lack.
1179
1219
  [
1180
1220
  'style',
1181
- `<style>\n${legacy ? css : `${css}\n${QUIZ_CSS}`}\n</style>\n${headScript('lesson')}`,
1221
+ `${teachFavicon()}\n<style>\n${legacy ? css : `${css}\n${QUIZ_CSS}`}\n</style>\n${headScript('lesson')}`,
1182
1222
  ],
1183
1223
  ['header', header],
1184
1224
  ['footnav', `${renderFootNav(metas, index)}\n${PANE_CLOSE}`],
@@ -579,8 +579,8 @@ function missionText(
579
579
 
580
580
  return [
581
581
  '---',
582
- `title: ${title}`,
583
- `description: ${request.subject}`,
582
+ `title: ${yamlValue(title)}`,
583
+ `description: ${yamlValue(request.subject)}`,
584
584
  `date: ${date}`,
585
585
  '---',
586
586
  '',
@@ -606,8 +606,8 @@ function missionText(
606
606
  function resourcesText(title: string): string {
607
607
  return [
608
608
  '---',
609
- `title: Sources for ${title}`,
610
- `description: Sources read for ${title}, and leads found but not opened`,
609
+ `title: ${yamlValue(`Sources for ${title}`)}`,
610
+ `description: ${yamlValue(`Sources read for ${title}, and leads found but not opened`)}`,
611
611
  '---',
612
612
  '',
613
613
  `# Sources for ${title}`,
@@ -628,8 +628,8 @@ function resourcesText(title: string): string {
628
628
  function glossaryText(title: string): string {
629
629
  return [
630
630
  '---',
631
- `title: Glossary for ${title}`,
632
- `description: Terms ${title} defines, one entry each`,
631
+ `title: ${yamlValue(`Glossary for ${title}`)}`,
632
+ `description: ${yamlValue(`Terms ${title} defines, one entry each`)}`,
633
633
  '---',
634
634
  '',
635
635
  `# Glossary for ${title}`,
@@ -641,6 +641,14 @@ function glossaryText(title: string): string {
641
641
  ].join('\n')
642
642
  }
643
643
 
644
+ /**
645
+ * Serializes a frontmatter value, quoting only what YAML would otherwise read
646
+ * as something other than the string, so a plain title stays byte-identical.
647
+ */
648
+ function yamlValue(value: string): string {
649
+ return Bun.YAML.stringify(value)
650
+ }
651
+
644
652
  /** Today as `YYYY-MM-DD`, which is the one field the mission dates. */
645
653
  function today(): string {
646
654
  return new Date().toISOString().slice(0, DATE_LENGTH)
@@ -16,5 +16,5 @@
16
16
  }
17
17
  ],
18
18
  "dictionaries": ["project-terms", "tech-stack"],
19
- "ignorePaths": [".cspell/**", ".git/**"]
19
+ "ignorePaths": [".cspell/**", ".git/**", ".claude/rules/canon/**"]
20
20
  }
@@ -49,19 +49,18 @@ paths=$(tail -n +"$line_start" "$transcript_path" | jq -r '
49
49
 
50
50
  # Two written paths can share a basename, and a bare basename match would then
51
51
  # read one mention as covering both. Counting first is what lets a collision
52
- # fall back to the full path, which the ordinary case never needs.
53
- declare -A base_count
54
- while IFS= read -r path; do
52
+ # fall back to the full path, which the ordinary case never needs. The count
53
+ # avoids an associative array, since macOS ships bash 3.2.
54
+ colliding=$(while IFS= read -r path; do
55
55
  [ -n "$path" ] || continue
56
- base=$(basename "$path")
57
- base_count["$base"]=$((${base_count["$base"]:-0} + 1))
58
- done <<<"$paths"
56
+ printf '%s\n' "${path##*/}"
57
+ done <<<"$paths" | sort | uniq -d)
59
58
 
60
59
  unmentioned=()
61
60
  while IFS= read -r path; do
62
61
  [ -n "$path" ] || continue
63
- base=$(basename "$path")
64
- if [ "${base_count[$base]}" -gt 1 ]; then
62
+ base=${path##*/}
63
+ if [ -n "$colliding" ] && printf '%s\n' "$colliding" | grep -Fxq -- "$base"; then
65
64
  needle="$path"
66
65
  else
67
66
  needle="$base"
@@ -5,7 +5,7 @@ import { defineConfig } from 'vitest/config'
5
5
  export default defineConfig({
6
6
  resolve: {
7
7
  alias: {
8
- '@': path.resolve(__dirname, 'src'),
8
+ '@': path.resolve(import.meta.dirname, 'src'),
9
9
  },
10
10
  },
11
11
  test: {
@@ -2,7 +2,7 @@
2
2
  name = "python"
3
3
  extends = "base"
4
4
  runtime = "uv"
5
- scaffold = "uv init --app {{name}}"
5
+ scaffold = "uv init --package {{name}}"
6
6
 
7
7
  [scripts]
8
8
  "lint" = "uv run ruff check . && uv run ruff format --check ."
@@ -10,14 +10,13 @@ Configs ship as sidecar files (`ruff.toml`, `mypy.ini`, `pytest.ini`, `.coverage
10
10
 
11
11
  ## Scaffold checklist
12
12
 
13
- 1. Scaffold with `uv init --app <name>`. This creates `pyproject.toml`, `.python-version` pinned to 3.14, `src/<name>/`, and a starter `main.py`. `uv init` defaults `requires-python` to `>=3.14`, which matches the `.python-version` pin this stack ships.
13
+ 1. Scaffold with `uv init --package <name>`. This creates `pyproject.toml` with a `uv_build` backend, `.python-version` pinned to 3.14, and `src/<name with underscores>/__init__.py` holding an annotated `def main() -> None:`, so source lands under `src/` where `mypy.ini`, `pytest.ini`, and `.coveragerc` look for it. `uv init` defaults `requires-python` to `>=3.14`, which matches the `.python-version` pin this stack ships.
14
14
  2. Seed `package.json` so the base layer's bun-side tools (husky, prettier, cspell, commitlint) have a target to install into: `bun init -y`. Without this the sync drops base configs but skips the dep install, since `resolve_missing_deps` short-circuits when `package.json` is absent.
15
15
  3. Install base tooling: `canon tooling sync base . --write`
16
16
  4. Install python tooling: `canon tooling sync python . --write`
17
17
  5. Install Python tooling deps: `uv add --dev ruff mypy pytest pytest-cov`. v1 of this stack does not declare these in `[dependencies.dev]` because manifest injection hardcodes `bun add -D`, which can not install Python packages. Until the injector branches on `runtime`, this step is manual.
18
18
  6. Sync the lockfile and create the venv: `uv sync`.
19
- 7. Annotate `main()` in the scaffold-generated `main.py` with `-> None`. `uv init --app` ships an unannotated `main()` that fails strict mypy on the first run.
20
- 8. Run `bun run lint:fix` then `bun run check`.
19
+ 7. Run `bun run lint:fix` then `bun run check`.
21
20
 
22
21
  The python stack also ships `tests/test_smoke.py` as a copy-once seed. `pytest` collects at least one test on first run, so `bun run test:run` exits 0 instead of the empty-collection exit code 5. Delete or replace the smoke test with real tests.
23
22
 
@@ -27,7 +26,7 @@ The python stack also ships `tests/test_smoke.py` as a copy-once seed. `pytest`
27
26
  - `mypy.ini`: strict mode, `python_version = 3.14`, `mypy_path = src`, excludes `tests/` and `docs/`.
28
27
  - `pytest.ini`: tests live under `tests/`, source under `src/`.
29
28
  - `.coveragerc`: branch coverage on `src/`, html report under `.coverage_cache/html`.
30
- - `.python-version`: pinned to `3.14` to match `uv init --app` defaults.
29
+ - `.python-version`: pinned to `3.14` to match `uv init` defaults.
31
30
  - `scripts/verify.sh`: overrides base verify to add Typecheck (`mypy`), Lint (`ruff check && ruff format --check`), and Tests (`pytest -v`) phases.
32
31
 
33
32
  ## Hybrid project shape
@@ -9,7 +9,7 @@ export default defineConfig({
9
9
  plugins: [react(), tailwindcss()],
10
10
  resolve: {
11
11
  alias: {
12
- '@': path.resolve(__dirname, './src'),
12
+ '@': path.resolve(import.meta.dirname, './src'),
13
13
  },
14
14
  },
15
15
  base: process.env.VITE_BASE_URL ?? '/',