smoodly 0.0.28 → 0.0.30

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (98) hide show
  1. package/README.md +123 -17
  2. package/dist/admin/client-ops.js +1 -0
  3. package/dist/admin/drift-copy.d.ts +19 -0
  4. package/dist/admin/drift-copy.js +89 -0
  5. package/dist/admin/drift.d.ts +76 -0
  6. package/dist/admin/drift.js +264 -0
  7. package/dist/admin/editor/CanvasWarning.d.ts +5 -0
  8. package/dist/admin/editor/CanvasWarning.js +10 -0
  9. package/dist/admin/editor/EditorView.js +113 -26
  10. package/dist/admin/editor/InlineRichtext.js +3 -1
  11. package/dist/admin/editor/Outline.d.ts +7 -0
  12. package/dist/admin/editor/Outline.js +8 -5
  13. package/dist/admin/editor/PageInspector.d.ts +6 -1
  14. package/dist/admin/editor/PageInspector.js +4 -2
  15. package/dist/admin/editor/PageSettings.js +26 -16
  16. package/dist/admin/editor/UnknownNode.d.ts +7 -0
  17. package/dist/admin/editor/UnknownNode.js +8 -0
  18. package/dist/admin/editor/canvas-diagnosis.d.ts +49 -0
  19. package/dist/admin/editor/canvas-diagnosis.js +106 -0
  20. package/dist/admin/editor/crash-copy.d.ts +7 -0
  21. package/dist/admin/editor/crash-copy.js +13 -0
  22. package/dist/admin/editor/protocol.d.ts +2 -0
  23. package/dist/admin/editor/useCanvasBridge.d.ts +3 -0
  24. package/dist/admin/editor/useCanvasBridge.js +14 -7
  25. package/dist/admin/editor/useCanvasDiagnosis.d.ts +7 -0
  26. package/dist/admin/editor/useCanvasDiagnosis.js +24 -0
  27. package/dist/admin/field-paths.d.ts +3 -0
  28. package/dist/admin/field-paths.js +26 -0
  29. package/dist/admin/forms/OrphanFields.d.ts +8 -0
  30. package/dist/admin/forms/OrphanFields.js +11 -0
  31. package/dist/admin/forms/RefPicker.js +6 -2
  32. package/dist/admin/forms/RichtextElementCard.js +9 -3
  33. package/dist/admin/forms/RichtextWidget.js +3 -1
  34. package/dist/admin/forms/TagsField.js +1 -1
  35. package/dist/admin/forms/richtext-form-context.d.ts +8 -0
  36. package/dist/admin/forms/richtext-form-context.js +5 -0
  37. package/dist/admin/health.d.ts +23 -0
  38. package/dist/admin/health.js +42 -0
  39. package/dist/admin/index.d.ts +1 -0
  40. package/dist/admin/next/bridge.js +4 -2
  41. package/dist/admin/next/index.d.ts +0 -1
  42. package/dist/admin/next/index.js +6 -1
  43. package/dist/admin/ops-impl.js +40 -9
  44. package/dist/admin/ops.d.ts +13 -0
  45. package/dist/admin/shell/AddRedirectModal.js +1 -1
  46. package/dist/admin/shell/ContentHealthPage.d.ts +10 -0
  47. package/dist/admin/shell/ContentHealthPage.js +37 -0
  48. package/dist/admin/shell/EntriesList.js +4 -4
  49. package/dist/admin/shell/EntryForm.js +20 -3
  50. package/dist/admin/shell/ErrorPane.d.ts +4 -2
  51. package/dist/admin/shell/ErrorPane.js +5 -4
  52. package/dist/admin/shell/GlobalForm.js +22 -2
  53. package/dist/admin/shell/PagesList.js +3 -3
  54. package/dist/admin/shell/SettingsView.js +6 -2
  55. package/dist/admin/shell/health-list.d.ts +27 -0
  56. package/dist/admin/shell/health-list.js +47 -0
  57. package/dist/admin/shell/settings.d.ts +4 -0
  58. package/dist/admin/shell/settings.js +5 -2
  59. package/dist/admin/tree-ops.d.ts +4 -0
  60. package/dist/admin/tree-ops.js +15 -1
  61. package/dist/admin/ui/ActionMenu.d.ts +20 -0
  62. package/dist/admin/ui/ActionMenu.js +33 -7
  63. package/dist/admin/ui/Combobox.d.ts +8 -2
  64. package/dist/admin/ui/Combobox.js +48 -16
  65. package/dist/admin/ui/ConfirmDialog.js +3 -3
  66. package/dist/admin/ui/DriftNotice.d.ts +6 -0
  67. package/dist/admin/ui/DriftNotice.js +10 -0
  68. package/dist/admin/ui/combo-box.d.ts +13 -0
  69. package/dist/admin/ui/combo-box.js +8 -0
  70. package/dist/admin/ui/confirm.d.ts +8 -0
  71. package/dist/admin/ui/confirm.js +17 -4
  72. package/dist/admin/ui/theme.js +29 -3
  73. package/dist/admin/validate.js +15 -47
  74. package/dist/custom/CustomSection.d.ts +1 -0
  75. package/dist/custom/CustomSection.js +4 -1
  76. package/dist/entry-store.d.ts +13 -0
  77. package/dist/entry-store.js +26 -3
  78. package/dist/index.d.ts +2 -2
  79. package/dist/marks.d.ts +12 -0
  80. package/dist/marks.js +14 -0
  81. package/dist/next/canvas-boundary.d.ts +16 -0
  82. package/dist/next/canvas-boundary.js +25 -0
  83. package/dist/next/page-renderer.d.ts +3 -1
  84. package/dist/next/page-renderer.js +33 -4
  85. package/dist/pairing.js +10 -3
  86. package/dist/render.d.ts +12 -1
  87. package/dist/render.js +15 -4
  88. package/dist/section-crash.d.ts +11 -0
  89. package/dist/section-crash.js +16 -0
  90. package/dist/section-guard.d.ts +27 -0
  91. package/dist/section-guard.js +41 -0
  92. package/dist/store.d.ts +9 -0
  93. package/dist/store.js +8 -0
  94. package/dist/supabase-entry-store.d.ts +2 -1
  95. package/dist/supabase-entry-store.js +27 -5
  96. package/dist/supabase-store.d.ts +2 -1
  97. package/dist/supabase-store.js +27 -0
  98. package/package.json +1 -1
