smoodly 0.0.15 → 0.0.17

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 (154) hide show
  1. package/README.md +238 -36
  2. package/dist/admin/auth.d.ts +7 -4
  3. package/dist/admin/auth.js +1 -1
  4. package/dist/admin/client-ops.js +3 -3
  5. package/dist/admin/custom-section.d.ts +10 -0
  6. package/dist/admin/custom-section.js +10 -0
  7. package/dist/admin/editor/CanvasOverlay.d.ts +6 -2
  8. package/dist/admin/editor/CanvasOverlay.js +6 -6
  9. package/dist/admin/editor/EditorView.d.ts +2 -0
  10. package/dist/admin/editor/EditorView.js +322 -62
  11. package/dist/admin/editor/GlobalInspector.d.ts +24 -0
  12. package/dist/admin/editor/GlobalInspector.js +13 -0
  13. package/dist/admin/editor/InsertPicker.d.ts +6 -5
  14. package/dist/admin/editor/InsertPicker.js +6 -4
  15. package/dist/admin/editor/InspectorFields.d.ts +21 -0
  16. package/dist/admin/editor/InspectorFields.js +22 -0
  17. package/dist/admin/editor/Outline.d.ts +0 -1
  18. package/dist/admin/editor/Outline.js +3 -3
  19. package/dist/admin/editor/PageSettings.js +3 -2
  20. package/dist/admin/editor/global-edit.d.ts +39 -0
  21. package/dist/admin/editor/global-edit.js +124 -0
  22. package/dist/admin/editor/useGlobalEdit.d.ts +6 -0
  23. package/dist/admin/editor/useGlobalEdit.js +18 -0
  24. package/dist/admin/forms/FieldWidget.d.ts +14 -1
  25. package/dist/admin/forms/FieldWidget.js +38 -11
  26. package/dist/admin/forms/ListField.d.ts +9 -1
  27. package/dist/admin/forms/ListField.js +16 -8
  28. package/dist/admin/forms/RichtextElementCard.js +11 -5
  29. package/dist/admin/forms/RowsWidget.d.ts +13 -0
  30. package/dist/admin/forms/RowsWidget.js +26 -0
  31. package/dist/admin/forms/SpanBar.d.ts +5 -0
  32. package/dist/admin/forms/SpanBar.js +37 -0
  33. package/dist/admin/forms/richtext-insert.d.ts +2 -2
  34. package/dist/admin/forms/richtext-insert.js +3 -3
  35. package/dist/admin/forms/richtext-nodes.js +1 -1
  36. package/dist/admin/forms/tabs.d.ts +20 -8
  37. package/dist/admin/forms/tabs.js +19 -23
  38. package/dist/admin/forms/useRichtextEditor.js +8 -1
  39. package/dist/admin/global-items.d.ts +9 -0
  40. package/dist/admin/{shared-items.js → global-items.js} +29 -14
  41. package/dist/admin/index.d.ts +3 -3
  42. package/dist/admin/index.js +1 -1
  43. package/dist/admin/next/op-handler.js +4 -3
  44. package/dist/admin/ops-impl.d.ts +3 -0
  45. package/dist/admin/ops-impl.js +242 -56
  46. package/dist/admin/ops.d.ts +38 -6
  47. package/dist/admin/serialize.d.ts +18 -7
  48. package/dist/admin/serialize.js +27 -5
  49. package/dist/admin/shell/AdminApp.js +7 -7
  50. package/dist/admin/shell/EntriesList.js +19 -3
  51. package/dist/admin/shell/EntryForm.js +183 -77
  52. package/dist/admin/shell/GlobalFields.d.ts +24 -0
  53. package/dist/admin/shell/GlobalFields.js +19 -0
  54. package/dist/admin/shell/{SharedForm.d.ts → GlobalForm.d.ts} +1 -1
  55. package/dist/admin/shell/GlobalForm.js +258 -0
  56. package/dist/admin/shell/{SharedList.d.ts → GlobalList.d.ts} +1 -1
  57. package/dist/admin/shell/{SharedList.js → GlobalList.js} +12 -11
  58. package/dist/admin/shell/IconRail.js +14 -7
  59. package/dist/admin/shell/PagesList.js +4 -8
  60. package/dist/admin/shell/entries-list.d.ts +2 -2
  61. package/dist/admin/shell/entries-list.js +3 -3
  62. package/dist/admin/shell/{shared-list.d.ts → global-list.d.ts} +3 -3
  63. package/dist/admin/shell/{shared-list.js → global-list.js} +2 -2
  64. package/dist/admin/supabase-auth.js +1 -1
  65. package/dist/admin/tree-ops.d.ts +10 -8
  66. package/dist/admin/tree-ops.js +28 -16
  67. package/dist/admin/ui/ActionMenu.d.ts +11 -0
  68. package/dist/admin/ui/ActionMenu.js +25 -0
  69. package/dist/admin/ui/HistoryModal.d.ts +23 -0
  70. package/dist/admin/ui/HistoryModal.js +40 -0
  71. package/dist/admin/ui/Modal.d.ts +5 -1
  72. package/dist/admin/ui/Modal.js +3 -3
  73. package/dist/admin/ui/PublishControl.d.ts +31 -0
  74. package/dist/admin/ui/PublishControl.js +24 -0
  75. package/dist/admin/ui/format.d.ts +4 -0
  76. package/dist/admin/ui/format.js +9 -0
  77. package/dist/admin/ui/primitives.d.ts +16 -1
  78. package/dist/admin/ui/primitives.js +20 -4
  79. package/dist/admin/ui/status-label.d.ts +10 -0
  80. package/dist/admin/ui/status-label.js +14 -0
  81. package/dist/admin/ui/theme.js +93 -13
  82. package/dist/admin/validate.d.ts +27 -2
  83. package/dist/admin/validate.js +47 -10
  84. package/dist/collections.d.ts +3 -9
  85. package/dist/collections.js +1 -2
  86. package/dist/conditions.d.ts +33 -0
  87. package/dist/conditions.js +79 -0
  88. package/dist/config.d.ts +26 -19
  89. package/dist/config.js +26 -25
  90. package/dist/css.d.ts +7 -0
  91. package/dist/css.js +57 -0
  92. package/dist/custom/CustomSection.d.ts +26 -0
  93. package/dist/custom/CustomSection.js +20 -0
  94. package/dist/custom/css.d.ts +3 -0
  95. package/dist/custom/css.js +28 -0
  96. package/dist/custom/index.d.ts +26 -0
  97. package/dist/custom/index.js +16 -0
  98. package/dist/custom/rows.d.ts +28 -0
  99. package/dist/custom/rows.js +129 -0
  100. package/dist/custom/schema.d.ts +32 -0
  101. package/dist/custom/schema.js +39 -0
  102. package/dist/entry-store.d.ts +42 -17
  103. package/dist/entry-store.js +107 -40
  104. package/dist/fields.d.ts +5 -2
  105. package/dist/fields.js +4 -2
  106. package/dist/{shared-id.d.ts → global-id.d.ts} +1 -1
  107. package/dist/{shared-id.js → global-id.js} +4 -4
  108. package/dist/global.d.ts +57 -0
  109. package/dist/global.js +51 -0
  110. package/dist/index.d.ts +28 -18
  111. package/dist/index.js +10 -5
  112. package/dist/media.js +1 -1
  113. package/dist/next/index.d.ts +1 -1
  114. package/dist/next/page-renderer.d.ts +4 -2
  115. package/dist/next/page-renderer.js +17 -13
  116. package/dist/next/queries.d.ts +6 -6
  117. package/dist/next/queries.js +7 -7
  118. package/dist/page.js +2 -2
  119. package/dist/pairing.d.ts +13 -7
  120. package/dist/pairing.js +4 -4
  121. package/dist/refs.d.ts +2 -2
  122. package/dist/refs.js +4 -4
  123. package/dist/render.d.ts +13 -1
  124. package/dist/render.js +10 -5
  125. package/dist/resolve.d.ts +11 -6
  126. package/dist/resolve.js +22 -12
  127. package/dist/revalidate.d.ts +4 -4
  128. package/dist/revalidate.js +4 -4
  129. package/dist/richtext-render.js +2 -2
  130. package/dist/richtext-walk.d.ts +5 -5
  131. package/dist/richtext-walk.js +4 -4
  132. package/dist/schema.d.ts +15 -10
  133. package/dist/schema.js +19 -7
  134. package/dist/section-wrapper.d.ts +18 -6
  135. package/dist/section-wrapper.js +24 -17
  136. package/dist/site.d.ts +17 -5
  137. package/dist/site.js +36 -12
  138. package/dist/sql-space.js +1 -1
  139. package/dist/sql.js +19 -16
  140. package/dist/store.d.ts +45 -13
  141. package/dist/store.js +88 -12
  142. package/dist/styles.d.ts +27 -0
  143. package/dist/styles.js +60 -0
  144. package/dist/supabase-entry-store.d.ts +11 -6
  145. package/dist/supabase-entry-store.js +115 -39
  146. package/dist/supabase-store.d.ts +13 -6
  147. package/dist/supabase-store.js +90 -20
  148. package/dist/zones.d.ts +9 -7
  149. package/dist/zones.js +5 -5
  150. package/package.json +1 -1
  151. package/dist/admin/shared-items.d.ts +0 -9
  152. package/dist/admin/shell/SharedForm.js +0 -134
  153. package/dist/shared.d.ts +0 -46
  154. package/dist/shared.js +0 -36
