@1agh/maude 0.36.1 → 0.38.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 (71) hide show
  1. package/README.md +2 -0
  2. package/apps/studio/acp/bootstrap-brief.ts +93 -0
  3. package/apps/studio/acp/bridge.ts +114 -1
  4. package/apps/studio/acp/index.ts +184 -21
  5. package/apps/studio/acp/plugin-bootstrap.ts +115 -0
  6. package/apps/studio/acp/transcript.ts +36 -3
  7. package/apps/studio/activity.ts +45 -0
  8. package/apps/studio/api.ts +265 -47
  9. package/apps/studio/bin/_ensure-browser.mjs +305 -0
  10. package/apps/studio/bin/ensure-browser.sh +26 -0
  11. package/apps/studio/bin/screenshot.sh +33 -8
  12. package/apps/studio/bin/smoke.sh +46 -0
  13. package/apps/studio/canvas-edit.ts +422 -6
  14. package/apps/studio/canvas-lib.tsx +48 -0
  15. package/apps/studio/canvas-shell.tsx +684 -12
  16. package/apps/studio/client/app.jsx +686 -35
  17. package/apps/studio/client/github.js +6 -0
  18. package/apps/studio/client/panels/ChatPanel.jsx +593 -31
  19. package/apps/studio/client/panels/CreateProject.jsx +57 -23
  20. package/apps/studio/client/panels/GitPanel.jsx +1 -1
  21. package/apps/studio/client/panels/IdentityBar.jsx +43 -2
  22. package/apps/studio/client/panels/OnboardingWizard.jsx +24 -21
  23. package/apps/studio/client/panels/RepoBranchSwitcher.jsx +4 -4
  24. package/apps/studio/client/panels/acp-runtime.js +227 -70
  25. package/apps/studio/client/panels/chat-context.js +124 -0
  26. package/apps/studio/client/panels/slash-commands.js +147 -0
  27. package/apps/studio/client/styles/3-shell-maude.css +15 -0
  28. package/apps/studio/client/styles/6-acp-chat.css +244 -0
  29. package/apps/studio/client/tour/collab-tour.js +3 -3
  30. package/apps/studio/commands/reorder-command.ts +77 -0
  31. package/apps/studio/config.schema.json +6 -0
  32. package/apps/studio/dist/client.bundle.js +17 -13
  33. package/apps/studio/dist/styles.css +1 -1
  34. package/apps/studio/github/endpoints.ts +42 -0
  35. package/apps/studio/hmr-broadcast.ts +27 -19
  36. package/apps/studio/http.ts +183 -26
  37. package/apps/studio/inspect.ts +108 -1
  38. package/apps/studio/paths.ts +32 -0
  39. package/apps/studio/readiness.ts +79 -30
  40. package/apps/studio/scaffold-design.ts +95 -2
  41. package/apps/studio/server.ts +11 -2
  42. package/apps/studio/test/acp-activity.test.ts +154 -0
  43. package/apps/studio/test/acp-ai-activity.test.ts +182 -0
  44. package/apps/studio/test/acp-bootstrap-brief.test.ts +167 -0
  45. package/apps/studio/test/acp-commands.test.ts +108 -0
  46. package/apps/studio/test/acp-origin-gate.test.ts +64 -1
  47. package/apps/studio/test/acp-plugin-bootstrap.test.ts +89 -0
  48. package/apps/studio/test/acp-session-plugins.test.ts +132 -0
  49. package/apps/studio/test/acp-transcript.test.ts +53 -0
  50. package/apps/studio/test/active-state.test.ts +41 -0
  51. package/apps/studio/test/canvas-freshness-deps.test.ts +64 -0
  52. package/apps/studio/test/canvas-origin-gate.test.ts +10 -0
  53. package/apps/studio/test/canvas-reorder.test.ts +211 -0
  54. package/apps/studio/test/chat-context.test.ts +129 -0
  55. package/apps/studio/test/csrf-write-guard.test.ts +24 -0
  56. package/apps/studio/test/edit-suppress.test.ts +170 -0
  57. package/apps/studio/test/ensure-browser.test.ts +92 -0
  58. package/apps/studio/test/fixtures/mock-acp-agent-commands.mjs +44 -0
  59. package/apps/studio/test/hmr-broadcast.test.ts +44 -0
  60. package/apps/studio/test/inspect-selections.test.ts +219 -0
  61. package/apps/studio/test/paths.test.ts +57 -0
  62. package/apps/studio/test/readiness.test.ts +83 -13
  63. package/apps/studio/test/reorder-api.test.ts +210 -0
  64. package/apps/studio/test/slash-commands.test.ts +117 -0
  65. package/apps/studio/test/sync-agent.test.ts +9 -2
  66. package/apps/studio/undo-stack.ts +6 -0
  67. package/apps/studio/whats-new.json +63 -0
  68. package/apps/studio/ws.ts +37 -0
  69. package/cli/commands/design.mjs +1 -0
  70. package/package.json +11 -9
  71. package/plugins/design/dependencies.json +3 -3
