@unoverse-platform/studio 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.
Files changed (57) hide show
  1. package/bin/studio.mjs +124 -0
  2. package/index.html +12 -0
  3. package/local/catalog.ts +105 -0
  4. package/local/keys.ts +118 -0
  5. package/local/nodes.ts +77 -0
  6. package/local/plugin.ts +401 -0
  7. package/local/scaffold.d.mts +13 -0
  8. package/local/scaffold.mjs +85 -0
  9. package/package.json +43 -0
  10. package/public/logo.png +0 -0
  11. package/screens/AtomsView.tsx +206 -0
  12. package/screens/ComponentsView.tsx +551 -0
  13. package/screens/ConfigForm.tsx +217 -0
  14. package/screens/DesignSystemView.tsx +324 -0
  15. package/screens/IdentityHeader.tsx +88 -0
  16. package/screens/PromptBlocksView.tsx +137 -0
  17. package/screens/SkillsView.tsx +167 -0
  18. package/screens/TemplatesView.tsx +1005 -0
  19. package/screens/index.ts +26 -0
  20. package/screens/states.ts +44 -0
  21. package/src/App.tsx +252 -0
  22. package/src/ConnectUniverse.tsx +232 -0
  23. package/src/NewProject.tsx +89 -0
  24. package/src/NodeKeys.tsx +98 -0
  25. package/src/NodesView.tsx +443 -0
  26. package/src/PublishDialog.tsx +309 -0
  27. package/src/UniverseSession.tsx +121 -0
  28. package/src/host.ts +165 -0
  29. package/src/index.css +7 -0
  30. package/src/main.tsx +11 -0
  31. package/src/universes.ts +210 -0
  32. package/vendor/sdk/appEngine.tsx +219 -0
  33. package/vendor/sdk/lib/UnoverseComponent.tsx +120 -0
  34. package/vendor/sdk/lib/connection.tsx +302 -0
  35. package/vendor/sdk/lib/core/actions.ts +84 -0
  36. package/vendor/sdk/lib/core/client.ts +134 -0
  37. package/vendor/sdk/lib/core/conditions.ts +43 -0
  38. package/vendor/sdk/lib/core/connection.ts +142 -0
  39. package/vendor/sdk/lib/core/index.ts +32 -0
  40. package/vendor/sdk/lib/core/store.ts +455 -0
  41. package/vendor/sdk/lib/core/types.ts +194 -0
  42. package/vendor/sdk/lib/index.ts +59 -0
  43. package/vendor/sdk/lib/isolate.tsx +70 -0
  44. package/vendor/sdk/lib/primitives.tsx +453 -0
  45. package/vendor/sdk/lib/realtime/audioUtils.ts +40 -0
  46. package/vendor/sdk/lib/realtime/types.ts +45 -0
  47. package/vendor/sdk/lib/realtime/useAudioCapture.ts +237 -0
  48. package/vendor/sdk/lib/realtime/useAudioPlayback.ts +145 -0
  49. package/vendor/sdk/lib/realtime/useRealtimeWebSocket.ts +164 -0
  50. package/vendor/sdk/lib/render.tsx +178 -0
  51. package/vendor/sdk/lib/streamed.tsx +65 -0
  52. package/vendor/sdk/lib/style.ts +227 -0
  53. package/vendor/sdk/lib/template.tsx +521 -0
  54. package/vendor/sdk/lib/theme.ts +55 -0
  55. package/vendor/sdk/lib/voice.tsx +311 -0
  56. package/vendor/sdk/main.tsx +96 -0
  57. package/vite.config.ts +70 -0
