@1agh/maude 0.39.1 → 0.41.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.
- package/README.md +1 -1
- package/apps/studio/ai-banner.tsx +2 -2
- package/apps/studio/annotations-context-toolbar.tsx +44 -1
- package/apps/studio/annotations-layer.tsx +306 -5
- package/apps/studio/annotations-model.ts +107 -6
- package/apps/studio/api.ts +1316 -67
- package/apps/studio/artboard-marquee.tsx +1 -1
- package/apps/studio/bin/_canvas-rects-playwright.mjs +55 -0
- package/apps/studio/bin/_canvas-rects-static.mjs +166 -0
- package/apps/studio/bin/_fetch-asset.mjs +556 -0
- package/apps/studio/bin/_html-playwright.mjs +9 -1
- package/apps/studio/bin/_pdf-playwright.mjs +8 -1
- package/apps/studio/bin/_png-playwright.mjs +8 -1
- package/apps/studio/bin/_pw-launch.mjs +54 -0
- package/apps/studio/bin/_svg-playwright.mjs +8 -1
- package/apps/studio/bin/_video-playwright.mjs +452 -0
- package/apps/studio/bin/annotate.mjs +576 -34
- package/apps/studio/bin/canvas-rects.sh +152 -0
- package/apps/studio/bin/fetch-asset.sh +34 -0
- package/apps/studio/bin/prep.sh +8 -1
- package/apps/studio/bin/read-annotations.mjs +138 -7
- package/apps/studio/bin/smoke.sh +42 -6
- package/apps/studio/build.ts +21 -0
- package/apps/studio/canvas-comment-mount.tsx +138 -4
- package/apps/studio/canvas-edit.ts +2625 -111
- package/apps/studio/canvas-lib.tsx +238 -2
- package/apps/studio/canvas-list-watch.ts +6 -2
- package/apps/studio/canvas-shell.tsx +514 -20
- package/apps/studio/client/app.jsx +2480 -52
- package/apps/studio/client/comments-overlay.css +130 -126
- package/apps/studio/client/github.js +15 -0
- package/apps/studio/client/panels/TimelinePanel.jsx +860 -0
- package/apps/studio/client/panels/timeline-parse.js +229 -0
- package/apps/studio/client/panels/timeline-snap.js +55 -0
- package/apps/studio/client/styles/3-shell-maude.css +155 -0
- package/apps/studio/comments-overlay.tsx +148 -41
- package/apps/studio/config.schema.json +14 -0
- package/apps/studio/context-menu.tsx +16 -6
- package/apps/studio/context.ts +113 -1
- package/apps/studio/contextual-toolbar.tsx +262 -4
- package/apps/studio/cursors-overlay.tsx +4 -4
- package/apps/studio/dist/client.bundle.js +89 -17
- package/apps/studio/dist/comment-mount.js +59 -1
- package/apps/studio/dist/runtime/.min-sizes.json +11 -1
- package/apps/studio/dist/runtime/@remotion_media.js +491 -0
- package/apps/studio/dist/runtime/@remotion_player.js +56 -0
- package/apps/studio/dist/runtime/@remotion_transitions.js +300 -0
- package/apps/studio/dist/runtime/@remotion_transitions_clock-wipe.js +1 -0
- package/apps/studio/dist/runtime/@remotion_transitions_fade.js +1 -0
- package/apps/studio/dist/runtime/@remotion_transitions_flip.js +1 -0
- package/apps/studio/dist/runtime/@remotion_transitions_none.js +1 -0
- package/apps/studio/dist/runtime/@remotion_transitions_slide.js +1 -0
- package/apps/studio/dist/runtime/@remotion_transitions_wipe.js +1 -0
- package/apps/studio/dist/runtime/REMOTION-LICENSE.md +28 -0
- package/apps/studio/dist/runtime/remotion.js +42 -0
- package/apps/studio/dist/styles.css +1 -1
- package/apps/studio/dom-selection.ts +127 -1
- package/apps/studio/drag-state.ts +24 -0
- package/apps/studio/equal-spacing-detector.ts +205 -0
- package/apps/studio/export-dialog.tsx +1 -1
- package/apps/studio/exporters/_browser-bundles.ts +117 -0
- package/apps/studio/exporters/_runtime.ts +69 -0
- package/apps/studio/exporters/html.ts +4 -3
- package/apps/studio/exporters/index.ts +28 -2
- package/apps/studio/exporters/pdf.ts +4 -3
- package/apps/studio/exporters/png.ts +5 -3
- package/apps/studio/exporters/pptx.ts +6 -4
- package/apps/studio/exporters/svg.ts +9 -5
- package/apps/studio/exporters/video-encode-lib.ts +200 -0
- package/apps/studio/exporters/video-render-lib.ts +108 -0
- package/apps/studio/exporters/video.ts +184 -0
- package/apps/studio/history.ts +47 -1
- package/apps/studio/http.ts +758 -27
- package/apps/studio/input-router.tsx +19 -1
- package/apps/studio/marquee-overlay.tsx +1 -1
- package/apps/studio/measure-overlay.tsx +241 -0
- package/apps/studio/participants-chrome.tsx +3 -3
- package/apps/studio/runtime-bundle.ts +30 -0
- package/apps/studio/server.ts +34 -9
- package/apps/studio/sizing-mode.ts +117 -0
- package/apps/studio/spacing-handles.ts +166 -0
- package/apps/studio/test/annotate-write.test.ts +890 -0
- package/apps/studio/test/annotations-roundtrip.test.ts +47 -0
- package/apps/studio/test/camera-reveal.test.tsx +173 -0
- package/apps/studio/test/canvas-create-api.test.ts +76 -0
- package/apps/studio/test/canvas-edit.test.ts +140 -0
- package/apps/studio/test/canvas-list-watch.test.ts +49 -0
- package/apps/studio/test/canvas-media-drop.test.ts +30 -1
- package/apps/studio/test/canvas-origin-gate.test.ts +54 -0
- package/apps/studio/test/canvas-rects.test.ts +198 -0
- package/apps/studio/test/clip-addressing.test.ts +732 -0
- package/apps/studio/test/comments-overlay.test.ts +117 -0
- package/apps/studio/test/config-reload.test.ts +230 -0
- package/apps/studio/test/dns-rebinding-guard.test.ts +74 -0
- package/apps/studio/test/dom-selection.test.ts +130 -0
- package/apps/studio/test/edit-css-occurrence.test.ts +81 -0
- package/apps/studio/test/edit-persistence.test.ts +91 -0
- package/apps/studio/test/edit-scope-api.test.ts +115 -0
- package/apps/studio/test/element-resize.test.ts +136 -0
- package/apps/studio/test/element-structural-api.test.ts +360 -0
- package/apps/studio/test/element-structural-edit.test.ts +233 -0
- package/apps/studio/test/equal-spacing-detector.test.ts +165 -1
- package/apps/studio/test/exporters/runtime.test.ts +59 -0
- package/apps/studio/test/file-lock.test.ts +84 -0
- package/apps/studio/test/fixtures/video-comp-fixture.tsx +82 -0
- package/apps/studio/test/history-rollback.test.ts +26 -0
- package/apps/studio/test/knob-props-authored.test.ts +87 -0
- package/apps/studio/test/read-annotations.test.ts +154 -0
- package/apps/studio/test/sizing-mode.test.ts +102 -0
- package/apps/studio/test/spacing-handles.test.ts +138 -0
- package/apps/studio/test/specimen-select.test.ts +88 -0
- package/apps/studio/test/timeline-parse.test.ts +127 -0
- package/apps/studio/test/timeline-snap.test.ts +85 -0
- package/apps/studio/test/tool-palette-insert-anchor.test.ts +84 -0
- package/apps/studio/test/undo-sequence-byte-compare.test.ts +211 -0
- package/apps/studio/test/video-asset.test.ts +163 -0
- package/apps/studio/test/video-comp-fixture.test.ts +50 -0
- package/apps/studio/test/video-comp.test.ts +168 -0
- package/apps/studio/test/video-render-bridge.test.ts +149 -0
- package/apps/studio/tool-palette.tsx +122 -2
- package/apps/studio/undo-hud.tsx +2 -2
- package/apps/studio/use-annotation-resize.tsx +6 -3
- package/apps/studio/use-canvas-media-drop.tsx +66 -4
- package/apps/studio/use-element-resize.tsx +732 -0
- package/apps/studio/use-keyboard-discipline.tsx +205 -15
- package/apps/studio/use-selection-set.tsx +14 -0
- package/apps/studio/use-spacing-handles.tsx +388 -0
- package/apps/studio/video-comp.tsx +444 -0
- package/apps/studio/whats-new.json +55 -0
- package/apps/studio/ws.ts +5 -0
- package/cli/commands/design.mjs +6 -1
- package/cli/commands/design.test.mjs +49 -1
- package/cli/lib/fetch-asset.test.mjs +213 -0
- package/package.json +8 -8
- package/plugins/design/dependencies.json +10 -2
- package/plugins/design/templates/_shell.html +25 -2
- package/plugins/design/templates/design-system-inspiration/_MAPPING.md +1 -1
|
@@ -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
|
-
*
|
|
8
|
-
*
|
|
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 `
|
|
27
|
-
*
|
|
28
|
-
* plane
|
|
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
|
-
|
|
96
|
-
|
|
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
|
-
|
|
145
|
-
selector: string
|
|
146
|
-
index?: number
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
761
|
-
//
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
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
|
|
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
|
-
|
|
846
|
-
node.style.
|
|
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
|
-
|
|
971
|
-
node.style.
|
|
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
|
|
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
|
|
1315
|
+
const rect = screenRectFor(state.selection);
|
|
1209
1316
|
if (rect) {
|
|
1210
1317
|
return { x: rect.x, y: rect.y + rect.h + 8 };
|
|
1211
1318
|
}
|
|
@@ -140,6 +140,20 @@
|
|
|
140
140
|
"description": "How hard DS reuse is enforced during canvas generation/edit. 'advisory' (default) = reuse findings (invented brand mark/icons, reinvented components, parallel product shell) surface as warnings. 'strict' = when the DS ships a canonical specimen (logo, iconography, component, platform showcase), reinventing it is a blocker the auto-fix loop must clear. Orthogonal to opt_out_scope on the SAME axis of 'how much the DS binds': a canvas's explicit opt_out_scope=full always wins over strict (explicit free-use beats project policy). A11y is independent of this knob. Per DDR-141.",
|
|
141
141
|
"default": "advisory"
|
|
142
142
|
},
|
|
143
|
+
"moodboard": {
|
|
144
|
+
"type": "object",
|
|
145
|
+
"description": "Knobs for the /design:setup-ds Stage-3 direction gate (the design-language moodboard). Per DDR-147.",
|
|
146
|
+
"properties": {
|
|
147
|
+
"variants": {
|
|
148
|
+
"type": "integer",
|
|
149
|
+
"minimum": 1,
|
|
150
|
+
"maximum": 3,
|
|
151
|
+
"default": 3,
|
|
152
|
+
"description": "How many direction tiles the interactive direction gate composes by default. The SINGLE authoritative home of this default — docs reference this knob, never a literal. Effective count still degrades to the number of pairwise-distinct research seeds (floor 1), and --quick / autonomous / re-bootstrap force 1. Default tiles are main-agent seed-only compositions (no web, no fan-out); the blind-sub-agent + self-harvest path is an explicit escalation. Per DDR-147."
|
|
153
|
+
}
|
|
154
|
+
},
|
|
155
|
+
"additionalProperties": false
|
|
156
|
+
},
|
|
143
157
|
"designSystems": {
|
|
144
158
|
"type": "array",
|
|
145
159
|
"description": "Design systems available in this project. Single-DS projects have one entry; multi-DS projects (marketing vs. admin vs. mobile) list each here. Each canvas's .meta.json declares which DS it uses via the 'designSystem' field. The System view's DS picker iterates this array.",
|
|
@@ -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:
|
|
213
|
-
color:
|
|
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));
|
|
@@ -297,7 +303,7 @@ export function useContextMenuOptional(): ContextMenuValue | null {
|
|
|
297
303
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
298
304
|
// View
|
|
299
305
|
|
|
300
|
-
function ContextMenuView({
|
|
306
|
+
export function ContextMenuView({
|
|
301
307
|
target,
|
|
302
308
|
sections,
|
|
303
309
|
onClose,
|
|
@@ -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
|
-
|
|
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
|
-
{
|
|
495
|
+
{submenuItems.map((sub) => (
|
|
486
496
|
<button
|
|
487
497
|
key={sub.id}
|
|
488
498
|
type="button"
|
package/apps/studio/context.ts
CHANGED
|
@@ -206,8 +206,47 @@ export function normalizeDesignSystems<T extends DevServerConfig>(cfg: T): T {
|
|
|
206
206
|
return { ...cfg, designSystems };
|
|
207
207
|
}
|
|
208
208
|
|
|
209
|
+
/**
|
|
210
|
+
* True when a config-declared relative path stays inside the design root once
|
|
211
|
+
* joined to it. Rejects absolute paths and `..` escapes. Security clamp from
|
|
212
|
+
* the DDR-149 fan-out review: `canvasGroups[].path` / `tokensCssRel` feed
|
|
213
|
+
* directory walks (`/_index-data`) and a served stylesheet URL — a poisoned
|
|
214
|
+
* (e.g. peer-committed) config must not walk or serve outside the design root,
|
|
215
|
+
* and with hot-reload the escape would apply live, no restart gate.
|
|
216
|
+
*/
|
|
217
|
+
export function isContainedRel(rel: unknown): boolean {
|
|
218
|
+
if (typeof rel !== 'string' || !rel) return false;
|
|
219
|
+
const p = rel.replace(/\\/g, '/');
|
|
220
|
+
if (p.startsWith('/') || /^[A-Za-z]:/.test(p)) return false;
|
|
221
|
+
const norm = path.posix.normalize(p);
|
|
222
|
+
return norm !== '..' && !norm.startsWith('../');
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function clampToDesignRoot(cfg: DevServerConfig): DevServerConfig {
|
|
226
|
+
const groups = cfg.canvasGroups ?? [];
|
|
227
|
+
const canvasGroups = groups.filter((g) => isContainedRel(g?.path));
|
|
228
|
+
for (const g of groups) {
|
|
229
|
+
if (!canvasGroups.includes(g)) {
|
|
230
|
+
console.warn(` warn: canvasGroups path escapes the design root — ignored: ${g?.path}`);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
let tokensCssRel = cfg.tokensCssRel;
|
|
234
|
+
if (!isContainedRel(tokensCssRel)) {
|
|
235
|
+
console.warn(` warn: tokensCssRel escapes the design root — using default: ${tokensCssRel}`);
|
|
236
|
+
tokensCssRel = DEFAULT_CONFIG.tokensCssRel;
|
|
237
|
+
}
|
|
238
|
+
const designSystems = cfg.designSystems?.filter((d) => {
|
|
239
|
+
const ok =
|
|
240
|
+
isContainedRel(d?.path) && (d?.tokensCssRel == null || isContainedRel(d.tokensCssRel));
|
|
241
|
+
if (!ok)
|
|
242
|
+
console.warn(` warn: designSystems entry escapes the design root — ignored: ${d?.name}`);
|
|
243
|
+
return ok;
|
|
244
|
+
});
|
|
245
|
+
return { ...cfg, canvasGroups, tokensCssRel, designSystems };
|
|
246
|
+
}
|
|
247
|
+
|
|
209
248
|
function normalizeConfig(cfg: DevServerConfig): DevServerConfig {
|
|
210
|
-
return normalizeDesignSystems(cfg);
|
|
249
|
+
return normalizeDesignSystems(clampToDesignRoot(cfg));
|
|
211
250
|
}
|
|
212
251
|
|
|
213
252
|
export function createContext(): Context {
|
|
@@ -245,3 +284,76 @@ export function createContext(): Context {
|
|
|
245
284
|
bus: createBus(),
|
|
246
285
|
};
|
|
247
286
|
}
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* Hot-reload `.design/config.json` into an existing Context. `/design:setup-ds`
|
|
290
|
+
* (and any hand edit) rewrites the config mid-session; without this the server
|
|
291
|
+
* keeps serving the boot snapshot — `/_index-data` never lists a newly added
|
|
292
|
+
* canvas group, so scaffolded DS files stay invisible even on a manual tree
|
|
293
|
+
* reload. RCA: .ai/logs/rca/issue-ds-scaffold-files-not-in-filetree-stale-config.md
|
|
294
|
+
*
|
|
295
|
+
* CONTRACT: `ctx.cfg` and `ctx.paths` are mutated IN PLACE — module factories
|
|
296
|
+
* capture the object references (`const { cfg, paths } = ctx`) and must see the
|
|
297
|
+
* fresh values through them. Never copy a cfg VALUE at construction time in a
|
|
298
|
+
* long-lived module (that re-introduces the stale-config bug); read from the
|
|
299
|
+
* shared object at use time instead.
|
|
300
|
+
*
|
|
301
|
+
* Deliberately NOT hot-reloadable: `designRoot` — the fs-watcher and every
|
|
302
|
+
* runtime path hang off it; changing it requires a restart (warn + keep old).
|
|
303
|
+
* A config.json that is missing or invalid mid-edit keeps the current cfg
|
|
304
|
+
* (a running server must not downgrade to defaults on a half-written save).
|
|
305
|
+
*
|
|
306
|
+
* Returns true when the config actually changed.
|
|
307
|
+
*/
|
|
308
|
+
export function reloadConfig(ctx: Context): boolean {
|
|
309
|
+
const next = loadConfig(ctx.paths.repoRoot);
|
|
310
|
+
// Boot from defaults + config.json created later IS a legit reload; but a
|
|
311
|
+
// file that vanished or fails to parse mid-edit must not clobber a working
|
|
312
|
+
// cfg. loadConfig encodes both cases in _source.
|
|
313
|
+
if (next._source !== '.design/config.json') {
|
|
314
|
+
if (ctx.cfg._source === '.design/config.json') {
|
|
315
|
+
console.warn(
|
|
316
|
+
` warn: config.json ${next._source === 'defaults' ? 'missing' : 'invalid'} on reload — keeping the running config.`
|
|
317
|
+
);
|
|
318
|
+
}
|
|
319
|
+
return false;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
const nextDesignRel = next.designRoot.replace(/^\/+|\/+$/g, '');
|
|
323
|
+
if (nextDesignRel !== ctx.paths.designRel) {
|
|
324
|
+
console.warn(
|
|
325
|
+
` warn: designRoot changed (${ctx.paths.designRel} → ${nextDesignRel}) — not hot-reloadable, restart the server to apply.`
|
|
326
|
+
);
|
|
327
|
+
next.designRoot = ctx.cfg.designRoot;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// linkedHub is boot-pinned like designRoot: the sync runtime captures it once
|
|
331
|
+
// at startup (sync/index.ts), so a live swap would let use-time readers
|
|
332
|
+
// (syncTsx gating) drift out of step with the hub the socket is actually
|
|
333
|
+
// attached to — and a poisoned config must never re-point sync without a
|
|
334
|
+
// restart (DDR-149 fan-out review).
|
|
335
|
+
if (JSON.stringify(next.linkedHub) !== JSON.stringify(ctx.cfg.linkedHub)) {
|
|
336
|
+
console.warn(' warn: linkedHub changed — not hot-reloadable, restart the server to apply.');
|
|
337
|
+
if (ctx.cfg.linkedHub === undefined) delete next.linkedHub;
|
|
338
|
+
else next.linkedHub = ctx.cfg.linkedHub;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
if (JSON.stringify(ctx.cfg) === JSON.stringify(next)) return false;
|
|
342
|
+
|
|
343
|
+
// In-place swap so every captured `ctx.cfg` reference sees the new values.
|
|
344
|
+
// INVARIANT: no `await` between the delete loop and the assign — the swap is
|
|
345
|
+
// atomic only because it is synchronous; an interleaved request must never
|
|
346
|
+
// observe a partially-emptied cfg.
|
|
347
|
+
const cfg = ctx.cfg as unknown as Record<string, unknown>;
|
|
348
|
+
for (const key of Object.keys(cfg)) delete cfg[key];
|
|
349
|
+
Object.assign(cfg, next);
|
|
350
|
+
|
|
351
|
+
ctx.projectLabel = ctx.cfg.projectLabel || `${ctx.cfg.name} Design`;
|
|
352
|
+
ctx.paths.tokensUrlRel = path.posix.join(
|
|
353
|
+
ctx.paths.designRel,
|
|
354
|
+
ctx.cfg.tokensCssRel.replace(/^\/+/, '')
|
|
355
|
+
);
|
|
356
|
+
ctx.paths.systemDirRel =
|
|
357
|
+
ctx.cfg.canvasGroups.find((g) => /system/i.test(g.path))?.path ?? 'system';
|
|
358
|
+
return true;
|
|
359
|
+
}
|