@@ -170,6 +170,13 @@ export interface EditResult {
170
170
  source: string;
171
171
  /** Number of bytes the edit changed (positive = grew, negative = shrunk). */
172
172
  delta: number;
173
+ /**
174
+ * Whether a disk write actually happened. Set by the disk-op wrappers only
175
+ * (pure apply* variants leave it undefined). Callers must NOT infer this from
176
+ * `delta` — an equal-length replacement (e.g. text "Alpha" → "Gamma") is a
177
+ * real write with delta 0 (RC1 rim-suppression finding).
178
+ */
179
+ changed?: boolean;
173
180
  }
174
181
 
175
182
  /**
@@ -193,12 +200,12 @@ export async function editAttribute(
193
200
  }
194
201
  const source = await file.text();
195
202
  const next = applyEdit(canvasAbsPath, source, id, attr, value);
196
- if (next.source === source) return { source, delta: 0 };
203
+ if (next.source === source) return { source, delta: 0, changed: false };
197
204
  const tmp = `${canvasAbsPath}.tmp.${Math.random().toString(36).slice(2, 10)}`;
198
205
  await Bun.write(tmp, next.source);
199
206
  const { rename } = await import('node:fs/promises');
200
207
  await rename(tmp, canvasAbsPath);
201
- return next;
208
+ return { ...next, changed: true };
202
209
  });
203
210
  }
204
211
 
@@ -225,12 +232,12 @@ export async function removeAttribute(
225
232
  }
226
233
  const source = await file.text();
227
234
  const next = applyRemove(canvasAbsPath, source, id, attr);
228
- if (next.source === source) return { source, delta: 0 };
235
+ if (next.source === source) return { source, delta: 0, changed: false };
229
236
  const tmp = `${canvasAbsPath}.tmp.${Math.random().toString(36).slice(2, 10)}`;
230
237
  await Bun.write(tmp, next.source);
231
238
  const { rename } = await import('node:fs/promises');
232
239
  await rename(tmp, canvasAbsPath);
233
- return next;
240
+ return { ...next, changed: true };
234
241
  });
235
242
  }
236
243
 
@@ -298,12 +305,12 @@ export async function editText(
298
305
  }
299
306
  const source = await file.text();
300
307
  const next = applyTextEdit(canvasAbsPath, source, id, text);
301
- if (next.source === source) return { source, delta: 0 };
308
+ if (next.source === source) return { source, delta: 0, changed: false };
302
309
  const tmp = `${canvasAbsPath}.tmp.${Math.random().toString(36).slice(2, 10)}`;
303
310
  await Bun.write(tmp, next.source);
304
311
  const { rename } = await import('node:fs/promises');
305
312
  await rename(tmp, canvasAbsPath);
306
- return next;
313
+ return { ...next, changed: true };
307
314
  });
308
315
  }
309
316
 
@@ -414,6 +421,415 @@ export function applyTextEdit(
414
421
  return { source: out, delta: out.length - source.length };
415
422
  }
416
423
 
424
+ // ---------------------------------------------------------------------------
425
+ // Node-move reorder (DDR-138, phase-12.1). Moving a whole JSXElement to a new
426
+ // sibling/parent position — the one structural edit `editAttribute`/`editText`
427
+ // don't do. Remove the element's line-span + insert a re-indented copy at an
428
+ // anchor derived from `refId` + `position`. A same-parent reorder is the
429
+ // degenerate case where source/target indent match (no re-indent). Reparenting
430
+ // works because we re-indent (not raw magic-string.move). Guardrails refuse
431
+ // structurally-unsafe moves; a post-move reparse gate guarantees we never write
432
+ // corrupt source. The move response recomputes the moved element's positional
433
+ // id (matches what the pipeline assigns on the next pass) + surfaces its
434
+ // author-semantic `data-dc-element` handle so the client can re-settle the
435
+ // selection through the id churn.
436
+
437
+ export type MovePosition = 'before' | 'after' | 'inside-start' | 'inside-end';
438
+
439
+ export interface MoveResult extends EditResult {
440
+ /** Recomputed positional id of the moved element (== the DOM `data-cd-id`
441
+ * after the next pipeline pass). Best-effort — null if not resolvable. */
442
+ movedId: string | null;
443
+ /** The moved element's `data-dc-element` value (DDR-007), if it has one — a
444
+ * stable re-select key that survives the id churn byte-for-byte. */
445
+ semanticId: string | null;
446
+ }
447
+
448
+ /** Read a plain-string JSX attribute value off an opening element (null when
449
+ * absent or not a string literal). */
450
+ function getStringAttr(opening: AnyNode, name: string): string | null {
451
+ const attr = findAttribute(opening, name);
452
+ if (!attr) return null;
453
+ const v = attr.value;
454
+ if (v == null) return '';
455
+ if (v.type === 'Literal' || v.type === 'StringLiteral') return String(v.value);
456
+ return null;
457
+ }
458
+
459
+ /** Info about the line a byte offset sits on: the whitespace indentation, where
460
+ * it begins, and whether a newline immediately precedes it. */
461
+ function lineStartInfo(
462
+ source: string,
463
+ pos: number
464
+ ): { indent: string; indentStart: number; newlineBefore: boolean } {
465
+ let i = pos;
466
+ while (i > 0 && (source[i - 1] === ' ' || source[i - 1] === '\t')) i--;
467
+ return {
468
+ indent: source.slice(i, pos),
469
+ indentStart: i,
470
+ newlineBefore: i > 0 && source[i - 1] === '\n',
471
+ };
472
+ }
473
+
474
+ /** Detect the file's indentation unit (tab vs N spaces). Canvases are Prettier
475
+ * 2-space by default; fall back to that. */
476
+ function detectIndentUnit(source: string): string {
477
+ if (/\n\t/.test(source)) return '\t';
478
+ const m = /\n( +)\S/.exec(source);
479
+ return m ? m[1] : ' ';
480
+ }
481
+
482
+ /** Re-indent an element's source text from `fromIndent` (its old line indent) to
483
+ * `toIndent` (the target depth). The first line carries no leading indent in
484
+ * `elText` (it starts at the element), so only continuation lines shift, by the
485
+ * same delta, preserving the element's internal structure. */
486
+ function reindentBlock(elText: string, fromIndent: string, toIndent: string): string {
487
+ if (fromIndent === toIndent) return elText;
488
+ const lines = elText.split('\n');
489
+ return lines
490
+ .map((line, i) => {
491
+ if (i === 0) return line;
492
+ const lead = /^[ \t]*/.exec(line)?.[0] ?? '';
493
+ const rest = line.slice(lead.length);
494
+ if (rest === '') return line; // blank line — leave as-is
495
+ const rel = lead.startsWith(fromIndent) ? lead.slice(fromIndent.length) : lead;
496
+ return toIndent + rel + rest;
497
+ })
498
+ .join('\n');
499
+ }
500
+
501
+ /** Normalize element text for duplicate-tolerant matching: trim each line so
502
+ * re-indentation differences don't defeat the match. */
503
+ function normalizeForMatch(t: string): string {
504
+ return t
505
+ .split('\n')
506
+ .map((l) => l.trim())
507
+ .join('\n');
508
+ }
509
+
510
+ /** Walk the program with the SAME component + jsxIndex bookkeeping the pipeline
511
+ * uses (canvas-pipeline.ts walkInjectIds), collecting every JSXElement with the
512
+ * id it will be assigned. Reused to recompute the moved element's post-move id. */
513
+ function collectElements(program: AnyNode): Array<{ id: string; node: AnyNode }> {
514
+ interface Frame {
515
+ componentName: string;
516
+ jsxIndex: number;
517
+ }
518
+ const stack: Frame[] = [{ componentName: '', jsxIndex: 0 }];
519
+ const out: Array<{ id: string; node: AnyNode }> = [];
520
+
521
+ function visit(node: AnyNode): void {
522
+ if (!node || typeof node !== 'object') return;
523
+ if (Array.isArray(node)) {
524
+ for (const c of node) visit(c);
525
+ return;
526
+ }
527
+ if (typeof node.type !== 'string') return;
528
+
529
+ const newComp = componentNameOf(node);
530
+ let pushed = false;
531
+ if (newComp !== null) {
532
+ stack.push({ componentName: newComp, jsxIndex: 0 });
533
+ pushed = true;
534
+ }
535
+
536
+ if (node.type === 'JSXElement') {
537
+ const frame = stack[stack.length - 1] as Frame;
538
+ const idx = frame.jsxIndex;
539
+ frame.jsxIndex += 1;
540
+ out.push({ id: computeId(frame.componentName, idx), node });
541
+ if (node.openingElement) visit(node.openingElement.attributes);
542
+ visit(node.children);
543
+ if (pushed) stack.pop();
544
+ return;
545
+ }
546
+
547
+ for (const k of Object.keys(node)) {
548
+ if (k === 'loc' || k === 'range' || k === 'start' || k === 'end' || k === 'type') continue;
549
+ visit(node[k]);
550
+ }
551
+ if (pushed) stack.pop();
552
+ }
553
+
554
+ visit(program);
555
+ return out;
556
+ }
557
+
558
+ /**
559
+ * Every JSX element with its enclosing-component frame + tag. Superset of
560
+ * collectElements used to resolve a reused-component INSTANCE to its parent
561
+ * USAGE (see resolveUsageId).
562
+ */
563
+ function collectElementsFull(
564
+ program: AnyNode
565
+ ): Array<{ id: string; componentName: string; isFrameRoot: boolean; tag: string | null }> {
566
+ interface Frame {
567
+ componentName: string;
568
+ jsxIndex: number;
569
+ }
570
+ const stack: Frame[] = [{ componentName: '', jsxIndex: 0 }];
571
+ const out: Array<{
572
+ id: string;
573
+ componentName: string;
574
+ isFrameRoot: boolean;
575
+ tag: string | null;
576
+ }> = [];
577
+ function tagOf(node: AnyNode): string | null {
578
+ const n = node?.openingElement?.name;
579
+ if (n?.type === 'JSXIdentifier' && typeof n.name === 'string') return n.name;
580
+ return null;
581
+ }
582
+ function visit(node: AnyNode): void {
583
+ if (!node || typeof node !== 'object') return;
584
+ if (Array.isArray(node)) {
585
+ for (const c of node) visit(c);
586
+ return;
587
+ }
588
+ if (typeof node.type !== 'string') return;
589
+ const newComp = componentNameOf(node);
590
+ let pushed = false;
591
+ if (newComp !== null) {
592
+ stack.push({ componentName: newComp, jsxIndex: 0 });
593
+ pushed = true;
594
+ }
595
+ if (node.type === 'JSXElement') {
596
+ const frame = stack[stack.length - 1] as Frame;
597
+ const idx = frame.jsxIndex;
598
+ frame.jsxIndex += 1;
599
+ out.push({
600
+ id: computeId(frame.componentName, idx),
601
+ componentName: frame.componentName,
602
+ isFrameRoot: idx === 0,
603
+ tag: tagOf(node),
604
+ });
605
+ if (node.openingElement) visit(node.openingElement.attributes);
606
+ visit(node.children);
607
+ if (pushed) stack.pop();
608
+ return;
609
+ }
610
+ for (const k of Object.keys(node)) {
611
+ if (k === 'loc' || k === 'range' || k === 'start' || k === 'end' || k === 'type') continue;
612
+ visit(node[k]);
613
+ }
614
+ if (pushed) stack.pop();
615
+ }
616
+ visit(program);
617
+ return out;
618
+ }
619
+
620
+ /**
621
+ * Resolve a reused-component INSTANCE id to the parent's `<Component>` USAGE id.
622
+ *
623
+ * A component used N times (`<Column/>` … `<Column/>`) renders N DOM nodes that
624
+ * all carry ONE data-cd-id — the id of an element INSIDE the component's
625
+ * definition. That id can't be moved (there's only one, inside the loop-free
626
+ * component body). But each USAGE in the parent is a distinct, movable JSX
627
+ * element. So when `domId` names an element that lives inside a component with
628
+ * multiple usages, map it to the `occurrenceIndex`-th usage (the occurrence index
629
+ * of ANY element in the component equals the instance index = the usage index).
630
+ * Falls through to `domId` for a normal element, or a `.map()`ed single-usage
631
+ * element (one usage → can't split). Reordering elements WITHIN a reused
632
+ * component's definition isn't reachable via drag (edit the component source).
633
+ */
634
+ function resolveUsageId(
635
+ program: AnyNode,
636
+ domId: string,
637
+ occurrenceIndex: number | undefined
638
+ ): string {
639
+ const all = collectElementsFull(program);
640
+ const target = all.find((e) => e.id === domId);
641
+ if (!target?.componentName) return domId;
642
+ const usages = all.filter((e) => e.tag === target.componentName);
643
+ if (usages.length <= 1) return domId; // single usage (or `.map`) — not splittable
644
+ const i =
645
+ typeof occurrenceIndex === 'number' && occurrenceIndex >= 0 && occurrenceIndex < usages.length
646
+ ? occurrenceIndex
647
+ : 0;
648
+ return usages[i]?.id ?? domId;
649
+ }
650
+
651
+ /**
652
+ * Move the element with `data-cd-id === id` to a position relative to the element
653
+ * with `data-cd-id === refId`. Async wrapper: read, apply, atomic write under the
654
+ * per-file mutex — identical persistence to `editAttribute`.
655
+ *
656
+ * `idIndex` / `refIndex` are the DOM occurrence indices — which rendered instance
657
+ * of a reused component the id refers to (0 for a normal, single-render element).
658
+ */
659
+ export async function moveElement(
660
+ canvasAbsPath: string,
661
+ id: string,
662
+ refId: string,
663
+ position: MovePosition,
664
+ idIndex?: number,
665
+ refIndex?: number
666
+ ): Promise<MoveResult> {
667
+ return withLock(canvasAbsPath, async () => {
668
+ const file = Bun.file(canvasAbsPath);
669
+ if (!(await file.exists())) {
670
+ throw new CanvasEditError(`Canvas not found: ${canvasAbsPath}`, {
671
+ canvas: canvasAbsPath,
672
+ id,
673
+ });
674
+ }
675
+ const source = await file.text();
676
+ const next = applyMove(canvasAbsPath, source, id, refId, position, idIndex, refIndex);
677
+ if (next.source === source) return next;
678
+ const tmp = `${canvasAbsPath}.tmp.${Math.random().toString(36).slice(2, 10)}`;
679
+ await Bun.write(tmp, next.source);
680
+ const { rename } = await import('node:fs/promises');
681
+ await rename(tmp, canvasAbsPath);
682
+ return next;
683
+ });
684
+ }
685
+
686
+ /** Pure variant of `moveElement` — exposed for tests. Never mutates disk. */
687
+ export function applyMove(
688
+ canvasAbsPath: string,
689
+ source: string,
690
+ id: string,
691
+ refId: string,
692
+ position: MovePosition,
693
+ idIndex?: number,
694
+ refIndex?: number
695
+ ): MoveResult {
696
+ const parsed = parseSync(canvasAbsPath, source, { sourceType: 'module' });
697
+ if (parsed.errors && parsed.errors.length > 0) {
698
+ const first = parsed.errors[0];
699
+ throw new CanvasEditError(
700
+ `oxc-parser failed on ${canvasAbsPath}: ${first?.message ?? 'unknown'}`,
701
+ { canvas: canvasAbsPath, id }
702
+ );
703
+ }
704
+
705
+ // Map reused-component INSTANCE ids to their parent USAGE elements before the
706
+ // move (a shared internal id isn't movable per-instance; the usage is).
707
+ id = resolveUsageId(parsed.program, id, idIndex);
708
+ refId = resolveUsageId(parsed.program, refId, refIndex);
709
+
710
+ if (id === refId) {
711
+ throw new CanvasEditError('cannot move an element relative to itself', {
712
+ canvas: canvasAbsPath,
713
+ id,
714
+ });
715
+ }
716
+
717
+ const moved = findOpening(parsed.program, id);
718
+ if (!moved) {
719
+ throw new CanvasEditError(`data-cd-id "${id}" not found in ${canvasAbsPath}`, {
720
+ canvas: canvasAbsPath,
721
+ id,
722
+ });
723
+ }
724
+ const ref = findOpening(parsed.program, refId);
725
+ if (!ref) {
726
+ throw new CanvasEditError(`reference data-cd-id "${refId}" not found in ${canvasAbsPath}`, {
727
+ canvas: canvasAbsPath,
728
+ id: refId,
729
+ });
730
+ }
731
+
732
+ const movedEl = moved.element;
733
+ const refEl = ref.element;
734
+ const mStart = movedEl.start as number;
735
+ const mEnd = movedEl.end as number;
736
+ const rStart = refEl.start as number;
737
+ const rEnd = refEl.end as number;
738
+
739
+ // Guardrail: the reference must not live inside the moved element's subtree —
740
+ // that would splice the node into itself.
741
+ if (rStart >= mStart && rEnd <= mEnd) {
742
+ throw new CanvasEditError('cannot move an element into its own subtree', {
743
+ canvas: canvasAbsPath,
744
+ id,
745
+ });
746
+ }
747
+
748
+ const inside = position === 'inside-start' || position === 'inside-end';
749
+ if (inside && (refEl.openingElement?.selfClosing || !refEl.closingElement)) {
750
+ throw new CanvasEditError(
751
+ `target "${refId}" is self-closing — cannot nest an element inside it`,
752
+ { canvas: canvasAbsPath, id: refId }
753
+ );
754
+ }
755
+
756
+ const indentUnit = detectIndentUnit(source);
757
+ const elText = source.slice(mStart, mEnd);
758
+ const movedLine = lineStartInfo(source, mStart);
759
+ const removeStart = movedLine.newlineBefore ? movedLine.indentStart - 1 : movedLine.indentStart;
760
+
761
+ // Resolve the target indent + insertion anchor per position.
762
+ let targetIndent: string;
763
+ let anchor: number;
764
+ let insertText: string;
765
+
766
+ if (position === 'after') {
767
+ targetIndent = lineStartInfo(source, rStart).indent;
768
+ const reText = reindentBlock(elText, movedLine.indent, targetIndent);
769
+ anchor = rEnd;
770
+ insertText = `\n${targetIndent}${reText}`;
771
+ } else if (position === 'before') {
772
+ const rLine = lineStartInfo(source, rStart);
773
+ targetIndent = rLine.indent;
774
+ const reText = reindentBlock(elText, movedLine.indent, targetIndent);
775
+ if (rLine.newlineBefore) {
776
+ anchor = rLine.indentStart - 1;
777
+ insertText = `\n${targetIndent}${reText}`;
778
+ } else {
779
+ anchor = rLine.indentStart;
780
+ insertText = `${targetIndent}${reText}\n`;
781
+ }
782
+ } else if (position === 'inside-start') {
783
+ targetIndent = lineStartInfo(source, rStart).indent + indentUnit;
784
+ const reText = reindentBlock(elText, movedLine.indent, targetIndent);
785
+ anchor = refEl.openingElement.end as number;
786
+ insertText = `\n${targetIndent}${reText}`;
787
+ } else {
788
+ // inside-end — insert as the last child, before the closing tag's own line.
789
+ targetIndent = lineStartInfo(source, rStart).indent + indentUnit;
790
+ const reText = reindentBlock(elText, movedLine.indent, targetIndent);
791
+ const cStart = refEl.closingElement.start as number;
792
+ const cLine = lineStartInfo(source, cStart);
793
+ if (cLine.newlineBefore) {
794
+ anchor = cLine.indentStart - 1;
795
+ insertText = `\n${targetIndent}${reText}`;
796
+ } else {
797
+ anchor = cStart;
798
+ insertText = `${reText}`;
799
+ }
800
+ }
801
+
802
+ const s = new MagicString(source);
803
+ s.remove(removeStart, mEnd);
804
+ s.appendLeft(anchor, insertText);
805
+ const out = s.toString();
806
+
807
+ // Reparse gate: never write source that doesn't parse. This is the catch-all
808
+ // that lets us keep the guardrail set small.
809
+ const check = parseSync(canvasAbsPath, out, { sourceType: 'module' });
810
+ if (check.errors && check.errors.length > 0) {
811
+ const first = check.errors[0];
812
+ throw new CanvasEditError(
813
+ `move would produce invalid source (${first?.message ?? 'parse error'}); aborted`,
814
+ { canvas: canvasAbsPath, id }
815
+ );
816
+ }
817
+
818
+ // Re-settle hints. semanticId survives the move verbatim; movedId is the
819
+ // recomputed positional id (matches the post-reload DOM).
820
+ const semanticId = getStringAttr(movedEl.openingElement, 'data-dc-element');
821
+ let movedId: string | null = null;
822
+ const wanted = normalizeForMatch(reindentBlock(elText, movedLine.indent, targetIndent));
823
+ for (const { id: eid, node } of collectElements(check.program)) {
824
+ if (normalizeForMatch(out.slice(node.start as number, node.end as number)) === wanted) {
825
+ movedId = eid;
826
+ break;
827
+ }
828
+ }
829
+
830
+ return { source: out, delta: out.length - source.length, movedId, semanticId };
831
+ }
832
+
417
833
  // ---------------------------------------------------------------------------
