smoodly 0.0.33 → 0.0.35

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 (82) hide show
  1. package/README.md +845 -1301
  2. package/dist/addresses.d.ts +42 -0
  3. package/dist/addresses.js +51 -0
  4. package/dist/admin/editor/EditorView.js +14 -5
  5. package/dist/admin/editor/PageSettings.js +2 -1
  6. package/dist/admin/editor/autosave.d.ts +6 -1
  7. package/dist/admin/editor/autosave.js +43 -3
  8. package/dist/admin/editor/useCanvasBridge.js +25 -2
  9. package/dist/admin/fixed-nodes.d.ts +9 -24
  10. package/dist/admin/fixed-nodes.js +36 -72
  11. package/dist/admin/forms/list.js +10 -1
  12. package/dist/admin/health.js +9 -0
  13. package/dist/admin/next/create-admin.d.ts +4 -3
  14. package/dist/admin/next/create-admin.js +14 -3
  15. package/dist/admin/ops-impl.js +121 -12
  16. package/dist/admin/redirect-path.d.ts +3 -0
  17. package/dist/admin/redirect-path.js +9 -0
  18. package/dist/admin/serialize.d.ts +3 -0
  19. package/dist/admin/serialize.js +1 -0
  20. package/dist/admin/shell/AddRedirectModal.js +2 -2
  21. package/dist/admin/shell/EntryForm.js +3 -2
  22. package/dist/admin/shell/GlobalForm.js +3 -2
  23. package/dist/admin/shell/PagesList.js +29 -12
  24. package/dist/admin/shell/live-url.d.ts +3 -1
  25. package/dist/admin/shell/live-url.js +5 -2
  26. package/dist/admin/shell/pages-tree.d.ts +19 -14
  27. package/dist/admin/shell/pages-tree.js +49 -16
  28. package/dist/admin/shell/router.d.ts +5 -0
  29. package/dist/admin/shell/router.js +13 -0
  30. package/dist/admin/ui/theme.js +1 -0
  31. package/dist/anchors.d.ts +42 -0
  32. package/dist/anchors.js +72 -0
  33. package/dist/cli/export.js +3 -1
  34. package/dist/cli/import.d.ts +1 -0
  35. package/dist/cli/import.js +13 -2
  36. package/dist/cli/space-io.d.ts +1 -2
  37. package/dist/cli/space-io.js +5 -5
  38. package/dist/collections.js +5 -0
  39. package/dist/config.d.ts +6 -0
  40. package/dist/config.js +53 -18
  41. package/dist/current-space.d.ts +4 -0
  42. package/dist/current-space.js +9 -0
  43. package/dist/entry-store.js +4 -2
  44. package/dist/env.d.ts +4 -3
  45. package/dist/env.js +15 -17
  46. package/dist/index.d.ts +3 -1
  47. package/dist/index.js +3 -1
  48. package/dist/next/canvas-slot.d.ts +7 -0
  49. package/dist/next/canvas-slot.js +9 -0
  50. package/dist/next/index.d.ts +2 -0
  51. package/dist/next/index.js +2 -0
  52. package/dist/next/page-renderer.d.ts +2 -0
  53. package/dist/next/page-renderer.js +14 -10
  54. package/dist/next/preview-pill.d.ts +9 -0
  55. package/dist/next/preview-pill.js +56 -0
  56. package/dist/next/preview-route.d.ts +1 -0
  57. package/dist/next/preview-route.js +30 -0
  58. package/dist/path-index.d.ts +11 -0
  59. package/dist/path-index.js +52 -25
  60. package/dist/paths.d.ts +25 -12
  61. package/dist/paths.js +59 -21
  62. package/dist/schema-version.d.ts +1 -1
  63. package/dist/schema-version.js +1 -1
  64. package/dist/site.d.ts +7 -3
  65. package/dist/site.js +26 -15
  66. package/dist/space-transfer.d.ts +2 -0
  67. package/dist/space-transfer.js +8 -1
  68. package/dist/sql-space.d.ts +14 -0
  69. package/dist/sql-space.js +51 -23
  70. package/dist/sql.d.ts +5 -0
  71. package/dist/sql.js +51 -9
  72. package/dist/store.d.ts +15 -1
  73. package/dist/store.js +27 -6
  74. package/dist/supabase-entry-store.d.ts +0 -1
  75. package/dist/supabase-entry-store.js +6 -11
  76. package/dist/supabase-path-index.d.ts +12 -1
  77. package/dist/supabase-path-index.js +56 -19
  78. package/dist/supabase-store.d.ts +2 -0
  79. package/dist/supabase-store.js +30 -9
  80. package/migrations/20260929120000_smoodly_v5.sql +56 -0
  81. package/migrations/20260929135523_smoodly_v6.sql +200 -0
  82. package/package.json +1 -5
package/README.md CHANGED
@@ -265,12 +265,13 @@ Implemented:
265
265
  (`createSmoodlyAdmin` binds registry + op action into `AdminPage`/
266
266
  `AdminLayout` for the app's `[[...segments]]` route group;
267
267
  `createAdminOpHandler` is the one server action the client ops call),
