smoodly 0.0.61 → 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 (76) hide show
  1. package/dist/admin/client-ops.js +1 -1
  2. package/dist/admin/drift.js +3 -1
  3. package/dist/admin/editor/CanvasOverlay.d.ts +4 -1
  4. package/dist/admin/editor/CanvasOverlay.js +3 -3
  5. package/dist/admin/editor/EditorView.js +65 -9
  6. package/dist/admin/editor/GlobalCanvas.d.ts +27 -0
  7. package/dist/admin/editor/GlobalCanvas.js +159 -0
  8. package/dist/admin/editor/InsertPicker.d.ts +8 -27
  9. package/dist/admin/editor/InsertPicker.js +56 -47
  10. package/dist/admin/editor/SamplePreview.d.ts +7 -0
  11. package/dist/admin/editor/SamplePreview.js +86 -0
  12. package/dist/admin/editor/SectionIcon.d.ts +4 -0
  13. package/dist/admin/editor/SectionIcon.js +8 -0
  14. package/dist/admin/editor/StructureView.d.ts +2 -2
  15. package/dist/admin/editor/StructureView.js +2 -1
  16. package/dist/admin/editor/canvas-address.d.ts +2 -0
  17. package/dist/admin/editor/canvas-address.js +3 -1
  18. package/dist/admin/editor/picker-rows.d.ts +33 -0
  19. package/dist/admin/editor/picker-rows.js +38 -0
  20. package/dist/admin/editor/sample-frame.d.ts +36 -0
  21. package/dist/admin/editor/sample-frame.js +58 -0
  22. package/dist/admin/editor/sample-picks.d.ts +34 -0
  23. package/dist/admin/editor/sample-picks.js +49 -0
  24. package/dist/admin/editor/view-memory.d.ts +5 -0
  25. package/dist/admin/editor/view-memory.js +19 -0
  26. package/dist/admin/files.generated.js +1 -1
  27. package/dist/admin/forms/MediaWidget.d.ts +1 -1
  28. package/dist/admin/forms/MediaWidget.js +10 -2
  29. package/dist/admin/ops.d.ts +5 -0
  30. package/dist/admin/serialize.d.ts +2 -0
  31. package/dist/admin/serialize.js +1 -0
  32. package/dist/admin/shell/AdminApp.js +5 -5
  33. package/dist/admin/shell/GlobalForm.js +80 -7
  34. package/dist/admin/shell/GlobalList.js +1 -1
  35. package/dist/admin/shell/{IconRail.d.ts → NavBar.d.ts} +3 -3
  36. package/dist/admin/shell/NavBar.js +120 -0
  37. package/dist/admin/shell/PagesList.js +4 -4
  38. package/dist/admin/shell/console-links.js +1 -1
  39. package/dist/admin/shell/global-canvas.d.ts +40 -0
  40. package/dist/admin/shell/global-canvas.js +60 -0
  41. package/dist/admin/shell/health-list.js +1 -1
  42. package/dist/admin/tree-ops.d.ts +11 -4
  43. package/dist/admin/tree-ops.js +28 -17
  44. package/dist/admin/ui/primitives.js +1 -1
  45. package/dist/admin/ui/theme.js +128 -101
  46. package/dist/admin/validate.js +3 -2
  47. package/dist/api-client.js +12 -2
  48. package/dist/astro/middleware.js +2 -0
  49. package/dist/astro/shell.d.ts +3 -0
  50. package/dist/astro/shell.js +8 -1
  51. package/dist/canvas-marker.d.ts +5 -0
  52. package/dist/canvas-marker.js +18 -6
  53. package/dist/conditions.js +5 -2
  54. package/dist/media.d.ts +15 -2
  55. package/dist/media.js +68 -10
  56. package/dist/next/page-renderer.js +12 -5
  57. package/dist/resolve.d.ts +6 -1
  58. package/dist/resolve.js +7 -7
  59. package/dist/sample-fill.d.ts +40 -0
  60. package/dist/sample-fill.js +145 -0
  61. package/dist/sample-tree.d.ts +30 -0
  62. package/dist/sample-tree.js +76 -0
  63. package/dist/site.d.ts +13 -0
  64. package/dist/site.js +10 -1
  65. package/dist/version.d.ts +1 -1
  66. package/dist/version.js +1 -1
  67. package/docs/collections/defining.md +2 -2
  68. package/docs/content-modeling/sections.md +19 -3
  69. package/docs/content-modeling/zones.md +3 -3
  70. package/docs/getting-started/deploying.md +1 -1
  71. package/docs/getting-started/working-together.md +1 -1
  72. package/docs/globals/defining.md +1 -1
  73. package/docs/globals/header-and-footer.md +2 -2
  74. package/docs/media/library.md +1 -1
  75. package/package.json +1 -1
  76. package/dist/admin/shell/IconRail.js +0 -109
