@jxsuite/studio 0.1.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/dist/studio.js +47638 -33445
  2. package/dist/studio.js.map +449 -344
  3. package/package.json +45 -34
  4. package/src/browse/browse.js +414 -0
  5. package/src/editor/context-menu.js +48 -1
  6. package/src/editor/convert-to-component.js +208 -0
  7. package/src/editor/inline-edit.js +33 -6
  8. package/src/editor/shortcuts.js +6 -1
  9. package/src/files/components.js +4 -2
  10. package/src/files/file-ops.js +102 -54
  11. package/src/files/files.js +22 -8
  12. package/src/markdown/md-convert.js +309 -11
  13. package/src/panels/activity-bar.js +3 -0
  14. package/src/panels/head-panel.js +576 -0
  15. package/src/panels/overlays.js +125 -0
  16. package/src/panels/right-panel.js +104 -0
  17. package/src/panels/shared.js +41 -0
  18. package/src/panels/signals-panel.js +95 -94
  19. package/src/panels/toolbar.js +217 -0
  20. package/src/platforms/devserver.js +58 -16
  21. package/src/settings/collections-editor.js +428 -0
  22. package/src/settings/defs-editor.js +418 -0
  23. package/src/settings/schema-field-ui.js +329 -0
  24. package/src/state.js +99 -2
  25. package/src/store.js +77 -41
  26. package/src/studio.js +1523 -1375
  27. package/src/ui/button-group.js +91 -0
  28. package/src/ui/color-selector.js +299 -0
  29. package/src/ui/field-row.js +47 -0
  30. package/src/ui/media-picker.js +172 -0
  31. package/src/ui/panel-resize.js +96 -0
  32. package/src/ui/spectrum.js +36 -2
  33. package/src/ui/unit-selector.js +106 -0
  34. package/src/ui/{jx-styled-combobox.js → value-selector.js} +7 -7
  35. package/src/ui/widgets.js +106 -0
  36. package/src/utils/inherited-style.js +54 -0
  37. package/src/utils/studio-utils.js +32 -0
  38. package/src/view.js +45 -0
