smoodly 0.0.62 → 0.0.63

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 (61) hide show
  1. package/dist/admin/client-ops.js +1 -1
  2. package/dist/admin/drift.js +3 -1
  3. package/dist/admin/editor/EditorView.js +65 -9
  4. package/dist/admin/editor/InsertPicker.d.ts +8 -27
  5. package/dist/admin/editor/InsertPicker.js +56 -47
  6. package/dist/admin/editor/SamplePreview.d.ts +7 -0
  7. package/dist/admin/editor/SamplePreview.js +86 -0
  8. package/dist/admin/editor/SectionIcon.d.ts +4 -0
  9. package/dist/admin/editor/SectionIcon.js +8 -0
  10. package/dist/admin/editor/StructureView.d.ts +2 -2
  11. package/dist/admin/editor/StructureView.js +2 -1
  12. package/dist/admin/editor/canvas-address.d.ts +2 -0
  13. package/dist/admin/editor/canvas-address.js +3 -1
  14. package/dist/admin/editor/picker-rows.d.ts +33 -0
  15. package/dist/admin/editor/picker-rows.js +38 -0
  16. package/dist/admin/editor/sample-frame.d.ts +36 -0
  17. package/dist/admin/editor/sample-frame.js +58 -0
  18. package/dist/admin/editor/sample-picks.d.ts +34 -0
  19. package/dist/admin/editor/sample-picks.js +49 -0
  20. package/dist/admin/editor/view-memory.d.ts +5 -0
  21. package/dist/admin/editor/view-memory.js +19 -0
  22. package/dist/admin/files.generated.js +1 -1
  23. package/dist/admin/forms/MediaWidget.d.ts +1 -1
  24. package/dist/admin/forms/MediaWidget.js +10 -2
  25. package/dist/admin/ops.d.ts +5 -0
  26. package/dist/admin/shell/GlobalForm.js +1 -1
  27. package/dist/admin/shell/GlobalList.js +1 -1
  28. package/dist/admin/shell/NavBar.js +1 -1
  29. package/dist/admin/shell/PagesList.js +4 -4
  30. package/dist/admin/shell/console-links.js +1 -1
  31. package/dist/admin/shell/health-list.js +1 -1
  32. package/dist/admin/tree-ops.d.ts +11 -4
  33. package/dist/admin/tree-ops.js +28 -17
  34. package/dist/admin/ui/theme.js +32 -13
  35. package/dist/admin/validate.js +3 -2
  36. package/dist/api-client.js +12 -2
  37. package/dist/astro/middleware.js +2 -0
  38. package/dist/astro/shell.d.ts +3 -0
  39. package/dist/astro/shell.js +8 -1
  40. package/dist/canvas-marker.d.ts +5 -0
  41. package/dist/canvas-marker.js +18 -6
  42. package/dist/conditions.js +5 -2
  43. package/dist/media.d.ts +15 -2
  44. package/dist/media.js +68 -10
  45. package/dist/next/page-renderer.js +12 -5
  46. package/dist/resolve.d.ts +6 -1
  47. package/dist/resolve.js +7 -7
  48. package/dist/sample-fill.d.ts +40 -0
  49. package/dist/sample-fill.js +145 -0
  50. package/dist/sample-tree.d.ts +30 -0
  51. package/dist/sample-tree.js +76 -0
  52. package/dist/site.d.ts +13 -0
  53. package/dist/site.js +10 -1
  54. package/dist/version.d.ts +1 -1
  55. package/dist/version.js +1 -1
  56. package/docs/content-modeling/sections.md +19 -3
  57. package/docs/content-modeling/zones.md +2 -2
  58. package/docs/getting-started/working-together.md +1 -1
  59. package/docs/globals/defining.md +1 -1
  60. package/docs/globals/header-and-footer.md +1 -1
  61. package/package.json +1 -1
