@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,211 @@
1
+ /**
2
+ * OBL values and bindings — resolved against rows the resolver returned.
3
+ *
4
+ * A block prop holds one of four things (binding.schema.json#value):
5
+ *
6
+ * 42 | "Total" | true | null a literal (always a JSON scalar)
7
+ * {"$bind": {...}} a binding (always an object)
8
+ * {"$param": "artist_id"} a parameter
9
+ * {"$now": {...}} a resolve-time date
10
+ * {"$self": true} the value the user just entered (input.on_change only)
11
+ *
12
+ * The union needs no discriminator because literals are scalars and the rest are objects (B3).
13
+ *
14
+ * A binding's ARITY falls out of which keys it carries (B4). We never guess:
15
+ *
16
+ * {dataset} rows a table's rows, a list's items
17
+ * {dataset, field} column a chart axis
18
+ * {dataset, field, aggregate} scalar a metric's value
19
+ * {dataset, aggregate: "count"} scalar the one fieldless scalar: a row count
20
+ * {dataset, field, row} scalar the latest statement date
21
+ * {dataset, field, scope: "row"} scalar THIS row's value, inside a repeating element
22
+ *
23
+ * `row: "only"` is an ASSERTION (B6): ≠1 row is a loud error, never a quietly-wrong number.
24
+ */
25
+
26
+ export const isBinding = (v) => v !== null && typeof v === "object" && !Array.isArray(v) && "$bind" in v;
27
+ export const isParamRef = (v) => v !== null && typeof v === "object" && typeof v.$param === "string";
28
+ export const isNowRef = (v) => v !== null && typeof v === "object" && v.$now !== null && typeof v.$now === "object";
29
+ export const isSelfRef = (v) => v !== null && typeof v === "object" && v.$self === true;
30
+
31
+ /** A value that is present. `null` is absent; `0` and `""` and `false` are present. */
32
+ export const isPresent = (v) => v !== null && v !== undefined;
33
+
34
+ export class UIResponseResolveError extends Error {}
35
+
36
+ export function bindingArity(bind) {
37
+ if (!bind.field) return bind.aggregate ? "scalar" : "rows";
38
+ if (bind.aggregate || bind.row || bind.scope === "row") return "scalar";
39
+ return "column";
40
+ }
41
+
42
+ // ---------------------------------------------------------------------------
43
+ // $now
44
+ // ---------------------------------------------------------------------------
45
+
46
+ function truncate(d, unit) {
47
+ const out = new Date(d);
48
+ switch (unit) {
49
+ case "year": out.setMonth(0, 1); out.setHours(0, 0, 0, 0); break;
50
+ case "quarter": out.setMonth(Math.floor(out.getMonth() / 3) * 3, 1); out.setHours(0, 0, 0, 0); break;
51
+ case "month": out.setDate(1); out.setHours(0, 0, 0, 0); break;
52
+ case "week": { const dow = (out.getDay() + 6) % 7; out.setDate(out.getDate() - dow); out.setHours(0, 0, 0, 0); break; }
53
+ case "day": out.setHours(0, 0, 0, 0); break;
54
+ default: break;
55
+ }
56
+ return out;
57
+ }
58
+
59
+ /**
60
+ * `$now` on the READ path only. A `$now` inside a `mutate.set` is resolved SERVER-side by the
61
+ * resolver — a client's clock is untrusted input and "when was this chased" is an audit field
62
+ * (DECISIONS A10). The renderer never computes one for a write.
63
+ */
64
+ export function resolveNow(nowRef, now) {
65
+ const spec = nowRef.$now ?? {};
66
+ let d = new Date(now);
67
+ if (spec.offset_days) d.setDate(d.getDate() + spec.offset_days);
68
+ if (spec.offset_months) d.setMonth(d.getMonth() + spec.offset_months);
69
+ if (spec.offset_years) d.setFullYear(d.getFullYear() + spec.offset_years);
70
+ if (spec.truncate) d = truncate(d, spec.truncate);
71
+ return d;
72
+ }
73
+
74
+ // ---------------------------------------------------------------------------
75
+ // aggregates
76
+ // ---------------------------------------------------------------------------
77
+
78
+ const numeric = (xs) => xs.filter((x) => x !== null && x !== undefined && x !== "" && Number.isFinite(Number(x))).map(Number);
79
+
80
+ function median(xs) {
81
+ if (!xs.length) return null;
82
+ const s = [...xs].sort((a, b) => a - b);
83
+ const mid = s.length >> 1;
84
+ return s.length % 2 ? s[mid] : (s[mid - 1] + s[mid]) / 2;
85
+ }
86
+
87
+ /**
88
+ * `count` has two spellings and they mean different things (binding.schema.json#aggregate):
89
+ * fieldless counts ROWS; with a field it counts rows where that field is non-null.
90
+ */
91
+ export function aggregate(rows, field, fn) {
92
+ if (fn === "count") return field ? rows.filter((r) => isPresent(r[field])).length : rows.length;
93
+ if (fn === "count_distinct") return new Set(rows.map((r) => r[field]).filter(isPresent)).size;
94
+
95
+ const values = numeric(rows.map((r) => r[field]));
96
+ // An empty column is ABSENT, not zero. `sum` of nothing is not 0 — it is "no statements filed",
97
+ // and it must flow into the `empty` policy rather than render as £0.00 (DECISIONS B8).
98
+ if (!values.length) return null;
99
+
100
+ switch (fn) {
101
+ case "sum": return values.reduce((a, b) => a + b, 0);
102
+ case "avg": return values.reduce((a, b) => a + b, 0) / values.length;
103
+ case "min": return Math.min(...values);
104
+ case "max": return Math.max(...values);
105
+ case "median": return median(values);
106
+ default: throw new UIResponseResolveError(`unknown aggregate "${fn}"`);
107
+ }
108
+ }
109
+
110
+ function pickRow(rows, selector, bind) {
111
+ if (selector === "only") {
112
+ if (rows.length !== 1) {
113
+ // The assertion exists so a wrong number is loud rather than plausible.
114
+ throw new UIResponseResolveError(
115
+ `\`row: "only"\` on \`${bind.dataset}.${bind.field}\` requires exactly one row; the dataset resolved ${rows.length}.`,
116
+ );
117
+ }
118
+ return rows[0];
119
+ }
120
+ if (!rows.length) return null;
121
+ return selector === "last" ? rows[rows.length - 1] : rows[0];
122
+ }
123
+
124
+ // ---------------------------------------------------------------------------
125
+ // resolution
126
+ // ---------------------------------------------------------------------------
127
+
128
+ /**
129
+ * @typedef {object} Scope
130
+ * @property {(name:string) => {rows: object[], columns: object[]}} dataset resolved datasets
131
+ * @property {object} params current page parameter values
132
+ * @property {object} [row] the current row, inside a repeating element
133
+ * @property {Date} now
134
+ */
135
+
136
+ /** The column declaration a binding points at, or null. Drives format defaults and alignment. */
137
+ export function boundColumn(bind, scope) {
138
+ if (!bind?.field) return null;
139
+ const ds = scope.dataset(bind.dataset);
140
+ return ds?.columns?.find((c) => c.name === bind.field) ?? null;
141
+ }
142
+
143
+ /** Resolve a `$bind` to its value, at whatever arity its keys declare. */
144
+ export function resolveBinding(bind, scope) {
145
+ const ds = scope.dataset(bind.dataset);
146
+ if (!ds) throw new UIResponseResolveError(`dataset "${bind.dataset}" has not resolved`);
147
+ const rows = ds.rows ?? [];
148
+
149
+ switch (bindingArity(bind)) {
150
+ case "rows": return rows;
151
+ case "column": return rows.map((r) => r[bind.field]);
152
+ case "scalar": {
153
+ if (bind.scope === "row") {
154
+ // Row scope is explicit, never inferred from where the binding sits (DECISIONS R7).
155
+ if (!scope.row) throw new UIResponseResolveError(`\`scope: "row"\` used outside a repeating element`);
156
+ return scope.row[bind.field] ?? null;
157
+ }
158
+ if (bind.aggregate) return aggregate(rows, bind.field, bind.aggregate);
159
+ const picked = pickRow(rows, bind.row, bind);
160
+ return picked ? (picked[bind.field] ?? null) : null;
161
+ }
162
+ default: throw new UIResponseResolveError("unresolvable binding arity");
163
+ }
164
+ }
165
+
166
+ /**
167
+ * Resolve any member of the `value` union.
168
+ *
169
+ * `{"$self": true}` is the control's own value, and `scope.self` is how an `input` hands it in. A
170
+ * scope with no `self` is a scope with no control: the schema forbids `$self` there, so reaching this
171
+ * throw means the page was never validated. Guessing a value would be the renderer overriding the
172
+ * document, which is precisely the bug `$self` was added to end (DECISIONS B12).
173
+ *
174
+ * A control's value is never `undefined` — a cleared select is `null`, an empty field is `""`, an
175
+ * unticked toggle is `false` — so `undefined` unambiguously means "there is no control here".
176
+ */
177
+ export function resolveValue(value, scope) {
178
+ if (isBinding(value)) return resolveBinding(value.$bind, scope);
179
+ if (isParamRef(value)) return scope.params?.[value.$param] ?? null;
180
+ if (isNowRef(value)) return resolveNow(value, scope.now);
181
+ if (isSelfRef(value)) {
182
+ if (scope.self === undefined) throw new UIResponseResolveError(`\`{"$self": true}\` used outside an input's \`on_change\``);
183
+ return scope.self;
184
+ }
185
+ return value; // literal
186
+ }
187
+
188
+ /** The `format` a value carries, if it is a binding. Literals carry none. */
189
+ export const valueFormat = (value) => (isBinding(value) ? value.$bind.format : undefined);
190
+ /** The `empty` policy a value carries. Omitted means `{show: "dash"}`. */
191
+ export const valueEmpty = (value) => (isBinding(value) ? value.$bind.empty : undefined);
192
+ /** The column a value is bound to, for format defaults. */
193
+ export const valueColumn = (value, scope) => (isBinding(value) ? boundColumn(value.$bind, scope) : null);
194
+
195
+ /** Which datasets does this subtree read? Used to build the load graph. */
196
+ export function collectDatasets(node, out = new Set()) {
197
+ if (node === null || typeof node !== "object") return out;
198
+ if (Array.isArray(node)) { node.forEach((n) => collectDatasets(n, out)); return out; }
199
+ if (isBinding(node)) { if (node.$bind.dataset) out.add(node.$bind.dataset); return out; }
200
+ for (const v of Object.values(node)) collectDatasets(v, out);
201
+ return out;
202
+ }
203
+
204
+ /** Which parameters does this subtree read? Used to invalidate datasets on `set_param`. */
205
+ export function collectParams(node, out = new Set()) {
206
+ if (node === null || typeof node !== "object") return out;
207
+ if (Array.isArray(node)) { node.forEach((n) => collectParams(n, out)); return out; }
208
+ if (isParamRef(node)) { out.add(node.$param); return out; }
209
+ for (const v of Object.values(node)) collectParams(v, out);
210
+ return out;
211
+ }
package/src/index.js ADDED
@@ -0,0 +1,22 @@
1
+ /**
2
+ * @uir/renderer-web — OBL's first-party React renderer.
3
+ *
4
+ * The public surface is deliberately small: one component, one theme helper, and the pure semantic
5
+ * functions a host might want (a chat surface that renders one metric inline, a test that asserts a
6
+ * currency formats correctly). Everything else is internal and may move.
7
+ */
8
+ import "./theme/uir.css";
9
+
10
+ export { UIResponsePage, default } from "./UIResponsePage.jsx";
11
+
12
+ // Theme — the per-tenant brand story.
13
+ export { defaultTheme, darkTheme, makeTheme, themeToCssVars, EMPHASIS_RANK } from "./theme/tokens.js";
14
+
15
+ // Semantics, exported because they are the interesting part and they are pure.
16
+ export { formatValue, isNumericFormat } from "./core/format.js";
17
+ export { emptyRender, isEmptyValue, hidesBlock } from "./core/empty.js";
18
+ export { evaluateCondition } from "./core/condition.js";
19
+ export { resolveValue, resolveBinding, bindingArity, aggregate, collectDatasets, collectParams } from "./core/value.js";
20
+ export { planDatasetOrder, flattenBlocks, overlayParams } from "./core/store.js";
21
+ export { DISABLED_VERBS, isDisabledVerb, actionGate, verbReason } from "./core/actions.js";
22
+ export { initialsFor, paginateRows, timelineLayout, formatAudioTime, isDestructiveAction, TABLE_PAGE_SIZE } from "./core/presentation.js";
@@ -0,0 +1,364 @@
1
+ /**
2
+ * The theme token layer.
3
+ *
4
+ * ── SOURCE OF TRUTH ───────────────────────────────────────────────────────────────────────────
5
+ *
6
+ * Every value below is extracted from `design/UIR-element-system.dc.html` — the element system Dan
7
+ * built and approved. That file, not this one, is the design authority. When they disagree, the
8
+ * design wins and this file changes.
9
+ *
10
+ * Extracted, not eyeballed: the sheets carry inline styles, and each number here was read out of
11
+ * them. The provenance is noted per group so the next person can re-derive it rather than trust me.
12
+ *
13
+ * ── WHAT THE RENDERER STILL OWNS ──────────────────────────────────────────────────────────────
14
+ *
15
+ * `@openuidev/react-ui`'s components keep their own styling — in the light theme we emit no
16
+ * `--openui-*` overrides at all. We remap them in dark only, because they ship a single light
17
+ * `:root` and their charts would otherwise sit white on a near-black ground. That is a repair, not
18
+ * a redecoration.
19
+ *
20
+ * Brand is not in the LLM's vocabulary: Principle 1 bans colour from the document. Every generated
21
+ * page is therefore on-brand by construction, because brand lives here and nowhere else.
22
+ */
23
+
24
+ /** Blocks are ranked by `emphasis`; the renderer maps each rank to type scale and tile size. */
25
+ export const EMPHASIS_RANK = { hero: 0, primary: 1, normal: 2, subtle: 3 };
26
+
27
+ /**
28
+ * Streaming-arrival choreography. Lifted verbatim from the design's `<style>` block: `obFold`,
29
+ * `obShimmer`, `obTick`. A tile folds in when its data lands; the skeleton shimmers while it waits.
30
+ */
31
+ export const motion = {
32
+ fold: "0.45s cubic-bezier(0.2, 0.7, 0.3, 1)",
33
+ shimmer: "1.4s linear infinite",
34
+ tick: "2.6s ease forwards",
35
+ // Sheet `2a · Chat to page prototype`. Extracted from its inline styles, not chosen.
36
+ slideIn: "0.32s cubic-bezier(0.2, 0.7, 0.3, 1)",
37
+ fade: "0.25s ease",
38
+ dot: "1.2s ease infinite",
39
+ barIn: "0.9s cubic-bezier(0.2, 0.7, 0.3, 1)",
40
+ // No count-up in the design. See `countUp` below and NOTES-designsync D7.
41
+ countUp: "0.62s cubic-bezier(0.2, 0.7, 0.3, 1)",
42
+ };
43
+
44
+ /** The overlay drawer, `2a`. A page presented, not a page navigated to (DECISIONS A13). */
45
+ export const overlay = {
46
+ width: "372px",
47
+ scrim: "rgba(9, 9, 11, 0.18)",
48
+ shadow: "-16px 0 40px rgba(0, 0, 0, 0.1)",
49
+ };
50
+
51
+ export const defaultTheme = {
52
+ name: "OBL element system",
53
+ scheme: "light",
54
+
55
+ // Sheet light (`data-screen-label="Sheet light"`). Zinc neutrals.
56
+ color: {
57
+ ground: "#FFFFFF",
58
+ sheet: "#FFFFFF",
59
+ sunk: "#F4F4F5", // segmented control track, row hover
60
+ sunkStrong: "#FAFAFA", // shimmer highlight, button hover
61
+
62
+ ink: "#18181B",
63
+ muted: "#52525B", // subtle metric value, legend labels
64
+ graphite: "#71717A", // tile labels
65
+ faint: "#A1A1AA", // column headers, captions, units
66
+
67
+ rule: "#E4E4E7", // tile border
68
+ ruleSoft: "#F0F0F2", // separators INSIDE a tile — lighter than its own border
69
+ ruleStrong: "#D4D4D8",
70
+
71
+ // The one accent. `#3B6FE6` is the design's default; its tweak offered teal and violet too.
72
+ brand: "#3B6FE6",
73
+ brandHover: "#2D5AC4",
74
+ brandSoft: "#EFF6FF",
75
+
76
+ // Status tones, from the design's `tones` map.
77
+ positive: "#177A3D",
78
+ positiveSoft: "#ECFDF3",
79
+ warning: "#B54708",
80
+ warningSoft: "#FFFAEB",
81
+ negative: "#B42318",
82
+ negativeSoft: "#FEF3F2",
83
+ info: "#175CD3",
84
+ infoSoft: "#EFF6FF",
85
+ neutral: "#52525B",
86
+ neutralSoft: "#F4F4F5",
87
+
88
+ /** The validation tick's check. Deliberately not `positive`: it is a mark, not a status. */
89
+ tick: "#16A34A",
90
+
91
+ focus: "#3B6FE6",
92
+ },
93
+
94
+ /** Only our own donut reads this. Their charts are handed no palette and keep their own. */
95
+ chart: ["#3B6FE6", "#A1A1AA", "#1F9E86", "#7A5AF0", "#B54708", "#B42318", "#175CD3", "#52525B"],
96
+ /** Sheet `02 · Chart`: one accent slice, then four desaturating neutrals. */
97
+ pie: ["#3B6FE6", "#A7BFF2", "#C9CDD4", "#A1A1AA", "#E4E4E7"],
98
+
99
+ font: {
100
+ body: `-apple-system, BlinkMacSystemFont, "SF Pro Text", "Segoe UI", Roboto, system-ui, sans-serif`,
101
+ mono: `ui-monospace, Menlo, SFMono-Regular, Consolas, monospace`,
102
+ display: `-apple-system, BlinkMacSystemFont, "SF Pro Text", "Segoe UI", Roboto, system-ui, sans-serif`,
103
+ },
104
+
105
+ /**
106
+ * The metric ladder, read off the four tiles in `01 · Metric`:
107
+ * hero 44 / 600 / -0.02em primary 30 / 600 / -0.015em
108
+ * normal 22 / 600 subtle 17 / 500, in `muted`
109
+ * Emphasis is type scale and tile size. Never colour, never rule weight.
110
+ */
111
+ size: {
112
+ stamp: "0.625rem", // 10px — the mono captions under each sheet tile
113
+ micro: "0.6875rem", // 11px
114
+ small: "0.75rem", // 12px — column headers, legend
115
+ label: "0.8125rem", // 13px — a tile's label
116
+ base: "0.8125rem", // 13px
117
+ large: "0.9375rem", // 15px — a tile's title
118
+ heroValue: "2.75rem", // 44px
119
+ primaryValue: "1.875rem", // 30px
120
+ normalValue: "1.375rem", // 22px
121
+ subtleValue: "1.0625rem", // 17px
122
+ unit: "0.875rem", // 14px
123
+ },
124
+
125
+ space: { xs: "4px", sm: "8px", md: "12px", lg: "16px", xl: "24px", xxl: "36px" },
126
+
127
+ /** Tile 12, inner element 8, control 6. */
128
+ radius: { sm: "6px", md: "8px", lg: "12px" },
129
+
130
+ /** Tile padding is asymmetric in the design: 18px vertical, 20px horizontal. */
131
+ tilePad: { y: "18px", x: "20px" },
132
+
133
+ shadow: "0 1px 2px rgba(0, 0, 0, 0.04)",
134
+
135
+ /** `rowPadY` in the design's table: 12 comfortable, 6 compact. */
136
+ density: { comfortable: "12px", compact: "6px" },
137
+
138
+ /** The skeleton's shimmer sweep. */
139
+ shimmer: { from: "#F0F0F2", to: "#FAFAFA" },
140
+
141
+ measure: "1260px", // the sheets are drawn at 1260px
142
+ };
143
+
144
+ /** Sheet dark (`data-screen-label="Sheet dark"`). The same system, inverted. */
145
+ export const darkTheme = {
146
+ name: "OBL element system — dark",
147
+ scheme: "dark",
148
+ color: {
149
+ ground: "#0E0E10",
150
+ sheet: "#17171A",
151
+ sunk: "#232327",
152
+ sunkStrong: "#2A2A2F",
153
+
154
+ ink: "#F4F4F5",
155
+ muted: "#D4D4D8",
156
+ graphite: "#A1A1AA",
157
+ faint: "#6E6E78",
158
+
159
+ rule: "#2A2A2F",
160
+ ruleSoft: "#222227",
161
+ ruleStrong: "#34343B",
162
+
163
+ brand: "#6F95F2", // the design's `accentDark` map for #3B6FE6
164
+ brandHover: "#8FAEF6",
165
+ brandSoft: "rgba(96, 165, 250, 0.14)",
166
+
167
+ positive: "#4ADE80",
168
+ positiveSoft: "rgba(74, 222, 128, 0.13)",
169
+ warning: "#FBBF24",
170
+ warningSoft: "rgba(251, 191, 36, 0.13)",
171
+ negative: "#F87171",
172
+ negativeSoft: "rgba(248, 113, 113, 0.14)",
173
+ info: "#60A5FA",
174
+ infoSoft: "rgba(96, 165, 250, 0.14)",
175
+ neutral: "#A1A1AA",
176
+ neutralSoft: "rgba(161, 161, 170, 0.16)",
177
+
178
+ tick: "#4ADE80",
179
+ focus: "#6F95F2",
180
+ },
181
+ chart: ["#6F95F2", "#A1A1AA", "#4CC5AE", "#A08CF5", "#FBBF24", "#F87171", "#60A5FA", "#D4D4D8"],
182
+ pie: ["#6F95F2", "#5372B8", "#52525B", "#3F3F46", "#2C2C31"],
183
+ shadow: "0 1px 2px rgba(0, 0, 0, 0.5)",
184
+ shimmer: { from: "#222227", to: "#2A2A2F" },
185
+ };
186
+
187
+ const kebab = (s) => s.replace(/[A-Z]/g, (m) => `-${m.toLowerCase()}`);
188
+
189
+ /** Emit the token tree as CSS custom properties. */
190
+
191
+ /**
192
+ * OPENUI'S SCHEME-DEPENDENT VARIABLES — the 50 custom properties whose value differs between
193
+ * `@openuidev/react-ui`'s light `:root` and its dark block. Extracted from their own
194
+ * `openui-defaults.css`, and guarded by a test that re-derives this exact set (`tests.mjs`).
195
+ *
196
+ * WHY THIS TABLE EXISTS. Their stylesheet ships its dark palette as a bare
197
+ * `@media (prefers-color-scheme: dark) { :root { … } }` with **no class or attribute opt-out**. So
198
+ * their components follow the OPERATING SYSTEM, not the document. This renderer used to emit nothing
199
+ * for OpenUI in light mode, on the reasoning that "their components keep their own styling exactly" —
200
+ * and that reasoning quietly assumed an unstyled OpenUI is light. **It is light only when the OS is.**
201
+ * On a dark-mode OS, an OBL page drawing its LIGHT theme got OpenUI's DARK palette underneath it:
202
+ * chart tick labels near-white on white, and any mark filled with `--openui-interactive-accent-default`
203
+ * (light `oklch(0.097…)`, dark `oklch(0.994…)`) invisible.
204
+ *
205
+ * The fix is to pin every scheme-dependent variable for whichever theme the DOCUMENT is drawing, in
206
+ * BOTH directions. Our vars land on `.uir-page`'s style attribute, which outranks `:root`, so pinning
207
+ * is sufficient — the media query still fires, and loses.
208
+ *
209
+ * The values are OpenUI's OWN, verbatim, so a light page on a light OS is pixel-identical to before
210
+ * this table existed. The dark half is then overridden by our neutrals below, exactly as it was.
211
+ *
212
+ * CAVEAT, stated because it will matter one day: our vars land on `.uir-page`, so anything OpenUI
213
+ * PORTALS outside that element (a popover mounted on `document.body`) still reads `:root` and still
214
+ * follows the OS. No component we use portals. If one ever does, the pin must move to a wrapper the
215
+ * portal inherits from, or to `:root` itself.
216
+ *
217
+ * Only 50 of the 213 properties their dark block redeclares actually change; the other 163 are
218
+ * restated identically and are none of our business. `tests.mjs` asserts that this table's keys are
219
+ * precisely the 50 that differ — so an OpenUI upgrade that adds a 51st fails the suite instead of
220
+ * silently reintroducing white-on-white.
221
+ */
222
+ export const OPENUI_SCHEME_VARS = {
223
+ "--openui-background": { light: "oklch(0.97 0 89.876 / 1)", dark: "oklch(0.145 0 0 / 1)" },
224
+ "--openui-foreground": { light: "oklch(0.994 0 89.876 / 1)", dark: "oklch(0.205 0 0 / 1)" },
225
+ "--openui-popover-background": { light: "oklch(0.994 0 89.876 / 1)", dark: "oklch(0.205 0 0 / 1)" },
226
+ "--openui-sunk-light": { light: "oklch(0.097 0 0 / 0.02)", dark: "oklch(0.994 0 89.876 / 0.02)" },
227
+ "--openui-sunk": { light: "oklch(0.097 0 0 / 0.04)", dark: "oklch(0.994 0 89.876 / 0.04)" },
228
+ "--openui-sunk-deep": { light: "oklch(0.097 0 0 / 0.08)", dark: "oklch(0.994 0 89.876 / 0.08)" },
229
+ "--openui-elevated-light": { light: "oklch(0.097 0 0 / 0.04)", dark: "oklch(0.994 0 89.876 / 0.04)" },
230
+ "--openui-elevated": { light: "oklch(0.097 0 0 / 0.08)", dark: "oklch(0.994 0 89.876 / 0.08)" },
231
+ "--openui-elevated-strong": { light: "oklch(0.097 0 0 / 0.16)", dark: "oklch(0.994 0 89.876 / 0.16)" },
232
+ "--openui-elevated-intense": { light: "oklch(0.097 0 0 / 0.32)", dark: "oklch(0.994 0 89.876 / 0.32)" },
233
+ "--openui-overlay": { light: "oklch(0 0 0 / 0.4)", dark: "oklch(0 0 0 / 0.6)" },
234
+ "--openui-highlight-subtle": { light: "oklch(0.097 0 0 / 0.02)", dark: "oklch(0.994 0 89.876 / 0.02)" },
235
+ "--openui-highlight": { light: "oklch(0.097 0 0 / 0.04)", dark: "oklch(0.994 0 89.876 / 0.04)" },
236
+ "--openui-highlight-strong": { light: "oklch(0.097 0 0 / 0.08)", dark: "oklch(0.994 0 89.876 / 0.08)" },
237
+ "--openui-highlight-intense": { light: "oklch(0.097 0 0 / 0.32)", dark: "oklch(0.994 0 89.876 / 0.3)" },
238
+ "--openui-inverted-background": { light: "oklch(0.097 0 0 / 1)", dark: "oklch(0.994 0 89.876 / 1)" },
239
+ "--openui-text-neutral-primary": { light: "oklch(0.097 0 0 / 1)", dark: "oklch(0.985 0 89.876 / 1)" },
240
+ "--openui-text-neutral-secondary": { light: "oklch(0.097 0 0 / 0.5)", dark: "oklch(0.985 0 89.876 / 0.5)" },
241
+ "--openui-text-neutral-tertiary": { light: "oklch(0.097 0 0 / 0.2)", dark: "oklch(0.985 0 89.876 / 0.2)" },
242
+ "--openui-text-neutral-link": { light: "oklch(0.097 0 0 / 1)", dark: "oklch(0.985 0 89.876 / 1)" },
243
+ "--openui-text-brand": { light: "oklch(0.097 0 0 / 1)", dark: "oklch(0.994 0 89.876 / 1)" },
244
+ "--openui-text-accent-primary": { light: "oklch(0.994 0 89.876 / 1)", dark: "oklch(0.097 0 0 / 1)" },
245
+ "--openui-text-accent-secondary": { light: "oklch(0.994 0 89.876 / 0.5)", dark: "oklch(0.097 0 0 / 0.5)" },
246
+ "--openui-text-accent-tertiary": { light: "oklch(0.994 0 89.876 / 0.2)", dark: "oklch(0.097 0 0 / 0.2)" },
247
+ "--openui-text-success-primary": { light: "oklch(0.448 0.108 151.328 / 1)", dark: "oklch(0.871 0.136 154.449 / 1)" },
248
+ "--openui-text-alert-primary": { light: "oklch(0.476 0.103 61.907 / 1)", dark: "oklch(0.905 0.166 98.111 / 1)" },
249
+ "--openui-text-danger-primary": { light: "oklch(0.505 0.19 27.518 / 1)", dark: "oklch(0.808 0.103 19.571 / 1)" },
250
+ "--openui-text-danger-secondary": { light: "oklch(0.711 0.166 22.216 / 1)", dark: "oklch(0.885 0.059 18.334 / 1)" },
251
+ "--openui-text-danger-tertiary": { light: "oklch(0.808 0.103 19.571 / 1)", dark: "oklch(0.936 0.031 17.717 / 1)" },
252
+ "--openui-text-info-primary": { light: "oklch(0.424 0.181 265.638 / 1)", dark: "oklch(0.809 0.096 251.813 / 1)" },
253
+ "--openui-text-pink-primary": { light: "oklch(0.459 0.17 3.815 / 1)", dark: "oklch(0.823 0.11 346.018 / 1)" },
254
+ "--openui-text-purple-primary": { light: "oklch(0.438 0.198 303.724 / 1)", dark: "oklch(0.827 0.108 306.383 / 1)" },
255
+ "--openui-interactive-accent-default": { light: "oklch(0.097 0 0 / 1)", dark: "oklch(0.994 0 89.876 / 1)" },
256
+ "--openui-interactive-accent-hover": { light: "oklch(0.097 0 0 / 0.8)", dark: "oklch(0.994 0 89.876 / 0.8)" },
257
+ "--openui-interactive-accent-disabled": { light: "oklch(0.097 0 0 / 0.4)", dark: "oklch(0.994 0 89.876 / 0.4)" },
258
+ "--openui-interactive-accent-pressed": { light: "oklch(0.097 0 0 / 1)", dark: "oklch(0.994 0 89.876 / 1)" },
259
+ "--openui-chat-user-response-bg": { light: "oklch(0.097 0 0 / 0.08)", dark: "oklch(0.994 0 89.876 / 0.08)" },
260
+ "--openui-chat-user-response-text": { light: "oklch(0.097 0 0 / 1)", dark: "oklch(0.985 0 89.876 / 1)" },
261
+ "--openui-border-default": { light: "oklch(0.097 0 0 / 0.06)", dark: "oklch(0.994 0 89.876 / 0.06)" },
262
+ "--openui-border-interactive": { light: "oklch(0.097 0 0 / 0.12)", dark: "oklch(0.994 0 89.876 / 0.12)" },
263
+ "--openui-border-interactive-emphasis": { light: "oklch(0.097 0 0 / 0.3)", dark: "oklch(0.994 0 89.876 / 0.4)" },
264
+ "--openui-border-interactive-selected": { light: "oklch(0.097 0 0 / 1)", dark: "oklch(0.985 0 89.876 / 1)" },
265
+ "--openui-border-accent": { light: "oklch(0.097 0 0 / 0.08)", dark: "oklch(0.994 0 89.876 / 0.2)" },
266
+ "--openui-border-accent-emphasis": { light: "oklch(0.097 0 0 / 0.3)", dark: "oklch(0.994 0 89.876 / 0.4)" },
267
+ "--openui-shadow-s": { light: "0 1px 3px -2px oklch(0 0 0 / 0.02), 0 2px 5px -2px oklch(0 0 0 / 0.04)", dark: "0 1px 3px -2px oklch(0 0 0 / 0.06), 0 2px 5px -2px oklch(0 0 0 / 0.10)" },
268
+ "--openui-shadow-m": { light: "0 4px 6px -2px oklch(0 0 0 / 0.025), 0 2px 2px -2px oklch(0 0 0 / 0.05)", dark: "0 4px 6px -2px oklch(0 0 0 / 0.08), 0 2px 2px -2px oklch(0 0 0 / 0.12)" },
269
+ "--openui-shadow-l": { light: "0 4px 4px -2px oklch(0 0 0 / 0.05), 0 4px 8px -2px oklch(0 0 0 / 0.04)", dark: "0 4px 4px -2px oklch(0 0 0 / 0.12), 0 4px 8px -2px oklch(0 0 0 / 0.10)" },
270
+ "--openui-shadow-xl": { light: "0 8px 16px -4px oklch(0 0 0 / 0.08), 0 16px 32px -6px oklch(0 0 0 / 0.12)", dark: "0 8px 16px -4px oklch(0 0 0 / 0.16), 0 16px 32px -6px oklch(0 0 0 / 0.20)" },
271
+ "--openui-shadow-2xl": { light: "0 12px 24px -6px oklch(0 0 0 / 0.12), 0 24px 48px -8px oklch(0 0 0 / 0.16)", dark: "0 12px 24px -6px oklch(0 0 0 / 0.20), 0 24px 48px -8px oklch(0 0 0 / 0.24)" },
272
+ "--openui-shadow-3xl": { light: "0 16px 32px -8px oklch(0 0 0 / 0.16), 0 32px 64px -12px oklch(0 0 0 / 0.22)", dark: "0 16px 32px -8px oklch(0 0 0 / 0.24), 0 32px 64px -12px oklch(0 0 0 / 0.28)" },
273
+ };
274
+
275
+ export function themeToCssVars(theme = defaultTheme) {
276
+ const t = {
277
+ ...defaultTheme, ...theme,
278
+ color: { ...defaultTheme.color, ...(theme.color ?? {}) },
279
+ font: { ...defaultTheme.font, ...(theme.font ?? {}) },
280
+ size: { ...defaultTheme.size, ...(theme.size ?? {}) },
281
+ shimmer: { ...defaultTheme.shimmer, ...(theme.shimmer ?? {}) },
282
+ tilePad: { ...defaultTheme.tilePad, ...(theme.tilePad ?? {}) },
283
+ };
284
+ const vars = {};
285
+
286
+ for (const [k, v] of Object.entries(t.color)) vars[`--uir-color-${kebab(k)}`] = v;
287
+ for (const [k, v] of Object.entries(t.font)) vars[`--uir-font-${kebab(k)}`] = v;
288
+ for (const [k, v] of Object.entries(t.size)) vars[`--uir-size-${kebab(k)}`] = v;
289
+ for (const [k, v] of Object.entries(t.space)) vars[`--uir-space-${kebab(k)}`] = v;
290
+ for (const [k, v] of Object.entries(t.radius)) vars[`--uir-radius-${kebab(k)}`] = v;
291
+ for (const [k, v] of Object.entries(t.density)) vars[`--uir-density-${kebab(k)}`] = v;
292
+ vars["--uir-tile-pad-y"] = t.tilePad.y;
293
+ vars["--uir-tile-pad-x"] = t.tilePad.x;
294
+ vars["--uir-shimmer-from"] = t.shimmer.from;
295
+ vars["--uir-shimmer-to"] = t.shimmer.to;
296
+ vars["--uir-measure"] = t.measure;
297
+ vars["--uir-shadow"] = t.shadow ?? defaultTheme.shadow;
298
+ vars["--uir-motion-fold"] = motion.fold;
299
+ vars["--uir-motion-shimmer"] = motion.shimmer;
300
+ vars["--uir-motion-tick"] = motion.tick;
301
+ vars["--uir-motion-slide-in"] = motion.slideIn;
302
+ vars["--uir-motion-fade"] = motion.fade;
303
+ vars["--uir-motion-dot"] = motion.dot;
304
+ vars["--uir-motion-bar-in"] = motion.barIn;
305
+ vars["--uir-motion-count-up"] = motion.countUp;
306
+ vars["--uir-overlay-width"] = overlay.width;
307
+ vars["--uir-overlay-scrim"] = overlay.scrim;
308
+ vars["--uir-overlay-shadow"] = overlay.shadow;
309
+ (t.chart ?? defaultTheme.chart).forEach((c, i) => { vars[`--uir-chart-${i + 1}`] = c; });
310
+ const pieBase = t.scheme === "dark" ? darkTheme.pie : defaultTheme.pie;
311
+ (theme.pie ?? [t.color.brand, ...pieBase.slice(1)]).forEach((c, i) => { vars[`--uir-pie-${i + 1}`] = c; });
312
+
313
+ // Pin every scheme-dependent OpenUI variable to the theme the DOCUMENT chose, never the one the OS
314
+ // chose. Without this, a light OBL page on a dark-mode OS inherits OpenUI's dark palette from their
315
+ // media query and draws white on white. See `OPENUI_SCHEME_VARS`.
316
+ const scheme = t.scheme === "dark" ? "dark" : "light";
317
+ for (const [name, pair] of Object.entries(OPENUI_SCHEME_VARS)) vars[name] = pair[scheme];
318
+
319
+ // DARK: our own neutrals, layered over their dark values. None of these is a brand colour — the
320
+ // point is that their components sit on OUR ground, not that they wear our accent.
321
+ if (t.scheme === "dark") {
322
+ Object.assign(vars, {
323
+ "--openui-background": t.color.ground,
324
+ "--openui-foreground": t.color.sheet,
325
+ "--openui-popover-background": t.color.sheet,
326
+ "--openui-inverted-background": t.color.ink,
327
+ "--openui-sunk-light": "rgba(255,255,255,0.02)",
328
+ "--openui-sunk": "rgba(255,255,255,0.04)",
329
+ "--openui-sunk-deep": "rgba(255,255,255,0.08)",
330
+ "--openui-elevated-light": "rgba(255,255,255,0.04)",
331
+ "--openui-elevated": "rgba(255,255,255,0.08)",
332
+ "--openui-elevated-strong": "rgba(255,255,255,0.16)",
333
+ "--openui-highlight-subtle": "rgba(255,255,255,0.02)",
334
+ "--openui-highlight": "rgba(255,255,255,0.04)",
335
+ "--openui-highlight-strong": "rgba(255,255,255,0.08)",
336
+ "--openui-text-neutral-primary": t.color.ink,
337
+ "--openui-text-neutral-secondary": t.color.graphite,
338
+ "--openui-text-neutral-tertiary": t.color.faint,
339
+ "--openui-text-neutral-link": t.color.ink,
340
+ "--openui-text-brand": t.color.ink,
341
+ // `--openui-border-neutral` / `-strong` used to be here. OpenUI defines NEITHER and reads
342
+ // neither: two variables repairing nothing, for as long as the dark theme has existed. Their
343
+ // real border properties are these.
344
+ "--openui-border-default": t.color.rule,
345
+ "--openui-border-interactive": t.color.ruleStrong,
346
+ });
347
+ }
348
+
349
+ return vars;
350
+ }
351
+
352
+ /** Merge a partial tenant theme over the default. Tenants override colour and type, not structure. */
353
+ export const makeTheme = (partial = {}) => {
354
+ const pieBase = partial.scheme === "dark" ? darkTheme.pie : defaultTheme.pie;
355
+ return {
356
+ ...defaultTheme,
357
+ ...partial,
358
+ color: { ...defaultTheme.color, ...(partial.color ?? {}) },
359
+ font: { ...defaultTheme.font, ...(partial.font ?? {}) },
360
+ size: { ...defaultTheme.size, ...(partial.size ?? {}) },
361
+ chart: partial.chart ?? defaultTheme.chart,
362
+ pie: partial.pie ?? [partial.color?.brand ?? pieBase[0], ...pieBase.slice(1)],
363
+ };
364
+ };