@erclx/canon 5.22.0 → 5.24.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/claude/skills/canon-operator/REQUIREMENT.md +1 -0
- package/claude/skills/canon-operator/SKILL.md +5 -1
- package/claude/skills/draft-doc/REQUIREMENT.md +1 -0
- package/claude/skills/draft-doc/references/readme/agent-facing.md +59 -0
- package/claude/skills/draft-doc/references/readme/application.md +66 -0
- package/claude/skills/draft-doc/references/readme/cli.md +64 -0
- package/claude/skills/draft-doc/references/readme/library.md +57 -0
- package/claude/skills/draft-doc/references/readme/plugin.md +66 -0
- package/claude/skills/draft-doc/references/readme.md +33 -4
- package/claude/skills/target-check/SKILL.md +9 -9
- package/claude/skills/target-check/references/domains.md +11 -5
- package/docs/agents/architecture-record.md +2 -2
- package/docs/agents/canvas.md +5 -1
- package/docs/agents/index.md +1 -1
- package/docs/agents/tasks-validate.md +4 -2
- package/docs/agents/tasks.md +1 -1
- package/docs/target/projects.md +1 -1
- package/docs/workflow/visual-design-workflow.md +1 -1
- package/package.json +1 -1
- package/src/canvas/client/inspector.tsx +46 -4
- package/src/canvas/client/shell.css +7 -0
- package/src/canvas/tokens.ts +9 -1
- package/src/commands/canvas.ts +29 -0
- package/src/commands/tasks.ts +16 -0
- package/src/design/budget.ts +149 -0
- package/src/design/document.ts +22 -10
- package/src/design/render.ts +11 -1
- package/src/design/tokens.ts +40 -61
- package/src/gate/measures.ts +49 -0
- package/src/gate/stages.ts +5 -1
- package/src/tasks/validate.ts +65 -0
- package/standards/architecture.md +2 -2
- package/standards/board.md +2 -0
- package/standards/design.md +13 -3
- package/standards/requirements.md +3 -1
- package/tooling/claude/seeds/.claude/hooks/path-form.sh +2 -2
- package/tooling/claude/seeds/.claude/hooks/pr-create-log.sh +2 -2
- package/tooling/claude/seeds/.claude/hooks/{tasks-index.sh → records-index.sh} +42 -35
- package/tooling/claude/seeds/.claude/hooks/scratch-guard.sh +1 -1
- package/tooling/claude/seeds/.claude/settings.json +1 -5
- package/tooling/claude/seeds/canon/ARCHITECTURE.md +1 -1
- package/tooling/claude/seeds/canon/REQUIREMENTS.md +2 -0
- package/tooling/claude/seeds/.claude/hooks/memory-index.sh +0 -95
|
@@ -76,15 +76,49 @@ function positionRows(element: Element): Row[] {
|
|
|
76
76
|
]
|
|
77
77
|
}
|
|
78
78
|
|
|
79
|
+
function inlineValue(element: Element, property: string): string {
|
|
80
|
+
return 'style' in element
|
|
81
|
+
? (element as HTMLElement).style.getPropertyValue(property)
|
|
82
|
+
: ''
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Values that defer to something else, so they follow the theme as it does. */
|
|
86
|
+
const DEFERRING = new Set([
|
|
87
|
+
'currentcolor',
|
|
88
|
+
'inherit',
|
|
89
|
+
'initial',
|
|
90
|
+
'unset',
|
|
91
|
+
'revert',
|
|
92
|
+
'revert-layer',
|
|
93
|
+
])
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* A color that stays put when the theme changes: one naming no token anywhere
|
|
97
|
+
* in it and deferring to nothing, so `color-mix()` over a `var()` is not raw.
|
|
98
|
+
*/
|
|
99
|
+
export function isRawValue(value: string): boolean {
|
|
100
|
+
const normalized = value.trim().toLowerCase()
|
|
101
|
+
return (
|
|
102
|
+
normalized !== '' &&
|
|
103
|
+
!normalized.includes('var(') &&
|
|
104
|
+
!DEFERRING.has(normalized)
|
|
105
|
+
)
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Inherited and stylesheet values are not the element's own, so only an
|
|
110
|
+
* inline value counts.
|
|
111
|
+
*/
|
|
112
|
+
function isRawInline(element: Element, property: string): boolean {
|
|
113
|
+
return isRawValue(inlineValue(element, property))
|
|
114
|
+
}
|
|
115
|
+
|
|
79
116
|
/**
|
|
80
117
|
* What the element states inline, else the value the browser settled on, so
|
|
81
118
|
* an edit starts from what the operator sees.
|
|
82
119
|
*/
|
|
83
120
|
function currentValue(element: Element, property: string): string {
|
|
84
|
-
const inline =
|
|
85
|
-
'style' in element
|
|
86
|
-
? (element as HTMLElement).style.getPropertyValue(property)
|
|
87
|
-
: ''
|
|
121
|
+
const inline = inlineValue(element, property)
|
|
88
122
|
if (inline) return inline
|
|
89
123
|
return (
|
|
90
124
|
element.ownerDocument.defaultView
|
|
@@ -223,6 +257,14 @@ function ElementFields({
|
|
|
223
257
|
isBusy={isBusy}
|
|
224
258
|
onCommit={commit(field.property)}
|
|
225
259
|
/>
|
|
260
|
+
{field.isColor && isRawInline(node, field.property) ? (
|
|
261
|
+
<span
|
|
262
|
+
class="raw"
|
|
263
|
+
title="Set as a raw value, so it will not follow the theme. Pick a token to fix it"
|
|
264
|
+
>
|
|
265
|
+
raw
|
|
266
|
+
</span>
|
|
267
|
+
) : null}
|
|
226
268
|
{field.isColor ? (
|
|
227
269
|
<TokenPicker
|
|
228
270
|
label={field.label}
|
package/src/canvas/tokens.ts
CHANGED
|
@@ -138,7 +138,15 @@ export function resolveFrameTokens(
|
|
|
138
138
|
options: TokenOptions = {},
|
|
139
139
|
): FrameTokens {
|
|
140
140
|
if (options.isOwnCheckout ?? isOwnCheckout(root)) {
|
|
141
|
-
|
|
141
|
+
/*
|
|
142
|
+
* Faces embedded, since the type tokens name `Geist Variable` and a
|
|
143
|
+
* machine with only static Geist installed renders every frame in the
|
|
144
|
+
* fallback. An installed base owns its fonts, so that branch gets none.
|
|
145
|
+
*/
|
|
146
|
+
return {
|
|
147
|
+
source: 'toolkit',
|
|
148
|
+
css: buildDesignCss(undefined, { embedFonts: true }),
|
|
149
|
+
}
|
|
142
150
|
}
|
|
143
151
|
|
|
144
152
|
const files = installedFiles(root)
|
package/src/commands/canvas.ts
CHANGED
|
@@ -20,6 +20,7 @@ import {
|
|
|
20
20
|
STYLE_PROPERTIES,
|
|
21
21
|
} from '@/canvas/edit'
|
|
22
22
|
import { CANVAS_PORT, startCanvas } from '@/canvas/server'
|
|
23
|
+
import { PROJECT_ROOT } from '@/project-root'
|
|
23
24
|
import { resolveFrameTokens } from '@/canvas/tokens'
|
|
24
25
|
import { displayPath, parsePort, waitForInterrupt } from '@/serve/report'
|
|
25
26
|
import {
|
|
@@ -49,9 +50,35 @@ interface Refused {
|
|
|
49
50
|
| 'no-root'
|
|
50
51
|
| 'no-server'
|
|
51
52
|
| 'capture-failed'
|
|
53
|
+
| 'missing-client-deps'
|
|
52
54
|
readonly detail: string
|
|
53
55
|
}
|
|
54
56
|
|
|
57
|
+
const CLIENT_DEPS = ['preact', '@preact/signals'] as const
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Refuses when the shell's client packages do not resolve from `from`, since
|
|
61
|
+
* the bundler then answers `/` with an empty page rather than an error. The
|
|
62
|
+
* caller passes the package root the binary runs from, never the served
|
|
63
|
+
* project's, so a global install serving a target finds its own copies.
|
|
64
|
+
*/
|
|
65
|
+
export function missingClientDeps(from: string): Refused | undefined {
|
|
66
|
+
const missing = CLIENT_DEPS.filter((name) => {
|
|
67
|
+
try {
|
|
68
|
+
Bun.resolveSync(name, from)
|
|
69
|
+
return false
|
|
70
|
+
} catch {
|
|
71
|
+
return true
|
|
72
|
+
}
|
|
73
|
+
})
|
|
74
|
+
if (missing.length === 0) return undefined
|
|
75
|
+
return {
|
|
76
|
+
ok: false,
|
|
77
|
+
reason: 'missing-client-deps',
|
|
78
|
+
detail: `${missing.join(', ')} not installed in ${from}, so the canvas shell cannot load. Run bun install there`,
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
55
82
|
type RootOutcome = { readonly ok: true; readonly root: string } | Refused
|
|
56
83
|
|
|
57
84
|
/**
|
|
@@ -637,6 +664,8 @@ async function runServe(
|
|
|
637
664
|
return refuseServe(resolved.reason, resolved.detail, emitJson)
|
|
638
665
|
}
|
|
639
666
|
const { root } = resolved
|
|
667
|
+
const missing = missingClientDeps(PROJECT_ROOT)
|
|
668
|
+
if (missing) return refuseServe(missing.reason, missing.detail, emitJson)
|
|
640
669
|
/*
|
|
641
670
|
* Imported here rather than at the top, so a command module the test runner
|
|
642
671
|
* loads never asks it for an HTML module it cannot parse.
|
package/src/commands/tasks.ts
CHANGED
|
@@ -42,6 +42,7 @@ import {
|
|
|
42
42
|
import {
|
|
43
43
|
type Finding,
|
|
44
44
|
type FolderClaim,
|
|
45
|
+
type WideToken,
|
|
45
46
|
type Unplaced,
|
|
46
47
|
type Untested,
|
|
47
48
|
type ValidateOutcome,
|
|
@@ -1411,6 +1412,16 @@ function reportValidation(
|
|
|
1411
1412
|
for (const claim of outcome.claims) logWarn(describeClaim(claim))
|
|
1412
1413
|
}
|
|
1413
1414
|
|
|
1415
|
+
// The width limit is a heuristic over one preview pane, so a wide token
|
|
1416
|
+
// reports and moves no exit code, and a measure failing on a cell that
|
|
1417
|
+
// fits would teach a reader to skip it.
|
|
1418
|
+
logStep('Wide tokens')
|
|
1419
|
+
if (outcome.wide.length === 0) {
|
|
1420
|
+
logInfo('every code span fits the preview pane')
|
|
1421
|
+
} else {
|
|
1422
|
+
for (const token of outcome.wide) logWarn(describeWide(token))
|
|
1423
|
+
}
|
|
1424
|
+
|
|
1414
1425
|
// Unplaced is the normal state between a session filing a task and an
|
|
1415
1426
|
// orchestrator placing its row, so it moves no exit code. It still reports,
|
|
1416
1427
|
// since it is the only local detector for a row a hand-edit dropped or a
|
|
@@ -1436,6 +1447,7 @@ function reportValidation(
|
|
|
1436
1447
|
findings: outcome.findings,
|
|
1437
1448
|
untested: outcome.untested,
|
|
1438
1449
|
claims: outcome.claims,
|
|
1450
|
+
wide: outcome.wide,
|
|
1439
1451
|
unplaced: outcome.unplaced,
|
|
1440
1452
|
})}\n`,
|
|
1441
1453
|
)
|
|
@@ -1457,6 +1469,10 @@ function describeClaim(claim: FolderClaim): string {
|
|
|
1457
1469
|
return `${claim.group}: ${claim.subject} ${claim.message}`
|
|
1458
1470
|
}
|
|
1459
1471
|
|
|
1472
|
+
function describeWide(token: WideToken): string {
|
|
1473
|
+
return `${token.group}: ${token.subject} ${token.message}`
|
|
1474
|
+
}
|
|
1475
|
+
|
|
1460
1476
|
function describeUnplaced(task: Unplaced): string {
|
|
1461
1477
|
return `${task.subject} ${task.message}`
|
|
1462
1478
|
}
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The prose budget `standards/design.md` sets for a design record, and the
|
|
3
|
+
* counter the `design` gate stage reads it through.
|
|
4
|
+
*
|
|
5
|
+
* The limits live here and in the standard, never in the stage, so the stage
|
|
6
|
+
* names a failure without restating what it measured against.
|
|
7
|
+
*
|
|
8
|
+
* A row is excluded on the same test `table()` in `@/design/parse` reads one
|
|
9
|
+
* by, a line opening with a pipe, so a code span carrying a pipe or a cell
|
|
10
|
+
* carrying the uncertainty tag never reaches the count. A line opening with
|
|
11
|
+
* `<` is dropped the way `prose()` drops it, and so is a fenced block or an
|
|
12
|
+
* HTML comment spanning several lines, which `prose()` would read in part. A
|
|
13
|
+
* `###` subsection counts toward the `##` section holding it, since the
|
|
14
|
+
* standard fixes the sections and a subheading is no way past the cap.
|
|
15
|
+
*/
|
|
16
|
+
export const DESIGN_BUDGET = {
|
|
17
|
+
/** Words before the first `##` section, the title excluded. */
|
|
18
|
+
leadWords: 40,
|
|
19
|
+
/** Words in `## Personality`, held to one paragraph. */
|
|
20
|
+
personalityWords: 60,
|
|
21
|
+
personalityParagraphs: 1,
|
|
22
|
+
/** Bullets or paragraphs in any other section, beside its table. */
|
|
23
|
+
rules: 3,
|
|
24
|
+
/** Words in one of those rules, the proxy for one line. */
|
|
25
|
+
ruleWords: 25,
|
|
26
|
+
} as const
|
|
27
|
+
|
|
28
|
+
const PERSONALITY = 'Personality'
|
|
29
|
+
|
|
30
|
+
/** A list item at any depth, which starts a rule of its own. */
|
|
31
|
+
const BULLET = /^\s*(?:[-*+]|\d+[.)])\s+/
|
|
32
|
+
|
|
33
|
+
interface Section {
|
|
34
|
+
readonly heading: string | undefined
|
|
35
|
+
readonly lines: readonly string[]
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Every place the record exceeds the budget, one sentence each, in document
|
|
40
|
+
* order. An empty list is a record within budget, including an empty one.
|
|
41
|
+
*/
|
|
42
|
+
export function measureDesignProse(source: string): string[] {
|
|
43
|
+
const findings: string[] = []
|
|
44
|
+
|
|
45
|
+
for (const section of splitSections(source)) {
|
|
46
|
+
const rules = readRules(section.lines)
|
|
47
|
+
const total = rules.reduce((sum, rule) => sum + rule, 0)
|
|
48
|
+
|
|
49
|
+
if (section.heading === undefined) {
|
|
50
|
+
if (total > DESIGN_BUDGET.leadWords) {
|
|
51
|
+
findings.push(
|
|
52
|
+
`The lead holds ${total} words against a cap of ${DESIGN_BUDGET.leadWords}`,
|
|
53
|
+
)
|
|
54
|
+
}
|
|
55
|
+
} else if (section.heading === PERSONALITY) {
|
|
56
|
+
if (rules.length > DESIGN_BUDGET.personalityParagraphs) {
|
|
57
|
+
findings.push(
|
|
58
|
+
`${PERSONALITY} holds ${rules.length} paragraphs against a cap of ${DESIGN_BUDGET.personalityParagraphs}`,
|
|
59
|
+
)
|
|
60
|
+
}
|
|
61
|
+
if (total > DESIGN_BUDGET.personalityWords) {
|
|
62
|
+
findings.push(
|
|
63
|
+
`${PERSONALITY} holds ${total} words against a cap of ${DESIGN_BUDGET.personalityWords}`,
|
|
64
|
+
)
|
|
65
|
+
}
|
|
66
|
+
} else {
|
|
67
|
+
if (rules.length > DESIGN_BUDGET.rules) {
|
|
68
|
+
findings.push(
|
|
69
|
+
`${section.heading} holds ${rules.length} rules against a cap of ${DESIGN_BUDGET.rules}`,
|
|
70
|
+
)
|
|
71
|
+
}
|
|
72
|
+
rules.forEach((words, index) => {
|
|
73
|
+
if (words > DESIGN_BUDGET.ruleWords) {
|
|
74
|
+
findings.push(
|
|
75
|
+
`${section.heading} rule ${index + 1} holds ${words} words against a cap of ${DESIGN_BUDGET.ruleWords}`,
|
|
76
|
+
)
|
|
77
|
+
}
|
|
78
|
+
})
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return findings
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** The lead under an undefined heading, then each `##` section in order. */
|
|
86
|
+
function splitSections(source: string): Section[] {
|
|
87
|
+
const sections: { heading: string | undefined; lines: string[] }[] = [
|
|
88
|
+
{ heading: undefined, lines: [] },
|
|
89
|
+
]
|
|
90
|
+
for (const line of source.split('\n')) {
|
|
91
|
+
const match = line.match(/^##\s+(.+?)\s*$/)
|
|
92
|
+
if (match) {
|
|
93
|
+
sections.push({ heading: match[1], lines: [] })
|
|
94
|
+
} else if (!/^#\s/.test(line)) {
|
|
95
|
+
sections[sections.length - 1].lines.push(line)
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
return sections
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* The word count of each rule in a section. A rule opens on a bullet or on
|
|
103
|
+
* the first prose line after a break, and every following prose line joins it
|
|
104
|
+
* until a blank line, a table row, a heading, or the next bullet.
|
|
105
|
+
*/
|
|
106
|
+
function readRules(lines: readonly string[]): number[] {
|
|
107
|
+
const rules: number[] = []
|
|
108
|
+
let isOpen = false
|
|
109
|
+
let isFenced = false
|
|
110
|
+
let isCommented = false
|
|
111
|
+
|
|
112
|
+
for (const line of lines) {
|
|
113
|
+
const trimmed = line.trim()
|
|
114
|
+
if (isCommented) {
|
|
115
|
+
isCommented = !trimmed.includes('-->')
|
|
116
|
+
continue
|
|
117
|
+
}
|
|
118
|
+
if (trimmed.startsWith('<!--')) {
|
|
119
|
+
isCommented = !trimmed.includes('-->', 4)
|
|
120
|
+
isOpen = false
|
|
121
|
+
continue
|
|
122
|
+
}
|
|
123
|
+
if (trimmed.startsWith('```')) {
|
|
124
|
+
isFenced = !isFenced
|
|
125
|
+
isOpen = false
|
|
126
|
+
continue
|
|
127
|
+
}
|
|
128
|
+
if (
|
|
129
|
+
isFenced ||
|
|
130
|
+
trimmed === '' ||
|
|
131
|
+
trimmed.startsWith('|') ||
|
|
132
|
+
trimmed.startsWith('#') ||
|
|
133
|
+
trimmed.startsWith('<')
|
|
134
|
+
) {
|
|
135
|
+
isOpen = false
|
|
136
|
+
continue
|
|
137
|
+
}
|
|
138
|
+
const text = trimmed.replace(BULLET, '')
|
|
139
|
+
const count = text.split(/\s+/).filter(Boolean).length
|
|
140
|
+
if (BULLET.test(line) || !isOpen) {
|
|
141
|
+
rules.push(count)
|
|
142
|
+
isOpen = true
|
|
143
|
+
} else {
|
|
144
|
+
rules[rules.length - 1] += count
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
return rules
|
|
149
|
+
}
|
package/src/design/document.ts
CHANGED
|
@@ -88,6 +88,20 @@ function borderRows(tokens: readonly BorderToken[]): string[][] {
|
|
|
88
88
|
})
|
|
89
89
|
}
|
|
90
90
|
|
|
91
|
+
/**
|
|
92
|
+
* The lead a reader meets before the first section. It points at the module
|
|
93
|
+
* and at the context entry, since the record carries values and one-line rules
|
|
94
|
+
* and every reason behind them lives in the entry.
|
|
95
|
+
*/
|
|
96
|
+
const LEAD = [
|
|
97
|
+
'Authoring guidance: `standards/design.md`. Rendered from `src/design/tokens.ts` by `canon design regen`, so edit the module and never this file.',
|
|
98
|
+
'Why each value was chosen lives in `canon/context/design/tokens.md`.',
|
|
99
|
+
].join(' ')
|
|
100
|
+
|
|
101
|
+
function bullets(rules: readonly string[]): string {
|
|
102
|
+
return rules.map((rule) => `- ${rule}`).join('\n')
|
|
103
|
+
}
|
|
104
|
+
|
|
91
105
|
function section(heading: string, ...blocks: string[]): string {
|
|
92
106
|
return [`## ${heading}`, ...blocks.filter((block) => block !== '')].join(
|
|
93
107
|
'\n\n',
|
|
@@ -98,21 +112,19 @@ export function renderDesignDocument(tokens: DesignTokens = TOKENS): string {
|
|
|
98
112
|
const body = [
|
|
99
113
|
'# Design',
|
|
100
114
|
'',
|
|
101
|
-
|
|
102
|
-
'',
|
|
103
|
-
tokens.preamble,
|
|
115
|
+
LEAD,
|
|
104
116
|
'',
|
|
105
117
|
section('Personality', tokens.personality),
|
|
106
118
|
'',
|
|
107
119
|
section(
|
|
108
120
|
'Color',
|
|
109
|
-
tokens.
|
|
121
|
+
bullets(tokens.colorRules),
|
|
110
122
|
formatTable(['Role', 'Intent', 'Value'], colorRows(tokens.color)),
|
|
111
123
|
),
|
|
112
124
|
'',
|
|
113
125
|
section(
|
|
114
126
|
'Typography',
|
|
115
|
-
tokens.
|
|
127
|
+
bullets(tokens.typographyRules),
|
|
116
128
|
formatTable(
|
|
117
129
|
['Role', 'Family', 'Weight', 'Size', 'Line height'],
|
|
118
130
|
typeRows(tokens.typography),
|
|
@@ -121,24 +133,24 @@ export function renderDesignDocument(tokens: DesignTokens = TOKENS): string {
|
|
|
121
133
|
'',
|
|
122
134
|
section(
|
|
123
135
|
'Spacing',
|
|
124
|
-
tokens.
|
|
136
|
+
bullets(tokens.spacingRules),
|
|
125
137
|
formatTable(['Step', 'Multiplier', 'Value'], spaceRows(tokens.spacing)),
|
|
126
138
|
),
|
|
127
139
|
'',
|
|
128
140
|
section(
|
|
129
141
|
'Borders',
|
|
130
|
-
tokens.
|
|
142
|
+
bullets(tokens.bordersRules),
|
|
131
143
|
formatTable(
|
|
132
144
|
['Role', 'Radius', 'Width', 'When used'],
|
|
133
145
|
borderRows(tokens.borders),
|
|
134
146
|
),
|
|
135
147
|
),
|
|
136
148
|
'',
|
|
137
|
-
section('Layout', tokens.layout),
|
|
149
|
+
section('Layout', bullets(tokens.layout)),
|
|
138
150
|
'',
|
|
139
|
-
section('Motion', tokens.motion),
|
|
151
|
+
section('Motion', bullets(tokens.motion)),
|
|
140
152
|
'',
|
|
141
|
-
section('Iconography', tokens.iconography),
|
|
153
|
+
section('Iconography', bullets(tokens.iconography)),
|
|
142
154
|
]
|
|
143
155
|
|
|
144
156
|
return `${body.join('\n')}\n`
|
package/src/design/render.ts
CHANGED
|
@@ -328,7 +328,17 @@ function sectionBorders(rows: Row[]): string {
|
|
|
328
328
|
return `<h2>Borders</h2>\n<table><thead><tr><th>Role</th><th>Radius</th><th>Width</th><th>When used</th><th>Sample</th></tr></thead><tbody>${body}</tbody></table>`
|
|
329
329
|
}
|
|
330
330
|
|
|
331
|
+
/** A section the record writes as one-line rules draws them as a list. */
|
|
332
|
+
const BULLET = /^[-*+]\s+/
|
|
333
|
+
|
|
331
334
|
function sectionLine(title: string, text: string): string {
|
|
332
335
|
if (!text) return ''
|
|
333
|
-
|
|
336
|
+
const lines = text.split('\n')
|
|
337
|
+
if (!lines.every((line) => BULLET.test(line))) {
|
|
338
|
+
return `<h2>${title}</h2>\n<p>${escape(text)}</p>`
|
|
339
|
+
}
|
|
340
|
+
const items = lines
|
|
341
|
+
.map((line) => `<li>${escape(line.replace(BULLET, ''))}</li>`)
|
|
342
|
+
.join('')
|
|
343
|
+
return `<h2>${title}</h2>\n<ul>${items}</ul>`
|
|
334
344
|
}
|
package/src/design/tokens.ts
CHANGED
|
@@ -59,21 +59,25 @@ export interface BorderToken {
|
|
|
59
59
|
readonly verify?: readonly ('radius' | 'width')[]
|
|
60
60
|
}
|
|
61
61
|
|
|
62
|
+
/**
|
|
63
|
+
* Every prose slot is a list of one-line rules rather than a string, so a
|
|
64
|
+
* bullet is the unit the renderer emits and the prose budget counts, and a
|
|
65
|
+
* value needing a paragraph of why goes to `canon/context/design/tokens.md`.
|
|
66
|
+
*/
|
|
62
67
|
export interface DesignTokens {
|
|
63
68
|
readonly personality: string
|
|
64
69
|
readonly color: readonly ColorToken[]
|
|
65
|
-
readonly
|
|
70
|
+
readonly colorRules: readonly string[]
|
|
66
71
|
readonly typeScale: readonly TypeStep[]
|
|
67
72
|
readonly typography: readonly TypeToken[]
|
|
68
|
-
readonly
|
|
73
|
+
readonly typographyRules: readonly string[]
|
|
69
74
|
readonly spacing: readonly SpaceToken[]
|
|
70
|
-
readonly
|
|
75
|
+
readonly spacingRules: readonly string[]
|
|
71
76
|
readonly borders: readonly BorderToken[]
|
|
72
|
-
readonly
|
|
73
|
-
readonly layout: string
|
|
74
|
-
readonly motion: string
|
|
75
|
-
readonly iconography: string
|
|
76
|
-
readonly preamble: string
|
|
77
|
+
readonly bordersRules: readonly string[]
|
|
78
|
+
readonly layout: readonly string[]
|
|
79
|
+
readonly motion: readonly string[]
|
|
80
|
+
readonly iconography: readonly string[]
|
|
77
81
|
}
|
|
78
82
|
|
|
79
83
|
/** The monospace stack, taken by code and by the terminal frames a page embeds. */
|
|
@@ -94,35 +98,14 @@ const DARK_GROUNDS = ['background', 'surface'] as const
|
|
|
94
98
|
const LIGHT_GROUNDS = ['light-background', 'light-surface'] as const
|
|
95
99
|
|
|
96
100
|
export const TOKENS: DesignTokens = {
|
|
97
|
-
|
|
98
|
-
'
|
|
99
|
-
'',
|
|
100
|
-
'The values below are the system rather than a reading of one. Until 2026-09-01 this record transcribed two surfaces and agreed with nothing else, which is what made a change to it reach nobody. The slide theme, the token preview, and a teach workspace stylesheet now read the module this file is rendered from, so a value changed there changes what all three render.',
|
|
101
|
-
'',
|
|
102
|
-
'The two rendered captures read it as well. `scripts/core/regen-hero.ts` fills `assets/captures/hero.html.tmpl` and `assets/captures/install.html.tmpl` with what `canon design css --no-components` emits, so both frames now carry the custom properties rather than their own copies of the hex, and a value moved here moves what the next capture renders.',
|
|
103
|
-
'',
|
|
104
|
-
'The terminal framing is the one surface left holding its own values, and that is a decision rather than a gap. `scripts/lib/ui.sh` and `src/ui.ts` each spell six escape constants, and `canon/context/scripts/framing.md` records one color source per language with a check behind each, so generating a third spelling from here would break the rule those two checks enforce. What the record is still incomplete about is the other half of those six: `WHITE` and `GREY` name no role below, so the terminal palette is described here in part rather than in whole.',
|
|
105
|
-
].join('\n'),
|
|
106
|
-
|
|
107
|
-
personality: [
|
|
108
|
-
'Warm neutrals carry the frame under a single rust accent, set in Geist. The subject is a toolkit for people who read documents and diffs as much as they run commands, so the voice is a proportional one and the page reads as prose. 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.',
|
|
109
|
-
'',
|
|
110
|
-
'Monospace is a role the page uses rather than its voice. The `code` role takes it, and so does any terminal frame a surface embeds, since those show what the shell printed and matching the shell is what makes them legible. Every other role is Geist.',
|
|
111
|
-
].join('\n'),
|
|
101
|
+
personality:
|
|
102
|
+
'Warm neutrals under one rust accent, set in Geist. The page reads as prose, and monospace appears only where a surface shows what a shell printed. One accent carries every count, link, and primary action.',
|
|
112
103
|
|
|
113
|
-
|
|
114
|
-
'
|
|
115
|
-
'',
|
|
116
|
-
'
|
|
117
|
-
|
|
118
|
-
'Success is the one of the three that does have a rendered equivalent, which is why it carries a hex. `assets/captures/install.html` marks every confirmed step with it, and the shell writes `ANSI 32` for the same role, so the two are one role in two registers rather than one value in two spellings. The hex is what the rendered surface picked and no reading claims the terminal renders that value. It declares `background` alone as its ground, since that is the only role it is drawn on, where every other dark text role is drawn on both. It is the one role below that is not derived.',
|
|
119
|
-
'',
|
|
120
|
-
"Every other role is derived rather than picked, solved in OKLCH by binary search for the lightness that hits a target contrast against its ground, using this module's own anchors. Six anchors are the whole system: ground lightness 0.985 light and 0.165 dark, neutral chroma 0.003 light and 0.004 dark, neutral hue 90 both, accent hue 22 light and 28 dark, accent chroma 0.13 light and 0.12 dark for a mark and 0.095 light and 0.09 dark for a fill. Only the mark step is rendered below, in `accent`. That token already stands in for a fill in practice: `assets/captures/hero.html`'s `.cmd` rule paints its whole background with `var(--color-accent)` and sets text on top, which is a fill use rather than a mark one. The fill chroma step has no token of its own yet, so a lower-saturation `accent-fill` would recolor that button rather than introduce a new consumer. Targets, which are inputs rather than results: text 13.5, body 8.6, secondary 5.6, muted 4.6, accent 5.2. Dark is its own anchor set rather than an inversion of light, and every arm measured in the groundwork behind this landed on identical neutral ratios, which is what made the accent choice a question about hue alone.",
|
|
121
|
-
'',
|
|
122
|
-
'`muted` and `light-muted` solve against `surface` rather than `background`. Both declare two grounds and the groundwork solved its target against one, so the recorded hex cleared 4.6 against `background` and read 4.40 dark, 4.24 light against `surface`, the tighter of the two since surface sits a step closer to its text color. The values here are re-solved for the same 4.6 target read against `surface` instead, which clears both: 4.58 dark, 4.56 light against `surface`, and 4.80 dark, 4.95 light against `background`.',
|
|
123
|
-
'',
|
|
124
|
-
'The accent is a quiet red at hue 22 light, 28 dark, chosen over a vivid red that read as an error state against a page reporting success, over indigo which carries less distinctiveness at hue 280 in tooling already dominated by that hue, and over rust at hue 42, a larger temperature shift than the problem required. The debt this accepts: `error` and `warning` now share a register with the most repeated element on every rendered surface, so both need differentiating by lightness or by an icon rather than by hue, and that work is unscheduled.',
|
|
125
|
-
].join('\n'),
|
|
104
|
+
colorRules: [
|
|
105
|
+
'One accent. Never promote a second color into a structural role.',
|
|
106
|
+
'Every text role clears WCAG AA at 4.5:1 against each ground it is drawn on.',
|
|
107
|
+
'`warning` and `error` exist only in the terminal, as ANSI codes.',
|
|
108
|
+
],
|
|
126
109
|
|
|
127
110
|
color: [
|
|
128
111
|
{
|
|
@@ -242,15 +225,11 @@ export const TOKENS: DesignTokens = {
|
|
|
242
225
|
},
|
|
243
226
|
],
|
|
244
227
|
|
|
245
|
-
|
|
246
|
-
'
|
|
247
|
-
'',
|
|
248
|
-
'
|
|
249
|
-
|
|
250
|
-
'A tagged cell is one no rendering surface exercises yet, which is a declaration the system has not tested rather than one it has.',
|
|
251
|
-
'',
|
|
252
|
-
'Two rules set tracking and no others touch it. The label role carries `0.05em`, and the display role tightens to `-0.01em`.',
|
|
253
|
-
].join('\n'),
|
|
228
|
+
typographyRules: [
|
|
229
|
+
'Geist for every role except `code`, which is monospace.',
|
|
230
|
+
'Tracking: `label` at `0.05em`, `display` at `-0.01em`, nothing else.',
|
|
231
|
+
'Sizes come from seven steps, `t0` to `t6`. A role takes one step.',
|
|
232
|
+
],
|
|
254
233
|
|
|
255
234
|
typeScale: [
|
|
256
235
|
{ step: 't0', size: '3.175rem' },
|
|
@@ -312,11 +291,9 @@ export const TOKENS: DesignTokens = {
|
|
|
312
291
|
},
|
|
313
292
|
],
|
|
314
293
|
|
|
315
|
-
|
|
316
|
-
'Seven steps
|
|
317
|
-
|
|
318
|
-
'The outer window padding of the capture frames, which the previous record carried as a three-value frame register, is not part of the scale. Those frames set their own padding in the template and read no step, so the register is retired here rather than mapped onto steps no frame uses.',
|
|
319
|
-
].join('\n'),
|
|
294
|
+
spacingRules: [
|
|
295
|
+
'Seven steps. `md` sits at 3.5 because a control needs 14 pixels.',
|
|
296
|
+
],
|
|
320
297
|
|
|
321
298
|
spacing: [
|
|
322
299
|
{ step: 'xs', multiplier: '1', value: '0.25rem' },
|
|
@@ -328,9 +305,9 @@ export const TOKENS: DesignTokens = {
|
|
|
328
305
|
{ step: '3xl', multiplier: '24', value: '6rem' },
|
|
329
306
|
],
|
|
330
307
|
|
|
331
|
-
|
|
332
|
-
'Every border is one pixel solid
|
|
333
|
-
]
|
|
308
|
+
bordersRules: [
|
|
309
|
+
'Every border is one pixel solid in `border`. Radius and width are independent.',
|
|
310
|
+
],
|
|
334
311
|
|
|
335
312
|
borders: [
|
|
336
313
|
{
|
|
@@ -372,17 +349,19 @@ export const TOKENS: DesignTokens = {
|
|
|
372
349
|
},
|
|
373
350
|
],
|
|
374
351
|
|
|
375
|
-
layout:
|
|
376
|
-
'Capture widths
|
|
352
|
+
layout: [
|
|
353
|
+
'Capture widths: 320, 768, 1280, 1536. 320 is the reflow floor.',
|
|
354
|
+
'Every width query in the generated stylesheets opens a range one of these widths reaches.',
|
|
355
|
+
],
|
|
377
356
|
|
|
378
|
-
motion:
|
|
379
|
-
'
|
|
357
|
+
motion: [
|
|
358
|
+
'None. No transition, animation, or keyframe on any rendered surface.',
|
|
359
|
+
],
|
|
380
360
|
|
|
381
361
|
iconography: [
|
|
382
|
-
'No icon library
|
|
383
|
-
'',
|
|
384
|
-
|
|
385
|
-
].join('\n'),
|
|
362
|
+
'No icon library. `assets/brand/mark.svg` is the one authored icon.',
|
|
363
|
+
'The terminal draws glyphs: `│ ├ ✓ ! ✗ + - ◆ ◇ ❯`.',
|
|
364
|
+
],
|
|
386
365
|
}
|
|
387
366
|
|
|
388
367
|
/** A role's value, or `undefined` where the record declares no such role. */
|
package/src/gate/measures.ts
CHANGED
|
@@ -22,6 +22,8 @@ import {
|
|
|
22
22
|
PRACTICE_SKILLS,
|
|
23
23
|
SHIPPED_CORPUS,
|
|
24
24
|
} from '@/claude/skills-audit'
|
|
25
|
+
import { measureDesignProse } from '@/design/budget'
|
|
26
|
+
import { DESIGN_DOCUMENT } from '@/design/regen'
|
|
25
27
|
import { listRepositoryFiles } from '@/git-files'
|
|
26
28
|
import { ceilingFindings } from '@/markdown/ceiling'
|
|
27
29
|
import { resolveMarkdown } from '@/markdown/files'
|
|
@@ -325,6 +327,53 @@ export const canonicalRecords: Measure = async (ctx) => {
|
|
|
325
327
|
return { emissions }
|
|
326
328
|
}
|
|
327
329
|
|
|
330
|
+
/**
|
|
331
|
+
* The design records this repository holds, each read against the prose
|
|
332
|
+
* budget in `@/design/budget`. The worked example is read beside the record,
|
|
333
|
+
* since it is the one a target copies its shape from.
|
|
334
|
+
*/
|
|
335
|
+
const DESIGN_RECORDS = [
|
|
336
|
+
DESIGN_DOCUMENT,
|
|
337
|
+
join('examples', 'design', 'DESIGN.md'),
|
|
338
|
+
] as const
|
|
339
|
+
|
|
340
|
+
export const designProse: Measure = async (ctx) => {
|
|
341
|
+
const failures: string[] = []
|
|
342
|
+
const emissions: Emission[] = []
|
|
343
|
+
|
|
344
|
+
for (const rel of DESIGN_RECORDS) {
|
|
345
|
+
const path = join(ctx.root, rel)
|
|
346
|
+
if (!existsSync(path)) continue
|
|
347
|
+
let source: string
|
|
348
|
+
try {
|
|
349
|
+
source = readFileSync(path, 'utf8')
|
|
350
|
+
} catch {
|
|
351
|
+
return {
|
|
352
|
+
emissions: [],
|
|
353
|
+
unmeasured: `${rel} could not be read, so its prose was not counted.`,
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
const findings = measureDesignProse(source)
|
|
357
|
+
if (findings.length > 0) {
|
|
358
|
+
failures.push(
|
|
359
|
+
`${findings.map((finding) => `${finding} in ${rel}`).join('. ')}.`,
|
|
360
|
+
)
|
|
361
|
+
} else {
|
|
362
|
+
emissions.push(info(`${rel} within the prose budget`))
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
if (failures.length > 0) {
|
|
367
|
+
return {
|
|
368
|
+
emissions: [],
|
|
369
|
+
failure: `${failures.join(' ')} Cut to the budget in standards/design.md, moving the why to the context entry.`,
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
if (emissions.length === 0)
|
|
373
|
+
emissions.push(info('No design record to measure'))
|
|
374
|
+
return { emissions }
|
|
375
|
+
}
|
|
376
|
+
|
|
328
377
|
/**
|
|
329
378
|
* Every skill body and reference against the no-dated-provenance rule, across
|
|
330
379
|
* both corpora `auditSkills` reads.
|