smoodly 0.0.61 → 0.0.62

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.
@@ -67,6 +67,8 @@ export type AdminGlobal = {
67
67
  settings: Record<string, AdminField>;
68
68
  styles: Record<string, AdminField>;
69
69
  css: boolean;
70
+ /** Every page renders it through the layout (global.ts `layout`): the item's canvas shows the home page. */
71
+ layout?: true;
70
72
  };
71
73
  /** A registered Element (spec 2026-09-08 block nodes §1): a section shape plus its contexts — no styles, no css. */
72
74
  export type AdminElement = Omit<AdminSection, "styles" | "css"> & {
@@ -108,6 +108,7 @@ export function serializeAdminConfig(config) {
108
108
  settings: serializeMap(s.section !== undefined ? sectionSettings(config, s.section) : {}),
109
109
  styles: serializeMap(s.section !== undefined ? config.resolvedStyles(s.section) : {}),
110
110
  css: s.section !== undefined ? sectionCss(config, s.section) : false,
111
+ ...(s.layout ? { layout: true } : {}),
111
112
  })),
112
113
  custom: {
113
114
  name: CUSTOM_NODE_TYPE,
@@ -8,9 +8,9 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
8
8
  // never reloads (spec 2026-09-08 client routing). The shell is mounted by
9
9
  // the prebuilt entry (browser/main.tsx) into the admin's one HTML page (page.ts).
10
10
  //
11
- // Layout is the design system's three columns: the 72px icon rail
12
- // (shell/IconRail.tsx), then the main column whose views own their own
13
- // 48px top bar (the editor adds the third, its inspector). The shell is
11
+ // Layout: the navigation bar across the top (shell/NavBar.tsx; the 72px
12
+ // icon rail until 2026-11-14), then below it the main column whose views own
13
+ // their own 48px top bar (the editor adds the third, its inspector). The shell is
14
14
  // viewport-height; every column scrolls inside itself.
15
15
  import { useEffect, useRef, useState } from "react";
16
16
  import { makeClientOps } from "../client-ops.js";
@@ -22,7 +22,7 @@ import { GlobalForm } from "./GlobalForm.js";
22
22
  import { EditorView } from "../editor/EditorView.js";
23
23
  import { SignedOutView } from "./SignedOutView.js";
24
24
  import { useSpaceSession } from "./session.js";
25
- import { IconRail } from "./IconRail.js";
25
+ import { NavBar } from "./NavBar.js";
26
26
  import { ConfirmHost } from "../ui/ConfirmDialog.js";
27
27
  import { notify } from "../ui/confirm.js";
28
28
  import { useRoute } from "./router.js";
@@ -169,5 +169,5 @@ export function AdminApp({ registry, op, signIn, api, presenceConfig }) {
169
169
  main = _jsx(MediaScreen, {});
170
170
  else
171
171
  main = _jsx(ErrorPane, { message: "Not found." });
172
- return (_jsx(AdminPathProvider, { value: registry.adminPath, children: _jsx(PresenceProvider, { value: presence, children: _jsx(ElementsProvider, { elements: registry.elements, children: _jsx(TimeZoneProvider, { zone: registry.timeZone, children: _jsxs(AssetsProvider, { ops: ops, media: registry.media, upload: upload, children: [_jsxs("div", { className: "sm-shell", children: [_jsx(IconRail, { registry: registry, active: head, email: email, settings: role !== null, deploy: deploy ? { status: deploy, busy: deployBusy, onDeploy: () => { void onDeploy(); } } : undefined, onSignOut: () => { void session.signOut(); } }), _jsx("main", { className: "sm-main", children: main }, `${head}/${tail ?? ""}`)] }), _jsx(ConfirmHost, {})] }) }) }) }) }));
172
+ return (_jsx(AdminPathProvider, { value: registry.adminPath, children: _jsx(PresenceProvider, { value: presence, children: _jsx(ElementsProvider, { elements: registry.elements, children: _jsx(TimeZoneProvider, { zone: registry.timeZone, children: _jsxs(AssetsProvider, { ops: ops, media: registry.media, upload: upload, children: [_jsxs("div", { className: "sm-shell", children: [_jsx(NavBar, { registry: registry, active: head, email: email, settings: role !== null, deploy: deploy ? { status: deploy, busy: deployBusy, onDeploy: () => { void onDeploy(); } } : undefined, onSignOut: () => { void session.signOut(); } }), _jsx("main", { className: "sm-main", children: main }, `${head}/${tail ?? ""}`)] }), _jsx(ConfirmHost, {})] }) }) }) }) }));
173
173
  }
@@ -1,5 +1,5 @@
1
1
  "use client";
2
- import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
3
3
  // One Globals item (global content, spec 2026-09-07 §6), one locale: the
4
4
  // entry form's shape without what a singleton never has — no slug, no
5
5
  // delete, no add/remove locale (every supported locale exists by
@@ -11,18 +11,25 @@ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-run
11
11
  // bar carries the indicator and the one publish control, publish
12
12
  // validates the draft and lists the fields in an amber banner. "Used on
13
13
  // N pages" is the refs index's answer.
14
+ //
15
+ // A visual item is edited on a canvas (spec 2026-11-14 sections and the global canvas): when a page shows it —
16
+ // the home page for a layout item, the first page that places it otherwise (shell/global-canvas.ts) — the main
17
+ // column is that page in a frame with the item selected (editor/GlobalCanvas.tsx), and the fields move to the
18
+ // inspector under Content | Settings tabs. A page found not to draw the item is dropped and the next one tried;
19
+ // with none left, and for a Fields item, the page is the form as it always was. Everything this file saves,
20
+ // guards and publishes is the item's: the canvas reads a page and writes nothing of it.
14
21
  import { useEffect, useMemo, useRef, useState } from "react";
15
22
  import { notePublished } from "../delivery-mode.js";
16
23
  import { stillFailing, validateNode } from "../validate.js";
17
24
  import { elementSchemas } from "../../richtext-walk.js";
18
25
  import { CSS_KEY, SETTINGS_KEY, STYLES_KEY } from "../../global.js";
19
- import { tabsFor } from "../forms/tabs.js";
26
+ import { tabOf, tabsFor } from "../forms/tabs.js";
20
27
  import { revealField } from "../forms/reveal.js";
21
28
  import { useRefOptions } from "../forms/useRefOptions.js";
22
29
  import { useDraftAutosave } from "../editor/autosave.js";
23
30
  import { updatedLine } from "../ui/format.js";
24
31
  import { LocaleSwitcher } from "../ui/LocaleSwitcher.js";
25
- import { Banner, Breadcrumb, FieldErrorList, InfoRow, SaveIndicator, SectionLabel, TopBar } from "../ui/primitives.js";
32
+ import { Banner, Breadcrumb, FieldErrorList, InfoRow, SaveIndicator, SectionLabel, Tabs, TopBar } from "../ui/primitives.js";
26
33
  import { PublishControl } from "../ui/PublishControl.js";
27
34
  import { HistoryModal, historyRows } from "../ui/HistoryModal.js";
28
35
  import { localeStatusOf } from "./pages-tree.js";
@@ -39,7 +46,9 @@ import { DriftNotice } from "../ui/DriftNotice.js";
39
46
  import { OrphanFields } from "../forms/OrphanFields.js";
40
47
  import { entryDrift } from "../drift.js";
41
48
  import { misfitErrors } from "../drift-copy.js";
42
- import { unsetAtPath } from "../field-paths.js";
49
+ import { setAtPath, unsetAtPath } from "../field-paths.js";
50
+ import { GlobalCanvas } from "../editor/GlobalCanvas.js";
51
+ import { canvasCandidates, liveCandidates } from "./global-canvas.js";
43
52
  import { usePresence, usePresenceState, useSavedElsewhere, useTrackPresence } from "./presence-context.js";
44
53
  import { editorsOf } from "./presence.js";
45
54
  import { PresenceLine } from "../ui/PresenceLine.js";
@@ -63,6 +72,26 @@ export function GlobalForm({ ops, registry, name }) {
63
72
  const [stale, setStale] = useState(null);
64
73
  const [liveFindings, setLiveFindings] = useState([]);
65
74
  const formRef = useRef(null);
75
+ // The canvas (see the header): the page list a visual item's candidates are read from (null while it loads; a
76
+ // failed load is an empty list — the form shows and the fields stay usable), the pages found not to draw the
77
+ // item, the editor's pick among the rest, the inspector's tab and the field with an open inline session.
78
+ const visual = item?.section !== undefined;
79
+ const [sitePages, setSitePages] = useState(null);
80
+ const [emptyPages, setEmptyPages] = useState(() => new Set());
81
+ const [pickedPage, setPickedPage] = useState(null);
82
+ const [tab, setTab] = useState("Content");
83
+ const [inlinePath, setInlinePath] = useState(null);
84
+ const canvasRef = useRef(null);
85
+ useEffect(() => {
86
+ if (!visual)
87
+ return;
88
+ let live = true;
89
+ ops.pages.list().then((r) => { if (live)
90
+ setSitePages(r.ok ? r.data : []); }).catch(() => { if (live)
91
+ setSitePages([]); });
92
+ return () => { live = false; };
93
+ }, [ops, visual]);
94
+ useEffect(() => { setEmptyPages(new Set()); setPickedPage(null); setInlinePath(null); }, [name, locale]);
66
95
  const fieldsRef = useRef(fields);
67
96
  fieldsRef.current = fields;
68
97
  const recordRef = useRef(record);
@@ -122,6 +151,21 @@ export function GlobalForm({ ops, registry, name }) {
122
151
  setNotice({ tone: "error", text: "Couldn't reach the server." }); });
123
152
  return () => { cancelled = true; };
124
153
  }, [ops, name, locale, reloadKey]);