@@ -7,6 +7,10 @@ import { affectedTargets, collectionTag, entryTag, pageTag, pagesTreeTag, global
7
7
  import { ensureFixedNodes, fixedNodeOf } from "./fixed-nodes.js";
8
8
  import { ensureGlobalItems } from "./global-items.js";
9
9
  import { validateCustomNode, validateFields, validateNode } from "./validate.js";
10
+ import { serializeAdminConfig } from "./serialize.js";
11
+ import { entryDrift, pageDrift } from "./drift.js";
12
+ import { publishErrors } from "./drift-copy.js";
13
+ import { buildHealthReport } from "./health.js";
10
14
  import { CUSTOM_NODE_TYPE, customSchema } from "../custom/index.js";
11
15
  import { SETTINGS_KEY, CSS_KEY } from "../global.js";
12
16
  import { withDefaults } from "../field-defaults.js";
@@ -112,12 +116,23 @@ export function createAdminOps(deps) {
112
116
  const by = deps.actor?.email ?? undefined;
113
117
  const collectionFields = (name) => config.registry.collections?.find((c) => c.name === name)?.fields ?? null;
114
118
  // Element nodes inside richtext validate against these (spec 2026-09-08 block nodes §2).
119
+ // The serialized registry drift walks (spec 2026-09-27 editor safety §2) — the admin client's shape.
120
+ let adminRegistryMemo = null;
121
+ const adminRegistry = () => (adminRegistryMemo ?? (adminRegistryMemo = serializeAdminConfig(config)));
115
122
  const elementDescs = elementSchemas((config.registry.elements ?? []).map((e) => ({ name: e.schema.name, fields: e.schema.fields ?? {}, settings: e.schema.settings })));
116
123
  // The custom section (spec 2026-09-14 §6) validates like a section, over its own schema.
117
124
  const sectionFields = (type) => (type === CUSTOM_NODE_TYPE ? customSchema(config.custom.toolset) : config.registry.sections?.find((s) => s.schema.name === type)?.schema);
118
125
  const globalItem = (name) => config.registry.globals?.find((s) => s.name === name) ?? null;
119
126
  /** One row per registration; null when materialization could not create it. */
120
127
  const globalRecord = async (name) => (await entries.list(name))[0] ?? null;
128
+ /** The published snapshot's drift for the editors' live-only line (spec 2026-09-27 editor safety §5). */
129
+ const liveEntryFindings = async (name, record, locale) => {
130
+ const row = record.locales[locale];
131
+ if (row?.status !== "published" || !row.publishedVersionId)
132
+ return [];
133
+ const snap = await entries.getVersion(name, row.publishedVersionId);
134
+ return snap ? entryDrift(adminRegistry(), name, { fields: snap.fields }) : [];
135
+ };
121
136
  const fanOut = async (entryId) => {
122
137
  if (!effects?.revalidate)
123
138
  return;
@@ -317,7 +332,9 @@ export function createAdminOps(deps) {
317
332
  const parentLocales = record.parentId === null
318
333
  ? config.locales.supported
319
334
  : Object.keys((await pages.getPage(record.parentId))?.locales ?? {});
320
- return ok({ record, locale, draft, paths, urls, redirects, parentLocales });
335
+ const liveTree = record.locales[locale]?.publishedVersionId ? await pages.getPublishedTree(id, locale) : null;
336
+ const liveFindings = liveTree ? pageDrift(adminRegistry(), record.template, liveTree) : [];
337
+ return ok({ record, locale, draft, paths, urls, redirects, parentLocales, liveFindings });
321
338
  }),
322
339
  create: (input) => guard(async () => {
323
340
  const unsupported = supportedLocale(input.locale);
@@ -387,7 +404,10 @@ export function createAdminOps(deps) {
387
404
  : err("blocked", `smoodly: "${row.slug}" has no draft to publish.`);
388
405
  }
389
406
  const fieldErrors = [];
390
- for (const nodes of Object.values(draft.zones)) {
407
+ // Only the page type's zones (spec 2026-09-27 editor safety §1): a removed zone's nodes never block.
408
+ const shapeZones = Object.keys(openTemplate(record.template)?.schema?.zones ?? {});
409
+ for (const zone of shapeZones) {
410
+ const nodes = Array.isArray(draft.zones[zone]) ? draft.zones[zone] : [];
391
411
  for (const node of nodes) {
392
412
  const schema = sectionFields(node.type);
393
413
  if (!schema?.fields)
@@ -406,8 +426,9 @@ export function createAdminOps(deps) {
406
426
  for (const fe of validateFields(draft.fields ?? {}, pageDescs, elementDescs)) {
407
427
  fieldErrors.push({ field: `page.fields.${fe.field}`, message: fe.message });
408
428
  }
409
- if (fieldErrors.length > 0) {
410
- return err("validation", `smoodly: cannot publish "${row.slug}" — fix the listed fields first.`, fieldErrors);
429
+ const refused = publishErrors(fieldErrors, pageDrift(adminRegistry(), record.template, draft));
430
+ if (refused.length > 0) {
431
+ return err("validation", `smoodly: cannot publish "${row.slug}" — fix the listed problems first.`, refused);
411
432
  }
412
433
  const published = await pages.publish(id, locale, by, token);
413
434
  // The parent's cached unit lists its published children.
@@ -630,7 +651,8 @@ export function createAdminOps(deps) {
630
651
  const paths = await entries.pathsOf(collection, id);
631
652
  const urls = Object.fromEntries(Object.entries(paths).map(([l, p]) => [l, href(p, l)]));
632
653
  const redirects = await entries.redirectsOf(collection, id);
633
- return ok({ record, locale, fields, parentLocales, urls, redirects });
654
+ const liveFindings = await liveEntryFindings(collection, record, locale);
655
+ return ok({ record, locale, fields, parentLocales, urls, redirects, liveFindings });
634
656
  }),
635
657
  create: (collection, input) => guard(async () => {
636
658
  const unsupported = supportedLocale(input.locale);
@@ -675,7 +697,8 @@ export function createAdminOps(deps) {
675
697
  return err("not_found", `smoodly: no entry "${id}" in "${collection}".`);
676
698
  if (!record.locales[locale])
677
699
  return err("not_found", `smoodly: entry "${id}" does not exist in locale "${locale}".`);
678
- const fieldErrors = validateFields(entryFieldsIn(record, locale), descriptors, elementDescs);
700
+ const merged = entryFieldsIn(record, locale);
701
+ const fieldErrors = publishErrors(validateFields(merged, descriptors, elementDescs), entryDrift(adminRegistry(), collection, { fields: merged }));
679
702
  if (fieldErrors.length > 0)
680
703
  return err("validation", "Some fields need attention.", fieldErrors);
681
704
  }
@@ -776,7 +799,8 @@ export function createAdminOps(deps) {
776
799
  if (!record?.locales[locale])
777
800
  return err("not_found", `smoodly: global item "${name}" does not exist in locale "${locale}".`);
778
801
  const usage = await entries.referencesTo(name, record.id);
779
- return ok({ name, record, locale, fields: entryFieldsIn(record, locale), usage });
802
+ const liveFindings = await liveEntryFindings(name, record, locale);
803
+ return ok({ name, record, locale, fields: entryFieldsIn(record, locale), usage, liveFindings });
780
804
  }),
781
805
  save: (name, locale, fields, expected) => guard(async () => {
782
806
  const unsupported = supportedLocale(locale);
@@ -818,8 +842,9 @@ export function createAdminOps(deps) {
818
842
  const { [SETTINGS_KEY]: settings, [CSS_KEY]: css, ...fields } = entryFieldsIn(record, locale);
819
843
  const settingsDescs = item.section !== undefined ? sectionFields(item.section)?.settings : undefined;
820
844
  const fieldErrors = validateNode({ fields, settings: settings !== null && typeof settings === "object" ? settings : {}, css }, { fields: item.fields, settings: settingsDescs }, elementDescs);
821
- if (fieldErrors.length > 0)
822
- return err("validation", "Some fields need attention.", fieldErrors);
845
+ const refused = publishErrors(fieldErrors, entryDrift(adminRegistry(), name, { fields: entryFieldsIn(record, locale) }));
846
+ if (refused.length > 0)
847
+ return err("validation", "Some fields need attention.", refused);
823
848
  }
824
849
  const changed = await entries.setStatus(name, record.id, locale, status, by, token);
825
850
  // see save: the fan-out carries the item's own tag.
@@ -1052,6 +1077,12 @@ export function createAdminOps(deps) {
1052
1077
  return ok(null);
1053
1078
  }),
1054
1079
  },
1080
+ health: {
1081
+ scan: () => guard(async () => {
1082
+ const [trees, rows] = await Promise.all([pages.scanTrees(), entries.scanEntries()]);
1083
+ return ok(buildHealthReport(adminRegistry(), trees, rows));
1084
+ }),
1085
+ },
1055
1086
  schema: {
1056
1087
  status: () => guard(async () => ok({ database: await pages.schemaVersion(), required: SCHEMA_VERSION })),
1057
1088
  },
@@ -8,6 +8,8 @@ import type { MemberRole } from "../permissions.ts";
8
8
  import type { FieldError } from "./validate.ts";
9
9
  import type { DraftToken } from "../draft-token.ts";
10
10
  import type { PathTarget } from "../paths.ts";
11
+ import type { Finding } from "./drift.ts";
12
+ import type { HealthReport } from "./health.ts";
11
13
  export type OpError = {
12
14
  ok: false;
13
15
  code: "not_found" | "conflict" | "blocked" | "validation" | "internal" | "unauthorized";
@@ -52,6 +54,8 @@ export type PageGetResult = {
52
54
  * disables the rest with a reason. Every supported locale for a root
53
55
  * page: a root can be added to any locale. */
54
56
  parentLocales: string[];
57
+ /** The live version's drift (spec 2026-09-27 editor safety §5) — the editors' "live only" notice line. Empty when nothing is published. */
58
+ liveFindings: Finding[];
55
59
  };
56
60
  export type EntryCreateInput = {
57
61
  /** The one locale the entry is created in; others are added with addLocale. */
@@ -72,6 +76,8 @@ export type EntryGetResult = {
72
76
  redirects: Record<string, string[]>;
73
77
  /** The locales the PARENT exists in (every supported locale for a root) — the form disables the rest with a reason. */
74
78
  parentLocales: string[];
79
+ /** The live version's drift (spec 2026-09-27 editor safety §5) — the editors' "live only" notice line. Empty when nothing is published. */
80
+ liveFindings: Finding[];
75
81
  };
76
82
  export type GlobalListItem = {
77
83
  name: string;
@@ -86,6 +92,8 @@ export type GlobalGetResult = {
86
92
  fields: Record<string, unknown>;
87
93
  /** Who links it: "used on N pages". */
88
94
  usage: EntryUsage[];
95
+ /** The live version's drift (spec 2026-09-27 editor safety §5) — the editors' "live only" notice line. Empty when nothing is published. */
96
+ liveFindings: Finding[];
89
97
  };
90
98
  /** A history row: the version id and when its content last changed (spec 2026-09-13 history §5). */
91
99
  export type VersionSummary = {
@@ -264,6 +272,11 @@ export type AdminOps = {
264
272
  /** Deletes a redirect row; a live row is never touched. */
265
273
  remove(locale: string, path: string): Promise<OpResult<null>>;
266
274
  };
275
+ /** Content health (spec 2026-09-27 editor safety §3): read-only, every member. */
276
+ health: {
277
+ /** Every page, entry and global item against the registry, draft and live; unregistered collections counted. */
278
+ scan(): Promise<OpResult<HealthReport>>;
279
+ };
267
280
  schema: {
268
281
  /** Every member. The shell calls it once on mount and blocks when behind. */
269
282
  status(): Promise<OpResult<SchemaStatus>>;
@@ -25,5 +25,5 @@ export function AddRedirectModal({ locale, registry, targets, onClose, onAdd })
25
25
  setError(await onAdd(path, target));
26
26
  setBusy(false);
27
27
  };
28
- return (_jsx(Modal, { title: "Add redirect", width: 480, onClose: onClose, children: _jsxs("form", { onSubmit: submit, style: { display: "flex", flexDirection: "column", gap: 14 }, children: [_jsx(Field, { label: "Old address", error: path !== "" && problem ? REDIRECT_PATH_MESSAGE[problem] : undefined, children: _jsx("input", { className: "sm-input sm-input--mono", autoFocus: true, placeholder: "/old-address", value: path, onChange: (e) => setPath(e.target.value) }) }), _jsx(Field, { label: "Redirects to", asDiv: true, hint: picked ? `${picked.title} · ${picked.path}` : `A page or entry that is live in ${locale}.`, children: _jsx(Combobox, { label: "Redirects to", options: targetOptions(targets), placeholder: "Type a title or an address", emptyText: `Nothing is live in ${locale} yet.`, onPick: (value) => setTarget(parseTargetValue(value)) }) }), error && _jsx(Banner, { tone: "error", children: error }), _jsxs("div", { style: { display: "flex", justifyContent: "flex-end", gap: 8 }, children: [_jsx(Button, { variant: "secondary", onClick: onClose, children: "Cancel" }), _jsx("button", { type: "submit", className: "sm-btn sm-btn--primary", disabled: busy || !target || path === "" || problem !== null, children: busy ? "Saving…" : "Save redirect" })] })] }) }));
28
+ return (_jsx(Modal, { title: "Add redirect", width: 480, onClose: onClose, children: _jsxs("form", { onSubmit: submit, style: { display: "flex", flexDirection: "column", gap: 14 }, children: [_jsx(Field, { label: "Old address", error: path !== "" && problem ? REDIRECT_PATH_MESSAGE[problem] : undefined, children: _jsx("input", { className: "sm-input sm-input--mono", autoFocus: true, placeholder: "/old-address", value: path, onChange: (e) => setPath(e.target.value) }) }), _jsx(Field, { label: "Redirects to", asDiv: true, hint: `A page or entry that is live in ${locale}.`, children: _jsx(Combobox, { label: "Redirects to", options: targetOptions(targets), placeholder: "Type a title or an address", emptyText: `Nothing is live in ${locale} yet.`, selected: picked ? `${picked.title} · ${picked.path}` : undefined, onPick: (value) => setTarget(parseTargetValue(value)) }) }), error && _jsx(Banner, { tone: "error", children: error }), _jsxs("div", { style: { display: "flex", justifyContent: "flex-end", gap: 8 }, children: [_jsx(Button, { variant: "secondary", onClick: onClose, children: "Cancel" }), _jsx("button", { type: "submit", className: "sm-btn sm-btn--primary", disabled: busy || !target || path === "" || problem !== null, children: busy ? "Saving…" : "Save redirect" })] })] }) }));
29
29
  }
@@ -0,0 +1,10 @@
1
+ import { type ReactElement } from "react";
2
+ import type { AdminOps } from "../ops.ts";
3
+ import type { AdminRegistry } from "../serialize.ts";
4
+ import type { HealthReport } from "../health.ts";
5
+ export declare function ContentHealthPage({ ops, registry, initial }: {
6
+ ops: AdminOps;
7
+ registry: AdminRegistry;
8
+ /** Static-markup tests: a report without a scan. */
9
+ initial?: HealthReport | null;
10
+ }): ReactElement;
@@ -0,0 +1,37 @@
1
+ "use client";
2
+ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
3
+ // Settings › Content health (spec 2026-09-27 editor safety §4): the scan on
4
+ // open and on Rescan; "Blocks publishing" then "Left over", each group a
5
+ // headline over its documents with their locales, Draft/Live tags and an
6
+ // Open link. It only points — the editors fix. Every member.
7
+ import { useEffect, useState } from "react";
8
+ import { Button, TopBar } from "../ui/primitives.js";
9
+ import { useAdminHref } from "./base.js";
10
+ import { ErrorPane } from "./ErrorPane.js";
11
+ import { RouteLink } from "./RouteLink.js";
12
+ import { GROUP_PREVIEW, healthSections } from "./health-list.js";
13
+ export function ContentHealthPage({ ops, registry, initial = null }) {
14
+ const [report, setReport] = useState(initial);
15
+ const [error, setError] = useState(null);
16
+ const scan = () => {
17
+ setError(null);
18
+ setReport(null);
19
+ ops.health.scan()
20
+ .then((r) => (r.ok ? setReport(r.data) : setError(r.message)))
21
+ .catch(() => setError("Couldn't reach the server."));
22
+ };
23
+ useEffect(() => { if (!initial)
24
+ scan(); }, [ops, initial]); // eslint-disable-line react-hooks/exhaustive-deps
25
+ if (error)
26
+ return _jsx(ErrorPane, { message: error, back: false, onRetry: scan });
27
+ const sections = report ? healthSections(report, registry) : null;
28
+ const brokenCount = sections ? sections.broken.length + sections.collections.length : 0;
29
+ return (_jsxs(_Fragment, { children: [_jsx(TopBar, { left: _jsx("span", { className: "sm-title", children: "Content health" }), right: _jsxs(_Fragment, { children: [report && _jsx("span", { className: "sm-hint", children: "Checked just now" }), _jsx(Button, { variant: "secondary", onClick: scan, disabled: !report, children: "Rescan" })] }) }), _jsx("div", { className: "sm-listwrap", children: _jsxs("div", { className: "sm-table sm-health", children: [!sections && _jsx("div", { className: "sm-empty", children: "Checking content\u2026" }), sections && brokenCount === 0 && sections.orphan.length === 0 && _jsx("div", { className: "sm-empty", children: "Everything matches the code." }), sections && brokenCount > 0 && (_jsxs("section", { children: [_jsxs("h2", { className: "sm-health__section", children: ["Blocks publishing \u00B7 ", brokenCount] }), sections.collections.map((c) => _jsx("p", { className: "sm-health__headline", children: c.headline }, c.name)), sections.broken.map((g) => _jsx(Group, { group: g }, g.key))] })), sections && sections.orphan.length > 0 && (_jsxs("section", { children: [_jsxs("h2", { className: "sm-health__section", children: ["Left over \u00B7 ", sections.orphan.length] }), sections.orphan.map((g) => _jsx(Group, { group: g }, g.key))] }))] }) })] }));
30
+ }
31
+ function Group({ group }) {
32
+ const adminHref = useAdminHref();
33
+ const [all, setAll] = useState(false);
34
+ const rows = all ? group.rows : group.rows.slice(0, GROUP_PREVIEW);
35
+ const n = group.rows.length;
36
+ return (_jsxs("div", { className: "sm-health__group", children: [_jsxs("div", { className: "sm-health__headline", children: [_jsx("span", { children: group.headline }), _jsxs("span", { className: "sm-hint", children: [n, " ", n === 1 ? "document" : "documents"] })] }), rows.map((r) => (_jsxs("div", { className: "sm-row sm-health__row", children: [_jsx("span", { className: "sm-cell", children: r.title }), _jsx("span", { className: "sm-cell sm-hint", children: r.kind }), _jsx("span", { className: "sm-cell", children: r.locales.join(" ") }), _jsxs("span", { className: "sm-cell", children: [r.draft && _jsx("span", { className: "sm-badge", style: { background: "var(--status-draft-bg)", color: "var(--status-draft-fg)" }, children: "Draft" }), r.live && _jsx("span", { className: "sm-badge", style: { background: "var(--status-published-bg)", color: "var(--status-published-fg)" }, children: "Live" })] }), _jsx("span", { className: "sm-cell", children: _jsx(RouteLink, { href: adminHref(r.href), children: "Open \u2192" }) })] }, r.key))), !all && n > GROUP_PREVIEW && _jsxs(Button, { variant: "secondary", onClick: () => setAll(true), children: ["Show all ", n] })] }));
37
+ }
@@ -14,7 +14,7 @@ import { relativeTime } from "../ui/format.js";
14
14
  import { useSortable } from "../ui/useSortable.js";
15
15
  import { useAdminHref } from "./base.js";
16
16
  import { navigate } from "./router.js";
17
- import { confirm } from "../ui/confirm.js";
17
+ import { confirm, notify } from "../ui/confirm.js";
18
18
  import { ErrorPane } from "./ErrorPane.js";
19
19
  import { rememberLocale, rememberedLocale } from "./locale-memory.js";
20
20
  import { usePresenceState } from "./presence-context.js";
@@ -53,7 +53,7 @@ export function EntriesList({ ops, registry, collection }) {
53
53
  result = { ok: false, code: "internal", message: "Couldn't reach the server." };
54
54
  }
55
55
  if (!result.ok) {
56
- window.alert(result.message);
56
+ void notify({ title: "Couldn't move the entry", message: result.message });
57
57
  throw new Error(result.message);
58
58
  }
59
59
  await load();
@@ -76,7 +76,7 @@ export function EntriesList({ ops, registry, collection }) {
76
76
  result = { ok: false, code: "internal", message: "Couldn't reach the server." };
77
77
  }
78
78
  if (!result.ok) {
79
- window.alert(result.message);
79
+ void notify({ title: "Couldn't create the entry", message: result.message });
80
80
  return;
81
81
  }
82
82
  open(result.data.id);
@@ -102,7 +102,7 @@ export function EntriesList({ ops, registry, collection }) {
102
102
  result = { ok: false, code: "internal", message: "Couldn't reach the server." };
103
103
  }
104
104
  if (!result.ok) {
105
- window.alert(result.message);
105
+ void notify({ title: `Couldn't add the entry to ${locale}`, message: result.message });
106
106
  return;
107
107
  }
108
108
  open(row.id);
@@ -40,6 +40,11 @@ import { confirm, confirmWithOption, DISCARD_CONFIRM } from "../ui/confirm.js";
40
40
  import { draftToken } from "../../draft-token.js";
41
41
  import { isStale, staleInfoOf } from "./stale.js";
42
42
  import { StaleBanner } from "../ui/StaleBanner.js";
43
+ import { DriftNotice } from "../ui/DriftNotice.js";
44
+ import { OrphanFields } from "../forms/OrphanFields.js";
45
+ import { entryDrift } from "../drift.js";
46
+ import { misfitErrors } from "../drift-copy.js";
47
+ import { unsetAtPath } from "../field-paths.js";
43
48
  import { usePresence, usePresenceState, useSavedElsewhere, useTrackPresence } from "./presence-context.js";
44
49
  import { editorsOf } from "./presence.js";
45
50
  import { PresenceLine } from "../ui/PresenceLine.js";
@@ -69,6 +74,7 @@ export function EntryForm({ ops, registry, collection, id }) {
69
74
  const [history, setHistory] = useState(null);
70
75
  const [snapshot, setSnapshot] = useState(null);
71
76
  const [stale, setStale] = useState(null);
77
+ const [liveFindings, setLiveFindings] = useState([]);
72
78
  const formRef = useRef(null);
73
79
  const fieldsRef = useRef(fields);
74
80
  fieldsRef.current = fields;
@@ -149,6 +155,7 @@ export function EntryForm({ ops, registry, collection, id }) {
149
155
  setUrls(r.data.urls);
150
156
  setRedirects(r.data.redirects);
151
157
  setParentLocales(r.data.parentLocales);
158
+ setLiveFindings(r.data.liveFindings);
152
159
  }
153
160
  else
154
161
  setNotice({ tone: "error", text: r.message });
@@ -156,6 +163,11 @@ export function EntryForm({ ops, registry, collection, id }) {
156
163
  setNotice({ tone: "error", text: "Couldn't reach the server." }); });
157
164
  return () => { cancelled = true; };
158
165
  }, [ops, collection, id, locale, reloadKey]);
166
+ // Drift (spec 2026-09-27 editor safety §5): the draft on screen against the registry.
167
+ const findings = useMemo(() => (fields && record?.locales[locale]
168
+ ? entryDrift(registry, collection, { fields, shared: record.fields, own: record.locales[locale].fields })
169
+ : []), [fields, record, locale, registry, collection]);
170
+ const markerErrors = useMemo(() => ({ ...misfitErrors(findings), ...fieldErrors }), [findings, fieldErrors]);
159
171
  if (!meta)
160
172
  return _jsx("p", { style: { padding: 32 }, children: "No such collection." });
161
173
  const row = record?.locales[locale];
@@ -406,7 +418,7 @@ export function EntryForm({ ops, registry, collection, id }) {
406
418
  return record ? { name: heading, path: slugPrefix && slug ? `${slugPrefix}${slug}` : null } : undefined;
407
419
  };
408
420
  const present = record ? registry.locales.supported.filter((l) => record.locales[l]) : [locale];
409
- const widget = ([key, d]) => (_jsx(FieldWidget, { fieldKey: key, descriptor: d, value: fields?.[key], errors: fieldErrors, refOptions: refTargets[key] ? refOptions[refTargets[key]] : undefined, suggestions: tagSuggestions[key], prefix: d.type === "slug" ? slugPrefix : undefined, mark: multilingual && !localeKeys.includes(key) ? _jsx(UnlocalizedMark, {}) : undefined, onChange: (value) => {
421
+ const widget = ([key, d]) => (_jsx(FieldWidget, { fieldKey: key, descriptor: d, value: fields?.[key], errors: markerErrors, refOptions: refTargets[key] ? refOptions[refTargets[key]] : undefined, suggestions: tagSuggestions[key], prefix: d.type === "slug" ? slugPrefix : undefined, mark: multilingual && !localeKeys.includes(key) ? _jsx(UnlocalizedMark, {}) : undefined, onChange: (value) => {
410
422
  setFields((f) => ({ ...(f ?? {}), [key]: value }));
411
423
  setDirty(true);
412
424
  setTouched(true);
@@ -419,6 +431,11 @@ export function EntryForm({ ops, registry, collection, id }) {
419
431
  trailing: multilingual && (_jsx(LocaleSwitcher, { registry: registry, present: present, parentLocales: parentLocales, value: locale, adding: adding, onSwitch: (l) => { void switchLocale(l); }, onAdd: (l) => void doAddLocale(l) })),
420
432
  },
421
433
  { label: heading },
422
- ] }) }), right: _jsxs(_Fragment, { children: [(touched || saveState === "error") && _jsx(SaveIndicator, { state: saveState, pending: dirty }), urls[locale] && (_jsx(LinkButton, { icon: true, href: urls[locale], 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: status, pending: dirty || saveState !== "saved", busy: busy, flash: published, disabled: !row, onPublish: () => { void publish(); }, onUnpublish: () => { void unpublish(); }, onDiscard: () => { void discard(); }, onHistory: () => { void openHistory(); } })] }) }), history && row && (_jsx(HistoryModal, { rows: history, draftId: row.draftVersionId, onSelect: (v) => { void loadSnapshot(v); }, onClose: () => setHistory(null), onRestore: restore, render: (versionId) => snapshot?.id === versionId ? (_jsx("fieldset", { disabled: true, className: "sm-history__form", children: Object.entries(meta.fields).map(([key, d]) => (_jsx(FieldWidget, { fieldKey: key, descriptor: d, value: snapshot.fields[key], refOptions: refTargets[key] ? refOptions[refTargets[key]] : undefined, prefix: d.type === "slug" ? slugPrefix : undefined, onChange: () => { } }, key))) })) : _jsx("p", { className: "sm-hint", style: { padding: 16 }, children: "Loading\u2026" }) })), _jsx(PresenceLine, { names: editorsOf(others, target), noun: "entry" }), stale && _jsx(StaleBanner, { info: stale, onReload: loadTheirs }), _jsxs("div", { className: "sm-editor", inert: stale ? true : undefined, children: [_jsxs("div", { className: "sm-form", ref: formRef, children: [notice && (_jsxs(Banner, { tone: notice.tone, onDismiss: () => setNotice(null), children: [_jsx("p", { style: { margin: 0 }, children: notice.text }), _jsx(FieldErrorList, { fields: notice.fields ?? [], onReveal: (path) => { if (formRef.current)
423
- revealField(formRef.current, path); } })] })), absent ? (_jsxs("div", { style: { display: "flex", flexDirection: "column", gap: 12 }, children: [_jsxs("p", { children: ["This entry doesn't exist in ", locale, " yet. Adding it copies the ", entrySourceLocale(record, registry), " fields as the first ", locale, " draft."] }), _jsxs("div", { style: { display: "flex", gap: 8 }, children: [_jsxs(Button, { onClick: () => void doAddLocale(locale), disabled: adding || !parentLocales.includes(locale), title: parentLocales.includes(locale) ? undefined : `add ${locale} to the parent first`, children: ["Add ", locale] }), _jsxs(Button, { variant: "secondary", onClick: () => { void switchLocale(entrySourceLocale(record, registry)); }, children: ["Back to ", entrySourceLocale(record, registry)] })] })] })) : fields !== null ? (Object.entries(meta.fields).map(widget)) : !notice && _jsx("p", { style: { color: "var(--text-muted)" }, children: "Loading\u2026" })] }), _jsx(ResizeHandle, {}), _jsxs("aside", { className: "sm-inspector", children: [record && row && (_jsxs("div", { className: "sm-card", children: [_jsx(SectionLabel, { children: "Document info" }), _jsx(InfoRow, { label: "Created", value: relativeTime(record.createdAt) }), _jsx(InfoRow, { label: "Updated", value: updatedLine(row.updatedAt, row.updatedBy) }), (redirects[locale] ?? []).length > 0 && _jsx(InfoRow, { label: "Redirects from", value: redirects[locale].join(", "), mono: true }), multilingual && _jsx(InfoRow, { label: "Languages", value: Object.keys(record.locales).join(", ") }), _jsx(InfoRow, { label: "ID", value: id, mono: true })] })), record && (_jsxs("div", { className: "sm-card", children: [multilingual && row && Object.keys(record.locales).length > 1 && (_jsxs(Button, { variant: "secondary", onClick: () => void doRemoveLocale(), children: ["Remove from ", locale] })), _jsx(Button, { variant: "danger", onClick: destroy, children: "Delete" })] }))] })] })] }));
434
+ ] }) }), right: _jsxs(_Fragment, { children: [(touched || saveState === "error") && _jsx(SaveIndicator, { state: saveState, pending: dirty }), urls[locale] && (_jsx(LinkButton, { icon: true, href: urls[locale], 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: status, pending: dirty || saveState !== "saved", busy: busy, flash: published, disabled: !row, onPublish: () => { void publish(); }, onUnpublish: () => { void unpublish(); }, onDiscard: () => { void discard(); }, onHistory: () => { void openHistory(); } })] }) }), history && row && (_jsx(HistoryModal, { rows: history, draftId: row.draftVersionId, onSelect: (v) => { void loadSnapshot(v); }, onClose: () => setHistory(null), onRestore: restore, render: (versionId) => snapshot?.id === versionId ? (_jsx("fieldset", { disabled: true, className: "sm-history__form", children: Object.entries(meta.fields).map(([key, d]) => (_jsx(FieldWidget, { fieldKey: key, descriptor: d, value: snapshot.fields[key], refOptions: refTargets[key] ? refOptions[refTargets[key]] : undefined, prefix: d.type === "slug" ? slugPrefix : undefined, onChange: () => { } }, key))) })) : _jsx("p", { className: "sm-hint", style: { padding: 16 }, children: "Loading\u2026" }) })), _jsx(PresenceLine, { names: editorsOf(others, target), noun: "entry" }), stale && _jsx(StaleBanner, { info: stale, onReload: loadTheirs }), _jsx(DriftNotice, { draft: findings, live: liveFindings }), _jsxs("div", { className: "sm-editor", inert: stale ? true : undefined, children: [_jsxs("div", { className: "sm-form", ref: formRef, children: [notice && (_jsxs(Banner, { tone: notice.tone, onDismiss: () => setNotice(null), children: [_jsx("p", { style: { margin: 0 }, children: notice.text }), _jsx(FieldErrorList, { fields: notice.fields ?? [], onReveal: (path) => { if (formRef.current)
435
+ revealField(formRef.current, path); } })] })), absent ? (_jsxs("div", { style: { display: "flex", flexDirection: "column", gap: 12 }, children: [_jsxs("p", { children: ["This entry doesn't exist in ", locale, " yet. Adding it copies the ", entrySourceLocale(record, registry), " fields as the first ", locale, " draft."] }), _jsxs("div", { style: { display: "flex", gap: 8 }, children: [_jsxs(Button, { onClick: () => void doAddLocale(locale), disabled: adding || !parentLocales.includes(locale), title: parentLocales.includes(locale) ? undefined : `add ${locale} to the parent first`, children: ["Add ", locale] }), _jsxs(Button, { variant: "secondary", onClick: () => { void switchLocale(entrySourceLocale(record, registry)); }, children: ["Back to ", entrySourceLocale(record, registry)] })] })] })) : fields !== null ? (_jsxs(_Fragment, { children: [Object.entries(meta.fields).map(widget), _jsx(OrphanFields, { orphans: findings, onClear: (path) => {
436
+ setFields((f) => unsetAtPath(f ?? {}, path));
437
+ setDirty(true);
438
+ setTouched(true);
439
+ mark();
440
+ } })] })) : !notice && _jsx("p", { style: { color: "var(--text-muted)" }, children: "Loading\u2026" })] }), _jsx(ResizeHandle, {}), _jsxs("aside", { className: "sm-inspector", children: [record && row && (_jsxs("div", { className: "sm-card", children: [_jsx(SectionLabel, { children: "Document info" }), _jsx(InfoRow, { label: "Created", value: relativeTime(record.createdAt) }), _jsx(InfoRow, { label: "Updated", value: updatedLine(row.updatedAt, row.updatedBy) }), (redirects[locale] ?? []).length > 0 && _jsx(InfoRow, { label: "Redirects from", value: redirects[locale].join(", "), mono: true }), multilingual && _jsx(InfoRow, { label: "Languages", value: Object.keys(record.locales).join(", ") }), _jsx(InfoRow, { label: "ID", value: id, mono: true })] })), record && (_jsxs("div", { className: "sm-card", children: [multilingual && row && Object.keys(record.locales).length > 1 && (_jsxs(Button, { variant: "secondary", onClick: () => void doRemoveLocale(), children: ["Remove from ", locale] })), _jsx(Button, { variant: "danger", onClick: destroy, children: "Delete" })] }))] })] })] }));
424
441
  }
