smoodly 0.0.24 → 0.0.25

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 (115) hide show
  1. package/README.md +182 -40
  2. package/bin/smoodly.js +6 -0
  3. package/dist/admin/client-ops.js +1 -0
  4. package/dist/admin/editor/EditorView.js +20 -6
  5. package/dist/admin/editor/InsertPicker.d.ts +14 -0
  6. package/dist/admin/editor/InsertPicker.js +37 -7
  7. package/dist/admin/editor/InspectorFields.js +1 -1
  8. package/dist/admin/editor/Outline.js +2 -2
  9. package/dist/admin/editor/PageSettings.js +1 -1
  10. package/dist/admin/editor/SeoSettings.js +1 -1
  11. package/dist/admin/editor/locale-url.d.ts +7 -4
  12. package/dist/admin/editor/locale-url.js +14 -7
  13. package/dist/admin/editor/usePageLocale.js +9 -1
  14. package/dist/admin/forms/CropEditor.d.ts +4 -2
  15. package/dist/admin/forms/CropEditor.js +6 -3
  16. package/dist/admin/forms/DatePicker.d.ts +9 -0
  17. package/dist/admin/forms/DatePicker.js +133 -0
  18. package/dist/admin/forms/FieldWidget.js +11 -5
  19. package/dist/admin/forms/ImagePreview.d.ts +9 -0
  20. package/dist/admin/forms/ImagePreview.js +11 -0
  21. package/dist/admin/forms/MediaWidget.d.ts +8 -2
  22. package/dist/admin/forms/MediaWidget.js +13 -13
  23. package/dist/admin/forms/RichtextElementCard.js +1 -1
  24. package/dist/admin/forms/RichtextImageCard.js +14 -9
  25. package/dist/admin/forms/date-picker.d.ts +44 -0
  26. package/dist/admin/forms/date-picker.js +117 -0
  27. package/dist/admin/forms/elements-context.d.ts +3 -0
  28. package/dist/admin/forms/elements-context.js +7 -2
  29. package/dist/admin/forms/time-zone-context.d.ts +7 -0
  30. package/dist/admin/forms/time-zone-context.js +15 -0
  31. package/dist/admin/forms/useRichtextEditor.js +10 -1
  32. package/dist/admin/next/bridge.d.ts +2 -1
  33. package/dist/admin/next/bridge.js +35 -5
  34. package/dist/admin/ops-impl.js +4 -0
  35. package/dist/admin/ops.d.ts +9 -0
  36. package/dist/admin/serialize.d.ts +2 -0
  37. package/dist/admin/serialize.js +1 -0
  38. package/dist/admin/shell/AdminApp.js +23 -1
  39. package/dist/admin/shell/EntriesList.js +12 -21
  40. package/dist/admin/shell/EntryForm.js +48 -7
  41. package/dist/admin/shell/GlobalForm.js +22 -2
  42. package/dist/admin/shell/GlobalList.js +4 -18
  43. package/dist/admin/shell/IconRail.js +14 -5
  44. package/dist/admin/shell/InviteModal.js +9 -5
  45. package/dist/admin/shell/ListView.js +2 -2
  46. package/dist/admin/shell/MediaScreen.js +11 -1
  47. package/dist/admin/shell/MembersPage.js +33 -15
  48. package/dist/admin/shell/PageDialogs.d.ts +26 -0
  49. package/dist/admin/shell/PageDialogs.js +38 -0
  50. package/dist/admin/shell/PagesList.js +96 -73
  51. package/dist/admin/shell/RouteLink.js +1 -1
  52. package/dist/admin/shell/SchemaNotice.d.ts +4 -0
  53. package/dist/admin/shell/SchemaNotice.js +4 -0
  54. package/dist/admin/shell/SettingsView.js +11 -26
  55. package/dist/admin/shell/live-url.d.ts +4 -3
  56. package/dist/admin/shell/live-url.js +12 -3
  57. package/dist/admin/shell/locale-memory.d.ts +8 -0
  58. package/dist/admin/shell/locale-memory.js +23 -0
  59. package/dist/admin/shell/members-list.d.ts +2 -0
  60. package/dist/admin/shell/members-list.js +5 -0
  61. package/dist/admin/shell/page-dialogs.d.ts +9 -0
  62. package/dist/admin/shell/page-dialogs.js +19 -0
  63. package/dist/admin/shell/router.d.ts +10 -7
  64. package/dist/admin/shell/router.js +18 -9
  65. package/dist/admin/shell/settings.d.ts +0 -6
  66. package/dist/admin/shell/settings.js +4 -5
  67. package/dist/admin/tree-ops.js +3 -0
  68. package/dist/admin/ui/ActionMenu.js +26 -6
  69. package/dist/admin/ui/Combobox.d.ts +5 -0
  70. package/dist/admin/ui/Combobox.js +1 -1
  71. package/dist/admin/ui/ConfirmDialog.d.ts +8 -0
  72. package/dist/admin/ui/ConfirmDialog.js +30 -0
  73. package/dist/admin/ui/Dialog.d.ts +13 -0
  74. package/dist/admin/ui/Dialog.js +54 -0
  75. package/dist/admin/ui/HistoryModal.d.ts +2 -1
  76. package/dist/admin/ui/HistoryModal.js +7 -2
  77. package/dist/admin/ui/ResizeHandle.js +4 -4
  78. package/dist/admin/ui/confirm.d.ts +35 -0
  79. package/dist/admin/ui/confirm.js +57 -0
  80. package/dist/admin/ui/primitives.d.ts +19 -4
  81. package/dist/admin/ui/primitives.js +11 -5
  82. package/dist/admin/ui/resize.d.ts +7 -4
  83. package/dist/admin/ui/resize.js +7 -4
  84. package/dist/admin/ui/theme.d.ts +1 -1
  85. package/dist/admin/ui/theme.js +368 -203
  86. package/dist/admin/validate.d.ts +4 -0
  87. package/dist/admin/validate.js +28 -0
  88. package/dist/cli/load-collections.d.ts +2 -0
  89. package/dist/cli/load-collections.js +26 -0
  90. package/dist/cli/main.d.ts +13 -0
  91. package/dist/cli/main.js +62 -0
  92. package/dist/cli/migrate.d.ts +23 -0
  93. package/dist/cli/migrate.js +64 -0
  94. package/dist/config.d.ts +4 -0
  95. package/dist/config.js +9 -0
  96. package/dist/fields.d.ts +2 -0
  97. package/dist/fields.js +2 -0
  98. package/dist/index.d.ts +2 -1
  99. package/dist/index.js +2 -1
  100. package/dist/marks.d.ts +3 -0
  101. package/dist/marks.js +3 -0
  102. package/dist/migrations.d.ts +37 -0
  103. package/dist/migrations.js +73 -0
  104. package/dist/schema-version.d.ts +17 -0
  105. package/dist/schema-version.js +32 -0
  106. package/dist/sql-space.js +6 -0
  107. package/dist/sql.js +10 -0
  108. package/dist/store.d.ts +3 -0
  109. package/dist/store.js +4 -0
  110. package/dist/supabase-store.d.ts +3 -0
  111. package/dist/supabase-store.js +23 -0
  112. package/dist/time-zone.d.ts +6 -0
  113. package/dist/time-zone.js +27 -0
  114. package/migrations/20260926120000_smoodly_v1.sql +535 -0
  115. package/package.json +13 -3
