smoodly 0.0.9 → 0.0.10

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 (61) hide show
  1. package/README.md +94 -18
  2. package/dist/admin/editor/CanvasOverlay.d.ts +21 -0
  3. package/dist/admin/editor/CanvasOverlay.js +39 -0
  4. package/dist/admin/editor/EditorView.js +56 -237
  5. package/dist/admin/editor/InsertPicker.d.ts +18 -0
  6. package/dist/admin/editor/InsertPicker.js +18 -0
  7. package/dist/admin/editor/NoticePane.d.ts +4 -0
  8. package/dist/admin/editor/NoticePane.js +4 -0
  9. package/dist/admin/editor/Outline.d.ts +18 -0
  10. package/dist/admin/editor/Outline.js +12 -0
  11. package/dist/admin/editor/locale-url.d.ts +12 -0
  12. package/dist/admin/editor/locale-url.js +32 -0
  13. package/dist/admin/editor/overlay-geometry.d.ts +14 -0
  14. package/dist/admin/editor/overlay-geometry.js +27 -0
  15. package/dist/admin/editor/useCanvasBridge.d.ts +15 -0
  16. package/dist/admin/editor/useCanvasBridge.js +53 -0
  17. package/dist/admin/editor/usePageLocale.d.ts +23 -0
  18. package/dist/admin/editor/usePageLocale.js +88 -0
  19. package/dist/admin/forms/FieldWidget.js +4 -22
  20. package/dist/admin/forms/RichtextToolbar.d.ts +20 -0
  21. package/dist/admin/forms/RichtextToolbar.js +37 -0
  22. package/dist/admin/forms/RichtextWidget.d.ts +5 -0
  23. package/dist/admin/forms/RichtextWidget.js +123 -0
  24. package/dist/admin/forms/richtext-extensions.d.ts +5 -0
  25. package/dist/admin/forms/richtext-extensions.js +74 -0
  26. package/dist/admin/forms/richtext-paste.d.ts +22 -0
  27. package/dist/admin/forms/richtext-paste.js +59 -0
  28. package/dist/admin/index.d.ts +0 -1
  29. package/dist/admin/index.js +0 -1
  30. package/dist/admin/next/create-admin.d.ts +3 -7
  31. package/dist/admin/next/create-admin.js +14 -5
  32. package/dist/admin/richtext-doc.d.ts +7 -0
  33. package/dist/admin/richtext-doc.js +18 -0
  34. package/dist/admin/shell/AdminApp.d.ts +7 -2
  35. package/dist/admin/shell/AdminApp.js +12 -6
  36. package/dist/admin/shell/EntriesList.js +3 -2
  37. package/dist/admin/shell/EntryForm.js +8 -24
  38. package/dist/admin/shell/ListView.js +3 -1
  39. package/dist/admin/shell/PagesList.js +3 -2
  40. package/dist/admin/shell/RouteLink.d.ts +4 -0
  41. package/dist/admin/shell/RouteLink.js +12 -0
  42. package/dist/admin/shell/SharedForm.js +3 -21
  43. package/dist/admin/shell/router.d.ts +37 -0
  44. package/dist/admin/shell/router.js +84 -0
  45. package/dist/admin/shell/session.js +3 -1
  46. package/dist/admin/ui/primitives.d.ts +2 -1
  47. package/dist/admin/ui/primitives.js +5 -3
  48. package/dist/admin/ui/theme.d.ts +1 -1
  49. package/dist/admin/ui/theme.js +27 -1
  50. package/dist/admin/validate.js +10 -0
  51. package/dist/collections.d.ts +0 -14
  52. package/dist/collections.js +0 -10
  53. package/dist/fields.d.ts +2 -1
  54. package/dist/fields.js +2 -1
  55. package/dist/index.d.ts +4 -2
  56. package/dist/index.js +3 -1
  57. package/dist/toolset.d.ts +30 -0
  58. package/dist/toolset.js +49 -0
  59. package/package.json +11 -1
  60. package/dist/admin/richtext.d.ts +0 -16
  61. package/dist/admin/richtext.js +0 -98