268
- and `smoodly/bridge` (`EditorBridge`, the only piece that ships to the
269
- SITE side — runs inside the draft-route iframe, reports node, zone and
268
+ and `EditorBridge` (never on a live page: the renderer loads it lazily
269
+ through `next/canvas-slot.tsx` in editor mode only, spec 2026-09-29
270
+ pre-install fixes §1 — runs inside the draft-route iframe, reports node, zone and
270
271
  marked-field geometry, forwards select/hover with the field under the
271
272
  pointer, executes refresh/scroll, reports each committed render). Shell
272
273
  (registry-derived nav, list views, its own client-side router —
273
- `shell/router.ts`, `RouteLink`, a leave guard for dirty forms; the
274
+ `shell/router.ts`, `RouteLink`, a tab-close guard for pending saves; the
274
275
  shell mounts from the layout and the catch-all page only makes the
275
276
  URL exist, spec 2026-09-08),
276
277
  entry form (widgets by field type, validation errors surfaced inline,
@@ -295,7 +296,8 @@ Implemented:
295
296
  `entries.addLocale`/`removeLocale`.
296
297
  `pages.list` materializes the code-owned root nodes before an editor
297
298
  touches them (`admin/fixed-nodes.ts` derives fixed pages and collection
298
- mounts from the registry, never from a column), and every guard asks
299
+ mounts from the registry and finds their records by the `pages.fixed`
300
+ owner key, schema v5), and every guard asks
299
301
  the same `fixedNodeOf`: a fixed node is never renamed, moved or
300
302
  deleted, and no page is created under a mount, and a `homePageSlug`
301
303
  guard keeps the site root from being deleted, moved or re-slugged
@@ -601,12 +603,63 @@ Implemented:
601
603
  `cli-transfer`, `space-transfer.supabase`,
602
604
  `examples/site/e2e/transfer.spec.ts`.
603
605
 
606
+ - Nested addresses (spec 2026-09-29 nested paths, schema v5): a
607
+ collection `path` or fixed `slug` may have several parts
608
+ (`company/news`), each checked as a segment, a locale the map omits
609
+ filled from the default. `src/addresses.ts` turns the registry into
610
+ code addresses by owner key (`collection:<name>`, `page:<name>`),
611
+ `config.addresses`; `pages.fixed` names a code-owned record's owner,
612
+ the record stays a root with the last part as its slug, and its path
613
+ comes from the config. Anchored paths (spec 2026-09-29 anchored
614
+ paths): each `paths` row names its anchor (`pages.fixed`'s owner key,
615
+ `''` for the editors' tree) and stores only the editors' part; the
616
+ path index (`src/anchors.ts`, both adapters, `config.anchors`) joins
617
+ the address from the config, so a code change needs no write and the
618
+ live site does one lookup per request. `ensureFixedNodes` finds
619
+ records by owner and creates them over an editor's page, never
620
+ adopting it: code wins the address. The ops refuse a page write onto
621
+ or below a code address naming the owner; the Pages list flags an
622
+ editor's page there and a record no longer in code (delete only).
623
+ Tests: `addresses`, `anchors`, the path index contract,
624
+ `admin-fixed-nodes`, `admin-ops-addresses`, `admin-pages-tree`,
625
+ `site`, the page and entry store contracts,
626
+ `examples/site/e2e/entries.spec.ts`.
627
+
628
+ - Pre-install fixes (spec 2026-09-29 pre-install fixes, schema v6): one
629
+ permissive policy per table and action (the write key reads through
630
+ `smoodly_read`; `smoodly_write` splits into insert, update and delete
631
+ where a public read exists, the media pair too), the missing
632
+ foreign-key and published-version indexes, `smoodly_space_row_cap`
633
+ revoked from anon. A live page runs no Smoodly client code: the canvas
634
+ bridge and the section net load lazily from one stub under 1 KB
635
+ (`next/canvas-slot.tsx`), and `smoodly/bridge` is no longer an export.
636
+ Draft mode shows a preview pill (`next/preview-pill.tsx`: an inline
637
+ Shadow DOM script, bottom centre, movable left/centre/right, hidden
638
+ until reload, Exit preview through `previewRoute`, which every starter
639
+ mounts at `app/api/preview/route.ts`; `previewPill()` for routes the
640
+ renderer does not draw). The page editor, the entry form and the
641
+ Globals form save a pending edit on unmount (`close()` on the autosave
642
+ controller, reopened by the hook's effect for StrictMode; it waits for a
643
+ save in flight and sends the rest with that save's token) and ask only
644
+ before a tab close (`useUnloadGuard`); `useLeaveGuard`'s in-app "Discard"
645
+ confirm has no caller now. Draft mode's pill is absent from History's
646
+ version iframe (`smoodly-version`), whose Exit would end draft mode under
647
+ the editor.
648
+ The cached unit is keyed by (locale, path, kind, id). A new list item's
649
+ required select or radio stores its first option. Every starter sets
650
+ `metadataBase` from `SITE_URL`, which the installer writes. Tests:
651
+ `migration-policies`, `sql`, `canvas-slot`, `site-entries`,
652
+ `next-preview-route`, `next-preview-pill`, `next-page-renderer`,
653
+ `admin-autosave`, `admin-list-helpers`, `env` (installer),
654
+ `e2e/publish.spec.ts`, `e2e/editor.spec.ts`, `e2e/languages.spec.ts`,
655
+ `e2e/site-bundle.spec.ts` (CI-only).
656
+
604
657
  Not yet: canvas drag-and-drop (list fields, the outline, manual
605
658
  collections and the pages tree drag since 2026-09-09 — spec
606
659
  `docs/superpowers/specs/2026-09-09-drag-and-drop-design.md`), undo/redo, sample/placeholder content
607
- marking, per-locale section visibility conditions (OQ #3 — each locale
608
- now has its own tree, so a section's fields are already per locale; what
609
- is open is hiding a section by a locale-dependent condition).
660
+ marking. Per-locale section visibility is not coming: each locale has its
661
+ own tree, so a locale that does not want a section removes it (DESIGN.md
662
+ §3, 2026-09-14).
610
663
 
611
664
  ## Releasing
612
665
 
@@ -627,46 +680,129 @@ after both are on the registry. From the repo root:
627
680
  `prepublishOnly` rebuilds each package. The Claude Code skill
628
681
  `.claude/skills/releasing-smoodly` carries the same order with its checks.
629
682
 
630
- ## Follow-ups (logged 2026-09-28, ASCII slugs spec)
683
+ ## Follow-ups
631
684
 
632
- - Unicode redirect sources: a replaced site's old address may hold `ä`
633
- (`/sää-tänään`). Unchecked whether the catch-all hands `site.locate`
634
- the decoded or the percent-encoded path, so a source typed with `ä`
635
- may never match. Test it with a real old URL; normalize (decode + NFC)
636
- on both sides if it fails.
637
- - Content health does not report an entry slug outside the grammar: the
638
- scan and the markers use drift's `fits` (a slug fits if it is text), and
639
- the grammar is checked only at publish and on a published row's save.
640
- - `f.slug()` under a key other than `slug` is accepted by `collection()`
641
- but never follows its `from` and is not the entry's URL; refuse it, or
642
- say so in the Fields guide, which already says the field must be named
643
- `slug`.
685
+ The code-level debt register, triaged 2026-09-29 into four buckets:
686
+ **Now** (before the first client install), **Next** (hardening soon
687
+ after), **Later** (by area) and **Cloud** (only once Smoodly Cloud
688
+ exists). Each item ends with where it was logged. A fixed item is
689
+ deleted — git history and the specs keep the record — and a new one
690
+ goes straight into its bucket. Design-level questions live in
691
+ `docs/DESIGN.md`'s Open questions, not here.
644
692
 
645
- ## Follow-ups (logged 2026-09-28, export/import spec)
693
+ ### Now
646
694
 
647
- - `import --replace`: delete the space's content and media first, after
648
- typing the space to confirm — a backup restored over a live project.
649
- Mention it in the not-empty refusal when it lands.
650
- - A copy inside one database with fresh IDs — the cloud's "duplicate
651
- environment", staging → production; needs the reference walk (parent
652
- links, version pointers, refs, paths, asset ids in trees, fields and
653
- richtext) the eject avoids by keeping IDs.
654
- - A readable export for leaving Smoodly: one document per page or entry,
655
- fields resolved, no internal IDs.
656
- - A "Download backup" button in the admin: the same export behind an op,
657
- zipped for an editor.
695
+ - SEO for entry pages: entries have no `meta`; a `titleField`-based
696
+ title and a description field are the next step. *(2026-09-10, page fields and SEO)*
697
+ - `where` filters a versioned collection's LIVE row, not its published
698
+ snapshot: an entry whose published version had `kind: "x"` and whose
699
+ draft changed it to `"y"` drops out of `where: { kind: "x" }` before
700
+ the change is published. The published gate is per locale row, the
701
+ filter is on the node's shared fields. Fix by filtering the snapshot
702
+ (a join on the version row) or by documenting that a versioned
703
+ collection's shared fields are live for filtering. *(2026-09-11, queries)*
704
+ - `getEntries`/`getEntriesTree` return refs as ids (cheap lists); `getEntry`
705
+ by path resolves them, `getEntry` by id or slug does not — document or
706
+ unify when a site needs resolved lists. Worse than it reads: the field
707
+ types promise resolved objects everywhere (`f.ref` builds
708
+ `Resolved<T>`), and `guides/collections/queries.mdx` says `getEntry`
709
+ "by id or by its URL path" resolves references, so a developer's code
710
+ typechecks and fails at runtime. Decide the rule and make the types,
711
+ the runtime and the guide agree. *(2026-09-05; raised to Now 2026-09-29)*
712
+ - The read API returns the site layer's shapes unprojected: `getEntries`
713
+ gives `{ entry: EntryRecord, path }` (the locales map, sort, parentId,
714
+ collection and timestamps ride along under `entry`), `getPage` gives
715
+ the renderer's `SitePage` (`record`, `tree`). Project in `queries.ts`,
716
+ at the public boundary, to `{ id, path, fields, updatedAt }` per entry
717
+ and a page without renderer internals; the tree item is already lean.
718
+ Do it before the Astro glue (OQ 29) wraps the same reads and before
719
+ a second site depends on the shape — it is a breaking change to `q`. *(2026-09-11, queries)*
720
+
721
+ ### Next
722
+
723
+ #### Data integrity
724
+
725
+ - Older than this plan: the Supabase `addLocale` rollback on
726
+ `alreadyInLocale` (`supabase-store.ts`, the insertLocale catch)
727
+ deletes the racing WINNER's locale row and versions. Two admins
728
+ adding one locale at once converge on the next list; fix by rolling
729
+ back only what this call inserted. *(2026-09-29, nested collection paths)*
658
730
  - A ready asset whose `poster_id` names a pending asset would fail the
659
731
  export's consistency check on every run (pending assets are not
660
732
  exported). Unverified whether the upload flow can leave that state;
661
- if it can, export the poster or null the pointer with a warning.
662
- - Import cannot expire the app's cache tags; it prints a restart note. A
663
- revalidate call through the app (an op the CLI could reach) would make
664
- the note unnecessary.
665
- - A resumed import that lands nothing still prints the full "Imported …"
666
- summary and the member list — accurate (the space holds all of it) but
667
- noisy; count what actually landed and say "nothing new" when it is zero.
733
+ if it can, export the poster or null the pointer with a warning. *(2026-09-28, export/import)*
734
+ - Publish, discard and restore compare the token, then move the pointer under a
735
+ pointer filter: a save that rewrites the same draft row in place between the
736
+ two is published unseen (one round trip wide). A SQL function would close it. *(2026-09-26, concurrency)*
737
+ - Publish integrity for an entry's own refs: an entry can publish while
738
+ pointing at an unpublished entry; pages are gated, entries are not. *(2026-09-14, collections draft/publish)*
739
+ - Unpublishing a page does not cascade: a published child keeps serving
740
+ under an unpublished parent's path. Decide whether it should. *(2026-09-13, publish flow)*
741
+ - `entries.move` expires the entry and its descendants but does not fan
742
+ out to the pages referencing them, nor to the old and the new parent
743
+ entries the way `pages.move` does — asymmetric with `entries.save` and
744
+ `entries.setStatus`, which both fan out. *(2026-09-06)*
745
+
746
+ #### Multi-statement writes (no transaction on the Supabase adapters)
747
+
748
+ - The Supabase entry adapter's `removeLocale` deletes the row and its
749
+ versions before the paths rewrite with no rollback; its residue
750
+ comment describes the opposite case (a surviving path row for a
751
+ removed locale, not a present node without its path row). *(2026-09-06)*
752
+ - The Supabase entry adapter's rollbacks are best-effort and can mask the
753
+ original throw: `create`'s catch runs `paths.remove` before the node
754
+ delete (a failing remove strands the node and replaces the real error),
755
+ `update` and `move` restore by writing, and those writes can throw in
756
+ place of the rewrite's rejection. `recordVersion` is two statements
757
+ (insert, then point at it), so a failure between them leaves an
758
+ unreferenced `entry_versions` row — invisible to every policy and
759
+ harmless, but it accumulates. *(2026-09-06)*
760
+ - `move` is best-effort across two statements (reorder, then path rewrite):
761
+ a failed rewrite restores each destination sibling's prior `sort` row by
762
+ row, so a failure in the rollback itself still leaves the order changed. *(2026-09-05)*
763
+ - Rollback gaps that remain: a `smoodly_reorder`/`smoodly_reorder_pages`
764
+ failure AFTER the re-parent update has no rollback at all; the entry
765
+ `create` rollback discards its delete result (an orphan row survives
766
+ silently); and a throw inside any rollback replaces — masks — the original
767
+ error. *(2026-09-05)*
768
+ - `setTemplate`/`saveDraft` insert the version row before the pointer update:
769
+ an infra failure between the two orphans a version. *(2026-09-05)*
770
+
771
+ #### Addresses and code-owned records
772
+
773
+ - `homePageSlug` is compared with a collection's whole default path, so
774
+ `"home/news"` passes boot with the home page as its middle part: the
775
+ home record serves `/`, so `/home` is no page while `/home/news` is.
776
+ Refuse an address whose first part is the home slug if a site trips on
777
+ it. *(2026-09-29, nested collection paths)*
778
+ - An owner key that changes in code — a fixed page that becomes a
779
+ collection's index page (`page:x` → `collection:y`), a collection
780
+ renamed — leaves the old record "No longer in code" with its
781
+ sections and history, and a new empty record takes the address.
782
+ Carry the record over (rewrite `fixed`) when a site asks. *(2026-09-29, nested collection paths)*
783
+ - An index page registration renamed (`a` → `b`) leaves the record on
784
+ template `a`: `ensureFixedNodes` only handles a folder gaining an
785
+ index page, and `setTemplate` refuses a record that has one. *(2026-09-29, nested collection paths)*
786
+
787
+ #### Security
788
+
789
+ - The bridge posts with `"*"` on both sides. Safe today: the admin
790
+ accepts a message only when `e.source` is its own iframe's window, and
791
+ the canvas acts on two kinds, refresh and scroll. The target-origin
792
+ pass belongs to the content-surface sweep (spec §Out of scope). *(2026-09-26, security pass)*
793
+ - The invite's `redirectTo` is a client-supplied string bounded by the
794
+ auth project's redirect allow-list; a same-origin check in the op is
795
+ one line when wanted. *(2026-09-26, security pass)*
796
+ - The content-surface sweep: `f.link` values reach section components
797
+ unfiltered, uploads allow SVG and PDF on a public bucket (SVG served
798
+ with `?download=`), the custom CSS refusal list, richtext hrefs. *(2026-09-26, security pass)*
799
+ - `RichText` drops the anchor of a link mark whose href is not http(s),
800
+ mailto, tel or site-relative (`/`, `#`, `?`, `.`). The href is normalised
801
+ the way URL parsers do (tab, LF and CR removed) before the scheme test;
802
+ `javascript:`, `data:`, protocol-relative `//host` and the backslash form
803
+ `/\host` are rejected. The admin's link field does not validate on input yet. *(2026-09-07, list field)*
668
804
 
669
- ## Follow-ups (logged 2026-09-27, shadowed-page detector spec)
805
+ #### The live site
670
806
 
671
807
  - A section's crash on a live page after data changed outside the page
672
808
  editor (an entry or a global item edited in its form): the guard
@@ -674,1406 +810,814 @@ after both are on the registry. From the repo root:
674
810
  it renders, in a richtext element's view or in a `'use client'`
675
811
  section still fails the page, and no canvas previews those forms.
676
812
  Catching it live needs `<Suspense>` (spec 2026-09-27 section guard,
677
- measured). Revisit if a real site hits it.
813
+ measured). Revisit if a real site hits it. *(2026-09-27, shadowed-page detector)*
814
+ - Publishing a global the site layout reads (the footer) serves the old
815
+ layout on the first public request made right after the publish returns;
816
+ every request after it, and a first request made ~3 s later, is fresh.
817
+ Pages do not show it (an unpublish answers 404 at once). Seen on
818
+ `next dev`; `e2e/globals.spec.ts` polls the home page for it. Find out
819
+ whether `updateTag` over a layout's `getGlobal` read expires late, and
820
+ whether `next start` behaves the same. *(2026-09-26, browser suite)*
821
+ - A robots `Disallow` for the admin path from `next/meta`. *(2026-09-17, admin path)*
822
+ - Scripts and the seed write through the stores and expire nothing; a
823
+ `revalidate` hook for out-of-band writers. *(2026-09-11, queries)*
824
+ - Delivery caching (checked 2026-09-08): Supabase Storage's basic CDN
825
+ fronts every public-bucket object on a hosted project with no code on
826
+ our side, and the Smart CDN (Pro plan, automatic) only adds
827
+ invalidation on overwrite, which never happens — objects are
828
+ immutable, no upsert on either path, Replace picks a new id. So both
829
+ upload paths now set a year-long `cache-control`
830
+ (`MEDIA_CACHE_SECONDS`): the `cacheControl` option on the server-side
831
+ upload, the header on the browser's signed PUT (without it a signed
832
+ upload lands as `no-cache`; Storage's default is one hour). The live
833
+ suite reads the header back from the local stack. Not yet verified
834
+ against a hosted project's CDN edge; check `cf-cache-status` and
835
+ `cache-control` on a real upload there once. Remaining: in v1 the
836
+ site's `<SmoodlyImage>` goes through `next/image`, so the visitor hits
837
+ the app server's image cache and only its origin fetch touches the
838
+ CDN; video, files and SVGs hit the CDN directly. A Supabase Image
839
+ Transformations loader (`/storage/v1/render/image/public/...`, Pro
840
+ only, must stay optional) would move resizing to the CDN — the transform
841
+ loader under Cloud. *(2026-09-08, media library)*
842
+ - `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. *(2026-09-04)*
843
+ - Empty locked zones on the site side: `renderPage` renders nothing for a locked zone with no node. A page that predates the zone shows no prefooter on the published site until an editor opens it (the editor fills and saves). Options: render the component from `sample` when the zone is empty, or fill on `createPage`/schema load server-side. Fine for now — seeded and admin-created pages both arrive filled. *(2026-09-03)*
678
844
 
679
- ## Follow-ups (logged 2026-09-27, editor safety spec)
845
+ #### The editors
680
846
 
681
- - Scan paging: `health.scan` makes one bulk read per table (versions by
682
- id in chunks of 100), and PostgREST's max-rows would cap a very large
683
- space. Page it, and add a rail badge, when a real site's size asks.
684
- - Refs inside unknown nodes are not indexed: without a schema nothing
685
- says which values are refs, so safe-delete cannot see them. Publish
686
- refuses such nodes and a live one renders nothing.
687
- - Leftover shared values after `.localized()` is turned on stay on the
688
- entry as every locale's fallback; removing them for good waits on
689
- the fix tooling (OQ 35). `localization-on` is reported only where a
690
- locale has no value of its own, so a fully translated entry no
691
- longer lists it.
692
847
  - A visual global item's settings misfits (`settings.x`) don't reach
693
848
  their widgets in the Globals form: `GlobalFields` keys errors by the
694
849
  bare field key, the same gap the validator's refusal keys had before.
695
- The notice and the refusal list still name them.
696
- - A CLI drift report was left out (the admin already has the config and
697
- the database; a CLI needs the service key and a TS config loader).
850
+ The notice and the refusal list still name them. *(2026-09-27, editor safety)*
851
+ - A `refList` on a global item's `settings` still loads no options in
852
+ the Globals form or the page editor: both collect targets from the
853
+ item's `fields` only. *(2026-09-17, reference picker and tags)*
854
+ - A validation failure on a shared save retries with backoff like a
855
+ transport failure; the field error shows on the widget meanwhile. A
856
+ distinct "fix the field" state would stop the retries. *(2026-09-13, shared inline editing)*
857
+ - Escape inside any inspector control bubbles to the editor's window
858
+ handler and clears the block selection — the link popover stops it, a
859
+ textarea or the TipTap content does not. A `keydown` guard on
860
+ `.sm-inspector` would fix it for every control. *(2026-09-08, richtext widget)*
861
+
862
+ #### Admin
863
+
864
+ - Left open by the pre-install fixes' final review (2026-09-29): the pill
865
+ has `href="#"` on Exit and no keyboard way to move it; it appears after
866
+ a soft navigation from a non-Smoodly route only once the page reloads
867
+ (React never runs a script it creates on the client); a remounted view's
868
+ `pages.get` can read the draft before the outgoing view's `close()` save
869
+ commits (the next edit then meets `StaleBanner`); an in-flight save that
870
+ fails after `close()` is not retried; a required `radio` with no value
871
+ shows no pill pressed while the list item now stores the first option,
872
+ and `nodeFromSample` seeds a required `select` as `""`; `useLeaveGuard`
873
+ has no caller left; `examples/site/.env.local.example` lacks `SITE_URL`.
874
+ *(2026-09-29, pre-install fixes)*
875
+ - `admin/ui/theme.tsx` pulls Public Sans from Google Fonts with an `@import`.
876
+ Fine today; a self-hosted font (or `next/font` in the glue layer) removes the
877
+ third-party request and the flash before it loads. *(2026-09-05)*
698
878
 
699
- ## Follow-ups (logged 2026-09-26, redirects spec)
879
+ #### Performance
700
880
 
701
- - Destinations outside Smoodly, extension and query-string addresses,
702
- code-owned renames (a collection `path`, a fixed slug): the guide's
703
- `next.config` recipe; a `redirects` table is OQ 34.
704
- - The content API's `getPage("/old")` returns null for a redirect row
705
- (spec §7); a `follow` option is a one-line addition if a site asks.
706
- - A redirect row has no audit columns (who, when); add them with a
707
- need. The Redirects page is not paged.
708
- - The browser suite covers rename, the unticked box, no chains, the
709
- Settings tab's list and the manual add/remove; a parent's MOVE
710
- redirecting a child's URL is proven at the store contract and ops
711
- level only (no tree drag in the suite yet).
712
- - `remove` on the Supabase index deletes by target, one statement per
713
- target (the RPC now spares redirect rows); fine at subtree sizes.
881
+ - `pages.get` makes four sequential store round trips for a non-root page
882
+ (`getPage`, `getDraftTree`, `pathsOf`, and the parent read behind
883
+ `parentLocales`; three for a root, which has no parent to read), and the
884
+ editor calls it on load and after every rename — one store call
885
+ returning them all would cut the editor's latency. *(2026-09-06)*
714
886
 
715
- ## Follow-ups (logged 2026-09-26, security pass)
887
+ #### Code health
716
888
 
717
- - The bridge posts with `"*"` on both sides. Safe today: the admin
718
- accepts a message only when `e.source` is its own iframe's window, and
719
- the canvas acts on two kinds, refresh and scroll. The target-origin
720
- pass belongs to the content-surface sweep (spec §Out of scope).
721
- - The invite's `redirectTo` is a client-supplied string bounded by the
722
- auth project's redirect allow-list; a same-origin check in the op is
723
- one line when wanted.
724
- - Every op call with a junk token costs one `auth.getUser` against the
725
- auth project; rate limiting is the session-model spec's (OQ 20).
726
- - The content-surface sweep: `f.link` values reach section components
727
- unfiltered, uploads allow SVG and PDF on a public bucket (SVG served
728
- with `?download=`), the custom CSS refusal list, richtext hrefs.
729
- - A refused private-channel join surfaces as `CHANNEL_ERROR` (about 80
730
- ms on the local stack); the suite asserts "not SUBSCRIBED".
731
- - The isolation suite's `join` helper still uses a bare
732
- `realtime.setAuth(key)`: its positive cases resolve on the first
733
- SUBSCRIBED, before realtime-js re-asks the default `accessToken`
734
- callback and the server closes the channel. Fine for proving the
735
- policy; a client that must stay joined passes `accessToken` (what
736
- `connectRealtime` does since 3ed8931).
737
-
738
- ## Follow-ups (logged 2026-09-26, concurrency spec)
739
-
740
- - A hard lock with takeover, if a real team asks: presence has the facts.
741
- - Presence on the members and media screens; today only pages, entries and
742
- global items track.
743
- - Postgres Changes for live list refresh; the lists still load on navigation.
744
- - Field-level merging of two editors' changes; the guard refuses whole documents.
745
- - Publish, discard and restore compare the token, then move the pointer under a
746
- pointer filter: a save that rewrites the same draft row in place between the
747
- two is published unseen (one round trip wide). A SQL function would close it.
748
- - Publish leaves the token unchanged (the pointer and its saved time do not move),
749
- so a publish with a token from before another person's publish of the same
750
- draft succeeds — the content is the same. Recorded, not a bug.
751
- - The read client over the read key (DESIGN.md OQ 31) can reuse `publicContent()`.
752
- - A dropped socket clears presence on Realtime's timeout (tens of seconds); the
753
- lock outlives a closed tab by that much.
754
- - The live suites wipe `members` and the content tables; a pane walk after
755
- them needs `npm run reseed` (or `scripts/demo-editor.ts`) first.
889
+ - `classify` in `ops-impl.ts` is ~20 order-sensitive substring checks over
890
+ store messages — `"already exists in locale"` must precede
891
+ `"already exists"`, or an addLocale conflict reads as a slug conflict. A
892
+ typed code on `pageErrors` (thrown and matched, not re-parsed) would
893
+ remove the ordering hazard before plan 2 adds the entry messages. *(2026-09-06)*
756
894
 
757
- ## Follow-ups (logged 2026-09-26, browser suite)
895
+ #### Tests and CI
758
896
 
897
+ - The blank-intl starter is typechecked, not walked: the first tester-agent
898
+ mission (each tester site on its own local Supabase stack, two at a time),
899
+ after the 0.0.35 release. *(2026-09-15, locale alternates)*
759
900
  - Drag and drop has no browser test (spec §4): the outline, the pages
760
901
  tree and list rows reorder by real pointer drags; the first plan that
761
- touches `useSortable` adds them.
902
+ touches `useSortable` adds them. *(2026-09-26, browser suite)*
762
903
  - The suite runs Chromium only; the admin's WebKit and Firefox
763
- behaviour is unverified.
764
- - Three `window.confirm` calls remain outside the confirm dialog (the
765
- router's leave guard, `ui/confirm.ts`'s fallback) and are not exercised.
766
- - The media test uploads a starter photo under a suffixed name rather
767
- than a fixture file, so `e2e/fixtures/` from the spec does not exist.
768
- - ListView rows that are not clickable (Members) carry no role; the
769
- members test finds its row by `.sm-table__body > div`. A table role
770
- set (`table`/`row`/`cell`) on ListView would give every list a proper
771
- locator.
772
- - Publishing a global the site layout reads (the footer) serves the old
773
- layout on the first public request made right after the publish returns;
774
- every request after it, and a first request made ~3 s later, is fresh.
775
- Pages do not show it (an unpublish answers 404 at once). Seen on
776
- `next dev`; `e2e/globals.spec.ts` polls the home page for it. Find out
777
- whether `updateTag` over a layout's `getGlobal` read expires late, and
778
- whether `next start` behaves the same.
779
- - Fixed in the same session: an inserted section seeded an unsampled
780
- optional object as `{}`, which publish then validated as present — an
781
- untouched Image and text `link` refused the publish. The insert now
782
- leaves it absent and validation reads a blank optional object as absent.
783
-
784
- ## Follow-ups (logged 2026-09-26, schema migrations spec)
785
-
786
- - Existing databases (every install so far is ours; spec 2026-09-26 §7):
787
- a database created before version 1 has the schema but not the table.
788
- One time, in the SQL editor or psql:
789
- `create table if not exists "smoodly_meta" ("key" text primary key, "value" text not null);`
790
- `alter table "smoodly_meta" enable row level security;`
791
- `insert into "smoodly_meta" ("key", "value") values ('schema_version', '1') on conflict ("key") do update set "value" = excluded."value";`
792
- then re-apply the `smoodly_read` policy on `smoodly_meta` from
793
- `migrations/20260926120000_smoodly_v1.sql` (a `drop … if exists` /
794
- `create policy` pair). The hand-applied statement lists below stay as
795
- history; from version 2 on, every change is a file.
796
- - Drafting version N+1: `npx tsx scripts/emit-migration.ts` prints the
797
- whole schema plus the upsert; keep only the delta, name the file with
798
- the current UTC stamp and `_smoodly_v<N+1>.sql`, bump `SCHEMA_VERSION`,
799
- run `npm run db:migrate` at the root so the repo copy follows, and
800
- `npm run check:migrations`. Additive only; a contraction waits for the
801
- compatibility window and carries `-- smoodly:contraction`.
802
- - The views of a removed collection stay (spec 2026-09-26 §3); a
803
- `drop view` is the developer's.
804
- - `smoodly migrate` never pushes; a `--push` flag if the two-step
805
- proves annoying.
806
- - The CLI loads `smoodly/collections.ts` through tsx; a project that
807
- registers collections elsewhere passes `--collections <file>`.
808
- - The site's one warning fires from the Supabase page store's first
809
- `resolvePath`, once per store instance; a site that never locates (a
810
- pure `q` reader) never warns, and a long-running server that was
811
- current when it booted stays quiet — the admin notice is the real
812
- signal.
813
- - A Deploying guide (nav lists it, unwritten) should show
814
- `npx smoodly migrate --check` as a build step.
904
+ behaviour is unverified. *(2026-09-26, browser suite)*
905
+ - The seed's early exit probes `/` alone, and `createPage` writes the path
906
+ row before the tree is published, so a crash between the home record and
907
+ the last page leaves a database every later run reports as "already
908
+ seeded"; recovery is `supabase db reset`. A probe on the last thing the
909
+ seed writes, or a per-step check, would make it resumable. *(2026-09-07, starter)*
910
+ - The package root (`src/index.ts`) must stay free of Node builtins:
911
+ every scaffolded section does `import { f, smoodly } from "smoodly"`,
912
+ and a `"use client"` section's browser build would fail on one.
913
+ `sharedEntryId` (`src/shared-id.ts`) moved off `node:crypto` onto Web
914
+ Crypto for this reason (fix pass, 2026-09-07). Nothing tests the
915
+ invariant itself — a future addition that reaches for `node:fs`,
916
+ `node:crypto`, etc. from a module reachable through the root would
917
+ regress it silently. *(2026-09-07, shared content)*
918
+ - The single-language branch — no locale selector in the Pages list, no
919
+ switcher, no add/remove — has no automated coverage; only three sites
920
+ branch on the locale count (`PagesList.tsx`, `EditorView.tsx` and
921
+ `page-renderer.tsx`'s alternates), so a regression there shows up only
922
+ in a scaffolded single-language app. *(2026-09-06)*
923
+ - 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. — Partly resolved 2026-09-26: the `browser` job builds the example site and drives it, but with a database; the no-database build is still unscripted. *(2026-09-03)*
924
+
925
+ ### Later
926
+
927
+ #### Page editor and inline editing
928
+
929
+ - The page editor's publish banner lists fields as plain text; the forms'
930
+ lines focus the widget. Selecting the node from a `node.field` path is
931
+ the missing piece. *(2026-09-14, collections draft/publish)*
932
+ - Richtext instant write from the inspector: the pure `RichText` render
933
+ swapped into the container while the widget is typed in. *(2026-09-10, inline editing)*
934
+ - Item-level chrome in the canvas ("Key points · 3 of 4" on hover, add,
935
+ remove, reorder in place, later drag within a list): the item marks are
936
+ the hook. *(2026-09-10, inline editing)*
937
+ - Placeholder and validation marks on the page (the sample-sourced
938
+ placeholder treatment, "required, empty" outlines) wait for the tree's
939
+ placeholder marking. *(2026-09-10, inline editing)*
940
+ - A coverage hint for developers: the admin knows a section's text fields
941
+ and could list the ones without a mark. *(2026-09-10, inline editing)*
942
+ - AI verbs ("rewrite this heading") need a field under the cursor, which
943
+ a mark is. *(2026-09-10, inline editing)*
944
+ - Direct-child spacing rules on a richtext container (Tailwind `space-y-*`,
945
+ `.prose > p`) do not reach the canvas editor's blocks during a session;
946
+ flex and grid gaps are copied (onto the ProseMirror root since the
947
+ walk, 69aecc6). Documented, not fixed. *(2026-09-10, inline editing)*
948
+ - The floating toolbar flips below the caret only near the top of the
949
+ DOCUMENT (where the page box clips it); it scrolls with the content, so
950
+ a caret at the top of the scrolled canvas has its toolbar out of view
951
+ until the editor scrolls. A flip keyed to the scroller's viewport would
952
+ fix it. *(2026-09-10, inline editing)*
953
+ - No starter section gives the canvas a richtext with blocks: the Image
954
+ and text body is marks-only, and the article body (the `article`
955
+ toolset) is edited in the entry form. The walk borrowed the article
956
+ toolset for the block steps; a starter section with a long-form body
957
+ would let the canvas show them. *(2026-09-10, inline editing)*
958
+ - The caret placed right after an existing `/` reopens the slash menu
959
+ (TipTap's suggestion re-triggers on the character before the caret).
960
+ Harmless; a trigger keyed to typing only would be stricter. *(2026-09-10, inline editing)*
815
961
 
816
- ## Follow-ups (logged 2026-09-17, admin path spec)
962
+ #### Richtext and elements
817
963
 
818
- - A robots `Disallow` for the admin path from `next/meta`.
964
+ - Image sizing and float in a body (full width, wide, small, left/right):
965
+ a later additive `styles` on the image node; the site's CSS answers it. *(2026-09-08, block nodes)*
966
+ - Inline elements: `inline: true` on the registration; cursor, marks and
967
+ selection around an inline atom are a separate node-view effort. *(2026-09-08, block nodes)*
968
+ - The column context (`contexts: ["column"]`) waits for Phase 3 rows;
969
+ accepted and unused today. *(2026-09-08, block nodes)*
970
+ - Element copy between fields: a pasted element the target toolset
971
+ forbids is dropped silently; a notice would be kinder. *(2026-09-08, block nodes)*
972
+ - `hasContent` treats an unresolved image node as content, so a body
973
+ whose only image was deleted from the library still passes "required". *(2026-09-08, block nodes)*
974
+ - The "+" menu and the slash menu share `RichtextInsertMenu` but keep
975
+ separate active-row state and key handling; one keyboard model would
976
+ be smaller. *(2026-09-08, block nodes)*
977
+ - A page and entry picker in the link popover: a link inside a richtext
978
+ document is a reference the refs index, the resolver and the localizer
979
+ cannot see (they address refs by top-level key). Its own spec. *(2026-09-08, richtext widget)*
980
+ - Every richtext descriptor now carries its toolset, so the serialized
981
+ registry grew by a few bytes per field; if the AI-facing schema wants
982
+ the vocabulary once instead of per field, dedupe there. *(2026-09-08, richtext widget)*
819
983
 
820
- ## Follow-ups (logged 2026-09-17, reference picker and tags spec)
984
+ #### Forms, fields and pickers
821
985
 
822
986
  - A `Create…` button in the reference picker: create the target entry
823
987
  without leaving the form. Needs its own answer for modal vs navigation
824
- and for the parent form's pending autosave.
825
- - Server search for ref candidates: `useRefOptions` loads the whole
826
- target collection once per locale. When a collection outgrows that,
827
- the hook changes (a search op, plus "titles for these ids" for the
828
- picked rows); `RefPicker` takes options as a prop and does not.
988
+ and for the parent form's pending autosave. *(2026-09-17, reference picker and tags)*
829
989
  - Thumbnails in picker rows: collections have no preview-image
830
- convention (`titleField` only).
831
- - `entries.distinct` flattens in JS over `entries.list`; an RPC over
832
- `jsonb_array_elements` when a collection outgrows it.
833
- - Tags keep insertion order; no drag reorder.
990
+ convention (`titleField` only). *(2026-09-17, reference picker and tags)*
991
+ - Tags keep insertion order; no drag reorder. *(2026-09-17, reference picker and tags)*
834
992
  - Tag suggestions exist on collection entry forms only, and only for
835
993
  top-level `f.tags()` fields: a tags field inside an object, on a
836
- section, a global or a page is a plain chip input.
994
+ section, a global or a page is a plain chip input. *(2026-09-17, reference picker and tags)*
837
995
  - The picker's "open" link goes to `/admin/<collection>/<id>` without
838
- the edited locale; the form opens in the URL's or the default locale.
996
+ the edited locale; the form opens in the URL's or the default locale. *(2026-09-17, reference picker and tags)*
839
997
  - The Combobox list is portaled into the admin wrapper and fixed to the
840
998
  viewport (2026-09-27), so a modal body or scrolling form no longer
841
999
  clips it; it flips upwards when the viewport has no room under the
842
1000
  input (`listPlacement`) and follows the input on scroll. It does not
843
1001
  yet shrink when neither side has room for it. A single select (a
844
1002
  single `f.ref`, the redirect target) shows its pick inside the input;
845
- a single ref's status, open link and remove sit in the input too.
846
- - `RefOption.status` is the three-way `LocaleStatus` now; the old
847
- "(draft)" suffix in option titles is gone with the native select.
848
- - A `refList` on a global item's `settings` still loads no options in
849
- the Globals form or the page editor: both collect targets from the
850
- item's `fields` only.
851
-
852
- ## Follow-ups (logged 2026-09-15, locale alternates spec)
853
-
854
- - A layout-only language switch, if a real site needs one: a
855
- `q().getAlternates(path)` over the `paths` index plus a proxy header
856
- carrying the request path. Not built until asked for — the starters
857
- render the switch from the page (`components/PageFrame.tsx`,
858
- `components/LocaleNav.tsx`).
859
- - The walk covered the Lorem example only; blank-intl is typechecked, not
860
- walked. Walk it on the next blank-intl install.
1003
+ a single ref's status, open link and remove sit in the input too. *(2026-09-17, reference picker and tags)*
1004
+ - A create dialog (title and slug) before "+ New entry", if empty Untitled
1005
+ drafts turn out to litter lists. *(2026-09-14, collections draft/publish)*
1006
+ - `f.date()` and `f.datetime()` share one in-house calendar
1007
+ (`admin/forms/DatePicker.tsx` over the pure `date-picker.ts`); the
1008
+ browsers' own pickers were rejected (they differ and take no tokens).
1009
+ Weeks start on Monday and month names come from the browser's language
1010
+ — `weekStart` and a month-name locale as config keys are follow-ups. *(2026-09-25, date pickers)*
1011
+ - Not built: a time-only field, a date range, `.min` / `.max` on dates. *(2026-09-25, date pickers)*
1012
+ - Refs inside lists and objects are refused at build time (`f.list`,
1013
+ `f.object`). A list of objects each holding a ref (a featured entry with
1014
+ its own caption) needs the resolver, the refs index and the admin's
1015
+ ref-option loading to address nested paths; add when a site asks. *(2026-09-07, list field)*
1016
+ - `emptyValue` for an `image`/`video`/`file` item is `undefined`, so a new
1017
+ media row starts empty; a required image item is then a validation
1018
+ error until filled, which is the intended nudge but reads as an error
1019
+ on a row the editor just added. *(2026-09-07, list field)*
1020
+ - A list item's subtree still uses the row index as its React key, so a list
1021
+ of objects each holding a list hands the inner rows another item's collapse
1022
+ state when an outer row moves; fix with a stable per-item key when nested
1023
+ lists get real use. *(2026-09-07, list field)*
861
1024
 
862
- ## Follow-ups (logged 2026-09-15, custom section spec §6, plan B)
1025
+ #### Custom sections and section settings
863
1026
 
864
1027
  - Inline editing of a column on the canvas is OFF (2026-09-15, after the
865
1028
  first day's use): a click on a column reveals its editor in the
866
1029
  inspector (`onFieldClick` in `EditorView.tsx` skips the session for the
867
1030
  `custom` type). The marks, paths and session code are in place and
868
- tested; turn it on once a walk on a real device passes.
869
- - A canvas drag on column edges (today the bar in the inspector).
870
- - A background for a custom section from a config palette.
871
- - Elements as direct column children (today through richtext's menus).
872
- - A per-zone cap on columns (`custom: { columns: 2 }` or similar).
873
- - `--container` is a soft convention: the rows read
874
- `var(--smd-width, var(--container, none))` and `var(--smd-gutter, 1rem)`;
875
- a project without those variables gets full-width rows with a 1rem
876
- gutter. Documented, not enforced.
877
- - Presets exist for 2, 3 and 4 columns; 5 and 6 have the bar only.
878
- - Rows reorder with the ↑/↓ buttons only; no grip, no keyboard drag.
1031
+ tested; turn it on once a walk on a real device passes. *(2026-09-15, custom section)*
1032
+ - A canvas drag on column edges (today the bar in the inspector). *(2026-09-15, custom section)*
1033
+ - A background for a custom section from a config palette. *(2026-09-15, custom section)*
1034
+ - Elements as direct column children (today through richtext's menus). *(2026-09-15, custom section)*
1035
+ - A per-zone cap on columns (`custom: { columns: 2 }` or similar). *(2026-09-15, custom section)*
1036
+ - Presets exist for 2, 3 and 4 columns; 5 and 6 have the bar only. *(2026-09-15, custom section)*
1037
+ - Rows reorder with the ↑/↓ buttons only; no grip, no keyboard drag. *(2026-09-15, custom section)*
879
1038
  - An empty column has no box on the live page (an empty paragraph
880
1039
  renders 0px tall), so it cannot be clicked into from the canvas; the
881
1040
  inspector's `Column N` editor is the way in. A `min-height` in editor
882
- mode would give it a target.
1041
+ mode would give it a target. *(2026-09-15, custom section)*
883
1042
  - An empty column publishes (its richtext descriptor is `optional`);
884
- whether publish should refuse a row of only empty columns is open.
1043
+ whether publish should refuse a row of only empty columns is open. *(2026-09-15, custom section)*
885
1044
  - Growing a row copies the LAST span before renormalizing, so `[5, 7]` →
886
1045
  `[3, 5, 4]`, not thirds; a preset is one click away. Equal thirds on
887
- grow would be the other reasonable rule.
888
- - The picker's click cannot be unit-tested (no DOM in the suite); the
889
- walk covers it. A jsdom environment for the admin tests is a future
890
- tooling item.
891
- - No migration: the `rows: true` zone flag became `custom: true` and
892
- `config.rows` is gone; no stored data changed shape.
893
- - A column carries no project class: the starters extend their `.prose`
894
- rules to `.smd-col` so headings, figures and callouts inside a column
895
- read like body text. A project on its own stylesheet does the same, or
896
- targets `[data-custom]`. A `columnClass` in `config.custom` would
897
- remove that step.
898
-
899
- ## Follow-ups (logged 2026-09-15, section settings spec, plan A)
900
-
901
- Upgrade note: stored trees, global entries and element nodes changed
902
- shape (`styles` → `settings`, `meta` gone, the `global` node type and
903
- `global:` ids, `$settings`/`$styles`/`$css` on global rows). No migration
904
- tool — reset and reseed a development database (`supabase db reset`,
905
- `npm run seed`); no installs predate the change.
906
-
1046
+ grow would be the other reasonable rule. *(2026-09-15, custom section)*
907
1047
  - A class-mapping API for projects that want the wrapper to emit
908
- classes instead of inline values (DESIGN.md OQ 13).
909
- - `f.global()` — a ref field on a section that renders a global item.
1048
+ classes instead of inline values (DESIGN.md OQ 13). *(2026-09-15, section settings)*
1049
+ - `f.global()` — a ref field on a section that renders a global item. *(2026-09-15, section settings)*
910
1050
  - An AI helper filling the CSS box from a prompt (the box stores CSS,
911
- never a prompt, so the helper only fills it).
912
- - `and` / `or` conditions; today one `f.when()` per field.
1051
+ never a prompt, so the helper only fills it). *(2026-09-15, section settings)*
1052
+ - `and` / `or` conditions; today one `f.when()` per field. *(2026-09-15, section settings)*
913
1053
  - A condition inside a list item can read the item's siblings and the
914
1054
  top-level namespaces, not another item; per-item scoped `settings.`
915
- references inside lists are not a thing yet.
1055
+ references inside lists are not a thing yet. *(2026-09-15, section settings)*
916
1056
  - An element's settings cannot be hidden by the enclosing section's
917
1057
  settings: an element card's scope is the element's own fields and
918
- settings.
1058
+ settings. *(2026-09-15, section settings)*
919
1059
  - A hidden field's refs still count as usage in the refs index (its value
920
- is kept, so the edge is real; whether usage should hide with it is open).
921
- - The css refusal list grew past the spec's verbatim six in a security
922
- review of the wrapper: the regexes run on the escape-decoded snippet too
923
- (`\75 rl(` cannot spell `url(`) and unbalanced braces are refused as
924
- `Braces must balance.` (a `}` cannot step out of the `[data-smd-node]`
925
- block). Recorded here and in DESIGN.md.
926
- - The starters' `.prose figure img` is now inline-block without
927
- `width: 100%`, so a richtext image narrower than the column follows the
928
- section's `align` step; a photo wider than the column still fills it.
929
- - The Globals page has no tab row: Content's groups, then a `Settings`
930
- label and the Settings groups, stacked. A tab row there would match the
931
- canvas card.
932
- - `InspectorFields` filters hidden entries per render; a section with
933
- many conditions re-evaluates every keystroke. Fine at the sizes seen.
934
-
935
- ## Follow-ups (logged 2026-09-14, collections draft/publish spec)
936
-
937
- - Publish integrity for an entry's own refs: an entry can publish while
938
- pointing at an unpublished entry; pages are gated, entries are not.
939
- - A create dialog (title and slug) before "+ New entry", if empty Untitled
940
- drafts turn out to litter lists.
941
- - The page editor's publish banner lists fields as plain text; the forms'
942
- lines focus the widget. Selecting the node from a `node.field` path is
943
- the missing piece.
944
- - Discard on a published row from before versioning that nobody has
945
- written since is refused as "nothing published" (its pointers are null);
946
- the control never offers it there, so nothing shows — the store could
947
- answer it as a no-op instead.
948
-
949
- ## Follow-ups (logged 2026-09-25, settings + members spec)
950
-
951
- - Existing databases (every install so far is ours; DESIGN.md OQ #2):
952
- `alter table "editors" rename to "members";`
953
- `alter table "members" add column "email" text;`
954
- `alter table "space_caps" rename to "spaces";`
955
- `alter table "spaces" alter column "row_cap" drop not null;`
956
- `alter table "spaces" add column "console_url" text;`
957
- `update "members" set "role" = 'admin' where "role" = 'owner';`
958
- then re-run the regenerated migration's `smoodly_space_row_cap` function
959
- and the `smoodly_read` policy on `spaces` (both `create or replace` /
960
- `drop … if exists`, so the whole migration file can be re-applied).
961
- Policies and triggers follow a table rename. Rows written before the
962
- column show their user id on the Members page until re-inserted (the
963
- demo-editor upsert fills it on the next seed).
964
- - Members management shipped 2026-09-25 (spec
965
- `docs/superpowers/specs/2026-09-25-members-management-design.md`); out
966
- of its scope: a pending-invite status and "resend invite" on a row
967
- (needs the Auth user's `last_sign_in_at`, which the cloud projection
968
- does not carry); removing the Auth account together with the member
969
- row; invites from the cloud's `/admin` (they are smoodly.io's); a
970
- project whose Auth is OAuth-only (the invite mail asks for a password);
971
- redirects on a published rename (the confirm says "there are none
972
- yet").
973
- - 2026-09-26: the Role column is a badge and the row menu has Change role
974
- (confirm, to the other role) and Remove member; `ActionMenu` popovers are
975
- fixed-positioned from the button — the cell's overflow clipped them.
976
- - The members list is not paged (tens of rows, not thousands).
977
- - The Roles and API Keys pages were dropped 2026-09-25 (DESIGN.md §2
978
- "Settings"): Members is the only Settings page, the invite modal
979
- describes the two roles, and `/settings/roles` and `/settings/api-keys`
980
- are Not found.
981
-
982
- ## Follow-ups (logged 2026-09-13, history spec)
983
-
984
- - Existing databases (every install so far is ours; DESIGN.md OQ #2):
985
- `alter table "page_locales" add column "updated_by" text;`
986
- `alter table "entry_locales" add column "updated_by" text;`
987
- `alter table "page_versions" rename column "created_at" to "saved_at";`
988
- `alter table "page_versions" drop column "created_by";`
989
- `alter table "entry_versions" rename column "created_at" to "saved_at";`
990
- `alter table "entry_versions" drop column "created_by";`
991
- The indexes follow the rename. Autosave rows from before read as
992
- extra history rows until the page is published again; harmless.
993
- Since 2026-09-14 every collection is versioned and `collectionSQL` emits one
994
- view shape: re-apply `collectionSQL(collection)` for each collection on an
995
- existing install (`supabase db reset` locally), or a published row from
996
- before versioning reads through the view's live-row fallback until its
997
- first write gives it a snapshot — either way nothing goes dark.
998
- - Client-side undo in the editor (a stack of tree snapshots); the store
999
- is no longer an undo stack by design.
1000
- - The author per version row and in the lists; named versions.
1001
- - History on the canvas card's menu and in the list row menus.
1002
- - A diff between two versions.
1003
- - Ops are built per call (`createAdminOps` inside the handler) so the
1004
- session rides in as `actor`; if that ever measures, memoize the
1005
- actor-free parts.
1060
+ is kept, so the edge is real; whether usage should hide with it is open). *(2026-09-15, section settings)*
1006
1061
 
1007
- ## Follow-ups (logged 2026-09-13, publish flow spec)
1008
-
1009
- - Unpublishing a page does not cascade: a published child keeps serving
1010
- under an unpublished parent's path. Decide whether it should.
1011
- - Versioning collections by default — decided 2026-09-14: every
1012
- collection, no flag, the forms autosave (spec
1013
- `docs/superpowers/specs/2026-09-14-collections-draft-publish-design.md`).
1014
- - A draft-mode banner on the site, or an "exit preview" action in the
1015
- admin: the same browser shows drafts of unpublished items after an
1016
- Unpublish, which reads as "it published my draft".
1017
- - The lists' row menus (Pages, Entries) do not offer Unpublish; the
1018
- document's own control does.
1019
-
1020
- ## Follow-ups (logged 2026-09-13, shared inline editing spec)
1062
+ #### Globals
1021
1063
 
1022
1064
  - Layout items in the outline: a "From the layout" group the bridge could
1023
- populate from the `shared:` ids it reports; canvas-only today.
1024
- - The page autosave loses an edit made within the debounce window before
1025
- navigating away (`useDraftAutosave` only clears timers on unmount). The
1026
- shared controller flushes on unmount; the page's should too.
1027
- - A validation failure on a shared save retries with backoff like a
1028
- transport failure; the field error shows on the widget meanwhile. A
1029
- distinct "fix the field" state would stop the retries.
1065
+ populate from the `shared:` ids it reports; canvas-only today. *(2026-09-13, shared inline editing)*
1030
1066
  - The shared card's usage line lists page ids; titles need a pages
1031
- lookup the editor does not hold (the usage rows carry only ids).
1067
+ lookup the editor does not hold (the usage rows carry only ids). *(2026-09-13, shared inline editing)*
1032
1068
  - A "save and publish" action for sites that want no draft stage on a
1033
1069
  shared item (every item is versioned since 2026-09-13; the flag is
1034
- gone).
1035
-
1036
- ## Follow-ups (logged 2026-09-11, queries spec)
1037
-
1038
- - `where` on localized fields (a join on the locale row), ranges and
1039
- negation; any-of over an array-valued field (any-of compares the text
1040
- form of the field, so it covers selects, refs and numbers; on a
1041
- checkboxes, refList or list field `site.getEntries` refuses it rather
1042
- than match nothing — both adapters agree on the text form, so a
1043
- one-element array never matches its element).
1044
- - `where` filters a versioned collection's LIVE row, not its published
1045
- snapshot: an entry whose published version had `kind: "x"` and whose
1046
- draft changed it to `"y"` drops out of `where: { kind: "x" }` before
1047
- the change is published. The published gate is per locale row, the
1048
- filter is on the node's shared fields. Fix by filtering the snapshot
1049
- (a join on the version row) or by documenting that a versioned
1050
- collection's shared fields are live for filtering.
1051
- - A GIN index on `entries.fields` when a customer's collection is large
1052
- enough to need it; additive, no migration.
1053
- - OQ 26: whether page units should carry `pages:tree` too, replacing
1054
- the neighbour list.
1055
- - Scripts and the seed write through the stores and expire nothing; a
1056
- `revalidate` hook for out-of-band writers.
1057
- - `getEntriesTree` has no `q` counterpart yet; add one when a site
1058
- needs a nested collection nav.
1059
- - `getShared(["header", "footer", "contact"], { locale })` returning a
1060
- typed tuple, as sugar over the `Promise.all` the layout writes today.
1061
- Keep one cache unit per item under its own tag — a single unit tagged
1062
- with all three would expire the header whenever the footer is edited.
1063
- - `getEntry` by id goes through `site.getEntry`, which lists the whole
1064
- collection to find one row; a primary-key read first would be cheaper
1065
- on the uncached path (draft mode, first miss).
1066
- - The read API returns the site layer's shapes unprojected: `getEntries`
1067
- gives `{ entry: EntryRecord, path }` (the locales map, sort, parentId,
1068
- collection and timestamps ride along under `entry`), `getPage` gives
1069
- the renderer's `SitePage` (`record`, `tree`). Project in `queries.ts`,
1070
- at the public boundary, to `{ id, path, fields, updatedAt }` per entry
1071
- and a page without renderer internals; the tree item is already lean.
1072
- Do it before the Astro glue (OQ 29) wraps the same reads and before
1073
- a second site depends on the shape — it is a breaking change to `q`.
1074
-
1075
- ## Follow-ups (logged 2026-09-10, page fields and SEO spec)
1076
-
1077
- - SEO for entry pages: entries have no `meta`; a `titleField`-based
1078
- title and a description field are the next step.
1079
- - A `styles` block on pages, when a case needs presentation separate
1080
- from content.
1081
- - Refs in page fields, once the refs index walks them.
1082
- - A canonical URL field and a search-result preview snippet in the
1083
- SEO & Social tab.
1084
- - Configurable SEO set (`meta: { seo: { twitter: true } }`) if the four
1085
- values prove too few.
1086
- - Link fields are raw: an image in a page field reaches a nav link as
1087
- `{ asset, alt }`, not a URL. Resolve on demand if a nav ever wants one.
1088
- - The social image URL goes through the default `next` loader at 1200px;
1089
- the cloud's transform loader (OQ 28) should serve a real 1200×630.
1090
- - Page-field labels derive from the key (`showInNavigation` → "Show In
1091
- Navigation"), as every field's do; a `.label()` builder option would
1092
- read better for long keys.
1070
+ gone). *(2026-09-13, shared inline editing)*
1071
+ - The picker offers a shared item only in zones whose allow list includes
1072
+ its section (`allowedShared` in `admin/tree-ops.ts`), so a shared item's
1073
+ section is always also offered as an ordinary section. A "placeable
1074
+ only" mode (a shared item whose section no zone lists) would let the
1075
+ starter keep the quote shared-only. *(2026-09-07, starter)*
1076
+ - `f.shared(() => item)` — a ref field on a section that renders a shared
1077
+ item inside the section's own markup (spec 2026-09-07 decision 3). *(2026-09-07, shared content)*
1078
+ - Instance mode: a shared *type* with many editor-created items; today
1079
+ the answer is a collection plus a section with `f.ref` (decision 1). *(2026-09-07, shared content)*
1080
+ - Zone-shaped shared items (`smoodly.shared({ zone })`) (decision 2). *(2026-09-07, shared content)*
1081
+ - Standalone live preview on a visual item's admin page — would need a
1082
+ preview route in the glue. Less pressing since 2026-09-13: every shared
1083
+ render is edited in the page canvas, and the Globals page is the form. *(2026-09-07, shared content)*
1084
+ - Built-in meta on shared items and on placements (anchor per placement
1085
+ is the likely shape). *(2026-09-07, shared content)*
1086
+ - A placement of an object item or an unregistered item gates the page's
1087
+ publish (the refs edge is emitted without a registry lookup) yet never
1088
+ renders; only a hand-edited tree can produce one. *(2026-09-07, shared content)*
1093
1089
 
1094
- ## Follow-ups (logged 2026-09-10, inline editing spec)
1090
+ #### Drag and drop
1095
1091
 
1096
- - Richtext instant write from the inspector: the pure `RichText` render
1097
- swapped into the container while the widget is typed in.
1098
- - Item-level chrome in the canvas ("Key points · 3 of 4" on hover, add,
1099
- remove, reorder in place, later drag within a list): the item marks are
1100
- the hook.
1101
- - Placeholder and validation marks on the page (the sample-sourced
1102
- placeholder treatment, "required, empty" outlines) wait for the tree's
1103
- placeholder marking.
1104
- - A coverage hint for developers: the admin knows a section's text fields
1105
- and could list the ones without a mark.
1106
- - AI verbs ("rewrite this heading") need a field under the cursor, which
1107
- a mark is.
1108
- - A cross-origin admin needs the bridge-side editing variant (spec §3).
1109
- - Direct-child spacing rules on a richtext container (Tailwind `space-y-*`,
1110
- `.prose > p`) do not reach the canvas editor's blocks during a session;
1111
- flex and grid gaps are copied (onto the ProseMirror root since the
1112
- walk, 69aecc6). Documented, not fixed.
1113
- - The text session's Enter commits: a plain text field has no line breaks.
1114
- A multi-line text control would be its own descriptor.
1115
- - The floating toolbar flips below the caret only near the top of the
1116
- DOCUMENT (where the page box clips it); it scrolls with the content, so
1117
- a caret at the top of the scrolled canvas has its toolbar out of view
1118
- until the editor scrolls. A flip keyed to the scroller's viewport would
1119
- fix it.
1120
- - No starter section gives the canvas a richtext with blocks: the Image
1121
- and text body is marks-only, and the article body (the `article`
1122
- toolset) is edited in the entry form. The walk borrowed the article
1123
- toolset for the block steps; a starter section with a long-form body
1124
- would let the canvas show them.
1125
- - The caret placed right after an existing `/` reopens the slash menu
1126
- (TipTap's suggestion re-triggers on the character before the caret).
1127
- Harmless; a trigger keyed to typing only would be stricter.
1128
- - Walk findings, all fixed the same day (69aecc6): the copied flex gap
1129
- landed on the editor root instead of the ProseMirror element that holds
1130
- the blocks, so paragraphs lost their spacing; a click on a marked image
1131
- revealed nothing because the reveal forced the Content tab while the
1132
- media projection puts the image on Media; Escape after a card's button
1133
- had taken the focus did not end the session.
1092
+ - Cross-zone drag in the outline: each zone is its own sortable list;
1093
+ moving a block between zones is still insert-and-remove. *(2026-09-09)*
1094
+ - Nested manual collections show no grip (`canReorder` requires
1095
+ `depth === 1`) until the entry tree UI exists — a flat index is not a
1096
+ sibling index there. *(2026-09-09)*
1097
+ - Canvas drag: the overlay should reuse `ui/sortable.ts` across the
1098
+ iframe boundary; `useSortable` measures container children, which the
1099
+ overlay's ghost rows can be. *(2026-09-09)*
1100
+ - A dragged subtree stays collapsed after the drop (`onDragStart`
1101
+ collapses it through `toggle` and nothing re-expands it). *(2026-09-09)*
1134
1102
 
1135
- ## Follow-ups (logged 2026-09-25, neutral restyle + confirm dialog)
1103
+ #### Admin UI
1136
1104
 
1105
+ - The Globals page has no tab row: Content's groups, then a `Settings`
1106
+ label and the Settings groups, stacked. A tab row there would match the
1107
+ canvas card. *(2026-09-15, section settings)*
1137
1108
  - The warm look is the comparison block now (`WARM_*` + `WARM_EXTRA` in
1138
1109
  `src/admin/ui/theme.tsx`, `localStorage.smoodly-look = "warm"`); the
1139
1110
  technical block is deleted (tag `look-technical` keeps it). Remove the
1140
- warm block once the neutral look is judged, the same way.
1141
- - Kept from the app on purpose, against the handoff: the 14px base and
1142
- 18px titles (handoff 13/20/22), the 64px top bar (56), the 360px
1143
- inspector (296), the Lucide rail icons, the `--table-top` air above the
1144
- list, the 12px block-tag padding. Inputs stay white on focus (the
1145
- handoff's focused textarea takes the tile grey) and the locked outline
1146
- row is a white row like the rest (the handoff's is the ground, invisible
1147
- on a tile of the same grey).
1148
- - The handoff's status chips collapse Draft into amber; the app keeps its
1149
- three states (Published green, Unpublished changes amber, Draft grey on
1150
- `--control-hover`).
1151
- - The Pages list's prompts are dialogs on the same card since the same
1152
- day (`shell/PageDialogs.tsx` over `ui/Dialog.tsx`): New page / New
1153
- path / New subpage / New subpath (title, slug following the title
1154
- through `slugify` in `paths.ts`, page type when more than
1155
- one open template), Add page here (page type; skipped with one), Rename
1156
- (title and slug; the home page's title alone). Still native: the
1157
- `window.alert`s on a refused list op outside a dialog (`run` in
1158
- PagesList, EntriesList's add/remove) — a notice banner is the follow-up.
1159
- - `slugify` (`paths.ts`, exported) is the one slugifier; the slug-box
1160
- helpers in `admin/forms/slug-field.ts` (`followsTitle`, `tidySlug`,
1161
- `slugHint`, `withFollowingSlug`) serve the page dialogs, page Settings,
1162
- the `f.slug` widget and the entry form (spec 2026-09-28 ASCII slugs).
1163
- - The dialog's fields take the tile tone on the white card
1164
- (`.sm-dialog__form .sm-input`), the reverse of fields on tiles.
1165
- - The confirm's copy lives at each call site (`title` / `body` /
1166
- `confirmLabel` / `subject`); the shared ones are `DISCARD_CONFIRM`
1167
- (`ui/confirm.ts`), `LEAVE_CONFIRM` (router) and `LIVE_URL_CONFIRM`
1168
- (live-url). A subject path is shown where the screen already holds it;
1169
- the entries list and the media screen show the name only.
1170
- - `confirm()` without a mounted `<ConfirmHost/>` falls back to
1171
- `window.confirm` so nothing can hang; the host is mounted once in
1172
- `AdminApp` beside the shell. The tab-close guard stays native
1173
- (`beforeunload` cannot await).
1174
- - The handoff's `Styles` tab in the inspector is not built: it exists
1175
- only where a section registers styles, which is the existing `tabsFor`.
1176
- - The wordmark still takes `--logo-color` (accent); the rail's app icon
1177
- takes `--logo-outer` / `--logo-inner` and is hidden in the warm look.
1178
- - Polish round the same evening (2026-09-25): list row rules are a
1179
- `.sm-row::after` pseudo-element, since a border follows the rounded
1180
- hover corners; the admin remembers ONE locale across lists and forms
1181
- (`shell/locale-memory.ts`, key `smoodly-locale`; the lists' two
1182
- session-only keys are gone, `localeFromUrl` falls back to it and
1183
- remembers what it resolved); the section insert picker opens upward
1184
- when the room below is short and scrolls inside the room it has
1185
- (`placePicker` in `editor/InsertPicker.tsx`, measured in a layout
1186
- effect, no window-resize listener); the prefixed slug input keeps a
1187
- 22px line box so its caret reads as one; `.sm-input--mono` is scoped
1188
- under `.sm-admin` — the `input{font-family:inherit}` reset had beaten
1189
- it, so every mono input (slugs, IDs) had rendered in the sans font;
1190
- richtext block-node cards take `--rtcard-bg` / `--rtcard-border` (tile
1191
- grey with the soft accent border in the neutral look, white and
1192
- hairlined in the warm). The custom section's row cards and the list
1193
- field's rows keep the white-on-tile look — only the richtext cards sat
1194
- on a white field.
1195
- - Richtext cards, second pass (2026-09-25): the default card border is
1196
- the hairline (`--rtcard-border:var(--line)`), the accent outline marks
1197
- the selected card alone, and the widget's focus ring steps back while a
1198
- block node is selected (`:focus:not(:has(.ProseMirror-selectednode))`).
1199
- The image card's head carries Edit / Replace / Remove as
1200
- `.sm-field__action` text buttons, the same three the media field now
1201
- shows in its field head (`MediaWidget` renders its own `Field`;
1202
- `FieldWidget` returns it directly, `SeoSettings` passes the label). Edit
1203
- on a richtext image is the crop editor over the node's `image` attr
1204
- (an `ImageValue`, so crop/hotspot/aspect store there). The bound
1205
- `smoodly/richtext` renderer applies it through `<SmoodlyImage>`; the
1206
- root `RichText`'s plain figure does NOT (it may not import the image
1207
- layer, `richtext-entry.test.ts`), so a framework-neutral renderer
1208
- (the Astro adapter, OQ 29) must frame it with `frameFor` itself. The
1209
- card shows the cropped frame (`forms/ImagePreview.tsx`, shared with the
1210
- media field), the filename and the caption as text; alt and caption are
1211
- edited in the crop modal (`CropEditor` takes `caption`), so the card
1212
- holds no inputs. Fields on a modal panel take the tile tone.
1213
- - A refused publish's per-field errors clear as the fields are fixed
1214
- (2026-09-25): the entry and global forms revalidate on every change with
1215
- the op's own rules (`validateFields` / `validateNode` over
1216
- `elementSchemas(registry.elements)`) and keep only what still fails
1217
- (`stillFailing` in `validate.ts`); the notice's list shrinks and the
1218
- notice goes when it empties. Typing never adds an error — only a
1219
- publish does. The page editor's banner list and the canvas global
1220
- inspector (save errors) are unchanged.
1221
-
1222
- ## Follow-ups (logged 2026-09-25, date pickers)
1223
-
1224
- - `f.date()` and `f.datetime()` share one in-house calendar
1225
- (`admin/forms/DatePicker.tsx` over the pure `date-picker.ts`); the
1226
- browsers' own pickers were rejected (they differ and take no tokens).
1227
- Weeks start on Monday and month names come from the browser's language
1228
- — `weekStart` and a month-name locale as config keys are follow-ups.
1229
- - `defineConfig({ timeZone })` is validated by constructing an
1230
- `Intl.DateTimeFormat`, so an alias the runtime's database still knows
1231
- (`Europe/Kiev`) passes and an offset never does. Admin-owned site
1232
- settings (a per-space `settings` table with a Site page) were designed
1233
- and dropped for the zone; the design is in the spec's history should a
1234
- setting turn up that editors must change without a deploy.
1235
- - Not built: a time-only field, a date range, `.min` / `.max` on dates.
1236
- - Publish refuses a malformed date or datetime now (`validate.ts`); a
1237
- row saved before this with a bad string shows the message on its next
1238
- publish, as designed.
1239
-
1240
- ## Follow-ups (logged 2026-09-08, block nodes spec)
1241
-
1242
- - Image sizing and float in a body (full width, wide, small, left/right):
1243
- a later additive `styles` on the image node; the site's CSS answers it.
1244
- - Inline elements: `inline: true` on the registration; cursor, marks and
1245
- selection around an inline atom are a separate node-view effort.
1246
- - The column context (`contexts: ["column"]`) waits for Phase 3 rows;
1247
- accepted and unused today.
1248
- - A page or entry picker for `f.link()` inside an element — the link
1249
- picker spec.
1250
- - Element copy between fields: a pasted element the target toolset
1251
- forbids is dropped silently; a notice would be kinder.
1252
- - Placeholder marking for sample-populated element fields waits for the
1253
- tree's own placeholder marking.
1254
- - An element's styles are not merged with the config's `styles.elements`
1255
- standard set (sections get `resolvedStyles`); add the merge when the
1256
- set is non-empty somewhere.
1257
- - Element styles are not validated on publish (sections' aren't either).
1258
- - `hasContent` treats an unresolved image node as content, so a body
1259
- whose only image was deleted from the library still passes "required".
1260
- - Publishing a saved version of a versioned entry: the entry form's
1261
- status control returns early when the target equals the current status,
1262
- so a new version of a published entry goes live only by toggling Draft
1263
- then Published (seen in the block-nodes walk; pre-existing). A "Publish
1264
- changes" action when the draft version is ahead of the published one.
1265
- - The insert position is recorded when the "+" menu opens and handed to
1266
- `insertContentAt`, because after the menu or the media picker takes
1267
- focus the refocused contenteditable placed its DOM caret at the first
1268
- card and the editor's selection followed it (the walk's first inserts
1269
- landed at the document top). The explicit position closes the hole;
1270
- the DOM-caret drift itself was not chased to its root.
1271
- - The "+" menu and the slash menu share `RichtextInsertMenu` but keep
1272
- separate active-row state and key handling; one keyboard model would
1273
- be smaller.
1274
-
1275
- ## Follow-ups (logged 2026-09-08, tooling session)
1276
-
1277
- - **CI live job red since e09a632, 0.0.11 fresh install broken — fixed
1278
- 2026-09-08, released as 0.0.12: `smoodly/image/config` is the entry
1279
- `next.config.ts` imports; `test/image-config-entry.test.ts` loads the
1280
- built file under bare node.** The
1281
- scaffolded `next.config.ts` imports `smoodlyImages` from `smoodly/image`,
1282
- whose index re-exports `SmoodlyImage.tsx` and its `import Image from
1283
- "next/image"`. Next loads the config in plain Node ESM and next@16.3.4
1284
- has no `exports` map, so the extensionless specifier fails
1285
- (`ERR_MODULE_NOT_FOUND ... Did you mean "next/image.js"?`) before
1286
- `next build` starts; the bundler resolves it, which is why nothing else
1287
- caught it. Reproduce from `examples/site`:
1288
- `node --input-type=module -e "await import('smoodly/image')"`. Fix: a
1289
- component-free entry for the config helper (the template and the
1290
- example comment then import from it), a plain-Node import test, then
1291
- release 0.0.12. Appending `.js` alone is weaker: Node hands back the
1292
- CommonJS `module.exports` object as the default, not the component.
1293
-
1294
- ## Follow-ups (logged 2026-09-08, client routing spec)
1295
-
1111
+ warm block once the neutral look is judged, the same way. *(2026-09-25, neutral restyle + confirm dialog)*
1296
1112
  - Keep the previous view painted until the next one has data: today a
1297
1113
  navigation switches instantly and the new view shows its own loading
1298
1114
  state; a transition that holds the old view, or prefetches the next
1299
- view's ops call on hover, is the next step.
1300
- - Browser Back with unsaved changes is not guarded: `popstate` cannot be
1301
- cancelled cleanly, so a dirty form only asks on a click and on reload.
1302
- Re-pushing the current URL and asking is possible if editors lose work.
1303
- - Every revalidating op (publish, delete, a save of published content)
1304
- answers with a fresh RSC render of the admin route — the layout's
1305
- styles included, ~30 KB — because `updateTag` runs inside the server
1306
- action and Next re-renders the tree it was posted to. Harmless now
1307
- that the shell lives in the layout (the empty page subtree is what
1308
- remounts), but it is the price of server actions as the transport.
1309
- - The bundle split: the editor bridge out of the admin tree and the
1310
- editor lazy-loaded, so site pages stop loading the admin chunk (the
1311
- richtext widget register entry below measures it). Its own spec.
1312
- - `SharedForm` and `EntryForm` now share the locale helpers; the rest
1313
- of the fork (`applyResult`, `InfoRow`, the ref-options effect) is still
1314
- the 2026-09-07 entry below.
1315
-
1316
- ## Follow-ups (logged 2026-09-08, media library spec)
1317
-
1318
- - Video transcoding and adaptive streaming: a Mux or Cloudflare Stream
1319
- loader behind the `media.loader` seam, if a site ever needs more than
1320
- an MP4 over Storage's CDN.
1321
- - Sweeping abandoned `pending` asset rows (an upload that began and
1322
- never called `finishUpload`); a periodic op or a cleanup on `list`.
1323
- - A private bucket with signed reads, for sites that must not expose
1324
- draft-only media by URL before publish.
1325
- - Pre-generated image variants at upload time, for hosts without sharp.
1326
- - Transform loaders beyond the Next default: Supabase Image
1327
- Transformations, imgix, Cloudflare Images (DESIGN.md OQ 28).
1328
- - Delivery caching (checked 2026-09-08): Supabase Storage's basic CDN
1329
- fronts every public-bucket object on a hosted project with no code on
1330
- our side, and the Smart CDN (Pro plan, automatic) only adds
1331
- invalidation on overwrite, which never happens — objects are
1332
- immutable, no upsert on either path, Replace picks a new id. So both
1333
- upload paths now set a year-long `cache-control`
1334
- (`MEDIA_CACHE_SECONDS`): the `cacheControl` option on the server-side
1335
- upload, the header on the browser's signed PUT (without it a signed
1336
- upload lands as `no-cache`; Storage's default is one hour). The live
1337
- suite reads the header back from the local stack. Not yet verified
1338
- against a hosted project's CDN edge; check `cf-cache-status` and
1339
- `cache-control` on a real upload there once. Remaining: in v1 the
1340
- site's `<SmoodlyImage>` goes through `next/image`, so the visitor hits
1341
- the app server's image cache and only its origin fetch touches the
1342
- CDN; video, files and SVGs hit the CDN directly. A Supabase Image
1343
- Transformations loader (`/storage/v1/render/image/public/...`, Pro
1344
- only, must stay optional) would move resizing to the CDN — the loader
1345
- bullet above.
1346
- - The asset refs inherit the index's scope: it reflects the latest saved
1347
- version of a source, so a published version still using an asset the
1348
- draft dropped is not protected from delete — the same gap entry refs
1349
- have today.
1350
- - Deviations from the spec, decided while building: the starter's Hero
1351
- declares `aspect: "4:5"` (the design's own ratio), not `16:9`; `assets.list`
1352
- pages by a `created_at` cursor, 60 per page; the loader seam is the
1353
- `loader` prop of `<SmoodlyImage>` and `imageUrl`'s third argument — a
1354
- config-level `media.loader` waits for a second loader to exist.
1355
- - Image dimensions server-side cover JPEG and PNG (`imageDimensions`); a
1356
- WebP or AVIF seeded through `AssetStore.create` lands without a size
1357
- and resolves to nothing until the row is edited. Browser uploads
1358
- measure every type.
1359
- - A section's `styles` are walked for media, the config's standard style
1360
- set is not (it holds selects). A standard-set image would need the
1361
- walk to read `resolvedStyles`.
1362
- - `<SmoodlyImage>` sizes the image by the crop factor, so `sizes` picks a
1363
- candidate for the box, not for the framed image; a CDN loader with
1364
- `handlesCrop` removes the overfetch.
1365
- - `smoodlyImages()` returns the whole `images` config, not just the
1366
- patterns (decided 2026-09-08 in the browser walk): Next 16's optimizer
1367
- answers 400 for an upstream that resolves to a private IP unless
1368
- `images.dangerouslyAllowLocalIP` is set, and the local stack is
1369
- `127.0.0.1`. The helper sets the flag only for `localhost`, loopback
1370
- and RFC 1918 hosts, so a hosted project stays strict; a Docker alias
1371
- that resolves privately needs the flag by hand. The spec's "a helper
1372
- on `smoodly/next`" lives on `smoodly/image` instead, beside the
1373
- components it serves.
1374
- - Build deviations from the plan (2026-09-08, sessions A–C):
1375
- `test/admin-pages-tree.test.ts` builds its `AdminRegistry` literal by
1376
- hand and carries `media: { maxSize: 1, accept: [] }`; `MediaPicker`'s
1377
- `filters` is `readonly KindFilter[]` and the `Segmented` calls pin
1378
- `<KindFilter>`; `MediaScreen` seeds its metadata state from
1379
- `initialSelected` because the plan's effect never runs under
1380
- `renderToStaticMarkup`; `forms/measure.ts` is browser-only and
1381
- untested by design (`putSigned` is unit-tested over a stubbed fetch
1382
- and exercised live since 2026-09-08); the installer's templates are
1383
- generated (gitignored), so "templates rebuilt" is a build step, not a
1384
- diff.
1385
-
1386
- ## Follow-ups (logged 2026-09-09, admin tweaks)
1387
-
1388
- The 2026-09-09 tweak round: view titles sans 18px, the entry and
1389
- Globals forms in one 680px column with a globe-off mark on fields that
1390
- are not `.localized()` (the "Shared across languages" / "In <locale>"
1391
- groups are gone), the image field on a 300px canvas, every locale
1392
- switch a segmented pill (a dropdown past three locales), Media before
1393
- Globals (née Shared) on the rail, richtext paragraph and card spacing,
1394
- element cards collapsed unless blank, and the "+" menu floating under
1395
- the toolbar instead of pushing the content.
1396
-
1115
+ view's ops call on hover, is the next step. *(2026-09-08, client routing)*
1397
1116
  - `LocaleSwitcher` in pill form has no "+ add" affordance: an absent
1398
1117
  locale reads subtle and switching to it lands on the form's or the
1399
1118
  editor's "doesn't exist yet · Add" pane, which adds. The dropdown form
1400
1119
  (four locales and up) still adds straight from its "Add locale" group,
1401
1120
  so the two forms differ by one click; a "+" inside the pill would close
1402
- that gap.
1121
+ that gap. *(2026-09-09, admin tweaks)*
1403
1122
  - The image field's canvas height (300px) lives twice: `CANVAS_H` in
1404
1123
  `MediaWidget.tsx` sizes the frame, `.sm-media__canvas` /
1405
- `.sm-media__thumb` cap it in the stylesheet. One token would do.
1124
+ `.sm-media__thumb` cap it in the stylesheet. One token would do. *(2026-09-09, admin tweaks)*
1406
1125
  - `.sm-form>*{max-width:680px}` caps the banner too; if a wide
1407
- field type ever appears (a table), give it an opt-out class.
1126
+ field type ever appears (a table), give it an opt-out class. *(2026-09-09, admin tweaks)*
1408
1127
  - An element card decides "open" from its fields alone (blank = open);
1409
1128
  an element whose fields are all optional and empty by design stays
1410
- open on every load. The richtext image card is never collapsible.
1129
+ open on every load. The richtext image card is never collapsible. *(2026-09-09, admin tweaks)*
1411
1130
  - The "+" menu anchors to the toolbar's right end (`right:0` in
1412
1131
  `.sm-richtext__head`), which is under the "+" only while the toolbar
1413
1132
  fits on one line; in the 320px inspector the toolbar wraps and the
1414
- menu sits below the wrapped row's end.
1415
- - The technical look's `TECHNICAL_SHAPE` tokens were not touched: its
1416
- title stays sans at `--text-xl`, its label has no mark styling beyond
1417
- the shared `.sm-label__mark` rule.
1418
- - Browser-pane sign-in (2026-09-09): the magic-link hash on
1419
- `/admin/...` was NOT consumed by the admin's Supabase client in the
1420
- pane (the login form stayed); fetching `/auth/v1/user` with the
1421
- access token and writing the session object to `localStorage`
1422
- under `smoodly-auth`, then reloading, signed in. The pane was hidden
1423
- during the walk, so scrolling went through `.sm-form.scrollTop` and
1424
- clicks through `element.click()` in `javascript_tool`.
1425
-
1426
- ## Follow-ups (logged 2026-09-08, richtext widget spec)
1427
-
1428
- - ~~Drag-to-resize the inspector~~ — DONE 2026-09-10 (spec
1429
- `docs/superpowers/specs/2026-09-10-inspector-resize-design.md`):
1430
- `ui/ResizeHandle.tsx` writes `--inspector-w` inline on the wrapper,
1431
- clamped to 260px..the row less the handle and a 440px canvas (`ui/resize.ts`,
1432
- half the row until 2026-09-26), remembered under
1433
- `smoodly-inspector-w` and applied by the theme's boot script. Walk
1434
- finding: the desktop pane hides its tab on every `navigate`, and a
1435
- hidden tab lays out at zero width, so the handle treats a zero
1436
- measurement as unknown and re-measures on focus. Real drags through
1437
- the pane's `left_click_drag` worked (synthetic `PointerEvent`s cannot
1438
- satisfy `setPointerCapture`); coordinates are the screenshot frame,
1439
- 0.625 × CSS px at the pane's 1280 width.
1440
- - A page and entry picker in the link popover: a link inside a richtext
1441
- document is a reference the refs index, the resolver and the localizer
1442
- cannot see (they address refs by top-level key). Its own spec.
1443
- - Escape inside any inspector control bubbles to the editor's window
1444
- handler and clears the block selection — the link popover stops it, a
1445
- textarea or the TipTap content does not. A `keydown` guard on
1446
- `.sm-inspector` would fix it for every control.
1447
- - Every richtext descriptor now carries its toolset, so the serialized
1448
- registry grew by a few bytes per field; if the AI-facing schema wants
1449
- the vocabulary once instead of per field, dedupe there.
1133
+ menu sits below the wrapped row's end. *(2026-09-09, admin tweaks)*
1450
1134
  - The entry form's column is narrower than the inspector, so the default
1451
- toolbar wraps to two rows there; the inspector shows it on one.
1135
+ toolbar wraps to two rows there; the inspector shows it on one. *(2026-09-08, richtext widget)*
1136
+ - A list row's collapsed summary shows the item's first string subfield —
1137
+ the header nav rows read `/`, `/posts` — where the label would identify
1138
+ the row; `itemSummary` could prefer a `label`/`title`/`heading` key
1139
+ (walk, 2026-09-07). *(2026-09-07, starter)*
1140
+ - Page settings prints `paths[locale]` under each slug field — the
1141
+ locale-relative CMS path, not `urls[locale]`, the site URL the canvas
1142
+ and "View live" actually open. On a translated site the Finnish hint
1143
+ reads `/yritys/tietoa-meista` while the page lives at
1144
+ `/fi/yritys/tietoa-meista`. Both maps are already in `EditorView`;
1145
+ which one belongs under the field is a UI decision, not a missing
1146
+ value. *(2026-09-06)*
1147
+ - The row menu in `PagesList.tsx` does not close on Escape — only a click
1148
+ on the backdrop closes it. *(2026-09-06)*
1149
+ - `PageSettings` re-seeds its inputs from `record` on every rename (and
1150
+ every publish), so keystrokes typed while a rename is in flight can be
1151
+ dropped. The blur- and Enter-driven commits themselves are unaffected. *(2026-09-06)*
1152
+ - A childless folder row is inert on click (a mount opens its collection,
1153
+ a page opens the editor, a folder with children expands); "Add page
1154
+ here" lives in the row menu only. *(2026-09-06)*
1155
+ - The tree rows nest interactive buttons — the chevron and the `···`
1156
+ menu — inside a `role="button"` row. It works, because both stop the
1157
+ click and the keydown, but it is an accessibility smell a real treegrid
1158
+ would not have. *(2026-09-06)*
1159
+ - The Pages list offers "Add <locale>" on a dimmed row whose parent lacks
1160
+ that locale, and learns the refusal only after the round trip.
1161
+ `pagesTreeRows` already carries every node's locales, so the parent's
1162
+ presence could disable the item with its reason up front, exactly as
1163
+ the editor's switcher does with `parentLocales`. `EntriesList` has the
1164
+ same gap (and `entryRows` would have to carry the locales again — the
1165
+ dead field was deleted rather than left unused); it is unreachable
1166
+ today, since nothing in the admin creates a child entry. *(2026-09-06)*
1167
+ - Design system pass (DESIGN.md §2, "The admin's visual system"): the shell,
1168
+ lists, entry form, login and editor were restyled onto `admin/ui/theme.tsx`.
1169
+ Deferred deliberately, because each is a feature rather than a restyle: the
1170
+ list search field and status filter, the locale select in the shell's top bar
1171
+ (the editor has one; the lists always read the default locale), the article
1172
+ sidebar's taxonomy/SEO/publish-date sections, and the rich-text toolbar. The
1173
+ pages list's tree shape (chevrons, depth indent) and its row `···` menu
1174
+ shipped 2026-09-06, without the drag handle. The tokens for all of them are
1175
+ already in place. *(2026-09-05)*
1176
+ - `lucide-react` is now a runtime dependency, for the toggle's two icons. The
1177
+ rest of the UI still uses the design system's unicode glyphs (↑ ↓ ⧉ × ··· ▾
1178
+ ← ⋮⋮); swapping them for Lucide is the next step whenever it is wanted. *(2026-09-05)*
1179
+
1180
+ #### Addresses and the Pages tree
1181
+
1182
+ - The rename/move confirm's code-address line names the addresses in
1183
+ the selected locale only, though a move re-paths every locale; the
1184
+ Pages list's conflict notice is one ellipsized line (the whole text
1185
+ on hover), and an editor's flagged page cannot be moved from the
1186
+ notice itself. *(2026-09-29, nested collection paths)*
1187
+ - An index page removed from code leaves the collection's record with
1188
+ its template (spec amendment 3): the record keeps rendering as that
1189
+ page type's stale name until an editor or a later pass clears it. *(2026-09-29, nested collection paths)*
1190
+
1191
+ #### Collections
1192
+
1193
+ - Nested entries have no admin UI beyond `entries.move`: nothing creates
1194
+ a child entry or shows the entry tree, and the entry form's slug prefix
1195
+ is the collection's mount base only, not the parent chain. *(2026-09-06)*
1196
+ - A published entry under a draft parent is dropped from `getEntriesTree`
1197
+ (the parent is not in the list to hang it from) but returned flat by
1198
+ `getEntries`. Untested either way. *(2026-09-05)*
1199
+
1200
+ #### Queries (`q`)
1201
+
1202
+ - The read client over the read key (DESIGN.md OQ 31) can reuse `publicContent()`. *(2026-09-26, concurrency)*
1203
+ - `where` on localized fields (a join on the locale row), ranges and
1204
+ negation; any-of over an array-valued field (any-of compares the text
1205
+ form of the field, so it covers selects, refs and numbers; on a
1206
+ checkboxes, refList or list field `site.getEntries` refuses it rather
1207
+ than match nothing — both adapters agree on the text form, so a
1208
+ one-element array never matches its element). *(2026-09-11, queries)*
1209
+ - `getEntriesTree` has no `q` counterpart yet; add one when a site
1210
+ needs a nested collection nav. *(2026-09-11, queries)*
1211
+ - `getShared(["header", "footer", "contact"], { locale })` returning a
1212
+ typed tuple, as sugar over the `Promise.all` the layout writes today.
1213
+ Keep one cache unit per item under its own tag — a single unit tagged
1214
+ with all three would expire the header whenever the footer is edited. *(2026-09-11, queries)*
1215
+ - An explicit JSON `null` VALUE in a declared `order.by` field sorts
1216
+ FIRST in Supabase (a jsonb null is the lowest jsonb value, not SQL
1217
+ NULL, so `nullsFirst: false` does not reach it) and LAST in memory. A
1218
+ MISSING key is SQL NULL and sorts last in both, and the admin only ever
1219
+ produces that — a cleared number field emits `undefined`, which the
1220
+ JSON body drops — so this needs a programmatic or external writer.
1221
+ Mixed types in one field diverge for the same reason: Postgres orders
1222
+ jsonb by type first (null < string < number), memory by `String(...)`. *(2026-09-05)*
1223
+
1224
+ #### SEO and page fields
1225
+
1226
+ - A `styles` block on pages, when a case needs presentation separate
1227
+ from content. *(2026-09-10, page fields and SEO)*
1228
+ - Refs in page fields, once the refs index walks them. *(2026-09-10, page fields and SEO)*
1229
+ - A canonical URL field and a search-result preview snippet in the
1230
+ SEO & Social tab. *(2026-09-10, page fields and SEO)*
1231
+ - Configurable SEO set (`meta: { seo: { twitter: true } }`) if the four
1232
+ values prove too few. *(2026-09-10, page fields and SEO)*
1233
+ - Link fields are raw: an image in a page field reaches a nav link as
1234
+ `{ asset, alt }`, not a URL. Resolve on demand if a nav ever wants one. *(2026-09-10, page fields and SEO)*
1235
+ - Page-field labels derive from the key (`showInNavigation` → "Show In
1236
+ Navigation"), as every field's do; a `.label()` builder option would
1237
+ read better for long keys. *(2026-09-10, page fields and SEO)*
1452
1238
 
1453
- ## Follow-ups (logged 2026-09-07, starter)
1239
+ #### Site rendering and starters
1454
1240
 
1241
+ - A separate canvas route would take the last Smoodly bytes off live
1242
+ pages (the lazy stub that loads the bridge and the canvas boundary in
1243
+ the editor frame), at the cost of the frame no longer loading the
1244
+ page's real address: the site's layout would have to be reached from
1245
+ a sibling route, and a developer's own route file or the escape hatch
1246
+ would preview differently from live. Only if a site needs zero.
1247
+ *(2026-09-29, pre-install fixes)*
1248
+ - A layout-only language switch, if a real site needs one: a
1249
+ `q().getAlternates(path)` over the `paths` index plus a proxy header
1250
+ carrying the request path. Not built until asked for — the starters
1251
+ render the switch from the page (`components/PageFrame.tsx`,
1252
+ `components/LocaleNav.tsx`). *(2026-09-15, locale alternates)*
1455
1253
  - A paired component (`smoodly.section`) accepts only its schema's props, so
1456
1254
  a layout cannot hand the footer the contact item or the header the locale
1457
1255
  links through the paired `Footer`/`Header`; the starter's layouts render
1458
1256
  `FooterView`/`HeaderView` directly with the extra prop. A supported way
1459
1257
  to pass layout-only props through a paired component (or a `children`
1460
- slot) would let the layouts use the registered component.
1461
- - Page views and entry pages receive no `locale`; the starter's views get
1462
- it from the app's strings seam (`next-intl`'s `getLocale`, or a constant
1463
- in the single-language scaffold). Adding `locale` to `PageContext` and
1464
- `EntryPageProps` would remove that dependency and let sections that
1465
- format dates stay synchronous.
1466
- - The image widget edits the URL only; `alt` is set by the seed and cannot
1467
- be changed in the admin.
1258
+ slot) would let the layouts use the registered component. *(2026-09-07, starter)*
1468
1259
  - A per-locale collection path or fixed slug must name the default locale;
1469
1260
  the installer rewrites the starter's two lines to name exactly the
1470
1261
  chosen locales (`articlesSegments` in `scaffold.ts`), since a path
1471
1262
  claiming a locale the site lacks fails the index-page rule at config
1472
1263
  load. A "fall back to the first declared segment" rule in
1473
- `defineConfig` would make the patch unnecessary.
1474
- - The picker offers a shared item only in zones whose allow list includes
1475
- its section (`allowedShared` in `admin/tree-ops.ts`), so a shared item's
1476
- section is always also offered as an ordinary section. A "placeable
1477
- only" mode (a shared item whose section no zone lists) would let the
1478
- starter keep the quote shared-only.
1264
+ `defineConfig` would make the patch unnecessary. *(2026-09-07, starter)*
1479
1265
  - Registry names are one namespace across sections, elements, collections
1480
1266
  and page shapes (`assertUniqueNames`); the starter names its People
1481
1267
  section `peopleSection` and its Articles page `articlesIndex` to stay
1482
1268
  clear of the collections. Namespacing by kind would let a section and a
1483
- collection share a name.
1484
- - The seed's early exit probes `/` alone, and `createPage` writes the path
1485
- row before the tree is published, so a crash between the home record and
1486
- the last page leaves a database every later run reports as "already
1487
- seeded"; recovery is `supabase db reset`. A probe on the last thing the
1488
- seed writes, or a per-step check, would make it resumable.
1269
+ collection share a name. *(2026-09-07, starter)*
1489
1270
  - With `fi` as the default locale the seed writes the English content into
1490
1271
  `fi`. Before this fix the index page was seeded at `posts`
1491
1272
  while the collection's `fi` path is `artikkelit`, so `ensureFixedNodes`
1492
1273
  materialized a second, empty Articles page at `artikkelit` and the
1493
1274
  links pointed at `/posts`; the seed now derives the segment from the
1494
1275
  collection's `path` per locale. Left: the starter's copy in a `fi`
1495
- default is still English.
1496
- - Config-level section style defaults (`styles.sections` in `defineConfig`)
1497
- are not filled at render: a node saved without an explicit style emits
1498
- no `data-spacing`/`data-tone`, so a stylesheet cannot key the default
1499
- on the attribute. The starter pads body sections by element instead.
1500
- Filling the resolved defaults in the render pass (as the admin form
1501
- does) would make the attributes reliable (walk, 2026-09-07).
1502
- - A new list item's `select` subfield shows the first option but stores
1503
- nothing (`emptyValue` leaves it undefined), so publish fails with
1504
- `points.3.icon: Required` until the editor re-picks the value that is
1505
- already on screen. Seed the first option, or render a blank option so
1506
- the screen matches the value (walk, 2026-09-07).
1507
- - Publish validates the last saved draft, so a click inside the autosave
1508
- debounce reports fields the editor has just filled as `Required`. Flush
1509
- the pending save before validating, or disable Publish while a save is
1510
- pending (walk, 2026-09-07).
1511
- - The entry form cannot republish a published entry's newer draft: Save
1512
- writes a new draft version, the status control's `setPublished` returns
1513
- early when the status is unchanged, and nothing shows that the draft
1514
- differs from the published version. The workaround unpublishes and
1515
- republishes, which 404s the entry in between. Needs a "Publish changes"
1516
- action and a draft-edits indicator like the page editor's (walk,
1517
- 2026-09-07).
1518
- - Versions accumulate (`entry_versions` grew to four rows after one edit)
1519
- but no admin screen lists or restores them (walk, 2026-09-07).
1520
- - A list row's collapsed summary shows the item's first string subfield —
1521
- the header nav rows read `/`, `/posts` — where the label would identify
1522
- the row; `itemSummary` could prefer a `label`/`title`/`heading` key
1523
- (walk, 2026-09-07).
1524
- - New page / New path (a folder, "path" in the UI since 2026-09-09) ask for the slug through `window.prompt`, which
1525
- browser automation blocks and which cannot validate as you type; an
1526
- inline field would do both (walk, 2026-09-07).
1527
-
1528
- ## Follow-ups (logged 2026-09-07, list field spec)
1529
-
1530
- - Saving the interim richtext textarea drops marks (bold, italic, link);
1531
- flattens a hard break inside a heading, list item or blockquote line to a
1532
- space (a prefixed line cannot carry one, DESIGN.md §3 Richtext); and drops
1533
- anything the line syntax cannot express at all — nested lists,
1534
- multi-paragraph list items (joined with a space instead), and unknown
1535
- nodes such as a horizontal rule. All of it goes away with the TipTap
1536
- widget.
1537
- - Refs inside lists and objects are refused at build time (`f.list`,
1538
- `f.object`). A list of objects each holding a ref (a featured entry with
1539
- its own caption) needs the resolver, the refs index and the admin's
1540
- ref-option loading to address nested paths; add when a site asks.
1541
- - Heading levels 4–6 render but the line syntax writes `####`…`######`
1542
- for them; the spec names 1–3 as the supported set.
1543
- - `emptyValue` for an `image`/`video`/`file` item is `undefined`, so a new
1544
- media row starts empty; a required image item is then a validation
1545
- error until filled, which is the intended nudge but reads as an error
1546
- on a row the editor just added.
1547
- - `RichText` drops the anchor of a link mark whose href is not http(s),
1548
- mailto, tel or site-relative (`/`, `#`, `?`, `.`). The href is normalised
1549
- the way URL parsers do (tab, LF and CR removed) before the scheme test;
1550
- `javascript:`, `data:`, protocol-relative `//host` and the backslash form
1551
- `/\host` are rejected. The admin's link field does not validate on input yet.
1552
- - The list widget has no test for an object item holding a nested list
1553
- subfield.
1554
- - A list item's subtree still uses the row index as its React key, so a list
1555
- of objects each holding a list hands the inner rows another item's collapse
1556
- state when an outer row moves; fix with a stable per-item key when nested
1557
- lists get real use.
1276
+ default is still English. *(2026-09-07, starter)*
1277
+ - Cache tags are still `page:<id>`: publishing one locale expires every
1278
+ locale's cached unit of that page (spec 2026-09-06 §4). A
1279
+ `page:<id>:<locale>` tag would halve the revalidations on a bilingual
1280
+ site. *(2026-09-06)*
1281
+ - `smoodlyMetadata` does not apply the registration check `renderSmoodlyPath`
1282
+ applies: a record whose `template` has no registration is a 404 body with
1283
+ its meta emitted beside it. *(2026-09-05)*
1284
+ - The entry-page view receives the whole `EntryRecord`, every locale's
1285
+ row in `locales` included (fields, slug, status and version pointers); a
1286
+ client entry page would ship all of it to the browser. *(2026-09-05)*
1558
1287
 
1559
- ## Follow-ups (logged 2026-09-07, shared content spec)
1288
+ #### Media
1560
1289
 
1561
- - `f.shared(() => item)` — a ref field on a section that renders a shared
1562
- item inside the section's own markup (spec 2026-09-07 decision 3).
1563
- - Instance mode: a shared *type* with many editor-created items; today
1564
- the answer is a collection plus a section with `f.ref` (decision 1).
1565
- - Zone-shaped shared items (`smoodly.shared({ zone })`) (decision 2).
1566
- - Standalone live preview on a visual item's admin page — would need a
1567
- preview route in the glue. Less pressing since 2026-09-13: every shared
1568
- render is edited in the page canvas, and the Globals page is the form.
1569
- - Built-in meta on shared items and on placements (anchor per placement
1570
- is the likely shape).
1571
- - Per-placement overrides (a shared band with a per-page heading) — out
1572
- of scope; use a normal section with `f.ref` fields.
1573
- - `fillLockedZones` for a zone bound to a shared item waits for
1574
- `ops.shared.list()`; a page opened before that resolves fills on the
1575
- next effect run. Not seen in the walk — the example binds no zone, so
1576
- the path was never exercised — but a double fill is unreachable by
1577
- construction: the effect depends on `sharedIds`, `record`, `mutate` and
1578
- `tree === null`, and `fillLockedZones` only touches EMPTY zones.
1579
- `z.locked(item)` still has no example usage.
1580
- - `SharedForm` lists linking pages by id; a title needs `ops.pages.get`
1581
- per page (or a usage op returning titles).
1582
- - `getSmoodlyShared` reads the store per request in draft mode and once
1583
- per (item, locale) otherwise; a layout calling it for several items
1584
- makes one round trip each — batch if it shows.
1585
- - A shared item's row id is now uuid v5 of (space, name), so the primary
1586
- key ends the materialization race — but only for rows created from now
1587
- on: an item materialized before this change keeps its random
1588
- `gen_random_uuid()` id, and a second load would still be able to insert
1589
- a duplicate beside it. Re-materializing such an item (delete the row in
1590
- SQL, reload the admin) moves it onto the derived id.
1591
- - `SharedForm.tsx` is a near-verbatim fork of `EntryForm.tsx`
1592
- (`applyResult`, `InfoRow`, the ref-options effect; the locale helpers
1593
- are shared since 2026-09-08) and `SharedList.tsx` is the third copy of
1594
- the remembered-locale block (`EntriesList`, `PagesList`); extract a
1595
- `useFormLocale` hook when the next form arrives.
1596
- - A placement selected in a bound locked zone shows the canvas toolbar's
1597
- "fields only" label though a placement has no page-level fields; the
1598
- inspector notice is the real message.
1599
- - A zone whose policy allows a shared item's section also offers that
1600
- section as an ordinary block, so the picker lists e.g. Testimonials
1601
- twice (once under Shared); decide whether a section used by a shared
1602
- item should be hidden from the ordinary list.
1603
- - A placement of an object item or an unregistered item gates the page's
1604
- publish (the refs edge is emitted without a registry lookup) yet never
1605
- renders; only a hand-edited tree can produce one.
1606
- - `ops.shared.get` runs `ensureSharedItems` and then lists the item
1607
- again; one round trip could go.
1608
- - The example's Testimonials `quotes` richtext is required, so the first
1609
- admin save of the seeded band asks for it; make it optional or seed it.
1610
- - `CollectionSchema.kind` was widened to carry `"shared"`, so a
1611
- `SharedSchema` typechecks where `registry.collections` is expected;
1612
- `defineConfig` should throw on a `kind === "shared"` entry listed there.
1613
- - `ensureSharedItems` runs on every `ops.shared.list()` — which is every
1614
- editor mount, not only the Shared content page — costing one
1615
- `entries.list` per registration per open.
1616
- - `fanOut` resolves every shared registration's row id (one
1617
- `entries.list` each) on every entry write, so it can expire
1618
- `shared:<name>` when something the item references changes; a row-id
1619
- cache would remove the reads but has to survive materialization.
1620
- - Because `fanOut` lists every shared registration, an entry store built
1621
- without `options.shared` now fails on every ordinary entry write with
1622
- "no collection named", not only on `ops.shared.*`; both scaffolds pass
1623
- the same `shared` array to the store and the config, so this bites only
1624
- a hand-wired app.
1625
- - `ops.entries.move` expires entry tags directly and does not `fanOut`,
1626
- so a reorder that changes what a shared item's `refList` renders does
1627
- not expire `shared:<name>`.
1628
- - The package root (`src/index.ts`) must stay free of Node builtins:
1629
- every scaffolded section does `import { f, smoodly } from "smoodly"`,
1630
- and a `"use client"` section's browser build would fail on one.
1631
- `sharedEntryId` (`src/shared-id.ts`) moved off `node:crypto` onto Web
1632
- Crypto for this reason (fix pass, 2026-09-07). Nothing tests the
1633
- invariant itself — a future addition that reaches for `node:fs`,
1634
- `node:crypto`, etc. from a module reachable through the root would
1635
- regress it silently.
1290
+ - Video transcoding and adaptive streaming: a Mux or Cloudflare Stream
1291
+ loader behind the `media.loader` seam, if a site ever needs more than
1292
+ an MP4 over Storage's CDN. *(2026-09-08, media library)*
1293
+ - Sweeping abandoned `pending` asset rows (an upload that began and
1294
+ never called `finishUpload`); a periodic op or a cleanup on `list`. *(2026-09-08, media library)*
1295
+ - A private bucket with signed reads, for sites that must not expose
1296
+ draft-only media by URL before publish. *(2026-09-08, media library)*
1297
+ - Pre-generated image variants at upload time, for hosts without sharp. *(2026-09-08, media library)*
1298
+ - Image dimensions server-side cover JPEG and PNG (`imageDimensions`); a
1299
+ WebP or AVIF seeded through `AssetStore.create` lands without a size
1300
+ and resolves to nothing until the row is edited. Browser uploads
1301
+ measure every type. *(2026-09-08, media library)*
1636
1302
 
