@erclx/canon 5.16.0 → 5.18.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 (57) hide show
  1. package/claude/.claude-plugin/plugin.json +1 -1
  2. package/claude/skills/deploy-app/REQUIREMENT.md +44 -0
  3. package/claude/skills/deploy-app/SKILL.md +45 -0
  4. package/claude/skills/{deploy-cloudflare/SKILL.md → deploy-app/references/cloudflare.md} +5 -9
  5. package/claude/skills/deploy-app/references/vercel.md +72 -0
  6. package/claude/skills/draft-figure/REQUIREMENT.md +8 -2
  7. package/claude/skills/draft-figure/SKILL.md +19 -4
  8. package/claude/skills/draft-wireframes/SKILL.md +1 -1
  9. package/claude/skills/teach-workspace/SKILL.md +1 -1
  10. package/claude/skills/teach-workspace/references/lesson-craft.md +1 -1
  11. package/docs/agents/architecture-record.md +12 -4
  12. package/docs/agents/canvas.md +13 -1
  13. package/docs/agents/commands.md +2 -2
  14. package/docs/agents/context-audit-checks.md +1 -1
  15. package/docs/agents/context-audit.md +4 -4
  16. package/docs/agents/demo.md +3 -1
  17. package/docs/agents/index.md +2 -2
  18. package/docs/agents/teach.md +2 -2
  19. package/docs/workflow/skill-map.md +9 -9
  20. package/docs/workflow/visual-design-workflow.md +8 -15
  21. package/governance/rules/claude/566-output.md +3 -5
  22. package/governance/rules/claude/575-hooks.md +1 -1
  23. package/package.json +1 -1
  24. package/src/audits/catalog.ts +46 -1
  25. package/src/canvas/address.ts +73 -39
  26. package/src/canvas/client/inspector.tsx +241 -33
  27. package/src/canvas/client/pages-panel.tsx +51 -0
  28. package/src/canvas/client/shell.css +126 -1
  29. package/src/canvas/client/state.ts +120 -4
  30. package/src/canvas/client/theme-panel.tsx +55 -0
  31. package/src/canvas/content.ts +14 -3
  32. package/src/canvas/edit.ts +360 -0
  33. package/src/canvas/server.ts +50 -5
  34. package/src/canvas/tokens.ts +82 -0
  35. package/src/claude/cases/misc.ts +5 -1
  36. package/src/commands/canvas.ts +95 -1
  37. package/src/commands/context.ts +85 -5
  38. package/src/commands/demo.ts +2 -0
  39. package/src/commands/teach.ts +3 -1
  40. package/src/context/architecture.ts +98 -18
  41. package/src/context/gate.ts +30 -3
  42. package/src/context/requirements.ts +77 -0
  43. package/src/demo/drive.ts +36 -7
  44. package/src/demo/timeline.ts +64 -0
  45. package/src/gate/measures.ts +69 -28
  46. package/src/gate/stages.ts +5 -5
  47. package/src/teach/workspace.ts +61 -6
  48. package/standards/architecture.md +13 -7
  49. package/standards/figures.md +1 -0
  50. package/standards/requirements.md +4 -0
  51. package/tooling/claude/reference.md +1 -1
  52. package/tooling/claude/seeds/.claude/hooks/path-form.sh +2 -4
  53. package/tooling/claude/seeds/.claude/hooks/search-reminder.sh +35 -0
  54. package/tooling/claude/seeds/.claude/settings.json +9 -0
  55. package/tooling/cloudflare/configs/.github/workflows/deploy.yml +2 -2
  56. package/tooling/cloudflare/reference.md +1 -1
  57. package/claude/skills/deploy-cloudflare/REQUIREMENT.md +0 -37
@@ -1,12 +1,15 @@
1
1
  /** @jsxImportSource preact */
2
2
  import type { JSX } from 'preact'
3
3
  import { Layers } from '@/canvas/client/layers'
4
+ import { ThemePanel } from '@/canvas/client/theme-panel'
4
5
  import type { Frame } from '@/canvas/content'
5
6
  import {
6
7
  currentPage,
7
8
  expandedFrames,
8
9
  frameKey,
9
10
  isLoaded,
11
+ type LeftTab,
12
+ leftTab,
10
13
  loadError,
11
14
  pages,
12
15
  selectedFrame,
@@ -127,6 +130,48 @@ function FrameList({ onFocusFrame }: PagesPanelProps): JSX.Element | null {
127
130
  )
128
131
  }