154
+ // The frame shows the item's draft: every write of it — a save, a discard, a restore, someone else's version
155
+ // loaded — is a new saved time on the row, and the frame renders again. The first value is the load itself.
156
+ const draftSavedAt = record?.locales[locale]?.draftSavedAt;
157
+ const lastDraft = useRef(null);
158
+ useEffect(() => {
159
+ const key = `${name}:${locale}`;
160
+ const prev = lastDraft.current;
161
+ lastDraft.current = { key, at: draftSavedAt };
162
+ if (prev && prev.key === key && prev.at !== undefined && prev.at !== draftSavedAt)
163
+ canvasRef.current?.refresh();
164
+ }, [name, locale, draftSavedAt]);
165
+ // The freeze (spec 2026-09-26 concurrency §4) ends an inline session with it: nothing typed after it is kept.
166
+ useEffect(() => { if (stale)
167
+ canvasRef.current?.endInline(); }, [stale]);
168
+ const candidates = useMemo(() => (item && sitePages ? canvasCandidates(item, usage, sitePages, locale, registry) : []), [item, usage, sitePages, locale, registry]);
125
169
  // Drift (spec 2026-09-27 editor safety §5): the draft on screen against the registry.
126
170
  const findings = useMemo(() => (fields && record?.locales[locale]
127
171
  ? entryDrift(registry, name, { fields, shared: record.fields, own: record.locales[locale].fields })
@@ -308,7 +352,35 @@ export function GlobalForm({ ops, registry, name }) {
308
352
  setDirty(true);
309
353
  setTouched(true);
310
354
  mark();
355
+ // Typed text shows in the frame at once; the refresh follows the save.
356
+ if (ns === "fields" && typeof value === "string")
357
+ canvasRef.current?.writeText(key, value);
358
+ };
359
+ /** A value typed in the frame (an inline session), by dotted path inside the item's fields. */
360
+ const onCanvasPatch = (path, value) => {
361
+ if (stale)
362
+ return;
363
+ setFields((f) => setAtPath(f ?? {}, path, value));
364
+ setDirty(true);
365
+ setTouched(true);
366
+ mark();
367
+ };
368
+ // The page the canvas shows: the editor's pick while it still draws the item, else the first that may.
369
+ const livePages = liveCandidates(candidates, emptyPages);
370
+ const shown = record ? livePages.find((c) => c.pageId === pickedPage) ?? livePages[0] ?? null : null;
371
+ const noneShows = candidates.length > 0 && livePages.length === 0;
372
+ const tabNames = tabs.map((t) => t.tab);
373
+ const activeTab = tabNames.includes(tab) ? tab : tabNames[0] ?? "Content";
374
+ /** A field named by a publish error: its tab first (the inspector shows one tab at a time), then the widget. */
375
+ const revealInInspector = (path) => {
376
+ const wanted = tabOf(item, path.startsWith("settings.") || path.startsWith("styles.") ? path.split(".")[1] ?? path : path);
377
+ if (wanted && wanted !== activeTab)
378
+ setTab(wanted);
379
+ requestAnimationFrame(() => { if (formRef.current)
380
+ revealField(formRef.current, path); });
311
381
  };
382
+ const usedOn = record && (_jsxs("p", { className: "sm-hint", children: [pages.size === 0 ? "Not placed on any page yet." : `Used on ${pages.size} page${pages.size === 1 ? "" : "s"}: `, [...pages].map((id, i) => (_jsxs("span", { children: [i > 0 && ", ", _jsx("a", { href: adminHref(`/pages/${encodeURIComponent(id)}?locale=${encodeURIComponent(locale)}`), children: id })] }, id)))] }));
383
+ const documentInfo = record && row && (_jsxs("div", { className: "sm-card", children: [_jsx(SectionLabel, { children: "Document info" }), _jsx(InfoRow, { label: "Kind", value: item.section !== undefined ? `Visual · ${item.section}` : "Fields" }), _jsx(InfoRow, { label: "Updated", value: updatedLine(row.updatedAt, row.updatedBy) }), _jsx(InfoRow, { label: "ID", value: record.id, mono: true })] }));
312
384
  // An orphan's path names a visual item's namespaces as drift does (`settings.x`); the stored keys are `$settings` / `$styles`.
313
385
  const storedPath = (p) => p.startsWith("settings.") ? `${SETTINGS_KEY}.${p.slice(9)}` : p.startsWith("styles.") ? `${STYLES_KEY}.${p.slice(7)}` : p;
314
386
  const clearOrphan = (path) => {
@@ -319,10 +391,11 @@ export function GlobalForm({ ops, registry, name }) {
319
391
  };
320
392
  return (_jsxs(_Fragment, { children: [_jsx(TopBar, { left: _jsx(_Fragment, { children: _jsx(Breadcrumb, { backHref: adminHref("/globals"), items: [
321
393
  {
322
- label: "Site", href: adminHref("/globals"),
394
+ label: "Sections", href: adminHref("/globals"),
323
395
  trailing: multilingual && (_jsx(LocaleSwitcher, { registry: registry, present: registry.locales.supported, parentLocales: [], value: locale, adding: false, onSwitch: (l) => { void switchLocale(l); }, onAdd: () => undefined })),
324
396
  },
325
397
  { label: item.title },
326
- ] }) }), right: _jsxs(_Fragment, { children: [(touched || saveState === "error" || saveState === "refused") && _jsx(SaveIndicator, { state: saveState, pending: dirty }), _jsx(PublishControl, { status: status, pending: dirty || saveState !== "saved", busy: busy, flash: published, disabled: !row, onPublish: () => { void publish(); }, onUnpublish: () => { void unpublish(); }, onDiscard: () => { void discard(); }, onHistory: () => { void openHistory(); } })] }) }), history && row && (_jsx(HistoryModal, { rows: history, draftId: row.draftVersionId, onSelect: (v) => { void loadSnapshot(v); }, onClose: () => setHistory(null), onRestore: restore, render: (versionId) => snapshot?.id === versionId ? (_jsxs("fieldset", { disabled: true, className: "sm-history__form", children: [_jsx(GlobalFields, { item: item, groups: contentGroups, fields: snapshot.fields, refOptions: refOptions, multilingual: multilingual, onChange: () => { } }), settingGroups.length > 0 && (_jsxs(_Fragment, { children: [_jsx(SectionLabel, { children: "Settings" }), _jsx(GlobalFields, { item: item, groups: settingGroups, fields: snapshot.fields, refOptions: refOptions, multilingual: multilingual, onChange: () => { } })] }))] })) : _jsx("p", { className: "sm-hint", style: { padding: 16 }, children: "Loading\u2026" }) })), _jsx(PresenceLine, { names: editorsOf(others, target), noun: "item" }), stale && _jsx(StaleBanner, { info: stale, onReload: loadTheirs }), _jsx(DriftNotice, { draft: findings, live: liveFindings }), _jsxs("div", { className: "sm-editor", inert: stale ? true : undefined, children: [_jsxs("div", { className: "sm-form", ref: formRef, children: [notice && (_jsxs(Banner, { tone: notice.tone, onDismiss: () => setNotice(null), revealKey: `${notice.tone}:${notice.text}`, children: [_jsx("p", { style: { margin: 0 }, children: notice.text }), _jsx(FieldErrorList, { fields: notice.fields ?? [], onReveal: (path) => { if (formRef.current)
327
- revealField(formRef.current, path); } })] })), record && (_jsxs("p", { className: "sm-hint", children: [pages.size === 0 ? "Not placed on any page yet." : `Used on ${pages.size} page${pages.size === 1 ? "" : "s"}: `, [...pages].map((id, i) => (_jsxs("span", { children: [i > 0 && ", ", _jsx("a", { href: adminHref(`/pages/${encodeURIComponent(id)}?locale=${encodeURIComponent(locale)}`), children: id })] }, id)))] })), fields !== null ? (_jsxs(_Fragment, { children: [_jsx(GlobalFields, { item: item, groups: contentGroups, fields: fields, errors: markerErrors, refOptions: refOptions, multilingual: multilingual, onChange: onChange }), settingGroups.length > 0 && (_jsxs(_Fragment, { children: [_jsx(SectionLabel, { children: "Settings" }), _jsx(GlobalFields, { item: item, groups: settingGroups, fields: fields, errors: markerErrors, refOptions: refOptions, multilingual: multilingual, onChange: onChange })] })), _jsx(OrphanFields, { orphans: findings, onClear: clearOrphan })] })) : !notice && _jsx("p", { style: { color: "var(--text-muted)" }, children: "Loading\u2026" })] }), _jsx(ResizeHandle, {}), _jsx("aside", { className: "sm-inspector", children: record && row && (_jsxs("div", { className: "sm-card", children: [_jsx(SectionLabel, { children: "Document info" }), _jsx(InfoRow, { label: "Kind", value: item.section !== undefined ? `Visual · ${item.section}` : "Fields" }), _jsx(InfoRow, { label: "Updated", value: updatedLine(row.updatedAt, row.updatedBy) }), _jsx(InfoRow, { label: "ID", value: record.id, mono: true })] })) })] })] }));
398
+ ] }) }), right: _jsxs(_Fragment, { children: [(touched || saveState === "error" || saveState === "refused") && _jsx(SaveIndicator, { state: saveState, pending: dirty }), _jsx(PublishControl, { status: status, pending: dirty || saveState !== "saved", busy: busy, flash: published, disabled: !row, onPublish: () => { void publish(); }, onUnpublish: () => { void unpublish(); }, onDiscard: () => { void discard(); }, onHistory: () => { void openHistory(); } })] }) }), history && row && (_jsx(HistoryModal, { rows: history, draftId: row.draftVersionId, onSelect: (v) => { void loadSnapshot(v); }, onClose: () => setHistory(null), onRestore: restore, render: (versionId) => snapshot?.id === versionId ? (_jsxs("fieldset", { disabled: true, className: "sm-history__form", children: [_jsx(GlobalFields, { item: item, groups: contentGroups, fields: snapshot.fields, refOptions: refOptions, multilingual: multilingual, onChange: () => { } }), settingGroups.length > 0 && (_jsxs(_Fragment, { children: [_jsx(SectionLabel, { children: "Settings" }), _jsx(GlobalFields, { item: item, groups: settingGroups, fields: snapshot.fields, refOptions: refOptions, multilingual: multilingual, onChange: () => { } })] }))] })) : _jsx("p", { className: "sm-hint", style: { padding: 16 }, children: "Loading\u2026" }) })), _jsx(PresenceLine, { names: editorsOf(others, target), noun: "item" }), stale && _jsx(StaleBanner, { info: stale, onReload: loadTheirs }), _jsx(DriftNotice, { draft: findings, live: liveFindings }), shown ? (_jsxs("div", { className: "sm-editor", inert: stale ? true : undefined, children: [_jsxs("div", { className: "sm-globalcanvas", children: [_jsxs("div", { className: "sm-globalcanvas__bar", children: [pages.size > 0 ? usedOn : _jsx("p", { className: "sm-hint", children: item.layout ? "Shown on every page." : `Shown on ${shown.title}.` }), livePages.length > 1 && (_jsxs("label", { className: "sm-globalcanvas__pick", children: [_jsx("span", { className: "sm-hint", children: "Shown on" }), _jsx("select", { className: "sm-select", "aria-label": "Shown on", value: shown.pageId, onChange: (e) => setPickedPage(e.target.value), children: livePages.map((c) => _jsx("option", { value: c.pageId, children: c.title }, c.pageId)) })] }))] }), _jsx(GlobalCanvas, { ref: canvasRef, ops: ops, item: item, pageId: shown.pageId, locale: locale, fields: fields ?? {}, onPatch: onCanvasPatch, onInlinePath: setInlinePath, onMissing: () => setEmptyPages((prev) => new Set(prev).add(shown.pageId)) }, `${shown.pageId}:${locale}:${reloadKey}`)] }), _jsx(ResizeHandle, {}), _jsxs("aside", { className: "sm-inspector", children: [_jsxs("div", { className: "sm-card", ref: formRef, children: [notice && (_jsxs(Banner, { tone: notice.tone, onDismiss: () => setNotice(null), revealKey: `${notice.tone}:${notice.text}`, children: [_jsx("p", { style: { margin: 0 }, children: notice.text }), _jsx(FieldErrorList, { fields: notice.fields ?? [], onReveal: revealInInspector })] })), tabNames.length > 1 && _jsx(Tabs, { tabs: tabNames, value: activeTab, onChange: setTab }), fields !== null ? (_jsxs(_Fragment, { children: [_jsx(GlobalFields, { item: item, groups: tabs.find((t) => t.tab === activeTab)?.groups ?? [], fields: fields, errors: markerErrors, refOptions: refOptions, multilingual: multilingual, onChange: onChange, inlinePath: inlinePath ?? undefined, onEditHere: () => { const p = inlinePath; canvasRef.current?.endInline(); if (p)
399
+ revealInInspector(p); } }), _jsx(OrphanFields, { orphans: findings, onClear: clearOrphan })] })) : !notice && _jsx("p", { style: { color: "var(--text-muted)" }, children: "Loading\u2026" })] }), documentInfo] })] })) : (_jsxs("div", { className: "sm-editor", inert: stale ? true : undefined, children: [_jsxs("div", { className: "sm-form", ref: formRef, children: [notice && (_jsxs(Banner, { tone: notice.tone, onDismiss: () => setNotice(null), revealKey: `${notice.tone}:${notice.text}`, children: [_jsx("p", { style: { margin: 0 }, children: notice.text }), _jsx(FieldErrorList, { fields: notice.fields ?? [], onReveal: (path) => { if (formRef.current)
400
+ revealField(formRef.current, path); } })] })), usedOn, noneShows && _jsx("p", { className: "sm-hint", children: "No page shows this item in this locale." }), fields !== null ? (_jsxs(_Fragment, { children: [_jsx(GlobalFields, { item: item, groups: contentGroups, fields: fields, errors: markerErrors, refOptions: refOptions, multilingual: multilingual, onChange: onChange }), settingGroups.length > 0 && (_jsxs(_Fragment, { children: [_jsx(SectionLabel, { children: "Settings" }), _jsx(GlobalFields, { item: item, groups: settingGroups, fields: fields, errors: markerErrors, refOptions: refOptions, multilingual: multilingual, onChange: onChange })] })), _jsx(OrphanFields, { orphans: findings, onClear: clearOrphan })] })) : !notice && _jsx("p", { style: { color: "var(--text-muted)" }, children: "Loading\u2026" })] }), _jsx(ResizeHandle, {}), _jsx("aside", { className: "sm-inspector", children: documentInfo })] }))] }));
328
401
  }
