smoodly 0.0.25 → 0.0.27
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 +66 -2
- package/dist/admin/editor/EditorView.js +82 -18
- package/dist/admin/editor/autosave.d.ts +4 -1
- package/dist/admin/editor/autosave.js +24 -1
- package/dist/admin/editor/global-edit.d.ts +5 -1
- package/dist/admin/editor/global-edit.js +15 -5
- package/dist/admin/editor/useGlobalEdit.d.ts +1 -1
- package/dist/admin/editor/useGlobalEdit.js +1 -1
- package/dist/admin/next/create-admin.d.ts +4 -0
- package/dist/admin/next/create-admin.js +3 -1
- package/dist/admin/next/index.d.ts +1 -0
- package/dist/admin/ops-impl.js +70 -24
- package/dist/admin/ops.d.ts +22 -12
- package/dist/admin/shell/AdminApp.d.ts +3 -1
- package/dist/admin/shell/AdminApp.js +36 -3
- package/dist/admin/shell/EntriesList.js +5 -1
- package/dist/admin/shell/EntryForm.js +44 -7
- package/dist/admin/shell/GlobalForm.js +46 -7
- package/dist/admin/shell/GlobalList.js +6 -2
- package/dist/admin/shell/PagesList.js +5 -1
- package/dist/admin/shell/presence-context.d.ts +9 -0
- package/dist/admin/shell/presence-context.js +35 -0
- package/dist/admin/shell/presence-realtime.d.ts +3 -0
- package/dist/admin/shell/presence-realtime.js +32 -0
- package/dist/admin/shell/presence.d.ts +69 -0
- package/dist/admin/shell/presence.js +103 -0
- package/dist/admin/shell/stale.d.ts +28 -0
- package/dist/admin/shell/stale.js +17 -0
- package/dist/admin/ui/LockIcon.d.ts +4 -0
- package/dist/admin/ui/LockIcon.js +12 -0
- package/dist/admin/ui/PresenceLine.d.ts +6 -0
- package/dist/admin/ui/PresenceLine.js +11 -0
- package/dist/admin/ui/StaleBanner.d.ts +5 -0
- package/dist/admin/ui/StaleBanner.js +9 -0
- package/dist/admin/ui/primitives.d.ts +4 -1
- package/dist/admin/ui/primitives.js +5 -2
- package/dist/admin/ui/theme.js +4 -0
- package/dist/draft-token.d.ts +31 -0
- package/dist/draft-token.js +41 -0
- package/dist/entry-store.d.ts +11 -8
- package/dist/entry-store.js +24 -10
- package/dist/env.d.ts +9 -0
- package/dist/env.js +19 -0
- package/dist/index.d.ts +4 -2
- package/dist/index.js +2 -1
- package/dist/schema-version.d.ts +1 -1
- package/dist/schema-version.js +1 -1
- package/dist/sql-space.d.ts +6 -0
- package/dist/sql-space.js +41 -3
- package/dist/sql.js +16 -4
- package/dist/store.d.ts +11 -8
- package/dist/store.js +26 -8
- package/dist/supabase-entry-store.d.ts +15 -5
- package/dist/supabase-entry-store.js +87 -22
- package/dist/supabase-store.d.ts +15 -5
- package/dist/supabase-store.js +60 -20
- package/migrations/20260926130000_smoodly_v2.sql +26 -0
- package/migrations/20260926140000_smoodly_v3.sql +38 -0
- package/package.json +1 -1
|
@@ -31,6 +31,12 @@ import { GlobalFields } from "./GlobalFields.js";
|
|
|
31
31
|
import { localeFromUrl, writeLocaleToUrl } from "../editor/locale-url.js";
|
|
32
32
|
import { useLeaveGuard } from "./router.js";
|
|
33
33
|
import { confirm, DISCARD_CONFIRM } from "../ui/confirm.js";
|
|
34
|
+
import { draftToken } from "../../draft-token.js";
|
|
35
|
+
import { isStale, staleInfoOf } from "./stale.js";
|
|
36
|
+
import { StaleBanner } from "../ui/StaleBanner.js";
|
|
37
|
+
import { usePresence, usePresenceState, useSavedElsewhere, useTrackPresence } from "./presence-context.js";
|
|
38
|
+
import { editorsOf } from "./presence.js";
|
|
39
|
+
import { PresenceLine } from "../ui/PresenceLine.js";
|
|
34
40
|
export function GlobalForm({ ops, registry, name }) {
|
|
35
41
|
const adminHref = useAdminHref();
|
|
36
42
|
const item = registry.globals.find((s) => s.name === name);
|
|
@@ -48,15 +54,29 @@ export function GlobalForm({ ops, registry, name }) {
|
|
|
48
54
|
const [reloadKey, setReloadKey] = useState(0);
|
|
49
55
|
const [history, setHistory] = useState(null);
|
|
50
56
|
const [snapshot, setSnapshot] = useState(null);
|
|
57
|
+
const [stale, setStale] = useState(null);
|
|
51
58
|
const formRef = useRef(null);
|
|
52
59
|
const fieldsRef = useRef(fields);
|
|
53
60
|
fieldsRef.current = fields;
|
|
54
|
-
const
|
|
61
|
+
const recordRef = useRef(record);
|
|
62
|
+
recordRef.current = record;
|
|
63
|
+
/** The token of the row on screen — what every guarded op sends (spec 2026-09-26 concurrency §1). */
|
|
64
|
+
const token = () => { const row = recordRef.current?.locales[locale]; return row ? draftToken(row) : null; };
|
|
65
|
+
// Presence (spec 2026-09-26 concurrency §5): this tab on this item, the others on it, every ack announced.
|
|
66
|
+
const target = { kind: "global", id: name, locale };
|
|
67
|
+
useTrackPresence(target);
|
|
68
|
+
const { others } = usePresenceState();
|
|
69
|
+
const presence = usePresence();
|
|
70
|
+
const { saveState, mark, flush, reset } = useDraftAutosave({
|
|
55
71
|
read: () => fieldsRef.current,
|
|
56
72
|
persist: async (value) => {
|
|
57
|
-
const r = await ops.globals.save(name, locale, value);
|
|
58
|
-
if (r.ok)
|
|
73
|
+
const r = await ops.globals.save(name, locale, value, token());
|
|
74
|
+
if (r.ok) {
|
|
59
75
|
setRecord(r.data);
|
|
76
|
+
presence?.announce(target, r.data.locales[locale]?.draftSavedAt ?? null);
|
|
77
|
+
}
|
|
78
|
+
else if (isStale(r))
|
|
79
|
+
setStale(staleInfoOf(r));
|
|
60
80
|
return r;
|
|
61
81
|
},
|
|
62
82
|
onSaved: () => setDirty(false),
|
|
@@ -77,6 +97,7 @@ export function GlobalForm({ ops, registry, name }) {
|
|
|
77
97
|
setFields(null);
|
|
78
98
|
setFieldErrors({});
|
|
79
99
|
setDirty(false);
|
|
100
|
+
setStale(null);
|
|
80
101
|
setTouched(false);
|
|
81
102
|
ops.globals.get(name, locale).then((r) => {
|
|
82
103
|
if (cancelled)
|
|
@@ -97,6 +118,10 @@ export function GlobalForm({ ops, registry, name }) {
|
|
|
97
118
|
const row = record?.locales[locale];
|
|
98
119
|
const status = row ? localeStatusOf(row) : null;
|
|
99
120
|
const fail = (r) => {
|
|
121
|
+
if (isStale(r)) {
|
|
122
|
+
setStale(staleInfoOf(r));
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
100
125
|
if (r.code === "validation" && r.fields && r.fields.length > 0) {
|
|
101
126
|
setFieldErrors(Object.fromEntries(r.fields.map((f) => [f.field, f.message])));
|
|
102
127
|
setNotice({ tone: "warn", text: r.message, fields: r.fields });
|
|
@@ -131,7 +156,7 @@ export function GlobalForm({ ops, registry, name }) {
|
|
|
131
156
|
}
|
|
132
157
|
let r;
|
|
133
158
|
try {
|
|
134
|
-
r = await ops.globals.setStatus(name, locale, "published");
|
|
159
|
+
r = await ops.globals.setStatus(name, locale, "published", token());
|
|
135
160
|
}
|
|
136
161
|
catch {
|
|
137
162
|
r = { ok: false, code: "internal", message: "Couldn't reach the server." };
|
|
@@ -142,6 +167,7 @@ export function GlobalForm({ ops, registry, name }) {
|
|
|
142
167
|
return;
|
|
143
168
|
}
|
|
144
169
|
setRecord(r.data);
|
|
170
|
+
presence?.announce(target, r.data.locales[locale]?.draftSavedAt ?? null);
|
|
145
171
|
setFieldErrors({});
|
|
146
172
|
setPublished(true);
|
|
147
173
|
setTimeout(() => setPublished(false), 1600);
|
|
@@ -174,7 +200,7 @@ export function GlobalForm({ ops, registry, name }) {
|
|
|
174
200
|
}
|
|
175
201
|
let r;
|
|
176
202
|
try {
|
|
177
|
-
r = await ops.globals.discardDraft(name, locale);
|
|
203
|
+
r = await ops.globals.discardDraft(name, locale, token());
|
|
178
204
|
}
|
|
179
205
|
catch {
|
|
180
206
|
r = { ok: false, code: "internal", message: "Couldn't reach the server." };
|
|
@@ -185,6 +211,7 @@ export function GlobalForm({ ops, registry, name }) {
|
|
|
185
211
|
return;
|
|
186
212
|
}
|
|
187
213
|
setRecord(r.data);
|
|
214
|
+
presence?.announce(target, r.data.locales[locale]?.draftSavedAt ?? null);
|
|
188
215
|
setReloadKey((k) => k + 1);
|
|
189
216
|
setNotice({ tone: "ok", text: "Changes discarded." });
|
|
190
217
|
};
|
|
@@ -210,13 +237,25 @@ export function GlobalForm({ ops, registry, name }) {
|
|
|
210
237
|
else
|
|
211
238
|
fail(r);
|
|
212
239
|
};
|
|
240
|
+
/** Load their version (spec 2026-09-26 concurrency §4): drop the frozen state and re-get through the ops seam. */
|
|
241
|
+
const loadTheirs = () => { setStale(null); reset(); setReloadKey((k) => k + 1); };
|
|
242
|
+
// Someone else saved this item (§5): clean → their version silently; unsaved edits → the freeze at once.
|
|
243
|
+
useSavedElsewhere(target, (event) => {
|
|
244
|
+
if (stale)
|
|
245
|
+
return;
|
|
246
|
+
if (dirty || saveState !== "saved")
|
|
247
|
+
setStale({ by: event.email, at: Date.now() });
|
|
248
|
+
else
|
|
249
|
+
setReloadKey((k) => k + 1);
|
|
250
|
+
});
|
|
213
251
|
const restore = async (versionId) => {
|
|
214
|
-
const r = await ops.globals.restore(name, locale, versionId);
|
|
252
|
+
const r = await ops.globals.restore(name, locale, versionId, token());
|
|
215
253
|
if (!r.ok) {
|
|
216
254
|
fail(r);
|
|
217
255
|
return false;
|
|
218
256
|
}
|
|
219
257
|
setRecord(r.data);
|
|
258
|
+
presence?.announce(target, r.data.locales[locale]?.draftSavedAt ?? null);
|
|
220
259
|
setReloadKey((k) => k + 1);
|
|
221
260
|
setNotice({ tone: "ok", text: "Version restored." });
|
|
222
261
|
return true;
|
|
@@ -259,6 +298,6 @@ export function GlobalForm({ ops, registry, name }) {
|
|
|
259
298
|
trailing: multilingual && (_jsx(LocaleSwitcher, { registry: registry, present: registry.locales.supported, parentLocales: [], value: locale, adding: false, onSwitch: (l) => { void switchLocale(l); }, onAdd: () => undefined })),
|
|
260
299
|
},
|
|
261
300
|
{ label: item.title },
|
|
262
|
-
] }) }), right: _jsxs(_Fragment, { children: [(touched || saveState === "error") && _jsx(SaveIndicator, { state: saveState }), _jsx(PublishControl, { status: status, pending: dirty || saveState !== "saved", busy: busy, flash: published, disabled: !row, onPublish: () => { void publish(); }, onUnpublish: () => { void unpublish(); }, onDiscard: () => { void discard(); }, onHistory: () => { void openHistory(); } })] }) }), history && row && (_jsx(HistoryModal, { rows: history, draftId: row.draftVersionId, onSelect: (v) => { void loadSnapshot(v); }, onClose: () => setHistory(null), onRestore: restore, render: (versionId) => snapshot?.id === versionId ? (_jsxs("fieldset", { disabled: true, className: "sm-history__form", children: [_jsx(GlobalFields, { item: item, groups: contentGroups, fields: snapshot.fields, refOptions: refOptions, multilingual: multilingual, onChange: () => { } }), settingGroups.length > 0 && (_jsxs(_Fragment, { children: [_jsx(SectionLabel, { children: "Settings" }), _jsx(GlobalFields, { item: item, groups: settingGroups, fields: snapshot.fields, refOptions: refOptions, multilingual: multilingual, onChange: () => { } })] }))] })) : _jsx("p", { className: "sm-hint", style: { padding: 16 }, children: "Loading\u2026" }) })), _jsxs("div", { className: "sm-editor", children: [_jsxs("div", { className: "sm-form", ref: formRef, children: [notice && (_jsxs(Banner, { tone: notice.tone, onDismiss: () => setNotice(null), children: [_jsx("p", { style: { margin: 0 }, children: notice.text }), _jsx(FieldErrorList, { fields: notice.fields ?? [], onReveal: (path) => { if (formRef.current)
|
|
301
|
+
] }) }), right: _jsxs(_Fragment, { children: [(touched || saveState === "error") && _jsx(SaveIndicator, { state: saveState, pending: dirty }), _jsx(PublishControl, { status: status, pending: dirty || saveState !== "saved", busy: busy, flash: published, disabled: !row, onPublish: () => { void publish(); }, onUnpublish: () => { void unpublish(); }, onDiscard: () => { void discard(); }, onHistory: () => { void openHistory(); } })] }) }), history && row && (_jsx(HistoryModal, { rows: history, draftId: row.draftVersionId, onSelect: (v) => { void loadSnapshot(v); }, onClose: () => setHistory(null), onRestore: restore, render: (versionId) => snapshot?.id === versionId ? (_jsxs("fieldset", { disabled: true, className: "sm-history__form", children: [_jsx(GlobalFields, { item: item, groups: contentGroups, fields: snapshot.fields, refOptions: refOptions, multilingual: multilingual, onChange: () => { } }), settingGroups.length > 0 && (_jsxs(_Fragment, { children: [_jsx(SectionLabel, { children: "Settings" }), _jsx(GlobalFields, { item: item, groups: settingGroups, fields: snapshot.fields, refOptions: refOptions, multilingual: multilingual, onChange: () => { } })] }))] })) : _jsx("p", { className: "sm-hint", style: { padding: 16 }, children: "Loading\u2026" }) })), _jsx(PresenceLine, { names: editorsOf(others, target), noun: "item" }), stale && _jsx(StaleBanner, { info: stale, onReload: loadTheirs }), _jsxs("div", { className: "sm-editor", inert: stale ? true : undefined, children: [_jsxs("div", { className: "sm-form", ref: formRef, children: [notice && (_jsxs(Banner, { tone: notice.tone, onDismiss: () => setNotice(null), children: [_jsx("p", { style: { margin: 0 }, children: notice.text }), _jsx(FieldErrorList, { fields: notice.fields ?? [], onReveal: (path) => { if (formRef.current)
|
|
263
302
|
revealField(formRef.current, path); } })] })), record && (_jsxs("p", { className: "sm-hint", children: [pages.size === 0 ? "Not placed on any page yet." : `Used on ${pages.size} page${pages.size === 1 ? "" : "s"}: `, [...pages].map((id, i) => (_jsxs("span", { children: [i > 0 && ", ", _jsx("a", { href: adminHref(`/pages/${encodeURIComponent(id)}?locale=${encodeURIComponent(locale)}`), children: id })] }, id)))] })), fields !== null ? (_jsxs(_Fragment, { children: [_jsx(GlobalFields, { item: item, groups: contentGroups, fields: fields, errors: fieldErrors, refOptions: refOptions, multilingual: multilingual, onChange: onChange }), settingGroups.length > 0 && (_jsxs(_Fragment, { children: [_jsx(SectionLabel, { children: "Settings" }), _jsx(GlobalFields, { item: item, groups: settingGroups, fields: fields, errors: fieldErrors, refOptions: refOptions, multilingual: multilingual, onChange: onChange })] }))] })) : !notice && _jsx("p", { style: { color: "var(--text-muted)" }, children: "Loading\u2026" })] }), _jsx(ResizeHandle, {}), _jsx("aside", { className: "sm-inspector", children: record && row && (_jsxs("div", { className: "sm-card", children: [_jsx(SectionLabel, { children: "Document info" }), _jsx(InfoRow, { label: "Kind", value: item.section !== undefined ? `Visual · ${item.section}` : "Fields" }), _jsx(InfoRow, { label: "Updated", value: updatedLine(row.updatedAt, row.updatedBy) }), _jsx(InfoRow, { label: "ID", value: record.id, mono: true })] })) })] })] }));
|
|
264
303
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
"use client";
|
|
2
|
-
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
3
|
// The Globals list (global content, spec 2026-09-07 §6; renamed in the
|
|
4
4
|
// UI 2026-09-09, the route is /globals): every registered item is
|
|
5
5
|
// a row, edited on its own page. Nothing here is created or deleted by
|
|
@@ -15,11 +15,15 @@ import { relativeTime } from "../ui/format.js";
|
|
|
15
15
|
import { useAdminHref } from "./base.js";
|
|
16
16
|
import { ErrorPane } from "./ErrorPane.js";
|
|
17
17
|
import { rememberLocale, rememberedLocale } from "./locale-memory.js";
|
|
18
|
+
import { usePresenceState } from "./presence-context.js";
|
|
19
|
+
import { editorsOf } from "./presence.js";
|
|
20
|
+
import { LockIcon } from "../ui/LockIcon.js";
|
|
18
21
|
export function GlobalList({ ops, registry }) {
|
|
19
22
|
const adminHref = useAdminHref();
|
|
20
23
|
const [items, setItems] = useState(null);
|
|
21
24
|
const [error, setError] = useState(null);
|
|
22
25
|
const [locale, setLocale] = useState(() => rememberedLocale(registry.locales));
|
|
26
|
+
const { others } = usePresenceState();
|
|
23
27
|
const multilingual = registry.locales.supported.length > 1;
|
|
24
28
|
const pickLocale = (next) => {
|
|
25
29
|
setLocale(next);
|
|
@@ -43,7 +47,7 @@ export function GlobalList({ ops, registry }) {
|
|
|
43
47
|
key: row.name,
|
|
44
48
|
href: adminHref(`/globals/${encodeURIComponent(row.name)}?locale=${encodeURIComponent(locale)}`),
|
|
45
49
|
cells: [
|
|
46
|
-
|
|
50
|
+
_jsxs("span", { className: "sm-cell sm-cell--title", children: [row.title, _jsx(LockIcon, { names: editorsOf(others, { kind: "global", id: row.name, locale }) })] }, "t"),
|
|
47
51
|
_jsx("span", { className: "sm-cell sm-cell--muted", children: row.flavour }, "k"),
|
|
48
52
|
_jsx("span", { className: "sm-cell sm-cell--muted", children: relativeTime(row.updatedAt) }, "u"),
|
|
49
53
|
_jsx(StatusBadge, { status: statusLabel(row.status) }, "s"),
|
|
@@ -21,7 +21,11 @@ import { rememberLocale, rememberedLocale } from "./locale-memory.js";
|
|
|
21
21
|
import { confirmLiveUrl } from "./live-url.js";
|
|
22
22
|
import { confirm } from "../ui/confirm.js";
|
|
23
23
|
import { PageFormDialog } from "./PageDialogs.js";
|
|
24
|
+
import { usePresenceState } from "./presence-context.js";
|
|
25
|
+
import { editorsOf } from "./presence.js";
|
|
26
|
+
import { LockIcon } from "../ui/LockIcon.js";
|
|
24
27
|
export function PagesList({ ops, registry }) {
|
|
28
|
+
const { others } = usePresenceState();
|
|
25
29
|
const adminHref = useAdminHref();
|
|
26
30
|
const [pages, setPages] = useState(null);
|
|
27
31
|
const [error, setError] = useState(null);
|
|
@@ -243,7 +247,7 @@ export function PagesList({ ops, registry }) {
|
|
|
243
247
|
key: row.id,
|
|
244
248
|
onClick: () => onRow(row),
|
|
245
249
|
cells: [
|
|
246
|
-
_jsxs("span", { className: row.hasChildren ? "sm-cell sm-cell--tree sm-cell--parent" : "sm-cell sm-cell--tree", style: { paddingLeft: depth * 20, ...dim }, children: [_jsx("button", { type: "button", className: "sm-chevron", "aria-label": row.expanded ? "Collapse" : "Expand", "aria-expanded": row.hasChildren ? row.expanded : undefined, style: { visibility: row.hasChildren ? "visible" : "hidden" }, onClick: (e) => { e.stopPropagation(); toggle(row.id); }, onKeyDown: (e) => e.stopPropagation(), children: row.expanded ? "▾" : "▸" }), _jsx("span", { style: { overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }, children: row.title })] }, "t"),
|
|
250
|
+
_jsxs("span", { className: row.hasChildren ? "sm-cell sm-cell--tree sm-cell--parent" : "sm-cell sm-cell--tree", style: { paddingLeft: depth * 20, ...dim }, children: [_jsx("button", { type: "button", className: "sm-chevron", "aria-label": row.expanded ? "Collapse" : "Expand", "aria-expanded": row.hasChildren ? row.expanded : undefined, style: { visibility: row.hasChildren ? "visible" : "hidden" }, onClick: (e) => { e.stopPropagation(); toggle(row.id); }, onKeyDown: (e) => e.stopPropagation(), children: row.expanded ? "▾" : "▸" }), _jsx("span", { style: { overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }, children: row.title }), _jsx(LockIcon, { names: editorsOf(others, { kind: "page", id: row.id, locale }) })] }, "t"),
|
|
247
251
|
row.present
|
|
248
252
|
? _jsx("span", { className: "sm-cell sm-cell--mono", children: row.path }, "p")
|
|
249
253
|
: _jsxs("span", { className: "sm-cell sm-cell--muted", children: ["not in ", locale, " \u2014 click to add"] }, "p"),
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { Presence, PresenceState, PresenceTarget, SavedEvent } from "./presence.ts";
|
|
2
|
+
export declare const PresenceProvider: import("react").Provider<Presence | null>;
|
|
3
|
+
export declare const usePresence: () => Presence | null;
|
|
4
|
+
export declare function usePresenceState(): PresenceState;
|
|
5
|
+
/** Track `target` while mounted; null tracks nothing. Keys on the target's three fields. */
|
|
6
|
+
export declare function useTrackPresence(target: PresenceTarget | null): void;
|
|
7
|
+
/** Hear another person's save of exactly `target`. The handler is read at call time
|
|
8
|
+
* (re-subscribed every render on purpose: one Set entry, always fresh state). */
|
|
9
|
+
export declare function useSavedElsewhere(target: PresenceTarget | null, handler: (event: SavedEvent) => void): void;
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
// The React side of presence: one controller per signed-in session from AdminApp,
|
|
3
|
+
// the hooks the lists and the three views use.
|
|
4
|
+
import { createContext, useContext, useEffect, useSyncExternalStore } from "react";
|
|
5
|
+
const OFF = { status: "off", others: [] };
|
|
6
|
+
const PresenceContext = createContext(null);
|
|
7
|
+
export const PresenceProvider = PresenceContext.Provider;
|
|
8
|
+
export const usePresence = () => useContext(PresenceContext);
|
|
9
|
+
export function usePresenceState() {
|
|
10
|
+
const p = usePresence();
|
|
11
|
+
return useSyncExternalStore(p ? p.subscribe : () => () => { }, p ? p.getState : () => OFF, () => OFF);
|
|
12
|
+
}
|
|
13
|
+
/** Track `target` while mounted; null tracks nothing. Keys on the target's three fields. */
|
|
14
|
+
export function useTrackPresence(target) {
|
|
15
|
+
const p = usePresence();
|
|
16
|
+
const kind = target?.kind, id = target?.id, locale = target?.locale;
|
|
17
|
+
useEffect(() => {
|
|
18
|
+
if (!p || !kind || !id || !locale)
|
|
19
|
+
return;
|
|
20
|
+
p.track({ kind, id, locale });
|
|
21
|
+
return () => p.track(null);
|
|
22
|
+
}, [p, kind, id, locale]);
|
|
23
|
+
}
|
|
24
|
+
/** Hear another person's save of exactly `target`. The handler is read at call time
|
|
25
|
+
* (re-subscribed every render on purpose: one Set entry, always fresh state). */
|
|
26
|
+
export function useSavedElsewhere(target, handler) {
|
|
27
|
+
const p = usePresence();
|
|
28
|
+
const kind = target?.kind, id = target?.id, locale = target?.locale;
|
|
29
|
+
useEffect(() => {
|
|
30
|
+
if (!p || !kind || !id || !locale)
|
|
31
|
+
return;
|
|
32
|
+
return p.onSaved((event) => { if (event.kind === kind && event.id === id && event.locale === locale)
|
|
33
|
+
handler(event); });
|
|
34
|
+
});
|
|
35
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
// The real channel behind `createPresence` (spec 2026-09-26 concurrency §5–§6;
|
|
3
|
+
// gated 2026-09-26 security pass §1): one supabase-js client on the CONTENT
|
|
4
|
+
// project whose public key (the anon key in self-host, the read key in the
|
|
5
|
+
// cloud) is only the gateway apikey — the socket authenticates with the
|
|
6
|
+
// editor's SESSION token, which the policies read auth.uid() from. A
|
|
7
|
+
// private channel named after the space, presence keyed by the email.
|
|
8
|
+
//
|
|
9
|
+
// The token rides the client's `accessToken` callback, not a bare
|
|
10
|
+
// `realtime.setAuth(token)`: realtime-js treats the callback as the source
|
|
11
|
+
// of truth and re-asks it after every join and reconnect — with the
|
|
12
|
+
// default callback that answer is the public key, which the server then
|
|
13
|
+
// refuses ("no permissions to read"). The join itself waits for the first
|
|
14
|
+
// setAuth, so it never goes out with the apikey alone.
|
|
15
|
+
import { createClient } from "@supabase/supabase-js";
|
|
16
|
+
export function connectRealtime(config, email, token) {
|
|
17
|
+
let current = token;
|
|
18
|
+
const client = createClient(config.url, config.key, { accessToken: async () => current });
|
|
19
|
+
const ready = client.realtime.setAuth(token).catch(() => { });
|
|
20
|
+
const channel = client.channel(`space:${config.spaceId}`, { config: { private: true, presence: { key: email } } });
|
|
21
|
+
return {
|
|
22
|
+
subscribe: (onStatus) => { void ready.then(() => { channel.subscribe((status) => onStatus(status)); }); },
|
|
23
|
+
setAuth: (fresh) => { current = fresh; return client.realtime.setAuth(fresh); },
|
|
24
|
+
track: (state) => channel.track(state),
|
|
25
|
+
untrack: () => channel.untrack(),
|
|
26
|
+
send: (message) => channel.send(message),
|
|
27
|
+
onSync: (listener) => { channel.on("presence", { event: "sync" }, listener); },
|
|
28
|
+
presenceState: () => channel.presenceState(),
|
|
29
|
+
onBroadcast: (event, listener) => { channel.on("broadcast", { event }, (m) => listener(m.payload)); },
|
|
30
|
+
unsubscribe: async () => { await channel.unsubscribe(); await client.removeAllChannels(); },
|
|
31
|
+
};
|
|
32
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
export type PresenceTarget = {
|
|
2
|
+
kind: "page" | "entry" | "global";
|
|
3
|
+
id: string;
|
|
4
|
+
locale: string;
|
|
5
|
+
};
|
|
6
|
+
export type PresenceEntry = PresenceTarget & {
|
|
7
|
+
email: string;
|
|
8
|
+
};
|
|
9
|
+
export type SavedEvent = PresenceTarget & {
|
|
10
|
+
email: string;
|
|
11
|
+
savedAt: string | null;
|
|
12
|
+
};
|
|
13
|
+
export type PresenceStatus = "off" | "joining" | "joined" | "failed";
|
|
14
|
+
export type PresenceState = {
|
|
15
|
+
status: PresenceStatus;
|
|
16
|
+
others: PresenceEntry[];
|
|
17
|
+
};
|
|
18
|
+
/** The slice of a Realtime channel presence uses. */
|
|
19
|
+
export type PresenceChannel = {
|
|
20
|
+
subscribe(onStatus: (status: string) => void): void;
|
|
21
|
+
/** A session token for the socket (spec 2026-09-26 security pass §1): the channel policies admit by auth.uid(). */
|
|
22
|
+
setAuth(token: string): Promise<unknown>;
|
|
23
|
+
track(state: Record<string, unknown>): Promise<unknown>;
|
|
24
|
+
untrack(): Promise<unknown>;
|
|
25
|
+
send(message: {
|
|
26
|
+
type: "broadcast";
|
|
27
|
+
event: string;
|
|
28
|
+
payload: Record<string, unknown>;
|
|
29
|
+
}): Promise<unknown>;
|
|
30
|
+
onSync(listener: () => void): void;
|
|
31
|
+
/** key (the email) → that person's tabs' states. */
|
|
32
|
+
presenceState(): Record<string, Record<string, unknown>[]>;
|
|
33
|
+
onBroadcast(event: string, listener: (payload: Record<string, unknown>) => void): void;
|
|
34
|
+
unsubscribe(): Promise<unknown>;
|
|
35
|
+
};
|
|
36
|
+
export type Presence = {
|
|
37
|
+
join(): void;
|
|
38
|
+
/** What this tab edits now; null on leaving a view. Re-sent on rejoin. */
|
|
39
|
+
track(target: PresenceTarget | null): void;
|
|
40
|
+
/** After a guarded op acked: tell the others. */
|
|
41
|
+
announce(target: PresenceTarget, savedAt: string | null): void;
|
|
42
|
+
/** The session token was refreshed: re-authenticate the socket so the channel outlives the old token. No-op unless joined. */
|
|
43
|
+
setAuth(token: string): void;
|
|
44
|
+
onSaved(listener: (event: SavedEvent) => void): () => void;
|
|
45
|
+
getState(): PresenceState;
|
|
46
|
+
subscribe(listener: () => void): () => void;
|
|
47
|
+
dispose(): void;
|
|
48
|
+
};
|
|
49
|
+
export declare const shortName: (email: string) => string;
|
|
50
|
+
/** The short names editing `target`, other than me, sorted and deduped. */
|
|
51
|
+
export declare function editorsOf(others: PresenceEntry[], target: PresenceTarget): string[];
|
|
52
|
+
export declare function createPresence(opts: {
|
|
53
|
+
email: string;
|
|
54
|
+
connect: () => PresenceChannel;
|
|
55
|
+
}): Presence;
|
|
56
|
+
/** What the shell needs from the auth client to follow refreshes — supabase-js's `auth` satisfies it. */
|
|
57
|
+
export type RefreshSource = {
|
|
58
|
+
onAuthStateChange(cb: (event: string, session: {
|
|
59
|
+
access_token: string;
|
|
60
|
+
} | null) => void): {
|
|
61
|
+
data: {
|
|
62
|
+
subscription: {
|
|
63
|
+
unsubscribe(): void;
|
|
64
|
+
};
|
|
65
|
+
};
|
|
66
|
+
};
|
|
67
|
+
};
|
|
68
|
+
/** Re-send every refreshed session token to the socket (spec 2026-09-26 security pass §1). Returns the stop function. */
|
|
69
|
+
export declare function followTokenRefresh(auth: RefreshSource, presence: Pick<Presence, "setAuth">): () => void;
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
// Editing presence (spec 2026-09-26 concurrency §5): who has what open, and a
|
|
2
|
+
// "saved" announcement, over ONE channel per space. Framework-free: the channel
|
|
3
|
+
// is injected (presence-realtime.ts builds the supabase-js one; tests fake it),
|
|
4
|
+
// the React glue is presence-context.tsx. Never awaited by anything — a failed
|
|
5
|
+
// join leaves the admin exactly as without presence.
|
|
6
|
+
const sameTarget = (a, b) => a.kind === b.kind && a.id === b.id && a.locale === b.locale;
|
|
7
|
+
export const shortName = (email) => email.split("@")[0];
|
|
8
|
+
/** The short names editing `target`, other than me, sorted and deduped. */
|
|
9
|
+
export function editorsOf(others, target) {
|
|
10
|
+
return [...new Set(others.filter((o) => sameTarget(o, target)).map((o) => shortName(o.email)))].sort();
|
|
11
|
+
}
|
|
12
|
+
const isTarget = (v) => typeof v === "object" && v !== null
|
|
13
|
+
&& ["page", "entry", "global"].includes(v.kind)
|
|
14
|
+
&& typeof v.id === "string" && typeof v.locale === "string";
|
|
15
|
+
export function createPresence(opts) {
|
|
16
|
+
let state = { status: "off", others: [] };
|
|
17
|
+
let channel = null;
|
|
18
|
+
let current = null;
|
|
19
|
+
const listeners = new Set();
|
|
20
|
+
const savedListeners = new Set();
|
|
21
|
+
const set = (next) => { state = { ...state, ...next }; for (const l of listeners)
|
|
22
|
+
l(); };
|
|
23
|
+
const readOthers = () => {
|
|
24
|
+
if (!channel)
|
|
25
|
+
return [];
|
|
26
|
+
const out = [];
|
|
27
|
+
for (const [email, metas] of Object.entries(channel.presenceState())) {
|
|
28
|
+
if (email === opts.email)
|
|
29
|
+
continue;
|
|
30
|
+
for (const meta of metas)
|
|
31
|
+
if (isTarget(meta))
|
|
32
|
+
out.push({ email, kind: meta.kind, id: meta.id, locale: meta.locale });
|
|
33
|
+
}
|
|
34
|
+
return out;
|
|
35
|
+
};
|
|
36
|
+
return {
|
|
37
|
+
join() {
|
|
38
|
+
if (channel)
|
|
39
|
+
return;
|
|
40
|
+
try {
|
|
41
|
+
channel = opts.connect();
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
set({ status: "failed" });
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
set({ status: "joining" });
|
|
48
|
+
channel.onSync(() => set({ others: readOthers() }));
|
|
49
|
+
channel.onBroadcast("saved", (payload) => {
|
|
50
|
+
const { email, savedAt } = payload;
|
|
51
|
+
if (!isTarget(payload) || typeof email !== "string" || email === opts.email)
|
|
52
|
+
return;
|
|
53
|
+
const event = { kind: payload.kind, id: payload.id, locale: payload.locale, email, savedAt: typeof savedAt === "string" ? savedAt : null };
|
|
54
|
+
for (const l of savedListeners)
|
|
55
|
+
l(event);
|
|
56
|
+
});
|
|
57
|
+
channel.subscribe((status) => {
|
|
58
|
+
if (status === "SUBSCRIBED") {
|
|
59
|
+
set({ status: "joined" });
|
|
60
|
+
if (current && channel)
|
|
61
|
+
void channel.track(current).catch(() => { });
|
|
62
|
+
}
|
|
63
|
+
else if (status === "CHANNEL_ERROR" || status === "TIMED_OUT" || status === "CLOSED") {
|
|
64
|
+
set({ status: "failed", others: [] });
|
|
65
|
+
}
|
|
66
|
+
});
|
|
67
|
+
},
|
|
68
|
+
track(target) {
|
|
69
|
+
current = target;
|
|
70
|
+
if (state.status !== "joined" || !channel)
|
|
71
|
+
return;
|
|
72
|
+
void (target ? channel.track(target) : channel.untrack()).catch(() => { });
|
|
73
|
+
},
|
|
74
|
+
announce(target, savedAt) {
|
|
75
|
+
if (state.status !== "joined" || !channel)
|
|
76
|
+
return;
|
|
77
|
+
void channel.send({ type: "broadcast", event: "saved", payload: { ...target, email: opts.email, savedAt } }).catch(() => { });
|
|
78
|
+
},
|
|
79
|
+
setAuth(token) {
|
|
80
|
+
if (!channel)
|
|
81
|
+
return;
|
|
82
|
+
void channel.setAuth(token).catch(() => { });
|
|
83
|
+
},
|
|
84
|
+
onSaved(listener) { savedListeners.add(listener); return () => { savedListeners.delete(listener); }; },
|
|
85
|
+
getState: () => state,
|
|
86
|
+
subscribe(listener) { listeners.add(listener); return () => { listeners.delete(listener); }; },
|
|
87
|
+
dispose() {
|
|
88
|
+
const c = channel;
|
|
89
|
+
channel = null;
|
|
90
|
+
set({ status: "off", others: [] });
|
|
91
|
+
if (c)
|
|
92
|
+
void c.unsubscribe().catch(() => { });
|
|
93
|
+
},
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
/** Re-send every refreshed session token to the socket (spec 2026-09-26 security pass §1). Returns the stop function. */
|
|
97
|
+
export function followTokenRefresh(auth, presence) {
|
|
98
|
+
const { data } = auth.onAuthStateChange((event, session) => {
|
|
99
|
+
if (event === "TOKEN_REFRESHED" && session)
|
|
100
|
+
presence.setAuth(session.access_token);
|
|
101
|
+
});
|
|
102
|
+
return () => data.subscription.unsubscribe();
|
|
103
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { OpError, OpResult } from "../ops.ts";
|
|
2
|
+
export type StaleInfo = {
|
|
3
|
+
by: string | null;
|
|
4
|
+
at: number;
|
|
5
|
+
};
|
|
6
|
+
/** The views' `fail` helpers take a loose failure shape; the guard's refusal is recognised in either. */
|
|
7
|
+
type FailureLike = {
|
|
8
|
+
ok: false;
|
|
9
|
+
code: string;
|
|
10
|
+
message: string;
|
|
11
|
+
stale?: true;
|
|
12
|
+
by?: string | null;
|
|
13
|
+
at?: number;
|
|
14
|
+
};
|
|
15
|
+
export declare const isStale: (r: OpResult<unknown> | FailureLike) => r is OpError & {
|
|
16
|
+
stale: true;
|
|
17
|
+
};
|
|
18
|
+
/** The part before the @ — how the members list and history shorten an actor. */
|
|
19
|
+
export declare const shortName: (email: string | null) => string;
|
|
20
|
+
export declare function staleText(info: StaleInfo, now?: number): string;
|
|
21
|
+
export declare const STALE_DETAIL = "Your unsaved changes here will be discarded.";
|
|
22
|
+
export declare const LOAD_THEIRS = "Load their version";
|
|
23
|
+
/** An op result's stale facts, for the views' `fail` paths. */
|
|
24
|
+
export declare const staleInfoOf: (r: {
|
|
25
|
+
by?: string | null;
|
|
26
|
+
at?: number;
|
|
27
|
+
}) => StaleInfo;
|
|
28
|
+
export {};
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { versionTime } from "../ui/format.js";
|
|
2
|
+
export const isStale = (r) => !r.ok && r.code === "conflict" && r.stale === true;
|
|
3
|
+
/** The part before the @ — how the members list and history shorten an actor. */
|
|
4
|
+
export const shortName = (email) => (email ? email.split("@")[0] : "someone");
|
|
5
|
+
export function staleText(info, now = Date.now()) {
|
|
6
|
+
const at = new Date(info.at);
|
|
7
|
+
const today = new Date(now);
|
|
8
|
+
const sameDay = at.getFullYear() === today.getFullYear() && at.getMonth() === today.getMonth() && at.getDate() === today.getDate();
|
|
9
|
+
const when = sameDay
|
|
10
|
+
? `${String(at.getHours()).padStart(2, "0")}:${String(at.getMinutes()).padStart(2, "0")}`
|
|
11
|
+
: versionTime(info.at);
|
|
12
|
+
return `${shortName(info.by)} saved a newer version at ${when}.`;
|
|
13
|
+
}
|
|
14
|
+
export const STALE_DETAIL = "Your unsaved changes here will be discarded.";
|
|
15
|
+
export const LOAD_THEIRS = "Load their version";
|
|
16
|
+
/** An op result's stale facts, for the views' `fail` paths. */
|
|
17
|
+
export const staleInfoOf = (r) => ({ by: r.by ?? null, at: r.at ?? Date.now() });
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
3
|
+
// The lock after a list row's title (spec 2026-09-26 concurrency §5): someone else
|
|
4
|
+
// has this target open in this locale. A tooltip names them.
|
|
5
|
+
import { Lock } from "lucide-react";
|
|
6
|
+
export const lockLabel = (names) => names.length === 1 ? `${names[0]} is editing` : `${names.join(", ")} are editing`;
|
|
7
|
+
export function LockIcon({ names }) {
|
|
8
|
+
if (names.length === 0)
|
|
9
|
+
return null;
|
|
10
|
+
const label = lockLabel(names);
|
|
11
|
+
return (_jsx("span", { className: "sm-lock", role: "img", "aria-label": label, title: label, children: _jsx(Lock, { size: 14, strokeWidth: 1.5, "aria-hidden": "true" }) }));
|
|
12
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export type PresenceNoun = "page" | "entry" | "item";
|
|
2
|
+
export declare function presenceLineText(names: string[], noun: PresenceNoun): string;
|
|
3
|
+
export declare function PresenceLine({ names, noun }: {
|
|
4
|
+
names: string[];
|
|
5
|
+
noun: PresenceNoun;
|
|
6
|
+
}): import("react").JSX.Element | null;
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
3
|
+
export function presenceLineText(names, noun) {
|
|
4
|
+
const who = names.length === 1 ? names[0] : `${names.slice(0, -1).join(", ")} and ${names[names.length - 1]}`;
|
|
5
|
+
return `${who} ${names.length === 1 ? "is" : "are"} also editing this ${noun}.`;
|
|
6
|
+
}
|
|
7
|
+
export function PresenceLine({ names, noun }) {
|
|
8
|
+
if (names.length === 0)
|
|
9
|
+
return null;
|
|
10
|
+
return _jsx("p", { className: "sm-presence", role: "status", children: presenceLineText(names, noun) });
|
|
11
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
// The freeze notice under the top bar (spec 2026-09-26 concurrency §4): the fact,
|
|
4
|
+
// what a reload costs, and the one button. The view puts everything below it inert.
|
|
5
|
+
import { LOAD_THEIRS, STALE_DETAIL, staleText } from "../shell/stale.js";
|
|
6
|
+
import { Banner, Button } from "./primitives.js";
|
|
7
|
+
export function StaleBanner({ info, onReload }) {
|
|
8
|
+
return (_jsx("div", { className: "sm-stale", "data-testid": "stale-banner", children: _jsxs(Banner, { tone: "error", children: [_jsx("p", { style: { margin: 0 }, children: staleText(info) }), _jsx("p", { style: { margin: "4px 0 10px", color: "inherit", opacity: 0.85 }, children: STALE_DETAIL }), _jsx(Button, { onClick: onReload, children: LOAD_THEIRS })] }) }));
|
|
9
|
+
}
|
|
@@ -120,8 +120,11 @@ export declare function StatusBadge({ status }: {
|
|
|
120
120
|
status: string;
|
|
121
121
|
}): import("react").JSX.Element;
|
|
122
122
|
/** The autosave indicator: a dot and one uppercase word. */
|
|
123
|
-
|
|
123
|
+
/** `pending`: an edit is waiting on the autosave debounce — shown as Saving, so "Saved" always means the
|
|
124
|
+
* store holds what is on screen (the browser suite's `expectSaved` relies on exactly that). */
|
|
125
|
+
export declare function SaveIndicator({ state, pending }: {
|
|
124
126
|
state: SaveState;
|
|
127
|
+
pending?: boolean;
|
|
125
128
|
}): import("react").JSX.Element;
|
|
126
129
|
/** `caps`: the overline style — small, uppercase, tracked (2026-09-26; the mono face before). */
|
|
127
130
|
export declare function SectionLabel({ children, action, caps, style }: {
|
|
@@ -124,13 +124,16 @@ export function StatusBadge({ status }) {
|
|
|
124
124
|
}, children: status }));
|
|
125
125
|
}
|
|
126
126
|
/** The autosave indicator: a dot and one uppercase word. */
|
|
127
|
-
|
|
127
|
+
/** `pending`: an edit is waiting on the autosave debounce — shown as Saving, so "Saved" always means the
|
|
128
|
+
* store holds what is on screen (the browser suite's `expectSaved` relies on exactly that). */
|
|
129
|
+
export function SaveIndicator({ state, pending = false }) {
|
|
128
130
|
const map = {
|
|
129
131
|
saved: { label: "Saved", dot: "var(--status-saved-dot)", color: "var(--text-muted)" },
|
|
130
132
|
saving: { label: "Saving", dot: "var(--warn)", color: "var(--text-muted)" },
|
|
131
133
|
error: { label: "Save failed — retrying", dot: "var(--danger)", color: "var(--danger)" },
|
|
134
|
+
stale: { label: "Not saved — newer version exists", dot: "var(--danger)", color: "var(--danger)" },
|
|
132
135
|
};
|
|
133
|
-
const { label, dot, color } = map[state];
|
|
136
|
+
const { label, dot, color } = map[pending && state === "saved" ? "saving" : state];
|
|
134
137
|
return (_jsxs("span", { className: "sm-savestate", "data-testid": "save-status", style: { color }, title: label, children: [_jsx("span", { className: "sm-dot", style: { background: dot } }), label] }));
|
|
135
138
|
}
|
|
136
139
|
/** `caps`: the overline style — small, uppercase, tracked (2026-09-26; the mono face before). */
|
package/dist/admin/ui/theme.js
CHANGED
|
@@ -794,6 +794,9 @@ body:has(.sm-admin){margin:0}
|
|
|
794
794
|
.sm-overline,.sm-sectionlabel--caps>.sm-label{
|
|
795
795
|
font-family:var(--font-sans);font-size:var(--text-xs);font-weight:600;letter-spacing:var(--tracking-tag);text-transform:uppercase;color:var(--text-muted);
|
|
796
796
|
}
|
|
797
|
+
.sm-stale{padding:8px 20px 0}
|
|
798
|
+
.sm-lock{display:inline-flex;align-items:center;margin-left:6px;color:var(--text-muted);vertical-align:middle}
|
|
799
|
+
.sm-presence{margin:0;padding:6px 20px 0;font-size:13px;color:var(--text-muted)}
|
|
797
800
|
.sm-banner{
|
|
798
801
|
display:flex;gap:14px;align-items:flex-start;padding:12px 16px;border-radius:var(--radius-md);
|
|
799
802
|
font-size:var(--text-md);
|
|
@@ -841,6 +844,7 @@ body:has(.sm-admin){margin:0}
|
|
|
841
844
|
handle and the canvas's 440px minimum — so a window that shrinks after a
|
|
842
845
|
drag never leaves the canvas squeezed. */
|
|
843
846
|
.sm-editor{flex:1;min-height:0;display:grid;grid-template-columns:1fr max(var(--gutter),8px) min(var(--inspector-w),calc(100% - max(var(--gutter),8px) - 440px))}
|
|
847
|
+
.sm-editor[inert],.sm-form[inert]{opacity:.6}
|
|
844
848
|
/* The drag-to-resize handle (spec 2026-09-10 §3): fills its column, draws a
|
|
845
849
|
2px line down its centre on hover and while dragging — a shade off the
|
|
846
850
|
ground, since the col-resize cursor already says it drags (2026-09-10);
|