129
132
 
133
+ const TABS: readonly { readonly id: LeftTab; readonly label: string }[] = [
134
+ { id: 'pages', label: 'Pages' },
135
+ { id: 'theme', label: 'Theme' },
136
+ ]
137
+
138
+ /** Two tabs, so the arrow keys move between them and wrap. */
139
+ function Tabs(): JSX.Element {
140
+ const select = (at: number) => {
141
+ const tab = TABS[(at + TABS.length) % TABS.length]
142
+ if (!tab) return
143
+ leftTab.value = tab.id
144
+ document.getElementById(`tab-${tab.id}`)?.focus()
145
+ }
146
+ return (
147
+ <div class="tabs" role="tablist" aria-label="Left panel">
148
+ {TABS.map((tab, at) => {
149
+ const isSelected = leftTab.value === tab.id
150
+ return (
151
+ <button
152
+ key={tab.id}
153
+ id={`tab-${tab.id}`}
154
+ type="button"
155
+ role="tab"
156
+ class="tab"
157
+ aria-selected={isSelected}
158
+ tabIndex={isSelected ? 0 : -1}
159
+ onClick={() => {
160
+ leftTab.value = tab.id
161
+ }}
162
+ onKeyDown={(event) => {
163
+ if (event.key === 'ArrowRight') select(at + 1)
164
+ if (event.key === 'ArrowLeft') select(at - 1)
165
+ }}
166
+ >
167
+ {tab.label}
168
+ </button>
169
+ )
170
+ })}
171
+ </div>
172
+ )
173
+ }
174
+
130
175
  export function PagesPanel(props: PagesPanelProps): JSX.Element {
131
176
  return (
132
177
  <nav class="panel panel-left" aria-label="Pages and frames">
@@ -140,8 +185,14 @@ export function PagesPanel(props: PagesPanelProps): JSX.Element {
140
185
  </p>
141
186
  ) : !isLoaded.value ? (
142
187
  <p class="empty">Loading</p>
188
+ ) : leftTab.value === 'theme' ? (
189
+ <>
190
+ <Tabs />
191
+ <ThemePanel />
192
+ </>
143
193
  ) : (
144
194
  <>
195
+ <Tabs />
145
196
  <h2 class="section-label">Pages</h2>
146
197
  <PageList />
147
198
  {currentPage.value ? (
@@ -39,7 +39,7 @@ code {
39
39
 
40
40
  .shell {
41
41
  display: grid;
42
- grid-template-columns: 15rem minmax(0, 1fr) 13rem;
42
+ grid-template-columns: 15rem minmax(0, 1fr) 16rem;
43
43
  height: 100%;
44
44
  }
45
45
 
@@ -447,3 +447,128 @@ code {
447
447
  text-overflow: ellipsis;
448
448
  white-space: nowrap;
449
449
  }
450
+
451
+ .styles .box-row {
452
+ align-items: center;
453
+ }
454
+
455
+ .styles dt {
456
+ flex: none;
457
+ width: 4.5rem;
458
+ }
459
+
460
+ .styles dd {
461
+ flex: 1;
462
+ }
463
+
464
+ .field-cell {
465
+ display: flex;
466
+ gap: var(--space-xs);
467
+ }
468
+
469
+ .field,
470
+ .token-picker {
471
+ min-width: 0;
472
+ min-height: 24px;
473
+ padding: 2px var(--space-xs);
474
+ border: 1px solid var(--color-border);
475
+ border-radius: var(--radius-action);
476
+ background: var(--color-surface);
477
+ color: var(--color-text);
478
+ font: inherit;
479
+ font-variant-numeric: tabular-nums;
480
+ }
481
+
482
+ .field {
483
+ flex: 1;
484
+ width: 100%;
485
+ }
486
+
487
+ .token-picker {
488
+ flex: none;
489
+ width: 4.5rem;
490
+ cursor: pointer;
491
+ }
492
+
493
+ .field:hover,
494
+ .token-picker:hover {
495
+ border-color: var(--color-muted);
496
+ }
497
+
498
+ .field:disabled,
499
+ .token-picker:disabled {
500
+ opacity: 0.5;
501
+ cursor: not-allowed;
502
+ }
503
+
504
+ .saved {
505
+ margin-left: var(--space-sm);
506
+ color: var(--color-success);
507
+ font-weight: 400;
508
+ }
509
+
510
+ .tabs {
511
+ display: flex;
512
+ gap: 2px;
513
+ margin-top: var(--space-sm);
514
+ padding: 2px;
515
+ border-radius: var(--radius-action);
516
+ background: var(--color-surface);
517
+ }
518
+
519
+ .tab {
520
+ flex: 1;
521
+ min-height: 24px;
522
+ border: 0;
523
+ border-radius: var(--radius-action);
524
+ background: transparent;
525
+ color: var(--color-text-secondary);
526
+ font: inherit;
527
+ cursor: pointer;
528
+ }
529
+
530
+ .tab:hover {
531
+ color: var(--color-text);
532
+ }
533
+
534
+ .tab[aria-selected='true'] {
535
+ background: var(--color-chrome);
536
+ color: var(--color-text);
537
+ }
538
+
539
+ .tokens {
540
+ list-style: none;
541
+ margin: 0;
542
+ padding: 0;
543
+ display: flex;
544
+ flex-direction: column;
545
+ gap: 2px;
546
+ }
547
+
548
+ .token {
549
+ display: flex;
550
+ align-items: center;
551
+ gap: var(--space-sm);
552
+ min-height: 24px;
553
+ padding: 0 var(--space-sm);
554
+ }
555
+
556
+ .token .row-label {
557
+ flex: 1;
558
+ min-width: 0;
559
+ }
560
+
561
+ .token-value {
562
+ max-width: 45%;
563
+ overflow: hidden;
564
+ text-overflow: ellipsis;
565
+ white-space: nowrap;
566
+ }
567
+
568
+ .swatch {
569
+ flex: none;
570
+ width: 14px;
571
+ height: 14px;
572
+ border: 1px solid var(--color-border);
573
+ border-radius: 4px;
574
+ }
@@ -1,6 +1,7 @@
1
1
  import { computed, signal } from '@preact/signals'
2
2
  import type { ElementAddress } from '@/canvas/address'
3
3
  import type { Frame, Page } from '@/canvas/content'
4
+ import type { TokenGroup } from '@/canvas/tokens'
4
5
 
5
6
  /**
6
7
  * Everything the panels read. Type imports only from outside `client/`, since
@@ -15,6 +16,17 @@ export interface TokenSource {
15
16
  readonly source: 'toolkit' | 'installed' | 'none'
16
17
  readonly files?: readonly string[]
17
18
  readonly notice?: string
19
+ /** What the stylesheet defines, which the token picker and Theme tab list. */
20
+ readonly groups?: readonly TokenGroup[]
21
+ }
22
+
23
+ export type LeftTab = 'pages' | 'theme'
24
+
25
+ /** One property change to the selected element, as the inspector posts it. */
26
+ export interface PendingEdit {
27
+ readonly key: string
28
+ readonly index: number
29
+ readonly property: string
18
30
  }
19
31
 
20
32
  export interface FrameRef {
@@ -98,6 +110,26 @@ export const draggingFrame = signal<string | undefined>(undefined)
98
110
  /** Why the last write to the canvas failed, until the next one succeeds. */
99
111
  export const writeError = signal<string | undefined>(undefined)
100
112
 
113
+ export const leftTab = signal<LeftTab>('pages')
114
+
115
+ /** The edit in flight, which holds its field until the server answers. */
116
+ export const pendingEdit = signal<PendingEdit | undefined>(undefined)
117
+
118
+ /** The edit that last landed, for a moment after it does. */
119
+ export const savedEdit = signal<PendingEdit | undefined>(undefined)
120
+
121
+ const SAVED_MS = 2500
122
+
123
+ /** Why the last edit wrote nothing, until the next one lands. */
124
+ export const editRefusal = signal<string | undefined>(undefined)
125
+
126
+ /**
127
+ * The hash an edit answered by frame key, until that frame reloads. A second
128
+ * edit sent before the reload is made from the file the first one wrote, not
129
+ * from the document still on screen.
130
+ */
131
+ export const editedHashes = signal<ReadonlyMap<string, string>>(new Map())
132
+
101
133
  export const currentPage = computed<Page | undefined>(() => {
102
134
  const all = pages.value
103
135
  return all.find((page) => page.name === selectedPage.value) ?? all[0]
@@ -142,6 +174,17 @@ export function registerFrameDocument(key: string, doc: Document): void {
142
174
  const next = new Map(frameDocuments.value)
143
175
  next.set(key, doc)
144
176
  frameDocuments.value = next
177
+ if (editedHashes.value.has(key)) {
178
+ const hashes = new Map(editedHashes.value)
179
+ hashes.delete(key)
180
+ editedHashes.value = hashes
181
+ }
182
+ }
183
+
184
+ function reloadFrame(key: string): void {
185
+ const next = new Map(frameVersions.value)
186
+ next.set(key, (next.get(key) ?? 0) + 1)
187
+ frameVersions.value = next
145
188
  }
146
189
 
147
190
  export function toggleLayers(key: string): void {
@@ -181,10 +224,7 @@ export async function applyChange(
181
224
  fetchImpl: typeof fetch = fetch,
182
225
  ): Promise<void> {
183
226
  if (change.file.endsWith('.html')) {
184
- const key = `${change.page}/${change.file}`
185
- const next = new Map(frameVersions.value)
186
- next.set(key, (next.get(key) ?? 0) + 1)
187
- frameVersions.value = next
227
+ reloadFrame(`${change.page}/${change.file}`)
188
228
  }
189
229
  await loadPages(fetchImpl)
190
230
  }
@@ -267,6 +307,77 @@ export async function selectElement(
267
307
  await write('/api/selection', { ...ref, element: address }, fetchImpl)
268
308
  }
269
309
 
310
+ /** What a refused edit tells the operator, by the server's reason. */
311
+ const EDIT_NOTICES: Readonly<Record<string, string | undefined>> = {
312
+ 'stale-address':
313
+ 'The frame changed before this edit arrived, so nothing was saved. It reloads with the file as it stands now. Make the edit again there.',
314
+ 'address-mismatch':
315
+ 'Could not save, since the browser and the file count the elements of this frame differently.',
316
+ 'not-text-only':
317
+ 'Could not save the text, since this element holds other elements.',
318
+ }
319
+
320
+ /**
321
+ * Posts one property change to the element the address names. A refusal
322
+ * says why and reloads the frame, so a pending value never sits over a file
323
+ * that moved under it.
324
+ */
325
+ export async function editElement(
326
+ ref: FrameRef,
327
+ key: string,
328
+ address: ElementAddress,
329
+ property: string,
330
+ value: string,
331
+ fetchImpl: typeof fetch = fetch,
332
+ ): Promise<void> {
333
+ const hash = editedHashes.value.get(key) ?? address.hash
334
+ pendingEdit.value = { key, index: address.index, property }
335
+ try {
336
+ const response = await fetchImpl('/api/frames/edit', {
337
+ method: 'POST',
338
+ headers: { 'content-type': 'application/json' },
339
+ body: JSON.stringify({
340
+ ...ref,
341
+ element: { ...address, hash },
342
+ property,
343
+ value,
344
+ }),
345
+ })
346
+ const body: unknown = await response.json().catch(() => undefined)
347
+ const record =
348
+ typeof body === 'object' && body !== null
349
+ ? (body as Record<string, unknown>)
350
+ : {}
351
+ if (!response.ok) {
352
+ const reason = typeof record.reason === 'string' ? record.reason : ''
353
+ const detail = typeof record.detail === 'string' ? record.detail : ''
354
+ editRefusal.value =
355
+ EDIT_NOTICES[reason] ??
356
+ (reason === 'invalid-edit'
357
+ ? `Could not save that value (${detail}).`
358
+ : `Could not save (status ${response.status}). Check canon canvas serve is still running.`)
359
+ reloadFrame(key)
360
+ await loadPages(fetchImpl)
361
+ return
362
+ }
363
+ editRefusal.value = undefined
364
+ const saved = pendingEdit.value
365
+ savedEdit.value = saved
366
+ setTimeout(() => {
367
+ if (savedEdit.value === saved) savedEdit.value = undefined
368
+ }, SAVED_MS)
369
+ if (typeof record.hash === 'string') {
370
+ const hashes = new Map(editedHashes.value)
371
+ hashes.set(key, record.hash)
372
+ editedHashes.value = hashes
373
+ }
374
+ } catch (error) {
375
+ editRefusal.value = `Could not save (${error instanceof Error ? error.message : 'unknown'}). Check canon canvas serve is still running.`
376
+ } finally {
377
+ pendingEdit.value = undefined
378
+ }
379
+ }
380
+
270
381
  /**
271
382
  * Moves a frame on the surface without writing, which a drag calls on every
272
383
  * pointer move. The next reread replaces it with what the layout holds.
@@ -369,4 +480,9 @@ export function resetState(): void {
369
480
  expandedFrames.value = new Set()
370
481
  draggingFrame.value = undefined
371
482
  writeError.value = undefined
483
+ leftTab.value = 'pages'
484
+ pendingEdit.value = undefined
485
+ savedEdit.value = undefined
486
+ editRefusal.value = undefined
487
+ editedHashes.value = new Map()
372
488
  }
@@ -0,0 +1,55 @@
1
+ /** @jsxImportSource preact */
2
+ import type { JSX } from 'preact'
3
+ import { tokens } from '@/canvas/client/state'
4
+ import type { Token, TokenKind } from '@/canvas/tokens'
5
+
6
+ const KIND_LABEL: Record<TokenKind, string> = {
7
+ color: 'Color',
8
+ spacing: 'Spacing',
9
+ radius: 'Radius',
10
+ 'font-family': 'Font family',
11
+ 'font-size': 'Font size',
12
+ other: 'Other',
13
+ }
14
+
15
+ /**
16
+ * A swatch paints the value in the chrome's document, where the frame's own
17
+ * custom properties are not defined, so a value still naming one shows none.
18
+ */
19
+ function Swatch({ token }: { readonly token: Token }): JSX.Element | null {
20
+ const paint = token.resolved ?? token.value
21
+ if (paint.includes('var(')) return null
22
+ return (
23
+ <span class="swatch" aria-hidden="true" style={{ background: paint }} />
24
+ )
25
+ }
26
+
27
+ /** Read-only: the frames' tokens are edited in their stylesheet, not here. */
28
+ export function ThemePanel(): JSX.Element {
29
+ const groups = tokens.value?.groups ?? []
30
+ return (
31
+ <section aria-label="Theme">
32
+ {groups.length === 0 ? (
33
+ <p class="empty">
34
+ No tokens resolve for this project, so there is nothing to list.{' '}
35
+ {tokens.value?.notice ?? ''}
36
+ </p>
37
+ ) : (
38
+ groups.map((group) => (
39
+ <div key={group.kind}>
40
+ <h2 class="section-label">{KIND_LABEL[group.kind]}</h2>
41
+ <ul class="tokens" aria-label={`${KIND_LABEL[group.kind]} tokens`}>
42
+ {group.tokens.map((token) => (
43
+ <li key={token.name} class="token" title={token.value}>
44
+ {group.kind === 'color' ? <Swatch token={token} /> : null}
45
+ <span class="row-label">{token.name}</span>
46
+ <span class="row-meta token-value">{token.value}</span>
47
+ </li>
48
+ ))}
49
+ </ul>
50
+ </div>
51
+ ))
52
+ )}
53
+ </section>
54
+ )
55
+ }
@@ -12,7 +12,7 @@ import {
12
12
  unlinkSync,
13
13
  writeFileSync,
14
14
  } from 'node:fs'
15
- import { join } from 'node:path'
15
+ import { basename, join } from 'node:path'
16
16
  import {
17
17
  type ElementAddress,
18
18
  resolveAddress,
@@ -200,7 +200,18 @@ function sleep(ms: number): void {
200
200
  * found it.
201
201
  */
202
202
  function withLayoutLock(dir: string, run: () => FrameOutcome): FrameOutcome {
203
- const lock = join(dir, `${LAYOUT_FILE}${LOCK_SUFFIX}`)
203
+ return withFileLock(join(dir, LAYOUT_FILE), run)
204
+ }
205
+
206
+ /**
207
+ * The same lock over any one file under the canvas folder, which a frame
208
+ * writer takes so its hash check and its write cannot straddle another's.
209
+ */
210
+ export function withFileLock<T>(
211
+ target: string,
212
+ run: () => T,
213
+ ): T | ContentRefused {
214
+ const lock = `${target}${LOCK_SUFFIX}`
204
215
  const deadline = Date.now() + LOCK_WAIT_MS
205
216
  for (;;) {
206
217
  try {
@@ -214,7 +225,7 @@ function withLayoutLock(dir: string, run: () => FrameOutcome): FrameOutcome {
214
225
  continue
215
226
  }
216
227
  if (Date.now() >= deadline) {
217
- return refuse('busy', `${LAYOUT_FILE} is being written, try again`)
228
+ return refuse('busy', `${basename(target)} is being written, try again`)
218
229
  }
219
230
  sleep(LOCK_POLL_MS)
220
231
  }