smoodly 0.0.4 → 0.0.5

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 (89) hide show
  1. package/README.md +408 -34
  2. package/dist/admin/client-ops.js +2 -2
  3. package/dist/admin/editor/EditorView.d.ts +3 -2
  4. package/dist/admin/editor/EditorView.js +133 -80
  5. package/dist/admin/editor/PageSettings.d.ts +13 -0
  6. package/dist/admin/editor/PageSettings.js +40 -0
  7. package/dist/admin/fixed-nodes.d.ts +23 -0
  8. package/dist/admin/fixed-nodes.js +83 -0
  9. package/dist/admin/forms/FieldWidget.d.ts +2 -1
  10. package/dist/admin/forms/FieldWidget.js +16 -16
  11. package/dist/admin/index.d.ts +2 -1
  12. package/dist/admin/index.js +1 -0
  13. package/dist/admin/next/create-admin.js +7 -2
  14. package/dist/admin/next/op-handler.d.ts +1 -0
  15. package/dist/admin/next/op-handler.js +7 -2
  16. package/dist/admin/ops-impl.d.ts +4 -1
  17. package/dist/admin/ops-impl.js +190 -48
  18. package/dist/admin/ops.d.ts +39 -11
  19. package/dist/admin/serialize.d.ts +10 -1
  20. package/dist/admin/serialize.js +13 -2
  21. package/dist/admin/shell/AdminApp.js +36 -74
  22. package/dist/admin/shell/EntryForm.js +34 -10
  23. package/dist/admin/shell/ErrorPane.d.ts +4 -0
  24. package/dist/admin/shell/ErrorPane.js +9 -0
  25. package/dist/admin/shell/ListView.d.ts +8 -5
  26. package/dist/admin/shell/ListView.js +12 -11
  27. package/dist/admin/shell/LoginView.js +6 -2
  28. package/dist/admin/shell/PagesList.d.ts +6 -0
  29. package/dist/admin/shell/PagesList.js +148 -0
  30. package/dist/admin/shell/base.d.ts +1 -0
  31. package/dist/admin/shell/base.js +4 -0
  32. package/dist/admin/shell/pages-tree.d.ts +39 -0
  33. package/dist/admin/shell/pages-tree.js +65 -0
  34. package/dist/admin/ui/format.d.ts +1 -0
  35. package/dist/admin/ui/format.js +25 -0
  36. package/dist/admin/ui/primitives.d.ts +89 -0
  37. package/dist/admin/ui/primitives.js +115 -0
  38. package/dist/admin/ui/theme.d.ts +5 -0
  39. package/dist/admin/ui/theme.js +396 -0
  40. package/dist/collections.d.ts +23 -2
  41. package/dist/collections.js +19 -0
  42. package/dist/config.d.ts +11 -1
  43. package/dist/config.js +71 -13
  44. package/dist/entry-page.d.ts +16 -0
  45. package/dist/entry-page.js +6 -0
  46. package/dist/entry-store.d.ts +94 -7
  47. package/dist/entry-store.js +175 -26
  48. package/dist/index.d.ts +24 -8
  49. package/dist/index.js +14 -3
  50. package/dist/localize.d.ts +19 -0
  51. package/dist/localize.js +42 -0
  52. package/dist/next/index.d.ts +2 -2
  53. package/dist/next/index.js +1 -1
  54. package/dist/next/meta.d.ts +0 -6
  55. package/dist/next/meta.js +0 -11
  56. package/dist/next/page-renderer.d.ts +26 -18
  57. package/dist/next/page-renderer.js +79 -56
  58. package/dist/page.d.ts +10 -3
  59. package/dist/page.js +12 -2
  60. package/dist/pairing.d.ts +7 -6
  61. package/dist/path-index.d.ts +16 -0
  62. package/dist/path-index.js +33 -0
  63. package/dist/paths.d.ts +80 -0
  64. package/dist/paths.js +110 -0
  65. package/dist/render.d.ts +34 -5
  66. package/dist/render.js +9 -2
  67. package/dist/resolve.d.ts +8 -0
  68. package/dist/resolve.js +19 -7
  69. package/dist/revalidate.d.ts +10 -2
  70. package/dist/revalidate.js +13 -8
  71. package/dist/site.d.ts +85 -0
  72. package/dist/site.js +164 -0
  73. package/dist/sql-space.d.ts +1 -1
  74. package/dist/sql-space.js +2 -0
  75. package/dist/sql.d.ts +1 -1
  76. package/dist/sql.js +92 -15
  77. package/dist/store.d.ts +106 -30
  78. package/dist/store.js +242 -66
  79. package/dist/supabase-entry-store.d.ts +17 -3
  80. package/dist/supabase-entry-store.js +198 -22
  81. package/dist/supabase-path-index.d.ts +11 -0
  82. package/dist/supabase-path-index.js +52 -0
  83. package/dist/supabase-store.d.ts +46 -16
  84. package/dist/supabase-store.js +322 -110
  85. package/package.json +5 -4
  86. package/dist/admin/page-path.d.ts +0 -1
  87. package/dist/admin/page-path.js +0 -6
  88. package/dist/admin/shell/pages-list.d.ts +0 -22
  89. package/dist/admin/shell/pages-list.js +0 -31
package/README.md CHANGED
@@ -41,26 +41,69 @@ Implemented:
41
41
  `coreTablesSQL` emits the once-ever core tables including `entries`
