@erclx/canon 4.111.0 → 4.112.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/assets/brand/mark.svg +10 -0
- package/claude/.claude-plugin/plugin.json +1 -1
- package/claude/skills/auto-ship/REQUIREMENT.md +1 -0
- package/claude/skills/auto-ship/SKILL.md +12 -2
- package/claude/skills/git-pr/REQUIREMENT.md +2 -0
- package/claude/skills/git-pr/SKILL.md +21 -3
- package/claude/skills/teach-workspace/SKILL.md +1 -1
- package/docs/agents/teach.md +3 -1
- package/package.json +2 -1
- package/src/design/components.ts +68 -87
- package/src/design/favicon.ts +8 -0
- package/src/design/tokens.ts +1 -1
- package/src/teach/nav.ts +51 -11
|
@@ -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>
|
|
@@ -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,
|
|
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
|
|
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 |
|
|
@@ -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
|
-
|
|
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
|
-
-
|
|
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
|
|
package/docs/agents/teach.md
CHANGED
|
@@ -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
|
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@erclx/canon",
|
|
3
3
|
"type": "module",
|
|
4
|
-
"version": "4.
|
|
4
|
+
"version": "4.112.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",
|
package/src/design/components.ts
CHANGED
|
@@ -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.
|
|
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:
|
|
396
|
+
font-size: var(--t6);
|
|
397
397
|
font-weight: 700;
|
|
398
|
-
letter-spacing: 0
|
|
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:
|
|
1002
|
-
line-height: 1.
|
|
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:
|
|
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.
|
|
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:
|
|
1022
|
-
line-height: 1.
|
|
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:
|
|
1035
|
-
line-height: 1.
|
|
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
|
|
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.
|
|
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:
|
|
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:
|
|
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:
|
|
1104
|
-
letter-spacing: 0
|
|
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-
|
|
1131
|
-
.toc b { font-size:
|
|
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.
|
|
1137
|
+
line-height: 1.55;
|
|
1137
1138
|
color: var(--color-text-secondary);
|
|
1138
1139
|
margin-top: 0.1rem;
|
|
1139
1140
|
}
|
|
1140
1141
|
|
|
1141
|
-
/* A
|
|
1142
|
-
|
|
1143
|
-
|
|
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
|
-
|
|
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:
|
|
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.
|
|
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:
|
|
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:
|
|
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: "
|
|
1312
|
+
content: "Your answer";
|
|
1333
1313
|
margin-left: auto;
|
|
1334
|
-
font-size:
|
|
1314
|
+
font-size: var(--t6);
|
|
1335
1315
|
font-weight: 700;
|
|
1336
|
-
letter-spacing: 0
|
|
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: "
|
|
1322
|
+
content: "Correct";
|
|
1344
1323
|
margin-left: auto;
|
|
1345
|
-
font-size:
|
|
1324
|
+
font-size: var(--t6);
|
|
1346
1325
|
font-weight: 700;
|
|
1347
|
-
letter-spacing: 0
|
|
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
|
-
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
|
|
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
|
-
|
|
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:
|
|
1415
|
-
border:
|
|
1416
|
-
|
|
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
|
-
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
|
|
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
|
-
|
|
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.
|
|
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:
|
|
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:
|
|
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.
|
|
1573
|
+
line-height: 1.6;
|
|
1593
1574
|
}
|
|
1594
1575
|
|
|
1595
1576
|
ol.refs li {
|
package/src/design/favicon.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/design/tokens.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
-
${
|
|
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
|
|
873
|
-
<input class="filter" type="search" id="gfilter" aria-label="Filter glossary terms" aria-controls="gloss" placeholder="
|
|
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) · ${workspace.reference} reference page(s) · ${workspace.terms} term(s)`
|
|
915
|
-
const inner = `<span class="num">${ordinalOf(workspace)}</span><b>${escapeHtml(titleCase(workspace.topic))}</b><span class="state
|
|
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="
|
|
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
|
|
1178
|
-
// one region that sits
|
|
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
|
-
|
|
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}`],
|