smoodly 0.0.52 → 0.0.54

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 (79) hide show
  1. package/dist/admin/client-ops.js +1 -1
  2. package/dist/admin/editor/CanvasOverlay.d.ts +3 -1
  3. package/dist/admin/editor/CanvasOverlay.js +2 -2
  4. package/dist/admin/editor/EditorView.js +243 -63
  5. package/dist/admin/editor/InsertPicker.d.ts +8 -1
  6. package/dist/admin/editor/InsertPicker.js +2 -2
  7. package/dist/admin/editor/InspectorFields.js +1 -1
  8. package/dist/admin/editor/Outline.d.ts +2 -3
  9. package/dist/admin/editor/PageInspector.js +1 -1
  10. package/dist/admin/editor/StructureView.d.ts +31 -0
  11. package/dist/admin/editor/StructureView.js +58 -0
  12. package/dist/admin/editor/card-preview.d.ts +10 -0
  13. package/dist/admin/editor/card-preview.js +50 -0
  14. package/dist/admin/editor/global-edit.js +29 -22
  15. package/dist/admin/editor/history.d.ts +39 -0
  16. package/dist/admin/editor/history.js +64 -0
  17. package/dist/admin/editor/section-clipboard.d.ts +40 -0
  18. package/dist/admin/editor/section-clipboard.js +106 -0
  19. package/dist/admin/editor/undo-keys.d.ts +19 -0
  20. package/dist/admin/editor/undo-keys.js +27 -0
  21. package/dist/admin/editor/useInlineSession.d.ts +4 -1
  22. package/dist/admin/editor/useInlineSession.js +11 -2
  23. package/dist/admin/editor/view-memory.d.ts +9 -0
  24. package/dist/admin/editor/view-memory.js +17 -0
  25. package/dist/admin/files.generated.js +1 -1
  26. package/dist/admin/handler.d.ts +1 -1
  27. package/dist/admin/index.d.ts +2 -2
  28. package/dist/admin/ops.d.ts +4 -10
  29. package/dist/admin/page-data.d.ts +1 -1
  30. package/dist/admin/session-flow.d.ts +24 -2
  31. package/dist/admin/session-flow.js +29 -2
  32. package/dist/admin/shell/AdminApp.d.ts +1 -1
  33. package/dist/admin/shell/IconRail.js +5 -4
  34. package/dist/admin/shell/presence.d.ts +1 -1
  35. package/dist/admin/shell/session.d.ts +1 -2
  36. package/dist/admin/shell/session.js +10 -9
  37. package/dist/admin/ui/ActionMenu.d.ts +2 -0
  38. package/dist/admin/ui/ActionMenu.js +1 -1
  39. package/dist/admin/ui/primitives.d.ts +1 -0
  40. package/dist/admin/ui/primitives.js +3 -2
  41. package/dist/admin/ui/theme.js +70 -0
  42. package/dist/admin/ui/useSortable.js +3 -1
  43. package/dist/api-client.js +1 -1
  44. package/dist/assemble.js +1 -1
  45. package/dist/astro/cache.d.ts +1 -1
  46. package/dist/astro/index.js +11 -6
  47. package/dist/astro/runtime.d.ts +1 -1
  48. package/dist/astro/runtime.js +1 -1
  49. package/dist/astro/shell.d.ts +1 -1
  50. package/dist/astro/shell.js +1 -1
  51. package/dist/content/content-api.d.ts +1 -0
  52. package/dist/content/content-api.js +3 -1
  53. package/dist/css.js +1 -1
  54. package/dist/entry-store.d.ts +5 -5
  55. package/dist/entry-store.js +7 -7
  56. package/dist/image/markup.js +1 -1
  57. package/dist/index.d.ts +2 -2
  58. package/dist/index.js +1 -1
  59. package/dist/media-cut.d.ts +1 -1
  60. package/dist/media-cut.js +2 -2
  61. package/dist/media.d.ts +1 -1
  62. package/dist/media.js +2 -2
  63. package/dist/member-store.d.ts +0 -29
  64. package/dist/member-store.js +0 -4
  65. package/dist/paths.d.ts +1 -1
  66. package/dist/paths.js +1 -1
  67. package/dist/revalidate.d.ts +1 -1
  68. package/dist/revalidate.js +1 -1
  69. package/dist/space-transfer.d.ts +1 -1
  70. package/dist/space-transfer.js +1 -1
  71. package/dist/version.d.ts +1 -1
  72. package/dist/version.js +1 -1
  73. package/package.json +1 -1
  74. package/dist/admin/auth-config.d.ts +0 -7
  75. package/dist/admin/auth-config.js +0 -1
  76. package/dist/admin/browser/fetch-op.d.ts +0 -10
  77. package/dist/admin/browser/fetch-op.js +0 -45
  78. package/dist/admin/browser/main.d.ts +0 -1
  79. package/dist/admin/browser/main.js +0 -27
