@jxsuite/studio 0.6.1 → 0.7.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
@@ -16,10 +16,7 @@ import {
16
16
  updateStyle,
17
17
  updateAttribute,
18
18
  updateDef,
19
- updateMediaStyle,
20
19
  updateMedia,
21
- updateNestedStyle,
22
- updateMediaNestedStyle,
23
20
  pushDocument,
24
21
  popDocument,
25
22
  updateProp,
@@ -28,9 +25,7 @@ import {
28
25
  renameSwitchCase,
29
26
  applyMutation,
30
27
  getNodeAtPath,
31
- flattenTree,
32
28
  nodeLabel,
33
- pathKey,
34
29
  pathsEqual,
35
30
  parentElementPath,
36
31
  childIndex,
@@ -41,8 +36,6 @@ import {
41
36
  elToPath,
42
37
  canvasPanels,
43
38
  VOID_ELEMENTS,
44
- COMMON_SELECTORS,
45
- isNestedSelector,
46
39
  debouncedStyleCommit,
47
40
  stripEventHandlers,
48
41
  registerRenderer,
@@ -77,7 +70,6 @@ import {
77
70
  isEditing,
78
71
  getActiveElement,
79
72
  isEditableBlock,
80
- isInlineElement,
81
73
  isInlineInContext,
82
74
  getInlineActions,
83
75
  } from "./editor/inline-edit.js";
@@ -88,10 +80,7 @@ import {
88
80
  } from "./editor/slash-menu.js";
89
81
  import { toggleInlineFormat, isTagActiveInSelection } from "./editor/inline-format.js";
90
82
  import {
91
- camelToKebab,
92
83
  camelToLabel,
93
- kebabToLabel,
94
- propLabel,
95
84
  attrLabel,
96
85
  inferInputType,
97
86
  findCollectionSchema,
@@ -172,18 +161,16 @@ import { styleMap } from "lit-html/directives/style-map.js";
172
161
  import { ifDefined } from "lit-html/directives/if-defined.js";
173
162
 
174
163
  import webdata from "../data/webdata.json";
175
- import cssMeta from "../data/css-meta.json";
176
164
  import htmlMeta from "../data/html-meta.json";
177
165
  import stylebookMeta from "../data/stylebook-meta.json";
178
166
  import { renderDataExplorerTemplate } from "./panels/data-explorer.js";
167
+ import { renderGitPanel } from "./panels/git-panel.js";
179
168
 
180
169
  // ─── Spectrum Web Components ──────────────────────────────────────────────────
181
170
  // Explicit class imports + registration — bare side-effect imports are tree-shaken
182
171
  // by Bun's bundler despite sideEffects declarations in Spectrum's package.json.
183
172
  import { components as _swc } from "./ui/spectrum.js"; // eslint-disable-line no-unused-vars
184
173
  import { renderFieldRow } from "./ui/field-row.js";
185
- import { widgetForType as _widgetForType } from "./ui/widgets.js";
186
- import { computeInheritedStyle } from "./utils/inherited-style.js";
187
174
  import "./ui/panel-resize.js";
188
175
  import { showContextMenu, dismissContextMenu } from "./editor/context-menu.js";
189
176
  import { convertToComponent } from "./editor/convert-to-component.js";
@@ -195,7 +182,10 @@ import { renderDefsEditor } from "./settings/defs-editor.js";
195
182
  import * as toolbarPanel from "./panels/toolbar.js";
196
183
  import * as overlaysPanel from "./panels/overlays.js";
197
184
  import * as rightPanelMod from "./panels/right-panel.js";
198
- import { mediaDisplayName, ensureLitState } from "./panels/shared.js";
185
+ 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";
199
189
  import * as monaco from "monaco-editor/esm/vs/editor/editor.api.js";
200
190
 
201
191
  // ─── Globals ──────────────────────────────────────────────────────────────────
@@ -740,8 +730,7 @@ litRender(
740
730
  datalistHost,
741
731
  );
742
732
 
743
- /** Map<camelCaseName, initialValue> for placeholder hints */
744
- const cssInitialMap = new Map(/** @type {any} */ (webdata.cssProps));
733
+ initCssData(webdata);
745
734
 
746
735
  // Persistent render hosts for lit-html (must be before bootstrap/render)
747
736
  let zoomIndicatorHost = document.createElement("div");
@@ -798,15 +787,37 @@ overlaysPanel.mount({
798
787
 
799
788
  rightPanelMod.mount({
800
789
  propertiesSidebarTemplate,
801
- renderStylePanelTemplate,
790
+ getCanvasMode: () => canvasMode,
802
791
  renderCanvas: () => renderCanvas(),
803
792
  updateForcedPseudoPreview,
804
793
  });
805
794
 
795
+ leftPanelMod.mount({
796
+ getCanvasMode: () => canvasMode,
797
+ renderImportsTemplate,
798
+ renderFilesTemplate,
799
+ renderSignalsTemplate,
800
+ renderDataExplorerTemplate,
801
+ renderHeadTemplate,
802
+ renderGitPanel,
803
+ renderCanvas: () => renderCanvas(),
804
+ defCategory,
805
+ defBadgeLabel,
806
+ navigateToComponent,
807
+ selectStylebookTag,
808
+ stylebookMeta,
809
+ webdata,
810
+ defaultDef,
811
+ registerLayersDnD,
812
+ registerElementsDnD,
813
+ registerComponentsDnD,
814
+ setupTreeKeyboard,
815
+ });
816
+
806
817
  // Register all renderers with the store so render()/renderOnly() work
807
818
  registerRenderer("toolbar", () => toolbarPanel.render());
808
819
  registerRenderer("activityBar", () => renderActivityBar(S));
809
- registerRenderer("leftPanel", () => renderLeftPanel());
820
+ registerRenderer("leftPanel", () => leftPanelMod.render());
810
821
  registerRenderer("canvas", () => renderCanvas());
811
822
  registerRenderer("rightPanel", () => rightPanelMod.render());
812
823
  registerRenderer("overlays", () => overlaysPanel.render());
@@ -815,20 +826,7 @@ setStatusbarRenderer(() => renderStatusbar(S));
815
826
  mountStatusbar();
816
827
 
817
828
  function safeRenderLeftPanel() {
818
- try {
819
- ensureLitState(leftPanel);
820
- renderLeftPanel();
821
- } catch (e) {
822
- console.error("renderLeftPanel error:", e);
823
- try {
824
- leftPanel.textContent = "";
825
- // @ts-ignore
826
- delete leftPanel["_$litPart$"];
827
- renderLeftPanel();
828
- } catch (e2) {
829
- console.error("renderLeftPanel retry failed:", e2);
830
- }
831
- }
829
+ leftPanelMod.render();
832
830
  }
833
831
 
834
832
  function safeRenderRightPanel() {
@@ -3193,331 +3191,13 @@ function handleComponentSlashSelect(cmd) {
3193
3191
  update(s);
3194
3192
  }
3195
3193
 
3196
- // ─── Left panel: Layers ───────────────────────────────────────────────────────
3194
+ // ─── Left panel: delegated to panels/left-panel.js ───────────────────────────
3197
3195
 
3198
3196
  function renderLeftPanel() {
3199
- const tab = S.ui.leftTab;
3200
-
3201
- /** @type {any} */
3202
- let content;
3203
- if (tab === "layers")
3204
- content = canvasMode === "settings" ? renderStylebookLayersTemplate() : renderLayersTemplate();
3205
- else if (tab === "imports")
3206
- content = renderImportsTemplate({
3207
- renderLeftPanel,
3208
- documentPath: S.documentPath,
3209
- documentElements: S.document.$elements || [],
3210
- applyMutation: (/** @type {any} */ fn) => {
3211
- S = applyMutation(S, fn);
3212
- update(S);
3213
- },
3214
- });
3215
- else if (tab === "files") content = renderFilesTemplate();
3216
- else if (tab === "blocks") content = renderElementsTemplate();
3217
- else if (tab === "state")
3218
- content = renderSignalsTemplate(S, { renderLeftPanel, renderCanvas, updateSession });
3219
- else if (tab === "data")
3220
- content = renderDataExplorerTemplate(S.document.state, view.liveScope, {
3221
- renderCanvas,
3222
- renderLeftPanel,
3223
- defCategory,
3224
- defBadgeLabel,
3225
- });
3226
- else if (tab === "head") {
3227
- // In content mode, title/$head live in S.content.frontmatter, not S.document
3228
- const isContent = S.mode === "content";
3229
- const fm = S.content?.frontmatter ?? {};
3230
- const headDoc = isContent ? { ...S.document, title: fm.title, $head: fm.$head } : S.document;
3231
- content = renderHeadTemplate({
3232
- document: headDoc,
3233
- applyMutation: isContent
3234
- ? (/** @type {any} */ fn) => {
3235
- // Apply mutation to a temporary doc, then sync title/$head back to frontmatter
3236
- const tmp = { title: fm.title, $head: fm.$head ? [...fm.$head] : undefined };
3237
- fn(tmp);
3238
- if (tmp.title !== fm.title) S = updateFrontmatter(S, "title", tmp.title);
3239
- // Always sync $head (may have been created, modified, or emptied)
3240
- const newHead = tmp.$head && tmp.$head.length > 0 ? tmp.$head : undefined;
3241
- S = updateFrontmatter(S, "$head", newHead);
3242
- update(S);
3243
- }
3244
- : (/** @type {any} */ fn) => {
3245
- S = applyMutation(S, fn);
3246
- update(S);
3247
- },
3248
- renderLeftPanel,
3249
- });
3250
- } else content = nothing;
3251
-
3252
- litRender(html`<div class="panel-body">${content}</div>`, /** @type {any} */ (leftPanel));
3253
-
3254
- // Post-render side effects
3255
- if (tab === "layers" && canvasMode !== "settings") registerLayersDnD();
3256
- else if (tab === "imports") {
3257
- /* no post-render DnD needed */
3258
- } else if (tab === "blocks") {
3259
- registerElementsDnD();
3260
- registerComponentsDnD();
3261
- } else if (tab === "files") {
3262
- const tree = /** @type {any} */ (leftPanel)?.querySelector(".file-tree");
3263
- if (tree) setupTreeKeyboard(tree);
3264
- }
3197
+ leftPanelMod.render();
3265
3198
  }
3266
3199
 
3267
- /** Returns a TemplateResult called from renderLeftPanel only when tab=layers & not stylebook */
3268
- function renderLayersTemplate() {
3269
- // Clean up previous DnD registrations
3270
- for (const fn of view.dndCleanups) fn();
3271
- view.dndCleanups = [];
3272
-
3273
- const rows = flattenTree(S.document);
3274
- const collapsed = S._collapsed || (S._collapsed = new Set());
3275
-
3276
- // Build layer rows
3277
- /** @type {any[]} */
3278
- const layerRows = [];
3279
- for (const { node, path, depth, nodeType } of rows) {
3280
- // Check if any ancestor is collapsed
3281
- let hidden = false;
3282
- for (let d = 1; d <= path.length; d++) {
3283
- const sub = path.slice(0, d);
3284
- if (d < path.length && collapsed.has(pathKey(sub))) {
3285
- hidden = true;
3286
- break;
3287
- }
3288
- }
3289
- if (hidden) continue;
3290
-
3291
- // In content mode, skip the document root row (it's not a real element)
3292
- if (S.mode === "content" && path.length === 0) continue;
3293
-
3294
- // Text node children: display-only row with truncated preview
3295
- if (nodeType === "text") {
3296
- const textPreview = String(node).length > 40 ? String(node).slice(0, 40) + "…" : String(node);
3297
- layerRows.push(html`
3298
- <div
3299
- class="layer-row"
3300
- style="padding-left:${depth * 16 + 8}px; opacity: 0.6; font-style: italic;"
3301
- >
3302
- <span class="layer-tag" style="background: #64748b; font-size: 0.65rem;">text</span>
3303
- <span class="layer-label">${textPreview}</span>
3304
- </div>
3305
- `);
3306
- continue;
3307
- }
3308
-
3309
- // Skip inline elements
3310
- if (path.length >= 2 && nodeType === "element") {
3311
- const pPath = parentElementPath(path);
3312
- const parentNode = pPath ? getNodeAtPath(S.document, pPath) : null;
3313
- if (parentNode && isInlineElement(node, parentNode)) continue;
3314
- }
3315
-
3316
- const key = pathKey(path);
3317
- const isSelected = pathsEqual(path, S.selection);
3318
- const hasChildren = Array.isArray(node.children) && node.children.length > 0;
3319
- const hasMapChildren =
3320
- node.children && typeof node.children === "object" && node.children.$prototype === "Array";
3321
- const hasCases =
3322
- node.$switch &&
3323
- node.cases &&
3324
- typeof node.cases === "object" &&
3325
- Object.keys(node.cases).length > 0;
3326
- const isExpandable =
3327
- hasChildren || hasMapChildren || hasCases || (nodeType === "map" && node.map);
3328
- const isVoidEl = VOID_ELEMENTS.has((node.tagName || "div").toLowerCase());
3329
-
3330
- // Badge
3331
- /** @type {any} */
3332
- let badgeClass, badgeText, badgeTitle;
3333
- if (nodeType === "map") {
3334
- badgeClass = "layer-tag map-tag";
3335
- badgeText = "↻";
3336
- badgeTitle = "Repeater (mapped array)";
3337
- } else if (nodeType === "case" || nodeType === "case-ref") {
3338
- badgeClass = "layer-tag case-tag";
3339
- badgeText = path[path.length - 1];
3340
- badgeTitle = `$switch case: ${path[path.length - 1]}`;
3341
- } else if (node.$switch) {
3342
- badgeClass = "layer-tag switch-tag";
3343
- badgeText = "⇄";
3344
- badgeTitle = "$switch";
3345
- } else {
3346
- badgeClass = "layer-tag";
3347
- badgeText = node.tagName || "div";
3348
- badgeTitle = undefined;
3349
- }
3350
-
3351
- // Label
3352
- /** @type {any} */
3353
- let labelText, labelItalic;
3354
- if (nodeType === "case-ref") {
3355
- labelText = node.$ref || "external";
3356
- labelItalic = true;
3357
- } else {
3358
- labelText = nodeLabel(node);
3359
- labelItalic = false;
3360
- }
3361
-
3362
- // Compute move-button availability for element nodes
3363
- const isElement = nodeType === "element";
3364
- const isRoot = S.mode === "content" ? path.length === 0 : path.length < 2;
3365
- const idx = isElement ? /** @type {number} */ (childIndex(path)) : 0;
3366
- const parentPath = isElement && !isRoot ? /** @type {any} */ (parentElementPath(path)) : null;
3367
- const parentNode = parentPath ? getNodeAtPath(S.document, parentPath) : null;
3368
- const siblingCount = parentNode?.children?.length || 0;
3369
- const canMoveUp = isElement && !isRoot && idx > 0;
3370
- const canMoveDown = isElement && !isRoot && idx < siblingCount - 1;
3371
- // "in" = move into the previous sibling (become its last child)
3372
- const prevSibling = canMoveUp && parentNode ? parentNode.children[idx - 1] : null;
3373
- const canMoveIn =
3374
- isElement &&
3375
- !isRoot &&
3376
- prevSibling &&
3377
- !VOID_ELEMENTS.has((prevSibling.tagName || "div").toLowerCase());
3378
- // "out" = move out of parent to grandparent (after parent)
3379
- const grandparentPath =
3380
- isElement && parentPath && parentPath.length >= 2
3381
- ? /** @type {any} */ (parentElementPath(parentPath))
3382
- : null;
3383
- const canMoveOut = isElement && !isRoot && !!grandparentPath;
3384
-
3385
- layerRows.push(html`
3386
- <div
3387
- class="layer-row${isSelected ? " selected" : ""}"
3388
- data-path=${key}
3389
- data-dnd-row=${isElement ? key : nothing}
3390
- data-dnd-depth=${isElement ? depth : nothing}
3391
- data-dnd-void=${isElement && isVoidEl ? "" : nothing}
3392
- @click=${() => update(selectNode(S, path))}
3393
- @contextmenu=${isElement
3394
- ? (/** @type {any} */ e) =>
3395
- showContextMenu(e, path, S, { onEditComponent: navigateToComponent })
3396
- : nothing}
3397
- >
3398
- <span class="layer-indent" style="width:${depth * 16}px"></span>
3399
- <span class="layer-toggle"
3400
- >${isExpandable
3401
- ? html`
3402
- ${collapsed.has(key)
3403
- ? html`<sp-icon-chevron-right></sp-icon-chevron-right>`
3404
- : html`<sp-icon-chevron-down></sp-icon-chevron-down>`}
3405
- `
3406
- : nothing}</span
3407
- >
3408
- <span class=${badgeClass} title=${badgeTitle ?? nothing}>${badgeText}</span>
3409
- <span class="layer-label" style=${labelItalic ? "font-style:italic" : nothing}
3410
- >${labelText}</span
3411
- >
3412
- ${isElement && !isRoot
3413
- ? html`
3414
- <span class="layer-actions">
3415
- ${canMoveUp
3416
- ? html`<sp-action-button
3417
- quiet
3418
- size="xs"
3419
- title="Move up"
3420
- @click=${(/** @type {any} */ e) => {
3421
- e.stopPropagation();
3422
- /** @type {HTMLElement} */ (e.currentTarget).blur();
3423
- update(moveNode(S, path, parentPath, idx - 1));
3424
- }}
3425
- >
3426
- <sp-icon-arrow-up slot="icon"></sp-icon-arrow-up>
3427
- </sp-action-button>`
3428
- : nothing}
3429
- ${canMoveDown
3430
- ? html`<sp-action-button
3431
- quiet
3432
- size="xs"
3433
- title="Move down"
3434
- @click=${(/** @type {any} */ e) => {
3435
- e.stopPropagation();
3436
- /** @type {HTMLElement} */ (e.currentTarget).blur();
3437
- update(moveNode(S, path, parentPath, idx + 1));
3438
- }}
3439
- >
3440
- <sp-icon-arrow-down slot="icon"></sp-icon-arrow-down>
3441
- </sp-action-button>`
3442
- : nothing}
3443
- ${canMoveIn
3444
- ? html`<sp-action-button
3445
- quiet
3446
- size="xs"
3447
- title="Move into previous sibling"
3448
- @click=${(/** @type {any} */ e) => {
3449
- e.stopPropagation();
3450
- /** @type {HTMLElement} */ (e.currentTarget).blur();
3451
- const prevPath = [...parentPath, idx - 1];
3452
- const prev = getNodeAtPath(S.document, prevPath);
3453
- const len = prev?.children?.length || 0;
3454
- update(moveNode(S, path, prevPath, len));
3455
- }}
3456
- >
3457
- <sp-icon-arrow-right slot="icon"></sp-icon-arrow-right>
3458
- </sp-action-button>`
3459
- : nothing}
3460
- ${canMoveOut
3461
- ? html`<sp-action-button
3462
- quiet
3463
- size="xs"
3464
- title="Move out of parent"
3465
- @click=${(/** @type {any} */ e) => {
3466
- e.stopPropagation();
3467
- /** @type {HTMLElement} */ (e.currentTarget).blur();
3468
- const parentIdx = /** @type {number} */ (childIndex(parentPath));
3469
- update(moveNode(S, path, grandparentPath, parentIdx + 1));
3470
- }}
3471
- >
3472
- <sp-icon-arrow-left slot="icon"></sp-icon-arrow-left>
3473
- </sp-action-button>`
3474
- : nothing}
3475
- <sp-action-button
3476
- quiet
3477
- size="xs"
3478
- class="layer-delete"
3479
- title="Delete"
3480
- @click=${(/** @type {any} */ e) => {
3481
- e.stopPropagation();
3482
- update(removeNode(S, path));
3483
- }}
3484
- >
3485
- <sp-icon-close slot="icon"></sp-icon-close>
3486
- </sp-action-button>
3487
- </span>
3488
- `
3489
- : nothing}
3490
- </div>
3491
- `);
3492
-
3493
- // Collapse toggle click handler — we add it via event delegation on the layer-toggle span
3494
- // It's already in the template above as the toggle span, but we need the click handler
3495
- }
3496
-
3497
- return html`
3498
- <div class="layers-container" style="position:relative">
3499
- <div
3500
- class="layers-tree"
3501
- @click=${(/** @type {any} */ e) => {
3502
- const toggle = e.target.closest(".layer-toggle");
3503
- if (!toggle) return;
3504
- e.stopPropagation();
3505
- const row = toggle.closest(".layer-row");
3506
- if (!row) return;
3507
- const key = row.dataset.path;
3508
- if (!key) return;
3509
- if (collapsed.has(key)) collapsed.delete(key);
3510
- else collapsed.add(key);
3511
- renderLeftPanel();
3512
- }}
3513
- >
3514
- ${layerRows}
3515
- </div>
3516
- </div>
3517
- `;
3518
- }
3519
-
3520
- /** Register DnD on layer rows after litRender — called from renderLeftPanel */
3200
+ /** Register DnD on layer rows after litRender called from left-panel.js */
3521
3201
  function registerLayersDnD() {
3522
3202
  requestAnimationFrame(() => {
3523
3203
  const container = /** @type {any} */ (leftPanel)?.querySelector(".layers-container");
@@ -3730,95 +3410,6 @@ function selectStylebookTag(tag, media) {
3730
3410
  });
3731
3411
  }
3732
3412
 
3733
- function renderStylebookLayersTemplate() {
3734
- const rootStyle = S.document?.style || {};
3735
- const selectedTag = S.ui.stylebookSelection;
3736
-
3737
- if (S.ui.stylebookTab === "elements") {
3738
- /**
3739
- * Render a stylebook entry row with recursive children.
3740
- *
3741
- * @param {any} entry
3742
- * @param {number} depth
3743
- * @returns {any}
3744
- */
3745
- const renderEntryRow = (entry, depth = 0) => {
3746
- const tag = entry.tag;
3747
- // Deduplicate children by tag (e.g. multiple <li> → show one "li" row)
3748
- const uniqueChildren = entry.children
3749
- ? [...new Map(entry.children.map((/** @type {any} */ c) => [c.tag, c])).values()]
3750
- : [];
3751
- return html`
3752
- <div
3753
- class="layer-row${tag === selectedTag ? " selected" : ""}"
3754
- style="padding-left:${8 + depth * 16}px"
3755
- @click=${(/** @type {any} */ e) => {
3756
- e.stopPropagation();
3757
- selectStylebookTag(tag);
3758
- }}
3759
- >
3760
- <span class="layer-tag">${tag}</span>
3761
- <span
3762
- class="layer-label"
3763
- style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1"
3764
- >${entry.text || `<${tag}>`}</span
3765
- >
3766
- ${hasTagStyle(rootStyle, tag)
3767
- ? html`<span
3768
- style="width:6px;height:6px;border-radius:50%;background:var(--accent);flex-shrink:0"
3769
- ></span>`
3770
- : nothing}
3771
- </div>
3772
- ${uniqueChildren.map((/** @type {any} */ child) => renderEntryRow(child, depth + 1))}
3773
- `;
3774
- };
3775
-
3776
- /** @type {any[]} */
3777
- const elementRows = [];
3778
- for (const section of stylebookMeta.$sections) {
3779
- for (const entry of /** @type {any[]} */ (section.elements)) {
3780
- elementRows.push(renderEntryRow(entry, 0));
3781
- }
3782
- }
3783
- // Custom components
3784
- const compRows = componentRegistry.map(
3785
- /** @param {any} comp */ (comp) => html`
3786
- <div
3787
- class="layer-row${comp.tagName === selectedTag ? " selected" : ""}"
3788
- @click=${() => selectStylebookTag(comp.tagName)}
3789
- >
3790
- <span class="layer-tag component-tag" style="background:var(--accent)">⬡</span>
3791
- <span class="layer-label">${comp.tagName}</span>
3792
- </div>
3793
- `,
3794
- );
3795
- return html`${elementRows}${compRows}`;
3796
- } else {
3797
- // Variables tab
3798
- const style = rootStyle;
3799
- const vars = Object.entries(style).filter(([k]) => k.startsWith("--"));
3800
- if (vars.length === 0) {
3801
- return html`<div style="padding:16px;text-align:center;color:var(--fg-dim);font-size:12px">
3802
- No variables defined
3803
- </div>`;
3804
- }
3805
- return html`${vars.map(
3806
- ([k, v]) => html`
3807
- <div class="layer-row">
3808
- <span class="layer-tag" style="font-size:10px;font-family:'SF Mono','Fira Code',monospace"
3809
- >var</span
3810
- >
3811
- <span class="layer-label">${k}</span>
3812
- <span
3813
- style="font-size:11px;color:var(--fg-dim);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:80px"
3814
- >${String(v)}</span
3815
- >
3816
- </div>
3817
- `,
3818
- )}`;
3819
- }
3820
- }
3821
-
3822
3413
  /**
3823
3414
  * Apply a DnD instruction to the state
3824
3415
  *
@@ -3977,142 +3568,6 @@ function defaultDef(tag) {
3977
3568
 
3978
3569
  const unsafeTags = new Set(["script", "style", "link", "iframe", "object", "embed"]);
3979
3570
 
3980
- function renderElementsTemplate() {
3981
- const categories = Object.entries(webdata.elements).map(
3982
- (/** @type {any} */ [category, elements]) => {
3983
- const filtered = view.elementsFilter
3984
- ? elements.filter((/** @type {any} */ e) => e.tag.includes(view.elementsFilter))
3985
- : elements;
3986
- if (filtered.length === 0) return nothing;
3987
-
3988
- return html`
3989
- <sp-accordion-item
3990
- label=${category}
3991
- ?open=${!view.elementsCollapsed.has(category)}
3992
- @sp-accordion-item-toggle=${(/** @type {any} */ e) => {
3993
- if (e.target.open) view.elementsCollapsed.delete(category);
3994
- else view.elementsCollapsed.add(category);
3995
- }}
3996
- >
3997
- ${filtered.map((/** @type {any} */ { tag }) => {
3998
- const def = defaultDef(tag);
3999
- return html`
4000
- <div
4001
- class="element-card"
4002
- data-block-tag=${tag}
4003
- @click=${() => {
4004
- const parentPath = S.selection || [];
4005
- const parent = getNodeAtPath(S.document, parentPath);
4006
- const idx = parent?.children ? parent.children.length : 0;
4007
- update(insertNode(S, parentPath, idx, structuredClone(def)));
4008
- }}
4009
- >
4010
- <div class="element-card-preview"></div>
4011
- <div class="element-card-label">&lt;${tag}&gt;</div>
4012
- </div>
4013
- `;
4014
- })}
4015
- </sp-accordion-item>
4016
- `;
4017
- },
4018
- );
4019
-
4020
- // Components from the component registry — only show enabled (imported) npm components
4021
- const effectiveEls = getEffectiveElements(S.document?.$elements);
4022
- /** @type {Set<string>} */
4023
- const enabledTags = new Set();
4024
- for (const entry of effectiveEls) {
4025
- if (typeof entry !== "string") continue;
4026
- // Cherry-picked subpath: match by package + modulePath
4027
- const comp = componentRegistry.find(
4028
- (/** @type {any} */ c) =>
4029
- c.source === "npm" && c.modulePath && entry === `${c.package}/${c.modulePath}`,
4030
- );
4031
- if (comp) {
4032
- enabledTags.add(comp.tagName);
4033
- } else {
4034
- // Legacy full-package import: enable all components from that package
4035
- for (const c of componentRegistry) {
4036
- if (c.source === "npm" && c.package === entry) enabledTags.add(c.tagName);
4037
- }
4038
- }
4039
- }
4040
- const compsFiltered =
4041
- componentRegistry.length > 0
4042
- ? componentRegistry
4043
- .filter((/** @type {any} */ c) => c.source !== "npm" || enabledTags.has(c.tagName))
4044
- .filter(
4045
- (/** @type {any} */ c) =>
4046
- !view.elementsFilter || c.tagName.toLowerCase().includes(view.elementsFilter),
4047
- )
4048
- : [];
4049
-
4050
- const componentsAccordion =
4051
- compsFiltered.length > 0
4052
- ? html`
4053
- <sp-accordion-item
4054
- label="Components"
4055
- ?open=${!view.elementsCollapsed.has("Components")}
4056
- @sp-accordion-item-toggle=${(/** @type {any} */ e) => {
4057
- if (e.target.open) view.elementsCollapsed.delete("Components");
4058
- else view.elementsCollapsed.add("Components");
4059
- }}
4060
- >
4061
- <div class="components-section">
4062
- ${compsFiltered.map(
4063
- (/** @type {any} */ comp) => html`
4064
- <div
4065
- class="element-card"
4066
- data-component-tag=${comp.tagName}
4067
- title=${comp.source === "npm"
4068
- ? `${comp.package}: <${comp.tagName}>`
4069
- : comp.path}
4070
- @click=${() => {
4071
- const parentPath = S.selection || [];
4072
- const parent = getNodeAtPath(S.document, parentPath);
4073
- const idx = parent?.children ? parent.children.length : 0;
4074
- const instanceDef = {
4075
- tagName: comp.tagName,
4076
- $props: Object.fromEntries(
4077
- (comp.props || []).map((/** @type {any} */ p) => [
4078
- p.name,
4079
- p.default !== undefined ? p.default : "",
4080
- ]),
4081
- ),
4082
- };
4083
- update(insertNode(S, parentPath, idx, structuredClone(instanceDef)));
4084
- }}
4085
- >
4086
- <div class="element-card-preview">
4087
- <span style="color:var(--fg-dim);font-size:11px;font-style:italic"
4088
- >&lt;${comp.tagName}&gt;</span
4089
- >
4090
- </div>
4091
- <div class="element-card-label">${comp.tagName}</div>
4092
- </div>
4093
- `,
4094
- )}
4095
- </div>
4096
- </sp-accordion-item>
4097
- `
4098
- : nothing;
4099
-
4100
- return html`
4101
- <sp-search
4102
- size="s"
4103
- placeholder="Filter elements…"
4104
- value=${view.elementsFilter}
4105
- @input=${(/** @type {any} */ e) => {
4106
- view.elementsFilter = e.target.value.toLowerCase();
4107
- renderLeftPanel();
4108
- }}
4109
- ></sp-search>
4110
- <sp-accordion class="elements-list" allow-multiple
4111
- >${componentsAccordion}${categories}</sp-accordion
4112
- >
4113
- `;
4114
- }
4115
-
4116
3571
  function registerElementsDnD() {
4117
3572
  requestAnimationFrame(() => {
4118
3573
  const container = /** @type {any} */ (leftPanel)?.querySelector(".panel-body");
@@ -6284,915 +5739,8 @@ function mediaBreakpointRowTemplate(/** @type {any} */ name, /** @type {any} */
6284
5739
 
6285
5740
  // inferInputType — imported from studio-utils.js
6286
5741
 
6287
- function conditionPasses(/** @type {any} */ cond, /** @type {any} */ styles) {
6288
- const val = styles[cond.prop] ?? "";
6289
- if (cond.values.length === 0) return val !== "" && val !== "initial";
6290
- return cond.values.includes(val);
6291
- }
6292
-
6293
- function allConditionsPass(/** @type {any} */ entry, /** @type {any} */ styles) {
6294
- return (entry.$show ?? []).every((/** @type {any} */ c) => conditionPasses(c, styles));
6295
- }
6296
-
6297
- function autoOpenSections(/** @type {any} */ node, /** @type {any} */ currentSections) {
6298
- const style = node.style || {};
6299
- const result = { ...currentSections };
6300
- for (const prop of Object.keys(style)) {
6301
- if (typeof style[prop] === "object") continue;
6302
- const entry = /** @type {Record<string, any>} */ (cssMeta.$defs)[prop];
6303
- const section = entry?.$section ?? "other";
6304
- if (!result[section]) result[section] = true;
6305
- }
6306
- return result;
6307
- }
6308
-
6309
- /** Get longhands for a shorthand property from css-meta */
6310
- function getLonghands(/** @type {any} */ shorthandProp) {
6311
- // Check for explicit $longhands array first (used by border-side shorthands)
6312
- const entry = /** @type {Record<string, any>} */ (cssMeta.$defs)[shorthandProp];
6313
- if (entry?.$longhands) {
6314
- return entry.$longhands
6315
- .map((/** @type {string} */ name) => ({
6316
- name,
6317
- entry: /** @type {Record<string, any>} */ (cssMeta.$defs)[name] || { $order: 0 },
6318
- }))
6319
- .sort((/** @type {any} */ a, /** @type {any} */ b) => a.entry.$order - b.entry.$order);
6320
- }
6321
- // Fallback: reverse-lookup by $shorthand reference
6322
- const result = [];
6323
- for (const [name, e] of /** @type {[string, any][]} */ (Object.entries(cssMeta.$defs))) {
6324
- if (e.$shorthand === shorthandProp) result.push({ name, entry: e });
6325
- }
6326
- result.sort((a, b) => a.entry.$order - b.entry.$order);
6327
- return result;
6328
- }
6329
-
6330
- /**
6331
- * Expand a CSS shorthand value (margin, padding, borderWidth, borderRadius) into individual
6332
- * longhand values following the standard 1–4 value TRBL pattern. Returns an array matching the
6333
- * longhand count (always 4 for box properties).
6334
- */
6335
- function expandShorthand(/** @type {string} */ shortVal, /** @type {number} */ count) {
6336
- if (!shortVal) return Array(count).fill("");
6337
- const parts = shortVal.trim().split(/\s+/);
6338
- if (count !== 4 || parts.length === 0) return Array(count).fill("");
6339
- if (parts.length === 1) return [parts[0], parts[0], parts[0], parts[0]];
6340
- if (parts.length === 2) return [parts[0], parts[1], parts[0], parts[1]];
6341
- if (parts.length === 3) return [parts[0], parts[1], parts[2], parts[1]];
6342
- return [parts[0], parts[1], parts[2], parts[3]];
6343
- }
6344
-
6345
- /**
6346
- * Compress 4 TRBL values back into the shortest valid CSS shorthand string. e.g.
6347
- * ["0","auto","3rem","auto"] → "0 auto 3rem"
6348
- */
6349
- function compressShorthand(/** @type {string[]} */ vals) {
6350
- const [t, r, b, l] = vals;
6351
- if (t === r && r === b && b === l) return t;
6352
- if (t === b && r === l) return `${t} ${r}`;
6353
- if (r === l) return `${t} ${r} ${b}`;
6354
- return `${t} ${r} ${b} ${l}`;
6355
- }
6356
-
6357
- // ─── Border-side shorthand parsing ────────────────────────────────────────────
6358
- // CSS border-side shorthand: <width> || <style> || <color> (any order, all optional)
6359
-
6360
- const BORDER_STYLES = new Set([
6361
- "none",
6362
- "solid",
6363
- "dashed",
6364
- "dotted",
6365
- "double",
6366
- "groove",
6367
- "ridge",
6368
- "inset",
6369
- "outset",
6370
- "hidden",
6371
- ]);
6372
-
6373
- /**
6374
- * Parse a border-side shorthand value into [width, style, color].
6375
- *
6376
- * @param {string} value — e.g. "1px solid var(--color-border)"
6377
- * @returns {string[]} — [width, style, color]
6378
- */
6379
- function expandBorderSide(value) {
6380
- if (!value) return ["", "", ""];
6381
- // Tokenize respecting parenthesized values like var(...) and rgb(...)
6382
- const tokens = [];
6383
- let current = "";
6384
- let depth = 0;
6385
- for (const ch of value.trim()) {
6386
- if (ch === "(") depth++;
6387
- if (ch === ")") depth--;
6388
- if (ch === " " && depth === 0) {
6389
- if (current) tokens.push(current);
6390
- current = "";
6391
- } else {
6392
- current += ch;
6393
- }
6394
- }
6395
- if (current) tokens.push(current);
6396
-
6397
- let width = "";
6398
- let style = "";
6399
- let color = "";
6400
-
6401
- for (const tok of tokens) {
6402
- if (!style && BORDER_STYLES.has(tok)) {
6403
- style = tok;
6404
- } else if (!width && /^[\d.]/.test(tok)) {
6405
- width = tok;
6406
- } else {
6407
- // Remaining token(s) are color — join in case color was split (shouldn't be with paren-aware tokenizer)
6408
- color = color ? `${color} ${tok}` : tok;
6409
- }
6410
- }
6411
-
6412
- return [width, style, color];
6413
- }
6414
-
6415
- /**
6416
- * Recompose border-side longhand values into a shorthand string.
6417
- *
6418
- * @param {string[]} vals — [width, style, color]
6419
- * @returns {string}
6420
- */
6421
- function compressBorderSide(/** @type {string[]} */ vals) {
6422
- return vals.filter((v) => v && v.trim()).join(" ");
6423
- }
6424
-
6425
- /** Extract --font-* CSS custom properties from the document root style. */
6426
- function getFontVars() {
6427
- const style = S.document?.style;
6428
- if (!style) return [];
6429
- const vars = [];
6430
- for (const [k, v] of Object.entries(style)) {
6431
- if (k.startsWith("--font") && (typeof v === "string" || typeof v === "number")) {
6432
- vars.push({ name: k, value: String(v) });
6433
- }
6434
- }
6435
- return vars;
6436
- }
6437
-
6438
- /** Typography CSS properties that should preview their values in-menu */
6439
- const TYPO_PREVIEW_PROPS = new Set(["fontStyle", "fontVariant", "textTransform", "textDecoration"]);
6440
-
6441
- // camelToKebab — imported from studio-utils.js
6442
-
6443
- /** Resolve the current font family for typography preview (handles var() references) */
6444
- function currentFontFamily() {
6445
- const node = S.selection ? getNodeAtPath(S.document, S.selection) : null;
6446
- const raw = node?.style?.fontFamily;
6447
- if (!raw) return "";
6448
- const m = typeof raw === "string" && raw.match(/^var\((--[^)]+)\)$/);
6449
- if (m) return S.document?.style?.[m[1]] || "";
6450
- return raw;
6451
- }
6452
-
6453
- /**
6454
- * Dual-mode keyword input — shared by select (enum) and combobox (examples) widgets.
6455
- *
6456
- * If the current value is one of the predefined options → renders as sp-picker with Title Case
6457
- * labels (and typography preview when applicable). Selecting "—" clears the value, which flips to
6458
- * combobox mode.
6459
- *
6460
- * If the value is empty or a custom string → renders as sp-combobox with predefined options in its
6461
- * dropdown. Selecting one flips to picker mode.
6462
- *
6463
- * Note: sp-combobox recreates items in shadow DOM as plain text, so typography preview props use a
6464
- * manual sp-textfield + sp-overlay + sp-menu instead.
6465
- *
6466
- * @param {any} options @param {any} prop @param {any} value @param {any} onChange
6467
- */
6468
- function renderKeywordInput(options, prop, value, onChange) {
6469
- const isTypoPreview = TYPO_PREVIEW_PROPS.has(prop) || prop === "fontWeight";
6470
- const font = isTypoPreview ? currentFontFamily() : "";
6471
- const cssProp = isTypoPreview ? camelToKebab(prop) : "";
6472
-
6473
- const comboOptions = options.map((/** @type {any} */ v) => {
6474
- const label = v.includes("-")
6475
- ? kebabToLabel(v)
6476
- : v.replace(/^./, (/** @type {any} */ c) => c.toUpperCase());
6477
- const style = isTypoPreview ? `${cssProp}: ${v};${font ? ` font-family: ${font}` : ""}` : "";
6478
- return { value: v, label, style };
6479
- });
6480
-
6481
- return html`<jx-value-selector
6482
- size="s"
6483
- .value=${value || ""}
6484
- placeholder=${cssInitialMap.get(prop) || ""}
6485
- .options=${comboOptions}
6486
- @change=${(/** @type {any} */ e) => onChange(e.target.value)}
6487
- @input=${debouncedStyleCommit(`kw:${prop}`, 400, (/** @type {any} */ e) =>
6488
- onChange(e.target.value),
6489
- )}
6490
- ></jx-value-selector>`;
6491
- }
6492
-
6493
- function renderSelectInput(
6494
- /** @type {any} */ entry,
6495
- /** @type {any} */ prop,
6496
- /** @type {any} */ value,
6497
- /** @type {any} */ onChange,
6498
- ) {
6499
- return renderKeywordInput(entry.enum || [], prop, value, onChange);
6500
- }
6501
-
6502
- function handleFontPresetSelection(/** @type {any} */ preset, /** @type {any} */ onChange) {
6503
- const varName = friendlyNameToVar(preset.title, "--font-");
6504
- if (!S.document?.style?.[varName]) {
6505
- S = updateStyle(S, [], varName, preset.value);
6506
- }
6507
- onChange(`var(${varName})`);
6508
- }
6509
-
6510
- function handleFontSelection(
6511
- /** @type {any} */ val,
6512
- /** @type {any} */ presets,
6513
- /** @type {any} */ onChange,
6514
- ) {
6515
- if (!val) return;
6516
- // sp-picker returns the option's value attribute (prefixed or var name)
6517
- if (val.startsWith("__preset__:")) {
6518
- const title = val.slice("__preset__:".length);
6519
- const preset = presets.find((/** @type {any} */ p) => p.title === title);
6520
- if (preset) handleFontPresetSelection(preset, onChange);
6521
- return;
6522
- }
6523
- if (val.startsWith("--")) {
6524
- onChange(`var(${val})`);
6525
- return;
6526
- }
6527
- // sp-combobox returns display text — match against preset titles and font var
6528
- // display names before falling through to plain text
6529
- const preset = presets.find((/** @type {any} */ p) => p.title === val);
6530
- if (preset) {
6531
- handleFontPresetSelection(preset, onChange);
6532
- return;
6533
- }
6534
- const fontVars = getFontVars();
6535
- const matchedVar = fontVars.find(
6536
- (/** @type {any} */ fv) => varDisplayName(fv.name, "--font-") === val,
6537
- );
6538
- if (matchedVar) {
6539
- onChange(`var(${matchedVar.name})`);
6540
- return;
6541
- }
6542
- // Plain font family string (e.g. "serif", "Arial, sans-serif")
6543
- onChange(val);
6544
- }
6545
-
6546
- /**
6547
- * Build font options array for jx-value-selector. Local font vars first, divider, then unadded
6548
- * presets.
6549
- *
6550
- * @param {any[]} fontVars @param {any[]} presets
6551
- * @returns {{ value: string; label: string; style: string }[] | { divider: true }[]}
6552
- */
6553
- function buildFontOptions(fontVars, presets) {
6554
- /** @type {any[]} */
6555
- const opts = fontVars.map((/** @type {any} */ fv) => ({
6556
- value: fv.name,
6557
- label: varDisplayName(fv.name, "--font-"),
6558
- style: `font-family: ${fv.value}`,
6559
- }));
6560
- const unadded = presets.filter(
6561
- (/** @type {any} */ p) =>
6562
- !fontVars.some((/** @type {any} */ fv) => fv.name === friendlyNameToVar(p.title, "--font-")),
6563
- );
6564
- if (unadded.length > 0 && opts.length > 0) opts.push({ divider: true });
6565
- for (const p of unadded) {
6566
- opts.push({
6567
- value: "__preset__:" + p.title,
6568
- label: p.title,
6569
- style: `font-family: ${p.value}`,
6570
- });
6571
- }
6572
- return opts;
6573
- }
6574
-
6575
- function renderComboboxInput(
6576
- /** @type {any} */ entry,
6577
- /** @type {any} */ prop,
6578
- /** @type {any} */ value,
6579
- /** @type {any} */ onChange,
6580
- ) {
6581
- const fontVars = prop === "fontFamily" ? getFontVars() : [];
6582
- const presets = entry.presets || [];
6583
- const examples = entry.examples || [];
6584
-
6585
- // fontFamily: single jx-value-selector with font options
6586
- if (prop === "fontFamily") {
6587
- // Strip var() wrapper so the component can match the option value
6588
- const varMatch = typeof value === "string" && value.match(/^var\((--[^)]+)\)$/);
6589
- const comboValue = varMatch ? varMatch[1] : value || "";
6590
- const fontOptions = buildFontOptions(fontVars, presets);
6591
- return html`<jx-value-selector
6592
- size="s"
6593
- .value=${comboValue}
6594
- placeholder=${cssInitialMap.get("fontFamily") || ""}
6595
- .options=${fontOptions}
6596
- @change=${(/** @type {any} */ e) => handleFontSelection(e.target.value, presets, onChange)}
6597
- @input=${debouncedStyleCommit("combo:fontFamily", 400, (/** @type {any} */ e) =>
6598
- onChange(e.target.value),
6599
- )}
6600
- ></jx-value-selector>`;
6601
- }
6602
-
6603
- // All other comboboxes: use the shared keyword dual-mode input
6604
- if (examples.length > 0) {
6605
- return renderKeywordInput(examples, prop, value, onChange);
6606
- }
6607
-
6608
- // Fallback: plain textfield (no predefined options)
6609
- return html`
6610
- <sp-textfield
6611
- size="s"
6612
- placeholder=${cssInitialMap.get(prop) || ""}
6613
- .value=${live(value || "")}
6614
- @input=${debouncedStyleCommit(`combo:${prop}`, 400, (/** @type {any} */ e) =>
6615
- onChange(e.target.value),
6616
- )}
6617
- ></sp-textfield>
6618
- `;
6619
- }
6620
-
6621
- // renderNumberInput, renderTextInput — imported from ui/widgets.js
6622
-
6623
- // camelToLabel, kebabToLabel, propLabel, attrLabel — imported from studio-utils.js
6624
-
6625
- function widgetForType(
6626
- /** @type {any} */ type,
6627
- /** @type {any} */ entry,
6628
- /** @type {any} */ prop,
6629
- /** @type {any} */ value,
6630
- /** @type {any} */ onCommit,
6631
- /** @type {any} */ opts = {},
6632
- ) {
6633
- return _widgetForType(type, entry, prop, value, onCommit, {
6634
- placeholder: opts.placeholder || cssInitialMap.get(prop) || "",
6635
- renderSelect: renderSelectInput,
6636
- renderCombobox: renderComboboxInput,
6637
- });
6638
- }
6639
-
6640
- function renderStyleRow(
6641
- /** @type {any} */ entry,
6642
- /** @type {any} */ prop,
6643
- /** @type {any} */ value,
6644
- /** @type {any} */ onCommit,
6645
- /** @type {any} */ onDelete,
6646
- /** @type {any} */ isWarning,
6647
- /** @type {any} */ gridMode,
6648
- /** @type {any} */ inheritedValue,
6649
- ) {
6650
- const type = inferInputType(entry);
6651
- const hasVal = value !== undefined && value !== "";
6652
- const placeholder = !hasVal && inheritedValue ? String(inheritedValue) : "";
6653
- return renderFieldRow({
6654
- prop,
6655
- label: propLabel(entry, prop),
6656
- hasValue: hasVal,
6657
- onClear: onDelete,
6658
- widget: widgetForType(type, entry, prop, value, onCommit, { placeholder }),
6659
- span: gridMode && entry.$span === 2 ? 2 : undefined,
6660
- warning: isWarning,
6661
- });
6662
- }
6663
-
6664
- function renderShorthandRow(
6665
- /** @type {any} */ shortProp,
6666
- /** @type {any} */ entry,
6667
- /** @type {any} */ style,
6668
- /** @type {any} */ commitFn,
6669
- /** @type {any} */ _deleteFn,
6670
- /** @type {Record<string, any>} */ inherited = {},
6671
- ) {
6672
- const longhands = getLonghands(shortProp);
6673
- const shortVal = style[shortProp];
6674
- const hasLonghands = longhands.some((/** @type {any} */ l) => style[l.name] !== undefined);
6675
- const isExpanded = S.ui.styleShorthands[shortProp] ?? hasLonghands;
6676
- const hasAnyVal =
6677
- shortVal !== undefined || longhands.some((/** @type {any} */ l) => style[l.name] !== undefined);
6678
-
6679
- return html`
6680
- <div class="style-row" data-prop=${shortProp}>
6681
- <div class="style-row-label">
6682
- ${hasAnyVal
6683
- ? html`<span
6684
- class="set-dot"
6685
- title="Clear ${shortProp}"
6686
- @click=${(/** @type {any} */ e) => {
6687
- e.stopPropagation();
6688
- let s = S;
6689
- if (shortVal !== undefined) s = commitFn(s, shortProp, undefined);
6690
- for (const l of longhands) {
6691
- if (style[l.name] !== undefined) s = commitFn(s, l.name, undefined);
6692
- }
6693
- update(s);
6694
- }}
6695
- ></span>`
6696
- : nothing}
6697
- <sp-field-label size="s" title=${shortProp}>${propLabel(entry, shortProp)}</sp-field-label>
6698
- </div>
6699
- <div class="style-shorthand-header">
6700
- <sp-textfield
6701
- size="s"
6702
- .value=${live(shortVal || "")}
6703
- placeholder=${!shortVal && hasLonghands
6704
- ? longhands.map((/** @type {any} */ l) => style[l.name] || "0").join(" ")
6705
- : !shortVal && inherited[shortProp]
6706
- ? inherited[shortProp]
6707
- : !shortVal && longhands.some((/** @type {any} */ l) => inherited[l.name])
6708
- ? longhands.map((/** @type {any} */ l) => inherited[l.name] || "0").join(" ")
6709
- : ""}
6710
- @input=${debouncedStyleCommit(`short:${shortProp}`, 400, (/** @type {any} */ e) => {
6711
- let s = S;
6712
- for (const l of longhands) {
6713
- if (style[l.name] !== undefined) s = commitFn(s, l.name, undefined);
6714
- }
6715
- s = commitFn(s, shortProp, e.target.value || undefined);
6716
- update(s);
6717
- })}
6718
- ></sp-textfield>
6719
- <sp-action-button
6720
- size="xs"
6721
- quiet
6722
- @click=${(/** @type {any} */ e) => {
6723
- e.stopPropagation();
6724
- updateUi("styleShorthands", { ...S.ui.styleShorthands, [shortProp]: !isExpanded });
6725
- }}
6726
- >
6727
- ${isExpanded
6728
- ? html`<sp-icon-chevron-down slot="icon"></sp-icon-chevron-down>`
6729
- : html`<sp-icon-chevron-right slot="icon"></sp-icon-chevron-right>`}
6730
- </sp-action-button>
6731
- </div>
6732
- </div>
6733
- ${isExpanded
6734
- ? (() => {
6735
- const isBorderSide = entry.$shorthandType === "border-side";
6736
- const expanded = shortVal
6737
- ? isBorderSide
6738
- ? expandBorderSide(shortVal)
6739
- : expandShorthand(shortVal, longhands.length)
6740
- : null;
6741
- const compress = isBorderSide ? compressBorderSide : compressShorthand;
6742
- const emptyVal = isBorderSide ? "" : "0";
6743
- return longhands.map(
6744
- (/** @type {any} */ { name, entry: lEntry }, /** @type {any} */ idx) => {
6745
- const lVal = style[name] ?? (expanded ? expanded[idx] : "");
6746
- return html`
6747
- <div class="style-row style-row--child" data-prop=${name}>
6748
- <div class="style-row-label">
6749
- ${lVal !== undefined && lVal !== ""
6750
- ? html`<span
6751
- class="set-dot"
6752
- title="Clear ${name}"
6753
- @click=${(/** @type {any} */ e) => {
6754
- e.stopPropagation();
6755
- // Recompose shorthand with this longhand cleared
6756
- const vals = longhands.map(
6757
- (/** @type {any} */ l, /** @type {any} */ i) =>
6758
- i === idx
6759
- ? emptyVal
6760
- : (style[l.name] ?? (expanded ? expanded[i] : emptyVal)),
6761
- );
6762
- let s = S;
6763
- for (const l of longhands) {
6764
- if (style[l.name] !== undefined) s = commitFn(s, l.name, undefined);
6765
- }
6766
- s = commitFn(s, shortProp, compress(vals));
6767
- update(s);
6768
- }}
6769
- ></span>`
6770
- : nothing}
6771
- <sp-field-label size="s" title=${name}
6772
- >${propLabel(lEntry, name)}</sp-field-label
6773
- >
6774
- </div>
6775
- ${widgetForType(
6776
- inferInputType(lEntry),
6777
- lEntry,
6778
- name,
6779
- lVal,
6780
- (/** @type {any} */ newVal) => {
6781
- // Recompose shorthand with this longhand updated
6782
- const vals = longhands.map((/** @type {any} */ l, /** @type {any} */ i) =>
6783
- i === idx
6784
- ? newVal || emptyVal
6785
- : (style[l.name] ?? (expanded ? expanded[i] : emptyVal)),
6786
- );
6787
- let s = S;
6788
- for (const l of longhands) {
6789
- if (style[l.name] !== undefined) s = commitFn(s, l.name, undefined);
6790
- }
6791
- s = commitFn(s, shortProp, compress(vals));
6792
- update(s);
6793
- renderRightPanel();
6794
- },
6795
- { placeholder: !lVal && inherited[name] ? String(inherited[name]) : "" },
6796
- )}
6797
- </div>
6798
- `;
6799
- },
6800
- );
6801
- })()
6802
- : nothing}
6803
- `;
6804
- }
6805
-
6806
- function styleSidebarTemplate(
6807
- /** @type {any} */ node,
6808
- /** @type {any} */ activeMediaTab,
6809
- /** @type {any} */ activeSelector,
6810
- ) {
6811
- const style = node.style || {};
6812
- const { sizeBreakpoints } = parseMediaEntries(getEffectiveMedia(S.document.$media));
6813
- const mediaNames = sizeBreakpoints.map((bp) => bp.name);
6814
- const activeTab = activeMediaTab;
6815
-
6816
- // ── Media tabs template ──────────────────────────────────────────────────
6817
- const mediaTabsT =
6818
- mediaNames.length > 0
6819
- ? html`
6820
- <sp-tabs
6821
- size="s"
6822
- selected=${activeTab || "base"}
6823
- @change=${(/** @type {any} */ e) => {
6824
- const val = e.target.selected;
6825
- const newMedia = val === "base" ? null : val;
6826
- if (newMedia !== S.ui.activeMedia) {
6827
- updateUi("activeMedia", newMedia);
6828
- }
6829
- }}
6830
- >
6831
- <sp-tab label="Base" value="base"></sp-tab>
6832
- ${mediaNames.map(
6833
- (name) => html` <sp-tab label=${mediaDisplayName(name)} value=${name}></sp-tab> `,
6834
- )}
6835
- </sp-tabs>
6836
- `
6837
- : nothing;
6838
-
6839
- // ── Selector dropdown ──────────────────────────────────────────────────────
6840
- const contextStyle = activeTab ? style[`@${activeTab}`] || {} : style;
6841
- const existingSelectors = Object.keys(contextStyle).filter(isNestedSelector);
6842
- const existingSet = new Set(existingSelectors);
6843
- const commonSet = new Set(COMMON_SELECTORS);
6844
- const extraSelectors = existingSelectors.filter((s) => !commonSet.has(s));
6845
- if (activeSelector && !commonSet.has(activeSelector) && !existingSet.has(activeSelector)) {
6846
- extraSelectors.unshift(activeSelector);
6847
- }
6848
-
6849
- const _selectorVal = activeSelector || "__base__";
6850
- const selectorT = html`
6851
- <div class="selector-bar">
6852
- <sp-picker
6853
- class="selector-select"
6854
- .value=${live(_selectorVal)}
6855
- @change=${(/** @type {any} */ e) => {
6856
- const val = e.target.value;
6857
- if (val === "__add_custom__") {
6858
- requestAnimationFrame(() => {
6859
- e.target.value = activeSelector || "__base__";
6860
- });
6861
- // Show inline input — imperative since it's a one-off interaction
6862
- const picker = e.target;
6863
- const bar = picker.closest(".selector-bar");
6864
- picker.style.display = "none";
6865
- const inp = document.createElement("input");
6866
- inp.type = "text";
6867
- inp.className = "selector-custom-input";
6868
- inp.placeholder = ":hover, .child, &.active, [attr]";
6869
- bar.appendChild(inp);
6870
- inp.focus();
6871
- let done = false;
6872
- const finish = (/** @type {any} */ accept) => {
6873
- if (done) return;
6874
- done = true;
6875
- const v = inp.value.trim();
6876
- inp.remove();
6877
- picker.style.display = "";
6878
- if (accept && v && isNestedSelector(v)) {
6879
- updateUi("activeSelector", v);
6880
- }
6881
- };
6882
- inp.addEventListener("keydown", (ev) => {
6883
- if (ev.key === "Enter") finish(true);
6884
- else if (ev.key === "Escape") finish(false);
6885
- });
6886
- inp.addEventListener("blur", () => finish(inp.value.trim().length > 0));
6887
- return;
6888
- }
6889
- const newSelector = val === "__base__" ? null : val;
6890
- updateUi("activeSelector", newSelector);
6891
- }}
6892
- >
6893
- <sp-menu-item value="__base__">(base)</sp-menu-item>
6894
- <sp-menu-divider></sp-menu-divider>
6895
- ${COMMON_SELECTORS.map(
6896
- (s) => html`
6897
- <sp-menu-item value=${s}>${existingSet.has(s) ? `${s} \u25CF` : s}</sp-menu-item>
6898
- `,
6899
- )}
6900
- ${extraSelectors.length > 0
6901
- ? html`
6902
- <sp-menu-divider></sp-menu-divider>
6903
- ${extraSelectors.map((s) => html` <sp-menu-item value=${s}>${s} ●</sp-menu-item> `)}
6904
- `
6905
- : nothing}
6906
- <sp-menu-divider></sp-menu-divider>
6907
- <sp-menu-item value="__add_custom__">+ Add custom…</sp-menu-item>
6908
- </sp-picker>
6909
- </div>
6910
- `;
6911
-
6912
- // ── Determine the active style object ──────────────────────────────────────
6913
- /** @type {Record<string, any>} */
6914
- let activeStyle;
6915
- /** @type {any} */
6916
- let commitStyle;
6917
- if (activeSelector && activeTab && mediaNames.length > 0) {
6918
- activeStyle = (style[`@${activeTab}`] || {})[activeSelector] || {};
6919
- commitStyle = (/** @type {any} */ s, /** @type {any} */ prop, /** @type {any} */ val) =>
6920
- updateMediaNestedStyle(s, S.selection, activeTab, activeSelector, prop, val);
6921
- } else if (activeSelector) {
6922
- activeStyle = style[activeSelector] || {};
6923
- commitStyle = (/** @type {any} */ s, /** @type {any} */ prop, /** @type {any} */ val) =>
6924
- updateNestedStyle(s, S.selection, activeSelector, prop, val);
6925
- } else if (activeTab !== null && mediaNames.length > 0) {
6926
- activeStyle = {};
6927
- for (const [p, v] of Object.entries(style[`@${activeTab}`] || {})) {
6928
- if (typeof v !== "object") activeStyle[p] = v;
6929
- }
6930
- commitStyle = (/** @type {any} */ s, /** @type {any} */ prop, /** @type {any} */ val) =>
6931
- updateMediaStyle(s, S.selection, activeTab, prop, val);
6932
- } else {
6933
- activeStyle = {};
6934
- for (const [p, v] of Object.entries(style)) {
6935
- if (typeof v !== "object") activeStyle[p] = v;
6936
- }
6937
- commitStyle = (/** @type {any} */ s, /** @type {any} */ prop, /** @type {any} */ val) =>
6938
- updateStyle(s, S.selection, prop, val);
6939
- }
6940
-
6941
- // ── Compute inherited style from higher breakpoints ──────────────────────
6942
- /** @type {Record<string, any>} */
6943
- const inheritedStyle = computeInheritedStyle(style, mediaNames, activeTab, activeSelector);
6944
-
6945
- // Auto-open sections that have properties
6946
- const newSections = autoOpenSections({ style: activeStyle }, S.ui.styleSections);
6947
- if (JSON.stringify(newSections) !== JSON.stringify(S.ui.styleSections)) {
6948
- session = { ...session, ui: { ...session.ui, styleSections: newSections } };
6949
- S = toFlat(doc, session);
6950
- }
6951
-
6952
- // Partition properties into sections
6953
- const sectionProps = /** @type {Record<string, any[]>} */ ({});
6954
- for (const sec of cssMeta.$sections) sectionProps[sec.key] = [];
6955
-
6956
- for (const [prop, entry] of /** @type {[string, any][]} */ (Object.entries(cssMeta.$defs))) {
6957
- if (typeof entry.$shorthand === "string") continue;
6958
- const sec = entry.$section || "other";
6959
- sectionProps[sec].push({ prop, entry });
6960
- }
6961
- for (const sec of cssMeta.$sections) {
6962
- sectionProps[sec.key].sort(
6963
- (/** @type {any} */ a, /** @type {any} */ b) => a.entry.$order - b.entry.$order,
6964
- );
6965
- }
6966
-
6967
- const otherProps = [];
6968
- for (const prop of Object.keys(activeStyle)) {
6969
- if (!(/** @type {Record<string, any>} */ (cssMeta.$defs)[prop])) otherProps.push(prop);
6970
- }
6971
-
6972
- // ── Section templates ────────────────────────────────────────────────────
6973
- const sectionTemplates = cssMeta.$sections
6974
- .filter((sec) => sec.key !== "other")
6975
- .map((sec) => {
6976
- const entries = sectionProps[sec.key];
6977
-
6978
- const sectionActiveProps = entries.filter((/** @type {any} */ { prop, entry }) => {
6979
- if (activeStyle[prop] !== undefined) return true;
6980
- if (inferInputType(entry) === "shorthand") {
6981
- return getLonghands(prop).some(
6982
- (/** @type {any} */ l) => activeStyle[l.name] !== undefined,
6983
- );
6984
- }
6985
- return false;
6986
- });
6987
-
6988
- const rows = [];
6989
- for (const { prop, entry } of entries) {
6990
- const val = activeStyle[prop];
6991
- const hasVal = val !== undefined;
6992
- const condMet = allConditionsPass(entry, activeStyle);
6993
- const type = inferInputType(entry);
6994
- if (!hasVal && !condMet) continue;
6995
-
6996
- if (type === "shorthand") {
6997
- const longhands = getLonghands(prop);
6998
- const hasAny =
6999
- hasVal || longhands.some((/** @type {any} */ l) => activeStyle[l.name] !== undefined);
7000
- if (!hasAny && !condMet) continue;
7001
- rows.push(
7002
- renderShorthandRow(prop, entry, activeStyle, commitStyle, () => {}, inheritedStyle),
7003
- );
7004
- } else {
7005
- const isWarning = hasVal && !condMet;
7006
- if (hasVal || condMet) {
7007
- rows.push(
7008
- renderStyleRow(
7009
- entry,
7010
- prop,
7011
- val ?? "",
7012
- (/** @type {any} */ newVal) => update(commitStyle(S, prop, newVal || undefined)),
7013
- () => update(commitStyle(S, prop, undefined)),
7014
- isWarning,
7015
- sec.$layout === "grid",
7016
- inheritedStyle[prop],
7017
- ),
7018
- );
7019
- }
7020
- }
7021
- }
7022
-
7023
- const isOpen = S.ui.styleSections[sec.key] ?? false;
7024
-
7025
- return html`
7026
- <sp-accordion-item
7027
- label=${sec.label}
7028
- .open=${isOpen}
7029
- @sp-accordion-item-toggle=${(/** @type {any} */ e) => {
7030
- updateUi("styleSections", { ...S.ui.styleSections, [sec.key]: e.target.open });
7031
- }}
7032
- >
7033
- ${sectionActiveProps.length > 0
7034
- ? html`
7035
- <span slot="heading" style="display:flex;align-items:center;gap:6px">
7036
- ${sec.label}
7037
- <span
7038
- class="set-dot set-dot--section"
7039
- title="Clear all ${sec.label.toLowerCase()} properties"
7040
- @click=${(/** @type {any} */ e) => {
7041
- e.stopPropagation();
7042
- e.preventDefault();
7043
- let s = S;
7044
- for (const { prop, entry } of sectionActiveProps) {
7045
- if (activeStyle[prop] !== undefined) s = commitStyle(s, prop, undefined);
7046
- if (inferInputType(entry) === "shorthand") {
7047
- for (const l of getLonghands(prop)) {
7048
- if (activeStyle[l.name] !== undefined)
7049
- s = commitStyle(s, l.name, undefined);
7050
- }
7051
- }
7052
- }
7053
- update(s);
7054
- }}
7055
- ></span>
7056
- </span>
7057
- `
7058
- : nothing}
7059
- <div class=${sec.$layout === "grid" ? "style-section-body--grid" : ""}>${rows}</div>
7060
- </sp-accordion-item>
7061
- `;
7062
- });
7063
-
7064
- // ── Custom section ─────────────────────────────────────────────────────────
7065
- const customIsOpen = S.ui.styleSections.other ?? otherProps.length > 0;
7066
- const customSectionT = html`
7067
- <sp-accordion-item
7068
- label="Custom"
7069
- .open=${customIsOpen}
7070
- @sp-accordion-item-toggle=${(/** @type {any} */ e) => {
7071
- updateUi("styleSections", { ...S.ui.styleSections, other: e.target.open });
7072
- }}
7073
- >
7074
- <div>
7075
- ${otherProps.map(
7076
- (prop) => html`
7077
- <div class="kv-row">
7078
- <sp-textfield
7079
- size="s"
7080
- class="kv-key"
7081
- .value=${live(prop)}
7082
- @change=${(/** @type {any} */ e) => {
7083
- const newProp = e.target.value.trim();
7084
- if (newProp && newProp !== prop) {
7085
- let s = commitStyle(S, prop, undefined);
7086
- s = commitStyle(s, newProp, String(activeStyle[prop]));
7087
- update(s);
7088
- }
7089
- }}
7090
- ></sp-textfield>
7091
- <sp-textfield
7092
- size="s"
7093
- class="kv-val"
7094
- .value=${live(String(activeStyle[prop]))}
7095
- placeholder=${ifDefined(cssInitialMap.get(prop))}
7096
- @input=${debouncedStyleCommit(`custom:${prop}`, 400, (/** @type {any} */ e) => {
7097
- update(commitStyle(S, prop, e.target.value));
7098
- })}
7099
- ></sp-textfield>
7100
- <sp-action-button
7101
- size="xs"
7102
- quiet
7103
- @click=${() => update(commitStyle(S, prop, undefined))}
7104
- >
7105
- <sp-icon-close slot="icon"></sp-icon-close>
7106
- </sp-action-button>
7107
- </div>
7108
- `,
7109
- )}
7110
- <div style="display:flex;gap:4px;padding-top:4px">
7111
- <sp-textfield
7112
- size="s"
7113
- placeholder="Property name…"
7114
- style="flex:1"
7115
- @keydown=${(/** @type {any} */ e) => {
7116
- if (e.key === "Enter") {
7117
- e.preventDefault();
7118
- const prop = e.target.value.trim();
7119
- if (prop) {
7120
- const initial = cssInitialMap.get(prop) || "";
7121
- update(commitStyle(S, prop, initial || ""));
7122
- e.target.value = "";
7123
- }
7124
- }
7125
- }}
7126
- ></sp-textfield>
7127
- </div>
7128
- </div>
7129
- </sp-accordion-item>
7130
- `;
7131
-
7132
- return html`
7133
- <div class="style-sidebar">
7134
- ${mediaTabsT} ${selectorT}
7135
- <sp-accordion allow-multiple size="s"> ${sectionTemplates} ${customSectionT} </sp-accordion>
7136
- </div>
7137
- `;
7138
- }
7139
-
7140
- /** Top-level Style panel — returns a lit-html template */
7141
- function renderStylePanelTemplate() {
7142
- if (canvasMode === "settings" && S.ui.stylebookSelection) {
7143
- const node = S.document;
7144
- if (!node) return html`<div class="empty-state">No document loaded</div>`;
7145
- return html`
7146
- <div class="stylebook-style-header">Styling: &lt;${S.ui.stylebookSelection}&gt;</div>
7147
- ${styleSidebarTemplate(node, S.ui.activeMedia, S.ui.activeSelector)}
7148
- `;
7149
- }
7150
- if (!S.selection) return html`<div class="empty-state">Select an element to style</div>`;
7151
- const node = getNodeAtPath(S.document, S.selection);
7152
- if (!node) return html`<div class="empty-state">Select an element to style</div>`;
7153
- return styleSidebarTemplate(node, S.ui.activeMedia, S.ui.activeSelector);
7154
- }
7155
-
7156
- /** @deprecated — use renderStylePanelTemplate() for lit-html integration */
7157
- function _renderStylePanel(/** @type {any} */ container) {
7158
- litRender(renderStylePanelTemplate(), container);
7159
- }
7160
-
7161
- /** Single property input row */
7162
- function _fieldRow(
7163
- /** @type {any} */ label,
7164
- /** @type {any} */ type,
7165
- /** @type {any} */ value,
7166
- /** @type {any} */ onChange,
7167
- /** @type {any} */ _datalistId,
7168
- ) {
7169
- /** @type {any} */
7170
- let debounceTimer;
7171
- const onInput = (/** @type {any} */ e) => {
7172
- clearTimeout(debounceTimer);
7173
- debounceTimer = setTimeout(() => onChange(e.target.value), 400);
7174
- };
7175
- const inputTpl =
7176
- type === "textarea"
7177
- ? html`<sp-textfield
7178
- multiline
7179
- size="s"
7180
- value=${value ?? ""}
7181
- @input=${onInput}
7182
- ></sp-textfield>`
7183
- : type === "checkbox"
7184
- ? html`<sp-checkbox
7185
- ?checked=${!!value}
7186
- @change=${(/** @type {any} */ e) => onChange(e.target.checked)}
7187
- ></sp-checkbox>`
7188
- : html`<sp-textfield size="s" value=${value ?? ""} @input=${onInput}></sp-textfield>`;
7189
- return html`
7190
- <div class="field-row">
7191
- <sp-field-label size="s">${label}</sp-field-label>
7192
- ${inputTpl}
7193
- </div>
7194
- `;
7195
- }
5742
+ // ─── Style panel ────────────────────────────────────────────────────────────
5743
+ // Extracted to panels/style-utils.js, panels/style-inputs.js, panels/style-panel.js
7196
5744
 
7197
5745
  /** Check if a selection path is inside a $map template (contains [..., "children", "map", ...]). */
7198
5746
  function isInsideMapTemplate(/** @type {any} */ path) {
@@ -7316,7 +5864,7 @@ function kvRow(
7316
5864
  clearTimeout(debounceTimer);
7317
5865
  debounceTimer = setTimeout(() => onChange(currentKey, currentVal), 400);
7318
5866
  };
7319
- const placeholder = datalistId === "css-props" ? cssInitialMap.get(key) || "" : "";
5867
+ const placeholder = datalistId === "css-props" ? getCssInitialMap().get(key) || "" : "";
7320
5868
  return html`
7321
5869
  <div class="kv-row">
7322
5870
  <sp-textfield
@@ -7330,7 +5878,7 @@ function kvRow(
7330
5878
  @change=${datalistId === "css-props"
7331
5879
  ? (/** @type {any} */ e) => {
7332
5880
  const el = e.target.closest(".kv-row")?.querySelector(".kv-val");
7333
- if (el) el.setAttribute("placeholder", cssInitialMap.get(e.target.value) || "");
5881
+ if (el) el.setAttribute("placeholder", getCssInitialMap().get(e.target.value) || "");
7334
5882
  }
7335
5883
  : nothing}
7336
5884
  ></sp-textfield>