@1agh/maude 0.45.2 → 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 +151 -1
- package/apps/studio/annotations-layer.tsx +42 -0
- package/apps/studio/annotations-snap.ts +50 -10
- package/apps/studio/api.ts +760 -15
- package/apps/studio/artboard-guides-overlay.tsx +270 -0
- 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/_pdf-playwright.mjs +35 -4
- package/apps/studio/bin/_png-playwright.mjs +38 -4
- package/apps/studio/bin/_pw-launch.mjs +52 -0
- package/apps/studio/bin/_pw-launch.test.mjs +90 -0
- package/apps/studio/bin/_smart-frames.mjs +419 -0
- package/apps/studio/bin/_smart-frames.test.mjs +140 -0
- package/apps/studio/bin/agent-browser-safe.sh +29 -0
- package/apps/studio/bin/curl-local.sh +28 -0
- package/apps/studio/bin/smart-frames.sh +30 -0
- package/apps/studio/canvas-cursors.ts +6 -0
- package/apps/studio/canvas-edit.ts +972 -6
- package/apps/studio/canvas-icons.tsx +13 -0
- package/apps/studio/canvas-lib.tsx +206 -11
- package/apps/studio/canvas-shell.tsx +809 -50
- package/apps/studio/client/app.jsx +1554 -206
- package/apps/studio/client/panels/ChatPanel.jsx +92 -3
- package/apps/studio/client/panels/SettingsPanel.jsx +211 -0
- package/apps/studio/client/panels/acp-capabilities.js +11 -0
- package/apps/studio/client/styles/3-shell-maude.css +39 -0
- package/apps/studio/client/styles/6-acp-chat.css +41 -0
- package/apps/studio/contextual-toolbar.tsx +5 -3
- package/apps/studio/dist/client.bundle.js +1627 -1627
- package/apps/studio/dist/comment-mount.js +2 -2
- package/apps/studio/dist/styles.css +1 -1
- package/apps/studio/dom-selection.ts +20 -0
- package/apps/studio/export-dialog.tsx +138 -18
- package/apps/studio/exporters/pdf.ts +332 -18
- package/apps/studio/exporters/png.ts +45 -3
- package/apps/studio/footage/schema.ts +17 -1
- package/apps/studio/generation/gemma-models.ts +224 -0
- package/apps/studio/generation/prefs.ts +43 -0
- package/apps/studio/grid-track-handles.ts +179 -0
- package/apps/studio/handoff.ts +35 -0
- package/apps/studio/http.ts +331 -7
- package/apps/studio/input-router.tsx +73 -17
- package/apps/studio/print/marks.ts +113 -0
- package/apps/studio/print/units.ts +269 -0
- package/apps/studio/print-overlay-content.tsx +132 -0
- package/apps/studio/test/acp-mode-banner.test.ts +45 -0
- package/apps/studio/test/acp-session-allowed-tools.test.ts +187 -0
- package/apps/studio/test/annotations-snap.test.ts +56 -0
- package/apps/studio/test/artboard-guides-overlay.test.tsx +152 -0
- package/apps/studio/test/artboard-kinds.test.tsx +83 -0
- package/apps/studio/test/artboard-selection-attrs.test.ts +69 -0
- 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 +237 -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 +363 -0
- package/apps/studio/test/exporters/png.test.ts +49 -1
- 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 +326 -0
- package/apps/studio/test/print-marks.test.ts +113 -0
- package/apps/studio/test/print-units.test.ts +173 -0
- package/apps/studio/test/use-snap-guides.test.ts +81 -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-chrome-visibility.tsx +19 -0
- package/apps/studio/use-element-resize.tsx +24 -3
- package/apps/studio/use-grid-track-handles.tsx +364 -0
- package/apps/studio/use-keyboard-discipline.tsx +15 -0
- package/apps/studio/use-snap-guides.tsx +73 -5
- package/apps/studio/use-spacing-handles.tsx +9 -5
- package/apps/studio/use-tool-mode.tsx +30 -3
- package/apps/studio/web-overlay-content.tsx +52 -0
- package/apps/studio/whats-new.json +55 -0
- package/cli/commands/design.mjs +25 -1
- package/package.json +8 -8
- package/plugins/design/dependencies.json +35 -0
- package/plugins/design/templates/_shell.html +4 -0
|
@@ -12,6 +12,14 @@ import { createRoot } from 'react-dom/client';
|
|
|
12
12
|
// import that Bun erases), so this pulls only string constants into the client
|
|
13
13
|
// bundle — no React, no input-router. See the tool-cursor handler below.
|
|
14
14
|
import { resolveToolCursor } from '../canvas-cursors.ts';
|
|
15
|
+
// feature-3-web-artboards T5 — the single source (grid-track-handles.ts) for
|
|
16
|
+
// the Inspector's Grid-section track parser/serializer, shared with the
|
|
17
|
+
// on-canvas gutter-drag overlay so both edit the SAME track-list shape.
|
|
18
|
+
import { GRID_KEYWORD_UNITS, parseTrackList, serializeTrackList } from '../grid-track-handles.ts';
|
|
19
|
+
// feature-2-print-artboards T2 — the single source (print/units.ts) for the
|
|
20
|
+
// Inspector's paper-preset picker, same "pull only pure math/data" shape as
|
|
21
|
+
// canvas-cursors.ts above.
|
|
22
|
+
import { PAPER_PRESETS, resolvePrintArtboard } from '../print/units.ts';
|
|
15
23
|
import { sizingModeOf, sizingModePatch } from '../sizing-mode.ts';
|
|
16
24
|
import { canvasUrl } from './canvas-url.js';
|
|
17
25
|
import ChatPanel from './panels/ChatPanel.jsx';
|
|
@@ -29,7 +37,7 @@ import GenerateDialog from './generate-dialog.jsx';
|
|
|
29
37
|
import RepoBranchSwitcher from './panels/RepoBranchSwitcher.jsx';
|
|
30
38
|
import SettingsPanel from './panels/SettingsPanel.jsx';
|
|
31
39
|
import StickerPicker from './panels/StickerPicker.jsx';
|
|
32
|
-
import { AlignPad, AngleDial, ColorField, IconButtonGroup, IconToggleGroup, makeScrubHandler, NumberField, RadiusControl, Segmented, SliderField, Toggle, UnitSelect, ValueTokenField } from './inspector-controls.jsx';
|
|
40
|
+
import { AlignPad, AngleDial, ColorField, IconButtonGroup, IconToggleGroup, makeScrubHandler, NumberField, RadiusControl, Segmented, Select, SliderField, Toggle, UnitSelect, ValueTokenField } from './inspector-controls.jsx';
|
|
33
41
|
import {
|
|
34
42
|
ALargeSmall as LuALargeSmall,
|
|
35
43
|
AlignCenter as LuAlignCenter,
|
|
@@ -95,6 +103,11 @@ const MINIMAP_STORE = 'mdcc-minimap-visible';
|
|
|
95
103
|
const ZOOMCTL_STORE = 'mdcc-zoomctl-visible';
|
|
96
104
|
const ANNOT_STORE = 'mdcc-annotations-visible';
|
|
97
105
|
const AUTOOPEN_STORE = 'maude-auto-open-inspector';
|
|
106
|
+
// DDR-185 security addendum — floor between OS notifications for a NEW
|
|
107
|
+
// permission/elicitation request, so a burst of distinct pending approvals
|
|
108
|
+
// (capped at 15 total server-side, DDR-179/180) reads as one attention-
|
|
109
|
+
// getting ping instead of a rapid-fire flood. The in-app badge is unaffected.
|
|
110
|
+
const ATTENTION_NOTIFY_COOLDOWN_MS = 30_000;
|
|
98
111
|
|
|
99
112
|
// feature-unified-settings-modal — the Settings modal's view prefs are ALSO
|
|
100
113
|
// persisted to disk (~/.config/maude/prefs.json via /_api/ui-prefs), so they
|
|
@@ -169,6 +182,20 @@ const CANVAS_EXT_RE = /\.(tsx|html?)$/i;
|
|
|
169
182
|
// the bundle is consumed in a context that hasn't run the build.
|
|
170
183
|
const MDCC_VERSION = typeof __MDCC_VERSION__ !== 'undefined' ? __MDCC_VERSION__ : 'dev';
|
|
171
184
|
|
|
185
|
+
// Best-effort OS notification — shared by handleAssistantFinished and
|
|
186
|
+
// handleAssistantAttention below (identical support/permission check +
|
|
187
|
+
// try/catch, only the title/body differ). The in-app badge stays the
|
|
188
|
+
// reliable signal if this silently fails for any reason.
|
|
189
|
+
function notifyDesktop(title, body) {
|
|
190
|
+
try {
|
|
191
|
+
if (typeof Notification !== 'undefined' && Notification.permission === 'granted') {
|
|
192
|
+
new Notification(title, { body });
|
|
193
|
+
}
|
|
194
|
+
} catch {
|
|
195
|
+
/* best-effort — the in-app badge is the reliable signal */
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
172
199
|
function readInitialTheme() {
|
|
173
200
|
if (typeof window === 'undefined') return 'dark';
|
|
174
201
|
try {
|
|
@@ -248,6 +275,58 @@ function shellToast(message, ok = false) {
|
|
|
248
275
|
}, 3200);
|
|
249
276
|
}
|
|
250
277
|
|
|
278
|
+
// feature-4 (browse/move split, DDR-187) — one-time first-run teaching hint.
|
|
279
|
+
// The boot default is `browse` (the mock is ALIVE — buttons click), so the
|
|
280
|
+
// Figma muscle-memory gesture (plain click = select) does nothing until V is
|
|
281
|
+
// pressed. Surface that ONCE, gated by a localStorage marker (same family as
|
|
282
|
+
// `mdcc-whatsnew-seen`). Auto-dismisses; a V press (learned) clears it early.
|
|
283
|
+
const BROWSE_HINT_SEEN = 'maude-browse-hint-seen';
|
|
284
|
+
function browseFirstRunHint() {
|
|
285
|
+
if (typeof document === 'undefined' || typeof localStorage === 'undefined') return;
|
|
286
|
+
try {
|
|
287
|
+
if (localStorage.getItem(BROWSE_HINT_SEEN) === '1') return;
|
|
288
|
+
} catch {
|
|
289
|
+
return;
|
|
290
|
+
}
|
|
291
|
+
if (document.getElementById('st-browse-hint')) return;
|
|
292
|
+
const el = document.createElement('div');
|
|
293
|
+
el.id = 'st-browse-hint';
|
|
294
|
+
el.setAttribute('role', 'status');
|
|
295
|
+
el.style.cssText =
|
|
296
|
+
'position:fixed;left:50%;bottom:64px;transform:translateX(-50%);z-index:81;' +
|
|
297
|
+
'display:flex;align-items:center;gap:10px;max-width:520px;padding:10px 14px;border-radius:10px;' +
|
|
298
|
+
'font:12px/1.5 var(--font-ui,system-ui,sans-serif);background:var(--surface-2,#1b1e24);' +
|
|
299
|
+
'color:var(--text-1,#e7eaf0);border:1px solid var(--border-1,#333a45);' +
|
|
300
|
+
'box-shadow:0 10px 34px rgba(0,0,0,.42);opacity:0;transition:opacity 160ms ease;';
|
|
301
|
+
el.innerHTML =
|
|
302
|
+
'<span>Your mock is <strong>live</strong> — click things to try it. ' +
|
|
303
|
+
'Press <kbd style="padding:1px 6px;border-radius:5px;border:1px solid var(--border-1,#333a45);' +
|
|
304
|
+
'background:var(--surface-3,#262b33);font-family:var(--font-mono,monospace)">V</kbd> ' +
|
|
305
|
+
'to select & edit like Figma.</span>' +
|
|
306
|
+
'<button type="button" aria-label="Dismiss" style="background:none;border:none;color:inherit;' +
|
|
307
|
+
'cursor:pointer;font-size:15px;line-height:1;opacity:.65;padding:2px">×</button>';
|
|
308
|
+
const dismiss = () => {
|
|
309
|
+
try {
|
|
310
|
+
localStorage.setItem(BROWSE_HINT_SEEN, '1');
|
|
311
|
+
} catch {
|
|
312
|
+
/* private mode */
|
|
313
|
+
}
|
|
314
|
+
el.style.opacity = '0';
|
|
315
|
+
setTimeout(() => el.remove(), 200);
|
|
316
|
+
document.removeEventListener('keydown', onV, true);
|
|
317
|
+
};
|
|
318
|
+
const onV = (e) => {
|
|
319
|
+
if ((e.key === 'v' || e.key === 'V') && !e.metaKey && !e.ctrlKey && !e.altKey) dismiss();
|
|
320
|
+
};
|
|
321
|
+
el.querySelector('button')?.addEventListener('click', dismiss);
|
|
322
|
+
document.addEventListener('keydown', onV, true);
|
|
323
|
+
document.body.appendChild(el);
|
|
324
|
+
requestAnimationFrame(() => {
|
|
325
|
+
el.style.opacity = '1';
|
|
326
|
+
});
|
|
327
|
+
setTimeout(dismiss, 9000);
|
|
328
|
+
}
|
|
329
|
+
|
|
251
330
|
// Strip canvas extensions for display. `Canvas Viewport.tsx` → `Canvas Viewport`.
|
|
252
331
|
// Sidecars (`.meta.json`, `.css`, `.registry.json`) keep their extensions so
|
|
253
332
|
// the file type stays unambiguous.
|
|
@@ -427,6 +506,19 @@ const STICONS = {
|
|
|
427
506
|
</>
|
|
428
507
|
),
|
|
429
508
|
folder: <path d="M2 4.5h4l1.3 1.5H14V13H2z" />,
|
|
509
|
+
// feature-4 T7b — Layers padlock (closed / open).
|
|
510
|
+
lock: (
|
|
511
|
+
<>
|
|
512
|
+
<rect x="3.5" y="7" width="9" height="6.5" rx="1" />
|
|
513
|
+
<path d="M5.5 7V5a2.5 2.5 0 015 0v2" />
|
|
514
|
+
</>
|
|
515
|
+
),
|
|
516
|
+
unlock: (
|
|
517
|
+
<>
|
|
518
|
+
<rect x="3.5" y="7" width="9" height="6.5" rx="1" />
|
|
519
|
+
<path d="M5.5 7V5a2.5 2.5 0 015-.7" />
|
|
520
|
+
</>
|
|
521
|
+
),
|
|
430
522
|
search: (
|
|
431
523
|
<>
|
|
432
524
|
<circle cx="7" cy="7" r="4" />
|
|
@@ -960,6 +1052,37 @@ const PNG_SCALES = [
|
|
|
960
1052
|
{ value: 3, label: '3× (max)' },
|
|
961
1053
|
];
|
|
962
1054
|
|
|
1055
|
+
// feature-2-print-artboards T6 — the PNG card's resolution picker folds the
|
|
1056
|
+
// legacy 1×/2×/3× multiplier and the new physical-DPI ladder (T4) into ONE
|
|
1057
|
+
// select: `kind:'scale'` entries send `options.scale`, `kind:'dpi'` entries
|
|
1058
|
+
// send `options.dpi` (exporters/png.ts resolveDeviceScale — dpi wins over
|
|
1059
|
+
// scale when both could apply). Default stays 2× (unchanged UX for anyone
|
|
1060
|
+
// not exporting for print).
|
|
1061
|
+
const PNG_RESOLUTIONS = [
|
|
1062
|
+
{ id: '1x', label: '1× (native)', kind: 'scale', value: 1 },
|
|
1063
|
+
{ id: '2x', label: '2× (retina)', kind: 'scale', value: 2 },
|
|
1064
|
+
{ id: '3x', label: '3× (max)', kind: 'scale', value: 3 },
|
|
1065
|
+
{ id: 'dpi150', label: '150 dpi', kind: 'dpi', value: 150 },
|
|
1066
|
+
{ id: 'dpi300', label: '300 dpi (print)', kind: 'dpi', value: 300 },
|
|
1067
|
+
{ id: 'dpi600', label: '600 dpi (high-res print)', kind: 'dpi', value: 600 },
|
|
1068
|
+
];
|
|
1069
|
+
const PNG_RESOLUTION_DEFAULT = '2x';
|
|
1070
|
+
|
|
1071
|
+
// feature-2-print-artboards T5/T6 (dogfood follow-up) — the PDF page itself
|
|
1072
|
+
// is always vector (text/shapes never rasterize), but any RASTER content ON
|
|
1073
|
+
// the artboard — a dropped photo, a large-format piece authored at a
|
|
1074
|
+
// fraction of its real physical size (e.g. a billboard at 1:10 scale) —
|
|
1075
|
+
// still embeds as a bitmap whose density this controls. Default "Auto (1×)"
|
|
1076
|
+
// is today's unchanged behavior; PDF has no legacy scale concept, so unlike
|
|
1077
|
+
// PNG_RESOLUTIONS this is DPI-only.
|
|
1078
|
+
const PDF_DPI_OPTIONS = [
|
|
1079
|
+
{ id: 'auto', label: 'Auto (1×)', value: undefined },
|
|
1080
|
+
{ id: 'dpi150', label: '150 dpi', value: 150 },
|
|
1081
|
+
{ id: 'dpi300', label: '300 dpi (print)', value: 300 },
|
|
1082
|
+
{ id: 'dpi600', label: '600 dpi (high-res print)', value: 600 },
|
|
1083
|
+
];
|
|
1084
|
+
const PDF_DPI_DEFAULT = 'auto';
|
|
1085
|
+
|
|
963
1086
|
// feature-bulk-media-insert — best-effort MIME guess from a filename extension.
|
|
964
1087
|
// Only used to classify a natively-picked file (Rust returns `{name, bytes}`,
|
|
965
1088
|
// no type) for the destination-toggle's image/video/audio split; the actual
|
|
@@ -1288,6 +1411,18 @@ function ExportDialog({
|
|
|
1288
1411
|
initialScope && EXPORT_SCOPE_LABELS[initialScope] ? initialScope : 'artboard'
|
|
1289
1412
|
);
|
|
1290
1413
|
const [scale, setScale] = useState(2);
|
|
1414
|
+
// feature-2-print-artboards T4/T6 — PNG resolution (folds scale + dpi, see
|
|
1415
|
+
// PNG_RESOLUTIONS above).
|
|
1416
|
+
const [pngResId, setPngResId] = useState(PNG_RESOLUTION_DEFAULT);
|
|
1417
|
+
// feature-2-print-artboards T5/T6 — PDF print options. Sent unconditionally
|
|
1418
|
+
// on every PDF export; the server no-ops them for a non-print artboard (no
|
|
1419
|
+
// `print` JSX prop → applyPrintBoxesAndMarks never runs), so there's no
|
|
1420
|
+
// need to detect the artboard's kind client-side before showing these.
|
|
1421
|
+
const [pdfIncludeBleed, setPdfIncludeBleed] = useState(true);
|
|
1422
|
+
const [pdfMarksOpen, setPdfMarksOpen] = useState(false);
|
|
1423
|
+
const [pdfMarksCrop, setPdfMarksCrop] = useState(false);
|
|
1424
|
+
const [pdfMarksRegistration, setPdfMarksRegistration] = useState(false);
|
|
1425
|
+
const [pdfDpiId, setPdfDpiId] = useState(PDF_DPI_DEFAULT);
|
|
1291
1426
|
// DDR-148 addendum — mp4/webm of a registered video-comp render through
|
|
1292
1427
|
// renderMediaOnWeb, which produces real audio (Remotion owns the
|
|
1293
1428
|
// TransitionSeries/volume-closure timeline). gif has no audio track at all
|
|
@@ -1338,12 +1473,31 @@ function ExportDialog({
|
|
|
1338
1473
|
}
|
|
1339
1474
|
setBusy(true);
|
|
1340
1475
|
setStatus(null);
|
|
1341
|
-
// `scale` drives
|
|
1342
|
-
//
|
|
1343
|
-
|
|
1476
|
+
// `scale` drives video resolution (deviceScaleFactor → encoder dims);
|
|
1477
|
+
// temporal formats were previously fixed at the tiny native size. PNG's
|
|
1478
|
+
// resolution now comes from pngResId (T4/T6 — scale OR dpi).
|
|
1479
|
+
const options = card.temporal ? { scale } : {};
|
|
1480
|
+
if (card.format === 'png') {
|
|
1481
|
+
const res = PNG_RESOLUTIONS.find((r) => r.id === pngResId) || PNG_RESOLUTIONS[1];
|
|
1482
|
+
if (res.kind === 'dpi') options.dpi = res.value;
|
|
1483
|
+
else options.scale = res.value;
|
|
1484
|
+
}
|
|
1344
1485
|
// Export-with-audio (DDR-148 addendum) — mp4/webm only; gif is silent by
|
|
1345
1486
|
// format, so the checkbox never renders for it (see hasAudioToggle below).
|
|
1346
1487
|
if (card.format === 'mp4' || card.format === 'webm') options.audio = audio;
|
|
1488
|
+
// feature-2-print-artboards T5/T6 — always sent on a PDF export; the
|
|
1489
|
+
// adapter no-ops includeBleed/marks for a non-print artboard (T5).
|
|
1490
|
+
if (card.format === 'pdf') {
|
|
1491
|
+
options.pdfPrint = {
|
|
1492
|
+
includeBleed: pdfIncludeBleed,
|
|
1493
|
+
marks: { crop: pdfMarksCrop, registration: pdfMarksRegistration },
|
|
1494
|
+
};
|
|
1495
|
+
// Dogfood follow-up — raster CONTENT on the artboard (a dropped photo,
|
|
1496
|
+
// large-format art) still needs a real capture density; the page
|
|
1497
|
+
// itself stays vector regardless of this.
|
|
1498
|
+
const pdfDpi = PDF_DPI_OPTIONS.find((d) => d.id === pdfDpiId)?.value;
|
|
1499
|
+
if (pdfDpi !== undefined) options.dpi = pdfDpi;
|
|
1500
|
+
}
|
|
1347
1501
|
// Scope targeting hints (resolveScope reads these): `artboardId` makes
|
|
1348
1502
|
// "Active artboard" export the right screen instead of `:first-of-type`;
|
|
1349
1503
|
// `selection` makes "Current selection" export the selected element. Mirrors
|
|
@@ -1428,14 +1582,42 @@ function ExportDialog({
|
|
|
1428
1582
|
</select>
|
|
1429
1583
|
</div>
|
|
1430
1584
|
)}
|
|
1431
|
-
{!card.handoff &&
|
|
1585
|
+
{!card.handoff && card.format === 'png' && (
|
|
1432
1586
|
<div className="st-dialog-row">
|
|
1433
1587
|
<label className="st-dialog-lbl" htmlFor="st-export-size">
|
|
1434
|
-
|
|
1588
|
+
Resolution
|
|
1435
1589
|
</label>
|
|
1436
1590
|
<select
|
|
1437
1591
|
id="st-export-size"
|
|
1438
1592
|
className="st-select"
|
|
1593
|
+
value={pngResId}
|
|
1594
|
+
onChange={(e) => setPngResId(e.target.value)}
|
|
1595
|
+
>
|
|
1596
|
+
{PNG_RESOLUTIONS.map((r) => (
|
|
1597
|
+
<option key={r.id} value={r.id}>
|
|
1598
|
+
{r.label}
|
|
1599
|
+
</option>
|
|
1600
|
+
))}
|
|
1601
|
+
</select>
|
|
1602
|
+
</div>
|
|
1603
|
+
)}
|
|
1604
|
+
{!card.handoff && card.format === 'png' && (
|
|
1605
|
+
<div className="st-mono" style={{ fontSize: 11, color: 'var(--fg-3)' }}>
|
|
1606
|
+
{(() => {
|
|
1607
|
+
const res = PNG_RESOLUTIONS.find((r) => r.id === pngResId) || PNG_RESOLUTIONS[1];
|
|
1608
|
+
const factor = res.kind === 'dpi' ? res.value / 96 : res.value;
|
|
1609
|
+
return `${res.label} ≈ ${Math.round(1440 * factor)}×${Math.round(900 * factor)} for a 1440×900 artboard.`;
|
|
1610
|
+
})()}
|
|
1611
|
+
</div>
|
|
1612
|
+
)}
|
|
1613
|
+
{!card.handoff && card.temporal && (
|
|
1614
|
+
<div className="st-dialog-row">
|
|
1615
|
+
<label className="st-dialog-lbl" htmlFor="st-export-temporal-size">
|
|
1616
|
+
Resolution
|
|
1617
|
+
</label>
|
|
1618
|
+
<select
|
|
1619
|
+
id="st-export-temporal-size"
|
|
1620
|
+
className="st-select"
|
|
1439
1621
|
value={scale}
|
|
1440
1622
|
onChange={(e) => setScale(Number(e.target.value))}
|
|
1441
1623
|
>
|
|
@@ -1447,16 +1629,86 @@ function ExportDialog({
|
|
|
1447
1629
|
</select>
|
|
1448
1630
|
</div>
|
|
1449
1631
|
)}
|
|
1450
|
-
{!card.handoff && card.format === 'png' && (
|
|
1451
|
-
<div className="st-mono" style={{ fontSize: 11, color: 'var(--fg-3)' }}>
|
|
1452
|
-
Resolution multiplier — {scale}× ≈ {1440 * scale}×{900 * scale} for a 1440×900 artboard.
|
|
1453
|
-
</div>
|
|
1454
|
-
)}
|
|
1455
1632
|
{!card.handoff && card.temporal && (
|
|
1456
1633
|
<div className="st-mono" style={{ fontSize: 11, color: 'var(--fg-3)' }}>
|
|
1457
1634
|
{scale}× the artboard's native resolution (e.g. 960×540 → {960 * scale}×{540 * scale}).
|
|
1458
1635
|
</div>
|
|
1459
1636
|
)}
|
|
1637
|
+
{!card.handoff && card.format === 'pdf' && (
|
|
1638
|
+
<>
|
|
1639
|
+
<div className="st-dialog-row">
|
|
1640
|
+
<label className="st-dialog-lbl" htmlFor="st-export-pdf-dpi">
|
|
1641
|
+
Image quality
|
|
1642
|
+
</label>
|
|
1643
|
+
<select
|
|
1644
|
+
id="st-export-pdf-dpi"
|
|
1645
|
+
className="st-select"
|
|
1646
|
+
value={pdfDpiId}
|
|
1647
|
+
onChange={(e) => setPdfDpiId(e.target.value)}
|
|
1648
|
+
>
|
|
1649
|
+
{PDF_DPI_OPTIONS.map((d) => (
|
|
1650
|
+
<option key={d.id} value={d.id}>
|
|
1651
|
+
{d.label}
|
|
1652
|
+
</option>
|
|
1653
|
+
))}
|
|
1654
|
+
</select>
|
|
1655
|
+
</div>
|
|
1656
|
+
<div className="st-mono" style={{ fontSize: 11, color: 'var(--fg-3)' }}>
|
|
1657
|
+
The page stays vector; this only sets the capture density for raster content on it
|
|
1658
|
+
(dropped photos, large-format art).
|
|
1659
|
+
</div>
|
|
1660
|
+
<div className="st-dialog-row">
|
|
1661
|
+
<label
|
|
1662
|
+
className="st-dialog-lbl"
|
|
1663
|
+
htmlFor="st-export-pdf-bleed"
|
|
1664
|
+
style={{ display: 'flex', alignItems: 'center', gap: 8, cursor: 'pointer' }}
|
|
1665
|
+
>
|
|
1666
|
+
<input
|
|
1667
|
+
id="st-export-pdf-bleed"
|
|
1668
|
+
type="checkbox"
|
|
1669
|
+
checked={pdfIncludeBleed}
|
|
1670
|
+
onChange={(e) => setPdfIncludeBleed(e.target.checked)}
|
|
1671
|
+
/>
|
|
1672
|
+
Include bleed
|
|
1673
|
+
</label>
|
|
1674
|
+
</div>
|
|
1675
|
+
<div className="st-dialog-row">
|
|
1676
|
+
<button
|
|
1677
|
+
type="button"
|
|
1678
|
+
className="st-dialog-lbl"
|
|
1679
|
+
style={{ background: 'none', border: 0, padding: 0, cursor: 'pointer' }}
|
|
1680
|
+
onClick={() => setPdfMarksOpen((v) => !v)}
|
|
1681
|
+
aria-expanded={pdfMarksOpen}
|
|
1682
|
+
>
|
|
1683
|
+
{pdfMarksOpen ? '▾' : '▸'} Marks
|
|
1684
|
+
</button>
|
|
1685
|
+
</div>
|
|
1686
|
+
{pdfMarksOpen && (
|
|
1687
|
+
<div style={{ padding: '0 12px 4px', display: 'flex', flexDirection: 'column', gap: 6 }}>
|
|
1688
|
+
<label style={{ display: 'flex', alignItems: 'center', gap: 8, cursor: 'pointer', fontSize: 12 }}>
|
|
1689
|
+
<input
|
|
1690
|
+
type="checkbox"
|
|
1691
|
+
checked={pdfMarksCrop}
|
|
1692
|
+
onChange={(e) => setPdfMarksCrop(e.target.checked)}
|
|
1693
|
+
/>
|
|
1694
|
+
Crop marks
|
|
1695
|
+
</label>
|
|
1696
|
+
<label style={{ display: 'flex', alignItems: 'center', gap: 8, cursor: 'pointer', fontSize: 12 }}>
|
|
1697
|
+
<input
|
|
1698
|
+
type="checkbox"
|
|
1699
|
+
checked={pdfMarksRegistration}
|
|
1700
|
+
onChange={(e) => setPdfMarksRegistration(e.target.checked)}
|
|
1701
|
+
/>
|
|
1702
|
+
Registration marks
|
|
1703
|
+
</label>
|
|
1704
|
+
</div>
|
|
1705
|
+
)}
|
|
1706
|
+
<div className="st-mono" style={{ fontSize: 11, color: 'var(--fg-3)' }}>
|
|
1707
|
+
Bleed and marks apply to print (<code>kind="print"</code>) artboards only.
|
|
1708
|
+
{scope === 'canvas-as-separate' ? ' One PDF page per artboard.' : ''}
|
|
1709
|
+
</div>
|
|
1710
|
+
</>
|
|
1711
|
+
)}
|
|
1460
1712
|
{!card.handoff && (card.format === 'mp4' || card.format === 'webm') && (
|
|
1461
1713
|
<div className="st-dialog-row">
|
|
1462
1714
|
<label
|
|
@@ -2867,7 +3119,10 @@ function ToolsDropdown({ onAction, onClose }) {
|
|
|
2867
3119
|
onAction={onAction}
|
|
2868
3120
|
onClose={onClose}
|
|
2869
3121
|
items={[
|
|
2870
|
-
|
|
3122
|
+
// feature-4 (browse/move split) — Browse is the boot default (mock is
|
|
3123
|
+
// alive); Move (V) is the select tool.
|
|
3124
|
+
{ id: 'browse', label: 'Browse (interact)', shortcut: '' },
|
|
3125
|
+
{ id: 'move', label: 'Select', shortcut: 'V' },
|
|
2871
3126
|
{ id: 'hand', label: 'Hand', shortcut: 'H' },
|
|
2872
3127
|
{ id: 'comment', label: 'Comment', shortcut: 'C' },
|
|
2873
3128
|
{ id: 'pen', label: 'Pen', shortcut: 'B' },
|
|
@@ -2933,6 +3188,11 @@ function EditDropdown({ onAction, onClose, hasCanvas }) {
|
|
|
2933
3188
|
{ id: 'new-artboard:laptop', label: 'New artboard: Laptop' },
|
|
2934
3189
|
{ id: 'new-artboard:tablet', label: 'New artboard: Tablet' },
|
|
2935
3190
|
{ id: 'new-artboard:mobile', label: 'New artboard: Mobile' },
|
|
3191
|
+
// feature-2-print-artboards T2 — "+ Artboard" quick-insert must
|
|
3192
|
+
// set kind="print" + the print prop together (the plan's own
|
|
3193
|
+
// gotcha for this task), not just a plain digital-sized board.
|
|
3194
|
+
{ id: 'new-artboard:print-a4', label: 'New artboard: A4 (print)' },
|
|
3195
|
+
{ id: 'new-artboard:print-letter', label: 'New artboard: Letter (print)' },
|
|
2936
3196
|
]
|
|
2937
3197
|
: []),
|
|
2938
3198
|
]}
|
|
@@ -2968,6 +3228,8 @@ function Menubar({
|
|
|
2968
3228
|
onToggleZoomCtl,
|
|
2969
3229
|
presentMode,
|
|
2970
3230
|
onTogglePresent,
|
|
3231
|
+
printGuidesVisible,
|
|
3232
|
+
onTogglePrintGuides,
|
|
2971
3233
|
postToActiveCanvas,
|
|
2972
3234
|
onOpenWhatsNew,
|
|
2973
3235
|
onOpenReadiness,
|
|
@@ -3105,6 +3367,18 @@ function Menubar({
|
|
|
3105
3367
|
checked: presentMode,
|
|
3106
3368
|
disabled: !activePath || isSystem,
|
|
3107
3369
|
},
|
|
3370
|
+
// feature-2-print-artboards T3 — per-canvas persisted (overlays.print in
|
|
3371
|
+
// view.json), same lane as the foundation's `guides` key; NOT gated on the
|
|
3372
|
+
// active artboard actually being kind="print" (mirrors minimap/zoomctl,
|
|
3373
|
+
// which aren't content-gated either — the overlay itself renders nothing
|
|
3374
|
+
// for a non-print artboard regardless of this flag).
|
|
3375
|
+
{
|
|
3376
|
+
id: 'print-guides',
|
|
3377
|
+
label: 'Show print guides',
|
|
3378
|
+
shortcut: '',
|
|
3379
|
+
checked: printGuidesVisible,
|
|
3380
|
+
disabled: !activePath || isSystem,
|
|
3381
|
+
},
|
|
3108
3382
|
];
|
|
3109
3383
|
|
|
3110
3384
|
const DROPDOWN_MENUS = ['file', 'edit', 'view', 'selection', 'tools', 'help'];
|
|
@@ -3242,6 +3516,7 @@ function Menubar({
|
|
|
3242
3516
|
else if (id === 'minimap') onToggleMinimap?.();
|
|
3243
3517
|
else if (id === 'zoomctl') onToggleZoomCtl?.();
|
|
3244
3518
|
else if (id === 'present') onTogglePresent?.();
|
|
3519
|
+
else if (id === 'print-guides') onTogglePrintGuides?.();
|
|
3245
3520
|
}}
|
|
3246
3521
|
onZoom={(op) => postToActiveCanvas({ dgn: 'zoom', op })}
|
|
3247
3522
|
hasCanvas={!!activePath && !isSystem}
|
|
@@ -4139,6 +4414,9 @@ function SyncBanner({ status }) {
|
|
|
4139
4414
|
// critic-approved + user-iterated `.design/ui/Studio.tsx` spec.
|
|
4140
4415
|
|
|
4141
4416
|
const CSS_DISPLAYS = ['block', 'inline-block', 'flex', 'inline-flex', 'grid', 'inline', 'none'];
|
|
4417
|
+
// feature-3-web-artboards T5 — per-track unit ladder for the Grid section's
|
|
4418
|
+
// UnitSelect (the stub's own list: px/%/fr/em/auto/min-content/max-content).
|
|
4419
|
+
const GRID_TRACK_UNITS = ['px', '%', 'fr', 'em', 'auto', 'min-content', 'max-content'];
|
|
4142
4420
|
const CSS_FLEX_DIR = ['row', 'row-reverse', 'column', 'column-reverse'];
|
|
4143
4421
|
const CSS_FLEX_WRAP = ['nowrap', 'wrap', 'wrap-reverse'];
|
|
4144
4422
|
const CSS_ALIGN = ['stretch', 'flex-start', 'center', 'flex-end', 'baseline'];
|
|
@@ -4324,6 +4602,43 @@ function cssSplitUnit(v) {
|
|
|
4324
4602
|
// click re-derives it live — the "tab is there, then it's gone" flicker.
|
|
4325
4603
|
function mergeSelClientFields(incoming, prev) {
|
|
4326
4604
|
if (!incoming || Array.isArray(incoming) || Array.isArray(prev) || !prev) return incoming;
|
|
4605
|
+
// Dogfood follow-up (print artboards) — a whole-ARTBOARD selection has NO
|
|
4606
|
+
// data-cd-id (the frame chrome carries only data-dc-screen), so the id
|
|
4607
|
+
// identity check below never matched it and every server echo returned
|
|
4608
|
+
// `incoming` bare — wiping `attrs` (which carries data-dc-kind/-print for
|
|
4609
|
+
// the ArtboardKnobs panel) seconds after each fresh click. The panel then
|
|
4610
|
+
// silently fell back to kind='digital' + screen presets even though the
|
|
4611
|
+
// artboard was print. Match an id-less pair by the artboard-shaped
|
|
4612
|
+
// SELECTOR + file — NOT by artboardId: the server projection
|
|
4613
|
+
// (inspect.ts `enrich()`) doesn't round-trip `artboardId` at all, so an
|
|
4614
|
+
// artboardId comparison always sees undefined on the echo side and never
|
|
4615
|
+
// matches (the second-round bug on this exact spot). The selector IS the
|
|
4616
|
+
// identity here — `[data-dc-screen="<id>"]` — and enrich() preserves it.
|
|
4617
|
+
if (!incoming.id && !prev.id) {
|
|
4618
|
+
const isArtboardSel = (s) =>
|
|
4619
|
+
typeof s.selector === 'string' && s.selector.startsWith('[data-dc-screen=');
|
|
4620
|
+
if (
|
|
4621
|
+
isArtboardSel(incoming) &&
|
|
4622
|
+
isArtboardSel(prev) &&
|
|
4623
|
+
incoming.selector === prev.selector &&
|
|
4624
|
+
incoming.file === prev.file
|
|
4625
|
+
) {
|
|
4626
|
+
return {
|
|
4627
|
+
...incoming,
|
|
4628
|
+
authored: incoming.authored ?? prev.authored,
|
|
4629
|
+
computed: incoming.computed ?? prev.computed,
|
|
4630
|
+
customStyles: incoming.customStyles ?? prev.customStyles,
|
|
4631
|
+
attrs: incoming.attrs ?? prev.attrs,
|
|
4632
|
+
// enrich() drops artboardId/worldW/worldH from the echo entirely —
|
|
4633
|
+
// restore them from the local snapshot so ArtboardKnobs keeps its
|
|
4634
|
+
// exact W/H and the resize/kind writers keep their target id.
|
|
4635
|
+
artboardId: incoming.artboardId ?? prev.artboardId,
|
|
4636
|
+
worldW: incoming.worldW ?? prev.worldW,
|
|
4637
|
+
worldH: incoming.worldH ?? prev.worldH,
|
|
4638
|
+
};
|
|
4639
|
+
}
|
|
4640
|
+
return incoming;
|
|
4641
|
+
}
|
|
4327
4642
|
if (!incoming.id || incoming.id !== prev.id) return incoming;
|
|
4328
4643
|
return {
|
|
4329
4644
|
...incoming,
|
|
@@ -4908,6 +5223,98 @@ function TokenPopover({ kind, groups, current, onPick, label, swatchBg, seedHex,
|
|
|
4908
5223
|
);
|
|
4909
5224
|
}
|
|
4910
5225
|
|
|
5226
|
+
// feature-3-web-artboards T5 (absorbed feature-grid-track-editor stub) — the
|
|
5227
|
+
// CssKnobs "Grid" section's track-list editor: one row per track (a
|
|
5228
|
+
// NumberField + UnitSelect for a numeric px/%/fr/em track, a bare Select for
|
|
5229
|
+
// a keyword auto/min-content/max-content track since it carries no numeric
|
|
5230
|
+
// value), + add/remove. `tracks`/`onChange` are the parsed/serialized shape
|
|
5231
|
+
// from `grid-track-handles.ts` — the SAME module the on-canvas gutter-drag
|
|
5232
|
+
// overlay uses, so the Inspector and the drag handles never disagree.
|
|
5233
|
+
function GridTracksEditor({ label, tracks, editable, onChange }) {
|
|
5234
|
+
const setUnit = (idx, unit) => {
|
|
5235
|
+
const cur = tracks[idx];
|
|
5236
|
+
onChange(
|
|
5237
|
+
tracks.map((t, i) =>
|
|
5238
|
+
i === idx
|
|
5239
|
+
? GRID_KEYWORD_UNITS.has(unit)
|
|
5240
|
+
? { value: 0, unit }
|
|
5241
|
+
: { value: cur.value || 1, unit }
|
|
5242
|
+
: t
|
|
5243
|
+
)
|
|
5244
|
+
);
|
|
5245
|
+
};
|
|
5246
|
+
const setValue = (idx, value) => {
|
|
5247
|
+
onChange(tracks.map((t, i) => (i === idx ? { ...t, value } : t)));
|
|
5248
|
+
};
|
|
5249
|
+
const addTrack = () => onChange([...tracks, { value: 1, unit: 'fr' }]);
|
|
5250
|
+
const removeTrack = (idx) => onChange(tracks.filter((_, i) => i !== idx));
|
|
5251
|
+
return (
|
|
5252
|
+
<div className="st-cp-gridtracks">
|
|
5253
|
+
<div className="st-cp-gridtracks-hd">
|
|
5254
|
+
<span className="st-cp-gridtracks-label">{label}</span>
|
|
5255
|
+
<button
|
|
5256
|
+
type="button"
|
|
5257
|
+
className="st-btn st-cp-gridtracks-add"
|
|
5258
|
+
disabled={!editable}
|
|
5259
|
+
onClick={addTrack}
|
|
5260
|
+
>
|
|
5261
|
+
+ Track
|
|
5262
|
+
</button>
|
|
5263
|
+
</div>
|
|
5264
|
+
{tracks.length === 0 ? (
|
|
5265
|
+
<div className="st-cp-note">
|
|
5266
|
+
No explicit tracks — add one to start defining {label.toLowerCase()}.
|
|
5267
|
+
</div>
|
|
5268
|
+
) : (
|
|
5269
|
+
tracks.map((t, idx) => {
|
|
5270
|
+
const isKeyword = GRID_KEYWORD_UNITS.has(t.unit);
|
|
5271
|
+
return (
|
|
5272
|
+
// biome-ignore lint/suspicious/noArrayIndexKey: tracks have no stable id;
|
|
5273
|
+
// add/remove/reorder all rebuild the array positionally, same as every
|
|
5274
|
+
// other index-keyed list in this file (e.g. the Layers tree rows).
|
|
5275
|
+
<div className="st-cp-gridtrack-row" key={idx}>
|
|
5276
|
+
{isKeyword ? (
|
|
5277
|
+
<Select
|
|
5278
|
+
value={t.unit}
|
|
5279
|
+
ariaLabel={`${label} track ${idx + 1}`}
|
|
5280
|
+
options={GRID_TRACK_UNITS.map((u) => ({ value: u, label: u }))}
|
|
5281
|
+
onChange={(u) => setUnit(idx, u)}
|
|
5282
|
+
/>
|
|
5283
|
+
) : (
|
|
5284
|
+
<NumberField
|
|
5285
|
+
value={t.value}
|
|
5286
|
+
min={t.unit === 'fr' ? 0.1 : 0}
|
|
5287
|
+
step={t.unit === 'fr' ? 0.1 : 1}
|
|
5288
|
+
ariaLabel={`${label} track ${idx + 1} value`}
|
|
5289
|
+
lead={String(idx + 1)}
|
|
5290
|
+
unitSlot={
|
|
5291
|
+
<UnitSelect
|
|
5292
|
+
value={t.unit}
|
|
5293
|
+
units={GRID_TRACK_UNITS}
|
|
5294
|
+
ariaLabel={`${label} track ${idx + 1} unit`}
|
|
5295
|
+
onChange={(u) => setUnit(idx, u)}
|
|
5296
|
+
/>
|
|
5297
|
+
}
|
|
5298
|
+
onCommit={(n) => setValue(idx, n)}
|
|
5299
|
+
/>
|
|
5300
|
+
)}
|
|
5301
|
+
<button
|
|
5302
|
+
type="button"
|
|
5303
|
+
className="st-btn st-cp-gridtrack-remove"
|
|
5304
|
+
aria-label={`remove ${label} track ${idx + 1}`}
|
|
5305
|
+
disabled={!editable || tracks.length <= 1}
|
|
5306
|
+
onClick={() => removeTrack(idx)}
|
|
5307
|
+
>
|
|
5308
|
+
×
|
|
5309
|
+
</button>
|
|
5310
|
+
</div>
|
|
5311
|
+
);
|
|
5312
|
+
})
|
|
5313
|
+
)}
|
|
5314
|
+
</div>
|
|
5315
|
+
);
|
|
5316
|
+
}
|
|
5317
|
+
|
|
4911
5318
|
function CssKnobs({ el, cfg, onOptimistic, onRecordEdit, onReplaceMedia, onUndoRedo, mode, onSetMode }) {
|
|
4912
5319
|
const editable = !!el.id;
|
|
4913
5320
|
const computed = el.computed || {};
|
|
@@ -5254,6 +5661,10 @@ function CssKnobs({ el, cfg, onOptimistic, onRecordEdit, onReplaceMedia, onUndoR
|
|
|
5254
5661
|
// Props each section owns — drives the per-section "reset section" affordance.
|
|
5255
5662
|
const SECTION_PROPS = {
|
|
5256
5663
|
Layout: ['display', 'flex-direction', 'flex-wrap', 'align-items', 'justify-content', 'gap'],
|
|
5664
|
+
// feature-3-web-artboards T5 — Grid track editor + cell placement, so the
|
|
5665
|
+
// section-reset affordance clears them like every other section.
|
|
5666
|
+
Grid: ['grid-template-columns', 'grid-template-rows'],
|
|
5667
|
+
'Grid item': ['grid-column', 'grid-row'],
|
|
5257
5668
|
// feature-element-editing-robustness Stage B — promoted DDR-104 OUT-list.
|
|
5258
5669
|
Position: ['position', 'top', 'right', 'bottom', 'left', 'z-index'],
|
|
5259
5670
|
Typography: [
|
|
@@ -6151,6 +6562,54 @@ function CssKnobs({ el, cfg, onOptimistic, onRecordEdit, onReplaceMedia, onUndoR
|
|
|
6151
6562
|
})()
|
|
6152
6563
|
)}
|
|
6153
6564
|
|
|
6565
|
+
{(() => {
|
|
6566
|
+
// feature-3-web-artboards T5 (absorbed feature-grid-track-editor
|
|
6567
|
+
// stub) — Grid section, only when the element IS a grid container.
|
|
6568
|
+
// Track parse/serialize is the SAME grid-track-handles.ts module the
|
|
6569
|
+
// on-canvas gutter-drag overlay uses, so the Inspector and the drag
|
|
6570
|
+
// handles never disagree about track shape.
|
|
6571
|
+
const disp = (authored.display || cssHint(computed.display) || '').trim();
|
|
6572
|
+
const isGrid = disp === 'grid' || disp === 'inline-grid';
|
|
6573
|
+
if (!isGrid) return null;
|
|
6574
|
+
const colsRaw = authored['grid-template-columns'] || '';
|
|
6575
|
+
const rowsRaw = authored['grid-template-rows'] || '';
|
|
6576
|
+
const cols = parseTrackList(colsRaw);
|
|
6577
|
+
const rows = parseTrackList(rowsRaw);
|
|
6578
|
+
const colsEditable = cols.length > 0 || !colsRaw.trim();
|
|
6579
|
+
const rowsEditable = rows.length > 0 || !rowsRaw.trim();
|
|
6580
|
+
return sec(
|
|
6581
|
+
'Grid',
|
|
6582
|
+
<>
|
|
6583
|
+
{colsEditable ? (
|
|
6584
|
+
<GridTracksEditor
|
|
6585
|
+
label="Columns"
|
|
6586
|
+
tracks={cols}
|
|
6587
|
+
editable={editable}
|
|
6588
|
+
onChange={(next) => commit('grid-template-columns', serializeTrackList(next))}
|
|
6589
|
+
/>
|
|
6590
|
+
) : (
|
|
6591
|
+
<div className="st-cp-note">
|
|
6592
|
+
Columns use <code>{colsRaw}</code> — not editable as tracks here (repeat()/
|
|
6593
|
+
minmax()/subgrid); use /design:edit for these.
|
|
6594
|
+
</div>
|
|
6595
|
+
)}
|
|
6596
|
+
{rowsEditable ? (
|
|
6597
|
+
<GridTracksEditor
|
|
6598
|
+
label="Rows"
|
|
6599
|
+
tracks={rows}
|
|
6600
|
+
editable={editable}
|
|
6601
|
+
onChange={(next) => commit('grid-template-rows', serializeTrackList(next))}
|
|
6602
|
+
/>
|
|
6603
|
+
) : (
|
|
6604
|
+
<div className="st-cp-note">
|
|
6605
|
+
Rows use <code>{rowsRaw}</code> — not editable as tracks here (repeat()/minmax()/
|
|
6606
|
+
subgrid); use /design:edit for these.
|
|
6607
|
+
</div>
|
|
6608
|
+
)}
|
|
6609
|
+
</>
|
|
6610
|
+
);
|
|
6611
|
+
})()}
|
|
6612
|
+
|
|
6154
6613
|
{sec(
|
|
6155
6614
|
'Position',
|
|
6156
6615
|
<>
|
|
@@ -6174,6 +6633,18 @@ function CssKnobs({ el, cfg, onOptimistic, onRecordEdit, onReplaceMedia, onUndoR
|
|
|
6174
6633
|
</>
|
|
6175
6634
|
)}
|
|
6176
6635
|
|
|
6636
|
+
{(el.parentDisplay === 'grid' || el.parentDisplay === 'inline-grid') &&
|
|
6637
|
+
sec(
|
|
6638
|
+
'Grid item',
|
|
6639
|
+
<>
|
|
6640
|
+
<div className="st-cp-note">
|
|
6641
|
+
Manual cell placement — <code>start / end</code> or <code>start / span N</code>.
|
|
6642
|
+
</div>
|
|
6643
|
+
{row('grid-column', text('grid-column'))}
|
|
6644
|
+
{row('grid-row', text('grid-row'))}
|
|
6645
|
+
</>
|
|
6646
|
+
)}
|
|
6647
|
+
|
|
6177
6648
|
{sec(
|
|
6178
6649
|
'Typography',
|
|
6179
6650
|
<>
|
|
@@ -6486,6 +6957,8 @@ const LAYER_TYPE_ICON = {
|
|
|
6486
6957
|
list: 'list',
|
|
6487
6958
|
nav: 'layers',
|
|
6488
6959
|
box: 'box',
|
|
6960
|
+
// feature-4 T7 — synthetic group row (unstamped wrapper with stamped kids).
|
|
6961
|
+
group: 'folder',
|
|
6489
6962
|
};
|
|
6490
6963
|
|
|
6491
6964
|
|
|
@@ -6552,21 +7025,52 @@ function LayerRow({
|
|
|
6552
7025
|
onReorder,
|
|
6553
7026
|
onRowPointerDown,
|
|
6554
7027
|
dragState,
|
|
7028
|
+
// feature-4 T7a/b/c — component map (purple ◇/◆), locked keys + toggle,
|
|
7029
|
+
// dblclick rename (writes data-dc-element via edit-attr).
|
|
7030
|
+
componentMap,
|
|
7031
|
+
lockedKeys,
|
|
7032
|
+
onToggleLock,
|
|
7033
|
+
onRename,
|
|
6555
7034
|
}) {
|
|
6556
7035
|
const key = `${node.id}:${node.index}`;
|
|
6557
7036
|
const hasKids = node.children && node.children.length > 0;
|
|
6558
7037
|
const isCollapsed = collapsed.has(key);
|
|
7038
|
+
// feature-4 T7 — a synthetic group row (unstamped wrapper) is a pure
|
|
7039
|
+
// structural stand-in: NOT selectable (no data-cd-id to address in source),
|
|
7040
|
+
// NOT draggable, NO eye toggle. Clicking it just expands/collapses.
|
|
7041
|
+
const isSynthetic = !!node.synthetic;
|
|
7042
|
+
// feature-4 T7a — purple instance rows: this element renders through an
|
|
7043
|
+
// instantiated component. `root` = the component's own frame root (◆).
|
|
7044
|
+
const inst = !isSynthetic && componentMap ? componentMap[node.id] : null;
|
|
7045
|
+
// feature-4 T7b — locked layers can't be selected/dragged on the canvas.
|
|
7046
|
+
const isLocked = !isSynthetic && !!lockedKeys?.has(key);
|
|
7047
|
+
// feature-4 T7c — inline rename (dblclick the label).
|
|
7048
|
+
const [renaming, setRenaming] = useState(false);
|
|
7049
|
+
const [renameDraft, setRenameDraft] = useState('');
|
|
6559
7050
|
// Match the specific INSTANCE (id + occurrence index), not every element that
|
|
6560
7051
|
// shares this source id — otherwise a `.map`ed element highlights all its
|
|
6561
7052
|
// clones at once. Fall back to id-only when the selection carries no index.
|
|
6562
7053
|
const isSel =
|
|
6563
|
-
|
|
7054
|
+
!isSynthetic &&
|
|
7055
|
+
node.id === selectedId &&
|
|
7056
|
+
(selectedIndex == null || node.index === selectedIndex);
|
|
6564
7057
|
const isHidden = hiddenOverride?.has(key) ? hiddenOverride.get(key) : !!node.hidden;
|
|
6565
7058
|
// A shared data-cd-id (reused component instance) IS reorderable now — the
|
|
6566
7059
|
// server maps the occurrence index to the parent <Component> usage — so these
|
|
6567
7060
|
// are no longer greyed/blocked. A `.map()`ed single-usage element still can't
|
|
6568
|
-
// split; that move is refused server-side and reverts.
|
|
6569
|
-
|
|
7061
|
+
// split; that move is refused server-side and reverts. A LOCKED row never
|
|
7062
|
+
// drags (feature-4 T7b).
|
|
7063
|
+
const canDrag = !!onReorder && !isSynthetic && !isLocked;
|
|
7064
|
+
const commitRename = () => {
|
|
7065
|
+
setRenaming(false);
|
|
7066
|
+
const v = renameDraft
|
|
7067
|
+
.trim()
|
|
7068
|
+
.toLowerCase()
|
|
7069
|
+
.replace(/\s+/g, '-')
|
|
7070
|
+
.replace(/[^a-z0-9-]/g, '');
|
|
7071
|
+
if (!v || v === (node.dcElement ?? '')) return;
|
|
7072
|
+
onRename?.(node, v);
|
|
7073
|
+
};
|
|
6570
7074
|
// Phase 12.1 — the row being dragged FLOATS with the cursor (same model as the
|
|
6571
7075
|
// in-canvas drag): a transform follows the pointer, its layout box stays
|
|
6572
7076
|
// reserved (empty slot at the origin), and pointer-events:none lets the row
|
|
@@ -6588,26 +7092,46 @@ function LayerRow({
|
|
|
6588
7092
|
<>
|
|
6589
7093
|
<div
|
|
6590
7094
|
className={
|
|
6591
|
-
'st-layer st-layer--row' +
|
|
7095
|
+
'st-layer st-layer--row' +
|
|
7096
|
+
(isSel ? ' is-sel' : '') +
|
|
7097
|
+
(isHidden ? ' is-hidden' : '') +
|
|
7098
|
+
(isSynthetic ? ' is-group' : '') +
|
|
7099
|
+
(inst ? ' is-instance' : '') +
|
|
7100
|
+
(isLocked ? ' is-locked' : '')
|
|
6592
7101
|
}
|
|
6593
7102
|
style={{ paddingLeft: 6 + depth * 14, ...dragStyle }}
|
|
6594
7103
|
role="treeitem"
|
|
6595
|
-
aria-selected={isSel}
|
|
7104
|
+
aria-selected={isSynthetic ? undefined : isSel}
|
|
6596
7105
|
aria-expanded={hasKids ? !isCollapsed : undefined}
|
|
6597
7106
|
aria-grabbed={canDrag ? isDragging : undefined}
|
|
6598
7107
|
tabIndex={0}
|
|
6599
|
-
title={
|
|
7108
|
+
title={
|
|
7109
|
+
isSynthetic
|
|
7110
|
+
? `${node.tag} · group (unstamped wrapper)`
|
|
7111
|
+
: `${node.tag} · ${node.type}${onRename ? ' · F2 to rename' : ''}`
|
|
7112
|
+
}
|
|
6600
7113
|
data-layer-key={key}
|
|
6601
|
-
onClick={() => onSelect(node)}
|
|
6602
|
-
onMouseEnter={() => onHover(node)}
|
|
7114
|
+
onClick={() => (isSynthetic ? hasKids && onToggle(key) : onSelect(node))}
|
|
7115
|
+
onMouseEnter={() => !isSynthetic && onHover(node)}
|
|
6603
7116
|
onMouseLeave={() => onHover(null)}
|
|
6604
7117
|
onPointerDown={canDrag ? (e) => onRowPointerDown(e, node, key) : undefined}
|
|
6605
7118
|
onKeyDown={(e) => {
|
|
6606
|
-
// Enter/Space select
|
|
6607
|
-
// container (selection-driven —
|
|
7119
|
+
// Enter/Space select (or, for a synthetic group, toggle); ↑/↓ (+
|
|
7120
|
+
// modifiers) are handled at the tree container (selection-driven —
|
|
7121
|
+
// survives the HMR re-render).
|
|
6608
7122
|
if (e.key === 'Enter' || e.key === ' ') {
|
|
6609
7123
|
e.preventDefault();
|
|
6610
|
-
|
|
7124
|
+
if (isSynthetic) hasKids && onToggle(key);
|
|
7125
|
+
else onSelect(node);
|
|
7126
|
+
} else if (e.key === 'F2' && onRename && !isSynthetic) {
|
|
7127
|
+
// a11y fix (review fan-out, 2026-07-21) — dblclick was the ONLY
|
|
7128
|
+
// entry point into rename, a keyboard-only user had no way to
|
|
7129
|
+
// reach it at all (WCAG 2.1.1). F2 is the conventional rename key
|
|
7130
|
+
// (Explorer/Finder/most tree UIs); mirrors the dblclick handler's
|
|
7131
|
+
// own pre-fill.
|
|
7132
|
+
e.preventDefault();
|
|
7133
|
+
setRenameDraft(node.dcElement || node.label || '');
|
|
7134
|
+
setRenaming(true);
|
|
6611
7135
|
}
|
|
6612
7136
|
}}
|
|
6613
7137
|
>
|
|
@@ -6626,10 +7150,67 @@ function LayerRow({
|
|
|
6626
7150
|
) : (
|
|
6627
7151
|
<span className="st-layer-caret" aria-hidden="true" />
|
|
6628
7152
|
)}
|
|
6629
|
-
|
|
6630
|
-
|
|
6631
|
-
|
|
6632
|
-
|
|
7153
|
+
{inst ? (
|
|
7154
|
+
// feature-4 T7a — Figma vocabulary: ◆ the component's own frame root,
|
|
7155
|
+
// ◇ an inner member of an instance. Purple via .is-instance CSS.
|
|
7156
|
+
<span className="st-layer-inst-glyph" aria-hidden="true">
|
|
7157
|
+
{inst.root ? '◆' : '◇'}
|
|
7158
|
+
</span>
|
|
7159
|
+
) : (
|
|
7160
|
+
<StIcon name={LAYER_TYPE_ICON[node.type] || 'box'} size={12} className="st-layer-ticon" />
|
|
7161
|
+
)}
|
|
7162
|
+
{renaming ? (
|
|
7163
|
+
<input
|
|
7164
|
+
className="st-layer-rename"
|
|
7165
|
+
value={renameDraft}
|
|
7166
|
+
autoFocus
|
|
7167
|
+
aria-label={`Rename ${node.label}`}
|
|
7168
|
+
onFocus={(e) => e.target.select()}
|
|
7169
|
+
onChange={(e) => setRenameDraft(e.target.value)}
|
|
7170
|
+
onClick={(e) => e.stopPropagation()}
|
|
7171
|
+
onPointerDown={(e) => e.stopPropagation()}
|
|
7172
|
+
onBlur={commitRename}
|
|
7173
|
+
onKeyDown={(e) => {
|
|
7174
|
+
e.stopPropagation();
|
|
7175
|
+
if (e.key === 'Enter') commitRename();
|
|
7176
|
+
else if (e.key === 'Escape') setRenaming(false);
|
|
7177
|
+
}}
|
|
7178
|
+
/>
|
|
7179
|
+
) : (
|
|
7180
|
+
<span
|
|
7181
|
+
className="st-layer-label"
|
|
7182
|
+
onDoubleClick={
|
|
7183
|
+
onRename && !isSynthetic
|
|
7184
|
+
? (e) => {
|
|
7185
|
+
// feature-4 T7c — dblclick rename. Pre-fill from the raw
|
|
7186
|
+
// data-dc-element (kebab) when set, else the display label.
|
|
7187
|
+
e.stopPropagation();
|
|
7188
|
+
setRenameDraft(node.dcElement || node.label || '');
|
|
7189
|
+
setRenaming(true);
|
|
7190
|
+
}
|
|
7191
|
+
: undefined
|
|
7192
|
+
}
|
|
7193
|
+
>
|
|
7194
|
+
{node.label}
|
|
7195
|
+
</span>
|
|
7196
|
+
)}
|
|
7197
|
+
<span className="st-layer-type">{inst ? inst.component : node.type}</span>
|
|
7198
|
+
{onToggleLock && !isSynthetic ? (
|
|
7199
|
+
<button
|
|
7200
|
+
type="button"
|
|
7201
|
+
className="st-layer-lock"
|
|
7202
|
+
aria-label={isLocked ? `Unlock ${node.label}` : `Lock ${node.label}`}
|
|
7203
|
+
aria-pressed={isLocked}
|
|
7204
|
+
title={isLocked ? 'Unlock' : 'Lock'}
|
|
7205
|
+
onClick={(e) => {
|
|
7206
|
+
e.stopPropagation();
|
|
7207
|
+
onToggleLock(key);
|
|
7208
|
+
}}
|
|
7209
|
+
>
|
|
7210
|
+
<StIcon name={isLocked ? 'lock' : 'unlock'} size={12} />
|
|
7211
|
+
</button>
|
|
7212
|
+
) : null}
|
|
7213
|
+
{onToggleVisibility && !isSynthetic ? (
|
|
6633
7214
|
<button
|
|
6634
7215
|
type="button"
|
|
6635
7216
|
className="st-layer-eye"
|
|
@@ -6662,6 +7243,10 @@ function LayerRow({
|
|
|
6662
7243
|
onReorder={onReorder}
|
|
6663
7244
|
onRowPointerDown={onRowPointerDown}
|
|
6664
7245
|
dragState={dragState}
|
|
7246
|
+
componentMap={componentMap}
|
|
7247
|
+
lockedKeys={lockedKeys}
|
|
7248
|
+
onToggleLock={onToggleLock}
|
|
7249
|
+
onRename={onRename}
|
|
6665
7250
|
/>
|
|
6666
7251
|
))
|
|
6667
7252
|
: null}
|
|
@@ -6760,8 +7345,53 @@ const ARTBOARD_LAYOUT_OPTIONS = [
|
|
|
6760
7345
|
['grid', 'Grid'],
|
|
6761
7346
|
];
|
|
6762
7347
|
|
|
6763
|
-
|
|
7348
|
+
// feature-1-artboard-kinds-foundation, T8 — kind picker options. Order
|
|
7349
|
+
// mirrors the DCArtboard `kind` union; 'digital' is the implicit default
|
|
7350
|
+
// (writes `kind: null`, clearing the explicit prop, per applySetArtboardKind).
|
|
7351
|
+
const ARTBOARD_KIND_OPTIONS = [
|
|
7352
|
+
['digital', 'Digital'],
|
|
7353
|
+
['print', 'Print'],
|
|
7354
|
+
['web', 'Web'],
|
|
7355
|
+
['video', 'Video'],
|
|
7356
|
+
];
|
|
7357
|
+
|
|
7358
|
+
function ArtboardKnobs({
|
|
7359
|
+
el,
|
|
7360
|
+
cfg,
|
|
7361
|
+
onResizeArtboard,
|
|
7362
|
+
onSetArtboardHug,
|
|
7363
|
+
onSetArtboardStyle,
|
|
7364
|
+
onSetArtboardKind,
|
|
7365
|
+
onSetArtboardPrint,
|
|
7366
|
+
onDuplicateArtboard,
|
|
7367
|
+
}) {
|
|
6764
7368
|
const artboardId = resolveArtboardIdFromSelection(el);
|
|
7369
|
+
// Dogfood (artboard panel ↔ shared inspector controls) — the panel now uses
|
|
7370
|
+
// the SAME control library + design-token plumbing as CssKnobs (NumberField
|
|
7371
|
+
// / Segmented / Select / ColorField+TokenPopover / ValueTokenField), so Bg
|
|
7372
|
+
// binds color variables and Pad/Gap bind space variables exactly like any
|
|
7373
|
+
// CSS-panel row. Token resolution mirrors CssKnobs' own tokenGroups/
|
|
7374
|
+
// flatTokens closures over useAllDsTokens.
|
|
7375
|
+
const _designRel = (cfg?.designRel || cfg?.designRoot || '.design').replace(/^\/+|\/+$/g, '');
|
|
7376
|
+
const _activeDs = activeDsNameFor(el.file, cfg);
|
|
7377
|
+
const allDs = useAllDsTokens(cfg, _designRel, _activeDs);
|
|
7378
|
+
const tokenGroups = (familyKey) =>
|
|
7379
|
+
allDs
|
|
7380
|
+
.map((d) => ({ ds: d.name, names: d[familyKey] || [], vals: d.vals }))
|
|
7381
|
+
.filter((g) => g.names.length);
|
|
7382
|
+
const flatTokens = (familyKey) =>
|
|
7383
|
+
tokenGroups(familyKey).flatMap((g) => (g.names || []).map((n) => ({ name: n, value: g.vals?.[n] || '' })));
|
|
7384
|
+
// Resolve a `var(--x)` binding to its concrete value across every DS (for
|
|
7385
|
+
// the Bg swatch color); a raw value passes through.
|
|
7386
|
+
const resolveTokenValue = (v) => {
|
|
7387
|
+
const m = /^var\(\s*(--[\w-]+)\s*\)$/.exec(v || '');
|
|
7388
|
+
if (!m) return v || '';
|
|
7389
|
+
for (const d of allDs) {
|
|
7390
|
+
const val = d.vals?.[m[1]];
|
|
7391
|
+
if (val) return val;
|
|
7392
|
+
}
|
|
7393
|
+
return '';
|
|
7394
|
+
};
|
|
6765
7395
|
// Dogfood 2026-07-07 (round 2) — `worldW`/`worldH` (zoom-independent) are
|
|
6766
7396
|
// undefined for a selection that reached here via a code path predating
|
|
6767
7397
|
// that field (a canvas iframe that hasn't remounted since); fall back to
|
|
@@ -6788,6 +7418,12 @@ function ArtboardKnobs({ el, onResizeArtboard, onSetArtboardHug, onSetArtboardSt
|
|
|
6788
7418
|
const activePreset = Object.entries(SCREEN_PRESETS).find(
|
|
6789
7419
|
([, p]) => p.width === w && p.height === h
|
|
6790
7420
|
)?.[0];
|
|
7421
|
+
// feature-3-web-artboards T2 — a web artboard hugs height (Design Decision
|
|
7422
|
+
// 1), so its `h` rarely equals a preset's device height even when the
|
|
7423
|
+
// artboard genuinely represents that breakpoint. Match by WIDTH alone so
|
|
7424
|
+
// the picker still shows "Desktop" selected after the hugged height
|
|
7425
|
+
// settles to the content's real size.
|
|
7426
|
+
const activeWidthPreset = Object.entries(SCREEN_PRESETS).find(([, p]) => p.width === w)?.[0];
|
|
6791
7427
|
// Hug default (artboard "hug height") — current mode + the "more settings"
|
|
6792
7428
|
// (background/padding/layout/gap) read off the SAME generic `attrs` escape
|
|
6793
7429
|
// hatch dom-selection.ts already scrapes for every selection (the `data-dc-*`
|
|
@@ -6798,6 +7434,70 @@ function ArtboardKnobs({ el, onResizeArtboard, onSetArtboardHug, onSetArtboardSt
|
|
|
6798
7434
|
const padding = el.attrs?.['data-dc-padding'] ?? '';
|
|
6799
7435
|
const layoutMode = el.attrs?.['data-dc-layout'] ?? '';
|
|
6800
7436
|
const gap = el.attrs?.['data-dc-gap'] ?? '';
|
|
7437
|
+
// T8 — `data-dc-kind` is DCArtboard's RESOLVED kind (readBackAttrs always
|
|
7438
|
+
// emits it, unlike the optional-override attrs above — see T1's comment on
|
|
7439
|
+
// why), so this reads 'digital' even for an implicit/unmigrated artboard.
|
|
7440
|
+
const kind = el.attrs?.['data-dc-kind'] || 'digital';
|
|
7441
|
+
// feature-2-print-artboards T2 — paper/orientation/bleed. Read-back mirrors
|
|
7442
|
+
// `data-dc-kind` above: a small JSON attr DCArtboard stamps for exactly this
|
|
7443
|
+
// panel (canvas-lib.tsx readBackAttrs), since `print` (unlike bg/padding/…)
|
|
7444
|
+
// is object-valued, not a scalar.
|
|
7445
|
+
let print = null;
|
|
7446
|
+
try {
|
|
7447
|
+
print = el.attrs?.['data-dc-print'] ? JSON.parse(el.attrs['data-dc-print']) : null;
|
|
7448
|
+
} catch {
|
|
7449
|
+
/* malformed attr — treat as absent */
|
|
7450
|
+
}
|
|
7451
|
+
const setKind = (nextKind) => {
|
|
7452
|
+
if (!artboardId) return;
|
|
7453
|
+
// Dogfood fix — switching TO "print" with no print prop yet left the
|
|
7454
|
+
// artboard half-configured (no guides geometry, no bleed for the PDF
|
|
7455
|
+
// exporter). Seed a default A4 print prop + its resolved px size in the
|
|
7456
|
+
// SAME gesture. Round 6: the seed is PASSED THROUGH the kind flow (not
|
|
7457
|
+
// written immediately) so the freeze-convert the canvas may enqueue first
|
|
7458
|
+
// lands BEFORE the A4 resize — resizing pre-freeze moved elements around
|
|
7459
|
+
// while the confirm dialog was still open.
|
|
7460
|
+
let seedPrint = null;
|
|
7461
|
+
if (nextKind === 'print' && !print) {
|
|
7462
|
+
const defaults = { paper: 'a4' };
|
|
7463
|
+
try {
|
|
7464
|
+
const resolved = resolvePrintArtboard(defaults);
|
|
7465
|
+
if (resolved) {
|
|
7466
|
+
seedPrint = { defaults, widthPx: resolved.widthPx, heightPx: resolved.heightPx };
|
|
7467
|
+
}
|
|
7468
|
+
} catch {
|
|
7469
|
+
/* keep null */
|
|
7470
|
+
}
|
|
7471
|
+
}
|
|
7472
|
+
// Picking "Digital" clears the explicit prop back to the implicit
|
|
7473
|
+
// default (applySetArtboardKind's `kind: null` path) rather than writing
|
|
7474
|
+
// a redundant `kind="digital"`.
|
|
7475
|
+
onSetArtboardKind?.(artboardId, nextKind === 'digital' ? null : nextKind, seedPrint);
|
|
7476
|
+
// feature-3-web-artboards Design Decision 1 — a web artboard's height
|
|
7477
|
+
// hugs content (`fixed` omitted); switching TO "web" flips hug mode in
|
|
7478
|
+
// the same gesture so the artboard doesn't stay pinned to whatever exact
|
|
7479
|
+
// height a prior digital/print size left it at. Mirrors the print
|
|
7480
|
+
// auto-seed above — switching kind should never require a second,
|
|
7481
|
+
// easy-to-miss step in the Hug/Fixed segmented control above.
|
|
7482
|
+
if (nextKind === 'web' && fixed) {
|
|
7483
|
+
onSetArtboardHug?.(artboardId, false, undefined);
|
|
7484
|
+
}
|
|
7485
|
+
};
|
|
7486
|
+
const setPrint = (patch) => {
|
|
7487
|
+
if (!artboardId) return;
|
|
7488
|
+
const next = { paper: 'a4', ...print, ...patch };
|
|
7489
|
+
let resolved;
|
|
7490
|
+
try {
|
|
7491
|
+
resolved = resolvePrintArtboard(next);
|
|
7492
|
+
} catch {
|
|
7493
|
+
return; // unknown paper id — ignore rather than write garbage
|
|
7494
|
+
}
|
|
7495
|
+
// Design Decision 2 — the picker writes BOTH the resolved px size (via
|
|
7496
|
+
// the existing resize lane, DDR-027) and the `print` intent prop, in the
|
|
7497
|
+
// same gesture, so geometry and intent never drift apart.
|
|
7498
|
+
onResizeArtboard?.(artboardId, resolved.widthPx, resolved.heightPx);
|
|
7499
|
+
onSetArtboardPrint?.(artboardId, next);
|
|
7500
|
+
};
|
|
6801
7501
|
const setHug = (nextFixed) => {
|
|
6802
7502
|
if (!artboardId) return;
|
|
6803
7503
|
const freezeHeight = nextFixed && Number.isFinite(h) ? Math.round(h) : undefined;
|
|
@@ -6813,162 +7513,251 @@ function ArtboardKnobs({ el, onResizeArtboard, onSetArtboardHug, onSetArtboardSt
|
|
|
6813
7513
|
<span className="st-cp-sechd">Artboard</span>
|
|
6814
7514
|
</div>
|
|
6815
7515
|
<div style={{ display: 'flex', gap: 8, padding: '4px 12px' }}>
|
|
6816
|
-
<
|
|
6817
|
-
|
|
6818
|
-
|
|
6819
|
-
|
|
6820
|
-
|
|
6821
|
-
|
|
6822
|
-
|
|
6823
|
-
|
|
6824
|
-
|
|
6825
|
-
|
|
6826
|
-
|
|
6827
|
-
|
|
6828
|
-
|
|
6829
|
-
|
|
6830
|
-
|
|
6831
|
-
|
|
6832
|
-
|
|
6833
|
-
|
|
6834
|
-
|
|
6835
|
-
|
|
6836
|
-
|
|
6837
|
-
<input
|
|
6838
|
-
className="st-cp-numin"
|
|
6839
|
-
type="number"
|
|
6840
|
-
min="1"
|
|
6841
|
-
aria-label="artboard height"
|
|
6842
|
-
readOnly={!fixed}
|
|
6843
|
-
title={fixed ? undefined : 'Hug — grows to fit content. Switch to Fixed to set an exact height.'}
|
|
6844
|
-
key={`h:${h ?? ''}:${fixed}`}
|
|
6845
|
-
defaultValue={h ?? ''}
|
|
6846
|
-
onKeyDown={(e) => {
|
|
6847
|
-
if (e.key === 'Enter') e.currentTarget.blur();
|
|
6848
|
-
}}
|
|
6849
|
-
onBlur={(e) => {
|
|
6850
|
-
if (!fixed) return;
|
|
6851
|
-
commitSize(null, Number.parseFloat(e.currentTarget.value));
|
|
6852
|
-
}}
|
|
6853
|
-
/>
|
|
6854
|
-
</div>
|
|
7516
|
+
<NumberField
|
|
7517
|
+
value={w ?? 0}
|
|
7518
|
+
min={1}
|
|
7519
|
+
ariaLabel="artboard width"
|
|
7520
|
+
lead="W"
|
|
7521
|
+
steppers={false}
|
|
7522
|
+
onCommit={(n) => commitSize(n, null)}
|
|
7523
|
+
/>
|
|
7524
|
+
<NumberField
|
|
7525
|
+
value={h ?? 0}
|
|
7526
|
+
min={1}
|
|
7527
|
+
ariaLabel="artboard height"
|
|
7528
|
+
lead="H"
|
|
7529
|
+
steppers={false}
|
|
7530
|
+
scrub={fixed}
|
|
7531
|
+
onCommit={(n) => {
|
|
7532
|
+
// Hug — height is content-driven; ignore a typed value rather
|
|
7533
|
+
// than silently switching modes.
|
|
7534
|
+
if (fixed) commitSize(null, n);
|
|
7535
|
+
}}
|
|
7536
|
+
/>
|
|
6855
7537
|
</div>
|
|
6856
7538
|
<div style={{ padding: '0 12px 8px' }}>
|
|
6857
|
-
|
|
6858
|
-
|
|
6859
|
-
|
|
6860
|
-
|
|
6861
|
-
|
|
6862
|
-
|
|
6863
|
-
|
|
6864
|
-
|
|
6865
|
-
|
|
6866
|
-
|
|
6867
|
-
{
|
|
6868
|
-
|
|
6869
|
-
|
|
6870
|
-
|
|
6871
|
-
{p.label} — {p.width}×{p.height}
|
|
7539
|
+
{kind === 'print' ? (
|
|
7540
|
+
// Dogfood follow-up — a PRINT artboard's "Preset size…" dropdown
|
|
7541
|
+
// showed only the screen presets (Desktop/Laptop/Tablet/Mobile),
|
|
7542
|
+
// which are meaningless for paper. Show the paper ladder here
|
|
7543
|
+
// instead, writing through the SAME setPrint lane as the Print
|
|
7544
|
+
// section below (resolved px + print prop together).
|
|
7545
|
+
<select
|
|
7546
|
+
className="st-cp-nsel"
|
|
7547
|
+
aria-label="artboard paper preset"
|
|
7548
|
+
value={print?.paper ?? ''}
|
|
7549
|
+
onChange={(e) => setPrint({ paper: e.currentTarget.value })}
|
|
7550
|
+
>
|
|
7551
|
+
<option value="" disabled>
|
|
7552
|
+
Paper size…
|
|
6872
7553
|
</option>
|
|
6873
|
-
|
|
6874
|
-
|
|
7554
|
+
{PAPER_PRESETS.map((p) => (
|
|
7555
|
+
<option key={p.id} value={p.id}>
|
|
7556
|
+
{p.label} — {p.width}×{p.height}
|
|
7557
|
+
{p.unit}
|
|
7558
|
+
</option>
|
|
7559
|
+
))}
|
|
7560
|
+
</select>
|
|
7561
|
+
) : kind === 'web' ? (
|
|
7562
|
+
// feature-3-web-artboards T2 — a web artboard's "Preset size…"
|
|
7563
|
+
// dropdown reads as BREAKPOINT selection, not exact-box selection:
|
|
7564
|
+
// same SCREEN_PRESETS widths (Design Decision 2 — no separate
|
|
7565
|
+
// preset table needed), but commits width-only (height stays
|
|
7566
|
+
// hug-driven) and labels each option as the breakpoint it is.
|
|
7567
|
+
<select
|
|
7568
|
+
className="st-cp-nsel"
|
|
7569
|
+
aria-label="artboard breakpoint preset"
|
|
7570
|
+
value={activeWidthPreset ?? ''}
|
|
7571
|
+
onChange={(e) => {
|
|
7572
|
+
const p = SCREEN_PRESETS[e.currentTarget.value];
|
|
7573
|
+
if (p) commitSize(p.width, null);
|
|
7574
|
+
}}
|
|
7575
|
+
>
|
|
7576
|
+
<option value="" disabled>
|
|
7577
|
+
{activeWidthPreset
|
|
7578
|
+
? `${SCREEN_PRESETS[activeWidthPreset].label} — ≤ ${SCREEN_PRESETS[activeWidthPreset].width}px`
|
|
7579
|
+
: 'Breakpoint…'}
|
|
7580
|
+
</option>
|
|
7581
|
+
{Object.entries(SCREEN_PRESETS).map(([key, p]) => (
|
|
7582
|
+
<option key={key} value={key}>
|
|
7583
|
+
{p.label} — ≤ {p.width}px breakpoint
|
|
7584
|
+
</option>
|
|
7585
|
+
))}
|
|
7586
|
+
</select>
|
|
7587
|
+
) : (
|
|
7588
|
+
<select
|
|
7589
|
+
className="st-cp-nsel"
|
|
7590
|
+
aria-label="artboard size preset"
|
|
7591
|
+
value={activePreset ?? ''}
|
|
7592
|
+
onChange={(e) => {
|
|
7593
|
+
const p = SCREEN_PRESETS[e.currentTarget.value];
|
|
7594
|
+
if (p) commitSize(p.width, p.height);
|
|
7595
|
+
}}
|
|
7596
|
+
>
|
|
7597
|
+
<option value="" disabled>
|
|
7598
|
+
{activePreset ? SCREEN_PRESETS[activePreset].label : 'Preset size…'}
|
|
7599
|
+
</option>
|
|
7600
|
+
{Object.entries(SCREEN_PRESETS).map(([key, p]) => (
|
|
7601
|
+
<option key={key} value={key}>
|
|
7602
|
+
{p.label} — {p.width}×{p.height}
|
|
7603
|
+
</option>
|
|
7604
|
+
))}
|
|
7605
|
+
</select>
|
|
7606
|
+
)}
|
|
6875
7607
|
</div>
|
|
6876
7608
|
<div style={{ padding: '0 12px 8px' }}>
|
|
6877
|
-
<
|
|
6878
|
-
|
|
6879
|
-
|
|
6880
|
-
|
|
6881
|
-
|
|
6882
|
-
|
|
6883
|
-
|
|
6884
|
-
|
|
6885
|
-
|
|
6886
|
-
key={label}
|
|
6887
|
-
type="button"
|
|
6888
|
-
className={`st-cp-modebtn${fixed === val ? ' is-active' : ''}`}
|
|
6889
|
-
aria-pressed={fixed === val}
|
|
6890
|
-
onClick={() => setHug(val)}
|
|
6891
|
-
title={`${label} height`}
|
|
6892
|
-
>
|
|
6893
|
-
{label}
|
|
6894
|
-
</button>
|
|
6895
|
-
))}
|
|
6896
|
-
</div>
|
|
7609
|
+
<Segmented
|
|
7610
|
+
value={fixed ? 'fixed' : 'hug'}
|
|
7611
|
+
ariaLabel="artboard height sizing mode"
|
|
7612
|
+
options={[
|
|
7613
|
+
{ value: 'hug', label: 'Hug' },
|
|
7614
|
+
{ value: 'fixed', label: 'Fixed' },
|
|
7615
|
+
]}
|
|
7616
|
+
onChange={(v) => setHug(v === 'fixed')}
|
|
7617
|
+
/>
|
|
6897
7618
|
</div>
|
|
6898
7619
|
<div className="st-cp-sechd-row">
|
|
6899
|
-
<span className="st-cp-sechd">
|
|
7620
|
+
<span className="st-cp-sechd">Kind</span>
|
|
6900
7621
|
</div>
|
|
6901
|
-
<div style={{ padding: '
|
|
6902
|
-
<
|
|
6903
|
-
|
|
6904
|
-
|
|
6905
|
-
|
|
6906
|
-
|
|
6907
|
-
|
|
6908
|
-
|
|
6909
|
-
|
|
6910
|
-
|
|
6911
|
-
|
|
6912
|
-
|
|
6913
|
-
|
|
6914
|
-
|
|
7622
|
+
<div style={{ padding: '0 12px 8px' }}>
|
|
7623
|
+
<Select
|
|
7624
|
+
value={kind}
|
|
7625
|
+
ariaLabel="artboard kind"
|
|
7626
|
+
options={ARTBOARD_KIND_OPTIONS.map(([value, label]) => ({ value, label }))}
|
|
7627
|
+
onChange={setKind}
|
|
7628
|
+
/>
|
|
7629
|
+
</div>
|
|
7630
|
+
{kind !== 'print' && artboardId && onDuplicateArtboard ? (
|
|
7631
|
+
// feature-3-web-artboards T3 — "Duplicate at width…". A stateless
|
|
7632
|
+
// action trigger (not a size preset reflecting current state, unlike
|
|
7633
|
+
// the Preset-size select above), so the control resets to its
|
|
7634
|
+
// placeholder after firing rather than showing the last pick as
|
|
7635
|
+
// though it were now selected. Gated off kind="print" — paper size
|
|
7636
|
+
// is picked via the Artboard preset dropdown above, not a px width.
|
|
7637
|
+
<div style={{ padding: '0 12px 8px' }}>
|
|
7638
|
+
<select
|
|
7639
|
+
className="st-cp-nsel"
|
|
7640
|
+
aria-label="duplicate artboard at breakpoint width"
|
|
7641
|
+
value=""
|
|
7642
|
+
onChange={(e) => {
|
|
7643
|
+
const p = SCREEN_PRESETS[e.currentTarget.value];
|
|
7644
|
+
if (p) onDuplicateArtboard(artboardId, p.width);
|
|
7645
|
+
e.currentTarget.value = '';
|
|
6915
7646
|
}}
|
|
6916
|
-
|
|
6917
|
-
|
|
6918
|
-
|
|
6919
|
-
<select
|
|
6920
|
-
className="st-cp-nsel"
|
|
6921
|
-
aria-label="artboard body layout"
|
|
6922
|
-
value={layoutMode}
|
|
6923
|
-
onChange={(e) => commitStyle({ layout: e.currentTarget.value || null })}
|
|
6924
|
-
>
|
|
6925
|
-
{ARTBOARD_LAYOUT_OPTIONS.map(([val, label]) => (
|
|
6926
|
-
<option key={val} value={val}>
|
|
6927
|
-
{label}
|
|
7647
|
+
>
|
|
7648
|
+
<option value="" disabled>
|
|
7649
|
+
Duplicate at width…
|
|
6928
7650
|
</option>
|
|
6929
|
-
|
|
6930
|
-
|
|
6931
|
-
|
|
6932
|
-
|
|
6933
|
-
|
|
6934
|
-
|
|
6935
|
-
|
|
6936
|
-
|
|
6937
|
-
|
|
6938
|
-
|
|
6939
|
-
|
|
6940
|
-
|
|
6941
|
-
|
|
6942
|
-
|
|
6943
|
-
|
|
6944
|
-
|
|
6945
|
-
|
|
6946
|
-
|
|
6947
|
-
|
|
6948
|
-
|
|
6949
|
-
|
|
7651
|
+
{Object.entries(SCREEN_PRESETS).map(([key, p]) => (
|
|
7652
|
+
<option key={key} value={key}>
|
|
7653
|
+
{p.label} — {p.width}px
|
|
7654
|
+
</option>
|
|
7655
|
+
))}
|
|
7656
|
+
</select>
|
|
7657
|
+
</div>
|
|
7658
|
+
) : null}
|
|
7659
|
+
{kind === 'print' ? (
|
|
7660
|
+
<>
|
|
7661
|
+
<div className="st-cp-sechd-row">
|
|
7662
|
+
<span className="st-cp-sechd">Print</span>
|
|
7663
|
+
</div>
|
|
7664
|
+
{/* Paper picker lives in the Artboard section's own preset dropdown
|
|
7665
|
+
above (it REPLACES the screen presets for kind="print") — this
|
|
7666
|
+
section carries the print-specific residue: orientation + bleed. */}
|
|
7667
|
+
<div style={{ padding: '0 12px 8px' }}>
|
|
7668
|
+
<Segmented
|
|
7669
|
+
value={print?.orientation ?? 'portrait'}
|
|
7670
|
+
ariaLabel="paper orientation"
|
|
7671
|
+
options={[
|
|
7672
|
+
{ value: 'portrait', label: 'Portrait' },
|
|
7673
|
+
{ value: 'landscape', label: 'Landscape' },
|
|
7674
|
+
]}
|
|
7675
|
+
onChange={(v) => setPrint({ orientation: v })}
|
|
6950
7676
|
/>
|
|
6951
7677
|
</div>
|
|
6952
|
-
<div
|
|
6953
|
-
<
|
|
6954
|
-
|
|
6955
|
-
|
|
6956
|
-
|
|
6957
|
-
|
|
6958
|
-
|
|
6959
|
-
|
|
6960
|
-
|
|
6961
|
-
|
|
6962
|
-
|
|
6963
|
-
|
|
6964
|
-
|
|
6965
|
-
|
|
6966
|
-
|
|
6967
|
-
|
|
6968
|
-
|
|
6969
|
-
}}
|
|
7678
|
+
<div style={{ padding: '0 12px 8px' }}>
|
|
7679
|
+
<NumberField
|
|
7680
|
+
value={Number.isFinite(print?.bleedMm) ? print.bleedMm : 3}
|
|
7681
|
+
min={0}
|
|
7682
|
+
step={0.5}
|
|
7683
|
+
ariaLabel="bleed, millimeters"
|
|
7684
|
+
// Single-glyph lead — the drag-handle slot is sized for 1–2
|
|
7685
|
+
// chars ("W"/"Pad"); a full word clips. mm suffix + aria carry
|
|
7686
|
+
// the meaning.
|
|
7687
|
+
lead="B"
|
|
7688
|
+
steppers={false}
|
|
7689
|
+
unitSlot={
|
|
7690
|
+
<span className="st-cp-numsuffix" aria-hidden="true">
|
|
7691
|
+
mm
|
|
7692
|
+
</span>
|
|
7693
|
+
}
|
|
7694
|
+
onCommit={(n) => setPrint({ bleedMm: n })}
|
|
6970
7695
|
/>
|
|
6971
7696
|
</div>
|
|
7697
|
+
</>
|
|
7698
|
+
) : null}
|
|
7699
|
+
<div className="st-cp-sechd-row">
|
|
7700
|
+
<span className="st-cp-sechd">Style</span>
|
|
7701
|
+
</div>
|
|
7702
|
+
<div style={{ padding: '4px 12px 8px', display: 'flex', flexDirection: 'column', gap: 8 }}>
|
|
7703
|
+
{(() => {
|
|
7704
|
+
// Bg — the CssKnobs `color()` recipe: TokenPopover swatch as the
|
|
7705
|
+
// flush prefix inside ColorField (HSV picker + per-DS variables).
|
|
7706
|
+
const bgResolved = resolveTokenValue(bg);
|
|
7707
|
+
const bgBound = /var\(\s*--/.test(bg);
|
|
7708
|
+
const bgDisplay = bgBound
|
|
7709
|
+
? bg.replace(/^var\(\s*|\s*\)$/g, '').replace(/^--/, '').replace(/-/g, ' ')
|
|
7710
|
+
: bg;
|
|
7711
|
+
return (
|
|
7712
|
+
<ColorField
|
|
7713
|
+
swatch={
|
|
7714
|
+
<TokenPopover
|
|
7715
|
+
kind="color"
|
|
7716
|
+
swatchClassName="st-cp-cf-sw"
|
|
7717
|
+
groups={tokenGroups('color')}
|
|
7718
|
+
current={bg}
|
|
7719
|
+
activeDs={_activeDs}
|
|
7720
|
+
swatchBg={bgResolved}
|
|
7721
|
+
seedHex={cssColorToHex(bgResolved) || '#000000'}
|
|
7722
|
+
onPick={(v) => commitStyle({ background: v || null })}
|
|
7723
|
+
label="artboard background colour"
|
|
7724
|
+
/>
|
|
7725
|
+
}
|
|
7726
|
+
displayValue={bgDisplay}
|
|
7727
|
+
bound={bgBound}
|
|
7728
|
+
ariaLabel="artboard background"
|
|
7729
|
+
onValue={(v) => commitStyle({ background: (v || '').trim() || null })}
|
|
7730
|
+
/>
|
|
7731
|
+
);
|
|
7732
|
+
})()}
|
|
7733
|
+
<Select
|
|
7734
|
+
value={layoutMode}
|
|
7735
|
+
ariaLabel="artboard body layout"
|
|
7736
|
+
options={ARTBOARD_LAYOUT_OPTIONS.map(([value, label]) => ({
|
|
7737
|
+
value,
|
|
7738
|
+
label,
|
|
7739
|
+
}))}
|
|
7740
|
+
onChange={(v) => commitStyle({ layout: v || null })}
|
|
7741
|
+
/>
|
|
7742
|
+
<div style={{ display: 'flex', gap: 8 }}>
|
|
7743
|
+
<ValueTokenField
|
|
7744
|
+
value={/^var\(/.test(padding) ? padding : Number.parseFloat(padding) || 0}
|
|
7745
|
+
tokens={flatTokens('space')}
|
|
7746
|
+
ariaLabel="artboard padding"
|
|
7747
|
+
lead="Pad"
|
|
7748
|
+
min={0}
|
|
7749
|
+
onChange={(v) =>
|
|
7750
|
+
commitStyle({ padding: typeof v === 'string' ? v : v > 0 ? v : null })
|
|
7751
|
+
}
|
|
7752
|
+
/>
|
|
7753
|
+
<ValueTokenField
|
|
7754
|
+
value={/^var\(/.test(gap) ? gap : Number.parseFloat(gap) || 0}
|
|
7755
|
+
tokens={flatTokens('space')}
|
|
7756
|
+
ariaLabel="artboard gap"
|
|
7757
|
+
lead="Gap"
|
|
7758
|
+
min={0}
|
|
7759
|
+
onChange={(v) => commitStyle({ gap: typeof v === 'string' ? v : v > 0 ? v : null })}
|
|
7760
|
+
/>
|
|
6972
7761
|
</div>
|
|
6973
7762
|
</div>
|
|
6974
7763
|
</section>
|
|
@@ -6981,11 +7770,28 @@ function ArtboardKnobs({ el, onResizeArtboard, onSetArtboardHug, onSetArtboardSt
|
|
|
6981
7770
|
// of the selection's src/html for a selection that reached the panel via a code
|
|
6982
7771
|
// path predating that field. Returns null for a non-photo element.
|
|
6983
7772
|
const PHOTO_ASSET_RE = /assets\/[0-9a-f]{8}\.[a-z0-9]+/i;
|
|
7773
|
+
// Simplifier fix (review fan-out, 2026-07-21) — reuse PHOTO_ASSET_RE's own
|
|
7774
|
+
// source for the global-match variant instead of re-inlining the pattern, so
|
|
7775
|
+
// the two can never drift apart (e.g. an extension-charset fix landing in one
|
|
7776
|
+
// and not the other).
|
|
7777
|
+
const PHOTO_ASSET_RE_G = new RegExp(PHOTO_ASSET_RE.source, 'gi');
|
|
6984
7778
|
function photoAssetOfSelection(el) {
|
|
6985
7779
|
if (!el || Array.isArray(el)) return null;
|
|
6986
7780
|
if (el.photoAsset) return el.photoAsset;
|
|
6987
|
-
if ((el.tag || '').toLowerCase() !== 'img') return null;
|
|
6988
7781
|
const html = el.html || '';
|
|
7782
|
+
// feature-4 regression fix (2026-07-20) — the select tool's bare click now
|
|
7783
|
+
// picks the TOP-LEVEL container, so a photo selection often arrives as the
|
|
7784
|
+
// WRAPPER around the <img>, not the <img> itself. When the selection's
|
|
7785
|
+
// subtree contains EXACTLY ONE content-addressed image, offer the Photo tab
|
|
7786
|
+
// for it (ambiguous multi-photo containers stay photo-less — drill/⌘-click
|
|
7787
|
+
// to pick one).
|
|
7788
|
+
if ((el.tag || '').toLowerCase() !== 'img') {
|
|
7789
|
+
const imgs = [...new Set(html.match(PHOTO_ASSET_RE_G) || [])];
|
|
7790
|
+
const tagged = [...new Set([...html.matchAll(/data-photo-asset="([^"]+)"/g)].map((m) => m[1]))];
|
|
7791
|
+
if (tagged.length === 1) return tagged[0];
|
|
7792
|
+
if (tagged.length === 0 && imgs.length === 1 && /<img/i.test(html)) return imgs[0];
|
|
7793
|
+
return null;
|
|
7794
|
+
}
|
|
6989
7795
|
// Once an edit is baked, the element's LIVE src is a `data:` URL (canvas-lib's
|
|
6990
7796
|
// PhotoPreviewBridge swaps it in directly) — it never matches PHOTO_ASSET_RE,
|
|
6991
7797
|
// so a REPLAYED/serialized selection (a WS resync, or the persisted
|
|
@@ -7004,6 +7810,13 @@ function InspectorPanel({
|
|
|
7004
7810
|
selected,
|
|
7005
7811
|
onClose,
|
|
7006
7812
|
layersTree,
|
|
7813
|
+
// feature-4 T7a — { [cdId]: { component, root, usages } } for purple rows.
|
|
7814
|
+
componentMap,
|
|
7815
|
+
// feature-4 T7b — locked layer keys (`"<id>:<index>"`) + toggle.
|
|
7816
|
+
lockedKeys,
|
|
7817
|
+
onToggleLock,
|
|
7818
|
+
// feature-4 detach-component — clone-definition detach for a shared instance.
|
|
7819
|
+
onDetachInstance,
|
|
7007
7820
|
canvasFile,
|
|
7008
7821
|
onSelectLayer,
|
|
7009
7822
|
onHoverLayer,
|
|
@@ -7016,6 +7829,9 @@ function InspectorPanel({
|
|
|
7016
7829
|
onResizeArtboard,
|
|
7017
7830
|
onSetArtboardHug,
|
|
7018
7831
|
onSetArtboardStyle,
|
|
7832
|
+
onSetArtboardKind,
|
|
7833
|
+
onSetArtboardPrint,
|
|
7834
|
+
onDuplicateArtboard,
|
|
7019
7835
|
onUndoRedo,
|
|
7020
7836
|
editScope,
|
|
7021
7837
|
tab: tabProp,
|
|
@@ -7062,6 +7878,48 @@ function InspectorPanel({
|
|
|
7062
7878
|
// `node.hidden` (the authoritative source value) so a first render of an
|
|
7063
7879
|
// already-hidden element shows the correct eye-icon state without a click.
|
|
7064
7880
|
const [hiddenOverride, setHiddenOverride] = useState(() => new Map());
|
|
7881
|
+
// feature-4 T7 — auto-reveal the selected row: expand any collapsed ancestor
|
|
7882
|
+
// group + scroll it into view, so a canvas selection is never hidden behind a
|
|
7883
|
+
// collapsed wrapper (a Figma layers-panel expectation). Runs whenever the
|
|
7884
|
+
// selection or the (HMR-refreshed) tree changes.
|
|
7885
|
+
useEffect(() => {
|
|
7886
|
+
const sel = Array.isArray(selected) ? selected[0] : selected;
|
|
7887
|
+
const selId = sel?.id;
|
|
7888
|
+
if (!selId || !layersTree?.nodes) return;
|
|
7889
|
+
const selIdx = sel.index;
|
|
7890
|
+
let matchedKey = null;
|
|
7891
|
+
const ancestorKeys = [];
|
|
7892
|
+
(function find(nodes, trail) {
|
|
7893
|
+
for (const n of nodes || []) {
|
|
7894
|
+
const k = `${n.id}:${n.index}`;
|
|
7895
|
+
if (!n.synthetic && n.id === selId && (selIdx == null || n.index === selIdx)) {
|
|
7896
|
+
matchedKey = k;
|
|
7897
|
+
ancestorKeys.push(...trail);
|
|
7898
|
+
return true;
|
|
7899
|
+
}
|
|
7900
|
+
if (n.children && n.children.length && find(n.children, [...trail, k])) return true;
|
|
7901
|
+
}
|
|
7902
|
+
return false;
|
|
7903
|
+
})(layersTree.nodes, []);
|
|
7904
|
+
if (!matchedKey) return;
|
|
7905
|
+
if (ancestorKeys.length) {
|
|
7906
|
+
setCollapsed((prev) => {
|
|
7907
|
+
let changed = false;
|
|
7908
|
+
const next = new Set(prev);
|
|
7909
|
+
for (const k of ancestorKeys) if (next.delete(k)) changed = true;
|
|
7910
|
+
return changed ? next : prev;
|
|
7911
|
+
});
|
|
7912
|
+
}
|
|
7913
|
+
requestAnimationFrame(() => {
|
|
7914
|
+
try {
|
|
7915
|
+
document
|
|
7916
|
+
.querySelector(`.st-layer--row[data-layer-key="${matchedKey}"]`)
|
|
7917
|
+
?.scrollIntoView({ block: 'nearest' });
|
|
7918
|
+
} catch {
|
|
7919
|
+
/* selector edge case — non-fatal */
|
|
7920
|
+
}
|
|
7921
|
+
});
|
|
7922
|
+
}, [selected, layersTree]);
|
|
7065
7923
|
const isNodeHidden = (node) => {
|
|
7066
7924
|
const key = `${node.id}:${node.index}`;
|
|
7067
7925
|
return hiddenOverride.has(key) ? hiddenOverride.get(key) : !!node.hidden;
|
|
@@ -7070,11 +7928,41 @@ function InspectorPanel({
|
|
|
7070
7928
|
// same drop target) + an aria-live announcement for keyboard moves.
|
|
7071
7929
|
const [dragState, setDragState] = useState(null);
|
|
7072
7930
|
const [reorderMsg, setReorderMsg] = useState('');
|
|
7931
|
+
// feature-4 T7c — Layers-panel rename: writes `data-dc-element` (the label's
|
|
7932
|
+
// top-priority source in `layerLabel`) via the existing /_api/edit-attr lane
|
|
7933
|
+
// + records undo through the same source-edit channel CssKnobs uses. The
|
|
7934
|
+
// HMR reload re-posts the tree with the new label.
|
|
7935
|
+
const renameLayer = (node, value) => {
|
|
7936
|
+
if (!canvasFile || !node?.id) return;
|
|
7937
|
+
fetch('/_api/edit-attr', {
|
|
7938
|
+
method: 'POST',
|
|
7939
|
+
headers: { 'content-type': 'application/json' },
|
|
7940
|
+
body: JSON.stringify({ canvas: canvasFile, id: node.id, attr: 'data-dc-element', value }),
|
|
7941
|
+
})
|
|
7942
|
+
.then((r) => r.json().catch(() => ({})))
|
|
7943
|
+
.then((j) => {
|
|
7944
|
+
if (!j.ok) return;
|
|
7945
|
+
onRecordEdit?.({
|
|
7946
|
+
op: 'attr',
|
|
7947
|
+
canvas: canvasFile,
|
|
7948
|
+
id: node.id,
|
|
7949
|
+
key: 'data-dc-element',
|
|
7950
|
+
before: node.dcElement ?? null,
|
|
7951
|
+
after: value,
|
|
7952
|
+
});
|
|
7953
|
+
})
|
|
7954
|
+
.catch(() => {});
|
|
7955
|
+
};
|
|
7073
7956
|
const handleReorder = onReorderLayer
|
|
7074
7957
|
? (dragged, ref, position) => {
|
|
7075
7958
|
// Gate keyboard + drop moves while a prior reorder is still landing — the
|
|
7076
7959
|
// write churns positional ids, so acting on the stale tree would misfire.
|
|
7077
7960
|
if (layersBusyRef?.current) return;
|
|
7961
|
+
// feature-4 T7 — a synthetic group row (unstamped wrapper) has no
|
|
7962
|
+
// data-cd-id to address in source, so it can be neither dragged nor a
|
|
7963
|
+
// drop target. Abort silently rather than post an unresolvable refId
|
|
7964
|
+
// that would optimistically move then revert (a visible flicker).
|
|
7965
|
+
if (dragged?.synthetic || ref?.synthetic) return;
|
|
7078
7966
|
const verb =
|
|
7079
7967
|
position === 'before'
|
|
7080
7968
|
? `before ${ref.label}`
|
|
@@ -7436,6 +8324,19 @@ function InspectorPanel({
|
|
|
7436
8324
|
editScope.affects
|
|
7437
8325
|
} place${editScope.affects === 1 ? '' : 's'}`
|
|
7438
8326
|
: 'Local · this element only'}
|
|
8327
|
+
{/* feature-4 detach-component (2026-07-19) — make THIS instance its
|
|
8328
|
+
own single-usage component so edits (incl. absolute positions)
|
|
8329
|
+
stay local per artboard. */}
|
|
8330
|
+
{editScope.scope === 'shared' && onDetachInstance ? (
|
|
8331
|
+
<button
|
|
8332
|
+
type="button"
|
|
8333
|
+
className="st-scope-detach"
|
|
8334
|
+
title="Clone this component for this instance only — edits stop affecting the other places"
|
|
8335
|
+
onClick={() => onDetachInstance(el.id, el.index)}
|
|
8336
|
+
>
|
|
8337
|
+
Detach
|
|
8338
|
+
</button>
|
|
8339
|
+
) : null}
|
|
7439
8340
|
</div>
|
|
7440
8341
|
) : null}
|
|
7441
8342
|
<div className="st-rp-body">
|
|
@@ -7451,15 +8352,16 @@ function InspectorPanel({
|
|
|
7451
8352
|
onRecordEdit={(before, after) => onPhotoRecordEdit?.(photoTarget.asset, before, after)}
|
|
7452
8353
|
/>
|
|
7453
8354
|
</div>
|
|
7454
|
-
) : !el ? (
|
|
8355
|
+
) : !el && !(effTab === 'layers' && layersTree?.nodes?.length) ? (
|
|
7455
8356
|
<div className="st-rp-empty">
|
|
7456
8357
|
{/* <p> wrapper — st-rp-empty is a flex column, bare text nodes +
|
|
7457
8358
|
kbd would stack as stretched flex items. */}
|
|
7458
8359
|
<p>
|
|
7459
|
-
|
|
8360
|
+
Press <Kbd>V</Kbd>, then click an element to select it — or <Kbd>⌘</Kbd>-click
|
|
8361
|
+
straight from Browse.
|
|
7460
8362
|
</p>
|
|
7461
8363
|
</div>
|
|
7462
|
-
) : effTab === 'inspect' ? (
|
|
8364
|
+
) : el && effTab === 'inspect' ? (
|
|
7463
8365
|
<>
|
|
7464
8366
|
<div className="st-rp-hd">{el.selector || el.tag || 'element'}</div>
|
|
7465
8367
|
<div className="st-insp-row">
|
|
@@ -7532,8 +8434,8 @@ function InspectorPanel({
|
|
|
7532
8434
|
key={`${n.id}:${n.index}`}
|
|
7533
8435
|
node={n}
|
|
7534
8436
|
depth={0}
|
|
7535
|
-
selectedId={el
|
|
7536
|
-
selectedIndex={el
|
|
8437
|
+
selectedId={el?.id}
|
|
8438
|
+
selectedIndex={el?.index}
|
|
7537
8439
|
collapsed={collapsed}
|
|
7538
8440
|
hiddenOverride={hiddenOverride}
|
|
7539
8441
|
onToggle={toggleCollapse}
|
|
@@ -7546,6 +8448,10 @@ function InspectorPanel({
|
|
|
7546
8448
|
onReorder={handleReorder}
|
|
7547
8449
|
onRowPointerDown={startLayerDrag}
|
|
7548
8450
|
dragState={dragState}
|
|
8451
|
+
componentMap={componentMap}
|
|
8452
|
+
lockedKeys={lockedKeys}
|
|
8453
|
+
onToggleLock={onToggleLock}
|
|
8454
|
+
onRename={renameLayer}
|
|
7549
8455
|
/>
|
|
7550
8456
|
))}
|
|
7551
8457
|
</div>
|
|
@@ -7564,7 +8470,7 @@ function InspectorPanel({
|
|
|
7564
8470
|
{reorderMsg}
|
|
7565
8471
|
</div>
|
|
7566
8472
|
</>
|
|
7567
|
-
) : Array.isArray(el.dom_path) && el.dom_path.length ? (
|
|
8473
|
+
) : el && Array.isArray(el.dom_path) && el.dom_path.length ? (
|
|
7568
8474
|
el.dom_path.map((node, i) => (
|
|
7569
8475
|
<div
|
|
7570
8476
|
key={i}
|
|
@@ -7584,9 +8490,13 @@ function InspectorPanel({
|
|
|
7584
8490
|
) : !el.id && resolveArtboardIdFromSelection(el) ? (
|
|
7585
8491
|
<ArtboardKnobs
|
|
7586
8492
|
el={el}
|
|
8493
|
+
cfg={cfg}
|
|
7587
8494
|
onResizeArtboard={onResizeArtboard}
|
|
7588
8495
|
onSetArtboardHug={onSetArtboardHug}
|
|
7589
8496
|
onSetArtboardStyle={onSetArtboardStyle}
|
|
8497
|
+
onSetArtboardKind={onSetArtboardKind}
|
|
8498
|
+
onSetArtboardPrint={onSetArtboardPrint}
|
|
8499
|
+
onDuplicateArtboard={onDuplicateArtboard}
|
|
7590
8500
|
/>
|
|
7591
8501
|
) : (
|
|
7592
8502
|
<CssKnobs
|
|
@@ -7655,6 +8565,11 @@ function App() {
|
|
|
7655
8565
|
// user meanwhile selected something else. select-by-id is idempotent
|
|
7656
8566
|
// (replace-same → select-set echo → ws guard sees same id → no re-schedule),
|
|
7657
8567
|
// so the ladder can't loop.
|
|
8568
|
+
// feature-4 dogfood fix — timestamp of the last LOCAL selection change sent
|
|
8569
|
+
// over WS. The ws 'selected' broadcast is both (a) our own echo and (b) a
|
|
8570
|
+
// genuine cross-canvas restore; within this window it's always (a) and must
|
|
8571
|
+
// not overwrite fresher local state (multi-select / drill races).
|
|
8572
|
+
const lastLocalSelectAtRef = useRef(0);
|
|
7658
8573
|
const haloRestoreTimersRef = useRef([]);
|
|
7659
8574
|
const scheduleHaloRestore = useCallback((one) => {
|
|
7660
8575
|
if (!one?.id || !one.file) return;
|
|
@@ -7680,6 +8595,32 @@ function App() {
|
|
|
7680
8595
|
}, delay)
|
|
7681
8596
|
);
|
|
7682
8597
|
}, []);
|
|
8598
|
+
// Dogfood follow-up — the ArtboardKnobs panel (Kind/Print/Style/Hug/W-H)
|
|
8599
|
+
// writes via structuralWrite, which never refreshed the Inspector's OWN
|
|
8600
|
+
// `attrs` snapshot afterward: the artboard itself re-rendered correctly
|
|
8601
|
+
// (new kind, new size) but the Inspector kept showing the PRE-edit values
|
|
8602
|
+
// (e.g. "Digital" right after picking "Print") until the user manually
|
|
8603
|
+
// re-clicked the artboard. Mirrors scheduleHaloRestore's retry ladder
|
|
8604
|
+
// (the HMR reload lands async, so a single immediate post races it) but
|
|
8605
|
+
// for a bare ARTBOARD selection (no data-cd-id to key off of — compares
|
|
8606
|
+
// by artboardId instead of id).
|
|
8607
|
+
const artboardResyncTimersRef = useRef([]);
|
|
8608
|
+
const scheduleArtboardResync = useCallback((artboardId, file) => {
|
|
8609
|
+
if (!artboardId || !file) return;
|
|
8610
|
+
for (const t of artboardResyncTimersRef.current) clearTimeout(t);
|
|
8611
|
+
artboardResyncTimersRef.current = [50, 450, 1200, 2500, 5000].map((delay) =>
|
|
8612
|
+
setTimeout(() => {
|
|
8613
|
+
const cur = selectedRef.current;
|
|
8614
|
+
const c1 = Array.isArray(cur) ? cur[0] : cur;
|
|
8615
|
+
if (!c1 || c1.artboardId !== artboardId || c1.file !== file) return; // superseded
|
|
8616
|
+
const frame = iframesRef.current.get(file);
|
|
8617
|
+
if (!frame?.contentWindow) return;
|
|
8618
|
+
try {
|
|
8619
|
+
frame.contentWindow.postMessage({ dgn: 'select-by-id', artboardId }, '*');
|
|
8620
|
+
} catch {}
|
|
8621
|
+
}, delay)
|
|
8622
|
+
);
|
|
8623
|
+
}, []);
|
|
7683
8624
|
// Phase 12.1 (DDR-138) — after a reorder writes source, the HMR reload remounts
|
|
7684
8625
|
// the canvas and the positional data-cd-id of the moved element (and everything
|
|
7685
8626
|
// after it) renumbers. Stash the re-settle target { file, movedId, artboardId }
|
|
@@ -7706,6 +8647,27 @@ function App() {
|
|
|
7706
8647
|
const layersBusyTimerRef = useRef(null);
|
|
7707
8648
|
// Phase 12 Task 4 — Layers tree for the active artboard (posted by canvas-shell).
|
|
7708
8649
|
const [layersTree, setLayersTree] = useState(null);
|
|
8650
|
+
// feature-4 T7a — Layers-panel component map ({ [cdId]: { component, root,
|
|
8651
|
+
// usages } }) for purple instance rows. Fetched from the read-only main-origin
|
|
8652
|
+
// /_api/component-map on canvas/tree changes, throttled: the map only changes
|
|
8653
|
+
// when SOURCE changes, but the tree re-posts on any DOM churn (drag reflows) —
|
|
8654
|
+
// don't re-parse the canvas for those.
|
|
8655
|
+
const [componentMap, setComponentMap] = useState(null);
|
|
8656
|
+
const componentMapAtRef = useRef({ canvas: null, at: 0 });
|
|
8657
|
+
useEffect(() => {
|
|
8658
|
+
if (!activePath || !layersTree) return;
|
|
8659
|
+
const now = Date.now();
|
|
8660
|
+
const last = componentMapAtRef.current;
|
|
8661
|
+
if (last.canvas === activePath && now - last.at < 1500) return;
|
|
8662
|
+
componentMapAtRef.current = { canvas: activePath, at: now };
|
|
8663
|
+
fetch(`/_api/component-map?canvas=${encodeURIComponent(activePath)}`)
|
|
8664
|
+
.then((r) => r.json())
|
|
8665
|
+
.then((j) => {
|
|
8666
|
+
if (j?.ok && j.map && typeof j.map === 'object') setComponentMap(j.map);
|
|
8667
|
+
else setComponentMap(null);
|
|
8668
|
+
})
|
|
8669
|
+
.catch(() => setComponentMap(null));
|
|
8670
|
+
}, [activePath, layersTree]);
|
|
7709
8671
|
const [wsConnected, setWsConnected] = useState(false);
|
|
7710
8672
|
// Phase 8 Task 7 — git lifecycle reload prompt. Server has already flushed
|
|
7711
8673
|
// every dirty Y.Doc to disk by the time this state populates, so accepting
|
|
@@ -7973,13 +8935,32 @@ function App() {
|
|
|
7973
8935
|
const handleAssistantFinished = useCallback(() => {
|
|
7974
8936
|
if (!assistantOpenRef.current || document.hidden) {
|
|
7975
8937
|
setAssistantUnseen(true);
|
|
7976
|
-
|
|
7977
|
-
|
|
7978
|
-
|
|
7979
|
-
|
|
7980
|
-
|
|
7981
|
-
|
|
7982
|
-
|
|
8938
|
+
notifyDesktop('Claude finished', 'Your assistant turn is ready in Maude.');
|
|
8939
|
+
}
|
|
8940
|
+
}, []);
|
|
8941
|
+
// DDR-185 — same "you weren't looking" gate as handleAssistantFinished
|
|
8942
|
+
// above, fired instead when a chat is PAUSED waiting on the user (a
|
|
8943
|
+
// permission prompt or an AskUserQuestion/elicitation form), not just when
|
|
8944
|
+
// a turn completes. A stalled turn otherwise gives no signal at all if the
|
|
8945
|
+
// window isn't focused. Distinct copy so it doesn't read as "done".
|
|
8946
|
+
//
|
|
8947
|
+
// Security-review addendum (Finding G): DDR-179/180 already cap concurrent
|
|
8948
|
+
// pending requests at 10 permissions + 5 elicitations, so a single
|
|
8949
|
+
// adversarial turn can legitimately queue up to 15 DISTINCT approval
|
|
8950
|
+
// decisions — each one now fires a real OS Notification, which is exactly
|
|
8951
|
+
// the burst that manufactures the "rapid Enter-mashing" precondition
|
|
8952
|
+
// DDR-179's own addendum already treats as a security-relevant habituation
|
|
8953
|
+
// risk. The in-app badge (setAssistantUnseen) still reflects EVERY new
|
|
8954
|
+
// request instantly — only the OS notification itself is rate-limited, so
|
|
8955
|
+
// a burst reads as one attention-getting ping, not fifteen.
|
|
8956
|
+
const lastAttentionNotifyRef = useRef(0);
|
|
8957
|
+
const handleAssistantAttention = useCallback(() => {
|
|
8958
|
+
if (!assistantOpenRef.current || document.hidden) {
|
|
8959
|
+
setAssistantUnseen(true);
|
|
8960
|
+
const now = Date.now();
|
|
8961
|
+
if (now - lastAttentionNotifyRef.current < ATTENTION_NOTIFY_COOLDOWN_MS) return;
|
|
8962
|
+
lastAttentionNotifyRef.current = now;
|
|
8963
|
+
notifyDesktop('Maude needs your input', 'Claude is waiting on an approval or a question in Maude.');
|
|
7983
8964
|
}
|
|
7984
8965
|
}, []);
|
|
7985
8966
|
// Inspector tab is lifted so View ▸ Layers can open the panel ON the Layers
|
|
@@ -8195,6 +9176,12 @@ function App() {
|
|
|
8195
9176
|
const [minimapVisible, setMinimapVisible] = useState(() => readBoolStore(MINIMAP_STORE, false));
|
|
8196
9177
|
const [zoomCtlVisible, setZoomCtlVisible] = useState(() => readBoolStore(ZOOMCTL_STORE, false));
|
|
8197
9178
|
const [presentMode, setPresentMode] = useState(false);
|
|
9179
|
+
// feature-2-print-artboards T3 — "Show print guides". Per-CANVAS persisted
|
|
9180
|
+
// (view.json `overlays.print`, via /_api/canvas-meta), not a global
|
|
9181
|
+
// localStorage pref like minimap/zoomctl — mirrors the foundation's
|
|
9182
|
+
// `overlays.guides` lane. Re-read whenever the active canvas changes (see
|
|
9183
|
+
// the activeArtboards effect below); sent only to the active iframe.
|
|
9184
|
+
const [printGuidesVisible, setPrintGuidesVisible] = useState(false);
|
|
8198
9185
|
// P2/P3 (Plan C) — top-bar live state. (Zoom lives in the canvas toolbar pill,
|
|
8199
9186
|
// so the top bar no longer mirrors it.)
|
|
8200
9187
|
// activeArtboards — real artboard count of the open canvas, read from its
|
|
@@ -8625,6 +9612,24 @@ function App() {
|
|
|
8625
9612
|
setPresentMode(false);
|
|
8626
9613
|
broadcastChrome({ present: false });
|
|
8627
9614
|
}, [broadcastChrome]);
|
|
9615
|
+
// feature-2-print-artboards T3 — per-canvas persisted, so (unlike
|
|
9616
|
+
// broadcastChrome above) this targets ONLY the active iframe and PATCHes
|
|
9617
|
+
// view.json's `overlays.print` through the same GET-merge/PATCH-split lane
|
|
9618
|
+
// the foundation built for `overlays.guides` (api.ts normalizeOverlays is a
|
|
9619
|
+
// flat Record<string, boolean> — no server change needed for a new key).
|
|
9620
|
+
const togglePrintGuides = useCallback(() => {
|
|
9621
|
+
if (!activePath || activePath === SYSTEM_TAB) return;
|
|
9622
|
+
setPrintGuidesVisible((v) => {
|
|
9623
|
+
const next = !v;
|
|
9624
|
+
postToActiveCanvas({ dgn: 'view-chrome', print: next });
|
|
9625
|
+
fetch('/_api/canvas-meta', {
|
|
9626
|
+
method: 'PATCH',
|
|
9627
|
+
headers: { 'Content-Type': 'application/json' },
|
|
9628
|
+
body: JSON.stringify({ file: activePath, patch: { overlays: { print: next } } }),
|
|
9629
|
+
}).catch(() => {});
|
|
9630
|
+
return next;
|
|
9631
|
+
});
|
|
9632
|
+
}, [activePath, postToActiveCanvas]);
|
|
8628
9633
|
|
|
8629
9634
|
// P3 (Plan C) — local git user for the menubar presence avatar. One-shot.
|
|
8630
9635
|
useEffect(() => {
|
|
@@ -8647,6 +9652,7 @@ function App() {
|
|
|
8647
9652
|
useEffect(() => {
|
|
8648
9653
|
if (!activePath || activePath === SYSTEM_TAB) {
|
|
8649
9654
|
setActiveArtboards(0);
|
|
9655
|
+
setPrintGuidesVisible(false);
|
|
8650
9656
|
return;
|
|
8651
9657
|
}
|
|
8652
9658
|
let cancelled = false;
|
|
@@ -8656,15 +9662,70 @@ function App() {
|
|
|
8656
9662
|
if (cancelled) return;
|
|
8657
9663
|
const n = Array.isArray(meta?.artboards) ? meta.artboards.length : 0;
|
|
8658
9664
|
setActiveArtboards(n);
|
|
9665
|
+
// feature-2-print-artboards T3 — reflect THIS canvas's own persisted
|
|
9666
|
+
// print-guides flag in the menu checkbox when switching tabs.
|
|
9667
|
+
setPrintGuidesVisible(meta?.overlays?.print === true);
|
|
9668
|
+
// feature-4 T7b — seed this canvas's persisted locked-layer keys
|
|
9669
|
+
// (view.json `locked`, per-user) into the Layers panel state.
|
|
9670
|
+
setLockedKeys(new Set(Array.isArray(meta?.locked) ? meta.locked : []));
|
|
8659
9671
|
})
|
|
8660
9672
|
.catch(() => {
|
|
8661
|
-
if (!cancelled)
|
|
9673
|
+
if (!cancelled) {
|
|
9674
|
+
setActiveArtboards(0);
|
|
9675
|
+
setPrintGuidesVisible(false);
|
|
9676
|
+
setLockedKeys(new Set());
|
|
9677
|
+
}
|
|
8662
9678
|
});
|
|
8663
9679
|
return () => {
|
|
8664
9680
|
cancelled = true;
|
|
8665
9681
|
};
|
|
8666
9682
|
}, [activePath]);
|
|
8667
9683
|
|
|
9684
|
+
// feature-4 dogfood fix (2026-07-19) — the Layers panel used to stay EMPTY
|
|
9685
|
+
// until something was selected (the tree only posted on selection). Request
|
|
9686
|
+
// the tree for the viewport-active artboard whenever it (or the canvas)
|
|
9687
|
+
// changes, so Layers work in browse mode with no selection at all. Small
|
|
9688
|
+
// debounce — the active-artboard signal can flicker during a fast pan.
|
|
9689
|
+
useEffect(() => {
|
|
9690
|
+
if (!activePath || activePath === SYSTEM_TAB || !canvasActiveArtboard) return;
|
|
9691
|
+
const t = setTimeout(() => {
|
|
9692
|
+
postToActiveCanvas({ dgn: 'request-layers', artboardId: canvasActiveArtboard });
|
|
9693
|
+
}, 250);
|
|
9694
|
+
return () => clearTimeout(t);
|
|
9695
|
+
}, [activePath, canvasActiveArtboard, postToActiveCanvas]);
|
|
9696
|
+
|
|
9697
|
+
// feature-4 T7b — locked layer keys (`"<cdId>:<index>"`, per-user, per-canvas
|
|
9698
|
+
// via view.json). Toggling PATCHes the FULL set (replace semantics) + pushes
|
|
9699
|
+
// the live set down to the canvas iframe so select/drag enforcement matches
|
|
9700
|
+
// the padlock instantly.
|
|
9701
|
+
const [lockedKeys, setLockedKeys] = useState(() => new Set());
|
|
9702
|
+
const toggleLockedKey = useCallback(
|
|
9703
|
+
(key) => {
|
|
9704
|
+
if (!activePath || activePath === SYSTEM_TAB) return;
|
|
9705
|
+
setLockedKeys((prev) => {
|
|
9706
|
+
const next = new Set(prev);
|
|
9707
|
+
if (next.has(key)) next.delete(key);
|
|
9708
|
+
else next.add(key);
|
|
9709
|
+
const arr = [...next];
|
|
9710
|
+
fetch('/_api/canvas-meta', {
|
|
9711
|
+
method: 'PATCH',
|
|
9712
|
+
headers: { 'Content-Type': 'application/json' },
|
|
9713
|
+
body: JSON.stringify({ file: activePath, patch: { locked: arr } }),
|
|
9714
|
+
}).catch(() => {});
|
|
9715
|
+
postToActiveCanvas({ dgn: 'locked-set', locked: arr });
|
|
9716
|
+
return next;
|
|
9717
|
+
});
|
|
9718
|
+
},
|
|
9719
|
+
[activePath, postToActiveCanvas]
|
|
9720
|
+
);
|
|
9721
|
+
// Push the seed to a (re)loaded canvas too — the iframe reads
|
|
9722
|
+
// `__canvas_meta__.locked` at boot, but a shell-side toggle after a reload
|
|
9723
|
+
// must still reach it; re-broadcast whenever the set or canvas changes.
|
|
9724
|
+
useEffect(() => {
|
|
9725
|
+
if (!activePath || activePath === SYSTEM_TAB) return;
|
|
9726
|
+
postToActiveCanvas({ dgn: 'locked-set', locked: [...lockedKeys] });
|
|
9727
|
+
}, [lockedKeys, activePath, postToActiveCanvas]);
|
|
9728
|
+
|
|
8668
9729
|
// Sync theme to <html data-theme> + localStorage on every change.
|
|
8669
9730
|
useEffect(() => {
|
|
8670
9731
|
try {
|
|
@@ -8900,6 +9961,18 @@ function App() {
|
|
|
8900
9961
|
// mount it lands after, so re-apply the halo from THIS edge too.
|
|
8901
9962
|
// Guarded to restore-transitions (prev missing / different id or
|
|
8902
9963
|
// file) so the select-set echo of our own re-select can't loop.
|
|
9964
|
+
//
|
|
9965
|
+
// feature-4 dogfood fix (2026-07-19) — the frame is ALSO the echo
|
|
9966
|
+
// of our OWN wsSend('select'): it round-trips the server and lands
|
|
9967
|
+
// hundreds of ms later, by which time the user may have drilled
|
|
9968
|
+
// deeper (dblclick) or multi-selected — the echo then OVERWROTE the
|
|
9969
|
+
// fresh state with the stale head and the halo-restore ladder
|
|
9970
|
+
// re-imposed it on the canvas ("multiselect snaps back to one",
|
|
9971
|
+
// "drill snaps back to the parent"). Suppress the restore path
|
|
9972
|
+
// entirely within a short window of any LOCAL selection send — a
|
|
9973
|
+
// genuine cross-canvas restore never follows a local select that
|
|
9974
|
+
// closely (it follows a canvas switch).
|
|
9975
|
+
if (Date.now() - lastLocalSelectAtRef.current < 2000) return;
|
|
8903
9976
|
const incoming = m.selected;
|
|
8904
9977
|
const one = Array.isArray(incoming) ? incoming[0] : incoming;
|
|
8905
9978
|
const prevSel = selectedRef.current;
|
|
@@ -9481,6 +10554,17 @@ function App() {
|
|
|
9481
10554
|
// only pick a known, always-visible glyph; it cannot inject an
|
|
9482
10555
|
// invisible/displaced SVG cursor as a clickjacking aid over the un-CSP'd
|
|
9483
10556
|
// shell (phase-24 ethical-hacker Finding 2; DDR-067).
|
|
10557
|
+
// feature-4 — the browse tool is a pure pass-through: don't force a
|
|
10558
|
+
// global shell cursor, so the shell chrome keeps its own affordance
|
|
10559
|
+
// cursors (pointer over buttons). Also fire the one-time "press V" hint
|
|
10560
|
+
// — a live canvas booting in browse is exactly when it's teachable.
|
|
10561
|
+
if (m.tool === 'browse') {
|
|
10562
|
+
document.body.style.cursor = '';
|
|
10563
|
+
const el = document.getElementById('dc-app-cursor');
|
|
10564
|
+
if (el) el.textContent = '';
|
|
10565
|
+
browseFirstRunHint();
|
|
10566
|
+
return;
|
|
10567
|
+
}
|
|
9484
10568
|
const cursor = resolveToolCursor(m.tool);
|
|
9485
10569
|
if (cursor) {
|
|
9486
10570
|
document.body.style.cursor = cursor;
|
|
@@ -9529,11 +10613,13 @@ function App() {
|
|
|
9529
10613
|
}
|
|
9530
10614
|
if (m.dgn === 'select' && m.selection) {
|
|
9531
10615
|
setPhotoSel(null); // a DOM selection supersedes an annotation-image Photo target
|
|
10616
|
+
lastLocalSelectAtRef.current = Date.now();
|
|
9532
10617
|
wsSend({ type: 'select', selection: m.selection });
|
|
9533
10618
|
setSelected(m.selection);
|
|
9534
10619
|
maybeAutoOpenInspectorOnSelect(m.selection); // Stage C
|
|
9535
10620
|
} else if (m.dgn === 'select-set') {
|
|
9536
10621
|
setPhotoSel(null);
|
|
10622
|
+
lastLocalSelectAtRef.current = Date.now();
|
|
9537
10623
|
// Canvas multi-select. Payload shape:
|
|
9538
10624
|
// null → empty selection
|
|
9539
10625
|
// Selection → length-1 (back-compat with legacy single-element shape)
|
|
@@ -9558,6 +10644,7 @@ function App() {
|
|
|
9558
10644
|
}
|
|
9559
10645
|
} else if (m.dgn === 'clear-select') {
|
|
9560
10646
|
setPhotoSel(null);
|
|
10647
|
+
lastLocalSelectAtRef.current = Date.now();
|
|
9561
10648
|
wsSend({ type: 'clear-select' });
|
|
9562
10649
|
setSelected(null);
|
|
9563
10650
|
} else if (m.dgn === 'edit-text' && m.id) {
|
|
@@ -9876,6 +10963,64 @@ function App() {
|
|
|
9876
10963
|
before: typeof m.before === 'string' ? m.before : null,
|
|
9877
10964
|
});
|
|
9878
10965
|
}
|
|
10966
|
+
} else if (m.dgn === 'convert-to-absolute-request') {
|
|
10967
|
+
// feature-4 T8 (convert-to-absolute, DDR-188) — the canvas measured the
|
|
10968
|
+
// frozen child boxes; perform the main-origin batch write (ONE undo
|
|
10969
|
+
// seq via structuralWrite). The server re-validates every field; here we
|
|
10970
|
+
// just confused-deputy-guard the source (DDR-054) + a light shape check.
|
|
10971
|
+
// Two shapes: single-container (element context menu) or `containers`
|
|
10972
|
+
// batch (the artboard-level "Convert layout to absolute").
|
|
10973
|
+
const activeWin = activePath ? iframesRef.current.get(activePath)?.contentWindow : null;
|
|
10974
|
+
const isBatch = Array.isArray(m.containers) && m.containers.length > 0;
|
|
10975
|
+
const isSingle =
|
|
10976
|
+
typeof m.containerId === 'string' && Array.isArray(m.children) && m.children.length > 0;
|
|
10977
|
+
if (e.source === activeWin && (isBatch || isSingle)) {
|
|
10978
|
+
const count = isBatch
|
|
10979
|
+
? m.containers.reduce(
|
|
10980
|
+
(n, c) => n + (Array.isArray(c?.children) ? c.children.length : 0),
|
|
10981
|
+
0
|
|
10982
|
+
)
|
|
10983
|
+
: m.children.length;
|
|
10984
|
+
structuralWriteRef.current?.(
|
|
10985
|
+
'/_api/convert-to-absolute',
|
|
10986
|
+
isBatch
|
|
10987
|
+
? {
|
|
10988
|
+
allowShared: m.allowShared === true,
|
|
10989
|
+
containers: m.containers,
|
|
10990
|
+
// feature-4 TRUE FLATTEN — unstyled layout wrappers to remove.
|
|
10991
|
+
...(Array.isArray(m.dissolve) && m.dissolve.length > 0
|
|
10992
|
+
? { dissolve: m.dissolve }
|
|
10993
|
+
: {}),
|
|
10994
|
+
}
|
|
10995
|
+
: {
|
|
10996
|
+
containerId: m.containerId,
|
|
10997
|
+
containerSetRelative: m.containerSetRelative === true,
|
|
10998
|
+
// feature-4 T8b — the canvas asked the user's confirm already;
|
|
10999
|
+
// the server still refuses `.map` children regardless.
|
|
11000
|
+
allowShared: m.allowShared === true,
|
|
11001
|
+
children: m.children,
|
|
11002
|
+
},
|
|
11003
|
+
{
|
|
11004
|
+
label: 'convert to absolute',
|
|
11005
|
+
// Dogfood 2026-07-19 — the conversion is zero-visual-delta by
|
|
11006
|
+
// design, which LOOKED like "nic nedělá". Say what happened, and
|
|
11007
|
+
// surface server refusals (.map children etc.) as a toast
|
|
11008
|
+
// instead of a console.warn nobody sees.
|
|
11009
|
+
onOk: () => {
|
|
11010
|
+
postToActiveCanvas({ dgn: 'selection-clear' });
|
|
11011
|
+
postToActiveCanvas({
|
|
11012
|
+
dgn: 'op-toast',
|
|
11013
|
+
message: `Converted ${count} element${count === 1 ? '' : 's'} to absolute — press V and drag them freely (⌘Z to undo).`,
|
|
11014
|
+
});
|
|
11015
|
+
},
|
|
11016
|
+
onFail: (j) =>
|
|
11017
|
+
postToActiveCanvas({
|
|
11018
|
+
dgn: 'op-toast',
|
|
11019
|
+
message: `Convert failed: ${j?.error || 'unknown error'}`,
|
|
11020
|
+
}),
|
|
11021
|
+
}
|
|
11022
|
+
);
|
|
11023
|
+
}
|
|
9879
11024
|
} else if (m.dgn === 'replace-annotation-media-request') {
|
|
9880
11025
|
// Stage F3 — "Replace…" on an annotation ImageStroke/MediaRefStroke.
|
|
9881
11026
|
// Opens the SAME AssetPicker; unlike F2 the pick is posted BACK DOWN to
|
|
@@ -9945,6 +11090,25 @@ function App() {
|
|
|
9945
11090
|
if (e.source === activeWin && typeof m.artboardId === 'string') {
|
|
9946
11091
|
deleteArtboardShellRef.current?.(m.artboardId);
|
|
9947
11092
|
}
|
|
11093
|
+
} else if (m.dgn === 'set-artboard-kind-request') {
|
|
11094
|
+
// feature-1-artboard-kinds-foundation, T8 — context-menu "Artboard
|
|
11095
|
+
// kind" submenu (inside the untrusted iframe). `kind: null` clears
|
|
11096
|
+
// back to the implicit default.
|
|
11097
|
+
const activeWin = activePath ? iframesRef.current.get(activePath)?.contentWindow : null;
|
|
11098
|
+
const okKind = m.kind === null || typeof m.kind === 'string';
|
|
11099
|
+
if (e.source === activeWin && typeof m.artboardId === 'string' && okKind) {
|
|
11100
|
+
// Dogfood round 6 — DIRECT write, never back into the ask flow (the
|
|
11101
|
+
// ask lives shell-side in setArtboardKindShell; re-entering it from
|
|
11102
|
+
// this canvas round-trip looped the confirm dialog forever).
|
|
11103
|
+
directArtboardKindWriteRef.current?.(m.artboardId, m.kind);
|
|
11104
|
+
}
|
|
11105
|
+
} else if (m.dgn === 'duplicate-artboard-request') {
|
|
11106
|
+
// feature-3-web-artboards T3 — context-menu "Duplicate at width…"
|
|
11107
|
+
// submenu (inside the untrusted iframe).
|
|
11108
|
+
const activeWin = activePath ? iframesRef.current.get(activePath)?.contentWindow : null;
|
|
11109
|
+
if (e.source === activeWin && typeof m.artboardId === 'string' && Number.isFinite(m.width)) {
|
|
11110
|
+
duplicateArtboardShellRef.current?.(m.artboardId, m.width);
|
|
11111
|
+
}
|
|
9948
11112
|
} else if (m.dgn === 'open-inspector') {
|
|
9949
11113
|
// Phase 12 — context-menu "Inspect" / tool-palette Inspect opens the right panel.
|
|
9950
11114
|
// feature-photo-editor (Task 16) — an optional `tab` lands the panel on a
|
|
@@ -10502,8 +11666,10 @@ function App() {
|
|
|
10502
11666
|
// usage (local), not the shared inner definition. undefined for a plain element.
|
|
10503
11667
|
const occ = Number.isInteger(idIndex) ? idIndex : undefined;
|
|
10504
11668
|
// `transform` rides the same lane for the rotate handle (Task L8);
|
|
10505
|
-
// `padding-*`/`gap` ride it for the on-canvas spacing drag (Stage J)
|
|
10506
|
-
//
|
|
11669
|
+
// `padding-*`/`gap` ride it for the on-canvas spacing drag (Stage J);
|
|
11670
|
+
// `grid-template-columns`/`grid-template-rows` ride it for the on-canvas
|
|
11671
|
+
// grid gutter drag (feature-3-web-artboards T5) — same single-prop
|
|
11672
|
+
// /_api/edit-css write + per-prop undo record, no new lane.
|
|
10507
11673
|
const props = [
|
|
10508
11674
|
'width',
|
|
10509
11675
|
'height',
|
|
@@ -10515,6 +11681,8 @@ function App() {
|
|
|
10515
11681
|
'padding-bottom',
|
|
10516
11682
|
'padding-left',
|
|
10517
11683
|
'gap',
|
|
11684
|
+
'grid-template-columns',
|
|
11685
|
+
'grid-template-rows',
|
|
10518
11686
|
].filter((p) => typeof patch[p] === 'string' && patch[p]);
|
|
10519
11687
|
if (!props.length) return;
|
|
10520
11688
|
// Dogfood 2026-07-07 — "resize paddingu nebo gap" was still deselecting on
|
|
@@ -10693,6 +11861,34 @@ function App() {
|
|
|
10693
11861
|
[activePath, canvasActiveArtboard, insertElementShell]
|
|
10694
11862
|
);
|
|
10695
11863
|
|
|
11864
|
+
// feature-4 detach-component (2026-07-19) — clone the definition + repoint
|
|
11865
|
+
// this usage so edits stay local to this instance. Rides structuralWrite
|
|
11866
|
+
// (one undo seq); the id churn after HMR invalidates the selection → clear.
|
|
11867
|
+
const detachInstanceShell = useCallback(
|
|
11868
|
+
(id, idIndex) => {
|
|
11869
|
+
structuralWrite(
|
|
11870
|
+
'/_api/detach-component',
|
|
11871
|
+
{ id, idIndex: Number.isInteger(idIndex) ? idIndex : undefined },
|
|
11872
|
+
{
|
|
11873
|
+
label: 'detach instance',
|
|
11874
|
+
onOk: (j) => {
|
|
11875
|
+
postToActiveCanvas({ dgn: 'selection-clear' });
|
|
11876
|
+
postToActiveCanvas({
|
|
11877
|
+
dgn: 'op-toast',
|
|
11878
|
+
message: `Detached — this instance is now ${j.detachedName || 'its own component'}; edits stay local (⌘Z to undo).`,
|
|
11879
|
+
});
|
|
11880
|
+
},
|
|
11881
|
+
onFail: (j) =>
|
|
11882
|
+
postToActiveCanvas({
|
|
11883
|
+
dgn: 'op-toast',
|
|
11884
|
+
message: `Detach failed: ${j?.error || 'unknown error'}`,
|
|
11885
|
+
}),
|
|
11886
|
+
}
|
|
11887
|
+
);
|
|
11888
|
+
},
|
|
11889
|
+
[structuralWrite, postToActiveCanvas]
|
|
11890
|
+
);
|
|
11891
|
+
|
|
10696
11892
|
const duplicateElementShell = useCallback(
|
|
10697
11893
|
(id, idIndex) => {
|
|
10698
11894
|
structuralWrite(
|
|
@@ -10793,20 +11989,6 @@ function App() {
|
|
|
10793
11989
|
[structuralWrite]
|
|
10794
11990
|
);
|
|
10795
11991
|
|
|
10796
|
-
// Stage I4 — "New artboard: <preset>" from the Edit menu. Generates a unique
|
|
10797
|
-
// id (server 422s a dup, negligible with a random suffix) + a preset label/dims
|
|
10798
|
-
// and inserts an empty <DCArtboard> after the last one (runtime default-grid
|
|
10799
|
-
// places it; DDR-027). The new frame is then selectable + resizable.
|
|
10800
|
-
const onInsertArtboard = useCallback(
|
|
10801
|
-
(presetKey) => {
|
|
10802
|
-
const p = SCREEN_PRESETS[presetKey];
|
|
10803
|
-
if (!p) return;
|
|
10804
|
-
const id = `s${Math.random().toString(36).slice(2, 8)}`;
|
|
10805
|
-
insertArtboardShell({ id, label: p.label, width: p.width, height: p.height });
|
|
10806
|
-
},
|
|
10807
|
-
[insertArtboardShell]
|
|
10808
|
-
);
|
|
10809
|
-
|
|
10810
11992
|
const resizeArtboardShell = useCallback(
|
|
10811
11993
|
(artboardId, width, height) => {
|
|
10812
11994
|
structuralWrite(
|
|
@@ -10818,10 +12000,14 @@ function App() {
|
|
|
10818
12000
|
// during the drag (no HMR yet); on a rejected/failed write, tell it to
|
|
10819
12001
|
// restore the pre-drag box so a phantom resize doesn't linger.
|
|
10820
12002
|
onFail: () => postToActiveCanvas({ dgn: 'resize-artboard-failed' }),
|
|
12003
|
+
// Dogfood follow-up — resync the Inspector's own W/H (and any other
|
|
12004
|
+
// attrs) once the HMR reload lands, so a paper-preset pick doesn't
|
|
12005
|
+
// leave the fields showing the pre-resize size.
|
|
12006
|
+
onOk: () => scheduleArtboardResync(artboardId, activePath),
|
|
10821
12007
|
}
|
|
10822
12008
|
);
|
|
10823
12009
|
},
|
|
10824
|
-
[structuralWrite, postToActiveCanvas]
|
|
12010
|
+
[structuralWrite, postToActiveCanvas, scheduleArtboardResync, activePath]
|
|
10825
12011
|
);
|
|
10826
12012
|
|
|
10827
12013
|
const deleteArtboardShell = useCallback(
|
|
@@ -10831,6 +12017,20 @@ function App() {
|
|
|
10831
12017
|
[structuralWrite]
|
|
10832
12018
|
);
|
|
10833
12019
|
|
|
12020
|
+
// feature-3-web-artboards T3 — "Duplicate at width…". No onOk auto-select
|
|
12021
|
+
// (matches insertArtboardShell's own precedent above — the new frame lands
|
|
12022
|
+
// selectable, not auto-selected).
|
|
12023
|
+
const duplicateArtboardShell = useCallback(
|
|
12024
|
+
(artboardId, width) => {
|
|
12025
|
+
structuralWrite(
|
|
12026
|
+
'/_api/duplicate-artboard',
|
|
12027
|
+
{ artboardId, width },
|
|
12028
|
+
{ label: 'duplicate artboard at width' }
|
|
12029
|
+
);
|
|
12030
|
+
},
|
|
12031
|
+
[structuralWrite]
|
|
12032
|
+
);
|
|
12033
|
+
|
|
10834
12034
|
// Artboard "hug height" default — Hug ⇄ Fixed toggle from ArtboardKnobs
|
|
10835
12035
|
// (CSS panel). Direct shell-side action (no canvas postMessage round trip,
|
|
10836
12036
|
// unlike resizeArtboardShell's drag-overlay caller), so no *ShellRef needed.
|
|
@@ -10839,18 +12039,147 @@ function App() {
|
|
|
10839
12039
|
structuralWrite(
|
|
10840
12040
|
'/_api/set-artboard-hug',
|
|
10841
12041
|
{ artboardId, fixed, freezeHeight },
|
|
10842
|
-
{
|
|
12042
|
+
{
|
|
12043
|
+
label: fixed ? 'pin artboard height' : 'hug artboard height',
|
|
12044
|
+
onOk: () => scheduleArtboardResync(artboardId, activePath),
|
|
12045
|
+
}
|
|
10843
12046
|
);
|
|
10844
12047
|
},
|
|
10845
|
-
[structuralWrite]
|
|
12048
|
+
[structuralWrite, scheduleArtboardResync, activePath]
|
|
10846
12049
|
);
|
|
10847
12050
|
|
|
10848
12051
|
// Artboard "more settings" (background/padding/layout/gap) from ArtboardKnobs.
|
|
10849
12052
|
const setArtboardStyleShell = useCallback(
|
|
10850
12053
|
(artboardId, patch) => {
|
|
10851
|
-
structuralWrite('/_api/set-artboard-style', { artboardId, ...patch }, {
|
|
12054
|
+
structuralWrite('/_api/set-artboard-style', { artboardId, ...patch }, {
|
|
12055
|
+
label: 'artboard style',
|
|
12056
|
+
onOk: () => scheduleArtboardResync(artboardId, activePath),
|
|
12057
|
+
});
|
|
10852
12058
|
},
|
|
10853
|
-
[structuralWrite]
|
|
12059
|
+
[structuralWrite, scheduleArtboardResync, activePath]
|
|
12060
|
+
);
|
|
12061
|
+
// feature-1-artboard-kinds-foundation, T8 — kind-switch surfaces. Direct
|
|
12062
|
+
// callable from ArtboardKnobs (Inspector); the context-menu submenu (inside
|
|
12063
|
+
// the iframe) reaches the SAME endpoint via the postMessage ref below.
|
|
12064
|
+
//
|
|
12065
|
+
// Dogfood follow-up — every one of these ArtboardKnobs writers now resyncs
|
|
12066
|
+
// the Inspector's OWN selection snapshot once the HMR reload lands
|
|
12067
|
+
// (scheduleArtboardResync's retry ladder). Before this fix the ARTBOARD
|
|
12068
|
+
// re-rendered correctly (new kind, new paper size) but the Inspector kept
|
|
12069
|
+
// showing PRE-edit attrs (e.g. "Digital" right after picking "Print",
|
|
12070
|
+
// "Preset size…" still listing screen presets) until the user manually
|
|
12071
|
+
// re-clicked the artboard — structuralWrite's onOk never refreshed
|
|
12072
|
+
// `selected`, only recorded undo history.
|
|
12073
|
+
// feature-4 dogfood round 6 — the DIRECT kind write (+ the Inspector's
|
|
12074
|
+
// print-seed, sequenced AFTER it on the same structuralWrite chain, so a
|
|
12075
|
+
// freeze-convert enqueued just before lands FIRST and the A4 resize can't
|
|
12076
|
+
// move anything pre-freeze). This is what the canvas's
|
|
12077
|
+
// `set-artboard-kind-request` calls — it must NEVER re-enter the ask flow
|
|
12078
|
+
// below (that round-trip was an infinite confirm loop: every "Switch +
|
|
12079
|
+
// freeze" click spawned the next dialog).
|
|
12080
|
+
const pendingPrintSeedRef = useRef(null);
|
|
12081
|
+
const directArtboardKindWrite = useCallback(
|
|
12082
|
+
(artboardId, kind) => {
|
|
12083
|
+
structuralWrite('/_api/set-artboard-kind', { artboardId, kind }, {
|
|
12084
|
+
label: 'artboard kind',
|
|
12085
|
+
onOk: () => scheduleArtboardResync(artboardId, activePath),
|
|
12086
|
+
});
|
|
12087
|
+
const seed = pendingPrintSeedRef.current;
|
|
12088
|
+
if (kind === 'print' && seed && seed.artboardId === artboardId) {
|
|
12089
|
+
pendingPrintSeedRef.current = null;
|
|
12090
|
+
// Same chain → serialized after the kind write (and after any freeze
|
|
12091
|
+
// convert the canvas enqueued before it).
|
|
12092
|
+
structuralWrite(
|
|
12093
|
+
'/_api/resize-artboard',
|
|
12094
|
+
{ artboardId, width: seed.widthPx, height: seed.heightPx },
|
|
12095
|
+
{ label: 'artboard size' }
|
|
12096
|
+
);
|
|
12097
|
+
structuralWrite(
|
|
12098
|
+
'/_api/set-artboard-print',
|
|
12099
|
+
{ artboardId, print: seed.defaults },
|
|
12100
|
+
{ label: 'artboard print' }
|
|
12101
|
+
);
|
|
12102
|
+
}
|
|
12103
|
+
},
|
|
12104
|
+
[structuralWrite, scheduleArtboardResync, activePath]
|
|
12105
|
+
);
|
|
12106
|
+
const directArtboardKindWriteRef = useRef(null);
|
|
12107
|
+
useEffect(() => {
|
|
12108
|
+
directArtboardKindWriteRef.current = directArtboardKindWrite;
|
|
12109
|
+
}, [directArtboardKindWrite]);
|
|
12110
|
+
|
|
12111
|
+
const setArtboardKindShell = useCallback(
|
|
12112
|
+
(artboardId, kind, seedPrint) => {
|
|
12113
|
+
// feature-4 (user steer 2026-07-20) — Digital & Print are the freely-
|
|
12114
|
+
// composed marketing kinds: switching TO them from the Inspector offers
|
|
12115
|
+
// the freeze-and-flatten convert first. The confirm runs IN THE CANVAS
|
|
12116
|
+
// (canvasConfirm — the shell's window.confirm is a silent no-op in the
|
|
12117
|
+
// Tauri WKWebView); the canvas then re-posts `set-artboard-kind-request`,
|
|
12118
|
+
// which lands on the DIRECT writer above (no re-entry → no dialog loop).
|
|
12119
|
+
if (kind === 'print' || kind == null || kind === 'digital') {
|
|
12120
|
+
if (seedPrint) pendingPrintSeedRef.current = { artboardId, ...seedPrint };
|
|
12121
|
+
postToActiveCanvas({
|
|
12122
|
+
dgn: 'freeze-and-set-kind',
|
|
12123
|
+
artboardId,
|
|
12124
|
+
kind: kind === 'digital' ? null : kind,
|
|
12125
|
+
ask: true,
|
|
12126
|
+
});
|
|
12127
|
+
return;
|
|
12128
|
+
}
|
|
12129
|
+
directArtboardKindWrite(artboardId, kind);
|
|
12130
|
+
},
|
|
12131
|
+
[directArtboardKindWrite, postToActiveCanvas]
|
|
12132
|
+
);
|
|
12133
|
+
// feature-2-print-artboards T2 — paper/orientation/bleed/margins. Direct
|
|
12134
|
+
// Inspector-only callable (no canvas-origin postMessage path — same shape
|
|
12135
|
+
// as setArtboardStyleShell, unlike setArtboardKindShell which also has a
|
|
12136
|
+
// context-menu caller inside the iframe).
|
|
12137
|
+
const setArtboardPrintShell = useCallback(
|
|
12138
|
+
(artboardId, print) => {
|
|
12139
|
+
structuralWrite('/_api/set-artboard-print', { artboardId, print }, {
|
|
12140
|
+
label: 'artboard print',
|
|
12141
|
+
onOk: () => scheduleArtboardResync(artboardId, activePath),
|
|
12142
|
+
});
|
|
12143
|
+
},
|
|
12144
|
+
[structuralWrite, scheduleArtboardResync, activePath]
|
|
12145
|
+
);
|
|
12146
|
+
|
|
12147
|
+
// Stage I4 — "New artboard: <preset>" from the Edit menu. Generates a unique
|
|
12148
|
+
// id (server 422s a dup, negligible with a random suffix) + a preset label/dims
|
|
12149
|
+
// and inserts an empty <DCArtboard> after the last one (runtime default-grid
|
|
12150
|
+
// places it; DDR-027). The new frame is then selectable + resizable.
|
|
12151
|
+
const onInsertArtboard = useCallback(
|
|
12152
|
+
(presetKey) => {
|
|
12153
|
+
const id = `s${Math.random().toString(36).slice(2, 8)}`;
|
|
12154
|
+
// feature-2-print-artboards T2 — kind="print" + the print prop must
|
|
12155
|
+
// land together, so the created artboard is never a plain digital
|
|
12156
|
+
// board sized to paper by coincidence. structuralWrite serializes
|
|
12157
|
+
// calls in submission order (editApplyChainRef), so these three
|
|
12158
|
+
// sequential POSTs land as insert → kind → print, in order.
|
|
12159
|
+
if (presetKey.startsWith('print-')) {
|
|
12160
|
+
const paper = presetKey.slice('print-'.length);
|
|
12161
|
+
let resolved;
|
|
12162
|
+
try {
|
|
12163
|
+
resolved = resolvePrintArtboard({ paper });
|
|
12164
|
+
} catch {
|
|
12165
|
+
return;
|
|
12166
|
+
}
|
|
12167
|
+
const preset = PAPER_PRESETS.find((p) => p.id === paper);
|
|
12168
|
+
insertArtboardShell({
|
|
12169
|
+
id,
|
|
12170
|
+
label: preset ? preset.label : paper.toUpperCase(),
|
|
12171
|
+
width: resolved.widthPx,
|
|
12172
|
+
height: resolved.heightPx,
|
|
12173
|
+
});
|
|
12174
|
+
setArtboardKindShell(id, 'print');
|
|
12175
|
+
setArtboardPrintShell(id, { paper });
|
|
12176
|
+
return;
|
|
12177
|
+
}
|
|
12178
|
+
const p = SCREEN_PRESETS[presetKey];
|
|
12179
|
+
if (!p) return;
|
|
12180
|
+
insertArtboardShell({ id, label: p.label, width: p.width, height: p.height });
|
|
12181
|
+
},
|
|
12182
|
+
[insertArtboardShell, setArtboardKindShell, setArtboardPrintShell]
|
|
10854
12183
|
);
|
|
10855
12184
|
// Refs the (stale-closure) onMessage handlers below read.
|
|
10856
12185
|
const deleteElementShellRef = useRef(null);
|
|
@@ -10858,7 +12187,9 @@ function App() {
|
|
|
10858
12187
|
const insertArtboardShellRef = useRef(null);
|
|
10859
12188
|
const resizeArtboardShellRef = useRef(null);
|
|
10860
12189
|
const deleteArtboardShellRef = useRef(null);
|
|
12190
|
+
const setArtboardKindShellRef = useRef(null);
|
|
10861
12191
|
const duplicateElementShellRef = useRef(null);
|
|
12192
|
+
const duplicateArtboardShellRef = useRef(null);
|
|
10862
12193
|
const copyStyleRef = useRef(null);
|
|
10863
12194
|
const pasteStyleRef = useRef(null);
|
|
10864
12195
|
useEffect(() => {
|
|
@@ -10867,7 +12198,9 @@ function App() {
|
|
|
10867
12198
|
insertArtboardShellRef.current = insertArtboardShell;
|
|
10868
12199
|
resizeArtboardShellRef.current = resizeArtboardShell;
|
|
10869
12200
|
deleteArtboardShellRef.current = deleteArtboardShell;
|
|
12201
|
+
setArtboardKindShellRef.current = setArtboardKindShell;
|
|
10870
12202
|
duplicateElementShellRef.current = duplicateElementShell;
|
|
12203
|
+
duplicateArtboardShellRef.current = duplicateArtboardShell;
|
|
10871
12204
|
copyStyleRef.current = copyStyle;
|
|
10872
12205
|
pasteStyleRef.current = pasteStyle;
|
|
10873
12206
|
}, [
|
|
@@ -10876,7 +12209,9 @@ function App() {
|
|
|
10876
12209
|
insertArtboardShell,
|
|
10877
12210
|
resizeArtboardShell,
|
|
10878
12211
|
deleteArtboardShell,
|
|
12212
|
+
setArtboardKindShell,
|
|
10879
12213
|
duplicateElementShell,
|
|
12214
|
+
duplicateArtboardShell,
|
|
10880
12215
|
copyStyle,
|
|
10881
12216
|
pasteStyle,
|
|
10882
12217
|
]);
|
|
@@ -11624,6 +12959,9 @@ function App() {
|
|
|
11624
12959
|
onResizeArtboard={resizeArtboardShell}
|
|
11625
12960
|
onSetArtboardHug={setArtboardHugShell}
|
|
11626
12961
|
onSetArtboardStyle={setArtboardStyleShell}
|
|
12962
|
+
onSetArtboardKind={setArtboardKindShell}
|
|
12963
|
+
onSetArtboardPrint={setArtboardPrintShell}
|
|
12964
|
+
onDuplicateArtboard={duplicateArtboardShell}
|
|
11627
12965
|
editScope={editScope}
|
|
11628
12966
|
onUndoRedo={(dir) => postToActiveCanvas({ dgn: dir })}
|
|
11629
12967
|
photoSel={photoSel}
|
|
@@ -11633,6 +12971,10 @@ function App() {
|
|
|
11633
12971
|
onPhotoRecordEdit={onPhotoRecordEdit}
|
|
11634
12972
|
onPhotoUndoRedo={(dir) => performPhotoUndo(dir === 'redo')}
|
|
11635
12973
|
layersTree={layersTree}
|
|
12974
|
+
componentMap={componentMap}
|
|
12975
|
+
lockedKeys={lockedKeys}
|
|
12976
|
+
onToggleLock={toggleLockedKey}
|
|
12977
|
+
onDetachInstance={detachInstanceShell}
|
|
11636
12978
|
canvasFile={activePath}
|
|
11637
12979
|
onSelectLayer={(n) =>
|
|
11638
12980
|
postToActiveCanvas({
|
|
@@ -11733,6 +13075,8 @@ function App() {
|
|
|
11733
13075
|
onToggleZoomCtl={toggleZoomCtl}
|
|
11734
13076
|
presentMode={presentMode}
|
|
11735
13077
|
onTogglePresent={togglePresent}
|
|
13078
|
+
printGuidesVisible={printGuidesVisible}
|
|
13079
|
+
onTogglePrintGuides={togglePrintGuides}
|
|
11736
13080
|
postToActiveCanvas={postToActiveCanvas}
|
|
11737
13081
|
onOpenReadiness={() => setReadinessOpen(true)}
|
|
11738
13082
|
onOpenQuickSetup={() => setQuickSetupOpen(true)}
|
|
@@ -11829,6 +13173,8 @@ function App() {
|
|
|
11829
13173
|
onClose={() => setAssistantOpen(false)}
|
|
11830
13174
|
onBusyChange={setAssistantBusy}
|
|
11831
13175
|
onFinished={handleAssistantFinished}
|
|
13176
|
+
onPermissionRequest={handleAssistantAttention}
|
|
13177
|
+
onElicitationRequest={handleAssistantAttention}
|
|
11832
13178
|
/>
|
|
11833
13179
|
)}
|
|
11834
13180
|
{leftActive && leftActive !== 'assistant' && renderPanelBody(leftActive)}
|
|
@@ -11897,6 +13243,8 @@ function App() {
|
|
|
11897
13243
|
onClose={() => setAssistantOpen(false)}
|
|
11898
13244
|
onBusyChange={setAssistantBusy}
|
|
11899
13245
|
onFinished={handleAssistantFinished}
|
|
13246
|
+
onPermissionRequest={handleAssistantAttention}
|
|
13247
|
+
onElicitationRequest={handleAssistantAttention}
|
|
11900
13248
|
/>
|
|
11901
13249
|
)}
|
|
11902
13250
|
{rightActive && rightActive !== 'assistant' && renderPanelBody(rightActive)}
|