@@ -28,32 +28,40 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
28
28
  // Core code: no next/* imports live here — the bridge is the only piece
29
29
  // that knows about Next, and it ships to the site, not the admin.
30
30
  import { useCallback, useEffect, useMemo, useRef, useState } from "react";
31
- import { SHARED_NODE_TYPE } from "../../shared.js";
31
+ import { CSS_KEY, GLOBAL_NODE_TYPE, SETTINGS_KEY, STYLES_KEY, globalNameOf } from "../../global.js";
32
32
  import { descriptorAt, getAtPath } from "../field-paths.js";
33
33
  import { FieldWidget } from "../forms/FieldWidget.js";
34
34
  import { revealField } from "../forms/reveal.js";
35
35
  import { tabOf, tabsFor } from "../forms/tabs.js";
36
- import { allowedSections, canInsert, fillLockedZones, findNode, insertNode, moveNode, moveNodeTo, newNodeId, nodeFromSample, patchNode, patchNodePath, patchPage, placementNode, removeNode, zoneOf, } from "../tree-ops.js";
36
+ import { InspectorFields, valueOf } from "./InspectorFields.js";
37
+ import { allowedSections, canInsert, fillLockedZones, findNode, insertNode, moveNode, moveNodeTo, newNodeId, nodeFromSample, patchNode, patchNodeCss, patchNodePath, patchPage, placementNode, removeNode, zoneOf, } from "../tree-ops.js";
37
38
  import { useDraftAutosave } from "./autosave.js";
38
39
  import { CanvasOverlay } from "./CanvasOverlay.js";
39
40
  import { InlineRichtext } from "./InlineRichtext.js";
40
41
  import { InsertPicker } from "./InsertPicker.js";
42
+ import { customNode, customNodeLabel, customSection } from "../custom-section.js";
43
+ import { CUSTOM_NODE_TYPE } from "../../custom/index.js";
41
44
  import { NoticePane } from "./NoticePane.js";
42
45
  import { Outline } from "./Outline.js";
43
46
  import { PageInspector } from "./PageInspector.js";
47
+ import { GlobalInspector } from "./GlobalInspector.js";
44
48
  import { localeFromUrl } from "./locale-url.js";
45
49
  import { plusButtonsFor } from "./overlay-geometry.js";
46
50
  import { useCanvasBridge } from "./useCanvasBridge.js";
47
51
  import { useInlineSession } from "./useInlineSession.js";
48
52
  import { usePageLocale } from "./usePageLocale.js";
53
+ import { useGlobalEdit } from "./useGlobalEdit.js";
49
54
  import { BASE } from "../shell/base.js";
50
- import { RouteLink } from "../shell/RouteLink.js";
51
55
  import { isFixedNode, localeStatusOf, sourceLocaleOf } from "../shell/pages-tree.js";
52
56
  import { entryTitle } from "../shell/entries-list.js";
53
57
  import { LocaleSwitcher } from "../ui/LocaleSwitcher.js";
54
58
  import { ResizeHandle } from "../ui/ResizeHandle.js";
55
- import { Banner as BannerBox, Breadcrumb, Button, IconButton, LinkButton, SaveIndicator, StatusBadge, Tabs, TopBar, } from "../ui/primitives.js";
59
+ import { PublishControl } from "../ui/PublishControl.js";
60
+ import { HistoryModal, historyRows } from "../ui/HistoryModal.js";
61
+ import { Banner as BannerBox, Breadcrumb, Button, FieldErrorList, IconButton, LinkButton, SaveIndicator, Tabs, TopBar, } from "../ui/primitives.js";
56
62
  export const withEditorParam = (url) => (url.includes("?") ? `${url}&smoodly-editor=1` : `${url}?smoodly-editor=1`);