@@ -0,0 +1,9 @@
1
+ /** A URL segment from a title: lowercase ASCII letters, digits and
2
+ * hyphens (the path grammar, DESIGN.md OQ 24), diacritics stripped
3
+ * (ä → a, é → e), everything else a hyphen, no leading, trailing or
4
+ * doubled hyphens. */
5
+ export declare function slugify(title: string): string;
6
+ /** The slug field follows the title until the editor types a slug of
7
+ * their own; a slug equal to what the previous title produced is still
8
+ * "following". */
9
+ export declare function followsTitle(slug: string, previousTitle: string): boolean;
@@ -0,0 +1,19 @@
1
+ // Pure helpers for the page dialogs (PageDialogs.tsx).
2
+ /** A URL segment from a title: lowercase ASCII letters, digits and
3
+ * hyphens (the path grammar, DESIGN.md OQ 24), diacritics stripped
4
+ * (ä → a, é → e), everything else a hyphen, no leading, trailing or
5
+ * doubled hyphens. */
6
+ export function slugify(title) {
7
+ return title
8
+ .normalize("NFD")
9
+ .replace(/[̀-ͯ]/g, "")
10
+ .toLowerCase()
11
+ .replace(/[^a-z0-9]+/g, "-")
12
+ .replace(/^-+|-+$/g, "");
13
+ }
14
+ /** The slug field follows the title until the editor types a slug of
15
+ * their own; a slug equal to what the previous title produced is still
16
+ * "following". */
17
+ export function followsTitle(slug, previousTitle) {
18
+ return slug === "" || slug === slugify(previousTitle);
19
+ }
@@ -1,3 +1,4 @@
1
+ import { type ConfirmOptions } from "../ui/confirm.ts";
1
2
  export type Route = {
2
3
  pathname: string;
3
4
  segments: string[];
@@ -18,18 +19,20 @@ export declare function isPlainLeftClick(e: {
18
19
  altKey: boolean;
19
20
  defaultPrevented: boolean;
20
21
  }, target?: string | null): boolean;
21
- export declare const LEAVE_MESSAGE = "Discard unsaved changes?";
22
+ /** The leave question, asked in the admin's own dialog (ui/confirm.ts). */
23
+ export declare const LEAVE_CONFIRM: ConfirmOptions;
22
24
  /** Leaving is allowed when no guard is dirty, else when the editor
23
25
  * confirms — asked once, however many guards are dirty. */
24
- export declare function mayLeave(guards: Iterable<() => boolean>, confirm: (message: string) => boolean): boolean;
25
- /** Move to `href` in place; true when the move happened. A push asks the
26
- * leave guard; a replace never leaves the view, so it does not ask.
27
- * `force` skips the guard for a view that has just saved or deleted its
28
- * record — React has not flushed its setDirty(false) yet. */
26
+ export declare function mayLeave(guards: Iterable<() => boolean>, confirm: (options: ConfirmOptions) => boolean | Promise<boolean>): Promise<boolean>;
27
+ /** Move to `href` in place; resolves true when the move happened. A push
28
+ * asks the leave guard (in the admin's dialog, so the answer is awaited);
29
+ * a replace never leaves the view, so it does not ask. `force` skips the
30
+ * guard for a view that has just saved or deleted its record — React has
31
+ * not flushed its setDirty(false) yet. */
29
32
  export declare function navigate(href: string, opts?: {
30
33
  replace?: boolean;
31
34
  force?: boolean;
32
- }): boolean;
35
+ }): Promise<boolean>;
33
36
  /** The current route under `base`: empty until mount, then the URL,
34
37
  * re-read on popstate and after navigate(). */
