smoodly 0.0.7 → 0.0.9

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 (64) hide show
  1. package/README.md +224 -4
  2. package/dist/admin/client-ops.js +1 -0
  3. package/dist/admin/editor/EditorView.js +64 -8
  4. package/dist/admin/forms/FieldWidget.d.ts +7 -3
  5. package/dist/admin/forms/FieldWidget.js +85 -7
  6. package/dist/admin/forms/list.d.ts +18 -0
  7. package/dist/admin/forms/list.js +81 -0
  8. package/dist/admin/index.d.ts +3 -2
  9. package/dist/admin/index.js +1 -0
  10. package/dist/admin/ops-impl.js +84 -1
  11. package/dist/admin/ops.d.ts +22 -1
  12. package/dist/admin/richtext.d.ts +13 -11
  13. package/dist/admin/richtext.js +94 -13
  14. package/dist/admin/serialize.d.ts +15 -0
  15. package/dist/admin/serialize.js +11 -0
  16. package/dist/admin/shared-items.d.ts +9 -0
  17. package/dist/admin/shared-items.js +61 -0
  18. package/dist/admin/shell/AdminApp.js +8 -2
  19. package/dist/admin/shell/EntryForm.js +1 -1
  20. package/dist/admin/shell/SharedForm.d.ts +7 -0
  21. package/dist/admin/shell/SharedForm.js +146 -0
  22. package/dist/admin/shell/SharedList.d.ts +6 -0
  23. package/dist/admin/shell/SharedList.js +62 -0
  24. package/dist/admin/shell/shared-list.d.ts +14 -0
  25. package/dist/admin/shell/shared-list.js +17 -0
  26. package/dist/admin/tree-ops.d.ts +12 -5
  27. package/dist/admin/tree-ops.js +39 -8
  28. package/dist/admin/ui/theme.d.ts +1 -1
  29. package/dist/admin/ui/theme.js +13 -0
  30. package/dist/admin/validate.d.ts +7 -2
  31. package/dist/admin/validate.js +43 -13
  32. package/dist/collections.d.ts +4 -1
  33. package/dist/config.d.ts +3 -0
  34. package/dist/config.js +35 -1
  35. package/dist/entry-store.d.ts +9 -1
  36. package/dist/entry-store.js +32 -4
  37. package/dist/fields.d.ts +16 -1
  38. package/dist/fields.js +49 -4
  39. package/dist/index.d.ts +8 -1
  40. package/dist/index.js +7 -1
  41. package/dist/next/page-renderer.d.ts +4 -0
  42. package/dist/next/page-renderer.js +14 -1
  43. package/dist/page.js +5 -1
  44. package/dist/refs.js +13 -0
  45. package/dist/resolve.d.ts +3 -0
  46. package/dist/resolve.js +41 -0
  47. package/dist/revalidate.d.ts +4 -0
  48. package/dist/revalidate.js +4 -0
  49. package/dist/richtext-render.d.ts +3 -0
  50. package/dist/richtext-render.js +62 -0
  51. package/dist/shared-id.d.ts +3 -0
  52. package/dist/shared-id.js +36 -0
  53. package/dist/shared.d.ts +46 -0
  54. package/dist/shared.js +36 -0
  55. package/dist/site.d.ts +10 -0
  56. package/dist/site.js +28 -1
  57. package/dist/sql-space.d.ts +5 -1
  58. package/dist/sql-space.js +9 -10
  59. package/dist/sql.js +1 -19
  60. package/dist/supabase-entry-store.d.ts +9 -1
  61. package/dist/supabase-entry-store.js +60 -7
  62. package/dist/zones.d.ts +6 -2
  63. package/dist/zones.js +8 -2
  64. package/package.json +1 -1
