smoodly 0.0.28 → 0.0.30

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 (98) hide show
  1. package/README.md +123 -17
  2. package/dist/admin/client-ops.js +1 -0
  3. package/dist/admin/drift-copy.d.ts +19 -0
  4. package/dist/admin/drift-copy.js +89 -0
  5. package/dist/admin/drift.d.ts +76 -0
  6. package/dist/admin/drift.js +264 -0
  7. package/dist/admin/editor/CanvasWarning.d.ts +5 -0
  8. package/dist/admin/editor/CanvasWarning.js +10 -0
  9. package/dist/admin/editor/EditorView.js +113 -26
  10. package/dist/admin/editor/InlineRichtext.js +3 -1
  11. package/dist/admin/editor/Outline.d.ts +7 -0
  12. package/dist/admin/editor/Outline.js +8 -5
  13. package/dist/admin/editor/PageInspector.d.ts +6 -1
  14. package/dist/admin/editor/PageInspector.js +4 -2
  15. package/dist/admin/editor/PageSettings.js +26 -16
  16. package/dist/admin/editor/UnknownNode.d.ts +7 -0
  17. package/dist/admin/editor/UnknownNode.js +8 -0
  18. package/dist/admin/editor/canvas-diagnosis.d.ts +49 -0
  19. package/dist/admin/editor/canvas-diagnosis.js +106 -0
  20. package/dist/admin/editor/crash-copy.d.ts +7 -0
  21. package/dist/admin/editor/crash-copy.js +13 -0
  22. package/dist/admin/editor/protocol.d.ts +2 -0
  23. package/dist/admin/editor/useCanvasBridge.d.ts +3 -0
  24. package/dist/admin/editor/useCanvasBridge.js +14 -7
  25. package/dist/admin/editor/useCanvasDiagnosis.d.ts +7 -0
  26. package/dist/admin/editor/useCanvasDiagnosis.js +24 -0
  27. package/dist/admin/field-paths.d.ts +3 -0
  28. package/dist/admin/field-paths.js +26 -0
  29. package/dist/admin/forms/OrphanFields.d.ts +8 -0
  30. package/dist/admin/forms/OrphanFields.js +11 -0
  31. package/dist/admin/forms/RefPicker.js +6 -2
  32. package/dist/admin/forms/RichtextElementCard.js +9 -3
  33. package/dist/admin/forms/RichtextWidget.js +3 -1
  34. package/dist/admin/forms/TagsField.js +1 -1
  35. package/dist/admin/forms/richtext-form-context.d.ts +8 -0
  36. package/dist/admin/forms/richtext-form-context.js +5 -0
  37. package/dist/admin/health.d.ts +23 -0
  38. package/dist/admin/health.js +42 -0
  39. package/dist/admin/index.d.ts +1 -0
  40. package/dist/admin/next/bridge.js +4 -2
  41. package/dist/admin/next/index.d.ts +0 -1
  42. package/dist/admin/next/index.js +6 -1
  43. package/dist/admin/ops-impl.js +40 -9
  44. package/dist/admin/ops.d.ts +13 -0
  45. package/dist/admin/shell/AddRedirectModal.js +1 -1
  46. package/dist/admin/shell/ContentHealthPage.d.ts +10 -0
  47. package/dist/admin/shell/ContentHealthPage.js +37 -0
  48. package/dist/admin/shell/EntriesList.js +4 -4
  49. package/dist/admin/shell/EntryForm.js +20 -3
  50. package/dist/admin/shell/ErrorPane.d.ts +4 -2
  51. package/dist/admin/shell/ErrorPane.js +5 -4
  52. package/dist/admin/shell/GlobalForm.js +22 -2
  53. package/dist/admin/shell/PagesList.js +3 -3
  54. package/dist/admin/shell/SettingsView.js +6 -2
  55. package/dist/admin/shell/health-list.d.ts +27 -0
  56. package/dist/admin/shell/health-list.js +47 -0
  57. package/dist/admin/shell/settings.d.ts +4 -0
  58. package/dist/admin/shell/settings.js +5 -2
  59. package/dist/admin/tree-ops.d.ts +4 -0
  60. package/dist/admin/tree-ops.js +15 -1
  61. package/dist/admin/ui/ActionMenu.d.ts +20 -0
  62. package/dist/admin/ui/ActionMenu.js +33 -7
  63. package/dist/admin/ui/Combobox.d.ts +8 -2
  64. package/dist/admin/ui/Combobox.js +48 -16
  65. package/dist/admin/ui/ConfirmDialog.js +3 -3
  66. package/dist/admin/ui/DriftNotice.d.ts +6 -0
  67. package/dist/admin/ui/DriftNotice.js +10 -0
  68. package/dist/admin/ui/combo-box.d.ts +13 -0
  69. package/dist/admin/ui/combo-box.js +8 -0
  70. package/dist/admin/ui/confirm.d.ts +8 -0
  71. package/dist/admin/ui/confirm.js +17 -4
  72. package/dist/admin/ui/theme.js +29 -3
  73. package/dist/admin/validate.js +15 -47
  74. package/dist/custom/CustomSection.d.ts +1 -0
  75. package/dist/custom/CustomSection.js +4 -1
  76. package/dist/entry-store.d.ts +13 -0
  77. package/dist/entry-store.js +26 -3
  78. package/dist/index.d.ts +2 -2
  79. package/dist/marks.d.ts +12 -0
  80. package/dist/marks.js +14 -0
  81. package/dist/next/canvas-boundary.d.ts +16 -0
  82. package/dist/next/canvas-boundary.js +25 -0
  83. package/dist/next/page-renderer.d.ts +3 -1
  84. package/dist/next/page-renderer.js +33 -4
  85. package/dist/pairing.js +10 -3
  86. package/dist/render.d.ts +12 -1
  87. package/dist/render.js +15 -4
  88. package/dist/section-crash.d.ts +11 -0
  89. package/dist/section-crash.js +16 -0
  90. package/dist/section-guard.d.ts +27 -0
  91. package/dist/section-guard.js +41 -0
  92. package/dist/store.d.ts +9 -0
  93. package/dist/store.js +8 -0
  94. package/dist/supabase-entry-store.d.ts +2 -1
  95. package/dist/supabase-entry-store.js +27 -5
  96. package/dist/supabase-store.d.ts +2 -1
  97. package/dist/supabase-store.js +27 -0
  98. package/package.json +1 -1
