@jxsuite/studio 0.7.0 → 0.9.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.
@@ -0,0 +1,1052 @@
1
+ /**
2
+ * Stylebook panel — renders the Settings mode canvas (element catalog, design variables,
3
+ * definitions, collections). Extracted from studio.js Phase 4e.
4
+ */
5
+
6
+ import { html, render as litRender, nothing } from "lit-html";
7
+ import { ref } from "lit-html/directives/ref.js";
8
+ import { styleMap } from "lit-html/directives/style-map.js";
9
+
10
+ import {
11
+ getState,
12
+ update,
13
+ updateStyle,
14
+ updateSession,
15
+ updateUi,
16
+ canvasWrap,
17
+ canvasPanels,
18
+ elToPath,
19
+ projectState,
20
+ } from "../store.js";
21
+ import { view } from "../view.js";
22
+ import { defineElement } from "@jxsuite/runtime";
23
+ import { componentRegistry } from "../files/components.js";
24
+ import { getEffectiveStyle, getEffectiveMedia } from "../site-context.js";
25
+ import { parseMediaEntries, activeBreakpointsForWidth } from "../utils/canvas-media.js";
26
+ import { mediaDisplayName } from "./shared.js";
27
+ import { friendlyNameToVar, varDisplayName } from "../utils/studio-utils.js";
28
+ import { renderDefsEditor } from "../settings/defs-editor.js";
29
+ import { renderCollectionsEditor } from "../settings/collections-editor.js";
30
+ import stylebookMeta from "../../data/stylebook-meta.json";
31
+
32
+ export { stylebookMeta };
33
+
34
+ /** @type {any} */
35
+ let _ctx = null;
36
+
37
+ /**
38
+ * Render the stylebook/settings mode into the canvas.
39
+ *
40
+ * @param {{
41
+ * canvasPanelTemplate: Function;
42
+ * applyTransform: Function;
43
+ * observeCenterUntilStable: Function;
44
+ * renderZoomIndicator: Function;
45
+ * updateActivePanelHeaders: Function;
46
+ * overlayBoxDescriptor: Function;
47
+ * effectiveZoom: Function;
48
+ * }} ctx
49
+ */
50
+ export function renderStylebookMode(ctx) {
51
+ _ctx = ctx;
52
+ const S = getState();
53
+
54
+ const settingsTab = S.ui.settingsTab || "stylebook";
55
+
56
+ const settingsChromeBarTpl = html`
57
+ <div
58
+ class="sb-chrome settings-top-chrome"
59
+ style="position:absolute;top:0;left:0;right:0;z-index:16;background:var(--bg-panel);border-bottom:1px solid var(--border)"
60
+ >
61
+ <sp-tabs
62
+ size="s"
63
+ selected=${settingsTab}
64
+ @change=${(/** @type {any} */ e) => {
65
+ updateUi("settingsTab", e.target.selected);
66
+ }}
67
+ >
68
+ <sp-tab label="Stylebook" value="stylebook"></sp-tab>
69
+ <sp-tab label="Definitions" value="definitions"></sp-tab>
70
+ <sp-tab label="Collections" value="collections"></sp-tab>
71
+ </sp-tabs>
72
+ </div>
73
+ `;
74
+
75
+ if (settingsTab === "definitions" || settingsTab === "collections") {
76
+ /** @type {any} */ (canvasWrap).style.overflow = "hidden";
77
+
78
+ litRender(
79
+ html`${settingsChromeBarTpl}
80
+ <div
81
+ class="settings-editor-container"
82
+ style="position:absolute;inset:40px 0 0 0;overflow:auto"
83
+ ></div>`,
84
+ /** @type {any} */ (canvasWrap),
85
+ );
86
+
87
+ const container = /** @type {HTMLElement} */ (
88
+ canvasWrap.querySelector(".settings-editor-container")
89
+ );
90
+ if (settingsTab === "definitions") renderDefsEditor(container);
91
+ else renderCollectionsEditor(container);
92
+ return;
93
+ }
94
+
95
+ // Stylebook tab — element catalog / variables
96
+ view.stylebookElToTag = new WeakMap();
97
+ const rootStyle = getEffectiveStyle(S.document.style);
98
+ const filter = (S.ui.stylebookFilter || "").toLowerCase();
99
+ const customizedOnly = S.ui.stylebookCustomizedOnly;
100
+
101
+ const { sizeBreakpoints, baseWidth } = parseMediaEntries(getEffectiveMedia(S.document.$media));
102
+ const hasMedia = sizeBreakpoints.length > 0;
103
+
104
+ const onTabClick = (/** @type {string} */ t) => {
105
+ updateUi("stylebookTab", t);
106
+ };
107
+
108
+ const onFilterInput = (/** @type {any} */ e) => {
109
+ updateUi("stylebookFilter", e.target.value);
110
+ };
111
+
112
+ const onCustomizedToggle = () => {
113
+ updateUi("stylebookCustomizedOnly", !S.ui.stylebookCustomizedOnly);
114
+ };
115
+
116
+ const chromeBarTpl = html`
117
+ ${settingsChromeBarTpl}
118
+ <div
119
+ class="sb-chrome"
120
+ style="position:absolute;top:36px;left:0;right:0;z-index:15;background:var(--bg-panel);border-bottom:1px solid var(--border)"
121
+ >
122
+ <sp-tabs
123
+ size="s"
124
+ selected=${S.ui.stylebookTab || "elements"}
125
+ @change=${(/** @type {any} */ e) => {
126
+ onTabClick(e.target.selected);
127
+ }}
128
+ >
129
+ ${["elements", "variables"].map(
130
+ (t) => html`
131
+ <sp-tab label=${t.charAt(0).toUpperCase() + t.slice(1)} value=${t}></sp-tab>
132
+ `,
133
+ )}
134
+ </sp-tabs>
135
+ ${S.ui.stylebookTab === "elements"
136
+ ? html`
137
+ <input
138
+ class="field-input"
139
+ style="flex:1;max-width:200px;margin-left:8px"
140
+ placeholder="Filter…"
141
+ .value=${S.ui.stylebookFilter}
142
+ @input=${onFilterInput}
143
+ />
144
+ <button
145
+ class="tb-toggle${S.ui.stylebookCustomizedOnly ? " active" : ""}"
146
+ style="margin-left:4px"
147
+ @click=${onCustomizedToggle}
148
+ >
149
+ Customized
150
+ </button>
151
+ `
152
+ : nothing}
153
+ </div>
154
+ `;
155
+
156
+ /** @type {any} */ (canvasWrap).style.overflow = "hidden";
157
+
158
+ /** @type {any[]} */
159
+ const allPanelDefs = [];
160
+ if (hasMedia) {
161
+ allPanelDefs.push({
162
+ name: "base",
163
+ displayName: mediaDisplayName("--"),
164
+ width: baseWidth,
165
+ activeSet: activeBreakpointsForWidth(sizeBreakpoints, baseWidth),
166
+ });
167
+ for (const bp of sizeBreakpoints) {
168
+ allPanelDefs.push({
169
+ name: bp.name,
170
+ displayName: mediaDisplayName(bp.name),
171
+ width: bp.width,
172
+ activeSet: activeBreakpointsForWidth(sizeBreakpoints, bp.width),
173
+ });
174
+ }
175
+ }
176
+
177
+ const renderIntoPanel = (/** @type {any} */ panel, /** @type {any} */ activeBreakpoints) => {
178
+ panel.canvas.classList.add("sb-canvas");
179
+ if (S.ui.stylebookTab === "elements") {
180
+ renderStylebookElementsIntoCanvas(
181
+ panel.canvas,
182
+ rootStyle,
183
+ filter,
184
+ customizedOnly,
185
+ activeBreakpoints,
186
+ );
187
+ for (const child of panel.canvas.querySelectorAll("*")) {
188
+ child.style.pointerEvents = "none";
189
+ }
190
+ registerStylebookPanelEvents(panel);
191
+ } else {
192
+ renderStylebookVarsIntoCanvas(panel.canvas, rootStyle);
193
+ panel.overlayClk.style.pointerEvents = "none";
194
+ }
195
+ };
196
+
197
+ /** @type {{ tpl: any; panel: any; activeSet: any }[]} */
198
+ let panelEntries;
199
+ if (!hasMedia) {
200
+ const effectiveMedia = getEffectiveMedia(S.document.$media);
201
+ const hasBaseWidth = effectiveMedia && effectiveMedia["--"];
202
+ const label = hasBaseWidth ? `${mediaDisplayName("--")} (${baseWidth}px)` : null;
203
+ const entry = ctx.canvasPanelTemplate(
204
+ hasBaseWidth ? "base" : null,
205
+ label,
206
+ !hasBaseWidth,
207
+ hasBaseWidth ? baseWidth : undefined,
208
+ );
209
+ panelEntries = [{ tpl: entry.tpl, panel: entry.panel, activeSet: new Set() }];
210
+ } else {
211
+ panelEntries = allPanelDefs.map((def) => {
212
+ const label = `${def.displayName} (${def.width}px)`;
213
+ const { tpl, panel } = ctx.canvasPanelTemplate(def.name, label, false, def.width);
214
+ return { tpl, panel, activeSet: def.activeSet };
215
+ });
216
+ }
217
+
218
+ litRender(
219
+ html`
220
+ ${chromeBarTpl}
221
+ <div
222
+ class="panzoom-wrap"
223
+ style="transform-origin:0 0;padding-top:72px"
224
+ ${ref((el) => {
225
+ if (el) view.panzoomWrap = /** @type {HTMLDivElement} */ (el);
226
+ })}
227
+ >
228
+ ${panelEntries.map((e) => e.tpl)}
229
+ </div>
230
+ `,
231
+ /** @type {any} */ (canvasWrap),
232
+ );
233
+
234
+ for (const { panel, activeSet } of panelEntries) {
235
+ canvasPanels.push(panel);
236
+ renderIntoPanel(panel, activeSet);
237
+ }
238
+ if (hasMedia) {
239
+ ctx.updateActivePanelHeaders();
240
+ }
241
+
242
+ ctx.applyTransform();
243
+ ctx.observeCenterUntilStable();
244
+ ctx.renderZoomIndicator();
245
+ }
246
+
247
+ /**
248
+ * Select a tag in the stylebook — shared by layers panel click and canvas click.
249
+ *
250
+ * @param {string} tag
251
+ * @param {string | null} [media]
252
+ */
253
+ export function selectStylebookTag(tag, media) {
254
+ updateSession({
255
+ selection: [],
256
+ ui: {
257
+ stylebookSelection: tag,
258
+ rightTab: "style",
259
+ activeSelector: `& ${tag}`,
260
+ ...(media !== undefined ? { activeMedia: media } : {}),
261
+ },
262
+ });
263
+ renderStylebookOverlays();
264
+ requestAnimationFrame(() => {
265
+ if (canvasPanels.length > 0) {
266
+ const el = findStylebookEl(canvasPanels[0].canvas, tag);
267
+ if (el) el.scrollIntoView({ behavior: "smooth", block: "center" });
268
+ }
269
+ });
270
+ }
271
+
272
+ /** Draw selection + hover overlays for stylebook elements */
273
+ export function renderStylebookOverlays() {
274
+ if (!_ctx) return;
275
+ if (canvasPanels.length === 0) return;
276
+
277
+ const S = getState();
278
+ const selectedTag = S.ui.stylebookSelection;
279
+
280
+ for (const panel of canvasPanels) {
281
+ const hoverTag = panel._lastHoverTag;
282
+ /**
283
+ * @type {{
284
+ * cls: string;
285
+ * top: string;
286
+ * left: string;
287
+ * width: string;
288
+ * height: string;
289
+ * label?: string;
290
+ * }[]}
291
+ */
292
+ const boxes = [];
293
+
294
+ if (hoverTag && hoverTag !== selectedTag) {
295
+ const el = findStylebookEl(panel.canvas, hoverTag);
296
+ if (el) boxes.push({ ..._ctx.overlayBoxDescriptor(el, "hover", panel), label: undefined });
297
+ }
298
+
299
+ if (selectedTag) {
300
+ const el = findStylebookEl(panel.canvas, selectedTag);
301
+ if (el)
302
+ boxes.push({
303
+ ..._ctx.overlayBoxDescriptor(el, "selection", panel),
304
+ label: `<${selectedTag}>`,
305
+ });
306
+ }
307
+
308
+ litRender(
309
+ html`
310
+ ${panel.dropLine}
311
+ ${boxes.map(
312
+ (b) => html`
313
+ <div
314
+ class=${b.cls}
315
+ style="top:${b.top};left:${b.left};width:${b.width};height:${b.height}"
316
+ >
317
+ ${b.label ? html`<div class="overlay-label">${b.label}</div>` : nothing}
318
+ </div>
319
+ `,
320
+ )}
321
+ `,
322
+ panel.overlay,
323
+ );
324
+ }
325
+ }
326
+
327
+ // ─── Internal helpers ─────────────────────────────────────────────────────────
328
+
329
+ /**
330
+ * Build a DOM element tree from a stylebook-meta.json entry.
331
+ *
332
+ * @param {any} entry
333
+ * @param {any} rootStyle
334
+ * @param {any} activeBreakpoints
335
+ */
336
+ function buildStylebookElement(entry, rootStyle, activeBreakpoints) {
337
+ const el = document.createElement(entry.tag);
338
+ if (entry.text) el.textContent = entry.text;
339
+ if (entry.attributes) {
340
+ for (const [k, v] of Object.entries(entry.attributes)) {
341
+ try {
342
+ el.setAttribute(k, /** @type {string} */ (v));
343
+ } catch {}
344
+ }
345
+ }
346
+ if (entry.style) el.style.cssText = entry.style;
347
+ const tagStyle = rootStyle[`& ${entry.tag}`];
348
+ if (tagStyle) {
349
+ for (const [prop, val] of Object.entries(tagStyle)) {
350
+ if (typeof val === "string" || typeof val === "number") {
351
+ try {
352
+ /** @type {any} */ (el.style)[prop] = val;
353
+ } catch {}
354
+ }
355
+ }
356
+ if (activeBreakpoints) {
357
+ for (const [key, val] of Object.entries(tagStyle)) {
358
+ if (!key.startsWith("@") || typeof val !== "object") continue;
359
+ const mediaName = key.slice(1);
360
+ if (mediaName === "--") continue;
361
+ if (activeBreakpoints.has(mediaName)) {
362
+ for (const [prop, v] of Object.entries(/** @type {any} */ (val))) {
363
+ if (typeof v === "string" || typeof v === "number") {
364
+ try {
365
+ /** @type {any} */ (el.style)[prop] = v;
366
+ } catch {}
367
+ }
368
+ }
369
+ }
370
+ }
371
+ }
372
+ }
373
+ if (entry.children) {
374
+ for (const child of entry.children) {
375
+ el.appendChild(buildStylebookElement(child, rootStyle, activeBreakpoints));
376
+ }
377
+ }
378
+ return el;
379
+ }
380
+
381
+ /**
382
+ * Render a live component preview by registering its custom element and instantiating it.
383
+ *
384
+ * @param {any} comp
385
+ * @returns {Promise<HTMLElement>}
386
+ */
387
+ export async function renderComponentPreview(comp) {
388
+ try {
389
+ if (comp.source === "npm") {
390
+ if (!customElements.get(comp.tagName)) {
391
+ throw new Error("not registered");
392
+ }
393
+ } else {
394
+ const root = projectState?.projectRoot;
395
+ const url = `${location.origin}/${root ? root + "/" : ""}${comp.path}`;
396
+ await defineElement(url);
397
+ }
398
+ const el = document.createElement(comp.tagName);
399
+ for (const p of comp.props || []) {
400
+ if (p.default !== undefined && p.default !== "false" && p.default !== "''") {
401
+ const val = String(p.default).replace(/^'|'$/g, "");
402
+ el.setAttribute(p.name, val);
403
+ }
404
+ }
405
+ return el;
406
+ } catch (/** @type {any} */ e) {
407
+ console.warn("Component preview failed:", comp.tagName, e);
408
+ const fallback = document.createElement("div");
409
+ fallback.style.cssText =
410
+ "padding:12px;border:1px dashed var(--border);border-radius:4px;color:var(--fg-dim)";
411
+ fallback.textContent = `<${comp.tagName}>`;
412
+ return fallback;
413
+ }
414
+ }
415
+
416
+ /**
417
+ * @param {any} rootStyle
418
+ * @param {any} tag
419
+ */
420
+ function hasTagStyle(rootStyle, tag) {
421
+ const s = rootStyle[`& ${tag}`];
422
+ return s && typeof s === "object" && Object.keys(s).length > 0;
423
+ }
424
+
425
+ /**
426
+ * @param {any} canvasEl
427
+ * @param {any} rootStyle
428
+ * @param {any} filter
429
+ * @param {any} customizedOnly
430
+ * @param {any} activeBreakpoints
431
+ */
432
+ function renderStylebookElementsIntoCanvas(
433
+ canvasEl,
434
+ rootStyle,
435
+ filter,
436
+ customizedOnly,
437
+ activeBreakpoints,
438
+ ) {
439
+ /** @type {import("lit-html").TemplateResult[]} */
440
+ const sectionTemplates = [];
441
+
442
+ for (const section of stylebookMeta.$sections) {
443
+ let entries = /** @type {any} */ (section.elements);
444
+ if (filter) {
445
+ entries = entries.filter(
446
+ (/** @type {any} */ e) =>
447
+ e.tag.includes(filter) || section.label.toLowerCase().includes(filter),
448
+ );
449
+ }
450
+ if (customizedOnly) {
451
+ entries = entries.filter((/** @type {any} */ e) => hasTagStyle(rootStyle, e.tag));
452
+ }
453
+ if (entries.length === 0) continue;
454
+
455
+ const cardTemplates = entries.map((/** @type {any} */ entry) => {
456
+ const el = buildStylebookElement(entry, rootStyle, activeBreakpoints);
457
+ return html`
458
+ <div
459
+ class="element-card"
460
+ ${ref((card) => {
461
+ if (!card) return;
462
+ view.stylebookElToTag.set(card, entry.tag);
463
+ elToPath.set(card, ["__sb", entry.tag]);
464
+ for (const child of el.querySelectorAll("*")) {
465
+ const tag = child.tagName.toLowerCase();
466
+ if (!view.stylebookElToTag.has(child)) {
467
+ view.stylebookElToTag.set(child, tag);
468
+ elToPath.set(child, ["__sb", tag]);
469
+ }
470
+ }
471
+ })}
472
+ >
473
+ <div
474
+ class="element-card-preview"
475
+ ${ref((c) => {
476
+ if (c && !c.firstChild) c.appendChild(el);
477
+ })}
478
+ ></div>
479
+ <div class="element-card-label">&lt;${entry.tag}&gt;</div>
480
+ </div>
481
+ `;
482
+ });
483
+
484
+ sectionTemplates.push(html`
485
+ <div class="sb-section">
486
+ <div class="sb-label">${section.label}</div>
487
+ <div class="sb-body">${cardTemplates}</div>
488
+ </div>
489
+ `);
490
+ }
491
+
492
+ // Custom components from registry
493
+ if (componentRegistry.length > 0) {
494
+ let comps = componentRegistry;
495
+ if (filter)
496
+ comps = comps.filter((/** @type {any} */ c) => c.tagName.toLowerCase().includes(filter));
497
+ if (customizedOnly)
498
+ comps = comps.filter((/** @type {any} */ c) => hasTagStyle(rootStyle, c.tagName));
499
+ if (comps.length > 0) {
500
+ const compCards = comps.map((/** @type {any} */ comp) => {
501
+ /** @type {HTMLDivElement | null} */
502
+ let previewEl = null;
503
+ const cardTpl = html`
504
+ <div
505
+ class="element-card"
506
+ style="display:inline-flex;width:auto"
507
+ ${ref((card) => {
508
+ if (!card) return;
509
+ view.stylebookElToTag.set(card, comp.tagName);
510
+ elToPath.set(card, ["__sb", comp.tagName]);
511
+ })}
512
+ >
513
+ <div
514
+ class="element-card-preview"
515
+ ${ref((c) => {
516
+ if (c) previewEl = /** @type {HTMLDivElement} */ (c);
517
+ })}
518
+ ></div>
519
+ <div class="element-card-label">&lt;${comp.tagName}&gt;</div>
520
+ </div>
521
+ `;
522
+ renderComponentPreview(comp).then((el) => {
523
+ if (previewEl) previewEl.appendChild(el);
524
+ });
525
+ return cardTpl;
526
+ });
527
+
528
+ sectionTemplates.push(html`
529
+ <div class="sb-section">
530
+ <div class="sb-label">Components</div>
531
+ <div class="sb-body">${compCards}</div>
532
+ </div>
533
+ `);
534
+ }
535
+ }
536
+
537
+ if (sectionTemplates.length === 0) {
538
+ litRender(
539
+ html`
540
+ <div style="padding:48px;text-align:center;color:var(--fg-dim);font-size:13px">
541
+ ${customizedOnly ? "No customized elements" : "No matching elements"}
542
+ </div>
543
+ `,
544
+ canvasEl,
545
+ );
546
+ } else {
547
+ litRender(html`${sectionTemplates}`, canvasEl);
548
+ }
549
+ }
550
+
551
+ /**
552
+ * Render variables into the canvas (card-based layout matching Elements tab)
553
+ *
554
+ * @param {any} canvasEl
555
+ * @param {any} rootStyle
556
+ */
557
+ function renderStylebookVarsIntoCanvas(canvasEl, rootStyle) {
558
+ const varCats = stylebookMeta.$variables;
559
+
560
+ /** @type {Record<string, any>} */
561
+ const groups = {};
562
+ for (const key of Object.keys(varCats)) groups[key] = [];
563
+ for (const [k, v] of Object.entries(rootStyle)) {
564
+ if (!k.startsWith("--")) continue;
565
+ if (typeof v !== "string" && typeof v !== "number") continue;
566
+ if (k.startsWith("--color")) groups.color.push([k, v]);
567
+ else if (k.startsWith("--font")) groups.font.push([k, v]);
568
+ else if (k.startsWith("--size") || k.startsWith("--spacing") || k.startsWith("--radius"))
569
+ groups.size.push([k, v]);
570
+ else groups.other.push([k, v]);
571
+ }
572
+
573
+ /** @type {Map<string, HTMLElement | null>} */
574
+ const bodyRefs = new Map();
575
+
576
+ const sectionTemplates = Object.entries(varCats).map(([catKey, catMeta]) => {
577
+ const vars = groups[catKey];
578
+
579
+ const onAdd = () => {
580
+ const bodyEl = bodyRefs.get(catKey);
581
+ if (!bodyEl) return;
582
+ const addBtn = bodyEl.querySelector(".sb-var-add-btn");
583
+ const row = renderVarRow(catKey, /** @type {any} */ (catMeta), null, "", true);
584
+ bodyEl.insertBefore(row, addBtn);
585
+ if (addBtn) /** @type {any} */ (addBtn).style.display = "none";
586
+ const nameField = /** @type {any} */ (row.querySelector("sp-textfield"));
587
+ if (nameField) requestAnimationFrame(() => nameField.focus());
588
+ };
589
+
590
+ return html`
591
+ <div class="sb-section">
592
+ <div class="sb-label">${/** @type {any} */ (catMeta).label}</div>
593
+ <div
594
+ class="sb-body"
595
+ ${ref((el) => {
596
+ if (el) bodyRefs.set(catKey, /** @type {HTMLElement} */ (el));
597
+ })}
598
+ >
599
+ ${vars.length > 0
600
+ ? vars.map((/** @type {[string, any]} */ [varName, varVal]) =>
601
+ renderVarRow(catKey, /** @type {any} */ (catMeta), varName, String(varVal), false),
602
+ )
603
+ : html`<div class="sb-var-empty">
604
+ No ${/** @type {any} */ (catMeta).label.toLowerCase()} variables yet.
605
+ </div>`}
606
+ <button class="sb-var-add-btn" @click=${onAdd}>
607
+ <span class="sb-var-add-icon">+</span> Add ${/** @type {any} */ (catMeta).label}
608
+ </button>
609
+ </div>
610
+ </div>
611
+ `;
612
+ });
613
+
614
+ litRender(html`${sectionTemplates}`, canvasEl);
615
+ }
616
+
617
+ /**
618
+ * Render a single variable row — used for both existing and add-new.
619
+ *
620
+ * @param {string} catKey
621
+ * @param {any} catMeta
622
+ * @param {string | null} varName
623
+ * @param {string} varVal
624
+ * @param {boolean} isNew
625
+ */
626
+ function renderVarRow(catKey, catMeta, varName, varVal, isNew) {
627
+ const row = document.createElement("div");
628
+ row.className = isNew ? "sb-var-row is-new" : "sb-var-row";
629
+
630
+ /** @type {any} */
631
+ let colorPicker = null;
632
+ /** @type {any} */
633
+ let nameField = null;
634
+ /** @type {any} */
635
+ let getValueFn;
636
+ /** @type {any} */
637
+ let hexField = null;
638
+
639
+ const swatchTpl =
640
+ catKey === "color"
641
+ ? html`
642
+ <div
643
+ class="sb-var-swatch"
644
+ style=${styleMap({ backgroundColor: varVal || "var(--accent)" })}
645
+ >
646
+ <input
647
+ type="color"
648
+ .value=${varVal && varVal.startsWith("#") ? varVal : "#007acc"}
649
+ ${ref((el) => {
650
+ if (el) colorPicker = el;
651
+ })}
652
+ @input=${() => {
653
+ if (!colorPicker || !hexField) return;
654
+ hexField.value = colorPicker.value;
655
+ const swatch = /** @type {any} */ (row.querySelector(".sb-var-swatch"));
656
+ if (swatch) swatch.style.backgroundColor = colorPicker.value;
657
+ if (!isNew && varName) {
658
+ const S = getState();
659
+ update(updateStyle(S, [], varName, colorPicker.value));
660
+ }
661
+ }}
662
+ />
663
+ </div>
664
+ `
665
+ : nothing;
666
+
667
+ const namePlaceholder =
668
+ catKey === "color"
669
+ ? "Primary Blue"
670
+ : catKey === "font"
671
+ ? "Body Serif"
672
+ : catKey === "size"
673
+ ? "Spacing Large"
674
+ : "Border Radius";
675
+
676
+ const nameColTpl = isNew
677
+ ? html`
678
+ <div class="sb-var-col-name">
679
+ <div class="sb-var-col-label">Name</div>
680
+ <sp-textfield
681
+ size="s"
682
+ placeholder=${namePlaceholder}
683
+ style="pointer-events:auto"
684
+ ${ref((el) => {
685
+ if (el) nameField = el;
686
+ })}
687
+ ></sp-textfield>
688
+ </div>
689
+ `
690
+ : nothing;
691
+
692
+ /** @type {any} */
693
+ let valueContent;
694
+
695
+ if (catKey === "color") {
696
+ /** @type {any} */
697
+ let debounce;
698
+ valueContent = html`
699
+ <sp-textfield
700
+ size="s"
701
+ .value=${varVal || "#007acc"}
702
+ placeholder="#007acc"
703
+ style="pointer-events:auto"
704
+ ${ref((el) => {
705
+ if (el) hexField = el;
706
+ })}
707
+ @input=${() => {
708
+ clearTimeout(debounce);
709
+ debounce = setTimeout(() => {
710
+ if (!hexField) return;
711
+ const v = hexField.value;
712
+ try {
713
+ if (colorPicker) colorPicker.value = v.startsWith("#") ? v : colorPicker.value;
714
+ } catch {}
715
+ const swatch = /** @type {any} */ (row.querySelector(".sb-var-swatch"));
716
+ if (swatch) swatch.style.backgroundColor = v;
717
+ if (!isNew && varName) {
718
+ const S = getState();
719
+ update(updateStyle(S, [], varName, v));
720
+ }
721
+ }, 400);
722
+ }}
723
+ ></sp-textfield>
724
+ `;
725
+ getValueFn = () => hexField?.value?.trim() || "";
726
+ } else if (catKey === "size") {
727
+ const ui = createUnitInput(varVal || "16px", {
728
+ onChange: (/** @type {any} */ newVal) => {
729
+ const bar = /** @type {any} */ (row.querySelector(".sb-var-size-bar"));
730
+ if (bar) bar.style.width = newVal;
731
+ if (!isNew && varName) {
732
+ const S = getState();
733
+ update(updateStyle(S, [], varName, newVal));
734
+ }
735
+ },
736
+ });
737
+ if (isNew) ui.textfield.value = "";
738
+ valueContent = html`<div
739
+ ${ref((el) => {
740
+ if (el && !el.firstChild) el.appendChild(ui.wrap);
741
+ })}
742
+ ></div>`;
743
+ getValueFn = () => ui.getValue();
744
+ } else {
745
+ /** @type {any} */
746
+ let textFieldEl = null;
747
+ /** @type {any} */
748
+ let debounce;
749
+ valueContent = html`
750
+ <sp-textfield
751
+ size="s"
752
+ .value=${varVal}
753
+ placeholder=${catMeta.placeholder}
754
+ style="pointer-events:auto"
755
+ ${ref((el) => {
756
+ if (el) textFieldEl = el;
757
+ })}
758
+ @input=${() => {
759
+ if (!textFieldEl || isNew || !varName) return;
760
+ clearTimeout(debounce);
761
+ debounce = setTimeout(() => {
762
+ const v = textFieldEl.value;
763
+ const fontPrev = /** @type {any} */ (row.querySelector(".sb-var-font-preview"));
764
+ if (fontPrev) fontPrev.style.fontFamily = v;
765
+ const S = getState();
766
+ update(updateStyle(S, [], varName, v));
767
+ }, 400);
768
+ }}
769
+ ></sp-textfield>
770
+ `;
771
+ getValueFn = () => textFieldEl?.value?.trim() || "";
772
+ }
773
+
774
+ const valColTpl = html`
775
+ <div class="sb-var-col-value">
776
+ ${isNew ? html`<div class="sb-var-col-label">Value</div>` : nothing} ${valueContent}
777
+ </div>
778
+ `;
779
+
780
+ const actionsTpl = isNew
781
+ ? html`
782
+ <div class="sb-var-add-actions">
783
+ <sp-action-button
784
+ size="s"
785
+ style="pointer-events:auto"
786
+ @click=${() => {
787
+ const name = (nameField?.value || "").trim();
788
+ const val = getValueFn();
789
+ const generatedVar = friendlyNameToVar(name, catMeta.prefix);
790
+ if (!generatedVar || !val) return;
791
+ const S = getState();
792
+ update(updateStyle(S, [], generatedVar, val));
793
+ }}
794
+ >Add</sp-action-button
795
+ >
796
+ <sp-action-button
797
+ size="s"
798
+ quiet
799
+ style="pointer-events:auto"
800
+ @click=${() => {
801
+ const body = row.parentElement;
802
+ row.remove();
803
+ const addBtn = /** @type {any} */ (body?.querySelector(".sb-var-add-btn"));
804
+ if (addBtn) addBtn.style.display = "";
805
+ }}
806
+ >
807
+ <sp-icon-close slot="icon"></sp-icon-close>
808
+ </sp-action-button>
809
+ </div>
810
+ `
811
+ : nothing;
812
+
813
+ const headerTpl =
814
+ !isNew && varName
815
+ ? html`
816
+ <div class="sb-var-row-header">
817
+ <span class="sb-var-row-title">${varDisplayName(varName, catMeta.prefix)}</span>
818
+ <span class="sb-var-row-ref">${varName}</span>
819
+ <sp-action-button
820
+ size="s"
821
+ quiet
822
+ class="sb-var-del"
823
+ style="pointer-events:auto"
824
+ @click=${() => {
825
+ const S = getState();
826
+ update(updateStyle(S, [], varName, undefined));
827
+ }}
828
+ >
829
+ <sp-icon-delete slot="icon"></sp-icon-delete>
830
+ </sp-action-button>
831
+ </div>
832
+ `
833
+ : nothing;
834
+
835
+ const addPreviewTpl = isNew
836
+ ? html`
837
+ <div
838
+ class="sb-var-add-preview"
839
+ ${ref((el) => {
840
+ if (!el || !nameField) return;
841
+ nameField.addEventListener("input", () => {
842
+ el.textContent = friendlyNameToVar(nameField.value || "", catMeta.prefix);
843
+ });
844
+ })}
845
+ ></div>
846
+ `
847
+ : nothing;
848
+
849
+ const typePrevTpl =
850
+ catKey === "font" && varVal
851
+ ? html`
852
+ <div class="sb-var-preview">
853
+ <div class="sb-var-font-preview" style=${styleMap({ fontFamily: varVal })}>
854
+ The quick brown fox jumps over the lazy dog
855
+ </div>
856
+ </div>
857
+ `
858
+ : catKey === "size" && varVal
859
+ ? html`
860
+ <div class="sb-var-preview">
861
+ <div class="sb-var-size-track">
862
+ <div class="sb-var-size-bar" style=${styleMap({ width: varVal })}></div>
863
+ </div>
864
+ </div>
865
+ `
866
+ : nothing;
867
+
868
+ litRender(
869
+ html`
870
+ ${headerTpl}
871
+ <div class="sb-var-input-row">${swatchTpl} ${nameColTpl} ${valColTpl} ${actionsTpl}</div>
872
+ ${addPreviewTpl} ${typePrevTpl}
873
+ `,
874
+ row,
875
+ );
876
+
877
+ return row;
878
+ }
879
+
880
+ /**
881
+ * Creates a combined textfield + quiet sp-picker for CSS values with units.
882
+ *
883
+ * @param {any} initialValue
884
+ * @param {any} [options]
885
+ */
886
+ function createUnitInput(initialValue, { onChange, size = "s" } = {}) {
887
+ const match = String(initialValue).match(
888
+ /^(-?[\d.]+)\s*(px|em|rem|vw|vh|%|ch|ex|vmin|vmax|pt|cm|mm|in)?$/,
889
+ );
890
+ let numVal = match ? match[1] : initialValue;
891
+ let unitVal = match ? match[2] || "px" : "";
892
+ const isNumeric = !!match;
893
+
894
+ const wrap = document.createElement("div");
895
+ wrap.className = "sb-unit-input";
896
+ wrap.style.pointerEvents = "auto";
897
+
898
+ /** @type {any} */
899
+ let textfield = null;
900
+ /** @type {any} */
901
+ let picker = null;
902
+
903
+ const units = [
904
+ { value: "px", label: "px" },
905
+ { value: "rem", label: "rem" },
906
+ { value: "em", label: "em" },
907
+ { value: "%", label: "%" },
908
+ { value: "vw", label: "vw" },
909
+ { value: "vh", label: "vh" },
910
+ { value: "ch", label: "ch" },
911
+ { value: "pt", label: "pt" },
912
+ { divider: true },
913
+ { value: "auto", label: "auto" },
914
+ { value: "fit-content", label: "fit-content" },
915
+ ];
916
+
917
+ /** @type {any} */
918
+ let debounce;
919
+
920
+ function getValue() {
921
+ const num = textfield?.value;
922
+ const unit = picker?.value;
923
+ if (unit === "auto" || unit === "fit-content") return unit;
924
+ return num ? `${num}${unit}` : "";
925
+ }
926
+
927
+ litRender(
928
+ html`
929
+ <sp-textfield
930
+ .value=${numVal}
931
+ size=${size}
932
+ ${ref((el) => {
933
+ if (el) textfield = el;
934
+ })}
935
+ @input=${() => {
936
+ clearTimeout(debounce);
937
+ const raw = textfield?.value?.trim();
938
+ const looksNumeric = /^-?[\d.]+$/.test(raw || "");
939
+ if (picker) picker.style.display = looksNumeric ? "" : "none";
940
+ debounce = setTimeout(() => {
941
+ if (onChange) onChange(looksNumeric ? `${raw}${picker?.value}` : raw);
942
+ }, 400);
943
+ }}
944
+ ></sp-textfield>
945
+ <sp-picker
946
+ quiet
947
+ size=${size}
948
+ style=${styleMap({ display: isNumeric ? "" : "none" })}
949
+ ${ref((el) => {
950
+ if (el) {
951
+ picker = el;
952
+ requestAnimationFrame(() => {
953
+ /** @type {any} */ (el).value = unitVal || "px";
954
+ });
955
+ }
956
+ })}
957
+ @change=${() => {
958
+ const unit = picker?.value;
959
+ if (unit === "auto" || unit === "fit-content") {
960
+ if (textfield) textfield.value = unit;
961
+ if (picker) picker.style.display = "none";
962
+ if (onChange) onChange(unit);
963
+ } else {
964
+ unitVal = unit;
965
+ if (onChange) onChange(getValue());
966
+ }
967
+ }}
968
+ >
969
+ ${units.map((u) =>
970
+ u.divider
971
+ ? html`<sp-menu-divider></sp-menu-divider>`
972
+ : html`<sp-menu-item value=${u.value}>${u.label}</sp-menu-item>`,
973
+ )}
974
+ </sp-picker>
975
+ `,
976
+ wrap,
977
+ );
978
+
979
+ return { wrap, textfield, picker, getValue };
980
+ }
981
+
982
+ /**
983
+ * Click handler for stylebook canvas — selects elements via elToPath/view.stylebookElToTag mapping
984
+ *
985
+ * @param {any} panel
986
+ */
987
+ function registerStylebookPanelEvents(panel) {
988
+ const { canvas, overlayClk } = panel;
989
+
990
+ overlayClk.addEventListener("click", (/** @type {any} */ e) => {
991
+ const els = canvas.querySelectorAll("*");
992
+ for (const el of els) el.style.pointerEvents = "auto";
993
+ overlayClk.style.display = "none";
994
+ const elements = document.elementsFromPoint(e.clientX, e.clientY);
995
+ overlayClk.style.display = "";
996
+ for (const el of els) el.style.pointerEvents = "none";
997
+
998
+ for (const el of elements) {
999
+ if (!canvas.contains(el) || el === canvas) continue;
1000
+ let cur = /** @type {any} */ (el);
1001
+ while (cur && cur !== canvas) {
1002
+ const tag = view.stylebookElToTag.get(cur);
1003
+ if (tag) {
1004
+ const newMedia = panel.mediaName === "base" ? null : (panel.mediaName ?? null);
1005
+ selectStylebookTag(tag, newMedia);
1006
+ if (_ctx) _ctx.updateActivePanelHeaders();
1007
+ return;
1008
+ }
1009
+ cur = cur.parentElement;
1010
+ }
1011
+ }
1012
+ updateSession({ ui: { stylebookSelection: null, activeSelector: null } });
1013
+ renderStylebookOverlays();
1014
+ });
1015
+
1016
+ overlayClk.addEventListener("mousemove", (/** @type {any} */ e) => {
1017
+ const els = canvas.querySelectorAll("*");
1018
+ for (const el of els) el.style.pointerEvents = "auto";
1019
+ overlayClk.style.display = "none";
1020
+ const elements = document.elementsFromPoint(e.clientX, e.clientY);
1021
+ overlayClk.style.display = "";
1022
+ for (const el of els) el.style.pointerEvents = "none";
1023
+
1024
+ let hoverTag = null;
1025
+ for (const el of elements) {
1026
+ if (!canvas.contains(el) || el === canvas) continue;
1027
+ let cur = /** @type {any} */ (el);
1028
+ while (cur && cur !== canvas) {
1029
+ const tag = view.stylebookElToTag.get(cur);
1030
+ if (tag) {
1031
+ hoverTag = tag;
1032
+ break;
1033
+ }
1034
+ cur = cur.parentElement;
1035
+ }
1036
+ if (hoverTag) break;
1037
+ }
1038
+
1039
+ if (hoverTag !== panel._lastHoverTag) {
1040
+ panel._lastHoverTag = hoverTag;
1041
+ renderStylebookOverlays();
1042
+ }
1043
+ });
1044
+ }
1045
+
1046
+ /** Find a stylebook element by tag in the canvas */
1047
+ function findStylebookEl(/** @type {any} */ canvasEl, /** @type {any} */ tag) {
1048
+ for (const child of canvasEl.querySelectorAll("*")) {
1049
+ if (view.stylebookElToTag.get(child) === tag) return child;
1050
+ }
1051
+ return null;
1052
+ }