@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.
- package/ATTRIBUTION.md +59 -0
- package/README.md +191 -0
- package/dist/uir-renderer-web.css +1 -0
- package/dist/uir-renderer-web.js +2213 -0
- package/package.json +63 -0
- package/src/UIResponsePage.jsx +576 -0
- package/src/blocks/containers.jsx +150 -0
- package/src/blocks/content.jsx +292 -0
- package/src/blocks/context.js +27 -0
- package/src/blocks/data.jsx +551 -0
- package/src/blocks/primitives.jsx +290 -0
- package/src/blocks/viz.jsx +616 -0
- package/src/core/actions.js +201 -0
- package/src/core/condition.js +90 -0
- package/src/core/empty.js +44 -0
- package/src/core/format.js +225 -0
- package/src/core/presentation.js +58 -0
- package/src/core/record.js +160 -0
- package/src/core/store.js +357 -0
- package/src/core/value.js +211 -0
- package/src/index.js +22 -0
- package/src/theme/tokens.js +364 -0
- package/src/theme/uir.css +1070 -0
package/package.json
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@uiresponse/renderer-react",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "OBL's first-party React renderer. Consumes OBL page JSON directly; no Lang step, nothing lossy.",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"author": "Orthen",
|
|
8
|
+
"homepage": "https://github.com/dnljl/uir/tree/main/uir/renderer/web#readme",
|
|
9
|
+
"repository": {
|
|
10
|
+
"type": "git",
|
|
11
|
+
"url": "git+https://github.com/dnljl/uir.git",
|
|
12
|
+
"directory": "renderer/web"
|
|
13
|
+
},
|
|
14
|
+
"bugs": {
|
|
15
|
+
"url": "https://github.com/dnljl/uir/issues"
|
|
16
|
+
},
|
|
17
|
+
"keywords": ["uir", "generative-ui", "orthen", "react", "renderer"],
|
|
18
|
+
"sideEffects": ["*.css"],
|
|
19
|
+
"main": "./dist/uir-renderer-web.js",
|
|
20
|
+
"module": "./dist/uir-renderer-web.js",
|
|
21
|
+
"style": "./dist/uir-renderer-web.css",
|
|
22
|
+
"exports": {
|
|
23
|
+
".": {
|
|
24
|
+
"import": "./dist/uir-renderer-web.js",
|
|
25
|
+
"default": "./dist/uir-renderer-web.js"
|
|
26
|
+
},
|
|
27
|
+
"./styles.css": "./dist/uir-renderer-web.css",
|
|
28
|
+
"./src": "./src/index.js",
|
|
29
|
+
"./package.json": "./package.json"
|
|
30
|
+
},
|
|
31
|
+
"files": [
|
|
32
|
+
"dist",
|
|
33
|
+
"src",
|
|
34
|
+
"README.md",
|
|
35
|
+
"ATTRIBUTION.md"
|
|
36
|
+
],
|
|
37
|
+
"scripts": {
|
|
38
|
+
"build": "vite build",
|
|
39
|
+
"prepack": "npm run build",
|
|
40
|
+
"test": "node tests.mjs",
|
|
41
|
+
"preview:dev": "vite --config preview/vite.config.js",
|
|
42
|
+
"preview:build": "vite build --config preview/vite.config.js"
|
|
43
|
+
},
|
|
44
|
+
"peerDependencies": {
|
|
45
|
+
"react": ">=18",
|
|
46
|
+
"react-dom": ">=18"
|
|
47
|
+
},
|
|
48
|
+
"dependencies": {
|
|
49
|
+
"@openuidev/react-ui": "^0.12.1"
|
|
50
|
+
},
|
|
51
|
+
"devDependencies": {
|
|
52
|
+
"@vitejs/plugin-react": "^4.3.4",
|
|
53
|
+
"react": "^19.0.0",
|
|
54
|
+
"react-dom": "^19.0.0",
|
|
55
|
+
"vite": "^6.0.0"
|
|
56
|
+
},
|
|
57
|
+
"engines": {
|
|
58
|
+
"node": ">=20.6.0"
|
|
59
|
+
},
|
|
60
|
+
"publishConfig": {
|
|
61
|
+
"access": "public"
|
|
62
|
+
}
|
|
63
|
+
}
|
|
@@ -0,0 +1,576 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* <UIResponsePage> — OBL's first-party React renderer.
|
|
3
|
+
*
|
|
4
|
+
* Consumes an OBL page document DIRECTLY. There is no Lang step and nothing lossy: the block tree in
|
|
5
|
+
* the JSON is the component tree on the screen, and every semantic the document carries — `format`,
|
|
6
|
+
* `empty`, `emphasis`, `density`, `intent.priority`, `visible_when` — reaches a pixel (DECISIONS X4).
|
|
7
|
+
*
|
|
8
|
+
* <UIResponsePage page={pageDoc} resolve={resolveFn} theme={tokens} />
|
|
9
|
+
*
|
|
10
|
+
* `resolve` is the ONE data surface. The renderer never sees a connector, a query, or a credential:
|
|
11
|
+
* it asks for `{page, dataset, params}` and receives `{rows}`. Principle 4, enforced by the props.
|
|
12
|
+
*/
|
|
13
|
+
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
14
|
+
import { UIResponseContext } from "./blocks/context.js";
|
|
15
|
+
import { usePageData, overlayParams, blockSettled } from "./core/store.js";
|
|
16
|
+
import { visibility } from "./core/condition.js";
|
|
17
|
+
import { resolveValue, collectDatasets } from "./core/value.js";
|
|
18
|
+
import { formatValue } from "./core/format.js";
|
|
19
|
+
import { hidesBlock } from "./core/empty.js";
|
|
20
|
+
import { pageHeadlineIsInDocument } from "./core/record.js";
|
|
21
|
+
import { defaultTheme, makeTheme, themeToCssVars } from "./theme/tokens.js";
|
|
22
|
+
import { Skeleton, BlockError, ConfirmDialog, ValidationTick } from "./blocks/primitives.jsx";
|
|
23
|
+
import { SectionBlock, CardBlock, TabsBlock } from "./blocks/containers.jsx";
|
|
24
|
+
import { TableBlock, ListBlock, DetailBlock, TimelineBlock, CalendarBlock } from "./blocks/data.jsx";
|
|
25
|
+
import { ChartBlock, MetricBlock, ProgressBlock, BadgeBlock, StageChecklistBlock } from "./blocks/viz.jsx";
|
|
26
|
+
import { TextBlock, MediaBlock, InputBlock, ButtonBlock } from "./blocks/content.jsx";
|
|
27
|
+
|
|
28
|
+
const LEAVES = {
|
|
29
|
+
table: TableBlock, list: ListBlock, detail: DetailBlock, timeline: TimelineBlock, calendar: CalendarBlock,
|
|
30
|
+
chart: ChartBlock, metric: MetricBlock, progress: ProgressBlock, badge: BadgeBlock,
|
|
31
|
+
stage_checklist: StageChecklistBlock,
|
|
32
|
+
text: TextBlock, media: MediaBlock, input: InputBlock, button: ButtonBlock,
|
|
33
|
+
};
|
|
34
|
+
const CONTAINERS = new Set(["section", "card", "tabs"]);
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Which blocks are TILES: self-contained objects placed on the host's canvas.
|
|
38
|
+
*
|
|
39
|
+
* The renderer used to draw a bounded paper sheet and lay blocks inside it — a page within a page.
|
|
40
|
+
* That reads as a document. It is not what a generated surface is. A generated answer is a handful of
|
|
41
|
+
* elements put ON the surface, and the surface belongs to the host: a workroom canvas, a chat
|
|
42
|
+
* thread, a home screen.
|
|
43
|
+
*
|
|
44
|
+
* So `metric`, `chart`, `table`, `list`, `detail`, `timeline`, `calendar`, `progress` and `media`
|
|
45
|
+
* each become a discrete rounded tile. `text`, `badge`, `stage_checklist`, `input` and `button` do
|
|
46
|
+
* not: prose, a status word, an ordered run of statuses, a field and a verb are elements, not
|
|
47
|
+
* objects, and boxing them would be chrome for chrome's sake.
|
|
48
|
+
*/
|
|
49
|
+
const TILES = new Set(["metric", "chart", "table", "list", "detail", "timeline", "calendar", "progress", "media"]);
|
|
50
|
+
|
|
51
|
+
/** The skeleton shape a block wears while its datasets are in flight. */
|
|
52
|
+
const SKELETON_VARIANT = { metric: "metric", progress: "value", badge: "value", text: "value", stage_checklist: "value" };
|
|
53
|
+
|
|
54
|
+
const prefersReducedMotion = () =>
|
|
55
|
+
typeof window !== "undefined" && typeof window.matchMedia === "function"
|
|
56
|
+
? window.matchMedia("(prefers-reduced-motion: reduce)").matches
|
|
57
|
+
: false;
|
|
58
|
+
|
|
59
|
+
/** Leaves only. A page of one or two elements gets a label, not a masthead. */
|
|
60
|
+
function countLeaves(blocks, n = 0) {
|
|
61
|
+
for (const b of blocks ?? []) {
|
|
62
|
+
n = CONTAINERS.has(b.type) ? countLeaves(b.children, n) : n + 1;
|
|
63
|
+
}
|
|
64
|
+
return n;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function UIResponsePage({
|
|
68
|
+
page,
|
|
69
|
+
resolve,
|
|
70
|
+
theme: themeProp,
|
|
71
|
+
params: suppliedParams,
|
|
72
|
+
onOpenRecord,
|
|
73
|
+
onNotice,
|
|
74
|
+
confirm,
|
|
75
|
+
now,
|
|
76
|
+
concurrency = 2,
|
|
77
|
+
datasetTimeoutMs,
|
|
78
|
+
/** Inline chat context: same artifact and data semantics, without title/shell chrome. */
|
|
79
|
+
inline = false,
|
|
80
|
+
className,
|
|
81
|
+
/**
|
|
82
|
+
* The minimum time a data-bearing block wears its skeleton before its content is allowed to
|
|
83
|
+
* paint (design behavior B4: "skeleton-before-content, always"). Instant data is real — but a
|
|
84
|
+
* tile that skips its skeleton reads as a pop, not an arrival, so the host may ask for a floor.
|
|
85
|
+
* 0 (the default) keeps today's paint-when-ready behavior; `prefers-reduced-motion` forces 0.
|
|
86
|
+
*/
|
|
87
|
+
revealDwellMs = 0,
|
|
88
|
+
/**
|
|
89
|
+
* The host says the page passed Phase 2. Only then does a block draw the design's validation tick:
|
|
90
|
+
* the renderer does not validate, so a tick it drew unbidden would assert a check nobody ran.
|
|
91
|
+
*/
|
|
92
|
+
validated = false,
|
|
93
|
+
/**
|
|
94
|
+
* `(pageId) => pageDoc | Promise<pageDoc>`. What makes `navigate.mode: "overlay"` executable
|
|
95
|
+
* (DECISIONS A13): the renderer can PRESENT a page over the one it is drawing, but it cannot
|
|
96
|
+
* invent the document. Without this, an overlay navigate draws disabled, wearing its reason.
|
|
97
|
+
*
|
|
98
|
+
* The host must return a page it has VALIDATED. The overlay renders it exactly as `<UIResponsePage>`
|
|
99
|
+
* renders anything, and this renderer trusts its input.
|
|
100
|
+
*/
|
|
101
|
+
resolvePage,
|
|
102
|
+
/**
|
|
103
|
+
* THE COMMENTARY TRACK. Generation-time narration lines — `[{ after, text, slots }]` — spoken
|
|
104
|
+
* over the page as it fills in: "The soonest is {title}, {date}." The model PHRASES a line and
|
|
105
|
+
* POINTS at values with slots (ordinary OBL bindings); it never states one, because it has
|
|
106
|
+
* never seen a row. Each line paints after the block it narrates, once every slot's dataset is
|
|
107
|
+
* ready, with the slots resolved and formatted by the same machinery as every block. A slot
|
|
108
|
+
* that cannot resolve withholds its line — narration understates, never invents. Not part of
|
|
109
|
+
* the page document: a saved view keeps its data live and its commentary in the past.
|
|
110
|
+
*/
|
|
111
|
+
narration = null,
|
|
112
|
+
/**
|
|
113
|
+
* `(settled: boolean) => void` — called with `true` each time the page reaches the state where
|
|
114
|
+
* every data-bearing block on screen has revealed its content (or settled terminally into an
|
|
115
|
+
* error), and with `false` when new unrevealed content appears (a streamed document growing,
|
|
116
|
+
* a refresh). Reported after commit, on transitions only; the mount state is always reported.
|
|
117
|
+
*
|
|
118
|
+
* THE SEAM, and why it is this and not dataset events: "revealed" is not "resolved". The dwell
|
|
119
|
+
* gate holds a tile's content on its skeleton after its rows are already in memory, page-wide,
|
|
120
|
+
* one per beat — timing only this component knows. A host deriving readiness from its own
|
|
121
|
+
* `resolve` calls would re-implement that private choreography and drift the first time it
|
|
122
|
+
* changed. So the renderer reports the FACT (what a viewer can see) and keeps the mechanism;
|
|
123
|
+
* the host composes rhythm — outros, CTAs, composer re-enable — on top of the fact.
|
|
124
|
+
*/
|
|
125
|
+
onAllRevealed,
|
|
126
|
+
/** Internal. An overlay does not open an overlay — see the comment on `openOverlay`. */
|
|
127
|
+
__inOverlay = false,
|
|
128
|
+
}) {
|
|
129
|
+
const theme = useMemo(() => (themeProp ? makeTheme(themeProp) : defaultTheme), [themeProp]);
|
|
130
|
+
const cssVars = useMemo(() => themeToCssVars(theme), [theme]);
|
|
131
|
+
const clock = useMemo(() => now ?? new Date(), [now]);
|
|
132
|
+
const [notice, setNotice] = useState(null);
|
|
133
|
+
const [confirmation, setConfirmation] = useState(null);
|
|
134
|
+
const confirmationResolve = useRef(null);
|
|
135
|
+
|
|
136
|
+
const defaultConfirm = useCallback((prompt) => new Promise((resolve) => {
|
|
137
|
+
confirmationResolve.current?.(false);
|
|
138
|
+
confirmationResolve.current = resolve;
|
|
139
|
+
setConfirmation(prompt);
|
|
140
|
+
}), []);
|
|
141
|
+
const settleConfirmation = useCallback((accepted) => {
|
|
142
|
+
const resolvePending = confirmationResolve.current;
|
|
143
|
+
confirmationResolve.current = null;
|
|
144
|
+
setConfirmation(null);
|
|
145
|
+
resolvePending?.(accepted);
|
|
146
|
+
}, []);
|
|
147
|
+
useEffect(() => () => confirmationResolve.current?.(false), []);
|
|
148
|
+
const effectiveConfirm = confirm ?? defaultConfirm;
|
|
149
|
+
|
|
150
|
+
const { datasets, dataset, params, setParam, refresh, blockReady, blockError, blockStale } =
|
|
151
|
+
usePageData(page, resolve, { params: suppliedParams, concurrency, timeoutMs: datasetTimeoutMs });
|
|
152
|
+
|
|
153
|
+
// The masthead states what the page is doing, in the page's own terms. An internal block id or a
|
|
154
|
+
// spec version would be engineering talking to itself; "2 of 3 datasets" is the thing a person
|
|
155
|
+
// watching a page fill in actually wants to know.
|
|
156
|
+
const status = useMemo(() => {
|
|
157
|
+
const all = Object.values(datasets);
|
|
158
|
+
if (!all.length) return null;
|
|
159
|
+
const ready = all.filter((d) => d.status === "ready").length;
|
|
160
|
+
const failed = all.filter((d) => d.status === "error").length;
|
|
161
|
+
if (failed) return `${failed} of ${all.length} datasets failed`;
|
|
162
|
+
if (ready < all.length) return `Resolving · ${ready} of ${all.length} datasets`;
|
|
163
|
+
return `${all.length} ${all.length === 1 ? "dataset" : "datasets"} · live`;
|
|
164
|
+
}, [datasets]);
|
|
165
|
+
|
|
166
|
+
const notify = useCallback((msg) => {
|
|
167
|
+
onNotice?.(msg);
|
|
168
|
+
setNotice(msg);
|
|
169
|
+
}, [onNotice]);
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* `navigate.mode: "overlay"` — present a page without leaving this one (DECISIONS A13).
|
|
173
|
+
*
|
|
174
|
+
* The overlay is NOT a stack. An overlay may not open an overlay: two drawers deep is chrome
|
|
175
|
+
* managing chrome, and OBL's claim is only "the user has not left". The nested page therefore
|
|
176
|
+
* receives no `resolvePage`, so its own overlay navigations draw disabled and say why — which is a
|
|
177
|
+
* far better outcome than a drawer sliding out from under a drawer.
|
|
178
|
+
*
|
|
179
|
+
* `openOverlay` is absent from `actionCtx` when the host gave us no `resolvePage`, and `verbReason`
|
|
180
|
+
* reads exactly that absence to decide whether the verb can run. One source of truth.
|
|
181
|
+
*/
|
|
182
|
+
const [overlay, setOverlay] = useState(null);
|
|
183
|
+
// The element that opened the drawer, so focus can go back to it. Captured when the ACTION fires,
|
|
184
|
+
// not when the panel mounts: by mount time the row may already have lost focus, and `document.body`
|
|
185
|
+
// is where focus lands when nobody remembers.
|
|
186
|
+
const overlayTrigger = useRef(null);
|
|
187
|
+
|
|
188
|
+
const openOverlay = useCallback(async ({ pageId, params }) => {
|
|
189
|
+
overlayTrigger.current = typeof document !== "undefined" ? document.activeElement : null;
|
|
190
|
+
setOverlay({ pageId, params, doc: null, error: null });
|
|
191
|
+
try {
|
|
192
|
+
const doc = await resolvePage(pageId);
|
|
193
|
+
if (!doc) throw new Error(`the host has no page \`${pageId}\``);
|
|
194
|
+
setOverlay({ pageId, params, doc, error: null });
|
|
195
|
+
} catch (e) {
|
|
196
|
+
setOverlay({ pageId, params, doc: null, error: e.message });
|
|
197
|
+
}
|
|
198
|
+
}, [resolvePage]);
|
|
199
|
+
const closeOverlay = useCallback(() => setOverlay(null), []);
|
|
200
|
+
|
|
201
|
+
const actionCtx = useMemo(() => ({
|
|
202
|
+
setParam, refresh, onOpenRecord, onNotice: notify, confirm: effectiveConfirm, resolveValue,
|
|
203
|
+
...(resolvePage && !__inOverlay ? { openOverlay } : null),
|
|
204
|
+
}), [setParam, refresh, onOpenRecord, notify, effectiveConfirm, resolvePage, __inOverlay, openOverlay]);
|
|
205
|
+
|
|
206
|
+
const uir = useMemo(() => ({
|
|
207
|
+
// `pageId` is here for continuity across closes and reopens — the count-up seeds its climb
|
|
208
|
+
// from the value this page last showed, and a value is only "the same one" per page per block.
|
|
209
|
+
dataset, params, now: clock, theme, actionCtx, row: undefined, pageId: page.id,
|
|
210
|
+
}), [dataset, params, clock, theme, actionCtx, page.id]);
|
|
211
|
+
|
|
212
|
+
const scope = useMemo(() => ({ dataset, params, now: clock }), [dataset, params, clock]);
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* THE SKELETON DWELL — and the CONTENT BEAT. Two floors, one knob:
|
|
216
|
+
*
|
|
217
|
+
* · Each data-bearing block wears its skeleton for at least `revealDwellMs` from its first
|
|
218
|
+
* paint, even if its rows are already here ("skeleton-before-content, always").
|
|
219
|
+
* · Contents flip in no faster than one per `revealDwellMs`, page-wide. Two blocks bound to
|
|
220
|
+
* the SAME dataset become ready in the same instant — without a shared floor they pop in the
|
|
221
|
+
* same frame, which is precisely the "load, not composition" the beat exists to prevent.
|
|
222
|
+
* Blocks claim their slot in document order, because that is the order render walks them.
|
|
223
|
+
*
|
|
224
|
+
* A revealed block STAYS revealed — the gate is for arrival, not for life. A timer per held
|
|
225
|
+
* block re-renders at its slot — nothing polls. Ids are the keys, so a block that re-enters
|
|
226
|
+
* after a failed round dwells again (its content IS arriving again).
|
|
227
|
+
*/
|
|
228
|
+
const dwellMs = revealDwellMs > 0 && !prefersReducedMotion() ? revealDwellMs : 0;
|
|
229
|
+
const firstPaintAt = useRef(new Map());
|
|
230
|
+
const revealedIds = useRef(new Set());
|
|
231
|
+
const lastRevealAt = useRef(-Infinity);
|
|
232
|
+
const dwellTimers = useRef(new Map());
|
|
233
|
+
const [, bumpDwell] = useState(0);
|
|
234
|
+
useEffect(() => {
|
|
235
|
+
const timers = dwellTimers.current;
|
|
236
|
+
return () => { for (const t of timers.values()) clearTimeout(t); };
|
|
237
|
+
}, []);
|
|
238
|
+
|
|
239
|
+
const dwelling = useCallback((id, ready) => {
|
|
240
|
+
if (!dwellMs) return false;
|
|
241
|
+
if (revealedIds.current.has(id)) return false;
|
|
242
|
+
const nowMs = Date.now();
|
|
243
|
+
let seen = firstPaintAt.current.get(id);
|
|
244
|
+
if (seen === undefined) {
|
|
245
|
+
seen = nowMs;
|
|
246
|
+
firstPaintAt.current.set(id, seen);
|
|
247
|
+
}
|
|
248
|
+
if (!ready) return false; // the natural skeleton is already showing; the clock started at first paint
|
|
249
|
+
const allowedAt = Math.max(seen + dwellMs, lastRevealAt.current + dwellMs);
|
|
250
|
+
if (nowMs >= allowedAt) {
|
|
251
|
+
revealedIds.current.add(id);
|
|
252
|
+
lastRevealAt.current = nowMs;
|
|
253
|
+
return false;
|
|
254
|
+
}
|
|
255
|
+
if (!dwellTimers.current.has(id)) {
|
|
256
|
+
dwellTimers.current.set(
|
|
257
|
+
id,
|
|
258
|
+
setTimeout(() => {
|
|
259
|
+
dwellTimers.current.delete(id);
|
|
260
|
+
bumpDwell((n) => n + 1);
|
|
261
|
+
}, allowedAt - nowMs + 15),
|
|
262
|
+
);
|
|
263
|
+
}
|
|
264
|
+
return true;
|
|
265
|
+
}, [dwellMs]);
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* SETTLEMENT — the ledger behind `onAllRevealed`. Reset at the top of every render pass and
|
|
269
|
+
* repopulated by `renderBlock` as it walks (children render after this function returns, still
|
|
270
|
+
* before the commit's effects — so the effect below always reads a complete pass). A partial
|
|
271
|
+
* re-render (a tab switching) only overwrites its own entries, and every state change that can
|
|
272
|
+
* move a block's settledness (datasets, the dwell timers) re-renders this component, so the
|
|
273
|
+
* ledger is never stale when it is read.
|
|
274
|
+
*/
|
|
275
|
+
const settlement = useRef(new Map());
|
|
276
|
+
const reportedAllRevealed = useRef(undefined);
|
|
277
|
+
settlement.current = new Map();
|
|
278
|
+
|
|
279
|
+
useEffect(() => {
|
|
280
|
+
if (!onAllRevealed) return;
|
|
281
|
+
let all = true;
|
|
282
|
+
for (const settled of settlement.current.values()) {
|
|
283
|
+
if (!settled) { all = false; break; }
|
|
284
|
+
}
|
|
285
|
+
if (all !== reportedAllRevealed.current) {
|
|
286
|
+
reportedAllRevealed.current = all;
|
|
287
|
+
onAllRevealed(all);
|
|
288
|
+
}
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
/** One or two elements is an answer, not a document. It gets a label. */
|
|
292
|
+
const minimal = useMemo(() => countLeaves(page.blocks) <= 2, [page.blocks]);
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* …and an answer that already OPENS with a headline gets nothing at all. A browse page is a single
|
|
296
|
+
* editorial section, so the shell's title and the section's headline were printing the same thing
|
|
297
|
+
* twice — the first as a 12px grey label (see `minimal`), the second as a 30px headline directly
|
|
298
|
+
* beneath it. The document's headline wins, because it is the one the author wrote in the document.
|
|
299
|
+
*/
|
|
300
|
+
const headlineInDocument = useMemo(() => pageHeadlineIsInDocument(page), [page]);
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* Whether the shell prints `page.title` at all. It does not when the document opens with its own
|
|
304
|
+
* headline, and it does not inside an overlay — the drawer's head is already showing this exact
|
|
305
|
+
* string, four pixels above where the header would print it again.
|
|
306
|
+
*/
|
|
307
|
+
const showTitle = !headlineInDocument && !__inOverlay;
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* One block. This function is where the document's promises are kept:
|
|
311
|
+
*
|
|
312
|
+
* - a block whose datasets have not landed paints its FRAME and a skeleton, never a blank;
|
|
313
|
+
* - a block whose dataset FAILED says what failed, never an empty table (R9);
|
|
314
|
+
* - `visible_when` is evaluated only once the data it reads exists, because "still loading" and
|
|
315
|
+
* "deliberately absent" are different claims and must never look the same;
|
|
316
|
+
* - `empty.show: "hide"` removes the block, and only that policy does.
|
|
317
|
+
*/
|
|
318
|
+
const renderBlock = useCallback(function render(block, key, opts = {}) {
|
|
319
|
+
const emphasis = block.emphasis ?? "normal";
|
|
320
|
+
const needsData = collectDatasets(block).size > 0;
|
|
321
|
+
const dataReady = !needsData || blockReady(block);
|
|
322
|
+
const error = blockError(block);
|
|
323
|
+
const stale = blockStale(block);
|
|
324
|
+
// The dwell delays PAINT, never truth: visibility and errors read the real readiness, and only
|
|
325
|
+
// a block that would otherwise show content is held on its skeleton.
|
|
326
|
+
const held = needsData && !error && dwelling(block.id ?? key, dataReady);
|
|
327
|
+
const ready = dataReady && !held;
|
|
328
|
+
|
|
329
|
+
// The settlement ledger entry for this block (see `settlement` above). Recorded on every exit
|
|
330
|
+
// path: a hidden block owes nothing, an errored one has settled into its error, and the rest
|
|
331
|
+
// follow `blockSettled` — data ready and the dwell released.
|
|
332
|
+
const note = (settled, node) => {
|
|
333
|
+
settlement.current.set(String(block.id ?? key), settled);
|
|
334
|
+
return node;
|
|
335
|
+
};
|
|
336
|
+
const settledNow = blockSettled({ needsData, error, dataReady, held });
|
|
337
|
+
|
|
338
|
+
const vis = visibility(block, scope, dataReady);
|
|
339
|
+
if (vis === "hidden") return note(true, null);
|
|
340
|
+
// A conditional block whose condition has not resolved draws nothing: we do not yet know that it
|
|
341
|
+
// belongs on this page, and a skeleton is a promise. See core/condition.js.
|
|
342
|
+
if (vis === "pending") return note(settledNow, null);
|
|
343
|
+
|
|
344
|
+
const isTile = TILES.has(block.type);
|
|
345
|
+
|
|
346
|
+
const wrap = (children, state) => (
|
|
347
|
+
<div
|
|
348
|
+
key={key}
|
|
349
|
+
className="uir-block"
|
|
350
|
+
data-block={block.id}
|
|
351
|
+
data-type={block.type}
|
|
352
|
+
data-emphasis={emphasis}
|
|
353
|
+
data-state={state}
|
|
354
|
+
data-stale={stale || undefined}
|
|
355
|
+
data-tile={isTile || undefined}
|
|
356
|
+
// STREAMING ARRIVAL: the tile's frame was already on screen; its content just landed, so it
|
|
357
|
+
// folds in. A block that never waited for data has nothing to arrive and does not animate.
|
|
358
|
+
data-arrived={(isTile && state === "ready" && needsData) || undefined}
|
|
359
|
+
>
|
|
360
|
+
{validated && isTile && state === "ready" && <ValidationTick />}
|
|
361
|
+
{children}
|
|
362
|
+
</div>
|
|
363
|
+
);
|
|
364
|
+
|
|
365
|
+
// Containers paint immediately: their structure is knowable from the document alone, and their
|
|
366
|
+
// children each decide for themselves whether they are ready. That is what "top-down" means.
|
|
367
|
+
if (CONTAINERS.has(block.type)) {
|
|
368
|
+
const childOf = (child, i) => render(child, child.id ?? i, { hideTitle: block.type === "tabs" });
|
|
369
|
+
if (block.type === "section") return note(true, wrap(<SectionBlock block={block} renderChild={childOf} hideTitle={opts.hideTitle} />, "ready"));
|
|
370
|
+
if (block.type === "card") {
|
|
371
|
+
if (!ready && needsData) return note(settledNow, wrap(<Skeleton block={block} />, "pending"));
|
|
372
|
+
return note(settledNow, wrap(<CardBlock block={block} renderChild={childOf} emphasis={emphasis} />, "ready"));
|
|
373
|
+
}
|
|
374
|
+
if (block.type === "tabs") return note(true, wrap(<TabsBlock block={block} renderChild={childOf} />, "ready"));
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
const Leaf = LEAVES[block.type];
|
|
378
|
+
if (!Leaf) {
|
|
379
|
+
// A block from the future. Skip it, keep its siblings, and say so once — never render a
|
|
380
|
+
// partial block, never fail the page (VERSIONING §3.2). It settled as this notice.
|
|
381
|
+
return note(true, wrap(<BlockError message={`This renderer does not know the block type "${block.type}". It needs a newer renderer.`} />, "ready"));
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
if (error) {
|
|
385
|
+
// A dataset that failed can be asked again. `refresh` is already a first-class verb, so the
|
|
386
|
+
// design's Retry costs nothing but the wiring.
|
|
387
|
+
const retry = () => refresh([...collectDatasets(block)]);
|
|
388
|
+
return note(true, wrap(<BlockError message={error} onRetry={retry} />, "error"));
|
|
389
|
+
}
|
|
390
|
+
if (!ready) {
|
|
391
|
+
return note(settledNow, wrap(<Skeleton variant={SKELETON_VARIANT[block.type] ?? "bars"} block={block} />, "pending"));
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
// `empty.show: "hide"` is the one policy that removes a block entirely.
|
|
395
|
+
if (block.value?.$bind?.empty && hidesBlock(block.value.$bind.empty)) {
|
|
396
|
+
const v = resolveValue(block.value, scope);
|
|
397
|
+
if (v === null || v === undefined) return note(true, null);
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
try {
|
|
401
|
+
return note(settledNow, wrap(<Leaf block={block} emphasis={emphasis} />, "ready"));
|
|
402
|
+
} catch (e) {
|
|
403
|
+
// An `only` assertion that fired, a binding that could not resolve. Loud, local, and it does
|
|
404
|
+
// not take the page down with it.
|
|
405
|
+
return note(true, wrap(<BlockError message={e.message} />, "error"));
|
|
406
|
+
}
|
|
407
|
+
}, [blockReady, blockError, blockStale, scope, refresh, validated, dwelling]);
|
|
408
|
+
|
|
409
|
+
/** Narration lines grouped by their anchor block; unanchored lines follow the last block. */
|
|
410
|
+
const narrationAfter = useMemo(() => {
|
|
411
|
+
const by = new Map();
|
|
412
|
+
for (const n of narration ?? []) {
|
|
413
|
+
if (!n || typeof n.text !== "string") continue;
|
|
414
|
+
const key = typeof n.after === "string" ? n.after : "__end__";
|
|
415
|
+
if (!by.has(key)) by.set(key, []);
|
|
416
|
+
by.get(key).push(n);
|
|
417
|
+
}
|
|
418
|
+
return by;
|
|
419
|
+
}, [narration]);
|
|
420
|
+
|
|
421
|
+
const narrationFor = useCallback((blockId) => {
|
|
422
|
+
const lines = narrationAfter.get(blockId);
|
|
423
|
+
if (!lines?.length) return null;
|
|
424
|
+
return lines.map((n, i) => {
|
|
425
|
+
const slotNames = Object.keys(n.slots ?? {});
|
|
426
|
+
// Every slot's dataset must be READY — a commentary line about data that has not landed
|
|
427
|
+
// would be a promise, and half-resolved prose is worse than none.
|
|
428
|
+
const pending = slotNames.some((name) => {
|
|
429
|
+
const ds = n.slots[name]?.$bind?.dataset ?? n.slots[name]?.dataset;
|
|
430
|
+
return ds && datasets[ds]?.status !== "ready";
|
|
431
|
+
});
|
|
432
|
+
if (pending) return null;
|
|
433
|
+
let line = n.text;
|
|
434
|
+
try {
|
|
435
|
+
for (const name of slotNames) {
|
|
436
|
+
const slot = n.slots[name];
|
|
437
|
+
const bind = slot?.$bind ?? slot;
|
|
438
|
+
const value = resolveValue({ $bind: bind }, scope);
|
|
439
|
+
if (value === null || value === undefined || value === "") throw new Error("empty slot");
|
|
440
|
+
const column = (page.data ?? []).find((d) => d.name === bind.dataset)?.columns?.find((c) => c.name === bind.field);
|
|
441
|
+
line = line.split(`{${name}}`).join(formatValue(value, bind.format, { column, now: clock }));
|
|
442
|
+
}
|
|
443
|
+
} catch {
|
|
444
|
+
return null; // a slot that cannot resolve withholds the line — understate, never invent
|
|
445
|
+
}
|
|
446
|
+
return (
|
|
447
|
+
<p key={`narr-${blockId}-${i}`} className="uir-narration">
|
|
448
|
+
{line}
|
|
449
|
+
</p>
|
|
450
|
+
);
|
|
451
|
+
});
|
|
452
|
+
}, [narrationAfter, datasets, scope, page, clock]);
|
|
453
|
+
|
|
454
|
+
return (
|
|
455
|
+
<UIResponseContext.Provider value={uir}>
|
|
456
|
+
<div
|
|
457
|
+
className={["uir-page", inline ? "uir-page--inline" : null, className].filter(Boolean).join(" ")}
|
|
458
|
+
style={cssVars}
|
|
459
|
+
aria-label={inline ? page.title : undefined}
|
|
460
|
+
>
|
|
461
|
+
<div className="uir-shell">
|
|
462
|
+
{/* Chrome scales to content. A dashboard earns a header line; a generated page of one chart
|
|
463
|
+
earns a label and nothing else. A masthead over a single tile is a poster, not a page. */}
|
|
464
|
+
{!inline && (
|
|
465
|
+
<header className="uir-header" data-minimal={minimal || undefined}>
|
|
466
|
+
<div className="uir-header__line">
|
|
467
|
+
{/* The title is chrome; the editorial headline below is the page. When the document
|
|
468
|
+
carries its own, the shell says nothing and keeps only its dataset status. */}
|
|
469
|
+
{showTitle && <h1 className="uir-header__title">{page.title}</h1>}
|
|
470
|
+
{status && (
|
|
471
|
+
<span className="uir-header__status" data-settled={status.endsWith("live") || undefined}>
|
|
472
|
+
{status}
|
|
473
|
+
</span>
|
|
474
|
+
)}
|
|
475
|
+
</div>
|
|
476
|
+
{page.subtitle && !minimal && showTitle && <p className="uir-header__subtitle">{page.subtitle}</p>}
|
|
477
|
+
</header>
|
|
478
|
+
)}
|
|
479
|
+
|
|
480
|
+
{page.blocks.map((block, i) => (
|
|
481
|
+
<React.Fragment key={`f-${block.id ?? i}`}>
|
|
482
|
+
{renderBlock(block, block.id ?? i)}
|
|
483
|
+
{narrationFor(block.id)}
|
|
484
|
+
</React.Fragment>
|
|
485
|
+
))}
|
|
486
|
+
{narrationFor("__end__")}
|
|
487
|
+
|
|
488
|
+
{notice && (
|
|
489
|
+
<div className="uir-notice" role="status">
|
|
490
|
+
<button type="button" className="uir-notice__close" onClick={() => setNotice(null)} aria-label="Dismiss">×</button>
|
|
491
|
+
{notice}
|
|
492
|
+
</div>
|
|
493
|
+
)}
|
|
494
|
+
</div>
|
|
495
|
+
|
|
496
|
+
{overlay && (
|
|
497
|
+
<OverlayDrawer overlay={overlay} onClose={closeOverlay} returnFocusTo={overlayTrigger}>
|
|
498
|
+
{overlay.doc && (
|
|
499
|
+
<UIResponsePage
|
|
500
|
+
page={overlay.doc}
|
|
501
|
+
resolve={resolve}
|
|
502
|
+
theme={themeProp}
|
|
503
|
+
params={overlayParams(suppliedParams, overlay.params)}
|
|
504
|
+
onOpenRecord={onOpenRecord}
|
|
505
|
+
onNotice={onNotice}
|
|
506
|
+
confirm={confirm}
|
|
507
|
+
now={now}
|
|
508
|
+
validated={validated}
|
|
509
|
+
revealDwellMs={revealDwellMs}
|
|
510
|
+
className="uir-page--nested"
|
|
511
|
+
__inOverlay
|
|
512
|
+
/>
|
|
513
|
+
)}
|
|
514
|
+
</OverlayDrawer>
|
|
515
|
+
)}
|
|
516
|
+
{confirmation && <ConfirmDialog prompt={confirmation} onResolve={settleConfirmation} />}
|
|
517
|
+
</div>
|
|
518
|
+
</UIResponseContext.Provider>
|
|
519
|
+
);
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
/**
|
|
523
|
+
* The overlay presentation of a page (DECISIONS A13, design sheet `2a · Chat to page prototype`).
|
|
524
|
+
*
|
|
525
|
+
* Geometry and motion are EXTRACTED from the sheet, not chosen: a 372px right drawer, a 1px left
|
|
526
|
+
* border, a `-16px 0 40px rgba(0,0,0,0.1)` shadow, `obSlideIn 0.32s` on the panel and `obFade 0.25s`
|
|
527
|
+
* on an `rgba(9,9,11,0.18)` scrim. They live in `theme/tokens.js` with everything else.
|
|
528
|
+
*
|
|
529
|
+
* Nothing about a drawer is in the schema. `mode: "overlay"` says only *the user has not left*; this
|
|
530
|
+
* is one medium's answer to that claim, and a voice renderer's answer is the word "aside".
|
|
531
|
+
*
|
|
532
|
+
* Escape closes it, the scrim closes it, and focus moves into the panel on open and back to whatever
|
|
533
|
+
* had it on close. A drawer you cannot leave with the keyboard is a trap, and the page underneath is
|
|
534
|
+
* still there — which is the whole point of the mode.
|
|
535
|
+
*/
|
|
536
|
+
function OverlayDrawer({ overlay, onClose, returnFocusTo, children }) {
|
|
537
|
+
const panelRef = useRef(null);
|
|
538
|
+
|
|
539
|
+
useEffect(() => {
|
|
540
|
+
panelRef.current?.focus();
|
|
541
|
+
const onKey = (e) => { if (e.key === "Escape") { e.stopPropagation(); onClose(); } };
|
|
542
|
+
document.addEventListener("keydown", onKey);
|
|
543
|
+
return () => {
|
|
544
|
+
document.removeEventListener("keydown", onKey);
|
|
545
|
+
// The element may have gone with the row that opened the drawer, and `body` is not a place to
|
|
546
|
+
// put focus — it is where focus goes when nobody put it anywhere.
|
|
547
|
+
const trigger = returnFocusTo?.current;
|
|
548
|
+
if (trigger && trigger !== document.body && trigger.isConnected) trigger.focus();
|
|
549
|
+
};
|
|
550
|
+
}, [onClose]);
|
|
551
|
+
|
|
552
|
+
return (
|
|
553
|
+
<div className="uir-overlay" role="dialog" aria-modal="true" aria-label={overlay.doc?.title ?? "Loading"}>
|
|
554
|
+
<div className="uir-overlay__scrim" onClick={onClose} />
|
|
555
|
+
<div className="uir-overlay__panel" ref={panelRef} tabIndex={-1}>
|
|
556
|
+
<div className="uir-overlay__head">
|
|
557
|
+
<span className="uir-overlay__title">{overlay.doc?.title ?? overlay.pageId}</span>
|
|
558
|
+
<button type="button" className="uir-overlay__close" onClick={onClose} aria-label="Close">
|
|
559
|
+
<svg width="9" height="9" viewBox="0 0 9 9" aria-hidden="true">
|
|
560
|
+
<line x1="1" y1="1" x2="8" y2="8" /><line x1="8" y1="1" x2="1" y2="8" />
|
|
561
|
+
</svg>
|
|
562
|
+
</button>
|
|
563
|
+
</div>
|
|
564
|
+
<div className="uir-overlay__body">
|
|
565
|
+
{overlay.error
|
|
566
|
+
? <BlockError message={`Could not present \`${overlay.pageId}\`: ${overlay.error}`} />
|
|
567
|
+
: overlay.doc
|
|
568
|
+
? children
|
|
569
|
+
: <Skeleton />}
|
|
570
|
+
</div>
|
|
571
|
+
</div>
|
|
572
|
+
</div>
|
|
573
|
+
);
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
export default UIResponsePage;
|