@jxsuite/studio 0.7.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/studio.js CHANGED
@@ -11,32 +11,20 @@ import {
11
11
  hoverNode,
12
12
  insertNode,
13
13
  removeNode,
14
- moveNode,
15
14
  updateProperty,
16
- updateStyle,
17
- updateAttribute,
18
15
  updateDef,
19
- updateMedia,
20
16
  pushDocument,
21
17
  popDocument,
22
- updateProp,
23
- addSwitchCase,
24
- removeSwitchCase,
25
- renameSwitchCase,
26
- applyMutation,
27
18
  getNodeAtPath,
28
- nodeLabel,
29
19
  pathsEqual,
30
20
  parentElementPath,
31
21
  childIndex,
32
22
  isAncestor,
33
23
  canvasWrap,
34
- leftPanel,
35
24
  toolbarEl,
36
25
  elToPath,
37
26
  canvasPanels,
38
27
  VOID_ELEMENTS,
39
- debouncedStyleCommit,
40
28
  stripEventHandlers,
41
29
  registerRenderer,
42
30
  render,
@@ -50,9 +38,7 @@ import {
50
38
  notify,
51
39
  projectState,
52
40
  setProjectState,
53
- updateFrontmatter,
54
41
  updateUi,
55
- updateSession,
56
42
  setUpdateSessionFn,
57
43
  setGetDocFn,
58
44
  setGetSessionFn,
@@ -71,23 +57,12 @@ import {
71
57
  getActiveElement,
72
58
  isEditableBlock,
73
59
  isInlineInContext,
74
- getInlineActions,
75
60
  } from "./editor/inline-edit.js";
76
61
  import {
77
62
  showSlashMenu as sharedShowSlashMenu,
78
63
  dismissSlashMenu as sharedDismissSlashMenu,
79
64
  isSlashMenuOpen,
80
65
  } from "./editor/slash-menu.js";
81
- import { toggleInlineFormat, isTagActiveInSelection } from "./editor/inline-format.js";
82
- import {
83
- camelToLabel,
84
- attrLabel,
85
- inferInputType,
86
- findCollectionSchema,
87
- friendlyNameToVar,
88
- varDisplayName,
89
- parseCemType,
90
- } from "./utils/studio-utils.js";
91
66
  import {
92
67
  renderStatusbar,
93
68
  statusMessage,
@@ -121,10 +96,9 @@ import {
121
96
  applyOverridesToCanvas,
122
97
  } from "./utils/canvas-media.js";
123
98
  import { createDevServerPlatform } from "./platforms/devserver.js";
124
- import { codeService, setLintMarkers, getFunctionArgs } from "./services/code-services.js";
99
+ import { codeService } from "./services/code-services.js";
125
100
  import {
126
101
  getEffectiveMedia,
127
- getEffectiveStyle,
128
102
  getEffectiveImports,
129
103
  getEffectiveElements,
130
104
  getEffectiveHead,
@@ -132,8 +106,6 @@ import {
132
106
  import {
133
107
  defCategory,
134
108
  defBadgeLabel,
135
- isCustomElementDoc,
136
- collectCssParts,
137
109
  resolveDefaultForCanvas,
138
110
  renderSignalsTemplate,
139
111
  } from "./panels/signals-panel.js";
@@ -144,25 +116,16 @@ import {
144
116
  } from "./files/components.js";
145
117
 
146
118
  import {
147
- draggable,
148
119
  dropTargetForElements,
149
120
  monitorForElements,
150
121
  } from "@atlaskit/pragmatic-drag-and-drop/element/adapter";
151
- import { combine } from "@atlaskit/pragmatic-drag-and-drop/combine";
152
- import {
153
- attachInstruction,
154
- extractInstruction,
155
- } from "@atlaskit/pragmatic-drag-and-drop-hitbox/tree-item";
156
122
 
157
123
  import { html, render as litRender, nothing } from "lit-html";
158
- import { live } from "lit-html/directives/live.js";
159
124
  import { ref } from "lit-html/directives/ref.js";
160
125
  import { styleMap } from "lit-html/directives/style-map.js";
161
126
  import { ifDefined } from "lit-html/directives/if-defined.js";
162
127
 
163
128
  import webdata from "../data/webdata.json";
164
- import htmlMeta from "../data/html-meta.json";
165
- import stylebookMeta from "../data/stylebook-meta.json";
166
129
  import { renderDataExplorerTemplate } from "./panels/data-explorer.js";
167
130
  import { renderGitPanel } from "./panels/git-panel.js";
168
131
 
@@ -170,22 +133,31 @@ import { renderGitPanel } from "./panels/git-panel.js";
170
133
  // Explicit class imports + registration — bare side-effect imports are tree-shaken
171
134
  // by Bun's bundler despite sideEffects declarations in Spectrum's package.json.
172
135
  import { components as _swc } from "./ui/spectrum.js"; // eslint-disable-line no-unused-vars
173
- import { renderFieldRow } from "./ui/field-row.js";
174
136
  import "./ui/panel-resize.js";
175
137
  import { showContextMenu, dismissContextMenu } from "./editor/context-menu.js";
176
- import { convertToComponent } from "./editor/convert-to-component.js";
177
138
  import { initShortcuts } from "./editor/shortcuts.js";
139
+ import * as insertionHelper from "./editor/insertion-helper.js";
178
140
  import { renderActivityBar } from "./panels/activity-bar.js";
179
141
  import { renderBrowse } from "./browse/browse.js";
180
- import { renderCollectionsEditor } from "./settings/collections-editor.js";
181
- import { renderDefsEditor } from "./settings/defs-editor.js";
182
142
  import * as toolbarPanel from "./panels/toolbar.js";
183
143
  import * as overlaysPanel from "./panels/overlays.js";
184
144
  import * as rightPanelMod from "./panels/right-panel.js";
185
145
  import * as leftPanelMod from "./panels/left-panel.js";
186
- import { mediaDisplayName } from "./panels/shared.js";
187
- import { initCssData, getCssInitialMap } from "./panels/style-utils.js";
188
- import { widgetForType } from "./panels/style-inputs.js";
146
+ import { renderStylebookMode, renderStylebookOverlays } from "./panels/stylebook-panel.js";
147
+ import {
148
+ registerLayersDnD,
149
+ registerComponentsDnD,
150
+ registerElementsDnD,
151
+ applyDropInstruction,
152
+ } from "./panels/dnd.js";
153
+ import { mediaDisplayName, defaultDef } from "./panels/shared.js";
154
+ import { renderFunctionEditor, registerFunctionCompletions } from "./panels/editors.js";
155
+ import {
156
+ renderBlockActionBar,
157
+ dismissLinkPopover,
158
+ initBlockActionBar,
159
+ } from "./panels/block-action-bar.js";
160
+ import { initCssData } from "./panels/style-utils.js";
189
161
  import * as monaco from "monaco-editor/esm/vs/editor/editor.api.js";
190
162
 
191
163
  // ─── Globals ──────────────────────────────────────────────────────────────────
@@ -785,8 +757,16 @@ overlaysPanel.mount({
785
757
  getActivePanel,
786
758
  });
787
759
 
760
+ initBlockActionBar({
761
+ getCanvasMode: () => canvasMode,
762
+ findCanvasElement,
763
+ getActivePanel,
764
+ navigateToComponent,
765
+ createFloatingContainer,
766
+ });
767
+
788
768
  rightPanelMod.mount({
789
- propertiesSidebarTemplate,
769
+ navigateToComponent,
790
770
  getCanvasMode: () => canvasMode,
791
771
  renderCanvas: () => renderCanvas(),
792
772
  updateForcedPseudoPreview,
@@ -804,8 +784,6 @@ leftPanelMod.mount({
804
784
  defCategory,
805
785
  defBadgeLabel,
806
786
  navigateToComponent,
807
- selectStylebookTag,
808
- stylebookMeta,
809
787
  webdata,
810
788
  defaultDef,
811
789
  registerLayersDnD,
@@ -1177,7 +1155,15 @@ function renderCanvas() {
1177
1155
 
1178
1156
  // Settings mode: render element catalog with panzoom surface
1179
1157
  if (canvasMode === "settings") {
1180
- renderSettings();
1158
+ renderStylebookMode({
1159
+ canvasPanelTemplate,
1160
+ applyTransform,
1161
+ observeCenterUntilStable,
1162
+ renderZoomIndicator,
1163
+ updateActivePanelHeaders,
1164
+ overlayBoxDescriptor,
1165
+ effectiveZoom,
1166
+ });
1181
1167
  return;
1182
1168
  }
1183
1169
 
@@ -1965,403 +1951,6 @@ function getActivePanel() {
1965
1951
  return canvasPanels[0];
1966
1952
  }
1967
1953
 
1968
- // ── Floating inline toolbar ────────────────────────────────────────────────
1969
-
1970
- /** Pre-built icon templates for inline format buttons (avoids unsafeStatic) */
1971
- const formatIconMap = /** @type {Record<string, any>} */ ({
1972
- "sp-icon-text-bold": html`<sp-icon-text-bold slot="icon"></sp-icon-text-bold>`,
1973
- "sp-icon-text-italic": html`<sp-icon-text-italic slot="icon"></sp-icon-text-italic>`,
1974
- "sp-icon-text-underline": html`<sp-icon-text-underline slot="icon"></sp-icon-text-underline>`,
1975
- "sp-icon-text-strikethrough": html`<sp-icon-text-strikethrough
1976
- slot="icon"
1977
- ></sp-icon-text-strikethrough>`,
1978
- "sp-icon-text-superscript": html`<sp-icon-text-superscript
1979
- slot="icon"
1980
- ></sp-icon-text-superscript>`,
1981
- "sp-icon-text-subscript": html`<sp-icon-text-subscript slot="icon"></sp-icon-text-subscript>`,
1982
- "sp-icon-code": html`<sp-icon-code slot="icon"></sp-icon-code>`,
1983
- "sp-icon-link": html`<sp-icon-link slot="icon"></sp-icon-link>`,
1984
- });
1985
-
1986
- /**
1987
- * Prevent the bar from stealing focus from contenteditable
1988
- *
1989
- * @param {any} e
1990
- */
1991
- function onBarMousedown(e) {
1992
- if (e.target.closest("sp-textfield")) return;
1993
- if (e.target.closest(".bar-drag-handle")) return;
1994
- e.preventDefault();
1995
- }
1996
-
1997
- /** Saved selection range for format button mousedown→click flow */
1998
- function captureSelectionRange() {
1999
- const sel = window.getSelection();
2000
- if (sel && sel.rangeCount) view.savedRange = sel.getRangeAt(0).cloneRange();
2001
- }
2002
-
2003
- /**
2004
- * @param {any} e
2005
- * @param {any} action
2006
- */
2007
- function onFormatClick(e, action) {
2008
- e.stopPropagation();
2009
- if (action.command === "link") {
2010
- showLinkPopover(e.target.closest("sp-action-button"));
2011
- } else if (view.savedRange) {
2012
- const sel = /** @type {any} */ (window.getSelection());
2013
- const anchor = view.savedRange.startContainer;
2014
- const editableRoot = (
2015
- anchor?.nodeType === Node.ELEMENT_NODE ? anchor : anchor?.parentElement
2016
- )?.closest("[contenteditable]");
2017
- if (editableRoot) {
2018
- editableRoot.focus();
2019
- sel.removeAllRanges();
2020
- sel.addRange(view.savedRange);
2021
- applyInlineFormat(action);
2022
- }
2023
- }
2024
- }
2025
-
2026
- function renderParentSelector() {
2027
- const pPath = parentElementPath(S.selection);
2028
- if (!pPath) return nothing;
2029
- const parentNode = getNodeAtPath(S.document, pPath);
2030
- return html`
2031
- <sp-action-button
2032
- size="xs"
2033
- quiet
2034
- title="Select parent: ${nodeLabel(parentNode)}"
2035
- @click=${(/** @type {any} */ e) => {
2036
- e.stopPropagation();
2037
- update(selectNode(S, pPath));
2038
- }}
2039
- >
2040
- <sp-icon-back slot="icon"></sp-icon-back>
2041
- </sp-action-button>
2042
- `;
2043
- }
2044
-
2045
- function renderMoveArrows() {
2046
- const idx = /** @type {number} */ (childIndex(S.selection));
2047
- const pPath = parentElementPath(S.selection);
2048
- const parentNode = getNodeAtPath(S.document, /** @type {any} */ (pPath));
2049
- const siblings = parentNode?.children;
2050
- return html`
2051
- <sp-action-button
2052
- size="xs"
2053
- quiet
2054
- title="Move up"
2055
- ?disabled=${idx <= 0}
2056
- @click=${(/** @type {any} */ e) => {
2057
- e.stopPropagation();
2058
- moveSelectionUp();
2059
- }}
2060
- >
2061
- <sp-icon-arrow-up slot="icon"></sp-icon-arrow-up>
2062
- </sp-action-button>
2063
- <sp-action-button
2064
- size="xs"
2065
- quiet
2066
- title="Move down"
2067
- ?disabled=${!siblings || idx >= siblings.length - 1}
2068
- @click=${(/** @type {any} */ e) => {
2069
- e.stopPropagation();
2070
- moveSelectionDown();
2071
- }}
2072
- >
2073
- <sp-icon-arrow-down slot="icon"></sp-icon-arrow-down>
2074
- </sp-action-button>
2075
- `;
2076
- }
2077
-
2078
- /**
2079
- * Apply an inline format action.
2080
- *
2081
- * @param {any} action
2082
- */
2083
- function applyInlineFormat(action) {
2084
- // Map commands to semantic tags
2085
- /** @type {Record<string, any>} */
2086
- const cmdToTag = {
2087
- bold: "strong",
2088
- italic: "em",
2089
- underline: "u",
2090
- strikethrough: "del",
2091
- superscript: "sup",
2092
- subscript: "sub",
2093
- code: "code",
2094
- };
2095
-
2096
- const tag = cmdToTag[action.command];
2097
- if (tag) {
2098
- const editableRoot = getActiveElement();
2099
- toggleInlineFormat(tag, editableRoot);
2100
- }
2101
- requestAnimationFrame(() => renderBlockActionBar());
2102
- }
2103
-
2104
- /** Show a link URL popover anchored to a toolbar button. */
2105
- view.linkPopoverHost = document.createElement("div");
2106
- view.linkPopoverHost.style.display = "contents";
2107
- (document.querySelector("sp-theme") || document.body).appendChild(view.linkPopoverHost);
2108
-
2109
- /** Dismiss the link popover if open. */
2110
- function dismissLinkPopover() {
2111
- if (view.linkPopoverHost) litRender(nothing, view.linkPopoverHost);
2112
- }
2113
-
2114
- /** @param {any} anchorBtn */
2115
- function showLinkPopover(anchorBtn) {
2116
- // Dismiss existing
2117
- litRender(nothing, view.linkPopoverHost);
2118
-
2119
- const sel = window.getSelection();
2120
- /** @type {any} */
2121
- let existingLink = null;
2122
- if (sel?.rangeCount) {
2123
- /** @type {any} */
2124
- let node = sel.anchorNode;
2125
- while (node && node !== document.body) {
2126
- if (node.nodeType === Node.ELEMENT_NODE && node.tagName.toLowerCase() === "a") {
2127
- existingLink = node;
2128
- break;
2129
- }
2130
- node = node.parentNode;
2131
- }
2132
- }
2133
-
2134
- const rect = anchorBtn.getBoundingClientRect();
2135
-
2136
- const onApply = () => {
2137
- const field = view.linkPopoverHost.querySelector("sp-textfield");
2138
- const url = /** @type {any} */ (field)?.value;
2139
- if (existingLink) {
2140
- existingLink.setAttribute("href", url);
2141
- } else if (url) {
2142
- document.execCommand("createLink", false, url);
2143
- }
2144
- litRender(nothing, view.linkPopoverHost);
2145
- renderBlockActionBar();
2146
- };
2147
-
2148
- const onRemove = () => {
2149
- const frag = document.createDocumentFragment();
2150
- while (existingLink.firstChild) frag.appendChild(existingLink.firstChild);
2151
- existingLink.parentNode.replaceChild(frag, existingLink);
2152
- litRender(nothing, view.linkPopoverHost);
2153
- renderBlockActionBar();
2154
- };
2155
-
2156
- const onKeydown = (/** @type {any} */ e) => {
2157
- if (e.key === "Enter") onApply();
2158
- else if (e.key === "Escape") {
2159
- litRender(nothing, view.linkPopoverHost);
2160
- }
2161
- };
2162
-
2163
- litRender(
2164
- html`
2165
- <sp-popover
2166
- class="link-popover"
2167
- open
2168
- style="position:fixed; left:${rect.left}px; top:${rect.bottom + 4}px; z-index:30"
2169
- >
2170
- <sp-textfield
2171
- placeholder="https://..."
2172
- size="s"
2173
- style="width:200px"
2174
- value=${existingLink?.getAttribute("href") || ""}
2175
- @keydown=${onKeydown}
2176
- ></sp-textfield>
2177
- <sp-action-button size="xs" @click=${onApply}>
2178
- ${existingLink ? "Update" : "Apply"}
2179
- </sp-action-button>
2180
- ${existingLink
2181
- ? html` <sp-action-button size="xs" @click=${onRemove}>Remove</sp-action-button> `
2182
- : nothing}
2183
- </sp-popover>
2184
- `,
2185
- view.linkPopoverHost,
2186
- );
2187
-
2188
- requestAnimationFrame(
2189
- () =>
2190
- /** @type {HTMLElement | null} */ (
2191
- view.linkPopoverHost?.querySelector("sp-textfield")
2192
- )?.focus(),
2193
- );
2194
- }
2195
-
2196
- /** Move the selected node up (swap with previous sibling). */
2197
- function moveSelectionUp() {
2198
- if (!S.selection || S.selection.length < 2) return;
2199
- const idx = /** @type {number} */ (childIndex(S.selection));
2200
- if (idx <= 0) return;
2201
- const pPath = /** @type {any} */ (parentElementPath(S.selection));
2202
- update(moveNode(S, S.selection, pPath, idx - 1));
2203
- session = { ...session, selection: [...pPath, "children", idx - 1] };
2204
- S = toFlat(doc, session);
2205
- renderOverlays();
2206
- }
2207
-
2208
- /** Move the selected node down (swap with next sibling). */
2209
- function moveSelectionDown() {
2210
- if (!S.selection || S.selection.length < 2) return;
2211
- const idx = /** @type {number} */ (childIndex(S.selection));
2212
- const pPath = /** @type {any} */ (parentElementPath(S.selection));
2213
- const parentNode = getNodeAtPath(S.document, pPath);
2214
- const siblings = parentNode?.children;
2215
- if (!siblings || idx >= siblings.length - 1) return;
2216
- update(moveNode(S, S.selection, pPath, idx + 2));
2217
- session = { ...session, selection: [...pPath, "children", idx + 1] };
2218
- S = toFlat(doc, session);
2219
- renderOverlays();
2220
- }
2221
-
2222
- /**
2223
- * Render the unified block action bar above the selected element. Combines tag indicator, drag
2224
- * handle, move arrows, and inline formatting.
2225
- */
2226
- function renderBlockActionBar() {
2227
- // Ensure persistent render container exists
2228
- if (!view.blockActionBarEl) {
2229
- view.blockActionBarEl = createFloatingContainer();
2230
- }
2231
-
2232
- // Tear down drag if it was active
2233
- if (view.selDragCleanup) {
2234
- view.selDragCleanup();
2235
- view.selDragCleanup = null;
2236
- }
2237
-
2238
- if (!S.selection || (canvasMode !== "design" && canvasMode !== "edit")) {
2239
- litRender(nothing, view.blockActionBarEl);
2240
- return;
2241
- }
2242
-
2243
- const activePanel = getActivePanel();
2244
- if (!activePanel) {
2245
- litRender(nothing, view.blockActionBarEl);
2246
- return;
2247
- }
2248
- const el = findCanvasElement(S.selection, activePanel.canvas);
2249
- const node = el && getNodeAtPath(S.document, S.selection);
2250
- if (!el || !node) {
2251
- litRender(nothing, view.blockActionBarEl);
2252
- return;
2253
- }
2254
-
2255
- const tag = (node.tagName ?? "div").toLowerCase();
2256
- const elRect = el.getBoundingClientRect();
2257
- const topPos = elRect.top < 80 ? elRect.bottom + 4 : elRect.top - 38;
2258
-
2259
- // Inline format state
2260
- const inlineEditing = isEditing() || el.contentEditable === "true";
2261
- const actions = getInlineActions(tag) || [];
2262
- const showFormat = inlineEditing && actions.length > 0;
2263
- const activeValues = showFormat
2264
- ? actions.filter((a) => isTagActiveInSelection(a.tag, el)).map((a) => a.tag)
2265
- : [];
2266
-
2267
- litRender(
2268
- html`
2269
- <div
2270
- class="block-action-bar"
2271
- style="left:${elRect.left}px; top:${topPos}px"
2272
- @mousedown=${onBarMousedown}
2273
- >
2274
- ${S.selection.length >= 2 ? renderParentSelector() : nothing}
2275
-
2276
- <span class="bar-tag">${node.$id || (node.tagName ?? "div")}</span>
2277
-
2278
- ${S.selection.length >= 2
2279
- ? html`<span class="bar-drag-handle" title="Drag to reorder">⡇</span>`
2280
- : nothing}
2281
- ${S.selection.length >= 2 ? renderMoveArrows() : nothing}
2282
- ${S.selection.length >= 2 && node.tagName
2283
- ? (() => {
2284
- const isComp =
2285
- node.tagName.includes("-") &&
2286
- componentRegistry.some((/** @type {any} */ c) => c.tagName === node.tagName);
2287
- if (isComp) {
2288
- const comp = componentRegistry.find(
2289
- (/** @type {any} */ c) => c.tagName === node.tagName,
2290
- );
2291
- return html`<sp-action-button
2292
- size="xs"
2293
- quiet
2294
- title="Edit Component"
2295
- @click=${() => navigateToComponent(comp.path)}
2296
- ><sp-icon-edit slot="icon" size="xs"></sp-icon-edit
2297
- ></sp-action-button>`;
2298
- }
2299
- return html`<sp-action-button
2300
- size="xs"
2301
- quiet
2302
- title="Convert to Component"
2303
- @click=${() => convertToComponent(S)}
2304
- ><sp-icon-box slot="icon" size="xs"></sp-icon-box
2305
- ></sp-action-button>`;
2306
- })()
2307
- : nothing}
2308
- ${showFormat
2309
- ? html`
2310
- <sp-divider size="s" vertical></sp-divider>
2311
- <sp-action-group
2312
- size="xs"
2313
- compact
2314
- emphasized
2315
- selects="multiple"
2316
- selected=${activeValues.length ? JSON.stringify(activeValues) : nothing}
2317
- >
2318
- ${actions.map(
2319
- (action) => html`
2320
- <sp-action-button
2321
- size="xs"
2322
- value=${action.tag}
2323
- title="${action.label}${action.shortcut ? ` (${action.shortcut})` : ""}"
2324
- @mousedown=${captureSelectionRange}
2325
- @click=${(/** @type {any} */ e) => onFormatClick(e, action)}
2326
- >
2327
- ${formatIconMap[action.icon] ?? nothing}
2328
- </sp-action-button>
2329
- `,
2330
- )}
2331
- </sp-action-group>
2332
- `
2333
- : nothing}
2334
- </div>
2335
- `,
2336
- view.blockActionBarEl,
2337
- );
2338
-
2339
- // Post-render side effects
2340
- requestAnimationFrame(() => {
2341
- const bar = view.blockActionBarEl?.firstElementChild;
2342
- if (!bar) return;
2343
- // Clamp to window
2344
- const barRect = bar.getBoundingClientRect();
2345
- if (barRect.right > window.innerWidth) {
2346
- bar.style.left = `${Math.max(0, window.innerWidth - barRect.width)}px`;
2347
- }
2348
- // Attach drag handle
2349
- if (S.selection.length >= 2) {
2350
- const handle = bar.querySelector(".bar-drag-handle");
2351
- if (handle) {
2352
- if (view.selDragCleanup) {
2353
- view.selDragCleanup();
2354
- view.selDragCleanup = null;
2355
- }
2356
- view.selDragCleanup = draggable({
2357
- element: handle,
2358
- getInitialData: () => ({ type: "tree-node", path: S.selection }),
2359
- });
2360
- }
2361
- }
2362
- });
2363
- }
2364
-
2365
1954
  // ── Pseudo-state preview ──────────────────────────────────────────────────────
2366
1955
  // When a pseudo-selector (:hover, :focus, etc.) is active in the style sidebar,
2367
1956
  // force those styles onto the selected element so the user can see the result.
@@ -2690,6 +2279,24 @@ function registerPanelEvents(panel) {
2690
2279
  },
2691
2280
  opts,
2692
2281
  );
2282
+
2283
+ // Mount insertion helper — positioned via CSS Anchor Positioning
2284
+ insertionHelper.mount({
2285
+ getState: () => S,
2286
+ update,
2287
+ getCanvasMode: () => canvasMode,
2288
+ withPanelPointerEvents,
2289
+ effectiveZoom,
2290
+ defaultDef,
2291
+ insertNode,
2292
+ selectNode,
2293
+ parentElementPath,
2294
+ childIndex,
2295
+ getNodeAtPath,
2296
+ elToPath,
2297
+ panel,
2298
+ });
2299
+ view.canvasEventCleanups.push(() => insertionHelper.unmount());
2693
2300
  }
2694
2301
 
2695
2302
  // ─── Inline editing bridge ────────────────────────────────────────────────────
@@ -3197,2907 +2804,24 @@ function renderLeftPanel() {
3197
2804
  leftPanelMod.render();
3198
2805
  }
3199
2806
 
3200
- /** Register DnD on layer rows after litRender — called from left-panel.js */
3201
- function registerLayersDnD() {
3202
- requestAnimationFrame(() => {
3203
- const container = /** @type {any} */ (leftPanel)?.querySelector(".layers-container");
3204
- if (!container) return;
3205
-
3206
- container.querySelectorAll("[data-dnd-row]").forEach(
3207
- /** @param {any} row */ (row) => {
3208
- const rowPath = /** @type {string} */ (row.dataset.path)
3209
- .split("/")
3210
- .map((/** @type {any} */ s) => (/^\d+$/.test(s) ? parseInt(s) : s));
3211
- const rowDepth = parseInt(/** @type {string} */ (row.dataset.dndDepth)) || 0;
3212
- const isVoid = row.hasAttribute("data-dnd-void");
3213
-
3214
- const cleanup = combine(
3215
- draggable({
3216
- element: row,
3217
- canDrag(/** @type {any} */ { element: _el, input }) {
3218
- // Prevent drag when clicking action buttons
3219
- const target = /** @type {HTMLElement} */ (
3220
- document.elementFromPoint(input.clientX, input.clientY)
3221
- );
3222
- if (target?.closest(".layer-actions")) return false;
3223
- return true;
3224
- },
3225
- getInitialData() {
3226
- return { type: "tree-node", path: rowPath };
3227
- },
3228
- onDragStart() {
3229
- row.classList.add("dragging");
3230
- view.layerDragSourceHeight = row.offsetHeight;
3231
- },
3232
- onDrop() {
3233
- row.classList.remove("dragging");
3234
- },
3235
- }),
3236
- dropTargetForElements({
3237
- element: row,
3238
- canDrop(/** @type {any} */ { source }) {
3239
- const srcPath = source.data.path;
3240
- if (srcPath && isAncestor(srcPath, rowPath)) return false;
3241
- return true;
3242
- },
3243
- getData(/** @type {any} */ { input, element }) {
3244
- return attachInstruction(
3245
- { path: rowPath },
3246
- /** @type {any} */ ({
3247
- input,
3248
- element,
3249
- currentLevel: rowDepth,
3250
- indentPerLevel: 16,
3251
- block: isVoid ? ["make-child"] : [],
3252
- }),
3253
- );
3254
- },
3255
- onDragEnter(/** @type {any} */ { self }) {
3256
- showLayerDropGap(row, self.data, container);
3257
- },
3258
- onDrag(/** @type {any} */ { self }) {
3259
- showLayerDropGap(row, self.data, container);
3260
- },
3261
- onDragLeave() {
3262
- clearLayerDropGap(container);
3263
- },
3264
- onDrop() {
3265
- clearLayerDropGap(container);
3266
- },
3267
- }),
3268
- );
3269
- view.dndCleanups.push(cleanup);
3270
- },
3271
- );
3272
-
3273
- // Global monitor
3274
- const monitorCleanup = monitorForElements({
3275
- onDrop(/** @type {any} */ { source, location }) {
3276
- clearLayerDropGap(container);
3277
- const target = location.current.dropTargets[0];
3278
- if (!target) return;
3279
- const instruction = extractInstruction(target.data);
3280
- if (!instruction || instruction.type === "instruction-blocked") return;
3281
- const srcData = source.data;
3282
- const targetPath = target.data.path;
3283
- applyDropInstruction(instruction, srcData, targetPath);
3284
- },
3285
- });
3286
- view.dndCleanups.push(monitorCleanup);
3287
- });
3288
- }
3289
-
3290
- /** Register DnD on component rows — called from renderLeftPanel when tab=components */
3291
- function registerComponentsDnD() {
3292
- requestAnimationFrame(() => {
3293
- const container = /** @type {any} */ (leftPanel)?.querySelector(".components-section");
3294
- if (!container) return;
3295
-
3296
- container.querySelectorAll("[data-component-tag]").forEach(
3297
- /** @param {any} row */ (row) => {
3298
- const tagName = row.dataset.componentTag;
3299
- if (!tagName) return;
3300
- const comp = componentRegistry.find(/** @param {any} c */ (c) => c.tagName === tagName);
3301
- if (!comp) return;
3302
-
3303
- // Fill preview with live rendered component
3304
- const preview = row.querySelector(".element-card-preview");
3305
- if (preview && !preview.querySelector(tagName)) {
3306
- renderComponentPreview(comp).then((el) => {
3307
- preview.textContent = "";
3308
- preview.appendChild(el);
3309
- });
3310
- }
3311
-
3312
- const instanceDef = {
3313
- tagName: comp.tagName,
3314
- $props: Object.fromEntries(
3315
- comp.props.map((/** @type {any} */ p) => [
3316
- p.name,
3317
- p.default !== undefined ? p.default : "",
3318
- ]),
3319
- ),
3320
- };
3321
- const cleanup = draggable({
3322
- element: row,
3323
- getInitialData() {
3324
- return { type: "block", fragment: structuredClone(instanceDef) };
3325
- },
3326
- });
3327
- view.dndCleanups.push(cleanup);
3328
- },
3329
- );
3330
- });
3331
- }
3332
-
3333
- /** @type {any} */
3334
-
3335
- /**
3336
- * @param {any} rowEl
3337
- * @param {any} data
3338
- * @param {any} container
3339
- */
3340
- function showLayerDropGap(rowEl, data, container) {
3341
- const instruction = extractInstruction(data);
3342
-
3343
- // Clear previous drop-target highlight
3344
- if (view._currentDropTargetRow && view._currentDropTargetRow !== rowEl) {
3345
- view._currentDropTargetRow.classList.remove("drop-target");
3346
- }
3347
-
3348
- if (!instruction || instruction.type === "instruction-blocked") {
3349
- clearLayerDropGap(container);
3350
- return;
3351
- }
3352
-
3353
- if (instruction.type === "make-child") {
3354
- clearLayerDropGap(container);
3355
- rowEl.classList.add("drop-target");
3356
- view._currentDropTargetRow = rowEl;
3357
- return;
3358
- }
3359
-
3360
- rowEl.classList.remove("drop-target");
3361
- view._currentDropTargetRow = rowEl;
3362
-
3363
- // Shift rows to create gap
3364
- const rows = Array.from(container.querySelectorAll(".layers-tree .layer-row"));
3365
- const targetIdx = rows.indexOf(rowEl);
3366
- const gap = view.layerDragSourceHeight;
3367
-
3368
- for (let i = 0; i < rows.length; i++) {
3369
- if (rows[i].classList.contains("dragging")) continue;
3370
- if (instruction.type === "reorder-above") {
3371
- rows[i].style.transform = i >= targetIdx ? `translateY(${gap}px)` : "";
3372
- } else {
3373
- rows[i].style.transform = i > targetIdx ? `translateY(${gap}px)` : "";
3374
- }
3375
- }
3376
- }
3377
-
3378
- /** @param {any} container */
3379
- function clearLayerDropGap(container) {
3380
- if (view._currentDropTargetRow) {
3381
- view._currentDropTargetRow.classList.remove("drop-target");
3382
- view._currentDropTargetRow = null;
3383
- }
3384
- const rows = container.querySelectorAll(".layers-tree .layer-row");
3385
- for (const r of rows) r.style.transform = "";
3386
- }
3387
-
3388
- /**
3389
- * Select a tag in the stylebook — shared by layers panel click and canvas click.
3390
- *
3391
- * @param {string} tag
3392
- * @param {string | null} [media]
3393
- */
3394
- function selectStylebookTag(tag, media) {
3395
- updateSession({
3396
- selection: [],
3397
- ui: {
3398
- stylebookSelection: tag,
3399
- rightTab: "style",
3400
- activeSelector: `& ${tag}`,
3401
- ...(media !== undefined ? { activeMedia: media } : {}),
3402
- },
3403
- });
3404
- renderStylebookOverlays();
3405
- requestAnimationFrame(() => {
3406
- if (canvasPanels.length > 0) {
3407
- const el = findStylebookEl(canvasPanels[0].canvas, tag);
3408
- if (el) el.scrollIntoView({ behavior: "smooth", block: "center" });
3409
- }
3410
- });
3411
- }
3412
-
3413
- /**
3414
- * Apply a DnD instruction to the state
3415
- *
3416
- * @param {any} instruction
3417
- * @param {any} srcData
3418
- * @param {any} targetPath
3419
- */
3420
- function applyDropInstruction(instruction, srcData, targetPath) {
3421
- if (srcData.type === "tree-node") {
3422
- const fromPath = srcData.path;
3423
- const targetParent = /** @type {any} */ (parentElementPath(targetPath));
3424
- const targetIdx = /** @type {number} */ (childIndex(targetPath));
3425
-
3426
- switch (instruction.type) {
3427
- case "reorder-above":
3428
- update(moveNode(S, fromPath, targetParent, targetIdx));
3429
- break;
3430
- case "reorder-below":
3431
- update(moveNode(S, fromPath, targetParent, targetIdx + 1));
3432
- break;
3433
- case "make-child": {
3434
- const target = getNodeAtPath(S.document, targetPath);
3435
- const len = target?.children?.length || 0;
3436
- update(moveNode(S, fromPath, targetPath, len));
3437
- break;
3438
- }
3439
- }
3440
- } else if (srcData.type === "block") {
3441
- const targetParent = /** @type {any} */ (parentElementPath(targetPath));
3442
- const targetIdx = /** @type {number} */ (childIndex(targetPath));
3443
-
3444
- switch (instruction.type) {
3445
- case "reorder-above":
3446
- update(insertNode(S, targetParent, targetIdx, structuredClone(srcData.fragment)));
3447
- break;
3448
- case "reorder-below":
3449
- update(insertNode(S, targetParent, targetIdx + 1, structuredClone(srcData.fragment)));
3450
- break;
3451
- case "make-child": {
3452
- const target = getNodeAtPath(S.document, targetPath);
3453
- const len = target?.children?.length || 0;
3454
- update(insertNode(S, targetPath, len, structuredClone(srcData.fragment)));
3455
- break;
3456
- }
3457
- }
3458
-
3459
- // Auto-import to $elements if the dropped block is a custom component
3460
- const tag = srcData.fragment?.tagName;
3461
- if (tag && tag.includes("-")) {
3462
- const comp = componentRegistry.find((/** @type {any} */ c) => c.tagName === tag);
3463
- if (comp) {
3464
- const elements = S.document.$elements || [];
3465
- if (comp.source === "npm") {
3466
- // npm web component: add cherry-picked subpath specifier
3467
- const specifier = comp.modulePath ? `${comp.package}/${comp.modulePath}` : comp.package;
3468
- const alreadyImported = elements.some(
3469
- (/** @type {any} */ e) => e === specifier || e === comp.package,
3470
- );
3471
- if (!alreadyImported) {
3472
- S = applyMutation(S, (/** @type {any} */ doc) => {
3473
- if (!doc.$elements) doc.$elements = [];
3474
- doc.$elements.push(specifier);
3475
- });
3476
- }
3477
- } else {
3478
- // JX component: add $ref object
3479
- const alreadyImported = elements.some(
3480
- (/** @type {any} */ e) =>
3481
- e.$ref &&
3482
- (e.$ref === `./${comp.path}` || e.$ref.endsWith(comp.path.split("/").pop())),
3483
- );
3484
- if (!alreadyImported) {
3485
- const relPath = computeRelativePath(S.documentPath, comp.path);
3486
- S = applyMutation(S, (/** @type {any} */ doc) => {
3487
- if (!doc.$elements) doc.$elements = [];
3488
- doc.$elements.push({ $ref: relPath });
3489
- });
3490
- }
3491
- }
3492
- }
3493
- }
3494
- }
3495
- }
2807
+ // ─── DnD registration: delegated to panels/dnd.js ───────────────────────────
3496
2808
 
3497
- /**
3498
- * Generate a sensible default Jx node for a given tag name
3499
- *
3500
- * @param {any} tag
3501
- */
3502
- function defaultDef(tag) {
3503
- /** @type {any} */
3504
- const def = { tagName: tag };
3505
- if (/^h[1-6]$/.test(tag)) def.textContent = "Heading";
3506
- else if (tag === "p") def.textContent = "Paragraph text";
3507
- else if (
3508
- tag === "span" ||
3509
- tag === "strong" ||
3510
- tag === "em" ||
3511
- tag === "small" ||
3512
- tag === "mark" ||
3513
- tag === "code" ||
3514
- tag === "abbr" ||
3515
- tag === "q" ||
3516
- tag === "sub" ||
3517
- tag === "sup" ||
3518
- tag === "time"
3519
- )
3520
- def.textContent = "Text";
3521
- else if (tag === "a") {
3522
- def.textContent = "Link";
3523
- def.attributes = { href: "#" };
3524
- } else if (tag === "button") def.textContent = "Button";
3525
- else if (tag === "label") def.textContent = "Label";
3526
- else if (tag === "legend") def.textContent = "Legend";
3527
- else if (tag === "caption") def.textContent = "Caption";
3528
- else if (tag === "summary") def.textContent = "Summary";
3529
- else if (
3530
- tag === "li" ||
3531
- tag === "dt" ||
3532
- tag === "dd" ||
3533
- tag === "th" ||
3534
- tag === "td" ||
3535
- tag === "option"
3536
- )
3537
- def.textContent = "Item";
3538
- else if (tag === "blockquote") def.textContent = "Quote";
3539
- else if (tag === "pre") def.textContent = "Preformatted text";
3540
- else if (tag === "input") def.attributes = { type: "text", placeholder: "Enter text..." };
3541
- else if (tag === "img") def.attributes = { src: "", alt: "Image" };
3542
- else if (tag === "iframe") def.attributes = { src: "" };
3543
- else if (tag === "select") def.children = [{ tagName: "option", textContent: "Option 1" }];
3544
- else if (tag === "ul" || tag === "ol") def.children = [{ tagName: "li", textContent: "Item" }];
3545
- else if (tag === "dl")
3546
- def.children = [
3547
- { tagName: "dt", textContent: "Term" },
3548
- { tagName: "dd", textContent: "Definition" },
3549
- ];
3550
- else if (tag === "table")
3551
- def.children = [
3552
- {
3553
- tagName: "thead",
3554
- children: [{ tagName: "tr", children: [{ tagName: "th", textContent: "Header" }] }],
3555
- },
3556
- {
3557
- tagName: "tbody",
3558
- children: [{ tagName: "tr", children: [{ tagName: "td", textContent: "Cell" }] }],
3559
- },
3560
- ];
3561
- else if (tag === "details")
3562
- def.children = [
3563
- { tagName: "summary", textContent: "Summary" },
3564
- { tagName: "p", textContent: "Detail content" },
3565
- ];
3566
- return def;
3567
- }
2809
+ // ─── Stylebook ───────────────────────────────────────────────────────────────
2810
+ // Extracted to panels/stylebook-panel.js
3568
2811
 
3569
- const unsafeTags = new Set(["script", "style", "link", "iframe", "object", "embed"]);
3570
-
3571
- function registerElementsDnD() {
3572
- requestAnimationFrame(() => {
3573
- const container = /** @type {any} */ (leftPanel)?.querySelector(".panel-body");
3574
- if (!container) return;
3575
- container.querySelectorAll("[data-block-tag]").forEach(
3576
- /** @param {any} row */ (row) => {
3577
- const tag = row.dataset.blockTag;
3578
- const preview = row.querySelector(".element-card-preview");
3579
- if (preview && !preview.firstChild) {
3580
- const el = document.createElement(unsafeTags.has(tag) ? "span" : tag);
3581
- el.textContent = tag;
3582
- preview.appendChild(el);
3583
- }
3584
- const def = defaultDef(tag);
3585
- const cleanup = draggable({
3586
- element: row,
3587
- getInitialData() {
3588
- return { type: "block", fragment: structuredClone(def) };
3589
- },
3590
- });
3591
- view.dndCleanups.push(cleanup);
3592
- },
3593
- );
3594
- });
3595
- }
2812
+ // ─── Inspector ────────────────────────────────────────────────────────────────
2813
+ // Extracted to panels/properties-panel.js
3596
2814
 
3597
- // ─── Stylebook ───────────────────────────────────────────────────────────────
2815
+ // ─── Style Sidebar (metadata-driven) ───────────────────────────────────────────
3598
2816
 
3599
- /** Map from rendered stylebook DOM elements to their tag names */
2817
+ // UNIT_RE imported from ui/unit-selector.js
3600
2818
 
3601
- /**
3602
- * Build a DOM element tree from a stylebook-meta.json entry. Applies any existing tag-scoped styles
3603
- * from rootStyle["& tag"].
3604
- *
3605
- * @param {any} entry
3606
- * @param {any} rootStyle
3607
- * @param {any} activeBreakpoints
3608
- */
3609
- function buildStylebookElement(entry, rootStyle, activeBreakpoints) {
3610
- const el = document.createElement(entry.tag);
3611
- if (entry.text) el.textContent = entry.text;
3612
- if (entry.attributes) {
3613
- for (const [k, v] of Object.entries(entry.attributes)) {
3614
- try {
3615
- el.setAttribute(k, /** @type {string} */ (v));
3616
- } catch {}
3617
- }
3618
- }
3619
- if (entry.style) el.style.cssText = entry.style;
3620
- // Apply custom styles from document root
3621
- const tagStyle = rootStyle[`& ${entry.tag}`];
3622
- if (tagStyle) {
3623
- for (const [prop, val] of Object.entries(tagStyle)) {
3624
- if (typeof val === "string" || typeof val === "number") {
3625
- try {
3626
- /** @type {any} */ (el.style)[prop] = val;
3627
- } catch {}
3628
- }
3629
- }
3630
- // Apply media overrides for active breakpoints
3631
- if (activeBreakpoints) {
3632
- for (const [key, val] of Object.entries(tagStyle)) {
3633
- if (!key.startsWith("@") || typeof val !== "object") continue;
3634
- const mediaName = key.slice(1);
3635
- if (mediaName === "--") continue;
3636
- if (activeBreakpoints.has(mediaName)) {
3637
- for (const [prop, v] of Object.entries(/** @type {any} */ (val))) {
3638
- if (typeof v === "string" || typeof v === "number") {
3639
- try {
3640
- /** @type {any} */ (el.style)[prop] = v;
3641
- } catch {}
3642
- }
3643
- }
3644
- }
3645
- }
3646
- }
3647
- }
3648
- if (entry.children) {
3649
- for (const child of entry.children) {
3650
- el.appendChild(buildStylebookElement(child, rootStyle, activeBreakpoints));
3651
- }
3652
- }
3653
- return el;
3654
- }
2819
+ // inferInputType — imported from studio-utils.js
3655
2820
 
3656
- /**
3657
- * Render a live component preview by registering its custom element and instantiating it. Falls
3658
- * back to a placeholder if registration fails.
3659
- *
3660
- * @param {any} comp - Entry from componentRegistry ({ tagName, path, props })
3661
- * @returns {Promise<HTMLElement>}
3662
- */
3663
- async function renderComponentPreview(comp) {
3664
- try {
3665
- if (comp.source === "npm") {
3666
- // npm components are already imported via $elements — check if registered
3667
- if (!customElements.get(comp.tagName)) {
3668
- throw new Error("not registered");
3669
- }
3670
- } else {
3671
- const root = projectState?.projectRoot;
3672
- const url = `${location.origin}/${root ? root + "/" : ""}${comp.path}`;
3673
- await defineElement(url);
3674
- }
3675
- const el = document.createElement(comp.tagName);
3676
- for (const p of comp.props || []) {
3677
- if (p.default !== undefined && p.default !== "false" && p.default !== "''") {
3678
- const val = String(p.default).replace(/^'|'$/g, "");
3679
- el.setAttribute(p.name, val);
3680
- }
3681
- }
3682
- return el;
3683
- } catch (/** @type {any} */ e) {
3684
- console.warn("Component preview failed:", comp.tagName, e);
3685
- const fallback = document.createElement("div");
3686
- fallback.style.cssText =
3687
- "padding:12px;border:1px dashed var(--border);border-radius:4px;color:var(--fg-dim)";
3688
- fallback.textContent = `<${comp.tagName}>`;
3689
- return fallback;
3690
- }
3691
- }
2821
+ // ─── Style panel ────────────────────────────────────────────────────────────
2822
+ // Extracted to panels/style-utils.js, panels/style-inputs.js, panels/style-panel.js
3692
2823
 
3693
- /**
3694
- * @param {any} rootStyle
3695
- * @param {any} tag
3696
- */
3697
- function hasTagStyle(rootStyle, tag) {
3698
- const s = rootStyle[`& ${tag}`];
3699
- return s && typeof s === "object" && Object.keys(s).length > 0;
3700
- }
3701
-
3702
- function renderSettings() {
3703
- const settingsTab = S.ui.settingsTab || "stylebook";
3704
-
3705
- // Top-level settings tabs chrome bar
3706
- const settingsChromeBarTpl = html`
3707
- <div
3708
- class="sb-chrome settings-top-chrome"
3709
- style="position:absolute;top:0;left:0;right:0;z-index:16;background:var(--bg-panel);border-bottom:1px solid var(--border)"
3710
- >
3711
- <sp-tabs
3712
- size="s"
3713
- selected=${settingsTab}
3714
- @change=${(/** @type {any} */ e) => {
3715
- updateUi("settingsTab", e.target.selected);
3716
- }}
3717
- >
3718
- <sp-tab label="Stylebook" value="stylebook"></sp-tab>
3719
- <sp-tab label="Definitions" value="definitions"></sp-tab>
3720
- <sp-tab label="Collections" value="collections"></sp-tab>
3721
- </sp-tabs>
3722
- </div>
3723
- `;
3724
-
3725
- // Non-stylebook tabs: render editor into canvasWrap with offset for chrome bar
3726
- if (settingsTab === "definitions" || settingsTab === "collections") {
3727
- /** @type {any} */ (canvasWrap).style.overflow = "hidden";
3728
-
3729
- litRender(
3730
- html`${settingsChromeBarTpl}
3731
- <div
3732
- class="settings-editor-container"
3733
- style="position:absolute;inset:40px 0 0 0;overflow:auto"
3734
- ></div>`,
3735
- /** @type {any} */ (canvasWrap),
3736
- );
3737
-
3738
- const container = /** @type {HTMLElement} */ (
3739
- canvasWrap.querySelector(".settings-editor-container")
3740
- );
3741
- if (settingsTab === "definitions") renderDefsEditor(container);
3742
- else renderCollectionsEditor(container);
3743
- return;
3744
- }
3745
-
3746
- // Stylebook tab — existing behavior
3747
- view.stylebookElToTag = new WeakMap();
3748
- const rootStyle = getEffectiveStyle(S.document.style);
3749
- const filter = (S.ui.stylebookFilter || "").toLowerCase();
3750
- const customizedOnly = S.ui.stylebookCustomizedOnly;
3751
-
3752
- const { sizeBreakpoints, baseWidth } = parseMediaEntries(getEffectiveMedia(S.document.$media));
3753
- const hasMedia = sizeBreakpoints.length > 0;
3754
-
3755
- // Chrome bar (tabs + filter) — positioned absolutely above the panzoom surface
3756
- const onTabClick = (/** @type {string} */ t) => {
3757
- updateUi("stylebookTab", t);
3758
- };
3759
-
3760
- const onFilterInput = (/** @type {any} */ e) => {
3761
- updateUi("stylebookFilter", e.target.value);
3762
- };
3763
-
3764
- const onCustomizedToggle = () => {
3765
- updateUi("stylebookCustomizedOnly", !S.ui.stylebookCustomizedOnly);
3766
- };
3767
-
3768
- const chromeBarTpl = html`
3769
- ${settingsChromeBarTpl}
3770
- <div
3771
- class="sb-chrome"
3772
- style="position:absolute;top:36px;left:0;right:0;z-index:15;background:var(--bg-panel);border-bottom:1px solid var(--border)"
3773
- >
3774
- <sp-tabs
3775
- size="s"
3776
- selected=${S.ui.stylebookTab || "elements"}
3777
- @change=${(/** @type {any} */ e) => {
3778
- onTabClick(e.target.selected);
3779
- }}
3780
- >
3781
- ${["elements", "variables"].map(
3782
- (t) => html`
3783
- <sp-tab label=${t.charAt(0).toUpperCase() + t.slice(1)} value=${t}></sp-tab>
3784
- `,
3785
- )}
3786
- </sp-tabs>
3787
- ${S.ui.stylebookTab === "elements"
3788
- ? html`
3789
- <input
3790
- class="field-input"
3791
- style="flex:1;max-width:200px;margin-left:8px"
3792
- placeholder="Filter…"
3793
- .value=${S.ui.stylebookFilter}
3794
- @input=${onFilterInput}
3795
- />
3796
- <button
3797
- class="tb-toggle${S.ui.stylebookCustomizedOnly ? " active" : ""}"
3798
- style="margin-left:4px"
3799
- @click=${onCustomizedToggle}
3800
- >
3801
- Customized
3802
- </button>
3803
- `
3804
- : nothing}
3805
- </div>
3806
- `;
3807
-
3808
- // Set up panzoom surface — same as normal canvas mode
3809
- /** @type {any} */ (canvasWrap).style.overflow = "hidden";
3810
-
3811
- // Build panel definitions
3812
- /** @type {any[]} */
3813
- const allPanelDefs = [];
3814
- if (hasMedia) {
3815
- allPanelDefs.push({
3816
- name: "base",
3817
- displayName: mediaDisplayName("--"),
3818
- width: baseWidth,
3819
- activeSet: activeBreakpointsForWidth(sizeBreakpoints, baseWidth),
3820
- });
3821
- for (const bp of sizeBreakpoints) {
3822
- allPanelDefs.push({
3823
- name: bp.name,
3824
- displayName: mediaDisplayName(bp.name),
3825
- width: bp.width,
3826
- activeSet: activeBreakpointsForWidth(sizeBreakpoints, bp.width),
3827
- });
3828
- }
3829
- }
3830
-
3831
- // Render content into panels
3832
- const renderIntoPanel = (/** @type {any} */ panel, /** @type {any} */ activeBreakpoints) => {
3833
- panel.canvas.classList.add("sb-canvas");
3834
- if (S.ui.stylebookTab === "elements") {
3835
- renderStylebookElementsIntoCanvas(
3836
- panel.canvas,
3837
- rootStyle,
3838
- filter,
3839
- customizedOnly,
3840
- activeBreakpoints,
3841
- );
3842
- for (const child of panel.canvas.querySelectorAll("*")) {
3843
- child.style.pointerEvents = "none";
3844
- }
3845
- registerStylebookPanelEvents(panel);
3846
- } else {
3847
- renderStylebookVarsIntoCanvas(panel.canvas, rootStyle);
3848
- panel.overlayClk.style.pointerEvents = "none";
3849
- }
3850
- };
3851
-
3852
- /** @type {{ tpl: any; panel: any; activeSet: any }[]} */
3853
- let panelEntries;
3854
- if (!hasMedia) {
3855
- // Single panel
3856
- const effectiveMedia = getEffectiveMedia(S.document.$media);
3857
- const hasBaseWidth = effectiveMedia && effectiveMedia["--"];
3858
- const label = hasBaseWidth ? `${mediaDisplayName("--")} (${baseWidth}px)` : null;
3859
- const entry = canvasPanelTemplate(
3860
- hasBaseWidth ? "base" : null,
3861
- label,
3862
- !hasBaseWidth,
3863
- hasBaseWidth ? baseWidth : undefined,
3864
- );
3865
- panelEntries = [{ tpl: entry.tpl, panel: entry.panel, activeSet: new Set() }];
3866
- } else {
3867
- // Multi-panel: one per breakpoint, sorted widest first
3868
- panelEntries = allPanelDefs.map((def) => {
3869
- const label = `${def.displayName} (${def.width}px)`;
3870
- const { tpl, panel } = canvasPanelTemplate(def.name, label, false, def.width);
3871
- return { tpl, panel, activeSet: def.activeSet };
3872
- });
3873
- }
3874
-
3875
- litRender(
3876
- html`
3877
- ${chromeBarTpl}
3878
- <div
3879
- class="panzoom-wrap"
3880
- style="transform-origin:0 0;padding-top:72px"
3881
- ${ref((el) => {
3882
- if (el) view.panzoomWrap = /** @type {HTMLDivElement} */ (el);
3883
- })}
3884
- >
3885
- ${panelEntries.map((e) => e.tpl)}
3886
- </div>
3887
- `,
3888
- /** @type {any} */ (canvasWrap),
3889
- );
3890
-
3891
- for (const { panel, activeSet } of panelEntries) {
3892
- canvasPanels.push(panel);
3893
- renderIntoPanel(panel, activeSet);
3894
- }
3895
- if (hasMedia) {
3896
- updateActivePanelHeaders();
3897
- }
3898
-
3899
- applyTransform();
3900
- observeCenterUntilStable();
3901
- renderZoomIndicator();
3902
- }
3903
-
3904
- /**
3905
- * @param {any} canvasEl
3906
- * @param {any} rootStyle
3907
- * @param {any} filter
3908
- * @param {any} customizedOnly
3909
- * @param {any} activeBreakpoints
3910
- */
3911
- function renderStylebookElementsIntoCanvas(
3912
- canvasEl,
3913
- rootStyle,
3914
- filter,
3915
- customizedOnly,
3916
- activeBreakpoints,
3917
- ) {
3918
- /** @type {import("lit-html").TemplateResult[]} */
3919
- const sectionTemplates = [];
3920
-
3921
- for (const section of stylebookMeta.$sections) {
3922
- // Filter elements
3923
- let entries = /** @type {any} */ (section.elements);
3924
- if (filter) {
3925
- entries = entries.filter(
3926
- (/** @type {any} */ e) =>
3927
- e.tag.includes(filter) || section.label.toLowerCase().includes(filter),
3928
- );
3929
- }
3930
- if (customizedOnly) {
3931
- entries = entries.filter((/** @type {any} */ e) => hasTagStyle(rootStyle, e.tag));
3932
- }
3933
- if (entries.length === 0) continue;
3934
-
3935
- const cardTemplates = entries.map((/** @type {any} */ entry) => {
3936
- const el = buildStylebookElement(entry, rootStyle, activeBreakpoints);
3937
- return html`
3938
- <div
3939
- class="element-card"
3940
- ${ref((card) => {
3941
- if (!card) return;
3942
- view.stylebookElToTag.set(card, entry.tag);
3943
- elToPath.set(card, ["__sb", entry.tag]);
3944
- for (const child of el.querySelectorAll("*")) {
3945
- const tag = child.tagName.toLowerCase();
3946
- if (!view.stylebookElToTag.has(child)) {
3947
- view.stylebookElToTag.set(child, tag);
3948
- elToPath.set(child, ["__sb", tag]);
3949
- }
3950
- }
3951
- })}
3952
- >
3953
- <div
3954
- class="element-card-preview"
3955
- ${ref((c) => {
3956
- if (c && !c.firstChild) c.appendChild(el);
3957
- })}
3958
- ></div>
3959
- <div class="element-card-label">&lt;${entry.tag}&gt;</div>
3960
- </div>
3961
- `;
3962
- });
3963
-
3964
- sectionTemplates.push(html`
3965
- <div class="sb-section">
3966
- <div class="sb-label">${section.label}</div>
3967
- <div class="sb-body">${cardTemplates}</div>
3968
- </div>
3969
- `);
3970
- }
3971
-
3972
- // Custom components from registry
3973
- if (componentRegistry.length > 0) {
3974
- let comps = componentRegistry;
3975
- if (filter)
3976
- comps = comps.filter((/** @type {any} */ c) => c.tagName.toLowerCase().includes(filter));
3977
- if (customizedOnly)
3978
- comps = comps.filter((/** @type {any} */ c) => hasTagStyle(rootStyle, c.tagName));
3979
- if (comps.length > 0) {
3980
- const compCards = comps.map((/** @type {any} */ comp) => {
3981
- /** @type {HTMLDivElement | null} */
3982
- let previewEl = null;
3983
- const cardTpl = html`
3984
- <div
3985
- class="element-card"
3986
- style="display:inline-flex;width:auto"
3987
- ${ref((card) => {
3988
- if (!card) return;
3989
- view.stylebookElToTag.set(card, comp.tagName);
3990
- elToPath.set(card, ["__sb", comp.tagName]);
3991
- })}
3992
- >
3993
- <div
3994
- class="element-card-preview"
3995
- ${ref((c) => {
3996
- if (c) previewEl = /** @type {HTMLDivElement} */ (c);
3997
- })}
3998
- ></div>
3999
- <div class="element-card-label">&lt;${comp.tagName}&gt;</div>
4000
- </div>
4001
- `;
4002
- // Fill preview asynchronously with live rendered component
4003
- renderComponentPreview(comp).then((el) => {
4004
- if (previewEl) previewEl.appendChild(el);
4005
- });
4006
- return cardTpl;
4007
- });
4008
-
4009
- sectionTemplates.push(html`
4010
- <div class="sb-section">
4011
- <div class="sb-label">Components</div>
4012
- <div class="sb-body">${compCards}</div>
4013
- </div>
4014
- `);
4015
- }
4016
- }
4017
-
4018
- if (sectionTemplates.length === 0) {
4019
- litRender(
4020
- html`
4021
- <div style="padding:48px;text-align:center;color:var(--fg-dim);font-size:13px">
4022
- ${customizedOnly ? "No customized elements" : "No matching elements"}
4023
- </div>
4024
- `,
4025
- canvasEl,
4026
- );
4027
- } else {
4028
- litRender(html`${sectionTemplates}`, canvasEl);
4029
- }
4030
- }
4031
-
4032
- /**
4033
- * Render variables into the canvas (card-based layout matching Elements tab)
4034
- *
4035
- * @param {any} canvasEl
4036
- * @param {any} rootStyle
4037
- */
4038
- function renderStylebookVarsIntoCanvas(canvasEl, rootStyle) {
4039
- const varCats = stylebookMeta.$variables;
4040
-
4041
- /** @type {Record<string, any>} */
4042
- const groups = {};
4043
- for (const key of Object.keys(varCats)) groups[key] = [];
4044
- for (const [k, v] of Object.entries(rootStyle)) {
4045
- if (!k.startsWith("--")) continue;
4046
- if (typeof v !== "string" && typeof v !== "number") continue;
4047
- if (k.startsWith("--color")) groups.color.push([k, v]);
4048
- else if (k.startsWith("--font")) groups.font.push([k, v]);
4049
- else if (k.startsWith("--size") || k.startsWith("--spacing") || k.startsWith("--radius"))
4050
- groups.size.push([k, v]);
4051
- else groups.other.push([k, v]);
4052
- }
4053
-
4054
- /** @type {Map<string, HTMLElement | null>} */
4055
- const bodyRefs = new Map();
4056
-
4057
- const sectionTemplates = Object.entries(varCats).map(([catKey, catMeta]) => {
4058
- const vars = groups[catKey];
4059
-
4060
- const onAdd = () => {
4061
- const bodyEl = bodyRefs.get(catKey);
4062
- if (!bodyEl) return;
4063
- const addBtn = bodyEl.querySelector(".sb-var-add-btn");
4064
- const row = renderVarRow(catKey, /** @type {any} */ (catMeta), null, "", true);
4065
- bodyEl.insertBefore(row, addBtn);
4066
- if (addBtn) /** @type {any} */ (addBtn).style.display = "none";
4067
- const nameField = /** @type {any} */ (row.querySelector("sp-textfield"));
4068
- if (nameField) requestAnimationFrame(() => nameField.focus());
4069
- };
4070
-
4071
- return html`
4072
- <div class="sb-section">
4073
- <div class="sb-label">${/** @type {any} */ (catMeta).label}</div>
4074
- <div
4075
- class="sb-body"
4076
- ${ref((el) => {
4077
- if (el) bodyRefs.set(catKey, /** @type {HTMLElement} */ (el));
4078
- })}
4079
- >
4080
- ${vars.length > 0
4081
- ? vars.map((/** @type {[string, any]} */ [varName, varVal]) =>
4082
- renderVarRow(catKey, /** @type {any} */ (catMeta), varName, String(varVal), false),
4083
- )
4084
- : html`<div class="sb-var-empty">
4085
- No ${/** @type {any} */ (catMeta).label.toLowerCase()} variables yet.
4086
- </div>`}
4087
- <button class="sb-var-add-btn" @click=${onAdd}>
4088
- <span class="sb-var-add-icon">+</span> Add ${/** @type {any} */ (catMeta).label}
4089
- </button>
4090
- </div>
4091
- </div>
4092
- `;
4093
- });
4094
-
4095
- litRender(html`${sectionTemplates}`, canvasEl);
4096
- }
4097
-
4098
- /**
4099
- * Render a single variable row — used for both existing and add-new.
4100
- *
4101
- * @param {string} catKey - "color"|"font"|"size"|"other"
4102
- * @param {any} catMeta - { label, prefix, placeholder }
4103
- * @param {string | null} varName - Existing var name, or null for add-new
4104
- * @param {string} varVal - Current value, or "" for add-new
4105
- * @param {boolean} isNew - True if this is an add-new row
4106
- */
4107
- function renderVarRow(catKey, catMeta, varName, varVal, isNew) {
4108
- const row = document.createElement("div");
4109
- row.className = isNew ? "sb-var-row is-new" : "sb-var-row";
4110
-
4111
- /** @type {any} */
4112
- let colorPicker = null;
4113
- /** @type {any} */
4114
- let nameField = null;
4115
- /** @type {any} */
4116
- let getValueFn;
4117
- /** @type {any} */
4118
- let hexField = null;
4119
-
4120
- // ─── Color swatch setup ───
4121
- const swatchTpl =
4122
- catKey === "color"
4123
- ? html`
4124
- <div
4125
- class="sb-var-swatch"
4126
- style=${styleMap({ backgroundColor: varVal || "var(--accent)" })}
4127
- >
4128
- <input
4129
- type="color"
4130
- .value=${varVal && varVal.startsWith("#") ? varVal : "#007acc"}
4131
- ${ref((el) => {
4132
- if (el) colorPicker = el;
4133
- })}
4134
- @input=${() => {
4135
- if (!colorPicker || !hexField) return;
4136
- hexField.value = colorPicker.value;
4137
- const swatch = /** @type {any} */ (row.querySelector(".sb-var-swatch"));
4138
- if (swatch) swatch.style.backgroundColor = colorPicker.value;
4139
- if (!isNew && varName) update(updateStyle(S, [], varName, colorPicker.value));
4140
- }}
4141
- />
4142
- </div>
4143
- `
4144
- : nothing;
4145
-
4146
- // ─── Name column (add-new only) ───
4147
- const namePlaceholder =
4148
- catKey === "color"
4149
- ? "Primary Blue"
4150
- : catKey === "font"
4151
- ? "Body Serif"
4152
- : catKey === "size"
4153
- ? "Spacing Large"
4154
- : "Border Radius";
4155
-
4156
- const nameColTpl = isNew
4157
- ? html`
4158
- <div class="sb-var-col-name">
4159
- <div class="sb-var-col-label">Name</div>
4160
- <sp-textfield
4161
- size="s"
4162
- placeholder=${namePlaceholder}
4163
- style="pointer-events:auto"
4164
- ${ref((el) => {
4165
- if (el) nameField = el;
4166
- })}
4167
- ></sp-textfield>
4168
- </div>
4169
- `
4170
- : nothing;
4171
-
4172
- // ─── Value column ───
4173
- /** @type {any} */
4174
- let valueContent;
4175
-
4176
- if (catKey === "color") {
4177
- /** @type {any} */
4178
- let debounce;
4179
- valueContent = html`
4180
- <sp-textfield
4181
- size="s"
4182
- .value=${varVal || "#007acc"}
4183
- placeholder="#007acc"
4184
- style="pointer-events:auto"
4185
- ${ref((el) => {
4186
- if (el) hexField = el;
4187
- })}
4188
- @input=${() => {
4189
- clearTimeout(debounce);
4190
- debounce = setTimeout(() => {
4191
- if (!hexField) return;
4192
- const v = hexField.value;
4193
- try {
4194
- if (colorPicker) colorPicker.value = v.startsWith("#") ? v : colorPicker.value;
4195
- } catch {}
4196
- const swatch = /** @type {any} */ (row.querySelector(".sb-var-swatch"));
4197
- if (swatch) swatch.style.backgroundColor = v;
4198
- if (!isNew && varName) update(updateStyle(S, [], varName, v));
4199
- }, 400);
4200
- }}
4201
- ></sp-textfield>
4202
- `;
4203
- getValueFn = () => hexField?.value?.trim() || "";
4204
- } else if (catKey === "size") {
4205
- const ui = createUnitInput(varVal || "16px", {
4206
- onChange: (/** @type {any} */ newVal) => {
4207
- const bar = /** @type {any} */ (row.querySelector(".sb-var-size-bar"));
4208
- if (bar) bar.style.width = newVal;
4209
- if (!isNew && varName) update(updateStyle(S, [], varName, newVal));
4210
- },
4211
- });
4212
- if (isNew) ui.textfield.value = "";
4213
- valueContent = html`<div
4214
- ${ref((el) => {
4215
- if (el && !el.firstChild) el.appendChild(ui.wrap);
4216
- })}
4217
- ></div>`;
4218
- getValueFn = () => ui.getValue();
4219
- } else {
4220
- /** @type {any} */
4221
- let textFieldEl = null;
4222
- /** @type {any} */
4223
- let debounce;
4224
- valueContent = html`
4225
- <sp-textfield
4226
- size="s"
4227
- .value=${varVal}
4228
- placeholder=${catMeta.placeholder}
4229
- style="pointer-events:auto"
4230
- ${ref((el) => {
4231
- if (el) textFieldEl = el;
4232
- })}
4233
- @input=${() => {
4234
- if (!textFieldEl || isNew || !varName) return;
4235
- clearTimeout(debounce);
4236
- debounce = setTimeout(() => {
4237
- const v = textFieldEl.value;
4238
- const fontPrev = /** @type {any} */ (row.querySelector(".sb-var-font-preview"));
4239
- if (fontPrev) fontPrev.style.fontFamily = v;
4240
- update(updateStyle(S, [], varName, v));
4241
- }, 400);
4242
- }}
4243
- ></sp-textfield>
4244
- `;
4245
- getValueFn = () => textFieldEl?.value?.trim() || "";
4246
- }
4247
-
4248
- const valColTpl = html`
4249
- <div class="sb-var-col-value">
4250
- ${isNew ? html`<div class="sb-var-col-label">Value</div>` : nothing} ${valueContent}
4251
- </div>
4252
- `;
4253
-
4254
- // ─── Action buttons (add-new only) ───
4255
- const actionsTpl = isNew
4256
- ? html`
4257
- <div class="sb-var-add-actions">
4258
- <sp-action-button
4259
- size="s"
4260
- style="pointer-events:auto"
4261
- @click=${() => {
4262
- const name = (nameField?.value || "").trim();
4263
- const val = getValueFn();
4264
- const generatedVar = friendlyNameToVar(name, catMeta.prefix);
4265
- if (!generatedVar || !val) return;
4266
- update(updateStyle(S, [], generatedVar, val));
4267
- }}
4268
- >Add</sp-action-button
4269
- >
4270
- <sp-action-button
4271
- size="s"
4272
- quiet
4273
- style="pointer-events:auto"
4274
- @click=${() => {
4275
- const body = row.parentElement;
4276
- row.remove();
4277
- const addBtn = /** @type {any} */ (body?.querySelector(".sb-var-add-btn"));
4278
- if (addBtn) addBtn.style.display = "";
4279
- }}
4280
- >
4281
- <sp-icon-close slot="icon"></sp-icon-close>
4282
- </sp-action-button>
4283
- </div>
4284
- `
4285
- : nothing;
4286
-
4287
- // ─── Header ───
4288
- const headerTpl =
4289
- !isNew && varName
4290
- ? html`
4291
- <div class="sb-var-row-header">
4292
- <span class="sb-var-row-title">${varDisplayName(varName, catMeta.prefix)}</span>
4293
- <span class="sb-var-row-ref">${varName}</span>
4294
- <sp-action-button
4295
- size="s"
4296
- quiet
4297
- class="sb-var-del"
4298
- style="pointer-events:auto"
4299
- @click=${() => update(updateStyle(S, [], varName, undefined))}
4300
- >
4301
- <sp-icon-delete slot="icon"></sp-icon-delete>
4302
- </sp-action-button>
4303
- </div>
4304
- `
4305
- : nothing;
4306
-
4307
- // ─── Live preview of generated var name (add-new) ───
4308
- const addPreviewTpl = isNew
4309
- ? html`
4310
- <div
4311
- class="sb-var-add-preview"
4312
- ${ref((el) => {
4313
- if (!el || !nameField) return;
4314
- nameField.addEventListener("input", () => {
4315
- el.textContent = friendlyNameToVar(nameField.value || "", catMeta.prefix);
4316
- });
4317
- })}
4318
- ></div>
4319
- `
4320
- : nothing;
4321
-
4322
- // ─── Type-specific preview ───
4323
- const typePrevTpl =
4324
- catKey === "font" && varVal
4325
- ? html`
4326
- <div class="sb-var-preview">
4327
- <div class="sb-var-font-preview" style=${styleMap({ fontFamily: varVal })}>
4328
- The quick brown fox jumps over the lazy dog
4329
- </div>
4330
- </div>
4331
- `
4332
- : catKey === "size" && varVal
4333
- ? html`
4334
- <div class="sb-var-preview">
4335
- <div class="sb-var-size-track">
4336
- <div class="sb-var-size-bar" style=${styleMap({ width: varVal })}></div>
4337
- </div>
4338
- </div>
4339
- `
4340
- : nothing;
4341
-
4342
- litRender(
4343
- html`
4344
- ${headerTpl}
4345
- <div class="sb-var-input-row">${swatchTpl} ${nameColTpl} ${valColTpl} ${actionsTpl}</div>
4346
- ${addPreviewTpl} ${typePrevTpl}
4347
- `,
4348
- row,
4349
- );
4350
-
4351
- return row;
4352
- }
4353
-
4354
- // varDisplayName, friendlyNameToVar — imported from studio-utils.js
4355
-
4356
- /**
4357
- * Convert a human-friendly name like "Tablet" to a $media key "--tablet"
4358
- *
4359
- * @param {any} name
4360
- */
4361
- function friendlyNameToMedia(name) {
4362
- return friendlyNameToVar(name, "--");
4363
- }
4364
-
4365
- /**
4366
- * Creates a combined textfield + quiet sp-picker for CSS values with units. Returns { wrap,
4367
- * textfield, picker, getValue, setValue }. The picker hides automatically when the textfield value
4368
- * is non-numeric (e.g. "auto", "inherit").
4369
- *
4370
- * @param {any} initialValue
4371
- * @param {any} [options]
4372
- */
4373
- function createUnitInput(initialValue, { onChange, size = "s" } = {}) {
4374
- const match = String(initialValue).match(
4375
- /^(-?[\d.]+)\s*(px|em|rem|vw|vh|%|ch|ex|vmin|vmax|pt|cm|mm|in)?$/,
4376
- );
4377
- let numVal = match ? match[1] : initialValue;
4378
- let unitVal = match ? match[2] || "px" : "";
4379
- const isNumeric = !!match;
4380
-
4381
- const wrap = document.createElement("div");
4382
- wrap.className = "sb-unit-input";
4383
- wrap.style.pointerEvents = "auto";
4384
-
4385
- /** @type {any} */
4386
- let textfield = null;
4387
- /** @type {any} */
4388
- let picker = null;
4389
-
4390
- const units = [
4391
- { value: "px", label: "px" },
4392
- { value: "rem", label: "rem" },
4393
- { value: "em", label: "em" },
4394
- { value: "%", label: "%" },
4395
- { value: "vw", label: "vw" },
4396
- { value: "vh", label: "vh" },
4397
- { value: "ch", label: "ch" },
4398
- { value: "pt", label: "pt" },
4399
- { divider: true },
4400
- { value: "auto", label: "auto" },
4401
- { value: "fit-content", label: "fit-content" },
4402
- ];
4403
-
4404
- /** @type {any} */
4405
- let debounce;
4406
-
4407
- function getValue() {
4408
- const num = textfield?.value;
4409
- const unit = picker?.value;
4410
- if (unit === "auto" || unit === "fit-content") return unit;
4411
- return num ? `${num}${unit}` : "";
4412
- }
4413
-
4414
- litRender(
4415
- html`
4416
- <sp-textfield
4417
- .value=${numVal}
4418
- size=${size}
4419
- ${ref((el) => {
4420
- if (el) textfield = el;
4421
- })}
4422
- @input=${() => {
4423
- clearTimeout(debounce);
4424
- const raw = textfield?.value?.trim();
4425
- const looksNumeric = /^-?[\d.]+$/.test(raw || "");
4426
- if (picker) picker.style.display = looksNumeric ? "" : "none";
4427
- debounce = setTimeout(() => {
4428
- if (onChange) onChange(looksNumeric ? `${raw}${picker?.value}` : raw);
4429
- }, 400);
4430
- }}
4431
- ></sp-textfield>
4432
- <sp-picker
4433
- quiet
4434
- size=${size}
4435
- style=${styleMap({ display: isNumeric ? "" : "none" })}
4436
- ${ref((el) => {
4437
- if (el) {
4438
- picker = el;
4439
- requestAnimationFrame(() => {
4440
- /** @type {any} */ (el).value = unitVal || "px";
4441
- });
4442
- }
4443
- })}
4444
- @change=${() => {
4445
- const unit = picker?.value;
4446
- if (unit === "auto" || unit === "fit-content") {
4447
- if (textfield) textfield.value = unit;
4448
- if (picker) picker.style.display = "none";
4449
- if (onChange) onChange(unit);
4450
- } else {
4451
- unitVal = unit;
4452
- if (onChange) onChange(getValue());
4453
- }
4454
- }}
4455
- >
4456
- ${units.map((u) =>
4457
- u.divider
4458
- ? html`<sp-menu-divider></sp-menu-divider>`
4459
- : html`<sp-menu-item value=${u.value}>${u.label}</sp-menu-item>`,
4460
- )}
4461
- </sp-picker>
4462
- `,
4463
- wrap,
4464
- );
4465
-
4466
- return { wrap, textfield, picker, getValue };
4467
- }
4468
-
4469
- /**
4470
- * Click handler for stylebook canvas — selects elements via the elToPath/view.stylebookElToTag
4471
- * mapping
4472
- *
4473
- * @param {any} panel
4474
- */
4475
- function registerStylebookPanelEvents(panel) {
4476
- const { canvas, overlayClk } = panel;
4477
-
4478
- overlayClk.addEventListener("click", (/** @type {any} */ e) => {
4479
- // Temporarily enable pointer events to hit-test
4480
- const els = canvas.querySelectorAll("*");
4481
- for (const el of els) el.style.pointerEvents = "auto";
4482
- overlayClk.style.display = "none";
4483
- const elements = document.elementsFromPoint(e.clientX, e.clientY);
4484
- overlayClk.style.display = "";
4485
- for (const el of els) el.style.pointerEvents = "none";
4486
-
4487
- // Find the closest element with a stylebook tag mapping (walk up parents for nested elements)
4488
- for (const el of elements) {
4489
- if (!canvas.contains(el) || el === canvas) continue;
4490
- let cur = /** @type {any} */ (el);
4491
- while (cur && cur !== canvas) {
4492
- const tag = view.stylebookElToTag.get(cur);
4493
- if (tag) {
4494
- const newMedia = panel.mediaName === "base" ? null : (panel.mediaName ?? null);
4495
- selectStylebookTag(tag, newMedia);
4496
- updateActivePanelHeaders();
4497
- return;
4498
- }
4499
- cur = cur.parentElement;
4500
- }
4501
- }
4502
- // Clicked empty area — deselect
4503
- updateSession({ ui: { stylebookSelection: null, activeSelector: null } });
4504
- renderStylebookOverlays();
4505
- });
4506
-
4507
- overlayClk.addEventListener("mousemove", (/** @type {any} */ e) => {
4508
- const els = canvas.querySelectorAll("*");
4509
- for (const el of els) el.style.pointerEvents = "auto";
4510
- overlayClk.style.display = "none";
4511
- const elements = document.elementsFromPoint(e.clientX, e.clientY);
4512
- overlayClk.style.display = "";
4513
- for (const el of els) el.style.pointerEvents = "none";
4514
-
4515
- let hoverTag = null;
4516
- for (const el of elements) {
4517
- if (!canvas.contains(el) || el === canvas) continue;
4518
- let cur = /** @type {any} */ (el);
4519
- while (cur && cur !== canvas) {
4520
- const tag = view.stylebookElToTag.get(cur);
4521
- if (tag) {
4522
- hoverTag = tag;
4523
- break;
4524
- }
4525
- cur = cur.parentElement;
4526
- }
4527
- if (hoverTag) break;
4528
- }
4529
-
4530
- if (hoverTag !== panel._lastHoverTag) {
4531
- panel._lastHoverTag = hoverTag;
4532
- renderStylebookOverlays();
4533
- }
4534
- });
4535
- }
4536
-
4537
- /** Draw selection + hover overlays for stylebook elements */
4538
- function renderStylebookOverlays() {
4539
- if (canvasPanels.length === 0) return;
4540
-
4541
- const selectedTag = S.ui.stylebookSelection;
4542
-
4543
- for (const panel of canvasPanels) {
4544
- const hoverTag = panel._lastHoverTag;
4545
- /**
4546
- * @type {{
4547
- * cls: string;
4548
- * top: string;
4549
- * left: string;
4550
- * width: string;
4551
- * height: string;
4552
- * label?: string;
4553
- * }[]}
4554
- */
4555
- const boxes = [];
4556
-
4557
- if (hoverTag && hoverTag !== selectedTag) {
4558
- const el = findStylebookEl(panel.canvas, hoverTag);
4559
- if (el) boxes.push({ ...overlayBoxDescriptor(el, "hover", panel), label: undefined });
4560
- }
4561
-
4562
- if (selectedTag) {
4563
- const el = findStylebookEl(panel.canvas, selectedTag);
4564
- if (el)
4565
- boxes.push({ ...overlayBoxDescriptor(el, "selection", panel), label: `<${selectedTag}>` });
4566
- }
4567
-
4568
- litRender(
4569
- html`
4570
- ${panel.dropLine}
4571
- ${boxes.map(
4572
- (b) => html`
4573
- <div
4574
- class=${b.cls}
4575
- style="top:${b.top};left:${b.left};width:${b.width};height:${b.height}"
4576
- >
4577
- ${b.label ? html`<div class="overlay-label">${b.label}</div>` : nothing}
4578
- </div>
4579
- `,
4580
- )}
4581
- `,
4582
- panel.overlay,
4583
- );
4584
- }
4585
- }
4586
-
4587
- /** Find a stylebook element by tag in the canvas */
4588
- function findStylebookEl(/** @type {any} */ canvasEl, /** @type {any} */ tag) {
4589
- for (const child of canvasEl.querySelectorAll("*")) {
4590
- if (view.stylebookElToTag.get(child) === tag) return child;
4591
- }
4592
- return null;
4593
- }
4594
-
4595
- // ─── Right panel: Inspector ───────────────────────────────────────────────────
4596
-
4597
- function renderRightPanel() {
4598
- rightPanelMod.render();
4599
- }
4600
-
4601
- // ─── Inspector ────────────────────────────────────────────────────────────────
4602
-
4603
- /** Frontmatter-only panel shown in content mode when no element is selected */
4604
- function renderFrontmatterOnlyPanel() {
4605
- const fm = S.content?.frontmatter || {};
4606
- const col = findCollectionSchema(S.documentPath, projectState?.projectConfig);
4607
- const schemaProps = col?.schema?.properties;
4608
- const requiredFields = new Set(col?.schema?.required || []);
4609
-
4610
- /** @type {{ field: string; entry: any; value: any }[]} */
4611
- const fields = [];
4612
- if (schemaProps) {
4613
- for (const [field, fieldSchema] of Object.entries(
4614
- /** @type {Record<string, any>} */ (schemaProps),
4615
- )) {
4616
- fields.push({ field, entry: fieldSchema, value: fm[field] });
4617
- }
4618
- for (const [field, value] of Object.entries(fm)) {
4619
- if (!schemaProps[field]) {
4620
- fields.push({
4621
- field,
4622
- entry: { type: typeof value === "boolean" ? "boolean" : "string" },
4623
- value,
4624
- });
4625
- }
4626
- }
4627
- } else {
4628
- for (const [field, value] of Object.entries(fm)) {
4629
- fields.push({
4630
- field,
4631
- entry: { type: typeof value === "boolean" ? "boolean" : "string" },
4632
- value,
4633
- });
4634
- }
4635
- }
4636
-
4637
- if (fields.length === 0 && !schemaProps) {
4638
- return html`<div class="empty-state">No frontmatter. Select an element to inspect.</div>`;
4639
- }
4640
-
4641
- return html`
4642
- <div class="style-sidebar">
4643
- <sp-accordion allow-multiple size="s">
4644
- <sp-accordion-item label=${col ? `Frontmatter (${col.name})` : "Frontmatter"} open>
4645
- <div class="style-section-body">
4646
- ${fields.map((f) => renderFmFieldRow(f.field, f.entry, f.value, requiredFields))}
4647
- </div>
4648
- </sp-accordion-item>
4649
- </sp-accordion>
4650
- </div>
4651
- `;
4652
- }
4653
-
4654
- /** Render a single frontmatter field row (shared between both panels) */
4655
- function renderFmFieldRow(
4656
- /** @type {string} */ field,
4657
- /** @type {any} */ entry,
4658
- /** @type {any} */ value,
4659
- /** @type {Set<string>} */ requiredFields,
4660
- ) {
4661
- const isRequired = requiredFields.has(field);
4662
- const label = field.replace(/([A-Z])/g, " $1").replace(/^./, (s) => s.toUpperCase());
4663
- const displayLabel = label + (isRequired ? " *" : "");
4664
- const hasVal = value !== undefined && value !== "" && value !== false;
4665
- const onClear = () => update(updateFrontmatter(S, field, undefined));
4666
-
4667
- // Boolean → checkbox
4668
- if (entry.type === "boolean") {
4669
- return renderFieldRow({
4670
- prop: field,
4671
- label: displayLabel,
4672
- hasValue: hasVal,
4673
- onClear,
4674
- widget: html`
4675
- <sp-checkbox
4676
- size="s"
4677
- .checked=${live(!!value)}
4678
- @change=${(/** @type {any} */ e) =>
4679
- update(updateFrontmatter(S, field, e.target.checked || undefined))}
4680
- ></sp-checkbox>
4681
- `,
4682
- });
4683
- }
4684
-
4685
- // Array of strings → comma-separated text
4686
- if (entry.type === "array") {
4687
- const display = Array.isArray(value) ? value.join(", ") : value || "";
4688
- return renderFieldRow({
4689
- prop: field,
4690
- label: displayLabel,
4691
- hasValue: hasVal,
4692
- onClear,
4693
- widget: html`
4694
- <sp-textfield
4695
- size="s"
4696
- placeholder="comma, separated"
4697
- .value=${live(display)}
4698
- @input=${debouncedStyleCommit(`fm:${field}`, 400, (/** @type {any} */ e) => {
4699
- const arr = e.target.value
4700
- ? e.target.value
4701
- .split(",")
4702
- .map((/** @type {string} */ s) => s.trim())
4703
- .filter(Boolean)
4704
- : undefined;
4705
- update(updateFrontmatter(S, field, arr));
4706
- })}
4707
- ></sp-textfield>
4708
- `,
4709
- });
4710
- }
4711
-
4712
- // Enum → select
4713
- if (Array.isArray(entry.enum)) {
4714
- return renderFieldRow({
4715
- prop: field,
4716
- label: displayLabel,
4717
- hasValue: hasVal,
4718
- onClear,
4719
- widget: html`
4720
- <sp-picker
4721
- size="s"
4722
- .value=${live(value || "")}
4723
- @change=${(/** @type {any} */ e) =>
4724
- update(updateFrontmatter(S, field, e.target.value || undefined))}
4725
- >
4726
- ${entry.enum.map(
4727
- (/** @type {string} */ opt) => html`<sp-menu-item value=${opt}>${opt}</sp-menu-item>`,
4728
- )}
4729
- </sp-picker>
4730
- `,
4731
- });
4732
- }
4733
-
4734
- // Number
4735
- if (entry.type === "number") {
4736
- return renderFieldRow({
4737
- prop: field,
4738
- label: displayLabel,
4739
- hasValue: hasVal,
4740
- onClear,
4741
- widget: html`
4742
- <sp-number-field
4743
- size="s"
4744
- hide-stepper
4745
- .value=${live(value !== undefined ? Number(value) : undefined)}
4746
- @change=${debouncedStyleCommit(`fm:${field}`, 400, (/** @type {any} */ e) => {
4747
- const v = e.target.value;
4748
- update(updateFrontmatter(S, field, isNaN(v) ? undefined : Number(v)));
4749
- })}
4750
- ></sp-number-field>
4751
- `,
4752
- });
4753
- }
4754
-
4755
- // Default: text (handles string, date, etc.)
4756
- return renderFieldRow({
4757
- prop: field,
4758
- label: displayLabel,
4759
- hasValue: hasVal,
4760
- onClear,
4761
- widget: html`
4762
- <sp-textfield
4763
- size="s"
4764
- placeholder=${entry.format === "date" ? "YYYY-MM-DD" : ""}
4765
- .value=${live(value || "")}
4766
- @input=${debouncedStyleCommit(`fm:${field}`, 400, (/** @type {any} */ e) => {
4767
- update(updateFrontmatter(S, field, e.target.value || undefined));
4768
- })}
4769
- ></sp-textfield>
4770
- `,
4771
- });
4772
- }
4773
-
4774
- /** Properties panel — lit-html template with accordion sections */
4775
- function propertiesSidebarTemplate() {
4776
- // In content mode with no selection, still show frontmatter fields
4777
- if (!S.selection) {
4778
- if (S.mode === "content") {
4779
- return renderFrontmatterOnlyPanel();
4780
- }
4781
- return html`<div class="empty-state">Select an element to inspect</div>`;
4782
- }
4783
- const node = getNodeAtPath(S.document, S.selection);
4784
- if (!node) return html`<div class="empty-state">Node not found</div>`;
4785
-
4786
- const path = S.selection;
4787
- const isMapNode = node.$prototype === "Array";
4788
- const isMapParent =
4789
- node.children && typeof node.children === "object" && node.children.$prototype === "Array";
4790
- const isSwitchNode = !!node.$switch;
4791
- const isCustomInstance = (node.tagName || "").includes("-");
4792
- const isRoot = path.length === 0;
4793
- const tagName = node.tagName || "div";
4794
- const attrs = node.attributes || {};
4795
-
4796
- const mapSignals = isInsideMapTemplate(path)
4797
- ? [
4798
- { value: "$map/item", label: "$map/item" },
4799
- { value: "$map/index", label: "$map/index" },
4800
- ]
4801
- : null;
4802
-
4803
- // Helper: render an attribute row using the style-row pattern
4804
- function renderAttrRow(
4805
- /** @type {any} */ attr,
4806
- /** @type {any} */ entry,
4807
- /** @type {any} */ value,
4808
- ) {
4809
- const type = inferInputType(entry);
4810
- const hasVal = value !== undefined && value !== "";
4811
-
4812
- // Boolean attributes render as checkboxes
4813
- if (entry.type === "boolean") {
4814
- return renderFieldRow({
4815
- prop: attr,
4816
- label: attrLabel(entry, attr),
4817
- hasValue: hasVal,
4818
- onClear: () => update(updateAttribute(S, path, attr, undefined)),
4819
- widget: html`
4820
- <sp-checkbox
4821
- size="s"
4822
- .checked=${live(!!value)}
4823
- @change=${(/** @type {any} */ e) =>
4824
- update(updateAttribute(S, path, attr, e.target.checked || undefined))}
4825
- >
4826
- </sp-checkbox>
4827
- `,
4828
- });
4829
- }
4830
-
4831
- return renderFieldRow({
4832
- prop: attr,
4833
- label: attrLabel(entry, attr),
4834
- hasValue: hasVal,
4835
- onClear: () => update(updateAttribute(S, path, attr, undefined)),
4836
- widget: widgetForType(type, entry, attr, value || "", (/** @type {any} */ v) =>
4837
- update(updateAttribute(S, path, attr, v || undefined)),
4838
- ),
4839
- });
4840
- }
4841
-
4842
- // ── Collect applicable attributes from html-meta ──
4843
- const applicableAttrs = /** @type {Record<string, any>} */ ({});
4844
- for (const [attr, entry] of /** @type {[string, any][]} */ (Object.entries(htmlMeta.$defs))) {
4845
- if (!entry.$elements || entry.$elements.includes(tagName)) {
4846
- applicableAttrs[attr] = entry;
4847
- }
4848
- }
4849
-
4850
- // Partition into sections
4851
- const attrSections = /** @type {Record<string, any[]>} */ ({});
4852
- for (const sec of htmlMeta.$sections) attrSections[sec.key] = [];
4853
- for (const [attr, entry] of Object.entries(applicableAttrs)) {
4854
- const secKey = entry.$section;
4855
- if (attrSections[secKey]) attrSections[secKey].push({ name: attr, entry });
4856
- }
4857
- for (const sec of htmlMeta.$sections) {
4858
- attrSections[sec.key].sort(
4859
- (/** @type {any} */ a, /** @type {any} */ b) => a.entry.$order - b.entry.$order,
4860
- );
4861
- }
4862
-
4863
- // Collect "custom" attributes (not in html-meta and not CEM-defined props)
4864
- const knownAttrNames = new Set(Object.keys(applicableAttrs));
4865
- // For npm web components, CEM-defined props are rendered in "Component Props"
4866
- if (isCustomInstance) {
4867
- const comp = componentRegistry.find((c) => c.tagName === node.tagName);
4868
- if (comp) for (const p of comp.props) knownAttrNames.add(p.name);
4869
- }
4870
- const customAttrs = Object.entries(attrs).filter(([k]) => !knownAttrNames.has(k));
4871
-
4872
- // Auto-open sections that have set values
4873
- const autoOpen = new Set();
4874
- for (const [attr] of Object.entries(attrs)) {
4875
- const entry = applicableAttrs[attr];
4876
- if (entry) autoOpen.add(entry.$section);
4877
- }
4878
- // Also auto-open if there are custom attrs
4879
- if (customAttrs.length > 0) autoOpen.add("__custom");
4880
-
4881
- function isSectionOpen(/** @type {any} */ key) {
4882
- if (S.ui.inspectorSections[key] !== undefined) return S.ui.inspectorSections[key];
4883
- return autoOpen.has(key);
4884
- }
4885
-
4886
- function toggleSection(/** @type {any} */ key) {
4887
- const current = isSectionOpen(key);
4888
- updateUi("inspectorSections", { ...S.ui.inspectorSections, [key]: !current });
4889
- }
4890
-
4891
- // ── Build section templates ─────────────────────────────────────────
4892
-
4893
- // "Element" section — tagName, textContent, hidden
4894
- const elemT = html`
4895
- <sp-accordion-item
4896
- label="Element"
4897
- ?open=${isSectionOpen("__element") !== false}
4898
- @sp-accordion-item-toggle=${() => toggleSection("__element")}
4899
- >
4900
- <div class="style-section-body">
4901
- <div class="style-row" data-prop="tagName">
4902
- <div class="style-row-label">
4903
- <sp-field-label size="s">Tag</sp-field-label>
4904
- </div>
4905
- <sp-textfield
4906
- size="s"
4907
- .value=${live(tagName)}
4908
- autocomplete="off"
4909
- list="tag-names"
4910
- @input=${debouncedStyleCommit("prop:tagName", 400, (/** @type {any} */ e) => {
4911
- update(updateProperty(S, path, "tagName", e.target.value || undefined));
4912
- })}
4913
- ></sp-textfield>
4914
- </div>
4915
- <div class="style-row" data-prop="$id">
4916
- <div class="style-row-label">
4917
- ${node.$id
4918
- ? html`<span
4919
- class="set-dot"
4920
- title="Clear $id"
4921
- @click=${(/** @type {any} */ e) => {
4922
- e.stopPropagation();
4923
- update(updateProperty(S, path, "$id", undefined));
4924
- }}
4925
- ></span>`
4926
- : nothing}
4927
- <sp-field-label size="s">ID</sp-field-label>
4928
- </div>
4929
- <sp-textfield
4930
- size="s"
4931
- .value=${live(node.$id || "")}
4932
- @input=${debouncedStyleCommit("prop:$id", 400, (/** @type {any} */ e) => {
4933
- update(updateProperty(S, path, "$id", e.target.value || undefined));
4934
- })}
4935
- ></sp-textfield>
4936
- </div>
4937
- <div class="style-row" data-prop="className">
4938
- <div class="style-row-label">
4939
- ${node.className
4940
- ? html`<span
4941
- class="set-dot"
4942
- title="Clear class"
4943
- @click=${(/** @type {any} */ e) => {
4944
- e.stopPropagation();
4945
- update(updateProperty(S, path, "className", undefined));
4946
- }}
4947
- ></span>`
4948
- : nothing}
4949
- <sp-field-label size="s">Class</sp-field-label>
4950
- </div>
4951
- <sp-textfield
4952
- size="s"
4953
- .value=${live(node.className || "")}
4954
- @input=${debouncedStyleCommit("prop:className", 400, (/** @type {any} */ e) => {
4955
- update(updateProperty(S, path, "className", e.target.value || undefined));
4956
- })}
4957
- ></sp-textfield>
4958
- </div>
4959
- ${!Array.isArray(node.children) || node.children.length === 0
4960
- ? html`
4961
- <div class="style-row" data-prop="textContent">
4962
- <div class="style-row-label">
4963
- ${node.textContent !== undefined
4964
- ? html`<span
4965
- class="set-dot"
4966
- title="Clear text"
4967
- @click=${(/** @type {any} */ e) => {
4968
- e.stopPropagation();
4969
- update(updateProperty(S, path, "textContent", undefined));
4970
- }}
4971
- ></span>`
4972
- : nothing}
4973
- <sp-field-label size="s">Text Content</sp-field-label>
4974
- </div>
4975
- <sp-textfield
4976
- size="s"
4977
- multiline
4978
- .value=${live(
4979
- typeof node.textContent === "string"
4980
- ? node.textContent
4981
- : (node.textContent ?? ""),
4982
- )}
4983
- @input=${debouncedStyleCommit("prop:textContent", 400, (/** @type {any} */ e) => {
4984
- update(updateProperty(S, path, "textContent", e.target.value || undefined));
4985
- })}
4986
- ></sp-textfield>
4987
- </div>
4988
- `
4989
- : nothing}
4990
- <div class="style-row" data-prop="hidden">
4991
- <div class="style-row-label">
4992
- ${node.hidden
4993
- ? html`<span
4994
- class="set-dot"
4995
- title="Clear hidden"
4996
- @click=${(/** @type {any} */ e) => {
4997
- e.stopPropagation();
4998
- update(updateProperty(S, path, "hidden", undefined));
4999
- }}
5000
- ></span>`
5001
- : nothing}
5002
- <sp-field-label size="s">Hidden</sp-field-label>
5003
- </div>
5004
- <sp-checkbox
5005
- size="s"
5006
- .checked=${live(!!node.hidden)}
5007
- @change=${(/** @type {any} */ e) =>
5008
- update(updateProperty(S, path, "hidden", e.target.checked || undefined))}
5009
- >
5010
- </sp-checkbox>
5011
- </div>
5012
- ${isMapParent
5013
- ? html`
5014
- <div style="font-size:10px;color:var(--fg-dim);padding:4px 0;font-style:italic">
5015
- Children: Repeater (select in layers to configure)
5016
- </div>
5017
- `
5018
- : nothing}
5019
- </div>
5020
- </sp-accordion-item>
5021
- `;
5022
-
5023
- // "Repeater" section (if $map node)
5024
- const repeaterT = isMapNode
5025
- ? html`
5026
- <sp-accordion-item label="Repeater" open>
5027
- <div class="style-section-body">
5028
- ${renderRepeaterFieldsTemplate(node, path, mapSignals)}
5029
- </div>
5030
- </sp-accordion-item>
5031
- `
5032
- : nothing;
5033
-
5034
- // "$switch" section
5035
- const switchT = isSwitchNode
5036
- ? html`
5037
- <sp-accordion-item label="Switch" open>
5038
- <div class="style-section-body">
5039
- ${renderSwitchFieldsTemplate(node, path, mapSignals)}
5040
- </div>
5041
- </sp-accordion-item>
5042
- `
5043
- : nothing;
5044
-
5045
- // "Observed Attributes" section (custom element doc root)
5046
- const observedAttrsT =
5047
- isCustomElementDoc(S) && isRoot
5048
- ? (() => {
5049
- const state = S.document.state || {};
5050
- const entries = Object.entries(state).filter(([, d]) => d.attribute);
5051
- return html`
5052
- <sp-accordion-item label="Observed Attributes" ?open=${isSectionOpen("__observed")}>
5053
- <div class="style-section-body">
5054
- ${entries.length === 0
5055
- ? html`<div class="empty-state">
5056
- No attributes declared. Set "attribute" on a state entry.
5057
- </div>`
5058
- : entries.map(
5059
- ([key, d]) => html`
5060
- <div
5061
- style="display:flex;gap:6px;align-items:center;padding:2px 0;font-size:11px"
5062
- >
5063
- <code style="font-family:monospace;color:var(--accent)"
5064
- >${d.attribute}</code
5065
- >
5066
- <span style="color:var(--fg-dim)"> → </span>
5067
- <span>${key}</span>
5068
- ${d.type
5069
- ? html`<span style="margin-left:auto;color:var(--fg-dim);font-size:10px"
5070
- >${d.type}</span
5071
- >`
5072
- : nothing}
5073
- ${d.reflects
5074
- ? html`<span
5075
- style="font-size:9px;background:var(--bg-hover);padding:1px 4px;border-radius:3px"
5076
- >reflects</span
5077
- >`
5078
- : nothing}
5079
- </div>
5080
- `,
5081
- )}
5082
- </div>
5083
- </sp-accordion-item>
5084
- `;
5085
- })()
5086
- : nothing;
5087
-
5088
- // "Component Props" section
5089
- const compPropsT = isCustomInstance
5090
- ? html`
5091
- <sp-accordion-item label="Component Props" open>
5092
- <div class="style-section-body">
5093
- ${renderComponentPropsFieldsTemplate(node, path, mapSignals)}
5094
- </div>
5095
- </sp-accordion-item>
5096
- `
5097
- : nothing;
5098
-
5099
- // HTML-meta attribute sections
5100
- const attrSectionTemplates = htmlMeta.$sections
5101
- .filter((sec) => attrSections[sec.key].length > 0)
5102
- .map((sec) => {
5103
- const sectionAttrs = attrSections[sec.key];
5104
- const hasAnySet = sectionAttrs.some((/** @type {any} */ a) => attrs[a.name] !== undefined);
5105
- return html`
5106
- <sp-accordion-item
5107
- label=${sec.label}
5108
- ?open=${isSectionOpen(sec.key)}
5109
- @sp-accordion-item-toggle=${() => toggleSection(sec.key)}
5110
- >
5111
- ${hasAnySet
5112
- ? html`<span slot="heading" class="set-dot set-dot--section"></span>`
5113
- : nothing}
5114
- <div class="style-section-body">
5115
- ${sectionAttrs.map((/** @type {any} */ a) =>
5116
- renderAttrRow(a.name, a.entry, attrs[a.name]),
5117
- )}
5118
- </div>
5119
- </sp-accordion-item>
5120
- `;
5121
- });
5122
-
5123
- // "Custom" attributes section (not in html-meta)
5124
- const customSectionT =
5125
- customAttrs.length > 0 || Object.keys(attrs).length > 0
5126
- ? html`
5127
- <sp-accordion-item
5128
- label="Custom"
5129
- ?open=${isSectionOpen("__custom")}
5130
- @sp-accordion-item-toggle=${() => toggleSection("__custom")}
5131
- >
5132
- ${customAttrs.length > 0
5133
- ? html`<span slot="heading" class="set-dot set-dot--section"></span>`
5134
- : nothing}
5135
- <div class="style-section-body">
5136
- ${renderCustomAttrsFieldsTemplate(node, path, attrs, knownAttrNames)}
5137
- </div>
5138
- </sp-accordion-item>
5139
- `
5140
- : nothing;
5141
-
5142
- // Media section (root only)
5143
- const mediaT = isRoot
5144
- ? html`
5145
- <sp-accordion-item
5146
- label="Media"
5147
- ?open=${isSectionOpen("__media")}
5148
- @sp-accordion-item-toggle=${() => toggleSection("__media")}
5149
- >
5150
- <div class="style-section-body">${renderMediaFieldsTemplate(node)}</div>
5151
- </sp-accordion-item>
5152
- `
5153
- : nothing;
5154
-
5155
- // CSS Properties + CSS Parts (custom element doc root)
5156
- const cssPropsT =
5157
- isCustomElementDoc(S) && isRoot
5158
- ? (() => {
5159
- const style = node.style || {};
5160
- const cssProps = Object.entries(style).filter(([k]) => k.startsWith("--"));
5161
- if (cssProps.length === 0) return nothing;
5162
- return html`
5163
- <sp-accordion-item
5164
- label="CSS Properties"
5165
- ?open=${isSectionOpen("__cssprops")}
5166
- @sp-accordion-item-toggle=${() => toggleSection("__cssprops")}
5167
- >
5168
- <div class="style-section-body">
5169
- ${cssProps.map(
5170
- ([prop, val]) => html`
5171
- <div
5172
- style="display:flex;gap:6px;align-items:center;padding:2px 0;font-size:11px"
5173
- >
5174
- <code style="font-family:monospace;color:var(--accent)">${prop}</code>
5175
- <span style="margin-left:auto;color:var(--fg-dim)">${String(val)}</span>
5176
- </div>
5177
- `,
5178
- )}
5179
- </div>
5180
- </sp-accordion-item>
5181
- `;
5182
- })()
5183
- : nothing;
5184
-
5185
- const cssPartsT =
5186
- isCustomElementDoc(S) && isRoot
5187
- ? (() => {
5188
- const parts = collectCssParts(S.document);
5189
- if (parts.length === 0) return nothing;
5190
- return html`
5191
- <sp-accordion-item
5192
- label="CSS Parts"
5193
- ?open=${isSectionOpen("__cssparts")}
5194
- @sp-accordion-item-toggle=${() => toggleSection("__cssparts")}
5195
- >
5196
- <div class="style-section-body">
5197
- ${parts.map(
5198
- (p) => html`
5199
- <div
5200
- style="display:flex;gap:6px;align-items:center;padding:2px 0;font-size:11px"
5201
- >
5202
- <code style="font-family:monospace;color:var(--accent)">${p.name}</code>
5203
- <span style="color:var(--fg-dim)">&lt;${p.tag}&gt;</span>
5204
- </div>
5205
- `,
5206
- )}
5207
- </div>
5208
- </sp-accordion-item>
5209
- `;
5210
- })()
5211
- : nothing;
5212
-
5213
- // ── Frontmatter section (content mode only) ──
5214
- const frontmatterT =
5215
- S.mode === "content"
5216
- ? (() => {
5217
- const fm = S.content?.frontmatter || {};
5218
- const col = findCollectionSchema(S.documentPath, projectState?.projectConfig);
5219
- const schemaProps = col?.schema?.properties;
5220
- const requiredFields = new Set(col?.schema?.required || []);
5221
-
5222
- /** @type {{ field: string; entry: any; value: any }[]} */
5223
- const fields = [];
5224
- if (schemaProps) {
5225
- for (const [field, fieldSchema] of Object.entries(
5226
- /** @type {Record<string, any>} */ (schemaProps),
5227
- )) {
5228
- fields.push({ field, entry: fieldSchema, value: fm[field] });
5229
- }
5230
- for (const [field, value] of Object.entries(fm)) {
5231
- if (!schemaProps[field]) {
5232
- fields.push({
5233
- field,
5234
- entry: { type: typeof value === "boolean" ? "boolean" : "string" },
5235
- value,
5236
- });
5237
- }
5238
- }
5239
- } else {
5240
- for (const [field, value] of Object.entries(fm)) {
5241
- fields.push({
5242
- field,
5243
- entry: { type: typeof value === "boolean" ? "boolean" : "string" },
5244
- value,
5245
- });
5246
- }
5247
- }
5248
-
5249
- if (fields.length === 0 && !schemaProps) return nothing;
5250
-
5251
- return html`
5252
- <sp-accordion-item
5253
- label=${col ? `Frontmatter (${col.name})` : "Frontmatter"}
5254
- ?open=${isSectionOpen("__frontmatter") !== false}
5255
- @sp-accordion-item-toggle=${() => toggleSection("__frontmatter")}
5256
- >
5257
- <div class="style-section-body">
5258
- ${fields.map((f) => renderFmFieldRow(f.field, f.entry, f.value, requiredFields))}
5259
- </div>
5260
- </sp-accordion-item>
5261
- `;
5262
- })()
5263
- : nothing;
5264
-
5265
- // ── Assemble ──
5266
- const tpl = html`
5267
- <div class="style-sidebar">
5268
- <sp-accordion allow-multiple size="s">
5269
- ${frontmatterT} ${isMapNode ? repeaterT : elemT} ${isMapNode ? nothing : observedAttrsT}
5270
- ${isMapNode ? nothing : switchT} ${isMapNode ? nothing : compPropsT}
5271
- ${isMapNode ? nothing : attrSectionTemplates} ${isMapNode ? nothing : customSectionT}
5272
- ${isMapNode ? nothing : mediaT} ${isMapNode ? nothing : cssPropsT}
5273
- ${isMapNode ? nothing : cssPartsT}
5274
- </sp-accordion>
5275
- </div>
5276
- `;
5277
-
5278
- return tpl;
5279
- }
5280
-
5281
- /** Repeater fields template */
5282
- function renderRepeaterFieldsTemplate(
5283
- /** @type {any} */ node,
5284
- /** @type {any} */ path,
5285
- /** @type {any} */ _mapSignals,
5286
- ) {
5287
- return html`
5288
- ${bindableFieldRow("Items", "text", node.items, (/** @type {any} */ v) =>
5289
- update(updateProperty(S, path, "items", v)),
5290
- )}
5291
- ${node.filter
5292
- ? bindableFieldRow("Filter", "text", node.filter, (/** @type {any} */ v) =>
5293
- update(updateProperty(S, path, "filter", v || undefined)),
5294
- )
5295
- : nothing}
5296
- ${node.sort
5297
- ? bindableFieldRow("Sort", "text", node.sort, (/** @type {any} */ v) =>
5298
- update(updateProperty(S, path, "sort", v || undefined)),
5299
- )
5300
- : nothing}
5301
- <div style="display:flex;gap:8px;margin-top:4px">
5302
- ${!node.filter
5303
- ? html`<span
5304
- class="kv-add"
5305
- @click=${() => update(updateProperty(S, path, "filter", { $ref: "#/state/" }))}
5306
- >+ Add filter</span
5307
- >`
5308
- : nothing}
5309
- ${!node.sort
5310
- ? html`<span
5311
- class="kv-add"
5312
- @click=${() => update(updateProperty(S, path, "sort", { $ref: "#/state/" }))}
5313
- >+ Add sort</span
5314
- >`
5315
- : nothing}
5316
- </div>
5317
- ${node.map
5318
- ? html`
5319
- <sp-action-button
5320
- size="s"
5321
- style="margin-top:8px;width:100%"
5322
- @click=${() => update(selectNode(S, [...path, "map"]))}
5323
- >Edit template →</sp-action-button
5324
- >
5325
- `
5326
- : nothing}
5327
- `;
5328
- }
5329
-
5330
- /** Switch fields template */
5331
- function renderSwitchFieldsTemplate(
5332
- /** @type {any} */ node,
5333
- /** @type {any} */ path,
5334
- /** @type {any} */ mapSignals,
5335
- ) {
5336
- const caseNames = Object.keys(node.cases || {});
5337
- return html`
5338
- ${bindableFieldRow(
5339
- "Expression",
5340
- "text",
5341
- node.$switch,
5342
- (/** @type {any} */ v) => update(updateProperty(S, path, "$switch", v)),
5343
- null,
5344
- mapSignals,
5345
- )}
5346
- <div
5347
- style="font-size:11px;font-weight:600;color:var(--fg-dim);margin:8px 0 4px;text-transform:uppercase;letter-spacing:0.05em"
5348
- >
5349
- Cases
5350
- </div>
5351
- ${caseNames.map((caseName) => {
5352
- /** @type {any} */
5353
- let debounce;
5354
- return html`
5355
- <div class="field-row" style="display:flex;align-items:center;gap:4px;margin-bottom:3px">
5356
- <input
5357
- class="field-input"
5358
- value=${caseName}
5359
- style="flex:1"
5360
- @input=${(/** @type {any} */ e) => {
5361
- clearTimeout(debounce);
5362
- debounce = setTimeout(() => {
5363
- if (e.target.value && e.target.value !== caseName)
5364
- update(renameSwitchCase(S, path, caseName, e.target.value));
5365
- }, 500);
5366
- }}
5367
- />
5368
- <span
5369
- class="bind-toggle"
5370
- title="Edit case"
5371
- style="cursor:pointer"
5372
- @click=${(/** @type {any} */ e) => {
5373
- e.stopPropagation();
5374
- update(selectNode(S, [...path, "cases", caseName]));
5375
- }}
5376
- >→</span
5377
- >
5378
- <span
5379
- style="cursor:pointer;color:var(--danger);font-size:11px"
5380
- @click=${(/** @type {any} */ e) => {
5381
- e.stopPropagation();
5382
- update(removeSwitchCase(S, path, caseName));
5383
- }}
5384
- >✕</span
5385
- >
5386
- </div>
5387
- `;
5388
- })}
5389
- <span
5390
- class="kv-add"
5391
- @click=${() => {
5392
- update(addSwitchCase(S, path, `case${caseNames.length + 1}`));
5393
- }}
5394
- >+ Add case</span
5395
- >
5396
- `;
5397
- }
5398
-
5399
- /** Component props fields template */
5400
- function renderComponentPropsFieldsTemplate(
5401
- /** @type {any} */ node,
5402
- /** @type {any} */ path,
5403
- /** @type {any} */ mapSignals,
5404
- ) {
5405
- const comp = componentRegistry.find((c) => c.tagName === node.tagName);
5406
- if (!comp) return html`<div class="empty-state">Component not found</div>`;
5407
- // For npm web components, props map to attributes; for JX components, use $props
5408
- const isNpm = comp.source === "npm";
5409
- const currentVals = isNpm ? node.attributes || {} : node.$props || {};
5410
- const updateFn = isNpm
5411
- ? (/** @type {string} */ name, /** @type {any} */ v) =>
5412
- update(updateAttribute(S, path, name, v === "" ? undefined : v))
5413
- : (/** @type {string} */ name, /** @type {any} */ v) => update(updateProp(S, path, name, v));
5414
-
5415
- const defs = S.document.state || {};
5416
- const signalDefs = Object.entries(defs).filter(
5417
- ([, d]) => !d.$handler && d.$prototype !== "Function",
5418
- );
5419
- const extraSignals = mapSignals;
5420
-
5421
- return html`
5422
- ${comp.props.map((/** @type {any} */ prop) => {
5423
- const rawValue = currentVals[prop.name];
5424
- const isBound = typeof rawValue === "object" && rawValue !== null && rawValue.$ref;
5425
- const hasVal = rawValue !== undefined && rawValue !== null;
5426
- const parsed = parseCemType(prop.type);
5427
- const onChange = (/** @type {any} */ v) => updateFn(prop.name, v);
5428
-
5429
- const clearProp = (/** @type {any} */ e) => {
5430
- e.stopPropagation();
5431
- updateFn(prop.name, undefined);
5432
- };
5433
-
5434
- const onToggleBind = () => {
5435
- if (isBound) {
5436
- const ref = rawValue.$ref;
5437
- const defName = ref.startsWith("#/state/") ? ref.slice(8) : ref;
5438
- const def = defs[defName];
5439
- let staticVal = "";
5440
- if (def && def.default !== undefined)
5441
- staticVal =
5442
- typeof def.default === "object" ? JSON.stringify(def.default) : String(def.default);
5443
- onChange(staticVal || undefined);
5444
- } else {
5445
- if (signalDefs.length > 0) {
5446
- onChange({ $ref: `#/state/${signalDefs[0][0]}` });
5447
- } else if (extraSignals?.length > 0) {
5448
- onChange({ $ref: extraSignals[0].value });
5449
- }
5450
- }
5451
- };
5452
-
5453
- // Signal picker for bound mode
5454
- const boundTpl = html`
5455
- <sp-picker
5456
- size="s"
5457
- quiet
5458
- placeholder="— select signal —"
5459
- value=${isBound && rawValue.$ref ? rawValue.$ref : nothing}
5460
- @change=${(/** @type {any} */ e) => {
5461
- if (e.target.value) onChange({ $ref: e.target.value });
5462
- else onChange(undefined);
5463
- }}
5464
- >
5465
- ${signalDefs.map(
5466
- ([defName]) =>
5467
- html`<sp-menu-item value=${`#/state/${defName}`}>${defName}</sp-menu-item>`,
5468
- )}
5469
- ${extraSignals
5470
- ? html`
5471
- <sp-menu-divider></sp-menu-divider>
5472
- ${extraSignals.map(
5473
- (/** @type {any} */ sig) =>
5474
- html`<sp-menu-item value=${sig.value}>${sig.label}</sp-menu-item>`,
5475
- )}
5476
- `
5477
- : nothing}
5478
- </sp-picker>
5479
- `;
5480
-
5481
- // Widget based on CEM type
5482
- /** @type {any} */
5483
- let debounce;
5484
- const staticVal = isBound ? "" : (rawValue ?? "");
5485
- /** @type {any} */
5486
- let widgetTpl;
5487
- if (parsed.kind === "boolean") {
5488
- widgetTpl = html`<sp-checkbox
5489
- size="s"
5490
- .checked=${live(!!staticVal)}
5491
- @change=${(/** @type {any} */ e) => onChange(e.target.checked || undefined)}
5492
- ></sp-checkbox>`;
5493
- } else if (parsed.kind === "number") {
5494
- widgetTpl = html`<sp-number-field
5495
- size="s"
5496
- value=${staticVal}
5497
- @input=${(/** @type {any} */ e) => {
5498
- clearTimeout(debounce);
5499
- debounce = setTimeout(() => onChange(e.target.value), 400);
5500
- }}
5501
- ></sp-number-field>`;
5502
- } else if (parsed.kind === "combobox") {
5503
- const options = /** @type {string[]} */ (/** @type {any} */ (parsed).options);
5504
- widgetTpl = html`<jx-value-selector
5505
- .value=${String(staticVal)}
5506
- size="s"
5507
- placeholder="—"
5508
- .options=${options.map((o) => ({ value: o, label: camelToLabel(o) }))}
5509
- @change=${(/** @type {any} */ e) => onChange(e.detail?.value ?? e.target.value)}
5510
- ></jx-value-selector>`;
5511
- } else {
5512
- widgetTpl = html`<sp-textfield
5513
- size="s"
5514
- value=${staticVal}
5515
- @input=${(/** @type {any} */ e) => {
5516
- clearTimeout(debounce);
5517
- debounce = setTimeout(() => onChange(e.target.value), 400);
5518
- }}
5519
- ></sp-textfield>`;
5520
- }
5521
-
5522
- return html`
5523
- <div class="style-row" data-prop=${prop.name}>
5524
- <div class="style-row-label">
5525
- ${hasVal
5526
- ? html`<span class="set-dot" title="Clear ${prop.name}" @click=${clearProp}></span>`
5527
- : nothing}
5528
- <sp-field-label size="s" title=${prop.description || prop.name}
5529
- >${camelToLabel(prop.name)}</sp-field-label
5530
- >
5531
- <sp-action-button
5532
- size="xs"
5533
- quiet
5534
- title=${isBound ? "Unbind (switch to static)" : "Bind to signal"}
5535
- @click=${onToggleBind}
5536
- >${isBound ? "\u26A1" : "\u2194"}</sp-action-button
5537
- >
5538
- </div>
5539
- ${isBound ? boundTpl : widgetTpl}
5540
- </div>
5541
- `;
5542
- })}
5543
- ${comp.props.length === 0 ? html`<div class="empty-state">No props defined</div>` : nothing}
5544
- ${comp.path
5545
- ? html`<span class="kv-add" @click=${() => navigateToComponent(comp.path)}
5546
- >→ Edit definition</span
5547
- >`
5548
- : nothing}
5549
- `;
5550
- }
5551
-
5552
- /** Custom attrs fields template */
5553
- function renderCustomAttrsFieldsTemplate(
5554
- /** @type {any} */ node,
5555
- /** @type {any} */ path,
5556
- /** @type {any} */ attrs,
5557
- /** @type {any} */ knownAttrNames,
5558
- ) {
5559
- const customAttrs = Object.entries(attrs).filter(([k]) => !knownAttrNames.has(k));
5560
- return html`
5561
- ${customAttrs.map(([attr, val]) =>
5562
- kvRow(
5563
- attr,
5564
- String(val),
5565
- (/** @type {any} */ newAttr, /** @type {any} */ newVal) => {
5566
- if (newAttr !== attr) {
5567
- let s = updateAttribute(S, path, attr, undefined);
5568
- s = updateAttribute(s, path, newAttr, newVal);
5569
- update(s);
5570
- } else {
5571
- update(updateAttribute(S, path, attr, newVal));
5572
- }
5573
- },
5574
- () => update(updateAttribute(S, path, attr, undefined)),
5575
- ),
5576
- )}
5577
- <span class="kv-add" @click=${() => update(updateAttribute(S, path, "data-", ""))}
5578
- >+ Add attribute</span
5579
- >
5580
- `;
5581
- }
5582
-
5583
- /** Media breakpoint fields template */
5584
-
5585
- function renderMediaFieldsTemplate(/** @type {any} */ node) {
5586
- const media = node.$media || {};
5587
- /** @type {any} */
5588
- let baseDebounce;
5589
- const breakpoints = Object.entries(media).filter(([k]) => k !== "--");
5590
-
5591
- return html`
5592
- <div class="kv-row" style="align-items:center">
5593
- <span class="field-label" style="width:auto;margin-right:4px">Base width</span>
5594
- <input
5595
- class="field-input"
5596
- style="width:70px;flex:none"
5597
- placeholder="320px"
5598
- value=${media["--"] || ""}
5599
- @input=${(/** @type {any} */ e) => {
5600
- clearTimeout(baseDebounce);
5601
- baseDebounce = setTimeout(() => {
5602
- const val = e.target.value.trim();
5603
- update(updateMedia(S, "--", val || undefined));
5604
- }, 400);
5605
- }}
5606
- />
5607
- ${media["--"]
5608
- ? html`<span class="kv-del" @click=${() => update(updateMedia(S, "--", undefined))}
5609
- >✕</span
5610
- >`
5611
- : nothing}
5612
- </div>
5613
-
5614
- ${breakpoints.map(([name, query]) => mediaBreakpointRowTemplate(name, query))}
5615
-
5616
- <div>
5617
- <span
5618
- class="kv-add"
5619
- style=${view.showAddBreakpointForm ? "display:none" : ""}
5620
- @click=${(/** @type {any} */ _e) => {
5621
- view.showAddBreakpointForm = true;
5622
- renderRightPanel();
5623
- }}
5624
- >+ Add breakpoint</span
5625
- >
5626
- ${view.showAddBreakpointForm
5627
- ? html`
5628
- <div style="margin-top:4px">
5629
- <div style="display:flex;gap:4px;margin-bottom:3px;align-items:center">
5630
- <input
5631
- class="field-input"
5632
- placeholder="Name (e.g. Tablet)"
5633
- style="flex:1"
5634
- @input=${(/** @type {any} */ e) => {
5635
- view.addBreakpointPreview = friendlyNameToMedia(e.target.value) || "";
5636
- renderRightPanel();
5637
- }}
5638
- />
5639
- <span
5640
- style="font-size:10px;color:var(--fg-dim);font-family:'SF Mono','Fira Code',monospace;white-space:nowrap"
5641
- >${view.addBreakpointPreview}</span
5642
- >
5643
- </div>
5644
- <div style="display:flex;gap:4px;margin-bottom:3px;align-items:center">
5645
- <input class="field-input add-bp-query" value="(min-width: 768px)" style="flex:1" />
5646
- </div>
5647
- <div style="display:flex;gap:4px">
5648
- <button
5649
- class="kv-add"
5650
- style="padding:2px 10px;cursor:pointer"
5651
- @click=${(/** @type {any} */ e) => {
5652
- const wrap = e.target.closest("div").parentElement;
5653
- const nameVal = wrap.querySelector("input")?.value;
5654
- const queryVal = wrap.querySelector(".add-bp-query")?.value?.trim();
5655
- const key = friendlyNameToMedia(nameVal);
5656
- if (key && queryVal) {
5657
- view.showAddBreakpointForm = false;
5658
- view.addBreakpointPreview = "";
5659
- update(updateMedia(S, key, queryVal));
5660
- }
5661
- }}
5662
- >
5663
- Add
5664
- </button>
5665
- <button
5666
- class="kv-add"
5667
- style="padding:2px 10px;cursor:pointer;color:var(--fg-dim)"
5668
- @click=${() => {
5669
- view.showAddBreakpointForm = false;
5670
- view.addBreakpointPreview = "";
5671
- renderRightPanel();
5672
- }}
5673
- >
5674
- Cancel
5675
- </button>
5676
- </div>
5677
- </div>
5678
- `
5679
- : nothing}
5680
- </div>
5681
- `;
5682
- }
5683
-
5684
- /** Single media breakpoint row template */
5685
- function mediaBreakpointRowTemplate(/** @type {any} */ name, /** @type {any} */ query) {
5686
- /** @type {any} */
5687
- let debounceTimer;
5688
- let currentRawLabel = name;
5689
- return html`
5690
- <div style="margin-bottom:6px;padding:4px 0;border-bottom:1px solid var(--border)">
5691
- <div style="display:flex;align-items:center;gap:4px;margin-bottom:2px">
5692
- <input
5693
- class="field-input"
5694
- value=${mediaDisplayName(name)}
5695
- style="flex:1;font-weight:600;font-size:12px"
5696
- @input=${(/** @type {any} */ e) => {
5697
- const newKey = friendlyNameToMedia(e.target.value);
5698
- currentRawLabel = newKey || "";
5699
- const rawEl = e.target.parentElement?.querySelector(".bp-raw-label");
5700
- if (rawEl) rawEl.textContent = currentRawLabel;
5701
- clearTimeout(debounceTimer);
5702
- debounceTimer = setTimeout(() => {
5703
- if (newKey && newKey !== name) {
5704
- const queryEl = e.target
5705
- .closest("div[style]")
5706
- ?.parentElement?.querySelector(".bp-query-input");
5707
- let s = updateMedia(S, name, undefined);
5708
- s = updateMedia(s, newKey, queryEl?.value || query);
5709
- update(s);
5710
- }
5711
- }, 600);
5712
- }}
5713
- />
5714
- <span
5715
- class="bp-raw-label"
5716
- style="font-size:10px;color:var(--fg-dim);font-family:'SF Mono','Fira Code',monospace;white-space:nowrap"
5717
- >${name}</span
5718
- >
5719
- <span class="kv-del" @click=${() => update(updateMedia(S, name, undefined))}>✕</span>
5720
- </div>
5721
- <div style="display:flex;gap:4px;align-items:center">
5722
- <input
5723
- class="field-input bp-query-input"
5724
- value=${query}
5725
- style="flex:1"
5726
- @input=${(/** @type {any} */ e) => {
5727
- clearTimeout(debounceTimer);
5728
- debounceTimer = setTimeout(() => update(updateMedia(S, name, e.target.value)), 400);
5729
- }}
5730
- />
5731
- </div>
5732
- </div>
5733
- `;
5734
- }
5735
-
5736
- // ─── Style Sidebar (metadata-driven) ───────────────────────────────────────────
5737
-
5738
- // UNIT_RE — imported from ui/unit-selector.js
5739
-
5740
- // inferInputType — imported from studio-utils.js
5741
-
5742
- // ─── Style panel ────────────────────────────────────────────────────────────
5743
- // Extracted to panels/style-utils.js, panels/style-inputs.js, panels/style-panel.js
5744
-
5745
- /** Check if a selection path is inside a $map template (contains [..., "children", "map", ...]). */
5746
- function isInsideMapTemplate(/** @type {any} */ path) {
5747
- if (!path) return false;
5748
- for (let i = 0; i < path.length - 1; i++) {
5749
- if (path[i] === "children" && path[i + 1] === "map") return true;
5750
- }
5751
- return false;
5752
- }
5753
-
5754
- /**
5755
- * Field row with binding toggle — allows switching between static value and signal binding.
5756
- * rawValue can be a string/bool (static) or { $ref: "..." } (bound).
5757
- */
5758
- function bindableFieldRow(
5759
- /** @type {any} */ label,
5760
- /** @type {any} */ type,
5761
- /** @type {any} */ rawValue,
5762
- /** @type {any} */ onChange,
5763
- /** @type {any} */ filterFn = null,
5764
- /** @type {any} */ extraSignals = null,
5765
- ) {
5766
- const defs = S.document.state || {};
5767
- const isBound = typeof rawValue === "object" && rawValue !== null && rawValue.$ref;
5768
-
5769
- const signalDefs = Object.entries(defs).filter(([, d]) =>
5770
- filterFn ? filterFn(d) : !d.$handler && d.$prototype !== "Function",
5771
- );
5772
-
5773
- /** @type {any} */
5774
- let debounce;
5775
- const onInput = (/** @type {any} */ e) => {
5776
- clearTimeout(debounce);
5777
- debounce = setTimeout(() => onChange(e.target.value), 400);
5778
- };
5779
-
5780
- const staticVal = isBound ? "" : (rawValue ?? "");
5781
- const staticTpl =
5782
- type === "textarea"
5783
- ? html`<sp-textfield multiline size="s" value=${staticVal} @input=${onInput}></sp-textfield>`
5784
- : type === "checkbox"
5785
- ? html`<sp-checkbox
5786
- ?checked=${!!staticVal}
5787
- @change=${(/** @type {any} */ e) => onChange(e.target.checked)}
5788
- ></sp-checkbox>`
5789
- : html`<sp-textfield size="s" value=${staticVal} @input=${onInput}></sp-textfield>`;
5790
-
5791
- const boundTpl = html`
5792
- <sp-picker
5793
- size="s"
5794
- quiet
5795
- placeholder="— select signal —"
5796
- value=${isBound && rawValue.$ref ? rawValue.$ref : nothing}
5797
- @change=${(/** @type {any} */ e) => {
5798
- if (e.target.value) onChange({ $ref: e.target.value });
5799
- else onChange(undefined);
5800
- }}
5801
- >
5802
- ${signalDefs.map(
5803
- ([defName]) => html`<sp-menu-item value=${`#/state/${defName}`}>${defName}</sp-menu-item>`,
5804
- )}
5805
- ${extraSignals
5806
- ? html`
5807
- <sp-menu-divider></sp-menu-divider>
5808
- ${extraSignals.map(
5809
- (/** @type {any} */ sig) =>
5810
- html`<sp-menu-item value=${sig.value}>${sig.label}</sp-menu-item>`,
5811
- )}
5812
- `
5813
- : nothing}
5814
- </sp-picker>
5815
- `;
5816
-
5817
- const onToggle = () => {
5818
- if (isBound) {
5819
- const ref = rawValue.$ref;
5820
- const defName = ref.startsWith("#/state/") ? ref.slice(8) : ref;
5821
- const def = defs[defName];
5822
- let staticVal = "";
5823
- if (def && def.default !== undefined)
5824
- staticVal =
5825
- typeof def.default === "object" ? JSON.stringify(def.default) : String(def.default);
5826
- onChange(staticVal || undefined);
5827
- } else {
5828
- if (signalDefs.length > 0) {
5829
- onChange({ $ref: `#/state/${signalDefs[0][0]}` });
5830
- } else if (extraSignals?.length > 0) {
5831
- onChange({ $ref: extraSignals[0].value });
5832
- }
5833
- }
5834
- };
5835
-
5836
- return html`
5837
- <div class="field-row">
5838
- <sp-field-label size="s">${label}</sp-field-label>
5839
- ${isBound ? boundTpl : staticTpl}
5840
- <sp-action-button
5841
- size="xs"
5842
- quiet
5843
- title=${isBound ? "Unbind (switch to static)" : "Bind to signal"}
5844
- @click=${onToggle}
5845
- >${isBound ? "\u26A1" : "\u2194"}</sp-action-button
5846
- >
5847
- </div>
5848
- `;
5849
- }
5850
-
5851
- /** Key-value pair row for styles / attributes */
5852
- function kvRow(
5853
- /** @type {any} */ key,
5854
- /** @type {any} */ value,
5855
- /** @type {any} */ onChange,
5856
- /** @type {any} */ onDelete,
5857
- /** @type {any} */ datalistId = null,
5858
- ) {
5859
- /** @type {any} */
5860
- let debounceTimer;
5861
- let currentKey = key;
5862
- let currentVal = value;
5863
- const commit = () => {
5864
- clearTimeout(debounceTimer);
5865
- debounceTimer = setTimeout(() => onChange(currentKey, currentVal), 400);
5866
- };
5867
- const placeholder = datalistId === "css-props" ? getCssInitialMap().get(key) || "" : "";
5868
- return html`
5869
- <div class="kv-row">
5870
- <sp-textfield
5871
- size="s"
5872
- class="kv-key"
5873
- value=${key}
5874
- @input=${(/** @type {any} */ e) => {
5875
- currentKey = e.target.value;
5876
- commit();
5877
- }}
5878
- @change=${datalistId === "css-props"
5879
- ? (/** @type {any} */ e) => {
5880
- const el = e.target.closest(".kv-row")?.querySelector(".kv-val");
5881
- if (el) el.setAttribute("placeholder", getCssInitialMap().get(e.target.value) || "");
5882
- }
5883
- : nothing}
5884
- ></sp-textfield>
5885
- <sp-textfield
5886
- size="s"
5887
- class="kv-val"
5888
- value=${value}
5889
- placeholder=${placeholder}
5890
- @input=${(/** @type {any} */ e) => {
5891
- currentVal = e.target.value;
5892
- commit();
5893
- }}
5894
- ></sp-textfield>
5895
- <sp-action-button size="xs" quiet @click=${onDelete}>
5896
- <sp-icon-close slot="icon"></sp-icon-close>
5897
- </sp-action-button>
5898
- </div>
5899
- `;
5900
- }
5901
-
5902
- // ─── Source view ──────────────────────────────────────────────────────────────
5903
-
5904
- function _renderSourceView(/** @type {any} */ container) {
5905
- if (!S.selection) {
5906
- litRender(
5907
- html`
5908
- <textarea
5909
- id="source-view"
5910
- .value=${live(JSON.stringify(S.document, null, 2))}
5911
- @blur=${(/** @type {any} */ e) => {
5912
- try {
5913
- const parsed = JSON.parse(e.target.value);
5914
- update({ ...S, document: parsed, dirty: true });
5915
- } catch {}
5916
- }}
5917
- ></textarea>
5918
- `,
5919
- container,
5920
- );
5921
- return;
5922
- }
5923
-
5924
- const node = getNodeAtPath(S.document, S.selection);
5925
- litRender(
5926
- html`
5927
- <textarea id="source-view" readonly .value=${live(JSON.stringify(node, null, 2))}></textarea>
5928
- `,
5929
- container,
5930
- );
5931
- }
5932
-
5933
- // ─── Function editor (Monaco JS mode) ─────────────────────────────────────────
5934
-
5935
- function renderFunctionEditor() {
5936
- const editing = S.ui.editingFunction;
5937
-
5938
- // If editor already exists and matches current target, just sync value
5939
- if (view.functionEditor && view.functionEditor._editingTarget === JSON.stringify(editing)) {
5940
- const body = getFunctionBody(editing);
5941
- const currentVal = view.functionEditor.getValue();
5942
- if (currentVal !== body) {
5943
- view.functionEditor._ignoreNextChange = true;
5944
- view.functionEditor.setValue(body);
5945
- }
5946
- return;
5947
- }
5948
-
5949
- // Dispose previous editors
5950
- if (view.functionEditor) {
5951
- view.functionEditor.dispose();
5952
- view.functionEditor = null;
5953
- }
5954
- if (view.monacoEditor) {
5955
- view.monacoEditor.dispose();
5956
- view.monacoEditor = null;
5957
- }
5958
-
5959
- // Clean up canvas DnD and event handlers
5960
- for (const fn of view.canvasDndCleanups) fn();
5961
- view.canvasDndCleanups = [];
5962
- for (const fn of view.canvasEventCleanups) fn();
5963
- view.canvasEventCleanups = [];
5964
- canvasPanels.length = 0;
5965
-
5966
- litRender(nothing, canvasWrap);
5967
- canvasWrap.style.padding = "0";
5968
-
5969
- // Toolbar breadcrumb handles context display — re-render it
5970
- renderToolbar();
5971
-
5972
- // Editor container
5973
- /** @type {HTMLDivElement | null} */
5974
- let editorContainer = null;
5975
- litRender(
5976
- html`<div
5977
- class="source-editor"
5978
- ${ref((el) => {
5979
- if (el) editorContainer = /** @type {HTMLDivElement} */ (el);
5980
- })}
5981
- ></div>`,
5982
- canvasWrap,
5983
- );
5984
-
5985
- const body = getFunctionBody(editing);
5986
- const args = getFunctionArgs(editing, S);
5987
-
5988
- view.functionEditor = monaco.editor.create(/** @type {any} */ (editorContainer), {
5989
- value: body,
5990
- language: "javascript",
5991
- theme: "vs-dark",
5992
- automaticLayout: true,
5993
- minimap: { enabled: false },
5994
- fontSize: 12,
5995
- fontFamily: "'SF Mono', 'Fira Code', 'Consolas', monospace",
5996
- lineNumbers: "on",
5997
- scrollBeyondLastLine: false,
5998
- wordWrap: "on",
5999
- tabSize: 2,
6000
- });
6001
- view.functionEditor._editingTarget = JSON.stringify(editing);
6002
-
6003
- // Format on open — show pretty-printed code, then run initial lint
6004
- codeService("format", { code: body, args }).then((result) => {
6005
- if (result?.code != null && view.functionEditor) {
6006
- view.functionEditor._ignoreNextChange = true;
6007
- view.functionEditor.setValue(result.code);
6008
- }
6009
- });
6010
- codeService("lint", { code: body, args }).then((result) => {
6011
- if (result?.diagnostics && view.functionEditor)
6012
- setLintMarkers(view.functionEditor, result.diagnostics);
6013
- });
6014
-
6015
- // Debounced sync back to state + lint on edit
6016
- /** @type {any} */
6017
- let syncDebounce;
6018
- /** @type {any} */
6019
- let lintDebounce;
6020
- let lintGen = 0;
6021
- view.functionEditor.onDidChangeModelContent(() => {
6022
- if (view.functionEditor._ignoreNextChange) {
6023
- view.functionEditor._ignoreNextChange = false;
6024
- return;
6025
- }
6026
-
6027
- clearTimeout(syncDebounce);
6028
- syncDebounce = setTimeout(() => {
6029
- const newBody = view.functionEditor.getValue();
6030
- if (editing.type === "def") {
6031
- update(updateDef(S, editing.defName, { body: newBody }));
6032
- } else if (editing.type === "event") {
6033
- const node = getNodeAtPath(S.document, editing.path);
6034
- const current = node?.[editing.eventKey] || {};
6035
- update(
6036
- updateProperty(S, editing.path, editing.eventKey, {
6037
- ...current,
6038
- $prototype: "Function",
6039
- body: newBody,
6040
- }),
6041
- );
6042
- }
6043
- renderLeftPanel();
6044
- }, 500);
6045
-
6046
- clearTimeout(lintDebounce);
6047
- lintDebounce = setTimeout(() => {
6048
- const gen = ++lintGen;
6049
- const currentCode = view.functionEditor.getValue();
6050
- codeService("lint", { code: currentCode, args }).then((result) => {
6051
- if (gen !== lintGen) return;
6052
- if (result?.diagnostics && view.functionEditor)
6053
- setLintMarkers(view.functionEditor, result.diagnostics);
6054
- });
6055
- }, 750);
6056
- });
6057
- }
6058
-
6059
- function getFunctionBody(/** @type {any} */ editing) {
6060
- if (editing.type === "def") {
6061
- return S.document.state?.[editing.defName]?.body || "";
6062
- } else if (editing.type === "event") {
6063
- const node = getNodeAtPath(S.document, editing.path);
6064
- return node?.[editing.eventKey]?.body || "";
6065
- }
6066
- return "";
6067
- }
6068
-
6069
- // Register Monaco JS completion provider for state scope variables (once)
6070
- function registerFunctionCompletions() {
6071
- if (view._completionRegistered) return;
6072
- view._completionRegistered = true;
6073
- monaco.languages.registerCompletionItemProvider("javascript", {
6074
- triggerCharacters: ["."],
6075
- provideCompletionItems(model, position) {
6076
- const defs = S?.document?.state || {};
6077
- const word = model.getWordUntilPosition(position);
6078
- const range = {
6079
- startLineNumber: position.lineNumber,
6080
- endLineNumber: position.lineNumber,
6081
- startColumn: word.startColumn,
6082
- endColumn: word.endColumn,
6083
- };
6084
-
6085
- const suggestions = Object.entries(defs).map(([key, def]) => {
6086
- let kind = monaco.languages.CompletionItemKind.Variable;
6087
- if (def?.$prototype === "Function" || def?.$handler)
6088
- kind = monaco.languages.CompletionItemKind.Function;
6089
- else if (def?.$prototype) kind = monaco.languages.CompletionItemKind.Property;
6090
- return {
6091
- label: `state.${key}`,
6092
- kind,
6093
- insertText: `state.${key}`,
6094
- range,
6095
- };
6096
- });
6097
- return { suggestions };
6098
- },
6099
- });
6100
- }
2824
+ // ─── Source/Function editors: delegated to panels/editors.js ─────────────────
6101
2825
 
6102
2826
  // ─── Toolbar (delegated to panels/toolbar.js) ────────────────────────────────
6103
2827