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,5 +1,5 @@
1
1
  import { type ReactElement, type ReactNode } from "react";
2
- import type { AssetRecord } from "../../media.ts";
2
+ import { type AssetRecord } from "../../media.ts";
3
3
  import type { AdminField } from "../serialize.ts";
4
4
  export declare function MediaWidget({ descriptor, value, onChange, asset: preloaded, label, mark, hint, error: fieldError, path }: {
5
5
  descriptor: AdminField;
@@ -8,9 +8,12 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
8
8
  // actions in the field's head, right of the label (2026-09-25; the same
9
9
  // row a richtext image card shows). The widget renders its own Field so
10
10
  // the actions can sit in the head.
11
+ // A fresh section's image placeholder (spec 2026-11-14 filled samples) is shown for what it is: the picture the
12
+ // canvas draws, marked "Placeholder", with Choose image and Remove; a dropped file replaces it.
11
13
  // The record comes from the assets context (cached per id); `asset`
12
14
  // preloads it for static renders.
13
15
  import { useEffect, useState } from "react";
16
+ import { isImagePlaceholder, placeholderImage } from "../../media.js";
14
17
  import { Button, Field, UploadProgress } from "../ui/primitives.js";
15
18
  import { acceptFor, formatBytes } from "../shell/media-list.js";
16
19
  import { useAssets } from "./assets-context.js";
@@ -25,6 +28,7 @@ export function MediaWidget({ descriptor, value, onChange, asset: preloaded, lab
25
28
  const assets = useAssets();
26
29
  const kind = descriptor.type;
27
30
  const id = typeof value?.asset === "string" ? value.asset : null;
31
+ const placeholder = kind === "image" && isImagePlaceholder(value);
28
32
  const [asset, setAsset] = useState(preloaded ?? null);
29
33
  /** The fraction sent while a file uploads; null when none is. */
30
34
  const [progress, setProgress] = useState(null);
@@ -69,7 +73,11 @@ export function MediaWidget({ descriptor, value, onChange, asset: preloaded, lab
69
73
  const accept = acceptFor([kind], assets.media.accept);
70
74
  const fileInput = (label, variant = "secondary") => (_jsxs("label", { className: `sm-btn sm-btn--${variant}`, children: [progress !== null ? `Uploading… ${Math.round(progress * 100)}%` : label, _jsx("input", { type: "file", accept: accept, hidden: true, disabled: busy, onChange: (e) => { void onFiles(e.target.files); } })] }));
71
75
  let body;
72
- if (!id) {
76
+ if (placeholder) {
77
+ const picture = placeholderImage(descriptor);
78
+ body = (_jsxs("div", { className: "sm-media", onDragOver: (e) => e.preventDefault(), onDrop: (e) => { e.preventDefault(); void onFiles(e.dataTransfer.files); }, children: [_jsx("div", { className: "sm-media__canvas", children: _jsxs("div", { className: "sm-media__thumb sm-media__thumb--placeholder", style: { aspectRatio: `${picture.width} / ${picture.height}`, width: `calc(${CANVAS_H}px * ${picture.width / picture.height})` }, children: [_jsx("img", { src: picture.url, alt: "" }), progress !== null && _jsx(UploadProgress, { fraction: progress })] }) }), _jsx("span", { className: "sm-media__alt", children: "Placeholder: shown in the editor only" })] }));
79
+ }
80
+ else if (!id) {
73
81
  body = (_jsx("div", { className: "sm-dropzone", onDragOver: (e) => e.preventDefault(), onDrop: (e) => { e.preventDefault(); void onFiles(e.dataTransfer.files); }, children: progress !== null ? _jsx(UploadProgress, { fraction: progress }) : _jsxs(_Fragment, { children: [_jsx("span", { children: "Drop a file here" }), _jsxs("div", { className: "sm-media__actions", children: [fileInput("Upload", "primary"), _jsx(Button, { variant: "secondary", onClick: () => setPicker(true), children: "Choose from library" })] })] }) }));
74
82
  }
75
83
  else if (!asset) {
@@ -87,6 +95,6 @@ export function MediaWidget({ descriptor, value, onChange, asset: preloaded, lab
87
95
  body = (_jsxs("div", { className: "sm-media", children: [preview, asset.kind === "image" && _jsx("span", { className: "sm-media__alt", children: v.alt ?? asset.alt ?? "No alt text" })] }));
88
96
  }
89
97
  // Edit only for an image that has its dimensions (the crop editor's frame needs them).
90
- const actions = id && asset ? (_jsxs("span", { className: "sm-field__actions", children: [asset.kind === "image" && asset.width && asset.height && (_jsx("button", { type: "button", className: "sm-field__action", onClick: () => setCrop(true), children: "Edit" })), _jsx("button", { type: "button", className: "sm-field__action", onClick: () => setPicker(true), children: "Replace" }), _jsx("button", { type: "button", className: "sm-field__action", onClick: () => onChange(undefined), children: "Remove" })] })) : undefined;
98
+ const actions = placeholder ? (_jsxs("span", { className: "sm-field__actions", children: [_jsx("button", { type: "button", className: "sm-field__action", onClick: () => setPicker(true), children: "Choose image" }), _jsx("button", { type: "button", className: "sm-field__action", onClick: () => onChange(undefined), children: "Remove" })] })) : id && asset ? (_jsxs("span", { className: "sm-field__actions", children: [asset.kind === "image" && asset.width && asset.height && (_jsx("button", { type: "button", className: "sm-field__action", onClick: () => setCrop(true), children: "Edit" })), _jsx("button", { type: "button", className: "sm-field__action", onClick: () => setPicker(true), children: "Replace" }), _jsx("button", { type: "button", className: "sm-field__action", onClick: () => onChange(undefined), children: "Remove" })] })) : undefined;
91
99
  return (_jsxs(Field, { label: label, mark: mark, hint: hint, error: fieldError, path: path, action: actions, asDiv: true, children: [body, error && _jsx("span", { className: "sm-error", children: error }), picker && _jsx(MediaPicker, { kinds: [kind], onPick: select, onClose: () => setPicker(false) }), crop && asset && (_jsx(CropEditor, { asset: asset, descriptor: descriptor, value: value, onSave: (next) => { onChange(next); setCrop(false); }, onClose: () => setCrop(false) }))] }));
92
100
  }
@@ -4,6 +4,7 @@ import type { EntryRecord, EntryUsage } from "../entry-store.ts";
4
4
  import type { AssetRecord, AssetMeta } from "../media.ts";
5
5
  import type { AssetPage, AssetQuery } from "../asset-store.ts";
6
6
  import type { MemberRecord } from "../member-store.ts";
7
+ import type { SamplePicks } from "../sample-fill.ts";
7
8
  import type { MemberRole } from "../permissions.ts";
8
9
  import type { FieldError } from "./validate.ts";
9
10
  import type { DeployState } from "../deploy-state.ts";
@@ -217,6 +218,10 @@ export type AdminOps = {
217
218
  entries: EntryRecord[];
218
219
  total: number;
219
220
  }>>;
221
+ /** The reference picks of a fresh node (spec 2026-11-14 filled samples): for each named collection the ids of its
222
+ * first three PUBLISHED entries in `locale`, as the site's `getEntries(collection, { locale, limit: 3 })` returns
223
+ * them, so an insert references what the picker's picture showed. A name that is no collection is left out. */
224
+ samplePicks(collections: string[], locale: string): Promise<OpResult<SamplePicks>>;
220
225
  create(collection: string, input: EntryCreateInput): Promise<OpResult<EntryRecord>>;
221
226
  /** `expected` as on pages: the token the client last saw; `undefined` skips the check. */
222
227
  save(collection: string, id: string, locale: string, fields: Record<string, unknown>, expected?: DraftToken | null, options?: {
@@ -391,7 +391,7 @@ export function GlobalForm({ ops, registry, name }) {
391
391
  };
392
392
  return (_jsxs(_Fragment, { children: [_jsx(TopBar, { left: _jsx(_Fragment, { children: _jsx(Breadcrumb, { backHref: adminHref("/globals"), items: [
393
393
  {
394
- label: "Sections", href: adminHref("/globals"),
394
+ label: "Globals", href: adminHref("/globals"),
395
395
  trailing: multilingual && (_jsx(LocaleSwitcher, { registry: registry, present: registry.locales.supported, parentLocales: [], value: locale, adding: false, onSwitch: (l) => { void switchLocale(l); }, onAdd: () => undefined })),
396
396
  },
397
397
  { label: item.title },
@@ -38,7 +38,7 @@ export function GlobalList({ ops, registry }) {
38
38
  if (error)
39
39
  return _jsx(ErrorPane, { message: error });
40
40
  const rows = items ? globalRows(items, registry, locale) : [];
41
- return (_jsx(ListView, { title: "Sections", titleTrailing: multilingual && _jsx(LocalePicker, { locales: registry.locales.supported, value: locale, onChange: pickLocale }), columns: [
41
+ return (_jsx(ListView, { title: "Globals", titleTrailing: multilingual && _jsx(LocalePicker, { locales: registry.locales.supported, value: locale, onChange: pickLocale }), columns: [
42
42
  { label: "Title", width: "minmax(160px,1fr)" },
43
43
  { label: "Kind", width: "90px" },
44
44
  { label: "Updated", width: "minmax(110px,160px)" },
@@ -106,7 +106,7 @@ export function NavBar({ registry, active, email, settings = true, deploy, onSig
106
106
  const collectionsOpen = open === "collections" || (hovered && open === null);
107
107
  // What ⋮ holds, in the rail's order.
108
108
  const more = [
109
- ...(registry.globals.length > 0 ? [{ key: "globals", label: "Sections", icon: _jsx(LayoutPanelTop, { ...ICON }), href: adminHref("/globals") }] : []),
109
+ ...(registry.globals.length > 0 ? [{ key: "globals", label: "Globals", icon: _jsx(LayoutPanelTop, { ...ICON }), href: adminHref("/globals") }] : []),
110
110
  ...(settings ? [{ key: "settings", label: "Settings", icon: _jsx(Settings, { ...ICON }), href: adminHref("/settings") }] : []),
111
111
  ];
112
112
  const moreActive = more.some((m) => m.key === active);
@@ -10,7 +10,7 @@ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-run
10
10
  import { useCallback, useEffect, useState } from "react";
11
11
  import { ListView } from "./ListView.js";
12
12
  import { canDragRow, codeAddressNote, openTemplates, pagesTreeRows, projectDrop, sourceLocaleOf, subtreeHeight } from "./pages-tree.js";
13
- import { Button, StatusBadge } from "../ui/primitives.js";
13
+ import { Button, StatusBadge, cx } from "../ui/primitives.js";
14
14
  import { statusLabel } from "../ui/status-label.js";
15
15
  import { LocalePicker } from "../ui/LocaleSwitcher.js";
16
16
  import { useSortable } from "../ui/useSortable.js";
@@ -238,7 +238,7 @@ export function PagesList({ ops, registry }) {
238
238
  // A record no longer in code (spec 2026-09-29 nested paths §5): deleting it is all that is open.
239
239
  if (row.orphan) {
240
240
  if (!row.hasChildren)
241
- items.push({ label: "Delete", run: () => destroy(row) });
241
+ items.push({ label: "Delete", run: () => destroy(row), danger: true });
242
242
  return items;
243
243
  }
244
244
  if (!row.present) {
@@ -262,7 +262,7 @@ export function PagesList({ ops, registry }) {
262
262
  items.push({ label: `Remove from ${locale}`, run: () => removeLocale(row) });
263
263
  }
264
264
  if (!row.fixed && !row.isHome && !row.hasChildren)
265
- items.push({ label: "Delete", run: () => destroy(row) });
265
+ items.push({ label: "Delete", run: () => destroy(row), danger: true });
266
266
  return items;
267
267
  };
268
268
  // A mount row is a page like any other when a fixed page claims the
@@ -322,7 +322,7 @@ export function PagesList({ ops, registry }) {
322
322
  position: "absolute",
323
323
  left: Math.max(12, Math.min(menu.x - 280, window.innerWidth - 312)),
324
324
  top: Math.min(menu.y + 8, Math.max(12, window.innerHeight - 300)),
325
- }, children: [items.map((item) => (_jsx("button", { type: "button", className: "sm-picker__item", onClick: () => { setMenu(null); item.run(); }, children: item.label }, item.label))), items.length === 0 && (_jsx("p", { style: { color: "var(--text-muted)", padding: 10 }, children: menu?.row.orphan ? "No longer in code — move its child pages out, then delete it." : "Owned by code — nothing to change here." }))] }) })), dialog?.kind === "new" && (_jsx(PageFormDialog, { title: dialog.node === "page" ? (dialog.parentId ? "New subpage" : "New page") : (dialog.parentId ? "New subpath" : "New path"), submitLabel: dialog.node === "page" ? "Create page" : "Create path", fields: { title: true, slug: true, template: dialog.node === "page" }, templates: creatable, parentPath: dialog.parentId ? (rows.find((r) => r.id === dialog.parentId)?.path ?? "/") : "/", onClose: closeDialog, onSubmit: (values) => submitDialog(values).then((message) => { if (message === null)
325
+ }, children: [items.map((item) => (_jsx("button", { type: "button", className: cx("sm-picker__item", item.danger && "sm-picker__item--danger"), onClick: () => { setMenu(null); item.run(); }, children: item.label }, item.label))), items.length === 0 && (_jsx("p", { style: { color: "var(--text-muted)", padding: 10 }, children: menu?.row.orphan ? "No longer in code — move its child pages out, then delete it." : "Owned by code — nothing to change here." }))] }) })), dialog?.kind === "new" && (_jsx(PageFormDialog, { title: dialog.node === "page" ? (dialog.parentId ? "New subpage" : "New page") : (dialog.parentId ? "New subpath" : "New path"), submitLabel: dialog.node === "page" ? "Create page" : "Create path", fields: { title: true, slug: true, template: dialog.node === "page" }, templates: creatable, parentPath: dialog.parentId ? (rows.find((r) => r.id === dialog.parentId)?.path ?? "/") : "/", onClose: closeDialog, onSubmit: (values) => submitDialog(values).then((message) => { if (message === null)
326
326
  closeDialog(); return message; }) })), dialog?.kind === "template" && (_jsx(PageFormDialog, { title: "Add page here", submitLabel: "Add page", fields: { template: true }, templates: creatable, onClose: closeDialog, onSubmit: (values) => submitDialog(values).then((message) => { if (message === null)
327
327
  closeDialog(); return message; }) })), dialog?.kind === "rename" && (_jsx(PageFormDialog, { title: "Rename page", submitLabel: "Rename", fields: { title: true, slug: !dialog.row.isHome }, initial: { title: dialog.row.record.locales[locale]?.title ?? "", slug: dialog.row.record.locales[locale]?.slug ?? "" }, parentPath: dialog.row.isHome ? undefined : parentPathOf(dialog.row.path), onClose: closeDialog, onSubmit: (values) => submitDialog(values).then((message) => { if (message === null)
328
328
  closeDialog(); return message; }) })), dialog?.kind === "duplicate" && (_jsx(PageFormDialog, { title: "Duplicate page", submitLabel: "Duplicate", fields: { title: true, slug: true }, initial: { title: `${dialog.row.title} copy`, slug: `${dialog.row.record.locales[locale]?.slug ?? ""}-copy` }, parentPath: dialog.row.isHome ? "/" : parentPathOf(dialog.row.path), onClose: closeDialog, onSubmit: (values) => submitDialog(values).then((message) => { if (message === null)
@@ -2,7 +2,7 @@
2
2
  // (this site, the docs; 2026-10-23), the account menu's "My Account" and the Members page's "Manage members at
3
3
  // smoodly.io". The dashboard links are deep links into the console the service names (`GET /v1/auth`'s consoleUrl),
4
4
  // set once by the entry from the page's data; the docs are smoodly.io itself.
5
- let console = { url: "https://smoodly.io", spaceId: "" };
5
+ let console = { url: "https://app.smoodly.io", spaceId: "" };
6
6
  export function setConsole(url, spaceId) {
7
7
  console = { url: url.replace(/\/+$/, ""), spaceId };
8
8
  }
@@ -2,7 +2,7 @@ import { SEVERITY, groupKey } from "../drift.js";
2
2
  import { driftHeadline } from "../drift-copy.js";
3
3
  /** A group shows this many rows, then "Show all N". */
4
4
  export const GROUP_PREVIEW = 10;
5
- const kindLabel = (doc, registry) => doc.kind === "page" ? "Page" : doc.kind === "global" ? "Sections" : registry.collections.find((c) => c.name === doc.collection)?.title ?? doc.collection ?? "";
5
+ const kindLabel = (doc, registry) => doc.kind === "page" ? "Page" : doc.kind === "global" ? "Globals" : registry.collections.find((c) => c.name === doc.collection)?.title ?? doc.collection ?? "";
6
6
  const href = (doc, locale) => {
7
7
  const q = `?locale=${encodeURIComponent(locale)}`;
8
8
  if (doc.kind === "page")
@@ -1,6 +1,7 @@
1
1
  import type { PageTree, TreeNode } from "../page-context.ts";
2
2
  import type { ZonePolicy } from "../zones.ts";
3
3
  import type { AdminSection, AdminGlobal } from "./serialize.ts";
4
+ import { type FreshOptions } from "../sample-fill.ts";
4
5
  export declare function zoneOf(tree: PageTree, id: string): string | null;
5
6
  export declare function findNode(tree: PageTree, id: string): TreeNode | null;
6
7
  export declare function insertNode(tree: PageTree, zone: string, index: number, node: TreeNode): PageTree;
@@ -24,15 +25,21 @@ export declare function allowedGlobals(policy: ZonePolicy | undefined, globals:
24
25
  /** A page places a visual item as this node (spec 2026-09-07 §6): `item`
25
26
  * names the registration, `ref` is the item's row id. */
26
27
  export declare function placementNode(item: AdminGlobal, ref: string, id: string): TreeNode;
27
- /** Also fills an element node (an `AdminElement` is a section shape plus contexts). Never seeds `styles` or `css`: Default stores nothing (spec 2026-09-14 §2). */
28
- export declare function nodeFromSample(section: Pick<AdminSection, "name" | "fields" | "settings" | "sample">, id: string): TreeNode;
28
+ /** The one builder of a fresh node (spec 2026-11-14 filled samples): the insert, a locked zone, a richtext element and
29
+ * the picker's sample tree all take it from here, so the picture is the insert. Per field: the `sample` (taken whole,
30
+ * nothing generated on it or inside it), then the `default`, a required choice's first option and a boolean's false,
31
+ * then the generated value (sample-fill.ts), then the empty value by type. `empty` skips the sample and the generated
32
+ * value. Settings are choices: sampled or defaulted, never generated.
33
+ * Also fills an element node (an `AdminElement` is a section shape plus contexts). Never seeds `styles` or `css`: Default stores nothing (spec 2026-09-14 §2). */
34
+ export declare function nodeFromSample(section: Pick<AdminSection, "name" | "fields" | "settings" | "sample">, id: string, options?: FreshOptions): TreeNode;
29
35
  /** A locked zone holds exactly one node the editor can never insert or
30
36
  * remove — so someone has to put it there. This does, from the component's
31
37
  * sample (or, for a zone bound to a global item, a placement), for every
32
38
  * locked zone that is empty or missing. Runs when the editor loads a draft,
33
39
  * which also covers pages that predate a locked zone being added to their
34
- * schema. Returns the same tree when nothing changed. */
35
- export declare function fillLockedZones(tree: PageTree, policies: Record<string, ZonePolicy>, sections: AdminSection[], globalIds?: Record<string, string>): PageTree;
40
+ * schema. Returns the same tree when nothing changed. `fresh` carries the
41
+ * space's reference picks and whether the service takes an image placeholder. */
42
+ export declare function fillLockedZones(tree: PageTree, policies: Record<string, ZonePolicy>, sections: AdminSection[], globalIds?: Record<string, string>, fresh?: FreshOptions): PageTree;
36
43
  export declare function newNodeId(type: string): string;
37
44
  /** A page-level value — the template's page fields or the SEO meta (spec 2026-09-10 page fields §5). */
38
45
  export declare function patchPage(tree: PageTree, ns: "fields" | "meta", key: string, value: unknown): PageTree;
@@ -1,6 +1,6 @@
1
1
  import { GLOBAL_NODE_TYPE } from "../global.js";
2
2
  import { setAtPath, unsetAtPath } from "./field-paths.js";
3
- import { optionsOf } from "../fields.js";
3
+ import { firstChoice, generated, starting } from "../sample-fill.js";
4
4
  export function zoneOf(tree, id) {
5
5
  for (const [zone, nodes] of Object.entries(tree.zones)) {
6
6
  if (nodes.some((n) => n.id === id))
@@ -119,32 +119,41 @@ export function placementNode(item, ref, id) {
119
119
  /** The empty value a fresh node's field starts at, by descriptor type.
120
120
  * Shape matters: a list-valued field seeded with "" is a type lie that
121
121
  * crashes the first section view that maps over it. */
122
- const EMPTY_BY_TYPE = { refList: [], object: {} };
122
+ const EMPTY_BY_TYPE = { refList: () => [], list: () => [], tags: () => [], checkboxes: () => [], object: () => ({}) };
123
123
  /** Media/number fields have no honest empty value — "" renders as a
124
- * broken-image icon or a stray zero, worse than nothing at all. With no
125
- * sample, leave the key out of `fields` entirely rather than seed a lie. */
124
+ * broken-image icon or a stray zero, worse than nothing at all. With
125
+ * nothing to start from, leave the key out of `fields` entirely rather than seed a lie. */
126
126
  const ABSENT_WHEN_UNSAMPLED = new Set(["image", "video", "file", "number"]);
127
- /** A required choice shows its first option (FieldWidget renders no blank one), so a new node stores it too — as a
128
- * list item does (forms/list.ts) — and publish never says Required for a choice the editor sees made. */
129
- const firstChoice = (d) => (d.type === "select" || d.type === "radio") && d.optional !== true ? optionsOf(d.options)[0]?.value : undefined;
130
- /** Also fills an element node (an `AdminElement` is a section shape plus contexts). Never seeds `styles` or `css`: Default stores nothing (spec 2026-09-14 §2). */
131
- export function nodeFromSample(section, id) {
127
+ /** What the section's `sample` sets for a key: an empty array counts as left out (spec 2026-11-14 filled samples),
128
+ * since "start with nothing" for a list is what leaving it out already means. */
129
+ const sampledOf = (sample, key) => {
130
+ const v = sample?.[key];
131
+ return Array.isArray(v) && v.length === 0 ? undefined : v;
132
+ };
133
+ /** The one builder of a fresh node (spec 2026-11-14 filled samples): the insert, a locked zone, a richtext element and
134
+ * the picker's sample tree all take it from here, so the picture is the insert. Per field: the `sample` (taken whole,
135
+ * nothing generated on it or inside it), then the `default`, a required choice's first option and a boolean's false,
136
+ * then the generated value (sample-fill.ts), then the empty value by type. `empty` skips the sample and the generated
137
+ * value. Settings are choices: sampled or defaulted, never generated.
138
+ * Also fills an element node (an `AdminElement` is a section shape plus contexts). Never seeds `styles` or `css`: Default stores nothing (spec 2026-09-14 §2). */
139
+ export function nodeFromSample(section, id, options = {}) {
132
140
  const fields = {};
133
141
  const settings = {};
142
+ const sample = options.empty ? undefined : section.sample;
134
143
  for (const [key, d] of Object.entries(section.fields)) {
135
- const sampled = section.sample?.[key] ?? d.default ?? firstChoice(d) ?? (d.type === "boolean" ? false : undefined);
136
- if (sampled !== undefined)
137
- fields[key] = sampled;
144
+ const value = sampledOf(sample, key) ?? starting(d) ?? (options.empty ? undefined : generated(key, d, options));
145
+ if (value !== undefined)
146
+ fields[key] = value;
138
147
  else if (ABSENT_WHEN_UNSAMPLED.has(d.type))
139
148
  continue;
140
149
  // An optional object with no sample is absent: `{}` would read as present, its members required at publish.
141
150
  else if (d.type === "object" && d.optional === true)
142
151
  continue;
143
152
  else
144
- fields[key] = EMPTY_BY_TYPE[d.type] ?? "";
153
+ fields[key] = EMPTY_BY_TYPE[d.type]?.() ?? "";
145
154
  }
146
155
  for (const [key, d] of Object.entries(section.settings)) {
147
- const v = section.sample?.[key] ?? d.default ?? firstChoice(d);
156
+ const v = sampledOf(sample, key) ?? d.default ?? firstChoice(d);
148
157
  if (v !== undefined)
149
158
  settings[key] = v;
150
159
  }
@@ -155,8 +164,9 @@ export function nodeFromSample(section, id) {
155
164
  * sample (or, for a zone bound to a global item, a placement), for every
156
165
  * locked zone that is empty or missing. Runs when the editor loads a draft,
157
166
  * which also covers pages that predate a locked zone being added to their
158
- * schema. Returns the same tree when nothing changed. */
159
- export function fillLockedZones(tree, policies, sections, globalIds = {}) {
167
+ * schema. Returns the same tree when nothing changed. `fresh` carries the
168
+ * space's reference picks and whether the service takes an image placeholder. */
169
+ export function fillLockedZones(tree, policies, sections, globalIds = {}, fresh) {
160
170
  let next = null;
161
171
  for (const [zone, policy] of Object.entries(policies)) {
162
172
  if (policy.kind !== "locked")
@@ -177,7 +187,8 @@ export function fillLockedZones(tree, policies, sections, globalIds = {}) {
177
187
  const section = sections.find((s) => s.name === policy.component);
178
188
  if (!section)
179
189
  continue;
180
- node = nodeFromSample(section, newNodeId(section.name));
190
+ // Always the filled node (spec 2026-11-14 filled samples): a locked zone has no picker and no switch.
191
+ node = nodeFromSample(section, newNodeId(section.name), { ...fresh, empty: false });
181
192
  }
182
193
  next ?? (next = { ...tree, zones: { ...tree.zones } });
183
194
  next.zones[zone] = [node];
@@ -26,7 +26,8 @@ const TOKENS_LIGHT = `
26
26
  /* The shell: navy frame, white sheet, grey tiles on the sheet, white fields on the tiles. */
27
27
  --frame:#151727; --sheet:${SHEET_LIGHT};
28
28
  --bg:var(--sheet); --canvas:#ECEDF1; --surface:#F6F6F6; --surface-form:#F6F6F6; --surface-inset:#FFFFFF;
29
- --menu-bg:#FFFFFF;
29
+ --menu-bg:#FFFFFF; --menu-hover:var(--hover);
30
+ --avatar-hover:color-mix(in srgb,var(--accent-soft),#fff 60%);
30
31
  --input-bg:#FFFFFF; --input-hover-bg:#FFFFFF; --input-focus-bg:#FFFFFF; --input-border:var(--line);
31
32
  --line:#E4E5EA; --line-dashed:#CFD1D9; --line-soft:#ECEDF1;
32
33
  --text:#151727; --text-secondary:#454A5C; --text-muted:#6B6F80; --text-subtle:#9A9EAB; --text-disabled:#C5C8D2;
@@ -61,6 +62,7 @@ const TOKENS_LIGHT = `
61
62
  --status-saved-dot:var(--green-500);
62
63
  --warn:var(--amber-700); --warn-soft:var(--amber-50);
63
64
  --danger:var(--red-700); --danger-soft:var(--red-50);
65
+ --destructive-bg:var(--red-700); /* the filled destructive button: white text on it in both themes (2026-11-14) */
64
66
 
65
67
  /* The edited page inside the canvas stays light in both themes. */
66
68
  --page-bg:#FFFFFF; --page-text:#151727;
@@ -80,11 +82,11 @@ const TOKENS_LIGHT = `
80
82
  and sheet a step darker, tiles a step lighter, fields in the sheet tone;
81
83
  the bar and the logo unchanged. */
82
84
  const TOKENS_DARK = `
83
- --accent:#6CC4A6; --accent-hover:#82D2B8; --accent-soft:rgba(108,196,166,.18); --accent-line:#6CC4A6; --on-accent:#151727;
85
+ --accent:#6CC4A6; --accent-hover:#82D2B8; --accent-soft:rgba(108,196,166,.18); --avatar-hover:rgba(108,196,166,.26); /* a step up from accent-soft: white over the tint read grey (2026-11-14) */ --accent-line:#6CC4A6; --on-accent:#151727;
84
86
  --selection-global:#F09A5C;
85
87
  --frame:#0F1120; --sheet:${SHEET_DARK};
86
88
  --canvas:#131526; --surface:#242840; --surface-form:#242840; --surface-inset:#1B1E30;
87
- --menu-bg:#242840;
89
+ --menu-bg:#242840; --menu-hover:#2A2E47; /* a step off the menu, not the brighter --hover (2026-11-14) */
88
90
  /* A field's edge is drawn in dark: a dialog's fields take the card's own tone there (2026-11-13). */
89
91
  --input-bg:#1B1E30; --input-hover-bg:#1B1E30; --input-focus-bg:#1B1E30; --input-border:var(--line-dashed);
90
92
  --line:#33384F; --line-dashed:#444A63; --line-soft:#33384F;
@@ -126,7 +128,7 @@ const SCALE = `
126
128
 
127
129
  --radius-xs:2px; --radius-sm:6px; --radius-md:8px; --radius-lg:10px;
128
130
  --radius-card:16px; --radius-card-lg:16px; --radius-modal:20px; --radius-btn:999px; --radius-round:999px;
129
- --page-radius:8px 8px 0 0; --selection-radius:2px; /* the canvas hover/selection ring: 2px (asked for 2026-09-26; 8px before) */
131
+ --page-radius:0; --selection-radius:2px; /* the canvas hover/selection ring: 2px (asked for 2026-09-26; 8px before) */
130
132
  --outline-row-radius:6px; --tabs-radius:10px; --tab-radius:6px; --row-radius:2px; --tag-radius:999px; /* list row hover fill: 2px (same day; 10px before) */
131
133
  --plus-radius:8px; --toolbar-radius:8px;
132
134
  --rtcard-bg:var(--surface); --rtcard-border:var(--line);
@@ -135,10 +137,10 @@ const SCALE = `
135
137
  --plus-h:32px; --plus-pad:0 14px 0 6px; --plus-label:inline;
136
138
  --inspector-w:360px; --gutter:20px;
137
139
  /* The frame is 8px left of, right of and below the sheet (6px in the handoff, +2px 2026-10-23); the bar fills the top edge. */
138
- --frame-pad:8px; --shell-gap:0px; --sheet-radius:20px; --sheet-pad:0 24px 24px;
140
+ --frame-pad:8px; --shell-gap:0px; --sheet-radius:12px; --sheet-pad:0 24px 24px;
139
141
  --navbar-h:64px; --navbar-pad-x:12px; --navbar-item-h:40px; --navbar-item-radius:10px;
140
142
  --topbar-pad:0; --back-size:32px; --back-bg:var(--surface);
141
- --table-pad:8px 12px; --table-top:80px; --canvas-pad:28px 40px 0; --card-pad:16px 18px; --card-gap:12px; --form-pad:32px 40px;
143
+ --table-pad:8px 12px; --table-top:80px; --canvas-pad:28px 28px 0; --card-pad:16px 18px; --card-gap:12px; --form-pad:32px 40px;
142
144
  --border:1px solid var(--line);
143
145
  --outline-selection:2px solid var(--selection);
144
146
  --outline-selection-global:2px solid var(--selection-global);
@@ -220,7 +222,7 @@ html:has(.sm-admin),body:has(.sm-admin){overscroll-behavior:none}
220
222
  width:32px;height:32px;border-radius:var(--radius-round);background:var(--accent-soft);color:var(--accent);
221
223
  display:grid;place-items:center;font-size:var(--text-xs);font-weight:600;border:0;cursor:pointer;padding:0;transition:background .15s;
222
224
  }
223
- .sm-navbar__user:hover{background:color-mix(in srgb,var(--accent-soft),#fff 60%)}
225
+ .sm-navbar__user:hover{background:var(--avatar-hover)}
224
226
  .sm-navbar__user:focus-visible{outline:2px solid var(--navbar-active-fg);outline-offset:2px;box-shadow:none}
225
227
  .sm-navbar__ext{width:13px;height:13px;flex:none;margin-left:12px;opacity:.45}
226
228
  .sm-navbar__menu-item:hover .sm-navbar__ext,.sm-navbar__menu-item:focus-visible .sm-navbar__ext{opacity:.8}
@@ -272,7 +274,7 @@ html:has(.sm-admin),body:has(.sm-admin){overscroll-behavior:none}
272
274
  appearance:none;background:none;border:0;width:100%;display:flex;align-items:center;justify-content:space-between;
273
275
  padding:7px 10px;border-radius:var(--radius-sm);color:inherit;font:inherit;cursor:pointer;text-align:left;text-decoration:none;
274
276
  }
275
- .sm-navbar__menu-item:hover,.sm-navbar__menu-item:focus-visible{background:var(--hover);color:inherit;outline:0;box-shadow:none}
277
+ .sm-navbar__menu-item:hover,.sm-navbar__menu-item:focus-visible{background:var(--menu-hover);color:inherit;outline:0;box-shadow:none}
276
278
  .sm-navbar__menu-item[aria-current="page"],.sm-navbar__menu-item[aria-current="page"]:hover{background:var(--nav-active-bg);color:var(--nav-active-fg);font-weight:600}
277
279
  .sm-navbar__menu-label{display:flex;align-items:center;gap:10px;min-width:0}
278
280
  .sm-navbar__menu-label>span{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
@@ -336,7 +338,7 @@ html:has(.sm-admin),body:has(.sm-admin){overscroll-behavior:none}
336
338
  .sm-btn--danger{background:var(--btn-secondary-bg);color:var(--danger)}
337
339
  .sm-btn--danger:hover{background:var(--danger-soft);color:var(--danger)}
338
340
  /* The confirm dialog's destructive primary: filled red (ConfirmModal.html). */
339
- .sm-btn--destructive{background:var(--danger);color:#fff;font-weight:600;padding:0 18px}
341
+ .sm-btn--destructive{background:var(--destructive-bg);color:#fff;font-weight:600;padding:0 18px}
340
342
  .sm-btn--destructive:hover{filter:brightness(.94);color:#fff}
341
343
  .sm-btn--ok{background:var(--status-published-bg);color:var(--status-published-fg);font-weight:600;padding:0 18px}
342
344
  .sm-btn:disabled,.sm-btn[aria-disabled="true"]{opacity:.5;cursor:default;pointer-events:none}
@@ -455,12 +457,12 @@ html:has(.sm-admin),body:has(.sm-admin){overscroll-behavior:none}
455
457
  .sm-richtext__content ul,.sm-richtext__content ol{padding-left:20px}
456
458
  .sm-richtext__content li+li{margin-top:.25em}
457
459
  .sm-richtext__body{position:relative}
458
- /* The "+" menu floats under the toolbar's right end (the "+" is its last
460
+ /* The "+" menu hangs right under the toolbar's right end, no gap (2026-11-14; the "+" is its last
459
461
  button) instead of pushing the content down; the picker inside drops
460
462
  its own chrome. The slash menu floats under the caret the same way. */
461
- .sm-richtext__head{position:relative}
463
+ .sm-richtext__head{position:relative;width:fit-content;max-width:100%} /* as wide as the toolbar, so the menu hangs from the "+" (2026-11-14) */
462
464
  .sm-richtext__menu{
463
- position:absolute;top:calc(100% + 6px);right:0;z-index:65;width:300px;max-width:100%;
465
+ position:absolute;top:100%;right:0;z-index:65;width:300px;max-width:calc(100vw - 32px);
464
466
  display:flex;flex-direction:column;gap:6px;padding:6px;
465
467
  background:var(--menu-bg);border:var(--menu-border);border-radius:var(--radius-md);box-shadow:var(--shadow-pop);
466
468
  }
@@ -564,7 +566,7 @@ html:has(.sm-admin),body:has(.sm-admin){overscroll-behavior:none}
564
566
  .sm-listnotice:empty{display:none}
565
567
  /* The rows sit in a centred column of at most 1200px inside the card,
566
568
  under --table-top of air reserved for the coming search and filters. */
567
- .sm-table{display:flex;flex-direction:column;padding:var(--table-pad);padding-top:var(--table-top);max-width:1200px;margin:0 auto}
569
+ .sm-table{display:flex;flex-direction:column;padding:var(--table-pad);padding-top:var(--table-top);padding-bottom:100px;max-width:1200px;margin:0 auto}
568
570
  /* The filter sits in that air, so the column heads stay where they are on a list without one (2026-11-13). */
569
571
  .sm-table--tools{padding-top:20px}
570
572
  .sm-table__tools{display:flex;align-items:center;gap:12px;height:40px;margin-bottom:20px;padding:0 8px}
@@ -963,6 +965,22 @@ html:has(.sm-admin),body:has(.sm-admin){overscroll-behavior:none}
963
965
  width:26px;height:26px;flex:none;border-radius:var(--radius-sm);background:var(--accent-soft);
964
966
  color:var(--accent);display:grid;place-items:center;font-size:13px;
965
967
  }
968
+ /* The insert picker as a modal (spec 2026-11-14 add section previews): the rows keep the picker's item styles in a
969
+ plain column; beside them the current row as the site draws it, in one scaled, cropped frame. */
970
+ .sm-insert{display:flex;flex:1;min-height:0}
971
+ .sm-insert__list{width:auto;flex:1;min-width:0;overflow-y:auto;background:transparent;border:0;border-radius:0;box-shadow:none;padding:8px}
972
+ .sm-insert__side{flex:1;min-width:0;min-height:0;display:flex;flex-direction:column}
973
+ .sm-insert--pane .sm-insert__side{flex:none;width:300px;border-right:var(--card-divider)}
974
+ .sm-insert__foot{flex:none;display:flex;align-items:center;gap:8px;padding:10px 16px;border-top:var(--card-divider);font-size:var(--text-sm);color:var(--text-muted);cursor:pointer}
975
+ /* The preview pane is a small canvas (2026-11-14): the canvas tone, inset from the dialog's edges, its corners rounded. */
976
+ .sm-insert__pane{flex:1;min-width:0;display:flex;flex-direction:column;gap:12px;padding:20px;margin:0 16px 16px 0;border-radius:12px;background:var(--canvas)}
977
+ .sm-insert__preview{flex:1;min-height:0;display:flex;flex-direction:column;justify-content:center;overflow:hidden;padding:4px 4px 10px} /* room for the crop's shadow */
978
+ .sm-insert__crop{position:relative;width:100%;overflow:hidden;border-radius:var(--radius-md);max-height:100%}
979
+ /* A hairline and a small shadow keep a light section off a light pane. */
980
+ .sm-insert__crop--shown{background:#fff;box-shadow:0 0 0 1px rgba(21,23,39,.1),0 2px 8px rgba(21,23,39,.1)}
981
+ .sm-insert__fallback{flex:1;display:grid;place-items:center;border-radius:var(--radius-md);border:1px dashed var(--line)}
982
+ .sm-insert__icon{width:72px;height:72px;border-radius:var(--radius-md);background:var(--accent-soft);color:var(--accent);display:grid;place-items:center;font-size:32px}
983
+ .sm-insert__caption{flex:none;min-height:40px}
966
984
  /* Combobox (spec 2026-09-17 §1): the input owns the width, the list hangs under it —
967
985
  portaled and fixed at the input's width (--combo-w), above modals and below
968
986
  the confirm scrim (2026-09-27); inline under the input in static markup. */
@@ -1114,6 +1132,7 @@ html:has(.sm-admin),body:has(.sm-admin){overscroll-behavior:none}
1114
1132
  }
1115
1133
  .sm-media__thumb{position:relative;overflow:hidden;max-width:100%;max-height:300px;background:var(--surface-inset)}
1116
1134
  .sm-media__thumb>img,.sm-media__thumb>video{position:absolute;max-width:none;display:block}
1135
+ .sm-media__thumb--placeholder>img{inset:0;width:100%;height:100%}
1117
1136
  .sm-media__alt{font-size:var(--text-sm);color:var(--text-muted);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
1118
1137
  .sm-media>.sm-media__alt{text-align:center}
1119
1138
  .sm-media__actions{display:flex;gap:6px}
@@ -15,7 +15,7 @@ import { isCalendarDay, isInstant } from "./forms/date-picker.js";
15
15
  import { fits, misfitMessage } from "./drift.js";
16
16
  import { isSegment } from "../paths.js";
17
17
  import { safeHref } from "../richtext-href.js";
18
- import { isCrop } from "../media.js";
18
+ import { isCrop, isImagePlaceholder } from "../media.js";
19
19
  /** A slug outside the path grammar, at publish (spec 2026-09-28 ASCII slugs §3). */
20
20
  export const SLUG_GRAMMAR_MESSAGE = "Use lowercase letters, digits and single hyphens, like my-post.";
21
21
  /** A link field takes only what the site renders (richtext-href.ts's rule): the widget says so as the editor types,
@@ -36,7 +36,8 @@ function validateValue(path, value, d, errors, elements, scope) {
36
36
  // An optional object with nothing filled in is absent (drafts inserted before 2026-09-26 carry `{}`).
37
37
  if (d.type === "object" && d.optional === true && isBlankObject(value))
38
38
  return;
39
- if (isEmpty(value)) {
39
+ // An image placeholder is unset to publish (spec 2026-11-14 filled samples): Required on a required image.
40
+ if (isEmpty(value) || (d.type === "image" && isImagePlaceholder(value))) {
40
41
  if (!mayBeAbsent(d))
41
42
  errors.push({ field: path, message: "Required." });
42
43
  return;
@@ -29,7 +29,8 @@ export function createSmoodlyApi(options) {
29
29
  * server pushes its schema to the space it reads, so the developer must see when that space is production. */
30
30
  let pushed = null;
31
31
  const pushOnce = (body) => (pushed ?? (pushed = pushSchema({ apiUrl, spaceId: options.spaceId, key: options.key, fetch: doFetch }, body).then((result) => { const line = pushLine(options.spaceId, result); console.log(`smoodly: ${line[0].toLowerCase()}${line.slice(1)}`); }, (e) => console.error(`smoodly: the schema push failed — ${e instanceof Error ? e.message : String(e)}; the site runs against the schema the space has.`))));
32
- async function call(path, params) {
32
+ /** `send`: a read that carries a body (the picker's samples) goes as a POST; every other read is a GET. */
33
+ async function call(path, params, send) {
33
34
  if (push)
34
35
  await pushOnce(push);
35
36
  countRead();
@@ -39,7 +40,9 @@ export function createSmoodlyApi(options) {
39
40
  url.searchParams.set(k, v);
40
41
  let res;
41
42
  try {
42
- res = await doFetch(url.toString(), { headers: { authorization: `Bearer ${options.key}`, accept: "application/json" } });
43
+ res = await doFetch(url.toString(), send === undefined
44
+ ? { headers: { authorization: `Bearer ${options.key}`, accept: "application/json" } }
45
+ : { method: "POST", headers: { authorization: `Bearer ${options.key}`, accept: "application/json", "content-type": "application/json" }, body: JSON.stringify(send) });
43
46
  }
44
47
  catch (e) {
45
48
  throw new TypeError(`smoodly: can't reach SMOODLY_API_URL (${apiUrl}): ${e instanceof Error ? e.message : String(e)}`);
@@ -60,6 +63,8 @@ export function createSmoodlyApi(options) {
60
63
  styles: statics.styles,
61
64
  custom: statics.custom,
62
65
  codePage: statics.codePage,
66
+ sampleTree: statics.sampleTree,
67
+ sampleCollections: statics.sampleCollections,
63
68
  async locate(locale, path) {
64
69
  return (await call("/locate", { locale, path })).data;
65
70
  },
@@ -71,6 +76,11 @@ export function createSmoodlyApi(options) {
71
76
  const r = await call("/load", { kind: target.kind, id: target.id, locale, path, draft: o?.draft ? "1" : undefined, version: o?.version });
72
77
  return withTags(r.data, r.tags, draft);
73
78
  },
79
+ async loadSamples(target, locale, path, tree) {
80
+ if (target.kind !== "page")
81
+ return null;
82
+ return (await call("/load-samples", {}, { id: target.id, locale, path, tree })).data;
83
+ },
74
84
  async lookup(locale, path) {
75
85
  const r = await call("/lookup", { locale, path });
76
86
  return withTags(r.data.hit, r.tags, false);
@@ -5,6 +5,7 @@
5
5
  // for the body is what makes a late read count. A preview response gains the draft marker and, in the
6
6
  // canvas, the bridge, when the shell rendered it; a live page gains nothing. Nothing under the preview
7
7
  // address is ever stored, and neither is a render a publish overtook or one a read failed in.
8
+ import { SAMPLES_PARAM } from "../canvas-marker.js";
8
9
  import { defineMiddleware } from "astro:middleware";
9
10
  import { loadAdminFiles } from "../admin/files.js";
10
11
  import { ADMIN_INTERNAL } from "../admin/page-data.js";
@@ -32,6 +33,7 @@ export const onRequest = defineMiddleware(async (context, next) => {
32
33
  draft,
33
34
  editor: draft && searchParams.get("smoodly-editor") === "1",
34
35
  version: (draft && searchParams.get("smoodly-version")) || undefined,
36
+ samples: (draft && searchParams.get(SAMPLES_PARAM)) || undefined,
35
37
  cacheable: false,
36
38
  shell: false,
37
39
  };
@@ -14,6 +14,9 @@ export type SmoodlyLocals = {
14
14
  editor: boolean;
15
15
  /** History's stored version, inside a preview only. */
16
16
  version?: string;
17
+ /** The picker's zone (`smoodly-samples`), inside a preview only: its rows are drawn from their samples in place of
18
+ * the page's content (spec 2026-11-14 add section previews). */
19
+ samples?: string;
17
20
  /** Set by the shell when it rendered a page read from the service: the response may be cached under its tags. */
18
21
  cacheable: boolean;
19
22
  /** Set by the shell when it rendered a page at all: in a preview, only that response is a canvas. */
@@ -1,6 +1,7 @@
1
1
  import { addressWithGlobalsUnit, globalUnit } from "../reads.js";
2
2
  import { toEntryItem } from "../read-shapes.js";
3
3
  import { pageContextOf } from "../site.js";
4
+ import { samplesHit } from "../sample-tree.js";
4
5
  import { headOf } from "./head.js";
5
6
  import { redirectsRoot, splitLocale } from "./locale-paths.js";
6
7
  const NOT_FOUND = { kind: "notFound" };
@@ -55,9 +56,15 @@ export async function resolveShell(deps, pathname, search, state, views) {
55
56
  // Only a page read from the service has a tag to expire it by.
56
57
  state.cacheable = !hit.record.id.startsWith("code:") && !state.draft;
57
58
  state.shell = true;
59
+ // The picker's samples: the same page with the zone's sample tree in place of its content. A code-owned page
60
+ // has no record for the service to answer with, so its zones are empty.
61
+ const shown = state.draft && state.samples !== undefined
62
+ ? await samplesHit(site, hit, hit.record.id.startsWith("code:") ? null : { kind: "page", id: hit.record.id }, locale, path, state.samples)
63
+ : hit;
64
+ const tree = shown.kind === "page" ? shown.tree : hit.tree;
58
65
  const zoneNames = Object.keys(schema.zones ?? {});
59
66
  const zones = Object.fromEntries(zoneNames.map((name) => [
60
- name, { name, nodes: hit.tree.zones?.[name] ?? [], editor: state.editor, styleValues: site.styles },
67
+ name, { name, nodes: tree.zones?.[name] ?? [], editor: state.editor, styleValues: site.styles },
61
68
  ]));
62
69
  return { kind: "page", template: hit.template, page: pageContextOf(hit, deps.href), zones, head };
63
70
  }
@@ -2,13 +2,18 @@ export type CanvasFlags = {
2
2
  editor: true;
3
3
  } | {
4
4
  version: string;
5
+ } | {
6
+ samples: string;
5
7
  };
8
+ /** The samples flag in the query, where a host keeps it (Astro; Next off Vercel). */
9
+ export declare const SAMPLES_PARAM = "smoodly-samples";
6
10
  /** `path` with the marker as its last segment, before any query or hash. */
7
11
  export declare function markCanvasPath(path: string, flags: CanvasFlags): string;
8
12
  export type CanvasMarker = {
9
13
  segments: string[];
10
14
  editor: boolean;
11
15
  version?: string;
16
+ samples?: string;
12
17
  };
13
18
  /** The marker taken off a catch-all's segments, which a host may hand over encoded or not. */
14
19
  export declare function readCanvasMarker(segments: string[]): CanvasMarker;
@@ -3,13 +3,18 @@
3
3
  // on Next the flags ride the path's last segment: `~editor` (mount the editor bridge) or `~version~<id>` (render a
4
4
  // stored version). The admin writes it (admin/editor/canvas-address.ts), the Next renderer reads it inside draft
5
5
  // mode only; outside draft mode a marked address is an ordinary miss. A slug never starts with `~`.
6
+ // `~samples~<zone>` (spec 2026-11-14 add section previews) asks for the zone's picker rows drawn from their samples
7
+ // in place of the page's content; where the host keeps the query it rides there as `smoodly-samples`.
8
+ /** The samples flag in the query, where a host keeps it (Astro; Next off Vercel). */
9
+ export const SAMPLES_PARAM = "smoodly-samples";
6
10
  const EDITOR = "~editor";
7
11
  const VERSION = "~version~";
12
+ const SAMPLES = "~samples~";
8
13
  /** `path` with the marker as its last segment, before any query or hash. */
9
14
  export function markCanvasPath(path, flags) {
10
15
  const cut = path.search(/[?#]/);
11
16
  const [pathname, rest] = cut === -1 ? [path, ""] : [path.slice(0, cut), path.slice(cut)];
12
- const segment = "editor" in flags ? EDITOR : VERSION + encodeURIComponent(flags.version);
17
+ const segment = "editor" in flags ? EDITOR : "version" in flags ? VERSION + encodeURIComponent(flags.version) : SAMPLES + encodeURIComponent(flags.samples);
13
18
  return `${pathname.replace(/\/+$/, "")}/${segment}${rest}`;
14
19
  }
15
20
  /** The marker taken off a catch-all's segments, which a host may hand over encoded or not. */
@@ -18,12 +23,19 @@ export function readCanvasMarker(segments) {
18
23
  const rest = segments.slice(0, -1);
19
24
  if (last === EDITOR)
20
25
  return { segments: rest, editor: true };
21
- if (last.startsWith(VERSION) && last.length > VERSION.length) {
26
+ // The value was encoded once when the marker was written; a host that decoded the segment already handed it over plain.
27
+ const valueAfter = (prefix) => {
28
+ if (!last.startsWith(prefix) || last.length === prefix.length)
29
+ return undefined;
22
30
  const raw = segments[segments.length - 1];
23
- // The id was encoded once when the marker was written; a host that decoded the segment already handed it over plain.
24
- const id = raw.startsWith(VERSION) ? safeDecode(raw.slice(VERSION.length)) : last.slice(VERSION.length);
25
- return { segments: rest, editor: false, version: id };
26
- }
31
+ return raw.startsWith(prefix) ? safeDecode(raw.slice(prefix.length)) : last.slice(prefix.length);
32
+ };
33
+ const version = valueAfter(VERSION);
34
+ if (version !== undefined)
35
+ return { segments: rest, editor: false, version };
36
+ const samples = valueAfter(SAMPLES);
37
+ if (samples !== undefined)
38
+ return { segments: rest, editor: false, samples };
27
39
  return { segments, editor: false };
28
40
  }
29
41
  function safeDecode(segment) {