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.
Files changed (115) hide show
  1. package/README.md +182 -40
  2. package/bin/smoodly.js +6 -0
  3. package/dist/admin/client-ops.js +1 -0
  4. package/dist/admin/editor/EditorView.js +20 -6
  5. package/dist/admin/editor/InsertPicker.d.ts +14 -0
  6. package/dist/admin/editor/InsertPicker.js +37 -7
  7. package/dist/admin/editor/InspectorFields.js +1 -1
  8. package/dist/admin/editor/Outline.js +2 -2
  9. package/dist/admin/editor/PageSettings.js +1 -1
  10. package/dist/admin/editor/SeoSettings.js +1 -1
  11. package/dist/admin/editor/locale-url.d.ts +7 -4
  12. package/dist/admin/editor/locale-url.js +14 -7
  13. package/dist/admin/editor/usePageLocale.js +9 -1
  14. package/dist/admin/forms/CropEditor.d.ts +4 -2
  15. package/dist/admin/forms/CropEditor.js +6 -3
  16. package/dist/admin/forms/DatePicker.d.ts +9 -0
  17. package/dist/admin/forms/DatePicker.js +133 -0
  18. package/dist/admin/forms/FieldWidget.js +11 -5
  19. package/dist/admin/forms/ImagePreview.d.ts +9 -0
  20. package/dist/admin/forms/ImagePreview.js +11 -0
  21. package/dist/admin/forms/MediaWidget.d.ts +8 -2
  22. package/dist/admin/forms/MediaWidget.js +13 -13
  23. package/dist/admin/forms/RichtextElementCard.js +1 -1
  24. package/dist/admin/forms/RichtextImageCard.js +14 -9
  25. package/dist/admin/forms/date-picker.d.ts +44 -0
  26. package/dist/admin/forms/date-picker.js +117 -0
  27. package/dist/admin/forms/elements-context.d.ts +3 -0
  28. package/dist/admin/forms/elements-context.js +7 -2
  29. package/dist/admin/forms/time-zone-context.d.ts +7 -0
  30. package/dist/admin/forms/time-zone-context.js +15 -0
  31. package/dist/admin/forms/useRichtextEditor.js +10 -1
  32. package/dist/admin/next/bridge.d.ts +2 -1
  33. package/dist/admin/next/bridge.js +35 -5
  34. package/dist/admin/ops-impl.js +4 -0
  35. package/dist/admin/ops.d.ts +9 -0
  36. package/dist/admin/serialize.d.ts +2 -0
  37. package/dist/admin/serialize.js +1 -0
  38. package/dist/admin/shell/AdminApp.js +23 -1
  39. package/dist/admin/shell/EntriesList.js +12 -21
  40. package/dist/admin/shell/EntryForm.js +48 -7
  41. package/dist/admin/shell/GlobalForm.js +22 -2
  42. package/dist/admin/shell/GlobalList.js +4 -18
  43. package/dist/admin/shell/IconRail.js +14 -5
  44. package/dist/admin/shell/InviteModal.js +9 -5
  45. package/dist/admin/shell/ListView.js +2 -2
  46. package/dist/admin/shell/MediaScreen.js +11 -1
  47. package/dist/admin/shell/MembersPage.js +33 -15
  48. package/dist/admin/shell/PageDialogs.d.ts +26 -0
  49. package/dist/admin/shell/PageDialogs.js +38 -0
  50. package/dist/admin/shell/PagesList.js +96 -73
  51. package/dist/admin/shell/RouteLink.js +1 -1
  52. package/dist/admin/shell/SchemaNotice.d.ts +4 -0
  53. package/dist/admin/shell/SchemaNotice.js +4 -0
  54. package/dist/admin/shell/SettingsView.js +11 -26
  55. package/dist/admin/shell/live-url.d.ts +4 -3
  56. package/dist/admin/shell/live-url.js +12 -3
  57. package/dist/admin/shell/locale-memory.d.ts +8 -0
  58. package/dist/admin/shell/locale-memory.js +23 -0
  59. package/dist/admin/shell/members-list.d.ts +2 -0
  60. package/dist/admin/shell/members-list.js +5 -0
  61. package/dist/admin/shell/page-dialogs.d.ts +9 -0
  62. package/dist/admin/shell/page-dialogs.js +19 -0
  63. package/dist/admin/shell/router.d.ts +10 -7
  64. package/dist/admin/shell/router.js +18 -9
  65. package/dist/admin/shell/settings.d.ts +0 -6
  66. package/dist/admin/shell/settings.js +4 -5
  67. package/dist/admin/tree-ops.js +3 -0
  68. package/dist/admin/ui/ActionMenu.js +26 -6
  69. package/dist/admin/ui/Combobox.d.ts +5 -0
  70. package/dist/admin/ui/Combobox.js +1 -1
  71. package/dist/admin/ui/ConfirmDialog.d.ts +8 -0
  72. package/dist/admin/ui/ConfirmDialog.js +30 -0
  73. package/dist/admin/ui/Dialog.d.ts +13 -0
  74. package/dist/admin/ui/Dialog.js +54 -0
  75. package/dist/admin/ui/HistoryModal.d.ts +2 -1
  76. package/dist/admin/ui/HistoryModal.js +7 -2
  77. package/dist/admin/ui/ResizeHandle.js +4 -4
  78. package/dist/admin/ui/confirm.d.ts +35 -0
  79. package/dist/admin/ui/confirm.js +57 -0
  80. package/dist/admin/ui/primitives.d.ts +19 -4
  81. package/dist/admin/ui/primitives.js +11 -5
  82. package/dist/admin/ui/resize.d.ts +7 -4
  83. package/dist/admin/ui/resize.js +7 -4
  84. package/dist/admin/ui/theme.d.ts +1 -1
  85. package/dist/admin/ui/theme.js +368 -203
  86. package/dist/admin/validate.d.ts +4 -0
  87. package/dist/admin/validate.js +28 -0
  88. package/dist/cli/load-collections.d.ts +2 -0
  89. package/dist/cli/load-collections.js +26 -0
  90. package/dist/cli/main.d.ts +13 -0
  91. package/dist/cli/main.js +62 -0
  92. package/dist/cli/migrate.d.ts +23 -0
  93. package/dist/cli/migrate.js +64 -0
  94. package/dist/config.d.ts +4 -0
  95. package/dist/config.js +9 -0
  96. package/dist/fields.d.ts +2 -0
  97. package/dist/fields.js +2 -0
  98. package/dist/index.d.ts +2 -1
  99. package/dist/index.js +2 -1
  100. package/dist/marks.d.ts +3 -0
  101. package/dist/marks.js +3 -0
  102. package/dist/migrations.d.ts +37 -0
  103. package/dist/migrations.js +73 -0
  104. package/dist/schema-version.d.ts +17 -0
  105. package/dist/schema-version.js +32 -0
  106. package/dist/sql-space.js +6 -0
  107. package/dist/sql.js +10 -0
  108. package/dist/store.d.ts +3 -0
  109. package/dist/store.js +4 -0
  110. package/dist/supabase-store.d.ts +3 -0
  111. package/dist/supabase-store.js +23 -0
  112. package/dist/time-zone.d.ts +6 -0
  113. package/dist/time-zone.js +27 -0
  114. package/migrations/20260926120000_smoodly_v1.sql +535 -0
  115. package/package.json +13 -3
