smoodly 0.0.27 → 0.0.29

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 (96) hide show
  1. package/README.md +75 -3
  2. package/dist/admin/client-ops.js +2 -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/EditorView.js +70 -15
  8. package/dist/admin/editor/InlineRichtext.js +3 -1
  9. package/dist/admin/editor/Outline.d.ts +7 -0
  10. package/dist/admin/editor/Outline.js +8 -5
  11. package/dist/admin/editor/PageInspector.d.ts +9 -1
  12. package/dist/admin/editor/PageInspector.js +4 -2
  13. package/dist/admin/editor/PageSettings.d.ts +4 -1
  14. package/dist/admin/editor/PageSettings.js +33 -15
  15. package/dist/admin/editor/UnknownNode.d.ts +7 -0
  16. package/dist/admin/editor/UnknownNode.js +8 -0
  17. package/dist/admin/field-paths.d.ts +3 -0
  18. package/dist/admin/field-paths.js +26 -0
  19. package/dist/admin/forms/OrphanFields.d.ts +8 -0
  20. package/dist/admin/forms/OrphanFields.js +11 -0
  21. package/dist/admin/forms/RefPicker.js +6 -2
  22. package/dist/admin/forms/RichtextElementCard.js +9 -3
  23. package/dist/admin/forms/RichtextWidget.js +3 -1
  24. package/dist/admin/forms/TagsField.js +1 -1
  25. package/dist/admin/forms/richtext-form-context.d.ts +8 -0
  26. package/dist/admin/forms/richtext-form-context.js +5 -0
  27. package/dist/admin/health.d.ts +23 -0
  28. package/dist/admin/health.js +42 -0
  29. package/dist/admin/ops-impl.js +124 -14
  30. package/dist/admin/ops.d.ts +53 -1
  31. package/dist/admin/redirect-path.d.ts +6 -0
  32. package/dist/admin/redirect-path.js +33 -0
  33. package/dist/admin/shell/AddRedirectModal.d.ts +11 -0
  34. package/dist/admin/shell/AddRedirectModal.js +29 -0
  35. package/dist/admin/shell/AdminApp.js +2 -3
  36. package/dist/admin/shell/ContentHealthPage.d.ts +10 -0
  37. package/dist/admin/shell/ContentHealthPage.js +37 -0
  38. package/dist/admin/shell/EntriesList.js +4 -4
  39. package/dist/admin/shell/EntryForm.js +32 -7
  40. package/dist/admin/shell/ErrorPane.d.ts +4 -2
  41. package/dist/admin/shell/ErrorPane.js +5 -4
  42. package/dist/admin/shell/GlobalForm.js +22 -2
  43. package/dist/admin/shell/IconRail.d.ts +1 -1
  44. package/dist/admin/shell/IconRail.js +3 -2
  45. package/dist/admin/shell/PagesList.js +9 -7
  46. package/dist/admin/shell/RedirectsPage.d.ts +11 -0
  47. package/dist/admin/shell/RedirectsPage.js +75 -0
  48. package/dist/admin/shell/SettingsView.d.ts +4 -2
  49. package/dist/admin/shell/SettingsView.js +22 -14
  50. package/dist/admin/shell/health-list.d.ts +27 -0
  51. package/dist/admin/shell/health-list.js +47 -0
  52. package/dist/admin/shell/live-url.d.ts +10 -2
  53. package/dist/admin/shell/live-url.js +9 -4
  54. package/dist/admin/shell/members-list.js +1 -1
  55. package/dist/admin/shell/redirects-list.d.ts +12 -0
  56. package/dist/admin/shell/redirects-list.js +15 -0
  57. package/dist/admin/shell/settings.d.ts +16 -3
  58. package/dist/admin/shell/settings.js +16 -9
  59. package/dist/admin/tree-ops.d.ts +4 -0
  60. package/dist/admin/tree-ops.js +15 -1
  61. package/dist/admin/ui/Combobox.d.ts +8 -2
  62. package/dist/admin/ui/Combobox.js +48 -16
  63. package/dist/admin/ui/ConfirmDialog.d.ts +1 -1
  64. package/dist/admin/ui/ConfirmDialog.js +5 -4
  65. package/dist/admin/ui/DriftNotice.d.ts +6 -0
  66. package/dist/admin/ui/DriftNotice.js +10 -0
  67. package/dist/admin/ui/combo-box.d.ts +13 -0
  68. package/dist/admin/ui/combo-box.js +8 -0
  69. package/dist/admin/ui/confirm.d.ts +21 -2
  70. package/dist/admin/ui/confirm.js +26 -9
  71. package/dist/admin/ui/theme.js +32 -3
  72. package/dist/admin/validate.js +15 -47
  73. package/dist/entry-store.d.ts +31 -6
  74. package/dist/entry-store.js +39 -11
  75. package/dist/index.d.ts +2 -2
  76. package/dist/next/page-renderer.js +28 -9
  77. package/dist/path-index.d.ts +28 -4
  78. package/dist/path-index.js +37 -4
  79. package/dist/paths.d.ts +16 -0
  80. package/dist/paths.js +9 -0
  81. package/dist/render.js +7 -2
  82. package/dist/schema-version.d.ts +1 -1
  83. package/dist/schema-version.js +1 -1
  84. package/dist/site.d.ts +4 -1
  85. package/dist/site.js +3 -1
  86. package/dist/sql.js +19 -1
  87. package/dist/store.d.ts +39 -5
  88. package/dist/store.js +32 -8
  89. package/dist/supabase-entry-store.d.ts +9 -3
  90. package/dist/supabase-entry-store.js +39 -13
  91. package/dist/supabase-path-index.d.ts +10 -3
  92. package/dist/supabase-path-index.js +50 -17
  93. package/dist/supabase-store.d.ts +15 -3
  94. package/dist/supabase-store.js +42 -8
  95. package/migrations/20260926150000_smoodly_v4.sql +41 -0
  96. package/package.json +1 -1
