@1agh/maude 0.40.0 → 0.42.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 (102) hide show
  1. package/apps/studio/ai-banner.tsx +2 -2
  2. package/apps/studio/annotations-layer.tsx +53 -2
  3. package/apps/studio/api.ts +504 -44
  4. package/apps/studio/artboard-marquee.tsx +1 -1
  5. package/apps/studio/bin/_canvas-rects-playwright.mjs +55 -0
  6. package/apps/studio/bin/_canvas-rects-static.mjs +166 -0
  7. package/apps/studio/bin/_fetch-asset.mjs +556 -0
  8. package/apps/studio/bin/_html-playwright.mjs +26 -4
  9. package/apps/studio/bin/_pdf-playwright.mjs +13 -2
  10. package/apps/studio/bin/_png-playwright.mjs +15 -2
  11. package/apps/studio/bin/_pptx-playwright.mjs +17 -4
  12. package/apps/studio/bin/_screenshot-playwright.mjs +17 -2
  13. package/apps/studio/bin/_svg-playwright.mjs +26 -4
  14. package/apps/studio/bin/annotate.mjs +576 -34
  15. package/apps/studio/bin/canvas-rects.sh +152 -0
  16. package/apps/studio/bin/fetch-asset.sh +34 -0
  17. package/apps/studio/bin/read-annotations.mjs +138 -7
  18. package/apps/studio/bin/screenshot.sh +53 -4
  19. package/apps/studio/bin/smoke.sh +42 -6
  20. package/apps/studio/build.ts +21 -0
  21. package/apps/studio/canvas-comment-mount.tsx +138 -4
  22. package/apps/studio/canvas-edit.ts +744 -11
  23. package/apps/studio/canvas-lib.tsx +219 -2
  24. package/apps/studio/canvas-shell.tsx +487 -20
  25. package/apps/studio/client/app.jsx +1476 -76
  26. package/apps/studio/client/comments-overlay.css +130 -126
  27. package/apps/studio/client/export-center.jsx +426 -0
  28. package/apps/studio/client/github.js +8 -0
  29. package/apps/studio/client/styles/3-shell-maude.css +65 -0
  30. package/apps/studio/client/styles/4-components.css +150 -0
  31. package/apps/studio/comments-overlay.tsx +148 -41
  32. package/apps/studio/config.schema.json +2 -2
  33. package/apps/studio/context-menu.tsx +15 -5
  34. package/apps/studio/contextual-toolbar.tsx +262 -4
  35. package/apps/studio/cursors-overlay.tsx +4 -4
  36. package/apps/studio/dist/client.bundle.js +20 -20
  37. package/apps/studio/dist/comment-mount.js +59 -1
  38. package/apps/studio/dist/styles.css +1 -1
  39. package/apps/studio/dom-selection.ts +127 -1
  40. package/apps/studio/drag-state.ts +24 -0
  41. package/apps/studio/equal-spacing-detector.ts +205 -0
  42. package/apps/studio/export-dialog.tsx +19 -26
  43. package/apps/studio/exporters/_runtime.ts +104 -0
  44. package/apps/studio/exporters/html.ts +12 -20
  45. package/apps/studio/exporters/index.ts +14 -2
  46. package/apps/studio/exporters/jobs.ts +334 -0
  47. package/apps/studio/exporters/pdf.ts +16 -20
  48. package/apps/studio/exporters/png.ts +12 -20
  49. package/apps/studio/exporters/pptx.ts +22 -23
  50. package/apps/studio/exporters/scope.ts +1 -0
  51. package/apps/studio/exporters/svg.ts +14 -22
  52. package/apps/studio/exporters/video.ts +15 -17
  53. package/apps/studio/git/service.ts +3 -1
  54. package/apps/studio/history.ts +47 -1
  55. package/apps/studio/http.ts +368 -50
  56. package/apps/studio/input-router.tsx +12 -0
  57. package/apps/studio/marquee-overlay.tsx +1 -1
  58. package/apps/studio/measure-overlay.tsx +241 -0
  59. package/apps/studio/participants-chrome.tsx +3 -3
  60. package/apps/studio/server.ts +3 -1
  61. package/apps/studio/sizing-mode.ts +117 -0
  62. package/apps/studio/spacing-handles.ts +166 -0
  63. package/apps/studio/test/annotate-write.test.ts +890 -0
  64. package/apps/studio/test/camera-reveal.test.tsx +173 -0
  65. package/apps/studio/test/canvas-edit.test.ts +50 -0
  66. package/apps/studio/test/canvas-origin-gate.test.ts +24 -0
  67. package/apps/studio/test/canvas-rects.test.ts +198 -0
  68. package/apps/studio/test/comments-overlay.test.ts +117 -0
  69. package/apps/studio/test/dns-rebinding-guard.test.ts +27 -0
  70. package/apps/studio/test/dom-selection.test.ts +130 -0
  71. package/apps/studio/test/edit-css-occurrence.test.ts +81 -0
  72. package/apps/studio/test/edit-scope-api.test.ts +115 -0
  73. package/apps/studio/test/element-resize.test.ts +136 -0
  74. package/apps/studio/test/element-structural-api.test.ts +360 -0
  75. package/apps/studio/test/element-structural-edit.test.ts +233 -0
  76. package/apps/studio/test/equal-spacing-detector.test.ts +165 -1
  77. package/apps/studio/test/export-center.test.tsx +287 -0
  78. package/apps/studio/test/export-shim-multi-capture.test.ts +83 -0
  79. package/apps/studio/test/exporters/history.test.ts +32 -3
  80. package/apps/studio/test/exporters/jobs.test.ts +263 -0
  81. package/apps/studio/test/history-rollback.test.ts +26 -0
  82. package/apps/studio/test/knob-props-authored.test.ts +87 -0
  83. package/apps/studio/test/read-annotations.test.ts +154 -0
  84. package/apps/studio/test/sizing-mode.test.ts +102 -0
  85. package/apps/studio/test/spacing-handles.test.ts +138 -0
  86. package/apps/studio/test/specimen-select.test.ts +88 -0
  87. package/apps/studio/test/tool-palette-insert-anchor.test.ts +84 -0
  88. package/apps/studio/test/undo-sequence-byte-compare.test.ts +211 -0
  89. package/apps/studio/tool-palette.tsx +122 -2
  90. package/apps/studio/undo-hud.tsx +2 -2
  91. package/apps/studio/use-element-resize.tsx +732 -0
  92. package/apps/studio/use-keyboard-discipline.tsx +205 -15
  93. package/apps/studio/use-selection-set.tsx +14 -0
  94. package/apps/studio/use-spacing-handles.tsx +388 -0
  95. package/apps/studio/whats-new.json +45 -0
  96. package/apps/studio/ws.ts +6 -0
  97. package/cli/commands/design.mjs +6 -1
  98. package/cli/commands/design.test.mjs +49 -1
  99. package/cli/lib/fetch-asset.test.mjs +213 -0
  100. package/cli/lib/gitignore-block.mjs +1 -0
  101. package/package.json +8 -8
  102. package/plugins/design/dependencies.json +10 -2