@@ -0,0 +1,7 @@
1
+ /** True when any text node in the tree has non-whitespace text. */
2
+ export declare function hasText(doc: unknown): boolean;
3
+ /** The document as plain JSON. ProseMirror builds node attrs with a null
4
+ * prototype, which React's server-action encoding refuses to serialize
5
+ * (it becomes a temporary reference and the server's structuredClone
6
+ * throws); a JSON round trip gives every object the plain prototype. */
7
+ export declare function plainDoc<T>(doc: T): T;
@@ -0,0 +1,18 @@
1
+ // Shape helpers over a TipTap document, used by the validator (an empty
2
+ // document is missing, spec 2026-09-08 §3). Pure, no TipTap import.
3
+ /** True when any text node in the tree has non-whitespace text. */
4
+ export function hasText(doc) {
5
+ if (!doc || typeof doc !== "object")
6
+ return false;
7
+ const n = doc;
8
+ if (typeof n.text === "string" && n.text.trim() !== "")
9
+ return true;
10
+ return Array.isArray(n.content) && n.content.some(hasText);
11
+ }
12
+ /** The document as plain JSON. ProseMirror builds node attrs with a null
13
+ * prototype, which React's server-action encoding refuses to serialize
14
+ * (it becomes a temporary reference and the server's structuredClone
15
+ * throws); a JSON round trip gives every object the plain prototype. */
16
+ export function plainDoc(doc) {
17
+ return JSON.parse(JSON.stringify(doc));
18
+ }
@@ -1,9 +1,14 @@
1
1
  import type { AdminOpAction } from "../ops.ts";
2
2
  import type { AdminRegistry } from "../serialize.ts";
3
3
  import { type AuthConfig } from "./session.ts";
