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,15 @@
1
+ "use client";
2
+ import { jsx as _jsx } from "react/jsx-runtime";
3
+ // The site's time zone from the registry (spec 2026-09-25 date pickers
4
+ // §5), for the datetime widget. Undefined = none configured: the widget
5
+ // falls back to the browser's zone and says so. Defaults to undefined so
6
+ // a form rendered without the shell (tests, a bare page) still works.
7
+ import { createContext, useContext } from "react";
8
+ const Ctx = createContext(undefined);
9
+ export function TimeZoneProvider({ zone, children }) {
10
+ return _jsx(Ctx.Provider, { value: zone, children: children });
11
+ }
12
+ /** The configured zone, or undefined. */
13
+ export function useTimeZone() {
14
+ return useContext(Ctx);
15
+ }
@@ -28,7 +28,16 @@ const EMPTY = { type: "doc", content: [{ type: "paragraph" }] };
28
28
  const LEVELS = [1, 2, 3, 4, 5, 6];
29
29
  const isDoc = (v) => !!v && typeof v === "object" && v.type === "doc";
30
30
  export function useRichtextEditor({ value, toolset, onChange, contentClass, onEscape }) {
31
- const allowed = useMemo(() => allowedOf(toolset), [toolset]);
31
+ // Keyed by content, not identity: the registry crosses the server → client
32
+ // boundary and is re-serialized whenever the route's server tree renders
33
+ // again — every autosave does that, since the op is a Server Action that
34
+ // expires the page's cache tag — so `toolset` arrives as a new object with
35
+ // the same content. On identity TipTap rebuilt the editor after each save,
36
+ // dropping its undo history and the focus (found 2026-09-26: cmd-Z did
37
+ // nothing once the first autosave had landed). The elements list is made
38
+ // stable the same way in ElementsProvider.
39
+ const toolsetKey = JSON.stringify(toolset ?? null);
40
+ const allowed = useMemo(() => allowedOf(toolset), [toolsetKey]); // eslint-disable-line react-hooks/exhaustive-deps
32
41
  const elements = useElements();
33
42
  const assets = useAssets();
34
43
  const items = useMemo(() => insertItems(allowed, elements), [allowed, elements]);
@@ -1 +1,2 @@
1
- export declare function EditorBridge(): null;
1
+ import { type ReactElement } from "react";
2
+ export declare function EditorBridge(): ReactElement;
@@ -1,16 +1,32 @@
1
1
  "use client";
2
+ import { jsx as _jsx } from "react/jsx-runtime";
2
3
  // Runs INSIDE the draft-route iframe (glue adds it only when draft mode +
3
4
  // the editor param are on). Reports geometry — section nodes, empty zones
4
5
  // and, since spec 2026-09-10, every marked field with its resolved path —
5
6
  // forwards select/hover with the field under the pointer, executes
6
7
  // refresh/scroll, and says when a render has committed. The admin overlay
7
- // draws all chrome. Rects are in document coordinates: the iframe is sized
8
+ // draws all chrome — bar one rule this component renders: a section that
9
+ // renders nothing (a fresh custom section, an all-optional section left
10
+ // empty) is stamped blank and takes a min-height, so the editor can see
11
+ // and select it; the live site never mounts the bridge, so it keeps its
12
+ // true height. Rects are in document coordinates: the iframe is sized
8
13
  // to the full document height by the editor, so client rects + scroll
9
14
  // offsets map 1:1 onto the overlay.
10
15
  import { useEffect } from "react";
11
16
  import { useRouter } from "next/navigation";
12
- import { EDITOR_ATTR, FIELD_ATTR, NODE_ATTR, ZONE_ATTR, resolveMarkPath } from "../../marks.js";
17
+ import { BLANK_ATTR, EDITOR_ATTR, FIELD_ATTR, NODE_ATTR, ZONE_ATTR, resolveMarkPath } from "../../marks.js";
13
18
  const post = (m) => window.parent.postMessage(m, "*");
19
+ /** The height of an md-spaced section with nothing inside (3.5rem + 3.5rem). */
20
+ // The editor sizes the iframe to the reported height; the document's own
21
+ // viewport must never scroll (2026-09-26): a fractional content height
22
+ // (4595.3px in a 4595px frame) left it scrollable by a hair, and a
23
+ // trackpad scroll over the canvas showed the frame's overlay scrollbar.
24
+ // Hidden overflow drops no layout — programmatic scrollIntoView still
25
+ // works, and the live site never mounts this.
26
+ const BRIDGE_CSS = `html{overflow:hidden}[${NODE_ATTR}][${BLANK_ATTR}]{min-height:7rem}`;
27
+ const MEDIA = "img,picture,video,audio,svg,canvas,iframe,object";
28
+ /** No visible text (innerText skips <style> and hidden content) and no media. */
29
+ const isBlank = (el) => el.innerText.trim() === "" && el.querySelector(MEDIA) === null;
14
30
  export function EditorBridge() {
15
31
  const router = useRouter();
16
32
  // Every commit of this component — the mount, then each RSC refresh,
@@ -28,7 +44,19 @@ export function EditorBridge() {
28
44
  const report = () => {
29
45
  // Array.from, not spread: the package's lib set is ES2020 + DOM
30
46
  // (no DOM.Iterable), so a NodeList isn't statically iterable here.
31
- const nodes = all(`[${NODE_ATTR}]`).map((el) => ({ id: el.getAttribute(NODE_ATTR), ...docRect(el) }));
47
+ const roots = all(`[${NODE_ATTR}]`);
48
+ // Stamp before measuring, so the min-height is in the rects; only on a
49
+ // change, so the attribute mutation this causes converges instead of looping.
50
+ for (const el of roots) {
51
+ const blank = isBlank(el);
52
+ if (blank !== el.hasAttribute(BLANK_ATTR)) {
53
+ if (blank)
54
+ el.setAttribute(BLANK_ATTR, "");
55
+ else
56
+ el.removeAttribute(BLANK_ATTR);
57
+ }
58
+ }
59
+ const nodes = roots.map((el) => ({ id: el.getAttribute(NODE_ATTR), ...docRect(el) }));
32
60
  const emptyZones = all(`[${ZONE_ATTR}]`).map((el) => ({ zone: el.getAttribute(ZONE_ATTR), ...docRect(el) }));
33
61
  const fields = [];
34
62
  for (const el of all(`[${FIELD_ATTR}]`)) {
@@ -39,7 +67,9 @@ export function EditorBridge() {
39
67
  if (mark)
40
68
  fields.push({ ...mark, ...docRect(el) });
41
69
  }
42
- post({ source: "smoodly", kind: "rects", nodes, emptyZones, fields, docH: document.documentElement.scrollHeight });
70
+ // Rounded up from the fractional box, so the frame is never a hair too short.
71
+ const docH = Math.ceil(Math.max(document.documentElement.scrollHeight, document.documentElement.getBoundingClientRect().height));
72
+ post({ source: "smoodly", kind: "rects", nodes, emptyZones, fields, docH });
43
73
  };
44
74
  const raf = () => requestAnimationFrame(report);
45
75
  const ro = new ResizeObserver(raf);
@@ -93,5 +123,5 @@ export function EditorBridge() {
93
123
  window.removeEventListener("message", onMessage);
94
124
  };
95
125
  }, [router]);
96
- return null;
126
+ return _jsx("style", { "data-smoodly-bridge": "", children: BRIDGE_CSS });
97
127
  }
@@ -11,6 +11,7 @@ import { CUSTOM_NODE_TYPE, customSchema } from "../custom/index.js";
11
11
  import { SETTINGS_KEY, CSS_KEY } from "../global.js";
12
12
  import { withDefaults } from "../field-defaults.js";
13
13
  import { elementSchemas } from "../richtext-walk.js";
14
+ import { SCHEMA_VERSION } from "../schema-version.js";
14
15
  const ok = (data) => ({ ok: true, data });
15
16
  /** A history row without its content (spec 2026-09-13 history §6). */
16
17
  const summary = ({ id, savedAt }) => ({ id, savedAt });
@@ -926,6 +927,9 @@ export function createAdminOps(deps) {
926
927
  return ok(null);
927
928
  }),
