@1agh/maude 1.4.5 → 1.5.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 (106) hide show
  1. package/apps/studio/annotations/ai-read.ts +288 -0
  2. package/apps/studio/annotations/ai-write.ts +533 -0
  3. package/apps/studio/annotations/board-io.ts +94 -0
  4. package/apps/studio/annotations/board-text.ts +45 -0
  5. package/apps/studio/annotations/constants.ts +58 -0
  6. package/apps/studio/annotations/elements/_shared.ts +125 -0
  7. package/apps/studio/annotations/elements/arrow.model.ts +196 -0
  8. package/apps/studio/annotations/elements/media.model.ts +79 -0
  9. package/apps/studio/annotations/elements/pen.model.ts +82 -0
  10. package/apps/studio/annotations/elements/section.model.ts +70 -0
  11. package/apps/studio/annotations/elements/shape.model.ts +141 -0
  12. package/apps/studio/annotations/elements/sticky.model.ts +48 -0
  13. package/apps/studio/annotations/elements/text.model.ts +52 -0
  14. package/apps/studio/annotations/fields.ts +347 -0
  15. package/apps/studio/annotations/fractional-index.ts +234 -0
  16. package/apps/studio/annotations/legacy/mini-dom.ts +207 -0
  17. package/apps/studio/annotations/migrate-boot.ts +172 -0
  18. package/apps/studio/annotations/migrate-cli.ts +37 -0
  19. package/apps/studio/annotations/migrate-v1.ts +383 -0
  20. package/apps/studio/annotations/ops.ts +474 -0
  21. package/apps/studio/annotations/registry.ts +159 -0
  22. package/apps/studio/annotations/replica.ts +264 -0
  23. package/apps/studio/annotations/scene.ts +235 -0
  24. package/apps/studio/annotations/schema.ts +188 -0
  25. package/apps/studio/annotations/types.ts +77 -0
  26. package/apps/studio/annotations/ui/board.ts +126 -0
  27. package/apps/studio/annotations/ui/containment.ts +118 -0
  28. package/apps/studio/annotations/ui/edit-actions.ts +551 -0
  29. package/apps/studio/annotations/ui/editor-channel.ts +62 -0
  30. package/apps/studio/annotations/ui/element-node.tsx +993 -0
  31. package/apps/studio/annotations/ui/pipeline-context.ts +17 -0
  32. package/apps/studio/annotations/ui/pointer-pipeline.ts +150 -0
  33. package/apps/studio/annotations/ui/render-model.ts +264 -0
  34. package/apps/studio/annotations/ui/scene.tsx +63 -0
  35. package/apps/studio/annotations/ui/text-editor.tsx +420 -0
  36. package/apps/studio/annotations/ui/text-session.ts +140 -0
  37. package/apps/studio/annotations/ui/text-style.ts +111 -0
  38. package/apps/studio/annotations/ui/world.ts +47 -0
  39. package/apps/studio/annotations/v1-adapter.ts +428 -0
  40. package/apps/studio/annotations-align.ts +21 -6
  41. package/apps/studio/annotations-bindings.ts +2 -0
  42. package/apps/studio/annotations-context-toolbar.tsx +9 -13
  43. package/apps/studio/annotations-groups.ts +3 -0
  44. package/apps/studio/annotations-layer.tsx +923 -1925
  45. package/apps/studio/annotations-model.ts +97 -4
  46. package/apps/studio/annotations-sync.ts +4 -47
  47. package/apps/studio/api.ts +262 -68
  48. package/apps/studio/bin/_import-figma.mjs +22 -12
  49. package/apps/studio/bin/annotate.mjs +331 -838
  50. package/apps/studio/bin/annotate.sh +4 -4
  51. package/apps/studio/bin/perf.sh +21 -7
  52. package/apps/studio/bin/read-annotations.mjs +184 -666
  53. package/apps/studio/bin/read-annotations.sh +9 -5
  54. package/apps/studio/canvas-artifacts.ts +9 -0
  55. package/apps/studio/canvas-comment-mount.tsx +35 -2
  56. package/apps/studio/canvas-lib.tsx +18 -1
  57. package/apps/studio/canvas-shell.tsx +54 -1
  58. package/apps/studio/client/app.jsx +101 -35
  59. package/apps/studio/client/comments-overlay.css +10 -0
  60. package/apps/studio/client/hmr.mjs +1 -1
  61. package/apps/studio/client/panels/git-grouping.js +2 -2
  62. package/apps/studio/client/tree-expansion.js +217 -0
  63. package/apps/studio/collab/index.ts +58 -5
  64. package/apps/studio/collab/persistence.ts +80 -11
  65. package/apps/studio/collab/registry.ts +63 -26
  66. package/apps/studio/commands/annotation-ops-command.ts +72 -0
  67. package/apps/studio/comment-anchor.ts +116 -0
  68. package/apps/studio/comments-overlay.tsx +78 -35
  69. package/apps/studio/context.ts +1 -0
  70. package/apps/studio/cursors-overlay.tsx +337 -109
  71. package/apps/studio/dist/client.bundle.js +850 -850
  72. package/apps/studio/dist/comment-mount.js +2 -2
  73. package/apps/studio/figma/to-strokes.ts +31 -10
  74. package/apps/studio/git/endpoints.ts +1 -1
  75. package/apps/studio/git/service.ts +1 -1
  76. package/apps/studio/git/watch.ts +1 -1
  77. package/apps/studio/http.ts +90 -15
  78. package/apps/studio/server.ts +16 -0
  79. package/apps/studio/sync/accepted-cold-start.ts +72 -8
  80. package/apps/studio/sync/agent.ts +36 -6
  81. package/apps/studio/sync/codec.ts +95 -40
  82. package/apps/studio/sync/comment-ledger.ts +229 -0
  83. package/apps/studio/sync/file-membership.ts +12 -2
  84. package/apps/studio/sync/file-plane.ts +78 -2
  85. package/apps/studio/sync/index.ts +75 -16
  86. package/apps/studio/sync/journal-client.ts +5 -0
  87. package/apps/studio/sync/limits.ts +7 -2
  88. package/apps/studio/sync/migrate-seed.ts +17 -7
  89. package/apps/studio/sync/projection.ts +7 -2
  90. package/apps/studio/sync/remote-docs.ts +34 -0
  91. package/apps/studio/sync/writer-registry.ts +10 -0
  92. package/apps/studio/text-caret.ts +11 -2
  93. package/apps/studio/tree-state.ts +45 -0
  94. package/apps/studio/undo-stack.ts +2 -2
  95. package/apps/studio/use-annotation-resize.tsx +48 -23
  96. package/apps/studio/use-annotation-selection.tsx +9 -2
  97. package/apps/studio/use-collab.tsx +104 -1
  98. package/apps/studio/use-selection-set.tsx +4 -0
  99. package/apps/studio/whats-new.json +63 -0
  100. package/cli/lib/design-link.mjs +5 -1
  101. package/cli/lib/gitignore-block.mjs +1 -1
  102. package/cli/lib/gitignore-drift.mjs +2 -1
  103. package/package.json +9 -8
  104. package/plugins/design/templates/brief-board.tsx.template +1 -1
  105. package/apps/studio/annotation-edit-base.ts +0 -36
  106. package/apps/studio/commands/annotation-strokes-command.ts +0 -137
