smoodly 0.0.24 → 0.0.26

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 (148) hide show
  1. package/README.md +214 -40
  2. package/bin/smoodly.js +6 -0
  3. package/dist/admin/client-ops.js +1 -0
  4. package/dist/admin/editor/EditorView.js +100 -22
  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/autosave.d.ts +4 -1
  12. package/dist/admin/editor/autosave.js +24 -1
  13. package/dist/admin/editor/global-edit.d.ts +5 -1
  14. package/dist/admin/editor/global-edit.js +15 -5
  15. package/dist/admin/editor/locale-url.d.ts +7 -4
  16. package/dist/admin/editor/locale-url.js +14 -7
  17. package/dist/admin/editor/useGlobalEdit.d.ts +1 -1
  18. package/dist/admin/editor/useGlobalEdit.js +1 -1
  19. package/dist/admin/editor/usePageLocale.js +9 -1
  20. package/dist/admin/forms/CropEditor.d.ts +4 -2
  21. package/dist/admin/forms/CropEditor.js +6 -3
  22. package/dist/admin/forms/DatePicker.d.ts +9 -0
  23. package/dist/admin/forms/DatePicker.js +133 -0
  24. package/dist/admin/forms/FieldWidget.js +11 -5
  25. package/dist/admin/forms/ImagePreview.d.ts +9 -0
  26. package/dist/admin/forms/ImagePreview.js +11 -0
  27. package/dist/admin/forms/MediaWidget.d.ts +8 -2
  28. package/dist/admin/forms/MediaWidget.js +13 -13
  29. package/dist/admin/forms/RichtextElementCard.js +1 -1
  30. package/dist/admin/forms/RichtextImageCard.js +14 -9
  31. package/dist/admin/forms/date-picker.d.ts +44 -0
  32. package/dist/admin/forms/date-picker.js +117 -0
  33. package/dist/admin/forms/elements-context.d.ts +3 -0
  34. package/dist/admin/forms/elements-context.js +7 -2
  35. package/dist/admin/forms/time-zone-context.d.ts +7 -0
  36. package/dist/admin/forms/time-zone-context.js +15 -0
  37. package/dist/admin/forms/useRichtextEditor.js +10 -1
  38. package/dist/admin/next/bridge.d.ts +2 -1
  39. package/dist/admin/next/bridge.js +35 -5
  40. package/dist/admin/next/create-admin.d.ts +4 -0
  41. package/dist/admin/next/create-admin.js +3 -1
  42. package/dist/admin/next/index.d.ts +1 -0
  43. package/dist/admin/ops-impl.js +74 -24
  44. package/dist/admin/ops.d.ts +31 -12
  45. package/dist/admin/serialize.d.ts +2 -0
  46. package/dist/admin/serialize.js +1 -0
  47. package/dist/admin/shell/AdminApp.d.ts +3 -1
  48. package/dist/admin/shell/AdminApp.js +42 -2
  49. package/dist/admin/shell/EntriesList.js +17 -22
  50. package/dist/admin/shell/EntryForm.js +91 -13
  51. package/dist/admin/shell/GlobalForm.js +68 -9
  52. package/dist/admin/shell/GlobalList.js +10 -20
  53. package/dist/admin/shell/IconRail.js +14 -5
  54. package/dist/admin/shell/InviteModal.js +9 -5
  55. package/dist/admin/shell/ListView.js +2 -2
  56. package/dist/admin/shell/MediaScreen.js +11 -1
  57. package/dist/admin/shell/MembersPage.js +33 -15
  58. package/dist/admin/shell/PageDialogs.d.ts +26 -0
  59. package/dist/admin/shell/PageDialogs.js +38 -0
  60. package/dist/admin/shell/PagesList.js +101 -74
  61. package/dist/admin/shell/RouteLink.js +1 -1
  62. package/dist/admin/shell/SchemaNotice.d.ts +4 -0
  63. package/dist/admin/shell/SchemaNotice.js +4 -0
  64. package/dist/admin/shell/SettingsView.js +11 -26
  65. package/dist/admin/shell/live-url.d.ts +4 -3
  66. package/dist/admin/shell/live-url.js +12 -3
  67. package/dist/admin/shell/locale-memory.d.ts +8 -0
  68. package/dist/admin/shell/locale-memory.js +23 -0
  69. package/dist/admin/shell/members-list.d.ts +2 -0
  70. package/dist/admin/shell/members-list.js +5 -0
  71. package/dist/admin/shell/page-dialogs.d.ts +9 -0
  72. package/dist/admin/shell/page-dialogs.js +19 -0
  73. package/dist/admin/shell/presence-context.d.ts +9 -0
  74. package/dist/admin/shell/presence-context.js +35 -0
  75. package/dist/admin/shell/presence-realtime.d.ts +3 -0
  76. package/dist/admin/shell/presence-realtime.js +22 -0
  77. package/dist/admin/shell/presence.d.ts +51 -0
  78. package/dist/admin/shell/presence.js +90 -0
  79. package/dist/admin/shell/router.d.ts +10 -7
  80. package/dist/admin/shell/router.js +18 -9
  81. package/dist/admin/shell/settings.d.ts +0 -6
  82. package/dist/admin/shell/settings.js +4 -5
  83. package/dist/admin/shell/stale.d.ts +28 -0
  84. package/dist/admin/shell/stale.js +17 -0
  85. package/dist/admin/tree-ops.js +3 -0
  86. package/dist/admin/ui/ActionMenu.js +26 -6
  87. package/dist/admin/ui/Combobox.d.ts +5 -0
  88. package/dist/admin/ui/Combobox.js +1 -1
  89. package/dist/admin/ui/ConfirmDialog.d.ts +8 -0
  90. package/dist/admin/ui/ConfirmDialog.js +30 -0
  91. package/dist/admin/ui/Dialog.d.ts +13 -0
  92. package/dist/admin/ui/Dialog.js +54 -0
  93. package/dist/admin/ui/HistoryModal.d.ts +2 -1
  94. package/dist/admin/ui/HistoryModal.js +7 -2
  95. package/dist/admin/ui/LockIcon.d.ts +4 -0
  96. package/dist/admin/ui/LockIcon.js +12 -0
  97. package/dist/admin/ui/PresenceLine.d.ts +6 -0
  98. package/dist/admin/ui/PresenceLine.js +11 -0
  99. package/dist/admin/ui/ResizeHandle.js +4 -4
  100. package/dist/admin/ui/StaleBanner.d.ts +5 -0
  101. package/dist/admin/ui/StaleBanner.js +9 -0
  102. package/dist/admin/ui/confirm.d.ts +35 -0
  103. package/dist/admin/ui/confirm.js +57 -0
  104. package/dist/admin/ui/primitives.d.ts +19 -4
  105. package/dist/admin/ui/primitives.js +12 -5
  106. package/dist/admin/ui/resize.d.ts +7 -4
  107. package/dist/admin/ui/resize.js +7 -4
  108. package/dist/admin/ui/theme.d.ts +1 -1
  109. package/dist/admin/ui/theme.js +373 -204
  110. package/dist/admin/validate.d.ts +4 -0
  111. package/dist/admin/validate.js +28 -0
  112. package/dist/cli/load-collections.d.ts +2 -0
  113. package/dist/cli/load-collections.js +26 -0
  114. package/dist/cli/main.d.ts +13 -0
  115. package/dist/cli/main.js +62 -0
  116. package/dist/cli/migrate.d.ts +23 -0
  117. package/dist/cli/migrate.js +64 -0
  118. package/dist/config.d.ts +4 -0
  119. package/dist/config.js +9 -0
  120. package/dist/draft-token.d.ts +31 -0
  121. package/dist/draft-token.js +41 -0
  122. package/dist/entry-store.d.ts +11 -8
  123. package/dist/entry-store.js +24 -10
  124. package/dist/env.d.ts +9 -0
  125. package/dist/env.js +19 -0
  126. package/dist/fields.d.ts +2 -0
  127. package/dist/fields.js +2 -0
  128. package/dist/index.d.ts +6 -3
  129. package/dist/index.js +4 -2
  130. package/dist/marks.d.ts +3 -0
  131. package/dist/marks.js +3 -0
  132. package/dist/migrations.d.ts +37 -0
  133. package/dist/migrations.js +73 -0
  134. package/dist/schema-version.d.ts +17 -0
  135. package/dist/schema-version.js +32 -0
  136. package/dist/sql-space.js +17 -0
  137. package/dist/sql.js +21 -0
  138. package/dist/store.d.ts +14 -8
  139. package/dist/store.js +30 -8
  140. package/dist/supabase-entry-store.d.ts +15 -5
  141. package/dist/supabase-entry-store.js +87 -22
  142. package/dist/supabase-store.d.ts +18 -5
  143. package/dist/supabase-store.js +83 -20
  144. package/dist/time-zone.d.ts +6 -0
  145. package/dist/time-zone.js +27 -0
  146. package/migrations/20260926120000_smoodly_v1.sql +535 -0
  147. package/migrations/20260926130000_smoodly_v2.sql +26 -0
  148. package/package.json +13 -3
