@1agh/maude 0.46.0 → 0.47.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/apps/studio/acp/bootstrap-brief.ts +8 -0
- package/apps/studio/acp/bridge.ts +113 -5
- package/apps/studio/annotations-layer.tsx +42 -0
- package/apps/studio/api.ts +417 -2
- package/apps/studio/bin/_agent-browser-safe-config.json +1 -0
- package/apps/studio/bin/_agent-browser-safe.mjs +228 -0
- package/apps/studio/bin/_agent-browser-safe.test.mjs +165 -0
- package/apps/studio/bin/_curl-local.mjs +349 -0
- package/apps/studio/bin/_curl-local.test.mjs +280 -0
- package/apps/studio/bin/agent-browser-safe.sh +29 -0
- package/apps/studio/bin/curl-local.sh +28 -0
- package/apps/studio/canvas-cursors.ts +6 -0
- package/apps/studio/canvas-edit.ts +678 -11
- package/apps/studio/canvas-icons.tsx +13 -0
- package/apps/studio/canvas-lib.tsx +3 -0
- package/apps/studio/canvas-shell.tsx +646 -26
- package/apps/studio/client/app.jsx +898 -52
- package/apps/studio/client/panels/ChatPanel.jsx +47 -2
- package/apps/studio/client/styles/3-shell-maude.css +39 -0
- package/apps/studio/contextual-toolbar.tsx +5 -3
- package/apps/studio/dist/client.bundle.js +1103 -1103
- package/apps/studio/dist/comment-mount.js +2 -2
- package/apps/studio/dist/styles.css +1 -1
- package/apps/studio/grid-track-handles.ts +179 -0
- package/apps/studio/handoff.ts +35 -0
- package/apps/studio/http.ts +118 -0
- package/apps/studio/input-router.tsx +73 -17
- package/apps/studio/test/acp-session-allowed-tools.test.ts +107 -11
- package/apps/studio/test/browse-posture.test.tsx +107 -0
- package/apps/studio/test/canvas-hide-chrome.test.ts +58 -0
- package/apps/studio/test/canvas-meta-api.test.ts +70 -0
- package/apps/studio/test/canvas-origin-gate.test.ts +4 -0
- package/apps/studio/test/comment-mount.test.ts +2 -1
- package/apps/studio/test/component-map.test.ts +48 -0
- package/apps/studio/test/convert-to-absolute.test.ts +333 -0
- package/apps/studio/test/detach-component.test.ts +94 -0
- package/apps/studio/test/edit-scope-api.test.ts +8 -4
- package/apps/studio/test/element-structural-api.test.ts +74 -0
- package/apps/studio/test/element-structural-edit.test.ts +113 -0
- package/apps/studio/test/grid-track-handles.test.ts +160 -0
- package/apps/studio/test/handoff.test.ts +48 -0
- package/apps/studio/test/input-router.test.ts +82 -8
- package/apps/studio/test/layers-synthetic-groups.test.ts +96 -0
- package/apps/studio/test/pdf-print-boxes.test.ts +54 -0
- package/apps/studio/test/use-tool-mode.test.tsx +10 -2
- package/apps/studio/tool-palette.tsx +3 -1
- package/apps/studio/use-canvas-media-drop.tsx +126 -0
- package/apps/studio/use-element-resize.tsx +3 -1
- package/apps/studio/use-grid-track-handles.tsx +364 -0
- package/apps/studio/use-keyboard-discipline.tsx +15 -0
- package/apps/studio/use-tool-mode.tsx +30 -3
- package/apps/studio/web-overlay-content.tsx +52 -0
- package/apps/studio/whats-new.json +27 -0
- package/cli/commands/design.mjs +16 -0
- package/package.json +8 -8
- package/plugins/design/templates/_shell.html +4 -0
|
@@ -227,6 +227,38 @@ const TOAST_CSS = `
|
|
|
227
227
|
@media (prefers-reduced-motion: reduce) {
|
|
228
228
|
.dc-media-toast { transition: none; }
|
|
229
229
|
}
|
|
230
|
+
/* feature-4 (2026-07-19) — sandbox-safe confirm dialog (window.confirm is
|
|
231
|
+
silently blocked in the allow-modals-less canvas iframe). HUD-token styled. */
|
|
232
|
+
.dc-confirm-backdrop {
|
|
233
|
+
position: fixed; inset: 0; z-index: 40;
|
|
234
|
+
background: rgba(0,0,0,0.45);
|
|
235
|
+
display: flex; align-items: center; justify-content: center;
|
|
236
|
+
}
|
|
237
|
+
.dc-confirm {
|
|
238
|
+
min-width: 300px; max-width: 420px;
|
|
239
|
+
background: var(--maude-chrome-bg-1, #1b1e24);
|
|
240
|
+
color: var(--maude-chrome-fg-0, #e7eaf0);
|
|
241
|
+
border: 1px solid var(--maude-chrome-border, #333a45);
|
|
242
|
+
border-radius: 10px;
|
|
243
|
+
box-shadow: 0 14px 44px rgba(0,0,0,0.5);
|
|
244
|
+
padding: 16px;
|
|
245
|
+
font-family: var(--maude-chrome-font-ui, system-ui, sans-serif);
|
|
246
|
+
}
|
|
247
|
+
.dc-confirm-title { font-size: 13px; font-weight: 600; margin-bottom: 8px; }
|
|
248
|
+
.dc-confirm-body { font-size: 12px; line-height: 1.5; color: var(--maude-chrome-fg-1, #b7bec9); white-space: pre-line; }
|
|
249
|
+
.dc-confirm-row { display: flex; justify-content: flex-end; gap: 8px; margin-top: 14px; }
|
|
250
|
+
.dc-confirm-btn {
|
|
251
|
+
padding: 5px 12px; border-radius: 7px; font-size: 12px; cursor: pointer;
|
|
252
|
+
background: var(--maude-chrome-bg-2, #262b33);
|
|
253
|
+
color: var(--maude-chrome-fg-0, #e7eaf0);
|
|
254
|
+
border: 1px solid var(--maude-chrome-border, #333a45);
|
|
255
|
+
}
|
|
256
|
+
.dc-confirm-btn--primary {
|
|
257
|
+
background: var(--maude-hud-accent, #0d99ff);
|
|
258
|
+
border-color: var(--maude-hud-accent, #0d99ff);
|
|
259
|
+
color: #fff;
|
|
260
|
+
}
|
|
261
|
+
.dc-confirm-btn:focus-visible { outline: 2px solid var(--maude-hud-accent, #0d99ff); outline-offset: 1px; }
|
|
230
262
|
`.trim();
|
|
231
263
|
|
|
232
264
|
function ensureMediaStyles(): void {
|
|
@@ -238,6 +270,100 @@ function ensureMediaStyles(): void {
|
|
|
238
270
|
document.head.appendChild(s);
|
|
239
271
|
}
|
|
240
272
|
|
|
273
|
+
/**
|
|
274
|
+
* feature-4 (2026-07-19) — sandbox-safe in-canvas CONFIRM. The canvas iframe
|
|
275
|
+
* runs with sandbox="allow-scripts allow-same-origin" (no allow-modals), so
|
|
276
|
+
* `window.confirm()` silently returns false — this promise-based overlay is
|
|
277
|
+
* the replacement (Esc / backdrop / Cancel → false, primary / Enter → true).
|
|
278
|
+
*/
|
|
279
|
+
export function canvasConfirm(
|
|
280
|
+
message: string,
|
|
281
|
+
opts?: { title?: string; confirmLabel?: string; cancelLabel?: string }
|
|
282
|
+
): Promise<boolean> {
|
|
283
|
+
if (typeof document === 'undefined') return Promise.resolve(false);
|
|
284
|
+
ensureMediaStyles();
|
|
285
|
+
return new Promise((resolve) => {
|
|
286
|
+
const backdrop = document.createElement('div');
|
|
287
|
+
backdrop.className = 'dc-confirm-backdrop';
|
|
288
|
+
const box = document.createElement('div');
|
|
289
|
+
box.className = 'dc-confirm';
|
|
290
|
+
box.setAttribute('role', 'alertdialog');
|
|
291
|
+
box.setAttribute('aria-modal', 'true');
|
|
292
|
+
const title = document.createElement('div');
|
|
293
|
+
title.className = 'dc-confirm-title';
|
|
294
|
+
title.textContent = opts?.title ?? 'Are you sure?';
|
|
295
|
+
const body = document.createElement('div');
|
|
296
|
+
body.className = 'dc-confirm-body';
|
|
297
|
+
body.textContent = message;
|
|
298
|
+
const row = document.createElement('div');
|
|
299
|
+
row.className = 'dc-confirm-row';
|
|
300
|
+
const cancel = document.createElement('button');
|
|
301
|
+
cancel.type = 'button';
|
|
302
|
+
cancel.className = 'dc-confirm-btn';
|
|
303
|
+
cancel.textContent = opts?.cancelLabel ?? 'Cancel';
|
|
304
|
+
const ok = document.createElement('button');
|
|
305
|
+
ok.type = 'button';
|
|
306
|
+
ok.className = 'dc-confirm-btn dc-confirm-btn--primary';
|
|
307
|
+
ok.textContent = opts?.confirmLabel ?? 'Continue';
|
|
308
|
+
// a11y fix (review fan-out, 2026-07-21) — this is a raw DOM overlay, not a
|
|
309
|
+
// native <dialog>, so it must implement the two APG modal-dialog musts
|
|
310
|
+
// itself: (1) restore focus to whatever had it before the dialog opened
|
|
311
|
+
// (a hostile canvas can't spoof this — it's just where focus already was);
|
|
312
|
+
// (2) trap Tab/Shift+Tab inside the two buttons so it can't escape to
|
|
313
|
+
// background content (this overlay mounts in the canvas iframe's own
|
|
314
|
+
// document, so "background" is live, possibly-interactive artboard markup).
|
|
315
|
+
const previouslyFocused = document.activeElement as HTMLElement | null;
|
|
316
|
+
const done = (v: boolean) => {
|
|
317
|
+
document.removeEventListener('keydown', onKey, true);
|
|
318
|
+
backdrop.remove();
|
|
319
|
+
try {
|
|
320
|
+
previouslyFocused?.focus();
|
|
321
|
+
} catch {
|
|
322
|
+
/* detached / no-op target — nothing to restore focus to */
|
|
323
|
+
}
|
|
324
|
+
resolve(v);
|
|
325
|
+
};
|
|
326
|
+
const onKey = (e: KeyboardEvent) => {
|
|
327
|
+
// SECURITY — ignore script-dispatched events (isTrusted: false). This
|
|
328
|
+
// dialog mounts in the canvas iframe's own document (untrusted, DDR-054);
|
|
329
|
+
// without this, hostile canvas JS could synthesize a KeyboardEvent/Event
|
|
330
|
+
// to auto-confirm itself past the "irreversible convert" prompt.
|
|
331
|
+
if (!e.isTrusted) return;
|
|
332
|
+
if (e.key === 'Escape') {
|
|
333
|
+
e.stopPropagation();
|
|
334
|
+
done(false);
|
|
335
|
+
} else if (e.key === 'Enter') {
|
|
336
|
+
e.stopPropagation();
|
|
337
|
+
done(true);
|
|
338
|
+
} else if (e.key === 'Tab') {
|
|
339
|
+
// 2-element focus trap: Tab/Shift+Tab just toggles between the two
|
|
340
|
+
// buttons, never leaving the dialog.
|
|
341
|
+
e.preventDefault();
|
|
342
|
+
e.stopPropagation();
|
|
343
|
+
(document.activeElement === ok ? cancel : ok).focus();
|
|
344
|
+
}
|
|
345
|
+
};
|
|
346
|
+
cancel.addEventListener('click', (e) => {
|
|
347
|
+
if (!e.isTrusted) return;
|
|
348
|
+
done(false);
|
|
349
|
+
});
|
|
350
|
+
ok.addEventListener('click', (e) => {
|
|
351
|
+
if (!e.isTrusted) return;
|
|
352
|
+
done(true);
|
|
353
|
+
});
|
|
354
|
+
backdrop.addEventListener('click', (e) => {
|
|
355
|
+
if (!e.isTrusted) return;
|
|
356
|
+
if (e.target === backdrop) done(false);
|
|
357
|
+
});
|
|
358
|
+
document.addEventListener('keydown', onKey, true);
|
|
359
|
+
row.append(cancel, ok);
|
|
360
|
+
box.append(title, body, row);
|
|
361
|
+
backdrop.append(box);
|
|
362
|
+
document.body.append(backdrop);
|
|
363
|
+
ok.focus();
|
|
364
|
+
});
|
|
365
|
+
}
|
|
366
|
+
|
|
241
367
|
/** Show a brief auto-dismissing toast in the canvas (e.g. an upload failure). */
|
|
242
368
|
export function showCanvasToast(message: string): void {
|
|
243
369
|
if (typeof document === 'undefined') return;
|
|
@@ -19,7 +19,9 @@
|
|
|
19
19
|
* resizes.
|
|
20
20
|
*
|
|
21
21
|
* In-flow elements resize by writing explicit `width`/`height` only
|
|
22
|
-
* (NEVER
|
|
22
|
+
* (a resize NEVER implicitly converts to absolute; the explicit
|
|
23
|
+
* context-menu "Convert children to absolute" — feature-4 T8,
|
|
24
|
+
* DDR-188 — is the opt-in flatten path); an out-of-flow
|
|
23
25
|
* (absolute/fixed) element additionally moves `left`/`top` when the
|
|
24
26
|
* top/left edge is dragged, so the resize feels origin-correct.
|
|
25
27
|
*/
|
|
@@ -0,0 +1,364 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file use-grid-track-handles.tsx — feature-3-web-artboards T5
|
|
3
|
+
* (absorbed feature-grid-track-editor stub).
|
|
4
|
+
* @scope apps/studio/use-grid-track-handles.tsx
|
|
5
|
+
* @purpose On-canvas CSS-Grid gutter drag-resize — the grid sibling of
|
|
6
|
+
* `use-spacing-handles.tsx`'s padding/gap overlay, same shape:
|
|
7
|
+
* live preview via direct inline-style writes, commit rides the
|
|
8
|
+
* SAME `resize-request` lane (`grid-template-columns`/`-rows`
|
|
9
|
+
* added to the shell's `resizeElement` allowed-prop list — no
|
|
10
|
+
* new write surface, mirroring how `padding-*`/`gap` were added
|
|
11
|
+
* there for Stage J). A component-instance drag threads the
|
|
12
|
+
* Stage H3 occurrence index the same way (`globalCdOccurrence`).
|
|
13
|
+
*
|
|
14
|
+
* Gated on computed `display: grid`/`inline-grid` (CLAUDE.md
|
|
15
|
+
* gotcha: "gate all of it on computed display" — a container
|
|
16
|
+
* that was converted to absolute, or never was a grid, is not an
|
|
17
|
+
* editing target). Only tracks AUTHORED inline via
|
|
18
|
+
* `grid-template-columns`/`-rows` (parseable by
|
|
19
|
+
* `grid-track-handles.ts`'s `parseTrackList`) get drag handles —
|
|
20
|
+
* `repeat()`/`minmax()`/a stylesheet-authored grid have no
|
|
21
|
+
* drag surface here (same constraint every curated knob already
|
|
22
|
+
* has: these on-canvas tools edit inline-style-authored values
|
|
23
|
+
* only); the Inspector's Grid section still shows the raw value
|
|
24
|
+
* for those cases.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import { type ReactNode, useCallback, useEffect, useRef } from 'react';
|
|
28
|
+
import { globalCdOccurrence, resolveSelectionEl } from './dom-selection.ts';
|
|
29
|
+
import { isElementDragActive } from './drag-state.ts';
|
|
30
|
+
import {
|
|
31
|
+
computeGutterLines,
|
|
32
|
+
computeTrackDrag,
|
|
33
|
+
GRID_KEYWORD_UNITS,
|
|
34
|
+
type GridTrack,
|
|
35
|
+
type GutterLine,
|
|
36
|
+
gutterTrackIndices,
|
|
37
|
+
parseTrackList,
|
|
38
|
+
serializeTrackList,
|
|
39
|
+
} from './grid-track-handles.ts';
|
|
40
|
+
import { worldZoomFor } from './use-element-resize.tsx';
|
|
41
|
+
import { useSelectionSet } from './use-selection-set.tsx';
|
|
42
|
+
import { useToolMode } from './use-tool-mode.tsx';
|
|
43
|
+
|
|
44
|
+
const GRID_HANDLE_CSS = `
|
|
45
|
+
.dc-grid-gutter-handle {
|
|
46
|
+
position: fixed;
|
|
47
|
+
transform: translate(-50%, -50%);
|
|
48
|
+
/* Same layering rationale as the spacing handles (z-index 4) — below the
|
|
49
|
+
Stage-D resize handles (6), which win any frame-edge hit-test tie. */
|
|
50
|
+
z-index: 4;
|
|
51
|
+
pointer-events: auto;
|
|
52
|
+
touch-action: none;
|
|
53
|
+
width: 10px;
|
|
54
|
+
height: 10px;
|
|
55
|
+
border-radius: 2px;
|
|
56
|
+
background: var(--maude-hud-accent, oklch(0.680 0.180 268));
|
|
57
|
+
border: 1px solid var(--maude-hud-accent-fg, oklch(0.180 0.030 268));
|
|
58
|
+
opacity: 0.55;
|
|
59
|
+
transition: opacity 120ms cubic-bezier(0.4, 0, 0.2, 1);
|
|
60
|
+
}
|
|
61
|
+
.dc-grid-gutter-handle:hover,
|
|
62
|
+
.dc-grid-gutter-handle[data-dragging="true"] { opacity: 1; }
|
|
63
|
+
.dc-grid-gutter-handle[data-axis="x"] { cursor: ew-resize; }
|
|
64
|
+
.dc-grid-gutter-handle[data-axis="y"] { cursor: ns-resize; }
|
|
65
|
+
@media (prefers-reduced-motion: reduce) {
|
|
66
|
+
.dc-grid-gutter-handle { transition-duration: 1ms; }
|
|
67
|
+
}
|
|
68
|
+
`.trim();
|
|
69
|
+
|
|
70
|
+
function ensureGridHandleStyles(): void {
|
|
71
|
+
if (typeof document === 'undefined') return;
|
|
72
|
+
if (document.getElementById('dc-grid-gutter-css')) return;
|
|
73
|
+
const s = document.createElement('style');
|
|
74
|
+
s.id = 'dc-grid-gutter-css';
|
|
75
|
+
s.textContent = GRID_HANDLE_CSS;
|
|
76
|
+
document.head.appendChild(s);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function computedPx(cs: CSSStyleDeclaration, prop: string): number {
|
|
80
|
+
return Number.parseFloat(cs.getPropertyValue(prop)) || 0;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Parse a browser-RESOLVED `grid-template-columns`/`-rows` computed value
|
|
84
|
+
* ("100px 200px 100px") into a plain px array. Never contains keywords or
|
|
85
|
+
* `fr` — the browser already ran the grid-sizing algorithm. */
|
|
86
|
+
function resolvedTrackSizes(computedValue: string): number[] {
|
|
87
|
+
const s = computedValue.trim();
|
|
88
|
+
if (!s || s === 'none') return [];
|
|
89
|
+
return s.split(/\s+/).map((tok) => Number.parseFloat(tok) || 0);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
interface AxisState {
|
|
93
|
+
container: 'col' | 'row';
|
|
94
|
+
authored: GridTrack[];
|
|
95
|
+
resolvedPx: number[];
|
|
96
|
+
gutters: GutterLine[];
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
interface GridDrag {
|
|
100
|
+
pointerId: number;
|
|
101
|
+
axis: 'x' | 'y';
|
|
102
|
+
container: 'col' | 'row';
|
|
103
|
+
gutterIndex: number;
|
|
104
|
+
el: HTMLElement;
|
|
105
|
+
cdId: string;
|
|
106
|
+
idIndex: number;
|
|
107
|
+
startClientX: number;
|
|
108
|
+
startClientY: number;
|
|
109
|
+
zoom: number;
|
|
110
|
+
/** Snapshot of the AUTHORED track list for this axis at drag-start. */
|
|
111
|
+
tracks: GridTrack[];
|
|
112
|
+
/** Snapshot of each touched track's resolved px at drag-start. */
|
|
113
|
+
resolvedAtStart: number[];
|
|
114
|
+
touched: number[];
|
|
115
|
+
prop: 'grid-template-columns' | 'grid-template-rows';
|
|
116
|
+
before: string;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export function GridTrackHandlesOverlay(): ReactNode {
|
|
120
|
+
ensureGridHandleStyles();
|
|
121
|
+
const { selected } = useSelectionSet();
|
|
122
|
+
const { tool } = useToolMode();
|
|
123
|
+
const containerRef = useRef<HTMLDivElement | null>(null);
|
|
124
|
+
const rafRef = useRef<number | null>(null);
|
|
125
|
+
const dragRef = useRef<GridDrag | null>(null);
|
|
126
|
+
const lastCommitRef = useRef<{ el: HTMLElement; prop: string; before: string } | null>(null);
|
|
127
|
+
// Latest per-axis geometry, read by the pointerdown handler (it fires
|
|
128
|
+
// between rAF ticks, so it needs the most recent computed snapshot rather
|
|
129
|
+
// than recomputing getComputedStyle itself).
|
|
130
|
+
const axesRef = useRef<{ col: AxisState | null; row: AxisState | null }>({
|
|
131
|
+
col: null,
|
|
132
|
+
row: null,
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
const one = selected.length === 1 ? selected[0] : null;
|
|
136
|
+
const cdId = one && typeof one.id === 'string' ? one.id : null;
|
|
137
|
+
const active = tool === 'move' && !!cdId;
|
|
138
|
+
|
|
139
|
+
useEffect(() => {
|
|
140
|
+
const c = containerRef.current;
|
|
141
|
+
if (!c) return;
|
|
142
|
+
const hideAll = () => {
|
|
143
|
+
for (const child of Array.from(c.children)) (child as HTMLElement).style.display = 'none';
|
|
144
|
+
axesRef.current = { col: null, row: null };
|
|
145
|
+
};
|
|
146
|
+
if (!active || !one) {
|
|
147
|
+
hideAll();
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
const tick = () => {
|
|
151
|
+
rafRef.current = null;
|
|
152
|
+
const reschedule = () => {
|
|
153
|
+
rafRef.current = requestAnimationFrame(tick);
|
|
154
|
+
};
|
|
155
|
+
const bail = () => {
|
|
156
|
+
hideAll();
|
|
157
|
+
reschedule();
|
|
158
|
+
};
|
|
159
|
+
if (isElementDragActive()) {
|
|
160
|
+
bail();
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
const el = resolveSelectionEl(document, one) as HTMLElement | null;
|
|
164
|
+
if (!el) {
|
|
165
|
+
bail();
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
const cs = getComputedStyle(el);
|
|
169
|
+
const isGrid = cs.display === 'grid' || cs.display === 'inline-grid';
|
|
170
|
+
if (!isGrid) {
|
|
171
|
+
bail();
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
const r = el.getBoundingClientRect();
|
|
175
|
+
if (r.width <= 0 && r.height <= 0) {
|
|
176
|
+
bail();
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
const zoom = worldZoomFor(el);
|
|
180
|
+
const rect = { x: r.left, y: r.top, w: r.width, h: r.height };
|
|
181
|
+
const colGap = computedPx(cs, 'column-gap') * zoom;
|
|
182
|
+
const rowGap = computedPx(cs, 'row-gap') * zoom;
|
|
183
|
+
|
|
184
|
+
const buildAxis = (
|
|
185
|
+
container: 'col' | 'row',
|
|
186
|
+
prop: string,
|
|
187
|
+
gapPx: number
|
|
188
|
+
): AxisState | null => {
|
|
189
|
+
const authored = parseTrackList(el.style.getPropertyValue(prop));
|
|
190
|
+
if (authored.length < 2) return null;
|
|
191
|
+
const resolvedPx = resolvedTrackSizes(cs.getPropertyValue(prop)).map((px) => px * zoom);
|
|
192
|
+
if (resolvedPx.length !== authored.length) return null; // repeat()/mismatch guard
|
|
193
|
+
const gutters = computeGutterLines(rect, resolvedPx, gapPx, container).filter((_, i) => {
|
|
194
|
+
const before = authored[i] as GridTrack;
|
|
195
|
+
const after = authored[i + 1] as GridTrack;
|
|
196
|
+
return !GRID_KEYWORD_UNITS.has(before.unit) || !GRID_KEYWORD_UNITS.has(after.unit);
|
|
197
|
+
});
|
|
198
|
+
if (gutters.length === 0) return null;
|
|
199
|
+
return { container, authored, resolvedPx, gutters };
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
const col = buildAxis('col', 'grid-template-columns', colGap);
|
|
203
|
+
const row = buildAxis('row', 'grid-template-rows', rowGap);
|
|
204
|
+
axesRef.current = { col, row };
|
|
205
|
+
|
|
206
|
+
const allGutters = [...(col?.gutters ?? []), ...(row?.gutters ?? [])];
|
|
207
|
+
const TOTAL = allGutters.length;
|
|
208
|
+
while (c.children.length < TOTAL) c.appendChild(document.createElement('div'));
|
|
209
|
+
while (c.children.length > TOTAL) c.lastChild && c.removeChild(c.lastChild);
|
|
210
|
+
let i = 0;
|
|
211
|
+
for (const [container, axisState] of [
|
|
212
|
+
['col', col],
|
|
213
|
+
['row', row],
|
|
214
|
+
] as const) {
|
|
215
|
+
const kind = container === 'col' ? 'column' : 'row';
|
|
216
|
+
for (const g of axisState?.gutters ?? []) {
|
|
217
|
+
const h = c.children[i] as HTMLElement;
|
|
218
|
+
h.className = 'dc-grid-gutter-handle';
|
|
219
|
+
h.dataset.container = container;
|
|
220
|
+
h.dataset.gutterIndex = String(g.index);
|
|
221
|
+
h.dataset.axis = g.axis;
|
|
222
|
+
h.title = `grid ${kind} gutter · drag to resize · shift = resize both neighbors`;
|
|
223
|
+
h.style.display = 'block';
|
|
224
|
+
h.style.left = `${Math.round(g.x)}px`;
|
|
225
|
+
h.style.top = `${Math.round(g.y)}px`;
|
|
226
|
+
i++;
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
rafRef.current = requestAnimationFrame(tick);
|
|
230
|
+
};
|
|
231
|
+
rafRef.current = requestAnimationFrame(tick);
|
|
232
|
+
return () => {
|
|
233
|
+
if (rafRef.current != null) cancelAnimationFrame(rafRef.current);
|
|
234
|
+
};
|
|
235
|
+
}, [active, one]);
|
|
236
|
+
|
|
237
|
+
const commit = useCallback((d: GridDrag, serialized: string) => {
|
|
238
|
+
lastCommitRef.current = { el: d.el, prop: d.prop, before: d.before };
|
|
239
|
+
try {
|
|
240
|
+
window.parent.postMessage(
|
|
241
|
+
{
|
|
242
|
+
dgn: 'resize-request',
|
|
243
|
+
id: d.cdId,
|
|
244
|
+
patch: { [d.prop]: serialized },
|
|
245
|
+
before: { [d.prop]: d.before },
|
|
246
|
+
idIndex: d.idIndex,
|
|
247
|
+
},
|
|
248
|
+
'*'
|
|
249
|
+
);
|
|
250
|
+
} catch {
|
|
251
|
+
/* detached / cross-origin teardown */
|
|
252
|
+
}
|
|
253
|
+
}, []);
|
|
254
|
+
|
|
255
|
+
useEffect(() => {
|
|
256
|
+
const c = containerRef.current;
|
|
257
|
+
if (!c) return;
|
|
258
|
+
|
|
259
|
+
const onDown = (e: PointerEvent) => {
|
|
260
|
+
const t = e.target as HTMLElement | null;
|
|
261
|
+
if (!t?.classList.contains('dc-grid-gutter-handle')) return;
|
|
262
|
+
if (!one || typeof one.id !== 'string') return;
|
|
263
|
+
const el = resolveSelectionEl(document, one) as HTMLElement | null;
|
|
264
|
+
if (!el) return;
|
|
265
|
+
const container = t.dataset.container as 'col' | 'row';
|
|
266
|
+
const axisState = axesRef.current[container];
|
|
267
|
+
if (!axisState) return;
|
|
268
|
+
const gutterIndex = Number(t.dataset.gutterIndex);
|
|
269
|
+
if (!Number.isFinite(gutterIndex)) return;
|
|
270
|
+
e.preventDefault();
|
|
271
|
+
e.stopPropagation();
|
|
272
|
+
const zoom = worldZoomFor(el);
|
|
273
|
+
const prop: GridDrag['prop'] =
|
|
274
|
+
container === 'col' ? 'grid-template-columns' : 'grid-template-rows';
|
|
275
|
+
dragRef.current = {
|
|
276
|
+
pointerId: e.pointerId,
|
|
277
|
+
axis: (t.dataset.axis as 'x' | 'y') ?? 'x',
|
|
278
|
+
container,
|
|
279
|
+
gutterIndex,
|
|
280
|
+
el,
|
|
281
|
+
cdId: one.id,
|
|
282
|
+
idIndex: globalCdOccurrence(document, one.id, el),
|
|
283
|
+
startClientX: e.clientX,
|
|
284
|
+
startClientY: e.clientY,
|
|
285
|
+
zoom: zoom > 0 ? zoom : 1,
|
|
286
|
+
tracks: axisState.authored,
|
|
287
|
+
resolvedAtStart: axisState.resolvedPx,
|
|
288
|
+
touched: gutterTrackIndices(gutterIndex, false),
|
|
289
|
+
prop,
|
|
290
|
+
before: serializeTrackList(axisState.authored),
|
|
291
|
+
};
|
|
292
|
+
t.dataset.dragging = 'true';
|
|
293
|
+
try {
|
|
294
|
+
t.setPointerCapture(e.pointerId);
|
|
295
|
+
} catch {
|
|
296
|
+
/* synthetic events may reject capture */
|
|
297
|
+
}
|
|
298
|
+
};
|
|
299
|
+
|
|
300
|
+
const previewFor = (d: GridDrag, e: PointerEvent): GridTrack[] => {
|
|
301
|
+
const dx = e.clientX - d.startClientX;
|
|
302
|
+
const dy = e.clientY - d.startClientY;
|
|
303
|
+
const touched = gutterTrackIndices(d.gutterIndex, e.shiftKey).filter(
|
|
304
|
+
(idx) => idx < d.tracks.length
|
|
305
|
+
);
|
|
306
|
+
const next = d.tracks.map((tr) => ({ ...tr }));
|
|
307
|
+
for (const idx of touched) {
|
|
308
|
+
const tr = d.tracks[idx];
|
|
309
|
+
const resolvedPx = d.resolvedAtStart[idx];
|
|
310
|
+
if (!tr || !Number.isFinite(resolvedPx) || GRID_KEYWORD_UNITS.has(tr.unit)) continue;
|
|
311
|
+
const newValue = computeTrackDrag(tr, resolvedPx as number, dx, dy, d.zoom, d.axis);
|
|
312
|
+
next[idx] = { value: newValue, unit: tr.unit };
|
|
313
|
+
}
|
|
314
|
+
return next;
|
|
315
|
+
};
|
|
316
|
+
|
|
317
|
+
const onMove = (e: PointerEvent) => {
|
|
318
|
+
const d = dragRef.current;
|
|
319
|
+
if (!d || e.pointerId !== d.pointerId) return;
|
|
320
|
+
e.preventDefault();
|
|
321
|
+
const next = previewFor(d, e);
|
|
322
|
+
d.el.style.setProperty(d.prop, serializeTrackList(next));
|
|
323
|
+
};
|
|
324
|
+
|
|
325
|
+
const onUp = (e: PointerEvent) => {
|
|
326
|
+
const d = dragRef.current;
|
|
327
|
+
if (!d || e.pointerId !== d.pointerId) return;
|
|
328
|
+
dragRef.current = null;
|
|
329
|
+
const handle = containerRef.current?.querySelector<HTMLElement>(
|
|
330
|
+
'.dc-grid-gutter-handle[data-dragging="true"]'
|
|
331
|
+
);
|
|
332
|
+
if (handle) delete handle.dataset.dragging;
|
|
333
|
+
const next = previewFor(d, e);
|
|
334
|
+
const serialized = serializeTrackList(next);
|
|
335
|
+
if (serialized === d.before) return; // no-op guard
|
|
336
|
+
commit(d, serialized);
|
|
337
|
+
};
|
|
338
|
+
|
|
339
|
+
const onFail = (e: MessageEvent) => {
|
|
340
|
+
const m = e.data as { dgn?: string } | null;
|
|
341
|
+
if (m?.dgn !== 'resize-failed') return;
|
|
342
|
+
if (e.source !== window.parent) return; // only the parent shell (DDR-054)
|
|
343
|
+
const last = lastCommitRef.current;
|
|
344
|
+
if (!last) return;
|
|
345
|
+
last.el.style.setProperty(last.prop, last.before);
|
|
346
|
+
lastCommitRef.current = null;
|
|
347
|
+
};
|
|
348
|
+
|
|
349
|
+
c.addEventListener('pointerdown', onDown);
|
|
350
|
+
window.addEventListener('pointermove', onMove);
|
|
351
|
+
window.addEventListener('pointerup', onUp);
|
|
352
|
+
window.addEventListener('pointercancel', onUp);
|
|
353
|
+
window.addEventListener('message', onFail);
|
|
354
|
+
return () => {
|
|
355
|
+
c.removeEventListener('pointerdown', onDown);
|
|
356
|
+
window.removeEventListener('pointermove', onMove);
|
|
357
|
+
window.removeEventListener('pointerup', onUp);
|
|
358
|
+
window.removeEventListener('pointercancel', onUp);
|
|
359
|
+
window.removeEventListener('message', onFail);
|
|
360
|
+
};
|
|
361
|
+
}, [one, commit]);
|
|
362
|
+
|
|
363
|
+
return <div ref={containerRef} aria-hidden="true" />;
|
|
364
|
+
}
|
|
@@ -29,6 +29,7 @@ import { useArtboardsContext, useDragStateContext } from './canvas-lib.tsx';
|
|
|
29
29
|
import { resolveSelectionEl, scopedCdSelector, selectorIndex } from './dom-selection.ts';
|
|
30
30
|
import { isEditableTarget } from './input-router.tsx';
|
|
31
31
|
import { type Selection, useSelectionSet } from './use-selection-set.tsx';
|
|
32
|
+
import { useToolModeOptional } from './use-tool-mode.tsx';
|
|
32
33
|
|
|
33
34
|
const STEP_SMALL = 1;
|
|
34
35
|
const STEP_LARGE = 10;
|
|
@@ -62,6 +63,18 @@ export function useKeyboardDiscipline(): void {
|
|
|
62
63
|
const selSet = useSelectionSet();
|
|
63
64
|
const artboardsCtx = useArtboardsContext();
|
|
64
65
|
const dragBus = useDragStateContext();
|
|
66
|
+
// feature-4 (browse/move split) — every keyboard EDITING op here (Cmd+A,
|
|
67
|
+
// Cmd+D, copy/paste-style, Enter/Tab tree traversal, Delete, arrow-nudge) is
|
|
68
|
+
// scoped OUT of the `browse` tool: in the alive mode the mock owns the
|
|
69
|
+
// keyboard (a real form submits on Enter, an input takes Delete). A live ref
|
|
70
|
+
// keeps the document capture listener from re-attaching on every tool change.
|
|
71
|
+
// Default to 'move' (NOT 'browse') when no ToolProvider is above us — a
|
|
72
|
+
// context without a tool store (a bare specimen) must keep its keyboard
|
|
73
|
+
// editing ops, exactly as before this feature. Only an ACTIVE `browse` tool
|
|
74
|
+
// suppresses them.
|
|
75
|
+
const toolCtx = useToolModeOptional();
|
|
76
|
+
const toolRef = useRef(toolCtx?.tool ?? 'move');
|
|
77
|
+
toolRef.current = toolCtx?.tool ?? 'move';
|
|
65
78
|
// Element arrow-nudge burst (Task L1): accumulate left/top, preview inline, and
|
|
66
79
|
// commit once after a short pause (one undo per settle, not per keypress).
|
|
67
80
|
const nudgeRef = useRef<{
|
|
@@ -78,6 +91,8 @@ export function useKeyboardDiscipline(): void {
|
|
|
78
91
|
|
|
79
92
|
const onKeyDown = (e: KeyboardEvent) => {
|
|
80
93
|
if (isEditableTarget(e.target)) return;
|
|
94
|
+
// feature-4 — browse is a pure pass-through; the mock owns the keyboard.
|
|
95
|
+
if (toolRef.current === 'browse') return;
|
|
81
96
|
|
|
82
97
|
// Cmd+A → select all stamped elements in the active artboard.
|
|
83
98
|
const isMeta = e.metaKey || e.ctrlKey;
|
|
@@ -48,7 +48,11 @@ export type ShapeKind = 'square' | 'rounded' | 'circle' | 'diamond' | 'triangle'
|
|
|
48
48
|
// crosshair/text/cell were thin + tiny ("pen almost invisible"); these mirror
|
|
49
49
|
// the tool-palette icons. `move` keeps the system arrow on purpose.
|
|
50
50
|
export const DEFAULT_TOOLS: readonly ToolDescriptor[] = Object.freeze([
|
|
51
|
-
|
|
51
|
+
// feature-4 — Browse is the BOOT default: the mock is alive (buttons click).
|
|
52
|
+
// Press V for the Move (select) tool. No letter shortcut — it's a deliberate
|
|
53
|
+
// "I'm done editing" choice via the palette / Esc-from-a-draw-tool.
|
|
54
|
+
{ id: 'browse', label: 'Browse', shortcut: '', cursor: TOOL_CURSORS.browse },
|
|
55
|
+
{ id: 'move', label: 'Select', shortcut: 'V', cursor: TOOL_CURSORS.move },
|
|
52
56
|
{ id: 'hand', label: 'Hand', shortcut: 'H', cursor: TOOL_CURSORS.hand },
|
|
53
57
|
{ id: 'comment', label: 'Comment', shortcut: 'C', cursor: TOOL_CURSORS.comment },
|
|
54
58
|
{ id: 'pen', label: 'Pen', shortcut: 'B', cursor: TOOL_CURSORS.pen },
|
|
@@ -89,7 +93,11 @@ const ToolContext = createContext<ToolContextValue | null>(null);
|
|
|
89
93
|
export function ToolProvider({
|
|
90
94
|
children,
|
|
91
95
|
tools = DEFAULT_TOOLS,
|
|
92
|
-
|
|
96
|
+
// feature-4 (browse/move split, DDR-187) — boot into `browse` so a freshly
|
|
97
|
+
// opened mock is ALIVE (native pass-through). V flips to the Move (select)
|
|
98
|
+
// tool. This is the user-decided boot posture (2026-07-15), zero regression
|
|
99
|
+
// for existing canvases + every native-input surface.
|
|
100
|
+
initial = 'browse',
|
|
93
101
|
}: {
|
|
94
102
|
children: ReactNode;
|
|
95
103
|
tools?: readonly ToolDescriptor[];
|
|
@@ -134,13 +142,32 @@ export function ToolProvider({
|
|
|
134
142
|
const desc = tools.find((t) => t.id === tool);
|
|
135
143
|
if (!desc) return;
|
|
136
144
|
const prev = document.body.style.cursor;
|
|
137
|
-
document.body.style.cursor = desc.cursor;
|
|
138
145
|
let styleEl = document.getElementById('dc-tool-cursor') as HTMLStyleElement | null;
|
|
139
146
|
if (!styleEl) {
|
|
140
147
|
styleEl = document.createElement('style');
|
|
141
148
|
styleEl.id = 'dc-tool-cursor';
|
|
142
149
|
document.head.appendChild(styleEl);
|
|
143
150
|
}
|
|
151
|
+
// feature-4 — the browse tool is a pure pass-through: it must NOT force a
|
|
152
|
+
// global cursor, or the `* { cursor: … !important }` rule below would beat
|
|
153
|
+
// the mock's own affordance cursors (pointer over a button, text over an
|
|
154
|
+
// input) and the canvas would stop reading as alive. Clear the forced rule
|
|
155
|
+
// and let the body/native cursors win.
|
|
156
|
+
if (tool === 'browse') {
|
|
157
|
+
document.body.style.cursor = '';
|
|
158
|
+
styleEl.textContent = '';
|
|
159
|
+
if (typeof window !== 'undefined' && window.parent && window.parent !== window) {
|
|
160
|
+
try {
|
|
161
|
+
window.parent.postMessage({ dgn: 'tool-cursor', tool }, '*');
|
|
162
|
+
} catch {
|
|
163
|
+
/* cross-origin parent rejected */
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
return () => {
|
|
167
|
+
document.body.style.cursor = prev;
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
document.body.style.cursor = desc.cursor;
|
|
144
171
|
// Truly GLOBAL inside the canvas document — `*` so it covers the empty grid
|
|
145
172
|
// host, `.dc-world`, every artboard + its content, AND the floating chrome
|
|
146
173
|
// (minimap, toolbar). The earlier `.dc-world`-scoped rule left the empty
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file web-overlay-content.tsx — feature-3-web-artboards T2.
|
|
3
|
+
* @scope apps/studio/web-overlay-content.tsx
|
|
4
|
+
* @purpose Registers the 'web' kind's on-canvas chrome into the
|
|
5
|
+
* foundation's kindOverlayRegistry (artboard-guides-overlay.tsx):
|
|
6
|
+
* a small breakpoint-band chip in the header's top-right corner
|
|
7
|
+
* (the video badge's own slot — 'web' and 'video' are mutually
|
|
8
|
+
* exclusive via DCArtboard's resolvedKind, so there's no
|
|
9
|
+
* collision) showing the artboard's CURRENT live width as
|
|
10
|
+
* "≤ {width}px" — the Framer-breakpoints-bar vocabulary (Design
|
|
11
|
+
* Decisions 2/3). Dragging the artboard's resize handle (T4)
|
|
12
|
+
* visibly updates this label live, which is how "drag width to
|
|
13
|
+
* test reflow" reads as a breakpoint change rather than a plain
|
|
14
|
+
* resize.
|
|
15
|
+
*
|
|
16
|
+
* Decorative — aria-hidden, pointer-events none. Reuses the
|
|
17
|
+
* engine's own HUD chrome tokens (--bg-2/--fg-1/--font-mono),
|
|
18
|
+
* same as the artboard label strip itself, rather than DS tokens
|
|
19
|
+
* — this chip IS chrome, never exported content. Always-on (no
|
|
20
|
+
* visibility gate): unlike print's bleed/trim/margin overlay
|
|
21
|
+
* (which visually covers artboard content and needs an opt-in
|
|
22
|
+
* toggle), this is a single small corner label with nothing to
|
|
23
|
+
* collide with, so there's no reason to hide it.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import { type KindOverlayRenderFn, registerKindOverlay } from './artboard-guides-overlay.tsx';
|
|
27
|
+
|
|
28
|
+
const WebOverlayContent: KindOverlayRenderFn = ({ rect }) => {
|
|
29
|
+
const width = Math.round(rect.w);
|
|
30
|
+
return (
|
|
31
|
+
<div
|
|
32
|
+
style={{
|
|
33
|
+
position: 'absolute',
|
|
34
|
+
top: 4,
|
|
35
|
+
right: 6,
|
|
36
|
+
padding: '2px 6px',
|
|
37
|
+
borderRadius: 3,
|
|
38
|
+
background: 'var(--bg-2, #e8e3d8)',
|
|
39
|
+
color: 'var(--fg-1, #4a3f30)',
|
|
40
|
+
fontFamily: 'var(--font-mono, ui-monospace, SFMono-Regular, Menlo, monospace)',
|
|
41
|
+
fontSize: 9,
|
|
42
|
+
letterSpacing: '0.04em',
|
|
43
|
+
whiteSpace: 'nowrap',
|
|
44
|
+
}}
|
|
45
|
+
>
|
|
46
|
+
{`≤ ${width}px`}
|
|
47
|
+
</div>
|
|
48
|
+
);
|
|
49
|
+
};
|
|
50
|
+
WebOverlayContent.displayName = 'WebOverlayContent';
|
|
51
|
+
|
|
52
|
+
registerKindOverlay('web', WebOverlayContent);
|