smoodly 0.0.25 → 0.0.27

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 (59) hide show
  1. package/README.md +66 -2
  2. package/dist/admin/editor/EditorView.js +82 -18
  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 +36 -3
  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 +32 -0
  25. package/dist/admin/shell/presence.d.ts +69 -0
  26. package/dist/admin/shell/presence.js +103 -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.d.ts +4 -1
  36. package/dist/admin/ui/primitives.js +5 -2
  37. package/dist/admin/ui/theme.js +4 -0
  38. package/dist/draft-token.d.ts +31 -0
  39. package/dist/draft-token.js +41 -0
  40. package/dist/entry-store.d.ts +11 -8
  41. package/dist/entry-store.js +24 -10
  42. package/dist/env.d.ts +9 -0
  43. package/dist/env.js +19 -0
  44. package/dist/index.d.ts +4 -2
  45. package/dist/index.js +2 -1
  46. package/dist/schema-version.d.ts +1 -1
  47. package/dist/schema-version.js +1 -1
  48. package/dist/sql-space.d.ts +6 -0
  49. package/dist/sql-space.js +41 -3
  50. package/dist/sql.js +16 -4
  51. package/dist/store.d.ts +11 -8
  52. package/dist/store.js +26 -8
  53. package/dist/supabase-entry-store.d.ts +15 -5
  54. package/dist/supabase-entry-store.js +87 -22
  55. package/dist/supabase-store.d.ts +15 -5
  56. package/dist/supabase-store.js +60 -20
  57. package/migrations/20260926130000_smoodly_v2.sql +26 -0
  58. package/migrations/20260926140000_smoodly_v3.sql +38 -0
  59. 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, pending: dirty }), _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, token: string): PresenceChannel;
@@ -0,0 +1,32 @@
1
+ "use client";
2
+ // The real channel behind `createPresence` (spec 2026-09-26 concurrency §5–§6;
3
+ // gated 2026-09-26 security pass §1): one supabase-js client on the CONTENT
4
+ // project whose public key (the anon key in self-host, the read key in the
5
+ // cloud) is only the gateway apikey — the socket authenticates with the
6
+ // editor's SESSION token, which the policies read auth.uid() from. A
7
+ // private channel named after the space, presence keyed by the email.
8
+ //
9
+ // The token rides the client's `accessToken` callback, not a bare
10
+ // `realtime.setAuth(token)`: realtime-js treats the callback as the source
11
+ // of truth and re-asks it after every join and reconnect — with the
12
+ // default callback that answer is the public key, which the server then
13
+ // refuses ("no permissions to read"). The join itself waits for the first
14
+ // setAuth, so it never goes out with the apikey alone.
15
+ import { createClient } from "@supabase/supabase-js";
16
+ export function connectRealtime(config, email, token) {
17
+ let current = token;
18
+ const client = createClient(config.url, config.key, { accessToken: async () => current });
19
+ const ready = client.realtime.setAuth(token).catch(() => { });
20
+ const channel = client.channel(`space:${config.spaceId}`, { config: { private: true, presence: { key: email } } });
21
+ return {
22
+ subscribe: (onStatus) => { void ready.then(() => { channel.subscribe((status) => onStatus(status)); }); },
23
+ setAuth: (fresh) => { current = fresh; return client.realtime.setAuth(fresh); },
24
+ track: (state) => channel.track(state),
25
+ untrack: () => channel.untrack(),
26
+ send: (message) => channel.send(message),
27
+ onSync: (listener) => { channel.on("presence", { event: "sync" }, listener); },
28
+ presenceState: () => channel.presenceState(),
29
+ onBroadcast: (event, listener) => { channel.on("broadcast", { event }, (m) => listener(m.payload)); },
30
+ unsubscribe: async () => { await channel.unsubscribe(); await client.removeAllChannels(); },
31
+ };
32
+ }
@@ -0,0 +1,69 @@
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
+ /** A session token for the socket (spec 2026-09-26 security pass §1): the channel policies admit by auth.uid(). */
22
+ setAuth(token: string): Promise<unknown>;
23
+ track(state: Record<string, unknown>): Promise<unknown>;
24
+ untrack(): Promise<unknown>;
25
+ send(message: {
26
+ type: "broadcast";
27
+ event: string;
28
+ payload: Record<string, unknown>;
29
+ }): Promise<unknown>;
30
+ onSync(listener: () => void): void;
31
+ /** key (the email) → that person's tabs' states. */
32
+ presenceState(): Record<string, Record<string, unknown>[]>;
33
+ onBroadcast(event: string, listener: (payload: Record<string, unknown>) => void): void;
34
+ unsubscribe(): Promise<unknown>;
35
+ };
36
+ export type Presence = {
37
+ join(): void;
38
+ /** What this tab edits now; null on leaving a view. Re-sent on rejoin. */
39
+ track(target: PresenceTarget | null): void;
40
+ /** After a guarded op acked: tell the others. */
41
+ announce(target: PresenceTarget, savedAt: string | null): void;
42
+ /** The session token was refreshed: re-authenticate the socket so the channel outlives the old token. No-op unless joined. */
43
+ setAuth(token: string): void;
44
+ onSaved(listener: (event: SavedEvent) => void): () => void;
45
+ getState(): PresenceState;
46
+ subscribe(listener: () => void): () => void;
47
+ dispose(): void;
48
+ };
49
+ export declare const shortName: (email: string) => string;
50
+ /** The short names editing `target`, other than me, sorted and deduped. */
51
+ export declare function editorsOf(others: PresenceEntry[], target: PresenceTarget): string[];
52
+ export declare function createPresence(opts: {
53
+ email: string;
54
+ connect: () => PresenceChannel;
55
+ }): Presence;
56
+ /** What the shell needs from the auth client to follow refreshes — supabase-js's `auth` satisfies it. */
57
+ export type RefreshSource = {
58
+ onAuthStateChange(cb: (event: string, session: {
59
+ access_token: string;
60
+ } | null) => void): {
61
+ data: {
62
+ subscription: {
63
+ unsubscribe(): void;
64
+ };
65
+ };
66
+ };
67
+ };
68
+ /** Re-send every refreshed session token to the socket (spec 2026-09-26 security pass §1). Returns the stop function. */
69
+ export declare function followTokenRefresh(auth: RefreshSource, presence: Pick<Presence, "setAuth">): () => void;
@@ -0,0 +1,103 @@
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
+ setAuth(token) {
80
+ if (!channel)
81
+ return;
82
+ void channel.setAuth(token).catch(() => { });
83
+ },
84
+ onSaved(listener) { savedListeners.add(listener); return () => { savedListeners.delete(listener); }; },
85
+ getState: () => state,
86
+ subscribe(listener) { listeners.add(listener); return () => { listeners.delete(listener); }; },
87
+ dispose() {
88
+ const c = channel;
89
+ channel = null;
90
+ set({ status: "off", others: [] });
91
+ if (c)
92
+ void c.unsubscribe().catch(() => { });
93
+ },
94
+ };
95
+ }
96
+ /** Re-send every refreshed session token to the socket (spec 2026-09-26 security pass §1). Returns the stop function. */
97
+ export function followTokenRefresh(auth, presence) {
98
+ const { data } = auth.onAuthStateChange((event, session) => {
99
+ if (event === "TOKEN_REFRESHED" && session)
100
+ presence.setAuth(session.access_token);
101
+ });
102
+ return () => data.subscription.unsubscribe();
103
+ }
@@ -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
+ }
@@ -120,8 +120,11 @@ export declare function StatusBadge({ status }: {
120
120
  status: string;
121
121
  }): import("react").JSX.Element;
