smoodly 0.0.14 → 0.0.16

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 (67) hide show
  1. package/README.md +94 -15
  2. package/dist/admin/auth.d.ts +7 -4
  3. package/dist/admin/auth.js +1 -1
  4. package/dist/admin/client-ops.js +3 -3
  5. package/dist/admin/editor/CanvasOverlay.d.ts +5 -1
  6. package/dist/admin/editor/CanvasOverlay.js +5 -5
  7. package/dist/admin/editor/EditorView.d.ts +2 -0
  8. package/dist/admin/editor/EditorView.js +251 -23
  9. package/dist/admin/editor/PageSettings.js +3 -2
  10. package/dist/admin/editor/SharedInspector.d.ts +23 -0
  11. package/dist/admin/editor/SharedInspector.js +13 -0
  12. package/dist/admin/editor/shared-edit.d.ts +39 -0
  13. package/dist/admin/editor/shared-edit.js +124 -0
  14. package/dist/admin/editor/useSharedEdit.d.ts +6 -0
  15. package/dist/admin/editor/useSharedEdit.js +18 -0
  16. package/dist/admin/forms/tabs.d.ts +9 -3
  17. package/dist/admin/next/op-handler.js +4 -3
  18. package/dist/admin/ops-impl.d.ts +3 -0
  19. package/dist/admin/ops-impl.js +198 -23
  20. package/dist/admin/ops.d.ts +32 -0
  21. package/dist/admin/serialize.d.ts +0 -1
  22. package/dist/admin/serialize.js +0 -1
  23. package/dist/admin/shared-items.js +14 -0
  24. package/dist/admin/shell/AdminApp.js +1 -1
  25. package/dist/admin/shell/EntriesList.js +19 -3
  26. package/dist/admin/shell/EntryForm.js +183 -77
  27. package/dist/admin/shell/IconRail.js +3 -3
  28. package/dist/admin/shell/PagesList.js +3 -2
  29. package/dist/admin/shell/SharedFields.d.ts +21 -0
  30. package/dist/admin/shell/SharedFields.js +15 -0
  31. package/dist/admin/shell/SharedForm.js +166 -52
  32. package/dist/admin/shell/SharedList.js +3 -2
  33. package/dist/admin/shell/entries-list.d.ts +2 -2
  34. package/dist/admin/shell/entries-list.js +3 -3
  35. package/dist/admin/supabase-auth.js +1 -1
  36. package/dist/admin/ui/ActionMenu.d.ts +11 -0
  37. package/dist/admin/ui/ActionMenu.js +25 -0
  38. package/dist/admin/ui/HistoryModal.d.ts +23 -0
  39. package/dist/admin/ui/HistoryModal.js +40 -0
  40. package/dist/admin/ui/Modal.d.ts +5 -1
  41. package/dist/admin/ui/Modal.js +3 -3
  42. package/dist/admin/ui/PublishControl.d.ts +31 -0
  43. package/dist/admin/ui/PublishControl.js +24 -0
  44. package/dist/admin/ui/format.d.ts +4 -0
  45. package/dist/admin/ui/format.js +9 -0
  46. package/dist/admin/ui/primitives.d.ts +13 -0
  47. package/dist/admin/ui/primitives.js +18 -2
  48. package/dist/admin/ui/status-label.d.ts +10 -0
  49. package/dist/admin/ui/status-label.js +14 -0
  50. package/dist/admin/ui/theme.js +46 -6
  51. package/dist/collections.d.ts +0 -6
  52. package/dist/collections.js +0 -1
  53. package/dist/entry-store.d.ts +38 -13
  54. package/dist/entry-store.js +90 -23
  55. package/dist/next/page-renderer.js +8 -4
  56. package/dist/shared.d.ts +8 -1
  57. package/dist/shared.js +10 -1
  58. package/dist/site.d.ts +2 -0
  59. package/dist/site.js +17 -7
  60. package/dist/sql.js +18 -15
  61. package/dist/store.d.ts +38 -13
  62. package/dist/store.js +82 -12
  63. package/dist/supabase-entry-store.d.ts +11 -6
  64. package/dist/supabase-entry-store.js +101 -25
  65. package/dist/supabase-store.d.ts +13 -6
  66. package/dist/supabase-store.js +88 -19
  67. package/package.json +1 -1
@@ -106,11 +106,11 @@ export function Segmented({ options, value, onChange, stretch, disabled, label,
106
106
  }) }));
107
107
  }
108
108
  /* ── data ────────────────────────────────────────────────────────────── */
109
+ // Keyed by the product labels of ui/status-label.ts (lowercased by StatusBadge).
109
110
  const STATUS_TONE = {
110
111
  published: "published",
112
+ "unpublished changes": "changes",
111
113
  draft: "draft",
112
- "draft edits": "scheduled",
113
- scheduled: "scheduled",
114
114
  };