@@ -1,6 +1,8 @@
1
1
  /** A whole-column failure: nothing to show but the message and a way
2
- * back — none when the shell is mounted outside its own path. */
3
- export declare function ErrorPane({ message, back }: {
2
+ * back — none when the shell is mounted outside its own path — and a
3
+ * Retry when the caller can run the load again (Content health, 2026-09-27). */
4
+ export declare function ErrorPane({ message, back, onRetry }: {
4
5
  message: string;
5
6
  back?: boolean;
7
+ onRetry?: () => void;
6
8
  }): import("react").JSX.Element;
@@ -1,11 +1,12 @@
1
1
  // packages/smoodly/src/admin/shell/ErrorPane.tsx
2
2
  "use client";
3
3
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
4
- import { Banner } from "../ui/primitives.js";
4
+ import { Banner, Button } from "../ui/primitives.js";
5
5
  import { useAdminHref } from "./base.js";
6
6
  /** A whole-column failure: nothing to show but the message and a way
7
- * back — none when the shell is mounted outside its own path. */
8
- export function ErrorPane({ message, back = true }) {
7
+ * back — none when the shell is mounted outside its own path — and a
8
+ * Retry when the caller can run the load again (Content health, 2026-09-27). */
9
+ export function ErrorPane({ message, back = true, onRetry }) {
9
10
  const adminHref = useAdminHref();
10
- return (_jsx("div", { style: { display: "grid", placeItems: "center", flex: 1, padding: 40 }, children: _jsxs("div", { style: { display: "flex", flexDirection: "column", gap: 12, maxWidth: 420 }, children: [_jsx(Banner, { tone: "error", children: message }), back && _jsx("a", { href: adminHref("/pages"), style: { fontSize: "var(--text-md)" }, children: "\u2190 Back to pages" })] }) }));
11
+ return (_jsx("div", { style: { display: "grid", placeItems: "center", flex: 1, padding: 40 }, children: _jsxs("div", { style: { display: "flex", flexDirection: "column", gap: 12, maxWidth: 420 }, children: [_jsx(Banner, { tone: "error", children: message }), onRetry && _jsx(Button, { variant: "secondary", style: { alignSelf: "flex-start" }, onClick: onRetry, children: "Retry" }), back && _jsx("a", { href: adminHref("/pages"), style: { fontSize: "var(--text-md)" }, children: "\u2190 Back to pages" })] }) }));
11
12
  }
@@ -34,6 +34,11 @@ import { confirm, DISCARD_CONFIRM } from "../ui/confirm.js";
34
34
  import { draftToken } from "../../draft-token.js";
35
35
  import { isStale, staleInfoOf } from "./stale.js";
36
36
  import { StaleBanner } from "../ui/StaleBanner.js";
37
+ import { DriftNotice } from "../ui/DriftNotice.js";
38
+ import { OrphanFields } from "../forms/OrphanFields.js";
39
+ import { entryDrift } from "../drift.js";
40
+ import { misfitErrors } from "../drift-copy.js";
41
+ import { unsetAtPath } from "../field-paths.js";
37
42
  import { usePresence, usePresenceState, useSavedElsewhere, useTrackPresence } from "./presence-context.js";
38
43
  import { editorsOf } from "./presence.js";
39
44
  import { PresenceLine } from "../ui/PresenceLine.js";
@@ -55,6 +60,7 @@ export function GlobalForm({ ops, registry, name }) {
55
60
  const [history, setHistory] = useState(null);
56
61
  const [snapshot, setSnapshot] = useState(null);
57
62
  const [stale, setStale] = useState(null);
63
+ const [liveFindings, setLiveFindings] = useState([]);
58
64
  const formRef = useRef(null);
59
65
  const fieldsRef = useRef(fields);
60
66
  fieldsRef.current = fields;
@@ -106,6 +112,7 @@ export function GlobalForm({ ops, registry, name }) {
106
112
  setRecord(r.data.record);
107
113
  setFields(r.data.fields);
108
114
  setUsage(r.data.usage);
115
+ setLiveFindings(r.data.liveFindings);
109
116
  }
110
117
  else
111
118
  setNotice({ tone: "error", text: r.message });
@@ -113,6 +120,11 @@ export function GlobalForm({ ops, registry, name }) {
113
120
  setNotice({ tone: "error", text: "Couldn't reach the server." }); });
114
121
  return () => { cancelled = true; };
115
122
  }, [ops, name, locale, reloadKey]);
123
+ // Drift (spec 2026-09-27 editor safety §5): the draft on screen against the registry.
124
+ const findings = useMemo(() => (fields && record?.locales[locale]
125
+ ? entryDrift(registry, name, { fields, shared: record.fields, own: record.locales[locale].fields })
126
+ : []), [fields, record, locale, registry, name]);
127
+ const markerErrors = useMemo(() => ({ ...misfitErrors(findings), ...fieldErrors }), [findings, fieldErrors]);
116
128
  if (!item)
117
129
  return _jsx("p", { style: { padding: 32 }, children: "No such global item." });
118
130
  const row = record?.locales[locale];
@@ -292,12 +304,20 @@ export function GlobalForm({ ops, registry, name }) {
292
304
  setTouched(true);
293
305
  mark();
294
306
  };
307
+ // An orphan's path names a visual item's namespaces as drift does (`settings.x`); the stored keys are `$settings` / `$styles`.
308
+ const storedPath = (p) => p.startsWith("settings.") ? `${SETTINGS_KEY}.${p.slice(9)}` : p.startsWith("styles.") ? `${STYLES_KEY}.${p.slice(7)}` : p;
309
+ const clearOrphan = (path) => {
310
+ setFields((f) => unsetAtPath(f ?? {}, storedPath(path)));
311
+ setDirty(true);
312
+ setTouched(true);
313
+ mark();
314
+ };
295
315
  return (_jsxs(_Fragment, { children: [_jsx(TopBar, { left: _jsx(_Fragment, { children: _jsx(Breadcrumb, { backHref: adminHref("/globals"), items: [
296
316
  {
297
317
  label: "Site", href: adminHref("/globals"),
298
318
  trailing: multilingual && (_jsx(LocaleSwitcher, { registry: registry, present: registry.locales.supported, parentLocales: [], value: locale, adding: false, onSwitch: (l) => { void switchLocale(l); }, onAdd: () => undefined })),
299
319
  },
300
320
  { label: item.title },
301
- ] }) }), right: _jsxs(_Fragment, { children: [(touched || saveState === "error") && _jsx(SaveIndicator, { state: saveState, pending: dirty }), _jsx(PublishControl, { status: status, pending: dirty || saveState !== "saved", busy: busy, flash: published, disabled: !row, onPublish: () => { void publish(); }, onUnpublish: () => { void unpublish(); }, onDiscard: () => { void discard(); }, onHistory: () => { void openHistory(); } })] }) }), history && row && (_jsx(HistoryModal, { rows: history, draftId: row.draftVersionId, onSelect: (v) => { void loadSnapshot(v); }, onClose: () => setHistory(null), onRestore: restore, render: (versionId) => snapshot?.id === versionId ? (_jsxs("fieldset", { disabled: true, className: "sm-history__form", children: [_jsx(GlobalFields, { item: item, groups: contentGroups, fields: snapshot.fields, refOptions: refOptions, multilingual: multilingual, onChange: () => { } }), settingGroups.length > 0 && (_jsxs(_Fragment, { children: [_jsx(SectionLabel, { children: "Settings" }), _jsx(GlobalFields, { item: item, groups: settingGroups, fields: snapshot.fields, refOptions: refOptions, multilingual: multilingual, onChange: () => { } })] }))] })) : _jsx("p", { className: "sm-hint", style: { padding: 16 }, children: "Loading\u2026" }) })), _jsx(PresenceLine, { names: editorsOf(others, target), noun: "item" }), stale && _jsx(StaleBanner, { info: stale, onReload: loadTheirs }), _jsxs("div", { className: "sm-editor", inert: stale ? true : undefined, children: [_jsxs("div", { className: "sm-form", ref: formRef, children: [notice && (_jsxs(Banner, { tone: notice.tone, onDismiss: () => setNotice(null), children: [_jsx("p", { style: { margin: 0 }, children: notice.text }), _jsx(FieldErrorList, { fields: notice.fields ?? [], onReveal: (path) => { if (formRef.current)
302
- revealField(formRef.current, path); } })] })), record && (_jsxs("p", { className: "sm-hint", children: [pages.size === 0 ? "Not placed on any page yet." : `Used on ${pages.size} page${pages.size === 1 ? "" : "s"}: `, [...pages].map((id, i) => (_jsxs("span", { children: [i > 0 && ", ", _jsx("a", { href: adminHref(`/pages/${encodeURIComponent(id)}?locale=${encodeURIComponent(locale)}`), children: id })] }, id)))] })), fields !== null ? (_jsxs(_Fragment, { children: [_jsx(GlobalFields, { item: item, groups: contentGroups, fields: fields, errors: fieldErrors, refOptions: refOptions, multilingual: multilingual, onChange: onChange }), settingGroups.length > 0 && (_jsxs(_Fragment, { children: [_jsx(SectionLabel, { children: "Settings" }), _jsx(GlobalFields, { item: item, groups: settingGroups, fields: fields, errors: fieldErrors, refOptions: refOptions, multilingual: multilingual, onChange: onChange })] }))] })) : !notice && _jsx("p", { style: { color: "var(--text-muted)" }, children: "Loading\u2026" })] }), _jsx(ResizeHandle, {}), _jsx("aside", { className: "sm-inspector", children: record && row && (_jsxs("div", { className: "sm-card", children: [_jsx(SectionLabel, { children: "Document info" }), _jsx(InfoRow, { label: "Kind", value: item.section !== undefined ? `Visual · ${item.section}` : "Fields" }), _jsx(InfoRow, { label: "Updated", value: updatedLine(row.updatedAt, row.updatedBy) }), _jsx(InfoRow, { label: "ID", value: record.id, mono: true })] })) })] })] }));
321
+ ] }) }), right: _jsxs(_Fragment, { children: [(touched || saveState === "error") && _jsx(SaveIndicator, { state: saveState, pending: dirty }), _jsx(PublishControl, { status: status, pending: dirty || saveState !== "saved", busy: busy, flash: published, disabled: !row, onPublish: () => { void publish(); }, onUnpublish: () => { void unpublish(); }, onDiscard: () => { void discard(); }, onHistory: () => { void openHistory(); } })] }) }), history && row && (_jsx(HistoryModal, { rows: history, draftId: row.draftVersionId, onSelect: (v) => { void loadSnapshot(v); }, onClose: () => setHistory(null), onRestore: restore, render: (versionId) => snapshot?.id === versionId ? (_jsxs("fieldset", { disabled: true, className: "sm-history__form", children: [_jsx(GlobalFields, { item: item, groups: contentGroups, fields: snapshot.fields, refOptions: refOptions, multilingual: multilingual, onChange: () => { } }), settingGroups.length > 0 && (_jsxs(_Fragment, { children: [_jsx(SectionLabel, { children: "Settings" }), _jsx(GlobalFields, { item: item, groups: settingGroups, fields: snapshot.fields, refOptions: refOptions, multilingual: multilingual, onChange: () => { } })] }))] })) : _jsx("p", { className: "sm-hint", style: { padding: 16 }, children: "Loading\u2026" }) })), _jsx(PresenceLine, { names: editorsOf(others, target), noun: "item" }), stale && _jsx(StaleBanner, { info: stale, onReload: loadTheirs }), _jsx(DriftNotice, { draft: findings, live: liveFindings }), _jsxs("div", { className: "sm-editor", inert: stale ? true : undefined, children: [_jsxs("div", { className: "sm-form", ref: formRef, children: [notice && (_jsxs(Banner, { tone: notice.tone, onDismiss: () => setNotice(null), children: [_jsx("p", { style: { margin: 0 }, children: notice.text }), _jsx(FieldErrorList, { fields: notice.fields ?? [], onReveal: (path) => { if (formRef.current)
322
+ revealField(formRef.current, path); } })] })), record && (_jsxs("p", { className: "sm-hint", children: [pages.size === 0 ? "Not placed on any page yet." : `Used on ${pages.size} page${pages.size === 1 ? "" : "s"}: `, [...pages].map((id, i) => (_jsxs("span", { children: [i > 0 && ", ", _jsx("a", { href: adminHref(`/pages/${encodeURIComponent(id)}?locale=${encodeURIComponent(locale)}`), children: id })] }, id)))] })), fields !== null ? (_jsxs(_Fragment, { children: [_jsx(GlobalFields, { item: item, groups: contentGroups, fields: fields, errors: markerErrors, refOptions: refOptions, multilingual: multilingual, onChange: onChange }), settingGroups.length > 0 && (_jsxs(_Fragment, { children: [_jsx(SectionLabel, { children: "Settings" }), _jsx(GlobalFields, { item: item, groups: settingGroups, fields: fields, errors: markerErrors, refOptions: refOptions, multilingual: multilingual, onChange: onChange })] })), _jsx(OrphanFields, { orphans: findings, onClear: clearOrphan })] })) : !notice && _jsx("p", { style: { color: "var(--text-muted)" }, children: "Loading\u2026" })] }), _jsx(ResizeHandle, {}), _jsx("aside", { className: "sm-inspector", children: record && row && (_jsxs("div", { className: "sm-card", children: [_jsx(SectionLabel, { children: "Document info" }), _jsx(InfoRow, { label: "Kind", value: item.section !== undefined ? `Visual · ${item.section}` : "Fields" }), _jsx(InfoRow, { label: "Updated", value: updatedLine(row.updatedAt, row.updatedBy) }), _jsx(InfoRow, { label: "ID", value: record.id, mono: true })] })) })] })] }));
303
323
  }
