@unoverse-platform/studio 0.1.26 → 0.1.28

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 (42) hide show
  1. package/package.json +1 -1
  2. package/screens/AtomsView.tsx +40 -2
  3. package/screens/ComponentsView.tsx +112 -21
  4. package/screens/NativeAppHost.tsx +8 -4
  5. package/screens/TemplatesView.tsx +51 -21
  6. package/screens/states.ts +27 -4
  7. package/vendor/sdk/appEngine.tsx +20 -4
  8. package/vendor/sdk/lib/UnoverseComponent.tsx +8 -2
  9. package/vendor/sdk/lib/core/actions.ts +10 -6
  10. package/vendor/sdk/lib/core/client.ts +39 -12
  11. package/vendor/sdk/lib/core/index.ts +21 -0
  12. package/vendor/sdk/lib/core/refs.ts +117 -0
  13. package/vendor/sdk/lib/core/store.ts +65 -38
  14. package/vendor/sdk/lib/core/templates.ts +270 -0
  15. package/vendor/sdk/lib/core/types.ts +27 -7
  16. package/vendor/sdk/lib/orb/ThinkingOrb.tsx +131 -0
  17. package/vendor/sdk/lib/orb/engine/braid.ts +50 -0
  18. package/vendor/sdk/lib/orb/engine/core.ts +167 -0
  19. package/vendor/sdk/lib/orb/engine/lattice.ts +205 -0
  20. package/vendor/sdk/lib/orb/engine/morph.ts +138 -0
  21. package/vendor/sdk/lib/orb/engine/orbits.ts +80 -0
  22. package/vendor/sdk/lib/orb/engine/profiles.ts +162 -0
  23. package/vendor/sdk/lib/orb/engine/registry.ts +31 -0
  24. package/vendor/sdk/lib/orb/engine/ribbon.ts +93 -0
  25. package/vendor/sdk/lib/orb/engine/rings.ts +239 -0
  26. package/vendor/sdk/lib/orb/engine/types.ts +14 -0
  27. package/vendor/sdk/lib/orb/engine/web.ts +98 -0
  28. package/vendor/sdk/lib/orb/index.ts +20 -0
  29. package/vendor/sdk/lib/orb/presets.ts +181 -0
  30. package/vendor/sdk/lib/orb/theme.ts +83 -0
  31. package/vendor/sdk/lib/orb/types.ts +84 -0
  32. package/vendor/sdk/lib/primitives.tsx +116 -2
  33. package/vendor/sdk/lib/render.tsx +32 -8
  34. package/vendor/sdk/lib/style.ts +6 -2
  35. package/vendor/sdk/lib/template.tsx +86 -224
  36. package/vendor/skills/unoverse-create/SKILL.md +18 -18
  37. package/vendor/skills/unoverse-create/references/agent-skill.md +12 -12
  38. package/vendor/skills/unoverse-create/references/block.md +5 -5
  39. package/vendor/skills/unoverse-create/references/component.md +150 -75
  40. package/vendor/skills/unoverse-create/references/node.md +1 -1
  41. package/vendor/skills/unoverse-create/references/template.md +142 -82
  42. package/vendor/skills/unoverse-create/references/workflow.md +15 -15