115
115
  export function StatusBadge({ status }) {
116
116
  const tone = STATUS_TONE[status.toLowerCase()] ?? "draft";
@@ -134,9 +134,25 @@ export function SectionLabel({ children, action, mono, style }) {
134
134
  export function Divider({ bleed = 0 }) {
135
135
  return _jsx("div", { className: "sm-divider", style: { margin: `0 -${bleed}px` } });
136
136
  }
137
+ /** The lines under a publish-validation banner: one per field, each focusing its widget
138
+ * when the screen can reveal it (spec 2026-09-14 §2). */
139
+ export function FieldErrorList({ fields, onReveal }) {
140
+ if (fields.length === 0)
141
+ return null;
142
+ return (_jsx("ul", { className: "sm-fielderr", children: fields.map((f) => (_jsxs("li", { children: [onReveal
143
+ ? _jsx("button", { type: "button", className: "sm-fielderr__field", onClick: () => onReveal(f.field), children: f.field })
144
+ : f.field, ": ", f.message] }, f.field))) }));
145
+ }
137
146
  export function Banner({ tone, children, onDismiss }) {
138
147
  return (_jsxs("div", { className: `sm-banner sm-banner--${tone}`, role: tone === "error" ? "alert" : undefined, children: [_jsx("div", { style: { flex: 1, minWidth: 0 }, children: children }), onDismiss && (_jsx("button", { type: "button", onClick: onDismiss, "aria-label": "Dismiss", style: { background: "transparent", border: 0, color: "inherit", cursor: "pointer", font: "inherit", padding: 0 }, children: "\u00D7" }))] }));
139
148
  }
140
149
  export function cx(...parts) {
141
150
  return parts.filter(Boolean).join(" ");
142
151
  }
152
+ /** One line of a form's info block: a muted label, the value right-aligned and clipped. */
153
+ export function InfoRow({ label, value, mono }) {
154
+ return (_jsxs("div", { style: { display: "flex", justifyContent: "space-between", gap: 10, fontSize: "var(--text-sm)", color: "var(--text-muted)" }, children: [_jsx("span", { children: label }), _jsx("span", { style: {
155
+ color: "var(--text)", fontFamily: mono ? "var(--font-mono)" : undefined,
156
+ overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap",
157
+ }, children: value })] }));
158
+ }
@@ -0,0 +1,10 @@
1
+ import type { LocaleStatus } from "../shell/pages-tree.ts";
2
+ export type StatusLabel = "Published" | "Unpublished changes" | "Draft";
3
+ export declare function statusLabel(status: LocaleStatus): StatusLabel;
4
+ /** Which of the three actions apply (spec §2). `pending`: the editor holds a change
5
+ * the store has not confirmed yet — Publish wakes on the first keystroke, not on the ack. */
6
+ export declare function publishActions(status: LocaleStatus | null, pending: boolean): {
7
+ publish: boolean;
8
+ discard: boolean;
9
+ unpublish: boolean;
10
+ };
@@ -0,0 +1,14 @@
1
+ export function statusLabel(status) {
2
+ return status === "published" ? "Published" : status === "draft edits" ? "Unpublished changes" : "Draft";
3
+ }
4
+ /** Which of the three actions apply (spec §2). `pending`: the editor holds a change
5
+ * the store has not confirmed yet — Publish wakes on the first keystroke, not on the ack. */
6
+ export function publishActions(status, pending) {
7
+ if (status === null)
8
+ return { publish: false, discard: false, unpublish: false };
9
+ return {
10
+ publish: status !== "published" || pending,
11
+ discard: status === "draft edits",
12
+ unpublish: status === "published" || status === "draft edits",
13
+ };
14
+ }
@@ -25,6 +25,7 @@ const TOKENS_LIGHT = `
25
25
  --line:#E8E3DA; --line-dashed:#D8D1C4; --line-soft:#EFEBE3;
26
26
  --text:#1F1D1A; --text-secondary:#4E4942; --text-muted:#7A746A; --text-subtle:#A39C90; --text-disabled:#C4BDB1;
27
27
  --selection:var(--accent); --nav-active-bg:var(--accent-soft); --nav-active-fg:var(--accent);
28
+ --selection-shared:#C4620C;
28
29
  --hover:rgba(31,29,26,.05); --press:rgba(31,29,26,.09); --control-hover:#F8F6F1;
29
30
  --btn-secondary-bg:var(--surface); --btn-secondary-hover:#F8F6F1; --tile-hover:var(--surface);
30
31
  --bar-bg:transparent; --bar-border:0; --menu-border:0; --card-divider:0;
@@ -40,7 +41,8 @@ const TOKENS_LIGHT = `
40
41
  --logo-color:var(--accent);
41
42
 
42
43
  --status-published-fg:var(--green-700); --status-published-bg:var(--green-50);
43
- --status-draft-fg:var(--amber-700); --status-draft-bg:var(--amber-50);
44
+ --status-changes-fg:var(--amber-700); --status-changes-bg:var(--amber-50);
45
+ --status-draft-fg:var(--text-muted); --status-draft-bg:var(--surface-inset);
44
46
  --status-scheduled-fg:var(--accent); --status-scheduled-bg:var(--accent-soft);
45
47
  --status-saved-dot:var(--green-500);
46
48
  --warn:var(--amber-700); --warn-soft:var(--amber-50);
@@ -61,6 +63,7 @@ const TOKENS_LIGHT = `
61
63
  `;
62
64
  const TOKENS_DARK = `
63
65
  --accent:#6CC4A6; --accent-hover:#82D2B8; --accent-soft:rgba(108,196,166,.18); --accent-line:#6CC4A6; --on-accent:#0B1F18;
66
+ --selection-shared:#F09A5C;
64
67
  --bg:#1C1A17; --canvas:#141210; --surface:#27241F; --surface-form:#27241F; --surface-inset:#1C1A17;
65
68
  --input-bg:#211E1A; --input-hover-bg:#211E1A; --input-focus-bg:#211E1A;
66
69
  --line:#3A3630; --line-dashed:#4A453D; --line-soft:#3A3630;
@@ -70,7 +73,8 @@ const TOKENS_DARK = `
70
73
  --toolbar-bg:#0F0E0C; --toolbar-fg:#F1EDE6;
71
74
 
72
75
  --status-published-fg:var(--green-300); --status-published-bg:rgba(111,211,154,.14);
73
- --status-draft-fg:var(--amber-300); --status-draft-bg:rgba(232,184,90,.14);
76
+ --status-changes-fg:var(--amber-300); --status-changes-bg:rgba(232,184,90,.14);
77
+ --status-draft-fg:var(--text-muted); --status-draft-bg:var(--press);
74
78
  --warn:var(--amber-300); --warn-soft:rgba(232,184,90,.14);
75
79
  --danger:var(--red-300); --danger-soft:rgba(244,102,92,.14);
76
80
 
@@ -106,6 +110,7 @@ const SCALE = `
106
110
  --table-pad:8px 12px; --table-top:80px; --canvas-pad:24px 40px 0; --card-pad:16px 18px; --card-gap:12px; --form-pad:32px 40px;
107
111
  --border:1px solid var(--line);
108
112
  --outline-selection:2px solid var(--selection);
113
+ --outline-selection-shared:2px solid var(--selection-shared);
109
114
  `;
110
115
  /* ── TEMPORARY: the original look, kept for side-by-side comparison while
111
116
  the warm restyle lands. Frozen copies of the three strings above as they
@@ -128,11 +133,13 @@ const TECHNICAL_LIGHT = `
128
133
  --text:var(--gray-900); --text-muted:var(--gray-600); --text-subtle:var(--gray-500);
129
134
  --accent:var(--brand-600); --on-accent:#FFFFFF; --accent-soft:var(--brand-50);
130
135
  --selection:var(--brand-600); --nav-active-bg:var(--brand-100); --nav-active-fg:var(--brand-600);
136
+ --selection-shared:#D2610A;
131
137
  --hover:rgba(15,20,32,.045); --press:rgba(15,20,32,.08);
132
138
  --control-hover:var(--gray-100);
133
139
 
134
140
  --status-published-fg:var(--green-700); --status-published-bg:var(--green-50);
135
- --status-draft-fg:var(--amber-700); --status-draft-bg:var(--amber-50);
141
+ --status-changes-fg:var(--amber-700); --status-changes-bg:var(--amber-50);
142
+ --status-draft-fg:var(--text-muted); --status-draft-bg:var(--surface-inset);
136
143
  --status-scheduled-fg:var(--accent); --status-scheduled-bg:var(--accent-soft);
137
144
  --status-saved-dot:var(--green-500);
138
145
  --warn:var(--amber-700); --warn-soft:var(--amber-50);
@@ -155,12 +162,14 @@ const TECHNICAL_DARK = `
155
162
  --line:#2A2F3B; --line-dashed:#2A2F3B;
156
163
  --text:#EEF0F5; --text-muted:#8C94A8; --text-subtle:#6B7386;
157
164
  --accent:var(--brand-300); --on-accent:#06122F; --accent-soft:rgba(110,150,255,.16);
165
+ --selection-shared:#F2A35F;
158
166
  --nav-active-bg:rgba(110,150,255,.14); --nav-active-fg:var(--brand-200);
159
167
  --hover:rgba(238,240,245,.05); --press:rgba(238,240,245,.09);
160
168
  --control-hover:var(--input-bg);
161
169
 
162
170
  --status-published-fg:var(--green-300); --status-published-bg:rgba(111,211,154,.14);
163
- --status-draft-fg:var(--amber-300); --status-draft-bg:rgba(232,184,90,.14);
171
+ --status-changes-fg:var(--amber-300); --status-changes-bg:rgba(232,184,90,.14);
172
+ --status-draft-fg:var(--text-muted); --status-draft-bg:var(--press);
164
173
  --warn:var(--amber-300); --warn-soft:rgba(232,184,90,.14);
165
174
  --danger:var(--red-300); --danger-soft:rgba(244,102,92,.14);
166
175
 
@@ -181,6 +190,7 @@ const TECHNICAL_SCALE = `
181
190
  --nav-w:72px; --inspector-w:380px;
182
191
  --border:1px solid var(--line);
183
192
  --outline-selection:1.5px solid var(--selection);
193
+ --outline-selection-shared:1.5px solid var(--selection-shared);
184
194
  `;
185
195
  /* The tokens the warm restyle added, with the values the technical look
186
196
  implied, so the frozen block above renders the old shapes too. */
@@ -293,7 +303,7 @@ body:has(.sm-admin){margin:0}
293
303
  .sm-rail__btn[aria-current="page"],.sm-rail__btn[aria-expanded="true"]{color:var(--nav-active-fg);font-weight:600}
294
304
  .sm-rail__btn[aria-current="page"]:hover,.sm-rail__btn[aria-expanded="true"]:hover{color:var(--nav-active-fg)}
295
305
  .sm-rail__btn[aria-current="page"] .sm-rail__tile,.sm-rail__btn[aria-expanded="true"] .sm-rail__tile{background:var(--nav-active-bg)}
296
- .sm-rail__tile svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.75;stroke-linecap:round;stroke-linejoin:round}
306
+ .sm-rail__tile svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:2;stroke-linecap:round;stroke-linejoin:round}
297
307
  .sm-rail__chev{position:absolute;right:4px;bottom:3px;width:8px;height:8px}