@@ -19,7 +19,7 @@ import { navigate } from "./router.js";
19
19
  import { ErrorPane } from "./ErrorPane.js";
20
20
  import { rememberLocale, rememberedLocale } from "./locale-memory.js";
21
21
  import { confirmLiveUrl } from "./live-url.js";
22
- import { confirm } from "../ui/confirm.js";
22
+ import { confirm, notify } from "../ui/confirm.js";
23
23
  import { PageFormDialog } from "./PageDialogs.js";
24
24
  import { usePresenceState } from "./presence-context.js";
25
25
  import { editorsOf } from "./presence.js";
@@ -95,11 +95,11 @@ export function PagesList({ ops, registry }) {
95
95
  await load();
96
96
  return null;
97
97
  };
98
- /** `attempt` for an action outside a dialog: the message goes to an alert. Resolves to whether it succeeded. */
98
+ /** `attempt` for an action outside a dialog: the message goes to a notify dialog. Resolves to whether it succeeded. */
99
99
  const run = async (work, then) => {
100
100
  const message = await attempt(work, then);
101
101
  if (message)
102
- window.alert(message);
102
+ void notify({ title: "That didn't work", message });
103
103
  return message === null;
104
104
  };
105
105
  /** New page and New path (a folder in the tree) create in the SELECTED locale (spec §6),
@@ -8,11 +8,13 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
8
8
  // unknown page is the shell's Not found pane. Each page names its
9
9
  // capability (settings.ts): Members is for admins (members management
10
10
  // §2) and an editor opening it gets the error pane; Redirects (spec
11
- // 2026-09-26 redirects §6) is for every member. Until the role is known
11
+ // 2026-09-26 redirects §6) and Content health (spec 2026-09-27 editor
12
+ // safety §4) are for every member. Until the role is known
12
13
  // the area is loading.
13
14
  import { useEffect } from "react";
14
15
  import { useAdminHref } from "./base.js";
15
16
  import { ErrorPane } from "./ErrorPane.js";
17
+ import { ContentHealthPage } from "./ContentHealthPage.js";
16
18
  import { MembersPage } from "./MembersPage.js";
17
19
  import { RedirectsPage } from "./RedirectsPage.js";
18
20
  import { RouteLink } from "./RouteLink.js";
@@ -34,5 +36,7 @@ export function SettingsView({ ops, registry, tail, role, userId }) {
34
36
  return _jsx(ErrorPane, { message: "Not found." });
35
37
  if (!visible.some((p) => p.id === page))
36
38
  return _jsx(ErrorPane, { message: "Settings are for admins." });
37
- return (_jsxs("div", { className: "sm-settings", children: [_jsx("nav", { className: "sm-settings__nav", "aria-label": "Settings", children: visible.map((p) => (_jsx(RouteLink, { className: "sm-settings__link", href: adminHref(`/settings/${p.id}`), "aria-current": p.id === page ? "page" : undefined, children: p.title }, p.id))) }), _jsx("section", { className: "sm-settings__page", children: page === "members" ? _jsx(MembersPage, { ops: ops, userId: userId }) : _jsx(RedirectsPage, { ops: ops, registry: registry }) })] }));
39
+ return (_jsxs("div", { className: "sm-settings", children: [_jsx("nav", { className: "sm-settings__nav", "aria-label": "Settings", children: visible.map((p) => (_jsx(RouteLink, { className: "sm-settings__link", href: adminHref(`/settings/${p.id}`), "aria-current": p.id === page ? "page" : undefined, children: p.title }, p.id))) }), _jsx("section", { className: "sm-settings__page", children: page === "members" ? _jsx(MembersPage, { ops: ops, userId: userId })
40
+ : page === "redirects" ? _jsx(RedirectsPage, { ops: ops, registry: registry })
41
+ : _jsx(ContentHealthPage, { ops: ops, registry: registry }) })] }));
38
42
  }
@@ -0,0 +1,27 @@
1
+ import type { AdminRegistry } from "../serialize.ts";
2
+ import type { HealthReport } from "../health.ts";
3
+ export type HealthRow = {
4
+ key: string;
5
+ title: string;
6
+ kind: string;
7
+ locales: string[];
8
+ draft: boolean;
9
+ live: boolean;
10
+ href: string;
11
+ };
12
+ export type HealthGroup = {
13
+ key: string;
14
+ headline: string;
15
+ rows: HealthRow[];
16
+ };
17
+ export type HealthSections = {
18
+ broken: HealthGroup[];
19
+ orphan: HealthGroup[];
20
+ collections: {
21
+ name: string;
22
+ headline: string;
23
+ }[];
24
+ };
25
+ /** A group shows this many rows, then "Show all N". */
26
+ export declare const GROUP_PREVIEW = 10;
27
+ export declare function healthSections(report: HealthReport, registry: AdminRegistry): HealthSections;
@@ -0,0 +1,47 @@
1
+ import { SEVERITY, groupKey } from "../drift.js";
2
+ import { driftHeadline } from "../drift-copy.js";
3
+ /** A group shows this many rows, then "Show all N". */
4
+ export const GROUP_PREVIEW = 10;
5
+ const kindLabel = (doc, registry) => doc.kind === "page" ? "Page" : doc.kind === "global" ? "Site" : registry.collections.find((c) => c.name === doc.collection)?.title ?? doc.collection ?? "";
6
+ const href = (doc, locale) => {
7
+ const q = `?locale=${encodeURIComponent(locale)}`;
8
+ if (doc.kind === "page")
9
+ return `/pages/${encodeURIComponent(doc.id)}${q}`;
10
+ if (doc.kind === "global")
11
+ return `/globals/${encodeURIComponent(doc.collection ?? "")}${q}`;
12
+ return `/${doc.collection}/${encodeURIComponent(doc.id)}${q}`;
13
+ };
14
+ export function healthSections(report, registry) {
15
+ const groups = new Map();
16
+ for (const doc of report.documents) {
17
+ for (const f of doc.findings) {
18
+ const key = groupKey(f);
19
+ let g = groups.get(key);
20
+ if (!g)
21
+ groups.set(key, (g = { severity: SEVERITY[f.kind], headline: driftHeadline(f, registry), rows: new Map() }));
22
+ const rowKey = `${doc.kind}:${doc.id}`;
23
+ const row = g.rows.get(rowKey);
24
+ if (!row) {
25
+ g.rows.set(rowKey, { key: rowKey, title: doc.title, kind: kindLabel(doc, registry), locales: [f.locale], draft: f.draft, live: f.live, href: href(doc, f.locale) });
26
+ }
27
+ else {
28
+ if (!row.locales.includes(f.locale))
29
+ row.locales.push(f.locale);
30
+ row.draft || (row.draft = f.draft);
31
+ row.live || (row.live = f.live);
32
+ }
33
+ }
34
+ }
35
+ const order = registry.locales.supported;
36
+ const out = (severity) => [...groups].filter(([, g]) => g.severity === severity)
37
+ .map(([key, g]) => ({ key, headline: g.headline, rows: [...g.rows.values()].map((r) => ({ ...r, locales: [...r.locales].sort((a, b) => order.indexOf(a) - order.indexOf(b)) })) }))
38
+ .sort((a, b) => b.rows.length - a.rows.length || a.headline.localeCompare(b.headline));
39
+ return {
40
+ broken: out("broken"),
41
+ orphan: out("orphan"),
42
+ collections: report.collections.map(({ name, count }) => ({
43
+ name,
44
+ headline: `Collection \`${name}\` no longer registered · ${count} ${count === 1 ? "entry" : "entries"}. Register it again or delete its ${count} ${count === 1 ? "entry" : "entries"}.`,
45
+ })),
46
+ };
47
+ }
@@ -7,6 +7,10 @@ export declare const SETTINGS_PAGES: readonly [{
7
7
  readonly id: "redirects";
8
8
  readonly title: "Redirects";
9
9
  readonly capability: "content.write";
10
+ }, {
11
+ readonly id: "health";
12
+ readonly title: "Content health";
13
+ readonly capability: "content.write";
10
14
  }];
11
15
  export type SettingsPage = (typeof SETTINGS_PAGES)[number];
12
16
  export type SettingsPageId = SettingsPage["id"];