35
38
  export declare function useRoute(base: string): Route;
@@ -10,6 +10,7 @@
10
10
  // framework-agnostic.
11
11
  import { useEffect, useState } from "react";
12
12
  import { DEFAULT_ADMIN_PATH } from "../../admin-path.js";
13
+ import { confirm } from "../ui/confirm.js";
13
14
  /** The segments under `base`: "/admin/articles/x" → ["articles", "x"];
14
15
  * the mount itself, or a pathname outside it, → []. Segments stay as
15
16
  * written; the shell decodes the ones it uses, as it does for the
@@ -28,13 +29,20 @@ export function isPlainLeftClick(e, target) {
28
29
  return false;
29
30
  return !target || target === "_self";
30
31
  }
31
- export const LEAVE_MESSAGE = "Discard unsaved changes?";
32
+ /** The leave question, asked in the admin's own dialog (ui/confirm.ts). */
33
+ export const LEAVE_CONFIRM = {
34
+ title: "Discard unsaved changes?",
35
+ body: "Your latest edits have not been saved and will be lost if you leave now.",
36
+ confirmLabel: "Discard changes",
37
+ cancelLabel: "Stay",
38
+ destructive: true,
39
+ };
32
40
  /** Leaving is allowed when no guard is dirty, else when the editor
33
41
  * confirms — asked once, however many guards are dirty. */
