smoodly 0.0.12 → 0.0.14

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 (159) hide show
  1. package/README.md +294 -40
  2. package/dist/admin/editor/CanvasOverlay.d.ts +6 -2
  3. package/dist/admin/editor/CanvasOverlay.js +9 -11
  4. package/dist/admin/editor/EditorView.js +135 -28
  5. package/dist/admin/editor/FloatingToolbar.d.ts +16 -0
  6. package/dist/admin/editor/FloatingToolbar.js +14 -0
  7. package/dist/admin/editor/InlineRichtext.d.ts +9 -0
  8. package/dist/admin/editor/InlineRichtext.js +50 -0
  9. package/dist/admin/editor/Outline.d.ts +16 -10
  10. package/dist/admin/editor/Outline.js +13 -10
  11. package/dist/admin/editor/PageInspector.d.ts +21 -0
  12. package/dist/admin/editor/PageInspector.js +24 -0
  13. package/dist/admin/editor/PageSettings.d.ts +1 -3
  14. package/dist/admin/editor/PageSettings.js +7 -6
  15. package/dist/admin/editor/SeoSettings.d.ts +7 -0
  16. package/dist/admin/editor/SeoSettings.js +15 -0
  17. package/dist/admin/editor/inline-dom.d.ts +27 -0
  18. package/dist/admin/editor/inline-dom.js +43 -0
  19. package/dist/admin/editor/inline-session.d.ts +30 -0
  20. package/dist/admin/editor/inline-session.js +19 -0
  21. package/dist/admin/editor/protocol.d.ts +22 -1
  22. package/dist/admin/editor/useCanvasBridge.d.ts +8 -4
  23. package/dist/admin/editor/useCanvasBridge.js +12 -7
  24. package/dist/admin/editor/useInlineSession.d.ts +46 -0
  25. package/dist/admin/editor/useInlineSession.js +244 -0
  26. package/dist/admin/field-paths.d.ts +13 -0
  27. package/dist/admin/field-paths.js +49 -0
  28. package/dist/admin/forms/FieldWidget.d.ts +7 -2
  29. package/dist/admin/forms/FieldWidget.js +35 -43
  30. package/dist/admin/forms/ListField.d.ts +10 -0
  31. package/dist/admin/forms/ListField.js +42 -0
  32. package/dist/admin/forms/MediaWidget.js +10 -3
  33. package/dist/admin/forms/RichtextElementCard.d.ts +3 -0
  34. package/dist/admin/forms/RichtextElementCard.js +37 -0
  35. package/dist/admin/forms/RichtextImageCard.d.ts +3 -0
  36. package/dist/admin/forms/RichtextImageCard.js +42 -0
  37. package/dist/admin/forms/RichtextInsertMenu.d.ts +6 -0
  38. package/dist/admin/forms/RichtextInsertMenu.js +6 -0
  39. package/dist/admin/forms/RichtextPopovers.d.ts +7 -0
  40. package/dist/admin/forms/RichtextPopovers.js +25 -0
  41. package/dist/admin/forms/RichtextToolbar.d.ts +4 -2
  42. package/dist/admin/forms/RichtextToolbar.js +4 -2
  43. package/dist/admin/forms/RichtextWidget.d.ts +4 -1
  44. package/dist/admin/forms/RichtextWidget.js +23 -120
  45. package/dist/admin/forms/elements-context.d.ts +7 -0
  46. package/dist/admin/forms/elements-context.js +13 -0
  47. package/dist/admin/forms/reveal.d.ts +5 -0
  48. package/dist/admin/forms/reveal.js +47 -0
  49. package/dist/admin/forms/richtext-extensions.d.ts +8 -0
  50. package/dist/admin/forms/richtext-extensions.js +11 -0
  51. package/dist/admin/forms/richtext-form-context.d.ts +16 -0
  52. package/dist/admin/forms/richtext-form-context.js +18 -0
  53. package/dist/admin/forms/richtext-insert.d.ts +38 -0
  54. package/dist/admin/forms/richtext-insert.js +37 -0
  55. package/dist/admin/forms/richtext-nodes.d.ts +37 -0
  56. package/dist/admin/forms/richtext-nodes.js +85 -0
  57. package/dist/admin/forms/richtext-paste.js +5 -0
  58. package/dist/admin/forms/tabs.d.ts +3 -0
  59. package/dist/admin/forms/tabs.js +6 -0
  60. package/dist/admin/forms/useRichtextEditor.d.ts +51 -0
  61. package/dist/admin/forms/useRichtextEditor.js +281 -0
  62. package/dist/admin/next/bridge.js +51 -20
  63. package/dist/admin/ops-impl.js +85 -19
  64. package/dist/admin/richtext-doc.d.ts +3 -0
  65. package/dist/admin/richtext-doc.js +12 -0
  66. package/dist/admin/serialize.d.ts +11 -0
  67. package/dist/admin/serialize.js +18 -1
  68. package/dist/admin/shell/AdminApp.js +2 -1
  69. package/dist/admin/shell/EntriesList.js +34 -12
  70. package/dist/admin/shell/EntryForm.js +22 -15
  71. package/dist/admin/shell/IconRail.js +6 -5
  72. package/dist/admin/shell/ListView.d.ts +9 -2
  73. package/dist/admin/shell/ListView.js +7 -6
  74. package/dist/admin/shell/LoginView.js +2 -2
  75. package/dist/admin/shell/PagesList.js +53 -24
  76. package/dist/admin/shell/SharedForm.js +17 -11
  77. package/dist/admin/shell/SharedList.js +5 -3
  78. package/dist/admin/shell/entries-list.d.ts +4 -0
  79. package/dist/admin/shell/entries-list.js +6 -0
  80. package/dist/admin/shell/pages-tree.d.ts +24 -0
  81. package/dist/admin/shell/pages-tree.js +59 -1
  82. package/dist/admin/tree-ops.d.ts +11 -1
  83. package/dist/admin/tree-ops.js +32 -1
  84. package/dist/admin/ui/LocaleSwitcher.d.ts +10 -0
  85. package/dist/admin/ui/LocaleSwitcher.js +28 -8
  86. package/dist/admin/ui/Modal.js +14 -2
  87. package/dist/admin/ui/ResizeHandle.d.ts +1 -0
  88. package/dist/admin/ui/ResizeHandle.js +127 -0
  89. package/dist/admin/ui/primitives.d.ts +38 -4
  90. package/dist/admin/ui/primitives.js +35 -10
  91. package/dist/admin/ui/resize.d.ts +16 -0
  92. package/dist/admin/ui/resize.js +23 -0
  93. package/dist/admin/ui/sortable.d.ts +23 -0
  94. package/dist/admin/ui/sortable.js +57 -0
  95. package/dist/admin/ui/theme.d.ts +3 -1
  96. package/dist/admin/ui/theme.js +527 -165
  97. package/dist/admin/ui/useSortable.d.ts +35 -0
  98. package/dist/admin/ui/useSortable.js +220 -0
  99. package/dist/admin/validate.d.ts +6 -1
  100. package/dist/admin/validate.js +52 -12
  101. package/dist/collections.d.ts +6 -5
  102. package/dist/config.d.ts +1 -0
  103. package/dist/config.js +36 -0
  104. package/dist/entry-store.d.ts +23 -3
  105. package/dist/entry-store.js +31 -5
  106. package/dist/field-defaults.d.ts +8 -0
  107. package/dist/field-defaults.js +18 -0
  108. package/dist/fields.d.ts +30 -1
  109. package/dist/fields.js +33 -3
  110. package/dist/image/SmoodlyImage.d.ts +4 -1
  111. package/dist/image/SmoodlyImage.js +2 -2
  112. package/dist/index.d.ts +16 -7
  113. package/dist/index.js +10 -4
  114. package/dist/marks.d.ts +53 -0
  115. package/dist/marks.js +43 -0
  116. package/dist/media.d.ts +3 -2
  117. package/dist/media.js +48 -6
  118. package/dist/next/index.d.ts +2 -0
  119. package/dist/next/index.js +1 -0
  120. package/dist/next/meta.d.ts +7 -3
  121. package/dist/next/meta.js +18 -9
  122. package/dist/next/page-renderer.js +1 -1
  123. package/dist/next/queries.d.ts +36 -0
  124. package/dist/next/queries.js +87 -0
  125. package/dist/page-tree.d.ts +3 -0
  126. package/dist/page-tree.js +6 -0
  127. package/dist/page.d.ts +11 -3
  128. package/dist/page.js +26 -0
  129. package/dist/pairing.d.ts +2 -1
  130. package/dist/pairing.js +2 -9
  131. package/dist/refs.d.ts +2 -2
  132. package/dist/refs.js +33 -12
  133. package/dist/render.d.ts +14 -7
  134. package/dist/render.js +4 -3
  135. package/dist/resolve.d.ts +12 -2
  136. package/dist/resolve.js +16 -7
  137. package/dist/revalidate.d.ts +6 -0
  138. package/dist/revalidate.js +6 -0
  139. package/dist/richtext/index.d.ts +12 -0
  140. package/dist/richtext/index.js +9 -0
  141. package/dist/richtext-render.d.ts +22 -2
  142. package/dist/richtext-render.js +40 -13
  143. package/dist/richtext-walk.d.ts +44 -0
  144. package/dist/richtext-walk.js +46 -0
  145. package/dist/schema.d.ts +1 -1
  146. package/dist/schema.js +13 -1
  147. package/dist/section-wrapper.js +2 -1
  148. package/dist/shared.d.ts +7 -7
  149. package/dist/site.d.ts +25 -1
  150. package/dist/site.js +89 -22
  151. package/dist/store.d.ts +7 -1
  152. package/dist/store.js +13 -4
  153. package/dist/supabase-entry-store.d.ts +1 -0
  154. package/dist/supabase-entry-store.js +34 -11
  155. package/dist/supabase-store.d.ts +1 -0
  156. package/dist/supabase-store.js +14 -1
  157. package/dist/toolset.d.ts +7 -1
  158. package/dist/toolset.js +13 -4
  159. package/package.json +6 -1