@@ -0,0 +1,83 @@
1
+ // Theme resolution: explicit prop → ancestor data-theme/.dark|.light
2
+ // class (watched live) → prefers-color-scheme (subscribed live).
3
+ // SSR-safe: everything runs in effects; the pre-mount fallback is dark.
4
+
5
+ import type { RefObject } from 'react';
6
+ import { useEffect, useState } from 'react';
7
+ import type { OrbTheme } from './types';
8
+
9
+ function ancestorTheme(el: Element | null): boolean | null {
10
+ let node: Element | null = el;
11
+ while (node) {
12
+ const attr = node.getAttribute('data-theme');
13
+ if (attr === 'dark') return true;
14
+ if (attr === 'light') return false;
15
+ if (node.classList.contains('dark')) return true;
16
+ if (node.classList.contains('light')) return false;
17
+ node = node.parentElement;
18
+ }
19
+ return null;
20
+ }
21
+
22
+ function systemDark(): boolean {
23
+ return typeof matchMedia === 'undefined' || matchMedia('(prefers-color-scheme: dark)').matches;
24
+ }
25
+
26
+ /** Resolve the effective dark/light substrate for a mounted element. */
27
+ export function useResolvedDark(theme: OrbTheme, hostRef: RefObject<Element | null>): boolean {
28
+ const [dark, setDark] = useState(true);
29
+
30
+ useEffect(() => {
31
+ if (theme === 'dark') {
32
+ setDark(true);
33
+ return;
34
+ }
35
+ if (theme === 'light') {
36
+ setDark(false);
37
+ return;
38
+ }
39
+
40
+ const resolve = () => {
41
+ const fromTree = ancestorTheme(hostRef.current);
42
+ setDark(fromTree ?? systemDark());
43
+ };
44
+ resolve();
45
+
46
+ // live OS/browser theme switches
47
+ const mq = typeof matchMedia !== 'undefined' ? matchMedia('(prefers-color-scheme: dark)') : null;
48
+ const onMq = () => resolve();
49
+ mq?.addEventListener('change', onMq);
50
+
51
+ // live app-level toggles: watch class/data-theme flips on ancestors
52
+ let mo: MutationObserver | null = null;
53
+ if (typeof MutationObserver !== 'undefined' && hostRef.current) {
54
+ mo = new MutationObserver(resolve);
55
+ mo.observe(document.documentElement, {
56
+ attributes: true,
57
+ attributeFilter: ['class', 'data-theme'],
58
+ subtree: true
59
+ });
60
+ }
61
+
62
+ return () => {
63
+ mq?.removeEventListener('change', onMq);
64
+ mo?.disconnect();
65
+ };
66
+ }, [theme, hostRef]);
67
+
68
+ return dark;
69
+ }
70
+
71
+ /** Live `prefers-reduced-motion` — reduced users get a static frame. */
72
+ export function useReducedMotion(): boolean {
73
+ const [reduced, setReduced] = useState(false);
74
+ useEffect(() => {
75
+ if (typeof matchMedia === 'undefined') return;
76
+ const mq = matchMedia('(prefers-reduced-motion: reduce)');
77
+ setReduced(mq.matches);
78
+ const on = (e: MediaQueryListEvent) => setReduced(e.matches);
79
+ mq.addEventListener('change', on);
80
+ return () => mq.removeEventListener('change', on);
81
+ }, []);
82
+ return reduced;
83
+ }
@@ -0,0 +1,84 @@
1
+ import type { CSSProperties, CanvasHTMLAttributes } from 'react';
2
+
3
+ /**
4
+ * The nine shipped states — each a hand-tuned animation:
5
+ * - `working` — particles on tilted orbits
6
+ * - `searching` — a scan meridian sweeps a dotted globe
7
+ * - `solving` — bands scramble in quarter turns, then click back
8
+ * - `listening` — a waveform rolls through latitude rings
9
+ * - `connecting` — a constellation wires itself, packets running the edges
10
+ * - `weaving` — three strands plait around the sphere
11
+ * - `composing` — an undulating multi-band sash
12
+ * - `breathing` — a face-on ring slowly morphing
13
+ * - `shaping` — a dotted outline morphs circle → triangle → square
14
+ *
15
+ * Plus the LOCAL (Unoverse) ANNULAR set — drawn only in a ring with the centre left
16
+ * empty, for framing an avatar's face rather than colliding with it (engine/rings.ts):
17
+ * - `halo` — a soft aura breathing, its form drifting off-circle
18
+ * - `sonar` — soft rings of light arriving inward, the shape of sound reaching you
19
+ * - `shimmer` — luminance sweeping through a still ring, a thought being turned over
20
+ * - `pulse` — rings of light leaving outward, the shape of a voice carrying
21
+ */
22
+ export type OrbState =
23
+ | 'working'
24
+ | 'searching'
25
+ | 'solving'
26
+ | 'listening'
27
+ | 'connecting'
28
+ | 'weaving'
29
+ | 'composing'
30
+ | 'breathing'
31
+ | 'shaping'
32
+ | 'halo'
33
+ | 'sonar'
34
+ | 'shimmer'
35
+ | 'pulse';
36
+
37
+ /**
38
+ * Rendered size in CSS pixels. Exactly two tuned presets ship:
39
+ * 64 (chat-avatar scale) and 20 (inline-text scale). Each size carries
40
+ * its own dot count, dot size and speed tuning — they are separate
41
+ * designs, not a scale factor.
42
+ */
43
+ export type OrbSize = 64 | 20;
44
+
45
+ /**
46
+ * Theme mode.
47
+ *
48
+ * - `auto` (default) resolves in three layers, live-updating on change:
49
+ * 1. a `data-theme="dark|light"` attribute or `dark`/`light` class on
50
+ * any ancestor (the Tailwind / shadcn convention), watched via
51
+ * `MutationObserver`;
52
+ * 2. otherwise `matchMedia('(prefers-color-scheme: dark)')`,
53
+ * subscribed for live OS/browser theme switches;
54
+ * 3. during SSR (no DOM) the first client render resolves the theme
55
+ * before anything is painted — the canvas is client-only.
56
+ * - `dark` / `light` pin the palette regardless of context.
57
+ *
58
+ * Dark renders light ink on the transparent canvas (for dark
59
+ * backgrounds); light renders dark ink (for light backgrounds).
60
+ */
61
+ export type OrbTheme = 'auto' | 'dark' | 'light';
62
+
63
+ /** Props for the ThinkingOrb React component. */
64
+ export interface ThinkingOrbProps extends Omit<CanvasHTMLAttributes<HTMLCanvasElement>, 'style'> {
65
+ /** Which animation to show. @default 'working' */
66
+ state?: OrbState;
67
+
68
+ /** Tuned size preset — 64 or 20 CSS px. @default 64 */
69
+ size?: OrbSize;
70
+
71
+ /** Theme mode; `auto` detects from the host project. @default 'auto' */
72
+ theme?: OrbTheme;
73
+
74
+ /**
75
+ * Animation speed multiplier on top of the preset's baked speed.
76
+ * @default 1
77
+ */
78
+ speed?: number;
79
+
80
+ /** Freeze the animation on the current frame. @default false */
81
+ paused?: boolean;
82
+
83
+ style?: CSSProperties;
84
+ }
@@ -24,6 +24,7 @@ import Markdown from "markdown-to-jsx";
24
24
  import type { ResolvedTheme, UnoverseNode } from "./core";
