@jxsuite/studio 0.10.2 → 0.13.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 (53) hide show
  1. package/README.md +82 -0
  2. package/dist/studio.js +5114 -3424
  3. package/dist/studio.js.map +65 -49
  4. package/package.json +6 -3
  5. package/src/browse/browse.js +27 -3
  6. package/src/canvas/canvas-diff.js +184 -0
  7. package/src/canvas/canvas-helpers.js +10 -14
  8. package/src/canvas/canvas-live-render.js +136 -17
  9. package/src/canvas/canvas-render.js +154 -21
  10. package/src/canvas/canvas-utils.js +21 -23
  11. package/src/editor/component-inline-edit.js +54 -41
  12. package/src/editor/content-inline-edit.js +46 -47
  13. package/src/editor/context-menu.js +63 -39
  14. package/src/editor/convert-to-component.js +11 -14
  15. package/src/editor/insertion-helper.js +8 -10
  16. package/src/editor/shortcuts.js +69 -39
  17. package/src/files/components.js +15 -4
  18. package/src/files/files.js +72 -24
  19. package/src/panels/activity-bar.js +29 -7
  20. package/src/panels/block-action-bar.js +104 -80
  21. package/src/panels/canvas-dnd.js +132 -50
  22. package/src/panels/dnd.js +32 -28
  23. package/src/panels/editors.js +7 -14
  24. package/src/panels/elements-panel.js +9 -3
  25. package/src/panels/events-panel.js +44 -39
  26. package/src/panels/git-panel.js +45 -3
  27. package/src/panels/layers-panel.js +16 -11
  28. package/src/panels/left-panel.js +108 -43
  29. package/src/panels/overlays.js +97 -35
  30. package/src/panels/panel-events.js +18 -11
  31. package/src/panels/properties-panel.js +467 -98
  32. package/src/panels/right-panel.js +85 -37
  33. package/src/panels/shared.js +0 -22
  34. package/src/panels/signals-panel.js +125 -54
  35. package/src/panels/statusbar.js +23 -8
  36. package/src/panels/style-inputs.js +4 -2
  37. package/src/panels/style-panel.js +128 -105
  38. package/src/panels/stylebook-panel.js +42 -17
  39. package/src/panels/tab-strip.js +124 -0
  40. package/src/panels/toolbar.js +39 -9
  41. package/src/reactivity.js +28 -0
  42. package/src/settings/content-types-editor.js +78 -8
  43. package/src/settings/defs-editor.js +56 -7
  44. package/src/settings/schema-field-ui.js +99 -11
  45. package/src/site-context.js +105 -0
  46. package/src/state.js +0 -456
  47. package/src/store.js +105 -124
  48. package/src/studio.js +112 -121
  49. package/src/tabs/tab.js +153 -0
  50. package/src/tabs/transact.js +406 -0
  51. package/src/ui/spectrum.js +2 -0
  52. package/src/view.js +3 -0
  53. package/src/workspace/workspace.js +89 -0
@@ -2,24 +2,19 @@
2
2
 
3
3
  import { html, nothing } from "lit-html";
4
4
  import { live } from "lit-html/directives/live.js";
5
+ import { getNodeAtPath, debouncedStyleCommit, renderOnly, projectState } from "../store.js";
5
6
  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";
7
+ transactDoc,
8
+ mutateUpdateProperty,
9
+ mutateUpdateAttribute,
10
+ mutateUpdateProp,
11
+ mutateUpdateFrontmatter,
12
+ mutateUpdateMedia,
13
+ mutateAddSwitchCase,
14
+ mutateRemoveSwitchCase,
15
+ mutateRenameSwitchCase,
16
+ } from "../tabs/transact.js";
17
+ import { activeTab } from "../workspace/workspace.js";
23
18
  import { view } from "../view.js";
24
19
  import { componentRegistry } from "../files/components.js";
25
20
  import { widgetForType } from "./style-inputs.js";
@@ -35,6 +30,10 @@ import {
35
30
  import { isCustomElementDoc, collectCssParts } from "./signals-panel.js";
36
31
  import { mediaDisplayName } from "./shared.js";
37
32
  import { getCssInitialMap } from "./style-utils.js";
33
+ import { renderMediaPicker } from "../ui/media-picker.js";
34
+ import { renderColorSelector } from "../ui/color-selector.js";
35
+ import { getEffectiveLayoutPath, invalidateLayoutCache } from "../site-context.js";
36
+ import { getPlatform } from "../platform.js";
38
37
  import htmlMeta from "../../data/html-meta.json";
39
38
 
40
39
  /**
@@ -67,8 +66,8 @@ function bindableFieldRow(
67
66
  /** @type {any} */ filterFn = null,
68
67
  /** @type {any} */ extraSignals = null,
69
68
  ) {
70
- const S = getState();
71
- const defs = S.document.state || {};
69
+ const tab = activeTab.value;
70
+ const defs = tab.doc.document.state || {};
72
71
  const isBound = typeof rawValue === "object" && rawValue !== null && rawValue.$ref;
73
72
 
74
73
  const signalDefs = Object.entries(defs).filter(([, d]) =>
@@ -208,9 +207,9 @@ function kvRow(
208
207
 
209
208
  /** Frontmatter-only panel shown in content mode when no element is selected */
210
209
  function renderFrontmatterOnlyPanel() {
211
- const S = getState();
212
- const fm = S.content?.frontmatter || {};
213
- const col = findContentTypeSchema(S.documentPath, projectState?.projectConfig);
210
+ const tab = activeTab.value;
211
+ const fm = tab.doc.content?.frontmatter || {};
212
+ const col = findContentTypeSchema(tab.documentPath, projectState?.projectConfig);
214
213
  const schemaProps = col?.schema?.properties;
215
214
  const requiredFields = new Set(col?.schema?.required || []);
216
215
 
@@ -245,9 +244,12 @@ function renderFrontmatterOnlyPanel() {
245
244
  return html`<div class="empty-state">No frontmatter. Select an element to inspect.</div>`;
246
245
  }
247
246
 
247
+ const pageT = renderPageSection(tab.doc.document || {});
248
+
248
249
  return html`
249
250
  <div class="style-sidebar">
250
251
  <sp-accordion allow-multiple size="s">
252
+ ${pageT}
251
253
  <sp-accordion-item label=${col ? `Frontmatter (${col.name})` : "Frontmatter"} open>
252
254
  <div class="style-section-body">
253
255
  ${fields.map((f) => renderFmFieldRow(f.field, f.entry, f.value, requiredFields))}
@@ -265,12 +267,12 @@ function renderFmFieldRow(
265
267
  /** @type {any} */ value,
266
268
  /** @type {Set<string>} */ requiredFields,
267
269
  ) {
268
- const S = getState();
269
270
  const isRequired = requiredFields.has(field);
270
271
  const label = field.replace(/([A-Z])/g, " $1").replace(/^./, (s) => s.toUpperCase());
271
272
  const displayLabel = label + (isRequired ? " *" : "");
272
273
  const hasVal = value !== undefined && value !== "" && value !== false;
273
- const onClear = () => update(updateFrontmatter(S, field, undefined));
274
+ const onClear = () =>
275
+ transactDoc(activeTab.value, (t) => mutateUpdateFrontmatter(t, field, undefined));
274
276
 
275
277
  if (entry.type === "boolean") {
276
278
  return renderFieldRow({
@@ -283,7 +285,9 @@ function renderFmFieldRow(
283
285
  size="s"
284
286
  .checked=${live(!!value)}
285
287
  @change=${(/** @type {any} */ e) =>
286
- update(updateFrontmatter(getState(), field, e.target.checked || undefined))}
288
+ transactDoc(activeTab.value, (t) =>
289
+ mutateUpdateFrontmatter(t, field, e.target.checked || undefined),
290
+ )}
287
291
  ></sp-checkbox>
288
292
  `,
289
293
  });
@@ -308,7 +312,7 @@ function renderFmFieldRow(
308
312
  .map((/** @type {string} */ s) => s.trim())
309
313
  .filter(Boolean)
310
314
  : undefined;
311
- update(updateFrontmatter(getState(), field, arr));
315
+ transactDoc(activeTab.value, (t) => mutateUpdateFrontmatter(t, field, arr));
312
316
  })}
