@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.
Files changed (122) hide show
  1. package/apps/studio/acp/bridge.ts +44 -10
  2. package/apps/studio/acp/env.ts +23 -0
  3. package/apps/studio/acp/login-state.ts +263 -0
  4. package/apps/studio/acp/plugin-bootstrap.ts +31 -25
  5. package/apps/studio/acp/probe.ts +152 -1
  6. package/apps/studio/annotations-layer.tsx +396 -46
  7. package/apps/studio/api.ts +395 -2
  8. package/apps/studio/bin/_audio-search.mjs +140 -0
  9. package/apps/studio/bin/_import-asset-pdf-worker.mjs +34 -0
  10. package/apps/studio/bin/_import-asset.mjs +815 -0
  11. package/apps/studio/bin/_import-brand.mjs +635 -0
  12. package/apps/studio/bin/_import-tokens-alias-resolver.mjs +95 -0
  13. package/apps/studio/bin/_import-tokens.mjs +1201 -0
  14. package/apps/studio/bin/_transcribe.mjs +419 -0
  15. package/apps/studio/bin/_transcribe.test.mjs +80 -0
  16. package/apps/studio/bin/audio-search.sh +28 -0
  17. package/apps/studio/bin/generate.sh +154 -0
  18. package/apps/studio/bin/import-asset.sh +34 -0
  19. package/apps/studio/bin/import-brand.sh +29 -0
  20. package/apps/studio/bin/import-tokens.sh +33 -0
  21. package/apps/studio/bin/photo-bg-remove.sh +119 -21
  22. package/apps/studio/bin/transcribe.sh +39 -0
  23. package/apps/studio/canvas-edit.ts +197 -2
  24. package/apps/studio/canvas-lib.tsx +115 -6
  25. package/apps/studio/client/app.jsx +1954 -685
  26. package/apps/studio/client/generate-dialog.jsx +352 -0
  27. package/apps/studio/client/github.js +14 -0
  28. package/apps/studio/client/inspector-controls.jsx +781 -0
  29. package/apps/studio/client/panels/BrandUploadPanel.jsx +233 -0
  30. package/apps/studio/client/panels/ChatPanel.jsx +15 -33
  31. package/apps/studio/client/panels/IntroVideoDialog.jsx +44 -0
  32. package/apps/studio/client/panels/OnboardingWizard.jsx +12 -0
  33. package/apps/studio/client/panels/ReadinessList.jsx +227 -7
  34. package/apps/studio/client/panels/SettingsPanel.jsx +828 -0
  35. package/apps/studio/client/panels/SetupChecklist.jsx +223 -0
  36. package/apps/studio/client/photo-knobs.jsx +167 -172
  37. package/apps/studio/client/styles/3-shell-maude.css +334 -28
  38. package/apps/studio/client/styles/4-components.css +223 -0
  39. package/apps/studio/client/styles/6-acp-chat.css +86 -0
  40. package/apps/studio/client/tour/quick-setup-tour.js +39 -0
  41. package/apps/studio/commands/annotation-strokes-command.ts +13 -3
  42. package/apps/studio/config.schema.json +70 -0
  43. package/apps/studio/context-menu.tsx +11 -2
  44. package/apps/studio/context.ts +16 -0
  45. package/apps/studio/design-setup-readiness.ts +115 -0
  46. package/apps/studio/dist/client.bundle.js +2097 -2088
  47. package/apps/studio/dist/styles.css +1 -1
  48. package/apps/studio/draw/palette.ts +34 -0
  49. package/apps/studio/footage/schema.test.ts +92 -0
  50. package/apps/studio/footage/schema.ts +179 -1
  51. package/apps/studio/generation/adapters/elevenlabs.ts +370 -0
  52. package/apps/studio/generation/adapters/gemini.ts +542 -0
  53. package/apps/studio/generation/adapters/groq.test.ts +80 -0
  54. package/apps/studio/generation/adapters/groq.ts +247 -0
  55. package/apps/studio/generation/audio-library.test.ts +95 -0
  56. package/apps/studio/generation/audio-library.ts +156 -0
  57. package/apps/studio/generation/captions.test.ts +78 -0
  58. package/apps/studio/generation/captions.ts +132 -0
  59. package/apps/studio/generation/download.ts +76 -0
  60. package/apps/studio/generation/elevenlabs.test.ts +223 -0
  61. package/apps/studio/generation/gemini.test.ts +350 -0
  62. package/apps/studio/generation/jobs.test.ts +128 -0
  63. package/apps/studio/generation/jobs.ts +243 -0
  64. package/apps/studio/generation/keys.test.ts +75 -0
  65. package/apps/studio/generation/keys.ts +162 -0
  66. package/apps/studio/generation/prefs.test.ts +95 -0
  67. package/apps/studio/generation/prefs.ts +82 -0
  68. package/apps/studio/generation/registry.ts +88 -0
  69. package/apps/studio/generation/types.test.ts +123 -0
  70. package/apps/studio/generation/types.ts +266 -0
  71. package/apps/studio/generation/whisper-models.test.ts +152 -0
  72. package/apps/studio/generation/whisper-models.ts +257 -0
  73. package/apps/studio/generation/whisper-spike-results.md +61 -0
  74. package/apps/studio/git/service.ts +3 -1
  75. package/apps/studio/http.ts +867 -9
  76. package/apps/studio/media/intro.mp4 +0 -0
  77. package/apps/studio/media-commit-chain.ts +119 -0
  78. package/apps/studio/paths.ts +10 -0
  79. package/apps/studio/readiness.ts +108 -20
  80. package/apps/studio/scaffold-design.ts +1145 -0
  81. package/apps/studio/server.ts +13 -1
  82. package/apps/studio/test/_helpers.ts +15 -2
  83. package/apps/studio/test/acp-bridge.test.ts +34 -1
  84. package/apps/studio/test/acp-env.test.ts +30 -0
  85. package/apps/studio/test/acp-plugin-bootstrap.test.ts +23 -57
  86. package/apps/studio/test/acp-session-plugins.test.ts +72 -1
  87. package/apps/studio/test/annotation-strokes-command.test.ts +17 -0
  88. package/apps/studio/test/annotations-layer.test.ts +174 -0
  89. package/apps/studio/test/canvas-origin-gate.test.ts +37 -0
  90. package/apps/studio/test/csp-canvas-shell.test.ts +11 -0
  91. package/apps/studio/test/csrf-write-guard.test.ts +23 -1
  92. package/apps/studio/test/design-setup-readiness.test.ts +155 -0
  93. package/apps/studio/test/element-structural-edit.test.ts +108 -0
  94. package/apps/studio/test/fixtures/fake-claude-auth.mjs +13 -0
  95. package/apps/studio/test/generate-route.test.ts +106 -0
  96. package/apps/studio/test/import-asset-browser.test.ts +64 -0
  97. package/apps/studio/test/import-asset-route.test.ts +71 -0
  98. package/apps/studio/test/import-asset.test.ts +399 -0
  99. package/apps/studio/test/import-brand-browser.test.ts +88 -0
  100. package/apps/studio/test/import-brand-route.test.ts +120 -0
  101. package/apps/studio/test/import-brand.test.ts +206 -0
  102. package/apps/studio/test/import-tokens.test.ts +722 -0
  103. package/apps/studio/test/media-commit-chain.test.ts +210 -0
  104. package/apps/studio/test/photo-bg-remove-validation.test.ts +71 -0
  105. package/apps/studio/test/readiness.test.ts +13 -11
  106. package/apps/studio/test/scaffold-design.test.ts +122 -0
  107. package/apps/studio/test/tour-overlay.test.tsx +16 -0
  108. package/apps/studio/tool-palette.tsx +11 -6
  109. package/apps/studio/ui-prefs.ts +130 -0
  110. package/apps/studio/use-canvas-media-drop.tsx +4 -2
  111. package/apps/studio/use-chrome-visibility.tsx +8 -2
  112. package/apps/studio/whats-new.json +96 -0
  113. package/apps/studio/ws.ts +5 -0
  114. package/cli/bin/maude.mjs +6 -6
  115. package/cli/commands/design.mjs +33 -0
  116. package/cli/lib/gitignore-block.mjs +1 -0
  117. package/cli/lib/pkg-root.mjs +107 -0
  118. package/cli/lib/pkg-root.test.mjs +79 -0
  119. package/cli/lib/reconstruct-toolset.test.mjs +194 -0
  120. package/cli/lib/update-check.mjs +8 -0
  121. package/package.json +12 -10
  122. 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({ designRel, onPick, onClose }) {
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) => alive && setAssets(j.ok ? j.assets : []))
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
- try {
887
- const res = await fetch('/_api/asset', {
888
- method: 'POST',
889
- headers: { 'content-type': f.type || 'application/octet-stream' },
890
- body: f,
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 owns the success path)
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 file = input.files?.[0];
1168
+ const files = input.files ? Array.from(input.files) : [];
949
1169
  cleanup();
950
- if (file) doUpload(file);
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
- <button
987
- type="button"
988
- key={a.path}
989
- className="st-ap-cell"
990
- title={`${a.name} · ${Math.max(1, Math.round(a.size / 1024))} KB`}
991
- onClick={() => onPick(a.path)}
992
- >
993
- {a.kind === 'video' ? (
994
- <video className="st-ap-thumb" src={assetUrl(a.path)} muted playsInline />
995
- ) : (
996
- <img className="st-ap-thumb" src={assetUrl(a.path)} alt={a.name} loading="lazy" />
997
- )}
998
- <span className="st-ap-name">{a.name}</span>
999
- </button>
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({ file, activePath, onOpen, onDelete, openCount: oc, depth, kind, sidecar, dirty }) {
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">No canvas open</div>
3355
+ <div className="st-empty-title">Nothing open yet</div>
3032
3356
  <div className="st-empty-body">
3033
- Click a <code>.tsx</code> (or legacy <code>.html</code>) file in the tree, or open the{' '}
3034
- <strong>Design system</strong> view above it.
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
- Active file, selection, and comments are tracked in <code>_active.json</code> +{' '}
3047
- <code>_comments/</code> Claude reads them when you run <code>/design</code>.
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': { icon: 'p-size' },
3840
- 'line-height': { icon: 'p-lineheight' },
3841
- 'letter-spacing': { icon: 'p-letterspacing' },
3842
- gap: { icon: 'p-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': { icon: 'p-corner' },
3847
- 'border-width': { icon: 'p-border' },
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="saturation and value"
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="hue"
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
- <input
4193
- className="st-cp-fin"
4194
- type="text"
4195
- value={hex}
4196
- aria-label="hex value"
4197
- onChange={(e) => {
4198
- const v = e.target.value;
4199
- if (/^#?[0-9a-f]{6}$/i.test(v)) setHsv(rgbToHsv(hexToRgb(v)));
4200
- }}
4201
- onKeyDown={(e) => {
4202
- if (e.key === 'Enter') onApply(e.currentTarget.value);
4203
- }}
4204
- onBlur={(e) => onApply(e.currentTarget.value)}
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
- const dismiss = () => setOpen(false);
4272
- // The popover is fixed-positioned from the trigger rect, so a scroll of the
4273
- // PANEL detaches it dismiss. But scrolling INSIDE the popover (its own
4274
- // overflow list) must NOT close it (the user couldn't scroll the variables).
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
- setOpen(false);
4688
+ place();
4278
4689
  };
4279
4690
  document.addEventListener('pointerdown', onDoc, true);
4280
4691
  document.addEventListener('keydown', onKey);
4281
- window.addEventListener('resize', dismiss);
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', dismiss);
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={`st-cp-swatch st-cp-swatch--mini st-cp-swatch--trigger${bound ? ' is-bound' : ''}`}
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
- <div className="st-cp-modeseg" role="group" aria-label={`${axis} sizing mode`}>
4663
- <span className="st-cp-modeax" aria-hidden="true">
4664
- {axis === 'width' ? 'W' : 'H'}
4665
- </span>
4666
- {[
4667
- ['fixed', 'Fixed'],
4668
- ['hug', 'Hug'],
4669
- ['fill', 'Fill'],
4670
- ].map(([m, label]) => (
4671
- <button
4672
- key={m}
4673
- type="button"
4674
- className={`st-cp-modebtn${cur === m ? ' is-active' : ''}`}
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
- const row = (prop, control, provKind) => {
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={prop}>
4835
- {prop}
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
- {dirty ? (
4925
- <button
4926
- type="button"
4927
- className="st-cp-secreset"
4928
- aria-label={`reset ${name} section to original`}
4929
- title={`reset ${name}`}
4930
- onClick={() => resetSection(name)}
4931
- >
4932
-
4933
- </button>
4934
- ) : null}
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
- const unit = unitless ? '' : cur.unit && cur.unit !== 'auto' ? cur.unit : 'px';
4999
- const bump = (d) => {
5000
- const base = Number.parseFloat(cur.n || cssHint(computed[prop]) || '0') || 0;
5001
- commit(prop, `${Math.round((base + d) * 100) / 100}${unit}`);
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
- <div className="st-cp-num">
5007
- {lead ? (
5008
- <span className="st-cp-numlead" aria-hidden="true">
5009
- {lead.t ? lead.t : <StIcon name={lead.icon} size={12} />}
5010
- </span>
5011
- ) : null}
5012
- <input
5013
- className="st-cp-numin st-cp-scrub"
5014
- key={`${prop}:${authored[prop] ?? ''}`}
5015
- aria-label={prop}
5016
- defaultValue={cur.unit && cur.unit !== '' ? cur.n : (authored[prop] ?? '')}
5017
- placeholder={cssHint(computed[prop]) || '—'}
5018
- onPointerDown={makeScrub(prop, { unitless, unit, min: opts.min })}
5019
- onKeyDown={(e) => {
5020
- if (e.key === 'Enter') e.currentTarget.blur();
5021
- }}
5022
- onBlur={(e) => {
5023
- const raw = e.currentTarget.value.trim();
5024
- if (!raw) return;
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 control: the swatch is the trigger for a single popover with a
5073
- // full HSV picker (Custom) + the DS swatches (Variables). No separate native
5074
- // OS picker (#6was two popovers doing the same thing).
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
- <TokenPopover
5079
- kind="color"
5080
- groups={tokenGroups('color')}
5081
- current={authored[prop]}
5082
- activeDs={_activeDs}
5083
- swatchBg={resolved}
5084
- seedHex={cssColorToHex(computed[prop] || authored[prop]) || '#000000'}
5085
- onPick={(v) => commit(prop, v)}
5086
- label={`${prop} colour`}
5087
- />
5088
- {text(prop)}
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
- onPointerDown={makeScrub(prop, { sides: { pair, all } })}
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
- onPointerDown={makeScrub(prop, { sides: { pair, all }, min: -Infinity })}
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="st-cp" key={el.id} data-tour="css-panel" onKeyDown={onKnobKeyDown}>
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
- <span className="st-cp-idmeta">inline style</span>
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', csel('flex-direction', CSS_FLEX_DIR))}
5222
- {row('flex-wrap', csel('flex-wrap', CSS_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', csel('align-items', CSS_ALIGN))}
5228
- {row('justify-content', csel('justify-content', CSS_JUSTIFY))}
5229
- {row('gap', num('gap', 'space'))}
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', num('font-size', 'type'))}
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
- {row(
5279
- 'text-align',
5280
- <div className="st-cp-seg" role="group" aria-label="text-align">
5281
- {CSS_ALIGN_OPTS.map((a) => (
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
- {Math.round(el.bounds?.w || 0)} × {Math.round(el.bounds?.h || 0)}
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', csel('overflow', CSS_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. Rendered only for a media element
5366
- (img / video / picture / svg / canvas) or a selection that already
5367
- carries a framing prop, so a plain <div> doesn't grow object-fit rows.
5368
- Media = box/framing/source (this plan); the photo-editor plan's "Photo"
5369
- tab owns pixels/look they are separate DOM slots by design. */}
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
- <div className="st-cp-row">
5439
- {prov(provOf('border-radius'))}
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
- {row(
5499
- 'opacity',
5500
- <div className="st-cp-num">
5501
- <span className="st-cp-numlead" aria-hidden="true">
5502
- <StIcon name="p-opacity" size={12} />
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
- function ArtboardKnobs({ el, onResizeArtboard }) {
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
- key={`h:${h ?? ''}`}
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) => commitSize(null, Number.parseFloat(e.currentTarget.value))}
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 className="st-rp-tabs" data-tour="inspector-tabs">
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 el={el} onResizeArtboard={onResizeArtboard} />
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
- const openRightPanel = useCallback((which) => {
7081
- setChangesOpen(which === 'changes');
7082
- setInspectorOpen(which === 'inspector');
7083
- setCommentsPanelOpen(which === 'comments');
7084
- setAssistantOpen(which === 'assistant');
7085
- // NOTE: the Timeline is a BOTTOM dock (DDR-148), NOT part of the right-rail
7086
- // mutual-exclusion it stays open when a right panel opens.
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
- setSidebarOpen(true);
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(true);
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) => ({ ...prev, canvasDesignSystems: data.canvasDesignSystems ?? {} }));
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 empty-artboard fallback (`artboardId` in place of
8793
- // `refId`). An image needs a contained asset src, so the shell opens
8794
- // the AssetPicker; the pick then drives insertElementShell(kind:'image').
8795
- // Confused-deputy gated + pinned to the active canvas like the other
8796
- // request verbs.
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
- (m.position === 'before' ||
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) setSidebarOpen(true);
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
- setSidebarOpen((v) => !v);
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
- setSidebarOpen(true);
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
- setSidebarOpen(true);
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={() => setSidebarOpen((v) => !v)}
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
- // Toggle: already open on Layers close; otherwise open on Layers
10459
- // (clearing the sibling panels one dock slot).
10460
- if (inspectorOpen && inspectorTab === 'layers') {
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
- openRightPanel('inspector');
11767
+ openPanelExclusive('inspector');
10465
11768
  }
10466
11769
  }}
10467
11770
  onNewCanvas={() => {
10468
- setSidebarOpen(true);
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={!sidebarOpen}
10499
- onExpand={() => setSidebarOpen(true)}
11806
+ shown={!leftActive}
11807
+ onExpand={() => openPanelExclusive('tree')}
10500
11808
  onSearch={() => {
10501
- setSidebarOpen(true);
11809
+ openPanelExclusive('tree');
10502
11810
  setTimeout(() => document.querySelector('.st-search input')?.focus(), 60);
10503
11811
  }}
10504
11812
  />
10505
- <Sidebar
10506
- groups={groups}
10507
- activePath={activePath}
10508
- activeDsName={activePath === SYSTEM_TAB ? (systemData?.ds?.name ?? null) : null}
10509
- onOpen={openTab}
10510
- onOpenSystem={openSystem}
10511
- wsConnected={wsConnected}
10512
- search={search}
10513
- setSearch={setSearch}
10514
- commentsByFile={commentsByFile}
10515
- showHidden={showHidden}
10516
- sectionsExpanded={sectionsExpanded}
10517
- onToggleSection={toggleSection}
10518
- onNewBoard={createBoard}
10519
- onDeleteBoard={deleteBoard}
10520
- onRefresh={refreshTree}
10521
- refreshing={treeRefreshing}
10522
- collapsed={!sidebarOpen}
10523
- onCollapse={() => setSidebarOpen(false)}
10524
- width={sbSize.w}
10525
- resizing={dragSide === 'sb'}
10526
- dirtyByPath={dirtyByPath}
10527
- project={project}
10528
- gitBranch={gitStatus?.branch}
10529
- remoteSync={remoteSync}
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 files panel"
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
- {(inspectorOpen || commentsPanelOpen || changesOpen || assistantOpen) && (
11865
+ {rightActive && (
10559
11866
  <PanelGrip
10560
- label="Resize side panel"
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
- {/* Right dock one panel at a time. Changes (E2) takes precedence,
10572
- then Inspector (T6), then the comments panel. */}
10573
- {changesOpen ? (
10574
- <GitPanel
10575
- status={
10576
- gitStatus && remoteSync ? { ...gitStatus, ...remoteSync } : gitStatus
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
- resizing={dragSide === 'rp'}
10672
- onClose={() => setAssistantOpen(false)}
10673
- onBusyChange={setAssistantBusy}
10674
- onFinished={handleAssistantFinished}
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">