@@ -1,6 +1,6 @@
1
1
  const GROUPS = {
2
2
  pages: ["list", "get", "create", "saveDraft", "publish", "unpublish", "discardDraft", "delete", "rename", "move", "setTemplate", "addLocale", "removeLocale", "listVersions", "restore"],
3
- entries: ["list", "page", "distinct", "get", "create", "save", "setStatus", "discardDraft", "delete", "move", "addLocale", "removeLocale", "listVersions", "getVersion", "restore"],
3
+ entries: ["list", "page", "distinct", "samplePicks", "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", "update", "delete", "usage"],
6
6
  members: ["list", "me"],
@@ -1,4 +1,5 @@
1
1
  import { optionValues } from "../fields.js";
2
+ import { isImagePlaceholder } from "../media.js";
2
3
  import { isHidden } from "../conditions.js";
3
4
  import { allowedOf } from "../toolset.js";
4
5
  import { CUSTOM_NODE_TYPE } from "../custom/schema.js";
@@ -38,7 +39,8 @@ export function fits(d, value) {
38
39
  const allowed = optionValues(d.options);
39
40
  return Array.isArray(value) && value.every((v) => typeof v === "string" && allowed.includes(v));
40
41
  }
41
- case "image":
42
+ // A fresh node's image placeholder is a value of an image field (spec 2026-11-14 filled samples), of no other.
43
+ case "image": return isObject(value) && (typeof value.asset === "string" || isImagePlaceholder(value));
42
44
  case "video":
43
45
  case "file": return isObject(value) && typeof value.asset === "string";
44
46
  case "ref": return typeof value === "string";
@@ -44,13 +44,14 @@ import { enableDraft } from "./preview-grant.js";
44
44
  import { CanvasOverlay } from "./CanvasOverlay.js";
45
45
  import { InlineRichtext } from "./InlineRichtext.js";
46
46
  import { InsertPicker } from "./InsertPicker.js";
47
+ import { insertStands, loadPicks, picksCache } from "./sample-picks.js";
47
48
  import { customNode, customNodeLabel, customSection } from "../custom-section.js";
48
49
  import { CUSTOM_NODE_TYPE } from "../../custom/index.js";
49
50
  import { NoticePane } from "./NoticePane.js";
50
51
  import { Outline } from "./Outline.js";
51
52
  import { StructureView } from "./StructureView.js";
52
53
  import { cardPreview } from "./card-preview.js";
53
- import { rememberView, rememberedView } from "./view-memory.js";
54
+ import { rememberFilled, rememberView, rememberedFilled, rememberedView } from "./view-memory.js";
54
55
  import { createHistory } from "./history.js";
55
56
  import { takesUndoKeys, undoKeyOf } from "./undo-keys.js";
56
57
  import { canPaste, clipOf, clipboardSpace, pastedNode, readClip, subscribeClip, writeClip } from "./section-clipboard.js";
@@ -123,6 +124,7 @@ export function EditorView({ ops, registry, id }) {
123
124
  const inspectorRef = useRef(null);
124
125
  // The session hook is created after `mutate`; the autosave and the bridge reach it through this ref.
125
126
  const inlineRef = useRef(null);
127
+ /** The open insert picker: where the pick lands. */
126
128
  const [picker, setPicker] = useState(null);
127
129
  const [tab, setTab] = useState("Content");
128
130
  /** Canvas or Structure (spec 2026-10-19 editor at launch §1): one tree, two renderers; remembered per browser. */
@@ -162,6 +164,17 @@ export function EditorView({ ops, registry, id }) {
162
164
  const scrollerRef = useRef(null);
163
165
  const treeRef = useRef(null);
164
166
  const registryRef = useRef(registry);
167
+ // A fresh node's reference picks and whether the service takes an image placeholder (spec 2026-11-14 filled
168
+ // samples): asked once per locale for this editor's life, on the first picker open, insert or locked-zone fill.
169
+ const picksRef = useRef(null);
170
+ // Counts the draft's loads and the editor's leaving: an insert that waited across one is dropped.
171
+ const loadCountRef = useRef(0);
172
+ useEffect(() => () => { loadCountRef.current++; }, []);
173
+ const picksFor = useCallback((forLocale) => {
174
+ if (picksRef.current?.ops !== ops)
175
+ picksRef.current = { ops, cache: picksCache() };
176
+ return picksRef.current.cache.get(forLocale, () => loadPicks(ops, registryRef.current.sections, forLocale));
177
+ }, [ops]);
165
178
  registryRef.current = registry;
166
179
  // The tree in memory belongs to the locale it was loaded for; a switch
167
180
  // flushes first (switchLocale), so the ref and the tree always agree.
@@ -253,6 +266,7 @@ export function EditorView({ ops, registry, id }) {
253
266
  let cancelled = false;
254
267
  (async () => {
255
268
  // A new locale (or a reload) is a new canvas: nothing from the previous one may survive.
269
+ loadCountRef.current++;
256
270
  setReady(false);
257
271
  setTree(null);
258
272
  treeRef.current = null;
@@ -318,7 +332,16 @@ export function EditorView({ ops, registry, id }) {
318
332
  // effect: a load runs for a new page, locale or reload, nothing else.
319
333
  const reg = registryRef.current;
320
334
  const template = reg.pages.find((p) => p.name === result.data.record.template);
321
- const draft = fillLockedZones(result.data.draft, template?.zones ?? {}, reg.sections);
335
+ // A zone that will be filled waits for the picks, so its references and its placeholder are the picker's; any
336
+ // other load asks nothing.
337
+ const stored = result.data.draft;
338
+ let draft = stored;
339
+ if (fillLockedZones(stored, template?.zones ?? {}, reg.sections) !== stored) {
340
+ const fresh = await picksFor(locale);
341
+ if (cancelled)
342
+ return;
343
+ draft = fillLockedZones(stored, template?.zones ?? {}, reg.sections, {}, fresh);
344
+ }
322
345
  treeLocaleRef.current = locale;
323
346
  setTree(draft);
324
347
  treeRef.current = draft;
@@ -655,7 +678,8 @@ export function EditorView({ ops, registry, id }) {
655
678
  if (!tree || !globalIds || !record)
656
679
  return;
657
680
  const template = registryRef.current.pages.find((p) => p.name === record.template);
658
- const filled = fillLockedZones(tree, template?.zones ?? {}, registryRef.current.sections, globalIds);
681
+ // The answer in hand, if any: the load already waited for it where a section fills a zone.
682
+ const filled = fillLockedZones(tree, template?.zones ?? {}, registryRef.current.sections, globalIds, picksRef.current?.cache.known(locale));
659
683
  // Saved like the fill on load, and like it not an edit of the editor's (editedRef).
660
684
  if (filled !== tree) {
661
685
  setTree(filled);
@@ -684,8 +708,40 @@ export function EditorView({ ops, registry, id }) {
684
708
  const fs = findingsAt.get(nodeId);
685
709
  return !fs ? undefined : fs.some(isBroken) ? "broken" : "orphan";
686
710
  };
687
- const doInsert = (zone, index, section) => {
688
- const node = nodeFromSample(section, newNodeId(section.name));
711
+ const pickerOpen = picker !== null;
712
+ useEffect(() => { if (pickerOpen)
713
+ void picksFor(locale); }, [pickerOpen, locale, picksFor]);
714
+ // An insert waits for the picks (asked when the picker opened, so they are usually there). One at a time: a second
715
+ // click while the first waits inserts nothing, and an answer that lands on another locale's canvas is dropped.
716
+ const insertingRef = useRef(false);
717
+ const pickerRef = useRef(picker);
718
+ pickerRef.current = picker;
719
+ const idRef = useRef(id);
720
+ idRef.current = id;
721
+ /** Where the editor stands now, as an insert's answer is checked against (sample-picks.ts insertStands). */
722
+ const standing = (fromPicker) => ({ page: idRef.current, locale: treeLocaleRef.current, load: loadCountRef.current, picker: fromPicker ? pickerRef.current : null });
723
+ const [filled, setFilled] = useState(() => rememberedFilled());
724
+ const filledRef = useRef(filled);
725
+ filledRef.current = filled;
726
+ const changeFilled = (next) => { setFilled(next); rememberFilled(next); };
727
+ const doInsert = async (zone, index, section, fromPicker = true) => {
728
+ if (insertingRef.current)
729
+ return;
730
+ insertingRef.current = true;
731
+ const asked = standing(fromPicker);
732
+ let fresh;
733
+ try {
734
+ fresh = await picksFor(asked.locale);
735
+ }
736
+ finally {
737
+ insertingRef.current = false;
738
+ }
739
+ // The editor moved on while the answer was on its way: nothing is inserted behind its back.
740
+ if (!treeRef.current || !insertStands(asked, standing(fromPicker)))
741
+ return;
742
+ // The switch is read when the node is built: off, neither the sample nor generated values (spec 2026-11-14 filled
743
+ // samples). It is the picker's: a gesture with no modal (the outline's "add first") always inserts filled.
744
+ const node = nodeFromSample(section, newNodeId(section.name), { picks: fresh.picks, images: fresh.images, empty: fromPicker && !filledRef.current });
689
745
  mutate((t) => insertNode(t, zone, index, node), { focus: node.id });
690
746
  setPicker(null);
691
747
  select(node.id);
@@ -1068,7 +1124,7 @@ export function EditorView({ ops, registry, id }) {
1068
1124
  return {
1069
1125
  ...n,
1070
1126
  label: global ? global.title : n.label,
1071
- icon: section?.icon ?? (global ? "⛓" : "▤"),
1127
+ ...(section?.icon ? { icon: section.icon } : global ? { icon: "⛓" } : {}),
1072
1128
  preview: global ? { text: null, image: null } : cardPreview(n.node, section),
1073
1129
  global: global !== null,
1074
1130
  };
@@ -1093,7 +1149,7 @@ export function EditorView({ ops, registry, id }) {
1093
1149
  ] }) }), right: _jsxs(_Fragment, { children: [(touched || saveState === "error" || saveState === "refused") && _jsx(SaveIndicator, { state: saveState, pending: dirty }), ready && tree && (_jsxs(_Fragment, { children: [_jsx(IconButton, { label: "Undo", disabled: !canUndo || !!stale, onClick: doUndo, children: _jsx(Undo2, { size: 16, strokeWidth: 1.5, "aria-hidden": "true" }) }), _jsx(IconButton, { label: "Redo", disabled: !canRedo || !!stale, onClick: doRedo, children: _jsx(Redo2, { size: 16, strokeWidth: 1.5, "aria-hidden": "true" }) })] })), ready && tree && (_jsx(Segmented, { options: ["canvas", "structure"], value: view, onChange: switchView, className: "sm-seg--view", label: (o) => o === "canvas"
1094
1150
  ? _jsx(AppWindow, { size: 14, strokeWidth: 1.5, "aria-hidden": "true" })
1095
1151
  : _jsx(Rows3, { size: 14, strokeWidth: 1.5, "aria-hidden": "true" }), option: (o) => ({ title: o === "canvas" ? "Canvas" : "Structure", "aria-label": o === "canvas" ? "Canvas" : "Structure" }) })), 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 }), _jsx(DriftNotice, { draft: findings, live: liveFindings }), 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: versionAddress(siteUrl, versionId) }) })), _jsxs("div", { className: "sm-editor", inert: stale ? true : undefined, children: [_jsxs("div", { className: "sm-canvas", children: [(banner || diagnosis || crashedLines.length > 0) && (_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 ?? [] })] })), diagnosis && _jsx(CanvasWarning, { cause: diagnosis.cause, path: diagnosis.path }), crashedLines.length > 0 && (_jsx(BannerBox, { tone: "warn", children: crashedLines.map((line, i) => _jsx("p", { children: line }, i)) }))] })), _jsxs("div", { className: cx("sm-canvas__scroll", view === "structure" && "sm-canvas__scroll--structure"), ref: scrollerRef, onClick: (e) => { if (e.target === e.currentTarget)
1096
- setSelectedId(null); }, children: [view === "structure" && !(ready && tree) && _jsx("div", { className: "sm-canvas__loading", children: "Loading canvas\u2026" }), view === "structure" && ready && tree && (_jsx(StructureView, { zones: structureZones, selectedId: selectedId, onSelect: select, onMoveTo: doMoveTo, onRemove: doRemove, onClearZone: clearZoneAsked, cardActions: cardActions, zoneActions: zoneActions, onAdd: (zone, index, x, y) => setPicker({ zone, index, x: x - 150, y: y + 12 }) })), _jsxs("div", { className: cx("sm-page", view === "structure" && "sm-canvas__hidden"), style: { overflow: "hidden" }, children: [iframeSrc ? (_jsx("iframe", { ref: iframeRef, src: iframeSrc, title: "Page canvas", onLoad: () => { onFrameLoad(); setFrameLoads((n) => n + 1); }, 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, onCopy: doCopy, onRemove: doRemove }), inline.richtext && (() => {
1152
+ setSelectedId(null); }, children: [view === "structure" && !(ready && tree) && _jsx("div", { className: "sm-canvas__loading", children: "Loading canvas\u2026" }), view === "structure" && ready && tree && (_jsx(StructureView, { zones: structureZones, selectedId: selectedId, onSelect: select, onMoveTo: doMoveTo, onRemove: doRemove, onClearZone: clearZoneAsked, cardActions: cardActions, zoneActions: zoneActions, onAdd: (zone, index) => setPicker({ zone, index }) })), _jsxs("div", { className: cx("sm-page", view === "structure" && "sm-canvas__hidden"), style: { overflow: "hidden" }, children: [iframeSrc ? (_jsx("iframe", { ref: iframeRef, src: iframeSrc, title: "Page canvas", onLoad: () => { onFrameLoad(); setFrameLoads((n) => n + 1); }, 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) => setPicker({ zone: p.zone, index: p.index }), onMove: doMove, onDuplicate: doDuplicate, onCopy: doCopy, onRemove: doRemove }), inline.richtext && (() => {
1097
1153
  const { session } = inline.richtext;
1098
1154
  const item = globalItemOf(session.node);
1099
1155
  const node = !item && tree ? findNode(tree, session.node) : null;
@@ -1135,8 +1191,8 @@ export function EditorView({ ops, registry, id }) {
1135
1191
  }, onPublish: () => { void doPublishGlobal(selectedItem.name); }, onDiscard: () => { void doDiscardGlobal(selectedItem.name); }, onUnpublish: () => { void doUnpublishGlobal(selectedItem.name); } }))] }))] }), view === "canvas" && (_jsx("div", { className: "sm-card", children: _jsx(Outline, { selectedId: selectedId, onSelect: select, onMoveTo: doMoveTo, onRemove: doRemove, zones: outlineZones, onClearZone: clearZoneAsked, cardActions: cardActions, onAddFirst: (zone) => {
1136
1192
  const first = allowedSections(policyOf(zone), registry.sections)[0];
1137
1193
  if (first)
1138
- doInsert(zone, 0, first);
1139
- } }) }))] })] }), picker && (_jsx(InsertPicker, { at: picker, policy: policyOf(picker.zone), registry: registry, globalIds: globalIds ?? {}, paste: clip ? { title: clip.title, check: pasteCheck(picker.zone), onPaste: () => doPaste(picker.zone, picker.index) } : undefined, onInsert: (section) => doInsert(picker.zone, picker.index, section), onInsertGlobal: (item) => doInsertGlobal(picker.zone, picker.index, item), onInsertCustom: () => doInsertCustom(picker.zone, picker.index), onClose: () => setPicker(null) }))] }));
1194
+ void doInsert(zone, 0, first, false);
1195
+ } }) }))] })] }), picker && (_jsx(InsertPicker, { zone: picker.zone, siteUrl: siteUrl, policy: policyOf(picker.zone), registry: registry, globalIds: globalIds ?? {}, paste: clip ? { title: clip.title, check: pasteCheck(picker.zone), onPaste: () => doPaste(picker.zone, picker.index) } : undefined, filled: filled, onFilledChange: changeFilled, onInsert: (section) => { void doInsert(picker.zone, picker.index, section); }, onInsertGlobal: (item) => doInsertGlobal(picker.zone, picker.index, item), onInsertCustom: () => doInsertCustom(picker.zone, picker.index), onClose: () => setPicker(null) }))] }));
1140
1196
  }
