smoodly 0.0.25 → 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 (56) hide show
  1. package/README.md +32 -0
  2. package/dist/admin/editor/EditorView.js +81 -17
  3. package/dist/admin/editor/autosave.d.ts +4 -1
  4. package/dist/admin/editor/autosave.js +24 -1
  5. package/dist/admin/editor/global-edit.d.ts +5 -1
  6. package/dist/admin/editor/global-edit.js +15 -5
  7. package/dist/admin/editor/useGlobalEdit.d.ts +1 -1
  8. package/dist/admin/editor/useGlobalEdit.js +1 -1
  9. package/dist/admin/next/create-admin.d.ts +4 -0
  10. package/dist/admin/next/create-admin.js +3 -1
  11. package/dist/admin/next/index.d.ts +1 -0
  12. package/dist/admin/ops-impl.js +70 -24
  13. package/dist/admin/ops.d.ts +22 -12
  14. package/dist/admin/shell/AdminApp.d.ts +3 -1
  15. package/dist/admin/shell/AdminApp.js +20 -2
  16. package/dist/admin/shell/EntriesList.js +5 -1
  17. package/dist/admin/shell/EntryForm.js +44 -7
  18. package/dist/admin/shell/GlobalForm.js +46 -7
  19. package/dist/admin/shell/GlobalList.js +6 -2
  20. package/dist/admin/shell/PagesList.js +5 -1
  21. package/dist/admin/shell/presence-context.d.ts +9 -0
  22. package/dist/admin/shell/presence-context.js +35 -0
  23. package/dist/admin/shell/presence-realtime.d.ts +3 -0
  24. package/dist/admin/shell/presence-realtime.js +22 -0
  25. package/dist/admin/shell/presence.d.ts +51 -0
  26. package/dist/admin/shell/presence.js +90 -0
  27. package/dist/admin/shell/stale.d.ts +28 -0
  28. package/dist/admin/shell/stale.js +17 -0
  29. package/dist/admin/ui/LockIcon.d.ts +4 -0
  30. package/dist/admin/ui/LockIcon.js +12 -0
  31. package/dist/admin/ui/PresenceLine.d.ts +6 -0
  32. package/dist/admin/ui/PresenceLine.js +11 -0
  33. package/dist/admin/ui/StaleBanner.d.ts +5 -0
  34. package/dist/admin/ui/StaleBanner.js +9 -0
  35. package/dist/admin/ui/primitives.js +1 -0
  36. package/dist/admin/ui/theme.js +4 -0
  37. package/dist/draft-token.d.ts +31 -0
  38. package/dist/draft-token.js +41 -0
  39. package/dist/entry-store.d.ts +11 -8
  40. package/dist/entry-store.js +24 -10
  41. package/dist/env.d.ts +9 -0
  42. package/dist/env.js +19 -0
  43. package/dist/index.d.ts +4 -2
  44. package/dist/index.js +2 -1
  45. package/dist/schema-version.d.ts +1 -1
  46. package/dist/schema-version.js +1 -1
  47. package/dist/sql-space.js +11 -0
  48. package/dist/sql.js +11 -0
  49. package/dist/store.d.ts +11 -8
  50. package/dist/store.js +26 -8
  51. package/dist/supabase-entry-store.d.ts +15 -5
  52. package/dist/supabase-entry-store.js +87 -22
  53. package/dist/supabase-store.d.ts +15 -5
  54. package/dist/supabase-store.js +60 -20
  55. package/migrations/20260926130000_smoodly_v2.sql +26 -0
  56. package/package.json +1 -1
@@ -31,6 +31,12 @@ import { GlobalFields } from "./GlobalFields.js";
31
31
  import { localeFromUrl, writeLocaleToUrl } from "../editor/locale-url.js";
32
32
  import { useLeaveGuard } from "./router.js";
33
33
  import { confirm, DISCARD_CONFIRM } from "../ui/confirm.js";
