smoodly 0.0.28 → 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 (67) hide show
  1. package/README.md +48 -8
  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/EditorView.js +67 -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 +6 -1
  12. package/dist/admin/editor/PageInspector.js +4 -2
  13. package/dist/admin/editor/PageSettings.js +26 -16
  14. package/dist/admin/editor/UnknownNode.d.ts +7 -0
  15. package/dist/admin/editor/UnknownNode.js +8 -0
  16. package/dist/admin/field-paths.d.ts +3 -0
  17. package/dist/admin/field-paths.js +26 -0
  18. package/dist/admin/forms/OrphanFields.d.ts +8 -0
  19. package/dist/admin/forms/OrphanFields.js +11 -0
  20. package/dist/admin/forms/RefPicker.js +6 -2
  21. package/dist/admin/forms/RichtextElementCard.js +9 -3
  22. package/dist/admin/forms/RichtextWidget.js +3 -1
  23. package/dist/admin/forms/TagsField.js +1 -1
  24. package/dist/admin/forms/richtext-form-context.d.ts +8 -0
  25. package/dist/admin/forms/richtext-form-context.js +5 -0
  26. package/dist/admin/health.d.ts +23 -0
  27. package/dist/admin/health.js +42 -0
  28. package/dist/admin/ops-impl.js +40 -9
  29. package/dist/admin/ops.d.ts +13 -0
  30. package/dist/admin/shell/AddRedirectModal.js +1 -1
  31. package/dist/admin/shell/ContentHealthPage.d.ts +10 -0
  32. package/dist/admin/shell/ContentHealthPage.js +37 -0
  33. package/dist/admin/shell/EntriesList.js +4 -4
  34. package/dist/admin/shell/EntryForm.js +20 -3
  35. package/dist/admin/shell/ErrorPane.d.ts +4 -2
  36. package/dist/admin/shell/ErrorPane.js +5 -4
  37. package/dist/admin/shell/GlobalForm.js +22 -2
  38. package/dist/admin/shell/PagesList.js +3 -3
  39. package/dist/admin/shell/SettingsView.js +6 -2
  40. package/dist/admin/shell/health-list.d.ts +27 -0
  41. package/dist/admin/shell/health-list.js +47 -0
  42. package/dist/admin/shell/settings.d.ts +4 -0
  43. package/dist/admin/shell/settings.js +5 -2
  44. package/dist/admin/tree-ops.d.ts +4 -0
  45. package/dist/admin/tree-ops.js +15 -1
  46. package/dist/admin/ui/Combobox.d.ts +8 -2
  47. package/dist/admin/ui/Combobox.js +48 -16
  48. package/dist/admin/ui/ConfirmDialog.js +3 -3
  49. package/dist/admin/ui/DriftNotice.d.ts +6 -0
  50. package/dist/admin/ui/DriftNotice.js +10 -0
  51. package/dist/admin/ui/combo-box.d.ts +13 -0
  52. package/dist/admin/ui/combo-box.js +8 -0
  53. package/dist/admin/ui/confirm.d.ts +8 -0
  54. package/dist/admin/ui/confirm.js +17 -4
  55. package/dist/admin/ui/theme.js +29 -3
  56. package/dist/admin/validate.js +15 -47
  57. package/dist/entry-store.d.ts +13 -0
  58. package/dist/entry-store.js +26 -3
  59. package/dist/index.d.ts +2 -2
  60. package/dist/render.js +7 -2
  61. package/dist/store.d.ts +9 -0
  62. package/dist/store.js +8 -0
  63. package/dist/supabase-entry-store.d.ts +2 -1
  64. package/dist/supabase-entry-store.js +27 -5
  65. package/dist/supabase-store.d.ts +2 -1
  66. package/dist/supabase-store.js +27 -0
  67. 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";