928
929
  },
930
+ schema: {
931
+ status: () => guard(async () => ok({ database: await pages.schemaVersion(), required: SCHEMA_VERSION })),
932
+ },
929
933
  preview: {
930
934
  enable: () => guard(async () => (await effects?.draft?.enable(), ok(null))),
931
935
  disable: () => guard(async () => (await effects?.draft?.disable(), ok(null))),
@@ -98,6 +98,11 @@ export type MembersListResult = {
98
98
  members: MemberRecord[];
99
99
  consoleUrl: string | null;
100
100
  };
101
+ /** `database` null = no smoodly_meta table (older than v1). Behind blocks the shell; ahead is silent (spec 2026-09-26 §5). */
102
+ export type SchemaStatus = {
103
+ database: number | null;
104
+ required: number;
105
+ };
101
106
  export type AdminOps = {
102
107
  pages: {
103
108
  /** Lists every node, materializing fixed pages and collection mounts (in every supported locale) first. */
@@ -210,6 +215,10 @@ export type AdminOps = {
210
215
  /** Admins only, self-host only; the same two guards. The Auth account stays. */
211
216
  remove(userId: string): Promise<OpResult<null>>;
212
217
  };
218
+ schema: {
219
+ /** Every member. The shell calls it once on mount and blocks when behind. */
220
+ status(): Promise<OpResult<SchemaStatus>>;
221
+ };
213
222
  preview: {
214
223
  enable(): Promise<OpResult<null>>;
215
224
  disable(): Promise<OpResult<null>>;
@@ -74,6 +74,8 @@ export type AdminRegistry = {
74
74
  default: string;
75
75
  supported: string[];
76
76
  };
77
+ /** The site's time zone when configured (spec 2026-09-25 date pickers §5); the datetime widget edits in it. */
78
+ timeZone?: string;
77
79
  /** The project's custom-CSS flag (spec 2026-09-14 §4). */
78
80
  css: boolean;
79
81
  /** Which slug the site root serves. */
@@ -104,6 +104,7 @@ export function serializeAdminConfig(config) {
104
104
  },
105
105
  css: config.css,
106
106
  locales: config.locales,
107
+ ...(config.timeZone !== undefined ? { timeZone: config.timeZone } : {}),
107
108
  homePageSlug: config.homePageSlug,
108
109
  pageDepth: config.pageDepth,
109
110
  media: { maxSize: config.media.maxSize, accept: [...config.media.accept] },
@@ -24,6 +24,7 @@ import { LoginView } from "./LoginView.js";
24
24
  import { SetPasswordView } from "./SetPasswordView.js";
25
25
  import { useAdminSession } from "./session.js";
26
26
  import { IconRail } from "./IconRail.js";
27
+ import { ConfirmHost } from "../ui/ConfirmDialog.js";
27
28
  import { useRoute } from "./router.js";
28
29
  import { AdminPathProvider } from "./base.js";
29
30
  import { mountMismatch } from "../../admin-path.js";
@@ -33,6 +34,9 @@ import { MediaScreen } from "./MediaScreen.js";
33
34
  import { SettingsView } from "./SettingsView.js";
34
35
  import { AssetsProvider } from "../forms/assets-context.js";
35
36
  import { ElementsProvider } from "../forms/elements-context.js";
37
+ import { TimeZoneProvider } from "../forms/time-zone-context.js";
38
+ import { SchemaNotice } from "./SchemaNotice.js";
39
+ import { schemaBehind } from "../../schema-version.js";
36
40
  export function AdminApp({ registry, op, auth }) {
37
41
  const session = useAdminSession(auth);
38
42
  const [ops] = useState(() => makeClientOps(op, session.token));
@@ -57,6 +61,22 @@ export function AdminApp({ registry, op, auth }) {
57
61
  setRole("editor"); });
58
62
  return () => { live = false; };
59
63
  }, [signedIn, ops]);
64
+ // The one version check (spec 2026-09-26 §5): behind blocks the shell,
65
+ // equal or ahead renders as usual. Unknown until it answers; a failed
66
+ // call renders as usual too — the notice is for a known-behind schema.
67
+ const [schema, setSchema] = useState(null);
68
+ useEffect(() => {
69
+ if (!signedIn) {
70
+ setSchema(null);
71
+ return;
72
+ }
73
+ let live = true;
74
+ ops.schema.status()
75
+ .then((r) => { if (live && r.ok)
76
+ setSchema(r.data); })
77
+ .catch(() => { });
78
+ return () => { live = false; };
79
+ }, [signedIn, ops]);
60
80
  const userId = session.state.status === "signed_in" ? session.state.session.user.id : "";
61
81
  if (session.state.status === "loading") {
62
82
  return (_jsx("div", { style: { height: "100vh", display: "grid", placeItems: "center", color: "var(--text-muted)" }, children: "Loading\u2026" }));
@@ -70,6 +90,8 @@ export function AdminApp({ registry, op, auth }) {
70
90
  // An invite or reset link signed the person in: Set password before the shell (members management §6).
71
91
  if (session.arrival)
72
92
  return _jsx(SetPasswordView, { client: session.client, mode: session.arrival, onDone: session.clearArrival });
93
+ if (schema && schemaBehind(schema.database, schema.required))
94
+ return _jsx(SchemaNotice, { status: schema });
73
95
  let main;
74
96
  if (head === "pages" && !tail)
75
97
  main = _jsx(PagesList, { ops: ops, registry: registry });
@@ -89,5 +111,5 @@ export function AdminApp({ registry, op, auth }) {
89
111
  main = _jsx(MediaScreen, {});
90
112
  else
91
113
  main = _jsx(ErrorPane, { message: "Not found." });
92
- return (_jsx(AdminPathProvider, { value: registry.adminPath, children: _jsx(ElementsProvider, { elements: registry.elements, children: _jsx(AssetsProvider, { ops: ops, media: registry.media, children: _jsxs("div", { className: "sm-shell", children: [_jsx(IconRail, { registry: registry, active: head, email: email, settings: role !== null && can(role, "settings"), onSignOut: () => session.client.auth.signOut() }), _jsx("main", { className: "sm-main", children: main }, `${head}/${tail ?? ""}`)] }) }) }) }));
114
+ return (_jsx(AdminPathProvider, { value: registry.adminPath, children: _jsx(ElementsProvider, { elements: registry.elements, children: _jsx(TimeZoneProvider, { zone: registry.timeZone, children: _jsxs(AssetsProvider, { ops: ops, media: registry.media, children: [_jsxs("div", { className: "sm-shell", children: [_jsx(IconRail, { registry: registry, active: head, email: email, settings: role !== null && can(role, "settings"), onSignOut: () => session.client.auth.signOut() }), _jsx("main", { className: "sm-main", children: main }, `${head}/${tail ?? ""}`)] }), _jsx(ConfirmHost, {})] }) }) }) }));
93
115
  }
@@ -14,34 +14,19 @@ 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
18
  import { ErrorPane } from "./ErrorPane.js";
18
- const LOCALE_KEY = "smoodly.entries.locale";
19
- /** The selected locale survives a reload within the session; a stored
20
- * value that is no longer supported falls back to the default. */
21
- function rememberedLocale(registry) {
22
- try {
23
- const stored = window.sessionStorage.getItem(LOCALE_KEY);
24
- if (stored && registry.locales.supported.includes(stored))
25
- return stored;
26
- }
27
- catch {
28
- // storage unavailable — the default is fine
29
- }
30
- return registry.locales.default;
31
- }
19
+ import { rememberLocale, rememberedLocale } from "./locale-memory.js";
32
20
  export function EntriesList({ ops, registry, collection }) {
33
21
  const adminHref = useAdminHref();
34
22
  const meta = registry.collections.find((c) => c.name === collection);
35
23
  const [records, setRecords] = useState(null);
36
24
  const [error, setError] = useState(null);
37
- const [locale, setLocale] = useState(() => rememberedLocale(registry));
25
+ const [locale, setLocale] = useState(() => rememberedLocale(registry.locales));
38
26
  const multilingual = registry.locales.supported.length > 1;
39
27
  const pickLocale = (next) => {
40
28
  setLocale(next);
41
- try {
42
- window.sessionStorage.setItem(LOCALE_KEY, next);
43
- }
44
- catch { /* see rememberedLocale */ }
29
+ rememberLocale(next);
45
30
  };
46
31
  const load = useCallback(() => ops.entries.list(collection)
47
32
  .then((r) => (r.ok ? setRecords(r.data) : setError(r.message)))
@@ -75,7 +60,7 @@ export function EntriesList({ ops, registry, collection }) {
75
60
  if (error)
76
61
  return _jsx(ErrorPane, { message: error });
77
62
  const open = (id) => {
78
- navigate(adminHref(`/${collection}/${encodeURIComponent(id)}?locale=${encodeURIComponent(locale)}`));
63
+ void navigate(adminHref(`/${collection}/${encodeURIComponent(id)}?locale=${encodeURIComponent(locale)}`));
79
64
  };
80
65
  /** "+ New entry" creates an empty draft in the locale on screen and opens it (spec 2026-09-14 §4). */
81
66
  const createEntry = async () => {
@@ -97,7 +82,13 @@ export function EntriesList({ ops, registry, collection }) {
97
82
  if (!record)
98
83
  return;
99
84
  const from = entrySourceLocale(record, registry);
100
- if (!window.confirm(`Add "${row.title}" to ${locale}? Its ${from} fields are copied as the first ${locale} draft.`))
85
+ const ok = await confirm({
86
+ title: `Add this entry to ${locale}?`,
87
+ body: `Its ${from} fields are copied as the first ${locale} draft. Nothing is published until you publish it.`,
88
+ confirmLabel: `Add to ${locale}`,
89
+ subject: { name: row.title },
90
+ });
91
+ if (!ok)
101
92
  return;
102
93
  let result;
103
94
  try {
@@ -15,7 +15,10 @@ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-run
15
15
  // publish control. A draft may be incomplete; publish validates it and
16
16
  // lists the fields in an amber banner. The safe-delete guard's message
17
17
  // renders verbatim — it is product surface.
18
+ import { Eye } from "lucide-react";
18
19
  import { useEffect, useMemo, useRef, useState } from "react";
20
+ import { stillFailing, validateFields } from "../validate.js";
21
+ import { elementSchemas } from "../../richtext-walk.js";
19
22
  import { FieldWidget } from "../forms/FieldWidget.js";
20
23
  import { revealField } from "../forms/reveal.js";
21
24
  import { useRefOptions } from "../forms/useRefOptions.js";
@@ -32,7 +35,8 @@ import { entrySourceLocale, entryTitle, localizedFieldKeys } from "./entries-lis
32
35
  import { useAdminHref } from "./base.js";
33
36
  import { localeFromUrl, writeLocaleToUrl } from "../editor/locale-url.js";
34
37
  import { navigate, useLeaveGuard } from "./router.js";
35
- import { LIVE_URL_MESSAGE, liveUrlChanges } from "./live-url.js";
38
+ import { LIVE_URL_CONFIRM, liveUrlChanges } from "./live-url.js";
39
+ import { confirm, DISCARD_CONFIRM } from "../ui/confirm.js";
36
40
  /** A stable empty map for the hooks that run before the "no such collection" return. */
37
41
  const NO_FIELDS = {};
38
42
  export function EntryForm({ ops, registry, collection, id }) {
@@ -73,7 +77,9 @@ export function EntryForm({ ops, registry, collection, id }) {
73
77
  const stored = row?.slug ?? null;
74
78
  const next = typeof value.slug === "string" ? value.slug : null;
75
79
  if (next !== confirmedSlug.current && liveUrlChanges({ status: row?.status, from: stored, to: next })) {
76
- if (window.confirm(LIVE_URL_MESSAGE))
80
+ const current = recordRef.current;
81
+ const subject = current && meta ? { name: entryTitle(current, meta, registry, locale), path: slugPrefix && stored ? `${slugPrefix}${stored}` : null } : undefined;
82
+ if (await confirm({ ...LIVE_URL_CONFIRM, subject }))
77
83
  confirmedSlug.current = next;
78
84
  else {
79
85
  value = { ...value, slug: stored };
@@ -127,6 +133,22 @@ export function EntryForm({ ops, registry, collection, id }) {
127
133
  return _jsx("p", { style: { padding: 32 }, children: "No such collection." });
128
134
  const row = record?.locales[locale];
129
135
  const status = row ? localeStatusOf(row) : null;
136
+ // A refused publish's field errors clear one by one as the fields are fixed: every change revalidates
137
+ // (the same rules the op applies) and keeps only what still fails; the notice's list shrinks with them.
138
+ const elementDescs = useMemo(() => elementSchemas(registry.elements), [registry]);
139
+ const hasFieldErrors = Object.keys(fieldErrors).length > 0;
140
+ useEffect(() => {
141
+ if (!hasFieldErrors || !fields || !meta)
142
+ return;
143
+ const fresh = validateFields(fields, meta.fields, elementDescs);
144
+ setFieldErrors((prev) => stillFailing(prev, fresh));
145
+ setNotice((n) => {
146
+ if (!n?.fields)
147
+ return n;
148
+ const left = n.fields.filter((f) => fresh.some((e) => e.field === f.field));
149
+ return left.length === 0 ? null : left.length === n.fields.length ? n : { ...n, fields: left };
150
+ });
151
+ }, [fields, hasFieldErrors, meta, elementDescs]);
130
152
  /** An op's failure onto the screen: amber with the fields for a validation result, red otherwise. */
131
153
  const fail = (r) => {
132
154
  if (r.code === "validation" && r.fields && r.fields.length > 0) {
@@ -180,7 +202,7 @@ export function EntryForm({ ops, registry, collection, id }) {
180
202
  };
181
203
  /** Discard (spec 2026-09-13 publish flow §4): confirm, flush so a late save cannot re-dirty the row, restore, reload. */
182
204
  const discard = async () => {
183
- if (!window.confirm("Discard your changes and restore the published version?"))
205
+ if (!(await confirm({ ...DISCARD_CONFIRM, subject: entrySubject() })))
184
206
  return;
185
207
  setNotice(null);
186
208
  setBusy("discard");
@@ -290,7 +312,14 @@ export function EntryForm({ ops, registry, collection, id }) {
290
312
  const remaining = Object.keys(record.locales).filter((l) => l !== locale);
291
313
  if (remaining.length === 0)
292
314
  return;
293
- if (!window.confirm(`Remove this entry from ${locale}? Its ${locale} fields and history are deleted.`))
315
+ const ok = await confirm({
316
+ title: `Remove this entry from ${locale}?`,
317
+ body: `Its ${locale} fields and history are deleted. Other languages are not affected.`,
318
+ confirmLabel: `Remove from ${locale}`,
319
+ destructive: true,
320
+ subject: entrySubject(),
321
+ });
322
+ if (!ok)
294
323
  return;
295
324
  let result;
296
325
  try {
@@ -307,11 +336,18 @@ export function EntryForm({ ops, registry, collection, id }) {
307
336
  goToLocale(remaining.includes(registry.locales.default) ? registry.locales.default : remaining[0]);
308
337
  };
309
338
  const destroy = async () => {
310
- if (!window.confirm(multilingual ? "Delete this entry in every language?" : "Delete this entry?"))
339
+ const ok = await confirm({
340
+ title: "Delete this entry?",
341
+ body: `The entry${multilingual ? " in every language" : ""} and its history are removed. Anything referencing it loses the reference. This cannot be undone.`,
342
+ confirmLabel: "Delete entry",
343
+ destructive: true,
344
+ subject: entrySubject(),
345
+ });
346
+ if (!ok)
311
347
  return;
312
348
  const r = await ops.entries.delete(collection, id);
313
349
  if (r.ok)
314
- navigate(adminHref(`/${collection}`), { force: true });
350
+ void navigate(adminHref(`/${collection}`), { force: true });
315
351
  else
316
352
  fail(r);
317
353
  };
@@ -319,6 +355,11 @@ export function EntryForm({ ops, registry, collection, id }) {
319
355
  // The mount's path in the ACTIVE locale prefixes the slug; no path, no prefix.
320
356
  const base = meta.path?.[locale];
321
357
  const slugPrefix = base ? `/${base}/` : undefined;
358
+ /** The confirm dialogs' context chip: the entry's title and, on a routed collection, its URL. */
359
+ const entrySubject = () => {
360
+ const slug = record?.locales[locale]?.slug;
361
+ return record ? { name: heading, path: slugPrefix && slug ? `${slugPrefix}${slug}` : null } : undefined;
362
+ };
322
363
  const present = record ? registry.locales.supported.filter((l) => record.locales[l]) : [locale];
323
364
  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) => {
324
365
  setFields((f) => ({ ...(f ?? {}), [key]: value }));
@@ -333,6 +374,6 @@ export function EntryForm({ ops, registry, collection, id }) {
333
374
  trailing: multilingual && (_jsx(LocaleSwitcher, { registry: registry, present: present, parentLocales: parentLocales, value: locale, adding: adding, onSwitch: (l) => { void switchLocale(l); }, onAdd: (l) => void doAddLocale(l) })),
334
375
  },
335
376
  { label: heading },
336
- ] }) }), right: _jsxs(_Fragment, { children: [(touched || saveState === "error") && _jsx(SaveIndicator, { state: saveState }), urls[locale] && (_jsx(LinkButton, { href: urls[locale], target: "_blank", rel: "noreferrer", title: "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: "View live" })), _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" }) })), _jsxs("div", { className: "sm-editor", 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)
377
+ ] }) }), right: _jsxs(_Fragment, { children: [(touched || saveState === "error") && _jsx(SaveIndicator, { state: saveState }), 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" }) })), _jsxs("div", { className: "sm-editor", 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)
337
378
  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) }), 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" })] }))] })] })] }));
338
379
  }
@@ -12,6 +12,8 @@ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-run
12
12
  // validates the draft and lists the fields in an amber banner. "Used on
13
13
  // N pages" is the refs index's answer.
14
14
  import { useEffect, useMemo, useRef, useState } from "react";
15
+ import { stillFailing, validateNode } from "../validate.js";
16
+ import { elementSchemas } from "../../richtext-walk.js";
15
17
  import { CSS_KEY, SETTINGS_KEY, STYLES_KEY } from "../../global.js";
16
18
  import { tabsFor } from "../forms/tabs.js";
17
19
  import { revealField } from "../forms/reveal.js";
@@ -28,6 +30,7 @@ import { useAdminHref } from "./base.js";
28
30
  import { GlobalFields } from "./GlobalFields.js";
29
31
  import { localeFromUrl, writeLocaleToUrl } from "../editor/locale-url.js";
30
32
  import { useLeaveGuard } from "./router.js";
33
+ import { confirm, DISCARD_CONFIRM } from "../ui/confirm.js";
31
34
  export function GlobalForm({ ops, registry, name }) {
32
35
  const adminHref = useAdminHref();
33
36
  const item = registry.globals.find((s) => s.name === name);
@@ -101,6 +104,23 @@ export function GlobalForm({ ops, registry, name }) {
101
104
  else
102
105
  setNotice({ tone: "error", text: r.message });
103
106
  };
107
+ // A refused publish's field errors clear as the fields are fixed: every change revalidates the way the
108
+ // op does (a visual item like a node: fields, `$settings` under `settings.`, `$css`) and keeps what still fails.
109
+ const elementDescs = useMemo(() => elementSchemas(registry.elements), [registry]);
110
+ const hasFieldErrors = Object.keys(fieldErrors).length > 0;
111
+ useEffect(() => {
112
+ if (!hasFieldErrors || !fields || !item)
113
+ return;
114
+ const { [SETTINGS_KEY]: settings, [CSS_KEY]: css, ...values } = fields;
115
+ const fresh = validateNode({ fields: values, settings: settings !== null && typeof settings === "object" ? settings : {}, css }, { fields: item.fields, settings: item.section !== undefined ? item.settings : undefined }, elementDescs);
116
+ setFieldErrors((prev) => stillFailing(prev, fresh));
117
+ setNotice((n) => {
118
+ if (!n?.fields)
119
+ return n;
120
+ const left = n.fields.filter((f) => fresh.some((e) => e.field === f.field));
121
+ return left.length === 0 ? null : left.length === n.fields.length ? n : { ...n, fields: left };
122
+ });
123
+ }, [fields, hasFieldErrors, item, elementDescs]);
104
124
  const publish = async () => {
105
125
  setNotice(null);
106
126
  setBusy("publish");
@@ -143,7 +163,7 @@ export function GlobalForm({ ops, registry, name }) {
143
163
  fail(r);
144
164
  };
145
165
  const discard = async () => {
146
- if (!window.confirm("Discard your changes and restore the published version?"))
166
+ if (!(await confirm({ ...DISCARD_CONFIRM, subject: { name: item.title } })))
147
167
  return;
148
168
  setNotice(null);
149
169
  setBusy("discard");
@@ -235,7 +255,7 @@ export function GlobalForm({ ops, registry, name }) {
235
255
  };
236
256
  return (_jsxs(_Fragment, { children: [_jsx(TopBar, { left: _jsx(_Fragment, { children: _jsx(Breadcrumb, { backHref: adminHref("/globals"), items: [
237
257
  {
238
- label: "Globals", href: adminHref("/globals"),
258
+ label: "Site", href: adminHref("/globals"),
239
259
  trailing: multilingual && (_jsx(LocaleSwitcher, { registry: registry, present: registry.locales.supported, parentLocales: [], value: locale, adding: false, onSwitch: (l) => { void switchLocale(l); }, onAdd: () => undefined })),
240
260
  },
241
261
  { label: item.title },
@@ -14,30 +14,16 @@ import { LocalePicker } from "../ui/LocaleSwitcher.js";
14
14
  import { relativeTime } from "../ui/format.js";
15
15
  import { useAdminHref } from "./base.js";
16
16
  import { ErrorPane } from "./ErrorPane.js";
17
- const LOCALE_KEY = "smoodly.entries.locale"; // the same choice the collection lists remember
18
- function rememberedLocale(registry) {
19
- try {
20
- const stored = window.sessionStorage.getItem(LOCALE_KEY);
21
- if (stored && registry.locales.supported.includes(stored))
22
- return stored;
23
- }
24
- catch {
25
- // storage unavailable — the default is fine
26
- }
27
- return registry.locales.default;
28
- }
17
+ import { rememberLocale, rememberedLocale } from "./locale-memory.js";
29
18
  export function GlobalList({ ops, registry }) {
30
19
  const adminHref = useAdminHref();
31
20
  const [items, setItems] = useState(null);
32
21
  const [error, setError] = useState(null);
33
- const [locale, setLocale] = useState(() => rememberedLocale(registry));
22
+ const [locale, setLocale] = useState(() => rememberedLocale(registry.locales));
34
23
  const multilingual = registry.locales.supported.length > 1;
35
24
  const pickLocale = (next) => {
36
25
  setLocale(next);
37
- try {
38
- window.sessionStorage.setItem(LOCALE_KEY, next);
39
- }
40
- catch { /* see rememberedLocale */ }
26
+ rememberLocale(next);
41
27
  };
42
28
  const load = useCallback(() => {
43
29
  ops.globals.list()
@@ -48,7 +34,7 @@ export function GlobalList({ ops, registry }) {
48
34
  if (error)
49
35
  return _jsx(ErrorPane, { message: error });
50
36
  const rows = items ? globalRows(items, registry, locale) : [];
51
- return (_jsx(ListView, { title: "Globals", titleTrailing: multilingual && _jsx(LocalePicker, { locales: registry.locales.supported, value: locale, onChange: pickLocale }), columns: [
37
+ return (_jsx(ListView, { title: "Site", titleTrailing: multilingual && _jsx(LocalePicker, { locales: registry.locales.supported, value: locale, onChange: pickLocale }), columns: [
52
38
  { label: "Title", width: "minmax(160px,1fr)" },
53
39
  { label: "Kind", width: "90px" },
54
40
  { label: "Updated", width: "minmax(110px,160px)" },
@@ -26,9 +26,11 @@ import { RouteLink } from "./RouteLink.js";
26
26
  /** Lucide at the rail's size and stroke (the tile's CSS restates both). */
27
27
  const ICON = { size: 18, strokeWidth: 2, "aria-hidden": true };
28
28
  /** The app icon, inlined: a package can't ship a file into the host app's
29
- * /public. Brand blue on white in both themes, so no --logo-filter. */
29
+ * /public. design_handoff_smoodly_neutral/assets/smoodly-logo.svg — the
30
+ * outer and inner shapes take --logo-outer / --logo-inner (white over
31
+ * navy on the frame), so a look decides its colours. */
30
32
  function AppIcon() {
31
- return (_jsxs("svg", { className: "sm-rail__logo", viewBox: "0 0 89 86", fill: "none", xmlns: "http://www.w3.org/2000/svg", role: "img", "aria-label": "Smoodly", children: [_jsx("path", { fill: "#0038B1", d: "M0 51.5552C0 24.3141 25.0505 0 52.5493 0H52.7761C74.4707 0 89 14.6336 89 34.445C89 61.6862 64.4034 86 36.0066 86C14.0946 86 0 70.9165 0 51.5552Z" }), _jsx("path", { fill: "#FFFFFF", d: "M23.25 46.1188C23.25 33.0027 34.8806 21.2959 47.6479 21.2959H47.7532C57.8257 21.2959 64.5714 28.3417 64.5714 37.8805C64.5714 50.9966 53.1516 62.7033 39.9674 62.7033C29.7939 62.7033 23.25 55.4409 23.25 46.1188Z" })] }));
33
+ return (_jsxs("svg", { className: "sm-rail__logo", viewBox: "0 0 28 27", fill: "none", xmlns: "http://www.w3.org/2000/svg", role: "img", "aria-label": "Smoodly", children: [_jsx("path", { style: { fill: "var(--logo-outer)" }, d: "M0 16.1859C0 7.6335 7.88106 0 16.5324 0H16.6037C23.429 0 28 4.59428 28 10.8141C28 19.3666 20.2618 27 11.3279 27C4.43425 27 0 22.2645 0 16.1859Z" }), _jsx("path", { style: { fill: "var(--logo-inner)" }, d: "M7 14.7932C7 10.6754 10.6591 7 14.6757 7H14.7089C17.8777 7 20 9.21206 20 12.2068C20 16.3247 16.4072 20 12.2594 20C9.05876 20 7 17.7199 7 14.7932Z" })] }));
32
34
  }
33
35
  export function IconRail({ registry, active, email, settings = true, onSignOut }) {
34
36
  const adminHref = useAdminHref();
@@ -44,14 +46,21 @@ export function IconRail({ registry, active, email, settings = true, onSignOut }
44
46
  const close = () => setOpen(null);
45
47
  const hoverLeave = () => setHovered(false);
46
48
  // A click open moves focus into the menu; a pointer down outside closes it.
49
+ // Outside means outside the menu and its trigger — the rest of the rail
50
+ // counts too (2026-09-26); the trigger is left to its own toggle.
47
51
  useEffect(() => {
48
52
  if (!open || !root.current)
49
53
  return;
50
54
  if (!hovered)
51
55
  root.current.querySelector(`#${CSS.escape(menuId(open))} [role=menuitem]`)?.focus();
52
56
  const onPointerDown = (e) => {
53
- if (e.target instanceof Node && !root.current?.contains(e.target))
54
- close();
57
+ if (!(e.target instanceof Node))
58
+ return;
59
+ const menu = root.current?.querySelector(`#${CSS.escape(menuId(open))}`);
60
+ const trigger = root.current?.querySelector(`[aria-controls="${menuId(open)}"]`);
61
+ if (menu?.contains(e.target) || trigger?.contains(e.target))
62
+ return;
63
+ close();
55
64
  };
56
65
  document.addEventListener("pointerdown", onPointerDown);
57
66
  return () => document.removeEventListener("pointerdown", onPointerDown);
@@ -79,5 +88,5 @@ export function IconRail({ registry, active, email, settings = true, onSignOut }
79
88
  const collectionsOpen = open === "collections" || (hovered && open === null);
80
89
  const name = email?.split("@")[0] ?? "Editor";
81
90
  const initials = name.slice(0, 2).toUpperCase();
82
- return (_jsxs("nav", { className: "sm-rail", "aria-label": "Primary", ref: root, onKeyDown: onKeyDown, children: [_jsx(AppIcon, {}), _jsxs("ul", { className: "sm-rail__list", children: [link("pages", "Pages", _jsx(PanelTop, { ...ICON }), adminHref("/pages")), collections.length > 0 && (_jsxs("li", { className: "sm-rail__item", onMouseEnter: () => setHovered(true), onMouseLeave: hoverLeave, children: [_jsxs("button", { type: "button", className: "sm-rail__btn", "aria-current": collectionActive ? "page" : undefined, "aria-haspopup": "menu", "aria-expanded": collectionsOpen, "aria-controls": menuId("collections"), onClick: () => toggle("collections"), children: [_jsx("span", { className: "sm-rail__tile", children: _jsx(LibraryBig, { ...ICON }) }), _jsx("span", { className: "sm-rail__label", children: "Collections" })] }), _jsx("ul", { className: "sm-rail__menu sm-rail__menu--collections", role: "menu", id: menuId("collections"), "data-open": collectionsOpen, children: collections.map((c) => (_jsx("li", { role: "none", children: _jsx(RouteLink, { role: "menuitem", className: "sm-rail__menu-item", href: adminHref(`/${c.name}`), "aria-current": c.name === active ? "page" : undefined, onClick: () => { close(); hoverLeave(); }, children: _jsx("span", { children: c.title }) }) }, c.name))) })] })), link("media", "Media", _jsx(Image, { ...ICON }), adminHref("/media")), registry.globals.length > 0 && link("globals", "Globals", _jsx(Globe, { ...ICON }), adminHref("/globals")), settings && link("settings", "Settings", _jsx(Settings, { ...ICON }), adminHref("/settings"))] }), _jsxs("div", { className: "sm-rail__item sm-rail__foot", children: [_jsx("button", { type: "button", className: "sm-rail__user", title: name, "aria-haspopup": "menu", "aria-expanded": open === "user", "aria-controls": menuId("user"), onClick: () => toggle("user"), children: initials }), _jsxs("ul", { className: "sm-rail__menu sm-rail__menu--up", role: "menu", id: menuId("user"), "data-open": open === "user", children: [_jsx("li", { className: "sm-rail__menu-title", role: "presentation", children: name }), _jsx("li", { role: "none", children: _jsx(ThemeToggle, {}) }), _jsx("li", { role: "none", children: _jsx("button", { type: "button", role: "menuitem", className: "sm-rail__menu-item", onClick: () => { close(); onSignOut(); }, children: "Sign out" }) })] })] })] }));
91
+ return (_jsxs("nav", { className: "sm-rail", "aria-label": "Primary", ref: root, onKeyDown: onKeyDown, children: [_jsx(AppIcon, {}), _jsxs("ul", { className: "sm-rail__list", children: [link("pages", "Pages", _jsx(PanelTop, { ...ICON }), adminHref("/pages")), collections.length > 0 && (_jsxs("li", { className: "sm-rail__item", onMouseEnter: () => setHovered(true), onMouseLeave: hoverLeave, children: [_jsxs("button", { type: "button", className: "sm-rail__btn", "aria-current": collectionActive ? "page" : undefined, "aria-haspopup": "menu", "aria-expanded": collectionsOpen, "aria-controls": menuId("collections"), onClick: () => toggle("collections"), children: [_jsx("span", { className: "sm-rail__tile", children: _jsx(LibraryBig, { ...ICON }) }), _jsx("span", { className: "sm-rail__label", children: "Collections" })] }), _jsx("ul", { className: "sm-rail__menu sm-rail__menu--collections", role: "menu", id: menuId("collections"), "data-open": collectionsOpen, children: collections.map((c) => (_jsx("li", { role: "none", children: _jsx(RouteLink, { role: "menuitem", className: "sm-rail__menu-item", href: adminHref(`/${c.name}`), "aria-current": c.name === active ? "page" : undefined, onClick: () => { close(); hoverLeave(); }, children: _jsx("span", { children: c.title }) }) }, c.name))) })] })), link("media", "Media", _jsx(Image, { ...ICON }), adminHref("/media")), registry.globals.length > 0 && link("globals", "Site", _jsx(Globe, { ...ICON }), adminHref("/globals")), settings && link("settings", "Settings", _jsx(Settings, { ...ICON }), adminHref("/settings"))] }), _jsxs("div", { className: "sm-rail__item sm-rail__foot", children: [_jsx("button", { type: "button", className: "sm-rail__user", title: name, "aria-haspopup": "menu", "aria-expanded": open === "user", "aria-controls": menuId("user"), onClick: () => toggle("user"), children: initials }), _jsxs("ul", { className: "sm-rail__menu sm-rail__menu--up", role: "menu", id: menuId("user"), "data-open": open === "user", children: [_jsx("li", { className: "sm-rail__menu-title", role: "presentation", children: name }), _jsx("li", { role: "none", children: _jsx(ThemeToggle, {}) }), _jsx("li", { role: "none", children: _jsx("button", { type: "button", role: "menuitem", className: "sm-rail__menu-item", onClick: () => { close(); onSignOut(); }, children: "Sign out" }) })] })] })] }));
83
92
  }
@@ -2,13 +2,15 @@
2
2
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
3
  // packages/smoodly/src/admin/shell/InviteModal.tsx
4
4
  // Invite a member (spec 2026-09-25 members management §4): an email, a
5
- // role, Send. The op's error shows inside; success closes it and the
6
- // page reports what happened.
5
+ // role, Send. The role is a radio list with a line under each option
6
+ // saying what it may do — the one place the difference is explained in
7
+ // the admin (the Roles page was dropped 2026-09-25). The op's error
8
+ // shows inside; success closes it and the page reports what happened.
7
9
  import { useState } from "react";
8
10
  import { MEMBER_ROLES } from "../../permissions.js";
9
11
  import { Modal } from "../ui/Modal.js";
10
- import { Banner, Button, Field, Segmented } from "../ui/primitives.js";
11
- import { roleLabel } from "./members-list.js";
12
+ import { Banner, Button, Field } from "../ui/primitives.js";
13
+ import { ROLE_DESCRIPTION, roleLabel } from "./members-list.js";
12
14
  export function InviteModal({ onClose, onInvite }) {
13
15
  const [email, setEmail] = useState("");
14
16
  const [role, setRole] = useState("editor");
@@ -20,5 +22,7 @@ export function InviteModal({ onClose, onInvite }) {
20
22
  setError(await onInvite({ email: email.trim(), role }));
21
23
  setBusy(false);
22
24
  };
23
- return (_jsx(Modal, { title: "Invite a member", width: 420, onClose: onClose, children: _jsxs("form", { onSubmit: submit, style: { display: "flex", flexDirection: "column", gap: 14 }, children: [_jsx(Field, { label: "Email", children: _jsx("input", { className: "sm-input", type: "email", required: true, autoFocus: true, value: email, onChange: (e) => setEmail(e.target.value) }) }), _jsx(Field, { label: "Role", asDiv: true, children: _jsx(Segmented, { options: MEMBER_ROLES, value: role, onChange: setRole, label: roleLabel }) }), 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, children: busy ? "Sending…" : "Send invite" })] })] }) }));
25
+ // Editor first: the default, and the one most invites want.
26
+ const roles = [...MEMBER_ROLES].reverse();
27
+ return (_jsx(Modal, { title: "Invite a member", width: 420, onClose: onClose, children: _jsxs("form", { onSubmit: submit, style: { display: "flex", flexDirection: "column", gap: 14 }, children: [_jsx(Field, { label: "Email", children: _jsx("input", { className: "sm-input", type: "email", required: true, autoFocus: true, value: email, onChange: (e) => setEmail(e.target.value) }) }), _jsx(Field, { label: "Role", asDiv: true, children: _jsx("div", { className: "sm-choices", role: "radiogroup", children: roles.map((r) => (_jsxs("label", { className: "sm-choice sm-choice--desc", children: [_jsx("input", { type: "radio", name: "role", value: r, checked: role === r, onChange: () => setRole(r) }), _jsxs("span", { children: [_jsx("span", { className: "sm-choice__label", children: roleLabel(r) }), _jsx("span", { className: "sm-choice__desc", children: ROLE_DESCRIPTION[r] })] })] }, r))) }) }), 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, children: busy ? "Sending…" : "Send invite" })] })] }) }));
24
28
  }