@@ -0,0 +1,10 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ // The warning above a canvas Smoodly could not show (spec 2026-09-27
3
+ // shadowed-page detector §5): the editor's sentence, then the line for
4
+ // whoever fixes it, so the editor can forward it as is.
5
+ import { Banner } from "../ui/primitives.js";
6
+ import { canvasMessage } from "./canvas-diagnosis.js";
7
+ export function CanvasWarning({ cause, path }) {
8
+ const { editor, developer } = canvasMessage(cause, path);
9
+ return (_jsxs(Banner, { tone: "warn", children: [_jsx("p", { children: editor }), _jsxs("p", { children: [_jsx("strong", { children: "For your developer:" }), " ", developer] })] }));
10
+ }
@@ -30,13 +30,13 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
30
30
  import { Eye } from "lucide-react";
31
31
  import { useCallback, useEffect, useMemo, useRef, useState } from "react";
32
32
  import { CSS_KEY, GLOBAL_NODE_TYPE, SETTINGS_KEY, STYLES_KEY, globalNameOf } from "../../global.js";
33
- import { descriptorAt, getAtPath } from "../field-paths.js";
33
+ import { descriptorAt, getAtPath, unsetAtPath } from "../field-paths.js";
34
34
  import { FieldWidget } from "../forms/FieldWidget.js";
35
35
  import { revealField } from "../forms/reveal.js";
36
36
  import { useRefOptions } from "../forms/useRefOptions.js";
37
37
  import { tabOf, tabsFor } from "../forms/tabs.js";
38
38
  import { InspectorFields, valueOf } from "./InspectorFields.js";
39
- import { allowedSections, canInsert, fillLockedZones, findNode, insertNode, moveNode, moveNodeTo, newNodeId, nodeFromSample, patchNode, patchNodeCss, patchNodePath, patchPage, placementNode, removeNode, zoneOf, } from "../tree-ops.js";
39
+ import { allowedSections, canInsert, fillLockedZones, findNode, insertNode, moveNode, moveNodeTo, newNodeId, nodeFromSample, patchNode, patchNodeCss, patchNodePath, patchPage, placementNode, removeNode, zoneOf, clearZone, unsetNodePath, } from "../tree-ops.js";
40
40
  import { useDraftAutosave } from "./autosave.js";
41
41
  import { CanvasOverlay } from "./CanvasOverlay.js";
42
42
  import { InlineRichtext } from "./InlineRichtext.js";
@@ -50,6 +50,9 @@ import { GlobalInspector } from "./GlobalInspector.js";
50
50
  import { localeFromUrl } from "./locale-url.js";
51
51
  import { plusButtonsFor } from "./overlay-geometry.js";
52
52
  import { useCanvasBridge } from "./useCanvasBridge.js";
53
+ import { useCanvasDiagnosis } from "./useCanvasDiagnosis.js";
54
+ import { CanvasWarning } from "./CanvasWarning.js";
55
+ import { crashLines, publishCrashConfirm } from "./crash-copy.js";
53
56
  import { useInlineSession } from "./useInlineSession.js";
54
57
  import { usePageLocale } from "./usePageLocale.js";
55
58
  import { useGlobalEdit } from "./useGlobalEdit.js";
@@ -63,6 +66,11 @@ import { HistoryModal, historyRows } from "../ui/HistoryModal.js";
63
66
  import { draftToken } from "../../draft-token.js";
64
67
  import { isStale, staleInfoOf } from "../shell/stale.js";
65
68
  import { StaleBanner } from "../ui/StaleBanner.js";
69
+ import { DriftNotice } from "../ui/DriftNotice.js";
70
+ import { OrphanFields } from "../forms/OrphanFields.js";
71
+ import { UnknownNode } from "./UnknownNode.js";
72
+ import { isBroken, pageDrift } from "../drift.js";
73
+ import { driftMessage, misfitErrors } from "../drift-copy.js";
66
74
  import { usePresence, usePresenceState, useSavedElsewhere, useTrackPresence } from "../shell/presence-context.js";
67
75
  import { editorsOf } from "../shell/presence.js";
68
76
  import { PresenceLine } from "../ui/PresenceLine.js";
