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
package/README.md CHANGED
@@ -468,6 +468,15 @@ Implemented:
468
468
  The media and refs walks descend into documents against the element
469
469
  schemas (`elements` beside `sections` in the store, resolve and site
470
470
  options).
471
+ - The token guard and presence (spec 2026-09-26 concurrency): every
472
+ draft write carries `{ versionId, savedAt }` from the locale row's
473
+ `draftSavedAt` and is refused as `conflict` + `stale` when the draft
474
+ moved (the write itself is conditional in Supabase); the admin freezes
475
+ under a notice naming who saved and when and reloads through "Load
476
+ their version"; one private Realtime channel per space shows who edits
477
+ what (a lock in the lists, a line under the top bar) and announces
478
+ saves so a clean view refreshes and a dirty one freezes early. Schema
479
+ version 2 adds the draft-pointer foreign keys and the channel policies.
471
480
 
472
481
  Not yet: canvas drag-and-drop (list fields, the outline, manual
473
482
  collections and the pages tree drag since 2026-09-09 — spec
@@ -495,6 +504,29 @@ after both are on the registry. From the repo root:
495
504
  `prepublishOnly` rebuilds each package. The Claude Code skill
496
505
  `.claude/skills/releasing-smoodly` carries the same order with its checks.
497
506
 
507
+ ## Follow-ups (logged 2026-09-26, concurrency spec)
508
+
509
+ - A hard lock with takeover, if a real team asks: presence has the facts.
510
+ - Presence on the members and media screens; today only pages, entries and
511
+ global items track.
512
+ - Postgres Changes for live list refresh; the lists still load on navigation.
513
+ - Field-level merging of two editors' changes; the guard refuses whole documents.
514
+ - Publish, discard and restore compare the token, then move the pointer under a
515
+ pointer filter: a save that rewrites the same draft row in place between the
516
+ two is published unseen (one round trip wide). A SQL function would close it.
517
+ - Publish leaves the token unchanged (the pointer and its saved time do not move),
518
+ so a publish with a token from before another person's publish of the same
519
+ draft succeeds — the content is the same. Recorded, not a bug.
520
+ - The read client over the read key (DESIGN.md OQ 31) can reuse `publicContent()`.
521
+ - A dropped socket clears presence on Realtime's timeout (tens of seconds); the
522
+ lock outlives a closed tab by that much.
523
+ - The live suites wipe `members` and the content tables; a pane walk after
524
+ them needs `npm run reseed` (or `scripts/demo-editor.ts`) first.
525
+ - `e2e/helpers.ts`'s `expectSaved` passes at once when the indicator still
526
+ reads "Saved" from before the edit (the debounce is 800 ms), so a step that
527
+ needs the save landed can race it; `concurrency.spec.ts` waits for the
528
+ save's server response instead. Fold that into the helper for every spec.
529
+
498
530
  ## Follow-ups (logged 2026-09-26, browser suite)
499
531
 
500
532
  - Drag and drop has no browser test (spec §4): the outline, the pages
@@ -60,6 +60,12 @@ import { ResizeHandle } from "../ui/ResizeHandle.js";
60
60
  import { PublishControl } from "../ui/PublishControl.js";
61
61
  import { confirm, DISCARD_CONFIRM } from "../ui/confirm.js";
62
62
  import { HistoryModal, historyRows } from "../ui/HistoryModal.js";
63
+ import { draftToken } from "../../draft-token.js";
64
+ import { isStale, staleInfoOf } from "../shell/stale.js";
65
+ import { StaleBanner } from "../ui/StaleBanner.js";
66
+ import { usePresence, usePresenceState, useSavedElsewhere, useTrackPresence } from "../shell/presence-context.js";
67
+ import { editorsOf } from "../shell/presence.js";
68
+ import { PresenceLine } from "../ui/PresenceLine.js";
63
69
  import { Banner as BannerBox, Breadcrumb, Button, FieldErrorList, IconButton, LinkButton, SaveIndicator, Tabs, TopBar, } from "../ui/primitives.js";
64
70
  export const withEditorParam = (url) => (url.includes("?") ? `${url}&smoodly-editor=1` : `${url}?smoodly-editor=1`);
65
71
  /** The site URL of a stored version — History's iframe (spec 2026-09-13 history §5); no editor parameter, so no bridge. */
@@ -67,6 +73,10 @@ export const withVersionParam = (url, versionId) => `${url}${url.includes("?") ?
67
73
  export function EditorView({ ops, registry, id }) {
68
74
  const adminHref = useAdminHref();
69
75
  const [record, setRecord] = useState(null);
76
+ const recordRef = useRef(record);
77
+ recordRef.current = record;
78
+ /** The guard's refusal (spec 2026-09-26 concurrency §4): the view freezes under the banner until Load their version. */
79
+ const [stale, setStale] = useState(null);
70
80
  const [tree, setTree] = useState(null);
71
81
  const [loadError, setLoadError] = useState(null);
72
82
  const [ready, setReady] = useState(false);
@@ -77,6 +87,8 @@ export function EditorView({ ops, registry, id }) {
77
87
  // The locales the parent exists in — "Add locale" offers only these.
78
88
  const [parentLocales, setParentLocales] = useState([]);
79
89
  const [selectedId, setSelectedId] = useState(null);
90
+ /** A reload after someone else's save (spec 2026-09-26 concurrency §5) keeps what is selected; every other reload clears it. */
91
+ const keepSelectionRef = useRef(false);
80
92
  const [hoverId, setHoverId] = useState(null);
81
93
  const [hoverField, setHoverField] = useState(null);
82
94
  const [revealReq, setRevealReq] = useState(null);
@@ -112,21 +124,26 @@ export function EditorView({ ops, registry, id }) {
112
124
  // (Read once: localeFromUrl also remembers the locale.)
113
125
  const [openedLocale] = useState(() => localeFromUrl(registry));
114
126
  const treeLocaleRef = useRef(openedLocale);
127
+ /** The token of the row the tree belongs to — what every guarded op sends (spec 2026-09-26 concurrency §1). */
128
+ const token = () => { const row = recordRef.current?.locales[treeLocaleRef.current]; return row ? draftToken(row) : null; };
115
129
  // Every edit marks the draft dirty; a debounced saveDraft persists, with
116
130
  // retry on failure, and the bridge refreshes the iframe on the save ack —
117
131
  // now, or once an inline session ends (spec 2026-09-10 §7).
118
- const { saveState, mark, flush } = useDraftAutosave({
132
+ const { saveState, mark, flush, reset } = useDraftAutosave({
119
133
  read: () => treeRef.current,
120
134
  persist: async (t) => {
121
135
  const target = treeLocaleRef.current;
122
- const r = await ops.pages.saveDraft(id, target, t);
136
+ const r = await ops.pages.saveDraft(id, target, t, token());
123
137
  if (r.ok) {
124
- // The badge and Publish follow each save without a re-get: saveDraft answers with the version id.
138
+ // The badge and Publish follow each save without a re-get: saveDraft answers with the version id and the new token.
125
139
  setRecord((rec) => rec?.locales[target]
126
- ? { ...rec, locales: { ...rec.locales, [target]: { ...rec.locales[target], draftVersionId: r.data.versionId } } }
140
+ ? { ...rec, locales: { ...rec.locales, [target]: { ...rec.locales[target], draftVersionId: r.data.versionId, draftSavedAt: r.data.draft?.savedAt ?? null } } }
127
141
  : rec);
128
142
  setDirty(false);
143
+ presence?.announce(pageTarget(target), r.data.draft?.savedAt ?? null);
129
144
  }
145
+ else if (isStale(r))
146
+ setStale(staleInfoOf(r));
130
147
  return r;
131
148
  },
132
149
  onSaved: () => inlineRef.current?.onSaved(),
@@ -134,7 +151,7 @@ export function EditorView({ ops, registry, id }) {
134
151
  // Edits to a global block go to the global entry through their own
135
152
  // autosave (spec 2026-09-13 §4); the save ack refreshes the canvas
136
153
  // through the same inline-session deferral as the page's.
137
- const globalEdit = useGlobalEdit({ ops, onSaved: () => { setGlobalDirty(false); inlineRef.current?.onSaved(); } });
154
+ const globalEdit = useGlobalEdit({ ops, onSaved: (name) => { setGlobalDirty(false); inlineRef.current?.onSaved(); presence?.announce({ kind: "global", id: name, locale }, null); } });
138
155
  const patchGlobal = (name, path, value) => {
139
156
  globalEdit.patch(name, path, value);
140
157
  setGlobalDirty(true);
@@ -148,6 +165,12 @@ export function EditorView({ ops, registry, id }) {
148
165
  clearBanner: () => setBanner(null),
149
166
  reload: () => setReloadKey((k) => k + 1),
150
167
  });
168
+ // Presence (spec 2026-09-26 concurrency §5): tracked once the record exists, the others on it, every ack announced.
169
+ const target = record ? { kind: "page", id, locale } : null;
170
+ useTrackPresence(target);
171
+ const { others } = usePresenceState();
172
+ const presence = usePresence();
173
+ const pageTarget = (forLocale) => ({ kind: "page", id, locale: forLocale });
151
174
  // The URL that serves this page, per locale, from the store's paths
152
175
  // index — the label is the CMS path, the canvas and "View live" take
153
176
  // the site URL (href applied server-side by pages.get).
@@ -177,11 +200,18 @@ export function EditorView({ ops, registry, id }) {
177
200
  setReady(false);
178
201
  setTree(null);
179
202
  treeRef.current = null;
180
- setSelectedId(null);
181
- setHoverId(null);
203
+ const keepSelection = keepSelectionRef.current;
204
+ keepSelectionRef.current = false;
205
+ if (!keepSelection) {
206
+ setSelectedId(null);
207
+ setHoverId(null);
208
+ }
182
209
  setFolder(false);
183
210
  globalEdit.reset(); // a new locale or a reload is a new canvas; the switch flushed first
211
+ if (keepSelection && selectedItemName)
212
+ void globalEdit.load(selectedItemName, locale); // the kept selection is a global item: load it again
184
213
  setDirty(false);
214
+ setStale(null);
185
215
  setTouched(false);
186
216
  setGlobalDirty(false);
187
217
  setGlobalTouched(false);
@@ -350,6 +380,24 @@ export function EditorView({ ops, registry, id }) {
350
380
  if (globalEdit.loadError)
351
381
  setBanner({ text: globalEdit.loadError });
352
382
  }, [globalEdit.loadError]);
383
+ // A global item's autosave refused by the guard freezes the whole editor too (§4).
384
+ useEffect(() => {
385
+ if (globalEdit.stale)
386
+ setStale(globalEdit.stale);
387
+ }, [globalEdit.stale]);
388
+ /** Load their version (§4): drop the frozen state; the load effect resets the tree, the globals and the flags. */
389
+ const loadTheirs = () => { setStale(null); reset(); setReloadKey((k) => k + 1); };
390
+ // Someone else saved this page (§5): clean → their version silently; unsaved edits anywhere → the freeze at once.
391
+ useSavedElsewhere(target, (event) => {
392
+ if (stale)
393
+ return;
394
+ if (dirty || globalDirty || saveState !== "saved" || globalEdit.saveState !== "saved")
395
+ setStale({ by: event.email, at: Date.now() });
396
+ else {
397
+ keepSelectionRef.current = true;
398
+ setReloadKey((k) => k + 1);
399
+ }
400
+ });
353
401
  // Selecting from the outline must reveal the block. The canvas column is
354
402
  // the scroller now (the iframe has no scrollbar of its own — it is sized to
355
403
  // the full document), so scroll it to the rect the bridge reported.
@@ -541,7 +589,7 @@ export function EditorView({ ops, registry, id }) {
541
589
  }
542
590
  let result;
543
591
  try {
544
- result = await ops.pages.publish(id, locale);
592
+ result = await ops.pages.publish(id, locale, token());
545
593
  }
546
594
  catch (e) {
547
595
  console.error(e);
@@ -552,9 +600,13 @@ export function EditorView({ ops, registry, id }) {
552
600
  setPageBusy(null);
553
601
  if (result.ok) {
554
602
  setRecord(result.data);
603
+ presence?.announce(pageTarget(locale), result.data.locales[locale]?.draftSavedAt ?? null);
555
604
  setPublished(true);
556
605
  setTimeout(() => setPublished(false), 1600);
557
606
  }
607
+ else if (isStale(result)) {
608
+ setStale(staleInfoOf(result));
609
+ }
558
610
  else {
559
611
  setBanner({ text: result.message, fields: result.fields });
560
612
  }
@@ -573,17 +625,21 @@ export function EditorView({ ops, registry, id }) {
573
625
  }
574
626
  let result;
575
627
  try {
576
- result = await ops.pages.discardDraft(id, locale);
628
+ result = await ops.pages.discardDraft(id, locale, token());
577
629
  }
578
630
  catch {
579
631
  result = { ok: false, code: "internal", message: "Couldn't reach the server." };
580
632
  }
581
633
  setPageBusy(null);
582
634
  if (!result.ok) {
583
- setBanner({ text: result.message });
635
+ if (isStale(result))
636
+ setStale(staleInfoOf(result));
637
+ else
638
+ setBanner({ text: result.message });
584
639
  return;
585
640
  }
586
641
  setRecord(result.data);
642
+ presence?.announce(pageTarget(locale), result.data.locales[locale]?.draftSavedAt ?? null);
587
643
  setReloadKey((k) => k + 1); // the tree and the canvas come back from the store
588
644
  };
589
645
  /** Unpublish (§5): the locale goes off the site; the draft stays for a later Publish. */
@@ -631,16 +687,20 @@ export function EditorView({ ops, registry, id }) {
631
687
  const doRestore = async (versionId) => {
632
688
  let result;
633
689
  try {
634
- result = await ops.pages.restore(id, locale, versionId);
690
+ result = await ops.pages.restore(id, locale, versionId, token());
635
691
  }
636
692
  catch {
637
693
  result = { ok: false, code: "internal", message: "Couldn't reach the server." };
638
694
  }
639
695
  if (!result.ok) {
640
- setBanner({ text: result.message });
696
+ if (isStale(result))
697
+ setStale(staleInfoOf(result));
698
+ else
699
+ setBanner({ text: result.message });
641
700
  return false;
642
701
  }
643
702
  setRecord(result.data);
703
+ presence?.announce(pageTarget(locale), result.data.locales[locale]?.draftSavedAt ?? null);
644
704
  setReloadKey((k) => k + 1);
645
705
  return true;
646
706
  };
@@ -650,8 +710,8 @@ export function EditorView({ ops, registry, id }) {
650
710
  setGlobalBusy("publish");
651
711
  const r = await globalEdit.setStatus(name, "published");
652
712
  setGlobalBusy(null);
653
- if (!r.ok)
654
- setBanner({ text: r.message, fields: r.fields });
713
+ if (!r.ok && !isStale(r))
714
+ setBanner({ text: r.message, fields: r.fields }); // a stale refusal arrives through globalEdit.stale
655
715
  };
656
716
  const doUnpublishGlobal = async (name) => {
657
717
  setBanner(null);
@@ -673,15 +733,19 @@ export function EditorView({ ops, registry, id }) {
673
733
  return;
674
734
  }
675
735
  let r;
736
+ const itemRow = globalEdit.items[name]?.record.locales[locale];
676
737
  try {
677
- r = await ops.globals.discardDraft(name, locale);
738
+ r = await ops.globals.discardDraft(name, locale, itemRow ? draftToken(itemRow) : null);
678
739
  }
679
740
  catch {
680
741
  r = { ok: false, code: "internal", message: "Couldn't reach the server." };
681
742
  }
682
743
  setGlobalBusy(null);
683
744
  if (!r.ok) {
684
- setBanner({ text: r.message });
745
+ if (isStale(r))
746
+ setStale(staleInfoOf(r));
747
+ else
748
+ setBanner({ text: r.message });
685
749
  return;
686
750
  }
687
751
  // The item reloads from the store; the canvas refreshes through the same path as a save ack.
@@ -765,7 +829,7 @@ export function EditorView({ ops, registry, id }) {
765
829
  trailing: registry.locales.supported.length > 1 && (_jsx(LocaleSwitcher, { registry: registry, present: record ? registry.locales.supported.filter((l) => record.locales[l]) : [locale], parentLocales: parentLocales, value: locale, adding: adding, onSwitch: (l) => { void switchLocale(l); }, onAdd: (l) => { void doAddLocale(l); } })),
766
830
  },
767
831
  { label },
768
- ] }) }), right: _jsxs(_Fragment, { children: [(touched || saveState === "error") && _jsx(SaveIndicator, { state: saveState }), ready && (_jsx(LinkButton, { icon: true, href: siteUrl, target: "_blank", rel: "noreferrer", "aria-label": "View live", title: "View live \u2014 opens the site route. While the admin's draft-mode cookie is on, this shows the DRAFT \u2014 open it in a private window to see what visitors see.", children: _jsx(Eye, { size: 16, strokeWidth: 1.5, "aria-hidden": "true" }) })), _jsx(PublishControl, { status: record?.locales[locale] ? localeStatusOf(record.locales[locale]) : null, pending: dirty || saveState !== "saved", busy: pageBusy, flash: published, onPublish: () => { void doPublish(); }, onDiscard: () => { void doDiscard(); }, onUnpublish: () => { void doUnpublish(); }, onHistory: () => { void doHistory(); } })] }) }), history && record?.locales[locale] && (_jsx(HistoryModal, { rows: history, draftId: record.locales[locale].draftVersionId, onClose: () => setHistory(null), onRestore: doRestore, render: (versionId) => _jsx("iframe", { className: "sm-history__frame", title: "Version preview", src: withVersionParam(siteUrl, versionId) }) })), _jsxs("div", { className: "sm-editor", children: [_jsxs("div", { className: "sm-canvas", children: [(banner || geometry === "missing") && (_jsxs("div", { style: { display: "flex", flexDirection: "column", gap: 8, padding: "0 20px 10px" }, children: [banner && (_jsxs(BannerBox, { tone: banner.fields && banner.fields.length > 0 ? "warn" : "error", onDismiss: () => setBanner(null), children: [_jsx("p", { children: banner.text }), _jsx(FieldErrorList, { fields: banner.fields ?? [] })] })), geometry === "missing" && (_jsx(BannerBox, { tone: "warn", children: "Selection unavailable \u2014 the page didn't report geometry. The outline still works." }))] })), _jsx("div", { className: "sm-canvas__scroll", ref: scrollerRef, onClick: (e) => { if (e.target === e.currentTarget)
832
+ ] }) }), right: _jsxs(_Fragment, { children: [(touched || saveState === "error") && _jsx(SaveIndicator, { state: saveState }), ready && (_jsx(LinkButton, { icon: true, href: siteUrl, target: "_blank", rel: "noreferrer", "aria-label": "View live", title: "View live \u2014 opens the site route. While the admin's draft-mode cookie is on, this shows the DRAFT \u2014 open it in a private window to see what visitors see.", children: _jsx(Eye, { size: 16, strokeWidth: 1.5, "aria-hidden": "true" }) })), _jsx(PublishControl, { status: record?.locales[locale] ? localeStatusOf(record.locales[locale]) : null, pending: dirty || saveState !== "saved", busy: pageBusy, flash: published, onPublish: () => { void doPublish(); }, onDiscard: () => { void doDiscard(); }, onUnpublish: () => { void doUnpublish(); }, onHistory: () => { void doHistory(); } })] }) }), target && _jsx(PresenceLine, { names: editorsOf(others, target), noun: "page" }), stale && _jsx(StaleBanner, { info: stale, onReload: loadTheirs }), history && record?.locales[locale] && (_jsx(HistoryModal, { rows: history, draftId: record.locales[locale].draftVersionId, onClose: () => setHistory(null), onRestore: doRestore, render: (versionId) => _jsx("iframe", { className: "sm-history__frame", title: "Version preview", src: withVersionParam(siteUrl, versionId) }) })), _jsxs("div", { className: "sm-editor", inert: stale ? true : undefined, children: [_jsxs("div", { className: "sm-canvas", children: [(banner || geometry === "missing") && (_jsxs("div", { style: { display: "flex", flexDirection: "column", gap: 8, padding: "0 20px 10px" }, children: [banner && (_jsxs(BannerBox, { tone: banner.fields && banner.fields.length > 0 ? "warn" : "error", onDismiss: () => setBanner(null), children: [_jsx("p", { children: banner.text }), _jsx(FieldErrorList, { fields: banner.fields ?? [] })] })), geometry === "missing" && (_jsx(BannerBox, { tone: "warn", children: "Selection unavailable \u2014 the page didn't report geometry. The outline still works." }))] })), _jsx("div", { className: "sm-canvas__scroll", ref: scrollerRef, onClick: (e) => { if (e.target === e.currentTarget)
769
833
  setSelectedId(null); }, children: _jsxs("div", { className: "sm-page", style: { overflow: "hidden" }, children: [iframeSrc ? (_jsx("iframe", { ref: iframeRef, src: iframeSrc, title: "Page canvas", style: { width: "100%", height: docH, border: 0, display: "block" } })) : (_jsx("div", { className: "sm-canvas__loading", children: "Loading canvas\u2026" })), _jsx(CanvasOverlay, { emptyZones: emptyZones, hoverRect: hoverRect, hoverGlobal: hoverGlobal, selection: selection, fieldHover: fieldHover, editing: editing, plusButtons: plusButtons, onPlus: (p, x, y) => setPicker({ zone: p.zone, index: p.index, x: x - 150, y: y + 12 }), onMove: doMove, onDuplicate: doDuplicate, onRemove: doRemove }), inline.richtext && (() => {
770
834
  const { session } = inline.richtext;
771
835
  const item = globalItemOf(session.node);
@@ -1,5 +1,5 @@
1
1
  import type { OpResult } from "../ops.ts";
2
- export type SaveState = "saved" | "saving" | "error";
2
+ export type SaveState = "saved" | "saving" | "error" | "stale";
3
3
  export type DraftAutosave = {
4
4
  /** Record a local change; a debounced save follows. */
5
5
  mark(): void;
@@ -11,6 +11,8 @@ export type DraftAutosave = {
11
11
  subscribe(listener: () => void): () => void;
12
12
  /** Cancel pending timers; call on unmount. */
13
13
  dispose(): void;
14
+ /** Leave `stale` after the record was reloaded: timers cleared, the reloaded value counts as saved. */
15
+ reset(): void;
14
16
  };
15
17
  export declare function createDraftAutosave<T>(opts: {
16
18
  read: () => T | null;
@@ -28,4 +30,5 @@ export declare function useDraftAutosave<T>(opts: DraftAutosaveOptions<T>): {
28
30
  saveState: SaveState;
29
31
  mark: () => void;
30
32
  flush: () => Promise<boolean>;
33
+ reset: () => void;
31
34
  };
@@ -46,6 +46,12 @@ export function createDraftAutosave(opts) {
46
46
  opts.onSaved?.();
47
47
  return true;
48
48
  }
49
+ else if (result.code === "conflict" && result.stale) {
50
+ // The guard refused (spec 2026-09-26 concurrency §4): terminal, never retried —
51
+ // the view freezes and only a reload (reset) brings the controller back.
52
+ setState("stale");
53
+ return false;
54
+ }
49
55
  else {
50
56
  // Keep local state; retry with a doubling, capped delay.
51
57
  setState("error");
@@ -62,12 +68,16 @@ export function createDraftAutosave(opts) {
62
68
  return () => { listeners.delete(listener); };
63
69
  },
64
70
  mark() {
71
+ if (state === "stale")
72
+ return;
65
73
  revision += 1;
66
74
  if (debounceTimer)
67
75
  clearTimeout(debounceTimer);
68
76
  debounceTimer = setTimeout(() => { debounceTimer = null; void save(); }, debounceMs);
69
77
  },
70
78
  flush() {
79
+ if (state === "stale")
80
+ return Promise.resolve(false);
71
81
  if (revision === savedRevision)
72
82
  return Promise.resolve(true);
73
83
  if (debounceTimer) {
@@ -86,6 +96,19 @@ export function createDraftAutosave(opts) {
86
96
  retryTimer = null;
87
97
  }
88
98
  },
99
+ reset() {
100
+ if (debounceTimer) {
101
+ clearTimeout(debounceTimer);
102
+ debounceTimer = null;
103
+ }
104
+ if (retryTimer) {
105
+ clearTimeout(retryTimer);
106
+ retryTimer = null;
107
+ }
108
+ savedRevision = revision;
109
+ retryDelay = retryBaseMs;
110
+ setState("saved");
111
+ },
89
112
  };
90
113
  }
91
114
  /** React glue: one controller per mount, always calling the latest
@@ -104,5 +127,5 @@ export function useDraftAutosave(opts) {
104
127
  }));
105
128
  useEffect(() => () => controller.dispose(), [controller]);
106
129
  const saveState = useSyncExternalStore(controller.subscribe, controller.getState, controller.getState);
107
- return { saveState, mark: controller.mark, flush: controller.flush };
130
+ return { saveState, mark: controller.mark, flush: controller.flush, reset: controller.reset };
108
131
  }
@@ -1,6 +1,7 @@
1
1
  import type { EntryRecord, EntryUsage } from "../../entry-store.ts";
2
2
  import type { AdminOps, OpResult } from "../ops.ts";
3
3
  import { type SaveState } from "./autosave.ts";
4
+ import { type StaleInfo } from "../shell/stale.ts";
4
5
  export type GlobalEditItem = {
5
6
  name: string;
6
7
  locale: string;
@@ -15,6 +16,8 @@ export type GlobalEditState = {
15
16
  items: Record<string, GlobalEditItem>;
16
17
  saveState: SaveState;
17
18
  loadError: string | null;
19
+ /** The guard's refusal (spec 2026-09-26 concurrency §4): set once, cleared only by reset(). */
20
+ stale: StaleInfo | null;
18
21
  };
19
22
  export type GlobalEdit = {
20
23
  getState(): GlobalEditState;
@@ -32,7 +35,8 @@ export type GlobalEdit = {
32
35
  };
33
36
  export declare function createGlobalEdit(opts: {
34
37
  ops: Pick<AdminOps, "globals">;
35
- onSaved?: () => void;
38
+ /** Per saved item, with its name (presence announces each; spec 2026-09-26 concurrency §5). */
39
+ onSaved?: (name: string) => void;
36
40
  debounceMs?: number;
37
41
  retryBaseMs?: number;
38
42
  retryCapMs?: number;
@@ -1,8 +1,11 @@
1
1
  import { setAtPath } from "../field-paths.js";
2
2
  import { createDraftAutosave } from "./autosave.js";
3
+ import { draftToken } from "../../draft-token.js";
4
+ import { isStale, staleInfoOf } from "../shell/stale.js";
3
5
  export function createGlobalEdit(opts) {
4
6
  let items = {};
5
7
  let loadError = null;
8
+ let stale = null;
6
9
  let generation = 0;
7
10
  const loading = new Set();
8
11
  const rev = new Map();
@@ -20,7 +23,7 @@ export function createGlobalEdit(opts) {
20
23
  const at = rev.get(item.name) ?? 0;
21
24
  let r;
22
25
  try {
23
- r = await opts.ops.globals.save(item.name, item.locale, item.fields);
26
+ r = await opts.ops.globals.save(item.name, item.locale, item.fields, draftToken(item.record.locales[item.locale]));
24
27
  }
25
28
  catch {
26
29
  r = { ok: false, code: "internal", message: "Couldn't reach the server." };
@@ -30,8 +33,11 @@ export function createGlobalEdit(opts) {
30
33
  savedRev.set(item.name, at);
31
34
  if (current)
32
35
  items = { ...items, [item.name]: { ...current, record: r.data, errors: {} } };
36
+ opts.onSaved?.(item.name);
33
37
  }
34
38
  else {
39
+ if (isStale(r))
40
+ stale = staleInfoOf(r);
35
41
  failed ?? (failed = r);
36
42
  if (current) {
37
43
  items = { ...items, [item.name]: { ...current, errors: Object.fromEntries((r.fields ?? []).map((f) => [f.field, f.message])) } };
@@ -41,13 +47,12 @@ export function createGlobalEdit(opts) {
41
47
  emit();
42
48
  return failed ?? { ok: true, data: null };
43
49
  },
44
- onSaved: opts.onSaved,
45
50
  debounceMs: opts.debounceMs,
46
51
  retryBaseMs: opts.retryBaseMs,
47
52
  retryCapMs: opts.retryCapMs,
48
53
  });
49
54
  autosave.subscribe(emit);
50
- const getState = () => (snapshot ?? (snapshot = { items, saveState: autosave.getState(), loadError }));
55
+ const getState = () => (snapshot ?? (snapshot = { items, saveState: autosave.getState(), loadError, stale }));
51
56
  return {
52
57
  getState,
53
58
  subscribe(listener) {
@@ -98,7 +103,7 @@ export function createGlobalEdit(opts) {
98
103
  }
99
104
  let r;
100
105
  try {
101
- r = await opts.ops.globals.setStatus(name, item.locale, status);
106
+ r = await opts.ops.globals.setStatus(name, item.locale, status, draftToken(item.record.locales[item.locale]));
102
107
  }
103
108
  catch {
104
109
  r = { ok: false, code: "internal", message: "Couldn't reach the server." };
@@ -107,6 +112,10 @@ export function createGlobalEdit(opts) {
107
112
  items = { ...items, [name]: { ...items[name], record: r.data } };
108
113
  emit();
109
114
  }
115
+ else if (isStale(r)) {
116
+ stale = staleInfoOf(r);
117
+ emit();
118
+ }
110
119
  return r;
111
120
  },
112
121
  reset() {
@@ -116,7 +125,8 @@ export function createGlobalEdit(opts) {
116
125
  savedRev.clear();
117
126
  loading.clear();
118
127
  loadError = null;
119
- autosave.dispose();
128
+ stale = null;
129
+ autosave.reset(); // timers cleared, and a stale controller comes back for the new canvas
120
130
  emit();
121
131
  },
122
132
  dispose: autosave.dispose,
@@ -2,5 +2,5 @@ import type { AdminOps } from "../ops.ts";
2
2
  import { type GlobalEdit, type GlobalEditState } from "./global-edit.ts";
3
3
  export declare function useGlobalEdit({ ops, onSaved }: {
4
4
  ops: Pick<AdminOps, "globals">;
5
- onSaved?: () => void;
5
+ onSaved?: (name: string) => void;
6
6
  }): GlobalEditState & Pick<GlobalEdit, "load" | "patch" | "flush" | "setStatus" | "reset">;
@@ -7,7 +7,7 @@ import { createGlobalEdit } from "./global-edit.js";
7
7
  export function useGlobalEdit({ ops, onSaved }) {
8
8
  const latest = useRef(onSaved);
9
9
  latest.current = onSaved;
10
- const [controller] = useState(() => createGlobalEdit({ ops, onSaved: () => latest.current?.() }));
10
+ const [controller] = useState(() => createGlobalEdit({ ops, onSaved: (name) => latest.current?.(name) }));
11
11
  useEffect(() => () => { void controller.flush(); controller.dispose(); }, [controller]);
12
12
  const state = useSyncExternalStore(controller.subscribe, controller.getState, controller.getState);
13
13
  return {
@@ -2,10 +2,14 @@ import type { ReactNode } from "react";
2
2
  import type { ResolvedConfig } from "../../config.ts";
3
3
  import type { AdminOpAction } from "../ops.ts";
4
4
  import type { AuthConfig } from "../shell/session.ts";
5
+ import type { ContentConfig } from "../../env.ts";
6
+ /** `content` (optional) is the content project's public config for editing presence (spec 2026-09-26
7
+ * concurrency §5); absent — an app wired before it — presence is off and everything else works. */
5
8
  export declare function createSmoodlyAdmin(input: {
6
9
  config: ResolvedConfig;
7
10
  op: AdminOpAction;
8
11
  auth: () => AuthConfig;
12
+ content?: () => ContentConfig;
9
13
  }): {
10
14
  AdminPage: () => null;
11
15
  AdminLayout: ({ children }: {
@@ -2,6 +2,8 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { serializeAdminConfig } from "../serialize.js";
3
3
  import { AdminApp } from "../shell/AdminApp.js";
4
4
  import { AdminStyles } from "../ui/theme.js";
5
+ /** `content` (optional) is the content project's public config for editing presence (spec 2026-09-26
6
+ * concurrency §5); absent — an app wired before it — presence is off and everything else works. */
5
7
  export function createSmoodlyAdmin(input) {
6
8
  const registry = serializeAdminConfig(input.config);
7
9
  /** The catch-all page only makes every /admin URL exist. The shell is
@@ -22,7 +24,7 @@ export function createSmoodlyAdmin(input) {
22
24
  * on this element before React hydrates, on purpose (no white flash
23
25
  * for a dark-mode editor), so the attribute differs from the server's. */
24
26
  function AdminLayout({ children }) {
25
- return (_jsxs("div", { className: "sm-admin", suppressHydrationWarning: true, children: [_jsx(AdminStyles, {}), _jsx(AdminApp, { registry: registry, op: input.op, auth: input.auth() }), children] }));
27
+ return (_jsxs("div", { className: "sm-admin", suppressHydrationWarning: true, children: [_jsx(AdminStyles, {}), _jsx(AdminApp, { registry: registry, op: input.op, auth: input.auth(), content: input.content?.() }), children] }));
26
28
  }
27
29
  return { AdminPage, AdminLayout };
28
30
  }
@@ -4,5 +4,6 @@ export type { AdminOpCall, AdminOpAction, AdminOps, OpResult, MembersListResult
4
4
  export type { InviteInput, InviteResult, MemberRecord } from "../../member-store.ts";
5
5
  export type { MemberRole } from "../../permissions.ts";
6
6
  export type { AuthConfig } from "../shell/session.ts";
7
+ export type { ContentConfig } from "../../env.ts";
7
8
  export type { AdminAuth } from "../auth.ts";
8
9
  export { supabaseAdminAuth } from "../supabase-auth.ts";