122
122
  /** The autosave indicator: a dot and one uppercase word. */
123
- export declare function SaveIndicator({ state }: {
123
+ /** `pending`: an edit is waiting on the autosave debounce — shown as Saving, so "Saved" always means the
124
+ * store holds what is on screen (the browser suite's `expectSaved` relies on exactly that). */
125
+ export declare function SaveIndicator({ state, pending }: {
124
126
  state: SaveState;
127
+ pending?: boolean;
125
128
  }): import("react").JSX.Element;
126
129
  /** `caps`: the overline style — small, uppercase, tracked (2026-09-26; the mono face before). */
127
130
  export declare function SectionLabel({ children, action, caps, style }: {
@@ -124,13 +124,16 @@ export function StatusBadge({ status }) {
124
124
  }, children: status }));
125
125
  }
126
126
  /** The autosave indicator: a dot and one uppercase word. */
127
- export function SaveIndicator({ state }) {
127
+ /** `pending`: an edit is waiting on the autosave debounce — shown as Saving, so "Saved" always means the
128
+ * store holds what is on screen (the browser suite's `expectSaved` relies on exactly that). */
129
+ export function SaveIndicator({ state, pending = false }) {
128
130
  const map = {
129
131
  saved: { label: "Saved", dot: "var(--status-saved-dot)", color: "var(--text-muted)" },
130
132
  saving: { label: "Saving", dot: "var(--warn)", color: "var(--text-muted)" },
131
133
  error: { label: "Save failed — retrying", dot: "var(--danger)", color: "var(--danger)" },
134
+ stale: { label: "Not saved — newer version exists", dot: "var(--danger)", color: "var(--danger)" },
132
135
  };
133
- const { label, dot, color } = map[state];
136
+ const { label, dot, color } = map[pending && state === "saved" ? "saving" : state];
134
137
  return (_jsxs("span", { className: "sm-savestate", "data-testid": "save-status", style: { color }, title: label, children: [_jsx("span", { className: "sm-dot", style: { background: dot } }), label] }));
135
138
  }
136
139
  /** `caps`: the overline style — small, uppercase, tracked (2026-09-26; the mono face before). */
@@ -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);