@@ -38,7 +38,7 @@ export function GlobalList({ ops, registry }) {
38
38
  if (error)
39
39
  return _jsx(ErrorPane, { message: error });
40
40
  const rows = items ? globalRows(items, registry, locale) : [];
41
- return (_jsx(ListView, { title: "Site", titleTrailing: multilingual && _jsx(LocalePicker, { locales: registry.locales.supported, value: locale, onChange: pickLocale }), columns: [
41
+ return (_jsx(ListView, { title: "Sections", titleTrailing: multilingual && _jsx(LocalePicker, { locales: registry.locales.supported, value: locale, onChange: pickLocale }), columns: [
42
42
  { label: "Title", width: "minmax(160px,1fr)" },
43
43
  { label: "Kind", width: "90px" },
44
44
  { label: "Updated", width: "minmax(110px,160px)" },
@@ -1,12 +1,12 @@
1
1
  import type { DeployStatus } from "../ops.ts";
2
2
  import type { AdminRegistry } from "../serialize.ts";
3
- export declare function IconRail({ registry, active, email, settings, deploy, onSignOut }: {
3
+ export declare function NavBar({ registry, active, email, settings, deploy, onSignOut }: {
4
4
  registry: AdminRegistry;
5
5
  active: string;
6
6
  email?: string;
7
- /** Whether the Settings tile shows — once the role is known, in the shell (every member has a Settings page). */
7
+ /** Whether Settings shows — once the role is known, in the shell (every member has a Settings page). */
8
8
  settings?: boolean;
9
- /** The Deploy tile (spec 2026-10-09 static mode §6), above the avatar on a static site; absent on a live one. */
9
+ /** The Deploy item (spec 2026-10-09 static mode §6), next to the avatar on a static site; absent on a live one. */
10
10
  deploy?: {
11
11
  status: DeployStatus;
12
12
  busy: boolean;
@@ -0,0 +1,120 @@
1
+ "use client";
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ // packages/smoodly/src/admin/shell/NavBar.tsx
4
+ // The admin's primary navigation: a bar across the top of the frame
5
+ // (2026-11-14), which replaced the 72px IconRail (itself the 200px SideNav's
6
+ // successor) so the visual editor gets the full width of the window. The
7
+ // colours, icons and the order of the items are the rail's; the bar stays put
8
+ // while the sheet below it scrolls, since the shell owns the viewport.
9
+ //
10
+ // Three groups. On the left the app icon, which opens the Smoodly menu. In the
11
+ // middle the items, registry-derived and never separately configured: Pages,
12
+ // then an item for each collection registered with `admin.showInNav`
13
+ // (2026-11-13), then Collections — one item whose menu lists every other
14
+ // collection, absent when none is left — then Media, then ⋮, the More menu,
15
+ // which holds what is opened rarely: Sections (the global content items, when
16
+ // the registry has any; the route is /globals) and Settings (every member has
17
+ // a page there, so the shell passes `settings` once the role is known); ⋮ is
18
+ // absent when it would be empty. On the right the Deploy item (a static site
19
+ // only) next to the avatar, which opens the account menu: the email, the theme
20
+ // toggle, "My Account" (at smoodly.io) and Sign out (spec 2026-10-16 sign-in
21
+ // and the dashboard §6).
22
+ //
23
+ // Menus: click toggles, Esc closes and returns focus to the trigger, ↑/↓ move
24
+ // between entries, a pointer down outside closes. Collections also opens on
25
+ // hover. A child being active highlights its parent and the entry inside the
26
+ // menu. Collections and More are dark, the bar's own colours, since they
27
+ // carry the bar's items; the others float on the sheet white.
28
+ import { useEffect, useId, useRef, useState } from "react";
29
+ import { ChevronDown, EllipsisVertical, ExternalLink, Image, Layers, LayoutPanelTop, LibraryBig, PanelTop, RadioTower, Settings, } from "lucide-react";
30
+ import { tileCopy } from "./deploy-tile.js";
31
+ import { ThemeToggle } from "../ui/primitives.js";
32
+ import { useAdminHref } from "./base.js";
33
+ import { consoleLinks } from "./console-links.js";
34
+ import { SMOODLY_VERSION } from "../../version.js";
35
+ import { RouteLink } from "./RouteLink.js";
36
+ /** Lucide at the bar's size and stroke (the item's CSS restates both). */
37
+ const ICON = { size: 18, strokeWidth: 2, "aria-hidden": true };
38
+ /** The app icon, inlined: a package can't ship a file into the host app's
39
+ * /public. The wordmark's "s" on a rounded square (chosen 2026-10-23; the
40
+ * same tile, green with a white s, is smoodly.io's favicon). The square takes
41
+ * --logo-outer and the glyph --logo-inner, so a look decides its colours. */
42
+ function AppIcon() {
43
+ return (_jsxs("svg", { className: "sm-navbar__logo", viewBox: "0 0 64 64", fill: "none", xmlns: "http://www.w3.org/2000/svg", role: "img", "aria-label": "Smoodly", children: [_jsx("rect", { width: "64", height: "64", rx: "14", style: { fill: "var(--logo-outer)" } }), _jsx("path", { style: { fill: "var(--logo-inner)" }, transform: "translate(14.382 -4.365) scale(0.7123)", d: "M30.5851 22.9763C36.7891 22.9763 45.6052 24.6681 49.3603 26.5237L46.2583 42.2415C41.6869 40.113 35.2653 38.4212 29.4422 38.4212C26.0137 38.4212 23.7825 39.1852 23.7825 40.9862C23.7825 42.1869 24.9797 42.8964 27.1021 43.333L31.5647 44.3153C40.8162 46.3346 45.5508 51.1373 45.5508 58.505C45.5508 73.4587 33.9592 79.1345 18.939 79.1345C11.4833 79.1345 4.24536 77.3335 0.109375 75.6963L3.42905 58.9416C8.97998 61.8341 16.4356 63.6351 21.2247 63.6351C24.6532 63.6351 26.9933 62.7619 26.9933 60.6334C26.9933 59.4327 26.1226 58.505 23.5648 58.0684L19.7553 57.3043C12.5173 55.9399 6.31336 50.9735 6.31336 43.0055C6.31336 30.6169 15.6737 23.0309 30.6939 23.0309L30.5851 22.9763Z" })] }));
44
+ }
45
+ /** A menu link that opens smoodly.io in a new tab, with the arrow that says so. */
46
+ function ExtLink({ href, onClick, children }) {
47
+ return (_jsxs("a", { role: "menuitem", className: "sm-navbar__menu-item", href: href, target: "_blank", rel: "noreferrer", onClick: onClick, children: [_jsx("span", { children: children }), _jsx(ExternalLink, { className: "sm-navbar__ext", size: 13, strokeWidth: 1.75, "aria-hidden": "true" })] }));
48
+ }
49
+ export function NavBar({ registry, active, email, settings = true, deploy, onSignOut }) {
50
+ const adminHref = useAdminHref();
51
+ const root = useRef(null);
52
+ const uid = useId();
53
+ const [open, setOpen] = useState(null);
54
+ // Collections opens on hover as well (spec 2026-09-15 rail tweaks): a hover
55
+ // open closes when the pointer leaves and never steals focus; a click open
56
+ // is sticky and moves focus into the menu for the keyboard.
57
+ const [hovered, setHovered] = useState(false);
58
+ const menuId = (key) => `sm-navbar-menu${uid}${key}`;
59
+ const toggle = (key) => setOpen((cur) => (cur === key ? null : key));
60
+ const close = () => setOpen(null);
61
+ const hoverLeave = () => setHovered(false);
62
+ // A click open moves focus into the menu; a pointer down outside closes it.
63
+ // Outside means outside the menu and its trigger — the rest of the bar
64
+ // counts too (2026-09-26); the trigger is left to its own toggle.
65
+ useEffect(() => {
66
+ if (!open || !root.current)
67
+ return;
68
+ if (!hovered)
69
+ root.current.querySelector(`#${CSS.escape(menuId(open))} [role=menuitem]`)?.focus();
70
+ const onPointerDown = (e) => {
71
+ if (!(e.target instanceof Node))
72
+ return;
73
+ const menu = root.current?.querySelector(`#${CSS.escape(menuId(open))}`);
74
+ const trigger = root.current?.querySelector(`[aria-controls="${menuId(open)}"]`);
75
+ if (menu?.contains(e.target) || trigger?.contains(e.target))
76
+ return;
77
+ close();
78
+ };
79
+ document.addEventListener("pointerdown", onPointerDown);
80
+ return () => document.removeEventListener("pointerdown", onPointerDown);
81
+ // eslint-disable-next-line react-hooks/exhaustive-deps
82
+ }, [open]);
83
+ const onKeyDown = (e) => {
84
+ if (!open || !root.current)
85
+ return;
86
+ if (e.key === "Escape") {
87
+ close();
88
+ root.current.querySelector(`[aria-controls="${menuId(open)}"]`)?.focus();
89
+ }
90
+ else if (e.key === "ArrowDown" || e.key === "ArrowUp") {
91
+ const items = Array.from(root.current.querySelectorAll(`#${CSS.escape(menuId(open))} [role=menuitem]`));
92
+ if (!items.length)
93
+ return;
94
+ e.preventDefault();
95
+ const i = items.indexOf(document.activeElement);
96
+ items[(i + (e.key === "ArrowDown" ? 1 : -1) + items.length) % items.length].focus();
97
+ }
98
+ };
99
+ const link = (key, label, icon, href) => (_jsx("li", { className: "sm-navbar__item", children: _jsxs(RouteLink, { className: "sm-navbar__btn", href: href, "aria-current": key === active ? "page" : undefined, onClick: close, children: [icon, _jsx("span", { className: "sm-navbar__label", title: label, children: label })] }) }, key));
100
+ /** An entry of a dark menu: the item's icon and its label, a route link. */
101
+ const menuLink = (key, label, icon, href) => (_jsx("li", { role: "none", children: _jsx(RouteLink, { role: "menuitem", className: "sm-navbar__menu-item", href: href, "aria-current": key === active ? "page" : undefined, onClick: () => { close(); hoverLeave(); }, children: _jsxs("span", { className: "sm-navbar__menu-label", children: [icon, _jsx("span", { children: label })] }) }) }, key));
102
+ // A collection with `admin.showInNav` is an item of its own; the rest stay in the Collections menu.
103
+ const pinned = registry.collections.filter((c) => c.inNav);
104
+ const collections = registry.collections.filter((c) => !c.inNav);
105
+ const collectionActive = collections.some((c) => c.name === active);
106
+ const collectionsOpen = open === "collections" || (hovered && open === null);
107
+ // What ⋮ holds, in the rail's order.
108
+ const more = [
109
+ ...(registry.globals.length > 0 ? [{ key: "globals", label: "Sections", icon: _jsx(LayoutPanelTop, { ...ICON }), href: adminHref("/globals") }] : []),
110
+ ...(settings ? [{ key: "settings", label: "Settings", icon: _jsx(Settings, { ...ICON }), href: adminHref("/settings") }] : []),
111
+ ];
112
+ const moreActive = more.some((m) => m.key === active);
113
+ const name = email?.split("@")[0] ?? "Editor";
114
+ const initials = name.slice(0, 2).toUpperCase();
115
+ return (_jsxs("nav", { className: "sm-navbar", "aria-label": "Primary", ref: root, onKeyDown: onKeyDown, children: [_jsx("div", { className: "sm-navbar__start", children: _jsxs("div", { className: "sm-navbar__item", children: [_jsx("button", { type: "button", className: "sm-navbar__brand", title: "Smoodly", "aria-haspopup": "menu", "aria-expanded": open === "brand", "aria-controls": menuId("brand"), onClick: () => toggle("brand"), children: _jsx(AppIcon, {}) }), _jsxs("ul", { className: "sm-navbar__menu sm-navbar__menu--brand", role: "menu", id: menuId("brand"), "data-open": open === "brand", children: [_jsx("li", { role: "none", children: _jsx(ExtLink, { href: consoleLinks().project, onClick: close, children: "This site at smoodly.io" }) }), _jsx("li", { role: "none", children: _jsx(ExtLink, { href: consoleLinks().docs, onClick: close, children: "Documentation" }) }), _jsxs("li", { className: "sm-navbar__menu-version", role: "presentation", children: ["Smoodly ", SMOODLY_VERSION] })] })] }) }), _jsxs("ul", { className: "sm-navbar__list", children: [link("pages", "Pages", _jsx(PanelTop, { ...ICON }), adminHref("/pages")), pinned.map((c) => link(c.name, c.title, _jsx(Layers, { ...ICON }), adminHref(`/${c.name}`))), collections.length > 0 && (_jsxs("li", { className: "sm-navbar__item", onMouseEnter: () => setHovered(true), onMouseLeave: hoverLeave, children: [_jsxs("button", { type: "button", className: "sm-navbar__btn", "aria-current": collectionActive ? "page" : undefined, "aria-haspopup": "menu", "aria-expanded": collectionsOpen, "aria-controls": menuId("collections"), onClick: () => toggle("collections"), children: [_jsx(LibraryBig, { ...ICON }), _jsx("span", { className: "sm-navbar__label", children: "Collections" }), _jsx(ChevronDown, { className: "sm-navbar__chevron", size: 14, strokeWidth: 2, "aria-hidden": "true" })] }), _jsx("ul", { className: "sm-navbar__menu sm-navbar__menu--dark sm-navbar__menu--collections", role: "menu", id: menuId("collections"), "data-open": collectionsOpen, children: collections.map((c) => menuLink(c.name, c.title, _jsx(Layers, { ...ICON }), adminHref(`/${c.name}`))) })] })), link("media", "Media", _jsx(Image, { ...ICON }), adminHref("/media")), more.length > 0 && (_jsxs("li", { className: "sm-navbar__item", children: [_jsx("button", { type: "button", className: "sm-navbar__btn sm-navbar__btn--icon", title: "More", "aria-label": "More", "aria-current": moreActive ? "page" : undefined, "aria-haspopup": "menu", "aria-expanded": open === "more", "aria-controls": menuId("more"), onClick: () => toggle("more"), children: _jsx(EllipsisVertical, { ...ICON }) }), _jsx("ul", { className: "sm-navbar__menu sm-navbar__menu--dark", role: "menu", id: menuId("more"), "data-open": open === "more", children: more.map((m) => menuLink(m.key, m.label, m.icon, m.href)) })] }))] }), _jsxs("div", { className: "sm-navbar__end", children: [deploy && (() => {
116
+ const copy = tileCopy(deploy.status, Date.now());
117
+ const id = menuId("deploy");
118
+ return (_jsxs("div", { className: "sm-navbar__item sm-navbar__deploy", "data-state": deploy.status.state, children: [_jsxs("button", { type: "button", className: `sm-navbar__btn${copy.lit ? " sm-navbar__btn--lit" : ""}${copy.warn ? " sm-navbar__btn--warn" : ""}`, "aria-haspopup": "menu", "aria-expanded": open === "deploy", "aria-controls": id, onClick: () => toggle("deploy"), title: copy.detail, children: [_jsxs("span", { className: "sm-navbar__icon", children: [_jsx(RadioTower, { ...ICON }), copy.badge && _jsx("span", { className: "sm-navbar__badge", "aria-label": `${copy.badge} changes waiting`, children: copy.badge })] }), _jsx("span", { className: "sm-navbar__label", children: copy.label })] }), _jsxs("ul", { className: "sm-navbar__menu sm-navbar__menu--end", role: "menu", id: id, "data-open": open === "deploy", children: [_jsx("li", { className: "sm-navbar__menu-detail", role: "presentation", children: copy.detail }), _jsx("li", { role: "none", children: _jsx("button", { type: "button", role: "menuitem", className: "sm-navbar__menu-item", disabled: copy.action === null || deploy.busy, title: copy.disabledReason ?? undefined, onClick: () => { close(); deploy.onDeploy(); }, children: copy.action === "retry" ? "Deploy again" : "Deploy now" }) }), copy.disabledReason && _jsx("li", { className: "sm-navbar__menu-note", role: "presentation", children: copy.disabledReason })] })] }));
119
+ })(), _jsxs("div", { className: "sm-navbar__item sm-navbar__foot", children: [_jsx("button", { type: "button", className: "sm-navbar__user", title: name, "aria-haspopup": "menu", "aria-expanded": open === "user", "aria-controls": menuId("user"), onClick: () => toggle("user"), children: initials }), _jsxs("ul", { className: "sm-navbar__menu sm-navbar__menu--end", role: "menu", id: menuId("user"), "data-open": open === "user", children: [_jsx("li", { className: "sm-navbar__menu-title", role: "presentation", children: email ?? name }), _jsx("li", { role: "none", children: _jsx(ThemeToggle, {}) }), _jsx("li", { role: "none", children: _jsx(ExtLink, { href: consoleLinks().account, onClick: close, children: "My Account" }) }), _jsx("li", { role: "none", children: _jsx("button", { type: "button", role: "menuitem", className: "sm-navbar__menu-item", onClick: () => { close(); onSignOut(); }, children: "Sign out" }) })] })] })] })] }));
120
+ }
@@ -0,0 +1,40 @@
1
+ import type { EntryUsage } from "../../entry-store.ts";
2
+ import type { PageTree } from "../../page-context.ts";
3
+ import type { PageRecord } from "../../store.ts";
4
+ import type { BridgeRect } from "../editor/protocol.ts";
5
+ import type { AdminGlobal, AdminRegistry } from "../serialize.ts";
6
+ export type CanvasCandidate = {
7
+ pageId: string;
8
+ title: string;
9
+ };
10
+ type HomeRule = {
11
+ homePageSlug: string;
12
+ locales: Pick<AdminRegistry["locales"], "default">;
13
+ };
14
+ /** The pages that may show `item` in `locale`, best first. A Fields item: none. A layout item: the site root (as
15
+ * pages-tree.ts names it: a root record whose default-locale slug is the home slug). Any other visual item: each
16
+ * distinct page of `usage`, in usage order. Only a page (not a folder) with a row in `locale` counts; a usage row
17
+ * carries no locale, so a candidate may still not draw the item — the frame decides (`globalNodeIn`). */
18
+ export declare function canvasCandidates(item: AdminGlobal, usage: EntryUsage[], pages: PageRecord[], locale: string, registry: HomeRule): CanvasCandidate[];
19
+ /** The node that draws the item in this frame, or null: the layout id when the frame reported it; else the first
20
+ * placement of the item in document order (zones as the tree lists them) that the frame drew. */
21
+ export declare function globalNodeIn(name: string, rects: BridgeRect[], tree: PageTree | null): string | null;
22
+ /** Whether a frame's report may be judged: the bridge committed a render and reported geometry after it. Before
23
+ * that, "no node" means "not yet" — the bridge posts its geometry more than once as a page settles. */
24
+ export declare function settled(seen: {
25
+ rendered: boolean;
26
+ rectsAfterRendered: boolean;
27
+ }): boolean;
28
+ /** How long a settled frame's reports must have been quiet before "no node" is believed. */
29
+ export declare const MISS_QUIET_MS = 1500;
30
+ /** Whether a frame does not draw the item: it may be judged, it holds no node of the item, and nothing has been
31
+ * reported for `MISS_QUIET_MS`. The bridge's first report follows its first render at once, so an item that
32
+ * streams in after it (a footer under Suspense, an Astro server island) is in a later report: the quiet period
33
+ * waits for that one. */
34
+ export declare function missing(seen: {
35
+ rendered: boolean;
36
+ rectsAfterRendered: boolean;
37
+ }, nodeId: string | null, quietForMs: number): boolean;
38
+ /** The candidates without the pages already found not to draw the item, order kept. */
39
+ export declare function liveCandidates(candidates: CanvasCandidate[], empty: ReadonlySet<string>): CanvasCandidate[];
40
+ export {};
@@ -0,0 +1,60 @@
1
+ import { globalLink, globalNodeId } from "../../global.js";
2
+ /** The pages that may show `item` in `locale`, best first. A Fields item: none. A layout item: the site root (as
3
+ * pages-tree.ts names it: a root record whose default-locale slug is the home slug). Any other visual item: each
4
+ * distinct page of `usage`, in usage order. Only a page (not a folder) with a row in `locale` counts; a usage row
5
+ * carries no locale, so a candidate may still not draw the item — the frame decides (`globalNodeIn`). */
6
+ export function canvasCandidates(item, usage, pages, locale, registry) {
7
+ if (item.section === undefined)
8
+ return [];
9
+ const shows = (p) => !!p && p.template !== null && p.locales[locale] !== undefined;
10
+ const candidate = (p) => ({ pageId: p.id, title: p.locales[locale].title });
11
+ if (item.layout) {
12
+ const root = pages.find((p) => p.parentId === null && p.locales[registry.locales.default]?.slug === registry.homePageSlug);
13
+ return shows(root) ? [candidate(root)] : [];
14
+ }
15
+ const byId = new Map(pages.map((p) => [p.id, p]));
16
+ const out = [];
17
+ const seen = new Set();
18
+ for (const u of usage) {
19
+ if (u.sourceKind !== "page" || seen.has(u.sourceId))
20
+ continue;
21
+ seen.add(u.sourceId);
22
+ const p = byId.get(u.sourceId);
23
+ if (shows(p))
24
+ out.push(candidate(p));
25
+ }
26
+ return out;
27
+ }
28
+ /** The node that draws the item in this frame, or null: the layout id when the frame reported it; else the first
29
+ * placement of the item in document order (zones as the tree lists them) that the frame drew. */
30
+ export function globalNodeIn(name, rects, tree) {
31
+ const drawn = new Set(rects.map((r) => r.id));
32
+ const layoutId = globalNodeId(name);
33
+ if (drawn.has(layoutId))
34
+ return layoutId;
35
+ for (const nodes of Object.values(tree?.zones ?? {})) {
36
+ for (const node of nodes) {
37
+ if (globalLink(node)?.item === name && drawn.has(node.id))
38
+ return node.id;
39
+ }
40
+ }
41
+ return null;
42
+ }
43
+ /** Whether a frame's report may be judged: the bridge committed a render and reported geometry after it. Before
44
+ * that, "no node" means "not yet" — the bridge posts its geometry more than once as a page settles. */
45
+ export function settled(seen) {
46
+ return seen.rendered && seen.rectsAfterRendered;
47
+ }
48
+ /** How long a settled frame's reports must have been quiet before "no node" is believed. */
49
+ export const MISS_QUIET_MS = 1500;
50
+ /** Whether a frame does not draw the item: it may be judged, it holds no node of the item, and nothing has been
51
+ * reported for `MISS_QUIET_MS`. The bridge's first report follows its first render at once, so an item that
52
+ * streams in after it (a footer under Suspense, an Astro server island) is in a later report: the quiet period
53
+ * waits for that one. */
54
+ export function missing(seen, nodeId, quietForMs) {
55
+ return settled(seen) && nodeId === null && quietForMs >= MISS_QUIET_MS;
56
+ }
57
+ /** The candidates without the pages already found not to draw the item, order kept. */
58
+ export function liveCandidates(candidates, empty) {
59
+ return candidates.filter((c) => !empty.has(c.pageId));
60
+ }
@@ -2,7 +2,7 @@ import { SEVERITY, groupKey } from "../drift.js";
2
2
  import { driftHeadline } from "../drift-copy.js";
3
3
  /** A group shows this many rows, then "Show all N". */
4
4
  export const GROUP_PREVIEW = 10;
5
- const kindLabel = (doc, registry) => doc.kind === "page" ? "Page" : doc.kind === "global" ? "Site" : registry.collections.find((c) => c.name === doc.collection)?.title ?? doc.collection ?? "";
5
+ const kindLabel = (doc, registry) => doc.kind === "page" ? "Page" : doc.kind === "global" ? "Sections" : registry.collections.find((c) => c.name === doc.collection)?.title ?? doc.collection ?? "";
6
6
  const href = (doc, locale) => {
7
7
  const q = `?locale=${encodeURIComponent(locale)}`;
8
8
  if (doc.kind === "page")
@@ -40,7 +40,7 @@ export function ThemeToggle() {
40
40
  }
41
41
  catch { /* not remembered */ }
42
42
  };
43
- return (_jsxs("button", { type: "button", role: "menuitem", className: "sm-rail__menu-item", onClick: toggle, children: [_jsxs("span", { className: "sm-when-light", children: [_jsx(Moon, { ...ICON }), "Dark mode"] }), _jsxs("span", { className: "sm-when-dark", children: [_jsx(Sun, { ...ICON }), "Light mode"] })] }));
43
+ return (_jsxs("button", { type: "button", role: "menuitem", className: "sm-navbar__menu-item", onClick: toggle, children: [_jsxs("span", { className: "sm-when-light", children: [_jsx(Moon, { ...ICON }), "Dark mode"] }), _jsxs("span", { className: "sm-when-dark", children: [_jsx(Sun, { ...ICON }), "Light mode"] })] }));
44
44
  }
45
45
  export function Button({ variant = "primary", size = "md", className, ...rest }) {
46
46
  return (_jsx("button", { type: "button", className: cx("sm-btn", `sm-btn--${variant}`, size === "lg" && "sm-btn--lg", size === "sm" && "sm-btn--sm", className), ...rest }));