@1agh/maude 1.2.0 → 1.3.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/README.md +3 -1
- package/apps/studio/acp/index.ts +43 -7
- package/apps/studio/annotations-layer.tsx +191 -116
- package/apps/studio/annotations-model.ts +39 -0
- package/apps/studio/annotations-sync.ts +50 -0
- package/apps/studio/api.ts +689 -36
- package/apps/studio/bin/annotate.mjs +3 -1
- package/apps/studio/bin/server-up.sh +17 -2
- package/apps/studio/canvas-edit.ts +391 -7
- package/apps/studio/canvas-lib.tsx +82 -18
- package/apps/studio/canvas-notice-message.ts +16 -0
- package/apps/studio/canvas-notifications.tsx +19 -0
- package/apps/studio/canvas-shell.tsx +57 -6
- package/apps/studio/client/app.jsx +686 -241
- package/apps/studio/client/apply-edit-request.ts +55 -0
- package/apps/studio/client/export-center.jsx +111 -61
- package/apps/studio/client/github.js +38 -1
- package/apps/studio/client/index-loader.ts +76 -0
- package/apps/studio/client/panels/CloudBar.jsx +52 -6
- package/apps/studio/client/panels/GitPanel.jsx +103 -0
- package/apps/studio/client/panels/OnboardingWizard.jsx +36 -7
- package/apps/studio/client/panels/RepoBranchSwitcher.jsx +92 -27
- package/apps/studio/client/panels/SourceConflictPanel.jsx +166 -0
- package/apps/studio/client/panels/SyncPanel.jsx +113 -0
- package/apps/studio/client/panels/TeamProjects.jsx +376 -0
- package/apps/studio/client/panels/file-deep-link-dialog.jsx +54 -0
- package/apps/studio/client/photo-knobs.jsx +3 -0
- package/apps/studio/client/share-dialog.jsx +91 -0
- package/apps/studio/client/share-link.js +94 -0
- package/apps/studio/client/styles/3-shell-maude.css +35 -18
- package/apps/studio/client/styles/4-components.css +18 -1
- package/apps/studio/client/tree-row-menu.jsx +48 -3
- package/apps/studio/client/whats-new.jsx +26 -32
- package/apps/studio/cloud/endpoints.ts +132 -0
- package/apps/studio/collab/index.ts +4 -0
- package/apps/studio/collab/persistence.ts +5 -1
- package/apps/studio/collab/registry.ts +9 -3
- package/apps/studio/collab/room.ts +13 -2
- package/apps/studio/context.ts +59 -0
- package/apps/studio/dist/client.bundle.js +1586 -1554
- package/apps/studio/dist/runtime/.min-sizes.json +1 -0
- package/apps/studio/dist/runtime/sonner.js +1 -0
- package/apps/studio/dist/styles.css +1 -1
- package/apps/studio/http.ts +289 -18
- package/apps/studio/inspect.ts +19 -0
- package/apps/studio/managed-projects.ts +150 -0
- package/apps/studio/notifications.tsx +292 -0
- package/apps/studio/runtime-bundle.ts +2 -0
- package/apps/studio/server.ts +59 -3
- package/apps/studio/sync/accepted-cold-start.ts +225 -0
- package/apps/studio/sync/accepted-link.ts +320 -0
- package/apps/studio/sync/action-stage.ts +343 -0
- package/apps/studio/sync/agent.ts +12 -60
- package/apps/studio/sync/cell-file-events.ts +3 -0
- package/apps/studio/sync/codec.ts +73 -6
- package/apps/studio/sync/ctl-provider.ts +15 -2
- package/apps/studio/sync/document-discovery.ts +81 -0
- package/apps/studio/sync/file-membership.ts +10 -2
- package/apps/studio/sync/file-plane.ts +309 -26
- package/apps/studio/sync/index.ts +1078 -19
- package/apps/studio/sync/migrate-seed.ts +110 -19
- package/apps/studio/sync/poke.ts +4 -2
- package/apps/studio/sync/presentation.ts +285 -2
- package/apps/studio/sync/projection.ts +820 -23
- package/apps/studio/sync/repeated-module.ts +73 -0
- package/apps/studio/sync/revision-barrier.ts +129 -0
- package/apps/studio/sync/seed-repair.ts +46 -0
- package/apps/studio/sync/source-merge.ts +100 -0
- package/apps/studio/sync/source-ops.ts +289 -0
- package/apps/studio/sync/source-recovery.ts +70 -0
- package/apps/studio/sync/source-validation.ts +56 -0
- package/apps/studio/sync/status.ts +79 -1
- package/apps/studio/sync/transaction-client.ts +460 -0
- package/apps/studio/sync/writer-registry.ts +236 -0
- package/apps/studio/text-caret.ts +35 -0
- package/apps/studio/undo-hud.tsx +9 -87
- package/apps/studio/use-canvas-media-drop.tsx +4 -39
- package/apps/studio/whats-new.json +101 -0
- package/cli/lib/harness/codex-runtime.mjs +4 -1
- package/package.json +9 -8
- package/plugins/design/dependencies.json +3 -3
- package/plugins/design/templates/_shell.html +47 -0
- package/plugins/flow/dependencies.json +3 -3
|
@@ -1242,7 +1242,9 @@ async function main() {
|
|
|
1242
1242
|
const res = await fetch(`${base}/_api/annotations`, {
|
|
1243
1243
|
method: 'PUT',
|
|
1244
1244
|
headers: { 'Content-Type': 'application/json' },
|
|
1245
|
-
|
|
1245
|
+
// `base` — the SVG this edit was merged onto, so a peer's strokes
|
|
1246
|
+
// that landed meanwhile are merged, not replaced (DDR-241).
|
|
1247
|
+
body: JSON.stringify({ file: `${designRel}/${relPath}`, svg: merged, base: svg }),
|
|
1246
1248
|
signal: AbortSignal.timeout(3000),
|
|
1247
1249
|
});
|
|
1248
1250
|
if (res.ok) via = 'server';
|
|
@@ -145,9 +145,24 @@ NEEDS_START=1
|
|
|
145
145
|
if [ -f "$STATE" ]; then
|
|
146
146
|
PID=$(read_pid)
|
|
147
147
|
PORT=$(read_port)
|
|
148
|
+
# Sandboxed agents may reach Studio while kill -0 returns EPERM. Verify the
|
|
149
|
+
# server's identity over HTTP instead of mistaking denied signalling for death.
|
|
150
|
+
HEALTH=""
|
|
151
|
+
if [ -n "$PORT" ]; then
|
|
152
|
+
HEALTH=$(curl -fs --max-time 2 "http://localhost:$PORT/_health" 2>/dev/null || true)
|
|
153
|
+
fi
|
|
148
154
|
if [ -n "$PID" ] && [ -n "$PORT" ] \
|
|
149
|
-
&&
|
|
150
|
-
|
|
155
|
+
&& printf '%s' "$HEALTH" | node -e '
|
|
156
|
+
try {
|
|
157
|
+
const fs = require("node:fs");
|
|
158
|
+
const h = JSON.parse(fs.readFileSync(0, "utf8"));
|
|
159
|
+
const root = fs.realpathSync(process.argv[2]);
|
|
160
|
+
const id = require("node:crypto").createHash("sha256").update(root).digest("hex").slice(0, 12);
|
|
161
|
+
process.exit(h.ok === true && h.app === "design" &&
|
|
162
|
+
Number(h.pid) === Number(process.argv[1]) &&
|
|
163
|
+
(h.rootId === undefined || h.rootId === id) ? 0 : 1);
|
|
164
|
+
} catch { process.exit(1); }
|
|
165
|
+
' "$PID" "$REPO"; then
|
|
151
166
|
echo "✓ server alive pid=$PID port=$PORT" >&2
|
|
152
167
|
echo "$PORT"
|
|
153
168
|
exit 0
|
|
@@ -37,14 +37,28 @@ import { parseSync } from 'oxc-parser';
|
|
|
37
37
|
export class CanvasEditError extends Error {
|
|
38
38
|
readonly canvas: string;
|
|
39
39
|
readonly id: string;
|
|
40
|
-
|
|
40
|
+
/** The target no longer holds the value the caller expected (a peer changed it). */
|
|
41
|
+
readonly conflict: boolean;
|
|
42
|
+
constructor(message: string, info: { canvas: string; id: string; conflict?: boolean }) {
|
|
41
43
|
super(message);
|
|
42
44
|
this.name = 'CanvasEditError';
|
|
43
45
|
this.canvas = info.canvas;
|
|
44
46
|
this.id = info.id;
|
|
47
|
+
this.conflict = info.conflict === true;
|
|
45
48
|
}
|
|
46
49
|
}
|
|
47
50
|
|
|
51
|
+
/**
|
|
52
|
+
* Expected-current-value guard for a single-attribute write (audit 2026-09-13
|
|
53
|
+
* P1 #5). `expected` is what the caller believes the source holds now, `next`
|
|
54
|
+
* is what it is about to write; `null` means "attribute / style key absent".
|
|
55
|
+
* Values are the RAW strings the edit routes take (not JSON-encoded).
|
|
56
|
+
*/
|
|
57
|
+
export interface AttributePrecondition {
|
|
58
|
+
expected: string | null;
|
|
59
|
+
next: string | null;
|
|
60
|
+
}
|
|
61
|
+
|
|
48
62
|
const PASCAL_CASE = /^[A-Z][A-Za-z0-9_]*$/;
|
|
49
63
|
// biome-ignore lint/suspicious/noExplicitAny: oxc-parser AST nodes are heterogeneous.
|
|
50
64
|
type AnyNode = any;
|
|
@@ -268,6 +282,12 @@ export interface EditResult {
|
|
|
268
282
|
* real write with delta 0 (RC1 rim-suppression finding).
|
|
269
283
|
*/
|
|
270
284
|
changed?: boolean;
|
|
285
|
+
/**
|
|
286
|
+
* What the target held BEFORE this write, read under the same lock. The
|
|
287
|
+
* value an undo must restore is what the write replaced — never what a
|
|
288
|
+
* client panel last believed was there (it can be a teammate's edit old).
|
|
289
|
+
*/
|
|
290
|
+
previous?: AttributeState;
|
|
271
291
|
}
|
|
272
292
|
|
|
273
293
|
/**
|
|
@@ -280,7 +300,8 @@ export async function editAttribute(
|
|
|
280
300
|
id: string,
|
|
281
301
|
attr: string,
|
|
282
302
|
value: string,
|
|
283
|
-
occurrence?: number
|
|
303
|
+
occurrence?: number,
|
|
304
|
+
precondition?: AttributePrecondition
|
|
284
305
|
): Promise<EditResult> {
|
|
285
306
|
return withLock(canvasAbsPath, async () => {
|
|
286
307
|
const file = Bun.file(canvasAbsPath);
|
|
@@ -291,13 +312,22 @@ export async function editAttribute(
|
|
|
291
312
|
});
|
|
292
313
|
}
|
|
293
314
|
const source = await file.text();
|
|
315
|
+
// Read under the same per-file lock as the write — no gap between the read
|
|
316
|
+
// that proves the precondition (and records what is replaced) and the rename.
|
|
317
|
+
const previous = readAttributeState(canvasAbsPath, source, id, attr, occurrence);
|
|
318
|
+
if (
|
|
319
|
+
precondition &&
|
|
320
|
+
checkPrecondition(previous, id, attr, canvasAbsPath, precondition) === 'already'
|
|
321
|
+
) {
|
|
322
|
+
return { source, delta: 0, changed: false, previous };
|
|
323
|
+
}
|
|
294
324
|
const next = applyEdit(canvasAbsPath, source, id, attr, value, occurrence);
|
|
295
|
-
if (next.source === source) return { source, delta: 0, changed: false };
|
|
325
|
+
if (next.source === source) return { source, delta: 0, changed: false, previous };
|
|
296
326
|
const tmp = `${canvasAbsPath}.tmp.${Math.random().toString(36).slice(2, 10)}`;
|
|
297
327
|
await Bun.write(tmp, next.source);
|
|
298
328
|
const { rename } = await import('node:fs/promises');
|
|
299
329
|
await rename(tmp, canvasAbsPath);
|
|
300
|
-
return { ...next, changed: true };
|
|
330
|
+
return { ...next, changed: true, previous };
|
|
301
331
|
});
|
|
302
332
|
}
|
|
303
333
|
|
|
@@ -313,7 +343,8 @@ export async function removeAttribute(
|
|
|
313
343
|
canvasAbsPath: string,
|
|
314
344
|
id: string,
|
|
315
345
|
attr: string,
|
|
316
|
-
occurrence?: number
|
|
346
|
+
occurrence?: number,
|
|
347
|
+
precondition?: AttributePrecondition
|
|
317
348
|
): Promise<EditResult> {
|
|
318
349
|
return withLock(canvasAbsPath, async () => {
|
|
319
350
|
const file = Bun.file(canvasAbsPath);
|
|
@@ -324,13 +355,124 @@ export async function removeAttribute(
|
|
|
324
355
|
});
|
|
325
356
|
}
|
|
326
357
|
const source = await file.text();
|
|
358
|
+
const previous = readAttributeState(canvasAbsPath, source, id, attr, occurrence);
|
|
359
|
+
if (
|
|
360
|
+
precondition &&
|
|
361
|
+
checkPrecondition(previous, id, attr, canvasAbsPath, precondition) === 'already'
|
|
362
|
+
) {
|
|
363
|
+
return { source, delta: 0, changed: false, previous };
|
|
364
|
+
}
|
|
327
365
|
const next = applyRemove(canvasAbsPath, source, id, attr, occurrence);
|
|
328
|
-
if (next.source === source) return { source, delta: 0, changed: false };
|
|
366
|
+
if (next.source === source) return { source, delta: 0, changed: false, previous };
|
|
329
367
|
const tmp = `${canvasAbsPath}.tmp.${Math.random().toString(36).slice(2, 10)}`;
|
|
330
368
|
await Bun.write(tmp, next.source);
|
|
331
369
|
const { rename } = await import('node:fs/promises');
|
|
332
370
|
await rename(tmp, canvasAbsPath);
|
|
333
|
-
return { ...next, changed: true };
|
|
371
|
+
return { ...next, changed: true, previous };
|
|
372
|
+
});
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/** What one plain attribute or inline style key currently holds in source. */
|
|
376
|
+
export type AttributeState =
|
|
377
|
+
| { kind: 'absent' }
|
|
378
|
+
| { kind: 'literal'; value: string }
|
|
379
|
+
/** Present, but not a literal this module can compare (an expression, a spread). */
|
|
380
|
+
| { kind: 'expression' };
|
|
381
|
+
|
|
382
|
+
function literalText(node: AnyNode): string | null {
|
|
383
|
+
if (!node) return null;
|
|
384
|
+
if (node.type === 'Literal' || node.type === 'StringLiteral' || node.type === 'NumericLiteral') {
|
|
385
|
+
return typeof node.value === 'string' || typeof node.value === 'number'
|
|
386
|
+
? String(node.value)
|
|
387
|
+
: null;
|
|
388
|
+
}
|
|
389
|
+
if (node.type === 'TemplateLiteral' && node.expressions?.length === 0) {
|
|
390
|
+
return node.quasis?.[0]?.value?.cooked ?? null;
|
|
391
|
+
}
|
|
392
|
+
return null;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
/**
|
|
396
|
+
* Read the current value of `attr` (`style.<prop>` or a plain attribute name)
|
|
397
|
+
* on the element with `data-cd-id` `id`, with the same occurrence routing as
|
|
398
|
+
* `applyEdit` / `applyRemove`. Pure — exposed for tests and preconditions.
|
|
399
|
+
*/
|
|
400
|
+
export function readAttributeState(
|
|
401
|
+
canvasAbsPath: string,
|
|
402
|
+
source: string,
|
|
403
|
+
id: string,
|
|
404
|
+
attr: string,
|
|
405
|
+
occurrence?: number
|
|
406
|
+
): AttributeState {
|
|
407
|
+
const parsed = parseSync(canvasAbsPath, source, { sourceType: 'module' });
|
|
408
|
+
if (parsed.errors && parsed.errors.length > 0) {
|
|
409
|
+
throw new CanvasEditError(
|
|
410
|
+
`oxc-parser failed on ${canvasAbsPath}: ${parsed.errors[0]?.message ?? 'unknown'}`,
|
|
411
|
+
{ canvas: canvasAbsPath, id }
|
|
412
|
+
);
|
|
413
|
+
}
|
|
414
|
+
if (typeof occurrence === 'number' && Number.isFinite(occurrence)) {
|
|
415
|
+
id = resolveUsageId(parsed.program, id, occurrence);
|
|
416
|
+
}
|
|
417
|
+
const hit = findOpening(parsed.program, id);
|
|
418
|
+
if (!hit) {
|
|
419
|
+
throw new CanvasEditError(`data-cd-id "${id}" not found in ${canvasAbsPath}`, {
|
|
420
|
+
canvas: canvasAbsPath,
|
|
421
|
+
id,
|
|
422
|
+
});
|
|
423
|
+
}
|
|
424
|
+
if (attr.startsWith('style.')) {
|
|
425
|
+
const style = findAttribute(hit.opening, 'style');
|
|
426
|
+
if (!style) return { kind: 'absent' };
|
|
427
|
+
const obj = style.value?.type === 'JSXExpressionContainer' ? style.value.expression : null;
|
|
428
|
+
if (obj?.type !== 'ObjectExpression') return { kind: 'expression' };
|
|
429
|
+
const prop = attr.slice('style.'.length);
|
|
430
|
+
const propCamel = prop.replace(/-([a-z])/g, (_, c) => c.toUpperCase());
|
|
431
|
+
for (const p of obj.properties as AnyNode[]) {
|
|
432
|
+
if (p?.type !== 'Property' && p?.type !== 'ObjectProperty') continue;
|
|
433
|
+
const k = p.key;
|
|
434
|
+
const kname =
|
|
435
|
+
k?.type === 'Identifier' ? k.name : k?.type === 'Literal' ? String(k.value) : null;
|
|
436
|
+
if (kname !== prop && kname !== propCamel) continue;
|
|
437
|
+
const text = literalText(p.value);
|
|
438
|
+
return text === null ? { kind: 'expression' } : { kind: 'literal', value: text };
|
|
439
|
+
}
|
|
440
|
+
// A spread may supply the key at runtime; "absent" would be a guess.
|
|
441
|
+
return (obj.properties as AnyNode[]).some((p) => p?.type === 'SpreadElement')
|
|
442
|
+
? { kind: 'expression' }
|
|
443
|
+
: { kind: 'absent' };
|
|
444
|
+
}
|
|
445
|
+
const found = findAttribute(hit.opening, attr);
|
|
446
|
+
if (!found) return { kind: 'absent' };
|
|
447
|
+
if (found.value == null) return { kind: 'literal', value: '' };
|
|
448
|
+
const direct = literalText(found.value);
|
|
449
|
+
if (direct !== null) return { kind: 'literal', value: direct };
|
|
450
|
+
const inner =
|
|
451
|
+
found.value.type === 'JSXExpressionContainer' ? literalText(found.value.expression) : null;
|
|
452
|
+
return inner === null ? { kind: 'expression' } : { kind: 'literal', value: inner };
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
/**
|
|
456
|
+
* `'apply'` when the source still holds `expected`; `'already'` when it holds
|
|
457
|
+
* `next` (a retried or already-applied write — idempotent success). Anything
|
|
458
|
+
* else is a peer's newer value: refuse rather than overwrite it.
|
|
459
|
+
*/
|
|
460
|
+
function checkPrecondition(
|
|
461
|
+
state: AttributeState,
|
|
462
|
+
id: string,
|
|
463
|
+
attr: string,
|
|
464
|
+
canvasAbsPath: string,
|
|
465
|
+
pre: AttributePrecondition
|
|
466
|
+
): 'apply' | 'already' {
|
|
467
|
+
const holds = (value: string | null) =>
|
|
468
|
+
value === null ? state.kind === 'absent' : state.kind === 'literal' && state.value === value;
|
|
469
|
+
if (holds(pre.next)) return 'already';
|
|
470
|
+
if (holds(pre.expected)) return 'apply';
|
|
471
|
+
const name = attr.startsWith('style.') ? attr.slice('style.'.length) : attr;
|
|
472
|
+
throw new CanvasEditError(`${name} was changed by someone else — kept their value`, {
|
|
473
|
+
canvas: canvasAbsPath,
|
|
474
|
+
id,
|
|
475
|
+
conflict: true,
|
|
334
476
|
});
|
|
335
477
|
}
|
|
336
478
|
|
|
@@ -3930,6 +4072,86 @@ export function applySetArtboardKind(
|
|
|
3930
4072
|
return { source: out };
|
|
3931
4073
|
}
|
|
3932
4074
|
|
|
4075
|
+
/** Longest artboard name the label edit accepts (the chrome truncates long ones). */
|
|
4076
|
+
export const MAX_ARTBOARD_LABEL = 80;
|
|
4077
|
+
|
|
4078
|
+
/**
|
|
4079
|
+
* Rename an artboard — write its DCArtboard `label` string prop (plan T25/L08,
|
|
4080
|
+
* the double-click-the-name gesture). Addressed by the `id` prop like every
|
|
4081
|
+
* other artboard verb; the label is a JSX string attribute, so `editStringAttr`
|
|
4082
|
+
* escapes it. Pure; reparse-gated.
|
|
4083
|
+
*/
|
|
4084
|
+
export function applySetArtboardLabel(
|
|
4085
|
+
canvasAbsPath: string,
|
|
4086
|
+
source: string,
|
|
4087
|
+
artboardId: string,
|
|
4088
|
+
label: string
|
|
4089
|
+
): { source: string } {
|
|
4090
|
+
// Control characters never reach a JSX attribute.
|
|
4091
|
+
const clean = [...label]
|
|
4092
|
+
.filter((c) => c.charCodeAt(0) >= 0x20 && c.charCodeAt(0) !== 0x7f)
|
|
4093
|
+
.join('')
|
|
4094
|
+
.trim();
|
|
4095
|
+
if (!clean || clean.length > MAX_ARTBOARD_LABEL) {
|
|
4096
|
+
throw new CanvasEditError(`artboard name must be 1–${MAX_ARTBOARD_LABEL} characters`, {
|
|
4097
|
+
canvas: canvasAbsPath,
|
|
4098
|
+
id: artboardId,
|
|
4099
|
+
});
|
|
4100
|
+
}
|
|
4101
|
+
const parsed = parseSync(canvasAbsPath, source, { sourceType: 'module' });
|
|
4102
|
+
if (parsed.errors && parsed.errors.length > 0) {
|
|
4103
|
+
throw new CanvasEditError(
|
|
4104
|
+
`oxc-parser failed on ${canvasAbsPath}: ${parsed.errors[0]?.message ?? 'unknown'}`,
|
|
4105
|
+
{ canvas: canvasAbsPath, id: artboardId }
|
|
4106
|
+
);
|
|
4107
|
+
}
|
|
4108
|
+
const target = collectJsxByTag(parsed.program, 'DCArtboard').find(
|
|
4109
|
+
(a) => getStringAttr(a.openingElement, 'id') === artboardId
|
|
4110
|
+
);
|
|
4111
|
+
if (!target) {
|
|
4112
|
+
throw new CanvasEditError(`<DCArtboard id="${artboardId}"> not found in ${canvasAbsPath}`, {
|
|
4113
|
+
canvas: canvasAbsPath,
|
|
4114
|
+
id: artboardId,
|
|
4115
|
+
});
|
|
4116
|
+
}
|
|
4117
|
+
const s = new MagicString(source);
|
|
4118
|
+
editStringAttr(s, target.openingElement, 'label', clean, canvasAbsPath, artboardId);
|
|
4119
|
+
const out = s.toString();
|
|
4120
|
+
const check = parseSync(canvasAbsPath, out, { sourceType: 'module' });
|
|
4121
|
+
if (check.errors && check.errors.length > 0) {
|
|
4122
|
+
throw new CanvasEditError(
|
|
4123
|
+
`artboard rename produced invalid source (${check.errors[0]?.message ?? 'parse error'})`,
|
|
4124
|
+
{ canvas: canvasAbsPath, id: artboardId }
|
|
4125
|
+
);
|
|
4126
|
+
}
|
|
4127
|
+
return { source: out };
|
|
4128
|
+
}
|
|
4129
|
+
|
|
4130
|
+
/** Rename an artboard on disk (atomic write + cross-process lock). */
|
|
4131
|
+
export async function setArtboardLabel(
|
|
4132
|
+
canvasAbsPath: string,
|
|
4133
|
+
artboardId: string,
|
|
4134
|
+
label: string
|
|
4135
|
+
): Promise<{ source: string }> {
|
|
4136
|
+
return withLock(canvasAbsPath, async () => {
|
|
4137
|
+
const file = Bun.file(canvasAbsPath);
|
|
4138
|
+
if (!(await file.exists())) {
|
|
4139
|
+
throw new CanvasEditError(`Canvas not found: ${canvasAbsPath}`, {
|
|
4140
|
+
canvas: canvasAbsPath,
|
|
4141
|
+
id: artboardId,
|
|
4142
|
+
});
|
|
4143
|
+
}
|
|
4144
|
+
const source = await file.text();
|
|
4145
|
+
const next = applySetArtboardLabel(canvasAbsPath, source, artboardId, label);
|
|
4146
|
+
if (next.source === source) return next;
|
|
4147
|
+
const tmp = `${canvasAbsPath}.tmp.${Math.random().toString(36).slice(2, 10)}`;
|
|
4148
|
+
await Bun.write(tmp, next.source);
|
|
4149
|
+
const { rename } = await import('node:fs/promises');
|
|
4150
|
+
await rename(tmp, canvasAbsPath);
|
|
4151
|
+
return next;
|
|
4152
|
+
});
|
|
4153
|
+
}
|
|
4154
|
+
|
|
3933
4155
|
/** Set an artboard's `kind` on disk (atomic write + cross-process lock). */
|
|
3934
4156
|
export async function setArtboardKind(
|
|
3935
4157
|
canvasAbsPath: string,
|
|
@@ -5354,3 +5576,165 @@ if (import.meta.main) {
|
|
|
5354
5576
|
process.exit(2);
|
|
5355
5577
|
}
|
|
5356
5578
|
}
|
|
5579
|
+
|
|
5580
|
+
// ---------------------------------------------------------------------------
|
|
5581
|
+
// Position-independent element identity — plan T23/T24 (DDR-241).
|
|
5582
|
+
//
|
|
5583
|
+
// `data-cd-id` is positional (component + pre-order index), so a sibling a
|
|
5584
|
+
// teammate inserted earlier in the same component renumbers everything after
|
|
5585
|
+
// it. An edit made through the UI is re-applied onto the project's newer
|
|
5586
|
+
// version when its own proposal lost a race (sync/source-ops.ts); before it
|
|
5587
|
+
// touches anything there, it has to find THE SAME element — by what the
|
|
5588
|
+
// element is, not where it was.
|
|
5589
|
+
//
|
|
5590
|
+
// A print is the enclosing component, the tag, the chain of ancestor tags and
|
|
5591
|
+
// the element's literal attributes (minus the one being edited, minus the
|
|
5592
|
+
// pipeline's own `data-cd-*`). It identifies an element when exactly one
|
|
5593
|
+
// element in the newer source carries it; anything else is ambiguous, and an
|
|
5594
|
+
// ambiguous edit is never guessed at.
|
|
5595
|
+
|
|
5596
|
+
export interface ElementPrint {
|
|
5597
|
+
component: string;
|
|
5598
|
+
tag: string;
|
|
5599
|
+
chain: string[];
|
|
5600
|
+
attrs: string;
|
|
5601
|
+
/** The element's own literal text (bounded) — absent for a text edit, whose text is what changes. */
|
|
5602
|
+
text?: string;
|
|
5603
|
+
}
|
|
5604
|
+
|
|
5605
|
+
function ownText(node: AnyNode): string {
|
|
5606
|
+
let t = '';
|
|
5607
|
+
for (const c of Array.isArray(node?.children) ? node.children : []) {
|
|
5608
|
+
if (c?.type === 'JSXText') t += String(c.value);
|
|
5609
|
+
}
|
|
5610
|
+
return t.replace(/\s+/g, ' ').trim().slice(0, 80);
|
|
5611
|
+
}
|
|
5612
|
+
|
|
5613
|
+
function literalAttrs(opening: AnyNode, ignore: string | null): string {
|
|
5614
|
+
const out: string[] = [];
|
|
5615
|
+
for (const a of Array.isArray(opening?.attributes) ? opening.attributes : []) {
|
|
5616
|
+
if (a?.type !== 'JSXAttribute' || a.name?.type !== 'JSXIdentifier') continue;
|
|
5617
|
+
const name = String(a.name.name);
|
|
5618
|
+
if (name.startsWith('data-cd-') || name === 'style' || name === ignore || name === 'key')
|
|
5619
|
+
continue;
|
|
5620
|
+
const v = a.value;
|
|
5621
|
+
const lit =
|
|
5622
|
+
v?.type === 'Literal' || v?.type === 'StringLiteral'
|
|
5623
|
+
? String(v.value)
|
|
5624
|
+
: v?.type === 'JSXExpressionContainer' &&
|
|
5625
|
+
(v.expression?.type === 'Literal' || v.expression?.type === 'StringLiteral')
|
|
5626
|
+
? String(v.expression.value)
|
|
5627
|
+
: v === null || v === undefined
|
|
5628
|
+
? 'true'
|
|
5629
|
+
: '{expr}';
|
|
5630
|
+
out.push(`${name}=${lit}`);
|
|
5631
|
+
}
|
|
5632
|
+
return out.sort().join('|');
|
|
5633
|
+
}
|
|
5634
|
+
|
|
5635
|
+
function printAll(
|
|
5636
|
+
canvasAbsPath: string,
|
|
5637
|
+
source: string,
|
|
5638
|
+
ignoreAttr: string | null,
|
|
5639
|
+
withText = true
|
|
5640
|
+
): Array<{ id: string; print: ElementPrint }> {
|
|
5641
|
+
const parsed = parseSync(canvasAbsPath, source, { sourceType: 'module' });
|
|
5642
|
+
if (parsed.errors && parsed.errors.length > 0) return [];
|
|
5643
|
+
const frames: Array<{ componentName: string; jsxIndex: number }> = [
|
|
5644
|
+
{ componentName: '', jsxIndex: 0 },
|
|
5645
|
+
];
|
|
5646
|
+
const chain: string[] = [];
|
|
5647
|
+
const out: Array<{ id: string; print: ElementPrint }> = [];
|
|
5648
|
+
function visit(node: AnyNode): void {
|
|
5649
|
+
if (!node || typeof node !== 'object') return;
|
|
5650
|
+
if (Array.isArray(node)) {
|
|
5651
|
+
for (const c of node) visit(c);
|
|
5652
|
+
return;
|
|
5653
|
+
}
|
|
5654
|
+
if (typeof node.type !== 'string') return;
|
|
5655
|
+
const comp = componentNameOf(node);
|
|
5656
|
+
if (comp !== null) frames.push({ componentName: comp, jsxIndex: 0 });
|
|
5657
|
+
if (node.type === 'JSXElement') {
|
|
5658
|
+
const frame = frames[frames.length - 1] as { componentName: string; jsxIndex: number };
|
|
5659
|
+
const idx = frame.jsxIndex;
|
|
5660
|
+
frame.jsxIndex += 1;
|
|
5661
|
+
const tag = jsxTagName(node) ?? '?';
|
|
5662
|
+
out.push({
|
|
5663
|
+
id: authoredCdId(node.openingElement) ?? computeId(frame.componentName, idx),
|
|
5664
|
+
print: {
|
|
5665
|
+
component: frame.componentName,
|
|
5666
|
+
tag,
|
|
5667
|
+
chain: [...chain],
|
|
5668
|
+
attrs: literalAttrs(node.openingElement, ignoreAttr),
|
|
5669
|
+
...(withText ? { text: ownText(node) } : {}),
|
|
5670
|
+
},
|
|
5671
|
+
});
|
|
5672
|
+
chain.push(tag);
|
|
5673
|
+
if (node.openingElement) visit(node.openingElement.attributes);
|
|
5674
|
+
visit(node.children);
|
|
5675
|
+
chain.pop();
|
|
5676
|
+
if (comp !== null) frames.pop();
|
|
5677
|
+
return;
|
|
5678
|
+
}
|
|
5679
|
+
for (const k of Object.keys(node)) {
|
|
5680
|
+
if (k === 'loc' || k === 'range' || k === 'start' || k === 'end' || k === 'type') continue;
|
|
5681
|
+
visit(node[k]);
|
|
5682
|
+
}
|
|
5683
|
+
if (comp !== null) frames.pop();
|
|
5684
|
+
}
|
|
5685
|
+
visit(parsed.program);
|
|
5686
|
+
return out;
|
|
5687
|
+
}
|
|
5688
|
+
|
|
5689
|
+
const samePrint = (a: ElementPrint, b: ElementPrint) =>
|
|
5690
|
+
a.component === b.component &&
|
|
5691
|
+
a.tag === b.tag &&
|
|
5692
|
+
a.attrs === b.attrs &&
|
|
5693
|
+
(a.text ?? null) === (b.text ?? null) &&
|
|
5694
|
+
a.chain.length === b.chain.length &&
|
|
5695
|
+
a.chain.every((t, i) => t === b.chain[i]);
|
|
5696
|
+
|
|
5697
|
+
/** The print of the element `id` in `source`, or null. */
|
|
5698
|
+
export function elementPrint(
|
|
5699
|
+
canvasAbsPath: string,
|
|
5700
|
+
source: string,
|
|
5701
|
+
id: string,
|
|
5702
|
+
ignoreAttr: string | null = null,
|
|
5703
|
+
withText = true
|
|
5704
|
+
): ElementPrint | null {
|
|
5705
|
+
return (
|
|
5706
|
+
printAll(canvasAbsPath, source, ignoreAttr, withText).find((e) => e.id === id)?.print ?? null
|
|
5707
|
+
);
|
|
5708
|
+
}
|
|
5709
|
+
|
|
5710
|
+
/**
|
|
5711
|
+
* The id the element printed `print` has in `source` — `hint` first (the
|
|
5712
|
+
* common case: nothing moved), else the ONE element carrying the print. Null
|
|
5713
|
+
* when it is gone or ambiguous.
|
|
5714
|
+
*/
|
|
5715
|
+
export function relocateElement(
|
|
5716
|
+
canvasAbsPath: string,
|
|
5717
|
+
source: string,
|
|
5718
|
+
hint: string,
|
|
5719
|
+
print: ElementPrint,
|
|
5720
|
+
ignoreAttr: string | null = null
|
|
5721
|
+
): string | null {
|
|
5722
|
+
const all = printAll(canvasAbsPath, source, ignoreAttr, print.text !== undefined);
|
|
5723
|
+
const atHint = all.find((e) => e.id === hint);
|
|
5724
|
+
if (atHint && samePrint(atHint.print, print)) return hint;
|
|
5725
|
+
const matches = all.filter((e) => samePrint(e.print, print));
|
|
5726
|
+
return matches.length === 1 ? (matches[0] as { id: string }).id : null;
|
|
5727
|
+
}
|
|
5728
|
+
|
|
5729
|
+
/** T23 — how many elements of `source` carry a print no other element shares. */
|
|
5730
|
+
export function printUniqueness(
|
|
5731
|
+
canvasAbsPath: string,
|
|
5732
|
+
source: string
|
|
5733
|
+
): { elements: number; unique: number } {
|
|
5734
|
+
const all = printAll(canvasAbsPath, source, null);
|
|
5735
|
+
const key = (p: ElementPrint) =>
|
|
5736
|
+
`${p.component}|${p.tag}|${p.chain.join('>')}|${p.attrs}|${p.text ?? ''}`;
|
|
5737
|
+
const counts = new Map<string, number>();
|
|
5738
|
+
for (const e of all) counts.set(key(e.print), (counts.get(key(e.print)) ?? 0) + 1);
|
|
5739
|
+
return { elements: all.length, unique: all.filter((e) => counts.get(key(e.print)) === 1).length };
|
|
5740
|
+
}
|
|
@@ -475,6 +475,17 @@ button.dc-artboard-label {
|
|
|
475
475
|
width: 100%;
|
|
476
476
|
}
|
|
477
477
|
button.dc-artboard-label:focus-visible { outline: 2px solid var(--maude-hud-accent, #d63b1f); outline-offset: -2px; }
|
|
478
|
+
input.dc-artboard-rename {
|
|
479
|
+
appearance: none;
|
|
480
|
+
border-width: 0 0 1px 0;
|
|
481
|
+
font: inherit;
|
|
482
|
+
display: block;
|
|
483
|
+
width: 100%;
|
|
484
|
+
box-sizing: border-box;
|
|
485
|
+
text-transform: none;
|
|
486
|
+
outline: 2px solid var(--maude-hud-accent, #d63b1f);
|
|
487
|
+
outline-offset: -2px;
|
|
488
|
+
}
|
|
478
489
|
/* DDR-148 — video-artboard badge, overlaid top-right of the header. Opens
|
|
479
490
|
the timeline panel (same postMessage the context-menu entry sends). */
|
|
480
491
|
.dc-canvas .dc-artboard-video-badge {
|
|
@@ -2447,6 +2458,25 @@ export function DCArtboard({
|
|
|
2447
2458
|
// edited), so it never ratchets up from a previously-larger measured value
|
|
2448
2459
|
// the way feeding `rect.h` back as the floor would.
|
|
2449
2460
|
const heightFloor = typeof height === 'number' ? height : VP_GRID.h;
|
|
2461
|
+
// Plan T25/L08 — rename in place: double-click the name, Enter commits (the
|
|
2462
|
+
// shell writes the `label` prop), Escape or an unchanged name cancels.
|
|
2463
|
+
const [renaming, setRenaming] = useState(false);
|
|
2464
|
+
const renameDoneRef = useRef(false);
|
|
2465
|
+
const commitRename = (value: string) => {
|
|
2466
|
+
if (renameDoneRef.current) return;
|
|
2467
|
+
renameDoneRef.current = true;
|
|
2468
|
+
setRenaming(false);
|
|
2469
|
+
const next = value.trim();
|
|
2470
|
+
if (!next || next === label) return;
|
|
2471
|
+
try {
|
|
2472
|
+
window.parent.postMessage(
|
|
2473
|
+
{ dgn: 'rename-artboard-request', artboardId: id, label: next },
|
|
2474
|
+
'*'
|
|
2475
|
+
);
|
|
2476
|
+
} catch {
|
|
2477
|
+
/* detached / cross-origin */
|
|
2478
|
+
}
|
|
2479
|
+
};
|
|
2450
2480
|
const articleRef = useRef<HTMLElement | null>(null);
|
|
2451
2481
|
const measured = useArtboardBounds(articleRef as RefObject<HTMLElement | null>);
|
|
2452
2482
|
useEffect(() => {
|
|
@@ -2590,24 +2620,58 @@ export function DCArtboard({
|
|
|
2590
2620
|
}}
|
|
2591
2621
|
{...handleProps}
|
|
2592
2622
|
>
|
|
2593
|
-
|
|
2594
|
-
|
|
2595
|
-
|
|
2596
|
-
|
|
2597
|
-
|
|
2598
|
-
|
|
2599
|
-
|
|
2600
|
-
|
|
2601
|
-
|
|
2602
|
-
|
|
2603
|
-
|
|
2604
|
-
|
|
2605
|
-
|
|
2606
|
-
|
|
2607
|
-
|
|
2608
|
-
|
|
2609
|
-
|
|
2610
|
-
|
|
2623
|
+
{renaming ? (
|
|
2624
|
+
<input
|
|
2625
|
+
className="dc-artboard-label sku dc-artboard-rename"
|
|
2626
|
+
data-testid={`artboard-rename-${id}`}
|
|
2627
|
+
aria-label="Artboard name"
|
|
2628
|
+
defaultValue={label}
|
|
2629
|
+
maxLength={80}
|
|
2630
|
+
// biome-ignore lint/a11y/noAutofocus: the rename field opens on the designer's own double-click
|
|
2631
|
+
autoFocus
|
|
2632
|
+
onFocus={(e) => e.currentTarget.select()}
|
|
2633
|
+
// The label is the drag handle; typing and clicking in the field
|
|
2634
|
+
// must not start a move or reach the canvas shortcuts.
|
|
2635
|
+
onPointerDown={(e) => e.stopPropagation()}
|
|
2636
|
+
onMouseDown={(e) => e.stopPropagation()}
|
|
2637
|
+
onKeyDown={(e) => {
|
|
2638
|
+
e.stopPropagation();
|
|
2639
|
+
if (e.key === 'Enter') commitRename(e.currentTarget.value);
|
|
2640
|
+
else if (e.key === 'Escape') {
|
|
2641
|
+
renameDoneRef.current = true;
|
|
2642
|
+
setRenaming(false);
|
|
2643
|
+
}
|
|
2644
|
+
}}
|
|
2645
|
+
onBlur={(e) => commitRename(e.currentTarget.value)}
|
|
2646
|
+
/>
|
|
2647
|
+
) : (
|
|
2648
|
+
<button
|
|
2649
|
+
type="button"
|
|
2650
|
+
className="dc-artboard-label sku"
|
|
2651
|
+
onDoubleClick={(e) => {
|
|
2652
|
+
if (isReadOnlyCanvas()) return;
|
|
2653
|
+
e.stopPropagation();
|
|
2654
|
+
renameDoneRef.current = false;
|
|
2655
|
+
setRenaming(true);
|
|
2656
|
+
}}
|
|
2657
|
+
// a11y-auditor (T3 review) — the kind chip is aria-hidden (decorative,
|
|
2658
|
+
// redundant with the Inspector's Kind picker), so a non-digital kind
|
|
2659
|
+
// must still reach the artboard's own accessible name or it's
|
|
2660
|
+
// invisible to AT users entirely.
|
|
2661
|
+
aria-label={
|
|
2662
|
+
resolvedKind !== 'digital'
|
|
2663
|
+
? `Artboard ${label}, ${resolvedKind}`
|
|
2664
|
+
: `Artboard ${label}`
|
|
2665
|
+
}
|
|
2666
|
+
>
|
|
2667
|
+
{resolvedKind !== 'digital' ? (
|
|
2668
|
+
<span className="dc-artboard-kind-chip" aria-hidden="true">
|
|
2669
|
+
<ArtboardKindIcon kind={resolvedKind} />
|
|
2670
|
+
</span>
|
|
2671
|
+
) : null}
|
|
2672
|
+
{label}
|
|
2673
|
+
</button>
|
|
2674
|
+
)}
|
|
2611
2675
|
{hasVideo ? (
|
|
2612
2676
|
<button
|
|
2613
2677
|
type="button"
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { Notice } from './notifications.tsx';
|
|
2
|
+
|
|
3
|
+
/** Canvas content can send text, never shell actions or executable markup. */
|
|
4
|
+
export function acceptCanvasNotice(
|
|
5
|
+
event: Pick<MessageEvent, 'origin' | 'source' | 'data'>,
|
|
6
|
+
expectedOrigin: string,
|
|
7
|
+
activeWindow: Window | null | undefined
|
|
8
|
+
): Notice | null {
|
|
9
|
+
if (!activeWindow || event.origin !== expectedOrigin || event.source !== activeWindow)
|
|
10
|
+
return null;
|
|
11
|
+
const data = event.data;
|
|
12
|
+
if (data?.dgn !== 'canvas-notice' || typeof data.message !== 'string' || !data.message.trim())
|
|
13
|
+
return null;
|
|
14
|
+
if (!['info', 'success', 'error', 'warning', 'undo'].includes(data.kind)) return null;
|
|
15
|
+
return { title: data.message.slice(0, 4000), kind: data.kind, group: 'canvas' };
|
|
16
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { createRoot } from 'react-dom/client';
|
|
2
|
+
import { type NoticeKind, NotificationHost, notifyCanvasText } from './notifications.tsx';
|
|
3
|
+
|
|
4
|
+
/** Embedded canvases use the shell's stack. Standalone canvases own one host. */
|
|
5
|
+
export function showCanvasToast(message: string, kind: NoticeKind = 'info'): void {
|
|
6
|
+
if (typeof document === 'undefined') return;
|
|
7
|
+
if (window.parent !== window) {
|
|
8
|
+
window.parent.postMessage({ dgn: 'canvas-notice', message, kind }, '*');
|
|
9
|
+
return;
|
|
10
|
+
}
|
|
11
|
+
let host = document.getElementById('maude-canvas-notifications');
|
|
12
|
+
if (!host) {
|
|
13
|
+
host = document.createElement('div');
|
|
14
|
+
host.id = 'maude-canvas-notifications';
|
|
15
|
+
document.body.appendChild(host);
|
|
16
|
+
createRoot(host).render(<NotificationHost />);
|
|
17
|
+
}
|
|
18
|
+
notifyCanvasText(message, kind);
|
|
19
|
+
}
|