smoodly 0.0.23 → 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 (135) hide show
  1. package/README.md +192 -43
  2. package/bin/smoodly.js +6 -0
  3. package/dist/admin/auth.d.ts +10 -5
  4. package/dist/admin/auth.js +14 -13
  5. package/dist/admin/client-ops.js +2 -1
  6. package/dist/admin/editor/EditorView.js +20 -6
  7. package/dist/admin/editor/InsertPicker.d.ts +14 -0
  8. package/dist/admin/editor/InsertPicker.js +37 -7
  9. package/dist/admin/editor/InspectorFields.js +1 -1
  10. package/dist/admin/editor/Outline.js +2 -2
  11. package/dist/admin/editor/PageSettings.js +6 -0
  12. package/dist/admin/editor/SeoSettings.js +1 -1
  13. package/dist/admin/editor/locale-url.d.ts +7 -4
  14. package/dist/admin/editor/locale-url.js +14 -7
  15. package/dist/admin/editor/usePageLocale.js +9 -1
  16. package/dist/admin/forms/CropEditor.d.ts +4 -2
  17. package/dist/admin/forms/CropEditor.js +6 -3
  18. package/dist/admin/forms/DatePicker.d.ts +9 -0
  19. package/dist/admin/forms/DatePicker.js +133 -0
  20. package/dist/admin/forms/FieldWidget.js +11 -5
  21. package/dist/admin/forms/ImagePreview.d.ts +9 -0
  22. package/dist/admin/forms/ImagePreview.js +11 -0
  23. package/dist/admin/forms/MediaWidget.d.ts +8 -2
  24. package/dist/admin/forms/MediaWidget.js +13 -13
  25. package/dist/admin/forms/RichtextElementCard.js +1 -1
  26. package/dist/admin/forms/RichtextImageCard.js +14 -9
  27. package/dist/admin/forms/date-picker.d.ts +44 -0
  28. package/dist/admin/forms/date-picker.js +117 -0
  29. package/dist/admin/forms/elements-context.d.ts +3 -0
  30. package/dist/admin/forms/elements-context.js +7 -2
  31. package/dist/admin/forms/time-zone-context.d.ts +7 -0
  32. package/dist/admin/forms/time-zone-context.js +15 -0
  33. package/dist/admin/forms/useRichtextEditor.js +10 -1
  34. package/dist/admin/index.d.ts +2 -0
  35. package/dist/admin/next/bridge.d.ts +2 -1
  36. package/dist/admin/next/bridge.js +35 -5
  37. package/dist/admin/next/index.d.ts +2 -0
  38. package/dist/admin/ops-impl.js +73 -1
  39. package/dist/admin/ops.d.ts +22 -2
  40. package/dist/admin/serialize.d.ts +2 -0
  41. package/dist/admin/serialize.js +1 -0
  42. package/dist/admin/shell/AdminApp.js +48 -3
  43. package/dist/admin/shell/EntriesList.js +12 -21
  44. package/dist/admin/shell/EntryForm.js +63 -5
  45. package/dist/admin/shell/GlobalForm.js +22 -2
  46. package/dist/admin/shell/GlobalList.js +4 -18
  47. package/dist/admin/shell/IconRail.d.ts +3 -1
  48. package/dist/admin/shell/IconRail.js +17 -7
  49. package/dist/admin/shell/InviteModal.d.ts +6 -0
  50. package/dist/admin/shell/InviteModal.js +28 -0
  51. package/dist/admin/shell/ListView.js +2 -2
  52. package/dist/admin/shell/LoginView.js +34 -5
  53. package/dist/admin/shell/MediaScreen.js +11 -1
  54. package/dist/admin/shell/MembersPage.d.ts +2 -1
  55. package/dist/admin/shell/MembersPage.js +90 -25
  56. package/dist/admin/shell/PageDialogs.d.ts +26 -0
  57. package/dist/admin/shell/PageDialogs.js +38 -0
  58. package/dist/admin/shell/PagesList.js +100 -70
  59. package/dist/admin/shell/RouteLink.js +1 -1
  60. package/dist/admin/shell/SchemaNotice.d.ts +4 -0
  61. package/dist/admin/shell/SchemaNotice.js +4 -0
  62. package/dist/admin/shell/SetPasswordView.d.ts +7 -0
  63. package/dist/admin/shell/SetPasswordView.js +41 -0
  64. package/dist/admin/shell/SettingsView.d.ts +5 -1
  65. package/dist/admin/shell/SettingsView.js +17 -11
  66. package/dist/admin/shell/live-url.d.ts +10 -0
  67. package/dist/admin/shell/live-url.js +19 -0
  68. package/dist/admin/shell/locale-memory.d.ts +8 -0
  69. package/dist/admin/shell/locale-memory.js +23 -0
  70. package/dist/admin/shell/members-list.d.ts +15 -1
  71. package/dist/admin/shell/members-list.js +24 -7
  72. package/dist/admin/shell/page-dialogs.d.ts +9 -0
  73. package/dist/admin/shell/page-dialogs.js +19 -0
  74. package/dist/admin/shell/router.d.ts +10 -7
  75. package/dist/admin/shell/router.js +18 -9
  76. package/dist/admin/shell/session.d.ts +6 -0
  77. package/dist/admin/shell/session.js +17 -2
  78. package/dist/admin/shell/settings.d.ts +0 -6
  79. package/dist/admin/shell/settings.js +4 -5
  80. package/dist/admin/supabase-auth.js +6 -5
  81. package/dist/admin/tree-ops.js +3 -0
  82. package/dist/admin/ui/ActionMenu.js +26 -6
  83. package/dist/admin/ui/Combobox.d.ts +5 -0
  84. package/dist/admin/ui/Combobox.js +1 -1
  85. package/dist/admin/ui/ConfirmDialog.d.ts +8 -0
  86. package/dist/admin/ui/ConfirmDialog.js +30 -0
  87. package/dist/admin/ui/Dialog.d.ts +13 -0
  88. package/dist/admin/ui/Dialog.js +54 -0
  89. package/dist/admin/ui/HistoryModal.d.ts +2 -1
  90. package/dist/admin/ui/HistoryModal.js +7 -2
  91. package/dist/admin/ui/ResizeHandle.js +4 -4
  92. package/dist/admin/ui/confirm.d.ts +35 -0
  93. package/dist/admin/ui/confirm.js +57 -0
  94. package/dist/admin/ui/primitives.d.ts +19 -4
  95. package/dist/admin/ui/primitives.js +11 -5
  96. package/dist/admin/ui/resize.d.ts +7 -4
  97. package/dist/admin/ui/resize.js +7 -4
  98. package/dist/admin/ui/theme.d.ts +1 -1
  99. package/dist/admin/ui/theme.js +368 -203
  100. package/dist/admin/validate.d.ts +4 -0
  101. package/dist/admin/validate.js +28 -0
  102. package/dist/cli/load-collections.d.ts +2 -0
  103. package/dist/cli/load-collections.js +26 -0
  104. package/dist/cli/main.d.ts +13 -0
  105. package/dist/cli/main.js +62 -0
  106. package/dist/cli/migrate.d.ts +23 -0
  107. package/dist/cli/migrate.js +64 -0
  108. package/dist/config.d.ts +4 -0
  109. package/dist/config.js +9 -0
  110. package/dist/fields.d.ts +2 -0
  111. package/dist/fields.js +2 -0
  112. package/dist/index.d.ts +6 -3
  113. package/dist/index.js +4 -2
  114. package/dist/marks.d.ts +3 -0
  115. package/dist/marks.js +3 -0
  116. package/dist/member-store.d.ts +30 -1
  117. package/dist/member-store.js +37 -6
  118. package/dist/migrations.d.ts +37 -0
  119. package/dist/migrations.js +73 -0
  120. package/dist/permissions.d.ts +7 -0
  121. package/dist/permissions.js +19 -0
  122. package/dist/schema-version.d.ts +17 -0
  123. package/dist/schema-version.js +32 -0
  124. package/dist/sql-space.js +6 -0
  125. package/dist/sql.js +10 -0
  126. package/dist/store.d.ts +3 -0
  127. package/dist/store.js +4 -0
  128. package/dist/supabase-member-store.d.ts +8 -1
  129. package/dist/supabase-member-store.js +62 -2
  130. package/dist/supabase-store.d.ts +3 -0
  131. package/dist/supabase-store.js +23 -0
  132. package/dist/time-zone.d.ts +6 -0
  133. package/dist/time-zone.js +27 -0
  134. package/migrations/20260926120000_smoodly_v1.sql +535 -0
  135. package/package.json +13 -3