@@ -0,0 +1,146 @@
1
+ "use client";
2
+ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
3
+ // One shared item, one locale (spec 2026-09-07 §6): the entry form's
4
+ // shape without what a singleton never has — no slug, no "new", no
5
+ // delete, no add/remove locale (every supported locale exists by
6
+ // construction). Shared fields above, the locale's own below, and for a
7
+ // visual item a Styles group edited under the reserved `$styles` key.
8
+ // Status is per locale and immediate. "Used on N pages" is the refs
9
+ // index's answer.
10
+ import { useEffect, useMemo, useState } from "react";
11
+ import { STYLES_KEY } from "../../shared.js";
12
+ import { FieldWidget } from "../forms/FieldWidget.js";
13
+ import { relativeTime } from "../ui/format.js";
14
+ import { LocaleSwitcher } from "../ui/LocaleSwitcher.js";
15
+ import { Banner, Breadcrumb, Button, Divider, SectionLabel, Segmented, TopBar } from "../ui/primitives.js";
16
+ import { entryTitle } from "./entries-list.js";
17
+ import { BASE } from "./base.js";
18
+ const STATUSES = ["Draft", "Published"];
19
+ function localeFromUrl(registry) {
20
+ try {
21
+ const l = new URLSearchParams(window.location.search).get("locale");
22
+ if (l && registry.locales.supported.includes(l))
23
+ return l;
24
+ }
25
+ catch {
26
+ // no window — the default is fine
27
+ }
28
+ return registry.locales.default;
29
+ }
30
+ function writeLocaleToUrl(next) {
31
+ try {
32
+ const u = new URL(window.location.href);
33
+ u.searchParams.set("locale", next);
34
+ window.history.replaceState(null, "", u);
35
+ }
36
+ catch {
37
+ // the URL is cosmetic here
38
+ }
39
+ }
40
+ export function SharedForm({ ops, registry, name }) {
41
+ const item = registry.shared.find((s) => s.name === name);
42
+ const multilingual = registry.locales.supported.length > 1;
43
+ const [locale, setLocale] = useState(() => localeFromUrl(registry));
44
+ const [record, setRecord] = useState(null);
45
+ const [fields, setFields] = useState(null);
46
+ const [usage, setUsage] = useState([]);
47
+ const [refOptions, setRefOptions] = useState({});
48
+ const [banner, setBanner] = useState(null);
49
+ const [fieldErrors, setFieldErrors] = useState({});
50
+ const [dirty, setDirty] = useState(false);
51
+ const refTargets = useMemo(() => {
52
+ const out = {};
53
+ for (const [key, d] of Object.entries(item?.fields ?? {})) {
54
+ if ((d.type === "ref" || d.type === "refList") && typeof d.target === "string")
55
+ out[key] = d.target;
56
+ }
57
+ return out;
58
+ }, [item]);
59
+ const localeKeys = useMemo(() => Object.entries(item?.fields ?? {}).filter(([, d]) => d.localized === true).map(([key]) => key), [item]);
60
+ useEffect(() => {
61
+ let cancelled = false;
62
+ setFields(null);
63
+ setFieldErrors({});
64
+ ops.shared.get(name, locale).then((r) => {
65
+ if (cancelled)
66
+ return;
67
+ if (r.ok) {
68
+ setRecord(r.data.record);
69
+ setFields(r.data.fields);
70
+ setUsage(r.data.usage);
71
+ setDirty(false);
72
+ }
73
+ else
74
+ setBanner({ tone: "error", text: r.message });
75
+ }).catch(() => { if (!cancelled)
76
+ setBanner({ tone: "error", text: "Couldn't reach the server." }); });
77
+ return () => { cancelled = true; };
78
+ }, [ops, name, locale]);
79
+ // Ref options in the locale being edited — the entry form's rule.
80
+ useEffect(() => {
81
+ let cancelled = false;
82
+ for (const target of new Set(Object.values(refTargets))) {
83
+ const targetMeta = registry.collections.find((c) => c.name === target);
84
+ ops.entries.list(target).then((r) => {
85
+ if (cancelled || !r.ok)
86
+ return;
87
+ const options = r.data
88
+ .filter((e) => e.locales[locale])
89
+ .map((e) => ({ id: e.id, title: targetMeta ? entryTitle(e, targetMeta, registry, locale) : e.id, status: e.locales[locale].status }));
90
+ setRefOptions((prev) => ({ ...prev, [target]: options }));
91
+ }).catch(console.error);
92
+ }
93
+ return () => { cancelled = true; };
94
+ }, [ops, refTargets, registry, locale]);
95
+ if (!item)
96
+ return _jsx("p", { style: { padding: 32 }, children: "No such shared item." });
97
+ const applyResult = (r, okText) => {
98
+ if (r.ok) {
99
+ setBanner({ tone: "ok", text: okText });
100
+ setFieldErrors({});
101
+ return true;
102
+ }
103
+ setBanner({ tone: "error", text: r.message });
104
+ setFieldErrors(Object.fromEntries((r.fields ?? []).map((f) => [f.field, f.message])));
105
+ return false;
106
+ };
107
+ const save = async () => {
108
+ if (!fields)
109
+ return;
110
+ const r = await ops.shared.save(name, locale, fields);
111
+ if (applyResult(r, "Saved.") && r.ok) {
112
+ setRecord(r.data);
113
+ setDirty(false);
114
+ }
115
+ };
116
+ const row = record?.locales[locale];
117
+ const status = row?.status ?? "draft";
118
+ const setPublished = async (next) => {
119
+ if (next === status)
120
+ return;
121
+ const r = await ops.shared.setStatus(name, locale, next);
122
+ if (applyResult(r, next === "published" ? "Published." : "Unpublished.") && r.ok)
123
+ setRecord(r.data);
124
+ };
125
+ const switchLocale = (next) => {
126
+ if (next === locale)
127
+ return;
128
+ if (dirty && !window.confirm("Discard unsaved changes?"))
129
+ return;
130
+ writeLocaleToUrl(next);
131
+ setBanner(null);
132
+ setLocale(next);
133
+ };
134
+ const shared = Object.entries(item.fields).filter(([key]) => !localeKeys.includes(key));
135
+ const own = Object.entries(item.fields).filter(([key]) => localeKeys.includes(key));
136
+ const styles = (fields?.[STYLES_KEY] ?? {});
137
+ const pages = new Set(usage.filter((u) => u.sourceKind === "page").map((u) => u.sourceId));
138
+ const widget = ([key, d]) => (_jsx(FieldWidget, { fieldKey: key, descriptor: d, value: fields?.[key], errors: fieldErrors, refOptions: refTargets[key] ? refOptions[refTargets[key]] ?? [] : undefined, onChange: (value) => { setFields((f) => ({ ...(f ?? {}), [key]: value })); setDirty(true); } }, key));
139
+ return (_jsxs(_Fragment, { children: [_jsx(TopBar, { left: _jsxs(_Fragment, { children: [_jsx(Breadcrumb, { backHref: `${BASE}/shared`, items: [{ label: "Shared content", href: `${BASE}/shared` }, { label: item.title }] }), multilingual && (_jsx(LocaleSwitcher, { registry: registry, present: registry.locales.supported, parentLocales: [], value: locale, adding: false, onSwitch: switchLocale, onAdd: () => undefined }))] }), right: _jsx(Button, { onClick: save, disabled: fields === null, children: "Save" }) }), _jsxs("div", { style: { flex: 1, minHeight: 0, display: "grid", gridTemplateColumns: "1fr var(--inspector-w)" }, children: [_jsxs("div", { style: { display: "flex", flexDirection: "column", gap: 18, padding: "24px 32px", minWidth: 0, overflow: "auto" }, children: [banner && _jsx(Banner, { tone: banner.tone, onDismiss: () => setBanner(null), children: banner.text }), 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: `${BASE}/pages/${encodeURIComponent(id)}?locale=${encodeURIComponent(locale)}`, children: id })] }, id)))] })), fields !== null ? (_jsxs(_Fragment, { children: [shared.length > 0 && (_jsxs(_Fragment, { children: [multilingual && _jsx(SectionLabel, { children: "Shared across languages" }), shared.map(widget)] })), own.length > 0 && (_jsxs(_Fragment, { children: [multilingual && _jsxs(SectionLabel, { children: ["In ", locale] }), own.map(widget)] })), item.section !== undefined && Object.keys(item.styles).length > 0 && (_jsxs(_Fragment, { children: [_jsx(SectionLabel, { children: "Styles" }), Object.entries(item.styles).map(([key, d]) => (_jsx(FieldWidget, { fieldKey: key, descriptor: d, value: styles[key], onChange: (value) => {
140
+ setFields((f) => ({ ...(f ?? {}), [STYLES_KEY]: { ...(f?.[STYLES_KEY] ?? {}), [key]: value } }));
141
+ setDirty(true);
142
+ } }, key)))] }))] })) : !banner && _jsx("p", { style: { color: "var(--text-muted)" }, children: "Loading\u2026" })] }), _jsxs("aside", { style: { display: "flex", flexDirection: "column", gap: 16, padding: "16px 14px", minHeight: 0, overflow: "auto", borderLeft: "var(--border)" }, children: [_jsxs("div", { style: { display: "flex", flexDirection: "column", gap: 8 }, children: [_jsxs(SectionLabel, { children: ["Status", multilingual ? ` · ${locale}` : ""] }), _jsx(Segmented, { options: STATUSES, stretch: true, disabled: !row, value: status === "published" ? "Published" : "Draft", onChange: (v) => { void setPublished(v === "Published" ? "published" : "draft"); } })] }), record && row && (_jsxs(_Fragment, { children: [_jsx(Divider, { bleed: 14 }), _jsxs("div", { style: { display: "flex", flexDirection: "column", gap: 8 }, children: [_jsx(SectionLabel, { children: "Document info" }), _jsx(InfoRow, { label: "Kind", value: item.section !== undefined ? `Visual · ${item.section}` : "Fields" }), _jsx(InfoRow, { label: "Updated", value: relativeTime(row.updatedAt) }), _jsx(InfoRow, { label: "ID", value: record.id, mono: true })] })] }))] })] })] }));
143
+ }
144
+ function InfoRow({ label, value, mono }) {
145
+ return (_jsxs("div", { style: { display: "flex", justifyContent: "space-between", gap: 10, fontSize: "var(--text-sm)", color: "var(--text-muted)" }, children: [_jsx("span", { children: label }), _jsx("span", { style: { color: "var(--text)", fontFamily: mono ? "var(--font-mono)" : undefined, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }, children: value })] }));
146
+ }
@@ -0,0 +1,6 @@
1
+ import type { AdminOps } from "../ops.ts";
2
+ import type { AdminRegistry } from "../serialize.ts";
3
+ export declare function SharedList({ ops, registry }: {
4
+ ops: AdminOps;
5
+ registry: AdminRegistry;
6
+ }): import("react").JSX.Element;
@@ -0,0 +1,62 @@
1
+ "use client";
2
+ import { jsx as _jsx } from "react/jsx-runtime";
3
+ // The Shared content list (spec 2026-09-07 §6): every registered item is
4
+ // a row, edited on its own page. Nothing here is created or deleted by
5
+ // editors — the registry declares the items and ops.shared.list
6
+ // materializes them — so there is no "+ New".
7
+ import { useCallback, useEffect, useState } from "react";
8
+ import { ListView } from "./ListView.js";
9
+ import { sharedRows } from "./shared-list.js";
10
+ import { Segmented, StatusBadge } from "../ui/primitives.js";
11
+ import { relativeTime } from "../ui/format.js";
12
+ import { BASE } from "./base.js";
13
+ import { ErrorPane } from "./ErrorPane.js";
14
+ const LOCALE_KEY = "smoodly.entries.locale"; // the same choice the collection lists remember
15
+ function rememberedLocale(registry) {
16
+ try {
17
+ const stored = window.sessionStorage.getItem(LOCALE_KEY);
18
+ if (stored && registry.locales.supported.includes(stored))
19
+ return stored;
20
+ }
21
+ catch {
22
+ // storage unavailable — the default is fine
23
+ }
24
+ return registry.locales.default;
25
+ }
26
+ export function SharedList({ ops, registry }) {
27
+ const [items, setItems] = useState(null);
28
+ const [error, setError] = useState(null);
29
+ const [locale, setLocale] = useState(() => rememberedLocale(registry));
30
+ const multilingual = registry.locales.supported.length > 1;
31
+ const pickLocale = (next) => {
32
+ setLocale(next);
33
+ try {
34
+ window.sessionStorage.setItem(LOCALE_KEY, next);
35
+ }
36
+ catch { /* see rememberedLocale */ }
37
+ };
38
+ const load = useCallback(() => {
39
+ ops.shared.list()
40
+ .then((r) => (r.ok ? setItems(r.data) : setError(r.message)))
41
+ .catch(() => setError("Couldn't reach the server."));
42
+ }, [ops]);
43
+ useEffect(load, [load]);
44
+ if (error)
45
+ return _jsx(ErrorPane, { message: error });
46
+ const rows = items ? sharedRows(items, registry, locale) : [];
47
+ return (_jsx(ListView, { title: "Shared content", count: items ? rows.length : undefined, actions: multilingual ? _jsx(Segmented, { options: registry.locales.supported, value: locale, onChange: pickLocale }) : undefined, columns: [
48
+ { label: "Title", width: "minmax(160px,1fr)" },
49
+ { label: "Kind", width: "90px" },
50
+ { label: "Updated", width: "minmax(110px,160px)" },
51
+ { label: "Status", width: "120px" },
52
+ ], empty: items ? "No shared content is registered." : "Loading…", rows: rows.map((row) => ({
53
+ key: row.name,
54
+ href: `${BASE}/shared/${encodeURIComponent(row.name)}?locale=${encodeURIComponent(locale)}`,
55
+ cells: [
56
+ _jsx("span", { className: "sm-cell sm-cell--title", children: row.title }, "t"),
57
+ _jsx("span", { className: "sm-cell sm-cell--muted", children: row.flavour }, "k"),
58
+ _jsx("span", { className: "sm-cell sm-cell--muted", children: relativeTime(row.updatedAt) }, "u"),
59
+ _jsx(StatusBadge, { status: row.status }, "s"),
60
+ ],
61
+ })) }));
62
+ }
@@ -0,0 +1,14 @@
1
+ import type { EntryRecord } from "../../entry-store.ts";
2
+ import type { AdminRegistry } from "../serialize.ts";
3
+ import { type LocaleStatus } from "./pages-tree.ts";
4
+ export type SharedRow = {
5
+ name: string;
6
+ title: string;
7
+ flavour: "Visual" | "Fields";
8
+ status: LocaleStatus;
9
+ updatedAt: number;
10
+ };
11
+ export declare function sharedRows(items: {
12
+ name: string;
13
+ record: EntryRecord;
14
+ }[], registry: AdminRegistry, locale: string): SharedRow[];
@@ -0,0 +1,17 @@
1
+ import { localeStatusOf } from "./pages-tree.js";
2
+ export function sharedRows(items, registry, locale) {
3
+ const byName = new Map(items.map((i) => [i.name, i.record]));
4
+ return registry.shared.flatMap((item) => {
5
+ const record = byName.get(item.name);
6
+ const row = record?.locales[locale];
7
+ if (!record || !row)
8
+ return [];
9
+ return [{
10
+ name: item.name,
11
+ title: item.title,
12
+ flavour: item.section !== undefined ? "Visual" : "Fields",
13
+ status: localeStatusOf(row),
14
+ updatedAt: row.updatedAt,
15
+ }];
16
+ });
17
+ }
@@ -1,6 +1,6 @@
1
1
  import type { PageTree, TreeNode } from "../render.tsx";
2
2
  import type { ZonePolicy } from "../zones.ts";
3
- import type { AdminSection } from "./serialize.ts";
3
+ import type { AdminSection, AdminShared } from "./serialize.ts";
4
4
  export declare function zoneOf(tree: PageTree, id: string): string | null;
5
5
  export declare function findNode(tree: PageTree, id: string): TreeNode | null;
6
6
  export declare function insertNode(tree: PageTree, zone: string, index: number, node: TreeNode): PageTree;
@@ -9,11 +9,18 @@ export declare function moveNode(tree: PageTree, id: string, dir: -1 | 1): PageT
9
9
  export declare function patchNode(tree: PageTree, id: string, ns: "fields" | "styles" | "meta", key: string, value: unknown): PageTree;
10
10
  export declare function canInsert(policy: ZonePolicy | undefined, count: number): boolean;
11
11
  export declare function allowedSections(policy: ZonePolicy | undefined, all: AdminSection[]): AdminSection[];
12
+ /** The visual items the picker offers in a zone: materialized (an id is
13
+ * known) and rendering a section the zone's policy allows. */
14
+ export declare function allowedShared(policy: ZonePolicy | undefined, shared: AdminShared[], sharedIds: Record<string, string>): AdminShared[];
15
+ /** A page places a visual item as this node (spec 2026-09-07 §6): `item`
16
+ * names the registration, `ref` is the item's row id. */
17
+ export declare function placementNode(item: AdminShared, ref: string, id: string): TreeNode;
12
18
  export declare function nodeFromSample(section: AdminSection, id: string): TreeNode;
13
19
  /** A locked zone holds exactly one node the editor can never insert or
14
20
  * remove — so someone has to put it there. This does, from the component's
15
- * sample, for every locked zone that is empty or missing. Runs when the
16
- * editor loads a draft, which also covers pages that predate a locked zone
17
- * being added to their schema. Returns the same tree when nothing changed. */
18
- export declare function fillLockedZones(tree: PageTree, policies: Record<string, ZonePolicy>, sections: AdminSection[]): PageTree;
21
+ * sample (or, for a zone bound to a shared item, a placement), for every
22
+ * locked zone that is empty or missing. Runs when the editor loads a draft,
23
+ * which also covers pages that predate a locked zone being added to their
24
+ * schema. Returns the same tree when nothing changed. */
25
+ export declare function fillLockedZones(tree: PageTree, policies: Record<string, ZonePolicy>, sections: AdminSection[], sharedIds?: Record<string, string>): PageTree;
19
26
  export declare function newNodeId(type: string): string;
