@erclx/canon 5.14.0 → 5.15.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.
Files changed (79) hide show
  1. package/README.md +3 -2
  2. package/claude/.claude-plugin/plugin.json +1 -1
  3. package/claude/skills/draft-screencast/REQUIREMENT.md +2 -1
  4. package/claude/skills/draft-screencast/SKILL.md +14 -9
  5. package/claude/skills/read-frames/SKILL.md +3 -2
  6. package/claude/skills/record-screencast/REQUIREMENT.md +3 -0
  7. package/claude/skills/record-screencast/SKILL.md +28 -5
  8. package/claude/skills/review-pr/SKILL.md +1 -1
  9. package/claude/skills/review-pr/references/close-out.md +1 -1
  10. package/claude/skills/review-pr/references/post.md +1 -1
  11. package/claude/skills/role-orchestrator/SKILL.md +1 -1
  12. package/claude/skills/role-orchestrator/references/orchestrator-poll.md +10 -10
  13. package/claude/skills/role-orchestrator/scripts/poll.ts +628 -0
  14. package/claude/skills/role-orchestrator/scripts/repo.ts +69 -0
  15. package/claude/skills/role-orchestrator/scripts/watch.ts +267 -0
  16. package/docs/agents/commands.md +3 -0
  17. package/docs/agents/demo.md +34 -7
  18. package/docs/agents/migrate.md +1 -1
  19. package/docs/agents/pr-reads.md +1 -1
  20. package/docs/agents/sandbox.md +28 -3
  21. package/docs/agents/state-scoped-risk.md +2 -2
  22. package/docs/target/migrations.md +1 -1
  23. package/docs/workflow/ai-workflow.md +2 -3
  24. package/docs/workflow/skill-map.md +1 -1
  25. package/governance/renames.toml +7 -5
  26. package/governance/rules/canon/630-requirements.md +1 -1
  27. package/package.json +8 -6
  28. package/scripts/core/check-color-source.sh +11 -1
  29. package/scripts/core/install-check.sh +13 -4
  30. package/scripts/lib/sandbox-dispatch.sh +1 -1
  31. package/scripts/lib/sandbox-fixtures.sh +2 -2
  32. package/scripts/lib/sandbox-path.sh +1 -1
  33. package/scripts/manage-sandbox.sh +2 -2
  34. package/src/canvas/client/app.tsx +85 -0
  35. package/src/canvas/client/main.tsx +62 -0
  36. package/src/canvas/client/pages-panel.tsx +123 -0
  37. package/src/canvas/client/shell.css +319 -0
  38. package/src/canvas/client/state.ts +186 -0
  39. package/src/canvas/client/surface.tsx +213 -0
  40. package/src/canvas/content.ts +326 -0
  41. package/src/canvas/index.html +14 -0
  42. package/src/canvas/server.ts +302 -0
  43. package/src/canvas/tokens.ts +71 -0
  44. package/src/claude/seeds.ts +0 -1
  45. package/src/cli.ts +2 -0
  46. package/src/commands/canvas.ts +325 -0
  47. package/src/commands/claude.ts +9 -14
  48. package/src/commands/demo.ts +6 -6
  49. package/src/commands/sandbox.ts +110 -2
  50. package/src/context/citations.ts +1 -1
  51. package/src/context/classify/prompts.ts +1 -1
  52. package/src/context/folders.ts +0 -1
  53. package/src/demo/compile.ts +8 -4
  54. package/src/demo/container.ts +1 -1
  55. package/src/gov/renames.ts +3 -2
  56. package/src/help.ts +1 -0
  57. package/src/record-root.ts +1 -0
  58. package/src/sandbox/census.ts +1 -1
  59. package/src/sandbox/coverage.ts +1 -1
  60. package/src/sandbox/equivalence.ts +847 -0
  61. package/src/sandbox/expect.ts +0 -1
  62. package/src/serve/static.ts +25 -11
  63. package/src/shipped/references.ts +8 -10
  64. package/src/surface-root.ts +0 -1
  65. package/src/targets/pulls.ts +2 -2
  66. package/standards/architecture.md +1 -1
  67. package/standards/context.md +1 -1
  68. package/standards/design.md +1 -1
  69. package/standards/index.md +0 -1
  70. package/standards/requirements.md +4 -6
  71. package/standards/wireframes.md +2 -2
  72. package/tooling/claude/reference.md +0 -1
  73. package/tooling/claude/seeds/canon/REQUIREMENTS.md +0 -2
  74. package/tsconfig.json +1 -1
  75. package/claude/skills/role-orchestrator/scripts/poll.sh +0 -504
  76. package/claude/skills/role-orchestrator/scripts/watch.sh +0 -181
  77. package/governance/rules/canon/645-decisions.md +0 -12
  78. package/standards/decisions.md +0 -105
  79. package/tooling/claude/seeds/canon/decisions/index.md +0 -8