@@ -0,0 +1,96 @@
1
+ /**
2
+ * Panel-resize.js — Draggable resize handles for left and right sidebars.
3
+ *
4
+ * Self-initializing module. Import it and the resize handles become interactive. Persists widths to
5
+ * localStorage so they survive page reloads.
6
+ */
7
+
8
+ const STORAGE_KEY = "jx-studio-panel-widths";
9
+ const MIN_WIDTH = 160;
10
+ const MAX_RATIO = 0.5; // max 50% of viewport
11
+ const DEFAULT_LEFT = 240;
12
+ const DEFAULT_RIGHT = 280;
13
+
14
+ const root = document.documentElement;
15
+
16
+ // ─── Restore saved widths ────────────────────────────────────────────────────
17
+
18
+ try {
19
+ const saved = JSON.parse(localStorage.getItem(STORAGE_KEY) || "{}");
20
+ if (saved.left) root.style.setProperty("--panel-w-left", `${saved.left}px`);
21
+ if (saved.right) root.style.setProperty("--panel-w-right", `${saved.right}px`);
22
+ } catch {
23
+ // ignore
24
+ }
25
+
26
+ // ─── Setup handles ───────────────────────────────────────────────────────────
27
+
28
+ /**
29
+ * @param {HTMLElement} handle
30
+ * @param {string} cssVar
31
+ * @param {"left" | "right"} side
32
+ * @param {number} defaultWidth
33
+ */
34
+ function setupHandle(handle, cssVar, side, defaultWidth) {
35
+ /** @type {{ startX: number; startWidth: number } | null} */
36
+ let drag = null;
37
+
38
+ handle.addEventListener("pointerdown", (e) => {
39
+ e.preventDefault();
40
+ try {
41
+ handle.setPointerCapture(e.pointerId);
42
+ } catch {
43
+ /* synthetic events */
44
+ }
45
+ handle.classList.add("dragging");
46
+ document.body.style.userSelect = "none";
47
+
48
+ const current = parseInt(getComputedStyle(root).getPropertyValue(cssVar)) || defaultWidth;
49
+ drag = { startX: e.clientX, startWidth: current };
50
+ });
51
+
52
+ handle.addEventListener("pointermove", (e) => {
53
+ if (!drag) return;
54
+ const delta = side === "left" ? e.clientX - drag.startX : drag.startX - e.clientX;
55
+ const maxWidth = window.innerWidth * MAX_RATIO;
56
+ const newWidth = Math.round(Math.min(maxWidth, Math.max(MIN_WIDTH, drag.startWidth + delta)));
57
+ root.style.setProperty(cssVar, `${newWidth}px`);
58
+ });
59
+
60
+ handle.addEventListener("pointerup", (e) => {
61
+ if (!drag) return;
62
+ drag = null;
63
+ try {
64
+ handle.releasePointerCapture(e.pointerId);
65
+ } catch {
66
+ /* synthetic events */
67
+ }
68
+ handle.classList.remove("dragging");
69
+ document.body.style.userSelect = "";
70
+ persistWidths();
71
+ });
72
+
73
+ handle.addEventListener("dblclick", () => {
74
+ root.style.setProperty(cssVar, `${defaultWidth}px`);
75
+ persistWidths();
76
+ });
77
+ }
78
+
79
+ function persistWidths() {
80
+ const left = parseInt(getComputedStyle(root).getPropertyValue("--panel-w-left")) || DEFAULT_LEFT;
81
+ const right =
82
+ parseInt(getComputedStyle(root).getPropertyValue("--panel-w-right")) || DEFAULT_RIGHT;
83
+ try {
84
+ localStorage.setItem(STORAGE_KEY, JSON.stringify({ left, right }));
85
+ } catch {
86
+ // storage full or unavailable
87
+ }
88
+ }
89
+
90
+ // ─── Initialize ──────────────────────────────────────────────────────────────
91
+
92
+ const resizeLeft = document.getElementById("resize-left");
93
+ const resizeRight = document.getElementById("resize-right");
94
+
95
+ if (resizeLeft) setupHandle(resizeLeft, "--panel-w-left", "left", DEFAULT_LEFT);
96
+ if (resizeRight) setupHandle(resizeRight, "--panel-w-right", "right", DEFAULT_RIGHT);
@@ -37,10 +37,22 @@ import { Switch as SpSwitch } from "@spectrum-web-components/switch/src/Switch.j
37
37
  import { Divider } from "@spectrum-web-components/divider/src/Divider.js";
38
38
  import { Tooltip } from "@spectrum-web-components/tooltip/src/Tooltip.js";
39
39
  import { Overlay } from "@spectrum-web-components/overlay/src/Overlay.js";
40
+ import { OverlayTrigger } from "@spectrum-web-components/overlay/src/OverlayTrigger.js";
41
+ import { Dialog } from "@spectrum-web-components/dialog/src/Dialog.js";
42
+ import { DialogWrapper } from "@spectrum-web-components/dialog/src/DialogWrapper.js";
43
+ import { HelpText } from "@spectrum-web-components/help-text/src/HelpText.js";
44
+ import { Button } from "@spectrum-web-components/button/src/Button.js";
45
+ import { Underlay } from "@spectrum-web-components/underlay/src/Underlay.js";
40
46
  import { PickerButton } from "@spectrum-web-components/picker-button/src/PickerButton.js";
41
47
  import { Accordion } from "@spectrum-web-components/accordion/src/Accordion.js";
42
48
  import { AccordionItem } from "@spectrum-web-components/accordion/src/AccordionItem.js";
43
49
  import { ActionBar } from "@spectrum-web-components/action-bar/src/ActionBar.js";
50
+ import { Table } from "@spectrum-web-components/table/src/Table.js";
51
+ import { TableHead } from "@spectrum-web-components/table/src/TableHead.js";
52
+ import { TableHeadCell } from "@spectrum-web-components/table/src/TableHeadCell.js";
53
+ import { TableBody } from "@spectrum-web-components/table/src/TableBody.js";
54
+ import { TableRow } from "@spectrum-web-components/table/src/TableRow.js";
55
+ import { TableCell } from "@spectrum-web-components/table/src/TableCell.js";
44
56
 
45
57
  // Icons
46
58
  import { IconFolder } from "@spectrum-web-components/icons-workflow/src/elements/IconFolder.js";
@@ -49,8 +61,10 @@ import { IconDocument } from "@spectrum-web-components/icons-workflow/src/elemen
49
61
  import { IconFileCode } from "@spectrum-web-components/icons-workflow/src/elements/IconFileCode.js";