34
+ import { draftToken } from "../../draft-token.js";
35
+ import { isStale, staleInfoOf } from "./stale.js";
36
+ import { StaleBanner } from "../ui/StaleBanner.js";
37
+ import { usePresence, usePresenceState, useSavedElsewhere, useTrackPresence } from "./presence-context.js";
38
+ import { editorsOf } from "./presence.js";
39
+ import { PresenceLine } from "../ui/PresenceLine.js";
34
40
  export function GlobalForm({ ops, registry, name }) {
35
41
  const adminHref = useAdminHref();
36
42
  const item = registry.globals.find((s) => s.name === name);
@@ -48,15 +54,29 @@ export function GlobalForm({ ops, registry, name }) {
48
54
  const [reloadKey, setReloadKey] = useState(0);
49
55
  const [history, setHistory] = useState(null);
50
56
  const [snapshot, setSnapshot] = useState(null);
57
+ const [stale, setStale] = useState(null);
51
58
  const formRef = useRef(null);
52
59
  const fieldsRef = useRef(fields);
53
60
  fieldsRef.current = fields;
54
- const { saveState, mark, flush } = useDraftAutosave({
61
+ const recordRef = useRef(record);
62
+ recordRef.current = record;
63
+ /** The token of the row on screen — what every guarded op sends (spec 2026-09-26 concurrency §1). */
64
+ const token = () => { const row = recordRef.current?.locales[locale]; return row ? draftToken(row) : null; };
65
+ // Presence (spec 2026-09-26 concurrency §5): this tab on this item, the others on it, every ack announced.
66
+ const target = { kind: "global", id: name, locale };
67
+ useTrackPresence(target);
68
+ const { others } = usePresenceState();
69
+ const presence = usePresence();
70
+ const { saveState, mark, flush, reset } = useDraftAutosave({
55
71
  read: () => fieldsRef.current,
56
72
  persist: async (value) => {
57
- const r = await ops.globals.save(name, locale, value);
58
- if (r.ok)
73
+ const r = await ops.globals.save(name, locale, value, token());
74
+ if (r.ok) {
59
75
  setRecord(r.data);
76
+ presence?.announce(target, r.data.locales[locale]?.draftSavedAt ?? null);
77
+ }
78
+ else if (isStale(r))
79
+ setStale(staleInfoOf(r));
60
80
  return r;
61
81
  },
62
82
  onSaved: () => setDirty(false),
@@ -77,6 +97,7 @@ export function GlobalForm({ ops, registry, name }) {
77
97
  setFields(null);
78
98
  setFieldErrors({});
79
99
  setDirty(false);
100
+ setStale(null);
80
101
  setTouched(false);
81
102
  ops.globals.get(name, locale).then((r) => {
82
103
  if (cancelled)
@@ -97,6 +118,10 @@ export function GlobalForm({ ops, registry, name }) {
97
118
  const row = record?.locales[locale];
98
119
  const status = row ? localeStatusOf(row) : null;
99
120
  const fail = (r) => {
121
+ if (isStale(r)) {
122
+ setStale(staleInfoOf(r));
123
+ return;
124
+ }
100
125
  if (r.code === "validation" && r.fields && r.fields.length > 0) {
101
126
  setFieldErrors(Object.fromEntries(r.fields.map((f) => [f.field, f.message])));
102
127
  setNotice({ tone: "warn", text: r.message, fields: r.fields });
@@ -131,7 +156,7 @@ export function GlobalForm({ ops, registry, name }) {
131
156
  }
132
157
  let r;
133
158
  try {
134
- r = await ops.globals.setStatus(name, locale, "published");
159
+ r = await ops.globals.setStatus(name, locale, "published", token());
135
160
  }
136
161
  catch {
137
162
  r = { ok: false, code: "internal", message: "Couldn't reach the server." };
@@ -142,6 +167,7 @@ export function GlobalForm({ ops, registry, name }) {
142
167
  return;
143
168
  }
144
169
  setRecord(r.data);
170
+ presence?.announce(target, r.data.locales[locale]?.draftSavedAt ?? null);
145
171
  setFieldErrors({});
146
172
  setPublished(true);
147
173
  setTimeout(() => setPublished(false), 1600);
@@ -174,7 +200,7 @@ export function GlobalForm({ ops, registry, name }) {
174
200
  }
175
201
  let r;
176
202
  try {
177
- r = await ops.globals.discardDraft(name, locale);
203
+ r = await ops.globals.discardDraft(name, locale, token());
178
204
  }
179
205
  catch {
180
206
  r = { ok: false, code: "internal", message: "Couldn't reach the server." };
@@ -185,6 +211,7 @@ export function GlobalForm({ ops, registry, name }) {
185
211
  return;
186
212
  }
187
213
  setRecord(r.data);
214
+ presence?.announce(target, r.data.locales[locale]?.draftSavedAt ?? null);
188
215
  setReloadKey((k) => k + 1);
189
216
  setNotice({ tone: "ok", text: "Changes discarded." });
190
217
  };
@@ -210,13 +237,25 @@ export function GlobalForm({ ops, registry, name }) {
210
237
  else
211
238
  fail(r);
212
239
  };
240
+ /** Load their version (spec 2026-09-26 concurrency §4): drop the frozen state and re-get through the ops seam. */
241
+ const loadTheirs = () => { setStale(null); reset(); setReloadKey((k) => k + 1); };
242
+ // Someone else saved this item (§5): clean → their version silently; unsaved edits → the freeze at once.
243
+ useSavedElsewhere(target, (event) => {
244
+ if (stale)
245
+ return;
246
+ if (dirty || saveState !== "saved")
247
+ setStale({ by: event.email, at: Date.now() });
248
+ else
249
+ setReloadKey((k) => k + 1);
250
+ });
213
251
  const restore = async (versionId) => {
214
- const r = await ops.globals.restore(name, locale, versionId);
252
+ const r = await ops.globals.restore(name, locale, versionId, token());
215
253
  if (!r.ok) {
216
254
  fail(r);
217
255
  return false;
218
256
  }
219
257
  setRecord(r.data);
258
+ presence?.announce(target, r.data.locales[locale]?.draftSavedAt ?? null);
220
259
  setReloadKey((k) => k + 1);
221
260
  setNotice({ tone: "ok", text: "Version restored." });
222
261
  return true;
@@ -259,6 +298,6 @@ export function GlobalForm({ ops, registry, name }) {
259
298
  trailing: multilingual && (_jsx(LocaleSwitcher, { registry: registry, present: registry.locales.supported, parentLocales: [], value: locale, adding: false, onSwitch: (l) => { void switchLocale(l); }, onAdd: () => undefined })),
260
299
  },
261
300
  { label: item.title },
262
- ] }) }), right: _jsxs(_Fragment, { children: [(touched || saveState === "error") && _jsx(SaveIndicator, { state: saveState }), _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 ? (_jsxs("fieldset", { disabled: true, className: "sm-history__form", children: [_jsx(GlobalFields, { item: item, groups: contentGroups, fields: snapshot.fields, refOptions: refOptions, multilingual: multilingual, onChange: () => { } }), settingGroups.length > 0 && (_jsxs(_Fragment, { children: [_jsx(SectionLabel, { children: "Settings" }), _jsx(GlobalFields, { item: item, groups: settingGroups, fields: snapshot.fields, refOptions: refOptions, multilingual: multilingual, onChange: () => { } })] }))] })) : _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)
301
+ ] }) }), right: _jsxs(_Fragment, { children: [(touched || saveState === "error") && _jsx(SaveIndicator, { state: saveState }), _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 ? (_jsxs("fieldset", { disabled: true, className: "sm-history__form", children: [_jsx(GlobalFields, { item: item, groups: contentGroups, fields: snapshot.fields, refOptions: refOptions, multilingual: multilingual, onChange: () => { } }), settingGroups.length > 0 && (_jsxs(_Fragment, { children: [_jsx(SectionLabel, { children: "Settings" }), _jsx(GlobalFields, { item: item, groups: settingGroups, fields: snapshot.fields, refOptions: refOptions, multilingual: multilingual, onChange: () => { } })] }))] })) : _jsx("p", { className: "sm-hint", style: { padding: 16 }, children: "Loading\u2026" }) })), _jsx(PresenceLine, { names: editorsOf(others, target), noun: "item" }), stale && _jsx(StaleBanner, { info: stale, onReload: loadTheirs }), _jsxs("div", { className: "sm-editor", inert: stale ? true : undefined, 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)
263
302
  revealField(formRef.current, path); } })] })), record && (_jsxs("p", { className: "sm-hint", children: [pages.size === 0 ? "Not placed on any page yet." : `Used on ${pages.size} page${pages.size === 1 ? "" : "s"}: `, [...pages].map((id, i) => (_jsxs("span", { children: [i > 0 && ", ", _jsx("a", { href: adminHref(`/pages/${encodeURIComponent(id)}?locale=${encodeURIComponent(locale)}`), children: id })] }, id)))] })), fields !== null ? (_jsxs(_Fragment, { children: [_jsx(GlobalFields, { item: item, groups: contentGroups, fields: fields, errors: fieldErrors, refOptions: refOptions, multilingual: multilingual, onChange: onChange }), settingGroups.length > 0 && (_jsxs(_Fragment, { children: [_jsx(SectionLabel, { children: "Settings" }), _jsx(GlobalFields, { item: item, groups: settingGroups, fields: fields, errors: fieldErrors, refOptions: refOptions, multilingual: multilingual, onChange: onChange })] }))] })) : !notice && _jsx("p", { style: { color: "var(--text-muted)" }, children: "Loading\u2026" })] }), _jsx(ResizeHandle, {}), _jsx("aside", { className: "sm-inspector", children: record && row && (_jsxs("div", { className: "sm-card", children: [_jsx(SectionLabel, { children: "Document info" }), _jsx(InfoRow, { label: "Kind", value: item.section !== undefined ? `Visual · ${item.section}` : "Fields" }), _jsx(InfoRow, { label: "Updated", value: updatedLine(row.updatedAt, row.updatedBy) }), _jsx(InfoRow, { label: "ID", value: record.id, mono: true })] })) })] })] }));