@@ -1,11 +1,15 @@
1
1
  #!/usr/bin/env bash
2
- # read-annotations.sh — thin wrapper around read-annotations.mjs (the headless
3
- # SVG → JSON annotation reader). Reached via `maude design read-annotations`
4
- # (DDR-062), never a raw bin path. The reader is zero-dep Node, so we exec under
5
- # `node` (always present — maude itself runs on Node), not Bun.
2
+ # read-annotations.sh — thin wrapper around read-annotations.mjs (the AI read
3
+ # verb over the v2 board). Reached via `maude design read-annotations`
4
+ # (DDR-062), never a raw bin path.
6
5
  #
7
6
  # Usage:
8
- # read-annotations.sh <rel-path> [--root <repo>] [--canvas-state <path>]
7
+ # read-annotations.sh <rel-path> [--in <id>] [--type <t,…>] [--rects <path>] [--graph] [--full]
9
8
  # (see read-annotations.mjs --help for the full contract)
10
9
  SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
10
+ # DDR-242 — the v2 board is read through the TS model: prefer bun (bundled with
11
+ # the app), fall back to node (≥ 22.18 strips TS types natively).
12
+ if command -v bun >/dev/null 2>&1; then
13
+ exec bun "$SCRIPT_DIR/read-annotations.mjs" "$@"
14
+ fi
11
15
  exec node "$SCRIPT_DIR/read-annotations.mjs" "$@"
@@ -113,6 +113,15 @@ export function canvasArtifacts(input: { rel: string; paths: Paths }): CanvasArt
113
113
  });