@@ -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";
@@ -63,6 +63,11 @@ import { HistoryModal, historyRows } from "../ui/HistoryModal.js";
63
63
  import { draftToken } from "../../draft-token.js";
64
64
  import { isStale, staleInfoOf } from "../shell/stale.js";
65
65
  import { StaleBanner } from "../ui/StaleBanner.js";
66
+ import { DriftNotice } from "../ui/DriftNotice.js";
67
+ import { OrphanFields } from "../forms/OrphanFields.js";
68
+ import { UnknownNode } from "./UnknownNode.js";
69
+ import { isBroken, pageDrift } from "../drift.js";
70
+ import { driftMessage, misfitErrors } from "../drift-copy.js";
66
71
  import { usePresence, usePresenceState, useSavedElsewhere, useTrackPresence } from "../shell/presence-context.js";
67
72
  import { editorsOf } from "../shell/presence.js";
68
73
  import { PresenceLine } from "../ui/PresenceLine.js";
@@ -83,6 +88,8 @@ export function EditorView({ ops, registry, id }) {
83
88
  const [folder, setFolder] = useState(false);
84
89
  const [reloadKey, setReloadKey] = useState(0);
85
90
  const [paths, setPaths] = useState({});
91
+ const [redirects, setRedirects] = useState({});
92
+ const [liveFindings, setLiveFindings] = useState([]);
86
93
  const [urls, setUrls] = useState({});
87
94
  // The locales the parent exists in — "Add locale" offers only these.
88
95
  const [parentLocales, setParentLocales] = useState([]);
@@ -249,6 +256,8 @@ export function EditorView({ ops, registry, id }) {
249
256
  }
250
257
  setRecord(result.data.record);
251
258
  setPaths(result.data.paths);
259
+ setRedirects(result.data.redirects);
260
+ setLiveFindings(result.data.liveFindings);
252
261
  setUrls(result.data.urls);
253
262
  setParentLocales(result.data.parentLocales);
254
263
  // Absent in this locale: the "Add <locale>" pane, derived from the record.
@@ -287,6 +296,8 @@ export function EditorView({ ops, registry, id }) {
287
296
  return;
288
297
  setRecord(result.data.record);
289
298
  setPaths(result.data.paths);
299
+ setRedirects(result.data.redirects);
300
+ setLiveFindings(result.data.liveFindings);
290
301
  setUrls(result.data.urls);
291
302
  setParentLocales(result.data.parentLocales);
292
303
  }, [ops, id, locale]);
