@1agh/maude 1.3.0 → 1.4.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 (36) hide show
  1. package/apps/studio/annotation-edit-base.ts +36 -0
  2. package/apps/studio/annotations-layer.tsx +13 -2
  3. package/apps/studio/api.ts +51 -2
  4. package/apps/studio/canvas-build.ts +49 -8
  5. package/apps/studio/canvas-edit.ts +77 -0
  6. package/apps/studio/canvas-lib.tsx +25 -10
  7. package/apps/studio/canvas-list-watch.ts +33 -2
  8. package/apps/studio/client/app.jsx +99 -4
  9. package/apps/studio/client/canvas-url.js +39 -1
  10. package/apps/studio/client/panels/TimelinePanel.jsx +75 -62
  11. package/apps/studio/client/photo-knobs.jsx +3 -3
  12. package/apps/studio/client/styles/3-shell-maude.css +1 -0
  13. package/apps/studio/collab/awareness-bridge.ts +65 -14
  14. package/apps/studio/comments-overlay.tsx +86 -2
  15. package/apps/studio/context.ts +93 -1
  16. package/apps/studio/dist/client.bundle.js +619 -619
  17. package/apps/studio/dist/comment-mount.js +2 -2
  18. package/apps/studio/dist/styles.css +1 -1
  19. package/apps/studio/git/accepted-guard.ts +36 -0
  20. package/apps/studio/hmr-broadcast.ts +34 -1
  21. package/apps/studio/http.ts +15 -1
  22. package/apps/studio/managed-projects.ts +22 -0
  23. package/apps/studio/server.ts +3 -0
  24. package/apps/studio/sync/accepted-link.ts +3 -0
  25. package/apps/studio/sync/file-plane.ts +15 -0
  26. package/apps/studio/sync/index.ts +138 -12
  27. package/apps/studio/sync/presentation.ts +30 -0
  28. package/apps/studio/sync/status.ts +5 -1
  29. package/apps/studio/sync/transaction-client.ts +29 -0
  30. package/apps/studio/use-tool-mode.tsx +44 -0
  31. package/apps/studio/whats-new.json +72 -0
  32. package/apps/studio/ws.ts +9 -2
  33. package/package.json +8 -8
  34. package/plugins/design/templates/_shell.html +48 -7
  35. package/plugins/flow/.claude-plugin/config.schema.json +15 -2
  36. package/plugins/flow/README.md +1 -1
@@ -0,0 +1,36 @@
1
+ // Which SVG an annotation edit names as its base (DDR-241 accepted revisions).
2
+ //
3
+ // The project merges an annotation proposal element by element against the
4
+ // text it was derived from. The canvas used to send the CANONICAL
5
+ // re-serialization of the strokes it held — fine while the stored text is
6
+ // canonical too, and silently fatal when it is not: an element written by an
7
+ // older serializer, a hand edit or any other writer then looks changed on BOTH
8
+ // sides, the merge refuses it as a conflict, and the person's edit is reverted
9
+ // without a word (plan T31, L09 arrowheads on a non-canonical arrow).
10
+ //
11
+ // An edit made on exactly the state this canvas last RECEIVED names the text
12
+ // it received — the project then sees "nothing changed underneath", accepts
13
+ // the edit whole, and the stored text becomes canonical as a side effect. Any
14
+ // other state (edits stacked before the echo returns) is this canvas's own
15
+ // canonical output, which is what the project holds after accepting it.
16
+
17
+ import { type Stroke, strokesToSvg } from './annotations-model.ts';
18
+
19
+ export interface ReceivedAnnotations {
20
+ /** The SVG text exactly as the project delivered it. */
21
+ raw: string;
22
+ /** The same state as this client serializes it. */
23
+ canon: string;
24
+ }
25
+
26
+ export function receivedAnnotations(raw: string, strokes: readonly Stroke[]): ReceivedAnnotations {
27
+ return { raw, canon: strokesToSvg(strokes) };
28
+ }
29
+
30
+ export function annotationEditBase(
31
+ before: readonly Stroke[],
32
+ received: ReceivedAnnotations | null
33
+ ): string {
34
+ const canon = strokesToSvg(before);
35
+ return received && received.canon === canon ? received.raw : canon;
36
+ }
@@ -1,3 +1,8 @@
1
+ import {
2
+ annotationEditBase,
3
+ type ReceivedAnnotations,
4
+ receivedAnnotations,
5
+ } from './annotation-edit-base.ts';
1
6
  import { createAnnotationEchoGuard, observeAnnotationSnapshots } from './annotations-sync.ts';
