smoodly 0.0.15 → 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 (154) hide show
  1. package/README.md +238 -36
  2. package/dist/admin/auth.d.ts +7 -4
  3. package/dist/admin/auth.js +1 -1
  4. package/dist/admin/client-ops.js +3 -3
  5. package/dist/admin/custom-section.d.ts +10 -0
  6. package/dist/admin/custom-section.js +10 -0
  7. package/dist/admin/editor/CanvasOverlay.d.ts +6 -2
  8. package/dist/admin/editor/CanvasOverlay.js +6 -6
  9. package/dist/admin/editor/EditorView.d.ts +2 -0
  10. package/dist/admin/editor/EditorView.js +322 -62
  11. package/dist/admin/editor/GlobalInspector.d.ts +24 -0
  12. package/dist/admin/editor/GlobalInspector.js +13 -0
  13. package/dist/admin/editor/InsertPicker.d.ts +6 -5
  14. package/dist/admin/editor/InsertPicker.js +6 -4
  15. package/dist/admin/editor/InspectorFields.d.ts +21 -0
  16. package/dist/admin/editor/InspectorFields.js +22 -0
  17. package/dist/admin/editor/Outline.d.ts +0 -1
  18. package/dist/admin/editor/Outline.js +3 -3
  19. package/dist/admin/editor/PageSettings.js +3 -2
  20. package/dist/admin/editor/global-edit.d.ts +39 -0
  21. package/dist/admin/editor/global-edit.js +124 -0
  22. package/dist/admin/editor/useGlobalEdit.d.ts +6 -0
  23. package/dist/admin/editor/useGlobalEdit.js +18 -0
  24. package/dist/admin/forms/FieldWidget.d.ts +14 -1
  25. package/dist/admin/forms/FieldWidget.js +38 -11
  26. package/dist/admin/forms/ListField.d.ts +9 -1
  27. package/dist/admin/forms/ListField.js +16 -8
  28. package/dist/admin/forms/RichtextElementCard.js +11 -5
  29. package/dist/admin/forms/RowsWidget.d.ts +13 -0
  30. package/dist/admin/forms/RowsWidget.js +26 -0
  31. package/dist/admin/forms/SpanBar.d.ts +5 -0
  32. package/dist/admin/forms/SpanBar.js +37 -0
  33. package/dist/admin/forms/richtext-insert.d.ts +2 -2
  34. package/dist/admin/forms/richtext-insert.js +3 -3
  35. package/dist/admin/forms/richtext-nodes.js +1 -1
  36. package/dist/admin/forms/tabs.d.ts +20 -8
  37. package/dist/admin/forms/tabs.js +19 -23
  38. package/dist/admin/forms/useRichtextEditor.js +8 -1
  39. package/dist/admin/global-items.d.ts +9 -0
  40. package/dist/admin/{shared-items.js → global-items.js} +29 -14
  41. package/dist/admin/index.d.ts +3 -3
  42. package/dist/admin/index.js +1 -1
  43. package/dist/admin/next/op-handler.js +4 -3
  44. package/dist/admin/ops-impl.d.ts +3 -0
  45. package/dist/admin/ops-impl.js +242 -56
  46. package/dist/admin/ops.d.ts +38 -6
  47. package/dist/admin/serialize.d.ts +18 -7
  48. package/dist/admin/serialize.js +27 -5
  49. package/dist/admin/shell/AdminApp.js +7 -7
  50. package/dist/admin/shell/EntriesList.js +19 -3
  51. package/dist/admin/shell/EntryForm.js +183 -77
  52. package/dist/admin/shell/GlobalFields.d.ts +24 -0
  53. package/dist/admin/shell/GlobalFields.js +19 -0
  54. package/dist/admin/shell/{SharedForm.d.ts → GlobalForm.d.ts} +1 -1
  55. package/dist/admin/shell/GlobalForm.js +258 -0
  56. package/dist/admin/shell/{SharedList.d.ts → GlobalList.d.ts} +1 -1
  57. package/dist/admin/shell/{SharedList.js → GlobalList.js} +12 -11
  58. package/dist/admin/shell/IconRail.js +14 -7
  59. package/dist/admin/shell/PagesList.js +4 -8
  60. package/dist/admin/shell/entries-list.d.ts +2 -2
  61. package/dist/admin/shell/entries-list.js +3 -3
  62. package/dist/admin/shell/{shared-list.d.ts → global-list.d.ts} +3 -3
  63. package/dist/admin/shell/{shared-list.js → global-list.js} +2 -2
  64. package/dist/admin/supabase-auth.js +1 -1
  65. package/dist/admin/tree-ops.d.ts +10 -8
  66. package/dist/admin/tree-ops.js +28 -16
  67. package/dist/admin/ui/ActionMenu.d.ts +11 -0
  68. package/dist/admin/ui/ActionMenu.js +25 -0
  69. package/dist/admin/ui/HistoryModal.d.ts +23 -0
  70. package/dist/admin/ui/HistoryModal.js +40 -0
  71. package/dist/admin/ui/Modal.d.ts +5 -1
  72. package/dist/admin/ui/Modal.js +3 -3
  73. package/dist/admin/ui/PublishControl.d.ts +31 -0
  74. package/dist/admin/ui/PublishControl.js +24 -0
  75. package/dist/admin/ui/format.d.ts +4 -0
  76. package/dist/admin/ui/format.js +9 -0
  77. package/dist/admin/ui/primitives.d.ts +16 -1
  78. package/dist/admin/ui/primitives.js +20 -4
  79. package/dist/admin/ui/status-label.d.ts +10 -0
  80. package/dist/admin/ui/status-label.js +14 -0
  81. package/dist/admin/ui/theme.js +93 -13
  82. package/dist/admin/validate.d.ts +27 -2
  83. package/dist/admin/validate.js +47 -10
  84. package/dist/collections.d.ts +3 -9
  85. package/dist/collections.js +1 -2
  86. package/dist/conditions.d.ts +33 -0
  87. package/dist/conditions.js +79 -0
  88. package/dist/config.d.ts +26 -19
  89. package/dist/config.js +26 -25
  90. package/dist/css.d.ts +7 -0
  91. package/dist/css.js +57 -0
  92. package/dist/custom/CustomSection.d.ts +26 -0
  93. package/dist/custom/CustomSection.js +20 -0
  94. package/dist/custom/css.d.ts +3 -0
  95. package/dist/custom/css.js +28 -0
  96. package/dist/custom/index.d.ts +26 -0
  97. package/dist/custom/index.js +16 -0
  98. package/dist/custom/rows.d.ts +28 -0
  99. package/dist/custom/rows.js +129 -0
  100. package/dist/custom/schema.d.ts +32 -0
  101. package/dist/custom/schema.js +39 -0
  102. package/dist/entry-store.d.ts +42 -17
  103. package/dist/entry-store.js +107 -40
  104. package/dist/fields.d.ts +5 -2
  105. package/dist/fields.js +4 -2
  106. package/dist/{shared-id.d.ts → global-id.d.ts} +1 -1
  107. package/dist/{shared-id.js → global-id.js} +4 -4
  108. package/dist/global.d.ts +57 -0
  109. package/dist/global.js +51 -0
  110. package/dist/index.d.ts +28 -18
  111. package/dist/index.js +10 -5
  112. package/dist/media.js +1 -1
  113. package/dist/next/index.d.ts +1 -1
  114. package/dist/next/page-renderer.d.ts +4 -2
  115. package/dist/next/page-renderer.js +17 -13
  116. package/dist/next/queries.d.ts +6 -6
  117. package/dist/next/queries.js +7 -7
  118. package/dist/page.js +2 -2
  119. package/dist/pairing.d.ts +13 -7
  120. package/dist/pairing.js +4 -4
  121. package/dist/refs.d.ts +2 -2
  122. package/dist/refs.js +4 -4
  123. package/dist/render.d.ts +13 -1
  124. package/dist/render.js +10 -5
  125. package/dist/resolve.d.ts +11 -6
  126. package/dist/resolve.js +22 -12
  127. package/dist/revalidate.d.ts +4 -4
  128. package/dist/revalidate.js +4 -4
  129. package/dist/richtext-render.js +2 -2
  130. package/dist/richtext-walk.d.ts +5 -5
  131. package/dist/richtext-walk.js +4 -4
  132. package/dist/schema.d.ts +15 -10
  133. package/dist/schema.js +19 -7
  134. package/dist/section-wrapper.d.ts +18 -6
  135. package/dist/section-wrapper.js +24 -17
  136. package/dist/site.d.ts +17 -5
  137. package/dist/site.js +36 -12
  138. package/dist/sql-space.js +1 -1
  139. package/dist/sql.js +19 -16
  140. package/dist/store.d.ts +45 -13
  141. package/dist/store.js +88 -12
  142. package/dist/styles.d.ts +27 -0
  143. package/dist/styles.js +60 -0
  144. package/dist/supabase-entry-store.d.ts +11 -6
  145. package/dist/supabase-entry-store.js +115 -39
  146. package/dist/supabase-store.d.ts +13 -6
  147. package/dist/supabase-store.js +90 -20
  148. package/dist/zones.d.ts +9 -7
  149. package/dist/zones.js +5 -5
  150. package/package.json +1 -1
  151. package/dist/admin/shared-items.d.ts +0 -9
  152. package/dist/admin/shell/SharedForm.js +0 -134
  153. package/dist/shared.d.ts +0 -46
  154. package/dist/shared.js +0 -36