@@ -510,6 +521,21 @@ export function EditorView({ ops, registry, id }) {
510
521
  // ── tree gestures ────────────────────────────────────────────────────
511
522
  const nodesIn = useCallback((zone) => tree?.zones[zone] ?? [], [tree]);
512
523
  const zoneCanTake = useCallback((zone) => canInsert(policyOf(zone), nodesIn(zone).length), [policyOf, nodesIn]);
524
+ // Drift (spec 2026-09-27 editor safety §5): the draft on screen against the registry, grouped by
525
+ // the first path segment — a node id, `zone:<name>` or `page`.
526
+ const findings = useMemo(() => (record && tree ? pageDrift(registry, record.template, tree) : []), [record, tree, registry]);
527
+ const findingsAt = useMemo(() => {
528
+ const by = new Map();
529
+ for (const f of findings) {
530
+ const key = f.path.split(".")[0];
531
+ by.set(key, [...(by.get(key) ?? []), f]);
532
+ }
533
+ return by;
534
+ }, [findings]);
535
+ const markOf = (nodeId) => {
536
+ const fs = findingsAt.get(nodeId);
537
+ return !fs ? undefined : fs.some(isBroken) ? "broken" : "orphan";
538
+ };
513
539
  const doInsert = (zone, index, section) => {
514
540
  const node = nodeFromSample(section, newNodeId(section.name));
515
541
  mutate((t) => insertNode(t, zone, index, node));
@@ -791,10 +817,11 @@ export function EditorView({ ops, registry, id }) {
791
817
  const fieldHover = hoverField && hoverField.node === selectedId ? fieldRectOf(hoverField) : undefined;
792
818
  const editing = fieldRectOf(inline.session);
793
819
  const pageWidth = rects.reduce((w, r) => Math.max(w, r.x + r.w), 320);
794
- const selection = selected && selRect && (selectedSection || selectedGlobal)
820
+ const selection = selected && selRect
795
821
  ? {
822
+ // An unknown node is selectable in place too (spec 2026-09-27 editor safety §5), titled as the Outline labels it.
796
823
  node: selected, rect: selRect,
797
- title: selectedSection?.title ?? selectedGlobal?.title ?? selected.type,
824
+ title: selectedSection?.title ?? selectedGlobal?.title ?? labelOf(selected),
798
825
  global: Boolean(selectedGlobal), locked: selLocked,
799
826
  }
800
827
  : layoutItem && selectedId && selRect
@@ -803,12 +830,12 @@ export function EditorView({ ops, registry, id }) {
803
830
  rect: selRect, title: layoutItem.title, global: true, locked: false, layout: true,
804
831
  }
805
832
  : null;
806
- const labelOf = (node) => {
833
+ function labelOf(node) {
807
834
  if (node.type === CUSTOM_NODE_TYPE)
808
835
  return customNodeLabel(node);
809
836
  const global = globalOf(node);
810
- return sectionOf(node.type)?.title ?? (global ? `${global.title} · global` : node.type);
811
- };
837
+ return sectionOf(node.type)?.title ?? (global ? `${global.title} · global` : `Unknown section \`${node.type}\``);
838
+ }
812
839
  // Reveal is defined here, where the rect map exists; select() calls it
813
840
  // through the ref so its own identity stays stable.
814
841
  revealRef.current = (id) => {
@@ -829,7 +856,7 @@ export function EditorView({ ops, registry, id }) {
829
856
  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); } })),
830
857
  },
831
858
  { label },
832
- ] }) }), 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)
859
+ ] }) }), 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 || 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)
833
860
  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 && (() => {
834
861
  const { session } = inline.richtext;
835
862
  const item = globalItemOf(session.node);
@@ -845,11 +872,11 @@ export function EditorView({ ops, registry, id }) {
845
872
  else
846
873
  mutate((t) => patchNodePath(t, session.node, session.path, doc));
847
874
  }, onEscape: inline.end }));
848
- })()] }) })] }), _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], fixed: isFixedNode(record, registry), onRename: onRename, onPatch: doPatchPage, onRemoveLocale: Object.keys(record.locales).length > 1 &&
875
+ })()] }) })] }), _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 &&
849
876
  !isFixedNode(record, registry) &&
