@1agh/maude 0.29.0 → 0.30.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/apps/studio/ai-banner.tsx +0 -1
- package/apps/studio/annotations-align.ts +149 -0
- package/apps/studio/annotations-bindings.ts +197 -0
- package/apps/studio/annotations-context-toolbar.tsx +436 -186
- package/apps/studio/annotations-groups.ts +270 -0
- package/apps/studio/annotations-layer.tsx +1708 -1660
- package/apps/studio/annotations-model.ts +2077 -0
- package/apps/studio/annotations-snap.ts +125 -0
- package/apps/studio/api.ts +208 -116
- package/apps/studio/bin/_png-playwright.mjs +1 -1
- package/apps/studio/bin/annotate.mjs +732 -0
- package/apps/studio/bin/annotate.sh +17 -0
- package/apps/studio/bin/read-annotations.mjs +152 -17
- package/apps/studio/build.ts +1 -1
- package/apps/studio/canvas-arrowheads.ts +78 -9
- package/apps/studio/canvas-cursors.ts +2 -0
- package/apps/studio/canvas-edit.ts +257 -7
- package/apps/studio/canvas-icons.tsx +105 -0
- package/apps/studio/canvas-shell.tsx +305 -8
- package/apps/studio/client/app.jsx +2812 -238
- package/apps/studio/client/index.html +1 -1
- package/apps/studio/client/styles/3-shell-maude.css +713 -11
- package/apps/studio/client/styles/4-components.css +39 -0
- package/apps/studio/client/styles/5-maude-overrides.css +16 -2
- package/apps/studio/client/tour/overlay.jsx +105 -17
- package/apps/studio/client/tour/usage-tour.js +23 -10
- package/apps/studio/commands/annotation-strokes-command.ts +1 -1
- package/apps/studio/commands/move-artboards-command.ts +1 -1
- package/apps/studio/comments-overlay.tsx +7 -5
- package/apps/studio/context-menu.tsx +4 -3
- package/apps/studio/contextual-toolbar.tsx +14 -0
- package/apps/studio/cursors-overlay.tsx +20 -20
- package/apps/studio/dist/client.bundle.js +3322 -602
- package/apps/studio/dist/comment-mount.js +95 -9
- package/apps/studio/dist/styles.css +2490 -167
- package/apps/studio/dom-selection.ts +115 -0
- package/apps/studio/export-dialog.tsx +3 -3
- package/apps/studio/history.ts +20 -3
- package/apps/studio/http.ts +124 -5
- package/apps/studio/input-router.tsx +7 -0
- package/apps/studio/inspect.ts +37 -8
- package/apps/studio/participants-chrome.tsx +1 -7
- package/apps/studio/server.ts +5 -2
- package/apps/studio/sync/agent.ts +231 -57
- package/apps/studio/sync/codec.ts +45 -0
- package/apps/studio/sync/cold-start.ts +158 -0
- package/apps/studio/sync/connection-state.ts +58 -2
- package/apps/studio/sync/index.ts +563 -221
- package/apps/studio/sync/journal.ts +190 -0
- package/apps/studio/sync/migrate-seed.ts +202 -44
- package/apps/studio/sync/projection.ts +22 -3
- package/apps/studio/sync/status.ts +15 -4
- package/apps/studio/test/activity.test.ts +1 -7
- package/apps/studio/test/annotate-write.test.ts +184 -0
- package/apps/studio/test/annotations-align.test.ts +88 -0
- package/apps/studio/test/annotations-bindings.test.ts +124 -0
- package/apps/studio/test/annotations-groups.test.ts +231 -0
- package/apps/studio/test/annotations-snap.test.ts +79 -0
- package/apps/studio/test/canvas-edit.test.ts +181 -1
- package/apps/studio/test/canvas-origin-gate.test.ts +8 -0
- package/apps/studio/test/collab-bridge.test.ts +0 -2
- package/apps/studio/test/collab-room.test.ts +2 -7
- package/apps/studio/test/csp-canvas-shell.test.ts +5 -0
- package/apps/studio/test/csrf-write-guard.test.ts +52 -0
- package/apps/studio/test/figjam-v3-model.test.ts +342 -0
- package/apps/studio/test/fixtures/figjam-v3-groups-bindings.svg +1 -0
- package/apps/studio/test/shared-doc-migrate.test.ts +224 -14
- package/apps/studio/test/sync-agent.test.ts +272 -2
- package/apps/studio/test/sync-codec.test.ts +65 -0
- package/apps/studio/test/sync-cold-start.test.ts +244 -0
- package/apps/studio/test/sync-connection-state.test.ts +68 -0
- package/apps/studio/test/sync-hardening.test.ts +0 -8
- package/apps/studio/test/sync-hubs-config.test.ts +0 -1
- package/apps/studio/test/sync-incident-replay.test.ts +211 -0
- package/apps/studio/test/sync-journal.test.ts +176 -0
- package/apps/studio/test/sync-runtime.test.ts +360 -5
- package/apps/studio/test/sync-status.test.ts +66 -0
- package/apps/studio/test/use-annotation-resize.test.ts +159 -0
- package/apps/studio/test/use-artboard-drag.test.ts +0 -1
- package/apps/studio/test/use-collab.test.ts +0 -0
- package/apps/studio/test/use-tool-mode.test.tsx +5 -2
- package/apps/studio/tool-palette.tsx +12 -2
- package/apps/studio/undo-hud.tsx +0 -1
- package/apps/studio/use-agent-presence.tsx +4 -2
- package/apps/studio/use-annotation-resize.tsx +484 -54
- package/apps/studio/use-annotation-selection.tsx +16 -4
- package/apps/studio/use-canvas-activity.tsx +2 -2
- package/apps/studio/use-collab.tsx +20 -15
- package/apps/studio/use-selection-set.tsx +9 -0
- package/apps/studio/use-tool-mode.tsx +5 -1
- package/apps/studio/whats-new.json +135 -10
- package/apps/studio/whats-new.schema.json +18 -2
- package/cli/commands/design-link.test.mjs +84 -0
- package/cli/commands/design.mjs +2 -1
- package/cli/commands/design.test.mjs +5 -1
- package/cli/lib/design-link.mjs +51 -1
- package/package.json +8 -8
- package/plugins/design/templates/_shell.html +28 -4
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
// Universal — no project tokens needed; styling lives in client/styles/.
|
|
5
5
|
|
|
6
6
|
import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
7
|
+
import { createPortal } from 'react-dom';
|
|
7
8
|
import { createRoot } from 'react-dom/client';
|
|
8
9
|
|
|
9
10
|
// Trusted tool→cursor resolver (shares the single TOOL_CURSORS source with the
|
|
@@ -319,6 +320,96 @@ const STICONS = {
|
|
|
319
320
|
<polyline points="2.2 9 8 12.3 13.8 9" />
|
|
320
321
|
</>
|
|
321
322
|
),
|
|
323
|
+
// Layer-type glyphs (Phase 12.3 W3.1) — one mark per LayerNode `type`.
|
|
324
|
+
box: <rect x="3" y="3" width="10" height="10" rx="1.2" />,
|
|
325
|
+
type: (
|
|
326
|
+
<>
|
|
327
|
+
<polyline points="4 4 12 4" />
|
|
328
|
+
<line x1="8" y1="4" x2="8" y2="12" />
|
|
329
|
+
</>
|
|
330
|
+
),
|
|
331
|
+
button: (
|
|
332
|
+
<>
|
|
333
|
+
<rect x="2.5" y="5" width="11" height="6" rx="3" />
|
|
334
|
+
<line x1="6" y1="8" x2="10" y2="8" />
|
|
335
|
+
</>
|
|
336
|
+
),
|
|
337
|
+
input: (
|
|
338
|
+
<>
|
|
339
|
+
<rect x="2.5" y="5" width="11" height="6" rx="1.2" />
|
|
340
|
+
<line x1="5" y1="8" x2="5" y2="8" />
|
|
341
|
+
</>
|
|
342
|
+
),
|
|
343
|
+
link: (
|
|
344
|
+
<>
|
|
345
|
+
<path d="M6.5 9.5a2.5 2.5 0 0 1 0-3.5l1.5-1.5a2.5 2.5 0 0 1 3.5 3.5l-1 1" />
|
|
346
|
+
<path d="M9.5 6.5a2.5 2.5 0 0 1 0 3.5l-1.5 1.5a2.5 2.5 0 0 1-3.5-3.5l1-1" />
|
|
347
|
+
</>
|
|
348
|
+
),
|
|
349
|
+
list: (
|
|
350
|
+
<>
|
|
351
|
+
<line x1="6" y1="4.5" x2="13" y2="4.5" />
|
|
352
|
+
<line x1="6" y1="8" x2="13" y2="8" />
|
|
353
|
+
<line x1="6" y1="11.5" x2="13" y2="11.5" />
|
|
354
|
+
<circle cx="3.2" cy="4.5" r="0.8" fill="currentColor" />
|
|
355
|
+
<circle cx="3.2" cy="8" r="0.8" fill="currentColor" />
|
|
356
|
+
<circle cx="3.2" cy="11.5" r="0.8" fill="currentColor" />
|
|
357
|
+
</>
|
|
358
|
+
),
|
|
359
|
+
eye: (
|
|
360
|
+
<>
|
|
361
|
+
<path d="M1.5 8S4 3.5 8 3.5 14.5 8 14.5 8 12 12.5 8 12.5 1.5 8 1.5 8z" />
|
|
362
|
+
<circle cx="8" cy="8" r="2" />
|
|
363
|
+
</>
|
|
364
|
+
),
|
|
365
|
+
eyedropper: (
|
|
366
|
+
<>
|
|
367
|
+
<path d="M11 2.6a1.7 1.7 0 0 1 2.4 2.4l-1.2 1.2-2.4-2.4z" />
|
|
368
|
+
<path d="M9.5 4.6 4 10.1V12h1.9l5.5-5.5" />
|
|
369
|
+
</>
|
|
370
|
+
),
|
|
371
|
+
// Figma-style property prefix glyphs (#2) — small marks INSIDE numeric fields.
|
|
372
|
+
'p-corner': <path d="M3.5 12.5V7a3.5 3.5 0 0 1 3.5-3.5h5.5" />,
|
|
373
|
+
'p-opacity': (
|
|
374
|
+
<>
|
|
375
|
+
<rect x="3" y="3" width="10" height="10" rx="1.5" />
|
|
376
|
+
<path d="M3 8h10M8 3v10" strokeWidth="0.9" opacity="0.55" />
|
|
377
|
+
</>
|
|
378
|
+
),
|
|
379
|
+
'p-lineheight': (
|
|
380
|
+
<>
|
|
381
|
+
<line x1="6.5" y1="4" x2="13" y2="4" />
|
|
382
|
+
<line x1="6.5" y1="8" x2="13" y2="8" />
|
|
383
|
+
<line x1="6.5" y1="12" x2="13" y2="12" />
|
|
384
|
+
<path d="M3.2 4.6 3.2 11.4M2 6 3.2 4.5 4.4 6M2 10 3.2 11.5 4.4 10" />
|
|
385
|
+
</>
|
|
386
|
+
),
|
|
387
|
+
'p-letterspacing': (
|
|
388
|
+
<>
|
|
389
|
+
<path d="M3 4v8M13 4v8" />
|
|
390
|
+
<path d="M6 11.5 8 5l2 6.5M6.7 9.3h2.6" strokeWidth="1.1" />
|
|
391
|
+
</>
|
|
392
|
+
),
|
|
393
|
+
'p-gap': (
|
|
394
|
+
<>
|
|
395
|
+
<rect x="2" y="4.5" width="3.6" height="7" rx="0.6" />
|
|
396
|
+
<rect x="10.4" y="4.5" width="3.6" height="7" rx="0.6" />
|
|
397
|
+
<path d="M6.8 8h2.4M7.4 6.9 6.4 8l1 1.1M8.6 6.9 9.6 8l-1 1.1" strokeWidth="1" />
|
|
398
|
+
</>
|
|
399
|
+
),
|
|
400
|
+
'p-border': <rect x="3" y="3" width="10" height="10" rx="1" />,
|
|
401
|
+
'p-size': (
|
|
402
|
+
<>
|
|
403
|
+
<path d="M3 13 6.6 3l3.6 10" />
|
|
404
|
+
<path d="M4.3 9.6h4.6" />
|
|
405
|
+
</>
|
|
406
|
+
),
|
|
407
|
+
'eye-off': (
|
|
408
|
+
<>
|
|
409
|
+
<path d="M6.3 4A6.7 6.7 0 0 1 8 3.5C12 3.5 14.5 8 14.5 8a12 12 0 0 1-2 2.4M4.4 5.3A12 12 0 0 0 1.5 8S4 12.5 8 12.5a6.5 6.5 0 0 0 2.1-.35" />
|
|
410
|
+
<line x1="2.5" y1="2.5" x2="13.5" y2="13.5" />
|
|
411
|
+
</>
|
|
412
|
+
),
|
|
322
413
|
sliders: (
|
|
323
414
|
<>
|
|
324
415
|
<line x1="3" y1="5" x2="13" y2="5" />
|
|
@@ -390,6 +481,22 @@ const STICONS = {
|
|
|
390
481
|
<line x1="13" y1="3" x2="7.6" y2="8.4" />
|
|
391
482
|
</>
|
|
392
483
|
),
|
|
484
|
+
share: (
|
|
485
|
+
<>
|
|
486
|
+
<circle cx="4" cy="8" r="1.9" />
|
|
487
|
+
<circle cx="11.6" cy="3.6" r="1.9" />
|
|
488
|
+
<circle cx="11.6" cy="12.4" r="1.9" />
|
|
489
|
+
<line x1="5.7" y1="7" x2="9.9" y2="4.6" />
|
|
490
|
+
<line x1="5.7" y1="9" x2="9.9" y2="11.4" />
|
|
491
|
+
</>
|
|
492
|
+
),
|
|
493
|
+
pen: (
|
|
494
|
+
<>
|
|
495
|
+
<path d="M3 13l.8-3L10.6 3.2a1.1 1.1 0 0 1 1.6 0l.6.6a1.1 1.1 0 0 1 0 1.6L6 12.2z" />
|
|
496
|
+
<line x1="9.6" y1="4.2" x2="11.8" y2="6.4" />
|
|
497
|
+
</>
|
|
498
|
+
),
|
|
499
|
+
square: <rect x="3.5" y="3.5" width="9" height="9" rx="1" />,
|
|
393
500
|
};
|
|
394
501
|
|
|
395
502
|
// ⌘K command palette — the mockup's signature surface, wired to real shell
|
|
@@ -398,12 +505,19 @@ const STICONS = {
|
|
|
398
505
|
function CommandPalette({ open, onClose, actions }) {
|
|
399
506
|
const [q, setQ] = useState('');
|
|
400
507
|
const [active, setActive] = useState(0);
|
|
508
|
+
const listRef = useRef(null);
|
|
401
509
|
useEffect(() => {
|
|
402
510
|
if (open) {
|
|
403
511
|
setQ('');
|
|
404
512
|
setActive(0);
|
|
405
513
|
}
|
|
406
514
|
}, [open]);
|
|
515
|
+
// Keep the keyboard-active row visible while arrowing through a scrolled list.
|
|
516
|
+
useEffect(() => {
|
|
517
|
+
listRef.current
|
|
518
|
+
?.querySelector('.st-pal-item.is-active')
|
|
519
|
+
?.scrollIntoView({ block: 'nearest' });
|
|
520
|
+
}, [active]);
|
|
407
521
|
const filtered = useMemo(() => {
|
|
408
522
|
const needle = q.trim().toLowerCase();
|
|
409
523
|
if (!needle) return actions;
|
|
@@ -457,7 +571,7 @@ function CommandPalette({ open, onClose, actions }) {
|
|
|
457
571
|
/>
|
|
458
572
|
<Kbd>⌘K</Kbd>
|
|
459
573
|
</div>
|
|
460
|
-
<div className="st-pal-list">
|
|
574
|
+
<div className="st-pal-list" ref={listRef}>
|
|
461
575
|
{filtered.length === 0 ? (
|
|
462
576
|
<div className="st-pal-empty">No matching command.</div>
|
|
463
577
|
) : (
|
|
@@ -530,9 +644,19 @@ function initialsOf(name) {
|
|
|
530
644
|
if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase();
|
|
531
645
|
return ((parts[0][0] || '') + (parts[parts.length - 1][0] || '')).toUpperCase() || '?';
|
|
532
646
|
}
|
|
533
|
-
function StAvatar({ initials, hue, title }) {
|
|
647
|
+
function StAvatar({ initials, hue, title, pulse }) {
|
|
648
|
+
// Hue rides a custom property so CSS can mix it into the surface (DS avatar
|
|
649
|
+
// recipe: tinted bg + hue border + fg-0 text — solid fill + white text broke
|
|
650
|
+
// the accent-fg contrast rule and washed out in light theme). `pulse` plays
|
|
651
|
+
// the DS motion-presence role (scale+opacity ring) — the AI agent's "live"
|
|
652
|
+
// tell while it's editing.
|
|
534
653
|
return (
|
|
535
|
-
<span
|
|
654
|
+
<span
|
|
655
|
+
className={'st-avatar' + (pulse ? ' is-pulsing' : '')}
|
|
656
|
+
style={{ '--av-hue': hue }}
|
|
657
|
+
data-tip={title}
|
|
658
|
+
aria-label={title}
|
|
659
|
+
>
|
|
536
660
|
{initials}
|
|
537
661
|
</span>
|
|
538
662
|
);
|
|
@@ -542,6 +666,78 @@ function Kbd({ children }) {
|
|
|
542
666
|
return <span className="kbd">{children}</span>;
|
|
543
667
|
}
|
|
544
668
|
|
|
669
|
+
// ───────── Resizable panel grip (DS components-resize-panels contract) ─────────
|
|
670
|
+
//
|
|
671
|
+
// 8px hit area on a 1px seam; grip dots + accent surface on hover/focus/drag;
|
|
672
|
+
// pointer drag (with capture, so moves keep arriving over the iframe), arrow-key
|
|
673
|
+
// nudge (8px, ⇧=24px), Home/End to min/max, double-click resets to default.
|
|
674
|
+
// Width persists per panel in localStorage.
|
|
675
|
+
|
|
676
|
+
function usePanelSize(storeKey, { min, max, def }) {
|
|
677
|
+
const clamp = useCallback((v) => Math.min(max, Math.max(min, v)), [min, max]);
|
|
678
|
+
const [w, setWRaw] = useState(() => {
|
|
679
|
+
try {
|
|
680
|
+
const v = parseInt(localStorage.getItem(storeKey) || '', 10);
|
|
681
|
+
return Number.isFinite(v) ? clamp(v) : def;
|
|
682
|
+
} catch {
|
|
683
|
+
return def;
|
|
684
|
+
}
|
|
685
|
+
});
|
|
686
|
+
useEffect(() => {
|
|
687
|
+
try {
|
|
688
|
+
localStorage.setItem(storeKey, String(w));
|
|
689
|
+
} catch {}
|
|
690
|
+
}, [storeKey, w]);
|
|
691
|
+
const setW = useCallback(
|
|
692
|
+
(next) => setWRaw((prev) => clamp(typeof next === 'function' ? next(prev) : next)),
|
|
693
|
+
[clamp]
|
|
694
|
+
);
|
|
695
|
+
return { w, setW, min, max, def };
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
function PanelGrip({ label, size, onPointerDown, active, dir = 'ltr' }) {
|
|
699
|
+
const { w, setW, min, max, def } = size;
|
|
700
|
+
// `dir` is grip-relative: 'ltr' (left panel) → ArrowRight widens; 'rtl'
|
|
701
|
+
// (right dock) → ArrowRight narrows, since the seam moves toward the panel.
|
|
702
|
+
const grow = dir === 'rtl' ? -1 : 1;
|
|
703
|
+
return (
|
|
704
|
+
<div
|
|
705
|
+
className={'st-grip' + (active ? ' is-active' : '')}
|
|
706
|
+
role="separator"
|
|
707
|
+
tabIndex={0}
|
|
708
|
+
aria-orientation="vertical"
|
|
709
|
+
aria-label={label}
|
|
710
|
+
aria-valuemin={min}
|
|
711
|
+
aria-valuemax={max}
|
|
712
|
+
aria-valuenow={Math.round(w)}
|
|
713
|
+
onPointerDown={onPointerDown}
|
|
714
|
+
onDoubleClick={() => setW(def)}
|
|
715
|
+
onKeyDown={(e) => {
|
|
716
|
+
const step = e.shiftKey ? 24 : 8;
|
|
717
|
+
if (e.key === 'ArrowRight') {
|
|
718
|
+
e.preventDefault();
|
|
719
|
+
setW((v) => v + step * grow);
|
|
720
|
+
} else if (e.key === 'ArrowLeft') {
|
|
721
|
+
e.preventDefault();
|
|
722
|
+
setW((v) => v - step * grow);
|
|
723
|
+
} else if (e.key === 'Home') {
|
|
724
|
+
e.preventDefault();
|
|
725
|
+
setW(min);
|
|
726
|
+
} else if (e.key === 'End') {
|
|
727
|
+
e.preventDefault();
|
|
728
|
+
setW(max);
|
|
729
|
+
}
|
|
730
|
+
}}
|
|
731
|
+
>
|
|
732
|
+
<svg className="st-grip-dots" viewBox="0 0 6 18" aria-hidden="true">
|
|
733
|
+
<circle cx="3" cy="3" r="1.1" fill="currentColor" />
|
|
734
|
+
<circle cx="3" cy="9" r="1.1" fill="currentColor" />
|
|
735
|
+
<circle cx="3" cy="15" r="1.1" fill="currentColor" />
|
|
736
|
+
</svg>
|
|
737
|
+
</div>
|
|
738
|
+
);
|
|
739
|
+
}
|
|
740
|
+
|
|
545
741
|
// T5 (Plan C) — shell-level Export & Handoff dialog (maude `.st-dialog`), per
|
|
546
742
|
// `.design/ui/Studio.tsx` HandoffBoard. Wired to the privileged main-origin
|
|
547
743
|
// `POST /_api/export` (7 real formats × scopes). The shadcn card is HANDOFF —
|
|
@@ -817,7 +1013,7 @@ function DirRow({ name, depth, defaultOpen, children }) {
|
|
|
817
1013
|
onClick={() => setOpen((v) => !v)}
|
|
818
1014
|
>
|
|
819
1015
|
<span className="st-row-glyph">
|
|
820
|
-
<StIcon name={open ? '
|
|
1016
|
+
<StIcon name="chevron-right" className={'st-chev' + (open ? ' is-open' : '')} size={13} />
|
|
821
1017
|
</span>
|
|
822
1018
|
<span className="st-row-name">{name}</span>
|
|
823
1019
|
</button>
|
|
@@ -847,7 +1043,7 @@ function DsFolderRow({ name, dsName, depth, defaultOpen, active, onOpenSystem, c
|
|
|
847
1043
|
aria-label={open ? 'Collapse design system' : 'Expand design system'}
|
|
848
1044
|
title={open ? 'Collapse' : 'Expand'}
|
|
849
1045
|
>
|
|
850
|
-
<StIcon name={open ? '
|
|
1046
|
+
<StIcon name="chevron-right" className={'st-chev' + (open ? ' is-open' : '')} size={13} />
|
|
851
1047
|
</button>
|
|
852
1048
|
<button
|
|
853
1049
|
type="button"
|
|
@@ -983,7 +1179,7 @@ function CanvasRow({
|
|
|
983
1179
|
setOpenState((v) => !v);
|
|
984
1180
|
}}
|
|
985
1181
|
>
|
|
986
|
-
<StIcon name={open ? '
|
|
1182
|
+
<StIcon name="chevron-right" className={'st-chev' + (open ? ' is-open' : '')} size={13} />
|
|
987
1183
|
</span>
|
|
988
1184
|
<span className="st-row-name">{displayName(primary.name)}</span>
|
|
989
1185
|
{oc > 0 && <span className="st-row-badge">{oc}</span>}
|
|
@@ -1155,6 +1351,8 @@ function Sidebar({
|
|
|
1155
1351
|
onDeleteBoard,
|
|
1156
1352
|
collapsed,
|
|
1157
1353
|
onCollapse,
|
|
1354
|
+
width,
|
|
1355
|
+
resizing,
|
|
1158
1356
|
}) {
|
|
1159
1357
|
const filteredGroups = useMemo(() => {
|
|
1160
1358
|
if (!search) return groups;
|
|
@@ -1200,14 +1398,19 @@ function Sidebar({
|
|
|
1200
1398
|
}, [filteredGroups]);
|
|
1201
1399
|
|
|
1202
1400
|
return (
|
|
1203
|
-
<nav
|
|
1401
|
+
<nav
|
|
1402
|
+
className={'st-sidebar' + (collapsed ? ' is-collapsed' : '') + (resizing ? ' is-resizing' : '')}
|
|
1403
|
+
style={collapsed || !width ? undefined : { width, flexBasis: width }}
|
|
1404
|
+
aria-label="Files"
|
|
1405
|
+
data-tour="sidebar"
|
|
1406
|
+
>
|
|
1204
1407
|
<div className="st-sb-hd">
|
|
1205
1408
|
<span className="st-sb-title">Files</span>
|
|
1206
1409
|
<div className="st-sb-hd-actions">
|
|
1207
1410
|
<button
|
|
1208
1411
|
type="button"
|
|
1209
1412
|
className="st-iconbtn"
|
|
1210
|
-
|
|
1413
|
+
data-tip="New blank brief board"
|
|
1211
1414
|
aria-label="New blank brief board"
|
|
1212
1415
|
aria-expanded={creating}
|
|
1213
1416
|
onClick={() => {
|
|
@@ -1217,7 +1420,10 @@ function Sidebar({
|
|
|
1217
1420
|
>
|
|
1218
1421
|
<StIcon name="plus" size={15} />
|
|
1219
1422
|
</button>
|
|
1220
|
-
<span
|
|
1423
|
+
<span
|
|
1424
|
+
className="st-live"
|
|
1425
|
+
data-tip={wsConnected ? 'live · file index synced' : 'reconnecting…'}
|
|
1426
|
+
>
|
|
1221
1427
|
<span className={'st-live-dot' + (wsConnected ? ' is-connected' : '')} aria-hidden="true" />
|
|
1222
1428
|
{htmlShown} / {htmlCount}
|
|
1223
1429
|
</span>
|
|
@@ -1226,7 +1432,7 @@ function Sidebar({
|
|
|
1226
1432
|
type="button"
|
|
1227
1433
|
className="st-iconbtn"
|
|
1228
1434
|
aria-label="Collapse sidebar"
|
|
1229
|
-
|
|
1435
|
+
data-tip="Collapse sidebar · T"
|
|
1230
1436
|
onClick={onCollapse}
|
|
1231
1437
|
>
|
|
1232
1438
|
<StIcon name="panel-left" size={15} />
|
|
@@ -1263,7 +1469,7 @@ function Sidebar({
|
|
|
1263
1469
|
type="button"
|
|
1264
1470
|
className="st-newboard-go"
|
|
1265
1471
|
disabled={newBusy || !newName.trim()}
|
|
1266
|
-
|
|
1472
|
+
data-tip="Create · Enter"
|
|
1267
1473
|
aria-label="Create brief board"
|
|
1268
1474
|
onClick={submitNewBoard}
|
|
1269
1475
|
>
|
|
@@ -1285,13 +1491,21 @@ function Sidebar({
|
|
|
1285
1491
|
placeholder="Search canvases…"
|
|
1286
1492
|
value={search}
|
|
1287
1493
|
onChange={(e) => setSearch(e.target.value)}
|
|
1494
|
+
onKeyDown={(e) => {
|
|
1495
|
+
// Esc — clear the filter first; a second Esc leaves the field.
|
|
1496
|
+
if (e.key === 'Escape') {
|
|
1497
|
+
e.preventDefault();
|
|
1498
|
+
if (search) setSearch('');
|
|
1499
|
+
else e.currentTarget.blur();
|
|
1500
|
+
}
|
|
1501
|
+
}}
|
|
1288
1502
|
aria-label="Filter files"
|
|
1289
1503
|
/>
|
|
1290
1504
|
{search ? (
|
|
1291
1505
|
<button
|
|
1292
1506
|
className="st-search-clear"
|
|
1293
1507
|
onClick={() => setSearch('')}
|
|
1294
|
-
|
|
1508
|
+
data-tip="Clear · Esc"
|
|
1295
1509
|
aria-label="Clear search"
|
|
1296
1510
|
>
|
|
1297
1511
|
×
|
|
@@ -1336,7 +1550,7 @@ function Sidebar({
|
|
|
1336
1550
|
aria-expanded={sectionOpen}
|
|
1337
1551
|
title={sectionOpen ? 'Collapse section' : 'Expand section'}
|
|
1338
1552
|
>
|
|
1339
|
-
<StIcon name={sectionOpen ? '
|
|
1553
|
+
<StIcon name="chevron-right" className={'st-chev' + (sectionOpen ? ' is-open' : '')} size={13} />
|
|
1340
1554
|
<span className="st-sec-name">{meta.title}</span>
|
|
1341
1555
|
{pill && <span className="st-pill">{pill}</span>}
|
|
1342
1556
|
</button>
|
|
@@ -1417,7 +1631,7 @@ function HelpModal({ open, onClose, onStartTour }) {
|
|
|
1417
1631
|
<span className="title" id="help-modal-title">
|
|
1418
1632
|
Help · shortcuts & commands
|
|
1419
1633
|
</span>
|
|
1420
|
-
<span className="sku">
|
|
1634
|
+
<span className="sku">MAUDE-DEV-SRV / v{MDCC_VERSION}</span>
|
|
1421
1635
|
{onStartTour && (
|
|
1422
1636
|
<button
|
|
1423
1637
|
type="button"
|
|
@@ -1506,22 +1720,28 @@ function HelpModal({ open, onClose, onStartTour }) {
|
|
|
1506
1720
|
</ul>
|
|
1507
1721
|
</details>
|
|
1508
1722
|
<details>
|
|
1509
|
-
<summary>
|
|
1723
|
+
<summary>Canvas & panels</summary>
|
|
1510
1724
|
<ul>
|
|
1511
1725
|
<li>
|
|
1512
|
-
click in tree <span>open
|
|
1726
|
+
click in tree <span>open canvas (replaces the active one)</span>
|
|
1513
1727
|
</li>
|
|
1514
1728
|
<li>
|
|
1515
|
-
|
|
1729
|
+
File ▸ Close canvas <span>clear the stage</span>
|
|
1516
1730
|
</li>
|
|
1517
1731
|
<li>
|
|
1518
|
-
<kbd>⌘R</kbd> <span>reload
|
|
1732
|
+
<kbd>⌘R</kbd> <span>reload canvas</span>
|
|
1519
1733
|
</li>
|
|
1520
1734
|
<li>
|
|
1521
1735
|
<kbd>/</kbd> <span>focus search</span>
|
|
1522
1736
|
</li>
|
|
1523
1737
|
<li>
|
|
1524
|
-
<kbd>⌘⇧M</kbd> <span>
|
|
1738
|
+
<kbd>⌘⇧M</kbd> <span>comments panel</span>
|
|
1739
|
+
</li>
|
|
1740
|
+
<li>
|
|
1741
|
+
<kbd>⌘⇧I</kbd> <span>inspector</span>
|
|
1742
|
+
</li>
|
|
1743
|
+
<li>
|
|
1744
|
+
<kbd>?</kbd> <span>keyboard-shortcuts cheat sheet</span>
|
|
1525
1745
|
</li>
|
|
1526
1746
|
</ul>
|
|
1527
1747
|
</details>
|
|
@@ -1661,7 +1881,7 @@ function HelpModal({ open, onClose, onStartTour }) {
|
|
|
1661
1881
|
<details>
|
|
1662
1882
|
<summary>Pin-to-element flow</summary>
|
|
1663
1883
|
<ol>
|
|
1664
|
-
<li>Open canvas
|
|
1884
|
+
<li>Open a canvas</li>
|
|
1665
1885
|
<li>
|
|
1666
1886
|
<kbd>⌘</kbd>+click element
|
|
1667
1887
|
</li>
|
|
@@ -1701,13 +1921,152 @@ function HelpModal({ open, onClose, onStartTour }) {
|
|
|
1701
1921
|
);
|
|
1702
1922
|
}
|
|
1703
1923
|
|
|
1924
|
+
// ───────── Keyboard-shortcuts overlay (DS components-shortcuts-overlay) ─────
|
|
1925
|
+
//
|
|
1926
|
+
// The ? cheat-sheet: dim scrim, shared panel material, four dense mono-headed
|
|
1927
|
+
// columns, Esc chip in the footer. REAL bindings only — every row here is
|
|
1928
|
+
// wired in the shell handler, the canvas input-router, or canvas-lib's
|
|
1929
|
+
// viewport controller. Scope chips mark the rows that need canvas focus.
|
|
1930
|
+
|
|
1931
|
+
const SHORTCUT_GROUPS = [
|
|
1932
|
+
{
|
|
1933
|
+
id: 'canvas',
|
|
1934
|
+
label: 'Canvas',
|
|
1935
|
+
items: [
|
|
1936
|
+
{ label: 'Command palette', kbd: '⌘ K' },
|
|
1937
|
+
{ label: 'New brief board', kbd: 'N' },
|
|
1938
|
+
{ label: 'Export…', kbd: '⇧ ⌘ E' },
|
|
1939
|
+
{ label: 'Handoff to production', kbd: '⇧ ⌘ H' },
|
|
1940
|
+
{ label: 'Reload canvas', kbd: '⌘ R' },
|
|
1941
|
+
{ label: 'Search files', kbd: '/', alt: '⌘ F' },
|
|
1942
|
+
],
|
|
1943
|
+
},
|
|
1944
|
+
{
|
|
1945
|
+
id: 'tools',
|
|
1946
|
+
label: 'Tools · canvas focus',
|
|
1947
|
+
items: [
|
|
1948
|
+
{ label: 'Move · Hand · Comment', kbd: 'V', alt: 'H / C' },
|
|
1949
|
+
{ label: 'Pen · Highlighter · Eraser', kbd: 'B', alt: 'I / E' },
|
|
1950
|
+
{ label: 'Shape · Arrow', kbd: 'R', alt: 'A' },
|
|
1951
|
+
{ label: 'Sticky · Text · Section', kbd: 'N', alt: 'T / ⇧S' },
|
|
1952
|
+
{ label: 'Undo / redo', kbd: '⌘ Z', alt: '⇧ ⌘ Z' },
|
|
1953
|
+
],
|
|
1954
|
+
},
|
|
1955
|
+
{
|
|
1956
|
+
id: 'selection',
|
|
1957
|
+
label: 'Selection & zoom',
|
|
1958
|
+
items: [
|
|
1959
|
+
{ label: 'Select element', kbd: '⌘ click' },
|
|
1960
|
+
{ label: 'Add to selection', kbd: '⌘ ⇧ click' },
|
|
1961
|
+
{ label: 'Preview deepest', kbd: '⌘ hover' },
|
|
1962
|
+
{ label: 'Deselect · close menu', kbd: 'Esc' },
|
|
1963
|
+
{ label: 'Zoom in / out', kbd: '⌘ +', alt: '⌘ −' },
|
|
1964
|
+
{ label: 'Fit · actual size', kbd: '⌘ 0', alt: '⌘ 1' },
|
|
1965
|
+
],
|
|
1966
|
+
},
|
|
1967
|
+
{
|
|
1968
|
+
id: 'view',
|
|
1969
|
+
label: 'View',
|
|
1970
|
+
items: [
|
|
1971
|
+
{ label: 'Project tree', kbd: 'T' },
|
|
1972
|
+
{ label: 'Design system view', kbd: 'S' },
|
|
1973
|
+
{ label: 'Inspector', kbd: '⌘ ⇧ I' },
|
|
1974
|
+
{ label: 'Comments sidebar', kbd: '⌘ ⇧ M' },
|
|
1975
|
+
{ label: 'Annotations', kbd: '⇧ P' },
|
|
1976
|
+
{ label: 'Hidden files', kbd: 'H' },
|
|
1977
|
+
{ label: 'This cheat sheet · help', kbd: '?', alt: 'F1' },
|
|
1978
|
+
],
|
|
1979
|
+
},
|
|
1980
|
+
];
|
|
1981
|
+
|
|
1982
|
+
function ShortcutCombo({ kbd, alt }) {
|
|
1983
|
+
const combo = (s, key) => (
|
|
1984
|
+
<span className="so-combo" key={key}>
|
|
1985
|
+
{s.split(' ').map((k, i) => (
|
|
1986
|
+
<Kbd key={`${k}-${i}`}>{k}</Kbd>
|
|
1987
|
+
))}
|
|
1988
|
+
</span>
|
|
1989
|
+
);
|
|
1990
|
+
return (
|
|
1991
|
+
<span className="so-combos">
|
|
1992
|
+
{combo(kbd, 'main')}
|
|
1993
|
+
{alt
|
|
1994
|
+
? alt.split(' / ').map((a) => (
|
|
1995
|
+
<Fragment key={a}>
|
|
1996
|
+
<span className="so-or">/</span>
|
|
1997
|
+
{combo(a, a)}
|
|
1998
|
+
</Fragment>
|
|
1999
|
+
))
|
|
2000
|
+
: null}
|
|
2001
|
+
</span>
|
|
2002
|
+
);
|
|
2003
|
+
}
|
|
2004
|
+
|
|
2005
|
+
function ShortcutsOverlay({ open, onClose }) {
|
|
2006
|
+
useEffect(() => {
|
|
2007
|
+
if (!open) return;
|
|
2008
|
+
function onKey(e) {
|
|
2009
|
+
if (e.key === 'Escape') {
|
|
2010
|
+
e.preventDefault();
|
|
2011
|
+
onClose();
|
|
2012
|
+
}
|
|
2013
|
+
}
|
|
2014
|
+
window.addEventListener('keydown', onKey);
|
|
2015
|
+
return () => window.removeEventListener('keydown', onKey);
|
|
2016
|
+
}, [open, onClose]);
|
|
2017
|
+
if (!open) return null;
|
|
2018
|
+
const bindings = SHORTCUT_GROUPS.reduce((n, g) => n + g.items.length, 0);
|
|
2019
|
+
return (
|
|
2020
|
+
<div
|
|
2021
|
+
className="st-scrim"
|
|
2022
|
+
role="presentation"
|
|
2023
|
+
onMouseDown={(e) => {
|
|
2024
|
+
if (e.target === e.currentTarget) onClose();
|
|
2025
|
+
}}
|
|
2026
|
+
>
|
|
2027
|
+
<div className="so-overlay" role="dialog" aria-modal="true" aria-label="Keyboard shortcuts">
|
|
2028
|
+
<div className="so-overlay-hd">
|
|
2029
|
+
<span className="so-title">Keyboard shortcuts</span>
|
|
2030
|
+
<span className="so-trigger">
|
|
2031
|
+
press <Kbd>?</Kbd> to open
|
|
2032
|
+
</span>
|
|
2033
|
+
</div>
|
|
2034
|
+
<div className="so-columns">
|
|
2035
|
+
{SHORTCUT_GROUPS.map((g) => (
|
|
2036
|
+
<section key={g.id} className={'so-section so-section--' + g.id}>
|
|
2037
|
+
<h3 className="so-section-hd">{g.label}</h3>
|
|
2038
|
+
<dl className="so-list">
|
|
2039
|
+
{g.items.map((it) => (
|
|
2040
|
+
<div key={it.label} className="so-pair">
|
|
2041
|
+
<dt>{it.label}</dt>
|
|
2042
|
+
<dd>
|
|
2043
|
+
<ShortcutCombo kbd={it.kbd} alt={it.alt} />
|
|
2044
|
+
</dd>
|
|
2045
|
+
</div>
|
|
2046
|
+
))}
|
|
2047
|
+
</dl>
|
|
2048
|
+
</section>
|
|
2049
|
+
))}
|
|
2050
|
+
</div>
|
|
2051
|
+
<div className="so-overlay-ft">
|
|
2052
|
+
<span>
|
|
2053
|
+
close with <Kbd>Esc</Kbd>
|
|
2054
|
+
</span>
|
|
2055
|
+
<span className="so-count">
|
|
2056
|
+
{bindings} bindings · {SHORTCUT_GROUPS.length} groups
|
|
2057
|
+
</span>
|
|
2058
|
+
</div>
|
|
2059
|
+
</div>
|
|
2060
|
+
</div>
|
|
2061
|
+
);
|
|
2062
|
+
}
|
|
2063
|
+
|
|
1704
2064
|
// ───────── Menubar (CV-01/CV-08 top chrome) ─────────
|
|
1705
2065
|
//
|
|
1706
2066
|
// Replaces the legacy `.header` action-button toolbar. Mirrors the shared
|
|
1707
2067
|
// Menubar component from .design/ui/Canvas Viewport.html — brand · menus ·
|
|
1708
|
-
// status. View dropdown is wired to the
|
|
1709
|
-
//
|
|
1710
|
-
// lands.
|
|
2068
|
+
// status. View dropdown is wired to the panels + zoom that exist today;
|
|
2069
|
+
// Presentation Mode stays phase-tagged until it ships.
|
|
1711
2070
|
|
|
1712
2071
|
const MENU_NAMES = ['File', 'Edit', 'View', 'Selection', 'Tools', 'Help'];
|
|
1713
2072
|
|
|
@@ -1729,7 +2088,7 @@ function useDropdownClose(onClose) {
|
|
|
1729
2088
|
}, [onClose]);
|
|
1730
2089
|
}
|
|
1731
2090
|
|
|
1732
|
-
function ViewDropdown({ panels, onToggle, onClose }) {
|
|
2091
|
+
function ViewDropdown({ panels, onToggle, onClose, onZoom, hasCanvas }) {
|
|
1733
2092
|
useDropdownClose(onClose);
|
|
1734
2093
|
return (
|
|
1735
2094
|
<div className="st-dropdown" role="menu" aria-label="View" style={{ left: 152 }}>
|
|
@@ -1752,117 +2111,55 @@ function ViewDropdown({ panels, onToggle, onClose }) {
|
|
|
1752
2111
|
<span className="st-dd-check">{p.checked ? <StIcon name="check" size={13} /> : null}</span>
|
|
1753
2112
|
<span>{p.label}</span>
|
|
1754
2113
|
</span>
|
|
1755
|
-
{p.phase ?
|
|
2114
|
+
{p.phase ? (
|
|
2115
|
+
<span className="st-dd-phase">{p.phase}</span>
|
|
2116
|
+
) : p.shortcut ? (
|
|
2117
|
+
<Kbd>{p.shortcut}</Kbd>
|
|
2118
|
+
) : null}
|
|
1756
2119
|
</button>
|
|
1757
2120
|
))}
|
|
1758
2121
|
<div className="st-dd-sep" />
|
|
1759
2122
|
<div className="st-dd-hd">Zoom</div>
|
|
1760
2123
|
{[
|
|
1761
|
-
{ label: 'Zoom In', shortcut: '⌘ +' },
|
|
1762
|
-
{ label: 'Zoom Out', shortcut: '⌘ −' },
|
|
1763
|
-
{ label: 'Fit to Screen', shortcut: '⌘ 0' },
|
|
1764
|
-
{ label: 'Actual Size · 100 %', shortcut: '
|
|
2124
|
+
{ op: 'in', label: 'Zoom In', shortcut: '⌘ +' },
|
|
2125
|
+
{ op: 'out', label: 'Zoom Out', shortcut: '⌘ −' },
|
|
2126
|
+
{ op: 'fit', label: 'Fit to Screen', shortcut: '⌘ 0' },
|
|
2127
|
+
{ op: 'actual', label: 'Actual Size · 100 %', shortcut: '⌘ 1' },
|
|
1765
2128
|
].map((z) => (
|
|
1766
|
-
<button key={z.label} type="button" role="menuitem" className="st-dd-item" aria-disabled="true">
|
|
1767
|
-
<span className="st-dd-lead">
|
|
1768
|
-
<span className="st-dd-check" />
|
|
1769
|
-
<span>{z.label}</span>
|
|
1770
|
-
</span>
|
|
1771
|
-
<span className="st-dd-phase">Phase 4</span>
|
|
1772
|
-
</button>
|
|
1773
|
-
))}
|
|
1774
|
-
</div>
|
|
1775
|
-
);
|
|
1776
|
-
}
|
|
1777
|
-
|
|
1778
|
-
// ─────────────────────────────────────────────────────────────────────────────
|
|
1779
|
-
// Phase 5.1 — Selection + Tools dropdowns (mirror ViewDropdown shape).
|
|
1780
|
-
|
|
1781
|
-
function SelectionDropdown({ onAction, onClose }) {
|
|
1782
|
-
useDropdownClose(onClose);
|
|
1783
|
-
const items = [
|
|
1784
|
-
{ id: 'deselect-all', label: 'Deselect all', shortcut: 'Esc' },
|
|
1785
|
-
{ id: 'select-all-annotations', label: 'Select all annotations', shortcut: '⌘ ⇧ A' },
|
|
1786
|
-
];
|
|
1787
|
-
return (
|
|
1788
|
-
<div className="st-dropdown" role="menu" aria-label="Selection" style={{ left: 214 }}>
|
|
1789
|
-
{items.map((it) => (
|
|
1790
|
-
<button
|
|
1791
|
-
key={it.id}
|
|
1792
|
-
type="button"
|
|
1793
|
-
role="menuitem"
|
|
1794
|
-
className="st-dd-item"
|
|
1795
|
-
onClick={() => {
|
|
1796
|
-
onAction(it.id);
|
|
1797
|
-
onClose();
|
|
1798
|
-
}}
|
|
1799
|
-
>
|
|
1800
|
-
<span className="st-dd-lead">
|
|
1801
|
-
<span className="st-dd-check" />
|
|
1802
|
-
<span>{it.label}</span>
|
|
1803
|
-
</span>
|
|
1804
|
-
<Kbd>{it.shortcut}</Kbd>
|
|
1805
|
-
</button>
|
|
1806
|
-
))}
|
|
1807
|
-
</div>
|
|
1808
|
-
);
|
|
1809
|
-
}
|
|
1810
|
-
|
|
1811
|
-
function ToolsDropdown({ onAction, onClose }) {
|
|
1812
|
-
useDropdownClose(onClose);
|
|
1813
|
-
// Mirrors DEFAULT_TOOLS in apps/studio/use-tool-mode.tsx —
|
|
1814
|
-
// kept in sync by hand because the menubar lives in the dev-server shell
|
|
1815
|
-
// (no shared bundle with the canvas iframes).
|
|
1816
|
-
const tools = [
|
|
1817
|
-
{ id: 'move', label: 'Move', shortcut: 'V' },
|
|
1818
|
-
{ id: 'hand', label: 'Hand', shortcut: 'H' },
|
|
1819
|
-
{ id: 'comment', label: 'Comment', shortcut: 'C' },
|
|
1820
|
-
{ id: 'pen', label: 'Pen', shortcut: 'B' },
|
|
1821
|
-
{ id: 'rect', label: 'Rect', shortcut: 'R' },
|
|
1822
|
-
{ id: 'ellipse', label: 'Ellipse', shortcut: 'O' },
|
|
1823
|
-
{ id: 'sticky', label: 'Sticky', shortcut: 'N' },
|
|
1824
|
-
{ id: 'arrow', label: 'Arrow', shortcut: 'A' },
|
|
1825
|
-
{ id: 'text', label: 'Text', shortcut: 'T' },
|
|
1826
|
-
{ id: 'eraser', label: 'Eraser', shortcut: 'E' },
|
|
1827
|
-
];
|
|
1828
|
-
return (
|
|
1829
|
-
<div className="st-dropdown" role="menu" aria-label="Tools" style={{ left: 290 }}>
|
|
1830
|
-
<div className="st-dd-hd">Tool palette</div>
|
|
1831
|
-
{tools.map((t) => (
|
|
1832
2129
|
<button
|
|
1833
|
-
key={
|
|
2130
|
+
key={z.label}
|
|
1834
2131
|
type="button"
|
|
1835
2132
|
role="menuitem"
|
|
1836
2133
|
className="st-dd-item"
|
|
2134
|
+
aria-disabled={hasCanvas ? undefined : 'true'}
|
|
1837
2135
|
onClick={() => {
|
|
1838
|
-
|
|
2136
|
+
if (!hasCanvas) return;
|
|
2137
|
+
onZoom?.(z.op);
|
|
1839
2138
|
onClose();
|
|
1840
2139
|
}}
|
|
1841
2140
|
>
|
|
1842
2141
|
<span className="st-dd-lead">
|
|
1843
2142
|
<span className="st-dd-check" />
|
|
1844
|
-
<span>{
|
|
2143
|
+
<span>{z.label}</span>
|
|
1845
2144
|
</span>
|
|
1846
|
-
<Kbd>{
|
|
2145
|
+
<Kbd>{z.shortcut}</Kbd>
|
|
1847
2146
|
</button>
|
|
1848
2147
|
))}
|
|
1849
2148
|
</div>
|
|
1850
2149
|
);
|
|
1851
2150
|
}
|
|
1852
2151
|
|
|
1853
|
-
//
|
|
1854
|
-
//
|
|
1855
|
-
|
|
2152
|
+
// Help dropdown — cheat sheet · deep help · tour · what's new.
|
|
2153
|
+
// Shared menubar dropdown — File / Edit / Selection / Tools / Help all render
|
|
2154
|
+
// the identical {id,label,shortcut,sep?,disabled?} list over the same button
|
|
2155
|
+
// skeleton, differing only in aria-label, left offset, and an optional header.
|
|
2156
|
+
// (ViewDropdown stays separate — its checkbox/phase/zoom-op rows genuinely
|
|
2157
|
+
// diverge.) Per the /flow:done simplifier pass — collapsed 5 near-dupes.
|
|
2158
|
+
function DropdownMenu({ label, left, header, items, onAction, onClose }) {
|
|
1856
2159
|
useDropdownClose(onClose);
|
|
1857
|
-
const items = [
|
|
1858
|
-
{ id: 'new', label: 'New canvas…', shortcut: '⌘N' },
|
|
1859
|
-
{ id: 'export', label: 'Export…', shortcut: '⇧⌘E' },
|
|
1860
|
-
{ id: 'handoff', label: 'Handoff to production', shortcut: '⇧⌘H' },
|
|
1861
|
-
{ sep: true },
|
|
1862
|
-
{ id: 'reload', label: 'Reload canvas', shortcut: '⌘R' },
|
|
1863
|
-
];
|
|
1864
2160
|
return (
|
|
1865
|
-
<div className="st-dropdown" role="menu" aria-label=
|
|
2161
|
+
<div className="st-dropdown" role="menu" aria-label={label} style={{ left }}>
|
|
2162
|
+
{header ? <div className="st-dd-hd">{header}</div> : null}
|
|
1866
2163
|
{items.map((it, i) =>
|
|
1867
2164
|
it.sep ? (
|
|
1868
2165
|
<div key={'s' + i} className="st-dd-sep" />
|
|
@@ -1872,7 +2169,9 @@ function FileDropdown({ onAction, onClose }) {
|
|
|
1872
2169
|
type="button"
|
|
1873
2170
|
role="menuitem"
|
|
1874
2171
|
className="st-dd-item"
|
|
2172
|
+
aria-disabled={it.disabled ? 'true' : undefined}
|
|
1875
2173
|
onClick={() => {
|
|
2174
|
+
if (it.disabled) return;
|
|
1876
2175
|
onAction(it.id);
|
|
1877
2176
|
onClose();
|
|
1878
2177
|
}}
|
|
@@ -1881,7 +2180,7 @@ function FileDropdown({ onAction, onClose }) {
|
|
|
1881
2180
|
<span className="st-dd-check" />
|
|
1882
2181
|
<span>{it.label}</span>
|
|
1883
2182
|
</span>
|
|
1884
|
-
<Kbd>{it.shortcut}</Kbd>
|
|
2183
|
+
{it.shortcut ? <Kbd>{it.shortcut}</Kbd> : null}
|
|
1885
2184
|
</button>
|
|
1886
2185
|
)
|
|
1887
2186
|
)}
|
|
@@ -1889,40 +2188,103 @@ function FileDropdown({ onAction, onClose }) {
|
|
|
1889
2188
|
);
|
|
1890
2189
|
}
|
|
1891
2190
|
|
|
2191
|
+
function HelpDropdown({ onAction, onClose }) {
|
|
2192
|
+
return (
|
|
2193
|
+
<DropdownMenu
|
|
2194
|
+
label="Help"
|
|
2195
|
+
left={320}
|
|
2196
|
+
onAction={onAction}
|
|
2197
|
+
onClose={onClose}
|
|
2198
|
+
items={[
|
|
2199
|
+
{ id: 'shortcuts', label: 'Keyboard shortcuts', shortcut: '?' },
|
|
2200
|
+
{ id: 'help', label: 'Help · commands & flows', shortcut: 'F1' },
|
|
2201
|
+
{ sep: true },
|
|
2202
|
+
{ id: 'tour', label: 'Take the tour' },
|
|
2203
|
+
{ id: 'whatsnew', label: "What's new" },
|
|
2204
|
+
]}
|
|
2205
|
+
/>
|
|
2206
|
+
);
|
|
2207
|
+
}
|
|
2208
|
+
|
|
2209
|
+
function SelectionDropdown({ onAction, onClose }) {
|
|
2210
|
+
return (
|
|
2211
|
+
<DropdownMenu
|
|
2212
|
+
label="Selection"
|
|
2213
|
+
left={214}
|
|
2214
|
+
onAction={onAction}
|
|
2215
|
+
onClose={onClose}
|
|
2216
|
+
items={[
|
|
2217
|
+
{ id: 'deselect-all', label: 'Deselect all', shortcut: 'Esc' },
|
|
2218
|
+
{ id: 'select-all-annotations', label: 'Select all annotations', shortcut: '⌘ ⇧ A' },
|
|
2219
|
+
]}
|
|
2220
|
+
/>
|
|
2221
|
+
);
|
|
2222
|
+
}
|
|
2223
|
+
|
|
2224
|
+
function ToolsDropdown({ onAction, onClose }) {
|
|
2225
|
+
// Mirrors DEFAULT_TOOLS in apps/studio/use-tool-mode.tsx — kept in sync by
|
|
2226
|
+
// hand because the menubar lives in the dev-server shell (no shared bundle
|
|
2227
|
+
// with the canvas iframes).
|
|
2228
|
+
return (
|
|
2229
|
+
<DropdownMenu
|
|
2230
|
+
label="Tools"
|
|
2231
|
+
left={290}
|
|
2232
|
+
header="Tool palette"
|
|
2233
|
+
onAction={onAction}
|
|
2234
|
+
onClose={onClose}
|
|
2235
|
+
items={[
|
|
2236
|
+
{ id: 'move', label: 'Move', shortcut: 'V' },
|
|
2237
|
+
{ id: 'hand', label: 'Hand', shortcut: 'H' },
|
|
2238
|
+
{ id: 'comment', label: 'Comment', shortcut: 'C' },
|
|
2239
|
+
{ id: 'pen', label: 'Pen', shortcut: 'B' },
|
|
2240
|
+
{ id: 'rect', label: 'Rect', shortcut: 'R' },
|
|
2241
|
+
{ id: 'ellipse', label: 'Ellipse', shortcut: 'O' },
|
|
2242
|
+
{ id: 'sticky', label: 'Sticky', shortcut: 'N' },
|
|
2243
|
+
{ id: 'arrow', label: 'Arrow', shortcut: 'A' },
|
|
2244
|
+
{ id: 'text', label: 'Text', shortcut: 'T' },
|
|
2245
|
+
{ id: 'eraser', label: 'Eraser', shortcut: 'E' },
|
|
2246
|
+
]}
|
|
2247
|
+
/>
|
|
2248
|
+
);
|
|
2249
|
+
}
|
|
2250
|
+
|
|
2251
|
+
// Plan C follow-up — File + Edit menus, previously inert. Both dispatch to real
|
|
2252
|
+
// shell flows (File) or the in-canvas undo stack / selection bridges (Edit).
|
|
2253
|
+
function FileDropdown({ onAction, onClose, hasCanvas }) {
|
|
2254
|
+
return (
|
|
2255
|
+
<DropdownMenu
|
|
2256
|
+
label="File"
|
|
2257
|
+
left={40}
|
|
2258
|
+
onAction={onAction}
|
|
2259
|
+
onClose={onClose}
|
|
2260
|
+
items={[
|
|
2261
|
+
// Bare N — the browser reserves ⌘N (New Window) and never delivers it.
|
|
2262
|
+
{ id: 'new', label: 'New canvas…', shortcut: 'N' },
|
|
2263
|
+
{ id: 'export', label: 'Export…', shortcut: '⇧⌘E' },
|
|
2264
|
+
{ id: 'handoff', label: 'Handoff to production', shortcut: '⇧⌘H' },
|
|
2265
|
+
{ sep: true },
|
|
2266
|
+
{ id: 'reload', label: 'Reload canvas', shortcut: '⌘R', disabled: !hasCanvas },
|
|
2267
|
+
{ id: 'close', label: 'Close canvas', disabled: !hasCanvas },
|
|
2268
|
+
]}
|
|
2269
|
+
/>
|
|
2270
|
+
);
|
|
2271
|
+
}
|
|
2272
|
+
|
|
1892
2273
|
function EditDropdown({ onAction, onClose }) {
|
|
1893
|
-
useDropdownClose(onClose);
|
|
1894
|
-
const items = [
|
|
1895
|
-
{ id: 'undo', label: 'Undo', shortcut: '⌘Z' },
|
|
1896
|
-
{ id: 'redo', label: 'Redo', shortcut: '⇧⌘Z' },
|
|
1897
|
-
{ sep: true },
|
|
1898
|
-
{ id: 'deselect-all', label: 'Deselect all', shortcut: 'Esc' },
|
|
1899
|
-
{ id: 'select-all-annotations', label: 'Select all annotations', shortcut: '⇧⌘A' },
|
|
1900
|
-
];
|
|
1901
2274
|
return (
|
|
1902
|
-
<
|
|
1903
|
-
|
|
1904
|
-
|
|
1905
|
-
|
|
1906
|
-
|
|
1907
|
-
|
|
1908
|
-
|
|
1909
|
-
|
|
1910
|
-
|
|
1911
|
-
|
|
1912
|
-
|
|
1913
|
-
|
|
1914
|
-
|
|
1915
|
-
}}
|
|
1916
|
-
>
|
|
1917
|
-
<span className="st-dd-lead">
|
|
1918
|
-
<span className="st-dd-check" />
|
|
1919
|
-
<span>{it.label}</span>
|
|
1920
|
-
</span>
|
|
1921
|
-
<Kbd>{it.shortcut}</Kbd>
|
|
1922
|
-
</button>
|
|
1923
|
-
)
|
|
1924
|
-
)}
|
|
1925
|
-
</div>
|
|
2275
|
+
<DropdownMenu
|
|
2276
|
+
label="Edit"
|
|
2277
|
+
left={90}
|
|
2278
|
+
onAction={onAction}
|
|
2279
|
+
onClose={onClose}
|
|
2280
|
+
items={[
|
|
2281
|
+
{ id: 'undo', label: 'Undo', shortcut: '⌘Z' },
|
|
2282
|
+
{ id: 'redo', label: 'Redo', shortcut: '⇧⌘Z' },
|
|
2283
|
+
{ sep: true },
|
|
2284
|
+
{ id: 'deselect-all', label: 'Deselect all', shortcut: 'Esc' },
|
|
2285
|
+
{ id: 'select-all-annotations', label: 'Select all annotations', shortcut: '⇧⌘A' },
|
|
2286
|
+
]}
|
|
2287
|
+
/>
|
|
1926
2288
|
);
|
|
1927
2289
|
}
|
|
1928
2290
|
|
|
@@ -1940,6 +2302,8 @@ function Menubar({
|
|
|
1940
2302
|
showHidden,
|
|
1941
2303
|
onToggleShowHidden,
|
|
1942
2304
|
onOpenHelp,
|
|
2305
|
+
onOpenShortcuts,
|
|
2306
|
+
onStartTour,
|
|
1943
2307
|
annotationsVisible,
|
|
1944
2308
|
onToggleAnnotations,
|
|
1945
2309
|
postToActiveCanvas,
|
|
@@ -1948,10 +2312,13 @@ function Menubar({
|
|
|
1948
2312
|
artboardCount = 0,
|
|
1949
2313
|
presence = null,
|
|
1950
2314
|
inspectorOpen,
|
|
2315
|
+
inspectorTab,
|
|
1951
2316
|
onToggleInspector,
|
|
2317
|
+
onOpenLayers,
|
|
1952
2318
|
onNewCanvas,
|
|
1953
2319
|
onOpenExport,
|
|
1954
2320
|
onReload,
|
|
2321
|
+
onCloseCanvas,
|
|
1955
2322
|
}) {
|
|
1956
2323
|
const isSystem = activePath === SYSTEM_TAB;
|
|
1957
2324
|
const stamp = isSystem ? 'SYSTEM' : activePath ? 'CANVAS' : 'IDLE';
|
|
@@ -1981,8 +2348,20 @@ function Menubar({
|
|
|
1981
2348
|
checked: showHidden,
|
|
1982
2349
|
disabled: false,
|
|
1983
2350
|
},
|
|
1984
|
-
{
|
|
1985
|
-
|
|
2351
|
+
{
|
|
2352
|
+
id: 'layers',
|
|
2353
|
+
label: 'Layers',
|
|
2354
|
+
shortcut: '',
|
|
2355
|
+
checked: inspectorOpen && inspectorTab === 'layers',
|
|
2356
|
+
disabled: false,
|
|
2357
|
+
},
|
|
2358
|
+
{
|
|
2359
|
+
id: 'inspector',
|
|
2360
|
+
label: 'Inspector',
|
|
2361
|
+
shortcut: '⌘ ⇧ I',
|
|
2362
|
+
checked: inspectorOpen,
|
|
2363
|
+
disabled: false,
|
|
2364
|
+
},
|
|
1986
2365
|
{
|
|
1987
2366
|
id: 'annotate',
|
|
1988
2367
|
label: 'Annotations',
|
|
@@ -1993,25 +2372,67 @@ function Menubar({
|
|
|
1993
2372
|
{ id: 'present', label: 'Presentation Mode', phase: 'Phase 6', disabled: true },
|
|
1994
2373
|
];
|
|
1995
2374
|
|
|
1996
|
-
const DROPDOWN_MENUS = ['file', 'edit', 'view', 'selection', 'tools'];
|
|
2375
|
+
const DROPDOWN_MENUS = ['file', 'edit', 'view', 'selection', 'tools', 'help'];
|
|
1997
2376
|
function onMenuClick(key) {
|
|
1998
2377
|
if (DROPDOWN_MENUS.includes(key)) {
|
|
1999
2378
|
setOpenMenu(openMenu === key ? null : key);
|
|
2000
|
-
} else if (key === 'help') {
|
|
2001
|
-
setOpenMenu(null);
|
|
2002
|
-
onOpenHelp();
|
|
2003
2379
|
}
|
|
2004
2380
|
}
|
|
2005
2381
|
|
|
2382
|
+
// Keyboard menubar (native-menu parity): while a dropdown is open, ↑/↓ rove
|
|
2383
|
+
// its items, ←/→ switch to the adjacent menu, Home/End jump, Esc returns
|
|
2384
|
+
// focus to the trigger (useDropdownClose handles the close itself).
|
|
2385
|
+
useEffect(() => {
|
|
2386
|
+
if (!openMenu || !DROPDOWN_MENUS.includes(openMenu)) return;
|
|
2387
|
+
// Move focus into the menu so ↑/↓ work immediately after a click.
|
|
2388
|
+
const t = setTimeout(() => {
|
|
2389
|
+
document
|
|
2390
|
+
.querySelector('.st-dropdown [role="menuitem"]:not([aria-disabled="true"])')
|
|
2391
|
+
?.focus();
|
|
2392
|
+
}, 0);
|
|
2393
|
+
function onKey(e) {
|
|
2394
|
+
const items = [
|
|
2395
|
+
...document.querySelectorAll('.st-dropdown [role="menuitem"]:not([aria-disabled="true"])'),
|
|
2396
|
+
];
|
|
2397
|
+
if (!items.length) return;
|
|
2398
|
+
const idx = items.indexOf(document.activeElement);
|
|
2399
|
+
if (e.key === 'ArrowDown') {
|
|
2400
|
+
e.preventDefault();
|
|
2401
|
+
items[(idx + 1) % items.length].focus();
|
|
2402
|
+
} else if (e.key === 'ArrowUp') {
|
|
2403
|
+
e.preventDefault();
|
|
2404
|
+
items[(idx - 1 + items.length) % items.length].focus();
|
|
2405
|
+
} else if (e.key === 'Home') {
|
|
2406
|
+
e.preventDefault();
|
|
2407
|
+
items[0].focus();
|
|
2408
|
+
} else if (e.key === 'End') {
|
|
2409
|
+
e.preventDefault();
|
|
2410
|
+
items[items.length - 1].focus();
|
|
2411
|
+
} else if (e.key === 'ArrowRight' || e.key === 'ArrowLeft') {
|
|
2412
|
+
e.preventDefault();
|
|
2413
|
+
const dir = e.key === 'ArrowRight' ? 1 : -1;
|
|
2414
|
+
const cur = DROPDOWN_MENUS.indexOf(openMenu);
|
|
2415
|
+
setOpenMenu(DROPDOWN_MENUS[(cur + dir + DROPDOWN_MENUS.length) % DROPDOWN_MENUS.length]);
|
|
2416
|
+
} else if (e.key === 'Escape') {
|
|
2417
|
+
document.querySelector('.st-menu[aria-expanded="true"]')?.focus();
|
|
2418
|
+
}
|
|
2419
|
+
}
|
|
2420
|
+
window.addEventListener('keydown', onKey);
|
|
2421
|
+
return () => {
|
|
2422
|
+
clearTimeout(t);
|
|
2423
|
+
window.removeEventListener('keydown', onKey);
|
|
2424
|
+
};
|
|
2425
|
+
}, [openMenu, setOpenMenu]);
|
|
2426
|
+
|
|
2006
2427
|
return (
|
|
2007
2428
|
<header className="st-menubar" role="menubar" aria-label="Application menubar">
|
|
2008
|
-
<span className="st-brand">
|
|
2429
|
+
<span className="st-brand" data-tour="brand">
|
|
2009
2430
|
<span className="st-brand-mark">
|
|
2010
2431
|
<svg viewBox="0 0 32 32" width="100%" height="100%" fill="none" aria-hidden="true"><path d="M16 5l2.8 8.2L27 16l-8.2 2.8L16 27l-2.8-8.2L5 16l8.2-2.8z" fill="currentColor" /></svg>
|
|
2011
2432
|
</span>
|
|
2012
2433
|
<span className="st-brand-name">maude</span>
|
|
2013
2434
|
</span>
|
|
2014
|
-
<nav className="st-menus" aria-label="Application menus">
|
|
2435
|
+
<nav className="st-menus" aria-label="Application menus" data-tour="menus">
|
|
2015
2436
|
{MENU_NAMES.map((name) => {
|
|
2016
2437
|
const key = name.toLowerCase();
|
|
2017
2438
|
const hasDropdown = DROPDOWN_MENUS.includes(key);
|
|
@@ -2040,11 +2461,13 @@ function Menubar({
|
|
|
2040
2461
|
</nav>
|
|
2041
2462
|
{openMenu === 'file' && (
|
|
2042
2463
|
<FileDropdown
|
|
2464
|
+
hasCanvas={!!activePath}
|
|
2043
2465
|
onAction={(id) => {
|
|
2044
2466
|
if (id === 'new') onNewCanvas?.();
|
|
2045
2467
|
else if (id === 'export') onOpenExport?.('export');
|
|
2046
2468
|
else if (id === 'handoff') onOpenExport?.('handoff');
|
|
2047
2469
|
else if (id === 'reload') onReload?.();
|
|
2470
|
+
else if (id === 'close') onCloseCanvas?.();
|
|
2048
2471
|
}}
|
|
2049
2472
|
onClose={() => setOpenMenu(null)}
|
|
2050
2473
|
/>
|
|
@@ -2070,7 +2493,10 @@ function Menubar({
|
|
|
2070
2493
|
else if (id === 'hidden') onToggleShowHidden();
|
|
2071
2494
|
else if (id === 'annotate') onToggleAnnotations();
|
|
2072
2495
|
else if (id === 'inspector') onToggleInspector();
|
|
2496
|
+
else if (id === 'layers') onOpenLayers?.();
|
|
2073
2497
|
}}
|
|
2498
|
+
onZoom={(op) => postToActiveCanvas({ dgn: 'zoom', op })}
|
|
2499
|
+
hasCanvas={!!activePath && !isSystem}
|
|
2074
2500
|
onClose={() => setOpenMenu(null)}
|
|
2075
2501
|
/>
|
|
2076
2502
|
)}
|
|
@@ -2090,14 +2516,26 @@ function Menubar({
|
|
|
2090
2516
|
onClose={() => setOpenMenu(null)}
|
|
2091
2517
|
/>
|
|
2092
2518
|
)}
|
|
2093
|
-
|
|
2519
|
+
{openMenu === 'help' && (
|
|
2520
|
+
<HelpDropdown
|
|
2521
|
+
onAction={(id) => {
|
|
2522
|
+
if (id === 'shortcuts') onOpenShortcuts?.();
|
|
2523
|
+
else if (id === 'help') onOpenHelp?.();
|
|
2524
|
+
else if (id === 'tour') onStartTour?.();
|
|
2525
|
+
else if (id === 'whatsnew') onOpenWhatsNew?.();
|
|
2526
|
+
}}
|
|
2527
|
+
onClose={() => setOpenMenu(null)}
|
|
2528
|
+
/>
|
|
2529
|
+
)}
|
|
2530
|
+
<div className="st-mb-right" data-tour="status">
|
|
2094
2531
|
{presence ? <div className="st-presence">{presence}</div> : null}
|
|
2095
2532
|
<button
|
|
2096
2533
|
type="button"
|
|
2097
2534
|
className="st-whatsnew"
|
|
2535
|
+
data-tour="whatsnew"
|
|
2098
2536
|
data-unseen={whatsNewCount > 0 ? 'true' : 'false'}
|
|
2099
2537
|
aria-label={`What's new${whatsNewCount > 0 ? ` — ${whatsNewCount} unseen` : ''}`}
|
|
2100
|
-
|
|
2538
|
+
data-tip="What's new"
|
|
2101
2539
|
onClick={onOpenWhatsNew}
|
|
2102
2540
|
>
|
|
2103
2541
|
<StIcon name="sparkle" size={15} />
|
|
@@ -2107,7 +2545,7 @@ function Menubar({
|
|
|
2107
2545
|
{fileLabel}
|
|
2108
2546
|
</span>
|
|
2109
2547
|
<span className="st-mb-sep" />
|
|
2110
|
-
<span className="st-mb-count"
|
|
2548
|
+
<span className="st-mb-count" data-tip="Artboards in the open canvas">
|
|
2111
2549
|
<span className="st-dot" style={{ background: 'var(--accent)' }} />
|
|
2112
2550
|
{artboardCount} ARTBOARDS
|
|
2113
2551
|
</span>
|
|
@@ -2127,9 +2565,11 @@ function Viewport({
|
|
|
2127
2565
|
onSelectDs,
|
|
2128
2566
|
project,
|
|
2129
2567
|
cfg,
|
|
2568
|
+
loadingPath,
|
|
2569
|
+
onIframeLoad,
|
|
2130
2570
|
}) {
|
|
2131
2571
|
return (
|
|
2132
|
-
<div className="viewport st-stage">
|
|
2572
|
+
<div className="viewport st-stage" data-tour="viewport">
|
|
2133
2573
|
{tabs.length === 0 && (
|
|
2134
2574
|
<div className="st-empty">
|
|
2135
2575
|
<div className="st-empty-brand">
|
|
@@ -2148,8 +2588,8 @@ function Viewport({
|
|
|
2148
2588
|
<strong>Design system</strong> view above it.
|
|
2149
2589
|
<br />
|
|
2150
2590
|
<br />
|
|
2151
|
-
|
|
2152
|
-
|
|
2591
|
+
Opening a file replaces the active canvas. <Kbd>⌘R</Kbd> reloads it; File ▸ Close
|
|
2592
|
+
canvas clears the stage.
|
|
2153
2593
|
<br />
|
|
2154
2594
|
<br />
|
|
2155
2595
|
<strong>Element selection:</strong> hold <Kbd>⌘</Kbd> inside the canvas and hover for a
|
|
@@ -2182,6 +2622,7 @@ function Viewport({
|
|
|
2182
2622
|
src={canvasUrl(t.path, cfg)}
|
|
2183
2623
|
className={t.path === activePath ? 'active' : ''}
|
|
2184
2624
|
data-path={t.path}
|
|
2625
|
+
onLoad={() => onIframeLoad?.(t.path)}
|
|
2185
2626
|
// T2 (9.1-A) — only sandbox + delegate clipboard when the canvas is
|
|
2186
2627
|
// served cross-origin (canvasOrigin present = the split is on). In
|
|
2187
2628
|
// the default same-origin mode these attrs are omitted so behavior
|
|
@@ -2193,6 +2634,19 @@ function Viewport({
|
|
|
2193
2634
|
/>
|
|
2194
2635
|
);
|
|
2195
2636
|
})}
|
|
2637
|
+
{loadingPath && loadingPath === activePath && (
|
|
2638
|
+
// DS skeletons recipe — calm .skel pulse while the canvas-shell compiles
|
|
2639
|
+
// the TSX. Cleared by the iframe's dgn:'loaded' message (or the onLoad
|
|
2640
|
+
// fallback timer for legacy .html canvases that never post it).
|
|
2641
|
+
<div className="st-canvas-loading" aria-hidden="true">
|
|
2642
|
+
<div className="st-skel-card">
|
|
2643
|
+
<div className="st-skel-cap st-mono">compiling canvas…</div>
|
|
2644
|
+
<span className="skel st-skel-thumb" />
|
|
2645
|
+
<span className="skel st-skel-line" style={{ width: '72%' }} />
|
|
2646
|
+
<span className="skel st-skel-line" style={{ width: '46%' }} />
|
|
2647
|
+
</div>
|
|
2648
|
+
</div>
|
|
2649
|
+
)}
|
|
2196
2650
|
</div>
|
|
2197
2651
|
);
|
|
2198
2652
|
}
|
|
@@ -2372,7 +2826,7 @@ function SystemView({ data, onOpen, cfg, onSelectDs }) {
|
|
|
2372
2826
|
return (
|
|
2373
2827
|
<div className="sv">
|
|
2374
2828
|
<header className="sv-header">
|
|
2375
|
-
<span className="sv-sku">
|
|
2829
|
+
<span className="sv-sku">MAUDE-DSN/01</span>
|
|
2376
2830
|
<span className="sv-title">design system view</span>
|
|
2377
2831
|
{hasPicker ? (
|
|
2378
2832
|
<label className="sv-ds-picker">
|
|
@@ -2516,7 +2970,9 @@ function StatusBar({
|
|
|
2516
2970
|
</span>
|
|
2517
2971
|
</span>
|
|
2518
2972
|
|
|
2519
|
-
{
|
|
2973
|
+
{/* Selection is meaningless without an open canvas — a stale _active.json
|
|
2974
|
+
selection used to leave a ghost SELECTED chip on the empty shell. */}
|
|
2975
|
+
{activePath && selected && selected.selector && !isSystem && (
|
|
2520
2976
|
<span className="st-sb-slot st-sb-sel" role="group" aria-label="Selected element">
|
|
2521
2977
|
<span className="lbl">selected</span>
|
|
2522
2978
|
<span className="val" title={title}>
|
|
@@ -2526,7 +2982,8 @@ function StatusBar({
|
|
|
2526
2982
|
type="button"
|
|
2527
2983
|
className="st-sb-sel-clear"
|
|
2528
2984
|
onClick={onClearSelected}
|
|
2529
|
-
|
|
2985
|
+
data-tip="Clear · Esc inside iframe"
|
|
2986
|
+
data-tip-pos="top"
|
|
2530
2987
|
aria-label="Clear selection"
|
|
2531
2988
|
>
|
|
2532
2989
|
×
|
|
@@ -2566,7 +3023,8 @@ function StatusBar({
|
|
|
2566
3023
|
type="button"
|
|
2567
3024
|
className="st-sb-theme"
|
|
2568
3025
|
onClick={onToggleTheme}
|
|
2569
|
-
|
|
3026
|
+
data-tip={`Switch to ${nextTheme} theme`}
|
|
3027
|
+
data-tip-pos="top"
|
|
2570
3028
|
aria-label={`Switch to ${nextTheme} theme`}
|
|
2571
3029
|
>
|
|
2572
3030
|
<StIcon name={theme === 'dark' ? 'sun' : 'moon'} size={13} />
|
|
@@ -2588,6 +3046,8 @@ function CommentsPanel({
|
|
|
2588
3046
|
onResolve,
|
|
2589
3047
|
onReopen,
|
|
2590
3048
|
onDelete,
|
|
3049
|
+
width,
|
|
3050
|
+
resizing,
|
|
2591
3051
|
}) {
|
|
2592
3052
|
const counts = totalCounts(commentsByFile);
|
|
2593
3053
|
// Build groups: [{ file, comments: filtered }]
|
|
@@ -2613,7 +3073,11 @@ function CommentsPanel({
|
|
|
2613
3073
|
}
|
|
2614
3074
|
|
|
2615
3075
|
return (
|
|
2616
|
-
<aside
|
|
3076
|
+
<aside
|
|
3077
|
+
className={'st-rpanel' + (resizing ? ' is-resizing' : '')}
|
|
3078
|
+
style={width ? { width, flexBasis: width } : undefined}
|
|
3079
|
+
aria-label="Comments"
|
|
3080
|
+
>
|
|
2617
3081
|
<div className="st-rp-tabs st-rp-tabs--filters">
|
|
2618
3082
|
<div className="st-cm-filters" role="tablist">
|
|
2619
3083
|
<button
|
|
@@ -2748,37 +3212,66 @@ function SyncBanner({ status }) {
|
|
|
2748
3212
|
if (!status || status.linked === false) return null;
|
|
2749
3213
|
// DDR-060 / 9.1-D — the "linked but 0 syncable" state is surfaced in the
|
|
2750
3214
|
// status bar (sb-sync slot), NOT as a floating banner. This component owns
|
|
2751
|
-
//
|
|
3215
|
+
// the transient offline / reconnect-flash banner (Task 8) plus the DDR-102
|
|
3216
|
+
// rejected-docs chip and divergence-resolution toast.
|
|
2752
3217
|
if (status.notSyncable) return null;
|
|
2753
3218
|
const { state, queuedOps, flash, conflicts } = status;
|
|
2754
3219
|
const showFlash = flash === 'synced';
|
|
2755
3220
|
const offline = state === 'offline' || state === 'offline-long';
|
|
2756
|
-
|
|
2757
|
-
|
|
2758
|
-
|
|
2759
|
-
|
|
3221
|
+
// DDR-102 — per-doc rollup + the latest divergence notice (additive fields;
|
|
3222
|
+
// an old payload without them renders exactly the pre-DDR-102 banner).
|
|
3223
|
+
const rejected = status.docs?.rejected ?? 0;
|
|
3224
|
+
const lastDiverged = Array.isArray(conflicts)
|
|
3225
|
+
? [...conflicts].reverse().find((c) => c.kind === 'cold-start-diverged')
|
|
3226
|
+
: null;
|
|
3227
|
+
if (!offline && !showFlash && !lastDiverged && rejected === 0) return null;
|
|
3228
|
+
|
|
3229
|
+
// One banner at a time — priority: reconnect flash > offline > divergence
|
|
3230
|
+
// toast > rejected chip. Dismissal is keyed per state so a new event
|
|
3231
|
+
// (another conflict, a changed rejected count) re-surfaces it.
|
|
2760
3232
|
let variant;
|
|
2761
3233
|
let text;
|
|
3234
|
+
let dismissKey;
|
|
2762
3235
|
if (showFlash) {
|
|
2763
3236
|
variant = 'success';
|
|
2764
3237
|
text = 'Synced with hub';
|
|
2765
|
-
|
|
2766
|
-
|
|
2767
|
-
|
|
3238
|
+
dismissKey = `${state}:flash`;
|
|
3239
|
+
} else if (offline) {
|
|
3240
|
+
const conflictNote =
|
|
3241
|
+
conflicts && conflicts.length > 0 ? ` (${conflicts.length} conflict notice(s))` : '';
|
|
3242
|
+
if (state === 'offline-long') {
|
|
3243
|
+
variant = 'error';
|
|
3244
|
+
text = `Long offline — ${queuedOps} edit(s) queued. Consider \`git commit && git push\` as backup.${conflictNote}`;
|
|
3245
|
+
} else {
|
|
3246
|
+
variant = 'warn';
|
|
3247
|
+
text = `Working offline · ${queuedOps} edit(s) queued · will sync when the hub reconnects.${conflictNote}`;
|
|
3248
|
+
}
|
|
3249
|
+
dismissKey = `${state}:offline`;
|
|
3250
|
+
} else if (lastDiverged) {
|
|
3251
|
+
// DDR-102 fail-closed: a snapshotFailed conflict means the hub-wins overwrite
|
|
3252
|
+
// was REFUSED (local kept) because _history couldn't be written — surface it
|
|
3253
|
+
// as an error, not a routine "kept newest" notice.
|
|
3254
|
+
if (lastDiverged.snapshotFailed) {
|
|
3255
|
+
variant = 'error';
|
|
3256
|
+
text = `Diverged on ${lastDiverged.slug}: kept local — the history snapshot FAILED, so the overwrite was refused. Check disk space / .design/_history write access.`;
|
|
3257
|
+
} else {
|
|
3258
|
+
variant = 'warn';
|
|
3259
|
+
text = `Diverged on ${lastDiverged.slug}: kept the ${
|
|
3260
|
+
lastDiverged.winner === 'local' ? 'local (newer)' : 'hub'
|
|
3261
|
+
} version — the other is snapshotted in history → /design:rollback ${lastDiverged.slug}`;
|
|
3262
|
+
}
|
|
3263
|
+
dismissKey = `diverged:${lastDiverged.slug}:${lastDiverged.at}`;
|
|
2768
3264
|
} else {
|
|
2769
3265
|
variant = 'warn';
|
|
2770
|
-
text =
|
|
3266
|
+
text = `${rejected} canvas(es) not syncing — the hub rejected auth. Details: maude design status`;
|
|
3267
|
+
dismissKey = `rejected:${rejected}`;
|
|
2771
3268
|
}
|
|
2772
|
-
|
|
2773
|
-
conflicts && conflicts.length > 0 ? ` (${conflicts.length} conflict notice(s))` : '';
|
|
3269
|
+
if (dismissedKey === dismissKey) return null;
|
|
2774
3270
|
|
|
2775
3271
|
return (
|
|
2776
3272
|
<div role="status" aria-live="polite" className={`st-banner st-banner--${variant}`}>
|
|
2777
3273
|
<span className="st-banner-dot" aria-hidden="true" />
|
|
2778
|
-
<span>
|
|
2779
|
-
{text}
|
|
2780
|
-
{conflictNote}
|
|
2781
|
-
</span>
|
|
3274
|
+
<span>{text}</span>
|
|
2782
3275
|
<button
|
|
2783
3276
|
type="button"
|
|
2784
3277
|
className="st-banner-close"
|
|
@@ -2792,16 +3285,1819 @@ function SyncBanner({ status }) {
|
|
|
2792
3285
|
);
|
|
2793
3286
|
}
|
|
2794
3287
|
|
|
2795
|
-
// ----------
|
|
3288
|
+
// ---------- CSS knobs (Phase 12.2, DDR-104) — interactive panel ----------
|
|
2796
3289
|
//
|
|
2797
|
-
//
|
|
2798
|
-
//
|
|
2799
|
-
//
|
|
2800
|
-
//
|
|
2801
|
-
//
|
|
2802
|
-
//
|
|
2803
|
-
|
|
2804
|
-
|
|
3290
|
+
// Hybrid vocabulary (friendly collapsible section headers + CSS-named rows),
|
|
3291
|
+
// per-field DS-token quick-pick, nested box-model widget, per-corner radius,
|
|
3292
|
+
// per-row provenance (token-bound / raw-override / inherited), per-field save
|
|
3293
|
+
// state, and two escape hatches: custom CSS property (via /_api/edit-css) +
|
|
3294
|
+
// custom HTML attribute (via /_api/edit-attr). Each knob pre-fills from the
|
|
3295
|
+
// AUTHORED inline value (`el.authored`); the resolved `computed` value is a
|
|
3296
|
+
// faint placeholder only (NOT editable — the v1 UX bug). Ported from the
|
|
3297
|
+
// critic-approved + user-iterated `.design/ui/Studio.tsx` spec.
|
|
3298
|
+
|
|
3299
|
+
const CSS_DISPLAYS = ['block', 'inline-block', 'flex', 'inline-flex', 'grid', 'inline', 'none'];
|
|
3300
|
+
const CSS_FLEX_DIR = ['row', 'row-reverse', 'column', 'column-reverse'];
|
|
3301
|
+
const CSS_ALIGN = ['stretch', 'flex-start', 'center', 'flex-end', 'baseline'];
|
|
3302
|
+
const CSS_JUSTIFY = [
|
|
3303
|
+
'flex-start',
|
|
3304
|
+
'center',
|
|
3305
|
+
'flex-end',
|
|
3306
|
+
'space-between',
|
|
3307
|
+
'space-around',
|
|
3308
|
+
'space-evenly',
|
|
3309
|
+
];
|
|
3310
|
+
const CSS_WEIGHTS = ['300', '400', '500', '600', '700', '800'];
|
|
3311
|
+
const CSS_FONTS = [
|
|
3312
|
+
'inherit',
|
|
3313
|
+
'system-ui',
|
|
3314
|
+
'sans-serif',
|
|
3315
|
+
'serif',
|
|
3316
|
+
'monospace',
|
|
3317
|
+
'Inter',
|
|
3318
|
+
'Inter Tight',
|
|
3319
|
+
'JetBrains Mono',
|
|
3320
|
+
];
|
|
3321
|
+
const CSS_BORDER_STYLES = ['none', 'solid', 'dashed', 'dotted', 'double'];
|
|
3322
|
+
const CSS_UNITS = ['px', 'rem', 'em', '%', 'vw', 'vh', 'auto'];
|
|
3323
|
+
// Properties whose bare-number value is unitless — never append a unit suffix.
|
|
3324
|
+
const CSS_UNITLESS = new Set(['line-height', 'opacity', 'font-weight', 'z-index', 'flex-grow', 'flex-shrink', 'order']);
|
|
3325
|
+
// #2 — Figma-style property prefix inside numeric fields: a small glyph (icon) or
|
|
3326
|
+
// a mono letter (t). Only where it reads cleanly; selects/colours keep their own.
|
|
3327
|
+
const PROP_LEAD = {
|
|
3328
|
+
'font-size': { icon: 'p-size' },
|
|
3329
|
+
'line-height': { icon: 'p-lineheight' },
|
|
3330
|
+
'letter-spacing': { icon: 'p-letterspacing' },
|
|
3331
|
+
gap: { icon: 'p-gap' },
|
|
3332
|
+
width: { t: 'W' },
|
|
3333
|
+
height: { t: 'H' },
|
|
3334
|
+
'max-width': { t: 'W' },
|
|
3335
|
+
'border-radius': { icon: 'p-corner' },
|
|
3336
|
+
'border-width': { icon: 'p-border' },
|
|
3337
|
+
opacity: { icon: 'p-opacity' },
|
|
3338
|
+
};
|
|
3339
|
+
const CSS_ALIGN_OPTS = ['left', 'center', 'right', 'justify'];
|
|
3340
|
+
|
|
3341
|
+
let _cssColorCtx = null;
|
|
3342
|
+
// Normalize any CSS color string to #rrggbb for the native color input via a
|
|
3343
|
+
// throwaway canvas fillStyle round-trip (parses rgb/hsl/named). Unparseable
|
|
3344
|
+
// values (some oklch) fall back to '' → picker defaults to #000000; the swatch
|
|
3345
|
+
// still shows the true color string regardless.
|
|
3346
|
+
function cssColorToHex(c) {
|
|
3347
|
+
if (!c) return '';
|
|
3348
|
+
if (/^#[0-9a-f]{6}$/i.test(c)) return c.toLowerCase();
|
|
3349
|
+
try {
|
|
3350
|
+
if (!_cssColorCtx) _cssColorCtx = document.createElement('canvas').getContext('2d');
|
|
3351
|
+
if (!_cssColorCtx) return '';
|
|
3352
|
+
_cssColorCtx.fillStyle = '#000000';
|
|
3353
|
+
_cssColorCtx.fillStyle = c;
|
|
3354
|
+
const v = _cssColorCtx.fillStyle;
|
|
3355
|
+
if (/^#[0-9a-f]{6}$/i.test(v)) return v.toLowerCase();
|
|
3356
|
+
const m = v.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)/);
|
|
3357
|
+
if (m) {
|
|
3358
|
+
return `#${[m[1], m[2], m[3]].map((n) => Number(n).toString(16).padStart(2, '0')).join('')}`;
|
|
3359
|
+
}
|
|
3360
|
+
} catch {
|
|
3361
|
+
/* canvas unavailable */
|
|
3362
|
+
}
|
|
3363
|
+
return '';
|
|
3364
|
+
}
|
|
3365
|
+
|
|
3366
|
+
// ---- Colour math for the HSV picker (#6 — Figma-style colour control) ----
|
|
3367
|
+
const clamp01 = (n) => Math.min(1, Math.max(0, n));
|
|
3368
|
+
function hexToRgb(hex) {
|
|
3369
|
+
const m = /^#?([0-9a-f]{6})$/i.exec(hex || '');
|
|
3370
|
+
if (!m) return { r: 0, g: 0, b: 0 };
|
|
3371
|
+
const n = Number.parseInt(m[1], 16);
|
|
3372
|
+
return { r: (n >> 16) & 255, g: (n >> 8) & 255, b: n & 255 };
|
|
3373
|
+
}
|
|
3374
|
+
function rgbToHex({ r, g, b }) {
|
|
3375
|
+
return `#${[r, g, b].map((v) => Math.round(v).toString(16).padStart(2, '0')).join('')}`;
|
|
3376
|
+
}
|
|
3377
|
+
function rgbToHsv({ r, g, b }) {
|
|
3378
|
+
r /= 255;
|
|
3379
|
+
g /= 255;
|
|
3380
|
+
b /= 255;
|
|
3381
|
+
const max = Math.max(r, g, b);
|
|
3382
|
+
const min = Math.min(r, g, b);
|
|
3383
|
+
const d = max - min;
|
|
3384
|
+
let h = 0;
|
|
3385
|
+
if (d) {
|
|
3386
|
+
if (max === r) h = ((g - b) / d) % 6;
|
|
3387
|
+
else if (max === g) h = (b - r) / d + 2;
|
|
3388
|
+
else h = (r - g) / d + 4;
|
|
3389
|
+
h *= 60;
|
|
3390
|
+
if (h < 0) h += 360;
|
|
3391
|
+
}
|
|
3392
|
+
return { h, s: max ? d / max : 0, v: max };
|
|
3393
|
+
}
|
|
3394
|
+
function hsvToRgb({ h, s, v }) {
|
|
3395
|
+
const c = v * s;
|
|
3396
|
+
const x = c * (1 - Math.abs(((h / 60) % 2) - 1));
|
|
3397
|
+
const m = v - c;
|
|
3398
|
+
let r = 0;
|
|
3399
|
+
let g = 0;
|
|
3400
|
+
let b = 0;
|
|
3401
|
+
if (h < 60) [r, g, b] = [c, x, 0];
|
|
3402
|
+
else if (h < 120) [r, g, b] = [x, c, 0];
|
|
3403
|
+
else if (h < 180) [r, g, b] = [0, c, x];
|
|
3404
|
+
else if (h < 240) [r, g, b] = [0, x, c];
|
|
3405
|
+
else if (h < 300) [r, g, b] = [x, 0, c];
|
|
3406
|
+
else [r, g, b] = [c, 0, x];
|
|
3407
|
+
return { r: (r + m) * 255, g: (g + m) * 255, b: (b + m) * 255 };
|
|
3408
|
+
}
|
|
3409
|
+
|
|
3410
|
+
// Round bare px to whole numbers for the placeholder hint; pass other values through.
|
|
3411
|
+
function cssHint(v) {
|
|
3412
|
+
if (!v) return '';
|
|
3413
|
+
const m = /^(-?\d*\.?\d+)px$/.exec(v);
|
|
3414
|
+
return m ? `${Math.round(Number.parseFloat(m[1]))}px` : v;
|
|
3415
|
+
}
|
|
3416
|
+
|
|
3417
|
+
// Split "16px" → { n:"16", unit:"px" }; "auto"→{n:"",unit:"auto"}; var()/raw → {n:raw,unit:""}.
|
|
3418
|
+
function cssSplitUnit(v) {
|
|
3419
|
+
if (!v) return { n: '', unit: 'px' };
|
|
3420
|
+
const t = v.trim();
|
|
3421
|
+
const m = /^(-?\d*\.?\d+)\s*(px|rem|em|%|vw|vh)?$/.exec(t);
|
|
3422
|
+
if (m) return { n: m[1], unit: m[2] || 'px' };
|
|
3423
|
+
if (t === 'auto') return { n: '', unit: 'auto' };
|
|
3424
|
+
return { n: t, unit: '' };
|
|
3425
|
+
}
|
|
3426
|
+
|
|
3427
|
+
// Phase 12.2/12.3 — the WS `selected` echo is the server's projection
|
|
3428
|
+
// (SelectedElement) and LACKS the client-only DOM fields the CSS knobs pre-fill
|
|
3429
|
+
// from (`authored` / `computed` inline style + `customStyles` / `attrs` — all
|
|
3430
|
+
// captured in the iframe, never round-tripped through the server). When the echo
|
|
3431
|
+
// is for the SAME element we already hold locally, preserve those fields instead
|
|
3432
|
+
// of clobbering them to empty (else the server round-trip wipes the custom-CSS /
|
|
3433
|
+
// custom-attr rows + computed readout right after selection).
|
|
3434
|
+
function mergeSelClientFields(incoming, prev) {
|
|
3435
|
+
if (!incoming || Array.isArray(incoming) || Array.isArray(prev) || !prev) return incoming;
|
|
3436
|
+
if (!incoming.id || incoming.id !== prev.id) return incoming;
|
|
3437
|
+
return {
|
|
3438
|
+
...incoming,
|
|
3439
|
+
authored: incoming.authored ?? prev.authored,
|
|
3440
|
+
computed: incoming.computed ?? prev.computed,
|
|
3441
|
+
customStyles: incoming.customStyles ?? prev.customStyles,
|
|
3442
|
+
attrs: incoming.attrs ?? prev.attrs,
|
|
3443
|
+
};
|
|
3444
|
+
}
|
|
3445
|
+
|
|
3446
|
+
// Resolve the active canvas's DS tokens CSS path (mirrors canvas-url.js / DDR-093):
|
|
3447
|
+
// the canvas's declared DS wins, else designSystems[0], else the legacy default.
|
|
3448
|
+
function cssTokensRelFor(file, cfg) {
|
|
3449
|
+
const ds0 = cfg?.designSystems?.[0];
|
|
3450
|
+
const name = file ? cfg?.canvasDesignSystems?.[file] : null;
|
|
3451
|
+
const ds = (name && cfg?.designSystems?.find((d) => d.name === name)) || ds0;
|
|
3452
|
+
return ds?.tokensCssRel || cfg?.tokensCssRel || ds0?.tokensCssRel || '';
|
|
3453
|
+
}
|
|
3454
|
+
|
|
3455
|
+
// The active canvas's DS NAME (mirrors cssTokensRelFor's resolution order).
|
|
3456
|
+
function activeDsNameFor(file, cfg) {
|
|
3457
|
+
const byCanvas = file ? cfg?.canvasDesignSystems?.[file] : null;
|
|
3458
|
+
return byCanvas || cfg?.defaultDesignSystem || cfg?.designSystems?.[0]?.name || null;
|
|
3459
|
+
}
|
|
3460
|
+
|
|
3461
|
+
// Parse a DS tokens CSS body → token names grouped by family + a name→value map
|
|
3462
|
+
// (resolving one level of var() aliasing so the popover renders real swatches +
|
|
3463
|
+
// values). Phase 12.3 (W2.1/W3 multi-DS).
|
|
3464
|
+
function parseTokensCss(css) {
|
|
3465
|
+
const raw = {};
|
|
3466
|
+
for (const m of css.matchAll(/(--[a-z0-9-]+)\s*:\s*([^;}]+)/gi)) {
|
|
3467
|
+
if (!(m[1] in raw)) raw[m[1]] = m[2].trim();
|
|
3468
|
+
}
|
|
3469
|
+
const vals = {};
|
|
3470
|
+
for (const name of Object.keys(raw)) {
|
|
3471
|
+
const v = raw[name];
|
|
3472
|
+
const ref = /^var\(\s*(--[a-z0-9-]+)\s*\)$/i.exec(v);
|
|
3473
|
+
vals[name] = ref && raw[ref[1]] ? raw[ref[1]] : v;
|
|
3474
|
+
}
|
|
3475
|
+
const names = Object.keys(raw);
|
|
3476
|
+
const g = (re) => names.filter((n) => re.test(n));
|
|
3477
|
+
// Colours detected by VALUE, not name — so EVERY colour token a DS defines is
|
|
3478
|
+
// offered (the name-prefix list dropped many). A token is a colour if its
|
|
3479
|
+
// resolved value reads as one. (#3 — "see all tokens the DS has".)
|
|
3480
|
+
const isColor = (v) =>
|
|
3481
|
+
/^(#[0-9a-f]{3,8}|rgba?\(|hsla?\(|oklch\(|oklab\(|lab\(|lch\(|hwb\(|color\()/i.test(v) ||
|
|
3482
|
+
/^(transparent|currentcolor|white|black|red|green|blue|gray|grey|orange|yellow|purple|pink|cyan|magenta|teal|navy|maroon|olive|lime|aqua|silver|gold)$/i.test(
|
|
3483
|
+
v
|
|
3484
|
+
);
|
|
3485
|
+
return {
|
|
3486
|
+
color: names.filter((n) => isColor(vals[n])),
|
|
3487
|
+
space: g(/^--space-/),
|
|
3488
|
+
radius: g(/^--radius-/),
|
|
3489
|
+
type: g(/^--type-/),
|
|
3490
|
+
shadow: g(/^--shadow-/),
|
|
3491
|
+
lh: g(/^--lh-/),
|
|
3492
|
+
vals,
|
|
3493
|
+
};
|
|
3494
|
+
}
|
|
3495
|
+
|
|
3496
|
+
// Fetch + parse the tokens CSS of EVERY design system in the config (main
|
|
3497
|
+
// origin), so the token popover can offer tokens grouped per DS (W3 multi-DS
|
|
3498
|
+
// feedback). The active canvas's DS is ordered first. Returns
|
|
3499
|
+
// `[{ name, color, space, radius, type, shadow, lh, vals }]`.
|
|
3500
|
+
function useAllDsTokens(cfg, designRel, activeName) {
|
|
3501
|
+
const list = cfg?.designSystems || [];
|
|
3502
|
+
// A stable key so the effect only re-fetches when the DS set / paths change.
|
|
3503
|
+
const sig = list.map((d) => `${d.name}:${d.tokensCssRel}`).join('|');
|
|
3504
|
+
const [byDs, setByDs] = useState([]);
|
|
3505
|
+
useEffect(() => {
|
|
3506
|
+
if (!list.length) return undefined;
|
|
3507
|
+
let cancelled = false;
|
|
3508
|
+
Promise.all(
|
|
3509
|
+
list.map(async (ds) => {
|
|
3510
|
+
if (!ds.tokensCssRel) return null;
|
|
3511
|
+
try {
|
|
3512
|
+
const r = await fetch(`/${designRel}/${ds.tokensCssRel}`);
|
|
3513
|
+
const css = r.ok ? await r.text() : '';
|
|
3514
|
+
return { name: ds.name, ...parseTokensCss(css) };
|
|
3515
|
+
} catch {
|
|
3516
|
+
return null;
|
|
3517
|
+
}
|
|
3518
|
+
})
|
|
3519
|
+
).then((res) => {
|
|
3520
|
+
if (cancelled) return;
|
|
3521
|
+
const got = res.filter(Boolean);
|
|
3522
|
+
// Active DS first, rest in config order.
|
|
3523
|
+
got.sort((a, b) => (a.name === activeName ? -1 : b.name === activeName ? 1 : 0));
|
|
3524
|
+
setByDs(got);
|
|
3525
|
+
});
|
|
3526
|
+
return () => {
|
|
3527
|
+
cancelled = true;
|
|
3528
|
+
};
|
|
3529
|
+
}, [sig, designRel, activeName]);
|
|
3530
|
+
return byDs;
|
|
3531
|
+
}
|
|
3532
|
+
|
|
3533
|
+
// Phase 12.3 (#4) — the "Custom" tab of the colour popover: a normal colour
|
|
3534
|
+
// input (native OS picker via a large swatch) + a hex/value text field. Applies
|
|
3535
|
+
// LIVE as you adjust (onApply), so the canvas previews while the picker is open.
|
|
3536
|
+
// #6 — the unified colour picker (Custom tab). A real HSV control: a
|
|
3537
|
+
// saturation/value square + a hue slider + a hex field + an eyedropper — the
|
|
3538
|
+
// Figma model. Replaces BOTH the old native <input type="color"> on the swatch
|
|
3539
|
+
// AND the simple hex field, so colours have ONE popover (Custom · Variables).
|
|
3540
|
+
// `seed` is the resolved current colour (hex). Drag updates the picker UI live;
|
|
3541
|
+
// commits on pointer-up (one source write per drag); the hex field commits on
|
|
3542
|
+
// blur/Enter.
|
|
3543
|
+
function ColorPicker({ seed, onApply }) {
|
|
3544
|
+
const [hsv, setHsv] = useState(() => rgbToHsv(hexToRgb(seed || '#000000')));
|
|
3545
|
+
const hsvRef = useRef(hsv);
|
|
3546
|
+
hsvRef.current = hsv;
|
|
3547
|
+
const svRef = useRef(null);
|
|
3548
|
+
const hueRef = useRef(null);
|
|
3549
|
+
// Reseed when the selection's colour changes (but not while the user drags).
|
|
3550
|
+
const seedRef = useRef(seed);
|
|
3551
|
+
// biome-ignore lint/correctness/useExhaustiveDependencies: reseed on seed change only.
|
|
3552
|
+
useEffect(() => {
|
|
3553
|
+
if (seed && seed !== seedRef.current) {
|
|
3554
|
+
seedRef.current = seed;
|
|
3555
|
+
setHsv(rgbToHsv(hexToRgb(seed)));
|
|
3556
|
+
}
|
|
3557
|
+
}, [seed]);
|
|
3558
|
+
const hex = rgbToHex(hsvToRgb(hsv));
|
|
3559
|
+
|
|
3560
|
+
const dragSV = (e) => {
|
|
3561
|
+
e.preventDefault();
|
|
3562
|
+
const r = svRef.current?.getBoundingClientRect();
|
|
3563
|
+
if (!r) return;
|
|
3564
|
+
const h = hsvRef.current.h;
|
|
3565
|
+
const move = (ev) => {
|
|
3566
|
+
setHsv({
|
|
3567
|
+
h,
|
|
3568
|
+
s: clamp01((ev.clientX - r.left) / r.width),
|
|
3569
|
+
v: clamp01(1 - (ev.clientY - r.top) / r.height),
|
|
3570
|
+
});
|
|
3571
|
+
};
|
|
3572
|
+
move(e);
|
|
3573
|
+
const up = () => {
|
|
3574
|
+
document.removeEventListener('pointermove', move);
|
|
3575
|
+
document.removeEventListener('pointerup', up);
|
|
3576
|
+
onApply(rgbToHex(hsvToRgb(hsvRef.current)));
|
|
3577
|
+
};
|
|
3578
|
+
document.addEventListener('pointermove', move);
|
|
3579
|
+
document.addEventListener('pointerup', up);
|
|
3580
|
+
};
|
|
3581
|
+
const dragHue = (e) => {
|
|
3582
|
+
e.preventDefault();
|
|
3583
|
+
const r = hueRef.current?.getBoundingClientRect();
|
|
3584
|
+
if (!r) return;
|
|
3585
|
+
const { s, v } = hsvRef.current;
|
|
3586
|
+
const move = (ev) => {
|
|
3587
|
+
setHsv({ h: clamp01((ev.clientX - r.left) / r.width) * 360, s, v });
|
|
3588
|
+
};
|
|
3589
|
+
move(e);
|
|
3590
|
+
const up = () => {
|
|
3591
|
+
document.removeEventListener('pointermove', move);
|
|
3592
|
+
document.removeEventListener('pointerup', up);
|
|
3593
|
+
onApply(rgbToHex(hsvToRgb(hsvRef.current)));
|
|
3594
|
+
};
|
|
3595
|
+
document.addEventListener('pointermove', move);
|
|
3596
|
+
document.addEventListener('pointerup', up);
|
|
3597
|
+
};
|
|
3598
|
+
const eyedrop = async () => {
|
|
3599
|
+
try {
|
|
3600
|
+
// EyeDropper is Chromium-only; guarded.
|
|
3601
|
+
const ED = window.EyeDropper;
|
|
3602
|
+
if (!ED) return;
|
|
3603
|
+
const res = await new ED().open();
|
|
3604
|
+
if (res?.sRGBHex) {
|
|
3605
|
+
setHsv(rgbToHsv(hexToRgb(res.sRGBHex)));
|
|
3606
|
+
onApply(res.sRGBHex);
|
|
3607
|
+
}
|
|
3608
|
+
} catch {
|
|
3609
|
+
/* user cancelled */
|
|
3610
|
+
}
|
|
3611
|
+
};
|
|
3612
|
+
|
|
3613
|
+
return (
|
|
3614
|
+
<div className="st-cp-cpick">
|
|
3615
|
+
<button
|
|
3616
|
+
type="button"
|
|
3617
|
+
ref={svRef}
|
|
3618
|
+
className="st-cp-cpick-sv"
|
|
3619
|
+
aria-label="saturation and value"
|
|
3620
|
+
style={{ background: `hsl(${hsv.h} 100% 50%)` }}
|
|
3621
|
+
onPointerDown={dragSV}
|
|
3622
|
+
>
|
|
3623
|
+
<span className="st-cp-cpick-svwhite" />
|
|
3624
|
+
<span className="st-cp-cpick-svblack" />
|
|
3625
|
+
<span
|
|
3626
|
+
className="st-cp-cpick-knob"
|
|
3627
|
+
style={{ left: `${hsv.s * 100}%`, top: `${(1 - hsv.v) * 100}%`, background: hex }}
|
|
3628
|
+
/>
|
|
3629
|
+
</button>
|
|
3630
|
+
<div className="st-cp-cpick-controls">
|
|
3631
|
+
{window.EyeDropper ? (
|
|
3632
|
+
<button
|
|
3633
|
+
type="button"
|
|
3634
|
+
className="st-cp-cpick-eye"
|
|
3635
|
+
aria-label="pick from screen"
|
|
3636
|
+
title="eyedropper"
|
|
3637
|
+
onClick={eyedrop}
|
|
3638
|
+
>
|
|
3639
|
+
<StIcon name="eyedropper" size={14} />
|
|
3640
|
+
</button>
|
|
3641
|
+
) : null}
|
|
3642
|
+
<button
|
|
3643
|
+
type="button"
|
|
3644
|
+
ref={hueRef}
|
|
3645
|
+
className="st-cp-cpick-hue"
|
|
3646
|
+
aria-label="hue"
|
|
3647
|
+
onPointerDown={dragHue}
|
|
3648
|
+
>
|
|
3649
|
+
<span className="st-cp-cpick-huethumb" style={{ left: `${(hsv.h / 360) * 100}%` }} />
|
|
3650
|
+
</button>
|
|
3651
|
+
</div>
|
|
3652
|
+
<input
|
|
3653
|
+
className="st-cp-fin"
|
|
3654
|
+
type="text"
|
|
3655
|
+
value={hex}
|
|
3656
|
+
aria-label="hex value"
|
|
3657
|
+
onChange={(e) => {
|
|
3658
|
+
const v = e.target.value;
|
|
3659
|
+
if (/^#?[0-9a-f]{6}$/i.test(v)) setHsv(rgbToHsv(hexToRgb(v)));
|
|
3660
|
+
}}
|
|
3661
|
+
onKeyDown={(e) => {
|
|
3662
|
+
if (e.key === 'Enter') onApply(e.currentTarget.value);
|
|
3663
|
+
}}
|
|
3664
|
+
onBlur={(e) => onApply(e.currentTarget.value)}
|
|
3665
|
+
/>
|
|
3666
|
+
</div>
|
|
3667
|
+
);
|
|
3668
|
+
}
|
|
3669
|
+
|
|
3670
|
+
// Phase 12.3 (W2.1) — token picker as a Figma-style popover instead of a native
|
|
3671
|
+
// <select>. `kind='color'` renders a swatch grid (resolved DS color values);
|
|
3672
|
+
// `kind='value'` a variable list (pretty name + resolved value, à la Figma's
|
|
3673
|
+
// variable picker). Picking commits `var(--token)`. Portals to <body> +
|
|
3674
|
+
// fixed-positions from the trigger rect so the panel's overflow never clips it.
|
|
3675
|
+
function TokenPopover({ kind, groups, current, onPick, label, swatchBg, seedHex, activeDs }) {
|
|
3676
|
+
const [open, setOpen] = useState(false);
|
|
3677
|
+
const [pos, setPos] = useState(null);
|
|
3678
|
+
// Phase 12.3 (#4) — colour popover gets two tabs: a normal colour input
|
|
3679
|
+
// (Custom) + the DS variables swatch list (Variables). Token-able non-colour
|
|
3680
|
+
// popovers stay single-mode.
|
|
3681
|
+
const [mode, setMode] = useState('custom');
|
|
3682
|
+
const [query, setQuery] = useState('');
|
|
3683
|
+
const btnRef = useRef(null);
|
|
3684
|
+
const popRef = useRef(null);
|
|
3685
|
+
const bound = typeof current === 'string' && /var\(\s*--/.test(current);
|
|
3686
|
+
const isOn = (n) => current === `var(${n})`;
|
|
3687
|
+
const pretty = (n) => n.replace(/^--/, '').replace(/-/g, ' ');
|
|
3688
|
+
const gs = groups || [];
|
|
3689
|
+
const total = gs.reduce((s, g) => s + (g.names?.length || 0), 0);
|
|
3690
|
+
const showDsHeaders = gs.length > 1; // group by DS only when there's >1
|
|
3691
|
+
// Search filter over the token name + resolved value, per group.
|
|
3692
|
+
const q = query.trim().toLowerCase();
|
|
3693
|
+
const filteredGs = !q
|
|
3694
|
+
? gs
|
|
3695
|
+
: gs
|
|
3696
|
+
.map((g) => ({
|
|
3697
|
+
...g,
|
|
3698
|
+
names: (g.names || []).filter(
|
|
3699
|
+
(n) =>
|
|
3700
|
+
pretty(n).toLowerCase().includes(q) ||
|
|
3701
|
+
n.toLowerCase().includes(q) ||
|
|
3702
|
+
(g.vals?.[n] || '').toLowerCase().includes(q)
|
|
3703
|
+
),
|
|
3704
|
+
}))
|
|
3705
|
+
.filter((g) => g.names.length);
|
|
3706
|
+
|
|
3707
|
+
useEffect(() => {
|
|
3708
|
+
if (!open) {
|
|
3709
|
+
setQuery('');
|
|
3710
|
+
return undefined;
|
|
3711
|
+
}
|
|
3712
|
+
const place = () => {
|
|
3713
|
+
const r = btnRef.current?.getBoundingClientRect();
|
|
3714
|
+
if (!r) return;
|
|
3715
|
+
const W = 224;
|
|
3716
|
+
const MAXH = 300;
|
|
3717
|
+
let left = Math.min(r.right - W, window.innerWidth - W - 8);
|
|
3718
|
+
if (left < 8) left = 8;
|
|
3719
|
+
const below = window.innerHeight - r.bottom;
|
|
3720
|
+
const top = below > MAXH + 8 ? r.bottom + 4 : Math.max(8, r.top - MAXH - 4);
|
|
3721
|
+
setPos({ left, top, width: W, maxHeight: MAXH });
|
|
3722
|
+
};
|
|
3723
|
+
place();
|
|
3724
|
+
const onDoc = (e) => {
|
|
3725
|
+
if (popRef.current?.contains(e.target) || btnRef.current?.contains(e.target)) return;
|
|
3726
|
+
setOpen(false);
|
|
3727
|
+
};
|
|
3728
|
+
const onKey = (e) => {
|
|
3729
|
+
if (e.key === 'Escape') setOpen(false);
|
|
3730
|
+
};
|
|
3731
|
+
const dismiss = () => setOpen(false);
|
|
3732
|
+
// The popover is fixed-positioned from the trigger rect, so a scroll of the
|
|
3733
|
+
// PANEL detaches it → dismiss. But scrolling INSIDE the popover (its own
|
|
3734
|
+
// overflow list) must NOT close it (the user couldn't scroll the variables).
|
|
3735
|
+
const onScroll = (e) => {
|
|
3736
|
+
if (popRef.current?.contains(e.target)) return;
|
|
3737
|
+
setOpen(false);
|
|
3738
|
+
};
|
|
3739
|
+
document.addEventListener('pointerdown', onDoc, true);
|
|
3740
|
+
document.addEventListener('keydown', onKey);
|
|
3741
|
+
window.addEventListener('resize', dismiss);
|
|
3742
|
+
document.addEventListener('scroll', onScroll, true);
|
|
3743
|
+
return () => {
|
|
3744
|
+
document.removeEventListener('pointerdown', onDoc, true);
|
|
3745
|
+
document.removeEventListener('keydown', onKey);
|
|
3746
|
+
window.removeEventListener('resize', dismiss);
|
|
3747
|
+
document.removeEventListener('scroll', onScroll, true);
|
|
3748
|
+
};
|
|
3749
|
+
}, [open]);
|
|
3750
|
+
|
|
3751
|
+
// Pick a token. #3 — apply CORRECTLY across design systems: a token from the
|
|
3752
|
+
// canvas's OWN active DS commits `var(--token)` (round-trips + resolves right);
|
|
3753
|
+
// a token from ANOTHER DS commits its RESOLVED value (literal), because
|
|
3754
|
+
// `var(--token)` would resolve against the canvas's DS scope and paint the WRONG
|
|
3755
|
+
// colour. So what you click is always what's applied ("natvrdo").
|
|
3756
|
+
// SECURITY (ethical-hacker A3) — a cross-DS token value is committed as a
|
|
3757
|
+
// LITERAL, and its source is a (possibly hub-pushed, untrusted) tokens CSS.
|
|
3758
|
+
// A colour-shaped value can still smuggle a fetch primitive (e.g.
|
|
3759
|
+
// `rgb(1 2 3) url(//x)` passes the colour sniff). Refuse to write a literal
|
|
3760
|
+
// carrying url()/image-set()/expression()/@import — fall back to var(), which
|
|
3761
|
+
// resolves against the CANVAS's own DS (never the attacker's value).
|
|
3762
|
+
const UNSAFE_TOKEN_VALUE = /url\(|image-set\(|cross-fade\(|element\(|expression\(|@import|javascript:/i;
|
|
3763
|
+
const pickFrom = (ds, n, resolved) => {
|
|
3764
|
+
if (activeDs && ds && ds !== activeDs && resolved && !UNSAFE_TOKEN_VALUE.test(resolved)) {
|
|
3765
|
+
onPick(resolved);
|
|
3766
|
+
} else {
|
|
3767
|
+
onPick(`var(${n})`);
|
|
3768
|
+
}
|
|
3769
|
+
setOpen(false);
|
|
3770
|
+
};
|
|
3771
|
+
// Custom colour / hex applies LIVE without closing, so the user can keep
|
|
3772
|
+
// tweaking; the popover dismisses on outside-click / Esc like everything else.
|
|
3773
|
+
const applyRaw = (v) => {
|
|
3774
|
+
const val = (v || '').trim();
|
|
3775
|
+
if (val) onPick(val);
|
|
3776
|
+
};
|
|
3777
|
+
// A search field over the token list (name + value). Auto-focuses so the user
|
|
3778
|
+
// can type straight away.
|
|
3779
|
+
const searchBar = (
|
|
3780
|
+
<div className="st-cp-pop-search">
|
|
3781
|
+
<StIcon name="search" size={12} />
|
|
3782
|
+
<input
|
|
3783
|
+
value={query}
|
|
3784
|
+
onChange={(e) => setQuery(e.target.value)}
|
|
3785
|
+
placeholder="Search variables"
|
|
3786
|
+
aria-label="search variables"
|
|
3787
|
+
// biome-ignore lint/a11y/noAutofocus: focusing the search on open is the intent.
|
|
3788
|
+
autoFocus
|
|
3789
|
+
/>
|
|
3790
|
+
</div>
|
|
3791
|
+
);
|
|
3792
|
+
// #2 — colour Variables as a scannable LIST (swatch · name · value), per DS.
|
|
3793
|
+
const swatchList = (grps) =>
|
|
3794
|
+
grps.map((g) => (
|
|
3795
|
+
<div className="st-cp-pop-group" key={g.ds}>
|
|
3796
|
+
{showDsHeaders ? <div className="st-cp-pop-ds">{g.ds}</div> : null}
|
|
3797
|
+
<div className="st-cp-pop-list">
|
|
3798
|
+
{g.names.map((n) => (
|
|
3799
|
+
<button
|
|
3800
|
+
key={`${g.ds}:${n}`}
|
|
3801
|
+
type="button"
|
|
3802
|
+
className={`st-cp-pop-row st-cp-pop-crow${isOn(n) ? ' is-on' : ''}`}
|
|
3803
|
+
onClick={() => pickFrom(g.ds, n, g.vals?.[n])}
|
|
3804
|
+
>
|
|
3805
|
+
<span
|
|
3806
|
+
className="st-cp-pop-cswatch"
|
|
3807
|
+
style={{ background: g.vals?.[n] || 'transparent' }}
|
|
3808
|
+
aria-hidden="true"
|
|
3809
|
+
/>
|
|
3810
|
+
<span className="st-cp-pop-name">{pretty(n)}</span>
|
|
3811
|
+
<span className="st-cp-pop-val">{g.vals?.[n] || ''}</span>
|
|
3812
|
+
</button>
|
|
3813
|
+
))}
|
|
3814
|
+
</div>
|
|
3815
|
+
</div>
|
|
3816
|
+
));
|
|
3817
|
+
// The value-token list (non-colour), per DS.
|
|
3818
|
+
const valueList = (grps) =>
|
|
3819
|
+
grps.map((g) => (
|
|
3820
|
+
<div className="st-cp-pop-group" key={g.ds}>
|
|
3821
|
+
{showDsHeaders ? <div className="st-cp-pop-ds">{g.ds}</div> : null}
|
|
3822
|
+
<div className="st-cp-pop-list">
|
|
3823
|
+
{g.names.map((n) => (
|
|
3824
|
+
<button
|
|
3825
|
+
key={`${g.ds}:${n}`}
|
|
3826
|
+
type="button"
|
|
3827
|
+
className={`st-cp-pop-row${isOn(n) ? ' is-on' : ''}`}
|
|
3828
|
+
onClick={() => pickFrom(g.ds, n, g.vals?.[n])}
|
|
3829
|
+
>
|
|
3830
|
+
<span className="st-cp-pop-name">{pretty(n)}</span>
|
|
3831
|
+
<span className="st-cp-pop-val">{g.vals?.[n] || ''}</span>
|
|
3832
|
+
</button>
|
|
3833
|
+
))}
|
|
3834
|
+
</div>
|
|
3835
|
+
</div>
|
|
3836
|
+
));
|
|
3837
|
+
const noMatch = <div className="st-cp-pop-empty">No match</div>;
|
|
3838
|
+
|
|
3839
|
+
return (
|
|
3840
|
+
<>
|
|
3841
|
+
{swatchBg !== undefined ? (
|
|
3842
|
+
// Colour rows: the swatch IS the trigger — one popover, no separate native
|
|
3843
|
+
// OS picker + ◇ (the "two popovers" the user flagged). Shows the current
|
|
3844
|
+
// colour; bound-to-token gets the accent ring.
|
|
3845
|
+
<button
|
|
3846
|
+
type="button"
|
|
3847
|
+
ref={btnRef}
|
|
3848
|
+
className={`st-cp-swatch st-cp-swatch--mini st-cp-swatch--trigger${bound ? ' is-bound' : ''}`}
|
|
3849
|
+
aria-haspopup="dialog"
|
|
3850
|
+
aria-expanded={open}
|
|
3851
|
+
aria-label={label || 'pick a colour'}
|
|
3852
|
+
title={current || 'pick a colour'}
|
|
3853
|
+
onClick={() => setOpen((v) => !v)}
|
|
3854
|
+
>
|
|
3855
|
+
<span style={{ position: 'absolute', inset: 0, background: swatchBg || 'transparent' }} />
|
|
3856
|
+
</button>
|
|
3857
|
+
) : (
|
|
3858
|
+
<button
|
|
3859
|
+
type="button"
|
|
3860
|
+
ref={btnRef}
|
|
3861
|
+
className={`st-cp-tokbtn${bound ? ' is-bound' : ''}`}
|
|
3862
|
+
aria-haspopup="dialog"
|
|
3863
|
+
aria-expanded={open}
|
|
3864
|
+
aria-label={label || 'pick a design token'}
|
|
3865
|
+
title="design tokens"
|
|
3866
|
+
onClick={() => setOpen((v) => !v)}
|
|
3867
|
+
>
|
|
3868
|
+
<span className="st-cp-tokbtn-glyph" aria-hidden="true" />
|
|
3869
|
+
</button>
|
|
3870
|
+
)}
|
|
3871
|
+
{open && pos
|
|
3872
|
+
? createPortal(
|
|
3873
|
+
<div
|
|
3874
|
+
ref={popRef}
|
|
3875
|
+
// Portalled to <body> (outside the App's `.maude` div), so it must
|
|
3876
|
+
// re-establish the maude token scope itself — otherwise var(--bg-*)
|
|
3877
|
+
// resolves to the legacy :root project palette (the cream popover bug).
|
|
3878
|
+
className="maude st-cp-pop"
|
|
3879
|
+
data-theme={
|
|
3880
|
+
(typeof document !== 'undefined' &&
|
|
3881
|
+
document.documentElement.getAttribute('data-theme')) ||
|
|
3882
|
+
'dark'
|
|
3883
|
+
}
|
|
3884
|
+
role="dialog"
|
|
3885
|
+
aria-label={label || 'design tokens'}
|
|
3886
|
+
style={{
|
|
3887
|
+
left: pos.left,
|
|
3888
|
+
top: pos.top,
|
|
3889
|
+
width: pos.width,
|
|
3890
|
+
maxHeight: pos.maxHeight,
|
|
3891
|
+
}}
|
|
3892
|
+
>
|
|
3893
|
+
{kind === 'color' ? (
|
|
3894
|
+
<>
|
|
3895
|
+
<div className="st-cp-poptabs" role="tablist">
|
|
3896
|
+
<button
|
|
3897
|
+
type="button"
|
|
3898
|
+
role="tab"
|
|
3899
|
+
aria-selected={mode === 'custom'}
|
|
3900
|
+
className={`st-cp-poptab${mode === 'custom' ? ' is-active' : ''}`}
|
|
3901
|
+
onClick={() => setMode('custom')}
|
|
3902
|
+
>
|
|
3903
|
+
Custom
|
|
3904
|
+
</button>
|
|
3905
|
+
<button
|
|
3906
|
+
type="button"
|
|
3907
|
+
role="tab"
|
|
3908
|
+
aria-selected={mode === 'vars'}
|
|
3909
|
+
className={`st-cp-poptab${mode === 'vars' ? ' is-active' : ''}`}
|
|
3910
|
+
onClick={() => setMode('vars')}
|
|
3911
|
+
>
|
|
3912
|
+
Variables
|
|
3913
|
+
</button>
|
|
3914
|
+
</div>
|
|
3915
|
+
{mode === 'custom' ? (
|
|
3916
|
+
<ColorPicker seed={seedHex || cssColorToHex(current) || '#000000'} onApply={applyRaw} />
|
|
3917
|
+
) : !total ? (
|
|
3918
|
+
<div className="st-cp-pop-empty">No color tokens</div>
|
|
3919
|
+
) : (
|
|
3920
|
+
<>
|
|
3921
|
+
{searchBar}
|
|
3922
|
+
{filteredGs.length ? swatchList(filteredGs) : noMatch}
|
|
3923
|
+
</>
|
|
3924
|
+
)}
|
|
3925
|
+
</>
|
|
3926
|
+
) : !total ? (
|
|
3927
|
+
<div className="st-cp-pop-empty">No tokens for this property</div>
|
|
3928
|
+
) : (
|
|
3929
|
+
<>
|
|
3930
|
+
{searchBar}
|
|
3931
|
+
{filteredGs.length ? valueList(filteredGs) : noMatch}
|
|
3932
|
+
</>
|
|
3933
|
+
)}
|
|
3934
|
+
</div>,
|
|
3935
|
+
document.body
|
|
3936
|
+
)
|
|
3937
|
+
: null}
|
|
3938
|
+
</>
|
|
3939
|
+
);
|
|
3940
|
+
}
|
|
3941
|
+
|
|
3942
|
+
function CssKnobs({ el, cfg, onOptimistic }) {
|
|
3943
|
+
const editable = !!el.id;
|
|
3944
|
+
const computed = el.computed || {};
|
|
3945
|
+
// Phase 12.3 — optimistic local overlay over the selection's authored / custom
|
|
3946
|
+
// / attr maps. With the redundant-reload suppression (the flicker fix), an edit
|
|
3947
|
+
// no longer triggers a reselect that would re-post fresh `authored` values — so
|
|
3948
|
+
// the panel must reflect its own commits immediately or it shows the stale
|
|
3949
|
+
// pre-edit value until the user re-selects. Each commit/reset writes here;
|
|
3950
|
+
// `null` marks a removed key. Cleared when a different element is selected.
|
|
3951
|
+
const [overlay, setOverlay] = useState({ a: {}, c: {}, t: {} });
|
|
3952
|
+
// biome-ignore lint/correctness/useExhaustiveDependencies: clear only on element change.
|
|
3953
|
+
useEffect(() => {
|
|
3954
|
+
setOverlay({ a: {}, c: {}, t: {} });
|
|
3955
|
+
}, [el.id]);
|
|
3956
|
+
const mergeOverlay = (base, ov) => {
|
|
3957
|
+
const out = { ...(base || {}) };
|
|
3958
|
+
for (const [k, v] of Object.entries(ov)) {
|
|
3959
|
+
if (v === null) delete out[k];
|
|
3960
|
+
else out[k] = v;
|
|
3961
|
+
}
|
|
3962
|
+
return out;
|
|
3963
|
+
};
|
|
3964
|
+
const authored = mergeOverlay(el.authored, overlay.a);
|
|
3965
|
+
const customStyles = mergeOverlay(el.customStyles, overlay.c);
|
|
3966
|
+
const attrs = mergeOverlay(el.attrs, overlay.t);
|
|
3967
|
+
const setA = (prop, v) => setOverlay((o) => ({ ...o, a: { ...o.a, [prop]: v } }));
|
|
3968
|
+
const setC = (prop, v) => setOverlay((o) => ({ ...o, c: { ...o.c, [prop]: v } }));
|
|
3969
|
+
const setT = (attr, v) => setOverlay((o) => ({ ...o, t: { ...o.t, [attr]: v } }));
|
|
3970
|
+
// Token CSS is served from the MAIN origin at the repo-relative path, i.e.
|
|
3971
|
+
// WITH the designRoot prefix (`/.design/system/<ds>/colors_and_type.css`) —
|
|
3972
|
+
// `tokensCssRel` from config is DS-root-relative (no `.design/`), so prepend it.
|
|
3973
|
+
const _designRel = (cfg?.designRel || cfg?.designRoot || '.design').replace(/^\/+|\/+$/g, '');
|
|
3974
|
+
const _activeDs = activeDsNameFor(el.file, cfg);
|
|
3975
|
+
// W3 — tokens from EVERY configured DS, active one first, so the popover can
|
|
3976
|
+
// offer them grouped per design system.
|
|
3977
|
+
const allDs = useAllDsTokens(cfg, _designRel, _activeDs);
|
|
3978
|
+
// Build per-DS popover groups for one token family (color/space/radius/…).
|
|
3979
|
+
const tokenGroups = (familyKey) =>
|
|
3980
|
+
allDs
|
|
3981
|
+
.map((d) => ({ ds: d.name, names: d[familyKey] || [], vals: d.vals }))
|
|
3982
|
+
.filter((g) => g.names.length);
|
|
3983
|
+
const [status, setStatus] = useState({});
|
|
3984
|
+
const [open, setOpen] = useState({
|
|
3985
|
+
Layout: true,
|
|
3986
|
+
Typography: true,
|
|
3987
|
+
Spacing: true,
|
|
3988
|
+
Size: true,
|
|
3989
|
+
Appearance: true,
|
|
3990
|
+
Advanced: false,
|
|
3991
|
+
});
|
|
3992
|
+
const [split, setSplit] = useState(false);
|
|
3993
|
+
|
|
3994
|
+
// Phase 12.3 — auto-expand Advanced when the selected element carries custom
|
|
3995
|
+
// CSS props / HTML attrs, so a just-added (or pre-existing) custom value is
|
|
3996
|
+
// visible without hunting for the disclosure. Keyed on el.id so it re-runs per
|
|
3997
|
+
// selection (CssKnobs persists across selections — the el prop changes).
|
|
3998
|
+
const hasCustom =
|
|
3999
|
+
Object.keys(customStyles).length > 0 || Object.keys(attrs).length > 0;
|
|
4000
|
+
useEffect(() => {
|
|
4001
|
+
if (hasCustom) setOpen((o) => (o.Advanced ? o : { ...o, Advanced: true }));
|
|
4002
|
+
}, [el.id, hasCustom]);
|
|
4003
|
+
|
|
4004
|
+
async function post(url, payload, key) {
|
|
4005
|
+
setStatus((s) => ({ ...s, [key]: 'saving' }));
|
|
4006
|
+
try {
|
|
4007
|
+
const res = await fetch(url, {
|
|
4008
|
+
method: 'POST',
|
|
4009
|
+
headers: { 'content-type': 'application/json' },
|
|
4010
|
+
body: JSON.stringify(payload),
|
|
4011
|
+
});
|
|
4012
|
+
const j = await res.json().catch(() => ({}));
|
|
4013
|
+
setStatus((s) => ({
|
|
4014
|
+
...s,
|
|
4015
|
+
[key]: !res.ok || !j.ok ? `err:${(j && j.error) || `HTTP ${res.status}`}` : 'saved',
|
|
4016
|
+
}));
|
|
4017
|
+
} catch (err) {
|
|
4018
|
+
setStatus((s) => ({ ...s, [key]: `err:${err && err.message ? err.message : String(err)}` }));
|
|
4019
|
+
}
|
|
4020
|
+
}
|
|
4021
|
+
// Optimistic preview: nudge the live element so the change shows before the
|
|
4022
|
+
// edit → HMR reload lands. `value` null = remove (reset path). No-op when the
|
|
4023
|
+
// selection has no stable id (can't be resolved in the canvas).
|
|
4024
|
+
const optimistic = (prop, value) => {
|
|
4025
|
+
if (!onOptimistic || !el.id) return;
|
|
4026
|
+
onOptimistic({
|
|
4027
|
+
id: el.id,
|
|
4028
|
+
artboardId: el.artboardId ?? null,
|
|
4029
|
+
index: el.index ?? 0,
|
|
4030
|
+
prop,
|
|
4031
|
+
value,
|
|
4032
|
+
});
|
|
4033
|
+
};
|
|
4034
|
+
const commit = (property, raw) => {
|
|
4035
|
+
const value = (raw || '').trim();
|
|
4036
|
+
if (!editable || !value) return;
|
|
4037
|
+
if (value === (authored[property] ?? '').trim()) return; // no-op
|
|
4038
|
+
optimistic(property, value);
|
|
4039
|
+
setA(property, value); // reflect in the panel immediately (no reload → no reselect)
|
|
4040
|
+
post('/_api/edit-css', { canvas: el.file, id: el.id, property, value }, property);
|
|
4041
|
+
};
|
|
4042
|
+
// A custom CSS property (Advanced) — same write, but the panel surfaces it from
|
|
4043
|
+
// the customStyles map, so overlay THERE.
|
|
4044
|
+
const commitCustom = (property, raw) => {
|
|
4045
|
+
const value = (raw || '').trim();
|
|
4046
|
+
if (!editable || !property.trim() || !value) return;
|
|
4047
|
+
optimistic(property.trim(), value);
|
|
4048
|
+
setC(property.trim(), value);
|
|
4049
|
+
post('/_api/edit-css', { canvas: el.file, id: el.id, property: property.trim(), value }, property.trim());
|
|
4050
|
+
};
|
|
4051
|
+
const commitAttr = (attr, raw) => {
|
|
4052
|
+
const a = (attr || '').trim();
|
|
4053
|
+
const value = (raw || '').trim();
|
|
4054
|
+
if (!editable || !a || !value) return;
|
|
4055
|
+
setT(a, value);
|
|
4056
|
+
post('/_api/edit-attr', { canvas: el.file, id: el.id, attr: a, value }, `@${a}`);
|
|
4057
|
+
};
|
|
4058
|
+
// Phase 12.3 — reset (remove the inline prop / attr → back to class/inherited).
|
|
4059
|
+
const reset = (property) => {
|
|
4060
|
+
if (!editable) return;
|
|
4061
|
+
optimistic(property, null);
|
|
4062
|
+
setA(property, null);
|
|
4063
|
+
post('/_api/edit-css', { canvas: el.file, id: el.id, property, reset: true }, property);
|
|
4064
|
+
};
|
|
4065
|
+
const resetCustom = (property) => {
|
|
4066
|
+
if (!editable) return;
|
|
4067
|
+
optimistic(property, null);
|
|
4068
|
+
setC(property, null);
|
|
4069
|
+
post('/_api/edit-css', { canvas: el.file, id: el.id, property, reset: true }, property);
|
|
4070
|
+
};
|
|
4071
|
+
const resetAttr = (attr) => {
|
|
4072
|
+
if (!editable) return;
|
|
4073
|
+
setT(attr, null);
|
|
4074
|
+
post('/_api/edit-attr', { canvas: el.file, id: el.id, attr, reset: true }, `@${attr}`);
|
|
4075
|
+
};
|
|
4076
|
+
// Phase 12.3 (W2.2) — Figma/Webflow scrub: drag a number field horizontally to
|
|
4077
|
+
// change its value. Live preview via optimistic apply on every move (no source
|
|
4078
|
+
// write); commits ONCE on release. A pointer that doesn't pass a 3px threshold
|
|
4079
|
+
// is a normal click (focus to type). `opts.step` modifiers: shift = ×10, alt =
|
|
4080
|
+
// ×0.1. `opts.sides` enables Webflow box-model modifiers: alt = symmetric pair,
|
|
4081
|
+
// alt+shift = all four (else just this side). `opts.min` clamps (default 0).
|
|
4082
|
+
const makeScrub = (prop, opts = {}) => (e) => {
|
|
4083
|
+
if (e.button !== 0) return;
|
|
4084
|
+
const input = e.currentTarget;
|
|
4085
|
+
const startX = e.clientX;
|
|
4086
|
+
const baseN =
|
|
4087
|
+
Number.parseFloat(
|
|
4088
|
+
cssSplitUnit(authored[prop] ?? cssHint(computed[prop]) ?? '0').n || '0'
|
|
4089
|
+
) || 0;
|
|
4090
|
+
const unit = opts.unitless
|
|
4091
|
+
? ''
|
|
4092
|
+
: opts.unit || cssSplitUnit(authored[prop] ?? '').unit || 'px';
|
|
4093
|
+
const min = opts.min ?? 0;
|
|
4094
|
+
const fmt = (n) => (opts.unitless ? `${n}` : `${n}${unit}`);
|
|
4095
|
+
const sidesFor = (ev) => {
|
|
4096
|
+
if (!opts.sides) return [prop];
|
|
4097
|
+
if (ev.altKey && ev.shiftKey) return opts.sides.all;
|
|
4098
|
+
if (ev.altKey) return opts.sides.pair;
|
|
4099
|
+
return [prop];
|
|
4100
|
+
};
|
|
4101
|
+
let scrubbing = false;
|
|
4102
|
+
let last = baseN;
|
|
4103
|
+
const move = (ev) => {
|
|
4104
|
+
const dx = ev.clientX - startX;
|
|
4105
|
+
if (!scrubbing && Math.abs(dx) < 3) return;
|
|
4106
|
+
if (!scrubbing) {
|
|
4107
|
+
scrubbing = true;
|
|
4108
|
+
document.body.classList.add('st-scrubbing');
|
|
4109
|
+
}
|
|
4110
|
+
ev.preventDefault();
|
|
4111
|
+
const granular = opts.sides ? 1 : ev.shiftKey ? 10 : ev.altKey ? 0.1 : 1;
|
|
4112
|
+
last = Math.round((baseN + dx * granular) * 100) / 100;
|
|
4113
|
+
if (last < min) last = min;
|
|
4114
|
+
const sides = sidesFor(ev);
|
|
4115
|
+
// Live-update the dragged field AND, for a box-model multi-side scrub, the
|
|
4116
|
+
// sibling box inputs so the whole pair / four-up move shows in the panel —
|
|
4117
|
+
// not just the one being dragged (W2.2 feedback).
|
|
4118
|
+
if (input) input.value = String(last);
|
|
4119
|
+
if (opts.sides && sides.length > 1) {
|
|
4120
|
+
const box = input?.closest('.st-cp-box');
|
|
4121
|
+
for (const p of sides) {
|
|
4122
|
+
if (p === prop) continue;
|
|
4123
|
+
const sib = box?.querySelector(`.st-cp-boxv[aria-label="${p}"]`);
|
|
4124
|
+
if (sib) sib.value = String(last);
|
|
4125
|
+
}
|
|
4126
|
+
}
|
|
4127
|
+
for (const p of sides) optimistic(p, fmt(last));
|
|
4128
|
+
};
|
|
4129
|
+
const up = (ev) => {
|
|
4130
|
+
document.removeEventListener('pointermove', move);
|
|
4131
|
+
document.removeEventListener('pointerup', up);
|
|
4132
|
+
if (!scrubbing) return;
|
|
4133
|
+
document.body.classList.remove('st-scrubbing');
|
|
4134
|
+
for (const p of sidesFor(ev)) commit(p, fmt(last));
|
|
4135
|
+
};
|
|
4136
|
+
document.addEventListener('pointermove', move);
|
|
4137
|
+
document.addEventListener('pointerup', up);
|
|
4138
|
+
};
|
|
4139
|
+
const provOf = (prop) => {
|
|
4140
|
+
const v = authored[prop];
|
|
4141
|
+
if (!v) return 'inherit';
|
|
4142
|
+
return /var\(\s*--/.test(v) ? 'bound' : 'raw';
|
|
4143
|
+
};
|
|
4144
|
+
|
|
4145
|
+
if (!editable) {
|
|
4146
|
+
return (
|
|
4147
|
+
<div className="st-cp">
|
|
4148
|
+
<div className="st-cp-id">
|
|
4149
|
+
<span className="st-cp-idtag">{el.tag || 'element'}</span>
|
|
4150
|
+
</div>
|
|
4151
|
+
<div className="st-css-disabled">
|
|
4152
|
+
This selection has no stable element id (a legacy canvas, or a non-element target). Edit
|
|
4153
|
+
it with <code>/design:edit</code>.
|
|
4154
|
+
</div>
|
|
4155
|
+
</div>
|
|
4156
|
+
);
|
|
4157
|
+
}
|
|
4158
|
+
|
|
4159
|
+
const PROVLABEL = { bound: 'token-bound', raw: 'raw override', inherit: 'inherited' };
|
|
4160
|
+
const prov = (p) => (
|
|
4161
|
+
<span className={`st-cp-prov st-cp-prov--${p}`} role="img" aria-label={PROVLABEL[p]} />
|
|
4162
|
+
);
|
|
4163
|
+
|
|
4164
|
+
// Phase 12.3 (#4) — the LEADING dot carries it all: provenance (shape) + save
|
|
4165
|
+
// status (a success/error/saving glow) + reset (double-click an authored row).
|
|
4166
|
+
// No trailing ✓/⟲ that shift the input rightward (the user's gripe). A tooltip
|
|
4167
|
+
// hints the double-click-to-reset.
|
|
4168
|
+
const provDot = (prop, provKind) => {
|
|
4169
|
+
const k = provKind ?? provOf(prop);
|
|
4170
|
+
const s = status[prop];
|
|
4171
|
+
const errMsg = typeof s === 'string' && s.startsWith('err:') ? s.slice(4) : '';
|
|
4172
|
+
const stCls = errMsg ? ' is-err' : s === 'saved' ? ' is-saved' : s === 'saving' ? ' is-saving' : '';
|
|
4173
|
+
const canReset = !!authored[prop];
|
|
4174
|
+
const tip = errMsg
|
|
4175
|
+
? `error: ${errMsg}`
|
|
4176
|
+
: canReset
|
|
4177
|
+
? `${PROVLABEL[k]} · double-click to reset`
|
|
4178
|
+
: PROVLABEL[k];
|
|
4179
|
+
return (
|
|
4180
|
+
<button
|
|
4181
|
+
type="button"
|
|
4182
|
+
className={`st-cp-prov st-cp-prov--${k}${stCls}${canReset ? ' is-resettable' : ''}`}
|
|
4183
|
+
aria-label={tip}
|
|
4184
|
+
title={tip}
|
|
4185
|
+
tabIndex={canReset ? 0 : -1}
|
|
4186
|
+
onDoubleClick={canReset ? () => reset(prop) : undefined}
|
|
4187
|
+
onKeyDown={
|
|
4188
|
+
canReset
|
|
4189
|
+
? (e) => {
|
|
4190
|
+
if (e.key === 'Backspace' || e.key === 'Delete') {
|
|
4191
|
+
e.preventDefault();
|
|
4192
|
+
reset(prop);
|
|
4193
|
+
}
|
|
4194
|
+
}
|
|
4195
|
+
: undefined
|
|
4196
|
+
}
|
|
4197
|
+
/>
|
|
4198
|
+
);
|
|
4199
|
+
};
|
|
4200
|
+
|
|
4201
|
+
const row = (prop, control, provKind) => {
|
|
4202
|
+
// #1 bigger-bet — scannable diff: a fully-unset single-prop row is dimmed so
|
|
4203
|
+
// the handful of overridden rows pop (Webflow/Framer model). Composite rows
|
|
4204
|
+
// (border — they pass an explicit provKind) are never dimmed.
|
|
4205
|
+
const unset = provKind === undefined && !authored[prop];
|
|
4206
|
+
return (
|
|
4207
|
+
<div className={`st-cp-row${unset ? ' is-unset' : ''}`} key={prop}>
|
|
4208
|
+
{provDot(prop, provKind)}
|
|
4209
|
+
<label className="st-cp-label" title={prop}>
|
|
4210
|
+
{prop}
|
|
4211
|
+
</label>
|
|
4212
|
+
<div className="st-cp-ctl">{control}</div>
|
|
4213
|
+
</div>
|
|
4214
|
+
);
|
|
4215
|
+
};
|
|
4216
|
+
|
|
4217
|
+
// Props each section owns — drives the per-section "reset section" affordance.
|
|
4218
|
+
const SECTION_PROPS = {
|
|
4219
|
+
Layout: ['display', 'flex-direction', 'align-items', 'justify-content', 'gap'],
|
|
4220
|
+
Typography: [
|
|
4221
|
+
'font-family',
|
|
4222
|
+
'color',
|
|
4223
|
+
'font-size',
|
|
4224
|
+
'font-weight',
|
|
4225
|
+
'line-height',
|
|
4226
|
+
'letter-spacing',
|
|
4227
|
+
'text-align',
|
|
4228
|
+
],
|
|
4229
|
+
Spacing: [
|
|
4230
|
+
'margin-top',
|
|
4231
|
+
'margin-right',
|
|
4232
|
+
'margin-bottom',
|
|
4233
|
+
'margin-left',
|
|
4234
|
+
'padding-top',
|
|
4235
|
+
'padding-right',
|
|
4236
|
+
'padding-bottom',
|
|
4237
|
+
'padding-left',
|
|
4238
|
+
],
|
|
4239
|
+
Size: ['width', 'height', 'max-width'],
|
|
4240
|
+
Appearance: [
|
|
4241
|
+
'background-color',
|
|
4242
|
+
'border-radius',
|
|
4243
|
+
'border-top-left-radius',
|
|
4244
|
+
'border-top-right-radius',
|
|
4245
|
+
'border-bottom-left-radius',
|
|
4246
|
+
'border-bottom-right-radius',
|
|
4247
|
+
'border-width',
|
|
4248
|
+
'border-style',
|
|
4249
|
+
'border-color',
|
|
4250
|
+
'box-shadow',
|
|
4251
|
+
'opacity',
|
|
4252
|
+
],
|
|
4253
|
+
};
|
|
4254
|
+
const resetSection = (name) => {
|
|
4255
|
+
(SECTION_PROPS[name] || []).forEach((p) => {
|
|
4256
|
+
if (authored[p]) reset(p);
|
|
4257
|
+
});
|
|
4258
|
+
};
|
|
4259
|
+
|
|
4260
|
+
const sec = (name, body) => {
|
|
4261
|
+
const dirty = (SECTION_PROPS[name] || []).some((p) => authored[p]);
|
|
4262
|
+
return (
|
|
4263
|
+
<section className="st-cp-sec" key={name}>
|
|
4264
|
+
<div className="st-cp-sechd-row">
|
|
4265
|
+
<button
|
|
4266
|
+
type="button"
|
|
4267
|
+
className="st-cp-sechd"
|
|
4268
|
+
aria-expanded={!!open[name]}
|
|
4269
|
+
onClick={() => setOpen((o) => ({ ...o, [name]: !o[name] }))}
|
|
4270
|
+
>
|
|
4271
|
+
<span className="st-cp-caret" aria-hidden="true">
|
|
4272
|
+
{open[name] ? '▾' : '▸'}
|
|
4273
|
+
</span>
|
|
4274
|
+
{name}
|
|
4275
|
+
</button>
|
|
4276
|
+
{dirty ? (
|
|
4277
|
+
<button
|
|
4278
|
+
type="button"
|
|
4279
|
+
className="st-cp-secreset"
|
|
4280
|
+
aria-label={`reset ${name} section to original`}
|
|
4281
|
+
title={`reset ${name}`}
|
|
4282
|
+
onClick={() => resetSection(name)}
|
|
4283
|
+
>
|
|
4284
|
+
⟲
|
|
4285
|
+
</button>
|
|
4286
|
+
) : null}
|
|
4287
|
+
</div>
|
|
4288
|
+
{open[name] ? body : null}
|
|
4289
|
+
</section>
|
|
4290
|
+
);
|
|
4291
|
+
};
|
|
4292
|
+
|
|
4293
|
+
// native <select> committing a CSS value directly
|
|
4294
|
+
const csel = (prop, list) => (
|
|
4295
|
+
<select
|
|
4296
|
+
className="st-cp-nsel"
|
|
4297
|
+
aria-label={prop}
|
|
4298
|
+
value={list.includes(authored[prop]) ? authored[prop] : ''}
|
|
4299
|
+
onChange={(e) => commit(prop, e.target.value)}
|
|
4300
|
+
>
|
|
4301
|
+
<option value="" disabled>
|
|
4302
|
+
{cssHint(computed[prop]) || '—'}
|
|
4303
|
+
</option>
|
|
4304
|
+
{list.map((v) => (
|
|
4305
|
+
<option key={v} value={v}>
|
|
4306
|
+
{v}
|
|
4307
|
+
</option>
|
|
4308
|
+
))}
|
|
4309
|
+
</select>
|
|
4310
|
+
);
|
|
4311
|
+
|
|
4312
|
+
// token quick-pick — Figma-style POPOVER (W2.1) listing the DS variables for
|
|
4313
|
+
// this property (name + resolved value), grouped per design system (W3);
|
|
4314
|
+
// picking writes var(--token). `familyKey` selects the token family.
|
|
4315
|
+
const tok = (prop, familyKey) => {
|
|
4316
|
+
const groups = tokenGroups(familyKey);
|
|
4317
|
+
return groups.length ? (
|
|
4318
|
+
<TokenPopover
|
|
4319
|
+
kind="value"
|
|
4320
|
+
groups={groups}
|
|
4321
|
+
current={authored[prop]}
|
|
4322
|
+
activeDs={_activeDs}
|
|
4323
|
+
onPick={(v) => commit(prop, v)}
|
|
4324
|
+
label={`${prop} design token`}
|
|
4325
|
+
/>
|
|
4326
|
+
) : null;
|
|
4327
|
+
};
|
|
4328
|
+
|
|
4329
|
+
// free text input — raw value or var(--token), commits on blur/Enter
|
|
4330
|
+
const text = (prop) => (
|
|
4331
|
+
<input
|
|
4332
|
+
className="st-cp-fin"
|
|
4333
|
+
key={`${prop}:${authored[prop] ?? ''}`}
|
|
4334
|
+
aria-label={prop}
|
|
4335
|
+
defaultValue={authored[prop] ?? ''}
|
|
4336
|
+
placeholder={cssHint(computed[prop]) || '—'}
|
|
4337
|
+
onKeyDown={(e) => {
|
|
4338
|
+
if (e.key === 'Enter') e.currentTarget.blur();
|
|
4339
|
+
}}
|
|
4340
|
+
onBlur={(e) => commit(prop, e.currentTarget.value)}
|
|
4341
|
+
/>
|
|
4342
|
+
);
|
|
4343
|
+
|
|
4344
|
+
// number + steppers + unit-select (+ optional token quick-pick after)
|
|
4345
|
+
const num = (prop, tokenList, opts = {}) => {
|
|
4346
|
+
const cur = cssSplitUnit(authored[prop] ?? '');
|
|
4347
|
+
// Unitless CSS properties — a bare number must commit WITHOUT a unit suffix
|
|
4348
|
+
// (line-height: 1.5px ≠ 1.5 — knob-smoke finding, 2026-06-12).
|
|
4349
|
+
const unitless = CSS_UNITLESS.has(prop);
|
|
4350
|
+
const unit = unitless ? '' : cur.unit && cur.unit !== 'auto' ? cur.unit : 'px';
|
|
4351
|
+
const bump = (d) => {
|
|
4352
|
+
const base = Number.parseFloat(cur.n || cssHint(computed[prop]) || '0') || 0;
|
|
4353
|
+
commit(prop, `${Math.round((base + d) * 100) / 100}${unit}`);
|
|
4354
|
+
};
|
|
4355
|
+
const lead = PROP_LEAD[prop];
|
|
4356
|
+
return (
|
|
4357
|
+
<>
|
|
4358
|
+
<div className="st-cp-num">
|
|
4359
|
+
{lead ? (
|
|
4360
|
+
<span className="st-cp-numlead" aria-hidden="true">
|
|
4361
|
+
{lead.t ? lead.t : <StIcon name={lead.icon} size={12} />}
|
|
4362
|
+
</span>
|
|
4363
|
+
) : null}
|
|
4364
|
+
<input
|
|
4365
|
+
className="st-cp-numin st-cp-scrub"
|
|
4366
|
+
key={`${prop}:${authored[prop] ?? ''}`}
|
|
4367
|
+
aria-label={prop}
|
|
4368
|
+
defaultValue={cur.unit && cur.unit !== '' ? cur.n : (authored[prop] ?? '')}
|
|
4369
|
+
placeholder={cssHint(computed[prop]) || '—'}
|
|
4370
|
+
onPointerDown={makeScrub(prop, { unitless, unit, min: opts.min })}
|
|
4371
|
+
onKeyDown={(e) => {
|
|
4372
|
+
if (e.key === 'Enter') e.currentTarget.blur();
|
|
4373
|
+
}}
|
|
4374
|
+
onBlur={(e) => {
|
|
4375
|
+
const raw = e.currentTarget.value.trim();
|
|
4376
|
+
if (!raw) return;
|
|
4377
|
+
commit(prop, /[a-z%(]/i.test(raw) ? raw : `${raw}${unit}`);
|
|
4378
|
+
}}
|
|
4379
|
+
/>
|
|
4380
|
+
<span className="st-cp-step">
|
|
4381
|
+
<button
|
|
4382
|
+
type="button"
|
|
4383
|
+
className="st-cp-stepb"
|
|
4384
|
+
tabIndex={-1}
|
|
4385
|
+
aria-label={`increase ${prop}`}
|
|
4386
|
+
onClick={() => bump(1)}
|
|
4387
|
+
>
|
|
4388
|
+
▲
|
|
4389
|
+
</button>
|
|
4390
|
+
<button
|
|
4391
|
+
type="button"
|
|
4392
|
+
className="st-cp-stepb"
|
|
4393
|
+
tabIndex={-1}
|
|
4394
|
+
aria-label={`decrease ${prop}`}
|
|
4395
|
+
onClick={() => bump(-1)}
|
|
4396
|
+
>
|
|
4397
|
+
▼
|
|
4398
|
+
</button>
|
|
4399
|
+
</span>
|
|
4400
|
+
{unitless ? null : (
|
|
4401
|
+
<select
|
|
4402
|
+
className="st-cp-unitsel"
|
|
4403
|
+
aria-label={`${prop} unit`}
|
|
4404
|
+
value={cur.unit || 'px'}
|
|
4405
|
+
onChange={(e) =>
|
|
4406
|
+
commit(prop, e.target.value === 'auto' ? 'auto' : `${cur.n || '0'}${e.target.value}`)
|
|
4407
|
+
}
|
|
4408
|
+
>
|
|
4409
|
+
{CSS_UNITS.map((u) => (
|
|
4410
|
+
<option key={u} value={u}>
|
|
4411
|
+
{u}
|
|
4412
|
+
</option>
|
|
4413
|
+
))}
|
|
4414
|
+
</select>
|
|
4415
|
+
)}
|
|
4416
|
+
</div>
|
|
4417
|
+
{tok(prop, tokenList)}
|
|
4418
|
+
</>
|
|
4419
|
+
);
|
|
4420
|
+
};
|
|
4421
|
+
|
|
4422
|
+
// color swatch (native picker → hex) + raw text + token quick-pick
|
|
4423
|
+
const color = (prop) => {
|
|
4424
|
+
// ONE colour control: the swatch is the trigger for a single popover with a
|
|
4425
|
+
// full HSV picker (Custom) + the DS swatches (Variables). No separate native
|
|
4426
|
+
// OS picker (#6 — was two popovers doing the same thing).
|
|
4427
|
+
const resolved = computed[prop] || authored[prop] || '';
|
|
4428
|
+
return (
|
|
4429
|
+
<>
|
|
4430
|
+
<TokenPopover
|
|
4431
|
+
kind="color"
|
|
4432
|
+
groups={tokenGroups('color')}
|
|
4433
|
+
current={authored[prop]}
|
|
4434
|
+
activeDs={_activeDs}
|
|
4435
|
+
swatchBg={resolved}
|
|
4436
|
+
seedHex={cssColorToHex(computed[prop] || authored[prop]) || '#000000'}
|
|
4437
|
+
onPick={(v) => commit(prop, v)}
|
|
4438
|
+
label={`${prop} colour`}
|
|
4439
|
+
/>
|
|
4440
|
+
{text(prop)}
|
|
4441
|
+
</>
|
|
4442
|
+
);
|
|
4443
|
+
};
|
|
4444
|
+
|
|
4445
|
+
// a box-model side input (margin/padding longhand). Phase 12.3 — Webflow-style:
|
|
4446
|
+
// always shows the RESOLVED value (0 instead of blank) and a faint `is-zero`
|
|
4447
|
+
// styling for an unset/zero side. Edits the single side (the old "link all
|
|
4448
|
+
// sides" toggle was removed — DDR-104 Phase 12.3 W1.5).
|
|
4449
|
+
const side = (prop, group) => {
|
|
4450
|
+
const a = authored[prop];
|
|
4451
|
+
const shown =
|
|
4452
|
+
a != null && a !== ''
|
|
4453
|
+
? cssSplitUnit(a).n || a
|
|
4454
|
+
: cssSplitUnit(cssHint(computed[prop]) ?? '').n || '0';
|
|
4455
|
+
const isZero = !a || a === '0' || a === '0px' || a === 'auto';
|
|
4456
|
+
// Webflow scrub modifiers — alt = symmetric pair (block for top/bottom,
|
|
4457
|
+
// inline for left/right), alt+shift = all four.
|
|
4458
|
+
const edge = prop.split('-').pop();
|
|
4459
|
+
const pair =
|
|
4460
|
+
edge === 'top' || edge === 'bottom'
|
|
4461
|
+
? [`${group}-top`, `${group}-bottom`]
|
|
4462
|
+
: [`${group}-left`, `${group}-right`];
|
|
4463
|
+
const all = [`${group}-top`, `${group}-right`, `${group}-bottom`, `${group}-left`];
|
|
4464
|
+
return (
|
|
4465
|
+
<input
|
|
4466
|
+
className={`st-cp-boxv st-cp-scrub st-cp-boxv--${group[0]}${prop.split('-').pop()[0]}${
|
|
4467
|
+
isZero ? ' is-zero' : ''
|
|
4468
|
+
}`}
|
|
4469
|
+
key={`${prop}:${a ?? ''}`}
|
|
4470
|
+
aria-label={prop}
|
|
4471
|
+
defaultValue={shown}
|
|
4472
|
+
title="drag to scrub · alt = symmetric · alt+shift = all sides"
|
|
4473
|
+
onPointerDown={makeScrub(prop, { sides: { pair, all } })}
|
|
4474
|
+
onKeyDown={(e) => {
|
|
4475
|
+
if (e.key === 'Enter') e.currentTarget.blur();
|
|
4476
|
+
}}
|
|
4477
|
+
onBlur={(e) => {
|
|
4478
|
+
const raw = e.currentTarget.value.trim();
|
|
4479
|
+
if (!raw) return;
|
|
4480
|
+
const val = /[a-z%]/i.test(raw) ? raw : `${raw}px`;
|
|
4481
|
+
commit(prop, val);
|
|
4482
|
+
}}
|
|
4483
|
+
/>
|
|
4484
|
+
);
|
|
4485
|
+
};
|
|
4486
|
+
|
|
4487
|
+
const corner = (label, prop) => (
|
|
4488
|
+
<label className="st-cp-cornerf">
|
|
4489
|
+
<span>{label}</span>
|
|
4490
|
+
<input
|
|
4491
|
+
key={`${prop}:${authored[prop] ?? ''}`}
|
|
4492
|
+
aria-label={prop}
|
|
4493
|
+
defaultValue={cssSplitUnit(authored[prop] ?? '').n || ''}
|
|
4494
|
+
placeholder={cssHint(computed[prop]) || '0'}
|
|
4495
|
+
onKeyDown={(e) => {
|
|
4496
|
+
if (e.key === 'Enter') e.currentTarget.blur();
|
|
4497
|
+
}}
|
|
4498
|
+
onBlur={(e) => {
|
|
4499
|
+
const raw = e.currentTarget.value.trim();
|
|
4500
|
+
if (raw) commit(prop, /[a-z%]/i.test(raw) ? raw : `${raw}px`);
|
|
4501
|
+
}}
|
|
4502
|
+
/>
|
|
4503
|
+
</label>
|
|
4504
|
+
);
|
|
4505
|
+
|
|
4506
|
+
// Phase 12.3 — authored inline props with no curated row + custom HTML attrs,
|
|
4507
|
+
// surfaced in Advanced so the user can see/edit/remove what they added.
|
|
4508
|
+
const customStyleRows = Object.entries(customStyles);
|
|
4509
|
+
const attrRows = Object.entries(attrs);
|
|
4510
|
+
|
|
4511
|
+
return (
|
|
4512
|
+
<div className="st-cp" key={el.id} data-tour="css-panel">
|
|
4513
|
+
<div className="st-cp-id">
|
|
4514
|
+
<span className="st-cp-idtag">
|
|
4515
|
+
{el.tag || 'element'}
|
|
4516
|
+
{el.classes ? <span className="st-cp-idcls">.{el.classes.split(/\s+/)[0]}</span> : null}
|
|
4517
|
+
</span>
|
|
4518
|
+
<span className="st-cp-idmeta">inline style</span>
|
|
4519
|
+
</div>
|
|
4520
|
+
|
|
4521
|
+
{sec(
|
|
4522
|
+
'Layout',
|
|
4523
|
+
<>
|
|
4524
|
+
{row('display', csel('display', CSS_DISPLAYS))}
|
|
4525
|
+
{row('flex-direction', csel('flex-direction', CSS_FLEX_DIR))}
|
|
4526
|
+
{row('align-items', csel('align-items', CSS_ALIGN))}
|
|
4527
|
+
{row('justify-content', csel('justify-content', CSS_JUSTIFY))}
|
|
4528
|
+
{row('gap', num('gap', 'space'))}
|
|
4529
|
+
</>
|
|
4530
|
+
)}
|
|
4531
|
+
|
|
4532
|
+
{sec(
|
|
4533
|
+
'Typography',
|
|
4534
|
+
<>
|
|
4535
|
+
{row('font-family', csel('font-family', CSS_FONTS))}
|
|
4536
|
+
{row('color', color('color'))}
|
|
4537
|
+
{row('font-size', num('font-size', 'type'))}
|
|
4538
|
+
{row('font-weight', csel('font-weight', CSS_WEIGHTS))}
|
|
4539
|
+
{row('line-height', num('line-height', 'lh'))}
|
|
4540
|
+
{row('letter-spacing', num('letter-spacing', null, { min: -Infinity }))}
|
|
4541
|
+
{row(
|
|
4542
|
+
'text-align',
|
|
4543
|
+
<div className="st-cp-seg" role="group" aria-label="text-align">
|
|
4544
|
+
{CSS_ALIGN_OPTS.map((a) => (
|
|
4545
|
+
<button
|
|
4546
|
+
key={a}
|
|
4547
|
+
type="button"
|
|
4548
|
+
className={`st-cp-segbtn${(authored['text-align'] || computed['text-align']) === a ? ' is-active' : ''}`}
|
|
4549
|
+
aria-label={`align ${a}`}
|
|
4550
|
+
aria-pressed={(authored['text-align'] || computed['text-align']) === a}
|
|
4551
|
+
onClick={() => commit('text-align', a)}
|
|
4552
|
+
>
|
|
4553
|
+
<span className={`st-cp-bars st-cp-bars--${a === 'justify' ? 'just' : a}`} aria-hidden="true">
|
|
4554
|
+
<i />
|
|
4555
|
+
<i />
|
|
4556
|
+
<i />
|
|
4557
|
+
</span>
|
|
4558
|
+
</button>
|
|
4559
|
+
))}
|
|
4560
|
+
</div>
|
|
4561
|
+
)}
|
|
4562
|
+
</>
|
|
4563
|
+
)}
|
|
4564
|
+
|
|
4565
|
+
{sec(
|
|
4566
|
+
'Spacing',
|
|
4567
|
+
<>
|
|
4568
|
+
<div className="st-cp-box" aria-label="margin and padding">
|
|
4569
|
+
<span className="st-cp-boxtag st-cp-boxtag--m">
|
|
4570
|
+
{prov(provOf('margin-top'))}margin
|
|
4571
|
+
</span>
|
|
4572
|
+
{side('margin-top', 'margin')}
|
|
4573
|
+
{side('margin-right', 'margin')}
|
|
4574
|
+
{side('margin-bottom', 'margin')}
|
|
4575
|
+
{side('margin-left', 'margin')}
|
|
4576
|
+
<div className="st-cp-boxpad">
|
|
4577
|
+
<span className="st-cp-boxtag st-cp-boxtag--p">
|
|
4578
|
+
{prov(provOf('padding-top'))}padding
|
|
4579
|
+
</span>
|
|
4580
|
+
{side('padding-top', 'padding')}
|
|
4581
|
+
{side('padding-right', 'padding')}
|
|
4582
|
+
{side('padding-bottom', 'padding')}
|
|
4583
|
+
{side('padding-left', 'padding')}
|
|
4584
|
+
<div className="st-cp-boxcore">
|
|
4585
|
+
{Math.round(el.bounds?.w || 0)} × {Math.round(el.bounds?.h || 0)}
|
|
4586
|
+
</div>
|
|
4587
|
+
</div>
|
|
4588
|
+
</div>
|
|
4589
|
+
</>
|
|
4590
|
+
)}
|
|
4591
|
+
|
|
4592
|
+
{sec(
|
|
4593
|
+
'Size',
|
|
4594
|
+
<>
|
|
4595
|
+
{row('width', num('width'))}
|
|
4596
|
+
{row('height', num('height'))}
|
|
4597
|
+
{row('max-width', num('max-width'))}
|
|
4598
|
+
</>
|
|
4599
|
+
)}
|
|
4600
|
+
|
|
4601
|
+
{sec(
|
|
4602
|
+
'Appearance',
|
|
4603
|
+
<>
|
|
4604
|
+
{row('background-color', color('background-color'))}
|
|
4605
|
+
<div className="st-cp-row">
|
|
4606
|
+
{prov(provOf('border-radius'))}
|
|
4607
|
+
<label className="st-cp-label" title="border-radius">
|
|
4608
|
+
border-radius
|
|
4609
|
+
</label>
|
|
4610
|
+
<div className="st-cp-ctl">
|
|
4611
|
+
{num('border-radius', 'radius')}
|
|
4612
|
+
<button
|
|
4613
|
+
type="button"
|
|
4614
|
+
className={`st-cp-split${split ? ' is-on' : ''}`}
|
|
4615
|
+
aria-pressed={split}
|
|
4616
|
+
aria-label="set each corner separately"
|
|
4617
|
+
title="set each corner separately"
|
|
4618
|
+
onClick={() => setSplit((v) => !v)}
|
|
4619
|
+
/>
|
|
4620
|
+
</div>
|
|
4621
|
+
</div>
|
|
4622
|
+
{split ? (
|
|
4623
|
+
<div className="st-cp-corners" aria-label="per-corner radius">
|
|
4624
|
+
{corner('TL', 'border-top-left-radius')}
|
|
4625
|
+
{corner('TR', 'border-top-right-radius')}
|
|
4626
|
+
{corner('BL', 'border-bottom-left-radius')}
|
|
4627
|
+
{corner('BR', 'border-bottom-right-radius')}
|
|
4628
|
+
</div>
|
|
4629
|
+
) : null}
|
|
4630
|
+
{row(
|
|
4631
|
+
'border',
|
|
4632
|
+
<div className="st-cp-border">
|
|
4633
|
+
{num('border-width')}
|
|
4634
|
+
<select
|
|
4635
|
+
className="st-cp-nsel st-cp-nsel--mini"
|
|
4636
|
+
aria-label="border-style"
|
|
4637
|
+
value={CSS_BORDER_STYLES.includes(authored['border-style']) ? authored['border-style'] : ''}
|
|
4638
|
+
onChange={(e) => commit('border-style', e.target.value)}
|
|
4639
|
+
>
|
|
4640
|
+
<option value="" disabled>
|
|
4641
|
+
style
|
|
4642
|
+
</option>
|
|
4643
|
+
{CSS_BORDER_STYLES.map((s) => (
|
|
4644
|
+
<option key={s} value={s}>
|
|
4645
|
+
{s}
|
|
4646
|
+
</option>
|
|
4647
|
+
))}
|
|
4648
|
+
</select>
|
|
4649
|
+
<TokenPopover
|
|
4650
|
+
kind="color"
|
|
4651
|
+
groups={tokenGroups('color')}
|
|
4652
|
+
current={authored['border-color']}
|
|
4653
|
+
activeDs={_activeDs}
|
|
4654
|
+
swatchBg={computed['border-color'] || authored['border-color'] || ''}
|
|
4655
|
+
seedHex={
|
|
4656
|
+
cssColorToHex(computed['border-color'] || authored['border-color']) || '#000000'
|
|
4657
|
+
}
|
|
4658
|
+
onPick={(v) => commit('border-color', v)}
|
|
4659
|
+
label="border colour"
|
|
4660
|
+
/>
|
|
4661
|
+
</div>,
|
|
4662
|
+
provOf('border-width')
|
|
4663
|
+
)}
|
|
4664
|
+
{row('box-shadow', tok('box-shadow', 'shadow') || text('box-shadow'))}
|
|
4665
|
+
{row(
|
|
4666
|
+
'opacity',
|
|
4667
|
+
<div className="st-cp-num">
|
|
4668
|
+
<span className="st-cp-numlead" aria-hidden="true">
|
|
4669
|
+
<StIcon name="p-opacity" size={12} />
|
|
4670
|
+
</span>
|
|
4671
|
+
<input
|
|
4672
|
+
className="st-cp-numin"
|
|
4673
|
+
key={`opacity:${authored.opacity ?? ''}`}
|
|
4674
|
+
aria-label="opacity"
|
|
4675
|
+
defaultValue={authored.opacity ?? ''}
|
|
4676
|
+
placeholder={cssHint(computed.opacity) || '1'}
|
|
4677
|
+
onKeyDown={(e) => {
|
|
4678
|
+
if (e.key === 'Enter') e.currentTarget.blur();
|
|
4679
|
+
}}
|
|
4680
|
+
onBlur={(e) => commit('opacity', e.currentTarget.value)}
|
|
4681
|
+
/>
|
|
4682
|
+
</div>
|
|
4683
|
+
)}
|
|
4684
|
+
</>
|
|
4685
|
+
)}
|
|
4686
|
+
|
|
4687
|
+
{/* #5 — the idle/saved status now lives in each row's leading dot (a glow),
|
|
4688
|
+
so the panel no longer carries a confusing standing 'written to source'
|
|
4689
|
+
line. Only a hard ERROR surfaces here, with the failing property. */}
|
|
4690
|
+
{(() => {
|
|
4691
|
+
const err = Object.entries(status).find(
|
|
4692
|
+
([, s]) => typeof s === 'string' && s.startsWith('err:')
|
|
4693
|
+
);
|
|
4694
|
+
return err ? (
|
|
4695
|
+
<div className="st-cp-save is-err" role="status">
|
|
4696
|
+
<StIcon name="x" size={12} />
|
|
4697
|
+
{err[0]}: {err[1].slice(4)}
|
|
4698
|
+
</div>
|
|
4699
|
+
) : null;
|
|
4700
|
+
})()}
|
|
4701
|
+
|
|
4702
|
+
{sec(
|
|
4703
|
+
'Advanced',
|
|
4704
|
+
<div className="st-cp-advbody">
|
|
4705
|
+
{customStyleRows.length ? (
|
|
4706
|
+
<>
|
|
4707
|
+
<div className="st-cp-advgrp">Custom CSS properties</div>
|
|
4708
|
+
{customStyleRows.map(([p, v]) => (
|
|
4709
|
+
<div className="st-cp-kv" key={`cs:${p}`}>
|
|
4710
|
+
<input
|
|
4711
|
+
className="st-cp-fin st-cp-fin--ro"
|
|
4712
|
+
readOnly
|
|
4713
|
+
value={p}
|
|
4714
|
+
aria-label={`custom property ${p} name`}
|
|
4715
|
+
/>
|
|
4716
|
+
<input
|
|
4717
|
+
className="st-cp-fin"
|
|
4718
|
+
key={`cs:${p}:${v}`}
|
|
4719
|
+
defaultValue={v}
|
|
4720
|
+
aria-label={`${p} value`}
|
|
4721
|
+
onKeyDown={(e) => {
|
|
4722
|
+
if (e.key === 'Enter') e.currentTarget.blur();
|
|
4723
|
+
}}
|
|
4724
|
+
onBlur={(e) => commitCustom(p, e.currentTarget.value)}
|
|
4725
|
+
/>
|
|
4726
|
+
<button
|
|
4727
|
+
type="button"
|
|
4728
|
+
className="st-cp-kvx"
|
|
4729
|
+
aria-label={`remove ${p}`}
|
|
4730
|
+
title="remove"
|
|
4731
|
+
onClick={() => resetCustom(p)}
|
|
4732
|
+
>
|
|
4733
|
+
<StIcon name="x" size={11} />
|
|
4734
|
+
</button>
|
|
4735
|
+
</div>
|
|
4736
|
+
))}
|
|
4737
|
+
</>
|
|
4738
|
+
) : null}
|
|
4739
|
+
<div className="st-cp-advgrp">Add CSS property</div>
|
|
4740
|
+
<RawKnob commit={commitCustom} />
|
|
4741
|
+
<div className="st-cp-note">applied as-is — not token-bound</div>
|
|
4742
|
+
{attrRows.length ? (
|
|
4743
|
+
<>
|
|
4744
|
+
<div className="st-cp-advgrp">Custom HTML attributes</div>
|
|
4745
|
+
{attrRows.map(([a, v]) => (
|
|
4746
|
+
<div className="st-cp-kv" key={`at:${a}`}>
|
|
4747
|
+
<input
|
|
4748
|
+
className="st-cp-fin st-cp-fin--ro"
|
|
4749
|
+
readOnly
|
|
4750
|
+
value={a}
|
|
4751
|
+
aria-label={`attribute ${a} name`}
|
|
4752
|
+
/>
|
|
4753
|
+
<input
|
|
4754
|
+
className="st-cp-fin"
|
|
4755
|
+
key={`at:${a}:${v}`}
|
|
4756
|
+
defaultValue={v}
|
|
4757
|
+
aria-label={`${a} value`}
|
|
4758
|
+
onKeyDown={(e) => {
|
|
4759
|
+
if (e.key === 'Enter') e.currentTarget.blur();
|
|
4760
|
+
}}
|
|
4761
|
+
onBlur={(e) => commitAttr(a, e.currentTarget.value)}
|
|
4762
|
+
/>
|
|
4763
|
+
<button
|
|
4764
|
+
type="button"
|
|
4765
|
+
className="st-cp-kvx"
|
|
4766
|
+
aria-label={`remove ${a}`}
|
|
4767
|
+
title="remove"
|
|
4768
|
+
onClick={() => resetAttr(a)}
|
|
4769
|
+
>
|
|
4770
|
+
<StIcon name="x" size={11} />
|
|
4771
|
+
</button>
|
|
4772
|
+
</div>
|
|
4773
|
+
))}
|
|
4774
|
+
</>
|
|
4775
|
+
) : null}
|
|
4776
|
+
<div className="st-cp-advgrp">Add HTML attribute</div>
|
|
4777
|
+
<AttrKnob commit={commitAttr} />
|
|
4778
|
+
</div>
|
|
4779
|
+
)}
|
|
4780
|
+
|
|
4781
|
+
<div className="st-cp-legend">
|
|
4782
|
+
<span>
|
|
4783
|
+
<i className="st-cp-prov st-cp-prov--bound" aria-hidden="true" />
|
|
4784
|
+
token
|
|
4785
|
+
</span>
|
|
4786
|
+
<span>
|
|
4787
|
+
<i className="st-cp-prov st-cp-prov--raw" aria-hidden="true" />
|
|
4788
|
+
override
|
|
4789
|
+
</span>
|
|
4790
|
+
<span>
|
|
4791
|
+
<i className="st-cp-prov st-cp-prov--inherit" aria-hidden="true" />
|
|
4792
|
+
inherited
|
|
4793
|
+
</span>
|
|
4794
|
+
</div>
|
|
4795
|
+
</div>
|
|
4796
|
+
);
|
|
4797
|
+
}
|
|
4798
|
+
|
|
4799
|
+
// Custom CSS property hatch — writes an arbitrary `property: value` to inline style.
|
|
4800
|
+
function RawKnob({ commit }) {
|
|
4801
|
+
const [prop, setProp] = useState('');
|
|
4802
|
+
const [val, setVal] = useState('');
|
|
4803
|
+
const submit = () => {
|
|
4804
|
+
if (prop.trim() && val.trim()) {
|
|
4805
|
+
commit(prop.trim(), val);
|
|
4806
|
+
setProp('');
|
|
4807
|
+
setVal('');
|
|
4808
|
+
}
|
|
4809
|
+
};
|
|
4810
|
+
return (
|
|
4811
|
+
<div className="st-cp-kv">
|
|
4812
|
+
<input
|
|
4813
|
+
className="st-cp-fin"
|
|
4814
|
+
aria-label="custom property name"
|
|
4815
|
+
placeholder="property"
|
|
4816
|
+
value={prop}
|
|
4817
|
+
onChange={(e) => setProp(e.target.value)}
|
|
4818
|
+
/>
|
|
4819
|
+
<input
|
|
4820
|
+
className="st-cp-fin"
|
|
4821
|
+
aria-label="custom property value"
|
|
4822
|
+
placeholder="value"
|
|
4823
|
+
value={val}
|
|
4824
|
+
onChange={(e) => setVal(e.target.value)}
|
|
4825
|
+
onKeyDown={(e) => {
|
|
4826
|
+
if (e.key === 'Enter') submit();
|
|
4827
|
+
}}
|
|
4828
|
+
onBlur={submit}
|
|
4829
|
+
/>
|
|
4830
|
+
</div>
|
|
4831
|
+
);
|
|
4832
|
+
}
|
|
4833
|
+
|
|
4834
|
+
// Custom HTML attribute hatch — writes a plain JSX attribute (data-*, aria-*, …).
|
|
4835
|
+
function AttrKnob({ commit }) {
|
|
4836
|
+
const [attr, setAttr] = useState('');
|
|
4837
|
+
const [val, setVal] = useState('');
|
|
4838
|
+
const submit = () => {
|
|
4839
|
+
if (attr.trim() && val.trim()) {
|
|
4840
|
+
commit(attr.trim(), val);
|
|
4841
|
+
setAttr('');
|
|
4842
|
+
setVal('');
|
|
4843
|
+
}
|
|
4844
|
+
};
|
|
4845
|
+
return (
|
|
4846
|
+
<div className="st-cp-kv">
|
|
4847
|
+
<input
|
|
4848
|
+
className="st-cp-fin"
|
|
4849
|
+
aria-label="custom attribute name"
|
|
4850
|
+
placeholder="data-…"
|
|
4851
|
+
value={attr}
|
|
4852
|
+
onChange={(e) => setAttr(e.target.value)}
|
|
4853
|
+
/>
|
|
4854
|
+
<input
|
|
4855
|
+
className="st-cp-fin"
|
|
4856
|
+
aria-label="custom attribute value"
|
|
4857
|
+
placeholder="value"
|
|
4858
|
+
value={val}
|
|
4859
|
+
onChange={(e) => setVal(e.target.value)}
|
|
4860
|
+
onKeyDown={(e) => {
|
|
4861
|
+
if (e.key === 'Enter') submit();
|
|
4862
|
+
}}
|
|
4863
|
+
onBlur={submit}
|
|
4864
|
+
/>
|
|
4865
|
+
</div>
|
|
4866
|
+
);
|
|
4867
|
+
}
|
|
4868
|
+
|
|
4869
|
+
// ---------- Inspector panel (display-only) ----------
|
|
4870
|
+
//
|
|
4871
|
+
// T6 (Plan C) — right-dock Inspect / Layers / CSS tabs per `.design/ui/Studio.tsx`
|
|
4872
|
+
// InspectorPanel. DISPLAY-ONLY: reads the live `selected` payload from the
|
|
4873
|
+
// inspector bridge (`bounds`, `tag`, `classes`, `dom_path`, `html`). The
|
|
4874
|
+
// mockup's live-CSS-knob WRITEBACK is Phase 12 (needs a canvas-origin write
|
|
4875
|
+
// bridge, DDR-054) — the CSS tab shows markup read-only + keeps that callout, so
|
|
4876
|
+
// it never implies functionality it lacks (the exact reason DDR-096 deferred it).
|
|
4877
|
+
// ---------- Layers tree row (Phase 12 Task 4) ----------
|
|
4878
|
+
// Phase 12.3 (W3.1) — map a LayerNode `type` (classified in canvas-shell) to a
|
|
4879
|
+
// type-distinct icon, matching the Studio.tsx layers design.
|
|
4880
|
+
const LAYER_TYPE_ICON = {
|
|
4881
|
+
button: 'button',
|
|
4882
|
+
heading: 'type',
|
|
4883
|
+
text: 'type',
|
|
4884
|
+
input: 'input',
|
|
4885
|
+
form: 'input',
|
|
4886
|
+
image: 'image',
|
|
4887
|
+
link: 'link',
|
|
4888
|
+
list: 'list',
|
|
4889
|
+
nav: 'layers',
|
|
4890
|
+
box: 'box',
|
|
4891
|
+
};
|
|
4892
|
+
|
|
4893
|
+
function LayerRow({
|
|
4894
|
+
node,
|
|
4895
|
+
depth,
|
|
4896
|
+
selectedId,
|
|
4897
|
+
collapsed,
|
|
4898
|
+
hidden,
|
|
4899
|
+
onToggle,
|
|
4900
|
+
onSelect,
|
|
4901
|
+
onHover,
|
|
4902
|
+
onToggleVisibility,
|
|
4903
|
+
}) {
|
|
4904
|
+
const key = `${node.id}:${node.index}`;
|
|
4905
|
+
const hasKids = node.children && node.children.length > 0;
|
|
4906
|
+
const isCollapsed = collapsed.has(key);
|
|
4907
|
+
const isSel = node.id === selectedId;
|
|
4908
|
+
const isHidden = hidden?.has(key);
|
|
4909
|
+
return (
|
|
4910
|
+
<>
|
|
4911
|
+
<div
|
|
4912
|
+
className={
|
|
4913
|
+
'st-layer st-layer--row' + (isSel ? ' is-sel' : '') + (isHidden ? ' is-hidden' : '')
|
|
4914
|
+
}
|
|
4915
|
+
style={{ paddingLeft: 6 + depth * 14 }}
|
|
4916
|
+
role="treeitem"
|
|
4917
|
+
aria-selected={isSel}
|
|
4918
|
+
aria-expanded={hasKids ? !isCollapsed : undefined}
|
|
4919
|
+
tabIndex={0}
|
|
4920
|
+
title={`${node.tag} · ${node.type}`}
|
|
4921
|
+
onClick={() => onSelect(node)}
|
|
4922
|
+
onMouseEnter={() => onHover(node)}
|
|
4923
|
+
onMouseLeave={() => onHover(null)}
|
|
4924
|
+
onKeyDown={(e) => {
|
|
4925
|
+
if (e.key === 'Enter' || e.key === ' ') {
|
|
4926
|
+
e.preventDefault();
|
|
4927
|
+
onSelect(node);
|
|
4928
|
+
}
|
|
4929
|
+
}}
|
|
4930
|
+
>
|
|
4931
|
+
{hasKids ? (
|
|
4932
|
+
<button
|
|
4933
|
+
type="button"
|
|
4934
|
+
className="st-layer-caret"
|
|
4935
|
+
aria-label={isCollapsed ? 'Expand' : 'Collapse'}
|
|
4936
|
+
onClick={(e) => {
|
|
4937
|
+
e.stopPropagation();
|
|
4938
|
+
onToggle(key);
|
|
4939
|
+
}}
|
|
4940
|
+
>
|
|
4941
|
+
{isCollapsed ? '▸' : '▾'}
|
|
4942
|
+
</button>
|
|
4943
|
+
) : (
|
|
4944
|
+
<span className="st-layer-caret" aria-hidden="true" />
|
|
4945
|
+
)}
|
|
4946
|
+
<StIcon name={LAYER_TYPE_ICON[node.type] || 'box'} size={12} className="st-layer-ticon" />
|
|
4947
|
+
<span className="st-layer-label">{node.label}</span>
|
|
4948
|
+
<span className="st-layer-type">{node.type}</span>
|
|
4949
|
+
{onToggleVisibility ? (
|
|
4950
|
+
<button
|
|
4951
|
+
type="button"
|
|
4952
|
+
className="st-layer-eye"
|
|
4953
|
+
aria-label={isHidden ? `Show ${node.label}` : `Hide ${node.label}`}
|
|
4954
|
+
aria-pressed={isHidden}
|
|
4955
|
+
title={isHidden ? 'Show' : 'Hide'}
|
|
4956
|
+
onClick={(e) => {
|
|
4957
|
+
e.stopPropagation();
|
|
4958
|
+
onToggleVisibility(node);
|
|
4959
|
+
}}
|
|
4960
|
+
>
|
|
4961
|
+
<StIcon name={isHidden ? 'eye-off' : 'eye'} size={13} />
|
|
4962
|
+
</button>
|
|
4963
|
+
) : null}
|
|
4964
|
+
</div>
|
|
4965
|
+
{hasKids && !isCollapsed
|
|
4966
|
+
? node.children.map((c) => (
|
|
4967
|
+
<LayerRow
|
|
4968
|
+
key={`${c.id}:${c.index}`}
|
|
4969
|
+
node={c}
|
|
4970
|
+
depth={depth + 1}
|
|
4971
|
+
selectedId={selectedId}
|
|
4972
|
+
collapsed={collapsed}
|
|
4973
|
+
hidden={hidden}
|
|
4974
|
+
onToggle={onToggle}
|
|
4975
|
+
onSelect={onSelect}
|
|
4976
|
+
onHover={onHover}
|
|
4977
|
+
onToggleVisibility={onToggleVisibility}
|
|
4978
|
+
/>
|
|
4979
|
+
))
|
|
4980
|
+
: null}
|
|
4981
|
+
</>
|
|
4982
|
+
);
|
|
4983
|
+
}
|
|
4984
|
+
|
|
4985
|
+
// Phase 12.3 — live computed readout for the Inspect tab (replaces the stale
|
|
4986
|
+
// "lands with the live CSS bridge (Phase 12)" callout — that bridge shipped).
|
|
4987
|
+
// Reads the resolved values the selection already carries (dom-selection
|
|
4988
|
+
// styleMapsFor → el.computed). Read-only; the CSS tab is where you edit.
|
|
4989
|
+
function InspectComputed({ el }) {
|
|
4990
|
+
const c = el?.computed || {};
|
|
4991
|
+
const a = el?.authored || {};
|
|
4992
|
+
// Prefer the authored token name (var(--accent) → "--accent") as the label;
|
|
4993
|
+
// fall back to the resolved value. The swatch always shows the RESOLVED color.
|
|
4994
|
+
const valueLabel = (prop) => {
|
|
4995
|
+
const av = a[prop];
|
|
4996
|
+
if (av && /var\(\s*--/.test(av)) return av.replace(/^var\(\s*|\s*\)$/g, '');
|
|
4997
|
+
return c[prop] || av || '';
|
|
4998
|
+
};
|
|
4999
|
+
const colorRow = (lbl, prop) => {
|
|
5000
|
+
const resolved = c[prop] || a[prop];
|
|
5001
|
+
if (!resolved) return null;
|
|
5002
|
+
return (
|
|
5003
|
+
<div className="st-insp-row" key={lbl}>
|
|
5004
|
+
<span className="st-insp-label">{lbl}</span>
|
|
5005
|
+
<div className="st-swatch-row">
|
|
5006
|
+
<span className="st-insp-swatch" style={{ background: resolved }} aria-hidden="true" />
|
|
5007
|
+
<span className="st-mono" style={{ fontSize: 11, color: 'var(--fg-1)' }}>
|
|
5008
|
+
{valueLabel(prop)}
|
|
5009
|
+
</span>
|
|
5010
|
+
</div>
|
|
5011
|
+
</div>
|
|
5012
|
+
);
|
|
5013
|
+
};
|
|
5014
|
+
const hasRadius = c['border-radius'] && c['border-radius'] !== '0px';
|
|
5015
|
+
const radiusN = hasRadius ? cssSplitUnit(c['border-radius']).n || c['border-radius'] : null;
|
|
5016
|
+
const font =
|
|
5017
|
+
c['font-size'] || c['font-weight']
|
|
5018
|
+
? [c['font-size'], c['font-weight']].filter(Boolean).join(' / ')
|
|
5019
|
+
: null;
|
|
5020
|
+
const anyType = c['background-color'] || c.color || hasRadius || font;
|
|
5021
|
+
if (!anyType) return null;
|
|
5022
|
+
return (
|
|
5023
|
+
<>
|
|
5024
|
+
{hasRadius ? (
|
|
5025
|
+
<div className="st-insp-row">
|
|
5026
|
+
<span className="st-insp-label">Radius</span>
|
|
5027
|
+
<div className="st-insp-fields">
|
|
5028
|
+
<span className="st-fmini" style={{ flex: '0 0 auto', maxWidth: 84 }}>
|
|
5029
|
+
<span className="st-mtag">r</span>
|
|
5030
|
+
<input value={radiusN} readOnly aria-label="border radius" />
|
|
5031
|
+
</span>
|
|
5032
|
+
<span className="st-insp-unit">px</span>
|
|
5033
|
+
</div>
|
|
5034
|
+
</div>
|
|
5035
|
+
) : null}
|
|
5036
|
+
{colorRow('Fill', 'background-color')}
|
|
5037
|
+
{colorRow('Text', 'color')}
|
|
5038
|
+
{font ? (
|
|
5039
|
+
<div className="st-insp-row">
|
|
5040
|
+
<span className="st-insp-label">Font</span>
|
|
5041
|
+
<div className="st-insp-fields">
|
|
5042
|
+
<span className="st-mono" style={{ fontSize: 11, color: 'var(--fg-0)' }}>{font}</span>
|
|
5043
|
+
</div>
|
|
5044
|
+
</div>
|
|
5045
|
+
) : null}
|
|
5046
|
+
</>
|
|
5047
|
+
);
|
|
5048
|
+
}
|
|
5049
|
+
|
|
5050
|
+
function InspectorPanel({
|
|
5051
|
+
selected,
|
|
5052
|
+
onClose,
|
|
5053
|
+
layersTree,
|
|
5054
|
+
onSelectLayer,
|
|
5055
|
+
onHoverLayer,
|
|
5056
|
+
cfg,
|
|
5057
|
+
onOptimistic,
|
|
5058
|
+
tab: tabProp,
|
|
5059
|
+
onTabChange,
|
|
5060
|
+
width,
|
|
5061
|
+
resizing,
|
|
5062
|
+
}) {
|
|
5063
|
+
// Tab is controllable from the parent (the guided tour drives it to 'css' /
|
|
5064
|
+
// 'layers' so a spotlight step lands on a real row) but falls back to local
|
|
5065
|
+
// state for normal use. A user click both updates local state and notifies the
|
|
5066
|
+
// parent, so the two stay in lockstep whichever owns it.
|
|
5067
|
+
const [tabState, setTabState] = useState('inspect');
|
|
5068
|
+
const tab = tabProp ?? tabState;
|
|
5069
|
+
const setTab = (t) => {
|
|
5070
|
+
setTabState(t);
|
|
5071
|
+
onTabChange?.(t);
|
|
5072
|
+
};
|
|
5073
|
+
const [collapsed, setCollapsed] = useState(() => new Set());
|
|
5074
|
+
// Phase 12.3 (W3.1) — per-layer visibility toggle. Live-only (display:none via
|
|
5075
|
+
// the optimistic apply bus); not persisted to source. Keyed by `${id}:${index}`.
|
|
5076
|
+
const [hiddenLayers, setHiddenLayers] = useState(() => new Set());
|
|
5077
|
+
const toggleCollapse = (key) =>
|
|
5078
|
+
setCollapsed((prev) => {
|
|
5079
|
+
const next = new Set(prev);
|
|
5080
|
+
if (next.has(key)) next.delete(key);
|
|
5081
|
+
else next.add(key);
|
|
5082
|
+
return next;
|
|
5083
|
+
});
|
|
5084
|
+
const toggleVisibility = (node) => {
|
|
5085
|
+
const key = `${node.id}:${node.index}`;
|
|
5086
|
+
const willHide = !hiddenLayers.has(key);
|
|
5087
|
+
onOptimistic?.({
|
|
5088
|
+
id: node.id,
|
|
5089
|
+
artboardId: layersTree?.artboardId ?? null,
|
|
5090
|
+
index: node.index,
|
|
5091
|
+
prop: 'display',
|
|
5092
|
+
value: willHide ? 'none' : null,
|
|
5093
|
+
});
|
|
5094
|
+
setHiddenLayers((prev) => {
|
|
5095
|
+
const next = new Set(prev);
|
|
5096
|
+
if (willHide) next.add(key);
|
|
5097
|
+
else next.delete(key);
|
|
5098
|
+
return next;
|
|
5099
|
+
});
|
|
5100
|
+
};
|
|
2805
5101
|
// `selected` may be a single element, an array (multi-select), or null.
|
|
2806
5102
|
const el = Array.isArray(selected) ? selected[0] : selected;
|
|
2807
5103
|
const tabBtn = (id, label, icon) => (
|
|
@@ -2816,8 +5112,13 @@ function InspectorPanel({ selected, onClose }) {
|
|
|
2816
5112
|
);
|
|
2817
5113
|
const b = el?.bounds || null;
|
|
2818
5114
|
return (
|
|
2819
|
-
<aside
|
|
2820
|
-
|
|
5115
|
+
<aside
|
|
5116
|
+
className={'st-rpanel' + (resizing ? ' is-resizing' : '')}
|
|
5117
|
+
style={width ? { width, flexBasis: width } : undefined}
|
|
5118
|
+
aria-label="Inspector"
|
|
5119
|
+
data-tour="inspector"
|
|
5120
|
+
>
|
|
5121
|
+
<div className="st-rp-tabs" data-tour="inspector-tabs">
|
|
2821
5122
|
{tabBtn('inspect', 'Inspect', 'sliders')}
|
|
2822
5123
|
{tabBtn('layers', 'Layers', 'layers')}
|
|
2823
5124
|
{tabBtn('css', 'CSS', 'code')}
|
|
@@ -2834,7 +5135,11 @@ function InspectorPanel({ selected, onClose }) {
|
|
|
2834
5135
|
<div className="st-rp-body">
|
|
2835
5136
|
{!el ? (
|
|
2836
5137
|
<div className="st-rp-empty">
|
|
2837
|
-
|
|
5138
|
+
{/* <p> wrapper — st-rp-empty is a flex column, bare text nodes +
|
|
5139
|
+
kbd would stack as stretched flex items. */}
|
|
5140
|
+
<p>
|
|
5141
|
+
Hold <Kbd>⌘</Kbd> inside the canvas and click an element to inspect it.
|
|
5142
|
+
</p>
|
|
2838
5143
|
</div>
|
|
2839
5144
|
) : tab === 'inspect' ? (
|
|
2840
5145
|
<>
|
|
@@ -2842,26 +5147,26 @@ function InspectorPanel({ selected, onClose }) {
|
|
|
2842
5147
|
<div className="st-insp-row">
|
|
2843
5148
|
<span className="st-insp-label">Pos</span>
|
|
2844
5149
|
<div className="st-insp-fields">
|
|
2845
|
-
<span className="st-
|
|
2846
|
-
<span className="
|
|
2847
|
-
<input
|
|
5150
|
+
<span className="st-fmini">
|
|
5151
|
+
<span className="st-mtag">X</span>
|
|
5152
|
+
<input value={b ? Math.round(b.x) : '—'} readOnly aria-label="x position" />
|
|
2848
5153
|
</span>
|
|
2849
|
-
<span className="st-
|
|
2850
|
-
<span className="
|
|
2851
|
-
<input
|
|
5154
|
+
<span className="st-fmini">
|
|
5155
|
+
<span className="st-mtag">Y</span>
|
|
5156
|
+
<input value={b ? Math.round(b.y) : '—'} readOnly aria-label="y position" />
|
|
2852
5157
|
</span>
|
|
2853
5158
|
</div>
|
|
2854
5159
|
</div>
|
|
2855
5160
|
<div className="st-insp-row">
|
|
2856
5161
|
<span className="st-insp-label">Size</span>
|
|
2857
5162
|
<div className="st-insp-fields">
|
|
2858
|
-
<span className="st-
|
|
2859
|
-
<span className="
|
|
2860
|
-
<input
|
|
5163
|
+
<span className="st-fmini">
|
|
5164
|
+
<span className="st-mtag">W</span>
|
|
5165
|
+
<input value={b ? Math.round(b.w) : '—'} readOnly aria-label="width" />
|
|
2861
5166
|
</span>
|
|
2862
|
-
<span className="st-
|
|
2863
|
-
<span className="
|
|
2864
|
-
<input
|
|
5167
|
+
<span className="st-fmini">
|
|
5168
|
+
<span className="st-mtag">H</span>
|
|
5169
|
+
<input value={b ? Math.round(b.h) : '—'} readOnly aria-label="height" />
|
|
2865
5170
|
</span>
|
|
2866
5171
|
</div>
|
|
2867
5172
|
</div>
|
|
@@ -2883,14 +5188,29 @@ function InspectorPanel({ selected, onClose }) {
|
|
|
2883
5188
|
</div>
|
|
2884
5189
|
</div>
|
|
2885
5190
|
) : null}
|
|
2886
|
-
<
|
|
2887
|
-
Computed fill / radius / type readout lands with the live CSS bridge (Phase 12).
|
|
2888
|
-
</div>
|
|
5191
|
+
<InspectComputed el={el} />
|
|
2889
5192
|
</>
|
|
2890
5193
|
) : tab === 'layers' ? (
|
|
2891
5194
|
<>
|
|
2892
|
-
<div className="st-rp-hd">Layers · ancestry</div>
|
|
2893
|
-
{
|
|
5195
|
+
<div className="st-rp-hd">Layers{layersTree?.nodes?.length ? '' : ' · ancestry'}</div>
|
|
5196
|
+
{layersTree?.nodes?.length ? (
|
|
5197
|
+
<div role="tree" aria-label="Artboard layers">
|
|
5198
|
+
{layersTree.nodes.map((n) => (
|
|
5199
|
+
<LayerRow
|
|
5200
|
+
key={`${n.id}:${n.index}`}
|
|
5201
|
+
node={n}
|
|
5202
|
+
depth={0}
|
|
5203
|
+
selectedId={el.id}
|
|
5204
|
+
collapsed={collapsed}
|
|
5205
|
+
hidden={hiddenLayers}
|
|
5206
|
+
onToggle={toggleCollapse}
|
|
5207
|
+
onSelect={(node) => onSelectLayer?.(node)}
|
|
5208
|
+
onHover={(node) => onHoverLayer?.(node)}
|
|
5209
|
+
onToggleVisibility={toggleVisibility}
|
|
5210
|
+
/>
|
|
5211
|
+
))}
|
|
5212
|
+
</div>
|
|
5213
|
+
) : Array.isArray(el.dom_path) && el.dom_path.length ? (
|
|
2894
5214
|
el.dom_path.map((node, i) => (
|
|
2895
5215
|
<div
|
|
2896
5216
|
key={i}
|
|
@@ -2902,25 +5222,13 @@ function InspectorPanel({ selected, onClose }) {
|
|
|
2902
5222
|
</div>
|
|
2903
5223
|
))
|
|
2904
5224
|
) : (
|
|
2905
|
-
<div className="st-rp-empty">
|
|
5225
|
+
<div className="st-rp-empty">
|
|
5226
|
+
Select an element (⌘-click in the canvas) to see its layer tree.
|
|
5227
|
+
</div>
|
|
2906
5228
|
)}
|
|
2907
5229
|
</>
|
|
2908
5230
|
) : (
|
|
2909
|
-
|
|
2910
|
-
<div className="st-rp-hd">Markup · {el.selector || el.tag}</div>
|
|
2911
|
-
<div className="st-css">
|
|
2912
|
-
<div>
|
|
2913
|
-
<span className="comment">/* read-only — outerHTML snapshot */</span>
|
|
2914
|
-
</div>
|
|
2915
|
-
<div style={{ whiteSpace: 'pre-wrap', wordBreak: 'break-word' }}>
|
|
2916
|
-
{(el.html || '').slice(0, 600) || '(no markup captured)'}
|
|
2917
|
-
</div>
|
|
2918
|
-
</div>
|
|
2919
|
-
<div className="callout callout--info" style={{ fontSize: 12 }}>
|
|
2920
|
-
Phase 12 — knob edits will write back to the artboard live and stage a diff for
|
|
2921
|
-
handoff. Today the inspector is read-only.
|
|
2922
|
-
</div>
|
|
2923
|
-
</>
|
|
5231
|
+
<CssKnobs el={el} cfg={cfg} onOptimistic={onOptimistic} />
|
|
2924
5232
|
)}
|
|
2925
5233
|
</div>
|
|
2926
5234
|
</aside>
|
|
@@ -2935,6 +5243,15 @@ function App() {
|
|
|
2935
5243
|
const [tabs, setTabs] = useState([]);
|
|
2936
5244
|
const [activePath, setActivePath] = useState(null);
|
|
2937
5245
|
const [selected, setSelected] = useState(null);
|
|
5246
|
+
// Phase 12.3 — latest selection, readable from the (stale-closure) onMessage
|
|
5247
|
+
// handler so an HMR reload (triggered by a CSS/attr edit) can re-select the
|
|
5248
|
+
// same element and restore the in-canvas halo the remount dropped.
|
|
5249
|
+
const selectedRef = useRef(null);
|
|
5250
|
+
useEffect(() => {
|
|
5251
|
+
selectedRef.current = selected;
|
|
5252
|
+
}, [selected]);
|
|
5253
|
+
// Phase 12 Task 4 — Layers tree for the active artboard (posted by canvas-shell).
|
|
5254
|
+
const [layersTree, setLayersTree] = useState(null);
|
|
2938
5255
|
const [wsConnected, setWsConnected] = useState(false);
|
|
2939
5256
|
// Phase 8 Task 7 — git lifecycle reload prompt. Server has already flushed
|
|
2940
5257
|
// every dirty Y.Doc to disk by the time this state populates, so accepting
|
|
@@ -2945,6 +5262,49 @@ function App() {
|
|
|
2945
5262
|
const [syncStatus, setSyncStatus] = useState(null);
|
|
2946
5263
|
const [search, setSearch] = useState('');
|
|
2947
5264
|
const [systemData, setSystemData] = useState(null);
|
|
5265
|
+
// Canvas-compile skeleton (single-canvas model → one path at a time).
|
|
5266
|
+
const [loadingPath, setLoadingPath] = useState(null);
|
|
5267
|
+
const loadFallbackTimer = useRef(null);
|
|
5268
|
+
// Resizable side panels (DS components-resize-panels) + the active drag side.
|
|
5269
|
+
const sbSize = usePanelSize('maude-sb-w', { min: 200, max: 420, def: 252 });
|
|
5270
|
+
const rpSize = usePanelSize('maude-rp-w', { min: 260, max: 480, def: 304 });
|
|
5271
|
+
const [dragSide, setDragSide] = useState(null); // 'sb' | 'rp' | null
|
|
5272
|
+
const bodyRef = useRef(null);
|
|
5273
|
+
|
|
5274
|
+
// Pointer drag for the panel grips — window listeners while dragging (the
|
|
5275
|
+
// grip also pointer-captures, and `.st-body.is-resizing iframe` drops pointer
|
|
5276
|
+
// events so the canvas iframe can't swallow the move stream mid-drag).
|
|
5277
|
+
useEffect(() => {
|
|
5278
|
+
if (!dragSide) return;
|
|
5279
|
+
const onMove = (e) => {
|
|
5280
|
+
const rect = bodyRef.current?.getBoundingClientRect();
|
|
5281
|
+
if (!rect) return;
|
|
5282
|
+
if (dragSide === 'sb') sbSize.setW(e.clientX - rect.left);
|
|
5283
|
+
else rpSize.setW(rect.right - e.clientX);
|
|
5284
|
+
};
|
|
5285
|
+
const onUp = () => setDragSide(null);
|
|
5286
|
+
window.addEventListener('pointermove', onMove);
|
|
5287
|
+
window.addEventListener('pointerup', onUp);
|
|
5288
|
+
return () => {
|
|
5289
|
+
window.removeEventListener('pointermove', onMove);
|
|
5290
|
+
window.removeEventListener('pointerup', onUp);
|
|
5291
|
+
};
|
|
5292
|
+
}, [dragSide, sbSize.setW, rpSize.setW]);
|
|
5293
|
+
|
|
5294
|
+
// Loading-skeleton lifecycle: dgn:'loaded' clears it instantly (TSX canvases);
|
|
5295
|
+
// the iframe load event arms a short fallback for legacy .html canvases that
|
|
5296
|
+
// never post it; a hard cap guards against a canvas that dies mid-compile.
|
|
5297
|
+
const onIframeLoad = useCallback((path) => {
|
|
5298
|
+
clearTimeout(loadFallbackTimer.current);
|
|
5299
|
+
loadFallbackTimer.current = setTimeout(() => {
|
|
5300
|
+
setLoadingPath((p) => (p === path ? null : p));
|
|
5301
|
+
}, 2500);
|
|
5302
|
+
}, []);
|
|
5303
|
+
useEffect(() => {
|
|
5304
|
+
if (!loadingPath) return;
|
|
5305
|
+
const cap = setTimeout(() => setLoadingPath(null), 15000);
|
|
5306
|
+
return () => clearTimeout(cap);
|
|
5307
|
+
}, [loadingPath]);
|
|
2948
5308
|
// Loaded once at boot from /_config — informs canvasUrl() so TSX iframes
|
|
2949
5309
|
// can pass the right ?designRel + ?tokens query to the canvas mount shell.
|
|
2950
5310
|
const [cfg, setCfg] = useState({ designRel: '.design' });
|
|
@@ -3008,17 +5368,43 @@ function App() {
|
|
|
3008
5368
|
const [showHidden, setShowHidden] = useState(() => readBoolStore(SHOW_HIDDEN_STORE, false));
|
|
3009
5369
|
const [sectionsExpanded, setSectionsExpanded] = useState(() => readJsonStore(SECTIONS_STORE, {}));
|
|
3010
5370
|
const [helpOpen, setHelpOpen] = useState(false);
|
|
5371
|
+
// ? cheat-sheet (DS components-shortcuts-overlay) — separate from the deep
|
|
5372
|
+
// Help modal (F1), which keeps commands & flows.
|
|
5373
|
+
const [shortcutsOpen, setShortcutsOpen] = useState(false);
|
|
3011
5374
|
const [paletteOpen, setPaletteOpen] = useState(false);
|
|
3012
5375
|
// T5/T6 (Plan C) — shell-level export/handoff dialog + inspector panel state.
|
|
3013
5376
|
// The palette (T4) drives them; the dialog (T5) + panel (T6) consume them.
|
|
3014
5377
|
const [exportDialog, setExportDialog] = useState(null); // null | { mode: 'export'|'handoff', scope? }
|
|
3015
5378
|
const [inspectorOpen, setInspectorOpen] = useState(false);
|
|
5379
|
+
// Inspector tab is lifted so View ▸ Layers can open the panel ON the Layers
|
|
5380
|
+
// tab (the menu item sat disabled as "Phase 12" long after the tab shipped).
|
|
5381
|
+
const [inspectorTab, setInspectorTab] = useState('inspect');
|
|
3016
5382
|
const whatsNew = useWhatsNew(MDCC_VERSION);
|
|
3017
5383
|
const [tourSteps, setTourSteps] = useState(null);
|
|
3018
5384
|
const [usageNudge, setUsageNudge] = useState(() => !readBoolStore(USAGE_TOUR_STORE, false));
|
|
3019
5385
|
const startTour = useCallback((steps) => {
|
|
3020
5386
|
setTourSteps(Array.isArray(steps) && steps.length ? steps : null);
|
|
3021
5387
|
}, []);
|
|
5388
|
+
// Guided-tour bus — the overlay calls setup() before each step to put the shell
|
|
5389
|
+
// into the state the step spotlights: open a canvas, open the Inspector, switch
|
|
5390
|
+
// its tab. The canvas iframe is cross-origin (DDR-054) so the tour can't select
|
|
5391
|
+
// an element for the user; requireSelection steps instead wait for a real
|
|
5392
|
+
// ⌘-click. Plain object (the overlay refs it), so per-render churn is harmless.
|
|
5393
|
+
const tourBus = {
|
|
5394
|
+
setup: (step) => {
|
|
5395
|
+
if (!step) return;
|
|
5396
|
+
if ((step.canvas || step.requireSelection) && tabs.length === 0) {
|
|
5397
|
+
setSidebarOpen(true);
|
|
5398
|
+
setTimeout(() => {
|
|
5399
|
+
try {
|
|
5400
|
+
document.querySelector('.st-sidebar [role="treeitem"]')?.click();
|
|
5401
|
+
} catch {}
|
|
5402
|
+
}, 80);
|
|
5403
|
+
}
|
|
5404
|
+
if (step.inspector || step.tab || step.requireSelection) setInspectorOpen(true);
|
|
5405
|
+
if (step.tab) setInspectorTab(step.tab);
|
|
5406
|
+
},
|
|
5407
|
+
};
|
|
3022
5408
|
const markUsageSeen = useCallback(() => {
|
|
3023
5409
|
setUsageNudge(false);
|
|
3024
5410
|
try {
|
|
@@ -3245,9 +5631,9 @@ function App() {
|
|
|
3245
5631
|
try {
|
|
3246
5632
|
const m = JSON.parse(e.data);
|
|
3247
5633
|
if (m.type === 'snapshot' && m.state) {
|
|
3248
|
-
setSelected(m.state.selected);
|
|
5634
|
+
setSelected((prev) => mergeSelClientFields(m.state.selected, prev));
|
|
3249
5635
|
} else if (m.type === 'selected') {
|
|
3250
|
-
setSelected(m.selected);
|
|
5636
|
+
setSelected((prev) => mergeSelClientFields(m.selected, prev));
|
|
3251
5637
|
} else if (m.type === 'comments' && typeof m.file === 'string') {
|
|
3252
5638
|
setCommentsByFile((prev) => ({ ...prev, [m.file]: m.comments || [] }));
|
|
3253
5639
|
} else if (m.type === 'ai-activity' && typeof m.file === 'string') {
|
|
@@ -3311,6 +5697,9 @@ function App() {
|
|
|
3311
5697
|
});
|
|
3312
5698
|
setActivePath(path);
|
|
3313
5699
|
setFocusedCommentId(null);
|
|
5700
|
+
// Canvas-compile skeleton — cleared by the iframe's dgn:'loaded' message,
|
|
5701
|
+
// the onLoad fallback timer (legacy .html), or a hard 15s cap.
|
|
5702
|
+
if (path !== SYSTEM_TAB) setLoadingPath(path);
|
|
3314
5703
|
}, []);
|
|
3315
5704
|
|
|
3316
5705
|
const openSystem = useCallback(
|
|
@@ -3349,6 +5738,7 @@ function App() {
|
|
|
3349
5738
|
return next;
|
|
3350
5739
|
});
|
|
3351
5740
|
iframesRef.current.delete(path);
|
|
5741
|
+
setLoadingPath((p) => (p === path ? null : p));
|
|
3352
5742
|
},
|
|
3353
5743
|
[activePath]
|
|
3354
5744
|
);
|
|
@@ -3507,6 +5897,27 @@ function App() {
|
|
|
3507
5897
|
} else if (m.dgn === 'clear-select') {
|
|
3508
5898
|
wsSend({ type: 'clear-select' });
|
|
3509
5899
|
setSelected(null);
|
|
5900
|
+
} else if (m.dgn === 'edit-text' && m.id) {
|
|
5901
|
+
// Phase 12 (DDR-103) — inline text edit committed in the canvas. POST to
|
|
5902
|
+
// the main-origin-only /_api/edit-text → editText writes the escaped
|
|
5903
|
+
// JSXText to source; the file-watcher HMR reload then shows the new text.
|
|
5904
|
+
// A refusal (mixed/expression content) is logged, not fatal.
|
|
5905
|
+
fetch('/_api/edit-text', {
|
|
5906
|
+
method: 'POST',
|
|
5907
|
+
headers: { 'content-type': 'application/json' },
|
|
5908
|
+
body: JSON.stringify({ canvas: m.file, id: m.id, text: m.text ?? '' }),
|
|
5909
|
+
})
|
|
5910
|
+
.then((r) => r.json().catch(() => ({})))
|
|
5911
|
+
.then((j) => {
|
|
5912
|
+
if (!j.ok) console.warn('[edit-text]', j.error || 'failed');
|
|
5913
|
+
})
|
|
5914
|
+
.catch(() => {});
|
|
5915
|
+
} else if (m.dgn === 'layers-tree') {
|
|
5916
|
+
// Phase 12 Task 4 — browsable layers tree for the active artboard.
|
|
5917
|
+
setLayersTree({ artboardId: m.artboardId, nodes: Array.isArray(m.tree) ? m.tree : [] });
|
|
5918
|
+
} else if (m.dgn === 'open-inspector') {
|
|
5919
|
+
// Phase 12 — context-menu "Inspect" / tool-palette Inspect opens the right panel.
|
|
5920
|
+
setInspectorOpen(true);
|
|
3510
5921
|
} else if (m.dgn === 'comment-compose' && m.selection) {
|
|
3511
5922
|
// Phase 6 — the iframe overlay owns the composer surface now. The
|
|
3512
5923
|
// shell just mirrors `selected` so the StatusBar / sidebar still
|
|
@@ -3555,6 +5966,14 @@ function App() {
|
|
|
3555
5966
|
// inspector forwards the chord here since the iframe's keydown never
|
|
3556
5967
|
// reaches the shell's window listener. Mirror that handler's toggle.
|
|
3557
5968
|
setPaletteOpen((v) => !v);
|
|
5969
|
+
} else if (m.dgn === 'shell-shortcut') {
|
|
5970
|
+
// Same forwarding lane for the other shell chords (inspect.ts) — so
|
|
5971
|
+
// ⌘R / ⌘⇧I / ⌘⇧M / ⌘⇧E / ⌘⇧H behave identically wherever focus is.
|
|
5972
|
+
if (m.id === 'reload') reloadActive();
|
|
5973
|
+
else if (m.id === 'inspector') setInspectorOpen((v) => !v);
|
|
5974
|
+
else if (m.id === 'comments') setCommentsPanelOpen((v) => !v);
|
|
5975
|
+
else if (m.id === 'export') setExportDialog({ mode: 'export' });
|
|
5976
|
+
else if (m.id === 'handoff') setExportDialog({ mode: 'handoff' });
|
|
3558
5977
|
} else if (m.dgn === 'open-export') {
|
|
3559
5978
|
// Plan C — the in-canvas toolbar / context menu route here so they open
|
|
3560
5979
|
// the SAME shell Export dialog as the menubar (one look, all settings).
|
|
@@ -3564,7 +5983,9 @@ function App() {
|
|
|
3564
5983
|
scope: m.detail && typeof m.detail.scope === 'string' ? m.detail.scope : undefined,
|
|
3565
5984
|
});
|
|
3566
5985
|
} else if (m.dgn === 'loaded' && m.file) {
|
|
3567
|
-
// iframe finished loading —
|
|
5986
|
+
// iframe finished loading — drop the compile skeleton, push current
|
|
5987
|
+
// comments + carry over focused pin if any
|
|
5988
|
+
setLoadingPath((p) => (p === m.file ? null : p));
|
|
3568
5989
|
const list = commentsByFile[m.file] || [];
|
|
3569
5990
|
const el = [...iframesRef.current.entries()].find(([k]) => k === m.file)?.[1];
|
|
3570
5991
|
if (el && el.contentWindow) {
|
|
@@ -3582,6 +6003,26 @@ function App() {
|
|
|
3582
6003
|
el.contentWindow.postMessage({ dgn: 'comment-focus', id: focusedCommentId }, '*');
|
|
3583
6004
|
} catch {}
|
|
3584
6005
|
}
|
|
6006
|
+
// Phase 12.3 (W1.1) — an edit-css/edit-attr commit triggers the file
|
|
6007
|
+
// watcher's HMR reload, which remounts the canvas and drops the
|
|
6008
|
+
// in-canvas selection halo. Re-select the same element by its stable
|
|
6009
|
+
// data-cd-id so the user keeps focus on what they're editing. The
|
|
6010
|
+
// canvas-shell `select-by-id` handler re-emits select-set, which keeps
|
|
6011
|
+
// the Inspector panel + halo in sync. Guarded to the active file.
|
|
6012
|
+
const sel = selectedRef.current;
|
|
6013
|
+
if (sel && sel.id && sel.file === m.file) {
|
|
6014
|
+
try {
|
|
6015
|
+
el.contentWindow.postMessage(
|
|
6016
|
+
{
|
|
6017
|
+
dgn: 'select-by-id',
|
|
6018
|
+
id: sel.id,
|
|
6019
|
+
artboardId: sel.artboardId ?? null,
|
|
6020
|
+
index: sel.index ?? 0,
|
|
6021
|
+
},
|
|
6022
|
+
'*'
|
|
6023
|
+
);
|
|
6024
|
+
} catch {}
|
|
6025
|
+
}
|
|
3585
6026
|
}
|
|
3586
6027
|
} else if (m.dgn === 'export-request' && m.id && m.payload) {
|
|
3587
6028
|
// The export dialog renders inside the canvas iframe (canvas origin),
|
|
@@ -3651,7 +6092,7 @@ function App() {
|
|
|
3651
6092
|
}
|
|
3652
6093
|
window.addEventListener('message', onMessage);
|
|
3653
6094
|
return () => window.removeEventListener('message', onMessage);
|
|
3654
|
-
}, [commentsByFile, focusedCommentId, cfg, theme]);
|
|
6095
|
+
}, [commentsByFile, focusedCommentId, cfg, theme, reloadActive]);
|
|
3655
6096
|
|
|
3656
6097
|
// Tell the active canvas iframe to drop any persistent selection (canvas
|
|
3657
6098
|
// SelectionSet) — used when the comment composer closes via submit /
|
|
@@ -3667,6 +6108,22 @@ function App() {
|
|
|
3667
6108
|
}
|
|
3668
6109
|
}, [activePath]);
|
|
3669
6110
|
|
|
6111
|
+
// Phase 12.3 (W1.1) — optimistic inline-style preview. The CSS panel calls this
|
|
6112
|
+
// on commit so the selected element updates instantly in the canvas before the
|
|
6113
|
+
// edit-css → HMR reload lands. `value` null = the reset path (remove the prop).
|
|
6114
|
+
const applyOptimisticStyle = useCallback(
|
|
6115
|
+
(payload) => {
|
|
6116
|
+
if (!activePath || activePath === SYSTEM_TAB) return;
|
|
6117
|
+
const el = iframesRef.current.get(activePath);
|
|
6118
|
+
if (el && el.contentWindow) {
|
|
6119
|
+
try {
|
|
6120
|
+
el.contentWindow.postMessage({ dgn: 'apply-style', ...payload }, '*');
|
|
6121
|
+
} catch {}
|
|
6122
|
+
}
|
|
6123
|
+
},
|
|
6124
|
+
[activePath]
|
|
6125
|
+
);
|
|
6126
|
+
|
|
3670
6127
|
const resolveComment = useCallback((id) => {
|
|
3671
6128
|
wsSend({ type: 'comments-patch', id, patch: { status: 'resolved' } });
|
|
3672
6129
|
}, []);
|
|
@@ -3737,6 +6194,25 @@ function App() {
|
|
|
3737
6194
|
setCommentsPanelOpen((v) => !v);
|
|
3738
6195
|
return;
|
|
3739
6196
|
}
|
|
6197
|
+
// Cmd+Shift+I — toggle Inspector. Was bare "I", which collided with the
|
|
6198
|
+
// canvas highlighter tool (same letter, different action by focus).
|
|
6199
|
+
if (meta && e.shiftKey && (e.key === 'i' || e.key === 'I')) {
|
|
6200
|
+
e.preventDefault();
|
|
6201
|
+
setInspectorOpen((v) => !v);
|
|
6202
|
+
return;
|
|
6203
|
+
}
|
|
6204
|
+
// Cmd+Shift+E / Cmd+Shift+H — the File-menu chords, previously
|
|
6205
|
+
// advertised but never bound.
|
|
6206
|
+
if (meta && e.shiftKey && (e.key === 'e' || e.key === 'E')) {
|
|
6207
|
+
e.preventDefault();
|
|
6208
|
+
setExportDialog({ mode: 'export' });
|
|
6209
|
+
return;
|
|
6210
|
+
}
|
|
6211
|
+
if (meta && e.shiftKey && (e.key === 'h' || e.key === 'H')) {
|
|
6212
|
+
e.preventDefault();
|
|
6213
|
+
setExportDialog({ mode: 'handoff' });
|
|
6214
|
+
return;
|
|
6215
|
+
}
|
|
3740
6216
|
// Cmd+C / Ctrl+C — Phase 4.1 removed the shell-side comment-drop chord.
|
|
3741
6217
|
// Canvas comment-drop is the `C` tool letter (press C in the canvas,
|
|
3742
6218
|
// then click the element) or right-click "Add comment". Cmd+C now
|
|
@@ -3806,14 +6282,25 @@ function App() {
|
|
|
3806
6282
|
}
|
|
3807
6283
|
return;
|
|
3808
6284
|
}
|
|
3809
|
-
//
|
|
3810
|
-
|
|
6285
|
+
// N — open the new-brief-board composer (replaces the advertised ⌘N,
|
|
6286
|
+
// which the browser reserves for New Window and never delivers).
|
|
6287
|
+
if ((e.key === 'n' || e.key === 'N') && !meta && !e.shiftKey) {
|
|
3811
6288
|
e.preventDefault();
|
|
3812
|
-
|
|
6289
|
+
setSidebarOpen(true);
|
|
6290
|
+
setTimeout(
|
|
6291
|
+
() => document.querySelector('[aria-label="New blank brief board"]')?.click(),
|
|
6292
|
+
60
|
|
6293
|
+
);
|
|
6294
|
+
return;
|
|
6295
|
+
}
|
|
6296
|
+
// ? — keyboard-shortcuts cheat sheet (DS shortcuts overlay)
|
|
6297
|
+
if (e.key === '?') {
|
|
6298
|
+
e.preventDefault();
|
|
6299
|
+
setShortcutsOpen((v) => !v);
|
|
3813
6300
|
return;
|
|
3814
6301
|
}
|
|
3815
|
-
//
|
|
3816
|
-
if (e.key === '
|
|
6302
|
+
// F1 — the full Help modal (commands & flows)
|
|
6303
|
+
if (e.key === 'F1') {
|
|
3817
6304
|
e.preventDefault();
|
|
3818
6305
|
setHelpOpen(true);
|
|
3819
6306
|
return;
|
|
@@ -3872,7 +6359,7 @@ function App() {
|
|
|
3872
6359
|
group: 'Canvas',
|
|
3873
6360
|
label: 'New canvas…',
|
|
3874
6361
|
icon: 'plus',
|
|
3875
|
-
kbd: '
|
|
6362
|
+
kbd: 'N',
|
|
3876
6363
|
run: () => {
|
|
3877
6364
|
setSidebarOpen(true);
|
|
3878
6365
|
setTimeout(
|
|
@@ -3919,7 +6406,7 @@ function App() {
|
|
|
3919
6406
|
group: 'View',
|
|
3920
6407
|
label: 'Open inspector',
|
|
3921
6408
|
icon: 'sliders',
|
|
3922
|
-
kbd: 'I',
|
|
6409
|
+
kbd: '⌘⇧I',
|
|
3923
6410
|
run: () => setInspectorOpen(true),
|
|
3924
6411
|
},
|
|
3925
6412
|
{
|
|
@@ -3960,11 +6447,19 @@ function App() {
|
|
|
3960
6447
|
run: () => whatsNew.openPanel(),
|
|
3961
6448
|
},
|
|
3962
6449
|
{
|
|
3963
|
-
id: '
|
|
6450
|
+
id: 'shortcuts',
|
|
3964
6451
|
group: 'Help',
|
|
3965
|
-
label: '
|
|
6452
|
+
label: 'Keyboard shortcuts',
|
|
3966
6453
|
icon: 'help',
|
|
3967
6454
|
kbd: '?',
|
|
6455
|
+
run: () => setShortcutsOpen(true),
|
|
6456
|
+
},
|
|
6457
|
+
{
|
|
6458
|
+
id: 'help',
|
|
6459
|
+
group: 'Help',
|
|
6460
|
+
label: 'Help · commands & flows',
|
|
6461
|
+
icon: 'help',
|
|
6462
|
+
kbd: 'F1',
|
|
3968
6463
|
run: () => setHelpOpen(true),
|
|
3969
6464
|
},
|
|
3970
6465
|
],
|
|
@@ -4010,6 +6505,8 @@ function App() {
|
|
|
4010
6505
|
showHidden={showHidden}
|
|
4011
6506
|
onToggleShowHidden={() => setShowHidden((v) => !v)}
|
|
4012
6507
|
onOpenHelp={() => setHelpOpen(true)}
|
|
6508
|
+
onOpenShortcuts={() => setShortcutsOpen(true)}
|
|
6509
|
+
onStartTour={() => startTour(USAGE_TOUR)}
|
|
4013
6510
|
annotationsVisible={annotationsVisible}
|
|
4014
6511
|
onToggleAnnotations={toggleAnnotations}
|
|
4015
6512
|
postToActiveCanvas={postToActiveCanvas}
|
|
@@ -4017,7 +6514,17 @@ function App() {
|
|
|
4017
6514
|
whatsNewCount={whatsNew.unseen.length}
|
|
4018
6515
|
artboardCount={activeArtboards}
|
|
4019
6516
|
inspectorOpen={inspectorOpen}
|
|
6517
|
+
inspectorTab={inspectorTab}
|
|
4020
6518
|
onToggleInspector={() => setInspectorOpen((v) => !v)}
|
|
6519
|
+
onOpenLayers={() => {
|
|
6520
|
+
// Toggle: already open on Layers → close; otherwise open on Layers.
|
|
6521
|
+
if (inspectorOpen && inspectorTab === 'layers') {
|
|
6522
|
+
setInspectorOpen(false);
|
|
6523
|
+
} else {
|
|
6524
|
+
setInspectorTab('layers');
|
|
6525
|
+
setInspectorOpen(true);
|
|
6526
|
+
}
|
|
6527
|
+
}}
|
|
4021
6528
|
onNewCanvas={() => {
|
|
4022
6529
|
setSidebarOpen(true);
|
|
4023
6530
|
setTimeout(
|
|
@@ -4027,6 +6534,7 @@ function App() {
|
|
|
4027
6534
|
}}
|
|
4028
6535
|
onOpenExport={(mode) => setExportDialog({ mode })}
|
|
4029
6536
|
onReload={reloadActive}
|
|
6537
|
+
onCloseCanvas={() => activePath && closeTab(activePath)}
|
|
4030
6538
|
presence={
|
|
4031
6539
|
<>
|
|
4032
6540
|
<StAvatar
|
|
@@ -4035,12 +6543,17 @@ function App() {
|
|
|
4035
6543
|
title={gitUser ? `${gitUser} (you)` : 'You'}
|
|
4036
6544
|
/>
|
|
4037
6545
|
{agentActive && (
|
|
4038
|
-
<StAvatar
|
|
6546
|
+
<StAvatar
|
|
6547
|
+
initials="C"
|
|
6548
|
+
hue="var(--presence-agent)"
|
|
6549
|
+
title="Claude · editing"
|
|
6550
|
+
pulse
|
|
6551
|
+
/>
|
|
4039
6552
|
)}
|
|
4040
6553
|
</>
|
|
4041
6554
|
}
|
|
4042
6555
|
/>
|
|
4043
|
-
<div className=
|
|
6556
|
+
<div className={'st-body' + (dragSide ? ' is-resizing' : '')} ref={bodyRef}>
|
|
4044
6557
|
<CollapsedRail
|
|
4045
6558
|
shown={!sidebarOpen}
|
|
4046
6559
|
onExpand={() => setSidebarOpen(true)}
|
|
@@ -4066,7 +6579,21 @@ function App() {
|
|
|
4066
6579
|
onDeleteBoard={deleteBoard}
|
|
4067
6580
|
collapsed={!sidebarOpen}
|
|
4068
6581
|
onCollapse={() => setSidebarOpen(false)}
|
|
6582
|
+
width={sbSize.w}
|
|
6583
|
+
resizing={dragSide === 'sb'}
|
|
4069
6584
|
/>
|
|
6585
|
+
{sidebarOpen && (
|
|
6586
|
+
<PanelGrip
|
|
6587
|
+
label="Resize files panel"
|
|
6588
|
+
size={sbSize}
|
|
6589
|
+
active={dragSide === 'sb'}
|
|
6590
|
+
onPointerDown={(e) => {
|
|
6591
|
+
e.preventDefault();
|
|
6592
|
+
e.currentTarget.setPointerCapture?.(e.pointerId);
|
|
6593
|
+
setDragSide('sb');
|
|
6594
|
+
}}
|
|
6595
|
+
/>
|
|
6596
|
+
)}
|
|
4070
6597
|
<div className="main">
|
|
4071
6598
|
<Viewport
|
|
4072
6599
|
tabs={tabs}
|
|
@@ -4077,12 +6604,53 @@ function App() {
|
|
|
4077
6604
|
onSelectDs={loadSystemData}
|
|
4078
6605
|
project={project}
|
|
4079
6606
|
cfg={cfg}
|
|
6607
|
+
loadingPath={loadingPath}
|
|
6608
|
+
onIframeLoad={onIframeLoad}
|
|
4080
6609
|
/>
|
|
4081
6610
|
</div>
|
|
6611
|
+
{(inspectorOpen || commentsPanelOpen) && (
|
|
6612
|
+
<PanelGrip
|
|
6613
|
+
label="Resize side panel"
|
|
6614
|
+
dir="rtl"
|
|
6615
|
+
size={rpSize}
|
|
6616
|
+
active={dragSide === 'rp'}
|
|
6617
|
+
onPointerDown={(e) => {
|
|
6618
|
+
e.preventDefault();
|
|
6619
|
+
e.currentTarget.setPointerCapture?.(e.pointerId);
|
|
6620
|
+
setDragSide('rp');
|
|
6621
|
+
}}
|
|
6622
|
+
/>
|
|
6623
|
+
)}
|
|
4082
6624
|
{/* Right dock — one panel at a time. Inspector takes precedence when
|
|
4083
6625
|
open (T6); else the comments panel. */}
|
|
4084
6626
|
{inspectorOpen ? (
|
|
4085
|
-
<InspectorPanel
|
|
6627
|
+
<InspectorPanel
|
|
6628
|
+
selected={selected}
|
|
6629
|
+
cfg={cfg}
|
|
6630
|
+
tab={inspectorTab}
|
|
6631
|
+
onTabChange={setInspectorTab}
|
|
6632
|
+
onClose={() => setInspectorOpen(false)}
|
|
6633
|
+
onOptimistic={applyOptimisticStyle}
|
|
6634
|
+
layersTree={layersTree}
|
|
6635
|
+
onSelectLayer={(n) =>
|
|
6636
|
+
postToActiveCanvas({
|
|
6637
|
+
dgn: 'select-by-id',
|
|
6638
|
+
id: n.id,
|
|
6639
|
+
artboardId: layersTree?.artboardId,
|
|
6640
|
+
index: n.index,
|
|
6641
|
+
})
|
|
6642
|
+
}
|
|
6643
|
+
onHoverLayer={(n) =>
|
|
6644
|
+
postToActiveCanvas({
|
|
6645
|
+
dgn: 'highlight',
|
|
6646
|
+
id: n ? n.id : null,
|
|
6647
|
+
artboardId: layersTree?.artboardId,
|
|
6648
|
+
index: n ? n.index : 0,
|
|
6649
|
+
})
|
|
6650
|
+
}
|
|
6651
|
+
width={rpSize.w}
|
|
6652
|
+
resizing={dragSide === 'rp'}
|
|
6653
|
+
/>
|
|
4086
6654
|
) : commentsPanelOpen ? (
|
|
4087
6655
|
<CommentsPanel
|
|
4088
6656
|
commentsByFile={commentsByFile}
|
|
@@ -4094,6 +6662,8 @@ function App() {
|
|
|
4094
6662
|
onResolve={resolveComment}
|
|
4095
6663
|
onReopen={reopenComment}
|
|
4096
6664
|
onDelete={deleteComment}
|
|
6665
|
+
width={rpSize.w}
|
|
6666
|
+
resizing={dragSide === 'rp'}
|
|
4097
6667
|
/>
|
|
4098
6668
|
) : null}
|
|
4099
6669
|
</div>
|
|
@@ -4121,6 +6691,7 @@ function App() {
|
|
|
4121
6691
|
onClose={() => setExportDialog(null)}
|
|
4122
6692
|
/>
|
|
4123
6693
|
)}
|
|
6694
|
+
<ShortcutsOverlay open={shortcutsOpen} onClose={() => setShortcutsOpen(false)} />
|
|
4124
6695
|
<HelpModal
|
|
4125
6696
|
open={helpOpen}
|
|
4126
6697
|
onClose={() => setHelpOpen(false)}
|
|
@@ -4160,6 +6731,9 @@ function App() {
|
|
|
4160
6731
|
open={!!tourSteps}
|
|
4161
6732
|
onClose={() => setTourSteps(null)}
|
|
4162
6733
|
onComplete={markUsageSeen}
|
|
6734
|
+
bus={tourBus}
|
|
6735
|
+
hasSelection={!!selected}
|
|
6736
|
+
hasCanvas={tabs.length > 0}
|
|
4163
6737
|
/>
|
|
4164
6738
|
</div>
|
|
4165
6739
|
);
|