1141
1197
  /** The selected node's ref field (if any) resolves to its target
1142
1198
  * collection's loaded options — the EntryForm shape, section-scoped. */
@@ -1,38 +1,19 @@
1
1
  import type { ZonePolicy } from "../../zones.ts";
2
2
  import type { AdminRegistry, AdminSection, AdminGlobal } from "../serialize.ts";
3
- import type { PasteCheck } from "./section-clipboard.ts";
4
- /** Where the "+" put the panel: `y` is 12px under the button's centre (EditorView). */
5
- export type PickerAt = {
3
+ import { type PickerPaste } from "./picker-rows.ts";
4
+ export declare function InsertPicker({ zone, siteUrl, policy, registry, globalIds, paste, filled, onFilledChange, onInsert, onInsertGlobal, onInsertCustom, onClose }: {
6
5
  zone: string;
7
- index: number;
8
- x: number;
9
- y: number;
10
- };
11
- export type PickerPlace = {
12
- left: number;
13
- top?: number;
14
- bottom?: number;
15
- maxHeight?: number;
16
- };
17
- /** Where the panel sits, given its natural height and the viewport: under the
18
- * anchor when it fits there, above it when that side has more room, and never
19
- * taller than the room on its side (the panel scrolls). Pure. */
20
- export declare function placePicker(at: PickerAt, height: number, viewport: {
21
- width: number;
22
- height: number;
23
- }): PickerPlace;
24
- export declare function InsertPicker({ at, policy, registry, globalIds, paste, onInsert, onInsertGlobal, onInsertCustom, onClose }: {
25
- at: PickerAt;
6
+ /** The page's address in the edited locale, as the canvas takes it: the samples frame is that page. */
7
+ siteUrl: string;
26
8
  /** The section clipboard's entry, first in the list (spec 2026-10-19 editor at launch §3): disabled with the reason when the zone refuses. */
27
- paste?: {
28
- title: string;
29
- check: PasteCheck;
30
- onPaste(): void;
31
- };
9
+ paste?: PickerPaste;
32
10
  policy: ZonePolicy | undefined;
33
11
  registry: AdminRegistry;
34
12
  /** Global item name → row id; an item without one cannot be placed. */
35
13
  globalIds: Record<string, string>;
14
+ /** The switch's state, held by the editor (remembered per browser); the editor reads it when a section is inserted. */
15
+ filled: boolean;
16
+ onFilledChange(filled: boolean): void;
36
17
  onInsert(section: AdminSection): void;
37
18
  onInsertGlobal(item: AdminGlobal): void;
38
19
  onInsertCustom(): void;
@@ -1,50 +1,59 @@
1
1
  "use client";
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";
11
- import { allowedSections, allowedGlobals } from "../tree-ops.js";
12
- import { allowsCustom } from "../custom-section.js";
13
- import { SectionLabel } from "../ui/primitives.js";
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) {
32
- if (typeof window === "undefined")
33
- return { left: at.x, top: at.y };
34
- return placePicker(at, 0, { width: window.innerWidth, height: window.innerHeight });
35
- }
36
- export function InsertPicker({ at, policy, registry, globalIds, paste, onInsert, onInsertGlobal, onInsertCustom, onClose }) {
37
- const sections = allowedSections(policy, registry.sections);
38
- const globals = allowedGlobals(policy, registry.globals, globalIds);
39
- const custom = allowsCustom(policy);
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)
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ // The insert picker a "+" opens (spec 2026-11-14 add section previews): a modal with the zone's rows on the left —
4
+ // the clipboard's Paste, the sections the zone's policy allows, the Custom section where the zone allows it (spec
5
+ // 2026-09-14 §6), then the global items that can be placed there — and on the right the current row drawn by the
6
+ // site itself from its sample (SamplePreview). A click on a row inserts, as it always did; hovering a row, or
7
+ // stepping to it with the arrow keys, only moves the picture. A zone that takes nothing says so and has no frame.
8
+ // Under the rows the "Insert filled" switch (spec 2026-11-14 filled samples): off, a section arrives with neither
9
+ // its sample nor generated values. The picture never follows it: it always shows the filled section.
10
+ import { useRef, useState } from "react";
11
+ import { Modal } from "../ui/Modal.js";
12
+ import { cx, SectionLabel, Switch } from "../ui/primitives.js";
13
+ import { samplesAddress } from "./canvas-address.js";
14
+ import { firstRow, pickerRows, stepRow } from "./picker-rows.js";
15
+ import { SamplePreview } from "./SamplePreview.js";
16
+ import { SectionIcon } from "./SectionIcon.js";
17
+ /** Below this window width the picture would be too small to read: the modal is the list alone, and no frame loads. */
18
+ const PANE_MIN_WINDOW = 820;
19
+ export function InsertPicker({ zone, siteUrl, policy, registry, globalIds, paste, filled, onFilledChange, onInsert, onInsertGlobal, onInsertCustom, onClose }) {
20
+ const rows = pickerRows({ policy, registry, globalIds, paste });
21
+ const [current, setCurrent] = useState(() => firstRow(rows));
22
+ const [wide] = useState(() => typeof window === "undefined" || window.innerWidth >= PANE_MIN_WINDOW);
23
+ const list = useRef(null);
24
+ const row = rows[current];
25
+ // Something to insert: a zone that takes nothing (a refused paste alone included) gets no picture.
26
+ const insertable = rows.some((r) => !r.disabled);
27
+ const pane = wide && insertable;
28
+ const pick = (r) => {
29
+ if (r.disabled)
30
+ return;
31
+ if (r.kind === "paste")
32
+ paste?.onPaste();
33
+ else if (r.kind === "section")
34
+ onInsert(r.section);
35
+ else if (r.kind === "custom")
36
+ onInsertCustom();
37
+ else
38
+ onInsertGlobal(r.item);
39
+ };
40
+ const onKeyDown = (e) => {
41
+ if (e.key !== "ArrowDown" && e.key !== "ArrowUp")
46
42
  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] }), paste && (_jsxs("button", { type: "button", className: "sm-picker__item sm-picker__item--paste", disabled: !paste.check.ok, title: paste.check.ok ? undefined : paste.check.reason, onClick: paste.onPaste, children: [_jsx("span", { className: "sm-picker__icon", children: "\u2398" }), _jsxs("span", { style: { minWidth: 0 }, children: [_jsxs("span", { style: { display: "block", fontWeight: 600 }, children: ["Paste \"", paste.title, "\""] }), _jsx("span", { style: { display: "block", fontSize: "var(--text-sm)", color: "var(--text-muted)" }, children: paste.check.ok ? "From the clipboard" : paste.check.reason })] })] })), 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: "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." }))] }) }));
