smoodly 0.0.5 → 0.0.7

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 (44) hide show
  1. package/README.md +231 -73
  2. package/dist/admin/client-ops.js +2 -2
  3. package/dist/admin/editor/EditorView.js +167 -24
  4. package/dist/admin/editor/PageSettings.d.ts +8 -4
  5. package/dist/admin/editor/PageSettings.js +25 -25
  6. package/dist/admin/fixed-nodes.d.ts +16 -10
  7. package/dist/admin/fixed-nodes.js +55 -41
  8. package/dist/admin/ops-impl.js +134 -35
  9. package/dist/admin/ops.d.ts +51 -11
  10. package/dist/admin/shell/AdminApp.js +3 -32
  11. package/dist/admin/shell/EntriesList.d.ts +7 -0
  12. package/dist/admin/shell/EntriesList.js +95 -0
  13. package/dist/admin/shell/EntryForm.js +167 -49
  14. package/dist/admin/shell/PagesList.js +86 -26
  15. package/dist/admin/shell/entries-list.d.ts +21 -0
  16. package/dist/admin/shell/entries-list.js +36 -0
  17. package/dist/admin/shell/pages-tree.d.ts +30 -12
  18. package/dist/admin/shell/pages-tree.js +48 -14
  19. package/dist/admin/ui/LocaleSwitcher.d.ts +11 -0
  20. package/dist/admin/ui/LocaleSwitcher.js +15 -0
  21. package/dist/collections.d.ts +6 -0
  22. package/dist/collections.js +16 -0
  23. package/dist/config.js +23 -3
  24. package/dist/entry-store.d.ts +132 -54
  25. package/dist/entry-store.js +294 -86
  26. package/dist/index.d.ts +6 -6
  27. package/dist/index.js +4 -4
  28. package/dist/localize.d.ts +0 -11
  29. package/dist/localize.js +11 -29
  30. package/dist/paths.d.ts +46 -27
  31. package/dist/paths.js +62 -29
  32. package/dist/revalidate.js +2 -1
  33. package/dist/site.d.ts +6 -2
  34. package/dist/site.js +65 -39
  35. package/dist/sql-space.d.ts +1 -1
  36. package/dist/sql-space.js +13 -5
  37. package/dist/sql.js +96 -53
  38. package/dist/store.d.ts +66 -32
  39. package/dist/store.js +205 -90
  40. package/dist/supabase-entry-store.d.ts +29 -8
  41. package/dist/supabase-entry-store.js +341 -177
  42. package/dist/supabase-store.d.ts +21 -9
  43. package/dist/supabase-store.js +202 -111
  44. package/package.json +1 -1
@@ -8,7 +8,10 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
8
8
  // the geometry the bridge reports map 1:1 onto the overlay layer above
9
9
  // it. Every gesture mutates client tree state through Task 4's pure ops;
10
10
  // a debounced saveDraft persists, and the bridge refreshes the iframe on
11
- // the save ack (refresh-on-change preview, DESIGN.md §3).
11
+ // the save ack (refresh-on-change preview, DESIGN.md §3). Every load,
12
+ // edit, autosave and publish targets ONE locale — the page's own tree in
13
+ // that locale (spec 2026-09-06 §6); switching flushes the pending save
14
+ // first.
12
15
  //
13
16
  // Chrome follows the design system's visual-editor screen: top bar, toned
14
17
  // canvas around a white page, 300px inspector holding fields then outline.
@@ -21,19 +24,52 @@ import { tabsFor } from "../forms/tabs.js";
21
24
  import { allowedSections, canInsert, fillLockedZones, findNode, insertNode, moveNode, newNodeId, nodeFromSample, patchNode, removeNode, zoneOf, } from "../tree-ops.js";
22
25
  import { useDraftAutosave } from "./autosave.js";
23
26
  import { PageSettings } from "./PageSettings.js";
24
- import { isFixedNode } from "../shell/pages-tree.js";
25
- import { Banner as BannerBox, Breadcrumb, Button, Divider, IconButton, LinkButton, SaveIndicator, SectionLabel, Segmented, Tabs, TopBar, cx, } from "../ui/primitives.js";
27
+ import { isFixedNode, localeStatusOf, sourceLocaleOf } from "../shell/pages-tree.js";
28
+ import { entryTitle } from "../shell/entries-list.js";
29
+ import { LocaleSwitcher } from "../ui/LocaleSwitcher.js";
30
+ import { Banner as BannerBox, Breadcrumb, Button, Divider, IconButton, LinkButton, SaveIndicator, SectionLabel, StatusBadge, Tabs, TopBar, cx, } from "../ui/primitives.js";
26
31
  export const withEditorParam = (url) => (url.includes("?") ? `${url}&smoodly-editor=1` : `${url}?smoodly-editor=1`);
27
32
  const GEOMETRY_TIMEOUT_MS = 4000;