418
834
  // Edit shapes.
419
835
 
@@ -256,6 +256,19 @@ const ENGINE_CSS = `
256
256
  flex: 1;
257
257
  position: relative;
258
258
  overflow: hidden;
259
+ /* Artboard-isolation root. An artboard is a fixed-size design surface — its
260
+ content must NOT react to the studio chrome (panel/sidebar/window resize)
261
+ or to pan/zoom. But @media, vw/vh, and position:fixed in mock CSS resolve
262
+ against the iframe viewport (= the studio's canvas stage), not this box —
263
+ so widening the Assistant panel narrows the iframe and reflows the mock
264
+ even though the world zoom never changed. A transformed ancestor (dc-world)
265
+ already re-roots position:fixed; container-type re-roots the responsive
266
+ path: authors get artboard-relative @container queries + cqw/cqh units that
267
+ stay put. Viewport units still escape by spec (no CSS can re-root them) —
268
+ the smoke lint + design-system-keeper flag those, and the design:new +
269
+ frontend-design guidance steers mocks off them. (NB: no backticks in this
270
+ comment — it lives inside the ENGINE_CSS template literal.) */
271
+ container-type: inline-size;
259
272
  }
260
273
  button.dc-artboard-label {
261
274
  appearance: none;
@@ -539,6 +552,22 @@ function patchCanvasMeta(patch: {
539
552
  })),
540
553
  };
541
554
  }
555
+ // Mirror the patch into the in-iframe meta snapshot. `getInitial` (viewport +
556
+ // layout) reads `window.__canvas_meta__`, which is otherwise populated ONCE at
557
+ // page load — so after a settle, a soft HMR remount (any module edit: text,
558
+ // reorder, agent edit) would re-read the STALE page-load camera and reset the
559
+ // pan/zoom. Keeping the snapshot current means the remount restores the live
560
+ // camera. General fix, not reorder-specific. (DDR-138 dogfood.)
561
+ const w = window as unknown as {
562
+ __canvas_meta__?: {
563
+ viewport?: ViewportState;
564
+ layout?: { artboards: PersistedArtboardLayout[] };
565
+ };
566
+ };
567
+ if (w.__canvas_meta__ && typeof w.__canvas_meta__ === 'object') {
568
+ if (sanitized.viewport) w.__canvas_meta__.viewport = sanitized.viewport;
569
+ if (sanitized.layout) w.__canvas_meta__.layout = sanitized.layout;
570
+ }
542
571
  // Stamp the self-write timestamp BEFORE the fetch so the round-trip
543
572
  // (PATCH → server write → fs:json broadcast → iframe message) lands
544
573
  // safely inside the echo window even on a fast network.
@@ -635,6 +664,20 @@ function fitRectIntoHost(rect: ArtboardRect, hostEl: HTMLElement, pad = 24): Vie
635
664
  return computeFit([rect], hostEl, pad);
636
665
  }
637
666
 
667
+ // RC3 (rca/issue-canvas-hmr-optimistic-update-consistency) — the LIVE camera,
668
+ // hoisted above the `key=attempt` remount boundary. softReload() swaps the
669
+ // canvas module by remounting a fresh React subtree (canvas-comment-mount.tsx,
670
+ // clean-slate `key`), which tears down useViewportController and re-runs the
671
+ // one-shot getInitial(). The snapshot that init used to read
672
+ // (`__canvas_meta__.viewport` via readCanvasMeta) is mirrored only on the
673
+ // 500 ms settle, so an edit landing mid-gesture snapped the camera back to a
674
+ // stale pan/zoom — the reported "zoom reset while an artboard is edited".
675
+ // canvas-lib is an externalized runtime module (canvas-build.ts): the SAME
676
+ // module instance survives the canvas re-import, so the camera parked here
677
+ // outlives the subtree. A full page load clears it — correct, view.json seeds
678
+ // then. Written on every applyViewport (exact — no settle/publish lag).
679
+ let liveViewport: ViewportState | null = null;
680
+
638
681
  export function useViewportController(opts: ViewportControllerOptions): ViewportControllerHandle {
639
682
  const {
640
683
  hostRef,
@@ -761,6 +804,7 @@ export function useViewportController(opts: ViewportControllerOptions): Viewport
761
804
  zoom: clampZoom(next.zoom),
762
805
  };
763
806
  vpRef.current = clamped;
807
+ liveViewport = clamped; // RC3 — survives the soft-reload remount
764
808
  writeTransform(clamped);
765
809
  schedulePublish();
766
810
  scheduleSettle();
@@ -1389,6 +1433,10 @@ function DesignCanvasInner({ children, controls }: DesignCanvasProps) {
1389
1433
  }, []);
1390
1434
 
1391
1435
  const getInitial = useCallback((): ViewportState | null => {
1436
+ // RC3 — after a soft-reload remount, resume the live camera parked at
1437
+ // module scope (exact, no settle lag) before falling back to the meta
1438
+ // snapshot. Null on a fresh page load, so view.json still seeds there.
1439
+ if (liveViewport) return { ...liveViewport };
1392
1440
  const meta = readCanvasMeta();
1393
1441
  const v = meta?.viewport;
1394
1442
  if (v && Number.isFinite(v.x) && Number.isFinite(v.y) && Number.isFinite(v.zoom)) {