smoodly 0.0.16 → 0.0.17

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 (133) hide show
  1. package/README.md +156 -21
  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 +5 -5
  7. package/dist/admin/editor/EditorView.js +140 -108
  8. package/dist/admin/editor/GlobalInspector.d.ts +24 -0
  9. package/dist/admin/editor/{SharedInspector.js → GlobalInspector.js} +4 -4
  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/HistoryModal.js +1 -1
  57. package/dist/admin/ui/primitives.d.ts +3 -1
  58. package/dist/admin/ui/primitives.js +2 -2
  59. package/dist/admin/ui/theme.js +54 -14
  60. package/dist/admin/validate.d.ts +27 -2
  61. package/dist/admin/validate.js +47 -10
  62. package/dist/collections.d.ts +3 -3
  63. package/dist/collections.js +1 -1
  64. package/dist/conditions.d.ts +33 -0
  65. package/dist/conditions.js +79 -0
  66. package/dist/config.d.ts +26 -19
  67. package/dist/config.js +26 -25
  68. package/dist/css.d.ts +7 -0
  69. package/dist/css.js +57 -0
  70. package/dist/custom/CustomSection.d.ts +26 -0
  71. package/dist/custom/CustomSection.js +20 -0
  72. package/dist/custom/css.d.ts +3 -0
  73. package/dist/custom/css.js +28 -0
  74. package/dist/custom/index.d.ts +26 -0
  75. package/dist/custom/index.js +16 -0
  76. package/dist/custom/rows.d.ts +28 -0
  77. package/dist/custom/rows.js +129 -0
  78. package/dist/custom/schema.d.ts +32 -0
  79. package/dist/custom/schema.js +39 -0
  80. package/dist/entry-store.d.ts +4 -4
  81. package/dist/entry-store.js +17 -17
  82. package/dist/fields.d.ts +5 -2
  83. package/dist/fields.js +4 -2
  84. package/dist/{shared-id.d.ts → global-id.d.ts} +1 -1
  85. package/dist/{shared-id.js → global-id.js} +4 -4
  86. package/dist/global.d.ts +57 -0
  87. package/dist/global.js +51 -0
  88. package/dist/index.d.ts +28 -18
  89. package/dist/index.js +10 -5
  90. package/dist/media.js +1 -1
  91. package/dist/next/index.d.ts +1 -1
  92. package/dist/next/page-renderer.d.ts +4 -2
  93. package/dist/next/page-renderer.js +9 -9
  94. package/dist/next/queries.d.ts +6 -6
  95. package/dist/next/queries.js +7 -7
  96. package/dist/page.js +2 -2
  97. package/dist/pairing.d.ts +13 -7
  98. package/dist/pairing.js +4 -4
  99. package/dist/refs.d.ts +2 -2
  100. package/dist/refs.js +4 -4
  101. package/dist/render.d.ts +13 -1
  102. package/dist/render.js +10 -5
  103. package/dist/resolve.d.ts +11 -6
  104. package/dist/resolve.js +22 -12
  105. package/dist/revalidate.d.ts +4 -4
  106. package/dist/revalidate.js +4 -4
  107. package/dist/richtext-render.js +2 -2
  108. package/dist/richtext-walk.d.ts +5 -5
  109. package/dist/richtext-walk.js +4 -4
  110. package/dist/schema.d.ts +15 -10
  111. package/dist/schema.js +19 -7
  112. package/dist/section-wrapper.d.ts +18 -6
  113. package/dist/section-wrapper.js +24 -17
  114. package/dist/site.d.ts +15 -5
  115. package/dist/site.js +24 -10
  116. package/dist/sql-space.js +1 -1
  117. package/dist/sql.js +2 -2
  118. package/dist/store.d.ts +7 -0
  119. package/dist/store.js +6 -0
  120. package/dist/styles.d.ts +27 -0
  121. package/dist/styles.js +60 -0
  122. package/dist/supabase-entry-store.js +14 -14
  123. package/dist/supabase-store.js +2 -1
  124. package/dist/zones.d.ts +9 -7
  125. package/dist/zones.js +5 -5
  126. package/package.json +1 -1
  127. package/dist/admin/editor/SharedInspector.d.ts +0 -23
  128. package/dist/admin/editor/useSharedEdit.d.ts +0 -6
  129. package/dist/admin/shared-items.d.ts +0 -9
  130. package/dist/admin/shell/SharedFields.d.ts +0 -21
  131. package/dist/admin/shell/SharedFields.js +0 -15
  132. package/dist/shared.d.ts +0 -53
  133. package/dist/shared.js +0 -45
@@ -1,20 +1,26 @@
1
1
  import type { AdminField } from "../serialize.ts";
2
- export type TabName = "Content" | "Styles" | "Media" | "Meta";
2
+ export type TabName = "Content" | "Settings";
3
+ export type GroupTitle = "Spacing & layout" | "Advanced";
3
4
  export type TabEntry = {
4
5
  key: string;
5
- ns: "fields" | "styles" | "meta";
6
+ ns: "fields" | "settings" | "styles" | "css";
6
7
  descriptor: AdminField;
7
8
  };