313
317
  ></sp-textfield>
314
318
  `,
@@ -326,7 +330,9 @@ function renderFmFieldRow(
326
330
  size="s"
327
331
  .value=${live(value || "")}
328
332
  @change=${(/** @type {any} */ e) =>
329
- update(updateFrontmatter(getState(), field, e.target.value || undefined))}
333
+ transactDoc(activeTab.value, (t) =>
334
+ mutateUpdateFrontmatter(t, field, e.target.value || undefined),
335
+ )}
330
336
  >
331
337
  ${entry.enum.map(
332
338
  (/** @type {string} */ opt) => html`<sp-menu-item value=${opt}>${opt}</sp-menu-item>`,
@@ -336,6 +342,74 @@ function renderFmFieldRow(
336
342
  });
337
343
  }
338
344
 
345
+ if (entry.format === "image") {
346
+ return renderFieldRow({
347
+ prop: field,
348
+ label: displayLabel,
349
+ hasValue: hasVal,
350
+ onClear,
351
+ widget: renderMediaPicker(field, value, (v) =>
352
+ transactDoc(activeTab.value, (t) => mutateUpdateFrontmatter(t, field, v || undefined)),
353
+ ),
354
+ });
355
+ }
356
+
357
+ if (entry.type === "array" && entry.items?.format === "image") {
358
+ const images = Array.isArray(value) ? value : [];
359
+ return renderFieldRow({
360
+ prop: field,
361
+ label: displayLabel,
362
+ hasValue: hasVal,
363
+ onClear,
364
+ widget: html`
365
+ <div class="gallery-picker">
366
+ <div class="gallery-picker-strip">
367
+ ${images.map(
368
+ (img, i) => html`
369
+ <div class="gallery-picker-item">
370
+ <img src=${img} alt="" class="gallery-picker-thumb" />
371
+ <sp-action-button
372
+ size="xs"
373
+ quiet
374
+ title="Remove"
375
+ @click=${() => {
376
+ const next = images.filter((_, idx) => idx !== i);
377
+ transactDoc(activeTab.value, (t) =>
378
+ mutateUpdateFrontmatter(t, field, next.length ? next : undefined),
379
+ );
380
+ }}
381
+ >
382
+ <sp-icon-close slot="icon"></sp-icon-close>
383
+ </sp-action-button>
384
+ </div>
385
+ `,
386
+ )}
387
+ </div>
388
+ ${renderMediaPicker(`${field}:add`, "", (v) => {
389
+ if (!v) return;
390
+ const next = [...images, v];
391
+ transactDoc(activeTab.value, (t) => mutateUpdateFrontmatter(t, field, next));
392
+ })}
393
+ </div>
394
+ `,
395
+ });
396
+ }
397
+
398
+ if (entry.$ref) {
399
+ const targetName = entry.$ref.replace("#/contentTypes/", "");
400
+ const targetDef = projectState?.projectConfig?.contentTypes?.[targetName];
401
+ const picker = renderReferencePicker(field, value, targetName, targetDef, (v) =>
402
+ transactDoc(activeTab.value, (t) => mutateUpdateFrontmatter(t, field, v || undefined)),
403
+ );
404
+ return renderFieldRow({
405
+ prop: field,
406
+ label: displayLabel,
407
+ hasValue: hasVal,
408
+ onClear,
409
+ widget: picker,
410
+ });
411
+ }
412
+
339
413
  if (entry.type === "number") {
340
414
  return renderFieldRow({
341
415
  prop: field,
@@ -349,7 +423,9 @@ function renderFmFieldRow(
349
423
  .value=${live(value !== undefined ? Number(value) : undefined)}
350
424
  @change=${debouncedStyleCommit(`fm:${field}`, 400, (/** @type {any} */ e) => {
351
425
  const v = e.target.value;
352
- update(updateFrontmatter(getState(), field, isNaN(v) ? undefined : Number(v)));
426
+ transactDoc(activeTab.value, (t) =>
427
+ mutateUpdateFrontmatter(t, field, isNaN(v) ? undefined : Number(v)),
428
+ );
353
429
  })}
354
430
  ></sp-number-field>
355
431
  `,
