@1agh/maude 0.46.0 → 0.48.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +7 -6
- package/apps/studio/acp/bootstrap-brief.ts +8 -0
- package/apps/studio/acp/bridge.ts +121 -6
- package/apps/studio/acp/plugin-bootstrap.ts +15 -1
- package/apps/studio/annotations-layer.tsx +42 -0
- package/apps/studio/api.ts +417 -2
- package/apps/studio/bin/_agent-browser-safe-config.json +1 -0
- package/apps/studio/bin/_agent-browser-safe.mjs +228 -0
- package/apps/studio/bin/_agent-browser-safe.test.mjs +165 -0
- package/apps/studio/bin/_curl-local.mjs +349 -0
- package/apps/studio/bin/_curl-local.test.mjs +280 -0
- package/apps/studio/bin/agent-browser-safe.sh +29 -0
- package/apps/studio/bin/curl-local.sh +28 -0
- package/apps/studio/build.ts +1 -1
- package/apps/studio/canvas-cursors.ts +6 -0
- package/apps/studio/canvas-edit.ts +678 -11
- package/apps/studio/canvas-icons.tsx +13 -0
- package/apps/studio/canvas-lib.tsx +3 -0
- package/apps/studio/canvas-shell.tsx +646 -26
- package/apps/studio/client/app.jsx +898 -52
- package/apps/studio/client/export-center.jsx +7 -6
- package/apps/studio/client/github.js +11 -4
- package/apps/studio/client/panels/ChatPanel.jsx +47 -2
- package/apps/studio/client/styles/3-shell-maude.css +39 -0
- package/apps/studio/contextual-toolbar.tsx +5 -3
- package/apps/studio/dist/client.bundle.js +1103 -1103
- package/apps/studio/dist/comment-mount.js +2 -2
- package/apps/studio/dist/runtime/REMOTION-LICENSE.md +1 -1
- package/apps/studio/dist/styles.css +1 -1
- package/apps/studio/examples/perf-100-artboards.tsx +1 -1
- package/apps/studio/exporters/pdf.ts +35 -1
- package/apps/studio/git/service.ts +4 -1
- package/apps/studio/grid-track-handles.ts +179 -0
- package/apps/studio/handoff.ts +35 -0
- package/apps/studio/http.ts +118 -0
- package/apps/studio/input-router.tsx +73 -17
- package/apps/studio/paths.ts +37 -1
- package/apps/studio/test/acp-plugin-bootstrap.test.ts +34 -0
- package/apps/studio/test/acp-session-allowed-tools.test.ts +107 -11
- package/apps/studio/test/acp-session-plugins.test.ts +6 -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 +70 -0
- package/apps/studio/test/canvas-origin-gate.test.ts +4 -0
- package/apps/studio/test/comment-mount.test.ts +2 -1
- package/apps/studio/test/component-map.test.ts +48 -0
- package/apps/studio/test/convert-to-absolute.test.ts +333 -0
- package/apps/studio/test/detach-component.test.ts +94 -0
- package/apps/studio/test/edit-scope-api.test.ts +8 -4
- package/apps/studio/test/element-structural-api.test.ts +74 -0
- package/apps/studio/test/element-structural-edit.test.ts +113 -0
- package/apps/studio/test/exporters/pdf.test.ts +33 -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 +54 -0
- package/apps/studio/test/use-tool-mode.test.tsx +10 -2
- package/apps/studio/tool-palette.tsx +3 -1
- package/apps/studio/use-canvas-media-drop.tsx +126 -0
- package/apps/studio/use-element-resize.tsx +3 -1
- package/apps/studio/use-grid-track-handles.tsx +364 -0
- package/apps/studio/use-keyboard-discipline.tsx +15 -0
- package/apps/studio/use-tool-mode.tsx +30 -3
- package/apps/studio/web-overlay-content.tsx +52 -0
- package/apps/studio/whats-new.json +27 -0
- package/cli/bin/maude.mjs +1 -0
- package/cli/commands/design.mjs +16 -0
- package/cli/commands/init.mjs +80 -3
- package/cli/commands/kg.mjs +368 -0
- package/cli/commands/kg.test.mjs +118 -0
- package/cli/lib/ddr-to-kgai.mjs +648 -0
- package/cli/lib/ddr-to-kgai.test.mjs +99 -0
- package/cli/lib/flow-design-integration.test.mjs +2 -2
- package/cli/lib/gitignore-block.mjs +4 -0
- package/cli/lib/plugin-name-namespace.test.mjs +71 -0
- package/package.json +8 -8
- package/plugins/design/dependencies.json +17 -0
- package/plugins/design/templates/_shell.html +4 -0
- package/plugins/flow/.claude-plugin/config.schema.json +66 -0
- package/plugins/flow/dependencies.json +17 -0
|
@@ -12,6 +12,10 @@ 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';
|
|
15
19
|
// feature-2-print-artboards T2 — the single source (print/units.ts) for the
|
|
16
20
|
// Inspector's paper-preset picker, same "pull only pure math/data" shape as
|
|
17
21
|
// canvas-cursors.ts above.
|
|
@@ -99,6 +103,11 @@ const MINIMAP_STORE = 'mdcc-minimap-visible';
|
|
|
99
103
|
const ZOOMCTL_STORE = 'mdcc-zoomctl-visible';
|
|
100
104
|
const ANNOT_STORE = 'mdcc-annotations-visible';
|
|
101
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;
|
|
102
111
|
|
|
103
112
|
// feature-unified-settings-modal — the Settings modal's view prefs are ALSO
|
|
104
113
|
// persisted to disk (~/.config/maude/prefs.json via /_api/ui-prefs), so they
|
|
@@ -173,6 +182,20 @@ const CANVAS_EXT_RE = /\.(tsx|html?)$/i;
|
|
|
173
182
|
// the bundle is consumed in a context that hasn't run the build.
|
|
174
183
|
const MDCC_VERSION = typeof __MDCC_VERSION__ !== 'undefined' ? __MDCC_VERSION__ : 'dev';
|
|
175
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
|
+
|
|
176
199
|
function readInitialTheme() {
|
|
177
200
|
if (typeof window === 'undefined') return 'dark';
|
|
178
201
|
try {
|
|
@@ -252,6 +275,58 @@ function shellToast(message, ok = false) {
|
|
|
252
275
|
}, 3200);
|
|
253
276
|
}
|
|
254
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
|
+
|
|
255
330
|
// Strip canvas extensions for display. `Canvas Viewport.tsx` → `Canvas Viewport`.
|
|
256
331
|
// Sidecars (`.meta.json`, `.css`, `.registry.json`) keep their extensions so
|
|
257
332
|
// the file type stays unambiguous.
|
|
@@ -431,6 +506,19 @@ const STICONS = {
|
|
|
431
506
|
</>
|
|
432
507
|
),
|
|
433
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
|
+
),
|
|
434
522
|
search: (
|
|
435
523
|
<>
|
|
436
524
|
<circle cx="7" cy="7" r="4" />
|
|
@@ -3031,7 +3119,10 @@ function ToolsDropdown({ onAction, onClose }) {
|
|
|
3031
3119
|
onAction={onAction}
|
|
3032
3120
|
onClose={onClose}
|
|
3033
3121
|
items={[
|
|
3034
|
-
|
|
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' },
|
|
3035
3126
|
{ id: 'hand', label: 'Hand', shortcut: 'H' },
|
|
3036
3127
|
{ id: 'comment', label: 'Comment', shortcut: 'C' },
|
|
3037
3128
|
{ id: 'pen', label: 'Pen', shortcut: 'B' },
|
|
@@ -4323,6 +4414,9 @@ function SyncBanner({ status }) {
|
|
|
4323
4414
|
// critic-approved + user-iterated `.design/ui/Studio.tsx` spec.
|
|
4324
4415
|
|
|
4325
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'];
|
|
4326
4420
|
const CSS_FLEX_DIR = ['row', 'row-reverse', 'column', 'column-reverse'];
|
|
4327
4421
|
const CSS_FLEX_WRAP = ['nowrap', 'wrap', 'wrap-reverse'];
|
|
4328
4422
|
const CSS_ALIGN = ['stretch', 'flex-start', 'center', 'flex-end', 'baseline'];
|
|
@@ -5129,6 +5223,98 @@ function TokenPopover({ kind, groups, current, onPick, label, swatchBg, seedHex,
|
|
|
5129
5223
|
);
|
|
5130
5224
|
}
|
|
5131
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
|
+
|
|
5132
5318
|
function CssKnobs({ el, cfg, onOptimistic, onRecordEdit, onReplaceMedia, onUndoRedo, mode, onSetMode }) {
|
|
5133
5319
|
const editable = !!el.id;
|
|
5134
5320
|
const computed = el.computed || {};
|
|
@@ -5475,6 +5661,10 @@ function CssKnobs({ el, cfg, onOptimistic, onRecordEdit, onReplaceMedia, onUndoR
|
|
|
5475
5661
|
// Props each section owns — drives the per-section "reset section" affordance.
|
|
5476
5662
|
const SECTION_PROPS = {
|
|
5477
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'],
|
|
5478
5668
|
// feature-element-editing-robustness Stage B — promoted DDR-104 OUT-list.
|
|
5479
5669
|
Position: ['position', 'top', 'right', 'bottom', 'left', 'z-index'],
|
|
5480
5670
|
Typography: [
|
|
@@ -6372,6 +6562,54 @@ function CssKnobs({ el, cfg, onOptimistic, onRecordEdit, onReplaceMedia, onUndoR
|
|
|
6372
6562
|
})()
|
|
6373
6563
|
)}
|
|
6374
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
|
+
|
|
6375
6613
|
{sec(
|
|
6376
6614
|
'Position',
|
|
6377
6615
|
<>
|
|
@@ -6395,6 +6633,18 @@ function CssKnobs({ el, cfg, onOptimistic, onRecordEdit, onReplaceMedia, onUndoR
|
|
|
6395
6633
|
</>
|
|
6396
6634
|
)}
|
|
6397
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
|
+
|
|
6398
6648
|
{sec(
|
|
6399
6649
|
'Typography',
|
|
6400
6650
|
<>
|
|
@@ -6707,6 +6957,8 @@ const LAYER_TYPE_ICON = {
|
|
|
6707
6957
|
list: 'list',
|
|
6708
6958
|
nav: 'layers',
|
|
6709
6959
|
box: 'box',
|
|
6960
|
+
// feature-4 T7 — synthetic group row (unstamped wrapper with stamped kids).
|
|
6961
|
+
group: 'folder',
|
|
6710
6962
|
};
|
|
6711
6963
|
|
|
6712
6964
|
|
|
@@ -6773,21 +7025,52 @@ function LayerRow({
|
|
|
6773
7025
|
onReorder,
|
|
6774
7026
|
onRowPointerDown,
|
|
6775
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,
|
|
6776
7034
|
}) {
|
|
6777
7035
|
const key = `${node.id}:${node.index}`;
|
|
6778
7036
|
const hasKids = node.children && node.children.length > 0;
|
|
6779
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('');
|
|
6780
7050
|
// Match the specific INSTANCE (id + occurrence index), not every element that
|
|
6781
7051
|
// shares this source id — otherwise a `.map`ed element highlights all its
|
|
6782
7052
|
// clones at once. Fall back to id-only when the selection carries no index.
|
|
6783
7053
|
const isSel =
|
|
6784
|
-
|
|
7054
|
+
!isSynthetic &&
|
|
7055
|
+
node.id === selectedId &&
|
|
7056
|
+
(selectedIndex == null || node.index === selectedIndex);
|
|
6785
7057
|
const isHidden = hiddenOverride?.has(key) ? hiddenOverride.get(key) : !!node.hidden;
|
|
6786
7058
|
// A shared data-cd-id (reused component instance) IS reorderable now — the
|
|
6787
7059
|
// server maps the occurrence index to the parent <Component> usage — so these
|
|
6788
7060
|
// are no longer greyed/blocked. A `.map()`ed single-usage element still can't
|
|
6789
|
-
// split; that move is refused server-side and reverts.
|
|
6790
|
-
|
|
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
|
+
};
|
|
6791
7074
|
// Phase 12.1 — the row being dragged FLOATS with the cursor (same model as the
|
|
6792
7075
|
// in-canvas drag): a transform follows the pointer, its layout box stays
|
|
6793
7076
|
// reserved (empty slot at the origin), and pointer-events:none lets the row
|
|
@@ -6809,26 +7092,46 @@ function LayerRow({
|
|
|
6809
7092
|
<>
|
|
6810
7093
|
<div
|
|
6811
7094
|
className={
|
|
6812
|
-
'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' : '')
|
|
6813
7101
|
}
|
|
6814
7102
|
style={{ paddingLeft: 6 + depth * 14, ...dragStyle }}
|
|
6815
7103
|
role="treeitem"
|
|
6816
|
-
aria-selected={isSel}
|
|
7104
|
+
aria-selected={isSynthetic ? undefined : isSel}
|
|
6817
7105
|
aria-expanded={hasKids ? !isCollapsed : undefined}
|
|
6818
7106
|
aria-grabbed={canDrag ? isDragging : undefined}
|
|
6819
7107
|
tabIndex={0}
|
|
6820
|
-
title={
|
|
7108
|
+
title={
|
|
7109
|
+
isSynthetic
|
|
7110
|
+
? `${node.tag} · group (unstamped wrapper)`
|
|
7111
|
+
: `${node.tag} · ${node.type}${onRename ? ' · F2 to rename' : ''}`
|
|
7112
|
+
}
|
|
6821
7113
|
data-layer-key={key}
|
|
6822
|
-
onClick={() => onSelect(node)}
|
|
6823
|
-
onMouseEnter={() => onHover(node)}
|
|
7114
|
+
onClick={() => (isSynthetic ? hasKids && onToggle(key) : onSelect(node))}
|
|
7115
|
+
onMouseEnter={() => !isSynthetic && onHover(node)}
|
|
6824
7116
|
onMouseLeave={() => onHover(null)}
|
|
6825
7117
|
onPointerDown={canDrag ? (e) => onRowPointerDown(e, node, key) : undefined}
|
|
6826
7118
|
onKeyDown={(e) => {
|
|
6827
|
-
// Enter/Space select
|
|
6828
|
-
// 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).
|
|
6829
7122
|
if (e.key === 'Enter' || e.key === ' ') {
|
|
6830
7123
|
e.preventDefault();
|
|
6831
|
-
|
|
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);
|
|
6832
7135
|
}
|
|
6833
7136
|
}}
|
|
6834
7137
|
>
|
|
@@ -6847,10 +7150,67 @@ function LayerRow({
|
|
|
6847
7150
|
) : (
|
|
6848
7151
|
<span className="st-layer-caret" aria-hidden="true" />
|
|
6849
7152
|
)}
|
|
6850
|
-
|
|
6851
|
-
|
|
6852
|
-
|
|
6853
|
-
|
|
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 ? (
|
|
6854
7214
|
<button
|
|
6855
7215
|
type="button"
|
|
6856
7216
|
className="st-layer-eye"
|
|
@@ -6883,6 +7243,10 @@ function LayerRow({
|
|
|
6883
7243
|
onReorder={onReorder}
|
|
6884
7244
|
onRowPointerDown={onRowPointerDown}
|
|
6885
7245
|
dragState={dragState}
|
|
7246
|
+
componentMap={componentMap}
|
|
7247
|
+
lockedKeys={lockedKeys}
|
|
7248
|
+
onToggleLock={onToggleLock}
|
|
7249
|
+
onRename={onRename}
|
|
6886
7250
|
/>
|
|
6887
7251
|
))
|
|
6888
7252
|
: null}
|
|
@@ -6999,6 +7363,7 @@ function ArtboardKnobs({
|
|
|
6999
7363
|
onSetArtboardStyle,
|
|
7000
7364
|
onSetArtboardKind,
|
|
7001
7365
|
onSetArtboardPrint,
|
|
7366
|
+
onDuplicateArtboard,
|
|
7002
7367
|
}) {
|
|
7003
7368
|
const artboardId = resolveArtboardIdFromSelection(el);
|
|
7004
7369
|
// Dogfood (artboard panel ↔ shared inspector controls) — the panel now uses
|
|
@@ -7053,6 +7418,12 @@ function ArtboardKnobs({
|
|
|
7053
7418
|
const activePreset = Object.entries(SCREEN_PRESETS).find(
|
|
7054
7419
|
([, p]) => p.width === w && p.height === h
|
|
7055
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];
|
|
7056
7427
|
// Hug default (artboard "hug height") — current mode + the "more settings"
|
|
7057
7428
|
// (background/padding/layout/gap) read off the SAME generic `attrs` escape
|
|
7058
7429
|
// hatch dom-selection.ts already scrapes for every selection (the `data-dc-*`
|
|
@@ -7079,31 +7450,38 @@ function ArtboardKnobs({
|
|
|
7079
7450
|
}
|
|
7080
7451
|
const setKind = (nextKind) => {
|
|
7081
7452
|
if (!artboardId) return;
|
|
7082
|
-
// Picking "Digital" clears the explicit prop back to the implicit
|
|
7083
|
-
// default (applySetArtboardKind's `kind: null` path) rather than writing
|
|
7084
|
-
// a redundant `kind="digital"`.
|
|
7085
|
-
onSetArtboardKind?.(artboardId, nextKind === 'digital' ? null : nextKind);
|
|
7086
7453
|
// Dogfood fix — switching TO "print" with no print prop yet left the
|
|
7087
|
-
// artboard
|
|
7088
|
-
//
|
|
7089
|
-
//
|
|
7090
|
-
//
|
|
7091
|
-
//
|
|
7092
|
-
//
|
|
7093
|
-
|
|
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;
|
|
7094
7461
|
if (nextKind === 'print' && !print) {
|
|
7095
7462
|
const defaults = { paper: 'a4' };
|
|
7096
|
-
let resolved;
|
|
7097
7463
|
try {
|
|
7098
|
-
resolved = resolvePrintArtboard(defaults);
|
|
7464
|
+
const resolved = resolvePrintArtboard(defaults);
|
|
7465
|
+
if (resolved) {
|
|
7466
|
+
seedPrint = { defaults, widthPx: resolved.widthPx, heightPx: resolved.heightPx };
|
|
7467
|
+
}
|
|
7099
7468
|
} catch {
|
|
7100
|
-
|
|
7101
|
-
}
|
|
7102
|
-
if (resolved) {
|
|
7103
|
-
onResizeArtboard?.(artboardId, resolved.widthPx, resolved.heightPx);
|
|
7104
|
-
onSetArtboardPrint?.(artboardId, defaults);
|
|
7469
|
+
/* keep null */
|
|
7105
7470
|
}
|
|
7106
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
|
+
}
|
|
7107
7485
|
};
|
|
7108
7486
|
const setPrint = (patch) => {
|
|
7109
7487
|
if (!artboardId) return;
|
|
@@ -7180,6 +7558,32 @@ function ArtboardKnobs({
|
|
|
7180
7558
|
</option>
|
|
7181
7559
|
))}
|
|
7182
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>
|
|
7183
7587
|
) : (
|
|
7184
7588
|
<select
|
|
7185
7589
|
className="st-cp-nsel"
|
|
@@ -7223,6 +7627,35 @@ function ArtboardKnobs({
|
|
|
7223
7627
|
onChange={setKind}
|
|
7224
7628
|
/>
|
|
7225
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 = '';
|
|
7646
|
+
}}
|
|
7647
|
+
>
|
|
7648
|
+
<option value="" disabled>
|
|
7649
|
+
Duplicate at width…
|
|
7650
|
+
</option>
|
|
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}
|
|
7226
7659
|
{kind === 'print' ? (
|
|
7227
7660
|
<>
|
|
7228
7661
|
<div className="st-cp-sechd-row">
|
|
@@ -7337,11 +7770,28 @@ function ArtboardKnobs({
|
|
|
7337
7770
|
// of the selection's src/html for a selection that reached the panel via a code
|
|
7338
7771
|
// path predating that field. Returns null for a non-photo element.
|
|
7339
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');
|
|
7340
7778
|
function photoAssetOfSelection(el) {
|
|
7341
7779
|
if (!el || Array.isArray(el)) return null;
|
|
7342
7780
|
if (el.photoAsset) return el.photoAsset;
|
|
7343
|
-
if ((el.tag || '').toLowerCase() !== 'img') return null;
|
|
7344
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
|
+
}
|
|
7345
7795
|
// Once an edit is baked, the element's LIVE src is a `data:` URL (canvas-lib's
|
|
7346
7796
|
// PhotoPreviewBridge swaps it in directly) — it never matches PHOTO_ASSET_RE,
|
|
7347
7797
|
// so a REPLAYED/serialized selection (a WS resync, or the persisted
|
|
@@ -7360,6 +7810,13 @@ function InspectorPanel({
|
|
|
7360
7810
|
selected,
|
|
7361
7811
|
onClose,
|
|
7362
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,
|
|
7363
7820
|
canvasFile,
|
|
7364
7821
|
onSelectLayer,
|
|
7365
7822
|
onHoverLayer,
|
|
@@ -7374,6 +7831,7 @@ function InspectorPanel({
|
|
|
7374
7831
|
onSetArtboardStyle,
|
|
7375
7832
|
onSetArtboardKind,
|
|
7376
7833
|
onSetArtboardPrint,
|
|
7834
|
+
onDuplicateArtboard,
|
|
7377
7835
|
onUndoRedo,
|
|
7378
7836
|
editScope,
|
|
7379
7837
|
tab: tabProp,
|
|
@@ -7420,6 +7878,48 @@ function InspectorPanel({
|
|
|
7420
7878
|
// `node.hidden` (the authoritative source value) so a first render of an
|
|
7421
7879
|
// already-hidden element shows the correct eye-icon state without a click.
|
|
7422
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]);
|
|
7423
7923
|
const isNodeHidden = (node) => {
|
|
7424
7924
|
const key = `${node.id}:${node.index}`;
|
|
7425
7925
|
return hiddenOverride.has(key) ? hiddenOverride.get(key) : !!node.hidden;
|
|
@@ -7428,11 +7928,41 @@ function InspectorPanel({
|
|
|
7428
7928
|
// same drop target) + an aria-live announcement for keyboard moves.
|
|
7429
7929
|
const [dragState, setDragState] = useState(null);
|
|
7430
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
|
+
};
|
|
7431
7956
|
const handleReorder = onReorderLayer
|
|
7432
7957
|
? (dragged, ref, position) => {
|
|
7433
7958
|
// Gate keyboard + drop moves while a prior reorder is still landing — the
|
|
7434
7959
|
// write churns positional ids, so acting on the stale tree would misfire.
|
|
7435
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;
|
|
7436
7966
|
const verb =
|
|
7437
7967
|
position === 'before'
|
|
7438
7968
|
? `before ${ref.label}`
|
|
@@ -7794,6 +8324,19 @@ function InspectorPanel({
|
|
|
7794
8324
|
editScope.affects
|
|
7795
8325
|
} place${editScope.affects === 1 ? '' : 's'}`
|
|
7796
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}
|
|
7797
8340
|
</div>
|
|
7798
8341
|
) : null}
|
|
7799
8342
|
<div className="st-rp-body">
|
|
@@ -7809,15 +8352,16 @@ function InspectorPanel({
|
|
|
7809
8352
|
onRecordEdit={(before, after) => onPhotoRecordEdit?.(photoTarget.asset, before, after)}
|
|
7810
8353
|
/>
|
|
7811
8354
|
</div>
|
|
7812
|
-
) : !el ? (
|
|
8355
|
+
) : !el && !(effTab === 'layers' && layersTree?.nodes?.length) ? (
|
|
7813
8356
|
<div className="st-rp-empty">
|
|
7814
8357
|
{/* <p> wrapper — st-rp-empty is a flex column, bare text nodes +
|
|
7815
8358
|
kbd would stack as stretched flex items. */}
|
|
7816
8359
|
<p>
|
|
7817
|
-
|
|
8360
|
+
Press <Kbd>V</Kbd>, then click an element to select it — or <Kbd>⌘</Kbd>-click
|
|
8361
|
+
straight from Browse.
|
|
7818
8362
|
</p>
|
|
7819
8363
|
</div>
|
|
7820
|
-
) : effTab === 'inspect' ? (
|
|
8364
|
+
) : el && effTab === 'inspect' ? (
|
|
7821
8365
|
<>
|
|
7822
8366
|
<div className="st-rp-hd">{el.selector || el.tag || 'element'}</div>
|
|
7823
8367
|
<div className="st-insp-row">
|
|
@@ -7890,8 +8434,8 @@ function InspectorPanel({
|
|
|
7890
8434
|
key={`${n.id}:${n.index}`}
|
|
7891
8435
|
node={n}
|
|
7892
8436
|
depth={0}
|
|
7893
|
-
selectedId={el
|
|
7894
|
-
selectedIndex={el
|
|
8437
|
+
selectedId={el?.id}
|
|
8438
|
+
selectedIndex={el?.index}
|
|
7895
8439
|
collapsed={collapsed}
|
|
7896
8440
|
hiddenOverride={hiddenOverride}
|
|
7897
8441
|
onToggle={toggleCollapse}
|
|
@@ -7904,6 +8448,10 @@ function InspectorPanel({
|
|
|
7904
8448
|
onReorder={handleReorder}
|
|
7905
8449
|
onRowPointerDown={startLayerDrag}
|
|
7906
8450
|
dragState={dragState}
|
|
8451
|
+
componentMap={componentMap}
|
|
8452
|
+
lockedKeys={lockedKeys}
|
|
8453
|
+
onToggleLock={onToggleLock}
|
|
8454
|
+
onRename={renameLayer}
|
|
7907
8455
|
/>
|
|
7908
8456
|
))}
|
|
7909
8457
|
</div>
|
|
@@ -7922,7 +8470,7 @@ function InspectorPanel({
|
|
|
7922
8470
|
{reorderMsg}
|
|
7923
8471
|
</div>
|
|
7924
8472
|
</>
|
|
7925
|
-
) : Array.isArray(el.dom_path) && el.dom_path.length ? (
|
|
8473
|
+
) : el && Array.isArray(el.dom_path) && el.dom_path.length ? (
|
|
7926
8474
|
el.dom_path.map((node, i) => (
|
|
7927
8475
|
<div
|
|
7928
8476
|
key={i}
|
|
@@ -7948,6 +8496,7 @@ function InspectorPanel({
|
|
|
7948
8496
|
onSetArtboardStyle={onSetArtboardStyle}
|
|
7949
8497
|
onSetArtboardKind={onSetArtboardKind}
|
|
7950
8498
|
onSetArtboardPrint={onSetArtboardPrint}
|
|
8499
|
+
onDuplicateArtboard={onDuplicateArtboard}
|
|
7951
8500
|
/>
|
|
7952
8501
|
) : (
|
|
7953
8502
|
<CssKnobs
|
|
@@ -8016,6 +8565,11 @@ function App() {
|
|
|
8016
8565
|
// user meanwhile selected something else. select-by-id is idempotent
|
|
8017
8566
|
// (replace-same → select-set echo → ws guard sees same id → no re-schedule),
|
|
8018
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);
|
|
8019
8573
|
const haloRestoreTimersRef = useRef([]);
|
|
8020
8574
|
const scheduleHaloRestore = useCallback((one) => {
|
|
8021
8575
|
if (!one?.id || !one.file) return;
|
|
@@ -8093,6 +8647,27 @@ function App() {
|
|
|
8093
8647
|
const layersBusyTimerRef = useRef(null);
|
|
8094
8648
|
// Phase 12 Task 4 — Layers tree for the active artboard (posted by canvas-shell).
|
|
8095
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]);
|
|
8096
8671
|
const [wsConnected, setWsConnected] = useState(false);
|
|
8097
8672
|
// Phase 8 Task 7 — git lifecycle reload prompt. Server has already flushed
|
|
8098
8673
|
// every dirty Y.Doc to disk by the time this state populates, so accepting
|
|
@@ -8360,13 +8935,32 @@ function App() {
|
|
|
8360
8935
|
const handleAssistantFinished = useCallback(() => {
|
|
8361
8936
|
if (!assistantOpenRef.current || document.hidden) {
|
|
8362
8937
|
setAssistantUnseen(true);
|
|
8363
|
-
|
|
8364
|
-
|
|
8365
|
-
|
|
8366
|
-
|
|
8367
|
-
|
|
8368
|
-
|
|
8369
|
-
|
|
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.');
|
|
8370
8964
|
}
|
|
8371
8965
|
}, []);
|
|
8372
8966
|
// Inspector tab is lifted so View ▸ Layers can open the panel ON the Layers
|
|
@@ -9071,11 +9665,15 @@ function App() {
|
|
|
9071
9665
|
// feature-2-print-artboards T3 — reflect THIS canvas's own persisted
|
|
9072
9666
|
// print-guides flag in the menu checkbox when switching tabs.
|
|
9073
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 : []));
|
|
9074
9671
|
})
|
|
9075
9672
|
.catch(() => {
|
|
9076
9673
|
if (!cancelled) {
|
|
9077
9674
|
setActiveArtboards(0);
|
|
9078
9675
|
setPrintGuidesVisible(false);
|
|
9676
|
+
setLockedKeys(new Set());
|
|
9079
9677
|
}
|
|
9080
9678
|
});
|
|
9081
9679
|
return () => {
|
|
@@ -9083,6 +9681,51 @@ function App() {
|
|
|
9083
9681
|
};
|
|
9084
9682
|
}, [activePath]);
|
|
9085
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
|
+
|
|
9086
9729
|
// Sync theme to <html data-theme> + localStorage on every change.
|
|
9087
9730
|
useEffect(() => {
|
|
9088
9731
|
try {
|
|
@@ -9318,6 +9961,18 @@ function App() {
|
|
|
9318
9961
|
// mount it lands after, so re-apply the halo from THIS edge too.
|
|
9319
9962
|
// Guarded to restore-transitions (prev missing / different id or
|
|
9320
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;
|
|
9321
9976
|
const incoming = m.selected;
|
|
9322
9977
|
const one = Array.isArray(incoming) ? incoming[0] : incoming;
|
|
9323
9978
|
const prevSel = selectedRef.current;
|
|
@@ -9899,6 +10554,17 @@ function App() {
|
|
|
9899
10554
|
// only pick a known, always-visible glyph; it cannot inject an
|
|
9900
10555
|
// invisible/displaced SVG cursor as a clickjacking aid over the un-CSP'd
|
|
9901
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
|
+
}
|
|
9902
10568
|
const cursor = resolveToolCursor(m.tool);
|
|
9903
10569
|
if (cursor) {
|
|
9904
10570
|
document.body.style.cursor = cursor;
|
|
@@ -9947,11 +10613,13 @@ function App() {
|
|
|
9947
10613
|
}
|
|
9948
10614
|
if (m.dgn === 'select' && m.selection) {
|
|
9949
10615
|
setPhotoSel(null); // a DOM selection supersedes an annotation-image Photo target
|
|
10616
|
+
lastLocalSelectAtRef.current = Date.now();
|
|
9950
10617
|
wsSend({ type: 'select', selection: m.selection });
|
|
9951
10618
|
setSelected(m.selection);
|
|
9952
10619
|
maybeAutoOpenInspectorOnSelect(m.selection); // Stage C
|
|
9953
10620
|
} else if (m.dgn === 'select-set') {
|
|
9954
10621
|
setPhotoSel(null);
|
|
10622
|
+
lastLocalSelectAtRef.current = Date.now();
|
|
9955
10623
|
// Canvas multi-select. Payload shape:
|
|
9956
10624
|
// null → empty selection
|
|
9957
10625
|
// Selection → length-1 (back-compat with legacy single-element shape)
|
|
@@ -9976,6 +10644,7 @@ function App() {
|
|
|
9976
10644
|
}
|
|
9977
10645
|
} else if (m.dgn === 'clear-select') {
|
|
9978
10646
|
setPhotoSel(null);
|
|
10647
|
+
lastLocalSelectAtRef.current = Date.now();
|
|
9979
10648
|
wsSend({ type: 'clear-select' });
|
|
9980
10649
|
setSelected(null);
|
|
9981
10650
|
} else if (m.dgn === 'edit-text' && m.id) {
|
|
@@ -10294,6 +10963,64 @@ function App() {
|
|
|
10294
10963
|
before: typeof m.before === 'string' ? m.before : null,
|
|
10295
10964
|
});
|
|
10296
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
|
+
}
|
|
10297
11024
|
} else if (m.dgn === 'replace-annotation-media-request') {
|
|
10298
11025
|
// Stage F3 — "Replace…" on an annotation ImageStroke/MediaRefStroke.
|
|
10299
11026
|
// Opens the SAME AssetPicker; unlike F2 the pick is posted BACK DOWN to
|
|
@@ -10370,7 +11097,17 @@ function App() {
|
|
|
10370
11097
|
const activeWin = activePath ? iframesRef.current.get(activePath)?.contentWindow : null;
|
|
10371
11098
|
const okKind = m.kind === null || typeof m.kind === 'string';
|
|
10372
11099
|
if (e.source === activeWin && typeof m.artboardId === 'string' && okKind) {
|
|
10373
|
-
|
|
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);
|
|
10374
11111
|
}
|
|
10375
11112
|
} else if (m.dgn === 'open-inspector') {
|
|
10376
11113
|
// Phase 12 — context-menu "Inspect" / tool-palette Inspect opens the right panel.
|
|
@@ -10929,8 +11666,10 @@ function App() {
|
|
|
10929
11666
|
// usage (local), not the shared inner definition. undefined for a plain element.
|
|
10930
11667
|
const occ = Number.isInteger(idIndex) ? idIndex : undefined;
|
|
10931
11668
|
// `transform` rides the same lane for the rotate handle (Task L8);
|
|
10932
|
-
// `padding-*`/`gap` ride it for the on-canvas spacing drag (Stage J)
|
|
10933
|
-
//
|
|
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.
|
|
10934
11673
|
const props = [
|
|
10935
11674
|
'width',
|
|
10936
11675
|
'height',
|
|
@@ -10942,6 +11681,8 @@ function App() {
|
|
|
10942
11681
|
'padding-bottom',
|
|
10943
11682
|
'padding-left',
|
|
10944
11683
|
'gap',
|
|
11684
|
+
'grid-template-columns',
|
|
11685
|
+
'grid-template-rows',
|
|
10945
11686
|
].filter((p) => typeof patch[p] === 'string' && patch[p]);
|
|
10946
11687
|
if (!props.length) return;
|
|
10947
11688
|
// Dogfood 2026-07-07 — "resize paddingu nebo gap" was still deselecting on
|
|
@@ -11120,6 +11861,34 @@ function App() {
|
|
|
11120
11861
|
[activePath, canvasActiveArtboard, insertElementShell]
|
|
11121
11862
|
);
|
|
11122
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
|
+
|
|
11123
11892
|
const duplicateElementShell = useCallback(
|
|
11124
11893
|
(id, idIndex) => {
|
|
11125
11894
|
structuralWrite(
|
|
@@ -11248,6 +12017,20 @@ function App() {
|
|
|
11248
12017
|
[structuralWrite]
|
|
11249
12018
|
);
|
|
11250
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
|
+
|
|
11251
12034
|
// Artboard "hug height" default — Hug ⇄ Fixed toggle from ArtboardKnobs
|
|
11252
12035
|
// (CSS panel). Direct shell-side action (no canvas postMessage round trip,
|
|
11253
12036
|
// unlike resizeArtboardShell's drag-overlay caller), so no *ShellRef needed.
|
|
@@ -11287,15 +12070,66 @@ function App() {
|
|
|
11287
12070
|
// "Preset size…" still listing screen presets) until the user manually
|
|
11288
12071
|
// re-clicked the artboard — structuralWrite's onOk never refreshed
|
|
11289
12072
|
// `selected`, only recorded undo history.
|
|
11290
|
-
|
|
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(
|
|
11291
12082
|
(artboardId, kind) => {
|
|
11292
12083
|
structuralWrite('/_api/set-artboard-kind', { artboardId, kind }, {
|
|
11293
12084
|
label: 'artboard kind',
|
|
11294
12085
|
onOk: () => scheduleArtboardResync(artboardId, activePath),
|
|
11295
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
|
+
}
|
|
11296
12103
|
},
|
|
11297
12104
|
[structuralWrite, scheduleArtboardResync, activePath]
|
|
11298
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
|
+
);
|
|
11299
12133
|
// feature-2-print-artboards T2 — paper/orientation/bleed/margins. Direct
|
|
11300
12134
|
// Inspector-only callable (no canvas-origin postMessage path — same shape
|
|
11301
12135
|
// as setArtboardStyleShell, unlike setArtboardKindShell which also has a
|
|
@@ -11355,6 +12189,7 @@ function App() {
|
|
|
11355
12189
|
const deleteArtboardShellRef = useRef(null);
|
|
11356
12190
|
const setArtboardKindShellRef = useRef(null);
|
|
11357
12191
|
const duplicateElementShellRef = useRef(null);
|
|
12192
|
+
const duplicateArtboardShellRef = useRef(null);
|
|
11358
12193
|
const copyStyleRef = useRef(null);
|
|
11359
12194
|
const pasteStyleRef = useRef(null);
|
|
11360
12195
|
useEffect(() => {
|
|
@@ -11365,6 +12200,7 @@ function App() {
|
|
|
11365
12200
|
deleteArtboardShellRef.current = deleteArtboardShell;
|
|
11366
12201
|
setArtboardKindShellRef.current = setArtboardKindShell;
|
|
11367
12202
|
duplicateElementShellRef.current = duplicateElementShell;
|
|
12203
|
+
duplicateArtboardShellRef.current = duplicateArtboardShell;
|
|
11368
12204
|
copyStyleRef.current = copyStyle;
|
|
11369
12205
|
pasteStyleRef.current = pasteStyle;
|
|
11370
12206
|
}, [
|
|
@@ -11375,6 +12211,7 @@ function App() {
|
|
|
11375
12211
|
deleteArtboardShell,
|
|
11376
12212
|
setArtboardKindShell,
|
|
11377
12213
|
duplicateElementShell,
|
|
12214
|
+
duplicateArtboardShell,
|
|
11378
12215
|
copyStyle,
|
|
11379
12216
|
pasteStyle,
|
|
11380
12217
|
]);
|
|
@@ -12124,6 +12961,7 @@ function App() {
|
|
|
12124
12961
|
onSetArtboardStyle={setArtboardStyleShell}
|
|
12125
12962
|
onSetArtboardKind={setArtboardKindShell}
|
|
12126
12963
|
onSetArtboardPrint={setArtboardPrintShell}
|
|
12964
|
+
onDuplicateArtboard={duplicateArtboardShell}
|
|
12127
12965
|
editScope={editScope}
|
|
12128
12966
|
onUndoRedo={(dir) => postToActiveCanvas({ dgn: dir })}
|
|
12129
12967
|
photoSel={photoSel}
|
|
@@ -12133,6 +12971,10 @@ function App() {
|
|
|
12133
12971
|
onPhotoRecordEdit={onPhotoRecordEdit}
|
|
12134
12972
|
onPhotoUndoRedo={(dir) => performPhotoUndo(dir === 'redo')}
|
|
12135
12973
|
layersTree={layersTree}
|
|
12974
|
+
componentMap={componentMap}
|
|
12975
|
+
lockedKeys={lockedKeys}
|
|
12976
|
+
onToggleLock={toggleLockedKey}
|
|
12977
|
+
onDetachInstance={detachInstanceShell}
|
|
12136
12978
|
canvasFile={activePath}
|
|
12137
12979
|
onSelectLayer={(n) =>
|
|
12138
12980
|
postToActiveCanvas({
|
|
@@ -12331,6 +13173,8 @@ function App() {
|
|
|
12331
13173
|
onClose={() => setAssistantOpen(false)}
|
|
12332
13174
|
onBusyChange={setAssistantBusy}
|
|
12333
13175
|
onFinished={handleAssistantFinished}
|
|
13176
|
+
onPermissionRequest={handleAssistantAttention}
|
|
13177
|
+
onElicitationRequest={handleAssistantAttention}
|
|
12334
13178
|
/>
|
|
12335
13179
|
)}
|
|
12336
13180
|
{leftActive && leftActive !== 'assistant' && renderPanelBody(leftActive)}
|
|
@@ -12399,6 +13243,8 @@ function App() {
|
|
|
12399
13243
|
onClose={() => setAssistantOpen(false)}
|
|
12400
13244
|
onBusyChange={setAssistantBusy}
|
|
12401
13245
|
onFinished={handleAssistantFinished}
|
|
13246
|
+
onPermissionRequest={handleAssistantAttention}
|
|
13247
|
+
onElicitationRequest={handleAssistantAttention}
|
|
12402
13248
|
/>
|
|
12403
13249
|
)}
|
|
12404
13250
|
{rightActive && rightActive !== 'assistant' && renderPanelBody(rightActive)}
|