@@ -84,6 +92,7 @@ export function EditorView({ ops, registry, id }) {
84
92
  const [reloadKey, setReloadKey] = useState(0);
85
93
  const [paths, setPaths] = useState({});
86
94
  const [redirects, setRedirects] = useState({});
95
+ const [liveFindings, setLiveFindings] = useState([]);
87
96
  const [urls, setUrls] = useState({});
88
97
  // The locales the parent exists in — "Add locale" offers only these.
89
98
  const [parentLocales, setParentLocales] = useState([]);
@@ -112,6 +121,10 @@ export function EditorView({ ops, registry, id }) {
112
121
  /** A global edit since the item's last save ack; and any global edit on this canvas yet. */
113
122
  const [globalDirty, setGlobalDirty] = useState(false);
114
123
  const [globalTouched, setGlobalTouched] = useState(false);
124
+ /** The editor changed something on this canvas. The locked-zone fill on load is not such a change: when
125
+ * another editor's fill landed first, the refusal and their saved broadcast load their version quietly. */
126
+ const editedRef = useRef(false);
127
+ editedRef.current = touched || globalTouched;
115
128
  // Global item name → row id, loaded once per editor mount: the picker's
116
129
  // Global group and a bound locked zone need the id to place a node.
117
130
  const [globalIds, setGlobalIds] = useState(null);
@@ -143,8 +156,8 @@ export function EditorView({ ops, registry, id }) {
143
156
  setDirty(false);
144
157
  presence?.announce(pageTarget(target), r.data.draft?.savedAt ?? null);
145
158
  }
146
- else if (isStale(r))
147
- setStale(staleInfoOf(r));
159
+ else if (isStale(r) && editedRef.current)
160
+ setStale(staleInfoOf(r)); // untouched: the effect below reloads quietly
148
161
  return r;
149
162
  },
150
163
  onSaved: () => inlineRef.current?.onSaved(),
@@ -182,7 +195,7 @@ export function EditorView({ ops, registry, id }) {
182
195
  // Canvas-originated selection: the node is under the cursor already, so
183
196
  // this path deliberately skips the scroll every other selection does.
184
197
  // (`onCanvasSelect` is declared further down; the handler runs at event time.)
185
- const { rects, emptyZones, fields: fieldRects, docH, geometry, postToCanvas } = useCanvasBridge({
198
+ const { rects, emptyZones, fields: fieldRects, docH, geometry, postToCanvas, onFrameLoad, crashed } = useCanvasBridge({
186
199
  iframeRef, iframeSrc,
187
200
  onSelect: (id, path, x, y) => onCanvasSelect(id, path, x, y),
188
201
  onHover: (id, path) => {
@@ -191,6 +204,8 @@ export function EditorView({ ops, registry, id }) {
191
204
  },
192
205
  onRendered: () => inlineRef.current?.onRendered(),
193
206
  });
207
+ // A canvas the bridge never spoke from is diagnosed and named (spec 2026-09-27 shadowed-page detector).
208
+ const diagnosis = useCanvasDiagnosis(iframeSrc, geometry);
194
209
  // ── load: draft mode FIRST, then the record; only then point the iframe
195
210
  // at the site route (an iframe loaded before the cookie exists would
196
211
  // render the published tree).
@@ -251,6 +266,7 @@ export function EditorView({ ops, registry, id }) {
251
266
  setRecord(result.data.record);
252
267
  setPaths(result.data.paths);
253
268
  setRedirects(result.data.redirects);
269
+ setLiveFindings(result.data.liveFindings);
254
270
  setUrls(result.data.urls);
255
271
  setParentLocales(result.data.parentLocales);
256
272
  // Absent in this locale: the "Add <locale>" pane, derived from the record.
@@ -290,6 +306,7 @@ export function EditorView({ ops, registry, id }) {
290
306
  setRecord(result.data.record);
291
307
  setPaths(result.data.paths);
292
308
  setRedirects(result.data.redirects);
309
+ setLiveFindings(result.data.liveFindings);
293
310
  setUrls(result.data.urls);
294
311
  setParentLocales(result.data.parentLocales);
295
312
  }, [ops, id, locale]);
@@ -326,6 +343,15 @@ export function EditorView({ ops, registry, id }) {
326
343
  const name = globalNameOf(id);
327
344
  return name ? registryRef.current.globals.find((s) => s.name === name) ?? null : null;
328
345
  }, [globalOf]);
346
+ // The crashed sections' banner lines (spec 2026-09-27 section guard §6): the section's title from the
347
+ // registry, a placed global's item title, the node type when the registry has no name for it.
348
+ const crashTitleOf = (id) => {
349
+ const node = treeRef.current ? findNode(treeRef.current, id) : null;
350
+ if (!node)
351
+ return "Section";
352
+ return globalItemOf(id)?.title ?? sectionOf(node.type)?.title ?? node.type;
353
+ };
354
+ const crashedLines = crashLines(crashed, crashTitleOf);
329
355
  // ── ref fields inside sections: same option loading as EntryForm ──────
330
356
  const refTargets = useMemo(() => {
331
357
  const out = {};
@@ -390,16 +416,26 @@ export function EditorView({ ops, registry, id }) {
390
416
  }, [globalEdit.stale]);
391
417
  /** Load their version (§4): drop the frozen state; the load effect resets the tree, the globals and the flags. */
392
418
  const loadTheirs = () => { setStale(null); reset(); setReloadKey((k) => k + 1); };
393
- // Someone else saved this page (§5): clean → their version silently; unsaved edits anywhere → the freeze at once.
419
+ /** Their version without the freeze: for a view with nothing of the editor's own to lose. */
420
+ const loadTheirsQuietly = useCallback(() => { reset(); keepSelectionRef.current = true; setReloadKey((k) => k + 1); }, [reset]);
421
+ // A refused save the editor never made — the locked-zone fill, when another editor's fill landed first.
422
+ // Decided by editedRef, not by `stale`: the refusal's setStale and the autosave's state can render apart.
423
+ useEffect(() => {
424
+ if (saveState === "stale" && !editedRef.current)
425
+ loadTheirsQuietly();
426
+ }, [saveState, loadTheirsQuietly]);
427
+ // Someone else saved this page (§5): clean or untouched → their version silently; the editor's own unsaved edits → the freeze at once.
394
428
  useSavedElsewhere(target, (event) => {
395
429
  if (stale)
396
430
  return;
397
- if (dirty || globalDirty || saveState !== "saved" || globalEdit.saveState !== "saved")
398
- setStale({ by: event.email, at: Date.now() });
399
- else {
431
+ if (!(dirty || globalDirty || saveState !== "saved" || globalEdit.saveState !== "saved")) {
400
432
  keepSelectionRef.current = true;
401
433
  setReloadKey((k) => k + 1);
402
434
  }
435
+ else if (editedRef.current)
436
+ setStale({ by: event.email, at: Date.now() });
437
+ else
438
+ loadTheirsQuietly(); // only the fill is pending: drop it
403
439
  });
404
440
  // Selecting from the outline must reveal the block. The canvas column is
405
441
  // the scroller now (the iframe has no scrollbar of its own — it is sized to
@@ -504,15 +540,34 @@ export function EditorView({ ops, registry, id }) {
504
540
  return;
505
541
  const template = registryRef.current.pages.find((p) => p.name === record.template);
506
542
  const filled = fillLockedZones(tree, template?.zones ?? {}, registryRef.current.sections, globalIds);
507
- if (filled !== tree)
508
- mutate(() => filled);
543
+ // Saved like the fill on load, and like it not an edit of the editor's (editedRef).
544
+ if (filled !== tree) {
545
+ setTree(filled);
546
+ treeRef.current = filled;
547
+ mark();
548
+ }
509
549
  // `tree` is deliberately not a dependency: this runs when the ids land
510
550
  // or a new draft loads, not on every keystroke.
511
551
  // eslint-disable-next-line react-hooks/exhaustive-deps
512
- }, [globalIds, record, mutate, tree === null]);
552
+ }, [globalIds, record, mark, tree === null]);
513
553
  // ── tree gestures ────────────────────────────────────────────────────
514
554
  const nodesIn = useCallback((zone) => tree?.zones[zone] ?? [], [tree]);
515
555
  const zoneCanTake = useCallback((zone) => canInsert(policyOf(zone), nodesIn(zone).length), [policyOf, nodesIn]);
556
+ // Drift (spec 2026-09-27 editor safety §5): the draft on screen against the registry, grouped by
557
+ // the first path segment — a node id, `zone:<name>` or `page`.
558
+ const findings = useMemo(() => (record && tree ? pageDrift(registry, record.template, tree) : []), [record, tree, registry]);
559
+ const findingsAt = useMemo(() => {
560
+ const by = new Map();
561
+ for (const f of findings) {
562
+ const key = f.path.split(".")[0];
563
+ by.set(key, [...(by.get(key) ?? []), f]);
564
+ }
565
+ return by;
566
+ }, [findings]);
567
+ const markOf = (nodeId) => {
568
+ const fs = findingsAt.get(nodeId);
569
+ return !fs ? undefined : fs.some(isBroken) ? "broken" : "orphan";
570
+ };
516
571
  const doInsert = (zone, index, section) => {
517
572
  const node = nodeFromSample(section, newNodeId(section.name));
518
573
  mutate((t) => insertNode(t, zone, index, node));
@@ -579,6 +634,9 @@ export function EditorView({ ops, registry, id }) {
579
634
  const doPatchPage = (ns, key, value) => mutate((t) => patchPage(t, ns, key, value));
580
635
  // ── publish ──────────────────────────────────────────────────────────
581
636
  const doPublish = async () => {
637
+ // A section the preview shows as crashed (spec 2026-09-27 section guard §6): ask before it goes live.
638
+ if (crashedLines.length > 0 && !(await confirm(publishCrashConfirm(crashedLines))))
639
+ return;
582
640
  setBanner(null);
583
641
  setPageBusy("publish");
584
642
  // Never publish behind a pending debounce.
@@ -794,10 +852,11 @@ export function EditorView({ ops, registry, id }) {
794
852
  const fieldHover = hoverField && hoverField.node === selectedId ? fieldRectOf(hoverField) : undefined;
795
853
  const editing = fieldRectOf(inline.session);
796
854
  const pageWidth = rects.reduce((w, r) => Math.max(w, r.x + r.w), 320);
797
- const selection = selected && selRect && (selectedSection || selectedGlobal)
855
+ const selection = selected && selRect
798
856
  ? {
857
+ // An unknown node is selectable in place too (spec 2026-09-27 editor safety §5), titled as the Outline labels it.
799
858
  node: selected, rect: selRect,
800
- title: selectedSection?.title ?? selectedGlobal?.title ?? selected.type,
859
+ title: selectedSection?.title ?? selectedGlobal?.title ?? labelOf(selected),
801
860
  global: Boolean(selectedGlobal), locked: selLocked,
802
861
  }
803
862
  : layoutItem && selectedId && selRect
@@ -806,12 +865,12 @@ export function EditorView({ ops, registry, id }) {
806
865
  rect: selRect, title: layoutItem.title, global: true, locked: false, layout: true,
807
866
  }
808
867
  : null;
809
- const labelOf = (node) => {
868
+ function labelOf(node) {
810
869
  if (node.type === CUSTOM_NODE_TYPE)
811
870
  return customNodeLabel(node);
812
871
  const global = globalOf(node);
813
- return sectionOf(node.type)?.title ?? (global ? `${global.title} · global` : node.type);
814
- };
872
+ return sectionOf(node.type)?.title ?? (global ? `${global.title} · global` : `Unknown section \`${node.type}\``);
873
+ }
815
874
  // Reveal is defined here, where the rect map exists; select() calls it
816
875
  // through the ref so its own identity stays stable.
817
876
  revealRef.current = (id) => {
@@ -832,8 +891,8 @@ export function EditorView({ ops, registry, id }) {
832
891
  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); } })),
833
892
  },
834
893
  { label },
835
- ] }) }), right: _jsxs(_Fragment, { children: [(touched || saveState === "error") && _jsx(SaveIndicator, { state: saveState, pending: dirty }), ready && (_jsx(LinkButton, { icon: true, href: siteUrl, target: "_blank", rel: "noreferrer", "aria-label": "View live", title: "View live \u2014 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: _jsx(Eye, { size: 16, strokeWidth: 1.5, "aria-hidden": "true" }) })), _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(); } })] }) }), target && _jsx(PresenceLine, { names: editorsOf(others, target), noun: "page" }), stale && _jsx(StaleBanner, { info: stale, onReload: loadTheirs }), 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", inert: stale ? true : undefined, 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, onClick: (e) => { if (e.target === e.currentTarget)
836
- setSelectedId(null); }, 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 && (() => {
894
+ ] }) }), right: _jsxs(_Fragment, { children: [(touched || saveState === "error") && _jsx(SaveIndicator, { state: saveState, pending: dirty }), ready && (_jsx(LinkButton, { icon: true, href: siteUrl, target: "_blank", rel: "noreferrer", "aria-label": "View live", title: "View live \u2014 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: _jsx(Eye, { size: 16, strokeWidth: 1.5, "aria-hidden": "true" }) })), _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(); } })] }) }), target && _jsx(PresenceLine, { names: editorsOf(others, target), noun: "page" }), stale && _jsx(StaleBanner, { info: stale, onReload: loadTheirs }), _jsx(DriftNotice, { draft: findings, live: liveFindings }), 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", inert: stale ? true : undefined, children: [_jsxs("div", { className: "sm-canvas", children: [(banner || diagnosis || crashedLines.length > 0) && (_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 ?? [] })] })), diagnosis && _jsx(CanvasWarning, { cause: diagnosis.cause, path: diagnosis.path }), crashedLines.length > 0 && (_jsx(BannerBox, { tone: "warn", children: crashedLines.map((line, i) => _jsx("p", { children: line }, i)) }))] })), _jsx("div", { className: "sm-canvas__scroll", ref: scrollerRef, onClick: (e) => { if (e.target === e.currentTarget)
895
+ setSelectedId(null); }, children: _jsxs("div", { className: "sm-page", style: { overflow: "hidden" }, children: [iframeSrc ? (_jsx("iframe", { ref: iframeRef, src: iframeSrc, title: "Page canvas", onLoad: onFrameLoad, 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 && (() => {
837
896
  const { session } = inline.richtext;
838
897
  const item = globalItemOf(session.node);
839
898
  const node = !item && tree ? findNode(tree, session.node) : null;
@@ -848,11 +907,11 @@ export function EditorView({ ops, registry, id }) {
848
907
  else
849
908
  mutate((t) => patchNodePath(t, session.node, session.path, doc));
850
909
  }, onEscape: inline.end }));
851
- })()] }) })] }), _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, pending: globalDirty })] }), _jsx(IconButton, { label: "Clear selection", onClick: () => setSelectedId(null), children: "\u00D7" })] }), tabs.length > 0 && selectedSection && (_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], redirects: redirects[locale] ?? [], fixed: isFixedNode(record, registry), onRename: onRename, onPatch: doPatchPage, onRemoveLocale: Object.keys(record.locales).length > 1 &&
910
+ })()] }) })] }), _jsx(ResizeHandle, {}), _jsxs("aside", { className: "sm-inspector", ref: inspectorRef, children: [_jsxs("div", { className: "sm-card", children: [selected && !selectedSection && !selectedItem ? (_jsx(UnknownNode, { node: selected, label: labelOf(selected), onRemove: () => { void doRemove(selected.id, true); } })) : 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, pending: globalDirty })] }), _jsx(IconButton, { label: "Clear selection", onClick: () => setSelectedId(null), children: "\u00D7" })] }), tabs.length > 0 && selectedSection && (_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], redirects: redirects[locale] ?? [], fixed: isFixedNode(record, registry), onRename: onRename, onPatch: doPatchPage, drift: findingsAt.get("page") ?? [], onClearPageField: (key) => mutate((t) => ({ ...t, fields: unsetAtPath(t.fields ?? {}, key) })), onRemoveLocale: Object.keys(record.locales).length > 1 &&
852
911
  !isFixedNode(record, registry) &&
