smoodly 0.0.29 → 0.0.31

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 (35) hide show
  1. package/README.md +75 -9
  2. package/dist/admin/editor/CanvasWarning.d.ts +5 -0
  3. package/dist/admin/editor/CanvasWarning.js +10 -0
  4. package/dist/admin/editor/EditorView.js +47 -12
  5. package/dist/admin/editor/canvas-diagnosis.d.ts +49 -0
  6. package/dist/admin/editor/canvas-diagnosis.js +106 -0
  7. package/dist/admin/editor/crash-copy.d.ts +7 -0
  8. package/dist/admin/editor/crash-copy.js +13 -0
  9. package/dist/admin/editor/protocol.d.ts +2 -0
  10. package/dist/admin/editor/useCanvasBridge.d.ts +3 -0
  11. package/dist/admin/editor/useCanvasBridge.js +14 -7
  12. package/dist/admin/editor/useCanvasDiagnosis.d.ts +7 -0
  13. package/dist/admin/editor/useCanvasDiagnosis.js +24 -0
  14. package/dist/admin/index.d.ts +1 -0
  15. package/dist/admin/next/bridge.js +4 -2
  16. package/dist/admin/next/index.d.ts +0 -1
  17. package/dist/admin/next/index.js +6 -1
  18. package/dist/admin/ui/ActionMenu.d.ts +20 -0
  19. package/dist/admin/ui/ActionMenu.js +33 -7
  20. package/dist/custom/CustomSection.d.ts +1 -0
  21. package/dist/custom/CustomSection.js +4 -1
  22. package/dist/marks.d.ts +12 -0
  23. package/dist/marks.js +14 -0
  24. package/dist/next/canvas-boundary.d.ts +16 -0
  25. package/dist/next/canvas-boundary.js +25 -0
  26. package/dist/next/page-renderer.d.ts +3 -1
  27. package/dist/next/page-renderer.js +33 -4
  28. package/dist/pairing.js +10 -3
  29. package/dist/render.d.ts +12 -1
  30. package/dist/render.js +9 -3
  31. package/dist/section-crash.d.ts +11 -0
  32. package/dist/section-crash.js +16 -0
  33. package/dist/section-guard.d.ts +27 -0
  34. package/dist/section-guard.js +41 -0
  35. package/package.json +1 -1
package/README.md CHANGED
@@ -252,7 +252,7 @@ Implemented:
252
252
  is stored as entries, spec 2026-09-07), not only through the generated
253
253
  views.
254
254
 
255
- - Admin login: `supabaseAdminAuth` + `gateAdminOp` around the ops seam;
255
+ - Admin login: `supabaseAdminAuth` (from `smoodly/admin`) + `gateAdminOp` around the ops seam;
256
256
  `LoginView` + `useAdminSession` in the shell; `createSmoodlyAdmin({ auth })`.
257
257
  Env: `smoodlyEnv()` / `createSmoodlyClient()`.
258
258
 
@@ -477,6 +477,26 @@ Implemented:
477
477
  what (a lock in the lists, a line under the top bar) and announces
478
478
  saves so a clean view refreshes and a dirty one freezes early. Schema
479
479
  version 2 adds the draft-pointer foreign keys and the channel policies.
480
+ Only the editor's own edits freeze a view (2026-09-27): two editors
481
+ opening a fresh page at once both fill its locked hero, and the fill
482
+ that lands second used to freeze its view under "Your unsaved changes
483
+ here will be discarded" with nothing changed; now that view loads the
484
+ other's quietly, on the refusal or the `saved` broadcast. This was the
485
+ local-only timeout of "the guard alone" (CI's slower sign-in let the
486
+ first fill land first). Tests: `concurrency.spec.ts` ("two editors
487
+ opening a new page at once", with the channel cut and on; the fill
488
+ save is held so the race is deterministic).
489
+ - ActionMenu near the screen's edge (2026-09-27): a row menu close to
490
+ the bottom opened below the viewport, where a fixed element cannot be
491
+ scrolled to, and the scroll event from bringing a row into view
492
+ arrived a frame after the click and closed the menu it had opened.
493
+ It now opens upward by the Combobox's `opensUp`, and closes only on a
494
+ scroll that moved its button. This was the Members test's flake (about
495
+ 1 in 8 runs; the list was long because every concurrency run left
496
+ its second member behind — `concurrency.spec.ts` removes it in
497
+ `afterAll` since 2026-09-28, and `members.spec.ts` its invited Auth
498
+ user, which Remove member leaves in place). Tests: `admin-action-menu`, `members.spec.ts` (a
499
+ late scroll event, a real move, a row at the bottom of the screen).
480
500
  - The security pass over the database boundary (spec 2026-09-26 security
481
501
  pass): the presence channel admits a signed-in member of the topic's
482
502
  space (`smoodly_presence_member()`, the socket carries the session
@@ -522,6 +542,50 @@ Implemented:
522
542
  `admin-unknown-node`, the store contracts (`scanTrees`,
523
543
  `scanEntries`, the pinning tests), `entry-store.supabase` (the
524
544
  localized fix across two schemas), `examples/site/e2e/health.spec.ts`.
545
+ - Shadowed-page detector (spec 2026-09-27 shadowed-page detector): a
546
+ canvas whose bridge never speaks within 4 s of the frame's load is
547
+ diagnosed — the editor fetches the canvas URL once and
548
+ `admin/editor/canvas-diagnosis.ts` names another route, a route file
549
+ that drops `searchParams`, a crash, a 404, a redirect, a refusal, a
550
+ framing header or a script error, as an editor sentence and a "For
551
+ your developer" line (`editor/CanvasWarning.tsx`, in place of
552
+ "Selection unavailable"). The renderer writes
553
+ `<meta name="smoodly-draft">` in draft mode only. No reserved-path
554
+ list (OQ 23 closed). Tests: `admin-canvas-diagnosis`,
555
+ `admin-canvas-warning`, `next-page-renderer` (the marker),
556
+ `admin-bridge`, `examples/site/e2e/editor.spec.ts`.
557
+ - cacheComponents unsupported (spec 2026-09-27 cache components): under
558
+ Next's Cache Components the catch-all is served from a fallback shell
559
+ that commits 200, so 404s and 308s are lost; the renderer warns once
560
+ per renderer when `process.env.__NEXT_CACHE_COMPONENTS` is on, and the
561
+ Next.js guide says why. OQ 19 closed. Tests: `next-page-renderer`
562
+ ("cacheComponents").
563
+ - Section guard (spec 2026-09-27 section guard): `Zone` calls a
564
+ plain-function section view inside try/catch (an async one through
565
+ `SectionGuard`, in place), so a throw in a section's own code leaves it
566
+ out and the page renders — nothing live, a placeholder in the canvas
567
+ and under `next dev`; the adapter's `rethrow` (Next: `unstable_rethrow`)
568
+ lets control flow through. In editor mode the Next adapter also wraps
569
+ each section in `<Suspense>` + the client `CanvasBoundary`, the bridge
570
+ reports crashed sections, the page editor lists them, and Publish asks
571
+ first. A healthy page's HTML is unchanged. Tests: `section-crash`,
572
+ `render` ("the section guard"), `next-canvas-boundary`,
573
+ `next-page-renderer`, `admin-crash-copy`, `admin-canvas-diagnosis`,
574
+ `examples/site/e2e/sections.spec.ts`.
575
+ - No admin on the live site (2026-09-27): `supabaseAdminAuth` moved from
576
+ `smoodly/admin/next` to `smoodly/admin` (its `AuthConfig` type too).
577
+ `smoodly/server.ts`, which every site route imports, took it from the
578
+ `admin/next` barrel beside `createSmoodlyAdmin`, so the admin shell was
579
+ a client module of every site route. Confirmed by removing the import
580
+ and rebuilding; the starter's home page, production build, went from
581
+ 1,616,602 to 628,849 bytes of JavaScript (8 chunks either way, the
582
+ 1,050,601-byte chunk holding `AdminApp` gone). The rest of the page's
583
+ JavaScript is the site's own: of a 62,848-byte chunk, next-intl is
584
+ ~34 KB, `next/link` and `next/image` most of the rest, and the
585
+ editor-only modules ~3.4 KB (`EditorBridge` ~2.5 KB, the canvas net
586
+ ~0.9 KB) — too little to lazy-load. Tests: `site-entries` (every entry
587
+ but `smoodly/admin/next` reaches no client module except those two),
588
+ `examples/site/e2e/site-bundle.spec.ts` (CI only: production chunks).
525
589
 