63
+ /** The site URL of a stored version — History's iframe (spec 2026-09-13 history §5); no editor parameter, so no bridge. */
64
+ export const withVersionParam = (url, versionId) => `${url}${url.includes("?") ? "&" : "?"}smoodly-version=${encodeURIComponent(versionId)}`;
57
65
  export function EditorView({ ops, registry, id }) {
58
66
  const [record, setRecord] = useState(null);
59
67
  const [tree, setTree] = useState(null);
@@ -76,10 +84,22 @@ export function EditorView({ ops, registry, id }) {
76
84
  const [tab, setTab] = useState("Content");
77
85
  const [banner, setBanner] = useState(null);
78
86
  const [published, setPublished] = useState(false);
87
+ /** An action of the page's control in flight. */
88
+ const [pageBusy, setPageBusy] = useState(null);
89
+ /** History's rows while the modal is open (spec 2026-09-13 history §5). */
90
+ const [history, setHistory] = useState(null);
91
+ /** An edit since the last save ack — Publish wakes on it, before the autosave runs (spec 2026-09-13 publish flow §2). */
92
+ const [dirty, setDirty] = useState(false);
93
+ /** Any edit on this canvas yet: the autosave indicator stays hidden until the first one (§6). */
94
+ const [touched, setTouched] = useState(false);
95
+ const [globalBusy, setGlobalBusy] = useState(null);
96
+ /** A global edit since the item's last save ack; and any global edit on this canvas yet. */
97
+ const [globalDirty, setGlobalDirty] = useState(false);
98
+ const [globalTouched, setGlobalTouched] = useState(false);
79
99
  const [refOptions, setRefOptions] = useState({});
80
- // Shared item name → row id, loaded once per editor mount: the picker's
81
- // Shared group and a bound locked zone need the id to place a node.
82
- const [sharedIds, setSharedIds] = useState(null);
100
+ // Global item name → row id, loaded once per editor mount: the picker's
101
+ // Global group and a bound locked zone need the id to place a node.
102
+ const [globalIds, setGlobalIds] = useState(null);
83
103
  const iframeRef = useRef(null);
84
104
  const scrollerRef = useRef(null);
85
105
  const treeRef = useRef(null);
@@ -93,11 +113,33 @@ export function EditorView({ ops, registry, id }) {
93
113
  // now, or once an inline session ends (spec 2026-09-10 §7).
94
114
  const { saveState, mark, flush } = useDraftAutosave({
95
115
  read: () => treeRef.current,
96
- persist: (t) => ops.pages.saveDraft(id, treeLocaleRef.current, t),
116
+ persist: async (t) => {
117
+ const target = treeLocaleRef.current;
118
+ const r = await ops.pages.saveDraft(id, target, t);
119
+ if (r.ok) {
120
+ // The badge and Publish follow each save without a re-get: saveDraft answers with the version id.
121
+ setRecord((rec) => rec?.locales[target]
122
+ ? { ...rec, locales: { ...rec.locales, [target]: { ...rec.locales[target], draftVersionId: r.data.versionId } } }
123
+ : rec);
124
+ setDirty(false);
125
+ }
126
+ return r;
127
+ },
97
128
  onSaved: () => inlineRef.current?.onSaved(),
98
129
  });
130
+ // Edits to a global block go to the global entry through their own
131
+ // autosave (spec 2026-09-13 §4); the save ack refreshes the canvas
132
+ // through the same inline-session deferral as the page's.
133
+ const globalEdit = useGlobalEdit({ ops, onSaved: () => { setGlobalDirty(false); inlineRef.current?.onSaved(); } });
134
+ const patchGlobal = (name, path, value) => {
135
+ globalEdit.patch(name, path, value);
136
+ setGlobalDirty(true);
137
+ setGlobalTouched(true);
138
+ };
139
+ /** Both targets, page first: a locale switch must leave nothing pending on either. Page Publish keeps `flush()` — it never touches a global item. */
140
+ const flushAll = useCallback(async () => (await flush()) && (await globalEdit.flush()), [flush, globalEdit.flush]);
99
141
  const { locale, adding, switchLocale, doAddLocale, doRemoveLocale } = usePageLocale({
100
- registry, ops, id, record, setRecord, flush,
142
+ registry, ops, id, record, setRecord, flush: flushAll,
101
143
  fail: (text) => setBanner({ text }),
102
144
  clearBanner: () => setBanner(null),
103
145
  reload: () => setReloadKey((k) => k + 1),
@@ -134,6 +176,11 @@ export function EditorView({ ops, registry, id }) {
134
176
  setSelectedId(null);
135
177
  setHoverId(null);
136
178
  setFolder(false);
179
+ globalEdit.reset(); // a new locale or a reload is a new canvas; the switch flushed first
180
+ setDirty(false);
181
+ setTouched(false);
182
+ setGlobalDirty(false);
183
+ setGlobalTouched(false);
137
184
  // ops is a server action — it can reject outright (offline, 500,
138
185
  // aborted), not just resolve to a typed error. Either failure mode
139
186
  // must land on the same full-pane error, never a hung load.
@@ -225,18 +272,28 @@ export function EditorView({ ops, registry, id }) {
225
272
  const template = useMemo(() => (record ? registry.pages.find((p) => p.name === record.template) ?? null : null), [record, registry]);
226
273
  const zoneOrder = useMemo(() => Object.keys(template?.zones ?? {}), [template]);
227
274
  const policyOf = useCallback((zone) => (zone ? template?.zones[zone] : undefined), [template]);
228
- const sectionOf = useCallback((type) => registry.sections.find((s) => s.name === type) ?? null, [registry]);
229
- /** The shared item a placement node places, or null for any other node. */
230
- const sharedOf = useCallback((node) => {
231
- if (node.type !== SHARED_NODE_TYPE)
275
+ // The custom section (spec 2026-09-14 §6) is the registry's one synthetic section.
276
+ const sectionOf = useCallback((type) => (type === CUSTOM_NODE_TYPE ? customSection(registry) : registry.sections.find((s) => s.name === type) ?? null), [registry]);
277
+ /** The global item a placement node places, or null for any other node. */
278
+ const globalOf = useCallback((node) => {
279
+ if (node.type !== GLOBAL_NODE_TYPE)
232
280
  return null;
233
- return registry.shared.find((s) => s.name === node.fields?.item) ?? null;
281
+ return registry.globals.find((s) => s.name === node.fields?.item) ?? null;
234
282
  }, [registry]);
283
+ /** The global item behind a node id: a placement's, by the tree, or a layout-rendered
284
+ * item's, by its `global:` id (spec 2026-09-13 §1). Reads the refs — safe at event time. */
285
+ const globalItemOf = useCallback((id) => {
286
+ const node = treeRef.current ? findNode(treeRef.current, id) : null;
287
+ if (node)
288
+ return globalOf(node);
289
+ const name = globalNameOf(id);
290
+ return name ? registryRef.current.globals.find((s) => s.name === name) ?? null : null;
291
+ }, [globalOf]);
235
292
  // ── ref fields inside sections: same option loading as EntryForm ──────
236
293
  const refTargets = useMemo(() => {
237
294
  const out = {};
238
295
  for (const section of registry.sections) {
239
- for (const ns of ["fields", "styles", "meta"]) {
296
+ for (const ns of ["fields", "settings"]) {
240
297
  for (const [key, d] of Object.entries(section[ns] ?? {})) {
241
298
  if ((d.type === "ref" || d.type === "refList") && typeof d.target === "string") {
242
299
  out[`${section.name}.${ns}.${key}`] = d.target;
@@ -272,26 +329,36 @@ export function EditorView({ ops, registry, id }) {
272
329
  return () => { cancelled = true; };
273
330
  }, [ops, refTargets, registry, locale]);
274
331
  useEffect(() => {
275
- if (registryRef.current.shared.length === 0) {
276
- setSharedIds({});
332
+ if (registryRef.current.globals.length === 0) {
333
+ setGlobalIds({});
277
334
  return;
278
335
  }
279
336
  let cancelled = false;
280
- // A failed load leaves the map empty: the picker's Shared group and
337
+ // A failed load leaves the map empty: the picker's Global group and
281
338
  // any zone bound to an item degrade to nothing to place, not a stuck
282
339
  // editor — so say why in the console, like the ref options above.
283
- ops.shared.list()
340
+ ops.globals.list()
284
341
  .then((r) => {
285
342
  if (cancelled)
286
343
  return;
287
344
  if (!r.ok)
288
345
  console.error(r.message);
289
- setSharedIds(r.ok ? Object.fromEntries(r.data.map((i) => [i.name, i.record.id])) : {});
346
+ setGlobalIds(r.ok ? Object.fromEntries(r.data.map((i) => [i.name, i.record.id])) : {});
290
347
  })
291
348
  .catch((e) => { console.error(e); if (!cancelled)
292
- setSharedIds({}); });
349
+ setGlobalIds({}); });
293
350
  return () => { cancelled = true; };
294
351
  }, [ops]);
352
+ // A selected global block loads its item for the canvas locale, once (spec 2026-09-13 §3).
353
+ const selectedItemName = selectedId ? globalItemOf(selectedId)?.name ?? null : null;
354
+ useEffect(() => {
355
+ if (selectedItemName)
356
+ void globalEdit.load(selectedItemName, locale);
357
+ }, [selectedItemName, locale]); // eslint-disable-line react-hooks/exhaustive-deps
358
+ useEffect(() => {
359
+ if (globalEdit.loadError)
360
+ setBanner({ text: globalEdit.loadError });
361
+ }, [globalEdit.loadError]);
295
362
  // Selecting from the outline must reveal the block. The canvas column is
296
363
  // the scroller now (the iframe has no scrollbar of its own — it is sized to
297
364
  // the full document), so scroll it to the rect the bridge reported.
@@ -311,12 +378,18 @@ export function EditorView({ ops, registry, id }) {
311
378
  return next;
312
379
  });
313
380
  mark();
381
+ setDirty(true);
382
+ setTouched(true);
314
383
  }, [mark]);
315
384
  // ── inline editing (spec 2026-09-10) ──────────────────────────────────
316
385
  const inline = useInlineSession({
317
386
  iframeRef, postToCanvas,
318
387
  patch: (node, path, value) => {
319
- mutate((t) => patchNodePath(t, node, path, value));
388
+ const item = globalItemOf(node);
389
+ if (item)
390
+ patchGlobal(item.name, path, value);
391
+ else
392
+ mutate((t) => patchNodePath(t, node, path, value));
320
393
  // A second element marked with the same field follows the one being typed in.
321
394
  if (typeof value === "string")
322
395
  inlineRef.current?.writeText(node, path, value);
@@ -359,37 +432,42 @@ export function EditorView({ ops, registry, id }) {
359
432
  };
360
433
  const onFieldClick = (id, path, x, y) => {
361
434
  const node = tree ? findNode(tree, id) : null;
362
- const section = node ? sectionOf(node.type) : null;
363
- if (!node || !section)
364
- return; // a shared placement is edited under Globals — nothing inline here
365
- const descriptor = descriptorAt(section, path);
435
+ // A global block's fields are the item's — the section's own (spec 2026-09-13 §5); any other block's are its section's.
436
+ const item = globalItemOf(id);
437
+ const schema = item ?? (node ? sectionOf(node.type) : null);
438
+ if (!schema)
439
+ return;
440
+ const descriptor = descriptorAt(schema, path);
366
441
  if (!descriptor) {
367
442
  if (process.env.NODE_ENV !== "production") {
368
- console.warn(`smoodly: "${section.name}" has no field at "${path}" — check its data-smd-field / data-smd-item marks.`);
443
+ console.warn(`smoodly: "${item?.name ?? node?.type}" has no field at "${path}" — check its data-smd-field / data-smd-item marks.`);
369
444
  }
370
445
  return;
371
446
  }
372
447
  if (inline.session && inline.session.node === id && inline.session.path === path)
373
448
  return;
374
- const kind = descriptor.type === "text" ? "text" : descriptor.type === "richtext" ? "richtext" : null;
449
+ // A custom section's columns edit in the inspector for now (register 2026-09-15): the click reveals
450
+ // the column's editor rather than opening an inline session on the canvas.
451
+ const kind = node?.type === CUSTOM_NODE_TYPE ? null
452
+ : descriptor.type === "text" ? "text" : descriptor.type === "richtext" ? "richtext" : null;
375
453
  if (kind && inline.start({ node: id, path, kind }, { x, y }))
376
454
  return;
377
455
  // Any other field, or a text mark on a container: the inspector reveals its widget.
378
- reveal(path, section);
456
+ reveal(path, schema);
379
457
  };
380
- // A zone bound to a shared item needs the item's row id (ops.shared.list),
458
+ // A zone bound to a global item needs the item's row id (ops.globals.list),
381
459
  // which can land after the draft did. Fill then; nothing else changes.
382
460
  useEffect(() => {
383
- if (!tree || !sharedIds || !record)
461
+ if (!tree || !globalIds || !record)
384
462
  return;
385
463
  const template = registryRef.current.pages.find((p) => p.name === record.template);
386
- const filled = fillLockedZones(tree, template?.zones ?? {}, registryRef.current.sections, sharedIds);
464
+ const filled = fillLockedZones(tree, template?.zones ?? {}, registryRef.current.sections, globalIds);
387
465
  if (filled !== tree)
388
466
  mutate(() => filled);
389
467
  // `tree` is deliberately not a dependency: this runs when the ids land
390
468
  // or a new draft loads, not on every keystroke.
391
469
  // eslint-disable-next-line react-hooks/exhaustive-deps
392
- }, [sharedIds, record, mutate, tree === null]);
470
+ }, [globalIds, record, mutate, tree === null]);
393
471
  // ── tree gestures ────────────────────────────────────────────────────
394
472
  const nodesIn = useCallback((zone) => tree?.zones[zone] ?? [], [tree]);
395
473
  const zoneCanTake = useCallback((zone) => canInsert(policyOf(zone), nodesIn(zone).length), [policyOf, nodesIn]);
@@ -399,11 +477,17 @@ export function EditorView({ ops, registry, id }) {
399
477
  setPicker(null);
400
478
  select(node.id);
401
479
  };
402
- const doInsertShared = (zone, index, item) => {
403
- const ref = sharedIds?.[item.name];
480
+ const doInsertCustom = (zone, index) => {
481
+ const node = customNode(registry, newNodeId(CUSTOM_NODE_TYPE));
482
+ mutate((t) => insertNode(t, zone, index, node));
483
+ setPicker(null);
484
+ select(node.id);
485
+ };
486
+ const doInsertGlobal = (zone, index, item) => {
487
+ const ref = globalIds?.[item.name];
404
488
  if (!ref)
405
489
  return;
406
- const node = placementNode(item, ref, newNodeId(SHARED_NODE_TYPE));
490
+ const node = placementNode(item, ref, newNodeId(GLOBAL_NODE_TYPE));
407
491
  mutate((t) => insertNode(t, zone, index, node));
408
492
  setPicker(null);
409
493
  select(node.id);
@@ -415,13 +499,16 @@ export function EditorView({ ops, registry, id }) {
415
499
  if (!zone || !zoneCanTake(zone))
416
500
  return;
417
501
  const index = nodesIn(zone).findIndex((n) => n.id === node.id) + 1;
502
+ // Deep copies: a shallow one would share every list (a custom section's rows) between the original and the copy.
418
503
  const copy = {
419
504
  ...node,
420
505
  id: newNodeId(node.type),
421
- fields: { ...node.fields },
422
- styles: node.styles ? { ...node.styles } : undefined,
423
- meta: node.meta ? { ...node.meta } : undefined,
506
+ fields: structuredClone(node.fields),
507
+ settings: node.settings ? structuredClone(node.settings) : undefined,
508
+ styles: node.styles ? structuredClone(node.styles) : undefined,
424
509
  };
510
+ if (node.css === undefined)
511
+ delete copy.css;
425
512
  mutate((t) => insertNode(t, zone, index, copy));
426
513
  select(copy.id);
427
514
  };
@@ -438,16 +525,19 @@ export function EditorView({ ops, registry, id }) {
438
525
  const doMove = (id, dir) => mutate((t) => moveNode(t, id, dir));
439
526
  const doMoveTo = (id, index) => mutate((t) => moveNodeTo(t, id, index));
440
527
  const doPatch = (id, ns, key, value) => mutate((t) => patchNode(t, id, ns, key, value));
528
+ const doPatchCss = (id, css) => mutate((t) => patchNodeCss(t, id, css));
441
529
  /** A page-level value (page fields, SEO meta) — same dirty/debounce path as a node's (spec 2026-09-10 page fields §5). */
442
530
  const doPatchPage = (ns, key, value) => mutate((t) => patchPage(t, ns, key, value));
443
531
  // ── publish ──────────────────────────────────────────────────────────
444
532
  const doPublish = async () => {
445
533
  setBanner(null);
534
+ setPageBusy("publish");
446
535
  // Never publish behind a pending debounce.
447
536
  const flushed = await flush();
448
537
  if (!flushed) {
449
538
  // The pre-publish flush failed (or is still behind, mid-retry) —
450
539
  // publishing now would ship a stale draft. Bail instead.
540
+ setPageBusy(null);
451
541
  setBanner({ text: "Couldn't save your latest changes — publish cancelled." });
452
542
  return;
453
543
  }
@@ -461,6 +551,7 @@ export function EditorView({ ops, registry, id }) {
461
551
  // outright instead of resolving to a typed error.
462
552
  result = { ok: false, code: "internal", message: "Publish failed — check your connection and try again." };
463
553
  }
554
+ setPageBusy(null);
464
555
  if (result.ok) {
465
556
  setRecord(result.data);
466
557
  setPublished(true);
@@ -470,6 +561,135 @@ export function EditorView({ ops, registry, id }) {
470
561
  setBanner({ text: result.message, fields: result.fields });
471
562
  }
472
563
  };
564
+ /** Discard (spec 2026-09-13 publish flow §4): confirm, flush so a late save cannot re-dirty the row, restore, reload the canvas. */
565
+ const doDiscard = async () => {
566
+ if (!window.confirm("Discard your changes and restore the published version?"))
567
+ return;
568
+ setBanner(null);
569
+ setPageBusy("discard");
570
+ if (!(await flushAll())) {
571
+ setPageBusy(null);
572
+ setBanner({ text: "Couldn't save your latest changes — discard cancelled." });
573
+ return;
574
+ }
575
+ let result;
576
+ try {
577
+ result = await ops.pages.discardDraft(id, locale);
578
+ }
579
+ catch {
580
+ result = { ok: false, code: "internal", message: "Couldn't reach the server." };
581
+ }
582
+ setPageBusy(null);
583
+ if (!result.ok) {
584
+ setBanner({ text: result.message });
585
+ return;
586
+ }
587
+ setRecord(result.data);
588
+ setReloadKey((k) => k + 1); // the tree and the canvas come back from the store
589
+ };
590
+ /** Unpublish (§5): the locale goes off the site; the draft stays for a later Publish. */
591
+ const doUnpublish = async () => {
592
+ setBanner(null);
593
+ setPageBusy("unpublish");
594
+ let result;
595
+ try {
596
+ result = await ops.pages.unpublish(id, locale);
597
+ }
598
+ catch {
599
+ result = { ok: false, code: "internal", message: "Couldn't reach the server." };
600
+ }
601
+ setPageBusy(null);
602
+ if (!result.ok) {
603
+ setBanner({ text: result.message });
604
+ return;
605
+ }
606
+ setRecord(result.data);
607
+ };
608
+ /** History: flush first so the list's draft row is current, then list the locale's versions. */
609
+ const doHistory = async () => {
610
+ setBanner(null);
611
+ const row = record?.locales[locale];
612
+ if (!row)
613
+ return;
614
+ if (!(await flushAll())) {
615
+ setBanner({ text: "Couldn't save your latest changes — history not opened." });
616
+ return;
617
+ }
618
+ let r;
619
+ try {
620
+ r = await ops.pages.listVersions(id, locale);
621
+ }
622
+ catch {
623
+ r = { ok: false, code: "internal", message: "Couldn't reach the server." };
624
+ }
625
+ if (!r.ok) {
626
+ setBanner({ text: r.message });
627
+ return;
628
+ }
629
+ setHistory(historyRows(r.data, row));
630
+ };
631
+ /** Restore: the version's tree becomes the draft; the canvas reloads from the store, as after Discard. */
632
+ const doRestore = async (versionId) => {
633
+ let result;
634
+ try {
635
+ result = await ops.pages.restore(id, locale, versionId);
636
+ }
637
+ catch {
638
+ result = { ok: false, code: "internal", message: "Couldn't reach the server." };
639
+ }
640
+ if (!result.ok) {
641
+ setBanner({ text: result.message });
642
+ return false;
643
+ }
644
+ setRecord(result.data);
645
+ setReloadKey((k) => k + 1);
646
+ return true;
647
+ };
648
+ /** The global card's own actions (spec 2026-09-13 §5, publish flow §2): the item in the canvas locale, never the page. */
649
+ const doPublishGlobal = async (name) => {
650
+ setBanner(null);
651
+ setGlobalBusy("publish");
652
+ const r = await globalEdit.setStatus(name, "published");
653
+ setGlobalBusy(null);
654
+ if (!r.ok)
655
+ setBanner({ text: r.message, fields: r.fields });
656
+ };
657
+ const doUnpublishGlobal = async (name) => {
658
+ setBanner(null);
659
+ setGlobalBusy("unpublish");
660
+ const r = await globalEdit.setStatus(name, "draft");
661
+ setGlobalBusy(null);
662
+ if (!r.ok)
663
+ setBanner({ text: r.message, fields: r.fields });
664
+ };
665
+ const doDiscardGlobal = async (name) => {
666
+ if (!window.confirm("Discard your changes and restore the published version?"))
667
+ return;
668
+ setBanner(null);
669
+ setGlobalBusy("discard");
670
+ if (!(await globalEdit.flush())) {
671
+ setGlobalBusy(null);
672
+ setBanner({ text: "Couldn't save your latest changes — discard cancelled." });
673
+ return;
674
+ }
675
+ let r;
676
+ try {
677
+ r = await ops.globals.discardDraft(name, locale);
678
+ }
679
+ catch {
680
+ r = { ok: false, code: "internal", message: "Couldn't reach the server." };
681
+ }
682
+ setGlobalBusy(null);
683
+ if (!r.ok) {
684
+ setBanner({ text: r.message });
685
+ return;
686
+ }
687
+ // The item reloads from the store; the canvas refreshes through the same path as a save ack.
688
+ globalEdit.reset();
689
+ setGlobalDirty(false);
690
+ void globalEdit.load(name, locale);
691
+ inlineRef.current?.onSaved();
692
+ };
473
693
  // ── keyboard ─────────────────────────────────────────────────────────
474
694
  useEffect(() => {
475
695
  const onKey = (e) => {
@@ -493,26 +713,37 @@ export function EditorView({ ops, registry, id }) {
493
713
  }
494
714
  const selected = tree && selectedId ? findNode(tree, selectedId) : null;
495
715
  const selectedSection = selected ? sectionOf(selected.type) : null;
496
- const selectedShared = selected ? sharedOf(selected) : null;
716
+ const selectedGlobal = selected ? globalOf(selected) : null;
717
+ // A layout-rendered item: selected by its `global:` id, in no zone (spec 2026-09-13 §1).
718
+ const layoutItem = selectedId && !selected ? globalItemOf(selectedId) : null;
719
+ const selectedItem = selectedGlobal ?? layoutItem;
497
720
  const selZone = tree && selected ? zoneOf(tree, selected.id) : null;
498
721
  const selLocked = policyOf(selZone)?.kind === "locked";
499
722
  const rectById = new Map(rects.map((r) => [r.id, r]));
500
- const selRect = selected ? rectById.get(selected.id) : undefined;
723
+ const selRect = selectedId ? rectById.get(selectedId) : undefined;
501
724
  const hoverRect = hoverId && hoverId !== selectedId ? rectById.get(hoverId) : undefined;
725
+ const hoverGlobal = hoverId ? globalItemOf(hoverId) !== null : false;
502
726
  const fieldRectOf = (f) => f ? fieldRects.find((r) => r.node === f.node && r.path === f.path) : undefined;
503
727
  const fieldHover = hoverField && hoverField.node === selectedId ? fieldRectOf(hoverField) : undefined;
504
728
  const editing = fieldRectOf(inline.session);
505
729
  const pageWidth = rects.reduce((w, r) => Math.max(w, r.x + r.w), 320);
506
- const selection = selected && selRect && (selectedSection || selectedShared)
730
+ const selection = selected && selRect && (selectedSection || selectedGlobal)
507
731
  ? {
508
732
  node: selected, rect: selRect,
509
- title: selectedSection?.title ?? selectedShared?.title ?? selected.type,
510
- shared: Boolean(selectedShared), locked: selLocked,
733
+ title: selectedSection?.title ?? selectedGlobal?.title ?? selected.type,
734
+ global: Boolean(selectedGlobal), locked: selLocked,
511
735
  }
512
- : null;
736
+ : layoutItem && selectedId && selRect
737
+ ? {
738
+ node: { id: selectedId, type: GLOBAL_NODE_TYPE, fields: { item: layoutItem.name } },
739
+ rect: selRect, title: layoutItem.title, global: true, locked: false, layout: true,
740
+ }
741
+ : null;
513
742
  const labelOf = (node) => {
514
- const shared = sharedOf(node);
515
- return sectionOf(node.type)?.title ?? (shared ? `${shared.title} · shared` : node.type);
743
+ if (node.type === CUSTOM_NODE_TYPE)
744
+ return customNodeLabel(node);
745
+ const global = globalOf(node);
746
+ return sectionOf(node.type)?.title ?? (global ? `${global.title} · global` : node.type);
516
747
  };
517
748
  // Reveal is defined here, where the rect map exists; select() calls it
518
749
  // through the ref so its own identity stays stable.
@@ -525,42 +756,71 @@ export function EditorView({ ops, registry, id }) {
525
756
  scroller.scrollTo({ top: Math.max(0, top), behavior: "smooth" });
526
757
  };
527
758
  const plusButtons = plusButtonsFor({ zoneOrder, canTake: zoneCanTake, nodesIn, rects: rectById, emptyZones });
528
- const tabs = selectedSection ? tabsFor(selectedSection) : [];
759
+ const tabs = selectedSection ? tabsFor(selectedSection) : selectedItem ? tabsFor(selectedItem) : [];
529
760
  const activeTab = tabs.some((t) => t.tab === tab) ? tab : tabs[0]?.tab;
530
- const entries = tabs.find((t) => t.tab === activeTab)?.entries ?? [];
761
+ const activeGroups = tabs.find((t) => t.tab === activeTab)?.groups ?? [];
531
762
  return (_jsxs(_Fragment, { children: [_jsx(TopBar, { left: _jsx(_Fragment, { children: _jsx(Breadcrumb, { backHref: `${BASE}/pages`, items: [
532
763
  {
533
764
  label: "Pages", href: `${BASE}/pages`,
534
765
  trailing: registry.locales.supported.length > 1 && (_jsx(LocaleSwitcher, { registry: registry, present: record ? registry.locales.supported.filter((l) => record.locales[l]) : [locale], parentLocales: parentLocales, value: locale, adding: adding, onSwitch: (l) => { void switchLocale(l); }, onAdd: (l) => { void doAddLocale(l); } })),
535
766
  },
536
767
  { label },
537
- ] }) }), right: _jsxs(_Fragment, { children: [_jsx(SaveIndicator, { state: saveState }), ready && (_jsx(LinkButton, { href: siteUrl, target: "_blank", rel: "noreferrer", title: "Opens the site route. While the admin's draft-mode cookie is on, this shows the DRAFT \u2014 open it in a private window to see what visitors see.", children: "View live" })), record?.locales[locale] && (_jsx(StatusBadge, { status: localeStatusOf(record.locales[locale]) })), _jsx(Button, { variant: published ? "ok" : "primary", onClick: () => { void doPublish(); }, children: published ? "Published ✓" : "Publish" })] }) }), _jsxs("div", { className: "sm-editor", children: [_jsxs("div", { className: "sm-canvas", children: [(banner || geometry === "missing") && (_jsxs("div", { style: { display: "flex", flexDirection: "column", gap: 8, padding: "0 20px 10px" }, children: [banner && (_jsxs(BannerBox, { tone: "error", onDismiss: () => setBanner(null), children: [_jsx("p", { children: banner.text }), banner.fields && banner.fields.length > 0 && (_jsx("ul", { className: "sm-mono", style: { margin: "6px 0 0", paddingLeft: 18, fontSize: "var(--text-sm)" }, children: banner.fields.map((f) => _jsxs("li", { children: [f.field, ": ", f.message] }, f.field)) }))] })), geometry === "missing" && (_jsx(BannerBox, { tone: "warn", children: "Selection unavailable \u2014 the page didn't report geometry. The outline still works." }))] })), _jsx("div", { className: "sm-canvas__scroll", ref: scrollerRef, children: _jsxs("div", { className: "sm-page", style: { overflow: "hidden" }, children: [iframeSrc ? (_jsx("iframe", { ref: iframeRef, src: iframeSrc, title: "Page canvas", style: { width: "100%", height: docH, border: 0, display: "block" } })) : (_jsx("div", { className: "sm-canvas__loading", children: "Loading canvas\u2026" })), _jsx(CanvasOverlay, { emptyZones: emptyZones, hoverRect: hoverRect, selection: selection, fieldHover: fieldHover, editing: editing, plusButtons: plusButtons, onPlus: (p, x, y) => setPicker({ zone: p.zone, index: p.index, x: x - 150, y: y + 12 }), onMove: doMove, onDuplicate: doDuplicate, onRemove: doRemove }), inline.richtext && tree && (() => {
768
+ ] }) }), right: _jsxs(_Fragment, { children: [(touched || saveState === "error") && _jsx(SaveIndicator, { state: saveState }), ready && (_jsx(LinkButton, { href: siteUrl, target: "_blank", rel: "noreferrer", title: "Opens the site route. While the admin's draft-mode cookie is on, this shows the DRAFT \u2014 open it in a private window to see what visitors see.", children: "View live" })), _jsx(PublishControl, { status: record?.locales[locale] ? localeStatusOf(record.locales[locale]) : null, pending: dirty || saveState !== "saved", busy: pageBusy, flash: published, onPublish: () => { void doPublish(); }, onDiscard: () => { void doDiscard(); }, onUnpublish: () => { void doUnpublish(); }, onHistory: () => { void doHistory(); } })] }) }), history && record?.locales[locale] && (_jsx(HistoryModal, { rows: history, draftId: record.locales[locale].draftVersionId, onClose: () => setHistory(null), onRestore: doRestore, render: (versionId) => _jsx("iframe", { className: "sm-history__frame", title: "Version preview", src: withVersionParam(siteUrl, versionId) }) })), _jsxs("div", { className: "sm-editor", children: [_jsxs("div", { className: "sm-canvas", children: [(banner || geometry === "missing") && (_jsxs("div", { style: { display: "flex", flexDirection: "column", gap: 8, padding: "0 20px 10px" }, children: [banner && (_jsxs(BannerBox, { tone: banner.fields && banner.fields.length > 0 ? "warn" : "error", onDismiss: () => setBanner(null), children: [_jsx("p", { children: banner.text }), _jsx(FieldErrorList, { fields: banner.fields ?? [] })] })), geometry === "missing" && (_jsx(BannerBox, { tone: "warn", children: "Selection unavailable \u2014 the page didn't report geometry. The outline still works." }))] })), _jsx("div", { className: "sm-canvas__scroll", ref: scrollerRef, children: _jsxs("div", { className: "sm-page", style: { overflow: "hidden" }, children: [iframeSrc ? (_jsx("iframe", { ref: iframeRef, src: iframeSrc, title: "Page canvas", style: { width: "100%", height: docH, border: 0, display: "block" } })) : (_jsx("div", { className: "sm-canvas__loading", children: "Loading canvas\u2026" })), _jsx(CanvasOverlay, { emptyZones: emptyZones, hoverRect: hoverRect, hoverGlobal: hoverGlobal, selection: selection, fieldHover: fieldHover, editing: editing, plusButtons: plusButtons, onPlus: (p, x, y) => setPicker({ zone: p.zone, index: p.index, x: x - 150, y: y + 12 }), onMove: doMove, onDuplicate: doDuplicate, onRemove: doRemove }), inline.richtext && (() => {
538
769
  const { session } = inline.richtext;
539
- const node = findNode(tree, session.node);
540
- const section = node ? sectionOf(node.type) : null;
541
- if (!node || !section)
770
+ const item = globalItemOf(session.node);
771
+ const node = !item && tree ? findNode(tree, session.node) : null;
772
+ const schema = item ?? (node ? sectionOf(node.type) : null);
773
+ const fields = item ? globalEdit.items[item.name]?.fields : node?.fields;
774
+ if (!schema || !fields)
542
775
  return null;
543
- return (_jsx(InlineRichtext, { mount: inline.richtext, pageWidth: pageWidth, value: getAtPath(node.fields, session.path), toolset: descriptorAt(section, session.path)?.toolset, onChange: (doc) => { inline.markChanged(); mutate((t) => patchNodePath(t, session.node, session.path, doc)); }, onEscape: inline.end }));
544
- })()] }) })] }), _jsx(ResizeHandle, {}), _jsxs("aside", { className: "sm-inspector", ref: inspectorRef, children: [_jsxs("div", { className: "sm-card", children: [selected && (selectedSection || selectedShared) ? (_jsxs(_Fragment, { children: [_jsxs("div", { className: "sm-inspector__head", children: [_jsxs("span", { style: { display: "flex", alignItems: "center", gap: 8, minWidth: 0 }, children: [_jsx("span", { className: "sm-blockglyph" }), _jsx("span", { className: "sm-title", style: { overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }, children: selectedSection?.title ?? selectedShared?.title })] }), _jsx(IconButton, { label: "Clear selection", onClick: () => setSelectedId(null), children: "\u00D7" })] }), tabs.length > 0 && (_jsx(Tabs, { tabs: tabs.map((t) => t.tab), value: activeTab, onChange: (t) => setTab(t) }))] })) : record ? (_jsx(PageInspector, { record: record, locale: locale, registry: registry, tree: tree, path: paths[locale], fixed: isFixedNode(record, registry), onRename: onRename, onPatch: doPatchPage, onRemoveLocale: Object.keys(record.locales).length > 1 &&
776
+ return (_jsx(InlineRichtext, { mount: inline.richtext, pageWidth: pageWidth, value: getAtPath(fields, session.path), toolset: descriptorAt(schema, session.path)?.toolset, onChange: (doc) => {
777
+ inline.markChanged();
778
+ if (item)
779
+ patchGlobal(item.name, session.path, doc);
780
+ else
781
+ mutate((t) => patchNodePath(t, session.node, session.path, doc));
782
+ }, onEscape: inline.end }));
783
+ })()] }) })] }), _jsx(ResizeHandle, {}), _jsxs("aside", { className: "sm-inspector", ref: inspectorRef, children: [_jsxs("div", { className: "sm-card", children: [selectedSection || selectedItem ? (_jsxs(_Fragment, { children: [_jsxs("div", { className: "sm-inspector__head", children: [_jsxs("span", { style: { display: "flex", alignItems: "center", gap: 8, minWidth: 0 }, children: [_jsx("span", { className: "sm-blockglyph" }), _jsx("span", { className: "sm-title", style: { overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }, children: selectedSection?.title ?? (selectedItem ? `${selectedItem.title} · global` : null) }), selectedItem && (globalTouched || globalEdit.saveState === "error") && _jsx(SaveIndicator, { state: globalEdit.saveState })] }), _jsx(IconButton, { label: "Clear selection", onClick: () => setSelectedId(null), children: "\u00D7" })] }), tabs.length > 0 && (_jsx(Tabs, { tabs: tabs.map((t) => t.tab), value: activeTab, onChange: (t) => setTab(t) }))] })) : record ? (_jsx(PageInspector, { record: record, locale: locale, registry: registry, tree: tree, path: paths[locale], fixed: isFixedNode(record, registry), onRename: onRename, onPatch: doPatchPage, onRemoveLocale: Object.keys(record.locales).length > 1 &&
545
784
  !isFixedNode(record, registry) &&
546
785
  !(record.parentId === null && record.locales[registry.locales.default]?.slug === registry.homePageSlug)
547
786
  ? () => { void doRemoveLocale(); }
548
- : undefined })) : null, selected && (selectedSection || selectedShared) && (_jsxs("div", { className: "sm-inspector__body", children: [selectedSection && entries.map(({ key, ns, descriptor }) => (_jsx(FieldWidget, { fieldKey: key, descriptor: descriptor, value: selected[ns]?.[key], refOptions: refOptionsFor(refOptions, refTargets, selectedSection, ns, key, descriptor), inlinePath: ns === "fields" && inline.session?.node === selected.id ? inline.session.path : undefined, onEditHere: () => { const p = inline.session?.path; inline.end(); if (p)
549
- reveal(p, selectedSection); }, onChange: (value) => {
550
- doPatch(selected.id, ns, key, value);
551
- if (ns === "fields")
552
- syncTexts(selected.id, selectedSection, { ...selected.fields, [key]: value });
553
- } }, `${selected.id}.${ns}.${key}`))), selectedShared && (_jsxs("div", { style: { display: "flex", flexDirection: "column", gap: 10 }, children: [_jsx("p", { className: "sm-hint", children: "This is shared content. Editing it changes every page it is placed on." }), _jsx(RouteLink, { className: "sm-nav-item", href: `${BASE}/shared/${encodeURIComponent(selectedShared.name)}?locale=${encodeURIComponent(locale)}`, children: "Edit shared item \u2192" })] }))] }))] }), _jsx("div", { className: "sm-card", children: _jsx(Outline, { selectedId: selectedId, onSelect: select, onMove: doMove, onMoveTo: doMoveTo, onRemove: doRemove, zones: zoneOrder.map((zone) => ({
787
+ : undefined })) : null, (selectedSection || selectedItem) && (_jsxs("div", { className: "sm-inspector__body", children: [selected && selectedSection && (_jsx(InspectorFields, { groups: activeGroups, values: selected, render: ({ key, ns, descriptor }, { emptyLabel, scope }) => (_jsx(FieldWidget, { fieldKey: key, descriptor: descriptor, value: valueOf(selected, { key, ns, descriptor }), emptyLabel: emptyLabel, scope: scope, refOptions: ns === "fields" || ns === "settings" ? refOptionsFor(refOptions, refTargets, selectedSection, ns, key, descriptor) : undefined, inlinePath: ns === "fields" && inline.session?.node === selected.id ? inline.session.path : undefined, onEditHere: () => { const p = inline.session?.path; inline.end(); if (p)
788
+ reveal(p, selectedSection); }, onChange: (value) => {
789
+ if (ns === "css")
790
+ doPatchCss(selected.id, value);
791
+ else
792
+ doPatch(selected.id, ns, key, value);
793
+ if (ns === "fields")
794
+ syncTexts(selected.id, selectedSection, { ...selected.fields, [key]: value });
795
+ } }, `${selected.id}.${ns}.${key}`)) })), selectedItem && selectedId && (_jsx(GlobalInspector, { item: selectedItem, edit: globalEdit.items[selectedItem.name] ?? null, locale: locale, groups: activeGroups, multilingual: registry.locales.supported.length > 1, refOptions: refOptions, pending: globalDirty || globalEdit.saveState !== "saved", busy: globalBusy, inlinePath: inline.session?.node === selectedId ? inline.session.path : undefined, onEditHere: () => { const p = inline.session?.path; inline.end(); if (p)
796
+ reveal(p, selectedItem); }, onChange: (ns, key, value) => {
797
+ patchGlobal(selectedItem.name, globalPath(ns, key), value);
798
+ if (ns === "fields") {
799
+ const current = globalEdit.items[selectedItem.name];
800
+ if (current)
801
+ syncTexts(selectedId, selectedItem, { ...current.fields, [key]: value });
802
+ }
803
+ }, onPublish: () => { void doPublishGlobal(selectedItem.name); }, onDiscard: () => { void doDiscardGlobal(selectedItem.name); }, onUnpublish: () => { void doUnpublishGlobal(selectedItem.name); } }))] }))] }), _jsx("div", { className: "sm-card", children: _jsx(Outline, { selectedId: selectedId, onSelect: select, onMoveTo: doMoveTo, onRemove: doRemove, zones: zoneOrder.map((zone) => ({
554
804
  zone, locked: policyOf(zone)?.kind === "locked", canTake: zoneCanTake(zone),
555
805
  nodes: nodesIn(zone).map((node) => ({ node, label: labelOf(node) })),
556
806
  })), onAddFirst: (zone) => {
557
807
  const first = allowedSections(policyOf(zone), registry.sections)[0];
558
808
  if (first)
559
809
  doInsert(zone, 0, first);
560
- } }) })] })] }), picker && (_jsx(InsertPicker, { at: picker, policy: policyOf(picker.zone), registry: registry, sharedIds: sharedIds ?? {}, onInsert: (section) => doInsert(picker.zone, picker.index, section), onInsertShared: (item) => doInsertShared(picker.zone, picker.index, item), onClose: () => setPicker(null) }))] }));
810
+ } }) })] })] }), picker && (_jsx(InsertPicker, { at: picker, policy: policyOf(picker.zone), registry: registry, globalIds: globalIds ?? {}, onInsert: (section) => doInsert(picker.zone, picker.index, section), onInsertGlobal: (item) => doInsertGlobal(picker.zone, picker.index, item), onInsertCustom: () => doInsertCustom(picker.zone, picker.index), onClose: () => setPicker(null) }))] }));
561
811
  }
562
812
  /** The selected node's ref field (if any) resolves to its target
563
813
  * collection's loaded options — the EntryForm shape, section-scoped. */
814
+ /** Where a global item's widget writes (spec 2026-09-13 §3, 2026-09-14 §5): fields at the key, the other namespaces under their reserved keys. */
815
+ function globalPath(ns, key) {
816
+ if (ns === "fields")
817
+ return key;
818
+ if (ns === "settings")
819
+ return `${SETTINGS_KEY}.${key}`;
820
+ if (ns === "styles")
821
+ return `${STYLES_KEY}.${key}`;
822
+ return CSS_KEY;
823
+ }
564
824
  function refOptionsFor(loaded, targets, section, ns, key, descriptor) {
565
825
  if (descriptor.type !== "ref" && descriptor.type !== "refList")
566
826
  return undefined;