2
7
  /**
3
8
  * @file annotations-layer.tsx — FigJam-style annotation overlay
@@ -1157,6 +1162,9 @@ export function AnnotationsLayer() {
1157
1162
  // peer undo/delete can legitimately return to any previously rendered SVG.
1158
1163
  const annotationEchoRef = useRef(createAnnotationEchoGuard());
1159
1164
  const annotationsChangedRef = useRef(false);
1165
+ // The annotations as the project last delivered them — the base an edit on
1166
+ // exactly that state names (annotation-edit-base.ts).
1167
+ const receivedRef = useRef<ReceivedAnnotations | null>(null);
1160
1168
  useEffect(() => {
1161
1169
  const file = deriveFile();
1162
1170
  fileRef.current = file;
@@ -1169,6 +1177,7 @@ export function AnnotationsLayer() {
1169
1177
  .then((text) => {
1170
1178
  if (cancelled || annotationsChangedRef.current) return;
1171
1179
  const loaded = svgToStrokes(text);
1180
+ receivedRef.current = receivedAnnotations(text, loaded);
1172
1181
  if (loaded.length) {
1173
1182
  setStrokesState(loaded);
1174
1183
  }
@@ -1186,8 +1195,9 @@ export function AnnotationsLayer() {
1186
1195
  if (!collab) return;
1187
1196
  return observeAnnotationSnapshots(collab.doc, (svg, writeId) => {
1188
1197
  annotationsChangedRef.current = true;
1189
- if (annotationEchoRef.current.isOwn(svg, writeId)) return;
1190
1198
  const incoming = svgToStrokes(svg);
1199
+ receivedRef.current = receivedAnnotations(svg, incoming);
1200
+ if (annotationEchoRef.current.isOwn(svg, writeId)) return;
1191
1201
  setStrokesState((prev) => reconcileForeignEcho(prev, incoming));
1192
1202
  });
1193
1203
  }, [collab]);
@@ -1243,7 +1253,7 @@ export function AnnotationsLayer() {
1243
1253
  const persistableBefore = before.some(isEphemeralHref)
1244
1254
  ? before.filter((s) => !isEphemeralHref(s))
1245
1255
  : before;
1246
- const base = strokesToSvg(persistableBefore);
1256
+ const base = annotationEditBase(persistableBefore, receivedRef.current);
1247
1257
  // Phase 8 Task 5 — record the SVG we just authored locally so the
1248
1258
  // server-broadcast echo (PUT → onAnnotationsChanged → syncRoom* →
1249
1259
  // Y.Map.observe) doesn't trigger a redundant setStrokesState.
@@ -4984,6 +4994,7 @@ function AnnotationContextMenu({
4984
4994
  <button
4985
4995
  type="button"
4986
4996
  role="menuitem"
4997
+ data-action={id}
4987
4998
  disabled={opts?.disabled}
4988
4999
  className={`dc-menu-item${opts?.destructive ? ' is-destructive' : ''}`}
4989
5000
  onClick={() => {
@@ -141,7 +141,7 @@ const HIDDEN_OK = new Set(['.ai', '.claude', '.design']);
141
141
  // an explicit enumerated list rather than a broad pattern so it can never
142
142
  // accidentally widen to swallow runtime JSON — findFiles already excludes
143
143
  // `_`-prefixed entries before this list is even consulted.
144
- const PREVIEW_ASSET_EXTS = [
144
+ export const PREVIEW_ASSET_EXTS = [
145
145
  '.svg',
146
146
  '.png',
147
147
  '.jpg',
@@ -171,6 +171,30 @@ const PREVIEW_ASSET_EXTS = [
171
171
  * (`system/<ds>/preview/*.html`) intentionally stay `.html` and travel via
172
172
  * the DS-aware `findFiles()` path below.
173
173
  */
