@erclx/canon 4.48.1 → 4.50.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.
@@ -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.48.1",
4
+ "version": "4.50.0",
5
5
  "author": {
6
6
  "name": "Eric Le",
7
7
  "url": "https://github.com/erclx"
@@ -43,6 +43,8 @@ A topic the listing already carries is a resume, and anything else is a new work
43
43
 
44
44
  On a resume, run `canon teach list <topic> --json` for the files behind each count, then read the highest-numbered learning record and `GLOSSARY.md`. Those carry where the learner stopped and what they got wrong. The listing record carries `success`, the mission's success lines, which are the exit criteria this workspace is finished against. Report them with what is already met before teaching anything.
45
45
 
46
+ It also carries `due`, what the learning records schedule, soonest first. Each entry names the item, its date, the rung it has reached, whether it is `overdue`, and the two dates the next record could write. Read it here rather than later: it is the input to what this session opens on, and `canon teach lesson` takes a `--slug` naming a choice already made. Report what is overdue before proposing a lesson. An empty `due` on a workspace holding records means no record scheduled anything, which is a gap in the records rather than a clear schedule.
47
+
46
48
  On a new workspace, settle the starting point first, by asking rather than by assuming. Difficulty with no floor under it teaches nobody, and the mission cannot be written without it.
47
49
 
48
50
  Then open the workspace, which derives the ordinal and writes all three required files:
@@ -75,13 +77,15 @@ A claim nothing was read for is the failure this step exists against. Where no s
75
77
 
76
78
  Pick the next lesson from the learning records rather than from the subject's own order. The target is the band immediately past what the learner can already do unaided, which `${CLAUDE_SKILL_DIR}/references/pedagogy.md` states in full.
77
79
 
78
- Open with retrieval on what the last session got wrong, before anything new. A learner who cannot retrieve the previous lesson is not ready for the next one, and moving on anyway buys fluency that decays.
80
+ Open on what `due` reports as overdue, oldest date first, before anything new. Where nothing is overdue, open on retrieval of what the last session got wrong. A learner who cannot retrieve the previous lesson is not ready for the next one, and moving on anyway buys fluency that decays.
81
+
82
+ Reading the last record alone is what the schedule replaces. A topic missed three sessions ago and never revisited is invisible to that read and sits in `due` with a date already past, which is the whole reason the field exists.
79
83
 
80
84
  ## Step 4: write the lesson and the reference
81
85
 
82
86
  Two outputs with two lifetimes, and the split decides the format.
83
87
 
84
- - A lesson is a self-contained page carrying its own quiz and the feedback for each answer. It embeds one shared stylesheet rather than restating styles, and it is disposable and never promoted.
88
+ - A lesson is a self-contained page carrying its own quiz, a teach-back block, and the feedback for each question. It embeds one shared stylesheet rather than restating styles, and it is disposable and never promoted.
85
89
  - A reference page goes to `reference/<slug>.md`, written for a reader with no learner in it. This is the half that survives the workspace, so it is written in markdown to pass the authoring gates a promotion would put it through.
86
90
 
87
91
  Resolve the lesson before writing it, rather than composing its name or its quiz order by hand:
@@ -101,6 +105,14 @@ It writes nothing and reports three things:
101
105
 
102
106
  Write the correct option first, then present the options in the order `order` reports, reading it as authored indices where `0` is the correct one. Take the order as given. Position drawn here rather than chosen is the whole reason the verb exists, and a lesson that reorders on its own judgment puts the answer back in the first slot.
103
107
 
108
+ ### The quiz and the teach-back block
109
+
110
+ The quiz markup is a contract rather than a convention. `canon teach nav` splices a stepper into the lesson that gates on exactly the class names and nesting `${CLAUDE_SKILL_DIR}/references/lesson-craft.md` states under `## Quiz construction`, so a quiz in any other shape shows every question at once and nothing reports it. That is the leak this shape closes: a later stem naming what an earlier question asked for answers it while both sit on screen.
111
+
112
+ Write each option as a `<label class="opt" data-k="<letter>">` holding a radio `<input>` and a `<span>`, every option in one question sharing a `name` and each question taking a different one, the correct option carrying `data-a="1"`, and one `.fb` block as the last child of the `.q`. Never write a `<button class="opt">` in a new lesson. That is the shape written before the stepper, kept working by an injected script the button itself still triggers, and a lesson mixing the two gets both mechanisms.
113
+
114
+ Then carry a teach-back block beside the quiz. A quiz is recognition and the pedagogy prefers production, so a lesson offering only a quiz tests the weak form. Ask for an explanation to a named audience, and carry a `<details>` listing what a complete explanation covers, closed by default, so a learner reading with no session in the room can grade themselves. The reference states both shapes in full.
115
+
104
116
  Write the chrome as four empty marker pairs rather than composing it by hand: `<!-- canon:teach:style -->`/`<!-- /canon:teach:style -->` inside `<head>`, and `<!-- canon:teach:header -->`, `<!-- canon:teach:footnav -->`, and `<!-- canon:teach:scripts -->` each with its own close marker, in that order in `<body>`. Write the authored `<h1>`, lede, body, and quiz between the header's close marker and the footnav's open marker, and nothing else anywhere in the file. Then run:
105
117
 
106
118
  ```bash
@@ -145,10 +157,26 @@ Do this on every run that opens or resumes a workspace, including one that write
145
157
 
146
158
  ## Step 5: record what happened
147
159
 
148
- Write `learning-records/<nnnn>-<slug>.md` before the session ends, carrying the lessons covered, what the learner retrieved unaided, what they got wrong with the wrong answer itself, and what to revisit.
160
+ Write `learning-records/<nnnn>-<slug>.md` before the session ends, carrying the lessons covered, what the learner retrieved unaided, what they got wrong with the wrong answer itself, and a `## Revisit` section.
149
161
 
150
162
  Record the wrong answer rather than the count. The next session places the learner from this file, and a tally carries no misconception to work against.
151
163
 
164
+ Record what the teach-back left out under what the learner got wrong, naming the concept and where the explanation broke. A produced answer and a selected one are both retrieval failures the next session places from, so a second heading would split one input across two sections.
165
+
166
+ Then write `## Revisit`, one bullet per item, in the shape `${CLAUDE_SKILL_DIR}/../../standards/teach.md` fixes:
167
+
168
+ ```markdown
169
+ ## Revisit
170
+
171
+ - **<what comes back up>**: due <YYYY-MM-DD>, rung <n>
172
+ ```
173
+
174
+ Take both the date and the rung from the `due` entry Step 1 already read, copying `hit` for an item the learner retrieved unaided and `miss` for one they did not, with the rung stepped the same way. Do not compute a date from the ladder. A session told to widen a gap still picks the number by judgment, which is the reason the verb reports both dates at all.
175
+
176
+ An item the records have never scheduled has no `due` entry to copy from. Open it at rung 1, dated the day after this session, which is the ladder's floor and the one number this body states. Every later date for it comes from the verb.
177
+
178
+ An entry outside this shape schedules nothing and nothing reports that it was skipped, so write the shape exactly. A later record naming the same item supersedes an earlier entry, so revise a schedule by writing the new bullet rather than editing the record it was set in.
179
+
152
180
  Then restate the mission's success lines with what is now met, reading them from the `success` the lesson verb already reported rather than from `MISSION.md` by eye. Report each line as met or not met, and name what the learner did that meets it. A mission whose lines are all met is finished, and saying so is what closes a workspace.
153
181
 
154
182
  ## Step 6: propose where the durable half belongs
@@ -49,9 +49,56 @@ The quiz is the retrieval, so a leak in its construction turns it into a reading
49
49
  - Write every option to the same length, in words and in characters. A longer option reads as the considered one.
50
50
  - Write the correct option first and let the ordering verb place it. Position is not the author's to pick: an author who varies it by hand still varies it by judgment, and the judgment settles on the first slot.
51
51
  - Make each wrong option a misconception someone actually holds. An obviously wrong option removes itself and shrinks the question.
52
- - Write feedback for every option, including the correct one, saying why rather than whether
52
+ - Write one feedback block per question, covering the correct option and why each distractor fails, saying why rather than whether. One block reads as an explanation where a block per option reads as four verdicts, and only the explanation names what separates a wrong option from the answer.
53
53
  - Give feedback after the attempt, never alongside the question
54
54
 
55
+ ### The markup
56
+
57
+ The quiz shape is a contract rather than a convention. `canon teach nav` splices a stepper into the lesson that gates on exactly these class names and this nesting, so a quiz written in any other shape renders with every question on screen at once and nothing reports it.
58
+
59
+ ```html
60
+ <div class="quiz">
61
+ <h2>Retrieval check</h2>
62
+
63
+ <div class="q"><p class="q-stem">1. <stem></p>
64
+ <label class="opt" data-k="A"><input type="radio" name="q1"><span><the option text></span></label>
65
+ <label class="opt" data-k="B"><input type="radio" name="q1" data-a="1"><span><the option text></span></label>
66
+ <div class="fb"><b>Correct: <the answer in a few words>.</b> <why each distractor fails.></div></div>
67
+ </div>
68
+ ```
69
+
70
+ - Give every option in one question the same `name`, and a different `name` per question. Options sharing a name across two questions let one answer clear another.
71
+ - Mark the correct option with `data-a="1"` and leave the others without the attribute
72
+ - Carry `data-k` on the `label`, since the workspace stylesheet renders the key letter from it
73
+ - Keep `.fb` the last child of its `.q`. The stepper reveals it as a direct child, and a feedback block nested deeper stays hidden.
74
+ - Put the option text in a `<span>` inside the label rather than bare beside the input, so the workspace can lay the two out
75
+ - Never write a `<button class="opt">` in a new lesson. That is the shape written before the stepper, and it is kept working by an injected script the presence of a button is what still triggers.
76
+
77
+ The stepper hides and shows and sets nothing else, so how a selected option looks, and whether a wrong one is marked as wrong, is the workspace stylesheet's to grow. Name the correct option in the feedback text for that reason: it is what a learner reads to find out whether they were right.
78
+
79
+ ## Teach back
80
+
81
+ A quiz is recognition and a teach-back is production, which is the form retrieval practice prefers. Carry both: the quiz places the learner against options someone could hold, and the teach-back is where the learner finds out what they cannot say without help.
82
+
83
+ - Ask for an explanation to a named audience rather than for an answer. Asking the learner to explain to somebody who has never written a pattern why a lazy quantifier is not an optional one gives the explanation a floor, where asking them to explain lazy quantifiers gives it none.
84
+ - Ask about the thing the lesson was for, not a detail beside it. A teach-back on a footnote tests attention rather than the material.
85
+ - Carry a self-check the learner can open, as a `<details>` listing what a complete explanation covers, closed by default. A learner reading with no session in the room otherwise gets an ask they cannot grade, and closed leaks nothing.
86
+ - Write the self-check as what the explanation has to cover rather than as the explanation itself. A model answer is read instead of produced, which turns the block back into reading.
87
+
88
+ ```html
89
+ <div class="teach-back">
90
+ <h2>Explain it back</h2>
91
+ <p>Without looking above: <the ask, naming who it is for.></p>
92
+ <details><summary>What a complete explanation covers</summary>
93
+ <ul>
94
+ <li><a point the explanation has to reach></li>
95
+ </ul>
96
+ </details>
97
+ </div>
98
+ ```
99
+
100
+ It needs no styles of its own beyond what the workspace already gives a section and a `<details>`.
101
+
55
102
  ## Tokens travel with the course
56
103
 
57
104
  A lesson carries its own values in the shared stylesheet rather than reading a host project's. A workspace runs in any project and most carry no token record at all, so a lesson inheriting one is a lesson that renders unstyled wherever the record is absent, with nothing reporting it.
@@ -25,13 +25,23 @@ Pulling an answer out of memory strengthens it far more than reading it again. R
25
25
  - Let the learner fail an attempt before correcting it. A failed retrieval followed by the answer beats being told first.
26
26
  - Do not cue the answer in the question. A question restating the material tests reading rather than memory.
27
27
 
28
+ The teach-back block is the production form this section prefers, and it is why a lesson carries one beside its quiz rather than a quiz alone. A quiz is recognition, so a lesson offering only one tests the weak form and reports it as retrieval. Asking the learner to explain the material to somebody who does not have it makes them produce the whole structure rather than pick from four, and what the explanation leaves out is the gap the next session places from.
29
+
30
+ - Carry a teach-back block in every lesson, asking for an explanation rather than an answer
31
+ - Name the audience the explanation is for, since an explanation with no reader has no floor to sit above
32
+ - Record what the explanation left out under what the learner got wrong. A produced answer and a selected one are both retrieval failures, and splitting them across two sections splits one placement input.
33
+
28
34
  ## Spacing
29
35
 
30
36
  The same total time spread across days beats the same time in one sitting. Forgetting between sessions is the mechanism rather than the cost, because effortful retrieval is what strengthens the trace.
31
37
 
32
38
  - Revisit a topic after the learner has begun to forget it, not while it is still fresh
33
39
  - Widen the gap each time a topic is retrieved correctly, and shorten it after a miss
34
- - Name what to revisit and roughly when in the learning record, so the next session has a schedule rather than a guess
40
+ - Name what to revisit and when in the learning record, so the next session has a schedule rather than a guess
41
+
42
+ The ladder is 1, 3, 7, 16, and 35 days. A topic retrieved unaided moves up a rung and a miss drops it back one, held inside the ladder at either end, so a topic the learner keeps missing stays on the shortest gap rather than restarting a sequence.
43
+
44
+ Do not compute a date from that ladder by hand. `canon teach list <topic> --json` reports the schedule with both dates already resolved for every item, which is what turns the ladder into a number to copy rather than a rule to apply. An author told to widen a gap still picks the gap by judgment, and this is the same reason quiz option order is drawn by a verb rather than stated here.
35
45
 
36
46
  ## Interleaving
37
47
 
@@ -37,7 +37,7 @@ Full help: `canon <command> --help`. Behavior notes for the install and sync ver
37
37
  | `canon tasks validate` | Report board rows whose shape, order, plan, task file, group, file set, or blocker does not hold (`--json`) |
38
38
  | `canon intake list` | Report intake folder counts, or one folder's items, keeping what is unread with `--unread` (`--json`) |
39
39
  | `canon intake answer` | Write selections into one cluster's answer slots, repeating `--set <item>=<answer>` (`--json`) |
40
- | `canon teach list` | Report learning workspaces and the ordinal a new one takes, or what one workspace holds (`--json`) |
40
+ | `canon teach list` | Report learning workspaces and the ordinal a new one takes, or what one workspace holds and what its records schedule (`--json`) |
41
41
  | `canon teach open` | Open a workspace at the next ordinal with its mission, resources, and glossary files (`--json`) |
42
42
  | `canon teach resource` | Record sources and leads in a workspace, repeating `--read` or `--lead` as `<title>=<url>` (`--json`) |
43
43
  | `canon teach glossary` | Add terms to a workspace glossary alphabetically, repeating `--term <term>=<definition>` (`--json`) |
@@ -44,6 +44,6 @@ CLI catalog and invocation rules for agents, split by command domain. Start with
44
44
  - [Superseded values](superseded.md): Reading where the tree still asserts a value a changed convention no longer produces, why the sweep keys on the value rather than the file, the family stem behind a templated citation, the exemption marker, the blind spots it cannot reach, and why it reports rather than gates
45
45
  - [Targets](targets.md): The projects this toolkit installed into, the record the install writes against the sweep that backs it, what bounds each answer, and the cross-target pull request read
46
46
  - [Tasks](tasks.md): Selecting a shipped task by stem or pull request, recording a number and closing an outcome, the refusal reasons, the board and backlog checks validate runs, and why the board root defaults to the main worktree
47
- - [Teach](teach.md): Listing learning workspaces and the ordinal a new one takes, opening one with its required files, recording sources and glossary terms, resolving what the next lesson needs before it is written, rewriting the root listing, a contents page, and each lesson's chrome from its marker regions, the refusal reasons, and why every write here runs through a verb
47
+ - [Teach](teach.md): Listing learning workspaces with what their records schedule next, opening one with its required files, recording sources and glossary terms, resolving what the next lesson needs before it is written, rewriting the root listing, a contents page, and each lesson's chrome and quiz stepper from its marker regions, the refusal reasons, and why every write here runs through a verb
48
48
  - [Test order](test-order.md): Reading where an implementation reached history ahead of its test, how a pair is decided, the three verdicts, the coverage the pairing cannot reach, and why the check reports rather than gates
49
49
  - [Worktrees](worktrees.md): Reporting which worktrees are reclaimable, removing the ones that are, the record a hook reads back, why the reading keys on the pull request rather than on git ancestry, the refusals it names, and the two removal shapes
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  title: Teach
3
- description: Listing learning workspaces and the ordinal a new one takes, opening one with its required files, recording sources and glossary terms, resolving what the next lesson needs before it is written, rewriting the root listing, a contents page, and each lesson's chrome from its marker regions, the refusal reasons, and why every write here runs through a verb
3
+ description: Listing learning workspaces with what their records schedule next, opening one with its required files, recording sources and glossary terms, resolving what the next lesson needs before it is written, rewriting the root listing, a contents page, and each lesson's chrome and quiz stepper from its marker regions, the refusal reasons, and why every write here runs through a verb
4
4
  ---
5
5
 
6
6
  # Teach
@@ -29,6 +29,12 @@ A folder not named `NN-<topic>` is still listed rather than dropped, since dropp
29
29
 
30
30
  The listing also names the required files a workspace does not carry, which is `MISSION.md`, `RESOURCES.md`, and `GLOSSARY.md`. That is a report rather than a refusal, because a workspace missing one is still a workspace a session can resume.
31
31
 
32
+ Every record carries `due`, read from the `## Revisit` sections of the workspace's learning records and sorted soonest first. Each entry names the `item`, its `date`, the `rung` it has reached on the spacing ladder, `overdue` once that date has arrived or passed, and the `record` the surviving entry was written in. A later record supersedes an earlier entry for the same item, matched without regard to case, since the schedule is the state of one topic rather than a log of every time it was set.
33
+
34
+ Each entry also carries `hit` and `miss`, the two dates the next record could write. The ladder is 1, 3, 7, 16, and 35 days, a retrieval the learner passed unaided moving up a rung and a miss dropping back one, both held inside the ladder at either end. Reporting both dates is what lets a session copy one rather than compute it, on the same reasoning that put quiz option order in `canon teach lesson`.
35
+
36
+ The field rides on the listing rather than on `canon teach lesson`, because a session needs the schedule before it has chosen what to teach and that verb takes a `--slug` naming a choice already made. An entry not written in the shape the standard fixes is skipped rather than reported, since a note carrying no date has no date to schedule.
37
+
32
38
  ## Open
33
39
 
34
40
  `canon teach open` creates a workspace at the next ordinal and writes all three required files.
@@ -152,6 +158,14 @@ A lesson carries its chrome as four marker pairs the authoring skill writes empt
152
158
 
153
159
  A lesson missing one of the four marker pairs is refused by name rather than rewritten, and every other lesson in the run still rewrites. The record's `skipped` list carries the refused files and which marker each is missing.
154
160
 
161
+ The `style` region carries the quiz stepper after the workspace stylesheet, for every lesson but the one shape named three paragraphs down, so it wins the cascade at equal specificity and reaches a workspace seeded before it existed. The stepper is CSS over the radio inputs a quiz is written from: it hides every question that follows one holding no checked input, and hides a question's feedback until that question does. The general sibling combinator is what keeps that from depending on the markup contract holding, since it selects the same questions as the adjacent one for a quiz written correctly and goes on gating when anything sits between two of them, where the adjacent one matches nothing and shows them all at once. Gating there rather than in a script is what keeps a later stem from answering an earlier question with nothing to bind, and the whole block sits inside `@supports selector(:has(*))`, so an engine without `:has()` renders every question rather than a stepper showing nothing. Feedback there follows the workspace stylesheet, shown by one that says nothing about `.fb` and hidden by one grown under the retired script.
162
+
163
+ The block hides and shows and sets nothing else, which leaves a workspace's own layout for a question and its own appearance for an option and a feedback panel. The one value it names is `display: block` on revealed feedback, since every stylesheet grown under the retired script carries `.fb { display: none }` and nothing adds the class that used to reveal it.
164
+
165
+ One detector decides both regions. A lesson carrying `<button class="opt"`, the shape written before the stepper, takes the feedback script and no stepper, and every other lesson takes the stepper and no script. That is a detector rather than a mode, since a lesson has one shape or the other, and removing the script outright would leave the lessons already written showing no feedback at all.
166
+
167
+ Splicing the stepper into a lesson of either shape was measured against a browser and reverted. Its rules match a button question, which can never hold a checked radio, so a lesson given both hides every question past the first for good and outranks the class the script reveals feedback with. Nothing else caught that: the markup is valid, the splice succeeds, and the page renders.
168
+
155
169
  ## Opening a workspace
156
170
 
157
171
  No `canon teach` verb serves the workspace. `canon serve` does, taking the teach root as its directory and the workspace contents page as its entry:
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@erclx/canon",
3
3
  "type": "module",
4
- "version": "4.48.1",
4
+ "version": "4.50.0",
5
5
  "description": "Infrastructure and quality tooling for developer workflows",
6
6
  "license": "MIT",
7
7
  "bin": {
@@ -11,6 +11,7 @@ import {
11
11
  type ReadOutcome,
12
12
  readWorkspace,
13
13
  recordSources,
14
+ type RevisitItem,
14
15
  type Source,
15
16
  type SourceOutcome,
16
17
  type TeachRefused,
@@ -104,6 +105,11 @@ export function register(program: Command): void {
104
105
  'With no topic it reports one line per workspace and the ordinal an',
105
106
  'open would take. With one it reports the files behind each count.',
106
107
  '',
108
+ 'Every record carries due, what the learning records schedule, each',
109
+ 'entry with its date, its rung, and the date to write on a hit and',
110
+ 'on a miss, so a session picks the next lesson from the schedule',
111
+ 'rather than from the last session alone.',
112
+ '',
107
113
  'A workspace not named NN-<topic> is still listed. It sorts last and',
108
114
  'moves no ordinal, since dropping it hides the folder needing a fix.',
109
115
  '',
@@ -711,6 +717,17 @@ function reportRefusal(
711
717
  return 1
712
718
  }
713
719
 
720
+ /**
721
+ * One scheduled revisit, carrying both ladder dates rather than the rung alone.
722
+ * A session copies the date the outcome names instead of computing one from a
723
+ * ladder stated in prose.
724
+ */
725
+ function describeRevisit(item: RevisitItem): string {
726
+ const when = item.overdue ? `due ${item.date}` : item.date
727
+
728
+ return `${item.item}: ${when}, rung ${item.rung} (hit ${item.hit}, miss ${item.miss}), from ${item.record}`
729
+ }
730
+
714
731
  function describe(workspace: WorkspaceSummary): string {
715
732
  return `${workspace.slug}: ${workspace.lessons} lesson(s), ${workspace.records} record(s), ${workspace.reference} reference page(s), ${workspace.terms} term(s)`
716
733
  }
@@ -795,6 +812,9 @@ function reportWorkspace(
795
812
  )
796
813
  for (const line of workspace.success) logInfo(line)
797
814
 
815
+ logStep(workspace.due.length > 0 ? 'Revisit' : 'Revisit (nothing scheduled)')
816
+ for (const item of workspace.due) logInfo(describeRevisit(item))
817
+
798
818
  if (workspace.missing.length > 0) {
799
819
  logStep('Missing a required file')
800
820
  logWarn(`no ${workspace.missing.join(' and no ')}`)
@@ -28,14 +28,22 @@
28
28
  --space-frame-top: 44px;
29
29
  --space-frame-inline: 52px;
30
30
  --space-frame-bottom: 38px;
31
+ --type-display-family: Noto Sans Mono, DejaVu Sans Mono, monospace;
31
32
  --type-display-size: 34px;
32
33
  --type-display-lh: 1.3;
34
+ --type-page-display-family: Noto Sans, DejaVu Sans, sans-serif;
35
+ --type-page-display-size: 52px;
36
+ --type-page-display-lh: 1.1;
37
+ --type-heading-family: Noto Sans Mono, DejaVu Sans Mono, monospace;
33
38
  --type-heading-size: 19px;
34
39
  --type-heading-lh: 1.3;
40
+ --type-body-family: Noto Sans Mono, DejaVu Sans Mono, monospace;
35
41
  --type-body-size: 16px;
36
42
  --type-body-lh: 1.65;
43
+ --type-label-family: Noto Sans Mono, DejaVu Sans Mono, monospace;
37
44
  --type-label-size: 12px;
38
45
  --type-label-lh: 1.45;
46
+ --type-code-family: Noto Sans Mono, DejaVu Sans Mono, monospace;
39
47
  --type-code-size: 14.5px;
40
48
  --type-code-lh: 1.3;
41
49
  --radius-frame: 12px;
package/src/design/css.ts CHANGED
@@ -76,6 +76,7 @@ function tokenProperties(tokens: DesignTokens): string[] {
76
76
  }
77
77
 
78
78
  for (const role of tokens.typography) {
79
+ lines.push(` --type-${slug(role.role)}-family: ${role.family};`)
79
80
  lines.push(` --type-${slug(role.role)}-size: ${role.size};`)
80
81
  lines.push(` --type-${slug(role.role)}-lh: ${role.lineHeight};`)
81
82
  }
@@ -68,9 +68,21 @@ export interface DesignTokens {
68
68
  readonly preamble: string
69
69
  }
70
70
 
71
- /** The one monospace stack every surface that renders text declares. */
71
+ /** The monospace stack every surface but the landing page hero declares. */
72
72
  const MONO = 'Noto Sans Mono, DejaVu Sans Mono, monospace'
73
73
 
74
+ /**
75
+ * The proportional stack the landing page hero declares and nothing else does.
76
+ * Noto Sans is the proportional sibling of the mono above rather than a face
77
+ * picked for taste, so the pairing is one superfamily rather than two systems.
78
+ *
79
+ * The stack stops at two names and a generic because `src/design/base.css` is
80
+ * written by `canon design regen` and formatted by prettier, and a declaration
81
+ * past 80 columns is wrapped by the second and flattened by the first. Every
82
+ * emitted line stays inside the width so the two writers never disagree.
83
+ */
84
+ const SANS = 'Noto Sans, DejaVu Sans, sans-serif'
85
+
74
86
  const DARK_GROUNDS = ['background', 'surface'] as const
75
87
  const LIGHT_GROUNDS = ['light-background', 'light-surface'] as const
76
88
 
@@ -87,6 +99,8 @@ export const TOKENS: DesignTokens = {
87
99
 
88
100
  personality: [
89
101
  'Warm neutrals carry the frame under a single rust accent, rendered in the same monospace the terminal uses. The subject picks the register rather than taste: a toolkit whose primary surface is a shell has no proportional voice available, so the rendered surfaces match the terminal instead of the reverse. One accent carries every count, link, and primary action. Promoting a second and third into structural roles is what reads as a generated interface, so the palette stays at one.',
102
+ '',
103
+ 'One surface is carved out of the sentence above, and it is the landing page hero. A rendered surface here shows a reader what the terminal did, so matching the terminal is what makes it legible. A public page addresses somebody who has never opened the terminal at all, and the shell has no voice available for that, which is the reverse of the case the rule was written for. The `page-display` role below is the whole of the carve-out. Every other role on that page stays monospace, including its body, its controls, and every frame it embeds, so the page reads as two families rather than as a second design system.',
90
104
  ].join('\n'),
91
105
 
92
106
  colorNote: [
@@ -193,7 +207,9 @@ export const TOKENS: DesignTokens = {
193
207
  ],
194
208
 
195
209
  typographyNote: [
196
- 'One family covers every role. The size scale runs from 11.5 to 34 pixels across ten values, and five of them map onto a role. The other five are adjustments inside a single component and get no role here, since a scale with five invented steps reads as a system the surfaces do not implement. They are 11.5, 12.5, 13, 14, and 15 pixels.',
210
+ 'One family covers every role but `page-display`, which is the landing page hero and takes the proportional sibling of the same superfamily. The size scale runs from 11.5 to 52 pixels, and six values map onto a role. Five further values are adjustments inside a single component and get no role here, since a scale with five invented steps reads as a system the surfaces do not implement. They are 11.5, 12.5, 13, 14, and 15 pixels.',
211
+ '',
212
+ 'The 52 pixel step sits above the 34 the rest of the scale tops out at, and it is the one size no other surface reaches. A hero headline set at the display cap reads as a section heading rather than an opening, which four rendered arms measured on 2026-09-04 before the step was added.',
197
213
  '',
198
214
  'A tagged cell is one no rendering surface exercises yet, which is a declaration the system has not tested rather than one it has.',
199
215
  '',
@@ -208,6 +224,14 @@ export const TOKENS: DesignTokens = {
208
224
  size: '34px',
209
225
  lineHeight: '1.3',
210
226
  },
227
+ {
228
+ role: 'page-display',
229
+ family: SANS,
230
+ weight: '700',
231
+ size: '52px',
232
+ lineHeight: '1.1',
233
+ verify: ['family', 'size', 'lineHeight'],
234
+ },
211
235
  {
212
236
  role: 'heading',
213
237
  family: MONO,
@@ -400,7 +400,8 @@ export async function planCitations(
400
400
 
401
401
  const stems = await listTaskStems(dir)
402
402
  if (!stems.includes(stem)) {
403
- return refuse('no-match', `No task named ${stem} on the board.`, stems)
403
+ const unmatched = describeUnmatchedStem(stems, stem)
404
+ return refuse(unmatched.reason, unmatched.message, unmatched.detail)
404
405
  }
405
406
 
406
407
  const target = readPlanTarget(await readFile(join(dir, `${stem}.md`), 'utf8'))
@@ -488,6 +489,45 @@ function refuse(
488
489
  return { ok: false, reason, message, detail }
489
490
  }
490
491
 
492
+ export interface UnmatchedStem {
493
+ readonly reason: 'no-match' | 'ambiguous'
494
+ readonly message: string
495
+ readonly detail: readonly string[]
496
+ }
497
+
498
+ /**
499
+ * The three-way refusal a bare stem earns once it fails an exact match:
500
+ * exactly one task starts with it, several do, or none does. `archive.ts`'s
501
+ * own `resolveStem`, `planCitations`, and `record.ts`'s `resolveStem` all
502
+ * refuse a stem this way, so the wording lives once rather than three times.
503
+ */
504
+ export function describeUnmatchedStem(
505
+ stems: readonly string[],
506
+ name: string,
507
+ ): UnmatchedStem {
508
+ const prefixed = stems.filter((stem) => stem.startsWith(name))
509
+ if (prefixed.length === 1) {
510
+ const [match] = prefixed
511
+ return {
512
+ reason: 'no-match',
513
+ message: `${name} does not name a task by itself. One task starts with it: ${match}. Pass the full name instead.`,
514
+ detail: [match],
515
+ }
516
+ }
517
+ if (prefixed.length > 1) {
518
+ return {
519
+ reason: 'ambiguous',
520
+ message: `${name} does not name a task by itself. ${prefixed.length} tasks start with it. Pass the full name instead.`,
521
+ detail: prefixed,
522
+ }
523
+ }
524
+ return {
525
+ reason: 'no-match',
526
+ message: `No task named ${name} on the board.`,
527
+ detail: stems,
528
+ }
529
+ }
530
+
491
531
  async function resolveStem(
492
532
  dir: string,
493
533
  selector: TaskSelector,
@@ -496,27 +536,11 @@ async function resolveStem(
496
536
 
497
537
  if (selector.kind === 'stem') {
498
538
  if (!stems.includes(selector.stem)) {
499
- const prefixed = stems.filter((stem) => stem.startsWith(selector.stem))
500
- if (prefixed.length === 1) {
501
- const [match] = prefixed
502
- return refuse(
503
- 'no-match',
504
- `${selector.stem} does not name a task by itself. One task starts with it: ${match}. Pass the full name to archive it.`,
505
- [match],
506
- )
507
- }
508
- if (prefixed.length > 1) {
509
- return refuse(
510
- 'ambiguous',
511
- `${selector.stem} does not name a task by itself. ${prefixed.length} tasks start with it. Pass the full name to archive one.`,
512
- prefixed,
513
- )
514
- }
515
- return refuse(
516
- 'no-match',
517
- `No task named ${selector.stem} on the board.`,
539
+ const { reason, message, detail } = describeUnmatchedStem(
518
540
  stems,
541
+ selector.stem,
519
542
  )
543
+ return refuse(reason, message, detail)
520
544
  }
521
545
  return selector.stem
522
546
  }
@@ -3,6 +3,7 @@ import { readFile, writeFile } from 'node:fs/promises'
3
3
  import { basename, join, relative } from 'node:path'
4
4
  import { planCandidates } from '@/tasks/answers'
5
5
  import {
6
+ describeUnmatchedStem,
6
7
  fenceMask,
7
8
  linkTo,
8
9
  listTaskStems,
@@ -241,11 +242,11 @@ async function resolveStem(
241
242
 
242
243
  if (selector.kind === 'stem') {
243
244
  if (!stems.includes(selector.stem)) {
244
- return refuse(
245
- 'no-match',
246
- `No task named ${selector.stem} on the board.`,
245
+ const { reason, message, detail } = describeUnmatchedStem(
247
246
  stems,
247
+ selector.stem,
248
248
  )
249
+ return refuse(reason, message, detail)
249
250
  }
250
251
  return selector.stem
251
252
  }
package/src/teach/nav.ts CHANGED
@@ -50,6 +50,55 @@ const CLOSE_OUTSIDE_CLICK_SCRIPT = `<script>
50
50
  })();
51
51
  </script>`
52
52
 
53
+ /**
54
+ * The stepper, as CSS over the radio inputs a lesson's quiz is written from.
55
+ *
56
+ * A quiz showing every question at once lets a later stem answer an earlier
57
+ * question, which is a leak no wording of the questions closes. Gating on
58
+ * `:has()` rather than on a script keeps the page working with nothing to bind,
59
+ * and the radio input is what records an answer with no handler in the loop.
60
+ *
61
+ * The general sibling combinator is what keeps the gate from depending on the
62
+ * markup contract holding. It selects the same questions as `+` for a quiz
63
+ * written correctly, and it goes on gating when anything sits between two of
64
+ * them, where `+` matches nothing and shows every later question at once. That
65
+ * is the leak this block exists to close, arriving through a shape nothing
66
+ * rejects.
67
+ *
68
+ * The two hide rules select the unanswered state alone, so a workspace's own
69
+ * `display` for a question survives being stepped through. Feedback has to be
70
+ * shown by a rule naming a value, because every stylesheet grown under the
71
+ * retired script carries `.fb { display: none }` and nothing adds the class
72
+ * that revealed it. Appearance stays the workspace's: this block hides and
73
+ * shows and sets nothing else.
74
+ *
75
+ * `@supports` wraps every rule, so an engine without `:has()` renders every
76
+ * question rather than a stepper showing nothing. Feedback there follows the
77
+ * workspace: a fresh stylesheet shows it and one grown under the retired script
78
+ * keeps it hidden. Showing it unconditionally outside the wrapper was measured
79
+ * and reverted, since that rule outranks `.fb { display: none }` on every
80
+ * engine and would reveal the feedback in the button-shape lessons the script
81
+ * still drives.
82
+ */
83
+ const QUIZ_CSS = `@supports selector(:has(*)) {
84
+ .quiz .q:not(:has(input[type="radio"]:checked)) ~ .q { display: none; }
85
+ .quiz .q:not(:has(input[type="radio"]:checked)) .fb { display: none; }
86
+ .quiz .q:has(input[type="radio"]:checked) > .fb { display: block; }
87
+ }`
88
+
89
+ /**
90
+ * The button shape a lesson written before the stepper carries. It is a
91
+ * detector rather than a mode: a lesson has one shape or the other, and which
92
+ * one it has decides both the style and the scripts region.
93
+ */
94
+ const LEGACY_OPTION = '<button class="opt"'
95
+
96
+ /**
97
+ * What reveals feedback in a lesson written against `LEGACY_OPTION`. The radio
98
+ * shape needs none of it, so a lesson carrying no button option gets no script,
99
+ * and removing this outright would leave the lessons already written showing no
100
+ * feedback at all.
101
+ */
53
102
  const QUIZ_SCRIPT =
54
103
  '<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>'
55
104
 
@@ -662,11 +711,20 @@ async function rewriteLesson(
662
711
  metas.map<TrackState>((_, i) => (i === index ? 'here' : 'done')),
663
712
  )
664
713
 
714
+ // One detector decides both mechanisms, because a lesson carries one quiz
715
+ // shape or the other and the two cannot share a page. The stepper's rules
716
+ // match a button question that can never hold a checked radio, so a lesson
717
+ // given both would hide every question past the first for good and outrank
718
+ // the class the script reveals feedback with.
719
+ const legacy = html.includes(LEGACY_OPTION)
720
+
665
721
  const regions: ReadonlyArray<readonly [Region, string]> = [
666
- ['style', `<style>\n${css}\n</style>`],
722
+ // The stepper follows the workspace stylesheet so it wins the cascade at
723
+ // equal specificity, which is what reaches a workspace seeded before it.
724
+ ['style', `<style>\n${legacy ? css : `${css}\n${QUIZ_CSS}`}\n</style>`],
667
725
  ['header', header],
668
726
  ['footnav', renderFootNav(metas, index)],
669
- ['scripts', renderScripts(html.includes('class="quiz"'), false)],
727
+ ['scripts', renderScripts(legacy, false)],
670
728
  ]
671
729
 
672
730
  for (const [region, content] of regions) {
@@ -75,6 +75,44 @@ const ORDINAL_WIDTH = 2
75
75
 
76
76
  const DATE_LENGTH = 'YYYY-MM-DD'.length
77
77
 
78
+ const MS_PER_DAY = 24 * 60 * 60 * 1000
79
+
80
+ /** The heading a learning record schedules its revisits under. */
81
+ const REVISIT_HEADING = '## Revisit'
82
+
83
+ /**
84
+ * The gaps a revisit item moves through, in days. A rung is a one-based index
85
+ * into this list, widening after a retrieval the learner passed unaided and
86
+ * narrowing after a miss, which is the spacing rule the pedagogy reference
87
+ * states. The values are here rather than in a skill body because a session
88
+ * told to widen a gap still picks the number by judgment.
89
+ */
90
+ const REVISIT_LADDER = [1, 3, 7, 16, 35] as const
91
+
92
+ /**
93
+ * One scheduled entry, as a learning record spells it. The rung is carried in
94
+ * the record rather than derived from how many records name the item, since a
95
+ * miss drops it back and a count only ever climbs.
96
+ */
97
+ const REVISIT_ENTRY =
98
+ /^-\s+\*\*(.+?)\*\*:\s*due\s+(\d{4}-\d{2}-\d{2}),\s*rung\s+([1-9]\d*)\s*$/
99
+
100
+ export interface RevisitItem {
101
+ /** What comes back up, as the record names it. */
102
+ readonly item: string
103
+ /** The day it comes back up, as `YYYY-MM-DD`. */
104
+ readonly date: string
105
+ readonly rung: number
106
+ /** True once that day has arrived or passed. */
107
+ readonly overdue: boolean
108
+ /** The date to schedule when the learner retrieves it unaided. */
109
+ readonly hit: string
110
+ /** The date to schedule when they miss it. */
111
+ readonly miss: string
112
+ /** The record the surviving entry was written in. */
113
+ readonly record: string
114
+ }
115
+
78
116
  export interface WorkspaceSummary {
79
117
  readonly slug: string
80
118
  /** `NaN` when the folder name carries no ordinal, which a listing reports. */
@@ -99,6 +137,12 @@ export interface WorkspaceDetail extends WorkspaceSummary {
99
137
  readonly glossary: readonly string[]
100
138
  /** The mission's success lines, which a session reports progress against. */
101
139
  readonly success: readonly string[]
140
+ /**
141
+ * What the learning records schedule, soonest first. A session reads this
142
+ * before it has chosen what to teach, which is why it rides on the listing
143
+ * rather than on the verb that resolves a lesson already picked.
144
+ */
145
+ readonly due: readonly RevisitItem[]
102
146
  }
103
147
 
104
148
  export interface WorkspacesListed {
@@ -242,6 +286,103 @@ function successLines(text: string): string[] {
242
286
  )
243
287
  }
244
288
 
289
+ /**
290
+ * Whether a `YYYY-MM-DD` string names a real day.
291
+ *
292
+ * The entry pattern admits `2026-13-45`, since it counts digits rather than
293
+ * reading a calendar, and `addDays` throws on a date that names no day, so a
294
+ * single mistyped record would take down every listing that reads the
295
+ * workspace. Every date reaching `addDays` passes here first.
296
+ */
297
+ function isDay(date: string): boolean {
298
+ return !Number.isNaN(Date.parse(`${date}T00:00:00Z`))
299
+ }
300
+
301
+ /** A day offset from a real `YYYY-MM-DD` date, back out in the same form. */
302
+ function addDays(date: string, days: number): string {
303
+ const start = Date.parse(`${date}T00:00:00Z`)
304
+ return new Date(start + days * MS_PER_DAY).toISOString().slice(0, DATE_LENGTH)
305
+ }
306
+
307
+ /** A rung moved by one step, held inside the ladder at both ends. */
308
+ function stepRung(rung: number, step: number): number {
309
+ return Math.min(REVISIT_LADDER.length, Math.max(1, rung + step))
310
+ }
311
+
312
+ function gapFor(rung: number, step: number): number {
313
+ return REVISIT_LADDER[stepRung(rung, step) - 1]
314
+ }
315
+
316
+ export interface RecordText {
317
+ readonly file: string
318
+ readonly text: string
319
+ }
320
+
321
+ /**
322
+ * Every revisit a workspace's learning records schedule, soonest first.
323
+ *
324
+ * Records arrive in read order and a later one supersedes an earlier entry for
325
+ * the same item, since the schedule is the state of one topic rather than a log
326
+ * of every time it was set. Matching is case-insensitive on the item, so a
327
+ * session that capitalized differently in a later record still supersedes
328
+ * rather than opening a second schedule beside the first.
329
+ *
330
+ * An entry not in the recorded shape is skipped rather than reported. A note
331
+ * carrying no date schedules nothing, which is what the standard's anti-pattern
332
+ * names, and this reader has no date to put on it. A date naming no real day is
333
+ * skipped on the same footing, since a schedule nothing can order is a note.
334
+ */
335
+ export function readRevisits(
336
+ records: readonly RecordText[],
337
+ today: string,
338
+ ): RevisitItem[] {
339
+ if (!isDay(today)) return []
340
+
341
+ const scheduled = new Map<
342
+ string,
343
+ { item: string; date: string; rung: number; record: string }
344
+ >()
345
+
346
+ for (const record of records) {
347
+ const lines = record.text.split('\n')
348
+ const section = sectionRange(
349
+ unfenced(record.text),
350
+ REVISIT_HEADING,
351
+ lines.length,
352
+ )
353
+
354
+ if (!section) continue
355
+
356
+ for (const block of bulletBlocks(lines.slice(section.start, section.end))) {
357
+ const match = REVISIT_ENTRY.exec(block.join(' ').replace(/\s+/g, ' '))
358
+ if (!match || !isDay(match[2])) continue
359
+
360
+ scheduled.set(match[1].toLowerCase(), {
361
+ item: match[1],
362
+ date: match[2],
363
+ rung: Number(match[3]),
364
+ record: record.file,
365
+ })
366
+ }
367
+ }
368
+
369
+ return [...scheduled.values()]
370
+ .map((entry) => ({
371
+ item: entry.item,
372
+ date: entry.date,
373
+ rung: entry.rung,
374
+ overdue: entry.date <= today,
375
+ hit: addDays(today, gapFor(entry.rung, 1)),
376
+ miss: addDays(today, gapFor(entry.rung, -1)),
377
+ record: entry.record,
378
+ }))
379
+ .sort(
380
+ (left, right) =>
381
+ left.date.localeCompare(right.date) ||
382
+ left.item.localeCompare(right.item),
383
+ )
384
+ }
385
+
245
386
  /** The ordinal a folder name carries, or `NaN` when it carries none. */
246
387
  function ordinalOf(slug: string): number {
247
388
  const match = WORKSPACE_NAME.exec(slug)
@@ -272,6 +413,15 @@ async function summarize(
272
413
  filesIn(dir, TEACH_REFERENCE, '.md'),
273
414
  ])
274
415
 
416
+ // Read order is filename order, which is record order, so a later record
417
+ // supersedes an earlier schedule for the same item.
418
+ const records = await Promise.all(
419
+ recordFiles.map(async (file) => ({
420
+ file,
421
+ text: await readFile(join(dir, TEACH_RECORDS, file), 'utf8'),
422
+ })),
423
+ )
424
+
275
425
  return {
276
426
  slug,
277
427
  ordinal: match ? Number(match[1]) : Number.NaN,
@@ -291,6 +441,7 @@ async function summarize(
291
441
  referenceFiles,
292
442
  glossary,
293
443
  success: mission === undefined ? [] : successLines(mission),
444
+ due: readRevisits(records, today()),
294
445
  }
295
446
  }
296
447
 
@@ -90,11 +90,27 @@ One record per session the learner worked through, holding what happened rather
90
90
 
91
91
  - The lessons covered, by number
92
92
  - What the learner retrieved correctly without help
93
- - What they got wrong, and what the wrong answer was
94
- - What to revisit, and roughly when
93
+ - What they got wrong, and what the wrong answer was, including what an explanation the learner produced left out
94
+ - A `## Revisit` section scheduling what comes back up
95
95
 
96
96
  Record the wrong answer rather than the fact of an error. A wrong answer names the misconception, and the count alone names nothing.
97
97
 
98
+ A produced explanation and a selected option are both retrieval, so a gap in an explanation goes under what the learner got wrong rather than under a heading of its own. Name the concept and where the explanation broke. Splitting the two puts one input to the next session's placement across two sections, and the placement reads both or neither.
99
+
100
+ ### The revisit section
101
+
102
+ Write one bullet per item, in this shape and no other:
103
+
104
+ ```markdown
105
+ ## Revisit
106
+
107
+ - **<what comes back up>**: due <YYYY-MM-DD>, rung <n>
108
+ ```
109
+
110
+ The rung is where the item sits on the spacing ladder, and it travels in the record rather than being counted from how many records name the item, since a miss drops it back and a count only climbs. A later record naming the same item supersedes an earlier one, so revise the schedule by writing the new entry rather than editing the old record.
111
+
112
+ Take the date and the rung from what the surface driving the workspace reports rather than picking either. An entry outside this shape schedules nothing, and nothing reports that it was skipped.
113
+
98
114
  ## GLOSSARY.md
99
115
 
100
116
  Required in every workspace, holding one entry per term the subject defines. The glossary reference the `canon:claude-teach` skill carries fixes what an entry looks like, how the file orders and groups them, and which terms it carries, so this standard states only that the file exists and sits at the workspace root. That reference ships with the plugin rather than installing here, because a promoted glossary keeps its shape wherever it lands and no project folder covers every destination. Say so and stop rather than working the shape from memory when the project has no plugin to read it from.
@@ -122,6 +138,7 @@ A source found and not read is listed as a lead because a link attached to a pag
122
138
  - **The mission with no test.** Success stated as understanding rather than as a task leaves nothing able to say the mission is finished.
123
139
  - **The lesson filed as reference.** A page written to be worked through once sits in `lessons/`, and putting it in `reference/` puts disposable output in the half that gets promoted.
124
140
  - **The record that counts errors.** A tally of how many were missed carries none of the misconception, which is the only part a later session can act on.
141
+ - **The revisit note with no date.** A line naming what to come back to and not when is read by nothing and schedules nothing, so the next session picks from the last record alone, which is the state the section exists to end.
125
142
  - **The date left in the body.** A frontmatter field and a sentence both claiming the opening date resolve to whichever a reader happens to hit, and only one of them is readable by a walker.
126
143
  - **The renumbered folder.** Closing a gap in the ordinals moves every name a reader or a record already cited.
127
144
 
@@ -22,5 +22,6 @@ export default defineConfig({
22
22
  command: 'bun run build && bun run preview',
23
23
  url: baseURL,
24
24
  reuseExistingServer: false,
25
+ env: { ASTRO_PREVIEW_BACKGROUND: '0' },
25
26
  },
26
27
  })