smoodly 0.0.23 → 0.0.25

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 (135) hide show
  1. package/README.md +192 -43
  2. package/bin/smoodly.js +6 -0
  3. package/dist/admin/auth.d.ts +10 -5
  4. package/dist/admin/auth.js +14 -13
  5. package/dist/admin/client-ops.js +2 -1
  6. package/dist/admin/editor/EditorView.js +20 -6
  7. package/dist/admin/editor/InsertPicker.d.ts +14 -0
  8. package/dist/admin/editor/InsertPicker.js +37 -7
  9. package/dist/admin/editor/InspectorFields.js +1 -1
  10. package/dist/admin/editor/Outline.js +2 -2
  11. package/dist/admin/editor/PageSettings.js +6 -0
  12. package/dist/admin/editor/SeoSettings.js +1 -1
  13. package/dist/admin/editor/locale-url.d.ts +7 -4
  14. package/dist/admin/editor/locale-url.js +14 -7
  15. package/dist/admin/editor/usePageLocale.js +9 -1
  16. package/dist/admin/forms/CropEditor.d.ts +4 -2
  17. package/dist/admin/forms/CropEditor.js +6 -3
  18. package/dist/admin/forms/DatePicker.d.ts +9 -0
  19. package/dist/admin/forms/DatePicker.js +133 -0
  20. package/dist/admin/forms/FieldWidget.js +11 -5
  21. package/dist/admin/forms/ImagePreview.d.ts +9 -0
  22. package/dist/admin/forms/ImagePreview.js +11 -0
  23. package/dist/admin/forms/MediaWidget.d.ts +8 -2
  24. package/dist/admin/forms/MediaWidget.js +13 -13
  25. package/dist/admin/forms/RichtextElementCard.js +1 -1
  26. package/dist/admin/forms/RichtextImageCard.js +14 -9
  27. package/dist/admin/forms/date-picker.d.ts +44 -0
  28. package/dist/admin/forms/date-picker.js +117 -0
  29. package/dist/admin/forms/elements-context.d.ts +3 -0
  30. package/dist/admin/forms/elements-context.js +7 -2
  31. package/dist/admin/forms/time-zone-context.d.ts +7 -0
  32. package/dist/admin/forms/time-zone-context.js +15 -0
  33. package/dist/admin/forms/useRichtextEditor.js +10 -1
  34. package/dist/admin/index.d.ts +2 -0
  35. package/dist/admin/next/bridge.d.ts +2 -1
  36. package/dist/admin/next/bridge.js +35 -5
  37. package/dist/admin/next/index.d.ts +2 -0
  38. package/dist/admin/ops-impl.js +73 -1
  39. package/dist/admin/ops.d.ts +22 -2
  40. package/dist/admin/serialize.d.ts +2 -0
  41. package/dist/admin/serialize.js +1 -0
  42. package/dist/admin/shell/AdminApp.js +48 -3
  43. package/dist/admin/shell/EntriesList.js +12 -21
  44. package/dist/admin/shell/EntryForm.js +63 -5
  45. package/dist/admin/shell/GlobalForm.js +22 -2
  46. package/dist/admin/shell/GlobalList.js +4 -18
  47. package/dist/admin/shell/IconRail.d.ts +3 -1
  48. package/dist/admin/shell/IconRail.js +17 -7
  49. package/dist/admin/shell/InviteModal.d.ts +6 -0
  50. package/dist/admin/shell/InviteModal.js +28 -0
  51. package/dist/admin/shell/ListView.js +2 -2
  52. package/dist/admin/shell/LoginView.js +34 -5
  53. package/dist/admin/shell/MediaScreen.js +11 -1
  54. package/dist/admin/shell/MembersPage.d.ts +2 -1
  55. package/dist/admin/shell/MembersPage.js +90 -25
  56. package/dist/admin/shell/PageDialogs.d.ts +26 -0
  57. package/dist/admin/shell/PageDialogs.js +38 -0
  58. package/dist/admin/shell/PagesList.js +100 -70
  59. package/dist/admin/shell/RouteLink.js +1 -1
  60. package/dist/admin/shell/SchemaNotice.d.ts +4 -0
  61. package/dist/admin/shell/SchemaNotice.js +4 -0
  62. package/dist/admin/shell/SetPasswordView.d.ts +7 -0
  63. package/dist/admin/shell/SetPasswordView.js +41 -0
  64. package/dist/admin/shell/SettingsView.d.ts +5 -1
  65. package/dist/admin/shell/SettingsView.js +17 -11
  66. package/dist/admin/shell/live-url.d.ts +10 -0
  67. package/dist/admin/shell/live-url.js +19 -0
  68. package/dist/admin/shell/locale-memory.d.ts +8 -0
  69. package/dist/admin/shell/locale-memory.js +23 -0
  70. package/dist/admin/shell/members-list.d.ts +15 -1
  71. package/dist/admin/shell/members-list.js +24 -7
  72. package/dist/admin/shell/page-dialogs.d.ts +9 -0
  73. package/dist/admin/shell/page-dialogs.js +19 -0
  74. package/dist/admin/shell/router.d.ts +10 -7
  75. package/dist/admin/shell/router.js +18 -9
  76. package/dist/admin/shell/session.d.ts +6 -0
  77. package/dist/admin/shell/session.js +17 -2
  78. package/dist/admin/shell/settings.d.ts +0 -6
  79. package/dist/admin/shell/settings.js +4 -5
  80. package/dist/admin/supabase-auth.js +6 -5
  81. package/dist/admin/tree-ops.js +3 -0
  82. package/dist/admin/ui/ActionMenu.js +26 -6
  83. package/dist/admin/ui/Combobox.d.ts +5 -0
  84. package/dist/admin/ui/Combobox.js +1 -1
  85. package/dist/admin/ui/ConfirmDialog.d.ts +8 -0
  86. package/dist/admin/ui/ConfirmDialog.js +30 -0
  87. package/dist/admin/ui/Dialog.d.ts +13 -0
  88. package/dist/admin/ui/Dialog.js +54 -0
  89. package/dist/admin/ui/HistoryModal.d.ts +2 -1
  90. package/dist/admin/ui/HistoryModal.js +7 -2
  91. package/dist/admin/ui/ResizeHandle.js +4 -4
  92. package/dist/admin/ui/confirm.d.ts +35 -0
  93. package/dist/admin/ui/confirm.js +57 -0
  94. package/dist/admin/ui/primitives.d.ts +19 -4
  95. package/dist/admin/ui/primitives.js +11 -5
  96. package/dist/admin/ui/resize.d.ts +7 -4
  97. package/dist/admin/ui/resize.js +7 -4
  98. package/dist/admin/ui/theme.d.ts +1 -1
  99. package/dist/admin/ui/theme.js +368 -203
  100. package/dist/admin/validate.d.ts +4 -0
  101. package/dist/admin/validate.js +28 -0
  102. package/dist/cli/load-collections.d.ts +2 -0
  103. package/dist/cli/load-collections.js +26 -0
  104. package/dist/cli/main.d.ts +13 -0
  105. package/dist/cli/main.js +62 -0
  106. package/dist/cli/migrate.d.ts +23 -0
  107. package/dist/cli/migrate.js +64 -0
  108. package/dist/config.d.ts +4 -0
  109. package/dist/config.js +9 -0
  110. package/dist/fields.d.ts +2 -0
  111. package/dist/fields.js +2 -0
  112. package/dist/index.d.ts +6 -3
  113. package/dist/index.js +4 -2
  114. package/dist/marks.d.ts +3 -0
  115. package/dist/marks.js +3 -0
  116. package/dist/member-store.d.ts +30 -1
  117. package/dist/member-store.js +37 -6
  118. package/dist/migrations.d.ts +37 -0
  119. package/dist/migrations.js +73 -0
  120. package/dist/permissions.d.ts +7 -0
  121. package/dist/permissions.js +19 -0
  122. package/dist/schema-version.d.ts +17 -0
  123. package/dist/schema-version.js +32 -0
  124. package/dist/sql-space.js +6 -0
  125. package/dist/sql.js +10 -0
  126. package/dist/store.d.ts +3 -0
  127. package/dist/store.js +4 -0
  128. package/dist/supabase-member-store.d.ts +8 -1
  129. package/dist/supabase-member-store.js +62 -2
  130. package/dist/supabase-store.d.ts +3 -0
  131. package/dist/supabase-store.js +23 -0
  132. package/dist/time-zone.d.ts +6 -0
  133. package/dist/time-zone.js +27 -0
  134. package/migrations/20260926120000_smoodly_v1.sql +535 -0
  135. package/package.json +13 -3
