@1agh/maude 0.37.0 → 0.38.1
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 +2 -0
- package/apps/studio/acp/bootstrap-brief.ts +93 -0
- package/apps/studio/acp/bridge.ts +114 -1
- package/apps/studio/acp/index.ts +184 -21
- package/apps/studio/acp/plugin-bootstrap.ts +115 -0
- package/apps/studio/acp/transcript.ts +36 -3
- package/apps/studio/activity.ts +45 -0
- package/apps/studio/api.ts +265 -47
- package/apps/studio/bin/_ensure-browser.mjs +316 -0
- package/apps/studio/bin/ensure-browser.sh +26 -0
- package/apps/studio/bin/screenshot.sh +33 -8
- package/apps/studio/bin/smoke.sh +46 -0
- package/apps/studio/canvas-edit.ts +422 -6
- package/apps/studio/canvas-lib.tsx +48 -0
- package/apps/studio/canvas-shell.tsx +684 -12
- package/apps/studio/client/app.jsx +683 -33
- package/apps/studio/client/panels/ChatPanel.jsx +593 -31
- package/apps/studio/client/panels/acp-runtime.js +227 -70
- package/apps/studio/client/panels/chat-context.js +124 -0
- package/apps/studio/client/panels/slash-commands.js +147 -0
- package/apps/studio/client/styles/3-shell-maude.css +15 -0
- package/apps/studio/client/styles/6-acp-chat.css +244 -0
- package/apps/studio/commands/reorder-command.ts +77 -0
- package/apps/studio/config.schema.json +6 -0
- package/apps/studio/dist/client.bundle.js +25 -53617
- package/apps/studio/dist/styles.css +1 -1
- package/apps/studio/hmr-broadcast.ts +27 -19
- package/apps/studio/http.ts +168 -26
- package/apps/studio/inspect.ts +108 -1
- package/apps/studio/paths.ts +32 -0
- package/apps/studio/readiness.ts +79 -30
- package/apps/studio/server.ts +11 -2
- package/apps/studio/test/acp-activity.test.ts +154 -0
- package/apps/studio/test/acp-ai-activity.test.ts +182 -0
- package/apps/studio/test/acp-bootstrap-brief.test.ts +167 -0
- package/apps/studio/test/acp-commands.test.ts +108 -0
- package/apps/studio/test/acp-origin-gate.test.ts +64 -1
- package/apps/studio/test/acp-plugin-bootstrap.test.ts +89 -0
- package/apps/studio/test/acp-session-plugins.test.ts +132 -0
- package/apps/studio/test/acp-transcript.test.ts +53 -0
- package/apps/studio/test/active-state.test.ts +41 -0
- package/apps/studio/test/canvas-freshness-deps.test.ts +64 -0
- package/apps/studio/test/canvas-origin-gate.test.ts +7 -0
- package/apps/studio/test/canvas-reorder.test.ts +211 -0
- package/apps/studio/test/chat-context.test.ts +129 -0
- package/apps/studio/test/csrf-write-guard.test.ts +24 -0
- package/apps/studio/test/edit-suppress.test.ts +170 -0
- package/apps/studio/test/ensure-browser.test.ts +92 -0
- package/apps/studio/test/fixtures/mock-acp-agent-commands.mjs +44 -0
- package/apps/studio/test/hmr-broadcast.test.ts +44 -0
- package/apps/studio/test/inspect-selections.test.ts +219 -0
- package/apps/studio/test/paths.test.ts +57 -0
- package/apps/studio/test/readiness.test.ts +83 -13
- package/apps/studio/test/reorder-api.test.ts +210 -0
- package/apps/studio/test/slash-commands.test.ts +117 -0
- package/apps/studio/undo-stack.ts +6 -0
- package/apps/studio/whats-new.json +45 -0
- package/apps/studio/ws.ts +37 -0
- package/cli/commands/design.mjs +1 -0
- package/package.json +8 -8
- 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)) {
|