@1agh/maude 0.43.0 → 0.44.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/apps/studio/acp/bridge.ts +44 -10
- package/apps/studio/acp/env.ts +23 -0
- package/apps/studio/acp/login-state.ts +263 -0
- package/apps/studio/acp/plugin-bootstrap.ts +31 -25
- package/apps/studio/acp/probe.ts +152 -1
- package/apps/studio/annotations-layer.tsx +396 -46
- package/apps/studio/api.ts +395 -2
- package/apps/studio/bin/_audio-search.mjs +140 -0
- package/apps/studio/bin/_import-asset-pdf-worker.mjs +34 -0
- package/apps/studio/bin/_import-asset.mjs +815 -0
- package/apps/studio/bin/_import-brand.mjs +635 -0
- package/apps/studio/bin/_import-tokens-alias-resolver.mjs +95 -0
- package/apps/studio/bin/_import-tokens.mjs +1201 -0
- package/apps/studio/bin/_transcribe.mjs +419 -0
- package/apps/studio/bin/_transcribe.test.mjs +80 -0
- package/apps/studio/bin/audio-search.sh +28 -0
- package/apps/studio/bin/generate.sh +154 -0
- package/apps/studio/bin/import-asset.sh +34 -0
- package/apps/studio/bin/import-brand.sh +29 -0
- package/apps/studio/bin/import-tokens.sh +33 -0
- package/apps/studio/bin/photo-bg-remove.sh +119 -21
- package/apps/studio/bin/transcribe.sh +39 -0
- package/apps/studio/canvas-edit.ts +197 -2
- package/apps/studio/canvas-lib.tsx +115 -6
- package/apps/studio/client/app.jsx +1954 -685
- package/apps/studio/client/generate-dialog.jsx +352 -0
- package/apps/studio/client/github.js +14 -0
- package/apps/studio/client/inspector-controls.jsx +781 -0
- package/apps/studio/client/panels/BrandUploadPanel.jsx +233 -0
- package/apps/studio/client/panels/ChatPanel.jsx +15 -33
- package/apps/studio/client/panels/IntroVideoDialog.jsx +44 -0
- package/apps/studio/client/panels/OnboardingWizard.jsx +12 -0
- package/apps/studio/client/panels/ReadinessList.jsx +227 -7
- package/apps/studio/client/panels/SettingsPanel.jsx +828 -0
- package/apps/studio/client/panels/SetupChecklist.jsx +223 -0
- package/apps/studio/client/photo-knobs.jsx +167 -172
- package/apps/studio/client/styles/3-shell-maude.css +334 -28
- package/apps/studio/client/styles/4-components.css +223 -0
- package/apps/studio/client/styles/6-acp-chat.css +86 -0
- package/apps/studio/client/tour/quick-setup-tour.js +39 -0
- package/apps/studio/commands/annotation-strokes-command.ts +13 -3
- package/apps/studio/config.schema.json +70 -0
- package/apps/studio/context-menu.tsx +11 -2
- package/apps/studio/context.ts +16 -0
- package/apps/studio/design-setup-readiness.ts +115 -0
- package/apps/studio/dist/client.bundle.js +2097 -2088
- package/apps/studio/dist/styles.css +1 -1
- package/apps/studio/draw/palette.ts +34 -0
- package/apps/studio/footage/schema.test.ts +92 -0
- package/apps/studio/footage/schema.ts +179 -1
- package/apps/studio/generation/adapters/elevenlabs.ts +370 -0
- package/apps/studio/generation/adapters/gemini.ts +542 -0
- package/apps/studio/generation/adapters/groq.test.ts +80 -0
- package/apps/studio/generation/adapters/groq.ts +247 -0
- package/apps/studio/generation/audio-library.test.ts +95 -0
- package/apps/studio/generation/audio-library.ts +156 -0
- package/apps/studio/generation/captions.test.ts +78 -0
- package/apps/studio/generation/captions.ts +132 -0
- package/apps/studio/generation/download.ts +76 -0
- package/apps/studio/generation/elevenlabs.test.ts +223 -0
- package/apps/studio/generation/gemini.test.ts +350 -0
- package/apps/studio/generation/jobs.test.ts +128 -0
- package/apps/studio/generation/jobs.ts +243 -0
- package/apps/studio/generation/keys.test.ts +75 -0
- package/apps/studio/generation/keys.ts +162 -0
- package/apps/studio/generation/prefs.test.ts +95 -0
- package/apps/studio/generation/prefs.ts +82 -0
- package/apps/studio/generation/registry.ts +88 -0
- package/apps/studio/generation/types.test.ts +123 -0
- package/apps/studio/generation/types.ts +266 -0
- package/apps/studio/generation/whisper-models.test.ts +152 -0
- package/apps/studio/generation/whisper-models.ts +257 -0
- package/apps/studio/generation/whisper-spike-results.md +61 -0
- package/apps/studio/git/service.ts +3 -1
- package/apps/studio/http.ts +867 -9
- package/apps/studio/media/intro.mp4 +0 -0
- package/apps/studio/media-commit-chain.ts +119 -0
- package/apps/studio/paths.ts +10 -0
- package/apps/studio/readiness.ts +108 -20
- package/apps/studio/scaffold-design.ts +1145 -0
- package/apps/studio/server.ts +13 -1
- package/apps/studio/test/_helpers.ts +15 -2
- package/apps/studio/test/acp-bridge.test.ts +34 -1
- package/apps/studio/test/acp-env.test.ts +30 -0
- package/apps/studio/test/acp-plugin-bootstrap.test.ts +23 -57
- package/apps/studio/test/acp-session-plugins.test.ts +72 -1
- package/apps/studio/test/annotation-strokes-command.test.ts +17 -0
- package/apps/studio/test/annotations-layer.test.ts +174 -0
- package/apps/studio/test/canvas-origin-gate.test.ts +37 -0
- package/apps/studio/test/csp-canvas-shell.test.ts +11 -0
- package/apps/studio/test/csrf-write-guard.test.ts +23 -1
- package/apps/studio/test/design-setup-readiness.test.ts +155 -0
- package/apps/studio/test/element-structural-edit.test.ts +108 -0
- package/apps/studio/test/fixtures/fake-claude-auth.mjs +13 -0
- package/apps/studio/test/generate-route.test.ts +106 -0
- package/apps/studio/test/import-asset-browser.test.ts +64 -0
- package/apps/studio/test/import-asset-route.test.ts +71 -0
- package/apps/studio/test/import-asset.test.ts +399 -0
- package/apps/studio/test/import-brand-browser.test.ts +88 -0
- package/apps/studio/test/import-brand-route.test.ts +120 -0
- package/apps/studio/test/import-brand.test.ts +206 -0
- package/apps/studio/test/import-tokens.test.ts +722 -0
- package/apps/studio/test/media-commit-chain.test.ts +210 -0
- package/apps/studio/test/photo-bg-remove-validation.test.ts +71 -0
- package/apps/studio/test/readiness.test.ts +13 -11
- package/apps/studio/test/scaffold-design.test.ts +122 -0
- package/apps/studio/test/tour-overlay.test.tsx +16 -0
- package/apps/studio/tool-palette.tsx +11 -6
- package/apps/studio/ui-prefs.ts +130 -0
- package/apps/studio/use-canvas-media-drop.tsx +4 -2
- package/apps/studio/use-chrome-visibility.tsx +8 -2
- package/apps/studio/whats-new.json +96 -0
- package/apps/studio/ws.ts +5 -0
- package/cli/bin/maude.mjs +6 -6
- package/cli/commands/design.mjs +33 -0
- package/cli/lib/gitignore-block.mjs +1 -0
- package/cli/lib/pkg-root.mjs +107 -0
- package/cli/lib/pkg-root.test.mjs +79 -0
- package/cli/lib/reconstruct-toolset.test.mjs +194 -0
- package/cli/lib/update-check.mjs +8 -0
- package/package.json +12 -10
- package/plugins/design/dependencies.json +18 -0
|
@@ -20,20 +20,61 @@ import GitPanel from './panels/GitPanel.jsx';
|
|
|
20
20
|
import IdentityBar from './panels/IdentityBar.jsx';
|
|
21
21
|
import OnboardingWizard from './panels/OnboardingWizard.jsx';
|
|
22
22
|
import { ReadinessDialog } from './panels/ReadinessList.jsx';
|
|
23
|
+
import IntroVideoDialog from './panels/IntroVideoDialog.jsx';
|
|
24
|
+
import BrandUploadPanel from './panels/BrandUploadPanel.jsx';
|
|
25
|
+
import SetupChecklistDialog, { useSetupReadiness } from './panels/SetupChecklist.jsx';
|
|
23
26
|
import TimelinePanel from './panels/TimelinePanel.jsx';
|
|
24
27
|
import { parseCompTimeline } from './panels/timeline-parse.js';
|
|
28
|
+
import GenerateDialog from './generate-dialog.jsx';
|
|
25
29
|
import RepoBranchSwitcher from './panels/RepoBranchSwitcher.jsx';
|
|
30
|
+
import SettingsPanel from './panels/SettingsPanel.jsx';
|
|
26
31
|
import StickerPicker from './panels/StickerPicker.jsx';
|
|
32
|
+
import { AlignPad, AngleDial, ColorField, IconButtonGroup, IconToggleGroup, makeScrubHandler, NumberField, RadiusControl, Segmented, SliderField, Toggle, UnitSelect, ValueTokenField } from './inspector-controls.jsx';
|
|
33
|
+
import {
|
|
34
|
+
ALargeSmall as LuALargeSmall,
|
|
35
|
+
AlignCenter as LuAlignCenter,
|
|
36
|
+
AlignHorizontalJustifyCenter as LuJustifyCenter,
|
|
37
|
+
AlignHorizontalJustifyEnd as LuJustifyEnd,
|
|
38
|
+
AlignHorizontalJustifyStart as LuJustifyStart,
|
|
39
|
+
AlignHorizontalSpaceBetween as LuSpaceBetween,
|
|
40
|
+
AlignJustify as LuAlignJustify,
|
|
41
|
+
AlignLeft as LuAlignLeft,
|
|
42
|
+
AlignRight as LuAlignRight,
|
|
43
|
+
AlignVerticalJustifyCenter as LuVJustifyCenter,
|
|
44
|
+
AlignVerticalJustifyEnd as LuVJustifyEnd,
|
|
45
|
+
AlignVerticalJustifyStart as LuVJustifyStart,
|
|
46
|
+
Baseline as LuBaseline,
|
|
47
|
+
Bold as LuBold,
|
|
48
|
+
Columns3 as LuColumns3,
|
|
49
|
+
Eye as LuEye,
|
|
50
|
+
Italic as LuItalic,
|
|
51
|
+
Minus as LuMinus,
|
|
52
|
+
MoveHorizontal as LuMoveH,
|
|
53
|
+
RotateCw as LuRotateCw,
|
|
54
|
+
Rows3 as LuRows3,
|
|
55
|
+
Scissors as LuScissors,
|
|
56
|
+
ScrollText as LuScrollText,
|
|
57
|
+
Spline as LuSpline,
|
|
58
|
+
StretchHorizontal as LuStretch,
|
|
59
|
+
Underline as LuUnderline,
|
|
60
|
+
Braces as LuBraces,
|
|
61
|
+
Wand2 as LuWand2,
|
|
62
|
+
} from 'lucide-react';
|
|
63
|
+
|
|
64
|
+
// lucide wrapper — hairline stroke to match the shell's icon weight (handoff).
|
|
65
|
+
const Lu = ({ as: C, size = 14 }) => <C size={size} strokeWidth={1.75} style={{ display: 'block' }} />;
|
|
27
66
|
import { PhotoKnobs } from './photo-knobs.jsx';
|
|
28
67
|
import {
|
|
29
68
|
appIsFirstRun,
|
|
30
69
|
isNativeApp,
|
|
31
70
|
onUpdateReady,
|
|
32
71
|
pickMediaFile,
|
|
72
|
+
pickMediaFiles,
|
|
33
73
|
restartToUpdate,
|
|
34
74
|
} from './github.js';
|
|
35
75
|
import { COLLAB_TOUR } from './tour/collab-tour.js';
|
|
36
76
|
import { TourOverlay } from './tour/overlay.jsx';
|
|
77
|
+
import { QUICK_SETUP_TOUR } from './tour/quick-setup-tour.js';
|
|
37
78
|
import { USAGE_TOUR } from './tour/usage-tour.js';
|
|
38
79
|
import { ExportBadge, ExportPanel, ExportToast, useExportCenter } from './export-center.jsx';
|
|
39
80
|
import { useWhatsNew, WhatsNewPanel, WhatsNewToast } from './whats-new.jsx';
|
|
@@ -46,9 +87,83 @@ const SYSTEM_TAB = '__system__';
|
|
|
46
87
|
const THEME_STORE = 'mdcc-theme';
|
|
47
88
|
const SHOW_HIDDEN_STORE = 'mdcc-show-hidden';
|
|
48
89
|
const SECTIONS_STORE = 'mdcc-sections-expanded';
|
|
90
|
+
// DDR-171 — CSS panel vocabulary mode ('advanced' | 'designer'), read inside
|
|
91
|
+
// CssKnobs.
|
|
92
|
+
const CP_MODE_STORE = 'maude-cp-mode';
|
|
49
93
|
const SIDEBAR_STORE = 'mdcc-sidebar-open';
|
|
50
94
|
const MINIMAP_STORE = 'mdcc-minimap-visible';
|
|
51
95
|
const ZOOMCTL_STORE = 'mdcc-zoomctl-visible';
|
|
96
|
+
const ANNOT_STORE = 'mdcc-annotations-visible';
|
|
97
|
+
const AUTOOPEN_STORE = 'maude-auto-open-inspector';
|
|
98
|
+
|
|
99
|
+
// feature-unified-settings-modal — the Settings modal's view prefs are ALSO
|
|
100
|
+
// persisted to disk (~/.config/maude/prefs.json via /_api/ui-prefs), so they
|
|
101
|
+
// survive a restart even if localStorage is cleared (the native WKWebView
|
|
102
|
+
// case). localStorage stays the synchronous init source (no boot flash); disk
|
|
103
|
+
// is the durable, cross-restart source of truth reconciled on mount.
|
|
104
|
+
function persistUiPrefs(patch) {
|
|
105
|
+
try {
|
|
106
|
+
fetch('/_api/ui-prefs', {
|
|
107
|
+
method: 'POST',
|
|
108
|
+
headers: { 'content-type': 'text/plain' },
|
|
109
|
+
body: JSON.stringify(patch),
|
|
110
|
+
}).catch(() => {});
|
|
111
|
+
} catch {}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// feature-configurable-panel-docking — the dockable shell panels. Each can live
|
|
115
|
+
// in the LEFT or RIGHT slot (persisted in UiPrefs.panelSides); each slot is a
|
|
116
|
+
// single-panel-at-a-time surface with a tab strip over the panels assigned to
|
|
117
|
+
// it. Order here = tab order within a slot. `assistant` is native-only.
|
|
118
|
+
const DOCK_PANELS = [
|
|
119
|
+
{ id: 'tree', label: 'Files' },
|
|
120
|
+
{ id: 'layers', label: 'Layers' },
|
|
121
|
+
{ id: 'inspector', label: 'Inspector' },
|
|
122
|
+
{ id: 'comments', label: 'Comments' },
|
|
123
|
+
{ id: 'changes', label: 'Changes' },
|
|
124
|
+
{ id: 'assistant', label: 'Assistant' },
|
|
125
|
+
];
|
|
126
|
+
const PANEL_SIDES_DEFAULTS = {
|
|
127
|
+
tree: 'left',
|
|
128
|
+
layers: 'left',
|
|
129
|
+
inspector: 'right',
|
|
130
|
+
comments: 'right',
|
|
131
|
+
changes: 'right',
|
|
132
|
+
assistant: 'right',
|
|
133
|
+
};
|
|
134
|
+
const PANEL_SIDES_STORE = 'mdcc-panel-sides';
|
|
135
|
+
const LAYERS_MODE_STORE = 'mdcc-layers-mode';
|
|
136
|
+
|
|
137
|
+
// feature-configurable-panel-docking — one dock slot (left or right). Owns the
|
|
138
|
+
// resizable width + a tab strip over the panels assigned to the slot; renders
|
|
139
|
+
// the active panel (passed as children). Collapses to 0 width when nothing is
|
|
140
|
+
// visibly open (it may still host an always-mounted hidden ChatPanel).
|
|
141
|
+
function DockSlot({ side, width, open, ids, activeId, onPick, children }) {
|
|
142
|
+
return (
|
|
143
|
+
<div
|
|
144
|
+
className={'st-dockslot st-dockslot--' + side + (open ? '' : ' is-collapsed')}
|
|
145
|
+
style={{ width: open ? width : 0, flexBasis: open ? width : 0 }}
|
|
146
|
+
>
|
|
147
|
+
{open && ids.length > 1 && (
|
|
148
|
+
<div className="st-docktabs" role="tablist" aria-label={side + ' panels'}>
|
|
149
|
+
{ids.map((id) => (
|
|
150
|
+
<button
|
|
151
|
+
key={id}
|
|
152
|
+
type="button"
|
|
153
|
+
role="tab"
|
|
154
|
+
aria-selected={activeId === id}
|
|
155
|
+
className={'st-docktab' + (activeId === id ? ' is-active' : '')}
|
|
156
|
+
onClick={() => onPick(id)}
|
|
157
|
+
>
|
|
158
|
+
{(DOCK_PANELS.find((p) => p.id === id) || {}).label || id}
|
|
159
|
+
</button>
|
|
160
|
+
))}
|
|
161
|
+
</div>
|
|
162
|
+
)}
|
|
163
|
+
{children}
|
|
164
|
+
</div>
|
|
165
|
+
);
|
|
166
|
+
}
|
|
52
167
|
const CANVAS_EXT_RE = /\.(tsx|html?)$/i;
|
|
53
168
|
// Bun's `define` substitutes this at build time (see build.ts); falls back when
|
|
54
169
|
// the bundle is consumed in a context that hasn't run the build.
|
|
@@ -845,6 +960,23 @@ const PNG_SCALES = [
|
|
|
845
960
|
{ value: 3, label: '3× (max)' },
|
|
846
961
|
];
|
|
847
962
|
|
|
963
|
+
// feature-bulk-media-insert — best-effort MIME guess from a filename extension.
|
|
964
|
+
// Only used to classify a natively-picked file (Rust returns `{name, bytes}`,
|
|
965
|
+
// no type) for the destination-toggle's image/video/audio split; the actual
|
|
966
|
+
// upload is always magic-byte-sniffed server-side, so a wrong guess here
|
|
967
|
+
// can't misrepresent what gets stored, only which picker UI state it shows.
|
|
968
|
+
const EXT_MIME = {
|
|
969
|
+
png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', gif: 'image/gif',
|
|
970
|
+
webp: 'image/webp', avif: 'image/avif', svg: 'image/svg+xml',
|
|
971
|
+
mp4: 'video/mp4', mov: 'video/quicktime', webm: 'video/webm', m4v: 'video/mp4', ogg: 'video/ogg',
|
|
972
|
+
mp3: 'audio/mpeg', wav: 'audio/wav', m4a: 'audio/mp4', aac: 'audio/aac',
|
|
973
|
+
flac: 'audio/flac', oga: 'audio/ogg', opus: 'audio/opus',
|
|
974
|
+
};
|
|
975
|
+
function mimeFromExt(name) {
|
|
976
|
+
const ext = String(name || '').split('.').pop()?.toLowerCase();
|
|
977
|
+
return EXT_MIME[ext] || 'application/octet-stream';
|
|
978
|
+
}
|
|
979
|
+
|
|
848
980
|
// feature-element-editing-robustness Stage F1 — AssetPicker. A shell modal that
|
|
849
981
|
// lists the versioned content-addressed media under <designRoot>/assets/ (via the
|
|
850
982
|
// main-origin-only GET /_api/assets) and lets the user pick one — or upload a new
|
|
@@ -852,16 +984,38 @@ const PNG_SCALES = [
|
|
|
852
984
|
// Insert. Thumbnails load from the main origin's designRoot static serve
|
|
853
985
|
// (/${designRel}/${asset.path}); never a remote hotlink (the CSP split origin
|
|
854
986
|
// blocks those — memory reference_canvas_images_download_first).
|
|
855
|
-
function AssetPicker({
|
|
987
|
+
function AssetPicker({
|
|
988
|
+
designRel,
|
|
989
|
+
onPick,
|
|
990
|
+
onClose,
|
|
991
|
+
multiple = false,
|
|
992
|
+
hasArtboardAnchor = false,
|
|
993
|
+
onPickMany,
|
|
994
|
+
}) {
|
|
856
995
|
const [assets, setAssets] = useState(null);
|
|
857
996
|
const [busy, setBusy] = useState(false);
|
|
858
997
|
const [err, setErr] = useState(null);
|
|
998
|
+
// feature-bulk-media-insert — multi-select state. `kindByPath` merges the
|
|
999
|
+
// fetched listing with freshly uploaded assets (the upload response carries
|
|
1000
|
+
// no `kind`, so a just-uploaded file's kind comes from its own File.type).
|
|
1001
|
+
const [selected, setSelected] = useState(() => new Set());
|
|
1002
|
+
const [kindByPath, setKindByPath] = useState({});
|
|
1003
|
+
const [destination, setDestination] = useState(hasArtboardAnchor ? 'artboard' : 'annotation');
|
|
859
1004
|
|
|
860
1005
|
useEffect(() => {
|
|
861
1006
|
let alive = true;
|
|
862
1007
|
fetch('/_api/assets')
|
|
863
1008
|
.then((r) => r.json())
|
|
864
|
-
.then((j) =>
|
|
1009
|
+
.then((j) => {
|
|
1010
|
+
if (!alive) return;
|
|
1011
|
+
const list = j.ok ? j.assets : [];
|
|
1012
|
+
setAssets(list);
|
|
1013
|
+
setKindByPath((prev) => {
|
|
1014
|
+
const next = { ...prev };
|
|
1015
|
+
for (const a of list) next[a.path] = a.kind;
|
|
1016
|
+
return next;
|
|
1017
|
+
});
|
|
1018
|
+
})
|
|
865
1019
|
.catch(() => alive && setAssets([]));
|
|
866
1020
|
return () => {
|
|
867
1021
|
alive = false;
|
|
@@ -879,28 +1033,84 @@ function AssetPicker({ designRel, onPick, onClose }) {
|
|
|
879
1033
|
return () => window.removeEventListener('keydown', onKey, true);
|
|
880
1034
|
}, [onClose]);
|
|
881
1035
|
|
|
1036
|
+
const hasNonImageSelected = Array.from(selected).some((p) => (kindByPath[p] || 'image') !== 'image');
|
|
1037
|
+
const artboardDisabled = !hasArtboardAnchor || hasNonImageSelected;
|
|
1038
|
+
const artboardDisabledReason = !hasArtboardAnchor
|
|
1039
|
+
? 'No artboard on this canvas'
|
|
1040
|
+
: hasNonImageSelected
|
|
1041
|
+
? 'Video/audio can only be added as annotations'
|
|
1042
|
+
: '';
|
|
1043
|
+
// Force off "Add to artboard" the moment it becomes unavailable (e.g. a
|
|
1044
|
+
// video gets added to an until-now all-image selection).
|
|
1045
|
+
useEffect(() => {
|
|
1046
|
+
if (artboardDisabled && destination === 'artboard') setDestination('annotation');
|
|
1047
|
+
}, [artboardDisabled, destination]);
|
|
1048
|
+
|
|
1049
|
+
const toggleSelected = (path) => {
|
|
1050
|
+
setSelected((prev) => {
|
|
1051
|
+
const next = new Set(prev);
|
|
1052
|
+
if (next.has(path)) next.delete(path);
|
|
1053
|
+
else next.add(path);
|
|
1054
|
+
return next;
|
|
1055
|
+
});
|
|
1056
|
+
};
|
|
1057
|
+
|
|
1058
|
+
// Shared upload core — both the single-pick `doUpload` and the multi-file
|
|
1059
|
+
// `doUploadMany` post through here, so neither duplicates the request shape.
|
|
1060
|
+
const uploadOne = async (f) => {
|
|
1061
|
+
const res = await fetch('/_api/asset', {
|
|
1062
|
+
method: 'POST',
|
|
1063
|
+
headers: { 'content-type': f.type || 'application/octet-stream' },
|
|
1064
|
+
body: f,
|
|
1065
|
+
});
|
|
1066
|
+
const j = await res.json().catch(() => ({}));
|
|
1067
|
+
// /_api/asset returns 201 { path } on success — NO `ok` field (the bug: a
|
|
1068
|
+
// `j.ok` check always failed → "upload failed" even on a good upload).
|
|
1069
|
+
if (res.ok && j.path) return { ok: true, path: j.path };
|
|
1070
|
+
return { ok: false, error: j.error || `upload failed (HTTP ${res.status})` };
|
|
1071
|
+
};
|
|
1072
|
+
|
|
882
1073
|
const doUpload = async (f) => {
|
|
883
1074
|
if (!f) return;
|
|
884
1075
|
setBusy(true);
|
|
885
1076
|
setErr(null);
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
1077
|
+
const res = await uploadOne(f);
|
|
1078
|
+
setBusy(false);
|
|
1079
|
+
if (res.ok) onPick(res.path);
|
|
1080
|
+
else setErr(res.error);
|
|
1081
|
+
};
|
|
1082
|
+
|
|
1083
|
+
const kindOfMime = (mime) => {
|
|
1084
|
+
if (typeof mime === 'string' && mime.startsWith('video/')) return 'video';
|
|
1085
|
+
if (typeof mime === 'string' && mime.startsWith('audio/')) return 'audio';
|
|
1086
|
+
return 'image';
|
|
1087
|
+
};
|
|
1088
|
+
|
|
1089
|
+
// Multi-file upload — each file uploads independently/concurrently (the
|
|
1090
|
+
// route is idempotent + content-addressed, so no ordering concern); every
|
|
1091
|
+
// one that succeeds joins the selection as it resolves.
|
|
1092
|
+
const doUploadMany = async (files) => {
|
|
1093
|
+
if (!files.length) return;
|
|
1094
|
+
setBusy(true);
|
|
1095
|
+
setErr(null);
|
|
1096
|
+
const results = await Promise.all(
|
|
1097
|
+
files.map((f) => uploadOne(f).then((r) => ({ ...r, file: f })))
|
|
1098
|
+
);
|
|
1099
|
+
setBusy(false);
|
|
1100
|
+
const ok = results.filter((r) => r.ok);
|
|
1101
|
+
const failed = results.length - ok.length;
|
|
1102
|
+
if (failed > 0) setErr(`${failed} of ${results.length} uploads failed`);
|
|
1103
|
+
if (ok.length) {
|
|
1104
|
+
setKindByPath((prev) => {
|
|
1105
|
+
const next = { ...prev };
|
|
1106
|
+
for (const r of ok) next[r.path] = kindOfMime(r.file.type);
|
|
1107
|
+
return next;
|
|
1108
|
+
});
|
|
1109
|
+
setSelected((prev) => {
|
|
1110
|
+
const next = new Set(prev);
|
|
1111
|
+
for (const r of ok) next.add(r.path);
|
|
1112
|
+
return next;
|
|
891
1113
|
});
|
|
892
|
-
const j = await res.json().catch(() => ({}));
|
|
893
|
-
// /_api/asset returns 201 { path } on success — NO `ok` field (the bug: a
|
|
894
|
-
// `j.ok` check always failed → "upload failed" even on a good upload).
|
|
895
|
-
if (res.ok && j.path) {
|
|
896
|
-
onPick(j.path);
|
|
897
|
-
return;
|
|
898
|
-
}
|
|
899
|
-
setErr(j.error || `upload failed (HTTP ${res.status})`);
|
|
900
|
-
} catch {
|
|
901
|
-
setErr('upload failed');
|
|
902
|
-
} finally {
|
|
903
|
-
setBusy(false);
|
|
904
1114
|
}
|
|
905
1115
|
};
|
|
906
1116
|
|
|
@@ -913,6 +1123,15 @@ function AssetPicker({ designRel, onPick, onClose }) {
|
|
|
913
1123
|
setBusy(true);
|
|
914
1124
|
setErr(null);
|
|
915
1125
|
try {
|
|
1126
|
+
if (multiple) {
|
|
1127
|
+
const picked = await pickMediaFiles();
|
|
1128
|
+
const files = (picked || []).map(
|
|
1129
|
+
(p) => new File([new Uint8Array(p.bytes)], p.name, { type: mimeFromExt(p.name) })
|
|
1130
|
+
);
|
|
1131
|
+
setBusy(false);
|
|
1132
|
+
if (files.length) await doUploadMany(files);
|
|
1133
|
+
return;
|
|
1134
|
+
}
|
|
916
1135
|
const picked = await pickMediaFile();
|
|
917
1136
|
if (picked?.bytes) {
|
|
918
1137
|
// Blob from the byte array; the server sniffs the type, so no content-type
|
|
@@ -923,7 +1142,7 @@ function AssetPicker({ designRel, onPick, onClose }) {
|
|
|
923
1142
|
} catch (e) {
|
|
924
1143
|
setErr(e?.message || 'open failed');
|
|
925
1144
|
}
|
|
926
|
-
setBusy(false); // only reached on cancel / error (doUpload
|
|
1145
|
+
setBusy(false); // only reached on cancel / error (doUpload/doUploadMany own the success path)
|
|
927
1146
|
};
|
|
928
1147
|
|
|
929
1148
|
// Browser — imperative <input>, mirrors the proven replaceMediaViaPicker
|
|
@@ -937,6 +1156,7 @@ function AssetPicker({ designRel, onPick, onClose }) {
|
|
|
937
1156
|
const input = document.createElement('input');
|
|
938
1157
|
input.type = 'file';
|
|
939
1158
|
input.accept = 'image/*,video/*';
|
|
1159
|
+
if (multiple) input.multiple = true;
|
|
940
1160
|
input.style.cssText =
|
|
941
1161
|
'position:fixed;left:-9999px;top:0;width:1px;height:1px;opacity:0;pointer-events:none';
|
|
942
1162
|
document.body.appendChild(input);
|
|
@@ -945,9 +1165,11 @@ function AssetPicker({ designRel, onPick, onClose }) {
|
|
|
945
1165
|
};
|
|
946
1166
|
window.addEventListener('focus', () => setTimeout(cleanup, 300), { once: true });
|
|
947
1167
|
input.addEventListener('change', () => {
|
|
948
|
-
const
|
|
1168
|
+
const files = input.files ? Array.from(input.files) : [];
|
|
949
1169
|
cleanup();
|
|
950
|
-
if (
|
|
1170
|
+
if (!files.length) return;
|
|
1171
|
+
if (multiple) doUploadMany(files);
|
|
1172
|
+
else doUpload(files[0]);
|
|
951
1173
|
});
|
|
952
1174
|
input.click();
|
|
953
1175
|
};
|
|
@@ -982,25 +1204,71 @@ function AssetPicker({ designRel, onPick, onClose }) {
|
|
|
982
1204
|
) : assets.length === 0 ? (
|
|
983
1205
|
<div className="st-ap-empty">No assets yet — upload one.</div>
|
|
984
1206
|
) : (
|
|
985
|
-
assets.map((a) =>
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1207
|
+
assets.map((a) => {
|
|
1208
|
+
const isSelected = multiple && selected.has(a.path);
|
|
1209
|
+
return (
|
|
1210
|
+
<button
|
|
1211
|
+
type="button"
|
|
1212
|
+
key={a.path}
|
|
1213
|
+
className="st-ap-cell"
|
|
1214
|
+
data-selected={isSelected ? 'true' : undefined}
|
|
1215
|
+
aria-pressed={multiple ? isSelected : undefined}
|
|
1216
|
+
title={`${a.name} · ${Math.max(1, Math.round(a.size / 1024))} KB`}
|
|
1217
|
+
onClick={() => (multiple ? toggleSelected(a.path) : onPick(a.path))}
|
|
1218
|
+
>
|
|
1219
|
+
{multiple && (
|
|
1220
|
+
<span className="st-ap-check" aria-hidden="true">
|
|
1221
|
+
{isSelected ? <StIcon name="check" size={12} /> : null}
|
|
1222
|
+
</span>
|
|
1223
|
+
)}
|
|
1224
|
+
{a.kind === 'video' ? (
|
|
1225
|
+
<video className="st-ap-thumb" src={assetUrl(a.path)} muted playsInline />
|
|
1226
|
+
) : (
|
|
1227
|
+
<img className="st-ap-thumb" src={assetUrl(a.path)} alt={a.name} loading="lazy" />
|
|
1228
|
+
)}
|
|
1229
|
+
<span className="st-ap-name">{a.name}</span>
|
|
1230
|
+
</button>
|
|
1231
|
+
);
|
|
1232
|
+
})
|
|
1001
1233
|
)}
|
|
1002
1234
|
</div>
|
|
1003
1235
|
</div>
|
|
1236
|
+
{multiple && selected.size > 0 && (
|
|
1237
|
+
<div className="st-dialog-ft st-ap-confirm">
|
|
1238
|
+
<span className="st-ap-count">{selected.size} selected</span>
|
|
1239
|
+
<div className="st-ap-seg" role="radiogroup" aria-label="Insert as">
|
|
1240
|
+
<button
|
|
1241
|
+
type="button"
|
|
1242
|
+
className="st-ap-seg-btn"
|
|
1243
|
+
aria-pressed={destination === 'artboard'}
|
|
1244
|
+
disabled={artboardDisabled}
|
|
1245
|
+
onClick={() => setDestination('artboard')}
|
|
1246
|
+
>
|
|
1247
|
+
Add to artboard
|
|
1248
|
+
</button>
|
|
1249
|
+
<button
|
|
1250
|
+
type="button"
|
|
1251
|
+
className="st-ap-seg-btn"
|
|
1252
|
+
aria-pressed={destination === 'annotation'}
|
|
1253
|
+
onClick={() => setDestination('annotation')}
|
|
1254
|
+
>
|
|
1255
|
+
Add as annotation
|
|
1256
|
+
</button>
|
|
1257
|
+
</div>
|
|
1258
|
+
{artboardDisabled && <span className="st-ap-dest-note">{artboardDisabledReason}</span>}
|
|
1259
|
+
<span className="st-ap-confirm-spacer" />
|
|
1260
|
+
<button type="button" className="btn btn--ghost" onClick={onClose}>
|
|
1261
|
+
Cancel
|
|
1262
|
+
</button>
|
|
1263
|
+
<button
|
|
1264
|
+
type="button"
|
|
1265
|
+
className="btn btn--primary"
|
|
1266
|
+
onClick={() => onPickMany?.(Array.from(selected), destination)}
|
|
1267
|
+
>
|
|
1268
|
+
Insert
|
|
1269
|
+
</button>
|
|
1270
|
+
</div>
|
|
1271
|
+
)}
|
|
1004
1272
|
</div>
|
|
1005
1273
|
</div>
|
|
1006
1274
|
);
|
|
@@ -1328,7 +1596,18 @@ function DsFolderRow({ name, dsName, depth, defaultOpen, active, onOpenSystem, c
|
|
|
1328
1596
|
);
|
|
1329
1597
|
}
|
|
1330
1598
|
|
|
1331
|
-
function FileRow({
|
|
1599
|
+
function FileRow({
|
|
1600
|
+
file,
|
|
1601
|
+
activePath,
|
|
1602
|
+
onOpen,
|
|
1603
|
+
onDelete,
|
|
1604
|
+
openCount: oc,
|
|
1605
|
+
depth,
|
|
1606
|
+
kind,
|
|
1607
|
+
sidecar,
|
|
1608
|
+
dirty,
|
|
1609
|
+
experimentalKind,
|
|
1610
|
+
}) {
|
|
1332
1611
|
const isSel = file.path === activePath;
|
|
1333
1612
|
const isCanvas = CANVAS_EXT_RE.test(file.name);
|
|
1334
1613
|
// Non-canvas rows (PROJECT *.md, RUNTIME _active.json, ...) are display-only —
|
|
@@ -1372,6 +1651,15 @@ function FileRow({ file, activePath, onOpen, onDelete, openCount: oc, depth, kin
|
|
|
1372
1651
|
<StIcon name="file" size={13} />
|
|
1373
1652
|
</span>
|
|
1374
1653
|
<span className="st-row-name">{label}</span>
|
|
1654
|
+
{experimentalKind === 'reconstructed-experimental' && (
|
|
1655
|
+
<span
|
|
1656
|
+
className="st-row-exp-badge"
|
|
1657
|
+
title="Reconstructed from an image via /design:import --reconstruct — experimental, lossy, review before trusting (DDR-174)"
|
|
1658
|
+
aria-label="Reconstructed, experimental"
|
|
1659
|
+
>
|
|
1660
|
+
exp
|
|
1661
|
+
</span>
|
|
1662
|
+
)}
|
|
1375
1663
|
{dirty && (
|
|
1376
1664
|
<span className="st-git-badge" data-kind={dirty} title={`Unsaved (${dirty})`} aria-label={`Unsaved, ${dirty}`}>
|
|
1377
1665
|
{dirty}
|
|
@@ -1414,6 +1702,7 @@ function CanvasRow({
|
|
|
1414
1702
|
showHidden,
|
|
1415
1703
|
forceOpen,
|
|
1416
1704
|
dirtyByPath,
|
|
1705
|
+
experimentalKind,
|
|
1417
1706
|
}) {
|
|
1418
1707
|
const dirty = dirtyByPath?.get(primary.path);
|
|
1419
1708
|
const hasSidecars = sidecars.length > 0;
|
|
@@ -1435,6 +1724,7 @@ function CanvasRow({
|
|
|
1435
1724
|
depth={depth}
|
|
1436
1725
|
kind={kind}
|
|
1437
1726
|
dirty={dirty}
|
|
1727
|
+
experimentalKind={experimentalKind}
|
|
1438
1728
|
/>
|
|
1439
1729
|
);
|
|
1440
1730
|
}
|
|
@@ -1468,6 +1758,15 @@ function CanvasRow({
|
|
|
1468
1758
|
<StIcon name="chevron-right" className={'st-chev' + (open ? ' is-open' : '')} size={13} />
|
|
1469
1759
|
</span>
|
|
1470
1760
|
<span className="st-row-name">{displayName(primary.name)}</span>
|
|
1761
|
+
{experimentalKind === 'reconstructed-experimental' && (
|
|
1762
|
+
<span
|
|
1763
|
+
className="st-row-exp-badge"
|
|
1764
|
+
title="Reconstructed from an image via /design:import --reconstruct — experimental, lossy, review before trusting (DDR-174)"
|
|
1765
|
+
aria-label="Reconstructed, experimental"
|
|
1766
|
+
>
|
|
1767
|
+
exp
|
|
1768
|
+
</span>
|
|
1769
|
+
)}
|
|
1471
1770
|
{dirty && (
|
|
1472
1771
|
<span className="st-git-badge" data-kind={dirty} title={`Unsaved (${dirty})`} aria-label={`Unsaved, ${dirty}`}>
|
|
1473
1772
|
{dirty}
|
|
@@ -1506,6 +1805,7 @@ function Tree({
|
|
|
1506
1805
|
onOpenSystem,
|
|
1507
1806
|
onDelete,
|
|
1508
1807
|
dirtyByPath,
|
|
1808
|
+
canvasKinds,
|
|
1509
1809
|
}) {
|
|
1510
1810
|
const dirs = Object.keys(node)
|
|
1511
1811
|
.filter((k) => k !== '_files')
|
|
@@ -1548,6 +1848,7 @@ function Tree({
|
|
|
1548
1848
|
showHidden={showHidden}
|
|
1549
1849
|
forceOpen={forceOpen}
|
|
1550
1850
|
dirtyByPath={dirtyByPath}
|
|
1851
|
+
experimentalKind={canvasKinds?.[entry.primary.path]}
|
|
1551
1852
|
/>
|
|
1552
1853
|
);
|
|
1553
1854
|
})}
|
|
@@ -1580,6 +1881,7 @@ function Tree({
|
|
|
1580
1881
|
onOpenSystem={onOpenSystem}
|
|
1581
1882
|
onDelete={onDelete}
|
|
1582
1883
|
dirtyByPath={dirtyByPath}
|
|
1884
|
+
canvasKinds={canvasKinds}
|
|
1583
1885
|
/>
|
|
1584
1886
|
);
|
|
1585
1887
|
if (dsMatch && onOpenSystem) {
|
|
@@ -1654,6 +1956,7 @@ function Sidebar({
|
|
|
1654
1956
|
gitBranch,
|
|
1655
1957
|
remoteSync,
|
|
1656
1958
|
onGetLatest,
|
|
1959
|
+
canvasKinds,
|
|
1657
1960
|
}) {
|
|
1658
1961
|
const filteredGroups = useMemo(() => {
|
|
1659
1962
|
if (!search) return groups;
|
|
@@ -1884,6 +2187,7 @@ function Sidebar({
|
|
|
1884
2187
|
onOpenSystem={isDs ? onOpenSystem : undefined}
|
|
1885
2188
|
onDelete={isDs ? undefined : onDeleteBoard}
|
|
1886
2189
|
dirtyByPath={dirtyByPath}
|
|
2190
|
+
canvasKinds={canvasKinds}
|
|
1887
2191
|
/>
|
|
1888
2192
|
) : (
|
|
1889
2193
|
<div className="st-tree-empty">{search ? 'No matches.' : 'Empty.'}</div>
|
|
@@ -2520,10 +2824,15 @@ function HelpDropdown({ onAction, onClose }) {
|
|
|
2520
2824
|
{ id: 'help', label: 'Help · commands & flows', shortcut: 'F1' },
|
|
2521
2825
|
{ sep: true },
|
|
2522
2826
|
{ id: 'tour', label: 'Take the tour' },
|
|
2827
|
+
{ id: 'watch-intro', label: 'Watch the intro' },
|
|
2523
2828
|
// The collab "how sharing works" course teaches the plain-words Save →
|
|
2524
2829
|
// Publish → Pull cycle — a non-technical, native-app concern. A web-studio
|
|
2525
2830
|
// dev already knows git, so it's hidden there (DDR-119).
|
|
2526
2831
|
...(isNativeApp() ? [{ id: 'collab-tour', label: 'How sharing works' }] : []),
|
|
2832
|
+
// DDR-166 plan, Phase 2 (T7) — the quick-setup journey (design system →
|
|
2833
|
+
// first canvas → first AI edit) is a native, no-terminal concern same as
|
|
2834
|
+
// the two tours above.
|
|
2835
|
+
...(isNativeApp() ? [{ id: 'quick-setup', label: 'Quick setup' }] : []),
|
|
2527
2836
|
...(isNativeApp() ? [{ id: 'readiness', label: 'Check AI editing readiness…' }] : []),
|
|
2528
2837
|
{ id: 'whatsnew', label: "What's new" },
|
|
2529
2838
|
]}
|
|
@@ -2591,6 +2900,10 @@ function FileDropdown({ onAction, onClose, hasCanvas }) {
|
|
|
2591
2900
|
{ id: 'export', label: 'Export…', shortcut: '⇧⌘E' },
|
|
2592
2901
|
{ id: 'handoff', label: 'Handoff to production', shortcut: '⇧⌘H' },
|
|
2593
2902
|
{ sep: true },
|
|
2903
|
+
// feature-ai-media-generation (DDR-16x) — BYOK generate action + settings.
|
|
2904
|
+
{ id: 'generate', label: 'Generate with AI…' },
|
|
2905
|
+
{ id: 'settings', label: 'Settings…', shortcut: '⌘,' },
|
|
2906
|
+
{ sep: true },
|
|
2594
2907
|
{ id: 'reload', label: 'Reload canvas', shortcut: '⌘R', disabled: !hasCanvas },
|
|
2595
2908
|
{ id: 'close', label: 'Close canvas', disabled: !hasCanvas },
|
|
2596
2909
|
]}
|
|
@@ -2658,6 +2971,8 @@ function Menubar({
|
|
|
2658
2971
|
postToActiveCanvas,
|
|
2659
2972
|
onOpenWhatsNew,
|
|
2660
2973
|
onOpenReadiness,
|
|
2974
|
+
onOpenQuickSetup,
|
|
2975
|
+
onWatchIntro,
|
|
2661
2976
|
whatsNewCount,
|
|
2662
2977
|
exportCenter,
|
|
2663
2978
|
artboardCount = 0,
|
|
@@ -2678,6 +2993,8 @@ function Menubar({
|
|
|
2678
2993
|
onNewCanvas,
|
|
2679
2994
|
onAssembleVideo,
|
|
2680
2995
|
onOpenExport,
|
|
2996
|
+
onOpenSettings,
|
|
2997
|
+
onOpenGenerate,
|
|
2681
2998
|
onReload,
|
|
2682
2999
|
onCloseCanvas,
|
|
2683
3000
|
onInsertArtboard,
|
|
@@ -2885,6 +3202,8 @@ function Menubar({
|
|
|
2885
3202
|
else if (id === 'assemble') onAssembleVideo?.();
|
|
2886
3203
|
else if (id === 'export') onOpenExport?.('export');
|
|
2887
3204
|
else if (id === 'handoff') onOpenExport?.('handoff');
|
|
3205
|
+
else if (id === 'generate') onOpenGenerate?.();
|
|
3206
|
+
else if (id === 'settings') onOpenSettings?.();
|
|
2888
3207
|
else if (id === 'reload') onReload?.();
|
|
2889
3208
|
else if (id === 'close') onCloseCanvas?.();
|
|
2890
3209
|
}}
|
|
@@ -2952,8 +3271,10 @@ function Menubar({
|
|
|
2952
3271
|
else if (id === 'help') onOpenHelp?.();
|
|
2953
3272
|
else if (id === 'tour') onStartTour?.();
|
|
2954
3273
|
else if (id === 'collab-tour') onStartCollabTour?.();
|
|
3274
|
+
else if (id === 'quick-setup') onOpenQuickSetup?.();
|
|
2955
3275
|
else if (id === 'readiness') onOpenReadiness?.();
|
|
2956
3276
|
else if (id === 'whatsnew') onOpenWhatsNew?.();
|
|
3277
|
+
else if (id === 'watch-intro') onWatchIntro?.();
|
|
2957
3278
|
}}
|
|
2958
3279
|
onClose={() => setOpenMenu(null)}
|
|
2959
3280
|
/>
|
|
@@ -2964,6 +3285,7 @@ function Menubar({
|
|
|
2964
3285
|
<button
|
|
2965
3286
|
type="button"
|
|
2966
3287
|
className="st-assistant"
|
|
3288
|
+
data-testid="assistant-toggle"
|
|
2967
3289
|
data-active={assistantOpen ? 'true' : 'false'}
|
|
2968
3290
|
data-busy={assistantBusy ? 'true' : 'false'}
|
|
2969
3291
|
data-unseen={assistantUnseen ? 'true' : 'false'}
|
|
@@ -3013,6 +3335,8 @@ function Viewport({
|
|
|
3013
3335
|
cfg,
|
|
3014
3336
|
loadingPath,
|
|
3015
3337
|
onIframeLoad,
|
|
3338
|
+
showQuickSetup,
|
|
3339
|
+
onStartQuickSetup,
|
|
3016
3340
|
}) {
|
|
3017
3341
|
return (
|
|
3018
3342
|
<div className="viewport st-stage" data-tour="viewport">
|
|
@@ -3028,24 +3352,34 @@ function Viewport({
|
|
|
3028
3352
|
localhost:{typeof window !== 'undefined' ? window.location.port : '4399'}
|
|
3029
3353
|
</span>
|
|
3030
3354
|
</div>
|
|
3031
|
-
<div className="st-empty-title">
|
|
3355
|
+
<div className="st-empty-title">Nothing open yet</div>
|
|
3032
3356
|
<div className="st-empty-body">
|
|
3033
|
-
←
|
|
3034
|
-
|
|
3035
|
-
<br />
|
|
3036
|
-
<br />
|
|
3037
|
-
Opening a file replaces the active canvas. <Kbd>⌘R</Kbd> reloads it; File ▸ Close
|
|
3038
|
-
canvas clears the stage.
|
|
3039
|
-
<br />
|
|
3040
|
-
<br />
|
|
3041
|
-
<strong>Element selection:</strong> hold <Kbd>⌘</Kbd> inside the canvas and hover for a
|
|
3042
|
-
preview, click to select. <Kbd>⌘⇧</Kbd>+click adds to a multi-selection. <Kbd>V</Kbd>
|
|
3043
|
-
/<Kbd>H</Kbd>/<Kbd>C</Kbd> swap tool; right-click opens the context menu.
|
|
3357
|
+
← Pick a screen from the list on the left, or open <strong>Design system</strong> above
|
|
3358
|
+
it to see your colors, type, and components.
|
|
3044
3359
|
<br />
|
|
3045
3360
|
<br />
|
|
3046
|
-
|
|
3047
|
-
|
|
3361
|
+
<strong>To select something on the canvas:</strong> hold <Kbd>⌘</Kbd> and hover to
|
|
3362
|
+
preview an element, click to select it — <Kbd>⌘⇧</Kbd>+click selects more than one.
|
|
3363
|
+
Right-click for more options.
|
|
3364
|
+
{isNativeApp() ? (
|
|
3365
|
+
<>
|
|
3366
|
+
<br />
|
|
3367
|
+
<br />
|
|
3368
|
+
Claude can see whatever's selected when you ask for a change in the Assistant
|
|
3369
|
+
panel, so pointing is often faster than describing it.
|
|
3370
|
+
</>
|
|
3371
|
+
) : null}
|
|
3048
3372
|
</div>
|
|
3373
|
+
{showQuickSetup && (
|
|
3374
|
+
<button
|
|
3375
|
+
type="button"
|
|
3376
|
+
data-testid="st-empty-start-quick-setup"
|
|
3377
|
+
className="btn btn--primary st-empty-quick-setup"
|
|
3378
|
+
onClick={onStartQuickSetup}
|
|
3379
|
+
>
|
|
3380
|
+
Start quick setup
|
|
3381
|
+
</button>
|
|
3382
|
+
)}
|
|
3049
3383
|
</div>
|
|
3050
3384
|
)}
|
|
3051
3385
|
{tabs.map((t) => {
|
|
@@ -3836,16 +4170,15 @@ const CSS_UNITLESS = new Set(['line-height', 'opacity', 'font-weight', 'z-index'
|
|
|
3836
4170
|
// #2 — Figma-style property prefix inside numeric fields: a small glyph (icon) or
|
|
3837
4171
|
// a mono letter (t). Only where it reads cleanly; selects/colours keep their own.
|
|
3838
4172
|
const PROP_LEAD = {
|
|
3839
|
-
'font-size': {
|
|
3840
|
-
'line-height': {
|
|
3841
|
-
'letter-spacing': {
|
|
3842
|
-
gap: {
|
|
4173
|
+
'font-size': { node: <Lu as={LuALargeSmall} size={12} /> },
|
|
4174
|
+
'line-height': { node: <Lu as={LuBaseline} size={12} /> },
|
|
4175
|
+
'letter-spacing': { node: <Lu as={LuMoveH} size={12} /> },
|
|
4176
|
+
gap: { node: <Lu as={LuSpaceBetween} size={12} /> },
|
|
3843
4177
|
width: { t: 'W' },
|
|
3844
4178
|
height: { t: 'H' },
|
|
3845
4179
|
'max-width': { t: 'W' },
|
|
3846
|
-
'border-radius': {
|
|
3847
|
-
'border-width': {
|
|
3848
|
-
opacity: { icon: 'p-opacity' },
|
|
4180
|
+
'border-radius': { node: <Lu as={LuSpline} size={12} /> },
|
|
4181
|
+
'border-width': { node: <Lu as={LuMinus} size={12} /> },
|
|
3849
4182
|
};
|
|
3850
4183
|
const CSS_ALIGN_OPTS = ['left', 'center', 'right', 'justify'];
|
|
3851
4184
|
// feature-element-editing-robustness Stage B — enum option lists for the promoted
|
|
@@ -3857,6 +4190,26 @@ const CSS_TEXT_DECORATION = ['none', 'underline', 'line-through', 'overline'];
|
|
|
3857
4190
|
const CSS_WHITE_SPACE = ['normal', 'nowrap', 'pre', 'pre-wrap', 'pre-line', 'break-spaces'];
|
|
3858
4191
|
const CSS_OBJECT_FIT = ['fill', 'contain', 'cover', 'none', 'scale-down'];
|
|
3859
4192
|
const CSS_OVERFLOW = ['visible', 'hidden', 'auto', 'scroll'];
|
|
4193
|
+
// DDR-171 — Designer mode "Effects" cluster (blend) + the matching Advanced-mode
|
|
4194
|
+
// Appearance row. Standard CSS `mix-blend-mode` keyword list.
|
|
4195
|
+
const CSS_BLEND_MODES = [
|
|
4196
|
+
'normal',
|
|
4197
|
+
'multiply',
|
|
4198
|
+
'screen',
|
|
4199
|
+
'overlay',
|
|
4200
|
+
'darken',
|
|
4201
|
+
'lighten',
|
|
4202
|
+
'color-dodge',
|
|
4203
|
+
'color-burn',
|
|
4204
|
+
'hard-light',
|
|
4205
|
+
'soft-light',
|
|
4206
|
+
'difference',
|
|
4207
|
+
'exclusion',
|
|
4208
|
+
'hue',
|
|
4209
|
+
'saturation',
|
|
4210
|
+
'color',
|
|
4211
|
+
'luminosity',
|
|
4212
|
+
];
|
|
3860
4213
|
// Common aspect ratios for the Media dropdown (dogfood request — a select, not a
|
|
3861
4214
|
// free-text field). Canonical spaced form so a dropdown-set value round-trips.
|
|
3862
4215
|
const CSS_ASPECT_RATIO = ['auto', '1 / 1', '4 / 3', '3 / 2', '16 / 9', '21 / 9', '3 / 4', '2 / 3', '9 / 16'];
|
|
@@ -4080,7 +4433,7 @@ function useAllDsTokens(cfg, designRel, activeName) {
|
|
|
4080
4433
|
// `seed` is the resolved current colour (hex). Drag updates the picker UI live;
|
|
4081
4434
|
// commits on pointer-up (one source write per drag); the hex field commits on
|
|
4082
4435
|
// blur/Enter.
|
|
4083
|
-
function ColorPicker({ seed, onApply }) {
|
|
4436
|
+
function ColorPicker({ seed, label, onApply }) {
|
|
4084
4437
|
const [hsv, setHsv] = useState(() => rgbToHsv(hexToRgb(seed || '#000000')));
|
|
4085
4438
|
const hsvRef = useRef(hsv);
|
|
4086
4439
|
hsvRef.current = hsv;
|
|
@@ -4135,6 +4488,31 @@ function ColorPicker({ seed, onApply }) {
|
|
|
4135
4488
|
document.addEventListener('pointermove', move);
|
|
4136
4489
|
document.addEventListener('pointerup', up);
|
|
4137
4490
|
};
|
|
4491
|
+
// Keyboard equivalents for dragSV/dragHue (feature-photo-editor follow-up
|
|
4492
|
+
// debt, Task 18) — the pad + hue bar were pointer-only. Arrow keys nudge
|
|
4493
|
+
// the same `setHsv`/`onApply` path the drag handlers use; shift = a bigger
|
|
4494
|
+
// step (mirrors makeScrub's shift=×10 modifier convention elsewhere).
|
|
4495
|
+
const nudgeHsv = (patch) => {
|
|
4496
|
+
const next = { ...hsvRef.current, ...patch };
|
|
4497
|
+
setHsv(next);
|
|
4498
|
+
onApply(rgbToHex(hsvToRgb(next)));
|
|
4499
|
+
};
|
|
4500
|
+
const onSvKeyDown = (e) => {
|
|
4501
|
+
const step = e.shiftKey ? 0.1 : 0.02;
|
|
4502
|
+
const deltas = { ArrowLeft: [-step, 0], ArrowRight: [step, 0], ArrowUp: [0, step], ArrowDown: [0, -step] };
|
|
4503
|
+
const d = deltas[e.key];
|
|
4504
|
+
if (!d) return;
|
|
4505
|
+
e.preventDefault();
|
|
4506
|
+
nudgeHsv({ s: clamp01(hsvRef.current.s + d[0]), v: clamp01(hsvRef.current.v + d[1]) });
|
|
4507
|
+
};
|
|
4508
|
+
const onHueKeyDown = (e) => {
|
|
4509
|
+
const step = e.shiftKey ? 20 : 2;
|
|
4510
|
+
const deltas = { ArrowLeft: -step, ArrowRight: step };
|
|
4511
|
+
const d = deltas[e.key];
|
|
4512
|
+
if (d == null) return;
|
|
4513
|
+
e.preventDefault();
|
|
4514
|
+
nudgeHsv({ h: Math.min(360, Math.max(0, hsvRef.current.h + d)) });
|
|
4515
|
+
};
|
|
4138
4516
|
const eyedrop = async () => {
|
|
4139
4517
|
try {
|
|
4140
4518
|
// EyeDropper is Chromium-only; guarded.
|
|
@@ -4150,15 +4528,43 @@ function ColorPicker({ seed, onApply }) {
|
|
|
4150
4528
|
}
|
|
4151
4529
|
};
|
|
4152
4530
|
|
|
4531
|
+
// handoff — RGB numeric fields (design parity). Editing one re-derives hsv.
|
|
4532
|
+
const rgb = hsvToRgb(hsv);
|
|
4533
|
+
const setRgb = (patch) => {
|
|
4534
|
+
const next = { ...rgb, ...patch };
|
|
4535
|
+
const h = rgbToHsv({ r: clamp01(next.r / 255) * 255, g: clamp01(next.g / 255) * 255, b: clamp01(next.b / 255) * 255 });
|
|
4536
|
+
setHsv(h);
|
|
4537
|
+
onApply(rgbToHex(hsvToRgb(h)));
|
|
4538
|
+
};
|
|
4153
4539
|
return (
|
|
4154
4540
|
<div className="st-cp-cpick">
|
|
4541
|
+
{/* hex + swatch on top (design), then the SV pad, controls, RGB */}
|
|
4542
|
+
<div className="st-cp-cpick-hexrow">
|
|
4543
|
+
<span className="st-cp-cpick-hexsw" style={{ background: hex }} />
|
|
4544
|
+
<input
|
|
4545
|
+
className="st-cp-cpick-hex"
|
|
4546
|
+
type="text"
|
|
4547
|
+
value={hex}
|
|
4548
|
+
aria-label={label ? `${label} hex value` : 'hex value'}
|
|
4549
|
+
onChange={(e) => {
|
|
4550
|
+
const v = e.target.value;
|
|
4551
|
+
if (/^#?[0-9a-f]{6}$/i.test(v)) setHsv(rgbToHsv(hexToRgb(v)));
|
|
4552
|
+
}}
|
|
4553
|
+
onKeyDown={(e) => {
|
|
4554
|
+
if (e.key === 'Enter') onApply(e.currentTarget.value);
|
|
4555
|
+
}}
|
|
4556
|
+
onBlur={(e) => onApply(e.currentTarget.value)}
|
|
4557
|
+
onFocus={(e) => e.currentTarget.select()}
|
|
4558
|
+
/>
|
|
4559
|
+
</div>
|
|
4155
4560
|
<button
|
|
4156
4561
|
type="button"
|
|
4157
4562
|
ref={svRef}
|
|
4158
4563
|
className="st-cp-cpick-sv"
|
|
4159
|
-
aria-label=
|
|
4564
|
+
aria-label={label ? `${label} saturation and value` : 'saturation and value'}
|
|
4160
4565
|
style={{ background: `hsl(${hsv.h} 100% 50%)` }}
|
|
4161
4566
|
onPointerDown={dragSV}
|
|
4567
|
+
onKeyDown={onSvKeyDown}
|
|
4162
4568
|
>
|
|
4163
4569
|
<span className="st-cp-cpick-svwhite" />
|
|
4164
4570
|
<span className="st-cp-cpick-svblack" />
|
|
@@ -4179,30 +4585,31 @@ function ColorPicker({ seed, onApply }) {
|
|
|
4179
4585
|
<StIcon name="eyedropper" size={14} />
|
|
4180
4586
|
</button>
|
|
4181
4587
|
) : null}
|
|
4588
|
+
<span className="st-cp-cpick-preview" style={{ background: hex }} aria-hidden="true" />
|
|
4182
4589
|
<button
|
|
4183
4590
|
type="button"
|
|
4184
4591
|
ref={hueRef}
|
|
4185
4592
|
className="st-cp-cpick-hue"
|
|
4186
|
-
aria-label=
|
|
4593
|
+
aria-label={label ? `${label} hue` : 'hue'}
|
|
4187
4594
|
onPointerDown={dragHue}
|
|
4595
|
+
onKeyDown={onHueKeyDown}
|
|
4188
4596
|
>
|
|
4189
4597
|
<span className="st-cp-cpick-huethumb" style={{ left: `${(hsv.h / 360) * 100}%` }} />
|
|
4190
4598
|
</button>
|
|
4191
4599
|
</div>
|
|
4192
|
-
<
|
|
4193
|
-
|
|
4194
|
-
|
|
4195
|
-
|
|
4196
|
-
|
|
4197
|
-
|
|
4198
|
-
|
|
4199
|
-
|
|
4200
|
-
|
|
4201
|
-
|
|
4202
|
-
|
|
4203
|
-
}
|
|
4204
|
-
|
|
4205
|
-
/>
|
|
4600
|
+
<div className="st-cp-cpick-rgb">
|
|
4601
|
+
{['r', 'g', 'b'].map((k) => (
|
|
4602
|
+
<label key={k} className="st-cp-cpick-rgbf">
|
|
4603
|
+
<input
|
|
4604
|
+
aria-label={k.toUpperCase()}
|
|
4605
|
+
value={Math.round(rgb[k])}
|
|
4606
|
+
onChange={(e) => setRgb({ [k]: clamp01((Number.parseFloat(e.target.value) || 0) / 255) * 255 })}
|
|
4607
|
+
onFocus={(e) => e.currentTarget.select()}
|
|
4608
|
+
/>
|
|
4609
|
+
<span>{k.toUpperCase()}</span>
|
|
4610
|
+
</label>
|
|
4611
|
+
))}
|
|
4612
|
+
</div>
|
|
4206
4613
|
</div>
|
|
4207
4614
|
);
|
|
4208
4615
|
}
|
|
@@ -4212,7 +4619,7 @@ function ColorPicker({ seed, onApply }) {
|
|
|
4212
4619
|
// `kind='value'` a variable list (pretty name + resolved value, à la Figma's
|
|
4213
4620
|
// variable picker). Picking commits `var(--token)`. Portals to <body> +
|
|
4214
4621
|
// fixed-positions from the trigger rect so the panel's overflow never clips it.
|
|
4215
|
-
function TokenPopover({ kind, groups, current, onPick, label, swatchBg, seedHex, activeDs }) {
|
|
4622
|
+
function TokenPopover({ kind, groups, current, onPick, label, swatchBg, seedHex, activeDs, swatchClassName }) {
|
|
4216
4623
|
const [open, setOpen] = useState(false);
|
|
4217
4624
|
const [pos, setPos] = useState(null);
|
|
4218
4625
|
// Phase 12.3 (#4) — colour popover gets two tabs: a normal colour input
|
|
@@ -4268,26 +4675,48 @@ function TokenPopover({ kind, groups, current, onPick, label, swatchBg, seedHex,
|
|
|
4268
4675
|
const onKey = (e) => {
|
|
4269
4676
|
if (e.key === 'Escape') setOpen(false);
|
|
4270
4677
|
};
|
|
4271
|
-
|
|
4272
|
-
//
|
|
4273
|
-
//
|
|
4274
|
-
//
|
|
4678
|
+
// Task 6 (feature-inspector-controls-redesign) — RE-ANCHOR on scroll/resize
|
|
4679
|
+
// instead of dismissing. `place()` computes viewport-relative coordinates
|
|
4680
|
+
// from `getBoundingClientRect()`, but this fixed-positioned popover's actual
|
|
4681
|
+
// containing block is whichever ANCESTOR (if any) has a transform/filter/
|
|
4682
|
+
// will-change — e.g. the right panel's mount-in `st-panel-in` transform —
|
|
4683
|
+
// not necessarily the viewport. Re-running place() keeps it glued to the
|
|
4684
|
+
// trigger through any layout change instead of vanishing on the first
|
|
4685
|
+
// scroll (the old dismiss-on-scroll workaround for the same root cause).
|
|
4275
4686
|
const onScroll = (e) => {
|
|
4276
4687
|
if (popRef.current?.contains(e.target)) return;
|
|
4277
|
-
|
|
4688
|
+
place();
|
|
4278
4689
|
};
|
|
4279
4690
|
document.addEventListener('pointerdown', onDoc, true);
|
|
4280
4691
|
document.addEventListener('keydown', onKey);
|
|
4281
|
-
window.addEventListener('resize',
|
|
4692
|
+
window.addEventListener('resize', place);
|
|
4282
4693
|
document.addEventListener('scroll', onScroll, true);
|
|
4283
4694
|
return () => {
|
|
4284
4695
|
document.removeEventListener('pointerdown', onDoc, true);
|
|
4285
4696
|
document.removeEventListener('keydown', onKey);
|
|
4286
|
-
window.removeEventListener('resize',
|
|
4697
|
+
window.removeEventListener('resize', place);
|
|
4287
4698
|
document.removeEventListener('scroll', onScroll, true);
|
|
4288
4699
|
};
|
|
4289
4700
|
}, [open]);
|
|
4290
4701
|
|
|
4702
|
+
// Transform-ancestor compensation — regardless of WHICH ancestor ends up
|
|
4703
|
+
// establishing this fixed popover's containing block, measure where it
|
|
4704
|
+
// actually landed vs where `place()` intended (viewport-relative) and cancel
|
|
4705
|
+
// out the delta. This is correct independent of the cause, so it doesn't
|
|
4706
|
+
// require hunting down every transform/filter/will-change that could ever
|
|
4707
|
+
// apply between `document.body` (the portal target) and this element.
|
|
4708
|
+
// Converges in at most one correction: once the delta is compensated, the
|
|
4709
|
+
// measured rect matches the intended position and the effect is a no-op.
|
|
4710
|
+
useEffect(() => {
|
|
4711
|
+
if (!open || !pos || !popRef.current) return;
|
|
4712
|
+
const r = popRef.current.getBoundingClientRect();
|
|
4713
|
+
const dx = pos.left - r.left;
|
|
4714
|
+
const dy = pos.top - r.top;
|
|
4715
|
+
if (Math.abs(dx) > 0.5 || Math.abs(dy) > 0.5) {
|
|
4716
|
+
setPos((p) => (p ? { ...p, left: p.left + dx, top: p.top + dy } : p));
|
|
4717
|
+
}
|
|
4718
|
+
}, [open, pos]);
|
|
4719
|
+
|
|
4291
4720
|
// Pick a token. #3 — apply CORRECTLY across design systems: a token from the
|
|
4292
4721
|
// canvas's OWN active DS commits `var(--token)` (round-trips + resolves right);
|
|
4293
4722
|
// a token from ANOTHER DS commits its RESOLVED value (literal), because
|
|
@@ -4385,7 +4814,7 @@ function TokenPopover({ kind, groups, current, onPick, label, swatchBg, seedHex,
|
|
|
4385
4814
|
<button
|
|
4386
4815
|
type="button"
|
|
4387
4816
|
ref={btnRef}
|
|
4388
|
-
className={
|
|
4817
|
+
className={`${swatchClassName || 'st-cp-swatch st-cp-swatch--mini st-cp-swatch--trigger'}${bound && !swatchClassName ? ' is-bound' : ''}`}
|
|
4389
4818
|
aria-haspopup="dialog"
|
|
4390
4819
|
aria-expanded={open}
|
|
4391
4820
|
aria-label={label || 'pick a colour'}
|
|
@@ -4479,7 +4908,7 @@ function TokenPopover({ kind, groups, current, onPick, label, swatchBg, seedHex,
|
|
|
4479
4908
|
);
|
|
4480
4909
|
}
|
|
4481
4910
|
|
|
4482
|
-
function CssKnobs({ el, cfg, onOptimistic, onRecordEdit, onReplaceMedia, onUndoRedo }) {
|
|
4911
|
+
function CssKnobs({ el, cfg, onOptimistic, onRecordEdit, onReplaceMedia, onUndoRedo, mode, onSetMode }) {
|
|
4483
4912
|
const editable = !!el.id;
|
|
4484
4913
|
const computed = el.computed || {};
|
|
4485
4914
|
// Phase 12.3 — optimistic local overlay over the selection's authored / custom
|
|
@@ -4531,7 +4960,6 @@ function CssKnobs({ el, cfg, onOptimistic, onRecordEdit, onReplaceMedia, onUndoR
|
|
|
4531
4960
|
Appearance: true,
|
|
4532
4961
|
Advanced: false,
|
|
4533
4962
|
});
|
|
4534
|
-
const [split, setSplit] = useState(false);
|
|
4535
4963
|
|
|
4536
4964
|
// Phase 12.3 — auto-expand Advanced when the selected element carries custom
|
|
4537
4965
|
// CSS props / HTML attrs, so a just-added (or pre-existing) custom value is
|
|
@@ -4542,6 +4970,28 @@ function CssKnobs({ el, cfg, onOptimistic, onRecordEdit, onReplaceMedia, onUndoR
|
|
|
4542
4970
|
useEffect(() => {
|
|
4543
4971
|
if (hasCustom) setOpen((o) => (o.Advanced ? o : { ...o, Advanced: true }));
|
|
4544
4972
|
}, [el.id, hasCustom]);
|
|
4973
|
+
// DDR-171 — Designer mode's Position cluster mirrors the same auto-expand
|
|
4974
|
+
// precedent: collapsed by default (position is the rare case), auto-opens
|
|
4975
|
+
// the moment the element actually has a non-static position so the inset
|
|
4976
|
+
// fields the user just set (or that came from the source) aren't hidden.
|
|
4977
|
+
const hasCustomPosition = (authored.position || cssHint(computed.position) || 'static') !== 'static';
|
|
4978
|
+
useEffect(() => {
|
|
4979
|
+
if (hasCustomPosition) setOpen((o) => (o['d:Position'] ? o : { ...o, 'd:Position': true }));
|
|
4980
|
+
}, [el.id, hasCustomPosition]);
|
|
4981
|
+
|
|
4982
|
+
// DDR-171 — the panel's vocabulary mode: 'advanced' (today's raw-CSS panel,
|
|
4983
|
+
// unchanged, still the default — zero behavior change for existing users) or
|
|
4984
|
+
// 'designer' (the Figma-vocabulary regroup). Lifted to App state (DDR-171
|
|
4985
|
+
// follow-up) so the same preference is controllable from BOTH the in-panel
|
|
4986
|
+
// corner toggle AND Settings → Appearance, and the two never diverge — the
|
|
4987
|
+
// exact single-source-of-truth pattern `theme` uses. App owns the
|
|
4988
|
+
// `maude-cp-mode` localStorage persistence; here it's a controlled prop.
|
|
4989
|
+
const setMode = onSetMode;
|
|
4990
|
+
// Designer-mode per-cluster "···" disclosure state (Auto layout/Wrap,
|
|
4991
|
+
// Size/Min-Max, Position/z-index, Text/extras) — keyed by cluster name, kept
|
|
4992
|
+
// in-memory only (unlike `open`, not persisted; matches the disclosure being
|
|
4993
|
+
// a "peek", not a durable preference).
|
|
4994
|
+
const [designerMore, setDesignerMore] = useState({});
|
|
4545
4995
|
|
|
4546
4996
|
async function post(url, payload, key) {
|
|
4547
4997
|
setStatus((s) => ({ ...s, [key]: 'saving' }));
|
|
@@ -4659,30 +5109,50 @@ function CssKnobs({ el, cfg, onOptimistic, onRecordEdit, onReplaceMedia, onUndoR
|
|
|
4659
5109
|
const sizeModeSeg = (axis) => {
|
|
4660
5110
|
const cur = sizingModeOf(axis, authored, computed, parentLayout);
|
|
4661
5111
|
return (
|
|
4662
|
-
|
|
4663
|
-
|
|
4664
|
-
|
|
4665
|
-
|
|
4666
|
-
{
|
|
4667
|
-
|
|
4668
|
-
|
|
4669
|
-
|
|
4670
|
-
|
|
4671
|
-
|
|
4672
|
-
|
|
4673
|
-
|
|
4674
|
-
|
|
4675
|
-
aria-pressed={cur === m}
|
|
4676
|
-
disabled={!editable}
|
|
4677
|
-
onClick={() => applySizing(axis, m)}
|
|
4678
|
-
title={`${label} ${axis}`}
|
|
4679
|
-
>
|
|
4680
|
-
{label}
|
|
4681
|
-
</button>
|
|
4682
|
-
))}
|
|
5112
|
+
// handoff — a proper inspector row: "Width sizing" / "Height sizing" label
|
|
5113
|
+
// in the label column, the shared Segmented right-aligned in the control
|
|
5114
|
+
// column (aligned with the number inputs below), input-matching height.
|
|
5115
|
+
<div className="st-cp-moderow" key={`mode-${axis}`}>
|
|
5116
|
+
<span className="st-cp-modelabel">{axis === 'width' ? 'Width sizing' : 'Height sizing'}</span>
|
|
5117
|
+
<div className="st-cp-modeseg" role="group" aria-label={`${axis} sizing mode`}>
|
|
5118
|
+
<Segmented
|
|
5119
|
+
value={cur}
|
|
5120
|
+
ariaLabel={`${axis} sizing`}
|
|
5121
|
+
options={[{ value: 'fixed', label: 'fixed' }, { value: 'hug', label: 'hug' }, { value: 'fill', label: 'fill' }]}
|
|
5122
|
+
onChange={(m) => applySizing(axis, m)}
|
|
5123
|
+
/>
|
|
5124
|
+
</div>
|
|
4683
5125
|
</div>
|
|
4684
5126
|
);
|
|
4685
5127
|
};
|
|
5128
|
+
// DDR-171 — Designer mode's Auto-layout alignment: `AlignPad`'s 9-cell grid
|
|
5129
|
+
// maps to the TWO real CSS axes (`justify-content` = main axis,
|
|
5130
|
+
// `align-items` = cross axis), which axis is "horizontal" vs "vertical"
|
|
5131
|
+
// depending on `flex-direction`. Only the 3 positional align-items values
|
|
5132
|
+
// (start/center/end) round-trip through the pad — `stretch` reads as the
|
|
5133
|
+
// pad's center cell (closest visual analog) but the pad never WRITES
|
|
5134
|
+
// `stretch`; that stays an Advanced-mode-only value, same as Figma's own
|
|
5135
|
+
// alignment pad (no "stretch" cell — it's a separate control there too).
|
|
5136
|
+
const AP_JC = ['flex-start', 'center', 'flex-end'];
|
|
5137
|
+
const AP_AI = ['flex-start', 'center', 'flex-end'];
|
|
5138
|
+
const AP_ROWS = ['t', 'c', 'b'];
|
|
5139
|
+
const AP_COLS = ['l', 'c', 'r'];
|
|
5140
|
+
const alignPadCell = () => {
|
|
5141
|
+
const isRow = !(authored['flex-direction'] || cssHint(computed['flex-direction']) || 'row').startsWith('column');
|
|
5142
|
+
const jcPos = Math.max(0, AP_JC.indexOf(authored['justify-content'] || cssHint(computed['justify-content']) || 'flex-start'));
|
|
5143
|
+
const aiRaw = authored['align-items'] || cssHint(computed['align-items']) || 'stretch';
|
|
5144
|
+
const aiPos = aiRaw === 'stretch' ? 1 : Math.max(0, AP_AI.indexOf(aiRaw));
|
|
5145
|
+
const [h, v] = isRow ? [jcPos, aiPos] : [aiPos, jcPos];
|
|
5146
|
+
return AP_ROWS[v] + AP_COLS[h];
|
|
5147
|
+
};
|
|
5148
|
+
const setAlignPadCell = (cell) => {
|
|
5149
|
+
const v = AP_ROWS.indexOf(cell[0]);
|
|
5150
|
+
const h = AP_COLS.indexOf(cell[1]);
|
|
5151
|
+
const isRow = !(authored['flex-direction'] || cssHint(computed['flex-direction']) || 'row').startsWith('column');
|
|
5152
|
+
const [jcPos, aiPos] = isRow ? [h, v] : [v, h];
|
|
5153
|
+
commit('justify-content', AP_JC[jcPos]);
|
|
5154
|
+
commit('align-items', AP_AI[aiPos]);
|
|
5155
|
+
};
|
|
4686
5156
|
// Cmd+Z / Cmd+Shift+Z (or Cmd+Y) inside the inspector forwards to the canvas
|
|
4687
5157
|
// undo stack — Figma-parity: a property field reverts the last DOCUMENT edit,
|
|
4688
5158
|
// not field text. Without this, an edit committed with focus still in the
|
|
@@ -4698,69 +5168,6 @@ function CssKnobs({ el, cfg, onOptimistic, onRecordEdit, onReplaceMedia, onUndoR
|
|
|
4698
5168
|
onUndoRedo?.('redo');
|
|
4699
5169
|
}
|
|
4700
5170
|
};
|
|
4701
|
-
// Phase 12.3 (W2.2) — Figma/Webflow scrub: drag a number field horizontally to
|
|
4702
|
-
// change its value. Live preview via optimistic apply on every move (no source
|
|
4703
|
-
// write); commits ONCE on release. A pointer that doesn't pass a 3px threshold
|
|
4704
|
-
// is a normal click (focus to type). `opts.step` modifiers: shift = ×10, alt =
|
|
4705
|
-
// ×0.1. `opts.sides` enables Webflow box-model modifiers: alt = symmetric pair,
|
|
4706
|
-
// alt+shift = all four (else just this side). `opts.min` clamps (default 0).
|
|
4707
|
-
const makeScrub = (prop, opts = {}) => (e) => {
|
|
4708
|
-
if (e.button !== 0) return;
|
|
4709
|
-
const input = e.currentTarget;
|
|
4710
|
-
const startX = e.clientX;
|
|
4711
|
-
const baseN =
|
|
4712
|
-
Number.parseFloat(
|
|
4713
|
-
cssSplitUnit(authored[prop] ?? cssHint(computed[prop]) ?? '0').n || '0'
|
|
4714
|
-
) || 0;
|
|
4715
|
-
const unit = opts.unitless
|
|
4716
|
-
? ''
|
|
4717
|
-
: opts.unit || cssSplitUnit(authored[prop] ?? '').unit || 'px';
|
|
4718
|
-
const min = opts.min ?? 0;
|
|
4719
|
-
const fmt = (n) => (opts.unitless ? `${n}` : `${n}${unit}`);
|
|
4720
|
-
const sidesFor = (ev) => {
|
|
4721
|
-
if (!opts.sides) return [prop];
|
|
4722
|
-
if (ev.altKey && ev.shiftKey) return opts.sides.all;
|
|
4723
|
-
if (ev.altKey) return opts.sides.pair;
|
|
4724
|
-
return [prop];
|
|
4725
|
-
};
|
|
4726
|
-
let scrubbing = false;
|
|
4727
|
-
let last = baseN;
|
|
4728
|
-
const move = (ev) => {
|
|
4729
|
-
const dx = ev.clientX - startX;
|
|
4730
|
-
if (!scrubbing && Math.abs(dx) < 3) return;
|
|
4731
|
-
if (!scrubbing) {
|
|
4732
|
-
scrubbing = true;
|
|
4733
|
-
document.body.classList.add('st-scrubbing');
|
|
4734
|
-
}
|
|
4735
|
-
ev.preventDefault();
|
|
4736
|
-
const granular = opts.sides ? 1 : ev.shiftKey ? 10 : ev.altKey ? 0.1 : 1;
|
|
4737
|
-
last = Math.round((baseN + dx * granular) * 100) / 100;
|
|
4738
|
-
if (last < min) last = min;
|
|
4739
|
-
const sides = sidesFor(ev);
|
|
4740
|
-
// Live-update the dragged field AND, for a box-model multi-side scrub, the
|
|
4741
|
-
// sibling box inputs so the whole pair / four-up move shows in the panel —
|
|
4742
|
-
// not just the one being dragged (W2.2 feedback).
|
|
4743
|
-
if (input) input.value = String(last);
|
|
4744
|
-
if (opts.sides && sides.length > 1) {
|
|
4745
|
-
const box = input?.closest('.st-cp-box');
|
|
4746
|
-
for (const p of sides) {
|
|
4747
|
-
if (p === prop) continue;
|
|
4748
|
-
const sib = box?.querySelector(`.st-cp-boxv[aria-label="${p}"]`);
|
|
4749
|
-
if (sib) sib.value = String(last);
|
|
4750
|
-
}
|
|
4751
|
-
}
|
|
4752
|
-
for (const p of sides) optimistic(p, fmt(last));
|
|
4753
|
-
};
|
|
4754
|
-
const up = (ev) => {
|
|
4755
|
-
document.removeEventListener('pointermove', move);
|
|
4756
|
-
document.removeEventListener('pointerup', up);
|
|
4757
|
-
if (!scrubbing) return;
|
|
4758
|
-
document.body.classList.remove('st-scrubbing');
|
|
4759
|
-
for (const p of sidesFor(ev)) commit(p, fmt(last));
|
|
4760
|
-
};
|
|
4761
|
-
document.addEventListener('pointermove', move);
|
|
4762
|
-
document.addEventListener('pointerup', up);
|
|
4763
|
-
};
|
|
4764
5171
|
const provOf = (prop) => {
|
|
4765
5172
|
const v = authored[prop];
|
|
4766
5173
|
if (!v) return 'inherit';
|
|
@@ -4823,16 +5230,21 @@ function CssKnobs({ el, cfg, onOptimistic, onRecordEdit, onReplaceMedia, onUndoR
|
|
|
4823
5230
|
);
|
|
4824
5231
|
};
|
|
4825
5232
|
|
|
4826
|
-
|
|
5233
|
+
// `labelOverride` (DDR-171 — Designer mode) swaps BOTH the visible label text
|
|
5234
|
+
// AND the title tooltip together, so they never drift out of sync. Optional
|
|
5235
|
+
// and additive — every Advanced-mode call site omits it and renders exactly
|
|
5236
|
+
// as before (label = title = the raw CSS property name).
|
|
5237
|
+
const row = (prop, control, provKind, labelOverride) => {
|
|
4827
5238
|
// #1 bigger-bet — scannable diff: a fully-unset single-prop row is dimmed so
|
|
4828
5239
|
// the handful of overridden rows pop (Webflow/Framer model). Composite rows
|
|
4829
5240
|
// (border — they pass an explicit provKind) are never dimmed.
|
|
4830
5241
|
const unset = provKind === undefined && !authored[prop];
|
|
5242
|
+
const label = labelOverride ?? prop;
|
|
4831
5243
|
return (
|
|
4832
5244
|
<div className={`st-cp-row${unset ? ' is-unset' : ''}`} key={prop}>
|
|
4833
5245
|
{provDot(prop, provKind)}
|
|
4834
|
-
<label className="st-cp-label" title={
|
|
4835
|
-
{
|
|
5246
|
+
<label className="st-cp-label" title={label}>
|
|
5247
|
+
{label}
|
|
4836
5248
|
</label>
|
|
4837
5249
|
<div className="st-cp-ctl">{control}</div>
|
|
4838
5250
|
</div>
|
|
@@ -4894,6 +5306,8 @@ function CssKnobs({ el, cfg, onOptimistic, onRecordEdit, onReplaceMedia, onUndoR
|
|
|
4894
5306
|
'border-style',
|
|
4895
5307
|
'border-color',
|
|
4896
5308
|
'box-shadow',
|
|
5309
|
+
'filter',
|
|
5310
|
+
'mix-blend-mode',
|
|
4897
5311
|
'opacity',
|
|
4898
5312
|
'transform',
|
|
4899
5313
|
'transform-origin',
|
|
@@ -4921,23 +5335,87 @@ function CssKnobs({ el, cfg, onOptimistic, onRecordEdit, onReplaceMedia, onUndoR
|
|
|
4921
5335
|
</span>
|
|
4922
5336
|
{name}
|
|
4923
5337
|
</button>
|
|
4924
|
-
{
|
|
4925
|
-
|
|
4926
|
-
|
|
4927
|
-
|
|
4928
|
-
|
|
4929
|
-
|
|
4930
|
-
|
|
4931
|
-
|
|
4932
|
-
|
|
4933
|
-
|
|
4934
|
-
|
|
5338
|
+
{/* handoff — reset always present (design), dimmed when nothing to reset. */}
|
|
5339
|
+
<button
|
|
5340
|
+
type="button"
|
|
5341
|
+
className={`st-cp-secreset${dirty ? '' : ' is-quiet'}`}
|
|
5342
|
+
aria-label={`reset ${name} section to original`}
|
|
5343
|
+
title={`reset ${name}`}
|
|
5344
|
+
disabled={!dirty}
|
|
5345
|
+
onClick={() => resetSection(name)}
|
|
5346
|
+
>
|
|
5347
|
+
<Lu as={LuRotateCw} size={12} />
|
|
5348
|
+
</button>
|
|
5349
|
+
</div>
|
|
5350
|
+
{/* animated collapse — grid-rows 0fr→1fr keeps the DOM + interpolates
|
|
5351
|
+
height (feature-inspector-controls-redesign handoff). */}
|
|
5352
|
+
<div className={`st-cp-sec-anim${open[name] ? ' is-open' : ''}`}>
|
|
5353
|
+
<div className="st-cp-sec-inner">{body}</div>
|
|
4935
5354
|
</div>
|
|
4936
|
-
{open[name] ? body : null}
|
|
4937
5355
|
</section>
|
|
4938
5356
|
);
|
|
4939
5357
|
};
|
|
4940
5358
|
|
|
5359
|
+
// DDR-171 — Designer mode's cluster wrapper. Mirrors `sec()`'s exact chrome
|
|
5360
|
+
// (caret, reset, animated collapse — reuses `.st-cp-sec`/`.st-cp-sechd`/
|
|
5361
|
+
// `.st-cp-sec-anim` verbatim, no new CSS shape) but takes an explicit
|
|
5362
|
+
// `props` reset-list instead of looking one up in `SECTION_PROPS`, because
|
|
5363
|
+
// Designer clusters cut across Advanced-mode section boundaries and don't
|
|
5364
|
+
// map 1:1 onto it (e.g. "Auto layout" pulls from both Layout and Size).
|
|
5365
|
+
// Open-state lives in the SAME `open` object under a `d:`-prefixed key so no
|
|
5366
|
+
// second piece of state is needed; `defaultOpen=false` is how the Position
|
|
5367
|
+
// cluster starts collapsed (see `hasCustomPosition` auto-expand above).
|
|
5368
|
+
const dsec = (name, props, body, defaultOpen = true) => {
|
|
5369
|
+
const key = `d:${name}`;
|
|
5370
|
+
const isOpen = open[key] === undefined ? defaultOpen : open[key];
|
|
5371
|
+
const dirty = props.some((p) => authored[p]);
|
|
5372
|
+
return (
|
|
5373
|
+
<section className="st-cp-sec" key={key}>
|
|
5374
|
+
<div className="st-cp-sechd-row">
|
|
5375
|
+
<button
|
|
5376
|
+
type="button"
|
|
5377
|
+
className="st-cp-sechd"
|
|
5378
|
+
aria-expanded={isOpen}
|
|
5379
|
+
onClick={() => setOpen((o) => ({ ...o, [key]: !isOpen }))}
|
|
5380
|
+
>
|
|
5381
|
+
<span className="st-cp-caret" aria-hidden="true">
|
|
5382
|
+
{isOpen ? '▾' : '▸'}
|
|
5383
|
+
</span>
|
|
5384
|
+
{name}
|
|
5385
|
+
</button>
|
|
5386
|
+
<button
|
|
5387
|
+
type="button"
|
|
5388
|
+
className={`st-cp-secreset${dirty ? '' : ' is-quiet'}`}
|
|
5389
|
+
aria-label={`reset ${name} to original`}
|
|
5390
|
+
title={`reset ${name}`}
|
|
5391
|
+
disabled={!dirty}
|
|
5392
|
+
onClick={() => props.forEach((p) => { if (authored[p]) reset(p); })}
|
|
5393
|
+
>
|
|
5394
|
+
<Lu as={LuRotateCw} size={12} />
|
|
5395
|
+
</button>
|
|
5396
|
+
</div>
|
|
5397
|
+
<div className={`st-cp-sec-anim${isOpen ? ' is-open' : ''}`}>
|
|
5398
|
+
<div className="st-cp-sec-inner">{body}</div>
|
|
5399
|
+
</div>
|
|
5400
|
+
</section>
|
|
5401
|
+
);
|
|
5402
|
+
};
|
|
5403
|
+
// A cluster's "···" disclosure toggle for its less-common rows (Figma's own
|
|
5404
|
+
// per-panel overflow affordance) — distinct from the cluster's own
|
|
5405
|
+
// open/collapse caret above. `designerMore[key]` gates the extra rows.
|
|
5406
|
+
const moreBtn = (key) => (
|
|
5407
|
+
<button
|
|
5408
|
+
type="button"
|
|
5409
|
+
className="st-cp-clustermore"
|
|
5410
|
+
aria-expanded={!!designerMore[key]}
|
|
5411
|
+
aria-label={designerMore[key] ? `${key} — fewer options` : `${key} — more options`}
|
|
5412
|
+
title={designerMore[key] ? 'fewer' : 'more'}
|
|
5413
|
+
onClick={() => setDesignerMore((m) => ({ ...m, [key]: !m[key] }))}
|
|
5414
|
+
>
|
|
5415
|
+
{designerMore[key] ? '▴' : '···'}
|
|
5416
|
+
</button>
|
|
5417
|
+
);
|
|
5418
|
+
|
|
4941
5419
|
// native <select> committing a CSS value directly
|
|
4942
5420
|
const csel = (prop, list) => (
|
|
4943
5421
|
<select
|
|
@@ -4957,6 +5435,165 @@ function CssKnobs({ el, cfg, onOptimistic, onRecordEdit, onReplaceMedia, onUndoR
|
|
|
4957
5435
|
</select>
|
|
4958
5436
|
);
|
|
4959
5437
|
|
|
5438
|
+
// ── feature-inspector-controls-redesign handoff — the design's control set,
|
|
5439
|
+
// wired to CssKnobs' CSS-string commit lane. ────────────────────────────────
|
|
5440
|
+
const flatTokens = (familyKey) => tokenGroups(familyKey).flatMap((g) => (g.names || []).map((n) => ({ name: n, value: g.vals?.[n] || '' })));
|
|
5441
|
+
// enum → lucide icon button group (commits the raw CSS keyword)
|
|
5442
|
+
const iconseg = (prop, options) => (
|
|
5443
|
+
<IconButtonGroup value={authored[prop] || cssHint(computed[prop]) || options[0].value} ariaLabel={prop} options={options} onChange={(v) => commit(prop, v)} />
|
|
5444
|
+
);
|
|
5445
|
+
// number + unit + ◇ design-token binding (space / radius / type families)
|
|
5446
|
+
const vtok = (prop, familyKey, opts = {}) => {
|
|
5447
|
+
const cur = cssSplitUnit(authored[prop] ?? '');
|
|
5448
|
+
const unitless = CSS_UNITLESS.has(prop);
|
|
5449
|
+
const av = authored[prop] ?? '';
|
|
5450
|
+
const bound = typeof av === 'string' && /var\(\s*--/.test(av);
|
|
5451
|
+
const unit = unitless ? '' : cur.unit && cur.unit !== 'auto' ? cur.unit : 'px';
|
|
5452
|
+
const hintN = Number.parseFloat(cssSplitUnit(cssHint(computed[prop]) ?? '').n) || 0;
|
|
5453
|
+
const numVal = cur.n !== '' && cur.n != null ? Number.parseFloat(cur.n) || 0 : hintN;
|
|
5454
|
+
const lead = PROP_LEAD[prop];
|
|
5455
|
+
return (
|
|
5456
|
+
<ValueTokenField
|
|
5457
|
+
value={bound ? av : numVal}
|
|
5458
|
+
tokens={flatTokens(familyKey)}
|
|
5459
|
+
ariaLabel={prop}
|
|
5460
|
+
min={opts.min ?? 0}
|
|
5461
|
+
lead={lead ? (lead.node ?? lead.t) : undefined}
|
|
5462
|
+
unitSlot={unitless ? null : <UnitSelect units={CSS_UNITS} value={cur.unit || 'px'} ariaLabel={`${prop} unit`} onChange={(u) => commit(prop, u === 'auto' ? 'auto' : `${cur.n || '0'}${u}`)} />}
|
|
5463
|
+
onChange={(v) => commit(prop, typeof v === 'string' ? v : unitless ? `${v}` : `${v}${unit}`)}
|
|
5464
|
+
/>
|
|
5465
|
+
);
|
|
5466
|
+
};
|
|
5467
|
+
// border-radius → uniform field + ▢ detach → 2×2 corner quad
|
|
5468
|
+
const radiusControl = () => {
|
|
5469
|
+
const parse = (p) => Number.parseFloat(cssSplitUnit(authored[p] ?? authored['border-radius'] ?? cssHint(computed[p]) ?? cssHint(computed['border-radius']) ?? '0').n) || 0;
|
|
5470
|
+
const corners = { tl: parse('border-top-left-radius'), tr: parse('border-top-right-radius'), bl: parse('border-bottom-left-radius'), br: parse('border-bottom-right-radius') };
|
|
5471
|
+
const onCorners = (c) => {
|
|
5472
|
+
const uniform = c.tl === c.tr && c.tr === c.bl && c.bl === c.br;
|
|
5473
|
+
if (uniform) {
|
|
5474
|
+
['border-top-left-radius', 'border-top-right-radius', 'border-bottom-left-radius', 'border-bottom-right-radius'].forEach((p) => { if (authored[p]) reset(p); });
|
|
5475
|
+
commit('border-radius', `${c.tl}px`);
|
|
5476
|
+
} else {
|
|
5477
|
+
commit('border-top-left-radius', `${c.tl}px`);
|
|
5478
|
+
commit('border-top-right-radius', `${c.tr}px`);
|
|
5479
|
+
commit('border-bottom-left-radius', `${c.bl}px`);
|
|
5480
|
+
commit('border-bottom-right-radius', `${c.br}px`);
|
|
5481
|
+
}
|
|
5482
|
+
};
|
|
5483
|
+
const lead = PROP_LEAD['border-radius'];
|
|
5484
|
+
return <RadiusControl corners={corners} lead={lead ? (lead.node ?? lead.t) : undefined} onCorners={onCorners} />;
|
|
5485
|
+
};
|
|
5486
|
+
// border composite (width + style + colour) — DDR-171 pulled this out of the
|
|
5487
|
+
// Advanced-mode Appearance row inline JSX so Designer mode's "Stroke"
|
|
5488
|
+
// cluster (Task 6) can reuse the exact same control, not a re-implementation.
|
|
5489
|
+
const borderControl = () => (
|
|
5490
|
+
<div className="st-cp-border">
|
|
5491
|
+
{num('border-width', null, { fixedUnit: 'px' })}
|
|
5492
|
+
<select
|
|
5493
|
+
className="st-cp-nsel st-cp-nsel--mini"
|
|
5494
|
+
aria-label="border-style"
|
|
5495
|
+
value={CSS_BORDER_STYLES.includes(authored['border-style']) ? authored['border-style'] : ''}
|
|
5496
|
+
onChange={(e) => commit('border-style', e.target.value)}
|
|
5497
|
+
>
|
|
5498
|
+
<option value="" disabled>
|
|
5499
|
+
style
|
|
5500
|
+
</option>
|
|
5501
|
+
{CSS_BORDER_STYLES.map((s) => (
|
|
5502
|
+
<option key={s} value={s}>
|
|
5503
|
+
{s}
|
|
5504
|
+
</option>
|
|
5505
|
+
))}
|
|
5506
|
+
</select>
|
|
5507
|
+
<TokenPopover
|
|
5508
|
+
kind="color"
|
|
5509
|
+
groups={tokenGroups('color')}
|
|
5510
|
+
current={authored['border-color']}
|
|
5511
|
+
activeDs={_activeDs}
|
|
5512
|
+
swatchBg={computed['border-color'] || authored['border-color'] || ''}
|
|
5513
|
+
seedHex={cssColorToHex(computed['border-color'] || authored['border-color']) || '#000000'}
|
|
5514
|
+
onPick={(v) => commit('border-color', v)}
|
|
5515
|
+
label="border colour"
|
|
5516
|
+
/>
|
|
5517
|
+
</div>
|
|
5518
|
+
);
|
|
5519
|
+
// rotation dial, reading/writing the rotate() term of `transform`
|
|
5520
|
+
const rotationControl = () => {
|
|
5521
|
+
const t = authored.transform || cssHint(computed.transform) || '';
|
|
5522
|
+
const m = /rotate\(\s*(-?\d+(?:\.\d+)?)deg\s*\)/.exec(t);
|
|
5523
|
+
const deg = (((m ? Number.parseFloat(m[1]) : 0) % 360) + 360) % 360;
|
|
5524
|
+
const setDeg = (d) => {
|
|
5525
|
+
const norm = ((d % 360) + 360) % 360;
|
|
5526
|
+
const base = (authored.transform || '').replace(/\s*rotate\([^)]*\)\s*/g, ' ').trim();
|
|
5527
|
+
commit('transform', `${base ? `${base} ` : ''}rotate(${norm}deg)`.trim());
|
|
5528
|
+
};
|
|
5529
|
+
return (
|
|
5530
|
+
<div className="st-cp-num" style={{ border: 0, background: 'transparent', gap: 'var(--space-2)' }}>
|
|
5531
|
+
<AngleDial value={deg} onChange={setDeg} />
|
|
5532
|
+
<NumberField value={deg} min={0} max={360} ariaLabel="rotation" lead={<Lu as={LuRotateCw} />} steppers={false} unitSlot={<span className="st-cp-numsuffix" aria-hidden="true">°</span>} onCommit={setDeg} />
|
|
5533
|
+
</div>
|
|
5534
|
+
);
|
|
5535
|
+
};
|
|
5536
|
+
// DDR-171 — `filter: blur(Npx)`, scoped to blur-only for v1 (not a full
|
|
5537
|
+
// filter-function editor). Local parse/serialize (unlike most rows this
|
|
5538
|
+
// doesn't pass the raw string straight to `commit`) — kept as a closure here,
|
|
5539
|
+
// not a top-level utility, so it stays governed by the "no new primitives"
|
|
5540
|
+
// constraint. A non-blur `filter` value (set via Advanced's raw-CSS hatch)
|
|
5541
|
+
// reads as 0 here rather than being clobbered — only written back once the
|
|
5542
|
+
// user actually commits a blur amount.
|
|
5543
|
+
const blurControl = () => {
|
|
5544
|
+
const f = authored.filter || cssHint(computed.filter) || '';
|
|
5545
|
+
const m = /blur\(\s*(-?\d+(?:\.\d+)?)px\s*\)/.exec(f);
|
|
5546
|
+
const px = m ? Number.parseFloat(m[1]) : 0;
|
|
5547
|
+
const setBlur = (n) => {
|
|
5548
|
+
const base = (authored.filter || '').replace(/\s*blur\([^)]*\)\s*/g, ' ').trim();
|
|
5549
|
+
commit('filter', n > 0 ? `${base ? `${base} ` : ''}blur(${n}px)`.trim() : base || 'none');
|
|
5550
|
+
};
|
|
5551
|
+
return <NumberField value={px} min={0} ariaLabel="filter blur" unitSlot={<span className="st-cp-numsuffix" aria-hidden="true">px</span>} onCommit={setBlur} />;
|
|
5552
|
+
};
|
|
5553
|
+
// handoff — shown as 0–100 % (design), stored as the CSS 0–1 value. Pulled
|
|
5554
|
+
// out (DDR-171) so Designer mode's "Opacity" cluster reuses it verbatim.
|
|
5555
|
+
const opacityControl = () => {
|
|
5556
|
+
const a = authored.opacity;
|
|
5557
|
+
const raw = a != null && a !== '' ? Number.parseFloat(a) : Number.parseFloat(cssHint(computed.opacity)) || 1;
|
|
5558
|
+
const pct = Math.round((Number.isNaN(raw) ? 1 : raw) * 100);
|
|
5559
|
+
return (
|
|
5560
|
+
<SliderField
|
|
5561
|
+
key={`opacity:${a ?? ''}`}
|
|
5562
|
+
value={pct}
|
|
5563
|
+
min={0}
|
|
5564
|
+
max={100}
|
|
5565
|
+
step={1}
|
|
5566
|
+
unit="%"
|
|
5567
|
+
ariaLabel="opacity"
|
|
5568
|
+
onInput={(n) => optimistic('opacity', String(n / 100))}
|
|
5569
|
+
onCommit={(n) => commit('opacity', String(n / 100))}
|
|
5570
|
+
/>
|
|
5571
|
+
);
|
|
5572
|
+
};
|
|
5573
|
+
// B / I / U quick-style toggle group → font-weight / font-style / text-decoration
|
|
5574
|
+
const textStyleToggle = () => {
|
|
5575
|
+
const isBold = Number.parseInt(authored['font-weight'] || cssHint(computed['font-weight']) || '400', 10) >= 600;
|
|
5576
|
+
const isItalic = (authored['font-style'] || cssHint(computed['font-style'])) === 'italic';
|
|
5577
|
+
const isUnder = /underline/.test(authored['text-decoration'] || cssHint(computed['text-decoration']) || '');
|
|
5578
|
+
return (
|
|
5579
|
+
<IconToggleGroup
|
|
5580
|
+
value={{ b: isBold, i: isItalic, u: isUnder }}
|
|
5581
|
+
ariaLabel="text style"
|
|
5582
|
+
options={[{ value: 'b', node: <Lu as={LuBold} />, label: 'Bold' }, { value: 'i', node: <Lu as={LuItalic} />, label: 'Italic' }, { value: 'u', node: <Lu as={LuUnderline} />, label: 'Underline' }]}
|
|
5583
|
+
onToggle={(k) => {
|
|
5584
|
+
if (k === 'b') commit('font-weight', isBold ? '400' : '700');
|
|
5585
|
+
else if (k === 'i') commit('font-style', isItalic ? 'normal' : 'italic');
|
|
5586
|
+
else commit('text-decoration', isUnder ? 'none' : 'underline');
|
|
5587
|
+
}}
|
|
5588
|
+
/>
|
|
5589
|
+
);
|
|
5590
|
+
};
|
|
5591
|
+
const DIR_OPTS = [{ value: 'row', node: <Lu as={LuColumns3} />, label: 'Row' }, { value: 'column', node: <Lu as={LuRows3} />, label: 'Column' }];
|
|
5592
|
+
const JUSTIFY_OPTS = [{ value: 'flex-start', node: <Lu as={LuJustifyStart} />, label: 'Start' }, { value: 'center', node: <Lu as={LuJustifyCenter} />, label: 'Center' }, { value: 'flex-end', node: <Lu as={LuJustifyEnd} />, label: 'End' }, { value: 'space-between', node: <Lu as={LuSpaceBetween} />, label: 'Space between' }];
|
|
5593
|
+
const ALIGNITEMS_OPTS = [{ value: 'flex-start', node: <Lu as={LuVJustifyStart} />, label: 'Start' }, { value: 'center', node: <Lu as={LuVJustifyCenter} />, label: 'Center' }, { value: 'flex-end', node: <Lu as={LuVJustifyEnd} />, label: 'End' }, { value: 'stretch', node: <Lu as={LuStretch} />, label: 'Stretch' }];
|
|
5594
|
+
const TEXTALIGN_OPTS = [{ value: 'left', node: <Lu as={LuAlignLeft} />, label: 'Left' }, { value: 'center', node: <Lu as={LuAlignCenter} />, label: 'Center' }, { value: 'right', node: <Lu as={LuAlignRight} />, label: 'Right' }, { value: 'justify', node: <Lu as={LuAlignJustify} />, label: 'Justify' }];
|
|
5595
|
+
const OVERFLOW_OPTS = [{ value: 'visible', node: <Lu as={LuEye} />, label: 'Visible' }, { value: 'hidden', node: <Lu as={LuScissors} />, label: 'Hidden' }, { value: 'scroll', node: <Lu as={LuScrollText} />, label: 'Scroll' }];
|
|
5596
|
+
|
|
4960
5597
|
// token quick-pick — Figma-style POPOVER (W2.1) listing the DS variables for
|
|
4961
5598
|
// this property (name + resolved value), grouped per design system (W3);
|
|
4962
5599
|
// picking writes var(--token). `familyKey` selects the token family.
|
|
@@ -4974,6 +5611,31 @@ function CssKnobs({ el, cfg, onOptimistic, onRecordEdit, onReplaceMedia, onUndoR
|
|
|
4974
5611
|
) : null;
|
|
4975
5612
|
};
|
|
4976
5613
|
|
|
5614
|
+
// Select-all-on-focus (deferred past the browser's own click-caret placement
|
|
5615
|
+
// so a SECOND click, already focused, places the caret instead) — the
|
|
5616
|
+
// interaction-model rule applied to the CssKnobs fields that stay bespoke
|
|
5617
|
+
// <input>s (free text, box-model cells) rather than the shared NumberField.
|
|
5618
|
+
const selectAllOnFocus = (e) => {
|
|
5619
|
+
const el = e.currentTarget;
|
|
5620
|
+
requestAnimationFrame(() => {
|
|
5621
|
+
if (document.activeElement === el) el.select();
|
|
5622
|
+
});
|
|
5623
|
+
};
|
|
5624
|
+
// Arrow-key stepping (±1, Shift ×10) for the bespoke box-model cells — same
|
|
5625
|
+
// keyboard model NumberField gives the main fields, hand-rolled here because
|
|
5626
|
+
// these stay compact <input>s (no room for NumberField's handle+stepper
|
|
5627
|
+
// chrome in a 36×24 box-model cell). Returns true if it handled the key.
|
|
5628
|
+
const stepBoxInput = (e, commitFn) => {
|
|
5629
|
+
if (e.key !== 'ArrowUp' && e.key !== 'ArrowDown') return false;
|
|
5630
|
+
e.preventDefault();
|
|
5631
|
+
const mult = e.shiftKey ? 10 : 1;
|
|
5632
|
+
const dir = e.key === 'ArrowUp' ? 1 : -1;
|
|
5633
|
+
const n = (Number.parseFloat(e.currentTarget.value) || 0) + dir * mult;
|
|
5634
|
+
e.currentTarget.value = String(n);
|
|
5635
|
+
commitFn(n);
|
|
5636
|
+
return true;
|
|
5637
|
+
};
|
|
5638
|
+
|
|
4977
5639
|
// free text input — raw value or var(--token), commits on blur/Enter
|
|
4978
5640
|
const text = (prop) => (
|
|
4979
5641
|
<input
|
|
@@ -4982,6 +5644,7 @@ function CssKnobs({ el, cfg, onOptimistic, onRecordEdit, onReplaceMedia, onUndoR
|
|
|
4982
5644
|
aria-label={prop}
|
|
4983
5645
|
defaultValue={authored[prop] ?? ''}
|
|
4984
5646
|
placeholder={cssHint(computed[prop]) || '—'}
|
|
5647
|
+
onFocus={selectAllOnFocus}
|
|
4985
5648
|
onKeyDown={(e) => {
|
|
4986
5649
|
if (e.key === 'Enter') e.currentTarget.blur();
|
|
4987
5650
|
}}
|
|
@@ -4989,79 +5652,46 @@ function CssKnobs({ el, cfg, onOptimistic, onRecordEdit, onReplaceMedia, onUndoR
|
|
|
4989
5652
|
/>
|
|
4990
5653
|
);
|
|
4991
5654
|
|
|
4992
|
-
// number + steppers + unit-select (+ optional token quick-pick after)
|
|
5655
|
+
// number + steppers + unit-select (+ optional token quick-pick after) — built
|
|
5656
|
+
// on the shared NumberField (feature-inspector-controls-redesign): the drag
|
|
5657
|
+
// handle moves to the leading icon/grip (never the input body, so click-to-
|
|
5658
|
+
// type + select-all-on-focus work), and arrow-key stepping comes for free.
|
|
4993
5659
|
const num = (prop, tokenList, opts = {}) => {
|
|
4994
5660
|
const cur = cssSplitUnit(authored[prop] ?? '');
|
|
4995
5661
|
// Unitless CSS properties — a bare number must commit WITHOUT a unit suffix
|
|
4996
5662
|
// (line-height: 1.5px ≠ 1.5 — knob-smoke finding, 2026-06-12).
|
|
4997
5663
|
const unitless = CSS_UNITLESS.has(prop);
|
|
4998
|
-
|
|
4999
|
-
|
|
5000
|
-
|
|
5001
|
-
|
|
5002
|
-
};
|
|
5664
|
+
// `opts.fixedUnit` — a px-only field (border-width) skips the unit <select>
|
|
5665
|
+
// entirely so the compact border-cluster row (width + style + swatch) has
|
|
5666
|
+
// room to fit at the panel's 260-304px widths (Task 5 overflow fix).
|
|
5667
|
+
const unit = unitless ? '' : opts.fixedUnit || (cur.unit && cur.unit !== 'auto' ? cur.unit : 'px');
|
|
5003
5668
|
const lead = PROP_LEAD[prop];
|
|
5669
|
+
// Unset (no authored value) shows the computed/inherited value as the
|
|
5670
|
+
// starting number — same value the old placeholder hinted at; the row's own
|
|
5671
|
+
// dimming (`row()`'s `is-unset`) is what signals "inherited", not this field.
|
|
5672
|
+
const hintN = Number.parseFloat(cssSplitUnit(cssHint(computed[prop]) ?? '').n) || 0;
|
|
5673
|
+
const shownN = cur.n !== '' && cur.n != null ? Number.parseFloat(cur.n) || 0 : hintN;
|
|
5004
5674
|
return (
|
|
5005
5675
|
<>
|
|
5006
|
-
<
|
|
5007
|
-
{
|
|
5008
|
-
|
|
5009
|
-
|
|
5010
|
-
|
|
5011
|
-
|
|
5012
|
-
|
|
5013
|
-
|
|
5014
|
-
|
|
5015
|
-
|
|
5016
|
-
|
|
5017
|
-
|
|
5018
|
-
|
|
5019
|
-
|
|
5020
|
-
|
|
5021
|
-
|
|
5022
|
-
|
|
5023
|
-
|
|
5024
|
-
|
|
5025
|
-
commit(prop, /[a-z%(]/i.test(raw) ? raw : `${raw}${unit}`);
|
|
5026
|
-
}}
|
|
5027
|
-
/>
|
|
5028
|
-
<span className="st-cp-step">
|
|
5029
|
-
<button
|
|
5030
|
-
type="button"
|
|
5031
|
-
className="st-cp-stepb"
|
|
5032
|
-
tabIndex={-1}
|
|
5033
|
-
aria-label={`increase ${prop}`}
|
|
5034
|
-
onClick={() => bump(1)}
|
|
5035
|
-
>
|
|
5036
|
-
▲
|
|
5037
|
-
</button>
|
|
5038
|
-
<button
|
|
5039
|
-
type="button"
|
|
5040
|
-
className="st-cp-stepb"
|
|
5041
|
-
tabIndex={-1}
|
|
5042
|
-
aria-label={`decrease ${prop}`}
|
|
5043
|
-
onClick={() => bump(-1)}
|
|
5044
|
-
>
|
|
5045
|
-
▼
|
|
5046
|
-
</button>
|
|
5047
|
-
</span>
|
|
5048
|
-
{unitless ? null : (
|
|
5049
|
-
<select
|
|
5050
|
-
className="st-cp-unitsel"
|
|
5051
|
-
aria-label={`${prop} unit`}
|
|
5052
|
-
value={cur.unit || 'px'}
|
|
5053
|
-
onChange={(e) =>
|
|
5054
|
-
commit(prop, e.target.value === 'auto' ? 'auto' : `${cur.n || '0'}${e.target.value}`)
|
|
5055
|
-
}
|
|
5056
|
-
>
|
|
5057
|
-
{CSS_UNITS.map((u) => (
|
|
5058
|
-
<option key={u} value={u}>
|
|
5059
|
-
{u}
|
|
5060
|
-
</option>
|
|
5061
|
-
))}
|
|
5062
|
-
</select>
|
|
5063
|
-
)}
|
|
5064
|
-
</div>
|
|
5676
|
+
<NumberField
|
|
5677
|
+
key={`${prop}:${authored[prop] ?? ''}`}
|
|
5678
|
+
value={shownN}
|
|
5679
|
+
min={opts.min ?? 0}
|
|
5680
|
+
step={1}
|
|
5681
|
+
ariaLabel={prop}
|
|
5682
|
+
lead={lead ? (lead.node ?? lead.t) : undefined}
|
|
5683
|
+
onCommit={(n) => commit(prop, unitless ? `${n}` : `${n}${unit}`)}
|
|
5684
|
+
unitSlot={
|
|
5685
|
+
unitless || opts.fixedUnit ? null : (
|
|
5686
|
+
<UnitSelect
|
|
5687
|
+
units={CSS_UNITS}
|
|
5688
|
+
value={cur.unit || 'px'}
|
|
5689
|
+
ariaLabel={`${prop} unit`}
|
|
5690
|
+
onChange={(u) => commit(prop, u === 'auto' ? 'auto' : `${cur.n || '0'}${u}`)}
|
|
5691
|
+
/>
|
|
5692
|
+
)
|
|
5693
|
+
}
|
|
5694
|
+
/>
|
|
5065
5695
|
{tok(prop, tokenList)}
|
|
5066
5696
|
</>
|
|
5067
5697
|
);
|
|
@@ -5069,24 +5699,34 @@ function CssKnobs({ el, cfg, onOptimistic, onRecordEdit, onReplaceMedia, onUndoR
|
|
|
5069
5699
|
|
|
5070
5700
|
// color swatch (native picker → hex) + raw text + token quick-pick
|
|
5071
5701
|
const color = (prop) => {
|
|
5072
|
-
// ONE colour
|
|
5073
|
-
//
|
|
5074
|
-
//
|
|
5702
|
+
// ONE compact colour field (feature-inspector-controls-redesign handoff): the
|
|
5703
|
+
// TokenPopover swatch is the FLUSH prefix (divider, no gap) inside ColorField,
|
|
5704
|
+
// then the value input — swatch + value read as one field. The popover keeps
|
|
5705
|
+
// its full HSV picker (Custom) + DS swatches (Variables) + cross-DS/security.
|
|
5075
5706
|
const resolved = computed[prop] || authored[prop] || '';
|
|
5707
|
+
const av = authored[prop] ?? '';
|
|
5708
|
+
const bound = typeof av === 'string' && /var\(\s*--/.test(av);
|
|
5709
|
+
const display = bound ? av.replace(/^var\(\s*|\s*\)$/g, '').replace(/^--/, '').replace(/-/g, ' ') : av;
|
|
5076
5710
|
return (
|
|
5077
|
-
|
|
5078
|
-
|
|
5079
|
-
|
|
5080
|
-
|
|
5081
|
-
|
|
5082
|
-
|
|
5083
|
-
|
|
5084
|
-
|
|
5085
|
-
|
|
5086
|
-
|
|
5087
|
-
|
|
5088
|
-
|
|
5089
|
-
|
|
5711
|
+
<ColorField
|
|
5712
|
+
swatch={
|
|
5713
|
+
<TokenPopover
|
|
5714
|
+
kind="color"
|
|
5715
|
+
swatchClassName="st-cp-cf-sw"
|
|
5716
|
+
groups={tokenGroups('color')}
|
|
5717
|
+
current={authored[prop]}
|
|
5718
|
+
activeDs={_activeDs}
|
|
5719
|
+
swatchBg={resolved}
|
|
5720
|
+
seedHex={cssColorToHex(computed[prop] || authored[prop]) || '#000000'}
|
|
5721
|
+
onPick={(v) => commit(prop, v)}
|
|
5722
|
+
label={`${prop} colour`}
|
|
5723
|
+
/>
|
|
5724
|
+
}
|
|
5725
|
+
displayValue={display}
|
|
5726
|
+
bound={bound}
|
|
5727
|
+
ariaLabel={prop}
|
|
5728
|
+
onValue={(v) => commit(prop, v)}
|
|
5729
|
+
/>
|
|
5090
5730
|
);
|
|
5091
5731
|
};
|
|
5092
5732
|
|
|
@@ -5094,6 +5734,49 @@ function CssKnobs({ el, cfg, onOptimistic, onRecordEdit, onReplaceMedia, onUndoR
|
|
|
5094
5734
|
// always shows the RESOLVED value (0 instead of blank) and a faint `is-zero`
|
|
5095
5735
|
// styling for an unset/zero side. Edits the single side (the old "link all
|
|
5096
5736
|
// sides" toggle was removed — DDR-104 Phase 12.3 W1.5).
|
|
5737
|
+
// Built on the shared `makeScrubHandler` engine (feature-inspector-controls-
|
|
5738
|
+
// redesign) — a plain (non-hook) factory, since `side`/`inset` are helper
|
|
5739
|
+
// closures invoked during render, not components (can't call a hook there).
|
|
5740
|
+
// These stay compact <input>s with whole-cell scrub (no separate drag handle
|
|
5741
|
+
// — the Figma/Webflow convention for tiny box-model cells with no room for
|
|
5742
|
+
// one; the 3px dead-zone already lets a plain click through to focus). A
|
|
5743
|
+
// multi-side drag (alt = pair, alt+shift = all four) live-updates the sibling
|
|
5744
|
+
// box inputs too, so the whole move shows in the panel, not just the dragged
|
|
5745
|
+
// cell — `node` closes over the actual input DOM element via onPointerDown.
|
|
5746
|
+
const boxScrub = (prop, opts) => {
|
|
5747
|
+
let node = null;
|
|
5748
|
+
const unit = opts.unitless ? '' : 'px';
|
|
5749
|
+
const fmt = (n) => (opts.unitless ? `${n}` : `${n}${unit}`);
|
|
5750
|
+
const applyToSides = (n, activeSides, fn) => {
|
|
5751
|
+
for (const p of activeSides ?? [prop]) fn(p, fmt(n));
|
|
5752
|
+
};
|
|
5753
|
+
const scrub = makeScrubHandler({
|
|
5754
|
+
getBase: () => node?.value ?? '0',
|
|
5755
|
+
min: opts.min ?? 0,
|
|
5756
|
+
step: 1,
|
|
5757
|
+
sides: opts.sides,
|
|
5758
|
+
onInput: (n, activeSides) => {
|
|
5759
|
+
if (node) node.value = String(n);
|
|
5760
|
+
if (activeSides) {
|
|
5761
|
+
const box = node?.closest('.st-cp-box');
|
|
5762
|
+
for (const p of activeSides) {
|
|
5763
|
+
if (p === prop) continue;
|
|
5764
|
+
const sib = box?.querySelector(`.st-cp-boxv[aria-label="${p}"]`);
|
|
5765
|
+
if (sib) sib.value = String(n);
|
|
5766
|
+
}
|
|
5767
|
+
}
|
|
5768
|
+
applyToSides(n, activeSides, optimistic);
|
|
5769
|
+
},
|
|
5770
|
+
onCommit: (n, activeSides) => applyToSides(n, activeSides, commit),
|
|
5771
|
+
});
|
|
5772
|
+
return {
|
|
5773
|
+
onPointerDown: (e) => {
|
|
5774
|
+
node = e.currentTarget;
|
|
5775
|
+
scrub(e);
|
|
5776
|
+
},
|
|
5777
|
+
};
|
|
5778
|
+
};
|
|
5779
|
+
|
|
5097
5780
|
const side = (prop, group) => {
|
|
5098
5781
|
const a = authored[prop];
|
|
5099
5782
|
const shown =
|
|
@@ -5118,8 +5801,10 @@ function CssKnobs({ el, cfg, onOptimistic, onRecordEdit, onReplaceMedia, onUndoR
|
|
|
5118
5801
|
aria-label={prop}
|
|
5119
5802
|
defaultValue={shown}
|
|
5120
5803
|
title="drag to scrub · alt = symmetric · alt+shift = all sides"
|
|
5121
|
-
|
|
5804
|
+
{...boxScrub(prop, { sides: { pair, all } })}
|
|
5805
|
+
onFocus={selectAllOnFocus}
|
|
5122
5806
|
onKeyDown={(e) => {
|
|
5807
|
+
if (stepBoxInput(e, (n) => commit(prop, `${n}px`))) return;
|
|
5123
5808
|
if (e.key === 'Enter') e.currentTarget.blur();
|
|
5124
5809
|
}}
|
|
5125
5810
|
onBlur={(e) => {
|
|
@@ -5153,8 +5838,10 @@ function CssKnobs({ el, cfg, onOptimistic, onRecordEdit, onReplaceMedia, onUndoR
|
|
|
5153
5838
|
defaultValue={shown}
|
|
5154
5839
|
placeholder="auto"
|
|
5155
5840
|
title="drag to scrub · alt = axis pair · alt+shift = all sides · type auto"
|
|
5156
|
-
|
|
5841
|
+
{...boxScrub(prop, { sides: { pair, all }, min: -Infinity })}
|
|
5842
|
+
onFocus={selectAllOnFocus}
|
|
5157
5843
|
onKeyDown={(e) => {
|
|
5844
|
+
if (stepBoxInput(e, (n) => commit(prop, `${n}px`))) return;
|
|
5158
5845
|
if (e.key === 'Enter') e.currentTarget.blur();
|
|
5159
5846
|
}}
|
|
5160
5847
|
onBlur={(e) => {
|
|
@@ -5167,40 +5854,261 @@ function CssKnobs({ el, cfg, onOptimistic, onRecordEdit, onReplaceMedia, onUndoR
|
|
|
5167
5854
|
);
|
|
5168
5855
|
};
|
|
5169
5856
|
|
|
5170
|
-
const corner = (label, prop) => (
|
|
5171
|
-
<label className="st-cp-cornerf">
|
|
5172
|
-
<span>{label}</span>
|
|
5173
|
-
<input
|
|
5174
|
-
key={`${prop}:${authored[prop] ?? ''}`}
|
|
5175
|
-
aria-label={prop}
|
|
5176
|
-
defaultValue={cssSplitUnit(authored[prop] ?? '').n || ''}
|
|
5177
|
-
placeholder={cssHint(computed[prop]) || '0'}
|
|
5178
|
-
onKeyDown={(e) => {
|
|
5179
|
-
if (e.key === 'Enter') e.currentTarget.blur();
|
|
5180
|
-
}}
|
|
5181
|
-
onBlur={(e) => {
|
|
5182
|
-
const raw = e.currentTarget.value.trim();
|
|
5183
|
-
if (raw) commit(prop, /[a-z%]/i.test(raw) ? raw : `${raw}px`);
|
|
5184
|
-
}}
|
|
5185
|
-
/>
|
|
5186
|
-
</label>
|
|
5187
|
-
);
|
|
5188
|
-
|
|
5189
5857
|
// Phase 12.3 — authored inline props with no curated row + custom HTML attrs,
|
|
5190
5858
|
// surfaced in Advanced so the user can see/edit/remove what they added.
|
|
5191
5859
|
const customStyleRows = Object.entries(customStyles);
|
|
5192
5860
|
const attrRows = Object.entries(attrs);
|
|
5193
5861
|
|
|
5862
|
+
// Stage B (Task B5) — Media framing gate + body, pulled out (DDR-171) so
|
|
5863
|
+
// Designer mode's "Media" cluster renders the identical content (just a
|
|
5864
|
+
// relabeled wrapper) instead of re-deriving `showMedia`/`canReplace`.
|
|
5865
|
+
// Rendered only for a media element (img / video / picture / svg / canvas)
|
|
5866
|
+
// or a selection that already carries a framing prop, so a plain <div>
|
|
5867
|
+
// doesn't grow object-fit rows. Media = box/framing/source (this plan); the
|
|
5868
|
+
// photo-editor plan's "Photo" tab owns pixels/look — separate DOM slots by
|
|
5869
|
+
// design.
|
|
5870
|
+
const mediaGate = () => {
|
|
5871
|
+
const t = (el.tag || '').toLowerCase();
|
|
5872
|
+
const isMediaEl = t === 'img' || t === 'video' || t === 'picture' || t === 'svg' || t === 'canvas';
|
|
5873
|
+
const showMedia =
|
|
5874
|
+
isMediaEl || !!authored['object-fit'] || !!authored['object-position'] || !!authored['aspect-ratio'];
|
|
5875
|
+
// Stage F2 — "Replace…" opens the AssetPicker to re-point src (authored
|
|
5876
|
+
// <img>/<video> only; a template-expression src can't be string-swapped,
|
|
5877
|
+
// so gate on a real src attr being present).
|
|
5878
|
+
const canReplace = (t === 'img' || t === 'video') && !!el.attrs?.src && !!onReplaceMedia;
|
|
5879
|
+
return { showMedia, canReplace };
|
|
5880
|
+
};
|
|
5881
|
+
const mediaBody = (canReplace) => (
|
|
5882
|
+
<>
|
|
5883
|
+
{canReplace && (
|
|
5884
|
+
<div className="st-cp-mediabtn">
|
|
5885
|
+
<button type="button" className="st-btn st-cp-replace" onClick={() => onReplaceMedia(el)}>
|
|
5886
|
+
Replace…
|
|
5887
|
+
</button>
|
|
5888
|
+
</div>
|
|
5889
|
+
)}
|
|
5890
|
+
{row('object-fit', csel('object-fit', CSS_OBJECT_FIT))}
|
|
5891
|
+
{row('object-position', text('object-position'))}
|
|
5892
|
+
{row(
|
|
5893
|
+
'aspect-ratio',
|
|
5894
|
+
<select
|
|
5895
|
+
className="st-cp-nsel"
|
|
5896
|
+
aria-label="aspect-ratio"
|
|
5897
|
+
value={CSS_ASPECT_RATIO.includes(authored['aspect-ratio']) ? authored['aspect-ratio'] : ''}
|
|
5898
|
+
onChange={(e) => {
|
|
5899
|
+
const v = e.target.value;
|
|
5900
|
+
commit('aspect-ratio', v);
|
|
5901
|
+
// A fixed height overrides aspect-ratio (CSS: explicit width+height
|
|
5902
|
+
// win). When applying a real ratio, release the height so the ratio
|
|
5903
|
+
// actually reshapes the box (dogfood: "nastavil jsem 16/9 a nic se
|
|
5904
|
+
// nestalo").
|
|
5905
|
+
if (v && v !== 'auto' && authored.height) reset('height');
|
|
5906
|
+
}}
|
|
5907
|
+
>
|
|
5908
|
+
<option value="" disabled>
|
|
5909
|
+
{cssHint(computed['aspect-ratio']) || '—'}
|
|
5910
|
+
</option>
|
|
5911
|
+
{CSS_ASPECT_RATIO.map((v) => (
|
|
5912
|
+
<option key={v} value={v}>
|
|
5913
|
+
{v}
|
|
5914
|
+
</option>
|
|
5915
|
+
))}
|
|
5916
|
+
</select>
|
|
5917
|
+
)}
|
|
5918
|
+
</>
|
|
5919
|
+
);
|
|
5920
|
+
|
|
5194
5921
|
return (
|
|
5195
|
-
<div className=
|
|
5922
|
+
<div className={`st-cp${mode === 'designer' ? ' st-cp--designer' : ''}`} key={el.id} data-tour="css-panel" onKeyDown={onKnobKeyDown}>
|
|
5196
5923
|
<div className="st-cp-id">
|
|
5197
5924
|
<span className="st-cp-idtag">
|
|
5198
5925
|
{el.tag || 'element'}
|
|
5199
5926
|
{el.classes ? <span className="st-cp-idcls">.{el.classes.split(/\s+/)[0]}</span> : null}
|
|
5200
5927
|
</span>
|
|
5201
|
-
|
|
5928
|
+
{/* DDR-171 — vocabulary mode toggle, tucked into the id row's corner
|
|
5929
|
+
slot (was a full-width Segmented row — read as too heavy; a
|
|
5930
|
+
two-icon IconButtonGroup matches every other compact toggle in
|
|
5931
|
+
this panel). 'advanced' is the default (today's panel, byte-
|
|
5932
|
+
identical below); 'designer' swaps in the Figma-vocabulary regroup.
|
|
5933
|
+
Named "Advanced" (not "Simple"/"Basic") so neither mode reads as
|
|
5934
|
+
the lesser fallback — see DDR-171 for the naming-collision call
|
|
5935
|
+
against the nested Advanced *section* below. */}
|
|
5936
|
+
<span className="st-cp-idmode" data-tour="cp-mode">
|
|
5937
|
+
<IconButtonGroup
|
|
5938
|
+
value={mode}
|
|
5939
|
+
ariaLabel="panel vocabulary mode"
|
|
5940
|
+
options={[
|
|
5941
|
+
{ value: 'advanced', node: <Lu as={LuBraces} size={12} />, label: 'Advanced — raw CSS' },
|
|
5942
|
+
{ value: 'designer', node: <Lu as={LuWand2} size={12} />, label: 'Designer — Figma vocabulary' },
|
|
5943
|
+
]}
|
|
5944
|
+
onChange={setMode}
|
|
5945
|
+
/>
|
|
5946
|
+
</span>
|
|
5202
5947
|
</div>
|
|
5203
5948
|
|
|
5949
|
+
{mode === 'designer' ? (
|
|
5950
|
+
<>
|
|
5951
|
+
{dsec(
|
|
5952
|
+
'Auto layout',
|
|
5953
|
+
['display', 'flex-direction', 'flex-wrap', 'align-items', 'justify-content', 'gap', 'padding-top', 'padding-right', 'padding-bottom', 'padding-left'],
|
|
5954
|
+
(() => {
|
|
5955
|
+
const disp = (authored.display || cssHint(computed.display) || '').trim();
|
|
5956
|
+
const isFlex = disp === 'flex' || disp === 'inline-flex';
|
|
5957
|
+
return isFlex ? (
|
|
5958
|
+
<>
|
|
5959
|
+
{row('flex-direction', iconseg('flex-direction', DIR_OPTS), undefined, 'Direction')}
|
|
5960
|
+
{row(
|
|
5961
|
+
'align-items',
|
|
5962
|
+
<AlignPad value={alignPadCell()} onChange={setAlignPadCell} ariaLabel="auto-layout alignment" />,
|
|
5963
|
+
provOf('align-items'),
|
|
5964
|
+
'Alignment'
|
|
5965
|
+
)}
|
|
5966
|
+
{row('gap', vtok('gap', 'space'), undefined, 'Gap')}
|
|
5967
|
+
<div className="st-cp-modes">
|
|
5968
|
+
{sizeModeSeg('width')}
|
|
5969
|
+
{sizeModeSeg('height')}
|
|
5970
|
+
</div>
|
|
5971
|
+
<div className="st-cp-box" aria-label="padding">
|
|
5972
|
+
<span className="st-cp-boxtag st-cp-boxtag--p">
|
|
5973
|
+
{prov(provOf('padding-top'))}padding
|
|
5974
|
+
</span>
|
|
5975
|
+
{side('padding-top', 'padding')}
|
|
5976
|
+
{side('padding-right', 'padding')}
|
|
5977
|
+
{side('padding-bottom', 'padding')}
|
|
5978
|
+
{side('padding-left', 'padding')}
|
|
5979
|
+
</div>
|
|
5980
|
+
<div className="st-cp-clustermore-row">{moreBtn('Auto layout')}</div>
|
|
5981
|
+
{designerMore['Auto layout']
|
|
5982
|
+
? row(
|
|
5983
|
+
'flex-wrap',
|
|
5984
|
+
<Toggle
|
|
5985
|
+
checked={/^wrap/.test(authored['flex-wrap'] || cssHint(computed['flex-wrap']) || '')}
|
|
5986
|
+
label="wrap items"
|
|
5987
|
+
ariaLabel="flex-wrap"
|
|
5988
|
+
onChange={(w) => commit('flex-wrap', w ? 'wrap' : 'nowrap')}
|
|
5989
|
+
/>,
|
|
5990
|
+
provOf('flex-wrap'),
|
|
5991
|
+
'Wrap'
|
|
5992
|
+
)
|
|
5993
|
+
: null}
|
|
5994
|
+
</>
|
|
5995
|
+
) : (
|
|
5996
|
+
<button type="button" className="st-cp-makeflex" disabled={!editable} onClick={() => commit('display', 'flex')}>
|
|
5997
|
+
+ Auto layout (flex)
|
|
5998
|
+
</button>
|
|
5999
|
+
);
|
|
6000
|
+
})()
|
|
6001
|
+
)}
|
|
6002
|
+
|
|
6003
|
+
{dsec(
|
|
6004
|
+
'Size',
|
|
6005
|
+
['width', 'height', 'min-width', 'max-width', 'min-height', 'max-height'],
|
|
6006
|
+
<>
|
|
6007
|
+
{row('width', num('width'), undefined, 'Width')}
|
|
6008
|
+
{row('height', num('height'), undefined, 'Height')}
|
|
6009
|
+
<div className="st-cp-clustermore-row">{moreBtn('Size')}</div>
|
|
6010
|
+
{designerMore.Size ? (
|
|
6011
|
+
<>
|
|
6012
|
+
{row('min-width', num('min-width'), undefined, 'Min width')}
|
|
6013
|
+
{row('max-width', num('max-width'), undefined, 'Max width')}
|
|
6014
|
+
{row('min-height', num('min-height'), undefined, 'Min height')}
|
|
6015
|
+
{row('max-height', num('max-height'), undefined, 'Max height')}
|
|
6016
|
+
</>
|
|
6017
|
+
) : null}
|
|
6018
|
+
</>
|
|
6019
|
+
)}
|
|
6020
|
+
|
|
6021
|
+
{dsec(
|
|
6022
|
+
'Position',
|
|
6023
|
+
['position', 'top', 'right', 'bottom', 'left', 'z-index'],
|
|
6024
|
+
<>
|
|
6025
|
+
{row('position', csel('position', CSS_POSITION), undefined, 'Position')}
|
|
6026
|
+
<div className="st-cp-box st-cp-box--inset" aria-label="position inset (top / right / bottom / left)">
|
|
6027
|
+
<span className="st-cp-boxtag st-cp-boxtag--i">{prov(provOf('top'))}inset</span>
|
|
6028
|
+
{inset('top')}
|
|
6029
|
+
{inset('right')}
|
|
6030
|
+
{inset('bottom')}
|
|
6031
|
+
{inset('left')}
|
|
6032
|
+
<div className="st-cp-boxcore st-cp-boxcore--pos">
|
|
6033
|
+
{authored.position || cssHint(computed.position) || 'static'}
|
|
6034
|
+
</div>
|
|
6035
|
+
</div>
|
|
6036
|
+
{(authored.position || cssHint(computed.position) || 'static') === 'static' ? (
|
|
6037
|
+
<div className="st-cp-note">
|
|
6038
|
+
top / right / bottom / left apply once position is relative, absolute, fixed, or sticky
|
|
6039
|
+
</div>
|
|
6040
|
+
) : null}
|
|
6041
|
+
<div className="st-cp-clustermore-row">{moreBtn('Position')}</div>
|
|
6042
|
+
{designerMore.Position ? row('z-index', num('z-index'), undefined, 'Layer order') : null}
|
|
6043
|
+
</>,
|
|
6044
|
+
false
|
|
6045
|
+
)}
|
|
6046
|
+
|
|
6047
|
+
{dsec('Fill', ['background-color'], row('background-color', color('background-color'), undefined, 'Fill'))}
|
|
6048
|
+
|
|
6049
|
+
{dsec('Stroke', ['border-width', 'border-style', 'border-color'], row('border', borderControl(), provOf('border-width'), 'Stroke'))}
|
|
6050
|
+
|
|
6051
|
+
{dsec(
|
|
6052
|
+
'Corner radius',
|
|
6053
|
+
['border-radius', 'border-top-left-radius', 'border-top-right-radius', 'border-bottom-left-radius', 'border-bottom-right-radius'],
|
|
6054
|
+
row('border-radius', radiusControl(), provOf('border-radius'), 'Corner radius')
|
|
6055
|
+
)}
|
|
6056
|
+
|
|
6057
|
+
{dsec(
|
|
6058
|
+
'Effects',
|
|
6059
|
+
['box-shadow', 'filter', 'mix-blend-mode'],
|
|
6060
|
+
<>
|
|
6061
|
+
{row('box-shadow', tok('box-shadow', 'shadow') || text('box-shadow'), undefined, 'Shadow')}
|
|
6062
|
+
{row('filter', blurControl(), provOf('filter'), 'Blur')}
|
|
6063
|
+
{row('mix-blend-mode', csel('mix-blend-mode', CSS_BLEND_MODES), undefined, 'Blend')}
|
|
6064
|
+
</>
|
|
6065
|
+
)}
|
|
6066
|
+
|
|
6067
|
+
{dsec('Opacity', ['opacity'], row('opacity', opacityControl(), undefined, 'Opacity'))}
|
|
6068
|
+
|
|
6069
|
+
{dsec(
|
|
6070
|
+
'Text',
|
|
6071
|
+
['font-family', 'color', 'font-size', 'font-weight', 'line-height', 'text-align', 'letter-spacing', 'font-style', 'text-transform', 'white-space'],
|
|
6072
|
+
<>
|
|
6073
|
+
{row('font-family', csel('font-family', CSS_FONTS), undefined, 'Font')}
|
|
6074
|
+
{row('color', color('color'), undefined, 'Color')}
|
|
6075
|
+
{row('font-size', vtok('font-size', 'type'), undefined, 'Size')}
|
|
6076
|
+
{row('font-weight', csel('font-weight', CSS_WEIGHTS), undefined, 'Weight')}
|
|
6077
|
+
{row('line-height', num('line-height', 'lh'), undefined, 'Line height')}
|
|
6078
|
+
{row('text-align', iconseg('text-align', TEXTALIGN_OPTS), undefined, 'Align')}
|
|
6079
|
+
<div className="st-cp-clustermore-row">{moreBtn('Text')}</div>
|
|
6080
|
+
{designerMore.Text ? (
|
|
6081
|
+
<>
|
|
6082
|
+
{row('letter-spacing', num('letter-spacing', null, { min: -Infinity }), undefined, 'Letter spacing')}
|
|
6083
|
+
{row('font-style', csel('font-style', CSS_FONT_STYLE), undefined, 'Style')}
|
|
6084
|
+
{row('text-transform', csel('text-transform', CSS_TEXT_TRANSFORM), undefined, 'Case')}
|
|
6085
|
+
{row('white-space', csel('white-space', CSS_WHITE_SPACE), undefined, 'Whitespace')}
|
|
6086
|
+
</>
|
|
6087
|
+
) : null}
|
|
6088
|
+
</>
|
|
6089
|
+
)}
|
|
6090
|
+
|
|
6091
|
+
{dsec(
|
|
6092
|
+
'Spacing',
|
|
6093
|
+
['margin-top', 'margin-right', 'margin-bottom', 'margin-left'],
|
|
6094
|
+
<div className="st-cp-box" aria-label="margin">
|
|
6095
|
+
<span className="st-cp-boxtag st-cp-boxtag--m">{prov(provOf('margin-top'))}margin</span>
|
|
6096
|
+
{side('margin-top', 'margin')}
|
|
6097
|
+
{side('margin-right', 'margin')}
|
|
6098
|
+
{side('margin-bottom', 'margin')}
|
|
6099
|
+
{side('margin-left', 'margin')}
|
|
6100
|
+
</div>
|
|
6101
|
+
)}
|
|
6102
|
+
|
|
6103
|
+
{(() => {
|
|
6104
|
+
const { showMedia, canReplace } = mediaGate();
|
|
6105
|
+
return showMedia
|
|
6106
|
+
? dsec('Media', ['object-fit', 'object-position', 'aspect-ratio'], mediaBody(canReplace))
|
|
6107
|
+
: null;
|
|
6108
|
+
})()}
|
|
6109
|
+
</>
|
|
6110
|
+
) : (
|
|
6111
|
+
<>
|
|
5204
6112
|
{sec(
|
|
5205
6113
|
'Layout',
|
|
5206
6114
|
(() => {
|
|
@@ -5218,15 +6126,15 @@ function CssKnobs({ el, cfg, onOptimistic, onRecordEdit, onReplaceMedia, onUndoR
|
|
|
5218
6126
|
{row('display', csel('display', CSS_DISPLAYS))}
|
|
5219
6127
|
{isFlex ? (
|
|
5220
6128
|
<>
|
|
5221
|
-
{row('flex-direction',
|
|
5222
|
-
{row('flex-wrap',
|
|
6129
|
+
{row('flex-direction', iconseg('flex-direction', DIR_OPTS))}
|
|
6130
|
+
{row('flex-wrap', <Toggle checked={/^wrap/.test(authored['flex-wrap'] || cssHint(computed['flex-wrap']) || '')} label="wrap items" ariaLabel="flex-wrap" onChange={(w) => commit('flex-wrap', w ? 'wrap' : 'nowrap')} />, provOf('flex-wrap'))}
|
|
5223
6131
|
</>
|
|
5224
6132
|
) : null}
|
|
5225
6133
|
{isFlex || isGrid ? (
|
|
5226
6134
|
<>
|
|
5227
|
-
{row('align-items',
|
|
5228
|
-
{row('justify-content',
|
|
5229
|
-
{row('gap',
|
|
6135
|
+
{row('align-items', iconseg('align-items', ALIGNITEMS_OPTS))}
|
|
6136
|
+
{row('justify-content', iconseg('justify-content', JUSTIFY_OPTS))}
|
|
6137
|
+
{row('gap', vtok('gap', 'space'))}
|
|
5230
6138
|
</>
|
|
5231
6139
|
) : (
|
|
5232
6140
|
<button
|
|
@@ -5271,35 +6179,16 @@ function CssKnobs({ el, cfg, onOptimistic, onRecordEdit, onReplaceMedia, onUndoR
|
|
|
5271
6179
|
<>
|
|
5272
6180
|
{row('font-family', csel('font-family', CSS_FONTS))}
|
|
5273
6181
|
{row('color', color('color'))}
|
|
5274
|
-
{row('font-size',
|
|
6182
|
+
{row('font-size', vtok('font-size', 'type'))}
|
|
5275
6183
|
{row('font-weight', csel('font-weight', CSS_WEIGHTS))}
|
|
5276
6184
|
{row('line-height', num('line-height', 'lh'))}
|
|
5277
6185
|
{row('letter-spacing', num('letter-spacing', null, { min: -Infinity }))}
|
|
5278
|
-
{
|
|
5279
|
-
|
|
5280
|
-
|
|
5281
|
-
|
|
5282
|
-
<button
|
|
5283
|
-
key={a}
|
|
5284
|
-
type="button"
|
|
5285
|
-
className={`st-cp-segbtn${(authored['text-align'] || computed['text-align']) === a ? ' is-active' : ''}`}
|
|
5286
|
-
aria-label={`align ${a}`}
|
|
5287
|
-
aria-pressed={(authored['text-align'] || computed['text-align']) === a}
|
|
5288
|
-
onClick={() => commit('text-align', a)}
|
|
5289
|
-
>
|
|
5290
|
-
<span className={`st-cp-bars st-cp-bars--${a === 'justify' ? 'just' : a}`} aria-hidden="true">
|
|
5291
|
-
<i />
|
|
5292
|
-
<i />
|
|
5293
|
-
<i />
|
|
5294
|
-
</span>
|
|
5295
|
-
</button>
|
|
5296
|
-
))}
|
|
5297
|
-
</div>
|
|
5298
|
-
)}
|
|
6186
|
+
{/* handoff — text-align as a lucide icon button group; B/I/U as a toggle
|
|
6187
|
+
group mapped to font-weight / font-style / text-decoration. */}
|
|
6188
|
+
{row('text-align', iconseg('text-align', TEXTALIGN_OPTS))}
|
|
6189
|
+
{row('font-style', textStyleToggle(), provOf('font-style'))}
|
|
5299
6190
|
{/* Stage B (Task B4) — promoted typography knobs (was DDR-104 OUT-list). */}
|
|
5300
|
-
{row('font-style', csel('font-style', CSS_FONT_STYLE))}
|
|
5301
6191
|
{row('text-transform', csel('text-transform', CSS_TEXT_TRANSFORM))}
|
|
5302
|
-
{row('text-decoration', csel('text-decoration', CSS_TEXT_DECORATION))}
|
|
5303
6192
|
{row('white-space', csel('white-space', CSS_WHITE_SPACE))}
|
|
5304
6193
|
</>
|
|
5305
6194
|
)}
|
|
@@ -5324,7 +6213,9 @@ function CssKnobs({ el, cfg, onOptimistic, onRecordEdit, onReplaceMedia, onUndoR
|
|
|
5324
6213
|
{side('padding-bottom', 'padding')}
|
|
5325
6214
|
{side('padding-left', 'padding')}
|
|
5326
6215
|
<div className="st-cp-boxcore">
|
|
5327
|
-
{
|
|
6216
|
+
{/* handoff — prefer the AUTHORED size (updates live as you edit
|
|
6217
|
+
width/height); el.bounds is stale until the canvas re-measures. */}
|
|
6218
|
+
{Math.round(Number.parseFloat(authored.width) || el.bounds?.w || 0)} × {Math.round(Number.parseFloat(authored.height) || el.bounds?.h || 0)}
|
|
5328
6219
|
</div>
|
|
5329
6220
|
</div>
|
|
5330
6221
|
</div>
|
|
@@ -5346,7 +6237,7 @@ function CssKnobs({ el, cfg, onOptimistic, onRecordEdit, onReplaceMedia, onUndoR
|
|
|
5346
6237
|
{row('max-width', num('max-width'))}
|
|
5347
6238
|
{row('min-height', num('min-height'))}
|
|
5348
6239
|
{row('max-height', num('max-height'))}
|
|
5349
|
-
{row('overflow',
|
|
6240
|
+
{row('overflow', iconseg('overflow', OVERFLOW_OPTS))}
|
|
5350
6241
|
{/* Stage M1 — flex-CHILD controls, only meaningful when the parent is a
|
|
5351
6242
|
flex container. align-self is the cross-axis override; flex-grow/shrink/
|
|
5352
6243
|
basis are the main-axis behavior the Fill mode writes for you. */}
|
|
@@ -5358,163 +6249,32 @@ function CssKnobs({ el, cfg, onOptimistic, onRecordEdit, onReplaceMedia, onUndoR
|
|
|
5358
6249
|
{row('flex-shrink', num('flex-shrink', null, { unitless: true }))}
|
|
5359
6250
|
{row('flex-basis', num('flex-basis'))}
|
|
5360
6251
|
</>
|
|
5361
|
-
) : null}
|
|
5362
|
-
</>
|
|
5363
|
-
)}
|
|
5364
|
-
|
|
5365
|
-
{/* Stage B (Task B5) — Media framing
|
|
5366
|
-
(
|
|
5367
|
-
|
|
5368
|
-
|
|
5369
|
-
|
|
5370
|
-
|
|
5371
|
-
const t = (el.tag || '').toLowerCase();
|
|
5372
|
-
const isMediaEl = t === 'img' || t === 'video' || t === 'picture' || t === 'svg' || t === 'canvas';
|
|
5373
|
-
const showMedia =
|
|
5374
|
-
isMediaEl ||
|
|
5375
|
-
!!authored['object-fit'] ||
|
|
5376
|
-
!!authored['object-position'] ||
|
|
5377
|
-
!!authored['aspect-ratio'];
|
|
5378
|
-
// Stage F2 — "Replace…" opens the AssetPicker to re-point src (authored
|
|
5379
|
-
// <img>/<video> only; a template-expression src can't be string-swapped,
|
|
5380
|
-
// so gate on a real src attr being present).
|
|
5381
|
-
const canReplace = (t === 'img' || t === 'video') && !!el.attrs?.src && !!onReplaceMedia;
|
|
5382
|
-
return showMedia
|
|
5383
|
-
? sec(
|
|
5384
|
-
'Media',
|
|
5385
|
-
<>
|
|
5386
|
-
{canReplace && (
|
|
5387
|
-
<div className="st-cp-mediabtn">
|
|
5388
|
-
<button
|
|
5389
|
-
type="button"
|
|
5390
|
-
className="st-btn st-cp-replace"
|
|
5391
|
-
onClick={() => onReplaceMedia(el)}
|
|
5392
|
-
>
|
|
5393
|
-
Replace…
|
|
5394
|
-
</button>
|
|
5395
|
-
</div>
|
|
5396
|
-
)}
|
|
5397
|
-
{row('object-fit', csel('object-fit', CSS_OBJECT_FIT))}
|
|
5398
|
-
{row('object-position', text('object-position'))}
|
|
5399
|
-
{row(
|
|
5400
|
-
'aspect-ratio',
|
|
5401
|
-
<select
|
|
5402
|
-
className="st-cp-nsel"
|
|
5403
|
-
aria-label="aspect-ratio"
|
|
5404
|
-
value={
|
|
5405
|
-
CSS_ASPECT_RATIO.includes(authored['aspect-ratio'])
|
|
5406
|
-
? authored['aspect-ratio']
|
|
5407
|
-
: ''
|
|
5408
|
-
}
|
|
5409
|
-
onChange={(e) => {
|
|
5410
|
-
const v = e.target.value;
|
|
5411
|
-
commit('aspect-ratio', v);
|
|
5412
|
-
// A fixed height overrides aspect-ratio (CSS: explicit
|
|
5413
|
-
// width+height win). When applying a real ratio, release the
|
|
5414
|
-
// height so the ratio actually reshapes the box (dogfood:
|
|
5415
|
-
// "nastavil jsem 16/9 a nic se nestalo").
|
|
5416
|
-
if (v && v !== 'auto' && authored.height) reset('height');
|
|
5417
|
-
}}
|
|
5418
|
-
>
|
|
5419
|
-
<option value="" disabled>
|
|
5420
|
-
{cssHint(computed['aspect-ratio']) || '—'}
|
|
5421
|
-
</option>
|
|
5422
|
-
{CSS_ASPECT_RATIO.map((v) => (
|
|
5423
|
-
<option key={v} value={v}>
|
|
5424
|
-
{v}
|
|
5425
|
-
</option>
|
|
5426
|
-
))}
|
|
5427
|
-
</select>
|
|
5428
|
-
)}
|
|
5429
|
-
</>
|
|
5430
|
-
)
|
|
5431
|
-
: null;
|
|
6252
|
+
) : null}
|
|
6253
|
+
</>
|
|
6254
|
+
)}
|
|
6255
|
+
|
|
6256
|
+
{/* Stage B (Task B5) — Media framing (gate/body pulled into mediaGate()/
|
|
6257
|
+
mediaBody() above, DDR-171, so Designer mode's "Media" cluster
|
|
6258
|
+
reuses the identical content). */}
|
|
6259
|
+
{(() => {
|
|
6260
|
+
const { showMedia, canReplace } = mediaGate();
|
|
6261
|
+
return showMedia ? sec('Media', mediaBody(canReplace)) : null;
|
|
5432
6262
|
})()}
|
|
5433
6263
|
|
|
5434
6264
|
{sec(
|
|
5435
6265
|
'Appearance',
|
|
5436
6266
|
<>
|
|
5437
6267
|
{row('background-color', color('background-color'))}
|
|
5438
|
-
|
|
5439
|
-
|
|
5440
|
-
<label className="st-cp-label" title="border-radius">
|
|
5441
|
-
border-radius
|
|
5442
|
-
</label>
|
|
5443
|
-
<div className="st-cp-ctl">
|
|
5444
|
-
{num('border-radius', 'radius')}
|
|
5445
|
-
<button
|
|
5446
|
-
type="button"
|
|
5447
|
-
className={`st-cp-split${split ? ' is-on' : ''}`}
|
|
5448
|
-
aria-pressed={split}
|
|
5449
|
-
aria-label="set each corner separately"
|
|
5450
|
-
title="set each corner separately"
|
|
5451
|
-
onClick={() => setSplit((v) => !v)}
|
|
5452
|
-
/>
|
|
5453
|
-
</div>
|
|
5454
|
-
</div>
|
|
5455
|
-
{split ? (
|
|
5456
|
-
<div className="st-cp-corners" aria-label="per-corner radius">
|
|
5457
|
-
{corner('TL', 'border-top-left-radius')}
|
|
5458
|
-
{corner('TR', 'border-top-right-radius')}
|
|
5459
|
-
{corner('BL', 'border-bottom-left-radius')}
|
|
5460
|
-
{corner('BR', 'border-bottom-right-radius')}
|
|
5461
|
-
</div>
|
|
5462
|
-
) : null}
|
|
5463
|
-
{row(
|
|
5464
|
-
'border',
|
|
5465
|
-
<div className="st-cp-border">
|
|
5466
|
-
{num('border-width')}
|
|
5467
|
-
<select
|
|
5468
|
-
className="st-cp-nsel st-cp-nsel--mini"
|
|
5469
|
-
aria-label="border-style"
|
|
5470
|
-
value={CSS_BORDER_STYLES.includes(authored['border-style']) ? authored['border-style'] : ''}
|
|
5471
|
-
onChange={(e) => commit('border-style', e.target.value)}
|
|
5472
|
-
>
|
|
5473
|
-
<option value="" disabled>
|
|
5474
|
-
style
|
|
5475
|
-
</option>
|
|
5476
|
-
{CSS_BORDER_STYLES.map((s) => (
|
|
5477
|
-
<option key={s} value={s}>
|
|
5478
|
-
{s}
|
|
5479
|
-
</option>
|
|
5480
|
-
))}
|
|
5481
|
-
</select>
|
|
5482
|
-
<TokenPopover
|
|
5483
|
-
kind="color"
|
|
5484
|
-
groups={tokenGroups('color')}
|
|
5485
|
-
current={authored['border-color']}
|
|
5486
|
-
activeDs={_activeDs}
|
|
5487
|
-
swatchBg={computed['border-color'] || authored['border-color'] || ''}
|
|
5488
|
-
seedHex={
|
|
5489
|
-
cssColorToHex(computed['border-color'] || authored['border-color']) || '#000000'
|
|
5490
|
-
}
|
|
5491
|
-
onPick={(v) => commit('border-color', v)}
|
|
5492
|
-
label="border colour"
|
|
5493
|
-
/>
|
|
5494
|
-
</div>,
|
|
5495
|
-
provOf('border-width')
|
|
5496
|
-
)}
|
|
6268
|
+
{row('border-radius', radiusControl(), provOf('border-radius'))}
|
|
6269
|
+
{row('border', borderControl(), provOf('border-width'))}
|
|
5497
6270
|
{row('box-shadow', tok('box-shadow', 'shadow') || text('box-shadow'))}
|
|
5498
|
-
{
|
|
5499
|
-
|
|
5500
|
-
|
|
5501
|
-
|
|
5502
|
-
|
|
5503
|
-
</span>
|
|
5504
|
-
<input
|
|
5505
|
-
className="st-cp-numin"
|
|
5506
|
-
key={`opacity:${authored.opacity ?? ''}`}
|
|
5507
|
-
aria-label="opacity"
|
|
5508
|
-
defaultValue={authored.opacity ?? ''}
|
|
5509
|
-
placeholder={cssHint(computed.opacity) || '1'}
|
|
5510
|
-
onKeyDown={(e) => {
|
|
5511
|
-
if (e.key === 'Enter') e.currentTarget.blur();
|
|
5512
|
-
}}
|
|
5513
|
-
onBlur={(e) => commit('opacity', e.currentTarget.value)}
|
|
5514
|
-
/>
|
|
5515
|
-
</div>
|
|
5516
|
-
)}
|
|
6271
|
+
{/* DDR-171 — blur + blend, real Advanced-mode rows (not Designer-exclusive);
|
|
6272
|
+
also power Designer mode's "Effects" cluster. */}
|
|
6273
|
+
{row('filter', blurControl(), provOf('filter'))}
|
|
6274
|
+
{row('mix-blend-mode', csel('mix-blend-mode', CSS_BLEND_MODES))}
|
|
6275
|
+
{row('opacity', opacityControl())}
|
|
5517
6276
|
{/* Stage B (Task B4) — transform as a free-value row (mirrors box-shadow). */}
|
|
6277
|
+
{row('rotation', rotationControl(), provOf('transform'))}
|
|
5518
6278
|
{row('transform', text('transform'))}
|
|
5519
6279
|
{row('transform-origin', text('transform-origin'))}
|
|
5520
6280
|
</>
|
|
@@ -5613,6 +6373,8 @@ function CssKnobs({ el, cfg, onOptimistic, onRecordEdit, onReplaceMedia, onUndoR
|
|
|
5613
6373
|
<AttrKnob commit={commitAttr} />
|
|
5614
6374
|
</div>
|
|
5615
6375
|
)}
|
|
6376
|
+
</>
|
|
6377
|
+
)}
|
|
5616
6378
|
|
|
5617
6379
|
<div className="st-cp-legend">
|
|
5618
6380
|
<span>
|
|
@@ -5991,7 +6753,14 @@ function resolveArtboardIdFromSelection(el) {
|
|
|
5991
6753
|
// /_api/resize-artboard, NOT edit-css — DDR-027 numeric JSX props) + the SAME
|
|
5992
6754
|
// SCREEN_PRESETS the "+ Artboard" menu uses, so picking "Tablet" resizes the
|
|
5993
6755
|
// CURRENT artboard to 834×1194 in one click instead of typing both fields.
|
|
5994
|
-
|
|
6756
|
+
const ARTBOARD_LAYOUT_OPTIONS = [
|
|
6757
|
+
['', 'Block (default)'],
|
|
6758
|
+
['flex-col', 'Flex ↓ (column)'],
|
|
6759
|
+
['flex-row', 'Flex → (row)'],
|
|
6760
|
+
['grid', 'Grid'],
|
|
6761
|
+
];
|
|
6762
|
+
|
|
6763
|
+
function ArtboardKnobs({ el, onResizeArtboard, onSetArtboardHug, onSetArtboardStyle }) {
|
|
5995
6764
|
const artboardId = resolveArtboardIdFromSelection(el);
|
|
5996
6765
|
// Dogfood 2026-07-07 (round 2) — `worldW`/`worldH` (zoom-independent) are
|
|
5997
6766
|
// undefined for a selection that reached here via a code path predating
|
|
@@ -6019,6 +6788,25 @@ function ArtboardKnobs({ el, onResizeArtboard }) {
|
|
|
6019
6788
|
const activePreset = Object.entries(SCREEN_PRESETS).find(
|
|
6020
6789
|
([, p]) => p.width === w && p.height === h
|
|
6021
6790
|
)?.[0];
|
|
6791
|
+
// Hug default (artboard "hug height") — current mode + the "more settings"
|
|
6792
|
+
// (background/padding/layout/gap) read off the SAME generic `attrs` escape
|
|
6793
|
+
// hatch dom-selection.ts already scrapes for every selection (the `data-dc-*`
|
|
6794
|
+
// attributes DCArtboard stamps on the frame purely for this panel to read
|
|
6795
|
+
// its own resolved props back — see canvas-lib.tsx readBackAttrs).
|
|
6796
|
+
const fixed = el.attrs?.['data-dc-fixed'] === 'true';
|
|
6797
|
+
const bg = el.attrs?.['data-dc-bg'] ?? '';
|
|
6798
|
+
const padding = el.attrs?.['data-dc-padding'] ?? '';
|
|
6799
|
+
const layoutMode = el.attrs?.['data-dc-layout'] ?? '';
|
|
6800
|
+
const gap = el.attrs?.['data-dc-gap'] ?? '';
|
|
6801
|
+
const setHug = (nextFixed) => {
|
|
6802
|
+
if (!artboardId) return;
|
|
6803
|
+
const freezeHeight = nextFixed && Number.isFinite(h) ? Math.round(h) : undefined;
|
|
6804
|
+
onSetArtboardHug?.(artboardId, nextFixed, freezeHeight);
|
|
6805
|
+
};
|
|
6806
|
+
const commitStyle = (patch) => {
|
|
6807
|
+
if (!artboardId) return;
|
|
6808
|
+
onSetArtboardStyle?.(artboardId, patch);
|
|
6809
|
+
};
|
|
6022
6810
|
return (
|
|
6023
6811
|
<section className="st-cp-sec">
|
|
6024
6812
|
<div className="st-cp-sechd-row">
|
|
@@ -6051,12 +6839,17 @@ function ArtboardKnobs({ el, onResizeArtboard }) {
|
|
|
6051
6839
|
type="number"
|
|
6052
6840
|
min="1"
|
|
6053
6841
|
aria-label="artboard height"
|
|
6054
|
-
|
|
6842
|
+
readOnly={!fixed}
|
|
6843
|
+
title={fixed ? undefined : 'Hug — grows to fit content. Switch to Fixed to set an exact height.'}
|
|
6844
|
+
key={`h:${h ?? ''}:${fixed}`}
|
|
6055
6845
|
defaultValue={h ?? ''}
|
|
6056
6846
|
onKeyDown={(e) => {
|
|
6057
6847
|
if (e.key === 'Enter') e.currentTarget.blur();
|
|
6058
6848
|
}}
|
|
6059
|
-
onBlur={(e) =>
|
|
6849
|
+
onBlur={(e) => {
|
|
6850
|
+
if (!fixed) return;
|
|
6851
|
+
commitSize(null, Number.parseFloat(e.currentTarget.value));
|
|
6852
|
+
}}
|
|
6060
6853
|
/>
|
|
6061
6854
|
</div>
|
|
6062
6855
|
</div>
|
|
@@ -6080,6 +6873,104 @@ function ArtboardKnobs({ el, onResizeArtboard }) {
|
|
|
6080
6873
|
))}
|
|
6081
6874
|
</select>
|
|
6082
6875
|
</div>
|
|
6876
|
+
<div style={{ padding: '0 12px 8px' }}>
|
|
6877
|
+
<div className="st-cp-modeseg" role="group" aria-label="artboard height sizing mode">
|
|
6878
|
+
<span className="st-cp-modeax" aria-hidden="true">
|
|
6879
|
+
H
|
|
6880
|
+
</span>
|
|
6881
|
+
{[
|
|
6882
|
+
[false, 'Hug'],
|
|
6883
|
+
[true, 'Fixed'],
|
|
6884
|
+
].map(([val, label]) => (
|
|
6885
|
+
<button
|
|
6886
|
+
key={label}
|
|
6887
|
+
type="button"
|
|
6888
|
+
className={`st-cp-modebtn${fixed === val ? ' is-active' : ''}`}
|
|
6889
|
+
aria-pressed={fixed === val}
|
|
6890
|
+
onClick={() => setHug(val)}
|
|
6891
|
+
title={`${label} height`}
|
|
6892
|
+
>
|
|
6893
|
+
{label}
|
|
6894
|
+
</button>
|
|
6895
|
+
))}
|
|
6896
|
+
</div>
|
|
6897
|
+
</div>
|
|
6898
|
+
<div className="st-cp-sechd-row">
|
|
6899
|
+
<span className="st-cp-sechd">Style</span>
|
|
6900
|
+
</div>
|
|
6901
|
+
<div style={{ padding: '4px 12px 8px', display: 'flex', flexDirection: 'column', gap: 8 }}>
|
|
6902
|
+
<div className="st-cp-num" style={{ width: '100%' }}>
|
|
6903
|
+
<span className="st-cp-numlead" aria-hidden="true">
|
|
6904
|
+
Bg
|
|
6905
|
+
</span>
|
|
6906
|
+
<input
|
|
6907
|
+
className="st-cp-numin"
|
|
6908
|
+
type="text"
|
|
6909
|
+
aria-label="artboard background"
|
|
6910
|
+
placeholder="var(--bg-1)"
|
|
6911
|
+
key={`bg:${bg}`}
|
|
6912
|
+
defaultValue={bg}
|
|
6913
|
+
onKeyDown={(e) => {
|
|
6914
|
+
if (e.key === 'Enter') e.currentTarget.blur();
|
|
6915
|
+
}}
|
|
6916
|
+
onBlur={(e) => commitStyle({ background: e.currentTarget.value.trim() || null })}
|
|
6917
|
+
/>
|
|
6918
|
+
</div>
|
|
6919
|
+
<select
|
|
6920
|
+
className="st-cp-nsel"
|
|
6921
|
+
aria-label="artboard body layout"
|
|
6922
|
+
value={layoutMode}
|
|
6923
|
+
onChange={(e) => commitStyle({ layout: e.currentTarget.value || null })}
|
|
6924
|
+
>
|
|
6925
|
+
{ARTBOARD_LAYOUT_OPTIONS.map(([val, label]) => (
|
|
6926
|
+
<option key={val} value={val}>
|
|
6927
|
+
{label}
|
|
6928
|
+
</option>
|
|
6929
|
+
))}
|
|
6930
|
+
</select>
|
|
6931
|
+
<div style={{ display: 'flex', gap: 8 }}>
|
|
6932
|
+
<div className="st-cp-num">
|
|
6933
|
+
<span className="st-cp-numlead" aria-hidden="true">
|
|
6934
|
+
Pad
|
|
6935
|
+
</span>
|
|
6936
|
+
<input
|
|
6937
|
+
className="st-cp-numin"
|
|
6938
|
+
type="number"
|
|
6939
|
+
min="0"
|
|
6940
|
+
aria-label="artboard padding"
|
|
6941
|
+
key={`pad:${padding}`}
|
|
6942
|
+
defaultValue={padding}
|
|
6943
|
+
onKeyDown={(e) => {
|
|
6944
|
+
if (e.key === 'Enter') e.currentTarget.blur();
|
|
6945
|
+
}}
|
|
6946
|
+
onBlur={(e) => {
|
|
6947
|
+
const v = e.currentTarget.value;
|
|
6948
|
+
commitStyle({ padding: v === '' ? null : Number.parseFloat(v) });
|
|
6949
|
+
}}
|
|
6950
|
+
/>
|
|
6951
|
+
</div>
|
|
6952
|
+
<div className="st-cp-num">
|
|
6953
|
+
<span className="st-cp-numlead" aria-hidden="true">
|
|
6954
|
+
Gap
|
|
6955
|
+
</span>
|
|
6956
|
+
<input
|
|
6957
|
+
className="st-cp-numin"
|
|
6958
|
+
type="number"
|
|
6959
|
+
min="0"
|
|
6960
|
+
aria-label="artboard gap"
|
|
6961
|
+
key={`gap:${gap}`}
|
|
6962
|
+
defaultValue={gap}
|
|
6963
|
+
onKeyDown={(e) => {
|
|
6964
|
+
if (e.key === 'Enter') e.currentTarget.blur();
|
|
6965
|
+
}}
|
|
6966
|
+
onBlur={(e) => {
|
|
6967
|
+
const v = e.currentTarget.value;
|
|
6968
|
+
commitStyle({ gap: v === '' ? null : Number.parseFloat(v) });
|
|
6969
|
+
}}
|
|
6970
|
+
/>
|
|
6971
|
+
</div>
|
|
6972
|
+
</div>
|
|
6973
|
+
</div>
|
|
6083
6974
|
</section>
|
|
6084
6975
|
);
|
|
6085
6976
|
}
|
|
@@ -6123,6 +7014,8 @@ function InspectorPanel({
|
|
|
6123
7014
|
onRecordEdit,
|
|
6124
7015
|
onReplaceMedia,
|
|
6125
7016
|
onResizeArtboard,
|
|
7017
|
+
onSetArtboardHug,
|
|
7018
|
+
onSetArtboardStyle,
|
|
6126
7019
|
onUndoRedo,
|
|
6127
7020
|
editScope,
|
|
6128
7021
|
tab: tabProp,
|
|
@@ -6139,13 +7032,23 @@ function InspectorPanel({
|
|
|
6139
7032
|
onPhotoRemoveBackground,
|
|
6140
7033
|
onPhotoRecordEdit,
|
|
6141
7034
|
onPhotoUndoRedo,
|
|
7035
|
+
// feature-configurable-panel-docking — when true this instance IS the standalone
|
|
7036
|
+
// Layers panel: it forces the Layers view and hides the tab bar (the tab strip
|
|
7037
|
+
// lives on the dock slot instead). `hideLayersTab` drops the inline Layers tab
|
|
7038
|
+
// when Layers has been split into its own panel.
|
|
7039
|
+
layersOnly = false,
|
|
7040
|
+
hideLayersTab = false,
|
|
7041
|
+
// DDR-171 — CSS-panel vocabulary mode ('advanced' | 'designer'), owned by App
|
|
7042
|
+
// (single source of truth shared with Settings → Appearance).
|
|
7043
|
+
cpMode,
|
|
7044
|
+
onSetCpMode,
|
|
6142
7045
|
}) {
|
|
6143
7046
|
// Tab is controllable from the parent (the guided tour drives it to 'css' /
|
|
6144
7047
|
// 'layers' so a spotlight step lands on a real row) but falls back to local
|
|
6145
7048
|
// state for normal use. A user click both updates local state and notifies the
|
|
6146
7049
|
// parent, so the two stay in lockstep whichever owns it.
|
|
6147
7050
|
const [tabState, setTabState] = useState('inspect');
|
|
6148
|
-
const tab = tabProp ?? tabState;
|
|
7051
|
+
const tab = layersOnly ? 'layers' : (tabProp ?? tabState);
|
|
6149
7052
|
const setTab = (t) => {
|
|
6150
7053
|
setTabState(t);
|
|
6151
7054
|
onTabChange?.(t);
|
|
@@ -6486,13 +7389,17 @@ function InspectorPanel({
|
|
|
6486
7389
|
aria-label="Inspector"
|
|
6487
7390
|
data-tour="inspector"
|
|
6488
7391
|
>
|
|
6489
|
-
<div
|
|
7392
|
+
<div
|
|
7393
|
+
className="st-rp-tabs"
|
|
7394
|
+
data-tour="inspector-tabs"
|
|
7395
|
+
style={layersOnly ? { display: 'none' } : undefined}
|
|
7396
|
+
>
|
|
6490
7397
|
{photoOnly ? (
|
|
6491
7398
|
tabBtn('photo', 'Photo', 'image')
|
|
6492
7399
|
) : (
|
|
6493
7400
|
<>
|
|
6494
7401
|
{tabBtn('inspect', 'Inspect', 'sliders')}
|
|
6495
|
-
{tabBtn('layers', 'Layers', 'layers')}
|
|
7402
|
+
{!hideLayersTab && tabBtn('layers', 'Layers', 'layers')}
|
|
6496
7403
|
{tabBtn('css', 'CSS', 'code')}
|
|
6497
7404
|
{photoTarget ? tabBtn('photo', 'Photo', 'image') : null}
|
|
6498
7405
|
</>
|
|
@@ -6538,6 +7445,7 @@ function InspectorPanel({
|
|
|
6538
7445
|
key={`${photoTarget.asset}:${photoRev ?? 0}`}
|
|
6539
7446
|
asset={photoTarget.asset}
|
|
6540
7447
|
ColorPicker={ColorPicker}
|
|
7448
|
+
StIcon={StIcon}
|
|
6541
7449
|
onEdit={(edit) => onPhotoEdit?.(photoTarget.asset, edit)}
|
|
6542
7450
|
onRemoveBackground={onPhotoRemoveBackground}
|
|
6543
7451
|
onRecordEdit={(before, after) => onPhotoRecordEdit?.(photoTarget.asset, before, after)}
|
|
@@ -6674,7 +7582,12 @@ function InspectorPanel({
|
|
|
6674
7582
|
)}
|
|
6675
7583
|
</>
|
|
6676
7584
|
) : !el.id && resolveArtboardIdFromSelection(el) ? (
|
|
6677
|
-
<ArtboardKnobs
|
|
7585
|
+
<ArtboardKnobs
|
|
7586
|
+
el={el}
|
|
7587
|
+
onResizeArtboard={onResizeArtboard}
|
|
7588
|
+
onSetArtboardHug={onSetArtboardHug}
|
|
7589
|
+
onSetArtboardStyle={onSetArtboardStyle}
|
|
7590
|
+
/>
|
|
6678
7591
|
) : (
|
|
6679
7592
|
<CssKnobs
|
|
6680
7593
|
el={el}
|
|
@@ -6683,6 +7596,8 @@ function InspectorPanel({
|
|
|
6683
7596
|
onRecordEdit={onRecordEdit}
|
|
6684
7597
|
onReplaceMedia={onReplaceMedia}
|
|
6685
7598
|
onUndoRedo={onUndoRedo}
|
|
7599
|
+
mode={cpMode}
|
|
7600
|
+
onSetMode={onSetCpMode}
|
|
6686
7601
|
/>
|
|
6687
7602
|
)}
|
|
6688
7603
|
</div>
|
|
@@ -6930,12 +7845,31 @@ function App() {
|
|
|
6930
7845
|
const [commentsPanelOpen, setCommentsPanelOpen] = useState(false);
|
|
6931
7846
|
const [commentsFilter, setCommentsFilter] = useState('open'); // 'all' | 'open' | 'resolved'
|
|
6932
7847
|
const [theme, setTheme] = useState(readInitialTheme);
|
|
7848
|
+
// DDR-171 — CSS-panel vocabulary mode ('advanced' | 'designer'), App-owned so
|
|
7849
|
+
// the in-panel corner toggle (CssKnobs) and Settings → Appearance share one
|
|
7850
|
+
// source of truth; persisted to `maude-cp-mode` (same pattern as `theme`).
|
|
7851
|
+
const [cpMode, setCpMode] = useState(() => {
|
|
7852
|
+
const m = readJsonStore(CP_MODE_STORE, 'advanced');
|
|
7853
|
+
return m === 'designer' ? 'designer' : 'advanced';
|
|
7854
|
+
});
|
|
7855
|
+
useEffect(() => {
|
|
7856
|
+
try {
|
|
7857
|
+
localStorage.setItem(CP_MODE_STORE, JSON.stringify(cpMode));
|
|
7858
|
+
} catch {}
|
|
7859
|
+
}, [cpMode]);
|
|
6933
7860
|
const [openMenu, setOpenMenu] = useState(null);
|
|
6934
7861
|
const [sidebarOpen, setSidebarOpen] = useState(() => readBoolStore(SIDEBAR_STORE, true));
|
|
6935
7862
|
const [showHidden, setShowHidden] = useState(() => readBoolStore(SHOW_HIDDEN_STORE, false));
|
|
6936
7863
|
const [sectionsExpanded, setSectionsExpanded] = useState(() => readJsonStore(SECTIONS_STORE, {}));
|
|
6937
7864
|
const [helpOpen, setHelpOpen] = useState(false);
|
|
6938
7865
|
const [readinessOpen, setReadinessOpen] = useState(false);
|
|
7866
|
+
const [introOpen, setIntroOpen] = useState(false);
|
|
7867
|
+
const [quickSetupOpen, setQuickSetupOpen] = useState(false);
|
|
7868
|
+
const [brandUploadOpen, setBrandUploadOpen] = useState(false);
|
|
7869
|
+
// DDR-166 plan, Phase 2 (T7) — the persistent "Setup" affordance in the empty
|
|
7870
|
+
// canvas state (below) only renders while the project's own setup (design
|
|
7871
|
+
// system / first canvas / brand assets) is incomplete; native-only concern.
|
|
7872
|
+
const { report: setupReadiness } = useSetupReadiness(isNativeApp());
|
|
6939
7873
|
// ? cheat-sheet (DS components-shortcuts-overlay) — separate from the deep
|
|
6940
7874
|
// Help modal (F1), which keeps commands & flows.
|
|
6941
7875
|
const [shortcutsOpen, setShortcutsOpen] = useState(false);
|
|
@@ -6943,7 +7877,26 @@ function App() {
|
|
|
6943
7877
|
// T5/T6 (Plan C) — shell-level export/handoff dialog + inspector panel state.
|
|
6944
7878
|
// The palette (T4) drives them; the dialog (T5) + panel (T6) consume them.
|
|
6945
7879
|
const [exportDialog, setExportDialog] = useState(null); // null | { mode: 'export'|'handoff', scope? }
|
|
7880
|
+
// feature-ai-media-generation (DDR-16x) — BYOK provider-key Settings modal +
|
|
7881
|
+
// the AI generate action.
|
|
7882
|
+
const [settingsOpen, setSettingsOpen] = useState(false);
|
|
7883
|
+
const [generateOpen, setGenerateOpen] = useState(false);
|
|
6946
7884
|
const [inspectorOpen, setInspectorOpen] = useState(false);
|
|
7885
|
+
// feature-configurable-panel-docking — Layers as its own openable panel (when
|
|
7886
|
+
// layersMode==='separate'), plus the per-panel side map + layers mode. All
|
|
7887
|
+
// three hydrate from disk (/_api/ui-prefs) on mount and persist on change,
|
|
7888
|
+
// mirroring the view-pref pattern.
|
|
7889
|
+
const [layersOpen, setLayersOpen] = useState(false);
|
|
7890
|
+
const [panelSide, setPanelSide] = useState(() =>
|
|
7891
|
+
readJsonStore(PANEL_SIDES_STORE, PANEL_SIDES_DEFAULTS)
|
|
7892
|
+
);
|
|
7893
|
+
const [layersMode, setLayersMode] = useState(() => {
|
|
7894
|
+
try {
|
|
7895
|
+
const v = localStorage.getItem(LAYERS_MODE_STORE);
|
|
7896
|
+
if (v === 'separate' || v === 'in-inspector') return v;
|
|
7897
|
+
} catch {}
|
|
7898
|
+
return 'separate';
|
|
7899
|
+
});
|
|
6947
7900
|
// DDR-148 — Timeline panel (right dock) for scrubbing a video-comp. `activeComps`
|
|
6948
7901
|
// is populated from the iframe's `timeline-comps` announce; `timelineFrame` from
|
|
6949
7902
|
// its live `timeline-frame`. Empty comps ⇒ the panel shows its empty state.
|
|
@@ -7077,14 +8030,57 @@ function App() {
|
|
|
7077
8030
|
// ~13 call sites and only some closed their siblings, so a panel opened via a
|
|
7078
8031
|
// path that left a sibling `true` rendered *behind* it under the fixed
|
|
7079
8032
|
// precedence — looking like the new panel "overlapped" the old one.)
|
|
7080
|
-
|
|
7081
|
-
|
|
7082
|
-
|
|
7083
|
-
|
|
7084
|
-
|
|
7085
|
-
|
|
7086
|
-
|
|
8033
|
+
// feature-configurable-panel-docking — generic, SIDE-AWARE panel management.
|
|
8034
|
+
// A slot (left/right) shows one panel at a time, so opening a panel closes any
|
|
8035
|
+
// OTHER open panel ON THE SAME SIDE (panels on the other side are independent).
|
|
8036
|
+
// A live ref carries the current open + side maps so the callbacks stay stable
|
|
8037
|
+
// yet never act on stale state. (The Timeline is a BOTTOM dock, DDR-148, and is
|
|
8038
|
+
// NOT part of either slot's mutual-exclusion.)
|
|
8039
|
+
const panelStateRef = useRef({ open: {}, side: PANEL_SIDES_DEFAULTS });
|
|
8040
|
+
panelStateRef.current = {
|
|
8041
|
+
open: {
|
|
8042
|
+
tree: sidebarOpen,
|
|
8043
|
+
layers: layersOpen,
|
|
8044
|
+
inspector: inspectorOpen,
|
|
8045
|
+
comments: commentsPanelOpen,
|
|
8046
|
+
changes: changesOpen,
|
|
8047
|
+
assistant: assistantOpen,
|
|
8048
|
+
},
|
|
8049
|
+
side: panelSide,
|
|
8050
|
+
};
|
|
8051
|
+
const setPanelOpen = useCallback((id, val) => {
|
|
8052
|
+
if (id === 'tree') setSidebarOpen(val);
|
|
8053
|
+
else if (id === 'layers') setLayersOpen(val);
|
|
8054
|
+
else if (id === 'inspector') setInspectorOpen(val);
|
|
8055
|
+
else if (id === 'comments') setCommentsPanelOpen(val);
|
|
8056
|
+
else if (id === 'changes') setChangesOpen(val);
|
|
8057
|
+
else if (id === 'assistant') setAssistantOpen(val);
|
|
7087
8058
|
}, []);
|
|
8059
|
+
const sideOf = useCallback(
|
|
8060
|
+
(id) => panelStateRef.current.side?.[id] || PANEL_SIDES_DEFAULTS[id],
|
|
8061
|
+
[]
|
|
8062
|
+
);
|
|
8063
|
+
const openPanelExclusive = useCallback(
|
|
8064
|
+
(id) => {
|
|
8065
|
+
const side = sideOf(id);
|
|
8066
|
+
setPanelOpen(id, true);
|
|
8067
|
+
for (const p of DOCK_PANELS) {
|
|
8068
|
+
if (p.id !== id && sideOf(p.id) === side) setPanelOpen(p.id, false);
|
|
8069
|
+
}
|
|
8070
|
+
},
|
|
8071
|
+
[setPanelOpen, sideOf]
|
|
8072
|
+
);
|
|
8073
|
+
const togglePanel = useCallback(
|
|
8074
|
+
(id) => {
|
|
8075
|
+
if (panelStateRef.current.open?.[id]) setPanelOpen(id, false);
|
|
8076
|
+
else openPanelExclusive(id);
|
|
8077
|
+
},
|
|
8078
|
+
[openPanelExclusive, setPanelOpen]
|
|
8079
|
+
);
|
|
8080
|
+
// Legacy name kept for the many call sites; now side-aware (opens `which` and
|
|
8081
|
+
// closes only same-side siblings, so a panel moved to the left is unaffected).
|
|
8082
|
+
const openRightPanel = openPanelExclusive;
|
|
8083
|
+
const toggleRightPanel = togglePanel;
|
|
7088
8084
|
// feature-element-editing-robustness Stage C (Task C1) — open the Inspector on
|
|
7089
8085
|
// the CSS tab for a fresh SINGLE selection, but only when nothing is already
|
|
7090
8086
|
// docked on the right (never override an open Layers/Inspect/Comments panel)
|
|
@@ -7100,47 +8096,6 @@ function App() {
|
|
|
7100
8096
|
},
|
|
7101
8097
|
[openRightPanel]
|
|
7102
8098
|
);
|
|
7103
|
-
// Functional updates so this is stale-closure-safe inside the keydown /
|
|
7104
|
-
// postMessage listeners; opening always clears the sibling panels.
|
|
7105
|
-
const toggleRightPanel = useCallback((which) => {
|
|
7106
|
-
if (which === 'inspector') {
|
|
7107
|
-
setInspectorOpen((v) => {
|
|
7108
|
-
if (!v) {
|
|
7109
|
-
setChangesOpen(false);
|
|
7110
|
-
setCommentsPanelOpen(false);
|
|
7111
|
-
setAssistantOpen(false);
|
|
7112
|
-
}
|
|
7113
|
-
return !v;
|
|
7114
|
-
});
|
|
7115
|
-
} else if (which === 'comments') {
|
|
7116
|
-
setCommentsPanelOpen((v) => {
|
|
7117
|
-
if (!v) {
|
|
7118
|
-
setChangesOpen(false);
|
|
7119
|
-
setInspectorOpen(false);
|
|
7120
|
-
setAssistantOpen(false);
|
|
7121
|
-
}
|
|
7122
|
-
return !v;
|
|
7123
|
-
});
|
|
7124
|
-
} else if (which === 'changes') {
|
|
7125
|
-
setChangesOpen((v) => {
|
|
7126
|
-
if (!v) {
|
|
7127
|
-
setInspectorOpen(false);
|
|
7128
|
-
setCommentsPanelOpen(false);
|
|
7129
|
-
setAssistantOpen(false);
|
|
7130
|
-
}
|
|
7131
|
-
return !v;
|
|
7132
|
-
});
|
|
7133
|
-
} else if (which === 'assistant') {
|
|
7134
|
-
setAssistantOpen((v) => {
|
|
7135
|
-
if (!v) {
|
|
7136
|
-
setChangesOpen(false);
|
|
7137
|
-
setInspectorOpen(false);
|
|
7138
|
-
setCommentsPanelOpen(false);
|
|
7139
|
-
}
|
|
7140
|
-
return !v;
|
|
7141
|
-
});
|
|
7142
|
-
}
|
|
7143
|
-
}, []);
|
|
7144
8099
|
// DDR-148 — the Timeline is a BOTTOM dock, independent of the right rail: it
|
|
7145
8100
|
// toggles on its own and coexists with Inspector/Changes/Comments/Chat.
|
|
7146
8101
|
const toggleTimeline = useCallback(() => setTimelineOpen((v) => !v), []);
|
|
@@ -7206,7 +8161,7 @@ function App() {
|
|
|
7206
8161
|
setup: (step) => {
|
|
7207
8162
|
if (!step) return;
|
|
7208
8163
|
if ((step.canvas || step.requireSelection) && tabs.length === 0) {
|
|
7209
|
-
|
|
8164
|
+
openPanelExclusive('tree');
|
|
7210
8165
|
setTimeout(() => {
|
|
7211
8166
|
try {
|
|
7212
8167
|
document.querySelector('.st-sidebar [role="treeitem"]')?.click();
|
|
@@ -7226,7 +8181,13 @@ function App() {
|
|
|
7226
8181
|
localStorage.setItem(USAGE_TOUR_STORE, '1');
|
|
7227
8182
|
} catch {}
|
|
7228
8183
|
}, []);
|
|
7229
|
-
const [annotationsVisible, setAnnotationsVisible] = useState(
|
|
8184
|
+
const [annotationsVisible, setAnnotationsVisible] = useState(() =>
|
|
8185
|
+
readBoolStore(ANNOT_STORE, true)
|
|
8186
|
+
);
|
|
8187
|
+
// feature-unified-settings-modal — gate the disk-write effect until the boot
|
|
8188
|
+
// GET /_api/ui-prefs has reconciled (disk-wins), so we never clobber a stored
|
|
8189
|
+
// pref with the localStorage/default value on the first render.
|
|
8190
|
+
const [uiPrefsHydrated, setUiPrefsHydrated] = useState(false);
|
|
7230
8191
|
// Canvas-chrome visibility (View menu). minimap + zoom-controls are
|
|
7231
8192
|
// persistent prefs broadcast to every open canvas iframe; presentMode is a
|
|
7232
8193
|
// non-destructive "hide ALL chrome + shell, artboards only" overlay with an
|
|
@@ -7751,6 +8712,77 @@ function App() {
|
|
|
7751
8712
|
localStorage.setItem(ZOOMCTL_STORE, zoomCtlVisible ? '1' : '0');
|
|
7752
8713
|
} catch {}
|
|
7753
8714
|
}, [zoomCtlVisible]);
|
|
8715
|
+
useEffect(() => {
|
|
8716
|
+
try {
|
|
8717
|
+
localStorage.setItem(ANNOT_STORE, annotationsVisible ? '1' : '0');
|
|
8718
|
+
} catch {}
|
|
8719
|
+
}, [annotationsVisible]);
|
|
8720
|
+
|
|
8721
|
+
// feature-unified-settings-modal — reconcile view prefs with the on-disk store
|
|
8722
|
+
// once on mount (disk wins over the localStorage/default init: it survives a
|
|
8723
|
+
// cleared localStorage). Applying a value that already matches is a no-op, so
|
|
8724
|
+
// there's no flash in the common case. On failure we still mark hydrated so
|
|
8725
|
+
// writes resume best-effort.
|
|
8726
|
+
useEffect(() => {
|
|
8727
|
+
let cancelled = false;
|
|
8728
|
+
fetch('/_api/ui-prefs')
|
|
8729
|
+
.then((r) => (r.ok ? r.json() : Promise.reject(new Error(`HTTP ${r.status}`))))
|
|
8730
|
+
.then((p) => {
|
|
8731
|
+
if (cancelled || !p || typeof p !== 'object') return;
|
|
8732
|
+
if (p.theme === 'light' || p.theme === 'dark') setTheme(p.theme);
|
|
8733
|
+
if (typeof p.minimap === 'boolean') setMinimapVisible(p.minimap);
|
|
8734
|
+
if (typeof p.zoom === 'boolean') setZoomCtlVisible(p.zoom);
|
|
8735
|
+
if (typeof p.annotations === 'boolean') setAnnotationsVisible(p.annotations);
|
|
8736
|
+
if (typeof p.autoOpenInspector === 'boolean') setAutoOpenInspector(p.autoOpenInspector);
|
|
8737
|
+
if (p.panelSides && typeof p.panelSides === 'object')
|
|
8738
|
+
setPanelSide({ ...PANEL_SIDES_DEFAULTS, ...p.panelSides });
|
|
8739
|
+
if (p.layersMode === 'separate' || p.layersMode === 'in-inspector')
|
|
8740
|
+
setLayersMode(p.layersMode);
|
|
8741
|
+
})
|
|
8742
|
+
.catch(() => {})
|
|
8743
|
+
.finally(() => {
|
|
8744
|
+
if (!cancelled) setUiPrefsHydrated(true);
|
|
8745
|
+
});
|
|
8746
|
+
return () => {
|
|
8747
|
+
cancelled = true;
|
|
8748
|
+
};
|
|
8749
|
+
}, []);
|
|
8750
|
+
|
|
8751
|
+
// Mirror every view pref to disk once hydrated. The barrier stops the initial
|
|
8752
|
+
// render (localStorage/default values) from overwriting the disk store before
|
|
8753
|
+
// the boot GET has reconciled.
|
|
8754
|
+
useEffect(() => {
|
|
8755
|
+
if (!uiPrefsHydrated) return;
|
|
8756
|
+
persistUiPrefs({
|
|
8757
|
+
theme,
|
|
8758
|
+
minimap: minimapVisible,
|
|
8759
|
+
zoom: zoomCtlVisible,
|
|
8760
|
+
annotations: annotationsVisible,
|
|
8761
|
+
autoOpenInspector,
|
|
8762
|
+
panelSides: panelSide,
|
|
8763
|
+
layersMode,
|
|
8764
|
+
});
|
|
8765
|
+
}, [
|
|
8766
|
+
uiPrefsHydrated,
|
|
8767
|
+
theme,
|
|
8768
|
+
minimapVisible,
|
|
8769
|
+
zoomCtlVisible,
|
|
8770
|
+
annotationsVisible,
|
|
8771
|
+
autoOpenInspector,
|
|
8772
|
+
panelSide,
|
|
8773
|
+
layersMode,
|
|
8774
|
+
]);
|
|
8775
|
+
// localStorage mirror for panel sides + layers mode (synchronous no-flash init).
|
|
8776
|
+
useEffect(() => {
|
|
8777
|
+
try {
|
|
8778
|
+
localStorage.setItem(PANEL_SIDES_STORE, JSON.stringify(panelSide));
|
|
8779
|
+
} catch {}
|
|
8780
|
+
}, [panelSide]);
|
|
8781
|
+
useEffect(() => {
|
|
8782
|
+
try {
|
|
8783
|
+
localStorage.setItem(LAYERS_MODE_STORE, layersMode);
|
|
8784
|
+
} catch {}
|
|
8785
|
+
}, [layersMode]);
|
|
7754
8786
|
|
|
7755
8787
|
const toggleSection = useCallback((label, defaultOpen) => {
|
|
7756
8788
|
setSectionsExpanded((prev) => {
|
|
@@ -7781,7 +8813,14 @@ function App() {
|
|
|
7781
8813
|
// fetch (either may land first). `?? {}` keeps older servers (no map) on
|
|
7782
8814
|
// the ds0 fallback. Re-runs on every tree reload, so adding/retargeting a
|
|
7783
8815
|
// canvas refreshes the map.
|
|
7784
|
-
setCfg((prev) => ({
|
|
8816
|
+
setCfg((prev) => ({
|
|
8817
|
+
...prev,
|
|
8818
|
+
canvasDesignSystems: data.canvasDesignSystems ?? {},
|
|
8819
|
+
// DDR-174 (T15) — per-canvas notable `.meta.json` `kind` values (today:
|
|
8820
|
+
// only `reconstructed-experimental`), folded in the same way + for the
|
|
8821
|
+
// same reason as canvasDesignSystems above.
|
|
8822
|
+
canvasKinds: data.canvasKinds ?? {},
|
|
8823
|
+
}));
|
|
7785
8824
|
} catch (e) {
|
|
7786
8825
|
console.error('failed to load tree', e);
|
|
7787
8826
|
}
|
|
@@ -8789,17 +9828,21 @@ function App() {
|
|
|
8789
9828
|
}
|
|
8790
9829
|
} else if (m.dgn === 'insert-image-request') {
|
|
8791
9830
|
// Stage F/I3 — "Insert ▸ Image" from the canvas context menu, or the
|
|
8792
|
-
// tool-palette's
|
|
8793
|
-
//
|
|
8794
|
-
//
|
|
8795
|
-
//
|
|
8796
|
-
//
|
|
9831
|
+
// tool-palette's Image tool (`artboardId` in place of `refId` for its
|
|
9832
|
+
// empty-artboard fallback, or NEITHER when the canvas has no artboard
|
|
9833
|
+
// at all — feature-bulk-media-insert). An image needs a contained
|
|
9834
|
+
// asset src, so the shell opens the AssetPicker in bulk mode; the
|
|
9835
|
+
// confirm then drives insertElementShell(kind:'image') per path (Task
|
|
9836
|
+
// 10) or a single batched annotation insert. Confused-deputy gated +
|
|
9837
|
+
// pinned to the active canvas like the other request verbs.
|
|
8797
9838
|
const activeWin = activePath ? iframesRef.current.get(activePath)?.contentWindow : null;
|
|
8798
9839
|
const hasRefId = typeof m.refId === 'string';
|
|
8799
9840
|
const hasArtboardId = typeof m.artboardId === 'string';
|
|
9841
|
+
const noAnchor = !hasRefId && !hasArtboardId;
|
|
8800
9842
|
const okShape =
|
|
8801
|
-
hasRefId !== hasArtboardId &&
|
|
8802
|
-
(
|
|
9843
|
+
(hasRefId !== hasArtboardId || noAnchor) &&
|
|
9844
|
+
(noAnchor ||
|
|
9845
|
+
m.position === 'before' ||
|
|
8803
9846
|
m.position === 'after' ||
|
|
8804
9847
|
m.position === 'inside-start' ||
|
|
8805
9848
|
m.position === 'inside-end') &&
|
|
@@ -8810,8 +9853,14 @@ function App() {
|
|
|
8810
9853
|
canvas: activePath,
|
|
8811
9854
|
refId: m.refId,
|
|
8812
9855
|
artboardId: hasArtboardId ? m.artboardId : undefined,
|
|
8813
|
-
position: m.position,
|
|
9856
|
+
position: noAnchor ? undefined : m.position,
|
|
8814
9857
|
refIndex: Number.isInteger(m.refIndex) ? m.refIndex : undefined,
|
|
9858
|
+
multiple: true,
|
|
9859
|
+
// Artboard destination is possible whenever there's ANY resolvable
|
|
9860
|
+
// anchor — a refId (context-menu, or the tool-palette's normal
|
|
9861
|
+
// last-element case) inserts inside the SAME artboard that
|
|
9862
|
+
// element already lives in, not just the artboardId fallback.
|
|
9863
|
+
hasArtboardAnchor: !noAnchor,
|
|
8815
9864
|
});
|
|
8816
9865
|
}
|
|
8817
9866
|
} else if (m.dgn === 'replace-media-request') {
|
|
@@ -9618,6 +10667,32 @@ function App() {
|
|
|
9618
10667
|
[structuralWrite]
|
|
9619
10668
|
);
|
|
9620
10669
|
|
|
10670
|
+
// feature-ai-media-generation Phase 1 (Task 1.1) — auto-insert a generated
|
|
10671
|
+
// image onto the canvas where the user is looking, so generation is never a
|
|
10672
|
+
// dead-end modal. The image landed in the content-addressed asset store
|
|
10673
|
+
// (assets/<sha8>.png) via the privileged /_api/generate-jobs route; splice it
|
|
10674
|
+
// into the active artboard through the SAME main-origin source-write lane the
|
|
10675
|
+
// AssetPicker uses (insertElementShell → /_api/insert-element). Returns true
|
|
10676
|
+
// when it placed the image, false when there's no target artboard (no canvas
|
|
10677
|
+
// open / no active artboard) so the caller can fall back to a manual affordance.
|
|
10678
|
+
const insertGeneratedImage = useCallback(
|
|
10679
|
+
(assetPath) => {
|
|
10680
|
+
if (typeof assetPath !== 'string' || !assetPath) return false;
|
|
10681
|
+
if (!activePath) return false;
|
|
10682
|
+
// Respect the active-canvas + selected-artboard signals (_active.json): an
|
|
10683
|
+
// explicit selection wins, else the viewport-active artboard canvas-lib
|
|
10684
|
+
// reports on pan. Without a target artboard we can't source-write.
|
|
10685
|
+
const artboardId = selectedRef.current?.artboardId ?? canvasActiveArtboard ?? null;
|
|
10686
|
+
if (!artboardId) return false;
|
|
10687
|
+
// Insert as the last child of the artboard (empty or not — the engine's
|
|
10688
|
+
// insertElementIntoArtboard handles both). Content-addressed src only; the
|
|
10689
|
+
// route contains an `image` src to assets/ (no remote hotlink / scheme).
|
|
10690
|
+
insertElementShell(undefined, 'inside-end', 'image', { artboardId, src: assetPath });
|
|
10691
|
+
return true;
|
|
10692
|
+
},
|
|
10693
|
+
[activePath, canvasActiveArtboard, insertElementShell]
|
|
10694
|
+
);
|
|
10695
|
+
|
|
9621
10696
|
const duplicateElementShell = useCallback(
|
|
9622
10697
|
(id, idIndex) => {
|
|
9623
10698
|
structuralWrite(
|
|
@@ -9755,6 +10830,28 @@ function App() {
|
|
|
9755
10830
|
},
|
|
9756
10831
|
[structuralWrite]
|
|
9757
10832
|
);
|
|
10833
|
+
|
|
10834
|
+
// Artboard "hug height" default — Hug ⇄ Fixed toggle from ArtboardKnobs
|
|
10835
|
+
// (CSS panel). Direct shell-side action (no canvas postMessage round trip,
|
|
10836
|
+
// unlike resizeArtboardShell's drag-overlay caller), so no *ShellRef needed.
|
|
10837
|
+
const setArtboardHugShell = useCallback(
|
|
10838
|
+
(artboardId, fixed, freezeHeight) => {
|
|
10839
|
+
structuralWrite(
|
|
10840
|
+
'/_api/set-artboard-hug',
|
|
10841
|
+
{ artboardId, fixed, freezeHeight },
|
|
10842
|
+
{ label: fixed ? 'pin artboard height' : 'hug artboard height' }
|
|
10843
|
+
);
|
|
10844
|
+
},
|
|
10845
|
+
[structuralWrite]
|
|
10846
|
+
);
|
|
10847
|
+
|
|
10848
|
+
// Artboard "more settings" (background/padding/layout/gap) from ArtboardKnobs.
|
|
10849
|
+
const setArtboardStyleShell = useCallback(
|
|
10850
|
+
(artboardId, patch) => {
|
|
10851
|
+
structuralWrite('/_api/set-artboard-style', { artboardId, ...patch }, { label: 'artboard style' });
|
|
10852
|
+
},
|
|
10853
|
+
[structuralWrite]
|
|
10854
|
+
);
|
|
9758
10855
|
// Refs the (stale-closure) onMessage handlers below read.
|
|
9759
10856
|
const deleteElementShellRef = useRef(null);
|
|
9760
10857
|
const insertElementShellRef = useRef(null);
|
|
@@ -9897,6 +10994,36 @@ function App() {
|
|
|
9897
10994
|
[assetPickerReq, insertElementShell, activePath, recordSourceEdit, postToActiveCanvas]
|
|
9898
10995
|
);
|
|
9899
10996
|
|
|
10997
|
+
// feature-bulk-media-insert Task 10 — the picker's multi-select confirm.
|
|
10998
|
+
// `destination === 'artboard'`: loop insertElementShell once per path
|
|
10999
|
+
// (already race-safe via editApplyChainRef, no new serialization needed);
|
|
11000
|
+
// always appended `inside-end` of whichever artboard is currently active —
|
|
11001
|
+
// NOT the single-pick request's own refId/position, which only made sense
|
|
11002
|
+
// for inserting exactly one image relative to one anchor. `destination ===
|
|
11003
|
+
// 'annotation'`: one batched message so the canvas-side handler (Task 11)
|
|
11004
|
+
// performs a single atomic commit instead of N racing ones.
|
|
11005
|
+
const onPickMany = useCallback(
|
|
11006
|
+
(paths, destination) => {
|
|
11007
|
+
const req = assetPickerReq;
|
|
11008
|
+
setAssetPickerReq(null);
|
|
11009
|
+
if (!req || !Array.isArray(paths) || !paths.length) return;
|
|
11010
|
+
if (req.canvas && req.canvas !== activePath) {
|
|
11011
|
+
console.warn('[asset-picker] active canvas changed since request — aborting');
|
|
11012
|
+
return;
|
|
11013
|
+
}
|
|
11014
|
+
if (destination === 'artboard') {
|
|
11015
|
+
const artboardId = selectedRef.current?.artboardId ?? canvasActiveArtboard ?? null;
|
|
11016
|
+
if (!artboardId) return;
|
|
11017
|
+
for (const src of paths) {
|
|
11018
|
+
insertElementShell(undefined, 'inside-end', 'image', { artboardId, src });
|
|
11019
|
+
}
|
|
11020
|
+
return;
|
|
11021
|
+
}
|
|
11022
|
+
postToActiveCanvas({ dgn: 'insert-annotation-media', paths });
|
|
11023
|
+
},
|
|
11024
|
+
[assetPickerReq, activePath, insertElementShell, canvasActiveArtboard, postToActiveCanvas]
|
|
11025
|
+
);
|
|
11026
|
+
|
|
9900
11027
|
// Phase 4 (whiteboard-improvements) — a bundled sticker has no project asset
|
|
9901
11028
|
// path yet (it lives in MAUDE's own STICKERS_DIR, main-origin-only per
|
|
9902
11029
|
// DDR-054), so re-upload its bytes through the SAME /_api/asset lane every
|
|
@@ -10110,6 +11237,13 @@ function App() {
|
|
|
10110
11237
|
setExportDialog({ mode: 'handoff' });
|
|
10111
11238
|
return;
|
|
10112
11239
|
}
|
|
11240
|
+
// Cmd+, — open Settings (AI generation keys). The platform convention for
|
|
11241
|
+
// a settings/preferences surface (feature-ai-media-generation, DDR-16x).
|
|
11242
|
+
if (meta && !e.shiftKey && !e.altKey && e.key === ',') {
|
|
11243
|
+
e.preventDefault();
|
|
11244
|
+
setSettingsOpen(true);
|
|
11245
|
+
return;
|
|
11246
|
+
}
|
|
10113
11247
|
// Cmd+Shift+T — toggle the Timeline (video-comp scrub) dock.
|
|
10114
11248
|
if (meta && e.shiftKey && (e.key === 't' || e.key === 'T')) {
|
|
10115
11249
|
e.preventDefault();
|
|
@@ -10146,7 +11280,7 @@ function App() {
|
|
|
10146
11280
|
}
|
|
10147
11281
|
if (meta && (e.key === 'f' || e.key === 'F')) {
|
|
10148
11282
|
e.preventDefault();
|
|
10149
|
-
if (!sidebarOpen)
|
|
11283
|
+
if (!sidebarOpen) openPanelExclusive('tree');
|
|
10150
11284
|
setTimeout(() => {
|
|
10151
11285
|
const inp = document.querySelector('.st-search input');
|
|
10152
11286
|
if (inp) inp.focus();
|
|
@@ -10165,7 +11299,7 @@ function App() {
|
|
|
10165
11299
|
if (e.key === 't' || e.key === 'T') {
|
|
10166
11300
|
if (e.shiftKey || meta) return;
|
|
10167
11301
|
e.preventDefault();
|
|
10168
|
-
|
|
11302
|
+
togglePanel('tree');
|
|
10169
11303
|
return;
|
|
10170
11304
|
}
|
|
10171
11305
|
// H — toggle show-hidden (sidecars + project/runtime orphans)
|
|
@@ -10189,7 +11323,7 @@ function App() {
|
|
|
10189
11323
|
// which the browser reserves for New Window and never delivers).
|
|
10190
11324
|
if ((e.key === 'n' || e.key === 'N') && !meta && !e.shiftKey) {
|
|
10191
11325
|
e.preventDefault();
|
|
10192
|
-
|
|
11326
|
+
openPanelExclusive('tree');
|
|
10193
11327
|
setTimeout(
|
|
10194
11328
|
() => document.querySelector('[aria-label="New blank brief board"]')?.click(),
|
|
10195
11329
|
60
|
|
@@ -10270,7 +11404,7 @@ function App() {
|
|
|
10270
11404
|
icon: 'plus',
|
|
10271
11405
|
kbd: 'N',
|
|
10272
11406
|
run: () => {
|
|
10273
|
-
|
|
11407
|
+
openPanelExclusive('tree');
|
|
10274
11408
|
setTimeout(
|
|
10275
11409
|
() => document.querySelector('[aria-label="New blank brief board"]')?.click(),
|
|
10276
11410
|
60
|
|
@@ -10293,6 +11427,21 @@ function App() {
|
|
|
10293
11427
|
kbd: '⇧⌘H',
|
|
10294
11428
|
run: () => setExportDialog({ mode: 'handoff' }),
|
|
10295
11429
|
},
|
|
11430
|
+
{
|
|
11431
|
+
id: 'generate',
|
|
11432
|
+
group: 'Canvas',
|
|
11433
|
+
label: 'Generate with AI…',
|
|
11434
|
+
icon: 'sparkle',
|
|
11435
|
+
run: () => setGenerateOpen(true),
|
|
11436
|
+
},
|
|
11437
|
+
{
|
|
11438
|
+
id: 'settings',
|
|
11439
|
+
group: 'Canvas',
|
|
11440
|
+
label: 'Settings…',
|
|
11441
|
+
icon: 'sliders',
|
|
11442
|
+
kbd: '⌘,',
|
|
11443
|
+
run: () => setSettingsOpen(true),
|
|
11444
|
+
},
|
|
10296
11445
|
// ── View ────────────────────────────────────────────────────────────
|
|
10297
11446
|
{
|
|
10298
11447
|
id: 'system',
|
|
@@ -10375,6 +11524,155 @@ function App() {
|
|
|
10375
11524
|
[openSystem, toggleTheme, reloadActive, whatsNew]
|
|
10376
11525
|
);
|
|
10377
11526
|
|
|
11527
|
+
// feature-configurable-panel-docking — resolve, for each slot, the panels
|
|
11528
|
+
// assigned to it and which one is active (the open one). Layers is only a
|
|
11529
|
+
// dockable panel in `separate` mode; Assistant is native-only.
|
|
11530
|
+
const panelAvailable = (id) =>
|
|
11531
|
+
id === 'assistant' ? isNativeApp() : id === 'layers' ? layersMode === 'separate' : true;
|
|
11532
|
+
const idsForSide = (side) =>
|
|
11533
|
+
DOCK_PANELS.filter(
|
|
11534
|
+
(p) => panelAvailable(p.id) && (panelSide[p.id] || PANEL_SIDES_DEFAULTS[p.id]) === side
|
|
11535
|
+
).map((p) => p.id);
|
|
11536
|
+
const panelIsOpen = {
|
|
11537
|
+
tree: sidebarOpen,
|
|
11538
|
+
layers: layersOpen,
|
|
11539
|
+
inspector: inspectorOpen,
|
|
11540
|
+
comments: commentsPanelOpen,
|
|
11541
|
+
changes: changesOpen,
|
|
11542
|
+
assistant: assistantOpen,
|
|
11543
|
+
};
|
|
11544
|
+
const leftIds = idsForSide('left');
|
|
11545
|
+
const rightIds = idsForSide('right');
|
|
11546
|
+
const leftActive = leftIds.find((id) => panelIsOpen[id]) || null;
|
|
11547
|
+
const rightActive = rightIds.find((id) => panelIsOpen[id]) || null;
|
|
11548
|
+
const leftHostsAssistant = isNativeApp() && (panelSide.assistant || 'right') === 'left';
|
|
11549
|
+
const rightHostsAssistant = isNativeApp() && (panelSide.assistant || 'right') === 'right';
|
|
11550
|
+
const resizingFor = (id) =>
|
|
11551
|
+
(panelSide[id] || PANEL_SIDES_DEFAULTS[id]) === 'left' ? dragSide === 'sb' : dragSide === 'rp';
|
|
11552
|
+
const activeCanvasFile =
|
|
11553
|
+
activePath && activePath !== SYSTEM_TAB && /\.(tsx|html)$/i.test(activePath) ? activePath : null;
|
|
11554
|
+
|
|
11555
|
+
// Render a panel body by id (width undefined ⇒ fills the .st-dockslot wrapper,
|
|
11556
|
+
// which owns the resizable width). Assistant is handled separately below as an
|
|
11557
|
+
// always-mounted ChatPanel so its stream survives a tab switch.
|
|
11558
|
+
const renderPanelBody = (id) => {
|
|
11559
|
+
if (id === 'tree')
|
|
11560
|
+
return (
|
|
11561
|
+
<Sidebar
|
|
11562
|
+
groups={groups}
|
|
11563
|
+
activePath={activePath}
|
|
11564
|
+
activeDsName={activePath === SYSTEM_TAB ? (systemData?.ds?.name ?? null) : null}
|
|
11565
|
+
onOpen={openTab}
|
|
11566
|
+
onOpenSystem={openSystem}
|
|
11567
|
+
wsConnected={wsConnected}
|
|
11568
|
+
search={search}
|
|
11569
|
+
setSearch={setSearch}
|
|
11570
|
+
commentsByFile={commentsByFile}
|
|
11571
|
+
showHidden={showHidden}
|
|
11572
|
+
sectionsExpanded={sectionsExpanded}
|
|
11573
|
+
onToggleSection={toggleSection}
|
|
11574
|
+
onNewBoard={createBoard}
|
|
11575
|
+
onDeleteBoard={deleteBoard}
|
|
11576
|
+
onRefresh={refreshTree}
|
|
11577
|
+
refreshing={treeRefreshing}
|
|
11578
|
+
collapsed={false}
|
|
11579
|
+
onCollapse={() => togglePanel('tree')}
|
|
11580
|
+
resizing={resizingFor('tree')}
|
|
11581
|
+
dirtyByPath={dirtyByPath}
|
|
11582
|
+
project={project}
|
|
11583
|
+
gitBranch={gitStatus?.branch}
|
|
11584
|
+
remoteSync={remoteSync}
|
|
11585
|
+
onGetLatest={gitGetLatest}
|
|
11586
|
+
canvasKinds={cfg?.canvasKinds}
|
|
11587
|
+
/>
|
|
11588
|
+
);
|
|
11589
|
+
if (id === 'changes')
|
|
11590
|
+
return (
|
|
11591
|
+
<GitPanel
|
|
11592
|
+
status={gitStatus && remoteSync ? { ...gitStatus, ...remoteSync } : gitStatus}
|
|
11593
|
+
project={project}
|
|
11594
|
+
readOnly={!isNativeApp()}
|
|
11595
|
+
resizing={resizingFor('changes')}
|
|
11596
|
+
onClose={() => setChangesOpen(false)}
|
|
11597
|
+
onCommit={gitCommit}
|
|
11598
|
+
onDiscard={gitDiscard}
|
|
11599
|
+
onPublish={gitPublish}
|
|
11600
|
+
onGetLatest={gitGetLatest}
|
|
11601
|
+
loadLog={gitLoadLog}
|
|
11602
|
+
onOpenCanvas={(p) => openTab(p)}
|
|
11603
|
+
onOpenDiff={(file) => setDiffTarget({ file, beforeSha: 'HEAD', conflict: false })}
|
|
11604
|
+
activeCanvas={activeCanvasFile}
|
|
11605
|
+
onPreviewVersion={(sha) => setDiffTarget({ file: activePath, beforeSha: sha, conflict: false })}
|
|
11606
|
+
designRel={(cfg?.designRel || cfg?.designRoot || '.design').replace(/^\/+|\/+$/g, '')}
|
|
11607
|
+
/>
|
|
11608
|
+
);
|
|
11609
|
+
if (id === 'inspector' || id === 'layers')
|
|
11610
|
+
return (
|
|
11611
|
+
<InspectorPanel
|
|
11612
|
+
layersOnly={id === 'layers'}
|
|
11613
|
+
hideLayersTab={layersMode === 'separate'}
|
|
11614
|
+
cpMode={cpMode}
|
|
11615
|
+
onSetCpMode={setCpMode}
|
|
11616
|
+
selected={selected}
|
|
11617
|
+
cfg={cfg}
|
|
11618
|
+
tab={id === 'layers' ? 'layers' : inspectorTab}
|
|
11619
|
+
onTabChange={setInspectorTab}
|
|
11620
|
+
onClose={() => (id === 'layers' ? setLayersOpen(false) : setInspectorOpen(false))}
|
|
11621
|
+
onOptimistic={applyOptimisticStyle}
|
|
11622
|
+
onRecordEdit={recordSourceEdit}
|
|
11623
|
+
onReplaceMedia={onReplaceMedia}
|
|
11624
|
+
onResizeArtboard={resizeArtboardShell}
|
|
11625
|
+
onSetArtboardHug={setArtboardHugShell}
|
|
11626
|
+
onSetArtboardStyle={setArtboardStyleShell}
|
|
11627
|
+
editScope={editScope}
|
|
11628
|
+
onUndoRedo={(dir) => postToActiveCanvas({ dgn: dir })}
|
|
11629
|
+
photoSel={photoSel}
|
|
11630
|
+
photoRev={photoRev}
|
|
11631
|
+
onPhotoEdit={onPhotoEdit}
|
|
11632
|
+
onPhotoRemoveBackground={onPhotoRemoveBackground}
|
|
11633
|
+
onPhotoRecordEdit={onPhotoRecordEdit}
|
|
11634
|
+
onPhotoUndoRedo={(dir) => performPhotoUndo(dir === 'redo')}
|
|
11635
|
+
layersTree={layersTree}
|
|
11636
|
+
canvasFile={activePath}
|
|
11637
|
+
onSelectLayer={(n) =>
|
|
11638
|
+
postToActiveCanvas({
|
|
11639
|
+
dgn: 'select-by-id',
|
|
11640
|
+
id: n.id,
|
|
11641
|
+
artboardId: layersTree?.artboardId,
|
|
11642
|
+
index: n.index,
|
|
11643
|
+
})
|
|
11644
|
+
}
|
|
11645
|
+
onHoverLayer={(n) =>
|
|
11646
|
+
postToActiveCanvas({
|
|
11647
|
+
dgn: 'highlight',
|
|
11648
|
+
id: n ? n.id : null,
|
|
11649
|
+
artboardId: layersTree?.artboardId,
|
|
11650
|
+
index: n ? n.index : 0,
|
|
11651
|
+
})
|
|
11652
|
+
}
|
|
11653
|
+
onReorderLayer={reorderLayer}
|
|
11654
|
+
layersBusyRef={layersBusyRef}
|
|
11655
|
+
resizing={resizingFor(id)}
|
|
11656
|
+
/>
|
|
11657
|
+
);
|
|
11658
|
+
if (id === 'comments')
|
|
11659
|
+
return (
|
|
11660
|
+
<CommentsPanel
|
|
11661
|
+
commentsByFile={commentsByFile}
|
|
11662
|
+
filter={commentsFilter}
|
|
11663
|
+
setFilter={setCommentsFilter}
|
|
11664
|
+
activePath={activePath}
|
|
11665
|
+
focusedId={focusedCommentId}
|
|
11666
|
+
onJump={jumpToComment}
|
|
11667
|
+
onResolve={resolveComment}
|
|
11668
|
+
onReopen={reopenComment}
|
|
11669
|
+
onDelete={deleteComment}
|
|
11670
|
+
resizing={resizingFor('comments')}
|
|
11671
|
+
/>
|
|
11672
|
+
);
|
|
11673
|
+
return null;
|
|
11674
|
+
};
|
|
11675
|
+
|
|
10378
11676
|
return (
|
|
10379
11677
|
<div
|
|
10380
11678
|
className={'maude' + (presentMode ? ' is-present' : '')}
|
|
@@ -10420,7 +11718,7 @@ function App() {
|
|
|
10420
11718
|
onToggleChanges={() => toggleRightPanel('changes')}
|
|
10421
11719
|
onOpenSystem={openSystem}
|
|
10422
11720
|
sidebarOpen={sidebarOpen}
|
|
10423
|
-
onToggleSidebar={() =>
|
|
11721
|
+
onToggleSidebar={() => togglePanel('tree')}
|
|
10424
11722
|
showHidden={showHidden}
|
|
10425
11723
|
onToggleShowHidden={() => setShowHidden((v) => !v)}
|
|
10426
11724
|
onOpenHelp={() => setHelpOpen(true)}
|
|
@@ -10437,6 +11735,8 @@ function App() {
|
|
|
10437
11735
|
onTogglePresent={togglePresent}
|
|
10438
11736
|
postToActiveCanvas={postToActiveCanvas}
|
|
10439
11737
|
onOpenReadiness={() => setReadinessOpen(true)}
|
|
11738
|
+
onOpenQuickSetup={() => setQuickSetupOpen(true)}
|
|
11739
|
+
onWatchIntro={() => setIntroOpen(true)}
|
|
10440
11740
|
onOpenWhatsNew={whatsNew.openPanel}
|
|
10441
11741
|
whatsNewCount={whatsNew.unseen.length}
|
|
10442
11742
|
exportCenter={exportCenter}
|
|
@@ -10455,17 +11755,20 @@ function App() {
|
|
|
10455
11755
|
assistantBusy={assistantBusy}
|
|
10456
11756
|
assistantUnseen={assistantUnseen}
|
|
10457
11757
|
onOpenLayers={() => {
|
|
10458
|
-
//
|
|
10459
|
-
//
|
|
10460
|
-
|
|
11758
|
+
// feature-configurable-panel-docking — Layers is its own dockable
|
|
11759
|
+
// panel when layersMode==='separate' (toggle it), else it's the
|
|
11760
|
+
// Inspector's Layers tab (open the inspector on that tab).
|
|
11761
|
+
if (layersMode === 'separate') {
|
|
11762
|
+
togglePanel('layers');
|
|
11763
|
+
} else if (inspectorOpen && inspectorTab === 'layers') {
|
|
10461
11764
|
setInspectorOpen(false);
|
|
10462
11765
|
} else {
|
|
10463
11766
|
setInspectorTab('layers');
|
|
10464
|
-
|
|
11767
|
+
openPanelExclusive('inspector');
|
|
10465
11768
|
}
|
|
10466
11769
|
}}
|
|
10467
11770
|
onNewCanvas={() => {
|
|
10468
|
-
|
|
11771
|
+
openPanelExclusive('tree');
|
|
10469
11772
|
setTimeout(
|
|
10470
11773
|
() => document.querySelector('[aria-label="New blank brief board"]')?.click(),
|
|
10471
11774
|
60
|
|
@@ -10473,6 +11776,8 @@ function App() {
|
|
|
10473
11776
|
}}
|
|
10474
11777
|
onAssembleVideo={assembleVideo}
|
|
10475
11778
|
onOpenExport={(mode) => setExportDialog({ mode })}
|
|
11779
|
+
onOpenSettings={() => setSettingsOpen(true)}
|
|
11780
|
+
onOpenGenerate={() => setGenerateOpen(true)}
|
|
10476
11781
|
onReload={reloadActive}
|
|
10477
11782
|
onCloseCanvas={() => activePath && closeTab(activePath)}
|
|
10478
11783
|
presence={
|
|
@@ -10494,44 +11799,44 @@ function App() {
|
|
|
10494
11799
|
}
|
|
10495
11800
|
/>
|
|
10496
11801
|
<div className={'st-body' + (dragSide ? ' is-resizing' : '')} ref={bodyRef}>
|
|
11802
|
+
{/* LEFT dock slot (feature-configurable-panel-docking) — the collapsed
|
|
11803
|
+
rail shows when the left slot is empty; the tree's expand hooks open
|
|
11804
|
+
it on the tree tab. */}
|
|
10497
11805
|
<CollapsedRail
|
|
10498
|
-
shown={!
|
|
10499
|
-
onExpand={() =>
|
|
11806
|
+
shown={!leftActive}
|
|
11807
|
+
onExpand={() => openPanelExclusive('tree')}
|
|
10500
11808
|
onSearch={() => {
|
|
10501
|
-
|
|
11809
|
+
openPanelExclusive('tree');
|
|
10502
11810
|
setTimeout(() => document.querySelector('.st-search input')?.focus(), 60);
|
|
10503
11811
|
}}
|
|
10504
11812
|
/>
|
|
10505
|
-
|
|
10506
|
-
|
|
10507
|
-
|
|
10508
|
-
|
|
10509
|
-
|
|
10510
|
-
|
|
10511
|
-
|
|
10512
|
-
|
|
10513
|
-
|
|
10514
|
-
|
|
10515
|
-
|
|
10516
|
-
|
|
10517
|
-
|
|
10518
|
-
|
|
10519
|
-
|
|
10520
|
-
|
|
10521
|
-
|
|
10522
|
-
|
|
10523
|
-
|
|
10524
|
-
|
|
10525
|
-
|
|
10526
|
-
|
|
10527
|
-
|
|
10528
|
-
|
|
10529
|
-
|
|
10530
|
-
onGetLatest={gitGetLatest}
|
|
10531
|
-
/>
|
|
10532
|
-
{sidebarOpen && (
|
|
11813
|
+
{(leftActive || leftHostsAssistant) && (
|
|
11814
|
+
<DockSlot
|
|
11815
|
+
side="left"
|
|
11816
|
+
width={sbSize.w}
|
|
11817
|
+
open={!!leftActive}
|
|
11818
|
+
ids={leftIds}
|
|
11819
|
+
activeId={leftActive}
|
|
11820
|
+
onPick={togglePanel}
|
|
11821
|
+
>
|
|
11822
|
+
{leftHostsAssistant && (
|
|
11823
|
+
<ChatPanel
|
|
11824
|
+
hidden={leftActive !== 'assistant'}
|
|
11825
|
+
activeCanvas={activeCanvasFile}
|
|
11826
|
+
selected={selected}
|
|
11827
|
+
designRel={(cfg?.designRel || cfg?.designRoot || '.design').replace(/^\/+|\/+$/g, '')}
|
|
11828
|
+
resizing={resizingFor('assistant')}
|
|
11829
|
+
onClose={() => setAssistantOpen(false)}
|
|
11830
|
+
onBusyChange={setAssistantBusy}
|
|
11831
|
+
onFinished={handleAssistantFinished}
|
|
11832
|
+
/>
|
|
11833
|
+
)}
|
|
11834
|
+
{leftActive && leftActive !== 'assistant' && renderPanelBody(leftActive)}
|
|
11835
|
+
</DockSlot>
|
|
11836
|
+
)}
|
|
11837
|
+
{leftActive && (
|
|
10533
11838
|
<PanelGrip
|
|
10534
|
-
label="Resize
|
|
11839
|
+
label="Resize left panel"
|
|
10535
11840
|
size={sbSize}
|
|
10536
11841
|
active={dragSide === 'sb'}
|
|
10537
11842
|
onPointerDown={(e) => {
|
|
@@ -10553,11 +11858,13 @@ function App() {
|
|
|
10553
11858
|
cfg={cfg}
|
|
10554
11859
|
loadingPath={loadingPath}
|
|
10555
11860
|
onIframeLoad={onIframeLoad}
|
|
11861
|
+
showQuickSetup={isNativeApp() && !!setupReadiness && !setupReadiness.ready}
|
|
11862
|
+
onStartQuickSetup={() => setQuickSetupOpen(true)}
|
|
10556
11863
|
/>
|
|
10557
11864
|
</div>
|
|
10558
|
-
{
|
|
11865
|
+
{rightActive && (
|
|
10559
11866
|
<PanelGrip
|
|
10560
|
-
label="Resize
|
|
11867
|
+
label="Resize right panel"
|
|
10561
11868
|
dir="rtl"
|
|
10562
11869
|
size={rpSize}
|
|
10563
11870
|
active={dragSide === 'rp'}
|
|
@@ -10568,111 +11875,32 @@ function App() {
|
|
|
10568
11875
|
}}
|
|
10569
11876
|
/>
|
|
10570
11877
|
)}
|
|
10571
|
-
{/*
|
|
10572
|
-
|
|
10573
|
-
|
|
10574
|
-
|
|
10575
|
-
|
|
10576
|
-
|
|
10577
|
-
}
|
|
10578
|
-
project={project}
|
|
10579
|
-
readOnly={!isNativeApp()}
|
|
10580
|
-
width={rpSize.w}
|
|
10581
|
-
resizing={dragSide === 'rp'}
|
|
10582
|
-
onClose={() => setChangesOpen(false)}
|
|
10583
|
-
onCommit={gitCommit}
|
|
10584
|
-
onDiscard={gitDiscard}
|
|
10585
|
-
onPublish={gitPublish}
|
|
10586
|
-
onGetLatest={gitGetLatest}
|
|
10587
|
-
loadLog={gitLoadLog}
|
|
10588
|
-
onOpenCanvas={(p) => openTab(p)}
|
|
10589
|
-
onOpenDiff={(file) => setDiffTarget({ file, beforeSha: 'HEAD', conflict: false })}
|
|
10590
|
-
activeCanvas={
|
|
10591
|
-
activePath && activePath !== SYSTEM_TAB && /\.(tsx|html)$/i.test(activePath)
|
|
10592
|
-
? activePath
|
|
10593
|
-
: null
|
|
10594
|
-
}
|
|
10595
|
-
onPreviewVersion={(sha) =>
|
|
10596
|
-
setDiffTarget({ file: activePath, beforeSha: sha, conflict: false })
|
|
10597
|
-
}
|
|
10598
|
-
designRel={(cfg?.designRel || cfg?.designRoot || '.design').replace(/^\/+|\/+$/g, '')}
|
|
10599
|
-
/>
|
|
10600
|
-
) : inspectorOpen ? (
|
|
10601
|
-
<InspectorPanel
|
|
10602
|
-
selected={selected}
|
|
10603
|
-
cfg={cfg}
|
|
10604
|
-
tab={inspectorTab}
|
|
10605
|
-
onTabChange={setInspectorTab}
|
|
10606
|
-
onClose={() => setInspectorOpen(false)}
|
|
10607
|
-
onOptimistic={applyOptimisticStyle}
|
|
10608
|
-
onRecordEdit={recordSourceEdit}
|
|
10609
|
-
onReplaceMedia={onReplaceMedia}
|
|
10610
|
-
onResizeArtboard={resizeArtboardShell}
|
|
10611
|
-
editScope={editScope}
|
|
10612
|
-
onUndoRedo={(dir) => postToActiveCanvas({ dgn: dir })}
|
|
10613
|
-
photoSel={photoSel}
|
|
10614
|
-
photoRev={photoRev}
|
|
10615
|
-
onPhotoEdit={onPhotoEdit}
|
|
10616
|
-
onPhotoRemoveBackground={onPhotoRemoveBackground}
|
|
10617
|
-
onPhotoRecordEdit={onPhotoRecordEdit}
|
|
10618
|
-
onPhotoUndoRedo={(dir) => performPhotoUndo(dir === 'redo')}
|
|
10619
|
-
layersTree={layersTree}
|
|
10620
|
-
canvasFile={activePath}
|
|
10621
|
-
onSelectLayer={(n) =>
|
|
10622
|
-
postToActiveCanvas({
|
|
10623
|
-
dgn: 'select-by-id',
|
|
10624
|
-
id: n.id,
|
|
10625
|
-
artboardId: layersTree?.artboardId,
|
|
10626
|
-
index: n.index,
|
|
10627
|
-
})
|
|
10628
|
-
}
|
|
10629
|
-
onHoverLayer={(n) =>
|
|
10630
|
-
postToActiveCanvas({
|
|
10631
|
-
dgn: 'highlight',
|
|
10632
|
-
id: n ? n.id : null,
|
|
10633
|
-
artboardId: layersTree?.artboardId,
|
|
10634
|
-
index: n ? n.index : 0,
|
|
10635
|
-
})
|
|
10636
|
-
}
|
|
10637
|
-
onReorderLayer={reorderLayer}
|
|
10638
|
-
layersBusyRef={layersBusyRef}
|
|
10639
|
-
width={rpSize.w}
|
|
10640
|
-
resizing={dragSide === 'rp'}
|
|
10641
|
-
/>
|
|
10642
|
-
) : commentsPanelOpen ? (
|
|
10643
|
-
<CommentsPanel
|
|
10644
|
-
commentsByFile={commentsByFile}
|
|
10645
|
-
filter={commentsFilter}
|
|
10646
|
-
setFilter={setCommentsFilter}
|
|
10647
|
-
activePath={activePath}
|
|
10648
|
-
focusedId={focusedCommentId}
|
|
10649
|
-
onJump={jumpToComment}
|
|
10650
|
-
onResolve={resolveComment}
|
|
10651
|
-
onReopen={reopenComment}
|
|
10652
|
-
onDelete={deleteComment}
|
|
10653
|
-
width={rpSize.w}
|
|
10654
|
-
resizing={dragSide === 'rp'}
|
|
10655
|
-
/>
|
|
10656
|
-
) : null}
|
|
10657
|
-
{/* Phase 31 (DDR-123) — the ACP chat panel stays MOUNTED (display:none
|
|
10658
|
-
when inactive) so the chat keeps streaming + its history survives a
|
|
10659
|
-
switch to Changes/Inspector/Comments. Native-only. */}
|
|
10660
|
-
{isNativeApp() && (
|
|
10661
|
-
<ChatPanel
|
|
10662
|
-
hidden={!assistantOpen}
|
|
10663
|
-
activeCanvas={
|
|
10664
|
-
activePath && activePath !== SYSTEM_TAB && /\.(tsx|html)$/i.test(activePath)
|
|
10665
|
-
? activePath
|
|
10666
|
-
: null
|
|
10667
|
-
}
|
|
10668
|
-
selected={selected}
|
|
10669
|
-
designRel={(cfg?.designRel || cfg?.designRoot || '.design').replace(/^\/+|\/+$/g, '')}
|
|
11878
|
+
{/* RIGHT dock slot (feature-configurable-panel-docking). The Assistant
|
|
11879
|
+
(ACP) chat stays MOUNTED (display:none when inactive) so its stream
|
|
11880
|
+
survives a tab switch — DDR-123. Native-only. */}
|
|
11881
|
+
{(rightActive || rightHostsAssistant) && (
|
|
11882
|
+
<DockSlot
|
|
11883
|
+
side="right"
|
|
10670
11884
|
width={rpSize.w}
|
|
10671
|
-
|
|
10672
|
-
|
|
10673
|
-
|
|
10674
|
-
|
|
10675
|
-
|
|
11885
|
+
open={!!rightActive}
|
|
11886
|
+
ids={rightIds}
|
|
11887
|
+
activeId={rightActive}
|
|
11888
|
+
onPick={togglePanel}
|
|
11889
|
+
>
|
|
11890
|
+
{rightHostsAssistant && (
|
|
11891
|
+
<ChatPanel
|
|
11892
|
+
hidden={rightActive !== 'assistant'}
|
|
11893
|
+
activeCanvas={activeCanvasFile}
|
|
11894
|
+
selected={selected}
|
|
11895
|
+
designRel={(cfg?.designRel || cfg?.designRoot || '.design').replace(/^\/+|\/+$/g, '')}
|
|
11896
|
+
resizing={resizingFor('assistant')}
|
|
11897
|
+
onClose={() => setAssistantOpen(false)}
|
|
11898
|
+
onBusyChange={setAssistantBusy}
|
|
11899
|
+
onFinished={handleAssistantFinished}
|
|
11900
|
+
/>
|
|
11901
|
+
)}
|
|
11902
|
+
{rightActive && rightActive !== 'assistant' && renderPanelBody(rightActive)}
|
|
11903
|
+
</DockSlot>
|
|
10676
11904
|
)}
|
|
10677
11905
|
</div>
|
|
10678
11906
|
{/* DDR-148 — Timeline is a BOTTOM dock (full-width strip below the stage,
|
|
@@ -11072,11 +12300,44 @@ function App() {
|
|
|
11072
12300
|
onClose={() => setExportDialog(null)}
|
|
11073
12301
|
/>
|
|
11074
12302
|
)}
|
|
12303
|
+
{settingsOpen && (
|
|
12304
|
+
<SettingsPanel
|
|
12305
|
+
onClose={() => setSettingsOpen(false)}
|
|
12306
|
+
initialTab={typeof settingsOpen === 'string' ? settingsOpen : undefined}
|
|
12307
|
+
theme={theme}
|
|
12308
|
+
onSetTheme={setTheme}
|
|
12309
|
+
cpMode={cpMode}
|
|
12310
|
+
onSetCpMode={setCpMode}
|
|
12311
|
+
minimapVisible={minimapVisible}
|
|
12312
|
+
onToggleMinimap={toggleMinimap}
|
|
12313
|
+
zoomCtlVisible={zoomCtlVisible}
|
|
12314
|
+
onToggleZoomCtl={toggleZoomCtl}
|
|
12315
|
+
annotationsVisible={annotationsVisible}
|
|
12316
|
+
onToggleAnnotations={toggleAnnotations}
|
|
12317
|
+
autoOpenInspector={autoOpenInspector}
|
|
12318
|
+
onToggleAutoOpenInspector={() => setAutoOpenInspector((v) => !v)}
|
|
12319
|
+
hasCanvas={!!activePath && activePath !== SYSTEM_TAB}
|
|
12320
|
+
panelSide={panelSide}
|
|
12321
|
+
onSetPanelSide={(id, side) => setPanelSide((prev) => ({ ...prev, [id]: side }))}
|
|
12322
|
+
layersMode={layersMode}
|
|
12323
|
+
onSetLayersMode={(m) => {
|
|
12324
|
+
setLayersMode(m);
|
|
12325
|
+
// Leaving separate mode retires the standalone Layers panel.
|
|
12326
|
+
if (m !== 'separate') setLayersOpen(false);
|
|
12327
|
+
}}
|
|
12328
|
+
/>
|
|
12329
|
+
)}
|
|
12330
|
+
{generateOpen && (
|
|
12331
|
+
<GenerateDialog onClose={() => setGenerateOpen(false)} onInsert={insertGeneratedImage} />
|
|
12332
|
+
)}
|
|
11075
12333
|
{assetPickerReq && (
|
|
11076
12334
|
<AssetPicker
|
|
11077
12335
|
designRel={(cfg?.designRel || cfg?.designRoot || '.design').replace(/^\/+|\/+$/g, '')}
|
|
11078
12336
|
onPick={onAssetPicked}
|
|
11079
12337
|
onClose={() => setAssetPickerReq(null)}
|
|
12338
|
+
multiple={!!assetPickerReq.multiple}
|
|
12339
|
+
hasArtboardAnchor={!!assetPickerReq.hasArtboardAnchor}
|
|
12340
|
+
onPickMany={onPickMany}
|
|
11080
12341
|
/>
|
|
11081
12342
|
)}
|
|
11082
12343
|
{stickerPickerReq && (
|
|
@@ -11119,6 +12380,14 @@ function App() {
|
|
|
11119
12380
|
<WhatsNewPanel wn={whatsNew} onStartTour={startTour} />
|
|
11120
12381
|
<ExportPanel center={exportCenter} />
|
|
11121
12382
|
<ReadinessDialog open={readinessOpen} onClose={() => setReadinessOpen(false)} />
|
|
12383
|
+
<IntroVideoDialog open={introOpen} onClose={() => setIntroOpen(false)} />
|
|
12384
|
+
<SetupChecklistDialog
|
|
12385
|
+
open={quickSetupOpen}
|
|
12386
|
+
onClose={() => setQuickSetupOpen(false)}
|
|
12387
|
+
onStartTour={() => startTour(QUICK_SETUP_TOUR)}
|
|
12388
|
+
onBringBrand={() => setBrandUploadOpen(true)}
|
|
12389
|
+
/>
|
|
12390
|
+
<BrandUploadPanel open={brandUploadOpen} onClose={() => setBrandUploadOpen(false)} />
|
|
11122
12391
|
{usageNudge && !tourSteps && !collabNudge && (
|
|
11123
12392
|
<div className="mdcc-tour-nudge" role="status" aria-live="polite">
|
|
11124
12393
|
<div className="mdcc-tour-nudge__body">
|