smoodly 0.0.24 → 0.0.26

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 (148) hide show
  1. package/README.md +214 -40
  2. package/bin/smoodly.js +6 -0
  3. package/dist/admin/client-ops.js +1 -0
  4. package/dist/admin/editor/EditorView.js +100 -22
  5. package/dist/admin/editor/InsertPicker.d.ts +14 -0
  6. package/dist/admin/editor/InsertPicker.js +37 -7
  7. package/dist/admin/editor/InspectorFields.js +1 -1
  8. package/dist/admin/editor/Outline.js +2 -2
  9. package/dist/admin/editor/PageSettings.js +1 -1
  10. package/dist/admin/editor/SeoSettings.js +1 -1
  11. package/dist/admin/editor/autosave.d.ts +4 -1
  12. package/dist/admin/editor/autosave.js +24 -1
  13. package/dist/admin/editor/global-edit.d.ts +5 -1
  14. package/dist/admin/editor/global-edit.js +15 -5
  15. package/dist/admin/editor/locale-url.d.ts +7 -4
  16. package/dist/admin/editor/locale-url.js +14 -7
  17. package/dist/admin/editor/useGlobalEdit.d.ts +1 -1
  18. package/dist/admin/editor/useGlobalEdit.js +1 -1
  19. package/dist/admin/editor/usePageLocale.js +9 -1
  20. package/dist/admin/forms/CropEditor.d.ts +4 -2
  21. package/dist/admin/forms/CropEditor.js +6 -3
  22. package/dist/admin/forms/DatePicker.d.ts +9 -0
  23. package/dist/admin/forms/DatePicker.js +133 -0
  24. package/dist/admin/forms/FieldWidget.js +11 -5
  25. package/dist/admin/forms/ImagePreview.d.ts +9 -0
  26. package/dist/admin/forms/ImagePreview.js +11 -0
  27. package/dist/admin/forms/MediaWidget.d.ts +8 -2
  28. package/dist/admin/forms/MediaWidget.js +13 -13
  29. package/dist/admin/forms/RichtextElementCard.js +1 -1
  30. package/dist/admin/forms/RichtextImageCard.js +14 -9
  31. package/dist/admin/forms/date-picker.d.ts +44 -0
  32. package/dist/admin/forms/date-picker.js +117 -0
  33. package/dist/admin/forms/elements-context.d.ts +3 -0
  34. package/dist/admin/forms/elements-context.js +7 -2
  35. package/dist/admin/forms/time-zone-context.d.ts +7 -0
  36. package/dist/admin/forms/time-zone-context.js +15 -0
  37. package/dist/admin/forms/useRichtextEditor.js +10 -1
  38. package/dist/admin/next/bridge.d.ts +2 -1
  39. package/dist/admin/next/bridge.js +35 -5
  40. package/dist/admin/next/create-admin.d.ts +4 -0
  41. package/dist/admin/next/create-admin.js +3 -1
  42. package/dist/admin/next/index.d.ts +1 -0
  43. package/dist/admin/ops-impl.js +74 -24
  44. package/dist/admin/ops.d.ts +31 -12
  45. package/dist/admin/serialize.d.ts +2 -0
  46. package/dist/admin/serialize.js +1 -0
  47. package/dist/admin/shell/AdminApp.d.ts +3 -1
  48. package/dist/admin/shell/AdminApp.js +42 -2
  49. package/dist/admin/shell/EntriesList.js +17 -22
  50. package/dist/admin/shell/EntryForm.js +91 -13
  51. package/dist/admin/shell/GlobalForm.js +68 -9
  52. package/dist/admin/shell/GlobalList.js +10 -20
  53. package/dist/admin/shell/IconRail.js +14 -5
  54. package/dist/admin/shell/InviteModal.js +9 -5
  55. package/dist/admin/shell/ListView.js +2 -2
  56. package/dist/admin/shell/MediaScreen.js +11 -1
  57. package/dist/admin/shell/MembersPage.js +33 -15
  58. package/dist/admin/shell/PageDialogs.d.ts +26 -0
  59. package/dist/admin/shell/PageDialogs.js +38 -0
  60. package/dist/admin/shell/PagesList.js +101 -74
  61. package/dist/admin/shell/RouteLink.js +1 -1
  62. package/dist/admin/shell/SchemaNotice.d.ts +4 -0
  63. package/dist/admin/shell/SchemaNotice.js +4 -0
  64. package/dist/admin/shell/SettingsView.js +11 -26
  65. package/dist/admin/shell/live-url.d.ts +4 -3
  66. package/dist/admin/shell/live-url.js +12 -3
  67. package/dist/admin/shell/locale-memory.d.ts +8 -0
  68. package/dist/admin/shell/locale-memory.js +23 -0
  69. package/dist/admin/shell/members-list.d.ts +2 -0
  70. package/dist/admin/shell/members-list.js +5 -0
  71. package/dist/admin/shell/page-dialogs.d.ts +9 -0
  72. package/dist/admin/shell/page-dialogs.js +19 -0
  73. package/dist/admin/shell/presence-context.d.ts +9 -0
  74. package/dist/admin/shell/presence-context.js +35 -0
  75. package/dist/admin/shell/presence-realtime.d.ts +3 -0
  76. package/dist/admin/shell/presence-realtime.js +22 -0
  77. package/dist/admin/shell/presence.d.ts +51 -0
  78. package/dist/admin/shell/presence.js +90 -0
  79. package/dist/admin/shell/router.d.ts +10 -7
  80. package/dist/admin/shell/router.js +18 -9
  81. package/dist/admin/shell/settings.d.ts +0 -6
  82. package/dist/admin/shell/settings.js +4 -5
  83. package/dist/admin/shell/stale.d.ts +28 -0
  84. package/dist/admin/shell/stale.js +17 -0
  85. package/dist/admin/tree-ops.js +3 -0
  86. package/dist/admin/ui/ActionMenu.js +26 -6
  87. package/dist/admin/ui/Combobox.d.ts +5 -0
  88. package/dist/admin/ui/Combobox.js +1 -1
  89. package/dist/admin/ui/ConfirmDialog.d.ts +8 -0
  90. package/dist/admin/ui/ConfirmDialog.js +30 -0
  91. package/dist/admin/ui/Dialog.d.ts +13 -0
  92. package/dist/admin/ui/Dialog.js +54 -0
  93. package/dist/admin/ui/HistoryModal.d.ts +2 -1
  94. package/dist/admin/ui/HistoryModal.js +7 -2
  95. package/dist/admin/ui/LockIcon.d.ts +4 -0
  96. package/dist/admin/ui/LockIcon.js +12 -0
  97. package/dist/admin/ui/PresenceLine.d.ts +6 -0
  98. package/dist/admin/ui/PresenceLine.js +11 -0
  99. package/dist/admin/ui/ResizeHandle.js +4 -4
  100. package/dist/admin/ui/StaleBanner.d.ts +5 -0
  101. package/dist/admin/ui/StaleBanner.js +9 -0
  102. package/dist/admin/ui/confirm.d.ts +35 -0
  103. package/dist/admin/ui/confirm.js +57 -0
  104. package/dist/admin/ui/primitives.d.ts +19 -4
  105. package/dist/admin/ui/primitives.js +12 -5
  106. package/dist/admin/ui/resize.d.ts +7 -4
  107. package/dist/admin/ui/resize.js +7 -4
  108. package/dist/admin/ui/theme.d.ts +1 -1
  109. package/dist/admin/ui/theme.js +373 -204
  110. package/dist/admin/validate.d.ts +4 -0
  111. package/dist/admin/validate.js +28 -0
  112. package/dist/cli/load-collections.d.ts +2 -0
  113. package/dist/cli/load-collections.js +26 -0
  114. package/dist/cli/main.d.ts +13 -0
  115. package/dist/cli/main.js +62 -0
  116. package/dist/cli/migrate.d.ts +23 -0
  117. package/dist/cli/migrate.js +64 -0
  118. package/dist/config.d.ts +4 -0
  119. package/dist/config.js +9 -0
  120. package/dist/draft-token.d.ts +31 -0
  121. package/dist/draft-token.js +41 -0
  122. package/dist/entry-store.d.ts +11 -8
  123. package/dist/entry-store.js +24 -10
  124. package/dist/env.d.ts +9 -0
  125. package/dist/env.js +19 -0
  126. package/dist/fields.d.ts +2 -0
  127. package/dist/fields.js +2 -0
  128. package/dist/index.d.ts +6 -3
  129. package/dist/index.js +4 -2
  130. package/dist/marks.d.ts +3 -0
  131. package/dist/marks.js +3 -0
  132. package/dist/migrations.d.ts +37 -0
  133. package/dist/migrations.js +73 -0
  134. package/dist/schema-version.d.ts +17 -0
  135. package/dist/schema-version.js +32 -0
  136. package/dist/sql-space.js +17 -0
  137. package/dist/sql.js +21 -0
  138. package/dist/store.d.ts +14 -8
  139. package/dist/store.js +30 -8
  140. package/dist/supabase-entry-store.d.ts +15 -5
  141. package/dist/supabase-entry-store.js +87 -22
  142. package/dist/supabase-store.d.ts +18 -5
  143. package/dist/supabase-store.js +83 -20
  144. package/dist/time-zone.d.ts +6 -0
  145. package/dist/time-zone.js +27 -0
  146. package/migrations/20260926120000_smoodly_v1.sql +535 -0
  147. package/migrations/20260926130000_smoodly_v2.sql +26 -0
  148. package/package.json +13 -3