50
62
  import { IconFileTxt } from "@spectrum-web-components/icons-workflow/src/elements/IconFileTxt.js";
51
63
  import { IconImage } from "@spectrum-web-components/icons-workflow/src/elements/IconImage.js";
64
+ import { IconFileSingleWebPage } from "@spectrum-web-components/icons-workflow/src/elements/IconFileSingleWebPage.js";
52
65
  import { IconRefresh } from "@spectrum-web-components/icons-workflow/src/elements/IconRefresh.js";
53
66
  import { IconAdd } from "@spectrum-web-components/icons-workflow/src/elements/IconAdd.js";
67
+ import { IconUpload } from "@spectrum-web-components/icons-workflow/src/elements/IconUpload.js";
54
68
  import { IconLayers } from "@spectrum-web-components/icons-workflow/src/elements/IconLayers.js";
55
69
  import { IconViewGrid } from "@spectrum-web-components/icons-workflow/src/elements/IconViewGrid.js";
56
70
  import { IconBrackets } from "@spectrum-web-components/icons-workflow/src/elements/IconBrackets.js";
@@ -69,6 +83,7 @@ import { IconExport } from "@spectrum-web-components/icons-workflow/src/elements
69
83
  import { IconPreview } from "@spectrum-web-components/icons-workflow/src/elements/IconPreview.js";
70
84
  import { IconCode } from "@spectrum-web-components/icons-workflow/src/elements/IconCode.js";
71
85
  import { IconBrush } from "@spectrum-web-components/icons-workflow/src/elements/IconBrush.js";
86
+ import { IconGears } from "@spectrum-web-components/icons-workflow/src/elements/IconGears.js";
72
87
  import { IconBack } from "@spectrum-web-components/icons-workflow/src/elements/IconBack.js";
73
88
  import { IconProperties } from "@spectrum-web-components/icons-workflow/src/elements/IconProperties.js";
74
89
  import { IconEvent } from "@spectrum-web-components/icons-workflow/src/elements/IconEvent.js";
@@ -103,6 +118,7 @@ import { IconBox } from "@spectrum-web-components/icons-workflow/src/elements/Ic
103
118
  import { IconVisibility } from "@spectrum-web-components/icons-workflow/src/elements/IconVisibility.js";
104
119
  import { IconVisibilityOff } from "@spectrum-web-components/icons-workflow/src/elements/IconVisibilityOff.js";
105
120
  import { IconArtboard } from "@spectrum-web-components/icons-workflow/src/elements/IconArtboard.js";
121
+ import { IconViewList } from "@spectrum-web-components/icons-workflow/src/elements/IconViewList.js";
106
122
 
107
123
  // Inline formatting icons
108
124
  import { IconTextBold } from "@spectrum-web-components/icons-workflow/src/elements/IconTextBold.js";
@@ -114,7 +130,8 @@ import { IconTextSubscript } from "@spectrum-web-components/icons-workflow/src/e
114
130
  import { IconLink } from "@spectrum-web-components/icons-workflow/src/elements/IconLink.js";
115
131
 
116
132
  // Custom studio components
117
- import { JxStyledCombobox } from "./jx-styled-combobox.js";
133
+ import { JxValueSelector } from "./value-selector.js";
134
+ import { JxColorPopover } from "./color-selector.js";
118
135
 
119
136
  // UI icons (used internally by Spectrum components like accordion, picker, combobox)
120
137
  import { IconChevron100 } from "@spectrum-web-components/icons-ui/src/elements/IconChevron100.js";