@@ -3,7 +3,7 @@ const GROUPS = {
3
3
  entries: ["list", "distinct", "get", "create", "save", "setStatus", "discardDraft", "delete", "move", "addLocale", "removeLocale", "listVersions", "getVersion", "restore"],
4
4
  globals: ["list", "get", "save", "setStatus", "discardDraft", "listVersions", "getVersion", "restore"],
5
5
  assets: ["list", "get", "update", "delete", "usage"],
6
- members: ["list", "me", "invite", "setRole", "remove"],
6
+ members: ["list", "me"],
7
7
  redirects: ["list", "targets", "add", "remove"],
8
8
  health: ["scan"],
9
9
  preview: ["enable", "disable"],
@@ -10,7 +10,7 @@ export type OverlaySelection = {
10
10
  /** Rendered by the site's layout, not placed in a zone: no tree gestures (spec 2026-09-13 §2). */
11
11
  layout?: boolean;
12
12
  };
13
- export declare function CanvasOverlay({ emptyZones, hoverRect, hoverGlobal, selection, plusButtons, fieldHover, editing, onPlus, onMove, onDuplicate, onRemove, }: {
13
+ export declare function CanvasOverlay({ emptyZones, hoverRect, hoverGlobal, selection, plusButtons, fieldHover, editing, onPlus, onMove, onDuplicate, onCopy, onRemove, }: {
14
14
  emptyZones: ZoneRect[];
15
15
  hoverRect: BridgeRect | undefined;
16
16
  /** The hovered block is a global item: the ring takes the global colour. */
@@ -25,5 +25,7 @@ export declare function CanvasOverlay({ emptyZones, hoverRect, hoverGlobal, sele
25
25
  onPlus(button: PlusButton, clientX: number, clientY: number): void;
26
26
  onMove(id: string, dir: -1 | 1): void;
27
27
  onDuplicate(node: TreeNode): void;
28
+ /** Copy to the section clipboard (spec 2026-10-19 editor at launch §3). */
29
+ onCopy(node: TreeNode): void;
28
30
  onRemove(id: string): void;
29
31
  }): import("react").JSX.Element;
@@ -9,7 +9,7 @@ import { useState } from "react";
9
9
  import { cx, IconButton } from "../ui/primitives.js";
10
10
  /** Pixels above a block the riding pill needs (half its height); less and it moves inside. */
11
11
  const TAG_CLEARANCE = 14;
12
- export function CanvasOverlay({ emptyZones, hoverRect, hoverGlobal, selection, plusButtons, fieldHover, editing, onPlus, onMove, onDuplicate, onRemove, }) {
12
+ export function CanvasOverlay({ emptyZones, hoverRect, hoverGlobal, selection, plusButtons, fieldHover, editing, onPlus, onMove, onDuplicate, onCopy, onRemove, }) {
13
13
  const [hoverPlus, setHoverPlus] = useState(null);
14
14
  return (_jsxs("div", { style: { position: "absolute", inset: 0, pointerEvents: "none" }, children: [emptyZones.map((z) => (_jsxs("div", { className: "sm-dashed", style: {
15
15
  position: "absolute", left: z.x + 12, top: z.y, width: Math.max(0, z.w - 24),
@@ -27,7 +27,7 @@ export function CanvasOverlay({ emptyZones, hoverRect, hoverGlobal, selection, p
27
27
  } }), _jsxs("span", { className: cx("sm-blocktag", selection.global && "sm-blocktag--global", selection.rect.y < TAG_CLEARANCE && "sm-blocktag--inside"), style: { position: "absolute", left: selection.rect.x, top: selection.rect.y }, children: [selection.title, selection.global && " · global", selection.locked && " · locked"] }), !selection.layout && (_jsx("div", { className: "sm-blocktoolbar", style: {
28
28
  position: "absolute", left: selection.rect.x + selection.rect.w - 8, top: selection.rect.y + 8,
29
29
  transform: "translateX(-100%)", pointerEvents: "auto",
30
- }, children: selection.locked ? (_jsx("span", { className: "sm-blocktoolbar__note", children: "fields only" })) : (_jsxs(_Fragment, { children: [_jsx(IconButton, { label: "Move up", tone: "inverse", onClick: () => onMove(selection.node.id, -1), children: "\u2191" }), _jsx(IconButton, { label: "Move down", tone: "inverse", onClick: () => onMove(selection.node.id, 1), children: "\u2193" }), _jsx(IconButton, { label: "Duplicate", tone: "inverse", onClick: () => onDuplicate(selection.node), children: "\u29C9" }), _jsx(IconButton, { label: "Remove", tone: "inverse", onClick: () => onRemove(selection.node.id), children: "\u00D7" })] })) }))] })), plusButtons.map((p) => {
30
+ }, children: selection.locked ? (_jsx("span", { className: "sm-blocktoolbar__note", children: "fields only" })) : (_jsxs(_Fragment, { children: [_jsx(IconButton, { label: "Move up", tone: "inverse", onClick: () => onMove(selection.node.id, -1), children: "\u2191" }), _jsx(IconButton, { label: "Move down", tone: "inverse", onClick: () => onMove(selection.node.id, 1), children: "\u2193" }), _jsx(IconButton, { label: "Duplicate", tone: "inverse", onClick: () => onDuplicate(selection.node), children: "\u29C9" }), _jsx(IconButton, { label: "Copy", tone: "inverse", onClick: () => onCopy(selection.node), children: "\u2398" }), _jsx(IconButton, { label: "Remove", tone: "inverse", onClick: () => onRemove(selection.node.id), children: "\u00D7" })] })) }))] })), plusButtons.map((p) => {
31
31
  const key = `${p.zone}-${p.index}`;
32
32
  const hot = hoverPlus === key;
33
33
  return (_jsxs("div", { style: { position: "absolute", left: 0, right: 0, top: p.y }, children: [hot && _jsx("div", { className: "sm-guide", style: { margin: "0 12px" } }), _jsx("div", { style: {
@@ -27,8 +27,8 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
27
27
  //
28
28
  // Core code: no next/* imports live here — the bridge is the only piece
29
29
  // that knows about Next, and it ships to the site, not the admin.
30
- import { Eye } from "lucide-react";
31
- import { useCallback, useEffect, useMemo, useRef, useState } from "react";
30
+ import { AppWindow, Eye, Redo2, Rows3, Undo2 } from "lucide-react";
31
+ import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
32
32
  import { notePublished } from "../delivery-mode.js";
33
33
  import { CSS_KEY, GLOBAL_NODE_TYPE, SETTINGS_KEY, STYLES_KEY, globalNameOf } from "../../global.js";
34
34
  import { descriptorAt, getAtPath, unsetAtPath } from "../field-paths.js";
@@ -48,6 +48,12 @@ import { customNode, customNodeLabel, customSection } from "../custom-section.js
48
48
  import { CUSTOM_NODE_TYPE } from "../../custom/index.js";
49
49
  import { NoticePane } from "./NoticePane.js";
50
50
  import { Outline } from "./Outline.js";
51
+ import { StructureView } from "./StructureView.js";
52
+ import { cardPreview } from "./card-preview.js";
53
+ import { rememberView, rememberedView } from "./view-memory.js";
54
+ import { createHistory } from "./history.js";
55
+ import { takesUndoKeys, undoKeyOf } from "./undo-keys.js";
56
+ import { canPaste, clipOf, clipboardSpace, pastedNode, readClip, subscribeClip, writeClip } from "./section-clipboard.js";
51
57
  import { PageInspector } from "./PageInspector.js";
52
58
  import { GlobalInspector } from "./GlobalInspector.js";
53
59
  import { localeFromUrl } from "./locale-url.js";
@@ -78,7 +84,18 @@ import { driftMessage, misfitErrors } from "../drift-copy.js";
78
84
  import { usePresence, usePresenceState, useSavedElsewhere, useTrackPresence } from "../shell/presence-context.js";
79
85
  import { editorsOf } from "../shell/presence.js";
80
86
  import { PresenceLine } from "../ui/PresenceLine.js";
81
- import { Banner as BannerBox, Breadcrumb, Button, FieldErrorList, IconButton, LinkButton, SaveIndicator, Tabs, TopBar, } from "../ui/primitives.js";
87
+ import { Banner as BannerBox, Breadcrumb, Button, FieldErrorList, IconButton, LinkButton, SaveIndicator, Segmented, Tabs, TopBar, cx, } from "../ui/primitives.js";
88
+ /** Cmd on a Mac, Ctrl elsewhere, for the undo keys (spec 2026-10-19 editor at launch §2). */
89
+ const IS_MAC = typeof navigator !== "undefined" && /Mac|iPhone|iPad/.test(navigator.platform);
90
+ /** The site's storage for the section clipboard; a browser that refuses the property reads as empty and refuses the write. */
91
+ const clipStorage = () => {
92
+ try {
93
+ return window.localStorage;
94
+ }
95
+ catch {
96
+ return { getItem: () => null, setItem: () => { throw new Error("blocked"); } };
97
+ }
98
+ };
82
99
  export function EditorView({ ops, registry, id }) {
83
100
  const adminHref = useAdminHref();
84
101
  const [record, setRecord] = useState(null);
@@ -108,6 +125,18 @@ export function EditorView({ ops, registry, id }) {
108
125
  const inlineRef = useRef(null);
109
126
  const [picker, setPicker] = useState(null);
110
127
  const [tab, setTab] = useState("Content");
128
+ /** Canvas or Structure (spec 2026-10-19 editor at launch §1): one tree, two renderers; remembered per browser. */
129
+ const [view, setView] = useState(() => rememberedView());
130
+ /** Undo and redo (spec 2026-10-19 editor at launch §2): the steps since the page opened, in the tab. */
131
+ const [undoHistory] = useState(() => createHistory());
132
+ const undoState = useSyncExternalStore(undoHistory.subscribe, () => `${undoHistory.canUndo()}:${undoHistory.canRedo()}`, () => "false:false");
133
+ const canUndo = undoState.startsWith("true");
134
+ const canRedo = undoState.endsWith("true");
135
+ /** The frame's load count: the undo keys are attached to each new frame document. */
136
+ const [frameLoads, setFrameLoads] = useState(0);
137
+ /** The section clipboard (spec 2026-10-19 editor at launch §3): this space's clip in the site's storage, every tab's. */
138
+ const [clip, setClip] = useState(() => readClip(clipStorage(), clipboardSpace()));
139
+ useEffect(() => subscribeClip(window, () => setClip(readClip(clipStorage(), clipboardSpace()))), []);
111
140
  const [banner, setBanner] = useState(null);
112
141
  const [published, setPublished] = useState(false);
113
142
  /** An action of the page's control in flight. */
@@ -173,7 +202,11 @@ export function EditorView({ ops, registry, id }) {
173
202
  // through the same inline-session deferral as the page's.
174
203
  const globalEdit = useGlobalEdit({ ops, onSaved: (name) => { setGlobalDirty(false); inlineRef.current?.onSaved(); presence?.announce({ kind: "global", id: name, locale }, null); } });
175
204
  useUnloadGuard(dirty || saveState !== "saved" || globalDirty || globalEdit.saveState !== "saved");
176
- const patchGlobal = (name, path, value) => {
205
+ const patchGlobal = (name, path, value, opts = {}) => {
206
+ // A global step (spec 2026-10-19 editor at launch §2): the value before and after at the path, coalesced by `name:path`
207
+ // (an inline session passes its group's key, so the whole session stays one step).
208
+ if (opts.record !== false)
209
+ undoHistory.record({ kind: "global", name, path, before: getAtPath(globalEdit.items[name]?.fields, path), after: value, key: opts.key ?? `${name}:${path}`, at: Date.now(), focus: selectedId ?? undefined });
177
210
  globalEdit.patch(name, path, value);
178
211
  setGlobalDirty(true);
179
212
  setGlobalTouched(true);
@@ -223,6 +256,7 @@ export function EditorView({ ops, registry, id }) {
223
256
  setReady(false);
224
257
  setTree(null);
225
258
  treeRef.current = null;
259
+ undoHistory.clear(); // a tree loaded from the server starts the history over (a locale switch, a reload, Discard, a restore, Load their version)
226
260
  const keepSelection = keepSelectionRef.current;
227
261
  keepSelectionRef.current = false;
228
262
  if (!keepSelection) {
@@ -294,7 +328,8 @@ export function EditorView({ ops, registry, id }) {
294
328
  })();
295
329
  return () => { cancelled = true; };
296
330
  }, [ops, id, locale, reloadKey]);
297
- useEffect(() => { treeRef.current = tree; }, [tree]);
331
+ // No lagging `treeRef.current = tree` effect: every writer (the load, the fill, mutate, applyTree) sets the ref synchronously,
332
+ // and a passive effect could move it back between a commit and a later mutate (review 2026-10-20).
298
333
  // After a rename only the record and the URL indexes are stale — never
299
334
  // re-read the tree here: the draft in memory may be ahead of the store.
300
335
  const reload = useCallback(async () => {
@@ -446,33 +481,115 @@ export function EditorView({ ops, registry, id }) {
446
481
  revealRef.current(id);
447
482
  }, []);
448
483
  // ── persistence ──────────────────────────────────────────────────────
449
- const mutate = useCallback((fn) => {
450
- setTree((t) => {
451
- if (!t)
452
- return t;
453
- const next = fn(t);
454
- treeRef.current = next;
455
- return next;
456
- });
484
+ /** Every edit of the tree: the op over the current tree, a history step when it changed (`key` coalesces a field's
485
+ * burst, `focus` is the node the gesture concerned), then the marking the autosave debounces on. */
486
+ const mutate = useCallback((fn, opts) => {
487
+ const t = treeRef.current;
488
+ if (!t)
489
+ return;
490
+ const next = fn(t);
491
+ if (next !== t)
492
+ undoHistory.record({ kind: "tree", before: t, after: next, key: opts?.key, focus: opts?.focus, at: Date.now() });
493
+ treeRef.current = next;
494
+ setTree(next);
457
495
  mark();
458
496
  setDirty(true);
459
497
  setTouched(true);
460
- }, [mark]);
498
+ }, [mark, undoHistory]);
461
499
  // ── inline editing (spec 2026-09-10) ──────────────────────────────────
462
500
  const inline = useInlineSession({
463
501
  iframeRef, postToCanvas,
502
+ // An inline session is one step: its key stays grouped from start to end (spec 2026-10-19 editor at launch §2).
503
+ onSession: (s) => undoHistory.group(`inline:${s.node}:${s.path}`),
464
504
  patch: (node, path, value) => {
465
505
  const item = globalItemOf(node);
506
+ // The session's steps carry its group's key (`inline:<node>:<path>`), so every change it dispatches merges into one step.
466
507
  if (item)
467
- patchGlobal(item.name, path, value);
508
+ patchGlobal(item.name, path, value, { key: `inline:${node}:${path}` });
468
509
  else
469
- mutate((t) => patchNodePath(t, node, path, value));
510
+ mutate((t) => patchNodePath(t, node, path, value), { key: `inline:${node}:${path}`, focus: node });
470
511
  // A second element marked with the same field follows the one being typed in.
471
512
  if (typeof value === "string")
472
513
  inlineRef.current?.writeText(node, path, value);
473
514
  },
474
515
  });
475
516
  inlineRef.current = inline;
517
+ /** The switch: an inline session ends on it (the closing editor waits for the refresh), the picker closes, the choice is remembered. */
518
+ const switchView = (v) => {
519
+ if (v === view)
520
+ return;
521
+ inline.end();
522
+ setPicker(null);
523
+ setView(v);
524
+ rememberView(v);
525
+ };
526
+ // ── undo and redo (spec 2026-10-19 editor at launch §2) ─────────────
527
+ /** A tree step's side, set through the path `mutate` marks by, without recording; the focus node selected when it exists. */
528
+ const applyTree = (tree, focus) => {
529
+ treeRef.current = tree;
530
+ setTree(tree);
531
+ mark();
532
+ setDirty(true);
533
+ setTouched(true);
534
+ setSelectedId(focus && findNode(tree, focus) ? focus : null);
535
+ };
536
+ /** A global step's side through the item's own save; an item not loaded (the editor switched locale and back) is loaded first, never skipped. */
537
+ const applyGlobal = async (step, dir) => {
538
+ if (!globalEdit.items[step.name])
539
+ await globalEdit.load(step.name, locale);
540
+ patchGlobal(step.name, step.path, dir === "undo" ? step.before : step.after, { record: false });
541
+ const focus = step.focus;
542
+ const t = treeRef.current;
543
+ setSelectedId(focus && ((t && findNode(t, focus)) || globalItemOf(focus)) ? focus : null);
544
+ };
545
+ const doUndo = () => {
546
+ if (stale)
547
+ return;
548
+ const step = undoHistory.undo();
549
+ if (!step)
550
+ return;
551
+ inline.end();
552
+ if (step.kind === "tree")
553
+ applyTree(step.before, step.focus);
554
+ else
555
+ void applyGlobal(step, "undo");
556
+ };
557
+ const doRedo = () => {
558
+ if (stale)
559
+ return;
560
+ const step = undoHistory.redo();
561
+ if (!step)
562
+ return;
563
+ inline.end();
564
+ if (step.kind === "tree")
565
+ applyTree(step.after, step.focus);
566
+ else
567
+ void applyGlobal(step, "redo");
568
+ };
569
+ const undoRef = useRef({ doUndo, doRedo });
570
+ undoRef.current = { doUndo, doRedo };
571
+ /** Cmd+Z / Shift+Cmd+Z (Ctrl elsewhere): the editor's outside text controls and inside the inspector's field widgets. */
572
+ const onUndoKey = useCallback((e) => {
573
+ if (e.defaultPrevented)
574
+ return; // a widget with its own undo (the sidebar richtext) took the key
575
+ const k = undoKeyOf(e, IS_MAC);
576
+ if (!k || !takesUndoKeys(e.target))
577
+ return;
578
+ e.preventDefault();
579
+ if (k === "undo")
580
+ undoRef.current.doUndo();
581
+ else
582
+ undoRef.current.doRedo();
583
+ }, []);
584
+ // The same keys from the canvas frame's document (a click on a block leaves the focus there): attached on every frame
585
+ // load, detached while an inline session is open, so inside a session the field's own undo runs.
586
+ useEffect(() => {
587
+ const d = iframeRef.current?.contentDocument;
588
+ if (!d || inline.session)
589
+ return;
590
+ d.addEventListener("keydown", onUndoKey);
591
+ return () => d.removeEventListener("keydown", onUndoKey);
592
+ }, [frameLoads, inline.session, onUndoKey]);
476
593
  // A different selection ends the session; a new canvas drops everything.
477
594
  useEffect(() => { inline.onSelection(selectedId); }, [selectedId]); // eslint-disable-line react-hooks/exhaustive-deps
478
595
  useEffect(() => { inline.onCanvasChange(); }, [iframeSrc]); // eslint-disable-line react-hooks/exhaustive-deps
@@ -569,13 +686,13 @@ export function EditorView({ ops, registry, id }) {
569
686
  };
570
687
  const doInsert = (zone, index, section) => {
571
688
  const node = nodeFromSample(section, newNodeId(section.name));
572
- mutate((t) => insertNode(t, zone, index, node));
689
+ mutate((t) => insertNode(t, zone, index, node), { focus: node.id });
573
690
  setPicker(null);
574
691
  select(node.id);
575
692
  };
576
693
  const doInsertCustom = (zone, index) => {
577
694
  const node = customNode(registry, newNodeId(CUSTOM_NODE_TYPE));
578
- mutate((t) => insertNode(t, zone, index, node));
695
+ mutate((t) => insertNode(t, zone, index, node), { focus: node.id });
579
696
  setPicker(null);
580
697
  select(node.id);
581
698
  };
@@ -584,7 +701,7 @@ export function EditorView({ ops, registry, id }) {
584
701
  if (!ref)
585
702
  return;
586
703
  const node = placementNode(item, ref, newNodeId(GLOBAL_NODE_TYPE));
587
- mutate((t) => insertNode(t, zone, index, node));
704
+ mutate((t) => insertNode(t, zone, index, node), { focus: node.id });
588
705
  setPicker(null);
589
706
  select(node.id);
590
707
  };
@@ -605,7 +722,7 @@ export function EditorView({ ops, registry, id }) {
605
722
  };
606
723
  if (node.css === undefined)
607
724
  delete copy.css;
608
- mutate((t) => insertNode(t, zone, index, copy));
725
+ mutate((t) => insertNode(t, zone, index, copy), { focus: copy.id });
609
726
  select(copy.id);
610
727
  };
611
728
  const doRemove = async (id, ask = true) => {
@@ -622,15 +739,53 @@ export function EditorView({ ops, registry, id }) {
622
739
  subject: node ? { name: labelOf(node) } : undefined,
623
740
  })))
624
741
  return;
625
- mutate((t) => removeNode(t, id));
742
+ mutate((t) => removeNode(t, id), { focus: id }); // undo selects the restored node again
626
743
  setSelectedId((current) => (current === id ? null : current));
627
744
  };
628
- const doMove = (id, dir) => mutate((t) => moveNode(t, id, dir));
629
- const doMoveTo = (id, index) => mutate((t) => moveNodeTo(t, id, index));
630
- const doPatch = (id, ns, key, value) => mutate((t) => patchNode(t, id, ns, key, value));
631
- const doPatchCss = (id, css) => mutate((t) => patchNodeCss(t, id, css));
745
+ const doMove = (id, dir) => mutate((t) => moveNode(t, id, dir), { focus: id });
746
+ const doMoveTo = (id, index) => mutate((t) => moveNodeTo(t, id, index), { focus: id });
747
+ const doPatch = (id, ns, key, value) => mutate((t) => patchNode(t, id, ns, key, value), { key: `${id}:${ns === "fields" ? key : `${ns}.${key}`}`, focus: id });
748
+ const doPatchCss = (id, css) => mutate((t) => patchNodeCss(t, id, css), { key: `${id}:css`, focus: id });
632
749
  /** A page-level value (page fields, SEO meta) — same dirty/debounce path as a node's (spec 2026-09-10 page fields §5). */
633
- const doPatchPage = (ns, key, value) => mutate((t) => patchPage(t, ns, key, value));
750
+ const doPatchPage = (ns, key, value) => mutate((t) => patchPage(t, ns, key, value), { key: `page:${ns}.${key}` });
751
+ // ── the section clipboard (spec 2026-10-19 editor at launch §3) ──────
752
+ const doCopy = (node) => {
753
+ const next = clipOf(node, globalOf(node)?.title ?? labelOf(node), clipboardSpace()); // a placement's title without the outline's suffix, as its card reads
754
+ if (!writeClip(clipStorage(), next)) {
755
+ setBanner({ text: "Couldn't copy — this browser blocks site storage." });
756
+ return;
757
+ }
758
+ setClip(next);
759
+ };
760
+ /** Whether `zone` takes the clip, the picker's own check; null without a clip. */
761
+ const pasteCheck = (zone) => clip ? canPaste(clip, policyOf(zone), nodesIn(zone).length, registry, globalIds ?? {}) : null;
762
+ /** One mutate, one undo step, the new node selected. */
763
+ const doPaste = (zone, index) => {
764
+ if (!clip || !pasteCheck(zone)?.ok)
765
+ return;
766
+ const node = pastedNode(clip, globalIds ?? {});
767
+ mutate((t) => insertNode(t, zone, index, node), { focus: node.id });
768
+ setPicker(null);
769
+ select(node.id);
770
+ };
771
+ const pasteEntry = (zone, label, index) => {
772
+ const check = pasteCheck(zone);
773
+ return check ? { label, run: () => doPaste(zone, index), disabled: !check.ok, title: check.ok ? undefined : check.reason } : null;
774
+ };
775
+ /** The structure card's ⋯ beyond Remove: Copy, then Paste before and after when a clip exists; a locked zone's card gets Copy only. */
776
+ const cardActions = (node, zone, index) => {
777
+ const copy = { label: "Copy", run: () => doCopy(node) };
778
+ if (!clip || policyOf(zone)?.kind === "locked")
779
+ return [copy];
780
+ return [copy, pasteEntry(zone, `Paste "${clip.title}" before`, index), pasteEntry(zone, `Paste "${clip.title}" after`, index + 1)].filter((e) => e !== null);
781
+ };
782
+ /** The zone heading's ⋯: Paste at the zone's end, when a clip exists and the zone is not locked. */
783
+ const zoneActions = (zone) => {
784
+ if (!clip || policyOf(zone)?.kind === "locked")
785
+ return [];
786
+ const entry = pasteEntry(zone, `Paste "${clip.title}"`, nodesIn(zone).length);
787
+ return entry ? [entry] : [];
788
+ };
634
789
  // ── publish ──────────────────────────────────────────────────────────
635
790
  const doPublish = async () => {
636
791
  // A section the preview shows as crashed (spec 2026-09-27 section guard §6): ask before it goes live.
@@ -816,6 +971,7 @@ export function EditorView({ ops, registry, id }) {
816
971
  }
817
972
  // The item reloads from the store; the canvas refreshes through the same path as a save ack.
818
973
  globalEdit.reset();
974
+ undoHistory.forget((s) => s.kind === "global" && s.name === name); // the item's steps alone (spec 2026-10-19 editor at launch §2)
819
975
  setGlobalDirty(false);
820
976
  void globalEdit.load(name, locale);
821
977
  inlineRef.current?.onSaved();
@@ -826,11 +982,13 @@ export function EditorView({ ops, registry, id }) {
826
982
  if (e.key === "Escape") {
827
983
  setPicker(null);
828
984
  setSelectedId(null);
985
+ return;
829
986
  }
987
+ onUndoKey(e);
830
988
  };
831
989
  window.addEventListener("keydown", onKey);
832
990
  return () => window.removeEventListener("keydown", onKey);
833
- }, []);
991
+ }, [onUndoKey]);
834
992
  if (loadError) {
835
993
  return _jsx(NoticePane, { children: _jsx(BannerBox, { tone: "error", children: loadError }) });
836
994
  }
@@ -879,6 +1037,8 @@ export function EditorView({ ops, registry, id }) {
879
1037
  // Reveal is defined here, where the rect map exists; select() calls it
880
1038
  // through the ref so its own identity stays stable.
881
1039
  revealRef.current = (id) => {
1040
+ if (view === "structure")
1041
+ return; // the card clicked is in view; the hidden frame is not scrolled under it
882
1042
  const rect = rectById.get(id);
883
1043
  const scroller = scrollerRef.current;
884
1044
  if (!rect || !scroller)
@@ -886,6 +1046,40 @@ export function EditorView({ ops, registry, id }) {
886
1046
  const top = rect.y - Math.max(0, (scroller.clientHeight - rect.h) / 2);
887
1047
  scroller.scrollTo({ top: Math.max(0, top), behavior: "smooth" });
888
1048
  };
1049
+ // The outline's view model (spec 2026-09-27 editor safety §5), shared with the structure view (spec 2026-10-19 editor at
1050
+ // launch §1): the declared zones in order, then stored zones the page type no longer declares, listed after the real ones.
1051
+ const outlineZones = [
1052
+ ...zoneOrder.map((zone) => ({
1053
+ zone, locked: policyOf(zone)?.kind === "locked", canTake: zoneCanTake(zone),
1054
+ note: findingsAt.get(`zone:${zone}`)?.map(driftMessage)[0],
1055
+ nodes: nodesIn(zone).map((node) => ({ node, label: labelOf(node), mark: markOf(node.id) })),
1056
+ })),
1057
+ ...Object.keys(tree?.zones ?? {}).filter((z) => !zoneOrder.includes(z) && (tree.zones[z]?.length ?? 0) > 0).map((zone) => ({
1058
+ zone, locked: false, canTake: false, removed: true,
1059
+ nodes: tree.zones[zone].map((node) => ({ node, label: labelOf(node) })),
1060
+ })),
1061
+ ];
1062
+ // A card: the section's icon, its preview line; a placed global item's title carries the badge instead of the outline's suffix.
1063
+ const structureZones = view !== "structure" ? [] : outlineZones.map((z) => ({
1064
+ ...z,
1065
+ nodes: z.nodes.map((n) => {
1066
+ const global = globalOf(n.node);
1067
+ const section = sectionOf(n.node.type);
1068
+ return {
1069
+ ...n,
1070
+ label: global ? global.title : n.label,
1071
+ icon: section?.icon ?? (global ? "⛓" : "▤"),
1072
+ preview: global ? { text: null, image: null } : cardPreview(n.node, section),
1073
+ global: global !== null,
1074
+ };
1075
+ }),
1076
+ }));
1077
+ const clearZoneAsked = async (zone) => {
1078
+ const count = tree?.zones[zone]?.length ?? 0;
1079
+ if (!(await confirm({ title: "Clear this zone?", body: `Its ${count === 1 ? "section is" : `${count} sections are`} removed from the draft. The live page keeps ${count === 1 ? "it" : "them"} until you publish.`, confirmLabel: "Clear zone", destructive: true })))
1080
+ return;
1081
+ mutate((t) => clearZone(t, zone));
1082
+ };
889
1083
  const plusButtons = plusButtonsFor({ zoneOrder, canTake: zoneCanTake, nodesIn, rects: rectById, emptyZones });
890
1084
  const tabs = selectedSection ? tabsFor(selectedSection) : selectedItem ? tabsFor(selectedItem) : [];
891
1085
  const activeTab = tabs.some((t) => t.tab === tab) ? tab : tabs[0]?.tab;
@@ -896,23 +1090,25 @@ export function EditorView({ ops, registry, id }) {
896
1090
  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); } })),
897
1091
  },
898
1092
  { label },
899
- ] }) }), right: _jsxs(_Fragment, { children: [(touched || saveState === "error" || saveState === "refused") && _jsx(SaveIndicator, { state: saveState, pending: dirty }), ready && (_jsx(LinkButton, { icon: true, href: siteUrl, target: "_blank", rel: "noreferrer", "aria-label": "View live", title: "View live \u2014 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: _jsx(Eye, { size: 16, strokeWidth: 1.5, "aria-hidden": "true" }) })), _jsx(PublishControl, { status: record?.locales[locale] ? localeStatusOf(record.locales[locale]) : null, pending: dirty || saveState !== "saved", busy: pageBusy, flash: published, onPublish: () => { void doPublish(); }, onDiscard: () => { void doDiscard(); }, onUnpublish: () => { void doUnpublish(); }, onHistory: () => { void doHistory(); } })] }) }), target && _jsx(PresenceLine, { names: editorsOf(others, target), noun: "page" }), stale && _jsx(StaleBanner, { info: stale, onReload: loadTheirs }), _jsx(DriftNotice, { draft: findings, live: liveFindings }), history && record?.locales[locale] && (_jsx(HistoryModal, { rows: history, draftId: record.locales[locale].draftVersionId, onClose: () => setHistory(null), onRestore: doRestore, render: (versionId) => _jsx("iframe", { className: "sm-history__frame", title: "Version preview", src: versionAddress(siteUrl, versionId) }) })), _jsxs("div", { className: "sm-editor", inert: stale ? true : undefined, children: [_jsxs("div", { className: "sm-canvas", children: [(banner || diagnosis || crashedLines.length > 0) && (_jsxs("div", { style: { display: "flex", flexDirection: "column", gap: 8, padding: "0 20px 10px" }, children: [banner && (_jsxs(BannerBox, { tone: banner.fields && banner.fields.length > 0 ? "warn" : "error", onDismiss: () => setBanner(null), children: [_jsx("p", { children: banner.text }), _jsx(FieldErrorList, { fields: banner.fields ?? [] })] })), diagnosis && _jsx(CanvasWarning, { cause: diagnosis.cause, path: diagnosis.path }), crashedLines.length > 0 && (_jsx(BannerBox, { tone: "warn", children: crashedLines.map((line, i) => _jsx("p", { children: line }, i)) }))] })), _jsx("div", { className: "sm-canvas__scroll", ref: scrollerRef, onClick: (e) => { if (e.target === e.currentTarget)
900
- setSelectedId(null); }, children: _jsxs("div", { className: "sm-page", style: { overflow: "hidden" }, children: [iframeSrc ? (_jsx("iframe", { ref: iframeRef, src: iframeSrc, title: "Page canvas", onLoad: onFrameLoad, 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, hoverGlobal: hoverGlobal, 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 && (() => {
901
- const { session } = inline.richtext;
902
- const item = globalItemOf(session.node);
903
- const node = !item && tree ? findNode(tree, session.node) : null;
904
- const schema = item ?? (node ? sectionOf(node.type) : null);
905
- const fields = item ? globalEdit.items[item.name]?.fields : node?.fields;
906
- if (!schema || !fields)
907
- return null;
908
- return (_jsx(InlineRichtext, { mount: inline.richtext, pageWidth: pageWidth, value: getAtPath(fields, session.path), toolset: descriptorAt(schema, session.path)?.toolset, onChange: (doc) => {
909
- inline.markChanged();
910
- if (item)
911
- patchGlobal(item.name, session.path, doc);
912
- else
913
- mutate((t) => patchNodePath(t, session.node, session.path, doc));
914
- }, onEscape: inline.end }));
915
- })()] }) })] }), _jsx(ResizeHandle, {}), _jsxs("aside", { className: "sm-inspector", ref: inspectorRef, children: [_jsxs("div", { className: "sm-card", children: [selected && !selectedSection && !selectedItem ? (_jsx(UnknownNode, { node: selected, label: labelOf(selected), onRemove: () => { void doRemove(selected.id, true); } })) : selectedSection || selectedItem ? (_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 ?? (selectedItem ? `${selectedItem.title} · global` : null) }), selectedItem && (globalTouched || globalEdit.saveState === "error" || globalEdit.saveState === "refused") && _jsx(SaveIndicator, { state: globalEdit.saveState, pending: globalDirty })] }), _jsx(IconButton, { label: "Clear selection", onClick: () => setSelectedId(null), children: "\u00D7" })] }), tabs.length > 0 && selectedSection && (_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], redirects: redirects[locale] ?? [],
1093
+ ] }) }), right: _jsxs(_Fragment, { children: [(touched || saveState === "error" || saveState === "refused") && _jsx(SaveIndicator, { state: saveState, pending: dirty }), ready && tree && (_jsxs(_Fragment, { children: [_jsx(IconButton, { label: "Undo", disabled: !canUndo || !!stale, onClick: doUndo, children: _jsx(Undo2, { size: 16, strokeWidth: 1.5, "aria-hidden": "true" }) }), _jsx(IconButton, { label: "Redo", disabled: !canRedo || !!stale, onClick: doRedo, children: _jsx(Redo2, { size: 16, strokeWidth: 1.5, "aria-hidden": "true" }) })] })), ready && tree && (_jsx(Segmented, { options: ["canvas", "structure"], value: view, onChange: switchView, className: "sm-seg--view", label: (o) => o === "canvas"
1094
+ ? _jsx(AppWindow, { size: 14, strokeWidth: 1.5, "aria-hidden": "true" })
1095
+ : _jsx(Rows3, { size: 14, strokeWidth: 1.5, "aria-hidden": "true" }), option: (o) => ({ title: o === "canvas" ? "Canvas" : "Structure", "aria-label": o === "canvas" ? "Canvas" : "Structure" }) })), ready && (_jsx(LinkButton, { icon: true, href: siteUrl, target: "_blank", rel: "noreferrer", "aria-label": "View live", title: "View live \u2014 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: _jsx(Eye, { size: 16, strokeWidth: 1.5, "aria-hidden": "true" }) })), _jsx(PublishControl, { status: record?.locales[locale] ? localeStatusOf(record.locales[locale]) : null, pending: dirty || saveState !== "saved", busy: pageBusy, flash: published, onPublish: () => { void doPublish(); }, onDiscard: () => { void doDiscard(); }, onUnpublish: () => { void doUnpublish(); }, onHistory: () => { void doHistory(); } })] }) }), target && _jsx(PresenceLine, { names: editorsOf(others, target), noun: "page" }), stale && _jsx(StaleBanner, { info: stale, onReload: loadTheirs }), _jsx(DriftNotice, { draft: findings, live: liveFindings }), history && record?.locales[locale] && (_jsx(HistoryModal, { rows: history, draftId: record.locales[locale].draftVersionId, onClose: () => setHistory(null), onRestore: doRestore, render: (versionId) => _jsx("iframe", { className: "sm-history__frame", title: "Version preview", src: versionAddress(siteUrl, versionId) }) })), _jsxs("div", { className: "sm-editor", inert: stale ? true : undefined, children: [_jsxs("div", { className: "sm-canvas", children: [(banner || diagnosis || crashedLines.length > 0) && (_jsxs("div", { style: { display: "flex", flexDirection: "column", gap: 8, padding: "0 20px 10px" }, children: [banner && (_jsxs(BannerBox, { tone: banner.fields && banner.fields.length > 0 ? "warn" : "error", onDismiss: () => setBanner(null), children: [_jsx("p", { children: banner.text }), _jsx(FieldErrorList, { fields: banner.fields ?? [] })] })), diagnosis && _jsx(CanvasWarning, { cause: diagnosis.cause, path: diagnosis.path }), crashedLines.length > 0 && (_jsx(BannerBox, { tone: "warn", children: crashedLines.map((line, i) => _jsx("p", { children: line }, i)) }))] })), _jsxs("div", { className: cx("sm-canvas__scroll", view === "structure" && "sm-canvas__scroll--structure"), ref: scrollerRef, onClick: (e) => { if (e.target === e.currentTarget)
1096
+ setSelectedId(null); }, children: [view === "structure" && !(ready && tree) && _jsx("div", { className: "sm-canvas__loading", children: "Loading canvas\u2026" }), view === "structure" && ready && tree && (_jsx(StructureView, { zones: structureZones, selectedId: selectedId, onSelect: select, onMoveTo: doMoveTo, onRemove: doRemove, onClearZone: clearZoneAsked, cardActions: cardActions, zoneActions: zoneActions, onAdd: (zone, index, x, y) => setPicker({ zone, index, x: x - 150, y: y + 12 }) })), _jsxs("div", { className: cx("sm-page", view === "structure" && "sm-canvas__hidden"), style: { overflow: "hidden" }, children: [iframeSrc ? (_jsx("iframe", { ref: iframeRef, src: iframeSrc, title: "Page canvas", onLoad: () => { onFrameLoad(); setFrameLoads((n) => n + 1); }, 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, hoverGlobal: hoverGlobal, 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, onCopy: doCopy, onRemove: doRemove }), inline.richtext && (() => {
1097
+ const { session } = inline.richtext;
1098
+ const item = globalItemOf(session.node);
1099
+ const node = !item && tree ? findNode(tree, session.node) : null;
1100
+ const schema = item ?? (node ? sectionOf(node.type) : null);
1101
+ const fields = item ? globalEdit.items[item.name]?.fields : node?.fields;
1102
+ if (!schema || !fields)
1103
+ return null;
1104
+ return (_jsx(InlineRichtext, { mount: inline.richtext, pageWidth: pageWidth, value: getAtPath(fields, session.path), toolset: descriptorAt(schema, session.path)?.toolset, onChange: (doc) => {
1105
+ inline.markChanged();
1106
+ if (item)
1107
+ patchGlobal(item.name, session.path, doc, { key: `inline:${session.node}:${session.path}` });
1108
+ else
1109
+ mutate((t) => patchNodePath(t, session.node, session.path, doc), { key: `inline:${session.node}:${session.path}`, focus: session.node });
1110
+ }, onEscape: inline.end }));
1111
+ })()] })] })] }), _jsx(ResizeHandle, {}), _jsxs("aside", { className: "sm-inspector", ref: inspectorRef, children: [_jsxs("div", { className: "sm-card", children: [selected && !selectedSection && !selectedItem ? (_jsx(UnknownNode, { node: selected, label: labelOf(selected), onRemove: () => { void doRemove(selected.id, true); } })) : selectedSection || selectedItem ? (_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 ?? (selectedItem ? `${selectedItem.title} · global` : null) }), selectedItem && (globalTouched || globalEdit.saveState === "error" || globalEdit.saveState === "refused") && _jsx(SaveIndicator, { state: globalEdit.saveState, pending: globalDirty })] }), _jsx(IconButton, { label: "Clear selection", onClick: () => setSelectedId(null), children: "\u00D7" })] }), tabs.length > 0 && selectedSection && (_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], redirects: redirects[locale] ?? [],
916
1112
  // Code-owned, or no longer in code (only deleting is open then): either way the URL is not the editor's.
917
1113
  fixed: isFixedNode(record, registry) || !!record.fixed, onRename: onRename, onPatch: doPatchPage, drift: findingsAt.get("page") ?? [], onClearPageField: (key) => mutate((t) => ({ ...t, fields: unsetAtPath(t.fields ?? {}, key) })), onRemoveLocale: Object.keys(record.locales).length > 1 &&
918
1114
  !record.fixed &&
@@ -928,7 +1124,7 @@ export function EditorView({ ops, registry, id }) {
928
1124
  syncTexts(selected.id, selectedSection, { ...selected.fields, [key]: value });
929
1125
  } }, `${selected.id}.${ns}.${key}`)) })), selected && selectedSection && (
930
1126
  // Element findings sit under the node id too, but their paths are the element card's (Task 10), not node keys.
931
- _jsx(OrphanFields, { orphans: (findingsAt.get(selected.id) ?? []).filter((f) => !f.path.includes(".elements.")), strip: selected.id, onClear: (path) => mutate((t) => unsetNodePath(t, selected.id, path)) })), selectedItem && selectedId && (_jsx(GlobalInspector, { item: selectedItem, edit: globalEdit.items[selectedItem.name] ?? null, locale: locale, groups: activeGroups, multilingual: registry.locales.supported.length > 1, refOptions: refOptions, tabs: tabs.length > 0 ? _jsx(Tabs, { tabs: tabs.map((t) => t.tab), value: activeTab, onChange: (t) => setTab(t) }) : undefined, pending: globalDirty || globalEdit.saveState !== "saved", busy: globalBusy, inlinePath: inline.session?.node === selectedId ? inline.session.path : undefined, onEditHere: () => { const p = inline.session?.path; inline.end(); if (p)
1127
+ _jsx(OrphanFields, { orphans: (findingsAt.get(selected.id) ?? []).filter((f) => !f.path.includes(".elements.")), strip: selected.id, onClear: (path) => mutate((t) => unsetNodePath(t, selected.id, path), { focus: selected.id }) })), selectedItem && selectedId && (_jsx(GlobalInspector, { item: selectedItem, edit: globalEdit.items[selectedItem.name] ?? null, locale: locale, groups: activeGroups, multilingual: registry.locales.supported.length > 1, refOptions: refOptions, tabs: tabs.length > 0 ? _jsx(Tabs, { tabs: tabs.map((t) => t.tab), value: activeTab, onChange: (t) => setTab(t) }) : undefined, pending: globalDirty || globalEdit.saveState !== "saved", busy: globalBusy, inlinePath: inline.session?.node === selectedId ? inline.session.path : undefined, onEditHere: () => { const p = inline.session?.path; inline.end(); if (p)
932
1128
  reveal(p, selectedItem); }, onChange: (ns, key, value) => {
933
1129
  patchGlobal(selectedItem.name, globalPath(ns, key), value);
934
1130
  if (ns === "fields") {
@@ -936,27 +1132,11 @@ export function EditorView({ ops, registry, id }) {
936
1132
  if (current)
937
1133
  syncTexts(selectedId, selectedItem, { ...current.fields, [key]: value });
938
1134
  }
939
- }, onPublish: () => { void doPublishGlobal(selectedItem.name); }, onDiscard: () => { void doDiscardGlobal(selectedItem.name); }, onUnpublish: () => { void doUnpublishGlobal(selectedItem.name); } }))] }))] }), _jsx("div", { className: "sm-card", children: _jsx(Outline, { selectedId: selectedId, onSelect: select, onMoveTo: doMoveTo, onRemove: doRemove, zones: [
940
- ...zoneOrder.map((zone) => ({
941
- zone, locked: policyOf(zone)?.kind === "locked", canTake: zoneCanTake(zone),
942
- note: findingsAt.get(`zone:${zone}`)?.map(driftMessage)[0],
943
- nodes: nodesIn(zone).map((node) => ({ node, label: labelOf(node), mark: markOf(node.id) })),
944
- })),
945
- // Stored zones the page type no longer declares (spec 2026-09-27 editor safety §5): listed after the real ones.
946
- ...Object.keys(tree?.zones ?? {}).filter((z) => !zoneOrder.includes(z) && (tree.zones[z]?.length ?? 0) > 0).map((zone) => ({
947
- zone, locked: false, canTake: false, removed: true,
948
- nodes: tree.zones[zone].map((node) => ({ node, label: labelOf(node) })),
949
- })),
950
- ], onClearZone: async (zone) => {
951
- const count = tree?.zones[zone]?.length ?? 0;
952
- if (!(await confirm({ title: "Clear this zone?", body: `Its ${count === 1 ? "section is" : `${count} sections are`} removed from the draft. The live page keeps ${count === 1 ? "it" : "them"} until you publish.`, confirmLabel: "Clear zone", destructive: true })))
953
- return;
954
- mutate((t) => clearZone(t, zone));
955
- }, onAddFirst: (zone) => {
1135
+ }, onPublish: () => { void doPublishGlobal(selectedItem.name); }, onDiscard: () => { void doDiscardGlobal(selectedItem.name); }, onUnpublish: () => { void doUnpublishGlobal(selectedItem.name); } }))] }))] }), view === "canvas" && (_jsx("div", { className: "sm-card", children: _jsx(Outline, { selectedId: selectedId, onSelect: select, onMoveTo: doMoveTo, onRemove: doRemove, zones: outlineZones, onClearZone: clearZoneAsked, onAddFirst: (zone) => {
956
1136
  const first = allowedSections(policyOf(zone), registry.sections)[0];
957
1137
  if (first)
958
1138
  doInsert(zone, 0, first);
959
- } }) })] })] }), picker && (_jsx(InsertPicker, { at: picker, policy: policyOf(picker.zone), registry: registry, globalIds: globalIds ?? {}, onInsert: (section) => doInsert(picker.zone, picker.index, section), onInsertGlobal: (item) => doInsertGlobal(picker.zone, picker.index, item), onInsertCustom: () => doInsertCustom(picker.zone, picker.index), onClose: () => setPicker(null) }))] }));
1139
+ } }) }))] })] }), picker && (_jsx(InsertPicker, { at: picker, policy: policyOf(picker.zone), registry: registry, globalIds: globalIds ?? {}, paste: clip ? { title: clip.title, check: pasteCheck(picker.zone), onPaste: () => doPaste(picker.zone, picker.index) } : undefined, onInsert: (section) => doInsert(picker.zone, picker.index, section), onInsertGlobal: (item) => doInsertGlobal(picker.zone, picker.index, item), onInsertCustom: () => doInsertCustom(picker.zone, picker.index), onClose: () => setPicker(null) }))] }));
960
1140
  }
