smoodly 0.0.16 → 0.0.18

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 (142) hide show
  1. package/README.md +176 -26
  2. package/dist/admin/client-ops.js +1 -1
  3. package/dist/admin/custom-section.d.ts +10 -0
  4. package/dist/admin/custom-section.js +10 -0
  5. package/dist/admin/editor/CanvasOverlay.d.ts +4 -4
  6. package/dist/admin/editor/CanvasOverlay.js +8 -6
  7. package/dist/admin/editor/EditorView.js +140 -108
  8. package/dist/admin/editor/GlobalInspector.d.ts +27 -0
  9. package/dist/admin/editor/GlobalInspector.js +13 -0
  10. package/dist/admin/editor/InsertPicker.d.ts +6 -5
  11. package/dist/admin/editor/InsertPicker.js +6 -4
  12. package/dist/admin/editor/InspectorFields.d.ts +21 -0
  13. package/dist/admin/editor/InspectorFields.js +22 -0
  14. package/dist/admin/editor/Outline.d.ts +0 -1
  15. package/dist/admin/editor/Outline.js +3 -3
  16. package/dist/admin/editor/{shared-edit.d.ts → global-edit.d.ts} +10 -10
  17. package/dist/admin/editor/{shared-edit.js → global-edit.js} +4 -4
  18. package/dist/admin/editor/useGlobalEdit.d.ts +6 -0
  19. package/dist/admin/editor/{useSharedEdit.js → useGlobalEdit.js} +4 -4
  20. package/dist/admin/forms/FieldWidget.d.ts +14 -1
  21. package/dist/admin/forms/FieldWidget.js +38 -11
  22. package/dist/admin/forms/ListField.d.ts +9 -1
  23. package/dist/admin/forms/ListField.js +16 -8
  24. package/dist/admin/forms/RichtextElementCard.js +11 -5
  25. package/dist/admin/forms/RowsWidget.d.ts +13 -0
  26. package/dist/admin/forms/RowsWidget.js +26 -0
  27. package/dist/admin/forms/SpanBar.d.ts +5 -0
  28. package/dist/admin/forms/SpanBar.js +37 -0
  29. package/dist/admin/forms/richtext-insert.d.ts +2 -2
  30. package/dist/admin/forms/richtext-insert.js +3 -3
  31. package/dist/admin/forms/richtext-nodes.js +1 -1
  32. package/dist/admin/forms/tabs.d.ts +15 -9
  33. package/dist/admin/forms/tabs.js +19 -23
  34. package/dist/admin/forms/useRichtextEditor.js +8 -1
  35. package/dist/admin/global-items.d.ts +9 -0
  36. package/dist/admin/{shared-items.js → global-items.js} +17 -16
  37. package/dist/admin/index.d.ts +3 -3
  38. package/dist/admin/index.js +1 -1
  39. package/dist/admin/ops-impl.js +65 -54
  40. package/dist/admin/ops.d.ts +6 -6
  41. package/dist/admin/serialize.d.ts +18 -6
  42. package/dist/admin/serialize.js +27 -4
  43. package/dist/admin/shell/AdminApp.js +6 -6
  44. package/dist/admin/shell/GlobalFields.d.ts +24 -0
  45. package/dist/admin/shell/GlobalFields.js +19 -0
  46. package/dist/admin/shell/{SharedForm.d.ts → GlobalForm.d.ts} +1 -1
  47. package/dist/admin/shell/{SharedForm.js → GlobalForm.js} +36 -26
  48. package/dist/admin/shell/{SharedList.d.ts → GlobalList.d.ts} +1 -1
  49. package/dist/admin/shell/{SharedList.js → GlobalList.js} +9 -9
  50. package/dist/admin/shell/IconRail.js +13 -6
  51. package/dist/admin/shell/PagesList.js +1 -6
  52. package/dist/admin/shell/{shared-list.d.ts → global-list.d.ts} +3 -3
  53. package/dist/admin/shell/{shared-list.js → global-list.js} +2 -2
  54. package/dist/admin/tree-ops.d.ts +10 -8
  55. package/dist/admin/tree-ops.js +28 -16
  56. package/dist/admin/ui/ActionMenu.d.ts +3 -1
  57. package/dist/admin/ui/ActionMenu.js +4 -3
  58. package/dist/admin/ui/HistoryModal.js +1 -1
  59. package/dist/admin/ui/PublishControl.d.ts +3 -1
  60. package/dist/admin/ui/PublishControl.js +2 -2
  61. package/dist/admin/ui/primitives.d.ts +4 -2
  62. package/dist/admin/ui/primitives.js +3 -3
  63. package/dist/admin/ui/theme.js +61 -18
  64. package/dist/admin/validate.d.ts +27 -2
  65. package/dist/admin/validate.js +47 -10
  66. package/dist/collections.d.ts +3 -3
  67. package/dist/collections.js +1 -1
  68. package/dist/conditions.d.ts +33 -0
  69. package/dist/conditions.js +79 -0
  70. package/dist/config.d.ts +26 -19
  71. package/dist/config.js +26 -25
  72. package/dist/css.d.ts +7 -0
  73. package/dist/css.js +57 -0
  74. package/dist/custom/CustomSection.d.ts +26 -0
  75. package/dist/custom/CustomSection.js +20 -0
  76. package/dist/custom/css.d.ts +3 -0
  77. package/dist/custom/css.js +28 -0
  78. package/dist/custom/index.d.ts +26 -0
  79. package/dist/custom/index.js +16 -0
  80. package/dist/custom/rows.d.ts +28 -0
  81. package/dist/custom/rows.js +129 -0
  82. package/dist/custom/schema.d.ts +32 -0
  83. package/dist/custom/schema.js +39 -0
  84. package/dist/entry-page.d.ts +5 -0
  85. package/dist/entry-page.js +1 -1
  86. package/dist/entry-store.d.ts +4 -4
  87. package/dist/entry-store.js +17 -17
  88. package/dist/fields.d.ts +5 -2
  89. package/dist/fields.js +4 -2
  90. package/dist/{shared-id.d.ts → global-id.d.ts} +1 -1
  91. package/dist/{shared-id.js → global-id.js} +4 -4
  92. package/dist/global.d.ts +57 -0
  93. package/dist/global.js +51 -0
  94. package/dist/index.d.ts +31 -21
  95. package/dist/index.js +11 -6
  96. package/dist/media.js +1 -1
  97. package/dist/next/index.d.ts +1 -1
  98. package/dist/next/page-renderer.d.ts +4 -2
  99. package/dist/next/page-renderer.js +16 -12
  100. package/dist/next/queries.d.ts +6 -6
  101. package/dist/next/queries.js +7 -7
  102. package/dist/page.js +2 -2
  103. package/dist/pairing.d.ts +13 -7
  104. package/dist/pairing.js +4 -4
  105. package/dist/paths.d.ts +21 -0
  106. package/dist/paths.js +25 -0
  107. package/dist/refs.d.ts +2 -2
  108. package/dist/refs.js +4 -4
  109. package/dist/render.d.ts +23 -1
  110. package/dist/render.js +12 -6
  111. package/dist/resolve.d.ts +11 -6
  112. package/dist/resolve.js +22 -12
  113. package/dist/revalidate.d.ts +4 -4
  114. package/dist/revalidate.js +4 -4
  115. package/dist/richtext-render.js +2 -2
  116. package/dist/richtext-walk.d.ts +5 -5
  117. package/dist/richtext-walk.js +4 -4
  118. package/dist/schema.d.ts +15 -10
  119. package/dist/schema.js +19 -7
  120. package/dist/section-wrapper.d.ts +18 -6
  121. package/dist/section-wrapper.js +24 -17
  122. package/dist/site.d.ts +29 -14
  123. package/dist/site.js +55 -28
  124. package/dist/sql-space.js +1 -1
  125. package/dist/sql.js +2 -2
  126. package/dist/store.d.ts +7 -0
  127. package/dist/store.js +6 -0
  128. package/dist/styles.d.ts +27 -0
  129. package/dist/styles.js +60 -0
  130. package/dist/supabase-entry-store.js +14 -14
  131. package/dist/supabase-store.js +2 -1
  132. package/dist/zones.d.ts +9 -7
  133. package/dist/zones.js +5 -5
  134. package/package.json +1 -1
  135. package/dist/admin/editor/SharedInspector.d.ts +0 -23
  136. package/dist/admin/editor/SharedInspector.js +0 -13
  137. package/dist/admin/editor/useSharedEdit.d.ts +0 -6
  138. package/dist/admin/shared-items.d.ts +0 -9
  139. package/dist/admin/shell/SharedFields.d.ts +0 -21
  140. package/dist/admin/shell/SharedFields.js +0 -15
  141. package/dist/shared.d.ts +0 -53
  142. package/dist/shared.js +0 -45
