@jxsuite/studio 0.0.1 → 0.5.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 (38) hide show
  1. package/dist/studio.js +47638 -33445
  2. package/dist/studio.js.map +449 -344
  3. package/package.json +44 -33
  4. package/src/browse/browse.js +414 -0
  5. package/src/editor/context-menu.js +48 -1
  6. package/src/editor/convert-to-component.js +208 -0
  7. package/src/editor/inline-edit.js +33 -6
  8. package/src/editor/shortcuts.js +6 -1
  9. package/src/files/components.js +4 -2
  10. package/src/files/file-ops.js +102 -54
  11. package/src/files/files.js +22 -8
  12. package/src/markdown/md-convert.js +309 -11
  13. package/src/panels/activity-bar.js +3 -0
  14. package/src/panels/head-panel.js +576 -0
  15. package/src/panels/overlays.js +125 -0
  16. package/src/panels/right-panel.js +104 -0
  17. package/src/panels/shared.js +41 -0
  18. package/src/panels/signals-panel.js +95 -94
  19. package/src/panels/toolbar.js +217 -0
  20. package/src/platforms/devserver.js +58 -16
  21. package/src/settings/collections-editor.js +428 -0
  22. package/src/settings/defs-editor.js +418 -0
  23. package/src/settings/schema-field-ui.js +329 -0
  24. package/src/state.js +99 -2
  25. package/src/store.js +77 -41
  26. package/src/studio.js +1523 -1375
  27. package/src/ui/button-group.js +91 -0
  28. package/src/ui/color-selector.js +299 -0
  29. package/src/ui/field-row.js +47 -0
  30. package/src/ui/media-picker.js +172 -0
  31. package/src/ui/panel-resize.js +96 -0
  32. package/src/ui/spectrum.js +36 -2
  33. package/src/ui/unit-selector.js +106 -0
  34. package/src/ui/{jx-styled-combobox.js → value-selector.js} +7 -7
  35. package/src/ui/widgets.js +106 -0
  36. package/src/utils/inherited-style.js +54 -0
  37. package/src/utils/studio-utils.js +32 -0
  38. package/src/view.js +45 -0
