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
@@ -1,4 +1,4 @@
1
- import { SHARED_NODE_TYPE } from "../shared.js";
1
+ import { GLOBAL_NODE_TYPE } from "../global.js";
2
2
  import { setAtPath } from "./field-paths.js";
3
3
  export function zoneOf(tree, id) {
4
4
  for (const [zone, nodes] of Object.entries(tree.zones)) {
@@ -64,6 +64,18 @@ export function patchNode(tree, id, ns, key, value) {
64
64
  return tree;
65
65
  return withZone(tree, zone, tree.zones[zone].map((n) => (n.id === id ? { ...n, [ns]: { ...(n[ns] ?? {}), [key]: value } } : n)));
66
66
  }
67
+ /** The node's custom CSS (spec 2026-09-14 §4); undefined or "" removes the key. */
68
+ export function patchNodeCss(tree, id, css) {
69
+ const zone = zoneOf(tree, id);
70
+ if (!zone)
71
+ return tree;
72
+ return withZone(tree, zone, tree.zones[zone].map((n) => {
73
+ if (n.id !== id)
74
+ return n;
75
+ const { css: _old, ...rest } = n;
76
+ return css === undefined || css === "" ? rest : { ...rest, css };
77
+ }));
78
+ }
67
79
  /** patchNode for a dotted path inside `fields` (spec 2026-09-10 §1):
68
80
  * `points.2.heading` sets one item's heading and copies only the spine. */
69
81
  export function patchNodePath(tree, id, path, value) {
@@ -89,11 +101,11 @@ export function allowedSections(policy, all) {
89
101
  }
90
102
  /** The visual items the picker offers in a zone: materialized (an id is
91
103
  * known) and rendering a section the zone's policy allows. */
92
- export function allowedShared(policy, shared, sharedIds) {
104
+ export function allowedGlobals(policy, globals, globalIds) {
93
105
  if (!policy || policy.kind === "locked")
94
106
  return [];
95
- return shared.filter((s) => {
96
- if (s.section === undefined || sharedIds[s.name] === undefined)
107
+ return globals.filter((s) => {
108
+ if (s.section === undefined || globalIds[s.name] === undefined)
97
109
  return false;
98
110
  return policy.allow === "*" || policy.allow.includes(s.section);
99
111
  });
@@ -101,7 +113,7 @@ export function allowedShared(policy, shared, sharedIds) {
101
113
  /** A page places a visual item as this node (spec 2026-09-07 §6): `item`
102
114
  * names the registration, `ref` is the item's row id. */
103
115
  export function placementNode(item, ref, id) {
104
- return { id, type: SHARED_NODE_TYPE, fields: { item: item.name, ref } };
116
+ return { id, type: GLOBAL_NODE_TYPE, fields: { item: item.name, ref } };
105
117
  }
106
118
  /** The empty value a fresh node's field starts at, by descriptor type.
107
119
  * Shape matters: a list-valued field seeded with "" is a type lie that
@@ -111,10 +123,10 @@ const EMPTY_BY_TYPE = { refList: [], object: {} };
111
123
  * broken-image icon or a stray zero, worse than nothing at all. With no
112
124
  * sample, leave the key out of `fields` entirely rather than seed a lie. */
113
125
  const ABSENT_WHEN_UNSAMPLED = new Set(["image", "video", "file", "number"]);
114
- /** Also fills an element node (an `AdminElement` is a section shape plus contexts). */
126
+ /** Also fills an element node (an `AdminElement` is a section shape plus contexts). Never seeds `styles` or `css`: Default stores nothing (spec 2026-09-14 §2). */
115
127
  export function nodeFromSample(section, id) {
116
128
  const fields = {};
117
- const styles = {};
129
+ const settings = {};
118
130
  for (const [key, d] of Object.entries(section.fields)) {
119
131
  const sampled = section.sample?.[key] ?? d.default ?? (d.type === "boolean" ? false : undefined);
120
132
  if (sampled !== undefined)
@@ -124,20 +136,20 @@ export function nodeFromSample(section, id) {
124
136
  else
125
137
  fields[key] = EMPTY_BY_TYPE[d.type] ?? "";
126
138
  }
127
- for (const [key, d] of Object.entries(section.styles)) {
139
+ for (const [key, d] of Object.entries(section.settings)) {
128
140
  const v = section.sample?.[key] ?? d.default;
129
141
  if (v !== undefined)
130
- styles[key] = v;
142
+ settings[key] = v;
131
143
  }
132
- return { id, type: section.name, fields, styles };
144
+ return { id, type: section.name, fields, settings };
133
145
  }
134
146
  /** A locked zone holds exactly one node the editor can never insert or
135
147
  * remove — so someone has to put it there. This does, from the component's
136
- * sample (or, for a zone bound to a shared item, a placement), for every
148
+ * sample (or, for a zone bound to a global item, a placement), for every
137
149
  * locked zone that is empty or missing. Runs when the editor loads a draft,
138
150
  * which also covers pages that predate a locked zone being added to their
139
151
  * schema. Returns the same tree when nothing changed. */
140
- export function fillLockedZones(tree, policies, sections, sharedIds = {}) {
152
+ export function fillLockedZones(tree, policies, sections, globalIds = {}) {
141
153
  let next = null;
142
154
  for (const [zone, policy] of Object.entries(policies)) {
143
155
  if (policy.kind !== "locked")
@@ -145,14 +157,14 @@ export function fillLockedZones(tree, policies, sections, sharedIds = {}) {
145
157
  if ((tree.zones[zone] ?? []).length > 0)
146
158
  continue;
147
159
  let node = null;
148
- if (policy.shared !== undefined) {
149
- // Bound to a shared item (spec 2026-09-07 §4): a placement, once
160
+ if (policy.global !== undefined) {
161
+ // Bound to a global item (spec 2026-09-07 §4): a placement, once
150
162
  // the editor has loaded the item's row id; until then leave the
151
163
  // zone empty rather than seed a plain section there.
152
- const ref = sharedIds[policy.shared];
164
+ const ref = globalIds[policy.global];
153
165
  if (ref === undefined)
154
166
  continue;
155
- node = placementNode({ name: policy.shared }, ref, newNodeId(SHARED_NODE_TYPE));
167
+ node = placementNode({ name: policy.global }, ref, newNodeId(GLOBAL_NODE_TYPE));
156
168
  }
157
169
  else {
158
170
  const section = sections.find((s) => s.name === policy.component);
@@ -0,0 +1,11 @@
1
+ export type ActionMenuItem = {
2
+ label: string;
3
+ run(): void;
4
+ disabled?: boolean;
5
+ danger?: boolean;
6
+ };
7
+ export declare function ActionMenu({ label, items, disabled }: {
8
+ label: string;
9
+ items: ActionMenuItem[];
10
+ disabled?: boolean;
11
+ }): import("react").JSX.Element;
@@ -0,0 +1,25 @@
1
+ "use client";
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ // A round three-dot button and its popover of actions (spec 2026-09-13
4
+ // publish flow §2). Items are always rendered and disabled when they do
5
+ // not apply — the layout never shifts and the editor learns what exists.
6
+ // The popover is the Pages list's row menu shape (`.sm-picker`), anchored
7
+ // under the button instead of at the pointer.
8
+ import { useEffect, useRef, useState } from "react";
9
+ import { cx } from "./primitives.js";
10
+ export function ActionMenu({ label, items, disabled }) {
11
+ const [open, setOpen] = useState(false);
12
+ const ref = useRef(null);
13
+ useEffect(() => {
14
+ if (!open)
15
+ return;
16
+ const onDown = (e) => { if (!ref.current?.contains(e.target))
17
+ setOpen(false); };
18
+ const onKey = (e) => { if (e.key === "Escape")
19
+ setOpen(false); };
20
+ document.addEventListener("pointerdown", onDown);
21
+ document.addEventListener("keydown", onKey);
22
+ return () => { document.removeEventListener("pointerdown", onDown); document.removeEventListener("keydown", onKey); };
23
+ }, [open]);
24
+ return (_jsxs("div", { ref: ref, style: { position: "relative", flex: "none" }, children: [_jsx("button", { type: "button", className: "sm-dotmenu", "aria-label": label, title: label, "aria-haspopup": "menu", "aria-expanded": open, disabled: disabled, onClick: () => setOpen((o) => !o), children: "\u00B7\u00B7\u00B7" }), open && (_jsx("div", { role: "menu", className: "sm-picker sm-picker--menu", children: items.map((item) => (_jsx("button", { type: "button", role: "menuitem", className: cx("sm-picker__item", item.danger && "sm-picker__item--danger"), disabled: item.disabled, onClick: () => { setOpen(false); item.run(); }, children: item.label }, item.label))) }))] }));
25
+ }
@@ -0,0 +1,23 @@
1
+ import { type ReactElement, type ReactNode } from "react";
2
+ import type { PageLocale } from "../../store.ts";
3
+ import type { VersionSummary } from "../ops.ts";
4
+ export type HistoryTag = "Draft" | "Live" | null;
5
+ export type HistoryRow = VersionSummary & {
6
+ tag: HistoryTag;
7
+ };
8
+ export declare const RESTORE_CONFIRM = "Restore this version? The current draft will be replaced. Nothing goes live until you publish.";
9
+ /** The rows for a locale's versions: the unpublished draft is Draft, the
10
+ * row the site serves Live, an older publish carries nothing. */
11
+ export declare function historyRows(versions: VersionSummary[], row: Pick<PageLocale, "status" | "draftVersionId" | "publishedVersionId">): HistoryRow[];
12
+ export declare function HistoryModal({ rows, draftId, render, onSelect, onRestore, onClose }: {
13
+ rows: HistoryRow[];
14
+ /** The current draft's version id: Restore is disabled on it. */
15
+ draftId: string | null;
16
+ /** The main area for the selected version. */
17
+ render(versionId: string): ReactNode;
18
+ /** The selection changed — fired once for the initial row too; the entry screens load the snapshot here. */
19
+ onSelect?(versionId: string): void;
20
+ /** Resolves true when the restore landed; the modal then closes itself. */
21
+ onRestore(versionId: string): Promise<boolean>;
22
+ onClose(): void;
23
+ }): ReactElement;
@@ -0,0 +1,40 @@
1
+ "use client";
2
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
3
+ // The History modal (spec 2026-09-13 history §5): the locale's versions on
4
+ // the left — newest first, the unpublished draft tagged Draft and the row
5
+ // the site serves Live — and the selected version on the right, rendered
6
+ // by the screen that opened it (an iframe of the site's draft route for a
7
+ // page, a disabled form for an entry or a global item). Restore confirms,
8
+ // hands the id to the screen, and closes once the restore landed.
9
+ import { useEffect, useState } from "react";
10
+ import { versionTime } from "./format.js";
11
+ import { Modal } from "./Modal.js";
12
+ import { Button, cx } from "./primitives.js";
13
+ export const RESTORE_CONFIRM = "Restore this version? The current draft will be replaced. Nothing goes live until you publish.";
14
+ /** The rows for a locale's versions: the unpublished draft is Draft, the
15
+ * row the site serves Live, an older publish carries nothing. */
16
+ export function historyRows(versions, row) {
17
+ const live = row.status === "published";
18
+ return versions.map((v) => ({
19
+ ...v,
20
+ tag: v.id === row.draftVersionId && (v.id !== row.publishedVersionId || !live) ? "Draft"
21
+ : v.id === row.publishedVersionId && live ? "Live"
22
+ : null,
23
+ }));
24
+ }
25
+ export function HistoryModal({ rows, draftId, render, onSelect, onRestore, onClose }) {
26
+ const [selected, setSelected] = useState(rows[0]?.id ?? null);
27
+ const [busy, setBusy] = useState(false);
28
+ useEffect(() => { if (selected)
29
+ onSelect?.(selected); }, [selected]); // eslint-disable-line react-hooks/exhaustive-deps
30
+ const restore = async () => {
31
+ if (!selected || !window.confirm(RESTORE_CONFIRM))
32
+ return;
33
+ setBusy(true);
34
+ const landed = await onRestore(selected);
35
+ setBusy(false);
36
+ if (landed)
37
+ onClose();
38
+ };
39
+ return (_jsx(Modal, { title: "History", width: 1200, height: "85vh", flush: true, onClose: onClose, footer: _jsxs(_Fragment, { children: [_jsx(Button, { variant: "secondary", onClick: onClose, children: "Close" }), _jsx(Button, { disabled: busy || !selected || selected === draftId, onClick: () => { void restore(); }, children: busy ? "Restoring…" : "Restore" })] }), children: _jsxs("div", { className: "sm-history", children: [_jsxs("div", { className: "sm-history__list", role: "listbox", "aria-label": "Versions", children: [rows.length === 0 && _jsx("span", { className: "sm-hint", style: { padding: 12 }, children: "No versions yet." }), rows.map((r) => (_jsxs("button", { type: "button", role: "option", "aria-selected": r.id === selected, className: cx("sm-history__row", r.id === selected && "sm-history__row--active"), onClick: () => setSelected(r.id), children: [_jsx("span", { children: versionTime(r.savedAt) }), r.tag && _jsx("span", { className: cx("sm-history__tag", r.tag === "Live" && "sm-history__tag--live"), children: r.tag })] }, r.id)))] }), _jsx("div", { className: "sm-history__main", children: selected ? render(selected) : null })] }) }));
40
+ }
@@ -1,7 +1,11 @@
1
1
  import { type ReactElement, type ReactNode } from "react";
2
- export declare function Modal({ title, width, onClose, children, footer }: {
2
+ export declare function Modal({ title, width, height, flush, onClose, children, footer }: {
3
3
  title: string;
4
4
  width?: number;
5
+ /** A fixed panel height (e.g. `"85vh"`) for a modal whose body must fill it; default: the content's. */
6
+ height?: number | string;
7
+ /** Drops the body padding for a modal whose body is a two-pane layout (History). */
8
+ flush?: boolean;
5
9
  onClose(): void;
6
10
  children: ReactNode;
7
11
  footer?: ReactNode;
@@ -8,13 +8,13 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
8
8
  // overflow the same way. Static rendering (no document) keeps it inline.
9
9
  import { useEffect, useState } from "react";
10
10
  import { createPortal } from "react-dom";
11
- import { IconButton } from "./primitives.js";
11
+ import { cx, IconButton } from "./primitives.js";
12
12
  function adminRoot() {
13
13
  if (typeof document === "undefined")
14
14
  return null;
15
15
  return document.querySelector(".sm-admin") ?? document.body;
16
16
  }
17
- export function Modal({ title, width = 720, onClose, children, footer }) {
17
+ export function Modal({ title, width = 720, height, flush = false, onClose, children, footer }) {
18
18
  const [root] = useState(adminRoot);
19
19
  useEffect(() => {
20
20
  const onKey = (e) => { if (e.key === "Escape")
@@ -22,6 +22,6 @@ export function Modal({ title, width = 720, onClose, children, footer }) {
22
22
  window.addEventListener("keydown", onKey);
23
23
  return () => window.removeEventListener("keydown", onKey);
24
24
  }, [onClose]);
25
- const modal = (_jsx("div", { className: "sm-modal", onClick: onClose, children: _jsxs("div", { className: "sm-modal__panel", role: "dialog", "aria-label": title, style: { width }, onClick: (e) => e.stopPropagation(), children: [_jsxs("div", { className: "sm-modal__head", children: [_jsx("span", { className: "sm-title", children: title }), _jsx(IconButton, { label: "Close", onClick: onClose, children: "\u00D7" })] }), _jsx("div", { className: "sm-modal__body", children: children }), footer && _jsx("div", { className: "sm-modal__foot", children: footer })] }) }));
25
+ const modal = (_jsx("div", { className: "sm-modal", onClick: onClose, children: _jsxs("div", { className: "sm-modal__panel", role: "dialog", "aria-label": title, style: { width, ...(height !== undefined ? { height } : {}) }, onClick: (e) => e.stopPropagation(), children: [_jsxs("div", { className: "sm-modal__head", children: [_jsx("span", { className: "sm-title", children: title }), _jsx(IconButton, { label: "Close", onClick: onClose, children: "\u00D7" })] }), _jsx("div", { className: cx("sm-modal__body", flush && "sm-modal__body--flush"), children: children }), footer && _jsx("div", { className: "sm-modal__foot", children: footer })] }) }));
26
26
  return root ? createPortal(modal, root) : modal;
27
27
  }
@@ -0,0 +1,31 @@
1
+ import type { LocaleStatus } from "../shell/pages-tree.ts";
2
+ import { type ActionMenuItem } from "./ActionMenu.tsx";
3
+ export type PublishBusy = "publish" | "discard" | "unpublish" | null;
4
+ /** The three-dot menu's items: History first when the screen offers it (spec 2026-09-13 history §5;
5
+ * every collection since 2026-09-14), then the two publish-flow actions — always rendered,
6
+ * disabled when they do not apply. */
7
+ export declare function publishMenu(opts: {
8
+ can: {
9
+ discard: boolean;
10
+ unpublish: boolean;
11
+ };
12
+ onDiscard(): void;
13
+ onUnpublish(): void;
14
+ onHistory?(): void;
15
+ }): ActionMenuItem[];
16
+ export declare function PublishControl({ status, pending, busy, flash, disabled, onPublish, onDiscard, onUnpublish, onHistory, }: {
17
+ /** The locale's stored state; null while the document loads or before it exists. */
18
+ status: LocaleStatus | null;
19
+ /** The editor holds a change the store has not confirmed (dirty, or a save pending / in flight / failed). */
20
+ pending?: boolean;
21
+ busy?: PublishBusy;
22
+ /** The moment after a publish: the button reads "Published ✓". */
23
+ flash?: boolean;
24
+ /** No document to act on (loading, a new unsaved entry). */
25
+ disabled?: boolean;
26
+ onPublish(): void;
27
+ onDiscard(): void;
28
+ onUnpublish(): void;
29
+ /** Opens History (spec 2026-09-13 history §5). Absent = no History item (the canvas card). */
30
+ onHistory?(): void;
31
+ }): import("react").JSX.Element;
@@ -0,0 +1,24 @@
1
+ "use client";
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { ActionMenu } from "./ActionMenu.js";
4
+ import { Button, StatusBadge } from "./primitives.js";
5
+ import { publishActions, statusLabel } from "./status-label.js";
6
+ const BUSY_WORD = {
7
+ publish: "Publishing…", discard: "Discarding…", unpublish: "Unpublishing…",
8
+ };
9
+ /** The three-dot menu's items: History first when the screen offers it (spec 2026-09-13 history §5;
10
+ * every collection since 2026-09-14), then the two publish-flow actions — always rendered,
11
+ * disabled when they do not apply. */
12
+ export function publishMenu(opts) {
13
+ return [
14
+ ...(opts.onHistory ? [{ label: "History", run: opts.onHistory }] : []),
15
+ { label: "Discard changes", danger: true, disabled: !opts.can.discard, run: opts.onDiscard },
16
+ { label: "Unpublish", disabled: !opts.can.unpublish, run: opts.onUnpublish },
17
+ ];
18
+ }
19
+ export function PublishControl({ status, pending = false, busy = null, flash = false, disabled = false, onPublish, onDiscard, onUnpublish, onHistory, }) {
20
+ const can = publishActions(status, pending);
21
+ const inert = disabled || busy !== null;
22
+ const word = busy ? BUSY_WORD[busy] : flash ? "Published ✓" : "Publish";
23
+ return (_jsxs("span", { style: { display: "inline-flex", alignItems: "center", gap: 8 }, children: [status && _jsx(StatusBadge, { status: statusLabel(status) }), _jsx(Button, { variant: flash && !busy ? "ok" : "primary", disabled: inert || !can.publish, onClick: onPublish, children: word }), _jsx(ActionMenu, { label: "More actions", disabled: inert || status === null, items: publishMenu({ can, onDiscard, onUnpublish, onHistory }) })] }));
24
+ }
@@ -1 +1,5 @@
1
1
  export declare function relativeTime(ms: number, now?: number): string;
2
+ /** A version's saved time: an absolute date and time — History compares moments, not distances. */
3
+ export declare function versionTime(ms: number): string;
4
+ /** The "Updated" info line: the relative time, then who, when the row knows (spec 2026-09-13 history §2). */
5
+ export declare function updatedLine(updatedAt: number, updatedBy: string | null, now?: number): string;
@@ -23,3 +23,12 @@ export function relativeTime(ms, now = Date.now()) {
23
23
  month: "short", day: "numeric", ...(sameYear ? {} : { year: "numeric" }),
24
24
  });
25
25
  }
26
+ /** A version's saved time: an absolute date and time — History compares moments, not distances. */
27
+ export function versionTime(ms) {
28
+ return new Date(ms).toLocaleString(undefined, { day: "numeric", month: "short", year: "numeric", hour: "2-digit", minute: "2-digit" });
29
+ }
30
+ /** The "Updated" info line: the relative time, then who, when the row knows (spec 2026-09-13 history §2). */
31
+ export function updatedLine(updatedAt, updatedBy, now = Date.now()) {
32
+ const when = relativeTime(updatedAt, now);
33
+ return updatedBy ? `${when} · ${updatedBy}` : when;
34
+ }
@@ -1,5 +1,6 @@
1
1
  import type { AnchorHTMLAttributes, ButtonHTMLAttributes, CSSProperties, ReactNode } from "react";
2
2
  import type { SaveState } from "../editor/autosave.ts";
3
+ import type { FieldError } from "../validate.ts";
3
4
  /** The wordmark, inlined: a package can't ship a file into the host app's
4
5
  * /public. Its colour is the --logo-color token (accent in the warm look). */
5
6
  export declare function Logo({ height }: {
@@ -43,11 +44,13 @@ export declare function Grip(props: ButtonHTMLAttributes<HTMLButtonElement>): im
43
44
  /** Label + optional counter above a control — the handoff's Input/Textarea head.
44
45
  * `asDiv` for groups holding more than one control: an implicit <label>
45
46
  * activates its first labelable descendant, so wrapping several is a trap. */
46
- export declare function Field({ label, mark, counter, hint, error, asDiv, children, style, className, path }: {
47
+ export declare function Field({ label, mark, counter, action, hint, error, asDiv, children, style, className, path }: {
47
48
  label?: ReactNode;
48
49
  /** A small glyph after the label — the field's "same in every language" mark. */
49
50
  mark?: ReactNode;
50
51
  counter?: ReactNode;
52
+ /** A small text action at the head's right end, after the counter (a list's Collapse all). */
53
+ action?: ReactNode;
51
54
  hint?: ReactNode;
52
55
  error?: ReactNode;
53
56
  asDiv?: boolean;
@@ -115,10 +118,22 @@ export declare function SectionLabel({ children, action, mono, style }: {
115
118
  export declare function Divider({ bleed }: {
116
119
  bleed?: number;
117
120
  }): import("react").JSX.Element;
121
+ /** The lines under a publish-validation banner: one per field, each focusing its widget
122
+ * when the screen can reveal it (spec 2026-09-14 §2). */
123
+ export declare function FieldErrorList({ fields, onReveal }: {
124
+ fields: FieldError[];
125
+ onReveal?(path: string): void;
126
+ }): import("react").JSX.Element | null;
118
127
  export declare function Banner({ tone, children, onDismiss }: {
119
128
  tone: "ok" | "error" | "warn";
120
129
  children: ReactNode;
121
130
  onDismiss?: () => void;
122
131
  }): import("react").JSX.Element;
123
132
  export declare function cx(...parts: (string | false | null | undefined)[]): string;
133
+ /** One line of a form's info block: a muted label, the value right-aligned and clipped. */
134
+ export declare function InfoRow({ label, value, mono }: {
135
+ label: string;
136
+ value: string;
137
+ mono?: boolean;
138
+ }): import("react").JSX.Element;
124
139
  export {};
@@ -66,9 +66,9 @@ export function Grip(props) {
66
66
  /** Label + optional counter above a control — the handoff's Input/Textarea head.
67
67
  * `asDiv` for groups holding more than one control: an implicit <label>
68
68
  * activates its first labelable descendant, so wrapping several is a trap. */
69
- export function Field({ label, mark, counter, hint, error, asDiv, children, style, className, path }) {
69
+ export function Field({ label, mark, counter, action, hint, error, asDiv, children, style, className, path }) {
70
70
  const Tag = asDiv ? "div" : "label";
71
- return (_jsxs(Tag, { className: cx("sm-field", className), style: style, "data-sm-path": path, children: [(label || counter) && (_jsxs("span", { className: "sm-field__head", children: [_jsxs("span", { className: "sm-label", children: [label, mark] }), counter && _jsx("span", { className: "sm-counter", children: counter })] })), children, hint && _jsx("span", { className: "sm-hint", children: hint }), error && _jsx("span", { className: "sm-error", children: error })] }));
71
+ return (_jsxs(Tag, { className: cx("sm-field", className), style: style, "data-sm-path": path, children: [(label || counter || action) && (_jsxs("span", { className: "sm-field__head", children: [_jsxs("span", { className: "sm-label", children: [label, mark] }), (counter || action) && (_jsxs("span", { className: "sm-field__aside", children: [counter && _jsx("span", { className: "sm-counter", children: counter }), action] }))] })), children, hint && _jsx("span", { className: "sm-hint", children: hint }), error && _jsx("span", { className: "sm-error", children: error })] }));
72
72
  }
73
73
  /** The mark beside a field that is NOT `.localized()`: a globe-off, so a
74
74
  * multilingual form needs no "shared across languages" grouping. */
@@ -106,11 +106,11 @@ export function Segmented({ options, value, onChange, stretch, disabled, label,
106
106
  }) }));
107
107
  }
108
108
  /* ── data ────────────────────────────────────────────────────────────── */
109
+ // Keyed by the product labels of ui/status-label.ts (lowercased by StatusBadge).
109
110
  const STATUS_TONE = {
110
111
  published: "published",
112
+ "unpublished changes": "changes",
111
113
  draft: "draft",
112
- "draft edits": "scheduled",
113
- scheduled: "scheduled",
114
114
  };
115
115
  export function StatusBadge({ status }) {
116
116
  const tone = STATUS_TONE[status.toLowerCase()] ?? "draft";
@@ -134,9 +134,25 @@ export function SectionLabel({ children, action, mono, style }) {
134
134
  export function Divider({ bleed = 0 }) {
135
135
  return _jsx("div", { className: "sm-divider", style: { margin: `0 -${bleed}px` } });
136
136
  }
137
+ /** The lines under a publish-validation banner: one per field, each focusing its widget
138
+ * when the screen can reveal it (spec 2026-09-14 §2). */
139
+ export function FieldErrorList({ fields, onReveal }) {
140
+ if (fields.length === 0)
141
+ return null;
142
+ return (_jsx("ul", { className: "sm-fielderr", children: fields.map((f) => (_jsxs("li", { children: [onReveal
143
+ ? _jsx("button", { type: "button", className: "sm-fielderr__field", onClick: () => onReveal(f.field), children: f.field })
144
+ : f.field, ": ", f.message] }, f.field))) }));
145
+ }
137
146
  export function Banner({ tone, children, onDismiss }) {
138
147
  return (_jsxs("div", { className: `sm-banner sm-banner--${tone}`, role: tone === "error" ? "alert" : undefined, children: [_jsx("div", { style: { flex: 1, minWidth: 0 }, children: children }), onDismiss && (_jsx("button", { type: "button", onClick: onDismiss, "aria-label": "Dismiss", style: { background: "transparent", border: 0, color: "inherit", cursor: "pointer", font: "inherit", padding: 0 }, children: "\u00D7" }))] }));
139
148
  }
140
149
  export function cx(...parts) {
141
150
  return parts.filter(Boolean).join(" ");
142
151
  }
152
+ /** One line of a form's info block: a muted label, the value right-aligned and clipped. */
153
+ export function InfoRow({ label, value, mono }) {
154
+ return (_jsxs("div", { style: { display: "flex", justifyContent: "space-between", gap: 10, fontSize: "var(--text-sm)", color: "var(--text-muted)" }, children: [_jsx("span", { children: label }), _jsx("span", { style: {
155
+ color: "var(--text)", fontFamily: mono ? "var(--font-mono)" : undefined,
156
+ overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap",
157
+ }, children: value })] }));
158
+ }
@@ -0,0 +1,10 @@
1
+ import type { LocaleStatus } from "../shell/pages-tree.ts";
2
+ export type StatusLabel = "Published" | "Unpublished changes" | "Draft";
3
+ export declare function statusLabel(status: LocaleStatus): StatusLabel;
4
+ /** Which of the three actions apply (spec §2). `pending`: the editor holds a change
5
+ * the store has not confirmed yet — Publish wakes on the first keystroke, not on the ack. */
6
+ export declare function publishActions(status: LocaleStatus | null, pending: boolean): {
7
+ publish: boolean;
8
+ discard: boolean;
9
+ unpublish: boolean;
10
+ };
@@ -0,0 +1,14 @@
1
+ export function statusLabel(status) {
2
+ return status === "published" ? "Published" : status === "draft edits" ? "Unpublished changes" : "Draft";
3
+ }
4
+ /** Which of the three actions apply (spec §2). `pending`: the editor holds a change
5
+ * the store has not confirmed yet — Publish wakes on the first keystroke, not on the ack. */
6
+ export function publishActions(status, pending) {
7
+ if (status === null)
8
+ return { publish: false, discard: false, unpublish: false };
9
+ return {
10
+ publish: status !== "published" || pending,
11
+ discard: status === "draft edits",
12
+ unpublish: status === "published" || status === "draft edits",
13
+ };
14
+ }