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
@@ -0,0 +1,129 @@
1
+ // The custom section's reconcile functions (spec 2026-09-14 §6): pure,
2
+ // shared by the admin widget, the validator and the tests. Never silent
3
+ // deletion — a removed column's content folds into the last survivor.
4
+ import { ALIGN_STEPS, GAP_STEPS, MAX_COLUMNS, SPAN_TOTAL } from "./schema.js";
5
+ export const EMPTY_DOC = { type: "doc", content: [{ type: "paragraph" }] };
6
+ export function defaultRow(columns = 1) {
7
+ const n = Math.max(1, Math.min(MAX_COLUMNS, columns));
8
+ return { spans: renormalize(Array(n).fill(1)), gap: "md", align: "top", reverse: false, columns: Array.from({ length: n }, () => structuredClone(EMPTY_DOC)) };
9
+ }
10
+ /** Integers ≥ 1 summing to 12, proportions kept: scale, floor, hand the
11
+ * remainder out by largest fraction (ties to the earlier column), then
12
+ * lift any 0 to 1 from the largest column. */
13
+ export function renormalize(spans) {
14
+ const n = spans.length;
15
+ if (n === 0)
16
+ return [];
17
+ if (n > SPAN_TOTAL)
18
+ return renormalize(spans.slice(0, SPAN_TOTAL));
19
+ const clean = spans.map((s) => (Number.isFinite(s) && s > 0 ? s : 1));
20
+ const total = clean.reduce((a, b) => a + b, 0);
21
+ const exact = clean.map((s) => (s / total) * SPAN_TOTAL);
22
+ const out = exact.map((e) => Math.floor(e));
23
+ let left = SPAN_TOTAL - out.reduce((a, b) => a + b, 0);
24
+ const order = exact.map((e, i) => [e - out[i], i]).sort((a, b) => b[0] - a[0] || a[1] - b[1]);
25
+ for (const [, i] of order) {
26
+ if (left <= 0)
27
+ break;
28
+ out[i]++;
29
+ left--;
30
+ }
31
+ for (let i = 0; i < n; i++) {
32
+ while (out[i] < 1) {
33
+ const largest = out.indexOf(Math.max(...out));
34
+ out[largest]--;
35
+ out[i]++;
36
+ }
37
+ }
38
+ return out;
39
+ }
40
+ export function setColumnCount(row, count) {
41
+ const n = Math.max(1, Math.min(MAX_COLUMNS, count));
42
+ const current = row.columns.length;
43
+ if (n === current)
44
+ return row;
45
+ if (n > current) {
46
+ const spans = [...row.spans];
47
+ const columns = [...row.columns];
48
+ while (spans.length < n) {
49
+ spans.push(spans[spans.length - 1] ?? 1);
50
+ columns.push(structuredClone(EMPTY_DOC));
51
+ }
52
+ return { ...row, spans: renormalize(spans), columns };
53
+ }
54
+ const keep = row.columns.slice(0, n);
55
+ const folded = row.columns.slice(n).reduce((acc, doc) => concatDocs(acc, doc), keep[n - 1]);
56
+ keep[n - 1] = folded;
57
+ return { ...row, spans: renormalize(row.spans.slice(0, n)), columns: keep };
58
+ }
59
+ /** The boundary between columns `handle` and `handle + 1` moved so the left side is `left` twelfths; both sides keep ≥ 1. */
60
+ export function setSpan(spans, handle, left) {
61
+ if (handle < 0 || handle >= spans.length - 1)
62
+ return spans;
63
+ const pair = spans[handle] + spans[handle + 1];
64
+ const l = Math.max(1, Math.min(pair - 1, Math.round(left)));
65
+ const next = [...spans];
66
+ next[handle] = l;
67
+ next[handle + 1] = pair - l;
68
+ return next;
69
+ }
70
+ /** The left span a pointer at `x` over a bar `width` px wide asks for, for the handle between `handle` and `handle + 1`. */
71
+ export function spanAtX(x, width, spans, handle) {
72
+ const before = spans.slice(0, handle).reduce((a, b) => a + b, 0);
73
+ const twelfth = Math.max(1, width) / SPAN_TOTAL;
74
+ const pair = spans[handle] + spans[handle + 1];
75
+ const wanted = Math.round(x / twelfth) - before;
76
+ return Math.max(1, Math.min(pair - 1, wanted));
77
+ }
78
+ export function presetsFor(n) {
79
+ if (n === 2)
80
+ return [{ label: "1/2 · 1/2", spans: [6, 6] }, { label: "1/3 · 2/3", spans: [4, 8] }, { label: "2/3 · 1/3", spans: [8, 4] }];
81
+ if (n === 3)
82
+ return [{ label: "1/3 · 1/3 · 1/3", spans: [4, 4, 4] }];
83
+ if (n === 4)
84
+ return [{ label: "1/4 × 4", spans: [3, 3, 3, 3] }];
85
+ return [];
86
+ }
87
+ const isDoc = (v) => typeof v === "object" && v !== null && v.type === "doc" && Array.isArray(v.content);
88
+ /** A paragraph with no children: what an untouched column holds. Not content, so a fold never appends it. */
89
+ const isBlankNode = (n) => typeof n === "object" && n !== null && n.type === "paragraph"
90
+ && (!Array.isArray(n.content) || (n.content.length === 0));
91
+ const isBlankDoc = (v) => isDoc(v) && v.content.every(isBlankNode);
92
+ export function concatDocs(a, b) {
93
+ if (!isDoc(a) || isBlankDoc(a))
94
+ return isDoc(b) ? b : structuredClone(EMPTY_DOC);
95
+ if (!isDoc(b) || isBlankDoc(b))
96
+ return a;
97
+ return { type: "doc", content: [...a.content, ...b.content] };
98
+ }
99
+ /** The structural problems the publish validator reports, by dotted path. */
100
+ export function rowsProblems(rows) {
101
+ if (!Array.isArray(rows))
102
+ return [{ field: "rows", message: "Must be a list." }];
103
+ const out = [];
104
+ rows.forEach((row, i) => {
105
+ const r = row;
106
+ const columns = Array.isArray(r?.columns) ? r.columns : [];
107
+ const spans = Array.isArray(r?.spans) ? r.spans : [];
108
+ if (columns.length < 1 || columns.length > MAX_COLUMNS) {
109
+ out.push({ field: `rows.${i}.columns`, message: "Between 1 and 6 columns." });
110
+ return;
111
+ }
112
+ if (spans.length !== columns.length) {
113
+ out.push({ field: `rows.${i}.spans`, message: "One span per column." });
114
+ return;
115
+ }
116
+ if (!spans.every((s) => Number.isInteger(s) && s >= 1) || spans.reduce((a, b) => a + b, 0) !== SPAN_TOTAL)
117
+ out.push({ field: `rows.${i}.spans`, message: "Spans must sum to 12." });
118
+ if (r?.gap !== undefined && !GAP_STEPS.includes(r.gap))
119
+ out.push({ field: `rows.${i}.gap`, message: `Must be one of: ${GAP_STEPS.join(", ")}.` });
120
+ if (r?.align !== undefined && !ALIGN_STEPS.includes(r.align))
121
+ out.push({ field: `rows.${i}.align`, message: `Must be one of: ${ALIGN_STEPS.join(", ")}.` });
122
+ });
123
+ return out;
124
+ }
125
+ export function columnsLabel(rows) {
126
+ const first = Array.isArray(rows) ? rows[0] : undefined;
127
+ const n = Array.isArray(first?.columns) ? first.columns.length : 0;
128
+ return n === 0 ? "Custom section" : `Custom section · ${n} column${n === 1 ? "" : "s"}`;
129
+ }
@@ -0,0 +1,32 @@
1
+ import type { Descriptor } from "../fields.ts";
2
+ import { type Toolset } from "../toolset.ts";
3
+ export declare const CUSTOM_NODE_TYPE = "custom";
4
+ export declare const MAX_COLUMNS = 6;
5
+ export declare const SPAN_TOTAL = 12;
6
+ export declare const DEFAULT_BREAKPOINT = "48rem";
7
+ export declare const GAP_STEPS: readonly ["sm", "md", "lg"];
8
+ export type GapStep = (typeof GAP_STEPS)[number];
9
+ export declare const GAP_VALUES: Record<GapStep, string>;
10
+ export declare const ALIGN_STEPS: readonly ["top", "center", "bottom", "stretch"];
11
+ export type AlignStep = (typeof ALIGN_STEPS)[number];
12
+ /** CSS `align-items` per step — the only keywords the emitter writes. */
13
+ export declare const ALIGN_VALUES: Record<AlignStep, string>;
14
+ export type CustomRow = {
15
+ spans: number[];
16
+ gap: GapStep;
17
+ align: AlignStep;
18
+ reverse: boolean;
19
+ columns: unknown[];
20
+ };
21
+ export type CustomFields = {
22
+ rows: CustomRow[];
23
+ };
24
+ /** The columns' toolset: the developer's, or the richtext default plus the image node. */
25
+ export declare function customToolset(toolset?: Toolset): Toolset;
26
+ export declare function customFields(toolset: Toolset): Record<string, Descriptor>;
27
+ /** What every section map gets for the node type: the walks, the resolver and the validator read it like a section's schema. */
28
+ export declare function customSchema(toolset: Toolset): {
29
+ name: string;
30
+ fields: Record<string, Descriptor>;
31
+ settings: Record<string, Descriptor>;
32
+ };
@@ -0,0 +1,39 @@
1
+ import { DEFAULT_TOOLSET } from "../toolset.js";
2
+ export const CUSTOM_NODE_TYPE = "custom";
3
+ export const MAX_COLUMNS = 6;
4
+ export const SPAN_TOTAL = 12;
5
+ export const DEFAULT_BREAKPOINT = "48rem";
6
+ export const GAP_STEPS = ["sm", "md", "lg"];
7
+ export const GAP_VALUES = { sm: "1rem", md: "2rem", lg: "3rem" };
8
+ export const ALIGN_STEPS = ["top", "center", "bottom", "stretch"];
9
+ /** CSS `align-items` per step — the only keywords the emitter writes. */
10
+ export const ALIGN_VALUES = { top: "start", center: "center", bottom: "end", stretch: "stretch" };
11
+ /** The columns' toolset: the developer's, or the richtext default plus the image node. */
12
+ export function customToolset(toolset) {
13
+ if (toolset)
14
+ return toolset;
15
+ return { ...DEFAULT_TOOLSET, nodes: [...(DEFAULT_TOOLSET.nodes ?? []), "image"] };
16
+ }
17
+ const radio = (options, labels, fallback) => ({ type: "radio", options: options.map((value) => ({ value, label: labels[value] })), default: fallback });
18
+ export function customFields(toolset) {
19
+ return {
20
+ rows: {
21
+ type: "list", widget: "rows", min: 1,
22
+ item: {
23
+ type: "object",
24
+ fields: {
25
+ spans: { type: "spans" },
26
+ gap: radio(GAP_STEPS, { sm: "S", md: "M", lg: "L" }, "md"),
27
+ align: radio(ALIGN_STEPS, { top: "Top", center: "Center", bottom: "Bottom", stretch: "Stretch" }, "top"),
28
+ reverse: { type: "boolean", default: false },
29
+ // An empty column publishes (it is spacing); the row structure is rowsProblems' job.
30
+ columns: { type: "list", min: 1, max: MAX_COLUMNS, item: { type: "richtext", toolset, optional: true } },
31
+ },
32
+ },
33
+ },
34
+ };
35
+ }
36
+ /** What every section map gets for the node type: the walks, the resolver and the validator read it like a section's schema. */
37
+ export function customSchema(toolset) {
38
+ return { name: CUSTOM_NODE_TYPE, fields: customFields(toolset), settings: {} };
39
+ }
@@ -1,14 +1,19 @@
1
1
  import type { ComponentType, ReactElement } from "react";
