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
@@ -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" ? "Site" : 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];
@@ -40,7 +40,7 @@ export function ThemeToggle() {
40
40
  }
41
41
  catch { /* not remembered */ }
42
42
  };
43
- return (_jsxs("button", { type: "button", role: "menuitem", className: "sm-rail__menu-item", onClick: toggle, children: [_jsxs("span", { className: "sm-when-light", children: [_jsx(Moon, { ...ICON }), "Dark mode"] }), _jsxs("span", { className: "sm-when-dark", children: [_jsx(Sun, { ...ICON }), "Light mode"] })] }));
43
+ return (_jsxs("button", { type: "button", role: "menuitem", className: "sm-navbar__menu-item", onClick: toggle, children: [_jsxs("span", { className: "sm-when-light", children: [_jsx(Moon, { ...ICON }), "Dark mode"] }), _jsxs("span", { className: "sm-when-dark", children: [_jsx(Sun, { ...ICON }), "Light mode"] })] }));
44
44
  }
45
45
  export function Button({ variant = "primary", size = "md", className, ...rest }) {
46
46
  return (_jsx("button", { type: "button", className: cx("sm-btn", `sm-btn--${variant}`, size === "lg" && "sm-btn--lg", size === "sm" && "sm-btn--sm", className), ...rest }));
@@ -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;
@@ -34,8 +35,8 @@ const TOKENS_LIGHT = `
34
35
  --selection-global:#C4620C;
35
36
  --hover:rgba(21,23,39,.05); --press:rgba(21,23,39,.09); --control-hover:#ECEDF1; --canvas-ground:#EDEDED;
36
37
  --btn-secondary-bg:var(--surface); --btn-secondary-hover:#ECEDF1;
37
- /* The rail sits on the frame: its own greys, the active tile a white tint. */
38
- --rail-fg:#B9BDCE; --rail-active-fg:#FFFFFF; --rail-active-bg:rgba(255,255,255,.14); --rail-hover-bg:rgba(255,255,255,.08);
38
+ /* The navigation bar sits on the frame: its own greys, the active item a white tint, its dark menus a step lighter than the frame. */
39
+ --navbar-fg:#B9BDCE; --navbar-active-fg:#FFFFFF; --navbar-active-bg:rgba(255,255,255,.14); --navbar-hover-bg:rgba(255,255,255,.08); --navbar-menu-bg:#242840;
39
40
  --bar-bg:transparent; --bar-border:0; --menu-border:0; --card-divider:0;
40
41
  --card-title-size:var(--text-md); --card-title-color:var(--text);
41
42
  /* In-card segmented (tabs, the span bar): white track, the chosen option in the tile grey. */
@@ -52,7 +53,7 @@ const TOKENS_LIGHT = `
52
53
  dark mode that washed out over the white page, 2026-09-10). */
53
54
  --plus-bg:#DDEBE4; --plus-hover:#D0E3D9; --plus-fg:#1C6E5B;
54
55
  --plus-icon-bg:#1C6E5B; --plus-icon-fg:#FFFFFF; --plus-shadow:none;
55
- --logo-color:var(--accent); --logo-outer:#0B5A49; --logo-inner:#5FC9A0; /* the rail's app icon: deep green square, mint s (2026-10-23) */
56
+ --logo-color:var(--accent); --logo-outer:#0B5A49; --logo-inner:#5FC9A0; /* the bar's app icon: deep green square, mint s (2026-10-23) */
56
57
 
57
58
  --status-published-fg:var(--green-700); --status-published-bg:var(--green-50);
58
59
  --status-changes-fg:var(--amber-700); --status-changes-bg:var(--amber-50);
@@ -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;
@@ -78,13 +80,13 @@ const TOKENS_LIGHT = `
78
80
  `;
79
81
  /* The handoff's dark proposal (VisualEditor.md, 8c right board): the frame
80
82
  and sheet a step darker, tiles a step lighter, fields in the sheet tone;
81
- the rail and the logo unchanged. */
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,19 +128,19 @@ 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);
133
135
  --control-h:40px; --control-h-sm:34px; --control-h-lg:48px; --button-h:36px; --button-h-sm:28px; --row-h:48px; --topbar-h:64px;