1637
- ## Follow-ups (logged 2026-09-09)
1303
+ #### History and publishing
1638
1304
 
1639
- - Cross-zone drag in the outline: each zone is its own sortable list;
1640
- moving a block between zones is still insert-and-remove.
1641
- - Nested manual collections show no grip (`canReorder` requires
1642
- `depth === 1`) until the entry tree UI exists — a flat index is not a
1643
- sibling index there.
1644
- - Canvas drag: the overlay should reuse `ui/sortable.ts` across the
1645
- iframe boundary; `useSortable` measures container children, which the
1646
- overlay's ghost rows can be.
1647
- - ~~The Pages row menu's Move up / Move down could retire once the grip's
1648
- arrow keys are familiar; they stay for now as the discoverable path.~~
1649
- Retired 2026-09-15, with the outline rows' ↑/↓; list-field rows keep theirs.
1650
- - The grip's arrow keys in the pages tree step one VISIBLE slot with the
1651
- depth clamped — among siblings in a flat run, but across a depth change
1652
- the step follows the clamp. Documented as is.
1653
- - A dragged subtree stays collapsed after the drop (`onDragStart`
1654
- collapses it through `toggle` and nothing re-expands it).
1655
- - Walk findings (2026-09-09, every surface walked signed-in; the pointer
1656
- path with synthetic pointer events, autoscroll in Playwright because
1657
- the desktop pane pauses animation frames while hidden):
1658
- - FIXED: the lifted row's transform grew the scroller's overflow, so
1659
- the bottom-edge autoscroll chased it forever and the list scrolled
1660
- away under the pointer; `clampOffset` now holds the transform inside
1661
- the rows' extent while the slot keeps following the pointer.
1662
- - A depth-1 row dragged to the very end of the tree with no drift
1663
- projects onto the last root row's child slot, which a mount or the
1664
- home page refuses (muted row); the editor must drift left 20px to
1665
- land at the root. By spec §4 (depth = own depth + drift), noted as a
1666
- discoverability question for the canvas-drag round.
1667
- - The rows are held in place until the reload lands (checked with a
1668
- 1.2 s fetch delay): no flicker on a fast stack either.
1669
-
1670
- ## Follow-ups (logged 2026-09-06)
1305
+ - Client-side undo in the editor (a stack of tree snapshots); the store
1306
+ is no longer an undo stack by design. *(2026-09-13, history)*
1307
+ - The author per version row and in the lists; named versions. *(2026-09-13, history)*
1308
+ - History on the canvas card's menu and in the list row menus. *(2026-09-13, history)*
1309
+ - A diff between two versions. *(2026-09-13, history)*
1310
+ - The lists' row menus (Pages, Entries) do not offer Unpublish; the
1311
+ document's own control does. *(2026-09-13, publish flow)*
1671
1312
 