43
+ e.preventDefault();
44
+ const next = stepRow(rows, current, e.key === "ArrowDown" ? 1 : -1);
45
+ setCurrent(next);
46
+ list.current?.querySelectorAll(".sm-picker__item")[next]?.focus();
47
+ };
48
+ const firstGlobal = rows.findIndex((r) => r.kind === "global");
49
+ const empty = !rows.some((r) => r.kind !== "paste");
50
+ // The switch speaks for the section rows and the Custom section; Paste and a global item carry their own content.
51
+ const switchable = rows.some((r) => !r.disabled && (r.kind === "section" || r.kind === "custom"));
52
+ return (_jsx(Modal, { title: `Add to ${zone}`, width: pane ? 980 : 380, height: pane ? "min(640px, 85vh)" : undefined, flush: true, onClose: onClose, children: _jsxs("div", { className: cx("sm-insert", pane && "sm-insert--pane"), onKeyDown: onKeyDown, children: [_jsxs("div", { className: "sm-insert__side", children: [_jsxs("div", { ref: list, className: "sm-picker sm-insert__list", children: [rows.map((r, i) => (_jsxs("div", { style: { display: "contents" }, children: [i === firstGlobal && _jsx(SectionLabel, { caps: true, style: { padding: "8px 8px 4px" }, children: "Global" }), _jsxs("button", { type: "button", autoFocus: i === current, "data-kind": r.kind, className: cx("sm-picker__item", r.kind === "paste" && "sm-picker__item--paste", i === current && "sm-picker__item--active"), disabled: r.disabled, title: r.disabled ? r.description : undefined,
53
+ // The pointer takes the focus with it, as in a menu: the arrow keys then step from the row under it.
54
+ onMouseEnter: (e) => { if (!r.disabled) {
55
+ setCurrent(i);
56
+ e.currentTarget.focus({ preventScroll: true });
57
+ } }, onFocus: () => { if (!r.disabled)
58
+ setCurrent(i); }, onClick: () => pick(r), children: [_jsx("span", { className: "sm-picker__icon", children: _jsx(SectionIcon, { icon: r.icon }) }), _jsxs("span", { style: { minWidth: 0 }, children: [_jsx("span", { style: { display: "block", fontWeight: 600 }, children: r.title }), r.description && (_jsx("span", { style: { display: "block", fontSize: "var(--text-sm)", color: "var(--text-muted)" }, children: r.description }))] })] })] }, r.key))), empty && _jsx("p", { style: { color: "var(--text-muted)", padding: 10 }, children: "This zone doesn't accept insertions." })] }), switchable && (_jsxs("label", { className: "sm-insert__foot", title: "Off: a section is inserted empty, without its sample content", children: [_jsx(Switch, { checked: filled, onChange: onFilledChange, label: "Insert filled" }), _jsx("span", { "aria-hidden": "true", children: "Insert filled" })] }))] }), pane && row && (_jsxs("div", { className: "sm-insert__pane", children: [_jsx(SamplePreview, { src: samplesAddress(siteUrl, zone), nodeId: row.nodeId, fallback: _jsx("span", { className: "sm-insert__icon", children: _jsx(SectionIcon, { icon: row.icon, size: 32 }) }) }), _jsxs("div", { className: "sm-insert__caption", children: [_jsx("span", { style: { display: "block", fontWeight: 600 }, children: row.title }), row.description && _jsx("span", { style: { display: "block", fontSize: "var(--text-sm)", color: "var(--text-muted)" }, children: row.description })] })] }))] }) }));
50
59
  }