42
42
  (`collection` + `fields`/`i18n` jsonb, `space_id` multi-tenancy with
43
43
  a default space for self-hosted, `sort` for manual ordering, ONE
44
- generic slug-uniqueness index across all spaces and collections);
44
+ generic slug-uniqueness index across all spaces and collections,
45
+ scoped to siblings — `(space_id, collection, parent_id, slug)`, like
46
+ pages, partial on a non-null slug);
45
47
  `collectionSQL` emits only the disposable per-collection guardrails —
46
48
  a published-only read view over `entries` (typed casts, snake_case
47
49
  aliases, refs as plain id values) and partial btree indexes for
48
- declared orderBy fields
49
- - `PageStore` contract + `MemoryPageStore` — draft/publish semantics
50
- (versions accumulate; publish moves a pointer; drafts never leak into
51
- published reads). The contract is a reusable suite
52
- (`test/page-store-contract.ts`); every adapter runs the same tests.
53
- - `SupabasePageStore` the Supabase adapter over pages/page_versions
54
- (a server-side client from `createSmoodlyClient`: the service role in
55
- self-host, a write key in the cloud). Contract-tested against a
56
- real local stack: `supabase start` at the repo root (the generated
57
- DDL is its one migration), then `npm run test:supabase`; plain
58
- `npm test` skips it and never needs Docker.
50
+ the declared `order.by` field, on the jsonb VALUE (`("fields"->'x')`)
51
+ - `PageStore` contract + `MemoryPageStore` — id-keyed page persistence:
52
+ every read and write takes the record's `id`, and the URL is derived
53
+ from the tree, never stored on the record. Pages form a tree (`parentId`,
54
+ sibling `sort`, a null `template` = folder, `pages.tree.depth` capping
55
+ how deep editors may nest), carry a `title` and a per-locale `i18n`
56
+ overlay, and expose `rename` (per-locale slug/title), `move`,
57
+ `setTemplate`, `resolvePath(locale, path)` and `pathsOf(id)` alongside
58
+ the draft/publish semantics (versions accumulate; publish moves a
59
+ pointer; drafts never leak into published reads). The contract is a
60
+ reusable suite (`test/page-store-contract.ts`); every adapter runs the
61
+ same tests.
62
+ - `SupabasePageStore` — the same id-keyed contract over
63
+ pages/page_versions/paths (a server-side client from
64
+ `createSmoodlyClient`: the service role in self-host, a write key in
65
+ the cloud). Contract-tested against a real local stack: `supabase
66
+ start` at the repo root (the generated DDL is its one migration), then
67
+ `npm run test:supabase`; plain `npm test` skips it and never needs
68
+ Docker.
69
+ - `paths.ts` — the ONE place a URL is derived: pure, framework-free
70
+ functions (`segmentFor`, `chainOf`, `pagePath`, `pagePathRows`,
71
+ `entryPathRows`, `buildPageTree`, segment and depth assertions) that both
72
+ adapters and the tree query call, so the index and every derived `path`
73
+ agree by construction. `pagePath` is the single page-path derivation —
74
+ `pagePathRows`, `buildPageTree` and the site layer all go through it.
75
+ - `PathIndex` — the derived URL index, `MemoryPathIndex` and
76
+ `SupabasePathIndex` over the `paths` table ((space, locale, path) → page
77
+ or entry). Pages and entries share ONE instance, so a page and an entry
78
+ can never claim the same URL; the Supabase adapter rewrites a whole
79
+ subtree in one statement through the `smoodly_rewrite_paths` SQL
80
+ function, and a collision surfaces as "the path … is already taken".
81
+ - Collections declare their own URL shape: `path` (the code-owned base
82
+ segment, per locale — absent = the collection has no URLs), `tree`
83
+ (entries nest under entries up to a depth), and `order` (`"manual"` or
84
+ `{ by, direction }`, replacing `admin.orderBy` and driving both the
85
+ store's list order and the generated index). A declared field orders by
86
+ its jsonb VALUE, so numbers sort numerically and strings — ISO dates
87
+ included — alphabetically, the same in Supabase as in memory.
88
+ - `smoodly.entryPage(collection, View)` — a paired component registered
89
+ like a page (`kind: "entryPage"`, name `entry:<collection>`), served for
90
+ every entry of the collection by the catch-all through
91
+ `renderSmoodlyPath`.
59
92
  - `resolveTree` — the batched ref-resolution pass: one fetch per
60
93
  collection per round, ref-site depth modes (bare ref = entry only,
61
94
  `.resolve(names)` = named refs one level, bare `.resolve()` = whole
62
95
  chain), per-path cycle cutting with shallow copies, optional `maxDepth`
63
96
  safety valve (default unlimited)
97
+ - `resolveFields` — the same pass over ONE entry's fields against its
98
+ collection's descriptors, for the entry-page route: the shared task
99
+ collection and batched rounds are factored out, so a ref site behaves
100
+ identically whether it sits in a page tree or in an entry.
101
+ - `localize.ts` — per-field locale fallback over the two overlays the
102
+ stores carry: `titleFor` (a page's `i18n[locale].title`, else the default
103
+ locale's), `localizeFields` (an entry's `i18n[locale]`, per field, with a
104
+ blank overlay value falling back) and `fixedPathFor` (the path a fixed
105
+ page registration is served at in one locale — the escape hatch's
106
+ addressing). Slugs stay `paths.ts`'s business.
64
107
 
65
108
  - `collectEntryRefs` / `collectTreeRefs` — the refs-index feeders: walk
66
109
  an entry or a page tree against its schemas and list every outgoing
@@ -70,9 +113,13 @@ Implemented:
70
113
  every `saveDraft`/`publish`, so cross-kind safe-delete holds while
71
114
  either tree shows an entry. Publish integrity: `publish` walks the
72
115
  draft tree directly and blocks on missing/unpublished entries.
73
- Revalidation fan-out: `affectedPageSlugs` — reverse BFS over refs
116
+ Revalidation fan-out: `affectedTargets` — reverse BFS over refs
74
117
  (entry ← entry ← page, cycle-safe, optional `maxDepth`) returning
75
- published slugs for the Next glue to `revalidateTag`. All wired via
118
+ `{ pageIds, entryIds }`: every PUBLISHED page whose tree reaches the
119
+ changed entry, and the entry itself plus every entry that references it.
120
+ Ids, not slugs — `pageTag(id)`/`entryTag(id)` name the cache tags, and
121
+ the op handler's `revalidate` effect passes each to Next's `updateTag`,
122
+ so a rename never orphans a tag. All wired via
76
123
  store constructor options (`sections`, `entries`, shared
77
124
  `MemoryRefIndex` in memory); the cross-store contract
78
125
  (`test/page-refs-contract.ts`) runs against memory AND Supabase.
@@ -84,6 +131,9 @@ Implemented:
84
131
  published-route read — a post-publish demotion resolves as missing
85
132
  instead of leaking draft fields — DESIGN.md §3 "Page edges in refs"), deletes are blocked while