@@ -367,13 +443,87 @@ function renderFmFieldRow(
367
443
  placeholder=${entry.format === "date" ? "YYYY-MM-DD" : ""}
368
444
  .value=${live(value || "")}
369
445
  @input=${debouncedStyleCommit(`fm:${field}`, 400, (/** @type {any} */ e) => {
370
- update(updateFrontmatter(getState(), field, e.target.value || undefined));
446
+ transactDoc(activeTab.value, (t) =>
447
+ mutateUpdateFrontmatter(t, field, e.target.value || undefined),
448
+ );
371
449
  })}
372
450
  ></sp-textfield>
373
451
  `,
374
452
  });
375
453
  }
376
454
 
455
+ // ─── Reference picker ────────────────────────────────────────────────────────
456
+
457
+ /** @type {Map<string, { slug: string; title: string }[]>} */
458
+ const refEntriesCache = new Map();
459
+
460
+ /**
461
+ * Render a reference field as a picker of entries from the target content type.
462
+ *
463
+ * @param {string} field
464
+ * @param {any} value
465
+ * @param {string} targetName
466
+ * @param {any} targetDef
467
+ * @param {(val: any) => void} onCommit
468
+ */
469
+ function renderReferencePicker(field, value, targetName, targetDef, onCommit) {
470
+ if (!targetDef?.source) {
471
+ return html`<sp-textfield
472
+ size="s"
473
+ placeholder="slug"
474
+ .value=${live(value || "")}
475
+ @input=${debouncedStyleCommit(`fm:${field}`, 400, (/** @type {any} */ e) =>
476
+ onCommit(e.target.value),
477
+ )}
478
+ ></sp-textfield>`;
479
+ }
480
+
481
+ const cacheKey = targetName;
482
+ if (!refEntriesCache.has(cacheKey)) {
483
+ loadRefEntries(targetName, targetDef);
484
+ }
485
+ const entries = refEntriesCache.get(cacheKey) || [];
486
+
487
+ return html`
488
+ <sp-picker
489
+ size="s"
490
+ label=${targetName}
491
+ .value=${live(value || "")}
492
+ @change=${(/** @type {any} */ e) => onCommit(e.target.value || undefined)}
493
+ >
494
+ <sp-menu-item value="">— none —</sp-menu-item>
495
+ ${entries.map(
496
+ (ent) => html`<sp-menu-item value=${ent.slug}>${ent.title || ent.slug}</sp-menu-item>`,
497
+ )}
498
+ </sp-picker>
499
+ `;
500
+ }
501
+
502
+ async function loadRefEntries(/** @type {string} */ targetName, /** @type {any} */ targetDef) {
503
+ const platform = getPlatform();
504
+ const sourceDir = targetDef.source.replace(/^\.\//, "").split("/**")[0].split("/*")[0];
505
+ try {
506
+ const listing = await platform.listDirectory(sourceDir);
507
+ const entries = [];
508
+ for (const item of listing) {
509
+ if (item.type === "directory") continue;
510
+ const slug = item.name.replace(/\.[^.]+$/, "");
511
+ let title = slug;
512
+ try {
513
+ const content = await platform.readFile(item.path);
514
+ const match = content.match(/^---[\s\S]*?title:\s*(.+?)[\r\n]/m);
515
+ if (match) title = match[1].trim().replace(/^["']|["']$/g, "");
516
+ } catch {}
517
+ entries.push({ slug, title });
518
+ }
519
+ refEntriesCache.set(targetName, entries);
520
+ } catch {}
521
+ }
522
+
523
+ export function invalidateRefCache() {
524
+ refEntriesCache.clear();
525
+ }
526
+
377
527
  // ─── Sub-templates ──────────────────────────────────────────────────────────
378
528
 
379
529
  /** Repeater fields template */
@@ -382,33 +532,42 @@ function renderRepeaterFieldsTemplate(
382
532
  /** @type {any} */ path,
383
533
  /** @type {any} */ _mapSignals,
384
534
  ) {
385
- const S = getState();
386
535
  return html`
387
536
  ${bindableFieldRow("Items", "text", node.items, (/** @type {any} */ v) =>
388
- update(updateProperty(getState(), path, "items", v)),
537
+ transactDoc(activeTab.value, (t) => mutateUpdateProperty(t, path, "items", v)),
389
538
  )}
390
539
  ${node.filter
391
540
  ? bindableFieldRow("Filter", "text", node.filter, (/** @type {any} */ v) =>
392
- update(updateProperty(getState(), path, "filter", v || undefined)),
541
+ transactDoc(activeTab.value, (t) =>
542
+ mutateUpdateProperty(t, path, "filter", v || undefined),
543
+ ),
393
544
  )
394
545
  : nothing}
395
546
  ${node.sort
396
547
  ? bindableFieldRow("Sort", "text", node.sort, (/** @type {any} */ v) =>
397
- update(updateProperty(getState(), path, "sort", v || undefined)),
548
+ transactDoc(activeTab.value, (t) =>
549
+ mutateUpdateProperty(t, path, "sort", v || undefined),
550
+ ),
398
551
  )
399
552
  : nothing}
400
553
  <div style="display:flex;gap:8px;margin-top:4px">
401
554
  ${!node.filter
402
555
  ? html`<span
403
556
  class="kv-add"
404
- @click=${() => update(updateProperty(getState(), path, "filter", { $ref: "#/state/" }))}
557
+ @click=${() =>
558
+ transactDoc(activeTab.value, (t) =>
559
+ mutateUpdateProperty(t, path, "filter", { $ref: "#/state/" }),
560
+ )}
405
561
  >+ Add filter</span
406
562
  >`
407
563
  : nothing}
408
564
  ${!node.sort
409
565
  ? html`<span
410
566
  class="kv-add"
411
- @click=${() => update(updateProperty(getState(), path, "sort", { $ref: "#/state/" }))}
567
+ @click=${() =>
568
+ transactDoc(activeTab.value, (t) =>
569
+ mutateUpdateProperty(t, path, "sort", { $ref: "#/state/" }),
570
+ )}
412
571
  >+ Add sort</span
413
572
  >`
414
573
  : nothing}
@@ -418,7 +577,9 @@ function renderRepeaterFieldsTemplate(
418
577
  <sp-action-button
419
578
  size="s"
420
579
  style="margin-top:8px;width:100%"
421
- @click=${() => update(selectNode(S, [...path, "map"]))}
580
+ @click=${() => {
581
+ activeTab.value.session.selection = [...path, "map"];
582
+ }}
422
583
  >Edit template →</sp-action-button
423
584
  >
424
585
  `
@@ -438,7 +599,8 @@ function renderSwitchFieldsTemplate(
438
599
  "Expression",
439
600
  "text",
440
601
  node.$switch,
441
- (/** @type {any} */ v) => update(updateProperty(getState(), path, "$switch", v)),
602
+ (/** @type {any} */ v) =>
603
+ transactDoc(activeTab.value, (t) => mutateUpdateProperty(t, path, "$switch", v)),
442
604
  null,
443
605
  mapSignals,
444
606
  )}
@@ -460,7 +622,9 @@ function renderSwitchFieldsTemplate(
460
622
  clearTimeout(debounce);
461
623
  debounce = setTimeout(() => {
462
624
  if (e.target.value && e.target.value !== caseName)
463
- update(renameSwitchCase(getState(), path, caseName, e.target.value));
625
+ transactDoc(activeTab.value, (t) =>
626
+ mutateRenameSwitchCase(t, path, caseName, e.target.value),
627
+ );
464
628
  }, 500);
465
629
  }}
466
630
  />
@@ -470,7 +634,7 @@ function renderSwitchFieldsTemplate(
470
634
  style="cursor:pointer"
471
635
  @click=${(/** @type {any} */ e) => {
472
636
  e.stopPropagation();
473
- update(selectNode(getState(), [...path, "cases", caseName]));
637
+ activeTab.value.session.selection = [...path, "cases", caseName];
474
638
  }}
475
639
  >→</span
476
640
  >
@@ -478,7 +642,7 @@ function renderSwitchFieldsTemplate(
478
642
  style="cursor:pointer;color:var(--danger);font-size:11px"
479
643
  @click=${(/** @type {any} */ e) => {
480
644
  e.stopPropagation();
481
- update(removeSwitchCase(getState(), path, caseName));
645
+ transactDoc(activeTab.value, (t) => mutateRemoveSwitchCase(t, path, caseName));
482
646
  }}
483
647
  >✕</span
484
648
  >
@@ -488,7 +652,9 @@ function renderSwitchFieldsTemplate(
488
652
  <span
489
653
  class="kv-add"
490
654
  @click=${() => {
491
- update(addSwitchCase(getState(), path, `case${caseNames.length + 1}`));
655
+ transactDoc(activeTab.value, (t) =>
656
+ mutateAddSwitchCase(t, path, `case${caseNames.length + 1}`),
657
+ );
492
658
  }}
493
659
  >+ Add case</span
494
660
  >
@@ -502,18 +668,20 @@ function renderComponentPropsFieldsTemplate(
502
668
  /** @type {any} */ mapSignals,
503
669
  /** @type {(path: string) => void} */ navigateToComponent,
504
670
  ) {
505
- const S = getState();
671
+ const tab = activeTab.value;
506
672
  const comp = componentRegistry.find((c) => c.tagName === node.tagName);
507
- if (!comp) return html`<div class="empty-state">Component not found</div>`;
673
+ if (!comp || !comp.props) return html`<div class="empty-state">Component not found</div>`;
508
674
  const isNpm = comp.source === "npm";
509
675
  const currentVals = isNpm ? node.attributes || {} : node.$props || {};
510
676
  const updateFn = isNpm
511
677
  ? (/** @type {string} */ name, /** @type {any} */ v) =>
512
- update(updateAttribute(getState(), path, name, v === "" ? undefined : v))
678
+ transactDoc(activeTab.value, (t) =>
679
+ mutateUpdateAttribute(t, path, name, v === "" ? undefined : v),
680
+ )
513
681
  : (/** @type {string} */ name, /** @type {any} */ v) =>
514
- update(updateProp(getState(), path, name, v));
682
+ transactDoc(activeTab.value, (t) => mutateUpdateProp(t, path, name, v));
515
683
 
516
- const defs = S.document.state || {};
684
+ const defs = tab.doc.document.state || {};
517
685
  const signalDefs = Object.entries(defs).filter(
518
686
  ([, d]) => !d.$handler && d.$prototype !== "Function",
519
687
  );
@@ -583,7 +751,21 @@ function renderComponentPropsFieldsTemplate(
583
751
  const staticVal = isBound ? "" : (rawValue ?? "");
584
752
  /** @type {any} */
585
753
  let widgetTpl;
586
- if (parsed.kind === "boolean") {
754
+ if (prop.format === "image") {
755
+ widgetTpl = renderMediaPicker(prop.name, staticVal, onChange);
756
+ } else if (prop.format === "color") {
757
+ widgetTpl = renderColorSelector(prop.name, staticVal, onChange);
758
+ } else if (prop.format === "date") {
759
+ widgetTpl = html`<sp-textfield
760
+ size="s"
761
+ placeholder="YYYY-MM-DD"
762
+ value=${staticVal}
763
+ @input=${(/** @type {any} */ e) => {
764
+ clearTimeout(debounce);
765
+ debounce = setTimeout(() => onChange(e.target.value), 400);
766
+ }}
767
+ ></sp-textfield>`;
768
+ } else if (parsed.kind === "boolean") {
587
769
  widgetTpl = html`<sp-checkbox
588
770
  size="s"
589
771
  .checked=${live(!!staticVal)}
@@ -662,19 +844,22 @@ function renderCustomAttrsFieldsTemplate(
662
844
  attr,
663
845
  String(val),
664
846
  (/** @type {any} */ newAttr, /** @type {any} */ newVal) => {
665
- const S = getState();
666
847
  if (newAttr !== attr) {
667
- let s = updateAttribute(S, path, attr, undefined);
668
- s = updateAttribute(s, path, newAttr, newVal);
669
- update(s);
848
+ transactDoc(activeTab.value, (t) => {
849
+ mutateUpdateAttribute(t, path, attr, undefined);
850
+ mutateUpdateAttribute(t, path, newAttr, newVal);
851
+ });
670
852
  } else {
671
- update(updateAttribute(S, path, attr, newVal));
853
+ transactDoc(activeTab.value, (t) => mutateUpdateAttribute(t, path, attr, newVal));
672
854
  }
673
855
  },
674
- () => update(updateAttribute(getState(), path, attr, undefined)),
856
+ () => transactDoc(activeTab.value, (t) => mutateUpdateAttribute(t, path, attr, undefined)),
675
857
  ),
676
858
  )}
