@erclx/canon 5.75.0 → 5.76.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.
@@ -34,10 +34,13 @@ const FONT_PROBE_SIZE = 72
34
34
  const FONT_PROBE_TEXT = 'canon capture 0123456789'
35
35
  const ABSENT_FAMILY = '__canon_absent_family__'
36
36
 
37
+ export type ColorScheme = 'light' | 'dark'
38
+
37
39
  export interface CaptureOptions {
38
40
  selector: string
39
41
  outDir?: string
40
42
  width?: number
43
+ colorScheme?: ColorScheme
41
44
  }
42
45
 
43
46
  export type CaptureResult =
@@ -60,9 +63,7 @@ export async function captureSources(
60
63
  const browser = await chromium.launch()
61
64
  try {
62
65
  return await Promise.all(
63
- sources.map((source) =>
64
- captureOne(browser, source, options.selector, options.width),
65
- ),
66
+ sources.map((source) => captureOne(browser, source, options)),
66
67
  )
67
68
  } finally {
68
69
  await browser.close()
@@ -76,8 +77,7 @@ export async function captureSources(
76
77
  async function captureOne(
77
78
  browser: Browser,
78
79
  source: CaptureSource,
79
- selector: string,
80
- width: number | undefined,
80
+ { selector, width, colorScheme }: CaptureOptions,
81
81
  ): Promise<CaptureResult> {
82
82
  /**
83
83
  * A viewport rather than a wrapper, since a media query answers to the
@@ -90,11 +90,13 @@ async function captureOne(
90
90
  ...(width === undefined
91
91
  ? {}
92
92
  : { viewport: { width, height: DEFAULT_VIEWPORT_HEIGHT } }),
93
+ ...(colorScheme === undefined ? {} : { colorScheme }),
93
94
  })
94
95
  try {
95
96
  await page.goto(
96
97
  source.kind === 'url' ? source.url : pathToFileURL(source.htmlPath).href,
97
98
  )
99
+ if (colorScheme !== undefined) await applyColorScheme(page, colorScheme)
98
100
  const element = page.locator(selector).first()
99
101
  if ((await element.count()) === 0) {
100
102
  return failed(source, `no element matched ${selector}`)
@@ -177,6 +179,29 @@ function sourceIdentifier(source: CaptureSource): string {
177
179
  return source.kind === 'url' ? source.url : source.htmlPath
178
180
  }
179
181
 
182
+ /**
183
+ * Writes the theme the three switches a stylesheet reads, being the media
184
+ * query, a `data-theme` attribute, and a `light` or `dark` class on the root,
185
+ * which is the set Frameboard's theme toggle writes on every frame. The media
186
+ * query alone leaves a stylesheet keyed on the attribute in its default set,
187
+ * which is dark for one `canon design css` emits.
188
+ *
189
+ * Written after the load event, so a page script restoring a stored theme on
190
+ * load has already run and cannot overwrite it.
191
+ */
192
+ async function applyColorScheme(
193
+ page: Page,
194
+ scheme: ColorScheme,
195
+ ): Promise<void> {
196
+ await page.evaluate((theme) => {
197
+ const root = document.documentElement
198
+ root.setAttribute('data-theme', theme)
199
+ root.style.colorScheme = theme
200
+ root.classList.remove('light', 'dark')
201
+ root.classList.add(theme)
202
+ }, scheme)
203
+ }
204
+
180
205
  function failed(source: CaptureSource, reason: string): CaptureResult {
181
206
  return { status: 'failed', source: sourceIdentifier(source), reason }
182
207
  }
@@ -22,6 +22,11 @@ export const GIT_CASES: readonly SkillCase[] = [
22
22
  prompt: 'Turn this bug into a GitHub issue and file it.',
23
23
  expect: 'git-issue',
24
24
  },
25
+ {
26
+ prompt:
27
+ 'Log this bug, but add it to the open issue if one already covers it.',
28
+ expect: 'git-issue',
29
+ },
25
30
  {
26
31
  prompt: 'Close #42 without merging, #57 supersedes it.',
27
32
  expect: 'git-pr-close',
@@ -96,6 +96,10 @@ export const WORKFLOW_CASES: readonly SkillCase[] = [
96
96
  'Our backlog only ever grows, measure each row and suggest which to decline.',
97
97
  expect: 'backlog-triage',
98
98
  },
99
+ {
100
+ prompt: 'File the open issues on this repo into an intake folder.',
101
+ expect: 'issue-triage',
102
+ },
99
103
  // Rot across documents nobody changed is what this reads, rather than a
100
104
  // stated rule broken on a given line. It is also not `target-check`, which
101
105
  // asks whether a document exists and holds its sections rather than whether
@@ -31,6 +31,9 @@ const DEFAULT_SOURCE = 'assets'
31
31
  */
32
32
  type Renderer = typeof import('@/capture/render')
33
33
  type CaptureResult = Awaited<ReturnType<Renderer['captureSources']>>[number]
34
+ type ColorScheme = import('@/capture/render').ColorScheme
35
+
36
+ const COLOR_SCHEMES: readonly ColorScheme[] = ['light', 'dark']
34
37
 
35
38
  /**
36
39
  * Holds wiring only. Every browser reference sits behind a dynamic import,
@@ -58,6 +61,10 @@ export function register(program: Command): void {
58
61
  '--width <px>',
59
62
  'Viewport width in pixels, so a media query resolves at that width',
60
63
  )
64
+ .option(
65
+ '--color-scheme <scheme>',
66
+ 'Theme to render in, light or dark; defaults to whichever theme the page renders unset',
67
+ )
61
68
  .addHelpText(
62
69
  'after',
63
70
  [
@@ -70,12 +77,21 @@ export function register(program: Command): void {
70
77
  'font-family. --width takes one whole number and sets the viewport, so',
71
78
  'run it once per breakpoint with its own --out.',
72
79
  '',
80
+ '--color-scheme sets prefers-color-scheme, a data-theme attribute, and',
81
+ 'a light or dark class on the root, so a stylesheet keyed on any of the',
82
+ 'three renders that theme. Run it once per scheme with its own --out.',
83
+ '',
73
84
  ].join('\n'),
74
85
  )
75
86
  .action(
76
87
  async (
77
88
  source: string,
78
- opts: { out?: string; selector?: string; width?: string },
89
+ opts: {
90
+ out?: string
91
+ selector?: string
92
+ width?: string
93
+ colorScheme?: string
94
+ },
79
95
  ): Promise<void> => {
80
96
  /**
81
97
  * Refused rather than defaulted, and refused ahead of every other
@@ -102,6 +118,15 @@ export function register(program: Command): void {
102
118
  return
103
119
  }
104
120
 
121
+ const colorScheme = parseColorScheme(opts.colorScheme)
122
+ if (colorScheme === null) {
123
+ frameError(
124
+ `--color-scheme takes ${COLOR_SCHEMES.join(' or ')}, got ${JSON.stringify(opts.colorScheme)}.`,
125
+ )
126
+ process.exitCode = 1
127
+ return
128
+ }
129
+
105
130
  const isUrl = isUrlSource(source)
106
131
  const sourcePath = isUrl ? source : resolve(process.cwd(), source)
107
132
  if (!isUrl && !existsSync(sourcePath)) {
@@ -117,6 +142,7 @@ export function register(program: Command): void {
117
142
  results = await renderer.captureSources(sourcePath, {
118
143
  selector,
119
144
  width,
145
+ colorScheme,
120
146
  outDir: opts.out ? resolve(process.cwd(), opts.out) : undefined,
121
147
  })
122
148
  } catch (error) {
@@ -160,6 +186,17 @@ function parseWidth(raw: string | undefined): number | undefined {
160
186
  return Number.isInteger(value) && value >= 1 ? value : Number.NaN
161
187
  }
162
188
 
189
+ /**
190
+ * Undefined for an absent flag and `null` for a value naming no scheme, so the
191
+ * caller can tell no scheme from a bad one.
192
+ */
193
+ function parseColorScheme(
194
+ raw: string | undefined,
195
+ ): ColorScheme | undefined | null {
196
+ if (raw === undefined) return undefined
197
+ return COLOR_SCHEMES.find((scheme) => scheme === raw) ?? null
198
+ }
199
+
163
200
  /**
164
201
  * Closes an open frame around a failure that stopped the whole run rather than
165
202
  * one source. Two of them are setup states rather than defects and each names
@@ -713,6 +713,10 @@ async function runSync(target: string): Promise<number> {
713
713
  logWarn(
714
714
  'settings.json lacks the merge deny entries. Run `canon migrate session-defaults --write`',
715
715
  )
716
+ } else if (defaults.kind === 'planned' && defaults.plan.lacksPushAllow) {
717
+ logWarn(
718
+ 'settings.json lacks the lease push allow. Run `canon migrate session-defaults --write`',
719
+ )
716
720
  }
717
721
 
718
722
  logStep('Scanning .gitignore')
@@ -260,6 +260,8 @@ export const GATED_PATHS: readonly string[] = [
260
260
  'claude/skills/git-worktree/REQUIREMENT.md',
261
261
  'claude/skills/git-worktree/SKILL.md',
262
262
  'claude/skills/git-worktree/references/cleanup.md',
263
+ 'claude/skills/issue-triage/REQUIREMENT.md',
264
+ 'claude/skills/issue-triage/SKILL.md',
263
265
  'claude/skills/markdown-craft/REQUIREMENT.md',
264
266
  'claude/skills/markdown-craft/SKILL.md',
265
267
  'claude/skills/markdown-craft/references/markdown.md',
@@ -1,8 +1,8 @@
1
1
  /**
2
2
  * Adds the session defaults the seeded `.claude/settings.json` carries to a
3
3
  * target that was seeded before them: the subagent deny, the memory default,
4
- * the merge deny entries, and the plan guard's PreToolUse wiring where the
5
- * project holds the guard script.
4
+ * the merge deny entries, the lease push allow entries, and the plan guard's
5
+ * PreToolUse wiring where the project holds the guard script.
6
6
  *
7
7
  * `canon claude init` copies a seed only when the file is absent, so a target
8
8
  * holding its own settings never sees a later seed change. This verb adds each
@@ -29,6 +29,17 @@ export const MERGE_DENIED: readonly string[] = [
29
29
  'Bash(gh pr merge:*)',
30
30
  ]
31
31
 
32
+ /**
33
+ * The two lease pushes a worker runs after rebasing onto a merged pull request,
34
+ * each pushing the current branch to its own name. An allow rule resolves
35
+ * before auto mode's classifier, so each entry is exact: a wildcard would also
36
+ * approve a refspec, a remote, or a flag that reaches the default branch.
37
+ */
38
+ export const PUSH_ALLOWED: readonly string[] = [
39
+ 'Bash(git push --force-with-lease)',
40
+ 'Bash(git push --force-with-lease origin HEAD)',
41
+ ]
42
+
32
43
  /** The command the seed wires, as the seeded settings spell it. */
33
44
  export const PLAN_GUARD_COMMAND =
34
45
  '"$CLAUDE_PROJECT_DIR"/.claude/hooks/plan-guard.sh'
@@ -61,6 +72,8 @@ export interface SessionDefaultsPlan {
61
72
  readonly predates: boolean
62
73
  /** True when the deny list lacks any merge entry, whatever else it holds. */
63
74
  readonly lacksMergeDeny: boolean
75
+ /** True when the allow list lacks any lease push entry. */
76
+ readonly lacksPushAllow: boolean
64
77
  }
65
78
 
66
79
  export type SessionDefaultsRead =
@@ -92,12 +105,20 @@ export function planSessionDefaults(
92
105
  const next: SettingsRecord = { ...current }
93
106
  const permissions = isRecord(current.permissions) ? current.permissions : {}
94
107
  const deny = Array.isArray(permissions.deny) ? permissions.deny : []
108
+ const allow = Array.isArray(permissions.allow) ? permissions.allow : []
95
109
 
96
110
  const subagentsMissing = DENIED_TOOLS.filter((tool) => !deny.includes(tool))
97
111
  const mergeMissing = MERGE_DENIED.filter((entry) => !deny.includes(entry))
98
112
  const missing = [...subagentsMissing, ...mergeMissing]
99
- if (missing.length > 0) {
100
- next.permissions = { ...permissions, deny: [...deny, ...missing] }
113
+ const allowMissing = PUSH_ALLOWED.filter((entry) => !allow.includes(entry))
114
+
115
+ const nextPermissions: SettingsRecord = { ...permissions }
116
+ if (missing.length > 0) nextPermissions.deny = [...deny, ...missing]
117
+ if (allowMissing.length > 0) {
118
+ nextPermissions.allow = [...allow, ...allowMissing]
119
+ }
120
+ if (missing.length + allowMissing.length > 0) {
121
+ next.permissions = nextPermissions
101
122
  }
102
123
 
103
124
  const memoryAbsent = !('autoMemoryEnabled' in current)
@@ -111,6 +132,7 @@ export function planSessionDefaults(
111
132
 
112
133
  const keys: SettingsKey[] = [
113
134
  { label: 'permissions.deny', changed: missing.length > 0 },
135
+ { label: 'permissions.allow', changed: allowMissing.length > 0 },
114
136
  { label: 'autoMemoryEnabled', changed: memoryAbsent },
115
137
  { label: 'hooks.PreToolUse', changed: preToolUse !== undefined },
116
138
  ]
@@ -123,6 +145,7 @@ export function planSessionDefaults(
123
145
  subagentsMissing.length === DENIED_TOOLS.length &&
124
146
  mergeMissing.length === MERGE_DENIED.length,
125
147
  lacksMergeDeny: mergeMissing.length > 0,
148
+ lacksPushAllow: allowMissing.length > 0,
126
149
  }
127
150
  }
128
151
 
@@ -222,10 +245,15 @@ export async function readSessionDefaults(
222
245
  if (permissions !== undefined && !isRecord(permissions)) {
223
246
  return { kind: 'refused', reason: `${path}: permissions is not an object` }
224
247
  }
225
- if (permissions?.deny !== undefined && !Array.isArray(permissions.deny)) {
226
- return {
227
- kind: 'refused',
228
- reason: `${path}: permissions.deny is not a list`,
248
+ for (const list of ['deny', 'allow'] as const) {
249
+ if (
250
+ permissions?.[list] !== undefined &&
251
+ !Array.isArray(permissions[list])
252
+ ) {
253
+ return {
254
+ kind: 'refused',
255
+ reason: `${path}: permissions.${list} is not a list`,
256
+ }
229
257
  }
230
258
  }
231
259
 
@@ -1,6 +1,10 @@
1
1
  {
2
2
  "autoMemoryEnabled": false,
3
3
  "permissions": {
4
+ "allow": [
5
+ "Bash(git push --force-with-lease)",
6
+ "Bash(git push --force-with-lease origin HEAD)"
7
+ ],
4
8
  "deny": ["Agent", "Task", "Bash(canon pr merge:*)", "Bash(gh pr merge:*)"]
5
9
  },
6
10
  "hooks": {