4
- export declare function AdminApp({ registry, segments, op, auth }: {
4
+ export declare function Nav({ registry, active, email, onSignOut }: {
5
+ registry: AdminRegistry;
6
+ active: string;
7
+ email?: string;
8
+ onSignOut: () => void;
9
+ }): import("react").JSX.Element;
10
+ export declare function AdminApp({ registry, op, auth }: {
5
11
  registry: AdminRegistry;
6
- segments: string[];
7
12
  op: AdminOpAction;
8
13
  auth: AuthConfig;
9
14
  }): import("react").JSX.Element;
@@ -3,7 +3,10 @@
3
3
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
4
4
  // The admin shell: registry-derived nav (never separately configured),
5
5
  // list views in the main area, editor/entry views mounted by segment.
6
- // Internal routing = the catch-all's segments; plain <a> navigation.
6
+ // Internal routing is the shell's own (shell/router.ts): the route is read
7
+ // from the URL on mount, then pushState moves between views and the shell
8
+ // never reloads (spec 2026-09-08 client routing). The shell mounts from the
9
+ // admin LAYOUT (next/create-admin.tsx), never from the catch-all page.
7
10
  //
8
11
  // Layout is the design system's three columns: a 200px nav, then the main
9
12
  // column whose views own their own 48px top bar (the editor adds the third,
@@ -21,20 +24,23 @@ import { LoginView } from "./LoginView.js";
21
24
  import { useAdminSession } from "./session.js";
22
25
  import { Logo, ThemeToggle } from "../ui/primitives.js";
23
26
  import { BASE } from "./base.js";
27
+ import { RouteLink } from "./RouteLink.js";
28
+ import { useRoute } from "./router.js";
24
29
  import { ErrorPane } from "./ErrorPane.js";
25
30
  /** Nav items the registry can't produce yet: shown, disabled, so the shape
26
31
  * of the product is visible — the same set the design system's SideNav has. */
27
32
  const PLANNED = ["Media", "Settings"];
28
- function Nav({ registry, active, email, onSignOut }) {
29
- const item = (href, label, key) => (_jsx("a", { className: "sm-nav-item", href: href ?? undefined, "aria-disabled": href ? undefined : true, "aria-current": href && key === active ? "page" : undefined, children: label }, key));
33
+ export function Nav({ registry, active, email, onSignOut }) {
34
+ const item = (href, label, key) => href ? (_jsx(RouteLink, { className: "sm-nav-item", href: href, "aria-current": key === active ? "page" : undefined, children: label }, key)) : (_jsx("a", { className: "sm-nav-item", "aria-disabled": true, children: label }, key));
30
35
  const name = email?.split("@")[0] ?? "Editor";
31
36
  const initials = name.slice(0, 2).toUpperCase();
32
37
  return (_jsxs("nav", { className: "sm-nav", children: [_jsx(Logo, {}), _jsxs("div", { className: "sm-nav__list", children: [item(`${BASE}/pages`, "Pages", "pages"), registry.collections.map((c) => item(`${BASE}/${c.name}`, c.title, c.name)), registry.shared.length > 0 && item(`${BASE}/shared`, "Shared content", "shared"), PLANNED.map((label) => item(null, label, label))] }), _jsxs("div", { className: "sm-nav__foot", children: [_jsx(ThemeToggle, {}), _jsxs("div", { className: "sm-nav__user", children: [_jsx("span", { className: "sm-avatar", children: initials }), _jsxs("span", { style: { display: "flex", flexDirection: "column", minWidth: 0 }, children: [_jsx("span", { style: { fontWeight: 500, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }, children: name }), _jsx("span", { style: { fontSize: "var(--text-sm)", color: "var(--text-muted)" }, children: "Editor" })] })] }), _jsx("button", { className: "sm-nav-item", onClick: onSignOut, style: { color: "var(--text-muted)" }, children: "Sign out" })] })] }));
33
38
  }
34
- export function AdminApp({ registry, segments, op, auth }) {
39
+ export function AdminApp({ registry, op, auth }) {
35
40
  const session = useAdminSession(auth);
36
41
  const [ops] = useState(() => makeClientOps(op, session.token));
37
- const [head = "pages", tail] = segments.length ? segments : ["pages"];
42
+ const route = useRoute();
43
+ const [head = "pages", tail] = route.segments.length ? route.segments : ["pages"];
38
44
  const isCollection = registry.collections.some((c) => c.name === head);
39
45
  const email = session.state.status === "signed_in" ? session.state.session.user.email : undefined;
40
46
  if (session.state.status === "loading") {
@@ -57,5 +63,5 @@ export function AdminApp({ registry, segments, op, auth }) {
57
63
  main = _jsx(SharedForm, { ops: ops, registry: registry, name: decodeURIComponent(tail) });
58
64
  else
59
65
  main = _jsx(ErrorPane, { message: "Not found." });
60
- return (_jsxs("div", { className: "sm-shell", children: [_jsx(Nav, { registry: registry, active: head, email: email, onSignOut: () => session.client.auth.signOut() }), _jsx("main", { className: "sm-main", children: main })] }));
66
+ return (_jsxs("div", { className: "sm-shell", children: [_jsx(Nav, { registry: registry, active: head, email: email, onSignOut: () => session.client.auth.signOut() }), _jsx("main", { className: "sm-main", children: main }, `${head}/${tail ?? ""}`)] }));
61
67
  }
@@ -10,6 +10,7 @@ import { entryRows, entrySourceLocale } from "./entries-list.js";
10
10
  import { Button, Segmented, StatusBadge } from "../ui/primitives.js";
11
11
  import { relativeTime } from "../ui/format.js";
12
12
  import { BASE } from "./base.js";
13
+ import { navigate } from "./router.js";
13
14
  import { ErrorPane } from "./ErrorPane.js";
14
15
  const LOCALE_KEY = "smoodly.entries.locale";
15
16
  /** The selected locale survives a reload within the session; a stored
@@ -49,7 +50,7 @@ export function EntriesList({ ops, registry, collection }) {
49
50
  if (error)
50
51
  return _jsx(ErrorPane, { message: error });
51
52
  const open = (id) => {
52
- window.location.href = `${BASE}/${collection}/${encodeURIComponent(id)}?locale=${encodeURIComponent(locale)}`;
53
+ navigate(`${BASE}/${collection}/${encodeURIComponent(id)}?locale=${encodeURIComponent(locale)}`);
53
54
  };
54
55
  const addLocale = async (row) => {
55
56
  const record = records?.find((r) => r.id === row.id);
@@ -72,7 +73,7 @@ export function EntriesList({ ops, registry, collection }) {
72
73
  open(row.id);
73
74
  };
74
75
  const rows = records ? entryRows(records, meta, registry, locale) : [];
75
- return (_jsx(ListView, { title: meta.title, count: records?.length, actions: _jsxs(_Fragment, { children: [multilingual && _jsx(Segmented, { options: registry.locales.supported, value: locale, onChange: pickLocale }), _jsx(Button, { onClick: () => { window.location.href = `${BASE}/${collection}/new?locale=${encodeURIComponent(locale)}`; }, children: "+ New entry" })] }), columns: [
76
+ return (_jsx(ListView, { title: meta.title, count: records?.length, actions: _jsxs(_Fragment, { children: [multilingual && _jsx(Segmented, { options: registry.locales.supported, value: locale, onChange: pickLocale }), _jsx(Button, { onClick: () => { navigate(`${BASE}/${collection}/new?locale=${encodeURIComponent(locale)}`); }, children: "+ New entry" })] }), columns: [
76
77
  { label: "Title", width: "minmax(160px,1fr)" },
77
78
  { label: "Updated", width: "minmax(110px,160px)" },
78
79
  { label: "Status", width: "120px" },
@@ -15,29 +15,9 @@ import { LocaleSwitcher } from "../ui/LocaleSwitcher.js";
15
15
  import { Banner, Breadcrumb, Button, Divider, SectionLabel, Segmented, TopBar } from "../ui/primitives.js";
16
16
  import { entrySourceLocale, entryTitle, localizedFieldKeys } from "./entries-list.js";
17
17
  import { BASE } from "./base.js";
18
+ import { localeFromUrl, writeLocaleToUrl } from "../editor/locale-url.js";
19
+ import { navigate, useLeaveGuard } from "./router.js";
18
20
  const STATUSES = ["Draft", "Published"];
19
- /** The locale to edit: from the URL (the list links here with ?locale=), else the default. */
20
- function localeFromUrl(registry) {
21
- try {
22
- const l = new URLSearchParams(window.location.search).get("locale");
23
- if (l && registry.locales.supported.includes(l))
24
- return l;
25
- }
26
- catch {
27
- // no window — the default is fine
28
- }
29
- return registry.locales.default;
30
- }
31
- function writeLocaleToUrl(next) {
32
- try {
33
- const u = new URL(window.location.href);
34
- u.searchParams.set("locale", next);
35
- window.history.replaceState(null, "", u);
36
- }
37
- catch {
38
- // the URL is cosmetic here
39
- }
40
- }
41
21
  export function EntryForm({ ops, registry, collection, id }) {
42
22
  const meta = registry.collections.find((c) => c.name === collection);
43
23
  const isNew = id === "new";
@@ -52,6 +32,9 @@ export function EntryForm({ ops, registry, collection, id }) {
52
32
  const [adding, setAdding] = useState(false);
53
33
  const [reloadKey, setReloadKey] = useState(0);
54
34
  const [dirty, setDirty] = useState(false);
35
+ // A nav click, the Back arrow, a list row: the router asks before
36
+ // leaving unsaved changes; reload and close ask through beforeunload.
37
+ useLeaveGuard(dirty);
55
38
  const refTargets = useMemo(() => {
56
39
  const out = {};
57
40
  for (const [key, d] of Object.entries(meta?.fields ?? {})) {
@@ -123,7 +106,8 @@ export function EntryForm({ ops, registry, collection, id }) {
123
106
  const r = await ops.entries.create(collection, { locale, fields });
124
107
  if (applyResult(r, "Created.") && r.ok) {
125
108
  setDirty(false);
126
- window.location.href = `${BASE}/${collection}/${encodeURIComponent(r.data.id)}?locale=${encodeURIComponent(locale)}`;
109
+ // force: the create just landed, but React has not flushed setDirty(false) yet.
110
+ navigate(`${BASE}/${collection}/${encodeURIComponent(r.data.id)}?locale=${encodeURIComponent(locale)}`, { force: true });
127
111
  }
128
112
  }
129
113
  else {
@@ -212,7 +196,7 @@ export function EntryForm({ ops, registry, collection, id }) {
212
196
  return;
213
197
  const r = await ops.entries.delete(collection, id);
214
198
  if (r.ok)
215
- window.location.href = `${BASE}/${collection}`;
199
+ navigate(`${BASE}/${collection}`, { force: true });
216
200
  else
217
201
  setBanner({ tone: "error", text: r.message });
218
202
  };
@@ -2,11 +2,13 @@
2
2
  "use client";
3
3
  import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
4
4
  import { TopBar } from "../ui/primitives.js";
5
+ import { navigate } from "./router.js";
5
6
  export function ListView({ title, count, actions, columns, rows, empty }) {
6
7
  const grid = { gridTemplateColumns: columns.map((c) => c.width).join(" ") };
7
8
  return (_jsxs(_Fragment, { children: [_jsx(TopBar, { left: _jsxs(_Fragment, { children: [_jsx("span", { className: "sm-title", children: title }), count !== undefined && _jsx("span", { className: "sm-count", children: count })] }), right: actions }), _jsx("div", { style: { flex: 1, minHeight: 0, overflow: "auto" }, children: _jsxs("div", { className: "sm-table", children: [_jsx("div", { className: "sm-table__head", style: grid, children: columns.map((c) => _jsx("span", { children: c.label }, c.label)) }), _jsxs("div", { className: "sm-table__body", children: [rows.map((r) => {
8
9
  const clickable = Boolean(r.href || r.onClick);
9
- const go = r.onClick ?? (r.href ? () => { window.location.href = r.href; } : undefined);
10
+ const href = r.href;
11
+ const go = r.onClick ?? (href ? () => { navigate(href); } : undefined);
10
12
  return (_jsx("div", { style: grid, role: clickable ? "button" : undefined, tabIndex: clickable ? 0 : undefined, className: clickable ? "sm-row sm-row--clickable" : "sm-row", onClick: go, onKeyDown: (e) => { if (go && (e.key === "Enter" || e.key === " ")) {
11
13
  e.preventDefault();
12
14
  go();
@@ -12,6 +12,7 @@ import { ListView } from "./ListView.js";
12
12
  import { openTemplates, pagesTreeRows, sourceLocaleOf } from "./pages-tree.js";
13
13
  import { Button, Segmented, StatusBadge } from "../ui/primitives.js";
14
14
  import { BASE } from "./base.js";
15
+ import { navigate } from "./router.js";
15
16
  import { ErrorPane } from "./ErrorPane.js";
16
17
  const LOCALE_KEY = "smoodly.pages.locale";
17
18
  /** The selected locale survives a reload within the session; a stored
@@ -56,7 +57,7 @@ export function PagesList({ ops, registry }) {
56
57
  next.add(id); return next; });
57
58
  /** The editor opens in the locale selected here. */
58
59
  const openEditor = (id) => {
59
- window.location.href = `${BASE}/pages/${encodeURIComponent(id)}?locale=${encodeURIComponent(locale)}`;
60
+ navigate(`${BASE}/pages/${encodeURIComponent(id)}?locale=${encodeURIComponent(locale)}`);
60
61
  };
61
62
  /** Run an op; on success reload the list (and maybe navigate), else show the op's message. */
62
63
  const run = async (work, then) => {
@@ -167,7 +168,7 @@ export function PagesList({ ops, registry }) {
167
168
  if (!row.present)
168
169
  addLocale(row);
169
170
  else if (row.kind === "mount" && row.collection)
170
- window.location.href = `${BASE}/${row.collection}`;
171
+ navigate(`${BASE}/${row.collection}`);
171
172
  else if (row.template !== null)
172
173
  openEditor(row.id);
173
174
  else if (row.hasChildren)
@@ -0,0 +1,4 @@
1
+ import type { AnchorHTMLAttributes } from "react";
2
+ export declare function RouteLink({ href, onClick, ...rest }: AnchorHTMLAttributes<HTMLAnchorElement> & {
3
+ href: string;
4
+ }): import("react").JSX.Element;
@@ -0,0 +1,12 @@
1
+ "use client";
2
+ import { jsx as _jsx } from "react/jsx-runtime";
3
+ import { isPlainLeftClick, navigate } from "./router.js";
4
+ export function RouteLink({ href, onClick, ...rest }) {
5
+ return (_jsx("a", { href: href, ...rest, onClick: (e) => {
6
+ onClick?.(e);
7
+ if (isPlainLeftClick(e, rest.target)) {
8
+ e.preventDefault();
9
+ navigate(href);
10
+ }
11
+ } }));
12
+ }
@@ -15,28 +15,9 @@ import { LocaleSwitcher } from "../ui/LocaleSwitcher.js";
15
15
  import { Banner, Breadcrumb, Button, Divider, SectionLabel, Segmented, TopBar } from "../ui/primitives.js";
16
16
  import { entryTitle } from "./entries-list.js";
17
17
  import { BASE } from "./base.js";
18
+ import { localeFromUrl, writeLocaleToUrl } from "../editor/locale-url.js";
19
+ import { useLeaveGuard } from "./router.js";
18
20
  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
21
  export function SharedForm({ ops, registry, name }) {
41
22
  const item = registry.shared.find((s) => s.name === name);
42
23
  const multilingual = registry.locales.supported.length > 1;
@@ -48,6 +29,7 @@ export function SharedForm({ ops, registry, name }) {
48
29
  const [banner, setBanner] = useState(null);
49
30
  const [fieldErrors, setFieldErrors] = useState({});
50
31
  const [dirty, setDirty] = useState(false);
32
+ useLeaveGuard(dirty);
51
33
  const refTargets = useMemo(() => {
52
34
  const out = {};
53
35
  for (const [key, d] of Object.entries(item?.fields ?? {})) {
@@ -0,0 +1,37 @@
1
+ export type Route = {
2
+ segments: string[];
3
+ search: string;
4
+ };
5
+ /** The segments under `base`: "/admin/articles/x" → ["articles", "x"];
6
+ * the mount itself, or a pathname outside it, → []. Segments stay as
7
+ * written; the shell decodes the ones it uses, as it does for the
8
+ * catch-all's params. */
9
+ export declare function segmentsOf(pathname: string, base?: string): string[];
10
+ /** A click the browser would turn into a same-tab navigation: left
11
+ * button, no modifier, no other target, not already handled. */
12
+ export declare function isPlainLeftClick(e: {
13
+ button: number;
14
+ metaKey: boolean;
15
+ ctrlKey: boolean;
16
+ shiftKey: boolean;
17
+ altKey: boolean;
18
+ defaultPrevented: boolean;
19
+ }, target?: string | null): boolean;
20
+ export declare const LEAVE_MESSAGE = "Discard unsaved changes?";
21
+ /** Leaving is allowed when no guard is dirty, else when the editor
22
+ * confirms — asked once, however many guards are dirty. */
23
+ export declare function mayLeave(guards: Iterable<() => boolean>, confirm: (message: string) => boolean): boolean;
24
+ /** Move to `href` in place; true when the move happened. A push asks the
25
+ * leave guard; a replace never leaves the view, so it does not ask.
26
+ * `force` skips the guard for a view that has just saved or deleted its
27
+ * record — React has not flushed its setDirty(false) yet. */
28
+ export declare function navigate(href: string, opts?: {
29
+ replace?: boolean;
30
+ force?: boolean;
31
+ }): boolean;
32
+ /** The current route: empty until mount, then the URL, re-read on
33
+ * popstate and after navigate(). */
34
+ export declare function useRoute(): Route;
35
+ /** Register the calling view as dirty: navigate() asks before leaving,
36
+ * and beforeunload covers reload, closing the tab and a real link out. */
37
+ export declare function useLeaveGuard(dirty: boolean): void;
@@ -0,0 +1,84 @@
1
+ "use client";
2
+ // packages/smoodly/src/admin/shell/router.ts
3
+ // The admin's own router (spec 2026-09-08 client routing): the route is
4
+ // the URL under BASE, held in React state and moved with pushState, so
5
+ // the shell stays mounted and a click is a state change, not a document
6
+ // load. The URL is the only source: the shell's first render is the
7
+ // session gate, so no view exists before the mount effect has read it.
8
+ // Next's router is not involved — native pushState updates its
9
+ // usePathname without a refetch — and the admin core stays
10
+ // framework-agnostic.
11
+ import { useEffect, useState } from "react";
12
+ import { BASE } from "./base.js";
13
+ /** The segments under `base`: "/admin/articles/x" → ["articles", "x"];
14
+ * the mount itself, or a pathname outside it, → []. Segments stay as
15
+ * written; the shell decodes the ones it uses, as it does for the
16
+ * catch-all's params. */
17
+ export function segmentsOf(pathname, base = BASE) {
18
+ if (pathname !== base && !pathname.startsWith(`${base}/`))
19
+ return [];
20
+ return pathname.slice(base.length).split("/").filter((s) => s !== "");
21
+ }
22
+ /** A click the browser would turn into a same-tab navigation: left
23
+ * button, no modifier, no other target, not already handled. */
24
+ export function isPlainLeftClick(e, target) {
25
+ if (e.defaultPrevented || e.button !== 0)
26
+ return false;
27
+ if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey)
28
+ return false;
29
+ return !target || target === "_self";
30
+ }
31
+ export const LEAVE_MESSAGE = "Discard unsaved changes?";
32
+ /** Leaving is allowed when no guard is dirty, else when the editor
33
+ * confirms — asked once, however many guards are dirty. */
34
+ export function mayLeave(guards, confirm) {
35
+ for (const dirty of guards)
36
+ if (dirty())
37
+ return confirm(LEAVE_MESSAGE);
38
+ return true;
39
+ }
40
+ // ── the live router ──────────────────────────────────────────────────
41
+ const guards = new Set();
42
+ const listeners = new Set();
43
+ function currentRoute() {
44
+ return { segments: segmentsOf(window.location.pathname), search: window.location.search };
45
+ }
46
+ const sameRoute = (a, b) => a.search === b.search && a.segments.length === b.segments.length && a.segments.every((s, i) => s === b.segments[i]);
47
+ /** Move to `href` in place; true when the move happened. A push asks the
48
+ * leave guard; a replace never leaves the view, so it does not ask.
49
+ * `force` skips the guard for a view that has just saved or deleted its
50
+ * record — React has not flushed its setDirty(false) yet. */
51
+ export function navigate(href, opts = {}) {
52
+ if (!opts.replace && !opts.force && !mayLeave(guards, (m) => window.confirm(m)))
53
+ return false;
54
+ window.history[opts.replace ? "replaceState" : "pushState"](null, "", href);
55
+ for (const listener of listeners)
56
+ listener();
57
+ return true;
58
+ }
59
+ /** The current route: empty until mount, then the URL, re-read on
60
+ * popstate and after navigate(). */
61
+ export function useRoute() {
62
+ const [route, setRoute] = useState({ segments: [], search: "" });
63
+ useEffect(() => {
64
+ const sync = () => setRoute((prev) => { const next = currentRoute(); return sameRoute(prev, next) ? prev : next; });
65
+ sync();
66
+ listeners.add(sync);
67
+ window.addEventListener("popstate", sync);
68
+ return () => { listeners.delete(sync); window.removeEventListener("popstate", sync); };
69
+ }, []);
70
+ return route;
71
+ }
72
+ /** Register the calling view as dirty: navigate() asks before leaving,
73
+ * and beforeunload covers reload, closing the tab and a real link out. */
74
+ export function useLeaveGuard(dirty) {
75
+ useEffect(() => {
76
+ if (!dirty)
77
+ return;
78
+ const guard = () => true;
79
+ guards.add(guard);
80
+ const onBeforeUnload = (e) => { e.preventDefault(); e.returnValue = ""; };
81
+ window.addEventListener("beforeunload", onBeforeUnload);
82
+ return () => { guards.delete(guard); window.removeEventListener("beforeunload", onBeforeUnload); };
83
+ }, [dirty]);
84
+ }
@@ -8,7 +8,9 @@ import { createClient } from "@supabase/supabase-js";
8
8
  export function useAdminSession(auth) {
9
9
  const browser = typeof window !== "undefined";
10
10
  const [client] = useState(() => createClient(auth.url, auth.key, {
11
- auth: { autoRefreshToken: browser, persistSession: browser, detectSessionInUrl: browser },
11
+ // Own storage key: a self-hoster's own supabase-js client in the same
12
+ // browser must never share the admin's session slot (DESIGN.md §1).
13
+ auth: { autoRefreshToken: browser, persistSession: browser, detectSessionInUrl: browser, storageKey: "smoodly-auth" },
12
14
  }));
13
15
  const [state, setState] = useState({ status: "loading" });
14
16
  const current = useRef(null);
@@ -44,7 +44,8 @@ export declare function TopBar({ left, right }: {
44
44
  left: ReactNode;
45
45
  right?: ReactNode;
46
46
  }): import("react").JSX.Element;
47
- /** `items` are crumbs; every one but the last is a link when given an href. */
47
+ /** `items` are crumbs; every one but the last is a link when given an href.
48
+ * Links route in place: the shell never reloads for a crumb. */
48
49
  export declare function Breadcrumb({ items, backHref }: {
49
50
  items: {
50
51
  label: string;
@@ -7,6 +7,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
7
7
  import { Fragment } from "react";
8
8
  import { Moon, Sun } from "lucide-react";
9
9
  import { THEME_KEY } from "./theme.js";
10
+ import { RouteLink } from "../shell/RouteLink.js";
10
11
  /** Lucide at the design system's size and stroke. */
11
12
  const ICON = { size: 14, strokeWidth: 1.5, "aria-hidden": true };
12
13
  /* ── brand ───────────────────────────────────────────────────────────── */
@@ -61,14 +62,15 @@ export function Field({ label, counter, hint, error, asDiv, children, style, cla
61
62
  export function TopBar({ left, right }) {
62
63
  return (_jsxs("header", { className: "sm-topbar", children: [_jsx("div", { className: "sm-topbar__left", children: left }), _jsx("div", { className: "sm-topbar__right", children: right })] }));
63
64
  }
64
- /** `items` are crumbs; every one but the last is a link when given an href. */
65
+ /** `items` are crumbs; every one but the last is a link when given an href.
66
+ * Links route in place: the shell never reloads for a crumb. */
65
67
  export function Breadcrumb({ items, backHref }) {
66
- return (_jsxs("div", { className: "sm-crumbs", children: [backHref && _jsx("a", { className: "sm-back", href: backHref, "aria-label": "Back", children: "\u2190" }), items.map((it, i) => {
68
+ return (_jsxs("div", { className: "sm-crumbs", children: [backHref && _jsx(RouteLink, { className: "sm-back", href: backHref, "aria-label": "Back", children: "\u2190" }), items.map((it, i) => {
67
69
  const last = i === items.length - 1;
68
70
  return (_jsxs(Fragment, { children: [i > 0 && _jsx("span", { className: "sm-crumbs__sep", children: "/" }), last
69
71
  ? _jsx("span", { className: "sm-crumbs__here", children: it.label })
70
72
  : it.href
71
- ? _jsx("a", { href: it.href, style: { color: "inherit" }, children: it.label })
73
+ ? _jsx(RouteLink, { href: it.href, style: { color: "inherit" }, children: it.label })
72
74
  : _jsx("span", { children: it.label })] }, i));
73
75
  })] }));
74
76
  }
@@ -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.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";
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:380px;\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/* \u2500\u2500 richtext \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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-richtext{display:flex;flex-direction:column;gap:6px;min-width:0}\n.sm-richtext__bar{display:flex;flex-wrap:wrap;align-items:center;gap:2px}\n.sm-richtext__sep{width:1px;height:16px;margin:0 4px;background:var(--line)}\n.sm-richtext__bar .sm-iconbtn[aria-pressed=\"true\"]{background:var(--press);color:var(--text)}\n.sm-richtext__link{display:flex;align-items:center;gap:6px}\n.sm-richtext__content{\n min-height:110px;padding:8px 10px;border:1.5px solid transparent;border-radius:var(--radius-md);\n background:var(--surface-inset);color:var(--text);font-size:var(--text-md);line-height:1.5;outline:0;\n}\n.sm-richtext__content:hover{background:var(--control-hover)}\n.sm-richtext__content:focus{background:var(--input-bg);border-color:var(--selection);box-shadow:var(--focus-ring)}\n.sm-richtext__content>*+*{margin-top:.5em}\n.sm-richtext__content p,.sm-richtext__content h1,.sm-richtext__content h2,.sm-richtext__content h3,\n.sm-richtext__content h4,.sm-richtext__content h5,.sm-richtext__content h6,\n.sm-richtext__content ul,.sm-richtext__content ol,.sm-richtext__content blockquote{margin:0}\n.sm-richtext__content h1,.sm-richtext__content h2,.sm-richtext__content h3,\n.sm-richtext__content h4,.sm-richtext__content h5,.sm-richtext__content h6{font-weight:600;line-height:1.25}\n.sm-richtext__content h1{font-size:var(--text-3xl)}\n.sm-richtext__content h2{font-size:var(--text-2xl)}\n.sm-richtext__content h3{font-size:var(--text-xl)}\n.sm-richtext__content h4,.sm-richtext__content h5,.sm-richtext__content h6{font-size:var(--text-lg)}\n.sm-richtext__content ul,.sm-richtext__content ol{padding-left:18px}\n.sm-richtext__content li+li{margin-top:.25em}\n.sm-richtext__content blockquote{padding-left:10px;border-left:2px solid var(--line);color:var(--text-muted)}\n.sm-richtext__content a{color:var(--accent);text-decoration:underline}\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. */