2
2
  import type { CollectionSchema } from "./collections.ts";
3
3
  import type { EntryRecord } from "./entry-store.ts";
4
+ import type { LocaleAlternate } from "./paths.ts";
4
5
  export type EntryPageSchema = {
5
6
  kind: "entryPage";
6
7
  name: string;
7
8
  collection: string;
8
9
  };
10
+ /** What an entry page renders: the entry in the render locale, its path
11
+ * there, and its address in every supported locale (spec 2026-09-15 §1). */
9
12
  export type EntryPageProps = {
10
13
  entry: EntryRecord;
11
14
  path: string;
15
+ locale: string;
16
+ locales: Record<string, LocaleAlternate | null>;
12
17
  };
13
18
  export type PairedEntryPage = ((props: EntryPageProps) => ReactElement) & {
14
19
  schema: EntryPageSchema;
@@ -1,6 +1,6 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
2
  export function entryPage(collection, View) {
3
- const Component = ((props) => _jsx(View, { entry: props.entry, path: props.path }));
3
+ const Component = ((props) => _jsx(View, { ...props }));
4
4
  Component.schema = { kind: "entryPage", name: `entry:${collection.name}`, collection: collection.name };
5
5
  return Component;
6
6
  }
@@ -1,5 +1,5 @@
1
1
  import type { CollectionSchema, EntryOrder } from "./collections.ts";
2
- import type { SharedSchema } from "./shared.ts";
2
+ import type { GlobalSchema } from "./global.ts";
3
3
  import { type RefEdge, type SectionLike } from "./refs.ts";
4
4
  import { MemoryRefIndex } from "./ref-index.ts";
5
5
  import { type PathIndex } from "./path-index.ts";
@@ -82,7 +82,7 @@ export type AddEntryLocaleOptions = {
82
82
  export type EntryStoreOptions = {
83
83
  paths?: PathIndex;
84
84
  locales?: LocaleSet;
85
- shared?: SharedSchema[];
85
+ globals?: GlobalSchema[];
86
86
  /** Registered Elements a richtext document may embed (spec 2026-09-08 block nodes §2). */
87
87
  elements?: SectionLike[];
88
88
  };
@@ -154,8 +154,8 @@ export interface EntryStore {
154
154
  pathsOf(collection: string, id: string): Promise<Record<string, string>>;
155
155
  }
156
156
  export declare function schemaOf(collections: CollectionSchema<any>[], name: string): CollectionSchema<any>;
157
- /** A shared item is a collection with exactly one entry (spec 2026-09-07). */
158
- export declare const isSharedSchema: (schema: CollectionSchema<any>) => boolean;
157
+ /** A global item is a collection with exactly one entry (spec 2026-09-07). */
158
+ export declare const isGlobalSchema: (schema: CollectionSchema<any>) => boolean;
159
159
  export declare const DEFAULT_ENTRY_LOCALES: LocaleSet;
160
160
  export declare const entryErrors: {
161
161
  notFound: (collection: string, id: string) => Error;
@@ -13,7 +13,7 @@ import { collectionDepth, collectionOrder } from "./collections.js";
13
13
  import { collectEntryRefs } from "./refs.js";
14
14
  import { MemoryRefIndex } from "./ref-index.js";
15
15
  import { MemoryPathIndex } from "./path-index.js";
16
- import { sharedEntryId } from "./shared-id.js";
16
+ import { globalEntryId } from "./global-id.js";
17
17
  import { DEFAULT_SPACE_ID } from "./sql-space.js";
18
18
  import { assertDepth, assertSupportedLocale, chainOf, entryPathRows, slugOf, unsupportedLocale, } from "./paths.js";
19
19
  /** The field types whose value is an array: a scalar `where` on one is
@@ -40,8 +40,8 @@ export function schemaOf(collections, name) {
40
40
  throw new Error(`smoodly: no collection named "${name}".`);
41
41
  return schema;
42
42
  }
43
- /** A shared item is a collection with exactly one entry (spec 2026-09-07). */
44
- export const isSharedSchema = (schema) => schema.kind === "shared";
43
+ /** A global item is a collection with exactly one entry (spec 2026-09-07). */
44
+ export const isGlobalSchema = (schema) => schema.kind === "global";
45
45
  let counter = 0;
46
46
  const uid = (prefix) => `${prefix}-${++counter}-${Date.now().toString(36)}`;
47
47
  export const DEFAULT_ENTRY_LOCALES = { default: "en", supported: ["en"] };
@@ -62,11 +62,11 @@ export const entryErrors = {
62
62
  lastLocale: (id) => new Error(`smoodly: entry "${id}" must exist in at least one locale — delete the entry instead.`),
63
63
  localeInUse: (locale, titles) => new Error(`smoodly: child entries still exist in locale "${locale}" — remove it from ${titles.map((t) => `"${t}"`).join(", ")} first.`),
64
64
  moveLocales: (locale) => new Error(`smoodly: the destination parent does not exist in locale "${locale}" — the entry cannot move under it.`),
65
- // Shared items (spec 2026-09-07 §3): one row, no URL, every locale, code-owned.
66
- singleton: (name) => new Error(`smoodly: shared item "${name}" already exists — it is a singleton.`),
67
- owned: (name, verb) => new Error(`smoodly: shared item "${name}" is owned by code — ${verb}.`),
68
- noSlug: (name) => new Error(`smoodly: shared item "${name}" has no URL — it cannot take a slug.`),
69
- everyLocale: (name) => new Error(`smoodly: shared item "${name}" exists in every locale — it cannot be removed from one.`),
65
+ // Global items (spec 2026-09-07 §3): one row, no URL, every locale, code-owned.
66
+ singleton: (name) => new Error(`smoodly: global item "${name}" already exists — it is a singleton.`),
67
+ owned: (name, verb) => new Error(`smoodly: global item "${name}" is owned by code — ${verb}.`),
68
+ noSlug: (name) => new Error(`smoodly: global item "${name}" has no URL — it cannot take a slug.`),
69
+ everyLocale: (name) => new Error(`smoodly: global item "${name}" exists in every locale — it cannot be removed from one.`),
70
70
  foreignVersion: (collection, id, locale, versionId) => new Error(`smoodly: "${versionId}" is not a version of entry "${id}" in "${collection}", locale "${locale}".`),
71
71
  };
72
72
  /** The keys a locale row owns: every field with .localized(), except
@@ -167,7 +167,7 @@ export class MemoryEntryStore {
167
167
  this.byId = (id) => this.entries.get(id);
168
168
  this.paths = options.paths ?? new MemoryPathIndex();
169
169
  this.locales = options.locales ?? DEFAULT_ENTRY_LOCALES;
170
- this.schemas = [...collections, ...(options.shared ?? [])];
170
+ this.schemas = [...collections, ...(options.globals ?? [])];
171
171
  this.elements = options.elements;
172
172
  }
173
173
  schema(collection) {
@@ -304,7 +304,7 @@ export class MemoryEntryStore {
304
304
  const schema = this.schema(collection);
305
305
  assertSupportedLocale(options.locale, this.locales);
306
306
  const parentId = options.parentId ?? null;
307
- if (isSharedSchema(schema)) {
307
+ if (isGlobalSchema(schema)) {
308
308
  if ([...this.entries.values()].some((e) => e.collection === collection))
309
309
  throw entryErrors.singleton(collection);
310
310
  if (slugOf(fields) !== null)
@@ -316,10 +316,10 @@ export class MemoryEntryStore {
316
316
  this.assertSiblingSlug(collection, parentId, options.locale, slug);
317
317
  const now = Date.now();
318
318
  const entry = {
319
- // A shared item's id is derived, not minted (spec 2026-09-07 §10):
319
+ // A global item's id is derived, not minted (spec 2026-09-07 §10):
320
320
  // the same uuid v5 the Supabase adapter computes, so self-host and
321
321
  // cloud agree on the row a materialization would create.
322
- id: isSharedSchema(schema) ? await sharedEntryId(DEFAULT_SPACE_ID, collection) : uid("entry"),
322
+ id: isGlobalSchema(schema) ? await globalEntryId(DEFAULT_SPACE_ID, collection) : uid("entry"),
323
323
  collection, parentId, fields: shared,
324
324
  locales: {
325
325
  [options.locale]: {
@@ -393,7 +393,7 @@ export class MemoryEntryStore {
393
393
  const row = entryLocaleRow(entry, locale);
394
394
  this.ensureLiveSnapshot(entry, locale);
395
395
  const { shared, localized, slug } = splitFields(fields, schema);
396
- if (isSharedSchema(schema) && slug !== null)
396
+ if (isGlobalSchema(schema) && slug !== null)
397
397
  throw entryErrors.noSlug(collection);
398
398
  this.assertSiblingSlug(collection, entry.parentId, locale, slug, id);
399
399
  const before = { fields: entry.fields, row: { ...row } };
@@ -479,7 +479,7 @@ export class MemoryEntryStore {
479
479
  }
480
480
  async removeLocale(collection, id, locale) {
481
481
  const schema = this.schema(collection);
482
- if (isSharedSchema(schema))
482
+ if (isGlobalSchema(schema))
483
483
  throw entryErrors.everyLocale(collection);
484
484
  const entry = this.must(collection, id);
485
485
  const row = entryLocaleRow(entry, locale);
@@ -533,7 +533,7 @@ export class MemoryEntryStore {
533
533
  return v ? structuredClone(v) : null;
534
534
  }
535
535
  async move(collection, id, to) {
536
- if (isSharedSchema(this.schema(collection)))
536
+ if (isGlobalSchema(this.schema(collection)))
537
537
  throw entryErrors.owned(collection, "it has no tree position");
538
538
  const entry = this.must(collection, id);
539
539
  const subtree = this.subtree(entry);
@@ -572,7 +572,7 @@ export class MemoryEntryStore {
572
572
  const entry = this.row(collection, id);
573
573
  if (!entry)
574
574
  return;
575
- if (isSharedSchema(this.schema(collection)))
575
+ if (isGlobalSchema(this.schema(collection)))
576
576
  throw entryErrors.owned(collection, "remove the registration to remove it");
577
577
  if ([...this.entries.values()].some((e) => e.parentId === id))
578
578
  throw entryErrors.hasChildren(collection, id);
@@ -587,7 +587,7 @@ export class MemoryEntryStore {
587
587
  this.refs.remove({ sourceKind: "entry", sourceId: id });
588
588
  }
589
589
  async reorder(collection, orderedIds) {
590
- if (isSharedSchema(this.schema(collection)))
590
+ if (isGlobalSchema(this.schema(collection)))
591
591
  throw entryErrors.owned(collection, "it has no order");
592
592
  orderedIds.forEach((id, i) => {
593
593
  const entry = this.row(collection, id);
package/dist/fields.d.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { type Toolset } from "./toolset.ts";
2
2
  import { type ResolvedFile, type ResolvedImage, type ResolvedVideo } from "./media.ts";
3
+ import { type Condition, when } from "./conditions.ts";
3
4
  export type Descriptor = {
4
5
  type: string;
5
6
  [key: string]: unknown;
@@ -43,7 +44,8 @@ export type FieldBuilder<Value = unknown, Flags extends BuilderFlags = {}> = {
43
44
  * - no call — hydrate the entry itself; its refs stay ids
44
45
  */
45
46
  resolve(...projection: (string | Record<string, unknown>)[]): FieldBuilder<Value, Flags>;
46
- tab(tab: "content" | "styles" | "media" | "meta"): FieldBuilder<Value, Flags>;
47
+ /** Hide the field while the condition holds (spec 2026-09-14 §12): it leaves the form and validation skips it; the stored value is kept. */
48
+ hidden(condition: Condition): FieldBuilder<Value, Flags>;
47
49
  /** phantom, type-level only */
48
50
  readonly __value?: Value;
49
51
  readonly __flags?: Flags;
@@ -83,7 +85,8 @@ export declare const f: {
83
85
  richtext: (opts?: {
84
86
  toolset?: Toolset;
85
87
  }) => FieldBuilder<unknown, {}>;
86
- dataAttributes: () => FieldBuilder<Record<string, string>, {}>;
88
+ /** A condition for `.hidden()`: `f.when("settings.mediaType").is("video")`. */
89
+ when: typeof when;
87
90
  boolean: () => FieldBuilder<boolean, {}>;
88
91
  /** One value from a list, shown as a dropdown. */
89
92
  select: <const O extends readonly OptionInput[]>(options: O) => FieldBuilder<OptionValue<O[number]>, {}>;
package/dist/fields.js CHANGED
@@ -3,6 +3,7 @@
3
3
  // keyed by descriptor type. Chaining is immutable.
4
4
  import { DEFAULT_TOOLSET } from "./toolset.js";
5
5
  import { parseAspect } from "./media.js";
6
+ import { when } from "./conditions.js";
6
7
  /** Normalizes a schema's option list to objects; refuses an empty list,
7
8
  * since a choice with nothing to choose is a schema bug (spec 2026-09-10
8
9
  * page fields §1). */
@@ -43,7 +44,7 @@ function builder(descriptor) {
43
44
  return chain({ resolve: projection[0] });
44
45
  return chain({ resolve: Object.fromEntries(projection.map((k) => [k, true])) });
45
46
  },
46
- tab: (tab) => chain({ tab }),
47
+ hidden: (condition) => chain({ hidden: condition }),
47
48
  };
48
49
  }
49
50
  export const describePath = (path) => (path === "" ? "the item" : `"${path}"`);
@@ -97,7 +98,8 @@ export const f = {
97
98
  video: () => builder({ type: "video" }),
98
99
  file: () => builder({ type: "file" }),
99
100
  richtext: (opts) => builder({ type: "richtext", toolset: opts?.toolset ?? DEFAULT_TOOLSET }),
100
- dataAttributes: () => builder({ type: "dataAttributes" }),
101
+ /** A condition for `.hidden()`: `f.when("settings.mediaType").is("video")`. */
102
+ when,
101
103
  boolean: () => builder({ type: "boolean" }),
102
104
  /** One value from a list, shown as a dropdown. */
103
105
  select: (options) => builder({ type: "select", options: normalizeOptions(options) }),
@@ -1,3 +1,3 @@
1
1
  /** RFC 4122 §4.3 uuid v5: sha1(namespace bytes ‖ name bytes), version 5,
2
2
  * variant 10xx, first 16 bytes of the digest. */
3
- export declare function sharedEntryId(space: string, name: string): Promise<string>;
3
+ export declare function globalEntryId(space: string, name: string): Promise<string>;
@@ -1,8 +1,8 @@
1
- // The row id of a shared item (spec 2026-09-07 §10 "§3 row id"): uuid v5
1
+ // The row id of a global item (spec 2026-09-07 §10 "§3 row id"): uuid v5
2
2
  // of (space id as the namespace, item name as the name). Deterministic in
3
3
  // both adapters, so a second concurrent materialization of the same item
4
4
  // hits the `entries` primary key instead of adding an unremovable
5
- // duplicate — `delete` refuses every shared row. The space is part of it
5
+ // duplicate — `delete` refuses every global row. The space is part of it
6
6
  // because the primary key is `id` alone and, in the cloud, many spaces
7
7
  // share one table: a name-only id would make space B's footer collide
8
8
  // with space A's.
@@ -16,7 +16,7 @@ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/
16
16
  /** The namespace's 16 raw bytes — RFC 4122 hashes the bytes, not the text. */
17
17
  function uuidBytes(uuid) {
18
18
  if (!UUID_RE.test(uuid))
19
- throw new Error(`smoodly: "${uuid}" is not a uuid — a shared item's namespace is the space id.`);
19
+ throw new Error(`smoodly: "${uuid}" is not a uuid — a global item's namespace is the space id.`);
20
20
  const hex = uuid.replace(/-/g, "");
21
21
  const bytes = new Uint8Array(16);
22
22
  for (let i = 0; i < 16; i++)
@@ -25,7 +25,7 @@ function uuidBytes(uuid) {
25
25
  }
26
26
  /** RFC 4122 §4.3 uuid v5: sha1(namespace bytes ‖ name bytes), version 5,
27
27
  * variant 10xx, first 16 bytes of the digest. */
28
- export async function sharedEntryId(space, name) {
28
+ export async function globalEntryId(space, name) {
29
29
  const input = new Uint8Array([...uuidBytes(space), ...new TextEncoder().encode(name)]);
30
30
  const digest = new Uint8Array(await globalThis.crypto.subtle.digest("SHA-1", input));
31
31
  const bytes = digest.subarray(0, 16);
@@ -0,0 +1,57 @@
1
+ import type { CollectionSchema } from "./collections.ts";
2
+ import type { Descriptor } from "./fields.ts";
3
+ import type { PairedComponent } from "./pairing.tsx";
4
+ import type { BuilderMap, NamespaceValues, SectionSchema } from "./schema.ts";
5
+ import type { StyleProps } from "./styles.ts";
6
+ /** The reserved tree node type of a placement. */
7
+ export declare const GLOBAL_NODE_TYPE = "global";
8
+ /** The reserved keys a visual item's settings, styles steps and css live under in its row (spec 2026-09-14 §8). */
9
+ export declare const SETTINGS_KEY = "$settings";
10
+ export declare const STYLES_KEY = "$styles";
11
+ export declare const CSS_KEY = "$css";
12
+ /** The node id a layout-rendered visual item carries (spec 2026-09-13 §1):
13
+ * `getGlobal` stamps it into the styles' `__smd`, the section wrapper emits it,
14
+ * the canvas selects by it. A placement keeps its own tree node id
15
+ * (`global-xxxxxxxx`, no colon). */
16
+ export declare const GLOBAL_NODE_PREFIX = "global:";
17
+ export declare const globalNodeId: (name: string) => string;
18
+ /** The item a layout node id names, or null for any other id. */
19
+ export declare function globalNameOf(id: string): string | null;
20
+ export type GlobalSchema<V = Record<string, unknown>, N extends string = string> = CollectionSchema<any, N> & {
21
+ kind: "global";
22
+ /** The registered section a visual item renders through; absent = an object item. */
23
+ section?: string;
24
+ /** The section's settings descriptors (empty for an object item). */
25
+ settings: Record<string, Descriptor>;
26
+ sample?: Record<string, unknown>;
27
+ /** phantom: what getGlobal returns */
28
+ readonly __value?: V;
29
+ };
30
+ export type GlobalLink = {
31
+ item: string;
32
+ ref: string;
33
+ };
34
+ /** The placement a tree node is, or null for any other node. Accepts a
35
+ * bare `{ type, fields }` shape or a full tree node — `id` (and any
36
+ * other tree-node property) is ignored. */
37
+ export declare function globalLink(node: {
38
+ id?: string;
39
+ type: string;
40
+ fields?: Record<string, unknown>;
41
+ }): GlobalLink | null;
42
+ type GlobalInput<N extends string = string> = {
43
+ name: N;
44
+ title: string;
45
+ };
46
+ export declare function globalItem<F extends BuilderMap, const N extends string = string>(input: GlobalInput<N> & {
47
+ fields: F;
48
+ section?: undefined;
49
+ }): GlobalSchema<NamespaceValues<F>, N>;
50
+ export declare function globalItem<F extends BuilderMap, St extends BuilderMap, const N extends string = string>(input: GlobalInput<N> & {
51
+ section: PairedComponent<SectionSchema<F, St>, F, St>;
52
+ fields?: undefined;
53
+ }): GlobalSchema<NamespaceValues<F> & {
54
+ settings: NamespaceValues<St>;
55
+ styles: StyleProps;
56
+ }, N>;
57
+ export {};
package/dist/global.js ADDED
@@ -0,0 +1,51 @@
1
+ /** The reserved tree node type of a placement. */
2
+ export const GLOBAL_NODE_TYPE = "global";
3
+ /** The reserved keys a visual item's settings, styles steps and css live under in its row (spec 2026-09-14 §8). */
4
+ export const SETTINGS_KEY = "$settings";
5
+ export const STYLES_KEY = "$styles";
6
+ export const CSS_KEY = "$css";
7
+ const RESERVED_KEYS = [SETTINGS_KEY, STYLES_KEY, CSS_KEY];
8
+ /** The node id a layout-rendered visual item carries (spec 2026-09-13 §1):
9
+ * `getGlobal` stamps it into the styles' `__smd`, the section wrapper emits it,
10
+ * the canvas selects by it. A placement keeps its own tree node id
11
+ * (`global-xxxxxxxx`, no colon). */
12
+ export const GLOBAL_NODE_PREFIX = "global:";
13
+ export const globalNodeId = (name) => `${GLOBAL_NODE_PREFIX}${name}`;
14
+ /** The item a layout node id names, or null for any other id. */
15
+ export function globalNameOf(id) {
16
+ return id.startsWith(GLOBAL_NODE_PREFIX) && id.length > GLOBAL_NODE_PREFIX.length ? id.slice(GLOBAL_NODE_PREFIX.length) : null;
17
+ }
18
+ /** The placement a tree node is, or null for any other node. Accepts a
19
+ * bare `{ type, fields }` shape or a full tree node — `id` (and any
20
+ * other tree-node property) is ignored. */
21
+ export function globalLink(node) {
22
+ if (node.type !== GLOBAL_NODE_TYPE)
23
+ return null;
24
+ const { item, ref } = node.fields ?? {};
25
+ return typeof item === "string" && typeof ref === "string" ? { item, ref } : null;
26
+ }
27
+ /** `smoodly.global()`: named `globalItem` here so nothing shadows the `global` idiom. */
28
+ export function globalItem(input) {
29
+ if (!input?.name)
30
+ throw new Error("smoodly: a global item requires a name.");
31
+ if ((input.fields === undefined) === (input.section === undefined)) {
32
+ throw new Error(`smoodly: global item "${input.name}" must declare exactly one of \`section\` or \`fields\`.`);
33
+ }
34
+ const fields = input.section
35
+ ? input.section.schema.fields
36
+ : Object.fromEntries(Object.entries(input.fields).map(([k, b]) => [k, b.descriptor]));
37
+ for (const key of RESERVED_KEYS) {
38
+ if (Object.prototype.hasOwnProperty.call(fields, key)) {
39
+ throw new Error(`smoodly: global item "${input.name}" has a field named "${key}" — that key is reserved for a visual item's ${key === SETTINGS_KEY ? "settings" : key === STYLES_KEY ? "styles" : "css"}.`);
40
+ }
41
+ }
42
+ return {
43
+ kind: "global",
44
+ name: input.name,
45
+ title: input.title,
46
+ fields,
47
+ settings: input.section ? input.section.schema.settings : {},
48
+ ...(input.section ? { section: input.section.schema.name } : {}),
49
+ ...(input.section?.schema.sample ? { sample: input.section.schema.sample } : {}),
50
+ };
51
+ }