@@ -0,0 +1,8 @@
1
+ import type { AdminRegistry } from "../serialize.ts";
2
+ export declare const LOCALE_KEY = "smoodly-locale";
3
+ type Locales = AdminRegistry["locales"];
4
+ /** The stored locale when the config supports it, else the default. */
5
+ export declare function resolveLocale(stored: string | null | undefined, locales: Locales): string;
6
+ export declare function rememberedLocale(locales: Locales): string;
7
+ export declare function rememberLocale(locale: string): void;
8
+ export {};
@@ -0,0 +1,23 @@
1
+ export const LOCALE_KEY = "smoodly-locale";
2
+ /** The stored locale when the config supports it, else the default. */
3
+ export function resolveLocale(stored, locales) {
4
+ return stored && locales.supported.includes(stored) ? stored : locales.default;
5
+ }
6
+ export function rememberedLocale(locales) {
7
+ let stored = null;
8
+ try {
9
+ stored = window.localStorage.getItem(LOCALE_KEY);
10
+ }
11
+ catch {
12
+ // storage unavailable (private mode, no window) — the default is fine
13
+ }
14
+ return resolveLocale(stored, locales);
15
+ }
16
+ export function rememberLocale(locale) {
17
+ try {
18
+ window.localStorage.setItem(LOCALE_KEY, locale);
19
+ }
20
+ catch {
21
+ // not remembered — the screen still switches
22
+ }
23
+ }
@@ -12,6 +12,8 @@ export type MemberRow = {
12
12
  lastAdmin: boolean;
13
13
  };