@@ -1,4 +1,4 @@
1
- import { SHARED_NODE_TYPE } from "../shared.js";
1
+ import { GLOBAL_NODE_TYPE } from "../global.js";
2
2
  import { setAtPath } from "./field-paths.js";
3
3
  export function zoneOf(tree, id) {
4
4
  for (const [zone, nodes] of Object.entries(tree.zones)) {
@@ -64,6 +64,18 @@ export function patchNode(tree, id, ns, key, value) {
64
64
  return tree;
65
65
  return withZone(tree, zone, tree.zones[zone].map((n) => (n.id === id ? { ...n, [ns]: { ...(n[ns] ?? {}), [key]: value } } : n)));
66
66
  }
67
+ /** The node's custom CSS (spec 2026-09-14 §4); undefined or "" removes the key. */
68
+ export function patchNodeCss(tree, id, css) {
69
+ const zone = zoneOf(tree, id);
70
+ if (!zone)
71
+ return tree;
72
+ return withZone(tree, zone, tree.zones[zone].map((n) => {
73
+ if (n.id !== id)
74
+ return n;
75
+ const { css: _old, ...rest } = n;
76
+ return css === undefined || css === "" ? rest : { ...rest, css };
77
+ }));
78
+ }
67
79
  /** patchNode for a dotted path inside `fields` (spec 2026-09-10 §1):
68
80
  * `points.2.heading` sets one item's heading and copies only the spine. */
69
81
  export function patchNodePath(tree, id, path, value) {
@@ -89,11 +101,11 @@ export function allowedSections(policy, all) {
89
101
  }
90
102
  /** The visual items the picker offers in a zone: materialized (an id is
91
103
  * known) and rendering a section the zone's policy allows. */
92
- export function allowedShared(policy, shared, sharedIds) {
104
+ export function allowedGlobals(policy, globals, globalIds) {
93
105
  if (!policy || policy.kind === "locked")
94
106
  return [];
95
- return shared.filter((s) => {
96
- if (s.section === undefined || sharedIds[s.name] === undefined)
107
+ return globals.filter((s) => {
108
+ if (s.section === undefined || globalIds[s.name] === undefined)
97
109
  return false;
98
110
  return policy.allow === "*" || policy.allow.includes(s.section);
99
111
  });
@@ -101,7 +113,7 @@ export function allowedShared(policy, shared, sharedIds) {
101
113
  /** A page places a visual item as this node (spec 2026-09-07 §6): `item`
102
114
  * names the registration, `ref` is the item's row id. */
103
115
  export function placementNode(item, ref, id) {
104
- return { id, type: SHARED_NODE_TYPE, fields: { item: item.name, ref } };
116
+ return { id, type: GLOBAL_NODE_TYPE, fields: { item: item.name, ref } };
105
117
  }
106
118
  /** The empty value a fresh node's field starts at, by descriptor type.
107
119
  * Shape matters: a list-valued field seeded with "" is a type lie that
@@ -111,10 +123,10 @@ const EMPTY_BY_TYPE = { refList: [], object: {} };
111
123
  * broken-image icon or a stray zero, worse than nothing at all. With no
112
124
  * sample, leave the key out of `fields` entirely rather than seed a lie. */
113
125
  const ABSENT_WHEN_UNSAMPLED = new Set(["image", "video", "file", "number"]);
114
- /** Also fills an element node (an `AdminElement` is a section shape plus contexts). */
126
+ /** 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). */
115
127
  export function nodeFromSample(section, id) {
116
128
  const fields = {};
117
- const styles = {};
129
+ const settings = {};
118
130
  for (const [key, d] of Object.entries(section.fields)) {
119
131
  const sampled = section.sample?.[key] ?? d.default ?? (d.type === "boolean" ? false : undefined);
120
132
  if (sampled !== undefined)
@@ -124,20 +136,20 @@ export function nodeFromSample(section, id) {
124
136
  else
125
137
  fields[key] = EMPTY_BY_TYPE[d.type] ?? "";
126
138
  }
127
- for (const [key, d] of Object.entries(section.styles)) {
139
+ for (const [key, d] of Object.entries(section.settings)) {
128
140
  const v = section.sample?.[key] ?? d.default;
129
141
  if (v !== undefined)
130
- styles[key] = v;
142
+ settings[key] = v;
131
143
  }
132
- return { id, type: section.name, fields, styles };
144
+ return { id, type: section.name, fields, settings };
133
145
  }
134
146
  /** A locked zone holds exactly one node the editor can never insert or
135
147
  * remove — so someone has to put it there. This does, from the component's
136
- * sample (or, for a zone bound to a shared item, a placement), for every
148
+ * sample (or, for a zone bound to a global item, a placement), for every
137
149
  * locked zone that is empty or missing. Runs when the editor loads a draft,
138
150
  * which also covers pages that predate a locked zone being added to their
139
151
  * schema. Returns the same tree when nothing changed. */
140
- export function fillLockedZones(tree, policies, sections, sharedIds = {}) {
152
+ export function fillLockedZones(tree, policies, sections, globalIds = {}) {
141
153
  let next = null;
142
154
  for (const [zone, policy] of Object.entries(policies)) {
143
155
  if (policy.kind !== "locked")
@@ -145,14 +157,14 @@ export function fillLockedZones(tree, policies, sections, sharedIds = {}) {
145
157
  if ((tree.zones[zone] ?? []).length > 0)
146
158
  continue;
147
159
  let node = null;
148
- if (policy.shared !== undefined) {
149
- // Bound to a shared item (spec 2026-09-07 §4): a placement, once
160
+ if (policy.global !== undefined) {
161
+ // Bound to a global item (spec 2026-09-07 §4): a placement, once
150
162
  // the editor has loaded the item's row id; until then leave the
151
163
  // zone empty rather than seed a plain section there.
152
- const ref = sharedIds[policy.shared];
164
+ const ref = globalIds[policy.global];
153
165
  if (ref === undefined)
154
166
  continue;
155
- node = placementNode({ name: policy.shared }, ref, newNodeId(SHARED_NODE_TYPE));
167
+ node = placementNode({ name: policy.global }, ref, newNodeId(GLOBAL_NODE_TYPE));
156
168
  }
157
169
  else {
158
170
  const section = sections.find((s) => s.name === policy.component);
@@ -4,8 +4,10 @@ export type ActionMenuItem = {
4
4
  disabled?: boolean;
5
5
  danger?: boolean;
6
6
  };
7
- export declare function ActionMenu({ label, items, disabled }: {
7
+ export declare function ActionMenu({ label, items, disabled, small }: {
8
8
  label: string;
9
9
  items: ActionMenuItem[];
10
10
  disabled?: boolean;
11
+ /** The smaller round button, next to a small Publish. */
12
+ small?: boolean;
11
13
  }): import("react").JSX.Element;
@@ -4,10 +4,11 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
4
4
  // publish flow §2). Items are always rendered and disabled when they do
5
5
  // not apply — the layout never shifts and the editor learns what exists.
6
6
  // The popover is the Pages list's row menu shape (`.sm-picker`), anchored
7
- // under the button instead of at the pointer.
7
+ // under the button instead of at the pointer, hanging from the button's
8
+ // right edge (every control sits at its bar's or card's right).
8
9
  import { useEffect, useRef, useState } from "react";
9
10
  import { cx } from "./primitives.js";
10
- export function ActionMenu({ label, items, disabled }) {
11
+ export function ActionMenu({ label, items, disabled, small = false }) {
11
12
  const [open, setOpen] = useState(false);
12
13
  const ref = useRef(null);
13
14
  useEffect(() => {
@@ -21,5 +22,5 @@ export function ActionMenu({ label, items, disabled }) {
21
22
  document.addEventListener("keydown", onKey);
22
23
  return () => { document.removeEventListener("pointerdown", onDown); document.removeEventListener("keydown", onKey); };
23
24
  }, [open]);
24
- return (_jsxs("div", { ref: ref, style: { position: "relative", flex: "none" }, children: [_jsx("button", { type: "button", className: "sm-dotmenu", "aria-label": label, title: label, "aria-haspopup": "menu", "aria-expanded": open, disabled: disabled, onClick: () => setOpen((o) => !o), children: "\u00B7\u00B7\u00B7" }), open && (_jsx("div", { role: "menu", className: "sm-picker sm-picker--menu", children: items.map((item) => (_jsx("button", { type: "button", role: "menuitem", className: cx("sm-picker__item", item.danger && "sm-picker__item--danger"), disabled: item.disabled, onClick: () => { setOpen(false); item.run(); }, children: item.label }, item.label))) }))] }));
25
+ return (_jsxs("div", { ref: ref, style: { position: "relative", flex: "none" }, children: [_jsx("button", { type: "button", className: cx("sm-dotmenu", small && "sm-dotmenu--sm"), "aria-label": label, title: label, "aria-haspopup": "menu", "aria-expanded": open, disabled: disabled, onClick: () => setOpen((o) => !o), children: "\u00B7\u00B7\u00B7" }), open && (_jsx("div", { role: "menu", className: "sm-picker sm-picker--menu", children: items.map((item) => (_jsx("button", { type: "button", role: "menuitem", className: cx("sm-picker__item", item.danger && "sm-picker__item--danger"), disabled: item.disabled, onClick: () => { setOpen(false); item.run(); }, children: item.label }, item.label))) }))] }));
25
26
  }
@@ -4,7 +4,7 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
4
4
  // the left — newest first, the unpublished draft tagged Draft and the row
5
5
  // the site serves Live — and the selected version on the right, rendered
6
6
  // by the screen that opened it (an iframe of the site's draft route for a
7
- // page, a disabled form for an entry or a shared item). Restore confirms,
7
+ // page, a disabled form for an entry or a global item). Restore confirms,
8
8
  // hands the id to the screen, and closes once the restore landed.
9
9
  import { useEffect, useState } from "react";
10
10
  import { versionTime } from "./format.js";
@@ -13,7 +13,7 @@ export declare function publishMenu(opts: {
13
13
  onUnpublish(): void;
14
14
  onHistory?(): void;
15
15
  }): ActionMenuItem[];
16
- export declare function PublishControl({ status, pending, busy, flash, disabled, onPublish, onDiscard, onUnpublish, onHistory, }: {
16
+ export declare function PublishControl({ status, pending, busy, flash, disabled, compact, onPublish, onDiscard, onUnpublish, onHistory, }: {
17
17
  /** The locale's stored state; null while the document loads or before it exists. */
18
18
  status: LocaleStatus | null;
19
19
  /** The editor holds a change the store has not confirmed (dirty, or a save pending / in flight / failed). */
@@ -28,4 +28,6 @@ export declare function PublishControl({ status, pending, busy, flash, disabled,
28
28
  onUnpublish(): void;
29
29
  /** Opens History (spec 2026-09-13 history §5). Absent = no History item (the canvas card). */
30
30
  onHistory?(): void;
31
+ /** The small button and menu — the global inspector's control, so it never reads as the page's Publish. */
32
+ compact?: boolean;
31
33
  }): import("react").JSX.Element;
@@ -16,9 +16,9 @@ export function publishMenu(opts) {
16
16
  { label: "Unpublish", disabled: !opts.can.unpublish, run: opts.onUnpublish },
17
17
  ];
18
18
  }
19
- export function PublishControl({ status, pending = false, busy = null, flash = false, disabled = false, onPublish, onDiscard, onUnpublish, onHistory, }) {
19
+ export function PublishControl({ status, pending = false, busy = null, flash = false, disabled = false, compact = false, onPublish, onDiscard, onUnpublish, onHistory, }) {
20
20
  const can = publishActions(status, pending);
21
21
  const inert = disabled || busy !== null;
22
22
  const word = busy ? BUSY_WORD[busy] : flash ? "Published ✓" : "Publish";
23
- return (_jsxs("span", { style: { display: "inline-flex", alignItems: "center", gap: 8 }, children: [status && _jsx(StatusBadge, { status: statusLabel(status) }), _jsx(Button, { variant: flash && !busy ? "ok" : "primary", disabled: inert || !can.publish, onClick: onPublish, children: word }), _jsx(ActionMenu, { label: "More actions", disabled: inert || status === null, items: publishMenu({ can, onDiscard, onUnpublish, onHistory }) })] }));
23
+ return (_jsxs("span", { style: { display: "inline-flex", alignItems: "center", gap: 8 }, children: [status && _jsx(StatusBadge, { status: statusLabel(status) }), _jsx(Button, { variant: flash && !busy ? "ok" : "primary", size: compact ? "sm" : "md", disabled: inert || !can.publish, onClick: onPublish, children: word }), _jsx(ActionMenu, { label: "More actions", disabled: inert || status === null, small: compact, items: publishMenu({ can, onDiscard, onUnpublish, onHistory }) })] }));
24
24
  }
@@ -14,7 +14,7 @@ export declare function Logo({ height }: {
14
14
  export declare function ThemeToggle(): import("react").JSX.Element;
15
15
  type ButtonProps = ButtonHTMLAttributes<HTMLButtonElement> & {
16
16
  variant?: "primary" | "secondary" | "ghost" | "danger" | "ok";
17
- size?: "md" | "lg";
17
+ size?: "sm" | "md" | "lg";
18
18
  };
19
19
  export declare function Button({ variant, size, className, ...rest }: ButtonProps): import("react").JSX.Element;
20
20
  /** The same shape as Button, for navigations that must stay real links. */
@@ -44,11 +44,13 @@ export declare function Grip(props: ButtonHTMLAttributes<HTMLButtonElement>): im
44
44
  /** Label + optional counter above a control — the handoff's Input/Textarea head.
45
45
  * `asDiv` for groups holding more than one control: an implicit <label>
46
46
  * activates its first labelable descendant, so wrapping several is a trap. */
47
- export declare function Field({ label, mark, counter, hint, error, asDiv, children, style, className, path }: {
47
+ export declare function Field({ label, mark, counter, action, hint, error, asDiv, children, style, className, path }: {
48
48
  label?: ReactNode;
49
49
  /** A small glyph after the label — the field's "same in every language" mark. */
50
50
  mark?: ReactNode;
51
51
  counter?: ReactNode;
52
+ /** A small text action at the head's right end, after the counter (a list's Collapse all). */
53
+ action?: ReactNode;
52
54
  hint?: ReactNode;
53
55
  error?: ReactNode;
54
56
  asDiv?: boolean;
@@ -42,7 +42,7 @@ export function ThemeToggle() {
42
42
  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
43
  }
44
44
  export function Button({ variant = "primary", size = "md", className, ...rest }) {
45
- return (_jsx("button", { type: "button", className: cx("sm-btn", `sm-btn--${variant}`, size === "lg" && "sm-btn--lg", className), ...rest }));
45
+ return (_jsx("button", { type: "button", className: cx("sm-btn", `sm-btn--${variant}`, size === "lg" && "sm-btn--lg", size === "sm" && "sm-btn--sm", className), ...rest }));
46
46
  }
47
47
  /** The same shape as Button, for navigations that must stay real links. */
48
48
  export function LinkButton({ variant = "secondary", children, ...rest }) {
@@ -66,9 +66,9 @@ export function Grip(props) {
66
66
  /** Label + optional counter above a control — the handoff's Input/Textarea head.
67
67
  * `asDiv` for groups holding more than one control: an implicit <label>
68
68
  * activates its first labelable descendant, so wrapping several is a trap. */
69
- export function Field({ label, mark, counter, hint, error, asDiv, children, style, className, path }) {
69
+ export function Field({ label, mark, counter, action, hint, error, asDiv, children, style, className, path }) {
70
70
  const Tag = asDiv ? "div" : "label";
71
- return (_jsxs(Tag, { className: cx("sm-field", className), style: style, "data-sm-path": path, children: [(label || counter) && (_jsxs("span", { className: "sm-field__head", children: [_jsxs("span", { className: "sm-label", children: [label, mark] }), counter && _jsx("span", { className: "sm-counter", children: counter })] })), children, hint && _jsx("span", { className: "sm-hint", children: hint }), error && _jsx("span", { className: "sm-error", children: error })] }));
71
+ return (_jsxs(Tag, { className: cx("sm-field", className), style: style, "data-sm-path": path, children: [(label || counter || action) && (_jsxs("span", { className: "sm-field__head", children: [_jsxs("span", { className: "sm-label", children: [label, mark] }), (counter || action) && (_jsxs("span", { className: "sm-field__aside", children: [counter && _jsx("span", { className: "sm-counter", children: counter }), action] }))] })), children, hint && _jsx("span", { className: "sm-hint", children: hint }), error && _jsx("span", { className: "sm-error", children: error })] }));
72
72
  }
73
73
  /** The mark beside a field that is NOT `.localized()`: a globe-off, so a
74
74
  * multilingual form needs no "shared across languages" grouping. */
@@ -25,7 +25,7 @@ const TOKENS_LIGHT = `
25
25
  --line:#E8E3DA; --line-dashed:#D8D1C4; --line-soft:#EFEBE3;
26
26
  --text:#1F1D1A; --text-secondary:#4E4942; --text-muted:#7A746A; --text-subtle:#A39C90; --text-disabled:#C4BDB1;
27
27
  --selection:var(--accent); --nav-active-bg:var(--accent-soft); --nav-active-fg:var(--accent);
28
- --selection-shared:#C4620C;
28
+ --selection-global:#C4620C;
29
29
  --hover:rgba(31,29,26,.05); --press:rgba(31,29,26,.09); --control-hover:#F8F6F1;
30
30
  --btn-secondary-bg:var(--surface); --btn-secondary-hover:#F8F6F1; --tile-hover:var(--surface);
31
31
  --bar-bg:transparent; --bar-border:0; --menu-border:0; --card-divider:0;
@@ -63,7 +63,7 @@ const TOKENS_LIGHT = `
63
63
  `;
64
64
  const TOKENS_DARK = `
65
65
  --accent:#6CC4A6; --accent-hover:#82D2B8; --accent-soft:rgba(108,196,166,.18); --accent-line:#6CC4A6; --on-accent:#0B1F18;
66
- --selection-shared:#F09A5C;
66
+ --selection-global:#F09A5C;
67
67
  --bg:#1C1A17; --canvas:#141210; --surface:#27241F; --surface-form:#27241F; --surface-inset:#1C1A17;
68
68
  --input-bg:#211E1A; --input-hover-bg:#211E1A; --input-focus-bg:#211E1A;
69
69
  --line:#3A3630; --line-dashed:#4A453D; --line-soft:#3A3630;
@@ -96,13 +96,13 @@ const SCALE = `
96
96
  --title-font:var(--font-sans); --title-size:18px; --title-weight:600; --title-tracking:-0.01em;
97
97
  --doc-title-size:30px;
98
98
  --blocktag-font:var(--font-sans); --blocktag-size:11px; --blocktag-transform:none; --blocktag-tracking:0;
99
- --blocktag-radius:var(--radius-round); --blocktag-shift:translate(16px,-50%);
99
+ --blocktag-radius:var(--radius-round); --blocktag-shift:translate(16px,-50%); --blocktag-shift-inside:translate(16px,8px);
100
100
  --badge-h:24px; --badge-dot:inline-block;
101
101
 
102
102
  --radius-xs:2px; --radius-sm:8px; --radius-md:10px; --radius-lg:12px;
103
103
  --radius-card:14px; --radius-card-lg:16px; --radius-btn:999px; --radius-round:999px;
104
104
  --page-radius:4px 4px 0 0; --selection-radius:4px;
105
- --control-h:40px; --control-h-sm:34px; --control-h-lg:48px; --button-h:36px; --row-h:48px; --topbar-h:64px;
105
+ --control-h:40px; --control-h-sm:34px; --control-h-lg:48px; --button-h:36px; --button-h-sm:28px; --row-h:48px; --topbar-h:64px;
106
106
  --outline-row-h:36px; --seg-pad-y:7px; --tabs-pad:3px;
107
107
  --plus-h:32px; --plus-pad:0 14px 0 6px; --plus-label:inline;
108
108
  --nav-w:72px; --inspector-w:360px; --gutter:20px; --frame-pad:24px;
@@ -110,7 +110,7 @@ const SCALE = `
110
110
  --table-pad:8px 12px; --table-top:80px; --canvas-pad:24px 40px 0; --card-pad:16px 18px; --card-gap:12px; --form-pad:32px 40px;
111
111
  --border:1px solid var(--line);
112
112
  --outline-selection:2px solid var(--selection);
113
- --outline-selection-shared:2px solid var(--selection-shared);
113
+ --outline-selection-global:2px solid var(--selection-global);
114
114
  `;
115
115
  /* ── TEMPORARY: the original look, kept for side-by-side comparison while
116
116
  the warm restyle lands. Frozen copies of the three strings above as they
@@ -133,7 +133,7 @@ const TECHNICAL_LIGHT = `
133
133
  --text:var(--gray-900); --text-muted:var(--gray-600); --text-subtle:var(--gray-500);
134
134
  --accent:var(--brand-600); --on-accent:#FFFFFF; --accent-soft:var(--brand-50);
135
135
  --selection:var(--brand-600); --nav-active-bg:var(--brand-100); --nav-active-fg:var(--brand-600);
136
- --selection-shared:#D2610A;
136
+ --selection-global:#D2610A;
137
137
  --hover:rgba(15,20,32,.045); --press:rgba(15,20,32,.08);
138
138
  --control-hover:var(--gray-100);
139
139
 
@@ -162,7 +162,7 @@ const TECHNICAL_DARK = `
162
162
  --line:#2A2F3B; --line-dashed:#2A2F3B;
163
163
  --text:#EEF0F5; --text-muted:#8C94A8; --text-subtle:#6B7386;
164
164
  --accent:var(--brand-300); --on-accent:#06122F; --accent-soft:rgba(110,150,255,.16);
165
- --selection-shared:#F2A35F;
165
+ --selection-global:#F2A35F;
166
166
  --nav-active-bg:rgba(110,150,255,.14); --nav-active-fg:var(--brand-200);
167
167
  --hover:rgba(238,240,245,.05); --press:rgba(238,240,245,.09);
168
168
  --control-hover:var(--input-bg);
@@ -190,7 +190,7 @@ const TECHNICAL_SCALE = `
190
190
  --nav-w:72px; --inspector-w:380px;
191
191
  --border:1px solid var(--line);
192
192
  --outline-selection:1.5px solid var(--selection);
193
- --outline-selection-shared:1.5px solid var(--selection-shared);
193
+ --outline-selection-global:1.5px solid var(--selection-global);
194
194
  `;
195
195
  /* The tokens the warm restyle added, with the values the technical look
196
196
  implied, so the frozen block above renders the old shapes too. */
@@ -202,11 +202,11 @@ const TECHNICAL_SHAPE = `
202
202
  --title-font:var(--font-sans); --title-size:var(--text-xl); --title-weight:600; --title-tracking:0;
203
203
  --doc-title-size:var(--text-2xl);
204
204
  --blocktag-font:var(--font-mono); --blocktag-size:var(--text-2xs); --blocktag-transform:uppercase; --blocktag-tracking:var(--tracking-tag);
205
- --blocktag-radius:var(--radius-sm) 0 var(--radius-sm) 0; --blocktag-shift:none;
205
+ --blocktag-radius:var(--radius-sm) 0 var(--radius-sm) 0; --blocktag-shift:none; --blocktag-shift-inside:none;
206
206
  --badge-h:20px; --badge-dot:none;
207
207
  --radius-lg:var(--radius-md); --radius-card:var(--radius-md); --radius-card-lg:var(--radius-md); --radius-btn:var(--radius-md);
208
208
  --page-radius:var(--radius-md); --selection-radius:var(--radius-sm);
209
- --control-h-sm:24px; --button-h:var(--control-h);
209
+ --control-h-sm:24px; --button-h:var(--control-h); --button-h-sm:24px;
210
210
  --outline-row-h:var(--control-h); --seg-pad-y:3px; --tabs-pad:0 14px;
211
211
  --plus-h:24px; --plus-pad:0; --plus-label:none;
212
212
  --gutter:0px; --frame-pad:0px;
@@ -304,8 +304,6 @@ body:has(.sm-admin){margin:0}
304
304
  .sm-rail__btn[aria-current="page"]:hover,.sm-rail__btn[aria-expanded="true"]:hover{color:var(--nav-active-fg)}
305
305
  .sm-rail__btn[aria-current="page"] .sm-rail__tile,.sm-rail__btn[aria-expanded="true"] .sm-rail__tile{background:var(--nav-active-bg)}
306
306
  .sm-rail__tile svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:2;stroke-linecap:round;stroke-linejoin:round}
307
- .sm-rail__chev{position:absolute;right:4px;bottom:3px;width:8px;height:8px}
308
- .sm-rail__chev svg{width:8px;height:8px;stroke-width:3}
309
307
  .sm-rail__label{letter-spacing:.01em;white-space:nowrap}
310
308
  /* Not greyed: the reference renders every inactive item at --text and marks
311
309
  the unavailable ones only by being inert (no pointer, no hover). */
@@ -325,6 +323,11 @@ body:has(.sm-admin){margin:0}
325
323
  }
326
324
  .sm-rail__menu[data-open="true"]{display:flex}
327
325
  .sm-rail__menu--up{top:auto;bottom:0}
326
+ /* The Collections flyout opens on hover, so it is roomier (the names at --text-lg) and an invisible
327
+ bridge spans the 6px gap so the pointer can cross from the rail without closing it. */
328
+ .sm-rail__menu--collections{width:208px;padding:8px;gap:2px;font-size:var(--text-lg)}
329
+ .sm-rail__menu--collections::before{content:"";position:absolute;left:-8px;top:0;bottom:0;width:8px}
330
+ .sm-rail__menu--collections .sm-rail__menu-item{padding:9px 12px}
328
331
  .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}
329
332
  .sm-rail__menu-item{
330
333
  appearance:none;background:none;border:0;width:100%;display:flex;align-items:center;justify-content:space-between;
@@ -374,6 +377,7 @@ body:has(.sm-admin){margin:0}
374
377
  text-decoration:none;
375
378
  }
376
379
  .sm-btn--lg{height:var(--control-h-lg);padding:0 20px}
380
+ .sm-btn--sm,.sm-btn--sm.sm-btn--primary,.sm-btn--sm.sm-btn--ok{height:var(--button-h-sm);padding:0 12px;font-size:var(--text-sm)}
377
381
  .sm-btn--primary{background:var(--accent);color:var(--on-accent);font-weight:600;padding:0 18px}
378
382
  .sm-btn--primary:hover{background:var(--accent-hover);color:var(--on-accent)}
379
383
  .sm-btn--secondary{background:var(--btn-secondary-bg);color:var(--text)}
@@ -385,7 +389,7 @@ body:has(.sm-admin){margin:0}
385
389
  .sm-btn--ok{background:var(--status-published-bg);color:var(--status-published-fg);font-weight:600;padding:0 18px}
386
390
  .sm-btn:disabled,.sm-btn[aria-disabled="true"]{opacity:.5;cursor:default;pointer-events:none}
387
391
  .sm-btn:focus-visible,.sm-iconbtn:focus-visible,.sm-nav-item:focus-visible,
388
- .sm-tab:focus-visible,.sm-seg__opt:focus-visible,.sm-row:focus-visible{
392
+ .sm-tab:focus-visible,.sm-seg__opt:focus-visible,.sm-pill:focus-visible,.sm-row:focus-visible{
389
393
  outline:2px solid var(--accent);outline-offset:1px
390
394
  }
391
395
 
@@ -413,6 +417,10 @@ body:has(.sm-admin){margin:0}
413
417
  .sm-label__mark{display:inline-flex;color:var(--text-subtle);cursor:help}
414
418
  .sm-label__mark svg{width:13px;height:13px}
415
419
  .sm-counter{font-size:var(--text-sm);color:var(--text-subtle)}
420
+ .sm-field__aside{display:flex;align-items:baseline;gap:10px;flex:none}
421
+ /* A head's text action (a list's Collapse all / Expand all): a small accent link. */
422
+ .sm-field__action{background:none;border:0;padding:0;font:inherit;font-size:var(--text-sm);font-weight:600;color:var(--accent);cursor:pointer}
423
+ .sm-field__action:hover{color:var(--accent-hover)}
416
424
  .sm-hint{font-size:var(--text-sm);color:var(--text-muted)}
417
425
  .sm-input,.sm-textarea,.sm-select{
418
426
  width:100%;min-width:0;border:1px solid var(--input-border);border-radius:var(--radius-md);
@@ -504,7 +512,8 @@ body:has(.sm-admin){margin:0}
504
512
  .sm-rtcard__icon{width:18px;height:18px;flex:none;border-radius:var(--radius-xs);background:var(--accent-soft);color:var(--accent);display:grid;place-items:center;font-size:11px}
505
513
  .sm-rtcard__title{flex:1;min-width:0;font-weight:600;font-size:var(--text-sm);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
506
514
  .sm-rtcard__body{display:flex;flex-direction:column;gap:10px;padding:10px 12px 12px;border-top:1px solid var(--line)}
507
- .sm-rtcard__thumb{display:block;width:100%;max-height:160px;object-fit:cover;border-radius:var(--radius-sm);background:var(--surface-inset)}
515
+ /* The whole image, letterboxed on the inset tone, never cropped: the card is a preview, not a crop. */
516
+ .sm-rtcard__thumb{display:block;width:100%;height:160px;object-fit:contain;border-radius:var(--radius-sm);background:var(--surface-inset)}
508
517
  .sm-rtcard__file{font-size:var(--text-sm);color:var(--text-muted);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
509
518
  .sm-richtext__content blockquote{padding-left:12px;border-left:2px solid var(--line);color:var(--text-muted)}
510
519
  .sm-richtext__content a{color:var(--accent);text-decoration:underline;text-underline-offset:3px;text-decoration-color:var(--accent-line)}
@@ -533,6 +542,23 @@ body:has(.sm-admin){margin:0}
533
542
  display:flex;align-items:center;justify-content:space-between;gap:8px;min-height:var(--control-h);padding:0 6px 0 14px;
534
543
  border-radius:var(--radius-md);background:var(--surface-inset);color:var(--text-muted);font-size:var(--text-sm);
535
544
  }
545
+ /* The custom section's row cards and the width bar (spec 2026-09-14 §6). */
546
+ .sm-rowcards{display:flex;flex-direction:column;gap:12px;align-items:stretch}
547
+ .sm-rowcard{display:flex;flex-direction:column;gap:12px;padding:12px;border:1px solid var(--line);border-radius:var(--radius-md);background:var(--surface);min-width:0}
548
+ .sm-rowcard__head{display:flex;align-items:center;gap:4px}
549
+ .sm-rowcard__title{flex:1;font-weight:600}
550
+ .sm-rowcard__presets{display:flex;flex-wrap:wrap;gap:6px;margin-top:8px}
551
+ /* Secondary buttons sit on the white card: an outline so they read as buttons. */
552
+ .sm-rowcard__presets .sm-btn{height:var(--control-h-sm);padding:0 10px;font-size:var(--text-sm);border:1px solid var(--line)}
553
+ .sm-rowcards > .sm-btn{align-self:flex-start;border:1px solid var(--line)}
554
+ .sm-stepper{display:flex;align-items:center;gap:8px}
555
+ .sm-stepper__value{min-width:20px;text-align:center;font-weight:600}
556
+ .sm-spanbar{display:flex;gap:2px;height:32px;background:var(--seg-bg);border-radius:var(--radius-md);padding:3px;user-select:none}
557
+ .sm-spanbar__col{position:relative;display:flex;align-items:center;justify-content:center;min-width:0;background:var(--surface);border-radius:6px;font-size:var(--text-sm);color:var(--text-muted)}
558
+ .sm-spanbar__handle{position:absolute;right:-6px;top:0;bottom:0;width:10px;padding:0;border:0;background:transparent;cursor:col-resize;touch-action:none;z-index:1}
559
+ .sm-spanbar__handle::after{content:"";position:absolute;left:4px;top:8px;bottom:8px;width:2px;border-radius:1px;background:var(--text-muted)}
560
+ .sm-spanbar__handle:focus-visible{outline:none}
561
+ .sm-spanbar__handle:focus-visible::after,.sm-spanbar__handle:hover::after{background:var(--accent)}
536
562
  /* A field revealed from a canvas click: one pulse of the accent, then nothing. */
537
563
  .sm-field--reveal{animation:sm-reveal .9s ease-out;border-radius:var(--radius-sm)}
538
564
  @keyframes sm-reveal{0%{outline:2px solid var(--selection);outline-offset:4px}100%{outline:2px solid transparent;outline-offset:4px}}
@@ -610,6 +636,7 @@ body:has(.sm-admin){margin:0}
610
636
  .sm-dotmenu:hover{background:var(--btn-secondary-hover);color:var(--text)}
611
637
  .sm-dotmenu:disabled{opacity:.4;cursor:default}
612
638
  .sm-dotmenu:disabled:hover{background:var(--btn-secondary-bg);color:var(--text-muted)}
639
+ .sm-dotmenu--sm{width:var(--button-h-sm);height:var(--button-h-sm);font-size:var(--text-sm)}
613
640
  .sm-picker--menu{position:absolute;top:calc(100% + 6px);right:0;width:220px;z-index:60}
614
641
 
615
642
  /* Status pill: a dot before the word in the warm look (none in the technical). */
@@ -656,6 +683,18 @@ body:has(.sm-admin){margin:0}
656
683
  .sm-seg__opt[aria-pressed="true"]{background:var(--surface);color:var(--text);font-weight:600;box-shadow:var(--shadow-seg)}
657
684
  /* A locale the record does not have yet: subtle, still a button (it opens the add pane). */
658
685
  .sm-seg__opt--absent{color:var(--text-subtle)}
686
+ /* f.radio as a pill row (spec 2026-09-14 §5): small chips a step below the tabs and the
687
+ segmented control — no track, the chosen pill filled in the soft accent, the rest bare.
688
+ Wraps when the labels need it. */
689
+ .sm-pills{display:flex;flex-wrap:wrap;gap:4px;padding:2px 0}
690
+ .sm-pill{
691
+ height:var(--control-h-sm);padding:0 10px;border:0;border-radius:var(--radius-btn);background:transparent;
692
+ color:var(--text-muted);font:inherit;font-size:var(--text-sm);font-weight:500;line-height:1;cursor:pointer;white-space:nowrap;
693
+ }
694
+ .sm-pill:hover{color:var(--text);background:var(--surface-inset)}
695
+ .sm-pill[aria-pressed="true"]{background:var(--accent-soft);color:var(--accent);font-weight:600}
696
+ /* The custom-CSS box: code, no wrapping, no spellcheck. */
697
+ .sm-css{font-family:var(--font-mono);font-size:12px;line-height:1.5;white-space:pre;overflow-x:auto}
659
698
  /* Switch (f.boolean, the SEO noindex toggle): a pill track, knob slides right when on. */
660
699
  .sm-switch{width:36px;height:20px;padding:2px;border:0;border-radius:var(--radius-round);background:var(--seg-bg);cursor:pointer;display:inline-flex;align-items:center;flex:none}
661
700
  .sm-switch[aria-checked="true"]{background:var(--accent)}
@@ -687,7 +726,7 @@ body:has(.sm-admin){margin:0}
687
726
  .sm-fielderr__field{background:none;border:0;padding:0;font:inherit;color:inherit;text-decoration:underline;cursor:pointer}
688
727
 
689
728
  /* ── cards ─────────────────────────────────────────────────────────── */
690
- /* The inspector column (editor, entry and shared forms) is a stack of
729
+ /* The inspector column (editor, entry and global forms) is a stack of
691
730
  cards; the technical look draws them flush with a hairline between. */
692
731
  .sm-inspector{
693
732
  display:flex;flex-direction:column;gap:var(--card-gap);min-height:0;overflow:auto;
@@ -704,7 +743,10 @@ body:has(.sm-admin){margin:0}
704
743
  }
705
744
  .sm-inspector__head{display:flex;align-items:center;justify-content:space-between;gap:8px;flex:none}
706
745
  .sm-inspector__body{display:flex;flex-direction:column;gap:14px}
707
- /* The form card: the main column of the entry and shared forms. Every
746
+ /* A tab's groups (spec 2026-09-14 §5): the section's own settings untitled, then Spacing & layout and Advanced under a hairline. */
747
+ .sm-inspector__group{display:flex;flex-direction:column;gap:12px}
748
+ .sm-inspector__group+.sm-inspector__group{margin-top:8px;padding-top:12px;border-top:1px solid var(--line)}
749
+ /* The form card: the main column of the entry and global forms. Every
708
750
  field (and the banner) is a centred 680px column — a reading width —
709
751
  while the card itself still fills the main area. */
710
752
  .sm-form{
@@ -753,13 +795,14 @@ body:has(.sm-admin){margin:0}
753
795
  letter-spacing:var(--blocktag-tracking);text-transform:var(--blocktag-transform);padding:3px 10px;
754
796
  border-radius:var(--blocktag-radius);white-space:nowrap;
755
797
  }
756
- .sm-blocktag--shared{background:var(--selection-shared)}
798
+ .sm-blocktag--global{background:var(--selection-global)}
799
+ .sm-blocktag--inside{transform:var(--blocktag-shift-inside)}
757
800
  .sm-blocktoolbar{
758
801
  display:flex;gap:2px;padding:3px;background:var(--toolbar-bg);border-radius:var(--radius-btn);
759
802
  box-shadow:var(--shadow-float);
760
803
  }
761
804
  .sm-blocktoolbar__note{font-family:var(--font-mono);font-size:var(--text-xs);color:var(--toolbar-fg);opacity:.7;padding:4px 8px}
762
- /* The insert affordance between blocks: a labelled pill (the label is
805
+ /* The insert affordance between sections: a labelled pill (the label is
763
806
  hidden in the technical look, leaving the 24px circle). */
764
807
  .sm-plus{
765
808
  display:inline-flex;align-items:center;gap:8px;height:var(--plus-h);min-width:var(--plus-h);padding:var(--plus-pad);
@@ -10,11 +10,36 @@ type Desc = {
10
10
  min?: unknown;
11
11
  fields?: unknown;
12
12
  item?: unknown;
13
+ hidden?: unknown;
14
+ };
15
+ /** What conditions read beyond the current level: the node's two namespaces. */
16
+ export type ValidationScope = {
17
+ fields?: Record<string, unknown>;
18
+ settings?: Record<string, unknown>;
13
19
  };
14
20
  /** The element schemas an element node is validated against (spec 2026-09-08 block nodes §2). */
15
21
  export type ElementDescs = Record<string, {
16
22
  fields: Record<string, Desc>;
17
- styles?: Record<string, Desc>;
23
+ settings?: Record<string, Desc>;
18
24
  }>;
19
- export declare function validateFields(values: Record<string, unknown>, descriptors: Record<string, Desc>, elements?: ElementDescs): FieldError[];
25
+ /** Validate one value map. Without `scope.fields` the values ARE the fields (an entry, a page, an object global). */
26
+ export declare function validateFields(values: Record<string, unknown>, descriptors: Record<string, Desc>, elements?: ElementDescs, scope?: ValidationScope): FieldError[];
27
+ /** A tree node (or a visual global, in node shape) against its schema: fields, settings (prefixed), css (spec 2026-09-14 §3, §4). */
28
+ export declare function validateNode(node: {
29
+ fields?: Record<string, unknown>;
30
+ settings?: Record<string, unknown>;
31
+ css?: unknown;
32
+ [other: string]: unknown;
33
+ }, schema: {
34
+ fields: Record<string, Desc>;
35
+ settings?: Record<string, Desc>;
36
+ }, elements?: ElementDescs): FieldError[];
37
+ /** A custom section node (spec 2026-09-14 §6): the columns' richtext (element nodes included), the row structure, the css.
38
+ * The generic walk's own words on `rows.<i>.spans` are dropped (`spans` is no descriptor type it knows) so rowsProblems
39
+ * is the one voice on structure; the columns' richtext errors keep their paths. */
40
+ export declare function validateCustomNode(node: {
41
+ fields?: Record<string, unknown>;
42
+ css?: unknown;
43
+ [other: string]: unknown;
44
+ }, fields: Record<string, Desc>, elements?: ElementDescs): FieldError[];
20
45
  export {};
@@ -2,9 +2,15 @@
2
2
  // lives admin/server-side). Entry saves validate; page drafts do NOT
3
3
  // (drafts must save freely) — publish validates every node. Objects and
4
4
  // lists recurse; error paths are dotted (`cta.href`, `points.2.heading`).
5
+ // A field whose `.hidden()` condition holds is skipped entirely — a hidden
6
+ // required field is not required (spec 2026-09-14 §12); the same isHidden
7
+ // the form uses decides, over the same scope.
5
8
  import { optionValues } from "../fields.js";
9
+ import { isHidden } from "../conditions.js";
10
+ import { cssProblem } from "../css.js";
6
11
  import { hasContent } from "./richtext-doc.js";
7
12
  import { ELEMENT_NODE, richtextBlocks } from "../richtext-walk.js";
13
+ import { rowsProblems } from "../custom/rows.js";
8
14
  const subfields = (d) => d.fields && typeof d.fields === "object" ? d.fields : {};
9
15
  const itemOf = (d) => d.item && typeof d.item === "object" ? d.item : undefined;
10
16
  const isEmpty = (v) => v === undefined || v === null || v === "" || (Array.isArray(v) && v.length === 0);
@@ -12,7 +18,7 @@ const isEmpty = (v) => v === undefined || v === null || v === "" || (Array.isArr
12
18
  * may be absent — may be absent altogether. */
13
19
  const mayBeAbsent = (d) => d.optional === true || d.type === "boolean" || (d.type === "object" && Object.values(subfields(d)).every(mayBeAbsent));
14
20
  const plural = (n, word) => `${n} ${word}${n === 1 ? "" : "s"}`;
15
- function validateValue(path, value, d, errors, elements) {
21
+ function validateValue(path, value, d, errors, elements, scope) {
16
22
  if (isEmpty(value)) {
17
23
  if (!mayBeAbsent(d))
18
24
  errors.push({ field: path, message: "Required." });
@@ -34,8 +40,9 @@ function validateValue(path, value, d, errors, elements) {
34
40
  const el = typeof a.name === "string" ? elements[a.name] : undefined;
35
41
  if (el) {
36
42
  const fields = (a.fields && typeof a.fields === "object" ? a.fields : {});
37
- for (const [k, sub] of Object.entries(el.fields))
38
- validateValue(`${path}.elements.${i}.${k}`, fields[k], sub, errors, elements);
43
+ const settings = (a.settings && typeof a.settings === "object" ? a.settings : {});
44
+ validateMap(`${path}.elements.${i}`, fields, el.fields, errors, elements, { self: fields, fields, settings });
45
+ validateMap(`${path}.elements.${i}.settings`, settings, el.settings ?? {}, errors, elements, { self: settings, fields, settings });
39
46
  }
40
47
  i++;
41
48
  }
@@ -57,8 +64,9 @@ function validateValue(path, value, d, errors, elements) {
57
64
  if (typeof d.max === "number" && value.length > d.max)
58
65
  errors.push({ field: path, message: `At most ${plural(d.max, "item")}.` });
59
66
  const item = itemOf(d);
60
- if (item)
61
- value.forEach((v, i) => validateValue(`${path}.${i}`, v, item, errors, elements));
67
+ // A scalar item's condition reads the list's own level; an object item reads itself (the object branch).
68
+ if (item && !isHidden(item, scope))
69
+ value.forEach((v, i) => validateValue(`${path}.${i}`, v, item, errors, elements, scope));
62
70
  return;
63
71
  }
64
72
  if (d.type === "object") {
@@ -66,8 +74,7 @@ function validateValue(path, value, d, errors, elements) {
66
74
  errors.push({ field: path, message: "Must be an object." });
67
75
  return;
68
76
  }
69
- for (const [k, sub] of Object.entries(subfields(d)))
70
- validateValue(`${path}.${k}`, value[k], sub, errors, elements);
77
+ validateMap(path, value, subfields(d), errors, elements, { ...scope, self: value });
71
78
  return;
72
79
  }
73
80
  if (d.type === "boolean") {
@@ -97,9 +104,39 @@ function validateValue(path, value, d, errors, elements) {
97
104
  errors.push({ field: path, message: `Must be at most ${d.max} characters.` });
98
105
  }
99
106
  }
100
- export function validateFields(values, descriptors, elements = {}) {
107
+ /** One descriptor map at one level: hidden entries are skipped. `prefix` is the dotted path above it ("" at the top). */
108
+ function validateMap(prefix, values, descriptors, errors, elements, scope) {
109
+ for (const [key, d] of Object.entries(descriptors)) {
110
+ if (isHidden(d, scope))
111
+ continue;
112
+ validateValue(prefix === "" ? key : `${prefix}.${key}`, values[key], d, errors, elements, scope);
113
+ }
114
+ }
115
+ /** Validate one value map. Without `scope.fields` the values ARE the fields (an entry, a page, an object global). */
116
+ export function validateFields(values, descriptors, elements = {}, scope = {}) {
101
117
  const errors = [];
102
- for (const [field, d] of Object.entries(descriptors))
103
- validateValue(field, values[field], d, errors, elements);
118
+ validateMap("", values, descriptors, errors, elements, { self: values, fields: scope.fields ?? values, settings: scope.settings });
119
+ return errors;
120
+ }
121
+ /** A tree node (or a visual global, in node shape) against its schema: fields, settings (prefixed), css (spec 2026-09-14 §3, §4). */
122
+ export function validateNode(node, schema, elements = {}) {
123
+ const fields = node.fields ?? {};
124
+ const settings = node.settings ?? {};
125
+ const scope = { fields, settings };
126
+ const errors = validateFields(fields, schema.fields, elements, scope);
127
+ for (const e of validateFields(settings, schema.settings ?? {}, elements, scope))
128
+ errors.push({ field: `settings.${e.field}`, message: e.message });
129
+ if (typeof node.css === "string" && node.css !== "") {
130
+ const problem = cssProblem(node.css);
131
+ if (problem)
132
+ errors.push({ field: "css", message: problem });
133
+ }
104
134
  return errors;
105
135
  }
136
+ /** A custom section node (spec 2026-09-14 §6): the columns' richtext (element nodes included), the row structure, the css.
137
+ * The generic walk's own words on `rows.<i>.spans` are dropped (`spans` is no descriptor type it knows) so rowsProblems
138
+ * is the one voice on structure; the columns' richtext errors keep their paths. */
139
+ export function validateCustomNode(node, fields, elements = {}) {
140
+ const errors = validateNode(node, { fields }, elements).filter((e) => !e.field.startsWith("rows.") || e.field.includes(".columns."));
141
+ return [...rowsProblems(node.fields?.rows), ...errors];
142
+ }
@@ -9,10 +9,10 @@ export type EntryOrder = "manual" | {
9
9
  export declare const DEFAULT_ORDER: EntryOrder;
10
10
  export declare const BUILT_IN_ORDER_FIELDS: readonly ["createdAt", "updatedAt"];
11
11
  export type CollectionSchema<F extends BuilderMap = BuilderMap, N extends string = string> = {
12
- /** "shared" marks a code-declared singleton (spec 2026-09-07 decision
13
- * 4): a shared item IS a collection with one entry, so both entry
12
+ /** "global" marks a code-declared singleton (spec 2026-09-07 decision
13
+ * 4): a global item IS a collection with one entry, so both entry
14
14
  * stores accept it in the same list and branch on this. */
15
- kind: "collection" | "shared";
15
+ kind: "collection" | "global";
16
16
  /** Literal when declared inline (spec 2026-09-11 §6), so a queries object can check names. */
17
17
  name: N;
18
18
  title: string;
@@ -5,7 +5,7 @@ export const collectionDepth = (c) => c.tree?.depth ?? 1;
5
5
  export function collection(input) {
6
6
  if (!input?.name)
7
7
  throw new Error("smoodly: a collection requires a name.");
8
- if (["shared", "asset", "media"].includes(input.name))
8
+ if (["global", "asset", "media"].includes(input.name))
9
9
  throw new Error(`smoodly: "${input.name}" is a reserved name.`);
10
10
  if (input.tree !== undefined && (!Number.isInteger(input.tree.depth) || input.tree.depth < 1)) {
11
11
  throw new Error(`smoodly: collection "${input.name}" declares an invalid tree depth.`);