@erclx/canon 4.49.0 → 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.
- package/claude/.claude-plugin/plugin.json +1 -1
- package/package.json +1 -1
- package/src/design/base.css +8 -0
- package/src/design/css.ts +1 -0
- package/src/design/tokens.ts +26 -2
- package/src/tasks/archive.ts +44 -20
- package/src/tasks/record.ts +4 -3
- package/tooling/astro/configs/playwright.config.ts +1 -0
package/package.json
CHANGED
package/src/design/base.css
CHANGED
|
@@ -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
|
}
|
package/src/design/tokens.ts
CHANGED
|
@@ -68,9 +68,21 @@ export interface DesignTokens {
|
|
|
68
68
|
readonly preamble: string
|
|
69
69
|
}
|
|
70
70
|
|
|
71
|
-
/** The
|
|
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
|
|
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,
|
package/src/tasks/archive.ts
CHANGED
|
@@ -400,7 +400,8 @@ export async function planCitations(
|
|
|
400
400
|
|
|
401
401
|
const stems = await listTaskStems(dir)
|
|
402
402
|
if (!stems.includes(stem)) {
|
|
403
|
-
|
|
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
|
|
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
|
}
|
package/src/tasks/record.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
}
|