@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.
Files changed (83) hide show
  1. package/README.md +3 -1
  2. package/apps/studio/acp/index.ts +43 -7
  3. package/apps/studio/annotations-layer.tsx +191 -116
  4. package/apps/studio/annotations-model.ts +39 -0
  5. package/apps/studio/annotations-sync.ts +50 -0
  6. package/apps/studio/api.ts +689 -36
  7. package/apps/studio/bin/annotate.mjs +3 -1
  8. package/apps/studio/bin/server-up.sh +17 -2
  9. package/apps/studio/canvas-edit.ts +391 -7
  10. package/apps/studio/canvas-lib.tsx +82 -18
  11. package/apps/studio/canvas-notice-message.ts +16 -0
  12. package/apps/studio/canvas-notifications.tsx +19 -0
  13. package/apps/studio/canvas-shell.tsx +57 -6
  14. package/apps/studio/client/app.jsx +686 -241
  15. package/apps/studio/client/apply-edit-request.ts +55 -0
  16. package/apps/studio/client/export-center.jsx +111 -61
  17. package/apps/studio/client/github.js +38 -1
  18. package/apps/studio/client/index-loader.ts +76 -0
  19. package/apps/studio/client/panels/CloudBar.jsx +52 -6
  20. package/apps/studio/client/panels/GitPanel.jsx +103 -0
  21. package/apps/studio/client/panels/OnboardingWizard.jsx +36 -7
  22. package/apps/studio/client/panels/RepoBranchSwitcher.jsx +92 -27
  23. package/apps/studio/client/panels/SourceConflictPanel.jsx +166 -0
  24. package/apps/studio/client/panels/SyncPanel.jsx +113 -0
  25. package/apps/studio/client/panels/TeamProjects.jsx +376 -0
  26. package/apps/studio/client/panels/file-deep-link-dialog.jsx +54 -0
  27. package/apps/studio/client/photo-knobs.jsx +3 -0
  28. package/apps/studio/client/share-dialog.jsx +91 -0
  29. package/apps/studio/client/share-link.js +94 -0
  30. package/apps/studio/client/styles/3-shell-maude.css +35 -18
  31. package/apps/studio/client/styles/4-components.css +18 -1
  32. package/apps/studio/client/tree-row-menu.jsx +48 -3
  33. package/apps/studio/client/whats-new.jsx +26 -32
  34. package/apps/studio/cloud/endpoints.ts +132 -0
  35. package/apps/studio/collab/index.ts +4 -0
  36. package/apps/studio/collab/persistence.ts +5 -1
  37. package/apps/studio/collab/registry.ts +9 -3
  38. package/apps/studio/collab/room.ts +13 -2
  39. package/apps/studio/context.ts +59 -0
  40. package/apps/studio/dist/client.bundle.js +1586 -1554
  41. package/apps/studio/dist/runtime/.min-sizes.json +1 -0
  42. package/apps/studio/dist/runtime/sonner.js +1 -0
  43. package/apps/studio/dist/styles.css +1 -1
  44. package/apps/studio/http.ts +289 -18
  45. package/apps/studio/inspect.ts +19 -0
  46. package/apps/studio/managed-projects.ts +150 -0
  47. package/apps/studio/notifications.tsx +292 -0
  48. package/apps/studio/runtime-bundle.ts +2 -0
  49. package/apps/studio/server.ts +59 -3
  50. package/apps/studio/sync/accepted-cold-start.ts +225 -0
  51. package/apps/studio/sync/accepted-link.ts +320 -0
  52. package/apps/studio/sync/action-stage.ts +343 -0
  53. package/apps/studio/sync/agent.ts +12 -60
  54. package/apps/studio/sync/cell-file-events.ts +3 -0
  55. package/apps/studio/sync/codec.ts +73 -6
  56. package/apps/studio/sync/ctl-provider.ts +15 -2
  57. package/apps/studio/sync/document-discovery.ts +81 -0
  58. package/apps/studio/sync/file-membership.ts +10 -2
  59. package/apps/studio/sync/file-plane.ts +309 -26
  60. package/apps/studio/sync/index.ts +1078 -19
  61. package/apps/studio/sync/migrate-seed.ts +110 -19
  62. package/apps/studio/sync/poke.ts +4 -2
  63. package/apps/studio/sync/presentation.ts +285 -2
  64. package/apps/studio/sync/projection.ts +820 -23
  65. package/apps/studio/sync/repeated-module.ts +73 -0
  66. package/apps/studio/sync/revision-barrier.ts +129 -0
  67. package/apps/studio/sync/seed-repair.ts +46 -0
  68. package/apps/studio/sync/source-merge.ts +100 -0
  69. package/apps/studio/sync/source-ops.ts +289 -0
  70. package/apps/studio/sync/source-recovery.ts +70 -0
  71. package/apps/studio/sync/source-validation.ts +56 -0
  72. package/apps/studio/sync/status.ts +79 -1
  73. package/apps/studio/sync/transaction-client.ts +460 -0
  74. package/apps/studio/sync/writer-registry.ts +236 -0
  75. package/apps/studio/text-caret.ts +35 -0
  76. package/apps/studio/undo-hud.tsx +9 -87
  77. package/apps/studio/use-canvas-media-drop.tsx +4 -39
  78. package/apps/studio/whats-new.json +101 -0
  79. package/cli/lib/harness/codex-runtime.mjs +4 -1
  80. package/package.json +9 -8
  81. package/plugins/design/dependencies.json +3 -3
  82. package/plugins/design/templates/_shell.html +47 -0
  83. 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
- body: JSON.stringify({ file: `${designRel}/${relPath}`, svg: merged }),
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
- && kill -0 "$PID" 2>/dev/null \
150
- && curl -fs "http://localhost:$PORT/_health" >/dev/null 2>&1; then
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
- constructor(message: string, info: { canvas: string; id: string }) {
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
- <button
2594
- type="button"
2595
- className="dc-artboard-label sku"
2596
- // a11y-auditor (T3 review) — the kind chip is aria-hidden (decorative,
2597
- // redundant with the Inspector's Kind picker), so a non-digital kind
2598
- // must still reach the artboard's own accessible name or it's
2599
- // invisible to AT users entirely.
2600
- aria-label={
2601
- resolvedKind !== 'digital' ? `Artboard ${label}, ${resolvedKind}` : `Artboard ${label}`
2602
- }
2603
- >
2604
- {resolvedKind !== 'digital' ? (
2605
- <span className="dc-artboard-kind-chip" aria-hidden="true">
2606
- <ArtboardKindIcon kind={resolvedKind} />
2607
- </span>
2608
- ) : null}
2609
- {label}
2610
- </button>
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
+ }