package/README.md CHANGED
@@ -495,6 +495,65 @@ after both are on the registry. From the repo root:
495
495
  `prepublishOnly` rebuilds each package. The Claude Code skill
496
496
  `.claude/skills/releasing-smoodly` carries the same order with its checks.
497
497
 
498
+ ## Follow-ups (logged 2026-09-26, browser suite)
499
+
500
+ - Drag and drop has no browser test (spec §4): the outline, the pages
501
+ tree and list rows reorder by real pointer drags; the first plan that
502
+ touches `useSortable` adds them.
503
+ - The suite runs Chromium only; the admin's WebKit and Firefox
504
+ behaviour is unverified.
505
+ - Three `window.confirm` calls remain outside the confirm dialog (the
506
+ router's leave guard, `ui/confirm.ts`'s fallback) and are not exercised.
507
+ - The media test uploads a starter photo under a suffixed name rather
508
+ than a fixture file, so `e2e/fixtures/` from the spec does not exist.
509
+ - ListView rows that are not clickable (Members) carry no role; the
510
+ members test finds its row by `.sm-table__body > div`. A table role
511
+ set (`table`/`row`/`cell`) on ListView would give every list a proper
512
+ locator.
513
+ - Publishing a global the site layout reads (the footer) serves the old
514
+ layout on the first public request made right after the publish returns;
515
+ every request after it, and a first request made ~3 s later, is fresh.
516
+ Pages do not show it (an unpublish answers 404 at once). Seen on
517
+ `next dev`; `e2e/globals.spec.ts` polls the home page for it. Find out
518
+ whether `updateTag` over a layout's `getGlobal` read expires late, and
519
+ whether `next start` behaves the same.
520
+ - Fixed in the same session: an inserted section seeded an unsampled
521
+ optional object as `{}`, which publish then validated as present — an
522
+ untouched Image and text `link` refused the publish. The insert now
523
+ leaves it absent and validation reads a blank optional object as absent.
524
+
525
+ ## Follow-ups (logged 2026-09-26, schema migrations spec)
526
+
527
+ - Existing databases (every install so far is ours; spec 2026-09-26 §7):
528
+ a database created before version 1 has the schema but not the table.
529
+ One time, in the SQL editor or psql:
530
+ `create table if not exists "smoodly_meta" ("key" text primary key, "value" text not null);`
531
+ `alter table "smoodly_meta" enable row level security;`
532
+ `insert into "smoodly_meta" ("key", "value") values ('schema_version', '1') on conflict ("key") do update set "value" = excluded."value";`
533
+ then re-apply the `smoodly_read` policy on `smoodly_meta` from
534
+ `migrations/20260926120000_smoodly_v1.sql` (a `drop … if exists` /
535
+ `create policy` pair). The hand-applied statement lists below stay as
536
+ history; from version 2 on, every change is a file.
537
+ - Drafting version N+1: `npx tsx scripts/emit-migration.ts` prints the
538
+ whole schema plus the upsert; keep only the delta, name the file with
539
+ the current UTC stamp and `_smoodly_v<N+1>.sql`, bump `SCHEMA_VERSION`,
540
+ run `npm run db:migrate` at the root so the repo copy follows, and
541
+ `npm run check:migrations`. Additive only; a contraction waits for the
542
+ compatibility window and carries `-- smoodly:contraction`.
543
+ - The views of a removed collection stay (spec 2026-09-26 §3); a
544
+ `drop view` is the developer's.
545
+ - `smoodly migrate` never pushes; a `--push` flag if the two-step
546
+ proves annoying.
547
+ - The CLI loads `smoodly/collections.ts` through tsx; a project that
548
+ registers collections elsewhere passes `--collections <file>`.
549
+ - The site's one warning fires from the Supabase page store's first
550
+ `resolvePath`, once per store instance; a site that never locates (a
551
+ pure `q` reader) never warns, and a long-running server that was
552
+ current when it booted stays quiet — the admin notice is the real
553
+ signal.
554
+ - A Deploying guide (nav lists it, unwritten) should show
555
+ `npx smoodly migrate --check` as a build step.
556
+
498
557
  ## Follow-ups (logged 2026-09-25, docs session)
