@jxsuite/studio 0.1.0 → 0.5.1

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 (40) hide show
  1. package/dist/studio.js +50941 -34749
  2. package/dist/studio.js.map +461 -345
  3. package/package.json +46 -35
  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 +133 -0
  16. package/src/panels/right-panel.js +130 -0
  17. package/src/panels/shared.js +41 -0
  18. package/src/panels/signals-panel.js +95 -94
  19. package/src/panels/statusbar.js +15 -1
  20. package/src/panels/toolbar.js +223 -0
  21. package/src/platforms/devserver.js +58 -16
  22. package/src/settings/collections-editor.js +428 -0
  23. package/src/settings/defs-editor.js +418 -0
  24. package/src/settings/schema-field-ui.js +329 -0
  25. package/src/state.js +99 -2
  26. package/src/store.js +112 -41
  27. package/src/studio.js +1551 -1565
  28. package/src/ui/button-group.js +91 -0
  29. package/src/ui/color-selector.js +299 -0
  30. package/src/ui/field-row.js +47 -0
  31. package/src/ui/media-picker.js +172 -0
  32. package/src/ui/panel-resize.js +96 -0
  33. package/src/ui/spectrum.js +36 -2
  34. package/src/ui/unit-selector.js +106 -0
  35. package/src/ui/{jx-styled-combobox.js → value-selector.js} +7 -7
  36. package/src/ui/widgets.js +106 -0
  37. package/src/utils/canvas-media.js +151 -0
  38. package/src/utils/inherited-style.js +54 -0
  39. package/src/utils/studio-utils.js +32 -0
  40. package/src/view.js +68 -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,