1672
- - Nested entries have no admin UI beyond `entries.move`: nothing creates
1673
- a child entry or shows the entry tree, and the entry form's slug prefix
1674
- is the collection's mount base only, not the parent chain.
1675
- - `pages.get` makes four sequential store round trips for a non-root page
1676
- (`getPage`, `getDraftTree`, `pathsOf`, and the parent read behind
1677
- `parentLocales`; three for a root, which has no parent to read), and the
1678
- editor calls it on load and after every rename — one store call
1679
- returning them all would cut the editor's latency.
1680
- - `entries.move` expires the entry and its descendants but does not fan
1681
- out to the pages referencing them, nor to the old and the new parent
1682
- entries the way `pages.move` does — asymmetric with `entries.save` and
1683
- `entries.setStatus`, which both fan out.
1684
- - The row menu's item set (`menuItems`) and the row-click routing live in
1685
- `PagesList.tsx`, untested; extract them into `pages-tree.ts` before
1686
- plan 4 adds entry rows to the same menu.
1687
- - The shell derives the fixed nodes a second time, over the serialized
1688
- registry (`mountOf` / `isFixedNode` in `pages-tree.ts`), beside the
1689
- server's `fixedNodeOf` — one derivation shipped to the client would do.
1690
- - The locale switcher points the canvas at `urls[locale]`, and `href` is
1691
- identity in the example, so every path serves the default locale: a
1692
- page with a translated slug shows a 404 in the canvas for that locale
1693
- until next-intl is wired (plan 4). **Done 2026-09-06** — `href` is
1694
- `getPathname` in the example now; the canvas loads
1695
- `/fi/yritys/tietoa-meista?smoodly-editor=1` and renders (walked in
1696
- `examples/site/README.md`).
1697
- - Page settings prints `paths[locale]` under each slug field — the
1698
- locale-relative CMS path, not `urls[locale]`, the site URL the canvas
1699
- and "View live" actually open. On a translated site the Finnish hint
1700
- reads `/yritys/tietoa-meista` while the page lives at
1701
- `/fi/yritys/tietoa-meista`. Both maps are already in `EditorView`;
1702
- which one belongs under the field is a UI decision, not a missing
1703
- value.
1313
+ #### Collaboration
1314
+
1315
+ - A hard lock with takeover, if a real team asks: presence has the facts. *(2026-09-26, concurrency)*
1316
+ - Presence on the members and media screens; today only pages, entries and
1317
+ global items track. *(2026-09-26, concurrency)*
1318
+ - Postgres Changes for live list refresh; the lists still load on navigation. *(2026-09-26, concurrency)*
1319
+ - Field-level merging of two editors' changes; the guard refuses whole documents. *(2026-09-26, concurrency)*
1320
+ - A dropped socket clears presence on Realtime's timeout (tens of seconds); the
1321
+ lock outlives a closed tab by that much. *(2026-09-26, concurrency)*
1322
+
1323
+ #### Members
1324
+
1325
+ - Members management shipped 2026-09-25 (spec
1326
+ `docs/superpowers/specs/2026-09-25-members-management-design.md`); out
1327
+ of its scope: a pending-invite status and "resend invite" on a row
1328
+ (needs the Auth user's `last_sign_in_at`, which the cloud projection
1329
+ does not carry); removing the Auth account together with the member
1330
+ row; invites from the cloud's `/admin` (they are smoodly.io's); a
1331
+ project whose Auth is OAuth-only (the invite mail asks for a password). *(2026-09-25, settings + members)*
1332
+ - The members list is not paged (tens of rows, not thousands). *(2026-09-25, settings + members)*
1333
+
1334
+ #### Redirects
1335
+
1336
+ - Destinations outside Smoodly, extension and query-string addresses,
1337
+ code-owned renames (a collection `path`, a fixed slug): the guide's
1338
+ `next.config` recipe; a `redirects` table is OQ 34. *(2026-09-26, redirects)*
1339
+ - The content API's `getPage("/old")` returns null for a redirect row
1340
+ (spec §7); a `follow` option is a one-line addition if a site asks. *(2026-09-26, redirects)*
1341
+ - A redirect row has no audit columns (who, when); add them with a
1342
+ need. The Redirects page is not paged. *(2026-09-26, redirects)*
1343
+
1344
+ #### Export and import
1345
+
1346
+ - `import --replace`: delete the space's content and media first, after
1347
+ typing the space to confirm — a backup restored over a live project.
1348
+ Mention it in the not-empty refusal when it lands. *(2026-09-28, export/import)*
1349
+ - A readable export for leaving Smoodly: one document per page or entry,
1350
+ fields resolved, no internal IDs. *(2026-09-28, export/import)*
1351
+ - A "Download backup" button in the admin: the same export behind an op,
1352
+ zipped for an editor. *(2026-09-28, export/import)*
1353
+ - Import cannot expire the app's cache tags; it prints a restart note. A
1354
+ revalidate call through the app (an op the CLI could reach) would make
1355
+ the note unnecessary. *(2026-09-28, export/import)*
1356
+
1357
+ #### Content health and drift
1358
+
1359
+ - Content health lists an entry slug outside the grammar, but its link
1360
+ opens an entry form that marks nothing on the slug: drift leaves the
1361
+ slug out on purpose (a marker would flash while it is typed), so only
1362
+ the publish refusal marks it. Mark it in the form once the box has lost
1363
+ focus, if editors miss it. *(2026-09-29, pre-install fixes)*
1364
+ - Content health does not check page slugs: pages refuse a bad slug at
1365
+ create and rename, so only an import of older data or a hand edit could
1366
+ hold one. *(2026-09-29, pre-install fixes)*
1367
+ - Refs inside unknown nodes are not indexed: without a schema nothing
1368
+ says which values are refs, so safe-delete cannot see them. Publish
1369
+ refuses such nodes and a live one renders nothing. *(2026-09-27, editor safety)*
1370
+ - Leftover shared values after `.localized()` is turned on stay on the
1371
+ entry as every locale's fallback; removing them for good waits on
1372
+ the fix tooling (OQ 35). `localization-on` is reported only where a
1373
+ locale has no value of its own, so a fully translated entry no
1374
+ longer lists it. *(2026-09-27, editor safety)*
1375
+ - A CLI drift report was left out (the admin already has the config and
1376
+ the database; a CLI needs the service key and a TS config loader). *(2026-09-27, editor safety)*
1377
+ - Turning `.localized()` off for a field with per-locale values leaves
1378
+ those values in `entry_locales.fields` until the next save of each
1379
+ locale (a save writes only the current split) — the drift/codemod
1380
+ pipeline (Phase 2) is where "keep en, discard fi" belongs. *(2026-09-06)*
1381
+
1382
+ #### Schema and migrations
1383
+
1384
+ - The views of a removed collection stay (spec 2026-09-26 §3); a
1385
+ `drop view` is the developer's. *(2026-09-26, schema migrations)*
1386
+ - `smoodly migrate` never pushes; a `--push` flag if the two-step
1387
+ proves annoying. *(2026-09-26, schema migrations)*
1388
+
1389
+ #### Data lifecycle
1390
+
1391
+ - A locale dropped from `locales.supported` leaves its `page_locales`,
1392
+ `page_versions` and `paths` rows in place but unreachable — nothing
1393
+ lists or serves them, and nothing cleans them up. It is the mirror of
1394
+ the materializer's top-up, and wants the same deliberate answer (an
1395
+ admin-visible orphan list, or a prune op). *(2026-09-06)*
1396
+ - `addLocale` does not bump the entry's `updatedAt` while `removeLocale`
1397
+ does. Both adapters agree, so the contract is honest, but "adding a
1398
+ language is not an edit and removing one is" is arbitrary — decide it
1399
+ deliberately before someone sorts a list by Updated. *(2026-09-06)*
1400
+ - No `rebuildPaths()`: the index is documented as derived and rebuildable, but
1401
+ nothing rebuilds it. *(2026-09-05)*
1402
+
1403
+ #### Config and registration
1404
+
1405
+ - `LOCALE_CODE_RE` accepts any 2–4 letter subtag as script or region
1406
+ (`en-ZZZZ` passes), and a duplicate entry in `supported` passes
1407
+ silently — boot validation catches typos in shape, not in fact. *(2026-09-06)*
1408
+ - `entryPathRows` relies on the stores validating locale keys on write
1409
+ (no guard of its own); `list`/`getMany` accept an unsupported locale
1410
+ silently and read as "no content". *(2026-09-06)*
1411
+ - `sections` is passed twice — to `createSmoodlySite` (ref resolution) and to
1412
+ `createPageRenderer` (the component map) — and `elements` cannot be passed
1413
+ at all; the two lists can drift silently. *(2026-09-05)*
1414
+ - `SmoodlyConfig.registry.pages` (`Registered` in `src/config.ts`) has no
1415
+ call signature, so every app's glue casts it `as PageRegistration[]`. *(2026-09-05)*
1416
+ - Two `entryPage` registrations for one collection are silently accepted.
1417
+ `PageStore.rename` still accepts a locale outside `supported`; the admin
1418
+ op rejects it first, so only a direct store caller reaches it. *(2026-09-05)*
1419
+
1420
+ #### Scale and performance
1421
+
1422
+ - `listLive` / `listRedirects` on Supabase read at most PostgREST's
1423
+ `max_rows` (1000) per locale, as before, now ordered by (anchor, path)
1424
+ and sorted by URL in JS; page them if a locale's redirect list grows
1425
+ past it. *(2026-09-29, nested collection paths)*
1426
+ - Scan paging: `health.scan` makes one bulk read per table (versions by
1427
+ id in chunks of 100), and PostgREST's max-rows would cap a very large
1428
+ space. Page it, and add a rail badge, when a real site's size asks. *(2026-09-27, editor safety)*
1429
+ - Server search for ref candidates: `useRefOptions` loads the whole
1430
+ target collection once per locale. When a collection outgrows that,
1431
+ the hook changes (a search op, plus "titles for these ids" for the
1432
+ picked rows); `RefPicker` takes options as a prop and does not. *(2026-09-17, reference picker and tags)*
1433
+ - `entries.distinct` flattens in JS over `entries.list`; an RPC over
1434
+ `jsonb_array_elements` when a collection outgrows it. *(2026-09-17, reference picker and tags)*
1435
+ - `InspectorFields` filters hidden entries per render; a section with
1436
+ many conditions re-evaluates every keystroke. Fine at the sizes seen. *(2026-09-15, section settings)*
1437
+ - Ops are built per call (`createAdminOps` inside the handler) so the
1438
+ session rides in as `actor`; if that ever measures, memoize the
1439
+ actor-free parts. *(2026-09-13, history)*
1440
+ - A GIN index on `entries.fields` when a customer's collection is large
1441
+ enough to need it; additive, no migration. *(2026-09-11, queries)*
1442
+ - `getEntry` by id goes through `site.getEntry`, which lists the whole
1443
+ collection to find one row; a primary-key read first would be cheaper
1444
+ on the uncached path (draft mode, first miss). *(2026-09-11, queries)*
1445
+ - Every revalidating op (publish, delete, a save of published content)
1446
+ answers with a fresh RSC render of the admin route — the layout's
1447
+ styles included, ~30 KB — because `updateTag` runs inside the server
1448
+ action and Next re-renders the tree it was posted to. Harmless now
1449
+ that the shell lives in the layout (the empty page subtree is what
1450
+ remounts), but it is the price of server actions as the transport. *(2026-09-08, client routing)*
1451
+ - `getSmoodlyShared` reads the store per request in draft mode and once
1452
+ per (item, locale) otherwise; a layout calling it for several items
1453
+ makes one round trip each — batch if it shows. *(2026-09-07, shared content)*
1454
+ - `ops.shared.get` runs `ensureSharedItems` and then lists the item
1455
+ again; one round trip could go. *(2026-09-07, shared content)*
1456
+ - `ensureSharedItems` runs on every `ops.shared.list()` — which is every
1457
+ editor mount, not only the Shared content page — costing one
1458
+ `entries.list` per registration per open. *(2026-09-07, shared content)*
1459
+ - `fanOut` resolves every shared registration's row id (one
1460
+ `entries.list` each) on every entry write, so it can expire
1461
+ `shared:<name>` when something the item references changes; a row-id
1462
+ cache would remove the reads but has to survive materialization. *(2026-09-07, shared content)*
1704
1463
  - `ensureFixedNodes` runs on every `pages.list` and every ROOT
