@1agh/maude 1.2.0 → 1.3.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.
Files changed (91) 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 +732 -38
  7. package/apps/studio/bin/annotate.mjs +3 -1
  8. package/apps/studio/bin/server-up.sh +17 -2
  9. package/apps/studio/canvas-build.ts +49 -8
  10. package/apps/studio/canvas-edit.ts +468 -7
  11. package/apps/studio/canvas-lib.tsx +82 -18
  12. package/apps/studio/canvas-list-watch.ts +33 -2
  13. package/apps/studio/canvas-notice-message.ts +16 -0
  14. package/apps/studio/canvas-notifications.tsx +19 -0
  15. package/apps/studio/canvas-shell.tsx +57 -6
  16. package/apps/studio/client/app.jsx +783 -245
  17. package/apps/studio/client/apply-edit-request.ts +55 -0
  18. package/apps/studio/client/canvas-url.js +39 -1
  19. package/apps/studio/client/export-center.jsx +111 -61
  20. package/apps/studio/client/github.js +38 -1
  21. package/apps/studio/client/index-loader.ts +76 -0
  22. package/apps/studio/client/panels/CloudBar.jsx +52 -6
  23. package/apps/studio/client/panels/GitPanel.jsx +103 -0
  24. package/apps/studio/client/panels/OnboardingWizard.jsx +36 -7
  25. package/apps/studio/client/panels/RepoBranchSwitcher.jsx +92 -27
  26. package/apps/studio/client/panels/SourceConflictPanel.jsx +166 -0
  27. package/apps/studio/client/panels/SyncPanel.jsx +113 -0
  28. package/apps/studio/client/panels/TeamProjects.jsx +376 -0
  29. package/apps/studio/client/panels/file-deep-link-dialog.jsx +54 -0
  30. package/apps/studio/client/photo-knobs.jsx +3 -0
  31. package/apps/studio/client/share-dialog.jsx +91 -0
  32. package/apps/studio/client/share-link.js +94 -0
  33. package/apps/studio/client/styles/3-shell-maude.css +36 -18
  34. package/apps/studio/client/styles/4-components.css +18 -1
  35. package/apps/studio/client/tree-row-menu.jsx +48 -3
  36. package/apps/studio/client/whats-new.jsx +26 -32
  37. package/apps/studio/cloud/endpoints.ts +132 -0
  38. package/apps/studio/collab/awareness-bridge.ts +65 -14
  39. package/apps/studio/collab/index.ts +4 -0
  40. package/apps/studio/collab/persistence.ts +5 -1
  41. package/apps/studio/collab/registry.ts +9 -3
  42. package/apps/studio/collab/room.ts +13 -2
  43. package/apps/studio/context.ts +59 -0
  44. package/apps/studio/dist/client.bundle.js +1594 -1562
  45. package/apps/studio/dist/comment-mount.js +2 -2
  46. package/apps/studio/dist/runtime/.min-sizes.json +1 -0
  47. package/apps/studio/dist/runtime/sonner.js +1 -0
  48. package/apps/studio/dist/styles.css +1 -1
  49. package/apps/studio/git/accepted-guard.ts +36 -0
  50. package/apps/studio/hmr-broadcast.ts +34 -1
  51. package/apps/studio/http.ts +300 -18
  52. package/apps/studio/inspect.ts +19 -0
  53. package/apps/studio/managed-projects.ts +172 -0
  54. package/apps/studio/notifications.tsx +292 -0
  55. package/apps/studio/runtime-bundle.ts +2 -0
  56. package/apps/studio/server.ts +59 -3
  57. package/apps/studio/sync/accepted-cold-start.ts +225 -0
  58. package/apps/studio/sync/accepted-link.ts +320 -0
  59. package/apps/studio/sync/action-stage.ts +343 -0
  60. package/apps/studio/sync/agent.ts +12 -60
  61. package/apps/studio/sync/cell-file-events.ts +3 -0
  62. package/apps/studio/sync/codec.ts +73 -6
  63. package/apps/studio/sync/ctl-provider.ts +15 -2
  64. package/apps/studio/sync/document-discovery.ts +81 -0
  65. package/apps/studio/sync/file-membership.ts +10 -2
  66. package/apps/studio/sync/file-plane.ts +309 -26
  67. package/apps/studio/sync/index.ts +1174 -21
  68. package/apps/studio/sync/migrate-seed.ts +110 -19
  69. package/apps/studio/sync/poke.ts +4 -2
  70. package/apps/studio/sync/presentation.ts +315 -2
  71. package/apps/studio/sync/projection.ts +820 -23
  72. package/apps/studio/sync/repeated-module.ts +73 -0
  73. package/apps/studio/sync/revision-barrier.ts +129 -0
  74. package/apps/studio/sync/seed-repair.ts +46 -0
  75. package/apps/studio/sync/source-merge.ts +100 -0
  76. package/apps/studio/sync/source-ops.ts +289 -0
  77. package/apps/studio/sync/source-recovery.ts +70 -0
  78. package/apps/studio/sync/source-validation.ts +56 -0
  79. package/apps/studio/sync/status.ts +83 -1
  80. package/apps/studio/sync/transaction-client.ts +486 -0
  81. package/apps/studio/sync/writer-registry.ts +236 -0
  82. package/apps/studio/text-caret.ts +35 -0
  83. package/apps/studio/undo-hud.tsx +9 -87
  84. package/apps/studio/use-canvas-media-drop.tsx +4 -39
  85. package/apps/studio/use-tool-mode.tsx +44 -0
  86. package/apps/studio/whats-new.json +137 -0
  87. package/cli/lib/harness/codex-runtime.mjs +4 -1
  88. package/package.json +9 -8
  89. package/plugins/design/dependencies.json +3 -3
  90. package/plugins/design/templates/_shell.html +95 -7
  91. package/plugins/flow/dependencies.json +3 -3