@@ -2,16 +2,19 @@
2
2
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
3
  // The element node view (spec 2026-09-08 block nodes §3): the node view IS
4
4
  // the form — a header (icon, title, collapse, delete) and the element's
5
- // fields then styles through FieldWidget, reading and writing attrs.
5
+ // fields then settings through FieldWidget, reading and writing attrs.
6
6
  // A richtext field inside mounts a nested RichtextWidget with its own
7
- // toolset. Errors come from the enclosing form under
7
+ // toolset. Settings sit under a divider; a field hidden by a condition
8
+ // leaves the card — the element's own values are the scope (spec
9
+ // 2026-09-14 §12). Errors come from the enclosing form under
8
10
  // `<path>.elements.<i>.<key>`, i being this node's index among the
9
11
  // document's element nodes. Collapse is component state only: a card
10
12
  // starts collapsed (2026-09-09) unless it holds nothing yet — a freshly
11
13
  // inserted element opens, so its fields can be filled straight away.
12
14
  import { useState } from "react";
13
15
  import { NodeViewWrapper } from "@tiptap/react";
14
- import { IconButton } from "../ui/primitives.js";
16
+ import { isHidden } from "../../conditions.js";
17
+ import { IconButton, SectionLabel } from "../ui/primitives.js";
15
18
  import { FieldWidget } from "./FieldWidget.js";