package/src/studio.js CHANGED
@@ -9,11 +9,8 @@ import {
9
9
  createState,
10
10
  selectNode,
11
11
  hoverNode,
12
- undo,
13
- redo,
14
12
  insertNode,
15
13
  removeNode,
16
- duplicateNode,
17
14
  moveNode,
18
15
  updateProperty,
19
16
  updateStyle,
@@ -60,9 +57,19 @@ import {
60
57
  runPostRenderHooks,
61
58
  projectState,
62
59
  setProjectState,
60
+ updateFrontmatter,
61
+ updateUi,
62
+ updateSession,
63
+ setUpdateSessionFn,
64
+ setGetDocFn,
65
+ setGetSessionFn,
66
+ toFlat,
67
+ fromFlat,
63
68
  } from "./store.js";
64
69
 
65
- import { renderNode as runtimeRenderNode, buildScope, defineElement } from "@jxplatform/runtime";
70
+ import { view } from "./view.js";
71
+
72
+ import { renderNode as runtimeRenderNode, buildScope, defineElement } from "@jxsuite/runtime";
66
73
 
67
74
  import {
68
75
  startEditing,
@@ -86,8 +93,8 @@ import {
86
93
  kebabToLabel,
87
94
  propLabel,
88
95
  attrLabel,
89
- abbreviateValue,
90
96
  inferInputType,
97
+ findCollectionSchema,
91
98
  friendlyNameToVar,
92
99
  varDisplayName,
93
100
  parseCemType,
@@ -97,6 +104,7 @@ import {
97
104
  openFile as _openFile,
98
105
  loadMarkdown as _loadMarkdown,
99
106
  saveFile as _saveFile,
107
+ exportFile as _exportFile,
100
108
  } from "./files/file-ops.js";
101
109
  import {
102
110
  loadProject as _loadProject,
@@ -107,6 +115,7 @@ import {
107
115
  } from "./files/files.js";
108
116
  import { eventsSidebarTemplate as _eventsSidebarTemplate } from "./panels/events-panel.js";
109
117
  import { renderImportsTemplate } from "./panels/imports-panel.js";
118
+ import { renderHeadTemplate } from "./panels/head-panel.js";
110
119
  import { exportCemManifest as _exportCemManifest } from "./services/cem-export.js";
111
120
 
112
121
  import { registerPlatform, getPlatform, hasPlatform } from "./platform.js";
@@ -146,7 +155,6 @@ import {
146
155
 
147
156
  import { html, render as litRender, nothing } from "lit-html";
148
157
  import { live } from "lit-html/directives/live.js";
149
- import { classMap } from "lit-html/directives/class-map.js";
150
158
  import { ref } from "lit-html/directives/ref.js";
151
159
  import { styleMap } from "lit-html/directives/style-map.js";
152
160
  import { ifDefined } from "lit-html/directives/if-defined.js";
@@ -161,10 +169,22 @@ import { renderDataExplorerTemplate } from "./panels/data-explorer.js";
161
169
  // Explicit class imports + registration — bare side-effect imports are tree-shaken
162
170
  // by Bun's bundler despite sideEffects declarations in Spectrum's package.json.
163
171
  import { components as _swc } from "./ui/spectrum.js"; // eslint-disable-line no-unused-vars
164
- import icons from "./ui/icons.js";
165
- import { showContextMenu } from "./editor/context-menu.js";
172
+ import { renderFieldRow } from "./ui/field-row.js";
173
+ import { isColorPopoverOpen } from "./ui/color-selector.js";
174
+ import { widgetForType as _widgetForType } from "./ui/widgets.js";
175
+ import { computeInheritedStyle } from "./utils/inherited-style.js";
176
+ import "./ui/panel-resize.js";
177
+ import { showContextMenu, dismissContextMenu } from "./editor/context-menu.js";
178
+ import { convertToComponent } from "./editor/convert-to-component.js";
166
179
  import { initShortcuts } from "./editor/shortcuts.js";
167
- import { renderActivityBar, tabIcon } from "./panels/activity-bar.js";
180
+ import { renderActivityBar } from "./panels/activity-bar.js";
181
+ import { renderBrowse } from "./browse/browse.js";
182
+ import { renderCollectionsEditor } from "./settings/collections-editor.js";
183
+ import { renderDefsEditor } from "./settings/defs-editor.js";
184
+ import * as toolbarPanel from "./panels/toolbar.js";
185
+ import * as overlaysPanel from "./panels/overlays.js";
186
+ import * as rightPanelMod from "./panels/right-panel.js";
187
+ import { mediaDisplayName, ensureLitState } from "./panels/shared.js";
168
188
  import * as monaco from "monaco-editor/esm/vs/editor/editor.api.js";
169
189
 
170
190
  // ─── Globals ──────────────────────────────────────────────────────────────────
@@ -172,7 +192,11 @@ import * as monaco from "monaco-editor/esm/vs/editor/editor.api.js";
172
192
  // into their own modules, they will migrate to ctx in store.js.
173
193
 
174
194
  /** @type {any} */
175
- let S; // current state
195
+ let S; // current state (flat compatibility view)
196
+ /** @type {any} */
197
+ let doc = null; // doc slice (persisted, history, autosave)
198
+ /** @type {any} */
199
+ let session = null; // session slice (selection, hover, ui)
176
200
 
177
201
  /** Creates a display:contents container appended to sp-theme or body, for floating popovers/menus. */
178
202
  function createFloatingContainer() {
@@ -182,32 +206,7 @@ function createFloatingContainer() {
182
206
  return el;
183
207
  }
184
208
 
185
- const toolbar = toolbarEl;
186
-
187
209
  let canvasMode = "design";
188
- let panX = 0;
189
- let panY = 0;
190
- let needsCenter = true;
191
- /** @type {ResizeObserver | null} */
192
- let centerObserver = null;
193
- /** @type {any} */
194
- let panzoomWrap = null;
195
- /** @type {any} */
196
- let componentInlineEdit = null;
197
- /** @type {any} */
198
- let pendingInlineEdit = null;
199
- /** @type {any} */
200
- let monacoEditor = null;
201
- /** @type {any} */
202
- let functionEditor = null;
203
- /** @type {any} */
204
- let liveScope = null;
205
- /** @type {any} */
206
- let blockActionBarEl = null;
207
- /** @type {any} */
208
- let _inlineEditCleanup = null;
209
- /** @type {any} */
210
- let selDragCleanup = null;
211
210
 
212
211
  // ─── Component registry ───────────────────────────────────────────────────────
213
212
 
@@ -247,8 +246,8 @@ async function navigateBack() {
247
246
  async function closeFunctionEditor() {
248
247
  const editing = S.ui.editingFunction;
249
248
  if (!editing) return;
250
- if (functionEditor) {
251
- const currentCode = functionEditor.getValue();
249
+ if (view.functionEditor) {
250
+ const currentCode = view.functionEditor.getValue();
252
251
  const minResult = await codeService("minify", { code: currentCode });
253
252
  const bodyToStore = minResult?.code ?? currentCode;
254
253
  if (editing.type === "def") {
@@ -264,27 +263,12 @@ async function closeFunctionEditor() {
264
263
  }),
265
264
  );
266
265
  }
267
- functionEditor.dispose();
268
- functionEditor = null;
266
+ view.functionEditor.dispose();
267
+ view.functionEditor = null;
269
268
  }
270
- S = { ...S, ui: { ...S.ui, editingFunction: null } };
271
- renderCanvas();
272
- renderToolbar();
269
+ updateUi("editingFunction", null);
273
270
  }
274
271
 
275
- /**
276
- * DnD cleanup functions from previous render — called on re-render
277
- *
278
- * @type {any[]}
279
- */
280
- let dndCleanups = [];
281
- /**
282
- * Canvas DnD cleanup functions — separate from layer panel
283
- *
284
- * @type {any[]}
285
- */
286
- let canvasDndCleanups = [];
287
-
288
272
  /**
289
273
  * Convert a template string to a displayable expression for edit mode. Replaces ${expr} with ❮ expr
290
274
  * ❯ so the runtime renders it as literal text.
@@ -482,10 +466,11 @@ function prepareForEditMode(node) {
482
466
  * created element via onNodeCreated callback. Returns the live state scope on success, null on
483
467
  * failure.
484
468
  *
469
+ * @param {number} gen - Render generation for staleness detection
485
470
  * @param {any} doc
486
471
  * @param {any} canvasEl
487
472
  */
488
- async function renderCanvasLive(doc, canvasEl) {
473
+ async function renderCanvasLive(gen, doc, canvasEl) {
489
474
  canvasEl.innerHTML = "";
490
475
 
491
476
  // Apply content mode typography styling
@@ -525,10 +510,44 @@ async function renderCanvasLive(doc, canvasEl) {
525
510
  }
526
511
 
527
512
  try {
528
- const docBase = S.documentPath ? `${location.origin}/${S.documentPath}` : undefined;
513
+ const root = projectState?.projectRoot || "";
514
+ const docPrefix = root ? `${root}/` : "";
515
+ const docBase = S.documentPath ? `${location.origin}/${docPrefix}${S.documentPath}` : undefined;
529
516
 
530
517
  // Register custom elements so the runtime can render them
531
- const effectiveElements = getEffectiveElements(renderDoc.$elements);
518
+ let effectiveElements = getEffectiveElements(renderDoc.$elements);
519
+
520
+ // In content mode (markdown), auto-discover components for directive-based
521
+ // custom elements that have no explicit $elements registration.
522
+ if (S.mode === "content" && componentRegistry.length > 0) {
523
+ const existingRefs = new Set(
524
+ effectiveElements.map((/** @type {any} */ e) => (typeof e === "string" ? e : e?.$ref)),
525
+ );
526
+ /** @param {any} node */
527
+ const collectTags = (node) => {
528
+ /** @type {Set<string>} */
529
+ const tags = new Set();
530
+ if (!node || typeof node !== "object") return tags;
531
+ if (node.tagName) tags.add(node.tagName);
532
+ if (Array.isArray(node.children)) {
533
+ for (const child of node.children) {
534
+ for (const t of collectTags(child)) tags.add(t);
535
+ }
536
+ }
537
+ return tags;
538
+ };
539
+ for (const tag of collectTags(renderDoc)) {
540
+ const comp = componentRegistry.find((/** @type {any} */ c) => c.tagName === tag);
541
+ if (comp && comp.source !== "npm") {
542
+ const relPath = computeRelativePath(S.documentPath, comp.path);
543
+ if (!existingRefs.has(relPath)) {
544
+ effectiveElements.push({ $ref: relPath });
545
+ existingRefs.add(relPath);
546
+ }
547
+ }
548
+ }
549
+ }
550
+
532
551
  if (effectiveElements.length) {
533
552
  renderDoc.$elements = effectiveElements;
534
553
  for (const entry of effectiveElements) {
@@ -553,9 +572,55 @@ async function renderCanvasLive(doc, canvasEl) {
553
572
  }
554
573
  }
555
574
 
575
+ // Bail out if a newer render started while we were importing elements
576
+ if (gen !== view.renderGeneration) return null;
577
+
556
578
  // Inject site-level imports so buildScope can resolve $prototype names
557
579
  renderDoc.imports = getEffectiveImports(renderDoc.imports);
558
580
 
581
+ // Apply project-level styles mirroring the compiler convention:
582
+ // viewport ≈ :root → CSS custom properties (they inherit down)
583
+ // canvasEl ≈ body → regular CSS properties (inline beats CSS defaults)
584
+ // This ensures project font-family, color, etc. override the
585
+ // content-mode fallback typography rules in the stylesheet.
586
+ // In edit mode, propagate to the .content-edit-canvas wrapper for seamless appearance.
587
+ const viewport = canvasEl.closest(".canvas-panel-viewport");
588
+ const editSurface = canvasMode === "edit" ? canvasEl.closest(".content-edit-canvas") : null;
589
+ const siteStyle = projectState?.projectConfig?.style;
590
+ if (viewport) {
591
+ viewport.style.cssText = "";
592
+ if (siteStyle && typeof siteStyle === "object") {
593
+ for (const [k, v] of Object.entries(siteStyle)) {
594
+ if (k.startsWith("--")) {
595
+ viewport.style.setProperty(k, String(v));
596
+ } else {
597
+ /** @type {any} */ (viewport.style)[k] = v;
598
+ }
599
+ }
600
+ }
601
+ }
602
+ if (editSurface) {
603
+ if (siteStyle && typeof siteStyle === "object") {
604
+ for (const [k, v] of Object.entries(siteStyle)) {
605
+ if (k.startsWith("--")) {
606
+ /** @type {any} */ (editSurface).style.setProperty(k, String(v));
607
+ } else {
608
+ /** @type {any} */ (editSurface.style)[k] = v;
609
+ }
610
+ }
611
+ }
612
+ }
613
+ if (siteStyle && typeof siteStyle === "object") {
614
+ for (const [k, v] of Object.entries(siteStyle)) {
615
+ if (!k.startsWith("--")) {
616
+ /** @type {any} */ (canvasEl.style)[k] = v;
617
+ }
618
+ }
619
+ }
620
+
621
+ // Inject site-level $media so runtime can resolve media queries in styles
622
+ renderDoc.$media = getEffectiveMedia(renderDoc.$media);
623
+
559
624
  // Inject $head elements (link/meta/script) into document.head
560
625
  const effectiveHead = getEffectiveHead(renderDoc.$head);
561
626
  if (effectiveHead.length) {
@@ -584,6 +649,8 @@ async function renderCanvasLive(doc, canvasEl) {
584
649
  }
585
650
 
586
651
  const $defs = await buildScope(renderDoc, {}, docBase);
652
+ // Bail out if a newer render started while buildScope was running
653
+ if (gen !== view.renderGeneration) return null;
587
654
  const el = /** @type {HTMLElement} */ (
588
655
  runtimeRenderNode(renderDoc, $defs, {
589
656
  onNodeCreated(/** @type {any} */ el, /** @type {any} */ path) {
@@ -632,7 +699,7 @@ async function renderCanvasLive(doc, canvasEl) {
632
699
  const editingEl = getActiveElement();
633
700
  for (const child of canvasEl.querySelectorAll("*")) {
634
701
  // Preserve pointer-events on the actively-edited element
635
- if (componentInlineEdit && child === componentInlineEdit.el) continue;
702
+ if (view.componentInlineEdit && child === view.componentInlineEdit.el) continue;
636
703
  if (editingEl && child === editingEl) continue;
637
704
  /** @type {any} */ (child).style.pointerEvents = "none";
638
705
  }
@@ -640,7 +707,7 @@ async function renderCanvasLive(doc, canvasEl) {
640
707
  }
641
708
  return $defs;
642
709
  } catch (/** @type {any} */ err) {
643
- console.warn("Jx Studio: runtime render failed, falling back to structural preview", err);
710
+ console.warn("renderCanvasLive failed:", err.message, err);
644
711
  return null;
645
712
  }
646
713
  }
@@ -666,40 +733,11 @@ litRender(
666
733
  const cssInitialMap = new Map(/** @type {any} */ (webdata.cssProps));
667
734
 
668
735
  // Persistent render hosts for lit-html (must be before bootstrap/render)
669
- const zoomIndicatorHost = document.createElement("div");
736
+ let zoomIndicatorHost = document.createElement("div");
670
737
  zoomIndicatorHost.style.display = "contents";
671
738
  document.body.appendChild(zoomIndicatorHost);
672
739
 
673
- // ─── Icon maps & module-level UI state (must be before render() call) ─────────
674
-
675
- const toolbarIconMap = /** @type {Record<string, any>} */ ({
676
- "sp-icon-folder-open": html`<sp-icon-folder-open slot="icon"></sp-icon-folder-open>`,
677
- "sp-icon-save-floppy": html`<sp-icon-save-floppy slot="icon"></sp-icon-save-floppy>`,
678
- "sp-icon-back": html`<sp-icon-back slot="icon"></sp-icon-back>`,
679
- "sp-icon-undo": html`<sp-icon-undo slot="icon"></sp-icon-undo>`,
680
- "sp-icon-redo": html`<sp-icon-redo slot="icon"></sp-icon-redo>`,
681
- "sp-icon-duplicate": html`<sp-icon-duplicate slot="icon"></sp-icon-duplicate>`,
682
- "sp-icon-delete": html`<sp-icon-delete slot="icon"></sp-icon-delete>`,
683
- "sp-icon-edit": html`<sp-icon-edit slot="icon"></sp-icon-edit>`,
684
- "sp-icon-artboard": html`<sp-icon-artboard slot="icon"></sp-icon-artboard>`,
685
- "sp-icon-preview": html`<sp-icon-preview slot="icon"></sp-icon-preview>`,
686
- "sp-icon-code": html`<sp-icon-code slot="icon"></sp-icon-code>`,
687
- "sp-icon-brush": html`<sp-icon-brush slot="icon"></sp-icon-brush>`,
688
- "sp-icon-document": html`<sp-icon-document slot="icon"></sp-icon-document>`,
689
- });
690
-
691
- /**
692
- * @param {any} label
693
- * @param {any} onClick
694
- * @param {any} iconTag
695
- */
696
- function tbBtnTpl(label, onClick, iconTag) {
697
- return html`
698
- <sp-action-button size="s" @click=${onClick}>
699
- ${iconTag ? toolbarIconMap[iconTag] : nothing} ${label}
700
- </sp-action-button>
701
- `;
702
- }
740
+ // ─── Module-level UI state (must be before render() call) ─────────────────────
703
741
 
704
742
  let elementsCollapsed = new Set();
705
743
  let elementsFilter = "";
@@ -721,43 +759,125 @@ const EMPTY_DOC = {
721
759
  };
722
760
 
723
761
  S = createState(structuredClone(EMPTY_DOC));
762
+ ({ doc, session } = fromFlat(S));
724
763
 
725
764
  // ─── Render loop ──────────────────────────────────────────────────────────────
726
765
 
766
+ // Mount extracted panel modules
767
+ toolbarPanel.mount(toolbarEl, {
768
+ navigateBack: () => navigateBack(),
769
+ closeFunctionEditor: () => closeFunctionEditor(),
770
+ openProject: () => openProject(),
771
+ openFile: () => openFile(),
772
+ saveFile: () => saveFile(),
773
+ parseMediaEntries,
774
+ getCanvasMode: () => canvasMode,
775
+ setCanvasMode: (/** @type {any} */ m) => {
776
+ canvasMode = m;
777
+ },
778
+ renderCanvas: () => renderCanvas(),
779
+ safeRenderRightPanel: () => safeRenderRightPanel(),
780
+ });
781
+
782
+ overlaysPanel.mount({
783
+ effectiveZoom,
784
+ getCanvasMode: () => canvasMode,
785
+ isEditing,
786
+ renderBlockActionBar,
787
+ findCanvasElement,
788
+ getActivePanel,
789
+ });
790
+
791
+ rightPanelMod.mount({
792
+ propertiesSidebarTemplate,
793
+ renderStylePanelTemplate,
794
+ renderCanvas: () => renderCanvas(),
795
+ updateForcedPseudoPreview,
796
+ });
797
+
727
798
  // Register all renderers with the store so render()/renderOnly() work
728
- registerRenderer("toolbar", () => renderToolbar());
799
+ registerRenderer("toolbar", () => toolbarPanel.render());
729
800
  registerRenderer("activityBar", () => renderActivityBar(S));
730
801
  registerRenderer("leftPanel", () => renderLeftPanel());
731
802
  registerRenderer("canvas", () => renderCanvas());
732
- registerRenderer("rightPanel", () => renderRightPanel());
733
- registerRenderer("overlays", () => renderOverlays());
803
+ registerRenderer("rightPanel", () => rightPanelMod.render());
804
+ registerRenderer("overlays", () => overlaysPanel.render());
734
805
  registerRenderer("statusbar", () => renderStatusbar(S));
735
806
  setStatusbarRenderer(() => renderStatusbar(S));
736
807
 
808
+ function safeRenderLeftPanel() {
809
+ try {
810
+ ensureLitState(leftPanel);
811
+ renderLeftPanel();
812
+ } catch (e) {
813
+ console.error("renderLeftPanel error:", e);
814
+ try {
815
+ leftPanel.textContent = "";
816
+ // @ts-ignore
817
+ delete leftPanel["_$litPart$"];
818
+ renderLeftPanel();
819
+ } catch (e2) {
820
+ console.error("renderLeftPanel retry failed:", e2);
821
+ }
822
+ }
823
+ }
824
+
825
+ function safeRenderRightPanel() {
826
+ rightPanelMod.render();
827
+ }
828
+
737
829
  // Register the update implementation with the store
738
830
  setGetStateFn(() => S);
739
831
  setUpdateFn(function _update(/** @type {any} */ newState) {
832
+ const prev = S;
740
833
  const prevDoc = S.document;
741
834
  const prevSel = S.selection;
742
835
  S = newState;
743
836
 
744
- renderToolbar();
837
+ // Keep doc/session slices in sync with flat S
838
+ ({ doc, session } = fromFlat(S));
839
+
840
+ const docChanged = prevDoc !== S.document;
841
+ const selChanged = !pathsEqual(prevSel, S.selection);
842
+ const modeChanged = prev.mode !== S.mode;
843
+ const uiChanged = prev.ui !== S.ui;
844
+
845
+ const canvasUiChanged =
846
+ uiChanged &&
847
+ (prev.ui?.editingFunction !== S.ui?.editingFunction ||
848
+ prev.ui?.settingsTab !== S.ui?.settingsTab ||
849
+ prev.ui?.stylebookTab !== S.ui?.stylebookTab ||
850
+ prev.ui?.stylebookFilter !== S.ui?.stylebookFilter ||
851
+ prev.ui?.stylebookCustomizedOnly !== S.ui?.stylebookCustomizedOnly ||
852
+ prev.ui?.featureToggles !== S.ui?.featureToggles);
853
+ const leftUiChanged =
854
+ uiChanged && (prev.ui?.leftTab !== S.ui?.leftTab || prev.ui?.settingsTab !== S.ui?.settingsTab);
855
+
856
+ try {
857
+ renderToolbar();
858
+ } catch (e) {
859
+ console.error("renderToolbar error:", e);
860
+ }
745
861
 
746
- if (prevDoc !== S.document) {
862
+ if (docChanged || modeChanged || canvasUiChanged) {
747
863
  try {
748
864
  renderCanvas();
749
865
  } catch (e) {
750
- console.warn("renderCanvas error:", e);
866
+ console.error("renderCanvas error:", e);
751
867
  }
752
- renderLeftPanel();
753
- } else if (!pathsEqual(prevSel, S.selection)) {
754
- renderLeftPanel();
868
+ safeRenderLeftPanel();
869
+ } else if (selChanged || leftUiChanged) {
870
+ safeRenderLeftPanel();
871
+ }
872
+
873
+ if (uiChanged && prev.ui?.activeMedia !== S.ui?.activeMedia) {
874
+ updateActivePanelHeaders();
755
875
  }
756
876
 
757
877
  // Skip right-panel rebuild when an input inside it is focused (user is typing)
758
878
  // unless the selection changed — that always needs a full re-render
759
879
  // Also re-render when color popover is open (changes come from outside rightPanel)
760
- const colorPopoverOpen = !!_colorPopoverHost.querySelector("sp-popover[open]");
880
+ const colorPopoverOpen = isColorPopoverOpen();
761
881
  const activeTag = document.activeElement?.tagName;
762
882
  const rightHasFocus =
763
883
  !colorPopoverOpen &&
@@ -769,27 +889,98 @@ setUpdateFn(function _update(/** @type {any} */ newState) {
769
889
  activeTag === "SP-PICKER" ||
770
890
  activeTag === "SP-COMBOBOX" ||
771
891
  activeTag === "SP-SEARCH");
772
- if (!rightHasFocus || !pathsEqual(prevSel, S.selection)) {
773
- renderRightPanel();
892
+ if (!rightHasFocus || selChanged || uiChanged) {
893
+ safeRenderRightPanel();
774
894
  }
775
- renderOverlays();
776
- renderStatusbar(S);
777
895
 
778
- // Post-render hooks (pseudo-state preview, pending inline edit, etc.)
779
- runPostRenderHooks(prevDoc, prevSel);
896
+ try {
897
+ renderOverlays();
898
+ } catch (e) {
899
+ console.error("renderOverlays error:", e);
900
+ }
901
+ try {
902
+ renderStatusbar(S);
903
+ } catch (e) {
904
+ console.error("renderStatusbar error:", e);
905
+ }
780
906
 
781
- // Update middleware (autosave, etc.)
907
+ runPostRenderHooks(prevDoc, prevSel);
782
908
  runUpdateMiddleware(S);
783
909
  });
784
910
 
911
+ // Register session dispatch — lightweight path for selection/hover/ui changes
912
+ setGetDocFn(() => doc);
913
+ setGetSessionFn(() => session);
914
+ setUpdateSessionFn(function _updateSession(/** @type {any} */ patch) {
915
+ const prev = session;
916
+ session = { ...session, ...patch };
917
+ if (patch.ui) {
918
+ session.ui = { ...prev.ui, ...patch.ui };
919
+ }
920
+ S = toFlat(doc, session);
921
+
922
+ const selChanged = !pathsEqual(prev.selection, session.selection);
923
+ const uiChanged = prev.ui !== session.ui;
924
+
925
+ const canvasUiChanged =
926
+ uiChanged &&
927
+ (prev.ui?.editingFunction !== session.ui?.editingFunction ||
928
+ prev.ui?.settingsTab !== session.ui?.settingsTab ||
929
+ prev.ui?.stylebookTab !== session.ui?.stylebookTab ||
930
+ prev.ui?.stylebookFilter !== session.ui?.stylebookFilter ||
931
+ prev.ui?.stylebookCustomizedOnly !== session.ui?.stylebookCustomizedOnly ||
932
+ prev.ui?.featureToggles !== session.ui?.featureToggles);
933
+ const leftUiChanged =
934
+ uiChanged &&
935
+ (prev.ui?.leftTab !== session.ui?.leftTab || prev.ui?.settingsTab !== session.ui?.settingsTab);
936
+
937
+ try {
938
+ renderToolbar();
939
+ } catch (e) {
940
+ console.error("renderToolbar error:", e);
941
+ }
942
+
943
+ if (canvasUiChanged) {
944
+ try {
945
+ renderCanvas();
946
+ } catch (e) {
947
+ console.error("renderCanvas error:", e);
948
+ }
949
+ safeRenderLeftPanel();
950
+ } else if (selChanged || leftUiChanged) {
951
+ safeRenderLeftPanel();
952
+ }
953
+
954
+ if (uiChanged && prev.ui?.activeMedia !== session.ui?.activeMedia) {
955
+ updateActivePanelHeaders();
956
+ }
957
+
958
+ if (selChanged || uiChanged) {
959
+ safeRenderRightPanel();
960
+ }
961
+
962
+ try {
963
+ renderOverlays();
964
+ } catch (e) {
965
+ console.error("renderOverlays error:", e);
966
+ }
967
+ try {
968
+ renderStatusbar(S);
969
+ } catch (e) {
970
+ console.error("renderStatusbar error:", e);
971
+ }
972
+
973
+ runPostRenderHooks(doc.document, prev.selection);
974
+ });
975
+
785
976
  // Register post-render hook for pseudo-state preview
786
977
  addPostRenderHook(() => updateForcedPseudoPreview());
787
978
 
788
979
  // Register post-render hook for pending inline edit
789
980
  addPostRenderHook((/** @type {any} */ prevDoc) => {
790
- if (pendingInlineEdit && prevDoc === S.document) {
791
- const { path, mediaName: mn } = pendingInlineEdit;
792
- pendingInlineEdit = null;
981
+ if (view.pendingInlineEdit && prevDoc === S.document) {
982
+ const { path, mediaName: mn } = view.pendingInlineEdit;
983
+ view.pendingInlineEdit = null;
793
984
  const targetPanel =
794
985
  canvasPanels.find((/** @type {any} */ p) => p.mediaName === mn) || canvasPanels[0];
795
986
  if (targetPanel) {
@@ -806,7 +997,9 @@ const _openParam = new URLSearchParams(location.search).get("open");
806
997
 
807
998
  if (_openParam) {
808
999
  // ?open= mode: skip normal loadProject, set up site context from the path
809
- if (!_openParam.startsWith("/") && !_openParam.startsWith("~")) {
1000
+ const isAbsPath =
1001
+ _openParam.startsWith("/") || _openParam.startsWith("~") || /^[A-Za-z]:[/\\]/.test(_openParam);
1002
+ if (!isAbsPath) {
810
1003
  statusMessage(`Error: ?open= requires an absolute path (got "${_openParam}")`);
811
1004
  render();
812
1005
  } else {
@@ -819,13 +1012,17 @@ if (_openParam) {
819
1012
  : { sitePath: null };
820
1013
 
821
1014
  if (siteCtx.sitePath) {
822
- // Set PAL project root to server-relative path so file ops work
823
- if (siteCtx.relPath) platform.projectRoot = siteCtx.relPath;
1015
+ // Set PAL project root to absolute path so file ops work
1016
+ if (siteCtx.sitePath) {
1017
+ platform.projectRoot = siteCtx.sitePath;
1018
+ // Await activation so the server resolves project-relative static files
1019
+ if (platform.activate) await platform.activate();
1020
+ }
824
1021
 
825
1022
  setProjectState({
826
1023
  root: siteCtx.sitePath,
827
1024
  name: siteCtx.projectConfig?.name || "Project",
828
- projectRoot: siteCtx.relPath || ".",
1025
+ projectRoot: siteCtx.sitePath,
829
1026
  isSiteProject: true,
830
1027
  projectConfig: siteCtx.projectConfig,
831
1028
  projectDirs: [],
@@ -837,27 +1034,40 @@ if (_openParam) {
837
1034
 
838
1035
  await loadComponentRegistry();
839
1036
 
840
- // Load directory tree
1037
+ // Load directory tree and populate projectDirs from conventional dirs found
1038
+ const conventionalDirs = [
1039
+ "pages",
1040
+ "layouts",
1041
+ "components",
1042
+ "content",
1043
+ "data",
1044
+ "public",
1045
+ "styles",
1046
+ ];
841
1047
  const dirEntries = await platform.listDirectory(".");
842
1048
  projectState.dirs.set(".", dirEntries);
1049
+ const foundDirs = [];
843
1050
  for (const e of dirEntries) {
844
- if (e.type === "directory" && ["pages", "components", "layouts"].includes(e.name)) {
1051
+ if (e.type === "directory" && conventionalDirs.includes(e.name)) {
1052
+ foundDirs.push(e.name);
845
1053
  projectState.expanded.add(e.path || e.name);
846
1054
  const sub = await platform.listDirectory(e.path || e.name);
847
1055
  projectState.dirs.set(e.path || e.name, sub);
848
1056
  }
849
1057
  }
1058
+ projectState.projectDirs = foundDirs;
850
1059
  }
851
1060
 
852
1061
  // Read and open the file
853
1062
  const fileRelPath = siteCtx.fileRelPath || _openParam;
854
1063
  const content = await platform.readFile(fileRelPath);
855
1064
  if (content) {
856
- const doc = JSON.parse(content);
857
- S = createState(doc);
1065
+ const parsed = JSON.parse(content);
1066
+ S = createState(parsed);
858
1067
  S.dirty = false;
859
1068
  S.documentPath = fileRelPath;
860
1069
  S.ui = { ...S.ui, leftTab: "files" };
1070
+ ({ doc, session } = fromFlat(S));
861
1071
  render();
862
1072
  statusMessage(`Opened ${_openParam}`);
863
1073
  }
@@ -954,9 +1164,55 @@ function applyCanvasStyle(el, styleDef, activeBreakpoints, featureToggles) {
954
1164
  }
955
1165
  }
956
1166
 
1167
+ /**
1168
+ * After a runtime render, apply active media overrides as inline styles so they beat the base
1169
+ * inline styles the runtime already set. The runtime uses @media CSS rules for overrides, but those
1170
+ * can never beat inline base styles.
1171
+ *
1172
+ * @param {Element} canvasEl
1173
+ * @param {Set<string>} activeBreakpoints
1174
+ */
1175
+ function applyCanvasMediaOverrides(canvasEl, activeBreakpoints) {
1176
+ if (!activeBreakpoints.size) return;
1177
+ for (const el of /** @type {NodeListOf<HTMLElement>} */ (canvasEl.querySelectorAll("*"))) {
1178
+ const path = elToPath.get(el);
1179
+ if (!path) continue;
1180
+ const node = getNodeAtPath(S.document, path);
1181
+ if (!node?.style) continue;
1182
+ for (const [key, val] of Object.entries(node.style)) {
1183
+ if (!key.startsWith("@") || typeof val !== "object") continue;
1184
+ const mediaName = key.slice(1);
1185
+ if (mediaName === "--") continue;
1186
+ if (!activeBreakpoints.has(mediaName)) continue;
1187
+ for (const [prop, v] of Object.entries(/** @type {any} */ (val))) {
1188
+ if (typeof v === "string" || typeof v === "number") {
1189
+ try {
1190
+ if (prop.startsWith("--")) el.style.setProperty(prop, String(v));
1191
+ else /** @type {any} */ (el.style)[prop] = v;
1192
+ } catch {}
1193
+ }
1194
+ }
1195
+ }
1196
+ }
1197
+ }
1198
+
957
1199
  // ─── Canvas ───────────────────────────────────────────────────────────────────
958
1200
 
959
1201
  function renderCanvas() {
1202
+ // Advance render generation so stale async renders from the previous cycle bail out
1203
+ ++view.renderGeneration;
1204
+
1205
+ // Always clear Lit's internal state so it builds fresh DOM. Stale async
1206
+ // renderCanvasLive calls from a previous cycle can corrupt nested ChildPart
1207
+ // markers (Comment nodes inside panzoom-wrap) in ways the root-only
1208
+ // ensureLitState check cannot detect.
1209
+ // @ts-ignore
1210
+ if (canvasWrap["_$litPart$"]) {
1211
+ canvasWrap.textContent = "";
1212
+ // @ts-ignore
1213
+ delete canvasWrap["_$litPart$"];
1214
+ }
1215
+
960
1216
  // Function editor mode: editing a function body in Monaco (JS)
961
1217
  if (S.ui.editingFunction) {
962
1218
  renderFunctionEditor();
@@ -964,68 +1220,125 @@ function renderCanvas() {
964
1220
  }
965
1221
 
966
1222
  // Dispose function editor if switching away
967
- if (functionEditor) {
968
- functionEditor.dispose();
969
- functionEditor = null;
1223
+ if (view.functionEditor) {
1224
+ view.functionEditor.dispose();
1225
+ view.functionEditor = null;
970
1226
  }
971
1227
 
972
1228
  // Source mode: update existing Monaco editor without recreating
973
- if (canvasMode === "source" && monacoEditor) {
1229
+ if (canvasMode === "source" && view.monacoEditor) {
974
1230
  const jsonStr = JSON.stringify(S.document, null, 2);
975
- const currentVal = monacoEditor.getValue();
1231
+ const currentVal = view.monacoEditor.getValue();
976
1232
  if (currentVal !== jsonStr) {
977
1233
  // Prevent triggering the onChange handler for this programmatic update
978
- monacoEditor._ignoreNextChange = true;
979
- monacoEditor.setValue(jsonStr);
1234
+ view.monacoEditor._ignoreNextChange = true;
1235
+ view.monacoEditor.setValue(jsonStr);
980
1236
  }
981
1237
  return;
982
1238
  }
983
1239
 
984
- // Clean up previous canvas DnD registrations and center observer
985
- if (centerObserver) {
986
- centerObserver.disconnect();
987
- centerObserver = null;
988
- }
989
- for (const fn of canvasDndCleanups) fn();
990
- canvasDndCleanups = [];
1240
+ // Detect whether this is a mode transition or a content-only re-render
1241
+ const modeChanged = canvasMode !== view.prevCanvasMode;
1242
+ view.prevCanvasMode = canvasMode;
1243
+
1244
+ // DnD handlers are registered on inner canvas elements that get replaced on every
1245
+ // content render, so always clean them up.
1246
+ for (const fn of view.canvasDndCleanups) fn();
1247
+ view.canvasDndCleanups = [];
1248
+
1249
+ // Panel event handlers (click, dblclick, etc.) capture closures over panel references.
1250
+ // Always re-register to keep closures fresh across document switches.
1251
+ for (const fn of view.canvasEventCleanups) fn();
1252
+ view.canvasEventCleanups = [];
1253
+
1254
+ // Panel JS objects are cheap — always clear and repopulate from templates.
1255
+ // The actual DOM elements are preserved by Lit's diffing on content-only re-renders.
991
1256
  canvasPanels.length = 0;
992
1257
 
993
- // Dispose Monaco editor if switching away from source mode
994
- if (monacoEditor) {
995
- monacoEditor.dispose();
996
- monacoEditor = null;
1258
+ if (modeChanged) {
1259
+ // Full teardown on mode transitions — new panel structure needed
1260
+ if (view.centerObserver) {
1261
+ view.centerObserver.disconnect();
1262
+ view.centerObserver = null;
1263
+ }
1264
+
1265
+ // Dispose Monaco editor if switching away from source mode
1266
+ if (view.monacoEditor) {
1267
+ view.monacoEditor.dispose();
1268
+ view.monacoEditor = null;
1269
+ }
1270
+
1271
+ litRender(nothing, canvasWrap);
1272
+ view.panzoomWrap = null;
1273
+ // Reset inline style overrides from other modes
1274
+ canvasWrap.style.padding = "";
1275
+ canvasWrap.style.alignItems = "";
1276
+ canvasWrap.style.display = "";
1277
+ canvasWrap.style.overflow = "";
1278
+ canvasWrap.style.overflow = "";
1279
+
1280
+ // Clear zoom indicator (only re-rendered by design/preview/stylebook)
1281
+ try {
1282
+ litRender(nothing, zoomIndicatorHost);
1283
+ } catch {
1284
+ const newHost = document.createElement("div");
1285
+ newHost.style.display = "contents";
1286
+ zoomIndicatorHost.replaceWith(newHost);
1287
+ zoomIndicatorHost = newHost;
1288
+ }
1289
+
1290
+ // Dismiss open popovers/toolbars that are no longer relevant
1291
+ if (view.blockActionBarEl) litRender(nothing, view.blockActionBarEl);
1292
+ dismissLinkPopover();
1293
+ dismissContextMenu();
1294
+ sharedDismissSlashMenu();
1295
+ }
1296
+
1297
+ // Manage mode: project-level file browser table
1298
+ if (canvasMode === "manage") {
1299
+ canvasWrap.style.padding = "0";
1300
+ canvasWrap.style.overflow = "auto";
1301
+ renderBrowse(canvasWrap, {
1302
+ openFile: (/** @type {string} */ path) => {
1303
+ canvasMode = "edit";
1304
+ openFileFromTree(path);
1305
+ },
1306
+ });
1307
+ return;
997
1308
  }
998
1309
 
999
- litRender(nothing, canvasWrap);
1000
- panzoomWrap = null;
1001
- // Reset inline style overrides from other modes
1002
- canvasWrap.style.padding = "";
1003
- canvasWrap.style.alignItems = "";
1004
- canvasWrap.style.overflow = "";
1005
-
1006
- // Stylebook mode: render element catalog with panzoom surface
1007
- if (canvasMode === "stylebook") {
1008
- renderStylebook();
1310
+ // Settings mode: render element catalog with panzoom surface
1311
+ if (canvasMode === "settings") {
1312
+ renderSettings();
1009
1313
  return;
1010
1314
  }
1011
1315
 
1012
1316
  // Source mode: create Monaco editor instead of canvas
1013
1317
  if (canvasMode === "source") {
1014
1318
  canvasWrap.style.padding = "0";
1319
+ canvasWrap.style.display = "block";
1015
1320
  /** @type {HTMLDivElement | null} */
1016
1321
  let editorContainer = null;
1017
1322
  litRender(
1018
- html`<div
1019
- class="source-editor"
1020
- ${ref((el) => {
1021
- if (el) editorContainer = /** @type {HTMLDivElement} */ (el);
1022
- })}
1023
- ></div>`,
1323
+ html`<div class="source-wrap">
1324
+ <div class="source-toolbar">
1325
+ <sp-action-button size="s" @click=${exportFile}>
1326
+ <sp-icon-export slot="icon"></sp-icon-export>
1327
+ Export
1328
+ </sp-action-button>
1329
+ </div>
1330
+ <div
1331
+ class="source-editor"
1332
+ ${ref((el) => {
1333
+ if (el) editorContainer = /** @type {HTMLDivElement} */ (el);
1334
+ })}
1335
+ ></div>
1336
+ </div>`,
1024
1337
  canvasWrap,
1025
1338
  );
1026
1339
 
1027
1340
  const jsonStr = JSON.stringify(S.document, null, 2);
1028
- monacoEditor = monaco.editor.create(/** @type {any} */ (editorContainer), {
1341
+ view.monacoEditor = monaco.editor.create(/** @type {any} */ (editorContainer), {
1029
1342
  value: jsonStr,
1030
1343
  language: "json",
1031
1344
  theme: "vs-dark",
@@ -1042,19 +1355,16 @@ function renderCanvas() {
1042
1355
  // Debounced sync back to state
1043
1356
  /** @type {any} */
1044
1357
  let debounce;
1045
- monacoEditor.onDidChangeModelContent(() => {
1046
- if (monacoEditor._ignoreNextChange) {
1047
- monacoEditor._ignoreNextChange = false;
1358
+ view.monacoEditor.onDidChangeModelContent(() => {
1359
+ if (view.monacoEditor._ignoreNextChange) {
1360
+ view.monacoEditor._ignoreNextChange = false;
1048
1361
  return;
1049
1362
  }
1050
1363
  clearTimeout(debounce);
1051
1364
  debounce = setTimeout(() => {
1052
1365
  try {
1053
- const parsed = JSON.parse(monacoEditor.getValue());
1054
- S = { ...S, document: parsed, dirty: true };
1055
- renderToolbar();
1056
- renderLeftPanel();
1057
- renderRightPanel();
1366
+ const parsed = JSON.parse(view.monacoEditor.getValue());
1367
+ update({ ...S, document: parsed, dirty: true });
1058
1368
  } catch {
1059
1369
  // Invalid JSON — don't update state
1060
1370
  }
@@ -1065,33 +1375,38 @@ function renderCanvas() {
1065
1375
 
1066
1376
  // Edit (content) mode — centered column, no panzoom, always 100%
1067
1377
  if (canvasMode === "edit") {
1068
- canvasWrap.style.padding = "0";
1069
- canvasWrap.style.overflow = "hidden";
1378
+ if (modeChanged) {
1379
+ canvasWrap.style.padding = "0";
1380
+ canvasWrap.style.overflow = "hidden";
1070
1381
 
1071
- // Remove zoom indicator left over from design/preview mode
1072
- try {
1073
- litRender(nothing, zoomIndicatorHost);
1074
- } catch {
1075
- zoomIndicatorHost.textContent = "";
1382
+ // Remove zoom indicator left over from design/preview mode
1383
+ try {
1384
+ litRender(nothing, zoomIndicatorHost);
1385
+ } catch {
1386
+ const newHost = document.createElement("div");
1387
+ newHost.style.display = "contents";
1388
+ zoomIndicatorHost.replaceWith(newHost);
1389
+ zoomIndicatorHost = newHost;
1390
+ }
1076
1391
  }
1077
1392
 
1078
1393
  const { tpl: panelTpl, panel } = canvasPanelTemplate(null, null, true);
1079
- litRender(
1080
- html`
1081
- <div class="content-edit-canvas">
1082
- <div class="content-edit-column">${panelTpl}</div>
1083
- </div>
1084
- `,
1085
- canvasWrap,
1086
- );
1394
+ const editTpl = html`
1395
+ <div class="content-edit-canvas">
1396
+ <div class="content-edit-column">${panelTpl}</div>
1397
+ </div>
1398
+ `;
1399
+ litRender(editTpl, canvasWrap);
1087
1400
  canvasPanels.push(panel);
1088
1401
  renderCanvasIntoPanel(panel, new Set(), S.ui.featureToggles);
1089
1402
  return;
1090
1403
  }
1091
1404
 
1092
1405
  // Normal canvas mode (design / preview) — set up panzoom surface
1093
- canvasWrap.style.padding = "0";
1094
- canvasWrap.style.overflow = "hidden";
1406
+ if (modeChanged) {
1407
+ canvasWrap.style.padding = "0";
1408
+ canvasWrap.style.overflow = "hidden";
1409
+ }
1095
1410
 
1096
1411
  const {
1097
1412
  sizeBreakpoints,
@@ -1119,7 +1434,7 @@ function renderCanvas() {
1119
1434
  class="panzoom-wrap"
1120
1435
  style="transform-origin:0 0"
1121
1436
  ${ref((el) => {
1122
- if (el) panzoomWrap = /** @type {HTMLDivElement} */ (el);
1437
+ if (el) view.panzoomWrap = /** @type {HTMLDivElement} */ (el);
1123
1438
  })}
1124
1439
  >
1125
1440
  ${panelTpl}
@@ -1130,12 +1445,15 @@ function renderCanvas() {
1130
1445
  canvasPanels.push(panel);
1131
1446
  renderCanvasIntoPanel(panel, new Set(), featureToggles);
1132
1447
  applyTransform();
1133
- observeCenterUntilStable();
1448
+ if (modeChanged) {
1449
+ observeCenterUntilStable();
1450
+ }
1134
1451
  renderZoomIndicator();
1135
1452
  return;
1136
1453
  }
1137
1454
 
1138
- // Build all panels (base + breakpoints), sorted widest-first (left to right)
1455
+ // Build all panels: base first, then breakpoints in declared order (ascending for min-width,
1456
+ // descending for max-width — matching the direction of the design's media queries).
1139
1457
  const allPanelDefs = [
1140
1458
  {
1141
1459
  name: "base",
@@ -1152,7 +1470,6 @@ function renderCanvas() {
1152
1470
  activeSet: activeBreakpointsForWidth(sizeBreakpoints, bp.width),
1153
1471
  });
1154
1472
  }
1155
- allPanelDefs.sort((a, b) => b.width - a.width);
1156
1473
 
1157
1474
  /** @type {{ tpl: any; panel: any; activeSet: any }[]} */
1158
1475
  const panelEntries = allPanelDefs.map((def) => {
@@ -1167,7 +1484,7 @@ function renderCanvas() {
1167
1484
  class="panzoom-wrap"
1168
1485
  style="transform-origin:0 0"
1169
1486
  ${ref((el) => {
1170
- if (el) panzoomWrap = /** @type {HTMLDivElement} */ (el);
1487
+ if (el) view.panzoomWrap = /** @type {HTMLDivElement} */ (el);
1171
1488
  })}
1172
1489
  >
1173
1490
  ${panelEntries.map((e) => e.tpl)}
@@ -1186,7 +1503,9 @@ function renderCanvas() {
1186
1503
 
1187
1504
  // Apply current zoom + pan transform
1188
1505
  applyTransform();
1189
- observeCenterUntilStable();
1506
+ if (modeChanged) {
1507
+ observeCenterUntilStable();
1508
+ }
1190
1509
 
1191
1510
  // Floating zoom indicator
1192
1511
  renderZoomIndicator();
@@ -1201,9 +1520,13 @@ function renderCanvas() {
1201
1520
  * @param {any} featureToggles
1202
1521
  */
1203
1522
  function renderCanvasIntoPanel(panel, activeBreakpoints, featureToggles) {
1204
- renderCanvasLive(S.document, panel.canvas).then((scope) => {
1523
+ const gen = view.renderGeneration;
1524
+ renderCanvasLive(gen, S.document, panel.canvas).then((scope) => {
1525
+ // Skip post-render setup if a newer render has started
1526
+ if (gen !== view.renderGeneration) return;
1205
1527
  if (scope) {
1206
- liveScope = scope;
1528
+ view.liveScope = scope;
1529
+ applyCanvasMediaOverrides(panel.canvas, activeBreakpoints);
1207
1530
  statusMessage("Runtime render OK", 1500);
1208
1531
  } else {
1209
1532
  // Fallback to structural preview
@@ -1214,9 +1537,9 @@ function renderCanvasIntoPanel(panel, activeBreakpoints, featureToggles) {
1214
1537
  renderOverlays();
1215
1538
 
1216
1539
  // Process pending inline edit now that the canvas is populated
1217
- if (pendingInlineEdit) {
1218
- const { path, mediaName: mn } = pendingInlineEdit;
1219
- pendingInlineEdit = null;
1540
+ if (view.pendingInlineEdit) {
1541
+ const { path, mediaName: mn } = view.pendingInlineEdit;
1542
+ view.pendingInlineEdit = null;
1220
1543
  const targetPanel = canvasPanels.find((p) => p.mediaName === mn) || canvasPanels[0];
1221
1544
  if (targetPanel) {
1222
1545
  const el = findCanvasElement(path, targetPanel.canvas);
@@ -1271,9 +1594,7 @@ function canvasPanelTemplate(mediaName, label, fullWidth, width) {
1271
1594
  <div
1272
1595
  class="canvas-panel-header"
1273
1596
  @click=${() => {
1274
- S = { ...S, ui: { ...S.ui, activeMedia: mediaName === "base" ? null : mediaName } };
1275
- updateActivePanelHeaders();
1276
- renderRightPanel();
1597
+ updateUi("activeMedia", mediaName === "base" ? null : mediaName);
1277
1598
  }}
1278
1599
  >
1279
1600
  ${label}
@@ -1322,52 +1643,52 @@ function canvasPanelTemplate(mediaName, label, fullWidth, width) {
1322
1643
 
1323
1644
  /** Center canvas in viewport. */
1324
1645
  function centerCanvas() {
1325
- if (!panzoomWrap) return;
1646
+ if (!view.panzoomWrap) return;
1326
1647
  const wrapWidth = canvasWrap.clientWidth;
1327
1648
  const wrapHeight = canvasWrap.clientHeight;
1328
- const contentWidth = panzoomWrap.scrollWidth;
1329
- const contentHeight = panzoomWrap.scrollHeight;
1649
+ const contentWidth = view.panzoomWrap.scrollWidth;
1650
+ const contentHeight = view.panzoomWrap.scrollHeight;
1330
1651
  const scaledWidth = contentWidth * S.ui.zoom;
1331
1652
  const scaledHeight = contentHeight * S.ui.zoom;
1332
- panX = Math.max(16, (wrapWidth - scaledWidth) / 2);
1653
+ view.panX = Math.max(16, (wrapWidth - scaledWidth) / 2);
1333
1654
  // Center vertically only when content fits; top-align with margin when taller
1334
1655
  const verticalCenter = (wrapHeight - scaledHeight) / 2;
1335
- panY = verticalCenter > 16 ? verticalCenter : 16;
1656
+ view.panY = verticalCenter > 16 ? verticalCenter : 16;
1336
1657
  }
1337
1658
 
1338
1659
  /**
1339
- * Attach a ResizeObserver to panzoomWrap that re-centers until the user pans. Handles async content
1340
- * (runtime rendering, data fetching) that changes layout after initial paint.
1660
+ * Attach a ResizeObserver to view.panzoomWrap that re-centers until the user pans. Handles async
1661
+ * content (runtime rendering, data fetching) that changes layout after initial paint.
1341
1662
  */
1342
1663
  function observeCenterUntilStable() {
1343
- if (centerObserver) {
1344
- centerObserver.disconnect();
1345
- centerObserver = null;
1346
- }
1347
- if (!panzoomWrap) return;
1348
- needsCenter = true;
1349
- centerObserver = new ResizeObserver(() => {
1350
- if (!needsCenter) {
1351
- centerObserver?.disconnect();
1352
- centerObserver = null;
1664
+ if (view.centerObserver) {
1665
+ view.centerObserver.disconnect();
1666
+ view.centerObserver = null;
1667
+ }
1668
+ if (!view.panzoomWrap) return;
1669
+ view.needsCenter = true;
1670
+ view.centerObserver = new ResizeObserver(() => {
1671
+ if (!view.needsCenter) {
1672
+ view.centerObserver?.disconnect();
1673
+ view.centerObserver = null;
1353
1674
  return;
1354
1675
  }
1355
1676
  centerCanvas();
1356
1677
  applyTransform();
1357
1678
  });
1358
- centerObserver.observe(panzoomWrap);
1679
+ view.centerObserver.observe(view.panzoomWrap);
1359
1680
  // Also center immediately for synchronous content
1360
1681
  centerCanvas();
1361
1682
  }
1362
1683
 
1363
1684
  /** Apply the current zoom + pan transform to the panzoom wrapper. */
1364
1685
  function applyTransform() {
1365
- if (!panzoomWrap) return;
1366
- panzoomWrap.style.transform = `translate(${panX}px, ${panY}px) scale(${S.ui.zoom})`;
1686
+ if (!view.panzoomWrap) return;
1687
+ view.panzoomWrap.style.transform = `translate(${view.panX}px, ${view.panY}px) scale(${S.ui.zoom})`;
1367
1688
  const label = document.querySelector(".zoom-indicator-label");
1368
1689
  if (label) label.textContent = `${Math.round(S.ui.zoom * 100)}%`;
1369
1690
  renderOverlays();
1370
- if (canvasMode === "stylebook") renderStylebookOverlays();
1691
+ if (canvasMode === "settings") renderStylebookOverlays();
1371
1692
  }
1372
1693
 
1373
1694
  /** Lightweight in-place zoom update — no full re-render. */
@@ -1377,7 +1698,7 @@ function _applyZoom() {
1377
1698
 
1378
1699
  /** Calculate zoom + pan to fit all panels within the viewport. */
1379
1700
  function fitToScreen() {
1380
- if (!panzoomWrap) return;
1701
+ if (!view.panzoomWrap) return;
1381
1702
  const wrapWidth = canvasWrap.clientWidth;
1382
1703
  const wrapHeight = canvasWrap.clientHeight;
1383
1704
  const gap = 24;
@@ -1390,7 +1711,7 @@ function fitToScreen() {
1390
1711
  totalPanelWidth += gap * Math.max(0, canvasPanels.length - 1) + padding;
1391
1712
 
1392
1713
  // Get actual content height from rendered panels
1393
- const wrapRect = panzoomWrap.getBoundingClientRect();
1714
+ const wrapRect = view.panzoomWrap.getBoundingClientRect();
1394
1715
  const unscaledHeight = wrapRect.height / S.ui.zoom;
1395
1716
  maxPanelHeight = unscaledHeight + padding;
1396
1717
 
@@ -1398,12 +1719,13 @@ function fitToScreen() {
1398
1719
  const fitZoomH = wrapHeight / maxPanelHeight;
1399
1720
  const fitZoom = Math.min(5.0, Math.max(0.05, Math.min(fitZoomW, fitZoomH)));
1400
1721
 
1401
- S = { ...S, ui: { ...S.ui, zoom: fitZoom } };
1722
+ session = { ...session, ui: { ...session.ui, zoom: fitZoom } };
1723
+ S = toFlat(doc, session);
1402
1724
  // Center the content
1403
1725
  const scaledWidth = totalPanelWidth * fitZoom;
1404
1726
  const scaledHeight = maxPanelHeight * fitZoom;
1405
- panX = Math.max(0, (wrapWidth - scaledWidth) / 2);
1406
- panY = Math.max(0, (wrapHeight - scaledHeight) / 2);
1727
+ view.panX = Math.max(0, (wrapWidth - scaledWidth) / 2);
1728
+ view.panY = Math.max(0, (wrapHeight - scaledHeight) / 2);
1407
1729
  applyTransform();
1408
1730
  }
1409
1731
 
@@ -1443,7 +1765,11 @@ function renderZoomIndicator() {
1443
1765
  zoomIndicatorHost,
1444
1766
  );
1445
1767
  } catch {
1446
- zoomIndicatorHost.textContent = "";
1768
+ // Lit markers were corrupted — replace the host element to fully reset Lit state
1769
+ const newHost = document.createElement("div");
1770
+ newHost.style.display = "contents";
1771
+ zoomIndicatorHost.replaceWith(newHost);
1772
+ zoomIndicatorHost = newHost;
1447
1773
  litRender(
1448
1774
  html`
1449
1775
  <div class="zoom-indicator">
@@ -1632,7 +1958,7 @@ function registerPanelDnD(panel) {
1632
1958
  for (const p of canvasPanels) p.overlayClk.style.pointerEvents = "";
1633
1959
  },
1634
1960
  });
1635
- canvasDndCleanups.push(monitorCleanup);
1961
+ view.canvasDndCleanups.push(monitorCleanup);
1636
1962
 
1637
1963
  for (const el of allEls) {
1638
1964
  const elPath = elToPath.get(el);
@@ -1669,7 +1995,7 @@ function registerPanelDnD(panel) {
1669
1995
  applyDropInstruction(instruction, source.data, elPath);
1670
1996
  },
1671
1997
  });
1672
- canvasDndCleanups.push(cleanup);
1998
+ view.canvasDndCleanups.push(cleanup);
1673
1999
  }
1674
2000
  }
1675
2001
 
@@ -1739,83 +2065,7 @@ function showCanvasDropIndicator(el, elPath, isVoid, panel) {
1739
2065
  // ─── Overlay system ───────────────────────────────────────────────────────────
1740
2066
 
1741
2067
  function renderOverlays() {
1742
- // In non-interactive modes (except stylebook), hide overlays and click interceptors
1743
- if (canvasMode !== "design" && canvasMode !== "edit" && canvasMode !== "stylebook") {
1744
- for (const p of canvasPanels) {
1745
- litRender(nothing, p.overlay);
1746
- p.overlayClk.style.pointerEvents = "none";
1747
- }
1748
- if (selDragCleanup) {
1749
- selDragCleanup();
1750
- selDragCleanup = null;
1751
- }
1752
- return;
1753
- }
1754
- // Stylebook manages its own overlays
1755
- if (canvasMode === "stylebook") {
1756
- const enable = S.ui.stylebookTab === "elements";
1757
- for (const p of canvasPanels) {
1758
- p.overlayClk.style.pointerEvents = enable ? "" : "none";
1759
- }
1760
- return;
1761
- }
1762
- for (const p of canvasPanels) {
1763
- p.overlayClk.style.pointerEvents = componentInlineEdit || isEditing() ? "none" : "";
1764
- }
1765
-
1766
- if (selDragCleanup) {
1767
- selDragCleanup();
1768
- selDragCleanup = null;
1769
- }
1770
-
1771
- // Collect overlay boxes per panel, then render in batch
1772
- for (const p of canvasPanels) {
1773
- /**
1774
- * @type {{
1775
- * cls: string;
1776
- * top: string;
1777
- * left: string;
1778
- * width: string;
1779
- * height: string;
1780
- * border?: string;
1781
- * }[]}
1782
- */
1783
- const boxes = [];
1784
-
1785
- // Hover overlay
1786
- if (S.hover && !pathsEqual(S.hover, S.selection)) {
1787
- const el = findCanvasElement(S.hover, p.canvas);
1788
- if (el) boxes.push(overlayBoxDescriptor(el, "hover", p));
1789
- }
1790
-
1791
- // Selection overlay (only on active panel)
1792
- if (S.selection && p === getActivePanel()) {
1793
- const el = findCanvasElement(S.selection, p.canvas);
1794
- if (el) {
1795
- const desc = overlayBoxDescriptor(el, "selection", p);
1796
- if (componentInlineEdit || isEditing()) /** @type {any} */ (desc).border = "none";
1797
- boxes.push(desc);
1798
- }
1799
- }
1800
-
1801
- litRender(
1802
- html`
1803
- ${p.dropLine}
1804
- ${boxes.map(
1805
- (b) => html`
1806
- <div
1807
- class=${b.cls}
1808
- style="top:${b.top};left:${b.left};width:${b.width};height:${b.height}${b.border
1809
- ? `;border:${b.border}`
1810
- : ""}"
1811
- ></div>
1812
- `,
1813
- )}
1814
- `,
1815
- p.overlay,
1816
- );
1817
- }
1818
- renderBlockActionBar();
2068
+ overlaysPanel.render();
1819
2069
  }
1820
2070
 
1821
2071
  /**
@@ -1990,14 +2240,19 @@ function applyInlineFormat(action) {
1990
2240
  }
1991
2241
 
1992
2242
  /** Show a link URL popover anchored to a toolbar button. */
1993
- const linkPopoverHost = document.createElement("div");
1994
- linkPopoverHost.style.display = "contents";
1995
- (document.querySelector("sp-theme") || document.body).appendChild(linkPopoverHost);
2243
+ view.linkPopoverHost = document.createElement("div");
2244
+ view.linkPopoverHost.style.display = "contents";
2245
+ (document.querySelector("sp-theme") || document.body).appendChild(view.linkPopoverHost);
2246
+
2247
+ /** Dismiss the link popover if open. */
2248
+ function dismissLinkPopover() {
2249
+ if (view.linkPopoverHost) litRender(nothing, view.linkPopoverHost);
2250
+ }
1996
2251
 
1997
2252
  /** @param {any} anchorBtn */
1998
2253
  function showLinkPopover(anchorBtn) {
1999
2254
  // Dismiss existing
2000
- litRender(nothing, linkPopoverHost);
2255
+ litRender(nothing, view.linkPopoverHost);
2001
2256
 
2002
2257
  const sel = window.getSelection();
2003
2258
  /** @type {any} */
@@ -2017,14 +2272,14 @@ function showLinkPopover(anchorBtn) {
2017
2272
  const rect = anchorBtn.getBoundingClientRect();
2018
2273
 
2019
2274
  const onApply = () => {
2020
- const field = linkPopoverHost.querySelector("sp-textfield");
2275
+ const field = view.linkPopoverHost.querySelector("sp-textfield");
2021
2276
  const url = /** @type {any} */ (field)?.value;
2022
2277
  if (existingLink) {
2023
2278
  existingLink.setAttribute("href", url);
2024
2279
  } else if (url) {
2025
2280
  document.execCommand("createLink", false, url);
2026
2281
  }
2027
- litRender(nothing, linkPopoverHost);
2282
+ litRender(nothing, view.linkPopoverHost);
2028
2283
  renderBlockActionBar();
2029
2284
  };
2030
2285
 
@@ -2032,14 +2287,14 @@ function showLinkPopover(anchorBtn) {
2032
2287
  const frag = document.createDocumentFragment();
2033
2288
  while (existingLink.firstChild) frag.appendChild(existingLink.firstChild);
2034
2289
  existingLink.parentNode.replaceChild(frag, existingLink);
2035
- litRender(nothing, linkPopoverHost);
2290
+ litRender(nothing, view.linkPopoverHost);
2036
2291
  renderBlockActionBar();
2037
2292
  };
2038
2293
 
2039
2294
  const onKeydown = (/** @type {any} */ e) => {
2040
2295
  if (e.key === "Enter") onApply();
2041
2296
  else if (e.key === "Escape") {
2042
- litRender(nothing, linkPopoverHost);
2297
+ litRender(nothing, view.linkPopoverHost);
2043
2298
  }
2044
2299
  };
2045
2300
 
@@ -2065,12 +2320,14 @@ function showLinkPopover(anchorBtn) {
2065
2320
  : nothing}
2066
2321
  </sp-popover>
2067
2322
  `,
2068
- linkPopoverHost,
2323
+ view.linkPopoverHost,
2069
2324
  );
2070
2325
 
2071
2326
  requestAnimationFrame(
2072
2327
  () =>
2073
- /** @type {HTMLElement | null} */ (linkPopoverHost?.querySelector("sp-textfield"))?.focus(),
2328
+ /** @type {HTMLElement | null} */ (
2329
+ view.linkPopoverHost?.querySelector("sp-textfield")
2330
+ )?.focus(),
2074
2331
  );
2075
2332
  }
2076
2333
 
@@ -2081,7 +2338,8 @@ function moveSelectionUp() {
2081
2338
  if (idx <= 0) return;
2082
2339
  const pPath = /** @type {any} */ (parentElementPath(S.selection));
2083
2340
  update(moveNode(S, S.selection, pPath, idx - 1));
2084
- S = { ...S, selection: [...pPath, "children", idx - 1] };
2341
+ session = { ...session, selection: [...pPath, "children", idx - 1] };
2342
+ S = toFlat(doc, session);
2085
2343
  renderOverlays();
2086
2344
  }
2087
2345
 
@@ -2094,7 +2352,8 @@ function moveSelectionDown() {
2094
2352
  const siblings = parentNode?.children;
2095
2353
  if (!siblings || idx >= siblings.length - 1) return;
2096
2354
  update(moveNode(S, S.selection, pPath, idx + 2));
2097
- S = { ...S, selection: [...pPath, "children", idx + 1] };
2355
+ session = { ...session, selection: [...pPath, "children", idx + 1] };
2356
+ S = toFlat(doc, session);
2098
2357
  renderOverlays();
2099
2358
  }
2100
2359
 
@@ -2104,30 +2363,30 @@ function moveSelectionDown() {
2104
2363
  */
2105
2364
  function renderBlockActionBar() {
2106
2365
  // Ensure persistent render container exists
2107
- if (!blockActionBarEl) {
2108
- blockActionBarEl = createFloatingContainer();
2366
+ if (!view.blockActionBarEl) {
2367
+ view.blockActionBarEl = createFloatingContainer();
2109
2368
  }
2110
2369
 
2111
2370
  // Tear down drag if it was active
2112
- if (selDragCleanup) {
2113
- selDragCleanup();
2114
- selDragCleanup = null;
2371
+ if (view.selDragCleanup) {
2372
+ view.selDragCleanup();
2373
+ view.selDragCleanup = null;
2115
2374
  }
2116
2375
 
2117
2376
  if (!S.selection || (canvasMode !== "design" && canvasMode !== "edit")) {
2118
- litRender(nothing, blockActionBarEl);
2377
+ litRender(nothing, view.blockActionBarEl);
2119
2378
  return;
2120
2379
  }
2121
2380
 
2122
2381
  const activePanel = getActivePanel();
2123
2382
  if (!activePanel) {
2124
- litRender(nothing, blockActionBarEl);
2383
+ litRender(nothing, view.blockActionBarEl);
2125
2384
  return;
2126
2385
  }
2127
2386
  const el = findCanvasElement(S.selection, activePanel.canvas);
2128
2387
  const node = el && getNodeAtPath(S.document, S.selection);
2129
2388
  if (!el || !node) {
2130
- litRender(nothing, blockActionBarEl);
2389
+ litRender(nothing, view.blockActionBarEl);
2131
2390
  return;
2132
2391
  }
2133
2392
 
@@ -2158,6 +2417,32 @@ function renderBlockActionBar() {
2158
2417
  ? html`<span class="bar-drag-handle" title="Drag to reorder">⡇</span>`
2159
2418
  : nothing}
2160
2419
  ${S.selection.length >= 2 ? renderMoveArrows() : nothing}
2420
+ ${S.selection.length >= 2 && node.tagName
2421
+ ? (() => {
2422
+ const isComp =
2423
+ node.tagName.includes("-") &&
2424
+ componentRegistry.some((/** @type {any} */ c) => c.tagName === node.tagName);
2425
+ if (isComp) {
2426
+ const comp = componentRegistry.find(
2427
+ (/** @type {any} */ c) => c.tagName === node.tagName,
2428
+ );
2429
+ return html`<sp-action-button
2430
+ size="xs"
2431
+ quiet
2432
+ title="Edit Component"
2433
+ @click=${() => navigateToComponent(comp.path)}
2434
+ ><sp-icon-edit slot="icon" size="xs"></sp-icon-edit
2435
+ ></sp-action-button>`;
2436
+ }
2437
+ return html`<sp-action-button
2438
+ size="xs"
2439
+ quiet
2440
+ title="Convert to Component"
2441
+ @click=${() => convertToComponent(S)}
2442
+ ><sp-icon-box slot="icon" size="xs"></sp-icon-box
2443
+ ></sp-action-button>`;
2444
+ })()
2445
+ : nothing}
2161
2446
  ${showFormat
2162
2447
  ? html`
2163
2448
  <sp-divider size="s" vertical></sp-divider>
@@ -2186,12 +2471,12 @@ function renderBlockActionBar() {
2186
2471
  : nothing}
2187
2472
  </div>
2188
2473
  `,
2189
- blockActionBarEl,
2474
+ view.blockActionBarEl,
2190
2475
  );
2191
2476
 
2192
2477
  // Post-render side effects
2193
2478
  requestAnimationFrame(() => {
2194
- const bar = blockActionBarEl?.firstElementChild;
2479
+ const bar = view.blockActionBarEl?.firstElementChild;
2195
2480
  if (!bar) return;
2196
2481
  // Clamp to window
2197
2482
  const barRect = bar.getBoundingClientRect();
@@ -2202,7 +2487,11 @@ function renderBlockActionBar() {
2202
2487
  if (S.selection.length >= 2) {
2203
2488
  const handle = bar.querySelector(".bar-drag-handle");
2204
2489
  if (handle) {
2205
- selDragCleanup = draggable({
2490
+ if (view.selDragCleanup) {
2491
+ view.selDragCleanup();
2492
+ view.selDragCleanup = null;
2493
+ }
2494
+ view.selDragCleanup = draggable({
2206
2495
  element: handle,
2207
2496
  getInitialData: () => ({ type: "tree-node", path: S.selection }),
2208
2497
  });
@@ -2215,20 +2504,15 @@ function renderBlockActionBar() {
2215
2504
  // When a pseudo-selector (:hover, :focus, etc.) is active in the style sidebar,
2216
2505
  // force those styles onto the selected element so the user can see the result.
2217
2506
 
2218
- /** @type {any} */
2219
- let _forcedStyleTag = null;
2220
- /** @type {any} */
2221
- let _forcedAttrEl = null;
2222
-
2223
2507
  function updateForcedPseudoPreview() {
2224
2508
  // Clean up previous
2225
- if (_forcedStyleTag) {
2226
- _forcedStyleTag.remove();
2227
- _forcedStyleTag = null;
2509
+ if (view.forcedStyleTag) {
2510
+ view.forcedStyleTag.remove();
2511
+ view.forcedStyleTag = null;
2228
2512
  }
2229
- if (_forcedAttrEl) {
2230
- _forcedAttrEl.removeAttribute("data-studio-forced");
2231
- _forcedAttrEl = null;
2513
+ if (view.forcedAttrEl) {
2514
+ view.forcedAttrEl.removeAttribute("data-studio-forced");
2515
+ view.forcedAttrEl = null;
2232
2516
  }
2233
2517
 
2234
2518
  const sel = S.ui?.activeSelector;
@@ -2259,12 +2543,12 @@ function updateForcedPseudoPreview() {
2259
2543
  if (!cssProps) return;
2260
2544
 
2261
2545
  el.setAttribute("data-studio-forced", "1");
2262
- _forcedAttrEl = el;
2546
+ view.forcedAttrEl = el;
2263
2547
 
2264
2548
  const tag = document.createElement("style");
2265
2549
  tag.textContent = `[data-studio-forced] { ${cssProps} }`;
2266
2550
  document.head.appendChild(tag);
2267
- _forcedStyleTag = tag;
2551
+ view.forcedStyleTag = tag;
2268
2552
  }
2269
2553
 
2270
2554
  /**
@@ -2316,9 +2600,24 @@ function findCanvasElement(path, canvasEl) {
2316
2600
  } else {
2317
2601
  el = el.children[idx];
2318
2602
  }
2319
- if (!el) return null;
2603
+ if (!el) break;
2320
2604
  }
2321
- return el;
2605
+
2606
+ // Verify the result: if DOM traversal landed on the wrong element
2607
+ // (e.g. a custom element template child instead of the intended node),
2608
+ // fall back to scanning elToPath.
2609
+ if (el) {
2610
+ const elPath = elToPath.get(el);
2611
+ if (elPath && pathsEqual(elPath, path)) return el;
2612
+ // el has no path or wrong path — it's a template element, not the target
2613
+ }
2614
+
2615
+ // Fall back: scan all descendants for an element with matching elToPath
2616
+ for (const candidate of canvasEl.querySelectorAll("*")) {
2617
+ const p = elToPath.get(candidate);
2618
+ if (p && pathsEqual(p, path)) return candidate;
2619
+ }
2620
+ return null;
2322
2621
  }
2323
2622
 
2324
2623
  // ─── Per-panel click-to-select ────────────────────────────────────────────────
@@ -2326,6 +2625,9 @@ function findCanvasElement(path, canvasEl) {
2326
2625
  /** @param {any} panel */
2327
2626
  function registerPanelEvents(panel) {
2328
2627
  const { canvas, overlayClk, mediaName } = panel;
2628
+ const ac = new AbortController();
2629
+ const opts = { signal: ac.signal };
2630
+ view.canvasEventCleanups.push(() => ac.abort());
2329
2631
 
2330
2632
  /** @param {any} fn */
2331
2633
  function withPanelPointerEvents(fn) {
@@ -2341,162 +2643,191 @@ function registerPanelEvents(panel) {
2341
2643
  // During component inline edit, the overlayClk is disabled (see enterComponentInlineEdit).
2342
2644
  // No mousedown passthrough needed — native events reach the contenteditable directly.
2343
2645
 
2344
- overlayClk.addEventListener("click", (/** @type {any} */ e) => {
2345
- // Don't intercept clicks meant for the block action bar
2346
- const barInner = blockActionBarEl?.firstElementChild;
2347
- if (barInner) {
2348
- const r = barInner.getBoundingClientRect();
2349
- if (
2350
- e.clientX >= r.left &&
2351
- e.clientX <= r.right &&
2352
- e.clientY >= r.top &&
2353
- e.clientY <= r.bottom
2354
- )
2355
- return;
2356
- }
2357
- // If content-mode inline editing is active, treat click outside as blur
2358
- if (isEditing()) {
2359
- stopEditing();
2360
- }
2361
-
2362
- // Component-mode inline editing is handled by its own document-level listener
2363
- // (see enterComponentInlineEdit), so nothing to do here — just fall through.
2646
+ overlayClk.addEventListener(
2647
+ "click",
2648
+ (/** @type {any} */ e) => {
2649
+ // Don't intercept clicks meant for the block action bar
2650
+ const barInner = view.blockActionBarEl?.firstElementChild;
2651
+ if (barInner) {
2652
+ const r = barInner.getBoundingClientRect();
2653
+ if (
2654
+ e.clientX >= r.left &&
2655
+ e.clientX <= r.right &&
2656
+ e.clientY >= r.top &&
2657
+ e.clientY <= r.bottom
2658
+ )
2659
+ return;
2660
+ }
2661
+ // If content-mode inline editing is active, treat click outside as blur
2662
+ if (isEditing()) {
2663
+ stopEditing();
2664
+ }
2364
2665
 
2365
- const elements = withPanelPointerEvents(() => document.elementsFromPoint(e.clientX, e.clientY));
2666
+ // Component-mode inline editing is handled by its own document-level listener
2667
+ // (see enterComponentInlineEdit), so nothing to do here — just fall through.
2366
2668
 
2367
- for (const el of elements) {
2368
- if (canvas.contains(el) && el !== canvas) {
2369
- let path = elToPath.get(el);
2370
- if (path) {
2371
- path = bubbleInlinePath(S.document, path);
2372
- const newMedia = mediaName === "base" ? null : (mediaName ?? null);
2373
- S = { ...S, ui: { ...S.ui, activeMedia: newMedia } };
2669
+ const elements = withPanelPointerEvents(() =>
2670
+ document.elementsFromPoint(e.clientX, e.clientY),
2671
+ );
2374
2672
 
2375
- // Find the DOM element for the bubbled path (may differ from hit element)
2376
- const resolvedEl = findCanvasElement(path, canvas) || el;
2673
+ for (const el of elements) {
2674
+ if (canvas.contains(el) && el !== canvas) {
2675
+ const originalPath = elToPath.get(el);
2676
+ if (originalPath) {
2677
+ let path = bubbleInlinePath(S.document, originalPath);
2678
+ const newMedia = mediaName === "base" ? null : (mediaName ?? null);
2679
+ const withMedia = { ...S, ui: { ...S.ui, activeMedia: newMedia } };
2680
+
2681
+ // Find the DOM element for the bubbled path (may differ from hit element)
2682
+ // When path didn't change (no inline bubbling), prefer the hit element directly
2683
+ // since findCanvasElement can't navigate into custom element template DOM.
2684
+ const resolvedEl = path === originalPath ? el : findCanvasElement(path, canvas) || el;
2685
+
2686
+ // Re-click on selected editable block: enter inline editing
2687
+ // Edit mode / content mode → rich text editing (enterInlineEdit)
2688
+ // Design mode → plaintext component editing (enterComponentInlineEdit via view.pendingInlineEdit)
2689
+ if (
2690
+ pathsEqual(path, S.selection) &&
2691
+ isEditableBlock(resolvedEl) &&
2692
+ (canvasMode === "edit" || S.mode === "content")
2693
+ ) {
2694
+ S = withMedia;
2695
+ enterInlineEdit(resolvedEl, path);
2696
+ return;
2697
+ }
2377
2698
 
2378
- // Re-click on selected editable block: enter inline editing
2379
- // Edit mode / content mode rich text editing (enterInlineEdit)
2380
- // Design mode plaintext component editing (enterComponentInlineEdit via pendingInlineEdit)
2381
- if (
2382
- pathsEqual(path, S.selection) &&
2383
- isEditableBlock(resolvedEl) &&
2384
- (canvasMode === "edit" || S.mode === "content")
2385
- ) {
2386
- enterInlineEdit(resolvedEl, path);
2387
- return;
2388
- }
2699
+ // Design mode or first click: select and schedule component inline editing
2700
+ if (canvasMode === "design" && S.mode !== "content") {
2701
+ view.pendingInlineEdit = { path, mediaName };
2702
+ update(selectNode(withMedia, path));
2703
+ return;
2704
+ }
2389
2705
 
2390
- // Design mode or first click: select and schedule component inline editing
2391
- if (canvasMode === "design" && S.mode !== "content") {
2392
- pendingInlineEdit = { path, mediaName };
2393
- update(selectNode(S, path));
2706
+ update(selectNode(withMedia, path));
2394
2707
  return;
2395
2708
  }
2396
-
2397
- update(selectNode(S, path));
2398
- return;
2399
2709
  }
2400
2710
  }
2401
- }
2402
- update(selectNode(S, null));
2403
- });
2711
+ update(selectNode(S, null));
2712
+ },
2713
+ opts,
2714
+ );
2404
2715
 
2405
2716
  // Double-click shortcut for immediate inline editing
2406
- overlayClk.addEventListener("dblclick", (/** @type {any} */ e) => {
2407
- const barInner = blockActionBarEl?.firstElementChild;
2408
- if (barInner) {
2409
- const r = barInner.getBoundingClientRect();
2410
- if (
2411
- e.clientX >= r.left &&
2412
- e.clientX <= r.right &&
2413
- e.clientY >= r.top &&
2414
- e.clientY <= r.bottom
2415
- )
2416
- return;
2417
- }
2418
- if (canvasMode !== "edit" && canvasMode !== "design") return;
2717
+ overlayClk.addEventListener(
2718
+ "dblclick",
2719
+ (/** @type {any} */ e) => {
2720
+ const barInner = view.blockActionBarEl?.firstElementChild;
2721
+ if (barInner) {
2722
+ const r = barInner.getBoundingClientRect();
2723
+ if (
2724
+ e.clientX >= r.left &&
2725
+ e.clientX <= r.right &&
2726
+ e.clientY >= r.top &&
2727
+ e.clientY <= r.bottom
2728
+ )
2729
+ return;
2730
+ }
2731
+ if (canvasMode !== "edit" && canvasMode !== "design") return;
2419
2732
 
2420
- const elements = withPanelPointerEvents(() => document.elementsFromPoint(e.clientX, e.clientY));
2733
+ const elements = withPanelPointerEvents(() =>
2734
+ document.elementsFromPoint(e.clientX, e.clientY),
2735
+ );
2421
2736
 
2422
- for (const el of elements) {
2423
- if (canvas.contains(el) && el !== canvas) {
2424
- let path = elToPath.get(el);
2425
- if (path) {
2426
- path = bubbleInlinePath(S.document, path);
2427
- const resolvedEl = findCanvasElement(path, canvas) || el;
2428
- if (isEditableBlock(resolvedEl)) {
2429
- const newMedia = mediaName === "base" ? null : (mediaName ?? null);
2430
- S = { ...S, ui: { ...S.ui, activeMedia: newMedia } };
2431
- update(selectNode(S, path));
2432
- enterInlineEdit(resolvedEl, path);
2737
+ for (const el of elements) {
2738
+ if (canvas.contains(el) && el !== canvas) {
2739
+ const originalPath = elToPath.get(el);
2740
+ if (originalPath) {
2741
+ const path = bubbleInlinePath(S.document, originalPath);
2742
+ const resolvedEl = path === originalPath ? el : findCanvasElement(path, canvas) || el;
2743
+ if (isEditableBlock(resolvedEl)) {
2744
+ const newMedia = mediaName === "base" ? null : (mediaName ?? null);
2745
+ const withMedia = { ...S, ui: { ...S.ui, activeMedia: newMedia } };
2746
+ update(selectNode(withMedia, path));
2747
+ enterInlineEdit(resolvedEl, path);
2748
+ return;
2749
+ }
2750
+ }
2751
+ }
2752
+ }
2753
+ },
2754
+ opts,
2755
+ );
2756
+
2757
+ overlayClk.addEventListener(
2758
+ "contextmenu",
2759
+ (/** @type {any} */ e) => {
2760
+ const barInner = view.blockActionBarEl?.firstElementChild;
2761
+ if (barInner) {
2762
+ const r = barInner.getBoundingClientRect();
2763
+ if (
2764
+ e.clientX >= r.left &&
2765
+ e.clientX <= r.right &&
2766
+ e.clientY >= r.top &&
2767
+ e.clientY <= r.bottom
2768
+ )
2769
+ return;
2770
+ }
2771
+ const elements = withPanelPointerEvents(() =>
2772
+ document.elementsFromPoint(e.clientX, e.clientY),
2773
+ );
2774
+ for (const el of elements) {
2775
+ if (canvas.contains(el) && el !== canvas) {
2776
+ let path = elToPath.get(el);
2777
+ if (path) {
2778
+ path = bubbleInlinePath(S.document, path);
2779
+ showContextMenu(e, path, S, { onEditComponent: navigateToComponent });
2433
2780
  return;
2434
2781
  }
2435
2782
  }
2436
2783
  }
2437
- }
2438
- });
2784
+ e.preventDefault();
2785
+ },
2786
+ opts,
2787
+ );
2439
2788
 
2440
- overlayClk.addEventListener("contextmenu", (/** @type {any} */ e) => {
2441
- const barInner = blockActionBarEl?.firstElementChild;
2442
- if (barInner) {
2443
- const r = barInner.getBoundingClientRect();
2444
- if (
2445
- e.clientX >= r.left &&
2446
- e.clientX <= r.right &&
2447
- e.clientY >= r.top &&
2448
- e.clientY <= r.bottom
2449
- )
2450
- return;
2451
- }
2452
- const elements = withPanelPointerEvents(() => document.elementsFromPoint(e.clientX, e.clientY));
2453
- for (const el of elements) {
2454
- if (canvas.contains(el) && el !== canvas) {
2789
+ overlayClk.addEventListener(
2790
+ "mousemove",
2791
+ (/** @type {any} */ e) => {
2792
+ const barInner = view.blockActionBarEl?.firstElementChild;
2793
+ if (barInner) {
2794
+ const r = barInner.getBoundingClientRect();
2795
+ if (
2796
+ e.clientX >= r.left &&
2797
+ e.clientX <= r.right &&
2798
+ e.clientY >= r.top &&
2799
+ e.clientY <= r.bottom
2800
+ )
2801
+ return;
2802
+ }
2803
+ const el = withPanelPointerEvents(() => document.elementFromPoint(e.clientX, e.clientY));
2804
+ if (el && canvas.contains(el) && el !== canvas) {
2455
2805
  let path = elToPath.get(el);
2456
2806
  if (path) {
2457
2807
  path = bubbleInlinePath(S.document, path);
2458
- showContextMenu(e, path, S);
2459
- return;
2808
+ if (!pathsEqual(path, S.hover)) {
2809
+ S = hoverNode(S, path);
2810
+ renderOverlays();
2811
+ }
2460
2812
  }
2813
+ } else if (S.hover) {
2814
+ S = hoverNode(S, null);
2815
+ renderOverlays();
2461
2816
  }
2462
- }
2463
- e.preventDefault();
2464
- });
2817
+ },
2818
+ opts,
2819
+ );
2465
2820
 
2466
- overlayClk.addEventListener("mousemove", (/** @type {any} */ e) => {
2467
- const barInner = blockActionBarEl?.firstElementChild;
2468
- if (barInner) {
2469
- const r = barInner.getBoundingClientRect();
2470
- if (
2471
- e.clientX >= r.left &&
2472
- e.clientX <= r.right &&
2473
- e.clientY >= r.top &&
2474
- e.clientY <= r.bottom
2475
- )
2476
- return;
2477
- }
2478
- const el = withPanelPointerEvents(() => document.elementFromPoint(e.clientX, e.clientY));
2479
- if (el && canvas.contains(el) && el !== canvas) {
2480
- let path = elToPath.get(el);
2481
- if (path) {
2482
- path = bubbleInlinePath(S.document, path);
2483
- if (!pathsEqual(path, S.hover)) {
2484
- S = hoverNode(S, path);
2485
- renderOverlays();
2486
- }
2821
+ overlayClk.addEventListener(
2822
+ "mouseleave",
2823
+ () => {
2824
+ if (S.hover) {
2825
+ S = hoverNode(S, null);
2826
+ renderOverlays();
2487
2827
  }
2488
- } else if (S.hover) {
2489
- S = hoverNode(S, null);
2490
- renderOverlays();
2491
- }
2492
- });
2493
-
2494
- overlayClk.addEventListener("mouseleave", () => {
2495
- if (S.hover) {
2496
- S = hoverNode(S, null);
2497
- renderOverlays();
2498
- }
2499
- });
2828
+ },
2829
+ opts,
2830
+ );
2500
2831
  }
2501
2832
 
2502
2833
  // ─── Inline editing bridge ────────────────────────────────────────────────────
@@ -2589,12 +2920,63 @@ function enterInlineEdit(el, path) {
2589
2920
  });
2590
2921
  },
2591
2922
 
2592
- onInsert(/** @type {any} */ afterPath, /** @type {any} */ cmd) {
2923
+ onInsert(/** @type {any} */ afterPath, /** @type {any} */ cmd, /** @type {any} */ commitData) {
2593
2924
  // cmd comes from the shared slash menu: { label, tag, description }
2925
+ const isEmpty =
2926
+ !commitData ||
2927
+ (commitData.textContent != null && commitData.textContent.trim() === "") ||
2928
+ (commitData.children &&
2929
+ (commitData.children.length === 0 ||
2930
+ (commitData.children.length === 1 &&
2931
+ typeof commitData.children[0] === "string" &&
2932
+ commitData.children[0].trim() === "") ||
2933
+ (commitData.children.length === 1 &&
2934
+ typeof commitData.children[0] === "object" &&
2935
+ commitData.children[0]?.tagName === "br")));
2936
+
2937
+ // If the element is empty, swap its tagName instead of inserting after
2938
+ if (isEmpty) {
2939
+ let s = S;
2940
+ s = updateProperty(s, afterPath, "tagName", cmd.tag);
2941
+ s = updateProperty(s, afterPath, "children", undefined);
2942
+ const def = defaultDef(cmd.tag);
2943
+ if (def.textContent && def.textContent !== "Paragraph text") {
2944
+ s = updateProperty(s, afterPath, "textContent", def.textContent);
2945
+ } else {
2946
+ s = updateProperty(s, afterPath, "textContent", undefined);
2947
+ }
2948
+ s = selectNode(s, afterPath);
2949
+ update(s);
2950
+
2951
+ requestAnimationFrame(() => {
2952
+ const activePanel = getActivePanel();
2953
+ if (activePanel) {
2954
+ const el = findCanvasElement(afterPath, activePanel.canvas);
2955
+ if (el && isEditableBlock(el)) {
2956
+ enterInlineEdit(el, afterPath);
2957
+ }
2958
+ }
2959
+ });
2960
+ return;
2961
+ }
2962
+
2594
2963
  const elementDef = defaultDef(cmd.tag);
2595
2964
  const parentPath = /** @type {any} */ (parentElementPath(afterPath));
2596
2965
  const idx = /** @type {number} */ (childIndex(afterPath));
2597
- let s = insertNode(S, parentPath, idx + 1, structuredClone(elementDef));
2966
+
2967
+ // Apply pending commit from inline edit first (batched to avoid double render)
2968
+ let s = S;
2969
+ if (commitData) {
2970
+ if (commitData.children) {
2971
+ s = updateProperty(s, afterPath, "textContent", undefined);
2972
+ s = updateProperty(s, afterPath, "children", commitData.children);
2973
+ } else if (commitData.textContent != null) {
2974
+ s = updateProperty(s, afterPath, "children", undefined);
2975
+ s = updateProperty(s, afterPath, "textContent", commitData.textContent);
2976
+ }
2977
+ }
2978
+
2979
+ s = insertNode(s, parentPath, idx + 1, structuredClone(elementDef));
2598
2980
  const newPath = [...parentPath, "children", idx + 1];
2599
2981
  s = selectNode(s, newPath);
2600
2982
  update(s);
@@ -2613,9 +2995,9 @@ function enterInlineEdit(el, path) {
2613
2995
 
2614
2996
  onEnd() {
2615
2997
  // Cleanup inline edit listeners
2616
- if (_inlineEditCleanup) {
2617
- _inlineEditCleanup();
2618
- _inlineEditCleanup = null;
2998
+ if (view.inlineEditCleanup) {
2999
+ view.inlineEditCleanup();
3000
+ view.inlineEditCleanup = null;
2619
3001
  }
2620
3002
  // Restore overlays after inline editing ends
2621
3003
  for (const p of canvasPanels) {
@@ -2642,7 +3024,7 @@ function enterInlineEdit(el, path) {
2642
3024
  el.removeEventListener("mouseup", selectionHandler);
2643
3025
  el.removeEventListener("keyup", selectionHandler);
2644
3026
  };
2645
- _inlineEditCleanup = inlineEditCleanup;
3027
+ view.inlineEditCleanup = inlineEditCleanup;
2646
3028
  }
2647
3029
 
2648
3030
  // ─── Component-mode inline text editing ──────────────────────────────────────
@@ -2653,7 +3035,7 @@ function enterInlineEdit(el, path) {
2653
3035
  */
2654
3036
  function enterComponentInlineEdit(el, path) {
2655
3037
  // Already editing this element
2656
- if (componentInlineEdit && componentInlineEdit.el === el) {
3038
+ if (view.componentInlineEdit && view.componentInlineEdit.el === el) {
2657
3039
  return;
2658
3040
  }
2659
3041
 
@@ -2666,7 +3048,7 @@ function enterComponentInlineEdit(el, path) {
2666
3048
  if (Array.isArray(node.children) && node.children.length > 0) return;
2667
3049
  if (node.children && typeof node.children === "object") return;
2668
3050
  if (tc && typeof tc === "object") return;
2669
- const voids = new Set(["img", "input", "br", "hr", "video", "audio", "source", "embed"]);
3051
+ const voids = new Set(["img", "input", "br", "hr", "video", "audio", "source", "embed", "slot"]);
2670
3052
  if (voids.has(node.tagName)) return;
2671
3053
 
2672
3054
  // Keep overlay visible for the label, but hide selection border to not obscure editing outline.
@@ -2690,7 +3072,7 @@ function enterComponentInlineEdit(el, path) {
2690
3072
  const rawText = typeof tc === "string" ? tc : "";
2691
3073
  el.textContent = rawText;
2692
3074
 
2693
- componentInlineEdit = {
3075
+ view.componentInlineEdit = {
2694
3076
  el,
2695
3077
  path,
2696
3078
  originalText: rawText,
@@ -2712,15 +3094,15 @@ function enterComponentInlineEdit(el, path) {
2712
3094
  // Document-level mousedown: clicking outside the editing element commits
2713
3095
  // the edit and selects the new target element for inline editing.
2714
3096
  const outsideHandler = (/** @type {any} */ evt) => {
2715
- if (!componentInlineEdit) {
3097
+ if (!view.componentInlineEdit) {
2716
3098
  document.removeEventListener("mousedown", outsideHandler, true);
2717
3099
  return;
2718
3100
  }
2719
- if (componentInlineEdit.el.contains(evt.target)) return; // click within editing el — let it through
3101
+ if (view.componentInlineEdit.el.contains(evt.target)) return; // click within editing el — let it through
2720
3102
  // Let clicks through when the slash command menu is open
2721
3103
  if (isSlashMenuOpen()) return;
2722
3104
  // Let clicks inside the block action bar through
2723
- if (blockActionBarEl && blockActionBarEl.contains(evt.target)) return;
3105
+ if (view.blockActionBarEl && view.blockActionBarEl.contains(evt.target)) return;
2724
3106
  document.removeEventListener("mousedown", outsideHandler, true);
2725
3107
 
2726
3108
  // Hit-test BEFORE commit (while the current canvas DOM + elToPath are still valid)
@@ -2747,7 +3129,7 @@ function enterComponentInlineEdit(el, path) {
2747
3129
  }
2748
3130
 
2749
3131
  // Commit + select new element in a single state update if possible
2750
- const { el: editEl, path: editPath, originalText } = componentInlineEdit;
3132
+ const { el: editEl, path: editPath, originalText } = view.componentInlineEdit;
2751
3133
  const newText = (editEl.textContent ?? "").trim();
2752
3134
  cleanupComponentInlineEdit(editEl);
2753
3135
 
@@ -2757,26 +3139,29 @@ function enterComponentInlineEdit(el, path) {
2757
3139
 
2758
3140
  if (hitPath) {
2759
3141
  const media = hitMedia === "base" ? null : (hitMedia ?? null);
2760
- pendingInlineEdit = { path: hitPath, mediaName: hitMedia };
2761
- S = { ...S, ui: { ...S.ui, activeMedia: media } };
3142
+ view.pendingInlineEdit = { path: hitPath, mediaName: hitMedia };
3143
+ const withMedia = { ...S, ui: { ...S.ui, activeMedia: media } };
2762
3144
  if (isEmpty && pPath) {
2763
3145
  // Remove empty node; adjust hitPath if it shifts after removal
2764
- let s = removeNode(S, editPath);
3146
+ let s = removeNode(withMedia, editPath);
2765
3147
  // If hit path is a later sibling in the same parent, adjust index
2766
3148
  const removedIdx = /** @type {number} */ (childIndex(editPath));
2767
3149
  const hitIdx = /** @type {number} */ (childIndex(hitPath));
2768
3150
  const hitParent = parentElementPath(hitPath);
2769
3151
  if (hitParent && pPath && hitParent.join("/") === pPath.join("/") && hitIdx > removedIdx) {
2770
3152
  hitPath = [...pPath, "children", hitIdx - 1];
2771
- pendingInlineEdit = { path: hitPath, mediaName: hitMedia };
3153
+ view.pendingInlineEdit = { path: hitPath, mediaName: hitMedia };
2772
3154
  }
2773
3155
  update(selectNode(s, hitPath));
2774
3156
  } else if (newText !== originalText) {
2775
3157
  update(
2776
- selectNode(updateProperty(S, editPath, "textContent", newText || undefined), hitPath),
3158
+ selectNode(
3159
+ updateProperty(withMedia, editPath, "textContent", newText || undefined),
3160
+ hitPath,
3161
+ ),
2777
3162
  );
2778
3163
  } else {
2779
- update(selectNode(S, hitPath));
3164
+ update(selectNode(withMedia, hitPath));
2780
3165
  }
2781
3166
  } else {
2782
3167
  // Clicked on empty space — just commit
@@ -2791,7 +3176,7 @@ function enterComponentInlineEdit(el, path) {
2791
3176
  }
2792
3177
  };
2793
3178
  document.addEventListener("mousedown", outsideHandler, true);
2794
- componentInlineEdit._outsideHandler = outsideHandler;
3179
+ view.componentInlineEdit._outsideHandler = outsideHandler;
2795
3180
 
2796
3181
  // Re-render block action bar to show inline formatting buttons
2797
3182
  renderBlockActionBar();
@@ -2815,8 +3200,8 @@ function componentInlineKeydown(e) {
2815
3200
  }
2816
3201
 
2817
3202
  function splitParagraph() {
2818
- if (!componentInlineEdit) return;
2819
- const { el, path, mediaName } = componentInlineEdit;
3203
+ if (!view.componentInlineEdit) return;
3204
+ const { el, path, mediaName } = view.componentInlineEdit;
2820
3205
 
2821
3206
  // Determine cursor offset within text
2822
3207
  const sel = /** @type {any} */ (el.ownerDocument.defaultView?.getSelection());
@@ -2848,13 +3233,13 @@ function splitParagraph() {
2848
3233
  s = insertNode(s, pPath, idx + 1, newDef);
2849
3234
  s = selectNode(s, newPath);
2850
3235
 
2851
- pendingInlineEdit = { path: newPath, mediaName };
3236
+ view.pendingInlineEdit = { path: newPath, mediaName };
2852
3237
  update(s);
2853
3238
  }
2854
3239
 
2855
3240
  function _commitComponentInlineEdit() {
2856
- if (!componentInlineEdit) return;
2857
- const { el, path, originalText } = componentInlineEdit;
3241
+ if (!view.componentInlineEdit) return;
3242
+ const { el, path, originalText } = view.componentInlineEdit;
2858
3243
  const newText = (el.textContent ?? "").trim();
2859
3244
 
2860
3245
  cleanupComponentInlineEdit(el);
@@ -2872,8 +3257,8 @@ function _commitComponentInlineEdit() {
2872
3257
  }
2873
3258
 
2874
3259
  function cancelComponentInlineEdit() {
2875
- if (!componentInlineEdit) return;
2876
- const { el } = componentInlineEdit;
3260
+ if (!view.componentInlineEdit) return;
3261
+ const { el } = view.componentInlineEdit;
2877
3262
  cleanupComponentInlineEdit(el);
2878
3263
  renderCanvas();
2879
3264
  renderOverlays();
@@ -2892,10 +3277,10 @@ function cleanupComponentInlineEdit(el) {
2892
3277
  el.style.pointerEvents = "";
2893
3278
 
2894
3279
  // Remove the document-level outside-click handler
2895
- if (componentInlineEdit?._outsideHandler) {
2896
- document.removeEventListener("mousedown", componentInlineEdit._outsideHandler, true);
3280
+ if (view.componentInlineEdit?._outsideHandler) {
3281
+ document.removeEventListener("mousedown", view.componentInlineEdit._outsideHandler, true);
2897
3282
  }
2898
- componentInlineEdit = null;
3283
+ view.componentInlineEdit = null;
2899
3284
 
2900
3285
  // Restore overlay and click interceptor
2901
3286
  for (const p of canvasPanels) {
@@ -2907,8 +3292,8 @@ function cleanupComponentInlineEdit(el) {
2907
3292
  // ─── Component-mode slash commands (delegates to shared slash-menu.js) ────────
2908
3293
 
2909
3294
  function componentInlineInput() {
2910
- if (!componentInlineEdit) return;
2911
- const { el, originalText } = componentInlineEdit;
3295
+ if (!view.componentInlineEdit) return;
3296
+ const { el, originalText } = view.componentInlineEdit;
2912
3297
  const text = el.textContent || "";
2913
3298
 
2914
3299
  // Only trigger slash menu when the paragraph was originally empty and starts with /
@@ -2922,8 +3307,8 @@ function componentInlineInput() {
2922
3307
 
2923
3308
  /** @param {any} cmd */
2924
3309
  function handleComponentSlashSelect(cmd) {
2925
- if (!componentInlineEdit) return;
2926
- const { el, path, mediaName } = componentInlineEdit;
3310
+ if (!view.componentInlineEdit) return;
3311
+ const { el, path, mediaName } = view.componentInlineEdit;
2927
3312
  const pPath = parentElementPath(path);
2928
3313
  const idx = /** @type {number} */ (childIndex(path));
2929
3314
  if (!pPath) return;
@@ -2940,7 +3325,7 @@ function handleComponentSlashSelect(cmd) {
2940
3325
 
2941
3326
  // If the new element has textContent, enter inline edit on it
2942
3327
  const hasText = newDef.textContent != null;
2943
- if (hasText) pendingInlineEdit = { path: newPath, mediaName };
3328
+ if (hasText) view.pendingInlineEdit = { path: newPath, mediaName };
2944
3329
  update(s);
2945
3330
  }
2946
3331
 
@@ -2952,7 +3337,7 @@ function renderLeftPanel() {
2952
3337
  /** @type {any} */
2953
3338
  let content;
2954
3339
  if (tab === "layers")
2955
- content = canvasMode === "stylebook" ? renderStylebookLayersTemplate() : renderLayersTemplate();
3340
+ content = canvasMode === "settings" ? renderStylebookLayersTemplate() : renderLayersTemplate();
2956
3341
  else if (tab === "imports")
2957
3342
  content = renderImportsTemplate({
2958
3343
  renderLeftPanel,
@@ -2965,20 +3350,45 @@ function renderLeftPanel() {
2965
3350
  });
2966
3351
  else if (tab === "files") content = renderFilesTemplate();
2967
3352
  else if (tab === "blocks") content = renderElementsTemplate();
2968
- else if (tab === "state") content = renderSignalsTemplate(S, { renderLeftPanel, renderCanvas });
3353
+ else if (tab === "state")
3354
+ content = renderSignalsTemplate(S, { renderLeftPanel, renderCanvas, updateSession });
2969
3355
  else if (tab === "data")
2970
- content = renderDataExplorerTemplate(S.document.state, liveScope, {
3356
+ content = renderDataExplorerTemplate(S.document.state, view.liveScope, {
2971
3357
  renderCanvas,
2972
3358
  renderLeftPanel,
2973
3359
  defCategory,
2974
3360
  defBadgeLabel,
2975
3361
  });
2976
- else content = nothing;
3362
+ else if (tab === "head") {
3363
+ // In content mode, title/$head live in S.content.frontmatter, not S.document
3364
+ const isContent = S.mode === "content";
3365
+ const fm = S.content?.frontmatter ?? {};
3366
+ const headDoc = isContent ? { ...S.document, title: fm.title, $head: fm.$head } : S.document;
3367
+ content = renderHeadTemplate({
3368
+ document: headDoc,
3369
+ applyMutation: isContent
3370
+ ? (/** @type {any} */ fn) => {
3371
+ // Apply mutation to a temporary doc, then sync title/$head back to frontmatter
3372
+ const tmp = { title: fm.title, $head: fm.$head ? [...fm.$head] : undefined };
3373
+ fn(tmp);
3374
+ if (tmp.title !== fm.title) S = updateFrontmatter(S, "title", tmp.title);
3375
+ // Always sync $head (may have been created, modified, or emptied)
3376
+ const newHead = tmp.$head && tmp.$head.length > 0 ? tmp.$head : undefined;
3377
+ S = updateFrontmatter(S, "$head", newHead);
3378
+ update(S);
3379
+ }
3380
+ : (/** @type {any} */ fn) => {
3381
+ S = applyMutation(S, fn);
3382
+ update(S);
3383
+ },
3384
+ renderLeftPanel,
3385
+ });
3386
+ } else content = nothing;
2977
3387
 
2978
3388
  litRender(html`<div class="panel-body">${content}</div>`, /** @type {any} */ (leftPanel));
2979
3389
 
2980
3390
  // Post-render side effects
2981
- if (tab === "layers" && canvasMode !== "stylebook") registerLayersDnD();
3391
+ if (tab === "layers" && canvasMode !== "settings") registerLayersDnD();
2982
3392
  else if (tab === "imports") {
2983
3393
  /* no post-render DnD needed */
2984
3394
  } else if (tab === "blocks") {
@@ -2993,8 +3403,8 @@ function renderLeftPanel() {
2993
3403
  /** Returns a TemplateResult — called from renderLeftPanel only when tab=layers & not stylebook */
2994
3404
  function renderLayersTemplate() {
2995
3405
  // Clean up previous DnD registrations
2996
- for (const fn of dndCleanups) fn();
2997
- dndCleanups = [];
3406
+ for (const fn of view.dndCleanups) fn();
3407
+ view.dndCleanups = [];
2998
3408
 
2999
3409
  const rows = flattenTree(S.document);
3000
3410
  const collapsed = S._collapsed || (S._collapsed = new Set());
@@ -3014,6 +3424,9 @@ function renderLayersTemplate() {
3014
3424
  }
3015
3425
  if (hidden) continue;
3016
3426
 
3427
+ // In content mode, skip the document root row (it's not a real element)
3428
+ if (S.mode === "content" && path.length === 0) continue;
3429
+
3017
3430
  // Text node children: display-only row with truncated preview
3018
3431
  if (nodeType === "text") {
3019
3432
  const textPreview = String(node).length > 40 ? String(node).slice(0, 40) + "…" : String(node);
@@ -3084,7 +3497,7 @@ function renderLayersTemplate() {
3084
3497
 
3085
3498
  // Compute move-button availability for element nodes
3086
3499
  const isElement = nodeType === "element";
3087
- const isRoot = path.length < 2;
3500
+ const isRoot = S.mode === "content" ? path.length === 0 : path.length < 2;
3088
3501
  const idx = isElement ? /** @type {number} */ (childIndex(path)) : 0;
3089
3502
  const parentPath = isElement && !isRoot ? /** @type {any} */ (parentElementPath(path)) : null;
3090
3503
  const parentNode = parentPath ? getNodeAtPath(S.document, parentPath) : null;
@@ -3113,7 +3526,10 @@ function renderLayersTemplate() {
3113
3526
  data-dnd-depth=${isElement ? depth : nothing}
3114
3527
  data-dnd-void=${isElement && isVoidEl ? "" : nothing}
3115
3528
  @click=${() => update(selectNode(S, path))}
3116
- @contextmenu=${isElement ? (/** @type {any} */ e) => showContextMenu(e, path, S) : nothing}
3529
+ @contextmenu=${isElement
3530
+ ? (/** @type {any} */ e) =>
3531
+ showContextMenu(e, path, S, { onEditComponent: navigateToComponent })
3532
+ : nothing}
3117
3533
  >
3118
3534
  <span class="layer-indent" style="width:${depth * 16}px"></span>
3119
3535
  <span class="layer-toggle"
@@ -3306,7 +3722,7 @@ function registerLayersDnD() {
3306
3722
  },
3307
3723
  }),
3308
3724
  );
3309
- dndCleanups.push(cleanup);
3725
+ view.dndCleanups.push(cleanup);
3310
3726
  },
3311
3727
  );
3312
3728
 
@@ -3323,7 +3739,7 @@ function registerLayersDnD() {
3323
3739
  applyDropInstruction(instruction, srcData, targetPath);
3324
3740
  },
3325
3741
  });
3326
- dndCleanups.push(monitorCleanup);
3742
+ view.dndCleanups.push(monitorCleanup);
3327
3743
  });
3328
3744
  }
3329
3745
 
@@ -3364,7 +3780,7 @@ function registerComponentsDnD() {
3364
3780
  return { type: "block", fragment: structuredClone(instanceDef) };
3365
3781
  },
3366
3782
  });
3367
- dndCleanups.push(cleanup);
3783
+ view.dndCleanups.push(cleanup);
3368
3784
  },
3369
3785
  );
3370
3786
  });
@@ -3858,7 +4274,7 @@ function registerElementsDnD() {
3858
4274
  return { type: "block", fragment: structuredClone(def) };
3859
4275
  },
3860
4276
  });
3861
- dndCleanups.push(cleanup);
4277
+ view.dndCleanups.push(cleanup);
3862
4278
  },
3863
4279
  );
3864
4280
  });
@@ -3970,7 +4386,51 @@ function hasTagStyle(rootStyle, tag) {
3970
4386
  return s && typeof s === "object" && Object.keys(s).length > 0;
3971
4387
  }
3972
4388
 
3973
- function renderStylebook() {
4389
+ function renderSettings() {
4390
+ const settingsTab = S.ui.settingsTab || "stylebook";
4391
+
4392
+ // Top-level settings tabs chrome bar
4393
+ const settingsChromeBarTpl = html`
4394
+ <div
4395
+ class="sb-chrome settings-top-chrome"
4396
+ style="position:absolute;top:0;left:0;right:0;z-index:16;background:var(--bg-panel);border-bottom:1px solid var(--border)"
4397
+ >
4398
+ <sp-tabs
4399
+ size="s"
4400
+ selected=${settingsTab}
4401
+ @change=${(/** @type {any} */ e) => {
4402
+ updateUi("settingsTab", e.target.selected);
4403
+ }}
4404
+ >
4405
+ <sp-tab label="Stylebook" value="stylebook"></sp-tab>
4406
+ <sp-tab label="Definitions" value="definitions"></sp-tab>
4407
+ <sp-tab label="Collections" value="collections"></sp-tab>
4408
+ </sp-tabs>
4409
+ </div>
4410
+ `;
4411
+
4412
+ // Non-stylebook tabs: render editor into canvasWrap with offset for chrome bar
4413
+ if (settingsTab === "definitions" || settingsTab === "collections") {
4414
+ /** @type {any} */ (canvasWrap).style.overflow = "hidden";
4415
+
4416
+ litRender(
4417
+ html`${settingsChromeBarTpl}
4418
+ <div
4419
+ class="settings-editor-container"
4420
+ style="position:absolute;inset:40px 0 0 0;overflow:auto"
4421
+ ></div>`,
4422
+ /** @type {any} */ (canvasWrap),
4423
+ );
4424
+
4425
+ const container = /** @type {HTMLElement} */ (
4426
+ canvasWrap.querySelector(".settings-editor-container")
4427
+ );
4428
+ if (settingsTab === "definitions") renderDefsEditor(container);
4429
+ else renderCollectionsEditor(container);
4430
+ return;
4431
+ }
4432
+
4433
+ // Stylebook tab — existing behavior
3974
4434
  stylebookElToTag = new WeakMap();
3975
4435
  const rootStyle = getEffectiveStyle(S.document.style);
3976
4436
  const filter = (S.ui.stylebookFilter || "").toLowerCase();
@@ -3981,38 +4441,33 @@ function renderStylebook() {
3981
4441
 
3982
4442
  // Chrome bar (tabs + filter) — positioned absolutely above the panzoom surface
3983
4443
  const onTabClick = (/** @type {string} */ t) => {
3984
- S = { ...S, ui: { ...S.ui, stylebookTab: t } };
3985
- renderCanvas();
3986
- renderOverlays();
3987
- renderLeftPanel();
4444
+ updateUi("stylebookTab", t);
3988
4445
  };
3989
4446
 
3990
4447
  const onFilterInput = (/** @type {any} */ e) => {
3991
- S = { ...S, ui: { ...S.ui, stylebookFilter: e.target.value } };
3992
- renderCanvas();
3993
- renderOverlays();
4448
+ updateUi("stylebookFilter", e.target.value);
3994
4449
  };
3995
4450
 
3996
4451
  const onCustomizedToggle = () => {
3997
- S = { ...S, ui: { ...S.ui, stylebookCustomizedOnly: !S.ui.stylebookCustomizedOnly } };
3998
- renderCanvas();
3999
- renderOverlays();
4452
+ updateUi("stylebookCustomizedOnly", !S.ui.stylebookCustomizedOnly);
4000
4453
  };
4001
4454
 
4002
4455
  const chromeBarTpl = html`
4456
+ ${settingsChromeBarTpl}
4003
4457
  <div
4004
4458
  class="sb-chrome"
4005
- style="position:absolute;top:0;left:0;right:0;z-index:15;background:var(--bg-panel);border-bottom:1px solid var(--border)"
4459
+ style="position:absolute;top:36px;left:0;right:0;z-index:15;background:var(--bg-panel);border-bottom:1px solid var(--border)"
4006
4460
  >
4007
- <sp-tabs size="s">
4461
+ <sp-tabs
4462
+ size="s"
4463
+ selected=${S.ui.stylebookTab || "elements"}
4464
+ @change=${(/** @type {any} */ e) => {
4465
+ onTabClick(e.target.selected);
4466
+ }}
4467
+ >
4008
4468
  ${["elements", "variables"].map(
4009
4469
  (t) => html`
4010
- <sp-tab
4011
- label=${t.charAt(0).toUpperCase() + t.slice(1)}
4012
- value=${t}
4013
- ?selected=${S.ui.stylebookTab === t}
4014
- @click=${() => onTabClick(t)}
4015
- ></sp-tab>
4470
+ <sp-tab label=${t.charAt(0).toUpperCase() + t.slice(1)} value=${t}></sp-tab>
4016
4471
  `,
4017
4472
  )}
4018
4473
  </sp-tabs>
@@ -4058,7 +4513,6 @@ function renderStylebook() {
4058
4513
  activeSet: activeBreakpointsForWidth(sizeBreakpoints, bp.width),
4059
4514
  });
4060
4515
  }
4061
- allPanelDefs.sort((a, b) => b.width - a.width);
4062
4516
  }
4063
4517
 
4064
4518
  // Render content into panels
@@ -4110,9 +4564,9 @@ function renderStylebook() {
4110
4564
  ${chromeBarTpl}
4111
4565
  <div
4112
4566
  class="panzoom-wrap"
4113
- style="transform-origin:0 0;padding-top:36px"
4567
+ style="transform-origin:0 0;padding-top:72px"
4114
4568
  ${ref((el) => {
4115
- if (el) panzoomWrap = /** @type {HTMLDivElement} */ (el);
4569
+ if (el) view.panzoomWrap = /** @type {HTMLDivElement} */ (el);
4116
4570
  })}
4117
4571
  >
4118
4572
  ${panelEntries.map((e) => e.tpl)}
@@ -4586,21 +5040,6 @@ function renderVarRow(catKey, catMeta, varName, varVal, isNew) {
4586
5040
 
4587
5041
  // varDisplayName, friendlyNameToVar — imported from studio-utils.js
4588
5042
 
4589
- /**
4590
- * Convert a $media key like "--tablet" to a friendly display name "Tablet". "--" returns "Base".
4591
- *
4592
- * @param {any} name
4593
- */
4594
- function mediaDisplayName(name) {
4595
- if (name === "--") return "Base";
4596
- return (
4597
- name
4598
- .replace(/^--/, "")
4599
- .replace(/-/g, " ")
4600
- .replace(/\b\w/g, (/** @type {any} */ c) => c.toUpperCase()) || name
4601
- );
4602
- }
4603
-
4604
5043
  /**
4605
5044
  * Convert a human-friendly name like "Tablet" to a $media key "--tablet"
4606
5045
  *
@@ -4747,9 +5186,8 @@ function registerStylebookPanelEvents(panel) {
4747
5186
  }
4748
5187
  }
4749
5188
  // Clicked empty area — deselect
4750
- S = { ...S, ui: { ...S.ui, stylebookSelection: null, activeSelector: null } };
5189
+ updateSession({ ui: { stylebookSelection: null, activeSelector: null } });
4751
5190
  renderStylebookOverlays();
4752
- renderRightPanel();
4753
5191
  });
4754
5192
 
4755
5193
  overlayClk.addEventListener("mousemove", (/** @type {any} */ e) => {
@@ -4843,73 +5281,191 @@ function findStylebookEl(/** @type {any} */ canvasEl, /** @type {any} */ tag) {
4843
5281
  // ─── Right panel: Inspector ───────────────────────────────────────────────────
4844
5282
 
4845
5283
  function renderRightPanel() {
4846
- const tab = S.ui.rightTab;
5284
+ rightPanelMod.render();
5285
+ }
4847
5286
 
4848
- // ── Icon tabs ──────────────────────────────────────────────────────────
4849
- const panelTabs = [
4850
- { value: "properties", icon: "sp-icon-properties", label: "Properties" },
4851
- { value: "events", icon: "sp-icon-event", label: "Events" },
4852
- { value: "style", icon: "sp-icon-brush", label: "Style" },
4853
- ];
5287
+ // ─── Inspector ────────────────────────────────────────────────────────────────
4854
5288
 
4855
- const tabsT = html`
4856
- <div class="panel-tabs">
4857
- <sp-tabs
4858
- selected=${tab}
4859
- quiet
4860
- @change=${(/** @type {any} */ e) => {
4861
- const sel = e.target.selected;
4862
- if (sel && sel !== tab) {
4863
- S = { ...S, ui: { ...S.ui, rightTab: sel } };
4864
- renderRightPanel();
4865
- renderOverlays();
4866
- }
4867
- }}
4868
- >
4869
- ${panelTabs.map(
4870
- (t) => html`
4871
- <sp-tab value=${t.value} title=${t.label} aria-label=${t.label}>
4872
- ${tabIcon(t.icon, "xs")}
4873
- </sp-tab>
4874
- `,
4875
- )}
4876
- </sp-tabs>
5289
+ /** Frontmatter-only panel shown in content mode when no element is selected */
5290
+ function renderFrontmatterOnlyPanel() {
5291
+ const fm = S.content?.frontmatter || {};
5292
+ const col = findCollectionSchema(S.documentPath, projectState?.projectConfig);
5293
+ const schemaProps = col?.schema?.properties;
5294
+ const requiredFields = new Set(col?.schema?.required || []);
5295
+
5296
+ /** @type {{ field: string; entry: any; value: any }[]} */
5297
+ const fields = [];
5298
+ if (schemaProps) {
5299
+ for (const [field, fieldSchema] of Object.entries(
5300
+ /** @type {Record<string, any>} */ (schemaProps),
5301
+ )) {
5302
+ fields.push({ field, entry: fieldSchema, value: fm[field] });
5303
+ }
5304
+ for (const [field, value] of Object.entries(fm)) {
5305
+ if (!schemaProps[field]) {
5306
+ fields.push({
5307
+ field,
5308
+ entry: { type: typeof value === "boolean" ? "boolean" : "string" },
5309
+ value,
5310
+ });
5311
+ }
5312
+ }
5313
+ } else {
5314
+ for (const [field, value] of Object.entries(fm)) {
5315
+ fields.push({
5316
+ field,
5317
+ entry: { type: typeof value === "boolean" ? "boolean" : "string" },
5318
+ value,
5319
+ });
5320
+ }
5321
+ }
5322
+
5323
+ if (fields.length === 0 && !schemaProps) {
5324
+ return html`<div class="empty-state">No frontmatter. Select an element to inspect.</div>`;
5325
+ }
5326
+
5327
+ return html`
5328
+ <div class="style-sidebar">
5329
+ <sp-accordion allow-multiple size="s">
5330
+ <sp-accordion-item label=${col ? `Frontmatter (${col.name})` : "Frontmatter"} open>
5331
+ <div class="style-section-body">
5332
+ ${fields.map((f) => renderFmFieldRow(f.field, f.entry, f.value, requiredFields))}
5333
+ </div>
5334
+ </sp-accordion-item>
5335
+ </sp-accordion>
4877
5336
  </div>
4878
5337
  `;
5338
+ }
4879
5339
 
4880
- // ── Panel body ────────────────────────────────────────────────────────
4881
- /** @type {any} */
4882
- let bodyT = nothing;
4883
- if (tab === "properties") {
4884
- bodyT = propertiesSidebarTemplate();
4885
- } else if (tab === "events") {
4886
- bodyT = _eventsSidebarTemplate(S, {
4887
- isCustomElementDoc: () => isCustomElementDoc(S),
4888
- renderCanvas,
5340
+ /** Render a single frontmatter field row (shared between both panels) */
5341
+ function renderFmFieldRow(
5342
+ /** @type {string} */ field,
5343
+ /** @type {any} */ entry,
5344
+ /** @type {any} */ value,
5345
+ /** @type {Set<string>} */ requiredFields,
5346
+ ) {
5347
+ const isRequired = requiredFields.has(field);
5348
+ const label = field.replace(/([A-Z])/g, " $1").replace(/^./, (s) => s.toUpperCase());
5349
+ const displayLabel = label + (isRequired ? " *" : "");
5350
+ const hasVal = value !== undefined && value !== "" && value !== false;
5351
+ const onClear = () => update(updateFrontmatter(S, field, undefined));
5352
+
5353
+ // Boolean → checkbox
5354
+ if (entry.type === "boolean") {
5355
+ return renderFieldRow({
5356
+ prop: field,
5357
+ label: displayLabel,
5358
+ hasValue: hasVal,
5359
+ onClear,
5360
+ widget: html`
5361
+ <sp-checkbox
5362
+ size="s"
5363
+ .checked=${live(!!value)}
5364
+ @change=${(/** @type {any} */ e) =>
5365
+ update(updateFrontmatter(S, field, e.target.checked || undefined))}
5366
+ ></sp-checkbox>
5367
+ `,
4889
5368
  });
4890
- } else if (tab === "style") {
4891
- try {
4892
- bodyT = renderStylePanelTemplate();
4893
- } catch (/** @type {any} */ e) {
4894
- console.error("[renderStylePanelTemplate]", e);
4895
- }
4896
5369
  }
4897
5370
 
4898
- const tpl = html`
4899
- ${tabsT}
4900
- <div class="panel-body">${bodyT}</div>
4901
- `;
5371
+ // Array of strings → comma-separated text
5372
+ if (entry.type === "array") {
5373
+ const display = Array.isArray(value) ? value.join(", ") : value || "";
5374
+ return renderFieldRow({
5375
+ prop: field,
5376
+ label: displayLabel,
5377
+ hasValue: hasVal,
5378
+ onClear,
5379
+ widget: html`
5380
+ <sp-textfield
5381
+ size="s"
5382
+ placeholder="comma, separated"
5383
+ .value=${live(display)}
5384
+ @input=${debouncedStyleCommit(`fm:${field}`, 400, (/** @type {any} */ e) => {
5385
+ const arr = e.target.value
5386
+ ? e.target.value
5387
+ .split(",")
5388
+ .map((/** @type {string} */ s) => s.trim())
5389
+ .filter(Boolean)
5390
+ : undefined;
5391
+ update(updateFrontmatter(S, field, arr));
5392
+ })}
5393
+ ></sp-textfield>
5394
+ `,
5395
+ });
5396
+ }
4902
5397
 
4903
- litRender(tpl, rightPanel);
5398
+ // Enum → select
5399
+ if (Array.isArray(entry.enum)) {
5400
+ return renderFieldRow({
5401
+ prop: field,
5402
+ label: displayLabel,
5403
+ hasValue: hasVal,
5404
+ onClear,
5405
+ widget: html`
5406
+ <sp-picker
5407
+ size="s"
5408
+ .value=${live(value || "")}
5409
+ @change=${(/** @type {any} */ e) =>
5410
+ update(updateFrontmatter(S, field, e.target.value || undefined))}
5411
+ >
5412
+ ${entry.enum.map(
5413
+ (/** @type {string} */ opt) => html`<sp-menu-item value=${opt}>${opt}</sp-menu-item>`,
5414
+ )}
5415
+ </sp-picker>
5416
+ `,
5417
+ });
5418
+ }
4904
5419
 
4905
- updateForcedPseudoPreview();
4906
- }
5420
+ // Number
5421
+ if (entry.type === "number") {
5422
+ return renderFieldRow({
5423
+ prop: field,
5424
+ label: displayLabel,
5425
+ hasValue: hasVal,
5426
+ onClear,
5427
+ widget: html`
5428
+ <sp-number-field
5429
+ size="s"
5430
+ hide-stepper
5431
+ .value=${live(value !== undefined ? Number(value) : undefined)}
5432
+ @change=${debouncedStyleCommit(`fm:${field}`, 400, (/** @type {any} */ e) => {
5433
+ const v = e.target.value;
5434
+ update(updateFrontmatter(S, field, isNaN(v) ? undefined : Number(v)));
5435
+ })}
5436
+ ></sp-number-field>
5437
+ `,
5438
+ });
5439
+ }
4907
5440
 
4908
- // ─── Inspector ────────────────────────────────────────────────────────────────
5441
+ // Default: text (handles string, date, etc.)
5442
+ return renderFieldRow({
5443
+ prop: field,
5444
+ label: displayLabel,
5445
+ hasValue: hasVal,
5446
+ onClear,
5447
+ widget: html`
5448
+ <sp-textfield
5449
+ size="s"
5450
+ placeholder=${entry.format === "date" ? "YYYY-MM-DD" : ""}
5451
+ .value=${live(value || "")}
5452
+ @input=${debouncedStyleCommit(`fm:${field}`, 400, (/** @type {any} */ e) => {
5453
+ update(updateFrontmatter(S, field, e.target.value || undefined));
5454
+ })}
5455
+ ></sp-textfield>
5456
+ `,
5457
+ });
5458
+ }
4909
5459
 
4910
5460
  /** Properties panel — lit-html template with accordion sections */
4911
5461
  function propertiesSidebarTemplate() {
4912
- if (!S.selection) return html`<div class="empty-state">Select an element to inspect</div>`;
5462
+ // In content mode with no selection, still show frontmatter fields
5463
+ if (!S.selection) {
5464
+ if (S.mode === "content") {
5465
+ return renderFrontmatterOnlyPanel();
5466
+ }
5467
+ return html`<div class="empty-state">Select an element to inspect</div>`;
5468
+ }
4913
5469
  const node = getNodeAtPath(S.document, S.selection);
4914
5470
  if (!node) return html`<div class="empty-state">Node not found</div>`;
4915
5471
 
@@ -4941,21 +5497,12 @@ function propertiesSidebarTemplate() {
4941
5497
 
4942
5498
  // Boolean attributes render as checkboxes
4943
5499
  if (entry.type === "boolean") {
4944
- return html`
4945
- <div class="style-row" data-prop=${attr}>
4946
- <div class="style-row-label">
4947
- ${hasVal
4948
- ? html`<span
4949
- class="set-dot"
4950
- title="Clear ${attr}"
4951
- @click=${(/** @type {any} */ e) => {
4952
- e.stopPropagation();
4953
- update(updateAttribute(S, path, attr, undefined));
4954
- }}
4955
- ></span>`
4956
- : nothing}
4957
- <sp-field-label size="s" title=${attr}>${attrLabel(entry, attr)}</sp-field-label>
4958
- </div>
5500
+ return renderFieldRow({
5501
+ prop: attr,
5502
+ label: attrLabel(entry, attr),
5503
+ hasValue: hasVal,
5504
+ onClear: () => update(updateAttribute(S, path, attr, undefined)),
5505
+ widget: html`
4959
5506
  <sp-checkbox
4960
5507
  size="s"
4961
5508
  .checked=${live(!!value)}
@@ -4963,30 +5510,19 @@ function propertiesSidebarTemplate() {
4963
5510
  update(updateAttribute(S, path, attr, e.target.checked || undefined))}
4964
5511
  >
4965
5512
  </sp-checkbox>
4966
- </div>
4967
- `;
5513
+ `,
5514
+ });
4968
5515
  }
4969
5516
 
4970
- return html`
4971
- <div class="style-row" data-prop=${attr}>
4972
- <div class="style-row-label">
4973
- ${hasVal
4974
- ? html`<span
4975
- class="set-dot"
4976
- title="Clear ${attr}"
4977
- @click=${(/** @type {any} */ e) => {
4978
- e.stopPropagation();
4979
- update(updateAttribute(S, path, attr, undefined));
4980
- }}
4981
- ></span>`
4982
- : nothing}
4983
- <sp-field-label size="s" title=${attr}>${attrLabel(entry, attr)}</sp-field-label>
4984
- </div>
4985
- ${widgetForType(type, entry, attr, value || "", (/** @type {any} */ v) =>
4986
- update(updateAttribute(S, path, attr, v || undefined)),
4987
- )}
4988
- </div>
4989
- `;
5517
+ return renderFieldRow({
5518
+ prop: attr,
5519
+ label: attrLabel(entry, attr),
5520
+ hasValue: hasVal,
5521
+ onClear: () => update(updateAttribute(S, path, attr, undefined)),
5522
+ widget: widgetForType(type, entry, attr, value || "", (/** @type {any} */ v) =>
5523
+ update(updateAttribute(S, path, attr, v || undefined)),
5524
+ ),
5525
+ });
4990
5526
  }
4991
5527
 
4992
5528
  // ── Collect applicable attributes from html-meta ──
@@ -5364,11 +5900,63 @@ function propertiesSidebarTemplate() {
5364
5900
  })()
5365
5901
  : nothing;
5366
5902
 
5903
+ // ── Frontmatter section (content mode only) ──
5904
+ const frontmatterT =
5905
+ S.mode === "content"
5906
+ ? (() => {
5907
+ const fm = S.content?.frontmatter || {};
5908
+ const col = findCollectionSchema(S.documentPath, projectState?.projectConfig);
5909
+ const schemaProps = col?.schema?.properties;
5910
+ const requiredFields = new Set(col?.schema?.required || []);
5911
+
5912
+ /** @type {{ field: string; entry: any; value: any }[]} */
5913
+ const fields = [];
5914
+ if (schemaProps) {
5915
+ for (const [field, fieldSchema] of Object.entries(
5916
+ /** @type {Record<string, any>} */ (schemaProps),
5917
+ )) {
5918
+ fields.push({ field, entry: fieldSchema, value: fm[field] });
5919
+ }
5920
+ for (const [field, value] of Object.entries(fm)) {
5921
+ if (!schemaProps[field]) {
5922
+ fields.push({
5923
+ field,
5924
+ entry: { type: typeof value === "boolean" ? "boolean" : "string" },
5925
+ value,
5926
+ });
5927
+ }
5928
+ }
5929
+ } else {
5930
+ for (const [field, value] of Object.entries(fm)) {
5931
+ fields.push({
5932
+ field,
5933
+ entry: { type: typeof value === "boolean" ? "boolean" : "string" },
5934
+ value,
5935
+ });
5936
+ }
5937
+ }
5938
+
5939
+ if (fields.length === 0 && !schemaProps) return nothing;
5940
+
5941
+ return html`
5942
+ <sp-accordion-item
5943
+ label=${col ? `Frontmatter (${col.name})` : "Frontmatter"}
5944
+ ?open=${isSectionOpen("__frontmatter") !== false}
5945
+ @sp-accordion-item-toggle=${() => toggleSection("__frontmatter")}
5946
+ >
5947
+ <div class="style-section-body">
5948
+ ${fields.map((f) => renderFmFieldRow(f.field, f.entry, f.value, requiredFields))}
5949
+ </div>
5950
+ </sp-accordion-item>
5951
+ `;
5952
+ })()
5953
+ : nothing;
5954
+
5367
5955
  // ── Assemble ──
5368
5956
  const tpl = html`
5369
5957
  <div class="style-sidebar">
5370
5958
  <sp-accordion allow-multiple size="s">
5371
- ${isMapNode ? repeaterT : elemT} ${isMapNode ? nothing : observedAttrsT}
5959
+ ${frontmatterT} ${isMapNode ? repeaterT : elemT} ${isMapNode ? nothing : observedAttrsT}
5372
5960
  ${isMapNode ? nothing : switchT} ${isMapNode ? nothing : compPropsT}
5373
5961
  ${isMapNode ? nothing : attrSectionTemplates} ${isMapNode ? nothing : customSectionT}
5374
5962
  ${isMapNode ? nothing : mediaT} ${isMapNode ? nothing : cssPropsT}
@@ -5603,13 +6191,13 @@ function renderComponentPropsFieldsTemplate(
5603
6191
  ></sp-number-field>`;
5604
6192
  } else if (parsed.kind === "combobox") {
5605
6193
  const options = /** @type {string[]} */ (/** @type {any} */ (parsed).options);
5606
- widgetTpl = html`<jx-styled-combobox
6194
+ widgetTpl = html`<jx-value-selector
5607
6195
  .value=${String(staticVal)}
5608
6196
  size="s"
5609
6197
  placeholder="—"
5610
6198
  .options=${options.map((o) => ({ value: o, label: camelToLabel(o) }))}
5611
6199
  @change=${(/** @type {any} */ e) => onChange(e.detail?.value ?? e.target.value)}
5612
- ></jx-styled-combobox>`;
6200
+ ></jx-value-selector>`;
5613
6201
  } else {
5614
6202
  widgetTpl = html`<sp-textfield
5615
6203
  size="s"
@@ -5839,7 +6427,7 @@ function mediaBreakpointRowTemplate(/** @type {any} */ name, /** @type {any} */
5839
6427
 
5840
6428
  // ─── Style Sidebar (metadata-driven) ───────────────────────────────────────────
5841
6429
 
5842
- const UNIT_RE = /^(-?[\d.]+)(px|rem|em|%|vw|vh|svw|svh|dvh|ms|s|fr|ch|ex|deg)?$/;
6430
+ // UNIT_RE imported from ui/unit-selector.js
5843
6431
 
5844
6432
  // inferInputType — imported from studio-utils.js
5845
6433
 
@@ -5867,32 +6455,118 @@ function autoOpenSections(/** @type {any} */ node, /** @type {any} */ currentSec
5867
6455
 
5868
6456
  /** Get longhands for a shorthand property from css-meta */
5869
6457
  function getLonghands(/** @type {any} */ shorthandProp) {
6458
+ // Check for explicit $longhands array first (used by border-side shorthands)
6459
+ const entry = /** @type {Record<string, any>} */ (cssMeta.$defs)[shorthandProp];
6460
+ if (entry?.$longhands) {
6461
+ return entry.$longhands
6462
+ .map((/** @type {string} */ name) => ({
6463
+ name,
6464
+ entry: /** @type {Record<string, any>} */ (cssMeta.$defs)[name] || { $order: 0 },
6465
+ }))
6466
+ .sort((/** @type {any} */ a, /** @type {any} */ b) => a.entry.$order - b.entry.$order);
6467
+ }
6468
+ // Fallback: reverse-lookup by $shorthand reference
5870
6469
  const result = [];
5871
- for (const [name, entry] of /** @type {[string, any][]} */ (Object.entries(cssMeta.$defs))) {
5872
- if (entry.$shorthand === shorthandProp) result.push({ name, entry });
6470
+ for (const [name, e] of /** @type {[string, any][]} */ (Object.entries(cssMeta.$defs))) {
6471
+ if (e.$shorthand === shorthandProp) result.push({ name, entry: e });
5873
6472
  }
5874
6473
  result.sort((a, b) => a.entry.$order - b.entry.$order);
5875
6474
  return result;
5876
6475
  }
5877
6476
 
5878
- // ── Color popover singleton ─────────────────────────────────────────────────
5879
- /** @type {any} */
5880
- /** @type {any} */
5881
- let _colorCallback = null;
5882
- /** @type {any} */
5883
- let _colorDismissHandler = null;
6477
+ /**
6478
+ * Expand a CSS shorthand value (margin, padding, borderWidth, borderRadius) into individual
6479
+ * longhand values following the standard 1–4 value TRBL pattern. Returns an array matching the
6480
+ * longhand count (always 4 for box properties).
6481
+ */
6482
+ function expandShorthand(/** @type {string} */ shortVal, /** @type {number} */ count) {
6483
+ if (!shortVal) return Array(count).fill("");
6484
+ const parts = shortVal.trim().split(/\s+/);
6485
+ if (count !== 4 || parts.length === 0) return Array(count).fill("");
6486
+ if (parts.length === 1) return [parts[0], parts[0], parts[0], parts[0]];
6487
+ if (parts.length === 2) return [parts[0], parts[1], parts[0], parts[1]];
6488
+ if (parts.length === 3) return [parts[0], parts[1], parts[2], parts[1]];
6489
+ return [parts[0], parts[1], parts[2], parts[3]];
6490
+ }
5884
6491
 
5885
- /** Extract --color-* CSS custom properties from the document root style. */
5886
- function getColorVars() {
5887
- const style = S.document?.style;
5888
- if (!style) return [];
5889
- const vars = [];
5890
- for (const [k, v] of Object.entries(style)) {
5891
- if (k.startsWith("--color") && (typeof v === "string" || typeof v === "number")) {
5892
- vars.push({ name: k, value: String(v) });
6492
+ /**
6493
+ * Compress 4 TRBL values back into the shortest valid CSS shorthand string. e.g.
6494
+ * ["0","auto","3rem","auto"] → "0 auto 3rem"
6495
+ */
6496
+ function compressShorthand(/** @type {string[]} */ vals) {
6497
+ const [t, r, b, l] = vals;
6498
+ if (t === r && r === b && b === l) return t;
6499
+ if (t === b && r === l) return `${t} ${r}`;
6500
+ if (r === l) return `${t} ${r} ${b}`;
6501
+ return `${t} ${r} ${b} ${l}`;
6502
+ }
6503
+
6504
+ // ─── Border-side shorthand parsing ────────────────────────────────────────────
6505
+ // CSS border-side shorthand: <width> || <style> || <color> (any order, all optional)
6506
+
6507
+ const BORDER_STYLES = new Set([
6508
+ "none",
6509
+ "solid",
6510
+ "dashed",
6511
+ "dotted",
6512
+ "double",
6513
+ "groove",
6514
+ "ridge",
6515
+ "inset",
6516
+ "outset",
6517
+ "hidden",
6518
+ ]);
6519
+
6520
+ /**
6521
+ * Parse a border-side shorthand value into [width, style, color].
6522
+ *
6523
+ * @param {string} value — e.g. "1px solid var(--color-border)"
6524
+ * @returns {string[]} — [width, style, color]
6525
+ */
6526
+ function expandBorderSide(value) {
6527
+ if (!value) return ["", "", ""];
6528
+ // Tokenize respecting parenthesized values like var(...) and rgb(...)
6529
+ const tokens = [];
6530
+ let current = "";
6531
+ let depth = 0;
6532
+ for (const ch of value.trim()) {
6533
+ if (ch === "(") depth++;
6534
+ if (ch === ")") depth--;
6535
+ if (ch === " " && depth === 0) {
6536
+ if (current) tokens.push(current);
6537
+ current = "";
6538
+ } else {
6539
+ current += ch;
5893
6540
  }
5894
6541
  }
5895
- return vars;
6542
+ if (current) tokens.push(current);
6543
+
6544
+ let width = "";
6545
+ let style = "";
6546
+ let color = "";
6547
+
6548
+ for (const tok of tokens) {
6549
+ if (!style && BORDER_STYLES.has(tok)) {
6550
+ style = tok;
6551
+ } else if (!width && /^[\d.]/.test(tok)) {
6552
+ width = tok;
6553
+ } else {
6554
+ // Remaining token(s) are color — join in case color was split (shouldn't be with paren-aware tokenizer)
6555
+ color = color ? `${color} ${tok}` : tok;
6556
+ }
6557
+ }
6558
+
6559
+ return [width, style, color];
6560
+ }
6561
+
6562
+ /**
6563
+ * Recompose border-side longhand values into a shorthand string.
6564
+ *
6565
+ * @param {string[]} vals — [width, style, color]
6566
+ * @returns {string}
6567
+ */
6568
+ function compressBorderSide(/** @type {string[]} */ vals) {
6569
+ return vals.filter((v) => v && v.trim()).join(" ");
5896
6570
  }
5897
6571
 
5898
6572
  /** Extract --font-* CSS custom properties from the document root style. */
@@ -5908,371 +6582,6 @@ function getFontVars() {
5908
6582
  return vars;
5909
6583
  }
5910
6584
 
5911
- /** Resolve a color value for display — if it's a var() reference, look up the actual color. */
5912
- function resolveColorForDisplay(/** @type {any} */ val) {
5913
- if (!val) return "transparent";
5914
- const m = val.match(/^var\((--[^)]+)\)$/);
5915
- if (m) {
5916
- const style = S.document?.style;
5917
- const resolved = style?.[m[1]];
5918
- if (typeof resolved === "string") return resolved;
5919
- return "transparent";
5920
- }
5921
- return val;
5922
- }
5923
-
5924
- const _colorPopoverHost = createFloatingContainer();
5925
-
5926
- function closeColorPopover() {
5927
- litRender(nothing, _colorPopoverHost);
5928
- _colorCallback = null;
5929
- if (_colorDismissHandler) {
5930
- document.removeEventListener("pointerdown", _colorDismissHandler, true);
5931
- document.removeEventListener("keydown", _colorDismissHandler, true);
5932
- _colorDismissHandler = null;
5933
- }
5934
- }
5935
-
5936
- function openColorPopover(
5937
- /** @type {any} */ anchorEl,
5938
- /** @type {any} */ currentColor,
5939
- /** @type {any} */ onChange,
5940
- ) {
5941
- const colorVars = getColorVars();
5942
- const rawResolved = resolveColorForDisplay(currentColor) || "#000000";
5943
- // Ensure # prefix so Spectrum components return #-prefixed hex
5944
- const resolvedColor =
5945
- rawResolved.startsWith("#") || rawResolved.startsWith("rgb") || rawResolved.startsWith("hsl")
5946
- ? rawResolved
5947
- : `#${rawResolved}`;
5948
-
5949
- const popoverQuery = (/** @type {string} */ sel) => _colorPopoverHost.querySelector(sel);
5950
-
5951
- /** Ensure hex color always has a # prefix */
5952
- const normalizeHex = (/** @type {string} */ c) => {
5953
- if (!c) return c;
5954
- if (c.startsWith("var(") || c.startsWith("rgb") || c.startsWith("hsl")) return c;
5955
- const hex = c.replace(/^#?/, "#");
5956
- return hex;
5957
- };
5958
-
5959
- // Render popover content with lit-html
5960
- const syncFromArea = (/** @type {any} */ _e) => {
5961
- /** @type {any} */
5962
- const area = popoverQuery("sp-color-area");
5963
- /** @type {any} */
5964
- const slider = popoverQuery("sp-color-slider");
5965
- /** @type {any} */
5966
- const tf = popoverQuery(".color-popover-hex");
5967
- const color = normalizeHex(String(area.color));
5968
- if (slider) slider.color = color;
5969
- if (tf) tf.value = color;
5970
- _colorCallback?.(color);
5971
- };
5972
-
5973
- const syncFromSlider = (/** @type {any} */ _e) => {
5974
- /** @type {any} */
5975
- const area = popoverQuery("sp-color-area");
5976
- /** @type {any} */
5977
- const slider = popoverQuery("sp-color-slider");
5978
- /** @type {any} */
5979
- const tf = popoverQuery(".color-popover-hex");
5980
- const color = normalizeHex(String(slider.color));
5981
- if (area) area.color = color;
5982
- if (tf) tf.value = color;
5983
- _colorCallback?.(color);
5984
- };
5985
-
5986
- const syncFromText = (/** @type {any} */ e) => {
5987
- const val = e.target.value.trim();
5988
- if (!val) return;
5989
- /** @type {any} */
5990
- const area = popoverQuery("sp-color-area");
5991
- /** @type {any} */
5992
- const slider = popoverQuery("sp-color-slider");
5993
- try {
5994
- if (area) area.color = val;
5995
- if (slider) slider.color = val;
5996
- } catch {}
5997
- _colorCallback?.(val);
5998
- };
5999
-
6000
- const r = anchorEl.getBoundingClientRect();
6001
-
6002
- litRender(
6003
- html`
6004
- <sp-popover
6005
- open
6006
- tabindex="-1"
6007
- style="padding:12px;position:fixed;z-index:9999;left:${r.left}px;top:${r.bottom +
6008
- 4}px;overflow:visible"
6009
- >
6010
- <div class="color-popover-inner">
6011
- <sp-color-area
6012
- style="width:200px; height:150px; --mod-colorarea-width:200px; --mod-colorarea-height:150px"
6013
- color=${resolvedColor}
6014
- @input=${syncFromArea}
6015
- ></sp-color-area>
6016
- <sp-color-slider
6017
- style="width:200px; --mod-colorslider-length:200px"
6018
- color=${resolvedColor}
6019
- @input=${syncFromSlider}
6020
- ></sp-color-slider>
6021
- <sp-textfield
6022
- size="s"
6023
- class="color-popover-hex"
6024
- style="width:200px"
6025
- .value=${live(currentColor || "")}
6026
- placeholder="#000000"
6027
- @change=${syncFromText}
6028
- ></sp-textfield>
6029
- ${colorVars.length > 0
6030
- ? html`
6031
- <sp-divider size="s"></sp-divider>
6032
- <span class="color-popover-swatches-label">Color Tokens</span>
6033
- <sp-swatch-group size="xs" border="light" rounding="none">
6034
- ${colorVars.map(
6035
- (cv) => html`
6036
- <sp-swatch
6037
- color=${cv.value}
6038
- .value=${cv.name}
6039
- title=${cv.name}
6040
- @click=${(/** @type {any} */ e) => {
6041
- e.stopPropagation();
6042
- const varRef = `var(${cv.name})`;
6043
- _colorCallback?.(varRef);
6044
- /** @type {any} */
6045
- const tf = popoverQuery(".color-popover-hex");
6046
- if (tf) tf.value = varRef;
6047
- }}
6048
- ></sp-swatch>
6049
- `,
6050
- )}
6051
- </sp-swatch-group>
6052
- `
6053
- : nothing}
6054
- </div>
6055
- </sp-popover>
6056
- `,
6057
- _colorPopoverHost,
6058
- );
6059
-
6060
- _colorCallback = onChange;
6061
-
6062
- // Dismiss on click-outside or Escape
6063
- if (_colorDismissHandler) {
6064
- document.removeEventListener("pointerdown", _colorDismissHandler, true);
6065
- document.removeEventListener("keydown", _colorDismissHandler, true);
6066
- }
6067
- _colorDismissHandler = (/** @type {any} */ e) => {
6068
- if (e.type === "keydown") {
6069
- if (e.key === "Escape") closeColorPopover();
6070
- return;
6071
- }
6072
- const popover = popoverQuery("sp-popover");
6073
- if (popover && !popover.contains(e.target) && !anchorEl.contains(e.target)) {
6074
- closeColorPopover();
6075
- }
6076
- };
6077
- requestAnimationFrame(() => {
6078
- document.addEventListener("pointerdown", _colorDismissHandler, true);
6079
- document.addEventListener("keydown", _colorDismissHandler, true);
6080
- });
6081
- }
6082
-
6083
- function safeColor(/** @type {any} */ val) {
6084
- if (!val) return "transparent";
6085
- return resolveColorForDisplay(val);
6086
- }
6087
-
6088
- function renderColorInput(
6089
- /** @type {any} */ prop,
6090
- /** @type {any} */ value,
6091
- /** @type {any} */ onChange,
6092
- ) {
6093
- return html`
6094
- <div class="style-input-color">
6095
- <sp-swatch
6096
- size="s"
6097
- rounding="none"
6098
- border="light"
6099
- color=${safeColor(value)}
6100
- @click=${(/** @type {any} */ e) => {
6101
- if (_colorPopoverHost.querySelector("sp-popover[open]")) {
6102
- closeColorPopover();
6103
- return;
6104
- }
6105
- openColorPopover(e.currentTarget, value, (/** @type {any} */ c) => {
6106
- onChange(c);
6107
- });
6108
- }}
6109
- ></sp-swatch>
6110
- <sp-textfield
6111
- size="s"
6112
- style="flex:1; min-width:0"
6113
- .value=${live(value || "")}
6114
- @input=${debouncedStyleCommit(`color:${prop}`, 400, (/** @type {any} */ e) => {
6115
- onChange(e.target.value.trim());
6116
- })}
6117
- ></sp-textfield>
6118
- </div>
6119
- `;
6120
- }
6121
-
6122
- function renderNumberUnitInput(
6123
- /** @type {any} */ entry,
6124
- /** @type {any} */ prop,
6125
- /** @type {any} */ value,
6126
- /** @type {any} */ onChange,
6127
- ) {
6128
- const units = entry.$units || [];
6129
- const keywords = entry.$keywords || [];
6130
- const strVal = String(value ?? "");
6131
- const match = strVal.match(UNIT_RE);
6132
- const isKeyword = !match && strVal !== "" && keywords.includes(strVal);
6133
- const isNumericVal = (/** @type {any} */ v) => /^-?\d*\.?\d*$/.test(v);
6134
-
6135
- const currentUnit = isKeyword ? units[0] || "" : match ? match[2] || "" : units[0] || "";
6136
- let displayValue;
6137
- if (isKeyword) displayValue = strVal;
6138
- else if (match) displayValue = match[1];
6139
- else if (strVal !== "") {
6140
- const num = parseFloat(strVal);
6141
- displayValue = isNaN(num) ? strVal : String(num);
6142
- } else displayValue = "";
6143
-
6144
- const isExpression = isKeyword || (displayValue !== "" && !isNumericVal(displayValue));
6145
- const hasUnits = units.length > 0 || keywords.length > 0;
6146
- const btnId = `style-unit-${prop}`;
6147
-
6148
- return html`
6149
- <div class="style-input-number-unit">
6150
- <div class=${classMap({ "input-group": true, "is-expression": isExpression })}>
6151
- <sp-textfield
6152
- size="s"
6153
- placeholder="0"
6154
- .value=${live(displayValue)}
6155
- @input=${debouncedStyleCommit(`nui:${prop}`, 400, (/** @type {any} */ e) => {
6156
- const val = (e.target.value ?? "").trim();
6157
- if (val === "") {
6158
- onChange("");
6159
- return;
6160
- }
6161
- if (isNumericVal(val)) onChange(units.length > 0 ? val + currentUnit : val);
6162
- else onChange(val);
6163
- })}
6164
- ></sp-textfield>
6165
- ${hasUnits
6166
- ? html`
6167
- <sp-picker-button id=${btnId} size="s">
6168
- <span slot="label">${currentUnit || units[0] || ""}</span>
6169
- </sp-picker-button>
6170
- <sp-overlay trigger="${btnId}@click" placement="bottom-end" offset="4">
6171
- <sp-popover style="min-width: var(--spectrum-component-width-900, 64px)">
6172
- <sp-menu
6173
- label="CSS unit"
6174
- @change=${(/** @type {any} */ e) => {
6175
- const chosen = e.target.value;
6176
- if (keywords.includes(chosen)) {
6177
- onChange(chosen);
6178
- } else if (units.includes(chosen)) {
6179
- // Re-commit with new unit
6180
- const curMatch = String(value ?? "").match(UNIT_RE);
6181
- const numPart = curMatch ? curMatch[1] : "";
6182
- if (numPart) onChange(numPart + chosen);
6183
- }
6184
- }}
6185
- >
6186
- ${units.map(
6187
- (/** @type {any} */ u) => html`<sp-menu-item value=${u}>${u}</sp-menu-item>`,
6188
- )}
6189
- ${keywords.length > 0 && units.length > 0
6190
- ? html`<sp-menu-divider></sp-menu-divider>`
6191
- : nothing}
6192
- ${keywords.map(
6193
- (/** @type {any} */ kw) =>
6194
- html`<sp-menu-item value=${kw}>${kw}</sp-menu-item>`,
6195
- )}
6196
- </sp-menu>
6197
- </sp-popover>
6198
- </sp-overlay>
6199
- `
6200
- : nothing}
6201
- </div>
6202
- </div>
6203
- `;
6204
- }
6205
-
6206
- // abbreviateValue — imported from studio-utils.js
6207
-
6208
- /** @param {any} entry @param {any} prop @param {any} value @param {any} onChange */
6209
- function renderButtonGroupInput(entry, prop, value, onChange) {
6210
- const values = entry.$buttonValues || entry.enum || [];
6211
- /** @type {Record<string, any>} */
6212
- const iconMap = entry.$icons || {};
6213
- const extra =
6214
- entry.$buttonValues && entry.enum && entry.enum.length > entry.$buttonValues.length
6215
- ? entry.enum.filter((/** @type {any} */ v) => !entry.$buttonValues.includes(v))
6216
- : [];
6217
-
6218
- const menuId = `style-btngrp-${prop}`;
6219
- const hasExtra = extra.length > 0;
6220
- // If the current value is one of the extra (non-button) options, show it selected in the picker
6221
- const extraSelected = hasExtra && extra.includes(value);
6222
-
6223
- return html`
6224
- <div class="button-group-combo ${hasExtra ? "has-overflow" : ""}">
6225
- <sp-action-group size="s" compact>
6226
- ${values.map(
6227
- (/** @type {any} */ v) => html`
6228
- <sp-action-button
6229
- size="s"
6230
- title=${v}
6231
- ?selected=${v === value}
6232
- @click=${() => onChange(v === value ? "" : v)}
6233
- >
6234
- ${
6235
- /** @type {any} */ (iconMap)[v] &&
6236
- /** @type {any} */ (icons)[/** @type {any} */ (iconMap)[v]]
6237
- ? /** @type {any} */ (icons)[/** @type {any} */ (iconMap)[v]]
6238
- : abbreviateValue(v)
6239
- }
6240
- </sp-action-button>
6241
- `,
6242
- )}
6243
- </sp-action-group>
6244
- ${hasExtra
6245
- ? html`
6246
- <sp-picker-button
6247
- size="s"
6248
- id=${menuId}
6249
- class=${extraSelected ? "has-selection" : ""}
6250
- ></sp-picker-button>
6251
- <sp-overlay trigger="${menuId}@click" placement="bottom-end" type="auto">
6252
- <sp-popover>
6253
- <sp-menu
6254
- @change=${(/** @type {any} */ e) => {
6255
- if (e.target.value) onChange(e.target.value);
6256
- }}
6257
- >
6258
- <sp-menu-item value="__none__">—</sp-menu-item>
6259
- ${extra.map((/** @type {any} */ v) => {
6260
- const label = v.includes("-")
6261
- ? kebabToLabel(v)
6262
- : v.replace(/^./, (/** @type {any} */ c) => c.toUpperCase());
6263
- return html`<sp-menu-item value=${v} ?selected=${v === value}
6264
- >${label}</sp-menu-item
6265
- >`;
6266
- })}
6267
- </sp-menu>
6268
- </sp-popover>
6269
- </sp-overlay>
6270
- `
6271
- : nothing}
6272
- </div>
6273
- `;
6274
- }
6275
-
6276
6585
  /** Typography CSS properties that should preview their values in-menu */
6277
6586
  const TYPO_PREVIEW_PROPS = new Set(["fontStyle", "fontVariant", "textTransform", "textDecoration"]);
6278
6587
 
@@ -6316,7 +6625,7 @@ function renderKeywordInput(options, prop, value, onChange) {
6316
6625
  return { value: v, label, style };
6317
6626
  });
6318
6627
 
6319
- return html`<jx-styled-combobox
6628
+ return html`<jx-value-selector
6320
6629
  size="s"
6321
6630
  .value=${value || ""}
6322
6631
  placeholder=${cssInitialMap.get(prop) || ""}
@@ -6325,7 +6634,7 @@ function renderKeywordInput(options, prop, value, onChange) {
6325
6634
  @input=${debouncedStyleCommit(`kw:${prop}`, 400, (/** @type {any} */ e) =>
6326
6635
  onChange(e.target.value),
6327
6636
  )}
6328
- ></jx-styled-combobox>`;
6637
+ ></jx-value-selector>`;
6329
6638
  }
6330
6639
 
6331
6640
  function renderSelectInput(
@@ -6382,7 +6691,7 @@ function handleFontSelection(
6382
6691
  }
6383
6692
 
6384
6693
  /**
6385
- * Build font options array for jx-styled-combobox. Local font vars first, divider, then unadded
6694
+ * Build font options array for jx-value-selector. Local font vars first, divider, then unadded
6386
6695
  * presets.
6387
6696
  *
6388
6697
  * @param {any[]} fontVars @param {any[]} presets
@@ -6420,13 +6729,13 @@ function renderComboboxInput(
6420
6729
  const presets = entry.presets || [];
6421
6730
  const examples = entry.examples || [];
6422
6731
 
6423
- // fontFamily: single jx-styled-combobox with font options
6732
+ // fontFamily: single jx-value-selector with font options
6424
6733
  if (prop === "fontFamily") {
6425
6734
  // Strip var() wrapper so the component can match the option value
6426
6735
  const varMatch = typeof value === "string" && value.match(/^var\((--[^)]+)\)$/);
6427
6736
  const comboValue = varMatch ? varMatch[1] : value || "";
6428
6737
  const fontOptions = buildFontOptions(fontVars, presets);
6429
- return html`<jx-styled-combobox
6738
+ return html`<jx-value-selector
6430
6739
  size="s"
6431
6740
  .value=${comboValue}
6432
6741
  placeholder=${cssInitialMap.get("fontFamily") || ""}
@@ -6435,7 +6744,7 @@ function renderComboboxInput(
6435
6744
  @input=${debouncedStyleCommit("combo:fontFamily", 400, (/** @type {any} */ e) =>
6436
6745
  onChange(e.target.value),
6437
6746
  )}
6438
- ></jx-styled-combobox>`;
6747
+ ></jx-value-selector>`;
6439
6748
  }
6440
6749
 
6441
6750
  // All other comboboxes: use the shared keyword dual-mode input
@@ -6456,45 +6765,7 @@ function renderComboboxInput(
6456
6765
  `;
6457
6766
  }
6458
6767
 
6459
- function renderNumberInput(
6460
- /** @type {any} */ entry,
6461
- /** @type {any} */ prop,
6462
- /** @type {any} */ value,
6463
- /** @type {any} */ onChange,
6464
- ) {
6465
- return html`
6466
- <sp-number-field
6467
- size="s"
6468
- hide-stepper
6469
- .value=${live(value !== undefined && value !== "" ? Number(value) : undefined)}
6470
- min=${ifDefined(entry.minimum)}
6471
- max=${ifDefined(entry.maximum)}
6472
- step=${ifDefined(entry.maximum !== undefined && entry.maximum <= 1 ? 0.1 : undefined)}
6473
- @change=${debouncedStyleCommit(`num:${prop}`, 400, (/** @type {any} */ e) => {
6474
- const v = e.target.value;
6475
- if (v === undefined || isNaN(v)) onChange("");
6476
- else onChange(Number(v));
6477
- })}
6478
- ></sp-number-field>
6479
- `;
6480
- }
6481
-
6482
- function renderTextInput(
6483
- /** @type {any} */ prop,
6484
- /** @type {any} */ value,
6485
- /** @type {any} */ onChange,
6486
- ) {
6487
- return html`
6488
- <sp-textfield
6489
- size="s"
6490
- placeholder=${cssInitialMap.get(prop) || ""}
6491
- .value=${live(value || "")}
6492
- @input=${debouncedStyleCommit(`text:${prop}`, 400, (/** @type {any} */ e) =>
6493
- onChange(e.target.value),
6494
- )}
6495
- ></sp-textfield>
6496
- `;
6497
- }
6768
+ // renderNumberInput, renderTextInput — imported from ui/widgets.js
6498
6769
 
6499
6770
  // camelToLabel, kebabToLabel, propLabel, attrLabel — imported from studio-utils.js
6500
6771
 
@@ -6504,23 +6775,13 @@ function widgetForType(
6504
6775
  /** @type {any} */ prop,
6505
6776
  /** @type {any} */ value,
6506
6777
  /** @type {any} */ onCommit,
6778
+ /** @type {any} */ opts = {},
6507
6779
  ) {
6508
- switch (type) {
6509
- case "button-group":
6510
- return renderButtonGroupInput(entry, prop, value, onCommit);
6511
- case "color":
6512
- return renderColorInput(prop, value, onCommit);
6513
- case "number-unit":
6514
- return renderNumberUnitInput(entry, prop, value, onCommit);
6515
- case "number":
6516
- return renderNumberInput(entry, prop, value, onCommit);
6517
- case "select":
6518
- return renderSelectInput(entry, prop, value, onCommit);
6519
- case "combobox":
6520
- return renderComboboxInput(entry, prop, value, onCommit);
6521
- default:
6522
- return renderTextInput(prop, value, onCommit);
6523
- }
6780
+ return _widgetForType(type, entry, prop, value, onCommit, {
6781
+ placeholder: opts.placeholder || cssInitialMap.get(prop) || "",
6782
+ renderSelect: renderSelectInput,
6783
+ renderCombobox: renderComboboxInput,
6784
+ });
6524
6785
  }
6525
6786
 
6526
6787
  function renderStyleRow(
@@ -6531,31 +6792,20 @@ function renderStyleRow(
6531
6792
  /** @type {any} */ onDelete,
6532
6793
  /** @type {any} */ isWarning,
6533
6794
  /** @type {any} */ gridMode,
6795
+ /** @type {any} */ inheritedValue,
6534
6796
  ) {
6535
6797
  const type = inferInputType(entry);
6536
6798
  const hasVal = value !== undefined && value !== "";
6537
- return html`
6538
- <div
6539
- class=${classMap({ "style-row": true, "style-row--warning": isWarning })}
6540
- data-prop=${prop}
6541
- style=${gridMode && entry.$span === 2 ? "grid-column: 1 / -1" : ""}
6542
- >
6543
- <div class="style-row-label">
6544
- ${hasVal
6545
- ? html`<span
6546
- class="set-dot"
6547
- title="Clear ${prop}"
6548
- @click=${(/** @type {any} */ e) => {
6549
- e.stopPropagation();
6550
- onDelete();
6551
- }}
6552
- ></span>`
6553
- : nothing}
6554
- <sp-field-label size="s" title=${prop}>${propLabel(entry, prop)}</sp-field-label>
6555
- </div>
6556
- ${widgetForType(type, entry, prop, value, onCommit)}
6557
- </div>
6558
- `;
6799
+ const placeholder = !hasVal && inheritedValue ? String(inheritedValue) : "";
6800
+ return renderFieldRow({
6801
+ prop,
6802
+ label: propLabel(entry, prop),
6803
+ hasValue: hasVal,
6804
+ onClear: onDelete,
6805
+ widget: widgetForType(type, entry, prop, value, onCommit, { placeholder }),
6806
+ span: gridMode && entry.$span === 2 ? 2 : undefined,
6807
+ warning: isWarning,
6808
+ });
6559
6809
  }
6560
6810
 
6561
6811
  function renderShorthandRow(
@@ -6564,12 +6814,14 @@ function renderShorthandRow(
6564
6814
  /** @type {any} */ style,
6565
6815
  /** @type {any} */ commitFn,
6566
6816
  /** @type {any} */ _deleteFn,
6817
+ /** @type {Record<string, any>} */ inherited = {},
6567
6818
  ) {
6568
6819
  const longhands = getLonghands(shortProp);
6569
6820
  const shortVal = style[shortProp];
6570
- const hasLonghands = longhands.some((l) => style[l.name] !== undefined);
6821
+ const hasLonghands = longhands.some((/** @type {any} */ l) => style[l.name] !== undefined);
6571
6822
  const isExpanded = S.ui.styleShorthands[shortProp] ?? hasLonghands;
6572
- const hasAnyVal = shortVal !== undefined || longhands.some((l) => style[l.name] !== undefined);
6823
+ const hasAnyVal =
6824
+ shortVal !== undefined || longhands.some((/** @type {any} */ l) => style[l.name] !== undefined);
6573
6825
 
6574
6826
  return html`
6575
6827
  <div class="style-row" data-prop=${shortProp}>
@@ -6596,8 +6848,12 @@ function renderShorthandRow(
6596
6848
  size="s"
6597
6849
  .value=${live(shortVal || "")}
6598
6850
  placeholder=${!shortVal && hasLonghands
6599
- ? longhands.map((l) => style[l.name] || "0").join(" ")
6600
- : ""}
6851
+ ? longhands.map((/** @type {any} */ l) => style[l.name] || "0").join(" ")
6852
+ : !shortVal && inherited[shortProp]
6853
+ ? inherited[shortProp]
6854
+ : !shortVal && longhands.some((/** @type {any} */ l) => inherited[l.name])
6855
+ ? longhands.map((/** @type {any} */ l) => inherited[l.name] || "0").join(" ")
6856
+ : ""}
6601
6857
  @input=${debouncedStyleCommit(`short:${shortProp}`, 400, (/** @type {any} */ e) => {
6602
6858
  let s = S;
6603
6859
  for (const l of longhands) {
@@ -6629,33 +6885,74 @@ function renderShorthandRow(
6629
6885
  </div>
6630
6886
  </div>
6631
6887
  ${isExpanded
6632
- ? longhands.map(({ name, entry: lEntry }) => {
6633
- const lVal = style[name] ?? "";
6634
- return html`
6635
- <div class="style-row style-row--child" data-prop=${name}>
6636
- <div class="style-row-label">
6637
- ${lVal !== undefined && lVal !== ""
6638
- ? html`<span
6639
- class="set-dot"
6640
- title="Clear ${name}"
6641
- @click=${(/** @type {any} */ e) => {
6642
- e.stopPropagation();
6643
- update(commitFn(S, name, undefined));
6644
- }}
6645
- ></span>`
6646
- : nothing}
6647
- <sp-field-label size="s" title=${name}>${propLabel(lEntry, name)}</sp-field-label>
6648
- </div>
6649
- ${widgetForType(
6650
- inferInputType(lEntry),
6651
- lEntry,
6652
- name,
6653
- lVal,
6654
- (/** @type {any} */ newVal) => update(commitFn(S, name, newVal || undefined)),
6655
- )}
6656
- </div>
6657
- `;
6658
- })
6888
+ ? (() => {
6889
+ const isBorderSide = entry.$shorthandType === "border-side";
6890
+ const expanded = shortVal
6891
+ ? isBorderSide
6892
+ ? expandBorderSide(shortVal)
6893
+ : expandShorthand(shortVal, longhands.length)
6894
+ : null;
6895
+ const compress = isBorderSide ? compressBorderSide : compressShorthand;
6896
+ const emptyVal = isBorderSide ? "" : "0";
6897
+ return longhands.map(
6898
+ (/** @type {any} */ { name, entry: lEntry }, /** @type {any} */ idx) => {
6899
+ const lVal = style[name] ?? (expanded ? expanded[idx] : "");
6900
+ return html`
6901
+ <div class="style-row style-row--child" data-prop=${name}>
6902
+ <div class="style-row-label">
6903
+ ${lVal !== undefined && lVal !== ""
6904
+ ? html`<span
6905
+ class="set-dot"
6906
+ title="Clear ${name}"
6907
+ @click=${(/** @type {any} */ e) => {
6908
+ e.stopPropagation();
6909
+ // Recompose shorthand with this longhand cleared
6910
+ const vals = longhands.map(
6911
+ (/** @type {any} */ l, /** @type {any} */ i) =>
6912
+ i === idx
6913
+ ? emptyVal
6914
+ : (style[l.name] ?? (expanded ? expanded[i] : emptyVal)),
6915
+ );
6916
+ let s = S;
6917
+ for (const l of longhands) {
6918
+ if (style[l.name] !== undefined) s = commitFn(s, l.name, undefined);
6919
+ }
6920
+ s = commitFn(s, shortProp, compress(vals));
6921
+ update(s);
6922
+ }}
6923
+ ></span>`
6924
+ : nothing}
6925
+ <sp-field-label size="s" title=${name}
6926
+ >${propLabel(lEntry, name)}</sp-field-label
6927
+ >
6928
+ </div>
6929
+ ${widgetForType(
6930
+ inferInputType(lEntry),
6931
+ lEntry,
6932
+ name,
6933
+ lVal,
6934
+ (/** @type {any} */ newVal) => {
6935
+ // Recompose shorthand with this longhand updated
6936
+ const vals = longhands.map((/** @type {any} */ l, /** @type {any} */ i) =>
6937
+ i === idx
6938
+ ? newVal || emptyVal
6939
+ : (style[l.name] ?? (expanded ? expanded[i] : emptyVal)),
6940
+ );
6941
+ let s = S;
6942
+ for (const l of longhands) {
6943
+ if (style[l.name] !== undefined) s = commitFn(s, l.name, undefined);
6944
+ }
6945
+ s = commitFn(s, shortProp, compress(vals));
6946
+ update(s);
6947
+ renderRightPanel();
6948
+ },
6949
+ { placeholder: !lVal && inherited[name] ? String(inherited[name]) : "" },
6950
+ )}
6951
+ </div>
6952
+ `;
6953
+ },
6954
+ );
6955
+ })()
6659
6956
  : nothing}
6660
6957
  `;
6661
6958
  }
@@ -6674,30 +6971,20 @@ function styleSidebarTemplate(
6674
6971
  const mediaTabsT =
6675
6972
  mediaNames.length > 0
6676
6973
  ? html`
6677
- <sp-tabs size="s">
6678
- <sp-tab
6679
- label="Base"
6680
- value="base"
6681
- ?selected=${activeTab === null}
6682
- @click=${() => {
6683
- S = { ...S, ui: { ...S.ui, activeMedia: null } };
6684
- updateActivePanelHeaders();
6685
- renderRightPanel();
6686
- }}
6687
- ></sp-tab>
6974
+ <sp-tabs
6975
+ size="s"
6976
+ selected=${activeTab || "base"}
6977
+ @change=${(/** @type {any} */ e) => {
6978
+ const val = e.target.selected;
6979
+ const newMedia = val === "base" ? null : val;
6980
+ if (newMedia !== S.ui.activeMedia) {
6981
+ updateUi("activeMedia", newMedia);
6982
+ }
6983
+ }}
6984
+ >
6985
+ <sp-tab label="Base" value="base"></sp-tab>
6688
6986
  ${mediaNames.map(
6689
- (name) => html`
6690
- <sp-tab
6691
- label=${mediaDisplayName(name)}
6692
- value=${name}
6693
- ?selected=${activeTab === name}
6694
- @click=${() => {
6695
- S = { ...S, ui: { ...S.ui, activeMedia: name } };
6696
- updateActivePanelHeaders();
6697
- renderRightPanel();
6698
- }}
6699
- ></sp-tab>
6700
- `,
6987
+ (name) => html` <sp-tab label=${mediaDisplayName(name)} value=${name}></sp-tab> `,
6701
6988
  )}
6702
6989
  </sp-tabs>
6703
6990
  `
@@ -6743,8 +7030,7 @@ function styleSidebarTemplate(
6743
7030
  inp.remove();
6744
7031
  picker.style.display = "";
6745
7032
  if (accept && v && isNestedSelector(v)) {
6746
- S = { ...S, ui: { ...S.ui, activeSelector: v } };
6747
- renderRightPanel();
7033
+ updateUi("activeSelector", v);
6748
7034
  }
6749
7035
  };
6750
7036
  inp.addEventListener("keydown", (ev) => {
@@ -6755,8 +7041,7 @@ function styleSidebarTemplate(
6755
7041
  return;
6756
7042
  }
6757
7043
  const newSelector = val === "__base__" ? null : val;
6758
- S = { ...S, ui: { ...S.ui, activeSelector: newSelector } };
6759
- renderRightPanel();
7044
+ updateUi("activeSelector", newSelector);
6760
7045
  }}
6761
7046
  >
6762
7047
  <sp-menu-item value="__base__">(base)</sp-menu-item>
@@ -6807,10 +7092,15 @@ function styleSidebarTemplate(
6807
7092
  updateStyle(s, S.selection, prop, val);
6808
7093
  }
6809
7094
 
7095
+ // ── Compute inherited style from higher breakpoints ──────────────────────
7096
+ /** @type {Record<string, any>} */
7097
+ const inheritedStyle = computeInheritedStyle(style, mediaNames, activeTab, activeSelector);
7098
+
6810
7099
  // Auto-open sections that have properties
6811
7100
  const newSections = autoOpenSections({ style: activeStyle }, S.ui.styleSections);
6812
7101
  if (JSON.stringify(newSections) !== JSON.stringify(S.ui.styleSections)) {
6813
- S = { ...S, ui: { ...S.ui, styleSections: newSections } };
7102
+ session = { ...session, ui: { ...session.ui, styleSections: newSections } };
7103
+ S = toFlat(doc, session);
6814
7104
  }
6815
7105
 
6816
7106
  // Partition properties into sections
@@ -6842,7 +7132,9 @@ function styleSidebarTemplate(
6842
7132
  const sectionActiveProps = entries.filter((/** @type {any} */ { prop, entry }) => {
6843
7133
  if (activeStyle[prop] !== undefined) return true;
6844
7134
  if (inferInputType(entry) === "shorthand") {
6845
- return getLonghands(prop).some((l) => activeStyle[l.name] !== undefined);
7135
+ return getLonghands(prop).some(
7136
+ (/** @type {any} */ l) => activeStyle[l.name] !== undefined,
7137
+ );
6846
7138
  }
6847
7139
  return false;
6848
7140
  });
@@ -6857,9 +7149,12 @@ function styleSidebarTemplate(
6857
7149
 
6858
7150
  if (type === "shorthand") {
6859
7151
  const longhands = getLonghands(prop);
6860
- const hasAny = hasVal || longhands.some((l) => activeStyle[l.name] !== undefined);
7152
+ const hasAny =
7153
+ hasVal || longhands.some((/** @type {any} */ l) => activeStyle[l.name] !== undefined);
6861
7154
  if (!hasAny && !condMet) continue;
6862
- rows.push(renderShorthandRow(prop, entry, activeStyle, commitStyle, () => {}));
7155
+ rows.push(
7156
+ renderShorthandRow(prop, entry, activeStyle, commitStyle, () => {}, inheritedStyle),
7157
+ );
6863
7158
  } else {
6864
7159
  const isWarning = hasVal && !condMet;
6865
7160
  if (hasVal || condMet) {
@@ -6872,6 +7167,7 @@ function styleSidebarTemplate(
6872
7167
  () => update(commitStyle(S, prop, undefined)),
6873
7168
  isWarning,
6874
7169
  sec.$layout === "grid",
7170
+ inheritedStyle[prop],
6875
7171
  ),
6876
7172
  );
6877
7173
  }
@@ -7003,7 +7299,7 @@ function styleSidebarTemplate(
7003
7299
 
7004
7300
  /** Top-level Style panel — returns a lit-html template */
7005
7301
  function renderStylePanelTemplate() {
7006
- if (canvasMode === "stylebook" && S.ui.stylebookSelection) {
7302
+ if (canvasMode === "settings" && S.ui.stylebookSelection) {
7007
7303
  const node = S.document;
7008
7304
  if (!node) return html`<div class="empty-state">No document loaded</div>`;
7009
7305
  return html`
@@ -7227,8 +7523,7 @@ function _renderSourceView(/** @type {any} */ container) {
7227
7523
  @blur=${(/** @type {any} */ e) => {
7228
7524
  try {
7229
7525
  const parsed = JSON.parse(e.target.value);
7230
- S = { ...S, document: parsed, dirty: true };
7231
- render();
7526
+ update({ ...S, document: parsed, dirty: true });
7232
7527
  } catch {}
7233
7528
  }}
7234
7529
  ></textarea>
@@ -7253,29 +7548,31 @@ function renderFunctionEditor() {
7253
7548
  const editing = S.ui.editingFunction;
7254
7549
 
7255
7550
  // If editor already exists and matches current target, just sync value
7256
- if (functionEditor && functionEditor._editingTarget === JSON.stringify(editing)) {
7551
+ if (view.functionEditor && view.functionEditor._editingTarget === JSON.stringify(editing)) {
7257
7552
  const body = getFunctionBody(editing);
7258
- const currentVal = functionEditor.getValue();
7553
+ const currentVal = view.functionEditor.getValue();
7259
7554
  if (currentVal !== body) {
7260
- functionEditor._ignoreNextChange = true;
7261
- functionEditor.setValue(body);
7555
+ view.functionEditor._ignoreNextChange = true;
7556
+ view.functionEditor.setValue(body);
7262
7557
  }
7263
7558
  return;
7264
7559
  }
7265
7560
 
7266
7561
  // Dispose previous editors
7267
- if (functionEditor) {
7268
- functionEditor.dispose();
7269
- functionEditor = null;
7562
+ if (view.functionEditor) {
7563
+ view.functionEditor.dispose();
7564
+ view.functionEditor = null;
7270
7565
  }
7271
- if (monacoEditor) {
7272
- monacoEditor.dispose();
7273
- monacoEditor = null;
7566
+ if (view.monacoEditor) {
7567
+ view.monacoEditor.dispose();
7568
+ view.monacoEditor = null;
7274
7569
  }
7275
7570
 
7276
- // Clean up canvas DnD
7277
- for (const fn of canvasDndCleanups) fn();
7278
- canvasDndCleanups = [];
7571
+ // Clean up canvas DnD and event handlers
7572
+ for (const fn of view.canvasDndCleanups) fn();
7573
+ view.canvasDndCleanups = [];
7574
+ for (const fn of view.canvasEventCleanups) fn();
7575
+ view.canvasEventCleanups = [];
7279
7576
  canvasPanels.length = 0;
7280
7577
 
7281
7578
  litRender(nothing, canvasWrap);
@@ -7300,7 +7597,7 @@ function renderFunctionEditor() {
7300
7597
  const body = getFunctionBody(editing);
7301
7598
  const args = getFunctionArgs(editing, S);
7302
7599
 
7303
- functionEditor = monaco.editor.create(/** @type {any} */ (editorContainer), {
7600
+ view.functionEditor = monaco.editor.create(/** @type {any} */ (editorContainer), {
7304
7601
  value: body,
7305
7602
  language: "javascript",
7306
7603
  theme: "vs-dark",
@@ -7313,17 +7610,18 @@ function renderFunctionEditor() {
7313
7610
  wordWrap: "on",
7314
7611
  tabSize: 2,
7315
7612
  });
7316
- functionEditor._editingTarget = JSON.stringify(editing);
7613
+ view.functionEditor._editingTarget = JSON.stringify(editing);
7317
7614
 
7318
7615
  // Format on open — show pretty-printed code, then run initial lint
7319
7616
  codeService("format", { code: body, args }).then((result) => {
7320
- if (result?.code != null && functionEditor) {
7321
- functionEditor._ignoreNextChange = true;
7322
- functionEditor.setValue(result.code);
7617
+ if (result?.code != null && view.functionEditor) {
7618
+ view.functionEditor._ignoreNextChange = true;
7619
+ view.functionEditor.setValue(result.code);
7323
7620
  }
7324
7621
  });
7325
7622
  codeService("lint", { code: body, args }).then((result) => {
7326
- if (result?.diagnostics && functionEditor) setLintMarkers(functionEditor, result.diagnostics);
7623
+ if (result?.diagnostics && view.functionEditor)
7624
+ setLintMarkers(view.functionEditor, result.diagnostics);
7327
7625
  });
7328
7626
 
7329
7627
  // Debounced sync back to state + lint on edit
@@ -7332,15 +7630,15 @@ function renderFunctionEditor() {
7332
7630
  /** @type {any} */
7333
7631
  let lintDebounce;
7334
7632
  let lintGen = 0;
7335
- functionEditor.onDidChangeModelContent(() => {
7336
- if (functionEditor._ignoreNextChange) {
7337
- functionEditor._ignoreNextChange = false;
7633
+ view.functionEditor.onDidChangeModelContent(() => {
7634
+ if (view.functionEditor._ignoreNextChange) {
7635
+ view.functionEditor._ignoreNextChange = false;
7338
7636
  return;
7339
7637
  }
7340
7638
 
7341
7639
  clearTimeout(syncDebounce);
7342
7640
  syncDebounce = setTimeout(() => {
7343
- const newBody = functionEditor.getValue();
7641
+ const newBody = view.functionEditor.getValue();
7344
7642
  if (editing.type === "def") {
7345
7643
  update(updateDef(S, editing.defName, { body: newBody }));
7346
7644
  } else if (editing.type === "event") {
@@ -7360,11 +7658,11 @@ function renderFunctionEditor() {
7360
7658
  clearTimeout(lintDebounce);
7361
7659
  lintDebounce = setTimeout(() => {
7362
7660
  const gen = ++lintGen;
7363
- const currentCode = functionEditor.getValue();
7661
+ const currentCode = view.functionEditor.getValue();
7364
7662
  codeService("lint", { code: currentCode, args }).then((result) => {
7365
7663
  if (gen !== lintGen) return;
7366
- if (result?.diagnostics && functionEditor)
7367
- setLintMarkers(functionEditor, result.diagnostics);
7664
+ if (result?.diagnostics && view.functionEditor)
7665
+ setLintMarkers(view.functionEditor, result.diagnostics);
7368
7666
  });
7369
7667
  }, 750);
7370
7668
  });
@@ -7414,166 +7712,10 @@ function registerFunctionCompletions() {
7414
7712
  });
7415
7713
  }
7416
7714
 
7417
- // ─── Toolbar ──────────────────────────────────────────────────────────────────
7715
+ // ─── Toolbar (delegated to panels/toolbar.js) ────────────────────────────────
7418
7716
 
7419
7717
  function renderToolbar() {
7420
- const hasStack = S.documentStack && S.documentStack.length > 0;
7421
- const hasFunc = !!S.ui.editingFunction;
7422
-
7423
- // Breadcrumb template
7424
- const breadcrumbTpl =
7425
- hasStack || hasFunc
7426
- ? html`
7427
- <div class="breadcrumb">
7428
- <sp-action-button
7429
- size="s"
7430
- title=${hasFunc ? "Close function editor" : "Return to parent document"}
7431
- @click=${hasFunc ? closeFunctionEditor : navigateBack}
7432
- >
7433
- ${toolbarIconMap["sp-icon-back"]}Back
7434
- </sp-action-button>
7435
- ${hasStack
7436
- ? S.documentStack.map(
7437
- (/** @type {any} */ frame) => html`
7438
- <span class="breadcrumb-item"
7439
- >${frame.documentPath?.split("/").pop() || "untitled"}</span
7440
- >
7441
- <span class="breadcrumb-sep"> › </span>
7442
- `,
7443
- )
7444
- : nothing}
7445
- <span
7446
- class="breadcrumb-item${hasFunc ? " clickable" : " current"}"
7447
- @click=${hasFunc ? closeFunctionEditor : nothing}
7448
- >
7449
- ${S.documentPath?.split("/").pop() || S.document.tagName || "document"}
7450
- </span>
7451
- ${hasFunc
7452
- ? html`
7453
- <span class="breadcrumb-sep"> › </span>
7454
- <span class="breadcrumb-item current"
7455
- >${S.ui.editingFunction.type === "def"
7456
- ? `ƒ ${S.ui.editingFunction.defName}`
7457
- : `ƒ ${S.ui.editingFunction.eventKey}`}</span
7458
- >
7459
- `
7460
- : nothing}
7461
- </div>
7462
- `
7463
- : nothing;
7464
-
7465
- // Feature toggles
7466
- const { featureQueries } = parseMediaEntries(getEffectiveMedia(S.document.$media));
7467
- const togglesTpl =
7468
- featureQueries.length > 0
7469
- ? html`
7470
- <sp-action-group compact size="s">
7471
- ${featureQueries.map(
7472
- ({ name, query }) => html`
7473
- <sp-action-button
7474
- toggles
7475
- size="s"
7476
- title=${query}
7477
- ?selected=${!!S.ui.featureToggles[name]}
7478
- @click=${() => {
7479
- const newToggles = {
7480
- ...S.ui.featureToggles,
7481
- [name]: !S.ui.featureToggles[name],
7482
- };
7483
- S = { ...S, ui: { ...S.ui, featureToggles: newToggles } };
7484
- renderCanvas();
7485
- renderOverlays();
7486
- renderToolbar();
7487
- }}
7488
- >
7489
- ${mediaDisplayName(name)}
7490
- </sp-action-button>
7491
- `,
7492
- )}
7493
- </sp-action-group>
7494
- `
7495
- : nothing;
7496
-
7497
- // Mode switcher
7498
- const modes = [
7499
- { key: "edit", label: "Edit", iconTag: "sp-icon-edit" },
7500
- { key: "design", label: "Design", iconTag: "sp-icon-artboard" },
7501
- { key: "preview", label: "Preview", iconTag: "sp-icon-preview" },
7502
- { key: "source", label: "Code", iconTag: "sp-icon-code" },
7503
- { key: "stylebook", label: "Stylebook", iconTag: "sp-icon-brush" },
7504
- ];
7505
-
7506
- const modeSwitcherTpl = html`
7507
- <sp-action-group selects="single" size="s" compact>
7508
- ${modes.map(
7509
- (m) => html`
7510
- <sp-action-button
7511
- size="s"
7512
- ?selected=${canvasMode === m.key}
7513
- @click=${() => {
7514
- if (canvasMode === m.key) return;
7515
- if (S.ui.editingFunction) {
7516
- if (functionEditor) {
7517
- functionEditor.dispose();
7518
- functionEditor = null;
7519
- }
7520
- S = { ...S, ui: { ...S.ui, editingFunction: null } };
7521
- }
7522
- canvasMode = m.key;
7523
- panX = 0;
7524
- panY = 0;
7525
- renderCanvas();
7526
- renderOverlays();
7527
- renderToolbar();
7528
- renderLeftPanel();
7529
- if (m.key === "stylebook") {
7530
- S = { ...S, ui: { ...S.ui, rightTab: "style" } };
7531
- renderRightPanel();
7532
- }
7533
- }}
7534
- >
7535
- ${toolbarIconMap[m.iconTag]}${m.label}
7536
- </sp-action-button>
7537
- `,
7538
- )}
7539
- </sp-action-group>
7540
- `;
7541
-
7542
- const tpl = html`
7543
- <sp-action-group compact size="s">
7544
- ${tbBtnTpl("Open Project", openProject, "sp-icon-folder-open")}
7545
- ${tbBtnTpl("Open File", openFile, "sp-icon-document")}
7546
- ${tbBtnTpl("Save", saveFile, "sp-icon-save-floppy")}
7547
- ${S.fileHandle ? html`<span class="tb-filename">${S.fileHandle.name}</span>` : nothing}
7548
- ${S.dirty ? html`<span class="tb-dirty">●</span>` : nothing}
7549
- </sp-action-group>
7550
- ${breadcrumbTpl}
7551
- <sp-action-group compact size="s">
7552
- ${tbBtnTpl("Undo", () => update(undo(S)), "sp-icon-undo")}
7553
- ${tbBtnTpl("Redo", () => update(redo(S)), "sp-icon-redo")}
7554
- </sp-action-group>
7555
- <sp-action-group compact size="s">
7556
- ${tbBtnTpl(
7557
- "Duplicate",
7558
- () => {
7559
- if (S.selection) update(duplicateNode(S, S.selection));
7560
- },
7561
- "sp-icon-duplicate",
7562
- )}
7563
- ${tbBtnTpl(
7564
- "Delete",
7565
- () => {
7566
- if (S.selection) update(removeNode(S, S.selection));
7567
- },
7568
- "sp-icon-delete",
7569
- )}
7570
- </sp-action-group>
7571
- ${togglesTpl}
7572
- <div class="tb-spacer"></div>
7573
- ${modeSwitcherTpl}
7574
- `;
7575
-
7576
- litRender(tpl, toolbar);
7718
+ toolbarPanel.render();
7577
7719
  }
7578
7720
 
7579
7721
  // ─── File Operations (delegated to file-ops.js) ─────────────────────────────
@@ -7591,13 +7733,16 @@ function fileOpsCtx() {
7591
7733
  function openFile() {
7592
7734
  return _openFile(fileOpsCtx());
7593
7735
  }
7594
- function loadMarkdown(/** @type {any} */ source, /** @type {any} */ fileHandle) {
7595
- const ns = _loadMarkdown(source, fileHandle);
7736
+ async function loadMarkdown(/** @type {any} */ source, /** @type {any} */ fileHandle) {
7737
+ const ns = await _loadMarkdown(source, fileHandle);
7596
7738
  S = ns;
7597
7739
  }
7598
7740
  function saveFile() {
7599
7741
  return _saveFile(fileOpsCtx());
7600
7742
  }
7743
+ function exportFile() {
7744
+ return _exportFile(fileOpsCtx());
7745
+ }
7601
7746
 
7602
7747
  // ─── File tree (delegated to files.js) ───────────────────────────────────────
7603
7748
 
@@ -7620,7 +7765,12 @@ function renderFilesTemplate() {
7620
7765
  function openFileFromTree(/** @type {any} */ path) {
7621
7766
  return _openFileFromTree(
7622
7767
  {
7623
- S,
7768
+ get S() {
7769
+ return S;
7770
+ },
7771
+ set S(v) {
7772
+ S = v;
7773
+ },
7624
7774
  commit: (/** @type {any} */ ns) => {
7625
7775
  S = ns;
7626
7776
  },
@@ -7638,16 +7788,16 @@ initShortcuts(() => ({
7638
7788
  S = ns;
7639
7789
  },
7640
7790
  canvasMode,
7641
- panX,
7642
- panY,
7791
+ panX: view.panX,
7792
+ panY: view.panY,
7643
7793
  setPan: (x, y) => {
7644
- panX = x;
7645
- panY = y;
7646
- needsCenter = false;
7794
+ view.panX = x;
7795
+ view.panY = y;
7796
+ view.needsCenter = false;
7647
7797
  },
7648
7798
  applyTransform,
7649
7799
  positionZoomIndicator,
7650
- componentInlineEdit,
7800
+ componentInlineEdit: view.componentInlineEdit,
7651
7801
  saveFile,
7652
7802
  openProject,
7653
7803
  enterEditOnPath(path) {
@@ -7678,8 +7828,7 @@ function scheduleAutosave() {
7678
7828
  const writable = await S.fileHandle.createWritable();
7679
7829
  await writable.write(JSON.stringify(S.document, null, 2));
7680
7830
  await writable.close();
7681
- S = { ...S, dirty: false };
7682
- renderToolbar();
7831
+ update({ ...S, dirty: false });
7683
7832
  statusMessage("Auto-saved");
7684
7833
  } catch {}
7685
7834
  }
@@ -7689,4 +7838,3 @@ function scheduleAutosave() {
7689
7838
  addUpdateMiddleware((/** @type {any} */ state) => {
7690
7839
  if (state.dirty) scheduleAutosave();
7691
7840
  });
7692
- // trigger rebuild