smoodly 0.0.8 → 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.
- package/README.md +228 -18
- package/dist/admin/editor/CanvasOverlay.d.ts +21 -0
- package/dist/admin/editor/CanvasOverlay.js +39 -0
- package/dist/admin/editor/EditorView.js +56 -237
- package/dist/admin/editor/InsertPicker.d.ts +18 -0
- package/dist/admin/editor/InsertPicker.js +18 -0
- package/dist/admin/editor/NoticePane.d.ts +4 -0
- package/dist/admin/editor/NoticePane.js +4 -0
- package/dist/admin/editor/Outline.d.ts +18 -0
- package/dist/admin/editor/Outline.js +12 -0
- package/dist/admin/editor/locale-url.d.ts +12 -0
- package/dist/admin/editor/locale-url.js +32 -0
- package/dist/admin/editor/overlay-geometry.d.ts +14 -0
- package/dist/admin/editor/overlay-geometry.js +27 -0
- package/dist/admin/editor/useCanvasBridge.d.ts +15 -0
- package/dist/admin/editor/useCanvasBridge.js +53 -0
- package/dist/admin/editor/usePageLocale.d.ts +23 -0
- package/dist/admin/editor/usePageLocale.js +88 -0
- package/dist/admin/forms/FieldWidget.d.ts +7 -3
- package/dist/admin/forms/FieldWidget.js +68 -8
- package/dist/admin/forms/RichtextToolbar.d.ts +20 -0
- package/dist/admin/forms/RichtextToolbar.js +37 -0
- package/dist/admin/forms/RichtextWidget.d.ts +5 -0
- package/dist/admin/forms/RichtextWidget.js +123 -0
- package/dist/admin/forms/list.d.ts +18 -0
- package/dist/admin/forms/list.js +81 -0
- package/dist/admin/forms/richtext-extensions.d.ts +5 -0
- package/dist/admin/forms/richtext-extensions.js +74 -0
- package/dist/admin/forms/richtext-paste.d.ts +22 -0
- package/dist/admin/forms/richtext-paste.js +59 -0
- package/dist/admin/index.d.ts +0 -1
- package/dist/admin/index.js +0 -1
- package/dist/admin/next/create-admin.d.ts +3 -7
- package/dist/admin/next/create-admin.js +14 -5
- package/dist/admin/richtext-doc.d.ts +7 -0
- package/dist/admin/richtext-doc.js +18 -0
- package/dist/admin/serialize.d.ts +4 -0
- package/dist/admin/serialize.js +3 -0
- package/dist/admin/shell/AdminApp.d.ts +7 -2
- package/dist/admin/shell/AdminApp.js +12 -6
- package/dist/admin/shell/EntriesList.js +3 -2
- package/dist/admin/shell/EntryForm.js +9 -25
- package/dist/admin/shell/ListView.js +3 -1
- package/dist/admin/shell/PagesList.js +3 -2
- package/dist/admin/shell/RouteLink.d.ts +4 -0
- package/dist/admin/shell/RouteLink.js +12 -0
- package/dist/admin/shell/SharedForm.js +4 -22
- package/dist/admin/shell/router.d.ts +37 -0
- package/dist/admin/shell/router.js +84 -0
- package/dist/admin/shell/session.js +3 -1
- package/dist/admin/ui/primitives.d.ts +2 -1
- package/dist/admin/ui/primitives.js +5 -3
- package/dist/admin/ui/theme.d.ts +1 -1
- package/dist/admin/ui/theme.js +40 -1
- package/dist/admin/validate.d.ts +7 -2
- package/dist/admin/validate.js +53 -13
- package/dist/collections.d.ts +0 -14
- package/dist/collections.js +0 -10
- package/dist/entry-store.js +7 -1
- package/dist/fields.d.ts +18 -2
- package/dist/fields.js +51 -5
- package/dist/index.d.ts +7 -2
- package/dist/index.js +6 -1
- package/dist/refs.js +5 -0
- package/dist/resolve.js +3 -0
- package/dist/richtext-render.d.ts +3 -0
- package/dist/richtext-render.js +62 -0
- package/dist/shared-id.d.ts +3 -0
- package/dist/shared-id.js +36 -0
- package/dist/sql-space.d.ts +4 -0
- package/dist/sql-space.js +5 -1
- package/dist/sql.js +1 -1
- package/dist/supabase-entry-store.d.ts +8 -0
- package/dist/supabase-entry-store.js +37 -2
- package/dist/toolset.d.ts +30 -0
- package/dist/toolset.js +49 -0
- package/package.json +11 -1
- package/dist/admin/richtext.d.ts +0 -14
- package/dist/admin/richtext.js +0 -17
|
@@ -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
|
-
|
|
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(
|
|
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(
|
|
73
|
+
? _jsx(RouteLink, { href: it.href, style: { color: "inherit" }, children: it.label })
|
|
72
74
|
: _jsx("span", { children: it.label })] }, i));
|
|
73
75
|
})] }));
|
|
74
76
|
}
|
package/dist/admin/ui/theme.d.ts
CHANGED
|
@@ -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: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. */
|
package/dist/admin/ui/theme.js
CHANGED
|
@@ -74,7 +74,7 @@ const SCALE = `
|
|
|
74
74
|
|
|
75
75
|
--radius-xs:1px; --radius-sm:3px; --radius-md:4px; --radius-round:999px;
|
|
76
76
|
--control-h:28px; --control-h-lg:36px; --row-h:38px; --topbar-h:48px;
|
|
77
|
-
--nav-w:200px; --inspector-w:
|
|
77
|
+
--nav-w:200px; --inspector-w:380px;
|
|
78
78
|
--border:1px solid var(--line);
|
|
79
79
|
--outline-selection:1.5px solid var(--selection);
|
|
80
80
|
`;
|
|
@@ -231,6 +231,45 @@ body:has(.sm-admin){margin:0}
|
|
|
231
231
|
.sm-inputwrap>.sm-input:hover,.sm-inputwrap>.sm-input:focus{background:transparent;box-shadow:none}
|
|
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
|
+
/* ── richtext ──────────────────────────────────────────────────────── */
|
|
235
|
+
.sm-richtext{display:flex;flex-direction:column;gap:6px;min-width:0}
|
|
236
|
+
.sm-richtext__bar{display:flex;flex-wrap:wrap;align-items:center;gap:2px}
|
|
237
|
+
.sm-richtext__sep{width:1px;height:16px;margin:0 4px;background:var(--line)}
|
|
238
|
+
.sm-richtext__bar .sm-iconbtn[aria-pressed="true"]{background:var(--press);color:var(--text)}
|
|
239
|
+
.sm-richtext__link{display:flex;align-items:center;gap:6px}
|
|
240
|
+
.sm-richtext__content{
|
|
241
|
+
min-height:110px;padding:8px 10px;border:1.5px solid transparent;border-radius:var(--radius-md);
|
|
242
|
+
background:var(--surface-inset);color:var(--text);font-size:var(--text-md);line-height:1.5;outline:0;
|
|
243
|
+
}
|
|
244
|
+
.sm-richtext__content:hover{background:var(--control-hover)}
|
|
245
|
+
.sm-richtext__content:focus{background:var(--input-bg);border-color:var(--selection);box-shadow:var(--focus-ring)}
|
|
246
|
+
.sm-richtext__content>*+*{margin-top:.5em}
|
|
247
|
+
.sm-richtext__content p,.sm-richtext__content h1,.sm-richtext__content h2,.sm-richtext__content h3,
|
|
248
|
+
.sm-richtext__content h4,.sm-richtext__content h5,.sm-richtext__content h6,
|
|
249
|
+
.sm-richtext__content ul,.sm-richtext__content ol,.sm-richtext__content blockquote{margin:0}
|
|
250
|
+
.sm-richtext__content h1,.sm-richtext__content h2,.sm-richtext__content h3,
|
|
251
|
+
.sm-richtext__content h4,.sm-richtext__content h5,.sm-richtext__content h6{font-weight:600;line-height:1.25}
|
|
252
|
+
.sm-richtext__content h1{font-size:var(--text-3xl)}
|
|
253
|
+
.sm-richtext__content h2{font-size:var(--text-2xl)}
|
|
254
|
+
.sm-richtext__content h3{font-size:var(--text-xl)}
|
|
255
|
+
.sm-richtext__content h4,.sm-richtext__content h5,.sm-richtext__content h6{font-size:var(--text-lg)}
|
|
256
|
+
.sm-richtext__content ul,.sm-richtext__content ol{padding-left:18px}
|
|
257
|
+
.sm-richtext__content li+li{margin-top:.25em}
|
|
258
|
+
.sm-richtext__content blockquote{padding-left:10px;border-left:2px solid var(--line);color:var(--text-muted)}
|
|
259
|
+
.sm-richtext__content a{color:var(--accent);text-decoration:underline}
|
|
260
|
+
|
|
261
|
+
.sm-group{display:flex;flex-direction:column;gap:10px;padding:2px 0 0 10px;border-left:2px solid var(--line)}
|
|
262
|
+
.sm-list{display:flex;flex-direction:column;gap:6px;align-items:stretch}
|
|
263
|
+
.sm-listrow{border:1px solid var(--line);border-radius:var(--radius-md);min-width:0}
|
|
264
|
+
.sm-listrow__head{display:flex;align-items:center;gap:2px;height:var(--control-h);padding:0 4px 0 4px}
|
|
265
|
+
.sm-listrow__toggle{
|
|
266
|
+
flex:1;min-width:0;display:flex;align-items:center;gap:6px;height:100%;padding:0 4px;
|
|
267
|
+
border:0;background:transparent;color:var(--text);font:inherit;font-size:var(--text-md);font-weight:500;
|
|
268
|
+
text-align:left;cursor:pointer;
|
|
269
|
+
}
|
|
270
|
+
.sm-listrow__toggle:hover{color:var(--accent)}
|
|
271
|
+
.sm-listrow__summary{overflow:hidden;white-space:nowrap;text-overflow:ellipsis}
|
|
272
|
+
.sm-listrow__body{display:flex;flex-direction:column;gap:10px;padding:8px 10px 10px;border-top:1px solid var(--line)}
|
|
234
273
|
|
|
235
274
|
/* ── data ──────────────────────────────────────────────────────────── */
|
|
236
275
|
.sm-table{display:flex;flex-direction:column;padding:0 16px 16px}
|
package/dist/admin/validate.d.ts
CHANGED
|
@@ -2,9 +2,14 @@ export type FieldError = {
|
|
|
2
2
|
field: string;
|
|
3
3
|
message: string;
|
|
4
4
|
};
|
|
5
|
-
|
|
5
|
+
type Desc = {
|
|
6
6
|
type: string;
|
|
7
7
|
optional?: unknown;
|
|
8
8
|
options?: unknown;
|
|
9
9
|
max?: unknown;
|
|
10
|
-
|
|
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 {};
|
package/dist/admin/validate.js
CHANGED
|
@@ -1,19 +1,59 @@
|
|
|
1
|
+
// MVP validation, keyed by inert descriptor data (the design rule: logic
|
|
2
|
+
// lives admin/server-side). Entry saves validate; page drafts do NOT
|
|
3
|
+
// (drafts must save freely) — publish validates every node. Objects and
|
|
4
|
+
// lists recurse; error paths are dotted (`cta.href`, `points.2.heading`).
|
|
5
|
+
import { hasText } from "./richtext-doc.js";
|
|
6
|
+
const subfields = (d) => d.fields && typeof d.fields === "object" ? d.fields : {};
|
|
7
|
+
const itemOf = (d) => d.item && typeof d.item === "object" ? d.item : undefined;
|
|
1
8
|
const isEmpty = (v) => v === undefined || v === null || v === "" || (Array.isArray(v) && v.length === 0);
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
9
|
+
/** An object whose every subfield is optional — or is itself an object that
|
|
10
|
+
* may be absent — may be absent altogether. */
|
|
11
|
+
const mayBeAbsent = (d) => d.optional === true || (d.type === "object" && Object.values(subfields(d)).every(mayBeAbsent));
|
|
12
|
+
const plural = (n, word) => `${n} ${word}${n === 1 ? "" : "s"}`;
|
|
13
|
+
function validateValue(path, value, d, errors) {
|
|
14
|
+
if (isEmpty(value)) {
|
|
15
|
+
if (!mayBeAbsent(d))
|
|
16
|
+
errors.push({ field: path, message: "Required." });
|
|
17
|
+
return;
|
|
18
|
+
}
|
|
19
|
+
if (d.type === "richtext" && !hasText(value)) {
|
|
20
|
+
if (!mayBeAbsent(d))
|
|
21
|
+
errors.push({ field: path, message: "Required." });
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
if (d.type === "list") {
|
|
25
|
+
if (!Array.isArray(value)) {
|
|
26
|
+
errors.push({ field: path, message: "Must be a list." });
|
|
27
|
+
return;
|
|
13
28
|
}
|
|
14
|
-
if (typeof d.
|
|
15
|
-
errors.push({ field, message: `
|
|
29
|
+
if (typeof d.min === "number" && value.length < d.min)
|
|
30
|
+
errors.push({ field: path, message: `At least ${plural(d.min, "item")}.` });
|
|
31
|
+
if (typeof d.max === "number" && value.length > d.max)
|
|
32
|
+
errors.push({ field: path, message: `At most ${plural(d.max, "item")}.` });
|
|
33
|
+
const item = itemOf(d);
|
|
34
|
+
if (item)
|
|
35
|
+
value.forEach((v, i) => validateValue(`${path}.${i}`, v, item, errors));
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
if (d.type === "object") {
|
|
39
|
+
if (typeof value !== "object" || Array.isArray(value)) {
|
|
40
|
+
errors.push({ field: path, message: "Must be an object." });
|
|
41
|
+
return;
|
|
16
42
|
}
|
|
43
|
+
for (const [k, sub] of Object.entries(subfields(d)))
|
|
44
|
+
validateValue(`${path}.${k}`, value[k], sub, errors);
|
|
45
|
+
return;
|
|
17
46
|
}
|
|
47
|
+
if (d.type === "select" && Array.isArray(d.options) && !d.options.includes(value)) {
|
|
48
|
+
errors.push({ field: path, message: `Must be one of: ${d.options.join(", ")}.` });
|
|
49
|
+
}
|
|
50
|
+
if (typeof d.max === "number" && typeof value === "string" && value.length > d.max) {
|
|
51
|
+
errors.push({ field: path, message: `Must be at most ${d.max} characters.` });
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
export function validateFields(values, descriptors) {
|
|
55
|
+
const errors = [];
|
|
56
|
+
for (const [field, d] of Object.entries(descriptors))
|
|
57
|
+
validateValue(field, values[field], d, errors);
|
|
18
58
|
return errors;
|
|
19
59
|
}
|
package/dist/collections.d.ts
CHANGED
|
@@ -53,17 +53,3 @@ export declare function collection<F extends BuilderMap>(input: {
|
|
|
53
53
|
};
|
|
54
54
|
versions?: boolean;
|
|
55
55
|
}): CollectionSchema<F>;
|
|
56
|
-
export type Toolset = {
|
|
57
|
-
marks?: string[];
|
|
58
|
-
nodes?: string[];
|
|
59
|
-
elements?: string[];
|
|
60
|
-
};
|
|
61
|
-
export declare function toolset(input: {
|
|
62
|
-
marks?: string[];
|
|
63
|
-
nodes?: string[];
|
|
64
|
-
elements?: {
|
|
65
|
-
schema: {
|
|
66
|
-
name: string;
|
|
67
|
-
};
|
|
68
|
-
}[];
|
|
69
|
-
}): Toolset;
|
package/dist/collections.js
CHANGED
|
@@ -45,13 +45,3 @@ export function collection(input) {
|
|
|
45
45
|
admin: input.admin,
|
|
46
46
|
};
|
|
47
47
|
}
|
|
48
|
-
export function toolset(input) {
|
|
49
|
-
const out = {};
|
|
50
|
-
if (input.marks)
|
|
51
|
-
out.marks = input.marks;
|
|
52
|
-
if (input.nodes)
|
|
53
|
-
out.nodes = input.nodes;
|
|
54
|
-
if (input.elements)
|
|
55
|
-
out.elements = input.elements.map((e) => e.schema.name);
|
|
56
|
-
return out;
|
|
57
|
-
}
|
package/dist/entry-store.js
CHANGED
|
@@ -13,6 +13,8 @@ import { collectionDepth, collectionOrder } from "./collections.js";
|
|
|
13
13
|
import { collectEntryRefs } from "./refs.js";
|
|
14
14
|
import { MemoryRefIndex } from "./ref-index.js";
|
|
15
15
|
import { MemoryPathIndex } from "./path-index.js";
|
|
16
|
+
import { sharedEntryId } from "./shared-id.js";
|
|
17
|
+
import { DEFAULT_SPACE_ID } from "./sql-space.js";
|
|
16
18
|
import { assertDepth, assertSupportedLocale, chainOf, entryPathRows, slugOf, unsupportedLocale, } from "./paths.js";
|
|
17
19
|
export function schemaOf(collections, name) {
|
|
18
20
|
const schema = collections.find((c) => c.name === name);
|
|
@@ -272,7 +274,11 @@ export class MemoryEntryStore {
|
|
|
272
274
|
this.assertSiblingSlug(collection, parentId, options.locale, slug);
|
|
273
275
|
const now = Date.now();
|
|
274
276
|
const entry = {
|
|
275
|
-
id
|
|
277
|
+
// A shared item's id is derived, not minted (spec 2026-09-07 §10):
|
|
278
|
+
// the same uuid v5 the Supabase adapter computes, so self-host and
|
|
279
|
+
// cloud agree on the row a materialization would create.
|
|
280
|
+
id: isSharedSchema(schema) ? await sharedEntryId(DEFAULT_SPACE_ID, collection) : uid("entry"),
|
|
281
|
+
collection, parentId, fields: shared,
|
|
276
282
|
locales: {
|
|
277
283
|
[options.locale]: { slug, status: "draft", fields: localized, draftVersionId: null, publishedVersionId: null, updatedAt: now },
|
|
278
284
|
},
|
package/dist/fields.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { type Toolset } from "./toolset.ts";
|
|
1
2
|
export type Descriptor = {
|
|
2
3
|
type: string;
|
|
3
4
|
[key: string]: unknown;
|
|
@@ -31,6 +32,15 @@ export type BuilderFlags = {
|
|
|
31
32
|
optional?: true;
|
|
32
33
|
localized?: true;
|
|
33
34
|
};
|
|
35
|
+
/** A builder whose flags carry `localized: true` becomes `never` here, so
|
|
36
|
+
* `f.list(f.text().localized())` and `f.object({ a: f.text().localized() })`
|
|
37
|
+
* are type errors: localization is whole-value on lists and objects
|
|
38
|
+
* (spec 2026-09-07, list field §2). */
|
|
39
|
+
type NotLocalized<B> = B extends {
|
|
40
|
+
__flags?: infer Fl;
|
|
41
|
+
} ? Fl extends {
|
|
42
|
+
localized: true;
|
|
43
|
+
} ? never : B : B;
|
|
34
44
|
export declare const f: {
|
|
35
45
|
text: () => FieldBuilder<string, {}>;
|
|
36
46
|
link: () => FieldBuilder<string, {}>;
|
|
@@ -50,11 +60,16 @@ export declare const f: {
|
|
|
50
60
|
url: string;
|
|
51
61
|
}, {}>;
|
|
52
62
|
richtext: (opts?: {
|
|
53
|
-
toolset?:
|
|
63
|
+
toolset?: Toolset;
|
|
54
64
|
}) => FieldBuilder<unknown, {}>;
|
|
55
65
|
dataAttributes: () => FieldBuilder<Record<string, string>, {}>;
|
|
56
66
|
select: <const O extends readonly string[]>(options: O) => FieldBuilder<O[number], {}>;
|
|
57
|
-
object: <S extends Record<string, FieldBuilder<any, any>>>(shape: S) => FieldBuilder<{ [K in keyof S]: ValueOf<S[K]>; }, {}>;
|
|
67
|
+
object: <S extends Record<string, FieldBuilder<any, any>>>(shape: S & { [K in keyof S]: NotLocalized<S[K]>; }) => FieldBuilder<{ [K in keyof S]: ValueOf<S[K]>; }, {}>;
|
|
68
|
+
/** A repeating item: any builder, but no list at any depth inside the item
|
|
69
|
+
* — a list in an object in a list is still a nested list on screen, and a
|
|
70
|
+
* 300px sidebar cannot show one legibly (spec 2026-09-07, list field §1).
|
|
71
|
+
* `.min`/`.max` count items. */
|
|
72
|
+
list: <B extends FieldBuilder<any, any>>(item: B & NotLocalized<B>) => FieldBuilder<ValueOf<B>[], {}>;
|
|
58
73
|
ref: <T>(target: () => T) => FieldBuilder<Resolved<T>, {}>;
|
|
59
74
|
refList: <T>(target: () => T) => FieldBuilder<Resolved<T>[], {}>;
|
|
60
75
|
};
|
|
@@ -65,3 +80,4 @@ export type ValueOf<B> = B extends FieldBuilder<infer V, infer Flags> ? Flags ex
|
|
|
65
80
|
export type Resolved<T> = T extends {
|
|
66
81
|
__entry?: infer E;
|
|
67
82
|
} ? E : Record<string, unknown>;
|
|
83
|
+
export {};
|
package/dist/fields.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// Field builders — inert descriptors by design rule (see DESIGN.md §3):
|
|
2
2
|
// f.text().max(40) is plain data; validation logic lives server/admin-side,
|
|
3
3
|
// keyed by descriptor type. Chaining is immutable.
|
|
4
|
+
import { DEFAULT_TOOLSET } from "./toolset.js";
|
|
4
5
|
function builder(descriptor) {
|
|
5
6
|
const chain = (patch) => builder({ ...descriptor, ...patch });
|
|
6
7
|
return {
|
|
@@ -20,6 +21,35 @@ function builder(descriptor) {
|
|
|
20
21
|
tab: (tab) => chain({ tab }),
|
|
21
22
|
};
|
|
22
23
|
}
|
|
24
|
+
const describePath = (path) => (path === "" ? "the item" : `"${path}"`);
|
|
25
|
+
/** First path at which `matches` is true of the descriptor, walking into
|
|
26
|
+
* object subfields and a list's item, or null. */
|
|
27
|
+
function findMatch(d, matches, path = "") {
|
|
28
|
+
if (matches(d))
|
|
29
|
+
return path;
|
|
30
|
+
if (d.type === "object") {
|
|
31
|
+
for (const [k, sub] of Object.entries(d.fields)) {
|
|
32
|
+
const hit = findMatch(sub, matches, path ? `${path}.${k}` : k);
|
|
33
|
+
if (hit !== null)
|
|
34
|
+
return hit;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
if (d.type === "list")
|
|
38
|
+
return findMatch(d.item, matches, path);
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
/** Refs are addressed by top-level key everywhere (resolver, refs index,
|
|
42
|
+
* admin); `.localized()` decides storage per top-level field. Neither may
|
|
43
|
+
* hide inside a container. */
|
|
44
|
+
function assertContainerItem(kind, d, path = "") {
|
|
45
|
+
const ref = findMatch(d, (sub) => sub.type === "ref" || sub.type === "refList", path);
|
|
46
|
+
if (ref !== null)
|
|
47
|
+
throw new Error(`smoodly: ${kind} cannot hold a ref (at ${describePath(ref)}) — refs live at the top level of fields.`);
|
|
48
|
+
const loc = findMatch(d, (sub) => sub.localized === true, path);
|
|
49
|
+
if (loc !== null) {
|
|
50
|
+
throw new Error(`smoodly: .localized() inside ${kind} (at ${describePath(loc)}) — localize the ${kind === "f.list" ? "list" : "object"} itself.`);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
23
53
|
export const f = {
|
|
24
54
|
text: () => builder({ type: "text" }),
|
|
25
55
|
link: () => builder({ type: "link" }),
|
|
@@ -29,13 +59,29 @@ export const f = {
|
|
|
29
59
|
image: () => builder({ type: "image" }),
|
|
30
60
|
video: () => builder({ type: "video" }),
|
|
31
61
|
file: () => builder({ type: "file" }),
|
|
32
|
-
richtext: (opts) => builder({ type: "richtext",
|
|
62
|
+
richtext: (opts) => builder({ type: "richtext", toolset: opts?.toolset ?? DEFAULT_TOOLSET }),
|
|
33
63
|
dataAttributes: () => builder({ type: "dataAttributes" }),
|
|
34
64
|
select: (options) => builder({ type: "select", options: [...options] }),
|
|
35
|
-
object: (shape) =>
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
65
|
+
object: (shape) => {
|
|
66
|
+
for (const [k, v] of Object.entries(shape))
|
|
67
|
+
assertContainerItem("f.object", v.descriptor, k);
|
|
68
|
+
return builder({
|
|
69
|
+
type: "object",
|
|
70
|
+
fields: Object.fromEntries(Object.entries(shape).map(([k, v]) => [k, v.descriptor])),
|
|
71
|
+
});
|
|
72
|
+
},
|
|
73
|
+
/** A repeating item: any builder, but no list at any depth inside the item
|
|
74
|
+
* — a list in an object in a list is still a nested list on screen, and a
|
|
75
|
+
* 300px sidebar cannot show one legibly (spec 2026-09-07, list field §1).
|
|
76
|
+
* `.min`/`.max` count items. */
|
|
77
|
+
list: (item) => {
|
|
78
|
+
const d = item.descriptor;
|
|
79
|
+
const nested = findMatch(d, (sub) => sub.type === "list");
|
|
80
|
+
if (nested !== null)
|
|
81
|
+
throw new Error(`smoodly: f.list cannot hold another list (at ${describePath(nested)}) — nested lists are not supported.`);
|
|
82
|
+
assertContainerItem("f.list", d);
|
|
83
|
+
return builder({ type: "list", item: d });
|
|
84
|
+
},
|
|
39
85
|
ref: (target) => builder({ type: "ref", target }),
|
|
40
86
|
refList: (target) => builder({ type: "refList", target }),
|
|
41
87
|
};
|
package/dist/index.d.ts
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
import { section, element } from "./pairing.tsx";
|
|
2
|
-
import { collection
|
|
2
|
+
import { collection } from "./collections.ts";
|
|
3
|
+
import { toolset } from "./toolset.ts";
|
|
3
4
|
import { Section } from "./section-wrapper.tsx";
|
|
4
5
|
import { page } from "./render.tsx";
|
|
5
6
|
import { entryPage } from "./entry-page.tsx";
|
|
6
7
|
import { shared } from "./shared.ts";
|
|
7
8
|
export { Zone, renderPage, EMPTY_PAGE_CONTEXT } from "./render.tsx";
|
|
8
9
|
export type { PageTree, TreeNode, ZoneContent, PairedPage, Registry, PageContext, PageContextLink, PageLink, PageViewProps } from "./render.tsx";
|
|
10
|
+
export { RichText } from "./richtext-render.tsx";
|
|
9
11
|
export type { PageSchema } from "./page.ts";
|
|
10
12
|
export { fixedSlug } from "./page.ts";
|
|
11
13
|
export type { EntryPageSchema, PairedEntryPage, EntryPageProps } from "./entry-page.tsx";
|
|
@@ -38,10 +40,13 @@ export type { SmoodlyConfig, ResolvedConfig } from "./config.ts";
|
|
|
38
40
|
export type { FieldBuilder, Descriptor, ValueOf } from "./fields.ts";
|
|
39
41
|
export type { SectionSchema, ElementSchema, BuilderMap } from "./schema.ts";
|
|
40
42
|
export type { Props, PairedComponent } from "./pairing.tsx";
|
|
41
|
-
export type { CollectionSchema,
|
|
43
|
+
export type { CollectionSchema, EntryOrder } from "./collections.ts";
|
|
44
|
+
export { DEFAULT_TOOLSET, type Toolset, type ToolsetMark, type ToolsetNode } from "./toolset.ts";
|
|
42
45
|
export { collectionOrder, collectionDepth, DEFAULT_ORDER } from "./collections.ts";
|
|
43
46
|
export type { ZonePolicy } from "./zones.ts";
|
|
44
47
|
export { SHARED_NODE_TYPE, STYLES_KEY, sharedLink } from "./shared.ts";
|
|
48
|
+
export { sharedEntryId } from "./shared-id.ts";
|
|
49
|
+
export { DEFAULT_SPACE_ID } from "./sql-space.ts";
|
|
45
50
|
export type { SharedSchema, SharedLink } from "./shared.ts";
|
|
46
51
|
export declare const smoodly: {
|
|
47
52
|
schema: {
|