smoodly 0.0.24 → 0.0.25
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 +182 -40
- package/bin/smoodly.js +6 -0
- package/dist/admin/client-ops.js +1 -0
- package/dist/admin/editor/EditorView.js +20 -6
- package/dist/admin/editor/InsertPicker.d.ts +14 -0
- package/dist/admin/editor/InsertPicker.js +37 -7
- package/dist/admin/editor/InspectorFields.js +1 -1
- package/dist/admin/editor/Outline.js +2 -2
- package/dist/admin/editor/PageSettings.js +1 -1
- package/dist/admin/editor/SeoSettings.js +1 -1
- package/dist/admin/editor/locale-url.d.ts +7 -4
- package/dist/admin/editor/locale-url.js +14 -7
- package/dist/admin/editor/usePageLocale.js +9 -1
- package/dist/admin/forms/CropEditor.d.ts +4 -2
- package/dist/admin/forms/CropEditor.js +6 -3
- package/dist/admin/forms/DatePicker.d.ts +9 -0
- package/dist/admin/forms/DatePicker.js +133 -0
- package/dist/admin/forms/FieldWidget.js +11 -5
- package/dist/admin/forms/ImagePreview.d.ts +9 -0
- package/dist/admin/forms/ImagePreview.js +11 -0
- package/dist/admin/forms/MediaWidget.d.ts +8 -2
- package/dist/admin/forms/MediaWidget.js +13 -13
- package/dist/admin/forms/RichtextElementCard.js +1 -1
- package/dist/admin/forms/RichtextImageCard.js +14 -9
- package/dist/admin/forms/date-picker.d.ts +44 -0
- package/dist/admin/forms/date-picker.js +117 -0
- package/dist/admin/forms/elements-context.d.ts +3 -0
- package/dist/admin/forms/elements-context.js +7 -2
- package/dist/admin/forms/time-zone-context.d.ts +7 -0
- package/dist/admin/forms/time-zone-context.js +15 -0
- package/dist/admin/forms/useRichtextEditor.js +10 -1
- package/dist/admin/next/bridge.d.ts +2 -1
- package/dist/admin/next/bridge.js +35 -5
- package/dist/admin/ops-impl.js +4 -0
- package/dist/admin/ops.d.ts +9 -0
- package/dist/admin/serialize.d.ts +2 -0
- package/dist/admin/serialize.js +1 -0
- package/dist/admin/shell/AdminApp.js +23 -1
- package/dist/admin/shell/EntriesList.js +12 -21
- package/dist/admin/shell/EntryForm.js +48 -7
- package/dist/admin/shell/GlobalForm.js +22 -2
- package/dist/admin/shell/GlobalList.js +4 -18
- package/dist/admin/shell/IconRail.js +14 -5
- package/dist/admin/shell/InviteModal.js +9 -5
- package/dist/admin/shell/ListView.js +2 -2
- package/dist/admin/shell/MediaScreen.js +11 -1
- package/dist/admin/shell/MembersPage.js +33 -15
- package/dist/admin/shell/PageDialogs.d.ts +26 -0
- package/dist/admin/shell/PageDialogs.js +38 -0
- package/dist/admin/shell/PagesList.js +96 -73
- package/dist/admin/shell/RouteLink.js +1 -1
- package/dist/admin/shell/SchemaNotice.d.ts +4 -0
- package/dist/admin/shell/SchemaNotice.js +4 -0
- package/dist/admin/shell/SettingsView.js +11 -26
- package/dist/admin/shell/live-url.d.ts +4 -3
- package/dist/admin/shell/live-url.js +12 -3
- package/dist/admin/shell/locale-memory.d.ts +8 -0
- package/dist/admin/shell/locale-memory.js +23 -0
- package/dist/admin/shell/members-list.d.ts +2 -0
- package/dist/admin/shell/members-list.js +5 -0
- package/dist/admin/shell/page-dialogs.d.ts +9 -0
- package/dist/admin/shell/page-dialogs.js +19 -0
- package/dist/admin/shell/router.d.ts +10 -7
- package/dist/admin/shell/router.js +18 -9
- package/dist/admin/shell/settings.d.ts +0 -6
- package/dist/admin/shell/settings.js +4 -5
- package/dist/admin/tree-ops.js +3 -0
- package/dist/admin/ui/ActionMenu.js +26 -6
- package/dist/admin/ui/Combobox.d.ts +5 -0
- package/dist/admin/ui/Combobox.js +1 -1
- package/dist/admin/ui/ConfirmDialog.d.ts +8 -0
- package/dist/admin/ui/ConfirmDialog.js +30 -0
- package/dist/admin/ui/Dialog.d.ts +13 -0
- package/dist/admin/ui/Dialog.js +54 -0
- package/dist/admin/ui/HistoryModal.d.ts +2 -1
- package/dist/admin/ui/HistoryModal.js +7 -2
- package/dist/admin/ui/ResizeHandle.js +4 -4
- package/dist/admin/ui/confirm.d.ts +35 -0
- package/dist/admin/ui/confirm.js +57 -0
- package/dist/admin/ui/primitives.d.ts +19 -4
- package/dist/admin/ui/primitives.js +11 -5
- package/dist/admin/ui/resize.d.ts +7 -4
- package/dist/admin/ui/resize.js +7 -4
- package/dist/admin/ui/theme.d.ts +1 -1
- package/dist/admin/ui/theme.js +368 -203
- package/dist/admin/validate.d.ts +4 -0
- package/dist/admin/validate.js +28 -0
- package/dist/cli/load-collections.d.ts +2 -0
- package/dist/cli/load-collections.js +26 -0
- package/dist/cli/main.d.ts +13 -0
- package/dist/cli/main.js +62 -0
- package/dist/cli/migrate.d.ts +23 -0
- package/dist/cli/migrate.js +64 -0
- package/dist/config.d.ts +4 -0
- package/dist/config.js +9 -0
- package/dist/fields.d.ts +2 -0
- package/dist/fields.js +2 -0
- package/dist/index.d.ts +2 -1
- package/dist/index.js +2 -1
- package/dist/marks.d.ts +3 -0
- package/dist/marks.js +3 -0
- package/dist/migrations.d.ts +37 -0
- package/dist/migrations.js +73 -0
- package/dist/schema-version.d.ts +17 -0
- package/dist/schema-version.js +32 -0
- package/dist/sql-space.js +6 -0
- package/dist/sql.js +10 -0
- package/dist/store.d.ts +3 -0
- package/dist/store.js +4 -0
- package/dist/supabase-store.d.ts +3 -0
- package/dist/supabase-store.js +23 -0
- package/dist/time-zone.d.ts +6 -0
- package/dist/time-zone.js +27 -0
- package/migrations/20260926120000_smoodly_v1.sql +535 -0
- package/package.json +13 -3
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/** A URL segment from a title: lowercase ASCII letters, digits and
|
|
2
|
+
* hyphens (the path grammar, DESIGN.md OQ 24), diacritics stripped
|
|
3
|
+
* (ä → a, é → e), everything else a hyphen, no leading, trailing or
|
|
4
|
+
* doubled hyphens. */
|
|
5
|
+
export declare function slugify(title: string): string;
|
|
6
|
+
/** The slug field follows the title until the editor types a slug of
|
|
7
|
+
* their own; a slug equal to what the previous title produced is still
|
|
8
|
+
* "following". */
|
|
9
|
+
export declare function followsTitle(slug: string, previousTitle: string): boolean;
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
// Pure helpers for the page dialogs (PageDialogs.tsx).
|
|
2
|
+
/** A URL segment from a title: lowercase ASCII letters, digits and
|
|
3
|
+
* hyphens (the path grammar, DESIGN.md OQ 24), diacritics stripped
|
|
4
|
+
* (ä → a, é → e), everything else a hyphen, no leading, trailing or
|
|
5
|
+
* doubled hyphens. */
|
|
6
|
+
export function slugify(title) {
|
|
7
|
+
return title
|
|
8
|
+
.normalize("NFD")
|
|
9
|
+
.replace(/[̀-ͯ]/g, "")
|
|
10
|
+
.toLowerCase()
|
|
11
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
12
|
+
.replace(/^-+|-+$/g, "");
|
|
13
|
+
}
|
|
14
|
+
/** The slug field follows the title until the editor types a slug of
|
|
15
|
+
* their own; a slug equal to what the previous title produced is still
|
|
16
|
+
* "following". */
|
|
17
|
+
export function followsTitle(slug, previousTitle) {
|
|
18
|
+
return slug === "" || slug === slugify(previousTitle);
|
|
19
|
+
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { type ConfirmOptions } from "../ui/confirm.ts";
|
|
1
2
|
export type Route = {
|
|
2
3
|
pathname: string;
|
|
3
4
|
segments: string[];
|
|
@@ -18,18 +19,20 @@ export declare function isPlainLeftClick(e: {
|
|
|
18
19
|
altKey: boolean;
|
|
19
20
|
defaultPrevented: boolean;
|
|
20
21
|
}, target?: string | null): boolean;
|
|
21
|
-
|
|
22
|
+
/** The leave question, asked in the admin's own dialog (ui/confirm.ts). */
|
|
23
|
+
export declare const LEAVE_CONFIRM: ConfirmOptions;
|
|
22
24
|
/** Leaving is allowed when no guard is dirty, else when the editor
|
|
23
25
|
* confirms — asked once, however many guards are dirty. */
|
|
24
|
-
export declare function mayLeave(guards: Iterable<() => boolean>, confirm: (
|
|
25
|
-
/** Move to `href` in place; true when the move happened. A push
|
|
26
|
-
* leave guard
|
|
27
|
-
*
|
|
28
|
-
*
|
|
26
|
+
export declare function mayLeave(guards: Iterable<() => boolean>, confirm: (options: ConfirmOptions) => boolean | Promise<boolean>): Promise<boolean>;
|
|
27
|
+
/** Move to `href` in place; resolves true when the move happened. A push
|
|
28
|
+
* asks the leave guard (in the admin's dialog, so the answer is awaited);
|
|
29
|
+
* a replace never leaves the view, so it does not ask. `force` skips the
|
|
30
|
+
* guard for a view that has just saved or deleted its record — React has
|
|
31
|
+
* not flushed its setDirty(false) yet. */
|
|
29
32
|
export declare function navigate(href: string, opts?: {
|
|
30
33
|
replace?: boolean;
|
|
31
34
|
force?: boolean;
|
|
32
|
-
}): boolean
|
|
35
|
+
}): Promise<boolean>;
|
|
33
36
|
/** The current route under `base`: empty until mount, then the URL,
|
|
34
37
|
* re-read on popstate and after navigate(). */
|
|
35
38
|
export declare function useRoute(base: string): Route;
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
// framework-agnostic.
|
|
11
11
|
import { useEffect, useState } from "react";
|
|
12
12
|
import { DEFAULT_ADMIN_PATH } from "../../admin-path.js";
|
|
13
|
+
import { confirm } from "../ui/confirm.js";
|
|
13
14
|
/** The segments under `base`: "/admin/articles/x" → ["articles", "x"];
|
|
14
15
|
* the mount itself, or a pathname outside it, → []. Segments stay as
|
|
15
16
|
* written; the shell decodes the ones it uses, as it does for the
|
|
@@ -28,13 +29,20 @@ export function isPlainLeftClick(e, target) {
|
|
|
28
29
|
return false;
|
|
29
30
|
return !target || target === "_self";
|
|
30
31
|
}
|
|
31
|
-
|
|
32
|
+
/** The leave question, asked in the admin's own dialog (ui/confirm.ts). */
|
|
33
|
+
export const LEAVE_CONFIRM = {
|
|
34
|
+
title: "Discard unsaved changes?",
|
|
35
|
+
body: "Your latest edits have not been saved and will be lost if you leave now.",
|
|
36
|
+
confirmLabel: "Discard changes",
|
|
37
|
+
cancelLabel: "Stay",
|
|
38
|
+
destructive: true,
|
|
39
|
+
};
|
|
32
40
|
/** Leaving is allowed when no guard is dirty, else when the editor
|
|
33
41
|
* confirms — asked once, however many guards are dirty. */
|
|
34
|
-
export function mayLeave(guards, confirm) {
|
|
42
|
+
export async function mayLeave(guards, confirm) {
|
|
35
43
|
for (const dirty of guards)
|
|
36
44
|
if (dirty())
|
|
37
|
-
return confirm(
|
|
45
|
+
return confirm(LEAVE_CONFIRM);
|
|
38
46
|
return true;
|
|
39
47
|
}
|
|
40
48
|
// ── the live router ──────────────────────────────────────────────────
|
|
@@ -47,12 +55,13 @@ function currentRoute(base) {
|
|
|
47
55
|
// The segments derive from the pathname and a base that never changes
|
|
48
56
|
// under a mounted shell, so two strings decide sameness.
|
|
49
57
|
const sameRoute = (a, b) => a.pathname === b.pathname && a.search === b.search;
|
|
50
|
-
/** Move to `href` in place; true when the move happened. A push
|
|
51
|
-
* leave guard
|
|
52
|
-
*
|
|
53
|
-
*
|
|
54
|
-
|
|
55
|
-
|
|
58
|
+
/** Move to `href` in place; resolves true when the move happened. A push
|
|
59
|
+
* asks the leave guard (in the admin's dialog, so the answer is awaited);
|
|
60
|
+
* a replace never leaves the view, so it does not ask. `force` skips the
|
|
61
|
+
* guard for a view that has just saved or deleted its record — React has
|
|
62
|
+
* not flushed its setDirty(false) yet. */
|
|
63
|
+
export async function navigate(href, opts = {}) {
|
|
64
|
+
if (!opts.replace && !opts.force && !(await mayLeave(guards, confirm)))
|
|
56
65
|
return false;
|
|
57
66
|
window.history[opts.replace ? "replaceState" : "pushState"](null, "", href);
|
|
58
67
|
for (const listener of listeners)
|
|
@@ -1,12 +1,6 @@
|
|
|
1
1
|
export declare const SETTINGS_PAGES: readonly [{
|
|
2
2
|
readonly id: "members";
|
|
3
3
|
readonly title: "Members";
|
|
4
|
-
}, {
|
|
5
|
-
readonly id: "roles";
|
|
6
|
-
readonly title: "Roles";
|
|
7
|
-
}, {
|
|
8
|
-
readonly id: "api-keys";
|
|
9
|
-
readonly title: "API Keys";
|
|
10
4
|
}];
|
|
11
5
|
export type SettingsPageId = (typeof SETTINGS_PAGES)[number]["id"];
|
|
12
6
|
export declare const DEFAULT_SETTINGS_PAGE: SettingsPageId;
|
|
@@ -1,10 +1,9 @@
|
|
|
1
|
-
// The Settings area's pages (spec 2026-09-25 §5
|
|
2
|
-
//
|
|
3
|
-
//
|
|
1
|
+
// The Settings area's pages (spec 2026-09-25 §5; narrowed 2026-09-25 to
|
|
2
|
+
// Members alone — Roles and API Keys dropped, see DESIGN.md §2): ids are
|
|
3
|
+
// URL segments under /settings, titles are what the secondary nav and
|
|
4
|
+
// each top bar show. Pure, so routing is testable without React.
|
|
4
5
|
export const SETTINGS_PAGES = [
|
|
5
6
|
{ id: "members", title: "Members" },
|
|
6
|
-
{ id: "roles", title: "Roles" },
|
|
7
|
-
{ id: "api-keys", title: "API Keys" },
|
|
8
7
|
];
|
|
9
8
|
export const DEFAULT_SETTINGS_PAGE = "members";
|
|
10
9
|
/** The page for the segment after /settings: the default for none, null for an unknown one. */
|
package/dist/admin/tree-ops.js
CHANGED
|
@@ -133,6 +133,9 @@ export function nodeFromSample(section, id) {
|
|
|
133
133
|
fields[key] = sampled;
|
|
134
134
|
else if (ABSENT_WHEN_UNSAMPLED.has(d.type))
|
|
135
135
|
continue;
|
|
136
|
+
// An optional object with no sample is absent: `{}` would read as present, its members required at publish.
|
|
137
|
+
else if (d.type === "object" && d.optional === true)
|
|
138
|
+
continue;
|
|
136
139
|
else
|
|
137
140
|
fields[key] = EMPTY_BY_TYPE[d.type] ?? "";
|
|
138
141
|
}
|
|
@@ -5,22 +5,42 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
5
5
|
// not apply — the layout never shifts and the editor learns what exists.
|
|
6
6
|
// The popover is the Pages list's row menu shape (`.sm-picker`), anchored
|
|
7
7
|
// under the button instead of at the pointer, hanging from the button's
|
|
8
|
-
// right edge (every control sits at its bar's or card's right).
|
|
8
|
+
// right edge (every control sits at its bar's or card's right). It is
|
|
9
|
+
// position:fixed from the button's rect (2026-09-26): inside a table cell
|
|
10
|
+
// an absolute child was clipped by the cell's overflow, so the Members
|
|
11
|
+
// row menu opened invisibly. A scroll closes it rather than leaving it behind.
|
|
9
12
|
import { useEffect, useRef, useState } from "react";
|
|
10
13
|
import { cx } from "./primitives.js";
|
|
11
14
|
export function ActionMenu({ label, items, disabled, small = false }) {
|
|
12
|
-
|
|
15
|
+
/** Open: the popover's viewport position, under the button's right edge. */
|
|
16
|
+
const [open, setOpen] = useState(null);
|
|
13
17
|
const ref = useRef(null);
|
|
18
|
+
const button = useRef(null);
|
|
19
|
+
const toggle = () => {
|
|
20
|
+
if (open) {
|
|
21
|
+
setOpen(null);
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
const r = button.current?.getBoundingClientRect();
|
|
25
|
+
if (r)
|
|
26
|
+
setOpen({ top: r.bottom + 6, right: window.innerWidth - r.right });
|
|
27
|
+
};
|
|
14
28
|
useEffect(() => {
|
|
15
29
|
if (!open)
|
|
16
30
|
return;
|
|
31
|
+
const close = () => setOpen(null);
|
|
17
32
|
const onDown = (e) => { if (!ref.current?.contains(e.target))
|
|
18
|
-
|
|
33
|
+
close(); };
|
|
19
34
|
const onKey = (e) => { if (e.key === "Escape")
|
|
20
|
-
|
|
35
|
+
close(); };
|
|
21
36
|
document.addEventListener("pointerdown", onDown);
|
|
22
37
|
document.addEventListener("keydown", onKey);
|
|
23
|
-
|
|
38
|
+
document.addEventListener("scroll", close, true);
|
|
39
|
+
return () => {
|
|
40
|
+
document.removeEventListener("pointerdown", onDown);
|
|
41
|
+
document.removeEventListener("keydown", onKey);
|
|
42
|
+
document.removeEventListener("scroll", close, true);
|
|
43
|
+
};
|
|
24
44
|
}, [open]);
|
|
25
|
-
return (_jsxs("div", { ref: ref, style: { position: "relative", flex: "none" }, children: [_jsx("button", { type: "button", className: cx("sm-dotmenu", small && "sm-dotmenu--sm"), "aria-label": label, title: label, "aria-haspopup": "menu", "aria-expanded": open, disabled: disabled, onClick:
|
|
45
|
+
return (_jsxs("div", { ref: ref, style: { position: "relative", flex: "none" }, children: [_jsx("button", { ref: button, type: "button", className: cx("sm-dotmenu", small && "sm-dotmenu--sm"), "aria-label": label, title: label, "aria-haspopup": "menu", "aria-expanded": open !== null, disabled: disabled, onClick: toggle, children: "\u00B7\u00B7\u00B7" }), open && (_jsx("div", { role: "menu", className: "sm-picker sm-picker--menu", style: { top: open.top, right: open.right }, children: items.map((item) => (_jsx("button", { type: "button", role: "menuitem", className: cx("sm-picker__item", item.danger && "sm-picker__item--danger"), disabled: item.disabled, onClick: () => { setOpen(null); item.run(); }, children: item.label }, item.label))) }))] }));
|
|
26
46
|
}
|
|
@@ -27,4 +27,9 @@ export type ComboboxProps = {
|
|
|
27
27
|
initialOpen?: boolean;
|
|
28
28
|
className?: string;
|
|
29
29
|
};
|
|
30
|
+
/** The vertical band the list may occupy: the nearest clipping ancestor's box, cut to the viewport. */
|
|
31
|
+
export declare function clipBand(el: HTMLElement): {
|
|
32
|
+
top: number;
|
|
33
|
+
bottom: number;
|
|
34
|
+
};
|
|
30
35
|
export declare function Combobox({ options, onPick, label, placeholder, disabled, keepOpen, emptyText, renderOption, onSubmit, createRow, onQueryChange, onInputKey, onClose, autoFocus, initialOpen, className, }: ComboboxProps): import("react").JSX.Element;
|
|
@@ -11,7 +11,7 @@ import { cx } from "./primitives.js";
|
|
|
11
11
|
import { filterOptions, opensUp, showsCreate, stepActive } from "./combo-box.js";
|
|
12
12
|
const keepFocus = (e) => e.preventDefault();
|
|
13
13
|
/** The vertical band the list may occupy: the nearest clipping ancestor's box, cut to the viewport. */
|
|
14
|
-
function clipBand(el) {
|
|
14
|
+
export function clipBand(el) {
|
|
15
15
|
let top = 0;
|
|
16
16
|
let bottom = window.innerHeight;
|
|
17
17
|
for (let p = el.parentElement; p; p = p.parentElement) {
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { type ReactElement } from "react";
|
|
2
|
+
import { type ConfirmOptions } from "./confirm.ts";
|
|
3
|
+
export declare function ConfirmDialog({ options, onAnswer }: {
|
|
4
|
+
options: ConfirmOptions;
|
|
5
|
+
onAnswer(yes: boolean): void;
|
|
6
|
+
}): ReactElement;
|
|
7
|
+
/** Renders the pending question, once, from the shell. */
|
|
8
|
+
export declare function ConfirmHost(): ReactElement | null;
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
3
|
+
// The confirm question on the dialog card (ui/Dialog.tsx; the pattern:
|
|
4
|
+
// design_handoff_smoodly_neutral/screens/ConfirmModal.md): the question,
|
|
5
|
+
// the consequence, an optional context chip naming the subject, then
|
|
6
|
+
// Cancel and the action verb. Focus opens on Cancel for a destructive
|
|
7
|
+
// action and on the primary otherwise, so Enter runs the primary only
|
|
8
|
+
// when it is safe.
|
|
9
|
+
//
|
|
10
|
+
// <ConfirmHost/> renders the pending question from ui/confirm.ts, once,
|
|
11
|
+
// from the shell; the dialog itself is a plain component so a test can
|
|
12
|
+
// render it statically.
|
|
13
|
+
import { useId, useSyncExternalStore } from "react";
|
|
14
|
+
import { DialogCard } from "./Dialog.js";
|
|
15
|
+
import { Button } from "./primitives.js";
|
|
16
|
+
import { pendingConfirm, subscribeConfirm } from "./confirm.js";
|
|
17
|
+
export function ConfirmDialog({ options, onAnswer }) {
|
|
18
|
+
const { title, body, confirmLabel, cancelLabel = "Cancel", destructive = false, subject } = options;
|
|
19
|
+
const uid = useId();
|
|
20
|
+
const bodyId = `sm-confirm-body${uid}`;
|
|
21
|
+
const cancel = () => onAnswer(false);
|
|
22
|
+
return (_jsxs(DialogCard, { title: title, role: "alertdialog", describedBy: body ? bodyId : undefined, onClose: cancel, className: "sm-confirm", actions: _jsxs(_Fragment, { children: [_jsx(Button, { variant: "secondary", autoFocus: destructive, onClick: cancel, children: cancelLabel }), _jsx(Button, { variant: destructive ? "destructive" : "primary", autoFocus: !destructive, onClick: () => onAnswer(true), children: confirmLabel })] }), children: [body && _jsx("p", { className: "sm-dialog__body", id: bodyId, children: body }), subject && (_jsxs("div", { className: "sm-dialog__context", children: [_jsx("strong", { children: subject.name }), subject.path && _jsx("code", { children: subject.path })] }))] }));
|
|
23
|
+
}
|
|
24
|
+
/** Renders the pending question, once, from the shell. */
|
|
25
|
+
export function ConfirmHost() {
|
|
26
|
+
const request = useSyncExternalStore(subscribeConfirm, pendingConfirm, () => null);
|
|
27
|
+
if (!request)
|
|
28
|
+
return null;
|
|
29
|
+
return _jsx(ConfirmDialog, { options: request.options, onAnswer: request.answer }, request.id);
|
|
30
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { type ReactElement, type ReactNode } from "react";
|
|
2
|
+
export declare function DialogCard({ title, role, describedBy, onClose, actions, className, children }: {
|
|
3
|
+
title: string;
|
|
4
|
+
/** `alertdialog` for a question that interrupts (the confirm); `dialog` for a form. */
|
|
5
|
+
role?: "dialog" | "alertdialog";
|
|
6
|
+
/** The id of the element that describes the dialog (a confirm's body). */
|
|
7
|
+
describedBy?: string;
|
|
8
|
+
onClose(): void;
|
|
9
|
+
/** The action row: Cancel then the verb. */
|
|
10
|
+
actions: ReactNode;
|
|
11
|
+
className?: string;
|
|
12
|
+
children: ReactNode;
|
|
13
|
+
}): ReactElement;
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
// The dialog card (design_handoff_smoodly_neutral/components/ConfirmModal.html):
|
|
4
|
+
// a scrim over the whole admin, a 440px card with a title row and a close
|
|
5
|
+
// button, whatever the dialog puts inside, then its actions. Escape and
|
|
6
|
+
// the scrim close it; Tab cycles inside; focus returns to where it was.
|
|
7
|
+
// The confirm question (ConfirmDialog) and the page form dialogs
|
|
8
|
+
// (shell/PageDialogs) are built on it. Portalled into the admin wrapper
|
|
9
|
+
// so it covers the frame and any modal underneath; static rendering (no
|
|
10
|
+
// document) keeps it inline.
|
|
11
|
+
import { useEffect, useId, useRef } from "react";
|
|
12
|
+
import { createPortal } from "react-dom";
|
|
13
|
+
import { cx } from "./primitives.js";
|
|
14
|
+
function adminRoot() {
|
|
15
|
+
if (typeof document === "undefined")
|
|
16
|
+
return null;
|
|
17
|
+
return document.querySelector(".sm-admin") ?? document.body;
|
|
18
|
+
}
|
|
19
|
+
export function DialogCard({ title, role = "dialog", describedBy, onClose, actions, className, children }) {
|
|
20
|
+
const panel = useRef(null);
|
|
21
|
+
const uid = useId();
|
|
22
|
+
const titleId = `sm-dialog-title${uid}`;
|
|
23
|
+
useEffect(() => {
|
|
24
|
+
const before = typeof document !== "undefined" ? document.activeElement : null;
|
|
25
|
+
const onKey = (e) => { if (e.key === "Escape") {
|
|
26
|
+
e.stopPropagation();
|
|
27
|
+
onClose();
|
|
28
|
+
} };
|
|
29
|
+
window.addEventListener("keydown", onKey, true);
|
|
30
|
+
return () => {
|
|
31
|
+
window.removeEventListener("keydown", onKey, true);
|
|
32
|
+
before?.focus?.();
|
|
33
|
+
};
|
|
34
|
+
}, [onClose]);
|
|
35
|
+
const trap = (e) => {
|
|
36
|
+
if (e.key !== "Tab" || !panel.current)
|
|
37
|
+
return;
|
|
38
|
+
const focusable = Array.from(panel.current.querySelectorAll("button:not(:disabled),input:not(:disabled),select:not(:disabled),textarea:not(:disabled)"));
|
|
39
|
+
if (focusable.length === 0)
|
|
40
|
+
return;
|
|
41
|
+
const first = focusable[0], last = focusable[focusable.length - 1];
|
|
42
|
+
if (e.shiftKey && document.activeElement === first) {
|
|
43
|
+
e.preventDefault();
|
|
44
|
+
last.focus();
|
|
45
|
+
}
|
|
46
|
+
else if (!e.shiftKey && document.activeElement === last) {
|
|
47
|
+
e.preventDefault();
|
|
48
|
+
first.focus();
|
|
49
|
+
}
|
|
50
|
+
};
|
|
51
|
+
const dialog = (_jsx("div", { className: "sm-scrim", onClick: onClose, children: _jsxs("div", { className: cx("sm-dialog", className), role: role, "aria-modal": "true", "aria-labelledby": titleId, "aria-describedby": describedBy, ref: panel, onClick: (e) => e.stopPropagation(), onKeyDown: trap, children: [_jsx("div", { className: "sm-dialog__head", children: _jsxs("div", { className: "sm-dialog__titlerow", children: [_jsx("h2", { className: "sm-dialog__title", id: titleId, children: title }), _jsx("button", { type: "button", className: "sm-dialog__close", "aria-label": "Close", onClick: onClose, children: "\u00D7" })] }) }), children, _jsx("div", { className: "sm-dialog__actions", children: actions })] }) }));
|
|
52
|
+
const root = adminRoot();
|
|
53
|
+
return root ? createPortal(dialog, root) : dialog;
|
|
54
|
+
}
|
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
import { type ReactElement, type ReactNode } from "react";
|
|
2
2
|
import type { PageLocale } from "../../store.ts";
|
|
3
3
|
import type { VersionSummary } from "../ops.ts";
|
|
4
|
+
import { type ConfirmOptions } from "./confirm.ts";
|
|
4
5
|
export type HistoryTag = "Draft" | "Live" | null;
|
|
5
6
|
export type HistoryRow = VersionSummary & {
|
|
6
7
|
tag: HistoryTag;
|
|
7
8
|
};
|
|
8
|
-
export declare const RESTORE_CONFIRM
|
|
9
|
+
export declare const RESTORE_CONFIRM: ConfirmOptions;
|
|
9
10
|
/** The rows for a locale's versions: the unpublished draft is Draft, the
|
|
10
11
|
* row the site serves Live, an older publish carries nothing. */
|
|
11
12
|
export declare function historyRows(versions: VersionSummary[], row: Pick<PageLocale, "status" | "draftVersionId" | "publishedVersionId">): HistoryRow[];
|
|
@@ -9,8 +9,13 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
|
|
|
9
9
|
import { useEffect, useState } from "react";
|
|
10
10
|
import { versionTime } from "./format.js";
|
|
11
11
|
import { Modal } from "./Modal.js";
|
|
12
|
+
import { confirm } from "./confirm.js";
|
|
12
13
|
import { Button, cx } from "./primitives.js";
|
|
13
|
-
export const RESTORE_CONFIRM =
|
|
14
|
+
export const RESTORE_CONFIRM = {
|
|
15
|
+
title: "Restore this version?",
|
|
16
|
+
body: "The current draft is replaced by this version. Nothing goes live until you publish.",
|
|
17
|
+
confirmLabel: "Restore",
|
|
18
|
+
};
|
|
14
19
|
/** The rows for a locale's versions: the unpublished draft is Draft, the
|
|
15
20
|
* row the site serves Live, an older publish carries nothing. */
|
|
16
21
|
export function historyRows(versions, row) {
|
|
@@ -28,7 +33,7 @@ export function HistoryModal({ rows, draftId, render, onSelect, onRestore, onClo
|
|
|
28
33
|
useEffect(() => { if (selected)
|
|
29
34
|
onSelect?.(selected); }, [selected]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
30
35
|
const restore = async () => {
|
|
31
|
-
if (!selected || !
|
|
36
|
+
if (!selected || !(await confirm(RESTORE_CONFIRM)))
|
|
32
37
|
return;
|
|
33
38
|
setBusy(true);
|
|
34
39
|
const landed = await onRestore(selected);
|
|
@@ -52,7 +52,7 @@ export function ResizeHandle() {
|
|
|
52
52
|
const { admin, grid } = parts(el);
|
|
53
53
|
if (!admin || !grid)
|
|
54
54
|
return;
|
|
55
|
-
const next = clampInspectorW(px, grid.clientWidth);
|
|
55
|
+
const next = clampInspectorW(px, grid.clientWidth, el.offsetWidth);
|
|
56
56
|
apply(admin, next);
|
|
57
57
|
remember(next);
|
|
58
58
|
setNow(next);
|
|
@@ -76,7 +76,7 @@ export function ResizeHandle() {
|
|
|
76
76
|
return;
|
|
77
77
|
e.preventDefault();
|
|
78
78
|
e.currentTarget.setPointerCapture(e.pointerId);
|
|
79
|
-
drag.current = { id: e.pointerId, startX: e.clientX, startW: aside.offsetWidth, rowW: grid.clientWidth, last: aside.offsetWidth };
|
|
79
|
+
drag.current = { id: e.pointerId, startX: e.clientX, startW: aside.offsetWidth, rowW: grid.clientWidth, handleW: e.currentTarget.offsetWidth, last: aside.offsetWidth };
|
|
80
80
|
admin.classList.add("sm-admin--resizing");
|
|
81
81
|
};
|
|
82
82
|
const onPointerMove = (e) => {
|
|
@@ -87,7 +87,7 @@ export function ResizeHandle() {
|
|
|
87
87
|
if (!admin)
|
|
88
88
|
return;
|
|
89
89
|
// The handle sits on the inspector's left edge: moving left widens it.
|
|
90
|
-
d.last = clampInspectorW(d.startW - (e.clientX - d.startX), d.rowW);
|
|
90
|
+
d.last = clampInspectorW(d.startW - (e.clientX - d.startX), d.rowW, d.handleW);
|
|
91
91
|
apply(admin, d.last);
|
|
92
92
|
};
|
|
93
93
|
// Also fires from releasePointerCapture below, when drag.current is already null.
|
|
@@ -116,7 +116,7 @@ export function ResizeHandle() {
|
|
|
116
116
|
else if (e.key === "Home")
|
|
117
117
|
next = MIN_INSPECTOR_W;
|
|
118
118
|
else if (e.key === "End")
|
|
119
|
-
next = maxInspectorW(grid.clientWidth);
|
|
119
|
+
next = maxInspectorW(grid.clientWidth, e.currentTarget.offsetWidth);
|
|
120
120
|
if (next === null)
|
|
121
121
|
return;
|
|
122
122
|
e.preventDefault();
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
export type ConfirmSubject = {
|
|
2
|
+
/** What the action touches — a page title, an entry title, an email. Truncates. */
|
|
3
|
+
name: string;
|
|
4
|
+
/** Its URL when it has one; never truncates. */
|
|
5
|
+
path?: string | null;
|
|
6
|
+
};
|
|
7
|
+
export type ConfirmOptions = {
|
|
8
|
+
/** The question, as a question: "Delete this page?" */
|
|
9
|
+
title: string;
|
|
10
|
+
/** The consequence in one or two plain sentences. */
|
|
11
|
+
body?: string;
|
|
12
|
+
/** The action verb on the primary button: "Delete page", never "OK". */
|
|
13
|
+
confirmLabel: string;
|
|
14
|
+
/** "Cancel" unless the action needs another word. */
|
|
15
|
+
cancelLabel?: string;
|
|
16
|
+
/** A destructive action: red primary, and focus lands on Cancel. */
|
|
17
|
+
destructive?: boolean;
|
|
18
|
+
/** The context chip repeating the subject; omit when there is no single subject. */
|
|
19
|
+
subject?: ConfirmSubject;
|
|
20
|
+
};
|
|
21
|
+
export type ConfirmRequest = {
|
|
22
|
+
id: number;
|
|
23
|
+
options: ConfirmOptions;
|
|
24
|
+
answer(yes: boolean): void;
|
|
25
|
+
};
|
|
26
|
+
/** The Discard changes question, shared by the page editor and the entry and global forms (publish flow §4). */
|
|
27
|
+
export declare const DISCARD_CONFIRM: Omit<ConfirmOptions, "subject">;
|
|
28
|
+
/** Ask; resolves with the answer once the dialog closes. */
|
|
29
|
+
export declare function confirm(options: ConfirmOptions): Promise<boolean>;
|
|
30
|
+
/** The question being asked, if any — the head of the queue. */
|
|
31
|
+
export declare const pendingConfirm: () => ConfirmRequest | null;
|
|
32
|
+
/** For useSyncExternalStore: fires on every queue change. */
|
|
33
|
+
export declare function subscribeConfirm(fn: () => void): () => void;
|
|
34
|
+
/** Test seam: drop every pending question unanswered. */
|
|
35
|
+
export declare function resetConfirms(): void;
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
// The admin's one "are you sure" (design_handoff_smoodly_neutral/screens/
|
|
2
|
+
// ConfirmModal.md): a caller awaits `confirm(...)` and gets a boolean, the
|
|
3
|
+
// way it awaited window.confirm — but the question is answered in
|
|
4
|
+
// Smoodly's own dialog, rendered by <ConfirmHost/> (ConfirmDialog.tsx)
|
|
5
|
+
// from this store. Framework-free on purpose: the router (shell/router.ts)
|
|
6
|
+
// asks from outside React.
|
|
7
|
+
//
|
|
8
|
+
// One question at a time; a second request waits for the first answer.
|
|
9
|
+
// Without a mounted host (a test, a stray render outside the shell) the
|
|
10
|
+
// question falls back to window.confirm so no caller can hang.
|
|
11
|
+
/** The Discard changes question, shared by the page editor and the entry and global forms (publish flow §4). */
|
|
12
|
+
export const DISCARD_CONFIRM = {
|
|
13
|
+
title: "Discard unsaved changes?",
|
|
14
|
+
body: "The draft goes back to the published version. Every edit since the last publish is lost.",
|
|
15
|
+
confirmLabel: "Discard changes",
|
|
16
|
+
destructive: true,
|
|
17
|
+
};
|
|
18
|
+
const listeners = new Set();
|
|
19
|
+
let queue = [];
|
|
20
|
+
let seq = 0;
|
|
21
|
+
const notify = () => { for (const fn of listeners)
|
|
22
|
+
fn(); };
|
|
23
|
+
/** Ask; resolves with the answer once the dialog closes. */
|
|
24
|
+
export function confirm(options) {
|
|
25
|
+
if (listeners.size === 0) {
|
|
26
|
+
const text = [options.title, options.body].filter(Boolean).join("\n\n");
|
|
27
|
+
return Promise.resolve(typeof window !== "undefined" && typeof window.confirm === "function" ? window.confirm(text) : false);
|
|
28
|
+
}
|
|
29
|
+
return new Promise((resolve) => {
|
|
30
|
+
const id = ++seq;
|
|
31
|
+
const request = {
|
|
32
|
+
id,
|
|
33
|
+
options,
|
|
34
|
+
answer(yes) {
|
|
35
|
+
if (!queue.some((r) => r.id === id))
|
|
36
|
+
return; // answered already
|
|
37
|
+
queue = queue.filter((r) => r.id !== id);
|
|
38
|
+
notify();
|
|
39
|
+
resolve(yes);
|
|
40
|
+
},
|
|
41
|
+
};
|
|
42
|
+
queue = [...queue, request];
|
|
43
|
+
notify();
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
/** The question being asked, if any — the head of the queue. */
|
|
47
|
+
export const pendingConfirm = () => queue[0] ?? null;
|
|
48
|
+
/** For useSyncExternalStore: fires on every queue change. */
|
|
49
|
+
export function subscribeConfirm(fn) {
|
|
50
|
+
listeners.add(fn);
|
|
51
|
+
return () => { listeners.delete(fn); };
|
|
52
|
+
}
|
|
53
|
+
/** Test seam: drop every pending question unanswered. */
|
|
54
|
+
export function resetConfirms() {
|
|
55
|
+
queue = [];
|
|
56
|
+
notify();
|
|
57
|
+
}
|
|
@@ -13,13 +13,16 @@ export declare function Logo({ height }: {
|
|
|
13
13
|
* is right on the very first paint with no state and no hydration mismatch. */
|
|
14
14
|
export declare function ThemeToggle(): import("react").JSX.Element;
|
|
15
15
|
type ButtonProps = ButtonHTMLAttributes<HTMLButtonElement> & {
|
|
16
|
-
|
|
16
|
+
/** `danger` is the quiet red-text button; `destructive` the filled red primary of a confirm dialog. */
|
|
17
|
+
variant?: "primary" | "secondary" | "ghost" | "danger" | "destructive" | "ok";
|
|
17
18
|
size?: "sm" | "md" | "lg";
|
|
18
19
|
};
|
|
19
20
|
export declare function Button({ variant, size, className, ...rest }: ButtonProps): import("react").JSX.Element;
|
|
20
21
|
/** The same shape as Button, for navigations that must stay real links. */
|
|
21
|
-
export declare function LinkButton({ variant, children, ...rest }: {
|
|
22
|
+
export declare function LinkButton({ variant, icon, children, ...rest }: {
|
|
22
23
|
variant?: ButtonProps["variant"];
|
|
24
|
+
/** An icon-only pill (a square the button's height); give it an aria-label. */
|
|
25
|
+
icon?: boolean;
|
|
23
26
|
children: ReactNode;
|
|
24
27
|
} & AnchorHTMLAttributes<HTMLAnchorElement>): import("react").JSX.Element;
|
|
25
28
|
export declare function IconButton({ label, tone, size, children, ...rest }: {
|
|
@@ -102,6 +105,17 @@ export declare function Segmented<T extends string>({ options, value, onChange,
|
|
|
102
105
|
className?: string;
|
|
103
106
|
} | undefined;
|
|
104
107
|
}): import("react").JSX.Element;
|
|
108
|
+
/** A pill row (the f.radio look, spec 2026-09-14 §5): no track, the chosen pill filled in the
|
|
109
|
+
* soft accent. For a small choice inside a table row, where a segmented control reads as tabs. */
|
|
110
|
+
export declare function Pills<T extends string>({ options, value, onChange, disabled, title, label }: {
|
|
111
|
+
options: readonly T[];
|
|
112
|
+
value: T;
|
|
113
|
+
onChange(v: T): void;
|
|
114
|
+
disabled?: boolean;
|
|
115
|
+
/** Why the row is off, on every pill. */
|
|
116
|
+
title?: string;
|
|
117
|
+
label?(o: T): ReactNode;
|
|
118
|
+
}): import("react").JSX.Element;
|
|
105
119
|
export declare function StatusBadge({ status }: {
|
|
106
120
|
status: string;
|
|
107
121
|
}): import("react").JSX.Element;
|
|
@@ -109,10 +123,11 @@ export declare function StatusBadge({ status }: {
|
|
|
109
123
|
export declare function SaveIndicator({ state }: {
|
|
110
124
|
state: SaveState;
|
|
111
125
|
}): import("react").JSX.Element;
|
|
112
|
-
|
|
126
|
+
/** `caps`: the overline style — small, uppercase, tracked (2026-09-26; the mono face before). */
|
|
127
|
+
export declare function SectionLabel({ children, action, caps, style }: {
|
|
113
128
|
children: ReactNode;
|
|
114
129
|
action?: ReactNode;
|
|
115
|
-
|
|
130
|
+
caps?: boolean;
|
|
116
131
|
style?: CSSProperties;
|
|
117
132
|
}): import("react").JSX.Element;
|
|
118
133
|
export declare function Divider({ bleed }: {
|
|
@@ -45,8 +45,8 @@ export function Button({ variant = "primary", size = "md", className, ...rest })
|
|
|
45
45
|
return (_jsx("button", { type: "button", className: cx("sm-btn", `sm-btn--${variant}`, size === "lg" && "sm-btn--lg", size === "sm" && "sm-btn--sm", className), ...rest }));
|
|
46
46
|
}
|
|
47
47
|
/** The same shape as Button, for navigations that must stay real links. */
|
|
48
|
-
export function LinkButton({ variant = "secondary", children, ...rest }) {
|
|
49
|
-
return _jsx("a", { className: cx("sm-btn", `sm-btn--${variant}
|
|
48
|
+
export function LinkButton({ variant = "secondary", icon = false, children, ...rest }) {
|
|
49
|
+
return _jsx("a", { className: cx("sm-btn", `sm-btn--${variant}`, icon && "sm-btn--icon"), ...rest, children: children });
|
|
50
50
|
}
|
|
51
51
|
export function IconButton({ label, tone = "default", size = 24, children, ...rest }) {
|
|
52
52
|
return (_jsx("button", { type: "button", "aria-label": label, title: label, className: cx("sm-iconbtn", tone === "inverse" && "sm-iconbtn--inverse"), style: { width: size, height: size }, ...rest, children: children }));
|
|
@@ -105,6 +105,11 @@ export function Segmented({ options, value, onChange, stretch, disabled, label,
|
|
|
105
105
|
return (_jsx("button", { type: "button", className: cx("sm-seg__opt", extra?.className), "aria-pressed": o === value, disabled: disabled || extra?.disabled, title: extra?.title, onClick: () => onChange(o), children: label ? label(o) : o }, o));
|
|
106
106
|
}) }));
|
|
107
107
|
}
|
|
108
|
+
/** A pill row (the f.radio look, spec 2026-09-14 §5): no track, the chosen pill filled in the
|
|
109
|
+
* soft accent. For a small choice inside a table row, where a segmented control reads as tabs. */
|
|
110
|
+
export function Pills({ options, value, onChange, disabled, title, label }) {
|
|
111
|
+
return (_jsx("div", { className: cx("sm-pills", disabled && "sm-pills--disabled"), role: "radiogroup", children: options.map((o) => (_jsx("button", { type: "button", className: "sm-pill", "aria-pressed": o === value, disabled: disabled, title: title, onClick: () => onChange(o), children: label ? label(o) : o }, o))) }));
|
|
112
|
+
}
|
|
108
113
|
/* ── data ────────────────────────────────────────────────────────────── */
|
|
109
114
|
// Keyed by the product labels of ui/status-label.ts (lowercased by StatusBadge).
|
|
110
115
|
const STATUS_TONE = {
|
|
@@ -126,10 +131,11 @@ export function SaveIndicator({ state }) {
|
|
|
126
131
|
error: { label: "Save failed — retrying", dot: "var(--danger)", color: "var(--danger)" },
|
|
127
132
|
};
|
|
128
133
|
const { label, dot, color } = map[state];
|
|
129
|
-
return (_jsxs("span", { className: "sm-savestate", style: { color }, title: label, children: [_jsx("span", { className: "sm-dot", style: { background: dot } }), label] }));
|
|
134
|
+
return (_jsxs("span", { className: "sm-savestate", "data-testid": "save-status", style: { color }, title: label, children: [_jsx("span", { className: "sm-dot", style: { background: dot } }), label] }));
|
|
130
135
|
}
|
|
131
|
-
|
|
132
|
-
|
|
136
|
+
/** `caps`: the overline style — small, uppercase, tracked (2026-09-26; the mono face before). */
|
|
137
|
+
export function SectionLabel({ children, action, caps, style }) {
|
|
138
|
+
return (_jsxs("div", { className: cx("sm-sectionlabel", caps && "sm-sectionlabel--caps"), style: style, children: [_jsx("span", { className: "sm-label", children: children }), action && _jsx("span", { style: { color: "var(--accent)", fontWeight: 600, fontSize: "var(--text-sm)" }, children: action })] }));
|
|
133
139
|
}
|
|
134
140
|
export function Divider({ bleed = 0 }) {
|
|
135
141
|
return _jsx("div", { className: "sm-divider", style: { margin: `0 -${bleed}px` } });
|
|
@@ -6,10 +6,13 @@ export declare const MIN_INSPECTOR_W = 260;
|
|
|
6
6
|
export declare const DEFAULT_INSPECTOR_W = 360;
|
|
7
7
|
/** One arrow-key press. */
|
|
8
8
|
export declare const RESIZE_STEP = 16;
|
|
9
|
-
/**
|
|
10
|
-
export declare const
|
|
11
|
-
/**
|
|
12
|
-
|
|
9
|
+
/** What the canvas keeps however far the inspector is dragged (2026-09-26, up from half the row; 340 for an hour). */
|
|
10
|
+
export declare const MIN_CANVAS_W = 440;
|
|
11
|
+
/** The editor row (canvas + handle + inspector) less the handle and the
|
|
12
|
+
* canvas minimum, never below the inspector minimum. */
|
|
13
|
+
export declare const maxInspectorW: (rowWidth: number, handleWidth?: number) => number;
|
|
14
|
+
/** An integer inside [MIN, the row less the handle and the canvas minimum]. */
|
|
15
|
+
export declare const clampInspectorW: (px: number, rowWidth: number, handleWidth?: number) => number;
|
|
13
16
|
/** A stored value is trusted only as a plain integer ≥ MIN; anything else is
|
|
14
17
|
* ignored (never clamped or rewritten). The boot script in theme.tsx repeats
|
|
15
18
|
* this test inline — keep the two in step. */
|