1705
1464
  `pages.create` — one `listPages()` each, and a second one when it
1706
1465
  tried to create something. It also treats a root record sitting at a
1707
1466
  fixed slug as that node even when its `template` is a foreign one: the
1708
1467
  node is adopted, never recreated, and the ops then guard it as
1709
- code-owned.
1710
- - Two admins loading at once race on materialization. Any spec whose
1711
- create fails is warned about and skipped, not fatal to the rest of the
1712
- list — except the loser's `slugTaken`, which means the other admin won
1713
- the same race: that one message is swallowed on purpose, silently,
1714
- with no warn.
1715
- - The `smoodly_reorder` / `smoodly_reorder_pages` RPCs keep their
1716
- implicit sibling scope (decided 2026-09-06: every caller passes exactly
1717
- one sibling set). A parent argument waits for a caller that needs it.
1718
- - The row menu in `PagesList.tsx` does not close on Escape — only a click
1719
- on the backdrop closes it.
1720
- - `PageSettings` re-seeds its inputs from `record` on every rename (and
1721
- every publish), so keystrokes typed while a rename is in flight can be
1722
- dropped. The blur- and Enter-driven commits themselves are unaffected.
1723
- - `subtreeIds` and `entrySubtreeIds` in `ops-impl.ts` are the same O(n²)
1724
- walk over a full list, written twice. One generic descendant walk when
1725
- the list sizes make it matter.
1726
- - `publish`'s "has no draft to publish" branch is unreachable with the
1727
- memory store — `createPage` gives every templated page a first version
1728
- — and is untested. It is defence for the Supabase adapter.
1729
- - Classifications the ops tests do not assert: a move that breaks the
1730
- home page's no-children rule or exceeds the tree depth (create asserts
1731
- both), and `entries.move`'s cross-collection and sibling-slug guards.
1732
- `MemoryEntryStore.move` has no children guard at all — the subtree
1733
- rides along — so `has child` is reachable from delete only.
1734
- - The seed's early exit still probes `/` alone: a run that dies between
1735
- the two publishes reports "Already seeded" on the next run and never
1736
- gets its `company` folder.
1737
- - `create-smoodly-app` scaffolds now ship `pages: { tree: { depth: 3 } }`
1738
- — the template build rewrites only the locale set in `smoodly/config.ts`
1739
- and `href` in `smoodly/server.ts` (each with its comment) — so nesting
1740
- is on by default in a fresh app.
1741
- - A childless folder row is inert on click (a mount opens its collection,
1742
- a page opens the editor, a folder with children expands); "Add page
1743
- here" lives in the row menu only.
1744
- - The tree rows nest interactive buttons — the chevron and the `···`
1745
- menu — inside a `role="button"` row. It works, because both stop the
1746
- click and the keydown, but it is an accessibility smell a real treegrid
1747
- would not have.
1748
- - `ensureFixedNodes` warns on every `pages.list` while a fixed node's
1749
- locale cannot land (an editor page holds its path); a one-time notice
1750
- in the admin would be kinder than a warning per listing.
1751
- - Cache tags are still `page:<id>`: publishing one locale expires every
1752
- locale's cached unit of that page (spec 2026-09-06 §4). A
1753
- `page:<id>:<locale>` tag would halve the revalidations on a bilingual
1754
- site.
1755
- - The Pages list's confirms (`Add <locale>`, `Remove from <locale>`,
1756
- `Delete …`) are the design system's dialog since 2026-09-25;
1757
- `EntriesList` still reports a refused add or remove with
1758
- `window.alert` — the follow-up, together with the row menu extraction
1759
- already logged above.
1760
- - `EditorView.tsx` is ~850 lines now that it also holds the locale switch,
1761
- add and remove flows — see the 2026-09-03 follow-up below for the seams
1762
- (that entry is the one to act on).
1763
- - `i18n/request.ts` returns `{ locale, messages: {} }`. next-intl 4.14.2
1764
- throws `No messages found` when the provider inherits none, and the
1765
- example has no UI strings of its own, so the object is empty; a
1766
- scaffolded site that translates its own UI fills it in (the comment in
1767
- the file points at next-intl's configuration docs). No `messages/`
1768
- folder is scaffolded — UI-string translation is next-intl's job, not
1769
- Smoodly's.
1770
- - `i18n/request.ts` reads `next/root-params` (next-intl's current
1771
- guidance; `requestLocale` is deprecated). Its real types are generated
1772
- into `.next/types/` by `next dev` / `next build` / `next typegen`;
1773
- `tsc` before a first build still passes, because `next` ships a bare
1774
- `declare module 'next/root-params'` for exactly that window.
1775
- - The four single-language route files in
1776
- `packages/create-smoodly-app/variants/single/` are compiled by nothing
1777
- but the CI hosted-mode e2e's `next build` — the example is the
1778
- multilingual layout, so `npm test` never type-checks them. A renderer
1779
- or admin signature change breaks them there, a whole CI job later than
1780
- it should. The fix is a `tsconfig` project for `variants/` with the
1781
- scaffold's `@/*` paths mapped into the example, or a scaffold-then-
1782
- `tsc` smoke test.
1783
- - `ArticleList` renders titles, not links. A section that linked to its
1784
- entries would put the entry's `path` through the app's `Link` / `href`
1785
- the way `components/pages/Page.tsx` does — `getEntries` returns `path`
1786
- already; the section simply does not use it.
1787
- - `smoodlyMetadata` emits `alternates.languages` as relative URLs and
1788
- neither the example nor either template sets `metadataBase`, so the
1789
- rendered `hreflang` hrefs are relative (`/fi/yritys/tietoa-meista`).
1790
- Crawlers want absolute ones: a real site sets `metadataBase` in its
1791
- root layout. `next build` on the example (2026-09-06, Next 16.3.4) does
1792
- not warn — every route is request-rendered, so nothing resolves the
1793
- relative URLs at build time.
1794
- - The proxy's matcher hard-codes `admin` and `api`
1795
- (`/((?!admin|api|_next|_vercel|.*\..*).*)`). An app that mounts the
1796
- admin somewhere else edits `proxy.ts` by hand; nothing derives the
1797
- matcher from the config.
1798
- - `classify` in `ops-impl.ts` is ~20 order-sensitive substring checks over
1799
- store messages — `"already exists in locale"` must precede
1800
- `"already exists"`, or an addLocale conflict reads as a slug conflict. A
1801
- typed code on `pageErrors` (thrown and matched, not re-parsed) would
1802
- remove the ordering hazard before plan 2 adds the entry messages.
1803
- - `ops-impl.ts` is ~436 lines with a ~165-line `pages` group. The guard
1804
- helpers (`nameOf`, `isHomeNode`, `supportedLocale`, `mountGuard`,
1805
- `owned`) are the extraction seam — a `pages-ops.ts` taking them as
1806
- collaborators. The `entries` group now duplicates two of them outright:
1807
- the `supportedLocale` guard and the `parentLocales` computation exist
1808
- once per group.
1468
+ code-owned. *(2026-09-06)*
1809
1469
  - `fixedNodeSpecs(config)` rebuilds the whole spec list on every
1810
1470
  `fixedNodeOf` call, and the ops call `fixedNodeOf` per guarded write. A
1811
- `WeakMap` memo keyed by the config object is the whole fix.
1471
+ `WeakMap` memo keyed by the config object is the whole fix. *(2026-09-06)*
1812
1472
  - `SupabasePageStore.updateLocale` re-reads the record (`must(id)`) after
1813
1473
  every update, so `setTemplate` is N+1 in the page's locales: one update
1814
1474
  and one full read each. Only the last read is used. The Supabase entry
1815
1475
  adapter is the same shape: `create` is insert → insert → read →
1816
1476
  version → read → paths → refs, and `removeLocale` re-fetches the entry
1817
- three times.
1818
- - The single-language branch — no locale selector in the Pages list, no
1819
- switcher, no add/remove — has no automated coverage; only three sites
1820
- branch on the locale count (`PagesList.tsx`, `EditorView.tsx` and
1821
- `page-renderer.tsx`'s alternates), so a regression there shows up only
1822
- in a scaffolded single-language app.
1823
- - No test pins that a published child's breadcrumb carries an UNPUBLISHED
1824
- ancestor's title with `hasPage: false`; `site.test.tsx` covers the
1825
- folder ancestor only, and the two paths share one `hasPage` predicate.
1826
- - A locale dropped from `locales.supported` leaves its `page_locales`,
1827
- `page_versions` and `paths` rows in place but unreachable — nothing
1828
- lists or serves them, and nothing cleans them up. It is the mirror of
1829
- the materializer's top-up, and wants the same deliberate answer (an
1830
- admin-visible orphan list, or a prune op).
1831
- - `LOCALE_CODE_RE` accepts any 2–4 letter subtag as script or region
1832
- (`en-ZZZZ` passes), and a duplicate entry in `supported` passes
1833
- silently — boot validation catches typos in shape, not in fact.
1834
- - `MemoryPageStore.publish` throws the "is a folder" message when a
1835
- templated row has no draft version. Unreachable through the ops (a
1836
- templated page always gets a first version), but the message would be
1837
- wrong if it ever were.
1838
- - The Pages list offers "Add <locale>" on a dimmed row whose parent lacks
1839
- that locale, and learns the refusal only after the round trip.
1840
- `pagesTreeRows` already carries every node's locales, so the parent's
1841
- presence could disable the item with its reason up front, exactly as
1842
- the editor's switcher does with `parentLocales`. `EntriesList` has the
1843
- same gap (and `entryRows` would have to carry the locales again — the
1844
- dead field was deleted rather than left unused); it is unreachable
1845
- today, since nothing in the admin creates a child entry.
1846
- - No version history UI for entries (nor pages): `listVersions` exists on
1847
- both stores; a history pane with restore is the follow-up. Restoring a
1848
- versioned entry's snapshot must also restore the shared fields (the
1849
- snapshot is merged).
1477
+ three times. *(2026-09-06)*
1850
1478
  - `entries.list` filters `{ locale, status }` in memory after fetching