14
14
  export declare const roleLabel: (role: string) => string;
15
+ /** What each role may do, in one line — the invite modal's radio descriptions (permissions.ts decides). */
16
+ export declare const ROLE_DESCRIPTION: Record<MemberRole, string>;
15
17
  export declare function memberRows(members: MemberRecord[], opts: {
16
18
  me: string;
17
19
  }): MemberRow[];
@@ -1,5 +1,10 @@
1
1
  import { normalizeRole } from "../../permissions.js";
2
2
  export const roleLabel = (role) => (role ? role[0].toUpperCase() + role.slice(1) : "");
3
+ /** What each role may do, in one line — the invite modal's radio descriptions (permissions.ts decides). */
4
+ export const ROLE_DESCRIPTION = {
5
+ editor: "Edits, publishes and deletes content and media.",
6
+ admin: "Everything an editor can, and Settings: invites, re-roles and removes members.",
7
+ };
3
8
  export function memberRows(members, opts) {
4
9
  const admins = members.filter((m) => normalizeRole(m.role) === "admin").length;
5
10
  return members.map((m) => {
@@ -0,0 +1,9 @@
1
+ /** A URL segment from a title: lowercase ASCII letters, digits and
2
+ * hyphens (the path grammar, DESIGN.md OQ 24), diacritics stripped
3
+ * (ä → a, é → e), everything else a hyphen, no leading, trailing or
4
+ * doubled hyphens. */
5
+ export declare function slugify(title: string): string;
6
+ /** The slug field follows the title until the editor types a slug of
7
+ * their own; a slug equal to what the previous title produced is still
8
+ * "following". */
9
+ export declare function followsTitle(slug: string, previousTitle: string): boolean;
@@ -0,0 +1,19 @@
1
+ // Pure helpers for the page dialogs (PageDialogs.tsx).
2
+ /** A URL segment from a title: lowercase ASCII letters, digits and
3
+ * hyphens (the path grammar, DESIGN.md OQ 24), diacritics stripped
4
+ * (ä → a, é → e), everything else a hyphen, no leading, trailing or
5
+ * doubled hyphens. */
6
+ export function slugify(title) {
7
+ return title
8
+ .normalize("NFD")
9
+ .replace(/[̀-ͯ]/g, "")
10
+ .toLowerCase()
11
+ .replace(/[^a-z0-9]+/g, "-")
12
+ .replace(/^-+|-+$/g, "");
13
+ }
14
+ /** The slug field follows the title until the editor types a slug of
15
+ * their own; a slug equal to what the previous title produced is still
16
+ * "following". */
17
+ export function followsTitle(slug, previousTitle) {
18
+ return slug === "" || slug === slugify(previousTitle);
19
+ }
@@ -0,0 +1,9 @@
1
+ import type { Presence, PresenceState, PresenceTarget, SavedEvent } from "./presence.ts";
2
+ export declare const PresenceProvider: import("react").Provider<Presence | null>;
3
+ export declare const usePresence: () => Presence | null;
4
+ export declare function usePresenceState(): PresenceState;
5
+ /** Track `target` while mounted; null tracks nothing. Keys on the target's three fields. */
6
+ export declare function useTrackPresence(target: PresenceTarget | null): void;
7
+ /** Hear another person's save of exactly `target`. The handler is read at call time
8
+ * (re-subscribed every render on purpose: one Set entry, always fresh state). */
9
+ export declare function useSavedElsewhere(target: PresenceTarget | null, handler: (event: SavedEvent) => void): void;
@@ -0,0 +1,35 @@
1
+ "use client";
2
+ // The React side of presence: one controller per signed-in session from AdminApp,
3
+ // the hooks the lists and the three views use.
4
+ import { createContext, useContext, useEffect, useSyncExternalStore } from "react";
5
+ const OFF = { status: "off", others: [] };
6
+ const PresenceContext = createContext(null);
7
+ export const PresenceProvider = PresenceContext.Provider;
8
+ export const usePresence = () => useContext(PresenceContext);
9
+ export function usePresenceState() {
10
+ const p = usePresence();
11
+ return useSyncExternalStore(p ? p.subscribe : () => () => { }, p ? p.getState : () => OFF, () => OFF);
12
+ }
13
+ /** Track `target` while mounted; null tracks nothing. Keys on the target's three fields. */
14
+ export function useTrackPresence(target) {
15
+ const p = usePresence();
16
+ const kind = target?.kind, id = target?.id, locale = target?.locale;
17
+ useEffect(() => {
18
+ if (!p || !kind || !id || !locale)
19
+ return;
20
+ p.track({ kind, id, locale });
21
+ return () => p.track(null);
22
+ }, [p, kind, id, locale]);
23
+ }
24
+ /** Hear another person's save of exactly `target`. The handler is read at call time
25
+ * (re-subscribed every render on purpose: one Set entry, always fresh state). */
26
+ export function useSavedElsewhere(target, handler) {
27
+ const p = usePresence();
28
+ const kind = target?.kind, id = target?.id, locale = target?.locale;
29
+ useEffect(() => {
30
+ if (!p || !kind || !id || !locale)
31
+ return;
32
+ return p.onSaved((event) => { if (event.kind === kind && event.id === id && event.locale === locale)
33
+ handler(event); });
34
+ });
35
+ }
@@ -0,0 +1,3 @@
1
+ import type { ContentConfig } from "../../env.ts";
2
+ import type { PresenceChannel } from "./presence.ts";
3
+ export declare function connectRealtime(config: ContentConfig, email: string): PresenceChannel;
@@ -0,0 +1,22 @@
1
+ "use client";
2
+ // The real channel behind `createPresence` (spec 2026-09-26 concurrency §5–§6): one
3
+ // supabase-js client on the CONTENT project with its public key (the anon key in
4
+ // self-host, the read key in the cloud), a private channel named after the space,
5
+ // presence keyed by the email. A key that is a JWT is also the realtime token.
6
+ import { createClient } from "@supabase/supabase-js";
7
+ export function connectRealtime(config, email) {
8
+ const client = createClient(config.url, config.key, { auth: { persistSession: false, autoRefreshToken: false } });
9
+ if (config.key.split(".").length === 3)
10
+ void client.realtime.setAuth(config.key);
11
+ const channel = client.channel(`space:${config.spaceId}`, { config: { private: true, presence: { key: email } } });
12
+ return {
13
+ subscribe: (onStatus) => { channel.subscribe((status) => onStatus(status)); },
14
+ track: (state) => channel.track(state),
15
+ untrack: () => channel.untrack(),
16
+ send: (message) => channel.send(message),
17
+ onSync: (listener) => { channel.on("presence", { event: "sync" }, listener); },
18
+ presenceState: () => channel.presenceState(),
19
+ onBroadcast: (event, listener) => { channel.on("broadcast", { event }, (m) => listener(m.payload)); },
20
+ unsubscribe: async () => { await channel.unsubscribe(); await client.removeAllChannels(); },
21
+ };
22
+ }
@@ -0,0 +1,51 @@
1
+ export type PresenceTarget = {
2
+ kind: "page" | "entry" | "global";
3
+ id: string;
4
+ locale: string;
5
+ };
6
+ export type PresenceEntry = PresenceTarget & {
7
+ email: string;
8
+ };
9
+ export type SavedEvent = PresenceTarget & {
10
+ email: string;
11
+ savedAt: string | null;
12
+ };
13
+ export type PresenceStatus = "off" | "joining" | "joined" | "failed";
14
+ export type PresenceState = {
15
+ status: PresenceStatus;
16
+ others: PresenceEntry[];
17
+ };
18
+ /** The slice of a Realtime channel presence uses. */
19
+ export type PresenceChannel = {
20
+ subscribe(onStatus: (status: string) => void): void;
21
+ track(state: Record<string, unknown>): Promise<unknown>;
22
+ untrack(): Promise<unknown>;
23
+ send(message: {
24
+ type: "broadcast";
25
+ event: string;
26
+ payload: Record<string, unknown>;
27
+ }): Promise<unknown>;
28
+ onSync(listener: () => void): void;
29
+ /** key (the email) → that person's tabs' states. */
30
+ presenceState(): Record<string, Record<string, unknown>[]>;
31
+ onBroadcast(event: string, listener: (payload: Record<string, unknown>) => void): void;
32
+ unsubscribe(): Promise<unknown>;
33
+ };
34
+ export type Presence = {
35
+ join(): void;
36
+ /** What this tab edits now; null on leaving a view. Re-sent on rejoin. */
37
+ track(target: PresenceTarget | null): void;
38
+ /** After a guarded op acked: tell the others. */
39
+ announce(target: PresenceTarget, savedAt: string | null): void;
40
+ onSaved(listener: (event: SavedEvent) => void): () => void;
41
+ getState(): PresenceState;
42
+ subscribe(listener: () => void): () => void;
43
+ dispose(): void;
44
+ };
45
+ export declare const shortName: (email: string) => string;
46
+ /** The short names editing `target`, other than me, sorted and deduped. */
47
+ export declare function editorsOf(others: PresenceEntry[], target: PresenceTarget): string[];
48
+ export declare function createPresence(opts: {
49
+ email: string;
50
+ connect: () => PresenceChannel;
51
+ }): Presence;
@@ -0,0 +1,90 @@
1
+ // Editing presence (spec 2026-09-26 concurrency §5): who has what open, and a
2
+ // "saved" announcement, over ONE channel per space. Framework-free: the channel
3
+ // is injected (presence-realtime.ts builds the supabase-js one; tests fake it),
4
+ // the React glue is presence-context.tsx. Never awaited by anything — a failed
5
+ // join leaves the admin exactly as without presence.
6
+ const sameTarget = (a, b) => a.kind === b.kind && a.id === b.id && a.locale === b.locale;
7
+ export const shortName = (email) => email.split("@")[0];
8
+ /** The short names editing `target`, other than me, sorted and deduped. */
9
+ export function editorsOf(others, target) {
10
+ return [...new Set(others.filter((o) => sameTarget(o, target)).map((o) => shortName(o.email)))].sort();
11
+ }
12
+ const isTarget = (v) => typeof v === "object" && v !== null
13
+ && ["page", "entry", "global"].includes(v.kind)
14
+ && typeof v.id === "string" && typeof v.locale === "string";
15
+ export function createPresence(opts) {
16
+ let state = { status: "off", others: [] };
17
+ let channel = null;
18
+ let current = null;
19
+ const listeners = new Set();
20
+ const savedListeners = new Set();
21
+ const set = (next) => { state = { ...state, ...next }; for (const l of listeners)
22
+ l(); };
23
+ const readOthers = () => {
24
+ if (!channel)
25
+ return [];
26
+ const out = [];
27
+ for (const [email, metas] of Object.entries(channel.presenceState())) {
28
+ if (email === opts.email)
29
+ continue;
30
+ for (const meta of metas)
31
+ if (isTarget(meta))
32
+ out.push({ email, kind: meta.kind, id: meta.id, locale: meta.locale });
33
+ }
34
+ return out;
35
+ };
36
+ return {
37
+ join() {
38
+ if (channel)
39
+ return;
40
+ try {
41
+ channel = opts.connect();
42
+ }
43
+ catch {
44
+ set({ status: "failed" });
45
+ return;
46
+ }
47
+ set({ status: "joining" });
48
+ channel.onSync(() => set({ others: readOthers() }));
49
+ channel.onBroadcast("saved", (payload) => {
50
+ const { email, savedAt } = payload;
51
+ if (!isTarget(payload) || typeof email !== "string" || email === opts.email)
52
+ return;
53
+ const event = { kind: payload.kind, id: payload.id, locale: payload.locale, email, savedAt: typeof savedAt === "string" ? savedAt : null };
54
+ for (const l of savedListeners)
55
+ l(event);
56
+ });
57
+ channel.subscribe((status) => {
58
+ if (status === "SUBSCRIBED") {
59
+ set({ status: "joined" });
60
+ if (current && channel)
61
+ void channel.track(current).catch(() => { });
62
+ }
63
+ else if (status === "CHANNEL_ERROR" || status === "TIMED_OUT" || status === "CLOSED") {
64
+ set({ status: "failed", others: [] });
65
+ }
66
+ });
67
+ },
68
+ track(target) {
69
+ current = target;
70
+ if (state.status !== "joined" || !channel)
71
+ return;
72
+ void (target ? channel.track(target) : channel.untrack()).catch(() => { });
73
+ },
74
+ announce(target, savedAt) {
75
+ if (state.status !== "joined" || !channel)
76
+ return;
77
+ void channel.send({ type: "broadcast", event: "saved", payload: { ...target, email: opts.email, savedAt } }).catch(() => { });
78
+ },
79
+ onSaved(listener) { savedListeners.add(listener); return () => { savedListeners.delete(listener); }; },
80
+ getState: () => state,
81
+ subscribe(listener) { listeners.add(listener); return () => { listeners.delete(listener); }; },
82
+ dispose() {
83
+ const c = channel;
84
+ channel = null;
85
+ set({ status: "off", others: [] });
86
+ if (c)
87
+ void c.unsubscribe().catch(() => { });
88
+ },
89
+ };
90
+ }
@@ -1,3 +1,4 @@
1
+ import { type ConfirmOptions } from "../ui/confirm.ts";
1
2
  export type Route = {
2
3
  pathname: string;
3
4
  segments: string[];
@@ -18,18 +19,20 @@ export declare function isPlainLeftClick(e: {
18
19
  altKey: boolean;
19
20
  defaultPrevented: boolean;
20
21
  }, target?: string | null): boolean;
21
- export declare const LEAVE_MESSAGE = "Discard unsaved changes?";
22
+ /** The leave question, asked in the admin's own dialog (ui/confirm.ts). */
23
+ export declare const LEAVE_CONFIRM: ConfirmOptions;
22
24
  /** Leaving is allowed when no guard is dirty, else when the editor
23
25
  * confirms — asked once, however many guards are dirty. */
24
- export declare function mayLeave(guards: Iterable<() => boolean>, confirm: (message: string) => boolean): boolean;
25
- /** Move to `href` in place; true when the move happened. A push asks the
26
- * leave guard; a replace never leaves the view, so it does not ask.
27
- * `force` skips the guard for a view that has just saved or deleted its
28
- * record — React has not flushed its setDirty(false) yet. */
26
+ export declare function mayLeave(guards: Iterable<() => boolean>, confirm: (options: ConfirmOptions) => boolean | Promise<boolean>): Promise<boolean>;
27
+ /** Move to `href` in place; resolves true when the move happened. A push
28
+ * asks the leave guard (in the admin's dialog, so the answer is awaited);
29
+ * a replace never leaves the view, so it does not ask. `force` skips the
30
+ * guard for a view that has just saved or deleted its record — React has
31
+ * not flushed its setDirty(false) yet. */
29
32
  export declare function navigate(href: string, opts?: {
30
33
  replace?: boolean;
31
34
  force?: boolean;
32
- }): boolean;
35
+ }): Promise<boolean>;
33
36
  /** The current route under `base`: empty until mount, then the URL,
34
37
  * re-read on popstate and after navigate(). */
35
38
  export declare function useRoute(base: string): Route;
@@ -10,6 +10,7 @@
10
10
  // framework-agnostic.
11
11
  import { useEffect, useState } from "react";
12
12
  import { DEFAULT_ADMIN_PATH } from "../../admin-path.js";
13
+ import { confirm } from "../ui/confirm.js";
13
14
  /** The segments under `base`: "/admin/articles/x" → ["articles", "x"];
14
15
  * the mount itself, or a pathname outside it, → []. Segments stay as
15
16
  * written; the shell decodes the ones it uses, as it does for the
@@ -28,13 +29,20 @@ export function isPlainLeftClick(e, target) {
28
29
  return false;
29
30
  return !target || target === "_self";
30
31
  }
31
- export const LEAVE_MESSAGE = "Discard unsaved changes?";
32
+ /** The leave question, asked in the admin's own dialog (ui/confirm.ts). */
33
+ export const LEAVE_CONFIRM = {
34
+ title: "Discard unsaved changes?",
35
+ body: "Your latest edits have not been saved and will be lost if you leave now.",
36
+ confirmLabel: "Discard changes",
37
+ cancelLabel: "Stay",
38
+ destructive: true,
39
+ };
32
40
  /** Leaving is allowed when no guard is dirty, else when the editor
33
41
  * confirms — asked once, however many guards are dirty. */
34
- export function mayLeave(guards, confirm) {
42
+ export async function mayLeave(guards, confirm) {
35
43
  for (const dirty of guards)
36
44
  if (dirty())
37
- return confirm(LEAVE_MESSAGE);
45
+ return confirm(LEAVE_CONFIRM);
38
46
  return true;
39
47
  }
40
48
  // ── the live router ──────────────────────────────────────────────────
@@ -47,12 +55,13 @@ function currentRoute(base) {
47
55
  // The segments derive from the pathname and a base that never changes
48
56
  // under a mounted shell, so two strings decide sameness.
49
57
  const sameRoute = (a, b) => a.pathname === b.pathname && a.search === b.search;
50
- /** Move to `href` in place; true when the move happened. A push asks the
51
- * leave guard; a replace never leaves the view, so it does not ask.
52
- * `force` skips the guard for a view that has just saved or deleted its
53
- * record — React has not flushed its setDirty(false) yet. */
54
- export function navigate(href, opts = {}) {
55
- if (!opts.replace && !opts.force && !mayLeave(guards, (m) => window.confirm(m)))
58
+ /** Move to `href` in place; resolves true when the move happened. A push
59
+ * asks the leave guard (in the admin's dialog, so the answer is awaited);
60
+ * a replace never leaves the view, so it does not ask. `force` skips the
61
+ * guard for a view that has just saved or deleted its record — React has
62
+ * not flushed its setDirty(false) yet. */
63
+ export async function navigate(href, opts = {}) {
64
+ if (!opts.replace && !opts.force && !(await mayLeave(guards, confirm)))
56
65
  return false;
57
66
  window.history[opts.replace ? "replaceState" : "pushState"](null, "", href);
58
67
  for (const listener of listeners)
@@ -1,12 +1,6 @@
1
1
  export declare const SETTINGS_PAGES: readonly [{
2
2
  readonly id: "members";
3
3
  readonly title: "Members";
4
- }, {
5
- readonly id: "roles";
6
- readonly title: "Roles";
7
- }, {
8
- readonly id: "api-keys";
9
- readonly title: "API Keys";
10
4
  }];
11
5
  export type SettingsPageId = (typeof SETTINGS_PAGES)[number]["id"];
12
6
  export declare const DEFAULT_SETTINGS_PAGE: SettingsPageId;
@@ -1,10 +1,9 @@
1
- // The Settings area's pages (spec 2026-09-25 §5): ids are URL segments
2
- // under /settings, titles are what the secondary nav and each top bar
3
- // show. Pure, so routing is testable without React.
1
+ // The Settings area's pages (spec 2026-09-25 §5; narrowed 2026-09-25 to
2
+ // Members alone — Roles and API Keys dropped, see DESIGN.md §2): ids are
3
+ // URL segments under /settings, titles are what the secondary nav and
4
+ // each top bar show. Pure, so routing is testable without React.
4
5
  export const SETTINGS_PAGES = [
5
6
  { id: "members", title: "Members" },
6
- { id: "roles", title: "Roles" },
7
- { id: "api-keys", title: "API Keys" },
8
7
  ];
9
8
  export const DEFAULT_SETTINGS_PAGE = "members";
10
9
  /** The page for the segment after /settings: the default for none, null for an unknown one. */
@@ -0,0 +1,28 @@
1
+ import type { OpError, OpResult } from "../ops.ts";
2
+ export type StaleInfo = {
3
+ by: string | null;
4
+ at: number;
5
+ };
6
+ /** The views' `fail` helpers take a loose failure shape; the guard's refusal is recognised in either. */
7
+ type FailureLike = {
8
+ ok: false;
9
+ code: string;
10
+ message: string;
11
+ stale?: true;
12
+ by?: string | null;
13
+ at?: number;
14
+ };
15
+ export declare const isStale: (r: OpResult<unknown> | FailureLike) => r is OpError & {
16
+ stale: true;
17
+ };
18
+ /** The part before the @ — how the members list and history shorten an actor. */
19
+ export declare const shortName: (email: string | null) => string;
20
+ export declare function staleText(info: StaleInfo, now?: number): string;
21
+ export declare const STALE_DETAIL = "Your unsaved changes here will be discarded.";
22
+ export declare const LOAD_THEIRS = "Load their version";
23
+ /** An op result's stale facts, for the views' `fail` paths. */
24
+ export declare const staleInfoOf: (r: {
25
+ by?: string | null;
26
+ at?: number;
27
+ }) => StaleInfo;
28
+ export {};
@@ -0,0 +1,17 @@
1
+ import { versionTime } from "../ui/format.js";
2
+ export const isStale = (r) => !r.ok && r.code === "conflict" && r.stale === true;
3
+ /** The part before the @ — how the members list and history shorten an actor. */
4
+ export const shortName = (email) => (email ? email.split("@")[0] : "someone");
5
+ export function staleText(info, now = Date.now()) {
6
+ const at = new Date(info.at);
7
+ const today = new Date(now);
8
+ const sameDay = at.getFullYear() === today.getFullYear() && at.getMonth() === today.getMonth() && at.getDate() === today.getDate();
9
+ const when = sameDay
10
+ ? `${String(at.getHours()).padStart(2, "0")}:${String(at.getMinutes()).padStart(2, "0")}`
11
+ : versionTime(info.at);
12
+ return `${shortName(info.by)} saved a newer version at ${when}.`;
13
+ }
14
+ export const STALE_DETAIL = "Your unsaved changes here will be discarded.";
15
+ export const LOAD_THEIRS = "Load their version";
16
+ /** An op result's stale facts, for the views' `fail` paths. */
17
+ export const staleInfoOf = (r) => ({ by: r.by ?? null, at: r.at ?? Date.now() });
@@ -133,6 +133,9 @@ export function nodeFromSample(section, id) {
133
133
  fields[key] = sampled;
134
134
  else if (ABSENT_WHEN_UNSAMPLED.has(d.type))
135
135
  continue;
136
+ // An optional object with no sample is absent: `{}` would read as present, its members required at publish.
137
+ else if (d.type === "object" && d.optional === true)
138
+ continue;
136
139
  else
137
140
  fields[key] = EMPTY_BY_TYPE[d.type] ?? "";
138
141
  }
@@ -5,22 +5,42 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
5
5
  // not apply — the layout never shifts and the editor learns what exists.
6
6
  // The popover is the Pages list's row menu shape (`.sm-picker`), anchored
7
7
  // under the button instead of at the pointer, hanging from the button's
8
- // right edge (every control sits at its bar's or card's right).
8
+ // right edge (every control sits at its bar's or card's right). It is
9
+ // position:fixed from the button's rect (2026-09-26): inside a table cell
10
+ // an absolute child was clipped by the cell's overflow, so the Members
11
+ // row menu opened invisibly. A scroll closes it rather than leaving it behind.
9
12
  import { useEffect, useRef, useState } from "react";
10
13
  import { cx } from "./primitives.js";
11
14
  export function ActionMenu({ label, items, disabled, small = false }) {
12
- const [open, setOpen] = useState(false);
15
+ /** Open: the popover's viewport position, under the button's right edge. */
16
+ const [open, setOpen] = useState(null);
13
17
  const ref = useRef(null);
18
+ const button = useRef(null);
19
+ const toggle = () => {
20
+ if (open) {
21
+ setOpen(null);
22
+ return;
23
+ }
24
+ const r = button.current?.getBoundingClientRect();
25
+ if (r)
26
+ setOpen({ top: r.bottom + 6, right: window.innerWidth - r.right });
27
+ };
14
28
  useEffect(() => {
15
29
  if (!open)
16
30
  return;
31
+ const close = () => setOpen(null);
17
32
  const onDown = (e) => { if (!ref.current?.contains(e.target))
18
- setOpen(false); };
33
+ close(); };
19
34
  const onKey = (e) => { if (e.key === "Escape")
20
- setOpen(false); };
35
+ close(); };
21
36
  document.addEventListener("pointerdown", onDown);
22
37
  document.addEventListener("keydown", onKey);
23
- return () => { document.removeEventListener("pointerdown", onDown); document.removeEventListener("keydown", onKey); };
38
+ document.addEventListener("scroll", close, true);
39
+ return () => {
40
+ document.removeEventListener("pointerdown", onDown);
41
+ document.removeEventListener("keydown", onKey);
42
+ document.removeEventListener("scroll", close, true);
43
+ };
24
44
  }, [open]);
25
- return (_jsxs("div", { ref: ref, style: { position: "relative", flex: "none" }, children: [_jsx("button", { type: "button", className: cx("sm-dotmenu", small && "sm-dotmenu--sm"), "aria-label": label, title: label, "aria-haspopup": "menu", "aria-expanded": open, disabled: disabled, onClick: () => setOpen((o) => !o), children: "\u00B7\u00B7\u00B7" }), open && (_jsx("div", { role: "menu", className: "sm-picker sm-picker--menu", children: items.map((item) => (_jsx("button", { type: "button", role: "menuitem", className: cx("sm-picker__item", item.danger && "sm-picker__item--danger"), disabled: item.disabled, onClick: () => { setOpen(false); item.run(); }, children: item.label }, item.label))) }))] }));
45
+ return (_jsxs("div", { ref: ref, style: { position: "relative", flex: "none" }, children: [_jsx("button", { ref: button, type: "button", className: cx("sm-dotmenu", small && "sm-dotmenu--sm"), "aria-label": label, title: label, "aria-haspopup": "menu", "aria-expanded": open !== null, disabled: disabled, onClick: toggle, children: "\u00B7\u00B7\u00B7" }), open && (_jsx("div", { role: "menu", className: "sm-picker sm-picker--menu", style: { top: open.top, right: open.right }, children: items.map((item) => (_jsx("button", { type: "button", role: "menuitem", className: cx("sm-picker__item", item.danger && "sm-picker__item--danger"), disabled: item.disabled, onClick: () => { setOpen(null); item.run(); }, children: item.label }, item.label))) }))] }));
26
46
  }
@@ -27,4 +27,9 @@ export type ComboboxProps = {
27
27
  initialOpen?: boolean;
28
28
  className?: string;
29
29
  };
30
+ /** The vertical band the list may occupy: the nearest clipping ancestor's box, cut to the viewport. */
31
+ export declare function clipBand(el: HTMLElement): {
32
+ top: number;
33
+ bottom: number;
34
+ };
30
35
  export declare function Combobox({ options, onPick, label, placeholder, disabled, keepOpen, emptyText, renderOption, onSubmit, createRow, onQueryChange, onInputKey, onClose, autoFocus, initialOpen, className, }: ComboboxProps): import("react").JSX.Element;
@@ -11,7 +11,7 @@ import { cx } from "./primitives.js";
11
11
  import { filterOptions, opensUp, showsCreate, stepActive } from "./combo-box.js";
12
12
  const keepFocus = (e) => e.preventDefault();
13
13
  /** The vertical band the list may occupy: the nearest clipping ancestor's box, cut to the viewport. */
14
- function clipBand(el) {
14
+ export function clipBand(el) {
15
15
  let top = 0;
16
16
  let bottom = window.innerHeight;
17
17
  for (let p = el.parentElement; p; p = p.parentElement) {
@@ -0,0 +1,8 @@
1
+ import { type ReactElement } from "react";
2
+ import { type ConfirmOptions } from "./confirm.ts";
3
+ export declare function ConfirmDialog({ options, onAnswer }: {
4
+ options: ConfirmOptions;
5
+ onAnswer(yes: boolean): void;
6
+ }): ReactElement;
7
+ /** Renders the pending question, once, from the shell. */
8
+ export declare function ConfirmHost(): ReactElement | null;
@@ -0,0 +1,30 @@
1
+ "use client";
2
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
3
+ // The confirm question on the dialog card (ui/Dialog.tsx; the pattern:
4
+ // design_handoff_smoodly_neutral/screens/ConfirmModal.md): the question,
5
+ // the consequence, an optional context chip naming the subject, then
6
+ // Cancel and the action verb. Focus opens on Cancel for a destructive
7
+ // action and on the primary otherwise, so Enter runs the primary only
8
+ // when it is safe.
9
+ //
10
+ // <ConfirmHost/> renders the pending question from ui/confirm.ts, once,
11
+ // from the shell; the dialog itself is a plain component so a test can
12
+ // render it statically.
13
+ import { useId, useSyncExternalStore } from "react";
14
+ import { DialogCard } from "./Dialog.js";
15
+ import { Button } from "./primitives.js";
16
+ import { pendingConfirm, subscribeConfirm } from "./confirm.js";
17
+ export function ConfirmDialog({ options, onAnswer }) {
18
+ const { title, body, confirmLabel, cancelLabel = "Cancel", destructive = false, subject } = options;
19
+ const uid = useId();
20
+ const bodyId = `sm-confirm-body${uid}`;
21
+ const cancel = () => onAnswer(false);
22
+ return (_jsxs(DialogCard, { title: title, role: "alertdialog", describedBy: body ? bodyId : undefined, onClose: cancel, className: "sm-confirm", actions: _jsxs(_Fragment, { children: [_jsx(Button, { variant: "secondary", autoFocus: destructive, onClick: cancel, children: cancelLabel }), _jsx(Button, { variant: destructive ? "destructive" : "primary", autoFocus: !destructive, onClick: () => onAnswer(true), children: confirmLabel })] }), children: [body && _jsx("p", { className: "sm-dialog__body", id: bodyId, children: body }), subject && (_jsxs("div", { className: "sm-dialog__context", children: [_jsx("strong", { children: subject.name }), subject.path && _jsx("code", { children: subject.path })] }))] }));
23
+ }
24
+ /** Renders the pending question, once, from the shell. */
25
+ export function ConfirmHost() {
26
+ const request = useSyncExternalStore(subscribeConfirm, pendingConfirm, () => null);
27
+ if (!request)
28
+ return null;
29
+ return _jsx(ConfirmDialog, { options: request.options, onAnswer: request.answer }, request.id);
30
+ }
@@ -0,0 +1,13 @@
1
+ import { type ReactElement, type ReactNode } from "react";
2
+ export declare function DialogCard({ title, role, describedBy, onClose, actions, className, children }: {
3
+ title: string;
4
+ /** `alertdialog` for a question that interrupts (the confirm); `dialog` for a form. */
5
+ role?: "dialog" | "alertdialog";
6
+ /** The id of the element that describes the dialog (a confirm's body). */
7
+ describedBy?: string;
8
+ onClose(): void;
9
+ /** The action row: Cancel then the verb. */
10
+ actions: ReactNode;
11
+ className?: string;
12
+ children: ReactNode;
13
+ }): ReactElement;