134
136
  --outline-row-h:36px; --seg-pad-y:7px; --tabs-pad:3px;
135
137
  --plus-h:32px; --plus-pad:0 14px 0 6px; --plus-label:inline;
136
- --nav-w:72px; --inspector-w:360px; --gutter:20px;
137
- /* The frame is 8px above, right of and below the sheet (6px in the handoff, +2px 2026-10-23); the rail fills the left edge. */
138
- --frame-pad:8px; --frame-pad-top:8px; --shell-gap:0px; --sheet-radius:20px; --sheet-pad:0 24px 24px;
139
- --rail-pad:18px 6px 16px; --rail-logo:block; --rail-logo-opacity:1; --rail-tile-w:44px; --rail-tile-h:40px; --rail-tile-radius:12px;
138
+ --inspector-w:360px; --gutter:20px;
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. */
140
+ --frame-pad:8px; --shell-gap:0px; --sheet-radius:12px; --sheet-pad:0 24px 24px;
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);
@@ -182,106 +184,106 @@ html:has(.sm-admin),body:has(.sm-admin){overscroll-behavior:none}
182
184
  .sm-code{background:var(--surface);border-radius:8px;padding:10px 12px;margin:0;font:13px/1.5 var(--font-mono);overflow:auto}
183
185
 
184
186
  /* ── shell ─────────────────────────────────────────────────────────── */
185
- /* Rail | main. The shell is the frame (design_handoff_smoodly_neutral/
186
- components/AppShell.html): navy, 8px above, right of and below the main
187
+ /* Bar over main (2026-11-14; a 72px rail on the left before it). The shell is
188
+ the frame (design_handoff_smoodly_neutral/components/AppShell.html): navy,
189
+ the navigation bar across its top, 8px right of, left of and below the main
187
190
  column, which is the white sheet with a 20px radius and its own inner
188
- padding; the rail fills the frame's left edge.
191
+ padding. The shell owns the viewport and the sheet scrolls inside itself,
192
+ so the bar stays put.
189
193
  The main column stacks the top bar over one tile-shaped region: list,
190
194
  form or editor. */
191
195
  .sm-shell{
192
- display:grid;grid-template-columns:var(--nav-w) 1fr;column-gap:var(--shell-gap);
193
- padding:var(--frame-pad-top) var(--frame-pad) var(--frame-pad) 0;height:100vh;height:100dvh;overflow:hidden;background:var(--frame);
196
+ display:grid;grid-template-rows:var(--navbar-h) minmax(0,1fr);grid-template-columns:minmax(0,1fr);row-gap:var(--shell-gap);
197
+ padding:0 var(--frame-pad) var(--frame-pad);height:100vh;height:100dvh;overflow:hidden;background:var(--frame);
194
198
  }
195
199
  .sm-shell>.sm-main{
196
200
  min-width:0;min-height:0;display:flex;flex-direction:column;overflow:hidden;
197
201
  background:var(--sheet);border-radius:var(--sheet-radius);padding:var(--sheet-pad);
198
202
  }
199
203
  .sm-wordmark{height:18px;width:auto;color:var(--logo-color);filter:var(--logo-filter);flex:none}