1851
1479
  every row (PostgREST cannot filter the parent by an embedded row
1852
1480
  without `!inner`, which would drop the other rows); fine at collection
1853
- sizes today.
1854
- - The entry form's ref picker offers only targets present in the locale
1855
- being edited; a target present but unpublished there is offered marked
1856
- `(draft)` and blocks a page publish in that locale until published —
1857
- the picker could say so.
1858
- - A collection's `path` map that omits a supported locale falls back to
1859
- the default segment for entry paths (`collectionSegment`) while the
1860
- mount node is materialized map-keys-only; the two should agree as
1861
- fixed pages now do.
1862
- - `removeLocale` on the Supabase entry adapter is deliberately
1863
- non-atomic (row and versions go, then paths), the same trade-off as
1864
- pages.
1481
+ sizes today. *(2026-09-06)*
1482
+ - `assertPublishable` (`store.ts`) awaits one `entries.get` per
1483
+ referenced entry, sequentially, on every publish — a `refList` of a
1484
+ dozen articles is a dozen serial reads, now per locale. `getMany(ids,
1485
+ locale)` takes exactly that shape and would make it one call. *(2026-09-06)*
1486
+ - `loadPage` calls `listPages()` for ancestors and children on every cache
1487
+ miss; fine for hundreds of nodes, a `children(id)`/`chain(id)` store query
1488
+ is the fix when a site outgrows it. *(2026-09-05)*
1489
+ - `loadEntry` probes each mounted collection in turn: the `paths` row
1490
+ carries the target id but not its collection. *(2026-09-05)*
1491
+
1492
+ #### Code health
1493
+
1494
+ - `SharedForm.tsx` is a near-verbatim fork of `EntryForm.tsx`
1495
+ (`applyResult`, `InfoRow`, the ref-options effect; the locale helpers
1496
+ are shared since 2026-09-08) and `SharedList.tsx` is the third copy of
1497
+ the remembered-locale block (`EntriesList`, `PagesList`); extract a
1498
+ `useFormLocale` hook when the next form arrives. *(2026-09-07, shared content)*
1499
+ - `CollectionSchema.kind` was widened to carry `"shared"`, so a
1500
+ `SharedSchema` typechecks where `registry.collections` is expected;
1501
+ `defineConfig` should throw on a `kind === "shared"` entry listed there. *(2026-09-07, shared content)*
1502
+ - Because `fanOut` lists every shared registration, an entry store built
1503
+ without `options.shared` now fails on every ordinary entry write with
1504
+ "no collection named", not only on `ops.shared.*`; both scaffolds pass
1505
+ the same `shared` array to the store and the config, so this bites only
1506
+ a hand-wired app. *(2026-09-07, shared content)*
1507
+ - `subtreeIds` and `entrySubtreeIds` in `ops-impl.ts` are the same O(n²)
1508
+ walk over a full list, written twice. One generic descendant walk when
1509
+ the list sizes make it matter. *(2026-09-06)*
1510
+ - `ops-impl.ts` is ~436 lines with a ~165-line `pages` group. The guard
1511
+ helpers (`nameOf`, `isHomeNode`, `supportedLocale`, `mountGuard`,
1512
+ `owned`) are the extraction seam — a `pages-ops.ts` taking them as
1513
+ collaborators. The `entries` group now duplicates two of them outright:
1514
+ the `supportedLocale` guard and the `parentLocales` computation exist
1515
+ once per group. *(2026-09-06)*
1865
1516
  - `EntryForm.tsx` grew the locale lifecycle beside the fields; the