33
+ /** The locale to edit: from the URL (the Pages list links here with
34
+ * ?locale=), else the default. */
35
+ function localeFromUrl(registry) {
36
+ try {
37
+ const l = new URLSearchParams(window.location.search).get("locale");
38
+ if (l && registry.locales.supported.includes(l))
39
+ return l;
40
+ }
41
+ catch {
42
+ // no window — the default is fine
43
+ }
44
+ return registry.locales.default;
45
+ }
46
+ /** Point the address bar at a locale, keeping the rest of the query
47
+ * string — a bare `?locale=…` would drop every other param. */
48
+ function writeLocaleToUrl(next) {
49
+ try {
50
+ const u = new URL(window.location.href);
51
+ u.searchParams.set("locale", next);
52
+ window.history.replaceState(null, "", u);
53
+ }
54
+ catch {
55
+ // no window (or no history) — the URL is cosmetic here
56
+ }
57
+ }
28
58
  export function EditorView({ ops, registry, id }) {
29
59
  const [record, setRecord] = useState(null);
30
60
  const [tree, setTree] = useState(null);
31
61
  const [loadError, setLoadError] = useState(null);
32
62
  const [ready, setReady] = useState(false);
33
63
  const [folder, setFolder] = useState(false);
34
- const [locale, setLocale] = useState(registry.locales.default);
64
+ // The locale being edited. Every load, edit, autosave and publish
65
+ // targets this ONE locale (spec 2026-09-06 §6).
66
+ const [locale, setLocale] = useState(() => localeFromUrl(registry));
67
+ const [reloadKey, setReloadKey] = useState(0);
68
+ const [adding, setAdding] = useState(false);
35
69
  const [paths, setPaths] = useState({});
36
70
  const [urls, setUrls] = useState({});
71
+ // The locales the parent exists in — "Add locale" offers only these.
72
+ const [parentLocales, setParentLocales] = useState([]);
37
73
  const [rects, setRects] = useState([]);
38
74
  const [emptyZones, setEmptyZones] = useState([]);
39
75
  const [docH, setDocH] = useState(900);
@@ -51,19 +87,21 @@ export function EditorView({ ops, registry, id }) {
51
87
  const treeRef = useRef(null);
52
88
  const registryRef = useRef(registry);
53
89
  registryRef.current = registry;
90
+ // The tree in memory belongs to the locale it was loaded for; a switch flushes first (switchLocale), so the ref and the tree always agree.
91
+ const treeLocaleRef = useRef(locale);
54
92
  // Every edit marks the draft dirty; a debounced saveDraft persists, with
55
93
  // retry on failure, and the bridge refreshes the iframe on the save ack.
56
94
  const { saveState, mark, flush } = useDraftAutosave({
57
95
  read: () => treeRef.current,
58
- persist: (t) => ops.pages.saveDraft(id, t),
96
+ persist: (t) => ops.pages.saveDraft(id, treeLocaleRef.current, t),
59
97
  onSaved: () => postToCanvas({ source: "smoodly", kind: "refresh" }),
60
98
  });
61
99
  // The URL that serves this page, per locale, from the store's paths
62
100
  // index — the label is the CMS path, the canvas and "View live" take
63
101
  // the site URL (href applied server-side by pages.get).
64
- const path = paths[locale] ?? paths[registry.locales.default] ?? "/";
102
+ const path = paths[locale] ?? "/";
65
103
  const label = path;
66
- const siteUrl = urls[locale] ?? urls[registry.locales.default] ?? path;
104
+ const siteUrl = urls[locale] ?? path;
67
105
  const iframeSrc = ready ? withEditorParam(siteUrl) : null;
68
106
  // ── load: draft mode FIRST, then the record; only then point the iframe
69
107
  // at the site route (an iframe loaded before the cookie exists would
@@ -71,6 +109,13 @@ export function EditorView({ ops, registry, id }) {
71
109
  useEffect(() => {
72
110
  let cancelled = false;
73
111
  (async () => {
112
+ // A new locale (or a reload) is a new canvas: nothing from the previous one may survive.
113
+ setReady(false);
114
+ setTree(null);
115
+ treeRef.current = null;
116
+ setSelectedId(null);
117
+ setHoverId(null);
118
+ setFolder(false);
74
119
  // ops is a server action — it can reject outright (offline, 500,
75
120
  // aborted), not just resolve to a typed error. Either failure mode
76
121
  // must land on the same full-pane error, never a hung load.
@@ -92,7 +137,7 @@ export function EditorView({ ops, registry, id }) {
92
137
  }
93
138
  let result;
94
139
  try {
95
- result = await ops.pages.get(id);
140
+ result = await ops.pages.get(id, locale);
96
141
  }
97
142
  catch {
98
143
  result = { ok: false, code: "internal", message: "Couldn't reach the server." };
@@ -103,6 +148,13 @@ export function EditorView({ ops, registry, id }) {
103
148
  setLoadError(result.message);
104
149
  return;
105
150
  }
151
+ setRecord(result.data.record);
152
+ setPaths(result.data.paths);
153
+ setUrls(result.data.urls);
154
+ setParentLocales(result.data.parentLocales);
155
+ // Absent in this locale: the "Add <locale>" pane, derived from the record.
156
+ if (!result.data.record.locales[locale])
157
+ return;
106
158
  // A folder has no draft tree — it gets the notice pane, not a canvas.
107
159
  if (result.data.draft === null) {
108
160
  setFolder(true);
@@ -118,9 +170,7 @@ export function EditorView({ ops, registry, id }) {
118
170
  const reg = registryRef.current;
119
171
  const template = reg.pages.find((p) => p.name === result.data.record.template);
120
172
  const draft = fillLockedZones(result.data.draft, template?.zones ?? {}, reg.sections);
121
- setRecord(result.data.record);
122
- setPaths(result.data.paths);
123
- setUrls(result.data.urls);
173
+ treeLocaleRef.current = locale;
124
174
  setTree(draft);
125
175
  treeRef.current = draft;
126
176
  if (draft !== result.data.draft)
@@ -128,7 +178,7 @@ export function EditorView({ ops, registry, id }) {
128
178
  setReady(true);
129
179
  })();
130
180
  return () => { cancelled = true; };
131
- }, [ops, id]);
181
+ }, [ops, id, locale, reloadKey]);
132
182
  // A new canvas URL (a locale switch, a rename) means new geometry — the
133
183
  // previous page's rects are stale the instant the iframe starts loading
134
184
  // a different route, so clear them alongside resetting geometry.
@@ -137,17 +187,18 @@ export function EditorView({ ops, registry, id }) {
137
187
  // After a rename only the record and the URL indexes are stale — never
138
188
  // re-read the tree here: the draft in memory may be ahead of the store.
139
189
  const reload = useCallback(async () => {
140
- const result = await ops.pages.get(id).catch(() => null);
190
+ const result = await ops.pages.get(id, locale).catch(() => null);
141
191
  if (!result || !result.ok)
142
192
  return;
143
193
  setRecord(result.data.record);
144
194
  setPaths(result.data.paths);
145
195
  setUrls(result.data.urls);
146
- }, [ops, id]);
147
- const onRename = async (renameLocale, patch) => {
196
+ setParentLocales(result.data.parentLocales);
197
+ }, [ops, id, locale]);
198
+ const onRename = async (patch) => {
148
199
  let result;
149
200
  try {
150
- result = await ops.pages.rename(id, { locale: renameLocale, ...patch });
201
+ result = await ops.pages.rename(id, { locale, ...patch });
151
202
  }
152
203
  catch {
153
204
  result = { ok: false, code: "internal", message: "Couldn't reach the server." };
@@ -157,6 +208,84 @@ export function EditorView({ ops, registry, id }) {
157
208
  await reload();
158
209
  return null;
159
210
  };
211
+ /** Switch the edited locale. The pending autosave must land first, or
212
+ * it would persist THIS tree under the new locale. */
213
+ const switchLocale = async (next) => {
214
+ if (next === locale)
215
+ return;
216
+ const flushed = await flush();
217
+ if (!flushed) {
218
+ setBanner({ text: "Couldn't save your latest changes — switch cancelled." });
219
+ return;
220
+ }
221
+ writeLocaleToUrl(next);
222
+ setBanner(null);
223
+ setLocale(next);
224
+ };
225
+ /** Add a locale, copying the tree on screen (or the record's source locale when this one is absent). */
226
+ const doAddLocale = async (target) => {
227
+ if (!record || adding)
228
+ return;
229
+ const from = record.locales[locale] ? locale : sourceLocaleOf(record, registry);
230
+ if (from === locale) {
231
+ const flushed = await flush();
232
+ if (!flushed) {
233
+ setBanner({ text: "Couldn't save your latest changes — add cancelled." });
234
+ return;
235
+ }
236
+ }
237
+ setAdding(true);
238
+ let result;
239
+ try {
240
+ result = await ops.pages.addLocale(id, target, { from });
241
+ }
242
+ catch {
243
+ result = { ok: false, code: "internal", message: "Couldn't reach the server." };
244
+ }
245
+ setAdding(false);
246
+ if (!result.ok) {
247
+ setBanner({ text: result.message });
248
+ return;
249
+ }
250
+ // The record now HAS the locale; keep it, or the pane below would go
251
+ // on offering "doesn't exist yet" until the reload lands.
252
+ setRecord(result.data);
253
+ if (target === locale)
254
+ setReloadKey((k) => k + 1);
255
+ else
256
+ void switchLocale(target);
257
+ };
258
+ const doRemoveLocale = async () => {
259
+ if (!record)
260
+ return;
261
+ const remaining = Object.keys(record.locales).filter((l) => l !== locale);
262
+ if (remaining.length === 0)
263
+ return;
264
+ if (!window.confirm(`Remove this page from ${locale}? Its ${locale} content and history are deleted.`))
265
+ return;
266
+ // Let a pending save land before the row goes, so no save races the
267
+ // delete — and if it cannot land, stop: a retry mid-flight would
268
+ // recreate the very draft the removal is deleting.
269
+ const flushed = await flush();
270
+ if (!flushed) {
271
+ setBanner({ text: "Couldn't save your latest changes — removal cancelled." });
272
+ return;
273
+ }
274
+ let result;
275
+ try {
276
+ result = await ops.pages.removeLocale(id, locale);
277
+ }
278
+ catch {
279
+ result = { ok: false, code: "internal", message: "Couldn't reach the server." };
280
+ }
281
+ if (!result.ok) {
282
+ setBanner({ text: result.message });
283
+ return;
284
+ }
285
+ const next = remaining.includes(registry.locales.default) ? registry.locales.default : remaining[0];
286
+ writeLocaleToUrl(next);
287
+ setLocale(next);
288
+ };
160
289
  const template = useMemo(() => (record ? registry.pages.find((p) => p.name === record.template) ?? null : null), [record, registry]);
161
290
  const zoneOrder = useMemo(() => Object.keys(template?.zones ?? {}), [template]);
162
291
  const policyOf = useCallback((zone) => (zone ? template?.zones[zone] : undefined), [template]);
@@ -176,15 +305,20 @@ export function EditorView({ ops, registry, id }) {
176
305
  return out;
177
306
  }, [registry]);
178
307
  useEffect(() => {
308
+ let cancelled = false;
179
309
  for (const target of new Set(Object.values(refTargets))) {
180
310
  const targetMeta = registry.collections.find((c) => c.name === target);
181
311
  ops.entries.list(target).then((r) => {
182
- if (!r.ok)
312
+ // A list issued for the previous locale must not overwrite this one's.
313
+ if (cancelled || !r.ok)
183
314
  return;
184
- const options = r.data.map((e) => ({
315
+ const options = r.data
316
+ // only entries present in the locale being edited can be referenced there
317
+ .filter((e) => e.locales[locale])
318
+ .map((e) => ({
185
319
  id: e.id,
186
- title: String((targetMeta?.titleField && e.fields[targetMeta.titleField]) ?? e.id),
187
- status: e.status,
320
+ title: targetMeta ? entryTitle(e, targetMeta, registry, locale) : e.id,
321
+ status: e.locales[locale].status,
188
322
  }));
189
323
  setRefOptions((prev) => ({ ...prev, [target]: options }));
190
324
  }).catch((e) => {
@@ -193,7 +327,8 @@ export function EditorView({ ops, registry, id }) {
193
327
  console.error(e);
194
328
  });
195
329
  }
196
- }, [ops, refTargets, registry]);
330
+ return () => { cancelled = true; };
331
+ }, [ops, refTargets, registry, locale]);
197
332
  // ── canvas messaging ─────────────────────────────────────────────────
198
333
  const postToCanvas = useCallback((m) => {
199
334
  iframeRef.current?.contentWindow?.postMessage(m, "*");
@@ -302,7 +437,7 @@ export function EditorView({ ops, registry, id }) {
302
437
  }
303
438
  let result;
304
439
  try {
305
- result = await ops.pages.publish(id);
440
+ result = await ops.pages.publish(id, locale);
306
441
  }
307
442
  catch (e) {
308
443
  console.error(e);
@@ -336,6 +471,10 @@ export function EditorView({ ops, registry, id }) {
336
471
  if (folder) {
337
472
  return (_jsx("div", { style: { display: "grid", placeItems: "center", flex: 1, padding: 40, background: "var(--bg)" }, children: _jsxs("div", { style: { display: "flex", flexDirection: "column", gap: 12, maxWidth: 420 }, children: [_jsx(BannerBox, { tone: "warn", children: "This is a folder \u2014 it has a title and a place in the tree, but no page. Use \u201CAdd page here\u201D in the Pages list to give it one." }), _jsx("a", { href: "/admin/pages", children: "\u2190 Back to pages" })] }) }));
338
473
  }
474
+ if (record && !record.locales[locale]) {
475
+ const from = sourceLocaleOf(record, registry);
476
+ return (_jsx("div", { style: { display: "grid", placeItems: "center", flex: 1, padding: 40, background: "var(--bg)" }, children: _jsxs("div", { style: { display: "flex", flexDirection: "column", gap: 12, maxWidth: 420 }, children: [banner && _jsx(BannerBox, { tone: "error", onDismiss: () => setBanner(null), children: banner.text }), _jsxs(BannerBox, { tone: "warn", children: ["This page doesn't exist in ", locale, " yet. Adding it copies the ", from, " draft as the first ", locale, " version."] }), _jsxs("div", { style: { display: "flex", gap: 8 }, children: [_jsxs(Button, { onClick: () => { void doAddLocale(locale); }, disabled: adding, children: ["Add ", locale] }), _jsxs(Button, { variant: "secondary", onClick: () => { void switchLocale(from); }, children: ["Back to ", from] })] }), _jsx("a", { href: "/admin/pages", children: "\u2190 Back to pages" })] }) }));
477
+ }
339
478
  const selected = tree && selectedId ? findNode(tree, selectedId) : null;
340
479
  const selectedSection = selected ? sectionOf(selected.type) : null;
341
480
  const selZone = tree && selected ? zoneOf(tree, selected.id) : null;
@@ -382,7 +521,7 @@ export function EditorView({ ops, registry, id }) {
382
521
  const tabs = selectedSection ? tabsFor(selectedSection) : [];
383
522
  const activeTab = tabs.some((t) => t.tab === tab) ? tab : tabs[0]?.tab;
384
523
  const entries = tabs.find((t) => t.tab === activeTab)?.entries ?? [];
385
- return (_jsxs(_Fragment, { children: [_jsx(TopBar, { left: _jsx(Breadcrumb, { backHref: "/admin/pages", items: [{ label: "Pages", href: "/admin/pages" }, { label }] }), right: _jsxs(_Fragment, { children: [_jsx(SaveIndicator, { state: saveState }), ready && (_jsx(LinkButton, { href: siteUrl, target: "_blank", rel: "noreferrer", title: "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: "View live" })), _jsx(Button, { variant: published ? "ok" : "primary", onClick: () => { void doPublish(); }, children: published ? "Published ✓" : "Publish" })] }) }), _jsxs("div", { className: "sm-editor", children: [_jsxs("div", { className: "sm-canvas", children: [_jsxs("div", { className: "sm-canvas__meta", children: [_jsxs("span", { style: { display: "flex", alignItems: "center", gap: 10 }, children: [_jsx("span", { children: label }), registry.locales.supported.length > 1 ? (_jsx(Segmented, { options: registry.locales.supported, value: locale, onChange: setLocale })) : (_jsxs("span", { children: ["\u00B7 ", locale] })), registry.locales.supported.length > 1 && paths[locale] === undefined && ready && (_jsxs("span", { children: ["not in this locale \u2014 showing ", registry.locales.default] }))] }), _jsxs("span", { children: [blockCount, " BLOCK", blockCount === 1 ? "" : "S"] })] }), (banner || geometry === "missing") && (_jsxs("div", { style: { display: "flex", flexDirection: "column", gap: 8, padding: "0 20px 10px" }, children: [banner && (_jsxs(BannerBox, { tone: "error", onDismiss: () => setBanner(null), children: [_jsx("p", { children: banner.text }), banner.fields && banner.fields.length > 0 && (_jsx("ul", { className: "sm-mono", style: { margin: "6px 0 0", paddingLeft: 18, fontSize: "var(--text-sm)" }, children: banner.fields.map((f) => _jsxs("li", { children: [f.field, ": ", f.message] }, f.field)) }))] })), 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, 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-mono", style: {
524
+ return (_jsxs(_Fragment, { children: [_jsx(TopBar, { left: _jsx(Breadcrumb, { backHref: "/admin/pages", items: [{ label: "Pages", href: "/admin/pages" }, { label }] }), right: _jsxs(_Fragment, { children: [_jsx(SaveIndicator, { state: saveState }), ready && (_jsx(LinkButton, { href: siteUrl, target: "_blank", rel: "noreferrer", title: "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: "View live" })), record?.locales[locale] && (_jsx(StatusBadge, { status: localeStatusOf(record.locales[locale]) })), _jsx(Button, { variant: published ? "ok" : "primary", onClick: () => { void doPublish(); }, children: published ? "Published ✓" : "Publish" })] }) }), _jsxs("div", { className: "sm-editor", children: [_jsxs("div", { className: "sm-canvas", children: [_jsxs("div", { className: "sm-canvas__meta", children: [_jsxs("span", { style: { display: "flex", alignItems: "center", gap: 10 }, children: [_jsx("span", { children: label }), 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); } })) : (_jsxs("span", { children: ["\u00B7 ", locale] }))] }), _jsxs("span", { children: [blockCount, " BLOCK", blockCount === 1 ? "" : "S"] })] }), (banner || geometry === "missing") && (_jsxs("div", { style: { display: "flex", flexDirection: "column", gap: 8, padding: "0 20px 10px" }, children: [banner && (_jsxs(BannerBox, { tone: "error", onDismiss: () => setBanner(null), children: [_jsx("p", { children: banner.text }), banner.fields && banner.fields.length > 0 && (_jsx("ul", { className: "sm-mono", style: { margin: "6px 0 0", paddingLeft: 18, fontSize: "var(--text-sm)" }, children: banner.fields.map((f) => _jsxs("li", { children: [f.field, ": ", f.message] }, f.field)) }))] })), 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, 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-mono", style: {
386
525
  height: 240, display: "grid", placeItems: "center",
387
526
  color: "var(--gray-500)", fontSize: "var(--text-xs)", letterSpacing: ".04em",
388
527
  }, children: "LOADING CANVAS\u2026" })), _jsxs("div", { style: { position: "absolute", inset: 0, pointerEvents: "none" }, children: [emptyZones.map((z) => (_jsxs("div", { className: "sm-dashed sm-mono", style: {
@@ -411,7 +550,11 @@ export function EditorView({ ops, registry, id }) {
411
550
  display: "flex", flexDirection: "column", alignItems: "center", gap: 6,
412
551
  pointerEvents: "auto",
413
552
  }, children: [_jsx("button", { className: "sm-plus", "aria-label": `Add to ${p.zone}`, onMouseEnter: () => setHoverPlus(key), onMouseLeave: () => setHoverPlus((k) => (k === key ? null : k)), onClick: (e) => setPicker({ zone: p.zone, index: p.index, x: e.clientX - 150, y: e.clientY + 12 }), children: "+" }), hot && _jsxs("span", { className: "sm-tooltip", children: ["Add to ", p.zone] })] })] }, key));
414
- })] })] }) })] }), _jsxs("aside", { className: "sm-inspector", children: [selected && selectedSection ? (_jsxs(_Fragment, { children: [_jsxs("div", { className: "sm-inspector__head", children: [_jsxs("span", { style: { display: "flex", alignItems: "center", gap: 8, minWidth: 0 }, children: [_jsx("span", { className: "sm-blockglyph" }), _jsx("span", { className: "sm-title", style: { overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }, children: selectedSection.title })] }), _jsx(IconButton, { label: "Clear selection", onClick: () => setSelectedId(null), children: "\u00D7" })] }), tabs.length > 0 && (_jsx(Tabs, { tabs: tabs.map((t) => t.tab), value: activeTab, onChange: (t) => setTab(t) }))] })) : (_jsx("div", { className: "sm-inspector__head", children: _jsx("span", { className: "sm-label", children: "Nothing selected \u2014 page settings below" }) })), _jsxs("div", { className: "sm-inspector__body", children: [!selected && record && (_jsxs(_Fragment, { children: [_jsx(PageSettings, { record: record, registry: registry, paths: paths, fixed: isFixedNode(record, registry), onRename: onRename }), _jsx(Divider, { bleed: 14 })] })), selected && selectedSection && entries.map(({ key, ns, descriptor }) => (_jsx(FieldWidget, { fieldKey: key, descriptor: descriptor, value: selected[ns]?.[key], refOptions: refOptionsFor(refOptions, refTargets, selectedSection, ns, key, descriptor), onChange: (value) => doPatch(selected.id, ns, key, value) }, `${selected.id}.${ns}.${key}`))), _jsx(Divider, { bleed: 14 }), _jsx(SectionLabel, { children: "Outline" }), zoneOrder.map((zone) => {
553
+ })] })] }) })] }), _jsxs("aside", { className: "sm-inspector", children: [selected && selectedSection ? (_jsxs(_Fragment, { children: [_jsxs("div", { className: "sm-inspector__head", children: [_jsxs("span", { style: { display: "flex", alignItems: "center", gap: 8, minWidth: 0 }, children: [_jsx("span", { className: "sm-blockglyph" }), _jsx("span", { className: "sm-title", style: { overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }, children: selectedSection.title })] }), _jsx(IconButton, { label: "Clear selection", onClick: () => setSelectedId(null), children: "\u00D7" })] }), tabs.length > 0 && (_jsx(Tabs, { tabs: tabs.map((t) => t.tab), value: activeTab, onChange: (t) => setTab(t) }))] })) : (_jsx("div", { className: "sm-inspector__head", children: _jsx("span", { className: "sm-label", children: "Nothing selected \u2014 page settings below" }) })), _jsxs("div", { className: "sm-inspector__body", children: [!selected && record && (_jsxs(_Fragment, { children: [_jsx(PageSettings, { record: record, locale: locale, registry: registry, path: paths[locale], fixed: isFixedNode(record, registry), onRename: onRename, onRemoveLocale: Object.keys(record.locales).length > 1 &&
554
+ !isFixedNode(record, registry) &&
555
+ !(record.parentId === null && record.locales[registry.locales.default]?.slug === registry.homePageSlug)
556
+ ? () => { void doRemoveLocale(); }
557
+ : undefined }), _jsx(Divider, { bleed: 14 })] })), selected && selectedSection && entries.map(({ key, ns, descriptor }) => (_jsx(FieldWidget, { fieldKey: key, descriptor: descriptor, value: selected[ns]?.[key], refOptions: refOptionsFor(refOptions, refTargets, selectedSection, ns, key, descriptor), onChange: (value) => doPatch(selected.id, ns, key, value) }, `${selected.id}.${ns}.${key}`))), _jsx(Divider, { bleed: 14 }), _jsx(SectionLabel, { children: "Outline" }), zoneOrder.map((zone) => {
415
558
  const locked = policyOf(zone)?.kind === "locked";
416
559
  const nodes = nodesIn(zone);
417
560
  return (_jsxs("div", { style: { display: "flex", flexDirection: "column", gap: 4 }, children: [_jsxs(SectionLabel, { mono: true, children: [zone, locked && " · locked"] }), nodes.length === 0 && (_jsxs("div", { className: cx("sm-outline-row", "sm-outline-row--empty", zoneCanTake(zone) && "sm-outline-row--clickable"), onClick: () => {
@@ -1,13 +1,17 @@
1
1
  import type { PageRecord } from "../../store.ts";
2
2
  import type { AdminRegistry } from "../serialize.ts";
3
- export declare function PageSettings({ record, registry, paths, fixed, onRename }: {
3
+ export declare function PageSettings({ record, locale, registry, path, fixed, onRename, onRemoveLocale }: {
4
4
  record: PageRecord;
5
+ locale: string;
5
6
  registry: AdminRegistry;
6
- paths: Record<string, string>;
7
+ /** The page's path in this locale (the `paths` index). */
8
+ path: string | undefined;
7
9
  fixed: boolean;
8
10
  /** Resolves to an error message, or null when the rename landed. */
9
- onRename(locale: string, patch: {
11
+ onRename(patch: {
10
12
  title?: string;
11
13
  slug?: string;
12
14
  }): Promise<string | null>;
13
- }): import("react").JSX.Element;
15
+ /** Absent when the locale cannot be removed: a fixed node, the site root, or the page's last locale. */
16
+ onRemoveLocale?: () => void;
17
+ }): import("react").JSX.Element | null;
@@ -1,40 +1,40 @@
1
1
  "use client";
2
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
- // Page settings (spec 2026-09-05 §7): title and slug per locale, saved
4
- // through pages.rename. SEO/meta fields are OQ #17's own design pass.
5
- // Rendered in the inspector while no block is selected.
2
+ import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
3
+ // Page settings (spec 2026-09-05 §7; per locale since spec 2026-09-06
4
+ // §6): the ACTIVE locale's title and slug, saved through pages.rename,
5
+ // and "Remove from this locale". The switcher in the canvas meta row
6
+ // selects the locale, so there are no per-locale tabs here. SEO/meta fields are OQ
7
+ // #17's own design pass. Rendered in the inspector while no block is
8
+ // selected.
6
9
  import { useEffect, useState } from "react";
7
- import { Field, SectionLabel } from "../ui/primitives.js";
8
- export function PageSettings({ record, registry, paths, fixed, onRename }) {
9
- const { default: def, supported } = registry.locales;
10
- const stored = (locale, key) => locale === def ? record[key] : (record.i18n?.[locale]?.[key] ?? "");
11
- const [draft, setDraft] = useState({});
10
+ import { Button, Field, SectionLabel } from "../ui/primitives.js";
11
+ export function PageSettings({ record, locale, registry, path, fixed, onRename, onRemoveLocale }) {
12
+ const row = record.locales[locale];
13
+ const [draft, setDraft] = useState({ title: row?.title ?? "", slug: row?.slug ?? "" });
12
14
  const [error, setError] = useState(null);
13
- const isHome = record.parentId === null && record.slug === registry.homePageSlug;
15
+ const isHome = record.parentId === null && record.locales[registry.locales.default]?.slug === registry.homePageSlug;
14
16
  // The record changes identity on every rename; re-seed the inputs from it.
15
17
  useEffect(() => {
16
- setDraft(Object.fromEntries(supported.map((l) => [l, { title: stored(l, "title"), slug: stored(l, "slug") }])));
17
- }, [record]); // eslint-disable-line react-hooks/exhaustive-deps
18
- const commit = async (locale, key) => {
18
+ setDraft({ title: row?.title ?? "", slug: row?.slug ?? "" });
19
+ }, [record, locale]); // eslint-disable-line react-hooks/exhaustive-deps
20
+ if (!row)
21
+ return null;
22
+ const commit = async (key) => {
19
23
  setError(null);
20
- const next = draft[locale]?.[key] ?? "";
21
- if (next === stored(locale, key))
24
+ const next = draft[key];
25
+ if (next === row[key])
22
26
  return;
23
27
  if (next === "") {
24
- if (locale === def) {
25
- setError(`The default locale needs a ${key}.`);
26
- return;
27
- }
28
- return; // a blank translation falls back to the default locale
28
+ setError(`A ${key} is required — nothing falls back to another locale.`);
29
+ return;
29
30
  }
30
- setError(await onRename(locale, { [key]: next }));
31
+ setError(await onRename({ [key]: next }));
31
32
  };
32
- const set = (locale, key, value) => setDraft((d) => ({ ...d, [locale]: { ...(d[locale] ?? { title: "", slug: "" }), [key]: value } }));
33
- const onKey = (locale, key) => (e) => {
33
+ const onKey = (key) => (e) => {
34
34
  if (e.key === "Enter") {
35
35
  e.preventDefault();
36
- void commit(locale, key);
36
+ void commit(key);
37
37
  }
38
38
  };
39
- return (_jsxs("div", { style: { display: "flex", flexDirection: "column", gap: 14 }, children: [_jsx(SectionLabel, { children: "Page" }), fixed && _jsx("span", { className: "sm-hint", children: "Owned by code \u2014 change it in the registration." }), error && _jsx("span", { className: "sm-error", children: error }), supported.map((locale) => (_jsxs("div", { style: { display: "flex", flexDirection: "column", gap: 6 }, children: [supported.length > 1 && _jsx("span", { className: "sm-label", children: locale }), _jsx(Field, { label: "Title", children: _jsx("input", { className: "sm-input", disabled: fixed, value: draft[locale]?.title ?? "", placeholder: locale === def ? "" : stored(def, "title"), onChange: (e) => set(locale, "title", e.target.value), onBlur: () => void commit(locale, "title"), onKeyDown: onKey(locale, "title") }) }), _jsx(Field, { label: "Slug", hint: paths[locale] ?? "not published in this locale", children: _jsx("input", { className: "sm-input sm-input--mono", disabled: fixed || isHome, value: draft[locale]?.slug ?? "", placeholder: locale === def ? "" : stored(def, "slug"), onChange: (e) => set(locale, "slug", e.target.value), onBlur: () => void commit(locale, "slug"), onKeyDown: onKey(locale, "slug") }) })] }, locale)))] }));
39
+ return (_jsxs("div", { style: { display: "flex", flexDirection: "column", gap: 14 }, children: [_jsxs(SectionLabel, { children: ["Page \u00B7 ", locale] }), fixed && _jsx("span", { className: "sm-hint", children: "Owned by code \u2014 change it in the registration." }), error && _jsx("span", { className: "sm-error", children: error }), _jsx(Field, { label: "Title", children: _jsx("input", { className: "sm-input", disabled: fixed, value: draft.title, onChange: (e) => setDraft((d) => ({ ...d, title: e.target.value })), onBlur: () => void commit("title"), onKeyDown: onKey("title") }) }), _jsx(Field, { label: "Slug", hint: path ?? "", children: _jsx("input", { className: "sm-input sm-input--mono", disabled: fixed || isHome, value: draft.slug, onChange: (e) => setDraft((d) => ({ ...d, slug: e.target.value })), onBlur: () => void commit("slug"), onKeyDown: onKey("slug") }) }), onRemoveLocale && (_jsxs(Button, { variant: "secondary", onClick: onRemoveLocale, children: ["Remove from ", locale] }))] }));
40
40
  }
@@ -1,23 +1,29 @@
1
1
  import type { ResolvedConfig } from "../config.ts";
2
- import type { PageI18n, PageRecord, PageStore } from "../store.ts";
2
+ import type { PageRecord, PageStore } from "../store.ts";
3
3
  export type FixedNode = {
4
- /** The default-locale segment (the record's `slug`). */
5
- slug: string;
6
- /** Other locales' segments, only where they differ from the default. */
7
- i18n: PageI18n | null;
4
+ /** The node's segment per locale it exists in. A fixed page always
5
+ * carries every supported locale — a slug map that omits one falls
6
+ * back to the default locale's segment, same as a string slug. A
7
+ * collection mount's segments are the collection's path exactly:
8
+ * every supported locale for a string path, exactly its keys for a
9
+ * per-locale map. */
10
+ segments: Record<string, string>;
8
11
  title: string;
9
- /** A per-locale collection path names its locales; everything else is present everywhere. */
10
- locales: string[] | null;
11
12
  /** The fixed page registration's name; null for a plain mount (a folder). */
12
13
  template: string | null;
13
14
  /** The collection whose `path` this node is; null for a plain fixed page. */
14
15
  collection: string | null;
15
16
  };
16
17
  export declare function fixedNodeSpecs(config: ResolvedConfig): FixedNode[];
17
- /** The spec a ROOT record materializes, or null for an editor-owned node. */
18
+ /** The spec a ROOT record materializes, or null for an editor-owned node.
19
+ * Judged by the default-locale slug only — a root that exists in Finnish
20
+ * alone is never a fixed node, whatever its Finnish slug. */
18
21
  export declare function fixedNodeOf(record: {
19
22
  parentId: string | null;
20
- slug: string;
23
+ locales: Record<string, {
24
+ slug: string;
25
+ }>;
21
26
  }, config: ResolvedConfig): FixedNode | null;
22
- /** Create every fixed node that has no root record yet, then list. */
27
+ /** Create every fixed node that has no root record yet, give every fixed
28
+ * node the locales it lacks, then list. */
23
29
  export declare function ensureFixedNodes(pages: PageStore, config: ResolvedConfig): Promise<PageRecord[]>;
@@ -1,25 +1,19 @@
1
- import { perLocaleSegments } from "../paths.js";
1
+ import { fixedPageSegments, perLocaleSegments } from "../paths.js";
2
2
  export function fixedNodeSpecs(config) {
3
3
  const { default: def, supported } = config.locales;
4
4
  const perLocale = (v) => perLocaleSegments(v, supported);
5
- const i18nOf = (segments) => {
6
- const out = {};
7
- for (const [locale, segment] of Object.entries(segments)) {
8
- if (locale !== def && segment !== segments[def])
9
- out[locale] = { slug: segment };
10
- }
11
- return Object.keys(out).length > 0 ? out : null;
12
- };
13
5
  const specs = new Map();
14
6
  for (const p of config.registry.pages ?? []) {
15
7
  if (p.schema.kind !== "page" || p.schema.slug === undefined)
16
8
  continue;
17
- const segments = perLocale(p.schema.slug);
9
+ // Fixed pages exist in every supported locale (spec 2026-09-06 §3): a
10
+ // slug map that omits a locale falls back to the default locale's
11
+ // segment. One helper fills it for the boot check (config.ts), here
12
+ // and fixedPathFor (localize.ts), so the three cannot drift.
13
+ const segments = fixedPageSegments(p.schema.slug, config.locales);
18
14
  specs.set(segments[def], {
19
- slug: segments[def],
20
- i18n: i18nOf(segments),
15
+ segments,
21
16
  title: p.schema.title ?? p.schema.name,
22
- locales: null,
23
17
  template: p.schema.name,
24
18
  collection: null,
25
19
  });
@@ -35,46 +29,66 @@ export function fixedNodeSpecs(config) {
35
29
  indexPage.collection = c.name;
36
30
  continue;
37
31
  }
38
- specs.set(segments[def], {
39
- slug: segments[def],
40
- i18n: i18nOf(segments),
41
- title: c.title,
42
- locales: typeof c.path === "string" ? null : Object.keys(segments),
43
- template: null,
44
- collection: c.name,
45
- });
32
+ specs.set(segments[def], { segments, title: c.title, template: null, collection: c.name });
46
33
  }
47
34
  return [...specs.values()];
48
35
  }
49
- /** The spec a ROOT record materializes, or null for an editor-owned node. */
36
+ /** The spec a ROOT record materializes, or null for an editor-owned node.
37
+ * Judged by the default-locale slug only — a root that exists in Finnish
38
+ * alone is never a fixed node, whatever its Finnish slug. */
50
39
  export function fixedNodeOf(record, config) {
51
40
  if (record.parentId !== null)
52
41
  return null;
53
- return fixedNodeSpecs(config).find((s) => s.slug === record.slug) ?? null;
42
+ const def = config.locales.default;
43
+ const slug = record.locales[def]?.slug;
44
+ if (slug === undefined)
45
+ return null;
46
+ return fixedNodeSpecs(config).find((s) => s.segments[def] === slug) ?? null;
54
47
  }
55
- /** Create every fixed node that has no root record yet, then list. */
48
+ /** Create every fixed node that has no root record yet, give every fixed
49
+ * node the locales it lacks, then list. */
56
50
  export async function ensureFixedNodes(pages, config) {
51
+ const def = config.locales.default;
57
52
  const all = await pages.listPages();
58
- const taken = new Set(all.filter((p) => p.parentId === null).map((p) => p.slug));
53
+ const roots = all.filter((p) => p.parentId === null);
59
54
  let attempted = false;
60
55
  for (const spec of fixedNodeSpecs(config)) {
61
- if (taken.has(spec.slug))
62
- continue;
63
- attempted = true;
64
- try {
65
- await pages.createPage({
66
- slug: spec.slug, template: spec.template, title: spec.title, i18n: spec.i18n, locales: spec.locales,
67
- });
56
+ const slug = spec.segments[def];
57
+ let record = roots.find((p) => p.locales[def]?.slug === slug);
58
+ if (!record) {
59
+ attempted = true;
60
+ try {
61
+ record = await pages.createPage({ locale: def, slug, template: spec.template, title: spec.title });
62
+ }
63
+ catch (e) {
64
+ // Two admins loading at once race on this very create; the loser's
65
+ // slugTaken is the other one winning, not a failure, so it skips
66
+ // silently — no warn. Anything else is worth saying out loud.
67
+ const message = e instanceof Error ? e.message : String(e);
68
+ if (!message.includes("already exists")) {
69
+ console.warn(`smoodly: could not materialize "${slug}": ${message}`);
70
+ }
71
+ continue;
72
+ }
68
73
  }
69
- catch (e) {
70
- // One spec that cannot land a path an editor already claimed in some
71
- // locale must not take the whole list down: warn, skip it, keep
72
- // materializing the rest. Two admins loading at once can also race on
73
- // this same create; the loser's slugTaken is the other one winning,
74
- // not a failure, so it skips silently — no warn.
75
- const message = e instanceof Error ? e.message : String(e);
76
- if (!message.includes("already exists")) {
77
- console.warn(`smoodly: could not materialize "${spec.slug}": ${message}`);
74
+ // Every locale the registration names — including one added to the
75
+ // config after the record was first materialized.
76
+ for (const [locale, segment] of Object.entries(spec.segments)) {
77
+ if (record.locales[locale])
78
+ continue;
79
+ attempted = true;
80
+ try {
81
+ record = await pages.addLocale(record.id, locale, { from: def, slug: segment });
82
+ }
83
+ catch (e) {
84
+ // One locale's row that cannot land — a segment or path an editor
85
+ // already claimed there — must not take the node or the whole list
86
+ // down: warn, skip that locale, keep materializing the rest. Only a
87
+ // concurrent add of the SAME locale is a race, and it skips silently.
88
+ const message = e instanceof Error ? e.message : String(e);
89
+ if (!message.includes("already exists in locale")) {
90
+ console.warn(`smoodly: could not materialize "${slug}" in "${locale}": ${message}`);
91
+ }
78
92
  }
79
93
  }
80
94
  }