114
114
  out.push({
115
115
  // Versioned — a real git rename on move, which is correct and expected.
116
+ // DDR-242: the annotations board.
117
+ abs: path.join(paths.designRoot, `${slug}.annotations.json`),
118
+ kind: 'slug-keyed',
119
+ rekey: true,
120
+ versioned: true,
121
+ });
122
+ out.push({
123
+ // A not-yet-migrated v1 sidecar travels with its canvas until the boot
124
+ // migration converts it (annotations/migrate-boot.ts).
116
125
  abs: path.join(paths.designRoot, `${slug}.annotations.svg`),
117
126
  kind: 'slug-keyed',
118
127
  rekey: true,
@@ -39,6 +39,7 @@ import {
39
39
  } from 'react';
40
40
  import { createRoot } from 'react-dom/client';
41
41
 
42
+ import { annotationElement, annotationIdAt, clientToWorld } from './comment-anchor.ts';
42
43
  import { CommentsOverlay } from './comments-overlay.tsx';
43
44
  import { deriveFile, hoverTargetToSelection } from './dom-selection.ts';
44
45
  import {
@@ -394,6 +395,36 @@ function dropComment(
394
395
  file: string | undefined
395
396
  ): void {
396
397
  if (typeof document === 'undefined') return;
398
+ const world = clientToWorld(clientX, clientY) ?? undefined;
399
+
400
+ // A click on an annotation (sticky, shape, stroke, image, link, media card)
401
+ // anchors to it (#134/#136). Checked first: annotations draw above the
402
+ // artboards, so what the user clicked is the annotation, not the element
403
+ // underneath it.
404
+ const annotationId = annotationIdAt(clientX, clientY);
405
+ if (annotationId) {
406
+ const el = annotationElement(annotationId);
407
+ const r = el?.getBoundingClientRect();
408
+ const annotationSel: Selection = {
409
+ file,
410
+ id: undefined,
411
+ selector: '',
412
+ artboardId: null,
413
+ tag: el?.getAttribute('data-tool') ?? 'annotation',
414
+ classes: '',
415
+ text: '',
416
+ dom_path: [],
417
+ bounds: r
418
+ ? { x: r.left, y: r.top, w: r.width, h: r.height }
419
+ : { x: clientX - 12, y: clientY - 12, w: 24, h: 24 },
420
+ html: '',
421
+ annotationId,
422
+ ...(world ? { world } : {}),
423
+ };
424
+ openComposer(annotationSel, clientX, clientY);
425
+ return;
426
+ }
427
+
397
428
  let target = resolveHoverTarget(document, clientX, clientY, { deep: true });
398
429
  if (!target) target = resolveHoverTarget(document, clientX, clientY, { deep: false });
399
430
  // UI-canvas recovery — when both passes bail on a `pointer-events: none`
@@ -428,8 +459,9 @@ function dropComment(
428
459
 
429
460
  if (!target) {
430
461
  // Floating comment — no element anchor, just the click point (e.g. a click
431
- // on empty canvas/specimen dead space). The overlay renders a pin at the
432
- // stored bounds.
462
+ // on empty canvas/specimen dead space). The overlay renders the pin at the
463
+ // WORLD point, so it stays put through pan and zoom; `bounds` is the
464
+ // screen-space fallback for surfaces without a world plane (specimens).
433
465
  const floatingSel: Selection = {
434
466
  file,
435
467
  id: undefined,
@@ -441,6 +473,7 @@ function dropComment(
441
473
  dom_path: [],
442
474
  bounds: { x: clientX - 12, y: clientY - 12, w: 24, h: 24 },
443
475
  html: '',
476
+ ...(world ? { world } : {}),
444
477
  };
445
478
  openComposer(floatingSel, clientX, clientY);
446
479
  return;
@@ -265,6 +265,8 @@ const IS_WEBKIT =
265
265
  type DcPerfCounters = {
266
266
  artboardRenders: number;
267
267
  annotationRenders: number;
268
+ /** Annotation element nodes (re-)rendered — a drag should touch only what moves. */
269
+ annotationNodeRenders?: number;
268
270
  instrumented?: boolean;
269
271
  };
270
272
 
@@ -1428,7 +1430,17 @@ export function useViewportController(opts: ViewportControllerOptions): Viewport
1428
1430
  // receives events natively.
1429
1431
  const onPointerEnter = () => {
1430
1432
  try {
1431
- if (typeof window !== 'undefined' && document.activeElement !== host) {
1433
+ const active = document.activeElement as HTMLElement | null;
1434
+ // Never steal focus from something being TYPED into. An editor that
1435
+ // mounts under a stationary pointer (double-click on a standalone
1436
+ // text) makes the engine fire pointerenter on the host; focusing the
1437
+ // host then blurred the editor, and blur commits — so the editor
1438
+ // closed ~5 ms after it opened and a double-click never entered edit.
1439
+ const typing =
1440
+ !!active &&
1441
+ active !== host &&
1442
+ (active.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(active.tagName));
1443
+ if (typeof window !== 'undefined' && active !== host && !typing) {
1432
1444
  host.focus({ preventScroll: true });
1433
1445
  }
1434
1446
  } catch {
@@ -2869,11 +2881,16 @@ function buildCanvasRectsManifest(): CanvasRectsManifest {
2869
2881
  declare global {
2870
2882
  interface Window {
2871
2883
  __maudeCanvasRects?: () => CanvasRectsManifest;
2884
+ /** The live camera, for layers built in another bundle (the comment
2885
+ * overlay mounts from canvas-comment-mount, which gets its own copy of
2886
+ * this module's state) — see comment-anchor.ts. */
2887
+ __maudeViewport?: () => ViewportState | null;
2872
2888
  }
2873
2889
  }
2874
2890
 
2875
2891
  if (typeof window !== 'undefined') {
2876
2892
  window.__maudeCanvasRects = buildCanvasRectsManifest;
2893
+ window.__maudeViewport = getLiveViewport;
2877
2894
  }
2878
2895
 
2879
2896
  // ─────────────────────────────────────────────────────────────────────────────
@@ -829,6 +829,12 @@ function CanvasCore({
829
829
  return;
830
830
  }
831
831
  if (t.closest('[data-dc-screen]')) return; // any part of an artboard
832
+ // Inside anything editable a double-click selects a WORD — the text
833
+ // editors of stickies / shape labels / texts sit over the world, not over
834
+ // an artboard, so this used to fit() and throw the view to the top-left
835
+ // mid-edit (user report 2026-09-30).
836
+ if (t.closest('[data-annot-editor], input, textarea, select')) return;
837
+ if ((t as HTMLElement).isContentEditable) return;
832
838
  e.preventDefault();
833
839
  controller.fit();
834
840
  };
@@ -1130,9 +1136,52 @@ interface DsThemeSupport {
1130
1136
  }
1131
1137
 
1132
1138
  let _dsThemeSupport: DsThemeSupport | null = null;
1139
+ // A NEGATIVE answer is cached too (#131). The probe below appends DOM to
1140
+ // <body> and reads getComputedStyle for every candidate, which forces a style
1141
+ // recalc of the whole document. Only a positive answer was cached, so on a
1142
+ // canvas whose DS has a single theme it re-ran on EVERY call — and the element
1143
+ // toolbar's menu asks on every render, i.e. every frame of a pan with something
1144
+ // selected. On a 160-board canvas in Safari that alone held pan at ~1.3 fps
1145
+ // (hundreds of ms per probe, two per frame). The reason a negative answer was
1146
+ // not cached — a DS stylesheet that parses after first paint — is honoured
1147
+ // precisely instead: the negative answer holds until a stylesheet is added to
1148
+ // or finishes loading in the document, then the next call probes again.
1149
+ let _dsThemeUnsupported = false;
1150
+ let _dsThemeWatch = false;
1151
+ const _unsupported: DsThemeSupport = { supported: false, wrapperClass: '' };
1152
+
1153
+ function watchStylesheetsOnce(): void {
1154
+ if (_dsThemeWatch || typeof document === 'undefined') return;
1155
+ _dsThemeWatch = true;
1156
+ const invalidate = (): void => {
1157
+ _dsThemeUnsupported = false;
1158
+ };
1159
+ try {
1160
+ new MutationObserver((records) => {
1161
+ for (const r of records) {
1162
+ for (const n of Array.from(r.addedNodes)) {
1163
+ const tag = (n as Element).tagName;
1164
+ if (tag === 'STYLE' || tag === 'LINK') return invalidate();
1165
+ }
1166
+ }
1167
+ }).observe(document.head ?? document.documentElement, { childList: true, subtree: true });
1168
+ // A <link> that was already in the DOM finishing its load (capture: load
1169
+ // does not bubble).
1170
+ document.addEventListener(
1171
+ 'load',
1172
+ (e) => {
1173
+ if ((e.target as Element | null)?.tagName === 'LINK') invalidate();
1174
+ },
1175
+ true
1176
+ );
1177
+ } catch {
1178
+ _dsThemeWatch = false;
1179
+ }
1180
+ }
1133
1181
 
1134
- function detectDsThemeSupport(): DsThemeSupport {
1182
+ export function detectDsThemeSupport(): DsThemeSupport {
1135
1183
  if (_dsThemeSupport) return _dsThemeSupport;
1184
+ if (_dsThemeUnsupported) return _unsupported;
1136
1185
  const fallback: DsThemeSupport = { supported: false, wrapperClass: '' };
1137
1186
  if (typeof document === 'undefined' || !document.body) return fallback;
1138
1187
  try {
@@ -1181,6 +1230,10 @@ function detectDsThemeSupport(): DsThemeSupport {
1181
1230
  // unsupported. Caching that would permanently disable theming; instead
1182
1231
  // re-probe until support is confirmed (or the DS genuinely has one theme).
1183
1232
  if (found.supported) _dsThemeSupport = found;
1233
+ else {
1234
+ _dsThemeUnsupported = true;
1235
+ watchStylesheetsOnce();
1236
+ }
1184
1237
  return found;
1185
1238
  } catch {
1186
1239
  return fallback;
@@ -130,6 +130,16 @@ import { acceptCanvasNotice } from '../canvas-notice-message.ts';
130
130
  import { ExportBadge, ExportPanel, ExportToast, useExportCenter } from './export-center.jsx';
131
131
  import { ReportBugDialog } from './report-bug.jsx';
132
132
  import { useWhatsNew, WhatsNewPanel, WhatsNewToast } from './whats-new.jsx';
133
+ import {
134
+ collectDirPaths,
135
+ isDirOpen,
136
+ pruneDirs,
137
+ remapDirPrefix,
138
+ revealPath,
139
+ setDirOpen,
140
+ toggleSection as toggleSectionState,
141
+ useTreeExpansion,
142
+ } from './tree-expansion.js';
133
143
 
134
144
  const USAGE_TOUR_STORE = 'mdcc-usage-tour-seen';
135
145
  // Phase 29 (E4) — the collab "rychlý kurz" is offered once after onboarding.
@@ -138,7 +148,6 @@ const COLLAB_TOUR_STORE = 'mdcc-collab-tour-seen';
138
148
  const SYSTEM_TAB = '__system__';
139
149
  const THEME_STORE = 'mdcc-theme';
140
150
  const SHOW_HIDDEN_STORE = 'mdcc-show-hidden';
141
- const SECTIONS_STORE = 'mdcc-sections-expanded';
142
151
  // DDR-171 — CSS panel vocabulary mode ('advanced' | 'designer'), read inside
143
152
  // CssKnobs.
144
153
  const CP_MODE_STORE = 'maude-cp-mode';
@@ -330,14 +339,14 @@ function readJsonStore(key, fallback) {
330
339
  }
331
340
  }
332
341
 
333
- // Section default-open: working sections (project + non-DS canvas groups)
334
- // open; meta sections (DS + runtime) collapsed. Users can override per-section
335
- // via the chevron; overrides persist in localStorage.
336
- function sectionDefaultOpen(g) {
337
- if (g.kind === 'runtime') return false;
338
- if (g.label === 'Design system') return false;
339
- return true;
342
+ // Section default-open: EVERY section starts collapsed (issue #124 — "default
343
+ // should be collapsed"). The user's per-section choice is remembered per
344
+ // project by useTreeExpansion (tree-expansion.js), next to the folder state.
345
+ function sectionDefaultOpen() {
346
+ return false;
340
347
  }
348
+ // Stable empty list for the Sidebar while the tree state hydrates.
349
+ const EMPTY_GROUPS = [];
341
350
 
342
351
  // ---------- Utility ----------
343
352
 
@@ -2155,8 +2164,17 @@ function ExportDialog({
2155
2164
  const TREE_INDENT_BASE = 12;
2156
2165
  const TREE_INDENT_STEP = 16;
2157
2166
 
2158
- function DirRow({ name, depth, defaultOpen, children, dirPath, drag, menu }) {
2159
- const [open, setOpen] = useState(defaultOpen);
2167
+ // Issue #124 — disclosure is CONTROLLED: `expansion` is App's tree state (see
2168
+ // tree-expansion.js), so a collapse outlives this row's unmount (dock-tab
2169
+ // switch, section toggle, reload). `forceOpen` = an active search; it shows
2170
+ // the hits without recording anything.
2171
+ function DirRow({ name, depth, children, dirPath, drag, menu, expansion, forceOpen }) {
2172
+ const open = !!forceOpen || !!expansion?.isOpen(dirPath);
2173
+ const setOpen = (v) => {
2174
+ if (forceOpen) return; // search is showing hits — don't record a choice
2175
+ const next = typeof v === 'function' ? v(open) : v;
2176
+ if (next !== open) expansion?.setOpen(dirPath, next);
2177
+ };
2160
2178
  // feature-file-tree-drag-drop-folders (Task 8) — a folder row IS the drop
2161
2179
  // target. `drag` is undefined for groups that can't accept a move (the
2162
2180
  // design-system group) — no handlers attach there, so the browser's default
@@ -2223,8 +2241,13 @@ function DirRow({ name, depth, defaultOpen, children, dirPath, drag, menu }) {
2223
2241
  // Split target: chevron toggles disclosure of the folder's contents; clicking
2224
2242
  // the folder name opens the SystemView focused on that DS (single SystemView
2225
2243
  // for now; the dsName is plumbed through so a future per-DS view can use it).
2226
- function DsFolderRow({ name, dsName, depth, defaultOpen, active, onOpenSystem, children }) {
2227
- const [open, setOpen] = useState(defaultOpen);
2244
+ function DsFolderRow({ name, dsName, dirPath, depth, active, onOpenSystem, children, expansion, forceOpen }) {
2245
+ const open = !!forceOpen || !!expansion?.isOpen(dirPath);
2246
+ const setOpen = (v) => {
2247
+ if (forceOpen) return; // search is showing hits — don't record a choice
2248
+ const next = typeof v === 'function' ? v(open) : v;
2249
+ if (next !== open) expansion?.setOpen(dirPath, next);
2250
+ };
2228
2251
  return (
2229
2252
  <FileTreeItem label={name} expanded={open} selected={active}
2230
2253
  onToggle={() => setOpen(v => !v)} row={
@@ -2613,6 +2636,8 @@ function Tree({
2613
2636
  // feature-file-tree-drag-drop-folders (Task 9) — the shared row-menu
2614
2637
  // instance (useRowMenu()), undefined for groups that can't participate.
2615
2638
  menu,
2639
+ // Issue #124 — App-level folder disclosure `{ isOpen, setOpen }`.
2640
+ expansion,
2616
2641
  }) {
2617
2642
  const dirs = Object.keys(node)
2618
2643
  .filter((k) => k !== '_files')
@@ -2728,6 +2753,7 @@ function Tree({
2728
2753
  dirPath={childPath}
2729
2754
  drag={drag}
2730
2755
  menu={menu}
2756
+ expansion={expansion}
2731
2757
  />
2732
2758
  );
2733
2759
  if (dsMatch && onOpenSystem) {
@@ -2736,8 +2762,10 @@ function Tree({
2736
2762
  key={d}
2737
2763
  name={d}
2738
2764
  dsName={dsMatch.name}
2765
+ dirPath={childPath}
2739
2766
  depth={depth}
2740
- defaultOpen={true}
2767
+ expansion={expansion}
2768
+ forceOpen={hasSearch}
2741
2769
  active={activePath === SYSTEM_TAB && dsMatch.name === activeDsName}
2742
2770
  onOpenSystem={onOpenSystem}
2743
2771
  >
@@ -2750,8 +2778,9 @@ function Tree({
2750
2778
  key={d}
2751
2779
  name={d}
2752
2780
  depth={depth}
2753
- defaultOpen={true}
2754
2781
  dirPath={childPath}
2782
+ expansion={expansion}
2783
+ forceOpen={hasSearch}
2755
2784
  drag={drag}
2756
2785
  menu={drag ? menu : undefined}
2757
2786
  >
@@ -2809,6 +2838,8 @@ function Sidebar({
2809
2838
  showHidden,
2810
2839
  sectionsExpanded,
2811
2840
  onToggleSection,
2841
+ // Issue #124 — App-level folder disclosure `{ isOpen, setOpen }`.
2842
+ treeExpansion,
2812
2843
  onNewBoard,
2813
2844
  onDeleteBoard,
2814
2845
  onRefresh,
@@ -3264,6 +3295,7 @@ function Sidebar({
3264
3295
  dirPath={g.fullPath}
3265
3296
  drag={!isDs && g.kind === 'canvas' && !readOnly ? treeDrag : undefined}
3266
3297
  menu={rowMenu}
3298
+ expansion={treeExpansion}
3267
3299
  />
3268
3300
  ) : (
3269
3301
  <div className="st-tree-empty">{search ? 'No matches.' : 'Empty.'}</div>
@@ -10412,7 +10444,11 @@ function App() {
10412
10444
  const [openMenu, setOpenMenu] = useState(null);
10413
10445
  const [sidebarOpen, setSidebarOpen] = useState(() => readBoolStore(SIDEBAR_STORE, true));
10414
10446
  const [showHidden, setShowHidden] = useState(() => readBoolStore(SHOW_HIDDEN_STORE, false));
10415
- const [sectionsExpanded, setSectionsExpanded] = useState(() => readJsonStore(SECTIONS_STORE, {}));
10447
+ // Issue #124 — folder + section disclosure of the Files panel, remembered
10448
+ // per project on disk (tree-expansion.js). Absent ⇒ closed.
10449
+ const treeExp = useTreeExpansion();
10450
+ const updateTreeExp = treeExp.update;
10451
+ const sectionsExpanded = treeExp.state.sections;
10416
10452
  const [helpOpen, setHelpOpen] = useState(false);
10417
10453
  const [reportBugOpen, setReportBugOpen] = useState(false);
10418
10454
 
@@ -11748,11 +11784,6 @@ function App() {
11748
11784
  localStorage.setItem(SHOW_HIDDEN_STORE, showHidden ? '1' : '0');
11749
11785
  } catch {}
11750
11786
  }, [showHidden]);
11751
- useEffect(() => {
11752
- try {
11753
- localStorage.setItem(SECTIONS_STORE, JSON.stringify(sectionsExpanded));
11754
- } catch {}
11755
- }, [sectionsExpanded]);
11756
11787
  useEffect(() => {
11757
11788
  try {
11758
11789
  localStorage.setItem(MINIMAP_STORE, minimapVisible ? '1' : '0');
@@ -11835,13 +11866,28 @@ function App() {
11835
11866
  } catch {}
11836
11867
  }, [layersMode]);
11837
11868
 
11838
- const toggleSection = useCallback((label, defaultOpen) => {
11839
- setSectionsExpanded((prev) => {
11840
- const cur = prev[label];
11841
- const isOpen = cur === undefined ? defaultOpen : cur;
11842
- return { ...prev, [label]: !isOpen };
11843
- });
11844
- }, []);
11869
+ // #124 — forget folders that no longer exist (deleted, or moved outside
11870
+ // Maude). ONCE per session, on the first loaded tree: pruning on every tree
11871
+ // change would race a move's remap against the reload that follows it.
11872
+ const treePruned = useRef(false);
11873
+ useEffect(() => {
11874
+ if (treePruned.current || !treeExp.ready || !treeLoaded || !groups.length) return;
11875
+ treePruned.current = true;
11876
+ const known = collectDirPaths(groups);
11877
+ updateTreeExp((st) => pruneDirs(st, known));
11878
+ }, [treeExp.ready, treeLoaded, groups, updateTreeExp]);
11879
+
11880
+ const toggleSection = useCallback(
11881
+ (label) => updateTreeExp((st) => toggleSectionState(st, label)),
11882
+ [updateTreeExp]
11883
+ );
11884
+ const treeExpansion = useMemo(
11885
+ () => ({
11886
+ isOpen: (dirPath) => isDirOpen(treeExp.state, dirPath),
11887
+ setOpen: (dirPath, open) => updateTreeExp((st) => setDirOpen(st, dirPath, open)),
11888
+ }),
11889
+ [treeExp.state, updateTreeExp]
11890
+ );
11845
11891
 
11846
11892
  const toggleTheme = useCallback(() => {
11847
11893
  setTheme((t) => (t === 'dark' ? 'light' : 'dark'));
@@ -12354,7 +12400,12 @@ function App() {
12354
12400
  // The `tabs` state stays as a 0-or-1 array so the rest of the plumbing
12355
12401
  // (iframesRef, comments push, WS `tabs` message) doesn't need refactoring.
12356
12402
  // ARTBOARDS slot in the menubar reads `tabs.length` and reports 0 or 1.
12357
- const openTab = useCallback((path) => {
12403
+ // `reveal: false` for opens the USER didn't just ask for (URL restore on
12404
+ // boot, the cloud first-canvas auto-open): nothing expands itself on launch
12405
+ // (#124). Every other open reveals the canvas's row in the Files tree.
12406
+ const openTab = useCallback((path, { reveal = true } = {}) => {
12407
+ if (reveal && path && path !== SYSTEM_TAB)
12408
+ updateTreeExp((st) => revealPath(st, groups, path));
12358
12409
  addressMode.current = 'push';
12359
12410
  setFocusedCommentId(null);
12360
12411
  setPreviewPath(null);
@@ -12372,12 +12423,12 @@ function App() {
12372
12423
  // Canvas-compile skeleton — cleared by the iframe's dgn:'loaded' message,
12373
12424
  // the onLoad fallback timer (legacy .html), or a hard 15s cap.
12374
12425
  if (path !== SYSTEM_TAB) setLoadingPath(path);
12375
- }, [activePath]);
12426
+ }, [activePath, groups, updateTreeExp]);
12376
12427
 
12377
12428
  // Resolve URL identities against the loaded tree, including non-canvas previews.
12378
12429
  const openLinkedFile = useCallback((rel, mode = 'push') => {
12379
12430
  const path = groups.flatMap((g) => g.paths || []).find((p) => normalizeOpenPath(p, cfg.designRel) === rel);
12380
- if (path && CANVAS_EXT_RE.test(path)) openTab(path);
12431
+ if (path && CANVAS_EXT_RE.test(path)) openTab(path, { reveal: false });
12381
12432
  else if (path && previewKind(basename(path))) onPreview(path);
12382
12433
  else {
12383
12434
  setTabs([]);
@@ -12482,7 +12533,7 @@ function App() {
12482
12533
  }
12483
12534
  if (!first) return;
12484
12535
  autoOpened.current = true;
12485
- openTab(first);
12536
+ openTab(first, { reveal: false });
12486
12537
  }, [cfg.cloud, groups, tabs.length, openTab]);
12487
12538
 
12488
12539
  const openSystem = useCallback(
@@ -12849,6 +12900,9 @@ function App() {
12849
12900
  );
12850
12901
  const fromFile = `${designRel}/${j.fromRel}`;
12851
12902
  const toFile = `${designRel}/${j.toRel}`;
12903
+ // #124 — a moved folder keeps its (and its subfolders') open state, and
12904
+ // the destination opens so the moved row is visible.
12905
+ updateTreeExp((st) => revealPath(remapDirPrefix(st, fromFile, toFile), groups, toFile));
12852
12906
  await loadTree();
12853
12907
  setTabs((prev) => prev.map((t) => (t.path === fromFile ? { path: toFile } : t)));
12854
12908
  setActivePath((prev) => (prev === fromFile ? toFile : prev));
@@ -12865,7 +12919,7 @@ function App() {
12865
12919
  return { ok: false, error: 'network error' };
12866
12920
  }
12867
12921
  },
12868
- [loadTree, cfg]
12922
+ [loadTree, cfg, groups, updateTreeExp]
12869
12923
  );
12870
12924
 
12871
12925
  // feature-file-tree-drag-drop-folders (Task 4/9/12) — create a folder under
@@ -12887,6 +12941,8 @@ function App() {
12887
12941
  shellToast(`Could not create folder: ${error}`);
12888
12942
  return { ok: false, error };
12889
12943
  }
12944
+ // #124 — open the parent so the new folder is visible (it starts closed).
12945
+ updateTreeExp((st) => revealPath(st, groups, parentDir, { includeSelf: true }));
12890
12946
  await loadTree();
12891
12947
  return { ok: true, dir: j.dir };
12892
12948
  } catch (e) {
@@ -12895,7 +12951,7 @@ function App() {
12895
12951
  return { ok: false, error };
12896
12952
  }
12897
12953
  },
12898
- [loadTree]
12954
+ [loadTree, groups, updateTreeExp]
12899
12955
  );
12900
12956
 
12901
12957
  // feature-file-tree-drag-drop-folders (dogfood follow-up) — delete a
@@ -12943,12 +12999,16 @@ function App() {
12943
12999
  shellToast(`Could not rename folder: ${j.error || `error ${r.status}`}`);
12944
13000
  return;
12945
13001
  }
13002
+ // #124 — the renamed folder keeps its open state (and its subfolders').
13003
+ const designRel = (cfg?.designRel || cfg?.designRoot || '.design').replace(/^\/+|\/+$/g, '');
13004
+ const toDir = typeof j.toRel === 'string' ? `${designRel}/${j.toRel}` : `${parent}/${name}`;
13005
+ updateTreeExp((st) => remapDirPrefix(st, dirPath, toDir));
12946
13006
  await loadTree();
12947
13007
  } catch (e) {
12948
13008
  shellToast(`Rename failed: ${e instanceof Error ? e.message : 'network error'}`);
12949
13009
  }
12950
13010
  },
12951
- [loadTree]
13011
+ [loadTree, cfg, updateTreeExp]
12952
13012
  );
12953
13013
 
12954
13014
  // Plan T25/L04 — rename a canvas in place (its sidecars follow; an open tab
@@ -13711,6 +13771,9 @@ function App() {
13711
13771
  classes: p.classes,
13712
13772
  bounds: p.bounds,
13713
13773
  html_excerpt: p.html_excerpt,
13774
+ // #134/#136 anchors — shape-checked server-side (api.ts commentsAdd).
13775
+ annotationId: p.annotationId,
13776
+ world: p.world,
13714
13777
  text: txt,
13715
13778
  },
13716
13779
  });
@@ -15671,7 +15734,9 @@ function App() {
15671
15734
  <Sidebar
15672
15735
  cloud={cfg.cloud}
15673
15736
  readOnly={viewerMode}
15674
- groups={groups}
15737
+ // Held back until the remembered disclosure is read, so the first
15738
+ // paint is the user's tree rather than an all-closed flash (#124).
15739
+ groups={treeExp.ready ? groups : EMPTY_GROUPS}
15675
15740
  activePath={activePath}
15676
15741
  previewPath={previewPath}
15677
15742
  activeDsName={activePath === SYSTEM_TAB ? (systemData?.ds?.name ?? null) : null}
@@ -15688,6 +15753,7 @@ function App() {
15688
15753
  showHidden={showHidden}
15689
15754
  sectionsExpanded={sectionsExpanded}
15690
15755
  onToggleSection={toggleSection}
15756
+ treeExpansion={treeExpansion}
15691
15757
  onNewBoard={createBoard}
15692
15758
  onDeleteBoard={deleteBoard}
15693
15759
  onMoveCanvas={moveCanvasReq}
@@ -65,6 +65,16 @@
65
65
  opacity: 0.6;
66
66
  }
67
67
 
68
+ /* Detached — the comment's element or annotation is gone from this canvas. The
69
+ comment is kept at its last known place (never deleted by software, #134/#136).
70
+ A hollow, dashed pin: the state reads from the shape, not from colour alone,
71
+ and the number keeps full contrast. The aria-label says "detached" too. */
72
+ .cm-pin[data-detached='true'] {
73
+ background: var(--maude-chrome-bg-1, #faf6ef);
74
+ color: var(--maude-chrome-fg-0, #2a2520);
75
+ border: 1.5px dashed var(--maude-hud-accent, #6f63ef);
76
+ }
77
+
68
78
  .cm-pin[data-focused='true'] {
69
79
  /* Subtle ring — DS hard-edges discipline: no glow, just a hairline outline. */
70
80
  outline: 1px solid var(--maude-chrome-fg-0, #2a2520);
@@ -69,7 +69,7 @@ function handle(evt) {
69
69
  }
70
70
  if (evt.type === 'fs:json' && evt.file) {
71
71
  // Phase 20 (DDR-049) — external edits to canvas `.meta.json` or
72
- // `.annotations.svg` invalidate the iframe's in-memory undo stack
72
+ // `.annotations.json` invalidate the iframe's in-memory undo stack
73
73
  // (stale before/after snapshots would otherwise restore wrong state).
74
74
  // The iframe's UndoStackProvider self-echo-dampens our own PATCH-es
75
75
  // via window.__maude_last_meta_self_write_at, so we forward
@@ -1,12 +1,12 @@
1
1
  // Pure grouping helpers for the Changes panel (GitPanel.jsx) — framework-free so
2
2
  // they unit-test without a DOM. DDR-115 follow-up: group changed files by parent
3
3
  // canvas + its supporting sidecars (same-stem `.meta.json`, slug-matched
4
- // `.annotations.svg`), mirroring the server's `expandSidecars` notion of "travels
4
+ // `.annotations.json`), mirroring the server's `expandSidecars` notion of "travels
5
5
  // with the canvas" so the visual grouping is honest with what a unit commits.
6
6
 
7
7
  export const CANVAS_RE = /\.(tsx|html)$/i;
8
8
  export const META_RE = /\.meta\.json$/i;
9
- export const ANNOT_RE = /\.annotations\.svg$/i;
9
+ export const ANNOT_RE = /\.annotations\.(?:svg|json)$/i;
10
10
 
11
11
  /** Display name for a file (last path segment, sidecar/canvas extension stripped). */
12
12
  export function baseName(p) {