@uiresponse/renderer-react 0.1.0

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.
@@ -0,0 +1,160 @@
1
+ /**
2
+ * Drawing ONE record: the pure decisions behind the `detail` block's hierarchy.
3
+ *
4
+ * A record has a name, a state, and then some facts. Which is which is not a style choice and not a
5
+ * guess — it is `column.role`, which the dataset already declares, and which exists so that "a model
6
+ * can assemble a good page rather than merely a valid one: bind `name` to the row title, `status` to
7
+ * a badge" (`data.schema.json#columnRole`).
8
+ *
9
+ * These functions are here, free of React, for the same reason `format.js` and `empty.js` are: they
10
+ * are the semantics, and the semantics are what must not regress.
11
+ */
12
+
13
+ /**
14
+ * Tone for a status the document did not tone.
15
+ *
16
+ * `badge` lets an author state tone outright (`tone`) or map it (`tone_map`). `detail` has neither
17
+ * prop and may not grow one — tone is not in that block's vocabulary, and Principle 1 keeps colour
18
+ * out of the document. So the renderer recognises a small, CLOSED vocabulary of process words and
19
+ * stays neutral for everything else.
20
+ *
21
+ * Neutral is the honest default. Colouring an unrecognised status would be a guess rendered as a
22
+ * fact, on a real label's real pipeline, where "Cancelled" and "Cleared" both start with C.
23
+ */
24
+ const STATUS_TONES = [
25
+ [/^(live|released|complete|completed|done|signed|approved|delivered|paid|cleared)$/i, "positive"],
26
+ [/^(overdue|blocked|rejected|cancelled|canceled|failed|void|at risk)$/i, "negative"],
27
+ [/^(pending|waiting|in progress|in review|draft|scheduled|chasing|on hold)$/i, "warning"],
28
+ ];
29
+
30
+ export function statusTone(value) {
31
+ const s = String(value ?? "").trim();
32
+ for (const [re, tone] of STATUS_TONES) if (re.test(s)) return tone;
33
+ return "neutral";
34
+ }
35
+
36
+ /** Only absolute http(s) may be drawn as a link, checked at the point of use (VERSIONING §9). */
37
+ export const isHttpUrl = (v) => typeof v === "string" && /^https?:\/\//i.test(v);
38
+
39
+ /**
40
+ * What may be drawn as an IMAGE: absolute http(s), or an inline `data:image/…` URI. The two gates
41
+ * differ on purpose — a data URI is a perfectly good picture and a nonsensical link, so seed and
42
+ * offline corpora can carry self-contained artwork without the renderer mistaking it for a URL a
43
+ * person could visit. Nothing else (javascript:, blob:, relative paths) is drawn at all.
44
+ */
45
+ export const isImageSrc = (v) => isHttpUrl(v) || (typeof v === "string" && /^data:image\//i.test(v));
46
+
47
+ /** Absent for the purpose of promotion: a headline cannot be an empty string. */
48
+ export const isEmptyish = (v) => v === null || v === undefined || v === "";
49
+
50
+ /** A url a person can read: the host and its path, without the ceremony. */
51
+ export function linkText(url) {
52
+ try {
53
+ const u = new URL(url);
54
+ const tail = `${u.pathname}${u.search}`.replace(/\/$/, "");
55
+ return `${u.host.replace(/^www\./, "")}${tail}`;
56
+ } catch {
57
+ return url;
58
+ }
59
+ }
60
+
61
+ /**
62
+ * The image column a list draws its posters from, or null.
63
+ *
64
+ * `role: image` is the dataset saying "this column is a picture, not a URL" — the same declaration a
65
+ * table already reads to draw a thumbnail. A list read it nowhere, so a list of records with cover
66
+ * art rendered as a column of text.
67
+ */
68
+ export const imageColumn = (columns) => (columns ?? []).find((c) => c?.role === "image") ?? null;
69
+
70
+ /**
71
+ * Rail or grid, decided from the content.
72
+ *
73
+ * `layout.columns` was deleted from the language (R5) precisely so this is the renderer's call. Four
74
+ * or more sleeves read as a shelf you push through; three or fewer read as a row that happens to be
75
+ * short, and making those scroll would be a lie about how much there is.
76
+ */
77
+ export const RAIL_THRESHOLD = 4;
78
+ export const posterMode = (count) => (count >= RAIL_THRESHOLD ? "rail" : "grid");
79
+
80
+ /**
81
+ * A section is EDITORIAL when it has a headline and opens with a sentence.
82
+ *
83
+ * `section.title` followed by a `text` child is the only way OBL can express "a headline, then one
84
+ * line saying what these things have in common". The composition was always expressible; the renderer
85
+ * just drew both parts in the same quiet grey. Reading it costs no schema and no new block, and a
86
+ * voice renderer already honours it — it reads the title, then the sentence, then the things.
87
+ */
88
+ export const isEditorialSection = (block) => Boolean(block?.title) && block?.children?.[0]?.type === "text";
89
+
90
+ /**
91
+ * THE PAGE ALREADY HAS A HEADLINE, so the shell must not print a second one.
92
+ *
93
+ * A browse answer is one editorial section: a headline, a standfirst, then the things. The shell drew
94
+ * `page.title` above it as well — and because a two-leaf page is "an answer, not a document", that
95
+ * title rendered as the 12px grey label, sitting directly on top of the 30px headline. Two titles,
96
+ * six pixels apart, saying nearly the same thing in two different voices.
97
+ *
98
+ * Only the SHELL's title is dropped, and only when the document's own first and only top-level block
99
+ * is the headline. `page.title` is untouched: it is what the overlay's head, the saved-page list and
100
+ * the browser address the page by. The header line survives too — it still carries the dataset status.
101
+ */
102
+ export const pageHeadlineIsInDocument = (page) => {
103
+ const blocks = page?.blocks ?? [];
104
+ return blocks.length === 1 && blocks[0]?.type === "section" && isEditorialSection(blocks[0]);
105
+ };
106
+
107
+ /**
108
+ * BLUR-UP: the low-resolution proxy of an image column, by convention rather than by schema.
109
+ *
110
+ * A cover arriving as a raw JPEG paints top-down, band by band, which is the ugliest thing on the
111
+ * page. The fix is the oldest one there is: show a tiny version instantly, blurred, and cross-fade to
112
+ * the real one. Airtable already makes the tiny version — every attachment carries `thumbnails.small`
113
+ * — so nothing has to be generated, only mapped.
114
+ *
115
+ * Where does the renderer LOOK for it? Not in a new schema property: `blur_thumb: "..."` would be a
116
+ * visual instruction in a document that forbids them (Principle 1), and a voice renderer could not
117
+ * honour it. So it is a NAME: the proxy of `artwork_url` is the column `artwork_thumb_url`, exactly
118
+ * as the proxy of `image_url` is `image_thumb_url`. A connector that has thumbnails maps one; a
119
+ * connector that has none maps nothing, and the image fades in from empty instead. Same document,
120
+ * same blocks, one more column — and the column is honest, because it really is data about the row.
121
+ *
122
+ * It is data about the row's PICTURE, though, not a fact about the record, so `defaultColumnSpecs`
123
+ * below keeps it out of any table or detail that did not ask for it by name.
124
+ */
125
+ const THUMB_SUFFIX = "_thumb_url";
126
+ export const thumbNameFor = (name) =>
127
+ typeof name === "string" ? `${name.replace(/_url$/, "")}${THUMB_SUFFIX}` : null;
128
+ export const isThumbName = (name) => typeof name === "string" && name.endsWith(THUMB_SUFFIX);
129
+ export const thumbColumn = (columns, imageCol) => {
130
+ if (!imageCol) return null;
131
+ const want = thumbNameFor(imageCol.name);
132
+ return (columns ?? []).find((c) => c?.name === want) ?? null;
133
+ };
134
+
135
+ /**
136
+ * Every column the dataset declared, in declared order — what `columns`/`fields` mean when omitted.
137
+ * Minus the blur-up proxies, which are a picture's loading state and never a column of a table.
138
+ */
139
+ export const defaultColumnSpecs = (columns) =>
140
+ (columns ?? []).filter((c) => !isThumbName(c?.name)).map((c) => ({ field: c.name }));
141
+
142
+ /** A row's arrival delay index, capped so a long table does not take seconds to finish appearing. */
143
+ export const STAGGER_CAP = 12;
144
+ export const staggerIndex = (i) => Math.min(i, STAGGER_CAP);
145
+
146
+ /**
147
+ * Split a record's field specs into a headline, its status badges, and the quiet facts.
148
+ *
149
+ * `find`, not `filter`, for the headline: a record has ONE name. A second name-role column stays in
150
+ * the facts, where it is a fact. A dataset that declares no `name` role gets `head: null` and the
151
+ * caller draws the flat list it always drew — inventing a headline from the first column would be
152
+ * the renderer making a claim the author did not.
153
+ */
154
+ export function splitRecordFields(withCols, row) {
155
+ const head = withCols.find(({ col }) => col?.role === "name" && !isEmptyish(row[col.name])) ?? null;
156
+ const badges = head ? withCols.filter(({ col }) => col?.role === "status" && !isEmptyish(row[col.name])) : [];
157
+ const promoted = new Set([head, ...badges].filter(Boolean).map(({ spec }) => spec.field));
158
+ const facts = withCols.filter(({ spec }) => !promoted.has(spec.field));
159
+ return { head, badges, facts };
160
+ }
@@ -0,0 +1,357 @@
1
+ /**
2
+ * Top-down progressive loading.
3
+ *
4
+ * The behaviour Dan asked for by name, and the one that makes a generated page feel like a product
5
+ * rather than a spinner:
6
+ *
7
+ * 1. The page's STRUCTURE paints immediately. Every block draws its frame, its title and its
8
+ * skeleton on the first frame, before a single row exists. The shape of the answer is knowable
9
+ * from the document alone — that is what a semantic block language buys you.
10
+ * 2. Datasets resolve in FIRST-USE ORDER, walking the block tree top-down. The dataset the top
11
+ * block needs is requested first; the dataset only the last tab needs is requested last.
12
+ * 3. `intent.priority` breaks ties. Two datasets first used at the same depth resolve `primary`
13
+ * before `supporting` before `ambient` — the page's own statement of what matters, honoured
14
+ * as load order. This is `priority` earning its keep as more than an annotation (DECISIONS I4).
15
+ * 4. Each block paints the moment ITS datasets land. One slow royalty aggregation does not hold
16
+ * the contract queue hostage.
17
+ *
18
+ * Concurrency is bounded, deliberately. Firing every dataset at once is the fastest way to fill the
19
+ * page and the slowest way to fill the TOP of it: the browser's connection pool would serve the
20
+ * footer's chart in parallel with the header's metric, and the user reads top-down.
21
+ */
22
+ import { useCallback, useEffect, useMemo, useReducer, useRef } from "react";
23
+ import { collectDatasets, collectParams } from "./value.js";
24
+
25
+ const PRIORITY_RANK = { primary: 0, supporting: 1, ambient: 2 };
26
+ const rankOf = (block) => PRIORITY_RANK[block?.intent?.priority] ?? 1;
27
+ export const DATASET_TIMEOUT_MS = 20_000;
28
+
29
+ /** Flatten the block tree in document order, carrying each block's depth. */
30
+ export function flattenBlocks(page) {
31
+ const out = [];
32
+ const walk = (block, depth, parent) => {
33
+ out.push({ block, depth, parent });
34
+ for (const child of block.children ?? []) walk(child, depth + 1, block);
35
+ };
36
+ for (const b of page.blocks ?? []) walk(b, 0, null);
37
+ return out;
38
+ }
39
+
40
+ /**
41
+ * The load plan: dataset names in the order they should be requested.
42
+ *
43
+ * First-use index (document order) is primary; `intent.priority` of the first block to use it is
44
+ * the tiebreak. A dataset used by a `primary` metric at index 4 outranks one used by an `ambient`
45
+ * footnote at index 4, and both outrank anything at index 9.
46
+ */
47
+ export function planDatasetOrder(page) {
48
+ const flat = flattenBlocks(page);
49
+ const firstUse = new Map(); // dataset -> { index, rank }
50
+
51
+ // Document order means the first sighting IS the lowest index, so we only ever record a dataset
52
+ // once. The tiebreak matters when two DIFFERENT datasets are first used at the same index — a
53
+ // card whose `visible_when` reads one dataset while its children read another.
54
+ flat.forEach(({ block }, index) => {
55
+ for (const name of collectDatasets(block)) {
56
+ if (!firstUse.has(name)) firstUse.set(name, { index, rank: rankOf(block) });
57
+ }
58
+ });
59
+
60
+ const declared = (page.data ?? []).map((d) => d.name);
61
+ return declared
62
+ .filter((name) => firstUse.has(name))
63
+ .sort((a, b) => {
64
+ const A = firstUse.get(a), B = firstUse.get(b);
65
+ return A.index - B.index || A.rank - B.rank || declared.indexOf(a) - declared.indexOf(b);
66
+ });
67
+ }
68
+
69
+ /** Which parameters does each dataset's query read? `set_param` invalidates exactly those datasets. */
70
+ export function datasetParamDeps(page) {
71
+ const deps = new Map();
72
+ for (const ds of page.data ?? []) deps.set(ds.name, collectParams(ds.query ?? {}));
73
+ return deps;
74
+ }
75
+
76
+ /** Initial parameter values: declared defaults, overridden by the host's session/url values. */
77
+ export function initialParams(page, supplied = {}) {
78
+ const params = {};
79
+ for (const p of page.params ?? []) {
80
+ params[p.name] = Object.prototype.hasOwnProperty.call(supplied, p.name)
81
+ ? supplied[p.name]
82
+ : "default" in p ? p.default : null;
83
+ }
84
+ return { ...params, ...supplied };
85
+ }
86
+
87
+ // ---------------------------------------------------------------------------
88
+ // state
89
+ // ---------------------------------------------------------------------------
90
+ // Per dataset: { status: "idle"|"loading"|"ready"|"error", rows, columns, error, epoch }
91
+
92
+ function reducer(state, action) {
93
+ switch (action.type) {
94
+ case "sync": {
95
+ const next = {};
96
+ for (const ds of action.datasets) {
97
+ next[ds.name] = state.datasets[ds.name] ?? {
98
+ status: "idle", rows: [], columns: ds.columns ?? [], error: null, stale: false,
99
+ };
100
+ }
101
+ return { ...state, datasets: next };
102
+ }
103
+ case "loading":
104
+ return { ...state, datasets: { ...state.datasets, [action.name]: { ...state.datasets[action.name], status: "loading" } } };
105
+ case "ready":
106
+ return { ...state, datasets: { ...state.datasets, [action.name]: { status: "ready", rows: action.rows, columns: action.columns, error: null } } };
107
+ case "error":
108
+ return { ...state, datasets: { ...state.datasets, [action.name]: { status: "error", rows: [], columns: action.columns ?? [], error: action.error } } };
109
+ case "invalidate": {
110
+ const next = { ...state.datasets };
111
+ // An invalidated dataset returns to `idle`, KEEPING its last rows. A filter change should
112
+ // dim the old answer, not blank the table — a blank table reads as "no matching rows", which
113
+ // is the confident lie this renderer exists to refuse.
114
+ for (const name of action.names) next[name] = { ...next[name], status: "idle", stale: true };
115
+ return { ...state, datasets: next, epoch: state.epoch + 1 };
116
+ }
117
+ case "params":
118
+ return { ...state, params: { ...state.params, ...action.params } };
119
+ default:
120
+ return state;
121
+ }
122
+ }
123
+
124
+ /**
125
+ * One bounded dataset request. Timeout aborts the browser fetch and rejects with a stable message;
126
+ * callers retain the abort handle so unmounts, parameter changes and manual refreshes also stop
127
+ * work that no longer has a consumer. Nothing here retries — an error is terminal until the user
128
+ * explicitly presses the block's Retry control.
129
+ */
130
+ export function startDatasetResolve(resolve, args, { timeoutMs = DATASET_TIMEOUT_MS } = {}) {
131
+ const controller = new AbortController();
132
+ let settled = false;
133
+ let timer;
134
+ let rejectOuter;
135
+
136
+ const promise = new Promise((resolveOuter, reject) => {
137
+ rejectOuter = reject;
138
+ const finish = (fn, value) => {
139
+ if (settled) return;
140
+ settled = true;
141
+ clearTimeout(timer);
142
+ fn(value);
143
+ };
144
+ timer = setTimeout(() => {
145
+ const error = new Error(`Dataset "${args.dataset}" timed out after ${Math.ceil(timeoutMs / 1000)}s.`);
146
+ controller.abort(error);
147
+ finish(reject, error);
148
+ }, timeoutMs);
149
+
150
+ Promise.resolve()
151
+ .then(() => resolve({ ...args, signal: controller.signal }))
152
+ .then((value) => finish(resolveOuter, value), (error) => finish(reject, error));
153
+ });
154
+
155
+ return {
156
+ promise,
157
+ signal: controller.signal,
158
+ abort(reason = `Dataset "${args.dataset}" was cancelled.`) {
159
+ if (settled) return;
160
+ const error = new Error(reason);
161
+ controller.abort(error);
162
+ settled = true;
163
+ clearTimeout(timer);
164
+ rejectOuter(error);
165
+ },
166
+ };
167
+ }
168
+
169
+ function initState(page, suppliedParams) {
170
+ const datasets = {};
171
+ for (const ds of page.data ?? []) datasets[ds.name] = { status: "idle", rows: [], columns: ds.columns ?? [], error: null, stale: false };
172
+ return { datasets, params: initialParams(page, suppliedParams), epoch: 0 };
173
+ }
174
+
175
+ /**
176
+ * @param {object} page the OBL page document
177
+ * @param {Function} resolve `({page, dataset, params}) => Promise<{rows}>` — the ONE data surface.
178
+ * The renderer never sees a connector, a query, or a credential.
179
+ * @param {object} options
180
+ * @param {object} [options.params] host-supplied param values (url, session)
181
+ * @param {number} [options.concurrency] datasets in flight at once. 2 keeps the top of the page
182
+ * first without leaving one slow query alone on the wire.
183
+ */
184
+ export function usePageData(page, resolve, { params: suppliedParams, concurrency = 2, timeoutMs = DATASET_TIMEOUT_MS } = {}) {
185
+ const [state, dispatch] = useReducer(reducer, undefined, () => initState(page, suppliedParams));
186
+ const order = useMemo(() => planDatasetOrder(page), [page]);
187
+ const paramDeps = useMemo(() => datasetParamDeps(page), [page]);
188
+ const inflight = useRef(new Set());
189
+ const requests = useRef(new Map());
190
+ const generation = useRef(0);
191
+ const unmounted = useRef(false);
192
+ /**
193
+ * Datasets that have SETTLED (ready or error) for the current generation, recorded synchronously
194
+ * — the reducer's answer arrives only when React next flushes. Without this, a resolver that
195
+ * settles in the same microtask turn (any in-memory resolver: tests, demos, offline fixtures)
196
+ * starves that flush: the post-settle pump reads the pre-flush `status: "idle"` through
197
+ * `latest`, refires the same dataset, settles again, and the microtask chain never yields to
198
+ * the MessageChannel macrotask React flushes on — a hard main-thread freeze (measured at 1.4M
199
+ * resolves in 8s). Same principle as `inflight` above: the ref is the authority, the reducer
200
+ * is the render. Entries are generation-scoped, so `setParam`/`refresh` (which bump the
201
+ * generation as they invalidate) naturally re-arm the pump.
202
+ */
203
+ const settled = useRef(new Map());
204
+
205
+ // The pump reads the LATEST state through a ref rather than through the effect's closure. An
206
+ // effect that closes over `state.datasets` and cancels on cleanup would abort every in-flight
207
+ // request the moment the first one landed and re-rendered — the datasets would never arrive.
208
+ const latest = useRef(state);
209
+ latest.current = state;
210
+
211
+ const columnsFor = useCallback(
212
+ (name) => (page.data ?? []).find((d) => d.name === name)?.columns ?? [],
213
+ [page],
214
+ );
215
+
216
+ useEffect(() => {
217
+ unmounted.current = false;
218
+ return () => {
219
+ unmounted.current = true;
220
+ for (const request of requests.current.values()) request.abort();
221
+ requests.current.clear();
222
+ inflight.current.clear();
223
+ };
224
+ }, []);
225
+
226
+ // Streaming can legitimately replace an initial declaration (for example a template offer).
227
+ // Synchronise by name so a renderer that first saw `data: []` can still resolve the reconciled
228
+ // document instead of keeping an empty reducer state forever.
229
+ const declarationKey = (page.data ?? []).map((d) => d.name).join("\u001f");
230
+ useEffect(() => {
231
+ const declared = new Set((page.data ?? []).map((d) => d.name));
232
+ for (const [name, request] of requests.current) {
233
+ if (!declared.has(name)) request.abort();
234
+ }
235
+ dispatch({ type: "sync", datasets: page.data ?? [] });
236
+ }, [declarationKey]);
237
+
238
+ /** Keep `concurrency` datasets in flight, always the earliest idle one in plan order. */
239
+ const pump = useCallback(() => {
240
+ for (const name of order) {
241
+ if (unmounted.current) return;
242
+ if (inflight.current.size >= concurrency) return;
243
+ // `inflight` is the authority, not `status`. `dispatch` is batched, so two pump calls in one
244
+ // tick both still see `status: "idle"` and both fire the request — the resolver gets asked
245
+ // the same question twice, and the second answer overwrites the first. The ref updates
246
+ // synchronously; the reducer does not.
247
+ if (inflight.current.has(name)) continue;
248
+ if (settled.current.get(name) === generation.current) continue;
249
+ if (latest.current.datasets[name]?.status !== "idle") continue;
250
+
251
+ const myGeneration = generation.current;
252
+ inflight.current.add(name);
253
+ dispatch({ type: "loading", name });
254
+
255
+ const request = startDatasetResolve(
256
+ resolve,
257
+ { page: page.id, dataset: name, params: latest.current.params, pageDoc: page },
258
+ { timeoutMs },
259
+ );
260
+ requests.current.set(name, request);
261
+
262
+ request.promise
263
+ .then((out) => {
264
+ // A result from before a `set_param` describes a page the user has moved on from.
265
+ if (unmounted.current || generation.current !== myGeneration) return;
266
+ settled.current.set(name, myGeneration);
267
+ dispatch({ type: "ready", name, rows: out?.rows ?? [], columns: out?.columns?.length ? out.columns : columnsFor(name) });
268
+ })
269
+ .catch((error) => {
270
+ if (unmounted.current || generation.current !== myGeneration) return;
271
+ // A dataset that failed is an ERROR, never an empty result set. "No matching rows" and
272
+ // "I could not ask the question" must never look the same (DECISIONS R9).
273
+ settled.current.set(name, myGeneration);
274
+ dispatch({ type: "error", name, error: error?.message ?? String(error), columns: columnsFor(name) });
275
+ })
276
+ .finally(() => {
277
+ if (requests.current.get(name) === request) requests.current.delete(name);
278
+ inflight.current.delete(name);
279
+ if (!unmounted.current) queueMicrotask(() => pumpRef.current());
280
+ });
281
+ }
282
+ }, [page, resolve, order, concurrency, columnsFor, timeoutMs]);
283
+
284
+ const pumpRef = useRef(pump);
285
+ pumpRef.current = pump;
286
+
287
+ // Re-run after every render: an `invalidate` returns datasets to `idle` and this picks them up.
288
+ useEffect(() => { pump(); });
289
+
290
+ const setParam = useCallback((name, value) => {
291
+ generation.current += 1;
292
+ dispatch({ type: "params", params: { [name]: value } });
293
+ const affected = [...paramDeps.entries()].filter(([, deps]) => deps.has(name)).map(([ds]) => ds);
294
+ if (affected.length) {
295
+ for (const ds of affected) requests.current.get(ds)?.abort();
296
+ dispatch({ type: "invalidate", names: affected });
297
+ }
298
+ }, [paramDeps]);
299
+
300
+ const refresh = useCallback((names) => {
301
+ generation.current += 1;
302
+ const all = (page.data ?? []).map((d) => d.name);
303
+ const targets = names === "all" || !names ? all : names;
304
+ for (const ds of targets) requests.current.get(ds)?.abort();
305
+ dispatch({ type: "invalidate", names: targets });
306
+ }, [page]);
307
+
308
+ const dataset = useCallback((name) => state.datasets[name], [state.datasets]);
309
+
310
+ /** A block can paint when every dataset it reads is `ready`. */
311
+ const blockReady = useCallback(
312
+ (block) => [...collectDatasets(block)].every((n) => state.datasets[n]?.status === "ready"),
313
+ [state.datasets],
314
+ );
315
+ const blockError = useCallback(
316
+ (block) => [...collectDatasets(block)].map((n) => state.datasets[n]).find((d) => d?.status === "error")?.error ?? null,
317
+ [state.datasets],
318
+ );
319
+ const blockStale = useCallback(
320
+ (block) => [...collectDatasets(block)].some((n) => state.datasets[n]?.stale && state.datasets[n]?.status !== "ready"),
321
+ [state.datasets],
322
+ );
323
+
324
+ return { datasets: state.datasets, params: state.params, dataset, setParam, refresh, blockReady, blockError, blockStale, order };
325
+ }
326
+
327
+
328
+ /**
329
+ * Whether one block has SETTLED — nothing about it is still on its way to the screen.
330
+ *
331
+ * - a block that reads no data settled the moment it painted;
332
+ * - an errored dataset is terminal (nothing here retries unbidden), so its block has settled
333
+ * INTO its error state — a gate that waited for it to become "ready" would wait forever;
334
+ * - otherwise the block settles when its data is ready AND the reveal dwell has released it —
335
+ * rows in memory whose tile still wears its skeleton have not been revealed to anyone.
336
+ *
337
+ * This is the pure heart of `<UIResponsePage onAllRevealed>`: the page has revealed when every walked
338
+ * block satisfies this.
339
+ */
340
+ export const blockSettled = ({ needsData, error, dataReady, held }) =>
341
+ !needsData || Boolean(error) || (dataReady && !held);
342
+
343
+ /**
344
+ * The parameters a page presented in an overlay is resolved with (DECISIONS A13, P8).
345
+ *
346
+ * The action supplies what the user chose — the row they clicked. The HOST supplies identity:
347
+ * `source: "session"` params like `label_id` or `current_user_id`, which P8 says are "the ONLY way
348
+ * user identity may enter a page", and which a saved document therefore can never carry.
349
+ *
350
+ * So a `navigate` can never name them, and merging them in is structurally the renderer's job. Miss
351
+ * it and every session-scoped drawer fails every dataset with `parameter "label_id" is required` —
352
+ * which is the resolver correctly refusing to guess who is asking.
353
+ *
354
+ * The action's own params win on collision: the row the user clicked is more specific than the
355
+ * session they clicked it in.
356
+ */
357
+ export const overlayParams = (sessionParams, actionParams) => ({ ...sessionParams, ...actionParams });