298
308
  .sm-rail__chev svg{width:8px;height:8px;stroke-width:3}
299
309
  .sm-rail__label{letter-spacing:.01em;white-space:nowrap}
@@ -592,6 +602,15 @@ body:has(.sm-admin){margin:0}
592
602
  border-radius:var(--radius-sm);line-height:1;height:24px;
593
603
  }
594
604
  .sm-rowmenu:hover{background:var(--surface-inset);color:var(--text)}
605
+ .sm-dotmenu{
606
+ width:var(--button-h);height:var(--button-h);flex:none;border-radius:50%;border:0;padding:0;
607
+ background:var(--btn-secondary-bg);color:var(--text-muted);font:inherit;font-weight:700;letter-spacing:1px;
608
+ display:grid;place-items:center;cursor:pointer;
609
+ }
610
+ .sm-dotmenu:hover{background:var(--btn-secondary-hover);color:var(--text)}
611
+ .sm-dotmenu:disabled{opacity:.4;cursor:default}
612
+ .sm-dotmenu:disabled:hover{background:var(--btn-secondary-bg);color:var(--text-muted)}
613
+ .sm-picker--menu{position:absolute;top:calc(100% + 6px);right:0;width:220px;z-index:60}
595
614
 
596
615
  /* Status pill: a dot before the word in the warm look (none in the technical). */