8
- /** Anything with namespaced descriptors: a section, or a shared item (fields + styles, no meta). */
9
+ export type Group = {
10
+ title?: GroupTitle;
11
+ entries: TabEntry[];
12
+ };
13
+ /** A section, an element (no styles, no css) or a global item. */
9
14
  export type FieldsHolder = {
10
15
  fields: Record<string, AdminField>;
16
+ settings?: Record<string, AdminField>;
11
17
  styles?: Record<string, AdminField>;
12
- meta?: Record<string, AdminField>;
18
+ css?: boolean;
13
19
  };
14
- export declare function tabsFor(section: FieldsHolder): {
20
+ export declare const CSS_DESCRIPTOR: AdminField;
21
+ export declare function tabsFor(holder: FieldsHolder): {
15
22
  tab: TabName;
16
- entries: TabEntry[];
23
+ groups: Group[];
17
24
  }[];
18
- /** The tab holding a content field, by its dotted path's top-level key — a canvas
19
- * click on a marked image reveals it on Media, where the projection put it. */
20
- export declare function tabOf(section: FieldsHolder, path: string): TabName | undefined;
25
+ /** The tab a dotted path belongs to, by its top-level key — a canvas click reveals it there. */
26
+ export declare function tabOf(holder: FieldsHolder, path: string): TabName | undefined;
@@ -1,26 +1,22 @@
1
- const MEDIA_TYPES = new Set(["image", "video", "file"]);
2
- const TAB_BY_NS = {
3
- fields: "Content", styles: "Styles", meta: "Meta",
4
- };
5
- const TAB_BY_OVERRIDE = {
6
- content: "Content", styles: "Styles", media: "Media", meta: "Meta",
7
- };
8
- export function tabsFor(section) {
9
- const buckets = { Content: [], Styles: [], Media: [], Meta: [] };
10
- for (const ns of ["fields", "styles", "meta"]) {
11
- for (const [key, descriptor] of Object.entries(section[ns] ?? {})) {
12
- const override = typeof descriptor.tab === "string" ? TAB_BY_OVERRIDE[descriptor.tab] : undefined;
13
- const tab = override ?? (MEDIA_TYPES.has(descriptor.type) ? "Media" : TAB_BY_NS[ns]);
14
- buckets[tab].push({ key, ns, descriptor });
15
- }
16
- }
17
- return ["Content", "Styles", "Media", "Meta"]
18
- .filter((tab) => buckets[tab].length > 0)
19
- .map((tab) => ({ tab, entries: buckets[tab] }));
1
+ export const CSS_DESCRIPTOR = { type: "css" };
2
+ const entries = (ns, map) => Object.entries(map ?? {}).map(([key, descriptor]) => ({ key, ns, descriptor }));
3
+ export function tabsFor(holder) {
4
+ const content = [{ entries: entries("fields", holder.fields) }];
5
+ const settings = [
6
+ { entries: entries("settings", holder.settings) },
7
+ { title: "Spacing & layout", entries: entries("styles", holder.styles) },
8
+ { title: "Advanced", entries: holder.css === true ? [{ key: "css", ns: "css", descriptor: CSS_DESCRIPTOR }] : [] },
9
+ ];
10
+ return [["Content", content], ["Settings", settings]]
11
+ .map(([tab, groups]) => ({ tab, groups: groups.filter((g) => g.entries.length > 0) }))
12
+ .filter((t) => t.groups.length > 0);
20
13
  }
21
- /** The tab holding a content field, by its dotted path's top-level key — a canvas
22
- * click on a marked image reveals it on Media, where the projection put it. */
23
- export function tabOf(section, path) {
14
+ /** The tab a dotted path belongs to, by its top-level key — a canvas click reveals it there. */
15
+ export function tabOf(holder, path) {
24
16
  const key = path.split(".")[0];
25
- return tabsFor(section).find((t) => t.entries.some((e) => e.ns === "fields" && e.key === key))?.tab;
17
+ if (key in holder.fields)
18
+ return "Content";
19
+ if (key in (holder.settings ?? {}) || key in (holder.styles ?? {}) || (key === "css" && holder.css === true))
20
+ return "Settings";
21
+ return undefined;
26
22
  }
@@ -135,7 +135,14 @@ export function useRichtextEditor({ value, toolset, onChange, contentClass, onEs
135
135
  if (!editor || value === emitted.current)
136
136
  return;
137
137
  emitted.current = value;
138
- editor.commands.setContent(isDoc(value) ? value : EMPTY, { emitUpdate: false });
138
+ // TipTap's React node views (the image and element cards) flush React synchronously inside
139
+ // setContent, which React forbids from a lifecycle effect ("flushSync was called from inside a
140
+ // lifecycle method"). One microtask later React is idle; a widget unmounted meanwhile is skipped.
141
+ const next = isDoc(value) ? value : EMPTY;
142
+ queueMicrotask(() => {
143
+ if (!editor.isDestroyed)
144
+ editor.commands.setContent(next, { emitUpdate: false });
145
+ });
139
146
  }, [editor, value]);
140
147
  const active = useEditorState({
141
148
  editor,
@@ -0,0 +1,9 @@
1
+ import type { ResolvedConfig } from "../config.ts";
2
+ import type { Descriptor } from "../fields.ts";
3
+ import type { EntryRecord, EntryStore } from "../entry-store.ts";
4
+ import { type GlobalSchema } from "../global.ts";
5
+ /** The merged fields a fresh item starts from: sampled field values, and
6
+ * for a visual item `$settings` = the section's settings defaults with the
7
+ * sample's settings values on top. Unsampled fields stay absent. */
8
+ export declare function initialGlobalFields(item: GlobalSchema, settingsDescriptors: Record<string, Descriptor>): Record<string, unknown>;
9
+ export declare function ensureGlobalItems(entries: EntryStore, config: ResolvedConfig): Promise<Record<string, EntryRecord>>;
@@ -1,8 +1,9 @@
1
- import { STYLES_KEY } from "../shared.js";
1
+ import { SETTINGS_KEY } from "../global.js";
2
+ import { sectionSettings } from "./serialize.js";
2
3
  /** The merged fields a fresh item starts from: sampled field values, and
3
- * for a visual item `$styles` = the resolved style defaults with the
4
- * sample's style values on top. Unsampled fields stay absent. */
5
- export function initialSharedFields(item, resolvedStyles) {
4
+ * for a visual item `$settings` = the section's settings defaults with the
5
+ * sample's settings values on top. Unsampled fields stay absent. */
6
+ export function initialGlobalFields(item, settingsDescriptors) {
6
7
  const out = {};
7
8
  for (const key of Object.keys(item.fields)) {
8
9
  const sampled = item.sample?.[key];
@@ -10,25 +11,25 @@ export function initialSharedFields(item, resolvedStyles) {
10
11
  out[key] = sampled;
11
12
  }
12
13
  if (item.section !== undefined) {
13
- const styles = {};
14
- for (const [key, d] of Object.entries(resolvedStyles)) {
14
+ const settings = {};
15
+ for (const [key, d] of Object.entries(settingsDescriptors)) {
15
16
  const value = item.sample?.[key] ?? d.default;
16
17
  if (value !== undefined)
17
- styles[key] = value;
18
+ settings[key] = value;
18
19
  }
19
- out[STYLES_KEY] = styles;
20
+ out[SETTINGS_KEY] = settings;
20
21
  }
21
22
  return out;
22
23
  }
23
- export async function ensureSharedItems(entries, config) {
24
+ export async function ensureGlobalItems(entries, config) {
24
25
  const { default: def, supported } = config.locales;
25
26
  const out = {};
26
- for (const item of config.registry.shared ?? []) {
27
+ for (const item of config.registry.globals ?? []) {
27
28
  let [record] = await entries.list(item.name);
28
29
  if (!record) {
29
30
  try {
30
- const styles = item.section !== undefined ? config.resolvedStyles(item.section) : {};
31
- record = await entries.create(item.name, initialSharedFields(item, styles), { locale: def });
31
+ const settings = item.section !== undefined ? sectionSettings(config, item.section) : {};
32
+ record = await entries.create(item.name, initialGlobalFields(item, settings), { locale: def });
32
33
  }
33
34
  catch (e) {
34
35
  const message = e instanceof Error ? e.message : String(e);
@@ -37,7 +38,7 @@ export async function ensureSharedItems(entries, config) {
37
38
  if (message.includes("it is a singleton"))
38
39
  [record] = await entries.list(item.name);
39
40
  else
40
- console.warn(`smoodly: could not materialize shared item "${item.name}": ${message}`);
41
+ console.warn(`smoodly: could not materialize global item "${item.name}": ${message}`);
41
42
  if (!record)
42
43
  continue;
43
44
  }
@@ -51,11 +52,11 @@ export async function ensureSharedItems(entries, config) {
51
52
  catch (e) {
52
53
  const message = e instanceof Error ? e.message : String(e);
53
54
  if (!message.includes("already exists in locale")) {
54
- console.warn(`smoodly: could not materialize shared item "${item.name}" in "${locale}": ${message}`);
55
+ console.warn(`smoodly: could not materialize global item "${item.name}" in "${locale}": ${message}`);
55
56
  }
56
57
  }
57
58
  }
58
- // Every shared item is versioned (decided 2026-09-13). A row published before that has
59
+ // Every global item is versioned (decided 2026-09-13). A row published before that has
59
60
  // no pointers, so its next save would go live: publish it once more here, which snapshots
60
61
  // what is live and points both pointers at it. Idempotent — pointed rows are skipped.
61
62
  for (const locale of supported) {
@@ -66,7 +67,7 @@ export async function ensureSharedItems(entries, config) {
66
67
  record = await entries.setStatus(item.name, record.id, locale, "published");
67
68
  }
68
69
  catch (e) {
69
- console.warn(`smoodly: could not version shared item "${item.name}" in "${locale}": ${e instanceof Error ? e.message : String(e)}`);
70
+ console.warn(`smoodly: could not version global item "${item.name}" in "${locale}": ${e instanceof Error ? e.message : String(e)}`);
70
71
  }
71
72
  }
72
73
  out[item.name] = record;
@@ -1,11 +1,11 @@
1
- export type { AdminOps, AdminOpCall, AdminOpAction, OpResult, OpError, PageCreateInput, PageGetResult, SharedListItem, SharedGetResult, AssetUsageItem } from "./ops.ts";
1
+ export type { AdminOps, AdminOpCall, AdminOpAction, OpResult, OpError, PageCreateInput, PageGetResult, GlobalListItem, GlobalGetResult, AssetUsageItem } from "./ops.ts";
2
2
  export { createAdminOps, runAdminOp, type AdminEffects } from "./ops-impl.ts";
3
3
  export { fixedNodeSpecs, fixedNodeOf, ensureFixedNodes, type FixedNode } from "./fixed-nodes.ts";
4
- export { ensureSharedItems, initialSharedFields } from "./shared-items.ts";
4
+ export { ensureGlobalItems, initialGlobalFields } from "./global-items.ts";
5
5
  export { makeClientOps } from "./client-ops.ts";
6
6
  export { gateAdminOp, type AdminAuth } from "./auth.ts";
7
7
  export { supabaseAdminAuth } from "./supabase-auth.ts";
8
8
  export { serializeAdminConfig } from "./serialize.ts";
9
- export type { AdminRegistry, AdminSection, AdminCollection, AdminPageTemplate, AdminShared, AdminField } from "./serialize.ts";
9
+ export type { AdminRegistry, AdminSection, AdminCollection, AdminPageTemplate, AdminGlobal, AdminField } from "./serialize.ts";
10
10
  export * from "./tree-ops.ts";
11
11
  export { validateFields, type FieldError } from "./validate.ts";
@@ -1,6 +1,6 @@
1
1
  export { createAdminOps, runAdminOp } from "./ops-impl.js";
2
2
  export { fixedNodeSpecs, fixedNodeOf, ensureFixedNodes } from "./fixed-nodes.js";
3
- export { ensureSharedItems, initialSharedFields } from "./shared-items.js";
3
+ export { ensureGlobalItems, initialGlobalFields } from "./global-items.js";
4
4
  export { makeClientOps } from "./client-ops.js";
5
5
  export { gateAdminOp } from "./auth.js";
6
6
  export { supabaseAdminAuth } from "./supabase-auth.js";
@@ -1,10 +1,12 @@
1
1
  import { entryFieldsIn, entryTitleIn } from "../entry-store.js";
2
2
  import { ASSET_COLLECTION } from "../media.js";
3
3
  import { BASE } from "./shell/base.js";
4
- import { affectedTargets, collectionTag, entryTag, pageTag, pagesTreeTag, sharedTag } from "../revalidate.js";
4
+ import { affectedTargets, collectionTag, entryTag, pageTag, pagesTreeTag, globalTag } from "../revalidate.js";
5
5
  import { ensureFixedNodes, fixedNodeOf } from "./fixed-nodes.js";
6
- import { ensureSharedItems } from "./shared-items.js";
7
- import { validateFields } from "./validate.js";
6
+ import { ensureGlobalItems } from "./global-items.js";
7
+ import { validateCustomNode, validateFields, validateNode } from "./validate.js";
8
+ import { CUSTOM_NODE_TYPE, customSchema } from "../custom/index.js";
9
+ import { SETTINGS_KEY, CSS_KEY } from "../global.js";
8
10
  import { withDefaults } from "../field-defaults.js";
9
11
  import { elementSchemas } from "../richtext-walk.js";
10
12
  const ok = (data) => ({ ok: true, data });
@@ -96,11 +98,12 @@ export function createAdminOps(deps) {
96
98
  const by = deps.actor?.email ?? undefined;
97
99
  const collectionFields = (name) => config.registry.collections?.find((c) => c.name === name)?.fields ?? null;
98
100
  // Element nodes inside richtext validate against these (spec 2026-09-08 block nodes §2).
99
- const elementDescs = elementSchemas((config.registry.elements ?? []).map((e) => ({ name: e.schema.name, fields: e.schema.fields ?? {}, styles: e.schema.styles })));
100
- const sectionFields = (type) => config.registry.sections?.find((s) => s.schema.name === type)?.schema;
101
- const sharedItem = (name) => config.registry.shared?.find((s) => s.name === name) ?? null;
101
+ const elementDescs = elementSchemas((config.registry.elements ?? []).map((e) => ({ name: e.schema.name, fields: e.schema.fields ?? {}, settings: e.schema.settings })));
102
+ // The custom section (spec 2026-09-14 §6) validates like a section, over its own schema.
103
+ const sectionFields = (type) => (type === CUSTOM_NODE_TYPE ? customSchema(config.custom.toolset) : config.registry.sections?.find((s) => s.schema.name === type)?.schema);
104
+ const globalItem = (name) => config.registry.globals?.find((s) => s.name === name) ?? null;
102
105
  /** One row per registration; null when materialization could not create it. */
103
- const sharedRecord = async (name) => (await entries.list(name))[0] ?? null;
106
+ const globalRecord = async (name) => (await entries.list(name))[0] ?? null;
104
107
  const fanOut = async (entryId) => {
105
108
  if (!effects?.revalidate)
106
109
  return;
@@ -109,28 +112,28 @@ export function createAdminOps(deps) {
109
112
  effects.revalidate(entryTag(id));
110
113
  for (const id of pageIds)
111
114
  effects.revalidate(pageTag(id));
112
- // A shared item's row is an entry like any other: when the item
115
+ // A global item's row is an entry like any other: when the item
113
116
  // itself changes OR when something it REFERENCES does, the unit
114
- // `getSmoodlyShared` caches under `shared:<name>` is stale too — and
117
+ // `getSmoodlyGlobal` caches under `global:<name>` is stale too — and
115
118
  // that unit is a layout, on every page. `affectedTargets` returns row
116
119
  // ids, so resolve name → row id here. Once per fan-out, not memoized
117
120
  // per ops instance: a row can be materialized between two calls, and
118
121
  // an entry write is rare enough that one `entries.list` per
119
122
  // registration beats reasoning about a stale cache. Last, so the
120
123
  // existing entry-then-page order is untouched.
121
- const registered = config.registry.shared ?? [];
124
+ const registered = config.registry.globals ?? [];
122
125
  if (registered.length === 0)
123
126
  return;
124
127
  const touched = new Set(entryIds);
125
128
  for (const item of registered) {
126
129
  const row = (await entries.list(item.name))[0];
127
130
  if (row && touched.has(row.id))
128
- effects.revalidate(sharedTag(item.name));
131
+ effects.revalidate(globalTag(item.name));
129
132
  }
130
133
  };
131
134
  /** An asset's metadata (alt, title, caption, credit) is read wherever media
132
135
  * resolves (spec 2026-09-08 §9): the entry and page units that reference the
133
- * asset, directly or through an entry, the shared items among those rows,
136
+ * asset, directly or through an entry, the global items among those rows,
134
137
  * and the list units of every referencing entry's collection. The walk is
135
138
  * the entry fan-out's, seeded with the asset's id — which is no entry, so
136
139
  * it gets no tag of its own. */
@@ -144,10 +147,10 @@ export function createAdminOps(deps) {
144
147
  for (const id of pageIds)
145
148
  effects.revalidate(pageTag(id));
146
149
  const touched = new Set(users);
147
- for (const item of config.registry.shared ?? []) {
150
+ for (const item of config.registry.globals ?? []) {
148
151
  const row = (await entries.list(item.name))[0];
149
152
  if (row && touched.has(row.id))
150
- effects.revalidate(sharedTag(item.name));
153
+ effects.revalidate(globalTag(item.name));
151
154
  }
152
155
  for (const c of config.registry.collections ?? []) {
153
156
  for (const id of users) {
@@ -206,9 +209,9 @@ export function createAdminOps(deps) {
206
209
  return { title: sourceId, href: null };
207
210
  return { title: record.locales[def]?.title ?? Object.values(record.locales)[0]?.title ?? sourceId, href: `${BASE}/pages/${sourceId}` };
208
211
  }
209
- for (const item of config.registry.shared ?? []) {
212
+ for (const item of config.registry.globals ?? []) {
210
213
  if (await entries.get(item.name, sourceId))
211
- return { title: item.title, href: `${BASE}/shared/${encodeURIComponent(item.name)}` };
214
+ return { title: item.title, href: `${BASE}/globals/${encodeURIComponent(item.name)}` };
212
215
  }
213
216
  for (const c of config.registry.collections ?? []) {
214
217
  const record = await entries.get(c.name, sourceId);
@@ -323,7 +326,11 @@ export function createAdminOps(deps) {
323
326
  const schema = sectionFields(node.type);
324
327
  if (!schema?.fields)
325
328
  continue;
326
- for (const fe of validateFields(node.fields ?? {}, schema.fields, elementDescs)) {
329
+ // Fields, settings (prefixed) and the css snippet (spec 2026-09-14 §3, §4); a custom section adds its row structure (§6).
330
+ const errors = node.type === CUSTOM_NODE_TYPE
331
+ ? validateCustomNode(node, schema.fields, elementDescs)
332
+ : validateNode(node, { fields: schema.fields, settings: schema.settings }, elementDescs);
333
+ for (const fe of errors) {
327
334
  fieldErrors.push({ field: `${node.id}.${fe.field}`, message: fe.message });
328
335
  }
329
336
  }
@@ -642,22 +649,22 @@ export function createAdminOps(deps) {
642
649
  return ok(entry);
643
650
  }),
644
651
  },
645
- shared: {
652
+ globals: {
646
653
  list: () => guard(async () => {
647
- const records = await ensureSharedItems(entries, config);
648
- return ok((config.registry.shared ?? []).flatMap((s) => (records[s.name] ? [{ name: s.name, record: records[s.name] }] : [])));
654
+ const records = await ensureGlobalItems(entries, config);
655
+ return ok((config.registry.globals ?? []).flatMap((s) => (records[s.name] ? [{ name: s.name, record: records[s.name] }] : [])));
649
656
  }),
650
657
  get: (name, locale) => guard(async () => {
651
658
  const unsupported = supportedLocale(locale);
652
659
  if (unsupported)
653
660
  return unsupported;
654
- const item = sharedItem(name);
661
+ const item = globalItem(name);
655
662
  if (!item)
656
- return err("not_found", `smoodly: no shared item named "${name}".`);
657
- await ensureSharedItems(entries, config);
658
- const record = await sharedRecord(name);
663
+ return err("not_found", `smoodly: no global item named "${name}".`);
664
+ await ensureGlobalItems(entries, config);
665
+ const record = await globalRecord(name);
659
666
  if (!record?.locales[locale])
660
- return err("not_found", `smoodly: shared item "${name}" does not exist in locale "${locale}".`);
667
+ return err("not_found", `smoodly: global item "${name}" does not exist in locale "${locale}".`);
661
668
  const usage = await entries.referencesTo(name, record.id);
662
669
  return ok({ name, record, locale, fields: entryFieldsIn(record, locale), usage });
663
670
  }),
@@ -665,14 +672,14 @@ export function createAdminOps(deps) {
665
672
  const unsupported = supportedLocale(locale);
666
673
  if (unsupported)
667
674
  return unsupported;
668
- const item = sharedItem(name);
675
+ const item = globalItem(name);
669
676
  if (!item)
670
- return err("not_found", `smoodly: no shared item named "${name}".`);
671
- const record = await sharedRecord(name);
677
+ return err("not_found", `smoodly: no global item named "${name}".`);
678
+ const record = await globalRecord(name);
672
679
  if (!record)
673
- return err("not_found", `smoodly: shared item "${name}" is not materialized — open Shared content first.`);
680
+ return err("not_found", `smoodly: global item "${name}" is not materialized — open Globals first.`);
674
681
  const saved = await entries.update(name, record.id, locale, fields, by);
675
- // fanOut expires `shared:<name>` itself — this row IS the item's.
682
+ // fanOut expires `global:<name>` itself — this row IS the item's.
676
683
  await fanOut(record.id);
677
684
  return ok(saved);
678
685
  }),
@@ -680,17 +687,21 @@ export function createAdminOps(deps) {
680
687
  const unsupported = supportedLocale(locale);
681
688
  if (unsupported)
682
689
  return unsupported;
683
- const item = sharedItem(name);
690
+ const item = globalItem(name);
684
691
  if (!item)
685
- return err("not_found", `smoodly: no shared item named "${name}".`);
686
- const record = await sharedRecord(name);
692
+ return err("not_found", `smoodly: no global item named "${name}".`);
693
+ const record = await globalRecord(name);
687
694
  if (!record)
688
- return err("not_found", `smoodly: shared item "${name}" is not materialized — open Shared content first.`);
695
+ return err("not_found", `smoodly: global item "${name}" is not materialized — open Globals first.`);
689
696
  if (status === "published") {
690
697
  // A draft may be incomplete; what goes live may not (spec 2026-09-14 §2).
691
698
  if (!record.locales[locale])
692
- return err("not_found", `smoodly: shared item "${name}" does not exist in locale "${locale}".`);
693
- const fieldErrors = validateFields(entryFieldsIn(record, locale), item.fields, elementDescs);
699
+ return err("not_found", `smoodly: global item "${name}" does not exist in locale "${locale}".`);
700
+ // A visual item validates like a tree node: its fields, its section's settings under `$settings`
701
+ // (prefixed `settings.`), its `$css` (spec 2026-09-14 §3, §4).
702
+ const { [SETTINGS_KEY]: settings, [CSS_KEY]: css, ...fields } = entryFieldsIn(record, locale);
703
+ const settingsDescs = item.section !== undefined ? sectionFields(item.section)?.settings : undefined;
704
+ const fieldErrors = validateNode({ fields, settings: settings !== null && typeof settings === "object" ? settings : {}, css }, { fields: item.fields, settings: settingsDescs }, elementDescs);
694
705
  if (fieldErrors.length > 0)
695
706
  return err("validation", "Some fields need attention.", fieldErrors);
696
707
  }
@@ -703,11 +714,11 @@ export function createAdminOps(deps) {
703
714
  const unsupported = supportedLocale(locale);
704
715
  if (unsupported)
705
716
  return unsupported;
706
- if (!sharedItem(name))
707
- return err("not_found", `smoodly: no shared item named "${name}".`);
708
- const record = await sharedRecord(name);
717
+ if (!globalItem(name))
718
+ return err("not_found", `smoodly: no global item named "${name}".`);
719
+ const record = await globalRecord(name);
709
720
  if (!record)
710
- return err("not_found", `smoodly: shared item "${name}" is not materialized — open Shared content first.`);
721
+ return err("not_found", `smoodly: global item "${name}" is not materialized — open Globals first.`);
711
722
  const restored = await entries.discardDraft(name, record.id, locale, by);
712
723
  await fanOut(record.id);
713
724
  return ok(restored);
@@ -716,22 +727,22 @@ export function createAdminOps(deps) {
716
727
  const unsupported = supportedLocale(locale);
717
728
  if (unsupported)
718
729
  return unsupported;
719
- if (!sharedItem(name))
720
- return err("not_found", `smoodly: no shared item named "${name}".`);
721
- const record = await sharedRecord(name);
730
+ if (!globalItem(name))
731
+ return err("not_found", `smoodly: no global item named "${name}".`);
732
+ const record = await globalRecord(name);
722
733
  if (!record)
723
- return err("not_found", `smoodly: shared item "${name}" is not materialized — open Shared content first.`);
734
+ return err("not_found", `smoodly: global item "${name}" is not materialized — open Globals first.`);
724
735
  return ok((await entries.listVersions(name, record.id, locale)).map(summary));
725
736
  }),
726
737
  getVersion: (name, versionId) => guard(async () => {
727
- if (!sharedItem(name))
728
- return err("not_found", `smoodly: no shared item named "${name}".`);
729
- const record = await sharedRecord(name);
738
+ if (!globalItem(name))
739
+ return err("not_found", `smoodly: no global item named "${name}".`);
740
+ const record = await globalRecord(name);
730
741
  if (!record)
731
- return err("not_found", `smoodly: shared item "${name}" is not materialized — open Shared content first.`);
742
+ return err("not_found", `smoodly: global item "${name}" is not materialized — open Globals first.`);
732
743
  const version = typeof versionId === "string" ? await entries.getVersion(name, versionId) : null;
733
744
  if (!version || version.entryId !== record.id) {
734
- return err("not_found", `smoodly: no version "${String(versionId)}" of shared item "${name}".`);
745
+ return err("not_found", `smoodly: no version "${String(versionId)}" of global item "${name}".`);
735
746
  }
736
747
  return ok({ ...summary(version), fields: version.fields });
737
748
  }),
@@ -739,14 +750,14 @@ export function createAdminOps(deps) {
739
750
  const unsupported = supportedLocale(locale);
740
751
  if (unsupported)
741
752
  return unsupported;
742
- if (!sharedItem(name))
743
- return err("not_found", `smoodly: no shared item named "${name}".`);
744
- const record = await sharedRecord(name);
753
+ if (!globalItem(name))
754
+ return err("not_found", `smoodly: no global item named "${name}".`);
755
+ const record = await globalRecord(name);
745
756
  if (!record)
746
- return err("not_found", `smoodly: shared item "${name}" is not materialized — open Shared content first.`);
757
+ return err("not_found", `smoodly: global item "${name}" is not materialized — open Globals first.`);
747
758
  const version = typeof versionId === "string" ? await entries.getVersion(name, versionId) : null;
748
759
  if (!version || version.entryId !== record.id || version.locale !== locale) {
749
- return err("not_found", `smoodly: no version "${String(versionId)}" of shared item "${name}" in ${locale}.`);
760
+ return err("not_found", `smoodly: no version "${String(versionId)}" of global item "${name}" in ${locale}.`);
750
761
  }
751
762
  const restored = await entries.restore(name, record.id, locale, versionId, by);
752
763
  await fanOut(record.id);
@@ -60,16 +60,16 @@ export type EntryGetResult = {
60
60
  /** The locales the PARENT exists in (every supported locale for a root) — the form disables the rest with a reason. */
61
61
  parentLocales: string[];
62
62
  };
63
- export type SharedListItem = {
63
+ export type GlobalListItem = {
64
64
  name: string;
65
65
  record: EntryRecord;
66
66
  };
67
- export type SharedGetResult = {
67
+ export type GlobalGetResult = {
68
68
  name: string;
69
69
  /** The node with ALL its locale rows (every supported one, by construction). */
70
70
  record: EntryRecord;
71
71
  locale: string;
72
- /** That locale's merged draft fields — a visual item's styles under `$styles`. */
72
+ /** That locale's merged draft fields — a visual item's settings under `$settings`. */
73
73
  fields: Record<string, unknown>;
74
74
  /** Who links it: "used on N pages". */
75
75
  usage: EntryUsage[];
@@ -156,10 +156,10 @@ export type AdminOps = {
156
156
  /** The snapshot becomes the draft (§6); `not_found` for a foreign version. Fans out like a save. */
157
157
  restore(collection: string, id: string, locale: string, versionId: string): Promise<OpResult<EntryRecord>>;
158
158
  };
159
- shared: {
159
+ globals: {
160
160
  /** Materializes every registered item in every supported locale, then lists them in registry order. */
161
- list(): Promise<OpResult<SharedListItem[]>>;
162
- get(name: string, locale: string): Promise<OpResult<SharedGetResult>>;
161
+ list(): Promise<OpResult<GlobalListItem[]>>;
162
+ get(name: string, locale: string): Promise<OpResult<GlobalGetResult>>;
163
163
  save(name: string, locale: string, fields: Record<string, unknown>): Promise<OpResult<EntryRecord>>;
164
164
  setStatus(name: string, locale: string, status: "draft" | "published"): Promise<OpResult<EntryRecord>>;
165
165
  /** The item's live row back to its published snapshot (§4); `blocked` before the first publish. */
@@ -1,4 +1,5 @@
1
1
  import type { ResolvedConfig } from "../config.ts";
2
+ import type { Descriptor } from "../fields.ts";
2
3
  import type { ZonePolicy } from "../zones.ts";
3
4
  export type AdminField = Record<string, unknown> & {
4
5
  type: string;
@@ -14,8 +15,11 @@ export type AdminSection = {
14
15
  icon?: string;
15
16
  description?: string;
16
17
  fields: Record<string, AdminField>;
18
+ settings: Record<string, AdminField>;
19
+ /** Smoodly's styles set after the schema's toggle (spec 2026-09-14 §2); `{}` for `styles: false`. */
17
20
  styles: Record<string, AdminField>;
18
- meta: Record<string, AdminField>;
21
+ /** The custom-CSS box exists for this section: the project flag, unless the schema says `css: false` (spec 2026-09-14 §4). */
22
+ css: boolean;
19
23
  sample?: Record<string, unknown>;
20
24
  };
21
25
  export type AdminCollection = {
@@ -43,17 +47,19 @@ export type AdminPageTemplate = {
43
47
  /** `meta: { seo: true }` — the SEO & Social tab exists for this template. */
44
48
  seo: boolean;
45
49
  };
46
- export type AdminShared = {
50
+ export type AdminGlobal = {
47
51
  name: string;
48
52
  title: string;
49
53
  /** The section a visual item renders through; absent = an object item. */
50
54
  section?: string;
51
55
  fields: Record<string, AdminField>;
52
- /** Resolved (the config's standard set merged in) for a visual item; empty for an object item. */
56
+ /** The section's settings, styles set and css flag for a visual item; `{}` / `{}` / `false` for an object item. */
57
+ settings: Record<string, AdminField>;
53
58
  styles: Record<string, AdminField>;
59
+ css: boolean;
54
60
  };
55
- /** A registered Element (spec 2026-09-08 block nodes §1): a section shape plus its contexts. */
56
- export type AdminElement = AdminSection & {
61
+ /** A registered Element (spec 2026-09-08 block nodes §1): a section shape plus its contexts — no styles, no css. */
62
+ export type AdminElement = Omit<AdminSection, "styles" | "css"> & {
57
63
  contexts: string[];
58
64
  };
59
65
  export type AdminRegistry = {
@@ -61,11 +67,15 @@ export type AdminRegistry = {
61
67
  elements: AdminElement[];
62
68
  collections: AdminCollection[];
63
69
  pages: AdminPageTemplate[];
64
- shared: AdminShared[];
70
+ globals: AdminGlobal[];
71
+ /** The custom section (spec 2026-09-14 §6) as one synthetic section: its rows descriptor, the full styles set, the project css flag. */
72
+ custom: AdminSection;
65
73
  locales: {
66
74
  default: string;
67
75
  supported: string[];
68
76
  };
77
+ /** The project's custom-CSS flag (spec 2026-09-14 §4). */
78
+ css: boolean;
69
79
  /** Which slug the site root serves. */
70
80
  homePageSlug: string;
71
81
  /** How deep editors may nest pages; 1 = flat. */
@@ -76,4 +86,6 @@ export type AdminRegistry = {
76
86
  accept: string[];
77
87
  };
78
88
  };
89
+ /** A registered section's settings descriptors — what a visual global's form shows (spec 2026-09-14 §3). */
90
+ export declare function sectionSettings(config: ResolvedConfig, name: string): Record<string, Descriptor>;
79
91
  export declare function serializeAdminConfig(config: ResolvedConfig): AdminRegistry;