86
133
  referenced,
134
+ entry slugs are unique among SIBLINGS in both adapters (the same rule
135
+ pages have: `/docs/guide-a/intro` and `/docs/guide-b/intro` coexist,
136
+ a slug-less entry never conflicts),
87
137
  and `reorder` rewrites sort 1..N through the `smoodly_reorder` SQL
88
138
  function (one statement, `updated_at` untouched). Same
89
139
  reusable-contract pattern as PageStore; `npm run test:supabase` runs
@@ -91,7 +141,12 @@ Implemented:
91
141
 
92
142
  - Proven end-to-end in `examples/site` — a real Next.js app wiring the
93
143
  config + Supabase stores into routes: tag-cached resolved trees,
94
- draft-mode preview, server-action writes with revalidation fan-out
144
+ draft-mode preview, server-action writes with revalidation fan-out.
145
+ The example is the multilingual layout (next-intl, `en`/`fi`, the site
146
+ under `app/[locale]/`, the admin outside it), and `create-smoodly-app`
147
+ generates both of its templates from it — `templates/next-intl`
148
+ verbatim, `templates/next` with the four single-language route files
149
+ from `variants/single/` and two rewritten lines of glue
95
150
 
96
151
  - The space layer (spec 2026-09-04 §3): `space_id` on every content
97
152
  table, `smoodly_current_space()`, the insert trigger, the row cap, the
@@ -137,19 +192,86 @@ Implemented:
137
192
  `examples/site/app/(smoodly)/admin` against the local Supabase stack —
138
193
  see its README's acceptance walk.
139
194
 
