@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.
- package/package.json +1 -1
- package/screens/AtomsView.tsx +40 -2
- package/screens/ComponentsView.tsx +112 -21
- package/screens/NativeAppHost.tsx +8 -4
- package/screens/TemplatesView.tsx +51 -21
- package/screens/states.ts +27 -4
- package/vendor/sdk/appEngine.tsx +20 -4
- package/vendor/sdk/lib/UnoverseComponent.tsx +8 -2
- package/vendor/sdk/lib/core/actions.ts +10 -6
- package/vendor/sdk/lib/core/client.ts +39 -12
- package/vendor/sdk/lib/core/index.ts +21 -0
- package/vendor/sdk/lib/core/refs.ts +117 -0
- package/vendor/sdk/lib/core/store.ts +65 -38
- package/vendor/sdk/lib/core/templates.ts +270 -0
- package/vendor/sdk/lib/core/types.ts +27 -7
- package/vendor/sdk/lib/orb/ThinkingOrb.tsx +131 -0
- package/vendor/sdk/lib/orb/engine/braid.ts +50 -0
- package/vendor/sdk/lib/orb/engine/core.ts +167 -0
- package/vendor/sdk/lib/orb/engine/lattice.ts +205 -0
- package/vendor/sdk/lib/orb/engine/morph.ts +138 -0
- package/vendor/sdk/lib/orb/engine/orbits.ts +80 -0
- package/vendor/sdk/lib/orb/engine/profiles.ts +162 -0
- package/vendor/sdk/lib/orb/engine/registry.ts +31 -0
- package/vendor/sdk/lib/orb/engine/ribbon.ts +93 -0
- package/vendor/sdk/lib/orb/engine/rings.ts +239 -0
- package/vendor/sdk/lib/orb/engine/types.ts +14 -0
- package/vendor/sdk/lib/orb/engine/web.ts +98 -0
- package/vendor/sdk/lib/orb/index.ts +20 -0
- package/vendor/sdk/lib/orb/presets.ts +181 -0
- package/vendor/sdk/lib/orb/theme.ts +83 -0
- package/vendor/sdk/lib/orb/types.ts +84 -0
- package/vendor/sdk/lib/primitives.tsx +116 -2
- package/vendor/sdk/lib/render.tsx +32 -8
- package/vendor/sdk/lib/style.ts +6 -2
- package/vendor/sdk/lib/template.tsx +86 -224
- package/vendor/skills/unoverse-create/SKILL.md +18 -18
- package/vendor/skills/unoverse-create/references/agent-skill.md +12 -12
- package/vendor/skills/unoverse-create/references/block.md +5 -5
- package/vendor/skills/unoverse-create/references/component.md +150 -75
- package/vendor/skills/unoverse-create/references/node.md +1 -1
- package/vendor/skills/unoverse-create/references/template.md +142 -82
- 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
|
|
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) =>
|
|
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 {
|
|
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
|
-
|
|
84
|
-
|
|
85
|
-
export
|
|
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}>
|
package/vendor/sdk/lib/style.ts
CHANGED
|
@@ -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
|
|
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("");
|