850
877
  !(record.parentId === null && record.locales[registry.locales.default]?.slug === registry.homePageSlug)
851
878
  ? () => { void doRemoveLocale(); }
852
- : 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)
879
+ : 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)
853
880
  reveal(p, selectedSection); }, onChange: (value) => {
854
881
  if (ns === "css")
855
882
  doPatchCss(selected.id, value);
@@ -857,7 +884,9 @@ export function EditorView({ ops, registry, id }) {
857
884
  doPatch(selected.id, ns, key, value);
858
885
  if (ns === "fields")
859
886
  syncTexts(selected.id, selectedSection, { ...selected.fields, [key]: value });
860
- } }, `${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)
887
+ } }, `${selected.id}.${ns}.${key}`)) })), selected && selectedSection && (
888
+ // Element findings sit under the node id too, but their paths are the element card's (Task 10), not node keys.
889
+ _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)
861
890
  reveal(p, selectedItem); }, onChange: (ns, key, value) => {
862
891
  patchGlobal(selectedItem.name, globalPath(ns, key), value);
863
892
  if (ns === "fields") {
@@ -865,10 +894,23 @@ export function EditorView({ ops, registry, id }) {
865
894
  if (current)
866
895
  syncTexts(selectedId, selectedItem, { ...current.fields, [key]: value });
867
896
  }
868
- }, 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) => ({
869
- zone, locked: policyOf(zone)?.kind === "locked", canTake: zoneCanTake(zone),
870
- nodes: nodesIn(zone).map((node) => ({ node, label: labelOf(node) })),
871
- })), onAddFirst: (zone) => {
897
+ }, 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: [
898
+ ...zoneOrder.map((zone) => ({
899
+ zone, locked: policyOf(zone)?.kind === "locked", canTake: zoneCanTake(zone),
900
+ note: findingsAt.get(`zone:${zone}`)?.map(driftMessage)[0],
901
+ nodes: nodesIn(zone).map((node) => ({ node, label: labelOf(node), mark: markOf(node.id) })),
902
+ })),
903
+ // Stored zones the page type no longer declares (spec 2026-09-27 editor safety §5): listed after the real ones.
904
+ ...Object.keys(tree?.zones ?? {}).filter((z) => !zoneOrder.includes(z) && (tree.zones[z]?.length ?? 0) > 0).map((zone) => ({
905
+ zone, locked: false, canTake: false, removed: true,
906
+ nodes: tree.zones[zone].map((node) => ({ node, label: labelOf(node) })),
907
+ })),
908
+ ], onClearZone: async (zone) => {
909
+ const count = tree?.zones[zone]?.length ?? 0;
910
+ 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 })))
911
+ return;
912
+ mutate((t) => clearZone(t, zone));
913
+ }, onAddFirst: (zone) => {
872
914
  const first = allowedSections(policyOf(zone), registry.sections)[0];
873
915
  if (first)
874
916
  doInsert(zone, 0, first);
@@ -876,6 +918,19 @@ export function EditorView({ ops, registry, id }) {
876
918
  }
877
919
  /** The selected node's ref field (if any) resolves to its target
878
920
  * collection's loaded options — the EntryForm shape, section-scoped. */
921
+ /** A node's misfit markers for one namespace's widgets (spec 2026-09-27 editor safety §5): a widget's
922
+ * path is its key within the namespace, so `settings.` / `styles.` come off and the rest stay fields'. */
923
+ function misfitErrorsIn(findings, nodeId, ns) {
924
+ const all = misfitErrors(findings, nodeId);
925
+ const out = {};
926
+ for (const [path, message] of Object.entries(all)) {
927
+ if (ns === "fields" && !path.startsWith("settings.") && !path.startsWith("styles."))
928
+ out[path] = message;
929
+ else if (ns !== "fields" && path.startsWith(`${ns}.`))
930
+ out[path.slice(ns.length + 1)] = message;
931
+ }
932
+ return out;
933
+ }
879
934
  /** 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. */
880
935
  function globalPath(ns, key) {
881
936
  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, 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;
@@ -11,11 +12,18 @@ export declare function PageInspector({ record, locale, registry, tree, path, fi
11
12
  /** The page's path in this locale (the `paths` index). */
12
13
  path: string | undefined;
13
14
  fixed: boolean;
15
+ /** The active locale's old addresses that redirect here (spec 2026-09-26 redirects §6). */
16
+ redirects?: string[];
14
17
  onRename(patch: {
15
18
  title?: string;
16
19
  slug?: string;
20
+ redirect?: boolean;
17
21
  }): Promise<string | null>;
18
22
  /** Absent when the locale cannot be removed: a fixed node, the site root, or the page's last locale. */
19
23
  onRemoveLocale?: () => void;
20
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;
21
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, 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, 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
  }
@@ -1,6 +1,6 @@
1
1
  import type { PageRecord } from "../../store.ts";
2
2
  import type { AdminRegistry } from "../serialize.ts";
3
- export declare function PageSettings({ record, locale, registry, path, fixed, onRename }: {
3
+ export declare function PageSettings({ record, locale, registry, path, fixed, redirects, onRename }: {
4
4
  record: PageRecord;
5
5
  locale: string;
6
6
  registry: AdminRegistry;
@@ -8,8 +8,11 @@ export declare function PageSettings({ record, locale, registry, path, fixed, on
8
8
  path: string | undefined;
9
9
  fixed: boolean;
10
10
  /** Resolves to an error message, or null when the rename landed. */
11
+ /** The active locale's old addresses that redirect here (spec 2026-09-26 redirects §6). */
12
+ redirects?: string[];
11
13
  onRename(patch: {
12
14
  title?: string;
13
15
  slug?: string;
16
+ redirect?: boolean;
14
17
  }): Promise<string | null>;
15
18
  }): import("react").JSX.Element | null;
@@ -1,5 +1,5 @@
1
1
  "use client";
2
- import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
2
+ import { jsxs as _jsxs, jsx as _jsx, Fragment as _Fragment } from "react/jsx-runtime";
3
3
  // Page settings (spec 2026-09-05 §7; per locale since spec 2026-09-06
4
4
  // §6): the ACTIVE locale's title and slug, saved through pages.rename,
5
5
  // and "Remove from this locale". The switcher in the canvas meta row
@@ -7,14 +7,19 @@ import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
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";
14
- export function PageSettings({ record, locale, registry, path, fixed, onRename }) {
14
+ import { RouteLink } from "../shell/RouteLink.js";
15
+ import { useAdminHref } from "../shell/base.js";
16
+ export function PageSettings({ record, locale, registry, path, fixed, redirects, onRename }) {
15
17
  const row = record.locales[locale];
16
18
  const [draft, setDraft] = useState({ title: row?.title ?? "", slug: row?.slug ?? "" });
17
19
  const [error, setError] = useState(null);
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);
18
23
  const isHome = record.parentId === null && record.locales[registry.locales.default]?.slug === registry.homePageSlug;
19
24
  // The record changes identity on every rename; re-seed the inputs from it.
20
25
  useEffect(() => {
@@ -23,20 +28,33 @@ export function PageSettings({ record, locale, registry, path, fixed, onRename }
23
28
  if (!row)
24
29
  return null;
25
30
  const commit = async (key) => {
26
- setError(null);
27
- const next = draft[key];
28
- if (next === row[key])
29
- return;
30
- if (next === "") {
31
- setError(`A ${key} is required — nothing falls back to another locale.`);
31
+ if (committing.current)
32
32
  return;
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.`);
41
+ return;
42
+ }
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 }));
33
54
  }