@@ -0,0 +1,186 @@
1
+ import { computed, signal } from '@preact/signals'
2
+ import type { Frame, Page } from '@/canvas/content'
3
+
4
+ /**
5
+ * Everything the panels read. Type imports only from outside `client/`, since
6
+ * this runs in the browser and a Node or Bun module would not bundle there.
7
+ */
8
+
9
+ export type Theme = 'light' | 'dark'
10
+
11
+ export interface TokenSource {
12
+ readonly source: 'toolkit' | 'installed' | 'none'
13
+ readonly files?: readonly string[]
14
+ readonly notice?: string
15
+ }
16
+
17
+ export interface PagesRecord {
18
+ readonly pages: readonly Page[]
19
+ readonly tokens: TokenSource
20
+ }
21
+
22
+ export interface ChangeEvent {
23
+ readonly page: string
24
+ readonly file: string
25
+ }
26
+
27
+ export interface View {
28
+ readonly x: number
29
+ readonly y: number
30
+ readonly zoom: number
31
+ }
32
+
33
+ export const MIN_ZOOM = 0.05
34
+ export const MAX_ZOOM = 4
35
+
36
+ export const pages = signal<readonly Page[]>([])
37
+ export const tokens = signal<TokenSource | undefined>(undefined)
38
+ export const isLoaded = signal(false)
39
+ export const loadError = signal<string | undefined>(undefined)
40
+ export const selectedPage = signal<string | undefined>(undefined)
41
+
42
+ /** Undefined follows the system, which `systemTheme` reports. */
43
+ export const themeChoice = signal<Theme | undefined>(undefined)
44
+ export const systemTheme = signal<Theme>('dark')
45
+ export const theme = computed<Theme>(
46
+ () => themeChoice.value ?? systemTheme.value,
47
+ )
48
+
49
+ /** A frame's own theme where the operator switched it, else the chrome's. */
50
+ export const frameThemes = signal<ReadonlyMap<string, Theme>>(new Map())
51
+
52
+ /** Bumped per frame on a change, so only that frame's document reloads. */
53
+ export const frameVersions = signal<ReadonlyMap<string, number>>(new Map())
54
+
55
+ export const view = signal<View>({ x: 0, y: 0, zoom: 0.5 })
56
+
57
+ export const currentPage = computed<Page | undefined>(() => {
58
+ const all = pages.value
59
+ return all.find((page) => page.name === selectedPage.value) ?? all[0]
60
+ })
61
+
62
+ export function frameKey(page: string, frame: Pick<Frame, 'file'>): string {
63
+ return `${page}/${frame.file}`
64
+ }
65
+
66
+ export function frameTheme(key: string): Theme {
67
+ return frameThemes.value.get(key) ?? theme.value
68
+ }
69
+
70
+ export function toggleTheme(): void {
71
+ themeChoice.value = theme.value === 'dark' ? 'light' : 'dark'
72
+ }
73
+
74
+ export function toggleFrameTheme(key: string): void {
75
+ const next = new Map(frameThemes.value)
76
+ next.set(key, frameTheme(key) === 'dark' ? 'light' : 'dark')
77
+ frameThemes.value = next
78
+ }
79
+
80
+ export function applyRecord(record: PagesRecord): void {
81
+ pages.value = record.pages
82
+ tokens.value = record.tokens
83
+ loadError.value = undefined
84
+ isLoaded.value = true
85
+ }
86
+
87
+ export async function loadPages(
88
+ fetchImpl: typeof fetch = fetch,
89
+ ): Promise<void> {
90
+ try {
91
+ const response = await fetchImpl('/api/pages')
92
+ if (!response.ok) throw new Error(`status ${response.status}`)
93
+ applyRecord((await response.json()) as PagesRecord)
94
+ } catch (error) {
95
+ loadError.value = `Could not read the canvas (${error instanceof Error ? error.message : 'unknown'}). Check canon canvas serve is still running.`
96
+ isLoaded.value = true
97
+ }
98
+ }
99
+
100
+ /**
101
+ * Reloads the one frame whose file changed and rereads the page list, which
102
+ * picks up an added frame or a moved box. The view is separate state, so
103
+ * neither resets pan or zoom.
104
+ */
105
+ export async function applyChange(
106
+ change: ChangeEvent,
107
+ fetchImpl: typeof fetch = fetch,
108
+ ): Promise<void> {
109
+ if (change.file.endsWith('.html')) {
110
+ const key = `${change.page}/${change.file}`
111
+ const next = new Map(frameVersions.value)
112
+ next.set(key, (next.get(key) ?? 0) + 1)
113
+ frameVersions.value = next
114
+ }
115
+ await loadPages(fetchImpl)
116
+ }
117
+
118
+ export function clampZoom(zoom: number): number {
119
+ return Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, zoom))
120
+ }
121
+
122
+ /** Zooms about a point in surface coordinates, keeping that point still. */
123
+ export function zoomAt(factor: number, originX: number, originY: number): void {
124
+ const current = view.value
125
+ const zoom = clampZoom(current.zoom * factor)
126
+ const scale = zoom / current.zoom
127
+ view.value = {
128
+ zoom,
129
+ x: originX - (originX - current.x) * scale,
130
+ y: originY - (originY - current.y) * scale,
131
+ }
132
+ }
133
+
134
+ export function panBy(dx: number, dy: number): void {
135
+ const current = view.value
136
+ view.value = { ...current, x: current.x + dx, y: current.y + dy }
137
+ }
138
+
139
+ /** Fits every frame on the current page into a viewport of the given size. */
140
+ export function fitView(width: number, height: number, padding = 48): void {
141
+ const frames = currentPage.value?.frames ?? []
142
+ if (frames.length === 0 || width <= 0 || height <= 0) {
143
+ view.value = { x: padding, y: padding, zoom: 0.5 }
144
+ return
145
+ }
146
+ const left = Math.min(...frames.map((frame) => frame.x))
147
+ const top = Math.min(...frames.map((frame) => frame.y))
148
+ const right = Math.max(...frames.map((frame) => frame.x + frame.width))
149
+ const bottom = Math.max(...frames.map((frame) => frame.y + frame.height))
150
+ const zoom = clampZoom(
151
+ Math.min(
152
+ (width - padding * 2) / (right - left),
153
+ (height - padding * 2) / (bottom - top),
154
+ 1,
155
+ ),
156
+ )
157
+ view.value = {
158
+ zoom,
159
+ x: padding - left * zoom,
160
+ y: padding - top * zoom,
161
+ }
162
+ }
163
+
164
+ /** Centers one frame in a viewport of the given size at the current zoom. */
165
+ export function focusFrame(frame: Frame, width: number, height: number): void {
166
+ const { zoom } = view.value
167
+ view.value = {
168
+ zoom,
169
+ x: width / 2 - (frame.x + frame.width / 2) * zoom,
170
+ y: height / 2 - (frame.y + frame.height / 2) * zoom,
171
+ }
172
+ }
173
+
174
+ /** Puts every signal back to its first value. Tests call it between cases. */
175
+ export function resetState(): void {
176
+ pages.value = []
177
+ tokens.value = undefined
178
+ isLoaded.value = false
179
+ loadError.value = undefined
180
+ selectedPage.value = undefined
181
+ themeChoice.value = undefined
182
+ systemTheme.value = 'dark'
183
+ frameThemes.value = new Map()
184
+ frameVersions.value = new Map()
185
+ view.value = { x: 0, y: 0, zoom: 0.5 }
186
+ }
@@ -0,0 +1,213 @@
1
+ /** @jsxImportSource preact */
2
+ import type { JSX, RefObject } from 'preact'
3
+ import { useEffect, useRef } from 'preact/hooks'
4
+ import type { Frame } from '@/canvas/content'
5
+ import {
6
+ currentPage,
7
+ fitView,
8
+ frameKey,
9
+ frameTheme,
10
+ frameVersions,
11
+ panBy,
12
+ type Theme,
13
+ toggleFrameTheme,
14
+ view,
15
+ zoomAt,
16
+ } from '@/canvas/client/state'
17
+
18
+ /** One wheel notch or one button press. */
19
+ const ZOOM_STEP = 1.2
20
+
21
+ export interface SurfaceProps {
22
+ readonly viewportRef: RefObject<HTMLDivElement | null>
23
+ }
24
+
25
+ export function frameSrc(page: string, frame: Frame, version: number): string {
26
+ const path = `/frames/${encodeURIComponent(page)}/${encodeURIComponent(frame.file)}`
27
+ return version === 0 ? path : `${path}?v=${version}`
28
+ }
29
+
30
+ /**
31
+ * Sets the frame document's theme the way the token stylesheet reads it. The
32
+ * frame shares the shell's origin, so its document is reachable once loaded.
33
+ */
34
+ function applyFrameTheme(iframe: HTMLIFrameElement | null, value: Theme): void {
35
+ const root = iframe?.contentDocument?.documentElement
36
+ if (!root) return
37
+ root.dataset.theme = value
38
+ root.style.colorScheme = value
39
+ }
40
+
41
+ function FrameView({
42
+ page,
43
+ frame,
44
+ }: {
45
+ readonly page: string
46
+ readonly frame: Frame
47
+ }): JSX.Element {
48
+ const key = frameKey(page, frame)
49
+ const version = frameVersions.value.get(key) ?? 0
50
+ const value = frameTheme(key)
51
+ const iframe = useRef<HTMLIFrameElement>(null)
52
+
53
+ useEffect(() => {
54
+ applyFrameTheme(iframe.current, value)
55
+ }, [value])
56
+
57
+ const next = value === 'dark' ? 'light' : 'dark'
58
+ return (
59
+ <figure
60
+ class="frame"
61
+ data-frame={frame.name}
62
+ style={{
63
+ left: `${frame.x}px`,
64
+ top: `${frame.y}px`,
65
+ width: `${frame.width}px`,
66
+ }}
67
+ >
68
+ <figcaption class="frame-label">
69
+ <span class="frame-name" title={frame.name}>
70
+ {frame.name}
71
+ </span>
72
+ <span class="frame-size">
73
+ {frame.width} × {frame.height}
74
+ </span>
75
+ <button
76
+ type="button"
77
+ class="frame-theme"
78
+ aria-label={`Show ${frame.name} in ${next} theme`}
79
+ title={`Show in ${next} theme`}
80
+ onClick={() => toggleFrameTheme(key)}
81
+ >
82
+ {value === 'dark' ? 'Dark' : 'Light'}
83
+ </button>
84
+ </figcaption>
85
+ <iframe
86
+ ref={iframe}
87
+ title={frame.name}
88
+ src={frameSrc(page, frame, version)}
89
+ width={frame.width}
90
+ height={frame.height}
91
+ style={{ height: `${frame.height}px` }}
92
+ onLoad={() => applyFrameTheme(iframe.current, frameTheme(key))}
93
+ />
94
+ </figure>
95
+ )
96
+ }
97
+
98
+ function viewportSize(viewport: RefObject<HTMLDivElement | null>): {
99
+ width: number
100
+ height: number
101
+ } {
102
+ const rect = viewport.current?.getBoundingClientRect()
103
+ return { width: rect?.width ?? 0, height: rect?.height ?? 0 }
104
+ }
105
+
106
+ function Toolbar({ viewportRef }: SurfaceProps): JSX.Element {
107
+ const zoomCentered = (factor: number) => {
108
+ const { width, height } = viewportSize(viewportRef)
109
+ zoomAt(factor, width / 2, height / 2)
110
+ }
111
+ return (
112
+ <div class="toolbar" role="toolbar" aria-label="Zoom">
113
+ <button
114
+ type="button"
115
+ class="icon-button"
116
+ aria-label="Zoom out"
117
+ onClick={() => zoomCentered(1 / ZOOM_STEP)}
118
+ >
119
+ −
120
+ </button>
121
+ <output class="zoom" aria-live="polite">
122
+ {Math.round(view.value.zoom * 100)}%
123
+ </output>
124
+ <button
125
+ type="button"
126
+ class="icon-button"
127
+ aria-label="Zoom in"
128
+ onClick={() => zoomCentered(ZOOM_STEP)}
129
+ >
130
+ +
131
+ </button>
132
+ <button
133
+ type="button"
134
+ class="text-button"
135
+ onClick={() => {
136
+ const { width, height } = viewportSize(viewportRef)
137
+ fitView(width, height)
138
+ }}
139
+ >
140
+ Fit
141
+ </button>
142
+ </div>
143
+ )
144
+ }
145
+
146
+ export function Surface({ viewportRef }: SurfaceProps): JSX.Element {
147
+ const page = currentPage.value
148
+ const drag = useRef<{ x: number; y: number } | undefined>(undefined)
149
+
150
+ /* Fits a page once when it opens, never on a reload of the same page. */
151
+ useEffect(() => {
152
+ const { width, height } = viewportSize(viewportRef)
153
+ fitView(width, height)
154
+ }, [page?.name])
155
+
156
+ const handleWheel = (event: WheelEvent) => {
157
+ event.preventDefault()
158
+ if (event.ctrlKey || event.metaKey) {
159
+ const rect = viewportRef.current?.getBoundingClientRect()
160
+ const factor = event.deltaY < 0 ? ZOOM_STEP : 1 / ZOOM_STEP
161
+ zoomAt(
162
+ factor,
163
+ event.clientX - (rect?.left ?? 0),
164
+ event.clientY - (rect?.top ?? 0),
165
+ )
166
+ return
167
+ }
168
+ panBy(-event.deltaX, -event.deltaY)
169
+ }
170
+
171
+ const handlePointerDown = (event: PointerEvent) => {
172
+ const target = event.target as HTMLElement | null
173
+ if (target?.closest('.frame, .toolbar')) return
174
+ drag.current = { x: event.clientX, y: event.clientY }
175
+ viewportRef.current?.setPointerCapture?.(event.pointerId)
176
+ }
177
+
178
+ const handlePointerMove = (event: PointerEvent) => {
179
+ const start = drag.current
180
+ if (!start) return
181
+ panBy(event.clientX - start.x, event.clientY - start.y)
182
+ drag.current = { x: event.clientX, y: event.clientY }
183
+ }
184
+
185
+ const handlePointerUp = () => {
186
+ drag.current = undefined
187
+ }
188
+
189
+ const { x, y, zoom } = view.value
190
+ return (
191
+ <main class="surface" aria-label="Canvas">
192
+ <div
193
+ ref={viewportRef}
194
+ class="viewport"
195
+ onWheel={handleWheel}
196
+ onPointerDown={handlePointerDown}
197
+ onPointerMove={handlePointerMove}
198
+ onPointerUp={handlePointerUp}
199
+ onPointerCancel={handlePointerUp}
200
+ >
201
+ <div
202
+ class="plane"
203
+ style={{ transform: `translate(${x}px, ${y}px) scale(${zoom})` }}
204
+ >
205
+ {page?.frames.map((frame) => (
206
+ <FrameView key={frame.name} page={page.name} frame={frame} />
207
+ ))}
208
+ </div>
209
+ </div>
210
+ <Toolbar viewportRef={viewportRef} />
211
+ </main>
212
+ )
213
+ }