264
303
  }
@@ -1,5 +1,5 @@
1
1
  "use client";
2
- import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
3
  // The Globals list (global content, spec 2026-09-07 §6; renamed in the
4
4
  // UI 2026-09-09, the route is /globals): every registered item is
5
5
  // a row, edited on its own page. Nothing here is created or deleted by
@@ -15,11 +15,15 @@ import { relativeTime } from "../ui/format.js";
15
15
  import { useAdminHref } from "./base.js";
16
16
  import { ErrorPane } from "./ErrorPane.js";
17
17
  import { rememberLocale, rememberedLocale } from "./locale-memory.js";
18
+ import { usePresenceState } from "./presence-context.js";
19
+ import { editorsOf } from "./presence.js";
20
+ import { LockIcon } from "../ui/LockIcon.js";
18
21
  export function GlobalList({ ops, registry }) {
19
22
  const adminHref = useAdminHref();
20
23
  const [items, setItems] = useState(null);
21
24
  const [error, setError] = useState(null);
22
25
  const [locale, setLocale] = useState(() => rememberedLocale(registry.locales));
26
+ const { others } = usePresenceState();
23
27
  const multilingual = registry.locales.supported.length > 1;
24
28
  const pickLocale = (next) => {
25
29
  setLocale(next);
@@ -43,7 +47,7 @@ export function GlobalList({ ops, registry }) {
43
47
  key: row.name,
44
48
  href: adminHref(`/globals/${encodeURIComponent(row.name)}?locale=${encodeURIComponent(locale)}`),
45
49
  cells: [
46
- _jsx("span", { className: "sm-cell sm-cell--title", children: row.title }, "t"),
50
+ _jsxs("span", { className: "sm-cell sm-cell--title", children: [row.title, _jsx(LockIcon, { names: editorsOf(others, { kind: "global", id: row.name, locale }) })] }, "t"),
47
51
  _jsx("span", { className: "sm-cell sm-cell--muted", children: row.flavour }, "k"),
48
52
  _jsx("span", { className: "sm-cell sm-cell--muted", children: relativeTime(row.updatedAt) }, "u"),
49
53
  _jsx(StatusBadge, { status: statusLabel(row.status) }, "s"),
@@ -21,7 +21,11 @@ import { rememberLocale, rememberedLocale } from "./locale-memory.js";
21
21
  import { confirmLiveUrl } from "./live-url.js";
22
22
  import { confirm } from "../ui/confirm.js";
23
23
  import { PageFormDialog } from "./PageDialogs.js";
24
+ import { usePresenceState } from "./presence-context.js";
25
+ import { editorsOf } from "./presence.js";
26
+ import { LockIcon } from "../ui/LockIcon.js";
24
27
  export function PagesList({ ops, registry }) {
28
+ const { others } = usePresenceState();
25
29
  const adminHref = useAdminHref();
26
30
  const [pages, setPages] = useState(null);
27
31
  const [error, setError] = useState(null);
@@ -243,7 +247,7 @@ export function PagesList({ ops, registry }) {
243
247
  key: row.id,
244
248
  onClick: () => onRow(row),
245
249
  cells: [
246
- _jsxs("span", { className: row.hasChildren ? "sm-cell sm-cell--tree sm-cell--parent" : "sm-cell sm-cell--tree", style: { paddingLeft: depth * 20, ...dim }, children: [_jsx("button", { type: "button", className: "sm-chevron", "aria-label": row.expanded ? "Collapse" : "Expand", "aria-expanded": row.hasChildren ? row.expanded : undefined, style: { visibility: row.hasChildren ? "visible" : "hidden" }, onClick: (e) => { e.stopPropagation(); toggle(row.id); }, onKeyDown: (e) => e.stopPropagation(), children: row.expanded ? "▾" : "▸" }), _jsx("span", { style: { overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }, children: row.title })] }, "t"),
250
+ _jsxs("span", { className: row.hasChildren ? "sm-cell sm-cell--tree sm-cell--parent" : "sm-cell sm-cell--tree", style: { paddingLeft: depth * 20, ...dim }, children: [_jsx("button", { type: "button", className: "sm-chevron", "aria-label": row.expanded ? "Collapse" : "Expand", "aria-expanded": row.hasChildren ? row.expanded : undefined, style: { visibility: row.hasChildren ? "visible" : "hidden" }, onClick: (e) => { e.stopPropagation(); toggle(row.id); }, onKeyDown: (e) => e.stopPropagation(), children: row.expanded ? "▾" : "▸" }), _jsx("span", { style: { overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }, children: row.title }), _jsx(LockIcon, { names: editorsOf(others, { kind: "page", id: row.id, locale }) })] }, "t"),
247
251
  row.present
248
252
  ? _jsx("span", { className: "sm-cell sm-cell--mono", children: row.path }, "p")
249
253
  : _jsxs("span", { className: "sm-cell sm-cell--muted", children: ["not in ", locale, " \u2014 click to add"] }, "p"),
@@ -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
+ }
@@ -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() });
@@ -0,0 +1,4 @@
1
+ export declare const lockLabel: (names: string[]) => string;
2
+ export declare function LockIcon({ names }: {
3
+ names: string[];
4
+ }): import("react").JSX.Element | null;
@@ -0,0 +1,12 @@
1
+ "use client";
2
+ import { jsx as _jsx } from "react/jsx-runtime";
3
+ // The lock after a list row's title (spec 2026-09-26 concurrency §5): someone else
4
+ // has this target open in this locale. A tooltip names them.
5
+ import { Lock } from "lucide-react";
6
+ export const lockLabel = (names) => names.length === 1 ? `${names[0]} is editing` : `${names.join(", ")} are editing`;
7
+ export function LockIcon({ names }) {
8
+ if (names.length === 0)
9
+ return null;
10
+ const label = lockLabel(names);
11
+ return (_jsx("span", { className: "sm-lock", role: "img", "aria-label": label, title: label, children: _jsx(Lock, { size: 14, strokeWidth: 1.5, "aria-hidden": "true" }) }));
12
+ }
@@ -0,0 +1,6 @@
1
+ export type PresenceNoun = "page" | "entry" | "item";
2
+ export declare function presenceLineText(names: string[], noun: PresenceNoun): string;
3
+ export declare function PresenceLine({ names, noun }: {
4
+ names: string[];
5
+ noun: PresenceNoun;
6
+ }): import("react").JSX.Element | null;
@@ -0,0 +1,11 @@
1
+ "use client";
2
+ import { jsx as _jsx } from "react/jsx-runtime";
3
+ export function presenceLineText(names, noun) {
4
+ const who = names.length === 1 ? names[0] : `${names.slice(0, -1).join(", ")} and ${names[names.length - 1]}`;
5
+ return `${who} ${names.length === 1 ? "is" : "are"} also editing this ${noun}.`;
6
+ }
7
+ export function PresenceLine({ names, noun }) {
8
+ if (names.length === 0)
9
+ return null;
10
+ return _jsx("p", { className: "sm-presence", role: "status", children: presenceLineText(names, noun) });
11
+ }
@@ -0,0 +1,5 @@
1
+ import { type StaleInfo } from "../shell/stale.ts";
2
+ export declare function StaleBanner({ info, onReload }: {
3
+ info: StaleInfo;
4
+ onReload: () => void;
5
+ }): import("react").JSX.Element;
@@ -0,0 +1,9 @@
1
+ "use client";
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ // The freeze notice under the top bar (spec 2026-09-26 concurrency §4): the fact,
4
+ // what a reload costs, and the one button. The view puts everything below it inert.
5
+ import { LOAD_THEIRS, STALE_DETAIL, staleText } from "../shell/stale.js";
6
+ import { Banner, Button } from "./primitives.js";
7
+ export function StaleBanner({ info, onReload }) {
8
+ return (_jsx("div", { className: "sm-stale", "data-testid": "stale-banner", children: _jsxs(Banner, { tone: "error", children: [_jsx("p", { style: { margin: 0 }, children: staleText(info) }), _jsx("p", { style: { margin: "4px 0 10px", color: "inherit", opacity: 0.85 }, children: STALE_DETAIL }), _jsx(Button, { onClick: onReload, children: LOAD_THEIRS })] }) }));
9
+ }
@@ -129,6 +129,7 @@ export function SaveIndicator({ state }) {
129
129
  saved: { label: "Saved", dot: "var(--status-saved-dot)", color: "var(--text-muted)" },
130
130
  saving: { label: "Saving", dot: "var(--warn)", color: "var(--text-muted)" },
131
131
  error: { label: "Save failed — retrying", dot: "var(--danger)", color: "var(--danger)" },
132
+ stale: { label: "Not saved — newer version exists", dot: "var(--danger)", color: "var(--danger)" },
132
133
  };
133
134
  const { label, dot, color } = map[state];
134
135
  return (_jsxs("span", { className: "sm-savestate", "data-testid": "save-status", style: { color }, title: label, children: [_jsx("span", { className: "sm-dot", style: { background: dot } }), label] }));
@@ -794,6 +794,9 @@ body:has(.sm-admin){margin:0}
794
794
  .sm-overline,.sm-sectionlabel--caps>.sm-label{
795
795
  font-family:var(--font-sans);font-size:var(--text-xs);font-weight:600;letter-spacing:var(--tracking-tag);text-transform:uppercase;color:var(--text-muted);
796
796
  }
797
+ .sm-stale{padding:8px 20px 0}
798
+ .sm-lock{display:inline-flex;align-items:center;margin-left:6px;color:var(--text-muted);vertical-align:middle}
799
+ .sm-presence{margin:0;padding:6px 20px 0;font-size:13px;color:var(--text-muted)}
797
800
  .sm-banner{
798
801
  display:flex;gap:14px;align-items:flex-start;padding:12px 16px;border-radius:var(--radius-md);
799
802
  font-size:var(--text-md);
@@ -841,6 +844,7 @@ body:has(.sm-admin){margin:0}
841
844
  handle and the canvas's 440px minimum — so a window that shrinks after a
842
845
  drag never leaves the canvas squeezed. */
843
846
  .sm-editor{flex:1;min-height:0;display:grid;grid-template-columns:1fr max(var(--gutter),8px) min(var(--inspector-w),calc(100% - max(var(--gutter),8px) - 440px))}
847
+ .sm-editor[inert],.sm-form[inert]{opacity:.6}
844
848
  /* The drag-to-resize handle (spec 2026-09-10 §3): fills its column, draws a
845
849
  2px line down its centre on hover and while dragging — a shade off the
846
850
  ground, since the col-resize cursor already says it drags (2026-09-10);
@@ -0,0 +1,31 @@
1
+ export type DraftToken = {
2
+ versionId: string;
3
+ savedAt: string;
4
+ };
5
+ /** What a locale row carries for the token (both stores' row types satisfy it). */
6
+ export type DraftPointerRow = {
7
+ draftVersionId: string | null;
8
+ draftSavedAt: string | null;
9
+ };
10
+ /** The row's token, or null when it has no draft pointer (a folder, a page created without a tree). */
11
+ export declare function draftToken(row: DraftPointerRow): DraftToken | null;
12
+ export declare function sameToken(a: DraftToken | null, b: DraftToken | null): boolean;
13
+ /** An untrusted value is a token when it has the two strings and nothing is missing. */
14
+ export declare function isDraftToken(v: unknown): v is DraftToken;
15
+ export declare class StaleDraftError extends Error {
16
+ /** The row's token now — what a reload would get. */
17
+ readonly current: DraftToken | null;
18
+ /** The audit pair of the row: who wrote last, when. */
19
+ readonly by: string | null;
20
+ readonly at: number;
21
+ constructor(
22
+ /** The row's token now — what a reload would get. */
23
+ current: DraftToken | null,
24
+ /** The audit pair of the row: who wrote last, when. */
25
+ by: string | null, at: number);
26
+ }
27
+ /** The read-side check every guarded write runs first. `undefined` = no check. */
28
+ export declare function assertFresh(row: DraftPointerRow & {
29
+ updatedBy: string | null;
30
+ updatedAt: number;
31
+ }, expected: DraftToken | null | undefined): void;
@@ -0,0 +1,41 @@
1
+ // The draft token (spec 2026-09-26 concurrency §1): the locale row's
2
+ // draft pointer with that version's saved time. Every state change moves
3
+ // it — a save rewrites saved_at, a discard points at the published
4
+ // version, a restore creates a new draft. Pure and framework-free; both
5
+ // stores throw StaleDraftError, the ops map it, the admin freezes on it.
6
+ /** The row's token, or null when it has no draft pointer (a folder, a page created without a tree). */
7
+ export function draftToken(row) {
8
+ return row.draftVersionId === null ? null : { versionId: row.draftVersionId, savedAt: row.draftSavedAt ?? "" };
9
+ }
10
+ export function sameToken(a, b) {
11
+ if (a === null || b === null)
12
+ return a === b;
13
+ return a.versionId === b.versionId && a.savedAt === b.savedAt;
14
+ }
15
+ /** An untrusted value is a token when it has the two strings and nothing is missing. */
16
+ export function isDraftToken(v) {
17
+ return typeof v === "object" && v !== null && !Array.isArray(v)
18
+ && typeof v.versionId === "string"
19
+ && typeof v.savedAt === "string";
20
+ }
21
+ export class StaleDraftError extends Error {
22
+ constructor(
23
+ /** The row's token now — what a reload would get. */
24
+ current,
25
+ /** The audit pair of the row: who wrote last, when. */
26
+ by, at) {
27
+ super("smoodly: the draft changed since it was loaded — reload before saving.");
28
+ this.current = current;
29
+ this.by = by;
30
+ this.at = at;
31
+ this.name = "StaleDraftError";
32
+ }
33
+ }
34
+ /** The read-side check every guarded write runs first. `undefined` = no check. */
35
+ export function assertFresh(row, expected) {
36
+ if (expected === undefined)
37
+ return;
38
+ const current = draftToken(row);
39
+ if (!sameToken(current, expected))
40
+ throw new StaleDraftError(current, row.updatedBy, row.updatedAt);
41
+ }