@erclx/canon 5.20.0 → 5.21.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/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-ready/REQUIREMENT.md +2 -1
- package/claude/skills/draft-ready/SKILL.md +6 -4
- package/claude/skills/markdown-propose/REQUIREMENT.md +3 -3
- package/claude/skills/markdown-propose/SKILL.md +1 -1
- package/docs/agents/commands.md +1 -0
- package/docs/agents/index.md +1 -0
- package/docs/agents/markdown-audit.md +1 -3
- package/docs/agents/slides.md +120 -0
- package/docs/agents/tasks-validate.md +1 -1
- package/docs/agents/tasks.md +1 -1
- package/docs/target/migrations.md +1 -1
- package/docs/workflow/ai-workflow.md +1 -1
- package/docs/workflow/skill-map.md +11 -13
- package/governance/rules/lib/330-testing-py.md +1 -1
- package/governance/rules/lib/336-testing-php.md +1 -1
- package/package.json +1 -1
- package/src/claude/cases/workflow.ts +4 -20
- package/src/commands/slides.ts +13 -3
- package/src/commands/tasks.ts +46 -2
- package/src/migrate/skill-names.ts +0 -7
- package/src/slides/convert/chart.ts +165 -0
- package/src/slides/convert/deck.ts +164 -0
- package/src/slides/convert/export.ts +217 -7
- package/src/slides/convert/master.ts +195 -0
- package/src/slides/convert/shapes.ts +1 -1
- package/src/slides/convert/walk.ts +187 -2
- package/src/tasks/record.ts +11 -1
- package/src/tasks/validate.ts +14 -0
- package/standards/tasks.md +1 -1
- package/tooling/astro/configs/card-src/pages/og-card.astro +2 -2
- package/claude/skills/docs-fold/REQUIREMENT.md +0 -25
- package/claude/skills/docs-fold/SKILL.md +0 -9
- package/claude/skills/standards-audit/REQUIREMENT.md +0 -33
- package/claude/skills/standards-audit/SKILL.md +0 -99
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
import type PptxGenJS from 'pptxgenjs'
|
|
2
|
+
import {
|
|
3
|
+
BAND_SLOTS,
|
|
4
|
+
type Band,
|
|
5
|
+
type BandSlot,
|
|
6
|
+
type DeckConfig,
|
|
7
|
+
} from '@/slides/convert/deck'
|
|
8
|
+
import { fontFace } from '@/slides/convert/shapes'
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* The deck's master, built from the project's own tokens as the page computed
|
|
12
|
+
* them rather than from the toolkit's design module. A slide that hides or
|
|
13
|
+
* overrides a band takes a master without that band, so editing the footer
|
|
14
|
+
* once in PowerPoint reaches every slide still on the master's own.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
export interface DeckTheme {
|
|
18
|
+
readonly background: string
|
|
19
|
+
readonly ink: string
|
|
20
|
+
readonly muted: string
|
|
21
|
+
readonly accent: string
|
|
22
|
+
readonly face: string
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** What the first slide computed, read in the page by `readTheme`. */
|
|
26
|
+
export interface ThemeReading {
|
|
27
|
+
/** Each role's `--color-*` value as hex, absent where none is declared. */
|
|
28
|
+
readonly tokens: {
|
|
29
|
+
readonly background?: string
|
|
30
|
+
readonly text?: string
|
|
31
|
+
readonly muted?: string
|
|
32
|
+
readonly accent?: string
|
|
33
|
+
}
|
|
34
|
+
readonly body: { readonly background: string; readonly color: string }
|
|
35
|
+
readonly fontFamily: string
|
|
36
|
+
/** Every declared `--color-*` property in sheet order, for chart series. */
|
|
37
|
+
readonly roles: readonly { readonly name: string; readonly hex: string }[]
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface MasterBands {
|
|
41
|
+
readonly header: boolean
|
|
42
|
+
readonly footer: boolean
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Intrinsic pixel size, which sets the mark's aspect inside its box. */
|
|
46
|
+
export interface MarkSize {
|
|
47
|
+
readonly width: number
|
|
48
|
+
readonly height: number
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const SLIDE_W = 13.333
|
|
52
|
+
const MARGIN = 0.5
|
|
53
|
+
const BAND_H = 0.4
|
|
54
|
+
const HEADER_Y = 0.2
|
|
55
|
+
const FOOTER_Y = 7.5 - 0.2 - BAND_H
|
|
56
|
+
const SLOT_W = (SLIDE_W - 2 * MARGIN) / 3
|
|
57
|
+
const NUMBER_W = 1
|
|
58
|
+
const MARK_BOX = { w: 1.2, h: BAND_H }
|
|
59
|
+
const BAND_PT = 10
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* A missing role falls back to what the slide's body computed, never to the
|
|
63
|
+
* toolkit's palette, which would put Canon's colors on another project's deck.
|
|
64
|
+
*/
|
|
65
|
+
export function deckTheme(reading: ThemeReading): {
|
|
66
|
+
readonly theme: DeckTheme
|
|
67
|
+
readonly notices: readonly string[]
|
|
68
|
+
} {
|
|
69
|
+
const { tokens, body } = reading
|
|
70
|
+
const missing = (
|
|
71
|
+
[
|
|
72
|
+
['background', tokens.background],
|
|
73
|
+
['text', tokens.text],
|
|
74
|
+
['muted', tokens.muted],
|
|
75
|
+
['accent', tokens.accent],
|
|
76
|
+
] as const
|
|
77
|
+
)
|
|
78
|
+
.filter(([, value]) => value === undefined)
|
|
79
|
+
.map(([role]) => `--color-${role}`)
|
|
80
|
+
return {
|
|
81
|
+
theme: {
|
|
82
|
+
background: tokens.background ?? body.background,
|
|
83
|
+
ink: tokens.text ?? body.color,
|
|
84
|
+
muted: tokens.muted ?? body.color,
|
|
85
|
+
accent: tokens.accent ?? body.color,
|
|
86
|
+
face: fontFace(reading.fontFamily),
|
|
87
|
+
},
|
|
88
|
+
notices:
|
|
89
|
+
missing.length === 0
|
|
90
|
+
? []
|
|
91
|
+
: [
|
|
92
|
+
`no ${missing.join(' or ')} token, so the deck master takes the slide body ${missing.length === 1 ? 'color for it' : 'color for them'}`,
|
|
93
|
+
],
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function masterName(bands: MasterBands): string {
|
|
98
|
+
const parts = [bands.header && 'header', bands.footer && 'footer'].filter(
|
|
99
|
+
Boolean,
|
|
100
|
+
)
|
|
101
|
+
return parts.length === 0 ? 'canon-bare' : `canon-${parts.join('-')}`
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const SLOT_ALIGN: Record<BandSlot, PptxGenJS.HAlign> = {
|
|
105
|
+
left: 'left',
|
|
106
|
+
center: 'center',
|
|
107
|
+
right: 'right',
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* One text object per filled slot. The master draws these for a band it
|
|
112
|
+
* carries, and a slide overriding a band draws its own through the same call.
|
|
113
|
+
*/
|
|
114
|
+
export function bandTexts(
|
|
115
|
+
band: Omit<Band, 'show'>,
|
|
116
|
+
kind: 'header' | 'footer',
|
|
117
|
+
theme: DeckTheme,
|
|
118
|
+
options: { readonly hasMark?: boolean } = {},
|
|
119
|
+
): { readonly text: string; readonly options: PptxGenJS.TextPropsOptions }[] {
|
|
120
|
+
return BAND_SLOTS.flatMap((slot, index) => {
|
|
121
|
+
const text = band[slot]
|
|
122
|
+
if (!text) return []
|
|
123
|
+
const isMarkSlot = kind === 'header' && slot === 'right' && options.hasMark
|
|
124
|
+
return [
|
|
125
|
+
{
|
|
126
|
+
text,
|
|
127
|
+
options: {
|
|
128
|
+
x: MARGIN + index * SLOT_W,
|
|
129
|
+
y: kind === 'header' ? HEADER_Y : FOOTER_Y,
|
|
130
|
+
w: SLOT_W - (isMarkSlot ? MARK_BOX.w + 0.1 : 0),
|
|
131
|
+
h: BAND_H,
|
|
132
|
+
margin: 0,
|
|
133
|
+
align: SLOT_ALIGN[slot],
|
|
134
|
+
valign: 'middle',
|
|
135
|
+
fontFace: theme.face,
|
|
136
|
+
fontSize: BAND_PT,
|
|
137
|
+
color: kind === 'header' ? theme.ink : theme.muted,
|
|
138
|
+
},
|
|
139
|
+
},
|
|
140
|
+
]
|
|
141
|
+
})
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** The right of the footer, which `deck.json` keeps free while numbers show. */
|
|
145
|
+
export function slideNumberProps(theme: DeckTheme): PptxGenJS.SlideNumberProps {
|
|
146
|
+
return {
|
|
147
|
+
x: SLIDE_W - MARGIN - NUMBER_W,
|
|
148
|
+
y: FOOTER_Y,
|
|
149
|
+
w: NUMBER_W,
|
|
150
|
+
h: BAND_H,
|
|
151
|
+
margin: 0,
|
|
152
|
+
align: 'right',
|
|
153
|
+
valign: 'middle',
|
|
154
|
+
fontFace: theme.face,
|
|
155
|
+
fontSize: BAND_PT,
|
|
156
|
+
color: theme.accent,
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function markImage(path: string, size: MarkSize): PptxGenJS.ImageProps {
|
|
161
|
+
const scale = Math.min(MARK_BOX.w / size.width, MARK_BOX.h / size.height)
|
|
162
|
+
const w = Math.round(size.width * scale * 1000) / 1000
|
|
163
|
+
const h = Math.round(size.height * scale * 1000) / 1000
|
|
164
|
+
return { path, x: SLIDE_W - MARGIN - w, y: HEADER_Y, w, h }
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export function buildMaster(
|
|
168
|
+
deck: DeckConfig,
|
|
169
|
+
theme: DeckTheme,
|
|
170
|
+
bands: MasterBands,
|
|
171
|
+
markSize: MarkSize = { width: 1, height: 1 },
|
|
172
|
+
): PptxGenJS.SlideMasterProps {
|
|
173
|
+
const hasHeader = bands.header && deck.header.show
|
|
174
|
+
const hasFooter = bands.footer && deck.footer.show
|
|
175
|
+
const hasMark = deck.mark !== undefined
|
|
176
|
+
const objects: NonNullable<PptxGenJS.SlideMasterProps['objects']> = [
|
|
177
|
+
...(deck.mark ? [{ image: markImage(deck.mark, markSize) }] : []),
|
|
178
|
+
...(hasHeader
|
|
179
|
+
? bandTexts(deck.header, 'header', theme, { hasMark }).map((text) => ({
|
|
180
|
+
text,
|
|
181
|
+
}))
|
|
182
|
+
: []),
|
|
183
|
+
...(hasFooter
|
|
184
|
+
? bandTexts(deck.footer, 'footer', theme).map((text) => ({ text }))
|
|
185
|
+
: []),
|
|
186
|
+
]
|
|
187
|
+
return {
|
|
188
|
+
title: masterName(bands),
|
|
189
|
+
background: { color: theme.background },
|
|
190
|
+
objects,
|
|
191
|
+
...(hasFooter && deck.slideNumbers
|
|
192
|
+
? { slideNumber: slideNumberProps(theme) }
|
|
193
|
+
: {}),
|
|
194
|
+
}
|
|
195
|
+
}
|
|
@@ -150,7 +150,7 @@ const PT_PER_PX = 0.75
|
|
|
150
150
|
const inches = (px: number): number => px / PX_PER_INCH
|
|
151
151
|
const points = (px: number): number => px * PT_PER_PX
|
|
152
152
|
|
|
153
|
-
function position(box: Rect): PptxGenJS.PositionProps {
|
|
153
|
+
export function position(box: Rect): PptxGenJS.PositionProps {
|
|
154
154
|
return {
|
|
155
155
|
x: inches(box.x),
|
|
156
156
|
y: inches(box.y),
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import type { ChartRecord } from '@/slides/convert/chart'
|
|
2
|
+
import type { ThemeReading } from '@/slides/convert/master'
|
|
1
3
|
import type {
|
|
2
4
|
BoxStyle,
|
|
3
5
|
ElementRecord,
|
|
@@ -9,10 +11,34 @@ import type {
|
|
|
9
11
|
TextRun,
|
|
10
12
|
} from '@/slides/convert/shapes'
|
|
11
13
|
|
|
14
|
+
/** How one slide treats a band the deck master draws. */
|
|
15
|
+
export type BandOverride =
|
|
16
|
+
| { readonly kind: 'master' }
|
|
17
|
+
| { readonly kind: 'off' }
|
|
18
|
+
| {
|
|
19
|
+
readonly kind: 'override'
|
|
20
|
+
readonly left?: string
|
|
21
|
+
readonly center?: string
|
|
22
|
+
readonly right?: string
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** What a slide declares about itself on `<body>` and in its notes. */
|
|
26
|
+
export interface SlideMeta {
|
|
27
|
+
readonly notes?: string
|
|
28
|
+
readonly section?: string
|
|
29
|
+
readonly isHidden: boolean
|
|
30
|
+
readonly header: BandOverride
|
|
31
|
+
readonly footer: BandOverride
|
|
32
|
+
/** The `--color-text` the slide's `<body>` computes, as hex. */
|
|
33
|
+
readonly textToken?: string
|
|
34
|
+
}
|
|
35
|
+
|
|
12
36
|
/** What one laid-out slide hands back across `page.evaluate`. */
|
|
13
37
|
export interface WalkedSlide {
|
|
14
38
|
readonly background: Rgba
|
|
15
39
|
readonly records: ElementRecord[]
|
|
40
|
+
readonly charts: ChartRecord[]
|
|
41
|
+
readonly meta: SlideMeta
|
|
16
42
|
}
|
|
17
43
|
|
|
18
44
|
/** Set on every walked element, so a screenshot finds the one a record names. */
|
|
@@ -27,7 +53,8 @@ export const ID_ATTRIBUTE = 'data-canon-id'
|
|
|
27
53
|
* Text gathers along the inline flow. A block holding text takes every run its
|
|
28
54
|
* inline descendants carry, so `<strong>` inside `<p>` is one shape with two
|
|
29
55
|
* runs, while a block-level child is walked on its own. Images, SVGs, and
|
|
30
|
-
* tables consume their whole subtree.
|
|
56
|
+
* tables consume their whole subtree. Speaker notes in `<aside class="notes">`
|
|
57
|
+
* and a chart's `<figure data-chart>` are read whole and never drawn as shapes.
|
|
31
58
|
*
|
|
32
59
|
* Each walked element is tagged with `idAttribute` set to its record id.
|
|
33
60
|
*/
|
|
@@ -293,10 +320,41 @@ export function walkSlide(idAttribute: string): WalkedSlide {
|
|
|
293
320
|
|
|
294
321
|
const SKIPPED = new Set(['script', 'style', 'template', 'noscript', 'link'])
|
|
295
322
|
|
|
323
|
+
const cellText = (element: Element): string =>
|
|
324
|
+
(element.textContent ?? '').replace(/\s+/g, ' ').trim()
|
|
325
|
+
|
|
326
|
+
/** One line per block, so a paragraph break survives and no tag does. */
|
|
327
|
+
const notesText = (element: Element): string => {
|
|
328
|
+
const clone = element.cloneNode(true)
|
|
329
|
+
if (!(clone instanceof Element)) return ''
|
|
330
|
+
for (const br of Array.from(clone.querySelectorAll('br'))) {
|
|
331
|
+
br.replaceWith('\n')
|
|
332
|
+
}
|
|
333
|
+
for (const block of Array.from(
|
|
334
|
+
clone.querySelectorAll('p, li, div, blockquote, h1, h2, h3, h4, h5, h6'),
|
|
335
|
+
)) {
|
|
336
|
+
block.append('\n')
|
|
337
|
+
}
|
|
338
|
+
return (clone.textContent ?? '')
|
|
339
|
+
.split('\n')
|
|
340
|
+
.map((line) => line.replace(/\s+/g, ' ').trim())
|
|
341
|
+
.filter(Boolean)
|
|
342
|
+
.join('\n')
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
const notes: string[] = []
|
|
346
|
+
const charts: ChartRecord[] = []
|
|
347
|
+
|
|
296
348
|
for (const element of Array.from(document.body.querySelectorAll('*'))) {
|
|
297
349
|
if (consumed.has(element)) continue
|
|
298
350
|
const tag = element.tagName.toLowerCase()
|
|
299
351
|
if (SKIPPED.has(tag)) continue
|
|
352
|
+
if (tag === 'aside' && element.classList.contains('notes')) {
|
|
353
|
+
consumeSubtree(element)
|
|
354
|
+
const text = notesText(element)
|
|
355
|
+
if (text) notes.push(text)
|
|
356
|
+
continue
|
|
357
|
+
}
|
|
300
358
|
if (!element.checkVisibility({ visibilityProperty: true })) continue
|
|
301
359
|
|
|
302
360
|
const id = records.length + 1
|
|
@@ -313,6 +371,27 @@ export function walkSlide(idAttribute: string): WalkedSlide {
|
|
|
313
371
|
ids.set(element, id)
|
|
314
372
|
element.setAttribute(idAttribute, String(id))
|
|
315
373
|
|
|
374
|
+
if (tag === 'figure' && element.hasAttribute('data-chart')) {
|
|
375
|
+
consumeSubtree(element)
|
|
376
|
+
records.push({ ...base, kind: 'box' })
|
|
377
|
+
const table = element.querySelector('table')
|
|
378
|
+
const caption = element.querySelector('figcaption')
|
|
379
|
+
const title = caption ? cellText(caption) : ''
|
|
380
|
+
charts.push({
|
|
381
|
+
selector: base.selector,
|
|
382
|
+
box: bounds,
|
|
383
|
+
type: element.getAttribute('data-chart') ?? '',
|
|
384
|
+
hasLabels: element.hasAttribute('data-labels'),
|
|
385
|
+
...(title ? { title } : {}),
|
|
386
|
+
rows: table
|
|
387
|
+
? Array.from(table.rows).map((row) =>
|
|
388
|
+
Array.from(row.cells).map(cellText),
|
|
389
|
+
)
|
|
390
|
+
: [],
|
|
391
|
+
})
|
|
392
|
+
continue
|
|
393
|
+
}
|
|
394
|
+
|
|
316
395
|
if (element instanceof HTMLImageElement) {
|
|
317
396
|
records.push({
|
|
318
397
|
...base,
|
|
@@ -440,5 +519,111 @@ export function walkSlide(idAttribute: string): WalkedSlide {
|
|
|
440
519
|
? htmlGround
|
|
441
520
|
: { hex: 'FFFFFF', alpha: 1 }
|
|
442
521
|
|
|
443
|
-
|
|
522
|
+
const body = document.body
|
|
523
|
+
const band = (kind: 'header' | 'footer'): BandOverride => {
|
|
524
|
+
if (body.getAttribute(`data-${kind}`) === 'off') return { kind: 'off' }
|
|
525
|
+
const slots: { left?: string; center?: string; right?: string } = {}
|
|
526
|
+
for (const slot of ['left', 'center', 'right'] as const) {
|
|
527
|
+
const value = body.getAttribute(`data-${kind}-${slot}`)
|
|
528
|
+
if (value !== null) slots[slot] = value
|
|
529
|
+
}
|
|
530
|
+
return Object.keys(slots).length > 0
|
|
531
|
+
? { kind: 'override', ...slots }
|
|
532
|
+
: { kind: 'master' }
|
|
533
|
+
}
|
|
534
|
+
const section = body.getAttribute('data-section')?.trim()
|
|
535
|
+
const textValue = getComputedStyle(body)
|
|
536
|
+
.getPropertyValue('--color-text')
|
|
537
|
+
.trim()
|
|
538
|
+
const meta: SlideMeta = {
|
|
539
|
+
...(textValue ? { textToken: rgba(textValue).hex } : {}),
|
|
540
|
+
...(notes.length > 0 ? { notes: notes.join('\n\n') } : {}),
|
|
541
|
+
...(section ? { section } : {}),
|
|
542
|
+
isHidden:
|
|
543
|
+
body.hasAttribute('data-hidden') &&
|
|
544
|
+
body.getAttribute('data-hidden') !== 'false',
|
|
545
|
+
header: band('header'),
|
|
546
|
+
footer: band('footer'),
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
return { background, records, charts, meta }
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
/**
|
|
553
|
+
* Runs inside the laid-out page and reads the colors and face the deck master
|
|
554
|
+
* takes. Tokens come from `<html>`'s computed style, so a slide that switches
|
|
555
|
+
* theme on its own `<body>`, such as a dark cover, leaves the master on the
|
|
556
|
+
* project's root theme. The body supplies the fallback colors and the face.
|
|
557
|
+
* Serialized to source like `walkSlide`, so its helpers sit in its own body.
|
|
558
|
+
*/
|
|
559
|
+
export function readTheme(): ThemeReading {
|
|
560
|
+
const canvas = document.createElement('canvas')
|
|
561
|
+
canvas.width = 1
|
|
562
|
+
canvas.height = 1
|
|
563
|
+
const context = canvas.getContext('2d', { willReadFrequently: true })
|
|
564
|
+
const hex = (value: string): string | undefined => {
|
|
565
|
+
if (!context || !value || value === 'transparent') return undefined
|
|
566
|
+
context.clearRect(0, 0, 1, 1)
|
|
567
|
+
context.fillStyle = '#000000'
|
|
568
|
+
context.fillStyle = value
|
|
569
|
+
context.fillRect(0, 0, 1, 1)
|
|
570
|
+
const [r = 0, g = 0, b = 0, a = 0] = context.getImageData(0, 0, 1, 1).data
|
|
571
|
+
if (a === 0) return undefined
|
|
572
|
+
return [r, g, b]
|
|
573
|
+
.map((channel) => channel.toString(16).padStart(2, '0'))
|
|
574
|
+
.join('')
|
|
575
|
+
.toUpperCase()
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
const style = getComputedStyle(document.body)
|
|
579
|
+
const rootStyle = getComputedStyle(document.documentElement)
|
|
580
|
+
const token = (name: string): string | undefined => {
|
|
581
|
+
const value = rootStyle.getPropertyValue(name).trim()
|
|
582
|
+
return value ? hex(value) : undefined
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
const names: string[] = []
|
|
586
|
+
for (const sheet of Array.from(document.styleSheets)) {
|
|
587
|
+
let rules: CSSRuleList
|
|
588
|
+
try {
|
|
589
|
+
rules = sheet.cssRules
|
|
590
|
+
} catch {
|
|
591
|
+
continue
|
|
592
|
+
}
|
|
593
|
+
for (const rule of Array.from(rules)) {
|
|
594
|
+
if (!(rule instanceof CSSStyleRule)) continue
|
|
595
|
+
for (const name of Array.from(rule.style)) {
|
|
596
|
+
if (name.startsWith('--color-') && !names.includes(name)) {
|
|
597
|
+
names.push(name)
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
const tokens: {
|
|
604
|
+
background?: string
|
|
605
|
+
text?: string
|
|
606
|
+
muted?: string
|
|
607
|
+
accent?: string
|
|
608
|
+
} = {}
|
|
609
|
+
for (const role of ['background', 'text', 'muted', 'accent'] as const) {
|
|
610
|
+
const value = token(`--color-${role}`)
|
|
611
|
+
if (value) tokens[role] = value
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
return {
|
|
615
|
+
tokens,
|
|
616
|
+
body: {
|
|
617
|
+
background:
|
|
618
|
+
hex(style.backgroundColor) ??
|
|
619
|
+
hex(getComputedStyle(document.documentElement).backgroundColor) ??
|
|
620
|
+
'FFFFFF',
|
|
621
|
+
color: hex(style.color) ?? '000000',
|
|
622
|
+
},
|
|
623
|
+
fontFamily: style.fontFamily,
|
|
624
|
+
roles: names.flatMap((name) => {
|
|
625
|
+
const value = token(name)
|
|
626
|
+
return value ? [{ name, hex: value }] : []
|
|
627
|
+
}),
|
|
628
|
+
}
|
|
444
629
|
}
|
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)
|
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
|
|
|
@@ -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">
|
|
@@ -1,25 +0,0 @@
|
|
|
1
|
-
---
|
|
2
|
-
name: docs-fold
|
|
3
|
-
description: Why a pointer answers under the fold skill's old name, and the test that retires it
|
|
4
|
-
---
|
|
5
|
-
|
|
6
|
-
# Docs fold requirement
|
|
7
|
-
|
|
8
|
-
## Gap
|
|
9
|
-
|
|
10
|
-
Without this skill, a person typing the old name after the rename to `context-fold` meets an unknown command with nothing naming where the skill went. A target that has not run `canon migrate skill-names` still holds files telling someone to type it, and a live gitignored record written before the rename still names it, since the sweep reads the tracked listing only.
|
|
11
|
-
|
|
12
|
-
## Must
|
|
13
|
-
|
|
14
|
-
- Name `context-fold` as the skill to run, so a typed invocation of the old name reaches the new one in one step
|
|
15
|
-
- Carry `disable-model-invocation: true`, so routing never matches the old description against a request the new skill owns
|
|
16
|
-
|
|
17
|
-
## Must not
|
|
18
|
-
|
|
19
|
-
- Carry any part of the fold procedure, which would give one behavior two bodies that drift apart
|
|
20
|
-
- Retire on a release number. Retire it when `canon migrate skill-names --json` reports zero citations in every target the rollout reaches and no live `.canon/` record names the old name, since a release number retires it on a date nothing measured.
|
|
21
|
-
|
|
22
|
-
## Out of scope
|
|
23
|
-
|
|
24
|
-
- The fold itself, which `context-fold` owns
|
|
25
|
-
- Rewriting a stale citation, which `canon migrate skill-names` owns in a tracked file and the controlling session owns on the board
|
|
@@ -1,9 +0,0 @@
|
|
|
1
|
-
---
|
|
2
|
-
name: docs-fold
|
|
3
|
-
description: Renamed to `context-fold`. Answers a typed invocation of the old name by pointing at the new one.
|
|
4
|
-
disable-model-invocation: true
|
|
5
|
-
---
|
|
6
|
-
|
|
7
|
-
# Docs fold
|
|
8
|
-
|
|
9
|
-
This skill is now `context-fold`. Run `/canon:context-fold` instead, and change any file that still names this one.
|
|
@@ -1,33 +0,0 @@
|
|
|
1
|
-
---
|
|
2
|
-
name: standards-audit
|
|
3
|
-
description: What the standards audit is for, the gaps it closes, and the fixing it deliberately refuses
|
|
4
|
-
---
|
|
5
|
-
|
|
6
|
-
# Standards audit requirement
|
|
7
|
-
|
|
8
|
-
## Gap
|
|
9
|
-
|
|
10
|
-
Without this skill, markdown ships unchecked against the authoring standards it was written under. A session fresh from writing prose does not reopen the standard it was meant to follow, banned punctuation survives because it reads fine, and the mapping from a file to the standards governing it gets re-derived by guess on every pass.
|
|
11
|
-
|
|
12
|
-
## Must
|
|
13
|
-
|
|
14
|
-
- Scope the audit to markdown the project hand-authors, so a generated file is not reported against a standard nobody applies to it
|
|
15
|
-
- Decide which standards govern each changed file, rather than auditing every file against every standard
|
|
16
|
-
- Grep for the violations a standard makes mechanically detectable, because reading alone misses occurrences
|
|
17
|
-
- Report each finding with a file and a line, grouped so one file is one fix pass
|
|
18
|
-
|
|
19
|
-
## Must not
|
|
20
|
-
|
|
21
|
-
- Fix a violation, or propose the fix inline. The cheap swap satisfies the grep and breaks the rule the grep was standing in for.
|
|
22
|
-
- Enumerate specific rules or tokens from a standard. The standard is the list, and a copy of it here drifts the moment the standard changes.
|
|
23
|
-
|
|
24
|
-
## Guards
|
|
25
|
-
|
|
26
|
-
- No markdown changed since main: stop with a pass, not an error
|
|
27
|
-
- No installed standards to audit against: stop and say where they come from
|
|
28
|
-
|
|
29
|
-
## Out of scope
|
|
30
|
-
|
|
31
|
-
- Code style and structure, which `.claude/rules/` governs and Claude Code loads directly
|
|
32
|
-
- Whether a standard is itself correct. A finding against the corpus is an argument to change the standard, and that is a standards edit rather than an audit result.
|
|
33
|
-
- Persisting a report. The diff is the living state and the audit is a check against it at a moment.
|