174
+ /**
175
+ * True when `abs` is a directory tree holding nothing but folder placeholders
176
+ * (`.gitkeep`, `.DS_Store`) — what a folder projection leaves before any
177
+ * content arrives. False when it is absent, is not a directory, or holds
178
+ * anything real (a symlink counts as real: never followed, never removed).
179
+ */
180
+ async function onlyFolderPlaceholders(abs: string, depth = 0): Promise<boolean> {
181
+ if (depth > 16) return false;
182
+ let entries: Dirent[];
183
+ try {
184
+ entries = await readdir(abs, { withFileTypes: true });
185
+ } catch {
186
+ return false;
187
+ }
188
+ for (const e of entries) {
189
+ if (e.isDirectory()) {
190
+ if (!(await onlyFolderPlaceholders(path.join(abs, e.name), depth + 1))) return false;
191
+ } else if (!(e.isFile() && (e.name === '.gitkeep' || e.name === '.DS_Store'))) {
192
+ return false;
193
+ }
194
+ }
195
+ return true;
196
+ }
197
+
174
198
  export async function findHtmlFiles(absRoot: string, prefixUnderRepo: string): Promise<string[]> {
175
199
  const out: string[] = [];
176
200
  let entries: Dirent[];
@@ -203,7 +227,7 @@ export async function findHtmlFiles(absRoot: string, prefixUnderRepo: string): P
203
227
  * traversal instead of adding a second full walk of `system/` (the largest
204
228
  * group) just to enumerate directories.
205
229
  */
206
- async function findFiles(
230
+ export async function findFiles(
207
231
  absRoot: string,
208
232
  prefix: string,
209
233
  exts: string[],
@@ -3214,6 +3238,11 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
3214
3238
  };
3215
3239
  }
3216
3240
  await rename(g.abs, toAbs);
3241
+ // A hub's own studio has no watcher to see this rename: announce both
3242
+ // ends, or the journal learns of it only from the walk-import belt and a
3243
+ // teammate waits minutes for a rename that took a second here.
3244
+ announceWritten(rel);
3245
+ announceWritten(toRel);
3217
3246
  ctx.bus.emit('canvas-list-update', {
3218
3247
  action: 'moved',
3219
3248
  rel: toRel,
@@ -3248,6 +3277,7 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
3248
3277
  await mkdir(trashDir, { recursive: true });
3249
3278
  const trashed = path.join(trashDir, path.posix.basename(rel));
3250
3279
  await rename(g.abs, trashed);
3280
+ announceWritten(rel);
3251
3281
  ctx.bus.emit('canvas-list-update', { action: 'removed', rel, slug });
3252
3282
  return {
3253
3283
  ok: true,
@@ -3903,6 +3933,17 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
3903
3933
  }
3904
3934
 
3905
3935
  await mkdir(toDirAbs, { recursive: true });
3936
+ // OUR OWN ACTION MAY ALREADY BE ON THIS DISK. The destination did not exist
3937
+ // when this move was checked; the project accepted the move since, and its
3938
+ // announcement can reach this machine's sync before the answer reaches this
3939
+ // request — which materializes the destination folders (each with its
3940
+ // `.gitkeep`). Renaming onto that non-empty placeholder failed (ENOTEMPTY),
3941
+ // leaving the old folder in this person's tree while every teammate had
3942
+ // the move (plan T31/L02). A destination holding nothing but placeholders
3943
+ // is that projection; it is replaced, never merged into.
3944
+ if (await onlyFolderPlaceholders(toDirFinalAbs)) {
3945
+ await rm(toDirFinalAbs, { recursive: true, force: true });
3946
+ }
3906
3947
  try {
3907
3948
  await rename(dirAbs, toDirFinalAbs);
3908
3949
  } catch (err) {
@@ -3925,6 +3966,14 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
3925
3966
  );
3926
3967
  for (const artifact of canvasArtifacts({ rel: fromRel, paths })) {
3927
3968
  if (artifact.kind !== 'slug-keyed') continue; // primary/siblings already moved with the dir
3969
+ if (artifact.carryOnMove === false) {
3970
+ // The old slug's CRDT cache, stamped "moved away" — the same drop as
3971
+ // moveCanvas; carried, the new document opens retired.
3972
+ await rm(artifact.abs, { force: true }).catch((err) => {
3973
+ console.warn(`[move] could not drop the stale doc cache: ${(err as Error).message}`);
3974
+ });
3975
+ continue;
3976
+ }
3928
3977
  const dest = relocatedName(artifact, fromRel, toRel, paths);
3929
3978
  if (path.resolve(dest) === path.resolve(artifact.abs)) continue;
3930
3979
  try {
@@ -22,7 +22,7 @@
22
22
  // serves the resulting JS at /<designRel>/ui/<slug>.tsx with Content-Type
23
23
  // `application/javascript`.
24
24
 
25
- import { existsSync } from 'node:fs';
25
+ import { existsSync, realpathSync } from 'node:fs';
26
26
  import path from 'node:path';
27
27
 
28
28
  import { canvasLibPath, canvasLibResolver } from './canvas-lib-resolver.ts';
@@ -129,6 +129,10 @@ export async function buildCanvasModule(
129
129
  // needed; RUNTIME_PACKAGES already lists every specifier the importmap covers.
130
130
  const externalSpecifiers = new Set<string>(RUNTIME_PACKAGES);
131
131
  const denials: Array<{ specifier: string; reason: string }> = [];
132
+ // Every stylesheet the canvas pulls in by relative import — they are inlined
133
+ // (below), so the open canvas has no <link> to swap when one changes; the
134
+ // injector records them for the iframe's HMR client (see buildCssInjector).
135
+ const cssSources = new Set<string>();
132
136
 
133
137
  const built = await Bun.build({
134
138
  entrypoints: [canvasAbsPath],
@@ -151,6 +155,17 @@ export async function buildCanvasModule(
151
155
  'process.env.NODE_ENV': '"production"',
152
156
  },
153
157
  plugins: [
158
+ {
159
+ name: 'css-sources',
160
+ setup(builder) {
161
+ builder.onResolve({ filter: /\.css$/ }, (args: { path: string; importer: string }) => {
162
+ if (args.path.startsWith('.') && args.importer) {
163
+ cssSources.add(path.resolve(path.dirname(args.importer), args.path));
164
+ }
165
+ return null; // record only — resolution continues as before
166
+ });
167
+ },
168
+ },
154
169
  // Resolve `@maude/canvas-lib` BEFORE exact-externals — we want the bare
155
170
  // specifier to map to the dev-server-bundled lib, not get marked external.
156
171
  canvasLibResolver(),
@@ -217,7 +232,26 @@ export async function buildCanvasModule(
217
232
  for (const a of cssAssets) css += await a.text();
218
233
  if (css.trim().length > 0) {
219
234
  const slug = canvasAbsPath.split('/').pop() ?? 'canvas';
220
- js = buildCssInjector(slug, css) + js;
235
+ const root = options.designRoot ?? options.restrictImportsTo;
236
+ // Real paths on both sides: the bundler reports importers by their REAL
237
+ // path, and a design root reached through a symlink (macOS `/var` →
238
+ // `/private/var`, where every temp dir lives) made each source look like
239
+ // `../../private/…` — filtered out, so nothing was ever named and the
240
+ // open canvas never restyled.
241
+ const real = (p: string) => {
242
+ try {
243
+ return realpathSync(p);
244
+ } catch {
245
+ return p;
246
+ }
247
+ };
248
+ const realRoot = root ? real(root) : null;
249
+ const sources = realRoot
250
+ ? [...cssSources]
251
+ .map((abs) => path.relative(realRoot, real(abs)).split(path.sep).join('/'))
252
+ .filter((rel) => rel && !rel.startsWith('..'))
253
+ : [];
254
+ js = buildCssInjector(slug, css, sources) + js;
221
255
  }
222
256
  }
223
257
 
@@ -227,16 +261,23 @@ export async function buildCanvasModule(
227
261
 
228
262
  /**
229
263
  * Synthesize a module-init prologue that creates a `<style data-canvas-css>`
230
- * tag with the bundled CSS text. Idempotent per-slug — duplicate mounts of
231
- * the same canvas don't re-inject. Run at top-level so it executes before
232
- * the React component does its first render.
264
+ * tag with the bundled CSS text. One tag per slug — a duplicate mount reuses
265
+ * it, and a RE-IMPORT (the iframe's soft reload after a stylesheet changed)
266
+ * replaces its text, so a changed import actually reaches the page. Run at
267
+ * top-level so it executes before the React component does its first render.
268
+ *
269
+ * `sources` — the design-root-relative stylesheets inlined here — lands on the
270
+ * tag as `data-canvas-css-sources` (`|`-separated): the iframe's HMR client
271
+ * reloads when one of them changes, since there is no <link> to swap (plan
272
+ * T31/L16: a canvas importing `../system/<ds>/tokens.css` never restyled).
233
273
  */
234
- function buildCssInjector(slug: string, css: string): string {
274
+ function buildCssInjector(slug: string, css: string, sources: readonly string[] = []): string {
235
275
  // JSON-encode the CSS text so we don't need to worry about backticks,
236
276
  // backslashes, or embedded `</style>` (which would break a raw template).
237
277
  const enc = JSON.stringify(css);
238
- const id = `canvas-css-${slug.replace(/[^a-zA-Z0-9-]/g, '_')}`;
239
- return `// canvas-build: inject bundled sibling CSS so the canvas is self-contained.\n(function(){if(typeof document==="undefined")return;if(document.getElementById(${JSON.stringify(id)}))return;var s=document.createElement("style");s.id=${JSON.stringify(id)};s.dataset.canvasCss="bundled";s.textContent=${enc};document.head.appendChild(s);})();\n`;
278
+ const id = JSON.stringify(`canvas-css-${slug.replace(/[^a-zA-Z0-9-]/g, '_')}`);
279
+ const src = JSON.stringify(sources.join('|'));
280
+ return `// canvas-build: inject bundled sibling CSS so the canvas is self-contained.\n(function(){if(typeof document==="undefined")return;var s=document.getElementById(${id});if(!s){s=document.createElement("style");s.id=${id};s.dataset.canvasCss="bundled";document.head.appendChild(s);}s.dataset.canvasCssSources=${src};if(s.textContent!==${enc})s.textContent=${enc};})();\n`;
240
281
  }
241
282
 
242
283
  /**
@@ -696,6 +696,7 @@ export function applyTextEdit(
696
696
  const expr = (only as AnyNode).expression;
697
697
  // A single `{'string literal'}` — rewrite the literal in place (DDR-150 P1).
698
698
  if (isStringLit(expr)) {
699
+ if (expr.value === text) return { source, delta: 0 };
699
700
  const s = new MagicString(source);
700
701
  s.overwrite(expr.start as number, expr.end as number, JSON.stringify(text));
701
702
  const out = s.toString();
@@ -727,6 +728,10 @@ export function applyTextEdit(
727
728
  const start = only.start as number;
728
729
  const end = only.end as number;
729
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 };
730
735
  // Preserve the original indentation/newline framing; swap only the visible text.
731
736
  const lead = /^\s*/.exec(raw)?.[0] ?? '';
732
737
  const trail = /\s*$/.exec(raw)?.[0] ?? '';
@@ -746,6 +751,26 @@ export function applyTextEdit(
746
751
  // the index drifts (`.filter().map()`, reorders). Anything we can't target
747
752
  // unambiguously returns null → the caller throws → routes to /design:edit.
748
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
+
749
774
  function isStringLit(n: AnyNode): boolean {
750
775
  return !!(
751
776
  n &&
@@ -4871,6 +4896,28 @@ export function assembleCompSource(
4871
4896
  // ---------------------------------------------------------------------------
4872
4897
  // Edit shapes.
4873
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
+
4874
4921
  function editStringAttr(
4875
4922
  s: MagicString,
4876
4923
  opening: AnyNode,
@@ -4897,6 +4944,9 @@ function editStringAttr(
4897
4944
  return;
4898
4945
  }
4899
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;
4900
4950
  // Replace the whole `"value"` (quotes included) so we control the escaping.
4901
4951
  // The value lands in a JSX *attribute*, where `"` must become `&quot;` and
4902
4952
  // `<`/`>` their entities — NOT JS backslash escaping. `JSON.stringify` would
@@ -4921,6 +4971,25 @@ function editStringAttr(
4921
4971
  { canvas: canvasAbsPath, id }
4922
4972
  );
4923
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
+ }
4924
4993
  // Replace the whole `{...}` with a plain quoted literal — keeps the
4925
4994
  // resulting JSX readable. Same JSX-attribute escaping as above (NOT
4926
4995
  // `JSON.stringify`, which would JS-escape a `"` and corrupt the source).
@@ -5726,6 +5795,14 @@ export function relocateElement(
5726
5795
  return matches.length === 1 ? (matches[0] as { id: string }).id : null;
5727
5796
  }
5728
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
+
5729
5806
  /** T23 — how many elements of `source` carry a print no other element shares. */
5730
5807
  export function printUniqueness(
5731
5808
  canvasAbsPath: string,
@@ -3138,32 +3138,47 @@ const ASSET_REF_RE = /assets\/[0-9a-f]{8}\.[a-z0-9]+/i;
3138
3138
  * baked, the element's `src`/`href` is a `data:` URL that no longer contains
3139
3139
  * the original asset path, so the substring match alone would lose track of
3140
3140
  * it on the very next edit. */
3141
- function findPhotoEl(asset: string): Element | null {
3141
+ function photoRefOf(el: Element): string {
3142
+ return el.getAttribute('src') || el.getAttribute('href') || el.getAttribute('xlink:href') || '';
3143
+ }
3144
+
3145
+ /**
3146
+ * Is the element's `data-photo-asset` tag still true? The tag is this bridge's
3147
+ * memory of a node it baked — while baked, the node shows a `data:` URL; left
3148
+ * alone, it shows the asset. Once the canvas points the node at something
3149
+ * else (Replace… on the image), the memory is stale: the node is no longer
3150
+ * this photo, and acting on the tag would put the old picture back over the
3151
+ * new one on the machine that made the replace (plan T31/L12).
3152
+ */
3153
+ function photoTagCurrent(el: Element, tagged: string): boolean {
3154
+ const ref = photoRefOf(el);
3155
+ return ref.startsWith('data:') || ref.includes(tagged);
3156
+ }
3157
+
3158
+ export function findPhotoEl(asset: string): Element | null {
3142
3159
  if (typeof document === 'undefined') return null;
3143
3160
  // Scoped to img/image (not a bare `[data-photo-asset]` attribute selector) —
3144
3161
  // the canvas iframe is untrusted content (DDR-054); an authored canvas could
3145
3162
  // otherwise stamp the tag on an arbitrary element to redirect a bake.
3146
3163
  for (const n of document.querySelectorAll('img[data-photo-asset], image[data-photo-asset]')) {
3147
- if (n.getAttribute('data-photo-asset') === asset) return n;
3164
+ if (n.getAttribute('data-photo-asset') !== asset) continue;
3165
+ if (photoTagCurrent(n, asset)) return n;
3166
+ n.removeAttribute('data-photo-asset');
3148
3167
  }
3149
3168
  for (const n of document.querySelectorAll('img, image')) {
3150
- const src =
3151
- n.getAttribute('src') || n.getAttribute('href') || n.getAttribute('xlink:href') || '';
3152
- if (src.includes(asset)) return n;
3169
+ if (photoRefOf(n).includes(asset)) return n;
3153
3170
  }
3154
3171
  return null;
3155
3172
  }
3156
3173
 
3157
- function extractAssetRef(el: Element): string | null {
3174
+ export function extractAssetRef(el: Element): string | null {
3158
3175
  // Only trust `data-photo-asset` when it actually has the `assets/<sha8>.<ext>`
3159
3176
  // shape — the tag is attacker-controllable (untrusted canvas content,
3160
3177
  // DDR-054), and an unshaped value would otherwise ride unbounded into
3161
3178
  // `_active.json`/the WS broadcast via inspect.ts's `enrich()`.
3162
3179
  const tagged = el.getAttribute('data-photo-asset');
3163
- if (tagged && ASSET_REF_RE.test(tagged)) return tagged;
3164
- const ref =
3165
- el.getAttribute('src') || el.getAttribute('href') || el.getAttribute('xlink:href') || '';
3166
- return ref.match(ASSET_REF_RE)?.[0] ?? null;
3180
+ if (tagged && ASSET_REF_RE.test(tagged) && photoTagCurrent(el, tagged)) return tagged;
3181
+ return photoRefOf(el).match(ASSET_REF_RE)?.[0] ?? null;
3167
3182
  }
3168
3183
 
3169
3184
  function setPhotoElSrc(el: Element, url: string): void {
@@ -25,9 +25,28 @@
25
25
 
26
26
  import path from 'node:path';
27
27
 
28
- import { canvasSlugFromRel, findHtmlFiles, SKIP_DIRS } from './api.ts';
28
+ import {
29
+ canvasSlugFromRel,
30
+ findFiles,
31
+ findHtmlFiles,
32
+ PREVIEW_ASSET_EXTS,
33
+ SKIP_DIRS,
34
+ } from './api.ts';
29
35
  import type { Context } from './context.ts';
30
36
 
37
+ /**
38
+ * Supporting files the tree lists beside the canvases (images, fonts, media —
39
+ * the same `PREVIEW_ASSET_EXTS` `/_index-data` walks). A new one written from
40
+ * outside the app — an agent's export, a teammate's image arriving through
41
+ * sync — is a tree change too; without it the row stayed missing until
42
+ * something else reloaded the tree (plan T17/L03). Sidecars and stylesheets are
43
+ * deliberately NOT here: they are rewritten on every edit, and the tree nests
44
+ * them under their canvas anyway.
45
+ */
46
+ function isListedFile(low: string): boolean {
47
+ return PREVIEW_ASSET_EXTS.some((x) => low.endsWith(x));
48
+ }
49
+
31
50
  /** Burst-collapse window. A new canvas writes both `.tsx` and `.meta.json`; a
32
51
  * git checkout touches many files at once — one snapshot per quiet window. */
33
52
  export const CANVAS_LIST_DEBOUNCE_MS = 150;
@@ -59,7 +78,8 @@ export function isCanvasCandidate(rel: string, groupPaths: string[]): boolean {
59
78
  // canvases costs one recompute and emits nothing.
60
79
  const last = p.slice(p.lastIndexOf('/') + 1);
61
80
  const looksLikeDir = !last.includes('.');
62
- if (!looksLikeDir && !low.endsWith('.tsx') && !low.endsWith('.html')) return false;
81
+ if (!looksLikeDir && !low.endsWith('.tsx') && !low.endsWith('.html') && !isListedFile(low))
82
+ return false;
63
83
  const segs = p.split('/');
64
84
  for (const s of segs) {
65
85
  if (!s) return false;
@@ -123,6 +143,11 @@ export function createCanvasListWatch(
123
143
  continue; // group dir missing / unreadable — treat as empty
124
144
  }
125
145
  for (const f of files) out.add(f);
146
+ try {
147
+ for (const f of await findFiles(groupAbs, groupRel, PREVIEW_ASSET_EXTS)) out.add(f);
148
+ } catch {
149
+ /* unreadable — the canvases above still diff */
150
+ }
126
151
  }
127
152
  return out;
128
153
  }
@@ -137,6 +162,12 @@ export function createCanvasListWatch(
137
162
  // render/open/build sink without re-validating against /_index-data.
138
163
  const prefix = `${ctx.paths.designRel.replace(/^\/+|\/+$/g, '')}/`;
139
164
  const relOut = rel.startsWith(prefix) ? rel.slice(prefix.length) : rel;
165
+ if (isListedFile(rel.toLowerCase())) {
166
+ // Not a canvas: its own action and no slug, so no consumer mistakes an
167
+ // image for a canvas to close, retarget or adopt.
168
+ ctx.bus.emit('canvas-list-update', { action: `file-${action}`, rel: relOut });
169
+ return;
170
+ }
140
171
  ctx.bus.emit('canvas-list-update', {
141
172
  action,
142
173
  rel: relOut,