677
- <span class="kv-add" @click=${() => update(updateAttribute(getState(), path, "data-", ""))}
859
+ <span
860
+ class="kv-add"
861
+ @click=${() =>
862
+ transactDoc(activeTab.value, (t) => mutateUpdateAttribute(t, path, "data-", ""))}
678
863
  >+ Add attribute</span
679
864
  >
680
865
  `;
@@ -701,12 +886,15 @@ function renderMediaFieldsTemplate(/** @type {any} */ node) {
701
886
  clearTimeout(baseDebounce);
702
887
  baseDebounce = setTimeout(() => {
703
888
  const val = e.target.value.trim();
704
- update(updateMedia(getState(), "--", val || undefined));
889
+ transactDoc(activeTab.value, (t) => mutateUpdateMedia(t, "--", val || undefined));
705
890
  }, 400);
706
891
  }}
707
892
  />
708
893
  ${media["--"]
709
- ? html`<span class="kv-del" @click=${() => update(updateMedia(getState(), "--", undefined))}
894
+ ? html`<span
895
+ class="kv-del"
896
+ @click=${() =>
897
+ transactDoc(activeTab.value, (t) => mutateUpdateMedia(t, "--", undefined))}
710
898
  >✕</span
711
899
  >`
712
900
  : nothing}