@@ -151,18 +168,32 @@ const components = [
151
168
  ["sp-divider", Divider],
152
169
  ["sp-tooltip", Tooltip],
153
170
  ["sp-overlay", Overlay],
171
+ ["overlay-trigger", OverlayTrigger],
172
+ ["sp-dialog", Dialog],
173
+ ["sp-dialog-wrapper", DialogWrapper],
174
+ ["sp-help-text", HelpText],
175
+ ["sp-button", Button],
176
+ ["sp-underlay", Underlay],
154
177
  ["sp-picker-button", PickerButton],
155
178
  ["sp-accordion", Accordion],
156
179
  ["sp-accordion-item", AccordionItem],
157
180
  ["sp-action-bar", ActionBar],
181
+ ["sp-table", Table],
182
+ ["sp-table-head", TableHead],
183
+ ["sp-table-head-cell", TableHeadCell],
184
+ ["sp-table-body", TableBody],
185
+ ["sp-table-row", TableRow],
186
+ ["sp-table-cell", TableCell],
158
187
  ["sp-icon-folder", IconFolder],
159
188
  ["sp-icon-folder-open", IconFolderOpen],
160
189
  ["sp-icon-document", IconDocument],
161
190
  ["sp-icon-file-code", IconFileCode],
162
191
  ["sp-icon-file-txt", IconFileTxt],
163
192
  ["sp-icon-image", IconImage],
193
+ ["sp-icon-file-single-web-page", IconFileSingleWebPage],
164
194
  ["sp-icon-refresh", IconRefresh],
165
195
  ["sp-icon-add", IconAdd],
196
+ ["sp-icon-upload", IconUpload],
166
197
  ["sp-icon-layers", IconLayers],
167
198
  ["sp-icon-view-grid", IconViewGrid],
168
199
  ["sp-icon-brackets", IconBrackets],
@@ -181,6 +212,7 @@ const components = [
181
212
  ["sp-icon-preview", IconPreview],
182
213
  ["sp-icon-code", IconCode],
183
214
  ["sp-icon-brush", IconBrush],
215
+ ["sp-icon-gears", IconGears],
184
216
  ["sp-icon-back", IconBack],
185
217
  ["sp-icon-properties", IconProperties],
186
218
  ["sp-icon-event", IconEvent],
@@ -213,6 +245,7 @@ const components = [
213
245
  ["sp-icon-visibility", IconVisibility],
214
246
  ["sp-icon-visibility-off", IconVisibilityOff],
215
247
  ["sp-icon-artboard", IconArtboard],
248
+ ["sp-icon-view-list", IconViewList],
216
249
  ["sp-icon-text-bold", IconTextBold],
217
250
  ["sp-icon-text-italic", IconTextItalic],
218
251
  ["sp-icon-text-underline", IconTextUnderline],
@@ -223,7 +256,8 @@ const components = [
223
256
  // UI icons (internal component chrome)
224
257
  ["sp-icon-chevron100", IconChevron100],
225
258
  // Custom studio components
226
- ["jx-styled-combobox", JxStyledCombobox],
259
+ ["jx-value-selector", JxValueSelector],
260
+ ["jx-color-popover", JxColorPopover],
227
261
  ];
228
262
 
229
263
  for (const [tag, ctor] of /** @type {[string, CustomElementConstructor][]} */ (components)) {
@@ -0,0 +1,106 @@
1
+ /**
2
+ * Unit-selector.js — Number + unit picker widget.
3
+ *
4
+ * Renders a text field for numeric input paired with a unit picker dropdown. Handles keywords
5
+ * (auto, inherit, etc.) alongside numeric+unit values.
6
+ */
7
+
8
+ import { html, nothing } from "lit-html";
9
+ import { live } from "lit-html/directives/live.js";
10
+ import { classMap } from "lit-html/directives/class-map.js";
11
+ import { debouncedStyleCommit } from "../store.js";
12
+
13
+ export const UNIT_RE = /^(-?[\d.]+)(px|rem|em|%|vw|vh|svw|svh|dvh|ms|s|fr|ch|ex|deg)?$/;
14
+
15
+ /**
16
+ * Render a number + unit selector widget.
17
+ *
18
+ * @param {any} entry — css-meta entry with $units and $keywords arrays
19
+ * @param {string} prop — property key (for debounce namespace)
20
+ * @param {any} value — current value (e.g. "12px", "auto", "")
21
+ * @param {(val: string) => void} onChange — commit callback
22
+ * @returns {any}
23
+ */
24
+ export function renderUnitSelector(
25
+ /** @type {any} */ entry,
26
+ /** @type {any} */ prop,
27
+ /** @type {any} */ value,
28
+ /** @type {any} */ onChange,
29
+ /** @type {string} */ placeholder = "",
30
+ ) {
31
+ const units = entry.$units || [];
32
+ const keywords = entry.$keywords || [];
33
+ const strVal = String(value ?? "");
34
+ const match = strVal.match(UNIT_RE);
35
+ const isKeyword = !match && strVal !== "" && keywords.includes(strVal);
36
+ const isNumericVal = (/** @type {any} */ v) => /^-?\d*\.?\d*$/.test(v);
37
+
38
+ const currentUnit = isKeyword ? units[0] || "" : match ? match[2] || "" : units[0] || "";
39
+ let displayValue;
40
+ if (isKeyword) displayValue = strVal;
41
+ else if (match) displayValue = match[1];
42
+ else if (strVal !== "") {
43
+ const num = parseFloat(strVal);
44
+ displayValue = isNaN(num) ? strVal : String(num);
45
+ } else displayValue = "";
46
+
47
+ const isExpression = isKeyword || (displayValue !== "" && !isNumericVal(displayValue));
48
+ const hasUnits = units.length > 0 || keywords.length > 0;
49
+ const btnId = `style-unit-${prop}`;
50
+
51
+ return html`
52
+ <div class="style-input-number-unit">
53
+ <div class=${classMap({ "input-group": true, "is-expression": isExpression })}>
54
+ <sp-textfield
55
+ size="s"
56
+ placeholder=${placeholder || "0"}
57
+ .value=${live(displayValue)}
58
+ @input=${debouncedStyleCommit(`nui:${prop}`, 400, (/** @type {any} */ e) => {
59
+ const val = (e.target.value ?? "").trim();
60
+ if (val === "") {
61
+ onChange("");
62
+ return;
63
+ }
64
+ if (isNumericVal(val)) onChange(units.length > 0 ? val + currentUnit : val);
65
+ else onChange(val);
66
+ })}
67
+ ></sp-textfield>
68
+ ${hasUnits
69
+ ? html`
70
+ <sp-picker-button id=${btnId} size="s">
71
+ <span slot="label">${currentUnit || units[0] || ""}</span>
72
+ </sp-picker-button>
73
+ <sp-overlay trigger="${btnId}@click" placement="bottom-end" offset="4">
74
+ <sp-popover style="min-width: var(--spectrum-component-width-900, 64px)">
75
+ <sp-menu
76
+ label="CSS unit"
77
+ @change=${(/** @type {any} */ e) => {
78
+ const chosen = e.target.value;
79
+ if (keywords.includes(chosen)) {
80
+ onChange(chosen);
81
+ } else if (units.includes(chosen)) {
82
+ const curMatch = String(value ?? "").match(UNIT_RE);
83
+ const numPart = curMatch ? curMatch[1] : "";
84
+ if (numPart) onChange(numPart + chosen);
85
+ }
86
+ }}
87
+ >
88
+ ${units.map(
89
+ (/** @type {any} */ u) => html`<sp-menu-item value=${u}>${u}</sp-menu-item>`,
90
+ )}
91
+ ${keywords.length > 0 && units.length > 0
92
+ ? html`<sp-menu-divider></sp-menu-divider>`
93
+ : nothing}
94
+ ${keywords.map(
95
+ (/** @type {any} */ kw) =>
96
+ html`<sp-menu-item value=${kw}>${kw}</sp-menu-item>`,
97
+ )}
98
+ </sp-menu>
99
+ </sp-popover>
100
+ </sp-overlay>
101
+ `
102
+ : nothing}
103
+ </div>
104
+ </div>
105
+ `;
106
+ }
@@ -1,15 +1,15 @@
1
1
  /**
2
- * Jx-styled-combobox — Dual-mode styled combobox custom element.
2
+ * Value Selector — Dual-mode styled combobox custom element.
3
3
  *
4
4
  * Renders as sp-picker when the current value matches a predefined option, or as a textfield +
5
5
  * dropdown overlay (manual combobox) when it doesn't. Both modes share identical styled menu items,
6
6
  * ensuring visual consistency.
7
7
  *
8
- * Usage: html`<jx-styled-combobox size="s" .value=${"italic"} placeholder="normal" .options=${[{
8
+ * Usage: html`<jx-value-selector size="s" .value=${"italic"} placeholder="normal" .options=${[{
9
9
  * value: "italic", label: "Italic", style: "font-style: italic" }]} @change=${handler}
10
10
  * @input=${handler}
11
11
  *
12
- * > </jx-styled-combobox>`
12
+ * > </jx-value-selector>`
13
13
  *
14
14
  * Options format: { value: string, label: string, style?: string } — menu item { divider: true } —
15
15
  * menu divider
@@ -20,7 +20,7 @@ import { live } from "lit/directives/live.js";
20
20
 
21
21
  /** @typedef {{ value: string; label: string; style?: string } | { divider: true }} ComboOption */
22
22
 
23
- export class JxStyledCombobox extends LitElement {
23
+ export class JxValueSelector extends LitElement {
24
24
  static properties = {
25
25
  value: { type: String },
26
26
  placeholder: { type: String },
@@ -71,14 +71,14 @@ export class JxStyledCombobox extends LitElement {
71
71
 
72
72
  /** Picker mode: sp-picker @change handler */
73
73
  _handlePickerChange(/** @type {any} */ e) {
74
- e.stopPropagation(); // prevent sp-picker's raw event from reaching consumer
74
+ e.stopPropagation();
75
75
  this.value = e.target.value;
76
76
  this.dispatchEvent(new Event("change", { bubbles: true, composed: true }));
77
77
  }
78
78
 
79
79
  /** Combobox mode: sp-menu @change handler */
80
80
  _handleMenuChange(/** @type {any} */ e) {
81
- e.stopPropagation(); // prevent sp-menu's raw event from reaching consumer
81
+ e.stopPropagation();
82
82
  if (!e.target.value) return;
83
83
  this.value = e.target.value;
84
84
  this.dispatchEvent(new Event("change", { bubbles: true, composed: true }));
@@ -86,7 +86,7 @@ export class JxStyledCombobox extends LitElement {
86
86
 
87
87
  /** Combobox mode: textfield @input handler */
88
88
  _handleInput(/** @type {any} */ e) {
89
- e.stopPropagation(); // prevent sp-textfield's raw event from reaching consumer
89
+ e.stopPropagation();
90
90
  this.value = e.target.value;
91
91
  this.dispatchEvent(new Event("input", { bubbles: true, composed: true }));
92
92
  }
@@ -0,0 +1,106 @@
1
+ /**
2
+ * Widgets.js — Widget type dispatcher and simple widget renderers.
3
+ *
4
+ * This module provides `widgetForType()` which dispatches to the appropriate widget based on the
5
+ * inferred type from css-meta/schema entries, plus the simpler widget renderers (text, number,
6
+ * select/combobox).
7
+ *
8
+ * Complex widgets are imported from their dedicated modules: - renderColorSelector →
9
+ * ui/color-selector.js - renderUnitSelector → ui/unit-selector.js - renderButtonGroup →
10
+ * ui/button-group.js
11
+ */
12
+
13
+ import { html } from "lit-html";
14
+ import { live } from "lit-html/directives/live.js";
15
+ import { ifDefined } from "lit-html/directives/if-defined.js";
16
+ import { debouncedStyleCommit } from "../store.js";
17
+ import { renderColorSelector } from "./color-selector.js";
18
+ import { renderUnitSelector } from "./unit-selector.js";
19
+ import { renderButtonGroup } from "./button-group.js";
20
+ import { renderMediaPicker } from "./media-picker.js";
21
+
22
+ /**
23
+ * Render a plain text input widget.
24
+ *
25
+ * @param {string} prop
26
+ * @param {any} value
27
+ * @param {(val: string) => void} onChange
28
+ * @param {string} [placeholder]
29
+ * @returns {any}
30
+ */
31
+ export function renderTextInput(prop, value, onChange, placeholder = "") {
32
+ return html`
33
+ <sp-textfield
34
+ size="s"
35
+ placeholder=${placeholder}
36
+ .value=${live(value || "")}
37
+ @input=${debouncedStyleCommit(`text:${prop}`, 400, (/** @type {any} */ e) =>
38
+ onChange(e.target.value),
39
+ )}
40
+ ></sp-textfield>
41
+ `;
42
+ }
43
+
44
+ /**
45
+ * Render a number input widget (sp-number-field).
46
+ *
47
+ * @param {any} entry
48
+ * @param {string} prop
49
+ * @param {any} value
50
+ * @param {(val: any) => void} onChange
51
+ * @returns {any}
52
+ */
53
+ export function renderNumberInput(entry, prop, value, onChange, placeholder = "") {
54
+ return html`
55
+ <sp-number-field
56
+ size="s"
57
+ hide-stepper
58
+ .value=${live(value !== undefined && value !== "" ? Number(value) : undefined)}
59
+ placeholder=${placeholder}
60
+ min=${ifDefined(entry.minimum)}
61
+ max=${ifDefined(entry.maximum)}
62
+ step=${ifDefined(entry.maximum !== undefined && entry.maximum <= 1 ? 0.1 : undefined)}
63
+ @change=${debouncedStyleCommit(`num:${prop}`, 400, (/** @type {any} */ e) => {
64
+ const v = e.target.value;
65
+ if (v === undefined || isNaN(v)) onChange("");
66
+ else onChange(Number(v));
67
+ })}
68
+ ></sp-number-field>
69
+ `;
70
+ }
71
+
72
+ /**
73
+ * Dispatch to the appropriate widget based on inferred type.
74
+ *
75
+ * @param {string} type — one of: button-group, color, number-unit, number, select, combobox, text
76
+ * @param {any} entry — css-meta or schema entry
77
+ * @param {string} prop — property key
78
+ * @param {any} value — current value
79
+ * @param {(val: any) => void} onCommit — commit callback
80
+ * @param {{ placeholder?: string; renderSelect?: Function; renderCombobox?: Function }} [opts]
81
+ * @returns {any}
82
+ */
83
+ export function widgetForType(type, entry, prop, value, onCommit, opts = {}) {
84
+ switch (type) {
85
+ case "button-group":
86
+ return renderButtonGroup(entry, prop, value, onCommit);
87
+ case "color":
88
+ return renderColorSelector(prop, value, onCommit);
89
+ case "number-unit":
90
+ return renderUnitSelector(entry, prop, value, onCommit, opts.placeholder);
91
+ case "number":
92
+ return renderNumberInput(entry, prop, value, onCommit, opts.placeholder);
93
+ case "media":
94
+ return renderMediaPicker(prop, value, onCommit);
95
+ case "select":
96
+ // Allow caller to override select rendering (e.g. for typography preview)
97
+ if (opts.renderSelect) return opts.renderSelect(entry, prop, value, onCommit);
98
+ return renderTextInput(prop, value, onCommit, opts.placeholder);
99
+ case "combobox":
100
+ // Allow caller to override combobox rendering (e.g. for font family)
101
+ if (opts.renderCombobox) return opts.renderCombobox(entry, prop, value, onCommit);
102
+ return renderTextInput(prop, value, onCommit, opts.placeholder);
103
+ default:
104
+ return renderTextInput(prop, value, onCommit, opts.placeholder);
105
+ }
106
+ }
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Inherited-style.js — Computes the effective inherited style for a given breakpoint tab.
3
+ *
4
+ * Walks the cascade (base → each media block in order) up to but not including the active
5
+ * breakpoint, producing the set of property values that would apply if no explicit override exists
6
+ * on the current tab.
7
+ */
8
+
9
+ /**
10
+ * Compute the inherited style object for a given breakpoint tab.
11
+ *
12
+ * @param {Record<string, any>} style — full style object (flat props + @media blocks + selectors)
13
+ * @param {string[]} mediaNames — ordered breakpoint names (from parseMediaEntries, respects cascade
14
+ * direction)
15
+ * @param {string | null} activeTab — current breakpoint tab name, or null for base
16
+ * @param {string | null} activeSelector — current nested selector, or null
17
+ * @returns {Record<string, any>} Inherited style map (prop → value)
18
+ */
19
+ export function computeInheritedStyle(style, mediaNames, activeTab, activeSelector = null) {
20
+ if (activeTab === null || mediaNames.length === 0) return {};
21
+
22
+ /** @type {Record<string, any>} */
23
+ let inherited = {};
24
+
25
+ if (!activeSelector) {
26
+ // Start with base flat props
27
+ for (const [p, v] of Object.entries(style)) {
28
+ if (typeof v !== "object") inherited[p] = v;
29
+ }
30
+ // Layer each media block in order until current tab
31
+ for (const name of mediaNames) {
32
+ if (name === activeTab) break;
33
+ const block = style[`@${name}`] || {};
34
+ for (const [p, v] of Object.entries(block)) {
35
+ if (typeof v !== "object") inherited[p] = v;
36
+ }
37
+ }
38
+ } else {
39
+ // Selector inheritance: base selector → each media's selector block in order
40
+ const baseSel = style[activeSelector] || {};
41
+ for (const [p, v] of Object.entries(baseSel)) {
42
+ if (typeof v !== "object") inherited[p] = v;
43
+ }
44
+ for (const name of mediaNames) {
45
+ if (name === activeTab) break;
46
+ const selBlock = (style[`@${name}`] || {})[activeSelector] || {};
47
+ for (const [p, v] of Object.entries(selBlock)) {
48
+ if (typeof v !== "object") inherited[p] = v;
49
+ }
50
+ }
51
+ }
52
+
53
+ return inherited;
54
+ }
@@ -112,7 +112,9 @@ export function abbreviateValue(val) {
112
112
  export function inferInputType(entry) {
113
113
  if (entry.$shorthand === true) return "shorthand";
114
114
  if (entry.$input === "button-group") return "button-group";
115
+ if (entry.$input === "media") return "media";
115
116
  if (entry.format === "color") return "color";
117
+ if (entry.format === "uri-reference") return "media";
116
118
  if (entry.$units !== undefined) return "number-unit";
117
119
  if (entry.type === "number") return "number";
118
120
  if (Array.isArray(entry.enum)) return "select";
@@ -120,6 +122,36 @@ export function inferInputType(entry) {
120
122
  return "text";
121
123
  }
122
124
 
125
+ /**
126
+ * Match a document path to a content collection and return its schema. Uses simple directory-prefix
127
+ * + extension matching against the collection's `source` glob.
128
+ *
129
+ * @param {string | null} documentPath — project-relative path (e.g. "blog/hello.md")
130
+ * @param {any} projectConfig — parsed project.json
131
+ * @returns {{ name: string; schema: any } | null}
132
+ */
133
+ export function findCollectionSchema(documentPath, projectConfig) {
134
+ if (!documentPath || !projectConfig?.collections) return null;
135
+ for (const [name, def] of Object.entries(
136
+ /** @type {Record<string, any>} */ (projectConfig.collections),
137
+ )) {
138
+ if (!def.source || !def.schema) continue;
139
+ const src = def.source.replace(/^\.\//, "");
140
+ const dir = src.split("/")[0];
141
+ const ext = src.includes("*.md")
142
+ ? ".md"
143
+ : src.includes("*.json")
144
+ ? ".json"
145
+ : src.includes("*.csv")
146
+ ? ".csv"
147
+ : null;
148
+ if (documentPath.startsWith(dir + "/") && (!ext || documentPath.endsWith(ext))) {
149
+ return { name, schema: def.schema };
150
+ }
151
+ }
152
+ return null;
153
+ }
154
+
123
155
  /**
124
156
  * Convert a human-readable name to a CSS variable name. E.g. "Geometric Humanist" →
125
157
  * "--font-geometric-humanist"
package/src/view.js ADDED
@@ -0,0 +1,45 @@
1
+ /**
2
+ * View.js — Transient view state for Jx Studio
3
+ *
4
+ * Holds DOM references, editor instances, cleanup functions, and other mutable state that is the
5
+ * OUTPUT of renderers (not the input). Separating this from persistent app state (in S via
6
+ * store.js) makes renderer dependencies explicit.
7
+ */
8
+
9
+ /** @type {any} */
10
+ export const view = {
11
+ // Canvas infrastructure
12
+ panzoomWrap: null,
13
+ liveScope: null,
14
+ renderGeneration: 0,
15
+ centerObserver: null,
16
+ needsCenter: true,
17
+ panX: 0,
18
+ panY: 0,
19
+ prevCanvasMode: null,
20
+
21
+ // Editor instances
22
+ monacoEditor: null,
23
+ functionEditor: null,
24
+
25
+ // Inline editing
26
+ componentInlineEdit: null,
27
+ pendingInlineEdit: null,
28
+ inlineEditCleanup: null,
29
+
30
+ // Floating UI containers
31
+ blockActionBarEl: null,
32
+ linkPopoverHost: null,
33
+
34
+ // Selection & drag
35
+ selDragCleanup: null,
36
+
37
+ // Cleanup arrays (reset on each render cycle)
38
+ dndCleanups: [],
39
+ canvasDndCleanups: [],
40
+ canvasEventCleanups: [],
41
+
42
+ // Pseudo-state preview
43
+ forcedStyleTag: null,
44
+ forcedAttrEl: null,
45
+ };