@@ -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
+ }
@@ -0,0 +1,36 @@
1
+ /** The frame's fixed viewport: a desktop width, and a height a section sized in viewport units cannot outgrow. */
2
+ export declare const SAMPLE_VIEWPORT: {
3
+ readonly width: 1280;
4
+ readonly height: 800;
5
+ };
6
+ export type FrameElement = {
7
+ tagName: string;
8
+ parentElement: FrameElement | null;
9
+ children: ArrayLike<FrameElement>;
10
+ getAttribute(name: string): string | null;
11
+ getBoundingClientRect(): {
12
+ top: number;
13
+ height: number;
14
+ };
15
+ };
16
+ export type FrameDocument = {
17
+ body: FrameElement | null;
18
+ querySelectorAll(selector: string): ArrayLike<FrameElement>;
19
+ };
20
+ /** Smoodly rendered this document as a draft: the marker its renderers write is there. */
21
+ export declare function isSmoodlyDraft(doc: FrameDocument): boolean;
22
+ /** The rules that hide the site's layout around the samples: at every level from the body down to the samples'
23
+ * common parent, everything beside the path (never a style, link or script). What shares the common parent with the
24
+ * samples stays, since that parent is the zone's own container; the body is never one, so there everything else
25
+ * goes. By position, as a stylesheet, and no element is touched: an attribute or an inline style written before
26
+ * the site's own script takes the page over is a hydration mismatch (Next's dev overlay reports it), and a rule
27
+ * also hides what the site adds to the body later. Empty when the document holds no sample. */
28
+ export declare function isolationCss(doc: FrameDocument): string;
29
+ /** A row's node in the frame; null when the site drew none, or drew the crash placeholder in its place. */
30
+ export declare function sampleNode(doc: FrameDocument, id: string): FrameElement | null;
31
+ /** Where the crop sits: the node's top in the document (its viewport top plus the frame's scroll) and its height,
32
+ * capped at the viewport's (a taller section shows its first screen). Whole pixels. */
33
+ export declare function sampleBox(node: FrameElement, scrollY: number): {
34
+ top: number;
35
+ height: number;
36
+ };
@@ -0,0 +1,58 @@
1
+ // The picker's samples frame, read from outside (spec 2026-11-14 add section previews): the frame is the site's own
2
+ // draft page with a zone's samples in place of its content, same-origin, so the admin reads its document directly.
3
+ // It hides the site's layout around the samples (a sticky header would sit over every picture) and crops the frame
4
+ // to one row's node. Pure over the few members of a document it uses, so the unit suite runs it without a DOM.
5
+ import { CRASHED_ATTR, NODE_ATTR } from "../../marks.js";
6
+ import { DRAFT_META_NAME } from "./canvas-diagnosis.js";
7
+ /** The frame's fixed viewport: a desktop width, and a height a section sized in viewport units cannot outgrow. */
8
+ export const SAMPLE_VIEWPORT = { width: 1280, height: 800 };
9
+ const SAMPLE_PREFIX = "sample-";
10
+ const nodesOf = (doc) => Array.from(doc.querySelectorAll(`[${NODE_ATTR}]`));
11
+ const chainOf = (el) => { const out = []; for (let cur = el; cur; cur = cur.parentElement)
12
+ out.push(cur); return out; };
13
+ /** Smoodly rendered this document as a draft: the marker its renderers write is there. */
14
+ export function isSmoodlyDraft(doc) {
15
+ return doc.querySelectorAll(`meta[name="${DRAFT_META_NAME}"]`).length > 0;
16
+ }
17
+ /** The rules that hide the site's layout around the samples: at every level from the body down to the samples'
18
+ * common parent, everything beside the path (never a style, link or script). What shares the common parent with the
19
+ * samples stays, since that parent is the zone's own container; the body is never one, so there everything else
20
+ * goes. By position, as a stylesheet, and no element is touched: an attribute or an inline style written before
21
+ * the site's own script takes the page over is a hydration mismatch (Next's dev overlay reports it), and a rule
22
+ * also hides what the site adds to the body later. Empty when the document holds no sample. */
23
+ export function isolationCss(doc) {
24
+ const body = doc.body;
25
+ const samples = nodesOf(doc).filter((n) => n.getAttribute(NODE_ATTR).startsWith(SAMPLE_PREFIX));
26
+ if (!body || samples.length === 0)
27
+ return "";
28
+ // The sample nodes and everything above them: never hidden.
29
+ const kept = new Set(samples.flatMap(chainOf));
30
+ // The common parent: the deepest element above the first sample that is above them all.
31
+ const above = samples.map((n) => new Set(chainOf(n).slice(1)));
32
+ const common = chainOf(samples[0]).slice(1).find((el) => above.every((set) => set.has(el)));
33
+ if (!common || !chainOf(common).includes(body))
34
+ return "";
35
+ const indexIn = (parent, child) => Array.from(parent.children).indexOf(child) + 1;
36
+ const selectorOf = (el) => (el === body || !el.parentElement ? "html > body" : `${selectorOf(el.parentElement)} > :nth-child(${indexIn(el.parentElement, el)})`);
37
+ const rule = (parent) => {
38
+ const keep = Array.from(parent.children).map((child, i) => (kept.has(child) ? `:not(:nth-child(${i + 1}))` : "")).join("");
39
+ return `${selectorOf(parent)} > :not(style):not(link):not(script)${keep}{display:none!important}`;
40
+ };
41
+ const rules = [];
42
+ if (common === body)
43
+ rules.push(rule(body));
44
+ for (let cur = common; cur !== body && cur.parentElement; cur = cur.parentElement)
45
+ rules.push(rule(cur.parentElement));
46
+ return rules.join("");
47
+ }
48
+ /** A row's node in the frame; null when the site drew none, or drew the crash placeholder in its place. */
49
+ export function sampleNode(doc, id) {
50
+ const node = nodesOf(doc).find((n) => n.getAttribute(NODE_ATTR) === id);
51
+ return node && node.getAttribute(CRASHED_ATTR) === null ? node : null;
52
+ }
53
+ /** Where the crop sits: the node's top in the document (its viewport top plus the frame's scroll) and its height,
54
+ * capped at the viewport's (a taller section shows its first screen). Whole pixels. */
55
+ export function sampleBox(node, scrollY) {
56
+ const rect = node.getBoundingClientRect();
57
+ return { top: Math.max(0, Math.round(rect.top + scrollY)), height: Math.max(0, Math.min(Math.round(rect.height), SAMPLE_VIEWPORT.height)) };
58
+ }
@@ -0,0 +1,34 @@
1
+ import type { AdminOps } from "../ops.ts";
2
+ import type { AdminSection } from "../serialize.ts";
3
+ import { type SamplePicks } from "../../sample-fill.ts";
4
+ /** `images: false`: store no image placeholder. `retry`: the call failed for a reason that may pass (the network, a
5
+ * session), so the next insert asks again; absent, the answer stands for the editor's life. */
6
+ export type PicksAnswer = {
7
+ picks: SamplePicks;
8
+ images: boolean;
9
+ retry?: true;
10
+ };
11
+ /** One operation call for every collection a top-level reference field of these sections targets. It is made even
12
+ * when there are none: the answer is how the admin learns that the service knows the placeholder. */
13
+ export declare function loadPicks(ops: Pick<AdminOps, "entries">, sections: Pick<AdminSection, "fields">[], locale: string): Promise<PicksAnswer>;
14
+ /** One answer per locale for a page editor's life. An answer that may be retried is handed out once and forgotten. */
15
+ export declare function picksCache(): {
16
+ get(locale: string, load: () => Promise<PicksAnswer>): Promise<PicksAnswer>;
17
+ /** The answer already in hand, for a caller that cannot wait. */
18
+ known: (locale: string) => PicksAnswer | undefined;
19
+ };
20
+ /** Where the editor stood when an insert asked for the picks: the page, the locale, which load of the draft, and the
21
+ * picker that was clicked (null for a gesture with no modal). */
22
+ export type InsertAsk = {
23
+ page: string;
24
+ locale: string;
25
+ load: number;
26
+ picker: {
27
+ zone: string;
28
+ index: number;
29
+ } | null;
30
+ };
31
+ /** Whether an insert that waited for the picks may still land (final review 2026-11-14): the same page, locale and
32
+ * load, and the picker it was clicked in still open at the same place. An editor who closed the modal, opened
33
+ * another, switched page or locale, or left, gets nothing inserted behind their back. */
34
+ export declare const insertStands: (asked: InsertAsk, now: InsertAsk) => boolean;
@@ -0,0 +1,49 @@
1
+ import { sampleCollections } from "../../sample-fill.js";
2
+ /** One operation call for every collection a top-level reference field of these sections targets. It is made even
3
+ * when there are none: the answer is how the admin learns that the service knows the placeholder. */
4
+ export async function loadPicks(ops, sections, locale) {
5
+ try {
6
+ const r = await ops.entries.samplePicks(sampleCollections(sections), locale);
7
+ if (!r.ok)
8
+ return r.code === "not_found" ? { picks: {}, images: false } : { picks: {}, images: false, retry: true };
9
+ const picks = {};
10
+ if (r.data && typeof r.data === "object") {
11
+ for (const [collection, ids] of Object.entries(r.data)) {
12
+ if (Array.isArray(ids))
13
+ picks[collection] = ids.filter((id) => typeof id === "string");
14
+ }
15
+ }
16
+ return { picks, images: true };
17
+ }
18
+ catch {
19
+ return { picks: {}, images: false, retry: true };
20
+ }
21
+ }
22
+ /** One answer per locale for a page editor's life. An answer that may be retried is handed out once and forgotten. */
23
+ export function picksCache() {
24
+ const asked = new Map();
25
+ const answers = new Map();
26
+ return {
27
+ get(locale, load) {
28
+ let pending = asked.get(locale);
29
+ if (!pending) {
30
+ pending = load().then((answer) => {
31
+ if (answer.retry)
32
+ asked.delete(locale);
33
+ else
34
+ answers.set(locale, answer);
35
+ return answer;
36
+ });
37
+ asked.set(locale, pending);
38
+ }
39
+ return pending;
40
+ },
41
+ /** The answer already in hand, for a caller that cannot wait. */
42
+ known: (locale) => answers.get(locale),
43
+ };
44
+ }
45
+ /** Whether an insert that waited for the picks may still land (final review 2026-11-14): the same page, locale and
46
+ * load, and the picker it was clicked in still open at the same place. An editor who closed the modal, opened
47
+ * another, switched page or locale, or left, gets nothing inserted behind their back. */
48
+ export const insertStands = (asked, now) => asked.page === now.page && asked.locale === now.locale && asked.load === now.load &&
49
+ (asked.picker === null || (now.picker !== null && now.picker.zone === asked.picker.zone && now.picker.index === asked.picker.index));
@@ -6,4 +6,9 @@ type Storage = {
6
6
  };