597
616
  .sm-badge{
@@ -664,6 +683,8 @@ body:has(.sm-admin){margin:0}
664
683
  .sm-banner--error{background:var(--danger-soft);color:var(--danger)}
665
684
  .sm-banner--ok{background:var(--status-published-bg);color:var(--status-published-fg)}
666
685
  .sm-banner--warn{background:var(--warn-soft);color:var(--warn)}
686
+ .sm-fielderr{margin:6px 0 0;padding-left:18px;font:inherit;font-size:var(--text-sm);font-family:var(--font-mono)}
687
+ .sm-fielderr__field{background:none;border:0;padding:0;font:inherit;color:inherit;text-decoration:underline;cursor:pointer}
667
688
 
668
689
  /* ── cards ─────────────────────────────────────────────────────────── */
669
690
  /* The inspector column (editor, entry and shared forms) is a stack of
@@ -732,6 +753,7 @@ body:has(.sm-admin){margin:0}
732
753
  letter-spacing:var(--blocktag-tracking);text-transform:var(--blocktag-transform);padding:3px 10px;
733
754
  border-radius:var(--blocktag-radius);white-space:nowrap;
734
755
  }
756
+ .sm-blocktag--shared{background:var(--selection-shared)}
735
757
  .sm-blocktoolbar{
736
758
  display:flex;gap:2px;padding:3px;background:var(--toolbar-bg);border-radius:var(--radius-btn);
737
759
  box-shadow:var(--shadow-float);
@@ -785,6 +807,10 @@ body:has(.sm-admin){margin:0}
785
807
  border:0;border-radius:var(--radius-sm);padding:8px 10px;cursor:pointer;color:var(--text);font:inherit;
786
808
  }
787
809
  .sm-picker__item:hover{background:var(--nav-active-bg)}
810
+ /* after the base item rule: same specificity, so source order decides (ActionMenu states) */
811
+ .sm-picker__item:disabled{opacity:.4;cursor:default}
812
+ .sm-picker__item:disabled:hover{background:transparent}
813
+ .sm-picker__item--danger{color:var(--danger)}
788
814
  .sm-picker__icon{
789
815
  width:26px;height:26px;flex:none;border-radius:var(--radius-sm);background:var(--accent-soft);
790
816
  color:var(--accent);display:grid;place-items:center;font-size:13px;
@@ -796,7 +822,9 @@ body:has(.sm-admin){margin:0}
796
822
  }
797
823
 
798
824
  /* ── media ─────────────────────────────────────────────────────────── */
799
- .sm-modal{position:fixed;inset:0;z-index:70;background:rgba(0,0,0,.45);display:grid;place-items:center;padding:24px}
825
+ /* The single track is minmax(0,1fr), not auto: an auto track grows to the panel's own width, so the
826
+ panel's max-width:100% would cap at nothing and a 1200px History panel ran past a laptop viewport. */
827
+ .sm-modal{position:fixed;inset:0;z-index:70;background:rgba(0,0,0,.45);display:grid;grid-template-columns:minmax(0,1fr);grid-template-rows:minmax(0,1fr);place-items:center;padding:24px}
800
828
  .sm-modal__panel{
801
829
  max-width:100%;max-height:100%;display:flex;flex-direction:column;background:var(--surface);
802
830
  border:var(--menu-border);border-radius:var(--radius-card);box-shadow:var(--shadow-pop);overflow:hidden;
@@ -804,6 +832,18 @@ body:has(.sm-admin){margin:0}
804
832
  .sm-modal__head{display:flex;align-items:center;justify-content:space-between;padding:14px 16px 10px;border-bottom:var(--card-divider)}
805
833
  .sm-modal__body{padding:12px 16px;overflow:auto;display:flex;flex-direction:column;gap:10px;min-height:0}
806
834
  .sm-modal__foot{display:flex;justify-content:flex-end;gap:8px;padding:12px 16px 14px;border-top:var(--card-divider)}
835
+ .sm-modal__body--flush{padding:0;gap:0;flex:1}
836
+ /* ── history (spec 2026-09-13) ─────────────────────────────────── */
837
+ .sm-history{display:flex;flex:1;min-height:0}
838
+ .sm-history__list{width:260px;flex:none;overflow:auto;display:flex;flex-direction:column;border-right:var(--card-divider)}
839
+ .sm-history__row{display:flex;align-items:center;justify-content:space-between;gap:8px;padding:10px 12px;border:0;border-bottom:var(--card-divider);background:none;color:inherit;font:inherit;font-size:var(--text-sm);text-align:left;cursor:pointer}
840
+ .sm-history__row:hover{background:var(--input-bg)}
841
+ .sm-history__row--active,.sm-history__row--active:hover{background:var(--input-bg);font-weight:600}
842
+ .sm-history__tag{font-size:var(--text-sm);color:var(--text-muted);padding:1px 8px;border-radius:999px;border:1px solid var(--line-dashed)}
843
+ .sm-history__tag--live{color:var(--status-published-fg);background:var(--status-published-bg);border-color:transparent}
844
+ .sm-history__main{flex:1;min-width:0;display:flex;overflow:auto}
845
+ .sm-history__frame{flex:1;width:100%;height:100%;border:0;background:#fff}
846
+ .sm-history__form{flex:1;min-width:0;margin:0;padding:16px;border:0;pointer-events:none;display:flex;flex-direction:column;gap:14px}
807
847
  .sm-picker-bar{display:flex;align-items:center;gap:8px;flex-wrap:wrap}
808
848
  .sm-dropzone{
809
849
  border:1px dashed var(--line-dashed);border-radius:var(--radius-lg);background:var(--input-bg);color:var(--text-muted);
@@ -29,11 +29,6 @@ export type CollectionSchema<F extends BuilderMap = BuilderMap, N extends string
29
29
  admin?: {
30
30
  listColumns?: string[];
31
31
  };
32
- /** Per-locale version history for this collection's entries (spec
33
- * 2026-09-06 §7): every save becomes an `entry_versions` snapshot and
34
- * publish moves a pointer, as on pages. Off by default — a People list
35
- * does not want history; articles do. */
36
- versions?: boolean;
37
32
  /** phantom: the entry value shape, used by f.ref target inference */
38
33
  readonly __entry?: NamespaceValues<F>;
39
34
  };
@@ -52,5 +47,4 @@ export declare function collection<F extends BuilderMap, const N extends string
52
47
  admin?: {
53
48
  listColumns?: string[];
54
49
  };
55
- versions?: boolean;
56
50
  }): CollectionSchema<F, N>;
@@ -43,7 +43,6 @@ export function collection(input) {
43
43
  ...(input.path !== undefined ? { path: input.path } : {}),
44
44
  ...(input.tree !== undefined ? { tree: input.tree } : {}),
45
45
  ...(input.order !== undefined ? { order: input.order } : {}),
46
- ...(input.versions ? { versions: true } : {}),
47
46
  admin: input.admin,
48
47
  };
49
48
  }