@@ -1,13 +1,15 @@
1
1
  "use client";
2
2
  import { jsx as _jsx } from "react/jsx-runtime";
3
- // The Shared content list (spec 2026-09-07 §6): every registered item is
3
+ // The Globals list (shared content, spec 2026-09-07 §6; renamed in the
4
+ // UI 2026-09-09, the route stays /shared): every registered item is
4
5
  // a row, edited on its own page. Nothing here is created or deleted by
5
6
  // editors — the registry declares the items and ops.shared.list
6
7
  // materializes them — so there is no "+ New".
7
8
  import { useCallback, useEffect, useState } from "react";
8
9
  import { ListView } from "./ListView.js";
9
10
  import { sharedRows } from "./shared-list.js";
10
- import { Segmented, StatusBadge } from "../ui/primitives.js";
11
+ import { StatusBadge } from "../ui/primitives.js";
12
+ import { LocalePicker } from "../ui/LocaleSwitcher.js";
11
13
  import { relativeTime } from "../ui/format.js";
12
14
  import { BASE } from "./base.js";
13
15
  import { ErrorPane } from "./ErrorPane.js";
@@ -44,7 +46,7 @@ export function SharedList({ ops, registry }) {
44
46
  if (error)
45
47
  return _jsx(ErrorPane, { message: error });
46
48
  const rows = items ? sharedRows(items, registry, locale) : [];
47
- return (_jsx(ListView, { title: "Shared content", count: items ? rows.length : undefined, actions: multilingual ? _jsx(Segmented, { options: registry.locales.supported, value: locale, onChange: pickLocale }) : undefined, columns: [
49
+ return (_jsx(ListView, { title: "Globals", titleTrailing: multilingual && _jsx(LocalePicker, { locales: registry.locales.supported, value: locale, onChange: pickLocale }), columns: [
48
50
  { label: "Title", width: "minmax(160px,1fr)" },
49
51
  { label: "Kind", width: "90px" },
50
52
  { label: "Updated", width: "minmax(110px,160px)" },
@@ -19,3 +19,7 @@ export declare function entrySourceLocale(record: EntryRecord, registry: AdminRe
19
19
  * (the same split entry-store.ts's splitFields makes). */
20
20
  export declare function localizedFieldKeys(meta: AdminCollection): string[];
21
21
  export declare function entryRows(records: EntryRecord[], meta: AdminCollection, registry: AdminRegistry, locale: string): EntryRow[];
22
+ /** The list drags only when the order IS the `sort` column and the rows
23
+ * are siblings: a nested collection's flat index is not a sibling index
24
+ * (spec 2026-09-09 §5; nested manual collections wait for the entry tree UI). */
25
+ export declare function canReorder(meta: AdminCollection): boolean;
@@ -34,3 +34,9 @@ export function entryRows(records, meta, registry, locale) {
34
34
  };
35
35
  });
36
36
  }
37
+ /** The list drags only when the order IS the `sort` column and the rows
38
+ * are siblings: a nested collection's flat index is not a sibling index
39
+ * (spec 2026-09-09 §5; nested manual collections wait for the entry tree UI). */
40
+ export function canReorder(meta) {
41
+ return meta.manual && meta.depth === 1;
42
+ }
@@ -1,5 +1,6 @@
1
1
  import type { PageLocale, PageRecord } from "../../store.ts";
2
2
  import type { AdminCollection, AdminPageTemplate, AdminRegistry } from "../serialize.ts";
3
+ import type { Projection } from "../ui/useSortable.ts";
3
4
  type NodeLike = {
4
5
  parentId: string | null;
5
6
  locales: Record<string, {
@@ -54,4 +55,27 @@ export type PagesTreeRow = {
54
55
  record: PageRecord;
55
56
  };
56
57
  export declare function pagesTreeRows(records: PageRecord[], registry: AdminRegistry, locale: string, collapsed: ReadonlySet<string>): PagesTreeRow[];
58
+ export type TreeProjection = Projection;
59
+ /** Every row drags: root order is the listing's order, nothing code owns.
60
+ * Fixed nodes and the home page stay at the root — projectDrop refuses
61
+ * them any deeper slot, the move op refuses any other parent. */
62
+ export declare const canDragRow: (_row: PagesTreeRow) => boolean;
63
+ /** Whether a row may only reorder among the root siblings. */
64
+ export declare const rootOnly: (row: PagesTreeRow) => boolean;
65
+ /** Levels below a node: 0 for a leaf. From the records, because the
66
+ * dragged node's rows are collapsed away during the drag. */
67
+ export declare function subtreeHeight(records: {
68
+ id: string;
69
+ parentId: string | null;
70
+ }[], id: string): number;
71
+ /**
72
+ * Reads a flat drop slot as a tree position. `rows` still hold the dragged
73
+ * row at `from` (its subtree collapsed); `slot` is its index in the
74
+ * resulting flat order; `xDrift` is the pointer's horizontal travel and
75
+ * every `indent` px of it is one level. Returns null for a slot the tree
76
+ * refuses: a parent that cannot have children, a subtree that would
77
+ * pass the depth limit, or any depth but the root for a row that is
78
+ * code-owned or the site root.
79
+ */
80
+ export declare function projectDrop(rows: PagesTreeRow[], from: number, slot: number, xDrift: number, height: number, pageDepth: number, indent?: number): TreeProjection | null;
57
81
  export {};
@@ -84,7 +84,7 @@ export function pagesTreeRows(records, registry, locale, collapsed) {
84
84
  template: r.template,
85
85
  templateTitle: mount
86
86
  ? r.template === null ? "Collection" : `Collection · ${templateTitle(r.template)}`
87
- : r.template === null ? "Folder" : templateTitle(r.template),
87
+ : r.template === null ? "Path" : templateTitle(r.template),
88
88
  status: !row || r.template === null ? null : localeStatusOf(row),
89
89
  // A child needs its parent in the locale (the parent rule), so an absent node offers none.
90
90
  canHaveChildren: present && !mount && !isHome && depth + 1 < registry.pageDepth,
@@ -97,3 +97,61 @@ export function pagesTreeRows(records, registry, locale, collapsed) {
97
97
  walk(buildPageTree(records, locale, opts), 0, null);
98
98
  return rows;
99
99
  }
100
+ /** Every row drags: root order is the listing's order, nothing code owns.
101
+ * Fixed nodes and the home page stay at the root — projectDrop refuses
102
+ * them any deeper slot, the move op refuses any other parent. */
103
+ export const canDragRow = (_row) => true;
104
+ /** Whether a row may only reorder among the root siblings. */
105
+ export const rootOnly = (row) => row.fixed || row.isHome;
106
+ /** Levels below a node: 0 for a leaf. From the records, because the
107
+ * dragged node's rows are collapsed away during the drag. */
108
+ export function subtreeHeight(records, id) {
109
+ const children = records.filter((r) => r.parentId === id);
110
+ if (children.length === 0)
111
+ return 0;
112
+ return 1 + Math.max(...children.map((c) => subtreeHeight(records, c.id)));
113
+ }
114
+ /**
115
+ * Reads a flat drop slot as a tree position. `rows` still hold the dragged
116
+ * row at `from` (its subtree collapsed); `slot` is its index in the
117
+ * resulting flat order; `xDrift` is the pointer's horizontal travel and
118
+ * every `indent` px of it is one level. Returns null for a slot the tree
119
+ * refuses: a parent that cannot have children, a subtree that would
120
+ * pass the depth limit, or any depth but the root for a row that is
121
+ * code-owned or the site root.
122
+ */
123
+ export function projectDrop(rows, from, slot, xDrift, height, pageDepth, indent = 20) {
124
+ const others = rows.filter((_, i) => i !== from);
125
+ const above = slot > 0 ? others[slot - 1] : null;
126
+ const below = slot < others.length ? others[slot] : null;
127
+ const maxDepth = above ? above.depth + 1 : 0;
128
+ const minDepth = below ? below.depth : 0;
129
+ if (minDepth > maxDepth)
130
+ return null;
131
+ const wanted = rows[from].depth + Math.round(xDrift / indent);
132
+ const depth = Math.max(minDepth, Math.min(maxDepth, wanted));
133
+ if (depth + height >= pageDepth)
134
+ return null;
135
+ if (depth > 0 && rootOnly(rows[from]))
136
+ return null;
137
+ let parent = null;
138
+ if (depth > 0) {
139
+ for (let i = slot - 1; i >= 0; i--) {
140
+ if (others[i].depth === depth - 1) {
141
+ parent = others[i];
142
+ break;
143
+ }
144
+ }
145
+ if (!parent || !parent.canHaveChildren)
146
+ return null;
147
+ }
148
+ const parentId = parent?.id ?? null;
149
+ // A collapsed parent hides its children: the slot after it is "last child".
150
+ if (parent && parent.hasChildren && !parent.expanded)
151
+ return { depth, parentId, index: undefined };
152
+ let index = 0;
153
+ for (let i = 0; i < slot; i++)
154
+ if (others[i].parentId === parentId)
155
+ index++;
156
+ return { depth, parentId, index };
157
+ }
@@ -6,7 +6,14 @@ export declare function findNode(tree: PageTree, id: string): TreeNode | null;
6
6
  export declare function insertNode(tree: PageTree, zone: string, index: number, node: TreeNode): PageTree;
7
7
  export declare function removeNode(tree: PageTree, id: string): PageTree;
8
8
  export declare function moveNode(tree: PageTree, id: string, dir: -1 | 1): PageTree;
9
+ /** Places `id` at `index` in its zone's RESULTING order (removed first,
10
+ * then spliced in — moveItem's convention); clamped, and the same tree
11
+ * when nothing changes. The drop path; `moveNode` stays for the arrows. */
12
+ export declare function moveNodeTo(tree: PageTree, id: string, index: number): PageTree;
9
13
  export declare function patchNode(tree: PageTree, id: string, ns: "fields" | "styles" | "meta", key: string, value: unknown): PageTree;
14
+ /** patchNode for a dotted path inside `fields` (spec 2026-09-10 §1):
15
+ * `points.2.heading` sets one item's heading and copies only the spine. */
16
+ export declare function patchNodePath(tree: PageTree, id: string, path: string, value: unknown): PageTree;
10
17
  export declare function canInsert(policy: ZonePolicy | undefined, count: number): boolean;
11
18
  export declare function allowedSections(policy: ZonePolicy | undefined, all: AdminSection[]): AdminSection[];
12
19
  /** The visual items the picker offers in a zone: materialized (an id is
@@ -15,7 +22,8 @@ export declare function allowedShared(policy: ZonePolicy | undefined, shared: Ad
15
22
  /** A page places a visual item as this node (spec 2026-09-07 §6): `item`
16
23
  * names the registration, `ref` is the item's row id. */
17
24
  export declare function placementNode(item: AdminShared, ref: string, id: string): TreeNode;
18
- export declare function nodeFromSample(section: AdminSection, id: string): TreeNode;
25
+ /** Also fills an element node (an `AdminElement` is a section shape plus contexts). */
26
+ export declare function nodeFromSample(section: Pick<AdminSection, "name" | "fields" | "styles" | "sample">, id: string): TreeNode;
19
27
  /** A locked zone holds exactly one node the editor can never insert or
20
28
  * remove — so someone has to put it there. This does, from the component's
21
29
  * sample (or, for a zone bound to a shared item, a placement), for every
@@ -24,3 +32,5 @@ export declare function nodeFromSample(section: AdminSection, id: string): TreeN
24
32
  * schema. Returns the same tree when nothing changed. */
25
33
  export declare function fillLockedZones(tree: PageTree, policies: Record<string, ZonePolicy>, sections: AdminSection[], sharedIds?: Record<string, string>): PageTree;
26
34
  export declare function newNodeId(type: string): string;
35
+ /** A page-level value — the template's page fields or the SEO meta (spec 2026-09-10 page fields §5). */
36
+ export declare function patchPage(tree: PageTree, ns: "fields" | "meta", key: string, value: unknown): PageTree;
@@ -1,4 +1,5 @@
1
1
  import { SHARED_NODE_TYPE } from "../shared.js";
2
+ import { setAtPath } from "./field-paths.js";
2
3
  export function zoneOf(tree, id) {
3
4
  for (const [zone, nodes] of Object.entries(tree.zones)) {
4
5
  if (nodes.some((n) => n.id === id))
@@ -40,12 +41,37 @@ export function moveNode(tree, id, dir) {
40
41
  [list[i], list[j]] = [list[j], list[i]];
41
42
  return withZone(tree, zone, list);
42
43
  }
44
+ /** Places `id` at `index` in its zone's RESULTING order (removed first,
45
+ * then spliced in — moveItem's convention); clamped, and the same tree
46
+ * when nothing changes. The drop path; `moveNode` stays for the arrows. */
47
+ export function moveNodeTo(tree, id, index) {
48
+ const zone = zoneOf(tree, id);
49
+ if (!zone)
50
+ return tree;
51
+ const list = tree.zones[zone];
52
+ const from = list.findIndex((n) => n.id === id);
53
+ const to = Math.max(0, Math.min(index, list.length - 1));
54
+ if (from === to)
55
+ return tree;
56
+ const next = [...list];
57
+ const [moved] = next.splice(from, 1);
58
+ next.splice(to, 0, moved);
59
+ return withZone(tree, zone, next);
60
+ }
43
61
  export function patchNode(tree, id, ns, key, value) {
44
62
  const zone = zoneOf(tree, id);
45
63
  if (!zone)
46
64
  return tree;
47
65
  return withZone(tree, zone, tree.zones[zone].map((n) => (n.id === id ? { ...n, [ns]: { ...(n[ns] ?? {}), [key]: value } } : n)));
48
66
  }
67
+ /** patchNode for a dotted path inside `fields` (spec 2026-09-10 §1):
68
+ * `points.2.heading` sets one item's heading and copies only the spine. */
69
+ export function patchNodePath(tree, id, path, value) {
70
+ const zone = zoneOf(tree, id);
71
+ if (!zone)
72
+ return tree;
73
+ return withZone(tree, zone, tree.zones[zone].map((n) => (n.id === id ? { ...n, fields: setAtPath(n.fields, path, value) } : n)));
74
+ }
49
75
  export function canInsert(policy, count) {
50
76
  if (!policy || policy.kind === "locked")
51
77
  return false;
@@ -85,11 +111,12 @@ const EMPTY_BY_TYPE = { refList: [], object: {} };
85
111
  * broken-image icon or a stray zero, worse than nothing at all. With no
86
112
  * sample, leave the key out of `fields` entirely rather than seed a lie. */
87
113
  const ABSENT_WHEN_UNSAMPLED = new Set(["image", "video", "file", "number"]);
114
+ /** Also fills an element node (an `AdminElement` is a section shape plus contexts). */
88
115
  export function nodeFromSample(section, id) {
89
116
  const fields = {};
90
117
  const styles = {};
91
118
  for (const [key, d] of Object.entries(section.fields)) {
92
- const sampled = section.sample?.[key];
119
+ const sampled = section.sample?.[key] ?? d.default ?? (d.type === "boolean" ? false : undefined);
93
120
  if (sampled !== undefined)
94
121
  fields[key] = sampled;
95
122
  else if (ABSENT_WHEN_UNSAMPLED.has(d.type))
@@ -141,3 +168,7 @@ export function fillLockedZones(tree, policies, sections, sharedIds = {}) {
141
168
  export function newNodeId(type) {
142
169
  return `${type}-${crypto.randomUUID().slice(0, 8)}`;
143
170
  }
171
+ /** A page-level value — the template's page fields or the SEO meta (spec 2026-09-10 page fields §5). */
172
+ export function patchPage(tree, ns, key, value) {
173
+ return { ...tree, [ns]: { ...(tree[ns] ?? {}), [key]: value } };
174
+ }
@@ -1,4 +1,8 @@
1
1
  import type { AdminRegistry } from "../serialize.ts";
2
+ /** Above this many supported locales the switch is a dropdown. */
3
+ export declare const SEGMENTED_MAX = 3;
4
+ /** "fi" → "Fi": the locale code as the pill reads it. */
5
+ export declare const localeLabel: (locale: string) => string;
2
6
  export declare function LocaleSwitcher({ registry, present, parentLocales, value, adding, onSwitch, onAdd }: {
3
7
  registry: AdminRegistry;
4
8
  /** The locales the record has; `[value]` while nothing is loaded. */
@@ -9,3 +13,9 @@ export declare function LocaleSwitcher({ registry, present, parentLocales, value
9
13
  onSwitch(locale: string): void;
10
14
  onAdd(locale: string): void;
11
15
  }): import("react").JSX.Element;
16
+ /** The lists' locale selector: every locale exists, nothing to add. */
17
+ export declare function LocalePicker({ locales, value, onChange }: {
18
+ locales: readonly string[];
19
+ value: string;
20
+ onChange(locale: string): void;
21
+ }): import("react").JSX.Element;
@@ -1,15 +1,35 @@
1
1
  "use client";
2
2
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { Segmented } from "./primitives.js";
4
+ /** Above this many supported locales the switch is a dropdown. */
5
+ export const SEGMENTED_MAX = 3;
6
+ /** "fi" → "Fi": the locale code as the pill reads it. */
7
+ export const localeLabel = (locale) => locale.charAt(0).toUpperCase() + locale.slice(1);
3
8
  export function LocaleSwitcher({ registry, present, parentLocales, value, adding, onSwitch, onAdd }) {
4
- const missing = registry.locales.supported.filter((l) => !present.includes(l) && l !== value);
9
+ const supported = registry.locales.supported;
10
+ const missing = supported.filter((l) => !present.includes(l) && l !== value);
11
+ const blocked = (l) => !parentLocales.includes(l);
12
+ const reason = (l) => `add ${l} to the parent first`;
13
+ if (supported.length <= SEGMENTED_MAX) {
14
+ return (_jsx(Segmented, { options: supported, value: value, disabled: adding, label: localeLabel, className: "sm-seg--locale", option: (l) => {
15
+ if (!missing.includes(l))
16
+ return undefined;
17
+ return blocked(l)
18
+ ? { disabled: true, title: reason(l), className: "sm-seg__opt--absent" }
19
+ : { title: `Not in ${l} yet`, className: "sm-seg__opt--absent" };
20
+ }, onChange: onSwitch }));
21
+ }
5
22
  return (_jsxs("select", { className: "sm-input", style: { width: "auto" }, value: value, "aria-label": "Locale", onChange: (e) => { const v = e.target.value; if (v.startsWith("+"))
6
23
  onAdd(v.slice(1));
7
24
  else
8
- onSwitch(v); }, children: [present.map((l) => _jsx("option", { value: l, children: l }, l)), !present.includes(value) && _jsxs("option", { value: value, children: [value, " (not yet)"] }), missing.length > 0 && (_jsx("optgroup", { label: "Add locale", disabled: adding, children: missing.map((l) => {
9
- const blocked = !parentLocales.includes(l);
10
- const reason = `add ${l} to the parent first`;
11
- return blocked
12
- ? _jsxs("option", { value: `+${l}`, disabled: true, title: reason, children: ["+ ", l, " \u2014 ", reason] }, l)
13
- : _jsxs("option", { value: `+${l}`, children: ["+ ", l] }, l);
14
- }) }))] }));
25
+ onSwitch(v); }, children: [present.map((l) => _jsx("option", { value: l, children: localeLabel(l) }, l)), !present.includes(value) && _jsxs("option", { value: value, children: [localeLabel(value), " (not yet)"] }), missing.length > 0 && (_jsx("optgroup", { label: "Add locale", disabled: adding, children: missing.map((l) => blocked(l)
26
+ ? _jsxs("option", { value: `+${l}`, disabled: true, title: reason(l), children: ["+ ", localeLabel(l), " \u2014 ", reason(l)] }, l)
27
+ : _jsxs("option", { value: `+${l}`, children: ["+ ", localeLabel(l)] }, l)) }))] }));
28
+ }
29
+ /** The lists' locale selector: every locale exists, nothing to add. */
30
+ export function LocalePicker({ locales, value, onChange }) {
31
+ if (locales.length <= SEGMENTED_MAX) {
32
+ return _jsx(Segmented, { options: locales, value: value, onChange: onChange, label: localeLabel, className: "sm-seg--locale" });
33
+ }
34
+ return (_jsx("select", { className: "sm-input", style: { width: "auto" }, value: value, "aria-label": "Locale", onChange: (e) => onChange(e.target.value), children: locales.map((l) => _jsx("option", { value: l, children: localeLabel(l) }, l)) }));
15
35
  }
@@ -2,14 +2,26 @@
2
2
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
3
  // The one modal: a fixed overlay (click outside or Escape closes) and a
4
4
  // centred panel with a title row. Same overlay shape as InsertPicker.
5
- import { useEffect } from "react";
5
+ // Rendered through a portal into the admin wrapper: a picker opened from
6
+ // a card inside the canvas iframe must not be fixed to the iframe's
7
+ // viewport (spec 2026-09-10 §5), and every modal escapes its parent's
8
+ // overflow the same way. Static rendering (no document) keeps it inline.
9
+ import { useEffect, useState } from "react";
10
+ import { createPortal } from "react-dom";
6
11
  import { IconButton } from "./primitives.js";
12
+ function adminRoot() {
13
+ if (typeof document === "undefined")
14
+ return null;
15
+ return document.querySelector(".sm-admin") ?? document.body;
16
+ }
7
17
  export function Modal({ title, width = 720, onClose, children, footer }) {
18
+ const [root] = useState(adminRoot);
8
19
  useEffect(() => {
9
20
  const onKey = (e) => { if (e.key === "Escape")
10
21
  onClose(); };
11
22
  window.addEventListener("keydown", onKey);
12
23
  return () => window.removeEventListener("keydown", onKey);
13
24
  }, [onClose]);
14
- return (_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 }, 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 })] }) }));
26
+ return root ? createPortal(modal, root) : modal;
15
27
  }
@@ -0,0 +1 @@
1
+ export declare function ResizeHandle(): import("react").JSX.Element;
@@ -0,0 +1,127 @@
1
+ "use client";
2
+ import { jsx as _jsx } from "react/jsx-runtime";
3
+ // The inspector's drag-to-resize handle (spec 2026-09-10 §3–§5). Rendered
4
+ // between the main column and <aside class="sm-inspector"> inside .sm-editor.
5
+ // A drag writes --inspector-w straight onto the .sm-admin wrapper — no React
6
+ // state, so the canvas iframe is never re-rendered mid-gesture; only the
7
+ // aria-valuenow re-renders, on release and after a keyboard step.
8
+ import { useEffect, useRef, useState } from "react";
9
+ import { INSPECTOR_W_KEY, MIN_INSPECTOR_W, RESIZE_STEP, clampInspectorW, maxInspectorW } from "./resize.js";
10
+ /** The wrapper the variable lives on, the grid row, and the inspector itself. */
11
+ const parts = (handle) => {
12
+ const admin = handle.closest(".sm-admin");
13
+ const grid = handle.parentElement;
14
+ const aside = handle.nextElementSibling;
15
+ return {
16
+ admin: admin instanceof HTMLElement ? admin : null,
17
+ grid: grid instanceof HTMLElement ? grid : null,
18
+ aside: aside instanceof HTMLElement ? aside : null,
19
+ };
20
+ };
21
+ const apply = (admin, px) => admin.style.setProperty("--inspector-w", `${px}px`);
22
+ /** Private mode and blocked site data both throw; the width still changes. */
23
+ const remember = (px) => {
24
+ try {
25
+ if (px === null)
26
+ localStorage.removeItem(INSPECTOR_W_KEY);
27
+ else
28
+ localStorage.setItem(INSPECTOR_W_KEY, String(px));
29
+ }
30
+ catch { /* not remembered */ }
31
+ };
32
+ export function ResizeHandle() {
33
+ const ref = useRef(null);
34
+ const drag = useRef(null);
35
+ const [now, setNow] = useState(null);
36
+ // A hidden tab lays out at zero width (seen in the desktop pane's walk):
37
+ // zero is "unknown", not a value — measured again on focus.
38
+ const measure = () => {
39
+ const el = ref.current;
40
+ if (!el)
41
+ return;
42
+ const { aside } = parts(el);
43
+ if (aside && aside.offsetWidth > 0)
44
+ setNow(aside.offsetWidth);
45
+ };
46
+ useEffect(measure, []);
47
+ /** Clamp, write and remember one width; the aria value follows. */
48
+ const commit = (px) => {
49
+ const el = ref.current;
50
+ if (!el)
51
+ return;
52
+ const { admin, grid } = parts(el);
53
+ if (!admin || !grid)
54
+ return;
55
+ const next = clampInspectorW(px, grid.clientWidth);
56
+ apply(admin, next);
57
+ remember(next);
58
+ setNow(next);
59
+ };
60
+ const reset = () => {
61
+ const el = ref.current;
62
+ if (!el)
63
+ return;
64
+ const { admin } = parts(el);
65
+ if (!admin)
66
+ return;
67
+ admin.style.removeProperty("--inspector-w");
68
+ remember(null);
69
+ measure();
70
+ };
71
+ const onPointerDown = (e) => {
72
+ if (e.button !== 0 || drag.current)
73
+ return;
74
+ const { admin, grid, aside } = parts(e.currentTarget);
75
+ if (!admin || !grid || !aside)
76
+ return;
77
+ e.preventDefault();
78
+ e.currentTarget.setPointerCapture(e.pointerId);
79
+ drag.current = { id: e.pointerId, startX: e.clientX, startW: aside.offsetWidth, rowW: grid.clientWidth, last: aside.offsetWidth };
80
+ admin.classList.add("sm-admin--resizing");
81
+ };
82
+ const onPointerMove = (e) => {
83
+ const d = drag.current;
84
+ if (!d || e.pointerId !== d.id)
85
+ return;
86
+ const { admin } = parts(e.currentTarget);
87
+ if (!admin)
88
+ return;
89
+ // The handle sits on the inspector's left edge: moving left widens it.
90
+ d.last = clampInspectorW(d.startW - (e.clientX - d.startX), d.rowW);
91
+ apply(admin, d.last);
92
+ };
93
+ // Also fires from releasePointerCapture below, when drag.current is already null.
94
+ const finish = (e) => {
95
+ const d = drag.current;
96
+ if (!d || e.pointerId !== d.id)
97
+ return;
98
+ drag.current = null;
99
+ const { admin } = parts(e.currentTarget);
100
+ if (e.currentTarget.hasPointerCapture(d.id))
101
+ e.currentTarget.releasePointerCapture(d.id);
102
+ admin?.classList.remove("sm-admin--resizing");
103
+ remember(d.last);
104
+ setNow(d.last);
105
+ };
106
+ const onKeyDown = (e) => {
107
+ const { grid, aside } = parts(e.currentTarget);
108
+ if (!grid || !aside || drag.current)
109
+ return;
110
+ const w = aside.offsetWidth;
111
+ let next = null;
112
+ if (e.key === "ArrowLeft")
113
+ next = w + RESIZE_STEP;
114
+ else if (e.key === "ArrowRight")
115
+ next = w - RESIZE_STEP;
116
+ else if (e.key === "Home")
117
+ next = MIN_INSPECTOR_W;
118
+ else if (e.key === "End")
119
+ next = maxInspectorW(grid.clientWidth);
120
+ if (next === null)
121
+ return;
122
+ e.preventDefault();
123
+ e.stopPropagation(); // the editor's window keydown must not see these
124
+ commit(next);
125
+ };
126
+ return (_jsx("div", { ref: ref, className: "sm-resize", role: "separator", "aria-orientation": "vertical", "aria-label": "Resize inspector", "aria-valuemin": MIN_INSPECTOR_W, "aria-valuenow": now ?? undefined, title: "Drag to resize \u00B7 double-click to reset", tabIndex: 0, onPointerDown: onPointerDown, onPointerMove: onPointerMove, onPointerUp: finish, onPointerCancel: finish, onLostPointerCapture: finish, onDoubleClick: reset, onKeyDown: onKeyDown, onFocus: measure }));
127
+ }
@@ -1,7 +1,7 @@
1
1
  import type { AnchorHTMLAttributes, ButtonHTMLAttributes, CSSProperties, ReactNode } from "react";
2
2
  import type { SaveState } from "../editor/autosave.ts";
3
3
  /** The wordmark, inlined: a package can't ship a file into the host app's
4
- * /public. Dark mode inverts it through --logo-filter, per the handoff. */
4
+ * /public. Its colour is the --logo-color token (accent in the warm look). */
5
5
  export declare function Logo({ height }: {
6
6
  height?: number;
7
7
  }): import("react").JSX.Element;
@@ -27,11 +27,26 @@ export declare function IconButton({ label, tone, size, children, ...rest }: {
27
27
  size?: number;
28
28
  children: ReactNode;
29
29
  } & ButtonHTMLAttributes<HTMLButtonElement>): import("react").JSX.Element;
30
+ /** An on/off control in the design system's control language: a pill
31
+ * track, a knob, the accent when on. A button, so it sits inside a
32
+ * field's implicit label and the label click toggles it. */
33
+ export declare function Switch({ checked, onChange, disabled, label }: {
34
+ checked: boolean;
35
+ onChange(next: boolean): void;
36
+ disabled?: boolean;
37
+ label?: string;
38
+ }): import("react").JSX.Element;
39
+ /** The drag handle every sortable row carries at its left edge (spec
40
+ * 2026-09-09 §2): a button, so it is focusable and takes the arrow keys;
41
+ * `touch-action: none` (theme) so a finger on it drags instead of scrolling. */
42
+ export declare function Grip(props: ButtonHTMLAttributes<HTMLButtonElement>): import("react").JSX.Element;
30
43
  /** Label + optional counter above a control — the handoff's Input/Textarea head.
31
44
  * `asDiv` for groups holding more than one control: an implicit <label>
32
45
  * activates its first labelable descendant, so wrapping several is a trap. */
33
- export declare function Field({ label, counter, hint, error, asDiv, children, style, className }: {
46
+ export declare function Field({ label, mark, counter, hint, error, asDiv, children, style, className, path }: {
34
47
  label?: ReactNode;
48
+ /** A small glyph after the label — the field's "same in every language" mark. */
49
+ mark?: ReactNode;
35
50
  counter?: ReactNode;
36
51
  hint?: ReactNode;
37
52
  error?: ReactNode;
@@ -39,17 +54,27 @@ export declare function Field({ label, counter, hint, error, asDiv, children, st
39
54
  children: ReactNode;
40
55
  style?: CSSProperties;
41
56
  className?: string;
57
+ /** The field's dotted path, for the canvas reveal (spec 2026-09-10 §8). */
58
+ path?: string;
42
59
  }): import("react").JSX.Element;
60
+ /** The mark beside a field that is NOT `.localized()`: a globe-off, so a
61
+ * multilingual form needs no "shared across languages" grouping. */
62
+ export declare function UnlocalizedMark(): import("react").JSX.Element;
43
63
  export declare function TopBar({ left, right }: {
44
64
  left: ReactNode;
45
65
  right?: ReactNode;
46
66
  }): import("react").JSX.Element;
47
67
  /** `items` are crumbs; every one but the last is a link when given an href.
48
- * Links route in place: the shell never reloads for a crumb. */
68
+ * Links route in place: the shell never reloads for a crumb. A crumb whose
69
+ * label starts with "/" (a page path) brings its own slash, so no
70
+ * separator is drawn before it. A crumb's `trailing` node renders right
71
+ * after it — the locale switch rides the first crumb, so it keeps its
72
+ * place however long the title or path after it grows. */
49
73
  export declare function Breadcrumb({ items, backHref }: {
50
74
  items: {
51
75
  label: string;
52
76
  href?: string;
77
+ trailing?: ReactNode;
53
78
  }[];
54
79
  backHref?: string;
55
80
  }): import("react").JSX.Element;
@@ -58,12 +83,21 @@ export declare function Tabs<T extends string>({ tabs, value, onChange }: {
58
83
  value: T | undefined;
59
84
  onChange(tab: T): void;
60
85
  }): import("react").JSX.Element;
61
- export declare function Segmented<T extends string>({ options, value, onChange, stretch, disabled }: {
86
+ export declare function Segmented<T extends string>({ options, value, onChange, stretch, disabled, label, option, className }: {
62
87
  options: readonly T[];
63
88
  value: T;
64
89
  onChange(v: T): void;
65
90
  stretch?: boolean;
66
91
  disabled?: boolean;
92
+ className?: string;
93
+ /** What an option reads as; the value itself by default. */
94
+ label?(o: T): ReactNode;
95
+ /** Per-option state: disabled with a reason, or a modifier class (an absent locale). */
96
+ option?(o: T): {
97
+ disabled?: boolean;
98
+ title?: string;
99
+ className?: string;
100
+ } | undefined;
67
101
  }): import("react").JSX.Element;
68
102
  export declare function StatusBadge({ status }: {
69
103
  status: string;