@@ -84,6 +89,7 @@ export function EditorView({ ops, registry, id }) {
84
89
  const [reloadKey, setReloadKey] = useState(0);
85
90
  const [paths, setPaths] = useState({});
86
91
  const [redirects, setRedirects] = useState({});
92
+ const [liveFindings, setLiveFindings] = useState([]);
87
93
  const [urls, setUrls] = useState({});
88
94
  // The locales the parent exists in — "Add locale" offers only these.
89
95
  const [parentLocales, setParentLocales] = useState([]);
@@ -251,6 +257,7 @@ export function EditorView({ ops, registry, id }) {
251
257
  setRecord(result.data.record);
252
258
  setPaths(result.data.paths);
253
259
  setRedirects(result.data.redirects);
260
+ setLiveFindings(result.data.liveFindings);
254
261
  setUrls(result.data.urls);
255
262
  setParentLocales(result.data.parentLocales);
256
263
  // Absent in this locale: the "Add <locale>" pane, derived from the record.
@@ -290,6 +297,7 @@ export function EditorView({ ops, registry, id }) {
290
297
  setRecord(result.data.record);
291
298
  setPaths(result.data.paths);
292
299
  setRedirects(result.data.redirects);
300
+ setLiveFindings(result.data.liveFindings);
293
301
  setUrls(result.data.urls);
294
302
  setParentLocales(result.data.parentLocales);
295
303
  }, [ops, id, locale]);
@@ -513,6 +521,21 @@ export function EditorView({ ops, registry, id }) {
513
521
  // ── tree gestures ────────────────────────────────────────────────────
514
522
  const nodesIn = useCallback((zone) => tree?.zones[zone] ?? [], [tree]);
515
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
+ };
516
539
  const doInsert = (zone, index, section) => {
517
540
  const node = nodeFromSample(section, newNodeId(section.name));
518
541
  mutate((t) => insertNode(t, zone, index, node));
@@ -794,10 +817,11 @@ export function EditorView({ ops, registry, id }) {
794
817
  const fieldHover = hoverField && hoverField.node === selectedId ? fieldRectOf(hoverField) : undefined;
795
818
  const editing = fieldRectOf(inline.session);
796
819
  const pageWidth = rects.reduce((w, r) => Math.max(w, r.x + r.w), 320);
797
- const selection = selected && selRect && (selectedSection || selectedGlobal)
820
+ const selection = selected && selRect
798
821
  ? {
822
+ // An unknown node is selectable in place too (spec 2026-09-27 editor safety §5), titled as the Outline labels it.
799
823
  node: selected, rect: selRect,
800
- title: selectedSection?.title ?? selectedGlobal?.title ?? selected.type,
824
+ title: selectedSection?.title ?? selectedGlobal?.title ?? labelOf(selected),
801
825
  global: Boolean(selectedGlobal), locked: selLocked,
802
826
  }
803
827
  : layoutItem && selectedId && selRect
@@ -806,12 +830,12 @@ export function EditorView({ ops, registry, id }) {
806
830
  rect: selRect, title: layoutItem.title, global: true, locked: false, layout: true,
807
831
  }
808
832
  : null;
809
- const labelOf = (node) => {
833
+ function labelOf(node) {
810
834
  if (node.type === CUSTOM_NODE_TYPE)
811
835
  return customNodeLabel(node);
812
836
  const global = globalOf(node);
813
- return sectionOf(node.type)?.title ?? (global ? `${global.title} · global` : node.type);
814
- };
837
+ return sectionOf(node.type)?.title ?? (global ? `${global.title} · global` : `Unknown section \`${node.type}\``);
838
+ }
815
839
  // Reveal is defined here, where the rect map exists; select() calls it
816
840
  // through the ref so its own identity stays stable.
817
841
  revealRef.current = (id) => {
@@ -832,7 +856,7 @@ export function EditorView({ ops, registry, id }) {
832
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); } })),
833
857
  },