@@ -1306,6 +1306,156 @@
1306
1306
  }
1307
1307
  }
1308
1308
 
1309
+ /* ───────── Export notification center — feature-background-export-notification-center ─────────
1310
+ Panel shell reuses .help-modal-backdrop (above); list/item/progress are new
1311
+ — no prior job-row or progress-bar component existed in the client. */
1312
+ .st-export-panel {
1313
+ width: min(560px, 92vw);
1314
+ max-height: 80vh;
1315
+ display: flex;
1316
+ flex-direction: column;
1317
+ background: var(--u-bg-1);
1318
+ color: var(--u-fg-0);
1319
+ border: 1px solid var(--u-bg-3);
1320
+ border-radius: var(--radius-md);
1321
+ box-shadow: 0 12px 48px rgba(0, 0, 0, 0.3);
1322
+ overflow: hidden;
1323
+ }
1324
+ .st-export-panel__body {
1325
+ overflow-y: auto;
1326
+ padding: 8px 14px 16px;
1327
+ }
1328
+ .st-export-list {
1329
+ list-style: none;
1330
+ margin: 0;
1331
+ padding: 0;
1332
+ }
1333
+ .st-export-item {
1334
+ padding: 12px 0;
1335
+ border-bottom: 1px solid var(--u-bg-3);
1336
+ }
1337
+ .st-export-item:last-child {
1338
+ border-bottom: none;
1339
+ }
1340
+ .st-export-item__hd {
1341
+ display: flex;
1342
+ align-items: center;
1343
+ justify-content: space-between;
1344
+ gap: 8px;
1345
+ margin-bottom: 6px;
1346
+ }
1347
+ .st-export-item__label {
1348
+ font-weight: 600;
1349
+ flex: 1;
1350
+ min-width: 0;
1351
+ }
1352
+ .st-export-item__ft {
1353
+ display: flex;
1354
+ align-items: center;
1355
+ justify-content: space-between;
1356
+ gap: 8px;
1357
+ margin-top: 6px;
1358
+ }
1359
+ .st-export-item__filename {
1360
+ color: var(--u-fg-2);
1361
+ font-size: 12px;
1362
+ font-family: var(--font-mono, monospace);
1363
+ overflow: hidden;
1364
+ text-overflow: ellipsis;
1365
+ white-space: nowrap;
1366
+ }
1367
+ .st-export-item__error {
1368
+ color: var(--u-status-error);
1369
+ font-size: 12px;
1370
+ }
1371
+ .st-export-pill {
1372
+ text-transform: uppercase;
1373
+ font-size: 9px;
1374
+ font-weight: 700;
1375
+ letter-spacing: 0.04em;
1376
+ padding: 2px 6px;
1377
+ border-radius: var(--radius-pill, 999px);
1378
+ white-space: nowrap;
1379
+ }
1380
+ .st-export-pill--queued {
1381
+ background: var(--u-bg-3);
1382
+ color: var(--u-fg-2);
1383
+ }
1384
+ .st-export-pill--running {
1385
+ background: var(--u-accent-bg);
1386
+ color: var(--u-accent);
1387
+ }
1388
+ .st-export-pill--done {
1389
+ background: color-mix(in oklab, var(--u-status-success) 18%, transparent);
1390
+ color: var(--u-status-success);
1391
+ }
1392
+ .st-export-pill--failed {
1393
+ background: color-mix(in oklab, var(--u-status-error) 18%, transparent);
1394
+ color: var(--u-status-error);
1395
+ }
1396
+ .st-export-progress {
1397
+ position: relative;
1398
+ height: 4px;
1399
+ border-radius: var(--radius-pill, 999px);
1400
+ background: var(--u-bg-3);
1401
+ overflow: hidden;
1402
+ }
1403
+ .st-export-progress-bar {
1404
+ height: 100%;
1405
+ background: var(--u-accent);
1406
+ transition: width 0.2s ease-out;
1407
+ }
1408
+ .st-export-progress--indeterminate::after {
1409
+ content: '';
1410
+ position: absolute;
1411
+ inset: 0;
1412
+ width: 40%;
1413
+ background: var(--u-accent);
1414
+ border-radius: var(--radius-pill, 999px);
1415
+ animation: st-export-indeterminate 1.2s ease-in-out infinite;
1416
+ }
1417
+ @keyframes st-export-indeterminate {
1418
+ 0% {
1419
+ transform: translateX(-100%);
1420
+ }
1421
+ 100% {
1422
+ transform: translateX(350%);
1423
+ }
1424
+ }
1425
+ @media (prefers-reduced-motion: reduce) {
1426
+ .st-export-progress--indeterminate::after {
1427
+ animation-duration: 1ms;
1428
+ }
1429
+ }
1430
+ .st-toast .st-export-progress {
1431
+ margin-top: var(--space-1, 4px);
1432
+ }
1433
+ /* Busy state for the Save…/Download button — the bytes fetch + (native) the
1434
+ OS save-dialog handoff can take several seconds on a large export; without
1435
+ this the button just goes silent after the click. */
1436
+ .st-btn-spin {
1437
+ display: inline-block;
1438
+ width: 11px;
1439
+ height: 11px;
1440
+ margin-right: 5px;
1441
+ vertical-align: -1px;
1442
+ border: 1.5px solid currentColor;
1443
+ border-top-color: transparent;
1444
+ border-radius: 50%;
1445
+ opacity: 0.75;
1446
+ animation: st-btn-spin 0.7s linear infinite;
1447
+ }
1448
+ @keyframes st-btn-spin {
1449
+ to {
1450
+ transform: rotate(360deg);
1451
+ }
1452
+ }
1453
+ @media (prefers-reduced-motion: reduce) {
1454
+ .st-btn-spin {
1455
+ animation-duration: 1ms;
1456
+ }
1457
+ }
1458
+
1309
1459
  /* ───────── Guided tour (overlay) — feature-in-app-whats-new-tour Phase 3 ───────── */