853
912
  !(record.parentId === null && record.locales[registry.locales.default]?.slug === registry.homePageSlug)
854
913
  ? () => { void doRemoveLocale(); }
855
- : 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)
914
+ : undefined })) : null, (selectedSection || selectedItem) && (_jsxs("div", { className: "sm-inspector__body", children: [selected && findingsAt.get(selected.id)?.filter((f) => f.kind === "zone-disallowed").map((f) => (_jsx("p", { className: "sm-outline-note", children: driftMessage(f) }, f.path))), 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, errors: ns === "css" ? undefined : misfitErrorsIn(findingsAt.get(selected.id) ?? [], selected.id, ns), 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)
856
915
  reveal(p, selectedSection); }, onChange: (value) => {
857
916
  if (ns === "css")
858
917
  doPatchCss(selected.id, value);
@@ -860,7 +919,9 @@ export function EditorView({ ops, registry, id }) {
860
919
  doPatch(selected.id, ns, key, value);
861
920
  if (ns === "fields")
862
921
  syncTexts(selected.id, selectedSection, { ...selected.fields, [key]: value });
863
- } }, `${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, tabs: tabs.length > 0 ? _jsx(Tabs, { tabs: tabs.map((t) => t.tab), value: activeTab, onChange: (t) => setTab(t) }) : undefined, 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)
922
+ } }, `${selected.id}.${ns}.${key}`)) })), selected && selectedSection && (
923
+ // Element findings sit under the node id too, but their paths are the element card's (Task 10), not node keys.
924
+ _jsx(OrphanFields, { orphans: (findingsAt.get(selected.id) ?? []).filter((f) => !f.path.includes(".elements.")), strip: selected.id, onClear: (path) => mutate((t) => unsetNodePath(t, selected.id, path)) })), selectedItem && selectedId && (_jsx(GlobalInspector, { item: selectedItem, edit: globalEdit.items[selectedItem.name] ?? null, locale: locale, groups: activeGroups, multilingual: registry.locales.supported.length > 1, refOptions: refOptions, tabs: tabs.length > 0 ? _jsx(Tabs, { tabs: tabs.map((t) => t.tab), value: activeTab, onChange: (t) => setTab(t) }) : undefined, 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)
864
925
  reveal(p, selectedItem); }, onChange: (ns, key, value) => {
865
926
  patchGlobal(selectedItem.name, globalPath(ns, key), value);
866
927
  if (ns === "fields") {
@@ -868,10 +929,23 @@ export function EditorView({ ops, registry, id }) {
868
929
  if (current)
869
930
  syncTexts(selectedId, selectedItem, { ...current.fields, [key]: value });
870
931
  }
871
- }, 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) => ({
872
- zone, locked: policyOf(zone)?.kind === "locked", canTake: zoneCanTake(zone),
873
- nodes: nodesIn(zone).map((node) => ({ node, label: labelOf(node) })),
874
- })), onAddFirst: (zone) => {
932
+ }, 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: [
933
+ ...zoneOrder.map((zone) => ({
934
+ zone, locked: policyOf(zone)?.kind === "locked", canTake: zoneCanTake(zone),
935
+ note: findingsAt.get(`zone:${zone}`)?.map(driftMessage)[0],
936
+ nodes: nodesIn(zone).map((node) => ({ node, label: labelOf(node), mark: markOf(node.id) })),
937
+ })),
938
+ // Stored zones the page type no longer declares (spec 2026-09-27 editor safety §5): listed after the real ones.
939
+ ...Object.keys(tree?.zones ?? {}).filter((z) => !zoneOrder.includes(z) && (tree.zones[z]?.length ?? 0) > 0).map((zone) => ({
940
+ zone, locked: false, canTake: false, removed: true,
941
+ nodes: tree.zones[zone].map((node) => ({ node, label: labelOf(node) })),
942
+ })),
943
+ ], onClearZone: async (zone) => {
944
+ const count = tree?.zones[zone]?.length ?? 0;
945
+ if (!(await confirm({ title: "Clear this zone?", body: `Its ${count === 1 ? "section is" : `${count} sections are`} removed from the draft. The live page keeps ${count === 1 ? "it" : "them"} until you publish.`, confirmLabel: "Clear zone", destructive: true })))
946
+ return;
947
+ mutate((t) => clearZone(t, zone));
948
+ }, onAddFirst: (zone) => {
875
949
  const first = allowedSections(policyOf(zone), registry.sections)[0];
876
950
  if (first)
877
951
  doInsert(zone, 0, first);
@@ -879,6 +953,19 @@ export function EditorView({ ops, registry, id }) {
879
953
  }
880
954
  /** The selected node's ref field (if any) resolves to its target
881
955
  * collection's loaded options — the EntryForm shape, section-scoped. */
956
+ /** A node's misfit markers for one namespace's widgets (spec 2026-09-27 editor safety §5): a widget's
957
+ * path is its key within the namespace, so `settings.` / `styles.` come off and the rest stay fields'. */
958
+ function misfitErrorsIn(findings, nodeId, ns) {
959
+ const all = misfitErrors(findings, nodeId);
960
+ const out = {};
961
+ for (const [path, message] of Object.entries(all)) {
962
+ if (ns === "fields" && !path.startsWith("settings.") && !path.startsWith("styles."))
963
+ out[path] = message;
964
+ else if (ns !== "fields" && path.startsWith(`${ns}.`))
965
+ out[path.slice(ns.length + 1)] = message;
966
+ }
967
+ return out;
968
+ }
882
969
  /** 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. */
883
970
  function globalPath(ns, key) {
884
971
  if (ns === "fields")
@@ -11,6 +11,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
11
11
  import { useEffect, useMemo } from "react";
12
12
  import { createPortal } from "react-dom";
13
13
  import { EditorContent, useEditorState } from "@tiptap/react";
14
+ import { allowedOf } from "../../toolset.js";
14
15
  import { MediaPicker } from "../forms/MediaPicker.js";
15
16
  import { RichtextInsertMenu } from "../forms/RichtextInsertMenu.js";
16
17
  import { RichtextLinkRow, RichtextMenu } from "../forms/RichtextPopovers.js";
@@ -20,7 +21,8 @@ import { FloatingToolbar } from "./FloatingToolbar.js";
20
21
  export function InlineRichtext({ mount, value, toolset, pageWidth, onChange, onEscape }) {
21
22
  const rt = useRichtextEditor({ value, toolset, onChange, contentClass: "sm-inline-richtext", onEscape });
22
23
  const { editor } = rt;
23
- const formInfo = useMemo(() => ({ path: mount.session.path, canvas: true }), [mount.session.path]);
24
+ const allowed = useMemo(() => allowedOf(toolset).elements, [toolset]);
25
+ const formInfo = useMemo(() => ({ path: mount.session.path, canvas: true, elements: allowed }), [mount.session.path, allowed]);
24
26
  // The caret where the click landed, once the editor exists.
25
27
  useEffect(() => {
26
28
  if (!editor)
@@ -6,7 +6,12 @@ type ZoneRows = {
6
6
  nodes: {
7
7
  node: TreeNode;
8
8
  label: string;
9
+ mark?: "broken" | "orphan";
9
10
  }[];
11
+ /** A stored zone the page type no longer declares: one row with Clear zone. */
12
+ removed?: boolean;
13
+ /** A zone rule the stored nodes break — "Zone `main` allows 3 sections and has 5." */
14
+ note?: string;
10
15
  };
11
16
  type Handlers = {
12
17
  selectedId: string | null;
@@ -16,6 +21,8 @@ type Handlers = {
16
21
  /** The drop and the grip's arrow keys: `index` in the resulting order. */
17
22
  onMoveTo(id: string, index: number): void;
18
23
  onRemove(id: string): void;
24
+ /** A removed zone's Clear zone. */
25
+ onClearZone?(zone: string): void;
19
26
  };
20
27
  export declare function Outline({ zones, ...handlers }: {
21
28
  zones: ZoneRows[];
@@ -1,15 +1,18 @@
1
1
  "use client";
2
2
  import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
3
- import { Grip, IconButton, SectionLabel, cx } from "../ui/primitives.js";
3
+ import { Button, Grip, IconButton, SectionLabel, cx } from "../ui/primitives.js";
4
4
  import { useSortable } from "../ui/useSortable.js";
5
5
  export function Outline({ zones, ...handlers }) {
6
6
  return (_jsxs(_Fragment, { children: [_jsx(SectionLabel, { children: "Page Outline" }), zones.map((z) => _jsx(OutlineZone, { ...z, ...handlers }, z.zone))] }));
7
7
  }
8
- function OutlineZone({ zone, locked, canTake, nodes, selectedId, onSelect, onAddFirst, onMoveTo, onRemove }) {
8
+ function OutlineZone({ zone, locked, canTake, nodes, removed, note, selectedId, onSelect, onAddFirst, onMoveTo, onRemove, onClearZone }) {
9
9
  const sortable = useSortable({
10
- count: locked ? 0 : nodes.length,
10
+ count: locked || removed ? 0 : nodes.length,
11
11
  onDrop: (from, to) => onMoveTo(nodes[from].node.id, to),
12
12
  });
13
- return (_jsxs("div", { style: { display: "flex", flexDirection: "column", gap: 4 }, children: [_jsxs(SectionLabel, { caps: true, children: [zone, locked && " · locked"] }), nodes.length === 0 && (_jsxs("div", { className: cx("sm-outline-row", "sm-outline-row--empty", canTake && "sm-outline-row--clickable"), onClick: () => { if (canTake)
14
- onAddFirst(zone); }, children: [_jsx("span", { className: "sm-outline-row__label", children: "Empty" }), canTake && _jsx("span", { className: "sm-outline-row__add", children: "+ Add" })] })), locked && nodes.map(({ node, label }) => (_jsxs("div", { className: "sm-outline-row sm-outline-row--locked sm-outline-row--clickable", "data-testid": "outline-row", onClick: () => onSelect(node.id), children: [_jsx("span", { className: "sm-outline-row__label", children: label }), _jsx("span", { className: "sm-outline-row__note", children: "Locked" })] }, node.id))), !locked && nodes.length > 0 && (_jsx("div", { ref: sortable.containerRef, style: { display: "flex", flexDirection: "column", gap: 4 }, children: nodes.map(({ node, label }, i) => (_jsxs("div", { onClick: () => onSelect(node.id), style: sortable.rowStyle(i), "data-testid": "outline-row", className: cx("sm-outline-row", "sm-outline-row--clickable", "sm-outline-row--grip", node.id === selectedId && "sm-outline-row--selected", sortable.rowClass(i)), children: [_jsx(Grip, { ...sortable.handleProps(i) }), _jsx("span", { className: "sm-outline-row__label", children: label }), _jsx(IconButton, { label: "Remove", size: 22, onClick: (e) => { e.stopPropagation(); onRemove(node.id); }, children: "\u00D7" })] }, node.id))) }))] }));
13
+ if (removed) {
14
+ return (_jsx("div", { style: { display: "flex", flexDirection: "column", gap: 4 }, "data-testid": "removed-zone", children: _jsxs("div", { className: "sm-outline-row sm-outline-row--orphan sm-outline-row--removed", children: [_jsxs("span", { className: "sm-outline-row__label", children: ["Removed zone `", zone, "` \u00B7 ", nodes.length, " ", nodes.length === 1 ? "section" : "sections"] }), _jsx(Button, { variant: "danger", size: "sm", onClick: () => onClearZone?.(zone), children: "Clear zone" })] }) }));
15
+ }
16
+ return (_jsxs("div", { style: { display: "flex", flexDirection: "column", gap: 4 }, children: [_jsxs(SectionLabel, { caps: true, children: [zone, locked && " · locked"] }), note && _jsx("p", { className: "sm-outline-note", children: note }), nodes.length === 0 && (_jsxs("div", { className: cx("sm-outline-row", "sm-outline-row--empty", canTake && "sm-outline-row--clickable"), onClick: () => { if (canTake)
17
+ onAddFirst(zone); }, children: [_jsx("span", { className: "sm-outline-row__label", children: "Empty" }), canTake && _jsx("span", { className: "sm-outline-row__add", children: "+ Add" })] })), locked && nodes.map(({ node, label, mark }) => (_jsxs("div", { className: cx("sm-outline-row sm-outline-row--locked sm-outline-row--clickable", mark && `sm-outline-row--${mark}`), "data-testid": "outline-row", onClick: () => onSelect(node.id), children: [_jsx("span", { className: "sm-outline-row__label", children: label }), _jsx("span", { className: "sm-outline-row__note", children: "Locked" })] }, node.id))), !locked && nodes.length > 0 && (_jsx("div", { ref: sortable.containerRef, style: { display: "flex", flexDirection: "column", gap: 4 }, children: nodes.map(({ node, label, mark }, i) => (_jsxs("div", { onClick: () => onSelect(node.id), style: sortable.rowStyle(i), "data-testid": "outline-row", className: cx("sm-outline-row", "sm-outline-row--clickable", "sm-outline-row--grip", node.id === selectedId && "sm-outline-row--selected", mark && `sm-outline-row--${mark}`, sortable.rowClass(i)), children: [_jsx(Grip, { ...sortable.handleProps(i) }), _jsx("span", { className: "sm-outline-row__label", children: label }), _jsx(IconButton, { label: "Remove", size: 22, onClick: (e) => { e.stopPropagation(); onRemove(node.id); }, children: "\u00D7" })] }, node.id))) }))] }));
15
18
  }
@@ -1,9 +1,10 @@
1
1
  import type { PageTree } from "../../render.tsx";
2
2
  import type { PageRecord } from "../../store.ts";
3
3
  import type { AdminPageTemplate, AdminRegistry } from "../serialize.ts";
4
+ import type { Finding } from "../drift.ts";
4
5
  export type PageTab = "SEO & Social" | "Settings";
5
6
  export declare function pageTabsFor(template: AdminPageTemplate | undefined): PageTab[];
6
- export declare function PageInspector({ record, locale, registry, tree, path, fixed, redirects, onRename, onRemoveLocale, onPatch }: {
7
+ export declare function PageInspector({ record, locale, registry, tree, path, fixed, redirects, drift, onRename, onRemoveLocale, onPatch, onClearPageField }: {
7
8
  record: PageRecord;
8
9
  locale: string;
9
10
  registry: AdminRegistry;
@@ -21,4 +22,8 @@ export declare function PageInspector({ record, locale, registry, tree, path, fi
21
22
  /** Absent when the locale cannot be removed: a fixed node, the site root, or the page's last locale. */
22
23
  onRemoveLocale?: () => void;
23
24
  onPatch(ns: "fields" | "meta", key: string, value: unknown): void;
25
+ /** The page fields' drift (spec 2026-09-27 editor safety §5): paths under `page.fields.`. */
26
+ drift?: Finding[];
27
+ /** An orphan page field's Clear. */
28
+ onClearPageField?(key: string): void;
24
29
  }): import("react").JSX.Element;
@@ -7,18 +7,20 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
7
7
  // reset when the page changes.
8
8
  import { useEffect, useState } from "react";
9
9
  import { FieldWidget } from "../forms/FieldWidget.js";
10
+ import { OrphanFields } from "../forms/OrphanFields.js";
11
+ import { misfitErrors } from "../drift-copy.js";
10
12
  import { Button, Tabs } from "../ui/primitives.js";
11
13
  import { PageSettings } from "./PageSettings.js";
12
14
  import { SeoSettings } from "./SeoSettings.js";
13
15
  export function pageTabsFor(template) {
14
16
  return template?.seo ? ["SEO & Social", "Settings"] : ["Settings"];
15
17
  }
16
- export function PageInspector({ record, locale, registry, tree, path, fixed, redirects, onRename, onRemoveLocale, onPatch }) {
18
+ export function PageInspector({ record, locale, registry, tree, path, fixed, redirects, drift = [], onRename, onRemoveLocale, onPatch, onClearPageField }) {
17
19
  const template = registry.pages.find((p) => p.name === record.template);
18
20
  const tabs = pageTabsFor(template);
19
21
  const [tab, setTab] = useState(tabs[0]);
20
22
  useEffect(() => { setTab(pageTabsFor(template)[0]); }, [record.id]); // eslint-disable-line react-hooks/exhaustive-deps
21
23
  const active = tabs.includes(tab) ? tab : tabs[0];
22
24
  const title = record.locales[locale]?.title ?? "";
23
- return (_jsxs(_Fragment, { children: [_jsx("div", { className: "sm-inspector__head", children: _jsxs("span", { style: { display: "flex", alignItems: "center", gap: 8, minWidth: 0 }, children: [_jsx("span", { className: "sm-pageglyph" }), _jsx("span", { className: "sm-title", style: { overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }, children: title })] }) }), tabs.length > 1 && _jsx(Tabs, { tabs: tabs, value: active, onChange: setTab }), _jsx("div", { className: "sm-inspector__body", children: active === "SEO & Social" ? (_jsx(SeoSettings, { meta: tree?.meta ?? {}, onPatch: (key, value) => onPatch("meta", key, value) })) : (_jsxs("div", { style: { display: "flex", flexDirection: "column", gap: 14 }, children: [_jsx(PageSettings, { record: record, locale: locale, registry: registry, path: path, fixed: fixed, redirects: redirects, onRename: onRename }), Object.entries(template?.fields ?? {}).map(([key, descriptor]) => (_jsx(FieldWidget, { fieldKey: key, descriptor: descriptor, value: tree?.fields?.[key], onChange: (value) => onPatch("fields", key, value) }, key))), onRemoveLocale && _jsxs(Button, { variant: "secondary", onClick: onRemoveLocale, children: ["Remove from ", locale] })] })) })] }));
25
+ return (_jsxs(_Fragment, { children: [_jsx("div", { className: "sm-inspector__head", children: _jsxs("span", { style: { display: "flex", alignItems: "center", gap: 8, minWidth: 0 }, children: [_jsx("span", { className: "sm-pageglyph" }), _jsx("span", { className: "sm-title", style: { overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }, children: title })] }) }), tabs.length > 1 && _jsx(Tabs, { tabs: tabs, value: active, onChange: setTab }), _jsx("div", { className: "sm-inspector__body", children: active === "SEO & Social" ? (_jsx(SeoSettings, { meta: tree?.meta ?? {}, onPatch: (key, value) => onPatch("meta", key, value) })) : (_jsxs("div", { style: { display: "flex", flexDirection: "column", gap: 14 }, children: [_jsx(PageSettings, { record: record, locale: locale, registry: registry, path: path, fixed: fixed, redirects: redirects, onRename: onRename }), Object.entries(template?.fields ?? {}).map(([key, descriptor]) => (_jsx(FieldWidget, { fieldKey: key, descriptor: descriptor, value: tree?.fields?.[key], errors: misfitErrors(drift, "page.fields"), onChange: (value) => onPatch("fields", key, value) }, key))), onClearPageField && _jsx(OrphanFields, { orphans: drift, strip: "page.fields", onClear: onClearPageField }), onRemoveLocale && _jsxs(Button, { variant: "secondary", onClick: onRemoveLocale, children: ["Remove from ", locale] })] })) })] }));
24
26
  }
@@ -7,7 +7,7 @@ import { jsxs as _jsxs, jsx as _jsx, Fragment as _Fragment } from "react/jsx-run
7
7
  // the Settings tab of PageInspector while no block is selected; the SEO
8
8
  // tab and the remove-from-locale button live there (spec 2026-09-10 page
9
9
  // fields §5).
10
- import { useEffect, useState } from "react";
10
+ import { useEffect, useRef, useState } from "react";
11
11
  import { updatedLine } from "../ui/format.js";
12
12
  import { Field, InfoRow, SectionLabel } from "../ui/primitives.js";
13
13
  import { confirmLiveUrl } from "../shell/live-url.js";
@@ -18,6 +18,8 @@ export function PageSettings({ record, locale, registry, path, fixed, redirects,
18
18
  const [draft, setDraft] = useState({ title: row?.title ?? "", slug: row?.slug ?? "" });
19
19
  const [error, setError] = useState(null);
20
20
  const adminHref = useAdminHref();
21
+ /** One commit at a time: Enter commits, then the confirm's autofocus blurs the input, and that blur must not ask again. */
22
+ const committing = useRef(false);
21
23
  const isHome = record.parentId === null && record.locales[registry.locales.default]?.slug === registry.homePageSlug;
22
24
  // The record changes identity on every rename; re-seed the inputs from it.
23
25
  useEffect(() => {
@@ -26,25 +28,33 @@ export function PageSettings({ record, locale, registry, path, fixed, redirects,
26
28
  if (!row)
27
29
  return null;
28
30
  const commit = async (key) => {
29
- setError(null);
30
- const next = draft[key];
31
- if (next === row[key])
31
+ if (committing.current)
32
32
  return;
33
- if (next === "") {
34
- setError(`A ${key} is required — nothing falls back to another locale.`);
35
- return;
36
- }
37
- // A published slug is a live URL: one confirm with the redirect box, Cancel restores the input (members management §5, redirects §6).
38
- let redirect = true;
39
- if (key === "slug") {
40
- const live = await confirmLiveUrl({ status: row.status, from: row.slug, to: next }, { name: row.title, path });
41
- if (!live.ok) {
42
- setDraft((d) => ({ ...d, slug: row.slug }));
33
+ committing.current = true;
34
+ try {
35
+ setError(null);
36
+ const next = draft[key];
37
+ if (next === row[key])
38
+ return;
39
+ if (next === "") {
40
+ setError(`A ${key} is required — nothing falls back to another locale.`);
43
41
  return;
44
42
  }
45
- redirect = live.redirect;
43
+ // A published slug is a live URL: one confirm with the redirect box, Cancel restores the input (members management §5, redirects §6).
44
+ let redirect = true;
45
+ if (key === "slug") {
46
+ const live = await confirmLiveUrl({ status: row.status, from: row.slug, to: next }, { name: row.title, path });
47
+ if (!live.ok) {
48
+ setDraft((d) => ({ ...d, slug: row.slug }));
49
+ return;
50
+ }
51
+ redirect = live.redirect;
52
+ }
53
+ setError(await onRename(key === "slug" ? { slug: next, redirect } : { title: next }));
54
+ }
55
+ finally {
56
+ committing.current = false;
46
57
  }
47
- setError(await onRename(key === "slug" ? { slug: next, redirect } : { title: next }));
48
58
  };
49
59
  const onKey = (key) => (e) => {
50
60
  if (e.key === "Enter") {
@@ -0,0 +1,7 @@
1
+ import type { ReactElement } from "react";
2
+ import type { TreeNode } from "../../render.tsx";
3
+ export declare function UnknownNode({ node, label, onRemove }: {
4
+ node: TreeNode;
5
+ label: string;
6
+ onRemove(): void;
7
+ }): ReactElement;
@@ -0,0 +1,8 @@
1
+ "use client";
2
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
3
+ import { Button } from "../ui/primitives.js";
4
+ const shown = (v) => (typeof v === "string" ? v : JSON.stringify(v));
5
+ export function UnknownNode({ node, label, onRemove }) {
6
+ const values = Object.entries(node.fields ?? {});
7
+ return (_jsxs(_Fragment, { children: [_jsx("div", { className: "sm-inspector__head", children: _jsx("span", { className: "sm-title", children: label }) }), _jsxs("div", { className: "sm-inspector__body", "data-testid": "unknown-node", children: [_jsxs("p", { className: "sm-hint", children: ["The code no longer registers `", node.type, "`, so it can't be edited or shown on the site."] }), values.map(([key, value]) => (_jsxs("div", { className: "sm-orphans__row", children: [_jsx("code", { children: key }), _jsx("span", { className: "sm-orphans__value", children: shown(value) }), _jsx(Button, { variant: "secondary", onClick: () => { void navigator.clipboard?.writeText(shown(value)); }, children: "Copy" })] }, key))), _jsx(Button, { variant: "danger", style: { alignSelf: "flex-start" }, onClick: onRemove, children: "Remove section" })] })] }));
8
+ }
@@ -0,0 +1,49 @@
1
+ /** The meta Smoodly's page renderer writes in draft mode (§3). */
2
+ export declare const DRAFT_META_NAME = "smoodly-draft";
3
+ /** The attribute on the <style> the editor bridge server-renders. */
4
+ export declare const BRIDGE_ATTR = "data-smoodly-bridge";
5
+ export type CanvasProbe = {
6
+ kind: "failed";
7
+ } | {
8
+ kind: "response";
9
+ requested: string;
10
+ url: string;
11
+ status: number;
12
+ redirected: boolean;
13
+ html: string;
14
+ frameOptions: string | null;
15
+ csp: string | null;
16
+ };
17
+ export type CanvasCause = {
18
+ kind: "unreachable";
19
+ } | {
20
+ kind: "redirect";
21
+ to: string;
22
+ } | {
23
+ kind: "notFound";
24
+ } | {
25
+ kind: "crashed";
26
+ status: number;
27
+ } | {
28
+ kind: "refused";
29
+ status: number;
30
+ } | {
31
+ kind: "shadowed";
32
+ } | {
33
+ kind: "noSearchParams";
34
+ } | {
35
+ kind: "framing";
36
+ header: string;
37
+ value: string;
38
+ } | {
39
+ kind: "script";
40
+ };
41
+ /** Read a fetch Response into a probe. A constructed Response has no url; the requested one stands in. */
42
+ export declare function probeOf(requested: string, res: Response): Promise<CanvasProbe>;
43
+ /** The first matching cause, in the spec's order (§4). */
44
+ export declare function diagnoseCanvas(probe: CanvasProbe): CanvasCause;
45
+ /** The warning's two lines for a cause (§5, verbatim). */
46
+ export declare function canvasMessage(cause: CanvasCause, path: string): {
47
+ editor: string;
48
+ developer: string;
49
+ };