@@ -40,7 +37,6 @@ import {
40
37
  isAncestor,
41
38
  canvasWrap,
42
39
  leftPanel,
43
- rightPanel,
44
40
  toolbarEl,
45
41
  elToPath,
46
42
  canvasPanels,
@@ -58,11 +54,22 @@ import {
58
54
  runUpdateMiddleware,
59
55
  addPostRenderHook,
60
56
  runPostRenderHooks,
57
+ notify,
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,17 +93,23 @@ 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,
94
101
  } from "./utils/studio-utils.js";
95
- import { renderStatusbar, statusMessage, setStatusbarRenderer } from "./panels/statusbar.js";
102
+ import {
103
+ renderStatusbar,
104
+ statusMessage,
105
+ setStatusbarRenderer,
106
+ mountStatusbar,
107
+ } from "./panels/statusbar.js";
96
108
  import {
97
109
  openFile as _openFile,
98
110
  loadMarkdown as _loadMarkdown,
99
111
  saveFile as _saveFile,
112
+ exportFile as _exportFile,
100
113
  } from "./files/file-ops.js";
101
114
  import {
102
115
  loadProject as _loadProject,
@@ -107,9 +120,17 @@ import {
107
120
  } from "./files/files.js";
108
121
  import { eventsSidebarTemplate as _eventsSidebarTemplate } from "./panels/events-panel.js";
109
122
  import { renderImportsTemplate } from "./panels/imports-panel.js";
123
+ import { renderHeadTemplate } from "./panels/head-panel.js";
110
124
  import { exportCemManifest as _exportCemManifest } from "./services/cem-export.js";
111
125
 
112
126
  import { registerPlatform, getPlatform, hasPlatform } from "./platform.js";
127
+ import {
128
+ parseMediaEntries,
129
+ activeBreakpointsForWidth,
130
+ applyCanvasStyle,
131
+ collectMediaOverrides,
132
+ applyOverridesToCanvas,
133
+ } from "./utils/canvas-media.js";
113
134
  import { createDevServerPlatform } from "./platforms/devserver.js";
114
135
  import { codeService, setLintMarkers, getFunctionArgs } from "./services/code-services.js";
115
136
  import {
@@ -146,7 +167,6 @@ import {
146
167
 
147
168
  import { html, render as litRender, nothing } from "lit-html";
148
169
  import { live } from "lit-html/directives/live.js";
149
- import { classMap } from "lit-html/directives/class-map.js";
150
170
  import { ref } from "lit-html/directives/ref.js";
151
171
  import { styleMap } from "lit-html/directives/style-map.js";
152
172
  import { ifDefined } from "lit-html/directives/if-defined.js";
@@ -161,10 +181,21 @@ import { renderDataExplorerTemplate } from "./panels/data-explorer.js";
161
181
  // Explicit class imports + registration — bare side-effect imports are tree-shaken
162
182
  // by Bun's bundler despite sideEffects declarations in Spectrum's package.json.
163
183
  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";
184
+ import { renderFieldRow } from "./ui/field-row.js";
185
+ import { widgetForType as _widgetForType } from "./ui/widgets.js";
186
+ import { computeInheritedStyle } from "./utils/inherited-style.js";
187
+ import "./ui/panel-resize.js";
188
+ import { showContextMenu, dismissContextMenu } from "./editor/context-menu.js";
189
+ import { convertToComponent } from "./editor/convert-to-component.js";
166
190
  import { initShortcuts } from "./editor/shortcuts.js";
167
- import { renderActivityBar, tabIcon } from "./panels/activity-bar.js";
191
+ import { renderActivityBar } from "./panels/activity-bar.js";
192
+ import { renderBrowse } from "./browse/browse.js";
193
+ import { renderCollectionsEditor } from "./settings/collections-editor.js";
194
+ import { renderDefsEditor } from "./settings/defs-editor.js";
195
+ import * as toolbarPanel from "./panels/toolbar.js";
196
+ import * as overlaysPanel from "./panels/overlays.js";
197
+ import * as rightPanelMod from "./panels/right-panel.js";
198
+ import { mediaDisplayName, ensureLitState } from "./panels/shared.js";
168
199
  import * as monaco from "monaco-editor/esm/vs/editor/editor.api.js";
169
200
 
170
201
  // ─── Globals ──────────────────────────────────────────────────────────────────
@@ -172,7 +203,11 @@ import * as monaco from "monaco-editor/esm/vs/editor/editor.api.js";
172
203
  // into their own modules, they will migrate to ctx in store.js.
173
204
 
174
205
  /** @type {any} */
175
- let S; // current state
206
+ let S; // current state (flat compatibility view)
207
+ /** @type {any} */
208
+ let doc = null; // doc slice (persisted, history, autosave)
209
+ /** @type {any} */
210
+ let session = null; // session slice (selection, hover, ui)
176
211
 
177
212
  /** Creates a display:contents container appended to sp-theme or body, for floating popovers/menus. */
178
213
  function createFloatingContainer() {
@@ -182,32 +217,7 @@ function createFloatingContainer() {
182
217
  return el;
183
218
  }
184
219
 
185
- const toolbar = toolbarEl;
186
-
187
220
  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
221
 
212
222
  // ─── Component registry ───────────────────────────────────────────────────────
213
223
 
@@ -247,8 +257,8 @@ async function navigateBack() {
247
257
  async function closeFunctionEditor() {
248
258
  const editing = S.ui.editingFunction;
249
259
  if (!editing) return;
250
- if (functionEditor) {
251
- const currentCode = functionEditor.getValue();
260
+ if (view.functionEditor) {
261
+ const currentCode = view.functionEditor.getValue();
252
262
  const minResult = await codeService("minify", { code: currentCode });
253
263
  const bodyToStore = minResult?.code ?? currentCode;
254
264
  if (editing.type === "def") {
@@ -264,27 +274,12 @@ async function closeFunctionEditor() {
264
274
  }),
265
275
  );
266
276
  }
267
- functionEditor.dispose();
268
- functionEditor = null;
277
+ view.functionEditor.dispose();
278
+ view.functionEditor = null;
269
279
  }
270
- S = { ...S, ui: { ...S.ui, editingFunction: null } };
271
- renderCanvas();
272
- renderToolbar();
280
+ updateUi("editingFunction", null);
273
281
  }
274
282
 
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
283
  /**
289
284
  * Convert a template string to a displayable expression for edit mode. Replaces ${expr} with ❮ expr
290
285
  * ❯ so the runtime renders it as literal text.
@@ -482,10 +477,11 @@ function prepareForEditMode(node) {
482
477
  * created element via onNodeCreated callback. Returns the live state scope on success, null on
483
478
  * failure.
484
479
  *
480
+ * @param {number} gen - Render generation for staleness detection
485
481
  * @param {any} doc
486
482
  * @param {any} canvasEl
487
483
  */
488
- async function renderCanvasLive(doc, canvasEl) {
484
+ async function renderCanvasLive(gen, doc, canvasEl) {
489
485
  canvasEl.innerHTML = "";
490
486
 
491
487
  // Apply content mode typography styling
@@ -525,10 +521,44 @@ async function renderCanvasLive(doc, canvasEl) {
525
521
  }
526
522
 
527
523
  try {
528
- const docBase = S.documentPath ? `${location.origin}/${S.documentPath}` : undefined;
524
+ const root = projectState?.projectRoot || "";
525
+ const docPrefix = root ? `${root}/` : "";
526
+ const docBase = S.documentPath ? `${location.origin}/${docPrefix}${S.documentPath}` : undefined;
529
527
 
530
528
  // Register custom elements so the runtime can render them
531
- const effectiveElements = getEffectiveElements(renderDoc.$elements);
529
+ let effectiveElements = getEffectiveElements(renderDoc.$elements);
530
+
531
+ // In content mode (markdown), auto-discover components for directive-based
532
+ // custom elements that have no explicit $elements registration.
533
+ if (S.mode === "content" && componentRegistry.length > 0) {
534
+ const existingRefs = new Set(
535
+ effectiveElements.map((/** @type {any} */ e) => (typeof e === "string" ? e : e?.$ref)),
536
+ );
537
+ /** @param {any} node */
538
+ const collectTags = (node) => {
539
+ /** @type {Set<string>} */
540
+ const tags = new Set();
541
+ if (!node || typeof node !== "object") return tags;
542
+ if (node.tagName) tags.add(node.tagName);
543
+ if (Array.isArray(node.children)) {
544
+ for (const child of node.children) {
545
+ for (const t of collectTags(child)) tags.add(t);
546
+ }
547
+ }
548
+ return tags;
549
+ };
550
+ for (const tag of collectTags(renderDoc)) {
551
+ const comp = componentRegistry.find((/** @type {any} */ c) => c.tagName === tag);
552
+ if (comp && comp.source !== "npm") {
553
+ const relPath = computeRelativePath(S.documentPath, comp.path);
554
+ if (!existingRefs.has(relPath)) {
555
+ effectiveElements.push({ $ref: relPath });
556
+ existingRefs.add(relPath);
557
+ }
558
+ }
559
+ }
560
+ }
561
+
532
562
  if (effectiveElements.length) {
533
563
  renderDoc.$elements = effectiveElements;
534
564
  for (const entry of effectiveElements) {
@@ -553,9 +583,55 @@ async function renderCanvasLive(doc, canvasEl) {
553
583
  }
554
584
  }
555
585
 
586
+ // Bail out if a newer render started while we were importing elements
587
+ if (gen !== view.renderGeneration) return null;
588
+
556
589
  // Inject site-level imports so buildScope can resolve $prototype names
557
590
  renderDoc.imports = getEffectiveImports(renderDoc.imports);
558
591
 
592
+ // Apply project-level styles mirroring the compiler convention:
593
+ // viewport ≈ :root → CSS custom properties (they inherit down)
594
+ // canvasEl ≈ body → regular CSS properties (inline beats CSS defaults)
595
+ // This ensures project font-family, color, etc. override the
596
+ // content-mode fallback typography rules in the stylesheet.
597
+ // In edit mode, propagate to the .content-edit-canvas wrapper for seamless appearance.
598
+ const viewport = canvasEl.closest(".canvas-panel-viewport");
599
+ const editSurface = canvasMode === "edit" ? canvasEl.closest(".content-edit-canvas") : null;
600
+ const siteStyle = projectState?.projectConfig?.style;
601
+ if (viewport) {
602
+ viewport.style.cssText = "";
603
+ if (siteStyle && typeof siteStyle === "object") {
604
+ for (const [k, v] of Object.entries(siteStyle)) {
605
+ if (k.startsWith("--")) {
606
+ viewport.style.setProperty(k, String(v));
607
+ } else {
608
+ /** @type {any} */ (viewport.style)[k] = v;
609
+ }
610
+ }
611
+ }
612
+ }
613
+ if (editSurface) {
614
+ if (siteStyle && typeof siteStyle === "object") {
615
+ for (const [k, v] of Object.entries(siteStyle)) {
616
+ if (k.startsWith("--")) {
617
+ /** @type {any} */ (editSurface).style.setProperty(k, String(v));
618
+ } else {
619
+ /** @type {any} */ (editSurface.style)[k] = v;
620
+ }
621
+ }
622
+ }
623
+ }
624
+ if (siteStyle && typeof siteStyle === "object") {
625
+ for (const [k, v] of Object.entries(siteStyle)) {
626
+ if (!k.startsWith("--")) {
627
+ /** @type {any} */ (canvasEl.style)[k] = v;
628
+ }
629
+ }
630
+ }
631
+
632
+ // Inject site-level $media so runtime can resolve media queries in styles
633
+ renderDoc.$media = getEffectiveMedia(renderDoc.$media);
634
+
559
635
  // Inject $head elements (link/meta/script) into document.head
560
636
  const effectiveHead = getEffectiveHead(renderDoc.$head);
561
637
  if (effectiveHead.length) {
@@ -584,6 +660,8 @@ async function renderCanvasLive(doc, canvasEl) {
584
660
  }
585
661
 
586
662
  const $defs = await buildScope(renderDoc, {}, docBase);
663
+ // Bail out if a newer render started while buildScope was running
664
+ if (gen !== view.renderGeneration) return null;
587
665
  const el = /** @type {HTMLElement} */ (
588
666
  runtimeRenderNode(renderDoc, $defs, {
589
667
  onNodeCreated(/** @type {any} */ el, /** @type {any} */ path) {
@@ -632,7 +710,7 @@ async function renderCanvasLive(doc, canvasEl) {
632
710
  const editingEl = getActiveElement();
633
711
  for (const child of canvasEl.querySelectorAll("*")) {
634
712
  // Preserve pointer-events on the actively-edited element
635
- if (componentInlineEdit && child === componentInlineEdit.el) continue;
713
+ if (view.componentInlineEdit && child === view.componentInlineEdit.el) continue;
636
714
  if (editingEl && child === editingEl) continue;
637
715
  /** @type {any} */ (child).style.pointerEvents = "none";
638
716
  }
@@ -640,7 +718,7 @@ async function renderCanvasLive(doc, canvasEl) {
640
718
  }
641
719
  return $defs;
642
720
  } catch (/** @type {any} */ err) {
643
- console.warn("Jx Studio: runtime render failed, falling back to structural preview", err);
721
+ console.warn("renderCanvasLive failed:", err.message, err);
644
722
  return null;
645
723
  }
646
724
  }
@@ -666,43 +744,11 @@ litRender(
666
744
  const cssInitialMap = new Map(/** @type {any} */ (webdata.cssProps));
667
745
 
668
746
  // Persistent render hosts for lit-html (must be before bootstrap/render)
669
- const zoomIndicatorHost = document.createElement("div");
747
+ let zoomIndicatorHost = document.createElement("div");
670
748
  zoomIndicatorHost.style.display = "contents";
671
749
  document.body.appendChild(zoomIndicatorHost);
672
750
 
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
- }
703
-
704
- let elementsCollapsed = new Set();
705
- let elementsFilter = "";
751
+ // ─── Module-level UI state (must be before render() call) ─────────────────────
706
752
 
707
753
  // ─── Bootstrap ────────────────────────────────────────────────────────────────
708
754
 
@@ -721,65 +767,173 @@ const EMPTY_DOC = {
721
767
  };
722
768
 
723
769
  S = createState(structuredClone(EMPTY_DOC));
770
+ ({ doc, session } = fromFlat(S));
724
771
 
725
772
  // ─── Render loop ──────────────────────────────────────────────────────────────
726
773
 
774
+ // Mount extracted panel modules
775
+ toolbarPanel.mount(toolbarEl, {
776
+ navigateBack: () => navigateBack(),
777
+ closeFunctionEditor: () => closeFunctionEditor(),
778
+ openProject: () => openProject(),
779
+ openFile: () => openFile(),
780
+ saveFile: () => saveFile(),
781
+ parseMediaEntries,
782
+ getCanvasMode: () => canvasMode,
783
+ setCanvasMode: (/** @type {any} */ m) => {
784
+ canvasMode = m;
785
+ },
786
+ renderCanvas: () => renderCanvas(),
787
+ safeRenderRightPanel: () => safeRenderRightPanel(),
788
+ });
789
+
790
+ overlaysPanel.mount({
791
+ effectiveZoom,
792
+ getCanvasMode: () => canvasMode,
793
+ isEditing,
794
+ renderBlockActionBar,
795
+ findCanvasElement,
796
+ getActivePanel,
797
+ });
798
+
799
+ rightPanelMod.mount({
800
+ propertiesSidebarTemplate,
801
+ renderStylePanelTemplate,
802
+ renderCanvas: () => renderCanvas(),
803
+ updateForcedPseudoPreview,
804
+ });
805
+
727
806
  // Register all renderers with the store so render()/renderOnly() work
728
- registerRenderer("toolbar", () => renderToolbar());
807
+ registerRenderer("toolbar", () => toolbarPanel.render());
729
808
  registerRenderer("activityBar", () => renderActivityBar(S));
730
809
  registerRenderer("leftPanel", () => renderLeftPanel());
731
810
  registerRenderer("canvas", () => renderCanvas());
732
- registerRenderer("rightPanel", () => renderRightPanel());
733
- registerRenderer("overlays", () => renderOverlays());
811
+ registerRenderer("rightPanel", () => rightPanelMod.render());
812
+ registerRenderer("overlays", () => overlaysPanel.render());
734
813
  registerRenderer("statusbar", () => renderStatusbar(S));
735
814
  setStatusbarRenderer(() => renderStatusbar(S));
815
+ mountStatusbar();
816
+
817
+ function safeRenderLeftPanel() {
818
+ try {
819
+ ensureLitState(leftPanel);
820
+ renderLeftPanel();
821
+ } catch (e) {
822
+ console.error("renderLeftPanel error:", e);
823
+ try {
824
+ leftPanel.textContent = "";
825
+ // @ts-ignore
826
+ delete leftPanel["_$litPart$"];
827
+ renderLeftPanel();
828
+ } catch (e2) {
829
+ console.error("renderLeftPanel retry failed:", e2);
830
+ }
831
+ }
832
+ }
833
+
834
+ function safeRenderRightPanel() {
835
+ rightPanelMod.render();
836
+ }
736
837
 
737
838
  // Register the update implementation with the store
738
839
  setGetStateFn(() => S);
739
840
  setUpdateFn(function _update(/** @type {any} */ newState) {
841
+ const prev = S;
740
842
  const prevDoc = S.document;
741
843
  const prevSel = S.selection;
742
844
  S = newState;
743
845
 
744
- renderToolbar();
745
-
746
- if (prevDoc !== S.document) {
846
+ // Keep doc/session slices in sync with flat S
847
+ ({ doc, session } = fromFlat(S));
848
+
849
+ const docChanged = prevDoc !== S.document;
850
+ const selChanged = !pathsEqual(prevSel, S.selection);
851
+ const modeChanged = prev.mode !== S.mode;
852
+ const uiChanged = prev.ui !== S.ui;
853
+
854
+ const canvasUiChanged =
855
+ uiChanged &&
856
+ (prev.ui?.editingFunction !== S.ui?.editingFunction ||
857
+ prev.ui?.settingsTab !== S.ui?.settingsTab ||
858
+ prev.ui?.stylebookTab !== S.ui?.stylebookTab ||
859
+ prev.ui?.stylebookFilter !== S.ui?.stylebookFilter ||
860
+ prev.ui?.stylebookCustomizedOnly !== S.ui?.stylebookCustomizedOnly ||
861
+ prev.ui?.featureToggles !== S.ui?.featureToggles);
862
+ const leftUiChanged =
863
+ uiChanged && (prev.ui?.leftTab !== S.ui?.leftTab || prev.ui?.settingsTab !== S.ui?.settingsTab);
864
+
865
+ if (docChanged || modeChanged || canvasUiChanged) {
747
866
  try {
748
867
  renderCanvas();
749
868
  } catch (e) {
750
- console.warn("renderCanvas error:", e);
869
+ console.error("renderCanvas error:", e);
751
870
  }
752
- renderLeftPanel();
753
- } else if (!pathsEqual(prevSel, S.selection)) {
754
- renderLeftPanel();
871
+ safeRenderLeftPanel();
872
+ } else if (selChanged || leftUiChanged) {
873
+ safeRenderLeftPanel();
755
874
  }
756
875
 
757
- // Skip right-panel rebuild when an input inside it is focused (user is typing)
758
- // unless the selection changed — that always needs a full re-render
759
- // Also re-render when color popover is open (changes come from outside rightPanel)
760
- const colorPopoverOpen = !!_colorPopoverHost.querySelector("sp-popover[open]");
761
- const activeTag = document.activeElement?.tagName;
762
- const rightHasFocus =
763
- !colorPopoverOpen &&
764
- rightPanel.contains(document.activeElement) &&
765
- (activeTag === "INPUT" ||
766
- activeTag === "TEXTAREA" ||
767
- activeTag === "SP-TEXTFIELD" ||
768
- activeTag === "SP-NUMBER-FIELD" ||
769
- activeTag === "SP-PICKER" ||
770
- activeTag === "SP-COMBOBOX" ||
771
- activeTag === "SP-SEARCH");
772
- if (!rightHasFocus || !pathsEqual(prevSel, S.selection)) {
773
- renderRightPanel();
876
+ if (uiChanged && prev.ui?.activeMedia !== S.ui?.activeMedia) {
877
+ updateActivePanelHeaders();
774
878
  }
775
- renderOverlays();
776
- renderStatusbar(S);
777
879
 
778
- // Post-render hooks (pseudo-state preview, pending inline edit, etc.)
779
880
  runPostRenderHooks(prevDoc, prevSel);
780
-
781
- // Update middleware (autosave, etc.)
782
881
  runUpdateMiddleware(S);
882
+
883
+ notify({
884
+ doc: docChanged,
885
+ selection: selChanged,
886
+ hover: false,
887
+ ui: uiChanged,
888
+ mode: modeChanged,
889
+ });
890
+ });
891
+
892
+ // Register session dispatch — lightweight path for selection/hover/ui changes
893
+ setGetDocFn(() => doc);
894
+ setGetSessionFn(() => session);
895
+ setUpdateSessionFn(function _updateSession(/** @type {any} */ patch) {
896
+ const prev = session;
897
+ session = { ...session, ...patch };
898
+ if (patch.ui) {
899
+ session.ui = { ...prev.ui, ...patch.ui };
900
+ }
901
+ S = toFlat(doc, session);
902
+
903
+ const selChanged = !pathsEqual(prev.selection, session.selection);
904
+ const uiChanged = prev.ui !== session.ui;
905
+
906
+ const canvasUiChanged =
907
+ uiChanged &&
908
+ (prev.ui?.editingFunction !== session.ui?.editingFunction ||
909
+ prev.ui?.settingsTab !== session.ui?.settingsTab ||
910
+ prev.ui?.stylebookTab !== session.ui?.stylebookTab ||
911
+ prev.ui?.stylebookFilter !== session.ui?.stylebookFilter ||
912
+ prev.ui?.stylebookCustomizedOnly !== session.ui?.stylebookCustomizedOnly ||
913
+ prev.ui?.featureToggles !== session.ui?.featureToggles);
914
+ const leftUiChanged =
915
+ uiChanged &&
916
+ (prev.ui?.leftTab !== session.ui?.leftTab || prev.ui?.settingsTab !== session.ui?.settingsTab);
917
+
918
+ if (canvasUiChanged) {
919
+ try {
920
+ renderCanvas();
921
+ } catch (e) {
922
+ console.error("renderCanvas error:", e);
923
+ }
924
+ safeRenderLeftPanel();
925
+ } else if (selChanged || leftUiChanged) {
926
+ safeRenderLeftPanel();
927
+ }
928
+
929
+ if (uiChanged && prev.ui?.activeMedia !== session.ui?.activeMedia) {
930
+ updateActivePanelHeaders();
931
+ }
932
+
933
+ runPostRenderHooks(doc.document, prev.selection);
934
+
935
+ const hoverChanged = prev.hover !== session.hover;
936
+ notify({ doc: false, selection: selChanged, hover: hoverChanged, ui: uiChanged, mode: false });
783
937
  });
784
938
 
785
939
  // Register post-render hook for pseudo-state preview
@@ -787,9 +941,9 @@ addPostRenderHook(() => updateForcedPseudoPreview());
787
941
 
788
942
  // Register post-render hook for pending inline edit
789
943
  addPostRenderHook((/** @type {any} */ prevDoc) => {
790
- if (pendingInlineEdit && prevDoc === S.document) {
791
- const { path, mediaName: mn } = pendingInlineEdit;
792
- pendingInlineEdit = null;
944
+ if (view.pendingInlineEdit && prevDoc === S.document) {
945
+ const { path, mediaName: mn } = view.pendingInlineEdit;
946
+ view.pendingInlineEdit = null;
793
947
  const targetPanel =
794
948
  canvasPanels.find((/** @type {any} */ p) => p.mediaName === mn) || canvasPanels[0];
795
949
  if (targetPanel) {
@@ -806,7 +960,9 @@ const _openParam = new URLSearchParams(location.search).get("open");
806
960
 
807
961
  if (_openParam) {
808
962
  // ?open= mode: skip normal loadProject, set up site context from the path
809
- if (!_openParam.startsWith("/") && !_openParam.startsWith("~")) {
963
+ const isAbsPath =
964
+ _openParam.startsWith("/") || _openParam.startsWith("~") || /^[A-Za-z]:[/\\]/.test(_openParam);
965
+ if (!isAbsPath) {
810
966
  statusMessage(`Error: ?open= requires an absolute path (got "${_openParam}")`);
811
967
  render();
812
968
  } else {
@@ -819,13 +975,17 @@ if (_openParam) {
819
975
  : { sitePath: null };
820
976
 
821
977
  if (siteCtx.sitePath) {
822
- // Set PAL project root to server-relative path so file ops work
823
- if (siteCtx.relPath) platform.projectRoot = siteCtx.relPath;
978
+ // Set PAL project root to absolute path so file ops work
979
+ if (siteCtx.sitePath) {
980
+ platform.projectRoot = siteCtx.sitePath;
981
+ // Await activation so the server resolves project-relative static files
982
+ if (platform.activate) await platform.activate();
983
+ }
824
984
 
825
985
  setProjectState({
826
986
  root: siteCtx.sitePath,
827
987
  name: siteCtx.projectConfig?.name || "Project",
828
- projectRoot: siteCtx.relPath || ".",
988
+ projectRoot: siteCtx.sitePath,
829
989
  isSiteProject: true,
830
990
  projectConfig: siteCtx.projectConfig,
831
991
  projectDirs: [],
@@ -837,27 +997,40 @@ if (_openParam) {
837
997
 
838
998
  await loadComponentRegistry();
839
999
 
840
- // Load directory tree
1000
+ // Load directory tree and populate projectDirs from conventional dirs found
1001
+ const conventionalDirs = [
1002
+ "pages",
1003
+ "layouts",
1004
+ "components",
1005
+ "content",
1006
+ "data",
1007
+ "public",
1008
+ "styles",
1009
+ ];
841
1010
  const dirEntries = await platform.listDirectory(".");
842
1011
  projectState.dirs.set(".", dirEntries);
1012
+ const foundDirs = [];
843
1013
  for (const e of dirEntries) {
844
- if (e.type === "directory" && ["pages", "components", "layouts"].includes(e.name)) {
1014
+ if (e.type === "directory" && conventionalDirs.includes(e.name)) {
1015
+ foundDirs.push(e.name);
845
1016
  projectState.expanded.add(e.path || e.name);
846
1017
  const sub = await platform.listDirectory(e.path || e.name);
847
1018
  projectState.dirs.set(e.path || e.name, sub);
848
1019
  }
849
1020
  }
1021
+ projectState.projectDirs = foundDirs;
850
1022
  }
851
1023
 
852
1024
  // Read and open the file
853
1025
  const fileRelPath = siteCtx.fileRelPath || _openParam;
854
1026
  const content = await platform.readFile(fileRelPath);
855
1027
  if (content) {
856
- const doc = JSON.parse(content);
857
- S = createState(doc);
1028
+ const parsed = JSON.parse(content);
1029
+ S = createState(parsed);
858
1030
  S.dirty = false;
859
1031
  S.documentPath = fileRelPath;
860
1032
  S.ui = { ...S.ui, leftTab: "files" };
1033
+ ({ doc, session } = fromFlat(S));
861
1034
  render();
862
1035
  statusMessage(`Opened ${_openParam}`);
863
1036
  }
@@ -875,88 +1048,41 @@ if (_openParam) {
875
1048
  // ─── Media helpers ────────────────────────────────────────────────────────────
876
1049
 
877
1050
  /**
878
- * Classify $media entries into size breakpoints (get a canvas each) and feature queries (rendered
879
- * as toolbar toggles).
880
- *
881
- * @param {any} mediaDef
882
- */
883
- function parseMediaEntries(mediaDef) {
884
- if (!mediaDef) return { sizeBreakpoints: [], featureQueries: [], baseWidth: 320 };
885
- const sizes = [],
886
- features = [];
887
- let baseWidth = 320;
888
- for (const [name, query] of Object.entries(mediaDef)) {
889
- if (name === "--") {
890
- const wm = String(query).match(/^(\d+)\s*px$/);
891
- baseWidth = wm ? parseFloat(wm[1]) : 320;
892
- continue;
893
- }
894
- const minMatch = query.match(/min-width:\s*([\d.]+)px/);
895
- const maxMatch = query.match(/max-width:\s*([\d.]+)px/);
896
- if (minMatch) sizes.push({ name, query, width: parseFloat(minMatch[1]), type: "min" });
897
- else if (maxMatch) sizes.push({ name, query, width: parseFloat(maxMatch[1]), type: "max" });
898
- else features.push({ name, query });
899
- }
900
- sizes.sort((a, b) => (a.type === "min" ? a.width - b.width : b.width - a.width));
901
- return { sizeBreakpoints: sizes, featureQueries: features, baseWidth };
902
- }
903
-
904
- /**
905
- * Compute which named breakpoints are active at a given canvas width. For min-width canvases: all
906
- * breakpoints with min-width <= canvasWidth are active. For max-width canvases: all breakpoints
907
- * with max-width >= canvasWidth are active.
908
- *
909
- * @param {any} sizeBreakpoints
910
- * @param {any} canvasWidth
911
- */
912
- function activeBreakpointsForWidth(sizeBreakpoints, canvasWidth) {
913
- const active = new Set();
914
- for (const bp of sizeBreakpoints) {
915
- if (bp.type === "min" && canvasWidth >= bp.width) active.add(bp.name);
916
- else if (bp.type === "max" && canvasWidth <= bp.width) active.add(bp.name);
917
- }
918
- return active;
919
- }
920
-
921
- /**
922
- * Apply styles to a canvas element, including active media overrides. Base (flat) styles applied
923
- * first, then matching media overrides in source order.
1051
+ * After a runtime render, apply active media overrides as inline styles so they beat the base
1052
+ * inline styles the runtime already set. The runtime uses @media CSS rules for overrides, but those
1053
+ * can never beat inline base styles.
924
1054
  *
925
- * @param {any} el
926
- * @param {any} styleDef
927
- * @param {any} activeBreakpoints
928
- * @param {any} featureToggles
1055
+ * @param {Element} canvasEl
1056
+ * @param {Set<string>} activeBreakpoints
929
1057
  */
930
- function applyCanvasStyle(el, styleDef, activeBreakpoints, featureToggles) {
931
- if (!styleDef || typeof styleDef !== "object") return;
932
- for (const [prop, val] of Object.entries(styleDef)) {
933
- if (typeof val === "string" || typeof val === "number") {
934
- try {
935
- if (prop.startsWith("--")) el.style.setProperty(prop, String(val));
936
- else /** @type {any} */ (el.style)[prop] = val;
937
- } catch {}
938
- }
939
- }
940
- for (const [key, val] of Object.entries(styleDef)) {
941
- if (!key.startsWith("@") || typeof val !== "object") continue;
942
- const mediaName = key.slice(1);
943
- if (mediaName === "--") continue; // skip base canvas width key
944
- if (activeBreakpoints.has(mediaName) || featureToggles[mediaName]) {
945
- for (const [prop, v] of Object.entries(/** @type {any} */ (val))) {
946
- if (typeof v === "string" || typeof v === "number") {
947
- try {
948
- if (prop.startsWith("--")) el.style.setProperty(prop, String(v));
949
- else /** @type {any} */ (el.style)[prop] = v;
950
- } catch {}
951
- }
952
- }
953
- }
1058
+ function applyCanvasMediaOverrides(canvasEl, activeBreakpoints) {
1059
+ if (!activeBreakpoints.size) return;
1060
+ const docMedia = getEffectiveMedia(S.document.$media || {});
1061
+ const validBreakpoints = new Set();
1062
+ for (const name of activeBreakpoints) {
1063
+ if (docMedia[name]) validBreakpoints.add(name);
954
1064
  }
1065
+ const overrides = collectMediaOverrides(document.styleSheets, validBreakpoints);
1066
+ applyOverridesToCanvas(canvasEl, overrides);
955
1067
  }
956
1068
 
957
1069
  // ─── Canvas ───────────────────────────────────────────────────────────────────
958
1070
 
959
1071
  function renderCanvas() {
1072
+ // Advance render generation so stale async renders from the previous cycle bail out
1073
+ ++view.renderGeneration;
1074
+
1075
+ // Always clear Lit's internal state so it builds fresh DOM. Stale async
1076
+ // renderCanvasLive calls from a previous cycle can corrupt nested ChildPart
1077
+ // markers (Comment nodes inside panzoom-wrap) in ways the root-only
1078
+ // ensureLitState check cannot detect.
1079
+ // @ts-ignore
1080
+ if (canvasWrap["_$litPart$"]) {
1081
+ canvasWrap.textContent = "";
1082
+ // @ts-ignore
1083
+ delete canvasWrap["_$litPart$"];
1084
+ }
1085
+
960
1086
  // Function editor mode: editing a function body in Monaco (JS)
961
1087
  if (S.ui.editingFunction) {
962
1088
  renderFunctionEditor();
@@ -964,68 +1090,125 @@ function renderCanvas() {
964
1090
  }
965
1091
 
966
1092
  // Dispose function editor if switching away
967
- if (functionEditor) {
968
- functionEditor.dispose();
969
- functionEditor = null;
1093
+ if (view.functionEditor) {
1094
+ view.functionEditor.dispose();
1095
+ view.functionEditor = null;
970
1096
  }
971
1097
 
972
1098
  // Source mode: update existing Monaco editor without recreating
973
- if (canvasMode === "source" && monacoEditor) {
1099
+ if (canvasMode === "source" && view.monacoEditor) {
974
1100
  const jsonStr = JSON.stringify(S.document, null, 2);
975
- const currentVal = monacoEditor.getValue();
1101
+ const currentVal = view.monacoEditor.getValue();
976
1102
  if (currentVal !== jsonStr) {
977
1103
  // Prevent triggering the onChange handler for this programmatic update
978
- monacoEditor._ignoreNextChange = true;
979
- monacoEditor.setValue(jsonStr);
1104
+ view.monacoEditor._ignoreNextChange = true;
1105
+ view.monacoEditor.setValue(jsonStr);
980
1106
  }
981
1107
  return;
982
1108
  }
983
1109
 
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 = [];
1110
+ // Detect whether this is a mode transition or a content-only re-render
1111
+ const modeChanged = canvasMode !== view.prevCanvasMode;
1112
+ view.prevCanvasMode = canvasMode;
1113
+
1114
+ // DnD handlers are registered on inner canvas elements that get replaced on every
1115
+ // content render, so always clean them up.
1116
+ for (const fn of view.canvasDndCleanups) fn();
1117
+ view.canvasDndCleanups = [];
1118
+
1119
+ // Panel event handlers (click, dblclick, etc.) capture closures over panel references.
1120
+ // Always re-register to keep closures fresh across document switches.
1121
+ for (const fn of view.canvasEventCleanups) fn();
1122
+ view.canvasEventCleanups = [];
1123
+
1124
+ // Panel JS objects are cheap — always clear and repopulate from templates.
1125
+ // The actual DOM elements are preserved by Lit's diffing on content-only re-renders.
991
1126
  canvasPanels.length = 0;
992
1127
 
993
- // Dispose Monaco editor if switching away from source mode
994
- if (monacoEditor) {
995
- monacoEditor.dispose();
996
- monacoEditor = null;
1128
+ if (modeChanged) {
1129
+ // Full teardown on mode transitions — new panel structure needed
1130
+ if (view.centerObserver) {
1131
+ view.centerObserver.disconnect();
1132
+ view.centerObserver = null;
1133
+ }
1134
+
1135
+ // Dispose Monaco editor if switching away from source mode
1136
+ if (view.monacoEditor) {
1137
+ view.monacoEditor.dispose();
1138
+ view.monacoEditor = null;
1139
+ }
1140
+
1141
+ litRender(nothing, canvasWrap);
1142
+ view.panzoomWrap = null;
1143
+ // Reset inline style overrides from other modes
1144
+ canvasWrap.style.padding = "";
1145
+ canvasWrap.style.alignItems = "";
1146
+ canvasWrap.style.display = "";
1147
+ canvasWrap.style.overflow = "";
1148
+ canvasWrap.style.overflow = "";
1149
+
1150
+ // Clear zoom indicator (only re-rendered by design/preview/stylebook)
1151
+ try {
1152
+ litRender(nothing, zoomIndicatorHost);
1153
+ } catch {
1154
+ const newHost = document.createElement("div");
1155
+ newHost.style.display = "contents";
1156
+ zoomIndicatorHost.replaceWith(newHost);
1157
+ zoomIndicatorHost = newHost;
1158
+ }
1159
+
1160
+ // Dismiss open popovers/toolbars that are no longer relevant
1161
+ if (view.blockActionBarEl) litRender(nothing, view.blockActionBarEl);
1162
+ dismissLinkPopover();
1163
+ dismissContextMenu();
1164
+ sharedDismissSlashMenu();
997
1165
  }
998
1166
 
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();
1167
+ // Manage mode: project-level file browser table
1168
+ if (canvasMode === "manage") {
1169
+ canvasWrap.style.padding = "0";
1170
+ canvasWrap.style.overflow = "auto";
1171
+ renderBrowse(canvasWrap, {
1172
+ openFile: (/** @type {string} */ path) => {
1173
+ canvasMode = "edit";
1174
+ openFileFromTree(path);
1175
+ },
1176
+ });
1177
+ return;
1178
+ }
1179
+
1180
+ // Settings mode: render element catalog with panzoom surface
1181
+ if (canvasMode === "settings") {
1182
+ renderSettings();
1009
1183
  return;
1010
1184
  }
1011
1185
 
1012
1186
  // Source mode: create Monaco editor instead of canvas
1013
1187
  if (canvasMode === "source") {
1014
1188
  canvasWrap.style.padding = "0";
1189
+ canvasWrap.style.display = "block";
1015
1190
  /** @type {HTMLDivElement | null} */
1016
1191
  let editorContainer = null;
1017
1192
  litRender(
1018
- html`<div
1019
- class="source-editor"
1020
- ${ref((el) => {
1021
- if (el) editorContainer = /** @type {HTMLDivElement} */ (el);
1022
- })}
1023
- ></div>`,
1193
+ html`<div class="source-wrap">
1194
+ <div class="source-toolbar">
1195
+ <sp-action-button size="s" @click=${exportFile}>
1196
+ <sp-icon-export slot="icon"></sp-icon-export>
1197
+ Export
1198
+ </sp-action-button>
1199
+ </div>
1200
+ <div
1201
+ class="source-editor"
1202
+ ${ref((el) => {
1203
+ if (el) editorContainer = /** @type {HTMLDivElement} */ (el);
1204
+ })}
1205
+ ></div>
1206
+ </div>`,
1024
1207
  canvasWrap,
1025
1208
  );
1026
1209
 
1027
1210
  const jsonStr = JSON.stringify(S.document, null, 2);
1028
- monacoEditor = monaco.editor.create(/** @type {any} */ (editorContainer), {
1211
+ view.monacoEditor = monaco.editor.create(/** @type {any} */ (editorContainer), {
1029
1212
  value: jsonStr,
1030
1213
  language: "json",
1031
1214
  theme: "vs-dark",
@@ -1042,19 +1225,16 @@ function renderCanvas() {
1042
1225
  // Debounced sync back to state
1043
1226
  /** @type {any} */
1044
1227
  let debounce;
1045
- monacoEditor.onDidChangeModelContent(() => {
1046
- if (monacoEditor._ignoreNextChange) {
1047
- monacoEditor._ignoreNextChange = false;
1228
+ view.monacoEditor.onDidChangeModelContent(() => {
1229
+ if (view.monacoEditor._ignoreNextChange) {
1230
+ view.monacoEditor._ignoreNextChange = false;
1048
1231
  return;
1049
1232
  }
1050
1233
  clearTimeout(debounce);
1051
1234
  debounce = setTimeout(() => {
1052
1235
  try {
1053
- const parsed = JSON.parse(monacoEditor.getValue());
1054
- S = { ...S, document: parsed, dirty: true };
1055
- renderToolbar();
1056
- renderLeftPanel();
1057
- renderRightPanel();
1236
+ const parsed = JSON.parse(view.monacoEditor.getValue());
1237
+ update({ ...S, document: parsed, dirty: true });
1058
1238
  } catch {
1059
1239
  // Invalid JSON — don't update state
1060
1240
  }
@@ -1065,33 +1245,38 @@ function renderCanvas() {
1065
1245
 
1066
1246
  // Edit (content) mode — centered column, no panzoom, always 100%
1067
1247
  if (canvasMode === "edit") {
1068
- canvasWrap.style.padding = "0";
1069
- canvasWrap.style.overflow = "hidden";
1248
+ if (modeChanged) {
1249
+ canvasWrap.style.padding = "0";
1250
+ canvasWrap.style.overflow = "hidden";
1070
1251
 
1071
- // Remove zoom indicator left over from design/preview mode
1072
- try {
1073
- litRender(nothing, zoomIndicatorHost);
1074
- } catch {
1075
- zoomIndicatorHost.textContent = "";
1252
+ // Remove zoom indicator left over from design/preview mode
1253
+ try {
1254
+ litRender(nothing, zoomIndicatorHost);
1255
+ } catch {
1256
+ const newHost = document.createElement("div");
1257
+ newHost.style.display = "contents";
1258
+ zoomIndicatorHost.replaceWith(newHost);
1259
+ zoomIndicatorHost = newHost;
1260
+ }
1076
1261
  }
1077
1262
 
1078
1263
  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
- );
1264
+ const editTpl = html`
1265
+ <div class="content-edit-canvas">
1266
+ <div class="content-edit-column">${panelTpl}</div>
1267
+ </div>
1268
+ `;
1269
+ litRender(editTpl, canvasWrap);
1087
1270
  canvasPanels.push(panel);
1088
1271
  renderCanvasIntoPanel(panel, new Set(), S.ui.featureToggles);
1089
1272
  return;
1090
1273
  }
1091
1274
 
1092
1275
  // Normal canvas mode (design / preview) — set up panzoom surface
1093
- canvasWrap.style.padding = "0";
1094
- canvasWrap.style.overflow = "hidden";
1276
+ if (modeChanged) {
1277
+ canvasWrap.style.padding = "0";
1278
+ canvasWrap.style.overflow = "hidden";
1279
+ }
1095
1280
 
1096
1281
  const {
1097
1282
  sizeBreakpoints,
@@ -1119,7 +1304,7 @@ function renderCanvas() {
1119
1304
  class="panzoom-wrap"
1120
1305
  style="transform-origin:0 0"
1121
1306
  ${ref((el) => {
1122
- if (el) panzoomWrap = /** @type {HTMLDivElement} */ (el);
1307
+ if (el) view.panzoomWrap = /** @type {HTMLDivElement} */ (el);
1123
1308
  })}
1124
1309
  >
1125
1310
  ${panelTpl}
@@ -1130,12 +1315,15 @@ function renderCanvas() {
1130
1315
  canvasPanels.push(panel);
1131
1316
  renderCanvasIntoPanel(panel, new Set(), featureToggles);
1132
1317
  applyTransform();
1133
- observeCenterUntilStable();
1318
+ if (modeChanged) {
1319
+ observeCenterUntilStable();
1320
+ }
1134
1321
  renderZoomIndicator();
1135
1322
  return;
1136
1323
  }
1137
1324
 
1138
- // Build all panels (base + breakpoints), sorted widest-first (left to right)
1325
+ // Build all panels: base first, then breakpoints in declared order (ascending for min-width,
1326
+ // descending for max-width — matching the direction of the design's media queries).
1139
1327
  const allPanelDefs = [
1140
1328
  {
1141
1329
  name: "base",
@@ -1152,7 +1340,6 @@ function renderCanvas() {
1152
1340
  activeSet: activeBreakpointsForWidth(sizeBreakpoints, bp.width),
1153
1341
  });
1154
1342
  }
1155
- allPanelDefs.sort((a, b) => b.width - a.width);
1156
1343
 
1157
1344
  /** @type {{ tpl: any; panel: any; activeSet: any }[]} */
1158
1345
  const panelEntries = allPanelDefs.map((def) => {
@@ -1167,7 +1354,7 @@ function renderCanvas() {
1167
1354
  class="panzoom-wrap"
1168
1355
  style="transform-origin:0 0"
1169
1356
  ${ref((el) => {
1170
- if (el) panzoomWrap = /** @type {HTMLDivElement} */ (el);
1357
+ if (el) view.panzoomWrap = /** @type {HTMLDivElement} */ (el);
1171
1358
  })}
1172
1359
  >
1173
1360
  ${panelEntries.map((e) => e.tpl)}
@@ -1186,7 +1373,9 @@ function renderCanvas() {
1186
1373
 
1187
1374
  // Apply current zoom + pan transform
1188
1375
  applyTransform();
1189
- observeCenterUntilStable();
1376
+ if (modeChanged) {
1377
+ observeCenterUntilStable();
1378
+ }
1190
1379
 
1191
1380
  // Floating zoom indicator
1192
1381
  renderZoomIndicator();
@@ -1201,9 +1390,13 @@ function renderCanvas() {
1201
1390
  * @param {any} featureToggles
1202
1391
  */
1203
1392
  function renderCanvasIntoPanel(panel, activeBreakpoints, featureToggles) {
1204
- renderCanvasLive(S.document, panel.canvas).then((scope) => {
1393
+ const gen = view.renderGeneration;
1394
+ renderCanvasLive(gen, S.document, panel.canvas).then((scope) => {
1395
+ // Skip post-render setup if a newer render has started
1396
+ if (gen !== view.renderGeneration) return;
1205
1397
  if (scope) {
1206
- liveScope = scope;
1398
+ view.liveScope = scope;
1399
+ applyCanvasMediaOverrides(panel.canvas, activeBreakpoints);
1207
1400
  statusMessage("Runtime render OK", 1500);
1208
1401
  } else {
1209
1402
  // Fallback to structural preview
@@ -1214,9 +1407,9 @@ function renderCanvasIntoPanel(panel, activeBreakpoints, featureToggles) {
1214
1407
  renderOverlays();
1215
1408
 
1216
1409
  // Process pending inline edit now that the canvas is populated
1217
- if (pendingInlineEdit) {
1218
- const { path, mediaName: mn } = pendingInlineEdit;
1219
- pendingInlineEdit = null;
1410
+ if (view.pendingInlineEdit) {
1411
+ const { path, mediaName: mn } = view.pendingInlineEdit;
1412
+ view.pendingInlineEdit = null;
1220
1413
  const targetPanel = canvasPanels.find((p) => p.mediaName === mn) || canvasPanels[0];
1221
1414
  if (targetPanel) {
1222
1415
  const el = findCanvasElement(path, targetPanel.canvas);
@@ -1271,9 +1464,7 @@ function canvasPanelTemplate(mediaName, label, fullWidth, width) {
1271
1464
  <div
1272
1465
  class="canvas-panel-header"
1273
1466
  @click=${() => {
1274
- S = { ...S, ui: { ...S.ui, activeMedia: mediaName === "base" ? null : mediaName } };
1275
- updateActivePanelHeaders();
1276
- renderRightPanel();
1467
+ updateUi("activeMedia", mediaName === "base" ? null : mediaName);
1277
1468
  }}
1278
1469
  >
1279
1470
  ${label}
@@ -1322,52 +1513,52 @@ function canvasPanelTemplate(mediaName, label, fullWidth, width) {
1322
1513
 
1323
1514
  /** Center canvas in viewport. */
1324
1515
  function centerCanvas() {
1325
- if (!panzoomWrap) return;
1516
+ if (!view.panzoomWrap) return;
1326
1517
  const wrapWidth = canvasWrap.clientWidth;
1327
1518
  const wrapHeight = canvasWrap.clientHeight;
1328
- const contentWidth = panzoomWrap.scrollWidth;
1329
- const contentHeight = panzoomWrap.scrollHeight;
1519
+ const contentWidth = view.panzoomWrap.scrollWidth;
1520
+ const contentHeight = view.panzoomWrap.scrollHeight;
1330
1521
  const scaledWidth = contentWidth * S.ui.zoom;
1331
1522
  const scaledHeight = contentHeight * S.ui.zoom;
1332
- panX = Math.max(16, (wrapWidth - scaledWidth) / 2);
1523
+ view.panX = Math.max(16, (wrapWidth - scaledWidth) / 2);
1333
1524
  // Center vertically only when content fits; top-align with margin when taller
1334
1525
  const verticalCenter = (wrapHeight - scaledHeight) / 2;
1335
- panY = verticalCenter > 16 ? verticalCenter : 16;
1526
+ view.panY = verticalCenter > 16 ? verticalCenter : 16;
1336
1527
  }
1337
1528
 
1338
1529
  /**
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.
1530
+ * Attach a ResizeObserver to view.panzoomWrap that re-centers until the user pans. Handles async
1531
+ * content (runtime rendering, data fetching) that changes layout after initial paint.
1341
1532
  */
1342
1533
  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;
1534
+ if (view.centerObserver) {
1535
+ view.centerObserver.disconnect();
1536
+ view.centerObserver = null;
1537
+ }
1538
+ if (!view.panzoomWrap) return;
1539
+ view.needsCenter = true;
1540
+ view.centerObserver = new ResizeObserver(() => {
1541
+ if (!view.needsCenter) {
1542
+ view.centerObserver?.disconnect();
1543
+ view.centerObserver = null;
1353
1544
  return;
1354
1545
  }
1355
1546
  centerCanvas();
1356
1547
  applyTransform();
1357
1548
  });
1358
- centerObserver.observe(panzoomWrap);
1549
+ view.centerObserver.observe(view.panzoomWrap);
1359
1550
  // Also center immediately for synchronous content
1360
1551
  centerCanvas();
1361
1552
  }
1362
1553
 
1363
1554
  /** Apply the current zoom + pan transform to the panzoom wrapper. */
1364
1555
  function applyTransform() {
1365
- if (!panzoomWrap) return;
1366
- panzoomWrap.style.transform = `translate(${panX}px, ${panY}px) scale(${S.ui.zoom})`;
1556
+ if (!view.panzoomWrap) return;
1557
+ view.panzoomWrap.style.transform = `translate(${view.panX}px, ${view.panY}px) scale(${S.ui.zoom})`;
1367
1558
  const label = document.querySelector(".zoom-indicator-label");
1368
1559
  if (label) label.textContent = `${Math.round(S.ui.zoom * 100)}%`;
1369
1560
  renderOverlays();
1370
- if (canvasMode === "stylebook") renderStylebookOverlays();
1561
+ if (canvasMode === "settings") renderStylebookOverlays();
1371
1562
  }
1372
1563
 
1373
1564
  /** Lightweight in-place zoom update — no full re-render. */
@@ -1377,7 +1568,7 @@ function _applyZoom() {
1377
1568
 
1378
1569
  /** Calculate zoom + pan to fit all panels within the viewport. */
1379
1570
  function fitToScreen() {
1380
- if (!panzoomWrap) return;
1571
+ if (!view.panzoomWrap) return;
1381
1572
  const wrapWidth = canvasWrap.clientWidth;
1382
1573
  const wrapHeight = canvasWrap.clientHeight;
1383
1574
  const gap = 24;
@@ -1390,7 +1581,7 @@ function fitToScreen() {
1390
1581
  totalPanelWidth += gap * Math.max(0, canvasPanels.length - 1) + padding;
1391
1582
 
1392
1583
  // Get actual content height from rendered panels
1393
- const wrapRect = panzoomWrap.getBoundingClientRect();
1584
+ const wrapRect = view.panzoomWrap.getBoundingClientRect();
1394
1585
  const unscaledHeight = wrapRect.height / S.ui.zoom;
1395
1586
  maxPanelHeight = unscaledHeight + padding;
1396
1587
 
@@ -1398,12 +1589,13 @@ function fitToScreen() {
1398
1589
  const fitZoomH = wrapHeight / maxPanelHeight;
1399
1590
  const fitZoom = Math.min(5.0, Math.max(0.05, Math.min(fitZoomW, fitZoomH)));
1400
1591
 
1401
- S = { ...S, ui: { ...S.ui, zoom: fitZoom } };
1592
+ session = { ...session, ui: { ...session.ui, zoom: fitZoom } };
1593
+ S = toFlat(doc, session);
1402
1594
  // Center the content
1403
1595
  const scaledWidth = totalPanelWidth * fitZoom;
1404
1596
  const scaledHeight = maxPanelHeight * fitZoom;
1405
- panX = Math.max(0, (wrapWidth - scaledWidth) / 2);
1406
- panY = Math.max(0, (wrapHeight - scaledHeight) / 2);
1597
+ view.panX = Math.max(0, (wrapWidth - scaledWidth) / 2);
1598
+ view.panY = Math.max(0, (wrapHeight - scaledHeight) / 2);
1407
1599
  applyTransform();
1408
1600
  }
1409
1601
 
@@ -1443,7 +1635,11 @@ function renderZoomIndicator() {
1443
1635
  zoomIndicatorHost,
1444
1636
  );
1445
1637
  } catch {
1446
- zoomIndicatorHost.textContent = "";
1638
+ // Lit markers were corrupted — replace the host element to fully reset Lit state
1639
+ const newHost = document.createElement("div");
1640
+ newHost.style.display = "contents";
1641
+ zoomIndicatorHost.replaceWith(newHost);
1642
+ zoomIndicatorHost = newHost;
1447
1643
  litRender(
1448
1644
  html`
1449
1645
  <div class="zoom-indicator">
@@ -1600,7 +1796,6 @@ function renderCanvasNode(node, path, parent, activeBreakpoints, featureToggles)
1600
1796
  *
1601
1797
  * @type {any}
1602
1798
  */
1603
- let lastDragInput = null;
1604
1799
 
1605
1800
  /**
1606
1801
  * Register all canvas elements in a panel as DnD drop targets.
@@ -1620,19 +1815,19 @@ function registerPanelDnD(panel) {
1620
1815
  for (const p of canvasPanels) p.overlayClk.style.pointerEvents = "none";
1621
1816
  },
1622
1817
  onDrag({ location }) {
1623
- lastDragInput = location.current.input;
1818
+ view.lastDragInput = location.current.input;
1624
1819
  },
1625
1820
  onDrop() {
1626
1821
  // Hide all drop lines
1627
1822
  for (const p of canvasPanels) p.dropLine.style.display = "none";
1628
- lastDragInput = null;
1823
+ view.lastDragInput = null;
1629
1824
  for (const el of canvas.querySelectorAll("*")) {
1630
1825
  /** @type {any} */ (el).style.pointerEvents = "none";
1631
1826
  }
1632
1827
  for (const p of canvasPanels) p.overlayClk.style.pointerEvents = "";
1633
1828
  },
1634
1829
  });
1635
- canvasDndCleanups.push(monitorCleanup);
1830
+ view.canvasDndCleanups.push(monitorCleanup);
1636
1831
 
1637
1832
  for (const el of allEls) {
1638
1833
  const elPath = elToPath.get(el);
@@ -1669,7 +1864,7 @@ function registerPanelDnD(panel) {
1669
1864
  applyDropInstruction(instruction, source.data, elPath);
1670
1865
  },
1671
1866
  });
1672
- canvasDndCleanups.push(cleanup);
1867
+ view.canvasDndCleanups.push(cleanup);
1673
1868
  }
1674
1869
  }
1675
1870
 
@@ -1680,8 +1875,8 @@ function registerPanelDnD(panel) {
1680
1875
  */
1681
1876
  function getCanvasDropInstruction(el, elPath, isVoid) {
1682
1877
  const rect = el.getBoundingClientRect();
1683
- if (!lastDragInput) return null;
1684
- const y = lastDragInput.clientY;
1878
+ if (!view.lastDragInput) return null;
1879
+ const y = view.lastDragInput.clientY;
1685
1880
  const relY = (y - rect.top) / rect.height;
1686
1881
 
1687
1882
  if (elPath.length === 0) return { type: "make-child" };
@@ -1739,83 +1934,7 @@ function showCanvasDropIndicator(el, elPath, isVoid, panel) {
1739
1934
  // ─── Overlay system ───────────────────────────────────────────────────────────
1740
1935
 
1741
1936
  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();
1937
+ overlaysPanel.render();
1819
1938
  }
1820
1939
 
1821
1940
  /**
@@ -1877,15 +1996,10 @@ function onBarMousedown(e) {
1877
1996
  e.preventDefault();
1878
1997
  }
1879
1998
 
1880
- /**
1881
- * Saved selection range for format button mousedown→click flow
1882
- *
1883
- * @type {any}
1884
- */
1885
- let savedRange = null;
1999
+ /** Saved selection range for format button mousedown→click flow */
1886
2000
  function captureSelectionRange() {
1887
2001
  const sel = window.getSelection();
1888
- if (sel && sel.rangeCount) savedRange = sel.getRangeAt(0).cloneRange();
2002
+ if (sel && sel.rangeCount) view.savedRange = sel.getRangeAt(0).cloneRange();
1889
2003
  }
1890
2004
 
1891
2005
  /**
@@ -1896,16 +2010,16 @@ function onFormatClick(e, action) {
1896
2010
  e.stopPropagation();
1897
2011
  if (action.command === "link") {
1898
2012
  showLinkPopover(e.target.closest("sp-action-button"));
1899
- } else if (savedRange) {
2013
+ } else if (view.savedRange) {
1900
2014
  const sel = /** @type {any} */ (window.getSelection());
1901
- const anchor = savedRange.startContainer;
2015
+ const anchor = view.savedRange.startContainer;
1902
2016
  const editableRoot = (
1903
2017
  anchor?.nodeType === Node.ELEMENT_NODE ? anchor : anchor?.parentElement
1904
2018
  )?.closest("[contenteditable]");
1905
2019
  if (editableRoot) {
1906
2020
  editableRoot.focus();
1907
2021
  sel.removeAllRanges();
1908
- sel.addRange(savedRange);
2022
+ sel.addRange(view.savedRange);
1909
2023
  applyInlineFormat(action);
1910
2024
  }
1911
2025
  }
@@ -1990,14 +2104,19 @@ function applyInlineFormat(action) {
1990
2104
  }
1991
2105
 
1992
2106
  /** 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);
2107
+ view.linkPopoverHost = document.createElement("div");
2108
+ view.linkPopoverHost.style.display = "contents";
2109
+ (document.querySelector("sp-theme") || document.body).appendChild(view.linkPopoverHost);
2110
+
2111
+ /** Dismiss the link popover if open. */
2112
+ function dismissLinkPopover() {
2113
+ if (view.linkPopoverHost) litRender(nothing, view.linkPopoverHost);
2114
+ }
1996
2115
 
1997
2116
  /** @param {any} anchorBtn */
1998
2117
  function showLinkPopover(anchorBtn) {
1999
2118
  // Dismiss existing
2000
- litRender(nothing, linkPopoverHost);
2119
+ litRender(nothing, view.linkPopoverHost);
2001
2120
 
2002
2121
  const sel = window.getSelection();
2003
2122
  /** @type {any} */
@@ -2017,14 +2136,14 @@ function showLinkPopover(anchorBtn) {
2017
2136
  const rect = anchorBtn.getBoundingClientRect();
2018
2137
 
2019
2138
  const onApply = () => {
2020
- const field = linkPopoverHost.querySelector("sp-textfield");
2139
+ const field = view.linkPopoverHost.querySelector("sp-textfield");
2021
2140
  const url = /** @type {any} */ (field)?.value;
2022
2141
  if (existingLink) {
2023
2142
  existingLink.setAttribute("href", url);
2024
2143
  } else if (url) {
2025
2144
  document.execCommand("createLink", false, url);
2026
2145
  }
2027
- litRender(nothing, linkPopoverHost);
2146
+ litRender(nothing, view.linkPopoverHost);
2028
2147
  renderBlockActionBar();
2029
2148
  };
2030
2149
 
@@ -2032,14 +2151,14 @@ function showLinkPopover(anchorBtn) {
2032
2151
  const frag = document.createDocumentFragment();
2033
2152
  while (existingLink.firstChild) frag.appendChild(existingLink.firstChild);
2034
2153
  existingLink.parentNode.replaceChild(frag, existingLink);
2035
- litRender(nothing, linkPopoverHost);
2154
+ litRender(nothing, view.linkPopoverHost);
2036
2155
  renderBlockActionBar();
2037
2156
  };
2038
2157
 
2039
2158
  const onKeydown = (/** @type {any} */ e) => {
2040
2159
  if (e.key === "Enter") onApply();
2041
2160
  else if (e.key === "Escape") {
2042
- litRender(nothing, linkPopoverHost);
2161
+ litRender(nothing, view.linkPopoverHost);
2043
2162
  }
2044
2163
  };
2045
2164
 
@@ -2065,12 +2184,14 @@ function showLinkPopover(anchorBtn) {
2065
2184
  : nothing}
2066
2185
  </sp-popover>
2067
2186
  `,
2068
- linkPopoverHost,
2187
+ view.linkPopoverHost,
2069
2188
  );
2070
2189
 
2071
2190
  requestAnimationFrame(
2072
2191
  () =>
2073
- /** @type {HTMLElement | null} */ (linkPopoverHost?.querySelector("sp-textfield"))?.focus(),
2192
+ /** @type {HTMLElement | null} */ (
2193
+ view.linkPopoverHost?.querySelector("sp-textfield")
2194
+ )?.focus(),
2074
2195
  );
2075
2196
  }
2076
2197
 
@@ -2081,7 +2202,8 @@ function moveSelectionUp() {
2081
2202
  if (idx <= 0) return;
2082
2203
  const pPath = /** @type {any} */ (parentElementPath(S.selection));
2083
2204
  update(moveNode(S, S.selection, pPath, idx - 1));
2084
- S = { ...S, selection: [...pPath, "children", idx - 1] };
2205
+ session = { ...session, selection: [...pPath, "children", idx - 1] };
2206
+ S = toFlat(doc, session);
2085
2207
  renderOverlays();
2086
2208
  }
2087
2209
 
@@ -2094,7 +2216,8 @@ function moveSelectionDown() {
2094
2216
  const siblings = parentNode?.children;
2095
2217
  if (!siblings || idx >= siblings.length - 1) return;
2096
2218
  update(moveNode(S, S.selection, pPath, idx + 2));
2097
- S = { ...S, selection: [...pPath, "children", idx + 1] };
2219
+ session = { ...session, selection: [...pPath, "children", idx + 1] };
2220
+ S = toFlat(doc, session);
2098
2221
  renderOverlays();
2099
2222
  }
2100
2223
 
@@ -2104,30 +2227,30 @@ function moveSelectionDown() {
2104
2227
  */
2105
2228
  function renderBlockActionBar() {
2106
2229
  // Ensure persistent render container exists
2107
- if (!blockActionBarEl) {
2108
- blockActionBarEl = createFloatingContainer();
2230
+ if (!view.blockActionBarEl) {
2231
+ view.blockActionBarEl = createFloatingContainer();
2109
2232
  }
2110
2233
 
2111
2234
  // Tear down drag if it was active
2112
- if (selDragCleanup) {
2113
- selDragCleanup();
2114
- selDragCleanup = null;
2235
+ if (view.selDragCleanup) {
2236
+ view.selDragCleanup();
2237
+ view.selDragCleanup = null;
2115
2238
  }
2116
2239
 
2117
2240
  if (!S.selection || (canvasMode !== "design" && canvasMode !== "edit")) {
2118
- litRender(nothing, blockActionBarEl);
2241
+ litRender(nothing, view.blockActionBarEl);
2119
2242
  return;
2120
2243
  }
2121
2244
 
2122
2245
  const activePanel = getActivePanel();
2123
2246
  if (!activePanel) {
2124
- litRender(nothing, blockActionBarEl);
2247
+ litRender(nothing, view.blockActionBarEl);
2125
2248
  return;
2126
2249
  }
2127
2250
  const el = findCanvasElement(S.selection, activePanel.canvas);
2128
2251
  const node = el && getNodeAtPath(S.document, S.selection);
2129
2252
  if (!el || !node) {
2130
- litRender(nothing, blockActionBarEl);
2253
+ litRender(nothing, view.blockActionBarEl);
2131
2254
  return;
2132
2255
  }
2133
2256
 
@@ -2158,6 +2281,32 @@ function renderBlockActionBar() {
2158
2281
  ? html`<span class="bar-drag-handle" title="Drag to reorder">⡇</span>`
2159
2282
  : nothing}
2160
2283
  ${S.selection.length >= 2 ? renderMoveArrows() : nothing}
2284
+ ${S.selection.length >= 2 && node.tagName
2285
+ ? (() => {
2286
+ const isComp =
2287
+ node.tagName.includes("-") &&
2288
+ componentRegistry.some((/** @type {any} */ c) => c.tagName === node.tagName);
2289
+ if (isComp) {
2290
+ const comp = componentRegistry.find(
2291
+ (/** @type {any} */ c) => c.tagName === node.tagName,
2292
+ );
2293
+ return html`<sp-action-button
2294
+ size="xs"
2295
+ quiet
2296
+ title="Edit Component"
2297
+ @click=${() => navigateToComponent(comp.path)}
2298
+ ><sp-icon-edit slot="icon" size="xs"></sp-icon-edit
2299
+ ></sp-action-button>`;
2300
+ }
2301
+ return html`<sp-action-button
2302
+ size="xs"
2303
+ quiet
2304
+ title="Convert to Component"
2305
+ @click=${() => convertToComponent(S)}
2306
+ ><sp-icon-box slot="icon" size="xs"></sp-icon-box
2307
+ ></sp-action-button>`;
2308
+ })()
2309
+ : nothing}
2161
2310
  ${showFormat
2162
2311
  ? html`
2163
2312
  <sp-divider size="s" vertical></sp-divider>
@@ -2186,12 +2335,12 @@ function renderBlockActionBar() {
2186
2335
  : nothing}
2187
2336
  </div>
2188
2337
  `,
2189
- blockActionBarEl,
2338
+ view.blockActionBarEl,
2190
2339
  );
2191
2340
 
2192
2341
  // Post-render side effects
2193
2342
  requestAnimationFrame(() => {
2194
- const bar = blockActionBarEl?.firstElementChild;
2343
+ const bar = view.blockActionBarEl?.firstElementChild;
2195
2344
  if (!bar) return;
2196
2345
  // Clamp to window
2197
2346
  const barRect = bar.getBoundingClientRect();
@@ -2202,7 +2351,11 @@ function renderBlockActionBar() {
2202
2351
  if (S.selection.length >= 2) {
2203
2352
  const handle = bar.querySelector(".bar-drag-handle");
2204
2353
  if (handle) {
2205
- selDragCleanup = draggable({
2354
+ if (view.selDragCleanup) {
2355
+ view.selDragCleanup();
2356
+ view.selDragCleanup = null;
2357
+ }
2358
+ view.selDragCleanup = draggable({
2206
2359
  element: handle,
2207
2360
  getInitialData: () => ({ type: "tree-node", path: S.selection }),
2208
2361
  });
@@ -2215,20 +2368,15 @@ function renderBlockActionBar() {
2215
2368
  // When a pseudo-selector (:hover, :focus, etc.) is active in the style sidebar,
2216
2369
  // force those styles onto the selected element so the user can see the result.
2217
2370
 
2218
- /** @type {any} */
2219
- let _forcedStyleTag = null;
2220
- /** @type {any} */
2221
- let _forcedAttrEl = null;
2222
-
2223
2371
  function updateForcedPseudoPreview() {
2224
2372
  // Clean up previous
2225
- if (_forcedStyleTag) {
2226
- _forcedStyleTag.remove();
2227
- _forcedStyleTag = null;
2373
+ if (view.forcedStyleTag) {
2374
+ view.forcedStyleTag.remove();
2375
+ view.forcedStyleTag = null;
2228
2376
  }
2229
- if (_forcedAttrEl) {
2230
- _forcedAttrEl.removeAttribute("data-studio-forced");
2231
- _forcedAttrEl = null;
2377
+ if (view.forcedAttrEl) {
2378
+ view.forcedAttrEl.removeAttribute("data-studio-forced");
2379
+ view.forcedAttrEl = null;
2232
2380
  }
2233
2381
 
2234
2382
  const sel = S.ui?.activeSelector;
@@ -2259,12 +2407,12 @@ function updateForcedPseudoPreview() {
2259
2407
  if (!cssProps) return;
2260
2408
 
2261
2409
  el.setAttribute("data-studio-forced", "1");
2262
- _forcedAttrEl = el;
2410
+ view.forcedAttrEl = el;
2263
2411
 
2264
2412
  const tag = document.createElement("style");
2265
2413
  tag.textContent = `[data-studio-forced] { ${cssProps} }`;
2266
2414
  document.head.appendChild(tag);
2267
- _forcedStyleTag = tag;
2415
+ view.forcedStyleTag = tag;
2268
2416
  }
2269
2417
 
2270
2418
  /**
@@ -2316,9 +2464,24 @@ function findCanvasElement(path, canvasEl) {
2316
2464
  } else {
2317
2465
  el = el.children[idx];
2318
2466
  }
2319
- if (!el) return null;
2467
+ if (!el) break;
2320
2468
  }
2321
- return el;
2469
+
2470
+ // Verify the result: if DOM traversal landed on the wrong element
2471
+ // (e.g. a custom element template child instead of the intended node),
2472
+ // fall back to scanning elToPath.
2473
+ if (el) {
2474
+ const elPath = elToPath.get(el);
2475
+ if (elPath && pathsEqual(elPath, path)) return el;
2476
+ // el has no path or wrong path — it's a template element, not the target
2477
+ }
2478
+
2479
+ // Fall back: scan all descendants for an element with matching elToPath
2480
+ for (const candidate of canvasEl.querySelectorAll("*")) {
2481
+ const p = elToPath.get(candidate);
2482
+ if (p && pathsEqual(p, path)) return candidate;
2483
+ }
2484
+ return null;
2322
2485
  }
2323
2486
 
2324
2487
  // ─── Per-panel click-to-select ────────────────────────────────────────────────
@@ -2326,6 +2489,9 @@ function findCanvasElement(path, canvasEl) {
2326
2489
  /** @param {any} panel */
2327
2490
  function registerPanelEvents(panel) {
2328
2491
  const { canvas, overlayClk, mediaName } = panel;
2492
+ const ac = new AbortController();
2493
+ const opts = { signal: ac.signal };
2494
+ view.canvasEventCleanups.push(() => ac.abort());
2329
2495
 
2330
2496
  /** @param {any} fn */
2331
2497
  function withPanelPointerEvents(fn) {
@@ -2341,162 +2507,191 @@ function registerPanelEvents(panel) {
2341
2507
  // During component inline edit, the overlayClk is disabled (see enterComponentInlineEdit).
2342
2508
  // No mousedown passthrough needed — native events reach the contenteditable directly.
2343
2509
 
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.
2510
+ overlayClk.addEventListener(
2511
+ "click",
2512
+ (/** @type {any} */ e) => {
2513
+ // Don't intercept clicks meant for the block action bar
2514
+ const barInner = view.blockActionBarEl?.firstElementChild;
2515
+ if (barInner) {
2516
+ const r = barInner.getBoundingClientRect();
2517
+ if (
2518
+ e.clientX >= r.left &&
2519
+ e.clientX <= r.right &&
2520
+ e.clientY >= r.top &&
2521
+ e.clientY <= r.bottom
2522
+ )
2523
+ return;
2524
+ }
2525
+ // If content-mode inline editing is active, treat click outside as blur
2526
+ if (isEditing()) {
2527
+ stopEditing();
2528
+ }
2364
2529
 
2365
- const elements = withPanelPointerEvents(() => document.elementsFromPoint(e.clientX, e.clientY));
2530
+ // Component-mode inline editing is handled by its own document-level listener
2531
+ // (see enterComponentInlineEdit), so nothing to do here — just fall through.
2366
2532
 
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 } };
2533
+ const elements = withPanelPointerEvents(() =>
2534
+ document.elementsFromPoint(e.clientX, e.clientY),
2535
+ );
2374
2536
 
2375
- // Find the DOM element for the bubbled path (may differ from hit element)
2376
- const resolvedEl = findCanvasElement(path, canvas) || el;
2537
+ for (const el of elements) {
2538
+ if (canvas.contains(el) && el !== canvas) {
2539
+ const originalPath = elToPath.get(el);
2540
+ if (originalPath) {
2541
+ let path = bubbleInlinePath(S.document, originalPath);
2542
+ const newMedia = mediaName === "base" ? null : (mediaName ?? null);
2543
+ const withMedia = { ...S, ui: { ...S.ui, activeMedia: newMedia } };
2544
+
2545
+ // Find the DOM element for the bubbled path (may differ from hit element)
2546
+ // When path didn't change (no inline bubbling), prefer the hit element directly
2547
+ // since findCanvasElement can't navigate into custom element template DOM.
2548
+ const resolvedEl = path === originalPath ? el : findCanvasElement(path, canvas) || el;
2549
+
2550
+ // Re-click on selected editable block: enter inline editing
2551
+ // Edit mode / content mode → rich text editing (enterInlineEdit)
2552
+ // Design mode → plaintext component editing (enterComponentInlineEdit via view.pendingInlineEdit)
2553
+ if (
2554
+ pathsEqual(path, S.selection) &&
2555
+ isEditableBlock(resolvedEl) &&
2556
+ (canvasMode === "edit" || S.mode === "content")
2557
+ ) {
2558
+ S = withMedia;
2559
+ enterInlineEdit(resolvedEl, path);
2560
+ return;
2561
+ }
2377
2562
 
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
- }
2563
+ // Design mode or first click: select and schedule component inline editing
2564
+ if (canvasMode === "design" && S.mode !== "content") {
2565
+ view.pendingInlineEdit = { path, mediaName };
2566
+ update(selectNode(withMedia, path));
2567
+ return;
2568
+ }
2389
2569
 
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));
2570
+ update(selectNode(withMedia, path));
2394
2571
  return;
2395
2572
  }
2396
-
2397
- update(selectNode(S, path));
2398
- return;
2399
2573
  }
2400
2574
  }
2401
- }
2402
- update(selectNode(S, null));
2403
- });
2575
+ update(selectNode(S, null));
2576
+ },
2577
+ opts,
2578
+ );
2404
2579
 
2405
2580
  // 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;
2581
+ overlayClk.addEventListener(
2582
+ "dblclick",
2583
+ (/** @type {any} */ e) => {
2584
+ const barInner = view.blockActionBarEl?.firstElementChild;
2585
+ if (barInner) {
2586
+ const r = barInner.getBoundingClientRect();
2587
+ if (
2588
+ e.clientX >= r.left &&
2589
+ e.clientX <= r.right &&
2590
+ e.clientY >= r.top &&
2591
+ e.clientY <= r.bottom
2592
+ )
2593
+ return;
2594
+ }
2595
+ if (canvasMode !== "edit" && canvasMode !== "design") return;
2419
2596
 
2420
- const elements = withPanelPointerEvents(() => document.elementsFromPoint(e.clientX, e.clientY));
2597
+ const elements = withPanelPointerEvents(() =>
2598
+ document.elementsFromPoint(e.clientX, e.clientY),
2599
+ );
2421
2600
 
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);
2601
+ for (const el of elements) {
2602
+ if (canvas.contains(el) && el !== canvas) {
2603
+ const originalPath = elToPath.get(el);
2604
+ if (originalPath) {
2605
+ const path = bubbleInlinePath(S.document, originalPath);
2606
+ const resolvedEl = path === originalPath ? el : findCanvasElement(path, canvas) || el;
2607
+ if (isEditableBlock(resolvedEl)) {
2608
+ const newMedia = mediaName === "base" ? null : (mediaName ?? null);
2609
+ const withMedia = { ...S, ui: { ...S.ui, activeMedia: newMedia } };
2610
+ update(selectNode(withMedia, path));
2611
+ enterInlineEdit(resolvedEl, path);
2612
+ return;
2613
+ }
2614
+ }
2615
+ }
2616
+ }
2617
+ },
2618
+ opts,
2619
+ );
2620
+
2621
+ overlayClk.addEventListener(
2622
+ "contextmenu",
2623
+ (/** @type {any} */ e) => {
2624
+ const barInner = view.blockActionBarEl?.firstElementChild;
2625
+ if (barInner) {
2626
+ const r = barInner.getBoundingClientRect();
2627
+ if (
2628
+ e.clientX >= r.left &&
2629
+ e.clientX <= r.right &&
2630
+ e.clientY >= r.top &&
2631
+ e.clientY <= r.bottom
2632
+ )
2633
+ return;
2634
+ }
2635
+ const elements = withPanelPointerEvents(() =>
2636
+ document.elementsFromPoint(e.clientX, e.clientY),
2637
+ );
2638
+ for (const el of elements) {
2639
+ if (canvas.contains(el) && el !== canvas) {
2640
+ let path = elToPath.get(el);
2641
+ if (path) {
2642
+ path = bubbleInlinePath(S.document, path);
2643
+ showContextMenu(e, path, S, { onEditComponent: navigateToComponent });
2433
2644
  return;
2434
2645
  }
2435
2646
  }
2436
2647
  }
2437
- }
2438
- });
2648
+ e.preventDefault();
2649
+ },
2650
+ opts,
2651
+ );
2439
2652
 
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) {
2653
+ overlayClk.addEventListener(
2654
+ "mousemove",
2655
+ (/** @type {any} */ e) => {
2656
+ const barInner = view.blockActionBarEl?.firstElementChild;
2657
+ if (barInner) {
2658
+ const r = barInner.getBoundingClientRect();
2659
+ if (
2660
+ e.clientX >= r.left &&
2661
+ e.clientX <= r.right &&
2662
+ e.clientY >= r.top &&
2663
+ e.clientY <= r.bottom
2664
+ )
2665
+ return;
2666
+ }
2667
+ const el = withPanelPointerEvents(() => document.elementFromPoint(e.clientX, e.clientY));
2668
+ if (el && canvas.contains(el) && el !== canvas) {
2455
2669
  let path = elToPath.get(el);
2456
2670
  if (path) {
2457
2671
  path = bubbleInlinePath(S.document, path);
2458
- showContextMenu(e, path, S);
2459
- return;
2672
+ if (!pathsEqual(path, S.hover)) {
2673
+ S = hoverNode(S, path);
2674
+ renderOverlays();
2675
+ }
2460
2676
  }
2677
+ } else if (S.hover) {
2678
+ S = hoverNode(S, null);
2679
+ renderOverlays();
2461
2680
  }
2462
- }
2463
- e.preventDefault();
2464
- });
2681
+ },
2682
+ opts,
2683
+ );
2465
2684
 
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
- }
2685
+ overlayClk.addEventListener(
2686
+ "mouseleave",
2687
+ () => {
2688
+ if (S.hover) {
2689
+ S = hoverNode(S, null);
2690
+ renderOverlays();
2487
2691
  }
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
- });
2692
+ },
2693
+ opts,
2694
+ );
2500
2695
  }
2501
2696
 
2502
2697
  // ─── Inline editing bridge ────────────────────────────────────────────────────
@@ -2589,12 +2784,63 @@ function enterInlineEdit(el, path) {
2589
2784
  });
2590
2785
  },
2591
2786
 
2592
- onInsert(/** @type {any} */ afterPath, /** @type {any} */ cmd) {
2787
+ onInsert(/** @type {any} */ afterPath, /** @type {any} */ cmd, /** @type {any} */ commitData) {
2593
2788
  // cmd comes from the shared slash menu: { label, tag, description }
2789
+ const isEmpty =
2790
+ !commitData ||
2791
+ (commitData.textContent != null && commitData.textContent.trim() === "") ||
2792
+ (commitData.children &&
2793
+ (commitData.children.length === 0 ||
2794
+ (commitData.children.length === 1 &&
2795
+ typeof commitData.children[0] === "string" &&
2796
+ commitData.children[0].trim() === "") ||
2797
+ (commitData.children.length === 1 &&
2798
+ typeof commitData.children[0] === "object" &&
2799
+ commitData.children[0]?.tagName === "br")));
2800
+
2801
+ // If the element is empty, swap its tagName instead of inserting after
2802
+ if (isEmpty) {
2803
+ let s = S;
2804
+ s = updateProperty(s, afterPath, "tagName", cmd.tag);
2805
+ s = updateProperty(s, afterPath, "children", undefined);
2806
+ const def = defaultDef(cmd.tag);
2807
+ if (def.textContent && def.textContent !== "Paragraph text") {
2808
+ s = updateProperty(s, afterPath, "textContent", def.textContent);
2809
+ } else {
2810
+ s = updateProperty(s, afterPath, "textContent", undefined);
2811
+ }
2812
+ s = selectNode(s, afterPath);
2813
+ update(s);
2814
+
2815
+ requestAnimationFrame(() => {
2816
+ const activePanel = getActivePanel();
2817
+ if (activePanel) {
2818
+ const el = findCanvasElement(afterPath, activePanel.canvas);
2819
+ if (el && isEditableBlock(el)) {
2820
+ enterInlineEdit(el, afterPath);
2821
+ }
2822
+ }
2823
+ });
2824
+ return;
2825
+ }
2826
+
2594
2827
  const elementDef = defaultDef(cmd.tag);
2595
2828
  const parentPath = /** @type {any} */ (parentElementPath(afterPath));
2596
2829
  const idx = /** @type {number} */ (childIndex(afterPath));
2597
- let s = insertNode(S, parentPath, idx + 1, structuredClone(elementDef));
2830
+
2831
+ // Apply pending commit from inline edit first (batched to avoid double render)
2832
+ let s = S;
2833
+ if (commitData) {
2834
+ if (commitData.children) {
2835
+ s = updateProperty(s, afterPath, "textContent", undefined);
2836
+ s = updateProperty(s, afterPath, "children", commitData.children);
2837
+ } else if (commitData.textContent != null) {
2838
+ s = updateProperty(s, afterPath, "children", undefined);
2839
+ s = updateProperty(s, afterPath, "textContent", commitData.textContent);
2840
+ }
2841
+ }
2842
+
2843
+ s = insertNode(s, parentPath, idx + 1, structuredClone(elementDef));
2598
2844
  const newPath = [...parentPath, "children", idx + 1];
2599
2845
  s = selectNode(s, newPath);
2600
2846
  update(s);
@@ -2613,9 +2859,9 @@ function enterInlineEdit(el, path) {
2613
2859
 
2614
2860
  onEnd() {
2615
2861
  // Cleanup inline edit listeners
2616
- if (_inlineEditCleanup) {
2617
- _inlineEditCleanup();
2618
- _inlineEditCleanup = null;
2862
+ if (view.inlineEditCleanup) {
2863
+ view.inlineEditCleanup();
2864
+ view.inlineEditCleanup = null;
2619
2865
  }
2620
2866
  // Restore overlays after inline editing ends
2621
2867
  for (const p of canvasPanels) {
@@ -2642,7 +2888,7 @@ function enterInlineEdit(el, path) {
2642
2888
  el.removeEventListener("mouseup", selectionHandler);
2643
2889
  el.removeEventListener("keyup", selectionHandler);
2644
2890
  };
2645
- _inlineEditCleanup = inlineEditCleanup;
2891
+ view.inlineEditCleanup = inlineEditCleanup;
2646
2892
  }
2647
2893
 
2648
2894
  // ─── Component-mode inline text editing ──────────────────────────────────────
@@ -2653,7 +2899,7 @@ function enterInlineEdit(el, path) {
2653
2899
  */
2654
2900
  function enterComponentInlineEdit(el, path) {
2655
2901
  // Already editing this element
2656
- if (componentInlineEdit && componentInlineEdit.el === el) {
2902
+ if (view.componentInlineEdit && view.componentInlineEdit.el === el) {
2657
2903
  return;
2658
2904
  }
2659
2905
 
@@ -2666,7 +2912,7 @@ function enterComponentInlineEdit(el, path) {
2666
2912
  if (Array.isArray(node.children) && node.children.length > 0) return;
2667
2913
  if (node.children && typeof node.children === "object") return;
2668
2914
  if (tc && typeof tc === "object") return;
2669
- const voids = new Set(["img", "input", "br", "hr", "video", "audio", "source", "embed"]);
2915
+ const voids = new Set(["img", "input", "br", "hr", "video", "audio", "source", "embed", "slot"]);
2670
2916
  if (voids.has(node.tagName)) return;
2671
2917
 
2672
2918
  // Keep overlay visible for the label, but hide selection border to not obscure editing outline.
@@ -2690,7 +2936,7 @@ function enterComponentInlineEdit(el, path) {
2690
2936
  const rawText = typeof tc === "string" ? tc : "";
2691
2937
  el.textContent = rawText;
2692
2938
 
2693
- componentInlineEdit = {
2939
+ view.componentInlineEdit = {
2694
2940
  el,
2695
2941
  path,
2696
2942
  originalText: rawText,
@@ -2712,15 +2958,15 @@ function enterComponentInlineEdit(el, path) {
2712
2958
  // Document-level mousedown: clicking outside the editing element commits
2713
2959
  // the edit and selects the new target element for inline editing.
2714
2960
  const outsideHandler = (/** @type {any} */ evt) => {
2715
- if (!componentInlineEdit) {
2961
+ if (!view.componentInlineEdit) {
2716
2962
  document.removeEventListener("mousedown", outsideHandler, true);
2717
2963
  return;
2718
2964
  }
2719
- if (componentInlineEdit.el.contains(evt.target)) return; // click within editing el — let it through
2965
+ if (view.componentInlineEdit.el.contains(evt.target)) return; // click within editing el — let it through
2720
2966
  // Let clicks through when the slash command menu is open
2721
2967
  if (isSlashMenuOpen()) return;
2722
2968
  // Let clicks inside the block action bar through
2723
- if (blockActionBarEl && blockActionBarEl.contains(evt.target)) return;
2969
+ if (view.blockActionBarEl && view.blockActionBarEl.contains(evt.target)) return;
2724
2970
  document.removeEventListener("mousedown", outsideHandler, true);
2725
2971
 
2726
2972
  // Hit-test BEFORE commit (while the current canvas DOM + elToPath are still valid)
@@ -2747,7 +2993,7 @@ function enterComponentInlineEdit(el, path) {
2747
2993
  }
2748
2994
 
2749
2995
  // Commit + select new element in a single state update if possible
2750
- const { el: editEl, path: editPath, originalText } = componentInlineEdit;
2996
+ const { el: editEl, path: editPath, originalText } = view.componentInlineEdit;
2751
2997
  const newText = (editEl.textContent ?? "").trim();
2752
2998
  cleanupComponentInlineEdit(editEl);
2753
2999
 
@@ -2757,26 +3003,29 @@ function enterComponentInlineEdit(el, path) {
2757
3003
 
2758
3004
  if (hitPath) {
2759
3005
  const media = hitMedia === "base" ? null : (hitMedia ?? null);
2760
- pendingInlineEdit = { path: hitPath, mediaName: hitMedia };
2761
- S = { ...S, ui: { ...S.ui, activeMedia: media } };
3006
+ view.pendingInlineEdit = { path: hitPath, mediaName: hitMedia };
3007
+ const withMedia = { ...S, ui: { ...S.ui, activeMedia: media } };
2762
3008
  if (isEmpty && pPath) {
2763
3009
  // Remove empty node; adjust hitPath if it shifts after removal
2764
- let s = removeNode(S, editPath);
3010
+ let s = removeNode(withMedia, editPath);
2765
3011
  // If hit path is a later sibling in the same parent, adjust index
2766
3012
  const removedIdx = /** @type {number} */ (childIndex(editPath));
2767
3013
  const hitIdx = /** @type {number} */ (childIndex(hitPath));
2768
3014
  const hitParent = parentElementPath(hitPath);
2769
3015
  if (hitParent && pPath && hitParent.join("/") === pPath.join("/") && hitIdx > removedIdx) {
2770
3016
  hitPath = [...pPath, "children", hitIdx - 1];
2771
- pendingInlineEdit = { path: hitPath, mediaName: hitMedia };
3017
+ view.pendingInlineEdit = { path: hitPath, mediaName: hitMedia };
2772
3018
  }
2773
3019
  update(selectNode(s, hitPath));
2774
3020
  } else if (newText !== originalText) {
2775
3021
  update(
2776
- selectNode(updateProperty(S, editPath, "textContent", newText || undefined), hitPath),
3022
+ selectNode(
3023
+ updateProperty(withMedia, editPath, "textContent", newText || undefined),
3024
+ hitPath,
3025
+ ),
2777
3026
  );
2778
3027
  } else {
2779
- update(selectNode(S, hitPath));
3028
+ update(selectNode(withMedia, hitPath));
2780
3029
  }
2781
3030
  } else {
2782
3031
  // Clicked on empty space — just commit
@@ -2791,7 +3040,7 @@ function enterComponentInlineEdit(el, path) {
2791
3040
  }
2792
3041
  };
2793
3042
  document.addEventListener("mousedown", outsideHandler, true);
2794
- componentInlineEdit._outsideHandler = outsideHandler;
3043
+ view.componentInlineEdit._outsideHandler = outsideHandler;
2795
3044
 
2796
3045
  // Re-render block action bar to show inline formatting buttons
2797
3046
  renderBlockActionBar();
@@ -2815,8 +3064,8 @@ function componentInlineKeydown(e) {
2815
3064
  }
2816
3065
 
2817
3066
  function splitParagraph() {
2818
- if (!componentInlineEdit) return;
2819
- const { el, path, mediaName } = componentInlineEdit;
3067
+ if (!view.componentInlineEdit) return;
3068
+ const { el, path, mediaName } = view.componentInlineEdit;
2820
3069
 
2821
3070
  // Determine cursor offset within text
2822
3071
  const sel = /** @type {any} */ (el.ownerDocument.defaultView?.getSelection());
@@ -2848,13 +3097,13 @@ function splitParagraph() {
2848
3097
  s = insertNode(s, pPath, idx + 1, newDef);
2849
3098
  s = selectNode(s, newPath);
2850
3099
 
2851
- pendingInlineEdit = { path: newPath, mediaName };
3100
+ view.pendingInlineEdit = { path: newPath, mediaName };
2852
3101
  update(s);
2853
3102
  }
2854
3103
 
2855
3104
  function _commitComponentInlineEdit() {
2856
- if (!componentInlineEdit) return;
2857
- const { el, path, originalText } = componentInlineEdit;
3105
+ if (!view.componentInlineEdit) return;
3106
+ const { el, path, originalText } = view.componentInlineEdit;
2858
3107
  const newText = (el.textContent ?? "").trim();
2859
3108
 
2860
3109
  cleanupComponentInlineEdit(el);
@@ -2872,8 +3121,8 @@ function _commitComponentInlineEdit() {
2872
3121
  }
2873
3122
 
2874
3123
  function cancelComponentInlineEdit() {
2875
- if (!componentInlineEdit) return;
2876
- const { el } = componentInlineEdit;
3124
+ if (!view.componentInlineEdit) return;
3125
+ const { el } = view.componentInlineEdit;
2877
3126
  cleanupComponentInlineEdit(el);
2878
3127
  renderCanvas();
2879
3128
  renderOverlays();
@@ -2892,10 +3141,10 @@ function cleanupComponentInlineEdit(el) {
2892
3141
  el.style.pointerEvents = "";
2893
3142
 
2894
3143
  // Remove the document-level outside-click handler
2895
- if (componentInlineEdit?._outsideHandler) {
2896
- document.removeEventListener("mousedown", componentInlineEdit._outsideHandler, true);
3144
+ if (view.componentInlineEdit?._outsideHandler) {
3145
+ document.removeEventListener("mousedown", view.componentInlineEdit._outsideHandler, true);
2897
3146
  }
2898
- componentInlineEdit = null;
3147
+ view.componentInlineEdit = null;
2899
3148
 
2900
3149
  // Restore overlay and click interceptor
2901
3150
  for (const p of canvasPanels) {
@@ -2907,8 +3156,8 @@ function cleanupComponentInlineEdit(el) {
2907
3156
  // ─── Component-mode slash commands (delegates to shared slash-menu.js) ────────
2908
3157
 
2909
3158
  function componentInlineInput() {
2910
- if (!componentInlineEdit) return;
2911
- const { el, originalText } = componentInlineEdit;
3159
+ if (!view.componentInlineEdit) return;
3160
+ const { el, originalText } = view.componentInlineEdit;
2912
3161
  const text = el.textContent || "";
2913
3162
 
2914
3163
  // Only trigger slash menu when the paragraph was originally empty and starts with /
@@ -2922,8 +3171,8 @@ function componentInlineInput() {
2922
3171
 
2923
3172
  /** @param {any} cmd */
2924
3173
  function handleComponentSlashSelect(cmd) {
2925
- if (!componentInlineEdit) return;
2926
- const { el, path, mediaName } = componentInlineEdit;
3174
+ if (!view.componentInlineEdit) return;
3175
+ const { el, path, mediaName } = view.componentInlineEdit;
2927
3176
  const pPath = parentElementPath(path);
2928
3177
  const idx = /** @type {number} */ (childIndex(path));
2929
3178
  if (!pPath) return;
@@ -2940,7 +3189,7 @@ function handleComponentSlashSelect(cmd) {
2940
3189
 
2941
3190
  // If the new element has textContent, enter inline edit on it
2942
3191
  const hasText = newDef.textContent != null;
2943
- if (hasText) pendingInlineEdit = { path: newPath, mediaName };
3192
+ if (hasText) view.pendingInlineEdit = { path: newPath, mediaName };
2944
3193
  update(s);
2945
3194
  }
2946
3195
 
@@ -2952,7 +3201,7 @@ function renderLeftPanel() {
2952
3201
  /** @type {any} */
2953
3202
  let content;
2954
3203
  if (tab === "layers")
2955
- content = canvasMode === "stylebook" ? renderStylebookLayersTemplate() : renderLayersTemplate();
3204
+ content = canvasMode === "settings" ? renderStylebookLayersTemplate() : renderLayersTemplate();
2956
3205
  else if (tab === "imports")
2957
3206
  content = renderImportsTemplate({
2958
3207
  renderLeftPanel,
@@ -2965,20 +3214,45 @@ function renderLeftPanel() {
2965
3214
  });
2966
3215
  else if (tab === "files") content = renderFilesTemplate();
2967
3216
  else if (tab === "blocks") content = renderElementsTemplate();
2968
- else if (tab === "state") content = renderSignalsTemplate(S, { renderLeftPanel, renderCanvas });
3217
+ else if (tab === "state")
3218
+ content = renderSignalsTemplate(S, { renderLeftPanel, renderCanvas, updateSession });
2969
3219
  else if (tab === "data")
2970
- content = renderDataExplorerTemplate(S.document.state, liveScope, {
3220
+ content = renderDataExplorerTemplate(S.document.state, view.liveScope, {
2971
3221
  renderCanvas,
2972
3222
  renderLeftPanel,
2973
3223
  defCategory,
2974
3224
  defBadgeLabel,
2975
3225
  });
2976
- else content = nothing;
3226
+ else if (tab === "head") {
3227
+ // In content mode, title/$head live in S.content.frontmatter, not S.document
3228
+ const isContent = S.mode === "content";
3229
+ const fm = S.content?.frontmatter ?? {};
3230
+ const headDoc = isContent ? { ...S.document, title: fm.title, $head: fm.$head } : S.document;
3231
+ content = renderHeadTemplate({
3232
+ document: headDoc,
3233
+ applyMutation: isContent
3234
+ ? (/** @type {any} */ fn) => {
3235
+ // Apply mutation to a temporary doc, then sync title/$head back to frontmatter
3236
+ const tmp = { title: fm.title, $head: fm.$head ? [...fm.$head] : undefined };
3237
+ fn(tmp);
3238
+ if (tmp.title !== fm.title) S = updateFrontmatter(S, "title", tmp.title);
3239
+ // Always sync $head (may have been created, modified, or emptied)
3240
+ const newHead = tmp.$head && tmp.$head.length > 0 ? tmp.$head : undefined;
3241
+ S = updateFrontmatter(S, "$head", newHead);
3242
+ update(S);
3243
+ }
3244
+ : (/** @type {any} */ fn) => {
3245
+ S = applyMutation(S, fn);
3246
+ update(S);
3247
+ },
3248
+ renderLeftPanel,
3249
+ });
3250
+ } else content = nothing;
2977
3251
 
2978
3252
  litRender(html`<div class="panel-body">${content}</div>`, /** @type {any} */ (leftPanel));
2979
3253
 
2980
3254
  // Post-render side effects
2981
- if (tab === "layers" && canvasMode !== "stylebook") registerLayersDnD();
3255
+ if (tab === "layers" && canvasMode !== "settings") registerLayersDnD();
2982
3256
  else if (tab === "imports") {
2983
3257
  /* no post-render DnD needed */
2984
3258
  } else if (tab === "blocks") {
@@ -2993,8 +3267,8 @@ function renderLeftPanel() {
2993
3267
  /** Returns a TemplateResult — called from renderLeftPanel only when tab=layers & not stylebook */
2994
3268
  function renderLayersTemplate() {
2995
3269
  // Clean up previous DnD registrations
2996
- for (const fn of dndCleanups) fn();
2997
- dndCleanups = [];
3270
+ for (const fn of view.dndCleanups) fn();
3271
+ view.dndCleanups = [];
2998
3272
 
2999
3273
  const rows = flattenTree(S.document);
3000
3274
  const collapsed = S._collapsed || (S._collapsed = new Set());
@@ -3014,6 +3288,9 @@ function renderLayersTemplate() {
3014
3288
  }
3015
3289
  if (hidden) continue;
3016
3290
 
3291
+ // In content mode, skip the document root row (it's not a real element)
3292
+ if (S.mode === "content" && path.length === 0) continue;
3293
+
3017
3294
  // Text node children: display-only row with truncated preview
3018
3295
  if (nodeType === "text") {
3019
3296
  const textPreview = String(node).length > 40 ? String(node).slice(0, 40) + "…" : String(node);
@@ -3084,7 +3361,7 @@ function renderLayersTemplate() {
3084
3361
 
3085
3362
  // Compute move-button availability for element nodes
3086
3363
  const isElement = nodeType === "element";
3087
- const isRoot = path.length < 2;
3364
+ const isRoot = S.mode === "content" ? path.length === 0 : path.length < 2;
3088
3365
  const idx = isElement ? /** @type {number} */ (childIndex(path)) : 0;
3089
3366
  const parentPath = isElement && !isRoot ? /** @type {any} */ (parentElementPath(path)) : null;
3090
3367
  const parentNode = parentPath ? getNodeAtPath(S.document, parentPath) : null;
@@ -3113,7 +3390,10 @@ function renderLayersTemplate() {
3113
3390
  data-dnd-depth=${isElement ? depth : nothing}
3114
3391
  data-dnd-void=${isElement && isVoidEl ? "" : nothing}
3115
3392
  @click=${() => update(selectNode(S, path))}
3116
- @contextmenu=${isElement ? (/** @type {any} */ e) => showContextMenu(e, path, S) : nothing}
3393
+ @contextmenu=${isElement
3394
+ ? (/** @type {any} */ e) =>
3395
+ showContextMenu(e, path, S, { onEditComponent: navigateToComponent })
3396
+ : nothing}
3117
3397
  >
3118
3398
  <span class="layer-indent" style="width:${depth * 16}px"></span>
3119
3399
  <span class="layer-toggle"
@@ -3267,7 +3547,7 @@ function registerLayersDnD() {
3267
3547
  },
3268
3548
  onDragStart() {
3269
3549
  row.classList.add("dragging");
3270
- layerDragSourceHeight = row.offsetHeight;
3550
+ view.layerDragSourceHeight = row.offsetHeight;
3271
3551
  },
3272
3552
  onDrop() {
3273
3553
  row.classList.remove("dragging");
@@ -3306,7 +3586,7 @@ function registerLayersDnD() {
3306
3586
  },
3307
3587
  }),
3308
3588
  );
3309
- dndCleanups.push(cleanup);
3589
+ view.dndCleanups.push(cleanup);
3310
3590
  },
3311
3591
  );
3312
3592
 
@@ -3323,7 +3603,7 @@ function registerLayersDnD() {
3323
3603
  applyDropInstruction(instruction, srcData, targetPath);
3324
3604
  },
3325
3605
  });
3326
- dndCleanups.push(monitorCleanup);
3606
+ view.dndCleanups.push(monitorCleanup);
3327
3607
  });
3328
3608
  }
3329
3609
 
@@ -3364,15 +3644,13 @@ function registerComponentsDnD() {
3364
3644
  return { type: "block", fragment: structuredClone(instanceDef) };
3365
3645
  },
3366
3646
  });
3367
- dndCleanups.push(cleanup);
3647
+ view.dndCleanups.push(cleanup);
3368
3648
  },
3369
3649
  );
3370
3650
  });
3371
3651
  }
3372
3652
 
3373
3653
  /** @type {any} */
3374
- let _currentDropTargetRow = null;
3375
- let layerDragSourceHeight = 0;
3376
3654
 
3377
3655
  /**
3378
3656
  * @param {any} rowEl
@@ -3383,8 +3661,8 @@ function showLayerDropGap(rowEl, data, container) {
3383
3661
  const instruction = extractInstruction(data);
3384
3662
 
3385
3663
  // Clear previous drop-target highlight
3386
- if (_currentDropTargetRow && _currentDropTargetRow !== rowEl) {
3387
- _currentDropTargetRow.classList.remove("drop-target");
3664
+ if (view._currentDropTargetRow && view._currentDropTargetRow !== rowEl) {
3665
+ view._currentDropTargetRow.classList.remove("drop-target");
3388
3666
  }
3389
3667
 
3390
3668
  if (!instruction || instruction.type === "instruction-blocked") {
@@ -3395,17 +3673,17 @@ function showLayerDropGap(rowEl, data, container) {
3395
3673
  if (instruction.type === "make-child") {
3396
3674
  clearLayerDropGap(container);
3397
3675
  rowEl.classList.add("drop-target");
3398
- _currentDropTargetRow = rowEl;
3676
+ view._currentDropTargetRow = rowEl;
3399
3677
  return;
3400
3678
  }
3401
3679
 
3402
3680
  rowEl.classList.remove("drop-target");
3403
- _currentDropTargetRow = rowEl;
3681
+ view._currentDropTargetRow = rowEl;
3404
3682
 
3405
3683
  // Shift rows to create gap
3406
3684
  const rows = Array.from(container.querySelectorAll(".layers-tree .layer-row"));
3407
3685
  const targetIdx = rows.indexOf(rowEl);
3408
- const gap = layerDragSourceHeight;
3686
+ const gap = view.layerDragSourceHeight;
3409
3687
 
3410
3688
  for (let i = 0; i < rows.length; i++) {
3411
3689
  if (rows[i].classList.contains("dragging")) continue;
@@ -3419,9 +3697,9 @@ function showLayerDropGap(rowEl, data, container) {
3419
3697
 
3420
3698
  /** @param {any} container */
3421
3699
  function clearLayerDropGap(container) {
3422
- if (_currentDropTargetRow) {
3423
- _currentDropTargetRow.classList.remove("drop-target");
3424
- _currentDropTargetRow = null;
3700
+ if (view._currentDropTargetRow) {
3701
+ view._currentDropTargetRow.classList.remove("drop-target");
3702
+ view._currentDropTargetRow = null;
3425
3703
  }
3426
3704
  const rows = container.querySelectorAll(".layers-tree .layer-row");
3427
3705
  for (const r of rows) r.style.transform = "";
@@ -3434,25 +3712,22 @@ function clearLayerDropGap(container) {
3434
3712
  * @param {string | null} [media]
3435
3713
  */
3436
3714
  function selectStylebookTag(tag, media) {
3437
- S = {
3438
- ...S,
3715
+ updateSession({
3439
3716
  selection: [],
3440
3717
  ui: {
3441
- ...S.ui,
3442
3718
  stylebookSelection: tag,
3443
3719
  rightTab: "style",
3444
3720
  activeSelector: `& ${tag}`,
3445
3721
  ...(media !== undefined ? { activeMedia: media } : {}),
3446
3722
  },
3447
- };
3723
+ });
3448
3724
  renderStylebookOverlays();
3449
- renderRightPanel();
3450
- renderLeftPanel();
3451
- renderToolbar();
3452
- if (canvasPanels.length > 0) {
3453
- const el = findStylebookEl(canvasPanels[0].canvas, tag);
3454
- if (el) el.scrollIntoView({ behavior: "smooth", block: "center" });
3455
- }
3725
+ requestAnimationFrame(() => {
3726
+ if (canvasPanels.length > 0) {
3727
+ const el = findStylebookEl(canvasPanels[0].canvas, tag);
3728
+ if (el) el.scrollIntoView({ behavior: "smooth", block: "center" });
3729
+ }
3730
+ });
3456
3731
  }
3457
3732
 
3458
3733
  function renderStylebookLayersTemplate() {
@@ -3705,18 +3980,18 @@ const unsafeTags = new Set(["script", "style", "link", "iframe", "object", "embe
3705
3980
  function renderElementsTemplate() {
3706
3981
  const categories = Object.entries(webdata.elements).map(
3707
3982
  (/** @type {any} */ [category, elements]) => {
3708
- const filtered = elementsFilter
3709
- ? elements.filter((/** @type {any} */ e) => e.tag.includes(elementsFilter))
3983
+ const filtered = view.elementsFilter
3984
+ ? elements.filter((/** @type {any} */ e) => e.tag.includes(view.elementsFilter))
3710
3985
  : elements;
3711
3986
  if (filtered.length === 0) return nothing;
3712
3987
 
3713
3988
  return html`
3714
3989
  <sp-accordion-item
3715
3990
  label=${category}
3716
- ?open=${!elementsCollapsed.has(category)}
3991
+ ?open=${!view.elementsCollapsed.has(category)}
3717
3992
  @sp-accordion-item-toggle=${(/** @type {any} */ e) => {
3718
- if (e.target.open) elementsCollapsed.delete(category);
3719
- else elementsCollapsed.add(category);
3993
+ if (e.target.open) view.elementsCollapsed.delete(category);
3994
+ else view.elementsCollapsed.add(category);
3720
3995
  }}
3721
3996
  >
3722
3997
  ${filtered.map((/** @type {any} */ { tag }) => {
@@ -3768,7 +4043,7 @@ function renderElementsTemplate() {
3768
4043
  .filter((/** @type {any} */ c) => c.source !== "npm" || enabledTags.has(c.tagName))
3769
4044
  .filter(
3770
4045
  (/** @type {any} */ c) =>
3771
- !elementsFilter || c.tagName.toLowerCase().includes(elementsFilter),
4046
+ !view.elementsFilter || c.tagName.toLowerCase().includes(view.elementsFilter),
3772
4047
  )
3773
4048
  : [];
3774
4049
 
@@ -3777,10 +4052,10 @@ function renderElementsTemplate() {
3777
4052
  ? html`
3778
4053
  <sp-accordion-item
3779
4054
  label="Components"
3780
- ?open=${!elementsCollapsed.has("Components")}
4055
+ ?open=${!view.elementsCollapsed.has("Components")}
3781
4056
  @sp-accordion-item-toggle=${(/** @type {any} */ e) => {
3782
- if (e.target.open) elementsCollapsed.delete("Components");
3783
- else elementsCollapsed.add("Components");
4057
+ if (e.target.open) view.elementsCollapsed.delete("Components");
4058
+ else view.elementsCollapsed.add("Components");
3784
4059
  }}
3785
4060
  >
3786
4061
  <div class="components-section">
@@ -3826,9 +4101,9 @@ function renderElementsTemplate() {
3826
4101
  <sp-search
3827
4102
  size="s"
3828
4103
  placeholder="Filter elements…"
3829
- value=${elementsFilter}
4104
+ value=${view.elementsFilter}
3830
4105
  @input=${(/** @type {any} */ e) => {
3831
- elementsFilter = e.target.value.toLowerCase();
4106
+ view.elementsFilter = e.target.value.toLowerCase();
3832
4107
  renderLeftPanel();
3833
4108
  }}
3834
4109
  ></sp-search>
@@ -3858,7 +4133,7 @@ function registerElementsDnD() {
3858
4133
  return { type: "block", fragment: structuredClone(def) };
3859
4134
  },
3860
4135
  });
3861
- dndCleanups.push(cleanup);
4136
+ view.dndCleanups.push(cleanup);
3862
4137
  },
3863
4138
  );
3864
4139
  });
@@ -3867,7 +4142,6 @@ function registerElementsDnD() {
3867
4142
  // ─── Stylebook ───────────────────────────────────────────────────────────────
3868
4143
 
3869
4144
  /** Map from rendered stylebook DOM elements to their tag names */
3870
- let stylebookElToTag = new WeakMap();
3871
4145
 
3872
4146
  /**
3873
4147
  * Build a DOM element tree from a stylebook-meta.json entry. Applies any existing tag-scoped styles
@@ -3970,8 +4244,52 @@ function hasTagStyle(rootStyle, tag) {
3970
4244
  return s && typeof s === "object" && Object.keys(s).length > 0;
3971
4245
  }
3972
4246
 
3973
- function renderStylebook() {
3974
- stylebookElToTag = new WeakMap();
4247
+ function renderSettings() {
4248
+ const settingsTab = S.ui.settingsTab || "stylebook";
4249
+
4250
+ // Top-level settings tabs chrome bar
4251
+ const settingsChromeBarTpl = html`
4252
+ <div
4253
+ class="sb-chrome settings-top-chrome"
4254
+ style="position:absolute;top:0;left:0;right:0;z-index:16;background:var(--bg-panel);border-bottom:1px solid var(--border)"
4255
+ >
4256
+ <sp-tabs
4257
+ size="s"
4258
+ selected=${settingsTab}
4259
+ @change=${(/** @type {any} */ e) => {
4260
+ updateUi("settingsTab", e.target.selected);
4261
+ }}
4262
+ >
4263
+ <sp-tab label="Stylebook" value="stylebook"></sp-tab>
4264
+ <sp-tab label="Definitions" value="definitions"></sp-tab>
4265
+ <sp-tab label="Collections" value="collections"></sp-tab>
4266
+ </sp-tabs>
4267
+ </div>
4268
+ `;
4269
+
4270
+ // Non-stylebook tabs: render editor into canvasWrap with offset for chrome bar
4271
+ if (settingsTab === "definitions" || settingsTab === "collections") {
4272
+ /** @type {any} */ (canvasWrap).style.overflow = "hidden";
4273
+
4274
+ litRender(
4275
+ html`${settingsChromeBarTpl}
4276
+ <div
4277
+ class="settings-editor-container"
4278
+ style="position:absolute;inset:40px 0 0 0;overflow:auto"
4279
+ ></div>`,
4280
+ /** @type {any} */ (canvasWrap),
4281
+ );
4282
+
4283
+ const container = /** @type {HTMLElement} */ (
4284
+ canvasWrap.querySelector(".settings-editor-container")
4285
+ );
4286
+ if (settingsTab === "definitions") renderDefsEditor(container);
4287
+ else renderCollectionsEditor(container);
4288
+ return;
4289
+ }
4290
+
4291
+ // Stylebook tab — existing behavior
4292
+ view.stylebookElToTag = new WeakMap();
3975
4293
  const rootStyle = getEffectiveStyle(S.document.style);
3976
4294
  const filter = (S.ui.stylebookFilter || "").toLowerCase();
3977
4295
  const customizedOnly = S.ui.stylebookCustomizedOnly;
@@ -3981,38 +4299,33 @@ function renderStylebook() {
3981
4299
 
3982
4300
  // Chrome bar (tabs + filter) — positioned absolutely above the panzoom surface
3983
4301
  const onTabClick = (/** @type {string} */ t) => {
3984
- S = { ...S, ui: { ...S.ui, stylebookTab: t } };
3985
- renderCanvas();
3986
- renderOverlays();
3987
- renderLeftPanel();
4302
+ updateUi("stylebookTab", t);
3988
4303
  };
3989
4304
 
3990
4305
  const onFilterInput = (/** @type {any} */ e) => {
3991
- S = { ...S, ui: { ...S.ui, stylebookFilter: e.target.value } };
3992
- renderCanvas();
3993
- renderOverlays();
4306
+ updateUi("stylebookFilter", e.target.value);
3994
4307
  };
3995
4308
 
3996
4309
  const onCustomizedToggle = () => {
3997
- S = { ...S, ui: { ...S.ui, stylebookCustomizedOnly: !S.ui.stylebookCustomizedOnly } };
3998
- renderCanvas();
3999
- renderOverlays();
4310
+ updateUi("stylebookCustomizedOnly", !S.ui.stylebookCustomizedOnly);
4000
4311
  };
4001
4312
 
4002
4313
  const chromeBarTpl = html`
4314
+ ${settingsChromeBarTpl}
4003
4315
  <div
4004
4316
  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)"
4317
+ style="position:absolute;top:36px;left:0;right:0;z-index:15;background:var(--bg-panel);border-bottom:1px solid var(--border)"
4006
4318
  >
4007
- <sp-tabs size="s">
4319
+ <sp-tabs
4320
+ size="s"
4321
+ selected=${S.ui.stylebookTab || "elements"}
4322
+ @change=${(/** @type {any} */ e) => {
4323
+ onTabClick(e.target.selected);
4324
+ }}
4325
+ >
4008
4326
  ${["elements", "variables"].map(
4009
4327
  (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>
4328
+ <sp-tab label=${t.charAt(0).toUpperCase() + t.slice(1)} value=${t}></sp-tab>
4016
4329
  `,
4017
4330
  )}
4018
4331
  </sp-tabs>
@@ -4058,7 +4371,6 @@ function renderStylebook() {
4058
4371
  activeSet: activeBreakpointsForWidth(sizeBreakpoints, bp.width),
4059
4372
  });
4060
4373
  }
4061
- allPanelDefs.sort((a, b) => b.width - a.width);
4062
4374
  }
4063
4375
 
4064
4376
  // Render content into panels
@@ -4110,9 +4422,9 @@ function renderStylebook() {
4110
4422
  ${chromeBarTpl}
4111
4423
  <div
4112
4424
  class="panzoom-wrap"
4113
- style="transform-origin:0 0;padding-top:36px"
4425
+ style="transform-origin:0 0;padding-top:72px"
4114
4426
  ${ref((el) => {
4115
- if (el) panzoomWrap = /** @type {HTMLDivElement} */ (el);
4427
+ if (el) view.panzoomWrap = /** @type {HTMLDivElement} */ (el);
4116
4428
  })}
4117
4429
  >
4118
4430
  ${panelEntries.map((e) => e.tpl)}
@@ -4172,12 +4484,12 @@ function renderStylebookElementsIntoCanvas(
4172
4484
  class="element-card"
4173
4485
  ${ref((card) => {
4174
4486
  if (!card) return;
4175
- stylebookElToTag.set(card, entry.tag);
4487
+ view.stylebookElToTag.set(card, entry.tag);
4176
4488
  elToPath.set(card, ["__sb", entry.tag]);
4177
4489
  for (const child of el.querySelectorAll("*")) {
4178
4490
  const tag = child.tagName.toLowerCase();
4179
- if (!stylebookElToTag.has(child)) {
4180
- stylebookElToTag.set(child, tag);
4491
+ if (!view.stylebookElToTag.has(child)) {
4492
+ view.stylebookElToTag.set(child, tag);
4181
4493
  elToPath.set(child, ["__sb", tag]);
4182
4494
  }
4183
4495
  }
@@ -4219,7 +4531,7 @@ function renderStylebookElementsIntoCanvas(
4219
4531
  style="display:inline-flex;width:auto"
4220
4532
  ${ref((card) => {
4221
4533
  if (!card) return;
4222
- stylebookElToTag.set(card, comp.tagName);
4534
+ view.stylebookElToTag.set(card, comp.tagName);
4223
4535
  elToPath.set(card, ["__sb", comp.tagName]);
4224
4536
  })}
4225
4537
  >
@@ -4586,21 +4898,6 @@ function renderVarRow(catKey, catMeta, varName, varVal, isNew) {
4586
4898
 
4587
4899
  // varDisplayName, friendlyNameToVar — imported from studio-utils.js
4588
4900
 
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
4901
  /**
4605
4902
  * Convert a human-friendly name like "Tablet" to a $media key "--tablet"
4606
4903
  *
@@ -4715,7 +5012,8 @@ function createUnitInput(initialValue, { onChange, size = "s" } = {}) {
4715
5012
  }
4716
5013
 
4717
5014
  /**
4718
- * Click handler for stylebook canvas — selects elements via the elToPath/stylebookElToTag mapping
5015
+ * Click handler for stylebook canvas — selects elements via the elToPath/view.stylebookElToTag
5016
+ * mapping
4719
5017
  *
4720
5018
  * @param {any} panel
4721
5019
  */
@@ -4736,7 +5034,7 @@ function registerStylebookPanelEvents(panel) {
4736
5034
  if (!canvas.contains(el) || el === canvas) continue;
4737
5035
  let cur = /** @type {any} */ (el);
4738
5036
  while (cur && cur !== canvas) {
4739
- const tag = stylebookElToTag.get(cur);
5037
+ const tag = view.stylebookElToTag.get(cur);
4740
5038
  if (tag) {
4741
5039
  const newMedia = panel.mediaName === "base" ? null : (panel.mediaName ?? null);
4742
5040
  selectStylebookTag(tag, newMedia);
@@ -4747,9 +5045,8 @@ function registerStylebookPanelEvents(panel) {
4747
5045
  }
4748
5046
  }
4749
5047
  // Clicked empty area — deselect
4750
- S = { ...S, ui: { ...S.ui, stylebookSelection: null, activeSelector: null } };
5048
+ updateSession({ ui: { stylebookSelection: null, activeSelector: null } });
4751
5049
  renderStylebookOverlays();
4752
- renderRightPanel();
4753
5050
  });
4754
5051
 
4755
5052
  overlayClk.addEventListener("mousemove", (/** @type {any} */ e) => {
@@ -4765,7 +5062,7 @@ function registerStylebookPanelEvents(panel) {
4765
5062
  if (!canvas.contains(el) || el === canvas) continue;
4766
5063
  let cur = /** @type {any} */ (el);
4767
5064
  while (cur && cur !== canvas) {
4768
- const tag = stylebookElToTag.get(cur);
5065
+ const tag = view.stylebookElToTag.get(cur);
4769
5066
  if (tag) {
4770
5067
  hoverTag = tag;
4771
5068
  break;
@@ -4835,7 +5132,7 @@ function renderStylebookOverlays() {
4835
5132
  /** Find a stylebook element by tag in the canvas */
4836
5133
  function findStylebookEl(/** @type {any} */ canvasEl, /** @type {any} */ tag) {
4837
5134
  for (const child of canvasEl.querySelectorAll("*")) {
4838
- if (stylebookElToTag.get(child) === tag) return child;
5135
+ if (view.stylebookElToTag.get(child) === tag) return child;
4839
5136
  }
4840
5137
  return null;
4841
5138
  }
@@ -4843,73 +5140,191 @@ function findStylebookEl(/** @type {any} */ canvasEl, /** @type {any} */ tag) {
4843
5140
  // ─── Right panel: Inspector ───────────────────────────────────────────────────
4844
5141
 
4845
5142
  function renderRightPanel() {
4846
- const tab = S.ui.rightTab;
5143
+ rightPanelMod.render();
5144
+ }
4847
5145
 
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
- ];
5146
+ // ─── Inspector ────────────────────────────────────────────────────────────────
4854
5147
 
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>
5148
+ /** Frontmatter-only panel shown in content mode when no element is selected */
5149
+ function renderFrontmatterOnlyPanel() {
5150
+ const fm = S.content?.frontmatter || {};
5151
+ const col = findCollectionSchema(S.documentPath, projectState?.projectConfig);
5152
+ const schemaProps = col?.schema?.properties;
5153
+ const requiredFields = new Set(col?.schema?.required || []);
5154
+
5155
+ /** @type {{ field: string; entry: any; value: any }[]} */
5156
+ const fields = [];
5157
+ if (schemaProps) {
5158
+ for (const [field, fieldSchema] of Object.entries(
5159
+ /** @type {Record<string, any>} */ (schemaProps),
5160
+ )) {
5161
+ fields.push({ field, entry: fieldSchema, value: fm[field] });
5162
+ }
5163
+ for (const [field, value] of Object.entries(fm)) {
5164
+ if (!schemaProps[field]) {
5165
+ fields.push({
5166
+ field,
5167
+ entry: { type: typeof value === "boolean" ? "boolean" : "string" },
5168
+ value,
5169
+ });
5170
+ }
5171
+ }
5172
+ } else {
5173
+ for (const [field, value] of Object.entries(fm)) {
5174
+ fields.push({
5175
+ field,
5176
+ entry: { type: typeof value === "boolean" ? "boolean" : "string" },
5177
+ value,
5178
+ });
5179
+ }
5180
+ }
5181
+
5182
+ if (fields.length === 0 && !schemaProps) {
5183
+ return html`<div class="empty-state">No frontmatter. Select an element to inspect.</div>`;
5184
+ }
5185
+
5186
+ return html`
5187
+ <div class="style-sidebar">
5188
+ <sp-accordion allow-multiple size="s">
5189
+ <sp-accordion-item label=${col ? `Frontmatter (${col.name})` : "Frontmatter"} open>
5190
+ <div class="style-section-body">
5191
+ ${fields.map((f) => renderFmFieldRow(f.field, f.entry, f.value, requiredFields))}
5192
+ </div>
5193
+ </sp-accordion-item>
5194
+ </sp-accordion>
4877
5195
  </div>
4878
5196
  `;
5197
+ }
4879
5198
 
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,
5199
+ /** Render a single frontmatter field row (shared between both panels) */
5200
+ function renderFmFieldRow(
5201
+ /** @type {string} */ field,
5202
+ /** @type {any} */ entry,
5203
+ /** @type {any} */ value,
5204
+ /** @type {Set<string>} */ requiredFields,
5205
+ ) {
5206
+ const isRequired = requiredFields.has(field);
5207
+ const label = field.replace(/([A-Z])/g, " $1").replace(/^./, (s) => s.toUpperCase());
5208
+ const displayLabel = label + (isRequired ? " *" : "");
5209
+ const hasVal = value !== undefined && value !== "" && value !== false;
5210
+ const onClear = () => update(updateFrontmatter(S, field, undefined));
5211
+
5212
+ // Boolean → checkbox
5213
+ if (entry.type === "boolean") {
5214
+ return renderFieldRow({
5215
+ prop: field,
5216
+ label: displayLabel,
5217
+ hasValue: hasVal,
5218
+ onClear,
5219
+ widget: html`
5220
+ <sp-checkbox
5221
+ size="s"
5222
+ .checked=${live(!!value)}
5223
+ @change=${(/** @type {any} */ e) =>
5224
+ update(updateFrontmatter(S, field, e.target.checked || undefined))}
5225
+ ></sp-checkbox>
5226
+ `,
4889
5227
  });
4890
- } else if (tab === "style") {
4891
- try {
4892
- bodyT = renderStylePanelTemplate();
4893
- } catch (/** @type {any} */ e) {
4894
- console.error("[renderStylePanelTemplate]", e);
4895
- }
4896
5228
  }
4897
5229
 
4898
- const tpl = html`
4899
- ${tabsT}
4900
- <div class="panel-body">${bodyT}</div>
4901
- `;
5230
+ // Array of strings → comma-separated text
5231
+ if (entry.type === "array") {
5232
+ const display = Array.isArray(value) ? value.join(", ") : value || "";
5233
+ return renderFieldRow({
5234
+ prop: field,
5235
+ label: displayLabel,
5236
+ hasValue: hasVal,
5237
+ onClear,
5238
+ widget: html`
5239
+ <sp-textfield
5240
+ size="s"
5241
+ placeholder="comma, separated"
5242
+ .value=${live(display)}
5243
+ @input=${debouncedStyleCommit(`fm:${field}`, 400, (/** @type {any} */ e) => {
5244
+ const arr = e.target.value
5245
+ ? e.target.value
5246
+ .split(",")
5247
+ .map((/** @type {string} */ s) => s.trim())
5248
+ .filter(Boolean)
5249
+ : undefined;
5250
+ update(updateFrontmatter(S, field, arr));
5251
+ })}
5252
+ ></sp-textfield>
5253
+ `,
5254
+ });
5255
+ }
4902
5256
 
4903
- litRender(tpl, rightPanel);
5257
+ // Enum → select
5258
+ if (Array.isArray(entry.enum)) {
5259
+ return renderFieldRow({
5260
+ prop: field,
5261
+ label: displayLabel,
5262
+ hasValue: hasVal,
5263
+ onClear,
5264
+ widget: html`
5265
+ <sp-picker
5266
+ size="s"
5267
+ .value=${live(value || "")}
5268
+ @change=${(/** @type {any} */ e) =>
5269
+ update(updateFrontmatter(S, field, e.target.value || undefined))}
5270
+ >
5271
+ ${entry.enum.map(
5272
+ (/** @type {string} */ opt) => html`<sp-menu-item value=${opt}>${opt}</sp-menu-item>`,
5273
+ )}
5274
+ </sp-picker>
5275
+ `,
5276
+ });
5277
+ }
4904
5278
 
4905
- updateForcedPseudoPreview();
4906
- }
5279
+ // Number
5280
+ if (entry.type === "number") {
5281
+ return renderFieldRow({
5282
+ prop: field,
5283
+ label: displayLabel,
5284
+ hasValue: hasVal,
5285
+ onClear,
5286
+ widget: html`
5287
+ <sp-number-field
5288
+ size="s"
5289
+ hide-stepper
5290
+ .value=${live(value !== undefined ? Number(value) : undefined)}
5291
+ @change=${debouncedStyleCommit(`fm:${field}`, 400, (/** @type {any} */ e) => {
5292
+ const v = e.target.value;
5293
+ update(updateFrontmatter(S, field, isNaN(v) ? undefined : Number(v)));
5294
+ })}
5295
+ ></sp-number-field>
5296
+ `,
5297
+ });
5298
+ }
4907
5299
 
4908
- // ─── Inspector ────────────────────────────────────────────────────────────────
5300
+ // Default: text (handles string, date, etc.)
5301
+ return renderFieldRow({
5302
+ prop: field,
5303
+ label: displayLabel,
5304
+ hasValue: hasVal,
5305
+ onClear,
5306
+ widget: html`
5307
+ <sp-textfield
5308
+ size="s"
5309
+ placeholder=${entry.format === "date" ? "YYYY-MM-DD" : ""}
5310
+ .value=${live(value || "")}
5311
+ @input=${debouncedStyleCommit(`fm:${field}`, 400, (/** @type {any} */ e) => {
5312
+ update(updateFrontmatter(S, field, e.target.value || undefined));
5313
+ })}
5314
+ ></sp-textfield>
5315
+ `,
5316
+ });
5317
+ }
4909
5318
 
4910
5319
  /** Properties panel — lit-html template with accordion sections */
4911
5320
  function propertiesSidebarTemplate() {
4912
- if (!S.selection) return html`<div class="empty-state">Select an element to inspect</div>`;
5321
+ // In content mode with no selection, still show frontmatter fields
5322
+ if (!S.selection) {
5323
+ if (S.mode === "content") {
5324
+ return renderFrontmatterOnlyPanel();
5325
+ }
5326
+ return html`<div class="empty-state">Select an element to inspect</div>`;
5327
+ }
4913
5328
  const node = getNodeAtPath(S.document, S.selection);
4914
5329
  if (!node) return html`<div class="empty-state">Node not found</div>`;
4915
5330
 
@@ -4941,21 +5356,12 @@ function propertiesSidebarTemplate() {
4941
5356
 
4942
5357
  // Boolean attributes render as checkboxes
4943
5358
  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>
5359
+ return renderFieldRow({
5360
+ prop: attr,
5361
+ label: attrLabel(entry, attr),
5362
+ hasValue: hasVal,
5363
+ onClear: () => update(updateAttribute(S, path, attr, undefined)),
5364
+ widget: html`
4959
5365
  <sp-checkbox
4960
5366
  size="s"
4961
5367
  .checked=${live(!!value)}
@@ -4963,30 +5369,19 @@ function propertiesSidebarTemplate() {
4963
5369
  update(updateAttribute(S, path, attr, e.target.checked || undefined))}
4964
5370
  >
4965
5371
  </sp-checkbox>
4966
- </div>
4967
- `;
5372
+ `,
5373
+ });
4968
5374
  }
4969
5375
 
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
- `;
5376
+ return renderFieldRow({
5377
+ prop: attr,
5378
+ label: attrLabel(entry, attr),
5379
+ hasValue: hasVal,
5380
+ onClear: () => update(updateAttribute(S, path, attr, undefined)),
5381
+ widget: widgetForType(type, entry, attr, value || "", (/** @type {any} */ v) =>
5382
+ update(updateAttribute(S, path, attr, v || undefined)),
5383
+ ),
5384
+ });
4990
5385
  }
4991
5386
 
4992
5387
  // ── Collect applicable attributes from html-meta ──
@@ -5035,11 +5430,7 @@ function propertiesSidebarTemplate() {
5035
5430
 
5036
5431
  function toggleSection(/** @type {any} */ key) {
5037
5432
  const current = isSectionOpen(key);
5038
- S = {
5039
- ...S,
5040
- ui: { ...S.ui, inspectorSections: { ...S.ui.inspectorSections, [key]: !current } },
5041
- };
5042
- renderRightPanel();
5433
+ updateUi("inspectorSections", { ...S.ui.inspectorSections, [key]: !current });
5043
5434
  }
5044
5435
 
5045
5436
  // ── Build section templates ─────────────────────────────────────────
@@ -5364,11 +5755,63 @@ function propertiesSidebarTemplate() {
5364
5755
  })()
5365
5756
  : nothing;
5366
5757
 
5758
+ // ── Frontmatter section (content mode only) ──
5759
+ const frontmatterT =
5760
+ S.mode === "content"
5761
+ ? (() => {
5762
+ const fm = S.content?.frontmatter || {};
5763
+ const col = findCollectionSchema(S.documentPath, projectState?.projectConfig);
5764
+ const schemaProps = col?.schema?.properties;
5765
+ const requiredFields = new Set(col?.schema?.required || []);
5766
+
5767
+ /** @type {{ field: string; entry: any; value: any }[]} */
5768
+ const fields = [];
5769
+ if (schemaProps) {
5770
+ for (const [field, fieldSchema] of Object.entries(
5771
+ /** @type {Record<string, any>} */ (schemaProps),
5772
+ )) {
5773
+ fields.push({ field, entry: fieldSchema, value: fm[field] });
5774
+ }
5775
+ for (const [field, value] of Object.entries(fm)) {
5776
+ if (!schemaProps[field]) {
5777
+ fields.push({
5778
+ field,
5779
+ entry: { type: typeof value === "boolean" ? "boolean" : "string" },
5780
+ value,
5781
+ });
5782
+ }
5783
+ }
5784
+ } else {
5785
+ for (const [field, value] of Object.entries(fm)) {
5786
+ fields.push({
5787
+ field,
5788
+ entry: { type: typeof value === "boolean" ? "boolean" : "string" },
5789
+ value,
5790
+ });
5791
+ }
5792
+ }
5793
+
5794
+ if (fields.length === 0 && !schemaProps) return nothing;
5795
+
5796
+ return html`
5797
+ <sp-accordion-item
5798
+ label=${col ? `Frontmatter (${col.name})` : "Frontmatter"}
5799
+ ?open=${isSectionOpen("__frontmatter") !== false}
5800
+ @sp-accordion-item-toggle=${() => toggleSection("__frontmatter")}
5801
+ >
5802
+ <div class="style-section-body">
5803
+ ${fields.map((f) => renderFmFieldRow(f.field, f.entry, f.value, requiredFields))}
5804
+ </div>
5805
+ </sp-accordion-item>
5806
+ `;
5807
+ })()
5808
+ : nothing;
5809
+
5367
5810
  // ── Assemble ──
5368
5811
  const tpl = html`
5369
5812
  <div class="style-sidebar">
5370
5813
  <sp-accordion allow-multiple size="s">
5371
- ${isMapNode ? repeaterT : elemT} ${isMapNode ? nothing : observedAttrsT}
5814
+ ${frontmatterT} ${isMapNode ? repeaterT : elemT} ${isMapNode ? nothing : observedAttrsT}
5372
5815
  ${isMapNode ? nothing : switchT} ${isMapNode ? nothing : compPropsT}
5373
5816
  ${isMapNode ? nothing : attrSectionTemplates} ${isMapNode ? nothing : customSectionT}
5374
5817
  ${isMapNode ? nothing : mediaT} ${isMapNode ? nothing : cssPropsT}
@@ -5603,13 +6046,13 @@ function renderComponentPropsFieldsTemplate(
5603
6046
  ></sp-number-field>`;
5604
6047
  } else if (parsed.kind === "combobox") {
5605
6048
  const options = /** @type {string[]} */ (/** @type {any} */ (parsed).options);
5606
- widgetTpl = html`<jx-styled-combobox
6049
+ widgetTpl = html`<jx-value-selector
5607
6050
  .value=${String(staticVal)}
5608
6051
  size="s"
5609
6052
  placeholder="—"
5610
6053
  .options=${options.map((o) => ({ value: o, label: camelToLabel(o) }))}
5611
6054
  @change=${(/** @type {any} */ e) => onChange(e.detail?.value ?? e.target.value)}
5612
- ></jx-styled-combobox>`;
6055
+ ></jx-value-selector>`;
5613
6056
  } else {
5614
6057
  widgetTpl = html`<sp-textfield
5615
6058
  size="s"
@@ -5683,8 +6126,6 @@ function renderCustomAttrsFieldsTemplate(
5683
6126
  }
5684
6127
 
5685
6128
  /** Media breakpoint fields template */
5686
- let showAddBreakpointForm = false;
5687
- let addBreakpointPreview = "";
5688
6129
 
5689
6130
  function renderMediaFieldsTemplate(/** @type {any} */ node) {
5690
6131
  const media = node.$media || {};
@@ -5720,14 +6161,14 @@ function renderMediaFieldsTemplate(/** @type {any} */ node) {
5720
6161
  <div>
5721
6162
  <span
5722
6163
  class="kv-add"
5723
- style=${showAddBreakpointForm ? "display:none" : ""}
6164
+ style=${view.showAddBreakpointForm ? "display:none" : ""}
5724
6165
  @click=${(/** @type {any} */ _e) => {
5725
- showAddBreakpointForm = true;
6166
+ view.showAddBreakpointForm = true;
5726
6167
  renderRightPanel();
5727
6168
  }}
5728
6169
  >+ Add breakpoint</span
5729
6170
  >
5730
- ${showAddBreakpointForm
6171
+ ${view.showAddBreakpointForm
5731
6172
  ? html`
5732
6173
  <div style="margin-top:4px">
5733
6174
  <div style="display:flex;gap:4px;margin-bottom:3px;align-items:center">
@@ -5736,13 +6177,13 @@ function renderMediaFieldsTemplate(/** @type {any} */ node) {
5736
6177
  placeholder="Name (e.g. Tablet)"
5737
6178
  style="flex:1"
5738
6179
  @input=${(/** @type {any} */ e) => {
5739
- addBreakpointPreview = friendlyNameToMedia(e.target.value) || "";
6180
+ view.addBreakpointPreview = friendlyNameToMedia(e.target.value) || "";
5740
6181
  renderRightPanel();
5741
6182
  }}
5742
6183
  />
5743
6184
  <span
5744
6185
  style="font-size:10px;color:var(--fg-dim);font-family:'SF Mono','Fira Code',monospace;white-space:nowrap"
5745
- >${addBreakpointPreview}</span
6186
+ >${view.addBreakpointPreview}</span
5746
6187
  >
5747
6188
  </div>
5748
6189
  <div style="display:flex;gap:4px;margin-bottom:3px;align-items:center">
@@ -5758,8 +6199,8 @@ function renderMediaFieldsTemplate(/** @type {any} */ node) {
5758
6199
  const queryVal = wrap.querySelector(".add-bp-query")?.value?.trim();
5759
6200
  const key = friendlyNameToMedia(nameVal);
5760
6201
  if (key && queryVal) {
5761
- showAddBreakpointForm = false;
5762
- addBreakpointPreview = "";
6202
+ view.showAddBreakpointForm = false;
6203
+ view.addBreakpointPreview = "";
5763
6204
  update(updateMedia(S, key, queryVal));
5764
6205
  }
5765
6206
  }}
@@ -5770,8 +6211,8 @@ function renderMediaFieldsTemplate(/** @type {any} */ node) {
5770
6211
  class="kv-add"
5771
6212
  style="padding:2px 10px;cursor:pointer;color:var(--fg-dim)"
5772
6213
  @click=${() => {
5773
- showAddBreakpointForm = false;
5774
- addBreakpointPreview = "";
6214
+ view.showAddBreakpointForm = false;
6215
+ view.addBreakpointPreview = "";
5775
6216
  renderRightPanel();
5776
6217
  }}
5777
6218
  >
@@ -5839,7 +6280,7 @@ function mediaBreakpointRowTemplate(/** @type {any} */ name, /** @type {any} */
5839
6280
 
5840
6281
  // ─── Style Sidebar (metadata-driven) ───────────────────────────────────────────
5841
6282
 
5842
- const UNIT_RE = /^(-?[\d.]+)(px|rem|em|%|vw|vh|svw|svh|dvh|ms|s|fr|ch|ex|deg)?$/;
6283
+ // UNIT_RE imported from ui/unit-selector.js
5843
6284
 
5844
6285
  // inferInputType — imported from studio-utils.js
5845
6286
 
@@ -5867,32 +6308,118 @@ function autoOpenSections(/** @type {any} */ node, /** @type {any} */ currentSec
5867
6308
 
5868
6309
  /** Get longhands for a shorthand property from css-meta */
5869
6310
  function getLonghands(/** @type {any} */ shorthandProp) {
6311
+ // Check for explicit $longhands array first (used by border-side shorthands)
6312
+ const entry = /** @type {Record<string, any>} */ (cssMeta.$defs)[shorthandProp];
6313
+ if (entry?.$longhands) {
6314
+ return entry.$longhands
6315
+ .map((/** @type {string} */ name) => ({
6316
+ name,
6317
+ entry: /** @type {Record<string, any>} */ (cssMeta.$defs)[name] || { $order: 0 },
6318
+ }))
6319
+ .sort((/** @type {any} */ a, /** @type {any} */ b) => a.entry.$order - b.entry.$order);
6320
+ }
6321
+ // Fallback: reverse-lookup by $shorthand reference
5870
6322
  const result = [];
5871
- for (const [name, entry] of /** @type {[string, any][]} */ (Object.entries(cssMeta.$defs))) {
5872
- if (entry.$shorthand === shorthandProp) result.push({ name, entry });
6323
+ for (const [name, e] of /** @type {[string, any][]} */ (Object.entries(cssMeta.$defs))) {
6324
+ if (e.$shorthand === shorthandProp) result.push({ name, entry: e });
5873
6325
  }
5874
6326
  result.sort((a, b) => a.entry.$order - b.entry.$order);
5875
6327
  return result;
5876
6328
  }
5877
6329
 
5878
- // ── Color popover singleton ─────────────────────────────────────────────────
5879
- /** @type {any} */
5880
- /** @type {any} */
5881
- let _colorCallback = null;
5882
- /** @type {any} */
5883
- let _colorDismissHandler = null;
6330
+ /**
6331
+ * Expand a CSS shorthand value (margin, padding, borderWidth, borderRadius) into individual
6332
+ * longhand values following the standard 1–4 value TRBL pattern. Returns an array matching the
6333
+ * longhand count (always 4 for box properties).
6334
+ */
6335
+ function expandShorthand(/** @type {string} */ shortVal, /** @type {number} */ count) {
6336
+ if (!shortVal) return Array(count).fill("");
6337
+ const parts = shortVal.trim().split(/\s+/);
6338
+ if (count !== 4 || parts.length === 0) return Array(count).fill("");
6339
+ if (parts.length === 1) return [parts[0], parts[0], parts[0], parts[0]];
6340
+ if (parts.length === 2) return [parts[0], parts[1], parts[0], parts[1]];
6341
+ if (parts.length === 3) return [parts[0], parts[1], parts[2], parts[1]];
6342
+ return [parts[0], parts[1], parts[2], parts[3]];
6343
+ }
5884
6344
 
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) });
6345
+ /**
6346
+ * Compress 4 TRBL values back into the shortest valid CSS shorthand string. e.g.
6347
+ * ["0","auto","3rem","auto"] → "0 auto 3rem"
6348
+ */
6349
+ function compressShorthand(/** @type {string[]} */ vals) {
6350
+ const [t, r, b, l] = vals;
6351
+ if (t === r && r === b && b === l) return t;
6352
+ if (t === b && r === l) return `${t} ${r}`;
6353
+ if (r === l) return `${t} ${r} ${b}`;
6354
+ return `${t} ${r} ${b} ${l}`;
6355
+ }
6356
+
6357
+ // ─── Border-side shorthand parsing ────────────────────────────────────────────
6358
+ // CSS border-side shorthand: <width> || <style> || <color> (any order, all optional)
6359
+
6360
+ const BORDER_STYLES = new Set([
6361
+ "none",
6362
+ "solid",
6363
+ "dashed",
6364
+ "dotted",
6365
+ "double",
6366
+ "groove",
6367
+ "ridge",
6368
+ "inset",
6369
+ "outset",
6370
+ "hidden",
6371
+ ]);
6372
+
6373
+ /**
6374
+ * Parse a border-side shorthand value into [width, style, color].
6375
+ *
6376
+ * @param {string} value — e.g. "1px solid var(--color-border)"
6377
+ * @returns {string[]} — [width, style, color]
6378
+ */
6379
+ function expandBorderSide(value) {
6380
+ if (!value) return ["", "", ""];
6381
+ // Tokenize respecting parenthesized values like var(...) and rgb(...)
6382
+ const tokens = [];
6383
+ let current = "";
6384
+ let depth = 0;
6385
+ for (const ch of value.trim()) {
6386
+ if (ch === "(") depth++;
6387
+ if (ch === ")") depth--;
6388
+ if (ch === " " && depth === 0) {
6389
+ if (current) tokens.push(current);
6390
+ current = "";
6391
+ } else {
6392
+ current += ch;
5893
6393
  }
5894
6394
  }
5895
- return vars;
6395
+ if (current) tokens.push(current);
6396
+
6397
+ let width = "";
6398
+ let style = "";
6399
+ let color = "";
6400
+
6401
+ for (const tok of tokens) {
6402
+ if (!style && BORDER_STYLES.has(tok)) {
6403
+ style = tok;
6404
+ } else if (!width && /^[\d.]/.test(tok)) {
6405
+ width = tok;
6406
+ } else {
6407
+ // Remaining token(s) are color — join in case color was split (shouldn't be with paren-aware tokenizer)
6408
+ color = color ? `${color} ${tok}` : tok;
6409
+ }
6410
+ }
6411
+
6412
+ return [width, style, color];
6413
+ }
6414
+
6415
+ /**
6416
+ * Recompose border-side longhand values into a shorthand string.
6417
+ *
6418
+ * @param {string[]} vals — [width, style, color]
6419
+ * @returns {string}
6420
+ */
6421
+ function compressBorderSide(/** @type {string[]} */ vals) {
6422
+ return vals.filter((v) => v && v.trim()).join(" ");
5896
6423
  }
5897
6424
 
5898
6425
  /** Extract --font-* CSS custom properties from the document root style. */
@@ -5908,371 +6435,6 @@ function getFontVars() {
5908
6435
  return vars;
5909
6436
  }
5910
6437
 
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
6438
  /** Typography CSS properties that should preview their values in-menu */
6277
6439
  const TYPO_PREVIEW_PROPS = new Set(["fontStyle", "fontVariant", "textTransform", "textDecoration"]);
6278
6440
 
@@ -6316,7 +6478,7 @@ function renderKeywordInput(options, prop, value, onChange) {
6316
6478
  return { value: v, label, style };
6317
6479
  });
6318
6480
 
6319
- return html`<jx-styled-combobox
6481
+ return html`<jx-value-selector
6320
6482
  size="s"
6321
6483
  .value=${value || ""}
6322
6484
  placeholder=${cssInitialMap.get(prop) || ""}
@@ -6325,7 +6487,7 @@ function renderKeywordInput(options, prop, value, onChange) {
6325
6487
  @input=${debouncedStyleCommit(`kw:${prop}`, 400, (/** @type {any} */ e) =>
6326
6488
  onChange(e.target.value),
6327
6489
  )}
6328
- ></jx-styled-combobox>`;
6490
+ ></jx-value-selector>`;
6329
6491
  }
6330
6492
 
6331
6493
  function renderSelectInput(
@@ -6382,7 +6544,7 @@ function handleFontSelection(
6382
6544
  }
6383
6545
 
6384
6546
  /**
6385
- * Build font options array for jx-styled-combobox. Local font vars first, divider, then unadded
6547
+ * Build font options array for jx-value-selector. Local font vars first, divider, then unadded
6386
6548
  * presets.
6387
6549
  *
6388
6550
  * @param {any[]} fontVars @param {any[]} presets
@@ -6420,13 +6582,13 @@ function renderComboboxInput(
6420
6582
  const presets = entry.presets || [];
6421
6583
  const examples = entry.examples || [];
6422
6584
 
6423
- // fontFamily: single jx-styled-combobox with font options
6585
+ // fontFamily: single jx-value-selector with font options
6424
6586
  if (prop === "fontFamily") {
6425
6587
  // Strip var() wrapper so the component can match the option value
6426
6588
  const varMatch = typeof value === "string" && value.match(/^var\((--[^)]+)\)$/);
6427
6589
  const comboValue = varMatch ? varMatch[1] : value || "";
6428
6590
  const fontOptions = buildFontOptions(fontVars, presets);
6429
- return html`<jx-styled-combobox
6591
+ return html`<jx-value-selector
6430
6592
  size="s"
6431
6593
  .value=${comboValue}
6432
6594
  placeholder=${cssInitialMap.get("fontFamily") || ""}
@@ -6435,7 +6597,7 @@ function renderComboboxInput(
6435
6597
  @input=${debouncedStyleCommit("combo:fontFamily", 400, (/** @type {any} */ e) =>
6436
6598
  onChange(e.target.value),
6437
6599
  )}
6438
- ></jx-styled-combobox>`;
6600
+ ></jx-value-selector>`;
6439
6601
  }
6440
6602
 
6441
6603
  // All other comboboxes: use the shared keyword dual-mode input
@@ -6456,45 +6618,7 @@ function renderComboboxInput(
6456
6618
  `;
6457
6619
  }
6458
6620
 
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
- }
6621
+ // renderNumberInput, renderTextInput — imported from ui/widgets.js
6498
6622
 
6499
6623
  // camelToLabel, kebabToLabel, propLabel, attrLabel — imported from studio-utils.js
6500
6624
 
@@ -6504,23 +6628,13 @@ function widgetForType(
6504
6628
  /** @type {any} */ prop,
6505
6629
  /** @type {any} */ value,
6506
6630
  /** @type {any} */ onCommit,
6631
+ /** @type {any} */ opts = {},
6507
6632
  ) {
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
- }
6633
+ return _widgetForType(type, entry, prop, value, onCommit, {
6634
+ placeholder: opts.placeholder || cssInitialMap.get(prop) || "",
6635
+ renderSelect: renderSelectInput,
6636
+ renderCombobox: renderComboboxInput,
6637
+ });
6524
6638
  }
6525
6639
 
6526
6640
  function renderStyleRow(
@@ -6531,31 +6645,20 @@ function renderStyleRow(
6531
6645
  /** @type {any} */ onDelete,
6532
6646
  /** @type {any} */ isWarning,
6533
6647
  /** @type {any} */ gridMode,
6648
+ /** @type {any} */ inheritedValue,
6534
6649
  ) {
6535
6650
  const type = inferInputType(entry);
6536
6651
  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
- `;
6652
+ const placeholder = !hasVal && inheritedValue ? String(inheritedValue) : "";
6653
+ return renderFieldRow({
6654
+ prop,
6655
+ label: propLabel(entry, prop),
6656
+ hasValue: hasVal,
6657
+ onClear: onDelete,
6658
+ widget: widgetForType(type, entry, prop, value, onCommit, { placeholder }),
6659
+ span: gridMode && entry.$span === 2 ? 2 : undefined,
6660
+ warning: isWarning,
6661
+ });
6559
6662
  }
6560
6663
 
6561
6664
  function renderShorthandRow(
@@ -6564,12 +6667,14 @@ function renderShorthandRow(
6564
6667
  /** @type {any} */ style,
6565
6668
  /** @type {any} */ commitFn,
6566
6669
  /** @type {any} */ _deleteFn,
6670
+ /** @type {Record<string, any>} */ inherited = {},
6567
6671
  ) {
6568
6672
  const longhands = getLonghands(shortProp);
6569
6673
  const shortVal = style[shortProp];
6570
- const hasLonghands = longhands.some((l) => style[l.name] !== undefined);
6674
+ const hasLonghands = longhands.some((/** @type {any} */ l) => style[l.name] !== undefined);
6571
6675
  const isExpanded = S.ui.styleShorthands[shortProp] ?? hasLonghands;
6572
- const hasAnyVal = shortVal !== undefined || longhands.some((l) => style[l.name] !== undefined);
6676
+ const hasAnyVal =
6677
+ shortVal !== undefined || longhands.some((/** @type {any} */ l) => style[l.name] !== undefined);
6573
6678
 
6574
6679
  return html`
6575
6680
  <div class="style-row" data-prop=${shortProp}>
@@ -6596,8 +6701,12 @@ function renderShorthandRow(
6596
6701
  size="s"
6597
6702
  .value=${live(shortVal || "")}
6598
6703
  placeholder=${!shortVal && hasLonghands
6599
- ? longhands.map((l) => style[l.name] || "0").join(" ")
6600
- : ""}
6704
+ ? longhands.map((/** @type {any} */ l) => style[l.name] || "0").join(" ")
6705
+ : !shortVal && inherited[shortProp]
6706
+ ? inherited[shortProp]
6707
+ : !shortVal && longhands.some((/** @type {any} */ l) => inherited[l.name])
6708
+ ? longhands.map((/** @type {any} */ l) => inherited[l.name] || "0").join(" ")
6709
+ : ""}
6601
6710
  @input=${debouncedStyleCommit(`short:${shortProp}`, 400, (/** @type {any} */ e) => {
6602
6711
  let s = S;
6603
6712
  for (const l of longhands) {
@@ -6612,14 +6721,7 @@ function renderShorthandRow(
6612
6721
  quiet
6613
6722
  @click=${(/** @type {any} */ e) => {
6614
6723
  e.stopPropagation();
6615
- S = {
6616
- ...S,
6617
- ui: {
6618
- ...S.ui,
6619
- styleShorthands: { ...S.ui.styleShorthands, [shortProp]: !isExpanded },
6620
- },
6621
- };
6622
- renderRightPanel();
6724
+ updateUi("styleShorthands", { ...S.ui.styleShorthands, [shortProp]: !isExpanded });
6623
6725
  }}
6624
6726
  >
6625
6727
  ${isExpanded
@@ -6629,33 +6731,74 @@ function renderShorthandRow(
6629
6731
  </div>
6630
6732
  </div>
6631
6733
  ${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
- })
6734
+ ? (() => {
6735
+ const isBorderSide = entry.$shorthandType === "border-side";
6736
+ const expanded = shortVal
6737
+ ? isBorderSide
6738
+ ? expandBorderSide(shortVal)
6739
+ : expandShorthand(shortVal, longhands.length)
6740
+ : null;
6741
+ const compress = isBorderSide ? compressBorderSide : compressShorthand;
6742
+ const emptyVal = isBorderSide ? "" : "0";
6743
+ return longhands.map(
6744
+ (/** @type {any} */ { name, entry: lEntry }, /** @type {any} */ idx) => {
6745
+ const lVal = style[name] ?? (expanded ? expanded[idx] : "");
6746
+ return html`
6747
+ <div class="style-row style-row--child" data-prop=${name}>
6748
+ <div class="style-row-label">
6749
+ ${lVal !== undefined && lVal !== ""
6750
+ ? html`<span
6751
+ class="set-dot"
6752
+ title="Clear ${name}"
6753
+ @click=${(/** @type {any} */ e) => {
6754
+ e.stopPropagation();
6755
+ // Recompose shorthand with this longhand cleared
6756
+ const vals = longhands.map(
6757
+ (/** @type {any} */ l, /** @type {any} */ i) =>
6758
+ i === idx
6759
+ ? emptyVal
6760
+ : (style[l.name] ?? (expanded ? expanded[i] : emptyVal)),
6761
+ );
6762
+ let s = S;
6763
+ for (const l of longhands) {
6764
+ if (style[l.name] !== undefined) s = commitFn(s, l.name, undefined);
6765
+ }
6766
+ s = commitFn(s, shortProp, compress(vals));
6767
+ update(s);
6768
+ }}
6769
+ ></span>`
6770
+ : nothing}
6771
+ <sp-field-label size="s" title=${name}
6772
+ >${propLabel(lEntry, name)}</sp-field-label
6773
+ >
6774
+ </div>
6775
+ ${widgetForType(
6776
+ inferInputType(lEntry),
6777
+ lEntry,
6778
+ name,
6779
+ lVal,
6780
+ (/** @type {any} */ newVal) => {
6781
+ // Recompose shorthand with this longhand updated
6782
+ const vals = longhands.map((/** @type {any} */ l, /** @type {any} */ i) =>
6783
+ i === idx
6784
+ ? newVal || emptyVal
6785
+ : (style[l.name] ?? (expanded ? expanded[i] : emptyVal)),
6786
+ );
6787
+ let s = S;
6788
+ for (const l of longhands) {
6789
+ if (style[l.name] !== undefined) s = commitFn(s, l.name, undefined);
6790
+ }
6791
+ s = commitFn(s, shortProp, compress(vals));
6792
+ update(s);
6793
+ renderRightPanel();
6794
+ },
6795
+ { placeholder: !lVal && inherited[name] ? String(inherited[name]) : "" },
6796
+ )}
6797
+ </div>
6798
+ `;
6799
+ },
6800
+ );
6801
+ })()
6659
6802
  : nothing}
6660
6803
  `;
6661
6804
  }
@@ -6674,30 +6817,20 @@ function styleSidebarTemplate(
6674
6817
  const mediaTabsT =
6675
6818
  mediaNames.length > 0
6676
6819
  ? 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>
6820
+ <sp-tabs
6821
+ size="s"
6822
+ selected=${activeTab || "base"}
6823
+ @change=${(/** @type {any} */ e) => {
6824
+ const val = e.target.selected;
6825
+ const newMedia = val === "base" ? null : val;
6826
+ if (newMedia !== S.ui.activeMedia) {
6827
+ updateUi("activeMedia", newMedia);
6828
+ }
6829
+ }}
6830
+ >
6831
+ <sp-tab label="Base" value="base"></sp-tab>
6688
6832
  ${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
- `,
6833
+ (name) => html` <sp-tab label=${mediaDisplayName(name)} value=${name}></sp-tab> `,
6701
6834
  )}
6702
6835
  </sp-tabs>
6703
6836
  `
@@ -6743,8 +6876,7 @@ function styleSidebarTemplate(
6743
6876
  inp.remove();
6744
6877
  picker.style.display = "";
6745
6878
  if (accept && v && isNestedSelector(v)) {
6746
- S = { ...S, ui: { ...S.ui, activeSelector: v } };
6747
- renderRightPanel();
6879
+ updateUi("activeSelector", v);
6748
6880
  }
6749
6881
  };
6750
6882
  inp.addEventListener("keydown", (ev) => {
@@ -6755,8 +6887,7 @@ function styleSidebarTemplate(
6755
6887
  return;
6756
6888
  }
6757
6889
  const newSelector = val === "__base__" ? null : val;
6758
- S = { ...S, ui: { ...S.ui, activeSelector: newSelector } };
6759
- renderRightPanel();
6890
+ updateUi("activeSelector", newSelector);
6760
6891
  }}
6761
6892
  >
6762
6893
  <sp-menu-item value="__base__">(base)</sp-menu-item>
@@ -6807,10 +6938,15 @@ function styleSidebarTemplate(
6807
6938
  updateStyle(s, S.selection, prop, val);
6808
6939
  }
6809
6940
 
6941
+ // ── Compute inherited style from higher breakpoints ──────────────────────
6942
+ /** @type {Record<string, any>} */
6943
+ const inheritedStyle = computeInheritedStyle(style, mediaNames, activeTab, activeSelector);
6944
+
6810
6945
  // Auto-open sections that have properties
6811
6946
  const newSections = autoOpenSections({ style: activeStyle }, S.ui.styleSections);
6812
6947
  if (JSON.stringify(newSections) !== JSON.stringify(S.ui.styleSections)) {
6813
- S = { ...S, ui: { ...S.ui, styleSections: newSections } };
6948
+ session = { ...session, ui: { ...session.ui, styleSections: newSections } };
6949
+ S = toFlat(doc, session);
6814
6950
  }
6815
6951
 
6816
6952
  // Partition properties into sections
@@ -6842,7 +6978,9 @@ function styleSidebarTemplate(
6842
6978
  const sectionActiveProps = entries.filter((/** @type {any} */ { prop, entry }) => {
6843
6979
  if (activeStyle[prop] !== undefined) return true;
6844
6980
  if (inferInputType(entry) === "shorthand") {
6845
- return getLonghands(prop).some((l) => activeStyle[l.name] !== undefined);
6981
+ return getLonghands(prop).some(
6982
+ (/** @type {any} */ l) => activeStyle[l.name] !== undefined,
6983
+ );
6846
6984
  }
6847
6985
  return false;
6848
6986
  });
@@ -6857,9 +6995,12 @@ function styleSidebarTemplate(
6857
6995
 
6858
6996
  if (type === "shorthand") {
6859
6997
  const longhands = getLonghands(prop);
6860
- const hasAny = hasVal || longhands.some((l) => activeStyle[l.name] !== undefined);
6998
+ const hasAny =
6999
+ hasVal || longhands.some((/** @type {any} */ l) => activeStyle[l.name] !== undefined);
6861
7000
  if (!hasAny && !condMet) continue;
6862
- rows.push(renderShorthandRow(prop, entry, activeStyle, commitStyle, () => {}));
7001
+ rows.push(
7002
+ renderShorthandRow(prop, entry, activeStyle, commitStyle, () => {}, inheritedStyle),
7003
+ );
6863
7004
  } else {
6864
7005
  const isWarning = hasVal && !condMet;
6865
7006
  if (hasVal || condMet) {
@@ -6872,6 +7013,7 @@ function styleSidebarTemplate(
6872
7013
  () => update(commitStyle(S, prop, undefined)),
6873
7014
  isWarning,
6874
7015
  sec.$layout === "grid",
7016
+ inheritedStyle[prop],
6875
7017
  ),
6876
7018
  );
6877
7019
  }
@@ -6885,10 +7027,7 @@ function styleSidebarTemplate(
6885
7027
  label=${sec.label}
6886
7028
  .open=${isOpen}
6887
7029
  @sp-accordion-item-toggle=${(/** @type {any} */ e) => {
6888
- S = {
6889
- ...S,
6890
- ui: { ...S.ui, styleSections: { ...S.ui.styleSections, [sec.key]: e.target.open } },
6891
- };
7030
+ updateUi("styleSections", { ...S.ui.styleSections, [sec.key]: e.target.open });
6892
7031
  }}
6893
7032
  >
6894
7033
  ${sectionActiveProps.length > 0
@@ -6929,10 +7068,7 @@ function styleSidebarTemplate(
6929
7068
  label="Custom"
6930
7069
  .open=${customIsOpen}
6931
7070
  @sp-accordion-item-toggle=${(/** @type {any} */ e) => {
6932
- S = {
6933
- ...S,
6934
- ui: { ...S.ui, styleSections: { ...S.ui.styleSections, other: e.target.open } },
6935
- };
7071
+ updateUi("styleSections", { ...S.ui.styleSections, other: e.target.open });
6936
7072
  }}
6937
7073
  >
6938
7074
  <div>
@@ -7003,7 +7139,7 @@ function styleSidebarTemplate(
7003
7139
 
7004
7140
  /** Top-level Style panel — returns a lit-html template */
7005
7141
  function renderStylePanelTemplate() {
7006
- if (canvasMode === "stylebook" && S.ui.stylebookSelection) {
7142
+ if (canvasMode === "settings" && S.ui.stylebookSelection) {
7007
7143
  const node = S.document;
7008
7144
  if (!node) return html`<div class="empty-state">No document loaded</div>`;
7009
7145
  return html`
@@ -7227,8 +7363,7 @@ function _renderSourceView(/** @type {any} */ container) {
7227
7363
  @blur=${(/** @type {any} */ e) => {
7228
7364
  try {
7229
7365
  const parsed = JSON.parse(e.target.value);
7230
- S = { ...S, document: parsed, dirty: true };
7231
- render();
7366
+ update({ ...S, document: parsed, dirty: true });
7232
7367
  } catch {}
7233
7368
  }}
7234
7369
  ></textarea>
@@ -7253,29 +7388,31 @@ function renderFunctionEditor() {
7253
7388
  const editing = S.ui.editingFunction;
7254
7389
 
7255
7390
  // If editor already exists and matches current target, just sync value
7256
- if (functionEditor && functionEditor._editingTarget === JSON.stringify(editing)) {
7391
+ if (view.functionEditor && view.functionEditor._editingTarget === JSON.stringify(editing)) {
7257
7392
  const body = getFunctionBody(editing);
7258
- const currentVal = functionEditor.getValue();
7393
+ const currentVal = view.functionEditor.getValue();
7259
7394
  if (currentVal !== body) {
7260
- functionEditor._ignoreNextChange = true;
7261
- functionEditor.setValue(body);
7395
+ view.functionEditor._ignoreNextChange = true;
7396
+ view.functionEditor.setValue(body);
7262
7397
  }
7263
7398
  return;
7264
7399
  }
7265
7400
 
7266
7401
  // Dispose previous editors
7267
- if (functionEditor) {
7268
- functionEditor.dispose();
7269
- functionEditor = null;
7402
+ if (view.functionEditor) {
7403
+ view.functionEditor.dispose();
7404
+ view.functionEditor = null;
7270
7405
  }
7271
- if (monacoEditor) {
7272
- monacoEditor.dispose();
7273
- monacoEditor = null;
7406
+ if (view.monacoEditor) {
7407
+ view.monacoEditor.dispose();
7408
+ view.monacoEditor = null;
7274
7409
  }
7275
7410
 
7276
- // Clean up canvas DnD
7277
- for (const fn of canvasDndCleanups) fn();
7278
- canvasDndCleanups = [];
7411
+ // Clean up canvas DnD and event handlers
7412
+ for (const fn of view.canvasDndCleanups) fn();
7413
+ view.canvasDndCleanups = [];
7414
+ for (const fn of view.canvasEventCleanups) fn();
7415
+ view.canvasEventCleanups = [];
7279
7416
  canvasPanels.length = 0;
7280
7417
 
7281
7418
  litRender(nothing, canvasWrap);
@@ -7300,7 +7437,7 @@ function renderFunctionEditor() {
7300
7437
  const body = getFunctionBody(editing);
7301
7438
  const args = getFunctionArgs(editing, S);
7302
7439
 
7303
- functionEditor = monaco.editor.create(/** @type {any} */ (editorContainer), {
7440
+ view.functionEditor = monaco.editor.create(/** @type {any} */ (editorContainer), {
7304
7441
  value: body,
7305
7442
  language: "javascript",
7306
7443
  theme: "vs-dark",
@@ -7313,17 +7450,18 @@ function renderFunctionEditor() {
7313
7450
  wordWrap: "on",
7314
7451
  tabSize: 2,
7315
7452
  });
7316
- functionEditor._editingTarget = JSON.stringify(editing);
7453
+ view.functionEditor._editingTarget = JSON.stringify(editing);
7317
7454
 
7318
7455
  // Format on open — show pretty-printed code, then run initial lint
7319
7456
  codeService("format", { code: body, args }).then((result) => {
7320
- if (result?.code != null && functionEditor) {
7321
- functionEditor._ignoreNextChange = true;
7322
- functionEditor.setValue(result.code);
7457
+ if (result?.code != null && view.functionEditor) {
7458
+ view.functionEditor._ignoreNextChange = true;
7459
+ view.functionEditor.setValue(result.code);
7323
7460
  }
7324
7461
  });
7325
7462
  codeService("lint", { code: body, args }).then((result) => {
7326
- if (result?.diagnostics && functionEditor) setLintMarkers(functionEditor, result.diagnostics);
7463
+ if (result?.diagnostics && view.functionEditor)
7464
+ setLintMarkers(view.functionEditor, result.diagnostics);
7327
7465
  });
7328
7466
 
7329
7467
  // Debounced sync back to state + lint on edit
@@ -7332,15 +7470,15 @@ function renderFunctionEditor() {
7332
7470
  /** @type {any} */
7333
7471
  let lintDebounce;
7334
7472
  let lintGen = 0;
7335
- functionEditor.onDidChangeModelContent(() => {
7336
- if (functionEditor._ignoreNextChange) {
7337
- functionEditor._ignoreNextChange = false;
7473
+ view.functionEditor.onDidChangeModelContent(() => {
7474
+ if (view.functionEditor._ignoreNextChange) {
7475
+ view.functionEditor._ignoreNextChange = false;
7338
7476
  return;
7339
7477
  }
7340
7478
 
7341
7479
  clearTimeout(syncDebounce);
7342
7480
  syncDebounce = setTimeout(() => {
7343
- const newBody = functionEditor.getValue();
7481
+ const newBody = view.functionEditor.getValue();
7344
7482
  if (editing.type === "def") {
7345
7483
  update(updateDef(S, editing.defName, { body: newBody }));
7346
7484
  } else if (editing.type === "event") {
@@ -7360,11 +7498,11 @@ function renderFunctionEditor() {
7360
7498
  clearTimeout(lintDebounce);
7361
7499
  lintDebounce = setTimeout(() => {
7362
7500
  const gen = ++lintGen;
7363
- const currentCode = functionEditor.getValue();
7501
+ const currentCode = view.functionEditor.getValue();
7364
7502
  codeService("lint", { code: currentCode, args }).then((result) => {
7365
7503
  if (gen !== lintGen) return;
7366
- if (result?.diagnostics && functionEditor)
7367
- setLintMarkers(functionEditor, result.diagnostics);
7504
+ if (result?.diagnostics && view.functionEditor)
7505
+ setLintMarkers(view.functionEditor, result.diagnostics);
7368
7506
  });
7369
7507
  }, 750);
7370
7508
  });
@@ -7381,10 +7519,9 @@ function getFunctionBody(/** @type {any} */ editing) {
7381
7519
  }
7382
7520
 
7383
7521
  // Register Monaco JS completion provider for state scope variables (once)
7384
- let _completionRegistered = false;
7385
7522
  function registerFunctionCompletions() {
7386
- if (_completionRegistered) return;
7387
- _completionRegistered = true;
7523
+ if (view._completionRegistered) return;
7524
+ view._completionRegistered = true;
7388
7525
  monaco.languages.registerCompletionItemProvider("javascript", {
7389
7526
  triggerCharacters: ["."],
7390
7527
  provideCompletionItems(model, position) {
@@ -7414,166 +7551,10 @@ function registerFunctionCompletions() {
7414
7551
  });
7415
7552
  }
7416
7553
 
7417
- // ─── Toolbar ──────────────────────────────────────────────────────────────────
7554
+ // ─── Toolbar (delegated to panels/toolbar.js) ────────────────────────────────
7418
7555
 
7419
7556
  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);
7557
+ toolbarPanel.render();
7577
7558
  }
7578
7559
 
7579
7560
  // ─── File Operations (delegated to file-ops.js) ─────────────────────────────
@@ -7591,13 +7572,16 @@ function fileOpsCtx() {
7591
7572
  function openFile() {
7592
7573
  return _openFile(fileOpsCtx());
7593
7574
  }
7594
- function loadMarkdown(/** @type {any} */ source, /** @type {any} */ fileHandle) {
7595
- const ns = _loadMarkdown(source, fileHandle);
7575
+ async function loadMarkdown(/** @type {any} */ source, /** @type {any} */ fileHandle) {
7576
+ const ns = await _loadMarkdown(source, fileHandle);
7596
7577
  S = ns;
7597
7578
  }
7598
7579
  function saveFile() {
7599
7580
  return _saveFile(fileOpsCtx());
7600
7581
  }
7582
+ function exportFile() {
7583
+ return _exportFile(fileOpsCtx());
7584
+ }
7601
7585
 
7602
7586
  // ─── File tree (delegated to files.js) ───────────────────────────────────────
7603
7587
 
@@ -7620,7 +7604,12 @@ function renderFilesTemplate() {
7620
7604
  function openFileFromTree(/** @type {any} */ path) {
7621
7605
  return _openFileFromTree(
7622
7606
  {
7623
- S,
7607
+ get S() {
7608
+ return S;
7609
+ },
7610
+ set S(v) {
7611
+ S = v;
7612
+ },
7624
7613
  commit: (/** @type {any} */ ns) => {
7625
7614
  S = ns;
7626
7615
  },
@@ -7638,16 +7627,16 @@ initShortcuts(() => ({
7638
7627
  S = ns;
7639
7628
  },
7640
7629
  canvasMode,
7641
- panX,
7642
- panY,
7630
+ panX: view.panX,
7631
+ panY: view.panY,
7643
7632
  setPan: (x, y) => {
7644
- panX = x;
7645
- panY = y;
7646
- needsCenter = false;
7633
+ view.panX = x;
7634
+ view.panY = y;
7635
+ view.needsCenter = false;
7647
7636
  },
7648
7637
  applyTransform,
7649
7638
  positionZoomIndicator,
7650
- componentInlineEdit,
7639
+ componentInlineEdit: view.componentInlineEdit,
7651
7640
  saveFile,
7652
7641
  openProject,
7653
7642
  enterEditOnPath(path) {
@@ -7666,20 +7655,18 @@ initShortcuts(() => ({
7666
7655
  // ─── Autosave (registered as update middleware) ──────────────────────────────
7667
7656
 
7668
7657
  /** @type {any} */
7669
- let autosaveTimer;
7670
7658
  const AUTO_SAVE_DELAY = 2000;
7671
7659
 
7672
7660
  function scheduleAutosave() {
7673
7661
  if (!S.fileHandle || !S.dirty) return;
7674
- clearTimeout(autosaveTimer);
7675
- autosaveTimer = setTimeout(async () => {
7662
+ clearTimeout(view.autosaveTimer);
7663
+ view.autosaveTimer = setTimeout(async () => {
7676
7664
  if (S.fileHandle && S.dirty && "createWritable" in S.fileHandle) {
7677
7665
  try {
7678
7666
  const writable = await S.fileHandle.createWritable();
7679
7667
  await writable.write(JSON.stringify(S.document, null, 2));
7680
7668
  await writable.close();
7681
- S = { ...S, dirty: false };
7682
- renderToolbar();
7669
+ update({ ...S, dirty: false });
7683
7670
  statusMessage("Auto-saved");
7684
7671
  } catch {}
7685
7672
  }
@@ -7689,4 +7676,3 @@ function scheduleAutosave() {
7689
7676
  addUpdateMiddleware((/** @type {any} */ state) => {
7690
7677
  if (state.dirty) scheduleAutosave();
7691
7678
  });
7692
- // trigger rebuild