@@ -13,6 +13,8 @@ export type EntryLocale = {
13
13
  draftVersionId: string | null;
14
14
  publishedVersionId: string | null;
15
15
  updatedAt: number;
16
+ /** The editor's email behind the last write that touched this row; null for the seed and scripts (spec 2026-09-13 history §2). */
17
+ updatedBy: string | null;
16
18
  };
17
19
  export type EntryRecord = {
18
20
  id: string;
@@ -32,7 +34,8 @@ export type EntryVersion = {
32
34
  locale: string;
33
35
  /** The MERGED fields (shared + localized + slug) at that save. */
34
36
  fields: Record<string, unknown>;
35
- createdAt: number;
37
+ /** When the content last changed: the unpublished draft row is rewritten in place (spec 2026-09-13 history §1). */
38
+ savedAt: number;
36
39
  };
37
40
  export type EntryUsage = {
38
41
  sourceKind: string;
@@ -87,7 +90,7 @@ export interface EntryStore {
87
90
  /** Creates the node and ONE locale row from the merged `fields`: shared
88
91
  * keys go to the node, .localized() keys and `slug` to the row. The
89
92
  * parent (if any) must exist in that locale. */
90
- create(collection: string, fields: Record<string, unknown>, options: CreateEntryOptions): Promise<EntryRecord>;
93
+ create(collection: string, fields: Record<string, unknown>, options: CreateEntryOptions, by?: string): Promise<EntryRecord>;
91
94
  get(collection: string, id: string): Promise<EntryRecord | null>;
92
95
  list(collection: string, options?: ListOptions): Promise<EntryRecord[]>;
93
96
  /** Batched read for the resolver: the MERGED fields in `locale`, keyed
@@ -101,17 +104,29 @@ export interface EntryStore {
101
104
  }): Promise<Record<string, Record<string, unknown>>>;
102
105
  /** Replaces the merged fields for `locale`: shared keys on the node
103
106
  * (every locale sees them), localized keys and the slug on the row. */
104
- update(collection: string, id: string, locale: string, fields: Record<string, unknown>): Promise<EntryRecord>;
105
- setStatus(collection: string, id: string, locale: string, status: "draft" | "published"): Promise<EntryRecord>;
107
+ update(collection: string, id: string, locale: string, fields: Record<string, unknown>, by?: string): Promise<EntryRecord>;
108
+ setStatus(collection: string, id: string, locale: string, status: "draft" | "published", by?: string): Promise<EntryRecord>;
109
+ /** The live row goes back to what is published: the published snapshot's fields
110
+ * (shared and localized, split as on save — the slug is never versioned) written over
111
+ * the row, the draft pointer set to the published one, the abandoned draft row deleted
112
+ * (spec 2026-09-13 history §1). Refused when the locale has no published version (a
113
+ * non-versioned collection never has one). */
114
+ discardDraft(collection: string, id: string, locale: string, by?: string): Promise<EntryRecord>;
106
115
  /** Copies `from`'s localized fields and slug as the new row (shared
107
116
  * fields need no copy); refused when the entry has the locale or its
108
117
  * parent lacks it. */
109
- addLocale(collection: string, id: string, locale: string, options: AddEntryLocaleOptions): Promise<EntryRecord>;
118
+ addLocale(collection: string, id: string, locale: string, options: AddEntryLocaleOptions, by?: string): Promise<EntryRecord>;
110
119
  /** Deletes the row, its versions and its paths rows; refused for the
111
120
  * last locale and while a child has it. */
112
121
  removeLocale(collection: string, id: string, locale: string): Promise<EntryRecord>;
113
- /** Newest first; empty for a collection without `versions`. */
122
+ /** Newest first (every collection is versioned, spec 2026-09-14 §1). */
114
123
  listVersions(collection: string, id: string, locale: string): Promise<EntryVersion[]>;
124
+ /** The snapshot's shared and localized fields become the draft (the slug is never
125
+ * versioned) through the draft rule; the live version restores as a discard. Refused
126
+ * when the version is not this entry's in this locale (spec 2026-09-13 history §6). */
127
+ restore(collection: string, id: string, locale: string, versionId: string, by?: string): Promise<EntryRecord>;
128
+ /** One stored version by id — callers check `entryId` and `locale`. */
129
+ getVersion(collection: string, versionId: string): Promise<EntryVersion | null>;
115
130
  /** Blocked while anything references the entry (the safe-delete guard),
116
131
  * and while it still has child entries. Removes every locale. */
117
132
  delete(collection: string, id: string): Promise<void>;
@@ -144,6 +159,7 @@ export declare const isSharedSchema: (schema: CollectionSchema<any>) => boolean;
144
159
  export declare const DEFAULT_ENTRY_LOCALES: LocaleSet;
145
160
  export declare const entryErrors: {
146
161
  notFound: (collection: string, id: string) => Error;
162
+ nothingPublished: (collection: string, id: string, locale: string) => Error;
147
163
  hasChildren: (collection: string, id: string) => Error;
148
164
  cycle: () => Error;
149
165
  foreignParent: () => Error;
@@ -162,6 +178,7 @@ export declare const entryErrors: {
162
178
  owned: (name: string, verb: string) => Error;
163
179
  noSlug: (name: string) => Error;
164
180
  everyLocale: (name: string) => Error;
181
+ foreignVersion: (collection: string, id: string, locale: string, versionId: string) => Error;
165
182
  };
166
183
  /** The keys a locale row owns: every field with .localized(), except
167
184
  * `slug`, which is the row's own column (a segment is an address, per
@@ -204,7 +221,7 @@ export declare class MemoryEntryStore implements EntryStore {
204
221
  private refs;
205
222
  private entries;
206
223
  private versions;
207
- /** Insertion order per version — `createdAt` alone can tie within a millisecond. */
224
+ /** Insertion order per version — `savedAt` alone can tie within a millisecond. */
208
225
  private versionSeq;
209
226
  private seq;
210
227
  private order;
@@ -221,9 +238,14 @@ export declare class MemoryEntryStore implements EntryStore {
221
238
  private subtree;
222
239
  /** An edit bumps the node and the edited row; `move` and `reorder` never call this. */
223
240
  private bump;
224
- /** A versioned collection records every save as a merged snapshot and
225
- * points the locale's draft at it; others keep the row as the live content. */
241
+ /** The draft rule (spec 2026-09-13 history §1) for every collection (spec 2026-09-14 §1):
242
+ * the unpublished draft row is rewritten in place and a new one starts only when the
243
+ * draft IS the published one. */
226
244
  private recordVersion;
245
+ /** A published row from before every collection was versioned has no snapshot; give it
246
+ * one from what is live BEFORE a write changes the fields, so the site keeps serving it
247
+ * and the write lands in a new draft row (spec 2026-09-14 §1). */
248
+ private ensureLiveSnapshot;
227
249
  private dropVersions;
228
250
  /** Every field set the site can render for this entry: each present
229
251
  * locale's live view, plus a published snapshot the draft has moved
@@ -240,17 +262,20 @@ export declare class MemoryEntryStore implements EntryStore {
240
262
  private assertParent;
241
263
  /** The parent rule: a locale can only be added where the parent has it. */
242
264
  private assertParentHasLocale;
243
- create(collection: string, fields: Record<string, unknown>, options: CreateEntryOptions): Promise<EntryRecord>;
265
+ create(collection: string, fields: Record<string, unknown>, options: CreateEntryOptions, by?: string): Promise<EntryRecord>;
244
266
  get(collection: string, id: string): Promise<EntryRecord | null>;
245
267
  list(collection: string, options?: ListOptions): Promise<EntryRecord[]>;
246
268
  getMany(collection: string, ids: string[], locale: string, options?: {
247
269
  status?: "published";
248
270
  }): Promise<Record<string, Record<string, unknown>>>;
249
- update(collection: string, id: string, locale: string, fields: Record<string, unknown>): Promise<EntryRecord>;
250
- setStatus(collection: string, id: string, locale: string, status: "draft" | "published"): Promise<EntryRecord>;
251
- addLocale(collection: string, id: string, locale: string, options: AddEntryLocaleOptions): Promise<EntryRecord>;
271
+ update(collection: string, id: string, locale: string, fields: Record<string, unknown>, by?: string): Promise<EntryRecord>;
272
+ setStatus(collection: string, id: string, locale: string, status: "draft" | "published", by?: string): Promise<EntryRecord>;
273
+ discardDraft(collection: string, id: string, locale: string, by?: string): Promise<EntryRecord>;
274
+ addLocale(collection: string, id: string, locale: string, options: AddEntryLocaleOptions, by?: string): Promise<EntryRecord>;
252
275
  removeLocale(collection: string, id: string, locale: string): Promise<EntryRecord>;
253
276
  listVersions(collection: string, id: string, locale: string): Promise<EntryVersion[]>;
277
+ restore(collection: string, id: string, locale: string, versionId: string, by?: string): Promise<EntryRecord>;
278
+ getVersion(collection: string, versionId: string): Promise<EntryVersion | null>;
254
279
  move(collection: string, id: string, to: {
255
280
  parentId: string | null;
256
281
  index?: number;
@@ -3,7 +3,7 @@
3
3
  // SHARED fields) with one row per locale it exists in (spec 2026-09-06
4
4
  // §7): the locale row owns its slug, its publish state and the
5
5
  // .localized() fields, and — for a collection registered with
6
- // `versions: true` — a history of merged snapshots. Nothing falls back
6
+ // every collection — a history of merged snapshots, one row per publish. Nothing falls back
7
7
  // to another locale. Writes always flow through this API (never raw
8
8
  // table access), which is what lets the refs index stay correct: every
9
9
  // save rewrites the entry's outgoing edges over EVERY locale's live
@@ -47,6 +47,7 @@ const uid = (prefix) => `${prefix}-${++counter}-${Date.now().toString(36)}`;
47
47
  export const DEFAULT_ENTRY_LOCALES = { default: "en", supported: ["en"] };
48
48
  export const entryErrors = {
49
49
  notFound: (collection, id) => new Error(`smoodly: no entry "${id}" in "${collection}".`),
50
+ nothingPublished: (collection, id, locale) => new Error(`smoodly: entry "${id}" in "${collection}" has nothing published in locale "${locale}" — there is no version to restore.`),
50
51
  hasChildren: (collection, id) => new Error(`smoodly: "${collection}/${id}" has child entries — move or delete them first.`),
51
52
  cycle: () => new Error("smoodly: an entry cannot be moved under itself."),
52
53
  foreignParent: () => new Error("smoodly: the parent of an entry must be in the same collection."),
@@ -66,6 +67,7 @@ export const entryErrors = {
66
67
  owned: (name, verb) => new Error(`smoodly: shared item "${name}" is owned by code — ${verb}.`),
67
68
  noSlug: (name) => new Error(`smoodly: shared item "${name}" has no URL — it cannot take a slug.`),
68
69
  everyLocale: (name) => new Error(`smoodly: shared item "${name}" exists in every locale — it cannot be removed from one.`),
70
+ foreignVersion: (collection, id, locale, versionId) => new Error(`smoodly: "${versionId}" is not a version of entry "${id}" in "${collection}", locale "${locale}".`),
69
71
  };
70
72
  /** The keys a locale row owns: every field with .localized(), except
71
73
  * `slug`, which is the row's own column (a segment is an address, per
@@ -158,7 +160,7 @@ export class MemoryEntryStore {
158
160
  this.refs = refs;
159
161
  this.entries = new Map();
160
162
  this.versions = new Map();
161
- /** Insertion order per version — `createdAt` alone can tie within a millisecond. */
163
+ /** Insertion order per version — `savedAt` alone can tie within a millisecond. */
162
164
  this.versionSeq = new Map();
163
165
  this.seq = 0;
164
166
  this.order = new Map();
@@ -197,23 +199,44 @@ export class MemoryEntryStore {
197
199
  return out;
198
200
  }
199
201
  /** An edit bumps the node and the edited row; `move` and `reorder` never call this. */
200
- bump(entry, row) {
202
+ bump(entry, row, by) {
201
203
  const now = Date.now();
202
204
  entry.updatedAt = now > entry.updatedAt ? now : entry.updatedAt + 1;
203
- if (row)
205
+ if (row) {
204
206
  row.updatedAt = entry.updatedAt;
207
+ row.updatedBy = by ?? null;
208
+ }
205
209
  }
206
- /** A versioned collection records every save as a merged snapshot and
207
- * points the locale's draft at it; others keep the row as the live content. */
210
+ /** The draft rule (spec 2026-09-13 history §1) for every collection (spec 2026-09-14 §1):
211
+ * the unpublished draft row is rewritten in place and a new one starts only when the
212
+ * draft IS the published one. */
208
213
  recordVersion(entry, locale) {
209
- if (!this.schema(entry.collection).versions)
214
+ const row = entry.locales[locale];
215
+ const fields = entryFieldsIn(entry, locale);
216
+ const current = row.draftVersionId ? this.versions.get(row.draftVersionId) : undefined;
217
+ if (current && row.draftVersionId !== row.publishedVersionId) {
218
+ current.fields = fields;
219
+ current.savedAt = Date.now();
220
+ this.versionSeq.set(current.id, ++this.seq); // newest again
210
221
  return;
211
- const version = {
212
- id: uid("version"), entryId: entry.id, locale, fields: entryFieldsIn(entry, locale), createdAt: Date.now(),
213
- };
222
+ }
223
+ const version = { id: uid("version"), entryId: entry.id, locale, fields, savedAt: Date.now() };
214
224
  this.versions.set(version.id, version);
215
225
  this.versionSeq.set(version.id, ++this.seq);
216
- entry.locales[locale].draftVersionId = version.id;
226
+ row.draftVersionId = version.id;
227
+ }
228
+ /** A published row from before every collection was versioned has no snapshot; give it
229
+ * one from what is live BEFORE a write changes the fields, so the site keeps serving it
230
+ * and the write lands in a new draft row (spec 2026-09-14 §1). */
231
+ ensureLiveSnapshot(entry, locale) {
232
+ const row = entry.locales[locale];
233
+ if (row.status !== "published" || row.publishedVersionId !== null)
234
+ return;
235
+ const version = { id: uid("version"), entryId: entry.id, locale, fields: entryFieldsIn(entry, locale), savedAt: Date.now() };
236
+ this.versions.set(version.id, version);
237
+ this.versionSeq.set(version.id, ++this.seq);
238
+ row.draftVersionId = version.id;
239
+ row.publishedVersionId = version.id;
217
240
  }
218
241
  dropVersions(entryId, locale) {
219
242
  for (const [id, v] of this.versions) {
@@ -277,7 +300,7 @@ export class MemoryEntryStore {
277
300
  if (parent && !parent.locales[locale])
278
301
  throw entryErrors.parentNotInLocale(locale);
279
302
  }
280
- async create(collection, fields, options) {
303
+ async create(collection, fields, options, by) {
281
304
  const schema = this.schema(collection);
282
305
  assertSupportedLocale(options.locale, this.locales);
283
306
  const parentId = options.parentId ?? null;
@@ -299,7 +322,9 @@ export class MemoryEntryStore {
299
322
  id: isSharedSchema(schema) ? await sharedEntryId(DEFAULT_SPACE_ID, collection) : uid("entry"),
300
323
  collection, parentId, fields: shared,
301
324
  locales: {
302
- [options.locale]: { slug, status: "draft", fields: localized, draftVersionId: null, publishedVersionId: null, updatedAt: now },
325
+ [options.locale]: {
326
+ slug, status: "draft", fields: localized, draftVersionId: null, publishedVersionId: null, updatedAt: now, updatedBy: by ?? null,
327
+ },
303
328
  },
304
329
  sort: null, createdAt: now, updatedAt: now,
305
330
  };
@@ -362,10 +387,11 @@ export class MemoryEntryStore {
362
387
  }
363
388
  return out;
364
389
  }
365
- async update(collection, id, locale, fields) {
390
+ async update(collection, id, locale, fields, by) {
366
391
  const schema = this.schema(collection);
367
392
  const entry = this.must(collection, id);
368
393
  const row = entryLocaleRow(entry, locale);
394
+ this.ensureLiveSnapshot(entry, locale);
369
395
  const { shared, localized, slug } = splitFields(fields, schema);
370
396
  if (isSharedSchema(schema) && slug !== null)
371
397
  throw entryErrors.noSlug(collection);
@@ -383,27 +409,46 @@ export class MemoryEntryStore {
383
409
  throw e;
384
410
  }
385
411
  this.recordVersion(entry, locale);
386
- this.bump(entry, row);
412
+ this.bump(entry, row, by);
387
413
  this.writeRefs(entry);
388
414
  return this.snapshot(entry);
389
415
  }
390
- async setStatus(collection, id, locale, status) {
416
+ async setStatus(collection, id, locale, status, by) {
391
417
  const entry = this.must(collection, id);
392
418
  const row = entryLocaleRow(entry, locale);
393
419
  if (status === "published") {
394
- // Publish points at the current draft; a versioned row always has one
395
- // (create and update record it), but never trust that from here.
396
- if (this.schema(collection).versions && !row.draftVersionId)
420
+ // Publish points at the current draft; a row always has one after its first write, but never trust that from here.
421
+ if (!row.draftVersionId)
397
422
  this.recordVersion(entry, locale);
398
423
  row.publishedVersionId = row.draftVersionId;
399
424
  }
400
425
  // Unpublish keeps the pointer for a later republish (spec §2).
401
426
  row.status = status;
402
- this.bump(entry, row);
427
+ this.bump(entry, row, by);
403
428
  this.writeRefs(entry); // the published snapshot just became, or stopped being, a live set
404
429
  return this.snapshot(entry);
405
430
  }
406
- async addLocale(collection, id, locale, options) {
431
+ async discardDraft(collection, id, locale, by) {
432
+ const schema = this.schema(collection);
433
+ const entry = this.must(collection, id);
434
+ const row = entryLocaleRow(entry, locale);
435
+ const snapshot = row.publishedVersionId ? this.versions.get(row.publishedVersionId) : undefined;
436
+ if (!snapshot)
437
+ throw entryErrors.nothingPublished(collection, id, locale);
438
+ const { shared, localized } = splitFields(snapshot.fields, schema);
439
+ entry.fields = shared;
440
+ row.fields = localized;
441
+ const abandoned = row.draftVersionId;
442
+ row.draftVersionId = row.publishedVersionId;
443
+ if (abandoned && abandoned !== row.publishedVersionId) {
444
+ this.versions.delete(abandoned);
445
+ this.versionSeq.delete(abandoned);
446
+ }
447
+ this.bump(entry, row, by);
448
+ this.writeRefs(entry);
449
+ return this.snapshot(entry);
450
+ }
451
+ async addLocale(collection, id, locale, options, by) {
407
452
  assertSupportedLocale(locale, this.locales);
408
453
  const entry = this.must(collection, id);
409
454
  // The source is read FIRST: "copy from a locale that isn't there" is
@@ -417,7 +462,7 @@ export class MemoryEntryStore {
417
462
  const now = Date.now();
418
463
  entry.locales[locale] = {
419
464
  slug: source.slug, status: "draft", fields: structuredClone(source.fields),
420
- draftVersionId: null, publishedVersionId: null, updatedAt: now,
465
+ draftVersionId: null, publishedVersionId: null, updatedAt: now, updatedBy: by ?? null,
421
466
  };
422
467
  this.recordVersion(entry, locale);
423
468
  // Only this node's rows change: a child cannot have a locale its parent lacked.
@@ -462,9 +507,31 @@ export class MemoryEntryStore {
462
507
  this.must(collection, id);
463
508
  return [...this.versions.values()]
464
509
  .filter((v) => v.entryId === id && v.locale === locale)
465
- .sort((a, b) => b.createdAt - a.createdAt || this.versionSeq.get(b.id) - this.versionSeq.get(a.id))
510
+ .sort((a, b) => b.savedAt - a.savedAt || this.versionSeq.get(b.id) - this.versionSeq.get(a.id))
466
511
  .map((v) => structuredClone(v));
467
512
  }
513
+ async restore(collection, id, locale, versionId, by) {
514
+ const schema = this.schema(collection);
515
+ const entry = this.must(collection, id);
516
+ const row = entryLocaleRow(entry, locale);
517
+ const version = this.versions.get(versionId);
518
+ if (!version || version.entryId !== id || version.locale !== locale)
519
+ throw entryErrors.foreignVersion(collection, id, locale, versionId);
520
+ if (versionId === row.publishedVersionId)
521
+ return this.discardDraft(collection, id, locale, by);
522
+ const { shared, localized } = splitFields(version.fields, schema);
523
+ entry.fields = shared;
524
+ row.fields = localized; // the slug is the row's own and never versioned
525
+ this.recordVersion(entry, locale);
526
+ this.bump(entry, row, by);
527
+ this.writeRefs(entry);
528
+ return this.snapshot(entry);
529
+ }
530
+ async getVersion(collection, versionId) {
531
+ this.schema(collection);
532
+ const v = this.versions.get(versionId);
533
+ return v ? structuredClone(v) : null;
534
+ }
468
535
  async move(collection, id, to) {
469
536
  if (isSharedSchema(this.schema(collection)))
470
537
  throw entryErrors.owned(collection, "it has no tree position");
@@ -17,12 +17,12 @@ export function createPageRenderer(options) {
17
17
  const entryPages = new Map(options.pages.filter((p) => p.schema.kind === "entryPage" && p.schema.collection).map((p) => [p.schema.collection, p]));
18
18
  /** locate (uncached: the id it returns IS the cache tag) then load —
19
19
  * cached per (locale, path) outside draft mode. */
20
- async function load(locale, path, draft) {
20
+ async function load(locale, path, draft, version) {
21
21
  const target = await site.locate(locale, path);
22
22
  if (!target)
23
23
  return null;
24
24
  if (draft)
25
- return site.load(target, locale, path, { draft: true });
25
+ return site.load(target, locale, path, { draft: true, ...(version !== undefined ? { version } : {}) });
26
26
  return unstable_cache(() => site.load(target, locale, path), ["smoodly-site", locale, path], {
27
27
  tags: [target.kind === "page" ? pageTag(target.id) : entryTag(target.id)],
28
28
  })();
@@ -58,10 +58,14 @@ export function createPageRenderer(options) {
58
58
  const locale = opts?.locale ?? site.locales.default;
59
59
  const path = pathFor(locale);
60
60
  const { isEnabled: draft } = await draftMode();
61
- const hit = await load(locale, path, draft);
61
+ const params = await opts?.searchParams;
62
+ // Both parameters exist only inside draft mode: the public site never sees an editor bridge or an old version.
63
+ const editor = draft && params?.["smoodly-editor"] === "1";
64
+ const versionParam = params?.["smoodly-version"];
65
+ const version = draft && typeof versionParam === "string" && versionParam !== "" ? versionParam : undefined;
66
+ const hit = await load(locale, path, draft, version);
62
67
  if (!hit || !accept(hit))
63
68
  notFound();
64
- const editor = draft && (await opts?.searchParams)?.["smoodly-editor"] === "1";
65
69
  return (_jsxs(_Fragment, { children: [editor ? _jsx(EditorBridge, {}) : null, render(hit, editor, locale)] }));
66
70
  }
67
71
  async function metadata(pathFor, opts, accept) {