@@ -0,0 +1,117 @@
1
+ // Pure calendar and instant math for the date and datetime pickers (spec
2
+ // 2026-09-25 date pickers §3). No DOM. Day arithmetic runs in UTC and
3
+ // every zone conversion goes through Intl parts with an explicit zone —
4
+ // never Date's local getters — so a test can pin any zone without
5
+ // touching the process zone, and a browser's own zone never leaks in.
6
+ const DAY_RE = /^(\d{4})-(\d{2})-(\d{2})$/;
7
+ const INSTANT_RE = /^(\d{4}-\d{2}-\d{2})T(\d{2}):(\d{2}):00Z$/;
8
+ const MS_PER_DAY = 86400000;
9
+ const pad = (n) => String(n).padStart(2, "0");
10
+ export function daysInMonth(y, m) {
11
+ return new Date(Date.UTC(y, m, 0)).getUTCDate();
12
+ }
13
+ /** Strict `YYYY-MM-DD`, and a day the calendar has (no 2026-02-30). */
14
+ export function parseDay(s) {
15
+ const m = DAY_RE.exec(s);
16
+ if (!m)
17
+ return null;
18
+ const day = { y: Number(m[1]), m: Number(m[2]), d: Number(m[3]) };
19
+ if (day.m < 1 || day.m > 12 || day.d < 1 || day.d > daysInMonth(day.y, day.m))
20
+ return null;
21
+ return day;
22
+ }
23
+ export const formatDay = (day) => `${String(day.y).padStart(4, "0")}-${pad(day.m)}-${pad(day.d)}`;
24
+ export const isSameDay = (a, b) => a.y === b.y && a.m === b.m && a.d === b.d;
25
+ export const isCalendarDay = (s) => parseDay(s) !== null;
26
+ const dayOf = (t) => {
27
+ const dt = new Date(t);
28
+ return { y: dt.getUTCFullYear(), m: dt.getUTCMonth() + 1, d: dt.getUTCDate() };
29
+ };
30
+ /** A day `n` days, weeks or months on (negative: back). A month step keeps the day, clamped to the target month's length. */
31
+ export function stepDay(day, by, n) {
32
+ if (by === "month") {
33
+ const total = day.y * 12 + (day.m - 1) + n;
34
+ const y = Math.floor(total / 12);
35
+ const m = total - y * 12 + 1;
36
+ return { y, m, d: Math.min(day.d, daysInMonth(y, m)) };
37
+ }
38
+ return dayOf(Date.UTC(day.y, day.m - 1, day.d) + n * (by === "week" ? 7 : 1) * MS_PER_DAY);
39
+ }
40
+ /** Six rows of seven, Monday first, the month's days padded with the neighbours'. */
41
+ export function monthGrid(y, m) {
42
+ const lead = (new Date(Date.UTC(y, m - 1, 1)).getUTCDay() + 6) % 7; // Monday = 0
43
+ let day = stepDay({ y, m, d: 1 }, "day", -lead);
44
+ const rows = [];
45
+ for (let r = 0; r < 6; r++) {
46
+ const row = [];
47
+ for (let c = 0; c < 7; c++) {
48
+ row.push(day);
49
+ day = stepDay(day, "day", 1);
50
+ }
51
+ rows.push(row);
52
+ }
53
+ return rows;
54
+ }
55
+ /** "September 2026" in a language; English when none is given (static renders). */
56
+ export function monthLabel(y, m, locale = "en") {
57
+ return new Intl.DateTimeFormat(locale, { month: "long", year: "numeric", timeZone: "UTC" }).format(new Date(Date.UTC(y, m - 1, 1)));
58
+ }
59
+ /** Short weekday names, Monday first (2024-01-01 was a Monday). */
60
+ export function weekdayLabels(locale = "en") {
61
+ const f = new Intl.DateTimeFormat(locale, { weekday: "short", timeZone: "UTC" });
62
+ return [0, 1, 2, 3, 4, 5, 6].map((i) => f.format(new Date(Date.UTC(2024, 0, 1 + i))));
63
+ }
64
+ /** Strict ISO to the minute, seconds 00, Z. */
65
+ export function parseInstant(s) {
66
+ const m = INSTANT_RE.exec(s);
67
+ if (!m)
68
+ return null;
69
+ const day = parseDay(m[1]);
70
+ if (!day)
71
+ return null;
72
+ const hour = Number(m[2]), minute = Number(m[3]);
73
+ if (hour > 23 || minute > 59)
74
+ return null;
75
+ return new Date(Date.UTC(day.y, day.m - 1, day.d, hour, minute));
76
+ }
77
+ export const formatInstant = (date) => `${formatDay(dayOf(date.getTime()))}T${pad(date.getUTCHours())}:${pad(date.getUTCMinutes())}:00Z`;
78
+ export const isInstant = (s) => parseInstant(s) !== null;
79
+ /** The clock of a zone at an instant, through Intl; `hourCycle: "h23"` so midnight is 0, never 24. */
80
+ export function zoneParts(date, zone) {
81
+ const parts = new Intl.DateTimeFormat("en-US", {
82
+ timeZone: zone, hourCycle: "h23", year: "numeric", month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit",
83
+ }).formatToParts(date);
84
+ const get = (type) => Number(parts.find((p) => p.type === type)?.value);
85
+ return { day: { y: get("year"), m: get("month"), d: get("day") }, hour: get("hour") % 24, minute: get("minute") };
86
+ }
87
+ export function toLocalParts(iso, zone) {
88
+ const date = parseInstant(iso);
89
+ return date ? zoneParts(date, zone) : null;
90
+ }
91
+ /** The instant at which the zone's clock reads the given wall time. A
92
+ * fixed point: read the zone's clock at a guess, shift by the difference,
93
+ * twice for a daylight-saving edge. A wall time that does not exist (the
94
+ * spring-forward gap) lands on the instant an hour on. */
95
+ export function fromLocalParts(day, hour, minute, zone) {
96
+ const want = Date.UTC(day.y, day.m - 1, day.d, hour, minute);
97
+ let guess = want;
98
+ for (let i = 0; i < 2; i++) {
99
+ const p = zoneParts(new Date(guess), zone);
100
+ const wall = Date.UTC(p.day.y, p.day.m - 1, p.day.d, p.hour, p.minute);
101
+ if (wall === want)
102
+ break;
103
+ guess += want - wall;
104
+ }
105
+ return formatInstant(new Date(guess));
106
+ }
107
+ export function todayIn(zone, now = new Date()) {
108
+ return zoneParts(now, zone).day;
109
+ }
110
+ export const HOURS = Array.from({ length: 24 }, (_, i) => i);
111
+ /** The five-minute grid, plus a stored minute that is not on it. */
112
+ export function minuteOptions(current) {
113
+ const grid = Array.from({ length: 12 }, (_, i) => i * 5);
114
+ if (current === null || grid.includes(current))
115
+ return grid;
116
+ return [...grid, current].sort((a, b) => a - b);
117
+ }
@@ -1,5 +1,8 @@
1
1
  import { type ReactElement, type ReactNode } from "react";
