@jxsuite/studio 0.7.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1340 @@
1
+ /** Properties panel — inspector for element attributes, component props, media, and frontmatter. */
2
+
3
+ import { html, nothing } from "lit-html";
4
+ import { live } from "lit-html/directives/live.js";
5
+ import {
6
+ getState,
7
+ selectNode,
8
+ getNodeAtPath,
9
+ update,
10
+ updateProperty,
11
+ updateAttribute,
12
+ updateProp,
13
+ updateMedia,
14
+ updateFrontmatter,
15
+ addSwitchCase,
16
+ removeSwitchCase,
17
+ renameSwitchCase,
18
+ debouncedStyleCommit,
19
+ renderOnly,
20
+ updateUi,
21
+ projectState,
22
+ } from "../store.js";
23
+ import { view } from "../view.js";
24
+ import { componentRegistry } from "../files/components.js";
25
+ import { widgetForType } from "./style-inputs.js";
26
+ import { renderFieldRow } from "../ui/field-row.js";
27
+ import {
28
+ attrLabel,
29
+ inferInputType,
30
+ findCollectionSchema,
31
+ friendlyNameToVar,
32
+ camelToLabel,
33
+ parseCemType,
34
+ } from "../utils/studio-utils.js";
35
+ import { isCustomElementDoc, collectCssParts } from "./signals-panel.js";
36
+ import { mediaDisplayName } from "./shared.js";
37
+ import { getCssInitialMap } from "./style-utils.js";
38
+ import htmlMeta from "../../data/html-meta.json";
39
+
40
+ /**
41
+ * Convert a human-friendly name like "Tablet" to a $media key "--tablet"
42
+ *
43
+ * @param {any} name
44
+ */
45
+ function friendlyNameToMedia(name) {
46
+ return friendlyNameToVar(name, "--");
47
+ }
48
+
49
+ /** Check if a selection path is inside a $map template (contains [..., "children", "map", ...]). */
50
+ function isInsideMapTemplate(/** @type {any} */ path) {
51
+ if (!path) return false;
52
+ for (let i = 0; i < path.length - 1; i++) {
53
+ if (path[i] === "children" && path[i + 1] === "map") return true;
54
+ }
55
+ return false;
56
+ }
57
+
58
+ /**
59
+ * Field row with binding toggle — allows switching between static value and signal binding.
60
+ * rawValue can be a string/bool (static) or { $ref: "..." } (bound).
61
+ */
62
+ function bindableFieldRow(
63
+ /** @type {any} */ label,
64
+ /** @type {any} */ type,
65
+ /** @type {any} */ rawValue,
66
+ /** @type {any} */ onChange,
67
+ /** @type {any} */ filterFn = null,
68
+ /** @type {any} */ extraSignals = null,
69
+ ) {
70
+ const S = getState();
71
+ const defs = S.document.state || {};
72
+ const isBound = typeof rawValue === "object" && rawValue !== null && rawValue.$ref;
73
+
74
+ const signalDefs = Object.entries(defs).filter(([, d]) =>
75
+ filterFn ? filterFn(d) : !d.$handler && d.$prototype !== "Function",
76
+ );
77
+
78
+ /** @type {any} */
79
+ let debounce;
80
+ const onInput = (/** @type {any} */ e) => {
81
+ clearTimeout(debounce);
82
+ debounce = setTimeout(() => onChange(e.target.value), 400);
83
+ };
84
+
85
+ const staticVal = isBound ? "" : (rawValue ?? "");
86
+ const staticTpl =
87
+ type === "textarea"
88
+ ? html`<sp-textfield multiline size="s" value=${staticVal} @input=${onInput}></sp-textfield>`
89
+ : type === "checkbox"
90
+ ? html`<sp-checkbox
91
+ ?checked=${!!staticVal}
92
+ @change=${(/** @type {any} */ e) => onChange(e.target.checked)}
93
+ ></sp-checkbox>`
94
+ : html`<sp-textfield size="s" value=${staticVal} @input=${onInput}></sp-textfield>`;
95
+
96
+ const boundTpl = html`
97
+ <sp-picker
98
+ size="s"
99
+ quiet
100
+ placeholder="— select signal —"
101
+ value=${isBound && rawValue.$ref ? rawValue.$ref : nothing}
102
+ @change=${(/** @type {any} */ e) => {
103
+ if (e.target.value) onChange({ $ref: e.target.value });
104
+ else onChange(undefined);
105
+ }}
106
+ >
107
+ ${signalDefs.map(
108
+ ([defName]) => html`<sp-menu-item value=${`#/state/${defName}`}>${defName}</sp-menu-item>`,
109
+ )}
110
+ ${extraSignals
111
+ ? html`
112
+ <sp-menu-divider></sp-menu-divider>
113
+ ${extraSignals.map(
114
+ (/** @type {any} */ sig) =>
115
+ html`<sp-menu-item value=${sig.value}>${sig.label}</sp-menu-item>`,
116
+ )}
117
+ `
118
+ : nothing}
119
+ </sp-picker>
120
+ `;
121
+
122
+ const onToggle = () => {
123
+ if (isBound) {
124
+ const ref = rawValue.$ref;
125
+ const defName = ref.startsWith("#/state/") ? ref.slice(8) : ref;
126
+ const def = defs[defName];
127
+ let staticVal = "";
128
+ if (def && def.default !== undefined)
129
+ staticVal =
130
+ typeof def.default === "object" ? JSON.stringify(def.default) : String(def.default);
131
+ onChange(staticVal || undefined);
132
+ } else {
133
+ if (signalDefs.length > 0) {
134
+ onChange({ $ref: `#/state/${signalDefs[0][0]}` });
135
+ } else if (extraSignals?.length > 0) {
136
+ onChange({ $ref: extraSignals[0].value });
137
+ }
138
+ }
139
+ };
140
+
141
+ return html`
142
+ <div class="field-row">
143
+ <sp-field-label size="s">${label}</sp-field-label>
144
+ ${isBound ? boundTpl : staticTpl}
145
+ <sp-action-button
146
+ size="xs"
147
+ quiet
148
+ title=${isBound ? "Unbind (switch to static)" : "Bind to signal"}
149
+ @click=${onToggle}
150
+ >${isBound ? "\u26A1" : "\u2194"}</sp-action-button
151
+ >
152
+ </div>
153
+ `;
154
+ }
155
+
156
+ /** Key-value pair row for styles / attributes */
157
+ function kvRow(
158
+ /** @type {any} */ key,
159
+ /** @type {any} */ value,
160
+ /** @type {any} */ onChange,
161
+ /** @type {any} */ onDelete,
162
+ /** @type {any} */ datalistId = null,
163
+ ) {
164
+ /** @type {any} */
165
+ let debounceTimer;
166
+ let currentKey = key;
167
+ let currentVal = value;
168
+ const commit = () => {
169
+ clearTimeout(debounceTimer);
170
+ debounceTimer = setTimeout(() => onChange(currentKey, currentVal), 400);
171
+ };
172
+ const placeholder = datalistId === "css-props" ? getCssInitialMap().get(key) || "" : "";
173
+ return html`
174
+ <div class="kv-row">
175
+ <sp-textfield
176
+ size="s"
177
+ class="kv-key"
178
+ value=${key}
179
+ @input=${(/** @type {any} */ e) => {
180
+ currentKey = e.target.value;
181
+ commit();
182
+ }}
183
+ @change=${datalistId === "css-props"
184
+ ? (/** @type {any} */ e) => {
185
+ const el = e.target.closest(".kv-row")?.querySelector(".kv-val");
186
+ if (el) el.setAttribute("placeholder", getCssInitialMap().get(e.target.value) || "");
187
+ }
188
+ : nothing}
189
+ ></sp-textfield>
190
+ <sp-textfield
191
+ size="s"
192
+ class="kv-val"
193
+ value=${value}
194
+ placeholder=${placeholder}
195
+ @input=${(/** @type {any} */ e) => {
196
+ currentVal = e.target.value;
197
+ commit();
198
+ }}
199
+ ></sp-textfield>
200
+ <sp-action-button size="xs" quiet @click=${onDelete}>
201
+ <sp-icon-close slot="icon"></sp-icon-close>
202
+ </sp-action-button>
203
+ </div>
204
+ `;
205
+ }
206
+
207
+ // ─── Frontmatter ────────────────────────────────────────────────────────────
208
+
209
+ /** Frontmatter-only panel shown in content mode when no element is selected */
210
+ function renderFrontmatterOnlyPanel() {
211
+ const S = getState();
212
+ const fm = S.content?.frontmatter || {};
213
+ const col = findCollectionSchema(S.documentPath, projectState?.projectConfig);
214
+ const schemaProps = col?.schema?.properties;
215
+ const requiredFields = new Set(col?.schema?.required || []);
216
+
217
+ /** @type {{ field: string; entry: any; value: any }[]} */
218
+ const fields = [];
219
+ if (schemaProps) {
220
+ for (const [field, fieldSchema] of Object.entries(
221
+ /** @type {Record<string, any>} */ (schemaProps),
222
+ )) {
223
+ fields.push({ field, entry: fieldSchema, value: fm[field] });
224
+ }
225
+ for (const [field, value] of Object.entries(fm)) {
226
+ if (!schemaProps[field]) {
227
+ fields.push({
228
+ field,
229
+ entry: { type: typeof value === "boolean" ? "boolean" : "string" },
230
+ value,
231
+ });
232
+ }
233
+ }
234
+ } else {
235
+ for (const [field, value] of Object.entries(fm)) {
236
+ fields.push({
237
+ field,
238
+ entry: { type: typeof value === "boolean" ? "boolean" : "string" },
239
+ value,
240
+ });
241
+ }
242
+ }
243
+
244
+ if (fields.length === 0 && !schemaProps) {
245
+ return html`<div class="empty-state">No frontmatter. Select an element to inspect.</div>`;
246
+ }
247
+
248
+ return html`
249
+ <div class="style-sidebar">
250
+ <sp-accordion allow-multiple size="s">
251
+ <sp-accordion-item label=${col ? `Frontmatter (${col.name})` : "Frontmatter"} open>
252
+ <div class="style-section-body">
253
+ ${fields.map((f) => renderFmFieldRow(f.field, f.entry, f.value, requiredFields))}
254
+ </div>
255
+ </sp-accordion-item>
256
+ </sp-accordion>
257
+ </div>
258
+ `;
259
+ }
260
+
261
+ /** Render a single frontmatter field row (shared between both panels) */
262
+ function renderFmFieldRow(
263
+ /** @type {string} */ field,
264
+ /** @type {any} */ entry,
265
+ /** @type {any} */ value,
266
+ /** @type {Set<string>} */ requiredFields,
267
+ ) {
268
+ const S = getState();
269
+ const isRequired = requiredFields.has(field);
270
+ const label = field.replace(/([A-Z])/g, " $1").replace(/^./, (s) => s.toUpperCase());
271
+ const displayLabel = label + (isRequired ? " *" : "");
272
+ const hasVal = value !== undefined && value !== "" && value !== false;
273
+ const onClear = () => update(updateFrontmatter(S, field, undefined));
274
+
275
+ if (entry.type === "boolean") {
276
+ return renderFieldRow({
277
+ prop: field,
278
+ label: displayLabel,
279
+ hasValue: hasVal,
280
+ onClear,
281
+ widget: html`
282
+ <sp-checkbox
283
+ size="s"
284
+ .checked=${live(!!value)}
285
+ @change=${(/** @type {any} */ e) =>
286
+ update(updateFrontmatter(getState(), field, e.target.checked || undefined))}
287
+ ></sp-checkbox>
288
+ `,
289
+ });
290
+ }
291
+
292
+ if (entry.type === "array") {
293
+ const display = Array.isArray(value) ? value.join(", ") : value || "";
294
+ return renderFieldRow({
295
+ prop: field,
296
+ label: displayLabel,
297
+ hasValue: hasVal,
298
+ onClear,
299
+ widget: html`
300
+ <sp-textfield
301
+ size="s"
302
+ placeholder="comma, separated"
303
+ .value=${live(display)}
304
+ @input=${debouncedStyleCommit(`fm:${field}`, 400, (/** @type {any} */ e) => {
305
+ const arr = e.target.value
306
+ ? e.target.value
307
+ .split(",")
308
+ .map((/** @type {string} */ s) => s.trim())
309
+ .filter(Boolean)
310
+ : undefined;
311
+ update(updateFrontmatter(getState(), field, arr));
312
+ })}
313
+ ></sp-textfield>
314
+ `,
315
+ });
316
+ }
317
+
318
+ if (Array.isArray(entry.enum)) {
319
+ return renderFieldRow({
320
+ prop: field,
321
+ label: displayLabel,
322
+ hasValue: hasVal,
323
+ onClear,
324
+ widget: html`
325
+ <sp-picker
326
+ size="s"
327
+ .value=${live(value || "")}
328
+ @change=${(/** @type {any} */ e) =>
329
+ update(updateFrontmatter(getState(), field, e.target.value || undefined))}
330
+ >
331
+ ${entry.enum.map(
332
+ (/** @type {string} */ opt) => html`<sp-menu-item value=${opt}>${opt}</sp-menu-item>`,
333
+ )}
334
+ </sp-picker>
335
+ `,
336
+ });
337
+ }
338
+
339
+ if (entry.type === "number") {
340
+ return renderFieldRow({
341
+ prop: field,
342
+ label: displayLabel,
343
+ hasValue: hasVal,
344
+ onClear,
345
+ widget: html`
346
+ <sp-number-field
347
+ size="s"
348
+ hide-stepper
349
+ .value=${live(value !== undefined ? Number(value) : undefined)}
350
+ @change=${debouncedStyleCommit(`fm:${field}`, 400, (/** @type {any} */ e) => {
351
+ const v = e.target.value;
352
+ update(updateFrontmatter(getState(), field, isNaN(v) ? undefined : Number(v)));
353
+ })}
354
+ ></sp-number-field>
355
+ `,
356
+ });
357
+ }
358
+
359
+ return renderFieldRow({
360
+ prop: field,
361
+ label: displayLabel,
362
+ hasValue: hasVal,
363
+ onClear,
364
+ widget: html`
365
+ <sp-textfield
366
+ size="s"
367
+ placeholder=${entry.format === "date" ? "YYYY-MM-DD" : ""}
368
+ .value=${live(value || "")}
369
+ @input=${debouncedStyleCommit(`fm:${field}`, 400, (/** @type {any} */ e) => {
370
+ update(updateFrontmatter(getState(), field, e.target.value || undefined));
371
+ })}
372
+ ></sp-textfield>
373
+ `,
374
+ });
375
+ }
376
+
377
+ // ─── Sub-templates ──────────────────────────────────────────────────────────
378
+
379
+ /** Repeater fields template */
380
+ function renderRepeaterFieldsTemplate(
381
+ /** @type {any} */ node,
382
+ /** @type {any} */ path,
383
+ /** @type {any} */ _mapSignals,
384
+ ) {
385
+ const S = getState();
386
+ return html`
387
+ ${bindableFieldRow("Items", "text", node.items, (/** @type {any} */ v) =>
388
+ update(updateProperty(getState(), path, "items", v)),
389
+ )}
390
+ ${node.filter
391
+ ? bindableFieldRow("Filter", "text", node.filter, (/** @type {any} */ v) =>
392
+ update(updateProperty(getState(), path, "filter", v || undefined)),
393
+ )
394
+ : nothing}
395
+ ${node.sort
396
+ ? bindableFieldRow("Sort", "text", node.sort, (/** @type {any} */ v) =>
397
+ update(updateProperty(getState(), path, "sort", v || undefined)),
398
+ )
399
+ : nothing}
400
+ <div style="display:flex;gap:8px;margin-top:4px">
401
+ ${!node.filter
402
+ ? html`<span
403
+ class="kv-add"
404
+ @click=${() => update(updateProperty(getState(), path, "filter", { $ref: "#/state/" }))}
405
+ >+ Add filter</span
406
+ >`
407
+ : nothing}
408
+ ${!node.sort
409
+ ? html`<span
410
+ class="kv-add"
411
+ @click=${() => update(updateProperty(getState(), path, "sort", { $ref: "#/state/" }))}
412
+ >+ Add sort</span
413
+ >`
414
+ : nothing}
415
+ </div>
416
+ ${node.map
417
+ ? html`
418
+ <sp-action-button
419
+ size="s"
420
+ style="margin-top:8px;width:100%"
421
+ @click=${() => update(selectNode(S, [...path, "map"]))}
422
+ >Edit template →</sp-action-button
423
+ >
424
+ `
425
+ : nothing}
426
+ `;
427
+ }
428
+
429
+ /** Switch fields template */
430
+ function renderSwitchFieldsTemplate(
431
+ /** @type {any} */ node,
432
+ /** @type {any} */ path,
433
+ /** @type {any} */ mapSignals,
434
+ ) {
435
+ const caseNames = Object.keys(node.cases || {});
436
+ return html`
437
+ ${bindableFieldRow(
438
+ "Expression",
439
+ "text",
440
+ node.$switch,
441
+ (/** @type {any} */ v) => update(updateProperty(getState(), path, "$switch", v)),
442
+ null,
443
+ mapSignals,
444
+ )}
445
+ <div
446
+ style="font-size:11px;font-weight:600;color:var(--fg-dim);margin:8px 0 4px;text-transform:uppercase;letter-spacing:0.05em"
447
+ >
448
+ Cases
449
+ </div>
450
+ ${caseNames.map((caseName) => {
451
+ /** @type {any} */
452
+ let debounce;
453
+ return html`
454
+ <div class="field-row" style="display:flex;align-items:center;gap:4px;margin-bottom:3px">
455
+ <input
456
+ class="field-input"
457
+ value=${caseName}
458
+ style="flex:1"
459
+ @input=${(/** @type {any} */ e) => {
460
+ clearTimeout(debounce);
461
+ debounce = setTimeout(() => {
462
+ if (e.target.value && e.target.value !== caseName)
463
+ update(renameSwitchCase(getState(), path, caseName, e.target.value));
464
+ }, 500);
465
+ }}
466
+ />
467
+ <span
468
+ class="bind-toggle"
469
+ title="Edit case"
470
+ style="cursor:pointer"
471
+ @click=${(/** @type {any} */ e) => {
472
+ e.stopPropagation();
473
+ update(selectNode(getState(), [...path, "cases", caseName]));
474
+ }}
475
+ >→</span
476
+ >
477
+ <span
478
+ style="cursor:pointer;color:var(--danger);font-size:11px"
479
+ @click=${(/** @type {any} */ e) => {
480
+ e.stopPropagation();
481
+ update(removeSwitchCase(getState(), path, caseName));
482
+ }}
483
+ >✕</span
484
+ >
485
+ </div>
486
+ `;
487
+ })}
488
+ <span
489
+ class="kv-add"
490
+ @click=${() => {
491
+ update(addSwitchCase(getState(), path, `case${caseNames.length + 1}`));
492
+ }}
493
+ >+ Add case</span
494
+ >
495
+ `;
496
+ }
497
+
498
+ /** Component props fields template */
499
+ function renderComponentPropsFieldsTemplate(
500
+ /** @type {any} */ node,
501
+ /** @type {any} */ path,
502
+ /** @type {any} */ mapSignals,
503
+ /** @type {(path: string) => void} */ navigateToComponent,
504
+ ) {
505
+ const S = getState();
506
+ const comp = componentRegistry.find((c) => c.tagName === node.tagName);
507
+ if (!comp) return html`<div class="empty-state">Component not found</div>`;
508
+ const isNpm = comp.source === "npm";
509
+ const currentVals = isNpm ? node.attributes || {} : node.$props || {};
510
+ const updateFn = isNpm
511
+ ? (/** @type {string} */ name, /** @type {any} */ v) =>
512
+ update(updateAttribute(getState(), path, name, v === "" ? undefined : v))
513
+ : (/** @type {string} */ name, /** @type {any} */ v) =>
514
+ update(updateProp(getState(), path, name, v));
515
+
516
+ const defs = S.document.state || {};
517
+ const signalDefs = Object.entries(defs).filter(
518
+ ([, d]) => !d.$handler && d.$prototype !== "Function",
519
+ );
520
+ const extraSignals = mapSignals;
521
+
522
+ return html`
523
+ ${comp.props.map((/** @type {any} */ prop) => {
524
+ const rawValue = currentVals[prop.name];
525
+ const isBound = typeof rawValue === "object" && rawValue !== null && rawValue.$ref;
526
+ const hasVal = rawValue !== undefined && rawValue !== null;
527
+ const parsed = parseCemType(prop.type);
528
+ const onChange = (/** @type {any} */ v) => updateFn(prop.name, v);
529
+
530
+ const clearProp = (/** @type {any} */ e) => {
531
+ e.stopPropagation();
532
+ updateFn(prop.name, undefined);
533
+ };
534
+
535
+ const onToggleBind = () => {
536
+ if (isBound) {
537
+ const ref = rawValue.$ref;
538
+ const defName = ref.startsWith("#/state/") ? ref.slice(8) : ref;
539
+ const def = defs[defName];
540
+ let staticVal = "";
541
+ if (def && def.default !== undefined)
542
+ staticVal =
543
+ typeof def.default === "object" ? JSON.stringify(def.default) : String(def.default);
544
+ onChange(staticVal || undefined);
545
+ } else {
546
+ if (signalDefs.length > 0) {
547
+ onChange({ $ref: `#/state/${signalDefs[0][0]}` });
548
+ } else if (extraSignals?.length > 0) {
549
+ onChange({ $ref: extraSignals[0].value });
550
+ }
551
+ }
552
+ };
553
+
554
+ const boundTpl = html`
555
+ <sp-picker
556
+ size="s"
557
+ quiet
558
+ placeholder="— select signal —"
559
+ value=${isBound && rawValue.$ref ? rawValue.$ref : nothing}
560
+ @change=${(/** @type {any} */ e) => {
561
+ if (e.target.value) onChange({ $ref: e.target.value });
562
+ else onChange(undefined);
563
+ }}
564
+ >
565
+ ${signalDefs.map(
566
+ ([defName]) =>
567
+ html`<sp-menu-item value=${`#/state/${defName}`}>${defName}</sp-menu-item>`,
568
+ )}
569
+ ${extraSignals
570
+ ? html`
571
+ <sp-menu-divider></sp-menu-divider>
572
+ ${extraSignals.map(
573
+ (/** @type {any} */ sig) =>
574
+ html`<sp-menu-item value=${sig.value}>${sig.label}</sp-menu-item>`,
575
+ )}
576
+ `
577
+ : nothing}
578
+ </sp-picker>
579
+ `;
580
+
581
+ /** @type {any} */
582
+ let debounce;
583
+ const staticVal = isBound ? "" : (rawValue ?? "");
584
+ /** @type {any} */
585
+ let widgetTpl;
586
+ if (parsed.kind === "boolean") {
587
+ widgetTpl = html`<sp-checkbox
588
+ size="s"
589
+ .checked=${live(!!staticVal)}
590
+ @change=${(/** @type {any} */ e) => onChange(e.target.checked || undefined)}
591
+ ></sp-checkbox>`;
592
+ } else if (parsed.kind === "number") {
593
+ widgetTpl = html`<sp-number-field
594
+ size="s"
595
+ value=${staticVal}
596
+ @input=${(/** @type {any} */ e) => {
597
+ clearTimeout(debounce);
598
+ debounce = setTimeout(() => onChange(e.target.value), 400);
599
+ }}
600
+ ></sp-number-field>`;
601
+ } else if (parsed.kind === "combobox") {
602
+ const options = /** @type {string[]} */ (/** @type {any} */ (parsed).options);
603
+ widgetTpl = html`<jx-value-selector
604
+ .value=${String(staticVal)}
605
+ size="s"
606
+ placeholder="—"
607
+ .options=${options.map((o) => ({ value: o, label: camelToLabel(o) }))}
608
+ @change=${(/** @type {any} */ e) => onChange(e.detail?.value ?? e.target.value)}
609
+ ></jx-value-selector>`;
610
+ } else {
611
+ widgetTpl = html`<sp-textfield
612
+ size="s"
613
+ value=${staticVal}
614
+ @input=${(/** @type {any} */ e) => {
615
+ clearTimeout(debounce);
616
+ debounce = setTimeout(() => onChange(e.target.value), 400);
617
+ }}
618
+ ></sp-textfield>`;
619
+ }
620
+
621
+ return html`
622
+ <div class="style-row" data-prop=${prop.name}>
623
+ <div class="style-row-label">
624
+ ${hasVal
625
+ ? html`<span class="set-dot" title="Clear ${prop.name}" @click=${clearProp}></span>`
626
+ : nothing}
627
+ <sp-field-label size="s" title=${prop.description || prop.name}
628
+ >${camelToLabel(prop.name)}</sp-field-label
629
+ >
630
+ <sp-action-button
631
+ size="xs"
632
+ quiet
633
+ title=${isBound ? "Unbind (switch to static)" : "Bind to signal"}
634
+ @click=${onToggleBind}
635
+ >${isBound ? "\u26A1" : "\u2194"}</sp-action-button
636
+ >
637
+ </div>
638
+ ${isBound ? boundTpl : widgetTpl}
639
+ </div>
640
+ `;
641
+ })}
642
+ ${comp.props.length === 0 ? html`<div class="empty-state">No props defined</div>` : nothing}
643
+ ${comp.path
644
+ ? html`<span class="kv-add" @click=${() => navigateToComponent(comp.path)}
645
+ >→ Edit definition</span
646
+ >`
647
+ : nothing}
648
+ `;
649
+ }
650
+
651
+ /** Custom attrs fields template */
652
+ function renderCustomAttrsFieldsTemplate(
653
+ /** @type {any} */ node,
654
+ /** @type {any} */ path,
655
+ /** @type {any} */ attrs,
656
+ /** @type {any} */ knownAttrNames,
657
+ ) {
658
+ const customAttrs = Object.entries(attrs).filter(([k]) => !knownAttrNames.has(k));
659
+ return html`
660
+ ${customAttrs.map(([attr, val]) =>
661
+ kvRow(
662
+ attr,
663
+ String(val),
664
+ (/** @type {any} */ newAttr, /** @type {any} */ newVal) => {
665
+ const S = getState();
666
+ if (newAttr !== attr) {
667
+ let s = updateAttribute(S, path, attr, undefined);
668
+ s = updateAttribute(s, path, newAttr, newVal);
669
+ update(s);
670
+ } else {
671
+ update(updateAttribute(S, path, attr, newVal));
672
+ }
673
+ },
674
+ () => update(updateAttribute(getState(), path, attr, undefined)),
675
+ ),
676
+ )}
677
+ <span class="kv-add" @click=${() => update(updateAttribute(getState(), path, "data-", ""))}
678
+ >+ Add attribute</span
679
+ >
680
+ `;
681
+ }
682
+
683
+ // ─── Media breakpoints ──────────────────────────────────────────────────────
684
+
685
+ /** Media breakpoint fields template */
686
+ function renderMediaFieldsTemplate(/** @type {any} */ node) {
687
+ const media = node.$media || {};
688
+ /** @type {any} */
689
+ let baseDebounce;
690
+ const breakpoints = Object.entries(media).filter(([k]) => k !== "--");
691
+
692
+ return html`
693
+ <div class="kv-row" style="align-items:center">
694
+ <span class="field-label" style="width:auto;margin-right:4px">Base width</span>
695
+ <input
696
+ class="field-input"
697
+ style="width:70px;flex:none"
698
+ placeholder="320px"
699
+ value=${media["--"] || ""}
700
+ @input=${(/** @type {any} */ e) => {
701
+ clearTimeout(baseDebounce);
702
+ baseDebounce = setTimeout(() => {
703
+ const val = e.target.value.trim();
704
+ update(updateMedia(getState(), "--", val || undefined));
705
+ }, 400);
706
+ }}
707
+ />
708
+ ${media["--"]
709
+ ? html`<span class="kv-del" @click=${() => update(updateMedia(getState(), "--", undefined))}
710
+ >✕</span
711
+ >`
712
+ : nothing}
713
+ </div>
714
+
715
+ ${breakpoints.map(([name, query]) => mediaBreakpointRowTemplate(name, query))}
716
+
717
+ <div>
718
+ <span
719
+ class="kv-add"
720
+ style=${view.showAddBreakpointForm ? "display:none" : ""}
721
+ @click=${(/** @type {any} */ _e) => {
722
+ view.showAddBreakpointForm = true;
723
+ renderOnly("rightPanel");
724
+ }}
725
+ >+ Add breakpoint</span
726
+ >
727
+ ${view.showAddBreakpointForm
728
+ ? html`
729
+ <div style="margin-top:4px">
730
+ <div style="display:flex;gap:4px;margin-bottom:3px;align-items:center">
731
+ <input
732
+ class="field-input"
733
+ placeholder="Name (e.g. Tablet)"
734
+ style="flex:1"
735
+ @input=${(/** @type {any} */ e) => {
736
+ view.addBreakpointPreview = friendlyNameToMedia(e.target.value) || "";
737
+ renderOnly("rightPanel");
738
+ }}
739
+ />
740
+ <span
741
+ style="font-size:10px;color:var(--fg-dim);font-family:'SF Mono','Fira Code',monospace;white-space:nowrap"
742
+ >${view.addBreakpointPreview}</span
743
+ >
744
+ </div>
745
+ <div style="display:flex;gap:4px;margin-bottom:3px;align-items:center">
746
+ <input class="field-input add-bp-query" value="(min-width: 768px)" style="flex:1" />
747
+ </div>
748
+ <div style="display:flex;gap:4px">
749
+ <button
750
+ class="kv-add"
751
+ style="padding:2px 10px;cursor:pointer"
752
+ @click=${(/** @type {any} */ e) => {
753
+ const wrap = e.target.closest("div").parentElement;
754
+ const nameVal = wrap.querySelector("input")?.value;
755
+ const queryVal = wrap.querySelector(".add-bp-query")?.value?.trim();
756
+ const key = friendlyNameToMedia(nameVal);
757
+ if (key && queryVal) {
758
+ view.showAddBreakpointForm = false;
759
+ view.addBreakpointPreview = "";
760
+ update(updateMedia(getState(), key, queryVal));
761
+ }
762
+ }}
763
+ >
764
+ Add
765
+ </button>
766
+ <button
767
+ class="kv-add"
768
+ style="padding:2px 10px;cursor:pointer;color:var(--fg-dim)"
769
+ @click=${() => {
770
+ view.showAddBreakpointForm = false;
771
+ view.addBreakpointPreview = "";
772
+ renderOnly("rightPanel");
773
+ }}
774
+ >
775
+ Cancel
776
+ </button>
777
+ </div>
778
+ </div>
779
+ `
780
+ : nothing}
781
+ </div>
782
+ `;
783
+ }
784
+
785
+ /** Single media breakpoint row template */
786
+ function mediaBreakpointRowTemplate(/** @type {any} */ name, /** @type {any} */ query) {
787
+ /** @type {any} */
788
+ let debounceTimer;
789
+ let currentRawLabel = name;
790
+ return html`
791
+ <div style="margin-bottom:6px;padding:4px 0;border-bottom:1px solid var(--border)">
792
+ <div style="display:flex;align-items:center;gap:4px;margin-bottom:2px">
793
+ <input
794
+ class="field-input"
795
+ value=${mediaDisplayName(name)}
796
+ style="flex:1;font-weight:600;font-size:12px"
797
+ @input=${(/** @type {any} */ e) => {
798
+ const newKey = friendlyNameToMedia(e.target.value);
799
+ currentRawLabel = newKey || "";
800
+ const rawEl = e.target.parentElement?.querySelector(".bp-raw-label");
801
+ if (rawEl) rawEl.textContent = currentRawLabel;
802
+ clearTimeout(debounceTimer);
803
+ debounceTimer = setTimeout(() => {
804
+ if (newKey && newKey !== name) {
805
+ const queryEl = e.target
806
+ .closest("div[style]")
807
+ ?.parentElement?.querySelector(".bp-query-input");
808
+ const S = getState();
809
+ let s = updateMedia(S, name, undefined);
810
+ s = updateMedia(s, newKey, queryEl?.value || query);
811
+ update(s);
812
+ }
813
+ }, 600);
814
+ }}
815
+ />
816
+ <span
817
+ class="bp-raw-label"
818
+ style="font-size:10px;color:var(--fg-dim);font-family:'SF Mono','Fira Code',monospace;white-space:nowrap"
819
+ >${name}</span
820
+ >
821
+ <span class="kv-del" @click=${() => update(updateMedia(getState(), name, undefined))}
822
+ >✕</span
823
+ >
824
+ </div>
825
+ <div style="display:flex;gap:4px;align-items:center">
826
+ <input
827
+ class="field-input bp-query-input"
828
+ value=${query}
829
+ style="flex:1"
830
+ @input=${(/** @type {any} */ e) => {
831
+ clearTimeout(debounceTimer);
832
+ debounceTimer = setTimeout(
833
+ () => update(updateMedia(getState(), name, e.target.value)),
834
+ 400,
835
+ );
836
+ }}
837
+ />
838
+ </div>
839
+ </div>
840
+ `;
841
+ }
842
+
843
+ // ─── Main entry point ───────────────────────────────────────────────────────
844
+
845
+ /**
846
+ * Properties panel — lit-html template with accordion sections.
847
+ *
848
+ * @param {{ navigateToComponent: (path: string) => void }} ctx
849
+ */
850
+ export function renderPropertiesPanelTemplate(ctx) {
851
+ const S = getState();
852
+
853
+ if (!S.selection) {
854
+ if (S.mode === "content") {
855
+ return renderFrontmatterOnlyPanel();
856
+ }
857
+ return html`<div class="empty-state">Select an element to inspect</div>`;
858
+ }
859
+ const node = getNodeAtPath(S.document, S.selection);
860
+ if (!node) return html`<div class="empty-state">Node not found</div>`;
861
+
862
+ const path = S.selection;
863
+ const isMapNode = node.$prototype === "Array";
864
+ const isMapParent =
865
+ node.children && typeof node.children === "object" && node.children.$prototype === "Array";
866
+ const isSwitchNode = !!node.$switch;
867
+ const isCustomInstance = (node.tagName || "").includes("-");
868
+ const isRoot = path.length === 0;
869
+ const tagName = node.tagName || "div";
870
+ const attrs = node.attributes || {};
871
+
872
+ const mapSignals = isInsideMapTemplate(path)
873
+ ? [
874
+ { value: "$map/item", label: "$map/item" },
875
+ { value: "$map/index", label: "$map/index" },
876
+ ]
877
+ : null;
878
+
879
+ function renderAttrRow(
880
+ /** @type {any} */ attr,
881
+ /** @type {any} */ entry,
882
+ /** @type {any} */ value,
883
+ ) {
884
+ const type = inferInputType(entry);
885
+ const hasVal = value !== undefined && value !== "";
886
+
887
+ if (entry.type === "boolean") {
888
+ return renderFieldRow({
889
+ prop: attr,
890
+ label: attrLabel(entry, attr),
891
+ hasValue: hasVal,
892
+ onClear: () => update(updateAttribute(getState(), path, attr, undefined)),
893
+ widget: html`
894
+ <sp-checkbox
895
+ size="s"
896
+ .checked=${live(!!value)}
897
+ @change=${(/** @type {any} */ e) =>
898
+ update(updateAttribute(getState(), path, attr, e.target.checked || undefined))}
899
+ >
900
+ </sp-checkbox>
901
+ `,
902
+ });
903
+ }
904
+
905
+ return renderFieldRow({
906
+ prop: attr,
907
+ label: attrLabel(entry, attr),
908
+ hasValue: hasVal,
909
+ onClear: () => update(updateAttribute(getState(), path, attr, undefined)),
910
+ widget: widgetForType(type, entry, attr, value || "", (/** @type {any} */ v) =>
911
+ update(updateAttribute(getState(), path, attr, v || undefined)),
912
+ ),
913
+ });
914
+ }
915
+
916
+ // ── Collect applicable attributes from html-meta ──
917
+ const applicableAttrs = /** @type {Record<string, any>} */ ({});
918
+ for (const [attr, entry] of /** @type {[string, any][]} */ (Object.entries(htmlMeta.$defs))) {
919
+ if (!entry.$elements || entry.$elements.includes(tagName)) {
920
+ applicableAttrs[attr] = entry;
921
+ }
922
+ }
923
+
924
+ const attrSections = /** @type {Record<string, any[]>} */ ({});
925
+ for (const sec of htmlMeta.$sections) attrSections[sec.key] = [];
926
+ for (const [attr, entry] of Object.entries(applicableAttrs)) {
927
+ const secKey = entry.$section;
928
+ if (attrSections[secKey]) attrSections[secKey].push({ name: attr, entry });
929
+ }
930
+ for (const sec of htmlMeta.$sections) {
931
+ attrSections[sec.key].sort(
932
+ (/** @type {any} */ a, /** @type {any} */ b) => a.entry.$order - b.entry.$order,
933
+ );
934
+ }
935
+
936
+ const knownAttrNames = new Set(Object.keys(applicableAttrs));
937
+ if (isCustomInstance) {
938
+ const comp = componentRegistry.find((c) => c.tagName === node.tagName);
939
+ if (comp) for (const p of comp.props) knownAttrNames.add(p.name);
940
+ }
941
+ const customAttrs = Object.entries(attrs).filter(([k]) => !knownAttrNames.has(k));
942
+
943
+ const autoOpen = new Set();
944
+ for (const [attr] of Object.entries(attrs)) {
945
+ const entry = applicableAttrs[attr];
946
+ if (entry) autoOpen.add(entry.$section);
947
+ }
948
+ if (customAttrs.length > 0) autoOpen.add("__custom");
949
+
950
+ function isSectionOpen(/** @type {any} */ key) {
951
+ if (S.ui.inspectorSections[key] !== undefined) return S.ui.inspectorSections[key];
952
+ return autoOpen.has(key);
953
+ }
954
+
955
+ function toggleSection(/** @type {any} */ key) {
956
+ const current = isSectionOpen(key);
957
+ updateUi("inspectorSections", { ...S.ui.inspectorSections, [key]: !current });
958
+ }
959
+
960
+ // ── Build section templates ─────────────────────────────────────────
961
+
962
+ const elemT = html`
963
+ <sp-accordion-item
964
+ label="Element"
965
+ ?open=${isSectionOpen("__element") !== false}
966
+ @sp-accordion-item-toggle=${() => toggleSection("__element")}
967
+ >
968
+ <div class="style-section-body">
969
+ <div class="style-row" data-prop="tagName">
970
+ <div class="style-row-label">
971
+ <sp-field-label size="s">Tag</sp-field-label>
972
+ </div>
973
+ <sp-textfield
974
+ size="s"
975
+ .value=${live(tagName)}
976
+ autocomplete="off"
977
+ list="tag-names"
978
+ @input=${debouncedStyleCommit("prop:tagName", 400, (/** @type {any} */ e) => {
979
+ update(updateProperty(getState(), path, "tagName", e.target.value || undefined));
980
+ })}
981
+ ></sp-textfield>
982
+ </div>
983
+ <div class="style-row" data-prop="$id">
984
+ <div class="style-row-label">
985
+ ${node.$id
986
+ ? html`<span
987
+ class="set-dot"
988
+ title="Clear $id"
989
+ @click=${(/** @type {any} */ e) => {
990
+ e.stopPropagation();
991
+ update(updateProperty(getState(), path, "$id", undefined));
992
+ }}
993
+ ></span>`
994
+ : nothing}
995
+ <sp-field-label size="s">ID</sp-field-label>
996
+ </div>
997
+ <sp-textfield
998
+ size="s"
999
+ .value=${live(node.$id || "")}
1000
+ @input=${debouncedStyleCommit("prop:$id", 400, (/** @type {any} */ e) => {
1001
+ update(updateProperty(getState(), path, "$id", e.target.value || undefined));
1002
+ })}
1003
+ ></sp-textfield>
1004
+ </div>
1005
+ <div class="style-row" data-prop="className">
1006
+ <div class="style-row-label">
1007
+ ${node.className
1008
+ ? html`<span
1009
+ class="set-dot"
1010
+ title="Clear class"
1011
+ @click=${(/** @type {any} */ e) => {
1012
+ e.stopPropagation();
1013
+ update(updateProperty(getState(), path, "className", undefined));
1014
+ }}
1015
+ ></span>`
1016
+ : nothing}
1017
+ <sp-field-label size="s">Class</sp-field-label>
1018
+ </div>
1019
+ <sp-textfield
1020
+ size="s"
1021
+ .value=${live(node.className || "")}
1022
+ @input=${debouncedStyleCommit("prop:className", 400, (/** @type {any} */ e) => {
1023
+ update(updateProperty(getState(), path, "className", e.target.value || undefined));
1024
+ })}
1025
+ ></sp-textfield>
1026
+ </div>
1027
+ ${!Array.isArray(node.children) || node.children.length === 0
1028
+ ? html`
1029
+ <div class="style-row" data-prop="textContent">
1030
+ <div class="style-row-label">
1031
+ ${node.textContent !== undefined
1032
+ ? html`<span
1033
+ class="set-dot"
1034
+ title="Clear text"
1035
+ @click=${(/** @type {any} */ e) => {
1036
+ e.stopPropagation();
1037
+ update(updateProperty(getState(), path, "textContent", undefined));
1038
+ }}
1039
+ ></span>`
1040
+ : nothing}
1041
+ <sp-field-label size="s">Text Content</sp-field-label>
1042
+ </div>
1043
+ <sp-textfield
1044
+ size="s"
1045
+ multiline
1046
+ .value=${live(
1047
+ typeof node.textContent === "string"
1048
+ ? node.textContent
1049
+ : (node.textContent ?? ""),
1050
+ )}
1051
+ @input=${debouncedStyleCommit("prop:textContent", 400, (/** @type {any} */ e) => {
1052
+ update(
1053
+ updateProperty(getState(), path, "textContent", e.target.value || undefined),
1054
+ );
1055
+ })}
1056
+ ></sp-textfield>
1057
+ </div>
1058
+ `
1059
+ : nothing}
1060
+ <div class="style-row" data-prop="hidden">
1061
+ <div class="style-row-label">
1062
+ ${node.hidden
1063
+ ? html`<span
1064
+ class="set-dot"
1065
+ title="Clear hidden"
1066
+ @click=${(/** @type {any} */ e) => {
1067
+ e.stopPropagation();
1068
+ update(updateProperty(getState(), path, "hidden", undefined));
1069
+ }}
1070
+ ></span>`
1071
+ : nothing}
1072
+ <sp-field-label size="s">Hidden</sp-field-label>
1073
+ </div>
1074
+ <sp-checkbox
1075
+ size="s"
1076
+ .checked=${live(!!node.hidden)}
1077
+ @change=${(/** @type {any} */ e) =>
1078
+ update(updateProperty(getState(), path, "hidden", e.target.checked || undefined))}
1079
+ >
1080
+ </sp-checkbox>
1081
+ </div>
1082
+ ${isMapParent
1083
+ ? html`
1084
+ <div style="font-size:10px;color:var(--fg-dim);padding:4px 0;font-style:italic">
1085
+ Children: Repeater (select in layers to configure)
1086
+ </div>
1087
+ `
1088
+ : nothing}
1089
+ </div>
1090
+ </sp-accordion-item>
1091
+ `;
1092
+
1093
+ const repeaterT = isMapNode
1094
+ ? html`
1095
+ <sp-accordion-item label="Repeater" open>
1096
+ <div class="style-section-body">
1097
+ ${renderRepeaterFieldsTemplate(node, path, mapSignals)}
1098
+ </div>
1099
+ </sp-accordion-item>
1100
+ `
1101
+ : nothing;
1102
+
1103
+ const switchT = isSwitchNode
1104
+ ? html`
1105
+ <sp-accordion-item label="Switch" open>
1106
+ <div class="style-section-body">
1107
+ ${renderSwitchFieldsTemplate(node, path, mapSignals)}
1108
+ </div>
1109
+ </sp-accordion-item>
1110
+ `
1111
+ : nothing;
1112
+
1113
+ const observedAttrsT =
1114
+ isCustomElementDoc(S) && isRoot
1115
+ ? (() => {
1116
+ const state = S.document.state || {};
1117
+ const entries = Object.entries(state).filter(([, d]) => d.attribute);
1118
+ return html`
1119
+ <sp-accordion-item label="Observed Attributes" ?open=${isSectionOpen("__observed")}>
1120
+ <div class="style-section-body">
1121
+ ${entries.length === 0
1122
+ ? html`<div class="empty-state">
1123
+ No attributes declared. Set "attribute" on a state entry.
1124
+ </div>`
1125
+ : entries.map(
1126
+ ([key, d]) => html`
1127
+ <div
1128
+ style="display:flex;gap:6px;align-items:center;padding:2px 0;font-size:11px"
1129
+ >
1130
+ <code style="font-family:monospace;color:var(--accent)"
1131
+ >${d.attribute}</code
1132
+ >
1133
+ <span style="color:var(--fg-dim)"> → </span>
1134
+ <span>${key}</span>
1135
+ ${d.type
1136
+ ? html`<span style="margin-left:auto;color:var(--fg-dim);font-size:10px"
1137
+ >${d.type}</span
1138
+ >`
1139
+ : nothing}
1140
+ ${d.reflects
1141
+ ? html`<span
1142
+ style="font-size:9px;background:var(--bg-hover);padding:1px 4px;border-radius:3px"
1143
+ >reflects</span
1144
+ >`
1145
+ : nothing}
1146
+ </div>
1147
+ `,
1148
+ )}
1149
+ </div>
1150
+ </sp-accordion-item>
1151
+ `;
1152
+ })()
1153
+ : nothing;
1154
+
1155
+ const compPropsT = isCustomInstance
1156
+ ? html`
1157
+ <sp-accordion-item label="Component Props" open>
1158
+ <div class="style-section-body">
1159
+ ${renderComponentPropsFieldsTemplate(node, path, mapSignals, ctx.navigateToComponent)}
1160
+ </div>
1161
+ </sp-accordion-item>
1162
+ `
1163
+ : nothing;
1164
+
1165
+ const attrSectionTemplates = htmlMeta.$sections
1166
+ .filter((sec) => attrSections[sec.key].length > 0)
1167
+ .map((sec) => {
1168
+ const sectionAttrs = attrSections[sec.key];
1169
+ const hasAnySet = sectionAttrs.some((/** @type {any} */ a) => attrs[a.name] !== undefined);
1170
+ return html`
1171
+ <sp-accordion-item
1172
+ label=${sec.label}
1173
+ ?open=${isSectionOpen(sec.key)}
1174
+ @sp-accordion-item-toggle=${() => toggleSection(sec.key)}
1175
+ >
1176
+ ${hasAnySet
1177
+ ? html`<span slot="heading" class="set-dot set-dot--section"></span>`
1178
+ : nothing}
1179
+ <div class="style-section-body">
1180
+ ${sectionAttrs.map((/** @type {any} */ a) =>
1181
+ renderAttrRow(a.name, a.entry, attrs[a.name]),
1182
+ )}
1183
+ </div>
1184
+ </sp-accordion-item>
1185
+ `;
1186
+ });
1187
+
1188
+ const customSectionT =
1189
+ customAttrs.length > 0 || Object.keys(attrs).length > 0
1190
+ ? html`
1191
+ <sp-accordion-item
1192
+ label="Custom"
1193
+ ?open=${isSectionOpen("__custom")}
1194
+ @sp-accordion-item-toggle=${() => toggleSection("__custom")}
1195
+ >
1196
+ ${customAttrs.length > 0
1197
+ ? html`<span slot="heading" class="set-dot set-dot--section"></span>`
1198
+ : nothing}
1199
+ <div class="style-section-body">
1200
+ ${renderCustomAttrsFieldsTemplate(node, path, attrs, knownAttrNames)}
1201
+ </div>
1202
+ </sp-accordion-item>
1203
+ `
1204
+ : nothing;
1205
+
1206
+ const mediaT = isRoot
1207
+ ? html`
1208
+ <sp-accordion-item
1209
+ label="Media"
1210
+ ?open=${isSectionOpen("__media")}
1211
+ @sp-accordion-item-toggle=${() => toggleSection("__media")}
1212
+ >
1213
+ <div class="style-section-body">${renderMediaFieldsTemplate(node)}</div>
1214
+ </sp-accordion-item>
1215
+ `
1216
+ : nothing;
1217
+
1218
+ const cssPropsT =
1219
+ isCustomElementDoc(S) && isRoot
1220
+ ? (() => {
1221
+ const style = node.style || {};
1222
+ const cssProps = Object.entries(style).filter(([k]) => k.startsWith("--"));
1223
+ if (cssProps.length === 0) return nothing;
1224
+ return html`
1225
+ <sp-accordion-item
1226
+ label="CSS Properties"
1227
+ ?open=${isSectionOpen("__cssprops")}
1228
+ @sp-accordion-item-toggle=${() => toggleSection("__cssprops")}
1229
+ >
1230
+ <div class="style-section-body">
1231
+ ${cssProps.map(
1232
+ ([prop, val]) => html`
1233
+ <div
1234
+ style="display:flex;gap:6px;align-items:center;padding:2px 0;font-size:11px"
1235
+ >
1236
+ <code style="font-family:monospace;color:var(--accent)">${prop}</code>
1237
+ <span style="margin-left:auto;color:var(--fg-dim)">${String(val)}</span>
1238
+ </div>
1239
+ `,
1240
+ )}
1241
+ </div>
1242
+ </sp-accordion-item>
1243
+ `;
1244
+ })()
1245
+ : nothing;
1246
+
1247
+ const cssPartsT =
1248
+ isCustomElementDoc(S) && isRoot
1249
+ ? (() => {
1250
+ const parts = collectCssParts(S.document);
1251
+ if (parts.length === 0) return nothing;
1252
+ return html`
1253
+ <sp-accordion-item
1254
+ label="CSS Parts"
1255
+ ?open=${isSectionOpen("__cssparts")}
1256
+ @sp-accordion-item-toggle=${() => toggleSection("__cssparts")}
1257
+ >
1258
+ <div class="style-section-body">
1259
+ ${parts.map(
1260
+ (p) => html`
1261
+ <div
1262
+ style="display:flex;gap:6px;align-items:center;padding:2px 0;font-size:11px"
1263
+ >
1264
+ <code style="font-family:monospace;color:var(--accent)">${p.name}</code>
1265
+ <span style="color:var(--fg-dim)">&lt;${p.tag}&gt;</span>
1266
+ </div>
1267
+ `,
1268
+ )}
1269
+ </div>
1270
+ </sp-accordion-item>
1271
+ `;
1272
+ })()
1273
+ : nothing;
1274
+
1275
+ const frontmatterT =
1276
+ S.mode === "content"
1277
+ ? (() => {
1278
+ const fm = S.content?.frontmatter || {};
1279
+ const col = findCollectionSchema(S.documentPath, projectState?.projectConfig);
1280
+ const schemaProps = col?.schema?.properties;
1281
+ const requiredFields = new Set(col?.schema?.required || []);
1282
+
1283
+ /** @type {{ field: string; entry: any; value: any }[]} */
1284
+ const fields = [];
1285
+ if (schemaProps) {
1286
+ for (const [field, fieldSchema] of Object.entries(
1287
+ /** @type {Record<string, any>} */ (schemaProps),
1288
+ )) {
1289
+ fields.push({ field, entry: fieldSchema, value: fm[field] });
1290
+ }
1291
+ for (const [field, value] of Object.entries(fm)) {
1292
+ if (!schemaProps[field]) {
1293
+ fields.push({
1294
+ field,
1295
+ entry: { type: typeof value === "boolean" ? "boolean" : "string" },
1296
+ value,
1297
+ });
1298
+ }
1299
+ }
1300
+ } else {
1301
+ for (const [field, value] of Object.entries(fm)) {
1302
+ fields.push({
1303
+ field,
1304
+ entry: { type: typeof value === "boolean" ? "boolean" : "string" },
1305
+ value,
1306
+ });
1307
+ }
1308
+ }
1309
+
1310
+ if (fields.length === 0 && !schemaProps) return nothing;
1311
+
1312
+ return html`
1313
+ <sp-accordion-item
1314
+ label=${col ? `Frontmatter (${col.name})` : "Frontmatter"}
1315
+ ?open=${isSectionOpen("__frontmatter") !== false}
1316
+ @sp-accordion-item-toggle=${() => toggleSection("__frontmatter")}
1317
+ >
1318
+ <div class="style-section-body">
1319
+ ${fields.map((f) => renderFmFieldRow(f.field, f.entry, f.value, requiredFields))}
1320
+ </div>
1321
+ </sp-accordion-item>
1322
+ `;
1323
+ })()
1324
+ : nothing;
1325
+
1326
+ // ── Assemble ──
1327
+ const tpl = html`
1328
+ <div class="style-sidebar">
1329
+ <sp-accordion allow-multiple size="s">
1330
+ ${frontmatterT} ${isMapNode ? repeaterT : elemT} ${isMapNode ? nothing : observedAttrsT}
1331
+ ${isMapNode ? nothing : switchT} ${isMapNode ? nothing : compPropsT}
1332
+ ${isMapNode ? nothing : attrSectionTemplates} ${isMapNode ? nothing : customSectionT}
1333
+ ${isMapNode ? nothing : mediaT} ${isMapNode ? nothing : cssPropsT}
1334
+ ${isMapNode ? nothing : cssPartsT}
1335
+ </sp-accordion>
1336
+ </div>
1337
+ `;
1338
+
1339
+ return tpl;
1340
+ }