smoodly 0.0.27 → 0.0.29

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (96) hide show
  1. package/README.md +75 -3
  2. package/dist/admin/client-ops.js +2 -0
  3. package/dist/admin/drift-copy.d.ts +19 -0
  4. package/dist/admin/drift-copy.js +89 -0
  5. package/dist/admin/drift.d.ts +76 -0
  6. package/dist/admin/drift.js +264 -0
  7. package/dist/admin/editor/EditorView.js +70 -15
  8. package/dist/admin/editor/InlineRichtext.js +3 -1
  9. package/dist/admin/editor/Outline.d.ts +7 -0
  10. package/dist/admin/editor/Outline.js +8 -5
  11. package/dist/admin/editor/PageInspector.d.ts +9 -1
  12. package/dist/admin/editor/PageInspector.js +4 -2
  13. package/dist/admin/editor/PageSettings.d.ts +4 -1
  14. package/dist/admin/editor/PageSettings.js +33 -15
  15. package/dist/admin/editor/UnknownNode.d.ts +7 -0
  16. package/dist/admin/editor/UnknownNode.js +8 -0
  17. package/dist/admin/field-paths.d.ts +3 -0
  18. package/dist/admin/field-paths.js +26 -0
  19. package/dist/admin/forms/OrphanFields.d.ts +8 -0
  20. package/dist/admin/forms/OrphanFields.js +11 -0
  21. package/dist/admin/forms/RefPicker.js +6 -2
  22. package/dist/admin/forms/RichtextElementCard.js +9 -3
  23. package/dist/admin/forms/RichtextWidget.js +3 -1
  24. package/dist/admin/forms/TagsField.js +1 -1
  25. package/dist/admin/forms/richtext-form-context.d.ts +8 -0
  26. package/dist/admin/forms/richtext-form-context.js +5 -0
  27. package/dist/admin/health.d.ts +23 -0
  28. package/dist/admin/health.js +42 -0
  29. package/dist/admin/ops-impl.js +124 -14
  30. package/dist/admin/ops.d.ts +53 -1
  31. package/dist/admin/redirect-path.d.ts +6 -0
  32. package/dist/admin/redirect-path.js +33 -0
  33. package/dist/admin/shell/AddRedirectModal.d.ts +11 -0
  34. package/dist/admin/shell/AddRedirectModal.js +29 -0
  35. package/dist/admin/shell/AdminApp.js +2 -3
  36. package/dist/admin/shell/ContentHealthPage.d.ts +10 -0
  37. package/dist/admin/shell/ContentHealthPage.js +37 -0
  38. package/dist/admin/shell/EntriesList.js +4 -4
  39. package/dist/admin/shell/EntryForm.js +32 -7
  40. package/dist/admin/shell/ErrorPane.d.ts +4 -2
  41. package/dist/admin/shell/ErrorPane.js +5 -4
  42. package/dist/admin/shell/GlobalForm.js +22 -2
  43. package/dist/admin/shell/IconRail.d.ts +1 -1
  44. package/dist/admin/shell/IconRail.js +3 -2
  45. package/dist/admin/shell/PagesList.js +9 -7
  46. package/dist/admin/shell/RedirectsPage.d.ts +11 -0
  47. package/dist/admin/shell/RedirectsPage.js +75 -0
  48. package/dist/admin/shell/SettingsView.d.ts +4 -2
  49. package/dist/admin/shell/SettingsView.js +22 -14
  50. package/dist/admin/shell/health-list.d.ts +27 -0
  51. package/dist/admin/shell/health-list.js +47 -0
  52. package/dist/admin/shell/live-url.d.ts +10 -2
  53. package/dist/admin/shell/live-url.js +9 -4
  54. package/dist/admin/shell/members-list.js +1 -1
  55. package/dist/admin/shell/redirects-list.d.ts +12 -0
  56. package/dist/admin/shell/redirects-list.js +15 -0
  57. package/dist/admin/shell/settings.d.ts +16 -3
  58. package/dist/admin/shell/settings.js +16 -9
  59. package/dist/admin/tree-ops.d.ts +4 -0
  60. package/dist/admin/tree-ops.js +15 -1
  61. package/dist/admin/ui/Combobox.d.ts +8 -2
  62. package/dist/admin/ui/Combobox.js +48 -16
  63. package/dist/admin/ui/ConfirmDialog.d.ts +1 -1
  64. package/dist/admin/ui/ConfirmDialog.js +5 -4
  65. package/dist/admin/ui/DriftNotice.d.ts +6 -0
  66. package/dist/admin/ui/DriftNotice.js +10 -0
  67. package/dist/admin/ui/combo-box.d.ts +13 -0
  68. package/dist/admin/ui/combo-box.js +8 -0
  69. package/dist/admin/ui/confirm.d.ts +21 -2
  70. package/dist/admin/ui/confirm.js +26 -9
  71. package/dist/admin/ui/theme.js +32 -3
  72. package/dist/admin/validate.js +15 -47
  73. package/dist/entry-store.d.ts +31 -6
  74. package/dist/entry-store.js +39 -11
  75. package/dist/index.d.ts +2 -2
  76. package/dist/next/page-renderer.js +28 -9
  77. package/dist/path-index.d.ts +28 -4
  78. package/dist/path-index.js +37 -4
  79. package/dist/paths.d.ts +16 -0
  80. package/dist/paths.js +9 -0
  81. package/dist/render.js +7 -2
  82. package/dist/schema-version.d.ts +1 -1
  83. package/dist/schema-version.js +1 -1
  84. package/dist/site.d.ts +4 -1
  85. package/dist/site.js +3 -1
  86. package/dist/sql.js +19 -1
  87. package/dist/store.d.ts +39 -5
  88. package/dist/store.js +32 -8
  89. package/dist/supabase-entry-store.d.ts +9 -3
  90. package/dist/supabase-entry-store.js +39 -13
  91. package/dist/supabase-path-index.d.ts +10 -3
  92. package/dist/supabase-path-index.js +50 -17
  93. package/dist/supabase-store.d.ts +15 -3
  94. package/dist/supabase-store.js +42 -8
  95. package/migrations/20260926150000_smoodly_v4.sql +41 -0
  96. package/package.json +1 -1