package/README.md CHANGED
@@ -468,6 +468,15 @@ Implemented:
468
468
  The media and refs walks descend into documents against the element
469
469
  schemas (`elements` beside `sections` in the store, resolve and site
470
470
  options).
471
+ - The token guard and presence (spec 2026-09-26 concurrency): every
472
+ draft write carries `{ versionId, savedAt }` from the locale row's
473
+ `draftSavedAt` and is refused as `conflict` + `stale` when the draft
474
+ moved (the write itself is conditional in Supabase); the admin freezes
475
+ under a notice naming who saved and when and reloads through "Load
476
+ their version"; one private Realtime channel per space shows who edits
477
+ what (a lock in the lists, a line under the top bar) and announces
478
+ saves so a clean view refreshes and a dirty one freezes early. Schema
479
+ version 2 adds the draft-pointer foreign keys and the channel policies.
471
480
 
472
481
  Not yet: canvas drag-and-drop (list fields, the outline, manual
473
482
  collections and the pages tree drag since 2026-09-09 — spec
@@ -495,6 +504,88 @@ after both are on the registry. From the repo root:
495
504
  `prepublishOnly` rebuilds each package. The Claude Code skill
496
505
  `.claude/skills/releasing-smoodly` carries the same order with its checks.
497
506
 
507
+ ## Follow-ups (logged 2026-09-26, concurrency spec)
508
+
509
+ - A hard lock with takeover, if a real team asks: presence has the facts.
510
+ - Presence on the members and media screens; today only pages, entries and
511
+ global items track.
512
+ - Postgres Changes for live list refresh; the lists still load on navigation.
513
+ - Field-level merging of two editors' changes; the guard refuses whole documents.
514
+ - Publish, discard and restore compare the token, then move the pointer under a
515
+ pointer filter: a save that rewrites the same draft row in place between the
516
+ two is published unseen (one round trip wide). A SQL function would close it.
517
+ - Publish leaves the token unchanged (the pointer and its saved time do not move),
518
+ so a publish with a token from before another person's publish of the same
519
+ draft succeeds — the content is the same. Recorded, not a bug.
520
+ - The read client over the read key (DESIGN.md OQ 31) can reuse `publicContent()`.
521
+ - A dropped socket clears presence on Realtime's timeout (tens of seconds); the
522
+ lock outlives a closed tab by that much.
523
+ - The live suites wipe `members` and the content tables; a pane walk after
524
+ them needs `npm run reseed` (or `scripts/demo-editor.ts`) first.
525
+ - `e2e/helpers.ts`'s `expectSaved` passes at once when the indicator still
526
+ reads "Saved" from before the edit (the debounce is 800 ms), so a step that
527
+ needs the save landed can race it; `concurrency.spec.ts` waits for the
528
+ save's server response instead. Fold that into the helper for every spec.
529
+
530
+ ## Follow-ups (logged 2026-09-26, browser suite)
531
+
532
+ - Drag and drop has no browser test (spec §4): the outline, the pages
533
+ tree and list rows reorder by real pointer drags; the first plan that
534
+ touches `useSortable` adds them.
535
+ - The suite runs Chromium only; the admin's WebKit and Firefox
536
+ behaviour is unverified.
537
+ - Three `window.confirm` calls remain outside the confirm dialog (the
538
+ router's leave guard, `ui/confirm.ts`'s fallback) and are not exercised.
539
+ - The media test uploads a starter photo under a suffixed name rather
540
+ than a fixture file, so `e2e/fixtures/` from the spec does not exist.
541
+ - ListView rows that are not clickable (Members) carry no role; the
542
+ members test finds its row by `.sm-table__body > div`. A table role
543
+ set (`table`/`row`/`cell`) on ListView would give every list a proper
544
+ locator.
545
+ - Publishing a global the site layout reads (the footer) serves the old
546
+ layout on the first public request made right after the publish returns;
547
+ every request after it, and a first request made ~3 s later, is fresh.
548
+ Pages do not show it (an unpublish answers 404 at once). Seen on
549
+ `next dev`; `e2e/globals.spec.ts` polls the home page for it. Find out
550
+ whether `updateTag` over a layout's `getGlobal` read expires late, and
551
+ whether `next start` behaves the same.
552
+ - Fixed in the same session: an inserted section seeded an unsampled
553
+ optional object as `{}`, which publish then validated as present — an
554
+ untouched Image and text `link` refused the publish. The insert now
555
+ leaves it absent and validation reads a blank optional object as absent.
556
+
557
+ ## Follow-ups (logged 2026-09-26, schema migrations spec)
558
+
559
+ - Existing databases (every install so far is ours; spec 2026-09-26 §7):
560
+ a database created before version 1 has the schema but not the table.
561
+ One time, in the SQL editor or psql:
562
+ `create table if not exists "smoodly_meta" ("key" text primary key, "value" text not null);`
563
+ `alter table "smoodly_meta" enable row level security;`
564
+ `insert into "smoodly_meta" ("key", "value") values ('schema_version', '1') on conflict ("key") do update set "value" = excluded."value";`
565
+ then re-apply the `smoodly_read` policy on `smoodly_meta` from
566
+ `migrations/20260926120000_smoodly_v1.sql` (a `drop … if exists` /
567
+ `create policy` pair). The hand-applied statement lists below stay as
568
+ history; from version 2 on, every change is a file.
569
+ - Drafting version N+1: `npx tsx scripts/emit-migration.ts` prints the
570
+ whole schema plus the upsert; keep only the delta, name the file with
571
+ the current UTC stamp and `_smoodly_v<N+1>.sql`, bump `SCHEMA_VERSION`,
572
+ run `npm run db:migrate` at the root so the repo copy follows, and
573
+ `npm run check:migrations`. Additive only; a contraction waits for the
574
+ compatibility window and carries `-- smoodly:contraction`.
575
+ - The views of a removed collection stay (spec 2026-09-26 §3); a
576
+ `drop view` is the developer's.
577
+ - `smoodly migrate` never pushes; a `--push` flag if the two-step
578
+ proves annoying.
579
+ - The CLI loads `smoodly/collections.ts` through tsx; a project that
580
+ registers collections elsewhere passes `--collections <file>`.
581
+ - The site's one warning fires from the Supabase page store's first
582
+ `resolvePath`, once per store instance; a site that never locates (a
583
+ pure `q` reader) never warns, and a long-running server that was
584
+ current when it booted stays quiet — the admin notice is the real
585
+ signal.
586
+ - A Deploying guide (nav lists it, unwritten) should show
587
+ `npx smoodly migrate --check` as a build step.
588
+
498
589
  ## Follow-ups (logged 2026-09-25, docs session)
499
590
 
500
591
  - `f.slug({ from })` stores `from` on the descriptor, but nothing reads it:
@@ -655,9 +746,15 @@ tool — reset and reseed a development database (`supabase db reset`,
655
746
  row; invites from the cloud's `/admin` (they are smoodly.io's); a
656
747
  project whose Auth is OAuth-only (the invite mail asks for a password);
657
748
  redirects on a published rename (the confirm says "there are none
658
- yet"). The API Keys page's contents are DESIGN.md OQ #33.
749
+ yet").
750
+ - 2026-09-26: the Role column is a badge and the row menu has Change role
751
+ (confirm, to the other role) and Remove member; `ActionMenu` popovers are
752
+ fixed-positioned from the button — the cell's overflow clipped them.
659
753
  - The members list is not paged (tens of rows, not thousands).
660
- - The `api-keys` page id is a URL segment; the title reads "API Keys".
754
+ - The Roles and API Keys pages were dropped 2026-09-25 (DESIGN.md §2
755
+ "Settings"): Members is the only Settings page, the invite modal
756
+ describes the two roles, and `/settings/roles` and `/settings/api-keys`
757
+ are Not found.
661
758
 
662
759
  ## Follow-ups (logged 2026-09-13, history spec)
663
760
 
@@ -812,33 +909,109 @@ tool — reset and reseed a development database (`supabase db reset`,
812
909
  media projection puts the image on Media; Escape after a card's button
813
910
  had taken the focus did not end the session.
814
911
 
815
- ## Follow-ups (logged 2026-09-08, admin restyle)
816
-
817
- - Remove the temporary `technical` look once the warm restyle is decided:
818
- the `TECHNICAL_*` strings (frozen tokens plus the `_SHAPE`/`_EXTRA`
819
- additions), the second `cascade` call and the boot script's
820
- `smoodly-look` branch in `src/admin/ui/theme.tsx`. The original look
821
- stays at tag `look-technical`; restore the file from there if the
822
- restyle is dropped instead.
823
- - The technical block is a token snapshot, not a pixel restore: shared
824
- class rules moved with the warm look, so under `data-look="technical"`
825
- the inspector is flush cards with a hairline instead of one column with
826
- a sticky head, tabs are stretched, inputs have a 1px (not 1.5px)
827
- border, rail tiles are 42×34, and a few weights and gaps drifted. Exact
828
- comparison is the tag.
829
- - Signed-in walk of the warm look still pending: the sign-in screen was
830
- checked in the browser pane (light and dark), the list, entry form and
831
- visual editor were not — the magic-link sign-in carries a token in the
832
- URL, which the pane's policy blocks. Walk them, then tune: inspector
833
- width (320px here, the handoff says 296px; the field widgets need the
834
- room), the canvas meta bar (the handoff has none; ours holds the locale
835
- switcher), and the block-toolbar note.
912
+ ## Follow-ups (logged 2026-09-25, neutral restyle + confirm dialog)
913
+
914
+ - The warm look is the comparison block now (`WARM_*` + `WARM_EXTRA` in
915
+ `src/admin/ui/theme.tsx`, `localStorage.smoodly-look = "warm"`); the
916
+ technical block is deleted (tag `look-technical` keeps it). Remove the
917
+ warm block once the neutral look is judged, the same way.
918
+ - Kept from the app on purpose, against the handoff: the 14px base and
919
+ 18px titles (handoff 13/20/22), the 64px top bar (56), the 360px
920
+ inspector (296), the Lucide rail icons, the `--table-top` air above the
921
+ list, the 12px block-tag padding. Inputs stay white on focus (the
922
+ handoff's focused textarea takes the tile grey) and the locked outline
923
+ row is a white row like the rest (the handoff's is the ground, invisible
924
+ on a tile of the same grey).
925
+ - The handoff's status chips collapse Draft into amber; the app keeps its
926
+ three states (Published green, Unpublished changes amber, Draft grey on
927
+ `--control-hover`).
928
+ - The Pages list's prompts are dialogs on the same card since the same
929
+ day (`shell/PageDialogs.tsx` over `ui/Dialog.tsx`): New page / New
930
+ path / New subpage / New subpath (title, slug following the title
931
+ through `slugify` in `shell/page-dialogs.ts`, page type when more than
932
+ one open template), Add page here (page type; skipped with one), Rename
933
+ (title and slug; the home page's title alone). Still native: the
934
+ `window.alert`s on a refused list op outside a dialog (`run` in
935
+ PagesList, EntriesList's add/remove) — a notice banner is the follow-up.
936
+ - `slugify` is the admin's first slugifier (title → segment); the slug
937
+ field widget (`f.slug`) does not use it yet, and neither does the entry
938
+ create-on-click. OQ 24 (unicode slugs) would relax its grammar.
939
+ - The dialog's fields take the tile tone on the white card
940
+ (`.sm-dialog__form .sm-input`), the reverse of fields on tiles.
941
+ - The confirm's copy lives at each call site (`title` / `body` /
942
+ `confirmLabel` / `subject`); the shared ones are `DISCARD_CONFIRM`
943
+ (`ui/confirm.ts`), `LEAVE_CONFIRM` (router) and `LIVE_URL_CONFIRM`
944
+ (live-url). A subject path is shown where the screen already holds it;
945
+ the entries list and the media screen show the name only.
946
+ - `confirm()` without a mounted `<ConfirmHost/>` falls back to
947
+ `window.confirm` so nothing can hang; the host is mounted once in
948
+ `AdminApp` beside the shell. The tab-close guard stays native
949
+ (`beforeunload` cannot await).
836
950
  - The handoff's `Styles` tab in the inspector is not built: it exists
837
951
  only where a section registers styles, which is the existing `tabsFor`.
838
- - Wordmark and app icon colour: the wordmark now takes `--logo-color`
839
- (accent in the warm look); the rail's app icon is hidden in the warm
840
- look and still brand blue in the technical one. A warm app icon is a
841
- design question.
952
+ - The wordmark still takes `--logo-color` (accent); the rail's app icon
953
+ takes `--logo-outer` / `--logo-inner` and is hidden in the warm look.
954
+ - Polish round the same evening (2026-09-25): list row rules are a
955
+ `.sm-row::after` pseudo-element, since a border follows the rounded
956
+ hover corners; the admin remembers ONE locale across lists and forms
957
+ (`shell/locale-memory.ts`, key `smoodly-locale`; the lists' two
958
+ session-only keys are gone, `localeFromUrl` falls back to it and
959
+ remembers what it resolved); the section insert picker opens upward
960
+ when the room below is short and scrolls inside the room it has
961
+ (`placePicker` in `editor/InsertPicker.tsx`, measured in a layout
962
+ effect, no window-resize listener); the prefixed slug input keeps a
963
+ 22px line box so its caret reads as one; `.sm-input--mono` is scoped
964
+ under `.sm-admin` — the `input{font-family:inherit}` reset had beaten
965
+ it, so every mono input (slugs, IDs) had rendered in the sans font;
966
+ richtext block-node cards take `--rtcard-bg` / `--rtcard-border` (tile
967
+ grey with the soft accent border in the neutral look, white and
968
+ hairlined in the warm). The custom section's row cards and the list
969
+ field's rows keep the white-on-tile look — only the richtext cards sat
970
+ on a white field.
971
+ - Richtext cards, second pass (2026-09-25): the default card border is
972
+ the hairline (`--rtcard-border:var(--line)`), the accent outline marks
973
+ the selected card alone, and the widget's focus ring steps back while a
974
+ block node is selected (`:focus:not(:has(.ProseMirror-selectednode))`).
975
+ The image card's head carries Edit / Replace / Remove as
976
+ `.sm-field__action` text buttons, the same three the media field now
977
+ shows in its field head (`MediaWidget` renders its own `Field`;
978
+ `FieldWidget` returns it directly, `SeoSettings` passes the label). Edit
979
+ on a richtext image is the crop editor over the node's `image` attr
980
+ (an `ImageValue`, so crop/hotspot/aspect store there). The bound
981
+ `smoodly/richtext` renderer applies it through `<SmoodlyImage>`; the
982
+ root `RichText`'s plain figure does NOT (it may not import the image
983
+ layer, `richtext-entry.test.ts`), so a framework-neutral renderer
984
+ (the Astro adapter, OQ 29) must frame it with `frameFor` itself. The
985
+ card shows the cropped frame (`forms/ImagePreview.tsx`, shared with the
986
+ media field), the filename and the caption as text; alt and caption are
987
+ edited in the crop modal (`CropEditor` takes `caption`), so the card
988
+ holds no inputs. Fields on a modal panel take the tile tone.
989
+ - A refused publish's per-field errors clear as the fields are fixed
990
+ (2026-09-25): the entry and global forms revalidate on every change with
991
+ the op's own rules (`validateFields` / `validateNode` over
992
+ `elementSchemas(registry.elements)`) and keep only what still fails
993
+ (`stillFailing` in `validate.ts`); the notice's list shrinks and the
994
+ notice goes when it empties. Typing never adds an error — only a
995
+ publish does. The page editor's banner list and the canvas global
996
+ inspector (save errors) are unchanged.
997
+
998
+ ## Follow-ups (logged 2026-09-25, date pickers)
999
+
1000
+ - `f.date()` and `f.datetime()` share one in-house calendar
1001
+ (`admin/forms/DatePicker.tsx` over the pure `date-picker.ts`); the
1002
+ browsers' own pickers were rejected (they differ and take no tokens).
1003
+ Weeks start on Monday and month names come from the browser's language
1004
+ — `weekStart` and a month-name locale as config keys are follow-ups.
1005
+ - `defineConfig({ timeZone })` is validated by constructing an
1006
+ `Intl.DateTimeFormat`, so an alias the runtime's database still knows
1007
+ (`Europe/Kiev`) passes and an offset never does. Admin-owned site
1008
+ settings (a per-space `settings` table with a Site page) were designed
1009
+ and dropped for the zone; the design is in the spec's history should a
1010
+ setting turn up that editors must change without a deploy.
1011
+ - Not built: a time-only field, a date range, `.min` / `.max` on dates.
1012
+ - Publish refuses a malformed date or datetime now (`validate.ts`); a
1013
+ row saved before this with a bad string shows the message on its next
1014
+ publish, as designed.
842
1015
 
843
1016
  ## Follow-ups (logged 2026-09-08, block nodes spec)
844
1017
 
@@ -1031,7 +1204,8 @@ the toolbar instead of pushing the content.
1031
1204
  - ~~Drag-to-resize the inspector~~ — DONE 2026-09-10 (spec
1032
1205
  `docs/superpowers/specs/2026-09-10-inspector-resize-design.md`):
1033
1206
  `ui/ResizeHandle.tsx` writes `--inspector-w` inline on the wrapper,
1034
- clamped to 260px..half the row (`ui/resize.ts`), remembered under
1207
+ clamped to 260px..the row less the handle and a 440px canvas (`ui/resize.ts`,
1208
+ half the row until 2026-09-26), remembered under
1035
1209
  `smoodly-inspector-w` and applied by the theme's boot script. Walk
1036
1210
  finding: the desktop pane hides its tab on every `navigate`, and a
1037
1211
  hidden tab lays out at zero width, so the handle treats a zero
@@ -1363,10 +1537,10 @@ the toolbar instead of pushing the content.
1363
1537
  `page:<id>:<locale>` tag would halve the revalidations on a bilingual
1364
1538
  site.
1365
1539
  - The Pages list's confirms (`Add <locale>`, `Remove from <locale>`,
1366
- `Delete … in every language`) are `window.confirm`, and `EntriesList`
1367
- reports a refused add or remove with `window.alert`; the design
1368
- system's dialog is the follow-up for both, together with the row menu
1369
- extraction already logged above.
1540
+ `Delete …`) are the design system's dialog since 2026-09-25;
1541
+ `EntriesList` still reports a refused add or remove with
1542
+ `window.alert` — the follow-up, together with the row menu extraction
1543
+ already logged above.
1370
1544
  - `EditorView.tsx` is ~850 lines now that it also holds the locale switch,
1371
1545
  add and remove flows — see the 2026-09-03 follow-up below for the seams
1372
1546
  (that entry is the one to act on).
@@ -1479,13 +1653,13 @@ the toolbar instead of pushing the content.
1479
1653
  those values in `entry_locales.fields` until the next save of each
1480
1654
  locale (a save writes only the current split) — the drift/codemod
1481
1655
  pipeline (Phase 2) is where "keep en, discard fi" belongs.
1482
- - The repo-root migration (`supabase/migrations/00000000000000_smoodly.sql`)
1483
- is generated by `packages/smoodly/scripts/emit-sql.ts` from that
1484
- script's OWN fixture collections, not from
1485
- `examples/site/smoodly/collections.ts`; the create-smoodly-app template
1486
- migration IS generated from the example. Since 2026-09-14 both emit the
1487
- same view shape for every collection, but the repo-root fixture's field
1488
- lists still drift from the example's — the two should share one source.
1656
+ - Resolved 2026-09-26 (schema migrations spec): the repo-root
1657
+ `supabase/migrations/` is the package's core files plus a views file
1658
+ that `npm run db:migrate` writes from
1659
+ `examples/site/smoodly/collections.ts` — the same source the
1660
+ create-smoodly-app templates use; `scripts/emit-sql.ts` and its own
1661
+ fixture collections are gone (`scripts/emit-migration.ts` drafts the
1662
+ next core file).
1489
1663
  - In the entry form, a shared ref field (e.g. an article's author) shows
1490
1664
  `—` in a locale where the target is absent, because the picker offers
1491
1665
  only targets present in the edited locale; the value is kept on the
@@ -1685,5 +1859,5 @@ the toolbar instead of pushing the content.
1685
1859
  - Editor canvas URL mapping: now `homePageSlug` config (done 2026-09-03); remaining follow-up is a registration-declared `path` for fixed pages at arbitrary paths.
1686
1860
  - `serialize.ts` structural cast duplicates `PageSchema`; tighten `Registered.schema` so the cast goes.
1687
1861
  - Catch-all `templates` map should filter to open registrations to state its intent (unreachable today).
1688
- - 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.
1862
+ - 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.
1689
1863
  - `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)
package/bin/smoodly.js ADDED
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env node
2
+ // `npx smoodly …` — resolves to the app's installed package, so the files
3
+ // it copies belong to the version the app runs (spec 2026-09-26 §2).
4
+ import { main } from "../dist/cli/main.js";
5
+
6
+ process.exitCode = await main(process.argv.slice(2));
@@ -4,6 +4,7 @@ const GROUPS = {
4
4
  globals: ["list", "get", "save", "setStatus", "discardDraft", "listVersions", "getVersion", "restore"],
5
5
  assets: ["list", "get", "beginUpload", "finishUpload", "update", "delete", "usage"],
6
6
  members: ["list", "me", "invite", "setRole", "remove"],
7
+ schema: ["status"],
7
8
  preview: ["enable", "disable"],
8
9
  };
9
10
  export function makeClientOps(action, token) {
@@ -27,6 +27,7 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
27
27
  //
28
28
  // Core code: no next/* imports live here — the bridge is the only piece
29
29
  // that knows about Next, and it ships to the site, not the admin.
30
+ import { Eye } from "lucide-react";
30
31
  import { useCallback, useEffect, useMemo, useRef, useState } from "react";
31
32
  import { CSS_KEY, GLOBAL_NODE_TYPE, SETTINGS_KEY, STYLES_KEY, globalNameOf } from "../../global.js";
32
33
  import { descriptorAt, getAtPath } from "../field-paths.js";
@@ -57,7 +58,14 @@ import { isFixedNode, localeStatusOf, sourceLocaleOf } from "../shell/pages-tree
57
58
  import { LocaleSwitcher } from "../ui/LocaleSwitcher.js";
58
59
  import { ResizeHandle } from "../ui/ResizeHandle.js";
59
60
  import { PublishControl } from "../ui/PublishControl.js";
61
+ import { confirm, DISCARD_CONFIRM } from "../ui/confirm.js";
60
62
  import { HistoryModal, historyRows } from "../ui/HistoryModal.js";
63
+ import { draftToken } from "../../draft-token.js";
64
+ import { isStale, staleInfoOf } from "../shell/stale.js";
65
+ import { StaleBanner } from "../ui/StaleBanner.js";
66
+ import { usePresence, usePresenceState, useSavedElsewhere, useTrackPresence } from "../shell/presence-context.js";
67
+ import { editorsOf } from "../shell/presence.js";
68
+ import { PresenceLine } from "../ui/PresenceLine.js";
61
69
  import { Banner as BannerBox, Breadcrumb, Button, FieldErrorList, IconButton, LinkButton, SaveIndicator, Tabs, TopBar, } from "../ui/primitives.js";
62
70
  export const withEditorParam = (url) => (url.includes("?") ? `${url}&smoodly-editor=1` : `${url}?smoodly-editor=1`);
63
71
  /** The site URL of a stored version — History's iframe (spec 2026-09-13 history §5); no editor parameter, so no bridge. */
@@ -65,6 +73,10 @@ export const withVersionParam = (url, versionId) => `${url}${url.includes("?") ?
65
73
  export function EditorView({ ops, registry, id }) {
66
74
  const adminHref = useAdminHref();
67
75
  const [record, setRecord] = useState(null);
76
+ const recordRef = useRef(record);
77
+ recordRef.current = record;
78
+ /** The guard's refusal (spec 2026-09-26 concurrency §4): the view freezes under the banner until Load their version. */
79
+ const [stale, setStale] = useState(null);
68
80
  const [tree, setTree] = useState(null);
69
81
  const [loadError, setLoadError] = useState(null);
70
82
  const [ready, setReady] = useState(false);
@@ -75,6 +87,8 @@ export function EditorView({ ops, registry, id }) {
75
87
  // The locales the parent exists in — "Add locale" offers only these.
76
88
  const [parentLocales, setParentLocales] = useState([]);
77
89
  const [selectedId, setSelectedId] = useState(null);
90
+ /** A reload after someone else's save (spec 2026-09-26 concurrency §5) keeps what is selected; every other reload clears it. */
91
+ const keepSelectionRef = useRef(false);
78
92
  const [hoverId, setHoverId] = useState(null);
79
93
  const [hoverField, setHoverField] = useState(null);
80
94
  const [revealReq, setRevealReq] = useState(null);
@@ -107,22 +121,29 @@ export function EditorView({ ops, registry, id }) {
107
121
  registryRef.current = registry;
108
122
  // The tree in memory belongs to the locale it was loaded for; a switch
109
123
  // flushes first (switchLocale), so the ref and the tree always agree.
110
- const treeLocaleRef = useRef(localeFromUrl(registry));
124
+ // (Read once: localeFromUrl also remembers the locale.)
125
+ const [openedLocale] = useState(() => localeFromUrl(registry));
126
+ const treeLocaleRef = useRef(openedLocale);
127
+ /** The token of the row the tree belongs to — what every guarded op sends (spec 2026-09-26 concurrency §1). */
128
+ const token = () => { const row = recordRef.current?.locales[treeLocaleRef.current]; return row ? draftToken(row) : null; };
111
129
  // Every edit marks the draft dirty; a debounced saveDraft persists, with
112
130
  // retry on failure, and the bridge refreshes the iframe on the save ack —
113
131
  // now, or once an inline session ends (spec 2026-09-10 §7).
114
- const { saveState, mark, flush } = useDraftAutosave({
132
+ const { saveState, mark, flush, reset } = useDraftAutosave({
115
133
  read: () => treeRef.current,
116
134
  persist: async (t) => {
117
135
  const target = treeLocaleRef.current;
118
- const r = await ops.pages.saveDraft(id, target, t);
136
+ const r = await ops.pages.saveDraft(id, target, t, token());
119
137
  if (r.ok) {
120
- // The badge and Publish follow each save without a re-get: saveDraft answers with the version id.
138
+ // The badge and Publish follow each save without a re-get: saveDraft answers with the version id and the new token.
121
139
  setRecord((rec) => rec?.locales[target]
122
- ? { ...rec, locales: { ...rec.locales, [target]: { ...rec.locales[target], draftVersionId: r.data.versionId } } }
140
+ ? { ...rec, locales: { ...rec.locales, [target]: { ...rec.locales[target], draftVersionId: r.data.versionId, draftSavedAt: r.data.draft?.savedAt ?? null } } }
123
141
  : rec);
124
142
  setDirty(false);
143
+ presence?.announce(pageTarget(target), r.data.draft?.savedAt ?? null);
125
144
  }
145
+ else if (isStale(r))
146
+ setStale(staleInfoOf(r));
126
147
  return r;
127
148
  },
128
149
  onSaved: () => inlineRef.current?.onSaved(),
@@ -130,7 +151,7 @@ export function EditorView({ ops, registry, id }) {
130
151
  // Edits to a global block go to the global entry through their own
131
152
  // autosave (spec 2026-09-13 §4); the save ack refreshes the canvas
132
153
  // through the same inline-session deferral as the page's.
133
- const globalEdit = useGlobalEdit({ ops, onSaved: () => { setGlobalDirty(false); inlineRef.current?.onSaved(); } });
154
+ const globalEdit = useGlobalEdit({ ops, onSaved: (name) => { setGlobalDirty(false); inlineRef.current?.onSaved(); presence?.announce({ kind: "global", id: name, locale }, null); } });
134
155
  const patchGlobal = (name, path, value) => {
135
156
  globalEdit.patch(name, path, value);
136
157
  setGlobalDirty(true);
@@ -144,6 +165,12 @@ export function EditorView({ ops, registry, id }) {
144
165
  clearBanner: () => setBanner(null),
145
166
  reload: () => setReloadKey((k) => k + 1),
146
167
  });
168
+ // Presence (spec 2026-09-26 concurrency §5): tracked once the record exists, the others on it, every ack announced.
169
+ const target = record ? { kind: "page", id, locale } : null;
170
+ useTrackPresence(target);
171
+ const { others } = usePresenceState();
172
+ const presence = usePresence();
173
+ const pageTarget = (forLocale) => ({ kind: "page", id, locale: forLocale });
147
174
  // The URL that serves this page, per locale, from the store's paths
148
175
  // index — the label is the CMS path, the canvas and "View live" take
149
176
  // the site URL (href applied server-side by pages.get).
@@ -173,11 +200,18 @@ export function EditorView({ ops, registry, id }) {
173
200
  setReady(false);
174
201
  setTree(null);
175
202
  treeRef.current = null;
176
- setSelectedId(null);
177
- setHoverId(null);
203
+ const keepSelection = keepSelectionRef.current;
204
+ keepSelectionRef.current = false;
205
+ if (!keepSelection) {
206
+ setSelectedId(null);
207
+ setHoverId(null);
208
+ }
178
209
  setFolder(false);
179
210
  globalEdit.reset(); // a new locale or a reload is a new canvas; the switch flushed first
211
+ if (keepSelection && selectedItemName)
212
+ void globalEdit.load(selectedItemName, locale); // the kept selection is a global item: load it again
180
213
  setDirty(false);
214
+ setStale(null);
181
215
  setTouched(false);
182
216
  setGlobalDirty(false);
183
217
  setGlobalTouched(false);
@@ -346,6 +380,24 @@ export function EditorView({ ops, registry, id }) {
346
380
  if (globalEdit.loadError)
347
381
  setBanner({ text: globalEdit.loadError });
348
382
  }, [globalEdit.loadError]);
383
+ // A global item's autosave refused by the guard freezes the whole editor too (§4).
384
+ useEffect(() => {
385
+ if (globalEdit.stale)
386
+ setStale(globalEdit.stale);
387
+ }, [globalEdit.stale]);
388
+ /** Load their version (§4): drop the frozen state; the load effect resets the tree, the globals and the flags. */
389
+ const loadTheirs = () => { setStale(null); reset(); setReloadKey((k) => k + 1); };
390
+ // Someone else saved this page (§5): clean → their version silently; unsaved edits anywhere → the freeze at once.
391
+ useSavedElsewhere(target, (event) => {
392
+ if (stale)
393
+ return;
394
+ if (dirty || globalDirty || saveState !== "saved" || globalEdit.saveState !== "saved")
395
+ setStale({ by: event.email, at: Date.now() });
396
+ else {
397
+ keepSelectionRef.current = true;
398
+ setReloadKey((k) => k + 1);
399
+ }
400
+ });
349
401
  // Selecting from the outline must reveal the block. The canvas column is
350
402
  // the scroller now (the iframe has no scrollbar of its own — it is sized to
351
403
  // the full document), so scroll it to the rect the bridge reported.
@@ -499,12 +551,19 @@ export function EditorView({ ops, registry, id }) {
499
551
  mutate((t) => insertNode(t, zone, index, copy));
500
552
  select(copy.id);
501
553
  };
502
- const doRemove = (id, confirm = true) => {
554
+ const doRemove = async (id, ask = true) => {
503
555
  if (!tree)
504
556
  return;
505
557
  if (policyOf(zoneOf(tree, id))?.kind === "locked")
506
558
  return;
507
- if (confirm && !window.confirm("Delete this section?"))
559
+ const node = findNode(tree, id);
560
+ if (ask && !(await confirm({
561
+ title: "Delete this section?",
562
+ body: "It is removed from the page draft. Nothing changes on the live site until you publish.",
563
+ confirmLabel: "Delete section",
564
+ destructive: true,
565
+ subject: node ? { name: labelOf(node) } : undefined,
566
+ })))
508
567
  return;
509
568
  mutate((t) => removeNode(t, id));
510
569
  setSelectedId((current) => (current === id ? null : current));
@@ -530,7 +589,7 @@ export function EditorView({ ops, registry, id }) {
530
589
  }
531
590
  let result;
532
591
  try {
533
- result = await ops.pages.publish(id, locale);
592
+ result = await ops.pages.publish(id, locale, token());
534
593
  }
535
594
  catch (e) {
536
595
  console.error(e);
@@ -541,16 +600,21 @@ export function EditorView({ ops, registry, id }) {
541
600
  setPageBusy(null);
542
601
  if (result.ok) {
543
602
  setRecord(result.data);
603
+ presence?.announce(pageTarget(locale), result.data.locales[locale]?.draftSavedAt ?? null);
544
604
  setPublished(true);
545
605
  setTimeout(() => setPublished(false), 1600);
546
606
  }
607
+ else if (isStale(result)) {
608
+ setStale(staleInfoOf(result));
609
+ }
547
610
  else {
548
611
  setBanner({ text: result.message, fields: result.fields });
549
612
  }
550
613
  };
551
614
  /** Discard (spec 2026-09-13 publish flow §4): confirm, flush so a late save cannot re-dirty the row, restore, reload the canvas. */
552
615
  const doDiscard = async () => {
553
- if (!window.confirm("Discard your changes and restore the published version?"))
616
+ const row = record?.locales[locale];
617
+ if (!(await confirm({ ...DISCARD_CONFIRM, subject: row ? { name: row.title, path } : undefined })))
554
618
  return;
555
619
  setBanner(null);
556
620
  setPageBusy("discard");
@@ -561,17 +625,21 @@ export function EditorView({ ops, registry, id }) {
561
625
  }
562
626
  let result;
563
627
  try {
564
- result = await ops.pages.discardDraft(id, locale);
628
+ result = await ops.pages.discardDraft(id, locale, token());
565
629
  }
566
630
  catch {
567
631
  result = { ok: false, code: "internal", message: "Couldn't reach the server." };
568
632
  }
569
633
  setPageBusy(null);
570
634
  if (!result.ok) {
571
- setBanner({ text: result.message });
635
+ if (isStale(result))
636
+ setStale(staleInfoOf(result));
637
+ else
638
+ setBanner({ text: result.message });
572
639
  return;
573
640
  }
574
641
  setRecord(result.data);
642
+ presence?.announce(pageTarget(locale), result.data.locales[locale]?.draftSavedAt ?? null);
575
643
  setReloadKey((k) => k + 1); // the tree and the canvas come back from the store
576
644
  };
577
645
  /** Unpublish (§5): the locale goes off the site; the draft stays for a later Publish. */
@@ -619,16 +687,20 @@ export function EditorView({ ops, registry, id }) {
619
687
  const doRestore = async (versionId) => {
620
688
  let result;
621
689
  try {
622
- result = await ops.pages.restore(id, locale, versionId);
690
+ result = await ops.pages.restore(id, locale, versionId, token());
623
691
  }
624
692
  catch {
625
693
  result = { ok: false, code: "internal", message: "Couldn't reach the server." };
626
694
  }
627
695
  if (!result.ok) {
628
- setBanner({ text: result.message });
696
+ if (isStale(result))
697
+ setStale(staleInfoOf(result));
698
+ else
699
+ setBanner({ text: result.message });
629
700
  return false;
630
701
  }
631
702
  setRecord(result.data);
703
+ presence?.announce(pageTarget(locale), result.data.locales[locale]?.draftSavedAt ?? null);
632
704
  setReloadKey((k) => k + 1);
633
705
  return true;
634
706
  };
@@ -638,8 +710,8 @@ export function EditorView({ ops, registry, id }) {
638
710
  setGlobalBusy("publish");
639
711
  const r = await globalEdit.setStatus(name, "published");
640
712
  setGlobalBusy(null);
641
- if (!r.ok)
642
- setBanner({ text: r.message, fields: r.fields });
713
+ if (!r.ok && !isStale(r))
714
+ setBanner({ text: r.message, fields: r.fields }); // a stale refusal arrives through globalEdit.stale
643
715
  };
644
716
  const doUnpublishGlobal = async (name) => {
645
717
  setBanner(null);
@@ -650,7 +722,8 @@ export function EditorView({ ops, registry, id }) {
650
722
  setBanner({ text: r.message, fields: r.fields });
651
723
  };
652
724
  const doDiscardGlobal = async (name) => {
653
- if (!window.confirm("Discard your changes and restore the published version?"))
725
+ const item = registry.globals.find((g) => g.name === name);
726
+ if (!(await confirm({ ...DISCARD_CONFIRM, subject: item ? { name: `${item.title} · global` } : undefined })))
654
727
  return;
655
728
  setBanner(null);
656
729
  setGlobalBusy("discard");
@@ -660,15 +733,19 @@ export function EditorView({ ops, registry, id }) {
660
733
  return;
661
734
  }
662
735
  let r;
736
+ const itemRow = globalEdit.items[name]?.record.locales[locale];
663
737
  try {
664
- r = await ops.globals.discardDraft(name, locale);
738
+ r = await ops.globals.discardDraft(name, locale, itemRow ? draftToken(itemRow) : null);
665
739
  }
666
740
  catch {
667
741
  r = { ok: false, code: "internal", message: "Couldn't reach the server." };
668
742
  }
669
743
  setGlobalBusy(null);
670
744
  if (!r.ok) {
671
- setBanner({ text: r.message });
745
+ if (isStale(r))
746
+ setStale(staleInfoOf(r));
747
+ else
748
+ setBanner({ text: r.message });
672
749
  return;
673
750
  }
674
751
  // The item reloads from the store; the canvas refreshes through the same path as a save ack.
@@ -752,7 +829,8 @@ export function EditorView({ ops, registry, id }) {
752
829
  trailing: registry.locales.supported.length > 1 && (_jsx(LocaleSwitcher, { registry: registry, present: record ? registry.locales.supported.filter((l) => record.locales[l]) : [locale], parentLocales: parentLocales, value: locale, adding: adding, onSwitch: (l) => { void switchLocale(l); }, onAdd: (l) => { void doAddLocale(l); } })),
753
830
  },
754
831
  { label },
755
- ] }) }), right: _jsxs(_Fragment, { children: [(touched || saveState === "error") && _jsx(SaveIndicator, { state: saveState }), ready && (_jsx(LinkButton, { href: siteUrl, target: "_blank", rel: "noreferrer", title: "Opens the site route. While the admin's draft-mode cookie is on, this shows the DRAFT \u2014 open it in a private window to see what visitors see.", children: "View live" })), _jsx(PublishControl, { status: record?.locales[locale] ? localeStatusOf(record.locales[locale]) : null, pending: dirty || saveState !== "saved", busy: pageBusy, flash: published, onPublish: () => { void doPublish(); }, onDiscard: () => { void doDiscard(); }, onUnpublish: () => { void doUnpublish(); }, onHistory: () => { void doHistory(); } })] }) }), history && record?.locales[locale] && (_jsx(HistoryModal, { rows: history, draftId: record.locales[locale].draftVersionId, onClose: () => setHistory(null), onRestore: doRestore, render: (versionId) => _jsx("iframe", { className: "sm-history__frame", title: "Version preview", src: withVersionParam(siteUrl, versionId) }) })), _jsxs("div", { className: "sm-editor", children: [_jsxs("div", { className: "sm-canvas", children: [(banner || geometry === "missing") && (_jsxs("div", { style: { display: "flex", flexDirection: "column", gap: 8, padding: "0 20px 10px" }, children: [banner && (_jsxs(BannerBox, { tone: banner.fields && banner.fields.length > 0 ? "warn" : "error", onDismiss: () => setBanner(null), children: [_jsx("p", { children: banner.text }), _jsx(FieldErrorList, { fields: banner.fields ?? [] })] })), geometry === "missing" && (_jsx(BannerBox, { tone: "warn", children: "Selection unavailable \u2014 the page didn't report geometry. The outline still works." }))] })), _jsx("div", { className: "sm-canvas__scroll", ref: scrollerRef, children: _jsxs("div", { className: "sm-page", style: { overflow: "hidden" }, children: [iframeSrc ? (_jsx("iframe", { ref: iframeRef, src: iframeSrc, title: "Page canvas", style: { width: "100%", height: docH, border: 0, display: "block" } })) : (_jsx("div", { className: "sm-canvas__loading", children: "Loading canvas\u2026" })), _jsx(CanvasOverlay, { emptyZones: emptyZones, hoverRect: hoverRect, hoverGlobal: hoverGlobal, selection: selection, fieldHover: fieldHover, editing: editing, plusButtons: plusButtons, onPlus: (p, x, y) => setPicker({ zone: p.zone, index: p.index, x: x - 150, y: y + 12 }), onMove: doMove, onDuplicate: doDuplicate, onRemove: doRemove }), inline.richtext && (() => {
832
+ ] }) }), right: _jsxs(_Fragment, { children: [(touched || saveState === "error") && _jsx(SaveIndicator, { state: saveState }), ready && (_jsx(LinkButton, { icon: true, href: siteUrl, target: "_blank", rel: "noreferrer", "aria-label": "View live", title: "View live \u2014 opens the site route. While the admin's draft-mode cookie is on, this shows the DRAFT \u2014 open it in a private window to see what visitors see.", children: _jsx(Eye, { size: 16, strokeWidth: 1.5, "aria-hidden": "true" }) })), _jsx(PublishControl, { status: record?.locales[locale] ? localeStatusOf(record.locales[locale]) : null, pending: dirty || saveState !== "saved", busy: pageBusy, flash: published, onPublish: () => { void doPublish(); }, onDiscard: () => { void doDiscard(); }, onUnpublish: () => { void doUnpublish(); }, onHistory: () => { void doHistory(); } })] }) }), target && _jsx(PresenceLine, { names: editorsOf(others, target), noun: "page" }), stale && _jsx(StaleBanner, { info: stale, onReload: loadTheirs }), history && record?.locales[locale] && (_jsx(HistoryModal, { rows: history, draftId: record.locales[locale].draftVersionId, onClose: () => setHistory(null), onRestore: doRestore, render: (versionId) => _jsx("iframe", { className: "sm-history__frame", title: "Version preview", src: withVersionParam(siteUrl, versionId) }) })), _jsxs("div", { className: "sm-editor", inert: stale ? true : undefined, children: [_jsxs("div", { className: "sm-canvas", children: [(banner || geometry === "missing") && (_jsxs("div", { style: { display: "flex", flexDirection: "column", gap: 8, padding: "0 20px 10px" }, children: [banner && (_jsxs(BannerBox, { tone: banner.fields && banner.fields.length > 0 ? "warn" : "error", onDismiss: () => setBanner(null), children: [_jsx("p", { children: banner.text }), _jsx(FieldErrorList, { fields: banner.fields ?? [] })] })), geometry === "missing" && (_jsx(BannerBox, { tone: "warn", children: "Selection unavailable \u2014 the page didn't report geometry. The outline still works." }))] })), _jsx("div", { className: "sm-canvas__scroll", ref: scrollerRef, onClick: (e) => { if (e.target === e.currentTarget)
833
+ setSelectedId(null); }, children: _jsxs("div", { className: "sm-page", style: { overflow: "hidden" }, children: [iframeSrc ? (_jsx("iframe", { ref: iframeRef, src: iframeSrc, title: "Page canvas", style: { width: "100%", height: docH, border: 0, display: "block" } })) : (_jsx("div", { className: "sm-canvas__loading", children: "Loading canvas\u2026" })), _jsx(CanvasOverlay, { emptyZones: emptyZones, hoverRect: hoverRect, hoverGlobal: hoverGlobal, selection: selection, fieldHover: fieldHover, editing: editing, plusButtons: plusButtons, onPlus: (p, x, y) => setPicker({ zone: p.zone, index: p.index, x: x - 150, y: y + 12 }), onMove: doMove, onDuplicate: doDuplicate, onRemove: doRemove }), inline.richtext && (() => {
756
834
  const { session } = inline.richtext;
757
835
  const item = globalItemOf(session.node);
758
836
  const node = !item && tree ? findNode(tree, session.node) : null;