526
590
  Not yet: canvas drag-and-drop (list fields, the outline, manual
527
591
  collections and the pages tree drag since 2026-09-09 — spec
@@ -549,6 +613,16 @@ after both are on the registry. From the repo root:
549
613
  `prepublishOnly` rebuilds each package. The Claude Code skill
550
614
  `.claude/skills/releasing-smoodly` carries the same order with its checks.
551
615
 
616
+ ## Follow-ups (logged 2026-09-27, shadowed-page detector spec)
617
+
618
+ - A section's crash on a live page after data changed outside the page
619
+ editor (an entry or a global item edited in its form): the guard
620
+ catches a throw in the section's own view, but one inside a component
621
+ it renders, in a richtext element's view or in a `'use client'`
622
+ section still fails the page, and no canvas previews those forms.
623
+ Catching it live needs `<Suspense>` (spec 2026-09-27 section guard,
624
+ measured). Revisit if a real site hits it.
625
+
552
626
  ## Follow-ups (logged 2026-09-27, editor safety spec)
553
627
 
554
628
  - Scan paging: `health.scan` makes one bulk read per table (versions by
@@ -1329,14 +1403,6 @@ the toolbar instead of pushing the content.
1329
1403
  the vocabulary once instead of per field, dedupe there.
1330
1404
  - The entry form's column is narrower than the inspector, so the default
1331
1405
  toolbar wraps to two rows there; the inspector shows it on one.
1332
- - The example site's production bundle loads one shared chunk (~750 KB)
1333
- on every site page holding the package's admin client modules — the
1334
- shell, the Supabase client and now the TipTap widget — because
1335
- `next/page-renderer.tsx` imports the editor bridge from
1336
- `admin/next/bridge.tsx` and Turbopack bundles the package's client
1337
- modules together. Measured on the 2026-09-08 build; whether the shell
1338
- was already there before the widget is unverified. The fix is to give
1339
- the bridge its own entry outside the admin tree and re-measure.
1340
1406
 
1341
1407
  ## Follow-ups (logged 2026-09-07, starter)
1342
1408
 
@@ -0,0 +1,5 @@
1
+ import { type CanvasCause } from "./canvas-diagnosis.ts";
2
+ export declare function CanvasWarning({ cause, path }: {
3
+ cause: CanvasCause;
4
+ path: string;
5
+ }): import("react").JSX.Element;
@@ -0,0 +1,10 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ // The warning above a canvas Smoodly could not show (spec 2026-09-27
3
+ // shadowed-page detector §5): the editor's sentence, then the line for
4
+ // whoever fixes it, so the editor can forward it as is.
5
+ import { Banner } from "../ui/primitives.js";
6
+ import { canvasMessage } from "./canvas-diagnosis.js";
7
+ export function CanvasWarning({ cause, path }) {
8
+ const { editor, developer } = canvasMessage(cause, path);
9
+ return (_jsxs(Banner, { tone: "warn", children: [_jsx("p", { children: editor }), _jsxs("p", { children: [_jsx("strong", { children: "For your developer:" }), " ", developer] })] }));
10
+ }
@@ -50,6 +50,9 @@ import { GlobalInspector } from "./GlobalInspector.js";
50
50
  import { localeFromUrl } from "./locale-url.js";
51
51
  import { plusButtonsFor } from "./overlay-geometry.js";
52
52
  import { useCanvasBridge } from "./useCanvasBridge.js";
53
+ import { useCanvasDiagnosis } from "./useCanvasDiagnosis.js";
54
+ import { CanvasWarning } from "./CanvasWarning.js";
55
+ import { crashLines, publishCrashConfirm } from "./crash-copy.js";
53
56
  import { useInlineSession } from "./useInlineSession.js";
54
57
  import { usePageLocale } from "./usePageLocale.js";
55
58
  import { useGlobalEdit } from "./useGlobalEdit.js";
@@ -118,6 +121,10 @@ export function EditorView({ ops, registry, id }) {
118
121
  /** A global edit since the item's last save ack; and any global edit on this canvas yet. */
119
122
  const [globalDirty, setGlobalDirty] = useState(false);
120
123
  const [globalTouched, setGlobalTouched] = useState(false);
124
+ /** The editor changed something on this canvas. The locked-zone fill on load is not such a change: when
125
+ * another editor's fill landed first, the refusal and their saved broadcast load their version quietly. */
126
+ const editedRef = useRef(false);
127
+ editedRef.current = touched || globalTouched;
121
128
  // Global item name → row id, loaded once per editor mount: the picker's
122
129
  // Global group and a bound locked zone need the id to place a node.
123
130
  const [globalIds, setGlobalIds] = useState(null);
@@ -149,8 +156,8 @@ export function EditorView({ ops, registry, id }) {
149
156
  setDirty(false);
150
157
  presence?.announce(pageTarget(target), r.data.draft?.savedAt ?? null);
151
158
  }
152
- else if (isStale(r))
153
- setStale(staleInfoOf(r));
159
+ else if (isStale(r) && editedRef.current)
160
+ setStale(staleInfoOf(r)); // untouched: the effect below reloads quietly
154
161
  return r;
155
162
  },
156
163
  onSaved: () => inlineRef.current?.onSaved(),
@@ -188,7 +195,7 @@ export function EditorView({ ops, registry, id }) {
188
195
  // Canvas-originated selection: the node is under the cursor already, so
189
196
  // this path deliberately skips the scroll every other selection does.
190
197
  // (`onCanvasSelect` is declared further down; the handler runs at event time.)
191
- const { rects, emptyZones, fields: fieldRects, docH, geometry, postToCanvas } = useCanvasBridge({
198
+ const { rects, emptyZones, fields: fieldRects, docH, geometry, postToCanvas, onFrameLoad, crashed } = useCanvasBridge({
192
199
  iframeRef, iframeSrc,
193
200
  onSelect: (id, path, x, y) => onCanvasSelect(id, path, x, y),
194
201
  onHover: (id, path) => {
@@ -197,6 +204,8 @@ export function EditorView({ ops, registry, id }) {
197
204
  },
198
205
  onRendered: () => inlineRef.current?.onRendered(),
199
206
  });
207
+ // A canvas the bridge never spoke from is diagnosed and named (spec 2026-09-27 shadowed-page detector).
208
+ const diagnosis = useCanvasDiagnosis(iframeSrc, geometry);
200
209
  // ── load: draft mode FIRST, then the record; only then point the iframe
201
210
  // at the site route (an iframe loaded before the cookie exists would
202
211
  // render the published tree).
@@ -334,6 +343,15 @@ export function EditorView({ ops, registry, id }) {
334
343
  const name = globalNameOf(id);
335
344
  return name ? registryRef.current.globals.find((s) => s.name === name) ?? null : null;
336
345
  }, [globalOf]);
346
+ // The crashed sections' banner lines (spec 2026-09-27 section guard §6): the section's title from the
347
+ // registry, a placed global's item title, the node type when the registry has no name for it.
348
+ const crashTitleOf = (id) => {
349
+ const node = treeRef.current ? findNode(treeRef.current, id) : null;
350
+ if (!node)
351
+ return "Section";
352
+ return globalItemOf(id)?.title ?? sectionOf(node.type)?.title ?? node.type;
353
+ };
354
+ const crashedLines = crashLines(crashed, crashTitleOf);
337
355
  // ── ref fields inside sections: same option loading as EntryForm ──────
338
356
  const refTargets = useMemo(() => {
339
357
  const out = {};
@@ -398,16 +416,26 @@ export function EditorView({ ops, registry, id }) {
398
416
  }, [globalEdit.stale]);
399
417
  /** Load their version (§4): drop the frozen state; the load effect resets the tree, the globals and the flags. */
400
418
  const loadTheirs = () => { setStale(null); reset(); setReloadKey((k) => k + 1); };
401
- // Someone else saved this page (§5): clean → their version silently; unsaved edits anywhere → the freeze at once.
419
+ /** Their version without the freeze: for a view with nothing of the editor's own to lose. */
420
+ const loadTheirsQuietly = useCallback(() => { reset(); keepSelectionRef.current = true; setReloadKey((k) => k + 1); }, [reset]);
421
+ // A refused save the editor never made — the locked-zone fill, when another editor's fill landed first.
422
+ // Decided by editedRef, not by `stale`: the refusal's setStale and the autosave's state can render apart.
423
+ useEffect(() => {
424
+ if (saveState === "stale" && !editedRef.current)
425
+ loadTheirsQuietly();
426
+ }, [saveState, loadTheirsQuietly]);
427
+ // Someone else saved this page (§5): clean or untouched → their version silently; the editor's own unsaved edits → the freeze at once.
402
428
  useSavedElsewhere(target, (event) => {
403
429
  if (stale)
404
430
  return;
405
- if (dirty || globalDirty || saveState !== "saved" || globalEdit.saveState !== "saved")
406
- setStale({ by: event.email, at: Date.now() });
407
- else {
431
+ if (!(dirty || globalDirty || saveState !== "saved" || globalEdit.saveState !== "saved")) {
408
432
  keepSelectionRef.current = true;
409
433
  setReloadKey((k) => k + 1);
410
434
  }
435
+ else if (editedRef.current)
436
+ setStale({ by: event.email, at: Date.now() });
437
+ else
438
+ loadTheirsQuietly(); // only the fill is pending: drop it
411
439
  });
412
440
  // Selecting from the outline must reveal the block. The canvas column is
413
441
  // the scroller now (the iframe has no scrollbar of its own — it is sized to
@@ -512,12 +540,16 @@ export function EditorView({ ops, registry, id }) {
512
540
  return;
513
541
  const template = registryRef.current.pages.find((p) => p.name === record.template);
514
542
  const filled = fillLockedZones(tree, template?.zones ?? {}, registryRef.current.sections, globalIds);
515
- if (filled !== tree)
516
- mutate(() => filled);
543
+ // Saved like the fill on load, and like it not an edit of the editor's (editedRef).
544
+ if (filled !== tree) {
545
+ setTree(filled);
546
+ treeRef.current = filled;
547
+ mark();
548
+ }
517
549
  // `tree` is deliberately not a dependency: this runs when the ids land
518
550
  // or a new draft loads, not on every keystroke.
519
551
  // eslint-disable-next-line react-hooks/exhaustive-deps
520
- }, [globalIds, record, mutate, tree === null]);
552
+ }, [globalIds, record, mark, tree === null]);
521
553
  // ── tree gestures ────────────────────────────────────────────────────
522
554
  const nodesIn = useCallback((zone) => tree?.zones[zone] ?? [], [tree]);
523
555
  const zoneCanTake = useCallback((zone) => canInsert(policyOf(zone), nodesIn(zone).length), [policyOf, nodesIn]);
@@ -602,6 +634,9 @@ export function EditorView({ ops, registry, id }) {
602
634
  const doPatchPage = (ns, key, value) => mutate((t) => patchPage(t, ns, key, value));
603
635
  // ── publish ──────────────────────────────────────────────────────────
604
636
  const doPublish = async () => {
637
+ // A section the preview shows as crashed (spec 2026-09-27 section guard §6): ask before it goes live.
638
+ if (crashedLines.length > 0 && !(await confirm(publishCrashConfirm(crashedLines))))
639
+ return;
605
640
  setBanner(null);
606
641
  setPageBusy("publish");
607
642
  // Never publish behind a pending debounce.
@@ -856,8 +891,8 @@ export function EditorView({ ops, registry, id }) {
856
891
  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); } })),
857
892
  },
858
893
  { label },
859
- ] }) }), right: _jsxs(_Fragment, { children: [(touched || saveState === "error") && _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: withVersionParam(siteUrl, versionId) }) })), _jsxs("div", { className: "sm-editor", inert: stale ? true : undefined, 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: banner.fields && banner.fields.length > 0 ? "warn" : "error", onDismiss: () => setBanner(null), children: [_jsx("p", { children: banner.text }), _jsx(FieldErrorList, { fields: banner.fields ?? [] })] })), 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, onClick: (e) => { if (e.target === e.currentTarget)
860
- setSelectedId(null); }, 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, 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 && (() => {
894
+ ] }) }), right: _jsxs(_Fragment, { children: [(touched || saveState === "error") && _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: withVersionParam(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)
895
+ 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 && (() => {
861
896
  const { session } = inline.richtext;
862
897
  const item = globalItemOf(session.node);
863
898
  const node = !item && tree ? findNode(tree, session.node) : null;
@@ -0,0 +1,49 @@
1
+ /** The meta Smoodly's page renderer writes in draft mode (§3). */
2
+ export declare const DRAFT_META_NAME = "smoodly-draft";
3
+ /** The attribute on the <style> the editor bridge server-renders. */
4
+ export declare const BRIDGE_ATTR = "data-smoodly-bridge";
5
+ export type CanvasProbe = {
6
+ kind: "failed";
7
+ } | {
8
+ kind: "response";
9
+ requested: string;
10
+ url: string;
11
+ status: number;
12
+ redirected: boolean;
13
+ html: string;
14
+ frameOptions: string | null;
15
+ csp: string | null;
16
+ };
17
+ export type CanvasCause = {
18
+ kind: "unreachable";
19
+ } | {
20
+ kind: "redirect";
21
+ to: string;
22
+ } | {
23
+ kind: "notFound";
24
+ } | {
25
+ kind: "crashed";
26
+ status: number;
27
+ } | {
28
+ kind: "refused";
29
+ status: number;
30
+ } | {
31
+ kind: "shadowed";
32
+ } | {
33
+ kind: "noSearchParams";
34
+ } | {
35
+ kind: "framing";
36
+ header: string;
37
+ value: string;
38
+ } | {
39
+ kind: "script";
40
+ };
41
+ /** Read a fetch Response into a probe. A constructed Response has no url; the requested one stands in. */
42
+ export declare function probeOf(requested: string, res: Response): Promise<CanvasProbe>;
43
+ /** The first matching cause, in the spec's order (§4). */
44
+ export declare function diagnoseCanvas(probe: CanvasProbe): CanvasCause;
45
+ /** The warning's two lines for a cause (§5, verbatim). */
46
+ export declare function canvasMessage(cause: CanvasCause, path: string): {
47
+ editor: string;
48
+ developer: string;
49
+ };
@@ -0,0 +1,106 @@
1
+ // Why the page editor's canvas stayed silent (spec 2026-09-27
2
+ // shadowed-page detector §3–§5). When the bridge never speaks, the editor
3
+ // fetches the canvas URL once; this module names the cause from the
4
+ // response and words it twice — a sentence for the editor, a line for
5
+ // whoever fixes it. Pure: no React, no store.
6
+ /** The meta Smoodly's page renderer writes in draft mode (§3). */
7
+ export const DRAFT_META_NAME = "smoodly-draft";
8
+ /** The attribute on the <style> the editor bridge server-renders. */
9
+ export const BRIDGE_ATTR = "data-smoodly-bridge";
10
+ /** Read a fetch Response into a probe. A constructed Response has no url; the requested one stands in. */
11
+ export async function probeOf(requested, res) {
12
+ return {
13
+ kind: "response", requested, url: res.url || requested, status: res.status, redirected: res.redirected,
14
+ html: await res.text(),
15
+ frameOptions: res.headers.get("x-frame-options"),
16
+ csp: res.headers.get("content-security-policy"),
17
+ };
18
+ }
19
+ /** The first matching cause, in the spec's order (§4). */
20
+ export function diagnoseCanvas(probe) {
21
+ if (probe.kind === "failed")
22
+ return { kind: "unreachable" };
23
+ if (probe.redirected)
24
+ return { kind: "redirect", to: redirectTarget(probe.url, probe.requested) };
25
+ if (probe.status === 404)
26
+ return { kind: "notFound" };
27
+ if (probe.status >= 500)
28
+ return { kind: "crashed", status: probe.status };
29
+ if (probe.status < 200 || probe.status > 299)
30
+ return { kind: "refused", status: probe.status };
31
+ if (!probe.html.includes(`name="${DRAFT_META_NAME}"`))
32
+ return { kind: "shadowed" };
33
+ if (!probe.html.includes(`${BRIDGE_ATTR}=`))
34
+ return { kind: "noSearchParams" };
35
+ const framing = framingHeader(probe.frameOptions, probe.csp, new URL(probe.requested).origin);
36
+ if (framing)
37
+ return { kind: "framing", ...framing };
38
+ return { kind: "script" };
39
+ }
40
+ /** The redirect's end as a visitor would read it: path + search without the editor parameter, the full URL off-origin. */
41
+ function redirectTarget(url, requested) {
42
+ const u = new URL(url);
43
+ u.searchParams.delete("smoodly-editor");
44
+ return u.origin === new URL(requested).origin ? u.pathname + u.search : u.href;
45
+ }
46
+ /** The header that keeps the canvas iframe out, if any: X-Frame-Options DENY, or a frame-ancestors without the site's own origin. */
47
+ function framingHeader(frameOptions, csp, origin) {
48
+ if (frameOptions?.trim().toLowerCase() === "deny")
49
+ return { header: "X-Frame-Options", value: frameOptions };
50
+ const directive = csp?.split(";").map((d) => d.trim()).find((d) => /^frame-ancestors(\s|$)/i.test(d));
51
+ if (!directive)
52
+ return null;
53
+ const sources = directive.split(/\s+/).slice(1);
54
+ const allows = sources.some((s) => s.toLowerCase() === "'self'" || s === "*" || s.replace(/\/$/, "") === origin);
55
+ return allows ? null : { header: "Content-Security-Policy", value: directive };
56
+ }
57
+ /** The warning's two lines for a cause (§5, verbatim). */
58
+ export function canvasMessage(cause, path) {
59
+ switch (cause.kind) {
60
+ case "shadowed":
61
+ return {
62
+ editor: `This page can't be shown here — another route in the site's code answers ${path}.`,
63
+ developer: `A route other than Smoodly's catch-all answers ${path} (the response has no Smoodly draft marker). Rename the page, or move or remove that route.`,
64
+ };
65
+ case "noSearchParams":
66
+ return {
67
+ editor: "This page can't be shown in the editor — its route is missing a setting.",
68
+ developer: `Smoodly rendered ${path} without the editor bridge. The route file must pass searchParams: renderSmoodlyPath(path, { searchParams }).`,
69
+ };
70
+ case "crashed":
71
+ return {
72
+ editor: "This page failed to render.",
73
+ developer: `${path} answered ${cause.status}. The server log has the error — often the page shape, or a component inside a section, throwing.`,
74
+ };
75
+ case "notFound":
76
+ return {
77
+ editor: `Nothing on the site answers ${path}.`,
78
+ developer: `${path} answered 404. Check that the locale is in the app's routing and that the catch-all route file is in place.`,
79
+ };
80
+ case "redirect":
81
+ return {
82
+ editor: `${path} sends visitors to another address, so this page can't be shown.`,
83
+ developer: `${path} redirects to ${cause.to}. Check middleware and the i18n routing.`,
84
+ };
85
+ case "refused":
86
+ return {
87
+ editor: `The site refused to show ${path}.`,
88
+ developer: `${path} answered ${cause.status}. Middleware or the host refused the request; the draft-mode cookie must reach the page.`,
89
+ };
90
+ case "framing":
91
+ return {
92
+ editor: "The site doesn't allow its pages to be shown inside the editor.",
93
+ developer: `${path} sends ${cause.header}: ${cause.value}, which blocks the canvas iframe. Allow the site's own origin (SAMEORIGIN, frame-ancestors 'self').`,
94
+ };
95
+ case "script":
96
+ return {
97
+ editor: "The page loaded but didn't connect to the editor.",
98
+ developer: `${path} rendered with the editor bridge, but its script never reached the editor. Look for an error in the canvas's browser console.`,
99
+ };
100
+ case "unreachable":
101
+ return {
102
+ editor: "The site didn't answer.",
103
+ developer: `The request for ${path} failed before any response — is the site running? A redirect to another origin fails this way too.`,
104
+ };
105
+ }
106
+ }
@@ -0,0 +1,7 @@
1
+ import type { CrashedSection } from "../../marks.ts";
2
+ export declare function crashLines(crashed: CrashedSection[], titleOf: (id: string) => string): string[];
3
+ export declare function publishCrashConfirm(lines: string[]): {
4
+ title: string;
5
+ body: string;
6
+ confirmLabel: string;
7
+ };
@@ -0,0 +1,13 @@
1
+ export function crashLines(crashed, titleOf) {
2
+ return crashed.map(({ id, live }) => live === "left-out"
3
+ ? `“${titleOf(id)}” failed to render in the preview — on the live site it is left out.`
4
+ : `“${titleOf(id)}” failed to render in the preview — on the live site the whole page would fail to load.`);
5
+ }
6
+ export function publishCrashConfirm(lines) {
7
+ const n = lines.length;
8
+ return {
9
+ title: n === 1 ? "Publish with 1 broken section?" : `Publish with ${n} broken sections?`,
10
+ body: [...lines, `Fix or remove ${n === 1 ? "it" : "them"} in the editor, or publish anyway.`].join(" "),
11
+ confirmLabel: "Publish anyway",
12
+ };
13
+ }
@@ -1,3 +1,4 @@
1
+ import type { CrashedSection } from "../../marks.ts";
1
2
  export type BridgeRect = {
2
3
  id: string;
3
4
  x: number;
@@ -28,6 +29,7 @@ export type FromBridge = {
28
29
  emptyZones: ZoneRect[];
29
30
  fields: FieldRect[];
30
31
  docH: number;
32
+ crashed: CrashedSection[];
31
33
  }
32
34
  /** A click: the node under it, the marked field under it, and where (iframe client coordinates). */
33
35
  | {
@@ -1,5 +1,6 @@
1
1
  import { type RefObject } from "react";
2
2
  import type { BridgeRect, FieldRect, ToBridge, ZoneRect } from "./protocol.ts";
3
+ import type { CrashedSection } from "../../marks.ts";
3
4
  export type Geometry = "waiting" | "ok" | "missing";
4
5
  export declare function useCanvasBridge({ iframeRef, iframeSrc, onSelect, onHover, onRendered }: {
5
6
  iframeRef: RefObject<HTMLIFrameElement | null>;
@@ -16,4 +17,6 @@ export declare function useCanvasBridge({ iframeRef, iframeSrc, onSelect, onHove
16
17
  docH: number;
17
18
  geometry: Geometry;
18
19
  postToCanvas: (m: ToBridge) => void;
20
+ onFrameLoad: () => void;
21
+ crashed: CrashedSection[];
19
22
  };
@@ -1,23 +1,28 @@
1
1
  // The admin side of the editor⇄canvas protocol: the geometry the bridge
2
2
  // reports (node rects, empty zones, document height), select/hover
3
3
  // forwarded from the iframe, and `postToCanvas` for refresh/scroll.
4
- // Geometry silence is survivable — after GEOMETRY_TIMEOUT_MS the overlay
5
- // goes dark, the iframe and the outline keep working.
4
+ // Silence is survivable — GEOMETRY_TIMEOUT_MS after the frame's load
5
+ // with no bridge message, `geometry` turns "missing" and the page editor
6
+ // diagnoses why (spec 2026-09-27 shadowed-page detector §2); the iframe
7
+ // and the outline keep working.
6
8
  import { useCallback, useEffect, useRef, useState } from "react";
7
9
  const GEOMETRY_TIMEOUT_MS = 4000;
8
10
  export function useCanvasBridge({ iframeRef, iframeSrc, onSelect, onHover, onRendered }) {
9
11
  const [rects, setRects] = useState([]);
10
12
  const [emptyZones, setEmptyZones] = useState([]);
11
13
  const [fields, setFields] = useState([]);
14
+ const [crashed, setCrashed] = useState([]);
12
15
  const [docH, setDocH] = useState(900);
13
16
  const [geometry, setGeometry] = useState("waiting");
17
+ // The wait starts at the frame's load, not its src: a slow first compile in `next dev` is not silence.
18
+ const [loaded, setLoaded] = useState(false);
14
19
  // The listener registers once; the handlers are read through refs.
15
20
  const handlers = useRef({ onSelect, onHover, onRendered });
16
21
  handlers.current = { onSelect, onHover, onRendered };
17
22
  // A new canvas URL (a locale switch, a rename) means new geometry — the
18
23
  // previous page's rects are stale the instant the iframe starts loading
19
24
  // a different route, so clear them alongside resetting geometry.
20
- useEffect(() => { setGeometry("waiting"); setRects([]); setEmptyZones([]); setFields([]); }, [iframeSrc]);
25
+ useEffect(() => { setGeometry("waiting"); setLoaded(false); setRects([]); setEmptyZones([]); setFields([]); setCrashed([]); }, [iframeSrc]);
21
26
  const postToCanvas = useCallback((m) => {
22
27
  iframeRef.current?.contentWindow?.postMessage(m, "*");
23
28
  }, [iframeRef]);
@@ -28,12 +33,13 @@ export function useCanvasBridge({ iframeRef, iframeSrc, onSelect, onHover, onRen
28
33
  return;
29
34
  if (e.source !== iframeRef.current?.contentWindow)
30
35
  return;
36
+ setGeometry("ok"); // any bridge message: Smoodly rendered the canvas with its bridge
31
37
  if (m.kind === "rects") {
32
38
  setRects(m.nodes);
33
39
  setEmptyZones(m.emptyZones);
34
40
  setFields(m.fields);
35
41
  setDocH(m.docH);
36
- setGeometry("ok");
42
+ setCrashed(m.crashed ?? []);
37
43
  }
38
44
  else if (m.kind === "select") {
39
45
  handlers.current.onSelect(m.id, m.path, m.x, m.y);
@@ -49,10 +55,11 @@ export function useCanvasBridge({ iframeRef, iframeSrc, onSelect, onHover, onRen
49
55
  return () => window.removeEventListener("message", onMessage);
50
56
  }, [iframeRef]);
51
57
  useEffect(() => {
52
- if (!iframeSrc || geometry !== "waiting")
58
+ if (!iframeSrc || !loaded || geometry !== "waiting")
53
59
  return;
54
60
  const t = setTimeout(() => setGeometry((g) => (g === "waiting" ? "missing" : g)), GEOMETRY_TIMEOUT_MS);
55
61
  return () => clearTimeout(t);
56
- }, [iframeSrc, geometry]);
57
- return { rects, emptyZones, fields, docH, geometry, postToCanvas };
62
+ }, [iframeSrc, loaded, geometry]);
63
+ const onFrameLoad = useCallback(() => setLoaded(true), []);
64
+ return { rects, emptyZones, fields, docH, geometry, postToCanvas, onFrameLoad, crashed };
58
65
  }
@@ -0,0 +1,7 @@
1
+ import { type CanvasCause } from "./canvas-diagnosis.ts";
2
+ import type { Geometry } from "./useCanvasBridge.ts";
3
+ export type CanvasDiagnosis = {
4
+ cause: CanvasCause;
5
+ path: string;
6
+ };
7
+ export declare function useCanvasDiagnosis(iframeSrc: string | null, geometry: Geometry): CanvasDiagnosis | null;
@@ -0,0 +1,24 @@
1
+ // The page editor's diagnosis of a silent canvas (spec 2026-09-27
2
+ // shadowed-page detector §4): once the bridge's wait ends in "missing",
3
+ // fetch the canvas URL once — same origin, so the draft-mode cookie rides
4
+ // along and the server renders what the frame got — and name the cause.
5
+ // A new src, or the bridge speaking after all, drops the answer.
6
+ import { useEffect, useState } from "react";
7
+ import { diagnoseCanvas, probeOf } from "./canvas-diagnosis.js";
8
+ export function useCanvasDiagnosis(iframeSrc, geometry) {
9
+ const [diagnosis, setDiagnosis] = useState(null);
10
+ useEffect(() => {
11
+ setDiagnosis(null);
12
+ if (!iframeSrc || geometry !== "missing")
13
+ return;
14
+ let live = true;
15
+ const requested = new URL(iframeSrc, window.location.href);
16
+ fetch(requested.href, { cache: "no-store" })
17
+ .then((res) => probeOf(requested.href, res))
18
+ .catch(() => ({ kind: "failed" }))
19
+ .then((probe) => { if (live)
20
+ setDiagnosis({ cause: diagnoseCanvas(probe), path: requested.pathname }); });
21
+ return () => { live = false; };
22
+ }, [iframeSrc, geometry]);
23
+ return diagnosis;
24
+ }
@@ -7,6 +7,7 @@ export { ensureGlobalItems, initialGlobalFields } from "./global-items.ts";
7
7
  export { makeClientOps } from "./client-ops.ts";
8
8
  export { gateAdminOp, type AdminAuth } from "./auth.ts";
9
9
  export { supabaseAdminAuth } from "./supabase-auth.ts";
10
+ export type { AuthConfig } from "./shell/session.ts";
10
11
  export { serializeAdminConfig } from "./serialize.ts";
11
12
  export type { AdminRegistry, AdminSection, AdminCollection, AdminPageTemplate, AdminGlobal, AdminField } from "./serialize.ts";
12
13
  export * from "./tree-ops.ts";
@@ -14,7 +14,7 @@ import { jsx as _jsx } from "react/jsx-runtime";
14
14
  // offsets map 1:1 onto the overlay.
15
15
  import { useEffect } from "react";
16
16
  import { useRouter } from "next/navigation";
17
- import { BLANK_ATTR, EDITOR_ATTR, FIELD_ATTR, NODE_ATTR, ZONE_ATTR, resolveMarkPath } from "../../marks.js";
17
+ import { BLANK_ATTR, CRASHED_ATTR, EDITOR_ATTR, FIELD_ATTR, NODE_ATTR, ZONE_ATTR, crashedOf, resolveMarkPath } from "../../marks.js";
18
18
  const post = (m) => window.parent.postMessage(m, "*");
19
19
  /** The height of an md-spaced section with nothing inside (3.5rem + 3.5rem). */
20
20
  // The editor sizes the iframe to the reported height; the document's own
@@ -69,7 +69,9 @@ export function EditorBridge() {
69
69
  }
70
70
  // Rounded up from the fractional box, so the frame is never a hair too short.
71
71
  const docH = Math.ceil(Math.max(document.documentElement.scrollHeight, document.documentElement.getBoundingClientRect().height));
72
- post({ source: "smoodly", kind: "rects", nodes, emptyZones, fields, docH });
72
+ // Crashed sections (spec 2026-09-27 section guard §6): the placeholders the guard and the canvas net left.
73
+ const crashed = crashedOf(all(`[${CRASHED_ATTR}]`));
74
+ post({ source: "smoodly", kind: "rects", nodes, emptyZones, fields, docH, crashed });
73
75
  };
74
76
  const raf = () => requestAnimationFrame(report);
75
77
  const ro = new ResizeObserver(raf);
@@ -6,4 +6,3 @@ export type { MemberRole } from "../../permissions.ts";
6
6
  export type { AuthConfig } from "../shell/session.ts";
7
7
  export type { ContentConfig } from "../../env.ts";
8
8
  export type { AdminAuth } from "../auth.ts";
9
- export { supabaseAdminAuth } from "../supabase-auth.ts";
@@ -1,3 +1,8 @@
1
+ // The admin route group's entry. createSmoodlyAdmin imports the "use
2
+ // client" shell, so any module that imports a value from here puts the
3
+ // whole admin in its route's JavaScript — whether it renders or not.
4
+ // Never import it from code the site's routes reach (smoodly/server.ts
5
+ // included); the auth glue lives in `smoodly/admin`, which has no client
6
+ // code.
1
7
  export { createAdminOpHandler } from "./op-handler.js";
2
8
  export { createSmoodlyAdmin } from "./create-admin.js";
3
- export { supabaseAdminAuth } from "../supabase-auth.js";
@@ -4,6 +4,25 @@ export type ActionMenuItem = {
4
4
  disabled?: boolean;
5
5
  danger?: boolean;
6
6
  };
7
+ /** The button's box in viewport pixels, `right` measured from the viewport's right edge. */
8
+ type Anchor = {
9
+ top: number;
10
+ bottom: number;
11
+ right: number;
12
+ };
13
+ export declare const anchorOf: (button: {
14
+ top: number;
15
+ bottom: number;
16
+ right: number;
17
+ }, viewportWidth: number) => Anchor;
18
+ /** Whether a scroll moved the button since the menu opened. */
19
+ export declare const anchorMoved: (open: Anchor, now: Anchor) => boolean;
20
+ /** 6px under the button from its right edge, or above it when it does not fit below and there is more room above. */
21
+ export declare function menuPlacement(anchor: Anchor, viewportHeight: number, height: number): {
22
+ right: number;
23
+ top?: number;
24
+ bottom?: number;
25
+ };
7
26
  export declare function ActionMenu({ label, items, disabled, small }: {
8
27
  label: string;
9
28
  items: ActionMenuItem[];
@@ -11,3 +30,4 @@ export declare function ActionMenu({ label, items, disabled, small }: {
11
30
  /** The smaller round button, next to a small Publish. */
12
31
  small?: boolean;
13
32
  }): import("react").JSX.Element;
33
+ export {};
@@ -8,14 +8,35 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
8
8
  // right edge (every control sits at its bar's or card's right). It is
9
9
  // position:fixed from the button's rect (2026-09-26): inside a table cell
10
10
  // an absolute child was clipped by the cell's overflow, so the Members
11
- // row menu opened invisibly. A scroll closes it rather than leaving it behind.
12
- import { useEffect, useRef, useState } from "react";
11
+ // row menu opened invisibly. Near the bottom of the screen it opens upward,
12
+ // the Combobox's rule (2026-09-27: a fixed menu cut off by the viewport
13
+ // cannot be scrolled into view). A scroll that moves the button closes it
14
+ // rather than leaving it behind — only one that moves it: scroll events
15
+ // arrive a frame late, so the one from bringing the row into view could
16
+ // land after the click and close the menu it had just opened.
17
+ import { useEffect, useLayoutEffect, useRef, useState } from "react";
18
+ import { opensUp } from "./combo-box.js";
13
19
  import { cx } from "./primitives.js";
20
+ export const anchorOf = (button, viewportWidth) => ({ top: button.top, bottom: button.bottom, right: viewportWidth - button.right });
21
+ /** Whether a scroll moved the button since the menu opened. */
22
+ export const anchorMoved = (open, now) => open.top !== now.top || open.bottom !== now.bottom || open.right !== now.right;
23
+ /** 6px under the button from its right edge, or above it when it does not fit below and there is more room above. */
24
+ export function menuPlacement(anchor, viewportHeight, height) {
25
+ return opensUp({ below: viewportHeight - anchor.bottom - 6, above: anchor.top - 6, height })
26
+ ? { right: anchor.right, bottom: viewportHeight - anchor.top + 6 }
27
+ : { right: anchor.right, top: anchor.bottom + 6 };
28
+ }
14
29
  export function ActionMenu({ label, items, disabled, small = false }) {
15
- /** Open: the popover's viewport position, under the button's right edge. */
30
+ /** Open: the button's box when it opened; the popover is placed from it. */
16
31
  const [open, setOpen] = useState(null);
32
+ const [place, setPlace] = useState(null);
17
33
  const ref = useRef(null);
18
34
  const button = useRef(null);
35
+ const menu = useRef(null);
36
+ // Measured before paint, so an upward menu never flashes below first.
37
+ useLayoutEffect(() => {
38
+ setPlace(open && menu.current ? menuPlacement(open, window.innerHeight, menu.current.offsetHeight) : null);
39
+ }, [open]);
19
40
  const toggle = () => {
20
41
  if (open) {
21
42
  setOpen(null);
@@ -23,7 +44,7 @@ export function ActionMenu({ label, items, disabled, small = false }) {
23
44
  }
24
45
  const r = button.current?.getBoundingClientRect();
25
46
  if (r)
26
- setOpen({ top: r.bottom + 6, right: window.innerWidth - r.right });
47
+ setOpen(anchorOf(r, window.innerWidth));
27
48
  };
28
49
  useEffect(() => {
29
50
  if (!open)
@@ -33,14 +54,19 @@ export function ActionMenu({ label, items, disabled, small = false }) {
33
54
  close(); };
34
55
  const onKey = (e) => { if (e.key === "Escape")
35
56
  close(); };
57
+ const onScroll = () => {
58
+ const r = button.current?.getBoundingClientRect();
59
+ if (!r || anchorMoved(open, anchorOf(r, window.innerWidth)))
60
+ close();
61
+ };
36
62
  document.addEventListener("pointerdown", onDown);
37
63
  document.addEventListener("keydown", onKey);
38
- document.addEventListener("scroll", close, true);
64
+ document.addEventListener("scroll", onScroll, true);
39
65
  return () => {
40
66
  document.removeEventListener("pointerdown", onDown);
41
67
  document.removeEventListener("keydown", onKey);
42
- document.removeEventListener("scroll", close, true);
68
+ document.removeEventListener("scroll", onScroll, true);
43
69
  };
44
70
  }, [open]);
45
- return (_jsxs("div", { ref: ref, style: { position: "relative", flex: "none" }, children: [_jsx("button", { ref: button, type: "button", className: cx("sm-dotmenu", small && "sm-dotmenu--sm"), "aria-label": label, title: label, "aria-haspopup": "menu", "aria-expanded": open !== null, disabled: disabled, onClick: toggle, children: "\u00B7\u00B7\u00B7" }), open && (_jsx("div", { role: "menu", className: "sm-picker sm-picker--menu", style: { top: open.top, right: open.right }, children: items.map((item) => (_jsx("button", { type: "button", role: "menuitem", className: cx("sm-picker__item", item.danger && "sm-picker__item--danger"), disabled: item.disabled, onClick: () => { setOpen(null); item.run(); }, children: item.label }, item.label))) }))] }));
71
+ return (_jsxs("div", { ref: ref, style: { position: "relative", flex: "none" }, children: [_jsx("button", { ref: button, type: "button", className: cx("sm-dotmenu", small && "sm-dotmenu--sm"), "aria-label": label, title: label, "aria-haspopup": "menu", "aria-expanded": open !== null, disabled: disabled, onClick: toggle, children: "\u00B7\u00B7\u00B7" }), open && (_jsx("div", { ref: menu, role: "menu", className: "sm-picker sm-picker--menu", style: place ?? { top: open.bottom + 6, right: open.right, visibility: "hidden" }, children: items.map((item) => (_jsx("button", { type: "button", role: "menuitem", className: cx("sm-picker__item", item.danger && "sm-picker__item--danger"), disabled: item.disabled, onClick: () => { setOpen(null); item.run(); }, children: item.label }, item.label))) }))] }));
46
72
  }
@@ -19,6 +19,7 @@ export type CustomSectionProps = {
19
19
  export type CustomSectionComponent = ((props: CustomSectionProps) => ReactElement) & {
20
20
  schema: {
21
21
  name: string;
22
+ title: string;
22
23
  };
23
24
  };
24
25
  export declare function createCustomSection({ RichText: Bound, breakpoint, elements }: CustomOptions & {
@@ -4,6 +4,7 @@ import { RichText } from "../richtext-render.js";
4
4
  import { FIELD_ATTR, ITEM_ATTR } from "../marks.js";
5
5
  import { CUSTOM_NODE_TYPE, DEFAULT_BREAKPOINT } from "./schema.js";
6
6
  import { customCss } from "./css.js";
7
+ import { RENDER_VIEW } from "../section-guard.js";
7
8
  const isRow = (r) => typeof r === "object" && r !== null && Array.isArray(r.columns);
8
9
  export function createCustomSection({ RichText: Bound, breakpoint = DEFAULT_BREAKPOINT, elements = [] }) {
9
10
  const Body = Bound ?? (({ doc }) => _jsx(RichText, { doc: doc, elements: elements }));
@@ -15,6 +16,8 @@ export function createCustomSection({ RichText: Bound, breakpoint = DEFAULT_BREA
15
16
  : styles;
16
17
  return (_jsx(Section, { styles: withLayout, "data-custom": "", children: list.map((row, i) => (_jsx("div", { className: row.reverse ? "smd-row smd-row--reverse" : "smd-row", "data-row": i, [ITEM_ATTR]: `rows.${i}`, children: row.columns.map((doc, j) => (_jsx("div", { className: "smd-col", [FIELD_ATTR]: `columns.${j}`, children: _jsx(Body, { doc: doc }) }, j))) }, i))) }));
17
18
  }
18
- CustomSection.schema = { name: CUSTOM_NODE_TYPE };
19
+ CustomSection.schema = { name: CUSTOM_NODE_TYPE, title: "Custom section" };
20
+ // Smoodly's own plain view: the guard may call it (spec 2026-09-27 section guard §1).
21
+ CustomSection[RENDER_VIEW] = CustomSection;
19
22
  return CustomSection;
20
23
  }
package/dist/marks.d.ts CHANGED
@@ -53,4 +53,16 @@ export type Scope<T> = Marks<T> & {
53
53
  export declare function marks<S extends {
54
54
  __f?: BuilderMap;
55
55
  }>(_schema: S): Marks<NamespaceValues<NonNullable<S["__f"]>>>;
56
+ /** A crashed section's placeholder (spec 2026-09-27 section guard §5): `section` — the guard caught
57
+ * it and the live site leaves it out; `page` — only the canvas net caught it, the live page would fail. */
58
+ export declare const CRASHED_ATTR = "data-smd-crashed";
59
+ /** One crashed section as the bridge reports it. */
60
+ export type CrashedSection = {
61
+ id: string;
62
+ live: "left-out" | "page-fails";
63
+ };
64
+ /** The crashed sections among the marked elements, in document order — what the bridge posts. */
65
+ export declare function crashedOf(els: ReadonlyArray<{
66
+ getAttribute(name: string): string | null;
67
+ }>): CrashedSection[];
56
68
  export {};
package/dist/marks.js CHANGED
@@ -44,3 +44,17 @@ function level() {
44
44
  export function marks(_schema) {
45
45
  return level();
46
46
  }
47
+ /** A crashed section's placeholder (spec 2026-09-27 section guard §5): `section` — the guard caught
48
+ * it and the live site leaves it out; `page` — only the canvas net caught it, the live page would fail. */
49
+ export const CRASHED_ATTR = "data-smd-crashed";
50
+ /** The crashed sections among the marked elements, in document order — what the bridge posts. */
51
+ export function crashedOf(els) {
52
+ const out = [];
53
+ for (const el of els) {
54
+ const id = el.getAttribute(NODE_ATTR);
55
+ const mark = el.getAttribute(CRASHED_ATTR);
56
+ if (id && (mark === "section" || mark === "page"))
57
+ out.push({ id, live: mark === "section" ? "left-out" : "page-fails" });
58
+ }
59
+ return out;
60
+ }
@@ -0,0 +1,16 @@
1
+ import { Component, type ReactNode } from "react";
2
+ type Props = {
3
+ id: string;
4
+ title: string;
5
+ children?: ReactNode;
6
+ };
7
+ type State = {
8
+ failed: boolean;
9
+ error: unknown;
10
+ };
11
+ export declare class CanvasBoundary extends Component<Props, State> {
12
+ state: State;
13
+ static getDerivedStateFromError(error: unknown): State;
14
+ render(): string | number | bigint | boolean | Iterable<ReactNode> | Promise<string | number | bigint | boolean | import("react").ReactPortal | import("react").ReactElement<unknown, string | import("react").JSXElementConstructor<any>> | Iterable<ReactNode> | null | undefined> | import("react").JSX.Element | null | undefined;
15
+ }
16
+ export {};
@@ -0,0 +1,25 @@
1
+ "use client";
2
+ import { jsx as _jsx } from "react/jsx-runtime";
3
+ // The Next adapter's canvas net (spec 2026-09-27 section guard §5): in
4
+ // editor mode each section sits in <Suspense> and this boundary, so a
5
+ // throw the guard cannot catch — in a component the section renders, or
6
+ // in a 'use client' section — fails only that section: Server Components
7
+ // hand it to the browser to retry, and this catches it there. Never
8
+ // rendered outside editor mode. Imports nothing from `next`, but it is
9
+ // Next's mechanism, so it lives with the adapter.
10
+ import { Component } from "react";
11
+ import { CrashPlaceholder } from "../section-crash.js";
12
+ export class CanvasBoundary extends Component {
13
+ constructor() {
14
+ super(...arguments);
15
+ this.state = { failed: false, error: undefined };
16
+ }
17
+ static getDerivedStateFromError(error) {
18
+ return { failed: true, error };
19
+ }
20
+ render() {
21
+ return this.state.failed
22
+ ? _jsx(CrashPlaceholder, { id: this.props.id, title: this.props.title, error: this.state.error, live: "page" })
23
+ : this.props.children;
24
+ }
25
+ }
@@ -1,9 +1,11 @@
1
- import type { ComponentType, ReactElement } from "react";
1
+ import { type ComponentType, type ReactElement } from "react";
2
2
  import type { Metadata } from "next";
3
3
  import type { Descriptor } from "../fields.ts";
4
4
  import { type PairedPage } from "../render.tsx";
5
5
  import type { GlobalSchema } from "../global.ts";
6
6
  import { type SmoodlySite } from "../site.ts";
7
+ /** Logged once per renderer when Next's cacheComponents is on (spec 2026-09-27 cache components §2). */
8
+ export declare const CACHE_COMPONENTS_WARNING = "smoodly: Next's cacheComponents is on, and Smoodly does not support it \u2014 a missing page answers 200 with noindex instead of 404, and a redirect answers 200 instead of 308. Turn cacheComponents off in next.config.ts; the Next.js guide's \"Cache Components\" section says why (https://smoodly.io/docs/frameworks/nextjs).";
7
9
  /** A registered section, as renderPage's registry needs it. */
8
10
  type SectionEntry = ComponentType<any> & {
9
11
  schema: {
@@ -1,15 +1,29 @@
1
1
  import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
2
+ // The site-route seam (spec 2026-09-05 §5): the two route files call
3
+ // renderSmoodlyPath; everything else — request gating, draft mode, the
4
+ // cache keyed by (locale, path) and tagged by id, 404s, the editor
5
+ // bridge — lives here, over the framework-free site layer. Bound once by
6
+ // the app, exactly like createAdminOpHandler.
7
+ import { Suspense } from "react";
2
8
  import { unstable_cache } from "next/cache";
3
9
  import { draftMode } from "next/headers";
4
- import { notFound, permanentRedirect } from "next/navigation";
10
+ import { notFound, permanentRedirect, unstable_rethrow } from "next/navigation";
5
11
  import { connection } from "next/server";
6
12
  import { EditorBridge } from "../admin/next/bridge.js";
13
+ import { CanvasBoundary } from "./canvas-boundary.js";
14
+ import { DRAFT_META_NAME } from "../admin/editor/canvas-diagnosis.js";
7
15
  import { fixedPathFor } from "../localize.js";
8
16
  import { joinPath } from "../paths.js";
9
17
  import { renderPage } from "../render.js";
10
18
  import { entryTag, pageTag, globalTag } from "../revalidate.js";
11
19
  import { pageContextOf } from "../site.js";
12
20
  import { metadataFrom } from "./meta.js";
21
+ /** Logged once per renderer when Next's cacheComponents is on (spec 2026-09-27 cache components §2). */
22
+ export const CACHE_COMPONENTS_WARNING = "smoodly: Next's cacheComponents is on, and Smoodly does not support it — a missing page answers 200 with noindex instead of 404, and a redirect answers 200 instead of 308. Turn cacheComponents off in next.config.ts; the Next.js guide's \"Cache Components\" section says why (https://smoodly.io/docs/frameworks/nextjs).";
23
+ /** The canvas net (spec 2026-09-27 section guard §5): Server Components hand a section that failed to the
24
+ * browser, where CanvasBoundary catches it. Defined here, not in the client file: a function exported
25
+ * from a "use client" module is a reference on the server and cannot be called. */
26
+ const canvasWrap = (child, section) => (_jsx(Suspense, { fallback: null, children: _jsx(CanvasBoundary, { id: section.id, title: section.title, children: child }) }));
13
27
  export function createPageRenderer(options) {
14
28
  const { site } = options;
15
29
  const href = options.href ?? ((path) => path);
@@ -48,7 +62,7 @@ export function createPageRenderer(options) {
48
62
  notFound();
49
63
  }
50
64
  const page = pageContextOf(hit, href);
51
- return renderPage(Page, hit.tree, { sections: options.sections, elements: options.elements, styles: site.styles, custom: site.custom }, { editor, page });
65
+ return renderPage(Page, hit.tree, { sections: options.sections, elements: options.elements, styles: site.styles, custom: site.custom }, { editor, page, rethrow: unstable_rethrow, editorWrap: canvasWrap });
52
66
  }
53
67
  const View = entryPages.get(hit.collection);
54
68
  if (!View) {
@@ -67,10 +81,22 @@ export function createPageRenderer(options) {
67
81
  languages[locale] = href(alt.path, locale);
68
82
  return { alternates: { languages } };
69
83
  }
84
+ // Next's cacheComponents serves the catch-all from a fallback shell that
85
+ // commits 200 before the page renders, so 404s and 308s are lost (spec
86
+ // 2026-09-27 cache components). The variable is Next's own, inlined at
87
+ // build: a warning, never an error, and once per renderer.
88
+ let cacheComponentsWarned = false;
89
+ function warnCacheComponents() {
90
+ if (cacheComponentsWarned || String(process.env.__NEXT_CACHE_COMPONENTS) !== "true")
91
+ return;
92
+ cacheComponentsWarned = true;
93
+ console.warn(CACHE_COMPONENTS_WARNING);
94
+ }
70
95
  async function page(pathFor, opts, accept) {
96
+ warnCacheComponents(); // before connection(), so a build's prerender prints it too
71
97
  // A CMS route is request-rendered by construction: without this a
72
98
  // route with no dynamic segment would be prerendered against the
73
- // database at build time (DESIGN.md §3, OQ #19).
99
+ // database at build time (DESIGN.md §3).
74
100
  await connection();
75
101
  const locale = opts?.locale ?? site.locales.default;
76
102
  const path = pathFor(locale);
@@ -86,7 +112,10 @@ export function createPageRenderer(options) {
86
112
  const hit = target ? await load(target, locale, path, draft, version) : null;
87
113
  if (!hit || !accept(hit))
88
114
  notFound();
89
- return (_jsxs(_Fragment, { children: [editor ? _jsx(EditorBridge, {}) : null, render(hit, editor, locale)] }));
115
+ // The draft marker tells the page editor's canvas diagnosis that Smoodly
116
+ // rendered this response (spec 2026-09-27 shadowed-page detector §3);
117
+ // React hoists the meta into the head. Never outside draft mode.
118
+ return (_jsxs(_Fragment, { children: [draft ? _jsx("meta", { name: DRAFT_META_NAME }) : null, editor ? _jsx(EditorBridge, {}) : null, render(hit, editor, locale)] }));
90
119
  }
91
120
  async function metadata(pathFor, opts, accept) {
92
121
  // generateMetadata runs alongside the page render, so it gates too.
package/dist/pairing.js CHANGED
@@ -1,14 +1,21 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
2
  import { defaultsOf } from "./field-defaults.js";
3
+ import { isPlainView, RENDER_VIEW } from "./section-guard.js";
3
4
  function pair(schema, View) {
4
5
  const defaults = defaultsOf(schema.settings);
5
- const Component = (props) => {
6
+ const viewProps = (props) => {
6
7
  const { settings: overrides, styles, ...fields } = props ?? {};
7
- const settings = { ...defaults, ...overrides };
8
+ return { ...fields, settings: { ...defaults, ...overrides }, styles: styles ?? {} };
9
+ };
10
+ const Component = (props) => {
8
11
  const V = View;
9
- return _jsx(V, { ...fields, settings: settings, styles: styles ?? {} });
12
+ return _jsx(V, { ...viewProps(props) });
10
13
  };
11
14
  Component.schema = schema;
15
+ // The guard's direct call (spec 2026-09-27 section guard §1): only a plain-function view may be called.
16
+ if (isPlainView(View)) {
17
+ Component[RENDER_VIEW] = (props) => View(viewProps(props));
18
+ }
12
19
  return Component;
13
20
  }
14
21
  export function section(schema, View) {
package/dist/render.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { ComponentType, ReactElement } from "react";
1
+ import { type ComponentType, type ReactElement, type ReactNode } from "react";
2
2
  import type { PageAlternate } from "./paths.ts";
3
3
  import type { PageSchema } from "./page.ts";
4
4
  import type { BuilderMap, NamespaceValues } from "./schema.ts";
@@ -70,8 +70,14 @@ export declare const EMPTY_PAGE_CONTEXT: PageContext;
70
70
  type AnyPaired = ComponentType<any> & {
71
71
  schema: {
72
72
  name: string;
73
+ title?: string;
73
74
  };
74
75
  };
76
+ /** The adapter's canvas net (spec 2026-09-27 section guard §5): called per section, in editor mode only. */
77
+ export type EditorWrap = (child: ReactNode, section: {
78
+ id: string;
79
+ title: string;
80
+ }) => ReactNode;
75
81
  export type ZoneContent = {
76
82
  readonly name: string;
77
83
  readonly nodes: TreeNode[];
@@ -79,6 +85,9 @@ export type ZoneContent = {
79
85
  /** The config's CSS values behind the styles set (spec 2026-09-14 §2). */
80
86
  readonly styleValues: StyleValues;
81
87
  readonly editor: boolean;
88
+ /** Control flow the guard must let through — the adapter's (Next: unstable_rethrow; spec 2026-09-27 section guard §2). */
89
+ readonly rethrow?: (error: unknown) => void;
90
+ readonly editorWrap?: EditorWrap;
82
91
  };
83
92
  export declare function Zone({ of: content }: {
84
93
  of: ZoneContent;
@@ -102,5 +111,7 @@ export type Registry = {
102
111
  export declare function renderPage<Zones extends Record<string, unknown>>(Page: PairedPage<Zones>, tree: PageTree, registry: Registry, options?: {
103
112
  editor?: boolean;
104
113
  page?: PageContext;
114
+ rethrow?: (error: unknown) => void;
115
+ editorWrap?: EditorWrap;
105
116
  }): ReactElement;
106
117
  export {};
package/dist/render.js CHANGED
@@ -1,8 +1,11 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ import { Fragment } from "react";
2
3
  import { EMPTY_ATTR, NODE_ATTR, ZONE_ATTR } from "./marks.js";
3
4
  import { SMD_KEY } from "./section-wrapper.js";
4
5
  import { DEFAULT_STYLE_VALUES } from "./styles.js";
5
6
  import { CUSTOM_NODE_TYPE, createCustomSection } from "./custom/index.js";
7
+ import { renderGuarded } from "./section-guard.js";
8
+ import { PLACEHOLDER_STYLE } from "./section-crash.js";
6
9
  /** The `page` prop of a view rendered outside the site (a test, a preview
7
10
  * of a shape): a real object, so a view never guards an optional prop, but
8
11
  * no position in the tree. Frozen: one singleton shared by every such
@@ -26,14 +29,17 @@ export function Zone({ of: content }) {
26
29
  // Editor mode draws a box the bridge can measure, so the node is selectable in place
27
30
  // (spec 2026-09-27 editor safety §5); the site renders nothing, as before.
28
31
  if (content.editor) {
29
- return (_jsxs("div", { [NODE_ATTR]: node.id, style: { padding: "24px 16px", border: "1px dashed #b54708", color: "#b54708", font: "500 14px/1.4 system-ui, sans-serif", textAlign: "center" }, children: ["Unknown section `", node.type, "`"] }, node.id));
32
+ return (_jsxs("div", { [NODE_ATTR]: node.id, style: PLACEHOLDER_STYLE, children: ["Unknown section `", node.type, "`"] }, node.id));
30
33
  }
31
34
  console.warn(`smoodly: no registered component for node type "${node.type}" — skipping.`);
32
35
  return null;
33
36
  }
34
37
  // The node id, the css and the values ride the styles under one reserved key; the wrapper reads and strips it.
35
38
  const styles = { ...(node.styles ?? {}), [SMD_KEY]: { node: node.id, css: typeof node.css === "string" ? node.css : undefined, values: content.styleValues } };
36
- return _jsx(Component, { ...node.fields, settings: node.settings ?? {}, styles: styles }, node.id);
39
+ // Through the guard (spec 2026-09-27 section guard): a throw in the section's own code leaves it out, not the page.
40
+ const title = Component.schema.title ?? node.type;
41
+ const section = renderGuarded(Component, { ...node.fields, settings: node.settings ?? {}, styles }, { id: node.id, type: node.type, title, editor: content.editor, rethrow: content.rethrow });
42
+ return (_jsx(Fragment, { children: content.editor && content.editorWrap ? content.editorWrap(section, { id: node.id, title }) : section }, node.id));
37
43
  }) }));
38
44
  }
39
45
  export function page(schema, View) {
@@ -54,7 +60,7 @@ export function renderPage(Page, tree, registry, options) {
54
60
  const styleValues = registry.styles ?? DEFAULT_STYLE_VALUES;
55
61
  const zones = Object.fromEntries(Object.keys(Page.schema.zones).map((zone) => [
56
62
  zone,
57
- { name: zone, nodes: tree.zones?.[zone] ?? [], components, styleValues, editor: options?.editor ?? false },
63
+ { name: zone, nodes: tree.zones?.[zone] ?? [], components, styleValues, editor: options?.editor ?? false, rethrow: options?.rethrow, editorWrap: options?.editorWrap },
58
64
  ]));
59
65
  return _jsx(Page, { zones: zones, page: (options?.page ?? EMPTY_PAGE_CONTEXT) });
60
66
  }
@@ -0,0 +1,11 @@
1
+ import type { CSSProperties } from "react";
2
+ /** The canvas placeholders' look — the unknown section's too (spec 2026-09-27 editor safety §5). */
3
+ export declare const PLACEHOLDER_STYLE: CSSProperties;
4
+ /** Why it crashed, for the placeholder: the message, or the digest when a production build stripped the message. */
5
+ export declare function crashMessage(error: unknown): string;
6
+ export declare function CrashPlaceholder({ id, title, error, live }: {
7
+ id: string;
8
+ title: string;
9
+ error: unknown;
10
+ live: "section" | "page";
11
+ }): import("react").JSX.Element;
@@ -0,0 +1,16 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { CRASHED_ATTR, NODE_ATTR } from "./marks.js";
3
+ /** The canvas placeholders' look — the unknown section's too (spec 2026-09-27 editor safety §5). */
4
+ export const PLACEHOLDER_STYLE = {
5
+ padding: "24px 16px", border: "1px dashed #b54708", color: "#b54708", font: "500 14px/1.4 system-ui, sans-serif", textAlign: "center",
6
+ };
7
+ /** Why it crashed, for the placeholder: the message, or the digest when a production build stripped the message. */
8
+ export function crashMessage(error) {
9
+ const digest = typeof error === "object" && error !== null ? error.digest : undefined;
10
+ if (typeof digest === "string")
11
+ return `the server log has the error (digest ${digest})`;
12
+ return error instanceof Error ? error.message : String(error);
13
+ }
14
+ export function CrashPlaceholder({ id, title, error, live }) {
15
+ return (_jsx("div", { [NODE_ATTR]: id, [CRASHED_ATTR]: live, style: PLACEHOLDER_STYLE, children: `Section “${title}” failed to render — ${crashMessage(error)}` }));
16
+ }
@@ -0,0 +1,27 @@
1
+ import type { ComponentType, ReactNode } from "react";
2
+ /** The paired component's own view call, set by pair() and the custom section — only for a plain-function view. */
3
+ export declare const RENDER_VIEW: unique symbol;
4
+ /** A view the guard may call: a plain function — not a client reference or memo/forwardRef ($$typeof), not a class. */
5
+ export declare function isPlainView(View: unknown): boolean;
6
+ export type GuardContext = {
7
+ id: string;
8
+ type: string;
9
+ title: string;
10
+ editor: boolean;
11
+ rethrow?: (error: unknown) => void;
12
+ };
13
+ type Settled = {
14
+ ok: true;
15
+ value: unknown;
16
+ } | {
17
+ ok: false;
18
+ error: unknown;
19
+ };
20
+ /** A section through the guard when its component offers the view call; any other component as an element, unguarded. */
21
+ export declare function renderGuarded(Component: ComponentType<any>, props: Record<string, unknown>, ctx: GuardContext): ReactNode;
22
+ /** An async section, awaited in place — no <Suspense>, so it renders where it always did. */
23
+ export declare function SectionGuard({ settled, ctx }: {
24
+ settled: Promise<Settled>;
25
+ ctx: GuardContext;
26
+ }): Promise<ReactNode>;
27
+ export {};
@@ -0,0 +1,41 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { CrashPlaceholder } from "./section-crash.js";
3
+ /** The paired component's own view call, set by pair() and the custom section — only for a plain-function view. */
4
+ export const RENDER_VIEW = Symbol("smoodly.renderView");
5
+ /** A view the guard may call: a plain function — not a client reference or memo/forwardRef ($$typeof), not a class. */
6
+ export function isPlainView(View) {
7
+ if (typeof View !== "function")
8
+ return false;
9
+ const v = View;
10
+ return v.$$typeof === undefined && !v.prototype?.isReactComponent;
11
+ }
12
+ /** The placeholder shows in the canvas, and under `next dev` so a developer never misses their own crash. */
13
+ const placeholderShown = (editor) => editor || process.env.NODE_ENV === "development";
14
+ function failed(error, ctx) {
15
+ ctx.rethrow?.(error); // notFound(), redirects and bailouts are control flow, not crashes
16
+ console.error(`smoodly: section "${ctx.type}" (node ${ctx.id}) failed to render and is left out of the page.`, error);
17
+ return placeholderShown(ctx.editor) ? _jsx(CrashPlaceholder, { id: ctx.id, title: ctx.title, error: error, live: "section" }) : null;
18
+ }
19
+ const isThenable = (v) => typeof v === "object" && v !== null && typeof v.then === "function";
20
+ /** A section through the guard when its component offers the view call; any other component as an element, unguarded. */
21
+ export function renderGuarded(Component, props, ctx) {
22
+ const render = Component[RENDER_VIEW];
23
+ if (!render)
24
+ return _jsx(Component, { ...props });
25
+ try {
26
+ const out = render(props);
27
+ if (!isThenable(out))
28
+ return out;
29
+ // Settled up front, so a rejection is never unhandled while React gets to the guard.
30
+ const settled = Promise.resolve(out).then((value) => ({ ok: true, value }), (error) => ({ ok: false, error }));
31
+ return _jsx(SectionGuard, { settled: settled, ctx: ctx });
32
+ }
33
+ catch (error) {
34
+ return failed(error, ctx);
35
+ }
36
+ }
37
+ /** An async section, awaited in place — no <Suspense>, so it renders where it always did. */
38
+ export async function SectionGuard({ settled, ctx }) {
39
+ const result = await settled;
40
+ return result.ok ? result.value : failed(result.error, ctx);
41
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "smoodly",
3
- "version": "0.0.29",
3
+ "version": "0.0.31",
4
4
  "description": "Open-source, code-first CMS for Next.js on Supabase: define a component once, it becomes a typed, editable building block.",
5
5
  "license": "MIT",
6
6
  "repository": {