200
- /* The IconRail (design_handoff_smoodly_neutral/components/IconRail.html):
201
- a 72px rail on the frame with the app icon on top, 18px Lucide icons
202
- over a 10px label and a 44×40 active tile in a white tint — the rail's
203
- own colour tokens, since the frame is navy in both themes. */
204
- .sm-rail{
205
- width:var(--nav-w);height:100%;display:flex;flex-direction:column;align-items:center;gap:18px;
206
- padding:var(--rail-pad);background:var(--bar-bg);border-right:var(--bar-border);color:var(--rail-fg);
207
- font:500 10px/1 var(--font-sans);position:relative;
208
- }
204
+ /* The navigation bar: the app icon on the left, the items centred, Deploy and
205
+ the avatar on the right. The rail's colours, icons and order: 18px Lucide
206
+ icons beside a 14px label, the active item a white-tint pill. */
207
+ .sm-navbar{
208
+ display:grid;grid-template-columns:1fr auto 1fr;align-items:center;gap:16px;min-width:0;
209
+ padding:0 var(--navbar-pad-x);background:var(--bar-bg);color:var(--navbar-fg);
210
+ font:500 var(--text-md)/1 var(--font-sans);position:relative;z-index:30;
211
+ }
212
+ .sm-navbar__start{display:flex;align-items:center;justify-self:start}
213
+ .sm-navbar__end{display:flex;align-items:center;gap:10px;justify-self:end}
209
214
  /* The app icon (the Smoodly menu's trigger): the deep green square with the
210
- mint s, 26px wide (2026-10-23; the lozenge before it was 20px, asked for
211
- 2026-09-26, at .8 opacity). */
212
- .sm-rail__brand{appearance:none;background:none;border:0;padding:0;margin-bottom:6px;cursor:pointer;display:block;border-radius:6px;color:inherit}
215
+ mint s (2026-10-23). */
216
+ .sm-navbar__brand{appearance:none;background:none;border:0;padding:0;cursor:pointer;display:block;border-radius:8px;color:inherit}
213
217
  /* Hover (2026-10-23): the app icon and the avatar brighten a touch. */