25
25
  import { styleToCss, cssDecls } from "./style";
26
26
  import { nodeAction, type ActionHandler } from "./render";
27
+ import { ThinkingOrb, type OrbState, type OrbSize, type OrbTheme } from "./orb";
27
28
 
28
29
  /** Read a bound data field as a string (for Text/Image/Button content). */
29
30
  function bound(field: string | undefined, data: Record<string, unknown>): string | undefined {
@@ -339,6 +340,49 @@ export function ButtonView({ node, data, theme, onAction, children }: { node: Un
339
340
  );
340
341
  }
341
342
 
343
+ /**
344
+ * `Orb` — a procedural "the assistant is doing something" animation, drawn
345
+ * per-frame on a 2D canvas (see ./orb — vendored thinking-orbs, MIT). This is
346
+ * the ONE class of motion keyframe data cannot express (per-frame math, depth
347
+ * occlusion), so like Skeleton's shimmer the drawing lives WITH the primitive;
348
+ * every authoring decision stays data: `state` (which of the nine animations —
349
+ * bindable), `size` (64 avatar / 20 inline), `speed`, `theme` override.
350
+ * Unknown/unbound state degrades to "working", never a crash.
351
+ */
352
+ const ORB_STATES = new Set(["working", "searching", "solving", "listening", "connecting", "weaving", "composing", "breathing", "shaping", "halo", "sonar", "shimmer", "pulse"]);
353
+ export function OrbView({ node, data, theme }: { node: UnoverseNode; data: Record<string, unknown>; theme: ResolvedTheme }): ReactNode {
354
+ const n = node as UnoverseNode & { state?: string; size?: number | string; speed?: number | string; theme?: string };
355
+ // Every knob is data-driven OR literal, like Icon: `bind.<knob>` wins, the attribute is the fallback.
356
+ const knob = (name: "state" | "size" | "speed") =>
357
+ (node.bind?.[name] ? (data[node.bind[name] as string] as string | number | undefined) : undefined) ?? n[name];
358
+ const raw = knob("state") as string | undefined;
359
+ // UNKNOWN NAME → the calm ANNULAR fallback, never the sphere. A bad/stale name is an
360
+ // authoring or version mismatch, and the orb most often sits behind a face: degrading
361
+ // to `working` threw 3-D particles across the portrait (the loudest mode in the set) to
362
+ // signal a problem the viewer cannot act on. `halo` is quiet, ring-shaped, and wrong in
363
+ // no context — a mismatch should look calm, not broken.
364
+ const state = raw && ORB_STATES.has(raw) ? (raw as OrbState) : "halo";
365
+ const size = Number(knob("size")) > 0 ? Number(knob("size")) : 64;
366
+ const speed = Number(knob("speed")) > 0 ? Number(knob("speed")) : 1;
367
+ const orbTheme: OrbTheme = n.theme === "dark" || n.theme === "light" ? n.theme : "auto";
368
+ // Brand ink comes from the STYLE vocabulary (`color: <token>`), never a prop — LAW 1:
369
+ // a definition names a token, the theme serves the value, the engine tints toward it.
370
+ const css = styleToCss(node.style ?? {}, theme, data);
371
+ return <ThinkingOrb state={state} size={size as OrbSize} speed={speed} theme={orbTheme} ink={cssColorToRgb(css.color as string | undefined)} style={css} />;
372
+ }
373
+
374
+ /** #rgb / #rrggbb / rgb()/rgba() → an rgb tuple; anything else (gradients, names) → null (neutral ink). */
375
+ function cssColorToRgb(c?: string): [number, number, number] | null {
376
+ if (!c) return null;
377
+ const hex = /^#([0-9a-f]{3}|[0-9a-f]{6})$/i.exec(c.trim());
378
+ if (hex) {
379
+ const h = hex[1].length === 3 ? [...hex[1]].map((x) => x + x).join("") : hex[1];
380
+ return [0, 2, 4].map((i) => parseInt(h.slice(i, i + 2), 16)) as [number, number, number];
381
+ }
382
+ const fn = /^rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/i.exec(c.trim());
383
+ return fn ? [Number(fn[1]), Number(fn[2]), Number(fn[3])] : null;
384
+ }
385
+
342
386
  /** Unknown node type — render children so the tree doesn't break (degenerate fallback). */
343
387
  export function UnknownView({ node, theme, children }: { node: UnoverseNode; theme: ResolvedTheme; children?: ReactNode }): ReactNode {
344
388
  return (
@@ -432,6 +476,58 @@ export function SkeletonView({ variant = "text", theme }: { variant?: string; th
432
476
  );
433
477
  }
434
478
 
479
+ /**
480
+ * `Select` — the irreducible `<select>`, like Text→<span> and Input→<input>.
481
+ *
482
+ * THE ONE CONTROL THAT CANNOT BE COMPOSED. Every other form field in this platform is
483
+ * built from Box, Text, Button and Input, as data. A closed set of two hundred countries
484
+ * is where that stops: laying them out is unusable, filtering them is logic a definition
485
+ * may not carry, and a hand-rolled menu forfeits the OS picker, type-to-search, keyboard
486
+ * navigation and screen-reader semantics that the real element gives for nothing.
487
+ *
488
+ * It reports a change exactly as a controlled Input does — `onAction("input", { field,
489
+ * text })` — so the bound field name IS the key the answer lands in, and a select needs no
490
+ * mechanism the platform does not already have.
491
+ *
492
+ * ⛔ NO CHROME HERE. No custom arrow, no popup, no styling: `node.style` is the whole look,
493
+ * and the options are DATA (`bind.options`, entries of `{ value, label }` or plain
494
+ * strings). A searchable picker is a different control and does not belong in this one.
495
+ */
496
+ export function SelectView({ node, data, theme, onAction }: { node: UnoverseNode; data: Record<string, unknown>; theme: ResolvedTheme; onAction?: ActionHandler }): ReactNode {
497
+ const field = node.bind?.value;
498
+ const value = field != null ? String(data[field] ?? "") : "";
499
+ const raw = node.bind?.options ? data[node.bind.options] : (node as { options?: unknown }).options;
500
+ const options = (Array.isArray(raw) ? raw : []).map((o) =>
501
+ o != null && typeof o === "object"
502
+ ? { 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 ?? "") }
503
+ : { value: String(o), label: String(o) },
504
+ );
505
+ const { style, disabled } = nodeChrome(node, data, theme);
506
+ return (
507
+ <select
508
+ style={style}
509
+ disabled={disabled}
510
+ value={value}
511
+ data-unoverse-id={inspectId(node)}
512
+ onChange={(e) => field != null && onAction?.("input", { text: e.target.value, field })}
513
+ >
514
+ {/* The placeholder is CONTENT, exactly as it is on an Input: an unanswered select
515
+ must say what it wants rather than pre-selecting the first option and claiming an
516
+ answer nobody gave. */}
517
+ {node.placeholder != null && (
518
+ <option value="" disabled>
519
+ {node.placeholder}
520
+ </option>
521
+ )}
522
+ {options.map((o) => (
523
+ <option key={o.value} value={o.value}>
524
+ {o.label}
525
+ </option>
526
+ ))}
527
+ </select>
528
+ );
529
+ }
530
+
435
531
  /**
436
532
  * `Input` — single-line composer (the irreducible `<input>`, like Text→<span>).
437
533
  * Holds its own draft text (local, not store state); on Enter it fires
@@ -463,6 +559,11 @@ export function InputView({ node, data, theme, onAction }: { node: UnoverseNode;
463
559
  // a postal code, "off") and the guess never happens. Generic pass-through: the SDK holds
464
560
  // no list of values and no idea what a one-time code is.
465
561
  const autoComplete = (node as { autoComplete?: string }).autoComplete;
562
+ // `type` — WHAT THE FIELD IS, passed to the platform: `date` gets the OS date picker,
563
+ // `tel` the phone keypad, `file` the file chooser. The same class of generic constraint
564
+ // as `maxLength` and `inputMode`, and the reason a definition never has to hand-roll a
565
+ // calendar out of Boxes. The SDK holds no list of types and no opinion about them.
566
+ const inputType = (node as { inputType?: string }).inputType;
466
567
  // `maxLength` is enforced by the element itself, but `inputMode` is only a HINT —
467
568
  // a desktop keyboard types straight past it — so the digit rule is applied here.
468
569
  const accept = (t: string) => {
@@ -538,13 +639,26 @@ export function InputView({ node, data, theme, onAction }: { node: UnoverseNode;
538
639
  ref={el}
539
640
  style={style}
540
641
  disabled={disabled}
541
- value={value}
642
+ // A file input is never controlled: assigning its value is a security error in every
643
+ // browser, so the chosen name lives in the data and the element stays uncontrolled.
644
+ value={inputType === "file" ? undefined : value}
542
645
  placeholder={node.placeholder}
646
+ type={inputType}
543
647
  maxLength={maxLength}
544
648
  inputMode={numeric ? "numeric" : undefined}
545
649
  autoComplete={autoComplete}
546
650
  data-uno-advance={advanceOnFill ? "" : undefined}
547
- onChange={(e) => setValue(e.target.value)}
651
+ onChange={(e) => {
652
+ // A FILE FIELD REPORTS ITS FILES, not its value: the DOM's value for one is a fake
653
+ // path, and the bytes are the host's business (an upload target, a stored
654
+ // reference) rather than the renderer's. The SDK states what was chosen and stops.
655
+ if (inputType === "file") {
656
+ const picked = Array.from(e.target.files ?? []).map((f) => ({ name: f.name, size: f.size, type: f.type }));
657
+ if (field != null) onAction?.("input", { text: picked.map((f) => f.name).join(", "), field, files: picked });
658
+ return;
659
+ }
660
+ setValue(e.target.value);
661
+ }}
548
662
  onKeyDown={(e) => {
549
663
  // Backspace in an ALREADY-empty field steps back, so correcting a sequence never
550
664
  // needs the mouse. A field with content just deletes it (the default).
@@ -35,8 +35,9 @@
35
35
  */
36
36
  import type { ReactNode } from "react";
37
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";
38
+ import { visible, selectCase, cssWidth } from "./core";
39
+ import { resolveRef } from "./core/refs";
40
+ import { BoxView, TextView, ImageView, ButtonView, UnknownView, SkeletonView, InputView, SelectView, MarkdownView, IconView, OrbView } from "./primitives";
40
41
 
41
42
  // The style + animation interpreter lives in ./style; re-exported so consumers keep
42
43
  // importing it from "./render".
@@ -80,9 +81,9 @@ export function nodeAction(node: UnoverseNode, fallback: string): string | Actio
80
81
  */
81
82
  export type SlotResolver = (node: UnoverseNode, key?: React.Key) => ReactNode;
82
83
 
83
- /** A usable appWidth is CSS ("360px", "min(50vw, 760px)") — an UNRESOLVED size name
84
- * has no digits; treat it as absent rather than emitting invalid CSS. */
85
- export const cssWidth = (w: unknown): string | undefined => (typeof w === "string" && /\d/.test(w) ? w : undefined);
84
+ // cssWidth moved to core/templates.ts (computeAppWidth needs it and core imports
85
+ // nothing from the React layer); re-exported to keep this import path working.
86
+ export { cssWidth };
86
87
 
87
88
  export function renderNode(
88
89
  node: UnoverseNode,
@@ -91,6 +92,12 @@ export function renderNode(
91
92
  theme: ResolvedTheme,
92
93
  key?: React.Key,
93
94
  slot?: SlotResolver,
95
+ /**
96
+ * The shared pieces this definition REFERENCES, by name. Present only when the server
97
+ * sends references instead of inlining copies of them; absent for every definition
98
+ * served the old way, in which case no `Ref` reaches this function at all.
99
+ */
100
+ defs?: Record<string, UnoverseNode>,
94
101
  ): ReactNode {
95
102
  // visibleWhen — conditional visibility (§2e-3). A bare field name is a truthiness test
96
103
  // (an empty array counts as empty); an object is a `Condition` (eq/ne/in, or truthy) so
@@ -135,17 +142,32 @@ export function renderNode(
135
142
  // through `with` — so without this an atom can be data-driven or literal, never both.
136
143
  if (node.type === "Icon")
137
144
  return <IconView key={key} name={node.bind?.name ? (data[node.bind.name] as string) : (node.icon ?? node.name)} style={node.style} theme={theme} />;
145
+ // Orb: procedural thinking/working animation — state is a literal or bound (`bind.state`).
146
+ if (node.type === "Orb") return <OrbView key={key} node={node} data={data} theme={theme} />;
138
147
  if (node.type === "Input") return <InputView key={key} node={node} data={data} theme={theme} onAction={onAction} />;
139
148
  if (node.type === "Markdown") return <MarkdownView key={key} node={node} data={data} theme={theme} />;
140
149
  if (node.type === "ComponentSlot" || node.type === "Timeline") return slot ? slot(node, key) : null;
141
150
 
151
+ // `Ref` — REFERENCE, NOT COPY. The design system's shared pieces travel once and are
152
+ // cached; a definition names one and specialises it here (core/refs.ts: `props` remap,
153
+ // `with` literals, style/action/visibleWhen overrides). This is what stops a button
154
+ // being re-sent inside every definition that uses it.
155
+ //
156
+ // A Ref only arrives when the server sent references, so this branch is inert for
157
+ // anything served the old, inlined way. An unknown name renders NOTHING rather than a
158
+ // broken box: a hole is easier to diagnose than a mystery element.
159
+ if (node.type === "Ref") {
160
+ const resolved = resolveRef(node, defs?.[String((node as { ref?: string }).ref ?? "")]);
161
+ return resolved ? renderNode(resolved, data, onAction, theme, key, slot, defs) : null;
162
+ }
163
+
142
164
  // `Switch` — control flow: render EXACTLY ONE branch, the case matching `data[on]`
143
165
  // (else `cases.default`, else nothing). The declarative whole-view swap (wizard step,
144
166
  // inline↔focused) — mutually exclusive in one place, vs. a `visibleWhen` per branch. No
145
167
  // wrapper element: it is transparent, like an `if`, delegating straight to the branch.
146
168
  if (node.type === "Switch") {
147
169
  const branch = selectCase(node, data);
148
- return branch ? renderNode(branch, data, onAction, theme, key, slot) : null;
170
+ return branch ? renderNode(branch, data, onAction, theme, key, slot, defs) : null;
149
171
  }
150
172
 
151
173
  // `Each` — control flow: map a bound array (bind.items) → the `template` subtree once
@@ -173,7 +195,7 @@ export function renderNode(
173
195
  ? { ...base, ...(item as Record<string, unknown>) }
174
196
  : { ...base, value: item };
175
197
  };
176
- const rendered = tpl ? items.map((item, i) => renderNode(tpl, scopeOf(item, i), onAction, theme, i, slot)) : null;
198
+ const rendered = tpl ? items.map((item, i) => renderNode(tpl, scopeOf(item, i), onAction, theme, i, slot, defs)) : null;
177
199
  return (
178
200
  <BoxView key={key} node={node} data={data} theme={theme}>
179
201
  {rendered}
@@ -182,7 +204,7 @@ export function renderNode(
182
204
  }
183
205
 
184
206
  // Element primitives → dispatch. Children are rendered here (control flow) and handed down.
185
- const kids = (node.children ?? []).map((c, i) => renderNode(c, data, onAction, theme, i, slot));
207
+ const kids = (node.children ?? []).map((c, i) => renderNode(c, data, onAction, theme, i, slot, defs));
186
208
  switch (node.type) {
187
209
  case "Box":
188
210
  case "Stack":
@@ -197,6 +219,8 @@ export function renderNode(
197
219
  return <TextView key={key} node={node} data={data} theme={theme} />;
198
220
  case "Image":
199
221
  return <ImageView key={key} node={node} data={data} theme={theme} />;
222
+ case "Select":
223
+ return <SelectView key={key} node={node} data={data} theme={theme} onAction={onAction} />;
200
224
  case "Button":
201
225
  return (
202
226
  <ButtonView key={key} node={node} data={data} theme={theme} onAction={onAction}>
@@ -223,17 +223,21 @@ export function cssDecls(css: CSSProperties, important = false): string {
223
223
  * into a CSS string, injected ONCE per render root (<style>). This authors zero values —
224
224
  * it only structures DATA into `@keyframes uno-<name>{ <stop>{ <prop>:<val> } }`. Adding a
225
225
  * new animation = adding a keyframe to rx/ + a `style.animation` ref in a definition. No
226
- * SDK edit, no new primitive. Keyframe decls use real CSS prop names (transform, opacity…).
226
+ * SDK edit, no new primitive. Keyframe decls use real CSS prop names, KEBAB-CASE
227
+ * (`box-shadow`, never `boxShadow`) — that is the canonical authored form, lint-enforced.
228
+ * Property keys are still kebab-normalized here, exactly as Studio's gallery serializer
229
+ * does, so the two renderers can never disagree on a key that slips past the lint.
227
230
  */
228
231
  export function keyframesCss(theme: ResolvedTheme): string {
229
232
  const hit = keyframesCache.get(theme);
230
233
  if (hit !== undefined) return hit;
231
234
  const kf = (theme.keyframes ?? {}) as Record<string, Record<string, Record<string, string>>>;
235
+ const kebab = (s: string) => s.replace(/[A-Z]/g, (m) => "-" + m.toLowerCase());
232
236
  const css = Object.entries(kf)
233
237
  .map(
234
238
  ([name, stops]) =>
235
239
  `@keyframes uno-${name}{${Object.entries(stops)
236
- .map(([stop, decls]) => `${stop}{${Object.entries(decls).map(([p, v]) => `${p}:${v}`).join(";")}}`)
240
+ .map(([stop, decls]) => `${stop}{${Object.entries(decls).map(([p, v]) => `${kebab(p)}:${v}`).join(";")}}`)
237
241
  .join("")}}`,
238
242
  )
239
243
  .join("");