499
558
 
500
559
  - `f.slug({ from })` stores `from` on the descriptor, but nothing reads it:
@@ -640,17 +699,30 @@ tool — reset and reseed a development database (`supabase db reset`,
640
699
  `alter table "space_caps" rename to "spaces";`
641
700
  `alter table "spaces" alter column "row_cap" drop not null;`
642
701
  `alter table "spaces" add column "console_url" text;`
702
+ `update "members" set "role" = 'admin' where "role" = 'owner';`
643
703
  then re-run the regenerated migration's `smoodly_space_row_cap` function
644
704
  and the `smoodly_read` policy on `spaces` (both `create or replace` /
645
705
  `drop … if exists`, so the whole migration file can be re-applied).
646
706
  Policies and triggers follow a table rename. Rows written before the
647
707
  column show their user id on the Members page until re-inserted (the
648
708
  demo-editor upsert fills it on the next seed).
649
- - The Members page is read-only; adding, re-roling and removing members
650
- in self-host, the role vocabulary and its enforcement at the gate, and
651
- the API Keys page's contents are DESIGN.md OQ #33.
709
+ - Members management shipped 2026-09-25 (spec
710
+ `docs/superpowers/specs/2026-09-25-members-management-design.md`); out
711
+ of its scope: a pending-invite status and "resend invite" on a row
712
+ (needs the Auth user's `last_sign_in_at`, which the cloud projection
713
+ does not carry); removing the Auth account together with the member
714
+ row; invites from the cloud's `/admin` (they are smoodly.io's); a
715
+ project whose Auth is OAuth-only (the invite mail asks for a password);
716
+ redirects on a published rename (the confirm says "there are none
717
+ yet").
718
+ - 2026-09-26: the Role column is a badge and the row menu has Change role
719
+ (confirm, to the other role) and Remove member; `ActionMenu` popovers are
720
+ fixed-positioned from the button — the cell's overflow clipped them.
652
721
  - The members list is not paged (tens of rows, not thousands).
653
- - The `api-keys` page id is a URL segment; the title reads "API Keys".
722
+ - The Roles and API Keys pages were dropped 2026-09-25 (DESIGN.md §2
723
+ "Settings"): Members is the only Settings page, the invite modal
724
+ describes the two roles, and `/settings/roles` and `/settings/api-keys`
725
+ are Not found.
654
726
 
655
727
  ## Follow-ups (logged 2026-09-13, history spec)
656
728
 
@@ -805,33 +877,109 @@ tool — reset and reseed a development database (`supabase db reset`,
805
877
  media projection puts the image on Media; Escape after a card's button
806
878
  had taken the focus did not end the session.
807
879
 
808
- ## Follow-ups (logged 2026-09-08, admin restyle)
809
-
810
- - Remove the temporary `technical` look once the warm restyle is decided:
811
- the `TECHNICAL_*` strings (frozen tokens plus the `_SHAPE`/`_EXTRA`
812
- additions), the second `cascade` call and the boot script's
813
- `smoodly-look` branch in `src/admin/ui/theme.tsx`. The original look
814
- stays at tag `look-technical`; restore the file from there if the
815
- restyle is dropped instead.
816
- - The technical block is a token snapshot, not a pixel restore: shared
817
- class rules moved with the warm look, so under `data-look="technical"`
818
- the inspector is flush cards with a hairline instead of one column with
819
- a sticky head, tabs are stretched, inputs have a 1px (not 1.5px)
820
- border, rail tiles are 42×34, and a few weights and gaps drifted. Exact
821
- comparison is the tag.
822
- - Signed-in walk of the warm look still pending: the sign-in screen was
823
- checked in the browser pane (light and dark), the list, entry form and
824
- visual editor were not — the magic-link sign-in carries a token in the
825
- URL, which the pane's policy blocks. Walk them, then tune: inspector
826
- width (320px here, the handoff says 296px; the field widgets need the
827
- room), the canvas meta bar (the handoff has none; ours holds the locale
828
- switcher), and the block-toolbar note.
880
+ ## Follow-ups (logged 2026-09-25, neutral restyle + confirm dialog)
881
+
882
+ - The warm look is the comparison block now (`WARM_*` + `WARM_EXTRA` in
883
+ `src/admin/ui/theme.tsx`, `localStorage.smoodly-look = "warm"`); the
884
+ technical block is deleted (tag `look-technical` keeps it). Remove the
885
+ warm block once the neutral look is judged, the same way.
886
+ - Kept from the app on purpose, against the handoff: the 14px base and
887
+ 18px titles (handoff 13/20/22), the 64px top bar (56), the 360px
888
+ inspector (296), the Lucide rail icons, the `--table-top` air above the
889
+ list, the 12px block-tag padding. Inputs stay white on focus (the
890
+ handoff's focused textarea takes the tile grey) and the locked outline
891
+ row is a white row like the rest (the handoff's is the ground, invisible
892
+ on a tile of the same grey).
893
+ - The handoff's status chips collapse Draft into amber; the app keeps its
894
+ three states (Published green, Unpublished changes amber, Draft grey on
895
+ `--control-hover`).
896
+ - The Pages list's prompts are dialogs on the same card since the same
897
+ day (`shell/PageDialogs.tsx` over `ui/Dialog.tsx`): New page / New
898
+ path / New subpage / New subpath (title, slug following the title
899
+ through `slugify` in `shell/page-dialogs.ts`, page type when more than
900
+ one open template), Add page here (page type; skipped with one), Rename
901
+ (title and slug; the home page's title alone). Still native: the
902
+ `window.alert`s on a refused list op outside a dialog (`run` in
903
+ PagesList, EntriesList's add/remove) — a notice banner is the follow-up.
904
+ - `slugify` is the admin's first slugifier (title → segment); the slug
905
+ field widget (`f.slug`) does not use it yet, and neither does the entry
906
+ create-on-click. OQ 24 (unicode slugs) would relax its grammar.
907
+ - The dialog's fields take the tile tone on the white card
908
+ (`.sm-dialog__form .sm-input`), the reverse of fields on tiles.
909
+ - The confirm's copy lives at each call site (`title` / `body` /
910
+ `confirmLabel` / `subject`); the shared ones are `DISCARD_CONFIRM`
911
+ (`ui/confirm.ts`), `LEAVE_CONFIRM` (router) and `LIVE_URL_CONFIRM`
912
+ (live-url). A subject path is shown where the screen already holds it;
913
+ the entries list and the media screen show the name only.
914
+ - `confirm()` without a mounted `<ConfirmHost/>` falls back to
915
+ `window.confirm` so nothing can hang; the host is mounted once in
916
+ `AdminApp` beside the shell. The tab-close guard stays native
917
+ (`beforeunload` cannot await).
829
918
  - The handoff's `Styles` tab in the inspector is not built: it exists
830
919
  only where a section registers styles, which is the existing `tabsFor`.
831
- - Wordmark and app icon colour: the wordmark now takes `--logo-color`
832
- (accent in the warm look); the rail's app icon is hidden in the warm
833
- look and still brand blue in the technical one. A warm app icon is a
834
- design question.
920
+ - The wordmark still takes `--logo-color` (accent); the rail's app icon
921
+ takes `--logo-outer` / `--logo-inner` and is hidden in the warm look.
922
+ - Polish round the same evening (2026-09-25): list row rules are a
923
+ `.sm-row::after` pseudo-element, since a border follows the rounded
924
+ hover corners; the admin remembers ONE locale across lists and forms
925
+ (`shell/locale-memory.ts`, key `smoodly-locale`; the lists' two
926
+ session-only keys are gone, `localeFromUrl` falls back to it and
927
+ remembers what it resolved); the section insert picker opens upward
928
+ when the room below is short and scrolls inside the room it has
929
+ (`placePicker` in `editor/InsertPicker.tsx`, measured in a layout
930
+ effect, no window-resize listener); the prefixed slug input keeps a
931
+ 22px line box so its caret reads as one; `.sm-input--mono` is scoped
932
+ under `.sm-admin` — the `input{font-family:inherit}` reset had beaten
933
+ it, so every mono input (slugs, IDs) had rendered in the sans font;
934
+ richtext block-node cards take `--rtcard-bg` / `--rtcard-border` (tile
935
+ grey with the soft accent border in the neutral look, white and
936
+ hairlined in the warm). The custom section's row cards and the list
937
+ field's rows keep the white-on-tile look — only the richtext cards sat
938
+ on a white field.
939
+ - Richtext cards, second pass (2026-09-25): the default card border is
940
+ the hairline (`--rtcard-border:var(--line)`), the accent outline marks
941
+ the selected card alone, and the widget's focus ring steps back while a
942
+ block node is selected (`:focus:not(:has(.ProseMirror-selectednode))`).
943
+ The image card's head carries Edit / Replace / Remove as
944
+ `.sm-field__action` text buttons, the same three the media field now
945
+ shows in its field head (`MediaWidget` renders its own `Field`;
946
+ `FieldWidget` returns it directly, `SeoSettings` passes the label). Edit
947
+ on a richtext image is the crop editor over the node's `image` attr
948
+ (an `ImageValue`, so crop/hotspot/aspect store there). The bound
949
+ `smoodly/richtext` renderer applies it through `<SmoodlyImage>`; the
950
+ root `RichText`'s plain figure does NOT (it may not import the image
951
+ layer, `richtext-entry.test.ts`), so a framework-neutral renderer
952
+ (the Astro adapter, OQ 29) must frame it with `frameFor` itself. The
953
+ card shows the cropped frame (`forms/ImagePreview.tsx`, shared with the
954
+ media field), the filename and the caption as text; alt and caption are
955
+ edited in the crop modal (`CropEditor` takes `caption`), so the card
956
+ holds no inputs. Fields on a modal panel take the tile tone.
957
+ - A refused publish's per-field errors clear as the fields are fixed
958
+ (2026-09-25): the entry and global forms revalidate on every change with
959
+ the op's own rules (`validateFields` / `validateNode` over
960
+ `elementSchemas(registry.elements)`) and keep only what still fails
961
+ (`stillFailing` in `validate.ts`); the notice's list shrinks and the
962
+ notice goes when it empties. Typing never adds an error — only a
963
+ publish does. The page editor's banner list and the canvas global
964
+ inspector (save errors) are unchanged.
965
+
966
+ ## Follow-ups (logged 2026-09-25, date pickers)
967
+
968
+ - `f.date()` and `f.datetime()` share one in-house calendar
969
+ (`admin/forms/DatePicker.tsx` over the pure `date-picker.ts`); the
970
+ browsers' own pickers were rejected (they differ and take no tokens).
971
+ Weeks start on Monday and month names come from the browser's language
972
+ — `weekStart` and a month-name locale as config keys are follow-ups.
973
+ - `defineConfig({ timeZone })` is validated by constructing an
974
+ `Intl.DateTimeFormat`, so an alias the runtime's database still knows
975
+ (`Europe/Kiev`) passes and an offset never does. Admin-owned site
976
+ settings (a per-space `settings` table with a Site page) were designed
977
+ and dropped for the zone; the design is in the spec's history should a
978
+ setting turn up that editors must change without a deploy.
979
+ - Not built: a time-only field, a date range, `.min` / `.max` on dates.
980
+ - Publish refuses a malformed date or datetime now (`validate.ts`); a
981
+ row saved before this with a bad string shows the message on its next
982
+ publish, as designed.
835
983
 
836
984
  ## Follow-ups (logged 2026-09-08, block nodes spec)
837
985
 
@@ -1024,7 +1172,8 @@ the toolbar instead of pushing the content.
1024
1172
  - ~~Drag-to-resize the inspector~~ — DONE 2026-09-10 (spec
1025
1173
  `docs/superpowers/specs/2026-09-10-inspector-resize-design.md`):
1026
1174
  `ui/ResizeHandle.tsx` writes `--inspector-w` inline on the wrapper,
1027
- clamped to 260px..half the row (`ui/resize.ts`), remembered under
1175
+ clamped to 260px..the row less the handle and a 440px canvas (`ui/resize.ts`,
1176
+ half the row until 2026-09-26), remembered under
1028
1177
  `smoodly-inspector-w` and applied by the theme's boot script. Walk
1029
1178
  finding: the desktop pane hides its tab on every `navigate`, and a
1030
1179
  hidden tab lays out at zero width, so the handle treats a zero
@@ -1356,10 +1505,10 @@ the toolbar instead of pushing the content.
1356
1505
  `page:<id>:<locale>` tag would halve the revalidations on a bilingual
1357
1506
  site.
1358
1507
  - The Pages list's confirms (`Add <locale>`, `Remove from <locale>`,
1359
- `Delete … in every language`) are `window.confirm`, and `EntriesList`
1360
- reports a refused add or remove with `window.alert`; the design
1361
- system's dialog is the follow-up for both, together with the row menu
1362
- extraction already logged above.
1508
+ `Delete …`) are the design system's dialog since 2026-09-25;
1509
+ `EntriesList` still reports a refused add or remove with
1510
+ `window.alert` — the follow-up, together with the row menu extraction
1511
+ already logged above.
1363
1512
  - `EditorView.tsx` is ~850 lines now that it also holds the locale switch,