@@ -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
+ }
@@ -1,10 +1,18 @@
1
1
  import { type ConfirmOptions, type ConfirmSubject } from "../ui/confirm.ts";
2
2
  export declare const LIVE_URL_CONFIRM: Omit<ConfirmOptions, "subject">;
3
+ /** The rider (spec 2026-09-26 redirects §1): on by default, the editor unticks to let the old address go dark. */
4
+ export declare const LIVE_URL_OPTION: {
5
+ readonly label: "Redirect the old address";
6
+ readonly checked: true;
7
+ };
3
8
  /** `from` null = there was no URL part to change (a new row, a collection without a path). */
4
9
  export declare function liveUrlChanges(input: {
5
10
  status: "draft" | "published" | undefined;
6
11
  from: string | null;
7
12
  to: string | null;
8
13
  }): boolean;
9
- /** Resolves true when the change may go ahead: not a live change, or confirmed. */
10
- export declare function confirmLiveUrl(input: Parameters<typeof liveUrlChanges>[0], subject?: ConfirmSubject): Promise<boolean>;
14
+ /** `ok` false = cancelled. `redirect` is the checkbox (true when no question was asked: the store's default). */
15
+ export declare function confirmLiveUrl(input: Parameters<typeof liveUrlChanges>[0], subject?: ConfirmSubject): Promise<{
16
+ ok: boolean;
17
+ redirect: boolean;
18
+ }>;
@@ -3,17 +3,22 @@
3
3
  // published page's parent. Whoever the role — the mistake is the same.
4
4
  // Asked in the admin's own dialog (ui/confirm.ts); the subject chip names
5
5
  // the record and the URL part that is about to change.
6
- import { confirm } from "../ui/confirm.js";
6
+ import { confirmWithOption } from "../ui/confirm.js";
7
7
  export const LIVE_URL_CONFIRM = {
8
8
  title: "Change the live URL?",
9
- body: "This is published. Old links to it stop working until a redirect exists, and Smoodly has none yet.",
9
+ body: "This is published. With a redirect, visitors and search engines who open the old address are sent to the new one.",
10
10
  confirmLabel: "Change URL",
11
11
  };
12
+ /** The rider (spec 2026-09-26 redirects §1): on by default, the editor unticks to let the old address go dark. */
13
+ export const LIVE_URL_OPTION = { label: "Redirect the old address", checked: true };
12
14
  /** `from` null = there was no URL part to change (a new row, a collection without a path). */
13
15
  export function liveUrlChanges(input) {
14
16
  return input.status === "published" && input.from !== null && input.to !== input.from;
15
17
  }
16
- /** Resolves true when the change may go ahead: not a live change, or confirmed. */
18
+ /** `ok` false = cancelled. `redirect` is the checkbox (true when no question was asked: the store's default). */
17
19
  export async function confirmLiveUrl(input, subject) {
18
- return !liveUrlChanges(input) || confirm({ ...LIVE_URL_CONFIRM, subject });
20
+ if (!liveUrlChanges(input))
21
+ return { ok: true, redirect: true };
22
+ const { yes, checked } = await confirmWithOption({ ...LIVE_URL_CONFIRM, option: { ...LIVE_URL_OPTION }, subject });
23
+ return { ok: yes, redirect: checked };
19
24
  }
@@ -2,7 +2,7 @@ import { normalizeRole } from "../../permissions.js";
2
2
  export const roleLabel = (role) => (role ? role[0].toUpperCase() + role.slice(1) : "");
3
3
  /** What each role may do, in one line — the invite modal's radio descriptions (permissions.ts decides). */
4
4
  export const ROLE_DESCRIPTION = {
5
- editor: "Edits, publishes and deletes content and media.",
5
+ editor: "Edits, publishes and deletes content and media, and manages redirects.",
6
6
  admin: "Everything an editor can, and Settings: invites, re-roles and removes members.",
7
7
  };