@@ -0,0 +1,178 @@
1
+ /**
2
+ * ════════════════════════════════════════════════════════════════════════════
3
+ * THE DISPATCHER: UnoverseNode → React. ⛔ READ BEFORE EDITING ⛔
4
+ * ════════════════════════════════════════════════════════════════════════════
5
+ *
6
+ * THIS FILE RENDERS NOTHING. It only WALKS the tree and DISPATCHES `node.type`
7
+ * to a primitive component in ./primitives. There is NOT A SINGLE raw element
8
+ * here — no `<div>`, `<span>`, `<button>`, `<img>`, `<input>`, `<svg>`. If you
9
+ * are about to write one, STOP: it belongs in ./primitives. This is enforced by
10
+ * test/dispatcher-only.test.mjs (the build fails if a raw element appears here),
11
+ * so the engine/primitive split can't silently rot — which is exactly how
12
+ * `Button` (and its styling logic) kept leaking back into the dispatcher.
13
+ *
14
+ * THE FRAMEWORK IN ONE SENTENCE: this SDK is a FIXED, GENERIC interpreter. ALL
15
+ * UX is built in DATA (rx/ definitions + the served theme) — an author creates
16
+ * ANY interface WITHOUT editing this SDK. (See FRAMEWORK.md.)
17
+ *
18
+ * WHERE THINGS LIVE:
19
+ * • ./render — this dispatcher (control flow: visibleWhen, Each, slots) ONLY.
20
+ * • ./primitives — EVERY primitive (Box/Text/Image/Button/Icon/Skeleton/Input/
21
+ * Markdown/Unknown) + the shared per-element chrome.
22
+ * • ./style — the style + animation interpreter (styleToCss/keyframesCss).
23
+ *
24
+ * Two laws (govern all three files + the whole package):
25
+ * LAW 1 — OWN ZERO STYLE VALUES. No hex/px/rem/em/recipes; resolve token NAMES
26
+ * against the SERVED theme. Enforced by test/golden-rule.test.mjs.
27
+ * LAW 2 — OWN ZERO UX SHAPE. A primitive renders ONE generic element, nothing
28
+ * about a specific UX. A composer/card/loader is a LAYOUT — compose it
29
+ * in a DEFINITION. Never a per-UX primitive or per-UX config.
30
+ *
31
+ * Need something the vocab can't express? Prefer extending the generic interpreter
32
+ * ONCE (a new style-vocab key in ./style, a new model projection) over a primitive.
33
+ * You almost never need to touch this file — build it in rx/ first.
34
+ * ════════════════════════════════════════════════════════════════════════════
35
+ */
36
+ import type { ReactNode } from "react";
37
+ import type { ActionSpec, ResolvedTheme, UnoverseNode } from "./core";
38
+ import { visible, selectCase } from "./core";
39
+ import { BoxView, TextView, ImageView, ButtonView, UnknownView, SkeletonView, InputView, MarkdownView, IconView } from "./primitives";
40
+
41
+ // The style + animation interpreter lives in ./style; re-exported so consumers keep
42
+ // importing it from "./render".
43
+ export { styleToCss, keyframesCss } from "./style";
44
+
45
+ // An action is a bare string (→ workflow) or a declarative envelope (§2e-3). The
46
+ // binding layer (streamed.tsx) interprets it; primitives just forward node.action.
47
+ /** Instance context riding with a bubbled server action — WHICH component fired and its
48
+ * current slice, so the channel can forward the submitted state (e.g. a wizard's answers)
49
+ * without reaching into the store. Pure transport metadata; the SDK interprets none of it. */
50
+ export interface ActionMeta {
51
+ chatId?: string;
52
+ nodeId?: string;
53
+ state?: Record<string, unknown>;
54
+ }
55
+
56
+ export type ActionHandler = (action: string | ActionSpec, data: Record<string, unknown>, meta?: ActionMeta) => void;
57
+
58
+ /**
59
+ * Resolves a `ComponentSlot` node against the store (provided by the template
60
+ * renderer). Returns the rendered leaves (or the slot's fallback). Components
61
+ * don't use slots, so this is undefined on the component render path.
62
+ */
63
+ export type SlotResolver = (node: UnoverseNode, key?: React.Key) => ReactNode;
64
+
65
+ /** A usable appWidth is CSS ("360px", "min(50vw, 760px)") — an UNRESOLVED size name
66
+ * has no digits; treat it as absent rather than emitting invalid CSS. */
67
+ export const cssWidth = (w: unknown): string | undefined => (typeof w === "string" && /\d/.test(w) ? w : undefined);
68
+
69
+ export function renderNode(
70
+ node: UnoverseNode,
71
+ data: Record<string, unknown>,
72
+ onAction: ActionHandler | undefined,
73
+ theme: ResolvedTheme,
74
+ key?: React.Key,
75
+ slot?: SlotResolver,
76
+ ): ReactNode {
77
+ // visibleWhen — conditional visibility (§2e-3). A bare field name is a truthiness test
78
+ // (an empty array counts as empty); an object is a `Condition` (eq/ne/in, or truthy) so
79
+ // one field can drive tabs/wizard steps without a boolean per state. The predicate is
80
+ // shared with `Switch` and `style.when` (core/conditions.ts) — one state semantics.
81
+ if (node.visibleWhen != null && !visible(node.visibleWhen, data)) return null;
82
+
83
+ // `skeleton: true` — the protocol's LOADING semantic: while the node's bound field has
84
+ // no data yet, render its Skeleton ghost IN PLACE (inside the node's own styled box, so
85
+ // the ghost occupies exactly the space the content will). Variant inferred from the
86
+ // node type; the ghost's entire look is theme data (LAW 1). This is a generic BEHAVIOR
87
+ // (no data yet → ghost), not a style or a UX shape — the one judgment only the
88
+ // renderer can make, since only it watches binds resolve over time.
89
+ if (node.skeleton) {
90
+ const field = node.bind?.value ?? node.bind?.src ?? node.bind?.items;
91
+ if (field && !visible(field, data)) {
92
+ const variant = node.type === "Image" ? "image" : node.type === "Each" ? "card" : "text";
93
+ return (
94
+ <BoxView key={key} node={{ type: "Box", style: node.style }} data={data} theme={theme}>
95
+ <SkeletonView variant={variant} theme={theme} />
96
+ </BoxView>
97
+ );
98
+ }
99
+ }
100
+
101
+ // STATE-OWNED SIZING (docs/design/05): a plain node's `appWidth` = an ALWAYS-OPEN
102
+ // panel (e.g. the core chat column). The ONE declared value both grows the app
103
+ // (computeAppWidth) and fixes the panel's box here — they can never disagree, and
104
+ // nothing elastic remains for a resize to land on. Slot panels are injected at their
105
+ // frame (template.tsx); a layout root never carries appWidth (guard-enforced).
106
+ const panelW = (node as { appWidth?: string }).appWidth;
107
+ if (panelW && node.type !== "ComponentSlot") {
108
+ const w = cssWidth(theme.appSize?.[panelW] ?? panelW);
109
+ if (w) node = { ...node, appWidth: undefined, style: { ...(node.style ?? {}), width: w, flex: "0 0 auto" } } as UnoverseNode;
110
+ }
111
+
112
+ // Store-backed / leaf primitives → dispatch to ./primitives.
113
+ if (node.type === "Skeleton") return <SkeletonView key={key} variant={node.variant} theme={theme} />;
114
+ if (node.type === "Icon") return <IconView key={key} name={node.bind?.name ? (data[node.bind.name] as string) : node.icon} style={node.style} theme={theme} />;
115
+ if (node.type === "Input") return <InputView key={key} node={node} data={data} theme={theme} onAction={onAction} />;
116
+ if (node.type === "Markdown") return <MarkdownView key={key} node={node} data={data} theme={theme} />;
117
+ if (node.type === "ComponentSlot" || node.type === "Timeline") return slot ? slot(node, key) : null;
118
+
119
+ // `Switch` — control flow: render EXACTLY ONE branch, the case matching `data[on]`
120
+ // (else `cases.default`, else nothing). The declarative whole-view swap (wizard step,
121
+ // inline↔focused) — mutually exclusive in one place, vs. a `visibleWhen` per branch. No
122
+ // wrapper element: it is transparent, like an `if`, delegating straight to the branch.
123
+ if (node.type === "Switch") {
124
+ const branch = selectCase(node, data);
125
+ return branch ? renderNode(branch, data, onAction, theme, key, slot) : null;
126
+ }
127
+
128
+ // `Each` — control flow: map a bound array (bind.items) → the `template` subtree once
129
+ // per item (that item as the data scope), inside the node's own container (a Box).
130
+ // A PRIMITIVE item (string/number — e.g. a `string[]` of follow-up questions) is exposed
131
+ // as `{ value: item }`, so the template binds `value` like any field; object items pass
132
+ // through unchanged. This is what lets an `Each` iterate a plain list of strings.
133
+ if (node.type === "Each") {
134
+ // Literal `items` fallback: a microapp component hardcodes its static option lists
135
+ // in the def (props exist only for state — UNOVERSE_AUTHORING.md). bind wins.
136
+ const src = node.bind?.items ? data[node.bind.items] : node.items;
137
+ const items = Array.isArray(src) ? src : [];
138
+ const tpl = node.template;
139
+ const scopeOf = (item: unknown): Record<string, unknown> =>
140
+ item != null && typeof item === "object" ? (item as Record<string, unknown>) : { value: item };
141
+ const rendered = tpl ? items.map((item, i) => renderNode(tpl, scopeOf(item), onAction, theme, i, slot)) : null;
142
+ return (
143
+ <BoxView key={key} node={node} data={data} theme={theme}>
144
+ {rendered}
145
+ </BoxView>
146
+ );
147
+ }
148
+
149
+ // Element primitives → dispatch. Children are rendered here (control flow) and handed down.
150
+ const kids = (node.children ?? []).map((c, i) => renderNode(c, data, onAction, theme, i, slot));
151
+ switch (node.type) {
152
+ case "Box":
153
+ case "Stack":
154
+ case "Row":
155
+ case "Column":
156
+ return (
157
+ <BoxView key={key} node={node} data={data} theme={theme}>
158
+ {kids}
159
+ </BoxView>
160
+ );
161
+ case "Text":
162
+ return <TextView key={key} node={node} data={data} theme={theme} />;
163
+ case "Image":
164
+ return <ImageView key={key} node={node} data={data} theme={theme} />;
165
+ case "Button":
166
+ return (
167
+ <ButtonView key={key} node={node} data={data} theme={theme} onAction={onAction}>
168
+ {kids}
169
+ </ButtonView>
170
+ );
171
+ default:
172
+ return (
173
+ <UnknownView key={key} node={node} theme={theme}>
174
+ {kids}
175
+ </UnknownView>
176
+ );
177
+ }
178
+ }
@@ -0,0 +1,65 @@
1
+ /**
2
+ * Streaming binding: subscribe to the merge-state store and render the
3
+ * component instance, re-rendering when COMPONENT_INIT/COMPONENT_DATA merge in.
4
+ */
5
+ import { useCallback, useMemo, useSyncExternalStore } from "react";
6
+ import { dispatchAction, type ComponentStore, type ResolvedTheme, type UnoverseClient } from "./core";
7
+ import { UnoverseComponent } from "./UnoverseComponent";
8
+ import type { ActionHandler } from "./render";
9
+
10
+ /** Subscribe to a component instance in the store (re-renders on merge).
11
+ * Snapshots THIS instance's slice (merge() gives it a fresh identity per merge),
12
+ * so chunks for other components bail out in React instead of re-rendering everyone. */
13
+ export function useUnoverseInstance(store: ComponentStore, chatId: string, nodeId: string) {
14
+ const subscribe = useCallback((cb: () => void) => store.subscribe(cb), [store]);
15
+ const data = useSyncExternalStore(
16
+ subscribe,
17
+ useCallback(() => store.get(chatId, nodeId), [store, chatId, nodeId]),
18
+ );
19
+ const type = useSyncExternalStore(
20
+ subscribe,
21
+ useCallback(() => store.getType(chatId, nodeId), [store, chatId, nodeId]),
22
+ );
23
+ return { type, data };
24
+ }
25
+
26
+ export interface StreamedUnoverseComponentProps {
27
+ client: UnoverseClient;
28
+ store: ComponentStore;
29
+ chatId: string;
30
+ nodeId: string;
31
+ onAction?: ActionHandler;
32
+ theme?: ResolvedTheme;
33
+ /** Neutral turn state merged into the component's data scope (e.g. `streaming`), so a
34
+ * component can reflect it — like legacy passing `streamingState` to StreamingText. */
35
+ extraData?: Record<string, unknown>;
36
+ /** A surface's single occupant fills the surface — see UnoverseComponent.fill. */
37
+ fill?: boolean;
38
+ }
39
+
40
+ /** Resolves `type` from the store → its definition → renders with merged data. */
41
+ export function StreamedUnoverseComponent({ client, store, chatId, nodeId, onAction, theme, extraData, fill }: StreamedUnoverseComponentProps) {
42
+ const { type, data } = useUnoverseInstance(store, chatId, nodeId);
43
+ // Interpret the action envelope (§2e-3): `setValue` writes THIS instance's slice
44
+ // locally (the ported `updateData`); any other type routes to the channel handler
45
+ // (the workflow). `then` chains. The component never sees the store directly.
46
+ const handleAction = useMemo<ActionHandler>(
47
+ () => (action, scope) =>
48
+ dispatchAction(action, scope, {
49
+ store,
50
+ chatId,
51
+ nodeId,
52
+ // Server actions carry the INSTANCE context (which component + its current slice)
53
+ // so the channel can forward the submitted state — e.g. a wizard's collected
54
+ // answers — with the action. Transport metadata only; nothing is interpreted here.
55
+ sendToServer: onAction ? (t, payload) => onAction(t, payload, { chatId, nodeId, state: store.get(chatId, nodeId) }) : undefined,
56
+ }),
57
+ [store, chatId, nodeId, onAction],
58
+ );
59
+ if (!type) return <div>waiting for COMPONENT_INIT…</div>;
60
+ // No focus/displayState injection: a component's inline/focused look is its OWN state,
61
+ // written via the `setValue` action into this slice and read via `visibleWhen` — the SDK
62
+ // hardcodes no focus concept (UNOVERSE_STATE_MODEL §2). It's just another data field.
63
+ const merged = extraData ? { ...data, ...extraData } : data;
64
+ return <UnoverseComponent client={client} uri={`unoverse://components/${type}`} data={merged} onAction={handleAction} theme={theme} fill={fill} />;
65
+ }
@@ -0,0 +1,227 @@
1
+ /**
2
+ * The style + animation INTERPRETER — neutral vocab → CSS, resolving token NAMES
3
+ * against a theme FETCHED from the server. ⛔ OWNS ZERO STYLE VALUES (no hex, no
4
+ * px/rem/em, no recipes); every value lives in rx/styles and is served. See
5
+ * FRAMEWORK.md and render.tsx's header. Enforced by test/golden-rule.test.mjs.
6
+ */
7
+ import type { CSSProperties } from "react";
8
+ import { resolveValue, matches, type ResolvedTheme, type StyleVariant } from "./core";
9
+
10
+ const WEIGHTS: Record<string, number> = { normal: 400, medium: 500, semibold: 600, bold: 700 };
11
+
12
+ // Resolution cache: style objects come from CACHED definitions, so they are
13
+ // referentially stable across renders — resolving the same (style, theme) pair per
14
+ // element per render (the interpreter's hot inner loop) is pure waste. Data-BOUND
15
+ // styles ({{field}} bindings or `when` variants) resolve against the live data scope
16
+ // and bypass the cache; the one-time dependency check is itself memoized per object.
17
+ // WeakMaps throughout — nothing here pins a def or theme in memory.
18
+ const styleCache = new WeakMap<ResolvedTheme, WeakMap<Record<string, unknown>, CSSProperties>>();
19
+ const dataDependent = new WeakMap<Record<string, unknown>, boolean>();
20
+ // Stable default so styleless nodes hit the cache instead of keying a fresh {} per call.
21
+ const EMPTY_STYLE: Record<string, unknown> = Object.freeze({});
22
+
23
+ function isDataDependent(s: Record<string, unknown>): boolean {
24
+ let dep = dataDependent.get(s);
25
+ if (dep === undefined) {
26
+ dep = Array.isArray(s.when) || JSON.stringify(s).includes("{{");
27
+ dataDependent.set(s, dep);
28
+ }
29
+ return dep;
30
+ }
31
+
32
+ /**
33
+ * Map the neutral style vocab → CSS, resolving token names against the theme. Unknown
34
+ * values pass through. When `data` is given, a style value that is a `{{field}}` binding
35
+ * is first resolved from the data scope (e.g. a bar's `height: "{{pct}}"` ← data.pct =
36
+ * "72%") — the data-driven twin of token resolution. This is what lets bar/progress
37
+ * charts be DATA (Box + Each), not a primitive: the producer supplies the proportion,
38
+ * the SDK authors nothing. (Same `{{...}}` resolver the action vocab uses.)
39
+ */
40
+ export function styleToCss(s: Record<string, unknown> = EMPTY_STYLE, theme: ResolvedTheme, data?: Record<string, unknown>): CSSProperties {
41
+ if (!data || !isDataDependent(s)) {
42
+ let byStyle = styleCache.get(theme);
43
+ if (!byStyle) {
44
+ byStyle = new WeakMap();
45
+ styleCache.set(theme, byStyle);
46
+ }
47
+ const hit = byStyle.get(s);
48
+ if (hit) return hit;
49
+ const css = computeCss(s, theme, undefined);
50
+ byStyle.set(s, css);
51
+ return css;
52
+ }
53
+ return computeCss(s, theme, data);
54
+ }
55
+
56
+ function computeCss(s: Record<string, unknown>, theme: ResolvedTheme, data?: Record<string, unknown>): CSSProperties {
57
+ if (data) {
58
+ const resolved: Record<string, unknown> = {};
59
+ for (const [k, v] of Object.entries(s)) resolved[k] = typeof v === "string" && v.includes("{{") ? resolveValue(v, data) : v;
60
+ s = resolved;
61
+ // `when` — data-driven style variants (the generalization of `style.disabled`). Each
62
+ // entry whose `Condition` holds against the data scope merges its `apply` OVER the
63
+ // base (later wins), so one element restyles by state instead of being cloned under
64
+ // opposite `visibleWhen`s. Same `matches()` predicate as visibleWhen/Switch. Folded
65
+ // here (render-time, like the {{}} pass); interaction states stay injected (chrome).
66
+ if (Array.isArray(s.when)) {
67
+ let merged: Record<string, unknown> = { ...s };
68
+ delete merged.when;
69
+ for (const variant of s.when as StyleVariant[]) {
70
+ if (variant && matches(variant, data)) merged = { ...merged, ...(variant.apply ?? {}) };
71
+ }
72
+ s = merged;
73
+ }
74
+ }
75
+ const css: CSSProperties = {};
76
+ const space = (v: string) => theme.space[v] ?? v;
77
+ // Resolve a dimension: a `space.*` scale step (e.g. "8" → 2rem) → its value; `full` → 100%;
78
+ // else pass through. ONE scale for spacing AND element sizes (Tailwind-style w-5/p-5).
79
+ const dim = (v: unknown) => theme.space[v as string] ?? (v === "full" ? "100%" : (v as CSSProperties["width"]));
80
+
81
+ if (s.width != null) css.width = dim(s.width);
82
+ if (s.height != null) css.height = dim(s.height);
83
+ if (s.maxWidth != null) css.maxWidth = dim(s.maxWidth) as CSSProperties["maxWidth"];
84
+ if (s.minWidth != null) css.minWidth = dim(s.minWidth) as CSSProperties["minWidth"];
85
+ if (s.minHeight != null) css.minHeight = dim(s.minHeight) as CSSProperties["minHeight"];
86
+ if (s.maxHeight != null) css.maxHeight = dim(s.maxHeight) as CSSProperties["maxHeight"];
87
+ if (s.flex != null) css.flex = s.flex as CSSProperties["flex"];
88
+ if (s.position) css.position = s.position as CSSProperties["position"];
89
+ if (s.inset != null) css.inset = s.inset as CSSProperties["inset"];
90
+ // Edge offsets (with `position`) — generic positioning, e.g. a bottom-anchored overlay
91
+ // panel that floats above without pushing siblings. Resolve like any dimension (`full`→100%).
92
+ if (s.top != null) css.top = dim(s.top) as CSSProperties["top"];
93
+ if (s.right != null) css.right = dim(s.right) as CSSProperties["right"];
94
+ if (s.bottom != null) css.bottom = dim(s.bottom) as CSSProperties["bottom"];
95
+ if (s.left != null) css.left = dim(s.left) as CSSProperties["left"];
96
+ if (s.zIndex != null) css.zIndex = s.zIndex as CSSProperties["zIndex"];
97
+ if (s.direction) {
98
+ css.display = "flex";
99
+ css.flexDirection = s.direction as CSSProperties["flexDirection"];
100
+ }
101
+ // `wrap` — flex-wrap, so a row of content-width items (e.g. FAQ chips, tag pills) sits
102
+ // side-by-side when there's room and wraps to the next line when there isn't. Generic
103
+ // layout vocab (any wrapping flex container); the value ("wrap"/"nowrap") is served/literal.
104
+ if (s.wrap) css.flexWrap = s.wrap as CSSProperties["flexWrap"];
105
+ if (s.gap) css.gap = space(s.gap as string);
106
+ if (s.padding) css.padding = Array.isArray(s.padding) ? (s.padding as string[]).map(space).join(" ") : space(s.padding as string);
107
+ if (s.margin) css.margin = Array.isArray(s.margin) ? (s.margin as string[]).map(space).join(" ") : space(s.margin as string);
108
+ if (s.display) css.display = s.display as CSSProperties["display"];
109
+ // `container` — establish a container-query context (e.g. "inline-size") so descendants
110
+ // can respond to THIS element's width via `hideBelow` (component-relative responsiveness,
111
+ // correct for SDUI: the component reacts to its slot, not the page viewport). Value served.
112
+ if (s.container) (css as Record<string, unknown>).containerType = s.container;
113
+ // `columns` → an equal-width CSS grid (generic layout; the welcome card grid uses it).
114
+ if (s.columns) {
115
+ css.display = "grid";
116
+ css.gridTemplateColumns = `repeat(${s.columns}, minmax(0, 1fr))`;
117
+ // Pack rows at the top. Without this, a columns-grid inside a flex:1 panel
118
+ // inherits CSS's default align-content:stretch and distributes the panel's
119
+ // leftover height BETWEEN rows — the phantom vertical gap between card rows.
120
+ css.alignContent = "start";
121
+ }
122
+ if (s.cursor) css.cursor = s.cursor as CSSProperties["cursor"];
123
+ if (s.align) css.alignItems = s.align === "start" ? "flex-start" : (s.align as CSSProperties["alignItems"]);
124
+ if (s.justify) css.justifyContent = s.justify as CSSProperties["justifyContent"];
125
+ // Explicit text alignment (inherited by children) — needed because a Button's
126
+ // user-agent stylesheet centers text, which flex `align` can't undo on wrapped lines.
127
+ if (s.textAlign) css.textAlign = (s.textAlign === "start" ? "left" : s.textAlign) as CSSProperties["textAlign"];
128
+ // Generic transform passthrough (values are DATA, e.g. a hover lift) — the SDK maps, never authors.
129
+ if (s.transform) css.transform = s.transform as CSSProperties["transform"];
130
+ if (s.background) css.background = theme.color[s.background as string] ?? (s.background as string);
131
+ // `radial` — a neutral dial/gauge fill: a `fill` token sweeps from 0 to `at` (a value
132
+ // or {{field}} binding, e.g. "72%"), the rest is the `track` token. The WEB realization
133
+ // is a conic-gradient; a native channel maps the same neutral intent to an arc. Authors
134
+ // ZERO values — both colours are served tokens, the stop is data. Enables radial gauges
135
+ // as DATA (a circular Box), not a primitive.
136
+ if (s.radial) {
137
+ const r = s.radial as { fill?: string; track?: string; at?: unknown };
138
+ const col = (t?: string) => (t ? (theme.color[t] ?? t) : "transparent");
139
+ const at = r.at != null ? String(resolveValue(r.at, data ?? {})) : "0%";
140
+ css.background = `conic-gradient(${col(r.fill)} ${at}, ${col(r.track)} 0)`;
141
+ }
142
+ // A border value → `<width> solid <color>`. `"subtle"` = thin subtle; an optional
143
+ // leading width token gives a thicker rule, e.g. `"thick action.primary"`.
144
+ const borderVal = (raw: string) => {
145
+ const parts = raw.split(" ");
146
+ const [wTok, cTok] = parts.length > 1 ? parts : ["thin", parts[0]];
147
+ const width = theme.borderWidth[wTok] ?? wTok;
148
+ const color = theme.color[`border.${cTok}`] ?? theme.color[cTok] ?? cTok;
149
+ return `${width} solid ${color}`;
150
+ };
151
+ if (s.border === "none") css.border = "none";
152
+ else if (s.border) css.border = borderVal(s.border as string);
153
+ // Per-side borders (a header rule, a hover edge-accent) — no fake divider Box needed.
154
+ if (s.borderTop) css.borderTop = borderVal(s.borderTop as string);
155
+ if (s.borderRight) css.borderRight = borderVal(s.borderRight as string);
156
+ if (s.borderBottom) css.borderBottom = borderVal(s.borderBottom as string);
157
+ if (s.borderLeft) css.borderLeft = borderVal(s.borderLeft as string);
158
+ // `outline` — pass-through (e.g. "none" to drop the input focus ring inside a pill).
159
+ if (s.outline != null) css.outline = s.outline as CSSProperties["outline"];
160
+ if (s.shadow) css.boxShadow = theme.shadow[s.shadow as string] ?? (s.shadow as string);
161
+ const rad = (v: string) => theme.radius[v] ?? v;
162
+ if (s.radius) css.borderRadius = rad(s.radius as string);
163
+ // Per-corner radius (a chat bubble squares the corner toward its sender).
164
+ if (s.radiusTopLeft) css.borderTopLeftRadius = rad(s.radiusTopLeft as string);
165
+ if (s.radiusTopRight) css.borderTopRightRadius = rad(s.radiusTopRight as string);
166
+ if (s.radiusBottomLeft) css.borderBottomLeftRadius = rad(s.radiusBottomLeft as string);
167
+ if (s.radiusBottomRight) css.borderBottomRightRadius = rad(s.radiusBottomRight as string);
168
+ if (s.overflow) css.overflow = s.overflow as CSSProperties["overflow"];
169
+ if (s.fit) css.objectFit = s.fit as CSSProperties["objectFit"];
170
+ if (s.font && theme.text[s.font as string]) Object.assign(css, theme.text[s.font as string]);
171
+ if (s.weight) css.fontWeight = WEIGHTS[s.weight as string] ?? (s.weight as CSSProperties["fontWeight"]);
172
+ // `lineHeight` — override the text style's line-height (e.g. tighten a single-line
173
+ // card title). Applied AFTER `font` so it wins. Resolves a lineHeight token (tight/…).
174
+ if (s.lineHeight != null) css.lineHeight = (theme.lineHeight[s.lineHeight as string] ?? s.lineHeight) as CSSProperties["lineHeight"];
175
+ if (s.color) css.color = theme.color[s.color as string] ?? (s.color as string);
176
+ // `transition` — smooth state changes (e.g. hover). The value is authored in the
177
+ // definition (e.g. "all 150ms ease"); the SDK passes it through, authoring nothing.
178
+ if (s.transition) css.transition = s.transition as CSSProperties["transition"];
179
+ // Animation — GENERIC vocab. `animation` names a served keyframe (theme.keyframes,
180
+ // injected once via keyframesCss); timing comes from the definition. The SDK authors
181
+ // no motion values, and there is NO per-animation primitive — any animated UX is data.
182
+ if (s.animation) {
183
+ const a = s.animation as { name: string; duration?: string; easing?: string; iteration?: string };
184
+ css.animation = `uno-${a.name} ${a.duration ?? ""} ${a.easing ?? ""} ${a.iteration ?? "infinite"}`.replace(/\s+/g, " ").trim();
185
+ }
186
+ if (s.animationDelay != null) css.animationDelay = s.animationDelay as string;
187
+ return css;
188
+ }
189
+
190
+ /**
191
+ * Serialize a resolved CSSProperties object → a CSS declaration string ("a-b:v;…").
192
+ * `important` is needed for injected state rules (hover/active): the base style is
193
+ * applied INLINE, and an inline style beats any class selector — so a `:hover` rule
194
+ * can only override it with `!important`.
195
+ */
196
+ export function cssDecls(css: CSSProperties, important = false): string {
197
+ const flag = important ? " !important" : "";
198
+ return Object.entries(css)
199
+ .map(([k, v]) => `${k.replace(/[A-Z]/g, (m) => "-" + m.toLowerCase())}:${v}${flag}`)
200
+ .join(";");
201
+ }
202
+
203
+ /**
204
+ * Serialize the SERVED keyframes (theme.keyframes ← rx/styles/semantic/keyframes.json)
205
+ * into a CSS string, injected ONCE per render root (<style>). This authors zero values —
206
+ * it only structures DATA into `@keyframes uno-<name>{ <stop>{ <prop>:<val> } }`. Adding a
207
+ * new animation = adding a keyframe to rx/ + a `style.animation` ref in a definition. No
208
+ * SDK edit, no new primitive. Keyframe decls use real CSS prop names (transform, opacity…).
209
+ */
210
+ export function keyframesCss(theme: ResolvedTheme): string {
211
+ const hit = keyframesCache.get(theme);
212
+ if (hit !== undefined) return hit;
213
+ const kf = (theme.keyframes ?? {}) as Record<string, Record<string, Record<string, string>>>;
214
+ const css = Object.entries(kf)
215
+ .map(
216
+ ([name, stops]) =>
217
+ `@keyframes uno-${name}{${Object.entries(stops)
218
+ .map(([stop, decls]) => `${stop}{${Object.entries(decls).map(([p, v]) => `${p}:${v}`).join(";")}}`)
219
+ .join("")}}`,
220
+ )
221
+ .join("");
222
+ keyframesCache.set(theme, css);
223
+ return css;
224
+ }
225
+
226
+ // Pure function of the theme (stable per fetch) — serialize once, not per component render.
227
+ const keyframesCache = new WeakMap<ResolvedTheme, string>();