16
19
  import { useElements } from "./elements-context.js";
17
20
  import { cardClass, useRichtextForm } from "./richtext-form-context.js";
@@ -26,12 +29,15 @@ export function RichtextElementCard({ node, editor, getPos, updateAttributes, de
26
29
  const name = String(node.attrs.name ?? "");
27
30
  const element = elements.find((e) => e.name === name);
28
31
  const fields = (node.attrs.fields && typeof node.attrs.fields === "object" ? node.attrs.fields : {});
29
- const styles = (node.attrs.styles && typeof node.attrs.styles === "object" ? node.attrs.styles : {});
32
+ const settings = (node.attrs.settings && typeof node.attrs.settings === "object" ? node.attrs.settings : {});
30
33
  const [open, setOpen] = useState(() => isBlank(fields));
31
34
  const pos = getPos();
32
35
  const index = typeof pos === "number" ? elementIndexAt(editor.state.doc, pos) : 0;
33
36
  const base = `${form.path}.elements.${index}`;
34
37
  const select = () => { if (typeof pos === "number")
35
38
  editor.commands.setNodeSelection(pos); };
36
- return (_jsxs(NodeViewWrapper, { className: cardClass(selected, form.canvas), "data-theme": form.canvas ? "light" : undefined, children: [_jsxs("div", { className: "sm-rtcard__head", "data-drag-handle": true, onClick: select, children: [_jsx("span", { className: "sm-rtcard__icon", "aria-hidden": "true", children: element?.icon ?? "▤" }), _jsx("span", { className: "sm-rtcard__title", children: element?.title ?? `Unknown element "${name}"` }), _jsx(IconButton, { label: open ? "Collapse" : "Expand", onClick: (e) => { e.stopPropagation(); setOpen((o) => !o); }, children: open ? "▾" : "▸" }), _jsx(IconButton, { label: "Delete element", onClick: (e) => { e.stopPropagation(); deleteNode(); }, children: "\u00D7" })] }), open && element && (_jsxs("div", { className: "sm-rtcard__body", contentEditable: false, children: [Object.entries(element.fields).map(([key, d]) => (_jsx(FieldWidget, { fieldKey: key, descriptor: d, value: fields[key], errors: form.errors, path: `${base}.${key}`, onChange: (v) => updateAttributes({ fields: { ...fields, [key]: v } }) }, key))), Object.entries(element.styles).map(([key, d]) => (_jsx(FieldWidget, { fieldKey: key, descriptor: d, value: styles[key], path: `${base}.styles.${key}`, onChange: (v) => updateAttributes({ styles: { ...styles, [key]: v } }) }, `style-${key}`)))] }))] }));
39
+ const scope = { fields, settings };
40
+ const visibleFields = Object.entries(element?.fields ?? {}).filter(([, d]) => !isHidden(d, { self: fields, ...scope }));
41
+ const visibleSettings = Object.entries(element?.settings ?? {}).filter(([, d]) => !isHidden(d, { self: settings, ...scope }));
42
+ return (_jsxs(NodeViewWrapper, { className: cardClass(selected, form.canvas), "data-theme": form.canvas ? "light" : undefined, children: [_jsxs("div", { className: "sm-rtcard__head", "data-drag-handle": true, onClick: select, children: [_jsx("span", { className: "sm-rtcard__icon", "aria-hidden": "true", children: element?.icon ?? "▤" }), _jsx("span", { className: "sm-rtcard__title", children: element?.title ?? `Unknown element "${name}"` }), _jsx(IconButton, { label: open ? "Collapse" : "Expand", onClick: (e) => { e.stopPropagation(); setOpen((o) => !o); }, children: open ? "▾" : "▸" }), _jsx(IconButton, { label: "Delete element", onClick: (e) => { e.stopPropagation(); deleteNode(); }, children: "\u00D7" })] }), open && element && (_jsxs("div", { className: "sm-rtcard__body", contentEditable: false, children: [visibleFields.map(([key, d]) => (_jsx(FieldWidget, { fieldKey: key, descriptor: d, value: fields[key], errors: form.errors, path: `${base}.${key}`, scope: scope, onChange: (v) => updateAttributes({ fields: { ...fields, [key]: v } }) }, key))), visibleSettings.length > 0 && _jsx(SectionLabel, { mono: true, children: "Settings" }), visibleSettings.map(([key, d]) => (_jsx(FieldWidget, { fieldKey: key, descriptor: d, value: settings[key], errors: form.errors, path: `${base}.settings.${key}`, scope: scope, onChange: (v) => updateAttributes({ settings: { ...settings, [key]: v } }) }, `setting-${key}`)))] }))] }));
37
43
  }
@@ -0,0 +1,13 @@
1
+ import type { ReactElement } from "react";
2
+ import type { AdminField } from "../serialize.ts";
3
+ import { type CustomRow } from "../../custom/index.ts";
4
+ export declare function RowsWidget({ descriptor, value, onChange, errors, path, inlinePath, onEditHere }: {
5
+ descriptor: AdminField;
6
+ value: unknown;
7
+ onChange(rows: CustomRow[]): void;
8
+ errors?: Record<string, string>;
9
+ path: string;
10
+ /** The path of the column being edited in the canvas: its widget yields to a note. */
11
+ inlinePath?: string;
12
+ onEditHere?(): void;
13
+ }): ReactElement;
@@ -0,0 +1,26 @@
1
+ "use client";
2
+ import { jsxs as _jsxs, jsx as _jsx, Fragment as _Fragment } from "react/jsx-runtime";
3
+ import { Button, Field, IconButton, Switch } from "../ui/primitives.js";
4
+ import { FieldWidget, InlineNote } from "./FieldWidget.js";
5
+ import { RichtextWidget } from "./RichtextWidget.js";
6
+ import { SpanBar } from "./SpanBar.js";
7
+ import { moveItem, removeItem } from "./list.js";
8
+ import { MAX_COLUMNS, defaultRow, presetsFor, setColumnCount } from "../../custom/index.js";
9
+ const isRow = (r) => typeof r === "object" && r !== null && Array.isArray(r.columns) && Array.isArray(r.spans);
10
+ export function RowsWidget({ descriptor, value, onChange, errors, path, inlinePath, onEditHere }) {
11
+ const rows = Array.isArray(value) ? value.filter(isRow) : [];
12
+ const item = descriptor.item?.fields ?? {};
13
+ const columnDescriptor = (item.columns?.item ?? { type: "richtext" });
14
+ const patch = (i, row) => onChange(rows.map((r, j) => (j === i ? row : r)));
15
+ return (_jsxs("div", { className: "sm-rowcards", children: [rows.map((row, i) => {
16
+ const n = row.columns.length;
17
+ const base = `${path}.${i}`;
18
+ const presets = presetsFor(n);
19
+ return (_jsxs("div", { className: "sm-rowcard", "data-sm-path": base, children: [_jsxs("div", { className: "sm-rowcard__head", children: [_jsxs("span", { className: "sm-rowcard__title", children: ["Row ", i + 1] }), _jsx(IconButton, { label: "Move up", disabled: i === 0, onClick: () => onChange(moveItem(rows, i, i - 1)), children: "\u2191" }), _jsx(IconButton, { label: "Move down", disabled: i === rows.length - 1, onClick: () => onChange(moveItem(rows, i, i + 1)), children: "\u2193" }), _jsx(IconButton, { label: "Remove row", disabled: rows.length === 1, onClick: () => onChange(removeItem(rows, i)), children: "\u00D7" })] }), _jsx(Field, { label: "Columns", asDiv: true, error: errors?.[`${base}.columns`] ?? errors?.[`${base}.spans`], children: _jsxs("div", { className: "sm-stepper", children: [_jsx(IconButton, { label: "Fewer columns", disabled: n <= 1, onClick: () => patch(i, setColumnCount(row, n - 1)), children: "\u2212" }), _jsx("span", { className: "sm-stepper__value", children: n }), _jsx(IconButton, { label: "More columns", disabled: n >= MAX_COLUMNS, onClick: () => patch(i, setColumnCount(row, n + 1)), children: "+" })] }) }), n > 1 && (_jsxs(_Fragment, { children: [_jsxs(Field, { label: "Widths", asDiv: true, path: `${base}.spans`, children: [_jsx(SpanBar, { spans: row.spans, onChange: (spans) => patch(i, { ...row, spans }) }), presets.length > 0 && (_jsx("div", { className: "sm-rowcard__presets", children: presets.map((p) => (_jsx(Button, { variant: "secondary", onClick: () => patch(i, { ...row, spans: p.spans }), children: p.label }, p.label))) }))] }), _jsx(FieldWidget, { fieldKey: "gap", descriptor: item.gap, value: row.gap, path: `${base}.gap`, onChange: (v) => patch(i, { ...row, gap: v }) }), _jsx(FieldWidget, { fieldKey: "verticalAlign", descriptor: item.align, value: row.align, path: `${base}.align`, onChange: (v) => patch(i, { ...row, align: v }) }), _jsx(Field, { label: "Reverse on mobile", asDiv: true, path: `${base}.reverse`, children: _jsx(Switch, { checked: row.reverse === true, onChange: (v) => patch(i, { ...row, reverse: v }), label: "Reverse on mobile" }) })] })), row.columns.map((doc, j) => {
20
+ const colPath = `${base}.columns.${j}`;
21
+ return (_jsx(Field, { label: `Column ${j + 1}`, asDiv: true, path: colPath, error: errors?.[colPath], children: inlinePath === colPath
22
+ ? _jsx(InlineNote, { onEditHere: onEditHere })
23
+ : _jsx(RichtextWidget, { value: doc, toolset: columnDescriptor.toolset, errors: errors, path: colPath, onChange: (next) => patch(i, { ...row, columns: row.columns.map((c, k) => (k === j ? next : c)) }) }) }, j));
24
+ })] }, i));
25
+ }), _jsx(Button, { variant: "secondary", onClick: () => onChange([...rows, defaultRow()]), children: "+ Add row" })] }));
26
+ }
@@ -0,0 +1,5 @@
1
+ import { type ReactElement } from "react";
2
+ export declare function SpanBar({ spans, onChange }: {
3
+ spans: number[];
4
+ onChange(spans: number[]): void;
5
+ }): ReactElement;
@@ -0,0 +1,37 @@
1
+ "use client";
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ // The width bar (spec 2026-09-14 §6): one box per column sized by its
4
+ // span, a handle on each boundary dragged in twelfths (pointer events on
5
+ // the bar's own width — no library) or nudged with the arrow keys.
6
+ import { useRef } from "react";
7
+ import { setSpan, spanAtX } from "../../custom/index.js";
8
+ export function SpanBar({ spans, onChange }) {
9
+ const bar = useRef(null);
10
+ const drag = (handle) => (e) => {
11
+ const el = bar.current;
12
+ if (!el)
13
+ return;
14
+ e.preventDefault();
15
+ e.currentTarget.setPointerCapture(e.pointerId);
16
+ const rect = el.getBoundingClientRect();
17
+ let current = spans;
18
+ const move = (ev) => {
19
+ const next = setSpan(current, handle, spanAtX(ev.clientX - rect.left, rect.width, current, handle));
20
+ if (next[handle] !== current[handle]) {
21
+ current = next;
22
+ onChange(next);
23
+ }
24
+ };
25
+ const up = () => { window.removeEventListener("pointermove", move); window.removeEventListener("pointerup", up); };
26
+ window.addEventListener("pointermove", move);
27
+ window.addEventListener("pointerup", up);
28
+ };
29
+ const key = (handle) => (e) => {
30
+ const delta = e.key === "ArrowLeft" ? -1 : e.key === "ArrowRight" ? 1 : 0;
31
+ if (delta === 0)
32
+ return;
33
+ e.preventDefault();
34
+ onChange(setSpan(spans, handle, spans[handle] + delta));
35
+ };
36
+ return (_jsx("div", { className: "sm-spanbar", ref: bar, children: spans.map((s, i) => (_jsxs("div", { className: "sm-spanbar__col", style: { flex: s }, children: [s, i < spans.length - 1 && (_jsx("button", { type: "button", className: "sm-spanbar__handle", role: "slider", "aria-label": `Resize columns ${i + 1} and ${i + 2}`, "aria-valuemin": 1, "aria-valuemax": s + spans[i + 1] - 1, "aria-valuenow": s, onPointerDown: drag(i), onKeyDown: key(i) }))] }, `c${i}`))) }));
37
+ }
@@ -18,7 +18,7 @@ export type ElementNodeJSON = {
18
18
  attrs: {
19
19
  name: string;
20
20
  fields: Record<string, unknown>;
21
- styles: Record<string, unknown>;
21
+ settings: Record<string, unknown>;
22
22
  };
23
23
  };
24
24
  export type ImageNodeJSON = {
@@ -31,7 +31,7 @@ export type ImageNodeJSON = {
31
31
  caption: string;
32
32
  };
33
33
  };
34
- /** A fresh element: sample first, then the empties, styles from their defaults — as a section insertion does. */
34
+ /** A fresh element: sample first, then the empties, settings from their defaults — as a section insertion does. */
35
35
  export declare function elementNode(element: AdminElement): ElementNodeJSON;
36
36
  export declare function imageNode(assetId: string): ImageNodeJSON;
37
37
  /** How many element nodes precede `pos` — the validator's `elements.<i>` index. */
@@ -15,10 +15,10 @@ export function filterItems(items, query) {
15
15
  const q = query.trim().toLowerCase();
16
16
  return q === "" ? items : items.filter((i) => i.title.toLowerCase().includes(q));
17
17
  }
18
- /** A fresh element: sample first, then the empties, styles from their defaults — as a section insertion does. */
18
+ /** A fresh element: sample first, then the empties, settings from their defaults — as a section insertion does. */
19
19
  export function elementNode(element) {
20
- const { fields, styles } = nodeFromSample(element, "");
21
- return { type: "element", attrs: { name: element.name, fields, styles: styles ?? {} } };
20
+ const { fields, settings } = nodeFromSample(element, "");
21
+ return { type: "element", attrs: { name: element.name, fields, settings: settings ?? {} } };
22
22
  }
23
23
  export function imageNode(assetId) {
24
24
  return { type: "image", attrs: { image: { asset: assetId }, caption: "" } };
@@ -36,7 +36,7 @@ export const ElementNode = Node.create({
36
36
  atom: true,
37
37
  draggable: true,
38
38
  addAttributes() {
39
- return { name: { default: "" }, fields: jsonAttr("fields", {}), styles: jsonAttr("styles", {}) };
39
+ return { name: { default: "" }, fields: jsonAttr("fields", {}), settings: jsonAttr("settings", {}) };
40
40
  },
41
41
  parseHTML() { return [{ tag: "div[data-smoodly-element]" }]; },
42
42
  renderHTML({ HTMLAttributes }) { return ["div", mergeAttributes({ "data-smoodly-element": "" }, HTMLAttributes)]; },
@@ -1,14 +1,26 @@
1
- import type { AdminField, AdminSection } from "../serialize.ts";
2
- export type TabName = "Content" | "Styles" | "Media" | "Meta";
1
+ import type { AdminField } from "../serialize.ts";
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
- export declare function tabsFor(section: AdminSection): {
9
- tab: TabName;
9
+ export type Group = {
10
+ title?: GroupTitle;
10
11
  entries: TabEntry[];
12
+ };
13
+ /** A section, an element (no styles, no css) or a global item. */
14
+ export type FieldsHolder = {
15
+ fields: Record<string, AdminField>;
16
+ settings?: Record<string, AdminField>;
17
+ styles?: Record<string, AdminField>;
18
+ css?: boolean;
19
+ };
20
+ export declare const CSS_DESCRIPTOR: AdminField;
21
+ export declare function tabsFor(holder: FieldsHolder): {
22
+ tab: TabName;
23
+ groups: Group[];
11
24
  }[];
12
- /** The tab holding a content field, by its dotted path's top-level key — a canvas
13
- * click on a marked image reveals it on Media, where the projection put it. */
14
- export declare function tabOf(section: AdminSection, 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,10 +52,24 @@ 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
  }
59
+ // Every global item is versioned (decided 2026-09-13). A row published before that has
60
+ // no pointers, so its next save would go live: publish it once more here, which snapshots
61
+ // what is live and points both pointers at it. Idempotent — pointed rows are skipped.
62
+ for (const locale of supported) {
63
+ const row = record.locales[locale];
64
+ if (!row || row.status !== "published" || row.publishedVersionId !== null)
65
+ continue;
66
+ try {
67
+ record = await entries.setStatus(item.name, record.id, locale, "published");
68
+ }
69
+ catch (e) {
70
+ console.warn(`smoodly: could not version global item "${item.name}" in "${locale}": ${e instanceof Error ? e.message : String(e)}`);
71
+ }
72
+ }
58
73
  out[item.name] = record;
59
74
  }
60
75
  return out;
@@ -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";
@@ -7,7 +7,7 @@ import { draftMode } from "next/headers";
7
7
  import { gateAdminOp } from "../auth.js";
8
8
  import { createAdminOps, runAdminOp } from "../ops-impl.js";
9
9
  export function createAdminOpHandler(deps) {
10
- const ops = createAdminOps({
10
+ const base = {
11
11
  config: deps.config,
12
12
  pages: deps.pages,
13
13
  entries: deps.entries,
@@ -24,6 +24,7 @@ export function createAdminOpHandler(deps) {
24
24
  disable: async () => (await draftMode()).disable(),
25
25
  },
26
26
  },
27
- });
28
- return (call) => gateAdminOp(deps.auth, call, (c) => runAdminOp(ops, c));
27
+ };
28
+ // Ops are built per call so the verified session rides into every store write as `by` (spec 2026-09-13 history §2).
29
+ return (call) => gateAdminOp(deps.auth, call, (c, session) => runAdminOp(createAdminOps({ ...base, actor: session }), c));
29
30
  }
@@ -3,6 +3,7 @@ import type { EntryStore } from "../entry-store.ts";
3
3
  import type { AssetStore } from "../asset-store.ts";
4
4
  import type { PageStore } from "../store.ts";
5
5
  import type { AdminOpCall, AdminOps, OpResult } from "./ops.ts";
6
+ import type { AdminSession } from "./auth.ts";
6
7
  export type AdminEffects = {
7
8
  /** Expire one cache tag (`pageTag(id)` / `entryTag(id)`). */
8
9
  revalidate?(tag: string): void;
@@ -20,6 +21,8 @@ export declare function createAdminOps(deps: {
20
21
  href?: (path: string, locale: string) => string;
21
22
  /** The media library; absent = every assets op answers "no media store". */
22
23
  assets?: AssetStore;
24
+ /** The verified editor; their email is every write's `by`. Absent for the seed and tests (spec 2026-09-13 history §2). */
25
+ actor?: AdminSession;
23
26
  }): AdminOps;
24
27
  /** Path-dispatch for the one-server-action seam. Own-property checks only —
25
28
  * "constructor.constructor" and prototype paths must not resolve. */