834
858
  { 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)
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)
836
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 && (() => {
837
861
  const { session } = inline.richtext;
838
862
  const item = globalItemOf(session.node);
@@ -848,11 +872,11 @@ export function EditorView({ ops, registry, id }) {
848
872
  else
849
873
  mutate((t) => patchNodePath(t, session.node, session.path, doc));
850
874
  }, 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 &&
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 &&
852
876
  !isFixedNode(record, registry) &&
853
877
  !(record.parentId === null && record.locales[registry.locales.default]?.slug === registry.homePageSlug)
854
878
  ? () => { 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)
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)
856
880
  reveal(p, selectedSection); }, onChange: (value) => {
857
881
  if (ns === "css")
858
882
  doPatchCss(selected.id, value);
@@ -860,7 +884,9 @@ export function EditorView({ ops, registry, id }) {
860
884
  doPatch(selected.id, ns, key, value);
861
885
  if (ns === "fields")
862
886
  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)
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)
864
890
  reveal(p, selectedItem); }, onChange: (ns, key, value) => {
865
891
  patchGlobal(selectedItem.name, globalPath(ns, key), value);
866
892
  if (ns === "fields") {
@@ -868,10 +894,23 @@ export function EditorView({ ops, registry, id }) {
868
894
  if (current)
869
895
  syncTexts(selectedId, selectedItem, { ...current.fields, [key]: value });
870
896
  }
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) => {
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) => {
875
914
  const first = allowedSections(policyOf(zone), registry.sections)[0];
876
915
  if (first)
877
916
  doInsert(zone, 0, first);
@@ -879,6 +918,19 @@ export function EditorView({ ops, registry, id }) {
879
918
  }
880
919
  /** The selected node's ref field (if any) resolves to its target
881
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
+ }
882
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. */
883
935
  function globalPath(ns, key) {
884
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, 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
+ }
@@ -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
  }
@@ -11,13 +11,16 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
11
11
  // document's element nodes. Collapse is component state only: a card
12
12
  // starts collapsed (2026-09-09) unless it holds nothing yet — a freshly
13
13
  // inserted element opens, so its fields can be filled straight away.
14
+ // Drift (spec 2026-09-27 editor safety §5): an unregistered element's card
15
+ // takes the broken stripe and lists its stored fields read-only; a
16
+ // registered one the field's toolset leaves out says so in its head.
14
17
  import { useState } from "react";
15
18
  import { NodeViewWrapper } from "@tiptap/react";
16
19
  import { isHidden } from "../../conditions.js";
17
- import { IconButton, SectionLabel } from "../ui/primitives.js";
20
+ import { IconButton, SectionLabel, cx } from "../ui/primitives.js";
18
21
  import { FieldWidget } from "./FieldWidget.js";
19
22
  import { useElements } from "./elements-context.js";
20
- import { cardClass, useRichtextForm } from "./richtext-form-context.js";
23
+ import { cardClass, elementCardState, useRichtextForm } from "./richtext-form-context.js";
21
24
  import { elementIndexAt } from "./richtext-insert.js";
22
25
  /** No field holds a value yet — the state right after an insert. */
23
26
  function isBlank(fields) {
@@ -31,6 +34,9 @@ export function RichtextElementCard({ node, editor, getPos, updateAttributes, de
31
34
  const fields = (node.attrs.fields && typeof node.attrs.fields === "object" ? node.attrs.fields : {});
32
35
  const settings = (node.attrs.settings && typeof node.attrs.settings === "object" ? node.attrs.settings : {});
33
36
  const [open, setOpen] = useState(() => isBlank(fields));
37
+ // As drift decides it: an element whose contexts dropped "richtext" is outside every toolset.
38
+ const allowed = form.elements?.filter((n) => elements.find((e) => e.name === n)?.contexts.includes("richtext"));
39
+ const state = elementCardState(name, Boolean(element), allowed);
34
40
  const pos = getPos();
35
41
  const index = typeof pos === "number" ? elementIndexAt(editor.state.doc, pos) : 0;
36
42
  const base = `${form.path}.elements.${index}`;
@@ -39,5 +45,5 @@ export function RichtextElementCard({ node, editor, getPos, updateAttributes, de
39
45
  const scope = { fields, settings };
40
46
  const visibleFields = Object.entries(element?.fields ?? {}).filter(([, d]) => !isHidden(d, { self: fields, ...scope }));
41
47
  const visibleSettings = Object.entries(element?.settings ?? {}).filter(([, d]) => !isHidden(d, { self: settings, ...scope }));
42
- return (_jsxs(NodeViewWrapper, { className: cardClass(selected, form.canvas), "data-theme": form.canvas ? "light" : undefined, children: [_jsxs("div", { className: "sm-rtcard__head", "data-drag-handle": true, onClick: select, children: [_jsx("span", { className: "sm-rtcard__icon", "aria-hidden": "true", children: element?.icon ?? "▤" }), _jsx("span", { className: "sm-rtcard__title", children: element?.title ?? `Unknown element "${name}"` }), _jsx(IconButton, { label: open ? "Collapse" : "Expand", onClick: (e) => { e.stopPropagation(); setOpen((o) => !o); }, children: open ? "▾" : "▸" }), _jsx(IconButton, { label: "Delete element", onClick: (e) => { e.stopPropagation(); deleteNode(); }, children: "\u00D7" })] }), open && element && (_jsxs("div", { className: "sm-rtcard__body", contentEditable: false, children: [visibleFields.map(([key, d]) => (_jsx(FieldWidget, { fieldKey: key, descriptor: d, value: fields[key], errors: form.errors, path: `${base}.${key}`, scope: scope, onChange: (v) => updateAttributes({ fields: { ...fields, [key]: v } }) }, key))), visibleSettings.length > 0 && _jsx(SectionLabel, { caps: true, children: "Settings" }), visibleSettings.map(([key, d]) => (_jsx(FieldWidget, { fieldKey: key, descriptor: d, value: settings[key], errors: form.errors, path: `${base}.settings.${key}`, scope: scope, onChange: (v) => updateAttributes({ settings: { ...settings, [key]: v } }) }, `setting-${key}`)))] }))] }));
48
+ return (_jsxs(NodeViewWrapper, { className: cx(cardClass(selected, form.canvas), state.unknown && "sm-rtcard--broken"), "data-theme": form.canvas ? "light" : undefined, children: [_jsxs("div", { className: "sm-rtcard__head", "data-drag-handle": true, onClick: select, children: [_jsx("span", { className: "sm-rtcard__icon", "aria-hidden": "true", children: element?.icon ?? "▤" }), _jsx("span", { className: "sm-rtcard__title", children: element?.title ?? `Unknown element "${name}"` }), state.outside && _jsx("span", { className: "sm-hint", children: "Not in this field's toolset" }), _jsx(IconButton, { label: open ? "Collapse" : "Expand", onClick: (e) => { e.stopPropagation(); setOpen((o) => !o); }, children: open ? "▾" : "▸" }), _jsx(IconButton, { label: "Delete element", onClick: (e) => { e.stopPropagation(); deleteNode(); }, children: "\u00D7" })] }), open && !element && (_jsx("div", { className: "sm-rtcard__body", contentEditable: false, children: Object.entries(fields).map(([key, value]) => (_jsxs("div", { className: "sm-orphans__row", children: [_jsx("code", { children: key }), _jsx("span", { className: "sm-orphans__value", children: typeof value === "string" ? value : JSON.stringify(value) })] }, key))) })), open && element && (_jsxs("div", { className: "sm-rtcard__body", contentEditable: false, children: [visibleFields.map(([key, d]) => (_jsx(FieldWidget, { fieldKey: key, descriptor: d, value: fields[key], errors: form.errors, path: `${base}.${key}`, scope: scope, onChange: (v) => updateAttributes({ fields: { ...fields, [key]: v } }) }, key))), visibleSettings.length > 0 && _jsx(SectionLabel, { caps: true, children: "Settings" }), visibleSettings.map(([key, d]) => (_jsx(FieldWidget, { fieldKey: key, descriptor: d, value: settings[key], errors: form.errors, path: `${base}.settings.${key}`, scope: scope, onChange: (v) => updateAttributes({ settings: { ...settings, [key]: v } }) }, `setting-${key}`)))] }))] }));
43
49
  }
@@ -8,6 +8,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
8
8
  // flag, so its own cards stay admin-styled.
9
9
  import { useMemo } from "react";
10
10
  import { EditorContent } from "@tiptap/react";
11
+ import { allowedOf } from "../../toolset.js";
11
12
  import { RichtextToolbar } from "./RichtextToolbar.js";
12
13
  import { RichtextInsertMenu } from "./RichtextInsertMenu.js";
13
14
  import { RichtextLinkRow, RichtextMenu } from "./RichtextPopovers.js";
@@ -17,7 +18,8 @@ import { useRichtextEditor } from "./useRichtextEditor.js";
17
18
  export function RichtextWidget({ value, toolset, onChange, errors, path = "" }) {
18
19
  const rt = useRichtextEditor({ value, toolset, onChange, contentClass: "sm-richtext__content" });
19
20
  const parent = useRichtextForm();
20
- const formInfo = useMemo(() => ({ errors, path, canvas: parent.canvas }), [errors, path, parent.canvas]);
21
+ const allowed = useMemo(() => allowedOf(toolset).elements, [toolset]);
22
+ const formInfo = useMemo(() => ({ errors, path, canvas: parent.canvas, elements: allowed }), [errors, path, parent.canvas, allowed]);
21
23
  return (_jsx(RichtextFormProvider, { value: formInfo, children: _jsxs("div", { className: "sm-richtext", children: [_jsxs("div", { className: "sm-richtext__head", children: [_jsx(RichtextToolbar, { allowed: rt.allowed, active: rt.active, canInsert: rt.canInsert, onCommand: rt.run }), rt.menu.open && _jsx(RichtextMenu, { menu: rt.menu })] }), rt.link.open && _jsx(RichtextLinkRow, { link: rt.link }), _jsxs("div", { className: "sm-richtext__body", children: [_jsx(EditorContent, { editor: rt.editor }), rt.slash && (_jsx("div", { className: "sm-richtext__slash", style: { top: rt.slash.state.rect ? rt.slash.state.rect.bottom - (editorTop(rt.editor) ?? 0) + 4 : 0 }, children: _jsx(RichtextInsertMenu, { items: rt.slash.state.items, active: rt.slash.active, onPick: (item) => rt.slash?.state.pick(item) }) }))] }), rt.picker.open && _jsx(MediaPicker, { kinds: ["image"], onPick: rt.picker.pick, onClose: rt.picker.close })] }) }));
22
24
  }
23
25
  /** The editor's top in viewport coordinates, to place the slash menu relative to the body wrapper. */
@@ -27,7 +27,7 @@ export function TagsField({ value, onChange, max, suggestions, label, initialAdd
27
27
  timer.current = setTimeout(() => setFlash(null), 600);
28
28
  }
29
29
  };
30
- return (_jsxs("div", { className: "sm-tags", children: [tags.map((t, i) => (_jsxs("span", { className: cx("sm-tag", flash === t && "sm-tag--flash"), children: [t, _jsx("button", { type: "button", className: "sm-tag__x", "aria-label": `Remove ${t}`, onClick: () => onChange(tags.filter((_, j) => j !== i)), children: "\u00D7" })] }, t))), adding && !atMax && (_jsx(Combobox, { className: "sm-combo--tag", label: `Add to ${label}`, placeholder: "Add a tag", autoFocus: true, keepOpen: true, initialOpen: true, options: (suggestions ?? []).filter((s) => !tags.some((t) => sameTag(t, s))).map((s) => ({ value: s, label: s })), onPick: commit, onSubmit: commit, createRow: suggestions !== undefined, onQueryChange: (next) => { if (!next.includes(","))
30
+ return (_jsxs("div", { className: "sm-tags", children: [tags.map((t, i) => (_jsxs("span", { className: cx("sm-tag", flash === t && "sm-tag--flash"), children: [t, _jsx("button", { type: "button", className: "sm-tag__x", "aria-label": `Remove ${t}`, onClick: () => onChange(tags.filter((_, j) => j !== i)), children: "\u00D7" })] }, t))), adding && !atMax && (_jsx(Combobox, { className: "sm-combo--tag", listClassName: "sm-combo__list--tag", label: `Add to ${label}`, placeholder: "Add a tag", autoFocus: true, keepOpen: true, initialOpen: true, options: (suggestions ?? []).filter((s) => !tags.some((t) => sameTag(t, s))).map((s) => ({ value: s, label: s })), onPick: commit, onSubmit: commit, createRow: suggestions !== undefined, onQueryChange: (next) => { if (!next.includes(","))
31
31
  return next; commit(next); return ""; }, onInputKey: (e, query) => {
32
32
  if (e.key !== "Backspace" || query !== "" || tags.length === 0)
33
33
  return false;