1866
1517
  `LocaleSwitcher` is shared with the editor now, the add/remove flows
1867
- are not (`doAddLocale`/`doRemoveLocale` exist twice).
1868
- - Turning `.localized()` off for a field with per-locale values leaves
1869
- those values in `entry_locales.fields` until the next save of each
1870
- locale (a save writes only the current split) — the drift/codemod
1871
- pipeline (Phase 2) is where "keep en, discard fi" belongs.
1872
- - Resolved 2026-09-26 (schema migrations spec): the repo-root
1873
- `supabase/migrations/` is the package's core files plus a views file
1874
- that `npm run db:migrate` writes from
1875
- `examples/site/smoodly/collections.ts` — the same source the
1876
- create-smoodly-app templates use; `scripts/emit-sql.ts` and its own
1877
- fixture collections are gone (`scripts/emit-migration.ts` drafts the
1878
- next core file).
1879
- - In the entry form, a shared ref field (e.g. an article's author) shows
1880
- `—` in a locale where the target is absent, because the picker offers
1881
- only targets present in the edited locale; the value is kept on the
1882
- node and the other locale's byline is unchanged, but it looks empty
1883
- and cannot be re-set from that locale.
1884
- - `entryPathRows` relies on the stores validating locale keys on write
1885
- (no guard of its own); `list`/`getMany` accept an unsupported locale
1886
- silently and read as "no content".
1887
- - The Supabase entry adapter's `removeLocale` deletes the row and its
1888
- versions before the paths rewrite with no rollback; its residue
1889
- comment describes the opposite case (a surviving path row for a
1890
- removed locale, not a present node without its path row).
1891
- - The entry form's locale switch, add and remove confirm before
1892
- discarding unsaved edits (a `dirty` flag; the form has no autosave,
1893
- unlike the page editor's flush).
1894
- - `addLocale` does not bump the entry's `updatedAt` while `removeLocale`
1895
- does. Both adapters agree, so the contract is honest, but "adding a
1896
- language is not an edit and removing one is" is arbitrary — decide it
1897
- deliberately before someone sorts a list by Updated.
1898
- - The Supabase entry adapter's rollbacks are best-effort and can mask the
1899
- original throw: `create`'s catch runs `paths.remove` before the node
1900
- delete (a failing remove strands the node and replaces the real error),
1901
- `update` and `move` restore by writing, and those writes can throw in
1902
- place of the rewrite's rejection. `recordVersion` is two statements
1903
- (insert, then point at it), so a failure between them leaves an
1904
- unreferenced `entry_versions` row — invisible to every policy and
1905
- harmless, but it accumulates.
1518
+ are not (`doAddLocale`/`doRemoveLocale` exist twice). *(2026-09-06)*
1906
1519
  - `removeLocale`'s blocker list (`localeInUse`) is unordered in the
1907
1520
  Supabase adapter — the children query has no `.order()` — so a
1908
1521
  multi-blocker message can name the titles in a different order than the
1909
- memory adapter's. Message text only; the contract case has one blocker.
1522
+ memory adapter's. Message text only; the contract case has one blocker. *(2026-09-06)*
1910
1523
  - `entry-store.ts` is ~624 lines. The pure helpers (`localizedKeys`,
1911
1524
  `splitFields`, `entryFieldsIn`, `entryRefEdges`, `entryTitleIn`) are the
1912
1525
  extraction seam from the memory adapter, the same shape as the
1913
- `EditorView.tsx` and `ops-impl.ts` entries above.
1914
- - In a single-language project the entry form renders `slug` last:
1915
- `localizedFieldKeys` puts it in the `own` group, which follows
1916
- `shared`, and no section labels are shown — so the field order the
1917
- schema declares is not the order on screen.
1918
- - `admin-entries-list.test.ts` has no case for a row with differing
1919
- pointers (the `draft edits` status), nor one asserting that an absent
1920
- row's `updatedAt` falls back to the node's. Two assertions.
1921
- - `assertPublishable` (`store.ts`) awaits one `entries.get` per
1922
- referenced entry, sequentially, on every publish — a `refList` of a
1923
- dozen articles is a dozen serial reads, now per locale. `getMany(ids,
1924
- locale)` takes exactly that shape and would make it one call.
1925
-
1926
- ## Follow-ups (logged 2026-09-05)
1927
-
1928
- - A page's cached unit embeds its ancestors' and children's titles and
1929
- paths (the `page` prop) but is tagged `page:<own id>` only. The admin
1930
- ops now expire the neighbours they change — publish and delete the
1931
- parent, rename and move the subtree and both parents (2026-09-06) — so
1932
- what remains stale is a write that does not go through them: the seed,
1933
- a script, any out-of-band writer. Closing that needs one tree-wide tag
1934
- instead of a neighbour list (DESIGN.md OQ #26).
1935
- - The cached unit is keyed by (locale, path) but tagged by the id found at
1936
- the first miss, so a path that changes hands without both ids' tags firing
1937
- serves the previous occupant. Delete fires its own tag and the rename/move
1938
- fan-out covers the moving record; re-keying the unit by
1939
- (locale, kind, id) would remove the class (DESIGN.md OQ #26).
1940
- - `ancestors` on a page hit are NOT filtered by locale (children are): a
1941
- page ancestor whose `locales` exclude `fi` (`hasPage` true) still
1942
- produces a breadcrumb link whose path 404s in `fi`.
1943
- - `smoodlyMetadata` does not apply the registration check `renderSmoodlyPath`
1944
- applies: a record whose `template` has no registration is a 404 body with
1945
- its meta emitted beside it.
1946
- - `sections` is passed twice — to `createSmoodlySite` (ref resolution) and to
1947
- `createPageRenderer` (the component map) — and `elements` cannot be passed
1948
- at all; the two lists can drift silently.
1949
- - `getEntry` by bare slug matches `entrySegment` — the locale row's own
1950
- `slug`, the same value `paths` is written from — so the two cannot
1951
- diverge (the double-overlay hazard went with `EntryRecord.i18n`).
1952
- `getEntry` by id still reads the whole collection to find one row.
1953
- - `locate` (one primary-key hit on `paths`) runs per request outside the
1954
- cache, because the cache tag needs the id before the unit can be cached; a
1955
- later `"use cache"` migration (OQ #19) can fold it in.
1956
- - Entry-page metadata is alternates only — entries have no `meta`. A
1957
- `titleField`-based title is a later nicety.
1958
- - Per-locale section visibility conditions are not modelled (OQ #3). Tree
1959
- content itself is per locale now — each `page_locales` row points at its
1960
- own version — so the section fields a locale sees are its own.
1961
- - `getEntries`/`getEntriesTree` return refs as ids (cheap lists); `getEntry`
1962
- by path resolves them, `getEntry` by id or slug does not — document or
1963
- unify when a site needs resolved lists.
1964
- - `loadPage` calls `listPages()` for ancestors and children on every cache
1965
- miss; fine for hundreds of nodes, a `children(id)`/`chain(id)` store query
1966
- is the fix when a site outgrows it.
1967
- - Views rendered outside the site get `EMPTY_PAGE_CONTEXT`: tests and any
1968
- preview of a shape that isn't going through `site.load`.
1969
- - `loadEntry` probes each mounted collection in turn: the `paths` row
1970
- carries the target id but not its collection.
1971
- - A published entry under a draft parent is dropped from `getEntriesTree`
1972
- (the parent is not in the list to hang it from) but returned flat by
1973
- `getEntries`. Untested either way.
1974
- - `SmoodlyConfig.registry.pages` (`Registered` in `src/config.ts`) has no
1975
- call signature, so every app's glue casts it `as PageRegistration[]`.
1976
- - The entry-page view receives the whole `EntryRecord`, every locale's
1977
- row in `locales` included (fields, slug, status and version pointers); a
1978
- client entry page would ship all of it to the browser.
1979
- - `move` is best-effort across two statements (reorder, then path rewrite):
1980
- a failed rewrite restores each destination sibling's prior `sort` row by
1981
- row, so a failure in the rollback itself still leaves the order changed.
1982
- - The live suites run on vitest's DEFAULT 5s per-test timeout against a real
1983
- database, while a single contract test makes dozens of round trips. On a
1984
- loaded or degraded local stack `npm run test:supabase` then fails with bare
1985
- timeouts on arbitrary tests (every file passes on its own, and the whole
1986
- run passes with `--testTimeout=30000`). Either raise the timeout in
1987
- `scripts/test-supabase.sh` or accept the flake in CI.
1988
- - The `smoodly_reorder` RPC takes no parent scope — the caller always passes
1989
- one sibling set, so scoping is implicit. Give it a parent argument if a
1990
- caller ever passes a mixed list.
1991
- - An explicit JSON `null` VALUE in a declared `order.by` field sorts
1992
- FIRST in Supabase (a jsonb null is the lowest jsonb value, not SQL
1993
- NULL, so `nullsFirst: false` does not reach it) and LAST in memory. A
1994
- MISSING key is SQL NULL and sorts last in both, and the admin only ever
1995
- produces that — a cleared number field emits `undefined`, which the
1996
- JSON body drops — so this needs a programmatic or external writer.
1997
- Mixed types in one field diverge for the same reason: Postgres orders
1998
- jsonb by type first (null < string < number), memory by `String(...)`.
1999
- - Rollback gaps that remain: a `smoodly_reorder`/`smoodly_reorder_pages`
2000
- failure AFTER the re-parent update has no rollback at all; the entry
2001
- `create` rollback discards its delete result (an orphan row survives
2002
- silently); and a throw inside any rollback replaces — masks — the original
2003
- error.
2004
- - Duplicate sibling `sort` values are reachable — create uses count + 1 while
2005
- delete and move leave gaps — so `sort` is an ordering hint and creation
2006
- order breaks ties.
2007
- - Folders have path rows and resolve as `kind: "page"`; the published-only
2008
- read policy makes them public 404s, while the admin (write key) sees them.
2009
- - `setTemplate`/`saveDraft` insert the version row before the pointer update:
2010
- an infra failure between the two orphans a version.
2011
- - No `rebuildPaths()`: the index is documented as derived and rebuildable, but
2012
- nothing rebuilds it.
2013
- - Two `entryPage` registrations for one collection are silently accepted.
2014
- `PageStore.rename` still accepts a locale outside `supported`; the admin
2015
- op rejects it first, so only a direct store caller reaches it.
2016
- - Entry `move` coverage is thinner than pages' — no omitted-index, clamp or
2017
- cross-collection tests.
1526
+ `EditorView.tsx` and `ops-impl.ts` entries in this list. *(2026-09-06)*
2018
1527
  - The memory stores default to SEPARATE private path indexes while the
2019
1528
  Supabase stores share one `paths` table: pages and entries share a URL space
2020
1529
  only when the wiring passes one `paths` option to both (convention, not
2021
- structure).
1530
+ structure). *(2026-09-05)*
2022
1531
  - `SupabasePathIndex` falls back to `pathTakenError("?", "?")` when
2023
1532
  Postgres reports a unique violation without `details` — the message then
2024
- names no path.
1533
+ names no path. *(2026-09-05)*
2025
1534
  - `chainOf` has no cycle guard: a corrupted `parent_id` loop would spin
2026
- forever. The stores prevent cycles on `move`, so it is unreachable today.
2027
- - Design system pass (DESIGN.md §2, "The admin's visual system"): the shell,
2028
- lists, entry form, login and editor were restyled onto `admin/ui/theme.tsx`.
2029
- Deferred deliberately, because each is a feature rather than a restyle: the
2030
- list search field and status filter, the locale select in the shell's top bar
2031
- (the editor has one; the lists always read the default locale), the article
2032
- sidebar's taxonomy/SEO/publish-date sections, and the rich-text toolbar. The
2033
- pages list's tree shape (chevrons, depth indent) and its row `···` menu
2034
- shipped 2026-09-06, without the drag handle. The tokens for all of them are
2035
- already in place.
1535
+ forever. The stores prevent cycles on `move`, so it is unreachable today. *(2026-09-05)*
2036
1536
  - The `scroll` message in `admin/editor/protocol.ts` is no longer sent: with the
2037
1537
  canvas column as the scroller, `EditorView` scrolls it directly instead of
2038
1538
  asking the bridge to `scrollIntoView` across the frame. `EditorBridge` still
2039
1539
  handles the message, so old and new admins interoperate; drop the message on
2040
- the next protocol change.
1540
+ the next protocol change. *(2026-09-05)*
1541
+ - `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. *(2026-09-04)*
1542
+ - `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. *(2026-09-04)*
1543
+ - `serialize.ts` structural cast duplicates `PageSchema`; tighten `Registered.schema` so the cast goes. *(2026-09-03)*
1544
+ - `EditorView` split (autosave extracted 2026-09-03; the rest 2026-09-08, before the TipTap widget): `usePageLocale` (switch/add/remove, over `locale-url.ts`), `useCanvasBridge` (geometry, select/hover, `postToCanvas`, the geometry timeout), `CanvasOverlay` over `overlay-geometry.ts` (`plusButtonsFor`, pure and tested), `InsertPicker`, `Outline`, `NoticePane`. `EditorView.tsx` is ~660 lines of load, gestures, publish and layout. Remaining seam: the inspector's field form (`FieldWidget` rows plus the tabs) stays put until inline editing lands (DESIGN.md §3 puts TipTap in the canvas, not the sidebar). The ref-option loading became `forms/useRefOptions.ts` (2026-09-17). (logged 2026-09-06, updated 2026-09-08) *(2026-09-03)*
1545
+
1546
+ #### Tests
1547
+
1548
+ - The media test uploads a starter photo under a suffixed name rather
1549
+ than a fixture file, so `e2e/fixtures/` from the spec does not exist. *(2026-09-26, browser suite)*
1550
+ - ListView rows that are not clickable (Members) carry no role; the
1551
+ members test finds its row by `.sm-table__body > div`. A table role
1552
+ set (`table`/`row`/`cell`) on ListView would give every list a proper
1553
+ locator. *(2026-09-26, browser suite)*
1554
+ - The picker's click cannot be unit-tested (no DOM in the suite); the
1555
+ walk covers it. A jsdom environment for the admin tests is a future
1556
+ tooling item. *(2026-09-15, custom section)*
1557
+ - The list widget has no test for an object item holding a nested list
1558
+ subfield. *(2026-09-07, list field)*
1559
+ - The row menu's item set (`menuItems`) and the row-click routing live in
1560
+ `PagesList.tsx`, untested; extract them into `pages-tree.ts` before
1561
+ plan 4 adds entry rows to the same menu. *(2026-09-06)*
1562
+ - `publish`'s "has no draft to publish" branch is unreachable with the
1563
+ memory store — `createPage` gives every templated page a first version
1564
+ — and is untested. It is defence for the Supabase adapter. *(2026-09-06)*
1565
+ - Classifications the ops tests do not assert: a move that breaks the
1566
+ home page's no-children rule or exceeds the tree depth (create asserts
1567
+ both), and `entries.move`'s cross-collection and sibling-slug guards.
1568
+ `MemoryEntryStore.move` has no children guard at all — the subtree
1569
+ rides along — so `has child` is reachable from delete only. *(2026-09-06)*
1570
+ - No test pins that a published child's breadcrumb carries an UNPUBLISHED
1571
+ ancestor's title with `hasPage: false`; `site.test.tsx` covers the
1572
+ folder ancestor only, and the two paths share one `hasPage` predicate. *(2026-09-06)*
1573
+ - `MemoryPageStore.publish` throws the "is a folder" message when a
1574
+ templated row has no draft version. Unreachable through the ops (a
1575
+ templated page always gets a first version), but the message would be
1576
+ wrong if it ever were. *(2026-09-06)*
1577
+ - `admin-entries-list.test.ts` has no case for a row with differing
1578
+ pointers (the `draft edits` status), nor one asserting that an absent
1579
+ row's `updatedAt` falls back to the node's. Two assertions. *(2026-09-06)*
1580
+ - The live suites run on vitest's DEFAULT 5s per-test timeout against a real
1581
+ database, while a single contract test makes dozens of round trips. On a
1582
+ loaded or degraded local stack `npm run test:supabase` then fails with bare
1583
+ timeouts on arbitrary tests (every file passes on its own, and the whole
1584
+ run passes with `--testTimeout=30000`). Either raise the timeout in
1585
+ `scripts/test-supabase.sh` or accept the flake in CI. *(2026-09-05)*
1586
+ - Entry `move` coverage is thinner than pages' — no omitted-index, clamp or
1587
+ cross-collection tests. *(2026-09-05)*
1588
+
1589
+ ### Cloud
1590
+
1591
+ - A copy inside one database with fresh IDs — the cloud's "duplicate
1592
+ environment", staging → production; needs the reference walk (parent
1593
+ links, version pointers, refs, paths, asset ids in trees, fields and
1594
+ richtext) the eject avoids by keeping IDs. *(2026-09-28, export/import)*
1595
+ - Every op call with a junk token costs one `auth.getUser` against the
1596
+ auth project; rate limiting is the session-model spec's (OQ 20). *(2026-09-26, security pass)*
1597
+ - The social image URL goes through the default `next` loader at 1200px;
1598
+ the cloud's transform loader (OQ 28) should serve a real 1200×630. *(2026-09-10, page fields and SEO)*
1599
+ - A cross-origin admin needs the bridge-side editing variant (spec §3). *(2026-09-10, inline editing)*
1600
+ - Transform loaders beyond the Next default: Supabase Image
1601
+ Transformations, imgix, Cloudflare Images (DESIGN.md OQ 28). *(2026-09-08, media library)*
1602
+ - `<SmoodlyImage>` sizes the image by the crop factor, so `sizes` picks a
1603
+ candidate for the box, not for the framed image; a CDN loader with
1604
+ `handlesCrop` removes the overfetch. *(2026-09-08, media library)*
2041
1605
  - Light/dark toggle (nav foot): the choice is stored in `localStorage`
2042
1606
  (`smoodly-theme`) and written as `data-theme` on the admin wrapper, never on
2043
1607
  the host `<html>`. It is per browser, not per editor account — a server-side
2044
- preference would need a place to live (`members`, or a cloud user profile).
2045
- - `lucide-react` is now a runtime dependency, for the toggle's two icons. The
2046
- rest of the UI still uses the design system's unicode glyphs (↑ ↓ ⧉ × ··· ▾
2047
- ← ⋮⋮); swapping them for Lucide is the next step whenever it is wanted.
2048
- - `admin/ui/theme.tsx` pulls Public Sans from Google Fonts with an `@import`.
2049
- Fine today; a self-hosted font (or `next/font` in the glue layer) removes the
2050
- third-party request and the flash before it loads.
2051
-
2052
- ## Follow-ups (logged 2026-09-04)
2053
-
2054
- - Cloud-ready foundation — done, including the self-host init
2055
- (`create-smoodly-app`, spec 2026-09-04). Remaining from the spec: none
2056
- in the package. The two-mode contract suites and the isolation suite
2057
- run in CI in the `smoodly-supabase` job (a local stack on the runner) — if that job
2058
- proves flaky, the fallback is a Postgres service container applying
2059
- the one migration.
2060
- - Once the policy-based published read exists, decide whether it becomes the canonical headless read path and the generated per-collection views turn optional (cloud has no views).
2061
- - Add an index on `pages(published_version_id)`: the `page_versions` read policy's `exists` probe runs on every anon/read-key read and has no supporting index.
2062
- - A malformed `space_id`/`key_id` claim raises `22P02` inside the policy: fails closed but surfaces as a 500. Only reachable from a validly-signed key, so it is a cloud key-minting guard, not a request-path one.
2063
- - **Verified and kept 2026-09-26 (security pass §3)** — Authorization trusts the JWT claims alone; `smoodly_space_key_revoked()` checks only `revoked_at`. When cloud key-minting exists, make it a full key check (the claims must match the `space_keys` row).
2064
- - **Verified and kept 2026-09-26 (security pass §3)** — `memberOf` and `SupabaseMemberStore` read `members` (and `spaces`) 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.
2065
- - 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.
2066
- - 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).
2067
- - `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.
2068
- - `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.
2069
- - `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.
2070
-
2071
- ## Follow-ups (logged 2026-09-03)
2072
-
2073
- - Empty locked zones on the site side: `renderPage` renders nothing for a locked zone with no node. A page that predates the zone shows no prefooter on the published site until an editor opens it (the editor fills and saves). Options: render the component from `sample` when the zone is empty, or fill on `createPage`/schema load server-side. Fine for now — seeded and admin-created pages both arrive filled.
2074
-
2075
- - 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.
2076
- - `serialize.ts` structural cast duplicates `PageSchema`; tighten `Registered.schema` so the cast goes.
2077
- - Catch-all `templates` map should filter to open registrations to state its intent (unreachable today).
2078
- - 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. — Partly resolved 2026-09-26: the `browser` job builds the example site and drives it, but with a database; the no-database build is still unscripted.
2079
- - `EditorView` split (autosave extracted 2026-09-03; the rest 2026-09-08, before the TipTap widget): `usePageLocale` (switch/add/remove, over `locale-url.ts`), `useCanvasBridge` (geometry, select/hover, `postToCanvas`, the geometry timeout), `CanvasOverlay` over `overlay-geometry.ts` (`plusButtonsFor`, pure and tested), `InsertPicker`, `Outline`, `NoticePane`. `EditorView.tsx` is ~660 lines of load, gestures, publish and layout. Remaining seam: the inspector's field form (`FieldWidget` rows plus the tabs) stays put until inline editing lands (DESIGN.md §3 puts TipTap in the canvas, not the sidebar). The ref-option loading became `forms/useRefOptions.ts` (2026-09-17). (logged 2026-09-06, updated 2026-09-08)
1608
+ preference would need a place to live (`members`, or a cloud user profile). *(2026-09-05)*
1609
+ - Once the policy-based published read exists, decide whether it becomes the canonical headless read path and the generated per-collection views turn optional (cloud has no views). *(2026-09-04)*
1610
+ - A malformed `space_id`/`key_id` claim raises `22P02` inside the policy: fails closed but surfaces as a 500. Only reachable from a validly-signed key, so it is a cloud key-minting guard, not a request-path one. *(2026-09-04)*
1611
+ - **Verified and kept 2026-09-26 (security pass §3)** — Authorization trusts the JWT claims alone; `smoodly_space_key_revoked()` checks only `revoked_at`. When cloud key-minting exists, make it a full key check (the claims must match the `space_keys` row). *(2026-09-04)*
1612
+ - **Verified and kept 2026-09-26 (security pass §3)** — `memberOf` and `SupabaseMemberStore` read `members` (and `spaces`) 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. *(2026-09-04)*
1613
+
1614
+ ## Notes for maintainers
1615
+
1616
+ - The live suites wipe `members` and the content tables; a pane walk after
1617
+ them needs `npm run reseed` (or `scripts/demo-editor.ts`) first. *(2026-09-26, concurrency)*
1618
+ - Drafting version N+1: `npx tsx scripts/emit-migration.ts` prints the
1619
+ whole schema plus the upsert; keep only the delta, name the file with
1620
+ the current UTC stamp and `_smoodly_v<N+1>.sql`, bump `SCHEMA_VERSION`,
1621
+ run `npm run db:migrate` at the root so the repo copy follows, and
1622
+ `npm run check:migrations`. Additive only; a contraction waits for the
1623
+ compatibility window and carries `-- smoodly:contraction`. *(2026-09-26, schema migrations)*