@@ -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
 
@@ -554,6 +696,7 @@ export function applyTextEdit(
554
696
  const expr = (only as AnyNode).expression;
555
697
  // A single `{'string literal'}` — rewrite the literal in place (DDR-150 P1).
556
698
  if (isStringLit(expr)) {
699
+ if (expr.value === text) return { source, delta: 0 };
557
700
  const s = new MagicString(source);
558
701
  s.overwrite(expr.start as number, expr.end as number, JSON.stringify(text));
559
702
  const out = s.toString();
@@ -585,6 +728,10 @@ export function applyTextEdit(
585
728
  const start = only.start as number;
586
729
  const end = only.end as number;
587
730
  const raw = source.slice(start, end);
731
+ // The text it already shows is not an edit (plan T23): a multi-line JSX text
732
+ // renders as its lines joined by one space, and rewriting it with that same
733
+ // text used to fold the author's line breaks into one line.
734
+ if (jsxRenderedText(raw) === escapeJsxText(text)) return { source, delta: 0 };
588
735
  // Preserve the original indentation/newline framing; swap only the visible text.
589
736
  const lead = /^\s*/.exec(raw)?.[0] ?? '';
590
737
  const trail = /\s*$/.exec(raw)?.[0] ?? '';
@@ -604,6 +751,26 @@ export function applyTextEdit(
604
751
  // the index drifts (`.filter().map()`, reorders). Anything we can't target
605
752
  // unambiguously returns null → the caller throws → routes to /design:edit.
606
753
 
754
+ /**
755
+ * JSX's own whitespace rule for a text child, on the raw source: each line is
756
+ * trimmed (inner lines on both sides), empty lines drop, and the rest join with
757
+ * one space. Entities stay encoded — the caller compares against escaped text.
758
+ */
759
+ function jsxRenderedText(raw: string): string {
760
+ const lines = raw.split(/\r\n|\n|\r/);
761
+ if (lines.length === 1) return raw.trim();
762
+ return lines
763
+ .map((line, i) => {
764
+ let l = line;
765
+ if (i !== 0) l = l.replace(/^[ \t]+/, '');
766
+ if (i !== lines.length - 1) l = l.replace(/[ \t]+$/, '');
767
+ return l;
768
+ })
769
+ .filter((l) => l.length > 0)
770
+ .join(' ')
771
+ .trim();
772
+ }
773
+
607
774
  function isStringLit(n: AnyNode): boolean {
608
775
  return !!(
609
776
  n &&
@@ -3930,6 +4097,86 @@ export function applySetArtboardKind(
3930
4097
  return { source: out };
3931
4098
  }
3932
4099
 
4100
+ /** Longest artboard name the label edit accepts (the chrome truncates long ones). */
4101
+ export const MAX_ARTBOARD_LABEL = 80;
4102
+
4103
+ /**
4104
+ * Rename an artboard — write its DCArtboard `label` string prop (plan T25/L08,
4105
+ * the double-click-the-name gesture). Addressed by the `id` prop like every
4106
+ * other artboard verb; the label is a JSX string attribute, so `editStringAttr`
4107
+ * escapes it. Pure; reparse-gated.
4108
+ */
4109
+ export function applySetArtboardLabel(
4110
+ canvasAbsPath: string,
4111
+ source: string,
4112
+ artboardId: string,
4113
+ label: string
4114
+ ): { source: string } {
4115
+ // Control characters never reach a JSX attribute.
4116
+ const clean = [...label]
4117
+ .filter((c) => c.charCodeAt(0) >= 0x20 && c.charCodeAt(0) !== 0x7f)
4118
+ .join('')
4119
+ .trim();
4120
+ if (!clean || clean.length > MAX_ARTBOARD_LABEL) {
4121
+ throw new CanvasEditError(`artboard name must be 1–${MAX_ARTBOARD_LABEL} characters`, {
4122
+ canvas: canvasAbsPath,
4123
+ id: artboardId,
4124
+ });
4125
+ }
4126
+ const parsed = parseSync(canvasAbsPath, source, { sourceType: 'module' });
4127
+ if (parsed.errors && parsed.errors.length > 0) {
4128
+ throw new CanvasEditError(
4129
+ `oxc-parser failed on ${canvasAbsPath}: ${parsed.errors[0]?.message ?? 'unknown'}`,
4130
+ { canvas: canvasAbsPath, id: artboardId }
4131
+ );
4132
+ }
4133
+ const target = collectJsxByTag(parsed.program, 'DCArtboard').find(
4134
+ (a) => getStringAttr(a.openingElement, 'id') === artboardId
4135
+ );
4136
+ if (!target) {
4137
+ throw new CanvasEditError(`<DCArtboard id="${artboardId}"> not found in ${canvasAbsPath}`, {
4138
+ canvas: canvasAbsPath,
4139
+ id: artboardId,
4140
+ });
4141
+ }
4142
+ const s = new MagicString(source);
4143
+ editStringAttr(s, target.openingElement, 'label', clean, canvasAbsPath, artboardId);
4144
+ const out = s.toString();
4145
+ const check = parseSync(canvasAbsPath, out, { sourceType: 'module' });
4146
+ if (check.errors && check.errors.length > 0) {
4147
+ throw new CanvasEditError(
4148
+ `artboard rename produced invalid source (${check.errors[0]?.message ?? 'parse error'})`,
4149
+ { canvas: canvasAbsPath, id: artboardId }
4150
+ );
4151
+ }
4152
+ return { source: out };
4153
+ }
4154
+
4155
+ /** Rename an artboard on disk (atomic write + cross-process lock). */
4156
+ export async function setArtboardLabel(
4157
+ canvasAbsPath: string,
4158
+ artboardId: string,
4159
+ label: string
4160
+ ): Promise<{ source: string }> {
4161
+ return withLock(canvasAbsPath, async () => {
4162
+ const file = Bun.file(canvasAbsPath);
4163
+ if (!(await file.exists())) {
4164
+ throw new CanvasEditError(`Canvas not found: ${canvasAbsPath}`, {
4165
+ canvas: canvasAbsPath,
4166
+ id: artboardId,
4167
+ });
4168
+ }
4169
+ const source = await file.text();
4170
+ const next = applySetArtboardLabel(canvasAbsPath, source, artboardId, label);
4171
+ if (next.source === source) return next;
4172
+ const tmp = `${canvasAbsPath}.tmp.${Math.random().toString(36).slice(2, 10)}`;
4173
+ await Bun.write(tmp, next.source);
4174
+ const { rename } = await import('node:fs/promises');
4175
+ await rename(tmp, canvasAbsPath);
4176
+ return next;
4177
+ });
4178
+ }
4179
+
3933
4180
  /** Set an artboard's `kind` on disk (atomic write + cross-process lock). */
3934
4181
  export async function setArtboardKind(
3935
4182
  canvasAbsPath: string,
@@ -4649,6 +4896,28 @@ export function assembleCompSource(
4649
4896
  // ---------------------------------------------------------------------------
4650
4897
  // Edit shapes.
4651
4898
 
4899
+ /**
4900
+ * The literal an attribute expression IS — a string, number or boolean (a
4901
+ * negative number arrives as a unary minus) — or null for anything computed.
4902
+ */
4903
+ function literalOfExpression(expr: AnyNode): string | number | boolean | null {
4904
+ if (!expr) return null;
4905
+ if (expr.type === 'Literal' || expr.type === 'StringLiteral') {
4906
+ const v = expr.value;
4907
+ return typeof v === 'string' || typeof v === 'number' || typeof v === 'boolean' ? v : null;
4908
+ }
4909
+ if (expr.type === 'NumericLiteral') return typeof expr.value === 'number' ? expr.value : null;
4910
+ if (expr.type === 'BooleanLiteral') return typeof expr.value === 'boolean' ? expr.value : null;
4911
+ if (
4912
+ expr.type === 'UnaryExpression' &&
4913
+ expr.operator === '-' &&
4914
+ (expr.argument?.type === 'Literal' || expr.argument?.type === 'NumericLiteral') &&
4915
+ typeof expr.argument.value === 'number'
4916
+ )
4917
+ return -expr.argument.value;
4918
+ return null;
4919
+ }
4920
+
4652
4921
  function editStringAttr(
4653
4922
  s: MagicString,
4654
4923
  opening: AnyNode,
@@ -4675,6 +4944,9 @@ function editStringAttr(
4675
4944
  return;
4676
4945
  }
4677
4946
  if (v.type === 'Literal' || v.type === 'StringLiteral') {
4947
+ // Setting the value it already has is not an edit (plan T23): keep the
4948
+ // author's bytes, quote style included.
4949
+ if (typeof v.value === 'string' && v.value === value) return;
4678
4950
  // Replace the whole `"value"` (quotes included) so we control the escaping.
4679
4951
  // The value lands in a JSX *attribute*, where `"` must become `&quot;` and
4680
4952
  // `<`/`>` their entities — NOT JS backslash escaping. `JSON.stringify` would
@@ -4699,6 +4971,25 @@ function editStringAttr(
4699
4971
  { canvas: canvasAbsPath, id }
4700
4972
  );
4701
4973
  }
4974
+ // A NUMBER OR BOOLEAN STAYS ONE (plan T23). `height={400}`,
4975
+ // `durationInFrames={30}`, `aria-pressed={false}` used to come back as
4976
+ // `"400"` / `"30"` / `"false"` — a different type, which a component that
4977
+ // does arithmetic on its prop (every Remotion timing prop) silently
4978
+ // mis-reads — and even an edit to the value it already had rewrote the
4979
+ // bytes. The same value is now no edit; a new number or boolean is
4980
+ // written into the braces it came in.
4981
+ const lit = literalOfExpression(inner);
4982
+ if (lit !== null) {
4983
+ if (String(lit) === value) return;
4984
+ if (typeof lit === 'number' && /^-?(?:\d+\.?\d*|\.\d+)(?:e[+-]?\d+)?$/i.test(value)) {
4985
+ s.overwrite(inner.start, inner.end, value);
4986
+ return;
4987
+ }
4988
+ if (typeof lit === 'boolean' && (value === 'true' || value === 'false')) {
4989
+ s.overwrite(inner.start, inner.end, value);
4990
+ return;
4991
+ }
4992
+ }
4702
4993
  // Replace the whole `{...}` with a plain quoted literal — keeps the
4703
4994
  // resulting JSX readable. Same JSX-attribute escaping as above (NOT
4704
4995
  // `JSON.stringify`, which would JS-escape a `"` and corrupt the source).
@@ -5354,3 +5645,173 @@ if (import.meta.main) {
5354
5645
  process.exit(2);
5355
5646
  }
5356
5647
  }
5648
+
5649
+ // ---------------------------------------------------------------------------
5650
+ // Position-independent element identity — plan T23/T24 (DDR-241).
5651
+ //
5652
+ // `data-cd-id` is positional (component + pre-order index), so a sibling a
5653
+ // teammate inserted earlier in the same component renumbers everything after
5654
+ // it. An edit made through the UI is re-applied onto the project's newer
5655
+ // version when its own proposal lost a race (sync/source-ops.ts); before it
5656
+ // touches anything there, it has to find THE SAME element — by what the
5657
+ // element is, not where it was.
5658
+ //
5659
+ // A print is the enclosing component, the tag, the chain of ancestor tags and
5660
+ // the element's literal attributes (minus the one being edited, minus the
5661
+ // pipeline's own `data-cd-*`). It identifies an element when exactly one
5662
+ // element in the newer source carries it; anything else is ambiguous, and an
5663
+ // ambiguous edit is never guessed at.
5664
+
5665
+ export interface ElementPrint {
5666
+ component: string;
5667
+ tag: string;
5668
+ chain: string[];
5669
+ attrs: string;
5670
+ /** The element's own literal text (bounded) — absent for a text edit, whose text is what changes. */
5671
+ text?: string;
5672
+ }
5673
+
5674
+ function ownText(node: AnyNode): string {
5675
+ let t = '';
5676
+ for (const c of Array.isArray(node?.children) ? node.children : []) {
5677
+ if (c?.type === 'JSXText') t += String(c.value);
5678
+ }
5679
+ return t.replace(/\s+/g, ' ').trim().slice(0, 80);
5680
+ }
5681
+
5682
+ function literalAttrs(opening: AnyNode, ignore: string | null): string {
5683
+ const out: string[] = [];
5684
+ for (const a of Array.isArray(opening?.attributes) ? opening.attributes : []) {
5685
+ if (a?.type !== 'JSXAttribute' || a.name?.type !== 'JSXIdentifier') continue;
5686
+ const name = String(a.name.name);
5687
+ if (name.startsWith('data-cd-') || name === 'style' || name === ignore || name === 'key')
5688
+ continue;
5689
+ const v = a.value;
5690
+ const lit =
5691
+ v?.type === 'Literal' || v?.type === 'StringLiteral'
5692
+ ? String(v.value)
5693
+ : v?.type === 'JSXExpressionContainer' &&
5694
+ (v.expression?.type === 'Literal' || v.expression?.type === 'StringLiteral')
5695
+ ? String(v.expression.value)
5696
+ : v === null || v === undefined
5697
+ ? 'true'
5698
+ : '{expr}';
5699
+ out.push(`${name}=${lit}`);
5700
+ }
5701
+ return out.sort().join('|');
5702
+ }
5703
+
5704
+ function printAll(
5705
+ canvasAbsPath: string,
5706
+ source: string,
5707
+ ignoreAttr: string | null,
5708
+ withText = true
5709
+ ): Array<{ id: string; print: ElementPrint }> {
5710
+ const parsed = parseSync(canvasAbsPath, source, { sourceType: 'module' });
5711
+ if (parsed.errors && parsed.errors.length > 0) return [];
5712
+ const frames: Array<{ componentName: string; jsxIndex: number }> = [
5713
+ { componentName: '', jsxIndex: 0 },
5714
+ ];
5715
+ const chain: string[] = [];
5716
+ const out: Array<{ id: string; print: ElementPrint }> = [];
5717
+ function visit(node: AnyNode): void {
5718
+ if (!node || typeof node !== 'object') return;
5719
+ if (Array.isArray(node)) {
5720
+ for (const c of node) visit(c);
5721
+ return;
5722
+ }
5723
+ if (typeof node.type !== 'string') return;
5724
+ const comp = componentNameOf(node);
5725
+ if (comp !== null) frames.push({ componentName: comp, jsxIndex: 0 });
5726
+ if (node.type === 'JSXElement') {
5727
+ const frame = frames[frames.length - 1] as { componentName: string; jsxIndex: number };
5728
+ const idx = frame.jsxIndex;
5729
+ frame.jsxIndex += 1;
5730
+ const tag = jsxTagName(node) ?? '?';
5731
+ out.push({
5732
+ id: authoredCdId(node.openingElement) ?? computeId(frame.componentName, idx),
5733
+ print: {
5734
+ component: frame.componentName,
5735
+ tag,
5736
+ chain: [...chain],
5737
+ attrs: literalAttrs(node.openingElement, ignoreAttr),
5738
+ ...(withText ? { text: ownText(node) } : {}),
5739
+ },
5740
+ });
5741
+ chain.push(tag);
5742
+ if (node.openingElement) visit(node.openingElement.attributes);
5743
+ visit(node.children);
5744
+ chain.pop();
5745
+ if (comp !== null) frames.pop();
5746
+ return;
5747
+ }
5748
+ for (const k of Object.keys(node)) {
5749
+ if (k === 'loc' || k === 'range' || k === 'start' || k === 'end' || k === 'type') continue;
5750
+ visit(node[k]);
5751
+ }
5752
+ if (comp !== null) frames.pop();
5753
+ }
5754
+ visit(parsed.program);
5755
+ return out;
5756
+ }
5757
+
5758
+ const samePrint = (a: ElementPrint, b: ElementPrint) =>
5759
+ a.component === b.component &&
5760
+ a.tag === b.tag &&
5761
+ a.attrs === b.attrs &&
5762
+ (a.text ?? null) === (b.text ?? null) &&
5763
+ a.chain.length === b.chain.length &&
5764
+ a.chain.every((t, i) => t === b.chain[i]);
5765
+
5766
+ /** The print of the element `id` in `source`, or null. */
5767
+ export function elementPrint(
5768
+ canvasAbsPath: string,
5769
+ source: string,
5770
+ id: string,
5771
+ ignoreAttr: string | null = null,
5772
+ withText = true
5773
+ ): ElementPrint | null {
5774
+ return (
5775
+ printAll(canvasAbsPath, source, ignoreAttr, withText).find((e) => e.id === id)?.print ?? null
5776
+ );
5777
+ }
5778
+
5779
+ /**
5780
+ * The id the element printed `print` has in `source` — `hint` first (the
5781
+ * common case: nothing moved), else the ONE element carrying the print. Null
5782
+ * when it is gone or ambiguous.
5783
+ */
5784
+ export function relocateElement(
5785
+ canvasAbsPath: string,
5786
+ source: string,
5787
+ hint: string,
5788
+ print: ElementPrint,
5789
+ ignoreAttr: string | null = null
5790
+ ): string | null {
5791
+ const all = printAll(canvasAbsPath, source, ignoreAttr, print.text !== undefined);
5792
+ const atHint = all.find((e) => e.id === hint);
5793
+ if (atHint && samePrint(atHint.print, print)) return hint;
5794
+ const matches = all.filter((e) => samePrint(e.print, print));
5795
+ return matches.length === 1 ? (matches[0] as { id: string }).id : null;
5796
+ }
5797
+
5798
+ /** T23 — every JSX element of `source` with its id and print (corpus checks). */
5799
+ export function listElements(
5800
+ canvasAbsPath: string,
5801
+ source: string
5802
+ ): Array<{ id: string; print: ElementPrint }> {
5803
+ return printAll(canvasAbsPath, source, null);
5804
+ }
5805
+
5806
+ /** T23 — how many elements of `source` carry a print no other element shares. */
5807
+ export function printUniqueness(
5808
+ canvasAbsPath: string,
5809
+ source: string
5810
+ ): { elements: number; unique: number } {
5811
+ const all = printAll(canvasAbsPath, source, null);
5812
+ const key = (p: ElementPrint) =>
5813
+ `${p.component}|${p.tag}|${p.chain.join('>')}|${p.attrs}|${p.text ?? ''}`;
5814
+ const counts = new Map<string, number>();
5815
+ for (const e of all) counts.set(key(e.print), (counts.get(key(e.print)) ?? 0) + 1);
5816
+ return { elements: all.length, unique: all.filter((e) => counts.get(key(e.print)) === 1).length };
5817
+ }