2
2
  import type { AdminElement } from "../serialize.ts";
3
+ /** The list is handed on by content: the registry is re-serialized on every
4
+ * server render (each autosave), and the richtext editor's deps must not
5
+ * see a new array for the same elements (see useRichtextEditor, 2026-09-26). */
3
6
  export declare function ElementsProvider({ elements, children }: {
4
7
  elements: AdminElement[];
5
8
  children: ReactNode;
@@ -3,10 +3,15 @@ import { jsx as _jsx } from "react/jsx-runtime";
3
3
  // The serialized element descriptors, for the richtext widget's insert
4
4
  // menu and element cards. Defaults to none, so a form rendered without
5
5
  // the shell (tests, a bare page) still works.
6
- import { createContext, useContext } from "react";
6
+ import { createContext, useContext, useMemo } from "react";
7
7
  const Ctx = createContext([]);
8
+ /** The list is handed on by content: the registry is re-serialized on every
9
+ * server render (each autosave), and the richtext editor's deps must not
10
+ * see a new array for the same elements (see useRichtextEditor, 2026-09-26). */
8
11
  export function ElementsProvider({ elements, children }) {
9
- return _jsx(Ctx.Provider, { value: elements, children: children });
12
+ const key = JSON.stringify(elements);
13
+ const stable = useMemo(() => elements, [key]); // eslint-disable-line react-hooks/exhaustive-deps
14
+ return _jsx(Ctx.Provider, { value: stable, children: children });
10
15
  }
11
16
  export function useElements() {
12
17
  return useContext(Ctx);
@@ -0,0 +1,7 @@
1
+ import { type ReactElement, type ReactNode } from "react";
2
+ export declare function TimeZoneProvider({ zone, children }: {
3
+ zone?: string;
4
+ children: ReactNode;
5
+ }): ReactElement;
6
+ /** The configured zone, or undefined. */
7
+ export declare function useTimeZone(): string | undefined;
@@ -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,4 +1,6 @@
1
1
  export type { AdminOps, AdminOpCall, AdminOpAction, OpResult, OpError, PageCreateInput, PageGetResult, GlobalListItem, GlobalGetResult, AssetUsageItem, MembersListResult } from "./ops.ts";
2
+ export type { InviteInput, InviteResult, MemberRecord } from "../member-store.ts";
3
+ export type { MemberRole } from "../permissions.ts";
2
4
  export { createAdminOps, runAdminOp, type AdminEffects } from "./ops-impl.ts";
3
5
  export { fixedNodeSpecs, fixedNodeOf, ensureFixedNodes, type FixedNode } from "./fixed-nodes.ts";
4
6
  export { ensureGlobalItems, initialGlobalFields } from "./global-items.ts";
@@ -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
  }
@@ -1,6 +1,8 @@
1
1
  export { createAdminOpHandler } from "./op-handler.ts";
2
2
  export { createSmoodlyAdmin } from "./create-admin.tsx";
3
3
  export type { AdminOpCall, AdminOpAction, AdminOps, OpResult, MembersListResult } from "../ops.ts";
4
+ export type { InviteInput, InviteResult, MemberRecord } from "../../member-store.ts";
5
+ export type { MemberRole } from "../../permissions.ts";
4
6
  export type { AuthConfig } from "../shell/session.ts";
5
7
  export type { AdminAuth } from "../auth.ts";
6
8
  export { supabaseAdminAuth } from "../supabase-auth.ts";
@@ -1,5 +1,6 @@
1
1
  import { entryFieldsIn, entryTitleIn } from "../entry-store.js";
2
2
  import { distinctTags } from "../tags.js";
3
+ import { can, isMemberRole, normalizeRole } from "../permissions.js";
3
4
  import { ASSET_COLLECTION } from "../media.js";
4
5
  import { adminHref, adminSlugTaken, isAdminSlug } from "../admin-path.js";
5
6
  import { affectedTargets, collectionTag, entryTag, pageTag, pagesTreeTag, globalTag } from "../revalidate.js";
@@ -10,6 +11,7 @@ import { CUSTOM_NODE_TYPE, customSchema } from "../custom/index.js";
10
11
  import { SETTINGS_KEY, CSS_KEY } from "../global.js";
11
12
  import { withDefaults } from "../field-defaults.js";
12
13
  import { elementSchemas } from "../richtext-walk.js";
14
+ import { SCHEMA_VERSION } from "../schema-version.js";
13
15
  const ok = (data) => ({ ok: true, data });
14
16
  /** A history row without its content (spec 2026-09-13 history §6). */
15
17
  const summary = ({ id, savedAt }) => ({ id, savedAt });
@@ -87,7 +89,7 @@ const classify = (e) => {
87
89
  return err("validation", message);
88
90
  if (message.includes("not a valid slug"))
89
91
  return err("validation", message);
90
- if (message.includes("no page") || message.includes("no entry") || message.includes("no collection") || message.includes("no asset"))
92
+ if (message.includes("no page") || message.includes("no entry") || message.includes("no collection") || message.includes("no asset") || message.includes("no member"))
91
93
  return err("not_found", message);
92
94
  if (message.includes("not pending"))
93
95
  return err("conflict", message);
@@ -201,6 +203,26 @@ export function createAdminOps(deps) {
201
203
  const assets = deps.assets;
202
204
  const noMedia = () => err("internal", "smoodly: no media store is configured — pass `assets` to createAdminOpHandler.");
203
205
  const noMembers = () => err("internal", "smoodly: no member store is configured — pass `members` to createAdminOpHandler.");
206
+ const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
207
+ /** unauthorized unless the actor may manage members; no actor (scripts, the seed) may. */
208
+ const membersDenied = () => deps.actor && !can(deps.actor.role, "members.manage") ? err("unauthorized", "smoodly: only an admin can manage members.") : null;
209
+ /** blocked when the control plane manages this space's members (a row, never a mode check). */
210
+ const membersManaged = async () => (await deps.members.consoleUrl()) ? err("blocked", "smoodly: members are managed at smoodly.io.") : null;
211
+ /** The two guards on setRole and remove: never your own row, never the last admin (unless the change keeps them admin). */
212
+ const memberGuards = async (userId, staysAdmin) => {
213
+ if (deps.actor?.userId === userId)
214
+ return err("blocked", "smoodly: you cannot change your own membership.");
215
+ if (staysAdmin)
216
+ return null;
217
+ const rows = await deps.members.list();
218
+ const target = rows.find((m) => m.userId === userId);
219
+ if (!target)
220
+ return null; // the store answers not_found
221
+ const admins = rows.filter((m) => normalizeRole(m.role) === "admin");
222
+ if (normalizeRole(target.role) === "admin" && admins.length <= 1)
223
+ return err("blocked", "smoodly: this site needs at least one admin.");
224
+ return null;
225
+ };
204
226
  const mb = (bytes) => `${Math.round((bytes / (1024 * 1024)) * 10) / 10} MB`;
205
227
  const isDim = (v) => v === undefined || (typeof v === "number" && Number.isFinite(v) && v >= 0);
206
228
  /** What an asset's usage row points at, labelled for the Media screen. */
@@ -854,9 +876,59 @@ export function createAdminOps(deps) {
854
876
  list: () => guard(async () => {
855
877
  if (!deps.members)
856
878
  return noMembers();
879
+ const denied = membersDenied();
880
+ if (denied)
881
+ return denied;
857
882
  const [members, consoleUrl] = await Promise.all([deps.members.list(), deps.members.consoleUrl()]);
858
883
  return ok({ members, consoleUrl });
859
884
  }),
885
+ me: () => guard(async () => ok({ role: deps.actor?.role ?? "admin" })),
886
+ invite: (input) => guard(async () => {
887
+ if (!deps.members)
888
+ return noMembers();
889
+ const refused = membersDenied() ?? (await membersManaged());
890
+ if (refused)
891
+ return refused;
892
+ const email = typeof input?.email === "string" ? input.email.trim() : "";
893
+ if (!EMAIL_RE.test(email))
894
+ return err("validation", "smoodly: enter a valid email address.");
895
+ if (!isMemberRole(input.role))
896
+ return err("validation", "smoodly: the role must be admin or editor.");
897
+ if (typeof input.redirectTo !== "string" || !input.redirectTo)
898
+ return err("validation", "smoodly: the invite needs a return URL.");
899
+ const rows = await deps.members.list();
900
+ if (rows.some((m) => m.email?.toLowerCase() === email.toLowerCase()))
901
+ return err("conflict", "smoodly: already a member.");
902
+ return ok(await deps.members.invite({ email, role: input.role, redirectTo: input.redirectTo }));
903
+ }),
904
+ setRole: (userId, role) => guard(async () => {
905
+ if (!deps.members)
906
+ return noMembers();
907
+ const refused = membersDenied() ?? (await membersManaged());
908
+ if (refused)
909
+ return refused;
910
+ if (!isMemberRole(role))
911
+ return err("validation", "smoodly: the role must be admin or editor.");
912
+ const guardHit = await memberGuards(userId, role === "admin");
913
+ if (guardHit)
914
+ return guardHit;
915
+ return ok(await deps.members.setRole(userId, role));
916
+ }),
917
+ remove: (userId) => guard(async () => {
918
+ if (!deps.members)
919
+ return noMembers();
920
+ const refused = membersDenied() ?? (await membersManaged());
921
+ if (refused)
922
+ return refused;
923
+ const guardHit = await memberGuards(userId, false);
924
+ if (guardHit)
925
+ return guardHit;
926
+ await deps.members.remove(userId);
927
+ return ok(null);
928
+ }),
929
+ },
930
+ schema: {
931
+ status: () => guard(async () => ok({ database: await pages.schemaVersion(), required: SCHEMA_VERSION })),
860
932
  },
861
933
  preview: {
862
934
  enable: () => guard(async () => (await effects?.draft?.enable(), ok(null))),
@@ -3,7 +3,8 @@ import type { PageTree } from "../render.tsx";
3
3
  import type { EntryRecord, EntryUsage } from "../entry-store.ts";
4
4
  import type { AssetRecord, AssetMeta } from "../media.ts";
5
5
  import type { AssetPage, AssetQuery, FinishInput, UploadTicket } from "../asset-store.ts";
6
- import type { MemberRecord } from "../member-store.ts";
6
+ import type { InviteInput, InviteResult, MemberRecord } from "../member-store.ts";
7
+ import type { MemberRole } from "../permissions.ts";
7
8
  import type { FieldError } from "./validate.ts";
8
9
  export type OpError = {
9
10
  ok: false;
@@ -97,6 +98,11 @@ export type MembersListResult = {
97
98
  members: MemberRecord[];
98
99
  consoleUrl: string | null;
99
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
+ };
100
106
  export type AdminOps = {
101
107
  pages: {
102
108
  /** Lists every node, materializing fixed pages and collection mounts (in every supported locale) first. */
@@ -196,8 +202,22 @@ export type AdminOps = {
196
202
  usage(id: string): Promise<OpResult<AssetUsageItem[]>>;
197
203
  };
198
204
  members: {
199
- /** The space's members, read-only in this cut; `consoleUrl` set means smoodly.io manages them. */
205
+ /** The space's members; admins only. `consoleUrl` set means smoodly.io manages them and every write below is blocked. */
200
206
  list(): Promise<OpResult<MembersListResult>>;
207
+ /** The caller's role; every member. The shell hides Settings from editors with it. */
208
+ me(): Promise<OpResult<{
209
+ role: MemberRole;
210
+ }>>;
211
+ /** Admins only, self-host only: invites (or adds an existing account), refuses a duplicate row (spec 2026-09-25 members management §3). */
212
+ invite(input: InviteInput): Promise<OpResult<InviteResult>>;
213
+ /** Admins only, self-host only; never your own row, never the last admin. */
214
+ setRole(userId: string, role: MemberRole): Promise<OpResult<MemberRecord>>;
215
+ /** Admins only, self-host only; the same two guards. The Auth account stays. */
216
+ remove(userId: string): Promise<OpResult<null>>;
217
+ };
218
+ schema: {
219
+ /** Every member. The shell calls it once on mount and blocks when behind. */
220
+ status(): Promise<OpResult<SchemaStatus>>;
201
221
  };
202
222
  preview: {
203
223
  enable(): 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] },
@@ -12,7 +12,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
12
12
  // (shell/IconRail.tsx), then the main column whose views own their own
13
13
  // 48px top bar (the editor adds the third, its inspector). The shell is
14
14
  // viewport-height; every column scrolls inside itself.
15
- import { useState } from "react";
15
+ import { useEffect, useState } from "react";
16
16
  import { makeClientOps } from "../client-ops.js";
17
17
  import { PagesList } from "./PagesList.js";
18
18
  import { EntriesList } from "./EntriesList.js";
@@ -21,16 +21,22 @@ import { GlobalList } from "./GlobalList.js";
21
21
  import { GlobalForm } from "./GlobalForm.js";
22
22
  import { EditorView } from "../editor/EditorView.js";
23
23
  import { LoginView } from "./LoginView.js";
24
+ import { SetPasswordView } from "./SetPasswordView.js";
24
25
  import { useAdminSession } from "./session.js";
25
26
  import { IconRail } from "./IconRail.js";
27
+ import { ConfirmHost } from "../ui/ConfirmDialog.js";
26
28
  import { useRoute } from "./router.js";
27
29
  import { AdminPathProvider } from "./base.js";
28
30
  import { mountMismatch } from "../../admin-path.js";
31
+ import { can } from "../../permissions.js";
29
32
  import { ErrorPane } from "./ErrorPane.js";
30
33
  import { MediaScreen } from "./MediaScreen.js";
31
34
  import { SettingsView } from "./SettingsView.js";
32
35
  import { AssetsProvider } from "../forms/assets-context.js";
33
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";
34
40
  export function AdminApp({ registry, op, auth }) {
35
41
  const session = useAdminSession(auth);
36
42
  const [ops] = useState(() => makeClientOps(op, session.token));
@@ -38,6 +44,40 @@ export function AdminApp({ registry, op, auth }) {
38
44
  const [head = "pages", tail] = route.segments.length ? route.segments : ["pages"];
39
45
  const isCollection = registry.collections.some((c) => c.name === head);
40
46
  const email = session.state.status === "signed_in" ? session.state.session.user.email : undefined;
47
+ // The role rides on members.me (members management §2): Settings hides
48
+ // from an editor, and the area itself refuses. Unknown until it answers.
49
+ const signedIn = session.state.status === "signed_in";
50
+ const [role, setRole] = useState(null);
51
+ useEffect(() => {
52
+ if (!signedIn) {
53
+ setRole(null);
54
+ return;
55
+ }
56
+ let live = true;
57
+ ops.members.me()
58
+ .then((r) => { if (live)
59
+ setRole(r.ok ? r.data.role : "editor"); })
60
+ .catch(() => { if (live)
61
+ setRole("editor"); });
62
+ return () => { live = false; };
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]);
80
+ const userId = session.state.status === "signed_in" ? session.state.session.user.id : "";
41
81
  if (session.state.status === "loading") {
42
82
  return (_jsx("div", { style: { height: "100vh", display: "grid", placeItems: "center", color: "var(--text-muted)" }, children: "Loading\u2026" }));
43
83
  }
@@ -47,6 +87,11 @@ export function AdminApp({ registry, op, auth }) {
47
87
  }
48
88
  if (session.state.status === "signed_out")
49
89
  return _jsx(LoginView, { client: session.client, providers: auth.providers });
90
+ // An invite or reset link signed the person in: Set password before the shell (members management §6).
91
+ if (session.arrival)
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 });
50
95
  let main;
51
96
  if (head === "pages" && !tail)
52
97
  main = _jsx(PagesList, { ops: ops, registry: registry });
@@ -61,10 +106,10 @@ export function AdminApp({ registry, op, auth }) {
61
106
  else if (head === "globals" && tail)
62
107
  main = _jsx(GlobalForm, { ops: ops, registry: registry, name: decodeURIComponent(tail) });
63
108
  else if (head === "settings")
64
- main = _jsx(SettingsView, { ops: ops, tail: tail });
109
+ main = _jsx(SettingsView, { ops: ops, tail: tail, role: role, userId: userId });
65
110
  else if (head === "media")
66
111
  main = _jsx(MediaScreen, {});
67
112
  else
68
113
  main = _jsx(ErrorPane, { message: "Not found." });
69
- 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, 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, {})] }) }) }) }));
70
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 {