smoodly 0.0.36 → 0.0.38
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.
- package/README.md +24 -1602
- package/dist/admin/fixed-nodes.d.ts +1 -1
- package/dist/admin/fixed-nodes.js +26 -1
- package/dist/admin/index.d.ts +1 -0
- package/dist/admin/index.js +1 -0
- package/dist/admin/ops-impl.js +21 -8
- package/dist/admin/shell/SchemaNotice.js +1 -1
- package/dist/admin/supabase-auth.js +10 -4
- package/dist/cli/add-admin.d.ts +11 -0
- package/dist/cli/add-admin.js +25 -0
- package/dist/cli/main.d.ts +5 -1
- package/dist/cli/main.js +23 -3
- package/dist/cli/migrate.d.ts +1 -1
- package/dist/cli/migrate.js +6 -1
- package/dist/cli/space-io.d.ts +1 -7
- package/dist/cli/space-io.js +2 -16
- package/dist/client.js +3 -1
- package/dist/content/content-api.d.ts +80 -0
- package/dist/content/content-api.js +152 -0
- package/dist/content/node.d.ts +7 -0
- package/dist/content/node.js +10 -0
- package/dist/entry-store.d.ts +1 -1
- package/dist/entry-store.js +1 -1
- package/dist/env.d.ts +6 -3
- package/dist/env.js +39 -15
- package/dist/index.d.ts +4 -3
- package/dist/index.js +1 -0
- package/dist/member-store.d.ts +12 -0
- package/dist/member-store.js +8 -0
- package/dist/model-key.d.ts +1 -0
- package/dist/model-key.js +36 -0
- package/dist/next/page-renderer.js +6 -5
- package/dist/next/queries.js +1 -1
- package/dist/schema-read.d.ts +7 -0
- package/dist/schema-read.js +15 -0
- package/dist/schema-version.d.ts +2 -0
- package/dist/schema-version.js +3 -1
- package/dist/schema.d.ts +10 -4
- package/dist/site.d.ts +8 -0
- package/dist/site.js +40 -2
- package/dist/supabase-member-store.d.ts +6 -0
- package/dist/supabase-member-store.js +20 -2
- package/dist/supabase-store.js +16 -14
- package/dist/tags.d.ts +3 -0
- package/dist/tags.js +16 -0
- package/dist/version.d.ts +1 -0
- package/dist/version.js +2 -0
- package/package.json +16 -13
package/README.md
CHANGED
|
@@ -1,1614 +1,36 @@
|
|
|
1
|
-
# smoodly
|
|
1
|
+
# smoodly
|
|
2
2
|
|
|
3
|
-
Define a component once in code
|
|
4
|
-
|
|
5
|
-
set. Content lives in Supabase; the admin mounts into
|
|
3
|
+
A code-first CMS for Next.js. Define a component once in code and it
|
|
4
|
+
becomes a typed, editable building block your editors compose, within
|
|
5
|
+
the rails you set. Content lives in Supabase; the admin mounts into
|
|
6
|
+
your own app at `/admin`.
|
|
6
7
|
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
A self-hosted app needs three environment variables: `SMOODLY_URL`,
|
|
10
|
-
`SMOODLY_SECRET_KEY` and `SMOODLY_PUBLISHABLE_KEY`.
|
|
11
|
-
Source and docs: https://github.com/laniakea-studio/smoodly-cms
|
|
12
|
-
|
|
13
|
-
The registration runtime — the real implementation of the API designed in
|
|
14
|
-
Phase 0 (`sketches/phase0/` remains the readable spec; `docs/DESIGN.md`
|
|
15
|
-
holds the decisions). Test-driven; run with `npm test`.
|
|
16
|
-
|
|
17
|
-
Implemented:
|
|
18
|
-
|
|
19
|
-
- `f.*` field builders — inert, immutable, JSON-serializable descriptors
|
|
20
|
-
(ref thunks excepted); validation lives server/admin-side by design rule
|
|
21
|
-
- `smoodly.schema.section/element` — descriptor capture + the
|
|
22
|
-
fields-only-`localized()` rule enforced at construction
|
|
23
|
-
- `smoodly.section/element(schema, View)` — the typed pairing: returns a
|
|
24
|
-
real component carrying `.schema`; drift between schema and view is a
|
|
25
|
-
compile error at the pairing; style select defaults apply in hard-coded
|
|
26
|
-
JSX use; `Props<typeof schema>` infers required/optional/select types
|
|
27
|
-
- `z.sections([...]).max(n)` / `z.freeform({ sections, custom? })` / `z.locked` — component
|
|
28
|
-
references in, names stored; `custom: true` lets editors add a custom section there
|
|
29
|
-
- `smoodly.toolset` — plain values, element refs serialized to names
|
|
30
|
-
- `smoodly.collection` — descriptors + admin hints; ref thunks resolve
|
|
31
|
-
post-evaluation and infer the target entry shape
|
|
32
|
-
- `defineConfig` — registry name-uniqueness, the `styles` CSS values
|
|
33
|
-
behind Smoodly's styles set (`spacing.sm/md/lg`, `width.narrow/wide/full`;
|
|
34
|
-
package fallbacks fill the rest) and the `css: true` gate for the
|
|
35
|
-
custom-CSS box (spec 2026-09-14)
|
|
36
|
-
- `<smoodly.Section>` — the root wrapper every section renders through:
|
|
37
|
-
`as`, Smoodly's `styles` steps rendered INLINE (`padding-top`,
|
|
38
|
-
`padding-bottom`, `text-align`, `--smd-width`; Default renders nothing),
|
|
39
|
-
the node's scoped `<style>` child (`[data-smd-node="…"]{…}`),
|
|
40
|
-
`data-smd-node` always, every other prop spread onto the root; settings
|
|
41
|
-
are never turned into attributes — the view writes its own
|
|
42
|
-
- Four namespaces on a node (spec 2026-09-14, plan A): `fields`
|
|
43
|
-
(content), `settings` (the developer's knobs — any builder, never
|
|
44
|
-
localized, interpreted by the view; `meta` is gone), `styles`
|
|
45
|
-
(Smoodly's set: `spacingTop`, `spacingBottom`, `width`, `align`, each
|
|
46
|
-
an optional radio — `styles: false | { omit }` on a schema opts out),
|
|
47
|
-
`css` (the snippet; `css: false` on a schema removes the box).
|
|
48
|
-
`styles.ts` holds the descriptors and `inlineStyles`; `css.ts` refuses
|
|
49
|
-
`@import`, `url(`, `expression(`, `behavior:`, `</`, more than 4000
|
|
50
|
-
characters and unbalanced braces — on the escape-decoded snippet too —
|
|
51
|
-
and `safeCss`/`scopedCss` strip and scope at render. Publish validates
|
|
52
|
-
settings and css (`validateNode`); saves stay lenient.
|
|
53
|
-
- Conditional fields (spec 2026-09-14 §12): every builder takes
|
|
54
|
-
`.hidden(f.when(path).is(v))` (`isNot`, `in`, `isEmpty`, `isNotEmpty`);
|
|
55
|
-
a bare path is a sibling, `fields.`/`settings.` crosses namespaces, an
|
|
56
|
-
unknown path throws at registration (`assertConditions`). One
|
|
57
|
-
`isHidden` in `conditions.ts` drives the form and the validator; a
|
|
58
|
-
hidden field's stored value is kept.
|
|
59
|
-
- The custom section (spec 2026-09-14 §6, plan B, 2026-09-15): the
|
|
60
|
-
editor's own block, `Custom section` at the bottom of the "+" picker in
|
|
61
|
-
a `z.freeform({ custom: true })` zone. One flat node `{ type: "custom",
|
|
62
|
-
fields: { rows: [{ spans, gap, align, reverse, columns: [richtext…] }] },
|
|
63
|
-
styles, css? }` — the shape is ordinary descriptors (`custom/schema.ts`,
|
|
64
|
-
the list carries `widget: "rows"`), so `descriptorAt`, the media walk,
|
|
65
|
-
the validator and inline editing understand it with no special case.
|
|
66
|
-
`custom/rows.ts` is the pure reconcile: `renormalize` (integers ≥ 1
|
|
67
|
-
summing to 12, largest remainder), `setColumnCount` (a new column takes
|
|
68
|
-
the last span; a removed column folds into the last survivor, blank
|
|
69
|
-
paragraphs dropped — never silent deletion), `setSpan`, `spanAtX`,
|
|
70
|
-
`presetsFor`, `rowsProblems` (`Spans must sum to 12.`, `Between 1 and 6
|
|
71
|
-
columns.`, `One span per column.`), `columnsLabel`. `custom/css.ts`
|
|
72
|
-
emits the scoped layout CSS from the stored numbers and whitelisted
|
|
73
|
-
keywords only — one grid per row by `data-row`, gap `1rem · 2rem ·
|
|
74
|
-
3rem`, one `@media (max-width: <breakpoint>)` that stacks every row
|
|
75
|
-
(`.smd-row--reverse` flips the stack); `createCustomSection` renders
|
|
76
|
-
through `<smoodly.Section>` with `data-custom`, `.smd-row` /
|
|
77
|
-
`.smd-col` marked `rows.<i>` / `columns.<j>`, richtext through the
|
|
78
|
-
project's bound `RichText` or the root renderer over `elements`, the
|
|
79
|
-
layout `<style>` before the editor's css so the box wins.
|
|
80
|
-
`config.custom: { breakpoint?, toolset?, RichText? }` (default `48rem`
|
|
81
|
-
and the richtext default toolset plus `image`; the breakpoint must be a
|
|
82
|
-
length) resolves onto `config.custom` / `site.custom`; `renderPage`
|
|
83
|
-
registers the renderer under the `custom` type; the resolver and both
|
|
84
|
-
page stores carry the custom schema (`withCustomSchema`,
|
|
85
|
-
`PageStoreOptions.custom`) so column images resolve and count as usage;
|
|
86
|
-
publish runs `validateCustomNode` (structure through `rowsProblems`,
|
|
87
|
-
the columns' richtext, the css). In the admin: `AdminRegistry.custom`
|
|
88
|
-
(one synthetic section), `admin/custom-section.ts`, the picker entry
|
|
89
|
-
`Custom section · Your own rows of columns`, `RowsWidget` (a card per
|
|
90
|
-
row: `Columns` stepper, `Widths` bar with `1/2 · 1/2`-style presets,
|
|
91
|
-
`Gap`, `Vertical align`, `Reverse on mobile`, `Column N` editors,
|
|
92
|
-
`+ Add row`) over `SpanBar` (pointer drag in twelfths, arrow keys),
|
|
93
|
-
`Custom section · N columns` labels, deep-copy on duplicate.
|
|
94
|
-
|
|
95
|
-
- `smoodly.schema.page` / `smoodly.page` / `<Zone>` / `renderPage` — the
|
|
96
|
-
pages runtime: policies normalized to plain data, tree nodes mapped to
|
|
97
|
-
registered components, fail-soft on unknown types
|
|
98
|
-
- `collectionSQL` / `coreTablesSQL` — the stable schema (DESIGN.md §3):
|
|
99
|
-
`coreTablesSQL` emits the once-ever core tables including `entries`,
|
|
100
|
-
`entry_locales` and `entry_versions` (shared fields on the node,
|
|
101
|
-
localized fields, slug and status per locale row, merged snapshots
|
|
102
|
-
per locale for versioned collections; `space_id` multi-tenancy with
|
|
103
|
-
a default space for self-hosted, `sort` for manual ordering, and no
|
|
104
|
-
sibling-slug index at all — sibling uniqueness is the primary key of
|
|
105
|
-
`paths` plus a pre-check the stores make per locale, for entries and
|
|
106
|
-
pages alike);
|
|
107
|
-
`collectionSQL` emits only the disposable per-collection guardrails —
|
|
108
|
-
a published-only read view joining `entries` and `entry_locales` (one
|
|
109
|
-
row per published locale, a `locale` column; a versioned collection
|
|
110
|
-
reads the published snapshot; typed casts, snake_case aliases, refs as
|
|
111
|
-
plain id values) and partial btree indexes for the declared `order.by`
|
|
112
|
-
field, on the jsonb VALUE (`("fields"->'x')`)
|
|
113
|
-
- `PageStore` contract + `MemoryPageStore` — id-keyed page persistence
|
|
114
|
-
over pages/page_locales/page_versions/paths — one node, one row per
|
|
115
|
-
locale, each locale with its own tree, draft, publish and history
|
|
116
|
-
(spec 2026-09-06). Every read and write takes the record's `id`, and
|
|
117
|
-
the URL is derived from the tree, never stored on the record. Pages
|
|
118
|
-
form a tree (`parentId`, sibling `sort`, a null `template` = folder,
|
|
119
|
-
`pages.tree.depth` capping how deep editors may nest), and expose
|
|
120
|
-
`rename` (a locale's slug/title), `move`, `setTemplate`, `addLocale`,
|
|
121
|
-
`removeLocale`, `resolvePath(locale, path)` and `pathsOf(id)` alongside
|
|
122
|
-
the draft/publish semantics (one version row per publish plus the live
|
|
123
|
-
draft, rewritten in place; publish moves that locale's pointer and
|
|
124
|
-
freezes the row; discard deletes the abandoned draft; `restore` writes
|
|
125
|
-
an old version as the draft; drafts never leak into published reads). The contract
|
|
126
|
-
is a reusable suite (`test/page-store-contract.ts`); every adapter runs
|
|
127
|
-
the same tests.
|
|
128
|
-
- `SupabasePageStore` — the same id-keyed contract over
|
|
129
|
-
pages/page_locales/page_versions/paths (a server-side client from
|
|
130
|
-
`createSmoodlyClient`: the service role in self-host, a write key in
|
|
131
|
-
the cloud). Contract-tested against a real local stack: `supabase
|
|
132
|
-
start` at the repo root (the generated DDL is its one migration), then
|
|
133
|
-
`npm run test:supabase`; plain `npm test` skips it and never needs
|
|
134
|
-
Docker.
|
|
135
|
-
- `paths.ts` — the ONE place a URL is derived: pure, framework-free
|
|
136
|
-
functions (`segmentFor` — a locale's own segment, no fallback —
|
|
137
|
-
`chainOf`, `pagePath`, `pagePathIn` (nullable, for the admin),
|
|
138
|
-
`pagePathRows`, `entryPathRows`, `entrySegment` (a locale's own slug,
|
|
139
|
-
no fallback), `assertSupportedLocale` (both stores), `buildPageTree`,
|
|
140
|
-
segment and depth assertions) that both adapters and the tree query call, so the index
|
|
141
|
-
and every derived `path` agree by construction. `pagePath` is the
|
|
142
|
-
single page-path derivation — `pagePathRows`, `buildPageTree` and the
|
|
143
|
-
site layer all go through it.
|
|
144
|
-
- `PathIndex` — the derived URL index, `MemoryPathIndex` and
|
|
145
|
-
`SupabasePathIndex` over the `paths` table ((space, locale, path) → page
|
|
146
|
-
or entry). Pages and entries share ONE instance, so a page and an entry
|
|
147
|
-
can never claim the same URL; the Supabase adapter rewrites a whole
|
|
148
|
-
subtree in one statement through the `smoodly_rewrite_paths` SQL
|
|
149
|
-
function, and a collision surfaces as "the path … is already taken".
|
|
150
|
-
- Collections declare their own URL shape: `path` (the code-owned base
|
|
151
|
-
segment, per locale — absent = the collection has no URLs), `tree`
|
|
152
|
-
(entries nest under entries up to a depth), and `order` (`"manual"` or
|
|
153
|
-
`{ by, direction }`, replacing `admin.orderBy` and driving both the
|
|
154
|
-
store's list order and the generated index). A declared field orders by
|
|
155
|
-
its jsonb VALUE, so numbers sort numerically and strings — ISO dates
|
|
156
|
-
included — alphabetically, the same in Supabase as in memory.
|
|
157
|
-
- `smoodly.entryPage(collection, View)` — a paired component registered
|
|
158
|
-
like a page (`kind: "entryPage"`, name `entry:<collection>`), served for
|
|
159
|
-
every entry of the collection by the catch-all through
|
|
160
|
-
`renderSmoodlyPath`.
|
|
161
|
-
- `resolveTree` — the batched ref-resolution pass: one fetch per
|
|
162
|
-
collection per round, ref-site depth modes (bare ref = entry only,
|
|
163
|
-
`.resolve(names)` = named refs one level, bare `.resolve()` = whole
|
|
164
|
-
chain), per-path cycle cutting with shallow copies, optional `maxDepth`
|
|
165
|
-
safety valve (default unlimited)
|
|
166
|
-
- `resolveFields` — the same pass over ONE entry's fields against its
|
|
167
|
-
collection's descriptors, for the entry-page route: the shared task
|
|
168
|
-
collection and batched rounds are factored out, so a ref site behaves
|
|
169
|
-
identically whether it sits in a page tree or in an entry.
|
|
170
|
-
- `localize.ts` — `fixedPathFor` (the path a fixed page registration
|
|
171
|
-
is served at in one locale — the escape hatch's addressing; a slug map
|
|
172
|
-
that omits a locale means "the same segment", since a registration is
|
|
173
|
-
code, not a record). Pages themselves have no fallback: title and slug
|
|
174
|
-
live on `page_locales`, and slugs stay `paths.ts`'s business.
|
|
175
|
-
|
|
176
|
-
- `collectEntryRefs` / `collectTreeRefs` — the refs-index feeders: walk
|
|
177
|
-
an entry or a page tree against its schemas and list every outgoing
|
|
178
|
-
reference edge (derived, rebuildable, never the source of truth)
|
|
179
|
-
- Page saves feed the refs index — a page's rows are the UNION of its
|
|
180
|
-
draft and published trees' edges (source kind `page`), rewritten on
|
|
181
|
-
every `saveDraft`/`publish`, so cross-kind safe-delete holds while
|
|
182
|
-
either tree shows an entry. Publish integrity: `publish` walks the
|
|
183
|
-
draft tree directly and blocks on missing/unpublished entries.
|
|
184
|
-
Revalidation fan-out: `affectedTargets` — reverse BFS over refs
|
|
185
|
-
(entry ← entry ← page, cycle-safe, optional `maxDepth`) returning
|
|
186
|
-
`{ pageIds, entryIds }`: every PUBLISHED page whose tree reaches the
|
|
187
|
-
changed entry, and the entry itself plus every entry that references it.
|
|
188
|
-
Ids, not slugs — `pageTag(id)`/`entryTag(id)` name the cache tags, and
|
|
189
|
-
the op handler's `revalidate` effect passes each to Next's `updateTag`,
|
|
190
|
-
so a rename never orphans a tag. All wired via
|
|
191
|
-
store constructor options (`sections`, `entries`, shared
|
|
192
|
-
`MemoryRefIndex` in memory); the cross-store contract
|
|
193
|
-
(`test/page-refs-contract.ts`) runs against memory AND Supabase.
|
|
194
|
-
- Media (spec 2026-09-08): `f.image({ aspect? })` / `f.video()` / `f.file()`
|
|
195
|
-
store `{ asset, alt?, crop?, hotspot?, aspect? }`; `AssetStore` (memory +
|
|
196
|
-
`SupabaseAssetStore`: rows in `assets`, bytes in the public `media`
|
|
197
|
-
bucket, two-op signed uploads); the refs index carries `asset` edges
|
|
198
|
-
through lists, objects and styles, so delete is guarded and usage is
|
|
199
|
-
listed; `resolveTree`/`resolveFields`/the site layer resolve media to
|
|
200
|
-
`{ url, width, height, mime, alt, crop?, hotspot?, aspect? }` with one
|
|
201
|
-
fetch per page; `smoodly/image` ships `<SmoodlyImage>` (a box at the
|
|
202
|
-
aspect, the image framed by crop and hotspot over `next/image`),
|
|
203
|
-
`<SmoodlyVideo>`, `imageUrl` behind the loader seam (v1: the Next
|
|
204
|
-
loader only) and `smoodlyImages()` for next.config's `images`. Admin: the media
|
|
205
|
-
widget in every form, the library picker, the crop-and-hotspot editor,
|
|
206
|
-
the Media screen with metadata, usage links and guarded delete.
|
|
207
|
-
- `EntryStore` contract + `MemoryEntryStore` + `SupabaseEntryStore` —
|
|
208
|
-
collection-entry persistence over entries/entry_locales/entry_versions/paths
|
|
209
|
-
— one node, one row per locale, each locale with its own slug, status
|
|
210
|
-
and `.localized()` fields, shared fields on the node, and a per-locale
|
|
211
|
-
version history for every collection (spec 2026-09-14; the 2026-09-06
|
|
212
|
-
opt-in is gone): `create({ locale })`, `update(id, locale, fields)`,
|
|
213
|
-
`setStatus(id, locale, status)`, `addLocale`, `removeLocale`,
|
|
214
|
-
`listVersions`, `getVersion` and `restore`; the draft row is rewritten
|
|
215
|
-
in place until published (spec 2026-09-13 history); saves rewrite the entry's refs rows over every locale's
|
|
216
|
-
live field set, `referencesTo` answers the reverse
|
|
217
|
-
question, `getMany` is the batched read whose signature is
|
|
218
|
-
`resolveTree`'s `EntryFetcher`, now `(collection, ids, locale, { status })`,
|
|
219
|
-
the published read of a versioned collection being the snapshot
|
|
220
|
-
(with `{ status: "published" }` for the
|
|
221
|
-
published-route read — a post-publish demotion resolves as missing
|
|
222
|
-
instead of leaking draft fields — DESIGN.md §3 "Page edges in refs"), deletes are blocked while
|
|
223
|
-
referenced,
|
|
224
|
-
entry slugs are unique among SIBLINGS per locale in both adapters
|
|
225
|
-
(a code pre-check; the `paths` primary key is the invariant),
|
|
226
|
-
and `reorder` rewrites sort 1..N through the `smoodly_reorder` SQL
|
|
227
|
-
function (one statement, `updated_at` untouched). Same
|
|
228
|
-
reusable-contract pattern as PageStore; `npm run test:supabase` runs
|
|
229
|
-
both adapters against the local stack
|
|
230
|
-
|
|
231
|
-
- Proven end-to-end in `examples/site` — a real Next.js app wiring the
|
|
232
|
-
config + Supabase stores into routes: tag-cached resolved trees,
|
|
233
|
-
draft-mode preview, server-action writes with revalidation fan-out.
|
|
234
|
-
The example is the multilingual layout (next-intl, `en`/`fi`, the site
|
|
235
|
-
under `app/[locale]/`, the admin outside it), and `create-smoodly-app`
|
|
236
|
-
generates both of its templates from it — `templates/next-intl`
|
|
237
|
-
verbatim, `templates/next` with the four single-language route files
|
|
238
|
-
from `variants/single/` and two rewritten lines of glue
|
|
239
|
-
|
|
240
|
-
- The space layer (spec 2026-09-04 §3): `space_id` on every content
|
|
241
|
-
table, `smoodly_current_space()`, the insert trigger, the row cap, the
|
|
242
|
-
three cloud-state tables (`members`, `space_keys`, `spaces`) and
|
|
243
|
-
the policy set. Service role bypasses it; a write key is scoped to its
|
|
244
|
-
space. Contract suites run in both modes; `test/isolation.supabase.test.ts`
|
|
245
|
-
proves isolation. Generated per-collection views are `security_invoker`,
|
|
246
|
-
so they read through the same policy set rather than their owner's
|
|
247
|
-
privileges. Live acceptance test in `test/rls.supabase.test.ts`. Note
|
|
248
|
-
for self-host: the anon key now reads published rows straight off the
|
|
249
|
-
base tables (`pages` and `entries` published in any locale, a published
|
|
250
|
-
`page_locales`/`entry_locales` row, the `page_versions`/`entry_versions`
|
|
251
|
-
row a published locale points at — shared content included, since it
|
|
252
|
-
is stored as entries, spec 2026-09-07), not only through the generated
|
|
253
|
-
views.
|
|
254
|
-
|
|
255
|
-
- Admin login: `supabaseAdminAuth` (from `smoodly/admin`) + `gateAdminOp` around the ops seam;
|
|
256
|
-
`LoginView` + `useAdminSession` in the shell; `createSmoodlyAdmin({ auth })`.
|
|
257
|
-
Env: `smoodlyEnv()` / `createSmoodlyClient()`.
|
|
258
|
-
|
|
259
|
-
- The admin, real and mounted: `smoodly/admin` (the AdminOps seam —
|
|
260
|
-
`createAdminOps`/`runAdminOp` server-side over the stores,
|
|
261
|
-
`makeClientOps` client-side forwarding `{ path, args }` through one
|
|
262
|
-
server action; typed `OpResult`, never throws — `serializeAdminConfig`
|
|
263
|
-
turns the registry into the admin's nav/forms; `validateFields` +
|
|
264
|
-
tree-ops for pure canvas mutations), `smoodly/admin/next`
|
|
265
|
-
(`createSmoodlyAdmin` binds registry + op action into `AdminPage`/
|
|
266
|
-
`AdminLayout` for the app's `[[...segments]]` route group;
|
|
267
|
-
`createAdminOpHandler` is the one server action the client ops call),
|
|
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
|
|
271
|
-
marked-field geometry, forwards select/hover with the field under the
|
|
272
|
-
pointer, executes refresh/scroll, reports each committed render). Shell
|
|
273
|
-
(registry-derived nav, list views, its own client-side router —
|
|
274
|
-
`shell/router.ts`, `RouteLink`, a tab-close guard for pending saves; the
|
|
275
|
-
shell mounts from the layout and the catch-all page only makes the
|
|
276
|
-
URL exist, spec 2026-09-08),
|
|
277
|
-
entry form (widgets by field type, validation errors surfaced inline,
|
|
278
|
-
save/publish/unpublish/delete with the safe-delete guard's message
|
|
279
|
-
shown verbatim), and the editor view (dark instrument around a bright
|
|
280
|
-
canvas — the iframe is the site's own draft route, pixel-identical by
|
|
281
|
-
construction; every gesture mutates client tree state through pure
|
|
282
|
-
ops, a debounced `saveDraft` persists, and the bridge refreshes the
|
|
283
|
-
iframe on the save ack — refresh-on-change preview for structure; text
|
|
284
|
-
and richtext are edited in place in the canvas and inspector text edits
|
|
285
|
-
land there the same tick, spec 2026-09-10) are all real, in-house. The autosave itself is a framework-free
|
|
286
|
-
controller (`createDraftAutosave`: debounce, doubling capped retry,
|
|
287
|
-
pre-publish `flush`) wrapped by `useDraftAutosave`; the controller is
|
|
288
|
-
unit-tested with fake timers, the hook only for its initial snapshot. Proven end-to-end in
|
|
289
|
-
`examples/site/app/(smoodly)/admin` against the local Supabase stack —
|
|
290
|
-
see its README's acceptance walk.
|
|
291
|
-
|
|
292
|
-
- The admin is the page TREE (spec 2026-09-05 §7): the ops seam addresses
|
|
293
|
-
pages by id — `pages.list`/`get`/`create`/`saveDraft`/`publish`/
|
|
294
|
-
`delete`/`rename`/`move`/`setTemplate`, plus `entries.move`.
|
|
295
|
-
`entries.get`/`create`/`save`/`setStatus` take a locale;
|
|
296
|
-
`entries.addLocale`/`removeLocale`.
|
|
297
|
-
`pages.list` materializes the code-owned root nodes before an editor
|
|
298
|
-
touches them (`admin/fixed-nodes.ts` derives fixed pages and collection
|
|
299
|
-
mounts from the registry and finds their records by the `pages.fixed`
|
|
300
|
-
owner key, schema v5), and every guard asks
|
|
301
|
-
the same `fixedNodeOf`: a fixed node is never renamed, moved or
|
|
302
|
-
deleted, and no page is created under a mount, and a `homePageSlug`
|
|
303
|
-
guard keeps the site root from being deleted, moved or re-slugged
|
|
304
|
-
even without a fixed registration. `pages.get` returns the
|
|
305
|
-
record, its draft, `paths` (locale → CMS path) and `urls` (`href`
|
|
306
|
-
applied), so the canvas and "View live" get site URLs. A tree write
|
|
307
|
-
expires the neighbours whose cached units embed it: publish and delete
|
|
308
|
-
the record and its parent, rename the subtree and its parent, move the
|
|
309
|
-
subtree plus the old and the new parent; `entries.move` expires the
|
|
310
|
-
entry and its descendants. The Pages list IS that tree — `admin/shell/pages-tree.ts`
|
|
311
|
-
is the pure view model (indent per depth, chevron on parents, the
|
|
312
|
-
derived path as the URL column, and per row its kind, status, sibling
|
|
313
|
-
position and whether it may take children), rendered by
|
|
314
|
-
`admin/shell/PagesList.tsx` with a row menu over the tree ops (New
|
|
315
|
-
subpage, New subpath, Add page here, Rename…, Delete — reordering is
|
|
316
|
-
the grip's: drag or its arrow keys, since 2026-09-15); a mount a fixed
|
|
317
|
-
page claims opens that index page's editor
|
|
318
|
-
on click like any page row (2026-09-09), a bare mount opens the
|
|
319
|
-
collection list. The editor
|
|
320
|
-
is keyed by id, with a locale switcher loading that locale's own tree
|
|
321
|
-
and pointing the canvas at `urls[locale]` and, while no block is
|
|
322
|
-
selected, page settings (`admin/editor/PageInspector.tsx`, spec
|
|
323
|
-
2026-09-10 page fields: **SEO & Social** — title, description, social
|
|
324
|
-
image, hide from search engines, under the tree's `meta` — and
|
|
325
|
-
**Settings** — the ACTIVE locale's title and slug through `pages.rename`,
|
|
326
|
-
the template's page fields under the tree's `fields`, remove from
|
|
327
|
-
locale). The entry form prefixes its slug field with the collection's
|
|
328
|
-
mount path. The serialized
|
|
329
|
-
registry carries `collections[].path`/`depth`, `pages[].slugs` and
|
|
330
|
-
`pageDepth`, and `defineConfig` validates `homePageSlug` — a real
|
|
331
|
-
segment, and never a collection's mount.
|
|
332
|
-
|
|
333
|
-
- `createSmoodlySite` — the site layer (`site.ts`), framework-free: one
|
|
334
|
-
place turns a (locale, path) into a loaded page or entry, so the Next
|
|
335
|
-
renderer and the content API can never disagree about published-only
|
|
336
|
-
reads, folders or fallback. `locate(locale, path)` is one primary-key
|
|
337
|
-
hit on the `paths` index; `load(target, locale, path, { draft? })`
|
|
338
|
-
fetches the record and its tree (draft or published), resolves refs,
|
|
339
|
-
reads the locale's own rows, and returns a `SitePage` — `record`,
|
|
340
|
-
`template`, `tree`, localized `title`, `path`, `ancestors`, `children`,
|
|
341
|
-
`locale` and `locales` — the page's `{ slug, path }` in every supported
|
|
342
|
-
locale, null where it is absent or, outside draft mode, unpublished
|
|
343
|
-
(spec 2026-09-15) — or a `SiteEntry` with the locale's merged fields (a
|
|
344
|
-
versioned collection's published snapshot on the public path) resolved,
|
|
345
|
-
`locale`, and the same `locales` map (an entry's slug and path are null
|
|
346
|
-
where its row claims no URL). `getEntries` items and `getEntry` carry
|
|
347
|
-
`locale` and `locales` too.
|
|
348
|
-
`resolve(locale, path, { draft? })`
|
|
349
|
-
is the two in sequence — `locate` then `load` — for a caller that just
|
|
350
|
-
wants the hit. A folder (null template), an
|
|
351
|
-
unpublished record outside draft mode, or a missing one is `null`.
|
|
352
|
-
On top of it the content API for menus, sitemaps and custom routes,
|
|
353
|
-
published-only unless `draft: true` (spec 2026-09-11):
|
|
354
|
-
`getPagesTree({ locale, draft? })` (nested
|
|
355
|
-
`{ id, title, path, hasPage, fields, children }`), `getPage(pathOrId)`,
|
|
356
|
-
`getEntries(collection, { locale, parent?, order?, where?, limit?, draft? })`
|
|
357
|
-
— `where` is equality / any-of on shared fields and `limit` a SQL limit,
|
|
358
|
-
both run by the store — `getEntry(collection, idOrPath)` (an id, a
|
|
359
|
-
path, or a bare slug naming a root entry) and `getEntriesTree(collection)`.
|
|
360
|
-
In the app, read through `q` (next bullet) rather than the site layer.
|
|
361
|
-
- `createSmoodlyQueries` (`smoodly/next`, spec 2026-09-11) — the read
|
|
362
|
-
API the scaffold exports as `q`: `getPageTree({ locale })`,
|
|
363
|
-
`getPage(pathOrId)`, `getEntries("articles", { where, order, limit,
|
|
364
|
-
parent })`, `getEntry("articles", idOrPath)` (an id or a path — a bare
|
|
365
|
-
slug is refused, since its unit would sit under a tag no op fires) and
|
|
366
|
-
`getGlobal("header")`. Typed by the registry (names are checked, fields
|
|
367
|
-
typed), the live draft inside the editor preview, and outside it one
|
|
368
|
-
`unstable_cache` unit per distinct call under a tag the admin ops
|
|
369
|
-
expire: `pages:tree` (every page op that changes the listing),
|
|
370
|
-
`entries:<collection>` (every entry write), `page:<id>`, `entry:<id>`,
|
|
371
|
-
`global:<name>`. `filterPageTree(items, keep)` prunes a tree for a nav
|
|
372
|
-
(a dropped parent drops its subtree).
|
|
373
|
-
- `smoodly/next` — `createPageRenderer`, the site-route seam over that
|
|
374
|
-
layer, bound once by the app like `createAdminOpHandler`. The two route
|
|
375
|
-
files call `renderSmoodlyPath(segments, { locale?, searchParams? })` and
|
|
376
|
-
`smoodlyMetadata(segments, { locale? })`: a page at any depth in any
|
|
377
|
-
locale rendered by the shape its `template` names, with the `page` prop
|
|
378
|
-
(`{ id, title, path, url, ancestors, children }`, every ancestor and
|
|
379
|
-
child carrying `url` beside its locale-relative `path`, both put through
|
|
380
|
-
the `href` seam) beside `zones`; a collection
|
|
381
|
-
entry rendered by its `smoodly.entryPage` as `{ entry, path, locale, locales }`; a folder,
|
|
382
|
-
an absent locale or an unregistered template a 404. The published read is
|
|
383
|
-
cached per (locale, path) and tagged `page:<id>` / `entry:<id>` — the
|
|
384
|
-
`locate` hit runs outside the cache, because the tag needs the id first.
|
|
385
|
-
Draft mode reads drafts uncached, and the editor param mounts
|
|
386
|
-
`EditorBridge`. Metadata is the tree's `meta` plus
|
|
387
|
-
`alternates.languages`, the locales with a URL in the hit's `locales` map
|
|
388
|
-
through the injectable `href(path, locale)`
|
|
389
|
-
seam (identity by default, `getPathname` with next-intl) and emitted only
|
|
390
|
-
for a multi-locale site. `renderSmoodlyPage(Paired, …)` and
|
|
391
|
-
`smoodlyPageMetadata` remain as the escape hatch for a FIXED page
|
|
392
|
-
rendered from the developer's own route file; each serves only its own
|
|
393
|
-
registration's record. All four await Next's
|
|
394
|
-
`connection()` before reading the stores, so a route with no dynamic
|
|
395
|
-
segment is request-rendered without a `force-dynamic` export and
|
|
396
|
-
`next build` never reaches the database
|
|
397
|
-
(`test/next-page-renderer.test.tsx`). Page schemas may declare a fixed `slug`
|
|
398
|
-
(owned by code, not by editors); the admin reads it to filter
|
|
399
|
-
New Page and protect the record. `defineConfig({ homePageSlug })`
|
|
400
|
-
(default `"home"`) names the slug the site root serves; the paths index
|
|
401
|
-
derives every URL from it, and `pages.get(id)` hands the admin the
|
|
402
|
-
finished `paths` (locale → CMS path) and `urls` (`href` applied), so the
|
|
403
|
-
admin no longer derives a path of its own.
|
|
404
|
-
- `smoodly/image` — the site's media components: `<SmoodlyImage>`,
|
|
405
|
-
`<SmoodlyVideo>`, `imageUrl(image, { width, quality? }, loader?)`,
|
|
406
|
-
`frameFor`/`boxAspectOf` (the crop math). Server-safe, no `"use client"`.
|
|
407
|
-
- `smoodly/image/config` — `smoodlyImages()` for `next.config`'s `images`:
|
|
408
|
-
the Supabase host as a remote pattern, plus `dangerouslyAllowLocalIP`
|
|
409
|
-
when that host is loopback or private — Next 16's optimizer refuses a
|
|
410
|
-
private upstream otherwise, and the local stack is one. Its own entry
|
|
411
|
-
because Next loads `next.config.ts` in plain Node, where anything that
|
|
412
|
-
reaches `next/image` fails to resolve (next@16 has no `exports` map);
|
|
413
|
-
`smoodly/image` still re-exports the helper for code the bundler sees.
|
|
414
|
-
- `smoodly/richtext` — the bound richtext renderer, `createRichText({
|
|
415
|
-
elements, sizes })`: the root `RichText` with the project's elements and
|
|
416
|
-
body images through `<SmoodlyImage>`. Its own entry for the same reason
|
|
417
|
-
as `smoodly/image`: it reaches `next/image`.
|
|
418
|
-
|
|
419
|
-
- Locked zones are usable: `fillLockedZones` (a pure tree op) materializes
|
|
420
|
-
the one node of every empty locked zone from the component's `sample`
|
|
421
|
-
when the editor loads a draft, and the change rides the normal debounced
|
|
422
|
-
save. No gesture can insert into or remove from a locked zone, so this is
|
|
423
|
-
the only way the node gets there — it also covers pages that existed
|
|
424
|
-
before a locked zone was added to their schema.
|
|
425
|
-
|
|
426
|
-
- Global content (spec 2026-09-07; `shared` → `global` in code
|
|
427
|
-
2026-09-15, spec 2026-09-14 §7): `smoodly.global({ name, title, section })`
|
|
428
|
-
registers a visual item, `smoodly.global({ name, title, fields })` an
|
|
429
|
-
object item; one registration materializes as a singleton entry in
|
|
430
|
-
every supported locale. `site.getGlobal(item, { locale })` and the Next
|
|
431
|
-
glue's `q.getGlobal("footer", { locale })` fetch it code-side (the
|
|
432
|
-
renderer's `getSmoodlyGlobal(item, { locale })` still works); the
|
|
433
|
-
editor places a visual item in a zone from the "+" picker as a linked
|
|
434
|
-
`{ type: "global", fields: { item, ref } }` node the resolve pass
|
|
435
|
-
rewrites into the section, and `z.locked(footer)` binds a zone to one.
|
|
436
|
-
Edited on its own admin page under Globals (`/admin/globals/<name>`),
|
|
437
|
-
with "used on N pages" from `refs`. Wired through the `globals` option
|
|
438
|
-
on the entry store and the site layer; a visual item's settings, styles
|
|
439
|
-
and css ride its row under `$settings`, `$styles` and `$css`.
|
|
440
|
-
|
|
441
|
-
- Lists and editable objects (spec 2026-09-07 list field): `f.list(item)`
|
|
442
|
-
holds any builder but another list and refuses a ref or `.localized()`
|
|
443
|
-
anywhere inside (refs are top-level only; localization is whole-value on
|
|
444
|
-
a list or object). `.min`/`.max` count items. The admin edits an object
|
|
445
|
-
as an indented fieldset and a list as rows — summary, collapse, move up
|
|
446
|
-
and down, remove, "N of max", Add disabled at max — recursively through
|
|
447
|
-
`FieldWidget`, with the row logic as pure functions in `admin/forms/list.ts`.
|
|
448
|
-
`validateFields` recurses with dotted paths (`points.2.heading`) and
|
|
449
|
-
checks list length; the serializer ships a list's `item`.
|
|
450
|
-
|
|
451
|
-
- Richtext, both directions: `RichText` (package root, pure React, no
|
|
452
|
-
wrapper) turns the TipTap document into paragraphs, headings, lists,
|
|
453
|
-
blockquotes, hard breaks and bold/italic/link marks, unknown nodes
|
|
454
|
-
degrading to their children; a link mark reaches the page only with a
|
|
455
|
-
safe scheme (http(s), mailto, tel, site-relative). The admin edits it
|
|
456
|
-
with a TipTap widget under a fixed toolbar (`admin/forms/RichtextWidget.tsx`,
|
|
457
|
-
spec 2026-09-08): the schema is always the full one, and the field's
|
|
458
|
-
toolset — typed names, `DEFAULT_TOOLSET` when a field declares none —
|
|
459
|
-
gates only creation (toolbar, shortcuts, input rules, paste), so content
|
|
460
|
-
a toolset forbids survives every round trip. Links carry `href` only. An
|
|
461
|
-
empty document is missing for a required field. The widget emits plain
|
|
462
|
-
JSON: ProseMirror's node attrs have a null prototype, which a server
|
|
463
|
-
action cannot serialize. Block nodes (spec 2026-09-08 block nodes): an
|
|
464
|
-
`image` node (media library, caption, alt override) and one generic
|
|
465
|
-
`element` node for every registered Element, always in the schema,
|
|
466
|
-
inserted through the toolbar's "+" or a `/` menu, edited as cards (the
|
|
467
|
-
element card renders its fields through `FieldWidget`, nested richtext
|
|
468
|
-
included). `RichText` takes `elements` and an `image` render prop;
|
|
469
|
-
`smoodly/richtext` exports `createRichText` bound over `<SmoodlyImage>`.
|
|
470
|
-
The media and refs walks descend into documents against the element
|
|
471
|
-
schemas (`elements` beside `sections` in the store, resolve and site
|
|
472
|
-
options).
|
|
473
|
-
- The token guard and presence (spec 2026-09-26 concurrency): every
|
|
474
|
-
draft write carries `{ versionId, savedAt }` from the locale row's
|
|
475
|
-
`draftSavedAt` and is refused as `conflict` + `stale` when the draft
|
|
476
|
-
moved (the write itself is conditional in Supabase); the admin freezes
|
|
477
|
-
under a notice naming who saved and when and reloads through "Load
|
|
478
|
-
their version"; one private Realtime channel per space shows who edits
|
|
479
|
-
what (a lock in the lists, a line under the top bar) and announces
|
|
480
|
-
saves so a clean view refreshes and a dirty one freezes early. Schema
|
|
481
|
-
version 2 adds the draft-pointer foreign keys and the channel policies.
|
|
482
|
-
Only the editor's own edits freeze a view (2026-09-27): two editors
|
|
483
|
-
opening a fresh page at once both fill its locked hero, and the fill
|
|
484
|
-
that lands second used to freeze its view under "Your unsaved changes
|
|
485
|
-
here will be discarded" with nothing changed; now that view loads the
|
|
486
|
-
other's quietly, on the refusal or the `saved` broadcast. This was the
|
|
487
|
-
local-only timeout of "the guard alone" (CI's slower sign-in let the
|
|
488
|
-
first fill land first). Tests: `concurrency.spec.ts` ("two editors
|
|
489
|
-
opening a new page at once", with the channel cut and on; the fill
|
|
490
|
-
save is held so the race is deterministic).
|
|
491
|
-
- ActionMenu near the screen's edge (2026-09-27): a row menu close to
|
|
492
|
-
the bottom opened below the viewport, where a fixed element cannot be
|
|
493
|
-
scrolled to, and the scroll event from bringing a row into view
|
|
494
|
-
arrived a frame after the click and closed the menu it had opened.
|
|
495
|
-
It now opens upward by the Combobox's `opensUp`, and closes only on a
|
|
496
|
-
scroll that moved its button. This was the Members test's flake (about
|
|
497
|
-
1 in 8 runs; the list was long because every concurrency run left
|
|
498
|
-
its second member behind — `concurrency.spec.ts` removes it in
|
|
499
|
-
`afterAll` since 2026-09-28, and `members.spec.ts` its invited Auth
|
|
500
|
-
user, which Remove member leaves in place). Tests: `admin-action-menu`, `members.spec.ts` (a
|
|
501
|
-
late scroll event, a real move, a row at the bottom of the screen).
|
|
502
|
-
- The security pass over the database boundary (spec 2026-09-26 security
|
|
503
|
-
pass): the presence channel admits a signed-in member of the topic's
|
|
504
|
-
space (`smoodly_presence_member()`, the socket carries the session
|
|
505
|
-
token through the client's `accessToken` callback, re-sent on refresh);
|
|
506
|
-
every function pins `search_path`; the three mutation RPCs are revoked
|
|
507
|
-
from `anon`/`public`. Schema version 3. The threat table, one test per
|
|
508
|
-
cell: content, `refs`/`members`, `spaces`/`smoodly_meta` and storage
|
|
509
|
-
columns — `test/isolation.supabase.test.ts` ("space isolation", "read
|
|
510
|
-
keys", "cloud state rows") and `test/rls.supabase.test.ts`; presence
|
|
511
|
-
and RPC columns — `isolation.supabase.test.ts` ("signed-in users and
|
|
512
|
-
the public keys"); admin ops — `test/admin-auth.test.ts`,
|
|
513
|
-
`test/admin-auth.supabase.test.ts`, `test/admin-ops.test.ts` ("members
|
|
514
|
-
ops").
|
|
515
|
-
- Redirects (spec 2026-09-26 redirects): a `paths` row with `redirect`
|
|
516
|
-
set is an old address of its target (schema version 4). A published
|
|
517
|
-
rename or move keeps the old addresses as redirects unless the editor
|
|
518
|
-
unticks "Redirect the old address" on the live-URL confirm; the live
|
|
519
|
-
URL wins an address; nothing chains; `renderSmoodlyPath` answers a
|
|
520
|
-
redirect row with `permanentRedirect` through `href`. Settings ›
|
|
521
|
-
Redirects (every member) lists, adds by hand (validated old address,
|
|
522
|
-
target type-ahead) and removes. Tests: `test/path-index-contract.ts`
|
|
523
|
-
(the rule table, both adapters), the store contracts, `admin-ops`
|
|
524
|
-
("redirects on rename and move", "redirects ops"),
|
|
525
|
-
`next-page-renderer` ("redirects"), `examples/site/e2e/redirects.spec.ts`.
|
|
526
|
-
- Content API contract (spec 2026-09-29 content API contract): every
|
|
527
|
-
read returns the public shape — `EntryItem`, `PageItem`, `GlobalItem`
|
|
528
|
-
(`src/read-shapes.ts`), nothing of other locales, pointers or editors
|
|
529
|
-
leaves the site layer; a reference resolves as `{ id, path, fields }`
|
|
530
|
-
as deep as its field says, in lists and single reads alike, and the
|
|
531
|
-
types follow the depth; `smoodly_list_entries` (schema version 7)
|
|
532
|
-
filters, orders and limits over the snapshot shown, so `where` sees
|
|
533
|
-
localized fields and never the live row; a list is tagged by every
|
|
534
|
-
collection it resolved into; `seo: true` or `{ description, image }`
|
|
535
|
-
on a collection adds the SEO & Social panel, stored per locale under
|
|
536
|
-
`$seo` (`src/seo.ts`); every content table admits a write key only, the
|
|
537
|
-
publishable key serves sign-in and presence (`presenceConfig`). Tests:
|
|
538
|
-
`test/entry-store-contract.ts` ("query"), `test/site.test.tsx`,
|
|
539
|
-
`test/next-queries.test.tsx`, `test/types-refs.test.ts`,
|
|
540
|
-
`test/seo.test.ts`, `test/isolation.supabase.test.ts`,
|
|
541
|
-
`test/rls.supabase.test.ts`.
|
|
542
|
-
- Editor safety (spec 2026-09-27 editor safety): schema drift — stored
|
|
543
|
-
content the code no longer describes — is found by one pure walk
|
|
544
|
-
(`admin/drift.ts`) over the serialized registry. Broken findings
|
|
545
|
-
(an unknown section, element, page type or collection; a value that
|
|
546
|
-
doesn't fit its field; a zone rule broken) refuse publish for pages,
|
|
547
|
-
entries and global items; left-over ones (a removed field or zone, an
|
|
548
|
-
element outside its toolset, a localization change) never do. The
|
|
549
|
-
validator's one type check is `fits` (text and number checked for the
|
|
550
|
-
first time). Settings › Content health (every member) reports every
|
|
551
|
-
document, draft and live, grouped by the change; the editors mark the
|
|
552
|
-
open document (notice, Outline marks, a canvas placeholder, the
|
|
553
|
-
unknown-section inspector with Remove section, misfits as field
|
|
554
|
-
errors, a "No longer in the schema" group with Copy and Clear, Clear
|
|
555
|
-
zone). Turning `.localized()` on no longer deletes the other locales'
|
|
556
|
-
value (update, discard and restore keep a localized key's shared
|
|
557
|
-
value); the last `window.alert`s are `notify()`. No bulk writes
|
|
558
|
-
(OQ 35). Tests: `admin-drift`, `admin-drift-copy`, `admin-health`,
|
|
559
|
-
`admin-health-list`, `admin-health-page`, `admin-drift-markers`,
|
|
560
|
-
`admin-unknown-node`, the store contracts (`scanTrees`,
|
|
561
|
-
`scanEntries`, the pinning tests), `entry-store.supabase` (the
|
|
562
|
-
localized fix across two schemas), `examples/site/e2e/health.spec.ts`.
|
|
563
|
-
- Shadowed-page detector (spec 2026-09-27 shadowed-page detector): a
|
|
564
|
-
canvas whose bridge never speaks within 4 s of the frame's load is
|
|
565
|
-
diagnosed — the editor fetches the canvas URL once and
|
|
566
|
-
`admin/editor/canvas-diagnosis.ts` names another route, a route file
|
|
567
|
-
that drops `searchParams`, a crash, a 404, a redirect, a refusal, a
|
|
568
|
-
framing header or a script error, as an editor sentence and a "For
|
|
569
|
-
your developer" line (`editor/CanvasWarning.tsx`, in place of
|
|
570
|
-
"Selection unavailable"). The renderer writes
|
|
571
|
-
`<meta name="smoodly-draft">` in draft mode only. No reserved-path
|
|
572
|
-
list (OQ 23 closed). Tests: `admin-canvas-diagnosis`,
|
|
573
|
-
`admin-canvas-warning`, `next-page-renderer` (the marker),
|
|
574
|
-
`admin-bridge`, `examples/site/e2e/editor.spec.ts`.
|
|
575
|
-
- cacheComponents unsupported (spec 2026-09-27 cache components): under
|
|
576
|
-
Next's Cache Components the catch-all is served from a fallback shell
|
|
577
|
-
that commits 200, so 404s and 308s are lost; the renderer warns once
|
|
578
|
-
per renderer when `process.env.__NEXT_CACHE_COMPONENTS` is on, and the
|
|
579
|
-
Next.js guide says why. OQ 19 closed. Tests: `next-page-renderer`
|
|
580
|
-
("cacheComponents").
|
|
581
|
-
- Section guard (spec 2026-09-27 section guard): `Zone` calls a
|
|
582
|
-
plain-function section view inside try/catch (an async one through
|
|
583
|
-
`SectionGuard`, in place), so a throw in a section's own code leaves it
|
|
584
|
-
out and the page renders — nothing live, a placeholder in the canvas
|
|
585
|
-
and under `next dev`; the adapter's `rethrow` (Next: `unstable_rethrow`)
|
|
586
|
-
lets control flow through. In editor mode the Next adapter also wraps
|
|
587
|
-
each section in `<Suspense>` + the client `CanvasBoundary`, the bridge
|
|
588
|
-
reports crashed sections, the page editor lists them, and Publish asks
|
|
589
|
-
first. A healthy page's HTML is unchanged. Tests: `section-crash`,
|
|
590
|
-
`render` ("the section guard"), `next-canvas-boundary`,
|
|
591
|
-
`next-page-renderer`, `admin-crash-copy`, `admin-canvas-diagnosis`,
|
|
592
|
-
`examples/site/e2e/sections.spec.ts`.
|
|
593
|
-
- No admin on the live site (2026-09-27): `supabaseAdminAuth` moved from
|
|
594
|
-
`smoodly/admin/next` to `smoodly/admin` (its `AuthConfig` type too).
|
|
595
|
-
`smoodly/server.ts`, which every site route imports, took it from the
|
|
596
|
-
`admin/next` barrel beside `createSmoodlyAdmin`, so the admin shell was
|
|
597
|
-
a client module of every site route. Confirmed by removing the import
|
|
598
|
-
and rebuilding; the starter's home page, production build, went from
|
|
599
|
-
1,616,602 to 628,849 bytes of JavaScript (8 chunks either way, the
|
|
600
|
-
1,050,601-byte chunk holding `AdminApp` gone). The rest of the page's
|
|
601
|
-
JavaScript is the site's own: of a 62,848-byte chunk, next-intl is
|
|
602
|
-
~34 KB, `next/link` and `next/image` most of the rest, and the
|
|
603
|
-
editor-only modules ~3.4 KB (`EditorBridge` ~2.5 KB, the canvas net
|
|
604
|
-
~0.9 KB) — too little to lazy-load. Tests: `site-entries` (every entry
|
|
605
|
-
but `smoodly/admin/next` reaches no client module except those two),
|
|
606
|
-
`examples/site/e2e/site-bundle.spec.ts` (CI only: production chunks).
|
|
607
|
-
|
|
608
|
-
- Export and import (spec 2026-09-28 export/import): `npx smoodly export
|
|
609
|
-
[--out <dir>]` writes the key's space — the nine content tables with
|
|
610
|
-
history, the member list, the ready assets' files — into a folder
|
|
611
|
-
(`manifest.json`, `rows/<table>.json` without `space_id`, `media/`
|
|
612
|
-
without the space prefix), written as `.partial` and renamed last; it
|
|
613
|
-
refuses a publishable key (the `smoodly_meta` read), a database behind
|
|
614
|
-
the package, and a snapshot whose foreign keys do not close ("content
|
|
615
|
-
changed during export"). `npx smoodly import <dir>` writes it into a
|
|
616
|
-
space that is empty or holds only this export's rows, schema versions
|
|
617
|
-
equal: media first, rows in reference order with on-conflict-do-nothing,
|
|
618
|
-
so a stopped run finishes by running it again. Tests: `space-transfer`,
|
|
619
|
-
`cli-transfer`, `space-transfer.supabase`,
|
|
620
|
-
`examples/site/e2e/transfer.spec.ts`.
|
|
621
|
-
|
|
622
|
-
- Nested addresses (spec 2026-09-29 nested paths, schema v5): a
|
|
623
|
-
collection `path` or fixed `slug` may have several parts
|
|
624
|
-
(`company/news`), each checked as a segment, a locale the map omits
|
|
625
|
-
filled from the default. `src/addresses.ts` turns the registry into
|
|
626
|
-
code addresses by owner key (`collection:<name>`, `page:<name>`),
|
|
627
|
-
`config.addresses`; `pages.fixed` names a code-owned record's owner,
|
|
628
|
-
the record stays a root with the last part as its slug, and its path
|
|
629
|
-
comes from the config. Anchored paths (spec 2026-09-29 anchored
|
|
630
|
-
paths): each `paths` row names its anchor (`pages.fixed`'s owner key,
|
|
631
|
-
`''` for the editors' tree) and stores only the editors' part; the
|
|
632
|
-
path index (`src/anchors.ts`, both adapters, `config.anchors`) joins
|
|
633
|
-
the address from the config, so a code change needs no write and the
|
|
634
|
-
live site does one lookup per request. `ensureFixedNodes` finds
|
|
635
|
-
records by owner and creates them over an editor's page, never
|
|
636
|
-
adopting it: code wins the address. The ops refuse a page write onto
|
|
637
|
-
or below a code address naming the owner; the Pages list flags an
|
|
638
|
-
editor's page there and a record no longer in code (delete only).
|
|
639
|
-
Tests: `addresses`, `anchors`, the path index contract,
|
|
640
|
-
`admin-fixed-nodes`, `admin-ops-addresses`, `admin-pages-tree`,
|
|
641
|
-
`site`, the page and entry store contracts,
|
|
642
|
-
`examples/site/e2e/entries.spec.ts`.
|
|
643
|
-
|
|
644
|
-
- Pre-install fixes (spec 2026-09-29 pre-install fixes, schema v6): one
|
|
645
|
-
permissive policy per table and action (the write key reads through
|
|
646
|
-
`smoodly_read`; `smoodly_write` splits into insert, update and delete
|
|
647
|
-
where a public read exists, the media pair too), the missing
|
|
648
|
-
foreign-key and published-version indexes, `smoodly_space_row_cap`
|
|
649
|
-
revoked from anon. A live page runs no Smoodly client code: the canvas
|
|
650
|
-
bridge and the section net load lazily from one stub under 1 KB
|
|
651
|
-
(`next/canvas-slot.tsx`), and `smoodly/bridge` is no longer an export.
|
|
652
|
-
Draft mode shows a preview pill (`next/preview-pill.tsx`: an inline
|
|
653
|
-
Shadow DOM script, bottom centre, movable left/centre/right, hidden
|
|
654
|
-
until reload, Exit preview through `previewRoute`, which every starter
|
|
655
|
-
mounts at `app/api/preview/route.ts`; `previewPill()` for routes the
|
|
656
|
-
renderer does not draw). The page editor, the entry form and the
|
|
657
|
-
Globals form save a pending edit on unmount (`close()` on the autosave
|
|
658
|
-
controller, reopened by the hook's effect for StrictMode; it waits for a
|
|
659
|
-
save in flight and sends the rest with that save's token) and ask only
|
|
660
|
-
before a tab close (`useUnloadGuard`); `useLeaveGuard`'s in-app "Discard"
|
|
661
|
-
confirm has no caller now. Draft mode's pill is absent from History's
|
|
662
|
-
version iframe (`smoodly-version`), whose Exit would end draft mode under
|
|
663
|
-
the editor.
|
|
664
|
-
The cached unit is keyed by (locale, path, kind, id). A new list item's
|
|
665
|
-
required select or radio stores its first option. Every starter sets
|
|
666
|
-
`metadataBase` from `SITE_URL`, which the installer writes. Tests:
|
|
667
|
-
`migration-policies`, `sql`, `canvas-slot`, `site-entries`,
|
|
668
|
-
`next-preview-route`, `next-preview-pill`, `next-page-renderer`,
|
|
669
|
-
`admin-autosave`, `admin-list-helpers`, `env` (installer),
|
|
670
|
-
`e2e/publish.spec.ts`, `e2e/editor.spec.ts`, `e2e/languages.spec.ts`,
|
|
671
|
-
`e2e/site-bundle.spec.ts` (CI-only).
|
|
672
|
-
|
|
673
|
-
Not yet: canvas drag-and-drop (list fields, the outline, manual
|
|
674
|
-
collections and the pages tree drag since 2026-09-09 — spec
|
|
675
|
-
`docs/superpowers/specs/2026-09-09-drag-and-drop-design.md`), undo/redo, sample/placeholder content
|
|
676
|
-
marking. Per-locale section visibility is not coming: each locale has its
|
|
677
|
-
own tree, so a locale that does not want a section removes it (DESIGN.md
|
|
678
|
-
§3, 2026-09-14).
|
|
679
|
-
|
|
680
|
-
## Releasing
|
|
681
|
-
|
|
682
|
-
Both packages move together. Bump before build (the CLI's templates are
|
|
683
|
-
regenerated from `examples/site` in the build and pin `smoodly@<version>`),
|
|
684
|
-
the user runs the two publishes (npm 2FA is a browser passkey), tag only
|
|
685
|
-
after both are on the registry. From the repo root:
|
|
686
|
-
|
|
687
|
-
npm version 0.0.12 -w smoodly -w create-smoodly-app --no-git-tag-version
|
|
688
|
-
npm run build
|
|
689
|
-
npm publish -w smoodly --dry-run && npm publish -w create-smoodly-app --dry-run
|
|
690
|
-
npm publish -w smoodly --access public # user; first, the scaffold pins it
|
|
691
|
-
npm publish -w create-smoodly-app --access public # user
|
|
692
|
-
npm view smoodly@0.0.12 version # 404s for a minute or two after a good publish; poll
|
|
693
|
-
npm view create-smoodly-app@0.0.12 version
|
|
694
|
-
git commit -am "release: 0.0.12 — <changes since 0.0.11>" && git tag v0.0.12 && git push --tags origin main
|
|
695
|
-
|
|
696
|
-
`prepublishOnly` rebuilds each package. The Claude Code skill
|
|
697
|
-
`.claude/skills/releasing-smoodly` carries the same order with its checks.
|
|
698
|
-
|
|
699
|
-
## Follow-ups
|
|
700
|
-
|
|
701
|
-
The code-level debt register, triaged 2026-09-29 into four buckets:
|
|
702
|
-
**Now** (before the first client install), **Next** (hardening soon
|
|
703
|
-
after), **Later** (by area) and **Cloud** (only once Smoodly Cloud
|
|
704
|
-
exists). Each item ends with where it was logged. A fixed item is
|
|
705
|
-
deleted — git history and the specs keep the record — and a new one
|
|
706
|
-
goes straight into its bucket. Design-level questions live in
|
|
707
|
-
`docs/DESIGN.md`'s Open questions, not here.
|
|
708
|
-
|
|
709
|
-
### Now
|
|
710
|
-
|
|
711
|
-
Nothing open.
|
|
712
|
-
|
|
713
|
-
### Next
|
|
714
|
-
|
|
715
|
-
#### Data integrity
|
|
716
|
-
|
|
717
|
-
- Older than this plan: the Supabase `addLocale` rollback on
|
|
718
|
-
`alreadyInLocale` (`supabase-store.ts`, the insertLocale catch)
|
|
719
|
-
deletes the racing WINNER's locale row and versions. Two admins
|
|
720
|
-
adding one locale at once converge on the next list; fix by rolling
|
|
721
|
-
back only what this call inserted. *(2026-09-29, nested collection paths)*
|
|
722
|
-
- A ready asset whose `poster_id` names a pending asset would fail the
|
|
723
|
-
export's consistency check on every run (pending assets are not
|
|
724
|
-
exported). Unverified whether the upload flow can leave that state;
|
|
725
|
-
if it can, export the poster or null the pointer with a warning. *(2026-09-28, export/import)*
|
|
726
|
-
- Publish, discard and restore compare the token, then move the pointer under a
|
|
727
|
-
pointer filter: a save that rewrites the same draft row in place between the
|
|
728
|
-
two is published unseen (one round trip wide). A SQL function would close it. *(2026-09-26, concurrency)*
|
|
729
|
-
- Publish integrity for an entry's own refs: an entry can publish while
|
|
730
|
-
pointing at an unpublished entry; pages are gated, entries are not. *(2026-09-14, collections draft/publish)*
|
|
731
|
-
- Unpublishing a page does not cascade: a published child keeps serving
|
|
732
|
-
under an unpublished parent's path. Decide whether it should. *(2026-09-13, publish flow)*
|
|
733
|
-
- `entries.move` expires the entry and its descendants but does not fan
|
|
734
|
-
out to the pages referencing them, nor to the old and the new parent
|
|
735
|
-
entries the way `pages.move` does — asymmetric with `entries.save` and
|
|
736
|
-
`entries.setStatus`, which both fan out. *(2026-09-06)*
|
|
737
|
-
|
|
738
|
-
#### Multi-statement writes (no transaction on the Supabase adapters)
|
|
739
|
-
|
|
740
|
-
- The Supabase entry adapter's `removeLocale` deletes the row and its
|
|
741
|
-
versions before the paths rewrite with no rollback; its residue
|
|
742
|
-
comment describes the opposite case (a surviving path row for a
|
|
743
|
-
removed locale, not a present node without its path row). *(2026-09-06)*
|
|
744
|
-
- The Supabase entry adapter's rollbacks are best-effort and can mask the
|
|
745
|
-
original throw: `create`'s catch runs `paths.remove` before the node
|
|
746
|
-
delete (a failing remove strands the node and replaces the real error),
|
|
747
|
-
`update` and `move` restore by writing, and those writes can throw in
|
|
748
|
-
place of the rewrite's rejection. `recordVersion` is two statements
|
|
749
|
-
(insert, then point at it), so a failure between them leaves an
|
|
750
|
-
unreferenced `entry_versions` row — invisible to every policy and
|
|
751
|
-
harmless, but it accumulates. *(2026-09-06)*
|
|
752
|
-
- `move` is best-effort across two statements (reorder, then path rewrite):
|
|
753
|
-
a failed rewrite restores each destination sibling's prior `sort` row by
|
|
754
|
-
row, so a failure in the rollback itself still leaves the order changed. *(2026-09-05)*
|
|
755
|
-
- Rollback gaps that remain: a `smoodly_reorder`/`smoodly_reorder_pages`
|
|
756
|
-
failure AFTER the re-parent update has no rollback at all; the entry
|
|
757
|
-
`create` rollback discards its delete result (an orphan row survives
|
|
758
|
-
silently); and a throw inside any rollback replaces — masks — the original
|
|
759
|
-
error. *(2026-09-05)*
|
|
760
|
-
- `setTemplate`/`saveDraft` insert the version row before the pointer update:
|
|
761
|
-
an infra failure between the two orphans a version. *(2026-09-05)*
|
|
762
|
-
|
|
763
|
-
#### Addresses and code-owned records
|
|
764
|
-
|
|
765
|
-
- `homePageSlug` is compared with a collection's whole default path, so
|
|
766
|
-
`"home/news"` passes boot with the home page as its middle part: the
|
|
767
|
-
home record serves `/`, so `/home` is no page while `/home/news` is.
|
|
768
|
-
Refuse an address whose first part is the home slug if a site trips on
|
|
769
|
-
it. *(2026-09-29, nested collection paths)*
|
|
770
|
-
- An owner key that changes in code — a fixed page that becomes a
|
|
771
|
-
collection's index page (`page:x` → `collection:y`), a collection
|
|
772
|
-
renamed — leaves the old record "No longer in code" with its
|
|
773
|
-
sections and history, and a new empty record takes the address.
|
|
774
|
-
Carry the record over (rewrite `fixed`) when a site asks. *(2026-09-29, nested collection paths)*
|
|
775
|
-
- An index page registration renamed (`a` → `b`) leaves the record on
|
|
776
|
-
template `a`: `ensureFixedNodes` only handles a folder gaining an
|
|
777
|
-
index page, and `setTemplate` refuses a record that has one. *(2026-09-29, nested collection paths)*
|
|
778
|
-
|
|
779
|
-
#### Security
|
|
780
|
-
|
|
781
|
-
- The bridge posts with `"*"` on both sides. Safe today: the admin
|
|
782
|
-
accepts a message only when `e.source` is its own iframe's window, and
|
|
783
|
-
the canvas acts on two kinds, refresh and scroll. The target-origin
|
|
784
|
-
pass belongs to the content-surface sweep (spec §Out of scope). *(2026-09-26, security pass)*
|
|
785
|
-
- The invite's `redirectTo` is a client-supplied string bounded by the
|
|
786
|
-
auth project's redirect allow-list; a same-origin check in the op is
|
|
787
|
-
one line when wanted. *(2026-09-26, security pass)*
|
|
788
|
-
- The content-surface sweep: `f.link` values reach section components
|
|
789
|
-
unfiltered, uploads allow SVG and PDF on a public bucket (SVG served
|
|
790
|
-
with `?download=`), the custom CSS refusal list, richtext hrefs. *(2026-09-26, security pass)*
|
|
791
|
-
- `RichText` drops the anchor of a link mark whose href is not http(s),
|
|
792
|
-
mailto, tel or site-relative (`/`, `#`, `?`, `.`). The href is normalised
|
|
793
|
-
the way URL parsers do (tab, LF and CR removed) before the scheme test;
|
|
794
|
-
`javascript:`, `data:`, protocol-relative `//host` and the backslash form
|
|
795
|
-
`/\host` are rejected. The admin's link field does not validate on input yet. *(2026-09-07, list field)*
|
|
796
|
-
|
|
797
|
-
#### The live site
|
|
798
|
-
|
|
799
|
-
- A section's crash on a live page after data changed outside the page
|
|
800
|
-
editor (an entry or a global item edited in its form): the guard
|
|
801
|
-
catches a throw in the section's own view, but one inside a component
|
|
802
|
-
it renders, in a richtext element's view or in a `'use client'`
|
|
803
|
-
section still fails the page, and no canvas previews those forms.
|
|
804
|
-
Catching it live needs `<Suspense>` (spec 2026-09-27 section guard,
|
|
805
|
-
measured). Revisit if a real site hits it. *(2026-09-27, shadowed-page detector)*
|
|
806
|
-
- Publishing a global the site layout reads (the footer) serves the old
|
|
807
|
-
layout on the first public request made right after the publish returns;
|
|
808
|
-
every request after it, and a first request made ~3 s later, is fresh.
|
|
809
|
-
Pages do not show it (an unpublish answers 404 at once). Seen on
|
|
810
|
-
`next dev`; `e2e/globals.spec.ts` polls the home page for it. Find out
|
|
811
|
-
whether `updateTag` over a layout's `getGlobal` read expires late, and
|
|
812
|
-
whether `next start` behaves the same. *(2026-09-26, browser suite)*
|
|
813
|
-
- A robots `Disallow` for the admin path from `next/meta`. *(2026-09-17, admin path)*
|
|
814
|
-
- Scripts and the seed write through the stores and expire nothing; a
|
|
815
|
-
`revalidate` hook for out-of-band writers. *(2026-09-11, queries)*
|
|
816
|
-
- Delivery caching (checked 2026-09-08): Supabase Storage's basic CDN
|
|
817
|
-
fronts every public-bucket object on a hosted project with no code on
|
|
818
|
-
our side, and the Smart CDN (Pro plan, automatic) only adds
|
|
819
|
-
invalidation on overwrite, which never happens — objects are
|
|
820
|
-
immutable, no upsert on either path, Replace picks a new id. So both
|
|
821
|
-
upload paths now set a year-long `cache-control`
|
|
822
|
-
(`MEDIA_CACHE_SECONDS`): the `cacheControl` option on the server-side
|
|
823
|
-
upload, the header on the browser's signed PUT (without it a signed
|
|
824
|
-
upload lands as `no-cache`; Storage's default is one hour). The live
|
|
825
|
-
suite reads the header back from the local stack. Not yet verified
|
|
826
|
-
against a hosted project's CDN edge; check `cf-cache-status` and
|
|
827
|
-
`cache-control` on a real upload there once. Remaining: in v1 the
|
|
828
|
-
site's `<SmoodlyImage>` goes through `next/image`, so the visitor hits
|
|
829
|
-
the app server's image cache and only its origin fetch touches the
|
|
830
|
-
CDN; video, files and SVGs hit the CDN directly. A Supabase Image
|
|
831
|
-
Transformations loader (`/storage/v1/render/image/public/...`, Pro
|
|
832
|
-
only, must stay optional) would move resizing to the CDN — the transform
|
|
833
|
-
loader under Cloud. *(2026-09-08, media library)*
|
|
834
|
-
- `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)*
|
|
835
|
-
- 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)*
|
|
836
|
-
|
|
837
|
-
#### The editors
|
|
838
|
-
|
|
839
|
-
- A visual global item's settings misfits (`settings.x`) don't reach
|
|
840
|
-
their widgets in the Globals form: `GlobalFields` keys errors by the
|
|
841
|
-
bare field key, the same gap the validator's refusal keys had before.
|
|
842
|
-
The notice and the refusal list still name them. *(2026-09-27, editor safety)*
|
|
843
|
-
- A `refList` on a global item's `settings` still loads no options in
|
|
844
|
-
the Globals form or the page editor: both collect targets from the
|
|
845
|
-
item's `fields` only. *(2026-09-17, reference picker and tags)*
|
|
846
|
-
- A validation failure on a shared save retries with backoff like a
|
|
847
|
-
transport failure; the field error shows on the widget meanwhile. A
|
|
848
|
-
distinct "fix the field" state would stop the retries. *(2026-09-13, shared inline editing)*
|
|
849
|
-
- Escape inside any inspector control bubbles to the editor's window
|
|
850
|
-
handler and clears the block selection — the link popover stops it, a
|
|
851
|
-
textarea or the TipTap content does not. A `keydown` guard on
|
|
852
|
-
`.sm-inspector` would fix it for every control. *(2026-09-08, richtext widget)*
|
|
853
|
-
|
|
854
|
-
#### Admin
|
|
855
|
-
|
|
856
|
-
- Left open by the pre-install fixes' final review (2026-09-29): the pill
|
|
857
|
-
has `href="#"` on Exit and no keyboard way to move it; it appears after
|
|
858
|
-
a soft navigation from a non-Smoodly route only once the page reloads
|
|
859
|
-
(React never runs a script it creates on the client); a remounted view's
|
|
860
|
-
`pages.get` can read the draft before the outgoing view's `close()` save
|
|
861
|
-
commits (the next edit then meets `StaleBanner`); an in-flight save that
|
|
862
|
-
fails after `close()` is not retried; a required `radio` with no value
|
|
863
|
-
shows no pill pressed while the list item now stores the first option,
|
|
864
|
-
and `nodeFromSample` seeds a required `select` as `""`; `useLeaveGuard`
|
|
865
|
-
has no caller left; `examples/site/.env.local.example` lacks `SITE_URL`.
|
|
866
|
-
*(2026-09-29, pre-install fixes)*
|
|
867
|
-
- `admin/ui/theme.tsx` pulls Public Sans from Google Fonts with an `@import`.
|
|
868
|
-
Fine today; a self-hosted font (or `next/font` in the glue layer) removes the
|
|
869
|
-
third-party request and the flash before it loads. *(2026-09-05)*
|
|
870
|
-
|
|
871
|
-
#### Performance
|
|
872
|
-
|
|
873
|
-
- `pages.get` makes four sequential store round trips for a non-root page
|
|
874
|
-
(`getPage`, `getDraftTree`, `pathsOf`, and the parent read behind
|
|
875
|
-
`parentLocales`; three for a root, which has no parent to read), and the
|
|
876
|
-
editor calls it on load and after every rename — one store call
|
|
877
|
-
returning them all would cut the editor's latency. *(2026-09-06)*
|
|
878
|
-
|
|
879
|
-
#### Code health
|
|
880
|
-
|
|
881
|
-
- `classify` in `ops-impl.ts` is ~20 order-sensitive substring checks over
|
|
882
|
-
store messages — `"already exists in locale"` must precede
|
|
883
|
-
`"already exists"`, or an addLocale conflict reads as a slug conflict. A
|
|
884
|
-
typed code on `pageErrors` (thrown and matched, not re-parsed) would
|
|
885
|
-
remove the ordering hazard before plan 2 adds the entry messages. *(2026-09-06)*
|
|
8
|
+
## Install
|
|
886
9
|
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
- The blank-intl starter is typechecked, not walked: the first tester-agent
|
|
890
|
-
mission (each tester site on its own local Supabase stack, two at a time),
|
|
891
|
-
after the 0.0.35 release. *(2026-09-15, locale alternates)*
|
|
892
|
-
- Drag and drop has no browser test (spec §4): the outline, the pages
|
|
893
|
-
tree and list rows reorder by real pointer drags; the first plan that
|
|
894
|
-
touches `useSortable` adds them. *(2026-09-26, browser suite)*
|
|
895
|
-
- The suite runs Chromium only; the admin's WebKit and Firefox
|
|
896
|
-
behaviour is unverified. *(2026-09-26, browser suite)*
|
|
897
|
-
- The seed's early exit probes `/` alone, and `createPage` writes the path
|
|
898
|
-
row before the tree is published, so a crash between the home record and
|
|
899
|
-
the last page leaves a database every later run reports as "already
|
|
900
|
-
seeded"; recovery is `supabase db reset`. A probe on the last thing the
|
|
901
|
-
seed writes, or a per-step check, would make it resumable. *(2026-09-07, starter)*
|
|
902
|
-
- The package root (`src/index.ts`) must stay free of Node builtins:
|
|
903
|
-
every scaffolded section does `import { f, smoodly } from "smoodly"`,
|
|
904
|
-
and a `"use client"` section's browser build would fail on one.
|
|
905
|
-
`sharedEntryId` (`src/shared-id.ts`) moved off `node:crypto` onto Web
|
|
906
|
-
Crypto for this reason (fix pass, 2026-09-07). Nothing tests the
|
|
907
|
-
invariant itself — a future addition that reaches for `node:fs`,
|
|
908
|
-
`node:crypto`, etc. from a module reachable through the root would
|
|
909
|
-
regress it silently. *(2026-09-07, shared content)*
|
|
910
|
-
- The single-language branch — no locale selector in the Pages list, no
|
|
911
|
-
switcher, no add/remove — has no automated coverage; only three sites
|
|
912
|
-
branch on the locale count (`PagesList.tsx`, `EditorView.tsx` and
|
|
913
|
-
`page-renderer.tsx`'s alternates), so a regression there shows up only
|
|
914
|
-
in a scaffolded single-language app. *(2026-09-06)*
|
|
915
|
-
- 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)*
|
|
916
|
-
|
|
917
|
-
### Later
|
|
918
|
-
|
|
919
|
-
#### Page editor and inline editing
|
|
920
|
-
|
|
921
|
-
- The page editor's publish banner lists fields as plain text; the forms'
|
|
922
|
-
lines focus the widget. Selecting the node from a `node.field` path is
|
|
923
|
-
the missing piece. *(2026-09-14, collections draft/publish)*
|
|
924
|
-
- Richtext instant write from the inspector: the pure `RichText` render
|
|
925
|
-
swapped into the container while the widget is typed in. *(2026-09-10, inline editing)*
|
|
926
|
-
- Item-level chrome in the canvas ("Key points · 3 of 4" on hover, add,
|
|
927
|
-
remove, reorder in place, later drag within a list): the item marks are
|
|
928
|
-
the hook. *(2026-09-10, inline editing)*
|
|
929
|
-
- Placeholder and validation marks on the page (the sample-sourced
|
|
930
|
-
placeholder treatment, "required, empty" outlines) wait for the tree's
|
|
931
|
-
placeholder marking. *(2026-09-10, inline editing)*
|
|
932
|
-
- A coverage hint for developers: the admin knows a section's text fields
|
|
933
|
-
and could list the ones without a mark. *(2026-09-10, inline editing)*
|
|
934
|
-
- AI verbs ("rewrite this heading") need a field under the cursor, which
|
|
935
|
-
a mark is. *(2026-09-10, inline editing)*
|
|
936
|
-
- Direct-child spacing rules on a richtext container (Tailwind `space-y-*`,
|
|
937
|
-
`.prose > p`) do not reach the canvas editor's blocks during a session;
|
|
938
|
-
flex and grid gaps are copied (onto the ProseMirror root since the
|
|
939
|
-
walk, 69aecc6). Documented, not fixed. *(2026-09-10, inline editing)*
|
|
940
|
-
- The floating toolbar flips below the caret only near the top of the
|
|
941
|
-
DOCUMENT (where the page box clips it); it scrolls with the content, so
|
|
942
|
-
a caret at the top of the scrolled canvas has its toolbar out of view
|
|
943
|
-
until the editor scrolls. A flip keyed to the scroller's viewport would
|
|
944
|
-
fix it. *(2026-09-10, inline editing)*
|
|
945
|
-
- No starter section gives the canvas a richtext with blocks: the Image
|
|
946
|
-
and text body is marks-only, and the article body (the `article`
|
|
947
|
-
toolset) is edited in the entry form. The walk borrowed the article
|
|
948
|
-
toolset for the block steps; a starter section with a long-form body
|
|
949
|
-
would let the canvas show them. *(2026-09-10, inline editing)*
|
|
950
|
-
- The caret placed right after an existing `/` reopens the slash menu
|
|
951
|
-
(TipTap's suggestion re-triggers on the character before the caret).
|
|
952
|
-
Harmless; a trigger keyed to typing only would be stricter. *(2026-09-10, inline editing)*
|
|
953
|
-
|
|
954
|
-
#### Richtext and elements
|
|
955
|
-
|
|
956
|
-
- Image sizing and float in a body (full width, wide, small, left/right):
|
|
957
|
-
a later additive `styles` on the image node; the site's CSS answers it. *(2026-09-08, block nodes)*
|
|
958
|
-
- Inline elements: `inline: true` on the registration; cursor, marks and
|
|
959
|
-
selection around an inline atom are a separate node-view effort. *(2026-09-08, block nodes)*
|
|
960
|
-
- The column context (`contexts: ["column"]`) waits for Phase 3 rows;
|
|
961
|
-
accepted and unused today. *(2026-09-08, block nodes)*
|
|
962
|
-
- Element copy between fields: a pasted element the target toolset
|
|
963
|
-
forbids is dropped silently; a notice would be kinder. *(2026-09-08, block nodes)*
|
|
964
|
-
- `hasContent` treats an unresolved image node as content, so a body
|
|
965
|
-
whose only image was deleted from the library still passes "required". *(2026-09-08, block nodes)*
|
|
966
|
-
- The "+" menu and the slash menu share `RichtextInsertMenu` but keep
|
|
967
|
-
separate active-row state and key handling; one keyboard model would
|
|
968
|
-
be smaller. *(2026-09-08, block nodes)*
|
|
969
|
-
- A page and entry picker in the link popover: a link inside a richtext
|
|
970
|
-
document is a reference the refs index, the resolver and the localizer
|
|
971
|
-
cannot see (they address refs by top-level key). Its own spec. *(2026-09-08, richtext widget)*
|
|
972
|
-
- Every richtext descriptor now carries its toolset, so the serialized
|
|
973
|
-
registry grew by a few bytes per field; if the AI-facing schema wants
|
|
974
|
-
the vocabulary once instead of per field, dedupe there. *(2026-09-08, richtext widget)*
|
|
975
|
-
|
|
976
|
-
#### Forms, fields and pickers
|
|
977
|
-
|
|
978
|
-
- A `Create…` button in the reference picker: create the target entry
|
|
979
|
-
without leaving the form. Needs its own answer for modal vs navigation
|
|
980
|
-
and for the parent form's pending autosave. *(2026-09-17, reference picker and tags)*
|
|
981
|
-
- Thumbnails in picker rows: collections have no preview-image
|
|
982
|
-
convention (`titleField` only). *(2026-09-17, reference picker and tags)*
|
|
983
|
-
- Tags keep insertion order; no drag reorder. *(2026-09-17, reference picker and tags)*
|
|
984
|
-
- Tag suggestions exist on collection entry forms only, and only for
|
|
985
|
-
top-level `f.tags()` fields: a tags field inside an object, on a
|
|
986
|
-
section, a global or a page is a plain chip input. *(2026-09-17, reference picker and tags)*
|
|
987
|
-
- The picker's "open" link goes to `/admin/<collection>/<id>` without
|
|
988
|
-
the edited locale; the form opens in the URL's or the default locale. *(2026-09-17, reference picker and tags)*
|
|
989
|
-
- The Combobox list is portaled into the admin wrapper and fixed to the
|
|
990
|
-
viewport (2026-09-27), so a modal body or scrolling form no longer
|
|
991
|
-
clips it; it flips upwards when the viewport has no room under the
|
|
992
|
-
input (`listPlacement`) and follows the input on scroll. It does not
|
|
993
|
-
yet shrink when neither side has room for it. A single select (a
|
|
994
|
-
single `f.ref`, the redirect target) shows its pick inside the input;
|
|
995
|
-
a single ref's status, open link and remove sit in the input too. *(2026-09-17, reference picker and tags)*
|
|
996
|
-
- A create dialog (title and slug) before "+ New entry", if empty Untitled
|
|
997
|
-
drafts turn out to litter lists. *(2026-09-14, collections draft/publish)*
|
|
998
|
-
- `f.date()` and `f.datetime()` share one in-house calendar
|
|
999
|
-
(`admin/forms/DatePicker.tsx` over the pure `date-picker.ts`); the
|
|
1000
|
-
browsers' own pickers were rejected (they differ and take no tokens).
|
|
1001
|
-
Weeks start on Monday and month names come from the browser's language
|
|
1002
|
-
— `weekStart` and a month-name locale as config keys are follow-ups. *(2026-09-25, date pickers)*
|
|
1003
|
-
- Not built: a time-only field, a date range, `.min` / `.max` on dates. *(2026-09-25, date pickers)*
|
|
1004
|
-
- Refs inside lists and objects are refused at build time (`f.list`,
|
|
1005
|
-
`f.object`). A list of objects each holding a ref (a featured entry with
|
|
1006
|
-
its own caption) needs the resolver, the refs index and the admin's
|
|
1007
|
-
ref-option loading to address nested paths; add when a site asks. *(2026-09-07, list field)*
|
|
1008
|
-
- `emptyValue` for an `image`/`video`/`file` item is `undefined`, so a new
|
|
1009
|
-
media row starts empty; a required image item is then a validation
|
|
1010
|
-
error until filled, which is the intended nudge but reads as an error
|
|
1011
|
-
on a row the editor just added. *(2026-09-07, list field)*
|
|
1012
|
-
- A list item's subtree still uses the row index as its React key, so a list
|
|
1013
|
-
of objects each holding a list hands the inner rows another item's collapse
|
|
1014
|
-
state when an outer row moves; fix with a stable per-item key when nested
|
|
1015
|
-
lists get real use. *(2026-09-07, list field)*
|
|
1016
|
-
|
|
1017
|
-
#### Custom sections and section settings
|
|
1018
|
-
|
|
1019
|
-
- Inline editing of a column on the canvas is OFF (2026-09-15, after the
|
|
1020
|
-
first day's use): a click on a column reveals its editor in the
|
|
1021
|
-
inspector (`onFieldClick` in `EditorView.tsx` skips the session for the
|
|
1022
|
-
`custom` type). The marks, paths and session code are in place and
|
|
1023
|
-
tested; turn it on once a walk on a real device passes. *(2026-09-15, custom section)*
|
|
1024
|
-
- A canvas drag on column edges (today the bar in the inspector). *(2026-09-15, custom section)*
|
|
1025
|
-
- A background for a custom section from a config palette. *(2026-09-15, custom section)*
|
|
1026
|
-
- Elements as direct column children (today through richtext's menus). *(2026-09-15, custom section)*
|
|
1027
|
-
- A per-zone cap on columns (`custom: { columns: 2 }` or similar). *(2026-09-15, custom section)*
|
|
1028
|
-
- Presets exist for 2, 3 and 4 columns; 5 and 6 have the bar only. *(2026-09-15, custom section)*
|
|
1029
|
-
- Rows reorder with the ↑/↓ buttons only; no grip, no keyboard drag. *(2026-09-15, custom section)*
|
|
1030
|
-
- An empty column has no box on the live page (an empty paragraph
|
|
1031
|
-
renders 0px tall), so it cannot be clicked into from the canvas; the
|
|
1032
|
-
inspector's `Column N` editor is the way in. A `min-height` in editor
|
|
1033
|
-
mode would give it a target. *(2026-09-15, custom section)*
|
|
1034
|
-
- An empty column publishes (its richtext descriptor is `optional`);
|
|
1035
|
-
whether publish should refuse a row of only empty columns is open. *(2026-09-15, custom section)*
|
|
1036
|
-
- Growing a row copies the LAST span before renormalizing, so `[5, 7]` →
|
|
1037
|
-
`[3, 5, 4]`, not thirds; a preset is one click away. Equal thirds on
|
|
1038
|
-
grow would be the other reasonable rule. *(2026-09-15, custom section)*
|
|
1039
|
-
- A class-mapping API for projects that want the wrapper to emit
|
|
1040
|
-
classes instead of inline values (DESIGN.md OQ 13). *(2026-09-15, section settings)*
|
|
1041
|
-
- `f.global()` — a ref field on a section that renders a global item. *(2026-09-15, section settings)*
|
|
1042
|
-
- An AI helper filling the CSS box from a prompt (the box stores CSS,
|
|
1043
|
-
never a prompt, so the helper only fills it). *(2026-09-15, section settings)*
|
|
1044
|
-
- `and` / `or` conditions; today one `f.when()` per field. *(2026-09-15, section settings)*
|
|
1045
|
-
- A condition inside a list item can read the item's siblings and the
|
|
1046
|
-
top-level namespaces, not another item; per-item scoped `settings.`
|
|
1047
|
-
references inside lists are not a thing yet. *(2026-09-15, section settings)*
|
|
1048
|
-
- An element's settings cannot be hidden by the enclosing section's
|
|
1049
|
-
settings: an element card's scope is the element's own fields and
|
|
1050
|
-
settings. *(2026-09-15, section settings)*
|
|
1051
|
-
- A hidden field's refs still count as usage in the refs index (its value
|
|
1052
|
-
is kept, so the edge is real; whether usage should hide with it is open). *(2026-09-15, section settings)*
|
|
1053
|
-
|
|
1054
|
-
#### Globals
|
|
1055
|
-
|
|
1056
|
-
- Layout items in the outline: a "From the layout" group the bridge could
|
|
1057
|
-
populate from the `shared:` ids it reports; canvas-only today. *(2026-09-13, shared inline editing)*
|
|
1058
|
-
- The shared card's usage line lists page ids; titles need a pages
|
|
1059
|
-
lookup the editor does not hold (the usage rows carry only ids). *(2026-09-13, shared inline editing)*
|
|
1060
|
-
- A "save and publish" action for sites that want no draft stage on a
|
|
1061
|
-
shared item (every item is versioned since 2026-09-13; the flag is
|
|
1062
|
-
gone). *(2026-09-13, shared inline editing)*
|
|
1063
|
-
- The picker offers a shared item only in zones whose allow list includes
|
|
1064
|
-
its section (`allowedShared` in `admin/tree-ops.ts`), so a shared item's
|
|
1065
|
-
section is always also offered as an ordinary section. A "placeable
|
|
1066
|
-
only" mode (a shared item whose section no zone lists) would let the
|
|
1067
|
-
starter keep the quote shared-only. *(2026-09-07, starter)*
|
|
1068
|
-
- `f.shared(() => item)` — a ref field on a section that renders a shared
|
|
1069
|
-
item inside the section's own markup (spec 2026-09-07 decision 3). *(2026-09-07, shared content)*
|
|
1070
|
-
- Instance mode: a shared *type* with many editor-created items; today
|
|
1071
|
-
the answer is a collection plus a section with `f.ref` (decision 1). *(2026-09-07, shared content)*
|
|
1072
|
-
- Zone-shaped shared items (`smoodly.shared({ zone })`) (decision 2). *(2026-09-07, shared content)*
|
|
1073
|
-
- Standalone live preview on a visual item's admin page — would need a
|
|
1074
|
-
preview route in the glue. Less pressing since 2026-09-13: every shared
|
|
1075
|
-
render is edited in the page canvas, and the Globals page is the form. *(2026-09-07, shared content)*
|
|
1076
|
-
- Built-in meta on shared items and on placements (anchor per placement
|
|
1077
|
-
is the likely shape). *(2026-09-07, shared content)*
|
|
1078
|
-
- A placement of an object item or an unregistered item gates the page's
|
|
1079
|
-
publish (the refs edge is emitted without a registry lookup) yet never
|
|
1080
|
-
renders; only a hand-edited tree can produce one. *(2026-09-07, shared content)*
|
|
1081
|
-
|
|
1082
|
-
#### Drag and drop
|
|
1083
|
-
|
|
1084
|
-
- Cross-zone drag in the outline: each zone is its own sortable list;
|
|
1085
|
-
moving a block between zones is still insert-and-remove. *(2026-09-09)*
|
|
1086
|
-
- Nested manual collections show no grip (`canReorder` requires
|
|
1087
|
-
`depth === 1`) until the entry tree UI exists — a flat index is not a
|
|
1088
|
-
sibling index there. *(2026-09-09)*
|
|
1089
|
-
- Canvas drag: the overlay should reuse `ui/sortable.ts` across the
|
|
1090
|
-
iframe boundary; `useSortable` measures container children, which the
|
|
1091
|
-
overlay's ghost rows can be. *(2026-09-09)*
|
|
1092
|
-
- A dragged subtree stays collapsed after the drop (`onDragStart`
|
|
1093
|
-
collapses it through `toggle` and nothing re-expands it). *(2026-09-09)*
|
|
1094
|
-
|
|
1095
|
-
#### Admin UI
|
|
1096
|
-
|
|
1097
|
-
- The Globals page has no tab row: Content's groups, then a `Settings`
|
|
1098
|
-
label and the Settings groups, stacked. A tab row there would match the
|
|
1099
|
-
canvas card. *(2026-09-15, section settings)*
|
|
1100
|
-
- The warm look is the comparison block now (`WARM_*` + `WARM_EXTRA` in
|
|
1101
|
-
`src/admin/ui/theme.tsx`, `localStorage.smoodly-look = "warm"`); the
|
|
1102
|
-
technical block is deleted (tag `look-technical` keeps it). Remove the
|
|
1103
|
-
warm block once the neutral look is judged, the same way. *(2026-09-25, neutral restyle + confirm dialog)*
|
|
1104
|
-
- Keep the previous view painted until the next one has data: today a
|
|
1105
|
-
navigation switches instantly and the new view shows its own loading
|
|
1106
|
-
state; a transition that holds the old view, or prefetches the next
|
|
1107
|
-
view's ops call on hover, is the next step. *(2026-09-08, client routing)*
|
|
1108
|
-
- `LocaleSwitcher` in pill form has no "+ add" affordance: an absent
|
|
1109
|
-
locale reads subtle and switching to it lands on the form's or the
|
|
1110
|
-
editor's "doesn't exist yet · Add" pane, which adds. The dropdown form
|
|
1111
|
-
(four locales and up) still adds straight from its "Add locale" group,
|
|
1112
|
-
so the two forms differ by one click; a "+" inside the pill would close
|
|
1113
|
-
that gap. *(2026-09-09, admin tweaks)*
|
|
1114
|
-
- The image field's canvas height (300px) lives twice: `CANVAS_H` in
|
|
1115
|
-
`MediaWidget.tsx` sizes the frame, `.sm-media__canvas` /
|
|
1116
|
-
`.sm-media__thumb` cap it in the stylesheet. One token would do. *(2026-09-09, admin tweaks)*
|
|
1117
|
-
- `.sm-form>*{max-width:680px}` caps the banner too; if a wide
|
|
1118
|
-
field type ever appears (a table), give it an opt-out class. *(2026-09-09, admin tweaks)*
|
|
1119
|
-
- An element card decides "open" from its fields alone (blank = open);
|
|
1120
|
-
an element whose fields are all optional and empty by design stays
|
|
1121
|
-
open on every load. The richtext image card is never collapsible. *(2026-09-09, admin tweaks)*
|
|
1122
|
-
- The "+" menu anchors to the toolbar's right end (`right:0` in
|
|
1123
|
-
`.sm-richtext__head`), which is under the "+" only while the toolbar
|
|
1124
|
-
fits on one line; in the 320px inspector the toolbar wraps and the
|
|
1125
|
-
menu sits below the wrapped row's end. *(2026-09-09, admin tweaks)*
|
|
1126
|
-
- The entry form's column is narrower than the inspector, so the default
|
|
1127
|
-
toolbar wraps to two rows there; the inspector shows it on one. *(2026-09-08, richtext widget)*
|
|
1128
|
-
- A list row's collapsed summary shows the item's first string subfield —
|
|
1129
|
-
the header nav rows read `/`, `/posts` — where the label would identify
|
|
1130
|
-
the row; `itemSummary` could prefer a `label`/`title`/`heading` key
|
|
1131
|
-
(walk, 2026-09-07). *(2026-09-07, starter)*
|
|
1132
|
-
- Page settings prints `paths[locale]` under each slug field — the
|
|
1133
|
-
locale-relative CMS path, not `urls[locale]`, the site URL the canvas
|
|
1134
|
-
and "View live" actually open. On a translated site the Finnish hint
|
|
1135
|
-
reads `/yritys/tietoa-meista` while the page lives at
|
|
1136
|
-
`/fi/yritys/tietoa-meista`. Both maps are already in `EditorView`;
|
|
1137
|
-
which one belongs under the field is a UI decision, not a missing
|
|
1138
|
-
value. *(2026-09-06)*
|
|
1139
|
-
- The row menu in `PagesList.tsx` does not close on Escape — only a click
|
|
1140
|
-
on the backdrop closes it. *(2026-09-06)*
|
|
1141
|
-
- `PageSettings` re-seeds its inputs from `record` on every rename (and
|
|
1142
|
-
every publish), so keystrokes typed while a rename is in flight can be
|
|
1143
|
-
dropped. The blur- and Enter-driven commits themselves are unaffected. *(2026-09-06)*
|
|
1144
|
-
- A childless folder row is inert on click (a mount opens its collection,
|
|
1145
|
-
a page opens the editor, a folder with children expands); "Add page
|
|
1146
|
-
here" lives in the row menu only. *(2026-09-06)*
|
|
1147
|
-
- The tree rows nest interactive buttons — the chevron and the `···`
|
|
1148
|
-
menu — inside a `role="button"` row. It works, because both stop the
|
|
1149
|
-
click and the keydown, but it is an accessibility smell a real treegrid
|
|
1150
|
-
would not have. *(2026-09-06)*
|
|
1151
|
-
- The Pages list offers "Add <locale>" on a dimmed row whose parent lacks
|
|
1152
|
-
that locale, and learns the refusal only after the round trip.
|
|
1153
|
-
`pagesTreeRows` already carries every node's locales, so the parent's
|
|
1154
|
-
presence could disable the item with its reason up front, exactly as
|
|
1155
|
-
the editor's switcher does with `parentLocales`. `EntriesList` has the
|
|
1156
|
-
same gap (and `entryRows` would have to carry the locales again — the
|
|
1157
|
-
dead field was deleted rather than left unused); it is unreachable
|
|
1158
|
-
today, since nothing in the admin creates a child entry. *(2026-09-06)*
|
|
1159
|
-
- Design system pass (DESIGN.md §2, "The admin's visual system"): the shell,
|
|
1160
|
-
lists, entry form, login and editor were restyled onto `admin/ui/theme.tsx`.
|
|
1161
|
-
Deferred deliberately, because each is a feature rather than a restyle: the
|
|
1162
|
-
list search field and status filter, the locale select in the shell's top bar
|
|
1163
|
-
(the editor has one; the lists always read the default locale), the article
|
|
1164
|
-
sidebar's taxonomy/SEO/publish-date sections, and the rich-text toolbar. The
|
|
1165
|
-
pages list's tree shape (chevrons, depth indent) and its row `···` menu
|
|
1166
|
-
shipped 2026-09-06, without the drag handle. The tokens for all of them are
|
|
1167
|
-
already in place. *(2026-09-05)*
|
|
1168
|
-
- `lucide-react` is now a runtime dependency, for the toggle's two icons. The
|
|
1169
|
-
rest of the UI still uses the design system's unicode glyphs (↑ ↓ ⧉ × ··· ▾
|
|
1170
|
-
← ⋮⋮); swapping them for Lucide is the next step whenever it is wanted. *(2026-09-05)*
|
|
1171
|
-
|
|
1172
|
-
#### Addresses and the Pages tree
|
|
1173
|
-
|
|
1174
|
-
- The rename/move confirm's code-address line names the addresses in
|
|
1175
|
-
the selected locale only, though a move re-paths every locale; the
|
|
1176
|
-
Pages list's conflict notice is one ellipsized line (the whole text
|
|
1177
|
-
on hover), and an editor's flagged page cannot be moved from the
|
|
1178
|
-
notice itself. *(2026-09-29, nested collection paths)*
|
|
1179
|
-
- An index page removed from code leaves the collection's record with
|
|
1180
|
-
its template (spec amendment 3): the record keeps rendering as that
|
|
1181
|
-
page type's stale name until an editor or a later pass clears it. *(2026-09-29, nested collection paths)*
|
|
1182
|
-
|
|
1183
|
-
#### Collections
|
|
1184
|
-
|
|
1185
|
-
- Nested entries have no admin UI beyond `entries.move`: nothing creates
|
|
1186
|
-
a child entry or shows the entry tree, and the entry form's slug prefix
|
|
1187
|
-
is the collection's mount base only, not the parent chain. *(2026-09-06)*
|
|
1188
|
-
- A published entry under a draft parent is dropped from `getEntriesTree`
|
|
1189
|
-
(the parent is not in the list to hang it from) but returned flat by
|
|
1190
|
-
`getEntries`. Untested either way. *(2026-09-05)*
|
|
1191
|
-
|
|
1192
|
-
#### Queries (`q`)
|
|
1193
|
-
|
|
1194
|
-
- `presence()` serves the presence channel only; a future read gateway (DESIGN.md OQ 31) is not built on it — no key but a write key reads content. *(2026-09-26, concurrency; 2026-09-30 §8)*
|
|
1195
|
-
- `where` ranges and negation. *(2026-09-11, queries)*
|
|
1196
|
-
- `EntryItem.updatedAt`, and `order: updatedAt`, read the LIVE locale row, so a draft autosave bumps the public timestamp and can reorder a public list; read the published version's `saved_at` for published reads. *(2026-09-30, content API contract final review)*
|
|
1197
|
-
- The bare-slug `getEntry` fallback re-reads the whole collection and resolves every entry's references and media before matching one slug; match on `record` first, then resolve only the hit (or query by slug). *(2026-09-30, content API contract final review)*
|
|
1198
|
-
- `SmoodlySite.resolve` (and `load`) still return the full `EntryRecord` (every locale, `updatedBy`) on the object the developer holds; the types are unexported and the spec calls it internal — mark it `@internal` or move both off the public interface. *(2026-09-30, content API contract final review)*
|
|
1199
|
-
- A nested `.resolve({ a: { b: true } })` types only the top level (`Rebase<V, keyof P>`) while the runtime resolves deeper; type the nesting. *(2026-09-30, content API contract final review)*
|
|
1200
|
-
- Any-of over an array-valued field: any-of compares the text form of
|
|
1201
|
-
the field, so it covers selects, refs and numbers; on a checkboxes,
|
|
1202
|
-
refList or list field `site.getEntries` refuses it rather than match
|
|
1203
|
-
nothing — both adapters agree on the text form, so a one-element array
|
|
1204
|
-
never matches its element. *(2026-09-11, queries)*
|
|
1205
|
-
- `getEntriesTree` has no `q` counterpart yet; add one when a site
|
|
1206
|
-
needs a nested collection nav. *(2026-09-11, queries)*
|
|
1207
|
-
- `getShared(["header", "footer", "contact"], { locale })` returning a
|
|
1208
|
-
typed tuple, as sugar over the `Promise.all` the layout writes today.
|
|
1209
|
-
Keep one cache unit per item under its own tag — a single unit tagged
|
|
1210
|
-
with all three would expire the header whenever the footer is edited. *(2026-09-11, queries)*
|
|
1211
|
-
- An explicit JSON `null` VALUE in a declared `order.by` field sorts
|
|
1212
|
-
FIRST in Supabase (a jsonb null is the lowest jsonb value, not SQL
|
|
1213
|
-
NULL, so `nullsFirst: false` does not reach it) and LAST in memory. A
|
|
1214
|
-
MISSING key is SQL NULL and sorts last in both, and the admin only ever
|
|
1215
|
-
produces that — a cleared number field emits `undefined`, which the
|
|
1216
|
-
JSON body drops — so this needs a programmatic or external writer.
|
|
1217
|
-
Mixed types in one field diverge for the same reason: Postgres orders
|
|
1218
|
-
jsonb by type first (null < string < number), memory by `String(...)`. *(2026-09-05)*
|
|
1219
|
-
|
|
1220
|
-
#### SEO and page fields
|
|
1221
|
-
|
|
1222
|
-
- A `styles` block on pages, when a case needs presentation separate
|
|
1223
|
-
from content. *(2026-09-10, page fields and SEO)*
|
|
1224
|
-
- Refs in page fields, once the refs index walks them. *(2026-09-10, page fields and SEO)*
|
|
1225
|
-
- A canonical URL field and a search-result preview snippet in the
|
|
1226
|
-
SEO & Social tab. *(2026-09-10, page fields and SEO)*
|
|
1227
|
-
- Configurable SEO set (`meta: { seo: { twitter: true } }`) if the four
|
|
1228
|
-
values prove too few. *(2026-09-10, page fields and SEO)*
|
|
1229
|
-
- Link fields are raw: an image in a page field reaches a nav link as
|
|
1230
|
-
`{ asset, alt }`, not a URL. Resolve on demand if a nav ever wants one. *(2026-09-10, page fields and SEO)*
|
|
1231
|
-
- Page-field labels derive from the key (`showInNavigation` → "Show In
|
|
1232
|
-
Navigation"), as every field's do; a `.label()` builder option would
|
|
1233
|
-
read better for long keys. *(2026-09-10, page fields and SEO)*
|
|
1234
|
-
|
|
1235
|
-
#### Site rendering and starters
|
|
1236
|
-
|
|
1237
|
-
- A separate canvas route would take the last Smoodly bytes off live
|
|
1238
|
-
pages (the lazy stub that loads the bridge and the canvas boundary in
|
|
1239
|
-
the editor frame), at the cost of the frame no longer loading the
|
|
1240
|
-
page's real address: the site's layout would have to be reached from
|
|
1241
|
-
a sibling route, and a developer's own route file or the escape hatch
|
|
1242
|
-
would preview differently from live. Only if a site needs zero.
|
|
1243
|
-
*(2026-09-29, pre-install fixes)*
|
|
1244
|
-
- A layout-only language switch, if a real site needs one: a
|
|
1245
|
-
`q().getAlternates(path)` over the `paths` index plus a proxy header
|
|
1246
|
-
carrying the request path. Not built until asked for — the starters
|
|
1247
|
-
render the switch from the page (`components/PageFrame.tsx`,
|
|
1248
|
-
`components/LocaleNav.tsx`). *(2026-09-15, locale alternates)*
|
|
1249
|
-
- A paired component (`smoodly.section`) accepts only its schema's props, so
|
|
1250
|
-
a layout cannot hand the footer the contact item or the header the locale
|
|
1251
|
-
links through the paired `Footer`/`Header`; the starter's layouts render
|
|
1252
|
-
`FooterView`/`HeaderView` directly with the extra prop. A supported way
|
|
1253
|
-
to pass layout-only props through a paired component (or a `children`
|
|
1254
|
-
slot) would let the layouts use the registered component. *(2026-09-07, starter)*
|
|
1255
|
-
- A per-locale collection path or fixed slug must name the default locale;
|
|
1256
|
-
the installer rewrites the starter's two lines to name exactly the
|
|
1257
|
-
chosen locales (`articlesSegments` in `scaffold.ts`), since a path
|
|
1258
|
-
claiming a locale the site lacks fails the index-page rule at config
|
|
1259
|
-
load. A "fall back to the first declared segment" rule in
|
|
1260
|
-
`defineConfig` would make the patch unnecessary. *(2026-09-07, starter)*
|
|
1261
|
-
- Registry names are one namespace across sections, elements, collections
|
|
1262
|
-
and page shapes (`assertUniqueNames`); the starter names its People
|
|
1263
|
-
section `peopleSection` and its Articles page `articlesIndex` to stay
|
|
1264
|
-
clear of the collections. Namespacing by kind would let a section and a
|
|
1265
|
-
collection share a name. *(2026-09-07, starter)*
|
|
1266
|
-
- With `fi` as the default locale the seed writes the English content into
|
|
1267
|
-
`fi`. Before this fix the index page was seeded at `posts`
|
|
1268
|
-
while the collection's `fi` path is `artikkelit`, so `ensureFixedNodes`
|
|
1269
|
-
materialized a second, empty Articles page at `artikkelit` and the
|
|
1270
|
-
links pointed at `/posts`; the seed now derives the segment from the
|
|
1271
|
-
collection's `path` per locale. Left: the starter's copy in a `fi`
|
|
1272
|
-
default is still English. *(2026-09-07, starter)*
|
|
1273
|
-
- Cache tags are still `page:<id>`: publishing one locale expires every
|
|
1274
|
-
locale's cached unit of that page (spec 2026-09-06 §4). A
|
|
1275
|
-
`page:<id>:<locale>` tag would halve the revalidations on a bilingual
|
|
1276
|
-
site. *(2026-09-06)*
|
|
1277
|
-
- Examples and guides write `url(entry.path!)`: `path` is `string | null` on every entry, though a collection that declares `path` always has one — type it `string` for those and drop the assertions. *(2026-09-30, content API contract final review)*
|
|
1278
|
-
- `smoodlyMetadata` does not apply the registration check `renderSmoodlyPath`
|
|
1279
|
-
applies: a record whose `template` has no registration is a 404 body with
|
|
1280
|
-
its meta emitted beside it. *(2026-09-05)*
|
|
1281
|
-
|
|
1282
|
-
#### Media
|
|
1283
|
-
|
|
1284
|
-
- Video transcoding and adaptive streaming: a Mux or Cloudflare Stream
|
|
1285
|
-
loader behind the `media.loader` seam, if a site ever needs more than
|
|
1286
|
-
an MP4 over Storage's CDN. *(2026-09-08, media library)*
|
|
1287
|
-
- Sweeping abandoned `pending` asset rows (an upload that began and
|
|
1288
|
-
never called `finishUpload`); a periodic op or a cleanup on `list`. *(2026-09-08, media library)*
|
|
1289
|
-
- A private bucket with signed reads, for sites that must not expose
|
|
1290
|
-
draft-only media by URL before publish. *(2026-09-08, media library)*
|
|
1291
|
-
- Pre-generated image variants at upload time, for hosts without sharp. *(2026-09-08, media library)*
|
|
1292
|
-
- Image dimensions server-side cover JPEG and PNG (`imageDimensions`); a
|
|
1293
|
-
WebP or AVIF seeded through `AssetStore.create` lands without a size
|
|
1294
|
-
and resolves to nothing until the row is edited. Browser uploads
|
|
1295
|
-
measure every type. *(2026-09-08, media library)*
|
|
1296
|
-
|
|
1297
|
-
#### History and publishing
|
|
1298
|
-
|
|
1299
|
-
- Client-side undo in the editor (a stack of tree snapshots); the store
|
|
1300
|
-
is no longer an undo stack by design. *(2026-09-13, history)*
|
|
1301
|
-
- The author per version row and in the lists; named versions. *(2026-09-13, history)*
|
|
1302
|
-
- History on the canvas card's menu and in the list row menus. *(2026-09-13, history)*
|
|
1303
|
-
- A diff between two versions. *(2026-09-13, history)*
|
|
1304
|
-
- The lists' row menus (Pages, Entries) do not offer Unpublish; the
|
|
1305
|
-
document's own control does. *(2026-09-13, publish flow)*
|
|
1306
|
-
|
|
1307
|
-
#### Collaboration
|
|
1308
|
-
|
|
1309
|
-
- A hard lock with takeover, if a real team asks: presence has the facts. *(2026-09-26, concurrency)*
|
|
1310
|
-
- Presence on the members and media screens; today only pages, entries and
|
|
1311
|
-
global items track. *(2026-09-26, concurrency)*
|
|
1312
|
-
- Postgres Changes for live list refresh; the lists still load on navigation. *(2026-09-26, concurrency)*
|
|
1313
|
-
- Field-level merging of two editors' changes; the guard refuses whole documents. *(2026-09-26, concurrency)*
|
|
1314
|
-
- A dropped socket clears presence on Realtime's timeout (tens of seconds); the
|
|
1315
|
-
lock outlives a closed tab by that much. *(2026-09-26, concurrency)*
|
|
1316
|
-
|
|
1317
|
-
#### Members
|
|
1318
|
-
|
|
1319
|
-
- Members management shipped 2026-09-25 (spec
|
|
1320
|
-
`docs/superpowers/specs/2026-09-25-members-management-design.md`); out
|
|
1321
|
-
of its scope: a pending-invite status and "resend invite" on a row
|
|
1322
|
-
(needs the Auth user's `last_sign_in_at`, which the cloud projection
|
|
1323
|
-
does not carry); removing the Auth account together with the member
|
|
1324
|
-
row; invites from the cloud's `/admin` (they are smoodly.io's); a
|
|
1325
|
-
project whose Auth is OAuth-only (the invite mail asks for a password). *(2026-09-25, settings + members)*
|
|
1326
|
-
- The members list is not paged (tens of rows, not thousands). *(2026-09-25, settings + members)*
|
|
1327
|
-
|
|
1328
|
-
#### Redirects
|
|
1329
|
-
|
|
1330
|
-
- Destinations outside Smoodly, extension and query-string addresses,
|
|
1331
|
-
code-owned renames (a collection `path`, a fixed slug): the guide's
|
|
1332
|
-
`next.config` recipe; a `redirects` table is OQ 34. *(2026-09-26, redirects)*
|
|
1333
|
-
- The content API's `getPage("/old")` returns null for a redirect row
|
|
1334
|
-
(spec §7); a `follow` option is a one-line addition if a site asks. *(2026-09-26, redirects)*
|
|
1335
|
-
- A redirect row has no audit columns (who, when); add them with a
|
|
1336
|
-
need. The Redirects page is not paged. *(2026-09-26, redirects)*
|
|
1337
|
-
|
|
1338
|
-
#### Export and import
|
|
1339
|
-
|
|
1340
|
-
- `import --replace`: delete the space's content and media first, after
|
|
1341
|
-
typing the space to confirm — a backup restored over a live project.
|
|
1342
|
-
Mention it in the not-empty refusal when it lands. *(2026-09-28, export/import)*
|
|
1343
|
-
- A readable export for leaving Smoodly: one document per page or entry,
|
|
1344
|
-
fields resolved, no internal IDs. *(2026-09-28, export/import)*
|
|
1345
|
-
- A "Download backup" button in the admin: the same export behind an op,
|
|
1346
|
-
zipped for an editor. *(2026-09-28, export/import)*
|
|
1347
|
-
- Import cannot expire the app's cache tags; it prints a restart note. A
|
|
1348
|
-
revalidate call through the app (an op the CLI could reach) would make
|
|
1349
|
-
the note unnecessary. *(2026-09-28, export/import)*
|
|
1350
|
-
|
|
1351
|
-
#### Content health and drift
|
|
1352
|
-
|
|
1353
|
-
- Content health lists an entry slug outside the grammar, but its link
|
|
1354
|
-
opens an entry form that marks nothing on the slug: drift leaves the
|
|
1355
|
-
slug out on purpose (a marker would flash while it is typed), so only
|
|
1356
|
-
the publish refusal marks it. Mark it in the form once the box has lost
|
|
1357
|
-
focus, if editors miss it. *(2026-09-29, pre-install fixes)*
|
|
1358
|
-
- Content health does not check page slugs: pages refuse a bad slug at
|
|
1359
|
-
create and rename, so only an import of older data or a hand edit could
|
|
1360
|
-
hold one. *(2026-09-29, pre-install fixes)*
|
|
1361
|
-
- Refs inside unknown nodes are not indexed: without a schema nothing
|
|
1362
|
-
says which values are refs, so safe-delete cannot see them. Publish
|
|
1363
|
-
refuses such nodes and a live one renders nothing. *(2026-09-27, editor safety)*
|
|
1364
|
-
- Leftover shared values after `.localized()` is turned on stay on the
|
|
1365
|
-
entry as every locale's fallback; removing them for good waits on
|
|
1366
|
-
the fix tooling (OQ 35). `localization-on` is reported only where a
|
|
1367
|
-
locale has no value of its own, so a fully translated entry no
|
|
1368
|
-
longer lists it. *(2026-09-27, editor safety)*
|
|
1369
|
-
- A CLI drift report was left out (the admin already has the config and
|
|
1370
|
-
the database; a CLI needs the service key and a TS config loader). *(2026-09-27, editor safety)*
|
|
1371
|
-
- Turning `.localized()` off for a field with per-locale values leaves
|
|
1372
|
-
those values in `entry_locales.fields` until the next save of each
|
|
1373
|
-
locale (a save writes only the current split) — the drift/codemod
|
|
1374
|
-
pipeline (Phase 2) is where "keep en, discard fi" belongs. *(2026-09-06)*
|
|
1375
|
-
|
|
1376
|
-
#### Schema and migrations
|
|
1377
|
-
|
|
1378
|
-
- The views of a removed collection stay (spec 2026-09-26 §3); a
|
|
1379
|
-
`drop view` is the developer's. *(2026-09-26, schema migrations)*
|
|
1380
|
-
- `smoodly migrate` never pushes; a `--push` flag if the two-step
|
|
1381
|
-
proves annoying. *(2026-09-26, schema migrations)*
|
|
1382
|
-
|
|
1383
|
-
#### Data lifecycle
|
|
1384
|
-
|
|
1385
|
-
- A locale dropped from `locales.supported` leaves its `page_locales`,
|
|
1386
|
-
`page_versions` and `paths` rows in place but unreachable — nothing
|
|
1387
|
-
lists or serves them, and nothing cleans them up. It is the mirror of
|
|
1388
|
-
the materializer's top-up, and wants the same deliberate answer (an
|
|
1389
|
-
admin-visible orphan list, or a prune op). *(2026-09-06)*
|
|
1390
|
-
- `addLocale` does not bump the entry's `updatedAt` while `removeLocale`
|
|
1391
|
-
does. Both adapters agree, so the contract is honest, but "adding a
|
|
1392
|
-
language is not an edit and removing one is" is arbitrary — decide it
|
|
1393
|
-
deliberately before someone sorts a list by Updated. *(2026-09-06)*
|
|
1394
|
-
- No `rebuildPaths()`: the index is documented as derived and rebuildable, but
|
|
1395
|
-
nothing rebuilds it. *(2026-09-05)*
|
|
1396
|
-
|
|
1397
|
-
#### Config and registration
|
|
1398
|
-
|
|
1399
|
-
- `LOCALE_CODE_RE` accepts any 2–4 letter subtag as script or region
|
|
1400
|
-
(`en-ZZZZ` passes), and a duplicate entry in `supported` passes
|
|
1401
|
-
silently — boot validation catches typos in shape, not in fact. *(2026-09-06)*
|
|
1402
|
-
- `entryPathRows` relies on the stores validating locale keys on write
|
|
1403
|
-
(no guard of its own); `list`/`getMany` accept an unsupported locale
|
|
1404
|
-
silently and read as "no content". *(2026-09-06)*
|
|
1405
|
-
- `sections` is passed twice — to `createSmoodlySite` (ref resolution) and to
|
|
1406
|
-
`createPageRenderer` (the component map) — and `elements` cannot be passed
|
|
1407
|
-
at all; the two lists can drift silently. *(2026-09-05)*
|
|
1408
|
-
- `SmoodlyConfig.registry.pages` (`Registered` in `src/config.ts`) has no
|
|
1409
|
-
call signature, so every app's glue casts it `as PageRegistration[]`. *(2026-09-05)*
|
|
1410
|
-
- Two `entryPage` registrations for one collection are silently accepted.
|
|
1411
|
-
`PageStore.rename` still accepts a locale outside `supported`; the admin
|
|
1412
|
-
op rejects it first, so only a direct store caller reaches it. *(2026-09-05)*
|
|
1413
|
-
|
|
1414
|
-
#### Scale and performance
|
|
10
|
+
npx create-smoodly-app my-site
|
|
1415
11
|
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
and sorted by URL in JS; page them if a locale's redirect list grows
|
|
1419
|
-
past it. *(2026-09-29, nested collection paths)*
|
|
1420
|
-
- Scan paging: `health.scan` makes one bulk read per table (versions by
|
|
1421
|
-
id in chunks of 100), and PostgREST's max-rows would cap a very large
|
|
1422
|
-
space. Page it, and add a rail badge, when a real site's size asks. *(2026-09-27, editor safety)*
|
|
1423
|
-
- Server search for ref candidates: `useRefOptions` loads the whole
|
|
1424
|
-
target collection once per locale. When a collection outgrows that,
|
|
1425
|
-
the hook changes (a search op, plus "titles for these ids" for the
|
|
1426
|
-
picked rows); `RefPicker` takes options as a prop and does not. *(2026-09-17, reference picker and tags)*
|
|
1427
|
-
- `entries.distinct` flattens in JS over `entries.list`; an RPC over
|
|
1428
|
-
`jsonb_array_elements` when a collection outgrows it. *(2026-09-17, reference picker and tags)*
|
|
1429
|
-
- `InspectorFields` filters hidden entries per render; a section with
|
|
1430
|
-
many conditions re-evaluates every keystroke. Fine at the sizes seen. *(2026-09-15, section settings)*
|
|
1431
|
-
- Ops are built per call (`createAdminOps` inside the handler) so the
|
|
1432
|
-
session rides in as `actor`; if that ever measures, memoize the
|
|
1433
|
-
actor-free parts. *(2026-09-13, history)*
|
|
1434
|
-
- A GIN index on `entries.fields` when a customer's collection is large
|
|
1435
|
-
enough to need it; additive, no migration. *(2026-09-11, queries)*
|
|
1436
|
-
- Every revalidating op (publish, delete, a save of published content)
|
|
1437
|
-
answers with a fresh RSC render of the admin route — the layout's
|
|
1438
|
-
styles included, ~30 KB — because `updateTag` runs inside the server
|
|
1439
|
-
action and Next re-renders the tree it was posted to. Harmless now
|
|
1440
|
-
that the shell lives in the layout (the empty page subtree is what
|
|
1441
|
-
remounts), but it is the price of server actions as the transport. *(2026-09-08, client routing)*
|
|
1442
|
-
- `getSmoodlyShared` reads the store per request in draft mode and once
|
|
1443
|
-
per (item, locale) otherwise; a layout calling it for several items
|
|
1444
|
-
makes one round trip each — batch if it shows. *(2026-09-07, shared content)*
|
|
1445
|
-
- `ops.shared.get` runs `ensureSharedItems` and then lists the item
|
|
1446
|
-
again; one round trip could go. *(2026-09-07, shared content)*
|
|
1447
|
-
- `ensureSharedItems` runs on every `ops.shared.list()` — which is every
|
|
1448
|
-
editor mount, not only the Shared content page — costing one
|
|
1449
|
-
`entries.list` per registration per open. *(2026-09-07, shared content)*
|
|
1450
|
-
- `fanOut` resolves every shared registration's row id (one
|
|
1451
|
-
`entries.list` each) on every entry write, so it can expire
|
|
1452
|
-
`shared:<name>` when something the item references changes; a row-id
|
|
1453
|
-
cache would remove the reads but has to survive materialization. *(2026-09-07, shared content)*
|
|
1454
|
-
- `ensureFixedNodes` runs on every `pages.list` and every ROOT
|
|
1455
|
-
`pages.create` — one `listPages()` each, and a second one when it
|
|
1456
|
-
tried to create something. It also treats a root record sitting at a
|
|
1457
|
-
fixed slug as that node even when its `template` is a foreign one: the
|
|
1458
|
-
node is adopted, never recreated, and the ops then guard it as
|
|
1459
|
-
code-owned. *(2026-09-06)*
|
|
1460
|
-
- `fixedNodeSpecs(config)` rebuilds the whole spec list on every
|
|
1461
|
-
`fixedNodeOf` call, and the ops call `fixedNodeOf` per guarded write. A
|
|
1462
|
-
`WeakMap` memo keyed by the config object is the whole fix. *(2026-09-06)*
|
|
1463
|
-
- `SupabasePageStore.updateLocale` re-reads the record (`must(id)`) after
|
|
1464
|
-
every update, so `setTemplate` is N+1 in the page's locales: one update
|
|
1465
|
-
and one full read each. Only the last read is used. The Supabase entry
|
|
1466
|
-
adapter is the same shape: `create` is insert → insert → read →
|
|
1467
|
-
version → read → paths → refs, and `removeLocale` re-fetches the entry
|
|
1468
|
-
three times. *(2026-09-06)*
|
|
1469
|
-
- `entries.list` filters `{ locale, status }` in memory after fetching
|
|
1470
|
-
every row (PostgREST cannot filter the parent by an embedded row
|
|
1471
|
-
without `!inner`, which would drop the other rows); fine at collection
|
|
1472
|
-
sizes today. *(2026-09-06)*
|
|
1473
|
-
- `assertPublishable` (`store.ts`) awaits one `entries.get` per
|
|
1474
|
-
referenced entry, sequentially, on every publish — a `refList` of a
|
|
1475
|
-
dozen articles is a dozen serial reads, now per locale. `getMany(ids,
|
|
1476
|
-
locale)` takes exactly that shape and would make it one call. *(2026-09-06)*
|
|
1477
|
-
- `loadPage` calls `listPages()` for ancestors and children on every cache
|
|
1478
|
-
miss; fine for hundreds of nodes, a `children(id)`/`chain(id)` store query
|
|
1479
|
-
is the fix when a site outgrows it. *(2026-09-05)*
|
|
1480
|
-
- `loadEntry` probes each mounted collection in turn: the `paths` row
|
|
1481
|
-
carries the target id but not its collection. *(2026-09-05)*
|
|
12
|
+
This creates a Next.js site with the admin mounted, the database schema
|
|
13
|
+
in place and a first editor who can sign in.
|
|
1482
14
|
|
|
1483
|
-
|
|
15
|
+
## Environment
|
|
1484
16
|
|
|
1485
|
-
-
|
|
1486
|
-
(`applyResult`, `InfoRow`, the ref-options effect; the locale helpers
|
|
1487
|
-
are shared since 2026-09-08) and `SharedList.tsx` is the third copy of
|
|
1488
|
-
the remembered-locale block (`EntriesList`, `PagesList`); extract a
|
|
1489
|
-
`useFormLocale` hook when the next form arrives. *(2026-09-07, shared content)*
|
|
1490
|
-
- `CollectionSchema.kind` was widened to carry `"shared"`, so a
|
|
1491
|
-
`SharedSchema` typechecks where `registry.collections` is expected;
|
|
1492
|
-
`defineConfig` should throw on a `kind === "shared"` entry listed there. *(2026-09-07, shared content)*
|
|
1493
|
-
- Because `fanOut` lists every shared registration, an entry store built
|
|
1494
|
-
without `options.shared` now fails on every ordinary entry write with
|
|
1495
|
-
"no collection named", not only on `ops.shared.*`; both scaffolds pass
|
|
1496
|
-
the same `shared` array to the store and the config, so this bites only
|
|
1497
|
-
a hand-wired app. *(2026-09-07, shared content)*
|
|
1498
|
-
- `subtreeIds` and `entrySubtreeIds` in `ops-impl.ts` are the same O(n²)
|
|
1499
|
-
walk over a full list, written twice. One generic descendant walk when
|
|
1500
|
-
the list sizes make it matter. *(2026-09-06)*
|
|
1501
|
-
- `ops-impl.ts` is ~436 lines with a ~165-line `pages` group. The guard
|
|
1502
|
-
helpers (`nameOf`, `isHomeNode`, `supportedLocale`, `mountGuard`,
|
|
1503
|
-
`owned`) are the extraction seam — a `pages-ops.ts` taking them as
|
|
1504
|
-
collaborators. The `entries` group now duplicates two of them outright:
|
|
1505
|
-
the `supportedLocale` guard and the `parentLocales` computation exist
|
|
1506
|
-
once per group. *(2026-09-06)*
|
|
1507
|
-
- `EntryForm.tsx` grew the locale lifecycle beside the fields; the
|
|
1508
|
-
`LocaleSwitcher` is shared with the editor now, the add/remove flows
|
|
1509
|
-
are not (`doAddLocale`/`doRemoveLocale` exist twice). *(2026-09-06)*
|
|
1510
|
-
- `removeLocale`'s blocker list (`localeInUse`) is unordered in the
|
|
1511
|
-
Supabase adapter — the children query has no `.order()` — so a
|
|
1512
|
-
multi-blocker message can name the titles in a different order than the
|
|
1513
|
-
memory adapter's. Message text only; the contract case has one blocker. *(2026-09-06)*
|
|
1514
|
-
- `entry-store.ts` is ~624 lines. The pure helpers (`localizedKeys`,
|
|
1515
|
-
`splitFields`, `entryFieldsIn`, `entryRefEdges`, `entryTitleIn`) are the
|
|
1516
|
-
extraction seam from the memory adapter, the same shape as the
|
|
1517
|
-
`EditorView.tsx` and `ops-impl.ts` entries in this list. *(2026-09-06)*
|
|
1518
|
-
- The memory stores default to SEPARATE private path indexes while the
|
|
1519
|
-
Supabase stores share one `paths` table: pages and entries share a URL space
|
|
1520
|
-
only when the wiring passes one `paths` option to both (convention, not
|
|
1521
|
-
structure). *(2026-09-05)*
|
|
1522
|
-
- `SupabasePathIndex` falls back to `pathTakenError("?", "?")` when
|
|
1523
|
-
Postgres reports a unique violation without `details` — the message then
|
|
1524
|
-
names no path. *(2026-09-05)*
|
|
1525
|
-
- `chainOf` has no cycle guard: a corrupted `parent_id` loop would spin
|
|
1526
|
-
forever. The stores prevent cycles on `move`, so it is unreachable today. *(2026-09-05)*
|
|
1527
|
-
- The `scroll` message in `admin/editor/protocol.ts` is no longer sent: with the
|
|
1528
|
-
canvas column as the scroller, `EditorView` scrolls it directly instead of
|
|
1529
|
-
asking the bridge to `scrollIntoView` across the frame. `EditorBridge` still
|
|
1530
|
-
handles the message, so old and new admins interoperate; drop the message on
|
|
1531
|
-
the next protocol change. *(2026-09-05)*
|
|
1532
|
-
- `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)*
|
|
1533
|
-
- `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)*
|
|
1534
|
-
- `serialize.ts` structural cast duplicates `PageSchema`; tighten `Registered.schema` so the cast goes. *(2026-09-03)*
|
|
1535
|
-
- `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)*
|
|
17
|
+
A self-hosted app needs three variables:
|
|
1536
18
|
|
|
1537
|
-
|
|
19
|
+
| Variable | What it is |
|
|
20
|
+
| --- | --- |
|
|
21
|
+
| `SMOODLY_URL` | Your Supabase project's URL |
|
|
22
|
+
| `SMOODLY_SECRET_KEY` | The project's secret key. Server-only. |
|
|
23
|
+
| `SMOODLY_PUBLISHABLE_KEY` | The project's publishable key |
|
|
1538
24
|
|
|
1539
|
-
|
|
1540
|
-
than a fixture file, so `e2e/fixtures/` from the spec does not exist. *(2026-09-26, browser suite)*
|
|
1541
|
-
- ListView rows that are not clickable (Members) carry no role; the
|
|
1542
|
-
members test finds its row by `.sm-table__body > div`. A table role
|
|
1543
|
-
set (`table`/`row`/`cell`) on ListView would give every list a proper
|
|
1544
|
-
locator. *(2026-09-26, browser suite)*
|
|
1545
|
-
- The picker's click cannot be unit-tested (no DOM in the suite); the
|
|
1546
|
-
walk covers it. A jsdom environment for the admin tests is a future
|
|
1547
|
-
tooling item. *(2026-09-15, custom section)*
|
|
1548
|
-
- The list widget has no test for an object item holding a nested list
|
|
1549
|
-
subfield. *(2026-09-07, list field)*
|
|
1550
|
-
- The row menu's item set (`menuItems`) and the row-click routing live in
|
|
1551
|
-
`PagesList.tsx`, untested; extract them into `pages-tree.ts` before
|
|
1552
|
-
plan 4 adds entry rows to the same menu. *(2026-09-06)*
|
|
1553
|
-
- `publish`'s "has no draft to publish" branch is unreachable with the
|
|
1554
|
-
memory store — `createPage` gives every templated page a first version
|
|
1555
|
-
— and is untested. It is defence for the Supabase adapter. *(2026-09-06)*
|
|
1556
|
-
- Classifications the ops tests do not assert: a move that breaks the
|
|
1557
|
-
home page's no-children rule or exceeds the tree depth (create asserts
|
|
1558
|
-
both), and `entries.move`'s cross-collection and sibling-slug guards.
|
|
1559
|
-
`MemoryEntryStore.move` has no children guard at all — the subtree
|
|
1560
|
-
rides along — so `has child` is reachable from delete only. *(2026-09-06)*
|
|
1561
|
-
- No test pins that a published child's breadcrumb carries an UNPUBLISHED
|
|
1562
|
-
ancestor's title with `hasPage: false`; `site.test.tsx` covers the
|
|
1563
|
-
folder ancestor only, and the two paths share one `hasPage` predicate. *(2026-09-06)*
|
|
1564
|
-
- `MemoryPageStore.publish` throws the "is a folder" message when a
|
|
1565
|
-
templated row has no draft version. Unreachable through the ops (a
|
|
1566
|
-
templated page always gets a first version), but the message would be
|
|
1567
|
-
wrong if it ever were. *(2026-09-06)*
|
|
1568
|
-
- `admin-entries-list.test.ts` has no case for a row with differing
|
|
1569
|
-
pointers (the `draft edits` status), nor one asserting that an absent
|
|
1570
|
-
row's `updatedAt` falls back to the node's. Two assertions. *(2026-09-06)*
|
|
1571
|
-
- The live suites run on vitest's DEFAULT 5s per-test timeout against a real
|
|
1572
|
-
database, while a single contract test makes dozens of round trips. On a
|
|
1573
|
-
loaded or degraded local stack `npm run test:supabase` then fails with bare
|
|
1574
|
-
timeouts on arbitrary tests (every file passes on its own, and the whole
|
|
1575
|
-
run passes with `--testTimeout=30000`). Either raise the timeout in
|
|
1576
|
-
`scripts/test-supabase.sh` or accept the flake in CI. *(2026-09-05)*
|
|
1577
|
-
- Entry `move` coverage is thinner than pages' — no omitted-index, clamp or
|
|
1578
|
-
cross-collection tests. *(2026-09-05)*
|
|
25
|
+
## Upgrading
|
|
1579
26
|
|
|
1580
|
-
|
|
27
|
+
A new version can need a newer database schema. After updating the
|
|
28
|
+
package, `npx smoodly migrate` writes the migration files; the
|
|
29
|
+
Upgrading guide has the rest.
|
|
1581
30
|
|
|
1582
|
-
|
|
1583
|
-
environment", staging → production; needs the reference walk (parent
|
|
1584
|
-
links, version pointers, refs, paths, asset ids in trees, fields and
|
|
1585
|
-
richtext) the eject avoids by keeping IDs. *(2026-09-28, export/import)*
|
|
1586
|
-
- Every op call with a junk token costs one `auth.getUser` against the
|
|
1587
|
-
auth project; rate limiting is the session-model spec's (OQ 20). *(2026-09-26, security pass)*
|
|
1588
|
-
- The social image URL goes through the default `next` loader at 1200px;
|
|
1589
|
-
the cloud's transform loader (OQ 28) should serve a real 1200×630. *(2026-09-10, page fields and SEO)*
|
|
1590
|
-
- A cross-origin admin needs the bridge-side editing variant (spec §3). *(2026-09-10, inline editing)*
|
|
1591
|
-
- Transform loaders beyond the Next default: Supabase Image
|
|
1592
|
-
Transformations, imgix, Cloudflare Images (DESIGN.md OQ 28). *(2026-09-08, media library)*
|
|
1593
|
-
- `<SmoodlyImage>` sizes the image by the crop factor, so `sizes` picks a
|
|
1594
|
-
candidate for the box, not for the framed image; a CDN loader with
|
|
1595
|
-
`handlesCrop` removes the overfetch. *(2026-09-08, media library)*
|
|
1596
|
-
- Light/dark toggle (nav foot): the choice is stored in `localStorage`
|
|
1597
|
-
(`smoodly-theme`) and written as `data-theme` on the admin wrapper, never on
|
|
1598
|
-
the host `<html>`. It is per browser, not per editor account — a server-side
|
|
1599
|
-
preference would need a place to live (`members`, or a cloud user profile). *(2026-09-05)*
|
|
1600
|
-
- 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)*
|
|
1601
|
-
- 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)*
|
|
1602
|
-
- **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)*
|
|
1603
|
-
- **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)*
|
|
31
|
+
## Docs and source
|
|
1604
32
|
|
|
1605
|
-
|
|
33
|
+
Guides: https://github.com/laniakea-studio/smoodly-cms/tree/main/guides
|
|
34
|
+
Source: https://github.com/laniakea-studio/smoodly-cms
|
|
1606
35
|
|
|
1607
|
-
|
|
1608
|
-
them needs `npm run reseed` (or `scripts/demo-editor.ts`) first. *(2026-09-26, concurrency)*
|
|
1609
|
-
- Drafting version N+1: `npx tsx scripts/emit-migration.ts` prints the
|
|
1610
|
-
whole schema plus the upsert; keep only the delta, name the file with
|
|
1611
|
-
the current UTC stamp and `_smoodly_v<N+1>.sql`, bump `SCHEMA_VERSION`,
|
|
1612
|
-
run `npm run db:migrate` at the root so the repo copy follows, and
|
|
1613
|
-
`npm run check:migrations`. Additive only; a contraction waits for the
|
|
1614
|
-
compatibility window and carries `-- smoodly:contraction`. *(2026-09-26, schema migrations)*
|
|
36
|
+
MIT licensed.
|