214
- .sm-rail__logo{transition:filter .15s}
215
- .sm-rail__brand:hover .sm-rail__logo{filter:brightness(1.3)}
216
- .sm-rail__user{transition:background .15s}
217
- .sm-rail__user:hover{background:color-mix(in srgb,var(--accent-soft),#fff 60%)}
218
- .sm-rail__brand:focus-visible{outline:2px solid var(--rail-active-fg);outline-offset:2px;box-shadow:none}
219
- .sm-rail__logo{width:26px;height:auto;display:var(--rail-logo);flex:none;opacity:var(--rail-logo-opacity)}
220
- .sm-rail__ext{width:13px;height:13px;flex:none;margin-left:12px;opacity:.45}
221
- .sm-rail__menu-item:hover .sm-rail__ext,.sm-rail__menu-item:focus-visible .sm-rail__ext{opacity:.8}
222
- .sm-rail__menu-version{font-size:var(--text-xs);color:var(--text-subtle);padding:7px 10px 4px;margin-top:3px;border-top:1px solid var(--line)}
223
- .sm-rail__list{display:flex;flex-direction:column;gap:8px;width:100%;margin:0;padding:0;list-style:none}
224
- .sm-rail__item{position:relative}
225
- .sm-rail__btn{
226
- appearance:none;background:none;border:0;margin:0;padding:2px 0;width:100%;display:flex;flex-direction:column;
227
- align-items:center;gap:6px;color:inherit;font:inherit;cursor:pointer;border-radius:var(--radius-md);text-decoration:none;
228
- }
229
- .sm-rail__btn:hover{color:inherit}
230
- .sm-rail__btn:focus-visible{outline:2px solid var(--rail-active-fg);outline-offset:-2px;box-shadow:none}
231
- .sm-rail__tile{position:relative;width:var(--rail-tile-w);height:var(--rail-tile-h);border-radius:var(--rail-tile-radius);display:grid;place-items:center;transition:background .12s}
232
- .sm-rail__btn:hover .sm-rail__tile{background:var(--rail-hover-bg)}
233
- .sm-rail__btn[aria-current="page"],.sm-rail__btn[aria-expanded="true"]{color:var(--rail-active-fg);font-weight:600}
234
- .sm-rail__btn[aria-current="page"]:hover,.sm-rail__btn[aria-expanded="true"]:hover{color:var(--rail-active-fg)}
235
- .sm-rail__btn[aria-current="page"] .sm-rail__tile,.sm-rail__btn[aria-expanded="true"] .sm-rail__tile{background:var(--rail-active-bg)}
236
- .sm-rail__tile svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:2;stroke-linecap:round;stroke-linejoin:round}
237
- /* A collection's own title can be long: it ends in an ellipsis inside the rail (2026-11-13). */
238
- .sm-rail__label{letter-spacing:.01em;white-space:nowrap;max-width:100%;overflow:hidden;text-overflow:ellipsis}
239
- /* Not greyed: the reference renders every inactive item at --text and marks
240
- the unavailable ones only by being inert (no pointer, no hover). */
241
- .sm-rail__btn[aria-disabled="true"]{cursor:default}
242
- .sm-rail__btn[aria-disabled="true"]:hover .sm-rail__tile{background:transparent}
243
- /* The Deploy tile (spec 2026-10-09 static mode §6): above the avatar; the badge is the count waiting, the
244
- lit tile a build in flight, the warning tile a build that has not landed or a site that did not answer. */
245
- .sm-rail__deploy{margin-top:auto}
246
- .sm-rail__deploy+.sm-rail__foot{margin-top:0}
247
- /* A publish on its way to the site (plan 2026-10-14 publish through the queue): a quiet line above the Deploy tile
248
- and the avatar, the warning colour when a delivery landed late or with an error. */
249
- .sm-rail__badge{position:absolute;top:-4px;right:-6px;min-width:16px;height:16px;padding:0 4px;border-radius:var(--radius-round);background:var(--accent);color:#fff;font-size:10px;font-weight:600;line-height:16px;text-align:center}
250
- .sm-rail__tile--lit{background:var(--accent-soft);color:var(--accent);animation:sm-rail-pulse 1.6s ease-in-out infinite}
251
- .sm-rail__tile--warn{background:var(--warn-soft);color:var(--warn)}
252
- @keyframes sm-rail-pulse{0%,100%{opacity:1}50%{opacity:.55}}
253
- .sm-rail__menu-detail{font-size:var(--text-sm);color:var(--text);padding:6px 8px 4px;white-space:normal}
254
- .sm-rail__menu-note{font-size:var(--text-xs);color:var(--text-muted);padding:4px 8px 6px;white-space:normal}
255
- .sm-rail__menu-item:disabled{cursor:default;color:var(--text-muted)}
256
- .sm-rail__menu-item:disabled:hover{background:none}
257
- .sm-rail__foot{margin-top:auto;display:flex;flex-direction:column;align-items:center}
258
- .sm-rail__user{
259
- width:28px;height:28px;border-radius:var(--radius-round);background:var(--accent-soft);color:var(--accent);
260
- display:grid;place-items:center;font-size:var(--text-xs);font-weight:600;border:0;cursor:pointer;padding:0;
261
- }
262
- .sm-rail__user:focus-visible{outline:2px solid var(--rail-active-fg);outline-offset:1px;box-shadow:none}
263
- /* Flyout: 6px right of the rail, aligned with its item (the user menu aligns to the bottom).
218
+ .sm-navbar__logo{width:30px;height:auto;display:block;flex:none;transition:filter .15s}
219
+ .sm-navbar__brand:hover .sm-navbar__logo{filter:brightness(1.3)}
220
+ .sm-navbar__brand:focus-visible{outline:2px solid var(--navbar-active-fg);outline-offset:2px;box-shadow:none}
221
+ .sm-navbar__user{
222
+ width:32px;height:32px;border-radius:var(--radius-round);background:var(--accent-soft);color:var(--accent);
223
+ display:grid;place-items:center;font-size:var(--text-xs);font-weight:600;border:0;cursor:pointer;padding:0;transition:background .15s;
224
+ }
225
+ .sm-navbar__user:hover{background:var(--avatar-hover)}
226
+ .sm-navbar__user:focus-visible{outline:2px solid var(--navbar-active-fg);outline-offset:2px;box-shadow:none}
227
+ .sm-navbar__ext{width:13px;height:13px;flex:none;margin-left:12px;opacity:.45}
228
+ .sm-navbar__menu-item:hover .sm-navbar__ext,.sm-navbar__menu-item:focus-visible .sm-navbar__ext{opacity:.8}
229
+ .sm-navbar__menu-version{font-size:var(--text-xs);color:var(--text-subtle);padding:7px 10px 4px;margin-top:3px;border-top:1px solid var(--line)}
230
+ .sm-navbar__list{display:flex;align-items:center;gap:4px;margin:0;padding:0;list-style:none;min-width:0}
231
+ .sm-navbar__item{position:relative;list-style:none}
232
+ .sm-navbar__btn{
233
+ appearance:none;background:none;border:0;margin:0;height:var(--navbar-item-h);padding:0 12px;display:flex;align-items:center;gap:8px;
234
+ color:inherit;font:inherit;cursor:pointer;border-radius:var(--navbar-item-radius);text-decoration:none;white-space:nowrap;transition:background .12s,color .12s;
235
+ }
236
+ .sm-navbar__btn--icon{padding:0;width:var(--navbar-item-h);justify-content:center}
237
+ .sm-navbar__btn:hover{color:var(--navbar-active-fg);background:var(--navbar-hover-bg)}
238
+ .sm-navbar__btn:focus-visible{outline:2px solid var(--navbar-active-fg);outline-offset:-2px;box-shadow:none}
239
+ .sm-navbar__btn[aria-current="page"],.sm-navbar__btn[aria-expanded="true"]{color:var(--navbar-active-fg);background:var(--navbar-active-bg)}
240
+ .sm-navbar__btn svg{flex:none;stroke:currentColor;fill:none;stroke-linecap:round;stroke-linejoin:round}
241
+ .sm-navbar__chevron{margin-left:-2px;opacity:.8}
242
+ /* A collection's own title can be long: it ends in an ellipsis (2026-11-13). */
243
+ .sm-navbar__label{letter-spacing:.01em;white-space:nowrap;max-width:160px;overflow:hidden;text-overflow:ellipsis}
244
+ /* A narrow window keeps the icons and drops the labels; each item keeps its name for a screen reader and a tooltip. */
245
+ @media (max-width:820px){
246
+ .sm-navbar__list .sm-navbar__label,.sm-navbar__deploy .sm-navbar__label{position:absolute;width:1px;height:1px;overflow:hidden;clip-path:inset(50%)}
247
+ .sm-navbar__btn{padding:0 10px}
248
+ }
249
+ /* The Deploy item (spec 2026-10-09 static mode §6): next to the avatar; the badge is the count waiting, the
250
+ lit item a build in flight, the warning item a build that has not landed or a site that did not answer. */
251
+ .sm-navbar__icon{position:relative;display:grid;place-items:center}
252
+ .sm-navbar__badge{position:absolute;top:-7px;right:-9px;min-width:16px;height:16px;padding:0 4px;border-radius:var(--radius-round);background:var(--accent);color:#fff;font-size:10px;font-weight:600;line-height:16px;text-align:center}
253
+ .sm-navbar__btn--lit,.sm-navbar__btn--lit:hover{background:var(--accent-soft);color:var(--accent);animation:sm-navbar-pulse 1.6s ease-in-out infinite}
254
+ .sm-navbar__btn--warn,.sm-navbar__btn--warn:hover{background:var(--warn-soft);color:var(--warn)}
255
+ @keyframes sm-navbar-pulse{0%,100%{opacity:1}50%{opacity:.55}}
256
+ .sm-navbar__menu-detail{font-size:var(--text-sm);color:var(--text);padding:6px 8px 4px;white-space:normal}
257
+ .sm-navbar__menu-note{font-size:var(--text-xs);color:var(--text-muted);padding:4px 8px 6px;white-space:normal}
258
+ .sm-navbar__menu-item:disabled{cursor:default;color:var(--text-muted)}
259
+ .sm-navbar__menu-item:disabled:hover{background:none}
260
+ /* A menu drops 6px below its item, aligned with its left edge (the end group's with its right edge).
264
261
  Menus float on --menu-bg (the sheet white in the neutral look, so they read over the frame). */
265
- .sm-rail__menu{
266
- position:absolute;left:calc(100% + 6px);top:0;z-index:20;width:168px;padding:6px;margin:0;list-style:none;
262
+ .sm-navbar__menu{
263
+ position:absolute;left:0;top:calc(100% + 6px);z-index:20;width:200px;padding:6px;margin:0;list-style:none;
267
264
  background:var(--menu-bg);border:var(--menu-border);border-radius:var(--radius-md);box-shadow:var(--shadow-pop);
268
- display:none;flex-direction:column;gap:1px;font-size:var(--text-md);font-weight:500;color:var(--text);
269
- }
270
- .sm-rail__menu[data-open="true"]{display:flex}
271
- .sm-rail__menu--up{top:auto;bottom:0}
272
- /* The Collections flyout opens on hover, so it is roomier (the names at --text-lg) and an invisible
273
- bridge spans the 6px gap so the pointer can cross from the rail without closing it. */
274
- .sm-rail__menu--brand{width:220px}
275
- .sm-rail__menu--collections{width:208px;padding:8px;gap:2px;font-size:var(--text-lg)}
276
- .sm-rail__menu--collections::before{content:"";position:absolute;left:-8px;top:0;bottom:0;width:8px}
277
- .sm-rail__menu--collections .sm-rail__menu-item{padding:9px 12px}
278
- .sm-rail__menu-title{font-size:9px;font-weight:600;letter-spacing:var(--tracking-label);text-transform:uppercase;color:var(--text-muted);padding:6px 8px 4px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
279
- .sm-rail__menu-item{
265
+ display:none;flex-direction:column;gap:1px;font-size:var(--text-md);font-weight:500;line-height:1.4;color:var(--text);
266
+ }
267
+ .sm-navbar__menu[data-open="true"]{display:flex}
268
+ .sm-navbar__menu--end{left:auto;right:0;width:240px}
269
+ .sm-navbar__menu--brand{width:240px}
270
+ /* An invisible bridge spans the 6px gap so the pointer can cross from Collections into its hover menu. */
271
+ .sm-navbar__menu--collections::before{content:"";position:absolute;left:0;right:0;top:-8px;height:8px}
272
+ .sm-navbar__menu-title{font-size:9px;font-weight:600;letter-spacing:var(--tracking-label);text-transform:uppercase;color:var(--text-muted);padding:6px 8px 4px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
273
+ .sm-navbar__menu-item{
280
274
  appearance:none;background:none;border:0;width:100%;display:flex;align-items:center;justify-content:space-between;
281
275
  padding:7px 10px;border-radius:var(--radius-sm);color:inherit;font:inherit;cursor:pointer;text-align:left;text-decoration:none;
282
276
  }
283
- .sm-rail__menu-item:hover,.sm-rail__menu-item:focus-visible{background:var(--hover);color:inherit;outline:0;box-shadow:none}
284
- .sm-rail__menu-item[aria-current="page"],.sm-rail__menu-item[aria-current="page"]:hover{background:var(--nav-active-bg);color:var(--nav-active-fg);font-weight:600}
277
+ .sm-navbar__menu-item:hover,.sm-navbar__menu-item:focus-visible{background:var(--menu-hover);color:inherit;outline:0;box-shadow:none}
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}
279
+ .sm-navbar__menu-label{display:flex;align-items:center;gap:10px;min-width:0}
280
+ .sm-navbar__menu-label>span{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
281
+ .sm-navbar__menu-label svg{flex:none;stroke:currentColor;fill:none}
282
+ /* Collections and ⋮ are dark (2026-11-14): they carry the bar's own items, so they take its colours. */
283
+ .sm-navbar__menu--dark{background:var(--navbar-menu-bg);color:var(--navbar-fg);border:0;padding:6px;gap:2px}
284
+ .sm-navbar__menu--dark .sm-navbar__menu-item{padding:9px 12px}
285
+ .sm-navbar__menu--dark .sm-navbar__menu-item:hover,.sm-navbar__menu--dark .sm-navbar__menu-item:focus-visible{background:var(--navbar-hover-bg);color:var(--navbar-active-fg)}
286
+ .sm-navbar__menu--dark .sm-navbar__menu-item[aria-current="page"],.sm-navbar__menu--dark .sm-navbar__menu-item[aria-current="page"]:hover{background:var(--navbar-active-bg);color:var(--navbar-active-fg)}
285
287
  .sm-when-light{display:var(--when-light);align-items:center;gap:8px}
286
288
  .sm-when-dark{display:var(--when-dark);align-items:center;gap:8px}
287
289
  /* .sm-nav-item survives as the inspector's link style (EditorView). */
@@ -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
  }
@@ -488,6 +490,7 @@ html:has(.sm-admin),body:has(.sm-admin){overscroll-behavior:none}
488
490
  /* Inline editing in the canvas (spec 2026-09-10). The outlines are drawn in
489
491
  the overlay from the bridge's field rects; the toolbar floats above the
490
492
  selection. */
493
+ .sm-dim{background:color-mix(in srgb,var(--canvas) 62%,transparent);pointer-events:none}
491
494
  .sm-fieldhover{outline:1px solid color-mix(in srgb,var(--selection) 55%,transparent);outline-offset:2px;border-radius:2px;pointer-events:none}
492
495
  .sm-fieldedit{outline:1.5px solid var(--selection);outline-offset:3px;border-radius:2px;pointer-events:none}
493
496
  .sm-floatbar{
@@ -563,7 +566,7 @@ html:has(.sm-admin),body:has(.sm-admin){overscroll-behavior:none}
563
566
  .sm-listnotice:empty{display:none}
564
567
  /* The rows sit in a centred column of at most 1200px inside the card,
565
568
  under --table-top of air reserved for the coming search and filters. */
566
- .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}
567
570
  /* The filter sits in that air, so the column heads stay where they are on a list without one (2026-11-13). */
568
571
  .sm-table--tools{padding-top:20px}
569
572
  .sm-table__tools{display:flex;align-items:center;gap:12px;height:40px;margin-bottom:20px;padding:0 8px}
@@ -800,6 +803,13 @@ html:has(.sm-admin),body:has(.sm-admin){overscroll-behavior:none}
800
803
  min-width:0;min-height:0;overflow:hidden;
801
804
  }
802
805
  .sm-canvas__scroll{flex:1;min-height:0;overflow:auto;padding:var(--canvas-pad)}
806
+ /* A global item's canvas (spec 2026-11-14): a row above the frame — where the item is used, and which page shows it. */
807
+ .sm-globalcanvas{display:flex;flex-direction:column;min-width:0;min-height:0}
808
+ .sm-globalcanvas>.sm-canvas{flex:1}
809
+ .sm-globalcanvas__bar{flex:none;display:flex;align-items:center;justify-content:space-between;gap:12px;flex-wrap:wrap;padding:0 4px 8px;min-height:28px}
810
+ .sm-globalcanvas__bar .sm-hint{margin:0}
811
+ .sm-globalcanvas__pick{display:flex;align-items:center;gap:8px}
812
+ .sm-globalcanvas__pick .sm-select{width:auto;height:30px;padding:0 10px}
803
813
  .sm-page{
804
814
  position:relative;background:var(--page-bg);border-radius:var(--page-radius);
805
815
  box-shadow:var(--shadow-page);min-height:100%;
@@ -955,6 +965,22 @@ html:has(.sm-admin),body:has(.sm-admin){overscroll-behavior:none}
955
965
  width:26px;height:26px;flex:none;border-radius:var(--radius-sm);background:var(--accent-soft);
956
966
  color:var(--accent);display:grid;place-items:center;font-size:13px;
957
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}
958
984
  /* Combobox (spec 2026-09-17 §1): the input owns the width, the list hangs under it —
959
985
  portaled and fixed at the input's width (--combo-w), above modals and below
960
986
  the confirm scrim (2026-09-27); inline under the input in static markup. */
@@ -1106,6 +1132,7 @@ html:has(.sm-admin),body:has(.sm-admin){overscroll-behavior:none}
1106
1132
  }
1107
1133
  .sm-media__thumb{position:relative;overflow:hidden;max-width:100%;max-height:300px;background:var(--surface-inset)}
1108
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%}
1109
1136
  .sm-media__alt{font-size:var(--text-sm);color:var(--text-muted);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
1110
1137
  .sm-media>.sm-media__alt{text-align:center}
1111
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. */