140
- - `smoodly/next` `createPageRenderer`, the site-route seam: draft
141
- branch, tag-cached published read, ref resolution, 404 and editor
142
- bridge behind one `renderSmoodlyPage(Page, { slug?, searchParams? })`,
143
- plus `smoodlyMetadata` for `generateMetadata`. Both await Next's
144
- `connection()` before reading the stores, so a fixed-slug route is
145
- request-rendered without a `force-dynamic` export and `next build`
146
- never reaches the database (`test/next-page-renderer.test.tsx`). Bound
147
- once by the app, like `createAdminOpHandler`. Page schemas may declare a fixed `slug`
148
- (a singleton owned by one route file); the admin reads it to filter
195
+ - The admin is the page TREE (spec 2026-09-05 §7): the ops seam addresses
196
+ pages by id `pages.list`/`get`/`create`/`saveDraft`/`publish`/
197
+ `delete`/`rename`/`move`/`setTemplate`, plus `entries.move`.
198
+ `pages.list` materializes the code-owned root nodes before an editor
199
+ touches them (`admin/fixed-nodes.ts` derives fixed pages and collection
200
+ mounts from the registry, never from a column), and every guard asks
201
+ the same `fixedNodeOf`: a fixed node is never renamed, moved or
202
+ deleted, and no page is created under a mount, and a `homePageSlug`
203
+ guard keeps the site root from being deleted, moved or re-slugged
204
+ even without a fixed registration. `pages.get` returns the
205
+ record, its draft, `paths` (locale → CMS path) and `urls` (`href`
206
+ applied), so the canvas and "View live" get site URLs. A tree write
207
+ expires the neighbours whose cached units embed it: publish and delete
208
+ the record and its parent, rename the subtree and its parent, move the
209
+ subtree plus the old and the new parent; `entries.move` expires the
210
+ entry and its descendants. The Pages list IS that tree — `admin/shell/pages-tree.ts`
211
+ is the pure view model (indent per depth, chevron on parents, the
212
+ derived path as the Path column, and per row its kind, status, sibling
213
+ position and whether it may take children), rendered by
214
+ `admin/shell/PagesList.tsx` with a row menu over the tree ops (New
215
+ subpage, New subfolder, Add page here, Rename…, Move up / Move down,
216
+ Delete, and Edit index page on a mount a fixed page claims). The editor
217
+ is keyed by id, with a locale switcher pointing the canvas at
218
+ `urls[locale]` and, while no block is selected, page settings
219
+ (`admin/editor/PageSettings.tsx`: title and slug per locale through
220
+ `pages.rename`; SEO/meta fields stay OQ #17). The entry form prefixes
221
+ its slug field with the collection's mount path. The serialized
222
+ registry carries `collections[].path`/`depth`, `pages[].slugs` and
223
+ `pageDepth`, and `defineConfig` validates `homePageSlug` — a real
224
+ segment, and never a collection's mount.
225
+
226
+ - `createSmoodlySite` — the site layer (`site.ts`), framework-free: one
227
+ place turns a (locale, path) into a loaded page or entry, so the Next
228
+ renderer and the content API can never disagree about published-only
229
+ reads, folders or fallback. `locate(locale, path)` is one primary-key
230
+ hit on the `paths` index; `load(target, locale, path, { draft? })`
231
+ fetches the record and its tree (draft or published), resolves refs,
232
+ applies the `i18n` overlays, and returns a `SitePage` — `record`,
233
+ `template`, `tree`, localized `title`, `path`, `ancestors`, `children`,
234
+ and `paths` (locale → path, for alternates) — or a `SiteEntry` with the
235
+ entry's fields localized and resolved. `resolve(locale, path, { draft? })`
236
+ is the two in sequence — `locate` then `load` — for a caller that just
237
+ wants the hit. A folder (null template), an
238
+ unpublished record outside draft mode, or a missing one is `null`.
239
+ On top of it the published-only content API for menus, sitemaps and
240
+ custom routes: `getPagesTree({ locale })` (nested
241
+ `{ id, title, path, hasPage, children }`), `getPage(pathOrId)`,
242
+ `getEntries(collection, { locale, parent?, order? })`,
243
+ `getEntry(collection, idOrPath)` (an id, a path, or a bare slug naming a
244
+ root entry) and `getEntriesTree(collection)`.
245
+ - `smoodly/next` — `createPageRenderer`, the site-route seam over that
246
+ layer, bound once by the app like `createAdminOpHandler`. The two route
247
+ files call `renderSmoodlyPath(segments, { locale?, searchParams? })` and
248
+ `smoodlyMetadata(segments, { locale? })`: a page at any depth in any
249
+ locale rendered by the shape its `template` names, with the `page` prop
250
+ (`{ id, title, path, url, ancestors, children }`, every ancestor and
251
+ child carrying `url` beside its locale-relative `path`, both put through
252
+ the `href` seam) beside `zones`; a collection
253
+ entry rendered by its `smoodly.entryPage` as `{ entry, path }`; a folder,
254
+ an absent locale or an unregistered template a 404. The published read is
255
+ cached per (locale, path) and tagged `page:<id>` / `entry:<id>` — the
256
+ `locate` hit runs outside the cache, because the tag needs the id first.
257
+ Draft mode reads drafts uncached, and the editor param mounts
258
+ `EditorBridge`. Metadata is the tree's `meta` plus
259
+ `alternates.languages`, built through the injectable `href(path, locale)`
260
+ seam (identity by default, `getPathname` with next-intl) and emitted only
261
+ for a multi-locale site. `renderSmoodlyPage(Paired, …)` and
262
+ `smoodlyPageMetadata` remain as the escape hatch for a FIXED page
263
+ rendered from the developer's own route file; each serves only its own
264
+ registration's record. All four await Next's
265
+ `connection()` before reading the stores, so a route with no dynamic
266
+ segment is request-rendered without a `force-dynamic` export and
267
+ `next build` never reaches the database
268
+ (`test/next-page-renderer.test.tsx`). Page schemas may declare a fixed `slug`
269
+ (owned by code, not by editors); the admin reads it to filter
149
270
  New Page and protect the record. `defineConfig({ homePageSlug })`
150
- (default `"home"`) names the slug the site root serves; the admin's
151
- `pagePath()` maps it to `/` — canvas URL and Pages list and every
152
- other slug to `/<slug>`.
271
+ (default `"home"`) names the slug the site root serves; the paths index
272
+ derives every URL from it, and `pages.get(id)` hands the admin the
273
+ finished `paths` (locale → CMS path) and `urls` (`href` applied), so the
274
+ admin no longer derives a path of its own.
153
275
 
154
276
  - Locked zones are usable: `fillLockedZones` (a pure tree op) materializes
155
277
  the one node of every empty locked zone from the component's `sample`
@@ -160,9 +282,11 @@ Implemented:
160
282
 
161
283
  Not yet: richtext components (TipTap; `plainToRichtext`/
162
284
  `richtextToPlain` exist but no rich editor widget), drag-and-drop
163
- reordering (move-up/move-down buttons only), undo/redo, sample/
164
- placeholder content marking, editor localization (the
165
- locale switcher examples/site is single-locale so it stays hidden).
285
+ reordering (move-up/move-down buttons in the editor, Move up / Move down
286
+ in the Pages list's row menu), undo/redo, sample/placeholder content
287
+ marking, per-locale section field values (OQ #3 the editor's locale
288
+ switcher moves the canvas and page settings between locales, but a
289
+ section's fields are one set for every locale).
166
290
 
167
291
  ## Releasing
168
292
 
@@ -178,6 +302,259 @@ Both packages move together. From the repo root:
178
302
  `examples/site` in that build, so the template always matches the example
179
303
  site at the tagged commit.
180
304
 
305
+ ## Follow-ups (logged 2026-09-06)
306
+
307
+ - Drag-and-drop in the Pages list — the ops it needs exist
308
+ (`pages.move(id, { parentId, index })`); the row menu's Move up / Move
309
+ down is the interim.
310
+ - Nested entries have no admin UI beyond `entries.move`: nothing creates
311
+ a child entry or shows the entry tree, and the entry form's slug prefix
312
+ is the collection's mount base only, not the parent chain.
313
+ - A blank translated slug cannot be CLEARED from the settings panel: the
314
+ panel skips an empty non-default value (a blank falls back to the
315
+ default locale) and the store reads `slug: undefined` as "no change",
316
+ so removing an existing override would need a `null` patch through
317
+ `rename`.
318
+ - SEO/meta fields in page settings — DESIGN.md OQ #17. Title and slug per
319
+ locale shipped; nothing writes `PageTree.meta` yet.
320
+ - `pages.get` makes three sequential store round trips (`getPage`,
321
+ `getDraftTree`, `pathsOf`), and the editor calls it on load and after
322
+ every rename — one store call returning the three would halve the
323
+ editor's latency.
324
+ - `entries.move` expires the entry and its descendants but does not fan
325
+ out to the pages referencing them, nor to the old and the new parent
326
+ entries the way `pages.move` does — asymmetric with `entries.save` and
327
+ `entries.setStatus`, which both fan out.
328
+ - The row menu's item set (`menuItems`) and the row-click routing live in
329
+ `PagesList.tsx`, untested; extract them into `pages-tree.ts` before
330
+ plan 4 adds entry rows to the same menu.
331
+ - The shell derives the fixed nodes a second time, over the serialized
332
+ registry (`mountOf` / `isFixedNode` in `pages-tree.ts`), beside the
333
+ server's `fixedNodeOf` — one derivation shipped to the client would do.
334
+ - The locale switcher points the canvas at `urls[locale]`, and `href` is
335
+ identity in the example, so every path serves the default locale: a
336
+ page with a translated slug shows a 404 in the canvas for that locale
337
+ until next-intl is wired (plan 4). **Done 2026-09-06** — `href` is
338
+ `getPathname` in the example now; the canvas loads
339
+ `/fi/yritys/tietoa-meista?smoodly-editor=1` and renders (walked in
340
+ `examples/site/README.md`).
341
+ - Page settings prints `paths[locale]` under each slug field — the
342
+ locale-relative CMS path, not `urls[locale]`, the site URL the canvas
343
+ and "View live" actually open. On a translated site the Finnish hint
344
+ reads `/yritys/tietoa-meista` while the page lives at
345
+ `/fi/yritys/tietoa-meista`. Both maps are already in `EditorView`;
346
+ which one belongs under the field is a UI decision, not a missing
347
+ value.
348
+ - `ensureFixedNodes` runs on every `pages.list` and every ROOT
349
+ `pages.create` — one `listPages()` each, and a second one when it
350
+ tried to create something. It also treats a root record sitting at a
351
+ fixed slug as that node even when its `template` is a foreign one: the
352
+ node is adopted, never recreated, and the ops then guard it as
353
+ code-owned.
354
+ - Two admins loading at once race on materialization. Any spec whose
355
+ create fails is warned about and skipped, not fatal to the rest of the
356
+ list — except the loser's `slugTaken`, which means the other admin won
357
+ the same race: that one message is swallowed on purpose, silently,
358
+ with no warn.
359
+ - The `smoodly_reorder` / `smoodly_reorder_pages` RPCs keep their
360
+ implicit sibling scope (decided 2026-09-06: every caller passes exactly
361
+ one sibling set). A parent argument waits for a caller that needs it.
362
+ - The row menu in `PagesList.tsx` does not close on Escape — only a click
363
+ on the backdrop closes it.
364
+ - `PageSettings` re-seeds its inputs from `record` on every rename (and
365
+ every publish), so keystrokes typed while a rename is in flight can be
366
+ dropped. The blur- and Enter-driven commits themselves are unaffected.
367
+ - `subtreeIds` and `entrySubtreeIds` in `ops-impl.ts` are the same O(n²)
368
+ walk over a full list, written twice. One generic descendant walk when
369
+ the list sizes make it matter.
370
+ - `publish`'s "has no draft to publish" branch is unreachable with the
371
+ memory store — `createPage` gives every templated page a first version
372
+ — and is untested. It is defence for the Supabase adapter.
373
+ - Classifications the ops tests do not assert: a move that breaks the
374
+ home page's no-children rule or exceeds the tree depth (create asserts
375
+ both), and `entries.move`'s cross-collection and sibling-slug guards.
376
+ `MemoryEntryStore.move` has no children guard at all — the subtree
377
+ rides along — so `has child` is reachable from delete only.
378
+ - The seed's early exit still probes `/` alone: a run that dies between
379
+ the two publishes reports "Already seeded" on the next run and never
380
+ gets its `company` folder.
381
+ - `create-smoodly-app` scaffolds now ship `pages: { tree: { depth: 3 } }`
382
+ — the template build rewrites only the locale set in `smoodly/config.ts`
383
+ and `href` in `smoodly/server.ts` (each with its comment) — so nesting
384
+ is on by default in a fresh app.
385
+ - A childless folder row is inert on click (a mount opens its collection,
386
+ a page opens the editor, a folder with children expands); "Add page
387
+ here" lives in the row menu only.
388
+ - The tree rows nest interactive buttons — the chevron and the `···`
389
+ menu — inside a `role="button"` row. It works, because both stop the
390
+ click and the keydown, but it is an accessibility smell a real treegrid
391
+ would not have.
392
+ - `EditorView.tsx` is ~720 lines. The inspector and the canvas column are
393
+ the extraction seam whenever it is next touched.
394
+ - `i18n/request.ts` returns `{ locale, messages: {} }`. next-intl 4.14.2
395
+ throws `No messages found` when the provider inherits none, and the
396
+ example has no UI strings of its own, so the object is empty; a
397
+ scaffolded site that translates its own UI fills it in (the comment in
398
+ the file points at next-intl's configuration docs). No `messages/`
399
+ folder is scaffolded — UI-string translation is next-intl's job, not
400
+ Smoodly's.
401
+ - `i18n/request.ts` reads `next/root-params` (next-intl's current
402
+ guidance; `requestLocale` is deprecated). Its real types are generated
403
+ into `.next/types/` by `next dev` / `next build` / `next typegen`;
404
+ `tsc` before a first build still passes, because `next` ships a bare
405
+ `declare module 'next/root-params'` for exactly that window.
406
+ - The four single-language route files in
407
+ `packages/create-smoodly-app/variants/single/` are compiled by nothing
408
+ but the CI hosted-mode e2e's `next build` — the example is the
409
+ multilingual layout, so `npm test` never type-checks them. A renderer
410
+ or admin signature change breaks them there, a whole CI job later than
411
+ it should. The fix is a `tsconfig` project for `variants/` with the
412
+ scaffold's `@/*` paths mapped into the example, or a scaffold-then-
413
+ `tsc` smoke test.
414
+ - `ArticleList` renders titles, not links. A section that linked to its
415
+ entries would put the entry's `path` through the app's `Link` / `href`
416
+ the way `components/pages/Page.tsx` does — `getEntries` returns `path`
417
+ already; the section simply does not use it.
418
+ - `smoodlyMetadata` emits `alternates.languages` as relative URLs and
419
+ neither the example nor either template sets `metadataBase`, so the
420
+ rendered `hreflang` hrefs are relative (`/fi/yritys/tietoa-meista`).
421
+ Crawlers want absolute ones: a real site sets `metadataBase` in its
422
+ root layout. `next build` on the example (2026-09-06, Next 16.3.4) does
423
+ not warn — every route is request-rendered, so nothing resolves the
424
+ relative URLs at build time.
425
+ - The proxy's matcher hard-codes `admin` and `api`
426
+ (`/((?!admin|api|_next|_vercel|.*\..*).*)`). An app that mounts the
427
+ admin somewhere else edits `proxy.ts` by hand; nothing derives the
428
+ matcher from the config.
429
+
430
+ ## Follow-ups (logged 2026-09-05)
431
+
432
+ - A page's cached unit embeds its ancestors' and children's titles and
433
+ paths (the `page` prop) but is tagged `page:<own id>` only. The admin
434
+ ops now expire the neighbours they change — publish and delete the
435
+ parent, rename and move the subtree and both parents (2026-09-06) — so
436
+ what remains stale is a write that does not go through them: the seed,
437
+ a script, any out-of-band writer. Closing that needs one tree-wide tag
438
+ instead of a neighbour list (DESIGN.md OQ #26).
439
+ - The cached unit is keyed by (locale, path) but tagged by the id found at
440
+ the first miss, so a path that changes hands without both ids' tags firing
441
+ serves the previous occupant. Delete fires its own tag and the rename/move
442
+ fan-out covers the moving record; re-keying the unit by
443
+ (locale, kind, id) would remove the class (DESIGN.md OQ #26).
444
+ - `ancestors` on a page hit are NOT filtered by locale (children are): a
445
+ page ancestor whose `locales` exclude `fi` (`hasPage` true) still
446
+ produces a breadcrumb link whose path 404s in `fi`.
447
+ - `smoodlyMetadata` does not apply the registration check `renderSmoodlyPath`
448
+ applies: a record whose `template` has no registration is a 404 body with
449
+ its meta emitted beside it.
450
+ - `sections` is passed twice — to `createSmoodlySite` (ref resolution) and to
451
+ `createPageRenderer` (the component map) — and `elements` cannot be passed
452
+ at all; the two lists can drift silently.
453
+ - `getEntry` by bare slug matches `entrySegment` on the ALREADY-localized
454
+ entry, so the overlay is applied twice; it diverges only for a non-string
455
+ `i18n[locale].slug`. `getEntry` by id reads the whole collection to find
456
+ one row.
457
+ - `locate` (one primary-key hit on `paths`) runs per request outside the
458
+ cache, because the cache tag needs the id before the unit can be cached; a
459
+ later `"use cache"` migration (OQ #19) can fold it in.
460
+ - Entry-page metadata is alternates only — entries have no `meta`. A
461
+ `titleField`-based title is a later nicety.
462
+ - Localized tree content (per-locale section fields) is not rendered: there
463
+ is no storage model for it yet (OQ #3). `title` and entry `fields` are
464
+ localized.
465
+ - `getEntries`/`getEntriesTree` return refs as ids (cheap lists); `getEntry`
466
+ by path resolves them, `getEntry` by id or slug does not — document or
467
+ unify when a site needs resolved lists.
468
+ - `loadPage` calls `listPages()` for ancestors and children on every cache
469
+ miss; fine for hundreds of nodes, a `children(id)`/`chain(id)` store query
470
+ is the fix when a site outgrows it.
471
+ - Views rendered outside the site get `EMPTY_PAGE_CONTEXT`: tests and any
472
+ preview of a shape that isn't going through `site.load`.
473
+ - `loadEntry` probes each mounted collection in turn: the `paths` row
474
+ carries the target id but not its collection.
475
+ - A published entry under a draft parent is dropped from `getEntriesTree`
476
+ (the parent is not in the list to hang it from) but returned flat by
477
+ `getEntries`. Untested either way.
478
+ - `SmoodlyConfig.registry.pages` (`Registered` in `src/config.ts`) has no
479
+ call signature, so every app's glue casts it `as PageRegistration[]`.
480
+ - The entry-page view receives the whole `EntryRecord`, every locale's
481
+ `i18n` included; a client entry page would ship all of it to the browser.
482
+ - `move` is best-effort across two statements (reorder, then path rewrite):
483
+ a failed rewrite restores each destination sibling's prior `sort` row by
484
+ row, so a failure in the rollback itself still leaves the order changed.
485
+ - The live suites run on vitest's DEFAULT 5s per-test timeout against a real
486
+ database, while a single contract test makes dozens of round trips. On a
487
+ loaded or degraded local stack `npm run test:supabase` then fails with bare
488
+ timeouts on arbitrary tests (every file passes on its own, and the whole
489
+ run passes with `--testTimeout=30000`). Either raise the timeout in
490
+ `scripts/test-supabase.sh` or accept the flake in CI.
491
+ - The `smoodly_reorder` RPC takes no parent scope — the caller always passes
492
+ one sibling set, so scoping is implicit. Give it a parent argument if a
493
+ caller ever passes a mixed list.
494
+ - An explicit JSON `null` VALUE in a declared `order.by` field sorts
495
+ FIRST in Supabase (a jsonb null is the lowest jsonb value, not SQL
496
+ NULL, so `nullsFirst: false` does not reach it) and LAST in memory. A
497
+ MISSING key is SQL NULL and sorts last in both, and the admin only ever
498
+ produces that — a cleared number field emits `undefined`, which the
499
+ JSON body drops — so this needs a programmatic or external writer.
500
+ Mixed types in one field diverge for the same reason: Postgres orders
501
+ jsonb by type first (null < string < number), memory by `String(...)`.
502
+ - Rollback gaps that remain: a `smoodly_reorder`/`smoodly_reorder_pages`
503
+ failure AFTER the re-parent update has no rollback at all; the entry
504
+ `create` rollback discards its delete result (an orphan row survives
505
+ silently); and a throw inside any rollback replaces — masks — the original
506
+ error.
507
+ - Duplicate sibling `sort` values are reachable — create uses count + 1 while
508
+ delete and move leave gaps — so `sort` is an ordering hint and creation
509
+ order breaks ties.
510
+ - Folders have path rows and resolve as `kind: "page"`; the published-only
511
+ read policy makes them public 404s, while the admin (write key) sees them.
512
+ - `setTemplate`/`saveDraft` insert the version row before the pointer update:
513
+ an infra failure between the two orphans a version.
514
+ - No `rebuildPaths()`: the index is documented as derived and rebuildable, but
515
+ nothing rebuilds it.
516
+ - Two `entryPage` registrations for one collection are silently accepted.
517
+ `PageStore.rename` still accepts a locale outside `supported`; the admin
518
+ op rejects it first, so only a direct store caller reaches it.
519
+ - Entry `move` coverage is thinner than pages' — no omitted-index, clamp or
520
+ cross-collection tests.
521
+ - The memory stores default to SEPARATE private path indexes while the
522
+ Supabase stores share one `paths` table: pages and entries share a URL space
523
+ only when the wiring passes one `paths` option to both (convention, not
524
+ structure).
525
+ - `SupabasePathIndex` falls back to `pathTakenError("?", "?")` when
526
+ Postgres reports a unique violation without `details` — the message then
527
+ names no path.
528
+ - `chainOf` has no cycle guard: a corrupted `parent_id` loop would spin
529
+ forever. The stores prevent cycles on `move`, so it is unreachable today.
530
+ - `EntryRecord.i18n` has no writer yet — the store reads and derives paths
531
+ from it, but nothing sets it, so per-locale entry slugs are unreachable
532
+ until the ops layer exposes them.
533
+ - Design system pass (DESIGN.md §2, "The admin's visual system"): the shell,
534
+ lists, entry form, login and editor were restyled onto `admin/ui/theme.tsx`.
535
+ Deferred deliberately, because each is a feature rather than a restyle: the
536
+ list search field and status filter, the locale select in the shell's top bar
537
+ (the editor has one; the lists always read the default locale), the article
538
+ sidebar's taxonomy/SEO/publish-date sections, and the rich-text toolbar. The
539
+ pages list's tree shape (chevrons, depth indent) and its row `···` menu
540
+ shipped 2026-09-06, without the drag handle. The tokens for all of them are
541
+ already in place.
542
+ - The `scroll` message in `admin/editor/protocol.ts` is no longer sent: with the
543
+ canvas column as the scroller, `EditorView` scrolls it directly instead of
544
+ asking the bridge to `scrollIntoView` across the frame. `EditorBridge` still
545
+ handles the message, so old and new admins interoperate; drop the message on
546
+ the next protocol change.
547
+ - Light/dark toggle (nav foot): the choice is stored in `localStorage`
548
+ (`smoodly-theme`) and written as `data-theme` on the admin wrapper, never on
549
+ the host `<html>`. It is per browser, not per editor account — a server-side
550
+ preference would need a place to live (`editors`, or a cloud user profile).
551
+ - `lucide-react` is now a runtime dependency, for the toggle's two icons. The
552
+ rest of the UI still uses the design system's unicode glyphs (↑ ↓ ⧉ × ··· ▾
553
+ ← ⋮⋮); swapping them for Lucide is the next step whenever it is wanted.
554
+ - `admin/ui/theme.tsx` pulls Public Sans from Google Fonts with an `@import`.
555
+ Fine today; a self-hosted font (or `next/font` in the glue layer) removes the
556
+ third-party request and the flash before it loads.
557
+
181
558
  ## Follow-ups (logged 2026-09-04)
182
559
 
183
560
  - Cloud-ready foundation — done, including the self-host init
@@ -193,7 +570,7 @@ site at the tagged commit.
193
570
  - `isEditor` reads `editors` with no space filter: under a cloud write key RLS scopes it, but a service-role client bypasses RLS and would admit an editor of any space. Harmless in self-host (one space); never use a service-role client against the shared content project.
194
571
  - The `sb_secret_` branch of `clientOptions` (secret key as the sole `apikey`, no bearer) was exercised live on 2026-09-05 against a hosted project: PostgREST reads with the service-role template and the GoTrue admin API (`listUsers`, `createUser`) both accept it; a password sign-in with the matching `sb_publishable_` key returns a session. Unit tests still cover the shape only; the local stack stays on legacy JWT keys.
195
572
  - The migration is not re-appliable to a pre-rename (`project_id`) database: `create table if not exists` leaves it untouched and the `space_id` policies then fail. No installs predate the rename; fold into DESIGN.md OQ #2 (schema upgrades).
196
- - `examples/site/lib/smoodly/index.ts` should carry `import "server-only"` now that it also exports `publicAuth` — a client-side import would fail loudly at build instead of at runtime.
573
+ - `examples/site/smoodly/server.ts` should carry `import "server-only"` now that it also exports `publicAuth` — a client-side import would fail loudly at build instead of at runtime.
197
574
  - `exports` has no `"./package.json"` entry, so tooling that reads a dependency's `package.json` through the exports map cannot. One line to add when something needs it.
198
575
  - `src/ref-index.ts` contains two raw NUL bytes used as key separators, which makes git show the file as binary in diffs. Replacing them with `\0` escapes would make it diffable again.
199
576
 
@@ -203,9 +580,6 @@ site at the tagged commit.
203
580
 
204
581
  - Editor canvas URL mapping: now `homePageSlug` config (done 2026-09-03); remaining follow-up is a registration-declared `path` for fixed pages at arbitrary paths.
205
582
  - `serialize.ts` structural cast duplicates `PageSchema`; tighten `Registered.schema` so the cast goes.
206
- - Pages list: no re-entrancy guard on a fast double-click of "New page" / a not-created row, and the row `onClick` promise is unhandled (a transport throw is an unhandled rejection); fix both together with a `.catch` → alert.
207
- - `pagesRows`: pin with a test the case of a record at a fixed slug whose `template` is an open registration (self-healing today: `/` 404s, `/<slug>` serves it, delete is allowed, then the singleton row reappears).
208
583
  - Catch-all `templates` map should filter to open registrations to state its intent (unreachable today).
209
584
  - The no-database build is the real proof that routes never prerender against the stores; the unit test only stubs `next/server`. Script it (`SUPABASE_URL=http://127.0.0.1:9 npx next build` in `examples/site`, expect `ƒ /`) and add it to CI, which today runs only the package's tests and typecheck — so a regression is caught when Next changes `connection()` semantics.
210
- - Next acceptance walk: start from a database with no `home` record so the "not created" singleton row is exercised.
211
585
  - `EditorView` split, remaining seams (autosave extracted 2026-09-03): pull the canvas overlay and the insert picker into components when the dnd-kit outline work starts; the sidebar form stays put until inline editing lands (DESIGN.md §3 puts TipTap in the canvas, not the sidebar).
@@ -1,6 +1,6 @@
1
1
  const GROUPS = {
2
- pages: ["list", "get", "create", "saveDraft", "publish", "delete"],
3
- entries: ["list", "get", "create", "save", "setStatus", "delete"],
2
+ pages: ["list", "get", "create", "saveDraft", "publish", "delete", "rename", "move", "setTemplate"],
3
+ entries: ["list", "get", "create", "save", "setStatus", "delete", "move"],
4
4
  preview: ["enable", "disable"],
5
5
  };
6
6
  export function makeClientOps(action, token) {
@@ -1,7 +1,8 @@
1
1
  import type { AdminOps } from "../ops.ts";
2
2
  import type { AdminRegistry } from "../serialize.ts";
3
- export declare function EditorView({ ops, registry, slug }: {
3
+ export declare const withEditorParam: (url: string) => string;
4
+ export declare function EditorView({ ops, registry, id }: {
4
5
  ops: AdminOps;
5
6
  registry: AdminRegistry;
6
- slug: string;
7
+ id: string;
7
8
  }): import("react").JSX.Element;