@@ -1,3 +1,4 @@
1
+ import { SHARED_NODE_TYPE } from "../shared.js";
1
2
  export function zoneOf(tree, id) {
2
3
  for (const [zone, nodes] of Object.entries(tree.zones)) {
3
4
  if (nodes.some((n) => n.id === id))
@@ -60,6 +61,22 @@ export function allowedSections(policy, all) {
60
61
  const allow = new Set(policy.allow);
61
62
  return all.filter((s) => allow.has(s.name));
62
63
  }
64
+ /** The visual items the picker offers in a zone: materialized (an id is
65
+ * known) and rendering a section the zone's policy allows. */
66
+ export function allowedShared(policy, shared, sharedIds) {
67
+ if (!policy || policy.kind === "locked")
68
+ return [];
69
+ return shared.filter((s) => {
70
+ if (s.section === undefined || sharedIds[s.name] === undefined)
71
+ return false;
72
+ return policy.allow === "*" || policy.allow.includes(s.section);
73
+ });
74
+ }
75
+ /** A page places a visual item as this node (spec 2026-09-07 §6): `item`
76
+ * names the registration, `ref` is the item's row id. */
77
+ export function placementNode(item, ref, id) {
78
+ return { id, type: SHARED_NODE_TYPE, fields: { item: item.name, ref } };
79
+ }
63
80
  /** The empty value a fresh node's field starts at, by descriptor type.
64
81
  * Shape matters: a list-valued field seeded with "" is a type lie that
65
82
  * crashes the first section view that maps over it. */
@@ -89,21 +106,35 @@ export function nodeFromSample(section, id) {
89
106
  }
90
107
  /** A locked zone holds exactly one node the editor can never insert or
91
108
  * remove — so someone has to put it there. This does, from the component's
92
- * sample, for every locked zone that is empty or missing. Runs when the
93
- * editor loads a draft, which also covers pages that predate a locked zone
94
- * being added to their schema. Returns the same tree when nothing changed. */
95
- export function fillLockedZones(tree, policies, sections) {
109
+ * sample (or, for a zone bound to a shared item, a placement), for every
110
+ * locked zone that is empty or missing. Runs when the editor loads a draft,
111
+ * which also covers pages that predate a locked zone being added to their
112
+ * schema. Returns the same tree when nothing changed. */
113
+ export function fillLockedZones(tree, policies, sections, sharedIds = {}) {
96
114
  let next = null;
97
115
  for (const [zone, policy] of Object.entries(policies)) {
98
116
  if (policy.kind !== "locked")
99
117
  continue;
100
118
  if ((tree.zones[zone] ?? []).length > 0)
101
119
  continue;
102
- const section = sections.find((s) => s.name === policy.component);
103
- if (!section)
104
- continue;
120
+ let node = null;
121
+ if (policy.shared !== undefined) {
122
+ // Bound to a shared item (spec 2026-09-07 §4): a placement, once
123
+ // the editor has loaded the item's row id; until then leave the
124
+ // zone empty rather than seed a plain section there.
125
+ const ref = sharedIds[policy.shared];
126
+ if (ref === undefined)
127
+ continue;
128
+ node = placementNode({ name: policy.shared }, ref, newNodeId(SHARED_NODE_TYPE));
129
+ }
130
+ else {
131
+ const section = sections.find((s) => s.name === policy.component);
132
+ if (!section)
133
+ continue;
134
+ node = nodeFromSample(section, newNodeId(section.name));
135
+ }
105
136
  next ?? (next = { ...tree, zones: { ...tree.zones } });
106
- next.zones[zone] = [nodeFromSample(section, newNodeId(section.name))];
137
+ next.zones[zone] = [node];
107
138
  }
108
139
  return next ?? tree;
109
140
  }
@@ -1,4 +1,4 @@
1
- export declare const ADMIN_CSS = "\n@import url(\"https://fonts.googleapis.com/css2?family=Public+Sans:wght@400;500;600;700&display=swap\");\n\n.sm-admin{\n --font-sans:'Public Sans',-apple-system,BlinkMacSystemFont,'Segoe UI',Helvetica,Arial,sans-serif;\n --font-mono:ui-monospace,Menlo,Consolas,monospace;\n --text-2xs:9px; --text-xs:10px; --text-sm:11px; --text-md:12px;\n --text-lg:13px; --text-xl:14px; --text-2xl:15px; --text-3xl:20px; --text-4xl:30px;\n --tracking-tight:-0.02em; --tracking-label:0.1em; --tracking-tag:0.08em;\n\n --radius-xs:1px; --radius-sm:3px; --radius-md:4px; --radius-round:999px;\n --control-h:28px; --control-h-lg:36px; --row-h:38px; --topbar-h:48px;\n --nav-w:200px; --inspector-w:300px;\n --border:1px solid var(--line);\n --outline-selection:1.5px solid var(--selection);\n\n --brand-600:#0038B1; --brand-500:#2554D6; --brand-300:#6E96FF;\n --brand-200:#A9C0FF; --brand-100:#DCE4F9; --brand-50:#E3EAFA;\n --gray-0:#FFFFFF; --gray-50:#F1F2F6; --gray-100:#E1E4EC; --gray-300:#C9D0E2;\n --gray-500:#8B93A7; --gray-600:#6B7386; --gray-700:#4A5165; --gray-900:#0F1420;\n --green-700:#1F7A46; --green-50:#E3F3EA; --green-300:#6FD39A; --green-500:#2E9E5B;\n --amber-700:#8A5A00; --amber-50:#FBF1DC; --amber-300:#E8B85A;\n --red-700:#B42318; --red-50:#FDECEC; --red-300:#F5A5A0;\n\n --bg:var(--gray-50); --surface:var(--gray-0); --surface-inset:var(--gray-50);\n --input-bg:var(--gray-0); --line:var(--gray-100); --line-dashed:var(--gray-300);\n --text:var(--gray-900); --text-muted:var(--gray-600); --text-subtle:var(--gray-500);\n --accent:var(--brand-600); --on-accent:#FFFFFF; --accent-soft:var(--brand-50);\n --selection:var(--brand-600); --nav-active-bg:var(--brand-100); --nav-active-fg:var(--brand-600);\n --hover:rgba(15,20,32,.045); --press:rgba(15,20,32,.08);\n --control-hover:var(--gray-100);\n\n --status-published-fg:var(--green-700); --status-published-bg:var(--green-50);\n --status-draft-fg:var(--amber-700); --status-draft-bg:var(--amber-50);\n --status-scheduled-fg:var(--accent); --status-scheduled-bg:var(--accent-soft);\n --status-saved-dot:var(--green-500);\n --warn:var(--amber-700); --warn-soft:var(--amber-50);\n --danger:var(--red-700); --danger-soft:var(--red-50);\n\n --page-bg:#FFFFFF; --page-text:var(--gray-900);\n --shadow-page:0 1px 2px rgba(0,0,0,.08), 0 8px 24px rgba(0,0,0,.06);\n --shadow-float:0 2px 8px rgba(0,0,0,.25);\n --shadow-seg:0 1px 2px rgba(0,0,0,.08);\n --focus-ring:0 0 0 3px var(--accent-soft);\n --shadow-pop:0 8px 32px rgba(0,0,0,.18);\n --logo-filter:none;\n\n /* Which half of the theme toggle is visible. A token, so the icon can never\n disagree with the colors \u2014 both follow the one cascade below. */\n --when-light:inline-flex; --when-dark:none;\n}\nhtml[data-theme=\"dark\"] .sm-admin,.sm-admin[data-theme=\"dark\"]{\n --bg:#0D0F14; --surface:#15181F; --surface-inset:#0D0F14; --input-bg:#1B1F28;\n --line:#2A2F3B; --line-dashed:#2A2F3B;\n --text:#EEF0F5; --text-muted:#8C94A8; --text-subtle:#6B7386;\n --accent:var(--brand-300); --on-accent:#06122F; --accent-soft:rgba(110,150,255,.16);\n --nav-active-bg:rgba(110,150,255,.14); --nav-active-fg:var(--brand-200);\n --hover:rgba(238,240,245,.05); --press:rgba(238,240,245,.09);\n --control-hover:var(--input-bg);\n\n --status-published-fg:var(--green-300); --status-published-bg:rgba(111,211,154,.14);\n --status-draft-fg:var(--amber-300); --status-draft-bg:rgba(232,184,90,.14);\n --warn:var(--amber-300); --warn-soft:rgba(232,184,90,.14);\n --danger:var(--red-300); --danger-soft:rgba(244,102,92,.14);\n\n --shadow-page:0 1px 2px rgba(0,0,0,.4), 0 8px 24px rgba(0,0,0,.35);\n --shadow-pop:0 8px 32px rgba(0,0,0,.5);\n --logo-filter:brightness(0) invert(1);\n --when-light:none; --when-dark:inline-flex;\n}\n@media (prefers-color-scheme:dark){\n html:not([data-theme=\"light\"]) .sm-admin:not([data-theme=\"light\"]){\n --bg:#0D0F14; --surface:#15181F; --surface-inset:#0D0F14; --input-bg:#1B1F28;\n --line:#2A2F3B; --line-dashed:#2A2F3B;\n --text:#EEF0F5; --text-muted:#8C94A8; --text-subtle:#6B7386;\n --accent:var(--brand-300); --on-accent:#06122F; --accent-soft:rgba(110,150,255,.16);\n --nav-active-bg:rgba(110,150,255,.14); --nav-active-fg:var(--brand-200);\n --hover:rgba(238,240,245,.05); --press:rgba(238,240,245,.09);\n --control-hover:var(--input-bg);\n\n --status-published-fg:var(--green-300); --status-published-bg:rgba(111,211,154,.14);\n --status-draft-fg:var(--amber-300); --status-draft-bg:rgba(232,184,90,.14);\n --warn:var(--amber-300); --warn-soft:rgba(232,184,90,.14);\n --danger:var(--red-300); --danger-soft:rgba(244,102,92,.14);\n\n --shadow-page:0 1px 2px rgba(0,0,0,.4), 0 8px 24px rgba(0,0,0,.35);\n --shadow-pop:0 8px 32px rgba(0,0,0,.5);\n --logo-filter:brightness(0) invert(1);\n --when-light:none; --when-dark:inline-flex;\n}\n}\n/* Last, and the same specificity as the host-attribute rule: an explicit\n choice from the theme toggle wins over a dark <html> and over the OS. */\n.sm-admin[data-theme=\"light\"]{\n --brand-600:#0038B1; --brand-500:#2554D6; --brand-300:#6E96FF;\n --brand-200:#A9C0FF; --brand-100:#DCE4F9; --brand-50:#E3EAFA;\n --gray-0:#FFFFFF; --gray-50:#F1F2F6; --gray-100:#E1E4EC; --gray-300:#C9D0E2;\n --gray-500:#8B93A7; --gray-600:#6B7386; --gray-700:#4A5165; --gray-900:#0F1420;\n --green-700:#1F7A46; --green-50:#E3F3EA; --green-300:#6FD39A; --green-500:#2E9E5B;\n --amber-700:#8A5A00; --amber-50:#FBF1DC; --amber-300:#E8B85A;\n --red-700:#B42318; --red-50:#FDECEC; --red-300:#F5A5A0;\n\n --bg:var(--gray-50); --surface:var(--gray-0); --surface-inset:var(--gray-50);\n --input-bg:var(--gray-0); --line:var(--gray-100); --line-dashed:var(--gray-300);\n --text:var(--gray-900); --text-muted:var(--gray-600); --text-subtle:var(--gray-500);\n --accent:var(--brand-600); --on-accent:#FFFFFF; --accent-soft:var(--brand-50);\n --selection:var(--brand-600); --nav-active-bg:var(--brand-100); --nav-active-fg:var(--brand-600);\n --hover:rgba(15,20,32,.045); --press:rgba(15,20,32,.08);\n --control-hover:var(--gray-100);\n\n --status-published-fg:var(--green-700); --status-published-bg:var(--green-50);\n --status-draft-fg:var(--amber-700); --status-draft-bg:var(--amber-50);\n --status-scheduled-fg:var(--accent); --status-scheduled-bg:var(--accent-soft);\n --status-saved-dot:var(--green-500);\n --warn:var(--amber-700); --warn-soft:var(--amber-50);\n --danger:var(--red-700); --danger-soft:var(--red-50);\n\n --page-bg:#FFFFFF; --page-text:var(--gray-900);\n --shadow-page:0 1px 2px rgba(0,0,0,.08), 0 8px 24px rgba(0,0,0,.06);\n --shadow-float:0 2px 8px rgba(0,0,0,.25);\n --shadow-seg:0 1px 2px rgba(0,0,0,.08);\n --focus-ring:0 0 0 3px var(--accent-soft);\n --shadow-pop:0 8px 32px rgba(0,0,0,.18);\n --logo-filter:none;\n\n /* Which half of the theme toggle is visible. A token, so the icon can never\n disagree with the colors \u2014 both follow the one cascade below. */\n --when-light:inline-flex; --when-dark:none;\n}\n\n/* The admin owns the viewport; a host <body> margin would push it into a\n page scrollbar. Scoped by :has, so only a page holding the admin is touched. */\nbody:has(.sm-admin){margin:0}\n.sm-admin,.sm-admin *,.sm-admin *::before,.sm-admin *::after{box-sizing:border-box}\n.sm-admin{\n font-family:var(--font-sans);font-size:var(--text-md);line-height:1.4;\n color:var(--text);background:var(--surface);\n -webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale;\n}\n.sm-admin h1,.sm-admin h2,.sm-admin h3,.sm-admin p,.sm-admin ul{margin:0}\n/* :where() keeps this at zero specificity \u2014 a bare <a> is accent, but any\n component class on it (.sm-nav-item, .sm-btn, .sm-back) decides its own\n colour. Without this the element selector silently beat every one of them. */\n:where(.sm-admin a){color:var(--accent);text-decoration:none}\n:where(.sm-admin a:hover){color:var(--brand-500)}\n.sm-admin button,.sm-admin input,.sm-admin select,.sm-admin textarea{font-family:inherit}\n.sm-admin ::placeholder{color:var(--text-subtle)}\n.sm-admin ::selection{background:var(--accent-soft);color:var(--text)}\n.sm-mono{font-family:var(--font-mono)}\n\n/* \u2500\u2500 shell \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n.sm-shell{display:grid;grid-template-columns:var(--nav-w) 1fr;height:100vh;height:100dvh;overflow:hidden}\n.sm-shell>.sm-main{min-width:0;min-height:0;display:flex;flex-direction:column;overflow:hidden}\n.sm-nav{\n display:flex;flex-direction:column;gap:24px;padding:16px 12px;\n border-right:var(--border);background:var(--surface);min-width:0;overflow:auto;\n}\n.sm-nav__logo{height:18px;width:auto;align-self:flex-start;filter:var(--logo-filter);flex:none}\n.sm-nav>.sm-nav__logo{margin-left:10px}\n.sm-nav__list{display:flex;flex-direction:column;gap:1px}\n.sm-nav-item{\n display:block;padding:6px 10px;border-radius:var(--radius-md);line-height:1.3;\n color:var(--text);font-weight:500;text-decoration:none;cursor:pointer;\n background:transparent;border:0;width:100%;text-align:left;font-size:var(--text-md);\n overflow:hidden;text-overflow:ellipsis;white-space:nowrap;\n}\n.sm-nav-item:hover{background:var(--surface-inset);color:var(--text)}\n.sm-nav-item[aria-current=\"page\"]{background:var(--nav-active-bg);color:var(--nav-active-fg);font-weight:600}\n.sm-nav-item[aria-current=\"page\"]:hover{background:var(--nav-active-bg);color:var(--nav-active-fg)}\n/* Not greyed: the reference renders every inactive item at --text and marks\n the unavailable ones only by being inert (no pointer, no hover). */\n.sm-nav-item[aria-disabled=\"true\"]{cursor:default}\n.sm-nav-item[aria-disabled=\"true\"]:hover{background:transparent}\n.sm-nav__foot{margin-top:auto;display:flex;flex-direction:column;gap:2px}\n.sm-nav__user{display:flex;align-items:center;gap:10px;padding:6px 10px;min-width:0}\n.sm-nav-item--icon{display:flex;align-items:center;gap:8px}\n.sm-when-light{display:var(--when-light);align-items:center;gap:8px}\n.sm-when-dark{display:var(--when-dark);align-items:center;gap:8px}\n.sm-avatar{\n width:24px;height:24px;flex:none;border-radius:var(--radius-round);\n background:var(--accent-soft);color:var(--accent);\n display:grid;place-items:center;font-size:var(--text-sm);font-weight:600;\n}\n\n/* \u2500\u2500 top bar \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n.sm-topbar{\n height:var(--topbar-h);flex:none;display:flex;align-items:center;justify-content:space-between;\n gap:16px;padding:0 16px;border-bottom:var(--border);background:var(--surface);\n}\n.sm-topbar__left{display:flex;align-items:center;gap:10px;min-width:0}\n.sm-topbar__right{display:flex;align-items:center;gap:8px;flex:none}\n.sm-crumbs{display:flex;align-items:center;gap:8px;color:var(--text-muted);min-width:0}\n.sm-crumbs__sep{opacity:.5}\n.sm-crumbs__here{color:var(--text);font-weight:600;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}\n.sm-back{border:0;background:transparent;padding:0;color:var(--text);font:inherit;cursor:pointer;line-height:1}\n.sm-back:hover{color:var(--accent)}\n.sm-title{font-size:var(--text-xl);font-weight:600}\n.sm-count{color:var(--text-muted)}\n\n/* \u2500\u2500 buttons \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n.sm-btn{\n display:inline-flex;align-items:center;justify-content:center;gap:6px;\n height:var(--control-h);padding:0 12px;border-radius:var(--radius-md);border:0;\n font-size:var(--text-md);font-weight:500;line-height:1;white-space:nowrap;cursor:pointer;\n text-decoration:none;\n}\n.sm-btn--lg{height:var(--control-h-lg);padding:0 16px}\n.sm-btn--primary{background:var(--accent);color:var(--on-accent);font-weight:600;padding:0 14px}\n.sm-btn--primary:hover{background:var(--brand-500);color:var(--on-accent)}\n.sm-btn--secondary{background:var(--surface-inset);color:var(--text)}\n.sm-btn--secondary:hover{background:var(--control-hover);color:var(--text)}\n.sm-btn--ghost{background:transparent;color:var(--accent);font-weight:600;padding:0 8px}\n.sm-btn--ghost:hover{background:var(--accent-soft);color:var(--accent)}\n.sm-btn--danger{background:var(--surface-inset);color:var(--danger)}\n.sm-btn--danger:hover{background:var(--danger-soft);color:var(--danger)}\n.sm-btn--ok{background:var(--status-published-bg);color:var(--status-published-fg);font-weight:600;padding:0 14px}\n.sm-btn:disabled,.sm-btn[aria-disabled=\"true\"]{opacity:.5;cursor:default;pointer-events:none}\n.sm-btn:focus-visible,.sm-iconbtn:focus-visible,.sm-nav-item:focus-visible,\n.sm-tab:focus-visible,.sm-seg__opt:focus-visible,.sm-row:focus-visible{\n outline:2px solid var(--accent);outline-offset:1px\n}\n\n.sm-iconbtn{\n width:24px;height:24px;flex:none;display:grid;place-items:center;padding:0;\n border:0;border-radius:var(--radius-sm);background:transparent;color:var(--text-muted);\n font-size:12px;line-height:1;cursor:pointer;\n}\n.sm-iconbtn:hover{background:var(--hover);color:var(--text)}\n.sm-iconbtn--inverse{color:#EEF0F5}\n.sm-iconbtn--inverse:hover{background:rgba(255,255,255,.14);color:#fff}\n.sm-iconbtn:disabled{opacity:.3;cursor:default}\n.sm-iconbtn:disabled:hover{background:transparent}\n\n/* \u2500\u2500 fields \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n.sm-field{display:flex;flex-direction:column;gap:6px;min-width:0}\n.sm-field__head{display:flex;align-items:baseline;justify-content:space-between;gap:8px}\n.sm-label{\n font-size:var(--text-2xs);font-weight:600;letter-spacing:var(--tracking-label);\n text-transform:uppercase;color:var(--text-muted);\n}\n.sm-counter,.sm-hint{font-size:var(--text-sm);color:var(--text-muted)}\n.sm-input,.sm-textarea,.sm-select{\n width:100%;min-width:0;border:1.5px solid transparent;border-radius:var(--radius-md);\n background:var(--surface-inset);color:var(--text);font-size:var(--text-md);\n outline:0;box-shadow:none;\n}\n.sm-input,.sm-select{height:var(--control-h);padding:0 10px}\n.sm-textarea{padding:8px 10px;line-height:1.5;resize:vertical;min-height:60px}\n.sm-input--lg{height:var(--control-h-lg);font-size:var(--text-2xl);font-weight:600}\n.sm-input--mono{font-family:var(--font-mono);font-size:var(--text-sm)}\n.sm-input:hover,.sm-textarea:hover,.sm-select:hover{background:var(--control-hover)}\n.sm-input:focus,.sm-textarea:focus,.sm-select:focus,\n.sm-inputwrap:focus-within{\n background:var(--input-bg);border-color:var(--selection);box-shadow:var(--focus-ring);\n}\n.sm-input:disabled,.sm-textarea:disabled,.sm-select:disabled{opacity:.6;cursor:default}\n.sm-select{\n appearance:none;-webkit-appearance:none;cursor:pointer;padding-right:26px;font-weight:500;\n background-image:linear-gradient(45deg,transparent 50%,var(--text-muted) 50%),\n linear-gradient(135deg,var(--text-muted) 50%,transparent 50%);\n background-position:calc(100% - 14px) 12px,calc(100% - 10px) 12px;\n background-size:4px 4px,4px 4px;background-repeat:no-repeat;\n}\n.sm-inputwrap{\n display:flex;align-items:center;gap:0;height:var(--control-h);padding:0 10px;\n border:1.5px solid transparent;border-radius:var(--radius-md);background:var(--surface-inset);\n}\n.sm-inputwrap>.sm-input{height:auto;padding:0;background:transparent;border:0;box-shadow:none}\n.sm-inputwrap>.sm-input:hover,.sm-inputwrap>.sm-input:focus{background:transparent;box-shadow:none}\n.sm-prefix{color:var(--text-muted);font-family:var(--font-mono);font-size:var(--text-sm);flex:none}\n.sm-error{color:var(--danger);font-size:var(--text-sm)}\n\n/* \u2500\u2500 data \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n.sm-table{display:flex;flex-direction:column;padding:0 16px 16px}\n.sm-table__head,.sm-row{display:grid;gap:12px;align-items:center;padding:0 8px}\n.sm-table__head{\n height:34px;border-bottom:var(--border);font-family:var(--font-mono);\n font-size:var(--text-2xs);letter-spacing:var(--tracking-tag);color:var(--text-muted);\n text-transform:uppercase;position:sticky;top:0;background:var(--surface);z-index:1;\n}\n.sm-table__body{display:flex;flex-direction:column;padding-top:4px}\n.sm-row{\n height:var(--row-h);border-radius:var(--radius-md);background:transparent;\n border:0;font:inherit;color:inherit;text-align:left;width:100%;\n}\n.sm-row--clickable{cursor:pointer}\n.sm-row--clickable:hover{background:var(--surface-inset)}\n.sm-cell{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;min-width:0}\n.sm-cell--muted{color:var(--text-muted)}\n.sm-cell--mono{font-family:var(--font-mono);font-size:var(--text-sm);color:var(--text-muted)}\n.sm-cell--title{font-weight:500}\n.sm-empty{padding:28px 8px;color:var(--text-muted)}\n.sm-chevron{\n width:12px;flex:none;border:0;background:none;padding:0;font:inherit;font-size:10px;\n color:var(--text-muted);cursor:pointer;line-height:1;\n}\n.sm-chevron:hover{color:var(--text)}\n.sm-cell--tree{display:flex;align-items:center;gap:6px;font-weight:500}\n.sm-cell--tree.sm-cell--parent{font-weight:600}\n.sm-rowmenu{\n border:0;background:none;padding:0 6px;font:inherit;color:var(--text-muted);cursor:pointer;\n border-radius:var(--radius-md);line-height:1;height:24px;\n}\n.sm-rowmenu:hover{background:var(--surface-inset);color:var(--text)}\n\n.sm-badge{\n display:inline-flex;align-items:center;height:20px;padding:0 8px;border-radius:var(--radius-md);\n font-size:var(--text-xs);font-weight:600;letter-spacing:.04em;white-space:nowrap;\n}\n.sm-savestate{\n display:inline-flex;align-items:center;gap:6px;padding:0 6px;\n font-size:var(--text-xs);font-weight:600;letter-spacing:.06em;text-transform:uppercase;\n}\n.sm-dot{width:5px;height:5px;border-radius:var(--radius-round);flex:none}\n\n/* \u2500\u2500 navigation bits \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n.sm-tabs{display:flex;gap:18px;padding:0 14px;height:36px;align-items:stretch;border-bottom:var(--border);flex:none}\n.sm-tab{\n display:flex;align-items:center;border:0;background:transparent;padding:0;font:inherit;\n cursor:pointer;color:var(--text-muted);border-bottom:2px solid transparent;margin-bottom:-1px;\n}\n.sm-tab:hover{color:var(--text)}\n.sm-tab[aria-selected=\"true\"]{color:var(--accent);font-weight:600;border-bottom-color:var(--accent)}\n.sm-seg{display:flex;gap:2px;padding:2px;background:var(--surface-inset);border-radius:var(--radius-md)}\n.sm-seg--stretch{width:100%}\n.sm-seg--disabled{opacity:.6}\n.sm-seg__opt:disabled{cursor:default}\n.sm-seg__opt{\n flex:1;padding:3px 10px;border:0;border-radius:var(--radius-sm);background:transparent;\n color:var(--text-muted);font:inherit;cursor:pointer;white-space:nowrap;\n}\n.sm-seg__opt:hover{color:var(--text)}\n.sm-seg__opt[aria-pressed=\"true\"]{background:var(--surface);color:var(--text);font-weight:600;box-shadow:var(--shadow-seg)}\n.sm-divider{height:1px;background:var(--line);flex:none}\n.sm-sectionlabel{display:flex;align-items:center;justify-content:space-between;gap:8px}\n.sm-sectionlabel--mono>.sm-label{\n font-family:var(--font-mono);font-weight:400;letter-spacing:var(--tracking-tag);\n}\n.sm-banner{\n display:flex;gap:14px;align-items:flex-start;padding:10px 14px;border-radius:var(--radius-md);\n font-size:var(--text-md);\n}\n.sm-banner--error{background:var(--danger-soft);color:var(--danger)}\n.sm-banner--ok{background:var(--status-published-bg);color:var(--status-published-fg)}\n.sm-banner--warn{background:var(--warn-soft);color:var(--warn)}\n\n/* \u2500\u2500 editor \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n.sm-editor{flex:1;min-height:0;display:grid;grid-template-columns:1fr var(--inspector-w)}\n.sm-canvas{display:flex;flex-direction:column;background:var(--bg);min-width:0;min-height:0;overflow:hidden}\n.sm-canvas__meta{\n display:flex;align-items:center;justify-content:space-between;gap:12px;padding:10px 20px;flex:none;\n font-family:var(--font-mono);font-size:var(--text-xs);letter-spacing:.04em;color:var(--text-muted);\n}\n.sm-canvas__scroll{flex:1;min-height:0;overflow:auto;padding:0 20px 20px}\n.sm-page{\n position:relative;background:var(--page-bg);border-radius:var(--radius-md);\n box-shadow:var(--shadow-page);min-height:100%;\n}\n.sm-inspector{\n display:flex;flex-direction:column;min-height:0;overflow:hidden;\n border-left:var(--border);background:var(--surface);\n}\n.sm-inspector__head{display:flex;align-items:center;justify-content:space-between;gap:8px;padding:12px 14px 10px;flex:none}\n.sm-inspector__body{display:flex;flex-direction:column;gap:14px;padding:14px;overflow:auto}\n.sm-blockglyph{width:14px;height:10px;border:1.5px solid var(--text-muted);border-radius:var(--radius-xs);flex:none}\n.sm-blocktag{\n position:absolute;top:0;left:0;background:var(--selection);color:#fff;\n font-family:var(--font-mono);font-size:var(--text-2xs);font-weight:700;\n letter-spacing:var(--tracking-tag);text-transform:uppercase;padding:3px 7px;\n border-radius:var(--radius-sm) 0 var(--radius-sm) 0;white-space:nowrap;\n}\n.sm-blocktoolbar{\n display:flex;gap:2px;padding:2px;background:#15181F;border-radius:var(--radius-md);\n box-shadow:var(--shadow-float);\n}\n.sm-plus{\n width:24px;height:24px;border-radius:var(--radius-round);border:0;background:var(--selection);\n color:#fff;display:grid;place-items:center;font-size:15px;line-height:1;cursor:pointer;\n box-shadow:0 2px 6px rgba(0,56,177,.35);padding:0;\n}\n.sm-plus:hover{background:var(--brand-500)}\n.sm-guide{height:1px;background:color-mix(in srgb,var(--selection) 35%,transparent)}\n.sm-tooltip{\n background:#15181F;color:#EEF0F5;font-size:var(--text-sm);padding:4px 8px;\n border-radius:var(--radius-sm);white-space:nowrap;\n}\n.sm-outline-row{\n display:flex;align-items:center;justify-content:space-between;gap:4px;\n height:var(--control-h);border-radius:var(--radius-md);padding:0 4px 0 10px;\n color:var(--text);font-weight:500;background:transparent;\n border:1px solid var(--line);\n}\n.sm-outline-row--clickable{cursor:pointer}\n.sm-outline-row--clickable:hover{background:var(--surface-inset)}\n/* Selected and locked rows are read by their fill, so they drop the hairline \u2014\n border-color, not border, so every row keeps the same 28px box. */\n.sm-outline-row--selected,.sm-outline-row--selected:hover{\n background:var(--nav-active-bg);color:var(--nav-active-fg);font-weight:600;border-color:transparent;\n}\n.sm-outline-row--empty{border:1px dashed var(--line-dashed);color:var(--text-muted);padding:0 10px}\n.sm-outline-row--locked{background:var(--surface-inset);color:var(--text-muted);padding:0 10px;border-color:transparent}\n.sm-outline-row__label{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;min-width:0}\n\n.sm-picker{\n width:300px;background:var(--surface);border:var(--border);border-radius:var(--radius-md);\n padding:6px;box-shadow:var(--shadow-pop);\n}\n.sm-picker__item{\n display:flex;gap:10px;align-items:center;width:100%;text-align:left;background:transparent;\n border:0;border-radius:var(--radius-md);padding:8px 8px;cursor:pointer;color:var(--text);font:inherit;\n}\n.sm-picker__item:hover{background:var(--nav-active-bg)}\n.sm-picker__icon{\n width:26px;height:26px;flex:none;border-radius:var(--radius-sm);background:var(--accent-soft);\n color:var(--accent);display:grid;place-items:center;font-size:13px;\n}\n.sm-dashed{\n border:1px dashed var(--line-dashed);border-radius:var(--radius-md);\n display:flex;align-items:center;justify-content:center;gap:8px;color:var(--text-muted);\n}\n";
1
+ export declare const ADMIN_CSS = "\n@import url(\"https://fonts.googleapis.com/css2?family=Public+Sans:wght@400;500;600;700&display=swap\");\n\n.sm-admin{\n --font-sans:'Public Sans',-apple-system,BlinkMacSystemFont,'Segoe UI',Helvetica,Arial,sans-serif;\n --font-mono:ui-monospace,Menlo,Consolas,monospace;\n --text-2xs:9px; --text-xs:10px; --text-sm:11px; --text-md:12px;\n --text-lg:13px; --text-xl:14px; --text-2xl:15px; --text-3xl:20px; --text-4xl:30px;\n --tracking-tight:-0.02em; --tracking-label:0.1em; --tracking-tag:0.08em;\n\n --radius-xs:1px; --radius-sm:3px; --radius-md:4px; --radius-round:999px;\n --control-h:28px; --control-h-lg:36px; --row-h:38px; --topbar-h:48px;\n --nav-w:200px; --inspector-w:300px;\n --border:1px solid var(--line);\n --outline-selection:1.5px solid var(--selection);\n\n --brand-600:#0038B1; --brand-500:#2554D6; --brand-300:#6E96FF;\n --brand-200:#A9C0FF; --brand-100:#DCE4F9; --brand-50:#E3EAFA;\n --gray-0:#FFFFFF; --gray-50:#F1F2F6; --gray-100:#E1E4EC; --gray-300:#C9D0E2;\n --gray-500:#8B93A7; --gray-600:#6B7386; --gray-700:#4A5165; --gray-900:#0F1420;\n --green-700:#1F7A46; --green-50:#E3F3EA; --green-300:#6FD39A; --green-500:#2E9E5B;\n --amber-700:#8A5A00; --amber-50:#FBF1DC; --amber-300:#E8B85A;\n --red-700:#B42318; --red-50:#FDECEC; --red-300:#F5A5A0;\n\n --bg:var(--gray-50); --surface:var(--gray-0); --surface-inset:var(--gray-50);\n --input-bg:var(--gray-0); --line:var(--gray-100); --line-dashed:var(--gray-300);\n --text:var(--gray-900); --text-muted:var(--gray-600); --text-subtle:var(--gray-500);\n --accent:var(--brand-600); --on-accent:#FFFFFF; --accent-soft:var(--brand-50);\n --selection:var(--brand-600); --nav-active-bg:var(--brand-100); --nav-active-fg:var(--brand-600);\n --hover:rgba(15,20,32,.045); --press:rgba(15,20,32,.08);\n --control-hover:var(--gray-100);\n\n --status-published-fg:var(--green-700); --status-published-bg:var(--green-50);\n --status-draft-fg:var(--amber-700); --status-draft-bg:var(--amber-50);\n --status-scheduled-fg:var(--accent); --status-scheduled-bg:var(--accent-soft);\n --status-saved-dot:var(--green-500);\n --warn:var(--amber-700); --warn-soft:var(--amber-50);\n --danger:var(--red-700); --danger-soft:var(--red-50);\n\n --page-bg:#FFFFFF; --page-text:var(--gray-900);\n --shadow-page:0 1px 2px rgba(0,0,0,.08), 0 8px 24px rgba(0,0,0,.06);\n --shadow-float:0 2px 8px rgba(0,0,0,.25);\n --shadow-seg:0 1px 2px rgba(0,0,0,.08);\n --focus-ring:0 0 0 3px var(--accent-soft);\n --shadow-pop:0 8px 32px rgba(0,0,0,.18);\n --logo-filter:none;\n\n /* Which half of the theme toggle is visible. A token, so the icon can never\n disagree with the colors \u2014 both follow the one cascade below. */\n --when-light:inline-flex; --when-dark:none;\n}\nhtml[data-theme=\"dark\"] .sm-admin,.sm-admin[data-theme=\"dark\"]{\n --bg:#0D0F14; --surface:#15181F; --surface-inset:#0D0F14; --input-bg:#1B1F28;\n --line:#2A2F3B; --line-dashed:#2A2F3B;\n --text:#EEF0F5; --text-muted:#8C94A8; --text-subtle:#6B7386;\n --accent:var(--brand-300); --on-accent:#06122F; --accent-soft:rgba(110,150,255,.16);\n --nav-active-bg:rgba(110,150,255,.14); --nav-active-fg:var(--brand-200);\n --hover:rgba(238,240,245,.05); --press:rgba(238,240,245,.09);\n --control-hover:var(--input-bg);\n\n --status-published-fg:var(--green-300); --status-published-bg:rgba(111,211,154,.14);\n --status-draft-fg:var(--amber-300); --status-draft-bg:rgba(232,184,90,.14);\n --warn:var(--amber-300); --warn-soft:rgba(232,184,90,.14);\n --danger:var(--red-300); --danger-soft:rgba(244,102,92,.14);\n\n --shadow-page:0 1px 2px rgba(0,0,0,.4), 0 8px 24px rgba(0,0,0,.35);\n --shadow-pop:0 8px 32px rgba(0,0,0,.5);\n --logo-filter:brightness(0) invert(1);\n --when-light:none; --when-dark:inline-flex;\n}\n@media (prefers-color-scheme:dark){\n html:not([data-theme=\"light\"]) .sm-admin:not([data-theme=\"light\"]){\n --bg:#0D0F14; --surface:#15181F; --surface-inset:#0D0F14; --input-bg:#1B1F28;\n --line:#2A2F3B; --line-dashed:#2A2F3B;\n --text:#EEF0F5; --text-muted:#8C94A8; --text-subtle:#6B7386;\n --accent:var(--brand-300); --on-accent:#06122F; --accent-soft:rgba(110,150,255,.16);\n --nav-active-bg:rgba(110,150,255,.14); --nav-active-fg:var(--brand-200);\n --hover:rgba(238,240,245,.05); --press:rgba(238,240,245,.09);\n --control-hover:var(--input-bg);\n\n --status-published-fg:var(--green-300); --status-published-bg:rgba(111,211,154,.14);\n --status-draft-fg:var(--amber-300); --status-draft-bg:rgba(232,184,90,.14);\n --warn:var(--amber-300); --warn-soft:rgba(232,184,90,.14);\n --danger:var(--red-300); --danger-soft:rgba(244,102,92,.14);\n\n --shadow-page:0 1px 2px rgba(0,0,0,.4), 0 8px 24px rgba(0,0,0,.35);\n --shadow-pop:0 8px 32px rgba(0,0,0,.5);\n --logo-filter:brightness(0) invert(1);\n --when-light:none; --when-dark:inline-flex;\n}\n}\n/* Last, and the same specificity as the host-attribute rule: an explicit\n choice from the theme toggle wins over a dark <html> and over the OS. */\n.sm-admin[data-theme=\"light\"]{\n --brand-600:#0038B1; --brand-500:#2554D6; --brand-300:#6E96FF;\n --brand-200:#A9C0FF; --brand-100:#DCE4F9; --brand-50:#E3EAFA;\n --gray-0:#FFFFFF; --gray-50:#F1F2F6; --gray-100:#E1E4EC; --gray-300:#C9D0E2;\n --gray-500:#8B93A7; --gray-600:#6B7386; --gray-700:#4A5165; --gray-900:#0F1420;\n --green-700:#1F7A46; --green-50:#E3F3EA; --green-300:#6FD39A; --green-500:#2E9E5B;\n --amber-700:#8A5A00; --amber-50:#FBF1DC; --amber-300:#E8B85A;\n --red-700:#B42318; --red-50:#FDECEC; --red-300:#F5A5A0;\n\n --bg:var(--gray-50); --surface:var(--gray-0); --surface-inset:var(--gray-50);\n --input-bg:var(--gray-0); --line:var(--gray-100); --line-dashed:var(--gray-300);\n --text:var(--gray-900); --text-muted:var(--gray-600); --text-subtle:var(--gray-500);\n --accent:var(--brand-600); --on-accent:#FFFFFF; --accent-soft:var(--brand-50);\n --selection:var(--brand-600); --nav-active-bg:var(--brand-100); --nav-active-fg:var(--brand-600);\n --hover:rgba(15,20,32,.045); --press:rgba(15,20,32,.08);\n --control-hover:var(--gray-100);\n\n --status-published-fg:var(--green-700); --status-published-bg:var(--green-50);\n --status-draft-fg:var(--amber-700); --status-draft-bg:var(--amber-50);\n --status-scheduled-fg:var(--accent); --status-scheduled-bg:var(--accent-soft);\n --status-saved-dot:var(--green-500);\n --warn:var(--amber-700); --warn-soft:var(--amber-50);\n --danger:var(--red-700); --danger-soft:var(--red-50);\n\n --page-bg:#FFFFFF; --page-text:var(--gray-900);\n --shadow-page:0 1px 2px rgba(0,0,0,.08), 0 8px 24px rgba(0,0,0,.06);\n --shadow-float:0 2px 8px rgba(0,0,0,.25);\n --shadow-seg:0 1px 2px rgba(0,0,0,.08);\n --focus-ring:0 0 0 3px var(--accent-soft);\n --shadow-pop:0 8px 32px rgba(0,0,0,.18);\n --logo-filter:none;\n\n /* Which half of the theme toggle is visible. A token, so the icon can never\n disagree with the colors \u2014 both follow the one cascade below. */\n --when-light:inline-flex; --when-dark:none;\n}\n\n/* The admin owns the viewport; a host <body> margin would push it into a\n page scrollbar. Scoped by :has, so only a page holding the admin is touched. */\nbody:has(.sm-admin){margin:0}\n.sm-admin,.sm-admin *,.sm-admin *::before,.sm-admin *::after{box-sizing:border-box}\n.sm-admin{\n font-family:var(--font-sans);font-size:var(--text-md);line-height:1.4;\n color:var(--text);background:var(--surface);\n -webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale;\n}\n.sm-admin h1,.sm-admin h2,.sm-admin h3,.sm-admin p,.sm-admin ul{margin:0}\n/* :where() keeps this at zero specificity \u2014 a bare <a> is accent, but any\n component class on it (.sm-nav-item, .sm-btn, .sm-back) decides its own\n colour. Without this the element selector silently beat every one of them. */\n:where(.sm-admin a){color:var(--accent);text-decoration:none}\n:where(.sm-admin a:hover){color:var(--brand-500)}\n.sm-admin button,.sm-admin input,.sm-admin select,.sm-admin textarea{font-family:inherit}\n.sm-admin ::placeholder{color:var(--text-subtle)}\n.sm-admin ::selection{background:var(--accent-soft);color:var(--text)}\n.sm-mono{font-family:var(--font-mono)}\n\n/* \u2500\u2500 shell \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n.sm-shell{display:grid;grid-template-columns:var(--nav-w) 1fr;height:100vh;height:100dvh;overflow:hidden}\n.sm-shell>.sm-main{min-width:0;min-height:0;display:flex;flex-direction:column;overflow:hidden}\n.sm-nav{\n display:flex;flex-direction:column;gap:24px;padding:16px 12px;\n border-right:var(--border);background:var(--surface);min-width:0;overflow:auto;\n}\n.sm-nav__logo{height:18px;width:auto;align-self:flex-start;filter:var(--logo-filter);flex:none}\n.sm-nav>.sm-nav__logo{margin-left:10px}\n.sm-nav__list{display:flex;flex-direction:column;gap:1px}\n.sm-nav-item{\n display:block;padding:6px 10px;border-radius:var(--radius-md);line-height:1.3;\n color:var(--text);font-weight:500;text-decoration:none;cursor:pointer;\n background:transparent;border:0;width:100%;text-align:left;font-size:var(--text-md);\n overflow:hidden;text-overflow:ellipsis;white-space:nowrap;\n}\n.sm-nav-item:hover{background:var(--surface-inset);color:var(--text)}\n.sm-nav-item[aria-current=\"page\"]{background:var(--nav-active-bg);color:var(--nav-active-fg);font-weight:600}\n.sm-nav-item[aria-current=\"page\"]:hover{background:var(--nav-active-bg);color:var(--nav-active-fg)}\n/* Not greyed: the reference renders every inactive item at --text and marks\n the unavailable ones only by being inert (no pointer, no hover). */\n.sm-nav-item[aria-disabled=\"true\"]{cursor:default}\n.sm-nav-item[aria-disabled=\"true\"]:hover{background:transparent}\n.sm-nav__foot{margin-top:auto;display:flex;flex-direction:column;gap:2px}\n.sm-nav__user{display:flex;align-items:center;gap:10px;padding:6px 10px;min-width:0}\n.sm-nav-item--icon{display:flex;align-items:center;gap:8px}\n.sm-when-light{display:var(--when-light);align-items:center;gap:8px}\n.sm-when-dark{display:var(--when-dark);align-items:center;gap:8px}\n.sm-avatar{\n width:24px;height:24px;flex:none;border-radius:var(--radius-round);\n background:var(--accent-soft);color:var(--accent);\n display:grid;place-items:center;font-size:var(--text-sm);font-weight:600;\n}\n\n/* \u2500\u2500 top bar \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n.sm-topbar{\n height:var(--topbar-h);flex:none;display:flex;align-items:center;justify-content:space-between;\n gap:16px;padding:0 16px;border-bottom:var(--border);background:var(--surface);\n}\n.sm-topbar__left{display:flex;align-items:center;gap:10px;min-width:0}\n.sm-topbar__right{display:flex;align-items:center;gap:8px;flex:none}\n.sm-crumbs{display:flex;align-items:center;gap:8px;color:var(--text-muted);min-width:0}\n.sm-crumbs__sep{opacity:.5}\n.sm-crumbs__here{color:var(--text);font-weight:600;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}\n.sm-back{border:0;background:transparent;padding:0;color:var(--text);font:inherit;cursor:pointer;line-height:1}\n.sm-back:hover{color:var(--accent)}\n.sm-title{font-size:var(--text-xl);font-weight:600}\n.sm-count{color:var(--text-muted)}\n\n/* \u2500\u2500 buttons \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n.sm-btn{\n display:inline-flex;align-items:center;justify-content:center;gap:6px;\n height:var(--control-h);padding:0 12px;border-radius:var(--radius-md);border:0;\n font-size:var(--text-md);font-weight:500;line-height:1;white-space:nowrap;cursor:pointer;\n text-decoration:none;\n}\n.sm-btn--lg{height:var(--control-h-lg);padding:0 16px}\n.sm-btn--primary{background:var(--accent);color:var(--on-accent);font-weight:600;padding:0 14px}\n.sm-btn--primary:hover{background:var(--brand-500);color:var(--on-accent)}\n.sm-btn--secondary{background:var(--surface-inset);color:var(--text)}\n.sm-btn--secondary:hover{background:var(--control-hover);color:var(--text)}\n.sm-btn--ghost{background:transparent;color:var(--accent);font-weight:600;padding:0 8px}\n.sm-btn--ghost:hover{background:var(--accent-soft);color:var(--accent)}\n.sm-btn--danger{background:var(--surface-inset);color:var(--danger)}\n.sm-btn--danger:hover{background:var(--danger-soft);color:var(--danger)}\n.sm-btn--ok{background:var(--status-published-bg);color:var(--status-published-fg);font-weight:600;padding:0 14px}\n.sm-btn:disabled,.sm-btn[aria-disabled=\"true\"]{opacity:.5;cursor:default;pointer-events:none}\n.sm-btn:focus-visible,.sm-iconbtn:focus-visible,.sm-nav-item:focus-visible,\n.sm-tab:focus-visible,.sm-seg__opt:focus-visible,.sm-row:focus-visible{\n outline:2px solid var(--accent);outline-offset:1px\n}\n\n.sm-iconbtn{\n width:24px;height:24px;flex:none;display:grid;place-items:center;padding:0;\n border:0;border-radius:var(--radius-sm);background:transparent;color:var(--text-muted);\n font-size:12px;line-height:1;cursor:pointer;\n}\n.sm-iconbtn:hover{background:var(--hover);color:var(--text)}\n.sm-iconbtn--inverse{color:#EEF0F5}\n.sm-iconbtn--inverse:hover{background:rgba(255,255,255,.14);color:#fff}\n.sm-iconbtn:disabled{opacity:.3;cursor:default}\n.sm-iconbtn:disabled:hover{background:transparent}\n\n/* \u2500\u2500 fields \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n.sm-field{display:flex;flex-direction:column;gap:6px;min-width:0}\n.sm-field__head{display:flex;align-items:baseline;justify-content:space-between;gap:8px}\n.sm-label{\n font-size:var(--text-2xs);font-weight:600;letter-spacing:var(--tracking-label);\n text-transform:uppercase;color:var(--text-muted);\n}\n.sm-counter,.sm-hint{font-size:var(--text-sm);color:var(--text-muted)}\n.sm-input,.sm-textarea,.sm-select{\n width:100%;min-width:0;border:1.5px solid transparent;border-radius:var(--radius-md);\n background:var(--surface-inset);color:var(--text);font-size:var(--text-md);\n outline:0;box-shadow:none;\n}\n.sm-input,.sm-select{height:var(--control-h);padding:0 10px}\n.sm-textarea{padding:8px 10px;line-height:1.5;resize:vertical;min-height:60px}\n.sm-input--lg{height:var(--control-h-lg);font-size:var(--text-2xl);font-weight:600}\n.sm-input--mono{font-family:var(--font-mono);font-size:var(--text-sm)}\n.sm-input:hover,.sm-textarea:hover,.sm-select:hover{background:var(--control-hover)}\n.sm-input:focus,.sm-textarea:focus,.sm-select:focus,\n.sm-inputwrap:focus-within{\n background:var(--input-bg);border-color:var(--selection);box-shadow:var(--focus-ring);\n}\n.sm-input:disabled,.sm-textarea:disabled,.sm-select:disabled{opacity:.6;cursor:default}\n.sm-select{\n appearance:none;-webkit-appearance:none;cursor:pointer;padding-right:26px;font-weight:500;\n background-image:linear-gradient(45deg,transparent 50%,var(--text-muted) 50%),\n linear-gradient(135deg,var(--text-muted) 50%,transparent 50%);\n background-position:calc(100% - 14px) 12px,calc(100% - 10px) 12px;\n background-size:4px 4px,4px 4px;background-repeat:no-repeat;\n}\n.sm-inputwrap{\n display:flex;align-items:center;gap:0;height:var(--control-h);padding:0 10px;\n border:1.5px solid transparent;border-radius:var(--radius-md);background:var(--surface-inset);\n}\n.sm-inputwrap>.sm-input{height:auto;padding:0;background:transparent;border:0;box-shadow:none}\n.sm-inputwrap>.sm-input:hover,.sm-inputwrap>.sm-input:focus{background:transparent;box-shadow:none}\n.sm-prefix{color:var(--text-muted);font-family:var(--font-mono);font-size:var(--text-sm);flex:none}\n.sm-error{color:var(--danger);font-size:var(--text-sm)}\n\n.sm-group{display:flex;flex-direction:column;gap:10px;padding:2px 0 0 10px;border-left:2px solid var(--line)}\n.sm-list{display:flex;flex-direction:column;gap:6px;align-items:stretch}\n.sm-listrow{border:1px solid var(--line);border-radius:var(--radius-md);min-width:0}\n.sm-listrow__head{display:flex;align-items:center;gap:2px;height:var(--control-h);padding:0 4px 0 4px}\n.sm-listrow__toggle{\n flex:1;min-width:0;display:flex;align-items:center;gap:6px;height:100%;padding:0 4px;\n border:0;background:transparent;color:var(--text);font:inherit;font-size:var(--text-md);font-weight:500;\n text-align:left;cursor:pointer;\n}\n.sm-listrow__toggle:hover{color:var(--accent)}\n.sm-listrow__summary{overflow:hidden;white-space:nowrap;text-overflow:ellipsis}\n.sm-listrow__body{display:flex;flex-direction:column;gap:10px;padding:8px 10px 10px;border-top:1px solid var(--line)}\n\n/* \u2500\u2500 data \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n.sm-table{display:flex;flex-direction:column;padding:0 16px 16px}\n.sm-table__head,.sm-row{display:grid;gap:12px;align-items:center;padding:0 8px}\n.sm-table__head{\n height:34px;border-bottom:var(--border);font-family:var(--font-mono);\n font-size:var(--text-2xs);letter-spacing:var(--tracking-tag);color:var(--text-muted);\n text-transform:uppercase;position:sticky;top:0;background:var(--surface);z-index:1;\n}\n.sm-table__body{display:flex;flex-direction:column;padding-top:4px}\n.sm-row{\n height:var(--row-h);border-radius:var(--radius-md);background:transparent;\n border:0;font:inherit;color:inherit;text-align:left;width:100%;\n}\n.sm-row--clickable{cursor:pointer}\n.sm-row--clickable:hover{background:var(--surface-inset)}\n.sm-cell{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;min-width:0}\n.sm-cell--muted{color:var(--text-muted)}\n.sm-cell--mono{font-family:var(--font-mono);font-size:var(--text-sm);color:var(--text-muted)}\n.sm-cell--title{font-weight:500}\n.sm-empty{padding:28px 8px;color:var(--text-muted)}\n.sm-chevron{\n width:12px;flex:none;border:0;background:none;padding:0;font:inherit;font-size:10px;\n color:var(--text-muted);cursor:pointer;line-height:1;\n}\n.sm-chevron:hover{color:var(--text)}\n.sm-cell--tree{display:flex;align-items:center;gap:6px;font-weight:500}\n.sm-cell--tree.sm-cell--parent{font-weight:600}\n.sm-rowmenu{\n border:0;background:none;padding:0 6px;font:inherit;color:var(--text-muted);cursor:pointer;\n border-radius:var(--radius-md);line-height:1;height:24px;\n}\n.sm-rowmenu:hover{background:var(--surface-inset);color:var(--text)}\n\n.sm-badge{\n display:inline-flex;align-items:center;height:20px;padding:0 8px;border-radius:var(--radius-md);\n font-size:var(--text-xs);font-weight:600;letter-spacing:.04em;white-space:nowrap;\n}\n.sm-savestate{\n display:inline-flex;align-items:center;gap:6px;padding:0 6px;\n font-size:var(--text-xs);font-weight:600;letter-spacing:.06em;text-transform:uppercase;\n}\n.sm-dot{width:5px;height:5px;border-radius:var(--radius-round);flex:none}\n\n/* \u2500\u2500 navigation bits \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n.sm-tabs{display:flex;gap:18px;padding:0 14px;height:36px;align-items:stretch;border-bottom:var(--border);flex:none}\n.sm-tab{\n display:flex;align-items:center;border:0;background:transparent;padding:0;font:inherit;\n cursor:pointer;color:var(--text-muted);border-bottom:2px solid transparent;margin-bottom:-1px;\n}\n.sm-tab:hover{color:var(--text)}\n.sm-tab[aria-selected=\"true\"]{color:var(--accent);font-weight:600;border-bottom-color:var(--accent)}\n.sm-seg{display:flex;gap:2px;padding:2px;background:var(--surface-inset);border-radius:var(--radius-md)}\n.sm-seg--stretch{width:100%}\n.sm-seg--disabled{opacity:.6}\n.sm-seg__opt:disabled{cursor:default}\n.sm-seg__opt{\n flex:1;padding:3px 10px;border:0;border-radius:var(--radius-sm);background:transparent;\n color:var(--text-muted);font:inherit;cursor:pointer;white-space:nowrap;\n}\n.sm-seg__opt:hover{color:var(--text)}\n.sm-seg__opt[aria-pressed=\"true\"]{background:var(--surface);color:var(--text);font-weight:600;box-shadow:var(--shadow-seg)}\n.sm-divider{height:1px;background:var(--line);flex:none}\n.sm-sectionlabel{display:flex;align-items:center;justify-content:space-between;gap:8px}\n.sm-sectionlabel--mono>.sm-label{\n font-family:var(--font-mono);font-weight:400;letter-spacing:var(--tracking-tag);\n}\n.sm-banner{\n display:flex;gap:14px;align-items:flex-start;padding:10px 14px;border-radius:var(--radius-md);\n font-size:var(--text-md);\n}\n.sm-banner--error{background:var(--danger-soft);color:var(--danger)}\n.sm-banner--ok{background:var(--status-published-bg);color:var(--status-published-fg)}\n.sm-banner--warn{background:var(--warn-soft);color:var(--warn)}\n\n/* \u2500\u2500 editor \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n.sm-editor{flex:1;min-height:0;display:grid;grid-template-columns:1fr var(--inspector-w)}\n.sm-canvas{display:flex;flex-direction:column;background:var(--bg);min-width:0;min-height:0;overflow:hidden}\n.sm-canvas__meta{\n display:flex;align-items:center;justify-content:space-between;gap:12px;padding:10px 20px;flex:none;\n font-family:var(--font-mono);font-size:var(--text-xs);letter-spacing:.04em;color:var(--text-muted);\n}\n.sm-canvas__scroll{flex:1;min-height:0;overflow:auto;padding:0 20px 20px}\n.sm-page{\n position:relative;background:var(--page-bg);border-radius:var(--radius-md);\n box-shadow:var(--shadow-page);min-height:100%;\n}\n.sm-inspector{\n display:flex;flex-direction:column;min-height:0;overflow:hidden;\n border-left:var(--border);background:var(--surface);\n}\n.sm-inspector__head{display:flex;align-items:center;justify-content:space-between;gap:8px;padding:12px 14px 10px;flex:none}\n.sm-inspector__body{display:flex;flex-direction:column;gap:14px;padding:14px;overflow:auto}\n.sm-blockglyph{width:14px;height:10px;border:1.5px solid var(--text-muted);border-radius:var(--radius-xs);flex:none}\n.sm-blocktag{\n position:absolute;top:0;left:0;background:var(--selection);color:#fff;\n font-family:var(--font-mono);font-size:var(--text-2xs);font-weight:700;\n letter-spacing:var(--tracking-tag);text-transform:uppercase;padding:3px 7px;\n border-radius:var(--radius-sm) 0 var(--radius-sm) 0;white-space:nowrap;\n}\n.sm-blocktoolbar{\n display:flex;gap:2px;padding:2px;background:#15181F;border-radius:var(--radius-md);\n box-shadow:var(--shadow-float);\n}\n.sm-plus{\n width:24px;height:24px;border-radius:var(--radius-round);border:0;background:var(--selection);\n color:#fff;display:grid;place-items:center;font-size:15px;line-height:1;cursor:pointer;\n box-shadow:0 2px 6px rgba(0,56,177,.35);padding:0;\n}\n.sm-plus:hover{background:var(--brand-500)}\n.sm-guide{height:1px;background:color-mix(in srgb,var(--selection) 35%,transparent)}\n.sm-tooltip{\n background:#15181F;color:#EEF0F5;font-size:var(--text-sm);padding:4px 8px;\n border-radius:var(--radius-sm);white-space:nowrap;\n}\n.sm-outline-row{\n display:flex;align-items:center;justify-content:space-between;gap:4px;\n height:var(--control-h);border-radius:var(--radius-md);padding:0 4px 0 10px;\n color:var(--text);font-weight:500;background:transparent;\n border:1px solid var(--line);\n}\n.sm-outline-row--clickable{cursor:pointer}\n.sm-outline-row--clickable:hover{background:var(--surface-inset)}\n/* Selected and locked rows are read by their fill, so they drop the hairline \u2014\n border-color, not border, so every row keeps the same 28px box. */\n.sm-outline-row--selected,.sm-outline-row--selected:hover{\n background:var(--nav-active-bg);color:var(--nav-active-fg);font-weight:600;border-color:transparent;\n}\n.sm-outline-row--empty{border:1px dashed var(--line-dashed);color:var(--text-muted);padding:0 10px}\n.sm-outline-row--locked{background:var(--surface-inset);color:var(--text-muted);padding:0 10px;border-color:transparent}\n.sm-outline-row__label{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;min-width:0}\n\n.sm-picker{\n width:300px;background:var(--surface);border:var(--border);border-radius:var(--radius-md);\n padding:6px;box-shadow:var(--shadow-pop);\n}\n.sm-picker__item{\n display:flex;gap:10px;align-items:center;width:100%;text-align:left;background:transparent;\n border:0;border-radius:var(--radius-md);padding:8px 8px;cursor:pointer;color:var(--text);font:inherit;\n}\n.sm-picker__item:hover{background:var(--nav-active-bg)}\n.sm-picker__icon{\n width:26px;height:26px;flex:none;border-radius:var(--radius-sm);background:var(--accent-soft);\n color:var(--accent);display:grid;place-items:center;font-size:13px;\n}\n.sm-dashed{\n border:1px dashed var(--line-dashed);border-radius:var(--radius-md);\n display:flex;align-items:center;justify-content:center;gap:8px;color:var(--text-muted);\n}\n";
2
2
  /** Where the theme toggle's choice is remembered, browser-side only. */
3
3
  export declare const THEME_KEY = "smoodly-theme";
4
4
  /** One <style> element and the theme boot, rendered once by AdminLayout. */
@@ -232,6 +232,19 @@ body:has(.sm-admin){margin:0}
232
232
  .sm-prefix{color:var(--text-muted);font-family:var(--font-mono);font-size:var(--text-sm);flex:none}
233
233
  .sm-error{color:var(--danger);font-size:var(--text-sm)}
234
234
 
235
+ .sm-group{display:flex;flex-direction:column;gap:10px;padding:2px 0 0 10px;border-left:2px solid var(--line)}
236
+ .sm-list{display:flex;flex-direction:column;gap:6px;align-items:stretch}
237
+ .sm-listrow{border:1px solid var(--line);border-radius:var(--radius-md);min-width:0}
238
+ .sm-listrow__head{display:flex;align-items:center;gap:2px;height:var(--control-h);padding:0 4px 0 4px}
239
+ .sm-listrow__toggle{
240
+ flex:1;min-width:0;display:flex;align-items:center;gap:6px;height:100%;padding:0 4px;
241
+ border:0;background:transparent;color:var(--text);font:inherit;font-size:var(--text-md);font-weight:500;
242
+ text-align:left;cursor:pointer;
243
+ }
244
+ .sm-listrow__toggle:hover{color:var(--accent)}
245
+ .sm-listrow__summary{overflow:hidden;white-space:nowrap;text-overflow:ellipsis}
246
+ .sm-listrow__body{display:flex;flex-direction:column;gap:10px;padding:8px 10px 10px;border-top:1px solid var(--line)}
247
+
235
248
  /* ── data ──────────────────────────────────────────────────────────── */
236
249
  .sm-table{display:flex;flex-direction:column;padding:0 16px 16px}
237
250
  .sm-table__head,.sm-row{display:grid;gap:12px;align-items:center;padding:0 8px}
@@ -2,9 +2,14 @@ export type FieldError = {
2
2
  field: string;
3
3
  message: string;
4
4
  };
5
- export declare function validateFields(values: Record<string, unknown>, descriptors: Record<string, {
5
+ type Desc = {
6
6
  type: string;
7
7
  optional?: unknown;
8
8
  options?: unknown;
9
9
  max?: unknown;
10
- }>): FieldError[];
10
+ min?: unknown;
11
+ fields?: unknown;
12
+ item?: unknown;
13
+ };
14
+ export declare function validateFields(values: Record<string, unknown>, descriptors: Record<string, Desc>): FieldError[];
15
+ export {};
@@ -1,19 +1,49 @@
1
+ const subfields = (d) => d.fields && typeof d.fields === "object" ? d.fields : {};
2
+ const itemOf = (d) => d.item && typeof d.item === "object" ? d.item : undefined;
1
3
  const isEmpty = (v) => v === undefined || v === null || v === "" || (Array.isArray(v) && v.length === 0);
2
- export function validateFields(values, descriptors) {
3
- const errors = [];
4
- for (const [field, d] of Object.entries(descriptors)) {
5
- const value = values[field];
6
- if (isEmpty(value)) {
7
- if (d.optional !== true)
8
- errors.push({ field, message: "Required." });
9
- continue;
10
- }
11
- if (d.type === "select" && Array.isArray(d.options) && !d.options.includes(value)) {
12
- errors.push({ field, message: `Must be one of: ${d.options.join(", ")}.` });
4
+ /** An object whose every subfield is optional — or is itself an object that
5
+ * may be absent — may be absent altogether. */
6
+ const mayBeAbsent = (d) => d.optional === true || (d.type === "object" && Object.values(subfields(d)).every(mayBeAbsent));
7
+ const plural = (n, word) => `${n} ${word}${n === 1 ? "" : "s"}`;
8
+ function validateValue(path, value, d, errors) {
9
+ if (isEmpty(value)) {
10
+ if (!mayBeAbsent(d))
11
+ errors.push({ field: path, message: "Required." });
12
+ return;
13
+ }
14
+ if (d.type === "list") {
15
+ if (!Array.isArray(value)) {
16
+ errors.push({ field: path, message: "Must be a list." });
17
+ return;
13
18
  }
14
- if (typeof d.max === "number" && typeof value === "string" && value.length > d.max) {
15
- errors.push({ field, message: `Must be at most ${d.max} characters.` });
19
+ if (typeof d.min === "number" && value.length < d.min)
20
+ errors.push({ field: path, message: `At least ${plural(d.min, "item")}.` });
21
+ if (typeof d.max === "number" && value.length > d.max)
22
+ errors.push({ field: path, message: `At most ${plural(d.max, "item")}.` });
23
+ const item = itemOf(d);
24
+ if (item)
25
+ value.forEach((v, i) => validateValue(`${path}.${i}`, v, item, errors));
26
+ return;
27
+ }
28
+ if (d.type === "object") {
29
+ if (typeof value !== "object" || Array.isArray(value)) {
30
+ errors.push({ field: path, message: "Must be an object." });
31
+ return;
16
32
  }
33
+ for (const [k, sub] of Object.entries(subfields(d)))
34
+ validateValue(`${path}.${k}`, value[k], sub, errors);
35
+ return;
17
36
  }
37
+ if (d.type === "select" && Array.isArray(d.options) && !d.options.includes(value)) {
38
+ errors.push({ field: path, message: `Must be one of: ${d.options.join(", ")}.` });
39
+ }
40
+ if (typeof d.max === "number" && typeof value === "string" && value.length > d.max) {
41
+ errors.push({ field: path, message: `Must be at most ${d.max} characters.` });
42
+ }
43
+ }
44
+ export function validateFields(values, descriptors) {
45
+ const errors = [];
46
+ for (const [field, d] of Object.entries(descriptors))
47
+ validateValue(field, values[field], d, errors);
18
48
  return errors;
19
49
  }
@@ -9,7 +9,10 @@ export type EntryOrder = "manual" | {
9
9
  export declare const DEFAULT_ORDER: EntryOrder;
10
10
  export declare const BUILT_IN_ORDER_FIELDS: readonly ["createdAt", "updatedAt"];
11
11
  export type CollectionSchema<F extends BuilderMap = BuilderMap> = {
12
- kind: "collection";
12
+ /** "shared" marks a code-declared singleton (spec 2026-09-07 decision
13
+ * 4): a shared item IS a collection with one entry, so both entry
14
+ * stores accept it in the same list and branch on this. */
15
+ kind: "collection" | "shared";
13
16
  name: string;
14
17
  title: string;
15
18
  titleField?: string;
package/dist/config.d.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import type { Descriptor, FieldBuilder } from "./fields.ts";
2
2
  import type { CollectionSchema } from "./collections.ts";
3
+ import type { SharedSchema } from "./shared.ts";
3
4
  type Registered = {
4
5
  schema: {
5
6
  kind: string;
@@ -19,6 +20,8 @@ export type SmoodlyConfig = {
19
20
  collections?: CollectionSchema<any>[];
20
21
  /** Page shapes and entry pages alike — everything that renders a URL. */
21
22
  pages?: Registered[];
23
+ /** Shared content: code-declared singletons (spec 2026-09-07). */
24
+ shared?: SharedSchema[];
22
25
  };
23
26
  locales: {
24
27
  default: string;