34
- // A published slug is a live URL: one confirm, Cancel restores the input (members management §5).
35
- if (key === "slug" && !(await confirmLiveUrl({ status: row.status, from: row.slug, to: next }, { name: row.title, path }))) {
36
- setDraft((d) => ({ ...d, slug: row.slug }));
37
- return;
55
+ finally {
56
+ committing.current = false;
38
57
  }
39
- setError(await onRename({ [key]: next }));
40
58
  };
41
59
  const onKey = (key) => (e) => {
42
60
  if (e.key === "Enter") {
@@ -44,5 +62,5 @@ export function PageSettings({ record, locale, registry, path, fixed, onRename }
44
62
  void commit(key);
45
63
  }
46
64
  };
47
- return (_jsxs("div", { style: { display: "flex", flexDirection: "column", gap: 14 }, children: [_jsxs(SectionLabel, { children: ["Page \u00B7 ", locale] }), fixed && _jsx("span", { className: "sm-hint", children: "Owned by code \u2014 change it in the registration." }), error && _jsx("span", { className: "sm-error", children: error }), _jsx(Field, { label: "Title", children: _jsx("input", { className: "sm-input", disabled: fixed, value: draft.title, onChange: (e) => setDraft((d) => ({ ...d, title: e.target.value })), onBlur: () => void commit("title"), onKeyDown: onKey("title") }) }), _jsx(Field, { label: "Slug", hint: path ?? "", children: _jsx("input", { className: "sm-input sm-input--mono", disabled: fixed || isHome, value: draft.slug, onChange: (e) => setDraft((d) => ({ ...d, slug: e.target.value })), onBlur: () => void commit("slug"), onKeyDown: onKey("slug") }) }), _jsx(SectionLabel, { children: "Document info" }), _jsx(InfoRow, { label: "Updated", value: updatedLine(row.updatedAt, row.updatedBy) }), _jsx(InfoRow, { label: "ID", value: record.id, mono: true })] }));
65
+ return (_jsxs("div", { style: { display: "flex", flexDirection: "column", gap: 14 }, children: [_jsxs(SectionLabel, { children: ["Page \u00B7 ", locale] }), fixed && _jsx("span", { className: "sm-hint", children: "Owned by code \u2014 change it in the registration." }), error && _jsx("span", { className: "sm-error", children: error }), _jsx(Field, { label: "Title", children: _jsx("input", { className: "sm-input", disabled: fixed, value: draft.title, onChange: (e) => setDraft((d) => ({ ...d, title: e.target.value })), onBlur: () => void commit("title"), onKeyDown: onKey("title") }) }), _jsx(Field, { label: "Slug", hint: path ?? "", children: _jsx("input", { className: "sm-input sm-input--mono", disabled: fixed || isHome, value: draft.slug, onChange: (e) => setDraft((d) => ({ ...d, slug: e.target.value })), onBlur: () => void commit("slug"), onKeyDown: onKey("slug") }) }), redirects && redirects.length > 0 && (_jsx(Field, { label: "Redirects from", asDiv: true, hint: _jsxs(_Fragment, { children: ["Old addresses that send visitors here. Manage them in ", _jsx(RouteLink, { href: adminHref("/settings/redirects"), children: "Settings \u203A Redirects" }), "."] }), children: _jsx("div", { className: "sm-redirects-from", children: redirects.map((p) => _jsx("code", { children: p }, p)) }) })), _jsx(SectionLabel, { children: "Document info" }), _jsx(InfoRow, { label: "Updated", value: updatedLine(row.updatedAt, row.updatedBy) }), _jsx(InfoRow, { label: "ID", value: record.id, mono: true })] }));
48
66
  }
