@unoverse-platform/studio 0.1.103 → 0.1.104
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/package.json +4 -2
- package/screens/AtomsView.tsx +2 -1
- package/screens/ComponentsView.tsx +2 -1
- package/screens/NativeAppHost.tsx +1 -2
- package/screens/TemplatesLibraryView.tsx +14 -1
- package/screens/TemplatesView.tsx +2 -1
- package/screens/index.ts +4 -0
- package/screens/react/unoverse.tsx +281 -0
- package/vendor/base/src/agent-mcp/index.ts +1 -1
- package/vendor/base/src/agent-mcp/invoke.ts +6 -2
- package/vendor/base/src/agent-mcp/tools.ts +14 -5
- package/vendor/base/src/agent-mcp/types.ts +6 -0
- package/vendor/base/src/auth/auth.ts +90 -23
- package/vendor/base/src/definitions/load/compile/sections.ts +79 -0
- package/vendor/base/src/definitions/load/compile/template.ts +23 -2
- package/vendor/base/src/definitions/model.ts +9 -0
- package/vendor/base/src/definitions/registry/catalog.ts +6 -7
- package/vendor/base/src/executor.ts +25 -1
- package/vendor/base/src/manifests/executor/toolBridge.ts +25 -19
- package/vendor/base/src/manifests/runtime/channels/final.ts +4 -1
- package/vendor/base/src/manifests/runtime/duplex/session.ts +3 -9
- package/vendor/base/src/manifests/runtime/tools/toolloop.ts +28 -25
- package/vendor/base/src/platform/serviceCalls.ts +13 -0
- package/vendor/sdk/appEngine.ts +363 -0
- package/vendor/sdk/lib/actions.ts +36 -0
- package/vendor/sdk/lib/citations.ts +48 -0
- package/vendor/sdk/lib/core/apps.ts +18 -0
- package/vendor/sdk/lib/core/client.ts +3 -3
- package/vendor/sdk/lib/core/store.ts +6 -0
- package/vendor/sdk/lib/dom/app.ts +546 -0
- package/vendor/sdk/lib/dom/chrome.ts +125 -0
- package/vendor/sdk/lib/dom/component.ts +333 -0
- package/vendor/sdk/lib/dom/connection.ts +309 -0
- package/vendor/sdk/lib/dom/element.ts +69 -0
- package/vendor/sdk/lib/dom/isolate.ts +37 -0
- package/vendor/sdk/lib/dom/markdown.ts +388 -0
- package/vendor/sdk/lib/dom/markdownParse.ts +440 -0
- package/vendor/sdk/lib/dom/mounted.ts +29 -0
- package/vendor/sdk/lib/dom/primitives.ts +689 -0
- package/vendor/sdk/lib/dom/region.ts +195 -0
- package/vendor/sdk/lib/dom/render.ts +177 -0
- package/vendor/sdk/lib/dom/theme.ts +38 -0
- package/vendor/sdk/lib/dom/types.ts +36 -0
- package/vendor/sdk/lib/index.ts +26 -17
- package/vendor/sdk/lib/orb/canvas.ts +139 -0
- package/vendor/sdk/lib/orb/index.ts +8 -18
- package/vendor/sdk/lib/orb/presets.ts +1 -1
- package/vendor/sdk/lib/orb/{types.ts → states.ts} +0 -24
- package/vendor/sdk/lib/realtime/capture.ts +152 -0
- package/vendor/sdk/lib/realtime/playback.ts +129 -0
- package/vendor/sdk/lib/realtime/socket.ts +117 -0
- package/vendor/sdk/lib/style.ts +45 -37
- package/vendor/sdk/lib/voiceService.ts +344 -0
- package/vendor/sdk/main.ts +107 -0
- package/vendor/sdk/mcpAppBridge.ts +125 -102
- package/vendor/sdk/pageInterfaces.ts +452 -0
- package/vendor/sdk/responseView.ts +193 -0
- package/vite.config.ts +3 -3
- package/vendor/sdk/appEngine.tsx +0 -395
- package/vendor/sdk/lib/UnoverseComponent.tsx +0 -325
- package/vendor/sdk/lib/app.tsx +0 -730
- package/vendor/sdk/lib/connection.tsx +0 -433
- package/vendor/sdk/lib/isolate.tsx +0 -70
- package/vendor/sdk/lib/orb/ThinkingOrb.tsx +0 -131
- package/vendor/sdk/lib/orb/theme.ts +0 -83
- package/vendor/sdk/lib/primitives.tsx +0 -943
- package/vendor/sdk/lib/realtime/useAudioCapture.ts +0 -237
- package/vendor/sdk/lib/realtime/useAudioPlayback.ts +0 -157
- package/vendor/sdk/lib/realtime/useRealtimeWebSocket.ts +0 -164
- package/vendor/sdk/lib/render.tsx +0 -244
- package/vendor/sdk/lib/streamed.tsx +0 -30
- package/vendor/sdk/lib/theme.ts +0 -55
- package/vendor/sdk/lib/transition/MountArrangement.tsx +0 -71
- package/vendor/sdk/lib/transition/index.ts +0 -9
- package/vendor/sdk/lib/transition/viewTransition.ts +0 -29
- package/vendor/sdk/lib/voice.tsx +0 -422
- package/vendor/sdk/main.tsx +0 -125
- package/vendor/sdk/pageInterfaces.tsx +0 -388
- package/vendor/sdk/responseView.tsx +0 -82
|
@@ -1,943 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* ════════════════════════════════════════════════════════════════════════════
|
|
3
|
-
* EVERY PRIMITIVE LIVES HERE — render.tsx is the dispatcher and renders NONE.
|
|
4
|
-
* ════════════════════════════════════════════════════════════════════════════
|
|
5
|
-
*
|
|
6
|
-
* This file is the CLOSED set of leaf primitives — the web realization of the
|
|
7
|
-
* irreducible neutral elements: Box, Text, Image, Button, Icon, Skeleton, Input,
|
|
8
|
-
* Markdown (+ the Unknown fallback). If you are writing a `<div>`, `<button>`,
|
|
9
|
-
* `<span>`, `<img>`, `<input>`, `<svg>` … it goes HERE, never in render.tsx.
|
|
10
|
-
* render.tsx ONLY dispatches `node.type` → one of these components. (A build
|
|
11
|
-
* guard, test/dispatcher-only.test.mjs, fails if a raw element appears in
|
|
12
|
-
* render.tsx — so this separation can't silently rot again.)
|
|
13
|
-
*
|
|
14
|
-
* ⛔ A primitive renders ONE generic element and NOTHING about a specific UX.
|
|
15
|
-
* No icons-in-the-input, no send buttons, no pills, no cards, no loaders.
|
|
16
|
-
* Those are LAYOUTS — compose them in a DEFINITION from generic primitives.
|
|
17
|
-
* Every style VALUE is served (theme.*); this file authors none. The bar to
|
|
18
|
-
* ADD a primitive is high — it must be an irreducible element at the tier of
|
|
19
|
-
* Text/Image (see FRAMEWORK.md), never a composite.
|
|
20
|
-
* ════════════════════════════════════════════════════════════════════════════
|
|
21
|
-
*/
|
|
22
|
-
import { createContext, createElement, useContext, useEffect, useLayoutEffect, useMemo, useRef, useState, type CSSProperties, type ReactNode } from "react";
|
|
23
|
-
import Markdown from "markdown-to-jsx";
|
|
24
|
-
import type { ResolvedTheme, UnoverseNode } from "./core";
|
|
25
|
-
import { styleToCss, cssDecls } from "./style";
|
|
26
|
-
import { nodeAction, type ActionHandler } from "./render";
|
|
27
|
-
import { ThinkingOrb, type OrbState, type OrbSize, type OrbTheme } from "./orb";
|
|
28
|
-
|
|
29
|
-
/** Read a bound data field as a string (for Text/Image/Button content). */
|
|
30
|
-
function bound(field: string | undefined, data: Record<string, unknown>): string | undefined {
|
|
31
|
-
return field ? (data[field] as string | undefined) : undefined;
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
/** Stable class name from a string (identical state specs share one injected rule). */
|
|
35
|
-
function hashStr(s: string): string {
|
|
36
|
-
let h = 0;
|
|
37
|
-
for (let i = 0; i < s.length; i++) h = (Math.imul(31, h) + s.charCodeAt(i)) | 0;
|
|
38
|
-
return (h >>> 0).toString(36);
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
// Interaction states are CSS pseudo-classes — inline styles can't express them, so we
|
|
42
|
-
// resolve each state's tokens → a scoped rule and inject it (deduped by class hash).
|
|
43
|
-
// Generic vocab (any element), values served; the SDK authors none. Add a state here.
|
|
44
|
-
const INTERACTION_STATES: [string, string][] = [
|
|
45
|
-
["hover", ":hover"],
|
|
46
|
-
["active", ":active"],
|
|
47
|
-
];
|
|
48
|
-
|
|
49
|
-
// A state rule is a pure function of (spec object, theme) — and spec objects come from
|
|
50
|
-
// cached defs, so they're referentially stable. Resolve + serialize + hash once, not
|
|
51
|
-
// per element per render.
|
|
52
|
-
const stateRuleCache = new WeakMap<ResolvedTheme, WeakMap<object, { cls: string; rule: string }>>();
|
|
53
|
-
function stateRule(theme: ResolvedTheme, spec: Record<string, unknown>, key: string, pseudo: string): { cls: string; rule: string } {
|
|
54
|
-
let bySpec = stateRuleCache.get(theme);
|
|
55
|
-
if (!bySpec) {
|
|
56
|
-
bySpec = new WeakMap();
|
|
57
|
-
stateRuleCache.set(theme, bySpec);
|
|
58
|
-
}
|
|
59
|
-
let hit = bySpec.get(spec);
|
|
60
|
-
if (!hit) {
|
|
61
|
-
// !important — the base style is inline, which beats a class selector otherwise.
|
|
62
|
-
const decls = cssDecls(styleToCss(spec, theme), true);
|
|
63
|
-
const cls = `uno-${key}-${hashStr(decls)}`;
|
|
64
|
-
hit = { cls, rule: `.${cls}${pseudo}{${decls}}` };
|
|
65
|
-
bySpec.set(spec, hit);
|
|
66
|
-
}
|
|
67
|
-
return hit;
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
/**
|
|
71
|
-
* Resolve the generic per-element "chrome" shared by interactive primitives:
|
|
72
|
-
* the base inline style, hover/active rules (injected as <style>), and the
|
|
73
|
-
* data-driven `disabled` state (known at render via `disabledWhen` → merges the
|
|
74
|
-
* `disabled` style + sets the attr). All values are served; this authors none.
|
|
75
|
-
*/
|
|
76
|
-
function nodeChrome(node: UnoverseNode, data: Record<string, unknown>, theme: ResolvedTheme) {
|
|
77
|
-
const base = styleToCss(node.style, theme, data);
|
|
78
|
-
const disabled = node.disabledWhen ? !!data[node.disabledWhen] : false;
|
|
79
|
-
const style = disabled && node.style?.disabled ? { ...base, ...styleToCss(node.style.disabled as Record<string, unknown>, theme) } : base;
|
|
80
|
-
const classes: string[] = [];
|
|
81
|
-
const styleEls: ReactNode[] = [];
|
|
82
|
-
const s = node.style as Record<string, unknown> | undefined;
|
|
83
|
-
for (const [key, pseudo] of INTERACTION_STATES) {
|
|
84
|
-
const spec = s?.[key];
|
|
85
|
-
if (!spec) continue;
|
|
86
|
-
const { cls, rule } = stateRule(theme, spec as Record<string, unknown>, key, pseudo);
|
|
87
|
-
classes.push(cls);
|
|
88
|
-
styleEls.push(<style key={key}>{rule}</style>);
|
|
89
|
-
}
|
|
90
|
-
// `hideBelow` — responsive visibility via a CONTAINER query (inline styles can't express
|
|
91
|
-
// one): hides the element when its nearest container ancestor (an element with the
|
|
92
|
-
// `container` style) is narrower than a SERVED width token — e.g. drop a side image when
|
|
93
|
-
// the COMPONENT (not the viewport) is mobile-width. Correct for SDUI: a component reacts to
|
|
94
|
-
// its slot, not the page. Generic vocab, value served; the SDK authors none.
|
|
95
|
-
if (s?.hideBelow) {
|
|
96
|
-
const w = theme.space[s.hideBelow as string] ?? theme.layout?.[s.hideBelow as string] ?? (s.hideBelow as string);
|
|
97
|
-
const cls = `uno-hidebelow-${hashStr(String(w))}`;
|
|
98
|
-
classes.push(cls);
|
|
99
|
-
styleEls.push(<style key="hideBelow">{`@container (max-width:${w}){.${cls}{display:none !important}}`}</style>);
|
|
100
|
-
}
|
|
101
|
-
// `span` STACKS. A child covering several of its grid's columns must give that up when
|
|
102
|
-
// the grid runs out of room, or four quarter-width cards keep being quarters in a 300px
|
|
103
|
-
// rail and the content is crushed. Below the threshold every spanning child takes the
|
|
104
|
-
// whole row (`1 / -1`), which IS the stack: one per row, full width, in source order.
|
|
105
|
-
//
|
|
106
|
-
// The query targets the GRID (style.ts marks it `container-type: inline-size`), so this
|
|
107
|
-
// reacts to the space the grid actually has, not the viewport. A component in a narrow
|
|
108
|
-
// panel on a wide screen stacks correctly, which a viewport breakpoint cannot do.
|
|
109
|
-
//
|
|
110
|
-
// The threshold is SERVED (`theme.grid.stackBelow`). The SDK holds no width of its own.
|
|
111
|
-
if (s?.span) {
|
|
112
|
-
const raw = theme.grid?.stackBelow ?? "";
|
|
113
|
-
const w = theme.space[raw] ?? theme.layout?.[raw] ?? raw;
|
|
114
|
-
if (w) {
|
|
115
|
-
const cls = `uno-stack-${hashStr(String(w))}`;
|
|
116
|
-
classes.push(cls);
|
|
117
|
-
styleEls.push(<style key="span-stack">{`@container (max-width:${w}){.${cls}:not(.uno-gridstack > *){grid-column:1 / -1 !important}}`}</style>);
|
|
118
|
-
}
|
|
119
|
-
}
|
|
120
|
-
// A GRID'S OWN `stackBelow` overrides the served threshold for that grid: below it, EVERY
|
|
121
|
-
// child takes the whole row, spanning or not, so a two-up list stacks as a 7/5 split does.
|
|
122
|
-
// The children query the grid itself (it is `container-type: inline-size`), and the
|
|
123
|
-
// served threshold above steps aside for them (`uno-gridstack`), so a grid may hold its
|
|
124
|
-
// shape longer as well as give it up sooner.
|
|
125
|
-
if (s?.columns && s.stackBelow) {
|
|
126
|
-
const raw = s.stackBelow as string;
|
|
127
|
-
const w = theme.space[raw] ?? theme.layout?.[raw] ?? raw;
|
|
128
|
-
const cls = `uno-gridstack-${hashStr(String(w))}`;
|
|
129
|
-
classes.push("uno-gridstack", cls);
|
|
130
|
-
styleEls.push(<style key="grid-stack">{`@container (max-width:${w}){.${cls} > *{grid-column:1 / -1 !important}}`}</style>);
|
|
131
|
-
}
|
|
132
|
-
// `columns` AS A BREAKPOINT MAP — the responsive grid: column counts per named band
|
|
133
|
-
// ({mobile: "1", tablet: "2", desktop: "3", full: "4"}), applied by CONTAINER width
|
|
134
|
-
// (the nearest `container` ancestor — a grid in a narrow panel stacks on a wide
|
|
135
|
-
// screen, which is what SDUI needs). `mobile` is the base band; a band not named
|
|
136
|
-
// inherits the previous one. Thresholds are SERVED (`theme.breakpoint.*`); the SDK
|
|
137
|
-
// holds no widths. The rule also marks the grid `container-type: inline-size`, so
|
|
138
|
-
// its own children (`span`) can query it, same as the inline `columns` form.
|
|
139
|
-
if (s?.columns && typeof s.columns === "object") {
|
|
140
|
-
const map = s.columns as Record<string, unknown>;
|
|
141
|
-
const base = map.mobile ?? map.tablet ?? map.desktop ?? map.full;
|
|
142
|
-
if (base != null) {
|
|
143
|
-
const cls = `uno-cols-${hashStr(JSON.stringify(map))}`;
|
|
144
|
-
let rules = `.${cls}{display:grid;grid-template-columns:repeat(${String(base)},minmax(0,1fr));align-content:start;container-type:inline-size}`;
|
|
145
|
-
for (const band of ["tablet", "desktop", "full"] as const) {
|
|
146
|
-
const w = theme.breakpoint?.[band];
|
|
147
|
-
if (map[band] != null && w) {
|
|
148
|
-
rules += `@container (min-width:${w}){.${cls}{grid-template-columns:repeat(${String(map[band])},minmax(0,1fr))}}`;
|
|
149
|
-
}
|
|
150
|
-
}
|
|
151
|
-
classes.push(cls);
|
|
152
|
-
styleEls.push(<style key="columns">{rules}</style>);
|
|
153
|
-
}
|
|
154
|
-
}
|
|
155
|
-
// `hideAbove` — the mirror: hide when the container is WIDER than the width. Together
|
|
156
|
-
// these are the whole layout-switch mechanism for space-responsive components: the
|
|
157
|
-
// compact layout carries hideAbove, the full layout hideBelow, and the container's
|
|
158
|
-
// width (granted by the template's surface) picks the rendering — never a state key.
|
|
159
|
-
if (s?.hideAbove) {
|
|
160
|
-
const w = theme.space[s.hideAbove as string] ?? theme.layout?.[s.hideAbove as string] ?? (s.hideAbove as string);
|
|
161
|
-
const cls = `uno-hideabove-${hashStr(String(w))}`;
|
|
162
|
-
classes.push(cls);
|
|
163
|
-
styleEls.push(<style key="hideAbove">{`@container (min-width:${w}){.${cls}{display:none !important}}`}</style>);
|
|
164
|
-
}
|
|
165
|
-
return { className: classes.join(" ") || undefined, styleEls, style, disabled };
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
/** `Box`/`Stack`/`Row`/`Column` — the generic container (`<div>`). Children are
|
|
169
|
-
* pre-rendered by the dispatcher; layout/look come entirely from node.style. */
|
|
170
|
-
/**
|
|
171
|
-
* INSPECTABILITY: a node carrying `$id` stamps it on the element it renders.
|
|
172
|
-
*
|
|
173
|
-
* Nothing in the SDK reads it back and nothing looks different without it. It exists so a
|
|
174
|
-
* TOOL can ask "which element did this node produce" — an authoring surface boxing the node
|
|
175
|
-
* under the cursor, for instance. The renderer is the only place that can answer, because
|
|
176
|
-
* `Switch`, `Each` and `visibleWhen` mean the rendered tree is not the definition tree, so
|
|
177
|
-
* the link is made while rendering or not at all.
|
|
178
|
-
*
|
|
179
|
-
* `$id` is assigned by whoever supplies the definition, never by an author. This is a
|
|
180
|
-
* generic behaviour, not a style and not a UX shape: no primitive is added, no layout
|
|
181
|
-
* decision is taken, and a definition without `$id` renders byte-identically.
|
|
182
|
-
*/
|
|
183
|
-
const inspectId = (node: UnoverseNode): string | undefined => {
|
|
184
|
-
const id = (node as { $id?: unknown }).$id;
|
|
185
|
-
return typeof id === "string" ? id : undefined;
|
|
186
|
-
};
|
|
187
|
-
|
|
188
|
-
export function BoxView({ node, data, theme, children, onAction }: { node: UnoverseNode; data: Record<string, unknown>; theme: ResolvedTheme; children?: ReactNode; onAction?: ActionHandler }): ReactNode {
|
|
189
|
-
const { className, styleEls, style } = nodeChrome(node, data, theme);
|
|
190
|
-
const scrollRef = useAutoScrollToEnd(node.autoScroll === true);
|
|
191
|
-
const revealRef = useRevealWhenStuck(node.revealOnStick === true);
|
|
192
|
-
const sel = (node as { selection?: { label?: string; action?: unknown } }).selection;
|
|
193
|
-
const { selRef, pick, clearPick } = useTextSelection(!!sel);
|
|
194
|
-
// A selection host must anchor its floating affordance; default to relative only when
|
|
195
|
-
// the definition declared no position of its own (sticky/absolute roots keep theirs).
|
|
196
|
-
const hostStyle = sel && !(style as CSSProperties).position ? ({ ...style, position: "relative" } as CSSProperties) : style;
|
|
197
|
-
// A STAGE (`style.stage: 1280x720`): the Box is drawn at that fixed size and scaled as one
|
|
198
|
-
// piece to the width it is given, the way slide tools render a slide. Its layout never
|
|
199
|
-
// reflows, so what is aligned stays aligned on every screen.
|
|
200
|
-
const stage = /^(\d+)x(\d+)$/.exec(String((node.style as { stage?: unknown } | undefined)?.stage ?? ""));
|
|
201
|
-
const stageW = stage ? Number(stage[1]) : 0;
|
|
202
|
-
const stageRef = useRef<HTMLDivElement>(null);
|
|
203
|
-
const [scale, setScale] = useState(1);
|
|
204
|
-
useLayoutEffect(() => {
|
|
205
|
-
const el = stageRef.current;
|
|
206
|
-
if (!stageW || !el) return;
|
|
207
|
-
const ro = new ResizeObserver(() => setScale(el.clientWidth / stageW));
|
|
208
|
-
ro.observe(el);
|
|
209
|
-
return () => ro.disconnect();
|
|
210
|
-
}, [stageW]);
|
|
211
|
-
if (stage)
|
|
212
|
-
return (
|
|
213
|
-
<div ref={stageRef} style={{ position: "relative", width: "100%", aspectRatio: `${stage[1]} / ${stage[2]}` }}>
|
|
214
|
-
<div className={className} style={{ ...hostStyle, position: "absolute", top: 0, left: 0, width: stageW, height: Number(stage[2]), transform: `scale(${scale})`, transformOrigin: "0 0" }} data-unoverse-id={inspectId(node)}>
|
|
215
|
-
{styleEls}
|
|
216
|
-
{children}
|
|
217
|
-
</div>
|
|
218
|
-
</div>
|
|
219
|
-
);
|
|
220
|
-
return (
|
|
221
|
-
<div ref={scrollRef ?? revealRef ?? selRef} className={className} style={hostStyle} data-unoverse-id={inspectId(node)}>
|
|
222
|
-
{styleEls}
|
|
223
|
-
{children}
|
|
224
|
-
{sel && pick && (
|
|
225
|
-
<button
|
|
226
|
-
style={{
|
|
227
|
-
position: "absolute",
|
|
228
|
-
top: pick.top,
|
|
229
|
-
left: pick.left,
|
|
230
|
-
transform: "translate(-50%, -100%)",
|
|
231
|
-
zIndex: 40,
|
|
232
|
-
whiteSpace: "nowrap",
|
|
233
|
-
...styleToCss(((theme as { selection?: { chip?: Record<string, unknown> } }).selection?.chip ?? SELECTION_CHIP_BUILTIN) as Record<string, unknown>, theme),
|
|
234
|
-
}}
|
|
235
|
-
onMouseDown={(e) => e.preventDefault() /* keep the selection alive through the click */}
|
|
236
|
-
onClick={() => {
|
|
237
|
-
onAction?.((sel.action as Parameters<ActionHandler>[0]) ?? "select", { ...data, selection: pick.text });
|
|
238
|
-
clearPick();
|
|
239
|
-
}}
|
|
240
|
-
>
|
|
241
|
-
{sel.label ?? "Comment"}
|
|
242
|
-
</button>
|
|
243
|
-
)}
|
|
244
|
-
</div>
|
|
245
|
-
);
|
|
246
|
-
}
|
|
247
|
-
|
|
248
|
-
// The selection chip's look: a SERVED recipe (`theme.selection.chip`) when the org styles
|
|
249
|
-
// one; else this neutral fallback of token NAMES resolved against the same theme — the
|
|
250
|
-
// skeleton-shimmer pattern. The SDK still authors no values, only names.
|
|
251
|
-
const SELECTION_CHIP_BUILTIN: Record<string, unknown> = {
|
|
252
|
-
background: "action.primary",
|
|
253
|
-
color: "text.inverse",
|
|
254
|
-
font: "body.sm",
|
|
255
|
-
radius: "full",
|
|
256
|
-
padding: ["1", "3"],
|
|
257
|
-
shadow: "lg",
|
|
258
|
-
border: "none",
|
|
259
|
-
cursor: "pointer",
|
|
260
|
-
};
|
|
261
|
-
|
|
262
|
-
/**
|
|
263
|
-
* Generic "text was selected here" behavior (opt-in via `node.selection`). The renderer is
|
|
264
|
-
* the ONLY layer that can know a reader selected text — definitions cannot express it —
|
|
265
|
-
* so, like `skeleton` and `autoScroll`, capture is SDK behavior while every consequence
|
|
266
|
-
* stays authored: the node declares the ACTION (fired with the selected text in scope as
|
|
267
|
-
* `{{selection}}`) and the chip's look is served (`theme.selection.chip`). Selection is
|
|
268
|
-
* read from the node's own root (shadow-DOM aware); collapsing it dismisses the chip.
|
|
269
|
-
*/
|
|
270
|
-
function useTextSelection(enabled: boolean) {
|
|
271
|
-
const selRef = useRef<HTMLDivElement | null>(null);
|
|
272
|
-
const [pick, setPick] = useState<{ text: string; top: number; left: number } | null>(null);
|
|
273
|
-
useEffect(() => {
|
|
274
|
-
if (!enabled) return;
|
|
275
|
-
const el = selRef.current;
|
|
276
|
-
if (!el) return;
|
|
277
|
-
const read = () => {
|
|
278
|
-
const root = el.getRootNode() as Document | ShadowRoot;
|
|
279
|
-
const s = (root as unknown as { getSelection?: () => Selection | null }).getSelection?.() ?? window.getSelection();
|
|
280
|
-
if (!s || s.isCollapsed || s.rangeCount === 0) return setPick(null);
|
|
281
|
-
const range = s.getRangeAt(0);
|
|
282
|
-
if (!el.contains(range.commonAncestorContainer)) return setPick(null);
|
|
283
|
-
const text = s.toString().trim();
|
|
284
|
-
if (!text) return setPick(null);
|
|
285
|
-
const r = range.getBoundingClientRect();
|
|
286
|
-
const host = el.getBoundingClientRect();
|
|
287
|
-
setPick({ text, top: r.top - host.top, left: Math.min(Math.max(0, r.left - host.left + r.width / 2), host.width) });
|
|
288
|
-
};
|
|
289
|
-
const up = () => window.setTimeout(read, 0); // after the browser settles the selection
|
|
290
|
-
// Listen on the DOCUMENT, not the host: a drag-select routinely ends with the mouse
|
|
291
|
-
// released outside the host (over a rail, past the edge), and mouse events are
|
|
292
|
-
// composed, so they cross the shadow boundary to reach it. `read` still guards with
|
|
293
|
-
// `el.contains(...)`, so only selections inside this host raise the chip.
|
|
294
|
-
const doc = el.ownerDocument;
|
|
295
|
-
doc.addEventListener("mouseup", up);
|
|
296
|
-
return () => doc.removeEventListener("mouseup", up);
|
|
297
|
-
}, [enabled]);
|
|
298
|
-
return { selRef: enabled ? selRef : undefined, pick, clearPick: () => setPick(null) };
|
|
299
|
-
}
|
|
300
|
-
|
|
301
|
-
/**
|
|
302
|
-
* Generic "stick to the newest content" behavior for a scroll container (opt-in via
|
|
303
|
-
* `node.autoScroll`). Smooth-scrolls to the bottom whenever content grows — a new turn
|
|
304
|
-
* (childList change) OR streaming text (a child resizing) — but only while the user is
|
|
305
|
-
* already near the bottom, so scrolling up to read history is never yanked back down.
|
|
306
|
-
* NOT chat-specific: any `overflow:auto` Box can opt in. The SDK owns behaviour, never
|
|
307
|
-
* styles — the scroll container itself is defined in `design/` (the conversation state box).
|
|
308
|
-
*/
|
|
309
|
-
function useAutoScrollToEnd(enabled: boolean) {
|
|
310
|
-
const ref = useRef<HTMLDivElement | null>(null);
|
|
311
|
-
useEffect(() => {
|
|
312
|
-
if (!enabled) return;
|
|
313
|
-
const el = ref.current;
|
|
314
|
-
if (!el) return;
|
|
315
|
-
const nearBottom = () => el.scrollHeight - el.scrollTop - el.clientHeight < 140;
|
|
316
|
-
const stick = () => {
|
|
317
|
-
if (nearBottom()) el.scrollTo({ top: el.scrollHeight, behavior: "smooth" });
|
|
318
|
-
};
|
|
319
|
-
el.scrollTop = el.scrollHeight; // land at the end on mount (no animation)
|
|
320
|
-
const ro = new ResizeObserver(stick); // catches streaming text growing a turn's height
|
|
321
|
-
const observeKids = () => {
|
|
322
|
-
for (const kid of Array.from(el.children)) ro.observe(kid);
|
|
323
|
-
};
|
|
324
|
-
observeKids();
|
|
325
|
-
const mo = new MutationObserver(() => {
|
|
326
|
-
observeKids(); // new turns → observe them too
|
|
327
|
-
stick();
|
|
328
|
-
});
|
|
329
|
-
mo.observe(el, { childList: true, subtree: true });
|
|
330
|
-
return () => {
|
|
331
|
-
ro.disconnect();
|
|
332
|
-
mo.disconnect();
|
|
333
|
-
};
|
|
334
|
-
}, [enabled]);
|
|
335
|
-
return enabled ? ref : undefined;
|
|
336
|
-
}
|
|
337
|
-
|
|
338
|
-
/**
|
|
339
|
-
* Generic "sticky bar that reveals only once you've scrolled past the element before it"
|
|
340
|
-
* (opt-in via `node.revealOnStick`). Pins the bar at the top (its own `position: sticky`
|
|
341
|
-
* + `top` in `design/`) and, using an IntersectionObserver on the bar's PREVIOUS sibling as
|
|
342
|
-
* the sentinel (e.g. a hero image), keeps it hidden while that sibling is on screen and
|
|
343
|
-
* fades it in once the sibling scrolls away. A negative margin removes the bar's flow
|
|
344
|
-
* footprint so it OVERLAYS the content (no gap at the top). NOT card-specific: any sticky
|
|
345
|
-
* bar can opt in. The SDK owns behaviour, never styles — the bar's look lives in `design/`.
|
|
346
|
-
*/
|
|
347
|
-
function useRevealWhenStuck(enabled: boolean) {
|
|
348
|
-
const ref = useRef<HTMLDivElement | null>(null);
|
|
349
|
-
useEffect(() => {
|
|
350
|
-
if (!enabled) return;
|
|
351
|
-
const el = ref.current;
|
|
352
|
-
if (!el) return;
|
|
353
|
-
const sentinel = el.previousElementSibling; // reveal the bar once THIS scrolls away
|
|
354
|
-
// Overlay, not push: cancel the bar's own height so content sits under it, bar pins on top.
|
|
355
|
-
el.style.marginBottom = `-${el.offsetHeight}px`;
|
|
356
|
-
el.style.transition = "opacity 200ms ease, transform 200ms ease";
|
|
357
|
-
const hide = () => {
|
|
358
|
-
el.style.opacity = "0";
|
|
359
|
-
el.style.transform = "translateY(-8px)";
|
|
360
|
-
el.style.pointerEvents = "none";
|
|
361
|
-
};
|
|
362
|
-
const show = () => {
|
|
363
|
-
el.style.opacity = "1";
|
|
364
|
-
el.style.transform = "translateY(0)";
|
|
365
|
-
el.style.pointerEvents = "auto";
|
|
366
|
-
};
|
|
367
|
-
hide();
|
|
368
|
-
if (!sentinel) return void show(); // no sentinel to hide behind → just show
|
|
369
|
-
const io = new IntersectionObserver(([e]) => (e.isIntersecting ? hide() : show()), { threshold: 0 });
|
|
370
|
-
io.observe(sentinel);
|
|
371
|
-
return () => io.disconnect();
|
|
372
|
-
}, [enabled]);
|
|
373
|
-
return enabled ? ref : undefined;
|
|
374
|
-
}
|
|
375
|
-
|
|
376
|
-
/** `Text` — a bound string (`<span>`). */
|
|
377
|
-
export function TextView({ node, data, theme }: { node: UnoverseNode; data: Record<string, unknown>; theme: ResolvedTheme }): ReactNode {
|
|
378
|
-
// Literal `value` fallback: components are microapps — static copy is HARDCODED in the
|
|
379
|
-
// def, props exist only for state (UNOVERSE_AUTHORING.md). bind wins when present.
|
|
380
|
-
return <span style={styleToCss(node.style, theme, data)} data-unoverse-id={inspectId(node)}>{bound(node.bind?.value, data) ?? (node.value as ReactNode)}</span>;
|
|
381
|
-
}
|
|
382
|
-
|
|
383
|
-
/** `Image` — a bound OR literal src (`<img>`). */
|
|
384
|
-
export function ImageView({ node, data, theme }: { node: UnoverseNode; data: Record<string, unknown>; theme: ResolvedTheme }): ReactNode {
|
|
385
|
-
return <img data-unoverse-id={inspectId(node)} style={styleToCss(node.style, theme, data)} src={bound(node.bind?.src, data) ?? node.src} alt={bound(node.bind?.alt, data) ?? node.alt ?? ""} />;
|
|
386
|
-
}
|
|
387
|
-
|
|
388
|
-
/** `Button` — fires `onAction(node.action, data)`. Content is a bound label OR composed
|
|
389
|
-
* children (so an Icon can sit in a button). The SDK authors no button styling. */
|
|
390
|
-
export function ButtonView({ node, data, theme, onAction, children }: { node: UnoverseNode; data: Record<string, unknown>; theme: ResolvedTheme; onAction?: ActionHandler; children?: ReactNode }): ReactNode {
|
|
391
|
-
const { className, styleEls, style, disabled } = nodeChrome(node, data, theme);
|
|
392
|
-
return (
|
|
393
|
-
<button className={className} style={style} disabled={disabled} data-unoverse-id={inspectId(node)} onClick={() => onAction?.(nodeAction(node, "click"), data)}>
|
|
394
|
-
{styleEls}
|
|
395
|
-
{node.children?.length ? children : bound(node.bind?.label, data)}
|
|
396
|
-
</button>
|
|
397
|
-
);
|
|
398
|
-
}
|
|
399
|
-
|
|
400
|
-
/**
|
|
401
|
-
* `Orb` — a procedural "the assistant is doing something" animation, drawn
|
|
402
|
-
* per-frame on a 2D canvas (see ./orb — vendored thinking-orbs, MIT). This is
|
|
403
|
-
* the ONE class of motion keyframe data cannot express (per-frame math, depth
|
|
404
|
-
* occlusion), so like Skeleton's shimmer the drawing lives WITH the primitive;
|
|
405
|
-
* every authoring decision stays data: `state` (which of the nine animations —
|
|
406
|
-
* bindable), `size` (64 avatar / 20 inline), `speed`, `theme` override.
|
|
407
|
-
* Unknown/unbound state degrades to "working", never a crash.
|
|
408
|
-
*/
|
|
409
|
-
const ORB_STATES = new Set(["working", "searching", "solving", "listening", "connecting", "weaving", "composing", "breathing", "shaping", "halo", "sonar", "shimmer", "pulse"]);
|
|
410
|
-
export function OrbView({ node, data, theme }: { node: UnoverseNode; data: Record<string, unknown>; theme: ResolvedTheme }): ReactNode {
|
|
411
|
-
const n = node as UnoverseNode & { state?: string; size?: number | string; speed?: number | string; theme?: string };
|
|
412
|
-
// Every knob is data-driven OR literal, like Icon: `bind.<knob>` wins, the attribute is the fallback.
|
|
413
|
-
const knob = (name: "state" | "size" | "speed") =>
|
|
414
|
-
(node.bind?.[name] ? (data[node.bind[name] as string] as string | number | undefined) : undefined) ?? n[name];
|
|
415
|
-
const raw = knob("state") as string | undefined;
|
|
416
|
-
// UNKNOWN NAME → the calm ANNULAR fallback, never the sphere. A bad/stale name is an
|
|
417
|
-
// authoring or version mismatch, and the orb most often sits behind a face: degrading
|
|
418
|
-
// to `working` threw 3-D particles across the portrait (the loudest mode in the set) to
|
|
419
|
-
// signal a problem the viewer cannot act on. `halo` is quiet, ring-shaped, and wrong in
|
|
420
|
-
// no context — a mismatch should look calm, not broken.
|
|
421
|
-
const state = raw && ORB_STATES.has(raw) ? (raw as OrbState) : "halo";
|
|
422
|
-
const size = Number(knob("size")) > 0 ? Number(knob("size")) : 64;
|
|
423
|
-
const speed = Number(knob("speed")) > 0 ? Number(knob("speed")) : 1;
|
|
424
|
-
const orbTheme: OrbTheme = n.theme === "dark" || n.theme === "light" ? n.theme : "auto";
|
|
425
|
-
// Brand ink comes from the STYLE vocabulary (`color: <token>`), never a prop — LAW 1:
|
|
426
|
-
// a definition names a token, the theme serves the value, the engine tints toward it.
|
|
427
|
-
const css = styleToCss(node.style ?? {}, theme, data);
|
|
428
|
-
return <ThinkingOrb state={state} size={size as OrbSize} speed={speed} theme={orbTheme} ink={cssColorToRgb(css.color as string | undefined)} style={css} />;
|
|
429
|
-
}
|
|
430
|
-
|
|
431
|
-
/** #rgb / #rrggbb / rgb()/rgba() → an rgb tuple; anything else (gradients, names) → null (neutral ink). */
|
|
432
|
-
function cssColorToRgb(c?: string): [number, number, number] | null {
|
|
433
|
-
if (!c) return null;
|
|
434
|
-
const hex = /^#([0-9a-f]{3}|[0-9a-f]{6})$/i.exec(c.trim());
|
|
435
|
-
if (hex) {
|
|
436
|
-
const h = hex[1].length === 3 ? [...hex[1]].map((x) => x + x).join("") : hex[1];
|
|
437
|
-
return [0, 2, 4].map((i) => parseInt(h.slice(i, i + 2), 16)) as [number, number, number];
|
|
438
|
-
}
|
|
439
|
-
const fn = /^rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/i.exec(c.trim());
|
|
440
|
-
return fn ? [Number(fn[1]), Number(fn[2]), Number(fn[3])] : null;
|
|
441
|
-
}
|
|
442
|
-
|
|
443
|
-
/** Unknown node type — render children so the tree doesn't break (degenerate fallback). */
|
|
444
|
-
export function UnknownView({ node, theme, children }: { node: UnoverseNode; theme: ResolvedTheme; children?: ReactNode }): ReactNode {
|
|
445
|
-
return (
|
|
446
|
-
<div style={styleToCss(node.style, theme)} data-unoverse-unknown={node.type}>
|
|
447
|
-
{children}
|
|
448
|
-
</div>
|
|
449
|
-
);
|
|
450
|
-
}
|
|
451
|
-
|
|
452
|
-
/**
|
|
453
|
-
* `Icon` — leaf primitive (the irreducible SVG, like Text→<span>/Image→<img>).
|
|
454
|
-
* Renders a named glyph whose element DATA is SERVED (theme.icons): the server sources
|
|
455
|
-
* it from an icon pack (control plane) and serves `{ viewBox, attrs, children }`, where
|
|
456
|
-
* children are raw SVG elements `[tag, attrs]`. The SDK NEVER bundles a pack — it's
|
|
457
|
-
* pack-agnostic, just rendering served elements. Size = node.style width/height, colour
|
|
458
|
-
* = node.style color (→ `currentColor`). Name is literal (`node.icon`) or bound
|
|
459
|
-
* (`bind.name`). NOT a composite — one glyph, no buttons/chrome.
|
|
460
|
-
*/
|
|
461
|
-
export function IconView({ name, style, theme }: { name?: string; style?: Record<string, unknown>; theme: ResolvedTheme }): ReactNode {
|
|
462
|
-
const set = (theme.icons ?? {}) as Record<string, { viewBox?: string; attrs?: Record<string, unknown>; children?: [string, Record<string, unknown>][] }>;
|
|
463
|
-
const ic = name ? set[name] : undefined;
|
|
464
|
-
if (!ic) return null;
|
|
465
|
-
const children = Array.isArray(ic.children) ? ic.children : [];
|
|
466
|
-
return (
|
|
467
|
-
<svg viewBox={ic.viewBox ?? "0 0 24 24"} {...(ic.attrs ?? {})} aria-hidden="true" style={styleToCss(style ?? {}, theme)}>
|
|
468
|
-
{children.map(([tag, attrs], i) => createElement(tag, { key: i, ...attrs }))}
|
|
469
|
-
</svg>
|
|
470
|
-
);
|
|
471
|
-
}
|
|
472
|
-
|
|
473
|
-
/**
|
|
474
|
-
* `Skeleton` — built-in loading placeholder. ALL dimensions are DATA, fetched
|
|
475
|
-
* from the theme (theme.skeleton ← design/styles/semantic/skeleton.json). The SDK
|
|
476
|
-
* authors nothing here but structure (flex/full). `bars` are 'width height' pairs.
|
|
477
|
-
*/
|
|
478
|
-
export function SkeletonView({ variant = "text", theme }: { variant?: string; theme: ResolvedTheme }): ReactNode {
|
|
479
|
-
// DEFENSIVE BY LAW: a loading GHOST must never crash the app. Every token read
|
|
480
|
-
// tolerates absence (missing token = that dimension simply unset — we still author
|
|
481
|
-
// no values); a theme without a skeleton recipe renders inert placeholders, never
|
|
482
|
-
// a TypeError that unmounts the whole tree (observed live: blank panel).
|
|
483
|
-
const sk = (theme.skeleton ?? {}) as Record<string, any>;
|
|
484
|
-
// SELF-CONTAINED shimmer: the loading ghost is generic chrome, so its motion lives
|
|
485
|
-
// WITH the primitive — no cross-file dependency on an org's keyframes recipe that
|
|
486
|
-
// silently breaks when absent. The sweep is a neutral white-alpha overlay (brand
|
|
487
|
-
// colors still come from the served `fill`); a served `skeleton.shimmer` object
|
|
488
|
-
// overrides it, and `skeleton.shimmer: false` disables motion entirely.
|
|
489
|
-
const SHIMMER_BUILTIN: CSSProperties = {
|
|
490
|
-
backgroundImage: "linear-gradient(90deg, rgba(255,255,255,0) 0%, rgba(255,255,255,0.55) 50%, rgba(255,255,255,0) 100%)",
|
|
491
|
-
backgroundSize: "200% 100%",
|
|
492
|
-
animation: "uno-skeleton-shimmer 1.4s ease-in-out infinite",
|
|
493
|
-
};
|
|
494
|
-
const shimmer: CSSProperties = sk.shimmer === false ? {} : typeof sk.shimmer === "object" ? (sk.shimmer as CSSProperties) : SHIMMER_BUILTIN;
|
|
495
|
-
const shimmerKeyframes =
|
|
496
|
-
sk.shimmer === false ? null : (
|
|
497
|
-
<style>{"@keyframes uno-skeleton-shimmer{0%{background-position:200% 0}100%{background-position:-200% 0}}"}</style>
|
|
498
|
-
);
|
|
499
|
-
const bar = (spec: string, i: number): ReactNode => {
|
|
500
|
-
const [w, h] = String(spec).split(" ");
|
|
501
|
-
return <div key={i} style={{ width: w, height: h, background: sk.fill, borderRadius: sk.barRadius, ...shimmer }} />;
|
|
502
|
-
};
|
|
503
|
-
if (variant === "image")
|
|
504
|
-
return (
|
|
505
|
-
<>
|
|
506
|
-
{shimmerKeyframes}
|
|
507
|
-
<div style={{ width: "100%", height: "100%", minHeight: sk.image?.minHeight, background: sk.fill, borderRadius: sk.radius, ...shimmer }} />
|
|
508
|
-
</>
|
|
509
|
-
);
|
|
510
|
-
if (variant === "card")
|
|
511
|
-
return (
|
|
512
|
-
<>
|
|
513
|
-
{shimmerKeyframes}
|
|
514
|
-
<div
|
|
515
|
-
style={{
|
|
516
|
-
display: "flex",
|
|
517
|
-
flexDirection: "column",
|
|
518
|
-
gap: sk.gap,
|
|
519
|
-
padding: sk.card?.padding,
|
|
520
|
-
border: theme.borderWidth?.thin && sk.fill ? `${theme.borderWidth.thin} solid ${sk.fill}` : undefined,
|
|
521
|
-
borderRadius: sk.radius,
|
|
522
|
-
}}
|
|
523
|
-
>
|
|
524
|
-
{(Array.isArray(sk.card?.bars) ? (sk.card.bars as string[]) : []).map(bar)}
|
|
525
|
-
</div>
|
|
526
|
-
</>
|
|
527
|
-
);
|
|
528
|
-
return (
|
|
529
|
-
<div style={{ display: "flex", flexDirection: "column", gap: sk.gap }}>
|
|
530
|
-
{shimmerKeyframes}
|
|
531
|
-
{(Array.isArray(sk.text?.bars) ? (sk.text.bars as string[]) : []).map(bar)}
|
|
532
|
-
</div>
|
|
533
|
-
);
|
|
534
|
-
}
|
|
535
|
-
|
|
536
|
-
/**
|
|
537
|
-
* `Select` — the irreducible `<select>`, like Text→<span> and Input→<input>.
|
|
538
|
-
*
|
|
539
|
-
* THE ONE CONTROL THAT CANNOT BE COMPOSED. Every other form field in this platform is
|
|
540
|
-
* built from Box, Text, Button and Input, as data. A closed set of two hundred countries
|
|
541
|
-
* is where that stops: laying them out is unusable, filtering them is logic a definition
|
|
542
|
-
* may not carry, and a hand-rolled menu forfeits the OS picker, type-to-search, keyboard
|
|
543
|
-
* navigation and screen-reader semantics that the real element gives for nothing.
|
|
544
|
-
*
|
|
545
|
-
* It reports a change exactly as a controlled Input does — `onAction("input", { field,
|
|
546
|
-
* text })` — so the bound field name IS the key the answer lands in, and a select needs no
|
|
547
|
-
* mechanism the platform does not already have.
|
|
548
|
-
*
|
|
549
|
-
* ⛔ NO CHROME HERE. No custom arrow, no popup, no styling: `node.style` is the whole look,
|
|
550
|
-
* and the options are DATA (`bind.options`, entries of `{ value, label }` or plain
|
|
551
|
-
* strings). A searchable picker is a different control and does not belong in this one.
|
|
552
|
-
*/
|
|
553
|
-
export function SelectView({ node, data, theme, onAction }: { node: UnoverseNode; data: Record<string, unknown>; theme: ResolvedTheme; onAction?: ActionHandler }): ReactNode {
|
|
554
|
-
const field = node.bind?.value;
|
|
555
|
-
const value = field != null ? String(data[field] ?? "") : "";
|
|
556
|
-
const raw = node.bind?.options ? data[node.bind.options] : (node as { options?: unknown }).options;
|
|
557
|
-
const options = (Array.isArray(raw) ? raw : []).map((o) =>
|
|
558
|
-
o != null && typeof o === "object"
|
|
559
|
-
? { value: String((o as Record<string, unknown>).value ?? (o as Record<string, unknown>).label ?? ""), label: String((o as Record<string, unknown>).label ?? (o as Record<string, unknown>).value ?? "") }
|
|
560
|
-
: { value: String(o), label: String(o) },
|
|
561
|
-
);
|
|
562
|
-
const { style, disabled } = nodeChrome(node, data, theme);
|
|
563
|
-
return (
|
|
564
|
-
<select
|
|
565
|
-
style={style}
|
|
566
|
-
disabled={disabled}
|
|
567
|
-
value={value}
|
|
568
|
-
data-unoverse-id={inspectId(node)}
|
|
569
|
-
onChange={(e) => field != null && onAction?.("input", { text: e.target.value, field })}
|
|
570
|
-
>
|
|
571
|
-
{/* The placeholder is CONTENT, exactly as it is on an Input: an unanswered select
|
|
572
|
-
must say what it wants rather than pre-selecting the first option and claiming an
|
|
573
|
-
answer nobody gave. */}
|
|
574
|
-
{node.placeholder != null && (
|
|
575
|
-
<option value="" disabled>
|
|
576
|
-
{node.placeholder}
|
|
577
|
-
</option>
|
|
578
|
-
)}
|
|
579
|
-
{options.map((o) => (
|
|
580
|
-
<option key={o.value} value={o.value}>
|
|
581
|
-
{o.label}
|
|
582
|
-
</option>
|
|
583
|
-
))}
|
|
584
|
-
</select>
|
|
585
|
-
);
|
|
586
|
-
}
|
|
587
|
-
|
|
588
|
-
/**
|
|
589
|
-
* `Input` — single-line composer (the irreducible `<input>`, like Text→<span>).
|
|
590
|
-
* Holds its own draft text (local, not store state); on Enter it fires
|
|
591
|
-
* `onAction(node.action, { text })` and clears. The SDK authors NO styling here —
|
|
592
|
-
* look comes entirely from `node.style` (data), exactly like Button. `placeholder`
|
|
593
|
-
* is copy (content), not a style value.
|
|
594
|
-
*
|
|
595
|
-
* ⛔ DO NOT add icons, send buttons, pills, or any composer chrome here. That is
|
|
596
|
-
* UX — compose it in a DEFINITION from generic primitives (Box + Icon + Input).
|
|
597
|
-
* A primitive renders ONE generic element; it never encodes a specific layout.
|
|
598
|
-
*/
|
|
599
|
-
export function InputView({ node, data, theme, onAction }: { node: UnoverseNode; data: Record<string, unknown>; theme: ResolvedTheme; onAction?: ActionHandler }): ReactNode {
|
|
600
|
-
// CONTROLLED when `bind.value` is set: value comes from shared data and changes are
|
|
601
|
-
// emitted as an "input" action (the channel writes it to the store). This is what lets
|
|
602
|
-
// a sibling send button submit the draft. Otherwise the field keeps its own local text.
|
|
603
|
-
const field = node.bind?.value;
|
|
604
|
-
const [local, setLocal] = useState("");
|
|
605
|
-
const value = field != null ? String(data[field] ?? "") : local;
|
|
606
|
-
// GENERIC constraints on what the field ACCEPTS — the same class of fact as
|
|
607
|
-
// `multiline`/`rows`, never a layout and never a named UX. `maxLength` caps the
|
|
608
|
-
// length; `inputMode: "numeric"` asks the platform for a digit keypad AND limits
|
|
609
|
-
// the value to 0-9. An OTP box is `maxLength: 1` + `inputMode: "numeric"` in a
|
|
610
|
-
// DEFINITION — the SDK knows nothing about OTP, verification, or six boxes.
|
|
611
|
-
const maxLength = (node as { maxLength?: number }).maxLength;
|
|
612
|
-
const numeric = (node as { inputMode?: string }).inputMode === "numeric";
|
|
613
|
-
// `autoComplete` — the browser's autofill hint, passed straight through. Without one,
|
|
614
|
-
// Chrome GUESSES from shape: a row of short numeric boxes reads as a card/ID number and
|
|
615
|
-
// it offers to save the digits. The definition says what the field IS (a one-time code,
|
|
616
|
-
// a postal code, "off") and the guess never happens. Generic pass-through: the SDK holds
|
|
617
|
-
// no list of values and no idea what a one-time code is.
|
|
618
|
-
const autoComplete = (node as { autoComplete?: string }).autoComplete;
|
|
619
|
-
// `type` — WHAT THE FIELD IS, passed to the platform: `date` gets the OS date picker,
|
|
620
|
-
// `tel` the phone keypad, `file` the file chooser. The same class of generic constraint
|
|
621
|
-
// as `maxLength` and `inputMode`, and the reason a definition never has to hand-roll a
|
|
622
|
-
// calendar out of Boxes. The SDK holds no list of types and no opinion about them.
|
|
623
|
-
const inputType = (node as { inputType?: string }).inputType;
|
|
624
|
-
// `maxLength` is enforced by the element itself, but `inputMode` is only a HINT —
|
|
625
|
-
// a desktop keyboard types straight past it — so the digit rule is applied here.
|
|
626
|
-
const accept = (t: string) => {
|
|
627
|
-
const kept = numeric ? t.replace(/[^0-9]/g, "") : t;
|
|
628
|
-
return maxLength != null ? kept.slice(0, maxLength) : kept;
|
|
629
|
-
};
|
|
630
|
-
// CONTROLLED: report the change as an "input" event carrying WHICH field changed.
|
|
631
|
-
// The component dispatcher writes that field into this instance's slice (local two-way
|
|
632
|
-
// binding, §2e-3); the template composer's channel reads `text` and ignores `field`
|
|
633
|
-
// (its bound field is the shared `draft`). The SDK authors no UX — just the fact.
|
|
634
|
-
// `advanceOnFill` — when this field is FULL, focus moves to the next field that opted
|
|
635
|
-
// in; Backspace in an empty one moves back. A generic fact about a field that is one
|
|
636
|
-
// of a SEQUENCE, opted into per field so an ordinary `maxLength` text box never yanks
|
|
637
|
-
// focus away the moment it fills. The SDK still knows nothing about codes or six boxes:
|
|
638
|
-
// it walks the marked peers in document order and moves focus one step.
|
|
639
|
-
const advanceOnFill = (node as { advanceOnFill?: boolean }).advanceOnFill === true;
|
|
640
|
-
const el = useRef<HTMLInputElement>(null);
|
|
641
|
-
const stepFocus = (dir: 1 | -1) => {
|
|
642
|
-
const self = el.current;
|
|
643
|
-
if (!self) return;
|
|
644
|
-
// Scope the walk to this render's ROOT — the SDK renders inside a shadow root, so
|
|
645
|
-
// `document` would miss the peers entirely (and, on a page with two instances,
|
|
646
|
-
// could reach the wrong ones).
|
|
647
|
-
const root = self.getRootNode() as Document | ShadowRoot;
|
|
648
|
-
const peers = Array.from(root.querySelectorAll<HTMLInputElement>("input[data-uno-advance]"));
|
|
649
|
-
const next = peers[peers.indexOf(self) + dir];
|
|
650
|
-
if (!next) return;
|
|
651
|
-
next.focus();
|
|
652
|
-
next.select();
|
|
653
|
-
};
|
|
654
|
-
// CONTROLLED: report the change as an "input" event carrying WHICH field changed.
|
|
655
|
-
// The component dispatcher writes that field into this instance's slice (local two-way
|
|
656
|
-
// binding, §2e-3); the template composer's channel reads `text` and ignores `field`
|
|
657
|
-
// (its bound field is the shared `draft`). The SDK authors no UX — just the fact.
|
|
658
|
-
const setValue = (raw: string) => {
|
|
659
|
-
const t = accept(raw);
|
|
660
|
-
// Move BEFORE the store round-trip: focus is DOM state, independent of the re-render,
|
|
661
|
-
// so a fast typist's next keystroke lands in the next field rather than this one.
|
|
662
|
-
if (advanceOnFill && maxLength != null && t.length >= maxLength) stepFocus(1);
|
|
663
|
-
return field != null ? onAction?.("input", { text: t, field }) : setLocal(t);
|
|
664
|
-
};
|
|
665
|
-
const submit = () => {
|
|
666
|
-
const t = value.trim();
|
|
667
|
-
if (!t) return;
|
|
668
|
-
onAction?.(nodeAction(node, "submit"), { text: t });
|
|
669
|
-
if (field == null) setLocal("");
|
|
670
|
-
};
|
|
671
|
-
const { style, disabled } = nodeChrome(node, data, theme);
|
|
672
|
-
// `multiline: true` — the same irreducible input as a `<textarea>` (critique, notes —
|
|
673
|
-
// anything longer than one line). Enter inserts a newline; Cmd/Ctrl+Enter submits.
|
|
674
|
-
// Same element class, no chrome, no new primitive.
|
|
675
|
-
if ((node as { multiline?: boolean }).multiline === true) {
|
|
676
|
-
return (
|
|
677
|
-
<textarea
|
|
678
|
-
style={style}
|
|
679
|
-
disabled={disabled}
|
|
680
|
-
value={value}
|
|
681
|
-
placeholder={node.placeholder}
|
|
682
|
-
maxLength={maxLength}
|
|
683
|
-
rows={(node as { rows?: number }).rows ?? 3}
|
|
684
|
-
onChange={(e) => setValue(e.target.value)}
|
|
685
|
-
onKeyDown={(e) => {
|
|
686
|
-
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
|
|
687
|
-
e.preventDefault();
|
|
688
|
-
submit();
|
|
689
|
-
}
|
|
690
|
-
}}
|
|
691
|
-
/>
|
|
692
|
-
);
|
|
693
|
-
}
|
|
694
|
-
return (
|
|
695
|
-
<input
|
|
696
|
-
ref={el}
|
|
697
|
-
style={style}
|
|
698
|
-
disabled={disabled}
|
|
699
|
-
// A file input is never controlled: assigning its value is a security error in every
|
|
700
|
-
// browser, so the chosen name lives in the data and the element stays uncontrolled.
|
|
701
|
-
value={inputType === "file" ? undefined : value}
|
|
702
|
-
placeholder={node.placeholder}
|
|
703
|
-
type={inputType}
|
|
704
|
-
maxLength={maxLength}
|
|
705
|
-
inputMode={numeric ? "numeric" : undefined}
|
|
706
|
-
autoComplete={autoComplete}
|
|
707
|
-
data-uno-advance={advanceOnFill ? "" : undefined}
|
|
708
|
-
onChange={(e) => {
|
|
709
|
-
// A FILE FIELD REPORTS ITS FILES, not its value: the DOM's value for one is a fake
|
|
710
|
-
// path, and the bytes are the host's business (an upload target, a stored
|
|
711
|
-
// reference) rather than the renderer's. The SDK states what was chosen and stops.
|
|
712
|
-
if (inputType === "file") {
|
|
713
|
-
const picked = Array.from(e.target.files ?? []).map((f) => ({ name: f.name, size: f.size, type: f.type }));
|
|
714
|
-
if (field != null) onAction?.("input", { text: picked.map((f) => f.name).join(", "), field, files: picked });
|
|
715
|
-
return;
|
|
716
|
-
}
|
|
717
|
-
setValue(e.target.value);
|
|
718
|
-
}}
|
|
719
|
-
onKeyDown={(e) => {
|
|
720
|
-
// Backspace in an ALREADY-empty field steps back, so correcting a sequence never
|
|
721
|
-
// needs the mouse. A field with content just deletes it (the default).
|
|
722
|
-
if (advanceOnFill && e.key === "Backspace" && value === "") {
|
|
723
|
-
e.preventDefault();
|
|
724
|
-
stepFocus(-1);
|
|
725
|
-
return;
|
|
726
|
-
}
|
|
727
|
-
if (e.key === "Enter" && !e.shiftKey) {
|
|
728
|
-
e.preventDefault();
|
|
729
|
-
submit();
|
|
730
|
-
}
|
|
731
|
-
}}
|
|
732
|
-
/>
|
|
733
|
-
);
|
|
734
|
-
}
|
|
735
|
-
|
|
736
|
-
/**
|
|
737
|
-
* WHAT A CITATION'S OPEN BUTTON FIRES (docs/unoverse/data/objects/citations-in-answers.md): the
|
|
738
|
-
* action the Markdown node declares in `citeAction`, with the render data. Carried by context,
|
|
739
|
-
* not baked into the overrides, so the overrides stay built once per theme and a marker's open
|
|
740
|
-
* pop-up survives the answer streaming on around it.
|
|
741
|
-
*/
|
|
742
|
-
const CiteContext = createContext<{ action?: string; onAction?: ActionHandler; data: Record<string, unknown> }>({ data: {} });
|
|
743
|
-
|
|
744
|
-
/**
|
|
745
|
-
* A CITATION STILL ARRIVING is held back (citations-in-answers.md): while an answer streams, the
|
|
746
|
-
* tail from an open `[1](cite:` to its closing `)` would draw as a long line of raw link and then
|
|
747
|
-
* collapse into its marker, jolting the text once per citation. Only the tail is touched, and only
|
|
748
|
-
* a numbered label (a citation's), so finished text and web links draw exactly as written.
|
|
749
|
-
*/
|
|
750
|
-
const OPEN_CITATION = /\[\d{0,3}(?:\](?:\((?:c(?:i(?:t(?:e(?::[^)\n]*)?)?)?)?)?)?)?$/;
|
|
751
|
-
export function holdOpenCitation(text: string): string {
|
|
752
|
-
return text.replace(OPEN_CITATION, "");
|
|
753
|
-
}
|
|
754
|
-
|
|
755
|
-
/**
|
|
756
|
-
* A CITATION'S QUOTE STAYS ONE CELL (citations-in-answers.md, 2026-09-15): a quote of a table row
|
|
757
|
-
* carried its pipes ("Total | 10") into the link's title, and inside an answer's own table the
|
|
758
|
-
* pipe split the row and left the marker raw. Before the markdown is parsed, a pipe inside a
|
|
759
|
-
* `cite:` link's title becomes a broken bar and a line break a space; the words still read.
|
|
760
|
-
*/
|
|
761
|
-
const CITATION_TITLE = /(\]\(cite:[^)\s]*\s+")([^"]*)("\))/g;
|
|
762
|
-
export function keepCitationsWhole(text: string): string {
|
|
763
|
-
return text.replace(CITATION_TITLE, (_, open: string, quote: string, close: string) => `${open}${quote.replace(/\|/g, "\u00a6").replace(/\s*\n\s*/g, " ")}${close}`);
|
|
764
|
-
}
|
|
765
|
-
|
|
766
|
-
/**
|
|
767
|
-
* WHERE A POP-UP FITS (citations-in-answers.md): the horizontal shift that keeps a box of this
|
|
768
|
-
* width inside the window from an anchor at `left`, and whether it opens above the anchor
|
|
769
|
-
* because below there is no room and above there is. A margin keeps it off the very edge.
|
|
770
|
-
*/
|
|
771
|
-
export function fitPopover(anchor: { left: number; top: number; bottom: number }, box: { width: number; height: number }, view: { width: number; height: number }, margin = 8) {
|
|
772
|
-
const over = anchor.left + box.width - (view.width - margin);
|
|
773
|
-
const shift = Math.max(over > 0 ? -over : 0, margin - anchor.left);
|
|
774
|
-
const above = anchor.bottom + box.height + margin > view.height && anchor.top - box.height - margin >= 0;
|
|
775
|
-
return { shift, above };
|
|
776
|
-
}
|
|
777
|
-
|
|
778
|
-
/** A `cite:` link's parts: `cite:<document>?part=&page=&doc=`, its title the quoted words. */
|
|
779
|
-
function citationOf(href: string, title?: string) {
|
|
780
|
-
try {
|
|
781
|
-
const u = new URL(href);
|
|
782
|
-
return { document: decodeURIComponent(u.pathname), part: u.searchParams.get("part") ?? "", page: u.searchParams.get("page") ?? "", doc: u.searchParams.get("doc") ?? "", quote: title ?? "" };
|
|
783
|
-
} catch {
|
|
784
|
-
return null;
|
|
785
|
-
}
|
|
786
|
-
}
|
|
787
|
-
|
|
788
|
-
/**
|
|
789
|
-
* markdown-to-jsx overrides for the `Markdown` primitive.
|
|
790
|
-
*
|
|
791
|
-
* ⛔ AUTHORS ZERO STYLE VALUES. Every element style is read verbatim from the
|
|
792
|
-
* SERVED prose recipe (`theme.prose` ← design/styles/semantic/prose.json). The SDK
|
|
793
|
-
* only maps element → recipe key and supplies BEHAVIOUR (target=_blank, image
|
|
794
|
-
* error-fallback). No hex, no px/rem, no weights here — they live in design/.
|
|
795
|
-
*
|
|
796
|
-
* Built once per theme (stable component identities so streaming re-renders don't
|
|
797
|
-
* remount images and drop their broken-state).
|
|
798
|
-
*/
|
|
799
|
-
function buildMarkdownOverrides(theme: ResolvedTheme, inheritLinkColor = false) {
|
|
800
|
-
const p = theme.prose as Record<string, CSSProperties | Record<string, string> | string>;
|
|
801
|
-
const s = (k: string) => (p[k] as CSSProperties | undefined) ?? {};
|
|
802
|
-
// Heading element → served text-style NAME (the map lives in the recipe, not here).
|
|
803
|
-
const headings = (p.heading as Record<string, string>) ?? {};
|
|
804
|
-
const head = (tag: string) => (theme.text[headings[tag]] as CSSProperties | undefined) ?? {};
|
|
805
|
-
|
|
806
|
-
// Served external-link glyph (theme.icons.externalLink), styled by served prose.linkIcon.
|
|
807
|
-
// The icon inherits the link's colour via `currentColor`. The SDK authors no geometry.
|
|
808
|
-
const li = (theme.icons?.externalLink ?? {}) as { viewBox?: string; attrs?: Record<string, unknown>; children?: [string, Record<string, unknown>][] };
|
|
809
|
-
const linkArrow = li.children?.length ? (
|
|
810
|
-
<svg viewBox={li.viewBox ?? "0 0 24 24"} {...(li.attrs ?? {})} aria-hidden="true" style={s("linkIcon")}>
|
|
811
|
-
{li.children.map(([tag, attrs], i) => createElement(tag, { key: i, ...attrs }))}
|
|
812
|
-
</svg>
|
|
813
|
-
) : null;
|
|
814
|
-
// A definition that declares `color` on its Markdown node owns its anchors'
|
|
815
|
-
// color too (`inherit` — no value authored here); the served recipe stays the
|
|
816
|
-
// default for undeclared nodes (chat prose).
|
|
817
|
-
const ExternalLink = ({ children, ...a }: React.AnchorHTMLAttributes<HTMLAnchorElement>) => (
|
|
818
|
-
<a {...a} target="_blank" rel="noopener noreferrer" style={inheritLinkColor ? { ...s("link"), color: "inherit" } : s("link")}>
|
|
819
|
-
{children}
|
|
820
|
-
{linkArrow}
|
|
821
|
-
</a>
|
|
822
|
-
);
|
|
823
|
-
// A CITATION (citations-in-answers.md): a `cite:` link is a marker after the fact it supports.
|
|
824
|
-
// Tapped, a pop-up shows the quoted words, the document and the page, and an Open button that
|
|
825
|
-
// fires the node's `citeAction` with the citation. Behaviour only: every look is a served
|
|
826
|
-
// prose key (citeAnchor, citeMark, citePopover, citeQuote, citeSource, citeOpen).
|
|
827
|
-
const CiteMark = ({ href, title, children }: { href: string; title?: string; children?: ReactNode }) => {
|
|
828
|
-
const [open, setOpen] = useState(false);
|
|
829
|
-
const box = useRef<HTMLSpanElement>(null);
|
|
830
|
-
const pop = useRef<HTMLSpanElement>(null);
|
|
831
|
-
const [place, setPlace] = useState({ shift: 0, above: false });
|
|
832
|
-
// ALWAYS ON SCREEN: measured as it opens, and again when the window changes size.
|
|
833
|
-
useLayoutEffect(() => {
|
|
834
|
-
if (!open) return;
|
|
835
|
-
const fit = () => {
|
|
836
|
-
if (!box.current || !pop.current) return;
|
|
837
|
-
const a = box.current.getBoundingClientRect();
|
|
838
|
-
setPlace(fitPopover(a, { width: pop.current.offsetWidth, height: pop.current.offsetHeight }, { width: window.innerWidth, height: window.innerHeight }));
|
|
839
|
-
};
|
|
840
|
-
fit();
|
|
841
|
-
window.addEventListener("resize", fit);
|
|
842
|
-
return () => window.removeEventListener("resize", fit);
|
|
843
|
-
}, [open]);
|
|
844
|
-
const { action, onAction, data } = useContext(CiteContext);
|
|
845
|
-
const c = citationOf(href, title);
|
|
846
|
-
useEffect(() => {
|
|
847
|
-
if (!open) return;
|
|
848
|
-
const away = (e: Event) => { if (box.current && !e.composedPath().includes(box.current)) setOpen(false); };
|
|
849
|
-
const esc = (e: KeyboardEvent) => { if (e.key === "Escape") setOpen(false); };
|
|
850
|
-
document.addEventListener("pointerdown", away);
|
|
851
|
-
document.addEventListener("keydown", esc);
|
|
852
|
-
return () => { document.removeEventListener("pointerdown", away); document.removeEventListener("keydown", esc); };
|
|
853
|
-
}, [open]);
|
|
854
|
-
if (!c) return <>{children}</>;
|
|
855
|
-
const source = [c.doc, c.page ? `p. ${c.page}` : ""].filter(Boolean).join(" · ");
|
|
856
|
-
return (
|
|
857
|
-
<span ref={box} style={s("citeAnchor")}>
|
|
858
|
-
<button type="button" aria-expanded={open} aria-label={source ? `Source: ${source}` : "Source"} style={s("citeMark")} onClick={() => setOpen((o) => !o)}>
|
|
859
|
-
{children}
|
|
860
|
-
</button>
|
|
861
|
-
{open && (
|
|
862
|
-
<span ref={pop} role="dialog" style={{ ...s("citePopover"), transform: `translateX(${place.shift}px)`, ...(place.above ? { top: "auto", bottom: "100%" } : {}) }}>
|
|
863
|
-
{c.quote && <span style={{ display: "block", ...s("citeQuote") }}>“{c.quote}”</span>}
|
|
864
|
-
{source && <span style={{ display: "block", ...s("citeSource") }}>{source}</span>}
|
|
865
|
-
{action && onAction && (
|
|
866
|
-
<button type="button" style={s("citeOpen")} onClick={() => { setOpen(false); onAction(action, { ...data, citation: c }); }}>
|
|
867
|
-
Open
|
|
868
|
-
</button>
|
|
869
|
-
)}
|
|
870
|
-
</span>
|
|
871
|
-
)}
|
|
872
|
-
</span>
|
|
873
|
-
);
|
|
874
|
-
};
|
|
875
|
-
const Link = (a: React.AnchorHTMLAttributes<HTMLAnchorElement>) =>
|
|
876
|
-
typeof a.href === "string" && a.href.startsWith("cite:") ? <CiteMark href={a.href} title={a.title}>{a.children}</CiteMark> : <ExternalLink {...a} />;
|
|
877
|
-
// Wide tables scroll instead of crushing columns (legacy ScrollableTable).
|
|
878
|
-
const ScrollableTable = (a: React.TableHTMLAttributes<HTMLTableElement>) => (
|
|
879
|
-
<div style={s("tableWrap")}>
|
|
880
|
-
<table {...a} style={s("table")} />
|
|
881
|
-
</div>
|
|
882
|
-
);
|
|
883
|
-
// Broken external image URLs degrade to alt text, not a broken-image icon.
|
|
884
|
-
const MarkdownImage = ({ src, alt, title }: React.ImgHTMLAttributes<HTMLImageElement>) => {
|
|
885
|
-
const [broken, setBroken] = useState(false);
|
|
886
|
-
if (!src || broken) return <span style={s("imageFallback")}>{alt || "Image unavailable"}</span>;
|
|
887
|
-
return (
|
|
888
|
-
<img src={src as string} alt={alt || ""} title={title} loading="lazy" decoding="async" onError={() => setBroken(true)} style={s("image")} />
|
|
889
|
-
);
|
|
890
|
-
};
|
|
891
|
-
|
|
892
|
-
return {
|
|
893
|
-
p: { props: { style: s("paragraph") } },
|
|
894
|
-
a: { component: Link },
|
|
895
|
-
table: { component: ScrollableTable },
|
|
896
|
-
img: { component: MarkdownImage },
|
|
897
|
-
// INLINE MARKS and BLOCKS. Authored copy uses all of these (a bolded access code, a
|
|
898
|
-
// rule between sections, a quoted term), and an element absent from this map renders
|
|
899
|
-
// at the browser's default — the one place a definition's look escapes the token
|
|
900
|
-
// layer. Each maps to a served recipe key; the VALUES live in design/styles/semantic/prose.
|
|
901
|
-
strong: { props: { style: s("strong") } },
|
|
902
|
-
em: { props: { style: s("emphasis") } },
|
|
903
|
-
del: { props: { style: s("strikethrough") } },
|
|
904
|
-
code: { props: { style: s("code") } },
|
|
905
|
-
pre: { props: { style: s("codeBlock") } },
|
|
906
|
-
blockquote: { props: { style: s("blockquote") } },
|
|
907
|
-
hr: { props: { style: s("rule") } },
|
|
908
|
-
th: { props: { style: { ...s("cell"), ...s("headerCell") } } },
|
|
909
|
-
td: { props: { style: s("cell") } },
|
|
910
|
-
ul: { props: { style: { ...s("list"), listStyleType: p.bulletStyle as string } } },
|
|
911
|
-
ol: { props: { style: { ...s("list"), listStyleType: p.orderedStyle as string } } },
|
|
912
|
-
li: { props: { style: s("listItem") } },
|
|
913
|
-
h1: { props: { style: head("h1") } },
|
|
914
|
-
h2: { props: { style: head("h2") } },
|
|
915
|
-
h3: { props: { style: head("h3") } },
|
|
916
|
-
h4: { props: { style: head("h4") } },
|
|
917
|
-
h5: { props: { style: head("h5") } },
|
|
918
|
-
h6: { props: { style: head("h6") } },
|
|
919
|
-
};
|
|
920
|
-
}
|
|
921
|
-
|
|
922
|
-
/**
|
|
923
|
-
* `Markdown` leaf primitive (SPEC §2d:197). Renders a bound markdown string; the
|
|
924
|
-
* web impl is markdown-to-jsx (a per-platform primitive impl, like Text→<span>).
|
|
925
|
-
* Container takes node.style (tokens); elements map to served theme tokens via
|
|
926
|
-
* buildMarkdownOverrides. The SDK authors ZERO style values.
|
|
927
|
-
*/
|
|
928
|
-
export function MarkdownView({ node, data, theme, onAction }: { node: UnoverseNode; data: Record<string, unknown>; theme: ResolvedTheme; onAction?: ActionHandler }): ReactNode {
|
|
929
|
-
const value = (node.bind?.value ? (data[node.bind.value] as string) : "") ?? "";
|
|
930
|
-
// Explicit opt-in ONLY (a CTA pill whose label must match its declared color):
|
|
931
|
-
// `inheritLinkColor: true` on the node. Declaring a body color alone never
|
|
932
|
-
// hijacks anchors — chat prose keeps the served link recipe.
|
|
933
|
-
const inheritLink = (node as { inheritLinkColor?: boolean }).inheritLinkColor === true;
|
|
934
|
-
const overrides = useMemo(() => buildMarkdownOverrides(theme, inheritLink), [theme, inheritLink]);
|
|
935
|
-
const cite = (node as { citeAction?: string }).citeAction;
|
|
936
|
-
return (
|
|
937
|
-
<div style={styleToCss(node.style, theme, data)}>
|
|
938
|
-
<CiteContext.Provider value={{ action: cite, onAction, data }}>
|
|
939
|
-
<Markdown options={{ overrides, forceBlock: true }}>{holdOpenCitation(keepCitationsWhole(value))}</Markdown>
|
|
940
|
-
</CiteContext.Provider>
|
|
941
|
-
</div>
|
|
942
|
-
);
|
|
943
|
-
}
|