@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.
- package/claude/.claude-plugin/plugin.json +1 -1
- package/claude/skills/deploy-app/REQUIREMENT.md +44 -0
- package/claude/skills/deploy-app/SKILL.md +45 -0
- package/claude/skills/{deploy-cloudflare/SKILL.md → deploy-app/references/cloudflare.md} +5 -9
- package/claude/skills/deploy-app/references/vercel.md +72 -0
- package/claude/skills/draft-figure/REQUIREMENT.md +8 -2
- package/claude/skills/draft-figure/SKILL.md +19 -4
- package/claude/skills/draft-wireframes/SKILL.md +1 -1
- package/claude/skills/teach-workspace/SKILL.md +1 -1
- package/claude/skills/teach-workspace/references/lesson-craft.md +1 -1
- package/docs/agents/architecture-record.md +12 -4
- package/docs/agents/canvas.md +13 -1
- package/docs/agents/commands.md +2 -2
- package/docs/agents/context-audit-checks.md +1 -1
- package/docs/agents/context-audit.md +4 -4
- package/docs/agents/demo.md +3 -1
- package/docs/agents/index.md +2 -2
- package/docs/agents/teach.md +2 -2
- package/docs/workflow/skill-map.md +9 -9
- package/docs/workflow/visual-design-workflow.md +8 -15
- package/governance/rules/claude/566-output.md +3 -5
- package/governance/rules/claude/575-hooks.md +1 -1
- package/package.json +1 -1
- package/src/audits/catalog.ts +46 -1
- package/src/canvas/address.ts +73 -39
- package/src/canvas/client/inspector.tsx +241 -33
- package/src/canvas/client/pages-panel.tsx +51 -0
- package/src/canvas/client/shell.css +126 -1
- package/src/canvas/client/state.ts +120 -4
- package/src/canvas/client/theme-panel.tsx +55 -0
- package/src/canvas/content.ts +14 -3
- package/src/canvas/edit.ts +360 -0
- package/src/canvas/server.ts +50 -5
- package/src/canvas/tokens.ts +82 -0
- package/src/claude/cases/misc.ts +5 -1
- package/src/commands/canvas.ts +95 -1
- package/src/commands/context.ts +85 -5
- package/src/commands/demo.ts +2 -0
- package/src/commands/teach.ts +3 -1
- package/src/context/architecture.ts +98 -18
- package/src/context/gate.ts +30 -3
- package/src/context/requirements.ts +77 -0
- package/src/demo/drive.ts +36 -7
- package/src/demo/timeline.ts +64 -0
- package/src/gate/measures.ts +69 -28
- package/src/gate/stages.ts +5 -5
- package/src/teach/workspace.ts +61 -6
- package/standards/architecture.md +13 -7
- package/standards/figures.md +1 -0
- package/standards/requirements.md +4 -0
- package/tooling/claude/reference.md +1 -1
- package/tooling/claude/seeds/.claude/hooks/path-form.sh +2 -4
- package/tooling/claude/seeds/.claude/hooks/search-reminder.sh +35 -0
- package/tooling/claude/seeds/.claude/settings.json +9 -0
- package/tooling/cloudflare/configs/.github/workflows/deploy.yml +2 -2
- package/tooling/cloudflare/reference.md +1 -1
- 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)
|
|
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
|
-
|
|
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
|
+
}
|
package/src/canvas/content.ts
CHANGED
|
@@ -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
|
-
|
|
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', `${
|
|
228
|
+
return refuse('busy', `${basename(target)} is being written, try again`)
|
|
218
229
|
}
|
|
219
230
|
sleep(LOCK_POLL_MS)
|
|
220
231
|
}
|