@@ -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
+ }
@@ -11,3 +11,6 @@ export declare function getAtPath(fields: Record<string, unknown> | undefined, p
11
11
  * siblings keep their identity — and keeps arrays arrays. Missing
12
12
  * objects along the way are created. */
13
13
  export declare function setAtPath(fields: Record<string, unknown> | undefined, path: string, value: unknown): Record<string, unknown>;
14
+ /** `fields` without the key at `path` (spec 2026-09-27 editor safety §5: an orphan's Clear).
15
+ * Copies only the spine; a missing path returns an equal copy. */
16
+ export declare function unsetAtPath(fields: Record<string, unknown> | undefined, path: string): Record<string, unknown>;
@@ -47,3 +47,29 @@ export function setAtPath(fields, path, value) {
47
47
  };
48
48
  return set(fields ?? {}, splitPath(path));
49
49
  }
50
+ /** `fields` without the key at `path` (spec 2026-09-27 editor safety §5: an orphan's Clear).
51
+ * Copies only the spine; a missing path returns an equal copy. */
52
+ export function unsetAtPath(fields, path) {
53
+ const unset = (obj, segments) => {
54
+ if (obj === null || typeof obj !== "object")
55
+ return obj;
56
+ const [head, ...rest] = segments;
57
+ if (Array.isArray(obj)) {
58
+ const i = Number(head);
59
+ if (rest.length === 0 || !(i in obj))
60
+ return obj;
61
+ const copy = obj.slice();
62
+ copy[i] = unset(obj[i], rest);
63
+ return copy;
64
+ }
65
+ const o = obj;
66
+ if (!(head in o))
67
+ return o;
68
+ if (rest.length === 0) {
69
+ const { [head]: _gone, ...keep } = o;
70
+ return keep;
71
+ }
72
+ return { ...o, [head]: unset(o[head], rest) };
73
+ };
74
+ return unset(fields ?? {}, splitPath(path));
75
+ }
@@ -0,0 +1,8 @@
1
+ import type { ReactElement } from "react";
2
+ import type { Finding } from "../drift.ts";
3
+ export declare function OrphanFields({ orphans, strip, onClear }: {
4
+ orphans: Finding[];
5
+ /** The node-id prefix to drop from the shown key and from the path Clear gets. */
6
+ strip?: string;
7
+ onClear(path: string): void;
8
+ }): ReactElement | null;
@@ -0,0 +1,11 @@
1
+ "use client";
2
+ import { jsx as _jsx, jsxs as _jsxs } 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 OrphanFields({ orphans, strip, onClear }) {
6
+ const rows = orphans.filter((f) => f.kind === "orphan-field");
7
+ if (rows.length === 0)
8
+ return null;
9
+ const local = (p) => (strip && p.startsWith(`${strip}.`) ? p.slice(strip.length + 1) : p);
10
+ return (_jsxs("details", { className: "sm-orphans", "data-testid": "orphan-fields", children: [_jsx("summary", { className: "sm-orphans__head", children: "No longer in the schema" }), rows.map((f) => (_jsxs("div", { className: "sm-orphans__row", children: [_jsx("code", { children: local(f.path) }), _jsx("span", { className: "sm-orphans__value", children: shown(f.value) }), _jsx(Button, { variant: "secondary", onClick: () => { void navigator.clipboard?.writeText(shown(f.value)); }, children: "Copy" }), _jsx(Button, { variant: "secondary", onClick: () => onClear(local(f.path)), children: "Clear" })] }, f.path)))] }));
11
+ }
@@ -1,7 +1,8 @@
1
1
  "use client";