34
- export function mayLeave(guards, confirm) {
42
+ export async function mayLeave(guards, confirm) {
35
43
  for (const dirty of guards)
36
44
  if (dirty())
37
- return confirm(LEAVE_MESSAGE);
45
+ return confirm(LEAVE_CONFIRM);
38
46
  return true;
39
47
  }
40
48
  // ── the live router ──────────────────────────────────────────────────
@@ -47,12 +55,13 @@ function currentRoute(base) {
47
55
  // The segments derive from the pathname and a base that never changes
48
56
  // under a mounted shell, so two strings decide sameness.
49
57
  const sameRoute = (a, b) => a.pathname === b.pathname && a.search === b.search;
50
- /** Move to `href` in place; true when the move happened. A push asks the
51
- * leave guard; a replace never leaves the view, so it does not ask.
52
- * `force` skips the guard for a view that has just saved or deleted its
53
- * record — React has not flushed its setDirty(false) yet. */
54
- export function navigate(href, opts = {}) {
55
- if (!opts.replace && !opts.force && !mayLeave(guards, (m) => window.confirm(m)))
58
+ /** Move to `href` in place; resolves true when the move happened. A push
59
+ * asks the leave guard (in the admin's dialog, so the answer is awaited);
60
+ * a replace never leaves the view, so it does not ask. `force` skips the
61
+ * guard for a view that has just saved or deleted its record — React has
62
+ * not flushed its setDirty(false) yet. */
63
+ export async function navigate(href, opts = {}) {
64
+ if (!opts.replace && !opts.force && !(await mayLeave(guards, confirm)))
56
65
  return false;
57
66
  window.history[opts.replace ? "replaceState" : "pushState"](null, "", href);
58
67
  for (const listener of listeners)
@@ -1,12 +1,6 @@
1
1
  export declare const SETTINGS_PAGES: readonly [{
2
2
  readonly id: "members";
3
3
  readonly title: "Members";
4
- }, {
5
- readonly id: "roles";
6
- readonly title: "Roles";
7
- }, {
8
- readonly id: "api-keys";
9
- readonly title: "API Keys";
10
4
  }];
11
5
  export type SettingsPageId = (typeof SETTINGS_PAGES)[number]["id"];
12
6
  export declare const DEFAULT_SETTINGS_PAGE: SettingsPageId;
@@ -1,10 +1,9 @@
1
- // The Settings area's pages (spec 2026-09-25 §5): ids are URL segments
2
- // under /settings, titles are what the secondary nav and each top bar
3
- // show. Pure, so routing is testable without React.
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.
4
5
  export const SETTINGS_PAGES = [
5
6
  { id: "members", title: "Members" },
6
- { id: "roles", title: "Roles" },
7
- { id: "api-keys", title: "API Keys" },
8
7
  ];
9
8
  export const DEFAULT_SETTINGS_PAGE = "members";
10
9
  /** The page for the segment after /settings: the default for none, null for an unknown one. */
@@ -133,6 +133,9 @@ export function nodeFromSample(section, id) {
133
133
  fields[key] = sampled;
134
134
  else if (ABSENT_WHEN_UNSAMPLED.has(d.type))
135
135
  continue;
136
+ // An optional object with no sample is absent: `{}` would read as present, its members required at publish.
137
+ else if (d.type === "object" && d.optional === true)
138
+ continue;
136
139
  else
137
140
  fields[key] = EMPTY_BY_TYPE[d.type] ?? "";
138
141
  }
@@ -5,22 +5,42 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
5
5
  // not apply — the layout never shifts and the editor learns what exists.
6
6
  // The popover is the Pages list's row menu shape (`.sm-picker`), anchored
7
7
  // under the button instead of at the pointer, hanging from the button's
8
- // right edge (every control sits at its bar's or card's right).
8
+ // right edge (every control sits at its bar's or card's right). It is
9
+ // position:fixed from the button's rect (2026-09-26): inside a table cell
10
+ // an absolute child was clipped by the cell's overflow, so the Members
11
+ // row menu opened invisibly. A scroll closes it rather than leaving it behind.
9
12
  import { useEffect, useRef, useState } from "react";
10
13
  import { cx } from "./primitives.js";
11
14
  export function ActionMenu({ label, items, disabled, small = false }) {
12
- const [open, setOpen] = useState(false);
15
+ /** Open: the popover's viewport position, under the button's right edge. */
16
+ const [open, setOpen] = useState(null);
13
17
  const ref = useRef(null);
18
+ const button = useRef(null);
19
+ const toggle = () => {
20
+ if (open) {
21
+ setOpen(null);
22
+ return;
23
+ }
24
+ const r = button.current?.getBoundingClientRect();
25
+ if (r)
26
+ setOpen({ top: r.bottom + 6, right: window.innerWidth - r.right });
27
+ };
14
28
  useEffect(() => {
15
29
  if (!open)
16
30
  return;
31
+ const close = () => setOpen(null);
17
32
  const onDown = (e) => { if (!ref.current?.contains(e.target))
18
- setOpen(false); };
33
+ close(); };
19
34
  const onKey = (e) => { if (e.key === "Escape")
20
- setOpen(false); };
35
+ close(); };
21
36
  document.addEventListener("pointerdown", onDown);
22
37
  document.addEventListener("keydown", onKey);
23
- return () => { document.removeEventListener("pointerdown", onDown); document.removeEventListener("keydown", onKey); };
38
+ document.addEventListener("scroll", close, true);
39
+ return () => {
40
+ document.removeEventListener("pointerdown", onDown);
41
+ document.removeEventListener("keydown", onKey);
42
+ document.removeEventListener("scroll", close, true);
43
+ };
24
44
  }, [open]);
25
- return (_jsxs("div", { ref: ref, style: { position: "relative", flex: "none" }, children: [_jsx("button", { type: "button", className: cx("sm-dotmenu", small && "sm-dotmenu--sm"), "aria-label": label, title: label, "aria-haspopup": "menu", "aria-expanded": open, disabled: disabled, onClick: () => setOpen((o) => !o), children: "\u00B7\u00B7\u00B7" }), open && (_jsx("div", { role: "menu", className: "sm-picker sm-picker--menu", children: items.map((item) => (_jsx("button", { type: "button", role: "menuitem", className: cx("sm-picker__item", item.danger && "sm-picker__item--danger"), disabled: item.disabled, onClick: () => { setOpen(false); item.run(); }, children: item.label }, item.label))) }))] }));
45
+ return (_jsxs("div", { ref: ref, style: { position: "relative", flex: "none" }, children: [_jsx("button", { ref: button, type: "button", className: cx("sm-dotmenu", small && "sm-dotmenu--sm"), "aria-label": label, title: label, "aria-haspopup": "menu", "aria-expanded": open !== null, disabled: disabled, onClick: toggle, children: "\u00B7\u00B7\u00B7" }), open && (_jsx("div", { role: "menu", className: "sm-picker sm-picker--menu", style: { top: open.top, right: open.right }, children: items.map((item) => (_jsx("button", { type: "button", role: "menuitem", className: cx("sm-picker__item", item.danger && "sm-picker__item--danger"), disabled: item.disabled, onClick: () => { setOpen(null); item.run(); }, children: item.label }, item.label))) }))] }));
26
46
  }
@@ -27,4 +27,9 @@ export type ComboboxProps = {
27
27
  initialOpen?: boolean;
28
28
  className?: string;
29
29
  };
30
+ /** The vertical band the list may occupy: the nearest clipping ancestor's box, cut to the viewport. */
31
+ export declare function clipBand(el: HTMLElement): {
32
+ top: number;
33
+ bottom: number;
34
+ };
30
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;
@@ -11,7 +11,7 @@ import { cx } from "./primitives.js";
11
11
  import { filterOptions, opensUp, showsCreate, stepActive } from "./combo-box.js";
12
12
  const keepFocus = (e) => e.preventDefault();
13
13
  /** The vertical band the list may occupy: the nearest clipping ancestor's box, cut to the viewport. */
14
- function clipBand(el) {
14
+ export function clipBand(el) {
15
15
  let top = 0;
16
16
  let bottom = window.innerHeight;
17
17
  for (let p = el.parentElement; p; p = p.parentElement) {
@@ -0,0 +1,8 @@
1
+ import { type ReactElement } from "react";
2
+ import { type ConfirmOptions } from "./confirm.ts";
3
+ export declare function ConfirmDialog({ options, onAnswer }: {
4
+ options: ConfirmOptions;
5
+ onAnswer(yes: boolean): void;
6
+ }): ReactElement;
7
+ /** Renders the pending question, once, from the shell. */
8
+ export declare function ConfirmHost(): ReactElement | null;
@@ -0,0 +1,30 @@
1
+ "use client";
2
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
3
+ // The confirm question on the dialog card (ui/Dialog.tsx; the pattern:
4
+ // design_handoff_smoodly_neutral/screens/ConfirmModal.md): the question,
5
+ // the consequence, an optional context chip naming the subject, then
6
+ // Cancel and the action verb. Focus opens on Cancel for a destructive
7
+ // action and on the primary otherwise, so Enter runs the primary only
8
+ // when it is safe.
9
+ //
10
+ // <ConfirmHost/> renders the pending question from ui/confirm.ts, once,
11
+ // from the shell; the dialog itself is a plain component so a test can
12
+ // render it statically.
13
+ import { useId, useSyncExternalStore } from "react";
14
+ import { DialogCard } from "./Dialog.js";
15
+ import { Button } from "./primitives.js";
16
+ import { pendingConfirm, subscribeConfirm } from "./confirm.js";
17
+ export function ConfirmDialog({ options, onAnswer }) {
18
+ const { title, body, confirmLabel, cancelLabel = "Cancel", destructive = false, subject } = options;
19
+ const uid = useId();
20
+ const bodyId = `sm-confirm-body${uid}`;
21
+ 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
+ }
24
+ /** Renders the pending question, once, from the shell. */
25
+ export function ConfirmHost() {
26
+ const request = useSyncExternalStore(subscribeConfirm, pendingConfirm, () => null);
27
+ if (!request)
28
+ return null;
29
+ return _jsx(ConfirmDialog, { options: request.options, onAnswer: request.answer }, request.id);
30
+ }
@@ -0,0 +1,13 @@
1
+ import { type ReactElement, type ReactNode } from "react";
2
+ export declare function DialogCard({ title, role, describedBy, onClose, actions, className, children }: {
3
+ title: string;
4
+ /** `alertdialog` for a question that interrupts (the confirm); `dialog` for a form. */
5
+ role?: "dialog" | "alertdialog";
6
+ /** The id of the element that describes the dialog (a confirm's body). */
7
+ describedBy?: string;
8
+ onClose(): void;
9
+ /** The action row: Cancel then the verb. */
10
+ actions: ReactNode;
11
+ className?: string;
12
+ children: ReactNode;
13
+ }): ReactElement;
@@ -0,0 +1,54 @@
1
+ "use client";
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ // The dialog card (design_handoff_smoodly_neutral/components/ConfirmModal.html):
4
+ // a scrim over the whole admin, a 440px card with a title row and a close
5
+ // button, whatever the dialog puts inside, then its actions. Escape and
6
+ // the scrim close it; Tab cycles inside; focus returns to where it was.
7
+ // The confirm question (ConfirmDialog) and the page form dialogs
8
+ // (shell/PageDialogs) are built on it. Portalled into the admin wrapper
9
+ // so it covers the frame and any modal underneath; static rendering (no
10
+ // document) keeps it inline.
11
+ import { useEffect, useId, useRef } from "react";
12
+ import { createPortal } from "react-dom";
13
+ import { cx } from "./primitives.js";
14
+ function adminRoot() {
15
+ if (typeof document === "undefined")
16
+ return null;
17
+ return document.querySelector(".sm-admin") ?? document.body;
18
+ }
19
+ export function DialogCard({ title, role = "dialog", describedBy, onClose, actions, className, children }) {
20
+ const panel = useRef(null);
21
+ const uid = useId();
22
+ const titleId = `sm-dialog-title${uid}`;
23
+ useEffect(() => {
24
+ const before = typeof document !== "undefined" ? document.activeElement : null;
25
+ const onKey = (e) => { if (e.key === "Escape") {
26
+ e.stopPropagation();
27
+ onClose();
28
+ } };
29
+ window.addEventListener("keydown", onKey, true);
30
+ return () => {
31
+ window.removeEventListener("keydown", onKey, true);
32
+ before?.focus?.();
33
+ };
34
+ }, [onClose]);
35
+ const trap = (e) => {
36
+ if (e.key !== "Tab" || !panel.current)
37
+ return;
38
+ const focusable = Array.from(panel.current.querySelectorAll("button:not(:disabled),input:not(:disabled),select:not(:disabled),textarea:not(:disabled)"));
39
+ if (focusable.length === 0)
40
+ return;
41
+ const first = focusable[0], last = focusable[focusable.length - 1];
42
+ if (e.shiftKey && document.activeElement === first) {
43
+ e.preventDefault();
44
+ last.focus();
45
+ }
46
+ else if (!e.shiftKey && document.activeElement === last) {
47
+ e.preventDefault();
48
+ first.focus();
49
+ }
50
+ };
51
+ const dialog = (_jsx("div", { className: "sm-scrim", onClick: onClose, children: _jsxs("div", { className: cx("sm-dialog", className), role: role, "aria-modal": "true", "aria-labelledby": titleId, "aria-describedby": describedBy, ref: panel, onClick: (e) => e.stopPropagation(), onKeyDown: trap, children: [_jsx("div", { className: "sm-dialog__head", children: _jsxs("div", { className: "sm-dialog__titlerow", children: [_jsx("h2", { className: "sm-dialog__title", id: titleId, children: title }), _jsx("button", { type: "button", className: "sm-dialog__close", "aria-label": "Close", onClick: onClose, children: "\u00D7" })] }) }), children, _jsx("div", { className: "sm-dialog__actions", children: actions })] }) }));
52
+ const root = adminRoot();
53
+ return root ? createPortal(dialog, root) : dialog;
54
+ }
@@ -1,11 +1,12 @@
1
1
  import { type ReactElement, type ReactNode } from "react";
2
2
  import type { PageLocale } from "../../store.ts";
3
3
  import type { VersionSummary } from "../ops.ts";
4
+ import { type ConfirmOptions } from "./confirm.ts";
4
5
  export type HistoryTag = "Draft" | "Live" | null;
5
6
  export type HistoryRow = VersionSummary & {
6
7
  tag: HistoryTag;
7
8
  };
8
- export declare const RESTORE_CONFIRM = "Restore this version? The current draft will be replaced. Nothing goes live until you publish.";
9
+ export declare const RESTORE_CONFIRM: ConfirmOptions;
9
10
  /** The rows for a locale's versions: the unpublished draft is Draft, the
10
11
  * row the site serves Live, an older publish carries nothing. */
11
12
  export declare function historyRows(versions: VersionSummary[], row: Pick<PageLocale, "status" | "draftVersionId" | "publishedVersionId">): HistoryRow[];
@@ -9,8 +9,13 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
9
9
  import { useEffect, useState } from "react";
10
10
  import { versionTime } from "./format.js";
11
11
  import { Modal } from "./Modal.js";
12
+ import { confirm } from "./confirm.js";
12
13
  import { Button, cx } from "./primitives.js";
13
- export const RESTORE_CONFIRM = "Restore this version? The current draft will be replaced. Nothing goes live until you publish.";
14
+ export const RESTORE_CONFIRM = {
15
+ title: "Restore this version?",
16
+ body: "The current draft is replaced by this version. Nothing goes live until you publish.",
17
+ confirmLabel: "Restore",
18
+ };
14
19
  /** The rows for a locale's versions: the unpublished draft is Draft, the
15
20
  * row the site serves Live, an older publish carries nothing. */
16
21
  export function historyRows(versions, row) {
@@ -28,7 +33,7 @@ export function HistoryModal({ rows, draftId, render, onSelect, onRestore, onClo
28
33
  useEffect(() => { if (selected)
29
34
  onSelect?.(selected); }, [selected]); // eslint-disable-line react-hooks/exhaustive-deps
30
35
  const restore = async () => {
31
- if (!selected || !window.confirm(RESTORE_CONFIRM))
36
+ if (!selected || !(await confirm(RESTORE_CONFIRM)))
32
37
  return;
33
38
  setBusy(true);
34
39
  const landed = await onRestore(selected);
@@ -52,7 +52,7 @@ export function ResizeHandle() {
52
52
  const { admin, grid } = parts(el);
53
53
  if (!admin || !grid)
54
54
  return;
55
- const next = clampInspectorW(px, grid.clientWidth);
55
+ const next = clampInspectorW(px, grid.clientWidth, el.offsetWidth);
56
56
  apply(admin, next);
57
57
  remember(next);
58
58
  setNow(next);
@@ -76,7 +76,7 @@ export function ResizeHandle() {
76
76
  return;
77
77
  e.preventDefault();
78
78
  e.currentTarget.setPointerCapture(e.pointerId);
79
- drag.current = { id: e.pointerId, startX: e.clientX, startW: aside.offsetWidth, rowW: grid.clientWidth, last: aside.offsetWidth };
79
+ drag.current = { id: e.pointerId, startX: e.clientX, startW: aside.offsetWidth, rowW: grid.clientWidth, handleW: e.currentTarget.offsetWidth, last: aside.offsetWidth };
80
80
  admin.classList.add("sm-admin--resizing");
81
81
  };
82
82
  const onPointerMove = (e) => {
@@ -87,7 +87,7 @@ export function ResizeHandle() {
87
87
  if (!admin)
88
88
  return;
89
89
  // The handle sits on the inspector's left edge: moving left widens it.
90
- d.last = clampInspectorW(d.startW - (e.clientX - d.startX), d.rowW);
90
+ d.last = clampInspectorW(d.startW - (e.clientX - d.startX), d.rowW, d.handleW);
91
91
  apply(admin, d.last);
92
92
  };
93
93
  // Also fires from releasePointerCapture below, when drag.current is already null.
@@ -116,7 +116,7 @@ export function ResizeHandle() {
116
116
  else if (e.key === "Home")
117
117
  next = MIN_INSPECTOR_W;
118
118
  else if (e.key === "End")
119
- next = maxInspectorW(grid.clientWidth);
119
+ next = maxInspectorW(grid.clientWidth, e.currentTarget.offsetWidth);
120
120
  if (next === null)
121
121
  return;
122
122
  e.preventDefault();
@@ -0,0 +1,35 @@
1
+ export type ConfirmSubject = {
2
+ /** What the action touches — a page title, an entry title, an email. Truncates. */
3
+ name: string;
4
+ /** Its URL when it has one; never truncates. */
5
+ path?: string | null;
6
+ };
7
+ export type ConfirmOptions = {
8
+ /** The question, as a question: "Delete this page?" */
9
+ title: string;
10
+ /** The consequence in one or two plain sentences. */
11
+ body?: string;
12
+ /** The action verb on the primary button: "Delete page", never "OK". */
13
+ confirmLabel: string;
14
+ /** "Cancel" unless the action needs another word. */
15
+ cancelLabel?: string;
16
+ /** A destructive action: red primary, and focus lands on Cancel. */
17
+ destructive?: boolean;
18
+ /** The context chip repeating the subject; omit when there is no single subject. */
19
+ subject?: ConfirmSubject;
20
+ };
21
+ export type ConfirmRequest = {
22
+ id: number;
23
+ options: ConfirmOptions;
24
+ answer(yes: boolean): void;
25
+ };
26
+ /** The Discard changes question, shared by the page editor and the entry and global forms (publish flow §4). */
27
+ export declare const DISCARD_CONFIRM: Omit<ConfirmOptions, "subject">;
28
+ /** Ask; resolves with the answer once the dialog closes. */
29
+ export declare function confirm(options: ConfirmOptions): Promise<boolean>;
30
+ /** The question being asked, if any — the head of the queue. */
31
+ export declare const pendingConfirm: () => ConfirmRequest | null;
32
+ /** For useSyncExternalStore: fires on every queue change. */
33
+ export declare function subscribeConfirm(fn: () => void): () => void;
34
+ /** Test seam: drop every pending question unanswered. */
35
+ export declare function resetConfirms(): void;
@@ -0,0 +1,57 @@
1
+ // The admin's one "are you sure" (design_handoff_smoodly_neutral/screens/
2
+ // ConfirmModal.md): a caller awaits `confirm(...)` and gets a boolean, the
3
+ // way it awaited window.confirm — but the question is answered in
4
+ // Smoodly's own dialog, rendered by <ConfirmHost/> (ConfirmDialog.tsx)
5
+ // from this store. Framework-free on purpose: the router (shell/router.ts)
6
+ // asks from outside React.
7
+ //
8
+ // One question at a time; a second request waits for the first answer.
9
+ // Without a mounted host (a test, a stray render outside the shell) the
10
+ // question falls back to window.confirm so no caller can hang.
11
+ /** The Discard changes question, shared by the page editor and the entry and global forms (publish flow §4). */
12
+ export const DISCARD_CONFIRM = {
13
+ title: "Discard unsaved changes?",
14
+ body: "The draft goes back to the published version. Every edit since the last publish is lost.",
15
+ confirmLabel: "Discard changes",
16
+ destructive: true,
17
+ };
18
+ const listeners = new Set();
19
+ let queue = [];
20
+ let seq = 0;
21
+ const notify = () => { for (const fn of listeners)
22
+ fn(); };
23
+ /** Ask; resolves with the answer once the dialog closes. */
24
+ export function confirm(options) {
25
+ if (listeners.size === 0) {
26
+ 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);
28
+ }
29
+ return new Promise((resolve) => {
30
+ const id = ++seq;
31
+ const request = {
32
+ id,
33
+ options,
34
+ answer(yes) {
35
+ if (!queue.some((r) => r.id === id))
36
+ return; // answered already
37
+ queue = queue.filter((r) => r.id !== id);
38
+ notify();
39
+ resolve(yes);
40
+ },
41
+ };
42
+ queue = [...queue, request];
43
+ notify();
44
+ });
45
+ }
46
+ /** The question being asked, if any — the head of the queue. */
47
+ export const pendingConfirm = () => queue[0] ?? null;
48
+ /** For useSyncExternalStore: fires on every queue change. */
49
+ export function subscribeConfirm(fn) {
50
+ listeners.add(fn);
51
+ return () => { listeners.delete(fn); };
52
+ }
53
+ /** Test seam: drop every pending question unanswered. */
54
+ export function resetConfirms() {
55
+ queue = [];
56
+ notify();
57
+ }
@@ -13,13 +13,16 @@ export declare function Logo({ height }: {
13
13
  * is right on the very first paint with no state and no hydration mismatch. */
14
14
  export declare function ThemeToggle(): import("react").JSX.Element;
15
15
  type ButtonProps = ButtonHTMLAttributes<HTMLButtonElement> & {
16
- variant?: "primary" | "secondary" | "ghost" | "danger" | "ok";
16
+ /** `danger` is the quiet red-text button; `destructive` the filled red primary of a confirm dialog. */
17
+ variant?: "primary" | "secondary" | "ghost" | "danger" | "destructive" | "ok";
17
18
  size?: "sm" | "md" | "lg";
18
19
  };
19
20
  export declare function Button({ variant, size, className, ...rest }: ButtonProps): import("react").JSX.Element;
20
21
  /** The same shape as Button, for navigations that must stay real links. */
21
- export declare function LinkButton({ variant, children, ...rest }: {
22
+ export declare function LinkButton({ variant, icon, children, ...rest }: {
22
23
  variant?: ButtonProps["variant"];
24
+ /** An icon-only pill (a square the button's height); give it an aria-label. */
25
+ icon?: boolean;
23
26
  children: ReactNode;
24
27
  } & AnchorHTMLAttributes<HTMLAnchorElement>): import("react").JSX.Element;
25
28
  export declare function IconButton({ label, tone, size, children, ...rest }: {
@@ -102,6 +105,17 @@ export declare function Segmented<T extends string>({ options, value, onChange,
102
105
  className?: string;
103
106
  } | undefined;
104
107
  }): import("react").JSX.Element;
108
+ /** A pill row (the f.radio look, spec 2026-09-14 §5): no track, the chosen pill filled in the
109
+ * soft accent. For a small choice inside a table row, where a segmented control reads as tabs. */
110
+ export declare function Pills<T extends string>({ options, value, onChange, disabled, title, label }: {
111
+ options: readonly T[];
112
+ value: T;
113
+ onChange(v: T): void;
114
+ disabled?: boolean;
115
+ /** Why the row is off, on every pill. */
116
+ title?: string;
117
+ label?(o: T): ReactNode;
118
+ }): import("react").JSX.Element;
105
119
  export declare function StatusBadge({ status }: {
106
120
  status: string;
107
121
  }): import("react").JSX.Element;
@@ -109,10 +123,11 @@ export declare function StatusBadge({ status }: {
109
123
  export declare function SaveIndicator({ state }: {
110
124
  state: SaveState;
111
125
  }): import("react").JSX.Element;
112
- export declare function SectionLabel({ children, action, mono, style }: {
126
+ /** `caps`: the overline style — small, uppercase, tracked (2026-09-26; the mono face before). */
127
+ export declare function SectionLabel({ children, action, caps, style }: {
113
128
  children: ReactNode;
114
129
  action?: ReactNode;
115
- mono?: boolean;
130
+ caps?: boolean;
116
131
  style?: CSSProperties;
117
132
  }): import("react").JSX.Element;
118
133
  export declare function Divider({ bleed }: {
@@ -45,8 +45,8 @@ export function Button({ variant = "primary", size = "md", className, ...rest })
45
45
  return (_jsx("button", { type: "button", className: cx("sm-btn", `sm-btn--${variant}`, size === "lg" && "sm-btn--lg", size === "sm" && "sm-btn--sm", className), ...rest }));
46
46
  }
47
47
  /** The same shape as Button, for navigations that must stay real links. */
48
- export function LinkButton({ variant = "secondary", children, ...rest }) {
49
- return _jsx("a", { className: cx("sm-btn", `sm-btn--${variant}`), ...rest, children: children });
48
+ export function LinkButton({ variant = "secondary", icon = false, children, ...rest }) {
49
+ return _jsx("a", { className: cx("sm-btn", `sm-btn--${variant}`, icon && "sm-btn--icon"), ...rest, children: children });
50
50
  }
51
51
  export function IconButton({ label, tone = "default", size = 24, children, ...rest }) {
52
52
  return (_jsx("button", { type: "button", "aria-label": label, title: label, className: cx("sm-iconbtn", tone === "inverse" && "sm-iconbtn--inverse"), style: { width: size, height: size }, ...rest, children: children }));
@@ -105,6 +105,11 @@ export function Segmented({ options, value, onChange, stretch, disabled, label,
105
105
  return (_jsx("button", { type: "button", className: cx("sm-seg__opt", extra?.className), "aria-pressed": o === value, disabled: disabled || extra?.disabled, title: extra?.title, onClick: () => onChange(o), children: label ? label(o) : o }, o));
106
106
  }) }));
107
107
  }
108
+ /** A pill row (the f.radio look, spec 2026-09-14 §5): no track, the chosen pill filled in the
109
+ * soft accent. For a small choice inside a table row, where a segmented control reads as tabs. */
110
+ export function Pills({ options, value, onChange, disabled, title, label }) {
111
+ return (_jsx("div", { className: cx("sm-pills", disabled && "sm-pills--disabled"), role: "radiogroup", children: options.map((o) => (_jsx("button", { type: "button", className: "sm-pill", "aria-pressed": o === value, disabled: disabled, title: title, onClick: () => onChange(o), children: label ? label(o) : o }, o))) }));
112
+ }
108
113
  /* ── data ────────────────────────────────────────────────────────────── */
109
114
  // Keyed by the product labels of ui/status-label.ts (lowercased by StatusBadge).
110
115
  const STATUS_TONE = {
@@ -126,10 +131,11 @@ export function SaveIndicator({ state }) {
126
131
  error: { label: "Save failed — retrying", dot: "var(--danger)", color: "var(--danger)" },
127
132
  };
128
133
  const { label, dot, color } = map[state];
129
- return (_jsxs("span", { className: "sm-savestate", style: { color }, title: label, children: [_jsx("span", { className: "sm-dot", style: { background: dot } }), label] }));
134
+ return (_jsxs("span", { className: "sm-savestate", "data-testid": "save-status", style: { color }, title: label, children: [_jsx("span", { className: "sm-dot", style: { background: dot } }), label] }));
130
135
  }
131
- export function SectionLabel({ children, action, mono, style }) {
132
- return (_jsxs("div", { className: cx("sm-sectionlabel", mono && "sm-sectionlabel--mono"), style: style, children: [_jsx("span", { className: "sm-label", children: children }), action && _jsx("span", { style: { color: "var(--accent)", fontWeight: 600, fontSize: "var(--text-sm)" }, children: action })] }));
136
+ /** `caps`: the overline style — small, uppercase, tracked (2026-09-26; the mono face before). */
137
+ export function SectionLabel({ children, action, caps, style }) {
138
+ return (_jsxs("div", { className: cx("sm-sectionlabel", caps && "sm-sectionlabel--caps"), style: style, children: [_jsx("span", { className: "sm-label", children: children }), action && _jsx("span", { style: { color: "var(--accent)", fontWeight: 600, fontSize: "var(--text-sm)" }, children: action })] }));
133
139
  }
134
140
  export function Divider({ bleed = 0 }) {
135
141
  return _jsx("div", { className: "sm-divider", style: { margin: `0 -${bleed}px` } });
@@ -6,10 +6,13 @@ export declare const MIN_INSPECTOR_W = 260;
6
6
  export declare const DEFAULT_INSPECTOR_W = 360;
7
7
  /** One arrow-key press. */
8
8
  export declare const RESIZE_STEP = 16;
9
- /** Half the editor row (canvas + handle + inspector), never below the minimum. */
10
- export declare const maxInspectorW: (rowWidth: number) => number;
11
- /** An integer inside [MIN, half the row]. */
12
- export declare const clampInspectorW: (px: number, rowWidth: number) => number;
9
+ /** What the canvas keeps however far the inspector is dragged (2026-09-26, up from half the row; 340 for an hour). */
10
+ export declare const MIN_CANVAS_W = 440;
11
+ /** The editor row (canvas + handle + inspector) less the handle and the
12
+ * canvas minimum, never below the inspector minimum. */
13
+ export declare const maxInspectorW: (rowWidth: number, handleWidth?: number) => number;
14
+ /** An integer inside [MIN, the row less the handle and the canvas minimum]. */
15
+ export declare const clampInspectorW: (px: number, rowWidth: number, handleWidth?: number) => number;
13
16
  /** A stored value is trusted only as a plain integer ≥ MIN; anything else is
14
17
  * ignored (never clamped or rewritten). The boot script in theme.tsx repeats
15
18
  * this test inline — keep the two in step. */