7
7
  export declare function rememberedView(storage?: Storage): EditorViewMode;
8
8
  export declare function rememberView(view: EditorViewMode, storage?: Storage): void;
9
+ /** The insert picker's "Insert filled" switch (spec 2026-11-14 filled samples): one setting for the whole browser,
10
+ * not per page or zone. On unless the stored value is exactly "0"; a storage that throws leaves it on. */
11
+ export declare const FILLED_KEY = "smoodly-insert-filled";
12
+ export declare function rememberedFilled(storage?: Storage): boolean;
13
+ export declare function rememberFilled(filled: boolean, storage?: Storage): void;
9
14
  export {};
@@ -15,3 +15,22 @@ export function rememberView(view, storage) {
15
15
  // not remembered — the view still switches
16
16
  }
17
17
  }
18
+ /** The insert picker's "Insert filled" switch (spec 2026-11-14 filled samples): one setting for the whole browser,
19
+ * not per page or zone. On unless the stored value is exactly "0"; a storage that throws leaves it on. */
20
+ export const FILLED_KEY = "smoodly-insert-filled";
21
+ export function rememberedFilled(storage) {
22
+ try {
23
+ return (storage ?? window.localStorage).getItem(FILLED_KEY) !== "0";
24
+ }
25
+ catch {
26
+ return true;
27
+ }
28
+ }
29
+ export function rememberFilled(filled, storage) {
30
+ try {
31
+ (storage ?? window.localStorage).setItem(FILLED_KEY, filled ? "1" : "0");
32
+ }
33
+ catch {
34
+ // not remembered — the switch still turns
35
+ }
36
+ }