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
@@ -8,7 +8,10 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
8
8
  // the geometry the bridge reports map 1:1 onto the overlay layer above
9
9
  // it. Every gesture mutates client tree state through Task 4's pure ops;
10
10
  // a debounced saveDraft persists, and the bridge refreshes the iframe on
11
- // the save ack (refresh-on-change preview, DESIGN.md §3). Every load,
11
+ // the save ack (refresh-on-change preview, DESIGN.md §3). Inline editing
12
+ // (spec 2026-09-10) runs beside it: `useInlineSession` owns the sessions
13
+ // in the iframe's document, `InlineRichtext` the canvas editor, and the
14
+ // save-ack refresh waits for a session to end. Every load,
12
15
  // edit, autosave and publish targets ONE locale — the page's own tree in
13
16
  // that locale (spec 2026-09-06 §6); switching flushes the pending save
14
17
  // first.
@@ -17,34 +20,39 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
17
20
  // The pieces live beside it — `useCanvasBridge` (geometry and messages),
18
21
  // `usePageLocale` (switch/add/remove), `CanvasOverlay` and
19
22
  // `overlay-geometry` (the chrome above the iframe), `InsertPicker`,
20
- // `Outline`, `PageSettings`, `NoticePane`.
23
+ // `Outline`, `PageInspector`, `NoticePane`.
21
24
  //
22
25
  // Chrome follows the design system's visual-editor screen: top bar, toned
23
- // canvas around a white page, 300px inspector holding fields then outline.
26
+ // canvas around a white page, 360px inspector holding fields then outline.
24
27
  //
25
28
  // Core code: no next/* imports live here — the bridge is the only piece
26
29
  // that knows about Next, and it ships to the site, not the admin.
27
30
  import { useCallback, useEffect, useMemo, useRef, useState } from "react";
28
31
  import { SHARED_NODE_TYPE } from "../../shared.js";
32
+ import { descriptorAt, getAtPath } from "../field-paths.js";
29
33
  import { FieldWidget } from "../forms/FieldWidget.js";
30
- import { tabsFor } from "../forms/tabs.js";
31
- import { allowedSections, canInsert, fillLockedZones, findNode, insertNode, moveNode, newNodeId, nodeFromSample, patchNode, placementNode, removeNode, zoneOf, } from "../tree-ops.js";
34
+ import { revealField } from "../forms/reveal.js";
35
+ import { tabOf, tabsFor } from "../forms/tabs.js";
36
+ import { allowedSections, canInsert, fillLockedZones, findNode, insertNode, moveNode, moveNodeTo, newNodeId, nodeFromSample, patchNode, patchNodePath, patchPage, placementNode, removeNode, zoneOf, } from "../tree-ops.js";
32
37
  import { useDraftAutosave } from "./autosave.js";
33
38
  import { CanvasOverlay } from "./CanvasOverlay.js";
39
+ import { InlineRichtext } from "./InlineRichtext.js";
34
40
  import { InsertPicker } from "./InsertPicker.js";
35
41
  import { NoticePane } from "./NoticePane.js";
36
42
  import { Outline } from "./Outline.js";
37
- import { PageSettings } from "./PageSettings.js";
43
+ import { PageInspector } from "./PageInspector.js";
38
44
  import { localeFromUrl } from "./locale-url.js";
39
45
  import { plusButtonsFor } from "./overlay-geometry.js";
40
46
  import { useCanvasBridge } from "./useCanvasBridge.js";
47
+ import { useInlineSession } from "./useInlineSession.js";
41
48
  import { usePageLocale } from "./usePageLocale.js";
42
49
  import { BASE } from "../shell/base.js";
43
50
  import { RouteLink } from "../shell/RouteLink.js";
44
51
  import { isFixedNode, localeStatusOf, sourceLocaleOf } from "../shell/pages-tree.js";
45
52
  import { entryTitle } from "../shell/entries-list.js";
46
53
  import { LocaleSwitcher } from "../ui/LocaleSwitcher.js";
47
- import { Banner as BannerBox, Breadcrumb, Button, Divider, IconButton, LinkButton, SaveIndicator, StatusBadge, Tabs, TopBar, } from "../ui/primitives.js";
54
+ import { ResizeHandle } from "../ui/ResizeHandle.js";
55
+ import { Banner as BannerBox, Breadcrumb, Button, IconButton, LinkButton, SaveIndicator, StatusBadge, Tabs, TopBar, } from "../ui/primitives.js";
48
56
  export const withEditorParam = (url) => (url.includes("?") ? `${url}&smoodly-editor=1` : `${url}?smoodly-editor=1`);
