smoodly 0.0.55 → 0.0.57

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.
@@ -10,6 +10,7 @@ import { DatePicker } from "./DatePicker.js";
10
10
  import { optionsOf } from "../../fields.js";
11
11
  import { isHidden } from "../../conditions.js";
12
12
  import { cssProblem } from "../../css.js";
13
+ import { linkProblem } from "../validate.js";
13
14
  import { Button, Field, Switch } from "../ui/primitives.js";
14
15
  import { fieldLabel } from "../field-label.js";
15
16
  import { patchObject } from "./list.js";
@@ -22,6 +23,8 @@ export function FieldWidget({ descriptor: d, value: valueProp, onChange, errors,
22
23
  const message = errors?.[path] ?? (below ? errors?.[below] : undefined);
23
24
  // The descriptor's default shows while the value is absent (spec 2026-09-10 page fields §1).
24
25
  const value = valueProp === undefined && d.default !== undefined ? d.default : valueProp;
26
+ // A link the site would not render is marked as it is typed; publish refuses it with the same words.
27
+ const problem = d.type === "link" ? linkProblem(value) : null;
25
28
  // The custom section's rows (spec 2026-09-14 §6): the row cards instead of the generic list.
26
29
  if (d.widget === "rows") {
27
30
  const rows = _jsx(RowsWidget, { descriptor: d, value: value, onChange: onChange, errors: errors, path: path, inlinePath: inlinePath, onEditHere: onEditHere });
@@ -146,7 +149,7 @@ export function FieldWidget({ descriptor: d, value: valueProp, onChange, errors,
146
149
  // A <label> activates its first labelable descendant, so it may only wrap a
147
150
  // single control — around refList's rows, an object's fieldset or a list's
148
151
  // rows it would turn every click into a press of the first button.
149
- return (_jsx(Field, { label: label(fieldKey), mark: mark, counter: counter, hint: hint, error: message, asDiv: asDiv, path: path, children: control }));
152
+ return (_jsx(Field, { label: label(fieldKey), mark: mark, counter: counter, hint: hint, error: message ?? problem ?? undefined, asDiv: asDiv, path: path, children: control }));
150
153
  }
151
154
  /** The richtext field while it is edited in the canvas (spec 2026-09-10 §5). */
152
155
  export function InlineNote({ onEditHere }) {
@@ -14,27 +14,32 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
14
14
  // 2026-09-25; the route is /settings; every member has a page there since
15
15
  // Redirects, 2026-09-26, so the shell passes `settings` once the role is
16
16
  // known). The user avatar is pinned to the bottom and opens the account
17
- // flyout: the email, the theme toggle, "Your sites at smoodly.io" and Sign
17
+ // flyout: the email, the theme toggle, "My Account" (at smoodly.io) and Sign
18
18
  // out (spec 2026-10-16 sign-in and the dashboard §6).
19
19
  //
20
20
  // Flyouts: click toggles, Esc closes and returns focus to the trigger,
21
21
  // ↑/↓ move between entries, a pointer down outside closes. A child being
22
22
  // active highlights its parent tile and the entry inside the menu.
23
23
  import { useEffect, useId, useRef, useState } from "react";
24
- import { Globe, Image, LibraryBig, PanelTop, RadioTower, Settings } from "lucide-react";
24
+ import { ExternalLink, Globe, Image, LibraryBig, PanelTop, RadioTower, Settings } from "lucide-react";
25
25
  import { tileCopy } from "./deploy-tile.js";
26
26
  import { ThemeToggle } from "../ui/primitives.js";
27
27
  import { useAdminHref } from "./base.js";
28
28
  import { consoleLinks } from "./console-links.js";
29
+ import { SMOODLY_VERSION } from "../../version.js";
29
30
  import { RouteLink } from "./RouteLink.js";
30
31
  /** Lucide at the rail's size and stroke (the tile's CSS restates both). */
31
32
  const ICON = { size: 18, strokeWidth: 2, "aria-hidden": true };
32
33
  /** The app icon, inlined: a package can't ship a file into the host app's
33
- * /public. design_handoff_smoodly_neutral/assets/smoodly-logo.svg — the
34
- * outer and inner shapes take --logo-outer / --logo-inner (white over
35
- * navy on the frame), so a look decides its colours. */
34
+ * /public. The wordmark's "s" on a rounded square (chosen 2026-10-23; the
35
+ * same tile, green with a white s, is smoodly.io's favicon). The square takes
36
+ * --logo-outer and the glyph --logo-inner, so a look decides its colours. */
36
37
  function AppIcon() {
37
- return (_jsxs("svg", { className: "sm-rail__logo", viewBox: "0 0 28 27", fill: "none", xmlns: "http://www.w3.org/2000/svg", role: "img", "aria-label": "Smoodly", children: [_jsx("path", { style: { fill: "var(--logo-outer)" }, d: "M0 16.1859C0 7.6335 7.88106 0 16.5324 0H16.6037C23.429 0 28 4.59428 28 10.8141C28 19.3666 20.2618 27 11.3279 27C4.43425 27 0 22.2645 0 16.1859Z" }), _jsx("path", { style: { fill: "var(--logo-inner)" }, d: "M7 14.7932C7 10.6754 10.6591 7 14.6757 7H14.7089C17.8777 7 20 9.21206 20 12.2068C20 16.3247 16.4072 20 12.2594 20C9.05876 20 7 17.7199 7 14.7932Z" })] }));
38
+ return (_jsxs("svg", { className: "sm-rail__logo", viewBox: "0 0 64 64", fill: "none", xmlns: "http://www.w3.org/2000/svg", role: "img", "aria-label": "Smoodly", children: [_jsx("rect", { width: "64", height: "64", rx: "14", style: { fill: "var(--logo-outer)" } }), _jsx("path", { style: { fill: "var(--logo-inner)" }, transform: "translate(14.382 -4.365) scale(0.7123)", d: "M30.5851 22.9763C36.7891 22.9763 45.6052 24.6681 49.3603 26.5237L46.2583 42.2415C41.6869 40.113 35.2653 38.4212 29.4422 38.4212C26.0137 38.4212 23.7825 39.1852 23.7825 40.9862C23.7825 42.1869 24.9797 42.8964 27.1021 43.333L31.5647 44.3153C40.8162 46.3346 45.5508 51.1373 45.5508 58.505C45.5508 73.4587 33.9592 79.1345 18.939 79.1345C11.4833 79.1345 4.24536 77.3335 0.109375 75.6963L3.42905 58.9416C8.97998 61.8341 16.4356 63.6351 21.2247 63.6351C24.6532 63.6351 26.9933 62.7619 26.9933 60.6334C26.9933 59.4327 26.1226 58.505 23.5648 58.0684L19.7553 57.3043C12.5173 55.9399 6.31336 50.9735 6.31336 43.0055C6.31336 30.6169 15.6737 23.0309 30.6939 23.0309L30.5851 22.9763Z" })] }));
39
+ }
40
+ /** A menu link that opens smoodly.io in a new tab, with the arrow that says so. */
41
+ function ExtLink({ href, onClick, children }) {
42
+ return (_jsxs("a", { role: "menuitem", className: "sm-rail__menu-item", href: href, target: "_blank", rel: "noreferrer", onClick: onClick, children: [_jsx("span", { children: children }), _jsx(ExternalLink, { className: "sm-rail__ext", size: 13, strokeWidth: 1.75, "aria-hidden": "true" })] }));
38
43
  }
39
44
  export function IconRail({ registry, active, email, settings = true, deploy, onSignOut }) {
40
45
  const adminHref = useAdminHref();
@@ -92,9 +97,9 @@ export function IconRail({ registry, active, email, settings = true, deploy, onS
92
97
  const collectionsOpen = open === "collections" || (hovered && open === null);
93
98
  const name = email?.split("@")[0] ?? "Editor";
94
99
  const initials = name.slice(0, 2).toUpperCase();
95
- return (_jsxs("nav", { className: "sm-rail", "aria-label": "Primary", ref: root, onKeyDown: onKeyDown, children: [_jsx(AppIcon, {}), _jsxs("ul", { className: "sm-rail__list", children: [link("pages", "Pages", _jsx(PanelTop, { ...ICON }), adminHref("/pages")), collections.length > 0 && (_jsxs("li", { className: "sm-rail__item", onMouseEnter: () => setHovered(true), onMouseLeave: hoverLeave, children: [_jsxs("button", { type: "button", className: "sm-rail__btn", "aria-current": collectionActive ? "page" : undefined, "aria-haspopup": "menu", "aria-expanded": collectionsOpen, "aria-controls": menuId("collections"), onClick: () => toggle("collections"), children: [_jsx("span", { className: "sm-rail__tile", children: _jsx(LibraryBig, { ...ICON }) }), _jsx("span", { className: "sm-rail__label", children: "Collections" })] }), _jsx("ul", { className: "sm-rail__menu sm-rail__menu--collections", role: "menu", id: menuId("collections"), "data-open": collectionsOpen, children: collections.map((c) => (_jsx("li", { role: "none", children: _jsx(RouteLink, { role: "menuitem", className: "sm-rail__menu-item", href: adminHref(`/${c.name}`), "aria-current": c.name === active ? "page" : undefined, onClick: () => { close(); hoverLeave(); }, children: _jsx("span", { children: c.title }) }) }, c.name))) })] })), link("media", "Media", _jsx(Image, { ...ICON }), adminHref("/media")), registry.globals.length > 0 && link("globals", "Site", _jsx(Globe, { ...ICON }), adminHref("/globals")), settings && link("settings", "Settings", _jsx(Settings, { ...ICON }), adminHref("/settings"))] }), deploy && (() => {
100
+ return (_jsxs("nav", { className: "sm-rail", "aria-label": "Primary", ref: root, onKeyDown: onKeyDown, children: [_jsxs("div", { className: "sm-rail__item", children: [_jsx("button", { type: "button", className: "sm-rail__brand", title: "Smoodly", "aria-haspopup": "menu", "aria-expanded": open === "brand", "aria-controls": menuId("brand"), onClick: () => toggle("brand"), children: _jsx(AppIcon, {}) }), _jsxs("ul", { className: "sm-rail__menu sm-rail__menu--brand", role: "menu", id: menuId("brand"), "data-open": open === "brand", children: [_jsx("li", { role: "none", children: _jsx(ExtLink, { href: consoleLinks().project, onClick: close, children: "This site at smoodly.io" }) }), _jsx("li", { role: "none", children: _jsx(ExtLink, { href: consoleLinks().docs, onClick: close, children: "Documentation" }) }), _jsxs("li", { className: "sm-rail__menu-version", role: "presentation", children: ["Smoodly ", SMOODLY_VERSION] })] })] }), _jsxs("ul", { className: "sm-rail__list", children: [link("pages", "Pages", _jsx(PanelTop, { ...ICON }), adminHref("/pages")), collections.length > 0 && (_jsxs("li", { className: "sm-rail__item", onMouseEnter: () => setHovered(true), onMouseLeave: hoverLeave, children: [_jsxs("button", { type: "button", className: "sm-rail__btn", "aria-current": collectionActive ? "page" : undefined, "aria-haspopup": "menu", "aria-expanded": collectionsOpen, "aria-controls": menuId("collections"), onClick: () => toggle("collections"), children: [_jsx("span", { className: "sm-rail__tile", children: _jsx(LibraryBig, { ...ICON }) }), _jsx("span", { className: "sm-rail__label", children: "Collections" })] }), _jsx("ul", { className: "sm-rail__menu sm-rail__menu--collections", role: "menu", id: menuId("collections"), "data-open": collectionsOpen, children: collections.map((c) => (_jsx("li", { role: "none", children: _jsx(RouteLink, { role: "menuitem", className: "sm-rail__menu-item", href: adminHref(`/${c.name}`), "aria-current": c.name === active ? "page" : undefined, onClick: () => { close(); hoverLeave(); }, children: _jsx("span", { children: c.title }) }) }, c.name))) })] })), link("media", "Media", _jsx(Image, { ...ICON }), adminHref("/media")), registry.globals.length > 0 && link("globals", "Site", _jsx(Globe, { ...ICON }), adminHref("/globals")), settings && link("settings", "Settings", _jsx(Settings, { ...ICON }), adminHref("/settings"))] }), deploy && (() => {
96
101
  const copy = tileCopy(deploy.status, Date.now());
97
102
  const id = menuId("deploy");
98
103
  return (_jsxs("div", { className: "sm-rail__item sm-rail__deploy", "data-state": deploy.status.state, children: [_jsxs("button", { type: "button", className: "sm-rail__btn", "aria-haspopup": "menu", "aria-expanded": open === "deploy", "aria-controls": id, onClick: () => toggle("deploy"), title: copy.detail, children: [_jsxs("span", { className: `sm-rail__tile${copy.lit ? " sm-rail__tile--lit" : ""}${copy.warn ? " sm-rail__tile--warn" : ""}`, children: [_jsx(RadioTower, { ...ICON }), copy.badge && _jsx("span", { className: "sm-rail__badge", "aria-label": `${copy.badge} changes waiting`, children: copy.badge })] }), _jsx("span", { className: "sm-rail__label", children: copy.label })] }), _jsxs("ul", { className: "sm-rail__menu sm-rail__menu--up", role: "menu", id: id, "data-open": open === "deploy", children: [_jsx("li", { className: "sm-rail__menu-detail", role: "presentation", children: copy.detail }), _jsx("li", { role: "none", children: _jsx("button", { type: "button", role: "menuitem", className: "sm-rail__menu-item", disabled: copy.action === null || deploy.busy, title: copy.disabledReason ?? undefined, onClick: () => { close(); deploy.onDeploy(); }, children: copy.action === "retry" ? "Deploy again" : "Deploy now" }) }), copy.disabledReason && _jsx("li", { className: "sm-rail__menu-note", role: "presentation", children: copy.disabledReason })] })] }));
99
- })(), _jsxs("div", { className: "sm-rail__item sm-rail__foot", children: [_jsx("button", { type: "button", className: "sm-rail__user", title: name, "aria-haspopup": "menu", "aria-expanded": open === "user", "aria-controls": menuId("user"), onClick: () => toggle("user"), children: initials }), _jsxs("ul", { className: "sm-rail__menu sm-rail__menu--up", role: "menu", id: menuId("user"), "data-open": open === "user", children: [_jsx("li", { className: "sm-rail__menu-title", role: "presentation", children: email ?? name }), _jsx("li", { role: "none", children: _jsx(ThemeToggle, {}) }), _jsx("li", { role: "none", children: _jsx("a", { role: "menuitem", className: "sm-rail__menu-item", href: consoleLinks().sites, target: "_blank", rel: "noreferrer", onClick: close, children: "Your sites at smoodly.io" }) }), _jsx("li", { role: "none", children: _jsx("button", { type: "button", role: "menuitem", className: "sm-rail__menu-item", onClick: () => { close(); onSignOut(); }, children: "Sign out" }) })] })] })] }));
104
+ })(), _jsxs("div", { className: "sm-rail__item sm-rail__foot", children: [_jsx("button", { type: "button", className: "sm-rail__user", title: name, "aria-haspopup": "menu", "aria-expanded": open === "user", "aria-controls": menuId("user"), onClick: () => toggle("user"), children: initials }), _jsxs("ul", { className: "sm-rail__menu sm-rail__menu--up", role: "menu", id: menuId("user"), "data-open": open === "user", children: [_jsx("li", { className: "sm-rail__menu-title", role: "presentation", children: email ?? name }), _jsx("li", { role: "none", children: _jsx(ThemeToggle, {}) }), _jsx("li", { role: "none", children: _jsx(ExtLink, { href: consoleLinks().account, onClick: close, children: "My Account" }) }), _jsx("li", { role: "none", children: _jsx("button", { type: "button", role: "menuitem", className: "sm-rail__menu-item", onClick: () => { close(); onSignOut(); }, children: "Sign out" }) })] })] })] }));
100
105
  }
@@ -2,4 +2,7 @@ export declare function setConsole(url: string, spaceId: string): void;
2
2
  export declare const consoleLinks: () => {
3
3
  sites: string;
4
4
  members: string;
5
+ project: string;
6
+ account: string;
7
+ docs: string;
5
8
  };
@@ -1,11 +1,16 @@
1
- // Where the admin points at smoodly.io (spec 2026-10-16 sign-in and the dashboard §5, §6): the account menu's "Your
2
- // sites at smoodly.io" and the Members page's "Manage members at smoodly.io", deep links into the dashboard the
3
- // service names (`GET /v1/auth`'s consoleUrl). Set once by the entry from the page's data.
1
+ // Where the admin points at smoodly.io (spec 2026-10-16 sign-in and the dashboard §5, §6): the rail's Smoodly menu
2
+ // (this site, the docs; 2026-10-23), the account menu's "My Account" and the Members page's "Manage members at
3
+ // smoodly.io". The dashboard links are deep links into the console the service names (`GET /v1/auth`'s consoleUrl),
4
+ // set once by the entry from the page's data; the docs are smoodly.io itself.
4
5
  let console = { url: "https://smoodly.io", spaceId: "" };
5
6
  export function setConsole(url, spaceId) {
6
7
  console = { url: url.replace(/\/+$/, ""), spaceId };
7
8
  }
9
+ const SITE = "https://smoodly.io";
8
10
  export const consoleLinks = () => ({
9
11
  sites: `${console.url}/projects`,
10
12
  members: `${console.url}/spaces/${encodeURIComponent(console.spaceId)}/members`,
13
+ project: `${console.url}/spaces/${encodeURIComponent(console.spaceId)}`,
14
+ account: `${console.url}/account`,
15
+ docs: `${SITE}/docs`,
11
16
  });
@@ -1,5 +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
4
  export function zoneOf(tree, id) {
4
5
  for (const [zone, nodes] of Object.entries(tree.zones)) {
5
6
  if (nodes.some((n) => n.id === id))
@@ -123,12 +124,15 @@ const EMPTY_BY_TYPE = { refList: [], object: {} };
123
124
  * broken-image icon or a stray zero, worse than nothing at all. With no
124
125
  * sample, leave the key out of `fields` entirely rather than seed a lie. */
125
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;
126
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). */
127
131
  export function nodeFromSample(section, id) {
128
132
  const fields = {};
129
133
  const settings = {};
130
134
  for (const [key, d] of Object.entries(section.fields)) {
131
- const sampled = section.sample?.[key] ?? d.default ?? (d.type === "boolean" ? false : undefined);
135
+ const sampled = section.sample?.[key] ?? d.default ?? firstChoice(d) ?? (d.type === "boolean" ? false : undefined);
132
136
  if (sampled !== undefined)
133
137
  fields[key] = sampled;
134
138
  else if (ABSENT_WHEN_UNSAMPLED.has(d.type))
@@ -140,7 +144,7 @@ export function nodeFromSample(section, id) {
140
144
  fields[key] = EMPTY_BY_TYPE[d.type] ?? "";
141
145
  }
142
146
  for (const [key, d] of Object.entries(section.settings)) {
143
- const v = section.sample?.[key] ?? d.default;
147
+ const v = section.sample?.[key] ?? d.default ?? firstChoice(d);
144
148
  if (v !== undefined)
145
149
  settings[key] = v;
146
150
  }
@@ -52,7 +52,7 @@ const TOKENS_LIGHT = `
52
52
  dark mode that washed out over the white page, 2026-09-10). */
53
53
  --plus-bg:#DDEBE4; --plus-hover:#D0E3D9; --plus-fg:#1C6E5B;
54
54
  --plus-icon-bg:#1C6E5B; --plus-icon-fg:#FFFFFF; --plus-shadow:none;
55
- --logo-color:var(--accent); --logo-outer:#FFFFFF; --logo-inner:var(--frame);
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
56
 
57
57
  --status-published-fg:var(--green-700); --status-published-bg:var(--green-50);
58
58
  --status-changes-fg:var(--amber-700); --status-changes-bg:var(--amber-50);
@@ -92,7 +92,6 @@ const TOKENS_DARK = `
92
92
  --btn-secondary-bg:var(--surface); --btn-secondary-hover:#2C3049;
93
93
  --switch-bg:var(--line-dashed);
94
94
  --toolbar-bg:#0F1120; --toolbar-fg:#EEF0F5;
95
- --logo-inner:#151727;
96
95
 
97
96
  --status-published-fg:var(--green-300); --status-published-bg:rgba(111,211,154,.14);
98
97
  --status-changes-fg:var(--amber-300); --status-changes-bg:rgba(232,184,90,.14);
@@ -134,9 +133,9 @@ const SCALE = `
134
133
  --outline-row-h:36px; --seg-pad-y:7px; --tabs-pad:3px;
135
134
  --plus-h:32px; --plus-pad:0 14px 0 6px; --plus-label:inline;
136
135
  --nav-w:72px; --inspector-w:360px; --gutter:20px;
137
- /* The frame is 6px above, right of and below the sheet; the rail fills the left edge. */
138
- --frame-pad:6px; --frame-pad-top:6px; --shell-gap:0px; --sheet-radius:20px; --sheet-pad:0 24px 24px;
139
- --rail-pad:18px 6px 16px; --rail-logo:block; --rail-logo-opacity:.8; --rail-tile-w:44px; --rail-tile-h:40px; --rail-tile-radius:12px;
136
+ /* 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. */
137
+ --frame-pad:8px; --frame-pad-top:8px; --shell-gap:0px; --sheet-radius:20px; --sheet-pad:0 24px 24px;
138
+ --rail-pad:18px 6px 16px; --rail-logo:block; --rail-logo-opacity:1; --rail-tile-w:44px; --rail-tile-h:40px; --rail-tile-radius:12px;
140
139
  --topbar-pad:0; --back-size:32px; --back-bg:var(--surface);
141
140
  --table-pad:8px 12px; --table-top:80px; --canvas-pad:28px 40px 0; --card-pad:16px 18px; --card-gap:12px; --form-pad:32px 40px;
142
141
  --border:1px solid var(--line);
@@ -159,8 +158,10 @@ export const ADMIN_CSS = `
159
158
  ${cascade(".sm-admin", SCALE, TOKENS_LIGHT, TOKENS_DARK)}
160
159
 
161
160
  /* The admin owns the viewport; a host <body> margin would push it into a
162
- page scrollbar. Scoped by :has, so only a page holding the admin is touched. */
161
+ page scrollbar, and the page's overscroll bounce would pull the frame off
162
+ the top edge (2026-10-23). Scoped by :has, so only a page holding the admin is touched. */
163
163
  body:has(.sm-admin){margin:0}
164
+ html:has(.sm-admin),body:has(.sm-admin){overscroll-behavior:none}
164
165
  .sm-admin,.sm-admin *,.sm-admin *::before,.sm-admin *::after{box-sizing:border-box}
165
166
  .sm-admin{
166
167
  font-family:var(--font-sans);font-size:var(--text-md);line-height:1.4;
@@ -181,7 +182,7 @@ body:has(.sm-admin){margin:0}
181
182
 
182
183
  /* ── shell ─────────────────────────────────────────────────────────── */
183
184
  /* Rail | main. The shell is the frame (design_handoff_smoodly_neutral/
184
- components/AppShell.html): navy, 6px above, right of and below the main
185
+ components/AppShell.html): navy, 8px above, right of and below the main
185
186
  column, which is the white sheet with a 20px radius and its own inner
186
187
  padding; the rail fills the frame's left edge.
187
188
  The main column stacks the top bar over one tile-shaped region: list,
@@ -204,9 +205,20 @@ body:has(.sm-admin){margin:0}
204
205
  padding:var(--rail-pad);background:var(--bar-bg);border-right:var(--bar-border);color:var(--rail-fg);
205
206
  font:500 10px/1 var(--font-sans);position:relative;
206
207
  }
207
- /* The logo: white outer / navy inner on the frame, at a little under full
208
- opacity so it does not glare (2026-09-25). */
209
- .sm-rail__logo{width:20px;height:auto;display:var(--rail-logo);flex:none;margin-bottom:6px;opacity:var(--rail-logo-opacity)} /* 20px wide (asked for 2026-09-26; 28px before) */
208
+ /* The app icon (the Smoodly menu's trigger): the deep green square with the
209
+ mint s, 26px wide (2026-10-23; the lozenge before it was 20px, asked for
210
+ 2026-09-26, at .8 opacity). */
211
+ .sm-rail__brand{appearance:none;background:none;border:0;padding:0;margin-bottom:6px;cursor:pointer;display:block;border-radius:6px;color:inherit}
212
+ /* Hover (2026-10-23): the app icon and the avatar brighten a touch. */
213
+ .sm-rail__logo{transition:filter .15s}
214
+ .sm-rail__brand:hover .sm-rail__logo{filter:brightness(1.3)}
215
+ .sm-rail__user{transition:background .15s}
216
+ .sm-rail__user:hover{background:color-mix(in srgb,var(--accent-soft),#fff 60%)}
217
+ .sm-rail__brand:focus-visible{outline:2px solid var(--rail-active-fg);outline-offset:2px;box-shadow:none}
218
+ .sm-rail__logo{width:26px;height:auto;display:var(--rail-logo);flex:none;opacity:var(--rail-logo-opacity)}
219
+ .sm-rail__ext{width:13px;height:13px;flex:none;margin-left:12px;opacity:.45}
220
+ .sm-rail__menu-item:hover .sm-rail__ext,.sm-rail__menu-item:focus-visible .sm-rail__ext{opacity:.8}
221
+ .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)}
210
222
  .sm-rail__list{display:flex;flex-direction:column;gap:8px;width:100%;margin:0;padding:0;list-style:none}
211
223
  .sm-rail__item{position:relative}
212
224
  .sm-rail__btn{
@@ -257,6 +269,7 @@ body:has(.sm-admin){margin:0}
257
269
  .sm-rail__menu--up{top:auto;bottom:0}
258
270
  /* The Collections flyout opens on hover, so it is roomier (the names at --text-lg) and an invisible
259
271
  bridge spans the 6px gap so the pointer can cross from the rail without closing it. */
272
+ .sm-rail__menu--brand{width:220px}
260
273
  .sm-rail__menu--collections{width:208px;padding:8px;gap:2px;font-size:var(--text-lg)}
261
274
  .sm-rail__menu--collections::before{content:"";position:absolute;left:-8px;top:0;bottom:0;width:8px}
262
275
  .sm-rail__menu--collections .sm-rail__menu-item{padding:9px 12px}
@@ -636,8 +649,8 @@ body:has(.sm-admin){margin:0}
636
649
  below the inset so it reads on the cream ground of the top bar and the
637
650
  canvas meta row as well as on a white card. */
638
651
  .sm-seg{display:flex;gap:2px;padding:3px;background:var(--seg-bg);border-radius:var(--radius-btn)}
639
- /* A locale switch: each option wide enough to read as a tab, not a chip. */
640
- .sm-seg--locale .sm-seg__opt{min-width:64px}
652
+ /* A locale switch: each option wide enough to read as a tab, not a chip; the codes at 12px (2026-10-23). */
653
+ .sm-seg--locale .sm-seg__opt{min-width:64px;font-size:var(--text-sm)}
641
654
  /* The Canvas | Structure switch: two icon options. */
642
655
  .sm-seg--view .sm-seg__opt{padding:4px 8px;display:grid;place-items:center}
643
656
  .sm-seg--stretch{width:100%}
@@ -19,6 +19,10 @@ export type ValidationScope = {
19
19
  fields?: Record<string, unknown>;
20
20
  settings?: Record<string, unknown>;
21
21
  };
22
+ /** A link field takes only what the site renders (richtext-href.ts's rule): the widget says so as the editor types,
23
+ * publish refuses it (register 2026-09-26, the content-surface sweep). */
24
+ export declare const LINK_MESSAGE = "A link starts with https://, http://, mailto:, tel:, / for a page on this site, # or ?.";
25
+ export declare const linkProblem: (value: unknown) => string | null;
22
26
  /** The element schemas an element node is validated against (spec 2026-09-08 block nodes §2). */
23
27
  export type ElementDescs = Record<string, {
24
28
  fields: Record<string, Desc>;
@@ -14,9 +14,14 @@ import { rowsProblems } from "../custom/rows.js";
14
14
  import { isCalendarDay, isInstant } from "./forms/date-picker.js";
15
15
  import { fits, misfitMessage } from "./drift.js";
16
16
  import { isSegment } from "../paths.js";
17
+ import { safeHref } from "../richtext-href.js";
17
18
  import { isCrop } from "../media.js";
18
19
  /** A slug outside the path grammar, at publish (spec 2026-09-28 ASCII slugs §3). */
19
20
  export const SLUG_GRAMMAR_MESSAGE = "Use lowercase letters, digits and single hyphens, like my-post.";
21
+ /** A link field takes only what the site renders (richtext-href.ts's rule): the widget says so as the editor types,
22
+ * publish refuses it (register 2026-09-26, the content-surface sweep). */
23
+ export const LINK_MESSAGE = "A link starts with https://, http://, mailto:, tel:, / for a page on this site, # or ?.";
24
+ export const linkProblem = (value) => typeof value === "string" && value.trim() !== "" && safeHref(value) === null ? LINK_MESSAGE : null;
20
25
  const subfields = (d) => d.fields && typeof d.fields === "object" ? d.fields : {};
21
26
  const itemOf = (d) => d.item && typeof d.item === "object" ? d.item : undefined;
22
27
  const isEmpty = (v) => v === undefined || v === null || v === "" || (Array.isArray(v) && v.length === 0);
@@ -47,6 +52,10 @@ function validateValue(path, value, d, errors, elements, scope) {
47
52
  errors.push({ field: path, message: SLUG_GRAMMAR_MESSAGE });
48
53
  return;
49
54
  }
55
+ if (d.type === "link" && linkProblem(value)) {
56
+ errors.push({ field: path, message: LINK_MESSAGE });
57
+ return;
58
+ }
50
59
  if (d.type === "richtext") {
51
60
  if (!hasContent(value)) {
52
61
  if (!mayBeAbsent(d))
@@ -19,5 +19,6 @@ const chunks = richtextChunks(doc);
19
19
  // An own name only: `constructor` or `toString` must not find Object's members.
20
20
  const View = Object.prototype.hasOwnProperty.call(views.elements, chunk.name) ? views.elements[chunk.name] : undefined;
21
21
  if (!View) { console.warn(`smoodly: no src/elements/${chunk.name}.astro for element "${chunk.name}" — skipping.`); return null; }
22
- return <View {...chunk.fields} settings={{ ...info.element(chunk.name).defaults, ...chunk.settings }} />;
22
+ // `styles={{}}` as the React pairing hands it (pairing.tsx: `styles ?? {}`), so a view that reads styles never throws here.
23
+ return <View {...chunk.fields} settings={{ ...info.element(chunk.name).defaults, ...chunk.settings }} styles={{}} />;
23
24
  })}
@@ -1,8 +1,11 @@
1
1
  export declare const DRAFT_COOKIE = "smoodly-draft";
2
2
  export declare const PREVIEW_BASE = "/_smoodly/preview";
3
- export declare function draftCookieValue(secret: string): Promise<string>;
4
- /** `verify` compares in constant time. */
5
- export declare function isDraftCookie(value: string | undefined, secret: string): Promise<boolean>;
3
+ /** Seconds: the browser's Max-Age and the signed expiry agree. */
4
+ export declare const DRAFT_COOKIE_MAX_AGE: number;
5
+ /** `2.<expiry ms>.<hex signature>`; `now` is for tests. */
6
+ export declare function draftCookieValue(secret: string, now?: number): Promise<string>;
7
+ /** `verify` compares in constant time; an expired value is refused before the key is touched. */
8
+ export declare function isDraftCookie(value: string | undefined, secret: string, now?: number): Promise<boolean>;
6
9
  /** The page path a preview address names ("/" for the base itself), or null when `pathname` is not one. */
7
10
  export declare function previewPath(pathname: string): string | null;
8
11
  /** The page path a preview address may rewrite to: never the admin, never a preview address again (a
@@ -1,25 +1,38 @@
1
1
  // The preview address and its key (DESIGN.md Vision, preview per framework; spec 2026-10-02 astro cloud
2
2
  // fit §7): Astro serves every draft under PREVIEW_BASE, to a browser that holds the draft cookie. The
3
- // cookie's value is an HMAC of a fixed message under the site's secret key: the draft operation, which
4
- // only a signed-in editor can call, is the only thing that can make one. Web Crypto only, so the code
5
- // runs on any host.
3
+ // cookie's value is an HMAC under the site's secret key over a message carrying its expiry: the draft
4
+ // operation, which only a signed-in editor can call, is the only thing that can make one, and a value
5
+ // ends twelve hours after it was made, with the browser's Max-Age (register 2026-10-05: before, one
6
+ // fixed value served every editor for as long as the key stood). Web Crypto only, so the code runs on
7
+ // any host.
6
8
  export const DRAFT_COOKIE = "smoodly-draft";
7
9
  export const PREVIEW_BASE = "/_smoodly/preview";
10
+ /** Seconds: the browser's Max-Age and the signed expiry agree. */
11
+ export const DRAFT_COOKIE_MAX_AGE = 12 * 3600;
8
12
  const encoder = new TextEncoder();
9
- const MESSAGE = encoder.encode("smoodly-draft:v1");
13
+ const VERSION = "2";
14
+ const message = (expiry) => encoder.encode(`smoodly-draft:v${VERSION}:${expiry}`);
10
15
  const keyOf = (secret) => crypto.subtle.importKey("raw", encoder.encode(secret), { name: "HMAC", hash: "SHA-256" }, false, ["sign", "verify"]);
11
- export async function draftCookieValue(secret) {
16
+ /** `2.<expiry ms>.<hex signature>`; `now` is for tests. */
17
+ export async function draftCookieValue(secret, now = Date.now()) {
12
18
  if (!secret)
13
19
  throw new Error("smoodly: the draft cookie needs SMOODLY_SECRET_KEY.");
14
- const signature = await crypto.subtle.sign("HMAC", await keyOf(secret), MESSAGE);
15
- return [...new Uint8Array(signature)].map((b) => b.toString(16).padStart(2, "0")).join("");
20
+ const expiry = now + DRAFT_COOKIE_MAX_AGE * 1000;
21
+ const signature = await crypto.subtle.sign("HMAC", await keyOf(secret), message(expiry));
22
+ return `${VERSION}.${expiry}.${[...new Uint8Array(signature)].map((b) => b.toString(16).padStart(2, "0")).join("")}`;
16
23
  }
17
- /** `verify` compares in constant time. */
18
- export async function isDraftCookie(value, secret) {
19
- if (!secret || !value || !/^[0-9a-f]{64}$/.test(value))
24
+ /** `verify` compares in constant time; an expired value is refused before the key is touched. */
25
+ export async function isDraftCookie(value, secret, now = Date.now()) {
26
+ if (!secret || !value)
20
27
  return false;
21
- const signature = Uint8Array.from(value.match(/../g), (pair) => parseInt(pair, 16));
22
- return crypto.subtle.verify("HMAC", await keyOf(secret), signature, MESSAGE);
28
+ const m = /^2\.(\d{1,16})\.([0-9a-f]{64})$/.exec(value);
29
+ if (!m)
30
+ return false;
31
+ const expiry = Number(m[1]);
32
+ if (!(expiry > now))
33
+ return false;
34
+ const signature = Uint8Array.from(m[2].match(/../g), (pair) => parseInt(pair, 16));
35
+ return crypto.subtle.verify("HMAC", await keyOf(secret), signature, message(expiry));
23
36
  }
24
37
  /** The page path a preview address names ("/" for the base itself), or null when `pathname` is not one. */
25
38
  export function previewPath(pathname) {
@@ -1,4 +1,4 @@
1
- import { escapeHtml, isResolvedImage, safeHref } from "../richtext-href.js";
1
+ import { escapeHtml, isResolvedImage, safeHref, textOf } from "../richtext-href.js";
2
2
  import { ELEMENT_NODE, IMAGE_NODE } from "../richtext-walk.js";
3
3
  const asRecord = (v) => typeof v === "object" && v !== null && !Array.isArray(v) ? v : {};
4
4
  const WRAP = { paragraph: "p", bulletList: "ul", orderedList: "ol", listItem: "li", blockquote: "blockquote" };
@@ -26,7 +26,7 @@ export function richtextChunks(doc) {
26
26
  }
27
27
  switch (node.type) {
28
28
  case "text":
29
- html += withMarks(escapeHtml(node.text ?? ""), node.marks ?? []);
29
+ html += withMarks(escapeHtml(textOf(node.text)), node.marks ?? []);
30
30
  return;
31
31
  case "heading": {
32
32
  const level = Math.min(6, Math.max(1, Math.round(Number(node.attrs?.level ?? 1) || 1)));
@@ -6,7 +6,7 @@
6
6
  import { AsyncLocalStorage } from "node:async_hooks";
7
7
  import { createAdminHandler } from "../../admin/handler.js";
8
8
  import { expire } from "../cache.js";
9
- import { DRAFT_COOKIE, PREVIEW_BASE, draftCookieValue } from "../preview.js";
9
+ import { DRAFT_COOKIE, DRAFT_COOKIE_MAX_AGE, PREVIEW_BASE, draftCookieValue } from "../preview.js";
10
10
  import { output } from "smoodly:build";
11
11
  import { adminDeps, secret } from "../server.js";
12
12
  export const prerender = false;
@@ -29,7 +29,7 @@ const handle = createAdminHandler(() => ({
29
29
  enable: async () => {
30
30
  const { context } = effects();
31
31
  context.cookies.set(DRAFT_COOKIE, await draftCookieValue(secret()), {
32
- path: PREVIEW_BASE, httpOnly: true, sameSite: "lax", secure: secure(context),
32
+ path: PREVIEW_BASE, httpOnly: true, sameSite: "lax", secure: secure(context), maxAge: DRAFT_COOKIE_MAX_AGE,
33
33
  });
34
34
  },
35
35
  disable: () => effects().context.cookies.delete(DRAFT_COOKIE, { path: PREVIEW_BASE }),
package/dist/config.js CHANGED
@@ -19,12 +19,12 @@ function normalizeRegistry(config) {
19
19
  }
20
20
  }
21
21
  return {
22
- sections: (config.registry.sections ?? []).map((s) => schemaOnly(s)),
23
- elements: (config.registry.elements ?? []).map((e) => schemaOnly(e)),
22
+ sections: (config.registry.sections ?? []).map((s) => schemaOnly(s, "registry.sections")),
23
+ elements: (config.registry.elements ?? []).map((e) => schemaOnly(e, "registry.elements")),
24
24
  collections,
25
25
  globals: config.registry.globals ?? [],
26
26
  // Page types only: an entry page is a view, registered with defineViews, and schemaOnly refuses it here.
27
- pages: (config.registry.pages ?? []).map((p) => schemaOnly(p)),
27
+ pages: (config.registry.pages ?? []).map((p) => schemaOnly(p, "registry.pages")),
28
28
  };
29
29
  }
30
30
  /** Names are unique per kind (plan 2026-10-02 registration split): a section and a collection may share one. */
@@ -152,6 +152,9 @@ function assertLocales(config) {
152
152
  }
153
153
  if (!supported.includes(def))
154
154
  throw new Error(`smoodly: the default locale "${def}" is not in the supported list.`);
155
+ const twice = supported.find((code, i) => supported.indexOf(code) !== i);
156
+ if (twice !== undefined)
157
+ throw new Error(`smoodly: the locale "${twice}" is listed twice in supported — list each locale once.`);
155
158
  }
156
159
  function assertTimeZone(config) {
157
160
  if (config.timeZone === undefined)
package/dist/css.js CHANGED
@@ -10,6 +10,10 @@ export const CSS_MAX = 4000;
10
10
  const REFUSED = [
11
11
  [/@import/i, "@import is not allowed."],
12
12
  [/url\s*\(/i, "url() is not allowed."],
13
+ // The other image functions take a bare string, so they can fetch as url() can.
14
+ [/image-set\s*\(/i, "image-set() is not allowed."],
15
+ [/image\s*\(/i, "image() is not allowed."],
16
+ [/src\s*\(/i, "src() is not allowed."],
13
17
  [/expression\s*\(/i, "expression() is not allowed."],
14
18
  [/behavior\s*:/i, "behavior: is not allowed."],
15
19
  [/<\//, '"</" is not allowed.'],
@@ -8,6 +8,9 @@ export function withCustomSchema(sections, toolset) {
8
8
  return [...sections, customSchema(toolset)];
9
9
  }
10
10
  export function resolveCustom(input) {
11
+ // The pre-split shape (custom.RichText) would be dropped without a word; an untyped config can still carry it.
12
+ if (input && "RichText" in input)
13
+ throw new Error("smoodly: custom.RichText is a view — give the bound RichText to defineViews({ RichText }), not to the config.");
11
14
  const breakpoint = input?.breakpoint ?? DEFAULT_BREAKPOINT;
12
15
  if (!isCssLength(breakpoint))
13
16
  throw new Error(`smoodly: custom.breakpoint "${breakpoint}" must be a length like "48rem" or "768px".`);
@@ -14,6 +14,9 @@ export type EntryLocale = {
14
14
  /** The draft version's saved time as the store's own text (spec 2026-09-26 concurrency §1). Null with no pointer. */
15
15
  draftSavedAt: string | null;
16
16
  publishedVersionId: string | null;
17
+ /** When the published snapshot last moved (null never published): what a published read shows as `updatedAt`, so a
18
+ * draft save after it moves nothing on the site (register 2026-09-30). */
19
+ publishedAt: number | null;
17
20
  updatedAt: number;
18
21
  /** The editor's email behind the last write that touched this row; null for the seed and scripts (spec 2026-09-13 history §2). */
19
22
  updatedBy: string | null;
@@ -36,7 +36,21 @@ function srcsetOf(cut, width, d) {
36
36
  function frameOverCut(image, rect, box) {
37
37
  const spot = image.hotspot ?? CENTER_HOTSPOT;
38
38
  const hotspot = { x: clamp01((spot.x * image.width - rect.left) / rect.width), y: clamp01((spot.y * image.height - rect.top) / rect.height) };
39
- return frameStyle(frameFor({ width: rect.width, height: rect.height, hotspot }, box));
39
+ return frameFor({ width: rect.width, height: rect.height, hotspot }, box);
40
+ }
41
+ /** `sizes` describes the box; a frame wider than the box shows the cut at `k` times that width, so each length is
42
+ * scaled, or the browser picks a candidate narrower than what it draws (register 2026-09-08). Each entry is an
43
+ * optional media condition in parentheses and a length, which calc() may wrap. */
44
+ function scaleSizes(sizes, k) {
45
+ if (k <= 1.005)
46
+ return sizes;
47
+ const factor = Math.round(k * 1000) / 1000;
48
+ return sizes.split(",").map((entry) => {
49
+ const m = /^(\s*)(\([^)]*\)\s*)?(.+?)(\s*)$/.exec(entry);
50
+ if (!m)
51
+ return entry;
52
+ return `${m[1]}${m[2] ?? ""}calc(${m[3]} * ${factor})${m[4]}`;
53
+ }).join(",");
40
54
  }
41
55
  export function imageMarkup(image, options) {
42
56
  const loading = options.priority ? "eager" : options.loading ?? "lazy";
@@ -71,12 +85,14 @@ export function imageMarkup(image, options) {
71
85
  if (rect && image.cut) {
72
86
  const own = rect.width / rect.height;
73
87
  const box = options.aspect ? parseAspect(options.aspect) : own;
88
+ const frame = Math.abs(box / own - 1) < 0.005 ? null : frameOverCut(image, rect, box);
89
+ const shown = frame && options.sizes ? { sizes: scaleSizes(options.sizes, frame.width / 100) } : sizes;
74
90
  return {
75
91
  box: boxStyle(box),
76
92
  sources,
77
93
  img: {
78
- ...srcsetOf(image.cut, rect.width, delivery), ...sizes, ...tag, width: rect.width, height: rect.height,
79
- style: Math.abs(box / own - 1) < 0.005 ? FULL : frameOverCut(image, rect, box),
94
+ ...srcsetOf(image.cut, rect.width, delivery), ...shown, ...tag, width: rect.width, height: rect.height,
95
+ style: frame ? frameStyle(frame) : FULL,
80
96
  },
81
97
  };
82
98
  }
package/dist/marks.d.ts CHANGED
@@ -12,8 +12,10 @@ export declare const BLANK_ATTR = "data-smd-blank";
12
12
  * editor sizes the frame to the reported height, so the document's own viewport must never scroll
13
13
  * (2026-09-26): a fractional content height (4595.3px in a 4595px frame) left it scrollable by a hair,
14
14
  * and a trackpad scroll over the canvas showed the frame's overlay scrollbar. Hidden overflow drops no
15
- * layout, and the live site never carries this. */
16
- export declare const BRIDGE_CSS = "html{overflow:hidden}[data-smd-node][data-smd-blank]{min-height:7rem}";
15
+ * layout, and the live site never carries this. For the same reason every wheel over the frame belongs
16
+ * to the editor's scroller (2026-11-13): a site's own `overscroll-behavior: none` on its root stops the
17
+ * wheel from chaining out of the frame, so the rule sets it back, over whatever the site says. */
18
+ export declare const BRIDGE_CSS = "html{overflow:hidden;overscroll-behavior:auto!important}[data-smd-node][data-smd-blank]{min-height:7rem}";
17
19
  /** The element that renders a field's value verbatim. */
18
20
  export declare const FIELD_ATTR = "data-smd-field";
19
21
  /** A list item's or object field's root: scopes the field names inside it. */
package/dist/marks.js CHANGED
@@ -11,8 +11,10 @@ export const BLANK_ATTR = "data-smd-blank";
11
11
  * editor sizes the frame to the reported height, so the document's own viewport must never scroll
12
12
  * (2026-09-26): a fractional content height (4595.3px in a 4595px frame) left it scrollable by a hair,
13
13
  * and a trackpad scroll over the canvas showed the frame's overlay scrollbar. Hidden overflow drops no
14
- * layout, and the live site never carries this. */
15
- export const BRIDGE_CSS = `html{overflow:hidden}[${NODE_ATTR}][${BLANK_ATTR}]{min-height:7rem}`;
14
+ * layout, and the live site never carries this. For the same reason every wheel over the frame belongs
15
+ * to the editor's scroller (2026-11-13): a site's own `overscroll-behavior: none` on its root stops the
16
+ * wheel from chaining out of the frame, so the rule sets it back, over whatever the site says. */
17
+ export const BRIDGE_CSS = `html{overflow:hidden;overscroll-behavior:auto!important}[${NODE_ATTR}][${BLANK_ATTR}]{min-height:7rem}`;
16
18
  /** The element that renders a field's value verbatim. */
17
19
  export const FIELD_ATTR = "data-smd-field";
18
20
  /** A list item's or object field's root: scopes the field names inside it. */
@@ -56,6 +56,7 @@ export declare function toEntryItem(record: EntryRecord, fields: Record<string,
56
56
  locale: string;
57
57
  locales: Record<string, LocaleAlternate | null>;
58
58
  seo?: Seo;
59
+ draft?: boolean;
59
60
  }): EntryItem;
60
61
  /** One page as a read returns it: no record, no tree. */
61
62
  export declare function toPageItem(hit: SitePage): PageItem;
@@ -3,9 +3,12 @@ import { SEO_KEY } from "./seo.js";
3
3
  export function toEntryItem(record, fields, o) {
4
4
  // The reserved `$seo` values are Smoodly's: they leave as `seo`, never as a field.
5
5
  const { [SEO_KEY]: _seo, ...rest } = fields;
6
+ const row = record.locales[o.locale];
7
+ // A published read shows when the published snapshot last moved; the draft read follows the live row.
8
+ const updatedAt = (o.draft ? null : row?.publishedAt ?? null) ?? row?.updatedAt ?? record.updatedAt;
6
9
  return {
7
10
  id: record.id, path: o.path, locale: o.locale, locales: o.locales,
8
- updatedAt: record.locales[o.locale]?.updatedAt ?? record.updatedAt, fields: rest,
11
+ updatedAt, fields: rest,
9
12
  ...(o.seo ? { seo: o.seo } : {}),
10
13
  };
11
14
  }
@@ -2,7 +2,8 @@ export type SchemaOr<S> = S | {
2
2
  schema: S;
3
3
  };
4
4
  export declare function schemaOf<S extends object>(x: SchemaOr<S>): S;
5
- /** The schema a public builder was given, refusing a paired component (plan 2026-10-02 registration split). */
5
+ /** The schema a public builder was given, refusing a paired component (plan 2026-10-02 registration split) and a
6
+ * plain function, which would pass as a schema named after itself (register 2026-10-03); `where` names the list. */
6
7
  export declare function schemaOnly<S extends {
7
8
  name: string;
8
- }>(x: S): S;
9
+ }>(x: S, where?: string): S;
@@ -2,9 +2,14 @@ export function schemaOf(x) {
2
2
  const carried = x.schema;
3
3
  return carried !== null && typeof carried === "object" ? carried : x;
4
4
  }
5
- /** The schema a public builder was given, refusing a paired component (plan 2026-10-02 registration split). */
6
- export function schemaOnly(x) {
5
+ /** The schema a public builder was given, refusing a paired component (plan 2026-10-02 registration split) and a
6
+ * plain function, which would pass as a schema named after itself (register 2026-10-03); `where` names the list. */
7
+ export function schemaOnly(x, where) {
7
8
  const carried = x.schema;
9
+ if (typeof x === "function" && (carried === null || typeof carried !== "object")) {
10
+ const name = String(x.name || "anonymous");
11
+ throw new Error(`smoodly: ${where ?? "a registration"} holds a function "${name}", not a schema — register the schema (${name}Schema) and give defineViews the component.`);
12
+ }
8
13
  if (carried !== null && typeof carried === "object") {
9
14
  if (carried.kind === "entryPage") {
10
15
  const c = String(carried.collection);