1310
1460
  .mdcc-tour {
1311
1461
  position: fixed;
@@ -3,9 +3,10 @@
3
3
  * @scope apps/studio/comments-overlay.tsx
4
4
  * @purpose Renders DS-styled comment pins (Phase 6 Task 2), the in-place
5
5
  * composer bubble (Task 3), and the thread popover (Task 4)
6
- * inside the canvas iframe. Sibling to `annotations-layer`
7
- * portals into `.dc-world` so CSS zoom + translate on the world
8
- * plane scale every pin/popover uniformly with the artboards.
6
+ * inside the canvas iframe. Sibling to `annotations-layer`, but
7
+ * NOT portaled into `.dc-world` renders as a screen-coord
8
+ * `position: fixed` layer instead (DDR-034; see "Pin position
9
+ * math" below).
9
10
  *
10
11
  * Data flow (Phase 6 Task 2 — pins only; composer + thread land in Task 3/4):
11
12
  * 1. Shell (`client/app.jsx`) pushes `{ dgn: 'comments-set', comments }`
@@ -23,9 +24,23 @@
23
24
  * gain a `comments-filter` channel in Task 6; until then the overlay always
24
25
  * hides resolved pins. Plan-aligned.
25
26
  *
26
- * Pin position math — see `offsetWithinWorld` below. We walk offsetParent up
27
- * to `.dc-world` to get pre-zoom world coords directly; CSS zoom on the world
28
- * plane then renders the pin at the right scale without us doing zoom math.
27
+ * Pin position math — see `resolveCommentTarget` below. Screen coords come
28
+ * straight from `getBoundingClientRect()` on the live target; CSS zoom on the
29
+ * world plane is already baked into that rect, so no zoom math is needed here.
30
+ *
31
+ * Target resolution + orphan cleanup — a canvas rewrite (`/design:edit`
32
+ * regenerating JSX) renumbers `data-cd-id` (DDR-019's documented AST-position
33
+ * trade-off), which can silently reanchor a comment to the wrong element or to
34
+ * nothing. `resolveCommentTarget` tries the stored selector first, falls back
35
+ * to a structural match via `resolveByDomPath` (dom-selection.ts) when the
36
+ * direct hit is missing or looks like the wrong element (tag mismatch), and —
37
+ * per DDR-034's deferred future-work item — `CommentPin` auto-deletes a
38
+ * comment whose target stays unresolvable past a short grace window.
39
+ *
40
+ * Popup placement — `CommentComposer` / `CommentThread` pick a side
41
+ * (left/right, above/below the anchor) that actually fits the viewport via
42
+ * `placeNearPoint`, instead of always growing down-right (which used to clip
43
+ * off-screen near the canvas edge).
29
44
  *
30
45
  * The legacy vanilla-JS `#dgn-pin-layer` injected by `inspect.ts` is hidden
31
46
  * on mount to avoid double-pins inside TSX canvases. The legacy layer still
@@ -34,6 +49,7 @@
34
49
 
35
50
  import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
36
51
 
52
+ import { resolveByDomPath } from './dom-selection.ts';
37
53
  import { useCollab } from './use-collab.tsx';
38
54
  import { useSelectionSetOptional } from './use-selection-set.tsx';
39
55
 
@@ -92,8 +108,15 @@ export interface OverlayComment {
92
108
  author?: string;
93
109
  thread?: OverlayReply[];
94
110
  mentions?: string[];
95
- // dom_path / tag / classes / html_excerpt unused at overlay layer; kept off
96
- // the type to keep the surface tight.
111
+ /** Target's tag/classes/ancestor-path at creation time unused for
112
+ * rendering, but the structural-fallback ingredients `resolveCommentTarget`
113
+ * reaches for when the stored `selector` no longer identifies the right
114
+ * element (see file header). Absent on legacy comments. */
115
+ tag?: string;
116
+ classes?: string;
117
+ dom_path?: string[];
118
+ // html_excerpt unused at overlay layer; kept off the type to keep the
119
+ // surface tight.
97
120
  }
98
121
 
99
122
  // ─────────────────────────────────────────────────────────────────────────────
@@ -141,32 +164,92 @@ function deriveFile(): string | null {
141
164
  // halo chrome at z-index 5) instead of being portaled into `.dc-world` where
142
165
  // it would lose the stacking battle.
143
166
 
144
- function screenRectFor(
145
- selector: string,
146
- index?: number
147
- ): {
148
- x: number;
149
- y: number;
150
- w: number;
151
- h: number;
152
- } | null {
153
- if (!selector) return null;
167
+ export interface TargetRef {
168
+ selector: string;
169
+ index?: number;
170
+ tag?: string;
171
+ classes?: string;
172
+ dom_path?: string[];
173
+ }
174
+
175
+ /**
176
+ * Resolve a comment (or in-progress selection)'s live target element. Tries
177
+ * the stored `data-cd-id` selector first — cheap, correct in the common case.
178
+ * A tag mismatch against what was captured at creation time is the tell that
179
+ * `data-cd-id` renumbered onto an unrelated element (DDR-019); when that
180
+ * happens, or the selector matches nothing at all, fall back to a structural
181
+ * match via `resolveByDomPath`.
182
+ */
183
+ export function resolveCommentTarget(target: TargetRef): HTMLElement | null {
184
+ if (!target.selector) return null;
154
185
  let el: HTMLElement | null = null;
155
186
  try {
156
187
  // index disambiguates a component repeated within one artboard (querySelector
157
188
  // alone would always grab the first match). Absent/0 → first.
158
- const all = document.querySelectorAll(selector);
159
- const i = index && index > 0 && index < all.length ? index : 0;
189
+ const all = document.querySelectorAll(target.selector);
190
+ const i = target.index && target.index > 0 && target.index < all.length ? target.index : 0;
160
191
  el = (all[i] ?? all[0] ?? null) as HTMLElement | null;
161
192
  } catch {
162
- return null;
193
+ el = null;
194
+ }
195
+ if (el && target.tag && el.tagName.toLowerCase() !== target.tag.toLowerCase()) {
196
+ el = null;
163
197
  }
198
+ if (!el && target.dom_path?.length) {
199
+ const artboardId = target.selector.match(/data-dc-screen="([^"]+)"/)?.[1];
200
+ el = resolveByDomPath(document, {
201
+ artboardId,
202
+ tag: target.tag,
203
+ classes: target.classes,
204
+ dom_path: target.dom_path,
205
+ }) as HTMLElement | null;
206
+ }
207
+ return el;
208
+ }
209
+
210
+ function screenRectFor(target: TargetRef): {
211
+ x: number;
212
+ y: number;
213
+ w: number;
214
+ h: number;
215
+ } | null {
216
+ const el = resolveCommentTarget(target);
164
217
  if (!el?.isConnected) return null;
165
218
  const r = el.getBoundingClientRect();
166
219
  if (r.width === 0 && r.height === 0) return null;
167
220
  return { x: r.left, y: r.top, w: r.width, h: r.height };
168
221
  }
169
222
 
223
+ // ─────────────────────────────────────────────────────────────────────────────
224
+ // Edge-aware popup placement — picks a side (left/right, above/below the
225
+ // anchor point) that actually fits the viewport, falling back to an inward
226
+ // clamp for the rare case where no side fully fits (tiny viewport). Mirrors
227
+ // the pattern context-menu.tsx already uses for the same problem, but flips
228
+ // axes instead of only clamping, per the explicit ask: always open toward
229
+ // whichever side has room, not just "shifted back into view".
230
+ export function placeNearPoint(
231
+ point: { x: number; y: number },
232
+ size: { w: number; h: number }
233
+ ): { x: number; y: number } {
234
+ const margin = 8;
235
+ const vw = typeof window !== 'undefined' ? window.innerWidth : point.x + size.w;
236
+ const vh = typeof window !== 'undefined' ? window.innerHeight : point.y + size.h;
237
+
238
+ let x = point.x;
239
+ if (x + size.w + margin > vw) {
240
+ const flipped = point.x - size.w;
241
+ x = flipped >= margin ? flipped : Math.max(margin, vw - size.w - margin);
242
+ }
243
+
244
+ let y = point.y;
245
+ if (y + size.h + margin > vh) {
246
+ const flipped = point.y - size.h;
247
+ y = flipped >= margin ? flipped : Math.max(margin, vh - size.h - margin);
248
+ }
249
+
250
+ return { x, y };
251
+ }
252
+
170
253
  // ─────────────────────────────────────────────────────────────────────────────
171
254
  // Public component — mounted from canvas-shell.tsx alongside ToolPalette /
172
255
  // AnnotationsLayer / SnapGuideOverlay.
@@ -453,6 +536,7 @@ export function CommentsOverlay(): React.ReactNode {
453
536
  sequence={n}
454
537
  focused={focusedId === c.id}
455
538
  onClick={handlePinClick}
539
+ onOrphaned={handleDelete}
456
540
  />
457
541
  );
458
542
  })}