1364
1513
  add and remove flows — see the 2026-09-03 follow-up below for the seams
1365
1514
  (that entry is the one to act on).
@@ -1472,13 +1621,13 @@ the toolbar instead of pushing the content.
1472
1621
  those values in `entry_locales.fields` until the next save of each
1473
1622
  locale (a save writes only the current split) — the drift/codemod
1474
1623
  pipeline (Phase 2) is where "keep en, discard fi" belongs.
1475
- - The repo-root migration (`supabase/migrations/00000000000000_smoodly.sql`)
1476
- is generated by `packages/smoodly/scripts/emit-sql.ts` from that
1477
- script's OWN fixture collections, not from
1478
- `examples/site/smoodly/collections.ts`; the create-smoodly-app template
1479
- migration IS generated from the example. Since 2026-09-14 both emit the
1480
- same view shape for every collection, but the repo-root fixture's field
1481
- lists still drift from the example's — the two should share one source.
1624
+ - Resolved 2026-09-26 (schema migrations spec): the repo-root
1625
+ `supabase/migrations/` is the package's core files plus a views file
1626
+ that `npm run db:migrate` writes from
1627
+ `examples/site/smoodly/collections.ts` — the same source the
1628
+ create-smoodly-app templates use; `scripts/emit-sql.ts` and its own
1629
+ fixture collections are gone (`scripts/emit-migration.ts` drafts the
1630
+ next core file).
1482
1631
  - In the entry form, a shared ref field (e.g. an article's author) shows
1483
1632
  `—` in a locale where the target is absent, because the picker offers
1484
1633
  only targets present in the edited locale; the value is kept on the
@@ -1664,7 +1813,7 @@ the toolbar instead of pushing the content.
1664
1813
  - Add an index on `pages(published_version_id)`: the `page_versions` read policy's `exists` probe runs on every anon/read-key read and has no supporting index.
1665
1814
  - 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.
1666
1815
  - 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).
