@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.
- package/bin/studio.mjs +124 -0
- package/index.html +12 -0
- package/local/catalog.ts +105 -0
- package/local/keys.ts +118 -0
- package/local/nodes.ts +77 -0
- package/local/plugin.ts +401 -0
- package/local/scaffold.d.mts +13 -0
- package/local/scaffold.mjs +85 -0
- package/package.json +43 -0
- package/public/logo.png +0 -0
- package/screens/AtomsView.tsx +206 -0
- package/screens/ComponentsView.tsx +551 -0
- package/screens/ConfigForm.tsx +217 -0
- package/screens/DesignSystemView.tsx +324 -0
- package/screens/IdentityHeader.tsx +88 -0
- package/screens/PromptBlocksView.tsx +137 -0
- package/screens/SkillsView.tsx +167 -0
- package/screens/TemplatesView.tsx +1005 -0
- package/screens/index.ts +26 -0
- package/screens/states.ts +44 -0
- package/src/App.tsx +252 -0
- package/src/ConnectUniverse.tsx +232 -0
- package/src/NewProject.tsx +89 -0
- package/src/NodeKeys.tsx +98 -0
- package/src/NodesView.tsx +443 -0
- package/src/PublishDialog.tsx +309 -0
- package/src/UniverseSession.tsx +121 -0
- package/src/host.ts +165 -0
- package/src/index.css +7 -0
- package/src/main.tsx +11 -0
- package/src/universes.ts +210 -0
- package/vendor/sdk/appEngine.tsx +219 -0
- package/vendor/sdk/lib/UnoverseComponent.tsx +120 -0
- package/vendor/sdk/lib/connection.tsx +302 -0
- package/vendor/sdk/lib/core/actions.ts +84 -0
- package/vendor/sdk/lib/core/client.ts +134 -0
- package/vendor/sdk/lib/core/conditions.ts +43 -0
- package/vendor/sdk/lib/core/connection.ts +142 -0
- package/vendor/sdk/lib/core/index.ts +32 -0
- package/vendor/sdk/lib/core/store.ts +455 -0
- package/vendor/sdk/lib/core/types.ts +194 -0
- package/vendor/sdk/lib/index.ts +59 -0
- package/vendor/sdk/lib/isolate.tsx +70 -0
- package/vendor/sdk/lib/primitives.tsx +453 -0
- package/vendor/sdk/lib/realtime/audioUtils.ts +40 -0
- package/vendor/sdk/lib/realtime/types.ts +45 -0
- package/vendor/sdk/lib/realtime/useAudioCapture.ts +237 -0
- package/vendor/sdk/lib/realtime/useAudioPlayback.ts +145 -0
- package/vendor/sdk/lib/realtime/useRealtimeWebSocket.ts +164 -0
- package/vendor/sdk/lib/render.tsx +178 -0
- package/vendor/sdk/lib/streamed.tsx +65 -0
- package/vendor/sdk/lib/style.ts +227 -0
- package/vendor/sdk/lib/template.tsx +521 -0
- package/vendor/sdk/lib/theme.ts +55 -0
- package/vendor/sdk/lib/voice.tsx +311 -0
- package/vendor/sdk/main.tsx +96 -0
- 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>();
|