961
1141
  /** The selected node's ref field (if any) resolves to its target
962
1142
  * collection's loaded options — the EntryForm shape, section-scoped. */
@@ -1,5 +1,6 @@
1
1
  import type { ZonePolicy } from "../../zones.ts";
2
2
  import type { AdminRegistry, AdminSection, AdminGlobal } from "../serialize.ts";
3
+ import type { PasteCheck } from "./section-clipboard.ts";
3
4
  /** Where the "+" put the panel: `y` is 12px under the button's centre (EditorView). */
4
5
  export type PickerAt = {
5
6
  zone: string;
@@ -20,8 +21,14 @@ export declare function placePicker(at: PickerAt, height: number, viewport: {
20
21
  width: number;
21
22
  height: number;
22
23
  }): PickerPlace;
23
- export declare function InsertPicker({ at, policy, registry, globalIds, onInsert, onInsertGlobal, onInsertCustom, onClose }: {
24
+ export declare function InsertPicker({ at, policy, registry, globalIds, paste, onInsert, onInsertGlobal, onInsertCustom, onClose }: {
24
25
  at: PickerAt;
26
+ /** The section clipboard's entry, first in the list (spec 2026-10-19 editor at launch §3): disabled with the reason when the zone refuses. */
27
+ paste?: {
28
+ title: string;
29
+ check: PasteCheck;
30
+ onPaste(): void;
31
+ };
25
32
  policy: ZonePolicy | undefined;
26
33
  registry: AdminRegistry;
27
34
  /** Global item name → row id; an item without one cannot be placed. */
@@ -33,7 +33,7 @@ function firstPlace(at) {
33
33
  return { left: at.x, top: at.y };
34
34
  return placePicker(at, 0, { width: window.innerWidth, height: window.innerHeight });
35
35
  }
36
- export function InsertPicker({ at, policy, registry, globalIds, onInsert, onInsertGlobal, onInsertCustom, onClose }) {
36
+ export function InsertPicker({ at, policy, registry, globalIds, paste, onInsert, onInsertGlobal, onInsertCustom, onClose }) {
37
37
  const sections = allowedSections(policy, registry.sections);
38
38
  const globals = allowedGlobals(policy, registry.globals, globalIds);
39
39
  const custom = allowsCustom(policy);
@@ -46,5 +46,5 @@ export function InsertPicker({ at, policy, registry, globalIds, onInsert, onInse
46
46
  return;
47
47
  setPlace(placePicker(at, el.scrollHeight + 2, { width: window.innerWidth, height: window.innerHeight }));
48
48
  }, [at]);
49
- return (_jsx("div", { style: { position: "fixed", inset: 0, zIndex: 60 }, onClick: onClose, children: _jsxs("div", { ref: panel, className: "sm-picker", onClick: (e) => e.stopPropagation(), style: { position: "absolute", overflowY: "auto", ...place }, children: [_jsxs(SectionLabel, { caps: true, style: { padding: "6px 8px 8px" }, children: ["Add to ", at.zone] }), sections.map((section) => (_jsxs("button", { type: "button", className: "sm-picker__item", onClick: () => onInsert(section), children: [_jsx("span", { className: "sm-picker__icon", children: section.icon ?? "▤" }), _jsxs("span", { style: { minWidth: 0 }, children: [_jsx("span", { style: { display: "block", fontWeight: 600 }, children: section.title }), section.description && (_jsx("span", { style: { display: "block", fontSize: "var(--text-sm)", color: "var(--text-muted)" }, children: section.description }))] })] }, section.name))), custom && (_jsxs("button", { type: "button", className: "sm-picker__item", onClick: onInsertCustom, children: [_jsx("span", { className: "sm-picker__icon", children: "\u25A6" }), _jsxs("span", { style: { minWidth: 0 }, children: [_jsx("span", { style: { display: "block", fontWeight: 600 }, children: registry.custom.title }), _jsx("span", { style: { display: "block", fontSize: "var(--text-sm)", color: "var(--text-muted)" }, children: registry.custom.description })] })] })), globals.length > 0 && (_jsxs(_Fragment, { children: [_jsx(SectionLabel, { caps: true, style: { padding: "8px 8px 4px" }, children: "Site" }), globals.map((item) => (_jsxs("button", { type: "button", className: "sm-picker__item", onClick: () => onInsertGlobal(item), children: [_jsx("span", { className: "sm-picker__icon", children: "\u26D3" }), _jsxs("span", { style: { minWidth: 0 }, children: [_jsx("span", { style: { display: "block", fontWeight: 600 }, children: item.title }), _jsx("span", { style: { display: "block", fontSize: "var(--text-sm)", color: "var(--text-muted)" }, children: "Global \u00B7 edited in one place" })] })] }, `global-${item.name}`)))] })), sections.length === 0 && globals.length === 0 && !custom && (_jsx("p", { style: { color: "var(--text-muted)", padding: 10 }, children: "This zone doesn't accept insertions." }))] }) }));
49
+ return (_jsx("div", { style: { position: "fixed", inset: 0, zIndex: 60 }, onClick: onClose, children: _jsxs("div", { ref: panel, className: "sm-picker", onClick: (e) => e.stopPropagation(), style: { position: "absolute", overflowY: "auto", ...place }, children: [_jsxs(SectionLabel, { caps: true, style: { padding: "6px 8px 8px" }, children: ["Add to ", at.zone] }), paste && (_jsxs("button", { type: "button", className: "sm-picker__item sm-picker__item--paste", disabled: !paste.check.ok, title: paste.check.ok ? undefined : paste.check.reason, onClick: paste.onPaste, children: [_jsx("span", { className: "sm-picker__icon", children: "\u2398" }), _jsxs("span", { style: { minWidth: 0 }, children: [_jsxs("span", { style: { display: "block", fontWeight: 600 }, children: ["Paste \"", paste.title, "\""] }), _jsx("span", { style: { display: "block", fontSize: "var(--text-sm)", color: "var(--text-muted)" }, children: paste.check.ok ? "From the clipboard" : paste.check.reason })] })] })), sections.map((section) => (_jsxs("button", { type: "button", className: "sm-picker__item", onClick: () => onInsert(section), children: [_jsx("span", { className: "sm-picker__icon", children: section.icon ?? "▤" }), _jsxs("span", { style: { minWidth: 0 }, children: [_jsx("span", { style: { display: "block", fontWeight: 600 }, children: section.title }), section.description && (_jsx("span", { style: { display: "block", fontSize: "var(--text-sm)", color: "var(--text-muted)" }, children: section.description }))] })] }, section.name))), custom && (_jsxs("button", { type: "button", className: "sm-picker__item", onClick: onInsertCustom, children: [_jsx("span", { className: "sm-picker__icon", children: "\u25A6" }), _jsxs("span", { style: { minWidth: 0 }, children: [_jsx("span", { style: { display: "block", fontWeight: 600 }, children: registry.custom.title }), _jsx("span", { style: { display: "block", fontSize: "var(--text-sm)", color: "var(--text-muted)" }, children: registry.custom.description })] })] })), globals.length > 0 && (_jsxs(_Fragment, { children: [_jsx(SectionLabel, { caps: true, style: { padding: "8px 8px 4px" }, children: "Site" }), globals.map((item) => (_jsxs("button", { type: "button", className: "sm-picker__item", onClick: () => onInsertGlobal(item), children: [_jsx("span", { className: "sm-picker__icon", children: "\u26D3" }), _jsxs("span", { style: { minWidth: 0 }, children: [_jsx("span", { style: { display: "block", fontWeight: 600 }, children: item.title }), _jsx("span", { style: { display: "block", fontSize: "var(--text-sm)", color: "var(--text-muted)" }, children: "Global \u00B7 edited in one place" })] })] }, `global-${item.name}`)))] })), sections.length === 0 && globals.length === 0 && !custom && (_jsx("p", { style: { color: "var(--text-muted)", padding: 10 }, children: "This zone doesn't accept insertions." }))] }) }));
50
50
  }
@@ -17,6 +17,6 @@ export function InspectorFields({ groups, values, render }) {
17
17
  const entries = visibleEntries(g.entries, values);
18
18
  if (entries.length === 0)
19
19
  return null;
20
- return (_jsxs("div", { className: "sm-inspector__group", children: [g.title && _jsx(SectionLabel, { caps: true, children: g.title }), entries.map((e) => render(e, { emptyLabel: e.ns === "styles" ? "Default" : undefined, scope }))] }, g.title ?? i));
20
+ return (_jsxs("div", { className: "sm-inspector__group", "data-smd-undo": "", children: [g.title && _jsx(SectionLabel, { caps: true, children: g.title }), entries.map((e) => render(e, { emptyLabel: e.ns === "styles" ? "Default" : undefined, scope }))] }, g.title ?? i));
21
21
  }) }));
22
22
  }