@@ -0,0 +1,7 @@
1
+ import { type ReactNode } from "react";
2
+ export declare function SamplePreview({ src, nodeId, fallback }: {
3
+ src: string;
4
+ /** The current row's sample node; null for a row that has none (Paste). */
5
+ nodeId: string | null;
6
+ fallback: ReactNode;
7
+ }): import("react").JSX.Element;
@@ -0,0 +1,86 @@
1
+ "use client";
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ // The picture in the insert picker (spec 2026-11-14 add section previews): ONE frame on the zone's samples address
4
+ // (the site's own draft page with the zone's rows drawn from their samples), not interactive, scaled to the pane and
5
+ // cropped to the current row's node. The frame is same-origin, so its document is read directly: no bridge, nothing
6
+ // posted. Until it has loaded, and for a row the site drew no node for, the pane shows `fallback`.
7
+ import { useEffect, useLayoutEffect, useRef, useState } from "react";
8
+ import { SAMPLE_VIEWPORT, isSmoodlyDraft, isolationCss, sampleBox, sampleNode } from "./sample-frame.js";
9
+ export function SamplePreview({ src, nodeId, fallback }) {
10
+ const pane = useRef(null);
11
+ const frame = useRef(null);
12
+ const [width, setWidth] = useState(0);
13
+ // Counts the frame's loads: a document Smoodly rendered as a draft, with its layout hidden. 0 = none yet.
14
+ const [loaded, setLoaded] = useState(0);
15
+ const [crop, setCrop] = useState(null);
16
+ // The pane's width decides the scale.
17
+ useLayoutEffect(() => {
18
+ const el = pane.current;
19
+ if (!el)
20
+ return;
21
+ // The content box: the pane's padding leaves the crop's shadow room (2026-11-14).
22
+ const measure = () => { const cs = getComputedStyle(el); setWidth(el.clientWidth - parseFloat(cs.paddingLeft) - parseFloat(cs.paddingRight)); };
23
+ measure();
24
+ const observer = new ResizeObserver(measure);
25
+ observer.observe(el);
26
+ return () => observer.disconnect();
27
+ }, []);
28
+ const onLoad = () => {
29
+ const doc = frame.current?.contentDocument;
30
+ if (!doc || !isSmoodlyDraft(doc)) {
31
+ setLoaded(0);
32
+ return;
33
+ }
34
+ // The layout around the samples goes, and the scrollbar with it (the frame is still scrolled from here). As a
35
+ // constructed stylesheet of the frame's own realm: no element of the site's document is written to.
36
+ const Sheet = frame.current?.contentWindow?.CSSStyleSheet;
37
+ if (Sheet) {
38
+ const sheet = new Sheet();
39
+ // `nextjs-portal`: Next's dev indicator, which sits fixed in a corner of the picture in development.
40
+ sheet.replaceSync(`html{overflow:hidden!important}nextjs-portal{display:none!important}${isolationCss(doc)}`);
41
+ doc.adoptedStyleSheets = [...doc.adoptedStyleSheets, sheet];
42
+ }
43
+ setLoaded((n) => n + 1);
44
+ };
45
+ // A frame that finished loading before React attached the handler (a cached document).
46
+ useEffect(() => {
47
+ if (frame.current?.contentDocument?.readyState === "complete" && frame.current.contentWindow?.location.href !== "about:blank")
48
+ onLoad();
49
+ }, []);
50
+ // The current row's node: scroll the frame to it and crop to it, again whenever it or the page around it resizes
51
+ // (images and fonts arrive late).
52
+ useEffect(() => {
53
+ const win = frame.current?.contentWindow;
54
+ const doc = frame.current?.contentDocument;
55
+ if (!loaded || !nodeId || !win || !doc) {
56
+ setCrop(null);
57
+ return;
58
+ }
59
+ const node = sampleNode(doc, nodeId);
60
+ if (!node) {
61
+ setCrop(null);
62
+ return;
63
+ }
64
+ const measure = () => {
65
+ const box = sampleBox(node, win.scrollY);
66
+ win.scrollTo({ top: box.top, behavior: "instant" });
67
+ // A document too short to scroll that far leaves the node lower in the viewport.
68
+ const offset = Math.max(0, Math.round(node.getBoundingClientRect().top));
69
+ const height = Math.min(box.height, SAMPLE_VIEWPORT.height - offset);
70
+ setCrop((was) => (was && was.offset === offset && was.height === height ? was : height > 0 ? { offset, height } : null));
71
+ };
72
+ measure();
73
+ const Observer = win.ResizeObserver ?? ResizeObserver;
74
+ const observer = new Observer(measure);
75
+ observer.observe(node);
76
+ if (doc.body)
77
+ observer.observe(doc.body);
78
+ return () => observer.disconnect();
79
+ }, [loaded, nodeId]);
80
+ const scale = width > 0 ? width / SAMPLE_VIEWPORT.width : 0;
81
+ const shown = crop !== null && scale > 0;
82
+ return (_jsxs("div", { ref: pane, className: "sm-insert__preview", children: [_jsx("div", { className: shown ? "sm-insert__crop sm-insert__crop--shown" : "sm-insert__crop", style: { height: shown ? Math.round(crop.height * scale) : 0, visibility: shown ? "visible" : "hidden" }, children: _jsx("iframe", { ref: frame, src: src, title: "Section preview", "aria-hidden": "true", tabIndex: -1, width: SAMPLE_VIEWPORT.width, height: SAMPLE_VIEWPORT.height, onLoad: onLoad, style: {
83
+ pointerEvents: "none", border: 0, display: "block", position: "absolute", left: 0, top: shown ? -Math.round(crop.offset * scale) : 0,
84
+ width: SAMPLE_VIEWPORT.width, height: SAMPLE_VIEWPORT.height, transform: `scale(${scale})`, transformOrigin: "0 0",
85
+ } }) }), !shown && _jsx("div", { className: "sm-insert__fallback", "data-testid": "sample-fallback", children: fallback })] }));
86
+ }
@@ -0,0 +1,4 @@
1
+ export declare function SectionIcon({ icon, size }: {
2
+ icon?: string | undefined;
3
+ size?: number;
4
+ }): import("react").JSX.Element;
@@ -0,0 +1,8 @@
1
+ "use client";
2
+ import { Fragment as _Fragment, jsx as _jsx } from "react/jsx-runtime";
3
+ // A section's icon (2026-11-14): the schema's own `icon` when it sets one, else Lucide's rectangle-horizontal — the
4
+ // one default for every section (and the Custom section) in the picker and on the Structure cards.
5
+ import { RectangleHorizontal } from "lucide-react";
6
+ export function SectionIcon({ icon, size = 14 }) {
7
+ return icon ? _jsx(_Fragment, { children: icon }) : _jsx(RectangleHorizontal, { size: size, strokeWidth: 1.75, "aria-hidden": "true" });
8
+ }
@@ -3,9 +3,9 @@ import { type ActionMenuItem } from "../ui/ActionMenu.tsx";
3
3
  import type { CardPreview } from "./card-preview.ts";