8
8
  export function memberRows(members, opts) {
@@ -0,0 +1,12 @@
1
+ import type { PathTarget } from "../../paths.ts";
2
+ import type { RedirectTargetItem } from "../ops.ts";
3
+ import type { ComboOption } from "../ui/combo-box.ts";
4
+ import type { ConfirmOptions } from "../ui/confirm.ts";
5
+ export declare const targetOptions: (targets: RedirectTargetItem[]) => ComboOption[];
6
+ export declare function parseTargetValue(value: string): PathTarget | null;
7
+ /** Admin-relative (the caller applies adminHref). */
8
+ export declare const editorHref: (item: {
9
+ target: PathTarget;
10
+ collection: string | null;
11
+ }, locale: string) => string;
12
+ export declare const REMOVE_REDIRECT_CONFIRM: Omit<ConfirmOptions, "subject">;
@@ -0,0 +1,15 @@
1
+ export const targetOptions = (targets) => targets.map((t) => ({ value: `${t.target.kind}:${t.target.id}`, label: `${t.title} · ${t.path}` }));
2
+ export function parseTargetValue(value) {
3
+ const m = /^(page|entry):(.+)$/.exec(value);
4
+ return m ? { kind: m[1], id: m[2] } : null;
5
+ }
6
+ /** Admin-relative (the caller applies adminHref). */
7
+ export const editorHref = (item, locale) => item.target.kind === "page"
8
+ ? `/pages/${encodeURIComponent(item.target.id)}?locale=${encodeURIComponent(locale)}`
9
+ : `/${item.collection}/${encodeURIComponent(item.target.id)}?locale=${encodeURIComponent(locale)}`;
10
+ export const REMOVE_REDIRECT_CONFIRM = {
11
+ title: "Remove this redirect?",
12
+ body: "Visitors who open the old address get a Not found page. Browsers that already followed the redirect may keep going to the new address for a while.",
13
+ confirmLabel: "Remove redirect",
14
+ destructive: true,
15
+ };
@@ -1,9 +1,22 @@
1
+ import { type MemberRole } from "../../permissions.ts";
1
2
  export declare const SETTINGS_PAGES: readonly [{
2
3
  readonly id: "members";
3
4
  readonly title: "Members";
5
+ readonly capability: "settings";
6
+ }, {
7
+ readonly id: "redirects";
8
+ readonly title: "Redirects";
9
+ readonly capability: "content.write";
10
+ }, {
11
+ readonly id: "health";
12
+ readonly title: "Content health";
13
+ readonly capability: "content.write";
4
14
  }];
5
- export type SettingsPageId = (typeof SETTINGS_PAGES)[number]["id"];
6
- export declare const DEFAULT_SETTINGS_PAGE: SettingsPageId;
7
- /** The page for the segment after /settings: the default for none, null for an unknown one. */
15
+ export type SettingsPage = (typeof SETTINGS_PAGES)[number];
16
+ export type SettingsPageId = SettingsPage["id"];
17
+ /** The pages this role may open, in nav order. Every member has content.write, so the area is never empty. */
18
+ export declare const visibleSettingsPages: (role: MemberRole) => readonly SettingsPage[];
19
+ export declare const defaultSettingsPage: (role: MemberRole) => SettingsPageId;
20
+ /** The page for the segment after /settings; null for none or an unknown one. */
8
21
  export declare function settingsPage(tail: string | undefined): SettingsPageId | null;
9
22
  export declare function settingsTitle(page: SettingsPageId): string;
@@ -1,15 +1,22 @@
1
- // The Settings area's pages (spec 2026-09-25 §5; narrowed 2026-09-25 to
2
- // Members alone — Roles and API Keys dropped, see DESIGN.md §2): ids are
3
- // URL segments under /settings, titles are what the secondary nav and
4
- // each top bar show. Pure, so routing is testable without React.
1
+ // The Settings area's pages (spec 2026-09-25 §5; Roles and API Keys
2
+ // dropped 2026-09-25, Redirects added 2026-09-26 §6, Content health added
3
+ // 2026-09-27, editor safety §4): ids are URL
4
+ // segments under /settings, titles are what the secondary nav and each
5
+ // top bar show, and each page names the capability that opens it —
6
+ // Members for admins, Redirects for every member, as renaming is,
7
+ // Content health for every member (editors do most of the fixing). Pure,
8
+ // so routing is testable without React.
9
+ import { can } from "../../permissions.js";
5
10
  export const SETTINGS_PAGES = [
6
- { id: "members", title: "Members" },
11
+ { id: "members", title: "Members", capability: "settings" },
12
+ { id: "redirects", title: "Redirects", capability: "content.write" },
13
+ { id: "health", title: "Content health", capability: "content.write" },
7
14
  ];
8
- export const DEFAULT_SETTINGS_PAGE = "members";
9
- /** The page for the segment after /settings: the default for none, null for an unknown one. */
15
+ /** The pages this role may open, in nav order. Every member has content.write, so the area is never empty. */
16
+ export const visibleSettingsPages = (role) => SETTINGS_PAGES.filter((p) => can(role, p.capability));
17
+ export const defaultSettingsPage = (role) => visibleSettingsPages(role)[0].id;
18
+ /** The page for the segment after /settings; null for none or an unknown one. */
10
19
  export function settingsPage(tail) {
11
- if (tail === undefined)
12
- return DEFAULT_SETTINGS_PAGE;
13
20
  return SETTINGS_PAGES.some((p) => p.id === tail) ? tail : null;
14
21
  }
15
22
  export function settingsTitle(page) {
@@ -36,3 +36,7 @@ export declare function fillLockedZones(tree: PageTree, policies: Record<string,
36
36
  export declare function newNodeId(type: string): string;
37
37
  /** A page-level value — the template's page fields or the SEO meta (spec 2026-09-10 page fields §5). */
38
38
  export declare function patchPage(tree: PageTree, ns: "fields" | "meta", key: string, value: unknown): PageTree;
39
+ /** A zone the page type no longer declares, dropped from the draft (spec 2026-09-27 editor safety §5: Clear zone). */
40
+ export declare function clearZone(tree: PageTree, zone: string): PageTree;
41
+ /** A key removed from a node: `x` / `a.b` in fields, `settings.x`, `styles.x` (§5: an orphan's Clear). */
42
+ export declare function unsetNodePath(tree: PageTree, id: string, path: string): PageTree;
@@ -1,5 +1,5 @@
1
1
  import { GLOBAL_NODE_TYPE } from "../global.js";
2
- import { setAtPath } from "./field-paths.js";
2
+ import { setAtPath, unsetAtPath } from "./field-paths.js";
3
3
  export function zoneOf(tree, id) {
4
4
  for (const [zone, nodes] of Object.entries(tree.zones)) {
5
5
  if (nodes.some((n) => n.id === id))
@@ -187,3 +187,17 @@ export function newNodeId(type) {
187
187
  export function patchPage(tree, ns, key, value) {
188
188
  return { ...tree, [ns]: { ...(tree[ns] ?? {}), [key]: value } };
189
189
  }
190
+ /** A zone the page type no longer declares, dropped from the draft (spec 2026-09-27 editor safety §5: Clear zone). */
191
+ export function clearZone(tree, zone) {
192
+ const { [zone]: _gone, ...zones } = tree.zones;
193
+ return { ...tree, zones };
194
+ }
195
+ /** A key removed from a node: `x` / `a.b` in fields, `settings.x`, `styles.x` (§5: an orphan's Clear). */
196
+ export function unsetNodePath(tree, id, path) {
197
+ const zone = zoneOf(tree, id);
198
+ if (!zone)
199
+ return tree;
200
+ const ns = path.startsWith("settings.") ? "settings" : path.startsWith("styles.") ? "styles" : "fields";
201
+ const rest = ns === "fields" ? path : path.slice(ns.length + 1);
202
+ return withZone(tree, zone, tree.zones[zone].map((n) => (n.id === id ? { ...n, [ns]: unsetAtPath((n[ns] ?? {}), rest) } : n)));
203
+ }
@@ -18,6 +18,12 @@ export type ComboboxProps = {
18
18
  createRow?: boolean;
19
19
  /** Every query change passes through; the return value is the query kept. */
20
20
  onQueryChange?(next: string): string;
21
+ /** A single select's pick: shown in the input until the editor types. */
22
+ selected?: string;
23
+ /** Inside the control, left of the chevron (a single ref's status, open and remove). */
24
+ suffix?: ReactNode;
25
+ /** A modifier for the floating list, which is not inside the root (the tags input's width). */
26
+ listClassName?: string;
21
27
  /** Keys the owner wants first; return true when handled. */
22
28
  onInputKey?(e: KeyboardEvent<HTMLInputElement>, query: string): boolean;
23
29
  /** Escape, or focus leaving the control; `query` is what was typed and not picked. */
@@ -27,9 +33,9 @@ export type ComboboxProps = {
27
33
  initialOpen?: boolean;
28
34
  className?: string;
29
35
  };
30
- /** The vertical band the list may occupy: the nearest clipping ancestor's box, cut to the viewport. */
36
+ /** The vertical band a popover may occupy: the nearest clipping ancestor's box, cut to the viewport. */
31
37
  export declare function clipBand(el: HTMLElement): {
32
38
  top: number;
33
39
  bottom: number;
34
40
  };
35
- export declare function Combobox({ options, onPick, label, placeholder, disabled, keepOpen, emptyText, renderOption, onSubmit, createRow, onQueryChange, onInputKey, onClose, autoFocus, initialOpen, className, }: ComboboxProps): import("react").JSX.Element;
41
+ export declare function Combobox({ options, onPick, label, placeholder, disabled, keepOpen, emptyText, renderOption, onSubmit, createRow, onQueryChange, onInputKey, onClose, autoFocus, initialOpen, className, selected, suffix, listClassName, }: ComboboxProps): import("react").JSX.Element;
@@ -4,13 +4,16 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
4
4
  // picker and the tags input are both this. It knows nothing about entries
5
5
  // or tags — options in, a pick out. Decisions live in ./combo-box.ts.
6
6
  // Rows keep the input focused (mousedown is swallowed), so "focus left the
7
- // control" is the one close signal besides Escape.
8
- import { useId, useLayoutEffect, useRef, useState } from "react";
7
+ // control" is the one close signal besides Escape. The list is portaled
8
+ // into the admin wrapper and fixed to the viewport, so no scrolling form or
9
+ // modal body clips it (2026-09-27); static rendering keeps it inline.
10
+ import { useCallback, useEffect, useId, useLayoutEffect, useRef, useState } from "react";
11
+ import { createPortal } from "react-dom";
9
12
  import { ChevronDown } from "lucide-react";
10
13
  import { cx } from "./primitives.js";
11
- import { filterOptions, opensUp, showsCreate, stepActive } from "./combo-box.js";
14
+ import { filterOptions, listPlacement, showsCreate, stepActive } from "./combo-box.js";
12
15
  const keepFocus = (e) => e.preventDefault();
13
- /** The vertical band the list may occupy: the nearest clipping ancestor's box, cut to the viewport. */
16
+ /** The vertical band a popover may occupy: the nearest clipping ancestor's box, cut to the viewport. */
14
17
  export function clipBand(el) {
15
18
  let top = 0;
16
19
  let bottom = window.innerHeight;
@@ -24,29 +27,53 @@ export function clipBand(el) {
24
27
  }
25
28
  return { top, bottom };
26
29
  }
27
- export function Combobox({ options, onPick, label, placeholder, disabled, keepOpen, emptyText, renderOption, onSubmit, createRow, onQueryChange, onInputKey, onClose, autoFocus, initialOpen = false, className, }) {
30
+ export function Combobox({ options, onPick, label, placeholder, disabled, keepOpen, emptyText, renderOption, onSubmit, createRow, onQueryChange, onInputKey, onClose, autoFocus, initialOpen = false, className, selected, suffix, listClassName, }) {
28
31
  const rest = onSubmit ? -1 : 0; // the active row a fresh query starts from
29
- const [query, setQuery] = useState("");
32
+ // null: nothing typed since the last pick, so the input shows `selected`.
33
+ const [typed, setTyped] = useState(null);
34
+ const query = typed ?? "";
30
35
  const [open, setOpen] = useState(initialOpen);
31
36
  const [active, setActive] = useState(rest);
32
37
  const root = useRef(null);
33
38
  const list = useRef(null);
34
- const [up, setUp] = useState(false);
39
+ const [host, setHost] = useState(null);
40
+ const [place, setPlace] = useState(null);
35
41
  const id = useId();
42
+ useLayoutEffect(() => {
43
+ const el = root.current;
44
+ if (el)
45
+ setHost(el.closest(".sm-admin") ?? el.ownerDocument.body);
46
+ }, []);
36
47
  const shown = filterOptions(options, query);
37
48
  const create = onSubmit !== undefined && createRow === true && showsCreate(options, query);
38
49
  const count = shown.length + (create ? 1 : 0);
39
50
  // Free text with nothing to offer shows no popover; a picker says why it is empty.
40
51
  const popover = open && !disabled && (count > 0 || !onSubmit);
41
- // A field at the bottom of a scrolling form has no room under it: the list hangs above instead.
52
+ // Under the input, or above it when the viewport has no room below; it follows the input on scroll.
53
+ const measure = useCallback(() => {
54
+ const el = root.current;
55
+ if (!el || !list.current)
56
+ return;
57
+ const box = el.getBoundingClientRect();
58
+ setPlace(listPlacement(box, el.ownerDocument.defaultView?.innerHeight ?? 0, list.current.offsetHeight));
59
+ }, []);
42
60
  useLayoutEffect(() => {
43
- if (!popover || !root.current || !list.current)
61
+ if (popover && host)
62
+ measure();
63
+ else
64
+ setPlace(null);
65
+ }, [popover, host, count, measure]);
66
+ useEffect(() => {
67
+ const view = root.current?.ownerDocument.defaultView;
68
+ if (!popover || !view)
44
69
  return;
45
- const box = root.current.getBoundingClientRect();
46
- const band = clipBand(root.current);
47
- setUp(opensUp({ below: band.bottom - box.bottom - 4, above: box.top - band.top - 4, height: list.current.offsetHeight }));
48
- }, [popover, count]);
49
- const reset = () => { setQuery(""); setActive(rest); };
70
+ const onScroll = (e) => { if (e.target !== list.current)
71
+ measure(); };
72
+ view.addEventListener("scroll", onScroll, true);
73
+ view.addEventListener("resize", measure);
74
+ return () => { view.removeEventListener("scroll", onScroll, true); view.removeEventListener("resize", measure); };
75
+ }, [popover, measure]);
76
+ const reset = () => { setTyped(null); setActive(rest); };
50
77
  const close = (reason) => {
51
78
  setOpen(false);
52
79
  onClose?.(query, reason);
@@ -84,6 +111,11 @@ export function Combobox({ options, onPick, label, placeholder, disabled, keepOp
84
111
  close("escape");
85
112
  }
86
113
  };
87
- return (_jsxs("div", { ref: root, className: cx("sm-combo", className), children: [_jsx("input", { className: "sm-input", role: "combobox", "aria-label": label, "aria-autocomplete": "list", "aria-expanded": popover, "aria-controls": `${id}-list`, "aria-activedescendant": popover && active >= 0 ? `${id}-opt-${active}` : undefined, placeholder: placeholder, disabled: disabled, autoFocus: autoFocus, value: query, onFocus: () => setOpen(true), onClick: () => setOpen(true), onChange: (e) => { setQuery(onQueryChange ? onQueryChange(e.target.value) : e.target.value); setActive(rest); setOpen(true); }, onKeyDown: onKeyDown, onBlur: (e) => { if (open && !root.current?.contains(e.relatedTarget))
88
- close("blur"); } }), _jsx(ChevronDown, { className: "sm-combo__chevron", size: 14, strokeWidth: 1.75, "aria-hidden": "true" }), popover && (_jsxs("div", { ref: list, id: `${id}-list`, role: "listbox", "aria-label": label, className: cx("sm-picker sm-combo__list", up && "sm-combo__list--up"), children: [count === 0 && _jsx("div", { className: "sm-combo__empty", children: options.length === 0 ? emptyText ?? "No matches" : "No matches" }), shown.map((o, i) => (_jsx("button", { id: `${id}-opt-${i}`, type: "button", role: "option", "aria-selected": i === active, className: cx("sm-picker__item", i === active && "sm-picker__item--active"), onMouseDown: keepFocus, onClick: () => pick(i), children: renderOption ? renderOption(o) : o.label }, o.value))), create && (_jsxs("button", { id: `${id}-opt-${shown.length}`, type: "button", role: "option", "aria-selected": active === shown.length, className: cx("sm-picker__item", active === shown.length && "sm-picker__item--active"), onMouseDown: keepFocus, onClick: () => pick(shown.length), children: ["Create \u201C", query.trim(), "\u201D"] }))] }))] }));
114
+ const floating = host
115
+ ? { left: place?.left, top: place?.top, bottom: place?.bottom, ["--combo-w"]: place ? `${place.width}px` : undefined, visibility: place ? undefined : "hidden" }
116
+ : undefined;
117
+ const listbox = popover && (_jsxs("div", { ref: list, id: `${id}-list`, role: "listbox", "aria-label": label, onMouseDown: keepFocus, className: cx("sm-picker sm-combo__list", host && "sm-combo__list--floating", listClassName), style: floating, children: [count === 0 && _jsx("div", { className: "sm-combo__empty", children: options.length === 0 ? emptyText ?? "No matches" : "No matches" }), shown.map((o, i) => (_jsx("button", { id: `${id}-opt-${i}`, type: "button", role: "option", "aria-selected": i === active, className: cx("sm-picker__item", i === active && "sm-picker__item--active"), onMouseDown: keepFocus, onClick: () => pick(i), children: renderOption ? renderOption(o) : o.label }, o.value))), create && (_jsxs("button", { id: `${id}-opt-${shown.length}`, type: "button", role: "option", "aria-selected": active === shown.length, className: cx("sm-picker__item", active === shown.length && "sm-picker__item--active"), onMouseDown: keepFocus, onClick: () => pick(shown.length), children: ["Create \u201C", query.trim(), "\u201D"] }))] }));
118
+ return (_jsxs("div", { ref: root, className: cx("sm-combo", suffix != null && "sm-combo--suffix", className), children: [_jsx("input", { className: "sm-input", role: "combobox", "aria-label": label, "aria-autocomplete": "list", "aria-expanded": popover, "aria-controls": `${id}-list`, "aria-activedescendant": popover && active >= 0 ? `${id}-opt-${active}` : undefined, placeholder: placeholder, disabled: disabled, autoFocus: autoFocus, value: typed ?? selected ?? "", onFocus: (e) => { setOpen(true); if (typed === null && selected)
119
+ e.currentTarget.select(); }, onClick: () => setOpen(true), onChange: (e) => { setTyped(onQueryChange ? onQueryChange(e.target.value) : e.target.value); setActive(rest); setOpen(true); }, onKeyDown: onKeyDown, onBlur: (e) => { if (open && !root.current?.contains(e.relatedTarget))
120
+ close("blur"); } }), suffix != null && _jsx("span", { className: "sm-combo__suffix", children: suffix }), _jsx(ChevronDown, { className: "sm-combo__chevron", size: 14, strokeWidth: 1.75, "aria-hidden": "true" }), listbox && host ? createPortal(listbox, host) : listbox] }));
89
121
  }
@@ -2,7 +2,7 @@ import { type ReactElement } from "react";
2
2
  import { type ConfirmOptions } from "./confirm.ts";
3
3
  export declare function ConfirmDialog({ options, onAnswer }: {
4
4
  options: ConfirmOptions;
5
- onAnswer(yes: boolean): void;
5
+ onAnswer(yes: boolean, checked?: boolean): void;
6
6
  }): ReactElement;
7
7
  /** Renders the pending question, once, from the shell. */
8
8
  export declare function ConfirmHost(): ReactElement | null;
@@ -5,21 +5,22 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
5
5
  // the consequence, an optional context chip naming the subject, then
6
6
  // Cancel and the action verb. Focus opens on Cancel for a destructive
7
7
  // action and on the primary otherwise, so Enter runs the primary only
8
- // when it is safe.
8
+ // when it is safe. A `single` dialog (notify) has the primary alone.
9
9
  //
10
10
  // <ConfirmHost/> renders the pending question from ui/confirm.ts, once,
11
11
  // from the shell; the dialog itself is a plain component so a test can
12
12
  // render it statically.
13
- import { useId, useSyncExternalStore } from "react";
13
+ import { useId, useState, useSyncExternalStore } from "react";
14
14
  import { DialogCard } from "./Dialog.js";
15
15
  import { Button } from "./primitives.js";
16
16
  import { pendingConfirm, subscribeConfirm } from "./confirm.js";
17
17
  export function ConfirmDialog({ options, onAnswer }) {
18
- const { title, body, confirmLabel, cancelLabel = "Cancel", destructive = false, subject } = options;
18
+ const { title, body, confirmLabel, cancelLabel = "Cancel", destructive = false, subject, option, single = false } = options;
19
+ const [checked, setChecked] = useState(option?.checked ?? false);
19
20
  const uid = useId();
20
21
  const bodyId = `sm-confirm-body${uid}`;
21
22
  const cancel = () => onAnswer(false);
22
- return (_jsxs(DialogCard, { title: title, role: "alertdialog", describedBy: body ? bodyId : undefined, onClose: cancel, className: "sm-confirm", actions: _jsxs(_Fragment, { children: [_jsx(Button, { variant: "secondary", autoFocus: destructive, onClick: cancel, children: cancelLabel }), _jsx(Button, { variant: destructive ? "destructive" : "primary", autoFocus: !destructive, onClick: () => onAnswer(true), children: confirmLabel })] }), children: [body && _jsx("p", { className: "sm-dialog__body", id: bodyId, children: body }), subject && (_jsxs("div", { className: "sm-dialog__context", children: [_jsx("strong", { children: subject.name }), subject.path && _jsx("code", { children: subject.path })] }))] }));
23
+ return (_jsxs(DialogCard, { title: title, role: "alertdialog", describedBy: body ? bodyId : undefined, onClose: cancel, className: "sm-confirm", actions: _jsxs(_Fragment, { children: [!single && _jsx(Button, { variant: "secondary", autoFocus: destructive, onClick: cancel, children: cancelLabel }), _jsx(Button, { variant: destructive ? "destructive" : "primary", autoFocus: !destructive, onClick: () => onAnswer(true, checked), children: confirmLabel })] }), children: [body && _jsx("p", { className: "sm-dialog__body", id: bodyId, children: body }), option && (_jsxs("label", { className: "sm-choice sm-confirm__option", children: [_jsx("input", { type: "checkbox", checked: checked, onChange: (e) => setChecked(e.target.checked) }), _jsx("span", { children: option.label })] })), subject && (_jsxs("div", { className: "sm-dialog__context", children: [_jsx("strong", { children: subject.name }), subject.path && _jsx("code", { children: subject.path })] }))] }));
23
24
  }
24
25
  /** Renders the pending question, once, from the shell. */
25
26
  export function ConfirmHost() {
@@ -0,0 +1,6 @@
1
+ import type { ReactElement } from "react";
2
+ import type { Finding } from "../drift.ts";
3
+ export declare function DriftNotice({ draft, live }: {
4
+ draft: Finding[];
5
+ live: Finding[];
6
+ }): ReactElement | null;
@@ -0,0 +1,10 @@
1
+ "use client";
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { driftNotice } from "../drift-copy.js";
4
+ import { Banner } from "./primitives.js";
5
+ export function DriftNotice({ draft, live }) {
6
+ const { summary, liveOnly, sentences } = driftNotice(draft, live);
7
+ if (!summary && !liveOnly && sentences.length === 0)
8
+ return null;
9
+ return (_jsx("div", { className: "sm-drift", "data-testid": "drift-notice", children: _jsxs(Banner, { tone: "warn", children: [summary && _jsx("p", { style: { margin: 0 }, children: summary }), liveOnly && _jsx("p", { style: { margin: 0 }, children: liveOnly }), sentences.map((s) => _jsx("p", { style: { margin: 0 }, children: s }, s))] }) }));
10
+ }
@@ -14,3 +14,16 @@ export declare const opensUp: (room: {
14
14
  above: number;
15
15
  height: number;
16
16
  }) => boolean;
17
+ /** Where the floating list sits (fixed, in viewport pixels): under the input at its width,
18
+ * or above it, anchored by its bottom edge, when opensUp says so. */
19
+ export declare function listPlacement(box: {
20
+ top: number;
21
+ bottom: number;
22
+ left: number;
23
+ width: number;
24
+ }, viewport: number, height: number): {
25
+ left: number;
26
+ width: number;
27
+ top?: number;
28
+ bottom?: number;
29
+ };
@@ -19,3 +19,11 @@ export function stepActive(active, count, dir) {
19
19
  }
20
20
  /** Whether the list hangs above the input: only when it does not fit below and there is more room above. */
21
21
  export const opensUp = (room) => room.height > room.below && room.above > room.below;
22
+ /** Where the floating list sits (fixed, in viewport pixels): under the input at its width,
23
+ * or above it, anchored by its bottom edge, when opensUp says so. */
24
+ export function listPlacement(box, viewport, height) {
25
+ const at = { left: box.left, width: box.width };
26
+ return opensUp({ below: viewport - box.bottom - 4, above: box.top - 4, height })
27
+ ? { ...at, bottom: viewport - box.top + 4 }
28
+ : { ...at, top: box.bottom + 4 };
29
+ }
@@ -17,19 +17,38 @@ export type ConfirmOptions = {
17
17
  destructive?: boolean;
18
18
  /** The context chip repeating the subject; omit when there is no single subject. */
19
19
  subject?: ConfirmSubject;
20
+ /** A yes/no rider under the body — "Redirect the old address" (spec 2026-09-26 redirects §6); `checked` is its initial state. */
21
+ option?: {
22
+ label: string;
23
+ checked: boolean;
24
+ };
25
+ /** One OK button, no Cancel — notify's dialog (spec 2026-09-27 editor safety §7). */
26
+ single?: boolean;
27
+ };
28
+ export type ConfirmAnswer = {
29
+ yes: boolean;
30
+ checked: boolean;
20
31
  };
21
32
  export type ConfirmRequest = {
22
33
  id: number;
23
34
  options: ConfirmOptions;
24
- answer(yes: boolean): void;
35
+ answer(yes: boolean, checked?: boolean): void;
25
36
  };
26
37
  /** The Discard changes question, shared by the page editor and the entry and global forms (publish flow §4). */
27
38
  export declare const DISCARD_CONFIRM: Omit<ConfirmOptions, "subject">;
39
+ /** Ask; resolves with the answer and the option's final state once the dialog closes. */
40
+ export declare function confirmWithOption(options: ConfirmOptions): Promise<ConfirmAnswer>;
28
41
  /** Ask; resolves with the answer once the dialog closes. */
29
- export declare function confirm(options: ConfirmOptions): Promise<boolean>;
42
+ export declare const confirm: (options: ConfirmOptions) => Promise<boolean>;
30
43
  /** The question being asked, if any — the head of the queue. */
31
44
  export declare const pendingConfirm: () => ConfirmRequest | null;
32
45
  /** For useSyncExternalStore: fires on every queue change. */
33
46
  export declare function subscribeConfirm(fn: () => void): () => void;
34
47
  /** Test seam: drop every pending question unanswered. */
35
48
  export declare function resetConfirms(): void;
49
+ /** Tell, don't ask (spec 2026-09-27 editor safety §7): a one-button dialog in place of window.alert,
50
+ * resolved once it is dismissed; with no host mounted, the native alert. */
51
+ export declare function notify(options: {
52
+ title: string;
53
+ message: string;
54
+ }): Promise<void>;
@@ -8,6 +8,9 @@
8
8
  // One question at a time; a second request waits for the first answer.
9
9
  // Without a mounted host (a test, a stray render outside the shell) the
10
10
  // question falls back to window.confirm so no caller can hang.
11
+ //
12
+ // `notify` tells rather than asks (spec 2026-09-27 editor safety §7): the
13
+ // same host, one OK button, window.alert as its no-host fallback.
11
14
  /** The Discard changes question, shared by the page editor and the entry and global forms (publish flow §4). */
12
15
  export const DISCARD_CONFIRM = {
13
16
  title: "Discard unsaved changes?",
@@ -18,31 +21,35 @@ export const DISCARD_CONFIRM = {
18
21
  const listeners = new Set();
19
22
  let queue = [];
20
23
  let seq = 0;
21
- const notify = () => { for (const fn of listeners)
24
+ const emit = () => { for (const fn of listeners)
22
25
  fn(); };
23
- /** Ask; resolves with the answer once the dialog closes. */
24
- export function confirm(options) {
26
+ /** Ask; resolves with the answer and the option's final state once the dialog closes. */
27
+ export function confirmWithOption(options) {
28
+ const initial = options.option?.checked ?? false;
25
29
  if (listeners.size === 0) {
26
30
  const text = [options.title, options.body].filter(Boolean).join("\n\n");
27
- return Promise.resolve(typeof window !== "undefined" && typeof window.confirm === "function" ? window.confirm(text) : false);
31
+ const yes = typeof window !== "undefined" && typeof window.confirm === "function" ? window.confirm(text) : false;
32
+ return Promise.resolve({ yes, checked: initial });
28
33
  }
29
34
  return new Promise((resolve) => {
30
35
  const id = ++seq;
31
36
  const request = {
32
37
  id,
33
38
  options,
34
- answer(yes) {
39
+ answer(yes, checked = initial) {
35
40
  if (!queue.some((r) => r.id === id))
36
41
  return; // answered already
37
42
  queue = queue.filter((r) => r.id !== id);
38
- notify();
39
- resolve(yes);
43
+ emit();
44
+ resolve({ yes, checked });
40
45
  },
41
46
  };
42
47
  queue = [...queue, request];
43
- notify();
48
+ emit();
44
49
  });
45
50
  }
51
+ /** Ask; resolves with the answer once the dialog closes. */
52
+ export const confirm = (options) => confirmWithOption(options).then((a) => a.yes);
46
53
  /** The question being asked, if any — the head of the queue. */
47
54
  export const pendingConfirm = () => queue[0] ?? null;
48
55
  /** For useSyncExternalStore: fires on every queue change. */
@@ -53,5 +60,15 @@ export function subscribeConfirm(fn) {
53
60
  /** Test seam: drop every pending question unanswered. */
54
61
  export function resetConfirms() {
55
62
  queue = [];
56
- notify();
63
+ emit();
64
+ }
65
+ /** Tell, don't ask (spec 2026-09-27 editor safety §7): a one-button dialog in place of window.alert,
66
+ * resolved once it is dismissed; with no host mounted, the native alert. */
67
+ export function notify(options) {
68
+ if (listeners.size === 0) {
69
+ if (typeof window !== "undefined" && typeof window.alert === "function")
70
+ window.alert(`${options.title}\n\n${options.message}`);
71
+ return Promise.resolve();
72
+ }
73
+ return confirmWithOption({ title: options.title, body: options.message, confirmLabel: "OK", single: true }).then(() => undefined);
57
74
  }