smoodly 0.0.14 → 0.0.16

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. package/README.md +94 -15
  2. package/dist/admin/auth.d.ts +7 -4
  3. package/dist/admin/auth.js +1 -1
  4. package/dist/admin/client-ops.js +3 -3
  5. package/dist/admin/editor/CanvasOverlay.d.ts +5 -1
  6. package/dist/admin/editor/CanvasOverlay.js +5 -5
  7. package/dist/admin/editor/EditorView.d.ts +2 -0
  8. package/dist/admin/editor/EditorView.js +251 -23
  9. package/dist/admin/editor/PageSettings.js +3 -2
  10. package/dist/admin/editor/SharedInspector.d.ts +23 -0
  11. package/dist/admin/editor/SharedInspector.js +13 -0
  12. package/dist/admin/editor/shared-edit.d.ts +39 -0
  13. package/dist/admin/editor/shared-edit.js +124 -0
  14. package/dist/admin/editor/useSharedEdit.d.ts +6 -0
  15. package/dist/admin/editor/useSharedEdit.js +18 -0
  16. package/dist/admin/forms/tabs.d.ts +9 -3
  17. package/dist/admin/next/op-handler.js +4 -3
  18. package/dist/admin/ops-impl.d.ts +3 -0
  19. package/dist/admin/ops-impl.js +198 -23
  20. package/dist/admin/ops.d.ts +32 -0
  21. package/dist/admin/serialize.d.ts +0 -1
  22. package/dist/admin/serialize.js +0 -1
  23. package/dist/admin/shared-items.js +14 -0
  24. package/dist/admin/shell/AdminApp.js +1 -1
  25. package/dist/admin/shell/EntriesList.js +19 -3
  26. package/dist/admin/shell/EntryForm.js +183 -77
  27. package/dist/admin/shell/IconRail.js +3 -3
  28. package/dist/admin/shell/PagesList.js +3 -2
  29. package/dist/admin/shell/SharedFields.d.ts +21 -0
  30. package/dist/admin/shell/SharedFields.js +15 -0
  31. package/dist/admin/shell/SharedForm.js +166 -52
  32. package/dist/admin/shell/SharedList.js +3 -2
  33. package/dist/admin/shell/entries-list.d.ts +2 -2
  34. package/dist/admin/shell/entries-list.js +3 -3
  35. package/dist/admin/supabase-auth.js +1 -1
  36. package/dist/admin/ui/ActionMenu.d.ts +11 -0
  37. package/dist/admin/ui/ActionMenu.js +25 -0
  38. package/dist/admin/ui/HistoryModal.d.ts +23 -0
  39. package/dist/admin/ui/HistoryModal.js +40 -0
  40. package/dist/admin/ui/Modal.d.ts +5 -1
  41. package/dist/admin/ui/Modal.js +3 -3
  42. package/dist/admin/ui/PublishControl.d.ts +31 -0
  43. package/dist/admin/ui/PublishControl.js +24 -0
  44. package/dist/admin/ui/format.d.ts +4 -0
  45. package/dist/admin/ui/format.js +9 -0
  46. package/dist/admin/ui/primitives.d.ts +13 -0
  47. package/dist/admin/ui/primitives.js +18 -2
  48. package/dist/admin/ui/status-label.d.ts +10 -0
  49. package/dist/admin/ui/status-label.js +14 -0
  50. package/dist/admin/ui/theme.js +46 -6
  51. package/dist/collections.d.ts +0 -6
  52. package/dist/collections.js +0 -1
  53. package/dist/entry-store.d.ts +38 -13
  54. package/dist/entry-store.js +90 -23
  55. package/dist/next/page-renderer.js +8 -4
  56. package/dist/shared.d.ts +8 -1
  57. package/dist/shared.js +10 -1
  58. package/dist/site.d.ts +2 -0
  59. package/dist/site.js +17 -7
  60. package/dist/sql.js +18 -15
  61. package/dist/store.d.ts +38 -13
  62. package/dist/store.js +82 -12
  63. package/dist/supabase-entry-store.d.ts +11 -6
  64. package/dist/supabase-entry-store.js +101 -25
  65. package/dist/supabase-store.d.ts +13 -6
  66. package/dist/supabase-store.js +88 -19
  67. package/package.json +1 -1
