@1agh/maude 0.29.0 → 0.31.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 +6 -6
- package/apps/studio/acp/bridge.ts +285 -0
- package/apps/studio/acp/env.ts +48 -0
- package/apps/studio/acp/index.ts +132 -0
- package/apps/studio/acp/probe.ts +112 -0
- package/apps/studio/acp/transcript.ts +149 -0
- 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 +1714 -1661
- package/apps/studio/annotations-model.ts +2077 -0
- package/apps/studio/annotations-snap.ts +125 -0
- package/apps/studio/api.ts +433 -182
- 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/chat-open.sh +44 -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-lib.tsx +112 -19
- package/apps/studio/canvas-list-watch.ts +177 -0
- package/apps/studio/canvas-shell.tsx +326 -9
- package/apps/studio/client/app.jsx +3579 -250
- package/apps/studio/client/canvas-url.js +5 -0
- package/apps/studio/client/github.js +99 -0
- package/apps/studio/client/index.html +1 -1
- package/apps/studio/client/panels/ChatPanel.jsx +770 -0
- package/apps/studio/client/panels/CollabModelInfographic.jsx +71 -0
- package/apps/studio/client/panels/CreateProject.jsx +334 -0
- package/apps/studio/client/panels/DiffView.jsx +590 -0
- package/apps/studio/client/panels/GitPanel.jsx +767 -0
- package/apps/studio/client/panels/IdentityBar.jsx +294 -0
- package/apps/studio/client/panels/OnboardingWizard.jsx +563 -0
- package/apps/studio/client/panels/RepoBranchSwitcher.jsx +349 -0
- package/apps/studio/client/panels/acp-runtime.js +286 -0
- package/apps/studio/client/panels/chat-markdown.jsx +138 -0
- package/apps/studio/client/panels/git-grouping.js +86 -0
- package/apps/studio/client/styles/0-reset.css +4 -0
- package/apps/studio/client/styles/3-shell-maude.css +1326 -14
- package/apps/studio/client/styles/4-components.css +39 -0
- package/apps/studio/client/styles/5-maude-overrides.css +41 -2
- package/apps/studio/client/styles/6-acp-chat.css +771 -0
- package/apps/studio/client/styles/_index.css +2 -0
- package/apps/studio/client/tour/collab-tour.js +61 -0
- package/apps/studio/client/tour/overlay.jsx +116 -18
- package/apps/studio/client/tour/usage-tour.js +23 -10
- package/apps/studio/client/whats-new.jsx +25 -10
- package/apps/studio/collab/registry.ts +13 -0
- package/apps/studio/collab/room.ts +36 -0
- 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 +37 -21
- package/apps/studio/dist/client.bundle.js +31795 -1829
- package/apps/studio/dist/comment-mount.js +97 -9
- package/apps/studio/dist/styles.css +7938 -1599
- package/apps/studio/dom-selection.ts +115 -0
- package/apps/studio/export-dialog.tsx +3 -3
- package/apps/studio/git/endpoints.ts +338 -0
- package/apps/studio/git/service.ts +1334 -0
- package/apps/studio/git/watch.ts +97 -0
- package/apps/studio/github/endpoints.ts +358 -0
- package/apps/studio/github/service.ts +231 -0
- package/apps/studio/github/token.ts +53 -0
- package/apps/studio/history.ts +20 -3
- package/apps/studio/hmr-broadcast.ts +9 -2
- package/apps/studio/http.ts +508 -6
- package/apps/studio/input-router.tsx +7 -0
- package/apps/studio/inspect.ts +37 -8
- package/apps/studio/participants-chrome.tsx +70 -16
- package/apps/studio/paths.ts +12 -0
- package/apps/studio/scaffold-design.ts +57 -0
- package/apps/studio/server.ts +70 -4
- package/apps/studio/sync/agent.ts +311 -57
- package/apps/studio/sync/codec.ts +69 -0
- package/apps/studio/sync/cold-start.ts +198 -0
- package/apps/studio/sync/connection-state.ts +58 -2
- package/apps/studio/sync/hub-link.ts +137 -0
- 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/acp-bridge.test.ts +127 -0
- package/apps/studio/test/acp-env.test.ts +65 -0
- package/apps/studio/test/acp-origin-gate.test.ts +78 -0
- package/apps/studio/test/acp-transcript.test.ts +112 -0
- 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-create-api.test.ts +72 -0
- package/apps/studio/test/canvas-edit.test.ts +181 -1
- package/apps/studio/test/canvas-list-watch.test.ts +322 -0
- package/apps/studio/test/canvas-meta-api.test.ts +161 -27
- package/apps/studio/test/canvas-origin-gate.test.ts +43 -0
- package/apps/studio/test/chat-markdown.test.tsx +58 -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/collab-session-survival.test.tsx +176 -0
- package/apps/studio/test/csp-canvas-shell.test.ts +5 -0
- package/apps/studio/test/csrf-write-guard.test.ts +78 -0
- package/apps/studio/test/editing-presence.test.ts +103 -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/fixtures/mock-acp-agent.mjs +45 -0
- package/apps/studio/test/git-api.test.ts +0 -0
- package/apps/studio/test/git-branches.test.ts +106 -0
- package/apps/studio/test/git-grouping.test.ts +106 -0
- package/apps/studio/test/git-watch.test.ts +97 -0
- package/apps/studio/test/github-api.test.ts +465 -0
- package/apps/studio/test/hub-link.test.ts +69 -0
- package/apps/studio/test/participants-chrome.test.ts +36 -1
- 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-seed-dedup.test.ts +187 -0
- package/apps/studio/test/sync-cold-start.test.ts +304 -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/tour-overlay.test.tsx +18 -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 +30 -11
- 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-chrome-visibility.tsx +66 -0
- package/apps/studio/use-collab.tsx +434 -202
- package/apps/studio/use-selection-set.tsx +9 -0
- package/apps/studio/use-tool-mode.tsx +5 -1
- package/apps/studio/whats-new.json +208 -10
- package/apps/studio/whats-new.schema.json +18 -2
- package/apps/studio/ws.ts +44 -1
- package/cli/commands/design-link.test.mjs +84 -0
- package/cli/commands/design.mjs +3 -1
- package/cli/commands/design.test.mjs +5 -1
- package/cli/lib/design-link.mjs +51 -1
- package/cli/lib/gitignore-block.mjs +16 -3
- package/cli/lib/gitignore-block.test.mjs +13 -1
- package/package.json +11 -9
- package/plugins/design/dependencies.json +17 -0
- package/plugins/design/templates/_shell.html +58 -12
|
@@ -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
|
|
@@ -12,11 +13,21 @@ import { createRoot } from 'react-dom/client';
|
|
|
12
13
|
// bundle — no React, no input-router. See the tool-cursor handler below.
|
|
13
14
|
import { resolveToolCursor } from '../canvas-cursors.ts';
|
|
14
15
|
import { canvasUrl } from './canvas-url.js';
|
|
16
|
+
import ChatPanel from './panels/ChatPanel.jsx';
|
|
17
|
+
import DiffView from './panels/DiffView.jsx';
|
|
18
|
+
import GitPanel from './panels/GitPanel.jsx';
|
|
19
|
+
import IdentityBar from './panels/IdentityBar.jsx';
|
|
20
|
+
import OnboardingWizard from './panels/OnboardingWizard.jsx';
|
|
21
|
+
import RepoBranchSwitcher from './panels/RepoBranchSwitcher.jsx';
|
|
22
|
+
import { appIsFirstRun, isNativeApp, onUpdateReady, restartToUpdate } from './github.js';
|
|
23
|
+
import { COLLAB_TOUR } from './tour/collab-tour.js';
|
|
15
24
|
import { TourOverlay } from './tour/overlay.jsx';
|
|
16
25
|
import { USAGE_TOUR } from './tour/usage-tour.js';
|
|
17
26
|
import { useWhatsNew, WhatsNewPanel, WhatsNewToast } from './whats-new.jsx';
|
|
18
27
|
|
|
19
28
|
const USAGE_TOUR_STORE = 'mdcc-usage-tour-seen';
|
|
29
|
+
// Phase 29 (E4) — the collab "rychlý kurz" is offered once after onboarding.
|
|
30
|
+
const COLLAB_TOUR_STORE = 'mdcc-collab-tour-seen';
|
|
20
31
|
|
|
21
32
|
const SYSTEM_TAB = '__system__';
|
|
22
33
|
const THEME_STORE = 'mdcc-theme';
|
|
@@ -295,6 +306,13 @@ const STICONS = {
|
|
|
295
306
|
sparkle: (
|
|
296
307
|
<path d="M8 1.8l1.4 4.8L14 8l-4.6 1.4L8 14.2l-1.4-4.8L2 8l4.6-1.4z" fill="currentColor" stroke="none" />
|
|
297
308
|
),
|
|
309
|
+
megaphone: (
|
|
310
|
+
<>
|
|
311
|
+
<path d="M2 6.7 11 4v8L2 9.3z" />
|
|
312
|
+
<path d="M11 5.2a2.4 2.4 0 0 1 0 5.6" />
|
|
313
|
+
<path d="M4.3 9.5v2.3a1.2 1.2 0 0 0 2.4 0v-1.7" />
|
|
314
|
+
</>
|
|
315
|
+
),
|
|
298
316
|
'panel-left': (
|
|
299
317
|
<>
|
|
300
318
|
<rect x="2.5" y="3" width="11" height="10" rx="1.5" />
|
|
@@ -319,6 +337,96 @@ const STICONS = {
|
|
|
319
337
|
<polyline points="2.2 9 8 12.3 13.8 9" />
|
|
320
338
|
</>
|
|
321
339
|
),
|
|
340
|
+
// Layer-type glyphs (Phase 12.3 W3.1) — one mark per LayerNode `type`.
|
|
341
|
+
box: <rect x="3" y="3" width="10" height="10" rx="1.2" />,
|
|
342
|
+
type: (
|
|
343
|
+
<>
|
|
344
|
+
<polyline points="4 4 12 4" />
|
|
345
|
+
<line x1="8" y1="4" x2="8" y2="12" />
|
|
346
|
+
</>
|
|
347
|
+
),
|
|
348
|
+
button: (
|
|
349
|
+
<>
|
|
350
|
+
<rect x="2.5" y="5" width="11" height="6" rx="3" />
|
|
351
|
+
<line x1="6" y1="8" x2="10" y2="8" />
|
|
352
|
+
</>
|
|
353
|
+
),
|
|
354
|
+
input: (
|
|
355
|
+
<>
|
|
356
|
+
<rect x="2.5" y="5" width="11" height="6" rx="1.2" />
|
|
357
|
+
<line x1="5" y1="8" x2="5" y2="8" />
|
|
358
|
+
</>
|
|
359
|
+
),
|
|
360
|
+
link: (
|
|
361
|
+
<>
|
|
362
|
+
<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" />
|
|
363
|
+
<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" />
|
|
364
|
+
</>
|
|
365
|
+
),
|
|
366
|
+
list: (
|
|
367
|
+
<>
|
|
368
|
+
<line x1="6" y1="4.5" x2="13" y2="4.5" />
|
|
369
|
+
<line x1="6" y1="8" x2="13" y2="8" />
|
|
370
|
+
<line x1="6" y1="11.5" x2="13" y2="11.5" />
|
|
371
|
+
<circle cx="3.2" cy="4.5" r="0.8" fill="currentColor" />
|
|
372
|
+
<circle cx="3.2" cy="8" r="0.8" fill="currentColor" />
|
|
373
|
+
<circle cx="3.2" cy="11.5" r="0.8" fill="currentColor" />
|
|
374
|
+
</>
|
|
375
|
+
),
|
|
376
|
+
eye: (
|
|
377
|
+
<>
|
|
378
|
+
<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" />
|
|
379
|
+
<circle cx="8" cy="8" r="2" />
|
|
380
|
+
</>
|
|
381
|
+
),
|
|
382
|
+
eyedropper: (
|
|
383
|
+
<>
|
|
384
|
+
<path d="M11 2.6a1.7 1.7 0 0 1 2.4 2.4l-1.2 1.2-2.4-2.4z" />
|
|
385
|
+
<path d="M9.5 4.6 4 10.1V12h1.9l5.5-5.5" />
|
|
386
|
+
</>
|
|
387
|
+
),
|
|
388
|
+
// Figma-style property prefix glyphs (#2) — small marks INSIDE numeric fields.
|
|
389
|
+
'p-corner': <path d="M3.5 12.5V7a3.5 3.5 0 0 1 3.5-3.5h5.5" />,
|
|
390
|
+
'p-opacity': (
|
|
391
|
+
<>
|
|
392
|
+
<rect x="3" y="3" width="10" height="10" rx="1.5" />
|
|
393
|
+
<path d="M3 8h10M8 3v10" strokeWidth="0.9" opacity="0.55" />
|
|
394
|
+
</>
|
|
395
|
+
),
|
|
396
|
+
'p-lineheight': (
|
|
397
|
+
<>
|
|
398
|
+
<line x1="6.5" y1="4" x2="13" y2="4" />
|
|
399
|
+
<line x1="6.5" y1="8" x2="13" y2="8" />
|
|
400
|
+
<line x1="6.5" y1="12" x2="13" y2="12" />
|
|
401
|
+
<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" />
|
|
402
|
+
</>
|
|
403
|
+
),
|
|
404
|
+
'p-letterspacing': (
|
|
405
|
+
<>
|
|
406
|
+
<path d="M3 4v8M13 4v8" />
|
|
407
|
+
<path d="M6 11.5 8 5l2 6.5M6.7 9.3h2.6" strokeWidth="1.1" />
|
|
408
|
+
</>
|
|
409
|
+
),
|
|
410
|
+
'p-gap': (
|
|
411
|
+
<>
|
|
412
|
+
<rect x="2" y="4.5" width="3.6" height="7" rx="0.6" />
|
|
413
|
+
<rect x="10.4" y="4.5" width="3.6" height="7" rx="0.6" />
|
|
414
|
+
<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" />
|
|
415
|
+
</>
|
|
416
|
+
),
|
|
417
|
+
'p-border': <rect x="3" y="3" width="10" height="10" rx="1" />,
|
|
418
|
+
'p-size': (
|
|
419
|
+
<>
|
|
420
|
+
<path d="M3 13 6.6 3l3.6 10" />
|
|
421
|
+
<path d="M4.3 9.6h4.6" />
|
|
422
|
+
</>
|
|
423
|
+
),
|
|
424
|
+
'eye-off': (
|
|
425
|
+
<>
|
|
426
|
+
<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" />
|
|
427
|
+
<line x1="2.5" y1="2.5" x2="13.5" y2="13.5" />
|
|
428
|
+
</>
|
|
429
|
+
),
|
|
322
430
|
sliders: (
|
|
323
431
|
<>
|
|
324
432
|
<line x1="3" y1="5" x2="13" y2="5" />
|
|
@@ -390,6 +498,22 @@ const STICONS = {
|
|
|
390
498
|
<line x1="13" y1="3" x2="7.6" y2="8.4" />
|
|
391
499
|
</>
|
|
392
500
|
),
|
|
501
|
+
share: (
|
|
502
|
+
<>
|
|
503
|
+
<circle cx="4" cy="8" r="1.9" />
|
|
504
|
+
<circle cx="11.6" cy="3.6" r="1.9" />
|
|
505
|
+
<circle cx="11.6" cy="12.4" r="1.9" />
|
|
506
|
+
<line x1="5.7" y1="7" x2="9.9" y2="4.6" />
|
|
507
|
+
<line x1="5.7" y1="9" x2="9.9" y2="11.4" />
|
|
508
|
+
</>
|
|
509
|
+
),
|
|
510
|
+
pen: (
|
|
511
|
+
<>
|
|
512
|
+
<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" />
|
|
513
|
+
<line x1="9.6" y1="4.2" x2="11.8" y2="6.4" />
|
|
514
|
+
</>
|
|
515
|
+
),
|
|
516
|
+
square: <rect x="3.5" y="3.5" width="9" height="9" rx="1" />,
|
|
393
517
|
};
|
|
394
518
|
|
|
395
519
|
// ⌘K command palette — the mockup's signature surface, wired to real shell
|
|
@@ -398,12 +522,19 @@ const STICONS = {
|
|
|
398
522
|
function CommandPalette({ open, onClose, actions }) {
|
|
399
523
|
const [q, setQ] = useState('');
|
|
400
524
|
const [active, setActive] = useState(0);
|
|
525
|
+
const listRef = useRef(null);
|
|
401
526
|
useEffect(() => {
|
|
402
527
|
if (open) {
|
|
403
528
|
setQ('');
|
|
404
529
|
setActive(0);
|
|
405
530
|
}
|
|
406
531
|
}, [open]);
|
|
532
|
+
// Keep the keyboard-active row visible while arrowing through a scrolled list.
|
|
533
|
+
useEffect(() => {
|
|
534
|
+
listRef.current
|
|
535
|
+
?.querySelector('.st-pal-item.is-active')
|
|
536
|
+
?.scrollIntoView({ block: 'nearest' });
|
|
537
|
+
}, [active]);
|
|
407
538
|
const filtered = useMemo(() => {
|
|
408
539
|
const needle = q.trim().toLowerCase();
|
|
409
540
|
if (!needle) return actions;
|
|
@@ -457,7 +588,7 @@ function CommandPalette({ open, onClose, actions }) {
|
|
|
457
588
|
/>
|
|
458
589
|
<Kbd>⌘K</Kbd>
|
|
459
590
|
</div>
|
|
460
|
-
<div className="st-pal-list">
|
|
591
|
+
<div className="st-pal-list" ref={listRef}>
|
|
461
592
|
{filtered.length === 0 ? (
|
|
462
593
|
<div className="st-pal-empty">No matching command.</div>
|
|
463
594
|
) : (
|
|
@@ -530,9 +661,19 @@ function initialsOf(name) {
|
|
|
530
661
|
if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase();
|
|
531
662
|
return ((parts[0][0] || '') + (parts[parts.length - 1][0] || '')).toUpperCase() || '?';
|
|
532
663
|
}
|
|
533
|
-
function StAvatar({ initials, hue, title }) {
|
|
664
|
+
function StAvatar({ initials, hue, title, pulse }) {
|
|
665
|
+
// Hue rides a custom property so CSS can mix it into the surface (DS avatar
|
|
666
|
+
// recipe: tinted bg + hue border + fg-0 text — solid fill + white text broke
|
|
667
|
+
// the accent-fg contrast rule and washed out in light theme). `pulse` plays
|
|
668
|
+
// the DS motion-presence role (scale+opacity ring) — the AI agent's "live"
|
|
669
|
+
// tell while it's editing.
|
|
534
670
|
return (
|
|
535
|
-
<span
|
|
671
|
+
<span
|
|
672
|
+
className={'st-avatar' + (pulse ? ' is-pulsing' : '')}
|
|
673
|
+
style={{ '--av-hue': hue }}
|
|
674
|
+
data-tip={title}
|
|
675
|
+
aria-label={title}
|
|
676
|
+
>
|
|
536
677
|
{initials}
|
|
537
678
|
</span>
|
|
538
679
|
);
|
|
@@ -542,6 +683,78 @@ function Kbd({ children }) {
|
|
|
542
683
|
return <span className="kbd">{children}</span>;
|
|
543
684
|
}
|
|
544
685
|
|
|
686
|
+
// ───────── Resizable panel grip (DS components-resize-panels contract) ─────────
|
|
687
|
+
//
|
|
688
|
+
// 8px hit area on a 1px seam; grip dots + accent surface on hover/focus/drag;
|
|
689
|
+
// pointer drag (with capture, so moves keep arriving over the iframe), arrow-key
|
|
690
|
+
// nudge (8px, ⇧=24px), Home/End to min/max, double-click resets to default.
|
|
691
|
+
// Width persists per panel in localStorage.
|
|
692
|
+
|
|
693
|
+
function usePanelSize(storeKey, { min, max, def }) {
|
|
694
|
+
const clamp = useCallback((v) => Math.min(max, Math.max(min, v)), [min, max]);
|
|
695
|
+
const [w, setWRaw] = useState(() => {
|
|
696
|
+
try {
|
|
697
|
+
const v = parseInt(localStorage.getItem(storeKey) || '', 10);
|
|
698
|
+
return Number.isFinite(v) ? clamp(v) : def;
|
|
699
|
+
} catch {
|
|
700
|
+
return def;
|
|
701
|
+
}
|
|
702
|
+
});
|
|
703
|
+
useEffect(() => {
|
|
704
|
+
try {
|
|
705
|
+
localStorage.setItem(storeKey, String(w));
|
|
706
|
+
} catch {}
|
|
707
|
+
}, [storeKey, w]);
|
|
708
|
+
const setW = useCallback(
|
|
709
|
+
(next) => setWRaw((prev) => clamp(typeof next === 'function' ? next(prev) : next)),
|
|
710
|
+
[clamp]
|
|
711
|
+
);
|
|
712
|
+
return { w, setW, min, max, def };
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
function PanelGrip({ label, size, onPointerDown, active, dir = 'ltr' }) {
|
|
716
|
+
const { w, setW, min, max, def } = size;
|
|
717
|
+
// `dir` is grip-relative: 'ltr' (left panel) → ArrowRight widens; 'rtl'
|
|
718
|
+
// (right dock) → ArrowRight narrows, since the seam moves toward the panel.
|
|
719
|
+
const grow = dir === 'rtl' ? -1 : 1;
|
|
720
|
+
return (
|
|
721
|
+
<div
|
|
722
|
+
className={'st-grip' + (active ? ' is-active' : '')}
|
|
723
|
+
role="separator"
|
|
724
|
+
tabIndex={0}
|
|
725
|
+
aria-orientation="vertical"
|
|
726
|
+
aria-label={label}
|
|
727
|
+
aria-valuemin={min}
|
|
728
|
+
aria-valuemax={max}
|
|
729
|
+
aria-valuenow={Math.round(w)}
|
|
730
|
+
onPointerDown={onPointerDown}
|
|
731
|
+
onDoubleClick={() => setW(def)}
|
|
732
|
+
onKeyDown={(e) => {
|
|
733
|
+
const step = e.shiftKey ? 24 : 8;
|
|
734
|
+
if (e.key === 'ArrowRight') {
|
|
735
|
+
e.preventDefault();
|
|
736
|
+
setW((v) => v + step * grow);
|
|
737
|
+
} else if (e.key === 'ArrowLeft') {
|
|
738
|
+
e.preventDefault();
|
|
739
|
+
setW((v) => v - step * grow);
|
|
740
|
+
} else if (e.key === 'Home') {
|
|
741
|
+
e.preventDefault();
|
|
742
|
+
setW(min);
|
|
743
|
+
} else if (e.key === 'End') {
|
|
744
|
+
e.preventDefault();
|
|
745
|
+
setW(max);
|
|
746
|
+
}
|
|
747
|
+
}}
|
|
748
|
+
>
|
|
749
|
+
<svg className="st-grip-dots" viewBox="0 0 6 18" aria-hidden="true">
|
|
750
|
+
<circle cx="3" cy="3" r="1.1" fill="currentColor" />
|
|
751
|
+
<circle cx="3" cy="9" r="1.1" fill="currentColor" />
|
|
752
|
+
<circle cx="3" cy="15" r="1.1" fill="currentColor" />
|
|
753
|
+
</svg>
|
|
754
|
+
</div>
|
|
755
|
+
);
|
|
756
|
+
}
|
|
757
|
+
|
|
545
758
|
// T5 (Plan C) — shell-level Export & Handoff dialog (maude `.st-dialog`), per
|
|
546
759
|
// `.design/ui/Studio.tsx` HandoffBoard. Wired to the privileged main-origin
|
|
547
760
|
// `POST /_api/export` (7 real formats × scopes). The shadcn card is HANDOFF —
|
|
@@ -817,7 +1030,7 @@ function DirRow({ name, depth, defaultOpen, children }) {
|
|
|
817
1030
|
onClick={() => setOpen((v) => !v)}
|
|
818
1031
|
>
|
|
819
1032
|
<span className="st-row-glyph">
|
|
820
|
-
<StIcon name={open ? '
|
|
1033
|
+
<StIcon name="chevron-right" className={'st-chev' + (open ? ' is-open' : '')} size={13} />
|
|
821
1034
|
</span>
|
|
822
1035
|
<span className="st-row-name">{name}</span>
|
|
823
1036
|
</button>
|
|
@@ -847,7 +1060,7 @@ function DsFolderRow({ name, dsName, depth, defaultOpen, active, onOpenSystem, c
|
|
|
847
1060
|
aria-label={open ? 'Collapse design system' : 'Expand design system'}
|
|
848
1061
|
title={open ? 'Collapse' : 'Expand'}
|
|
849
1062
|
>
|
|
850
|
-
<StIcon name={open ? '
|
|
1063
|
+
<StIcon name="chevron-right" className={'st-chev' + (open ? ' is-open' : '')} size={13} />
|
|
851
1064
|
</button>
|
|
852
1065
|
<button
|
|
853
1066
|
type="button"
|
|
@@ -867,7 +1080,7 @@ function DsFolderRow({ name, dsName, depth, defaultOpen, active, onOpenSystem, c
|
|
|
867
1080
|
);
|
|
868
1081
|
}
|
|
869
1082
|
|
|
870
|
-
function FileRow({ file, activePath, onOpen, onDelete, openCount: oc, depth, kind, sidecar }) {
|
|
1083
|
+
function FileRow({ file, activePath, onOpen, onDelete, openCount: oc, depth, kind, sidecar, dirty }) {
|
|
871
1084
|
const isSel = file.path === activePath;
|
|
872
1085
|
const isCanvas = CANVAS_EXT_RE.test(file.name);
|
|
873
1086
|
// Non-canvas rows (PROJECT *.md, RUNTIME _active.json, ...) are display-only —
|
|
@@ -898,6 +1111,11 @@ function FileRow({ file, activePath, onOpen, onDelete, openCount: oc, depth, kin
|
|
|
898
1111
|
<StIcon name="file" size={13} />
|
|
899
1112
|
</span>
|
|
900
1113
|
<span className="st-row-name">{label}</span>
|
|
1114
|
+
{dirty && (
|
|
1115
|
+
<span className="st-git-badge" data-kind={dirty} title={`Unsaved (${dirty})`} aria-label={`Unsaved, ${dirty}`}>
|
|
1116
|
+
{dirty}
|
|
1117
|
+
</span>
|
|
1118
|
+
)}
|
|
901
1119
|
{oc > 0 && <span className="st-row-badge">{oc}</span>}
|
|
902
1120
|
</button>
|
|
903
1121
|
);
|
|
@@ -934,7 +1152,9 @@ function CanvasRow({
|
|
|
934
1152
|
openCount: oc,
|
|
935
1153
|
showHidden,
|
|
936
1154
|
forceOpen,
|
|
1155
|
+
dirtyByPath,
|
|
937
1156
|
}) {
|
|
1157
|
+
const dirty = dirtyByPath?.get(primary.path);
|
|
938
1158
|
const hasSidecars = sidecars.length > 0;
|
|
939
1159
|
const [openState, setOpenState] = useState(false);
|
|
940
1160
|
// Sidecars are only revealed when the user opts in via `showHidden` — the
|
|
@@ -953,6 +1173,7 @@ function CanvasRow({
|
|
|
953
1173
|
openCount={oc}
|
|
954
1174
|
depth={depth}
|
|
955
1175
|
kind={kind}
|
|
1176
|
+
dirty={dirty}
|
|
956
1177
|
/>
|
|
957
1178
|
);
|
|
958
1179
|
}
|
|
@@ -983,9 +1204,14 @@ function CanvasRow({
|
|
|
983
1204
|
setOpenState((v) => !v);
|
|
984
1205
|
}}
|
|
985
1206
|
>
|
|
986
|
-
<StIcon name={open ? '
|
|
1207
|
+
<StIcon name="chevron-right" className={'st-chev' + (open ? ' is-open' : '')} size={13} />
|
|
987
1208
|
</span>
|
|
988
1209
|
<span className="st-row-name">{displayName(primary.name)}</span>
|
|
1210
|
+
{dirty && (
|
|
1211
|
+
<span className="st-git-badge" data-kind={dirty} title={`Unsaved (${dirty})`} aria-label={`Unsaved, ${dirty}`}>
|
|
1212
|
+
{dirty}
|
|
1213
|
+
</span>
|
|
1214
|
+
)}
|
|
989
1215
|
{oc > 0 && <span className="st-row-badge">{oc}</span>}
|
|
990
1216
|
</button>
|
|
991
1217
|
{open &&
|
|
@@ -1018,6 +1244,7 @@ function Tree({
|
|
|
1018
1244
|
activeDsName,
|
|
1019
1245
|
onOpenSystem,
|
|
1020
1246
|
onDelete,
|
|
1247
|
+
dirtyByPath,
|
|
1021
1248
|
}) {
|
|
1022
1249
|
const dirs = Object.keys(node)
|
|
1023
1250
|
.filter((k) => k !== '_files')
|
|
@@ -1059,6 +1286,7 @@ function Tree({
|
|
|
1059
1286
|
kind={kind}
|
|
1060
1287
|
showHidden={showHidden}
|
|
1061
1288
|
forceOpen={forceOpen}
|
|
1289
|
+
dirtyByPath={dirtyByPath}
|
|
1062
1290
|
/>
|
|
1063
1291
|
);
|
|
1064
1292
|
})}
|
|
@@ -1090,6 +1318,7 @@ function Tree({
|
|
|
1090
1318
|
activeDsName={activeDsName}
|
|
1091
1319
|
onOpenSystem={onOpenSystem}
|
|
1092
1320
|
onDelete={onDelete}
|
|
1321
|
+
dirtyByPath={dirtyByPath}
|
|
1093
1322
|
/>
|
|
1094
1323
|
);
|
|
1095
1324
|
if (dsMatch && onOpenSystem) {
|
|
@@ -1155,6 +1384,11 @@ function Sidebar({
|
|
|
1155
1384
|
onDeleteBoard,
|
|
1156
1385
|
collapsed,
|
|
1157
1386
|
onCollapse,
|
|
1387
|
+
width,
|
|
1388
|
+
resizing,
|
|
1389
|
+
dirtyByPath,
|
|
1390
|
+
project,
|
|
1391
|
+
gitBranch,
|
|
1158
1392
|
}) {
|
|
1159
1393
|
const filteredGroups = useMemo(() => {
|
|
1160
1394
|
if (!search) return groups;
|
|
@@ -1200,14 +1434,19 @@ function Sidebar({
|
|
|
1200
1434
|
}, [filteredGroups]);
|
|
1201
1435
|
|
|
1202
1436
|
return (
|
|
1203
|
-
<nav
|
|
1437
|
+
<nav
|
|
1438
|
+
className={'st-sidebar' + (collapsed ? ' is-collapsed' : '') + (resizing ? ' is-resizing' : '')}
|
|
1439
|
+
style={collapsed || !width ? undefined : { width, flexBasis: width }}
|
|
1440
|
+
aria-label="Files"
|
|
1441
|
+
data-tour="sidebar"
|
|
1442
|
+
>
|
|
1204
1443
|
<div className="st-sb-hd">
|
|
1205
1444
|
<span className="st-sb-title">Files</span>
|
|
1206
1445
|
<div className="st-sb-hd-actions">
|
|
1207
1446
|
<button
|
|
1208
1447
|
type="button"
|
|
1209
1448
|
className="st-iconbtn"
|
|
1210
|
-
|
|
1449
|
+
data-tip="New blank brief board"
|
|
1211
1450
|
aria-label="New blank brief board"
|
|
1212
1451
|
aria-expanded={creating}
|
|
1213
1452
|
onClick={() => {
|
|
@@ -1217,7 +1456,10 @@ function Sidebar({
|
|
|
1217
1456
|
>
|
|
1218
1457
|
<StIcon name="plus" size={15} />
|
|
1219
1458
|
</button>
|
|
1220
|
-
<span
|
|
1459
|
+
<span
|
|
1460
|
+
className="st-live"
|
|
1461
|
+
data-tip={wsConnected ? 'live · file index synced' : 'reconnecting…'}
|
|
1462
|
+
>
|
|
1221
1463
|
<span className={'st-live-dot' + (wsConnected ? ' is-connected' : '')} aria-hidden="true" />
|
|
1222
1464
|
{htmlShown} / {htmlCount}
|
|
1223
1465
|
</span>
|
|
@@ -1226,7 +1468,7 @@ function Sidebar({
|
|
|
1226
1468
|
type="button"
|
|
1227
1469
|
className="st-iconbtn"
|
|
1228
1470
|
aria-label="Collapse sidebar"
|
|
1229
|
-
|
|
1471
|
+
data-tip="Collapse sidebar · T"
|
|
1230
1472
|
onClick={onCollapse}
|
|
1231
1473
|
>
|
|
1232
1474
|
<StIcon name="panel-left" size={15} />
|
|
@@ -1263,7 +1505,7 @@ function Sidebar({
|
|
|
1263
1505
|
type="button"
|
|
1264
1506
|
className="st-newboard-go"
|
|
1265
1507
|
disabled={newBusy || !newName.trim()}
|
|
1266
|
-
|
|
1508
|
+
data-tip="Create · Enter"
|
|
1267
1509
|
aria-label="Create brief board"
|
|
1268
1510
|
onClick={submitNewBoard}
|
|
1269
1511
|
>
|
|
@@ -1285,13 +1527,21 @@ function Sidebar({
|
|
|
1285
1527
|
placeholder="Search canvases…"
|
|
1286
1528
|
value={search}
|
|
1287
1529
|
onChange={(e) => setSearch(e.target.value)}
|
|
1530
|
+
onKeyDown={(e) => {
|
|
1531
|
+
// Esc — clear the filter first; a second Esc leaves the field.
|
|
1532
|
+
if (e.key === 'Escape') {
|
|
1533
|
+
e.preventDefault();
|
|
1534
|
+
if (search) setSearch('');
|
|
1535
|
+
else e.currentTarget.blur();
|
|
1536
|
+
}
|
|
1537
|
+
}}
|
|
1288
1538
|
aria-label="Filter files"
|
|
1289
1539
|
/>
|
|
1290
1540
|
{search ? (
|
|
1291
1541
|
<button
|
|
1292
1542
|
className="st-search-clear"
|
|
1293
1543
|
onClick={() => setSearch('')}
|
|
1294
|
-
|
|
1544
|
+
data-tip="Clear · Esc"
|
|
1295
1545
|
aria-label="Clear search"
|
|
1296
1546
|
>
|
|
1297
1547
|
×
|
|
@@ -1336,7 +1586,7 @@ function Sidebar({
|
|
|
1336
1586
|
aria-expanded={sectionOpen}
|
|
1337
1587
|
title={sectionOpen ? 'Collapse section' : 'Expand section'}
|
|
1338
1588
|
>
|
|
1339
|
-
<StIcon name={sectionOpen ? '
|
|
1589
|
+
<StIcon name="chevron-right" className={'st-chev' + (sectionOpen ? ' is-open' : '')} size={13} />
|
|
1340
1590
|
<span className="st-sec-name">{meta.title}</span>
|
|
1341
1591
|
{pill && <span className="st-pill">{pill}</span>}
|
|
1342
1592
|
</button>
|
|
@@ -1355,6 +1605,7 @@ function Sidebar({
|
|
|
1355
1605
|
activeDsName={activeDsName}
|
|
1356
1606
|
onOpenSystem={isDs ? onOpenSystem : undefined}
|
|
1357
1607
|
onDelete={isDs ? undefined : onDeleteBoard}
|
|
1608
|
+
dirtyByPath={dirtyByPath}
|
|
1358
1609
|
/>
|
|
1359
1610
|
) : (
|
|
1360
1611
|
<div className="st-tree-empty">{search ? 'No matches.' : 'Empty.'}</div>
|
|
@@ -1363,6 +1614,14 @@ function Sidebar({
|
|
|
1363
1614
|
);
|
|
1364
1615
|
})}
|
|
1365
1616
|
</div>
|
|
1617
|
+
{/* Phase 29 (E4) — the project + draft switcher: a compact one-line dock that
|
|
1618
|
+
opens UPWARD, sitting directly above the GitHub identity avatar so the two
|
|
1619
|
+
form one bottom dock. Renders nothing until the project is a git repo. */}
|
|
1620
|
+
<RepoBranchSwitcher project={project} liveBranch={gitBranch} />
|
|
1621
|
+
{/* Phase 28 (E3) — GitHub identity as a compact avatar docked at the BOTTOM:
|
|
1622
|
+
sign in, connected account + New/Pull/Share, sign out. Self-contained
|
|
1623
|
+
(owns its device-code + CreateProject dialogs). Renders nothing in browser. */}
|
|
1624
|
+
<IdentityBar />
|
|
1366
1625
|
</nav>
|
|
1367
1626
|
);
|
|
1368
1627
|
}
|
|
@@ -1417,7 +1676,7 @@ function HelpModal({ open, onClose, onStartTour }) {
|
|
|
1417
1676
|
<span className="title" id="help-modal-title">
|
|
1418
1677
|
Help · shortcuts & commands
|
|
1419
1678
|
</span>
|
|
1420
|
-
<span className="sku">
|
|
1679
|
+
<span className="sku">MAUDE-DEV-SRV / v{MDCC_VERSION}</span>
|
|
1421
1680
|
{onStartTour && (
|
|
1422
1681
|
<button
|
|
1423
1682
|
type="button"
|
|
@@ -1506,22 +1765,28 @@ function HelpModal({ open, onClose, onStartTour }) {
|
|
|
1506
1765
|
</ul>
|
|
1507
1766
|
</details>
|
|
1508
1767
|
<details>
|
|
1509
|
-
<summary>
|
|
1768
|
+
<summary>Canvas & panels</summary>
|
|
1510
1769
|
<ul>
|
|
1511
1770
|
<li>
|
|
1512
|
-
click in tree <span>open
|
|
1771
|
+
click in tree <span>open canvas (replaces the active one)</span>
|
|
1513
1772
|
</li>
|
|
1514
1773
|
<li>
|
|
1515
|
-
|
|
1774
|
+
File ▸ Close canvas <span>clear the stage</span>
|
|
1516
1775
|
</li>
|
|
1517
1776
|
<li>
|
|
1518
|
-
<kbd>⌘R</kbd> <span>reload
|
|
1777
|
+
<kbd>⌘R</kbd> <span>reload canvas</span>
|
|
1519
1778
|
</li>
|
|
1520
1779
|
<li>
|
|
1521
1780
|
<kbd>/</kbd> <span>focus search</span>
|
|
1522
1781
|
</li>
|
|
1523
1782
|
<li>
|
|
1524
|
-
<kbd>⌘⇧M</kbd> <span>
|
|
1783
|
+
<kbd>⌘⇧M</kbd> <span>comments panel</span>
|
|
1784
|
+
</li>
|
|
1785
|
+
<li>
|
|
1786
|
+
<kbd>⌘⇧I</kbd> <span>inspector</span>
|
|
1787
|
+
</li>
|
|
1788
|
+
<li>
|
|
1789
|
+
<kbd>?</kbd> <span>keyboard-shortcuts cheat sheet</span>
|
|
1525
1790
|
</li>
|
|
1526
1791
|
</ul>
|
|
1527
1792
|
</details>
|
|
@@ -1661,7 +1926,7 @@ function HelpModal({ open, onClose, onStartTour }) {
|
|
|
1661
1926
|
<details>
|
|
1662
1927
|
<summary>Pin-to-element flow</summary>
|
|
1663
1928
|
<ol>
|
|
1664
|
-
<li>Open canvas
|
|
1929
|
+
<li>Open a canvas</li>
|
|
1665
1930
|
<li>
|
|
1666
1931
|
<kbd>⌘</kbd>+click element
|
|
1667
1932
|
</li>
|
|
@@ -1701,13 +1966,152 @@ function HelpModal({ open, onClose, onStartTour }) {
|
|
|
1701
1966
|
);
|
|
1702
1967
|
}
|
|
1703
1968
|
|
|
1969
|
+
// ───────── Keyboard-shortcuts overlay (DS components-shortcuts-overlay) ─────
|
|
1970
|
+
//
|
|
1971
|
+
// The ? cheat-sheet: dim scrim, shared panel material, four dense mono-headed
|
|
1972
|
+
// columns, Esc chip in the footer. REAL bindings only — every row here is
|
|
1973
|
+
// wired in the shell handler, the canvas input-router, or canvas-lib's
|
|
1974
|
+
// viewport controller. Scope chips mark the rows that need canvas focus.
|
|
1975
|
+
|
|
1976
|
+
const SHORTCUT_GROUPS = [
|
|
1977
|
+
{
|
|
1978
|
+
id: 'canvas',
|
|
1979
|
+
label: 'Canvas',
|
|
1980
|
+
items: [
|
|
1981
|
+
{ label: 'Command palette', kbd: '⌘ K' },
|
|
1982
|
+
{ label: 'New brief board', kbd: 'N' },
|
|
1983
|
+
{ label: 'Export…', kbd: '⇧ ⌘ E' },
|
|
1984
|
+
{ label: 'Handoff to production', kbd: '⇧ ⌘ H' },
|
|
1985
|
+
{ label: 'Reload canvas', kbd: '⌘ R' },
|
|
1986
|
+
{ label: 'Search files', kbd: '/', alt: '⌘ F' },
|
|
1987
|
+
],
|
|
1988
|
+
},
|
|
1989
|
+
{
|
|
1990
|
+
id: 'tools',
|
|
1991
|
+
label: 'Tools · canvas focus',
|
|
1992
|
+
items: [
|
|
1993
|
+
{ label: 'Move · Hand · Comment', kbd: 'V', alt: 'H / C' },
|
|
1994
|
+
{ label: 'Pen · Highlighter · Eraser', kbd: 'B', alt: 'I / E' },
|
|
1995
|
+
{ label: 'Shape · Arrow', kbd: 'R', alt: 'A' },
|
|
1996
|
+
{ label: 'Sticky · Text · Section', kbd: 'N', alt: 'T / ⇧S' },
|
|
1997
|
+
{ label: 'Undo / redo', kbd: '⌘ Z', alt: '⇧ ⌘ Z' },
|
|
1998
|
+
],
|
|
1999
|
+
},
|
|
2000
|
+
{
|
|
2001
|
+
id: 'selection',
|
|
2002
|
+
label: 'Selection & zoom',
|
|
2003
|
+
items: [
|
|
2004
|
+
{ label: 'Select element', kbd: '⌘ click' },
|
|
2005
|
+
{ label: 'Add to selection', kbd: '⌘ ⇧ click' },
|
|
2006
|
+
{ label: 'Preview deepest', kbd: '⌘ hover' },
|
|
2007
|
+
{ label: 'Deselect · close menu', kbd: 'Esc' },
|
|
2008
|
+
{ label: 'Zoom in / out', kbd: '⌘ +', alt: '⌘ −' },
|
|
2009
|
+
{ label: 'Fit · actual size', kbd: '⌘ 0', alt: '⌘ 1' },
|
|
2010
|
+
],
|
|
2011
|
+
},
|
|
2012
|
+
{
|
|
2013
|
+
id: 'view',
|
|
2014
|
+
label: 'View',
|
|
2015
|
+
items: [
|
|
2016
|
+
{ label: 'Project tree', kbd: 'T' },
|
|
2017
|
+
{ label: 'Design system view', kbd: 'S' },
|
|
2018
|
+
{ label: 'Inspector', kbd: '⌘ ⇧ I' },
|
|
2019
|
+
{ label: 'Comments sidebar', kbd: '⌘ ⇧ M' },
|
|
2020
|
+
{ label: 'Annotations', kbd: '⇧ P' },
|
|
2021
|
+
{ label: 'Hidden files', kbd: 'H' },
|
|
2022
|
+
{ label: 'This cheat sheet · help', kbd: '?', alt: 'F1' },
|
|
2023
|
+
],
|
|
2024
|
+
},
|
|
2025
|
+
];
|
|
2026
|
+
|
|
2027
|
+
function ShortcutCombo({ kbd, alt }) {
|
|
2028
|
+
const combo = (s, key) => (
|
|
2029
|
+
<span className="so-combo" key={key}>
|
|
2030
|
+
{s.split(' ').map((k, i) => (
|
|
2031
|
+
<Kbd key={`${k}-${i}`}>{k}</Kbd>
|
|
2032
|
+
))}
|
|
2033
|
+
</span>
|
|
2034
|
+
);
|
|
2035
|
+
return (
|
|
2036
|
+
<span className="so-combos">
|
|
2037
|
+
{combo(kbd, 'main')}
|
|
2038
|
+
{alt
|
|
2039
|
+
? alt.split(' / ').map((a) => (
|
|
2040
|
+
<Fragment key={a}>
|
|
2041
|
+
<span className="so-or">/</span>
|
|
2042
|
+
{combo(a, a)}
|
|
2043
|
+
</Fragment>
|
|
2044
|
+
))
|
|
2045
|
+
: null}
|
|
2046
|
+
</span>
|
|
2047
|
+
);
|
|
2048
|
+
}
|
|
2049
|
+
|
|
2050
|
+
function ShortcutsOverlay({ open, onClose }) {
|
|
2051
|
+
useEffect(() => {
|
|
2052
|
+
if (!open) return;
|
|
2053
|
+
function onKey(e) {
|
|
2054
|
+
if (e.key === 'Escape') {
|
|
2055
|
+
e.preventDefault();
|
|
2056
|
+
onClose();
|
|
2057
|
+
}
|
|
2058
|
+
}
|
|
2059
|
+
window.addEventListener('keydown', onKey);
|
|
2060
|
+
return () => window.removeEventListener('keydown', onKey);
|
|
2061
|
+
}, [open, onClose]);
|
|
2062
|
+
if (!open) return null;
|
|
2063
|
+
const bindings = SHORTCUT_GROUPS.reduce((n, g) => n + g.items.length, 0);
|
|
2064
|
+
return (
|
|
2065
|
+
<div
|
|
2066
|
+
className="st-scrim"
|
|
2067
|
+
role="presentation"
|
|
2068
|
+
onMouseDown={(e) => {
|
|
2069
|
+
if (e.target === e.currentTarget) onClose();
|
|
2070
|
+
}}
|
|
2071
|
+
>
|
|
2072
|
+
<div className="so-overlay" role="dialog" aria-modal="true" aria-label="Keyboard shortcuts">
|
|
2073
|
+
<div className="so-overlay-hd">
|
|
2074
|
+
<span className="so-title">Keyboard shortcuts</span>
|
|
2075
|
+
<span className="so-trigger">
|
|
2076
|
+
press <Kbd>?</Kbd> to open
|
|
2077
|
+
</span>
|
|
2078
|
+
</div>
|
|
2079
|
+
<div className="so-columns">
|
|
2080
|
+
{SHORTCUT_GROUPS.map((g) => (
|
|
2081
|
+
<section key={g.id} className={'so-section so-section--' + g.id}>
|
|
2082
|
+
<h3 className="so-section-hd">{g.label}</h3>
|
|
2083
|
+
<dl className="so-list">
|
|
2084
|
+
{g.items.map((it) => (
|
|
2085
|
+
<div key={it.label} className="so-pair">
|
|
2086
|
+
<dt>{it.label}</dt>
|
|
2087
|
+
<dd>
|
|
2088
|
+
<ShortcutCombo kbd={it.kbd} alt={it.alt} />
|
|
2089
|
+
</dd>
|
|
2090
|
+
</div>
|
|
2091
|
+
))}
|
|
2092
|
+
</dl>
|
|
2093
|
+
</section>
|
|
2094
|
+
))}
|
|
2095
|
+
</div>
|
|
2096
|
+
<div className="so-overlay-ft">
|
|
2097
|
+
<span>
|
|
2098
|
+
close with <Kbd>Esc</Kbd>
|
|
2099
|
+
</span>
|
|
2100
|
+
<span className="so-count">
|
|
2101
|
+
{bindings} bindings · {SHORTCUT_GROUPS.length} groups
|
|
2102
|
+
</span>
|
|
2103
|
+
</div>
|
|
2104
|
+
</div>
|
|
2105
|
+
</div>
|
|
2106
|
+
);
|
|
2107
|
+
}
|
|
2108
|
+
|
|
1704
2109
|
// ───────── Menubar (CV-01/CV-08 top chrome) ─────────
|
|
1705
2110
|
//
|
|
1706
2111
|
// Replaces the legacy `.header` action-button toolbar. Mirrors the shared
|
|
1707
2112
|
// Menubar component from .design/ui/Canvas Viewport.html — brand · menus ·
|
|
1708
|
-
// status. View dropdown is wired to the
|
|
1709
|
-
//
|
|
1710
|
-
// lands.
|
|
2113
|
+
// status. View dropdown is wired to the panels + zoom that exist today;
|
|
2114
|
+
// Presentation Mode stays phase-tagged until it ships.
|
|
1711
2115
|
|
|
1712
2116
|
const MENU_NAMES = ['File', 'Edit', 'View', 'Selection', 'Tools', 'Help'];
|
|
1713
2117
|
|
|
@@ -1729,7 +2133,7 @@ function useDropdownClose(onClose) {
|
|
|
1729
2133
|
}, [onClose]);
|
|
1730
2134
|
}
|
|
1731
2135
|
|
|
1732
|
-
function ViewDropdown({ panels, onToggle, onClose }) {
|
|
2136
|
+
function ViewDropdown({ panels, onToggle, onClose, onZoom, hasCanvas }) {
|
|
1733
2137
|
useDropdownClose(onClose);
|
|
1734
2138
|
return (
|
|
1735
2139
|
<div className="st-dropdown" role="menu" aria-label="View" style={{ left: 152 }}>
|
|
@@ -1752,117 +2156,55 @@ function ViewDropdown({ panels, onToggle, onClose }) {
|
|
|
1752
2156
|
<span className="st-dd-check">{p.checked ? <StIcon name="check" size={13} /> : null}</span>
|
|
1753
2157
|
<span>{p.label}</span>
|
|
1754
2158
|
</span>
|
|
1755
|
-
{p.phase ?
|
|
2159
|
+
{p.phase ? (
|
|
2160
|
+
<span className="st-dd-phase">{p.phase}</span>
|
|
2161
|
+
) : p.shortcut ? (
|
|
2162
|
+
<Kbd>{p.shortcut}</Kbd>
|
|
2163
|
+
) : null}
|
|
1756
2164
|
</button>
|
|
1757
2165
|
))}
|
|
1758
2166
|
<div className="st-dd-sep" />
|
|
1759
2167
|
<div className="st-dd-hd">Zoom</div>
|
|
1760
2168
|
{[
|
|
1761
|
-
{ label: 'Zoom In', shortcut: '⌘ +' },
|
|
1762
|
-
{ label: 'Zoom Out', shortcut: '⌘ −' },
|
|
1763
|
-
{ label: 'Fit to Screen', shortcut: '⌘ 0' },
|
|
1764
|
-
{ label: 'Actual Size · 100 %', shortcut: '
|
|
2169
|
+
{ op: 'in', label: 'Zoom In', shortcut: '⌘ +' },
|
|
2170
|
+
{ op: 'out', label: 'Zoom Out', shortcut: '⌘ −' },
|
|
2171
|
+
{ op: 'fit', label: 'Fit to Screen', shortcut: '⌘ 0' },
|
|
2172
|
+
{ op: 'actual', label: 'Actual Size · 100 %', shortcut: '⌘ 1' },
|
|
1765
2173
|
].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
2174
|
<button
|
|
1833
|
-
key={
|
|
2175
|
+
key={z.label}
|
|
1834
2176
|
type="button"
|
|
1835
2177
|
role="menuitem"
|
|
1836
2178
|
className="st-dd-item"
|
|
2179
|
+
aria-disabled={hasCanvas ? undefined : 'true'}
|
|
1837
2180
|
onClick={() => {
|
|
1838
|
-
|
|
2181
|
+
if (!hasCanvas) return;
|
|
2182
|
+
onZoom?.(z.op);
|
|
1839
2183
|
onClose();
|
|
1840
2184
|
}}
|
|
1841
2185
|
>
|
|
1842
2186
|
<span className="st-dd-lead">
|
|
1843
2187
|
<span className="st-dd-check" />
|
|
1844
|
-
<span>{
|
|
2188
|
+
<span>{z.label}</span>
|
|
1845
2189
|
</span>
|
|
1846
|
-
<Kbd>{
|
|
2190
|
+
<Kbd>{z.shortcut}</Kbd>
|
|
1847
2191
|
</button>
|
|
1848
2192
|
))}
|
|
1849
2193
|
</div>
|
|
1850
2194
|
);
|
|
1851
2195
|
}
|
|
1852
2196
|
|
|
1853
|
-
//
|
|
1854
|
-
//
|
|
1855
|
-
|
|
2197
|
+
// Help dropdown — cheat sheet · deep help · tour · what's new.
|
|
2198
|
+
// Shared menubar dropdown — File / Edit / Selection / Tools / Help all render
|
|
2199
|
+
// the identical {id,label,shortcut,sep?,disabled?} list over the same button
|
|
2200
|
+
// skeleton, differing only in aria-label, left offset, and an optional header.
|
|
2201
|
+
// (ViewDropdown stays separate — its checkbox/phase/zoom-op rows genuinely
|
|
2202
|
+
// diverge.) Per the /flow:done simplifier pass — collapsed 5 near-dupes.
|
|
2203
|
+
function DropdownMenu({ label, left, header, items, onAction, onClose }) {
|
|
1856
2204
|
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
2205
|
return (
|
|
1865
|
-
<div className="st-dropdown" role="menu" aria-label=
|
|
2206
|
+
<div className="st-dropdown" role="menu" aria-label={label} style={{ left }}>
|
|
2207
|
+
{header ? <div className="st-dd-hd">{header}</div> : null}
|
|
1866
2208
|
{items.map((it, i) =>
|
|
1867
2209
|
it.sep ? (
|
|
1868
2210
|
<div key={'s' + i} className="st-dd-sep" />
|
|
@@ -1872,7 +2214,9 @@ function FileDropdown({ onAction, onClose }) {
|
|
|
1872
2214
|
type="button"
|
|
1873
2215
|
role="menuitem"
|
|
1874
2216
|
className="st-dd-item"
|
|
2217
|
+
aria-disabled={it.disabled ? 'true' : undefined}
|
|
1875
2218
|
onClick={() => {
|
|
2219
|
+
if (it.disabled) return;
|
|
1876
2220
|
onAction(it.id);
|
|
1877
2221
|
onClose();
|
|
1878
2222
|
}}
|
|
@@ -1881,7 +2225,7 @@ function FileDropdown({ onAction, onClose }) {
|
|
|
1881
2225
|
<span className="st-dd-check" />
|
|
1882
2226
|
<span>{it.label}</span>
|
|
1883
2227
|
</span>
|
|
1884
|
-
<Kbd>{it.shortcut}</Kbd>
|
|
2228
|
+
{it.shortcut ? <Kbd>{it.shortcut}</Kbd> : null}
|
|
1885
2229
|
</button>
|
|
1886
2230
|
)
|
|
1887
2231
|
)}
|
|
@@ -1889,40 +2233,107 @@ function FileDropdown({ onAction, onClose }) {
|
|
|
1889
2233
|
);
|
|
1890
2234
|
}
|
|
1891
2235
|
|
|
2236
|
+
function HelpDropdown({ onAction, onClose }) {
|
|
2237
|
+
return (
|
|
2238
|
+
<DropdownMenu
|
|
2239
|
+
label="Help"
|
|
2240
|
+
left={320}
|
|
2241
|
+
onAction={onAction}
|
|
2242
|
+
onClose={onClose}
|
|
2243
|
+
items={[
|
|
2244
|
+
{ id: 'shortcuts', label: 'Keyboard shortcuts', shortcut: '?' },
|
|
2245
|
+
{ id: 'help', label: 'Help · commands & flows', shortcut: 'F1' },
|
|
2246
|
+
{ sep: true },
|
|
2247
|
+
{ id: 'tour', label: 'Take the tour' },
|
|
2248
|
+
// The collab "how sharing works" course teaches the plain-words Save →
|
|
2249
|
+
// Publish → Pull cycle — a non-technical, native-app concern. A web-studio
|
|
2250
|
+
// dev already knows git, so it's hidden there (DDR-119).
|
|
2251
|
+
...(isNativeApp() ? [{ id: 'collab-tour', label: 'How sharing works' }] : []),
|
|
2252
|
+
{ id: 'whatsnew', label: "What's new" },
|
|
2253
|
+
]}
|
|
2254
|
+
/>
|
|
2255
|
+
);
|
|
2256
|
+
}
|
|
2257
|
+
|
|
2258
|
+
function SelectionDropdown({ onAction, onClose }) {
|
|
2259
|
+
return (
|
|
2260
|
+
<DropdownMenu
|
|
2261
|
+
label="Selection"
|
|
2262
|
+
left={214}
|
|
2263
|
+
onAction={onAction}
|
|
2264
|
+
onClose={onClose}
|
|
2265
|
+
items={[
|
|
2266
|
+
{ id: 'deselect-all', label: 'Deselect all', shortcut: 'Esc' },
|
|
2267
|
+
{ id: 'select-all-annotations', label: 'Select all annotations', shortcut: '⌘ ⇧ A' },
|
|
2268
|
+
]}
|
|
2269
|
+
/>
|
|
2270
|
+
);
|
|
2271
|
+
}
|
|
2272
|
+
|
|
2273
|
+
function ToolsDropdown({ onAction, onClose }) {
|
|
2274
|
+
// Mirrors DEFAULT_TOOLS in apps/studio/use-tool-mode.tsx — kept in sync by
|
|
2275
|
+
// hand because the menubar lives in the dev-server shell (no shared bundle
|
|
2276
|
+
// with the canvas iframes).
|
|
2277
|
+
return (
|
|
2278
|
+
<DropdownMenu
|
|
2279
|
+
label="Tools"
|
|
2280
|
+
left={290}
|
|
2281
|
+
header="Tool palette"
|
|
2282
|
+
onAction={onAction}
|
|
2283
|
+
onClose={onClose}
|
|
2284
|
+
items={[
|
|
2285
|
+
{ id: 'move', label: 'Move', shortcut: 'V' },
|
|
2286
|
+
{ id: 'hand', label: 'Hand', shortcut: 'H' },
|
|
2287
|
+
{ id: 'comment', label: 'Comment', shortcut: 'C' },
|
|
2288
|
+
{ id: 'pen', label: 'Pen', shortcut: 'B' },
|
|
2289
|
+
{ id: 'rect', label: 'Rect', shortcut: 'R' },
|
|
2290
|
+
{ id: 'ellipse', label: 'Ellipse', shortcut: 'O' },
|
|
2291
|
+
{ id: 'sticky', label: 'Sticky', shortcut: 'N' },
|
|
2292
|
+
{ id: 'arrow', label: 'Arrow', shortcut: 'A' },
|
|
2293
|
+
{ id: 'text', label: 'Text', shortcut: 'T' },
|
|
2294
|
+
{ id: 'eraser', label: 'Eraser', shortcut: 'E' },
|
|
2295
|
+
]}
|
|
2296
|
+
/>
|
|
2297
|
+
);
|
|
2298
|
+
}
|
|
2299
|
+
|
|
2300
|
+
// Plan C follow-up — File + Edit menus, previously inert. Both dispatch to real
|
|
2301
|
+
// shell flows (File) or the in-canvas undo stack / selection bridges (Edit).
|
|
2302
|
+
function FileDropdown({ onAction, onClose, hasCanvas }) {
|
|
2303
|
+
return (
|
|
2304
|
+
<DropdownMenu
|
|
2305
|
+
label="File"
|
|
2306
|
+
left={40}
|
|
2307
|
+
onAction={onAction}
|
|
2308
|
+
onClose={onClose}
|
|
2309
|
+
items={[
|
|
2310
|
+
// Bare N — the browser reserves ⌘N (New Window) and never delivers it.
|
|
2311
|
+
{ id: 'new', label: 'New canvas…', shortcut: 'N' },
|
|
2312
|
+
{ id: 'export', label: 'Export…', shortcut: '⇧⌘E' },
|
|
2313
|
+
{ id: 'handoff', label: 'Handoff to production', shortcut: '⇧⌘H' },
|
|
2314
|
+
{ sep: true },
|
|
2315
|
+
{ id: 'reload', label: 'Reload canvas', shortcut: '⌘R', disabled: !hasCanvas },
|
|
2316
|
+
{ id: 'close', label: 'Close canvas', disabled: !hasCanvas },
|
|
2317
|
+
]}
|
|
2318
|
+
/>
|
|
2319
|
+
);
|
|
2320
|
+
}
|
|
2321
|
+
|
|
1892
2322
|
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
2323
|
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>
|
|
2324
|
+
<DropdownMenu
|
|
2325
|
+
label="Edit"
|
|
2326
|
+
left={90}
|
|
2327
|
+
onAction={onAction}
|
|
2328
|
+
onClose={onClose}
|
|
2329
|
+
items={[
|
|
2330
|
+
{ id: 'undo', label: 'Undo', shortcut: '⌘Z' },
|
|
2331
|
+
{ id: 'redo', label: 'Redo', shortcut: '⇧⌘Z' },
|
|
2332
|
+
{ sep: true },
|
|
2333
|
+
{ id: 'deselect-all', label: 'Deselect all', shortcut: 'Esc' },
|
|
2334
|
+
{ id: 'select-all-annotations', label: 'Select all annotations', shortcut: '⇧⌘A' },
|
|
2335
|
+
]}
|
|
2336
|
+
/>
|
|
1926
2337
|
);
|
|
1927
2338
|
}
|
|
1928
2339
|
|
|
@@ -1934,24 +2345,43 @@ function Menubar({
|
|
|
1934
2345
|
setOpenMenu,
|
|
1935
2346
|
commentsPanelOpen,
|
|
1936
2347
|
onToggleComments,
|
|
2348
|
+
changesOpen,
|
|
2349
|
+
changesCount,
|
|
2350
|
+
onToggleChanges,
|
|
1937
2351
|
onOpenSystem,
|
|
1938
2352
|
sidebarOpen,
|
|
1939
2353
|
onToggleSidebar,
|
|
1940
2354
|
showHidden,
|
|
1941
2355
|
onToggleShowHidden,
|
|
1942
2356
|
onOpenHelp,
|
|
2357
|
+
onOpenShortcuts,
|
|
2358
|
+
onStartTour,
|
|
2359
|
+
onStartCollabTour,
|
|
1943
2360
|
annotationsVisible,
|
|
1944
2361
|
onToggleAnnotations,
|
|
2362
|
+
minimapVisible,
|
|
2363
|
+
onToggleMinimap,
|
|
2364
|
+
zoomCtlVisible,
|
|
2365
|
+
onToggleZoomCtl,
|
|
2366
|
+
presentMode,
|
|
2367
|
+
onTogglePresent,
|
|
1945
2368
|
postToActiveCanvas,
|
|
1946
2369
|
onOpenWhatsNew,
|
|
1947
2370
|
whatsNewCount,
|
|
1948
2371
|
artboardCount = 0,
|
|
1949
2372
|
presence = null,
|
|
1950
2373
|
inspectorOpen,
|
|
2374
|
+
inspectorTab,
|
|
1951
2375
|
onToggleInspector,
|
|
2376
|
+
onOpenLayers,
|
|
2377
|
+
assistantOpen,
|
|
2378
|
+
onToggleAssistant,
|
|
2379
|
+
assistantBusy,
|
|
2380
|
+
assistantUnseen,
|
|
1952
2381
|
onNewCanvas,
|
|
1953
2382
|
onOpenExport,
|
|
1954
2383
|
onReload,
|
|
2384
|
+
onCloseCanvas,
|
|
1955
2385
|
}) {
|
|
1956
2386
|
const isSystem = activePath === SYSTEM_TAB;
|
|
1957
2387
|
const stamp = isSystem ? 'SYSTEM' : activePath ? 'CANVAS' : 'IDLE';
|
|
@@ -1967,6 +2397,13 @@ function Menubar({
|
|
|
1967
2397
|
|
|
1968
2398
|
const panels = [
|
|
1969
2399
|
{ id: 'tree', label: 'Project Tree', shortcut: 'T', checked: sidebarOpen, disabled: false },
|
|
2400
|
+
{
|
|
2401
|
+
id: 'changes',
|
|
2402
|
+
label: changesCount > 0 ? `Changes · ${changesCount} unsaved` : 'Changes',
|
|
2403
|
+
shortcut: '⌘ ⇧ G',
|
|
2404
|
+
checked: changesOpen,
|
|
2405
|
+
disabled: false,
|
|
2406
|
+
},
|
|
1970
2407
|
{
|
|
1971
2408
|
id: 'comments',
|
|
1972
2409
|
label: 'Comments Sidebar',
|
|
@@ -1981,8 +2418,32 @@ function Menubar({
|
|
|
1981
2418
|
checked: showHidden,
|
|
1982
2419
|
disabled: false,
|
|
1983
2420
|
},
|
|
1984
|
-
{
|
|
1985
|
-
|
|
2421
|
+
{
|
|
2422
|
+
id: 'layers',
|
|
2423
|
+
label: 'Layers',
|
|
2424
|
+
shortcut: '',
|
|
2425
|
+
checked: inspectorOpen && inspectorTab === 'layers',
|
|
2426
|
+
disabled: false,
|
|
2427
|
+
},
|
|
2428
|
+
{
|
|
2429
|
+
id: 'inspector',
|
|
2430
|
+
label: 'Inspector',
|
|
2431
|
+
shortcut: '⌘ ⇧ I',
|
|
2432
|
+
checked: inspectorOpen,
|
|
2433
|
+
disabled: false,
|
|
2434
|
+
},
|
|
2435
|
+
// Phase 31 (DDR-123) — native-only ACP chat sidepanel.
|
|
2436
|
+
...(isNativeApp()
|
|
2437
|
+
? [
|
|
2438
|
+
{
|
|
2439
|
+
id: 'assistant',
|
|
2440
|
+
label: 'Assistant',
|
|
2441
|
+
shortcut: '⌘ ⇧ A',
|
|
2442
|
+
checked: assistantOpen,
|
|
2443
|
+
disabled: false,
|
|
2444
|
+
},
|
|
2445
|
+
]
|
|
2446
|
+
: []),
|
|
1986
2447
|
{
|
|
1987
2448
|
id: 'annotate',
|
|
1988
2449
|
label: 'Annotations',
|
|
@@ -1990,28 +2451,90 @@ function Menubar({
|
|
|
1990
2451
|
checked: annotationsVisible,
|
|
1991
2452
|
disabled: false,
|
|
1992
2453
|
},
|
|
1993
|
-
{
|
|
2454
|
+
{
|
|
2455
|
+
id: 'minimap',
|
|
2456
|
+
label: 'Minimap',
|
|
2457
|
+
shortcut: '',
|
|
2458
|
+
checked: minimapVisible,
|
|
2459
|
+
disabled: !activePath || isSystem,
|
|
2460
|
+
},
|
|
2461
|
+
{
|
|
2462
|
+
id: 'zoomctl',
|
|
2463
|
+
label: 'Zoom controls',
|
|
2464
|
+
shortcut: '',
|
|
2465
|
+
checked: zoomCtlVisible,
|
|
2466
|
+
disabled: !activePath || isSystem,
|
|
2467
|
+
},
|
|
2468
|
+
{
|
|
2469
|
+
id: 'present',
|
|
2470
|
+
label: 'Presentation Mode',
|
|
2471
|
+
shortcut: '',
|
|
2472
|
+
checked: presentMode,
|
|
2473
|
+
disabled: !activePath || isSystem,
|
|
2474
|
+
},
|
|
1994
2475
|
];
|
|
1995
2476
|
|
|
1996
|
-
const DROPDOWN_MENUS = ['file', 'edit', 'view', 'selection', 'tools'];
|
|
2477
|
+
const DROPDOWN_MENUS = ['file', 'edit', 'view', 'selection', 'tools', 'help'];
|
|
1997
2478
|
function onMenuClick(key) {
|
|
1998
2479
|
if (DROPDOWN_MENUS.includes(key)) {
|
|
1999
2480
|
setOpenMenu(openMenu === key ? null : key);
|
|
2000
|
-
} else if (key === 'help') {
|
|
2001
|
-
setOpenMenu(null);
|
|
2002
|
-
onOpenHelp();
|
|
2003
2481
|
}
|
|
2004
2482
|
}
|
|
2005
2483
|
|
|
2484
|
+
// Keyboard menubar (native-menu parity): while a dropdown is open, ↑/↓ rove
|
|
2485
|
+
// its items, ←/→ switch to the adjacent menu, Home/End jump, Esc returns
|
|
2486
|
+
// focus to the trigger (useDropdownClose handles the close itself).
|
|
2487
|
+
useEffect(() => {
|
|
2488
|
+
if (!openMenu || !DROPDOWN_MENUS.includes(openMenu)) return;
|
|
2489
|
+
// Move focus into the menu so ↑/↓ work immediately after a click.
|
|
2490
|
+
const t = setTimeout(() => {
|
|
2491
|
+
document
|
|
2492
|
+
.querySelector('.st-dropdown [role="menuitem"]:not([aria-disabled="true"])')
|
|
2493
|
+
?.focus();
|
|
2494
|
+
}, 0);
|
|
2495
|
+
function onKey(e) {
|
|
2496
|
+
const items = [
|
|
2497
|
+
...document.querySelectorAll('.st-dropdown [role="menuitem"]:not([aria-disabled="true"])'),
|
|
2498
|
+
];
|
|
2499
|
+
if (!items.length) return;
|
|
2500
|
+
const idx = items.indexOf(document.activeElement);
|
|
2501
|
+
if (e.key === 'ArrowDown') {
|
|
2502
|
+
e.preventDefault();
|
|
2503
|
+
items[(idx + 1) % items.length].focus();
|
|
2504
|
+
} else if (e.key === 'ArrowUp') {
|
|
2505
|
+
e.preventDefault();
|
|
2506
|
+
items[(idx - 1 + items.length) % items.length].focus();
|
|
2507
|
+
} else if (e.key === 'Home') {
|
|
2508
|
+
e.preventDefault();
|
|
2509
|
+
items[0].focus();
|
|
2510
|
+
} else if (e.key === 'End') {
|
|
2511
|
+
e.preventDefault();
|
|
2512
|
+
items[items.length - 1].focus();
|
|
2513
|
+
} else if (e.key === 'ArrowRight' || e.key === 'ArrowLeft') {
|
|
2514
|
+
e.preventDefault();
|
|
2515
|
+
const dir = e.key === 'ArrowRight' ? 1 : -1;
|
|
2516
|
+
const cur = DROPDOWN_MENUS.indexOf(openMenu);
|
|
2517
|
+
setOpenMenu(DROPDOWN_MENUS[(cur + dir + DROPDOWN_MENUS.length) % DROPDOWN_MENUS.length]);
|
|
2518
|
+
} else if (e.key === 'Escape') {
|
|
2519
|
+
document.querySelector('.st-menu[aria-expanded="true"]')?.focus();
|
|
2520
|
+
}
|
|
2521
|
+
}
|
|
2522
|
+
window.addEventListener('keydown', onKey);
|
|
2523
|
+
return () => {
|
|
2524
|
+
clearTimeout(t);
|
|
2525
|
+
window.removeEventListener('keydown', onKey);
|
|
2526
|
+
};
|
|
2527
|
+
}, [openMenu, setOpenMenu]);
|
|
2528
|
+
|
|
2006
2529
|
return (
|
|
2007
2530
|
<header className="st-menubar" role="menubar" aria-label="Application menubar">
|
|
2008
|
-
<span className="st-brand">
|
|
2531
|
+
<span className="st-brand" data-tour="brand">
|
|
2009
2532
|
<span className="st-brand-mark">
|
|
2010
2533
|
<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
2534
|
</span>
|
|
2012
2535
|
<span className="st-brand-name">maude</span>
|
|
2013
2536
|
</span>
|
|
2014
|
-
<nav className="st-menus" aria-label="Application menus">
|
|
2537
|
+
<nav className="st-menus" aria-label="Application menus" data-tour="menus">
|
|
2015
2538
|
{MENU_NAMES.map((name) => {
|
|
2016
2539
|
const key = name.toLowerCase();
|
|
2017
2540
|
const hasDropdown = DROPDOWN_MENUS.includes(key);
|
|
@@ -2040,11 +2563,13 @@ function Menubar({
|
|
|
2040
2563
|
</nav>
|
|
2041
2564
|
{openMenu === 'file' && (
|
|
2042
2565
|
<FileDropdown
|
|
2566
|
+
hasCanvas={!!activePath}
|
|
2043
2567
|
onAction={(id) => {
|
|
2044
2568
|
if (id === 'new') onNewCanvas?.();
|
|
2045
2569
|
else if (id === 'export') onOpenExport?.('export');
|
|
2046
2570
|
else if (id === 'handoff') onOpenExport?.('handoff');
|
|
2047
2571
|
else if (id === 'reload') onReload?.();
|
|
2572
|
+
else if (id === 'close') onCloseCanvas?.();
|
|
2048
2573
|
}}
|
|
2049
2574
|
onClose={() => setOpenMenu(null)}
|
|
2050
2575
|
/>
|
|
@@ -2066,11 +2591,19 @@ function Menubar({
|
|
|
2066
2591
|
panels={panels}
|
|
2067
2592
|
onToggle={(id) => {
|
|
2068
2593
|
if (id === 'tree') onToggleSidebar();
|
|
2594
|
+
else if (id === 'changes') onToggleChanges();
|
|
2069
2595
|
else if (id === 'comments') onToggleComments();
|
|
2070
2596
|
else if (id === 'hidden') onToggleShowHidden();
|
|
2071
2597
|
else if (id === 'annotate') onToggleAnnotations();
|
|
2072
2598
|
else if (id === 'inspector') onToggleInspector();
|
|
2599
|
+
else if (id === 'assistant') onToggleAssistant?.();
|
|
2600
|
+
else if (id === 'layers') onOpenLayers?.();
|
|
2601
|
+
else if (id === 'minimap') onToggleMinimap?.();
|
|
2602
|
+
else if (id === 'zoomctl') onToggleZoomCtl?.();
|
|
2603
|
+
else if (id === 'present') onTogglePresent?.();
|
|
2073
2604
|
}}
|
|
2605
|
+
onZoom={(op) => postToActiveCanvas({ dgn: 'zoom', op })}
|
|
2606
|
+
hasCanvas={!!activePath && !isSystem}
|
|
2074
2607
|
onClose={() => setOpenMenu(null)}
|
|
2075
2608
|
/>
|
|
2076
2609
|
)}
|
|
@@ -2090,24 +2623,51 @@ function Menubar({
|
|
|
2090
2623
|
onClose={() => setOpenMenu(null)}
|
|
2091
2624
|
/>
|
|
2092
2625
|
)}
|
|
2093
|
-
|
|
2626
|
+
{openMenu === 'help' && (
|
|
2627
|
+
<HelpDropdown
|
|
2628
|
+
onAction={(id) => {
|
|
2629
|
+
if (id === 'shortcuts') onOpenShortcuts?.();
|
|
2630
|
+
else if (id === 'help') onOpenHelp?.();
|
|
2631
|
+
else if (id === 'tour') onStartTour?.();
|
|
2632
|
+
else if (id === 'collab-tour') onStartCollabTour?.();
|
|
2633
|
+
else if (id === 'whatsnew') onOpenWhatsNew?.();
|
|
2634
|
+
}}
|
|
2635
|
+
onClose={() => setOpenMenu(null)}
|
|
2636
|
+
/>
|
|
2637
|
+
)}
|
|
2638
|
+
<div className="st-mb-right" data-tour="status">
|
|
2094
2639
|
{presence ? <div className="st-presence">{presence}</div> : null}
|
|
2095
|
-
|
|
2096
|
-
|
|
2640
|
+
{isNativeApp() && (
|
|
2641
|
+
<button
|
|
2642
|
+
type="button"
|
|
2643
|
+
className="st-assistant"
|
|
2644
|
+
data-active={assistantOpen ? 'true' : 'false'}
|
|
2645
|
+
data-busy={assistantBusy ? 'true' : 'false'}
|
|
2646
|
+
data-unseen={assistantUnseen ? 'true' : 'false'}
|
|
2647
|
+
aria-label={`Assistant${assistantBusy ? ' — working' : assistantUnseen ? ' — new reply' : ''}`}
|
|
2648
|
+
data-tip="Assistant ⌘⇧A"
|
|
2649
|
+
onClick={onToggleAssistant}
|
|
2650
|
+
>
|
|
2651
|
+
<StIcon name="sparkle" size={15} />
|
|
2652
|
+
</button>
|
|
2653
|
+
)}
|
|
2654
|
+
<button
|
|
2655
|
+
type="button"
|
|
2097
2656
|
className="st-whatsnew"
|
|
2657
|
+
data-tour="whatsnew"
|
|
2098
2658
|
data-unseen={whatsNewCount > 0 ? 'true' : 'false'}
|
|
2099
2659
|
aria-label={`What's new${whatsNewCount > 0 ? ` — ${whatsNewCount} unseen` : ''}`}
|
|
2100
|
-
|
|
2660
|
+
data-tip="What's new"
|
|
2101
2661
|
onClick={onOpenWhatsNew}
|
|
2102
2662
|
>
|
|
2103
|
-
<StIcon name="
|
|
2663
|
+
<StIcon name="megaphone" size={15} />
|
|
2104
2664
|
</button>
|
|
2105
2665
|
<span className="st-stamp">{stamp}</span>
|
|
2106
2666
|
<span className="st-mb-file" title={activePath || ''}>
|
|
2107
2667
|
{fileLabel}
|
|
2108
2668
|
</span>
|
|
2109
2669
|
<span className="st-mb-sep" />
|
|
2110
|
-
<span className="st-mb-count"
|
|
2670
|
+
<span className="st-mb-count" data-tip="Artboards in the open canvas">
|
|
2111
2671
|
<span className="st-dot" style={{ background: 'var(--accent)' }} />
|
|
2112
2672
|
{artboardCount} ARTBOARDS
|
|
2113
2673
|
</span>
|
|
@@ -2127,9 +2687,11 @@ function Viewport({
|
|
|
2127
2687
|
onSelectDs,
|
|
2128
2688
|
project,
|
|
2129
2689
|
cfg,
|
|
2690
|
+
loadingPath,
|
|
2691
|
+
onIframeLoad,
|
|
2130
2692
|
}) {
|
|
2131
2693
|
return (
|
|
2132
|
-
<div className="viewport st-stage">
|
|
2694
|
+
<div className="viewport st-stage" data-tour="viewport">
|
|
2133
2695
|
{tabs.length === 0 && (
|
|
2134
2696
|
<div className="st-empty">
|
|
2135
2697
|
<div className="st-empty-brand">
|
|
@@ -2148,8 +2710,8 @@ function Viewport({
|
|
|
2148
2710
|
<strong>Design system</strong> view above it.
|
|
2149
2711
|
<br />
|
|
2150
2712
|
<br />
|
|
2151
|
-
|
|
2152
|
-
|
|
2713
|
+
Opening a file replaces the active canvas. <Kbd>⌘R</Kbd> reloads it; File ▸ Close
|
|
2714
|
+
canvas clears the stage.
|
|
2153
2715
|
<br />
|
|
2154
2716
|
<br />
|
|
2155
2717
|
<strong>Element selection:</strong> hold <Kbd>⌘</Kbd> inside the canvas and hover for a
|
|
@@ -2182,6 +2744,7 @@ function Viewport({
|
|
|
2182
2744
|
src={canvasUrl(t.path, cfg)}
|
|
2183
2745
|
className={t.path === activePath ? 'active' : ''}
|
|
2184
2746
|
data-path={t.path}
|
|
2747
|
+
onLoad={() => onIframeLoad?.(t.path)}
|
|
2185
2748
|
// T2 (9.1-A) — only sandbox + delegate clipboard when the canvas is
|
|
2186
2749
|
// served cross-origin (canvasOrigin present = the split is on). In
|
|
2187
2750
|
// the default same-origin mode these attrs are omitted so behavior
|
|
@@ -2193,6 +2756,19 @@ function Viewport({
|
|
|
2193
2756
|
/>
|
|
2194
2757
|
);
|
|
2195
2758
|
})}
|
|
2759
|
+
{loadingPath && loadingPath === activePath && (
|
|
2760
|
+
// DS skeletons recipe — calm .skel pulse while the canvas-shell compiles
|
|
2761
|
+
// the TSX. Cleared by the iframe's dgn:'loaded' message (or the onLoad
|
|
2762
|
+
// fallback timer for legacy .html canvases that never post it).
|
|
2763
|
+
<div className="st-canvas-loading" aria-hidden="true">
|
|
2764
|
+
<div className="st-skel-card">
|
|
2765
|
+
<div className="st-skel-cap st-mono">compiling canvas…</div>
|
|
2766
|
+
<span className="skel st-skel-thumb" />
|
|
2767
|
+
<span className="skel st-skel-line" style={{ width: '72%' }} />
|
|
2768
|
+
<span className="skel st-skel-line" style={{ width: '46%' }} />
|
|
2769
|
+
</div>
|
|
2770
|
+
</div>
|
|
2771
|
+
)}
|
|
2196
2772
|
</div>
|
|
2197
2773
|
);
|
|
2198
2774
|
}
|
|
@@ -2372,7 +2948,7 @@ function SystemView({ data, onOpen, cfg, onSelectDs }) {
|
|
|
2372
2948
|
return (
|
|
2373
2949
|
<div className="sv">
|
|
2374
2950
|
<header className="sv-header">
|
|
2375
|
-
<span className="sv-sku">
|
|
2951
|
+
<span className="sv-sku">MAUDE-DSN/01</span>
|
|
2376
2952
|
<span className="sv-title">design system view</span>
|
|
2377
2953
|
{hasPicker ? (
|
|
2378
2954
|
<label className="sv-ds-picker">
|
|
@@ -2459,6 +3035,10 @@ function StatusBar({
|
|
|
2459
3035
|
onToggleTheme,
|
|
2460
3036
|
onClearSelected,
|
|
2461
3037
|
syncStatus,
|
|
3038
|
+
changesCount = 0,
|
|
3039
|
+
unpushed = 0,
|
|
3040
|
+
changesOpen = false,
|
|
3041
|
+
onOpenChanges,
|
|
2462
3042
|
}) {
|
|
2463
3043
|
const isSystem = activePath === SYSTEM_TAB;
|
|
2464
3044
|
const text =
|
|
@@ -2516,7 +3096,9 @@ function StatusBar({
|
|
|
2516
3096
|
</span>
|
|
2517
3097
|
</span>
|
|
2518
3098
|
|
|
2519
|
-
{
|
|
3099
|
+
{/* Selection is meaningless without an open canvas — a stale _active.json
|
|
3100
|
+
selection used to leave a ghost SELECTED chip on the empty shell. */}
|
|
3101
|
+
{activePath && selected && selected.selector && !isSystem && (
|
|
2520
3102
|
<span className="st-sb-slot st-sb-sel" role="group" aria-label="Selected element">
|
|
2521
3103
|
<span className="lbl">selected</span>
|
|
2522
3104
|
<span className="val" title={title}>
|
|
@@ -2526,7 +3108,8 @@ function StatusBar({
|
|
|
2526
3108
|
type="button"
|
|
2527
3109
|
className="st-sb-sel-clear"
|
|
2528
3110
|
onClick={onClearSelected}
|
|
2529
|
-
|
|
3111
|
+
data-tip="Clear · Esc inside iframe"
|
|
3112
|
+
data-tip-pos="top"
|
|
2530
3113
|
aria-label="Clear selection"
|
|
2531
3114
|
>
|
|
2532
3115
|
×
|
|
@@ -2539,6 +3122,33 @@ function StatusBar({
|
|
|
2539
3122
|
<span className="val">{openCount} open</span>
|
|
2540
3123
|
</span>
|
|
2541
3124
|
|
|
3125
|
+
{/* Phase 28 — changes count, click to open the Changes panel (⌘⇧G). */}
|
|
3126
|
+
{onOpenChanges && (
|
|
3127
|
+
<button
|
|
3128
|
+
type="button"
|
|
3129
|
+
className={
|
|
3130
|
+
'st-sb-slot st-sb-changes' +
|
|
3131
|
+
(changesOpen ? ' is-open' : '') +
|
|
3132
|
+
(changesCount > 0 ? ' has-changes' : unpushed > 0 ? ' has-unpushed' : '')
|
|
3133
|
+
}
|
|
3134
|
+
onClick={onOpenChanges}
|
|
3135
|
+
data-tip="Open Changes · ⌘⇧G"
|
|
3136
|
+
data-tip-pos="top"
|
|
3137
|
+
aria-label="Open Changes panel"
|
|
3138
|
+
aria-pressed={changesOpen}
|
|
3139
|
+
>
|
|
3140
|
+
<span className="st-sb-changes-dot" aria-hidden="true" />
|
|
3141
|
+
<span className="lbl">changes</span>
|
|
3142
|
+
<span className="val">
|
|
3143
|
+
{changesCount > 0
|
|
3144
|
+
? `${changesCount} unsaved`
|
|
3145
|
+
: unpushed > 0
|
|
3146
|
+
? `${unpushed} to publish`
|
|
3147
|
+
: 'all saved'}
|
|
3148
|
+
</span>
|
|
3149
|
+
</button>
|
|
3150
|
+
)}
|
|
3151
|
+
|
|
2542
3152
|
<span className="st-sb-spacer" />
|
|
2543
3153
|
|
|
2544
3154
|
<span className="st-sb-slot" role="group" aria-label="Connection">
|
|
@@ -2566,7 +3176,8 @@ function StatusBar({
|
|
|
2566
3176
|
type="button"
|
|
2567
3177
|
className="st-sb-theme"
|
|
2568
3178
|
onClick={onToggleTheme}
|
|
2569
|
-
|
|
3179
|
+
data-tip={`Switch to ${nextTheme} theme`}
|
|
3180
|
+
data-tip-pos="top"
|
|
2570
3181
|
aria-label={`Switch to ${nextTheme} theme`}
|
|
2571
3182
|
>
|
|
2572
3183
|
<StIcon name={theme === 'dark' ? 'sun' : 'moon'} size={13} />
|
|
@@ -2588,6 +3199,8 @@ function CommentsPanel({
|
|
|
2588
3199
|
onResolve,
|
|
2589
3200
|
onReopen,
|
|
2590
3201
|
onDelete,
|
|
3202
|
+
width,
|
|
3203
|
+
resizing,
|
|
2591
3204
|
}) {
|
|
2592
3205
|
const counts = totalCounts(commentsByFile);
|
|
2593
3206
|
// Build groups: [{ file, comments: filtered }]
|
|
@@ -2613,7 +3226,11 @@ function CommentsPanel({
|
|
|
2613
3226
|
}
|
|
2614
3227
|
|
|
2615
3228
|
return (
|
|
2616
|
-
<aside
|
|
3229
|
+
<aside
|
|
3230
|
+
className={'st-rpanel' + (resizing ? ' is-resizing' : '')}
|
|
3231
|
+
style={width ? { width, flexBasis: width } : undefined}
|
|
3232
|
+
aria-label="Comments"
|
|
3233
|
+
>
|
|
2617
3234
|
<div className="st-rp-tabs st-rp-tabs--filters">
|
|
2618
3235
|
<div className="st-cm-filters" role="tablist">
|
|
2619
3236
|
<button
|
|
@@ -2740,6 +3357,35 @@ function CommentsPanel({
|
|
|
2740
3357
|
// while offline (with queued-edit count), red when offline > 24h, green flash
|
|
2741
3358
|
// for 3s right after a reconnect. Driven entirely by the 'sync:status' payload
|
|
2742
3359
|
// the dev-server's linked-mode sync runtime broadcasts.
|
|
3360
|
+
// Phase 32 (Task 1) — auto-update notice. Shown after the shell has downloaded +
|
|
3361
|
+
// staged a newer build in the background. Non-blocking: "Restart now" applies it,
|
|
3362
|
+
// "Later" dismisses (the next focus/4h check re-stages and re-surfaces it).
|
|
3363
|
+
function UpdateBanner({ update, onDismiss }) {
|
|
3364
|
+
const [restarting, setRestarting] = useState(false);
|
|
3365
|
+
if (!update) return null;
|
|
3366
|
+
const ver = update.version ? ` (v${update.version})` : '';
|
|
3367
|
+
return (
|
|
3368
|
+
<div role="status" aria-live="polite" className="st-banner st-banner--info">
|
|
3369
|
+
<span className="st-banner-dot" aria-hidden="true" />
|
|
3370
|
+
<span>Maude updated{ver} · restart to apply</span>
|
|
3371
|
+
<button
|
|
3372
|
+
type="button"
|
|
3373
|
+
className="btn btn--primary btn--sm"
|
|
3374
|
+
disabled={restarting}
|
|
3375
|
+
onClick={() => {
|
|
3376
|
+
setRestarting(true);
|
|
3377
|
+
restartToUpdate().catch(() => setRestarting(false));
|
|
3378
|
+
}}
|
|
3379
|
+
>
|
|
3380
|
+
{restarting ? 'Restarting…' : 'Restart now'}
|
|
3381
|
+
</button>
|
|
3382
|
+
<button type="button" className="btn btn--ghost btn--sm" onClick={onDismiss}>
|
|
3383
|
+
Later
|
|
3384
|
+
</button>
|
|
3385
|
+
</div>
|
|
3386
|
+
);
|
|
3387
|
+
}
|
|
3388
|
+
|
|
2743
3389
|
function SyncBanner({ status }) {
|
|
2744
3390
|
// Plan C follow-up — the banner overlapped the menubar and wasn't dismissable.
|
|
2745
3391
|
// Dismissal is keyed on the connection state, so a transition (reconnect flash,
|
|
@@ -2748,37 +3394,66 @@ function SyncBanner({ status }) {
|
|
|
2748
3394
|
if (!status || status.linked === false) return null;
|
|
2749
3395
|
// DDR-060 / 9.1-D — the "linked but 0 syncable" state is surfaced in the
|
|
2750
3396
|
// status bar (sb-sync slot), NOT as a floating banner. This component owns
|
|
2751
|
-
//
|
|
3397
|
+
// the transient offline / reconnect-flash banner (Task 8) plus the DDR-102
|
|
3398
|
+
// rejected-docs chip and divergence-resolution toast.
|
|
2752
3399
|
if (status.notSyncable) return null;
|
|
2753
3400
|
const { state, queuedOps, flash, conflicts } = status;
|
|
2754
3401
|
const showFlash = flash === 'synced';
|
|
2755
3402
|
const offline = state === 'offline' || state === 'offline-long';
|
|
2756
|
-
|
|
2757
|
-
|
|
2758
|
-
|
|
2759
|
-
|
|
3403
|
+
// DDR-102 — per-doc rollup + the latest divergence notice (additive fields;
|
|
3404
|
+
// an old payload without them renders exactly the pre-DDR-102 banner).
|
|
3405
|
+
const rejected = status.docs?.rejected ?? 0;
|
|
3406
|
+
const lastDiverged = Array.isArray(conflicts)
|
|
3407
|
+
? [...conflicts].reverse().find((c) => c.kind === 'cold-start-diverged')
|
|
3408
|
+
: null;
|
|
3409
|
+
if (!offline && !showFlash && !lastDiverged && rejected === 0) return null;
|
|
3410
|
+
|
|
3411
|
+
// One banner at a time — priority: reconnect flash > offline > divergence
|
|
3412
|
+
// toast > rejected chip. Dismissal is keyed per state so a new event
|
|
3413
|
+
// (another conflict, a changed rejected count) re-surfaces it.
|
|
2760
3414
|
let variant;
|
|
2761
3415
|
let text;
|
|
3416
|
+
let dismissKey;
|
|
2762
3417
|
if (showFlash) {
|
|
2763
3418
|
variant = 'success';
|
|
2764
3419
|
text = 'Synced with hub';
|
|
2765
|
-
|
|
2766
|
-
|
|
2767
|
-
|
|
3420
|
+
dismissKey = `${state}:flash`;
|
|
3421
|
+
} else if (offline) {
|
|
3422
|
+
const conflictNote =
|
|
3423
|
+
conflicts && conflicts.length > 0 ? ` (${conflicts.length} conflict notice(s))` : '';
|
|
3424
|
+
if (state === 'offline-long') {
|
|
3425
|
+
variant = 'error';
|
|
3426
|
+
text = `Long offline — ${queuedOps} edit(s) queued. Consider \`git commit && git push\` as backup.${conflictNote}`;
|
|
3427
|
+
} else {
|
|
3428
|
+
variant = 'warn';
|
|
3429
|
+
text = `Working offline · ${queuedOps} edit(s) queued · will sync when the hub reconnects.${conflictNote}`;
|
|
3430
|
+
}
|
|
3431
|
+
dismissKey = `${state}:offline`;
|
|
3432
|
+
} else if (lastDiverged) {
|
|
3433
|
+
// DDR-102 fail-closed: a snapshotFailed conflict means the hub-wins overwrite
|
|
3434
|
+
// was REFUSED (local kept) because _history couldn't be written — surface it
|
|
3435
|
+
// as an error, not a routine "kept newest" notice.
|
|
3436
|
+
if (lastDiverged.snapshotFailed) {
|
|
3437
|
+
variant = 'error';
|
|
3438
|
+
text = `Diverged on ${lastDiverged.slug}: kept local — the history snapshot FAILED, so the overwrite was refused. Check disk space / .design/_history write access.`;
|
|
3439
|
+
} else {
|
|
3440
|
+
variant = 'warn';
|
|
3441
|
+
text = `Diverged on ${lastDiverged.slug}: kept the ${
|
|
3442
|
+
lastDiverged.winner === 'local' ? 'local (newer)' : 'hub'
|
|
3443
|
+
} version — the other is snapshotted in history → /design:rollback ${lastDiverged.slug}`;
|
|
3444
|
+
}
|
|
3445
|
+
dismissKey = `diverged:${lastDiverged.slug}:${lastDiverged.at}`;
|
|
2768
3446
|
} else {
|
|
2769
3447
|
variant = 'warn';
|
|
2770
|
-
text =
|
|
3448
|
+
text = `${rejected} canvas(es) not syncing — the hub rejected auth. Details: maude design status`;
|
|
3449
|
+
dismissKey = `rejected:${rejected}`;
|
|
2771
3450
|
}
|
|
2772
|
-
|
|
2773
|
-
conflicts && conflicts.length > 0 ? ` (${conflicts.length} conflict notice(s))` : '';
|
|
3451
|
+
if (dismissedKey === dismissKey) return null;
|
|
2774
3452
|
|
|
2775
3453
|
return (
|
|
2776
3454
|
<div role="status" aria-live="polite" className={`st-banner st-banner--${variant}`}>
|
|
2777
3455
|
<span className="st-banner-dot" aria-hidden="true" />
|
|
2778
|
-
<span>
|
|
2779
|
-
{text}
|
|
2780
|
-
{conflictNote}
|
|
2781
|
-
</span>
|
|
3456
|
+
<span>{text}</span>
|
|
2782
3457
|
<button
|
|
2783
3458
|
type="button"
|
|
2784
3459
|
className="st-banner-close"
|
|
@@ -2792,6 +3467,1587 @@ function SyncBanner({ status }) {
|
|
|
2792
3467
|
);
|
|
2793
3468
|
}
|
|
2794
3469
|
|
|
3470
|
+
// ---------- CSS knobs (Phase 12.2, DDR-104) — interactive panel ----------
|
|
3471
|
+
//
|
|
3472
|
+
// Hybrid vocabulary (friendly collapsible section headers + CSS-named rows),
|
|
3473
|
+
// per-field DS-token quick-pick, nested box-model widget, per-corner radius,
|
|
3474
|
+
// per-row provenance (token-bound / raw-override / inherited), per-field save
|
|
3475
|
+
// state, and two escape hatches: custom CSS property (via /_api/edit-css) +
|
|
3476
|
+
// custom HTML attribute (via /_api/edit-attr). Each knob pre-fills from the
|
|
3477
|
+
// AUTHORED inline value (`el.authored`); the resolved `computed` value is a
|
|
3478
|
+
// faint placeholder only (NOT editable — the v1 UX bug). Ported from the
|
|
3479
|
+
// critic-approved + user-iterated `.design/ui/Studio.tsx` spec.
|
|
3480
|
+
|
|
3481
|
+
const CSS_DISPLAYS = ['block', 'inline-block', 'flex', 'inline-flex', 'grid', 'inline', 'none'];
|
|
3482
|
+
const CSS_FLEX_DIR = ['row', 'row-reverse', 'column', 'column-reverse'];
|
|
3483
|
+
const CSS_ALIGN = ['stretch', 'flex-start', 'center', 'flex-end', 'baseline'];
|
|
3484
|
+
const CSS_JUSTIFY = [
|
|
3485
|
+
'flex-start',
|
|
3486
|
+
'center',
|
|
3487
|
+
'flex-end',
|
|
3488
|
+
'space-between',
|
|
3489
|
+
'space-around',
|
|
3490
|
+
'space-evenly',
|
|
3491
|
+
];
|
|
3492
|
+
const CSS_WEIGHTS = ['300', '400', '500', '600', '700', '800'];
|
|
3493
|
+
const CSS_FONTS = [
|
|
3494
|
+
'inherit',
|
|
3495
|
+
'system-ui',
|
|
3496
|
+
'sans-serif',
|
|
3497
|
+
'serif',
|
|
3498
|
+
'monospace',
|
|
3499
|
+
'Inter',
|
|
3500
|
+
'Inter Tight',
|
|
3501
|
+
'JetBrains Mono',
|
|
3502
|
+
];
|
|
3503
|
+
const CSS_BORDER_STYLES = ['none', 'solid', 'dashed', 'dotted', 'double'];
|
|
3504
|
+
const CSS_UNITS = ['px', 'rem', 'em', '%', 'vw', 'vh', 'auto'];
|
|
3505
|
+
// Properties whose bare-number value is unitless — never append a unit suffix.
|
|
3506
|
+
const CSS_UNITLESS = new Set(['line-height', 'opacity', 'font-weight', 'z-index', 'flex-grow', 'flex-shrink', 'order']);
|
|
3507
|
+
// #2 — Figma-style property prefix inside numeric fields: a small glyph (icon) or
|
|
3508
|
+
// a mono letter (t). Only where it reads cleanly; selects/colours keep their own.
|
|
3509
|
+
const PROP_LEAD = {
|
|
3510
|
+
'font-size': { icon: 'p-size' },
|
|
3511
|
+
'line-height': { icon: 'p-lineheight' },
|
|
3512
|
+
'letter-spacing': { icon: 'p-letterspacing' },
|
|
3513
|
+
gap: { icon: 'p-gap' },
|
|
3514
|
+
width: { t: 'W' },
|
|
3515
|
+
height: { t: 'H' },
|
|
3516
|
+
'max-width': { t: 'W' },
|
|
3517
|
+
'border-radius': { icon: 'p-corner' },
|
|
3518
|
+
'border-width': { icon: 'p-border' },
|
|
3519
|
+
opacity: { icon: 'p-opacity' },
|
|
3520
|
+
};
|
|
3521
|
+
const CSS_ALIGN_OPTS = ['left', 'center', 'right', 'justify'];
|
|
3522
|
+
|
|
3523
|
+
let _cssColorCtx = null;
|
|
3524
|
+
// Normalize any CSS color string to #rrggbb for the native color input via a
|
|
3525
|
+
// throwaway canvas fillStyle round-trip (parses rgb/hsl/named). Unparseable
|
|
3526
|
+
// values (some oklch) fall back to '' → picker defaults to #000000; the swatch
|
|
3527
|
+
// still shows the true color string regardless.
|
|
3528
|
+
function cssColorToHex(c) {
|
|
3529
|
+
if (!c) return '';
|
|
3530
|
+
if (/^#[0-9a-f]{6}$/i.test(c)) return c.toLowerCase();
|
|
3531
|
+
try {
|
|
3532
|
+
if (!_cssColorCtx) _cssColorCtx = document.createElement('canvas').getContext('2d');
|
|
3533
|
+
if (!_cssColorCtx) return '';
|
|
3534
|
+
_cssColorCtx.fillStyle = '#000000';
|
|
3535
|
+
_cssColorCtx.fillStyle = c;
|
|
3536
|
+
const v = _cssColorCtx.fillStyle;
|
|
3537
|
+
if (/^#[0-9a-f]{6}$/i.test(v)) return v.toLowerCase();
|
|
3538
|
+
const m = v.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)/);
|
|
3539
|
+
if (m) {
|
|
3540
|
+
return `#${[m[1], m[2], m[3]].map((n) => Number(n).toString(16).padStart(2, '0')).join('')}`;
|
|
3541
|
+
}
|
|
3542
|
+
} catch {
|
|
3543
|
+
/* canvas unavailable */
|
|
3544
|
+
}
|
|
3545
|
+
return '';
|
|
3546
|
+
}
|
|
3547
|
+
|
|
3548
|
+
// ---- Colour math for the HSV picker (#6 — Figma-style colour control) ----
|
|
3549
|
+
const clamp01 = (n) => Math.min(1, Math.max(0, n));
|
|
3550
|
+
function hexToRgb(hex) {
|
|
3551
|
+
const m = /^#?([0-9a-f]{6})$/i.exec(hex || '');
|
|
3552
|
+
if (!m) return { r: 0, g: 0, b: 0 };
|
|
3553
|
+
const n = Number.parseInt(m[1], 16);
|
|
3554
|
+
return { r: (n >> 16) & 255, g: (n >> 8) & 255, b: n & 255 };
|
|
3555
|
+
}
|
|
3556
|
+
function rgbToHex({ r, g, b }) {
|
|
3557
|
+
return `#${[r, g, b].map((v) => Math.round(v).toString(16).padStart(2, '0')).join('')}`;
|
|
3558
|
+
}
|
|
3559
|
+
function rgbToHsv({ r, g, b }) {
|
|
3560
|
+
r /= 255;
|
|
3561
|
+
g /= 255;
|
|
3562
|
+
b /= 255;
|
|
3563
|
+
const max = Math.max(r, g, b);
|
|
3564
|
+
const min = Math.min(r, g, b);
|
|
3565
|
+
const d = max - min;
|
|
3566
|
+
let h = 0;
|
|
3567
|
+
if (d) {
|
|
3568
|
+
if (max === r) h = ((g - b) / d) % 6;
|
|
3569
|
+
else if (max === g) h = (b - r) / d + 2;
|
|
3570
|
+
else h = (r - g) / d + 4;
|
|
3571
|
+
h *= 60;
|
|
3572
|
+
if (h < 0) h += 360;
|
|
3573
|
+
}
|
|
3574
|
+
return { h, s: max ? d / max : 0, v: max };
|
|
3575
|
+
}
|
|
3576
|
+
function hsvToRgb({ h, s, v }) {
|
|
3577
|
+
const c = v * s;
|
|
3578
|
+
const x = c * (1 - Math.abs(((h / 60) % 2) - 1));
|
|
3579
|
+
const m = v - c;
|
|
3580
|
+
let r = 0;
|
|
3581
|
+
let g = 0;
|
|
3582
|
+
let b = 0;
|
|
3583
|
+
if (h < 60) [r, g, b] = [c, x, 0];
|
|
3584
|
+
else if (h < 120) [r, g, b] = [x, c, 0];
|
|
3585
|
+
else if (h < 180) [r, g, b] = [0, c, x];
|
|
3586
|
+
else if (h < 240) [r, g, b] = [0, x, c];
|
|
3587
|
+
else if (h < 300) [r, g, b] = [x, 0, c];
|
|
3588
|
+
else [r, g, b] = [c, 0, x];
|
|
3589
|
+
return { r: (r + m) * 255, g: (g + m) * 255, b: (b + m) * 255 };
|
|
3590
|
+
}
|
|
3591
|
+
|
|
3592
|
+
// Round bare px to whole numbers for the placeholder hint; pass other values through.
|
|
3593
|
+
function cssHint(v) {
|
|
3594
|
+
if (!v) return '';
|
|
3595
|
+
const m = /^(-?\d*\.?\d+)px$/.exec(v);
|
|
3596
|
+
return m ? `${Math.round(Number.parseFloat(m[1]))}px` : v;
|
|
3597
|
+
}
|
|
3598
|
+
|
|
3599
|
+
// Split "16px" → { n:"16", unit:"px" }; "auto"→{n:"",unit:"auto"}; var()/raw → {n:raw,unit:""}.
|
|
3600
|
+
function cssSplitUnit(v) {
|
|
3601
|
+
if (!v) return { n: '', unit: 'px' };
|
|
3602
|
+
const t = v.trim();
|
|
3603
|
+
const m = /^(-?\d*\.?\d+)\s*(px|rem|em|%|vw|vh)?$/.exec(t);
|
|
3604
|
+
if (m) return { n: m[1], unit: m[2] || 'px' };
|
|
3605
|
+
if (t === 'auto') return { n: '', unit: 'auto' };
|
|
3606
|
+
return { n: t, unit: '' };
|
|
3607
|
+
}
|
|
3608
|
+
|
|
3609
|
+
// Phase 12.2/12.3 — the WS `selected` echo is the server's projection
|
|
3610
|
+
// (SelectedElement) and LACKS the client-only DOM fields the CSS knobs pre-fill
|
|
3611
|
+
// from (`authored` / `computed` inline style + `customStyles` / `attrs` — all
|
|
3612
|
+
// captured in the iframe, never round-tripped through the server). When the echo
|
|
3613
|
+
// is for the SAME element we already hold locally, preserve those fields instead
|
|
3614
|
+
// of clobbering them to empty (else the server round-trip wipes the custom-CSS /
|
|
3615
|
+
// custom-attr rows + computed readout right after selection).
|
|
3616
|
+
function mergeSelClientFields(incoming, prev) {
|
|
3617
|
+
if (!incoming || Array.isArray(incoming) || Array.isArray(prev) || !prev) return incoming;
|
|
3618
|
+
if (!incoming.id || incoming.id !== prev.id) return incoming;
|
|
3619
|
+
return {
|
|
3620
|
+
...incoming,
|
|
3621
|
+
authored: incoming.authored ?? prev.authored,
|
|
3622
|
+
computed: incoming.computed ?? prev.computed,
|
|
3623
|
+
customStyles: incoming.customStyles ?? prev.customStyles,
|
|
3624
|
+
attrs: incoming.attrs ?? prev.attrs,
|
|
3625
|
+
};
|
|
3626
|
+
}
|
|
3627
|
+
|
|
3628
|
+
// Resolve the active canvas's DS tokens CSS path (mirrors canvas-url.js / DDR-093):
|
|
3629
|
+
// the canvas's declared DS wins, else designSystems[0], else the legacy default.
|
|
3630
|
+
function cssTokensRelFor(file, cfg) {
|
|
3631
|
+
const ds0 = cfg?.designSystems?.[0];
|
|
3632
|
+
const name = file ? cfg?.canvasDesignSystems?.[file] : null;
|
|
3633
|
+
const ds = (name && cfg?.designSystems?.find((d) => d.name === name)) || ds0;
|
|
3634
|
+
return ds?.tokensCssRel || cfg?.tokensCssRel || ds0?.tokensCssRel || '';
|
|
3635
|
+
}
|
|
3636
|
+
|
|
3637
|
+
// The active canvas's DS NAME (mirrors cssTokensRelFor's resolution order).
|
|
3638
|
+
function activeDsNameFor(file, cfg) {
|
|
3639
|
+
const byCanvas = file ? cfg?.canvasDesignSystems?.[file] : null;
|
|
3640
|
+
return byCanvas || cfg?.defaultDesignSystem || cfg?.designSystems?.[0]?.name || null;
|
|
3641
|
+
}
|
|
3642
|
+
|
|
3643
|
+
// Parse a DS tokens CSS body → token names grouped by family + a name→value map
|
|
3644
|
+
// (resolving one level of var() aliasing so the popover renders real swatches +
|
|
3645
|
+
// values). Phase 12.3 (W2.1/W3 multi-DS).
|
|
3646
|
+
function parseTokensCss(css) {
|
|
3647
|
+
const raw = {};
|
|
3648
|
+
for (const m of css.matchAll(/(--[a-z0-9-]+)\s*:\s*([^;}]+)/gi)) {
|
|
3649
|
+
if (!(m[1] in raw)) raw[m[1]] = m[2].trim();
|
|
3650
|
+
}
|
|
3651
|
+
const vals = {};
|
|
3652
|
+
for (const name of Object.keys(raw)) {
|
|
3653
|
+
const v = raw[name];
|
|
3654
|
+
const ref = /^var\(\s*(--[a-z0-9-]+)\s*\)$/i.exec(v);
|
|
3655
|
+
vals[name] = ref && raw[ref[1]] ? raw[ref[1]] : v;
|
|
3656
|
+
}
|
|
3657
|
+
const names = Object.keys(raw);
|
|
3658
|
+
const g = (re) => names.filter((n) => re.test(n));
|
|
3659
|
+
// Colours detected by VALUE, not name — so EVERY colour token a DS defines is
|
|
3660
|
+
// offered (the name-prefix list dropped many). A token is a colour if its
|
|
3661
|
+
// resolved value reads as one. (#3 — "see all tokens the DS has".)
|
|
3662
|
+
const isColor = (v) =>
|
|
3663
|
+
/^(#[0-9a-f]{3,8}|rgba?\(|hsla?\(|oklch\(|oklab\(|lab\(|lch\(|hwb\(|color\()/i.test(v) ||
|
|
3664
|
+
/^(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(
|
|
3665
|
+
v
|
|
3666
|
+
);
|
|
3667
|
+
return {
|
|
3668
|
+
color: names.filter((n) => isColor(vals[n])),
|
|
3669
|
+
space: g(/^--space-/),
|
|
3670
|
+
radius: g(/^--radius-/),
|
|
3671
|
+
type: g(/^--type-/),
|
|
3672
|
+
shadow: g(/^--shadow-/),
|
|
3673
|
+
lh: g(/^--lh-/),
|
|
3674
|
+
vals,
|
|
3675
|
+
};
|
|
3676
|
+
}
|
|
3677
|
+
|
|
3678
|
+
// Fetch + parse the tokens CSS of EVERY design system in the config (main
|
|
3679
|
+
// origin), so the token popover can offer tokens grouped per DS (W3 multi-DS
|
|
3680
|
+
// feedback). The active canvas's DS is ordered first. Returns
|
|
3681
|
+
// `[{ name, color, space, radius, type, shadow, lh, vals }]`.
|
|
3682
|
+
function useAllDsTokens(cfg, designRel, activeName) {
|
|
3683
|
+
const list = cfg?.designSystems || [];
|
|
3684
|
+
// A stable key so the effect only re-fetches when the DS set / paths change.
|
|
3685
|
+
const sig = list.map((d) => `${d.name}:${d.tokensCssRel}`).join('|');
|
|
3686
|
+
const [byDs, setByDs] = useState([]);
|
|
3687
|
+
useEffect(() => {
|
|
3688
|
+
if (!list.length) return undefined;
|
|
3689
|
+
let cancelled = false;
|
|
3690
|
+
Promise.all(
|
|
3691
|
+
list.map(async (ds) => {
|
|
3692
|
+
if (!ds.tokensCssRel) return null;
|
|
3693
|
+
try {
|
|
3694
|
+
const r = await fetch(`/${designRel}/${ds.tokensCssRel}`);
|
|
3695
|
+
const css = r.ok ? await r.text() : '';
|
|
3696
|
+
return { name: ds.name, ...parseTokensCss(css) };
|
|
3697
|
+
} catch {
|
|
3698
|
+
return null;
|
|
3699
|
+
}
|
|
3700
|
+
})
|
|
3701
|
+
).then((res) => {
|
|
3702
|
+
if (cancelled) return;
|
|
3703
|
+
const got = res.filter(Boolean);
|
|
3704
|
+
// Active DS first, rest in config order.
|
|
3705
|
+
got.sort((a, b) => (a.name === activeName ? -1 : b.name === activeName ? 1 : 0));
|
|
3706
|
+
setByDs(got);
|
|
3707
|
+
});
|
|
3708
|
+
return () => {
|
|
3709
|
+
cancelled = true;
|
|
3710
|
+
};
|
|
3711
|
+
}, [sig, designRel, activeName]);
|
|
3712
|
+
return byDs;
|
|
3713
|
+
}
|
|
3714
|
+
|
|
3715
|
+
// Phase 12.3 (#4) — the "Custom" tab of the colour popover: a normal colour
|
|
3716
|
+
// input (native OS picker via a large swatch) + a hex/value text field. Applies
|
|
3717
|
+
// LIVE as you adjust (onApply), so the canvas previews while the picker is open.
|
|
3718
|
+
// #6 — the unified colour picker (Custom tab). A real HSV control: a
|
|
3719
|
+
// saturation/value square + a hue slider + a hex field + an eyedropper — the
|
|
3720
|
+
// Figma model. Replaces BOTH the old native <input type="color"> on the swatch
|
|
3721
|
+
// AND the simple hex field, so colours have ONE popover (Custom · Variables).
|
|
3722
|
+
// `seed` is the resolved current colour (hex). Drag updates the picker UI live;
|
|
3723
|
+
// commits on pointer-up (one source write per drag); the hex field commits on
|
|
3724
|
+
// blur/Enter.
|
|
3725
|
+
function ColorPicker({ seed, onApply }) {
|
|
3726
|
+
const [hsv, setHsv] = useState(() => rgbToHsv(hexToRgb(seed || '#000000')));
|
|
3727
|
+
const hsvRef = useRef(hsv);
|
|
3728
|
+
hsvRef.current = hsv;
|
|
3729
|
+
const svRef = useRef(null);
|
|
3730
|
+
const hueRef = useRef(null);
|
|
3731
|
+
// Reseed when the selection's colour changes (but not while the user drags).
|
|
3732
|
+
const seedRef = useRef(seed);
|
|
3733
|
+
// biome-ignore lint/correctness/useExhaustiveDependencies: reseed on seed change only.
|
|
3734
|
+
useEffect(() => {
|
|
3735
|
+
if (seed && seed !== seedRef.current) {
|
|
3736
|
+
seedRef.current = seed;
|
|
3737
|
+
setHsv(rgbToHsv(hexToRgb(seed)));
|
|
3738
|
+
}
|
|
3739
|
+
}, [seed]);
|
|
3740
|
+
const hex = rgbToHex(hsvToRgb(hsv));
|
|
3741
|
+
|
|
3742
|
+
const dragSV = (e) => {
|
|
3743
|
+
e.preventDefault();
|
|
3744
|
+
const r = svRef.current?.getBoundingClientRect();
|
|
3745
|
+
if (!r) return;
|
|
3746
|
+
const h = hsvRef.current.h;
|
|
3747
|
+
const move = (ev) => {
|
|
3748
|
+
setHsv({
|
|
3749
|
+
h,
|
|
3750
|
+
s: clamp01((ev.clientX - r.left) / r.width),
|
|
3751
|
+
v: clamp01(1 - (ev.clientY - r.top) / r.height),
|
|
3752
|
+
});
|
|
3753
|
+
};
|
|
3754
|
+
move(e);
|
|
3755
|
+
const up = () => {
|
|
3756
|
+
document.removeEventListener('pointermove', move);
|
|
3757
|
+
document.removeEventListener('pointerup', up);
|
|
3758
|
+
onApply(rgbToHex(hsvToRgb(hsvRef.current)));
|
|
3759
|
+
};
|
|
3760
|
+
document.addEventListener('pointermove', move);
|
|
3761
|
+
document.addEventListener('pointerup', up);
|
|
3762
|
+
};
|
|
3763
|
+
const dragHue = (e) => {
|
|
3764
|
+
e.preventDefault();
|
|
3765
|
+
const r = hueRef.current?.getBoundingClientRect();
|
|
3766
|
+
if (!r) return;
|
|
3767
|
+
const { s, v } = hsvRef.current;
|
|
3768
|
+
const move = (ev) => {
|
|
3769
|
+
setHsv({ h: clamp01((ev.clientX - r.left) / r.width) * 360, s, v });
|
|
3770
|
+
};
|
|
3771
|
+
move(e);
|
|
3772
|
+
const up = () => {
|
|
3773
|
+
document.removeEventListener('pointermove', move);
|
|
3774
|
+
document.removeEventListener('pointerup', up);
|
|
3775
|
+
onApply(rgbToHex(hsvToRgb(hsvRef.current)));
|
|
3776
|
+
};
|
|
3777
|
+
document.addEventListener('pointermove', move);
|
|
3778
|
+
document.addEventListener('pointerup', up);
|
|
3779
|
+
};
|
|
3780
|
+
const eyedrop = async () => {
|
|
3781
|
+
try {
|
|
3782
|
+
// EyeDropper is Chromium-only; guarded.
|
|
3783
|
+
const ED = window.EyeDropper;
|
|
3784
|
+
if (!ED) return;
|
|
3785
|
+
const res = await new ED().open();
|
|
3786
|
+
if (res?.sRGBHex) {
|
|
3787
|
+
setHsv(rgbToHsv(hexToRgb(res.sRGBHex)));
|
|
3788
|
+
onApply(res.sRGBHex);
|
|
3789
|
+
}
|
|
3790
|
+
} catch {
|
|
3791
|
+
/* user cancelled */
|
|
3792
|
+
}
|
|
3793
|
+
};
|
|
3794
|
+
|
|
3795
|
+
return (
|
|
3796
|
+
<div className="st-cp-cpick">
|
|
3797
|
+
<button
|
|
3798
|
+
type="button"
|
|
3799
|
+
ref={svRef}
|
|
3800
|
+
className="st-cp-cpick-sv"
|
|
3801
|
+
aria-label="saturation and value"
|
|
3802
|
+
style={{ background: `hsl(${hsv.h} 100% 50%)` }}
|
|
3803
|
+
onPointerDown={dragSV}
|
|
3804
|
+
>
|
|
3805
|
+
<span className="st-cp-cpick-svwhite" />
|
|
3806
|
+
<span className="st-cp-cpick-svblack" />
|
|
3807
|
+
<span
|
|
3808
|
+
className="st-cp-cpick-knob"
|
|
3809
|
+
style={{ left: `${hsv.s * 100}%`, top: `${(1 - hsv.v) * 100}%`, background: hex }}
|
|
3810
|
+
/>
|
|
3811
|
+
</button>
|
|
3812
|
+
<div className="st-cp-cpick-controls">
|
|
3813
|
+
{window.EyeDropper ? (
|
|
3814
|
+
<button
|
|
3815
|
+
type="button"
|
|
3816
|
+
className="st-cp-cpick-eye"
|
|
3817
|
+
aria-label="pick from screen"
|
|
3818
|
+
title="eyedropper"
|
|
3819
|
+
onClick={eyedrop}
|
|
3820
|
+
>
|
|
3821
|
+
<StIcon name="eyedropper" size={14} />
|
|
3822
|
+
</button>
|
|
3823
|
+
) : null}
|
|
3824
|
+
<button
|
|
3825
|
+
type="button"
|
|
3826
|
+
ref={hueRef}
|
|
3827
|
+
className="st-cp-cpick-hue"
|
|
3828
|
+
aria-label="hue"
|
|
3829
|
+
onPointerDown={dragHue}
|
|
3830
|
+
>
|
|
3831
|
+
<span className="st-cp-cpick-huethumb" style={{ left: `${(hsv.h / 360) * 100}%` }} />
|
|
3832
|
+
</button>
|
|
3833
|
+
</div>
|
|
3834
|
+
<input
|
|
3835
|
+
className="st-cp-fin"
|
|
3836
|
+
type="text"
|
|
3837
|
+
value={hex}
|
|
3838
|
+
aria-label="hex value"
|
|
3839
|
+
onChange={(e) => {
|
|
3840
|
+
const v = e.target.value;
|
|
3841
|
+
if (/^#?[0-9a-f]{6}$/i.test(v)) setHsv(rgbToHsv(hexToRgb(v)));
|
|
3842
|
+
}}
|
|
3843
|
+
onKeyDown={(e) => {
|
|
3844
|
+
if (e.key === 'Enter') onApply(e.currentTarget.value);
|
|
3845
|
+
}}
|
|
3846
|
+
onBlur={(e) => onApply(e.currentTarget.value)}
|
|
3847
|
+
/>
|
|
3848
|
+
</div>
|
|
3849
|
+
);
|
|
3850
|
+
}
|
|
3851
|
+
|
|
3852
|
+
// Phase 12.3 (W2.1) — token picker as a Figma-style popover instead of a native
|
|
3853
|
+
// <select>. `kind='color'` renders a swatch grid (resolved DS color values);
|
|
3854
|
+
// `kind='value'` a variable list (pretty name + resolved value, à la Figma's
|
|
3855
|
+
// variable picker). Picking commits `var(--token)`. Portals to <body> +
|
|
3856
|
+
// fixed-positions from the trigger rect so the panel's overflow never clips it.
|
|
3857
|
+
function TokenPopover({ kind, groups, current, onPick, label, swatchBg, seedHex, activeDs }) {
|
|
3858
|
+
const [open, setOpen] = useState(false);
|
|
3859
|
+
const [pos, setPos] = useState(null);
|
|
3860
|
+
// Phase 12.3 (#4) — colour popover gets two tabs: a normal colour input
|
|
3861
|
+
// (Custom) + the DS variables swatch list (Variables). Token-able non-colour
|
|
3862
|
+
// popovers stay single-mode.
|
|
3863
|
+
const [mode, setMode] = useState('custom');
|
|
3864
|
+
const [query, setQuery] = useState('');
|
|
3865
|
+
const btnRef = useRef(null);
|
|
3866
|
+
const popRef = useRef(null);
|
|
3867
|
+
const bound = typeof current === 'string' && /var\(\s*--/.test(current);
|
|
3868
|
+
const isOn = (n) => current === `var(${n})`;
|
|
3869
|
+
const pretty = (n) => n.replace(/^--/, '').replace(/-/g, ' ');
|
|
3870
|
+
const gs = groups || [];
|
|
3871
|
+
const total = gs.reduce((s, g) => s + (g.names?.length || 0), 0);
|
|
3872
|
+
const showDsHeaders = gs.length > 1; // group by DS only when there's >1
|
|
3873
|
+
// Search filter over the token name + resolved value, per group.
|
|
3874
|
+
const q = query.trim().toLowerCase();
|
|
3875
|
+
const filteredGs = !q
|
|
3876
|
+
? gs
|
|
3877
|
+
: gs
|
|
3878
|
+
.map((g) => ({
|
|
3879
|
+
...g,
|
|
3880
|
+
names: (g.names || []).filter(
|
|
3881
|
+
(n) =>
|
|
3882
|
+
pretty(n).toLowerCase().includes(q) ||
|
|
3883
|
+
n.toLowerCase().includes(q) ||
|
|
3884
|
+
(g.vals?.[n] || '').toLowerCase().includes(q)
|
|
3885
|
+
),
|
|
3886
|
+
}))
|
|
3887
|
+
.filter((g) => g.names.length);
|
|
3888
|
+
|
|
3889
|
+
useEffect(() => {
|
|
3890
|
+
if (!open) {
|
|
3891
|
+
setQuery('');
|
|
3892
|
+
return undefined;
|
|
3893
|
+
}
|
|
3894
|
+
const place = () => {
|
|
3895
|
+
const r = btnRef.current?.getBoundingClientRect();
|
|
3896
|
+
if (!r) return;
|
|
3897
|
+
const W = 224;
|
|
3898
|
+
const MAXH = 300;
|
|
3899
|
+
let left = Math.min(r.right - W, window.innerWidth - W - 8);
|
|
3900
|
+
if (left < 8) left = 8;
|
|
3901
|
+
const below = window.innerHeight - r.bottom;
|
|
3902
|
+
const top = below > MAXH + 8 ? r.bottom + 4 : Math.max(8, r.top - MAXH - 4);
|
|
3903
|
+
setPos({ left, top, width: W, maxHeight: MAXH });
|
|
3904
|
+
};
|
|
3905
|
+
place();
|
|
3906
|
+
const onDoc = (e) => {
|
|
3907
|
+
if (popRef.current?.contains(e.target) || btnRef.current?.contains(e.target)) return;
|
|
3908
|
+
setOpen(false);
|
|
3909
|
+
};
|
|
3910
|
+
const onKey = (e) => {
|
|
3911
|
+
if (e.key === 'Escape') setOpen(false);
|
|
3912
|
+
};
|
|
3913
|
+
const dismiss = () => setOpen(false);
|
|
3914
|
+
// The popover is fixed-positioned from the trigger rect, so a scroll of the
|
|
3915
|
+
// PANEL detaches it → dismiss. But scrolling INSIDE the popover (its own
|
|
3916
|
+
// overflow list) must NOT close it (the user couldn't scroll the variables).
|
|
3917
|
+
const onScroll = (e) => {
|
|
3918
|
+
if (popRef.current?.contains(e.target)) return;
|
|
3919
|
+
setOpen(false);
|
|
3920
|
+
};
|
|
3921
|
+
document.addEventListener('pointerdown', onDoc, true);
|
|
3922
|
+
document.addEventListener('keydown', onKey);
|
|
3923
|
+
window.addEventListener('resize', dismiss);
|
|
3924
|
+
document.addEventListener('scroll', onScroll, true);
|
|
3925
|
+
return () => {
|
|
3926
|
+
document.removeEventListener('pointerdown', onDoc, true);
|
|
3927
|
+
document.removeEventListener('keydown', onKey);
|
|
3928
|
+
window.removeEventListener('resize', dismiss);
|
|
3929
|
+
document.removeEventListener('scroll', onScroll, true);
|
|
3930
|
+
};
|
|
3931
|
+
}, [open]);
|
|
3932
|
+
|
|
3933
|
+
// Pick a token. #3 — apply CORRECTLY across design systems: a token from the
|
|
3934
|
+
// canvas's OWN active DS commits `var(--token)` (round-trips + resolves right);
|
|
3935
|
+
// a token from ANOTHER DS commits its RESOLVED value (literal), because
|
|
3936
|
+
// `var(--token)` would resolve against the canvas's DS scope and paint the WRONG
|
|
3937
|
+
// colour. So what you click is always what's applied ("natvrdo").
|
|
3938
|
+
// SECURITY (ethical-hacker A3) — a cross-DS token value is committed as a
|
|
3939
|
+
// LITERAL, and its source is a (possibly hub-pushed, untrusted) tokens CSS.
|
|
3940
|
+
// A colour-shaped value can still smuggle a fetch primitive (e.g.
|
|
3941
|
+
// `rgb(1 2 3) url(//x)` passes the colour sniff). Refuse to write a literal
|
|
3942
|
+
// carrying url()/image-set()/expression()/@import — fall back to var(), which
|
|
3943
|
+
// resolves against the CANVAS's own DS (never the attacker's value).
|
|
3944
|
+
const UNSAFE_TOKEN_VALUE = /url\(|image-set\(|cross-fade\(|element\(|expression\(|@import|javascript:/i;
|
|
3945
|
+
const pickFrom = (ds, n, resolved) => {
|
|
3946
|
+
if (activeDs && ds && ds !== activeDs && resolved && !UNSAFE_TOKEN_VALUE.test(resolved)) {
|
|
3947
|
+
onPick(resolved);
|
|
3948
|
+
} else {
|
|
3949
|
+
onPick(`var(${n})`);
|
|
3950
|
+
}
|
|
3951
|
+
setOpen(false);
|
|
3952
|
+
};
|
|
3953
|
+
// Custom colour / hex applies LIVE without closing, so the user can keep
|
|
3954
|
+
// tweaking; the popover dismisses on outside-click / Esc like everything else.
|
|
3955
|
+
const applyRaw = (v) => {
|
|
3956
|
+
const val = (v || '').trim();
|
|
3957
|
+
if (val) onPick(val);
|
|
3958
|
+
};
|
|
3959
|
+
// A search field over the token list (name + value). Auto-focuses so the user
|
|
3960
|
+
// can type straight away.
|
|
3961
|
+
const searchBar = (
|
|
3962
|
+
<div className="st-cp-pop-search">
|
|
3963
|
+
<StIcon name="search" size={12} />
|
|
3964
|
+
<input
|
|
3965
|
+
value={query}
|
|
3966
|
+
onChange={(e) => setQuery(e.target.value)}
|
|
3967
|
+
placeholder="Search variables"
|
|
3968
|
+
aria-label="search variables"
|
|
3969
|
+
// biome-ignore lint/a11y/noAutofocus: focusing the search on open is the intent.
|
|
3970
|
+
autoFocus
|
|
3971
|
+
/>
|
|
3972
|
+
</div>
|
|
3973
|
+
);
|
|
3974
|
+
// #2 — colour Variables as a scannable LIST (swatch · name · value), per DS.
|
|
3975
|
+
const swatchList = (grps) =>
|
|
3976
|
+
grps.map((g) => (
|
|
3977
|
+
<div className="st-cp-pop-group" key={g.ds}>
|
|
3978
|
+
{showDsHeaders ? <div className="st-cp-pop-ds">{g.ds}</div> : null}
|
|
3979
|
+
<div className="st-cp-pop-list">
|
|
3980
|
+
{g.names.map((n) => (
|
|
3981
|
+
<button
|
|
3982
|
+
key={`${g.ds}:${n}`}
|
|
3983
|
+
type="button"
|
|
3984
|
+
className={`st-cp-pop-row st-cp-pop-crow${isOn(n) ? ' is-on' : ''}`}
|
|
3985
|
+
onClick={() => pickFrom(g.ds, n, g.vals?.[n])}
|
|
3986
|
+
>
|
|
3987
|
+
<span
|
|
3988
|
+
className="st-cp-pop-cswatch"
|
|
3989
|
+
style={{ background: g.vals?.[n] || 'transparent' }}
|
|
3990
|
+
aria-hidden="true"
|
|
3991
|
+
/>
|
|
3992
|
+
<span className="st-cp-pop-name">{pretty(n)}</span>
|
|
3993
|
+
<span className="st-cp-pop-val">{g.vals?.[n] || ''}</span>
|
|
3994
|
+
</button>
|
|
3995
|
+
))}
|
|
3996
|
+
</div>
|
|
3997
|
+
</div>
|
|
3998
|
+
));
|
|
3999
|
+
// The value-token list (non-colour), per DS.
|
|
4000
|
+
const valueList = (grps) =>
|
|
4001
|
+
grps.map((g) => (
|
|
4002
|
+
<div className="st-cp-pop-group" key={g.ds}>
|
|
4003
|
+
{showDsHeaders ? <div className="st-cp-pop-ds">{g.ds}</div> : null}
|
|
4004
|
+
<div className="st-cp-pop-list">
|
|
4005
|
+
{g.names.map((n) => (
|
|
4006
|
+
<button
|
|
4007
|
+
key={`${g.ds}:${n}`}
|
|
4008
|
+
type="button"
|
|
4009
|
+
className={`st-cp-pop-row${isOn(n) ? ' is-on' : ''}`}
|
|
4010
|
+
onClick={() => pickFrom(g.ds, n, g.vals?.[n])}
|
|
4011
|
+
>
|
|
4012
|
+
<span className="st-cp-pop-name">{pretty(n)}</span>
|
|
4013
|
+
<span className="st-cp-pop-val">{g.vals?.[n] || ''}</span>
|
|
4014
|
+
</button>
|
|
4015
|
+
))}
|
|
4016
|
+
</div>
|
|
4017
|
+
</div>
|
|
4018
|
+
));
|
|
4019
|
+
const noMatch = <div className="st-cp-pop-empty">No match</div>;
|
|
4020
|
+
|
|
4021
|
+
return (
|
|
4022
|
+
<>
|
|
4023
|
+
{swatchBg !== undefined ? (
|
|
4024
|
+
// Colour rows: the swatch IS the trigger — one popover, no separate native
|
|
4025
|
+
// OS picker + ◇ (the "two popovers" the user flagged). Shows the current
|
|
4026
|
+
// colour; bound-to-token gets the accent ring.
|
|
4027
|
+
<button
|
|
4028
|
+
type="button"
|
|
4029
|
+
ref={btnRef}
|
|
4030
|
+
className={`st-cp-swatch st-cp-swatch--mini st-cp-swatch--trigger${bound ? ' is-bound' : ''}`}
|
|
4031
|
+
aria-haspopup="dialog"
|
|
4032
|
+
aria-expanded={open}
|
|
4033
|
+
aria-label={label || 'pick a colour'}
|
|
4034
|
+
title={current || 'pick a colour'}
|
|
4035
|
+
onClick={() => setOpen((v) => !v)}
|
|
4036
|
+
>
|
|
4037
|
+
<span style={{ position: 'absolute', inset: 0, background: swatchBg || 'transparent' }} />
|
|
4038
|
+
</button>
|
|
4039
|
+
) : (
|
|
4040
|
+
<button
|
|
4041
|
+
type="button"
|
|
4042
|
+
ref={btnRef}
|
|
4043
|
+
className={`st-cp-tokbtn${bound ? ' is-bound' : ''}`}
|
|
4044
|
+
aria-haspopup="dialog"
|
|
4045
|
+
aria-expanded={open}
|
|
4046
|
+
aria-label={label || 'pick a design token'}
|
|
4047
|
+
title="design tokens"
|
|
4048
|
+
onClick={() => setOpen((v) => !v)}
|
|
4049
|
+
>
|
|
4050
|
+
<span className="st-cp-tokbtn-glyph" aria-hidden="true" />
|
|
4051
|
+
</button>
|
|
4052
|
+
)}
|
|
4053
|
+
{open && pos
|
|
4054
|
+
? createPortal(
|
|
4055
|
+
<div
|
|
4056
|
+
ref={popRef}
|
|
4057
|
+
// Portalled to <body> (outside the App's `.maude` div), so it must
|
|
4058
|
+
// re-establish the maude token scope itself — otherwise var(--bg-*)
|
|
4059
|
+
// resolves to the legacy :root project palette (the cream popover bug).
|
|
4060
|
+
className="maude st-cp-pop"
|
|
4061
|
+
data-theme={
|
|
4062
|
+
(typeof document !== 'undefined' &&
|
|
4063
|
+
document.documentElement.getAttribute('data-theme')) ||
|
|
4064
|
+
'dark'
|
|
4065
|
+
}
|
|
4066
|
+
role="dialog"
|
|
4067
|
+
aria-label={label || 'design tokens'}
|
|
4068
|
+
style={{
|
|
4069
|
+
left: pos.left,
|
|
4070
|
+
top: pos.top,
|
|
4071
|
+
width: pos.width,
|
|
4072
|
+
maxHeight: pos.maxHeight,
|
|
4073
|
+
}}
|
|
4074
|
+
>
|
|
4075
|
+
{kind === 'color' ? (
|
|
4076
|
+
<>
|
|
4077
|
+
<div className="st-cp-poptabs" role="tablist">
|
|
4078
|
+
<button
|
|
4079
|
+
type="button"
|
|
4080
|
+
role="tab"
|
|
4081
|
+
aria-selected={mode === 'custom'}
|
|
4082
|
+
className={`st-cp-poptab${mode === 'custom' ? ' is-active' : ''}`}
|
|
4083
|
+
onClick={() => setMode('custom')}
|
|
4084
|
+
>
|
|
4085
|
+
Custom
|
|
4086
|
+
</button>
|
|
4087
|
+
<button
|
|
4088
|
+
type="button"
|
|
4089
|
+
role="tab"
|
|
4090
|
+
aria-selected={mode === 'vars'}
|
|
4091
|
+
className={`st-cp-poptab${mode === 'vars' ? ' is-active' : ''}`}
|
|
4092
|
+
onClick={() => setMode('vars')}
|
|
4093
|
+
>
|
|
4094
|
+
Variables
|
|
4095
|
+
</button>
|
|
4096
|
+
</div>
|
|
4097
|
+
{mode === 'custom' ? (
|
|
4098
|
+
<ColorPicker seed={seedHex || cssColorToHex(current) || '#000000'} onApply={applyRaw} />
|
|
4099
|
+
) : !total ? (
|
|
4100
|
+
<div className="st-cp-pop-empty">No color tokens</div>
|
|
4101
|
+
) : (
|
|
4102
|
+
<>
|
|
4103
|
+
{searchBar}
|
|
4104
|
+
{filteredGs.length ? swatchList(filteredGs) : noMatch}
|
|
4105
|
+
</>
|
|
4106
|
+
)}
|
|
4107
|
+
</>
|
|
4108
|
+
) : !total ? (
|
|
4109
|
+
<div className="st-cp-pop-empty">No tokens for this property</div>
|
|
4110
|
+
) : (
|
|
4111
|
+
<>
|
|
4112
|
+
{searchBar}
|
|
4113
|
+
{filteredGs.length ? valueList(filteredGs) : noMatch}
|
|
4114
|
+
</>
|
|
4115
|
+
)}
|
|
4116
|
+
</div>,
|
|
4117
|
+
document.body
|
|
4118
|
+
)
|
|
4119
|
+
: null}
|
|
4120
|
+
</>
|
|
4121
|
+
);
|
|
4122
|
+
}
|
|
4123
|
+
|
|
4124
|
+
function CssKnobs({ el, cfg, onOptimistic }) {
|
|
4125
|
+
const editable = !!el.id;
|
|
4126
|
+
const computed = el.computed || {};
|
|
4127
|
+
// Phase 12.3 — optimistic local overlay over the selection's authored / custom
|
|
4128
|
+
// / attr maps. With the redundant-reload suppression (the flicker fix), an edit
|
|
4129
|
+
// no longer triggers a reselect that would re-post fresh `authored` values — so
|
|
4130
|
+
// the panel must reflect its own commits immediately or it shows the stale
|
|
4131
|
+
// pre-edit value until the user re-selects. Each commit/reset writes here;
|
|
4132
|
+
// `null` marks a removed key. Cleared when a different element is selected.
|
|
4133
|
+
const [overlay, setOverlay] = useState({ a: {}, c: {}, t: {} });
|
|
4134
|
+
// biome-ignore lint/correctness/useExhaustiveDependencies: clear only on element change.
|
|
4135
|
+
useEffect(() => {
|
|
4136
|
+
setOverlay({ a: {}, c: {}, t: {} });
|
|
4137
|
+
}, [el.id]);
|
|
4138
|
+
const mergeOverlay = (base, ov) => {
|
|
4139
|
+
const out = { ...(base || {}) };
|
|
4140
|
+
for (const [k, v] of Object.entries(ov)) {
|
|
4141
|
+
if (v === null) delete out[k];
|
|
4142
|
+
else out[k] = v;
|
|
4143
|
+
}
|
|
4144
|
+
return out;
|
|
4145
|
+
};
|
|
4146
|
+
const authored = mergeOverlay(el.authored, overlay.a);
|
|
4147
|
+
const customStyles = mergeOverlay(el.customStyles, overlay.c);
|
|
4148
|
+
const attrs = mergeOverlay(el.attrs, overlay.t);
|
|
4149
|
+
const setA = (prop, v) => setOverlay((o) => ({ ...o, a: { ...o.a, [prop]: v } }));
|
|
4150
|
+
const setC = (prop, v) => setOverlay((o) => ({ ...o, c: { ...o.c, [prop]: v } }));
|
|
4151
|
+
const setT = (attr, v) => setOverlay((o) => ({ ...o, t: { ...o.t, [attr]: v } }));
|
|
4152
|
+
// Token CSS is served from the MAIN origin at the repo-relative path, i.e.
|
|
4153
|
+
// WITH the designRoot prefix (`/.design/system/<ds>/colors_and_type.css`) —
|
|
4154
|
+
// `tokensCssRel` from config is DS-root-relative (no `.design/`), so prepend it.
|
|
4155
|
+
const _designRel = (cfg?.designRel || cfg?.designRoot || '.design').replace(/^\/+|\/+$/g, '');
|
|
4156
|
+
const _activeDs = activeDsNameFor(el.file, cfg);
|
|
4157
|
+
// W3 — tokens from EVERY configured DS, active one first, so the popover can
|
|
4158
|
+
// offer them grouped per design system.
|
|
4159
|
+
const allDs = useAllDsTokens(cfg, _designRel, _activeDs);
|
|
4160
|
+
// Build per-DS popover groups for one token family (color/space/radius/…).
|
|
4161
|
+
const tokenGroups = (familyKey) =>
|
|
4162
|
+
allDs
|
|
4163
|
+
.map((d) => ({ ds: d.name, names: d[familyKey] || [], vals: d.vals }))
|
|
4164
|
+
.filter((g) => g.names.length);
|
|
4165
|
+
const [status, setStatus] = useState({});
|
|
4166
|
+
const [open, setOpen] = useState({
|
|
4167
|
+
Layout: true,
|
|
4168
|
+
Typography: true,
|
|
4169
|
+
Spacing: true,
|
|
4170
|
+
Size: true,
|
|
4171
|
+
Appearance: true,
|
|
4172
|
+
Advanced: false,
|
|
4173
|
+
});
|
|
4174
|
+
const [split, setSplit] = useState(false);
|
|
4175
|
+
|
|
4176
|
+
// Phase 12.3 — auto-expand Advanced when the selected element carries custom
|
|
4177
|
+
// CSS props / HTML attrs, so a just-added (or pre-existing) custom value is
|
|
4178
|
+
// visible without hunting for the disclosure. Keyed on el.id so it re-runs per
|
|
4179
|
+
// selection (CssKnobs persists across selections — the el prop changes).
|
|
4180
|
+
const hasCustom =
|
|
4181
|
+
Object.keys(customStyles).length > 0 || Object.keys(attrs).length > 0;
|
|
4182
|
+
useEffect(() => {
|
|
4183
|
+
if (hasCustom) setOpen((o) => (o.Advanced ? o : { ...o, Advanced: true }));
|
|
4184
|
+
}, [el.id, hasCustom]);
|
|
4185
|
+
|
|
4186
|
+
async function post(url, payload, key) {
|
|
4187
|
+
setStatus((s) => ({ ...s, [key]: 'saving' }));
|
|
4188
|
+
try {
|
|
4189
|
+
const res = await fetch(url, {
|
|
4190
|
+
method: 'POST',
|
|
4191
|
+
headers: { 'content-type': 'application/json' },
|
|
4192
|
+
body: JSON.stringify(payload),
|
|
4193
|
+
});
|
|
4194
|
+
const j = await res.json().catch(() => ({}));
|
|
4195
|
+
setStatus((s) => ({
|
|
4196
|
+
...s,
|
|
4197
|
+
[key]: !res.ok || !j.ok ? `err:${(j && j.error) || `HTTP ${res.status}`}` : 'saved',
|
|
4198
|
+
}));
|
|
4199
|
+
} catch (err) {
|
|
4200
|
+
setStatus((s) => ({ ...s, [key]: `err:${err && err.message ? err.message : String(err)}` }));
|
|
4201
|
+
}
|
|
4202
|
+
}
|
|
4203
|
+
// Optimistic preview: nudge the live element so the change shows before the
|
|
4204
|
+
// edit → HMR reload lands. `value` null = remove (reset path). No-op when the
|
|
4205
|
+
// selection has no stable id (can't be resolved in the canvas).
|
|
4206
|
+
const optimistic = (prop, value) => {
|
|
4207
|
+
if (!onOptimistic || !el.id) return;
|
|
4208
|
+
onOptimistic({
|
|
4209
|
+
id: el.id,
|
|
4210
|
+
artboardId: el.artboardId ?? null,
|
|
4211
|
+
index: el.index ?? 0,
|
|
4212
|
+
prop,
|
|
4213
|
+
value,
|
|
4214
|
+
});
|
|
4215
|
+
};
|
|
4216
|
+
const commit = (property, raw) => {
|
|
4217
|
+
const value = (raw || '').trim();
|
|
4218
|
+
if (!editable || !value) return;
|
|
4219
|
+
if (value === (authored[property] ?? '').trim()) return; // no-op
|
|
4220
|
+
optimistic(property, value);
|
|
4221
|
+
setA(property, value); // reflect in the panel immediately (no reload → no reselect)
|
|
4222
|
+
post('/_api/edit-css', { canvas: el.file, id: el.id, property, value }, property);
|
|
4223
|
+
};
|
|
4224
|
+
// A custom CSS property (Advanced) — same write, but the panel surfaces it from
|
|
4225
|
+
// the customStyles map, so overlay THERE.
|
|
4226
|
+
const commitCustom = (property, raw) => {
|
|
4227
|
+
const value = (raw || '').trim();
|
|
4228
|
+
if (!editable || !property.trim() || !value) return;
|
|
4229
|
+
optimistic(property.trim(), value);
|
|
4230
|
+
setC(property.trim(), value);
|
|
4231
|
+
post('/_api/edit-css', { canvas: el.file, id: el.id, property: property.trim(), value }, property.trim());
|
|
4232
|
+
};
|
|
4233
|
+
const commitAttr = (attr, raw) => {
|
|
4234
|
+
const a = (attr || '').trim();
|
|
4235
|
+
const value = (raw || '').trim();
|
|
4236
|
+
if (!editable || !a || !value) return;
|
|
4237
|
+
setT(a, value);
|
|
4238
|
+
post('/_api/edit-attr', { canvas: el.file, id: el.id, attr: a, value }, `@${a}`);
|
|
4239
|
+
};
|
|
4240
|
+
// Phase 12.3 — reset (remove the inline prop / attr → back to class/inherited).
|
|
4241
|
+
const reset = (property) => {
|
|
4242
|
+
if (!editable) return;
|
|
4243
|
+
optimistic(property, null);
|
|
4244
|
+
setA(property, null);
|
|
4245
|
+
post('/_api/edit-css', { canvas: el.file, id: el.id, property, reset: true }, property);
|
|
4246
|
+
};
|
|
4247
|
+
const resetCustom = (property) => {
|
|
4248
|
+
if (!editable) return;
|
|
4249
|
+
optimistic(property, null);
|
|
4250
|
+
setC(property, null);
|
|
4251
|
+
post('/_api/edit-css', { canvas: el.file, id: el.id, property, reset: true }, property);
|
|
4252
|
+
};
|
|
4253
|
+
const resetAttr = (attr) => {
|
|
4254
|
+
if (!editable) return;
|
|
4255
|
+
setT(attr, null);
|
|
4256
|
+
post('/_api/edit-attr', { canvas: el.file, id: el.id, attr, reset: true }, `@${attr}`);
|
|
4257
|
+
};
|
|
4258
|
+
// Phase 12.3 (W2.2) — Figma/Webflow scrub: drag a number field horizontally to
|
|
4259
|
+
// change its value. Live preview via optimistic apply on every move (no source
|
|
4260
|
+
// write); commits ONCE on release. A pointer that doesn't pass a 3px threshold
|
|
4261
|
+
// is a normal click (focus to type). `opts.step` modifiers: shift = ×10, alt =
|
|
4262
|
+
// ×0.1. `opts.sides` enables Webflow box-model modifiers: alt = symmetric pair,
|
|
4263
|
+
// alt+shift = all four (else just this side). `opts.min` clamps (default 0).
|
|
4264
|
+
const makeScrub = (prop, opts = {}) => (e) => {
|
|
4265
|
+
if (e.button !== 0) return;
|
|
4266
|
+
const input = e.currentTarget;
|
|
4267
|
+
const startX = e.clientX;
|
|
4268
|
+
const baseN =
|
|
4269
|
+
Number.parseFloat(
|
|
4270
|
+
cssSplitUnit(authored[prop] ?? cssHint(computed[prop]) ?? '0').n || '0'
|
|
4271
|
+
) || 0;
|
|
4272
|
+
const unit = opts.unitless
|
|
4273
|
+
? ''
|
|
4274
|
+
: opts.unit || cssSplitUnit(authored[prop] ?? '').unit || 'px';
|
|
4275
|
+
const min = opts.min ?? 0;
|
|
4276
|
+
const fmt = (n) => (opts.unitless ? `${n}` : `${n}${unit}`);
|
|
4277
|
+
const sidesFor = (ev) => {
|
|
4278
|
+
if (!opts.sides) return [prop];
|
|
4279
|
+
if (ev.altKey && ev.shiftKey) return opts.sides.all;
|
|
4280
|
+
if (ev.altKey) return opts.sides.pair;
|
|
4281
|
+
return [prop];
|
|
4282
|
+
};
|
|
4283
|
+
let scrubbing = false;
|
|
4284
|
+
let last = baseN;
|
|
4285
|
+
const move = (ev) => {
|
|
4286
|
+
const dx = ev.clientX - startX;
|
|
4287
|
+
if (!scrubbing && Math.abs(dx) < 3) return;
|
|
4288
|
+
if (!scrubbing) {
|
|
4289
|
+
scrubbing = true;
|
|
4290
|
+
document.body.classList.add('st-scrubbing');
|
|
4291
|
+
}
|
|
4292
|
+
ev.preventDefault();
|
|
4293
|
+
const granular = opts.sides ? 1 : ev.shiftKey ? 10 : ev.altKey ? 0.1 : 1;
|
|
4294
|
+
last = Math.round((baseN + dx * granular) * 100) / 100;
|
|
4295
|
+
if (last < min) last = min;
|
|
4296
|
+
const sides = sidesFor(ev);
|
|
4297
|
+
// Live-update the dragged field AND, for a box-model multi-side scrub, the
|
|
4298
|
+
// sibling box inputs so the whole pair / four-up move shows in the panel —
|
|
4299
|
+
// not just the one being dragged (W2.2 feedback).
|
|
4300
|
+
if (input) input.value = String(last);
|
|
4301
|
+
if (opts.sides && sides.length > 1) {
|
|
4302
|
+
const box = input?.closest('.st-cp-box');
|
|
4303
|
+
for (const p of sides) {
|
|
4304
|
+
if (p === prop) continue;
|
|
4305
|
+
const sib = box?.querySelector(`.st-cp-boxv[aria-label="${p}"]`);
|
|
4306
|
+
if (sib) sib.value = String(last);
|
|
4307
|
+
}
|
|
4308
|
+
}
|
|
4309
|
+
for (const p of sides) optimistic(p, fmt(last));
|
|
4310
|
+
};
|
|
4311
|
+
const up = (ev) => {
|
|
4312
|
+
document.removeEventListener('pointermove', move);
|
|
4313
|
+
document.removeEventListener('pointerup', up);
|
|
4314
|
+
if (!scrubbing) return;
|
|
4315
|
+
document.body.classList.remove('st-scrubbing');
|
|
4316
|
+
for (const p of sidesFor(ev)) commit(p, fmt(last));
|
|
4317
|
+
};
|
|
4318
|
+
document.addEventListener('pointermove', move);
|
|
4319
|
+
document.addEventListener('pointerup', up);
|
|
4320
|
+
};
|
|
4321
|
+
const provOf = (prop) => {
|
|
4322
|
+
const v = authored[prop];
|
|
4323
|
+
if (!v) return 'inherit';
|
|
4324
|
+
return /var\(\s*--/.test(v) ? 'bound' : 'raw';
|
|
4325
|
+
};
|
|
4326
|
+
|
|
4327
|
+
if (!editable) {
|
|
4328
|
+
return (
|
|
4329
|
+
<div className="st-cp">
|
|
4330
|
+
<div className="st-cp-id">
|
|
4331
|
+
<span className="st-cp-idtag">{el.tag || 'element'}</span>
|
|
4332
|
+
</div>
|
|
4333
|
+
<div className="st-css-disabled">
|
|
4334
|
+
This selection has no stable element id (a legacy canvas, or a non-element target). Edit
|
|
4335
|
+
it with <code>/design:edit</code>.
|
|
4336
|
+
</div>
|
|
4337
|
+
</div>
|
|
4338
|
+
);
|
|
4339
|
+
}
|
|
4340
|
+
|
|
4341
|
+
const PROVLABEL = { bound: 'token-bound', raw: 'raw override', inherit: 'inherited' };
|
|
4342
|
+
const prov = (p) => (
|
|
4343
|
+
<span className={`st-cp-prov st-cp-prov--${p}`} role="img" aria-label={PROVLABEL[p]} />
|
|
4344
|
+
);
|
|
4345
|
+
|
|
4346
|
+
// Phase 12.3 (#4) — the LEADING dot carries it all: provenance (shape) + save
|
|
4347
|
+
// status (a success/error/saving glow) + reset (double-click an authored row).
|
|
4348
|
+
// No trailing ✓/⟲ that shift the input rightward (the user's gripe). A tooltip
|
|
4349
|
+
// hints the double-click-to-reset.
|
|
4350
|
+
const provDot = (prop, provKind) => {
|
|
4351
|
+
const k = provKind ?? provOf(prop);
|
|
4352
|
+
const s = status[prop];
|
|
4353
|
+
const errMsg = typeof s === 'string' && s.startsWith('err:') ? s.slice(4) : '';
|
|
4354
|
+
const stCls = errMsg ? ' is-err' : s === 'saved' ? ' is-saved' : s === 'saving' ? ' is-saving' : '';
|
|
4355
|
+
const canReset = !!authored[prop];
|
|
4356
|
+
const tip = errMsg
|
|
4357
|
+
? `error: ${errMsg}`
|
|
4358
|
+
: canReset
|
|
4359
|
+
? `${PROVLABEL[k]} · double-click to reset`
|
|
4360
|
+
: PROVLABEL[k];
|
|
4361
|
+
return (
|
|
4362
|
+
<button
|
|
4363
|
+
type="button"
|
|
4364
|
+
className={`st-cp-prov st-cp-prov--${k}${stCls}${canReset ? ' is-resettable' : ''}`}
|
|
4365
|
+
aria-label={tip}
|
|
4366
|
+
title={tip}
|
|
4367
|
+
tabIndex={canReset ? 0 : -1}
|
|
4368
|
+
onDoubleClick={canReset ? () => reset(prop) : undefined}
|
|
4369
|
+
onKeyDown={
|
|
4370
|
+
canReset
|
|
4371
|
+
? (e) => {
|
|
4372
|
+
if (e.key === 'Backspace' || e.key === 'Delete') {
|
|
4373
|
+
e.preventDefault();
|
|
4374
|
+
reset(prop);
|
|
4375
|
+
}
|
|
4376
|
+
}
|
|
4377
|
+
: undefined
|
|
4378
|
+
}
|
|
4379
|
+
/>
|
|
4380
|
+
);
|
|
4381
|
+
};
|
|
4382
|
+
|
|
4383
|
+
const row = (prop, control, provKind) => {
|
|
4384
|
+
// #1 bigger-bet — scannable diff: a fully-unset single-prop row is dimmed so
|
|
4385
|
+
// the handful of overridden rows pop (Webflow/Framer model). Composite rows
|
|
4386
|
+
// (border — they pass an explicit provKind) are never dimmed.
|
|
4387
|
+
const unset = provKind === undefined && !authored[prop];
|
|
4388
|
+
return (
|
|
4389
|
+
<div className={`st-cp-row${unset ? ' is-unset' : ''}`} key={prop}>
|
|
4390
|
+
{provDot(prop, provKind)}
|
|
4391
|
+
<label className="st-cp-label" title={prop}>
|
|
4392
|
+
{prop}
|
|
4393
|
+
</label>
|
|
4394
|
+
<div className="st-cp-ctl">{control}</div>
|
|
4395
|
+
</div>
|
|
4396
|
+
);
|
|
4397
|
+
};
|
|
4398
|
+
|
|
4399
|
+
// Props each section owns — drives the per-section "reset section" affordance.
|
|
4400
|
+
const SECTION_PROPS = {
|
|
4401
|
+
Layout: ['display', 'flex-direction', 'align-items', 'justify-content', 'gap'],
|
|
4402
|
+
Typography: [
|
|
4403
|
+
'font-family',
|
|
4404
|
+
'color',
|
|
4405
|
+
'font-size',
|
|
4406
|
+
'font-weight',
|
|
4407
|
+
'line-height',
|
|
4408
|
+
'letter-spacing',
|
|
4409
|
+
'text-align',
|
|
4410
|
+
],
|
|
4411
|
+
Spacing: [
|
|
4412
|
+
'margin-top',
|
|
4413
|
+
'margin-right',
|
|
4414
|
+
'margin-bottom',
|
|
4415
|
+
'margin-left',
|
|
4416
|
+
'padding-top',
|
|
4417
|
+
'padding-right',
|
|
4418
|
+
'padding-bottom',
|
|
4419
|
+
'padding-left',
|
|
4420
|
+
],
|
|
4421
|
+
Size: ['width', 'height', 'max-width'],
|
|
4422
|
+
Appearance: [
|
|
4423
|
+
'background-color',
|
|
4424
|
+
'border-radius',
|
|
4425
|
+
'border-top-left-radius',
|
|
4426
|
+
'border-top-right-radius',
|
|
4427
|
+
'border-bottom-left-radius',
|
|
4428
|
+
'border-bottom-right-radius',
|
|
4429
|
+
'border-width',
|
|
4430
|
+
'border-style',
|
|
4431
|
+
'border-color',
|
|
4432
|
+
'box-shadow',
|
|
4433
|
+
'opacity',
|
|
4434
|
+
],
|
|
4435
|
+
};
|
|
4436
|
+
const resetSection = (name) => {
|
|
4437
|
+
(SECTION_PROPS[name] || []).forEach((p) => {
|
|
4438
|
+
if (authored[p]) reset(p);
|
|
4439
|
+
});
|
|
4440
|
+
};
|
|
4441
|
+
|
|
4442
|
+
const sec = (name, body) => {
|
|
4443
|
+
const dirty = (SECTION_PROPS[name] || []).some((p) => authored[p]);
|
|
4444
|
+
return (
|
|
4445
|
+
<section className="st-cp-sec" key={name}>
|
|
4446
|
+
<div className="st-cp-sechd-row">
|
|
4447
|
+
<button
|
|
4448
|
+
type="button"
|
|
4449
|
+
className="st-cp-sechd"
|
|
4450
|
+
aria-expanded={!!open[name]}
|
|
4451
|
+
onClick={() => setOpen((o) => ({ ...o, [name]: !o[name] }))}
|
|
4452
|
+
>
|
|
4453
|
+
<span className="st-cp-caret" aria-hidden="true">
|
|
4454
|
+
{open[name] ? '▾' : '▸'}
|
|
4455
|
+
</span>
|
|
4456
|
+
{name}
|
|
4457
|
+
</button>
|
|
4458
|
+
{dirty ? (
|
|
4459
|
+
<button
|
|
4460
|
+
type="button"
|
|
4461
|
+
className="st-cp-secreset"
|
|
4462
|
+
aria-label={`reset ${name} section to original`}
|
|
4463
|
+
title={`reset ${name}`}
|
|
4464
|
+
onClick={() => resetSection(name)}
|
|
4465
|
+
>
|
|
4466
|
+
⟲
|
|
4467
|
+
</button>
|
|
4468
|
+
) : null}
|
|
4469
|
+
</div>
|
|
4470
|
+
{open[name] ? body : null}
|
|
4471
|
+
</section>
|
|
4472
|
+
);
|
|
4473
|
+
};
|
|
4474
|
+
|
|
4475
|
+
// native <select> committing a CSS value directly
|
|
4476
|
+
const csel = (prop, list) => (
|
|
4477
|
+
<select
|
|
4478
|
+
className="st-cp-nsel"
|
|
4479
|
+
aria-label={prop}
|
|
4480
|
+
value={list.includes(authored[prop]) ? authored[prop] : ''}
|
|
4481
|
+
onChange={(e) => commit(prop, e.target.value)}
|
|
4482
|
+
>
|
|
4483
|
+
<option value="" disabled>
|
|
4484
|
+
{cssHint(computed[prop]) || '—'}
|
|
4485
|
+
</option>
|
|
4486
|
+
{list.map((v) => (
|
|
4487
|
+
<option key={v} value={v}>
|
|
4488
|
+
{v}
|
|
4489
|
+
</option>
|
|
4490
|
+
))}
|
|
4491
|
+
</select>
|
|
4492
|
+
);
|
|
4493
|
+
|
|
4494
|
+
// token quick-pick — Figma-style POPOVER (W2.1) listing the DS variables for
|
|
4495
|
+
// this property (name + resolved value), grouped per design system (W3);
|
|
4496
|
+
// picking writes var(--token). `familyKey` selects the token family.
|
|
4497
|
+
const tok = (prop, familyKey) => {
|
|
4498
|
+
const groups = tokenGroups(familyKey);
|
|
4499
|
+
return groups.length ? (
|
|
4500
|
+
<TokenPopover
|
|
4501
|
+
kind="value"
|
|
4502
|
+
groups={groups}
|
|
4503
|
+
current={authored[prop]}
|
|
4504
|
+
activeDs={_activeDs}
|
|
4505
|
+
onPick={(v) => commit(prop, v)}
|
|
4506
|
+
label={`${prop} design token`}
|
|
4507
|
+
/>
|
|
4508
|
+
) : null;
|
|
4509
|
+
};
|
|
4510
|
+
|
|
4511
|
+
// free text input — raw value or var(--token), commits on blur/Enter
|
|
4512
|
+
const text = (prop) => (
|
|
4513
|
+
<input
|
|
4514
|
+
className="st-cp-fin"
|
|
4515
|
+
key={`${prop}:${authored[prop] ?? ''}`}
|
|
4516
|
+
aria-label={prop}
|
|
4517
|
+
defaultValue={authored[prop] ?? ''}
|
|
4518
|
+
placeholder={cssHint(computed[prop]) || '—'}
|
|
4519
|
+
onKeyDown={(e) => {
|
|
4520
|
+
if (e.key === 'Enter') e.currentTarget.blur();
|
|
4521
|
+
}}
|
|
4522
|
+
onBlur={(e) => commit(prop, e.currentTarget.value)}
|
|
4523
|
+
/>
|
|
4524
|
+
);
|
|
4525
|
+
|
|
4526
|
+
// number + steppers + unit-select (+ optional token quick-pick after)
|
|
4527
|
+
const num = (prop, tokenList, opts = {}) => {
|
|
4528
|
+
const cur = cssSplitUnit(authored[prop] ?? '');
|
|
4529
|
+
// Unitless CSS properties — a bare number must commit WITHOUT a unit suffix
|
|
4530
|
+
// (line-height: 1.5px ≠ 1.5 — knob-smoke finding, 2026-06-12).
|
|
4531
|
+
const unitless = CSS_UNITLESS.has(prop);
|
|
4532
|
+
const unit = unitless ? '' : cur.unit && cur.unit !== 'auto' ? cur.unit : 'px';
|
|
4533
|
+
const bump = (d) => {
|
|
4534
|
+
const base = Number.parseFloat(cur.n || cssHint(computed[prop]) || '0') || 0;
|
|
4535
|
+
commit(prop, `${Math.round((base + d) * 100) / 100}${unit}`);
|
|
4536
|
+
};
|
|
4537
|
+
const lead = PROP_LEAD[prop];
|
|
4538
|
+
return (
|
|
4539
|
+
<>
|
|
4540
|
+
<div className="st-cp-num">
|
|
4541
|
+
{lead ? (
|
|
4542
|
+
<span className="st-cp-numlead" aria-hidden="true">
|
|
4543
|
+
{lead.t ? lead.t : <StIcon name={lead.icon} size={12} />}
|
|
4544
|
+
</span>
|
|
4545
|
+
) : null}
|
|
4546
|
+
<input
|
|
4547
|
+
className="st-cp-numin st-cp-scrub"
|
|
4548
|
+
key={`${prop}:${authored[prop] ?? ''}`}
|
|
4549
|
+
aria-label={prop}
|
|
4550
|
+
defaultValue={cur.unit && cur.unit !== '' ? cur.n : (authored[prop] ?? '')}
|
|
4551
|
+
placeholder={cssHint(computed[prop]) || '—'}
|
|
4552
|
+
onPointerDown={makeScrub(prop, { unitless, unit, min: opts.min })}
|
|
4553
|
+
onKeyDown={(e) => {
|
|
4554
|
+
if (e.key === 'Enter') e.currentTarget.blur();
|
|
4555
|
+
}}
|
|
4556
|
+
onBlur={(e) => {
|
|
4557
|
+
const raw = e.currentTarget.value.trim();
|
|
4558
|
+
if (!raw) return;
|
|
4559
|
+
commit(prop, /[a-z%(]/i.test(raw) ? raw : `${raw}${unit}`);
|
|
4560
|
+
}}
|
|
4561
|
+
/>
|
|
4562
|
+
<span className="st-cp-step">
|
|
4563
|
+
<button
|
|
4564
|
+
type="button"
|
|
4565
|
+
className="st-cp-stepb"
|
|
4566
|
+
tabIndex={-1}
|
|
4567
|
+
aria-label={`increase ${prop}`}
|
|
4568
|
+
onClick={() => bump(1)}
|
|
4569
|
+
>
|
|
4570
|
+
▲
|
|
4571
|
+
</button>
|
|
4572
|
+
<button
|
|
4573
|
+
type="button"
|
|
4574
|
+
className="st-cp-stepb"
|
|
4575
|
+
tabIndex={-1}
|
|
4576
|
+
aria-label={`decrease ${prop}`}
|
|
4577
|
+
onClick={() => bump(-1)}
|
|
4578
|
+
>
|
|
4579
|
+
▼
|
|
4580
|
+
</button>
|
|
4581
|
+
</span>
|
|
4582
|
+
{unitless ? null : (
|
|
4583
|
+
<select
|
|
4584
|
+
className="st-cp-unitsel"
|
|
4585
|
+
aria-label={`${prop} unit`}
|
|
4586
|
+
value={cur.unit || 'px'}
|
|
4587
|
+
onChange={(e) =>
|
|
4588
|
+
commit(prop, e.target.value === 'auto' ? 'auto' : `${cur.n || '0'}${e.target.value}`)
|
|
4589
|
+
}
|
|
4590
|
+
>
|
|
4591
|
+
{CSS_UNITS.map((u) => (
|
|
4592
|
+
<option key={u} value={u}>
|
|
4593
|
+
{u}
|
|
4594
|
+
</option>
|
|
4595
|
+
))}
|
|
4596
|
+
</select>
|
|
4597
|
+
)}
|
|
4598
|
+
</div>
|
|
4599
|
+
{tok(prop, tokenList)}
|
|
4600
|
+
</>
|
|
4601
|
+
);
|
|
4602
|
+
};
|
|
4603
|
+
|
|
4604
|
+
// color swatch (native picker → hex) + raw text + token quick-pick
|
|
4605
|
+
const color = (prop) => {
|
|
4606
|
+
// ONE colour control: the swatch is the trigger for a single popover with a
|
|
4607
|
+
// full HSV picker (Custom) + the DS swatches (Variables). No separate native
|
|
4608
|
+
// OS picker (#6 — was two popovers doing the same thing).
|
|
4609
|
+
const resolved = computed[prop] || authored[prop] || '';
|
|
4610
|
+
return (
|
|
4611
|
+
<>
|
|
4612
|
+
<TokenPopover
|
|
4613
|
+
kind="color"
|
|
4614
|
+
groups={tokenGroups('color')}
|
|
4615
|
+
current={authored[prop]}
|
|
4616
|
+
activeDs={_activeDs}
|
|
4617
|
+
swatchBg={resolved}
|
|
4618
|
+
seedHex={cssColorToHex(computed[prop] || authored[prop]) || '#000000'}
|
|
4619
|
+
onPick={(v) => commit(prop, v)}
|
|
4620
|
+
label={`${prop} colour`}
|
|
4621
|
+
/>
|
|
4622
|
+
{text(prop)}
|
|
4623
|
+
</>
|
|
4624
|
+
);
|
|
4625
|
+
};
|
|
4626
|
+
|
|
4627
|
+
// a box-model side input (margin/padding longhand). Phase 12.3 — Webflow-style:
|
|
4628
|
+
// always shows the RESOLVED value (0 instead of blank) and a faint `is-zero`
|
|
4629
|
+
// styling for an unset/zero side. Edits the single side (the old "link all
|
|
4630
|
+
// sides" toggle was removed — DDR-104 Phase 12.3 W1.5).
|
|
4631
|
+
const side = (prop, group) => {
|
|
4632
|
+
const a = authored[prop];
|
|
4633
|
+
const shown =
|
|
4634
|
+
a != null && a !== ''
|
|
4635
|
+
? cssSplitUnit(a).n || a
|
|
4636
|
+
: cssSplitUnit(cssHint(computed[prop]) ?? '').n || '0';
|
|
4637
|
+
const isZero = !a || a === '0' || a === '0px' || a === 'auto';
|
|
4638
|
+
// Webflow scrub modifiers — alt = symmetric pair (block for top/bottom,
|
|
4639
|
+
// inline for left/right), alt+shift = all four.
|
|
4640
|
+
const edge = prop.split('-').pop();
|
|
4641
|
+
const pair =
|
|
4642
|
+
edge === 'top' || edge === 'bottom'
|
|
4643
|
+
? [`${group}-top`, `${group}-bottom`]
|
|
4644
|
+
: [`${group}-left`, `${group}-right`];
|
|
4645
|
+
const all = [`${group}-top`, `${group}-right`, `${group}-bottom`, `${group}-left`];
|
|
4646
|
+
return (
|
|
4647
|
+
<input
|
|
4648
|
+
className={`st-cp-boxv st-cp-scrub st-cp-boxv--${group[0]}${prop.split('-').pop()[0]}${
|
|
4649
|
+
isZero ? ' is-zero' : ''
|
|
4650
|
+
}`}
|
|
4651
|
+
key={`${prop}:${a ?? ''}`}
|
|
4652
|
+
aria-label={prop}
|
|
4653
|
+
defaultValue={shown}
|
|
4654
|
+
title="drag to scrub · alt = symmetric · alt+shift = all sides"
|
|
4655
|
+
onPointerDown={makeScrub(prop, { sides: { pair, all } })}
|
|
4656
|
+
onKeyDown={(e) => {
|
|
4657
|
+
if (e.key === 'Enter') e.currentTarget.blur();
|
|
4658
|
+
}}
|
|
4659
|
+
onBlur={(e) => {
|
|
4660
|
+
const raw = e.currentTarget.value.trim();
|
|
4661
|
+
if (!raw) return;
|
|
4662
|
+
const val = /[a-z%]/i.test(raw) ? raw : `${raw}px`;
|
|
4663
|
+
commit(prop, val);
|
|
4664
|
+
}}
|
|
4665
|
+
/>
|
|
4666
|
+
);
|
|
4667
|
+
};
|
|
4668
|
+
|
|
4669
|
+
const corner = (label, prop) => (
|
|
4670
|
+
<label className="st-cp-cornerf">
|
|
4671
|
+
<span>{label}</span>
|
|
4672
|
+
<input
|
|
4673
|
+
key={`${prop}:${authored[prop] ?? ''}`}
|
|
4674
|
+
aria-label={prop}
|
|
4675
|
+
defaultValue={cssSplitUnit(authored[prop] ?? '').n || ''}
|
|
4676
|
+
placeholder={cssHint(computed[prop]) || '0'}
|
|
4677
|
+
onKeyDown={(e) => {
|
|
4678
|
+
if (e.key === 'Enter') e.currentTarget.blur();
|
|
4679
|
+
}}
|
|
4680
|
+
onBlur={(e) => {
|
|
4681
|
+
const raw = e.currentTarget.value.trim();
|
|
4682
|
+
if (raw) commit(prop, /[a-z%]/i.test(raw) ? raw : `${raw}px`);
|
|
4683
|
+
}}
|
|
4684
|
+
/>
|
|
4685
|
+
</label>
|
|
4686
|
+
);
|
|
4687
|
+
|
|
4688
|
+
// Phase 12.3 — authored inline props with no curated row + custom HTML attrs,
|
|
4689
|
+
// surfaced in Advanced so the user can see/edit/remove what they added.
|
|
4690
|
+
const customStyleRows = Object.entries(customStyles);
|
|
4691
|
+
const attrRows = Object.entries(attrs);
|
|
4692
|
+
|
|
4693
|
+
return (
|
|
4694
|
+
<div className="st-cp" key={el.id} data-tour="css-panel">
|
|
4695
|
+
<div className="st-cp-id">
|
|
4696
|
+
<span className="st-cp-idtag">
|
|
4697
|
+
{el.tag || 'element'}
|
|
4698
|
+
{el.classes ? <span className="st-cp-idcls">.{el.classes.split(/\s+/)[0]}</span> : null}
|
|
4699
|
+
</span>
|
|
4700
|
+
<span className="st-cp-idmeta">inline style</span>
|
|
4701
|
+
</div>
|
|
4702
|
+
|
|
4703
|
+
{sec(
|
|
4704
|
+
'Layout',
|
|
4705
|
+
<>
|
|
4706
|
+
{row('display', csel('display', CSS_DISPLAYS))}
|
|
4707
|
+
{row('flex-direction', csel('flex-direction', CSS_FLEX_DIR))}
|
|
4708
|
+
{row('align-items', csel('align-items', CSS_ALIGN))}
|
|
4709
|
+
{row('justify-content', csel('justify-content', CSS_JUSTIFY))}
|
|
4710
|
+
{row('gap', num('gap', 'space'))}
|
|
4711
|
+
</>
|
|
4712
|
+
)}
|
|
4713
|
+
|
|
4714
|
+
{sec(
|
|
4715
|
+
'Typography',
|
|
4716
|
+
<>
|
|
4717
|
+
{row('font-family', csel('font-family', CSS_FONTS))}
|
|
4718
|
+
{row('color', color('color'))}
|
|
4719
|
+
{row('font-size', num('font-size', 'type'))}
|
|
4720
|
+
{row('font-weight', csel('font-weight', CSS_WEIGHTS))}
|
|
4721
|
+
{row('line-height', num('line-height', 'lh'))}
|
|
4722
|
+
{row('letter-spacing', num('letter-spacing', null, { min: -Infinity }))}
|
|
4723
|
+
{row(
|
|
4724
|
+
'text-align',
|
|
4725
|
+
<div className="st-cp-seg" role="group" aria-label="text-align">
|
|
4726
|
+
{CSS_ALIGN_OPTS.map((a) => (
|
|
4727
|
+
<button
|
|
4728
|
+
key={a}
|
|
4729
|
+
type="button"
|
|
4730
|
+
className={`st-cp-segbtn${(authored['text-align'] || computed['text-align']) === a ? ' is-active' : ''}`}
|
|
4731
|
+
aria-label={`align ${a}`}
|
|
4732
|
+
aria-pressed={(authored['text-align'] || computed['text-align']) === a}
|
|
4733
|
+
onClick={() => commit('text-align', a)}
|
|
4734
|
+
>
|
|
4735
|
+
<span className={`st-cp-bars st-cp-bars--${a === 'justify' ? 'just' : a}`} aria-hidden="true">
|
|
4736
|
+
<i />
|
|
4737
|
+
<i />
|
|
4738
|
+
<i />
|
|
4739
|
+
</span>
|
|
4740
|
+
</button>
|
|
4741
|
+
))}
|
|
4742
|
+
</div>
|
|
4743
|
+
)}
|
|
4744
|
+
</>
|
|
4745
|
+
)}
|
|
4746
|
+
|
|
4747
|
+
{sec(
|
|
4748
|
+
'Spacing',
|
|
4749
|
+
<>
|
|
4750
|
+
<div className="st-cp-box" aria-label="margin and padding">
|
|
4751
|
+
<span className="st-cp-boxtag st-cp-boxtag--m">
|
|
4752
|
+
{prov(provOf('margin-top'))}margin
|
|
4753
|
+
</span>
|
|
4754
|
+
{side('margin-top', 'margin')}
|
|
4755
|
+
{side('margin-right', 'margin')}
|
|
4756
|
+
{side('margin-bottom', 'margin')}
|
|
4757
|
+
{side('margin-left', 'margin')}
|
|
4758
|
+
<div className="st-cp-boxpad">
|
|
4759
|
+
<span className="st-cp-boxtag st-cp-boxtag--p">
|
|
4760
|
+
{prov(provOf('padding-top'))}padding
|
|
4761
|
+
</span>
|
|
4762
|
+
{side('padding-top', 'padding')}
|
|
4763
|
+
{side('padding-right', 'padding')}
|
|
4764
|
+
{side('padding-bottom', 'padding')}
|
|
4765
|
+
{side('padding-left', 'padding')}
|
|
4766
|
+
<div className="st-cp-boxcore">
|
|
4767
|
+
{Math.round(el.bounds?.w || 0)} × {Math.round(el.bounds?.h || 0)}
|
|
4768
|
+
</div>
|
|
4769
|
+
</div>
|
|
4770
|
+
</div>
|
|
4771
|
+
</>
|
|
4772
|
+
)}
|
|
4773
|
+
|
|
4774
|
+
{sec(
|
|
4775
|
+
'Size',
|
|
4776
|
+
<>
|
|
4777
|
+
{row('width', num('width'))}
|
|
4778
|
+
{row('height', num('height'))}
|
|
4779
|
+
{row('max-width', num('max-width'))}
|
|
4780
|
+
</>
|
|
4781
|
+
)}
|
|
4782
|
+
|
|
4783
|
+
{sec(
|
|
4784
|
+
'Appearance',
|
|
4785
|
+
<>
|
|
4786
|
+
{row('background-color', color('background-color'))}
|
|
4787
|
+
<div className="st-cp-row">
|
|
4788
|
+
{prov(provOf('border-radius'))}
|
|
4789
|
+
<label className="st-cp-label" title="border-radius">
|
|
4790
|
+
border-radius
|
|
4791
|
+
</label>
|
|
4792
|
+
<div className="st-cp-ctl">
|
|
4793
|
+
{num('border-radius', 'radius')}
|
|
4794
|
+
<button
|
|
4795
|
+
type="button"
|
|
4796
|
+
className={`st-cp-split${split ? ' is-on' : ''}`}
|
|
4797
|
+
aria-pressed={split}
|
|
4798
|
+
aria-label="set each corner separately"
|
|
4799
|
+
title="set each corner separately"
|
|
4800
|
+
onClick={() => setSplit((v) => !v)}
|
|
4801
|
+
/>
|
|
4802
|
+
</div>
|
|
4803
|
+
</div>
|
|
4804
|
+
{split ? (
|
|
4805
|
+
<div className="st-cp-corners" aria-label="per-corner radius">
|
|
4806
|
+
{corner('TL', 'border-top-left-radius')}
|
|
4807
|
+
{corner('TR', 'border-top-right-radius')}
|
|
4808
|
+
{corner('BL', 'border-bottom-left-radius')}
|
|
4809
|
+
{corner('BR', 'border-bottom-right-radius')}
|
|
4810
|
+
</div>
|
|
4811
|
+
) : null}
|
|
4812
|
+
{row(
|
|
4813
|
+
'border',
|
|
4814
|
+
<div className="st-cp-border">
|
|
4815
|
+
{num('border-width')}
|
|
4816
|
+
<select
|
|
4817
|
+
className="st-cp-nsel st-cp-nsel--mini"
|
|
4818
|
+
aria-label="border-style"
|
|
4819
|
+
value={CSS_BORDER_STYLES.includes(authored['border-style']) ? authored['border-style'] : ''}
|
|
4820
|
+
onChange={(e) => commit('border-style', e.target.value)}
|
|
4821
|
+
>
|
|
4822
|
+
<option value="" disabled>
|
|
4823
|
+
style
|
|
4824
|
+
</option>
|
|
4825
|
+
{CSS_BORDER_STYLES.map((s) => (
|
|
4826
|
+
<option key={s} value={s}>
|
|
4827
|
+
{s}
|
|
4828
|
+
</option>
|
|
4829
|
+
))}
|
|
4830
|
+
</select>
|
|
4831
|
+
<TokenPopover
|
|
4832
|
+
kind="color"
|
|
4833
|
+
groups={tokenGroups('color')}
|
|
4834
|
+
current={authored['border-color']}
|
|
4835
|
+
activeDs={_activeDs}
|
|
4836
|
+
swatchBg={computed['border-color'] || authored['border-color'] || ''}
|
|
4837
|
+
seedHex={
|
|
4838
|
+
cssColorToHex(computed['border-color'] || authored['border-color']) || '#000000'
|
|
4839
|
+
}
|
|
4840
|
+
onPick={(v) => commit('border-color', v)}
|
|
4841
|
+
label="border colour"
|
|
4842
|
+
/>
|
|
4843
|
+
</div>,
|
|
4844
|
+
provOf('border-width')
|
|
4845
|
+
)}
|
|
4846
|
+
{row('box-shadow', tok('box-shadow', 'shadow') || text('box-shadow'))}
|
|
4847
|
+
{row(
|
|
4848
|
+
'opacity',
|
|
4849
|
+
<div className="st-cp-num">
|
|
4850
|
+
<span className="st-cp-numlead" aria-hidden="true">
|
|
4851
|
+
<StIcon name="p-opacity" size={12} />
|
|
4852
|
+
</span>
|
|
4853
|
+
<input
|
|
4854
|
+
className="st-cp-numin"
|
|
4855
|
+
key={`opacity:${authored.opacity ?? ''}`}
|
|
4856
|
+
aria-label="opacity"
|
|
4857
|
+
defaultValue={authored.opacity ?? ''}
|
|
4858
|
+
placeholder={cssHint(computed.opacity) || '1'}
|
|
4859
|
+
onKeyDown={(e) => {
|
|
4860
|
+
if (e.key === 'Enter') e.currentTarget.blur();
|
|
4861
|
+
}}
|
|
4862
|
+
onBlur={(e) => commit('opacity', e.currentTarget.value)}
|
|
4863
|
+
/>
|
|
4864
|
+
</div>
|
|
4865
|
+
)}
|
|
4866
|
+
</>
|
|
4867
|
+
)}
|
|
4868
|
+
|
|
4869
|
+
{/* #5 — the idle/saved status now lives in each row's leading dot (a glow),
|
|
4870
|
+
so the panel no longer carries a confusing standing 'written to source'
|
|
4871
|
+
line. Only a hard ERROR surfaces here, with the failing property. */}
|
|
4872
|
+
{(() => {
|
|
4873
|
+
const err = Object.entries(status).find(
|
|
4874
|
+
([, s]) => typeof s === 'string' && s.startsWith('err:')
|
|
4875
|
+
);
|
|
4876
|
+
return err ? (
|
|
4877
|
+
<div className="st-cp-save is-err" role="status">
|
|
4878
|
+
<StIcon name="x" size={12} />
|
|
4879
|
+
{err[0]}: {err[1].slice(4)}
|
|
4880
|
+
</div>
|
|
4881
|
+
) : null;
|
|
4882
|
+
})()}
|
|
4883
|
+
|
|
4884
|
+
{sec(
|
|
4885
|
+
'Advanced',
|
|
4886
|
+
<div className="st-cp-advbody">
|
|
4887
|
+
{customStyleRows.length ? (
|
|
4888
|
+
<>
|
|
4889
|
+
<div className="st-cp-advgrp">Custom CSS properties</div>
|
|
4890
|
+
{customStyleRows.map(([p, v]) => (
|
|
4891
|
+
<div className="st-cp-kv" key={`cs:${p}`}>
|
|
4892
|
+
<input
|
|
4893
|
+
className="st-cp-fin st-cp-fin--ro"
|
|
4894
|
+
readOnly
|
|
4895
|
+
value={p}
|
|
4896
|
+
aria-label={`custom property ${p} name`}
|
|
4897
|
+
/>
|
|
4898
|
+
<input
|
|
4899
|
+
className="st-cp-fin"
|
|
4900
|
+
key={`cs:${p}:${v}`}
|
|
4901
|
+
defaultValue={v}
|
|
4902
|
+
aria-label={`${p} value`}
|
|
4903
|
+
onKeyDown={(e) => {
|
|
4904
|
+
if (e.key === 'Enter') e.currentTarget.blur();
|
|
4905
|
+
}}
|
|
4906
|
+
onBlur={(e) => commitCustom(p, e.currentTarget.value)}
|
|
4907
|
+
/>
|
|
4908
|
+
<button
|
|
4909
|
+
type="button"
|
|
4910
|
+
className="st-cp-kvx"
|
|
4911
|
+
aria-label={`remove ${p}`}
|
|
4912
|
+
title="remove"
|
|
4913
|
+
onClick={() => resetCustom(p)}
|
|
4914
|
+
>
|
|
4915
|
+
<StIcon name="x" size={11} />
|
|
4916
|
+
</button>
|
|
4917
|
+
</div>
|
|
4918
|
+
))}
|
|
4919
|
+
</>
|
|
4920
|
+
) : null}
|
|
4921
|
+
<div className="st-cp-advgrp">Add CSS property</div>
|
|
4922
|
+
<RawKnob commit={commitCustom} />
|
|
4923
|
+
<div className="st-cp-note">applied as-is — not token-bound</div>
|
|
4924
|
+
{attrRows.length ? (
|
|
4925
|
+
<>
|
|
4926
|
+
<div className="st-cp-advgrp">Custom HTML attributes</div>
|
|
4927
|
+
{attrRows.map(([a, v]) => (
|
|
4928
|
+
<div className="st-cp-kv" key={`at:${a}`}>
|
|
4929
|
+
<input
|
|
4930
|
+
className="st-cp-fin st-cp-fin--ro"
|
|
4931
|
+
readOnly
|
|
4932
|
+
value={a}
|
|
4933
|
+
aria-label={`attribute ${a} name`}
|
|
4934
|
+
/>
|
|
4935
|
+
<input
|
|
4936
|
+
className="st-cp-fin"
|
|
4937
|
+
key={`at:${a}:${v}`}
|
|
4938
|
+
defaultValue={v}
|
|
4939
|
+
aria-label={`${a} value`}
|
|
4940
|
+
onKeyDown={(e) => {
|
|
4941
|
+
if (e.key === 'Enter') e.currentTarget.blur();
|
|
4942
|
+
}}
|
|
4943
|
+
onBlur={(e) => commitAttr(a, e.currentTarget.value)}
|
|
4944
|
+
/>
|
|
4945
|
+
<button
|
|
4946
|
+
type="button"
|
|
4947
|
+
className="st-cp-kvx"
|
|
4948
|
+
aria-label={`remove ${a}`}
|
|
4949
|
+
title="remove"
|
|
4950
|
+
onClick={() => resetAttr(a)}
|
|
4951
|
+
>
|
|
4952
|
+
<StIcon name="x" size={11} />
|
|
4953
|
+
</button>
|
|
4954
|
+
</div>
|
|
4955
|
+
))}
|
|
4956
|
+
</>
|
|
4957
|
+
) : null}
|
|
4958
|
+
<div className="st-cp-advgrp">Add HTML attribute</div>
|
|
4959
|
+
<AttrKnob commit={commitAttr} />
|
|
4960
|
+
</div>
|
|
4961
|
+
)}
|
|
4962
|
+
|
|
4963
|
+
<div className="st-cp-legend">
|
|
4964
|
+
<span>
|
|
4965
|
+
<i className="st-cp-prov st-cp-prov--bound" aria-hidden="true" />
|
|
4966
|
+
token
|
|
4967
|
+
</span>
|
|
4968
|
+
<span>
|
|
4969
|
+
<i className="st-cp-prov st-cp-prov--raw" aria-hidden="true" />
|
|
4970
|
+
override
|
|
4971
|
+
</span>
|
|
4972
|
+
<span>
|
|
4973
|
+
<i className="st-cp-prov st-cp-prov--inherit" aria-hidden="true" />
|
|
4974
|
+
inherited
|
|
4975
|
+
</span>
|
|
4976
|
+
</div>
|
|
4977
|
+
</div>
|
|
4978
|
+
);
|
|
4979
|
+
}
|
|
4980
|
+
|
|
4981
|
+
// Custom CSS property hatch — writes an arbitrary `property: value` to inline style.
|
|
4982
|
+
function RawKnob({ commit }) {
|
|
4983
|
+
const [prop, setProp] = useState('');
|
|
4984
|
+
const [val, setVal] = useState('');
|
|
4985
|
+
const submit = () => {
|
|
4986
|
+
if (prop.trim() && val.trim()) {
|
|
4987
|
+
commit(prop.trim(), val);
|
|
4988
|
+
setProp('');
|
|
4989
|
+
setVal('');
|
|
4990
|
+
}
|
|
4991
|
+
};
|
|
4992
|
+
return (
|
|
4993
|
+
<div className="st-cp-kv">
|
|
4994
|
+
<input
|
|
4995
|
+
className="st-cp-fin"
|
|
4996
|
+
aria-label="custom property name"
|
|
4997
|
+
placeholder="property"
|
|
4998
|
+
value={prop}
|
|
4999
|
+
onChange={(e) => setProp(e.target.value)}
|
|
5000
|
+
/>
|
|
5001
|
+
<input
|
|
5002
|
+
className="st-cp-fin"
|
|
5003
|
+
aria-label="custom property value"
|
|
5004
|
+
placeholder="value"
|
|
5005
|
+
value={val}
|
|
5006
|
+
onChange={(e) => setVal(e.target.value)}
|
|
5007
|
+
onKeyDown={(e) => {
|
|
5008
|
+
if (e.key === 'Enter') submit();
|
|
5009
|
+
}}
|
|
5010
|
+
onBlur={submit}
|
|
5011
|
+
/>
|
|
5012
|
+
</div>
|
|
5013
|
+
);
|
|
5014
|
+
}
|
|
5015
|
+
|
|
5016
|
+
// Custom HTML attribute hatch — writes a plain JSX attribute (data-*, aria-*, …).
|
|
5017
|
+
function AttrKnob({ commit }) {
|
|
5018
|
+
const [attr, setAttr] = useState('');
|
|
5019
|
+
const [val, setVal] = useState('');
|
|
5020
|
+
const submit = () => {
|
|
5021
|
+
if (attr.trim() && val.trim()) {
|
|
5022
|
+
commit(attr.trim(), val);
|
|
5023
|
+
setAttr('');
|
|
5024
|
+
setVal('');
|
|
5025
|
+
}
|
|
5026
|
+
};
|
|
5027
|
+
return (
|
|
5028
|
+
<div className="st-cp-kv">
|
|
5029
|
+
<input
|
|
5030
|
+
className="st-cp-fin"
|
|
5031
|
+
aria-label="custom attribute name"
|
|
5032
|
+
placeholder="data-…"
|
|
5033
|
+
value={attr}
|
|
5034
|
+
onChange={(e) => setAttr(e.target.value)}
|
|
5035
|
+
/>
|
|
5036
|
+
<input
|
|
5037
|
+
className="st-cp-fin"
|
|
5038
|
+
aria-label="custom attribute value"
|
|
5039
|
+
placeholder="value"
|
|
5040
|
+
value={val}
|
|
5041
|
+
onChange={(e) => setVal(e.target.value)}
|
|
5042
|
+
onKeyDown={(e) => {
|
|
5043
|
+
if (e.key === 'Enter') submit();
|
|
5044
|
+
}}
|
|
5045
|
+
onBlur={submit}
|
|
5046
|
+
/>
|
|
5047
|
+
</div>
|
|
5048
|
+
);
|
|
5049
|
+
}
|
|
5050
|
+
|
|
2795
5051
|
// ---------- Inspector panel (display-only) ----------
|
|
2796
5052
|
//
|
|
2797
5053
|
// T6 (Plan C) — right-dock Inspect / Layers / CSS tabs per `.design/ui/Studio.tsx`
|
|
@@ -2800,8 +5056,230 @@ function SyncBanner({ status }) {
|
|
|
2800
5056
|
// mockup's live-CSS-knob WRITEBACK is Phase 12 (needs a canvas-origin write
|
|
2801
5057
|
// bridge, DDR-054) — the CSS tab shows markup read-only + keeps that callout, so
|
|
2802
5058
|
// it never implies functionality it lacks (the exact reason DDR-096 deferred it).
|
|
2803
|
-
|
|
2804
|
-
|
|
5059
|
+
// ---------- Layers tree row (Phase 12 Task 4) ----------
|
|
5060
|
+
// Phase 12.3 (W3.1) — map a LayerNode `type` (classified in canvas-shell) to a
|
|
5061
|
+
// type-distinct icon, matching the Studio.tsx layers design.
|
|
5062
|
+
const LAYER_TYPE_ICON = {
|
|
5063
|
+
button: 'button',
|
|
5064
|
+
heading: 'type',
|
|
5065
|
+
text: 'type',
|
|
5066
|
+
input: 'input',
|
|
5067
|
+
form: 'input',
|
|
5068
|
+
image: 'image',
|
|
5069
|
+
link: 'link',
|
|
5070
|
+
list: 'list',
|
|
5071
|
+
nav: 'layers',
|
|
5072
|
+
box: 'box',
|
|
5073
|
+
};
|
|
5074
|
+
|
|
5075
|
+
function LayerRow({
|
|
5076
|
+
node,
|
|
5077
|
+
depth,
|
|
5078
|
+
selectedId,
|
|
5079
|
+
collapsed,
|
|
5080
|
+
hidden,
|
|
5081
|
+
onToggle,
|
|
5082
|
+
onSelect,
|
|
5083
|
+
onHover,
|
|
5084
|
+
onToggleVisibility,
|
|
5085
|
+
}) {
|
|
5086
|
+
const key = `${node.id}:${node.index}`;
|
|
5087
|
+
const hasKids = node.children && node.children.length > 0;
|
|
5088
|
+
const isCollapsed = collapsed.has(key);
|
|
5089
|
+
const isSel = node.id === selectedId;
|
|
5090
|
+
const isHidden = hidden?.has(key);
|
|
5091
|
+
return (
|
|
5092
|
+
<>
|
|
5093
|
+
<div
|
|
5094
|
+
className={
|
|
5095
|
+
'st-layer st-layer--row' + (isSel ? ' is-sel' : '') + (isHidden ? ' is-hidden' : '')
|
|
5096
|
+
}
|
|
5097
|
+
style={{ paddingLeft: 6 + depth * 14 }}
|
|
5098
|
+
role="treeitem"
|
|
5099
|
+
aria-selected={isSel}
|
|
5100
|
+
aria-expanded={hasKids ? !isCollapsed : undefined}
|
|
5101
|
+
tabIndex={0}
|
|
5102
|
+
title={`${node.tag} · ${node.type}`}
|
|
5103
|
+
onClick={() => onSelect(node)}
|
|
5104
|
+
onMouseEnter={() => onHover(node)}
|
|
5105
|
+
onMouseLeave={() => onHover(null)}
|
|
5106
|
+
onKeyDown={(e) => {
|
|
5107
|
+
if (e.key === 'Enter' || e.key === ' ') {
|
|
5108
|
+
e.preventDefault();
|
|
5109
|
+
onSelect(node);
|
|
5110
|
+
}
|
|
5111
|
+
}}
|
|
5112
|
+
>
|
|
5113
|
+
{hasKids ? (
|
|
5114
|
+
<button
|
|
5115
|
+
type="button"
|
|
5116
|
+
className="st-layer-caret"
|
|
5117
|
+
aria-label={isCollapsed ? 'Expand' : 'Collapse'}
|
|
5118
|
+
onClick={(e) => {
|
|
5119
|
+
e.stopPropagation();
|
|
5120
|
+
onToggle(key);
|
|
5121
|
+
}}
|
|
5122
|
+
>
|
|
5123
|
+
{isCollapsed ? '▸' : '▾'}
|
|
5124
|
+
</button>
|
|
5125
|
+
) : (
|
|
5126
|
+
<span className="st-layer-caret" aria-hidden="true" />
|
|
5127
|
+
)}
|
|
5128
|
+
<StIcon name={LAYER_TYPE_ICON[node.type] || 'box'} size={12} className="st-layer-ticon" />
|
|
5129
|
+
<span className="st-layer-label">{node.label}</span>
|
|
5130
|
+
<span className="st-layer-type">{node.type}</span>
|
|
5131
|
+
{onToggleVisibility ? (
|
|
5132
|
+
<button
|
|
5133
|
+
type="button"
|
|
5134
|
+
className="st-layer-eye"
|
|
5135
|
+
aria-label={isHidden ? `Show ${node.label}` : `Hide ${node.label}`}
|
|
5136
|
+
aria-pressed={isHidden}
|
|
5137
|
+
title={isHidden ? 'Show' : 'Hide'}
|
|
5138
|
+
onClick={(e) => {
|
|
5139
|
+
e.stopPropagation();
|
|
5140
|
+
onToggleVisibility(node);
|
|
5141
|
+
}}
|
|
5142
|
+
>
|
|
5143
|
+
<StIcon name={isHidden ? 'eye-off' : 'eye'} size={13} />
|
|
5144
|
+
</button>
|
|
5145
|
+
) : null}
|
|
5146
|
+
</div>
|
|
5147
|
+
{hasKids && !isCollapsed
|
|
5148
|
+
? node.children.map((c) => (
|
|
5149
|
+
<LayerRow
|
|
5150
|
+
key={`${c.id}:${c.index}`}
|
|
5151
|
+
node={c}
|
|
5152
|
+
depth={depth + 1}
|
|
5153
|
+
selectedId={selectedId}
|
|
5154
|
+
collapsed={collapsed}
|
|
5155
|
+
hidden={hidden}
|
|
5156
|
+
onToggle={onToggle}
|
|
5157
|
+
onSelect={onSelect}
|
|
5158
|
+
onHover={onHover}
|
|
5159
|
+
onToggleVisibility={onToggleVisibility}
|
|
5160
|
+
/>
|
|
5161
|
+
))
|
|
5162
|
+
: null}
|
|
5163
|
+
</>
|
|
5164
|
+
);
|
|
5165
|
+
}
|
|
5166
|
+
|
|
5167
|
+
// Phase 12.3 — live computed readout for the Inspect tab (replaces the stale
|
|
5168
|
+
// "lands with the live CSS bridge (Phase 12)" callout — that bridge shipped).
|
|
5169
|
+
// Reads the resolved values the selection already carries (dom-selection
|
|
5170
|
+
// styleMapsFor → el.computed). Read-only; the CSS tab is where you edit.
|
|
5171
|
+
function InspectComputed({ el }) {
|
|
5172
|
+
const c = el?.computed || {};
|
|
5173
|
+
const a = el?.authored || {};
|
|
5174
|
+
// Prefer the authored token name (var(--accent) → "--accent") as the label;
|
|
5175
|
+
// fall back to the resolved value. The swatch always shows the RESOLVED color.
|
|
5176
|
+
const valueLabel = (prop) => {
|
|
5177
|
+
const av = a[prop];
|
|
5178
|
+
if (av && /var\(\s*--/.test(av)) return av.replace(/^var\(\s*|\s*\)$/g, '');
|
|
5179
|
+
return c[prop] || av || '';
|
|
5180
|
+
};
|
|
5181
|
+
const colorRow = (lbl, prop) => {
|
|
5182
|
+
const resolved = c[prop] || a[prop];
|
|
5183
|
+
if (!resolved) return null;
|
|
5184
|
+
return (
|
|
5185
|
+
<div className="st-insp-row" key={lbl}>
|
|
5186
|
+
<span className="st-insp-label">{lbl}</span>
|
|
5187
|
+
<div className="st-swatch-row">
|
|
5188
|
+
<span className="st-insp-swatch" style={{ background: resolved }} aria-hidden="true" />
|
|
5189
|
+
<span className="st-mono" style={{ fontSize: 11, color: 'var(--fg-1)' }}>
|
|
5190
|
+
{valueLabel(prop)}
|
|
5191
|
+
</span>
|
|
5192
|
+
</div>
|
|
5193
|
+
</div>
|
|
5194
|
+
);
|
|
5195
|
+
};
|
|
5196
|
+
const hasRadius = c['border-radius'] && c['border-radius'] !== '0px';
|
|
5197
|
+
const radiusN = hasRadius ? cssSplitUnit(c['border-radius']).n || c['border-radius'] : null;
|
|
5198
|
+
const font =
|
|
5199
|
+
c['font-size'] || c['font-weight']
|
|
5200
|
+
? [c['font-size'], c['font-weight']].filter(Boolean).join(' / ')
|
|
5201
|
+
: null;
|
|
5202
|
+
const anyType = c['background-color'] || c.color || hasRadius || font;
|
|
5203
|
+
if (!anyType) return null;
|
|
5204
|
+
return (
|
|
5205
|
+
<>
|
|
5206
|
+
{hasRadius ? (
|
|
5207
|
+
<div className="st-insp-row">
|
|
5208
|
+
<span className="st-insp-label">Radius</span>
|
|
5209
|
+
<div className="st-insp-fields">
|
|
5210
|
+
<span className="st-fmini" style={{ flex: '0 0 auto', maxWidth: 84 }}>
|
|
5211
|
+
<span className="st-mtag">r</span>
|
|
5212
|
+
<input value={radiusN} readOnly aria-label="border radius" />
|
|
5213
|
+
</span>
|
|
5214
|
+
<span className="st-insp-unit">px</span>
|
|
5215
|
+
</div>
|
|
5216
|
+
</div>
|
|
5217
|
+
) : null}
|
|
5218
|
+
{colorRow('Fill', 'background-color')}
|
|
5219
|
+
{colorRow('Text', 'color')}
|
|
5220
|
+
{font ? (
|
|
5221
|
+
<div className="st-insp-row">
|
|
5222
|
+
<span className="st-insp-label">Font</span>
|
|
5223
|
+
<div className="st-insp-fields">
|
|
5224
|
+
<span className="st-mono" style={{ fontSize: 11, color: 'var(--fg-0)' }}>{font}</span>
|
|
5225
|
+
</div>
|
|
5226
|
+
</div>
|
|
5227
|
+
) : null}
|
|
5228
|
+
</>
|
|
5229
|
+
);
|
|
5230
|
+
}
|
|
5231
|
+
|
|
5232
|
+
function InspectorPanel({
|
|
5233
|
+
selected,
|
|
5234
|
+
onClose,
|
|
5235
|
+
layersTree,
|
|
5236
|
+
onSelectLayer,
|
|
5237
|
+
onHoverLayer,
|
|
5238
|
+
cfg,
|
|
5239
|
+
onOptimistic,
|
|
5240
|
+
tab: tabProp,
|
|
5241
|
+
onTabChange,
|
|
5242
|
+
width,
|
|
5243
|
+
resizing,
|
|
5244
|
+
}) {
|
|
5245
|
+
// Tab is controllable from the parent (the guided tour drives it to 'css' /
|
|
5246
|
+
// 'layers' so a spotlight step lands on a real row) but falls back to local
|
|
5247
|
+
// state for normal use. A user click both updates local state and notifies the
|
|
5248
|
+
// parent, so the two stay in lockstep whichever owns it.
|
|
5249
|
+
const [tabState, setTabState] = useState('inspect');
|
|
5250
|
+
const tab = tabProp ?? tabState;
|
|
5251
|
+
const setTab = (t) => {
|
|
5252
|
+
setTabState(t);
|
|
5253
|
+
onTabChange?.(t);
|
|
5254
|
+
};
|
|
5255
|
+
const [collapsed, setCollapsed] = useState(() => new Set());
|
|
5256
|
+
// Phase 12.3 (W3.1) — per-layer visibility toggle. Live-only (display:none via
|
|
5257
|
+
// the optimistic apply bus); not persisted to source. Keyed by `${id}:${index}`.
|
|
5258
|
+
const [hiddenLayers, setHiddenLayers] = useState(() => new Set());
|
|
5259
|
+
const toggleCollapse = (key) =>
|
|
5260
|
+
setCollapsed((prev) => {
|
|
5261
|
+
const next = new Set(prev);
|
|
5262
|
+
if (next.has(key)) next.delete(key);
|
|
5263
|
+
else next.add(key);
|
|
5264
|
+
return next;
|
|
5265
|
+
});
|
|
5266
|
+
const toggleVisibility = (node) => {
|
|
5267
|
+
const key = `${node.id}:${node.index}`;
|
|
5268
|
+
const willHide = !hiddenLayers.has(key);
|
|
5269
|
+
onOptimistic?.({
|
|
5270
|
+
id: node.id,
|
|
5271
|
+
artboardId: layersTree?.artboardId ?? null,
|
|
5272
|
+
index: node.index,
|
|
5273
|
+
prop: 'display',
|
|
5274
|
+
value: willHide ? 'none' : null,
|
|
5275
|
+
});
|
|
5276
|
+
setHiddenLayers((prev) => {
|
|
5277
|
+
const next = new Set(prev);
|
|
5278
|
+
if (willHide) next.add(key);
|
|
5279
|
+
else next.delete(key);
|
|
5280
|
+
return next;
|
|
5281
|
+
});
|
|
5282
|
+
};
|
|
2805
5283
|
// `selected` may be a single element, an array (multi-select), or null.
|
|
2806
5284
|
const el = Array.isArray(selected) ? selected[0] : selected;
|
|
2807
5285
|
const tabBtn = (id, label, icon) => (
|
|
@@ -2816,8 +5294,13 @@ function InspectorPanel({ selected, onClose }) {
|
|
|
2816
5294
|
);
|
|
2817
5295
|
const b = el?.bounds || null;
|
|
2818
5296
|
return (
|
|
2819
|
-
<aside
|
|
2820
|
-
|
|
5297
|
+
<aside
|
|
5298
|
+
className={'st-rpanel' + (resizing ? ' is-resizing' : '')}
|
|
5299
|
+
style={width ? { width, flexBasis: width } : undefined}
|
|
5300
|
+
aria-label="Inspector"
|
|
5301
|
+
data-tour="inspector"
|
|
5302
|
+
>
|
|
5303
|
+
<div className="st-rp-tabs" data-tour="inspector-tabs">
|
|
2821
5304
|
{tabBtn('inspect', 'Inspect', 'sliders')}
|
|
2822
5305
|
{tabBtn('layers', 'Layers', 'layers')}
|
|
2823
5306
|
{tabBtn('css', 'CSS', 'code')}
|
|
@@ -2834,7 +5317,11 @@ function InspectorPanel({ selected, onClose }) {
|
|
|
2834
5317
|
<div className="st-rp-body">
|
|
2835
5318
|
{!el ? (
|
|
2836
5319
|
<div className="st-rp-empty">
|
|
2837
|
-
|
|
5320
|
+
{/* <p> wrapper — st-rp-empty is a flex column, bare text nodes +
|
|
5321
|
+
kbd would stack as stretched flex items. */}
|
|
5322
|
+
<p>
|
|
5323
|
+
Hold <Kbd>⌘</Kbd> inside the canvas and click an element to inspect it.
|
|
5324
|
+
</p>
|
|
2838
5325
|
</div>
|
|
2839
5326
|
) : tab === 'inspect' ? (
|
|
2840
5327
|
<>
|
|
@@ -2842,26 +5329,26 @@ function InspectorPanel({ selected, onClose }) {
|
|
|
2842
5329
|
<div className="st-insp-row">
|
|
2843
5330
|
<span className="st-insp-label">Pos</span>
|
|
2844
5331
|
<div className="st-insp-fields">
|
|
2845
|
-
<span className="st-
|
|
2846
|
-
<span className="
|
|
2847
|
-
<input
|
|
5332
|
+
<span className="st-fmini">
|
|
5333
|
+
<span className="st-mtag">X</span>
|
|
5334
|
+
<input value={b ? Math.round(b.x) : '—'} readOnly aria-label="x position" />
|
|
2848
5335
|
</span>
|
|
2849
|
-
<span className="st-
|
|
2850
|
-
<span className="
|
|
2851
|
-
<input
|
|
5336
|
+
<span className="st-fmini">
|
|
5337
|
+
<span className="st-mtag">Y</span>
|
|
5338
|
+
<input value={b ? Math.round(b.y) : '—'} readOnly aria-label="y position" />
|
|
2852
5339
|
</span>
|
|
2853
5340
|
</div>
|
|
2854
5341
|
</div>
|
|
2855
5342
|
<div className="st-insp-row">
|
|
2856
5343
|
<span className="st-insp-label">Size</span>
|
|
2857
5344
|
<div className="st-insp-fields">
|
|
2858
|
-
<span className="st-
|
|
2859
|
-
<span className="
|
|
2860
|
-
<input
|
|
5345
|
+
<span className="st-fmini">
|
|
5346
|
+
<span className="st-mtag">W</span>
|
|
5347
|
+
<input value={b ? Math.round(b.w) : '—'} readOnly aria-label="width" />
|
|
2861
5348
|
</span>
|
|
2862
|
-
<span className="st-
|
|
2863
|
-
<span className="
|
|
2864
|
-
<input
|
|
5349
|
+
<span className="st-fmini">
|
|
5350
|
+
<span className="st-mtag">H</span>
|
|
5351
|
+
<input value={b ? Math.round(b.h) : '—'} readOnly aria-label="height" />
|
|
2865
5352
|
</span>
|
|
2866
5353
|
</div>
|
|
2867
5354
|
</div>
|
|
@@ -2883,14 +5370,29 @@ function InspectorPanel({ selected, onClose }) {
|
|
|
2883
5370
|
</div>
|
|
2884
5371
|
</div>
|
|
2885
5372
|
) : null}
|
|
2886
|
-
<
|
|
2887
|
-
Computed fill / radius / type readout lands with the live CSS bridge (Phase 12).
|
|
2888
|
-
</div>
|
|
5373
|
+
<InspectComputed el={el} />
|
|
2889
5374
|
</>
|
|
2890
5375
|
) : tab === 'layers' ? (
|
|
2891
5376
|
<>
|
|
2892
|
-
<div className="st-rp-hd">Layers · ancestry</div>
|
|
2893
|
-
{
|
|
5377
|
+
<div className="st-rp-hd">Layers{layersTree?.nodes?.length ? '' : ' · ancestry'}</div>
|
|
5378
|
+
{layersTree?.nodes?.length ? (
|
|
5379
|
+
<div role="tree" aria-label="Artboard layers">
|
|
5380
|
+
{layersTree.nodes.map((n) => (
|
|
5381
|
+
<LayerRow
|
|
5382
|
+
key={`${n.id}:${n.index}`}
|
|
5383
|
+
node={n}
|
|
5384
|
+
depth={0}
|
|
5385
|
+
selectedId={el.id}
|
|
5386
|
+
collapsed={collapsed}
|
|
5387
|
+
hidden={hiddenLayers}
|
|
5388
|
+
onToggle={toggleCollapse}
|
|
5389
|
+
onSelect={(node) => onSelectLayer?.(node)}
|
|
5390
|
+
onHover={(node) => onHoverLayer?.(node)}
|
|
5391
|
+
onToggleVisibility={toggleVisibility}
|
|
5392
|
+
/>
|
|
5393
|
+
))}
|
|
5394
|
+
</div>
|
|
5395
|
+
) : Array.isArray(el.dom_path) && el.dom_path.length ? (
|
|
2894
5396
|
el.dom_path.map((node, i) => (
|
|
2895
5397
|
<div
|
|
2896
5398
|
key={i}
|
|
@@ -2902,25 +5404,13 @@ function InspectorPanel({ selected, onClose }) {
|
|
|
2902
5404
|
</div>
|
|
2903
5405
|
))
|
|
2904
5406
|
) : (
|
|
2905
|
-
<div className="st-rp-empty">
|
|
5407
|
+
<div className="st-rp-empty">
|
|
5408
|
+
Select an element (⌘-click in the canvas) to see its layer tree.
|
|
5409
|
+
</div>
|
|
2906
5410
|
)}
|
|
2907
5411
|
</>
|
|
2908
5412
|
) : (
|
|
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
|
-
</>
|
|
5413
|
+
<CssKnobs el={el} cfg={cfg} onOptimistic={onOptimistic} />
|
|
2924
5414
|
)}
|
|
2925
5415
|
</div>
|
|
2926
5416
|
</aside>
|
|
@@ -2935,6 +5425,15 @@ function App() {
|
|
|
2935
5425
|
const [tabs, setTabs] = useState([]);
|
|
2936
5426
|
const [activePath, setActivePath] = useState(null);
|
|
2937
5427
|
const [selected, setSelected] = useState(null);
|
|
5428
|
+
// Phase 12.3 — latest selection, readable from the (stale-closure) onMessage
|
|
5429
|
+
// handler so an HMR reload (triggered by a CSS/attr edit) can re-select the
|
|
5430
|
+
// same element and restore the in-canvas halo the remount dropped.
|
|
5431
|
+
const selectedRef = useRef(null);
|
|
5432
|
+
useEffect(() => {
|
|
5433
|
+
selectedRef.current = selected;
|
|
5434
|
+
}, [selected]);
|
|
5435
|
+
// Phase 12 Task 4 — Layers tree for the active artboard (posted by canvas-shell).
|
|
5436
|
+
const [layersTree, setLayersTree] = useState(null);
|
|
2938
5437
|
const [wsConnected, setWsConnected] = useState(false);
|
|
2939
5438
|
// Phase 8 Task 7 — git lifecycle reload prompt. Server has already flushed
|
|
2940
5439
|
// every dirty Y.Doc to disk by the time this state populates, so accepting
|
|
@@ -2943,8 +5442,65 @@ function App() {
|
|
|
2943
5442
|
// Phase 9 Task 8 — hub-down offline mode banner. Driven by the 'sync:status'
|
|
2944
5443
|
// WS message the linked-mode sync runtime emits. null in solo mode.
|
|
2945
5444
|
const [syncStatus, setSyncStatus] = useState(null);
|
|
5445
|
+
// Phase 27 (E2) — in-UI git layer. `gitStatus` is the live dirty-state the
|
|
5446
|
+
// server broadcasts on `git-status`; `changesOpen` toggles the Changes panel;
|
|
5447
|
+
// `diffTarget` opens the before/after DiffView ({ file, conflict }).
|
|
5448
|
+
const [gitStatus, setGitStatus] = useState(null);
|
|
5449
|
+
// Phase 28 (E3) — remote ahead/behind ("Get latest" nudge). Kept in its OWN
|
|
5450
|
+
// slice, NOT folded into `gitStatus`, because the `git-status` WS broadcast
|
|
5451
|
+
// (line ~5791) replaces `gitStatus` on every dirty-state change and carries
|
|
5452
|
+
// only LOCAL status — merging remote-ahead into it would be clobbered on the
|
|
5453
|
+
// next keystroke. The probe is a real network `git fetch` (server-side, token
|
|
5454
|
+
// from the keychain bridge), so it runs on a slow cadence — mount, a periodic
|
|
5455
|
+
// tick, and after each git action — never on the per-edit WS path.
|
|
5456
|
+
const [remoteSync, setRemoteSync] = useState(null); // { remoteAhead, behind } | null
|
|
5457
|
+
const [changesOpen, setChangesOpen] = useState(false);
|
|
5458
|
+
const [diffTarget, setDiffTarget] = useState(null);
|
|
2946
5459
|
const [search, setSearch] = useState('');
|
|
2947
5460
|
const [systemData, setSystemData] = useState(null);
|
|
5461
|
+
// Canvas-compile skeleton (single-canvas model → one path at a time).
|
|
5462
|
+
const [loadingPath, setLoadingPath] = useState(null);
|
|
5463
|
+
const loadFallbackTimer = useRef(null);
|
|
5464
|
+
// Resizable side panels (DS components-resize-panels) + the active drag side.
|
|
5465
|
+
const sbSize = usePanelSize('maude-sb-w', { min: 200, max: 420, def: 252 });
|
|
5466
|
+
const rpSize = usePanelSize('maude-rp-w', { min: 260, max: 480, def: 304 });
|
|
5467
|
+
const [dragSide, setDragSide] = useState(null); // 'sb' | 'rp' | null
|
|
5468
|
+
const bodyRef = useRef(null);
|
|
5469
|
+
|
|
5470
|
+
// Pointer drag for the panel grips — window listeners while dragging (the
|
|
5471
|
+
// grip also pointer-captures, and `.st-body.is-resizing iframe` drops pointer
|
|
5472
|
+
// events so the canvas iframe can't swallow the move stream mid-drag).
|
|
5473
|
+
useEffect(() => {
|
|
5474
|
+
if (!dragSide) return;
|
|
5475
|
+
const onMove = (e) => {
|
|
5476
|
+
const rect = bodyRef.current?.getBoundingClientRect();
|
|
5477
|
+
if (!rect) return;
|
|
5478
|
+
if (dragSide === 'sb') sbSize.setW(e.clientX - rect.left);
|
|
5479
|
+
else rpSize.setW(rect.right - e.clientX);
|
|
5480
|
+
};
|
|
5481
|
+
const onUp = () => setDragSide(null);
|
|
5482
|
+
window.addEventListener('pointermove', onMove);
|
|
5483
|
+
window.addEventListener('pointerup', onUp);
|
|
5484
|
+
return () => {
|
|
5485
|
+
window.removeEventListener('pointermove', onMove);
|
|
5486
|
+
window.removeEventListener('pointerup', onUp);
|
|
5487
|
+
};
|
|
5488
|
+
}, [dragSide, sbSize.setW, rpSize.setW]);
|
|
5489
|
+
|
|
5490
|
+
// Loading-skeleton lifecycle: dgn:'loaded' clears it instantly (TSX canvases);
|
|
5491
|
+
// the iframe load event arms a short fallback for legacy .html canvases that
|
|
5492
|
+
// never post it; a hard cap guards against a canvas that dies mid-compile.
|
|
5493
|
+
const onIframeLoad = useCallback((path) => {
|
|
5494
|
+
clearTimeout(loadFallbackTimer.current);
|
|
5495
|
+
loadFallbackTimer.current = setTimeout(() => {
|
|
5496
|
+
setLoadingPath((p) => (p === path ? null : p));
|
|
5497
|
+
}, 2500);
|
|
5498
|
+
}, []);
|
|
5499
|
+
useEffect(() => {
|
|
5500
|
+
if (!loadingPath) return;
|
|
5501
|
+
const cap = setTimeout(() => setLoadingPath(null), 15000);
|
|
5502
|
+
return () => clearTimeout(cap);
|
|
5503
|
+
}, [loadingPath]);
|
|
2948
5504
|
// Loaded once at boot from /_config — informs canvasUrl() so TSX iframes
|
|
2949
5505
|
// can pass the right ?designRel + ?tokens query to the canvas mount shell.
|
|
2950
5506
|
const [cfg, setCfg] = useState({ designRel: '.design' });
|
|
@@ -2996,6 +5552,20 @@ function App() {
|
|
|
2996
5552
|
cancelled = true;
|
|
2997
5553
|
};
|
|
2998
5554
|
}, []);
|
|
5555
|
+
// Phase 27 (E2) — seed the git dirty-state on mount; live updates arrive over
|
|
5556
|
+
// the `git-status` WS broadcast (Task 5). Solo/non-git projects → repo:false.
|
|
5557
|
+
useEffect(() => {
|
|
5558
|
+
let cancelled = false;
|
|
5559
|
+
fetch('/_api/git/status')
|
|
5560
|
+
.then((r) => r.json())
|
|
5561
|
+
.then((data) => {
|
|
5562
|
+
if (!cancelled && data) setGitStatus(data);
|
|
5563
|
+
})
|
|
5564
|
+
.catch(() => {});
|
|
5565
|
+
return () => {
|
|
5566
|
+
cancelled = true;
|
|
5567
|
+
};
|
|
5568
|
+
}, []);
|
|
2999
5569
|
const [commentsByFile, setCommentsByFile] = useState({}); // { file: [Comment] }
|
|
3000
5570
|
// Phase 6 — the in-iframe composer owns drafting; the shell no longer holds
|
|
3001
5571
|
// a `draft` state. Mutations route through postMessage → WS instead.
|
|
@@ -3008,17 +5578,180 @@ function App() {
|
|
|
3008
5578
|
const [showHidden, setShowHidden] = useState(() => readBoolStore(SHOW_HIDDEN_STORE, false));
|
|
3009
5579
|
const [sectionsExpanded, setSectionsExpanded] = useState(() => readJsonStore(SECTIONS_STORE, {}));
|
|
3010
5580
|
const [helpOpen, setHelpOpen] = useState(false);
|
|
5581
|
+
// ? cheat-sheet (DS components-shortcuts-overlay) — separate from the deep
|
|
5582
|
+
// Help modal (F1), which keeps commands & flows.
|
|
5583
|
+
const [shortcutsOpen, setShortcutsOpen] = useState(false);
|
|
3011
5584
|
const [paletteOpen, setPaletteOpen] = useState(false);
|
|
3012
5585
|
// T5/T6 (Plan C) — shell-level export/handoff dialog + inspector panel state.
|
|
3013
5586
|
// The palette (T4) drives them; the dialog (T5) + panel (T6) consume them.
|
|
3014
5587
|
const [exportDialog, setExportDialog] = useState(null); // null | { mode: 'export'|'handoff', scope? }
|
|
3015
5588
|
const [inspectorOpen, setInspectorOpen] = useState(false);
|
|
5589
|
+
// Phase 31 (DDR-123) — the native ACP chat sidepanel (right dock, native-only).
|
|
5590
|
+
const [assistantOpen, setAssistantOpen] = useState(false);
|
|
5591
|
+
const [assistantBusy, setAssistantBusy] = useState(false);
|
|
5592
|
+
const [assistantUnseen, setAssistantUnseen] = useState(false);
|
|
5593
|
+
const assistantOpenRef = useRef(assistantOpen);
|
|
5594
|
+
useEffect(() => {
|
|
5595
|
+
assistantOpenRef.current = assistantOpen;
|
|
5596
|
+
if (assistantOpen) {
|
|
5597
|
+
setAssistantUnseen(false); // opening clears the unseen badge
|
|
5598
|
+
// Ask for notification permission on a real user gesture (panel open).
|
|
5599
|
+
try {
|
|
5600
|
+
if (typeof Notification !== 'undefined' && Notification.permission === 'default') {
|
|
5601
|
+
Notification.requestPermission();
|
|
5602
|
+
}
|
|
5603
|
+
} catch {
|
|
5604
|
+
/* notifications unavailable */
|
|
5605
|
+
}
|
|
5606
|
+
}
|
|
5607
|
+
}, [assistantOpen]);
|
|
5608
|
+
// ChatPanel owns one connection PER CHAT (so chats run in parallel in the
|
|
5609
|
+
// background) and reports up here: `onBusyChange` for the menubar pulse, and
|
|
5610
|
+
// `onFinished` when any chat's turn ends — badge + notify if you weren't looking.
|
|
5611
|
+
const handleAssistantFinished = useCallback(() => {
|
|
5612
|
+
if (!assistantOpenRef.current || document.hidden) {
|
|
5613
|
+
setAssistantUnseen(true);
|
|
5614
|
+
try {
|
|
5615
|
+
if (typeof Notification !== 'undefined' && Notification.permission === 'granted') {
|
|
5616
|
+
new Notification('Claude finished', { body: 'Your assistant turn is ready in Maude.' });
|
|
5617
|
+
}
|
|
5618
|
+
} catch {
|
|
5619
|
+
/* best-effort — the in-app badge is the reliable signal */
|
|
5620
|
+
}
|
|
5621
|
+
}
|
|
5622
|
+
}, []);
|
|
5623
|
+
// Inspector tab is lifted so View ▸ Layers can open the panel ON the Layers
|
|
5624
|
+
// tab (the menu item sat disabled as "Phase 12" long after the tab shipped).
|
|
5625
|
+
const [inspectorTab, setInspectorTab] = useState('inspect');
|
|
5626
|
+
// The right dock holds exactly ONE panel (Changes / Inspector / Comments) at
|
|
5627
|
+
// a time — opening any panel REPLACES whatever was there. These two helpers
|
|
5628
|
+
// are the single source of that invariant; every open/toggle path routes
|
|
5629
|
+
// through them. (Before, the three booleans were flipped independently across
|
|
5630
|
+
// ~13 call sites and only some closed their siblings, so a panel opened via a
|
|
5631
|
+
// path that left a sibling `true` rendered *behind* it under the fixed
|
|
5632
|
+
// precedence — looking like the new panel "overlapped" the old one.)
|
|
5633
|
+
const openRightPanel = useCallback((which) => {
|
|
5634
|
+
setChangesOpen(which === 'changes');
|
|
5635
|
+
setInspectorOpen(which === 'inspector');
|
|
5636
|
+
setCommentsPanelOpen(which === 'comments');
|
|
5637
|
+
setAssistantOpen(which === 'assistant');
|
|
5638
|
+
}, []);
|
|
5639
|
+
// Functional updates so this is stale-closure-safe inside the keydown /
|
|
5640
|
+
// postMessage listeners; opening always clears the sibling panels.
|
|
5641
|
+
const toggleRightPanel = useCallback((which) => {
|
|
5642
|
+
if (which === 'inspector') {
|
|
5643
|
+
setInspectorOpen((v) => {
|
|
5644
|
+
if (!v) {
|
|
5645
|
+
setChangesOpen(false);
|
|
5646
|
+
setCommentsPanelOpen(false);
|
|
5647
|
+
setAssistantOpen(false);
|
|
5648
|
+
}
|
|
5649
|
+
return !v;
|
|
5650
|
+
});
|
|
5651
|
+
} else if (which === 'comments') {
|
|
5652
|
+
setCommentsPanelOpen((v) => {
|
|
5653
|
+
if (!v) {
|
|
5654
|
+
setChangesOpen(false);
|
|
5655
|
+
setInspectorOpen(false);
|
|
5656
|
+
setAssistantOpen(false);
|
|
5657
|
+
}
|
|
5658
|
+
return !v;
|
|
5659
|
+
});
|
|
5660
|
+
} else if (which === 'changes') {
|
|
5661
|
+
setChangesOpen((v) => {
|
|
5662
|
+
if (!v) {
|
|
5663
|
+
setInspectorOpen(false);
|
|
5664
|
+
setCommentsPanelOpen(false);
|
|
5665
|
+
setAssistantOpen(false);
|
|
5666
|
+
}
|
|
5667
|
+
return !v;
|
|
5668
|
+
});
|
|
5669
|
+
} else if (which === 'assistant') {
|
|
5670
|
+
setAssistantOpen((v) => {
|
|
5671
|
+
if (!v) {
|
|
5672
|
+
setChangesOpen(false);
|
|
5673
|
+
setInspectorOpen(false);
|
|
5674
|
+
setCommentsPanelOpen(false);
|
|
5675
|
+
}
|
|
5676
|
+
return !v;
|
|
5677
|
+
});
|
|
5678
|
+
}
|
|
5679
|
+
}, []);
|
|
3016
5680
|
const whatsNew = useWhatsNew(MDCC_VERSION);
|
|
5681
|
+
// Phase 29 (E4) — first-run onboarding wizard. The native shell boots a minimal
|
|
5682
|
+
// "welcome" project on first launch; we ask it whether this is a first run and, if
|
|
5683
|
+
// so, show the wizard OVER the (empty) canvas browser. Completing any door switches
|
|
5684
|
+
// the sidecar to a real project (the webview reloads → first-run is then false).
|
|
5685
|
+
const [firstRun, setFirstRun] = useState(false);
|
|
5686
|
+
// Offer the collab "rychlý kurz" once, AFTER onboarding (native app, not first run,
|
|
5687
|
+
// not yet seen). A returning user who already took it isn't re-nudged.
|
|
5688
|
+
const [collabNudge, setCollabNudge] = useState(false);
|
|
5689
|
+
useEffect(() => {
|
|
5690
|
+
if (!isNativeApp()) return undefined;
|
|
5691
|
+
let alive = true;
|
|
5692
|
+
appIsFirstRun()
|
|
5693
|
+
.then((v) => {
|
|
5694
|
+
if (!alive) return;
|
|
5695
|
+
setFirstRun(!!v);
|
|
5696
|
+
if (!v && !readBoolStore(COLLAB_TOUR_STORE, false)) setCollabNudge(true);
|
|
5697
|
+
})
|
|
5698
|
+
.catch(() => {});
|
|
5699
|
+
return () => {
|
|
5700
|
+
alive = false;
|
|
5701
|
+
};
|
|
5702
|
+
}, []);
|
|
5703
|
+
const markCollabSeen = useCallback(() => {
|
|
5704
|
+
setCollabNudge(false);
|
|
5705
|
+
try {
|
|
5706
|
+
localStorage.setItem(COLLAB_TOUR_STORE, '1');
|
|
5707
|
+
} catch {}
|
|
5708
|
+
}, []);
|
|
5709
|
+
// Phase 32 (Task 1) — auto-update. The native shell downloads + stages a newer
|
|
5710
|
+
// build in the background and emits `update-ready`; we surface a non-blocking
|
|
5711
|
+
// banner. Native-only (the web studio is updated by its own deploy).
|
|
5712
|
+
const [updateReady, setUpdateReady] = useState(null);
|
|
5713
|
+
useEffect(() => {
|
|
5714
|
+
if (!isNativeApp()) return undefined;
|
|
5715
|
+
let un;
|
|
5716
|
+
onUpdateReady((p) => setUpdateReady(p && typeof p === 'object' ? p : {}))
|
|
5717
|
+
.then((fn) => {
|
|
5718
|
+
un = fn;
|
|
5719
|
+
})
|
|
5720
|
+
.catch(() => {});
|
|
5721
|
+
return () => {
|
|
5722
|
+
try {
|
|
5723
|
+
un?.();
|
|
5724
|
+
} catch {}
|
|
5725
|
+
};
|
|
5726
|
+
}, []);
|
|
3017
5727
|
const [tourSteps, setTourSteps] = useState(null);
|
|
3018
5728
|
const [usageNudge, setUsageNudge] = useState(() => !readBoolStore(USAGE_TOUR_STORE, false));
|
|
3019
5729
|
const startTour = useCallback((steps) => {
|
|
3020
5730
|
setTourSteps(Array.isArray(steps) && steps.length ? steps : null);
|
|
3021
5731
|
}, []);
|
|
5732
|
+
// Guided-tour bus — the overlay calls setup() before each step to put the shell
|
|
5733
|
+
// into the state the step spotlights: open a canvas, open the Inspector, switch
|
|
5734
|
+
// its tab. The canvas iframe is cross-origin (DDR-054) so the tour can't select
|
|
5735
|
+
// an element for the user; requireSelection steps instead wait for a real
|
|
5736
|
+
// ⌘-click. Plain object (the overlay refs it), so per-render churn is harmless.
|
|
5737
|
+
const tourBus = {
|
|
5738
|
+
setup: (step) => {
|
|
5739
|
+
if (!step) return;
|
|
5740
|
+
if ((step.canvas || step.requireSelection) && tabs.length === 0) {
|
|
5741
|
+
setSidebarOpen(true);
|
|
5742
|
+
setTimeout(() => {
|
|
5743
|
+
try {
|
|
5744
|
+
document.querySelector('.st-sidebar [role="treeitem"]')?.click();
|
|
5745
|
+
} catch {}
|
|
5746
|
+
}, 80);
|
|
5747
|
+
}
|
|
5748
|
+
if (step.inspector || step.tab || step.requireSelection) openRightPanel('inspector');
|
|
5749
|
+
if (step.tab) setInspectorTab(step.tab);
|
|
5750
|
+
// Phase 29 (E4) collab tour — open the Changes panel so the Save / Publish /
|
|
5751
|
+
// Get-latest controls the action steps spotlight actually exist to anchor on.
|
|
5752
|
+
if (step.changes) openRightPanel('changes');
|
|
5753
|
+
},
|
|
5754
|
+
};
|
|
3022
5755
|
const markUsageSeen = useCallback(() => {
|
|
3023
5756
|
setUsageNudge(false);
|
|
3024
5757
|
try {
|
|
@@ -3026,6 +5759,13 @@ function App() {
|
|
|
3026
5759
|
} catch {}
|
|
3027
5760
|
}, []);
|
|
3028
5761
|
const [annotationsVisible, setAnnotationsVisible] = useState(true);
|
|
5762
|
+
// Canvas-chrome visibility (View menu). minimap + zoom-controls are
|
|
5763
|
+
// persistent prefs broadcast to every open canvas iframe; presentMode is a
|
|
5764
|
+
// non-destructive "hide ALL chrome + shell, artboards only" overlay with an
|
|
5765
|
+
// Esc / floating-pill escape hatch back to the chrome.
|
|
5766
|
+
const [minimapVisible, setMinimapVisible] = useState(true);
|
|
5767
|
+
const [zoomCtlVisible, setZoomCtlVisible] = useState(true);
|
|
5768
|
+
const [presentMode, setPresentMode] = useState(false);
|
|
3029
5769
|
// P2/P3 (Plan C) — top-bar live state. (Zoom lives in the canvas toolbar pill,
|
|
3030
5770
|
// so the top bar no longer mirrors it.)
|
|
3031
5771
|
// activeArtboards — real artboard count of the open canvas, read from its
|
|
@@ -3068,6 +5808,42 @@ function App() {
|
|
|
3068
5808
|
});
|
|
3069
5809
|
}, [activePath]);
|
|
3070
5810
|
|
|
5811
|
+
// Chrome visibility (minimap / zoom-controls / Presentation Mode) applies to
|
|
5812
|
+
// EVERY open canvas iframe, not just the active one — broadcast to all. A
|
|
5813
|
+
// freshly-loaded iframe is seeded from the dgn:'loaded' handler below.
|
|
5814
|
+
const broadcastChrome = useCallback((patch) => {
|
|
5815
|
+
for (const el of iframesRef.current.values()) {
|
|
5816
|
+
try {
|
|
5817
|
+
el.contentWindow.postMessage({ dgn: 'view-chrome', ...patch }, '*');
|
|
5818
|
+
} catch {}
|
|
5819
|
+
}
|
|
5820
|
+
}, []);
|
|
5821
|
+
const toggleMinimap = useCallback(() => {
|
|
5822
|
+
setMinimapVisible((v) => {
|
|
5823
|
+
const next = !v;
|
|
5824
|
+
broadcastChrome({ minimap: next });
|
|
5825
|
+
return next;
|
|
5826
|
+
});
|
|
5827
|
+
}, [broadcastChrome]);
|
|
5828
|
+
const toggleZoomCtl = useCallback(() => {
|
|
5829
|
+
setZoomCtlVisible((v) => {
|
|
5830
|
+
const next = !v;
|
|
5831
|
+
broadcastChrome({ zoom: next });
|
|
5832
|
+
return next;
|
|
5833
|
+
});
|
|
5834
|
+
}, [broadcastChrome]);
|
|
5835
|
+
const togglePresent = useCallback(() => {
|
|
5836
|
+
setPresentMode((v) => {
|
|
5837
|
+
const next = !v;
|
|
5838
|
+
broadcastChrome({ present: next });
|
|
5839
|
+
return next;
|
|
5840
|
+
});
|
|
5841
|
+
}, [broadcastChrome]);
|
|
5842
|
+
const exitPresent = useCallback(() => {
|
|
5843
|
+
setPresentMode(false);
|
|
5844
|
+
broadcastChrome({ present: false });
|
|
5845
|
+
}, [broadcastChrome]);
|
|
5846
|
+
|
|
3071
5847
|
// P3 (Plan C) — local git user for the menubar presence avatar. One-shot.
|
|
3072
5848
|
useEffect(() => {
|
|
3073
5849
|
let cancelled = false;
|
|
@@ -3245,9 +6021,9 @@ function App() {
|
|
|
3245
6021
|
try {
|
|
3246
6022
|
const m = JSON.parse(e.data);
|
|
3247
6023
|
if (m.type === 'snapshot' && m.state) {
|
|
3248
|
-
setSelected(m.state.selected);
|
|
6024
|
+
setSelected((prev) => mergeSelClientFields(m.state.selected, prev));
|
|
3249
6025
|
} else if (m.type === 'selected') {
|
|
3250
|
-
setSelected(m.selected);
|
|
6026
|
+
setSelected((prev) => mergeSelClientFields(m.selected, prev));
|
|
3251
6027
|
} else if (m.type === 'comments' && typeof m.file === 'string') {
|
|
3252
6028
|
setCommentsByFile((prev) => ({ ...prev, [m.file]: m.comments || [] }));
|
|
3253
6029
|
} else if (m.type === 'ai-activity' && typeof m.file === 'string') {
|
|
@@ -3271,6 +6047,20 @@ function App() {
|
|
|
3271
6047
|
} else if (m.type === 'sync:status' && m.payload) {
|
|
3272
6048
|
// Phase 9 Task 8 — hub connection state for the offline banner.
|
|
3273
6049
|
setSyncStatus(m.payload);
|
|
6050
|
+
} else if (m.type === 'canvas-list-update') {
|
|
6051
|
+
// Phase 30 — a canvas was created/deleted on THIS dev-server; re-read
|
|
6052
|
+
// the branch-scoped tree so other open tabs reflect it without a
|
|
6053
|
+
// reload. Cross-machine peers get a new canvas via git "Get latest".
|
|
6054
|
+
loadTree();
|
|
6055
|
+
} else if (m.type === 'acp-focus') {
|
|
6056
|
+
// Phase 31 (DDR-123) — `/design:chat` from the terminal asked us to
|
|
6057
|
+
// surface the native ACP chat sidepanel. Native-only (the panel
|
|
6058
|
+
// doesn't exist on the web surface).
|
|
6059
|
+
if (isNativeApp()) openRightPanel('assistant');
|
|
6060
|
+
} else if (m.type === 'git-status' && m.payload) {
|
|
6061
|
+
// Phase 27 (E2) Task 5 — live dirty-state. Updates the Changes-panel
|
|
6062
|
+
// count + tree M/A/D badges reactively, no polling.
|
|
6063
|
+
setGitStatus(m.payload);
|
|
3274
6064
|
} else if (m.type === 'git-lifecycle' && m.payload) {
|
|
3275
6065
|
// Phase 8 Task 7 — branch switch / pull mid-session. Server has
|
|
3276
6066
|
// already flushed every dirty Y.Doc to JSON; just prompt the user.
|
|
@@ -3289,7 +6079,9 @@ function App() {
|
|
|
3289
6079
|
}
|
|
3290
6080
|
connect();
|
|
3291
6081
|
return () => wsRef.current && wsRef.current.close();
|
|
3292
|
-
|
|
6082
|
+
// loadTree is a stable useCallback([]); listed so the canvas-list-update
|
|
6083
|
+
// handler always calls the live reference.
|
|
6084
|
+
}, [loadTree]);
|
|
3293
6085
|
|
|
3294
6086
|
function wsSend(obj) {
|
|
3295
6087
|
const ws = wsRef.current;
|
|
@@ -3298,6 +6090,144 @@ function App() {
|
|
|
3298
6090
|
} catch {}
|
|
3299
6091
|
}
|
|
3300
6092
|
|
|
6093
|
+
// ----- Phase 27 (E2) — git actions -----
|
|
6094
|
+
// All write actions POST same-origin (the dev-server's sameOriginWrite + the
|
|
6095
|
+
// dual-allowlist gate them main-origin only). After a mutation we refresh
|
|
6096
|
+
// status optimistically; the `git-status` WS broadcast also lands shortly.
|
|
6097
|
+
const refreshGitStatus = useCallback(async () => {
|
|
6098
|
+
try {
|
|
6099
|
+
const r = await fetch('/_api/git/status');
|
|
6100
|
+
if (r.ok) setGitStatus(await r.json());
|
|
6101
|
+
} catch {}
|
|
6102
|
+
}, []);
|
|
6103
|
+
|
|
6104
|
+
// Phase 28 (E3) — probe the tracking remote so the Changes panel can surface
|
|
6105
|
+
// the "Get latest" nudge (GitPanel reads `status.remoteAhead` / `status.behind`).
|
|
6106
|
+
// `?remote=1` is what makes the server do the `git fetch` + ahead/behind count;
|
|
6107
|
+
// without it the status is local-only and the nudge never fires. Network call —
|
|
6108
|
+
// call sparingly (mount / interval / post-action), never on the WS hot path.
|
|
6109
|
+
const refreshRemoteSync = useCallback(async () => {
|
|
6110
|
+
try {
|
|
6111
|
+
const r = await fetch('/_api/git/status?remote=1');
|
|
6112
|
+
if (!r.ok) return;
|
|
6113
|
+
const data = await r.json();
|
|
6114
|
+
if (data && data.repo !== false)
|
|
6115
|
+
setRemoteSync({ remoteAhead: !!data.remoteAhead, behind: data.behind || 0 });
|
|
6116
|
+
} catch {}
|
|
6117
|
+
}, []);
|
|
6118
|
+
|
|
6119
|
+
const gitPostJson = useCallback(async (path, body) => {
|
|
6120
|
+
try {
|
|
6121
|
+
const r = await fetch(path, {
|
|
6122
|
+
method: 'POST',
|
|
6123
|
+
headers: { 'Content-Type': 'application/json' },
|
|
6124
|
+
body: JSON.stringify(body || {}),
|
|
6125
|
+
});
|
|
6126
|
+
const data = await r.json().catch(() => ({}));
|
|
6127
|
+
return { ok: r.ok, ...data };
|
|
6128
|
+
} catch (e) {
|
|
6129
|
+
return { ok: false, error: 'Network error — is the project still open?' };
|
|
6130
|
+
}
|
|
6131
|
+
}, []);
|
|
6132
|
+
|
|
6133
|
+
const gitCommit = useCallback(
|
|
6134
|
+
async (message, files) => {
|
|
6135
|
+
const res = await gitPostJson('/_api/git/commit', { message, files });
|
|
6136
|
+
if (res.ok) await refreshGitStatus();
|
|
6137
|
+
return res;
|
|
6138
|
+
},
|
|
6139
|
+
[gitPostJson, refreshGitStatus]
|
|
6140
|
+
);
|
|
6141
|
+
|
|
6142
|
+
const gitDiscard = useCallback(
|
|
6143
|
+
async (files) => {
|
|
6144
|
+
const res = await gitPostJson('/_api/git/discard', { files });
|
|
6145
|
+
if (res.ok) await refreshGitStatus();
|
|
6146
|
+
return res;
|
|
6147
|
+
},
|
|
6148
|
+
[gitPostJson, refreshGitStatus]
|
|
6149
|
+
);
|
|
6150
|
+
|
|
6151
|
+
const gitPublish = useCallback(async () => {
|
|
6152
|
+
const res = await gitPostJson('/_api/git/push', {});
|
|
6153
|
+
// Refresh so the "N versions ready to publish" count clears to 0 after a
|
|
6154
|
+
// successful push (the server advanced the local remote-tracking ref), and
|
|
6155
|
+
// re-probe the remote so a stale "Get latest" nudge clears.
|
|
6156
|
+
if (res.ok) {
|
|
6157
|
+
await refreshGitStatus();
|
|
6158
|
+
refreshRemoteSync();
|
|
6159
|
+
}
|
|
6160
|
+
return res;
|
|
6161
|
+
}, [gitPostJson, refreshGitStatus, refreshRemoteSync]);
|
|
6162
|
+
|
|
6163
|
+
const gitGetLatest = useCallback(async () => {
|
|
6164
|
+
const res = await gitPostJson('/_api/git/pull', {});
|
|
6165
|
+
// On success the remote is merged in — clear the nudge by re-probing.
|
|
6166
|
+
if (res.ok) {
|
|
6167
|
+
await refreshGitStatus();
|
|
6168
|
+
refreshRemoteSync();
|
|
6169
|
+
}
|
|
6170
|
+
// A true content conflict → open the visual resolver on the first file.
|
|
6171
|
+
if (res.conflict && Array.isArray(res.files) && res.files.length) {
|
|
6172
|
+
setDiffTarget({ file: res.files[0], conflict: true });
|
|
6173
|
+
}
|
|
6174
|
+
return res;
|
|
6175
|
+
}, [gitPostJson, refreshGitStatus, refreshRemoteSync]);
|
|
6176
|
+
|
|
6177
|
+
// Phase 28 (E3) — finish a Get-latest conflict from the DiffView resolver.
|
|
6178
|
+
// `choice` is 'mine' | 'theirs' | 'both'; the server completes the two-parent
|
|
6179
|
+
// merge commit (and, for 'both', writes our version as a "(mine)" copy).
|
|
6180
|
+
const gitResolveConflict = useCallback(
|
|
6181
|
+
async (choice) => {
|
|
6182
|
+
const res = await gitPostJson('/_api/git/resolve', { choice });
|
|
6183
|
+
if (res.ok) {
|
|
6184
|
+
await refreshGitStatus();
|
|
6185
|
+
refreshRemoteSync();
|
|
6186
|
+
}
|
|
6187
|
+
return res;
|
|
6188
|
+
},
|
|
6189
|
+
[gitPostJson, refreshGitStatus, refreshRemoteSync]
|
|
6190
|
+
);
|
|
6191
|
+
|
|
6192
|
+
// `path` (optional) scopes History to one canvas — the per-file version list
|
|
6193
|
+
// behind the History click-to-preview + DiffView "Saved version" picker
|
|
6194
|
+
// (phase-27.1). Omit for the repo-wide log.
|
|
6195
|
+
const gitLoadLog = useCallback(async (path) => {
|
|
6196
|
+
try {
|
|
6197
|
+
const qs = '/_api/git/log?limit=40' + (path ? `&path=${encodeURIComponent(path)}` : '');
|
|
6198
|
+
const r = await fetch(qs);
|
|
6199
|
+
if (!r.ok) return [];
|
|
6200
|
+
const data = await r.json();
|
|
6201
|
+
return data.entries || [];
|
|
6202
|
+
} catch {
|
|
6203
|
+
return [];
|
|
6204
|
+
}
|
|
6205
|
+
}, []);
|
|
6206
|
+
|
|
6207
|
+
// Repo-relative path → M/A/D/U badge for the tree (paths match: both the tree
|
|
6208
|
+
// and gitStatus use `.design/ui/Foo.tsx`). Keyed off gitStatus so it updates
|
|
6209
|
+
// live with the WS broadcast.
|
|
6210
|
+
const dirtyByPath = useMemo(() => {
|
|
6211
|
+
const KIND = { modified: 'M', added: 'A', deleted: 'D', untracked: 'U' };
|
|
6212
|
+
const m = new Map();
|
|
6213
|
+
for (const f of gitStatus?.files || []) m.set(f.path, KIND[f.status]);
|
|
6214
|
+
return m;
|
|
6215
|
+
}, [gitStatus]);
|
|
6216
|
+
const unsavedCount = gitStatus?.files?.length || 0;
|
|
6217
|
+
|
|
6218
|
+
// Phase 28 (E3) — keep remote ahead/behind fresh so the "Get latest" nudge
|
|
6219
|
+
// surfaces on its own: probe once a repo is known, again whenever the Changes
|
|
6220
|
+
// panel opens, and on a slow 60 s tick WHILE the panel is open (a teammate's
|
|
6221
|
+
// publish then shows up without the user first attempting their own publish).
|
|
6222
|
+
// Declared after `refreshRemoteSync` to avoid a temporal-dead-zone on the dep.
|
|
6223
|
+
useEffect(() => {
|
|
6224
|
+
if (gitStatus?.repo === false) return; // solo / non-git project — no remote
|
|
6225
|
+
refreshRemoteSync();
|
|
6226
|
+
if (!changesOpen) return; // only keep polling while the panel is visible
|
|
6227
|
+
const id = setInterval(refreshRemoteSync, 60000);
|
|
6228
|
+
return () => clearInterval(id);
|
|
6229
|
+
}, [gitStatus?.repo, changesOpen, refreshRemoteSync]);
|
|
6230
|
+
|
|
3301
6231
|
// ----- Tab management (single-canvas) -----
|
|
3302
6232
|
// Single-canvas model: opening a file REPLACES the active one (no tab strip).
|
|
3303
6233
|
// The `tabs` state stays as a 0-or-1 array so the rest of the plumbing
|
|
@@ -3311,6 +6241,9 @@ function App() {
|
|
|
3311
6241
|
});
|
|
3312
6242
|
setActivePath(path);
|
|
3313
6243
|
setFocusedCommentId(null);
|
|
6244
|
+
// Canvas-compile skeleton — cleared by the iframe's dgn:'loaded' message,
|
|
6245
|
+
// the onLoad fallback timer (legacy .html), or a hard 15s cap.
|
|
6246
|
+
if (path !== SYSTEM_TAB) setLoadingPath(path);
|
|
3314
6247
|
}, []);
|
|
3315
6248
|
|
|
3316
6249
|
const openSystem = useCallback(
|
|
@@ -3349,6 +6282,7 @@ function App() {
|
|
|
3349
6282
|
return next;
|
|
3350
6283
|
});
|
|
3351
6284
|
iframesRef.current.delete(path);
|
|
6285
|
+
setLoadingPath((p) => (p === path ? null : p));
|
|
3352
6286
|
},
|
|
3353
6287
|
[activePath]
|
|
3354
6288
|
);
|
|
@@ -3435,15 +6369,17 @@ function App() {
|
|
|
3435
6369
|
}, [activePath]);
|
|
3436
6370
|
|
|
3437
6371
|
// ----- Push comments to iframe whenever they change for active file -----
|
|
6372
|
+
// Presentation Mode hides comment pins: post an empty list while present
|
|
6373
|
+
// (re-posting the real list on exit, since this effect re-runs on the flag).
|
|
3438
6374
|
useEffect(() => {
|
|
3439
6375
|
if (!activePath || activePath === SYSTEM_TAB) return;
|
|
3440
6376
|
const el = iframesRef.current.get(activePath);
|
|
3441
6377
|
if (!el || !el.contentWindow) return;
|
|
3442
|
-
const list = commentsByFile[activePath] || [];
|
|
6378
|
+
const list = presentMode ? [] : commentsByFile[activePath] || [];
|
|
3443
6379
|
try {
|
|
3444
6380
|
el.contentWindow.postMessage({ dgn: 'comments-set', comments: list }, '*');
|
|
3445
6381
|
} catch {}
|
|
3446
|
-
}, [activePath, commentsByFile]);
|
|
6382
|
+
}, [activePath, commentsByFile, presentMode]);
|
|
3447
6383
|
|
|
3448
6384
|
// ----- Inbound messages from iframes -----
|
|
3449
6385
|
useEffect(() => {
|
|
@@ -3507,6 +6443,45 @@ function App() {
|
|
|
3507
6443
|
} else if (m.dgn === 'clear-select') {
|
|
3508
6444
|
wsSend({ type: 'clear-select' });
|
|
3509
6445
|
setSelected(null);
|
|
6446
|
+
} else if (m.dgn === 'edit-text' && m.id) {
|
|
6447
|
+
// Phase 12 (DDR-103) — inline text edit committed in the canvas. POST to
|
|
6448
|
+
// the main-origin-only /_api/edit-text → editText writes the escaped
|
|
6449
|
+
// JSXText to source; the file-watcher HMR reload then shows the new text.
|
|
6450
|
+
// A refusal (mixed/expression content) is logged, not fatal.
|
|
6451
|
+
fetch('/_api/edit-text', {
|
|
6452
|
+
method: 'POST',
|
|
6453
|
+
headers: { 'content-type': 'application/json' },
|
|
6454
|
+
body: JSON.stringify({ canvas: m.file, id: m.id, text: m.text ?? '' }),
|
|
6455
|
+
})
|
|
6456
|
+
.then((r) => r.json().catch(() => ({})))
|
|
6457
|
+
.then((j) => {
|
|
6458
|
+
if (!j.ok) console.warn('[edit-text]', j.error || 'failed');
|
|
6459
|
+
})
|
|
6460
|
+
.catch(() => {});
|
|
6461
|
+
} else if (m.dgn === 'layers-tree') {
|
|
6462
|
+
// Phase 12 Task 4 — browsable layers tree for the active artboard.
|
|
6463
|
+
setLayersTree({ artboardId: m.artboardId, nodes: Array.isArray(m.tree) ? m.tree : [] });
|
|
6464
|
+
} else if (m.dgn === 'open-inspector') {
|
|
6465
|
+
// Phase 12 — context-menu "Inspect" / tool-palette Inspect opens the right panel.
|
|
6466
|
+
openRightPanel('inspector');
|
|
6467
|
+
} else if (m.dgn === 'present-enter') {
|
|
6468
|
+
// Canvas tool-palette "Presentation mode" button — Present Mode is a
|
|
6469
|
+
// shell-level state (hides the menubar / sidebar / panels), so the
|
|
6470
|
+
// canvas requests it here and the shell flips it on + broadcasts
|
|
6471
|
+
// dgn:'view-chrome' back to every iframe. Enter-only (the palette is
|
|
6472
|
+
// hidden while presenting); exit is Esc or the floating pill. The
|
|
6473
|
+
// inbound origin gate above (DDR-054) already authenticates the canvas.
|
|
6474
|
+
// Hardening (phase-28 audit F-2): honor it ONLY from the ACTIVE canvas
|
|
6475
|
+
// (a background tab's untrusted canvas must not flip the foreground),
|
|
6476
|
+
// and NEVER while a modal dialog is open — present mode hides Sidebar-
|
|
6477
|
+
// descendant modals (OAuth device-code / Share-invite), so an untrusted
|
|
6478
|
+
// canvas could otherwise blank an in-flight confirmation.
|
|
6479
|
+
const activeWin = activePath ? iframesRef.current.get(activePath)?.contentWindow : null;
|
|
6480
|
+
const modalOpen = !!document.querySelector('[role="dialog"][aria-modal="true"]');
|
|
6481
|
+
if (e.source === activeWin && !modalOpen && !presentMode) {
|
|
6482
|
+
setPresentMode(true);
|
|
6483
|
+
broadcastChrome({ present: true });
|
|
6484
|
+
}
|
|
3510
6485
|
} else if (m.dgn === 'comment-compose' && m.selection) {
|
|
3511
6486
|
// Phase 6 — the iframe overlay owns the composer surface now. The
|
|
3512
6487
|
// shell just mirrors `selected` so the StatusBar / sidebar still
|
|
@@ -3555,6 +6530,15 @@ function App() {
|
|
|
3555
6530
|
// inspector forwards the chord here since the iframe's keydown never
|
|
3556
6531
|
// reaches the shell's window listener. Mirror that handler's toggle.
|
|
3557
6532
|
setPaletteOpen((v) => !v);
|
|
6533
|
+
} else if (m.dgn === 'shell-shortcut') {
|
|
6534
|
+
// Same forwarding lane for the other shell chords (inspect.ts) — so
|
|
6535
|
+
// ⌘R / ⌘⇧I / ⌘⇧M / ⌘⇧E / ⌘⇧H behave identically wherever focus is.
|
|
6536
|
+
if (m.id === 'reload') reloadActive();
|
|
6537
|
+
else if (m.id === 'inspector') toggleRightPanel('inspector');
|
|
6538
|
+
else if (m.id === 'assistant' && isNativeApp()) toggleRightPanel('assistant');
|
|
6539
|
+
else if (m.id === 'comments') toggleRightPanel('comments');
|
|
6540
|
+
else if (m.id === 'export') setExportDialog({ mode: 'export' });
|
|
6541
|
+
else if (m.id === 'handoff') setExportDialog({ mode: 'handoff' });
|
|
3558
6542
|
} else if (m.dgn === 'open-export') {
|
|
3559
6543
|
// Plan C — the in-canvas toolbar / context menu route here so they open
|
|
3560
6544
|
// the SAME shell Export dialog as the menubar (one look, all settings).
|
|
@@ -3564,8 +6548,12 @@ function App() {
|
|
|
3564
6548
|
scope: m.detail && typeof m.detail.scope === 'string' ? m.detail.scope : undefined,
|
|
3565
6549
|
});
|
|
3566
6550
|
} else if (m.dgn === 'loaded' && m.file) {
|
|
3567
|
-
// iframe finished loading —
|
|
3568
|
-
|
|
6551
|
+
// iframe finished loading — drop the compile skeleton, push current
|
|
6552
|
+
// comments + carry over focused pin if any
|
|
6553
|
+
setLoadingPath((p) => (p === m.file ? null : p));
|
|
6554
|
+
// Presentation Mode suppresses comment pins (same gate as the push
|
|
6555
|
+
// effect above), so a canvas opened while presenting starts pin-free.
|
|
6556
|
+
const list = presentMode ? [] : commentsByFile[m.file] || [];
|
|
3569
6557
|
const el = [...iframesRef.current.entries()].find(([k]) => k === m.file)?.[1];
|
|
3570
6558
|
if (el && el.contentWindow) {
|
|
3571
6559
|
try {
|
|
@@ -3577,11 +6565,40 @@ function App() {
|
|
|
3577
6565
|
try {
|
|
3578
6566
|
el.contentWindow.postMessage({ dgn: 'theme', theme }, '*');
|
|
3579
6567
|
} catch {}
|
|
6568
|
+
// Seed the just-loaded canvas with the current chrome-visibility
|
|
6569
|
+
// state (minimap / zoom-controls toggles + Presentation Mode) so a
|
|
6570
|
+
// canvas opened after a toggle starts in the right state.
|
|
6571
|
+
try {
|
|
6572
|
+
el.contentWindow.postMessage(
|
|
6573
|
+
{ dgn: 'view-chrome', minimap: minimapVisible, zoom: zoomCtlVisible, present: presentMode },
|
|
6574
|
+
'*'
|
|
6575
|
+
);
|
|
6576
|
+
} catch {}
|
|
3580
6577
|
if (focusedCommentId && list.some((c) => c.id === focusedCommentId)) {
|
|
3581
6578
|
try {
|
|
3582
6579
|
el.contentWindow.postMessage({ dgn: 'comment-focus', id: focusedCommentId }, '*');
|
|
3583
6580
|
} catch {}
|
|
3584
6581
|
}
|
|
6582
|
+
// Phase 12.3 (W1.1) — an edit-css/edit-attr commit triggers the file
|
|
6583
|
+
// watcher's HMR reload, which remounts the canvas and drops the
|
|
6584
|
+
// in-canvas selection halo. Re-select the same element by its stable
|
|
6585
|
+
// data-cd-id so the user keeps focus on what they're editing. The
|
|
6586
|
+
// canvas-shell `select-by-id` handler re-emits select-set, which keeps
|
|
6587
|
+
// the Inspector panel + halo in sync. Guarded to the active file.
|
|
6588
|
+
const sel = selectedRef.current;
|
|
6589
|
+
if (sel && sel.id && sel.file === m.file) {
|
|
6590
|
+
try {
|
|
6591
|
+
el.contentWindow.postMessage(
|
|
6592
|
+
{
|
|
6593
|
+
dgn: 'select-by-id',
|
|
6594
|
+
id: sel.id,
|
|
6595
|
+
artboardId: sel.artboardId ?? null,
|
|
6596
|
+
index: sel.index ?? 0,
|
|
6597
|
+
},
|
|
6598
|
+
'*'
|
|
6599
|
+
);
|
|
6600
|
+
} catch {}
|
|
6601
|
+
}
|
|
3585
6602
|
}
|
|
3586
6603
|
} else if (m.dgn === 'export-request' && m.id && m.payload) {
|
|
3587
6604
|
// The export dialog renders inside the canvas iframe (canvas origin),
|
|
@@ -3651,7 +6668,18 @@ function App() {
|
|
|
3651
6668
|
}
|
|
3652
6669
|
window.addEventListener('message', onMessage);
|
|
3653
6670
|
return () => window.removeEventListener('message', onMessage);
|
|
3654
|
-
}, [
|
|
6671
|
+
}, [
|
|
6672
|
+
commentsByFile,
|
|
6673
|
+
focusedCommentId,
|
|
6674
|
+
cfg,
|
|
6675
|
+
theme,
|
|
6676
|
+
reloadActive,
|
|
6677
|
+
presentMode,
|
|
6678
|
+
minimapVisible,
|
|
6679
|
+
zoomCtlVisible,
|
|
6680
|
+
broadcastChrome,
|
|
6681
|
+
activePath,
|
|
6682
|
+
]);
|
|
3655
6683
|
|
|
3656
6684
|
// Tell the active canvas iframe to drop any persistent selection (canvas
|
|
3657
6685
|
// SelectionSet) — used when the comment composer closes via submit /
|
|
@@ -3667,6 +6695,22 @@ function App() {
|
|
|
3667
6695
|
}
|
|
3668
6696
|
}, [activePath]);
|
|
3669
6697
|
|
|
6698
|
+
// Phase 12.3 (W1.1) — optimistic inline-style preview. The CSS panel calls this
|
|
6699
|
+
// on commit so the selected element updates instantly in the canvas before the
|
|
6700
|
+
// edit-css → HMR reload lands. `value` null = the reset path (remove the prop).
|
|
6701
|
+
const applyOptimisticStyle = useCallback(
|
|
6702
|
+
(payload) => {
|
|
6703
|
+
if (!activePath || activePath === SYSTEM_TAB) return;
|
|
6704
|
+
const el = iframesRef.current.get(activePath);
|
|
6705
|
+
if (el && el.contentWindow) {
|
|
6706
|
+
try {
|
|
6707
|
+
el.contentWindow.postMessage({ dgn: 'apply-style', ...payload }, '*');
|
|
6708
|
+
} catch {}
|
|
6709
|
+
}
|
|
6710
|
+
},
|
|
6711
|
+
[activePath]
|
|
6712
|
+
);
|
|
6713
|
+
|
|
3670
6714
|
const resolveComment = useCallback((id) => {
|
|
3671
6715
|
wsSend({ type: 'comments-patch', id, patch: { status: 'resolved' } });
|
|
3672
6716
|
}, []);
|
|
@@ -3719,6 +6763,16 @@ function App() {
|
|
|
3719
6763
|
// ⌘F) still fire regardless of focus, mirroring browser convention.
|
|
3720
6764
|
const inCanvasIframe = document.activeElement?.tagName === 'IFRAME';
|
|
3721
6765
|
|
|
6766
|
+
// Esc exits Presentation Mode first — it's the primary way back to the
|
|
6767
|
+
// chrome (the menubar is hidden while presenting). Highest priority so it
|
|
6768
|
+
// wins over the focused-pin / deselect Esc handlers below, and fires even
|
|
6769
|
+
// when focus is inside the canvas iframe.
|
|
6770
|
+
if (presentMode && e.key === 'Escape') {
|
|
6771
|
+
e.preventDefault();
|
|
6772
|
+
exitPresent();
|
|
6773
|
+
return;
|
|
6774
|
+
}
|
|
6775
|
+
|
|
3722
6776
|
// Cmd+K / Ctrl+K — toggle the command palette (works even in inputs).
|
|
3723
6777
|
if (meta && (e.key === 'k' || e.key === 'K')) {
|
|
3724
6778
|
e.preventDefault();
|
|
@@ -3734,7 +6788,39 @@ function App() {
|
|
|
3734
6788
|
// Cmd+Shift+M / Ctrl+Shift+M — toggle right "Comments" panel
|
|
3735
6789
|
if (meta && e.shiftKey && (e.key === 'm' || e.key === 'M')) {
|
|
3736
6790
|
e.preventDefault();
|
|
3737
|
-
|
|
6791
|
+
toggleRightPanel('comments');
|
|
6792
|
+
return;
|
|
6793
|
+
}
|
|
6794
|
+
// Cmd+Shift+G — toggle the Changes (git) panel. Opening it closes the
|
|
6795
|
+
// other right-dock panels (one panel at a time).
|
|
6796
|
+
if (meta && e.shiftKey && (e.key === 'g' || e.key === 'G')) {
|
|
6797
|
+
e.preventDefault();
|
|
6798
|
+
toggleRightPanel('changes');
|
|
6799
|
+
return;
|
|
6800
|
+
}
|
|
6801
|
+
// Cmd+Shift+I — toggle Inspector. Was bare "I", which collided with the
|
|
6802
|
+
// canvas highlighter tool (same letter, different action by focus).
|
|
6803
|
+
if (meta && e.shiftKey && (e.key === 'i' || e.key === 'I')) {
|
|
6804
|
+
e.preventDefault();
|
|
6805
|
+
toggleRightPanel('inspector');
|
|
6806
|
+
return;
|
|
6807
|
+
}
|
|
6808
|
+
// Phase 31 (DDR-123) — Cmd+Shift+A opens the native ACP chat sidepanel.
|
|
6809
|
+
if (meta && e.shiftKey && (e.key === 'a' || e.key === 'A') && isNativeApp()) {
|
|
6810
|
+
e.preventDefault();
|
|
6811
|
+
toggleRightPanel('assistant');
|
|
6812
|
+
return;
|
|
6813
|
+
}
|
|
6814
|
+
// Cmd+Shift+E / Cmd+Shift+H — the File-menu chords, previously
|
|
6815
|
+
// advertised but never bound.
|
|
6816
|
+
if (meta && e.shiftKey && (e.key === 'e' || e.key === 'E')) {
|
|
6817
|
+
e.preventDefault();
|
|
6818
|
+
setExportDialog({ mode: 'export' });
|
|
6819
|
+
return;
|
|
6820
|
+
}
|
|
6821
|
+
if (meta && e.shiftKey && (e.key === 'h' || e.key === 'H')) {
|
|
6822
|
+
e.preventDefault();
|
|
6823
|
+
setExportDialog({ mode: 'handoff' });
|
|
3738
6824
|
return;
|
|
3739
6825
|
}
|
|
3740
6826
|
// Cmd+C / Ctrl+C — Phase 4.1 removed the shell-side comment-drop chord.
|
|
@@ -3806,14 +6892,25 @@ function App() {
|
|
|
3806
6892
|
}
|
|
3807
6893
|
return;
|
|
3808
6894
|
}
|
|
3809
|
-
//
|
|
3810
|
-
|
|
6895
|
+
// N — open the new-brief-board composer (replaces the advertised ⌘N,
|
|
6896
|
+
// which the browser reserves for New Window and never delivers).
|
|
6897
|
+
if ((e.key === 'n' || e.key === 'N') && !meta && !e.shiftKey) {
|
|
6898
|
+
e.preventDefault();
|
|
6899
|
+
setSidebarOpen(true);
|
|
6900
|
+
setTimeout(
|
|
6901
|
+
() => document.querySelector('[aria-label="New blank brief board"]')?.click(),
|
|
6902
|
+
60
|
|
6903
|
+
);
|
|
6904
|
+
return;
|
|
6905
|
+
}
|
|
6906
|
+
// ? — keyboard-shortcuts cheat sheet (DS shortcuts overlay)
|
|
6907
|
+
if (e.key === '?') {
|
|
3811
6908
|
e.preventDefault();
|
|
3812
|
-
|
|
6909
|
+
setShortcutsOpen((v) => !v);
|
|
3813
6910
|
return;
|
|
3814
6911
|
}
|
|
3815
|
-
//
|
|
3816
|
-
if (e.key === '
|
|
6912
|
+
// F1 — the full Help modal (commands & flows)
|
|
6913
|
+
if (e.key === 'F1') {
|
|
3817
6914
|
e.preventDefault();
|
|
3818
6915
|
setHelpOpen(true);
|
|
3819
6916
|
return;
|
|
@@ -3838,6 +6935,8 @@ function App() {
|
|
|
3838
6935
|
openSystem,
|
|
3839
6936
|
closeTab,
|
|
3840
6937
|
clearActiveCanvasSelection,
|
|
6938
|
+
presentMode,
|
|
6939
|
+
exitPresent,
|
|
3841
6940
|
]);
|
|
3842
6941
|
|
|
3843
6942
|
const registerIframe = useCallback((path, el) => {
|
|
@@ -3872,7 +6971,7 @@ function App() {
|
|
|
3872
6971
|
group: 'Canvas',
|
|
3873
6972
|
label: 'New canvas…',
|
|
3874
6973
|
icon: 'plus',
|
|
3875
|
-
kbd: '
|
|
6974
|
+
kbd: 'N',
|
|
3876
6975
|
run: () => {
|
|
3877
6976
|
setSidebarOpen(true);
|
|
3878
6977
|
setTimeout(
|
|
@@ -3912,15 +7011,15 @@ function App() {
|
|
|
3912
7011
|
label: 'Toggle comments panel',
|
|
3913
7012
|
icon: 'resolve',
|
|
3914
7013
|
kbd: '⌘⇧M',
|
|
3915
|
-
run: () =>
|
|
7014
|
+
run: () => toggleRightPanel('comments'),
|
|
3916
7015
|
},
|
|
3917
7016
|
{
|
|
3918
7017
|
id: 'inspector',
|
|
3919
7018
|
group: 'View',
|
|
3920
7019
|
label: 'Open inspector',
|
|
3921
7020
|
icon: 'sliders',
|
|
3922
|
-
kbd: 'I',
|
|
3923
|
-
run: () =>
|
|
7021
|
+
kbd: '⌘⇧I',
|
|
7022
|
+
run: () => openRightPanel('inspector'),
|
|
3924
7023
|
},
|
|
3925
7024
|
{
|
|
3926
7025
|
id: 'reload',
|
|
@@ -3960,11 +7059,19 @@ function App() {
|
|
|
3960
7059
|
run: () => whatsNew.openPanel(),
|
|
3961
7060
|
},
|
|
3962
7061
|
{
|
|
3963
|
-
id: '
|
|
7062
|
+
id: 'shortcuts',
|
|
3964
7063
|
group: 'Help',
|
|
3965
|
-
label: '
|
|
7064
|
+
label: 'Keyboard shortcuts',
|
|
3966
7065
|
icon: 'help',
|
|
3967
7066
|
kbd: '?',
|
|
7067
|
+
run: () => setShortcutsOpen(true),
|
|
7068
|
+
},
|
|
7069
|
+
{
|
|
7070
|
+
id: 'help',
|
|
7071
|
+
group: 'Help',
|
|
7072
|
+
label: 'Help · commands & flows',
|
|
7073
|
+
icon: 'help',
|
|
7074
|
+
kbd: 'F1',
|
|
3968
7075
|
run: () => setHelpOpen(true),
|
|
3969
7076
|
},
|
|
3970
7077
|
],
|
|
@@ -3972,7 +7079,13 @@ function App() {
|
|
|
3972
7079
|
);
|
|
3973
7080
|
|
|
3974
7081
|
return (
|
|
3975
|
-
<div
|
|
7082
|
+
<div
|
|
7083
|
+
className={'maude' + (presentMode ? ' is-present' : '')}
|
|
7084
|
+
data-theme={theme}
|
|
7085
|
+
onContextMenu={onShellContextMenu}
|
|
7086
|
+
>
|
|
7087
|
+
{firstRun && <OnboardingWizard />}
|
|
7088
|
+
<UpdateBanner update={updateReady} onDismiss={() => setUpdateReady(null)} />
|
|
3976
7089
|
<SyncBanner status={syncStatus} />
|
|
3977
7090
|
{!usageNudge && !tourSteps && <WhatsNewToast wn={whatsNew} />}
|
|
3978
7091
|
{gitLifecycle && (
|
|
@@ -4003,21 +7116,48 @@ function App() {
|
|
|
4003
7116
|
openMenu={openMenu}
|
|
4004
7117
|
setOpenMenu={setOpenMenu}
|
|
4005
7118
|
commentsPanelOpen={commentsPanelOpen}
|
|
4006
|
-
onToggleComments={() =>
|
|
7119
|
+
onToggleComments={() => toggleRightPanel('comments')}
|
|
7120
|
+
changesOpen={changesOpen}
|
|
7121
|
+
changesCount={unsavedCount}
|
|
7122
|
+
onToggleChanges={() => toggleRightPanel('changes')}
|
|
4007
7123
|
onOpenSystem={openSystem}
|
|
4008
7124
|
sidebarOpen={sidebarOpen}
|
|
4009
7125
|
onToggleSidebar={() => setSidebarOpen((v) => !v)}
|
|
4010
7126
|
showHidden={showHidden}
|
|
4011
7127
|
onToggleShowHidden={() => setShowHidden((v) => !v)}
|
|
4012
7128
|
onOpenHelp={() => setHelpOpen(true)}
|
|
7129
|
+
onOpenShortcuts={() => setShortcutsOpen(true)}
|
|
7130
|
+
onStartTour={() => startTour(USAGE_TOUR)}
|
|
7131
|
+
onStartCollabTour={() => startTour(COLLAB_TOUR)}
|
|
4013
7132
|
annotationsVisible={annotationsVisible}
|
|
4014
7133
|
onToggleAnnotations={toggleAnnotations}
|
|
7134
|
+
minimapVisible={minimapVisible}
|
|
7135
|
+
onToggleMinimap={toggleMinimap}
|
|
7136
|
+
zoomCtlVisible={zoomCtlVisible}
|
|
7137
|
+
onToggleZoomCtl={toggleZoomCtl}
|
|
7138
|
+
presentMode={presentMode}
|
|
7139
|
+
onTogglePresent={togglePresent}
|
|
4015
7140
|
postToActiveCanvas={postToActiveCanvas}
|
|
4016
7141
|
onOpenWhatsNew={whatsNew.openPanel}
|
|
4017
7142
|
whatsNewCount={whatsNew.unseen.length}
|
|
4018
7143
|
artboardCount={activeArtboards}
|
|
4019
7144
|
inspectorOpen={inspectorOpen}
|
|
4020
|
-
|
|
7145
|
+
inspectorTab={inspectorTab}
|
|
7146
|
+
onToggleInspector={() => toggleRightPanel('inspector')}
|
|
7147
|
+
assistantOpen={assistantOpen}
|
|
7148
|
+
onToggleAssistant={() => toggleRightPanel('assistant')}
|
|
7149
|
+
assistantBusy={assistantBusy}
|
|
7150
|
+
assistantUnseen={assistantUnseen}
|
|
7151
|
+
onOpenLayers={() => {
|
|
7152
|
+
// Toggle: already open on Layers → close; otherwise open on Layers
|
|
7153
|
+
// (clearing the sibling panels — one dock slot).
|
|
7154
|
+
if (inspectorOpen && inspectorTab === 'layers') {
|
|
7155
|
+
setInspectorOpen(false);
|
|
7156
|
+
} else {
|
|
7157
|
+
setInspectorTab('layers');
|
|
7158
|
+
openRightPanel('inspector');
|
|
7159
|
+
}
|
|
7160
|
+
}}
|
|
4021
7161
|
onNewCanvas={() => {
|
|
4022
7162
|
setSidebarOpen(true);
|
|
4023
7163
|
setTimeout(
|
|
@@ -4027,6 +7167,7 @@ function App() {
|
|
|
4027
7167
|
}}
|
|
4028
7168
|
onOpenExport={(mode) => setExportDialog({ mode })}
|
|
4029
7169
|
onReload={reloadActive}
|
|
7170
|
+
onCloseCanvas={() => activePath && closeTab(activePath)}
|
|
4030
7171
|
presence={
|
|
4031
7172
|
<>
|
|
4032
7173
|
<StAvatar
|
|
@@ -4035,12 +7176,17 @@ function App() {
|
|
|
4035
7176
|
title={gitUser ? `${gitUser} (you)` : 'You'}
|
|
4036
7177
|
/>
|
|
4037
7178
|
{agentActive && (
|
|
4038
|
-
<StAvatar
|
|
7179
|
+
<StAvatar
|
|
7180
|
+
initials="C"
|
|
7181
|
+
hue="var(--presence-agent)"
|
|
7182
|
+
title="Claude · editing"
|
|
7183
|
+
pulse
|
|
7184
|
+
/>
|
|
4039
7185
|
)}
|
|
4040
7186
|
</>
|
|
4041
7187
|
}
|
|
4042
7188
|
/>
|
|
4043
|
-
<div className=
|
|
7189
|
+
<div className={'st-body' + (dragSide ? ' is-resizing' : '')} ref={bodyRef}>
|
|
4044
7190
|
<CollapsedRail
|
|
4045
7191
|
shown={!sidebarOpen}
|
|
4046
7192
|
onExpand={() => setSidebarOpen(true)}
|
|
@@ -4066,7 +7212,24 @@ function App() {
|
|
|
4066
7212
|
onDeleteBoard={deleteBoard}
|
|
4067
7213
|
collapsed={!sidebarOpen}
|
|
4068
7214
|
onCollapse={() => setSidebarOpen(false)}
|
|
7215
|
+
width={sbSize.w}
|
|
7216
|
+
resizing={dragSide === 'sb'}
|
|
7217
|
+
dirtyByPath={dirtyByPath}
|
|
7218
|
+
project={project}
|
|
7219
|
+
gitBranch={gitStatus?.branch}
|
|
4069
7220
|
/>
|
|
7221
|
+
{sidebarOpen && (
|
|
7222
|
+
<PanelGrip
|
|
7223
|
+
label="Resize files panel"
|
|
7224
|
+
size={sbSize}
|
|
7225
|
+
active={dragSide === 'sb'}
|
|
7226
|
+
onPointerDown={(e) => {
|
|
7227
|
+
e.preventDefault();
|
|
7228
|
+
e.currentTarget.setPointerCapture?.(e.pointerId);
|
|
7229
|
+
setDragSide('sb');
|
|
7230
|
+
}}
|
|
7231
|
+
/>
|
|
7232
|
+
)}
|
|
4070
7233
|
<div className="main">
|
|
4071
7234
|
<Viewport
|
|
4072
7235
|
tabs={tabs}
|
|
@@ -4077,12 +7240,80 @@ function App() {
|
|
|
4077
7240
|
onSelectDs={loadSystemData}
|
|
4078
7241
|
project={project}
|
|
4079
7242
|
cfg={cfg}
|
|
7243
|
+
loadingPath={loadingPath}
|
|
7244
|
+
onIframeLoad={onIframeLoad}
|
|
4080
7245
|
/>
|
|
4081
7246
|
</div>
|
|
4082
|
-
{
|
|
4083
|
-
|
|
4084
|
-
|
|
4085
|
-
|
|
7247
|
+
{(inspectorOpen || commentsPanelOpen || changesOpen || assistantOpen) && (
|
|
7248
|
+
<PanelGrip
|
|
7249
|
+
label="Resize side panel"
|
|
7250
|
+
dir="rtl"
|
|
7251
|
+
size={rpSize}
|
|
7252
|
+
active={dragSide === 'rp'}
|
|
7253
|
+
onPointerDown={(e) => {
|
|
7254
|
+
e.preventDefault();
|
|
7255
|
+
e.currentTarget.setPointerCapture?.(e.pointerId);
|
|
7256
|
+
setDragSide('rp');
|
|
7257
|
+
}}
|
|
7258
|
+
/>
|
|
7259
|
+
)}
|
|
7260
|
+
{/* Right dock — one panel at a time. Changes (E2) takes precedence,
|
|
7261
|
+
then Inspector (T6), then the comments panel. */}
|
|
7262
|
+
{changesOpen ? (
|
|
7263
|
+
<GitPanel
|
|
7264
|
+
status={
|
|
7265
|
+
gitStatus && remoteSync ? { ...gitStatus, ...remoteSync } : gitStatus
|
|
7266
|
+
}
|
|
7267
|
+
project={project}
|
|
7268
|
+
readOnly={!isNativeApp()}
|
|
7269
|
+
width={rpSize.w}
|
|
7270
|
+
resizing={dragSide === 'rp'}
|
|
7271
|
+
onClose={() => setChangesOpen(false)}
|
|
7272
|
+
onCommit={gitCommit}
|
|
7273
|
+
onDiscard={gitDiscard}
|
|
7274
|
+
onPublish={gitPublish}
|
|
7275
|
+
onGetLatest={gitGetLatest}
|
|
7276
|
+
loadLog={gitLoadLog}
|
|
7277
|
+
onOpenCanvas={(p) => openTab(p)}
|
|
7278
|
+
onOpenDiff={(file) => setDiffTarget({ file, beforeSha: 'HEAD', conflict: false })}
|
|
7279
|
+
activeCanvas={
|
|
7280
|
+
activePath && activePath !== SYSTEM_TAB && /\.(tsx|html)$/i.test(activePath)
|
|
7281
|
+
? activePath
|
|
7282
|
+
: null
|
|
7283
|
+
}
|
|
7284
|
+
onPreviewVersion={(sha) =>
|
|
7285
|
+
setDiffTarget({ file: activePath, beforeSha: sha, conflict: false })
|
|
7286
|
+
}
|
|
7287
|
+
designRel={(cfg?.designRel || cfg?.designRoot || '.design').replace(/^\/+|\/+$/g, '')}
|
|
7288
|
+
/>
|
|
7289
|
+
) : inspectorOpen ? (
|
|
7290
|
+
<InspectorPanel
|
|
7291
|
+
selected={selected}
|
|
7292
|
+
cfg={cfg}
|
|
7293
|
+
tab={inspectorTab}
|
|
7294
|
+
onTabChange={setInspectorTab}
|
|
7295
|
+
onClose={() => setInspectorOpen(false)}
|
|
7296
|
+
onOptimistic={applyOptimisticStyle}
|
|
7297
|
+
layersTree={layersTree}
|
|
7298
|
+
onSelectLayer={(n) =>
|
|
7299
|
+
postToActiveCanvas({
|
|
7300
|
+
dgn: 'select-by-id',
|
|
7301
|
+
id: n.id,
|
|
7302
|
+
artboardId: layersTree?.artboardId,
|
|
7303
|
+
index: n.index,
|
|
7304
|
+
})
|
|
7305
|
+
}
|
|
7306
|
+
onHoverLayer={(n) =>
|
|
7307
|
+
postToActiveCanvas({
|
|
7308
|
+
dgn: 'highlight',
|
|
7309
|
+
id: n ? n.id : null,
|
|
7310
|
+
artboardId: layersTree?.artboardId,
|
|
7311
|
+
index: n ? n.index : 0,
|
|
7312
|
+
})
|
|
7313
|
+
}
|
|
7314
|
+
width={rpSize.w}
|
|
7315
|
+
resizing={dragSide === 'rp'}
|
|
7316
|
+
/>
|
|
4086
7317
|
) : commentsPanelOpen ? (
|
|
4087
7318
|
<CommentsPanel
|
|
4088
7319
|
commentsByFile={commentsByFile}
|
|
@@ -4094,8 +7325,28 @@ function App() {
|
|
|
4094
7325
|
onResolve={resolveComment}
|
|
4095
7326
|
onReopen={reopenComment}
|
|
4096
7327
|
onDelete={deleteComment}
|
|
7328
|
+
width={rpSize.w}
|
|
7329
|
+
resizing={dragSide === 'rp'}
|
|
4097
7330
|
/>
|
|
4098
7331
|
) : null}
|
|
7332
|
+
{/* Phase 31 (DDR-123) — the ACP chat panel stays MOUNTED (display:none
|
|
7333
|
+
when inactive) so the chat keeps streaming + its history survives a
|
|
7334
|
+
switch to Changes/Inspector/Comments. Native-only. */}
|
|
7335
|
+
{isNativeApp() && (
|
|
7336
|
+
<ChatPanel
|
|
7337
|
+
hidden={!assistantOpen}
|
|
7338
|
+
activeCanvas={
|
|
7339
|
+
activePath && activePath !== SYSTEM_TAB && /\.(tsx|html)$/i.test(activePath)
|
|
7340
|
+
? activePath
|
|
7341
|
+
: null
|
|
7342
|
+
}
|
|
7343
|
+
width={rpSize.w}
|
|
7344
|
+
resizing={dragSide === 'rp'}
|
|
7345
|
+
onClose={() => setAssistantOpen(false)}
|
|
7346
|
+
onBusyChange={setAssistantBusy}
|
|
7347
|
+
onFinished={handleAssistantFinished}
|
|
7348
|
+
/>
|
|
7349
|
+
)}
|
|
4099
7350
|
</div>
|
|
4100
7351
|
<StatusBar
|
|
4101
7352
|
activePath={activePath}
|
|
@@ -4106,8 +7357,32 @@ function App() {
|
|
|
4106
7357
|
onToggleTheme={toggleTheme}
|
|
4107
7358
|
onClearSelected={clearSelected}
|
|
4108
7359
|
syncStatus={syncStatus}
|
|
7360
|
+
changesCount={unsavedCount}
|
|
7361
|
+
unpushed={gitStatus?.unpushed || 0}
|
|
7362
|
+
changesOpen={changesOpen}
|
|
7363
|
+
onOpenChanges={gitStatus?.repo ? () => setChangesOpen(true) : undefined}
|
|
4109
7364
|
/>
|
|
4110
7365
|
</div>
|
|
7366
|
+
{presentMode && (
|
|
7367
|
+
<button
|
|
7368
|
+
type="button"
|
|
7369
|
+
className="st-present-exit"
|
|
7370
|
+
onClick={exitPresent}
|
|
7371
|
+
aria-label="Exit presentation mode"
|
|
7372
|
+
title="Exit presentation mode (Esc)"
|
|
7373
|
+
>
|
|
7374
|
+
<svg viewBox="0 0 16 16" width="13" height="13" fill="none" aria-hidden="true">
|
|
7375
|
+
<path
|
|
7376
|
+
d="M4 4l8 8M12 4l-8 8"
|
|
7377
|
+
stroke="currentColor"
|
|
7378
|
+
strokeWidth="1.6"
|
|
7379
|
+
strokeLinecap="round"
|
|
7380
|
+
/>
|
|
7381
|
+
</svg>
|
|
7382
|
+
<span>Exit presentation</span>
|
|
7383
|
+
<kbd className="st-present-exit-kbd">Esc</kbd>
|
|
7384
|
+
</button>
|
|
7385
|
+
)}
|
|
4111
7386
|
<CommandPalette
|
|
4112
7387
|
open={paletteOpen}
|
|
4113
7388
|
onClose={() => setPaletteOpen(false)}
|
|
@@ -4121,6 +7396,31 @@ function App() {
|
|
|
4121
7396
|
onClose={() => setExportDialog(null)}
|
|
4122
7397
|
/>
|
|
4123
7398
|
)}
|
|
7399
|
+
{diffTarget && (
|
|
7400
|
+
<DiffView
|
|
7401
|
+
target={diffTarget}
|
|
7402
|
+
cfg={cfg}
|
|
7403
|
+
loadLog={gitLoadLog}
|
|
7404
|
+
onClose={() => setDiffTarget(null)}
|
|
7405
|
+
onRestore={async (file) => {
|
|
7406
|
+
await gitDiscard([file]);
|
|
7407
|
+
setDiffTarget(null);
|
|
7408
|
+
}}
|
|
7409
|
+
onResolve={async (choice) => {
|
|
7410
|
+
// phase-28 (E3): apply the chosen side via /_api/git/resolve, which
|
|
7411
|
+
// completes the two-parent merge commit (and for "both" saves our
|
|
7412
|
+
// version as a "(mine)" copy — zero loss). Close on success; keep the
|
|
7413
|
+
// resolver open with the error otherwise.
|
|
7414
|
+
const res = await gitResolveConflict(choice);
|
|
7415
|
+
if (res.ok) {
|
|
7416
|
+
setDiffTarget(null);
|
|
7417
|
+
} else {
|
|
7418
|
+
window.alert(res.error || 'Could not finish the merge. Get the latest again, then retry.');
|
|
7419
|
+
}
|
|
7420
|
+
}}
|
|
7421
|
+
/>
|
|
7422
|
+
)}
|
|
7423
|
+
<ShortcutsOverlay open={shortcutsOpen} onClose={() => setShortcutsOpen(false)} />
|
|
4124
7424
|
<HelpModal
|
|
4125
7425
|
open={helpOpen}
|
|
4126
7426
|
onClose={() => setHelpOpen(false)}
|
|
@@ -4130,7 +7430,7 @@ function App() {
|
|
|
4130
7430
|
}}
|
|
4131
7431
|
/>
|
|
4132
7432
|
<WhatsNewPanel wn={whatsNew} onStartTour={startTour} />
|
|
4133
|
-
{usageNudge && !tourSteps && (
|
|
7433
|
+
{usageNudge && !tourSteps && !collabNudge && (
|
|
4134
7434
|
<div className="mdcc-tour-nudge" role="status" aria-live="polite">
|
|
4135
7435
|
<div className="mdcc-tour-nudge__body">
|
|
4136
7436
|
New here? Take a 60-second tour of the canvas browser.
|
|
@@ -4155,11 +7455,40 @@ function App() {
|
|
|
4155
7455
|
</button>
|
|
4156
7456
|
</div>
|
|
4157
7457
|
)}
|
|
7458
|
+
{/* Phase 29 (E4) — the collab "rychlý kurz", offered once after onboarding. */}
|
|
7459
|
+
{collabNudge && !tourSteps && (
|
|
7460
|
+
<div className="mdcc-tour-nudge" role="status" aria-live="polite">
|
|
7461
|
+
<div className="mdcc-tour-nudge__body">
|
|
7462
|
+
New to working with a team? See how saving & sharing works — 60 seconds.
|
|
7463
|
+
</div>
|
|
7464
|
+
<button
|
|
7465
|
+
type="button"
|
|
7466
|
+
className="mdcc-tour-nudge__cta"
|
|
7467
|
+
onClick={() => {
|
|
7468
|
+
markCollabSeen();
|
|
7469
|
+
startTour(COLLAB_TOUR);
|
|
7470
|
+
}}
|
|
7471
|
+
>
|
|
7472
|
+
Start
|
|
7473
|
+
</button>
|
|
7474
|
+
<button
|
|
7475
|
+
type="button"
|
|
7476
|
+
className="mdcc-tour-nudge__skip"
|
|
7477
|
+
aria-label="Dismiss"
|
|
7478
|
+
onClick={markCollabSeen}
|
|
7479
|
+
>
|
|
7480
|
+
×
|
|
7481
|
+
</button>
|
|
7482
|
+
</div>
|
|
7483
|
+
)}
|
|
4158
7484
|
<TourOverlay
|
|
4159
7485
|
steps={tourSteps ?? []}
|
|
4160
7486
|
open={!!tourSteps}
|
|
4161
7487
|
onClose={() => setTourSteps(null)}
|
|
4162
7488
|
onComplete={markUsageSeen}
|
|
7489
|
+
bus={tourBus}
|
|
7490
|
+
hasSelection={!!selected}
|
|
7491
|
+
hasCanvas={tabs.length > 0}
|
|
4163
7492
|
/>
|
|
4164
7493
|
</div>
|
|
4165
7494
|
);
|