1667
- - `isEditor` 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.
1816
+ - `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.
1668
1817
  - The `sb_secret_` branch of `clientOptions` (secret key as the sole `apikey`, no bearer) was exercised live on 2026-09-05 against a hosted project: PostgREST reads with the service-role template and the GoTrue admin API (`listUsers`, `createUser`) both accept it; a password sign-in with the matching `sb_publishable_` key returns a session. Unit tests still cover the shape only; the local stack stays on legacy JWT keys.
1669
1818
  - The migration is not re-appliable to a pre-rename (`project_id`) database: `create table if not exists` leaves it untouched and the `space_id` policies then fail. No installs predate the rename; fold into DESIGN.md OQ #2 (schema upgrades).
1670
1819
  - `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.
@@ -1678,5 +1827,5 @@ the toolbar instead of pushing the content.
1678
1827
  - 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.
1679
1828
  - `serialize.ts` structural cast duplicates `PageSchema`; tighten `Registered.schema` so the cast goes.
1680
1829
  - Catch-all `templates` map should filter to open registrations to state its intent (unreachable today).
1681
- - 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.
1830
+ - 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.
1682
1831
  - `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));
@@ -1,13 +1,18 @@
1
+ import { type MemberRole } from "../permissions.ts";
1
2
  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 = {
3
+ /** What the auth project vouches for: the Auth user id and the email the locale rows record as `updated_by` (spec 2026-09-13 history §2). */
4
+ export type VerifiedUser = {
4
5
  userId: string;
5
6
  email: string | null;
6
7
  };
8
+ /** The verified member: the user plus the normalized role from `members`. */
9
+ export type AdminSession = VerifiedUser & {
10
+ role: MemberRole;
11
+ };
7
12
  export type AdminAuth = {
8
13
  /** The user behind a session token, or null when the auth project rejects it. */
9
- verify(token: string): Promise<AdminSession | null>;
10
- /** Does `editors` hold this user for the current space? */
11
- isEditor(userId: string): Promise<boolean>;
14
+ verify(token: string): Promise<VerifiedUser | null>;
15
+ /** The stored role of this user in `members` for the current space, or null without a row. */
16
+ memberOf(userId: string): Promise<string | null>;
12
17
  };
13
18
  export declare function gateAdminOp(auth: AdminAuth, call: AdminOpCall, run: (call: AdminOpCall, session: AdminSession) => Promise<OpResult<unknown>>): Promise<OpResult<unknown>>;
@@ -1,3 +1,9 @@
1
+ // Admission to the admin (spec 2026-09-04 §5; roles 2026-09-25 members
2
+ // management §2): a session token from the auth project, then a row in
3
+ // `members` read with the write key, whose role rides on the session.
4
+ // One gate around the ops seam — individual ops ask can(). Framework-
5
+ // free; Supabase-backed implementation in supabase-auth.ts.
6
+ import { normalizeRole } from "../permissions.js";
1
7
  const unauthorized = (message) => ({ ok: false, code: "unauthorized", message });
2
8
  export async function gateAdminOp(auth, call, run) {
3
9
  const token = typeof call === "object" && call !== null && typeof call.token === "string"
@@ -5,24 +11,19 @@ export async function gateAdminOp(auth, call, run) {
5
11
  : null;
6
12
  if (!token)
7
13
  return unauthorized("smoodly: sign in to use the admin.");
8
- let session;
9
- let editor;
14
+ let user;
15
+ let stored;
10
16
  try {
11
- session = await auth.verify(token);
12
- if (!session)
17
+ user = await auth.verify(token);
18
+ if (!user)
13
19
  return unauthorized("smoodly: your session has expired — sign in again.");
14
- editor = await auth.isEditor(session.userId);
20
+ stored = await auth.memberOf(user.userId);
15
21
  }
16
22
  catch (e) {
17
23
  console.error("smoodly: auth check failed", e);
18
- return {
19
- ok: false,
20
- code: "internal",
21
- message: "smoodly: could not check your session.",
22
- };
24
+ return { ok: false, code: "internal", message: "smoodly: could not check your session." };
23
25
  }
24
- if (!editor) {
26
+ if (stored === null)
25
27
  return unauthorized("smoodly: this account is not an editor of this site.");
26
- }
27
- return run(call, session);
28
+ return run(call, { ...user, role: normalizeRole(stored) });
28
29
  }
@@ -3,7 +3,8 @@ const GROUPS = {
3
3
  entries: ["list", "distinct", "get", "create", "save", "setStatus", "discardDraft", "delete", "move", "addLocale", "removeLocale", "listVersions", "getVersion", "restore"],
4
4
  globals: ["list", "get", "save", "setStatus", "discardDraft", "listVersions", "getVersion", "restore"],
5
5
  assets: ["list", "get", "beginUpload", "finishUpload", "update", "delete", "usage"],
6
- members: ["list"],
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,6 +58,7 @@ 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";
61
63
  import { Banner as BannerBox, Breadcrumb, Button, FieldErrorList, IconButton, LinkButton, SaveIndicator, Tabs, TopBar, } from "../ui/primitives.js";
62
64
  export const withEditorParam = (url) => (url.includes("?") ? `${url}&smoodly-editor=1` : `${url}?smoodly-editor=1`);
@@ -107,7 +109,9 @@ export function EditorView({ ops, registry, id }) {
107
109
  registryRef.current = registry;
108
110
  // The tree in memory belongs to the locale it was loaded for; a switch
109
111
  // flushes first (switchLocale), so the ref and the tree always agree.
110
- const treeLocaleRef = useRef(localeFromUrl(registry));
112
+ // (Read once: localeFromUrl also remembers the locale.)
113
+ const [openedLocale] = useState(() => localeFromUrl(registry));
114
+ const treeLocaleRef = useRef(openedLocale);
111
115
  // Every edit marks the draft dirty; a debounced saveDraft persists, with
112
116
  // retry on failure, and the bridge refreshes the iframe on the save ack —
113
117
  // now, or once an inline session ends (spec 2026-09-10 §7).
@@ -499,12 +503,19 @@ export function EditorView({ ops, registry, id }) {
499
503
  mutate((t) => insertNode(t, zone, index, copy));
500
504
  select(copy.id);
501
505
  };
502
- const doRemove = (id, confirm = true) => {
506
+ const doRemove = async (id, ask = true) => {
503
507
  if (!tree)
504
508
  return;
505
509
  if (policyOf(zoneOf(tree, id))?.kind === "locked")
506
510
  return;
507
- if (confirm && !window.confirm("Delete this section?"))
511
+ const node = findNode(tree, id);
512
+ if (ask && !(await confirm({
513
+ title: "Delete this section?",
514
+ body: "It is removed from the page draft. Nothing changes on the live site until you publish.",
515
+ confirmLabel: "Delete section",
516
+ destructive: true,
517
+ subject: node ? { name: labelOf(node) } : undefined,
518
+ })))
508
519
  return;
509
520
  mutate((t) => removeNode(t, id));
510
521
  setSelectedId((current) => (current === id ? null : current));
@@ -550,7 +561,8 @@ export function EditorView({ ops, registry, id }) {
550
561
  };
551
562
  /** Discard (spec 2026-09-13 publish flow §4): confirm, flush so a late save cannot re-dirty the row, restore, reload the canvas. */
552
563
  const doDiscard = async () => {
553
- if (!window.confirm("Discard your changes and restore the published version?"))
564
+ const row = record?.locales[locale];
565
+ if (!(await confirm({ ...DISCARD_CONFIRM, subject: row ? { name: row.title, path } : undefined })))
554
566
  return;
555
567
  setBanner(null);
556
568
  setPageBusy("discard");
@@ -650,7 +662,8 @@ export function EditorView({ ops, registry, id }) {
650
662
  setBanner({ text: r.message, fields: r.fields });
651
663
  };
652
664
  const doDiscardGlobal = async (name) => {
653
- if (!window.confirm("Discard your changes and restore the published version?"))
665
+ const item = registry.globals.find((g) => g.name === name);
666
+ if (!(await confirm({ ...DISCARD_CONFIRM, subject: item ? { name: `${item.title} · global` } : undefined })))
654
667
  return;
655
668
  setBanner(null);
656
669
  setGlobalBusy("discard");
@@ -752,7 +765,8 @@ export function EditorView({ ops, registry, id }) {
752
765
  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
766
  },
754
767
  { 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 && (() => {
768
+ ] }) }), 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(); } })] }) }), 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, onClick: (e) => { if (e.target === e.currentTarget)
769
+ 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
770
  const { session } = inline.richtext;
757
771
  const item = globalItemOf(session.node);
758
772
  const node = !item && tree ? findNode(tree, session.node) : null;
@@ -1,11 +1,25 @@
1
1
  import type { ZonePolicy } from "../../zones.ts";
2
2
  import type { AdminRegistry, AdminSection, AdminGlobal } from "../serialize.ts";
3
+ /** Where the "+" put the panel: `y` is 12px under the button's centre (EditorView). */
3
4
  export type PickerAt = {
4
5
  zone: string;
5
6
  index: number;
6
7
  x: number;
7
8
  y: number;
8
9
  };
10
+ export type PickerPlace = {
11
+ left: number;
12
+ top?: number;
13
+ bottom?: number;
14
+ maxHeight?: number;
15
+ };
16
+ /** Where the panel sits, given its natural height and the viewport: under the
17
+ * anchor when it fits there, above it when that side has more room, and never
18
+ * taller than the room on its side (the panel scrolls). Pure. */
19
+ export declare function placePicker(at: PickerAt, height: number, viewport: {
20
+ width: number;
21
+ height: number;
22
+ }): PickerPlace;
9
23
  export declare function InsertPicker({ at, policy, registry, globalIds, onInsert, onInsertGlobal, onInsertCustom, onClose }: {
10
24
  at: PickerAt;
11
25
  policy: ZonePolicy | undefined;
@@ -1,20 +1,50 @@
1
1
  "use client";
2
2
  import { jsxs as _jsxs, jsx as _jsx, Fragment as _Fragment } from "react/jsx-runtime";
3
+ // The insert picker a "+" opens: the sections the zone's policy allows,
4
+ // then the Custom section where the zone allows it (spec 2026-09-14 §6),
5
+ // then the global items that can be placed there, or a note that the
6
+ // zone takes nothing. Clicking outside closes it. The panel hangs under
7
+ // the "+" when it fits, opens upward when there is more room above, and
8
+ // scrolls inside the room it has (2026-09-25) — a long section list near
9
+ // the bottom of the window used to run off screen.
10
+ import { useLayoutEffect, useRef, useState } from "react";
3
11
  import { allowedSections, allowedGlobals } from "../tree-ops.js";
4
12
  import { allowsCustom } from "../custom-section.js";
5
13
  import { SectionLabel } from "../ui/primitives.js";
6
- /** Keep the panel on screen; a static render (no window) leaves it where it was asked. */
7
- function clamp(at) {
14
+ const PICKER_W = 300;
15
+ const MARGIN = 12;
16
+ /** From `y` back up to the panel's bottom edge when it opens upward: the 12px the
17
+ * anchor sits under the button's centre, and the same 12px above it. */
18
+ const ABOVE_GAP = 24;
19
+ /** Where the panel sits, given its natural height and the viewport: under the
20
+ * anchor when it fits there, above it when that side has more room, and never
21
+ * taller than the room on its side (the panel scrolls). Pure. */
22
+ export function placePicker(at, height, viewport) {
23
+ const left = Math.max(MARGIN, Math.min(at.x, viewport.width - PICKER_W - MARGIN));
24
+ const below = viewport.height - MARGIN - at.y;
25
+ const above = at.y - ABOVE_GAP - MARGIN;
26
+ if (height <= below || below >= above)
27
+ return { left, top: at.y, maxHeight: Math.max(below, 0) };
28
+ return { left, bottom: viewport.height - (at.y - ABOVE_GAP), maxHeight: above };
29
+ }
30
+ /** The first paint, before the panel is measured; a static render (no window) leaves it where it was asked. */
31
+ function firstPlace(at) {
8
32
  if (typeof window === "undefined")
9
33
  return { left: at.x, top: at.y };
10
- return {
11
- left: Math.max(12, Math.min(at.x, window.innerWidth - 320)),
12
- top: Math.min(at.y, Math.max(12, window.innerHeight - 240)),
13
- };
34
+ return placePicker(at, 0, { width: window.innerWidth, height: window.innerHeight });
14
35
  }
15
36
  export function InsertPicker({ at, policy, registry, globalIds, onInsert, onInsertGlobal, onInsertCustom, onClose }) {
16
37
  const sections = allowedSections(policy, registry.sections);
17
38
  const globals = allowedGlobals(policy, registry.globals, globalIds);
18
39
  const custom = allowsCustom(policy);
19
- return (_jsx("div", { style: { position: "fixed", inset: 0, zIndex: 60 }, onClick: onClose, children: _jsxs("div", { className: "sm-picker", onClick: (e) => e.stopPropagation(), style: { position: "absolute", ...clamp(at) }, children: [_jsxs(SectionLabel, { mono: true, style: { padding: "6px 8px 8px" }, children: ["Add to ", at.zone] }), sections.map((section) => (_jsxs("button", { type: "button", className: "sm-picker__item", onClick: () => onInsert(section), children: [_jsx("span", { className: "sm-picker__icon", children: section.icon ?? "▤" }), _jsxs("span", { style: { minWidth: 0 }, children: [_jsx("span", { style: { display: "block", fontWeight: 600 }, children: section.title }), section.description && (_jsx("span", { style: { display: "block", fontSize: "var(--text-sm)", color: "var(--text-muted)" }, children: section.description }))] })] }, section.name))), custom && (_jsxs("button", { type: "button", className: "sm-picker__item", onClick: onInsertCustom, children: [_jsx("span", { className: "sm-picker__icon", children: "\u25A6" }), _jsxs("span", { style: { minWidth: 0 }, children: [_jsx("span", { style: { display: "block", fontWeight: 600 }, children: registry.custom.title }), _jsx("span", { style: { display: "block", fontSize: "var(--text-sm)", color: "var(--text-muted)" }, children: registry.custom.description })] })] })), globals.length > 0 && (_jsxs(_Fragment, { children: [_jsx(SectionLabel, { mono: true, style: { padding: "8px 8px 4px" }, children: "Global" }), globals.map((item) => (_jsxs("button", { type: "button", className: "sm-picker__item", onClick: () => onInsertGlobal(item), children: [_jsx("span", { className: "sm-picker__icon", children: "\u26D3" }), _jsxs("span", { style: { minWidth: 0 }, children: [_jsx("span", { style: { display: "block", fontWeight: 600 }, children: item.title }), _jsx("span", { style: { display: "block", fontSize: "var(--text-sm)", color: "var(--text-muted)" }, children: "Global \u00B7 edited in one place" })] })] }, `global-${item.name}`)))] })), sections.length === 0 && globals.length === 0 && !custom && (_jsx("p", { style: { color: "var(--text-muted)", padding: 10 }, children: "This zone doesn't accept insertions." }))] }) }));
40
+ const panel = useRef(null);
41
+ const [place, setPlace] = useState(() => firstPlace(at));
42
+ // Measure the panel's full height (scrollHeight ignores a max-height already set), then place it.
43
+ useLayoutEffect(() => {
44
+ const el = panel.current;
45
+ if (!el)
46
+ return;
47
+ setPlace(placePicker(at, el.scrollHeight + 2, { width: window.innerWidth, height: window.innerHeight }));
48
+ }, [at]);
49
+ return (_jsx("div", { style: { position: "fixed", inset: 0, zIndex: 60 }, onClick: onClose, children: _jsxs("div", { ref: panel, className: "sm-picker", onClick: (e) => e.stopPropagation(), style: { position: "absolute", overflowY: "auto", ...place }, children: [_jsxs(SectionLabel, { caps: true, style: { padding: "6px 8px 8px" }, children: ["Add to ", at.zone] }), sections.map((section) => (_jsxs("button", { type: "button", className: "sm-picker__item", onClick: () => onInsert(section), children: [_jsx("span", { className: "sm-picker__icon", children: section.icon ?? "▤" }), _jsxs("span", { style: { minWidth: 0 }, children: [_jsx("span", { style: { display: "block", fontWeight: 600 }, children: section.title }), section.description && (_jsx("span", { style: { display: "block", fontSize: "var(--text-sm)", color: "var(--text-muted)" }, children: section.description }))] })] }, section.name))), custom && (_jsxs("button", { type: "button", className: "sm-picker__item", onClick: onInsertCustom, children: [_jsx("span", { className: "sm-picker__icon", children: "\u25A6" }), _jsxs("span", { style: { minWidth: 0 }, children: [_jsx("span", { style: { display: "block", fontWeight: 600 }, children: registry.custom.title }), _jsx("span", { style: { display: "block", fontSize: "var(--text-sm)", color: "var(--text-muted)" }, children: registry.custom.description })] })] })), globals.length > 0 && (_jsxs(_Fragment, { children: [_jsx(SectionLabel, { caps: true, style: { padding: "8px 8px 4px" }, children: "Site" }), globals.map((item) => (_jsxs("button", { type: "button", className: "sm-picker__item", onClick: () => onInsertGlobal(item), children: [_jsx("span", { className: "sm-picker__icon", children: "\u26D3" }), _jsxs("span", { style: { minWidth: 0 }, children: [_jsx("span", { style: { display: "block", fontWeight: 600 }, children: item.title }), _jsx("span", { style: { display: "block", fontSize: "var(--text-sm)", color: "var(--text-muted)" }, children: "Global \u00B7 edited in one place" })] })] }, `global-${item.name}`)))] })), sections.length === 0 && globals.length === 0 && !custom && (_jsx("p", { style: { color: "var(--text-muted)", padding: 10 }, children: "This zone doesn't accept insertions." }))] }) }));
20
50
  }
@@ -17,6 +17,6 @@ export function InspectorFields({ groups, values, render }) {
17
17
  const entries = visibleEntries(g.entries, values);
18
18
  if (entries.length === 0)
19
19
  return null;
20
- return (_jsxs("div", { className: "sm-inspector__group", children: [g.title && _jsx(SectionLabel, { mono: true, children: g.title }), entries.map((e) => render(e, { emptyLabel: e.ns === "styles" ? "Default" : undefined, scope }))] }, g.title ?? i));
20
+ return (_jsxs("div", { className: "sm-inspector__group", children: [g.title && _jsx(SectionLabel, { caps: true, children: g.title }), entries.map((e) => render(e, { emptyLabel: e.ns === "styles" ? "Default" : undefined, scope }))] }, g.title ?? i));
21
21
  }) }));
22
22
  }
@@ -10,6 +10,6 @@ function OutlineZone({ zone, locked, canTake, nodes, selectedId, onSelect, onAdd
10
10
  count: locked ? 0 : nodes.length,
11
11
  onDrop: (from, to) => onMoveTo(nodes[from].node.id, to),
12
12
  });
13
- return (_jsxs("div", { style: { display: "flex", flexDirection: "column", gap: 4 }, children: [_jsxs(SectionLabel, { mono: true, children: [zone, locked && " · locked"] }), nodes.length === 0 && (_jsxs("div", { className: cx("sm-outline-row", "sm-outline-row--empty", canTake && "sm-outline-row--clickable"), onClick: () => { if (canTake)
14
- onAddFirst(zone); }, children: [_jsx("span", { className: "sm-outline-row__label", children: "Empty" }), canTake && _jsx("span", { className: "sm-outline-row__add", children: "+ Add" })] })), locked && nodes.map(({ node, label }) => (_jsxs("div", { className: "sm-outline-row sm-outline-row--locked sm-outline-row--clickable", onClick: () => onSelect(node.id), children: [_jsx("span", { className: "sm-outline-row__label", children: label }), _jsx("span", { className: "sm-outline-row__note", children: "Locked" })] }, node.id))), !locked && nodes.length > 0 && (_jsx("div", { ref: sortable.containerRef, style: { display: "flex", flexDirection: "column", gap: 4 }, children: nodes.map(({ node, label }, i) => (_jsxs("div", { onClick: () => onSelect(node.id), style: sortable.rowStyle(i), className: cx("sm-outline-row", "sm-outline-row--clickable", "sm-outline-row--grip", node.id === selectedId && "sm-outline-row--selected", sortable.rowClass(i)), children: [_jsx(Grip, { ...sortable.handleProps(i) }), _jsx("span", { className: "sm-outline-row__label", children: label }), _jsx(IconButton, { label: "Remove", size: 22, onClick: (e) => { e.stopPropagation(); onRemove(node.id); }, children: "\u00D7" })] }, node.id))) }))] }));
13
+ return (_jsxs("div", { style: { display: "flex", flexDirection: "column", gap: 4 }, children: [_jsxs(SectionLabel, { caps: true, children: [zone, locked && " · locked"] }), nodes.length === 0 && (_jsxs("div", { className: cx("sm-outline-row", "sm-outline-row--empty", canTake && "sm-outline-row--clickable"), onClick: () => { if (canTake)
14
+ onAddFirst(zone); }, children: [_jsx("span", { className: "sm-outline-row__label", children: "Empty" }), canTake && _jsx("span", { className: "sm-outline-row__add", children: "+ Add" })] })), locked && nodes.map(({ node, label }) => (_jsxs("div", { className: "sm-outline-row sm-outline-row--locked sm-outline-row--clickable", "data-testid": "outline-row", onClick: () => onSelect(node.id), children: [_jsx("span", { className: "sm-outline-row__label", children: label }), _jsx("span", { className: "sm-outline-row__note", children: "Locked" })] }, node.id))), !locked && nodes.length > 0 && (_jsx("div", { ref: sortable.containerRef, style: { display: "flex", flexDirection: "column", gap: 4 }, children: nodes.map(({ node, label }, i) => (_jsxs("div", { onClick: () => onSelect(node.id), style: sortable.rowStyle(i), "data-testid": "outline-row", className: cx("sm-outline-row", "sm-outline-row--clickable", "sm-outline-row--grip", node.id === selectedId && "sm-outline-row--selected", sortable.rowClass(i)), children: [_jsx(Grip, { ...sortable.handleProps(i) }), _jsx("span", { className: "sm-outline-row__label", children: label }), _jsx(IconButton, { label: "Remove", size: 22, onClick: (e) => { e.stopPropagation(); onRemove(node.id); }, children: "\u00D7" })] }, node.id))) }))] }));
15
15
  }