@@ -757,7 +945,7 @@ function renderMediaFieldsTemplate(/** @type {any} */ node) {
757
945
  if (key && queryVal) {
758
946
  view.showAddBreakpointForm = false;
759
947
  view.addBreakpointPreview = "";
760
- update(updateMedia(getState(), key, queryVal));
948
+ transactDoc(activeTab.value, (t) => mutateUpdateMedia(t, key, queryVal));
761
949
  }
762
950
  }}
763
951
  >
@@ -805,10 +993,10 @@ function mediaBreakpointRowTemplate(/** @type {any} */ name, /** @type {any} */
805
993
  const queryEl = e.target
806
994
  .closest("div[style]")
807
995
  ?.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);
996
+ transactDoc(activeTab.value, (t) => {
997
+ mutateUpdateMedia(t, name, undefined);
998
+ mutateUpdateMedia(t, newKey, queryEl?.value || query);
999
+ });
812
1000
  }
813
1001
  }, 600);
814
1002
  }}
@@ -818,7 +1006,9 @@ function mediaBreakpointRowTemplate(/** @type {any} */ name, /** @type {any} */
818
1006
  style="font-size:10px;color:var(--fg-dim);font-family:'SF Mono','Fira Code',monospace;white-space:nowrap"
819
1007
  >${name}</span
820
1008
  >
821
- <span class="kv-del" @click=${() => update(updateMedia(getState(), name, undefined))}
1009
+ <span
1010
+ class="kv-del"
1011
+ @click=${() => transactDoc(activeTab.value, (t) => mutateUpdateMedia(t, name, undefined))}
822
1012
  >✕</span
823
1013
  >
824
1014
  </div>
@@ -830,7 +1020,7 @@ function mediaBreakpointRowTemplate(/** @type {any} */ name, /** @type {any} */
830
1020
  @input=${(/** @type {any} */ e) => {
831
1021
  clearTimeout(debounceTimer);
832
1022
  debounceTimer = setTimeout(
833
- () => update(updateMedia(getState(), name, e.target.value)),
1023
+ () => transactDoc(activeTab.value, (t) => mutateUpdateMedia(t, name, e.target.value)),
834
1024
  400,
835
1025
  );
836
1026
  }}
@@ -840,6 +1030,153 @@ function mediaBreakpointRowTemplate(/** @type {any} */ name, /** @type {any} */
840
1030
  `;
841
1031
  }
842
1032
 
1033
+ // ─── Layout picker ──────────────────────────────────────────────────────────
1034
+
1035
+ /** @type {{ name: string; path: string }[] | null} */
1036
+ let layoutEntries = null;
1037
+
1038
+ async function loadLayoutEntries() {
1039
+ try {
1040
+ const platform = getPlatform();
1041
+ const listing = await platform.listDirectory("layouts");
1042
+ layoutEntries = listing
1043
+ .filter((/** @type {any} */ f) => f.type === "file" && f.name.endsWith(".json"))
1044
+ .map((/** @type {any} */ f) => ({
1045
+ name: f.name.replace(/\.json$/, ""),
1046
+ path: `./layouts/${f.name}`,
1047
+ }));
1048
+ } catch {
1049
+ layoutEntries = [];
1050
+ }
1051
+ renderOnly("rightPanel");
1052
+ }
1053
+
1054
+ export function invalidateLayoutPickerCache() {
1055
+ layoutEntries = null;
1056
+ }
1057
+
1058
+ function isPageDocument(/** @type {any} */ documentPath) {
1059
+ if (!documentPath || !projectState?.isSiteProject) return false;
1060
+ return documentPath.startsWith("pages/") || documentPath.startsWith("./pages/");
1061
+ }
1062
+
1063
+ function renderPageSection(/** @type {any} */ node) {
1064
+ const tab = activeTab.value;
1065
+ if (!isPageDocument(tab.documentPath)) return nothing;
1066
+
1067
+ if (layoutEntries === null) {
1068
+ loadLayoutEntries();
1069
+ return nothing;
1070
+ }
1071
+
1072
+ const currentLayout = node.$layout;
1073
+ const defaultLayout = projectState?.projectConfig?.defaults?.layout;
1074
+ const effectivePath = getEffectiveLayoutPath(currentLayout);
1075
+ const displayValue =
1076
+ currentLayout === false ? "__none__" : currentLayout ? currentLayout : "__default__";
1077
+
1078
+ return html`
1079
+ <sp-accordion-item label="Page" open>
1080
+ <div class="style-section-body">
1081
+ <div class="style-row" data-prop="$layout">
1082
+ <div class="style-row-label">
1083
+ ${currentLayout !== undefined
1084
+ ? html`<span
1085
+ class="set-dot"
1086
+ title="Reset to default"
1087
+ @click=${(/** @type {any} */ e) => {
1088
+ e.stopPropagation();
1089
+ transactDoc(activeTab.value, (t) =>
1090
+ mutateUpdateProperty(t, [], "$layout", undefined),
1091
+ );
1092
+ }}
1093
+ ></span>`
1094
+ : nothing}
1095
+ <sp-field-label size="s">Layout</sp-field-label>
1096
+ </div>
1097
+ <sp-picker
1098
+ size="s"
1099
+ value=${displayValue}
1100
+ @change=${(/** @type {any} */ e) => {
1101
+ const val = e.target.value;
1102
+ if (val === "__default__") {
1103
+ transactDoc(activeTab.value, (t) =>
1104
+ mutateUpdateProperty(t, [], "$layout", undefined),
1105
+ );
1106
+ } else if (val === "__none__") {
1107
+ transactDoc(activeTab.value, (t) => mutateUpdateProperty(t, [], "$layout", false));
1108
+ } else {
1109
+ transactDoc(activeTab.value, (t) => mutateUpdateProperty(t, [], "$layout", val));
1110
+ }
1111
+ invalidateLayoutCache();
1112
+ }}
1113
+ >
1114
+ <sp-menu-item value="__default__"
1115
+ >Default${defaultLayout
1116
+ ? ` (${defaultLayout.replace(/^\.\/layouts\//, "").replace(/\.json$/, "")})`
1117
+ : ""}</sp-menu-item
1118
+ >
1119
+ <sp-menu-item value="__none__">None</sp-menu-item>
1120
+ <sp-menu-divider></sp-menu-divider>
1121
+ ${layoutEntries.map(
1122
+ (/** @type {any} */ l) =>
1123
+ html`<sp-menu-item value=${l.path}>${l.name}</sp-menu-item>`,
1124
+ )}
1125
+ </sp-picker>
1126
+ </div>
1127
+ ${effectivePath
1128
+ ? html`<div style="font-size:10px;color:var(--fg-dim);padding:2px 0;font-style:italic">
1129
+ Wraps page content via &lt;slot&gt; distribution
1130
+ </div>`
1131
+ : nothing}
1132
+ </div>
1133
+ </sp-accordion-item>
1134
+ `;
1135
+ }
1136
+
1137
+ // ─── Layout selection panel ─────────────────────────────────────────────────
1138
+
1139
+ function renderLayoutSelectionPanel(/** @type {any} */ ctx) {
1140
+ const { el, layoutPath } = view.layoutSelection;
1141
+ const tagName = el?.tagName?.toLowerCase() || "element";
1142
+ const className = el?.className || "";
1143
+ const displayPath = layoutPath || "layout";
1144
+
1145
+ return html`
1146
+ <div class="style-sidebar">
1147
+ <sp-accordion allow-multiple size="s">
1148
+ <sp-accordion-item label="Layout Element" open>
1149
+ <div class="style-section-body">
1150
+ <div style="display:flex;align-items:center;gap:6px;margin-bottom:8px">
1151
+ <span
1152
+ style="font-size:9px;padding:2px 6px;background:var(--spectrum-purple-600);color:white;border-radius:3px;text-transform:uppercase;letter-spacing:0.5px"
1153
+ >Layout</span
1154
+ >
1155
+ <code style="font-size:12px;font-family:monospace">&lt;${tagName}&gt;</code>
1156
+ </div>
1157
+ ${className
1158
+ ? html`<div class="style-row">
1159
+ <div class="style-row-label">
1160
+ <sp-field-label size="s">Class</sp-field-label>
1161
+ </div>
1162
+ <span style="font-size:11px;color:var(--fg-dim);word-break:break-all"
1163
+ >${className}</span
1164
+ >
1165
+ </div>`
1166
+ : nothing}
1167
+ <div style="font-size:10px;color:var(--fg-dim);padding:4px 0;font-style:italic">
1168
+ This element is part of the page layout. Edit it by opening the layout file.
1169
+ </div>
1170
+ <span class="kv-add" @click=${() => ctx.navigateToComponent(displayPath)}
1171
+ >Open Layout →</span
1172
+ >
1173
+ </div>
1174
+ </sp-accordion-item>
1175
+ </sp-accordion>
1176
+ </div>
1177
+ `;
1178
+ }
1179
+
843
1180
  // ─── Main entry point ───────────────────────────────────────────────────────
844
1181
 
845
1182
  /**
@@ -848,18 +1185,24 @@ function mediaBreakpointRowTemplate(/** @type {any} */ name, /** @type {any} */
848
1185
  * @param {{ navigateToComponent: (path: string) => void }} ctx
849
1186
  */
850
1187
  export function renderPropertiesPanelTemplate(ctx) {
851
- const S = getState();
1188
+ const tab = activeTab.value;
1189
+ if (!tab) return html`<div class="empty-state">No document loaded</div>`;
1190
+
1191
+ // Layout element selected — show read-only info with link to open layout
1192
+ if (view.layoutSelection) {
1193
+ return renderLayoutSelectionPanel(ctx);
1194
+ }
852
1195
 
853
- if (!S.selection) {
854
- if (S.mode === "content") {
1196
+ if (!tab.session.selection) {
1197
+ if (tab.doc.mode === "content") {
855
1198
  return renderFrontmatterOnlyPanel();
856
1199
  }
857
1200
  return html`<div class="empty-state">Select an element to inspect</div>`;
858
1201
  }
859
- const node = getNodeAtPath(S.document, S.selection);
1202
+ const node = getNodeAtPath(tab.doc.document, tab.session.selection);
860
1203
  if (!node) return html`<div class="empty-state">Node not found</div>`;
861
1204
 
862
- const path = S.selection;
1205
+ const path = tab.session.selection;
863
1206
  const isMapNode = node.$prototype === "Array";
864
1207
  const isMapParent =
865
1208
  node.children && typeof node.children === "object" && node.children.$prototype === "Array";
@@ -889,13 +1232,16 @@ export function renderPropertiesPanelTemplate(ctx) {
889
1232
  prop: attr,
890
1233
  label: attrLabel(entry, attr),
891
1234
  hasValue: hasVal,
892
- onClear: () => update(updateAttribute(getState(), path, attr, undefined)),
1235
+ onClear: () =>
1236
+ transactDoc(activeTab.value, (t) => mutateUpdateAttribute(t, path, attr, undefined)),
893
1237
  widget: html`
894
1238
  <sp-checkbox
895
1239
  size="s"
896
1240
  .checked=${live(!!value)}
897
1241
  @change=${(/** @type {any} */ e) =>
898
- update(updateAttribute(getState(), path, attr, e.target.checked || undefined))}
1242
+ transactDoc(activeTab.value, (t) =>
1243
+ mutateUpdateAttribute(t, path, attr, e.target.checked || undefined),
1244
+ )}
899
1245
  >
900
1246
  </sp-checkbox>
901
1247
  `,
@@ -906,9 +1252,10 @@ export function renderPropertiesPanelTemplate(ctx) {
906
1252
  prop: attr,
907
1253
  label: attrLabel(entry, attr),
908
1254
  hasValue: hasVal,
909
- onClear: () => update(updateAttribute(getState(), path, attr, undefined)),
1255
+ onClear: () =>
1256
+ transactDoc(activeTab.value, (t) => mutateUpdateAttribute(t, path, attr, undefined)),
910
1257
  widget: widgetForType(type, entry, attr, value || "", (/** @type {any} */ v) =>
911
- update(updateAttribute(getState(), path, attr, v || undefined)),
1258
+ transactDoc(activeTab.value, (t) => mutateUpdateAttribute(t, path, attr, v || undefined)),
912
1259
  ),
913
1260
  });
914
1261
  }
@@ -936,7 +1283,7 @@ export function renderPropertiesPanelTemplate(ctx) {
936
1283
  const knownAttrNames = new Set(Object.keys(applicableAttrs));
937
1284
  if (isCustomInstance) {
938
1285
  const comp = componentRegistry.find((c) => c.tagName === node.tagName);
939
- if (comp) for (const p of comp.props) knownAttrNames.add(p.name);
1286
+ if (comp?.props) for (const p of comp.props) knownAttrNames.add(p.name);
940
1287
  }
941
1288
  const customAttrs = Object.entries(attrs).filter(([k]) => !knownAttrNames.has(k));
942
1289
 
@@ -948,13 +1295,17 @@ export function renderPropertiesPanelTemplate(ctx) {
948
1295
  if (customAttrs.length > 0) autoOpen.add("__custom");
949
1296
 
950
1297
  function isSectionOpen(/** @type {any} */ key) {
951
- if (S.ui.inspectorSections[key] !== undefined) return S.ui.inspectorSections[key];
1298
+ if (tab.session.ui.inspectorSections[key] !== undefined)
1299
+ return tab.session.ui.inspectorSections[key];
952
1300
  return autoOpen.has(key);
953
1301
  }
954
1302
 
955
1303
  function toggleSection(/** @type {any} */ key) {
956
1304
  const current = isSectionOpen(key);
957
- updateUi("inspectorSections", { ...S.ui.inspectorSections, [key]: !current });
1305
+ activeTab.value.session.ui.inspectorSections = {
1306
+ ...activeTab.value.session.ui.inspectorSections,
1307
+ [key]: !current,
1308
+ };
958
1309
  }
959
1310
 
960
1311
  // ── Build section templates ─────────────────────────────────────────
@@ -976,7 +1327,9 @@ export function renderPropertiesPanelTemplate(ctx) {
976
1327
  autocomplete="off"
977
1328
  list="tag-names"
978
1329
  @input=${debouncedStyleCommit("prop:tagName", 400, (/** @type {any} */ e) => {
979
- update(updateProperty(getState(), path, "tagName", e.target.value || undefined));
1330
+ transactDoc(activeTab.value, (t) =>
1331
+ mutateUpdateProperty(t, path, "tagName", e.target.value || undefined),
1332
+ );
980
1333
  })}
981
1334
  ></sp-textfield>
982
1335
  </div>
@@ -988,7 +1341,9 @@ export function renderPropertiesPanelTemplate(ctx) {
988
1341
  title="Clear $id"
989
1342
  @click=${(/** @type {any} */ e) => {
990
1343
  e.stopPropagation();
991
- update(updateProperty(getState(), path, "$id", undefined));
1344
+ transactDoc(activeTab.value, (t) =>
1345
+ mutateUpdateProperty(t, path, "$id", undefined),
1346
+ );
992
1347
  }}
993
1348
  ></span>`
994
1349
  : nothing}
@@ -998,7 +1353,9 @@ export function renderPropertiesPanelTemplate(ctx) {
998
1353
  size="s"
999
1354
  .value=${live(node.$id || "")}
1000
1355
  @input=${debouncedStyleCommit("prop:$id", 400, (/** @type {any} */ e) => {
1001
- update(updateProperty(getState(), path, "$id", e.target.value || undefined));
1356
+ transactDoc(activeTab.value, (t) =>
1357
+ mutateUpdateProperty(t, path, "$id", e.target.value || undefined),
1358
+ );
1002
1359
  })}
1003
1360
  ></sp-textfield>
1004
1361
  </div>
@@ -1010,7 +1367,9 @@ export function renderPropertiesPanelTemplate(ctx) {
1010
1367
  title="Clear class"
1011
1368
  @click=${(/** @type {any} */ e) => {
1012
1369
  e.stopPropagation();
1013
- update(updateProperty(getState(), path, "className", undefined));
1370
+ transactDoc(activeTab.value, (t) =>
1371
+ mutateUpdateProperty(t, path, "className", undefined),
1372
+ );
1014
1373
  }}
1015
1374
  ></span>`
1016
1375
  : nothing}
@@ -1020,7 +1379,9 @@ export function renderPropertiesPanelTemplate(ctx) {
1020
1379
  size="s"
1021
1380
  .value=${live(node.className || "")}
1022
1381
  @input=${debouncedStyleCommit("prop:className", 400, (/** @type {any} */ e) => {
1023
- update(updateProperty(getState(), path, "className", e.target.value || undefined));
1382
+ transactDoc(activeTab.value, (t) =>
1383
+ mutateUpdateProperty(t, path, "className", e.target.value || undefined),
1384
+ );
1024
1385
  })}
1025
1386
  ></sp-textfield>
1026
1387
  </div>
@@ -1034,7 +1395,9 @@ export function renderPropertiesPanelTemplate(ctx) {
1034
1395
  title="Clear text"
1035
1396
  @click=${(/** @type {any} */ e) => {
1036
1397
  e.stopPropagation();
1037
- update(updateProperty(getState(), path, "textContent", undefined));
1398
+ transactDoc(activeTab.value, (t) =>
1399
+ mutateUpdateProperty(t, path, "textContent", undefined),
1400
+ );
1038
1401
  }}
1039
1402
  ></span>`
1040
1403
  : nothing}
@@ -1049,8 +1412,8 @@ export function renderPropertiesPanelTemplate(ctx) {
1049
1412
  : (node.textContent ?? ""),
1050
1413
  )}
1051
1414
  @input=${debouncedStyleCommit("prop:textContent", 400, (/** @type {any} */ e) => {
1052
- update(
1053
- updateProperty(getState(), path, "textContent", e.target.value || undefined),
1415
+ transactDoc(activeTab.value, (t) =>
1416
+ mutateUpdateProperty(t, path, "textContent", e.target.value || undefined),
1054
1417
  );
1055
1418
  })}
1056
1419
  ></sp-textfield>
@@ -1065,7 +1428,9 @@ export function renderPropertiesPanelTemplate(ctx) {
1065
1428
  title="Clear hidden"
1066
1429
  @click=${(/** @type {any} */ e) => {
1067
1430
  e.stopPropagation();
1068
- update(updateProperty(getState(), path, "hidden", undefined));
1431
+ transactDoc(activeTab.value, (t) =>
1432
+ mutateUpdateProperty(t, path, "hidden", undefined),
1433
+ );
1069
1434
  }}
1070
1435
  ></span>`
1071
1436
  : nothing}
@@ -1075,7 +1440,9 @@ export function renderPropertiesPanelTemplate(ctx) {
1075
1440
  size="s"
1076
1441
  .checked=${live(!!node.hidden)}
1077
1442
  @change=${(/** @type {any} */ e) =>
1078
- update(updateProperty(getState(), path, "hidden", e.target.checked || undefined))}
1443
+ transactDoc(activeTab.value, (t) =>
1444
+ mutateUpdateProperty(t, path, "hidden", e.target.checked || undefined),
1445
+ )}
1079
1446
  >
1080
1447
  </sp-checkbox>
1081
1448
  </div>
@@ -1111,9 +1478,9 @@ export function renderPropertiesPanelTemplate(ctx) {
1111
1478
  : nothing;
1112
1479
 
1113
1480
  const observedAttrsT =
1114
- isCustomElementDoc(S) && isRoot
1481
+ isCustomElementDoc({ document: tab.doc.document }) && isRoot
1115
1482
  ? (() => {
1116
- const state = S.document.state || {};
1483
+ const state = tab.doc.document.state || {};
1117
1484
  const entries = Object.entries(state).filter(([, d]) => d.attribute);
1118
1485
  return html`
1119
1486
  <sp-accordion-item label="Observed Attributes" ?open=${isSectionOpen("__observed")}>
@@ -1216,7 +1583,7 @@ export function renderPropertiesPanelTemplate(ctx) {
1216
1583
  : nothing;
1217
1584
 
1218
1585
  const cssPropsT =
1219
- isCustomElementDoc(S) && isRoot
1586
+ isCustomElementDoc({ document: tab.doc.document }) && isRoot
1220
1587
  ? (() => {
1221
1588
  const style = node.style || {};
1222
1589
  const cssProps = Object.entries(style).filter(([k]) => k.startsWith("--"));
@@ -1245,9 +1612,9 @@ export function renderPropertiesPanelTemplate(ctx) {
1245
1612
  : nothing;
1246
1613
 
1247
1614
  const cssPartsT =
1248
- isCustomElementDoc(S) && isRoot
1615
+ isCustomElementDoc({ document: tab.doc.document }) && isRoot
1249
1616
  ? (() => {
1250
- const parts = collectCssParts(S.document);
1617
+ const parts = collectCssParts(tab.doc.document);
1251
1618
  if (parts.length === 0) return nothing;
1252
1619
  return html`
1253
1620
  <sp-accordion-item
@@ -1273,10 +1640,10 @@ export function renderPropertiesPanelTemplate(ctx) {
1273
1640
  : nothing;
1274
1641
 
1275
1642
  const frontmatterT =
1276
- S.mode === "content"
1643
+ tab.doc.mode === "content"
1277
1644
  ? (() => {
1278
- const fm = S.content?.frontmatter || {};
1279
- const col = findContentTypeSchema(S.documentPath, projectState?.projectConfig);
1645
+ const fm = tab.doc.content?.frontmatter || {};
1646
+ const col = findContentTypeSchema(tab.documentPath, projectState?.projectConfig);
1280
1647
  const schemaProps = col?.schema?.properties;
1281
1648
  const requiredFields = new Set(col?.schema?.required || []);
1282
1649
 
@@ -1323,15 +1690,17 @@ export function renderPropertiesPanelTemplate(ctx) {
1323
1690
  })()
1324
1691
  : nothing;
1325
1692
 
1693
+ const pageT = isRoot ? renderPageSection(node) : nothing;
1694
+
1326
1695
  // ── Assemble ──
1327
1696
  const tpl = html`
1328
1697
  <div class="style-sidebar">
1329
1698
  <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}
1699
+ ${pageT} ${frontmatterT} ${isMapNode ? repeaterT : elemT}
1700
+ ${isMapNode ? nothing : observedAttrsT} ${isMapNode ? nothing : switchT}
1701
+ ${isMapNode ? nothing : compPropsT} ${isMapNode ? nothing : attrSectionTemplates}
1702
+ ${isMapNode ? nothing : customSectionT} ${isMapNode ? nothing : mediaT}
1703
+ ${isMapNode ? nothing : cssPropsT} ${isMapNode ? nothing : cssPartsT}
1335
1704
  </sp-accordion>
1336
1705
  </div>
1337
1706
  `;