@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,150 @@
1
+ /**
2
+ * The three containers: `section`, `card`, `tabs`.
3
+ *
4
+ * Containers own ARRANGEMENT; blocks own EMPHASIS. There is no layout tree to keep in sync with the
5
+ * block tree, and no per-block position or size — structure IS layout (DECISIONS P1). A section of
6
+ * three cards arranged `grid` tells a web renderer to draw columns, an e-ink renderer to stack, and
7
+ * a voice renderer to say "three cards". This file is the web renderer's half of that bargain.
8
+ */
9
+ import React, { useState } from "react";
10
+ import { UIResponseContext, useUIResponse, scopeFor } from "./context.js";
11
+ import { runWithConfirm } from "../core/actions.js";
12
+ import { isEditorialSection } from "../core/record.js";
13
+
14
+ const arrangementOf = (block) => block.layout?.arrangement ?? "stack";
15
+ const densityOf = (block) => block.layout?.density ?? undefined;
16
+
17
+ /** `intent.priority` as an ordering tiebreak among siblings of equal emphasis. */
18
+ const PRIORITY = { primary: 0, supporting: 1, ambient: 2 };
19
+ const EMPHASIS = { hero: 0, primary: 1, normal: 2, subtle: 3 };
20
+
21
+ /**
22
+ * Sibling order. The DOCUMENT order is authoritative — a page's block order is a claim the author
23
+ * made, and reordering it would rewrite the page. `intent.priority` only breaks ties BETWEEN BLOCKS
24
+ * THE AUTHOR GAVE THE SAME EMPHASIS, and only inside `grid`/`row`/`flow` arrangements, where the
25
+ * blocks are peers scanned together rather than read in sequence. A `stack` is a reading order and
26
+ * is never reordered.
27
+ */
28
+ export function orderSiblings(children, arrangement) {
29
+ if (arrangement === "stack") return children;
30
+ return children
31
+ .map((block, index) => ({ block, index }))
32
+ .sort((a, b) => {
33
+ const ea = EMPHASIS[a.block.emphasis ?? "normal"], eb = EMPHASIS[b.block.emphasis ?? "normal"];
34
+ if (ea !== eb) return ea - eb;
35
+ const pa = PRIORITY[a.block.intent?.priority] ?? 1, pb = PRIORITY[b.block.intent?.priority] ?? 1;
36
+ if (pa !== pb) return pa - pb;
37
+ return a.index - b.index;
38
+ })
39
+ .map((x) => x.block);
40
+ }
41
+
42
+ export function Arrange({ block, children }) {
43
+ return (
44
+ <div className="uir-arrange" data-arrangement={arrangementOf(block)}>
45
+ {children}
46
+ </div>
47
+ );
48
+ }
49
+
50
+ /**
51
+ * A section is a LABELLED GROUPING of tiles, not a framed region.
52
+ *
53
+ * It draws no border, no rule and no rail. Its title is a small quiet label sitting above its tiles,
54
+ * and the grouping is legible from the gap around it — which is how grouping works on a canvas.
55
+ * A framed region inside a page is a page inside a page, and the tiles were already the objects.
56
+ *
57
+ * THE EXCEPTION IS THE EDITORIAL SECTION, and it is inferred rather than declared. A section whose
58
+ * title is followed by a `text` block is a magazine section: a headline, then one sentence saying
59
+ * what this run of things has in common, then the things. That composition was always expressible —
60
+ * `section.title` plus a `text` child is the only way OBL can say it — and the renderer drew both
61
+ * parts identically, a 12px grey label above a paragraph of body copy.
62
+ *
63
+ * So the pattern is READ, not added: no prop, no new block, nothing for a voice renderer to fail to
64
+ * honour (it already reads the title, then the sentence, then the things). The document is unchanged;
65
+ * only its treatment knows what it is looking at. The text block keeps its own `.uir-block` wrapper
66
+ * and its own `visible_when`, because it is still a block — it is styled as a standfirst by POSITION,
67
+ * not by being taken apart.
68
+ */
69
+ export function SectionBlock({ block, renderChild, hideTitle }) {
70
+ const uir = useUIResponse();
71
+ const arrangement = arrangementOf(block);
72
+ const density = densityOf(block) ?? uir.density;
73
+ const editorial = isEditorialSection(block) && !hideTitle;
74
+ return (
75
+ <section className="uir-section" data-density={densityOf(block)} data-editorial={editorial || undefined}>
76
+ {/* Inside `tabs`, the section's title IS the tab label. Drawing it again would say the same
77
+ word twice, six pixels apart. */}
78
+ {block.title && !hideTitle && <h2 className="uir-section__label">{block.title}</h2>}
79
+ <UIResponseContext.Provider value={{ ...uir, density }}>
80
+ <Arrange block={block}>
81
+ {/* An editorial section's intro sentence is its standfirst and belongs above the things it
82
+ introduces. `orderSiblings` would happily promote a `hero` list above it on emphasis. */}
83
+ {orderSiblings(block.children ?? [], editorial ? "stack" : arrangement).map(renderChild)}
84
+ </Arrange>
85
+ </UIResponseContext.Provider>
86
+ </section>
87
+ );
88
+ }
89
+
90
+ export function CardBlock({ block, renderChild, emphasis }) {
91
+ const uir = useUIResponse();
92
+ const clickable = Boolean(block.on_click);
93
+ return (
94
+ <div
95
+ className={`uir-card${clickable ? " uir-card--clickable" : ""}`}
96
+ data-emphasis={emphasis}
97
+ onClick={clickable ? () => runWithConfirm(block.on_click, { ...uir.actionCtx, scope: scopeFor(uir) }) : undefined}
98
+ >
99
+ {block.title && <h3 className="uir-card__title">{block.title}</h3>}
100
+ {/* A card IS a tile. Its children are the card's contents, so they shed their own tile chrome
101
+ (see `.uir-card .uir-block[data-tile]` in uir.css) — a tile inside a tile is a box inside
102
+ a box, which is the exact thing this composition model exists to stop. */}
103
+ <div className="uir-arrange" data-arrangement="grid">
104
+ {orderSiblings(block.children ?? [], "grid").map(renderChild)}
105
+ </div>
106
+ </div>
107
+ );
108
+ }
109
+
110
+ /**
111
+ * Tabs are alternative views of one subject, one at a time. Their children are SECTIONS, and each
112
+ * section's `title` is its tab label (DECISIONS P3) — there is no anonymous tab container.
113
+ */
114
+ export function TabsBlock({ block, renderChild }) {
115
+ const [active, setActive] = useState(0);
116
+ const sections = block.children ?? [];
117
+
118
+ return (
119
+ <div className="uir-tabs">
120
+ <div className="uir-tabs__list" role="tablist">
121
+ {sections.map((section, i) => (
122
+ <button
123
+ key={section.id}
124
+ type="button"
125
+ role="tab"
126
+ className="uir-tab"
127
+ aria-selected={i === active}
128
+ aria-controls={`panel-${section.id}`}
129
+ onClick={() => setActive(i)}
130
+ >
131
+ {section.title}
132
+ </button>
133
+ ))}
134
+ </div>
135
+ {sections.map((section, i) => (
136
+ <div
137
+ key={section.id}
138
+ id={`panel-${section.id}`}
139
+ role="tabpanel"
140
+ // The inactive panel is HIDDEN, not unmounted. Unmounting would restart its datasets'
141
+ // progressive load every time the user flicked between tabs, and a tab is a view of data
142
+ // the page already asked for.
143
+ hidden={i !== active}
144
+ >
145
+ {renderChild(section, i)}
146
+ </div>
147
+ ))}
148
+ </div>
149
+ );
150
+ }
@@ -0,0 +1,292 @@
1
+ /**
2
+ * The content and interactive leaves: `text`, `media`, `input`, `button`.
3
+ */
4
+ import React, { useEffect, useRef, useState } from "react";
5
+ import { useUIResponse, scopeFor } from "./context.js";
6
+ import { ActionButton, BlockError, BlurImage } from "./primitives.jsx";
7
+ import { resolveValue, isBinding, boundColumn } from "../core/value.js";
8
+ import { runWithConfirm, runInputAction } from "../core/actions.js";
9
+ import { thumbNameFor } from "../core/record.js";
10
+ import { formatAudioTime } from "../core/presentation.js";
11
+
12
+ // ───────────────────────────────────────────────────────────────────────────────
13
+ // text — prose the page author wrote. PLAIN TEXT: no markdown, no HTML.
14
+ // ───────────────────────────────────────────────────────────────────────────────
15
+ export function TextBlock({ block, emphasis }) {
16
+ // Rendered as a text node, never `dangerouslySetInnerHTML`. `body` is plain text by schema
17
+ // (Principle 8: microsyntax in a string is invisible to JSON Schema), and a renderer that parsed
18
+ // markdown here would quietly make it a markup surface — reachable from a chat message.
19
+ return <p className="uir-text" data-emphasis={emphasis}>{block.body}</p>;
20
+ }
21
+
22
+ // ───────────────────────────────────────────────────────────────────────────────
23
+ // media — something to look at or listen to
24
+ // ───────────────────────────────────────────────────────────────────────────────
25
+
26
+ /**
27
+ * THE PROVIDER ALLOWLIST (DECISIONS MED4, MED5).
28
+ *
29
+ * A page never carries a frame URL. It carries a provider from a closed enum and an opaque id, and
30
+ * THIS TABLE — code, reviewed, versioned — is the only thing that turns those into an origin. That
31
+ * inversion is what closes the three attack classes A2UI's architect names on stage
32
+ * (`docs/research/video-study-UsMDkEsR-ok-2026-07-09.md`):
33
+ *
34
+ * · exfiltration through a rendered resource the agent did not mean to fetch — a page cannot name
35
+ * an origin, so there is nowhere to exfiltrate TO;
36
+ * · a hidden form inside a click target — the page supplies no markup, only an id;
37
+ * · supply chain — the set of reachable origins is fixed by the spec, not by generation.
38
+ *
39
+ * `content_id` is already `^[A-Za-z0-9_-]{1,128}$` in the schema, so it cannot spell a scheme, a
40
+ * path traversal or markup. We encode it anyway. A validator that ran yesterday is not a guarantee
41
+ * about this render, and the same reasoning puts A8's scheme check back in `runAction`.
42
+ *
43
+ * An UNRECOGNISED provider — one a later minor added — draws NOTHING and reports itself. Guessing an
44
+ * origin from a name is the one thing this table exists to make impossible.
45
+ */
46
+ const EMBED_PROVIDERS = {
47
+ youtube: { origin: "https://www.youtube-nocookie.com", url: (id) => `https://www.youtube-nocookie.com/embed/${id}`, open: (id) => `https://www.youtube.com/watch?v=${id}`, domain: "youtube.com", name: "YouTube" },
48
+ tiktok: { origin: "https://www.tiktok.com", url: (id) => `https://www.tiktok.com/embed/v2/${id}`, open: (id) => `https://www.tiktok.com/player/v1/${id}`, domain: "tiktok.com", name: "TikTok" },
49
+ // An opaque id cannot say track-vs-playlist-vs-episode, so `spotify` resolves to the default
50
+ // surface. Widening that is a `content_type` prop or more enum members; encoding it in the id
51
+ // would be a microsyntax (B2) and would reopen the path surface the pattern closes.
52
+ spotify: { origin: "https://open.spotify.com", url: (id) => `https://open.spotify.com/embed/track/${id}`, open: (id) => `https://open.spotify.com/track/${id}`, domain: "open.spotify.com", name: "Spotify" },
53
+ };
54
+
55
+ /**
56
+ * `sandbox` without `allow-same-origin` would break every provider's player (they need their own
57
+ * storage and their own origin). Granting it is safe here precisely because the frame is CROSS-origin:
58
+ * "same origin" means the provider's, never ours. What is withheld is what matters — no `allow-forms`,
59
+ * no `allow-popups`, no `allow-top-navigation`: a frame from an allowlisted origin still cannot
60
+ * navigate the page that hosts it or put a form in front of the user.
61
+ */
62
+ const EMBED_SANDBOX = "allow-scripts allow-same-origin allow-presentation";
63
+
64
+ function ProviderEmbed({ block }) {
65
+ const spec = EMBED_PROVIDERS[block.provider];
66
+ if (!spec) {
67
+ return <BlockError message={`This renderer does not know the media provider \`${block.provider}\`. It will not guess an origin.`} />;
68
+ }
69
+ const src = spec.url(encodeURIComponent(block.content_id));
70
+ const external = spec.open(encodeURIComponent(block.content_id));
71
+ // No `aspect` means landscape. That is a renderer default over an absent optional prop, not an
72
+ // inference from one — a page that says nothing about shape gets the shape most video has.
73
+ const aspect = block.aspect ?? "16:9";
74
+
75
+ return (
76
+ <figure className="uir-media">
77
+ <div className="uir-media__frame" data-aspect={aspect} style={{ aspectRatio: aspect.replace(":", " / ") }}>
78
+ <iframe
79
+ className="uir-media__embed"
80
+ src={src}
81
+ title={block.alt}
82
+ sandbox={EMBED_SANDBOX}
83
+ referrerPolicy="no-referrer"
84
+ allow="encrypted-media; picture-in-picture; clipboard-write"
85
+ allowFullScreen
86
+ loading="lazy"
87
+ />
88
+ </div>
89
+ {/* `alt` is required on video and embed: the content lives at an origin this renderer cannot
90
+ inspect, so the page is the only thing that can describe it. */}
91
+ <figcaption className="uir-embed-meta">
92
+ <span className="uir-embed-meta__icon" aria-hidden="true">{spec.name[0]}</span>
93
+ <span className="uir-embed-meta__copy">
94
+ <strong>{block.caption ?? block.alt}</strong>
95
+ <span>{spec.domain}</span>
96
+ </span>
97
+ <a href={external} target="_blank" rel="noreferrer">Open <span aria-hidden="true">↗</span></a>
98
+ </figcaption>
99
+ </figure>
100
+ );
101
+ }
102
+
103
+ function AudioPlayer({ src, title }) {
104
+ const audio = useRef(null);
105
+ const [playing, setPlaying] = useState(false);
106
+ const [current, setCurrent] = useState(0);
107
+ const [duration, setDuration] = useState(0);
108
+ useEffect(() => { setPlaying(false); setCurrent(0); setDuration(0); }, [src]);
109
+ const toggle = async () => {
110
+ if (!audio.current) return;
111
+ try {
112
+ if (audio.current.paused) await audio.current.play(); else audio.current.pause();
113
+ } catch {
114
+ setPlaying(false);
115
+ }
116
+ };
117
+ const pct = duration > 0 ? Math.min(100, (current / duration) * 100) : 0;
118
+ return (
119
+ <div className="uir-audio">
120
+ <audio ref={audio} src={src} preload="metadata" onPlay={() => setPlaying(true)} onPause={() => setPlaying(false)}
121
+ onEnded={() => setPlaying(false)} onTimeUpdate={(event) => setCurrent(event.currentTarget.currentTime)}
122
+ onLoadedMetadata={(event) => setDuration(event.currentTarget.duration)} />
123
+ <button type="button" onClick={toggle} aria-label={playing ? `Pause ${title}` : `Play ${title}`}>
124
+ <span aria-hidden="true">{playing ? "Ⅱ" : "▶"}</span>
125
+ </button>
126
+ <div className="uir-audio__body">
127
+ <div className="uir-audio__line"><strong title={title}>{title}</strong><span>{formatAudioTime(current)} / {formatAudioTime(duration)}</span></div>
128
+ <div className="uir-audio__track"><span style={{ width: `${pct}%` }} /></div>
129
+ </div>
130
+ </div>
131
+ );
132
+ }
133
+
134
+ export function MediaBlock({ block }) {
135
+ const uir = useUIResponse();
136
+ const scope = scopeFor(uir, uir.row);
137
+
138
+ if (block.kind === "video" || block.kind === "embed") return <ProviderEmbed block={block} />;
139
+
140
+ const src = resolveValue(block.src, scope);
141
+
142
+ // A bound `src` is only known at render time, so the literal's schema-level `^https?://` check
143
+ // cannot have run on it. Check it here, at the point of use (VERSIONING §9, media.src).
144
+ if (typeof src !== "string" || !/^https?:\/\//i.test(src)) {
145
+ return <div className="uir-error" role="alert"><span className="uir-error__label">Refused media source</span>Only absolute http(s) URLs may be rendered.</div>;
146
+ }
147
+
148
+ if (block.kind === "audio") {
149
+ return (
150
+ <figure className="uir-media">
151
+ <AudioPlayer src={src} title={block.caption ?? "Audio"} />
152
+ </figure>
153
+ );
154
+ }
155
+ return (
156
+ <figure className="uir-media">
157
+ {/* `alt` is required on images by the schema: accessibility is semantics, and it is what a
158
+ voice renderer SAYS. */}
159
+ <BlurImage className="uir-media__image" src={src} thumb={thumbSrc(block.src, scope)} alt={block.alt ?? ""} />
160
+ {block.caption && <figcaption className="uir-media__caption">{block.caption}</figcaption>}
161
+ </figure>
162
+ );
163
+ }
164
+
165
+ /**
166
+ * The blur-up proxy for a BOUND `src`, or null.
167
+ *
168
+ * `media.src` is a binding at a named field of a named dataset. Its proxy is the SAME binding at the
169
+ * conventional sibling field (`artwork_url` → `artwork_thumb_url`, see `core/record.js`) — same
170
+ * dataset, same row selector, so a hero cover picked with `row: "first"` gets the first row's
171
+ * thumbnail and never the wrong record's. If the dataset never declared the sibling, there is no
172
+ * proxy and the image fades in from nothing.
173
+ *
174
+ * A literal `src` has no dataset to ask, and so has no proxy. That is correct: a URL written into a
175
+ * document is one URL, and inventing a second by string surgery would be a request for a file that
176
+ * nobody said exists.
177
+ */
178
+ function thumbSrc(srcSpec, scope) {
179
+ const bind = srcSpec?.$bind;
180
+ if (!bind?.dataset || !bind.field) return null;
181
+ const want = thumbNameFor(bind.field);
182
+ const declared = scope.dataset(bind.dataset)?.columns ?? [];
183
+ if (!declared.some((c) => c?.name === want)) return null;
184
+ try {
185
+ const v = resolveValue({ $bind: { ...bind, field: want } }, scope);
186
+ return typeof v === "string" && v ? v : null;
187
+ } catch {
188
+ return null; // a proxy that will not resolve is simply absent; the picture is not
189
+ }
190
+ }
191
+
192
+ // ───────────────────────────────────────────────────────────────────────────────
193
+ // input — the way a page listens. ONE block, types are props.
194
+ // ───────────────────────────────────────────────────────────────────────────────
195
+ export function InputBlock({ block }) {
196
+ const uir = useUIResponse();
197
+ const scope = scopeFor(uir);
198
+
199
+ // An input's state belongs to a param or to nobody. `value` names the param it reflects; failing
200
+ // that, the param its `on_change` writes.
201
+ const paramName = block.value?.$param ?? (block.on_change?.type === "set_param" ? block.on_change.param : null);
202
+ const current = paramName ? uir.params[paramName] : undefined;
203
+
204
+ const ctx = { ...uir.actionCtx, scope: scopeFor(uir) };
205
+
206
+ /**
207
+ * `widgetValue` is what `{"$self": true}` resolves to, and it is what each `input_type` promises
208
+ * to write (binding.schema.json#selfRef): a select writes its chosen option's value field, a
209
+ * toggle a boolean, a text field its string, a date its date. `row` is passed only by a select,
210
+ * because only a select has one — and `row: null` is the CLEARED select, which `runInputAction`
211
+ * turns into `set_param(null)` for the older row-binding spelling.
212
+ */
213
+ const fire = ({ row, widgetValue }) => runInputAction(block.on_change, ctx, { row, widgetValue });
214
+
215
+ if (block.input_type === "select") {
216
+ const bind = block.options?.$bind;
217
+ const ds = bind ? uir.dataset(bind.dataset) : null;
218
+ const rows = ds?.rows ?? [];
219
+ const labelField = block.option_label_field;
220
+ const valueField = block.option_value_field ?? labelField;
221
+
222
+ return (
223
+ <div className="uir-field">
224
+ <span className="uir-eyebrow">{block.label}</span>
225
+ <div className="uir-select-row">
226
+ <select
227
+ aria-label={block.label}
228
+ className="uir-select"
229
+ value={current ?? ""}
230
+ onChange={(e) => {
231
+ const picked = rows.find((r) => String(r[valueField]) === e.target.value);
232
+ // The empty option CLEARS the param. `set_param` to null is how "no filter" is spelled,
233
+ // and a query predicate marked `optional: true` then drops out entirely (R4).
234
+ if (!picked) { fire({ row: null, widgetValue: null }); return; }
235
+ fire({ row: picked, widgetValue: picked[valueField] ?? null });
236
+ }}
237
+ >
238
+ <option value="">All</option>
239
+ {rows.map((row, i) => (
240
+ <option key={i} value={String(row[valueField])}>{row[labelField]}</option>
241
+ ))}
242
+ </select>
243
+ {current !== null && current !== undefined && current !== "" && (
244
+ <button type="button" className="uir-select-clear" onClick={() => fire({ row: null, widgetValue: null })}>Clear</button>
245
+ )}
246
+ </div>
247
+ </div>
248
+ );
249
+ }
250
+
251
+ if (block.input_type === "toggle") {
252
+ return (
253
+ <div className="uir-toggle">
254
+ <span>{block.label}</span>
255
+ <button type="button" role="switch" aria-checked={Boolean(current)} aria-label={block.label}
256
+ onClick={() => fire({ widgetValue: !Boolean(current) })}><span aria-hidden="true" /></button>
257
+ </div>
258
+ );
259
+ }
260
+
261
+ return (
262
+ <label className="uir-field">
263
+ <span className="uir-eyebrow">{block.label}</span>
264
+ <input
265
+ className="uir-input"
266
+ type={block.input_type === "date" ? "date" : "text"}
267
+ placeholder={block.placeholder}
268
+ value={current ?? ""}
269
+ onChange={(e) => fire({ widgetValue: e.target.value })}
270
+ />
271
+ </label>
272
+ );
273
+ }
274
+
275
+ // ───────────────────────────────────────────────────────────────────────────────
276
+ // button — a verb with a border. Its label is the ACTION's label: one source of truth.
277
+ // ───────────────────────────────────────────────────────────────────────────────
278
+ export function ButtonBlock({ block, emphasis }) {
279
+ const uir = useUIResponse();
280
+ return (
281
+ <div>
282
+ <ActionButton
283
+ action={block.on_click}
284
+ ctx={{ ...uir.actionCtx, scope: scopeFor(uir) }}
285
+ variant={emphasis === "hero" || emphasis === "primary" ? "solid" : "ghost"}
286
+ fallbackLabel={block.on_click?.type}
287
+ />
288
+ </div>
289
+ );
290
+ }
291
+
292
+ export { isBinding, boundColumn };
@@ -0,0 +1,27 @@
1
+ import { createContext, useContext } from "react";
2
+
3
+ /**
4
+ * Everything a block needs and nothing it does not. Note what is ABSENT: no connector, no query, no
5
+ * credential, no fetch. A block cannot reach data except through `dataset(name)`, which holds rows
6
+ * the resolver already returned. Principle 4, enforced by the shape of this object.
7
+ */
8
+ export const UIResponseContext = createContext(null);
9
+
10
+ export function useUIResponse() {
11
+ const ctx = useContext(UIResponseContext);
12
+ if (!ctx) throw new Error("OBL blocks must render inside <UIResponsePage>.");
13
+ return ctx;
14
+ }
15
+
16
+ /**
17
+ * The binding scope for a block: optionally inside a repeating element's `row`, and optionally
18
+ * carrying the value of the control that fired the action (`self`).
19
+ *
20
+ * `self` is left OFF unless a control supplied one — `{"$self": true}` then throws rather than
21
+ * resolving to `undefined`, which is what tells us a page reached the renderer unvalidated.
22
+ */
23
+ export function scopeFor(uir, row, self) {
24
+ const scope = { dataset: uir.dataset, params: uir.params, now: uir.now, row };
25
+ if (self !== undefined) scope.self = self;
26
+ return scope;
27
+ }