@@ -736,19 +820,28 @@ function MentionAwareTextarea({
736
820
  // reflow, font load). Falls back to the stored `bounds` when the target is
737
821
  // gone from the DOM.
738
822
 
823
+ // How long a pin is allowed to stay unresolvable (no live target AND no
824
+ // structural-fallback match) before its comment is presumed orphaned and
825
+ // auto-deleted. Long enough to ride out a canvas HMR remount; short enough
826
+ // that a genuinely deleted element's comment doesn't linger.
827
+ const ORPHAN_GRACE_MS = 3000;
828
+
739
829
  function CommentPin({
740
830
  comment,
741
831
  sequence,
742
832
  focused,
743
833
  onClick,
834
+ onOrphaned,
744
835
  }: {
745
836
  comment: OverlayComment;
746
837
  sequence: number;
747
838
  focused: boolean;
748
839
  onClick: (id: string) => void;
840
+ onOrphaned: (id: string) => void;
749
841
  }) {
750
842
  const ref = useRef<HTMLButtonElement | null>(null);
751
843
  const rafRef = useRef<number | null>(null);
844
+ const unresolvedSinceRef = useRef<number | null>(null);
752
845
 
753
846
  useEffect(() => {
754
847
  const tick = () => {
@@ -756,17 +849,27 @@ function CommentPin({
756
849
  const pin = ref.current;
757
850
  if (!pin) return;
758
851
 
759
- // Live screen-coord lookup mirrors SelectionHalos in canvas-shell.tsx.
760
- // Falls back to stored bounds (a screen-coord capture at create time)
761
- // when the target element is gone better than vanishing entirely.
762
- let pos = screenRectFor(comment.selector, comment.index);
763
- if (!pos && comment.bounds) {
764
- pos = {
765
- x: comment.bounds.x,
766
- y: comment.bounds.y,
767
- w: comment.bounds.w,
768
- h: comment.bounds.h,
769
- };
852
+ // Live screen-coord lookup mirrors SelectionHalos in canvas-shell.tsx
853
+ // (resolveCommentTarget tries the stored selector, then a structural
854
+ // fallback). Falls back to stored bounds (a screen-coord capture at
855
+ // create time) when neither resolves — better than vanishing entirely.
856
+ let pos = screenRectFor(comment);
857
+ if (pos) {
858
+ unresolvedSinceRef.current = null;
859
+ } else {
860
+ if (unresolvedSinceRef.current == null) {
861
+ unresolvedSinceRef.current = Date.now();
862
+ } else if (Date.now() - unresolvedSinceRef.current > ORPHAN_GRACE_MS) {
863
+ onOrphaned(comment.id);
864
+ }
865
+ if (comment.bounds) {
866
+ pos = {
867
+ x: comment.bounds.x,
868
+ y: comment.bounds.y,
869
+ w: comment.bounds.w,
870
+ h: comment.bounds.h,
871
+ };
872
+ }
770
873
  }
771
874
  if (!pos) {
772
875
  pin.style.display = 'none';
@@ -786,7 +889,7 @@ function CommentPin({
786
889
  return () => {
787
890
  if (rafRef.current != null) cancelAnimationFrame(rafRef.current);
788
891
  };
789
- }, [comment.selector, comment.bounds, comment.index]);
892
+ }, [comment, onOrphaned]);
790
893
 
791
894
  const author = comment.author?.trim() || 'unknown';
792
895
  const label = `Comment ${sequence} by ${author}`;
@@ -835,15 +938,17 @@ function CommentComposer({
835
938
 
836
939
  // Live anchor — composer tracks the target element via rAF so pan/zoom
837
940
  // while typing keeps the card glued to its anchor. Writes directly to the
838
- // DOM so we don't re-render every frame.
941
+ // DOM so we don't re-render every frame. `placeNearPoint` picks whichever
942
+ // side actually fits the viewport instead of always growing down-right.
839
943
  useEffect(() => {
840
944
  const tick = () => {
841
945
  rafRef.current = null;
842
946
  const node = cardRef.current;
843
947
  if (!node) return;
844
948
  const anchor = computeAnchor(state);
845
- node.style.left = `${Math.round(anchor.x)}px`;
846
- node.style.top = `${Math.round(anchor.y)}px`;
949
+ const placed = placeNearPoint(anchor, { w: node.offsetWidth, h: node.offsetHeight });
950
+ node.style.left = `${Math.round(placed.x)}px`;
951
+ node.style.top = `${Math.round(placed.y)}px`;
847
952
  rafRef.current = requestAnimationFrame(tick);
848
953
  };
849
954
  rafRef.current = requestAnimationFrame(tick);
@@ -960,15 +1065,17 @@ function CommentThread({
960
1065
 
961
1066
  // Live anchor — popover tracks the pin via rAF so it stays glued to its
962
1067
  // target through pan / zoom (FigJam parity). Writing to the dialog style
963
- // directly avoids re-rendering every frame.
1068
+ // directly avoids re-rendering every frame. `placeNearPoint` picks whichever
1069
+ // side actually fits the viewport instead of always growing down-right.
964
1070
  useEffect(() => {
965
1071
  const tick = () => {
966
1072
  rafRef.current = null;
967
1073
  const node = dialogRef.current;
968
1074
  if (!node) return;
969
1075
  const anchor = computeThreadAnchor(comment);
970
- node.style.left = `${Math.round(anchor.x)}px`;
971
- node.style.top = `${Math.round(anchor.y)}px`;
1076
+ const placed = placeNearPoint(anchor, { w: node.offsetWidth, h: node.offsetHeight });
1077
+ node.style.left = `${Math.round(placed.x)}px`;
1078
+ node.style.top = `${Math.round(placed.y)}px`;
972
1079
  rafRef.current = requestAnimationFrame(tick);
973
1080
  };
974
1081
  rafRef.current = requestAnimationFrame(tick);
@@ -1180,7 +1287,7 @@ function computeThreadAnchor(comment: OverlayComment): { x: number; y: number }
1180
1287
  // Resolve target's live screen rect; popover drops below the pin with small
1181
1288
  // breathing room. Stored bounds (capture-time screen coords) are the
1182
1289
  // last-resort fallback for orphaned pins.
1183
- const rect = comment.selector ? screenRectFor(comment.selector, comment.index) : null;
1290
+ const rect = comment.selector ? screenRectFor(comment) : null;
1184
1291
  if (rect) {
1185
1292
  // Pin sits at (rect.right - 12, rect.top - 12). Place popover at the same
1186
1293
  // x for visual continuity, 16px below the top so it clears the pin.
@@ -1205,7 +1312,7 @@ function computeAnchor(state: ComposerState): { x: number; y: number } {
1205
1312
  return { x: state.clientX, y: state.clientY + 8 };
1206
1313
  }
1207
1314
  if (state.selection.selector) {
1208
- const rect = screenRectFor(state.selection.selector, state.selection.index);
1315
+ const rect = screenRectFor(state.selection);
1209
1316
  if (rect) {
1210
1317
  return { x: rect.x, y: rect.y + rect.h + 8 };
1211
1318
  }
@@ -47,7 +47,7 @@
47
47
  "themeDefault": {
48
48
  "type": "string",
49
49
  "enum": ["dark", "light"],
50
- "description": "Default theme attribute (data-theme) for new canvases.",
50
+ "description": "Default theme attribute (data-theme) for new canvases. Can only ever be a single theme name — it is NOT the signal for whether a DS ships more than one theme. For that, check 'designSystems[].themes'.",
51
51
  "default": "dark"
52
52
  },
53
53
  "tokensCssRel": {
@@ -187,7 +187,7 @@
187
187
  "themes": {
188
188
  "type": "array",
189
189
  "items": { "type": "string" },
190
- "description": "Optional list of theme names this DS supports (used by the System view's theme toggle when more than one theme is declared)."
190
+ "description": "Authoritative list of theme names this DS's tokens CSS actually ships (e.g. ['dark','light']). This is the ONLY field that should be checked to decide whether a DS is dual-theme — 'themeDefault' can never hold 'both' (enum is dark|light only), so a single-theme entry MUST still list its one theme (e.g. ['dark']), not omit the field. Bootstrap/re-bootstrap always populates this from the theme-default discovery answer. Drives the System view's theme toggle, design-system-completeness-critic V18/V18c, and /design:new step 9's per-theme reality-check capture."
191
191
  },
192
192
  "newCanvasDir": {
193
193
  "type": "string",
@@ -60,8 +60,14 @@ export interface MenuItem {
60
60
  * chrome`). When present the row opens a submenu on hover / ArrowRight /
61
61
  * click and `onSelect` on THIS item is not invoked — only the chosen leaf's
62
62
  * `onSelect` fires. A disabled leaf carries `disabledHint` for its title.
63
+ *
64
+ * A FUNCTION form is resolved lazily by `MenuItemRow` at render time (not
65
+ * memoized at registry-build time) — for a per-click computed flyout like
66
+ * "Select layer" (every stamped element stacked under the cursor, which
67
+ * depends on `target.clientX/clientY` and can't be known when the static
68
+ * registry is built).
63
69
  */
64
- submenu?: MenuItem[];
70
+ submenu?: MenuItem[] | ((target: ContextTarget) => MenuItem[]);
65
71
  /** Hover/title hint shown when the item is `disabled` (a11y affordance). */
66
72
  disabledHint?: string;
67
73
  onSelect: (target: ContextTarget) => void;
@@ -209,8 +215,8 @@ const MENU_CSS = `
209
215
  }
210
216
  .dc-context-menu .dc-menu-item.is-destructive:hover,
211
217
  .dc-context-menu .dc-menu-item.is-destructive:focus-visible {
212
- background: var(--status-error, #c0392b);
213
- color: var(--accent-fg, #fff);
218
+ background: #c0392b;
219
+ color: #fff;
214
220
  }
215
221
  .dc-context-menu .dc-menu-shortcut {
216
222
  color: var(--maude-chrome-fg-1, rgba(40,30,20,0.55));
@@ -402,7 +408,11 @@ function MenuItemRow({
402
408
  const flyoutRef = useRef<HTMLDivElement>(null);
403
409
  const closeTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
404
410
 
405
- if (!item.submenu || item.submenu.length === 0) {
411
+ // Resolve a function-form submenu against THIS click's target (e.g.
412
+ // "Select layer" computing candidates from `target.clientX/clientY`).
413
+ const submenuItems = typeof item.submenu === 'function' ? item.submenu(target) : item.submenu;
414
+
415
+ if (!submenuItems || submenuItems.length === 0) {
406
416
  return (
407
417
  <button
408
418
  type="button"
@@ -482,7 +492,7 @@ function MenuItemRow({
482
492
  onMouseEnter={cancelClose}
483
493
  onMouseLeave={scheduleClose}
484
494
  >
485
- {item.submenu.map((sub) => (
495
+ {submenuItems.map((sub) => (
486
496
  <button
487
497
  key={sub.id}
488
498
  type="button"