4
4
  import { cardMenu, type Handlers, type ZoneRows } from "./Outline.tsx";
5
5
  export { cardMenu };
6
- /** A card: the outline's row plus the section's icon from the registry (`icon ?? "▤"`), its preview, and whether it is a placed global item. */
6
+ /** A card: the outline's row plus the section's icon from the registry (none = SectionIcon's default), its preview, and whether it is a placed global item. */
7
7
  export type StructureNode = ZoneRows["nodes"][number] & {
8
- icon: string;
8
+ icon?: string;
9
9
  preview: CardPreview;
10
10
  global: boolean;
11
11
  };
@@ -17,6 +17,7 @@ import { Button, Grip, SectionLabel, cx } from "../ui/primitives.js";
17
17
  import { useSortable } from "../ui/useSortable.js";
18
18
  import { AssetThumb } from "../forms/MediaGrid.js";
19
19
  import { useAssets } from "../forms/assets-context.js";
20
+ import { SectionIcon } from "./SectionIcon.js";
20
21
  import { cardMenu } from "./Outline.js";
21
22
  export { cardMenu };
22
23
  export function StructureView({ zones, ...rest }) {
@@ -39,7 +40,7 @@ function StructureZoneView({ zone, locked, canTake, nodes, removed, note, select
39
40
  return (_jsxs("div", { className: "sm-structure__group", children: [_jsxs("div", { className: "sm-structure__zone", children: [_jsxs(SectionLabel, { caps: true, children: [zone, locked && " · locked"] }), actions.length > 0 && _jsx(ActionMenu, { small: true, label: "Zone actions", items: actions })] }), note && _jsx("p", { className: "sm-outline-note", children: note }), nodes.length === 0 && (_jsxs("div", { className: "sm-structure__empty", children: [_jsx("span", { children: "Empty" }), canTake && add(0)] })), nodes.length > 0 && (_jsx("div", { ref: sortable.containerRef, className: "sm-structure__rows", children: nodes.map(({ node, label, mark, icon, preview, global }, i) => (_jsx("div", { className: cx("sm-structure__row", sortable.rowClass(i)), style: sortable.rowStyle(i), children: _jsxs("div", { role: "button", tabIndex: 0, "data-testid": "structure-card", "data-node": node.id, className: cx("sm-structure__card", node.id === selectedId && "sm-structure__card--selected", locked && "sm-structure__card--locked", mark && `sm-structure__card--${mark}`), onClick: () => onSelect(node.id), onKeyDown: (e) => { if (e.key === "Enter" || e.key === " ") {
40
41
  e.preventDefault();
41
42
  onSelect(node.id);
42
- } }, children: [locked ? _jsx("span", { className: "sm-structure__gripslot", "aria-hidden": "true" }) : _jsx(Grip, { ...sortable.handleProps(i) }), _jsx("span", { className: "sm-structure__icon", "aria-hidden": "true", children: icon }), _jsxs("span", { className: "sm-structure__text", children: [_jsxs("span", { className: "sm-structure__title", children: [label, global && _jsx("span", { className: "sm-structure__badge", children: "global" })] }), global
43
+ } }, children: [locked ? _jsx("span", { className: "sm-structure__gripslot", "aria-hidden": "true" }) : _jsx(Grip, { ...sortable.handleProps(i) }), _jsx("span", { className: "sm-structure__icon", "aria-hidden": "true", children: _jsx(SectionIcon, { icon: icon }) }), _jsxs("span", { className: "sm-structure__text", children: [_jsxs("span", { className: "sm-structure__title", children: [label, global && _jsx("span", { className: "sm-structure__badge", children: "global" })] }), global
43
44
  ? _jsx("span", { className: "sm-structure__preview", children: "Global \u00B7 edited in one place" })
44
45
  : preview.text !== null && _jsx("span", { className: "sm-structure__preview", children: preview.text })] }), !global && preview.image && _jsx(Thumb, { image: preview.image }), _jsx("span", { className: "sm-structure__menu", onClick: stop, onKeyDown: stopActivation, children: _jsx(ActionMenu, { small: true, label: "Section actions", items: cardMenu({ locked, onRemove: () => onRemove(node.id), actions: cardActions?.(node, zone, i) ?? [] }) }) })] }) }, node.id))) })), nodes.length > 0 && canTake && add(nodes.length)] }));
45
46
  }
@@ -5,6 +5,8 @@ export declare function setCanvasMarks(value: "path" | "query"): void;
5
5
  export declare const canvasAddress: (siteUrl: string) => string;
6
6
  /** History's frame (spec 2026-09-13 history §5): a stored version, no bridge. */
7
7
  export declare const versionAddress: (siteUrl: string, versionId: string) => string;
8
+ /** The picker's samples frame (spec 2026-11-14 add section previews): the zone's rows drawn from their samples, no bridge. */
9
+ export declare const samplesAddress: (siteUrl: string, zone: string) => string;
8
10
  /** The site's preview route, from the page data: where the grant goes. */
9
11
  export declare function setPreviewUrl(value: string): void;
10
12
  export declare function previewUrl(): string;
@@ -3,7 +3,7 @@
3
3
  // they mark what they want: in the query, or in the path's last segment where the host strips the query
4
4
  // (Next on Vercel; canvas-marker.ts). Module state, set by the browser entry before the shell mounts: the
5
5
  // admin is one script on one page.
6
- import { markCanvasPath } from "../../canvas-marker.js";
6
+ import { markCanvasPath, SAMPLES_PARAM } from "../../canvas-marker.js";
7
7
  let base = "";
8
8
  let marks = "query";
9
9
  export function setPreviewBase(value) {
@@ -18,6 +18,8 @@ const withQuery = (url, key, value) => `${url}${url.includes("?") ? "&" : "?"}${
18
18
  export const canvasAddress = (siteUrl) => marks === "path" ? markCanvasPath(draftAddress(siteUrl), { editor: true }) : withQuery(draftAddress(siteUrl), "smoodly-editor", "1");
19
19
  /** History's frame (spec 2026-09-13 history §5): a stored version, no bridge. */
20
20
  export const versionAddress = (siteUrl, versionId) => marks === "path" ? markCanvasPath(draftAddress(siteUrl), { version: versionId }) : withQuery(draftAddress(siteUrl), "smoodly-version", versionId);
21
+ /** The picker's samples frame (spec 2026-11-14 add section previews): the zone's rows drawn from their samples, no bridge. */
22
+ export const samplesAddress = (siteUrl, zone) => marks === "path" ? markCanvasPath(draftAddress(siteUrl), { samples: zone }) : withQuery(draftAddress(siteUrl), SAMPLES_PARAM, zone);
21
23
  let url = "";
22
24
  /** The site's preview route, from the page data: where the grant goes. */
23
25
  export function setPreviewUrl(value) { url = value; }
@@ -0,0 +1,33 @@
1
+ import type { ZonePolicy } from "../../zones.ts";
2
+ import type { AdminGlobal, AdminRegistry, AdminSection } from "../serialize.ts";
3
+ import type { PasteCheck } from "./section-clipboard.ts";
4
+ export type PickerPaste = {
5
+ title: string;
6
+ check: PasteCheck;
7
+ onPaste(): void;
8
+ };
9
+ export type PickerRow = {
10
+ key: string;
11
+ kind: "paste" | "section" | "custom" | "global";
12
+ title: string;
13
+ description?: string;
14
+ /** The schema's own icon; none = the default (SectionIcon). */
15
+ icon?: string;
16
+ /** The row's node in the samples frame; null for Paste, whose content is not a sample. */
17
+ nodeId: string | null;
18
+ /** A refused paste: shown, never picked. */
19
+ disabled?: boolean;
20
+ section?: AdminSection;
21
+ item?: AdminGlobal;
22
+ };
23
+ export declare function pickerRows({ policy, registry, globalIds, paste }: {
24
+ policy: ZonePolicy | undefined;
25
+ registry: AdminRegistry;
26
+ globalIds: Record<string, string>;
27
+ paste?: PickerPaste;
28
+ }): PickerRow[];
29
+ /** The row the modal opens on: the first that can be picked and is not Paste (its preview is a card, not a picture);
30
+ * Paste when it is all there is; -1 when nothing can be picked. */
31
+ export declare function firstRow(rows: PickerRow[]): number;
32
+ /** The next row an arrow key lands on: the nearest one in `dir` that can be picked; the current one at an end. */
33
+ export declare function stepRow(rows: PickerRow[], current: number, dir: -1 | 1): number;
@@ -0,0 +1,38 @@
1
+ import { sampleNodeId } from "../../sample-tree.js";
2
+ import { allowedGlobals, allowedSections } from "../tree-ops.js";
3
+ import { allowsCustom } from "../custom-section.js";
4
+ export function pickerRows({ policy, registry, globalIds, paste }) {
5
+ const rows = [];
6
+ if (paste) {
7
+ rows.push({
8
+ key: "paste", kind: "paste", title: `Paste "${paste.title}"`, icon: "⎘", nodeId: null,
9
+ description: paste.check.ok ? "From the clipboard" : paste.check.reason, ...(paste.check.ok ? {} : { disabled: true }),
10
+ });
11
+ }
12
+ for (const section of allowedSections(policy, registry.sections)) {
13
+ rows.push({
14
+ key: `section-${section.name}`, kind: "section", title: section.title, ...(section.icon ? { icon: section.icon } : {}), section,
15
+ nodeId: sampleNodeId("section", section.name),
16
+ });
17
+ }
18
+ if (allowsCustom(policy)) {
19
+ rows.push({ key: "custom", kind: "custom", title: registry.custom.title, nodeId: sampleNodeId("custom") });
20
+ }
21
+ for (const item of allowedGlobals(policy, registry.globals, globalIds)) {
22
+ rows.push({ key: `global-${item.name}`, kind: "global", title: item.title, icon: "⛓", item, nodeId: sampleNodeId("global", item.name) });
23
+ }
24
+ return rows;
25
+ }
26
+ /** The row the modal opens on: the first that can be picked and is not Paste (its preview is a card, not a picture);
27
+ * Paste when it is all there is; -1 when nothing can be picked. */
28
+ export function firstRow(rows) {
29
+ const insertable = rows.findIndex((r) => !r.disabled && r.kind !== "paste");
30
+ return insertable !== -1 ? insertable : rows.findIndex((r) => !r.disabled);
31
+ }
32
+ /** The next row an arrow key lands on: the nearest one in `dir` that can be picked; the current one at an end. */
33
+ export function stepRow(rows, current, dir) {
34
+ for (let i = current + dir; i >= 0 && i < rows.length; i += dir)
35
+ if (!rows[i].disabled)
36
+ return i;
37
+ return current;
38
+ }