49
57
  export function EditorView({ ops, registry, id }) {
50
58
  const [record, setRecord] = useState(null);
@@ -59,6 +67,11 @@ export function EditorView({ ops, registry, id }) {
59
67
  const [parentLocales, setParentLocales] = useState([]);
60
68
  const [selectedId, setSelectedId] = useState(null);
61
69
  const [hoverId, setHoverId] = useState(null);
70
+ const [hoverField, setHoverField] = useState(null);
71
+ const [revealReq, setRevealReq] = useState(null);
72
+ const inspectorRef = useRef(null);
73
+ // The session hook is created after `mutate`; the autosave and the bridge reach it through this ref.
74
+ const inlineRef = useRef(null);
62
75
  const [picker, setPicker] = useState(null);
63
76
  const [tab, setTab] = useState("Content");
64
77
  const [banner, setBanner] = useState(null);
@@ -76,11 +89,12 @@ export function EditorView({ ops, registry, id }) {
76
89
  // flushes first (switchLocale), so the ref and the tree always agree.
77
90
  const treeLocaleRef = useRef(localeFromUrl(registry));
78
91
  // Every edit marks the draft dirty; a debounced saveDraft persists, with
79
- // retry on failure, and the bridge refreshes the iframe on the save ack.
92
+ // retry on failure, and the bridge refreshes the iframe on the save ack —
93
+ // now, or once an inline session ends (spec 2026-09-10 §7).
80
94
  const { saveState, mark, flush } = useDraftAutosave({
81
95
  read: () => treeRef.current,
82
96
  persist: (t) => ops.pages.saveDraft(id, treeLocaleRef.current, t),
83
- onSaved: () => postToCanvas({ source: "smoodly", kind: "refresh" }),
97
+ onSaved: () => inlineRef.current?.onSaved(),
84
98
  });
85
99
  const { locale, adding, switchLocale, doAddLocale, doRemoveLocale } = usePageLocale({
86
100
  registry, ops, id, record, setRecord, flush,
@@ -97,8 +111,15 @@ export function EditorView({ ops, registry, id }) {
97
111
  const iframeSrc = ready ? withEditorParam(siteUrl) : null;
98
112
  // Canvas-originated selection: the node is under the cursor already, so
99
113
  // this path deliberately skips the scroll every other selection does.
100
- const { rects, emptyZones, docH, geometry, postToCanvas } = useCanvasBridge({
101
- iframeRef, iframeSrc, onSelect: setSelectedId, onHover: setHoverId,
114
+ // (`onCanvasSelect` is declared further down; the handler runs at event time.)
115
+ const { rects, emptyZones, fields: fieldRects, docH, geometry, postToCanvas } = useCanvasBridge({
116
+ iframeRef, iframeSrc,
117
+ onSelect: (id, path, x, y) => onCanvasSelect(id, path, x, y),
118
+ onHover: (id, path) => {
119
+ setHoverId(id);
120
+ setHoverField((prev) => (prev && prev.node === id && prev.path === path ? prev : id && path ? { node: id, path } : null));
121
+ },
122
+ onRendered: () => inlineRef.current?.onRendered(),
102
123
  });
103
124
  // ── load: draft mode FIRST, then the record; only then point the iframe
104
125
  // at the site route (an iframe loaded before the cookie exists would
@@ -291,6 +312,71 @@ export function EditorView({ ops, registry, id }) {
291
312
  });
292
313
  mark();
293
314
  }, [mark]);
315
+ // ── inline editing (spec 2026-09-10) ──────────────────────────────────
316
+ const inline = useInlineSession({
317
+ iframeRef, postToCanvas,
318
+ patch: (node, path, value) => {
319
+ mutate((t) => patchNodePath(t, node, path, value));
320
+ // A second element marked with the same field follows the one being typed in.
321
+ if (typeof value === "string")
322
+ inlineRef.current?.writeText(node, path, value);
323
+ },
324
+ });
325
+ inlineRef.current = inline;
326
+ // A different selection ends the session; a new canvas drops everything.
327
+ useEffect(() => { inline.onSelection(selectedId); }, [selectedId]); // eslint-disable-line react-hooks/exhaustive-deps
328
+ useEffect(() => { inline.onCanvasChange(); }, [iframeSrc]); // eslint-disable-line react-hooks/exhaustive-deps
329
+ /** Inspector edits of text fields land in the canvas the same tick (spec §7): every
330
+ * marked text element of the node, by its reported path, from the fields after the patch. */
331
+ const syncTexts = (id, section, fields) => {
332
+ for (const f of fieldRects) {
333
+ if (f.node !== id)
334
+ continue;
335
+ const d = descriptorAt(section, f.path);
336
+ const v = getAtPath(fields, f.path);
337
+ if (d?.type === "text" && typeof v === "string")
338
+ inline.writeText(id, f.path, v);
339
+ }
340
+ };
341
+ /** Scroll the inspector to a field's widget, pulse it, focus it (spec §8). The field's tab first —
342
+ * marks address content fields, and the projection puts a content image on Media. */
343
+ const reveal = (path, section) => setRevealReq((r) => ({ path, tab: tabOf(section, path) ?? "Content", n: (r?.n ?? 0) + 1 }));
344
+ useEffect(() => {
345
+ if (!revealReq)
346
+ return;
347
+ setTab(revealReq.tab);
348
+ const frame = requestAnimationFrame(() => { if (inspectorRef.current)
349
+ revealField(inspectorRef.current, revealReq.path); });
350
+ return () => cancelAnimationFrame(frame);
351
+ }, [revealReq]);
352
+ /** A click in the canvas: on a marked field of the selected section it acts on the field (spec §2), else it selects. */
353
+ const onCanvasSelect = (id, path, x, y) => {
354
+ if (id && id === selectedId && path) {
355
+ onFieldClick(id, path, x, y);
356
+ return;
357
+ }
358
+ setSelectedId(id);
359
+ };
360
+ const onFieldClick = (id, path, x, y) => {
361
+ const node = tree ? findNode(tree, id) : null;
362
+ const section = node ? sectionOf(node.type) : null;
363
+ if (!node || !section)
364
+ return; // a shared placement is edited under Globals — nothing inline here
365
+ const descriptor = descriptorAt(section, path);
366
+ if (!descriptor) {
367
+ if (process.env.NODE_ENV !== "production") {
368
+ console.warn(`smoodly: "${section.name}" has no field at "${path}" — check its data-smd-field / data-smd-item marks.`);
369
+ }
370
+ return;
371
+ }
372
+ if (inline.session && inline.session.node === id && inline.session.path === path)
373
+ return;
374
+ const kind = descriptor.type === "text" ? "text" : descriptor.type === "richtext" ? "richtext" : null;
375
+ if (kind && inline.start({ node: id, path, kind }, { x, y }))
376
+ return;
377
+ // Any other field, or a text mark on a container: the inspector reveals its widget.
378
+ reveal(path, section);
379
+ };
294
380
  // A zone bound to a shared item needs the item's row id (ops.shared.list),
295
381
  // which can land after the draft did. Fill then; nothing else changes.
296
382
  useEffect(() => {
@@ -350,7 +436,10 @@ export function EditorView({ ops, registry, id }) {
350
436
  setSelectedId((current) => (current === id ? null : current));
351
437
  };
352
438
  const doMove = (id, dir) => mutate((t) => moveNode(t, id, dir));
439
+ const doMoveTo = (id, index) => mutate((t) => moveNodeTo(t, id, index));
353
440
  const doPatch = (id, ns, key, value) => mutate((t) => patchNode(t, id, ns, key, value));
441
+ /** A page-level value (page fields, SEO meta) — same dirty/debounce path as a node's (spec 2026-09-10 page fields §5). */
442
+ const doPatchPage = (ns, key, value) => mutate((t) => patchPage(t, ns, key, value));
354
443
  // ── publish ──────────────────────────────────────────────────────────
355
444
  const doPublish = async () => {
356
445
  setBanner(null);
@@ -396,7 +485,7 @@ export function EditorView({ ops, registry, id }) {
396
485
  return _jsx(NoticePane, { children: _jsx(BannerBox, { tone: "error", children: loadError }) });
397
486
  }
398
487
  if (folder) {
399
- return (_jsx(NoticePane, { children: _jsx(BannerBox, { tone: "warn", children: "This is a folder \u2014 it has a title and a place in the tree, but no page. Use \u201CAdd page here\u201D in the Pages list to give it one." }) }));
488
+ return (_jsx(NoticePane, { children: _jsx(BannerBox, { tone: "warn", children: "This is a path \u2014 it has a title and a place in the tree, but no page. Use \u201CAdd page here\u201D in the Pages list to give it one." }) }));
400
489
  }
401
490
  if (record && !record.locales[locale]) {
402
491
  const from = sourceLocaleOf(record, registry);
@@ -410,7 +499,10 @@ export function EditorView({ ops, registry, id }) {
410
499
  const rectById = new Map(rects.map((r) => [r.id, r]));
411
500
  const selRect = selected ? rectById.get(selected.id) : undefined;
412
501
  const hoverRect = hoverId && hoverId !== selectedId ? rectById.get(hoverId) : undefined;
413
- const blockCount = tree ? Object.values(tree.zones).reduce((n, list) => n + list.length, 0) : 0;
502
+ const fieldRectOf = (f) => f ? fieldRects.find((r) => r.node === f.node && r.path === f.path) : undefined;
503
+ const fieldHover = hoverField && hoverField.node === selectedId ? fieldRectOf(hoverField) : undefined;
504
+ const editing = fieldRectOf(inline.session);
505
+ const pageWidth = rects.reduce((w, r) => Math.max(w, r.x + r.w), 320);
414
506
  const selection = selected && selRect && (selectedSection || selectedShared)
415
507
  ? {
416
508
  node: selected, rect: selRect,
@@ -436,21 +528,36 @@ export function EditorView({ ops, registry, id }) {
436
528
  const tabs = selectedSection ? tabsFor(selectedSection) : [];
437
529
  const activeTab = tabs.some((t) => t.tab === tab) ? tab : tabs[0]?.tab;
438
530
  const entries = tabs.find((t) => t.tab === activeTab)?.entries ?? [];
439
- return (_jsxs(_Fragment, { children: [_jsx(TopBar, { left: _jsx(Breadcrumb, { backHref: `${BASE}/pages`, items: [{ label: "Pages", href: `${BASE}/pages` }, { label }] }), right: _jsxs(_Fragment, { children: [_jsx(SaveIndicator, { state: saveState }), ready && (_jsx(LinkButton, { href: siteUrl, target: "_blank", rel: "noreferrer", title: "Opens the site route. While the admin's draft-mode cookie is on, this shows the DRAFT \u2014 open it in a private window to see what visitors see.", children: "View live" })), record?.locales[locale] && (_jsx(StatusBadge, { status: localeStatusOf(record.locales[locale]) })), _jsx(Button, { variant: published ? "ok" : "primary", onClick: () => { void doPublish(); }, children: published ? "Published ✓" : "Publish" })] }) }), _jsxs("div", { className: "sm-editor", children: [_jsxs("div", { className: "sm-canvas", children: [_jsxs("div", { className: "sm-canvas__meta", children: [_jsxs("span", { style: { display: "flex", alignItems: "center", gap: 10 }, children: [_jsx("span", { children: label }), registry.locales.supported.length > 1 ? (_jsx(LocaleSwitcher, { registry: registry, present: record ? registry.locales.supported.filter((l) => record.locales[l]) : [locale], parentLocales: parentLocales, value: locale, adding: adding, onSwitch: (l) => { void switchLocale(l); }, onAdd: (l) => { void doAddLocale(l); } })) : (_jsxs("span", { children: ["\u00B7 ", locale] }))] }), _jsxs("span", { children: [blockCount, " BLOCK", blockCount === 1 ? "" : "S"] })] }), (banner || geometry === "missing") && (_jsxs("div", { style: { display: "flex", flexDirection: "column", gap: 8, padding: "0 20px 10px" }, children: [banner && (_jsxs(BannerBox, { tone: "error", onDismiss: () => setBanner(null), children: [_jsx("p", { children: banner.text }), banner.fields && banner.fields.length > 0 && (_jsx("ul", { className: "sm-mono", style: { margin: "6px 0 0", paddingLeft: 18, fontSize: "var(--text-sm)" }, children: banner.fields.map((f) => _jsxs("li", { children: [f.field, ": ", f.message] }, f.field)) }))] })), geometry === "missing" && (_jsx(BannerBox, { tone: "warn", children: "Selection unavailable \u2014 the page didn't report geometry. The outline still works." }))] })), _jsx("div", { className: "sm-canvas__scroll", ref: scrollerRef, children: _jsxs("div", { className: "sm-page", style: { overflow: "hidden" }, children: [iframeSrc ? (_jsx("iframe", { ref: iframeRef, src: iframeSrc, title: "Page canvas", style: { width: "100%", height: docH, border: 0, display: "block" } })) : (_jsx("div", { className: "sm-mono", style: {
440
- height: 240, display: "grid", placeItems: "center",
441
- color: "var(--gray-500)", fontSize: "var(--text-xs)", letterSpacing: ".04em",
442
- }, children: "LOADING CANVAS\u2026" })), _jsx(CanvasOverlay, { emptyZones: emptyZones, hoverRect: hoverRect, selection: selection, plusButtons: plusButtons, onPlus: (p, x, y) => setPicker({ zone: p.zone, index: p.index, x: x - 150, y: y + 12 }), onMove: doMove, onDuplicate: doDuplicate, onRemove: doRemove })] }) })] }), _jsxs("aside", { className: "sm-inspector", children: [selected && (selectedSection || selectedShared) ? (_jsxs(_Fragment, { children: [_jsxs("div", { className: "sm-inspector__head", children: [_jsxs("span", { style: { display: "flex", alignItems: "center", gap: 8, minWidth: 0 }, children: [_jsx("span", { className: "sm-blockglyph" }), _jsx("span", { className: "sm-title", style: { overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }, children: selectedSection?.title ?? selectedShared?.title })] }), _jsx(IconButton, { label: "Clear selection", onClick: () => setSelectedId(null), children: "\u00D7" })] }), tabs.length > 0 && (_jsx(Tabs, { tabs: tabs.map((t) => t.tab), value: activeTab, onChange: (t) => setTab(t) }))] })) : (_jsx("div", { className: "sm-inspector__head", children: _jsx("span", { className: "sm-label", children: "Nothing selected \u2014 page settings below" }) })), _jsxs("div", { className: "sm-inspector__body", children: [!selected && record && (_jsxs(_Fragment, { children: [_jsx(PageSettings, { record: record, locale: locale, registry: registry, path: paths[locale], fixed: isFixedNode(record, registry), onRename: onRename, onRemoveLocale: Object.keys(record.locales).length > 1 &&
443
- !isFixedNode(record, registry) &&
444
- !(record.parentId === null && record.locales[registry.locales.default]?.slug === registry.homePageSlug)
445
- ? () => { void doRemoveLocale(); }
446
- : undefined }), _jsx(Divider, { bleed: 14 })] })), selected && selectedSection && entries.map(({ key, ns, descriptor }) => (_jsx(FieldWidget, { fieldKey: key, descriptor: descriptor, value: selected[ns]?.[key], refOptions: refOptionsFor(refOptions, refTargets, selectedSection, ns, key, descriptor), onChange: (value) => doPatch(selected.id, ns, key, value) }, `${selected.id}.${ns}.${key}`))), selected && selectedShared && (_jsxs("div", { style: { display: "flex", flexDirection: "column", gap: 10 }, children: [_jsx("p", { className: "sm-hint", children: "This is shared content. Editing it changes every page it is placed on." }), _jsx(RouteLink, { className: "sm-nav-item", href: `${BASE}/shared/${encodeURIComponent(selectedShared.name)}?locale=${encodeURIComponent(locale)}`, children: "Edit shared item \u2192" })] })), _jsx(Divider, { bleed: 14 }), _jsx(Outline, { selectedId: selectedId, onSelect: select, onMove: doMove, onRemove: doRemove, zones: zoneOrder.map((zone) => ({
447
- zone, locked: policyOf(zone)?.kind === "locked", canTake: zoneCanTake(zone),
448
- nodes: nodesIn(zone).map((node) => ({ node, label: labelOf(node) })),
449
- })), onAddFirst: (zone) => {
450
- const first = allowedSections(policyOf(zone), registry.sections)[0];
451
- if (first)
452
- doInsert(zone, 0, first);
453
- } })] })] })] }), picker && (_jsx(InsertPicker, { at: picker, policy: policyOf(picker.zone), registry: registry, sharedIds: sharedIds ?? {}, onInsert: (section) => doInsert(picker.zone, picker.index, section), onInsertShared: (item) => doInsertShared(picker.zone, picker.index, item), onClose: () => setPicker(null) }))] }));
531
+ return (_jsxs(_Fragment, { children: [_jsx(TopBar, { left: _jsx(_Fragment, { children: _jsx(Breadcrumb, { backHref: `${BASE}/pages`, items: [
532
+ {
533
+ label: "Pages", href: `${BASE}/pages`,
534
+ trailing: registry.locales.supported.length > 1 && (_jsx(LocaleSwitcher, { registry: registry, present: record ? registry.locales.supported.filter((l) => record.locales[l]) : [locale], parentLocales: parentLocales, value: locale, adding: adding, onSwitch: (l) => { void switchLocale(l); }, onAdd: (l) => { void doAddLocale(l); } })),
535
+ },
536
+ { label },
537
+ ] }) }), right: _jsxs(_Fragment, { children: [_jsx(SaveIndicator, { state: saveState }), ready && (_jsx(LinkButton, { href: siteUrl, target: "_blank", rel: "noreferrer", title: "Opens the site route. While the admin's draft-mode cookie is on, this shows the DRAFT \u2014 open it in a private window to see what visitors see.", children: "View live" })), record?.locales[locale] && (_jsx(StatusBadge, { status: localeStatusOf(record.locales[locale]) })), _jsx(Button, { variant: published ? "ok" : "primary", onClick: () => { void doPublish(); }, children: published ? "Published ✓" : "Publish" })] }) }), _jsxs("div", { className: "sm-editor", children: [_jsxs("div", { className: "sm-canvas", children: [(banner || geometry === "missing") && (_jsxs("div", { style: { display: "flex", flexDirection: "column", gap: 8, padding: "0 20px 10px" }, children: [banner && (_jsxs(BannerBox, { tone: "error", onDismiss: () => setBanner(null), children: [_jsx("p", { children: banner.text }), banner.fields && banner.fields.length > 0 && (_jsx("ul", { className: "sm-mono", style: { margin: "6px 0 0", paddingLeft: 18, fontSize: "var(--text-sm)" }, children: banner.fields.map((f) => _jsxs("li", { children: [f.field, ": ", f.message] }, f.field)) }))] })), geometry === "missing" && (_jsx(BannerBox, { tone: "warn", children: "Selection unavailable \u2014 the page didn't report geometry. The outline still works." }))] })), _jsx("div", { className: "sm-canvas__scroll", ref: scrollerRef, children: _jsxs("div", { className: "sm-page", style: { overflow: "hidden" }, children: [iframeSrc ? (_jsx("iframe", { ref: iframeRef, src: iframeSrc, title: "Page canvas", style: { width: "100%", height: docH, border: 0, display: "block" } })) : (_jsx("div", { className: "sm-canvas__loading", children: "Loading canvas\u2026" })), _jsx(CanvasOverlay, { emptyZones: emptyZones, hoverRect: hoverRect, selection: selection, fieldHover: fieldHover, editing: editing, plusButtons: plusButtons, onPlus: (p, x, y) => setPicker({ zone: p.zone, index: p.index, x: x - 150, y: y + 12 }), onMove: doMove, onDuplicate: doDuplicate, onRemove: doRemove }), inline.richtext && tree && (() => {
538
+ const { session } = inline.richtext;
539
+ const node = findNode(tree, session.node);
540
+ const section = node ? sectionOf(node.type) : null;
541
+ if (!node || !section)
542
+ return null;
543
+ return (_jsx(InlineRichtext, { mount: inline.richtext, pageWidth: pageWidth, value: getAtPath(node.fields, session.path), toolset: descriptorAt(section, session.path)?.toolset, onChange: (doc) => { inline.markChanged(); mutate((t) => patchNodePath(t, session.node, session.path, doc)); }, onEscape: inline.end }));
544
+ })()] }) })] }), _jsx(ResizeHandle, {}), _jsxs("aside", { className: "sm-inspector", ref: inspectorRef, children: [_jsxs("div", { className: "sm-card", children: [selected && (selectedSection || selectedShared) ? (_jsxs(_Fragment, { children: [_jsxs("div", { className: "sm-inspector__head", children: [_jsxs("span", { style: { display: "flex", alignItems: "center", gap: 8, minWidth: 0 }, children: [_jsx("span", { className: "sm-blockglyph" }), _jsx("span", { className: "sm-title", style: { overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }, children: selectedSection?.title ?? selectedShared?.title })] }), _jsx(IconButton, { label: "Clear selection", onClick: () => setSelectedId(null), children: "\u00D7" })] }), tabs.length > 0 && (_jsx(Tabs, { tabs: tabs.map((t) => t.tab), value: activeTab, onChange: (t) => setTab(t) }))] })) : record ? (_jsx(PageInspector, { record: record, locale: locale, registry: registry, tree: tree, path: paths[locale], fixed: isFixedNode(record, registry), onRename: onRename, onPatch: doPatchPage, onRemoveLocale: Object.keys(record.locales).length > 1 &&
545
+ !isFixedNode(record, registry) &&
546
+ !(record.parentId === null && record.locales[registry.locales.default]?.slug === registry.homePageSlug)
547
+ ? () => { void doRemoveLocale(); }
548
+ : undefined })) : null, selected && (selectedSection || selectedShared) && (_jsxs("div", { className: "sm-inspector__body", children: [selectedSection && entries.map(({ key, ns, descriptor }) => (_jsx(FieldWidget, { fieldKey: key, descriptor: descriptor, value: selected[ns]?.[key], refOptions: refOptionsFor(refOptions, refTargets, selectedSection, ns, key, descriptor), inlinePath: ns === "fields" && inline.session?.node === selected.id ? inline.session.path : undefined, onEditHere: () => { const p = inline.session?.path; inline.end(); if (p)
549
+ reveal(p, selectedSection); }, onChange: (value) => {
550
+ doPatch(selected.id, ns, key, value);
551
+ if (ns === "fields")
552
+ syncTexts(selected.id, selectedSection, { ...selected.fields, [key]: value });
553
+ } }, `${selected.id}.${ns}.${key}`))), selectedShared && (_jsxs("div", { style: { display: "flex", flexDirection: "column", gap: 10 }, children: [_jsx("p", { className: "sm-hint", children: "This is shared content. Editing it changes every page it is placed on." }), _jsx(RouteLink, { className: "sm-nav-item", href: `${BASE}/shared/${encodeURIComponent(selectedShared.name)}?locale=${encodeURIComponent(locale)}`, children: "Edit shared item \u2192" })] }))] }))] }), _jsx("div", { className: "sm-card", children: _jsx(Outline, { selectedId: selectedId, onSelect: select, onMove: doMove, onMoveTo: doMoveTo, onRemove: doRemove, zones: zoneOrder.map((zone) => ({
554
+ zone, locked: policyOf(zone)?.kind === "locked", canTake: zoneCanTake(zone),
555
+ nodes: nodesIn(zone).map((node) => ({ node, label: labelOf(node) })),
556
+ })), onAddFirst: (zone) => {
557
+ const first = allowedSections(policyOf(zone), registry.sections)[0];
558
+ if (first)
559
+ doInsert(zone, 0, first);
560
+ } }) })] })] }), picker && (_jsx(InsertPicker, { at: picker, policy: policyOf(picker.zone), registry: registry, sharedIds: sharedIds ?? {}, onInsert: (section) => doInsert(picker.zone, picker.index, section), onInsertShared: (item) => doInsertShared(picker.zone, picker.index, item), onClose: () => setPicker(null) }))] }));
454
561
  }
455
562
  /** The selected node's ref field (if any) resolves to its target
456
563
  * collection's loaded options — the EntryForm shape, section-scoped. */
@@ -0,0 +1,16 @@
1
+ import type { ReactNode } from "react";
2
+ import type { Allowed } from "../../toolset.ts";
3
+ import { type ActiveState, type ToolbarCommand } from "../forms/RichtextToolbar.tsx";
4
+ export declare const FLOATBAR_W = 340;
5
+ export declare function FloatingToolbar({ x, top, bottom, pageWidth, allowed, active, canInsert, onCommand, popover }: {
6
+ /** The caret's left, top and bottom in overlay coordinates. */
7
+ x: number;
8
+ top: number;
9
+ bottom: number;
10
+ pageWidth: number;
11
+ allowed: Allowed;
12
+ active: ActiveState;
13
+ canInsert: boolean;
14
+ onCommand(command: ToolbarCommand): void;
15
+ popover?: ReactNode;
16
+ }): import("react").JSX.Element;
@@ -0,0 +1,14 @@
1
+ "use client";
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { RichtextToolbar } from "../forms/RichtextToolbar.js";
4
+ export const FLOATBAR_W = 340;
5
+ const GAP = 10;
6
+ const FLIP_AT = 56;
7
+ export function FloatingToolbar({ x, top, bottom, pageWidth, allowed, active, canInsert, onCommand, popover }) {
8
+ const left = Math.max(8, Math.min(x, pageWidth - FLOATBAR_W - 8));
9
+ const below = top < FLIP_AT;
10
+ const style = below
11
+ ? { left, top: bottom + 8 }
12
+ : { left, top: top - GAP, transform: "translateY(-100%)" };
13
+ return (_jsxs("div", { className: "sm-floatbar", style: style, children: [_jsx(RichtextToolbar, { allowed: allowed, active: active, canInsert: canInsert, onCommand: onCommand }), popover] }));
14
+ }
@@ -0,0 +1,9 @@
1
+ import type { RichtextMount } from "./useInlineSession.ts";
2
+ export declare function InlineRichtext({ mount, value, toolset, pageWidth, onChange, onEscape }: {
3
+ mount: RichtextMount;
4
+ value: unknown;
5
+ toolset: unknown;
6
+ pageWidth: number;
7
+ onChange(doc: unknown): void;
8
+ onEscape(): void;
9
+ }): import("react").JSX.Element;
@@ -0,0 +1,50 @@
1
+ "use client";
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ // A richtext session in the canvas (spec 2026-09-10 §5): the TipTap
4
+ // editor portalled into the root useInlineSession appended inside the
5
+ // marked container — the same extensions, gating and node views as the
6
+ // sidebar widget, through useRichtextEditor — and the chrome in the
7
+ // admin: the floating toolbar above the selection, the slash menu at the
8
+ // caret, the media picker. The form context's `canvas` flag gives the
9
+ // cards inside the admin's classes and the light theme. Rendered inside
10
+ // the page box, so the toolbar's coordinates are the caret's.
11
+ import { useEffect, useMemo } from "react";
12
+ import { createPortal } from "react-dom";
13
+ import { EditorContent, useEditorState } from "@tiptap/react";
14
+ import { MediaPicker } from "../forms/MediaPicker.js";
15
+ import { RichtextInsertMenu } from "../forms/RichtextInsertMenu.js";
16
+ import { RichtextLinkRow, RichtextMenu } from "../forms/RichtextPopovers.js";
17
+ import { RichtextFormProvider } from "../forms/richtext-form-context.js";
18
+ import { useRichtextEditor } from "../forms/useRichtextEditor.js";
19
+ import { FloatingToolbar } from "./FloatingToolbar.js";
20
+ export function InlineRichtext({ mount, value, toolset, pageWidth, onChange, onEscape }) {
21
+ const rt = useRichtextEditor({ value, toolset, onChange, contentClass: "sm-inline-richtext", onEscape });
22
+ const { editor } = rt;
23
+ const formInfo = useMemo(() => ({ path: mount.session.path, canvas: true }), [mount.session.path]);
24
+ // The caret where the click landed, once the editor exists.
25
+ useEffect(() => {
26
+ if (!editor)
27
+ return;
28
+ const pos = mount.at ? editor.view.posAtCoords({ left: mount.at.x, top: mount.at.y })?.pos : undefined;
29
+ editor.commands.focus(pos ?? "end");
30
+ }, [editor, mount.at]);
31
+ // The container's flex/grid layout lands on the element that holds the blocks — the
32
+ // ProseMirror root — and every wrapper between it and the mount root is made
33
+ // transparent, so a flex column's gap still separates the paragraphs (spec §5).
34
+ useEffect(() => {
35
+ if (!editor)
36
+ return;
37
+ const dom = editor.view.dom;
38
+ for (let el = dom.parentElement; el && el !== mount.root; el = el.parentElement)
39
+ el.style.display = "contents";
40
+ for (const [prop, value] of Object.entries(mount.layout))
41
+ dom.style.setProperty(prop, value);
42
+ }, [editor, mount.root, mount.layout]);
43
+ // Read-only while the refresh that carries its edits renders (spec §5).
44
+ useEffect(() => { editor?.setEditable(!mount.closing); }, [editor, mount.closing]);
45
+ // The selection's start, in iframe document coordinates = overlay coordinates.
46
+ const from = useEditorState({ editor, selector: ({ editor }) => editor?.state.selection.from ?? null });
47
+ const caret = editor && from !== null && editor.view.dom.isConnected ? editor.view.coordsAtPos(from) : null;
48
+ const slashRect = rt.slash?.state.rect ?? null;
49
+ return (_jsxs(RichtextFormProvider, { value: formInfo, children: [createPortal(_jsx(EditorContent, { editor: editor }), mount.root), !mount.closing && caret && (_jsx(FloatingToolbar, { x: caret.left, top: caret.top, bottom: caret.bottom, pageWidth: pageWidth, allowed: rt.allowed, active: rt.active, canInsert: rt.canInsert, onCommand: rt.run, popover: rt.menu.open ? _jsx(RichtextMenu, { menu: rt.menu }) : rt.link.open ? _jsx(RichtextLinkRow, { link: rt.link }) : null })), !mount.closing && rt.slash && (_jsx("div", { className: "sm-richtext__slash", style: { left: slashRect?.left ?? 0, top: (slashRect?.bottom ?? 0) + 4, pointerEvents: "auto" }, children: _jsx(RichtextInsertMenu, { items: rt.slash.state.items, active: rt.slash.active, onPick: (item) => rt.slash?.state.pick(item) }) })), rt.picker.open && _jsx(MediaPicker, { kinds: ["image"], onPick: rt.picker.pick, onClose: rt.picker.close })] }));
50
+ }
@@ -1,18 +1,24 @@
1
1
  import type { TreeNode } from "../../render.tsx";
2
- export declare function Outline({ zones, selectedId, onSelect, onAddFirst, onMove, onRemove }: {
3
- zones: {
4
- zone: string;
5
- locked: boolean;
6
- canTake: boolean;
7
- nodes: {
8
- node: TreeNode;
9
- label: string;
10
- }[];
2
+ type ZoneRows = {
3
+ zone: string;
4
+ locked: boolean;
5
+ canTake: boolean;
6
+ nodes: {
7
+ node: TreeNode;
8
+ label: string;
11
9
  }[];
10
+ };
11
+ type Handlers = {
12
12
  selectedId: string | null;
13
13
  onSelect(id: string): void;
14
14
  /** The empty row of an insertable zone was clicked. */
15
15
  onAddFirst(zone: string): void;
16
16
  onMove(id: string, dir: -1 | 1): void;
17
+ /** The drop and the grip's arrow keys: `index` in the resulting order. */
18
+ onMoveTo(id: string, index: number): void;
17
19
  onRemove(id: string): void;
18
- }): import("react").JSX.Element;
20
+ };
21
+ export declare function Outline({ zones, ...handlers }: {
22
+ zones: ZoneRows[];
23
+ } & Handlers): import("react").JSX.Element;
24
+ export {};
@@ -1,12 +1,15 @@
1
1
  "use client";
2
- import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
3
- import { IconButton, SectionLabel, cx } from "../ui/primitives.js";
4
- export function Outline({ zones, selectedId, onSelect, onAddFirst, onMove, onRemove }) {
5
- return (_jsxs(_Fragment, { children: [_jsx(SectionLabel, { children: "Outline" }), zones.map(({ zone, locked, canTake, nodes }) => (_jsxs("div", { style: { display: "flex", flexDirection: "column", gap: 4 }, children: [_jsxs(SectionLabel, { mono: true, children: [zone, locked && " · locked"] }), nodes.length === 0 && (_jsxs("div", { className: cx("sm-outline-row", "sm-outline-row--empty", canTake && "sm-outline-row--clickable"), onClick: () => { if (canTake)
6
- onAddFirst(zone); }, children: [_jsx("span", { className: "sm-outline-row__label", children: "Empty" }), canTake && _jsx("span", { style: { color: "var(--accent)", fontWeight: 600 }, children: "+ Add block" })] })), nodes.map(({ node, label }, i) => {
7
- if (locked) {
8
- return (_jsxs("div", { className: "sm-outline-row sm-outline-row--locked sm-outline-row--clickable", onClick: () => onSelect(node.id), children: [_jsx("span", { className: "sm-outline-row__label", children: label }), _jsx("span", { style: { fontSize: "var(--text-xs)", fontWeight: 600, letterSpacing: ".06em" }, children: "LOCKED" })] }, node.id));
9
- }
10
- return (_jsxs("div", { onClick: () => onSelect(node.id), className: cx("sm-outline-row", "sm-outline-row--clickable", node.id === selectedId && "sm-outline-row--selected"), children: [_jsx("span", { className: "sm-outline-row__label", children: label }), _jsxs("span", { style: { display: "flex", gap: 2, flex: "none" }, children: [_jsx(IconButton, { label: "Move up", size: 22, disabled: i === 0, onClick: (e) => { e.stopPropagation(); onMove(node.id, -1); }, children: "\u2191" }), _jsx(IconButton, { label: "Move down", size: 22, disabled: i === nodes.length - 1, onClick: (e) => { e.stopPropagation(); onMove(node.id, 1); }, children: "\u2193" }), _jsx(IconButton, { label: "Remove", size: 22, onClick: (e) => { e.stopPropagation(); onRemove(node.id); }, children: "\u00D7" })] })] }, node.id));
11
- })] }, zone)))] }));
2
+ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { Grip, IconButton, SectionLabel, cx } from "../ui/primitives.js";
4
+ import { useSortable } from "../ui/useSortable.js";
5
+ export function Outline({ zones, ...handlers }) {
6
+ return (_jsxs(_Fragment, { children: [_jsx(SectionLabel, { children: "Outline" }), zones.map((z) => _jsx(OutlineZone, { ...z, ...handlers }, z.zone))] }));
7
+ }
8
+ function OutlineZone({ zone, locked, canTake, nodes, selectedId, onSelect, onAddFirst, onMove, onMoveTo, onRemove }) {
9
+ const sortable = useSortable({
10
+ count: locked ? 0 : nodes.length,
11
+ onDrop: (from, to) => onMoveTo(nodes[from].node.id, to),
12
+ });
13
+ return (_jsxs("div", { style: { display: "flex", flexDirection: "column", gap: 4 }, children: [_jsxs(SectionLabel, { mono: true, children: [zone, locked && " · locked"] }), nodes.length === 0 && (_jsxs("div", { className: cx("sm-outline-row", "sm-outline-row--empty", canTake && "sm-outline-row--clickable"), onClick: () => { if (canTake)
14
+ onAddFirst(zone); }, children: [_jsx("span", { className: "sm-outline-row__label", children: "Empty" }), canTake && _jsx("span", { className: "sm-outline-row__add", children: "+ Add" })] })), locked && nodes.map(({ node, label }) => (_jsxs("div", { className: "sm-outline-row sm-outline-row--locked sm-outline-row--clickable", onClick: () => onSelect(node.id), children: [_jsx("span", { className: "sm-outline-row__label", children: label }), _jsx("span", { className: "sm-outline-row__note", children: "Locked" })] }, node.id))), !locked && nodes.length > 0 && (_jsx("div", { ref: sortable.containerRef, style: { display: "flex", flexDirection: "column", gap: 4 }, children: nodes.map(({ node, label }, i) => (_jsxs("div", { onClick: () => onSelect(node.id), style: sortable.rowStyle(i), className: cx("sm-outline-row", "sm-outline-row--clickable", "sm-outline-row--grip", node.id === selectedId && "sm-outline-row--selected", sortable.rowClass(i)), children: [_jsx(Grip, { ...sortable.handleProps(i) }), _jsx("span", { className: "sm-outline-row__label", children: label }), _jsxs("span", { style: { display: "flex", gap: 2, flex: "none" }, children: [_jsx(IconButton, { label: "Move up", size: 22, disabled: i === 0, onClick: (e) => { e.stopPropagation(); onMove(node.id, -1); }, children: "\u2191" }), _jsx(IconButton, { label: "Move down", size: 22, disabled: i === nodes.length - 1, onClick: (e) => { e.stopPropagation(); onMove(node.id, 1); }, children: "\u2193" }), _jsx(IconButton, { label: "Remove", size: 22, onClick: (e) => { e.stopPropagation(); onRemove(node.id); }, children: "\u00D7" })] })] }, node.id))) }))] }));
12
15
  }
@@ -0,0 +1,21 @@
1
+ import type { PageTree } from "../../render.tsx";
2
+ import type { PageRecord } from "../../store.ts";
3
+ import type { AdminPageTemplate, AdminRegistry } from "../serialize.ts";
4
+ export type PageTab = "SEO & Social" | "Settings";
5
+ export declare function pageTabsFor(template: AdminPageTemplate | undefined): PageTab[];
6
+ export declare function PageInspector({ record, locale, registry, tree, path, fixed, onRename, onRemoveLocale, onPatch }: {
7
+ record: PageRecord;
8
+ locale: string;
9
+ registry: AdminRegistry;
10
+ tree: PageTree | null;
11
+ /** The page's path in this locale (the `paths` index). */
12
+ path: string | undefined;
13
+ fixed: boolean;
14
+ onRename(patch: {
15
+ title?: string;
16
+ slug?: string;
17
+ }): Promise<string | null>;
18
+ /** Absent when the locale cannot be removed: a fixed node, the site root, or the page's last locale. */
19
+ onRemoveLocale?: () => void;
20
+ onPatch(ns: "fields" | "meta", key: string, value: unknown): void;
21
+ }): import("react").JSX.Element;
@@ -0,0 +1,24 @@
1
+ "use client";
2
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
3
+ // The inspector with nothing selected (spec 2026-09-10 page fields §5):
4
+ // the page's title as the head, then SEO & Social (first and default when
5
+ // the template enables it) and Settings — title, slug, the template's
6
+ // page fields, remove from locale. Page tabs keep their own state and
7
+ // reset when the page changes.
8
+ import { useEffect, useState } from "react";
9
+ import { FieldWidget } from "../forms/FieldWidget.js";
10
+ import { Button, Tabs } from "../ui/primitives.js";
11
+ import { PageSettings } from "./PageSettings.js";
12
+ import { SeoSettings } from "./SeoSettings.js";
13
+ export function pageTabsFor(template) {
14
+ return template?.seo ? ["SEO & Social", "Settings"] : ["Settings"];
15
+ }
16
+ export function PageInspector({ record, locale, registry, tree, path, fixed, onRename, onRemoveLocale, onPatch }) {
17
+ const template = registry.pages.find((p) => p.name === record.template);
18
+ const tabs = pageTabsFor(template);
19
+ const [tab, setTab] = useState(tabs[0]);
20
+ useEffect(() => { setTab(pageTabsFor(template)[0]); }, [record.id]); // eslint-disable-line react-hooks/exhaustive-deps
21
+ const active = tabs.includes(tab) ? tab : tabs[0];
22
+ const title = record.locales[locale]?.title ?? "";
23
+ return (_jsxs(_Fragment, { children: [_jsx("div", { className: "sm-inspector__head", children: _jsxs("span", { style: { display: "flex", alignItems: "center", gap: 8, minWidth: 0 }, children: [_jsx("span", { className: "sm-pageglyph" }), _jsx("span", { className: "sm-title", style: { overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }, children: title })] }) }), tabs.length > 1 && _jsx(Tabs, { tabs: tabs, value: active, onChange: setTab }), _jsx("div", { className: "sm-inspector__body", children: active === "SEO & Social" ? (_jsx(SeoSettings, { meta: tree?.meta ?? {}, onPatch: (key, value) => onPatch("meta", key, value) })) : (_jsxs("div", { style: { display: "flex", flexDirection: "column", gap: 14 }, children: [_jsx(PageSettings, { record: record, locale: locale, registry: registry, path: path, fixed: fixed, onRename: onRename }), Object.entries(template?.fields ?? {}).map(([key, descriptor]) => (_jsx(FieldWidget, { fieldKey: key, descriptor: descriptor, value: tree?.fields?.[key], onChange: (value) => onPatch("fields", key, value) }, key))), onRemoveLocale && _jsxs(Button, { variant: "secondary", onClick: onRemoveLocale, children: ["Remove from ", locale] })] })) })] }));
24
+ }
@@ -1,6 +1,6 @@
1
1
  import type { PageRecord } from "../../store.ts";
2
2
  import type { AdminRegistry } from "../serialize.ts";
3
- export declare function PageSettings({ record, locale, registry, path, fixed, onRename, onRemoveLocale }: {
3
+ export declare function PageSettings({ record, locale, registry, path, fixed, onRename }: {
4
4
  record: PageRecord;
5
5
  locale: string;
6
6
  registry: AdminRegistry;
@@ -12,6 +12,4 @@ export declare function PageSettings({ record, locale, registry, path, fixed, on
12
12
  title?: string;
13
13
  slug?: string;
14
14
  }): Promise<string | null>;
15
- /** Absent when the locale cannot be removed: a fixed node, the site root, or the page's last locale. */
16
- onRemoveLocale?: () => void;
17
15
  }): import("react").JSX.Element | null;
@@ -3,12 +3,13 @@ import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
3
3
  // Page settings (spec 2026-09-05 §7; per locale since spec 2026-09-06
4
4
  // §6): the ACTIVE locale's title and slug, saved through pages.rename,
5
5
  // and "Remove from this locale". The switcher in the canvas meta row
6
- // selects the locale, so there are no per-locale tabs here. SEO/meta fields are OQ
7
- // #17's own design pass. Rendered in the inspector while no block is
8
- // selected.
6
+ // selects the locale, so there are no per-locale tabs here. Rendered on
7
+ // the Settings tab of PageInspector while no block is selected; the SEO
8
+ // tab and the remove-from-locale button live there (spec 2026-09-10 page
9
+ // fields §5).
9
10
  import { useEffect, useState } from "react";
10
- import { Button, Field, SectionLabel } from "../ui/primitives.js";
11
- export function PageSettings({ record, locale, registry, path, fixed, onRename, onRemoveLocale }) {
11
+ import { Field, SectionLabel } from "../ui/primitives.js";
12
+ export function PageSettings({ record, locale, registry, path, fixed, onRename }) {
12
13
  const row = record.locales[locale];
13
14
  const [draft, setDraft] = useState({ title: row?.title ?? "", slug: row?.slug ?? "" });
14
15
  const [error, setError] = useState(null);
@@ -36,5 +37,5 @@ export function PageSettings({ record, locale, registry, path, fixed, onRename,
36
37
  void commit(key);
37
38
  }
38
39
  };
39
- return (_jsxs("div", { style: { display: "flex", flexDirection: "column", gap: 14 }, children: [_jsxs(SectionLabel, { children: ["Page \u00B7 ", locale] }), fixed && _jsx("span", { className: "sm-hint", children: "Owned by code \u2014 change it in the registration." }), error && _jsx("span", { className: "sm-error", children: error }), _jsx(Field, { label: "Title", children: _jsx("input", { className: "sm-input", disabled: fixed, value: draft.title, onChange: (e) => setDraft((d) => ({ ...d, title: e.target.value })), onBlur: () => void commit("title"), onKeyDown: onKey("title") }) }), _jsx(Field, { label: "Slug", hint: path ?? "", children: _jsx("input", { className: "sm-input sm-input--mono", disabled: fixed || isHome, value: draft.slug, onChange: (e) => setDraft((d) => ({ ...d, slug: e.target.value })), onBlur: () => void commit("slug"), onKeyDown: onKey("slug") }) }), onRemoveLocale && (_jsxs(Button, { variant: "secondary", onClick: onRemoveLocale, children: ["Remove from ", locale] }))] }));
40
+ return (_jsxs("div", { style: { display: "flex", flexDirection: "column", gap: 14 }, children: [_jsxs(SectionLabel, { children: ["Page \u00B7 ", locale] }), fixed && _jsx("span", { className: "sm-hint", children: "Owned by code \u2014 change it in the registration." }), error && _jsx("span", { className: "sm-error", children: error }), _jsx(Field, { label: "Title", children: _jsx("input", { className: "sm-input", disabled: fixed, value: draft.title, onChange: (e) => setDraft((d) => ({ ...d, title: e.target.value })), onBlur: () => void commit("title"), onKeyDown: onKey("title") }) }), _jsx(Field, { label: "Slug", hint: path ?? "", children: _jsx("input", { className: "sm-input sm-input--mono", disabled: fixed || isHome, value: draft.slug, onChange: (e) => setDraft((d) => ({ ...d, slug: e.target.value })), onBlur: () => void commit("slug"), onKeyDown: onKey("slug") }) })] }));
40
41
  }
@@ -0,0 +1,7 @@
1
+ export declare const SEO_TITLE_MAX = 60;
2
+ export declare const SEO_DESCRIPTION_MAX = 160;
3
+ export type SeoKey = "title" | "description" | "ogImage" | "noindex";
4
+ export declare function SeoSettings({ meta, onPatch }: {
5
+ meta: Record<string, unknown>;
6
+ onPatch(key: SeoKey, value: unknown): void;
7
+ }): import("react").JSX.Element;
@@ -0,0 +1,15 @@
1
+ "use client";
2
+ import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
3
+ import { MediaWidget } from "../forms/MediaWidget.js";
4
+ import { Field, Switch } from "../ui/primitives.js";
5
+ export const SEO_TITLE_MAX = 60;
6
+ export const SEO_DESCRIPTION_MAX = 160;
7
+ const str = (v) => (typeof v === "string" ? v : "");
8
+ function Counter({ n, max }) {
9
+ return _jsxs("span", { className: n > max ? "sm-counter--over" : undefined, children: [n, "/", max] });
10
+ }
11
+ export function SeoSettings({ meta, onPatch }) {
12
+ const title = str(meta.title);
13
+ const description = str(meta.description);
14
+ return (_jsxs("div", { style: { display: "flex", flexDirection: "column", gap: 14 }, children: [_jsx(Field, { label: "Title", counter: _jsx(Counter, { n: title.length, max: SEO_TITLE_MAX }), hint: "Used in search results and link previews. Empty: the page title.", children: _jsx("input", { className: "sm-input", value: title, onChange: (e) => onPatch("title", e.target.value) }) }), _jsx(Field, { label: "Description", counter: _jsx(Counter, { n: description.length, max: SEO_DESCRIPTION_MAX }), children: _jsx("textarea", { className: "sm-textarea", rows: 3, value: description, onChange: (e) => onPatch("description", e.target.value) }) }), _jsx(Field, { label: "Social image", hint: "1200\u00D7630 recommended", asDiv: true, children: _jsx(MediaWidget, { descriptor: { type: "image" }, value: meta.ogImage, onChange: (v) => onPatch("ogImage", v) }) }), _jsx(Field, { label: "Hide from search engines", asDiv: true, children: _jsx(Switch, { checked: meta.noindex === true, onChange: (v) => onPatch("noindex", v), label: "Hide from search engines" }) })] }));
15
+ }
@@ -0,0 +1,27 @@
1
+ /** What the text helpers need from an element — a DOM Element satisfies it. */
2
+ export type TextHost = {
3
+ childElementCount: number;
4
+ firstChild: {
5
+ nodeType: number;
6
+ nodeValue: string | null;
7
+ nextSibling: unknown;
8
+ } | null;
9
+ textContent: string | null;
10
+ };
11
+ /** A marked element the text session may edit: no child elements. A mark
12
+ * on a container (an <a> inside a <p>) falls through to the inspector. */
13
+ export declare const isPlainTextHost: (el: {
14
+ childElementCount: number;
15
+ }) => boolean;
16
+ /** Write `value` in place: into the one existing text node when there is
17
+ * one, so the site's React keeps pointing at the node it rendered and a
18
+ * refresh reconciles into it; else as new text content. False when the
19
+ * element is not a plain text host. */
20
+ export declare function writeTextInto(el: TextHost, value: string): boolean;
21
+ /** The container's layout the richtext editor root copies (spec §5), so a
22
+ * flex-column or grid container keeps its block spacing while its
23
+ * children are hidden behind the editor. Kebab-cased, for setProperty. */
24
+ export declare const LAYOUT_PROPS: readonly ["display", "flex-direction", "gap", "row-gap", "column-gap", "grid-template-columns", "grid-auto-flow", "align-items", "justify-content"];
25
+ export declare function layoutToCopy(computed: {
26
+ getPropertyValue(name: string): string;
27
+ }): Record<string, string>;