@@ -1,10 +1,12 @@
1
1
  import { type ReactElement } from "react";
2
2
  import type { AssetRecord, ImageValue } from "../../media.ts";
3
3
  import type { AdminField } from "../serialize.ts";
4
- export declare function CropEditor({ asset, descriptor, value, onSave, onClose }: {
4
+ export declare function CropEditor({ asset, descriptor, value, caption, onSave, onClose }: {
5
5
  asset: AssetRecord;
6
6
  descriptor: AdminField;
7
7
  value: ImageValue;
8
- onSave(next: ImageValue): void;
8
+ /** Given, a Caption field is shown and its text handed back beside the value. */
9
+ caption?: string;
10
+ onSave(next: ImageValue, caption?: string): void;
9
11
  onClose(): void;
10
12
  }): ReactElement;
@@ -5,6 +5,8 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
5
5
  // switch when several), a draggable hotspot, the per-use alt override
6
6
  // and a live preview at the chosen aspect. Pointer deltas become image
7
7
  // fractions through the stage's width; the math is in crop-editor.ts.
8
+ // A richtext image card passes its `caption` too (2026-09-25): the card
9
+ // shows no inputs, so the modal is where both texts are edited.
8
10
  import { useRef, useState } from "react";
9
11
  import { parseAspect } from "../../media.js";
10
12
  import { frameFor } from "../../image/crop-math.js";
@@ -12,7 +14,7 @@ import { Button, Field, Segmented } from "../ui/primitives.js";
12
14
  import { Modal } from "../ui/Modal.js";
13
15
  import { initialState, moveCrop, placeHotspot, relock, resizeCrop } from "./crop-editor.js";
14
16
  const pct = (n) => `${n * 100}%`;
15
- export function CropEditor({ asset, descriptor, value, onSave, onClose }) {
17
+ export function CropEditor({ asset, descriptor, value, caption, onSave, onClose }) {
16
18
  const declared = descriptor.aspect;
17
19
  const options = Array.isArray(declared) ? declared : typeof declared === "string" ? [declared] : [];
18
20
  const width = asset.width ?? 1, height = asset.height ?? 1;
@@ -21,6 +23,7 @@ export function CropEditor({ asset, descriptor, value, onSave, onClose }) {
21
23
  const aspect = chosen ? parseAspect(chosen) : undefined;
22
24
  const [state, setState] = useState(() => initialState(value, aspect, imageAspect));
23
25
  const [alt, setAlt] = useState(value.alt ?? "");
26
+ const [cap, setCap] = useState(caption ?? "");
24
27
  const stage = useRef(null);
25
28
  const drag = useRef(null);
26
29
  const pick = (next) => { setChosen(next); setState((s) => relock(s, parseAspect(next), imageAspect)); };
@@ -56,6 +59,6 @@ export function CropEditor({ asset, descriptor, value, onSave, onClose }) {
56
59
  ...value, crop, hotspot,
57
60
  ...(alt.trim() ? { alt: alt.trim() } : { alt: undefined }),
58
61
  ...(options.length > 1 && chosen ? { aspect: chosen } : { aspect: undefined }),
59
- });
60
- return (_jsx(Modal, { title: "Crop and focus", width: 900, onClose: onClose, footer: _jsxs(_Fragment, { children: [_jsx(Button, { variant: "secondary", onClick: onClose, children: "Cancel" }), _jsx(Button, { onClick: save, children: "Save" })] }), children: _jsxs("div", { className: "sm-crop", children: [_jsxs("div", { className: "sm-crop__stage", ref: stage, onPointerMove: move, onPointerUp: end, onPointerCancel: end, children: [_jsx("img", { src: asset.url, alt: "", draggable: false }), _jsx("div", { className: "sm-crop__shade", style: { clipPath: `polygon(0 0, 100% 0, 100% 100%, 0 100%, 0 ${pct(crop.top)}, ${pct(crop.left)} ${pct(crop.top)}, ${pct(crop.left)} ${pct(crop.top + crop.height)}, ${pct(crop.left + crop.width)} ${pct(crop.top + crop.height)}, ${pct(crop.left + crop.width)} ${pct(crop.top)}, 0 ${pct(crop.top)})` } }), _jsx("div", { className: "sm-crop__rect", style: { left: pct(crop.left), top: pct(crop.top), width: pct(crop.width), height: pct(crop.height) }, onPointerDown: begin("move"), children: ["nw", "ne", "sw", "se"].map((h) => (_jsx("span", { className: `sm-crop__handle sm-crop__handle--${h}`, onPointerDown: begin(h) }, h))) }), _jsx("div", { className: "sm-crop__hotspot", title: "Focus point", style: { left: pct(hotspot.x), top: pct(hotspot.y) }, onPointerDown: begin("hotspot") })] }), _jsxs("div", { className: "sm-crop__side", children: [options.length > 1 && (_jsx(Field, { label: "Aspect", asDiv: true, children: _jsx(Segmented, { options: options, value: chosen ?? options[0], onChange: pick }) })), _jsx(Field, { label: "Preview", asDiv: true, children: _jsx("div", { className: "sm-crop__preview", style: { aspectRatio: String(previewAspect) }, children: _jsx("img", { src: asset.url, alt: "", style: { left: `${frame.left}%`, top: `${frame.top}%`, width: `${frame.width}%`, height: `${frame.height}%` } }) }) }), _jsx(Field, { label: "Alt text for this use", hint: "Empty uses the asset's own alt.", children: _jsx("input", { className: "sm-input", value: alt, placeholder: asset.alt ?? "", onChange: (e) => setAlt(e.target.value) }) })] })] }) }));
62
+ }, caption === undefined ? undefined : cap);
63
+ return (_jsx(Modal, { title: "Crop and focus", width: 900, onClose: onClose, footer: _jsxs(_Fragment, { children: [_jsx(Button, { variant: "secondary", onClick: onClose, children: "Cancel" }), _jsx(Button, { onClick: save, children: "Save" })] }), children: _jsxs("div", { className: "sm-crop", children: [_jsxs("div", { className: "sm-crop__stage", ref: stage, onPointerMove: move, onPointerUp: end, onPointerCancel: end, children: [_jsx("img", { src: asset.url, alt: "", draggable: false }), _jsx("div", { className: "sm-crop__shade", style: { clipPath: `polygon(0 0, 100% 0, 100% 100%, 0 100%, 0 ${pct(crop.top)}, ${pct(crop.left)} ${pct(crop.top)}, ${pct(crop.left)} ${pct(crop.top + crop.height)}, ${pct(crop.left + crop.width)} ${pct(crop.top + crop.height)}, ${pct(crop.left + crop.width)} ${pct(crop.top)}, 0 ${pct(crop.top)})` } }), _jsx("div", { className: "sm-crop__rect", style: { left: pct(crop.left), top: pct(crop.top), width: pct(crop.width), height: pct(crop.height) }, onPointerDown: begin("move"), children: ["nw", "ne", "sw", "se"].map((h) => (_jsx("span", { className: `sm-crop__handle sm-crop__handle--${h}`, onPointerDown: begin(h) }, h))) }), _jsx("div", { className: "sm-crop__hotspot", title: "Focus point", style: { left: pct(hotspot.x), top: pct(hotspot.y) }, onPointerDown: begin("hotspot") })] }), _jsxs("div", { className: "sm-crop__side", children: [options.length > 1 && (_jsx(Field, { label: "Aspect", asDiv: true, children: _jsx(Segmented, { options: options, value: chosen ?? options[0], onChange: pick }) })), _jsx(Field, { label: "Preview", asDiv: true, children: _jsx("div", { className: "sm-crop__preview", style: { aspectRatio: String(previewAspect) }, children: _jsx("img", { src: asset.url, alt: "", style: { left: `${frame.left}%`, top: `${frame.top}%`, width: `${frame.width}%`, height: `${frame.height}%` } }) }) }), _jsx(Field, { label: "Alt text for this use", hint: "Empty uses the asset's own alt.", children: _jsx("input", { className: "sm-input", value: alt, placeholder: asset.alt ?? "", onChange: (e) => setAlt(e.target.value) }) }), caption !== undefined && (_jsx(Field, { label: "Caption", hint: "Shown under the image.", children: _jsx("input", { className: "sm-input", value: cap, onChange: (e) => setCap(e.target.value) }) }))] })] }) }));
61
64
  }
@@ -0,0 +1,9 @@
1
+ export declare function DatePicker({ kind, value, onChange, label, initialOpen }: {
2
+ kind: "date" | "datetime";
3
+ value: unknown;
4
+ onChange(value: unknown): void;
5
+ /** Accessible name of the text box; "Date" when absent. */
6
+ label?: string;
7
+ /** Start open: static-markup tests. */
8
+ initialOpen?: boolean;
9
+ }): import("react").JSX.Element;
@@ -0,0 +1,133 @@
1
+ "use client";
2
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
3
+ // The date and datetime widget (spec 2026-09-25 date pickers §4): a text
4
+ // box the editor may type into, a calendar button opening a month grid,
5
+ // and for datetime an hour and a minute select plus a hint naming the
6
+ // zone. The stored value is the field's string (a calendar day, or a UTC
7
+ // instant); the picker edits a datetime in the site's zone from the
8
+ // context, else the browser's. The input is the ONE focus owner: grid and
9
+ // head buttons swallow mousedown, so focus leaving the control is the
10
+ // close signal besides Escape (as the Combobox does). Math in
11
+ // ./date-picker.ts.
12
+ import { useLayoutEffect, useRef, useState } from "react";
13
+ import { CalendarDays, ChevronLeft, ChevronRight } from "lucide-react";
14
+ import { browserZone } from "../../time-zone.js";
15
+ import { cx } from "../ui/primitives.js";
16
+ import { clipBand } from "../ui/Combobox.js";
17
+ import { opensUp } from "../ui/combo-box.js";
18
+ import { useTimeZone } from "./time-zone-context.js";
19
+ import { HOURS, formatDay, fromLocalParts, isSameDay, minuteOptions, monthGrid, monthLabel, parseDay, stepDay, toLocalParts, todayIn, weekdayLabels, } from "./date-picker.js";
20
+ const DEFAULT_HOUR = 9;
21
+ const keepFocus = (e) => e.preventDefault();
22
+ const pad = (n) => String(n).padStart(2, "0");
23
+ export function DatePicker({ kind, value, onChange, label, initialOpen = false }) {
24
+ const configured = useTimeZone();
25
+ const zone = configured ?? browserZone();
26
+ const stored = typeof value === "string" ? value : "";
27
+ // What the value means on screen: the day (and the zone's clock, for a datetime).
28
+ const local = kind === "datetime" ? toLocalParts(stored, zone) : null;
29
+ const day = kind === "date" ? parseDay(stored) : local?.day ?? null;
30
+ const dayText = kind === "date" ? stored : local ? formatDay(local.day) : "";
31
+ const hour = local?.hour ?? DEFAULT_HOUR;
32
+ const minute = local?.minute ?? 0;
33
+ // A datetime's text box holds a draft until it parses (the value cannot carry a half-typed day).
34
+ const [draft, setDraft] = useState(null);
35
+ const [open, setOpen] = useState(initialOpen);
36
+ const [month, setMonth] = useState(() => { const d = day ?? todayIn(zone); return { y: d.y, m: d.m }; });
37
+ const [focus, setFocus] = useState(null);
38
+ const [up, setUp] = useState(false);
39
+ const root = useRef(null);
40
+ const pop = useRef(null);
41
+ const btn = useRef(null);
42
+ /** The popover's left inside the field: its right edge under the calendar button's (2026-09-26). */
43
+ const [x, setX] = useState(0);
44
+ useLayoutEffect(() => {
45
+ if (!open || !root.current || !pop.current)
46
+ return;
47
+ const box = root.current.getBoundingClientRect();
48
+ const band = clipBand(root.current);
49
+ setUp(opensUp({ below: band.bottom - box.bottom - 4, above: box.top - band.top - 4, height: pop.current.offsetHeight }));
50
+ const b = btn.current?.getBoundingClientRect();
51
+ if (b)
52
+ setX(Math.max(0, Math.round(b.right - box.left - pop.current.offsetWidth)));
53
+ }, [open, month]);
54
+ const emitDay = (next) => {
55
+ if (next === null) {
56
+ onChange(undefined);
57
+ return;
58
+ }
59
+ onChange(kind === "date" ? formatDay(next) : fromLocalParts(next, hour, minute, zone));
60
+ };
61
+ const showMonth = (d) => setMonth({ y: d.y, m: d.m });
62
+ const openAt = (d) => {
63
+ const at = d ?? todayIn(zone);
64
+ showMonth(at);
65
+ setFocus(at);
66
+ setOpen(true);
67
+ };
68
+ const close = () => { setOpen(false); setFocus(null); };
69
+ const pick = (d) => { emitDay(d); setDraft(null); close(); };
70
+ const moveFocus = (d) => { setFocus(d); showMonth(d); };
71
+ const stepMonth = (n) => setMonth((c) => { const next = stepDay({ ...c, d: 1 }, "month", n); return { y: next.y, m: next.m }; });
72
+ const onText = (text) => {
73
+ if (kind === "date") {
74
+ onChange(text === "" ? undefined : text);
75
+ return;
76
+ }
77
+ if (text === "") {
78
+ setDraft(null);
79
+ onChange(undefined);
80
+ return;
81
+ }
82
+ const parsed = parseDay(text);
83
+ if (parsed) {
84
+ setDraft(null);
85
+ emitDay(parsed);
86
+ }
87
+ else
88
+ setDraft(text);
89
+ };
90
+ const onKey = (e) => {
91
+ if (!open) {
92
+ if (e.key === "ArrowDown") {
93
+ e.preventDefault();
94
+ openAt(day);
95
+ }
96
+ return;
97
+ }
98
+ const at = focus ?? day ?? todayIn(zone);
99
+ const steps = {
100
+ ArrowLeft: ["day", -1], ArrowRight: ["day", 1], ArrowUp: ["week", -1], ArrowDown: ["week", 1], PageUp: ["month", -1], PageDown: ["month", 1],
101
+ };
102
+ if (e.key in steps) {
103
+ e.preventDefault();
104
+ const [by, n] = steps[e.key];
105
+ moveFocus(stepDay(at, by, n));
106
+ return;
107
+ }
108
+ if (e.key === "Enter") {
109
+ e.preventDefault();
110
+ pick(at);
111
+ return;
112
+ }
113
+ if (e.key === "Escape") {
114
+ e.preventDefault();
115
+ close();
116
+ }
117
+ };
118
+ const onBlur = (e) => {
119
+ if (root.current && e.relatedTarget instanceof Node && root.current.contains(e.relatedTarget))
120
+ return;
121
+ setDraft(null);
122
+ close();
123
+ };
124
+ const onTime = (h, m) => { if (day)
125
+ onChange(fromLocalParts(day, h, m, zone)); };
126
+ const today = todayIn(zone);
127
+ const hint = kind === "datetime"
128
+ ? configured
129
+ ? `${zone} time · stored as UTC`
130
+ : `Your browser's time (${zone}) · stored as UTC — set timeZone in smoodly.config`
131
+ : null;
132
+ return (_jsxs("div", { ref: root, className: cx("sm-date", kind === "datetime" && "sm-date--time"), onBlur: onBlur, children: [_jsxs("div", { className: "sm-date__row", children: [_jsxs("span", { className: "sm-inputwrap sm-date__wrap", children: [_jsx("input", { className: "sm-input", "aria-label": label ?? "Date", placeholder: "YYYY-MM-DD", value: draft ?? dayText, onChange: (e) => onText(e.target.value), onKeyDown: onKey }), _jsx("button", { ref: btn, type: "button", className: "sm-date__btn", "aria-label": "Open calendar", "aria-expanded": open, onMouseDown: keepFocus, onClick: () => (open ? close() : openAt(day)), children: _jsx(CalendarDays, { size: 16, strokeWidth: 1.75, "aria-hidden": "true" }) })] }), kind === "datetime" && (_jsxs(_Fragment, { children: [_jsx("select", { className: "sm-select sm-date__time", "aria-label": "Hour", value: hour, disabled: !day, onChange: (e) => onTime(Number(e.target.value), minute), children: HOURS.map((h) => _jsx("option", { value: h, children: pad(h) }, h)) }), _jsx("span", { className: "sm-date__colon", "aria-hidden": "true", children: ":" }), _jsx("select", { className: "sm-select sm-date__time", "aria-label": "Minute", value: minute, disabled: !day, onChange: (e) => onTime(hour, Number(e.target.value)), children: minuteOptions(local ? local.minute : null).map((m) => _jsx("option", { value: m, children: pad(m) }, m)) })] }))] }), hint && _jsx("span", { className: "sm-hint", children: hint }), open && (_jsxs("div", { ref: pop, className: cx("sm-cal", up && "sm-cal--up"), style: { left: x }, role: "dialog", "aria-label": "Calendar", onMouseDown: keepFocus, children: [_jsxs("div", { className: "sm-cal__head", children: [_jsx("button", { type: "button", className: "sm-cal__nav", "aria-label": "Previous month", onClick: () => stepMonth(-1), children: _jsx(ChevronLeft, { size: 14, strokeWidth: 1.75, "aria-hidden": "true" }) }), _jsx("span", { className: "sm-cal__month", children: monthLabel(month.y, month.m) }), _jsx("button", { type: "button", className: "sm-cal__nav", "aria-label": "Next month", onClick: () => stepMonth(1), children: _jsx(ChevronRight, { size: 14, strokeWidth: 1.75, "aria-hidden": "true" }) })] }), _jsxs("div", { className: "sm-cal__grid", role: "grid", children: [weekdayLabels().map((w) => _jsx("span", { className: "sm-cal__wd", role: "columnheader", children: w }, w)), monthGrid(month.y, month.m).flat().map((d) => (_jsx("button", { type: "button", role: "gridcell", className: cx("sm-cal__day", d.m !== month.m && "sm-cal__day--out", isSameDay(d, today) && "sm-cal__day--today", focus !== null && isSameDay(d, focus) && "sm-cal__day--focus"), "aria-pressed": day !== null && isSameDay(d, day), tabIndex: -1, onClick: () => pick(d), children: d.d }, formatDay(d))))] }), _jsx("div", { className: "sm-cal__foot", children: _jsx("button", { type: "button", className: "sm-field__action", onClick: () => pick(today), children: "Today" }) })] }))] }));
133
+ }
@@ -6,6 +6,7 @@ import { ListField } from "./ListField.js";
6
6
  import { RefPicker } from "./RefPicker.js";
7
7
  import { TagsField } from "./TagsField.js";
8
8
  import { RowsWidget } from "./RowsWidget.js";
9
+ import { DatePicker } from "./DatePicker.js";
9
10
  import { optionsOf } from "../../fields.js";
10
11
  import { isHidden } from "../../conditions.js";
11
12
  import { cssProblem } from "../../css.js";
@@ -31,14 +32,18 @@ export function FieldWidget({ descriptor: d, value: valueProp, onChange, errors,
31
32
  switch (d.type) {
32
33
  case "text":
33
34
  case "link":
34
- case "slug":
35
- case "date": {
35
+ case "slug": {
36
36
  const input = (_jsx("input", { className: d.type === "slug" ? "sm-input sm-input--mono" : "sm-input", value: String(value ?? ""), onChange: (e) => onChange(e.target.value) }));
37
37
  control = d.type === "slug" && prefix
38
38
  ? _jsxs("span", { className: "sm-inputwrap", children: [_jsx("span", { className: "sm-prefix", children: prefix }), input] })
39
39
  : input;
40
40
  break;
41
41
  }
42
+ case "date":
43
+ case "datetime":
44
+ asDiv = true; // the calendar button and the time selects: never inside one implicit label
45
+ control = _jsx(DatePicker, { kind: d.type, value: value, onChange: onChange, label: label(fieldKey) });
46
+ break;
42
47
  case "number":
43
48
  control = (_jsx("input", { className: "sm-input", type: "number", value: value == null ? "" : String(value), onChange: (e) => onChange(e.target.value === "" ? undefined : Number(e.target.value)) }));
44
49
  break;
@@ -97,9 +102,10 @@ export function FieldWidget({ descriptor: d, value: valueProp, onChange, errors,
97
102
  case "image":
98
103
  case "video":
99
104
  case "file":
100
- asDiv = true; // buttons inside
101
- control = _jsx(MediaWidget, { descriptor: d, value: value, onChange: onChange });
102
- break;
105
+ // The widget renders its own Field: its Edit / Replace / Remove sit in the head.
106
+ return bare
107
+ ? _jsx(MediaWidget, { descriptor: d, value: value, onChange: onChange, error: message, path: path })
108
+ : _jsx(MediaWidget, { descriptor: d, value: value, onChange: onChange, label: label(fieldKey), mark: mark, error: message, path: path });
103
109
  case "ref":
104
110
  case "refList": {
105
111
  asDiv = true; // rows of buttons: never inside one implicit label
@@ -0,0 +1,9 @@
1
+ import type { ReactElement } from "react";
2
+ import type { AssetRecord, ImageValue } from "../../media.ts";
3
+ export declare function ImagePreview({ asset, value, aspect, height }: {
4
+ asset: AssetRecord;
5
+ value: ImageValue;
6
+ /** The field's declared aspect (one string); the value's own wins. */
7
+ aspect?: string;
8
+ height: number;
9
+ }): ReactElement | null;
@@ -0,0 +1,11 @@
1
+ "use client";
2
+ import { jsx as _jsx } from "react/jsx-runtime";
3
+ import { boxAspectOf, frameFor } from "../../image/crop-math.js";
4
+ export function ImagePreview({ asset, value, aspect, height }) {
5
+ if (!asset.width || !asset.height)
6
+ return null;
7
+ const image = { url: asset.url, width: asset.width, height: asset.height, mime: asset.mime, alt: "", crop: value.crop, hotspot: value.hotspot, aspect: value.aspect ?? aspect };
8
+ const box = boxAspectOf(image);
9
+ const f = frameFor(image, box);
10
+ return (_jsx("div", { className: "sm-media__canvas", style: { maxHeight: height }, children: _jsx("div", { className: "sm-media__thumb", style: { aspectRatio: String(box), width: `calc(${height}px * ${box})`, maxHeight: height }, children: _jsx("img", { src: asset.url, alt: "", style: { left: `${f.left}%`, top: `${f.top}%`, width: `${f.width}%`, height: `${f.height}%` } }) }) }));
11
+ }
@@ -1,9 +1,15 @@
1
- import { type ReactElement } from "react";
1
+ import { type ReactElement, type ReactNode } from "react";
2
2
  import type { AssetRecord } from "../../media.ts";
3
3
  import type { AdminField } from "../serialize.ts";
4
- export declare function MediaWidget({ descriptor, value, onChange, asset: preloaded }: {
4
+ export declare function MediaWidget({ descriptor, value, onChange, asset: preloaded, label, mark, hint, error: fieldError, path }: {
5
5
  descriptor: AdminField;
6
6
  value: unknown;
7
7
  onChange(value: unknown): void;
8
8
  asset?: AssetRecord | null;
9
+ /** The field head's label and mark, its hint and error, its reveal path — the widget owns the Field. */
10
+ label?: ReactNode;
11
+ mark?: ReactNode;
12
+ hint?: ReactNode;
13
+ error?: ReactNode;
14
+ path?: string;
9
15
  }): ReactElement;
@@ -1,24 +1,27 @@
1
1
  "use client";
2
- import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
3
  // The media field widget (spec 2026-09-08 media §10): empty is a drop
4
4
  // zone (upload straight away, or open the library); filled is the
5
5
  // thumbnail with the crop applied — contained on a canvas at most
6
6
  // CANVAS_H tall, the frame sized min(CANVAS_H tall, full width) so it
7
- // keeps its aspect — the alt, and Replace / Edit / Remove.
7
+ // keeps its aspect — the alt, and Edit / Replace / Remove as small text
8
+ // actions in the field's head, right of the label (2026-09-25; the same
9
+ // row a richtext image card shows). The widget renders its own Field so
10
+ // the actions can sit in the head.
8
11
  // The record comes from the assets context (cached per id); `asset`
9
12
  // preloads it for static renders.
10
13
  import { useEffect, useState } from "react";
11
- import { frameFor, boxAspectOf } from "../../image/crop-math.js";
12
- import { Button } from "../ui/primitives.js";
14
+ import { Button, Field } from "../ui/primitives.js";
13
15
  import { acceptFor, formatBytes } from "../shell/media-list.js";
14
16
  import { useAssets } from "./assets-context.js";
15
17
  import { uploadFile } from "./upload.js";
16
18
  import { MediaPicker } from "./MediaPicker.js";
17
19
  import { CropEditor } from "./CropEditor.js";
18
20
  import { AssetThumb } from "./MediaGrid.js";
21
+ import { ImagePreview } from "./ImagePreview.js";
19
22
  /** The filled preview's canvas height cap (the stylesheet repeats it). */
20
23
  const CANVAS_H = 300;
21
- export function MediaWidget({ descriptor, value, onChange, asset: preloaded }) {
24
+ export function MediaWidget({ descriptor, value, onChange, asset: preloaded, label, mark, hint, error: fieldError, path }) {
22
25
  const assets = useAssets();
23
26
  const kind = descriptor.type;
24
27
  const id = typeof value?.asset === "string" ? value.asset : null;
@@ -75,16 +78,13 @@ export function MediaWidget({ descriptor, value, onChange, asset: preloaded }) {
75
78
  }
76
79
  else {
77
80
  const v = value;
78
- const preview = asset.kind === "image" && asset.width && asset.height ? (() => {
79
- const image = { url: asset.url, width: asset.width, height: asset.height, mime: asset.mime, alt: "", crop: v.crop, hotspot: v.hotspot, aspect: v.aspect ?? (typeof descriptor.aspect === "string" ? descriptor.aspect : undefined) };
80
- const box = boxAspectOf(image);
81
- const f = frameFor(image, box);
82
- return (_jsx("div", { className: "sm-media__canvas", children: _jsx("div", { className: "sm-media__thumb", style: { aspectRatio: String(box), width: `calc(${CANVAS_H}px * ${box})` }, children: _jsx("img", { src: asset.url, alt: "", style: { left: `${f.left}%`, top: `${f.top}%`, width: `${f.width}%`, height: `${f.height}%` } }) }) }));
83
- })() : asset.kind === "video" ? (() => {
81
+ const preview = asset.kind === "image" && asset.width && asset.height ? (_jsx(ImagePreview, { asset: asset, value: v, aspect: typeof descriptor.aspect === "string" ? descriptor.aspect : undefined, height: CANVAS_H })) : asset.kind === "video" ? (() => {
84
82
  const ratio = asset.width && asset.height ? asset.width / asset.height : 16 / 9;
85
83
  return (_jsx("div", { className: "sm-media__canvas", children: _jsx("div", { className: "sm-media__thumb", style: { aspectRatio: String(ratio), width: `calc(${CANVAS_H}px * ${ratio})` }, children: _jsx(AssetThumb, { asset: asset, className: "sm-mediacard__img" }) }) }));
86
84
  })() : (_jsxs("div", { className: "sm-media__chip", children: [_jsx("span", { style: { fontWeight: 500 }, children: asset.filename }), _jsx("span", { className: "sm-media__alt", children: formatBytes(asset.size) })] }));
87
- body = (_jsxs("div", { className: "sm-media", children: [preview, asset.kind === "image" && _jsx("span", { className: "sm-media__alt", children: v.alt ?? asset.alt ?? "No alt text" }), _jsxs("div", { className: "sm-media__actions", children: [_jsx(Button, { variant: "secondary", onClick: () => setPicker(true), children: "Replace" }), asset.kind === "image" && _jsx(Button, { variant: "secondary", onClick: () => setCrop(true), children: "Edit" }), _jsx(Button, { variant: "secondary", onClick: () => onChange(undefined), children: "Remove" })] })] }));
85
+ body = (_jsxs("div", { className: "sm-media", children: [preview, asset.kind === "image" && _jsx("span", { className: "sm-media__alt", children: v.alt ?? asset.alt ?? "No alt text" })] }));
88
86
  }
89
- return (_jsxs(_Fragment, { children: [body, error && _jsx("span", { className: "sm-error", children: error }), picker && _jsx(MediaPicker, { kinds: [kind], onPick: select, onClose: () => setPicker(false) }), crop && asset && (_jsx(CropEditor, { asset: asset, descriptor: descriptor, value: value, onSave: (next) => { onChange(next); setCrop(false); }, onClose: () => setCrop(false) }))] }));
87
+ // Edit only for an image that has its dimensions (the crop editor's frame needs them).
88
+ const actions = id && asset ? (_jsxs("span", { className: "sm-field__actions", children: [asset.kind === "image" && asset.width && asset.height && (_jsx("button", { type: "button", className: "sm-field__action", onClick: () => setCrop(true), children: "Edit" })), _jsx("button", { type: "button", className: "sm-field__action", onClick: () => setPicker(true), children: "Replace" }), _jsx("button", { type: "button", className: "sm-field__action", onClick: () => onChange(undefined), children: "Remove" })] })) : undefined;
89
+ return (_jsxs(Field, { label: label, mark: mark, hint: hint, error: fieldError, path: path, action: actions, asDiv: true, children: [body, error && _jsx("span", { className: "sm-error", children: error }), picker && _jsx(MediaPicker, { kinds: [kind], onPick: select, onClose: () => setPicker(false) }), crop && asset && (_jsx(CropEditor, { asset: asset, descriptor: descriptor, value: value, onSave: (next) => { onChange(next); setCrop(false); }, onClose: () => setCrop(false) }))] }));
90
90
  }
@@ -39,5 +39,5 @@ export function RichtextElementCard({ node, editor, getPos, updateAttributes, de
39
39
  const scope = { fields, settings };
40
40
  const visibleFields = Object.entries(element?.fields ?? {}).filter(([, d]) => !isHidden(d, { self: fields, ...scope }));
41
41
  const visibleSettings = Object.entries(element?.settings ?? {}).filter(([, d]) => !isHidden(d, { self: settings, ...scope }));
42
- return (_jsxs(NodeViewWrapper, { className: cardClass(selected, form.canvas), "data-theme": form.canvas ? "light" : undefined, children: [_jsxs("div", { className: "sm-rtcard__head", "data-drag-handle": true, onClick: select, children: [_jsx("span", { className: "sm-rtcard__icon", "aria-hidden": "true", children: element?.icon ?? "▤" }), _jsx("span", { className: "sm-rtcard__title", children: element?.title ?? `Unknown element "${name}"` }), _jsx(IconButton, { label: open ? "Collapse" : "Expand", onClick: (e) => { e.stopPropagation(); setOpen((o) => !o); }, children: open ? "▾" : "▸" }), _jsx(IconButton, { label: "Delete element", onClick: (e) => { e.stopPropagation(); deleteNode(); }, children: "\u00D7" })] }), open && element && (_jsxs("div", { className: "sm-rtcard__body", contentEditable: false, children: [visibleFields.map(([key, d]) => (_jsx(FieldWidget, { fieldKey: key, descriptor: d, value: fields[key], errors: form.errors, path: `${base}.${key}`, scope: scope, onChange: (v) => updateAttributes({ fields: { ...fields, [key]: v } }) }, key))), visibleSettings.length > 0 && _jsx(SectionLabel, { mono: true, children: "Settings" }), visibleSettings.map(([key, d]) => (_jsx(FieldWidget, { fieldKey: key, descriptor: d, value: settings[key], errors: form.errors, path: `${base}.settings.${key}`, scope: scope, onChange: (v) => updateAttributes({ settings: { ...settings, [key]: v } }) }, `setting-${key}`)))] }))] }));
42
+ return (_jsxs(NodeViewWrapper, { className: cardClass(selected, form.canvas), "data-theme": form.canvas ? "light" : undefined, children: [_jsxs("div", { className: "sm-rtcard__head", "data-drag-handle": true, onClick: select, children: [_jsx("span", { className: "sm-rtcard__icon", "aria-hidden": "true", children: element?.icon ?? "▤" }), _jsx("span", { className: "sm-rtcard__title", children: element?.title ?? `Unknown element "${name}"` }), _jsx(IconButton, { label: open ? "Collapse" : "Expand", onClick: (e) => { e.stopPropagation(); setOpen((o) => !o); }, children: open ? "▾" : "▸" }), _jsx(IconButton, { label: "Delete element", onClick: (e) => { e.stopPropagation(); deleteNode(); }, children: "\u00D7" })] }), open && element && (_jsxs("div", { className: "sm-rtcard__body", contentEditable: false, children: [visibleFields.map(([key, d]) => (_jsx(FieldWidget, { fieldKey: key, descriptor: d, value: fields[key], errors: form.errors, path: `${base}.${key}`, scope: scope, onChange: (v) => updateAttributes({ fields: { ...fields, [key]: v } }) }, key))), visibleSettings.length > 0 && _jsx(SectionLabel, { caps: true, children: "Settings" }), visibleSettings.map(([key, d]) => (_jsx(FieldWidget, { fieldKey: key, descriptor: d, value: settings[key], errors: form.errors, path: `${base}.settings.${key}`, scope: scope, onChange: (v) => updateAttributes({ settings: { ...settings, [key]: v } }) }, `setting-${key}`)))] }))] }));
43
43
  }
@@ -1,16 +1,19 @@
1
1
  "use client";
2
2
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
- // The image node view (spec 2026-09-08 block nodes §3): thumbnail,
4
- // filename, an alt input whose placeholder is the library's alt, a
5
- // caption input, Replace and Delete. Attribute edits go through
6
- // updateAttributes so the outer history sees them. Inputs stop keys from
7
- // reaching the editor (TipTap's node view stops events from form
8
- // controls), so Backspace in the caption never deletes the node.
3
+ // The image node view (spec 2026-09-08 block nodes §3): the cropped
4
+ // frame, the caption as centred text, and in the head Edit /
5
+ // Replace / Remove — the media field's three, as the same small text
6
+ // actions (2026-09-25). Edit is the crop editor, which also carries the
7
+ // alt and the caption: the card itself holds no inputs. The node's
8
+ // `image` attr is an ImageValue like the field's, so a crop and hotspot
9
+ // store there; the bound renderer applies them. Attribute edits go
10
+ // through updateAttributes so the outer history sees them.
9
11
  import { useEffect, useState } from "react";
10
12
  import { NodeViewWrapper } from "@tiptap/react";
11
- import { IconButton } from "../ui/primitives.js";
12
13
  import { useAssets } from "./assets-context.js";
13
14
  import { MediaPicker } from "./MediaPicker.js";
15
+ import { CropEditor } from "./CropEditor.js";
16
+ import { ImagePreview } from "./ImagePreview.js";
14
17
  import { cardClass, useRichtextForm } from "./richtext-form-context.js";
15
18
  export function RichtextImageCard({ node, editor, getPos, updateAttributes, deleteNode, selected }) {
16
19
  const assets = useAssets();
@@ -20,6 +23,7 @@ export function RichtextImageCard({ node, editor, getPos, updateAttributes, dele
20
23
  const id = typeof value.asset === "string" ? value.asset : null;
21
24
  const [asset, setAsset] = useState(null);
22
25
  const [picker, setPicker] = useState(false);
26
+ const [crop, setCrop] = useState(false);
23
27
  useEffect(() => {
24
28
  if (!id) {
25
29
  setAsset(null);
@@ -32,11 +36,12 @@ export function RichtextImageCard({ node, editor, getPos, updateAttributes, dele
32
36
  }, [id, assets]);
33
37
  const select = () => { const pos = getPos(); if (typeof pos === "number")
34
38
  editor.commands.setNodeSelection(pos); };
35
- const setAlt = (alt) => updateAttributes({ image: alt === "" ? { asset: id } : { asset: id, alt } });
39
+ // A new asset keeps the alt and drops the crop: the frame was drawn on the old picture.
36
40
  const replace = (record) => {
37
41
  assets.remember(record);
38
42
  setPicker(false);
39
43
  updateAttributes({ image: value.alt ? { asset: record.id, alt: value.alt } : { asset: record.id } });
40
44
  };
41
- return (_jsxs(NodeViewWrapper, { className: cardClass(selected, form.canvas), "data-theme": form.canvas ? "light" : undefined, "data-drag-handle": true, children: [_jsxs("div", { className: "sm-rtcard__head", onClick: select, children: [_jsx("span", { className: "sm-rtcard__icon", "aria-hidden": "true", children: "\u25A3" }), _jsx("span", { className: "sm-rtcard__title", children: "Image" }), _jsx(IconButton, { label: "Replace image", onClick: () => setPicker(true), children: "\u21BB" }), _jsx(IconButton, { label: "Delete image", onClick: () => deleteNode(), children: "\u00D7" })] }), _jsxs("div", { className: "sm-rtcard__body", contentEditable: false, children: [asset ? _jsx("img", { className: "sm-rtcard__thumb", src: asset.url, alt: "" }) : _jsx("div", { className: "sm-rtcard__thumb", style: { height: 80 } }), asset && _jsx("span", { className: "sm-rtcard__file", children: asset.filename }), _jsx("input", { className: "sm-input", "aria-label": "Alt text", placeholder: asset?.alt ?? "Alt text", value: value.alt ?? "", onChange: (e) => setAlt(e.target.value) }), _jsx("input", { className: "sm-input", "aria-label": "Caption", placeholder: "Caption", value: caption, onChange: (e) => updateAttributes({ caption: e.target.value }) })] }), picker && _jsx(MediaPicker, { kinds: ["image"], onPick: replace, onClose: () => setPicker(false) })] }));
45
+ const editable = asset !== null && asset.kind === "image" && !!asset.width && !!asset.height;
46
+ return (_jsxs(NodeViewWrapper, { className: cardClass(selected, form.canvas), "data-theme": form.canvas ? "light" : undefined, "data-drag-handle": true, children: [_jsxs("div", { className: "sm-rtcard__head", onClick: select, children: [_jsx("span", { className: "sm-rtcard__icon", "aria-hidden": "true", children: "\u25A3" }), _jsx("span", { className: "sm-rtcard__title", children: "Image" }), _jsxs("span", { className: "sm-field__actions", children: [editable && _jsx("button", { type: "button", className: "sm-field__action", onClick: () => setCrop(true), children: "Edit" }), _jsx("button", { type: "button", className: "sm-field__action", onClick: () => setPicker(true), children: "Replace" }), _jsx("button", { type: "button", className: "sm-field__action", onClick: () => deleteNode(), children: "Remove" })] })] }), _jsxs("div", { className: "sm-rtcard__body", contentEditable: false, children: [asset && id ? _jsx(ImagePreview, { asset: asset, value: { ...value, asset: id }, height: 160 }) : _jsx("div", { className: "sm-rtcard__thumb", style: { height: 80 } }), caption !== "" && _jsx("span", { className: "sm-rtcard__caption", children: caption })] }), picker && _jsx(MediaPicker, { kinds: ["image"], onPick: replace, onClose: () => setPicker(false) }), crop && asset && id && (_jsx(CropEditor, { asset: asset, descriptor: { type: "image" }, value: { ...value, asset: id }, caption: caption, onSave: (next, cap) => { updateAttributes({ image: next, caption: cap ?? "" }); setCrop(false); }, onClose: () => setCrop(false) }))] }));
42
47
  }
@@ -0,0 +1,44 @@
1
+ /** A calendar day; `m` is 1–12. */
2
+ export type Day = {
3
+ y: number;
4
+ m: number;
5
+ d: number;
6
+ };
7
+ export declare function daysInMonth(y: number, m: number): number;
8
+ /** Strict `YYYY-MM-DD`, and a day the calendar has (no 2026-02-30). */
9
+ export declare function parseDay(s: string): Day | null;
10
+ export declare const formatDay: (day: Day) => string;
11
+ export declare const isSameDay: (a: Day, b: Day) => boolean;
12
+ export declare const isCalendarDay: (s: string) => boolean;
13
+ /** A day `n` days, weeks or months on (negative: back). A month step keeps the day, clamped to the target month's length. */
14
+ export declare function stepDay(day: Day, by: "day" | "week" | "month", n: number): Day;
15
+ /** Six rows of seven, Monday first, the month's days padded with the neighbours'. */
16
+ export declare function monthGrid(y: number, m: number): Day[][];
17
+ /** "September 2026" in a language; English when none is given (static renders). */
18
+ export declare function monthLabel(y: number, m: number, locale?: string): string;
19
+ /** Short weekday names, Monday first (2024-01-01 was a Monday). */
20
+ export declare function weekdayLabels(locale?: string): string[];
21
+ /** Strict ISO to the minute, seconds 00, Z. */
22
+ export declare function parseInstant(s: string): Date | null;
23
+ export declare const formatInstant: (date: Date) => string;
24
+ export declare const isInstant: (s: string) => boolean;
25
+ /** The clock of a zone at an instant, through Intl; `hourCycle: "h23"` so midnight is 0, never 24. */
26
+ export declare function zoneParts(date: Date, zone: string): {
27
+ day: Day;
28
+ hour: number;
29
+ minute: number;
30
+ };
31
+ export declare function toLocalParts(iso: string, zone: string): {
32
+ day: Day;
33
+ hour: number;
34
+ minute: number;
35
+ } | null;
36
+ /** The instant at which the zone's clock reads the given wall time. A
37
+ * fixed point: read the zone's clock at a guess, shift by the difference,
38
+ * twice for a daylight-saving edge. A wall time that does not exist (the
39
+ * spring-forward gap) lands on the instant an hour on. */
40
+ export declare function fromLocalParts(day: Day, hour: number, minute: number, zone: string): string;
41
+ export declare function todayIn(zone: string, now?: Date): Day;
42
+ export declare const HOURS: number[];
43
+ /** The five-minute grid, plus a stored minute that is not on it. */
44
+ export declare function minuteOptions(current: number | null): number[];
@@ -0,0 +1,117 @@
1
+ // Pure calendar and instant math for the date and datetime pickers (spec
2
+ // 2026-09-25 date pickers §3). No DOM. Day arithmetic runs in UTC and
3
+ // every zone conversion goes through Intl parts with an explicit zone —
4
+ // never Date's local getters — so a test can pin any zone without
5
+ // touching the process zone, and a browser's own zone never leaks in.
6
+ const DAY_RE = /^(\d{4})-(\d{2})-(\d{2})$/;
7
+ const INSTANT_RE = /^(\d{4}-\d{2}-\d{2})T(\d{2}):(\d{2}):00Z$/;
8
+ const MS_PER_DAY = 86400000;
9
+ const pad = (n) => String(n).padStart(2, "0");
10
+ export function daysInMonth(y, m) {
11
+ return new Date(Date.UTC(y, m, 0)).getUTCDate();
12
+ }
13
+ /** Strict `YYYY-MM-DD`, and a day the calendar has (no 2026-02-30). */
14
+ export function parseDay(s) {
15
+ const m = DAY_RE.exec(s);
16
+ if (!m)
17
+ return null;
18
+ const day = { y: Number(m[1]), m: Number(m[2]), d: Number(m[3]) };
19
+ if (day.m < 1 || day.m > 12 || day.d < 1 || day.d > daysInMonth(day.y, day.m))
20
+ return null;
21
+ return day;
22
+ }
23
+ export const formatDay = (day) => `${String(day.y).padStart(4, "0")}-${pad(day.m)}-${pad(day.d)}`;
24
+ export const isSameDay = (a, b) => a.y === b.y && a.m === b.m && a.d === b.d;
25
+ export const isCalendarDay = (s) => parseDay(s) !== null;
26
+ const dayOf = (t) => {
27
+ const dt = new Date(t);
28
+ return { y: dt.getUTCFullYear(), m: dt.getUTCMonth() + 1, d: dt.getUTCDate() };
29
+ };
30
+ /** A day `n` days, weeks or months on (negative: back). A month step keeps the day, clamped to the target month's length. */
31
+ export function stepDay(day, by, n) {
32
+ if (by === "month") {
33
+ const total = day.y * 12 + (day.m - 1) + n;
34
+ const y = Math.floor(total / 12);
35
+ const m = total - y * 12 + 1;
36
+ return { y, m, d: Math.min(day.d, daysInMonth(y, m)) };
37
+ }
38
+ return dayOf(Date.UTC(day.y, day.m - 1, day.d) + n * (by === "week" ? 7 : 1) * MS_PER_DAY);
39
+ }
40
+ /** Six rows of seven, Monday first, the month's days padded with the neighbours'. */
41
+ export function monthGrid(y, m) {
42
+ const lead = (new Date(Date.UTC(y, m - 1, 1)).getUTCDay() + 6) % 7; // Monday = 0
43
+ let day = stepDay({ y, m, d: 1 }, "day", -lead);
44
+ const rows = [];
45
+ for (let r = 0; r < 6; r++) {
46
+ const row = [];
47
+ for (let c = 0; c < 7; c++) {
48
+ row.push(day);
49
+ day = stepDay(day, "day", 1);
50
+ }
51
+ rows.push(row);
52
+ }
53
+ return rows;
54
+ }
55
+ /** "September 2026" in a language; English when none is given (static renders). */
56
+ export function monthLabel(y, m, locale = "en") {
57
+ return new Intl.DateTimeFormat(locale, { month: "long", year: "numeric", timeZone: "UTC" }).format(new Date(Date.UTC(y, m - 1, 1)));
58
+ }
59
+ /** Short weekday names, Monday first (2024-01-01 was a Monday). */
60
+ export function weekdayLabels(locale = "en") {
61
+ const f = new Intl.DateTimeFormat(locale, { weekday: "short", timeZone: "UTC" });
62
+ return [0, 1, 2, 3, 4, 5, 6].map((i) => f.format(new Date(Date.UTC(2024, 0, 1 + i))));
63
+ }
64
+ /** Strict ISO to the minute, seconds 00, Z. */
65
+ export function parseInstant(s) {
66
+ const m = INSTANT_RE.exec(s);
67
+ if (!m)
68
+ return null;
69
+ const day = parseDay(m[1]);
70
+ if (!day)
71
+ return null;
72
+ const hour = Number(m[2]), minute = Number(m[3]);
73
+ if (hour > 23 || minute > 59)
74
+ return null;
75
+ return new Date(Date.UTC(day.y, day.m - 1, day.d, hour, minute));
76
+ }
77
+ export const formatInstant = (date) => `${formatDay(dayOf(date.getTime()))}T${pad(date.getUTCHours())}:${pad(date.getUTCMinutes())}:00Z`;
78
+ export const isInstant = (s) => parseInstant(s) !== null;
79
+ /** The clock of a zone at an instant, through Intl; `hourCycle: "h23"` so midnight is 0, never 24. */
80
+ export function zoneParts(date, zone) {
81
+ const parts = new Intl.DateTimeFormat("en-US", {
82
+ timeZone: zone, hourCycle: "h23", year: "numeric", month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit",
83
+ }).formatToParts(date);
84
+ const get = (type) => Number(parts.find((p) => p.type === type)?.value);
85
+ return { day: { y: get("year"), m: get("month"), d: get("day") }, hour: get("hour") % 24, minute: get("minute") };
86
+ }
87
+ export function toLocalParts(iso, zone) {
88
+ const date = parseInstant(iso);
89
+ return date ? zoneParts(date, zone) : null;
90
+ }
91
+ /** The instant at which the zone's clock reads the given wall time. A
92
+ * fixed point: read the zone's clock at a guess, shift by the difference,
93
+ * twice for a daylight-saving edge. A wall time that does not exist (the
94
+ * spring-forward gap) lands on the instant an hour on. */
95
+ export function fromLocalParts(day, hour, minute, zone) {
96
+ const want = Date.UTC(day.y, day.m - 1, day.d, hour, minute);
97
+ let guess = want;
98
+ for (let i = 0; i < 2; i++) {
99
+ const p = zoneParts(new Date(guess), zone);
100
+ const wall = Date.UTC(p.day.y, p.day.m - 1, p.day.d, p.hour, p.minute);
101
+ if (wall === want)
102
+ break;
103
+ guess += want - wall;
104
+ }
105
+ return formatInstant(new Date(guess));
106
+ }
107
+ export function todayIn(zone, now = new Date()) {
108
+ return zoneParts(now, zone).day;
109
+ }
110
+ export const HOURS = Array.from({ length: 24 }, (_, i) => i);
111
+ /** The five-minute grid, plus a stored minute that is not on it. */
112
+ export function minuteOptions(current) {
113
+ const grid = Array.from({ length: 12 }, (_, i) => i * 5);
114
+ if (current === null || grid.includes(current))
115
+ return grid;
116
+ return [...grid, current].sort((a, b) => a - b);
117
+ }
@@ -1,5 +1,8 @@
1
1
  import { type ReactElement, type ReactNode } from "react";
2
2
  import type { AdminElement } from "../serialize.ts";
3
+ /** The list is handed on by content: the registry is re-serialized on every
4
+ * server render (each autosave), and the richtext editor's deps must not
5
+ * see a new array for the same elements (see useRichtextEditor, 2026-09-26). */
3
6
  export declare function ElementsProvider({ elements, children }: {
4
7
  elements: AdminElement[];
5
8
  children: ReactNode;
@@ -3,10 +3,15 @@ import { jsx as _jsx } from "react/jsx-runtime";
3
3
  // The serialized element descriptors, for the richtext widget's insert
4
4
  // menu and element cards. Defaults to none, so a form rendered without
5
5
  // the shell (tests, a bare page) still works.
6
- import { createContext, useContext } from "react";
6
+ import { createContext, useContext, useMemo } from "react";
7
7
  const Ctx = createContext([]);
8
+ /** The list is handed on by content: the registry is re-serialized on every
9
+ * server render (each autosave), and the richtext editor's deps must not
10
+ * see a new array for the same elements (see useRichtextEditor, 2026-09-26). */
8
11
  export function ElementsProvider({ elements, children }) {
9
- return _jsx(Ctx.Provider, { value: elements, children: children });
12
+ const key = JSON.stringify(elements);
13
+ const stable = useMemo(() => elements, [key]); // eslint-disable-line react-hooks/exhaustive-deps
14
+ return _jsx(Ctx.Provider, { value: stable, children: children });
10
15
  }
11
16
  export function useElements() {
12
17
  return useContext(Ctx);
@@ -0,0 +1,7 @@
1
+ import { type ReactElement, type ReactNode } from "react";
2
+ export declare function TimeZoneProvider({ zone, children }: {
3
+ zone?: string;
4
+ children: ReactNode;
5
+ }): ReactElement;
6
+ /** The configured zone, or undefined. */
7
+ export declare function useTimeZone(): string | undefined;