package/README.md CHANGED
@@ -61,8 +61,10 @@ Implemented:
61
61
  `pages.tree.depth` capping how deep editors may nest), and expose
62
62
  `rename` (a locale's slug/title), `move`, `setTemplate`, `addLocale`,
63
63
  `removeLocale`, `resolvePath(locale, path)` and `pathsOf(id)` alongside
64
- the draft/publish semantics (versions accumulate; publish moves that
65
- locale's pointer; drafts never leak into published reads). The contract
64
+ the draft/publish semantics (one version row per publish plus the live
65
+ draft, rewritten in place; publish moves that locale's pointer and
66
+ freezes the row; discard deletes the abandoned draft; `restore` writes
67
+ an old version as the draft; drafts never leak into published reads). The contract
66
68
  is a reusable suite (`test/page-store-contract.ts`); every adapter runs
67
69
  the same tests.
68
70
  - `SupabasePageStore` — the same id-keyed contract over
@@ -148,10 +150,11 @@ Implemented:
148
150
  collection-entry persistence over entries/entry_locales/entry_versions/paths
149
151
  — one node, one row per locale, each locale with its own slug, status
150
152
  and `.localized()` fields, shared fields on the node, and a per-locale
151
- version history for collections with `versions: true` (spec 2026-09-06
152
- §7): `create({ locale })`, `update(id, locale, fields)`,
153
+ version history for every collection (spec 2026-09-14; the 2026-09-06
154
+ opt-in is gone): `create({ locale })`, `update(id, locale, fields)`,
153
155
  `setStatus(id, locale, status)`, `addLocale`, `removeLocale`,
154
- `listVersions`; saves rewrite the entry's refs rows over every locale's
156
+ `listVersions`, `getVersion` and `restore`; the draft row is rewritten
157
+ in place until published (spec 2026-09-13 history); saves rewrite the entry's refs rows over every locale's
155
158
  live field set, `referencesTo` answers the reverse
156
159
  question, `getMany` is the batched read whose signature is
157
160
  `resolveTree`'s `EntryFetcher`, now `(collection, ids, locale, { status })`,
@@ -426,6 +429,74 @@ after both are on the registry. From the repo root:
426
429
  `prepublishOnly` rebuilds each package. The Claude Code skill
427
430
  `.claude/skills/releasing-smoodly` carries the same order with its checks.
428
431
 
432
+ ## Follow-ups (logged 2026-09-14, collections draft/publish spec)
433
+
434
+ - Publish integrity for an entry's own refs: an entry can publish while
435
+ pointing at an unpublished entry; pages are gated, entries are not.
436
+ - A create dialog (title and slug) before "+ New entry", if empty Untitled
437
+ drafts turn out to litter lists.
438
+ - The page editor's publish banner lists fields as plain text; the forms'
439
+ lines focus the widget. Selecting the node from a `node.field` path is
440
+ the missing piece.
441
+ - Discard on a published row from before versioning that nobody has
442
+ written since is refused as "nothing published" (its pointers are null);
443
+ the control never offers it there, so nothing shows — the store could
444
+ answer it as a no-op instead.
445
+
446
+ ## Follow-ups (logged 2026-09-13, history spec)
447
+
448
+ - Existing databases (every install so far is ours; DESIGN.md OQ #2):
449
+ `alter table "page_locales" add column "updated_by" text;`
450
+ `alter table "entry_locales" add column "updated_by" text;`
451
+ `alter table "page_versions" rename column "created_at" to "saved_at";`
452
+ `alter table "page_versions" drop column "created_by";`
453
+ `alter table "entry_versions" rename column "created_at" to "saved_at";`
454
+ `alter table "entry_versions" drop column "created_by";`
455
+ The indexes follow the rename. Autosave rows from before read as
456
+ extra history rows until the page is published again; harmless.
457
+ Since 2026-09-14 every collection is versioned and `collectionSQL` emits one
458
+ view shape: re-apply `collectionSQL(collection)` for each collection on an
459
+ existing install (`supabase db reset` locally), or a published row from
460
+ before versioning reads through the view's live-row fallback until its
461
+ first write gives it a snapshot — either way nothing goes dark.
462
+ - Client-side undo in the editor (a stack of tree snapshots); the store
463
+ is no longer an undo stack by design.
464
+ - The author per version row and in the lists; named versions.
465
+ - History on the canvas card's menu and in the list row menus.
466
+ - A diff between two versions.
467
+ - Ops are built per call (`createAdminOps` inside the handler) so the
468
+ session rides in as `actor`; if that ever measures, memoize the
469
+ actor-free parts.
470
+
471
+ ## Follow-ups (logged 2026-09-13, publish flow spec)
472
+
473
+ - Unpublishing a page does not cascade: a published child keeps serving
474
+ under an unpublished parent's path. Decide whether it should.
475
+ - Versioning collections by default — decided 2026-09-14: every
476
+ collection, no flag, the forms autosave (spec
477
+ `docs/superpowers/specs/2026-09-14-collections-draft-publish-design.md`).
478
+ - A draft-mode banner on the site, or an "exit preview" action in the
479
+ admin: the same browser shows drafts of unpublished items after an
480
+ Unpublish, which reads as "it published my draft".
481
+ - The lists' row menus (Pages, Entries) do not offer Unpublish; the
482
+ document's own control does.
483
+
484
+ ## Follow-ups (logged 2026-09-13, shared inline editing spec)
485
+
486
+ - Layout items in the outline: a "From the layout" group the bridge could
487
+ populate from the `shared:` ids it reports; canvas-only today.
488
+ - The page autosave loses an edit made within the debounce window before
489
+ navigating away (`useDraftAutosave` only clears timers on unmount). The
490
+ shared controller flushes on unmount; the page's should too.
491
+ - A validation failure on a shared save retries with backoff like a
492
+ transport failure; the field error shows on the widget meanwhile. A
493
+ distinct "fix the field" state would stop the retries.
494
+ - The shared card's usage line lists page ids; titles need a pages
495
+ lookup the editor does not hold (the usage rows carry only ids).
496
+ - A "save and publish" action for sites that want no draft stage on a
497
+ shared item (every item is versioned since 2026-09-13; the flag is
498
+ gone).
499
+
429
500
  ## Follow-ups (logged 2026-09-11, queries spec)
430
501
 
431
502
  - `where` on localized fields (a join on the locale row), ranges and
@@ -449,9 +520,21 @@ after both are on the registry. From the repo root:
449
520
  `revalidate` hook for out-of-band writers.
450
521
  - `getEntriesTree` has no `q` counterpart yet; add one when a site
451
522
  needs a nested collection nav.
523
+ - `getShared(["header", "footer", "contact"], { locale })` returning a
524
+ typed tuple, as sugar over the `Promise.all` the layout writes today.
525
+ Keep one cache unit per item under its own tag — a single unit tagged
526
+ with all three would expire the header whenever the footer is edited.
452
527
  - `getEntry` by id goes through `site.getEntry`, which lists the whole
453
528
  collection to find one row; a primary-key read first would be cheaper
454
529
  on the uncached path (draft mode, first miss).
530
+ - The read API returns the site layer's shapes unprojected: `getEntries`
531
+ gives `{ entry: EntryRecord, path }` (the locales map, sort, parentId,
532
+ collection and timestamps ride along under `entry`), `getPage` gives
533
+ the renderer's `SitePage` (`record`, `tree`). Project in `queries.ts`,
534
+ at the public boundary, to `{ id, path, fields, updatedAt }` per entry
535
+ and a page without renderer internals; the tree item is already lean.
536
+ Do it before the Astro glue (OQ 29) wraps the same reads and before
537
+ a second site depends on the shape — it is a breaking change to `q`.
455
538
 
456
539
  ## Follow-ups (logged 2026-09-10, page fields and SEO spec)
457
540
 
@@ -474,10 +557,6 @@ after both are on the registry. From the repo root:
474
557
 
475
558
  ## Follow-ups (logged 2026-09-10, inline editing spec)
476
559
 
477
- - Shared placements ignore field clicks: their fields live in the shared
478
- item under Globals, which the page editor cannot patch. The starter's
479
- Header and Footer carry marks already; inline editing of shared content
480
- needs the shared form's ops behind the canvas.
481
560
  - Richtext instant write from the inspector: the pure `RichText` render
482
561
  swapped into the container while the widget is typed in.
483
562
  - Item-level chrome in the canvas ("Key points · 3 of 4" on hover, add,
@@ -878,8 +957,9 @@ the toolbar instead of pushing the content.
878
957
  - Instance mode: a shared *type* with many editor-created items; today
879
958
  the answer is a collection plus a section with `f.ref` (decision 1).
880
959
  - Zone-shaped shared items (`smoodly.shared({ zone })`) (decision 2).
881
- - Standalone live preview on a visual item's admin page — needs a
882
- preview route in the glue; the page canvas shows placements today (§6).
960
+ - Standalone live preview on a visual item's admin page — would need a
961
+ preview route in the glue. Less pressing since 2026-09-13: every shared
962
+ render is edited in the page canvas, and the Globals page is the form.
883
963
  - Built-in meta on shared items and on placements (anchor per placement
884
964
  is the likely shape).
885
965
  - Per-placement overrides (a shared band with a per-page heading) — out
@@ -1186,10 +1266,9 @@ the toolbar instead of pushing the content.
1186
1266
  is generated by `packages/smoodly/scripts/emit-sql.ts` from that
1187
1267
  script's OWN fixture collections, not from
1188
1268
  `examples/site/smoodly/collections.ts`; the create-smoodly-app template
1189
- migration IS generated from the example. Since the example's `articles`
1190
- is now `versions: true`, the repo-root `articles` view reads the live
1191
- locale row while the example's model serves the published snapshot —
1192
- the two should share one source.
1269
+ migration IS generated from the example. Since 2026-09-14 both emit the
1270
+ same view shape for every collection, but the repo-root fixture's field
1271
+ lists still drift from the example's — the two should share one source.
1193
1272
  - In the entry form, a shared ref field (e.g. an article's author) shows
1194
1273
  `—` in a locale where the target is absent, because the picker offers
1195
1274
  only targets present in the edited locale; the value is kept on the
@@ -1,10 +1,13 @@
1
1
  import type { AdminOpCall, OpResult } from "./ops.ts";
2
+ /** The verified editor: the Auth user id and the email the locale rows record as `updated_by` (spec 2026-09-13 history §2). */
3
+ export type AdminSession = {
4
+ userId: string;
5
+ email: string | null;
6
+ };
2
7
  export type AdminAuth = {
3
8
  /** The user behind a session token, or null when the auth project rejects it. */
4
- verify(token: string): Promise<{
5
- userId: string;
6
- } | null>;
9
+ verify(token: string): Promise<AdminSession | null>;
7
10
  /** Does `editors` hold this user for the current space? */
8
11
  isEditor(userId: string): Promise<boolean>;
9
12
  };
10
- export declare function gateAdminOp(auth: AdminAuth, call: AdminOpCall, run: (call: AdminOpCall) => Promise<OpResult<unknown>>): Promise<OpResult<unknown>>;
13
+ export declare function gateAdminOp(auth: AdminAuth, call: AdminOpCall, run: (call: AdminOpCall, session: AdminSession) => Promise<OpResult<unknown>>): Promise<OpResult<unknown>>;
@@ -24,5 +24,5 @@ export async function gateAdminOp(auth, call, run) {
24
24
  if (!editor) {
25
25
  return unauthorized("smoodly: this account is not an editor of this site.");
26
26
  }
27
- return run(call);
27
+ return run(call, session);
28
28
  }
@@ -1,7 +1,7 @@
1
1
  const GROUPS = {
2
- pages: ["list", "get", "create", "saveDraft", "publish", "delete", "rename", "move", "setTemplate", "addLocale", "removeLocale"],
3
- entries: ["list", "get", "create", "save", "setStatus", "delete", "move", "addLocale", "removeLocale"],
4
- shared: ["list", "get", "save", "setStatus"],
2
+ pages: ["list", "get", "create", "saveDraft", "publish", "unpublish", "discardDraft", "delete", "rename", "move", "setTemplate", "addLocale", "removeLocale", "listVersions", "restore"],
3
+ entries: ["list", "get", "create", "save", "setStatus", "discardDraft", "delete", "move", "addLocale", "removeLocale", "listVersions", "getVersion", "restore"],
4
+ shared: ["list", "get", "save", "setStatus", "discardDraft", "listVersions", "getVersion", "restore"],
5
5
  assets: ["list", "get", "beginUpload", "finishUpload", "update", "delete", "usage"],
6
6
  preview: ["enable", "disable"],
7
7
  };
@@ -7,10 +7,14 @@ export type OverlaySelection = {
7
7
  title: string;
8
8
  shared: boolean;
9
9
  locked: boolean;
10
+ /** Rendered by the site's layout, not placed in a zone: no tree gestures (spec 2026-09-13 §2). */
11
+ layout?: boolean;
10
12
  };
11
- export declare function CanvasOverlay({ emptyZones, hoverRect, selection, plusButtons, fieldHover, editing, onPlus, onMove, onDuplicate, onRemove, }: {
13
+ export declare function CanvasOverlay({ emptyZones, hoverRect, hoverShared, selection, plusButtons, fieldHover, editing, onPlus, onMove, onDuplicate, onRemove, }: {
12
14
  emptyZones: ZoneRect[];
13
15
  hoverRect: BridgeRect | undefined;
16
+ /** The hovered block is a shared item: the ring takes the shared colour. */
17
+ hoverShared?: boolean;
14
18
  selection: OverlaySelection | null;
15
19
  plusButtons: PlusButton[];
16
20
  /** The marked field under the pointer, inside the selected section (spec 2026-09-10 §2). */
@@ -7,7 +7,7 @@ import { jsxs as _jsxs, jsx as _jsx, Fragment as _Fragment } from "react/jsx-run
7
7
  // itself never touches the iframe.
8
8
  import { useState } from "react";
9
9
  import { IconButton } from "../ui/primitives.js";
10
- export function CanvasOverlay({ emptyZones, hoverRect, selection, plusButtons, fieldHover, editing, onPlus, onMove, onDuplicate, onRemove, }) {
10
+ export function CanvasOverlay({ emptyZones, hoverRect, hoverShared, selection, plusButtons, fieldHover, editing, onPlus, onMove, onDuplicate, onRemove, }) {
11
11
  const [hoverPlus, setHoverPlus] = useState(null);
12
12
  return (_jsxs("div", { style: { position: "absolute", inset: 0, pointerEvents: "none" }, children: [emptyZones.map((z) => (_jsxs("div", { className: "sm-dashed", style: {
13
13
  position: "absolute", left: z.x + 12, top: z.y, width: Math.max(0, z.w - 24),
@@ -15,17 +15,17 @@ export function CanvasOverlay({ emptyZones, hoverRect, selection, plusButtons, f
15
15
  }, children: [z.zone, " \u00B7 empty"] }, `empty-${z.zone}`))), hoverRect && (_jsx("div", { style: {
16
16
  position: "absolute", left: hoverRect.x, top: hoverRect.y,
17
17
  width: hoverRect.w, height: hoverRect.h,
18
- outline: "1.5px solid color-mix(in srgb, var(--selection) 45%, transparent)",
18
+ outline: `1.5px solid color-mix(in srgb, var(${hoverShared ? "--selection-shared" : "--selection"}) 45%, transparent)`,
19
19
  outlineOffset: -1.5, borderRadius: "var(--selection-radius)",
20
20
  } })), fieldHover && !(editing && editing.node === fieldHover.node && editing.path === fieldHover.path) && (_jsx("div", { className: "sm-fieldhover", style: { position: "absolute", left: fieldHover.x, top: fieldHover.y, width: fieldHover.w, height: fieldHover.h } })), editing && (_jsx("div", { className: "sm-fieldedit", style: { position: "absolute", left: editing.x, top: editing.y, width: editing.w, height: editing.h } })), selection && (_jsxs(_Fragment, { children: [_jsx("div", { style: {
21
21
  position: "absolute", left: selection.rect.x, top: selection.rect.y,
22
22
  width: selection.rect.w, height: selection.rect.h,
23
- outline: "var(--outline-selection)", outlineOffset: -1.5,
23
+ outline: selection.shared ? "var(--outline-selection-shared)" : "var(--outline-selection)", outlineOffset: -1.5,
24
24
  borderRadius: "var(--selection-radius)",
25
- } }), _jsxs("span", { className: "sm-blocktag", style: { position: "absolute", left: selection.rect.x, top: selection.rect.y }, children: [selection.title, selection.shared && " · shared", selection.locked && " · locked"] }), _jsx("div", { className: "sm-blocktoolbar", style: {
25
+ } }), _jsxs("span", { className: selection.shared ? "sm-blocktag sm-blocktag--shared" : "sm-blocktag", style: { position: "absolute", left: selection.rect.x, top: selection.rect.y }, children: [selection.title, selection.shared && " · shared", selection.locked && " · locked"] }), !selection.layout && (_jsx("div", { className: "sm-blocktoolbar", style: {
26
26
  position: "absolute", left: selection.rect.x + selection.rect.w - 8, top: selection.rect.y + 8,
27
27
  transform: "translateX(-100%)", pointerEvents: "auto",
28
- }, children: selection.locked ? (_jsx("span", { className: "sm-blocktoolbar__note", children: "fields only" })) : (_jsxs(_Fragment, { children: [_jsx(IconButton, { label: "Move up", tone: "inverse", onClick: () => onMove(selection.node.id, -1), children: "\u2191" }), _jsx(IconButton, { label: "Move down", tone: "inverse", onClick: () => onMove(selection.node.id, 1), children: "\u2193" }), _jsx(IconButton, { label: "Duplicate", tone: "inverse", onClick: () => onDuplicate(selection.node), children: "\u29C9" }), _jsx(IconButton, { label: "Remove", tone: "inverse", onClick: () => onRemove(selection.node.id), children: "\u00D7" })] })) })] })), plusButtons.map((p) => {
28
+ }, children: selection.locked ? (_jsx("span", { className: "sm-blocktoolbar__note", children: "fields only" })) : (_jsxs(_Fragment, { children: [_jsx(IconButton, { label: "Move up", tone: "inverse", onClick: () => onMove(selection.node.id, -1), children: "\u2191" }), _jsx(IconButton, { label: "Move down", tone: "inverse", onClick: () => onMove(selection.node.id, 1), children: "\u2193" }), _jsx(IconButton, { label: "Duplicate", tone: "inverse", onClick: () => onDuplicate(selection.node), children: "\u29C9" }), _jsx(IconButton, { label: "Remove", tone: "inverse", onClick: () => onRemove(selection.node.id), children: "\u00D7" })] })) }))] })), plusButtons.map((p) => {
29
29
  const key = `${p.zone}-${p.index}`;
30
30
  const hot = hoverPlus === key;
31
31
  return (_jsxs("div", { style: { position: "absolute", left: 0, right: 0, top: p.y }, children: [hot && _jsx("div", { className: "sm-guide", style: { margin: "0 12px" } }), _jsx("div", { style: {
@@ -1,6 +1,8 @@
1
1
  import type { AdminOps } from "../ops.ts";
2
2
  import type { AdminRegistry } from "../serialize.ts";
3
3
  export declare const withEditorParam: (url: string) => string;
4
+ /** The site URL of a stored version — History's iframe (spec 2026-09-13 history §5); no editor parameter, so no bridge. */
5
+ export declare const withVersionParam: (url: string, versionId: string) => string;
4
6
  export declare function EditorView({ ops, registry, id }: {
5
7
  ops: AdminOps;
6
8
  registry: AdminRegistry;