2
2
  import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
3
3
  // One widget for f.ref and f.refList (spec 2026-09-17 §2): a search input,
4
- // the picked entries under it. A refList's rows drag like a list's.
4
+ // the picked entries under it. A refList's rows drag like a list's; a single
5
+ // ref shows its pick inside the input, open and remove beside it (2026-09-27).
5
6
  import { ExternalLink } from "lucide-react";
6
7
  import { Combobox } from "../ui/Combobox.js";
7
8
  import { Grip, IconButton, cx } from "../ui/primitives.js";
@@ -10,6 +11,7 @@ import { useSortable } from "../ui/useSortable.js";
10
11
  import { useAdminHref } from "../shell/base.js";
11
12
  import { moveItem } from "./list.js";
12
13
  import { pickRef, refCandidates, refIds, refRows, refValue, statusTone } from "./ref-picker.js";
14
+ const ABSENT = "Not available in this locale";
13
15
  const titleCase = (name) => name.replace(/^./, (c) => c.toUpperCase());
14
16
  function StatusDot({ status }) {
15
17
  if (status === "")
@@ -26,5 +28,7 @@ export function RefPicker({ value, onChange, options, multiple, max, target }) {
26
28
  const set = (next) => onChange(refValue(next, multiple));
27
29
  const sortable = useSortable({ count: multiple ? rows.length : 0, onDrop: (from, to) => set(moveItem(ids, from, to)) });
28
30
  const present = (options ?? []).filter((o) => !o.absent).length;
29
- return (_jsxs("div", { className: "sm-refpicker", children: [_jsx(Combobox, { label: `Search ${titleCase(target)}`, keepOpen: multiple, options: candidates.map((o) => ({ value: o.id, label: o.title })), disabled: options === undefined || atMax, placeholder: atMax ? `Maximum ${max}` : options === undefined ? "Loading…" : "Type to search", emptyText: present === 0 ? `No entries in ${titleCase(target)} yet` : "Every entry is already picked", renderOption: (o) => (_jsxs(_Fragment, { children: [_jsx("span", { className: "sm-refpicker__title", children: o.label }), _jsx(StatusDot, { status: byId.get(o.value)?.status ?? "" })] })), onPick: (id) => set(pickRef(ids, id, multiple, max)) }), rows.length > 0 && (_jsx("div", { className: "sm-refpicker__rows", ref: sortable.containerRef, children: rows.map((r, i) => (_jsxs("div", { style: multiple ? sortable.rowStyle(i) : undefined, className: cx("sm-refrow", r.state !== "ok" && "sm-refrow--muted", multiple && sortable.rowClass(i)), children: [multiple && _jsx(Grip, { ...sortable.handleProps(i) }), _jsxs("span", { className: "sm-refpicker__title", children: [r.title, r.state === "absent" && _jsx("span", { className: "sm-refrow__note", children: " \u00B7 Not available in this locale" })] }), _jsx(StatusDot, { status: r.status }), (r.state === "ok" || r.state === "absent") && (_jsx("a", { className: "sm-iconbtn", href: adminHref(`/${target}/${r.id}`), target: "_blank", rel: "noreferrer", "aria-label": "Open entry", title: "Open entry", children: _jsx(ExternalLink, { size: 13, strokeWidth: 1.75, "aria-hidden": "true" }) })), _jsx(IconButton, { label: "Remove", onClick: () => set(ids.filter((_, j) => j !== i)), children: "\u00D7" })] }, `${r.id}-${i}`))) }))] }));
31
+ const openLink = (r) => (r.state === "ok" || r.state === "absent") && (_jsx("a", { className: "sm-iconbtn", href: adminHref(`/${target}/${r.id}`), target: "_blank", rel: "noreferrer", "aria-label": "Open entry", title: "Open entry", children: _jsx(ExternalLink, { size: 13, strokeWidth: 1.75, "aria-hidden": "true" }) }));
32
+ const single = multiple ? undefined : rows[0];
33
+ return (_jsxs("div", { className: "sm-refpicker", children: [_jsx(Combobox, { label: `Search ${titleCase(target)}`, keepOpen: multiple, options: candidates.map((o) => ({ value: o.id, label: o.title })), disabled: options === undefined || atMax, placeholder: atMax ? `Maximum ${max}` : options === undefined ? "Loading…" : "Type to search", emptyText: present === 0 ? `No entries in ${titleCase(target)} yet` : "Every entry is already picked", renderOption: (o) => (_jsxs(_Fragment, { children: [_jsx("span", { className: "sm-refpicker__title", children: o.label }), _jsx(StatusDot, { status: byId.get(o.value)?.status ?? "" })] })), selected: single && `${single.title}${single.state === "absent" ? ` · ${ABSENT}` : ""}`, suffix: single && (_jsxs(_Fragment, { children: [_jsx(StatusDot, { status: single.status }), openLink(single), _jsx(IconButton, { label: "Remove", onClick: () => set([]), children: "\u00D7" })] })), onPick: (id) => set(pickRef(ids, id, multiple, max)) }), multiple && rows.length > 0 && (_jsx("div", { className: "sm-refpicker__rows", ref: sortable.containerRef, children: rows.map((r, i) => (_jsxs("div", { style: multiple ? sortable.rowStyle(i) : undefined, className: cx("sm-refrow", r.state !== "ok" && "sm-refrow--muted", multiple && sortable.rowClass(i)), children: [multiple && _jsx(Grip, { ...sortable.handleProps(i) }), _jsxs("span", { className: "sm-refpicker__title", children: [r.title, r.state === "absent" && _jsxs("span", { className: "sm-refrow__note", children: [" \u00B7 ", ABSENT] })] }), _jsx(StatusDot, { status: r.status }), openLink(r), _jsx(IconButton, { label: "Remove", onClick: () => set(ids.filter((_, j) => j !== i)), children: "\u00D7" })] }, `${r.id}-${i}`))) }))] }));
30
34
  }