@erclx/canon 5.20.0 → 5.22.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/auto-ship/SKILL.md +1 -1
- package/claude/skills/auto-ship/references/draft-mark.md +8 -6
- package/claude/skills/auto-ship/references/verb-absent.md +1 -1
- package/claude/skills/docs-sync/REQUIREMENT.md +1 -1
- package/claude/skills/document-health/REQUIREMENT.md +1 -1
- package/claude/skills/draft-doc/REQUIREMENT.md +45 -0
- package/claude/skills/draft-doc/SKILL.md +77 -0
- package/claude/skills/draft-doc/references/context.md +31 -0
- package/claude/skills/draft-doc/references/docs.md +33 -0
- package/claude/skills/draft-doc/references/readme.md +58 -0
- package/claude/skills/draft-doc/references/wiki.md +53 -0
- package/claude/skills/draft-doc/references/wireframes.md +48 -0
- package/claude/skills/draft-figure/REQUIREMENT.md +1 -1
- package/claude/skills/draft-figure/SKILL.md +1 -1
- package/claude/skills/draft-ready/REQUIREMENT.md +2 -1
- package/claude/skills/draft-ready/SKILL.md +6 -4
- package/claude/skills/git-pr/SKILL.md +5 -46
- package/claude/skills/git-pr/references/final-command.md +75 -0
- package/claude/skills/git-pr/references/labels.md +2 -2
- package/claude/skills/markdown-propose/REQUIREMENT.md +3 -3
- package/claude/skills/markdown-propose/SKILL.md +1 -1
- package/claude/skills/role-orchestrator/references/orchestrator-cloud-launch.md +76 -0
- package/claude/skills/role-orchestrator/references/orchestrator-dispatch.md +11 -1
- package/claude/skills/role-orchestrator/references/orchestrator-launch.md +5 -1
- package/claude/skills/search-craft/REQUIREMENT.md +4 -4
- package/claude/skills/search-craft/SKILL.md +1 -1
- package/docs/agents/commands.md +1 -0
- package/docs/agents/drift-surfaces.md +20 -9
- package/docs/agents/index.md +2 -1
- package/docs/agents/install-and-sync.md +3 -3
- package/docs/agents/markdown-audit.md +1 -3
- package/docs/agents/migrate.md +1 -1
- package/docs/agents/rule-citations.md +1 -1
- package/docs/agents/slides.md +164 -0
- package/docs/agents/tasks-validate.md +1 -1
- package/docs/agents/tasks.md +2 -2
- package/docs/target/migrations.md +4 -2
- package/docs/target/sync.md +1 -1
- package/docs/workflow/ai-workflow.md +1 -1
- package/docs/workflow/skill-map.md +25 -31
- package/governance/rules/canon/604-scratch.md +0 -1
- package/governance/rules/claude/575-hooks.md +1 -2
- package/governance/rules/claude/576-settings.md +1 -2
- package/governance/rules/lib/330-testing-py.md +1 -1
- package/governance/rules/lib/336-testing-php.md +1 -1
- package/governance/rules/standards/architecture.md +0 -1
- package/governance/rules/standards/context.md +0 -1
- package/governance/rules/standards/design.md +0 -1
- package/governance/rules/standards/diagrams.md +0 -3
- package/governance/rules/standards/requirements.md +0 -1
- package/governance/rules/standards/wireframes.md +0 -1
- package/package.json +1 -1
- package/scripts/manage-sandbox.sh +7 -8
- package/src/claude/cases/authoring.ts +5 -5
- package/src/claude/cases/workflow.ts +4 -20
- package/src/commands/slides.ts +23 -3
- package/src/commands/sync.ts +10 -0
- package/src/commands/tasks.ts +46 -2
- package/src/gov/citations.ts +1 -2
- package/src/migrate/skill-names.ts +12 -7
- package/src/slides/convert/chart.ts +165 -0
- package/src/slides/convert/deck.ts +244 -0
- package/src/slides/convert/export.ts +281 -23
- package/src/slides/convert/master.ts +195 -0
- package/src/slides/convert/shapes.ts +44 -4
- package/src/slides/convert/walk.ts +229 -2
- package/src/slides/package/fonts.ts +297 -0
- package/src/slides/package/motion.ts +316 -0
- package/src/slides/package/test-face.ts +113 -0
- package/src/sync/check.ts +9 -0
- package/src/sync/layout.ts +27 -1
- package/src/tasks/record.ts +11 -1
- package/src/tasks/validate.ts +14 -0
- package/standards/architecture.md +0 -1
- package/standards/context.md +0 -1
- package/standards/design.md +0 -1
- package/standards/diagrams.md +0 -3
- package/standards/requirements.md +0 -1
- package/standards/rule.md +1 -0
- package/standards/tasks.md +1 -1
- package/standards/wireframes.md +0 -1
- package/tooling/astro/configs/card-src/pages/og-card.astro +2 -2
- package/tooling/claude/seeds/.claude/hooks/pr-create-log.sh +8 -3
- package/claude/skills/docs-fold/REQUIREMENT.md +0 -25
- package/claude/skills/docs-fold/SKILL.md +0 -9
- package/claude/skills/draft-context/REQUIREMENT.md +0 -37
- package/claude/skills/draft-context/SKILL.md +0 -61
- package/claude/skills/draft-docs/REQUIREMENT.md +0 -35
- package/claude/skills/draft-docs/SKILL.md +0 -59
- package/claude/skills/draft-readme/REQUIREMENT.md +0 -38
- package/claude/skills/draft-readme/SKILL.md +0 -73
- package/claude/skills/draft-wiki/REQUIREMENT.md +0 -42
- package/claude/skills/draft-wiki/SKILL.md +0 -83
- package/claude/skills/draft-wireframes/REQUIREMENT.md +0 -38
- package/claude/skills/draft-wireframes/SKILL.md +0 -69
- package/claude/skills/standards-audit/REQUIREMENT.md +0 -33
- package/claude/skills/standards-audit/SKILL.md +0 -99
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
import type JSZip from 'jszip'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Slide transitions and entrance animations, written into a slide's XML after
|
|
5
|
+
* pptxgenjs has written it, since pptxgenjs declares neither. A slide states
|
|
6
|
+
* them in its HTML, `walk.ts` reads the attributes, and the converter resolves
|
|
7
|
+
* each animated element to the object names `planSlide` gave its shapes. This
|
|
8
|
+
* module turns those names into the shape ids the written XML assigned.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
export interface TransitionSpec {
|
|
12
|
+
readonly effect: string
|
|
13
|
+
readonly duration?: string
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface EntranceSpec {
|
|
17
|
+
/** The element that declared it, which every notice names. */
|
|
18
|
+
readonly selector: string
|
|
19
|
+
readonly effect: string
|
|
20
|
+
readonly order?: string
|
|
21
|
+
readonly duration?: string
|
|
22
|
+
/** Every shape drawn from the element and the elements inside it. */
|
|
23
|
+
readonly shapes: readonly string[]
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface SlideMotion {
|
|
27
|
+
readonly transition?: TransitionSpec
|
|
28
|
+
readonly entrances: readonly EntranceSpec[]
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface MotionResult {
|
|
32
|
+
readonly xml: string
|
|
33
|
+
readonly notices: readonly string[]
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** A slide's motion, numbered as the package numbers its slide parts. */
|
|
37
|
+
export interface SlideMotionEdit {
|
|
38
|
+
readonly slide: number
|
|
39
|
+
readonly motion: SlideMotion
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface MotionNotice {
|
|
43
|
+
readonly slide: number
|
|
44
|
+
readonly message: string
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** The child element `<p:transition>` holds, each in its default direction. */
|
|
48
|
+
const TRANSITIONS: Readonly<Record<string, string>> = {
|
|
49
|
+
fade: '<p:fade/>',
|
|
50
|
+
push: '<p:push/>',
|
|
51
|
+
wipe: '<p:wipe/>',
|
|
52
|
+
cover: '<p:cover/>',
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* The strict schema states a transition's length only as one of three speeds,
|
|
57
|
+
* 0.5, 0.75, and 1 second. A length in milliseconds needs the PowerPoint 2010
|
|
58
|
+
* extension namespace, so a declared duration rounds to the nearest speed.
|
|
59
|
+
*/
|
|
60
|
+
function speedOf(duration: number | undefined): 'fast' | 'med' | 'slow' {
|
|
61
|
+
if (duration === undefined) return 'med'
|
|
62
|
+
if (duration < 625) return 'fast'
|
|
63
|
+
return duration < 875 ? 'med' : 'slow'
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
type Behaviors = (spid: string, duration: number, next: () => number) => string
|
|
67
|
+
|
|
68
|
+
const target = (spid: string): string =>
|
|
69
|
+
`<p:tgtEl><p:spTgt spid="${spid}"/></p:tgtEl>`
|
|
70
|
+
|
|
71
|
+
const animEffect =
|
|
72
|
+
(filter: string): Behaviors =>
|
|
73
|
+
(spid, duration, next) =>
|
|
74
|
+
`<p:animEffect transition="in" filter="${filter}"><p:cBhvr><p:cTn id="${next()}" dur="${duration}"/>${target(spid)}</p:cBhvr></p:animEffect>`
|
|
75
|
+
|
|
76
|
+
function anim(
|
|
77
|
+
spid: string,
|
|
78
|
+
duration: number,
|
|
79
|
+
id: number,
|
|
80
|
+
attribute: string,
|
|
81
|
+
from: string,
|
|
82
|
+
to: string,
|
|
83
|
+
): string {
|
|
84
|
+
return `<p:anim calcmode="lin" valueType="num"><p:cBhvr additive="base"><p:cTn id="${id}" dur="${duration}" fill="hold"/>${target(spid)}<p:attrNameLst><p:attrName>${attribute}</p:attrName></p:attrNameLst></p:cBhvr><p:tavLst><p:tav tm="0"><p:val><p:strVal val="${from}"/></p:val></p:tav><p:tav tm="100000"><p:val><p:strVal val="${to}"/></p:val></p:tav></p:tavLst></p:anim>`
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
interface Preset {
|
|
88
|
+
readonly id: number
|
|
89
|
+
readonly subtype: number
|
|
90
|
+
readonly behaviors: Behaviors
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* PowerPoint's own preset ids, so the effect shows by name in its animation
|
|
95
|
+
* pane. Fly comes in from the bottom and wipe from the left, the directions
|
|
96
|
+
* PowerPoint picks when none is chosen. A wipe filter names the edge the
|
|
97
|
+
* reveal starts from, so from the left pairs with `wipe(left)`.
|
|
98
|
+
*/
|
|
99
|
+
const ENTRANCES: Readonly<Record<string, Preset>> = {
|
|
100
|
+
fade: { id: 10, subtype: 0, behaviors: animEffect('fade') },
|
|
101
|
+
fly: {
|
|
102
|
+
id: 2,
|
|
103
|
+
subtype: 4,
|
|
104
|
+
behaviors: (spid, duration, next) =>
|
|
105
|
+
anim(spid, duration, next(), 'ppt_x', '#ppt_x', '#ppt_x') +
|
|
106
|
+
anim(spid, duration, next(), 'ppt_y', '1+#ppt_h/2', '#ppt_y'),
|
|
107
|
+
},
|
|
108
|
+
wipe: { id: 22, subtype: 8, behaviors: animEffect('wipe(left)') },
|
|
109
|
+
zoom: {
|
|
110
|
+
id: 53,
|
|
111
|
+
subtype: 16,
|
|
112
|
+
behaviors: (spid, duration, next) =>
|
|
113
|
+
anim(spid, duration, next(), 'ppt_w', '0', '#ppt_w') +
|
|
114
|
+
anim(spid, duration, next(), 'ppt_h', '0', '#ppt_h') +
|
|
115
|
+
animEffect('fade')(spid, duration, next),
|
|
116
|
+
},
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const DEFAULT_ENTRANCE_MS = 500
|
|
120
|
+
|
|
121
|
+
const ANCHOR = '</p:clrMapOvr>'
|
|
122
|
+
|
|
123
|
+
const list = (names: readonly string[]): string =>
|
|
124
|
+
`${names.slice(0, -1).join(', ')}, or ${names.at(-1)}`
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* The object names an entrance on `root` brings in: every shape drawn from it
|
|
128
|
+
* and from the elements inside it, so a card's box and its text arrive on one
|
|
129
|
+
* click. A descendant in `roots` declares its own entrance and keeps its
|
|
130
|
+
* subtree for that click. A record follows its parent in document order, which
|
|
131
|
+
* lets one pass collect the subtree.
|
|
132
|
+
*/
|
|
133
|
+
export function entranceShapes(
|
|
134
|
+
root: number | null,
|
|
135
|
+
records: readonly { readonly id: number; readonly parent: number | null }[],
|
|
136
|
+
names: readonly { readonly record: number; readonly name: string }[],
|
|
137
|
+
roots: ReadonlySet<number>,
|
|
138
|
+
): string[] {
|
|
139
|
+
if (root === null) return []
|
|
140
|
+
const subtree = new Set([root])
|
|
141
|
+
for (const record of records) {
|
|
142
|
+
const isOwnEntrance = roots.has(record.id)
|
|
143
|
+
if (
|
|
144
|
+
record.parent !== null &&
|
|
145
|
+
subtree.has(record.parent) &&
|
|
146
|
+
!isOwnEntrance
|
|
147
|
+
) {
|
|
148
|
+
subtree.add(record.id)
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
return names
|
|
152
|
+
.filter((named) => subtree.has(named.record))
|
|
153
|
+
.map((named) => named.name)
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** Milliseconds from `500`, `500ms`, or `0.5s`, or undefined when unreadable. */
|
|
157
|
+
export function millisecondsOf(value: string | undefined): number | undefined {
|
|
158
|
+
const match = /^\s*(\d+(?:\.\d+)?)\s*(ms|s)?\s*$/.exec(value ?? '')
|
|
159
|
+
if (!match?.[1]) return undefined
|
|
160
|
+
const amount = Number(match[1])
|
|
161
|
+
return Math.round(match[2] === 's' ? amount * 1000 : amount)
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* pptxgenjs numbers a table `n * slideNumber + 1` and every other shape by its
|
|
166
|
+
* index plus two, so a table can share an id with a shape beside it. A timing
|
|
167
|
+
* target has to name one shape, so each repeat after the first takes a fresh id.
|
|
168
|
+
*/
|
|
169
|
+
function uniqueIds(xml: string): string {
|
|
170
|
+
const pattern = /<p:cNvPr id="(\d+)"/g
|
|
171
|
+
let highest = Math.max(
|
|
172
|
+
0,
|
|
173
|
+
...[...xml.matchAll(pattern)].map((match) => Number(match[1])),
|
|
174
|
+
)
|
|
175
|
+
const seen = new Set<string>()
|
|
176
|
+
return xml.replace(pattern, (whole, id: string) => {
|
|
177
|
+
if (!seen.has(id)) {
|
|
178
|
+
seen.add(id)
|
|
179
|
+
return whole
|
|
180
|
+
}
|
|
181
|
+
highest += 1
|
|
182
|
+
return `<p:cNvPr id="${highest}"`
|
|
183
|
+
})
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function shapeIds(xml: string): Map<string, string> {
|
|
187
|
+
const ids = new Map<string, string>()
|
|
188
|
+
for (const [, id, name] of xml.matchAll(
|
|
189
|
+
/<p:cNvPr id="(\d+)" name="([^"]*)"/g,
|
|
190
|
+
)) {
|
|
191
|
+
if (id && name && !ids.has(name)) ids.set(name, id)
|
|
192
|
+
}
|
|
193
|
+
return ids
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
interface Click {
|
|
197
|
+
readonly preset: Preset
|
|
198
|
+
readonly duration: number
|
|
199
|
+
readonly spids: readonly string[]
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function timingXml(clicks: readonly Click[]): string {
|
|
203
|
+
let counter = 2
|
|
204
|
+
const next = (): number => {
|
|
205
|
+
counter += 1
|
|
206
|
+
return counter
|
|
207
|
+
}
|
|
208
|
+
const pars = clicks.map((click) => {
|
|
209
|
+
const outer = next()
|
|
210
|
+
const inner = next()
|
|
211
|
+
const effects = click.spids.map((spid, index) => {
|
|
212
|
+
const nodeType = index === 0 ? 'clickEffect' : 'withEffect'
|
|
213
|
+
const effect = next()
|
|
214
|
+
const visible = `<p:set><p:cBhvr><p:cTn id="${next()}" dur="1" fill="hold"><p:stCondLst><p:cond delay="0"/></p:stCondLst></p:cTn>${target(spid)}<p:attrNameLst><p:attrName>style.visibility</p:attrName></p:attrNameLst></p:cBhvr><p:to><p:strVal val="visible"/></p:to></p:set>`
|
|
215
|
+
const behaviors = click.preset.behaviors(spid, click.duration, next)
|
|
216
|
+
return `<p:par><p:cTn id="${effect}" presetID="${click.preset.id}" presetClass="entr" presetSubtype="${click.preset.subtype}" fill="hold" nodeType="${nodeType}"><p:stCondLst><p:cond delay="0"/></p:stCondLst><p:childTnLst>${visible}${behaviors}</p:childTnLst></p:cTn></p:par>`
|
|
217
|
+
})
|
|
218
|
+
return `<p:par><p:cTn id="${outer}" fill="hold"><p:stCondLst><p:cond delay="indefinite"/></p:stCondLst><p:childTnLst><p:par><p:cTn id="${inner}" fill="hold"><p:stCondLst><p:cond delay="0"/></p:stCondLst><p:childTnLst>${effects.join('')}</p:childTnLst></p:cTn></p:par></p:childTnLst></p:cTn></p:par>`
|
|
219
|
+
})
|
|
220
|
+
return `<p:timing><p:tnLst><p:par><p:cTn id="1" dur="indefinite" restart="never" nodeType="tmRoot"><p:childTnLst><p:seq concurrent="1" nextAc="seek"><p:cTn id="2" dur="indefinite" nodeType="mainSeq"><p:childTnLst>${pars.join('')}</p:childTnLst></p:cTn><p:prevCondLst><p:cond evt="onPrev" delay="0"><p:tgtEl><p:sldTgt/></p:tgtEl></p:cond></p:prevCondLst><p:nextCondLst><p:cond evt="onNext" delay="0"><p:tgtEl><p:sldTgt/></p:tgtEl></p:cond></p:nextCondLst></p:seq></p:childTnLst></p:cTn></p:par></p:tnLst></p:timing>`
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** Declared order first, then document order for ties and for no order. */
|
|
224
|
+
function ordered(entrances: readonly EntranceSpec[]): EntranceSpec[] {
|
|
225
|
+
const rank = (entrance: EntranceSpec): number => {
|
|
226
|
+
const order = Number.parseFloat(entrance.order ?? '')
|
|
227
|
+
return Number.isFinite(order) ? order : Number.POSITIVE_INFINITY
|
|
228
|
+
}
|
|
229
|
+
return entrances.toSorted((a, b) => rank(a) - rank(b))
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Inserts the transition and an on-click main sequence after the slide's color
|
|
234
|
+
* map override, where the schema orders them. Each entrance is one click, and
|
|
235
|
+
* every shape drawn from its element comes in on that click together. A slide
|
|
236
|
+
* missing the anchor is returned unchanged rather than patched elsewhere.
|
|
237
|
+
*/
|
|
238
|
+
export function addMotion(xml: string, motion: SlideMotion): MotionResult {
|
|
239
|
+
const notices: string[] = []
|
|
240
|
+
let transition = ''
|
|
241
|
+
if (motion.transition) {
|
|
242
|
+
const element = TRANSITIONS[motion.transition.effect]
|
|
243
|
+
if (element) {
|
|
244
|
+
const speed = speedOf(millisecondsOf(motion.transition.duration))
|
|
245
|
+
transition = `<p:transition spd="${speed}">${element}</p:transition>`
|
|
246
|
+
} else {
|
|
247
|
+
notices.push(
|
|
248
|
+
`unknown transition ${motion.transition.effect}. Use ${list(Object.keys(TRANSITIONS))}`,
|
|
249
|
+
)
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
const numbered = uniqueIds(xml)
|
|
254
|
+
const ids = shapeIds(numbered)
|
|
255
|
+
const clicks: Click[] = []
|
|
256
|
+
for (const entrance of ordered(motion.entrances)) {
|
|
257
|
+
const preset = ENTRANCES[entrance.effect]
|
|
258
|
+
if (!preset) {
|
|
259
|
+
notices.push(
|
|
260
|
+
`${entrance.selector}: unknown entrance ${entrance.effect}. Use ${list(Object.keys(ENTRANCES))}`,
|
|
261
|
+
)
|
|
262
|
+
continue
|
|
263
|
+
}
|
|
264
|
+
const spids = entrance.shapes.flatMap((name) => {
|
|
265
|
+
const id = ids.get(name)
|
|
266
|
+
return id ? [id] : []
|
|
267
|
+
})
|
|
268
|
+
if (spids.length === 0) {
|
|
269
|
+
notices.push(
|
|
270
|
+
`${entrance.selector}: draws no shape, so its entrance has nothing to animate`,
|
|
271
|
+
)
|
|
272
|
+
continue
|
|
273
|
+
}
|
|
274
|
+
const duration = millisecondsOf(entrance.duration) ?? DEFAULT_ENTRANCE_MS
|
|
275
|
+
clicks.push({ preset, duration, spids })
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
const timing = clicks.length > 0 ? timingXml(clicks) : ''
|
|
279
|
+
if (!transition && !timing) return { xml, notices }
|
|
280
|
+
if (!xml.includes(ANCHOR)) {
|
|
281
|
+
return {
|
|
282
|
+
xml,
|
|
283
|
+
notices: [
|
|
284
|
+
...notices,
|
|
285
|
+
`no ${ANCHOR} to anchor motion after, so none was written`,
|
|
286
|
+
],
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
return {
|
|
290
|
+
xml: (timing ? numbered : xml).replace(
|
|
291
|
+
ANCHOR,
|
|
292
|
+
`${ANCHOR}${transition}${timing}`,
|
|
293
|
+
),
|
|
294
|
+
notices,
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/** Writes each slide's motion into the opened package and returns its notices. */
|
|
299
|
+
export async function writeMotion(
|
|
300
|
+
zip: JSZip,
|
|
301
|
+
edits: readonly SlideMotionEdit[],
|
|
302
|
+
): Promise<MotionNotice[]> {
|
|
303
|
+
const notices: MotionNotice[] = []
|
|
304
|
+
for (const { slide, motion } of edits) {
|
|
305
|
+
const path = `ppt/slides/slide${slide}.xml`
|
|
306
|
+
const xml = await zip.file(path)?.async('string')
|
|
307
|
+
if (xml === undefined) {
|
|
308
|
+
notices.push({ slide, message: `no ${path} in the written deck` })
|
|
309
|
+
continue
|
|
310
|
+
}
|
|
311
|
+
const result = addMotion(xml, motion)
|
|
312
|
+
if (result.xml !== xml) zip.file(path, result.xml)
|
|
313
|
+
notices.push(...result.notices.map((message) => ({ slide, message })))
|
|
314
|
+
}
|
|
315
|
+
return notices
|
|
316
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Builds the smallest sfnt the font step reads, holding only the `OS/2`,
|
|
3
|
+
* `head`, and `name` tables an EOT header is built from, plus `fvar` when the
|
|
4
|
+
* face should read as variable. It carries no glyphs, so it embeds and never
|
|
5
|
+
* renders. Tests use it in place of a real face, whose license the repository
|
|
6
|
+
* would then have to carry.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export interface TestFaceOptions {
|
|
10
|
+
readonly family?: string
|
|
11
|
+
readonly subfamily?: string
|
|
12
|
+
readonly weight?: number
|
|
13
|
+
readonly isItalic?: boolean
|
|
14
|
+
readonly fsType?: number
|
|
15
|
+
readonly isVariable?: boolean
|
|
16
|
+
/** The first four bytes, `0x00010000` for TrueType. */
|
|
17
|
+
readonly version?: number
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Values distinct enough that a header copying the wrong field shows it. */
|
|
21
|
+
export const TEST_FACE = {
|
|
22
|
+
panose: [2, 11, 5, 3, 3, 4, 3, 2, 2, 4],
|
|
23
|
+
unicodeRanges: [0xe0000aff, 0x5000e5ff, 0x00000009, 0x00000001],
|
|
24
|
+
codePages: [0x0000019f, 0x01020000],
|
|
25
|
+
checkSumAdjustment: 0x1234abcd,
|
|
26
|
+
} as const
|
|
27
|
+
|
|
28
|
+
function os2(options: TestFaceOptions): Buffer {
|
|
29
|
+
const table = Buffer.alloc(96)
|
|
30
|
+
table.writeUInt16BE(4, 0)
|
|
31
|
+
table.writeUInt16BE(options.weight ?? 400, 4)
|
|
32
|
+
table.writeUInt16BE(options.fsType ?? 0, 8)
|
|
33
|
+
Buffer.from(TEST_FACE.panose).copy(table, 32)
|
|
34
|
+
TEST_FACE.unicodeRanges.forEach((range, index) => {
|
|
35
|
+
table.writeUInt32BE(range, 42 + index * 4)
|
|
36
|
+
})
|
|
37
|
+
table.write('TEST', 58, 'latin1')
|
|
38
|
+
table.writeUInt16BE(options.isItalic ? 0x0001 : 0x0040, 62)
|
|
39
|
+
TEST_FACE.codePages.forEach((page, index) => {
|
|
40
|
+
table.writeUInt32BE(page, 78 + index * 4)
|
|
41
|
+
})
|
|
42
|
+
return table
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function head(): Buffer {
|
|
46
|
+
const table = Buffer.alloc(54)
|
|
47
|
+
table.writeUInt32BE(0x00010000, 0)
|
|
48
|
+
table.writeUInt32BE(TEST_FACE.checkSumAdjustment, 8)
|
|
49
|
+
table.writeUInt32BE(0x5f0f3cf5, 12)
|
|
50
|
+
return table
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function name(strings: ReadonlyMap<number, string>): Buffer {
|
|
54
|
+
const encoded = [...strings].map(([id, text]) => ({
|
|
55
|
+
id,
|
|
56
|
+
bytes: Buffer.from(text, 'utf16le').swap16(),
|
|
57
|
+
}))
|
|
58
|
+
const header = Buffer.alloc(6 + encoded.length * 12)
|
|
59
|
+
header.writeUInt16BE(0, 0)
|
|
60
|
+
header.writeUInt16BE(encoded.length, 2)
|
|
61
|
+
header.writeUInt16BE(header.length, 4)
|
|
62
|
+
let offset = 0
|
|
63
|
+
encoded.forEach(({ id, bytes }, index) => {
|
|
64
|
+
const at = 6 + index * 12
|
|
65
|
+
header.writeUInt16BE(3, at)
|
|
66
|
+
header.writeUInt16BE(1, at + 2)
|
|
67
|
+
header.writeUInt16BE(0x0409, at + 4)
|
|
68
|
+
header.writeUInt16BE(id, at + 6)
|
|
69
|
+
header.writeUInt16BE(bytes.length, at + 8)
|
|
70
|
+
header.writeUInt16BE(offset, at + 10)
|
|
71
|
+
offset += bytes.length
|
|
72
|
+
})
|
|
73
|
+
return Buffer.concat([header, ...encoded.map(({ bytes }) => bytes)])
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function testFace(options: TestFaceOptions = {}): Buffer {
|
|
77
|
+
const family = options.family ?? 'Fixture Sans'
|
|
78
|
+
const subfamily = options.subfamily ?? 'Regular'
|
|
79
|
+
const tables = new Map<string, Buffer>([
|
|
80
|
+
['OS/2', os2(options)],
|
|
81
|
+
['head', head()],
|
|
82
|
+
[
|
|
83
|
+
'name',
|
|
84
|
+
name(
|
|
85
|
+
new Map([
|
|
86
|
+
[1, family],
|
|
87
|
+
[2, subfamily],
|
|
88
|
+
[4, `${family} ${subfamily}`],
|
|
89
|
+
[5, 'Version 1.000'],
|
|
90
|
+
]),
|
|
91
|
+
),
|
|
92
|
+
],
|
|
93
|
+
])
|
|
94
|
+
if (options.isVariable) tables.set('fvar', Buffer.alloc(16))
|
|
95
|
+
|
|
96
|
+
const entries = [...tables].sort(([a], [b]) => a.localeCompare(b))
|
|
97
|
+
const directory = Buffer.alloc(12 + entries.length * 16)
|
|
98
|
+
directory.writeUInt32BE(options.version ?? 0x00010000, 0)
|
|
99
|
+
directory.writeUInt16BE(entries.length, 4)
|
|
100
|
+
let offset = directory.length
|
|
101
|
+
const bodies: Buffer[] = []
|
|
102
|
+
entries.forEach(([tag, table], index) => {
|
|
103
|
+
const at = 12 + index * 16
|
|
104
|
+
directory.write(tag, at, 'latin1')
|
|
105
|
+
directory.writeUInt32BE(offset, at + 8)
|
|
106
|
+
directory.writeUInt32BE(table.length, at + 12)
|
|
107
|
+
const padded = Buffer.alloc(Math.ceil(table.length / 4) * 4)
|
|
108
|
+
table.copy(padded)
|
|
109
|
+
bodies.push(padded)
|
|
110
|
+
offset += padded.length
|
|
111
|
+
})
|
|
112
|
+
return Buffer.concat([directory, ...bodies])
|
|
113
|
+
}
|
package/src/sync/check.ts
CHANGED
|
@@ -10,8 +10,10 @@ import { planSync, type ScanEntry, type SyncAdapter } from '@/sync/engine'
|
|
|
10
10
|
import {
|
|
11
11
|
collectSuperseded,
|
|
12
12
|
detectUnmigrated,
|
|
13
|
+
detectUnmovedSurfaces,
|
|
13
14
|
type SupersededEntry,
|
|
14
15
|
type UnmigratedDomain,
|
|
16
|
+
type UnmovedSurface,
|
|
15
17
|
} from '@/sync/layout'
|
|
16
18
|
import {
|
|
17
19
|
buildReverseReport,
|
|
@@ -178,6 +180,11 @@ export interface CheckReport {
|
|
|
178
180
|
readonly seeds: SeedsReport
|
|
179
181
|
readonly superseded: readonly SupersededEntry[]
|
|
180
182
|
readonly unmigrated: readonly UnmigratedDomain[]
|
|
183
|
+
/**
|
|
184
|
+
* Report-only like `superseded`, since a target's CI gating on `--exit-code`
|
|
185
|
+
* would otherwise start failing on a layout nobody asked it to move yet.
|
|
186
|
+
*/
|
|
187
|
+
readonly unmovedSurfaces: readonly UnmovedSurface[]
|
|
181
188
|
readonly newSkills: readonly string[]
|
|
182
189
|
/**
|
|
183
190
|
* Rules the toolkit authored after this target's governance anchor, filtered
|
|
@@ -440,6 +447,7 @@ export async function buildCheckReport(
|
|
|
440
447
|
seeds: { entries: [], historyUnavailable: false },
|
|
441
448
|
superseded: [],
|
|
442
449
|
unmigrated: [],
|
|
450
|
+
unmovedSurfaces: [],
|
|
443
451
|
newSkills: [],
|
|
444
452
|
newRules: [],
|
|
445
453
|
reverse: emptyReverseReport(),
|
|
@@ -456,6 +464,7 @@ export async function buildCheckReport(
|
|
|
456
464
|
seeds: buildSeedsReport(toolkitRoot, target),
|
|
457
465
|
superseded: collectSuperseded(target),
|
|
458
466
|
unmigrated,
|
|
467
|
+
unmovedSurfaces: detectUnmovedSurfaces(target),
|
|
459
468
|
newSkills: await readNewSkills(toolkitRoot, anchors),
|
|
460
469
|
newRules: await readNewRules(toolkitRoot, target, stamp),
|
|
461
470
|
reverse: buildReverseReport(toolkitRoot, target),
|
package/src/sync/layout.ts
CHANGED
|
@@ -2,7 +2,7 @@ import { existsSync, statSync } from 'node:fs'
|
|
|
2
2
|
import { basename, join, relative } from 'node:path'
|
|
3
3
|
import { SUBDIRS } from '@/claude/seeds'
|
|
4
4
|
import { creationRel, isRecordEntry } from '@/record-root'
|
|
5
|
-
import { SURFACE_ENTRIES, surfaceDir } from '@/surface-root'
|
|
5
|
+
import { SURFACE_ENTRIES, spell, surfaceDir } from '@/surface-root'
|
|
6
6
|
import type { StampDomain } from '@/sync/stamp'
|
|
7
7
|
|
|
8
8
|
const CLAUDE_DIR = '.claude'
|
|
@@ -122,6 +122,32 @@ export function detectUnmigrated(
|
|
|
122
122
|
return found
|
|
123
123
|
}
|
|
124
124
|
|
|
125
|
+
/**
|
|
126
|
+
* A tracked surface the target still reads at its `.claude/` spelling, with
|
|
127
|
+
* nothing at the `canon/` one, and where `canon migrate surface-roots` moves it.
|
|
128
|
+
*/
|
|
129
|
+
export interface UnmovedSurface {
|
|
130
|
+
readonly rel: string
|
|
131
|
+
readonly movesTo: string
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Surfaces a target holds only at the root the surface move retired. Kept
|
|
136
|
+
* apart from `detectUnmigrated`, which models a domain stranded at the project
|
|
137
|
+
* root under a stamp domain, while a surface carries neither. A rule names the
|
|
138
|
+
* current layout only, so a target here stops loading the rules scoped to these
|
|
139
|
+
* files, and this report is what points it at the verb.
|
|
140
|
+
*/
|
|
141
|
+
export function detectUnmovedSurfaces(target: string): UnmovedSurface[] {
|
|
142
|
+
return SURFACE_ENTRIES.filter(
|
|
143
|
+
(entry) =>
|
|
144
|
+
relative(target, surfaceDir(target, entry)) === join(CLAUDE_DIR, entry),
|
|
145
|
+
).map((entry) => ({
|
|
146
|
+
rel: join(CLAUDE_DIR, entry),
|
|
147
|
+
movesTo: join('canon', spell('canon', entry)),
|
|
148
|
+
}))
|
|
149
|
+
}
|
|
150
|
+
|
|
125
151
|
/**
|
|
126
152
|
* Root files whose basename matches something the toolkit ships for this domain.
|
|
127
153
|
* Basenames rather than relative paths, because the root layout an older toolkit
|
package/src/tasks/record.ts
CHANGED
|
@@ -512,12 +512,18 @@ export async function recordPlan(
|
|
|
512
512
|
* line, so the merge of an earlier slice cannot archive the task in the window
|
|
513
513
|
* before this branch's own number is recorded. A rerun that ticks nothing adds
|
|
514
514
|
* no marker, since its branch has already recorded or is already listed.
|
|
515
|
+
*
|
|
516
|
+
* `branchPulls` holds the pull request numbers the branch's own head carries.
|
|
517
|
+
* One already on the task's `Pull request:` line means the branch recorded its
|
|
518
|
+
* number, so a marker would outlive the branch with nothing left to clear it.
|
|
519
|
+
* An absent or empty list keeps the marker, which is the first slice's case.
|
|
515
520
|
*/
|
|
516
521
|
export async function closeOutcomes(
|
|
517
522
|
root: string,
|
|
518
523
|
selector: RecordSelector,
|
|
519
524
|
positions: readonly number[],
|
|
520
525
|
branch?: string,
|
|
526
|
+
branchPulls: readonly number[] = [],
|
|
521
527
|
): Promise<CloseOutcome> {
|
|
522
528
|
const opened = await openTask(root, selector)
|
|
523
529
|
if ('ok' in opened) return opened
|
|
@@ -541,8 +547,12 @@ export async function closeOutcomes(
|
|
|
541
547
|
)
|
|
542
548
|
}
|
|
543
549
|
|
|
550
|
+
const recorded = readPullRequest(result.text).some((number) =>
|
|
551
|
+
branchPulls.includes(number),
|
|
552
|
+
)
|
|
553
|
+
|
|
544
554
|
const text =
|
|
545
|
-
result.closed.length > 0 && branch
|
|
555
|
+
result.closed.length > 0 && branch && !recorded
|
|
546
556
|
? writePendingBranch(result.text, branch).text
|
|
547
557
|
: result.text
|
|
548
558
|
|
package/src/tasks/validate.ts
CHANGED
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
declinedDir,
|
|
7
7
|
isReservedStem,
|
|
8
8
|
readOutcomes,
|
|
9
|
+
readPendingBranches,
|
|
9
10
|
readPlanTargets,
|
|
10
11
|
readPullRequest,
|
|
11
12
|
resolveLivePlan,
|
|
@@ -1065,6 +1066,10 @@ function nothing(): CitedResult {
|
|
|
1065
1066
|
* about, and a task naming none leaves the row untested rather than settled,
|
|
1066
1067
|
* because the only local signal left is the checkbox that produced the defect.
|
|
1067
1068
|
*
|
|
1069
|
+
* A task still carrying a pending branch is untested whatever its recorded
|
|
1070
|
+
* pull request says, since a slice is in flight that has recorded no number yet
|
|
1071
|
+
* and the landed one may belong to an earlier slice.
|
|
1072
|
+
*
|
|
1068
1073
|
* The outcome list comes off `readOutcomes` rather than a pattern of its own,
|
|
1069
1074
|
* so this check cannot disagree with the archive and outcome verbs about which
|
|
1070
1075
|
* checkboxes are outcomes and which sit inside a block a task displays.
|
|
@@ -1114,6 +1119,15 @@ async function checkCitedTask(
|
|
|
1114
1119
|
const { open, closed } = readOutcomes(text)
|
|
1115
1120
|
if (open.length > 0 || closed.length === 0) return nothing()
|
|
1116
1121
|
|
|
1122
|
+
const pending = readPendingBranches(text)
|
|
1123
|
+
if (pending.length > 0) {
|
|
1124
|
+
return untestedRow(
|
|
1125
|
+
group,
|
|
1126
|
+
subject,
|
|
1127
|
+
`waits on ${cited}, which closed every outcome but holds a pending branch (${pending.join(', ')}) that has yet to record its pull request.`,
|
|
1128
|
+
)
|
|
1129
|
+
}
|
|
1130
|
+
|
|
1117
1131
|
// The last number is the newest slice, and an earlier one closed without
|
|
1118
1132
|
// merging never lands, so only the last says whether the work reached trunk.
|
|
1119
1133
|
const pullRequest = readPullRequest(text).at(-1)
|
|
@@ -3,7 +3,6 @@ title: Architecture reference
|
|
|
3
3
|
description: Shape and content rules for canon/ARCHITECTURE.md
|
|
4
4
|
paths:
|
|
5
5
|
- 'canon/ARCHITECTURE.md'
|
|
6
|
-
- '.claude/ARCHITECTURE.md'
|
|
7
6
|
rule:
|
|
8
7
|
- "Add a decision here only when it fills one of the standard's slots, and write any other decision into the context entry for the domain it constrains. At the record's stated cap, merge or retire an entry before adding one."
|
|
9
8
|
---
|
package/standards/context.md
CHANGED
|
@@ -3,7 +3,6 @@ title: Context entry reference
|
|
|
3
3
|
description: Shape and content rules for canon/context/<domain>.md entries
|
|
4
4
|
paths:
|
|
5
5
|
- 'canon/context/**'
|
|
6
|
-
- '.claude/context/**'
|
|
7
6
|
rule:
|
|
8
7
|
- 'Before non-trivial work in a domain, read its `canon/context/<domain>.md` entry. Use `canon/context/index.md` to find it.'
|
|
9
8
|
- 'Leave the entry conforming when work in its domain changes what it describes.'
|
package/standards/design.md
CHANGED
package/standards/diagrams.md
CHANGED
|
@@ -3,12 +3,9 @@ title: Diagram reference
|
|
|
3
3
|
description: Shape and content rules for .canon/diagrams/<kind>.md files
|
|
4
4
|
paths:
|
|
5
5
|
- '.canon/diagrams/**'
|
|
6
|
-
- '.claude/DIAGRAMS.md'
|
|
7
6
|
rule:
|
|
8
7
|
- 'Layout, budgets, accessibility, labels, and render verification inside the fence are a separate topic. The mermaid standard routes them.'
|
|
9
8
|
- 'A diagram entry carries structure and flow, not implementation. Read the standard before adding or revising a kind.'
|
|
10
|
-
- 'Write a new diagram to `.canon/diagrams/<kind>.md`, never to `.claude/DIAGRAMS.md`'
|
|
11
|
-
- 'Convert a `.claude/DIAGRAMS.md` left by an older install into per-kind entries before editing it'
|
|
12
9
|
---
|
|
13
10
|
|
|
14
11
|
# Diagram reference
|
package/standards/rule.md
CHANGED
|
@@ -89,6 +89,7 @@ paths:
|
|
|
89
89
|
- Cut any rule that resists crisp one-line phrasing. Vague guidance is worse than none.
|
|
90
90
|
- Keep the file to one topic. A second topic is a second rule file.
|
|
91
91
|
- Do not restate a rule that a sibling rule or `CLAUDE.md` already owns. Point once, never duplicate.
|
|
92
|
+
- Name the current layout only, in a glob and in the body alike. A layout change ships a migration verb, as `canon migrate surface-roots` and `canon migrate records` did, rather than a rule line or a glob that keeps the old spelling loading.
|
|
92
93
|
|
|
93
94
|
## Examples
|
|
94
95
|
|
package/standards/tasks.md
CHANGED
|
@@ -141,7 +141,7 @@ One plan per task, or one per slice, which the board check reads as distinct pla
|
|
|
141
141
|
|
|
142
142
|
`Pull request:` records which pull requests carry the task's work, each as a bare `#NNN` the way `Issue:` does. It lists every pull request that shipped part of the task, oldest first and separated by commas, as in `Pull request: #NNN, #NNN`, and a task shipped whole lists one. It is not an origin, so a task without one is well-formed. `git-pr` appends to it when a pull request opens.
|
|
143
143
|
|
|
144
|
-
The line is what lets a merge close its own task. `canon tasks archive --pull-request <n>` finds the task by any number the line lists and archives it only on the last. One pull request, one task: two tasks naming the same number refuse to archive rather than both moving. `Pending branch: <branch>` holds that merge back while a branch that ticked an outcome waits on its own number, since `canon tasks outcome` writes it and `canon tasks pull-request` clears it.
|
|
144
|
+
The line is what lets a merge close its own task. `canon tasks archive --pull-request <n>` finds the task by any number the line lists and archives it only on the last. One pull request, one task: two tasks naming the same number refuse to archive rather than both moving. `Pending branch: <branch>` holds that merge back while a branch that ticked an outcome waits on its own number, since `canon tasks outcome` writes it and `canon tasks pull-request` clears it. `canon tasks outcome` skips the write when the branch's own pull request is already on the line.
|
|
145
145
|
|
|
146
146
|
## What goes in
|
|
147
147
|
|
package/standards/wireframes.md
CHANGED
|
@@ -20,12 +20,12 @@
|
|
|
20
20
|
* `scripts/check-card-exclusion.sh` fails a build that carries the marker
|
|
21
21
|
* below, which is what keeps the route out of the published site.
|
|
22
22
|
*/
|
|
23
|
-
import '@/styles/global.css'
|
|
24
23
|
|
|
25
|
-
//
|
|
24
|
+
// Point the import below at this project's global stylesheet when it lives
|
|
26
25
|
// somewhere else. An unresolved path refuses the card server by name, which is
|
|
27
26
|
// the loud direction: a card captured against no stylesheet renders as an
|
|
28
27
|
// unstyled box and reports success.
|
|
28
|
+
import '@/styles/global.css'
|
|
29
29
|
---
|
|
30
30
|
|
|
31
31
|
<html lang="en">
|