@unoverse-platform/studio 0.1.26 → 0.1.27
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 +50 -20
- package/screens/states.ts +27 -4
- package/vendor/sdk/appEngine.tsx +18 -2
- package/vendor/sdk/lib/core/actions.ts +10 -6
- package/vendor/sdk/lib/core/index.ts +18 -0
- package/vendor/sdk/lib/core/store.ts +65 -38
- package/vendor/sdk/lib/core/templates.ts +252 -0
- package/vendor/sdk/lib/core/types.ts +20 -7
- package/vendor/sdk/lib/orb/ThinkingOrb.tsx +127 -0
- package/vendor/sdk/lib/orb/engine/braid.ts +50 -0
- package/vendor/sdk/lib/orb/engine/core.ts +157 -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 +24 -0
- package/vendor/sdk/lib/orb/engine/ribbon.ts +93 -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 +104 -0
- package/vendor/sdk/lib/orb/theme.ts +83 -0
- package/vendor/sdk/lib/orb/types.ts +73 -0
- package/vendor/sdk/lib/primitives.tsx +111 -2
- package/vendor/sdk/lib/render.tsx +9 -5
- package/vendor/sdk/lib/style.ts +6 -2
- package/vendor/sdk/lib/template.tsx +77 -218
- 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
|
@@ -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,44 @@ 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"]);
|
|
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
|
+
const state = raw && ORB_STATES.has(raw) ? (raw as OrbState) : "working";
|
|
360
|
+
const size = Number(knob("size")) > 0 ? Number(knob("size")) : 64;
|
|
361
|
+
const speed = Number(knob("speed")) > 0 ? Number(knob("speed")) : 1;
|
|
362
|
+
const orbTheme: OrbTheme = n.theme === "dark" || n.theme === "light" ? n.theme : "auto";
|
|
363
|
+
// Brand ink comes from the STYLE vocabulary (`color: <token>`), never a prop — LAW 1:
|
|
364
|
+
// a definition names a token, the theme serves the value, the engine tints toward it.
|
|
365
|
+
const css = styleToCss(node.style ?? {}, theme);
|
|
366
|
+
return <ThinkingOrb state={state} size={size as OrbSize} speed={speed} theme={orbTheme} ink={cssColorToRgb(css.color as string | undefined)} style={css} />;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/** #rgb / #rrggbb / rgb()/rgba() → an rgb tuple; anything else (gradients, names) → null (neutral ink). */
|
|
370
|
+
function cssColorToRgb(c?: string): [number, number, number] | null {
|
|
371
|
+
if (!c) return null;
|
|
372
|
+
const hex = /^#([0-9a-f]{3}|[0-9a-f]{6})$/i.exec(c.trim());
|
|
373
|
+
if (hex) {
|
|
374
|
+
const h = hex[1].length === 3 ? [...hex[1]].map((x) => x + x).join("") : hex[1];
|
|
375
|
+
return [0, 2, 4].map((i) => parseInt(h.slice(i, i + 2), 16)) as [number, number, number];
|
|
376
|
+
}
|
|
377
|
+
const fn = /^rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/i.exec(c.trim());
|
|
378
|
+
return fn ? [Number(fn[1]), Number(fn[2]), Number(fn[3])] : null;
|
|
379
|
+
}
|
|
380
|
+
|
|
342
381
|
/** Unknown node type — render children so the tree doesn't break (degenerate fallback). */
|
|
343
382
|
export function UnknownView({ node, theme, children }: { node: UnoverseNode; theme: ResolvedTheme; children?: ReactNode }): ReactNode {
|
|
344
383
|
return (
|
|
@@ -432,6 +471,58 @@ export function SkeletonView({ variant = "text", theme }: { variant?: string; th
|
|
|
432
471
|
);
|
|
433
472
|
}
|
|
434
473
|
|
|
474
|
+
/**
|
|
475
|
+
* `Select` — the irreducible `<select>`, like Text→<span> and Input→<input>.
|
|
476
|
+
*
|
|
477
|
+
* THE ONE CONTROL THAT CANNOT BE COMPOSED. Every other form field in this platform is
|
|
478
|
+
* built from Box, Text, Button and Input, as data. A closed set of two hundred countries
|
|
479
|
+
* is where that stops: laying them out is unusable, filtering them is logic a definition
|
|
480
|
+
* may not carry, and a hand-rolled menu forfeits the OS picker, type-to-search, keyboard
|
|
481
|
+
* navigation and screen-reader semantics that the real element gives for nothing.
|
|
482
|
+
*
|
|
483
|
+
* It reports a change exactly as a controlled Input does — `onAction("input", { field,
|
|
484
|
+
* text })` — so the bound field name IS the key the answer lands in, and a select needs no
|
|
485
|
+
* mechanism the platform does not already have.
|
|
486
|
+
*
|
|
487
|
+
* ⛔ NO CHROME HERE. No custom arrow, no popup, no styling: `node.style` is the whole look,
|
|
488
|
+
* and the options are DATA (`bind.options`, entries of `{ value, label }` or plain
|
|
489
|
+
* strings). A searchable picker is a different control and does not belong in this one.
|
|
490
|
+
*/
|
|
491
|
+
export function SelectView({ node, data, theme, onAction }: { node: UnoverseNode; data: Record<string, unknown>; theme: ResolvedTheme; onAction?: ActionHandler }): ReactNode {
|
|
492
|
+
const field = node.bind?.value;
|
|
493
|
+
const value = field != null ? String(data[field] ?? "") : "";
|
|
494
|
+
const raw = node.bind?.options ? data[node.bind.options] : (node as { options?: unknown }).options;
|
|
495
|
+
const options = (Array.isArray(raw) ? raw : []).map((o) =>
|
|
496
|
+
o != null && typeof o === "object"
|
|
497
|
+
? { 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 ?? "") }
|
|
498
|
+
: { value: String(o), label: String(o) },
|
|
499
|
+
);
|
|
500
|
+
const { style, disabled } = nodeChrome(node, data, theme);
|
|
501
|
+
return (
|
|
502
|
+
<select
|
|
503
|
+
style={style}
|
|
504
|
+
disabled={disabled}
|
|
505
|
+
value={value}
|
|
506
|
+
data-unoverse-id={inspectId(node)}
|
|
507
|
+
onChange={(e) => field != null && onAction?.("input", { text: e.target.value, field })}
|
|
508
|
+
>
|
|
509
|
+
{/* The placeholder is CONTENT, exactly as it is on an Input: an unanswered select
|
|
510
|
+
must say what it wants rather than pre-selecting the first option and claiming an
|
|
511
|
+
answer nobody gave. */}
|
|
512
|
+
{node.placeholder != null && (
|
|
513
|
+
<option value="" disabled>
|
|
514
|
+
{node.placeholder}
|
|
515
|
+
</option>
|
|
516
|
+
)}
|
|
517
|
+
{options.map((o) => (
|
|
518
|
+
<option key={o.value} value={o.value}>
|
|
519
|
+
{o.label}
|
|
520
|
+
</option>
|
|
521
|
+
))}
|
|
522
|
+
</select>
|
|
523
|
+
);
|
|
524
|
+
}
|
|
525
|
+
|
|
435
526
|
/**
|
|
436
527
|
* `Input` — single-line composer (the irreducible `<input>`, like Text→<span>).
|
|
437
528
|
* Holds its own draft text (local, not store state); on Enter it fires
|
|
@@ -463,6 +554,11 @@ export function InputView({ node, data, theme, onAction }: { node: UnoverseNode;
|
|
|
463
554
|
// a postal code, "off") and the guess never happens. Generic pass-through: the SDK holds
|
|
464
555
|
// no list of values and no idea what a one-time code is.
|
|
465
556
|
const autoComplete = (node as { autoComplete?: string }).autoComplete;
|
|
557
|
+
// `type` — WHAT THE FIELD IS, passed to the platform: `date` gets the OS date picker,
|
|
558
|
+
// `tel` the phone keypad, `file` the file chooser. The same class of generic constraint
|
|
559
|
+
// as `maxLength` and `inputMode`, and the reason a definition never has to hand-roll a
|
|
560
|
+
// calendar out of Boxes. The SDK holds no list of types and no opinion about them.
|
|
561
|
+
const inputType = (node as { inputType?: string }).inputType;
|
|
466
562
|
// `maxLength` is enforced by the element itself, but `inputMode` is only a HINT —
|
|
467
563
|
// a desktop keyboard types straight past it — so the digit rule is applied here.
|
|
468
564
|
const accept = (t: string) => {
|
|
@@ -538,13 +634,26 @@ export function InputView({ node, data, theme, onAction }: { node: UnoverseNode;
|
|
|
538
634
|
ref={el}
|
|
539
635
|
style={style}
|
|
540
636
|
disabled={disabled}
|
|
541
|
-
value
|
|
637
|
+
// A file input is never controlled: assigning its value is a security error in every
|
|
638
|
+
// browser, so the chosen name lives in the data and the element stays uncontrolled.
|
|
639
|
+
value={inputType === "file" ? undefined : value}
|
|
542
640
|
placeholder={node.placeholder}
|
|
641
|
+
type={inputType}
|
|
543
642
|
maxLength={maxLength}
|
|
544
643
|
inputMode={numeric ? "numeric" : undefined}
|
|
545
644
|
autoComplete={autoComplete}
|
|
546
645
|
data-uno-advance={advanceOnFill ? "" : undefined}
|
|
547
|
-
onChange={(e) =>
|
|
646
|
+
onChange={(e) => {
|
|
647
|
+
// A FILE FIELD REPORTS ITS FILES, not its value: the DOM's value for one is a fake
|
|
648
|
+
// path, and the bytes are the host's business (an upload target, a stored
|
|
649
|
+
// reference) rather than the renderer's. The SDK states what was chosen and stops.
|
|
650
|
+
if (inputType === "file") {
|
|
651
|
+
const picked = Array.from(e.target.files ?? []).map((f) => ({ name: f.name, size: f.size, type: f.type }));
|
|
652
|
+
if (field != null) onAction?.("input", { text: picked.map((f) => f.name).join(", "), field, files: picked });
|
|
653
|
+
return;
|
|
654
|
+
}
|
|
655
|
+
setValue(e.target.value);
|
|
656
|
+
}}
|
|
548
657
|
onKeyDown={(e) => {
|
|
549
658
|
// Backspace in an ALREADY-empty field steps back, so correcting a sequence never
|
|
550
659
|
// needs the mouse. A field with content just deletes it (the default).
|
|
@@ -35,8 +35,8 @@
|
|
|
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 { BoxView, TextView, ImageView, ButtonView, UnknownView, SkeletonView, InputView, SelectView, MarkdownView, IconView, OrbView } from "./primitives";
|
|
40
40
|
|
|
41
41
|
// The style + animation interpreter lives in ./style; re-exported so consumers keep
|
|
42
42
|
// importing it from "./render".
|
|
@@ -80,9 +80,9 @@ export function nodeAction(node: UnoverseNode, fallback: string): string | Actio
|
|
|
80
80
|
*/
|
|
81
81
|
export type SlotResolver = (node: UnoverseNode, key?: React.Key) => ReactNode;
|
|
82
82
|
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
export
|
|
83
|
+
// cssWidth moved to core/templates.ts (computeAppWidth needs it and core imports
|
|
84
|
+
// nothing from the React layer); re-exported to keep this import path working.
|
|
85
|
+
export { cssWidth };
|
|
86
86
|
|
|
87
87
|
export function renderNode(
|
|
88
88
|
node: UnoverseNode,
|
|
@@ -135,6 +135,8 @@ export function renderNode(
|
|
|
135
135
|
// through `with` — so without this an atom can be data-driven or literal, never both.
|
|
136
136
|
if (node.type === "Icon")
|
|
137
137
|
return <IconView key={key} name={node.bind?.name ? (data[node.bind.name] as string) : (node.icon ?? node.name)} style={node.style} theme={theme} />;
|
|
138
|
+
// Orb: procedural thinking/working animation — state is a literal or bound (`bind.state`).
|
|
139
|
+
if (node.type === "Orb") return <OrbView key={key} node={node} data={data} theme={theme} />;
|
|
138
140
|
if (node.type === "Input") return <InputView key={key} node={node} data={data} theme={theme} onAction={onAction} />;
|
|
139
141
|
if (node.type === "Markdown") return <MarkdownView key={key} node={node} data={data} theme={theme} />;
|
|
140
142
|
if (node.type === "ComponentSlot" || node.type === "Timeline") return slot ? slot(node, key) : null;
|
|
@@ -197,6 +199,8 @@ export function renderNode(
|
|
|
197
199
|
return <TextView key={key} node={node} data={data} theme={theme} />;
|
|
198
200
|
case "Image":
|
|
199
201
|
return <ImageView key={key} node={node} data={data} theme={theme} />;
|
|
202
|
+
case "Select":
|
|
203
|
+
return <SelectView key={key} node={node} data={data} theme={theme} onAction={onAction} />;
|
|
200
204
|
case "Button":
|
|
201
205
|
return (
|
|
202
206
|
<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("");
|
|
@@ -13,8 +13,30 @@
|
|
|
13
13
|
* chat primitives (Conversation/Input/…) come in later slices.
|
|
14
14
|
*/
|
|
15
15
|
import { useCallback, useEffect, useMemo, useState, useSyncExternalStore, type CSSProperties, type ReactNode } from "react";
|
|
16
|
-
import {
|
|
17
|
-
|
|
16
|
+
import {
|
|
17
|
+
collectSurfacedViews,
|
|
18
|
+
cssWidth,
|
|
19
|
+
dispatchAction,
|
|
20
|
+
formatRelative,
|
|
21
|
+
isViewField,
|
|
22
|
+
orderedClaims,
|
|
23
|
+
propDefaults,
|
|
24
|
+
resolveActiveLayout,
|
|
25
|
+
resolveActiveView,
|
|
26
|
+
selectPointers,
|
|
27
|
+
viewOf,
|
|
28
|
+
type ComponentStore,
|
|
29
|
+
type ResolvedTheme,
|
|
30
|
+
type UnoverseClient,
|
|
31
|
+
type UnoverseDefinition,
|
|
32
|
+
type UnoverseNode,
|
|
33
|
+
} from "./core";
|
|
34
|
+
import { renderNode, styleToCss, keyframesCss, type ActionHandler, type SlotResolver } from "./render";
|
|
35
|
+
|
|
36
|
+
// The pure template derivations moved to core/templates.ts (the portable brain —
|
|
37
|
+
// framework-agnostic, guarded by sdk-core-portable.test.ts). Re-exported here so
|
|
38
|
+
// existing consumers keep their import path.
|
|
39
|
+
export { collectSurfacedViews, computeAppWidth, orderedClaims, resolveActiveLayout, resolveActiveView, selectPointers, viewOf } from "./core";
|
|
18
40
|
import { UnoverseComponent } from "./UnoverseComponent";
|
|
19
41
|
import { useUnoverseTheme, ensureFontStylesheets } from "./theme";
|
|
20
42
|
import { IsolatedRoot } from "./isolate";
|
|
@@ -49,203 +71,6 @@ const INERT_VOICE: UseVoiceServiceConfig = {
|
|
|
49
71
|
session: { userId: "", conversationId: "", workflowId: "", targetTriggerNode: "" },
|
|
50
72
|
};
|
|
51
73
|
|
|
52
|
-
/** The author's declared prop defaults (def.props[k].default) → a flat data object. Content, not policy. */
|
|
53
|
-
function propDefaults(props?: Record<string, unknown>): Record<string, unknown> {
|
|
54
|
-
const out: Record<string, unknown> = {};
|
|
55
|
-
for (const [k, v] of Object.entries(props ?? {})) {
|
|
56
|
-
if (v && typeof v === "object" && "default" in (v as Record<string, unknown>)) {
|
|
57
|
-
out[k] = (v as { default: unknown }).default;
|
|
58
|
-
}
|
|
59
|
-
}
|
|
60
|
-
return out;
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
/** Format a turn timestamp as relative copy ("just now" / "5 min ago"). Neutral
|
|
64
|
-
* projection of a model value (like `text`), not UX policy. Mirrors legacy
|
|
65
|
-
* ChatHistoryItem.formatRelativeTime. Empty string when unstamped. */
|
|
66
|
-
function formatRelative(ts?: number): string {
|
|
67
|
-
if (!ts) return "";
|
|
68
|
-
const diff = Math.floor((Date.now() - ts) / 1000);
|
|
69
|
-
if (diff < 60) return "just now";
|
|
70
|
-
if (diff < 3600) return `${Math.floor(diff / 60)} min ago`;
|
|
71
|
-
if (diff < 86400) return `${Math.floor(diff / 3600)} hr ago`;
|
|
72
|
-
return new Date(ts).toLocaleDateString();
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
/**
|
|
76
|
-
* Walk a template tree and collect every VIEW a reaction surface claims — the `eq`
|
|
77
|
-
* value of each `ComponentSlot.select.where { field: "defaultState" }`. State-selected
|
|
78
|
-
* UI (STATE_MODEL §5b): a component instance renders in exactly ONE placeholder. If a
|
|
79
|
-
* surface shows a given view, the conversation flow (the `inline` placeholder) must NOT
|
|
80
|
-
* also render instances in that view — else the same instance paints twice. A view no
|
|
81
|
-
* surface claims is absent from this set, so it stays in the flow (the fallback).
|
|
82
|
-
* Exported for testing.
|
|
83
|
-
*/
|
|
84
|
-
export function collectSurfacedViews(node: unknown, out: Set<string> = new Set()): Set<string> {
|
|
85
|
-
if (Array.isArray(node)) {
|
|
86
|
-
for (const n of node) collectSurfacedViews(n, out);
|
|
87
|
-
return out;
|
|
88
|
-
}
|
|
89
|
-
if (!node || typeof node !== "object") return out;
|
|
90
|
-
const n = node as Record<string, unknown>;
|
|
91
|
-
const sel = n.select as { where?: { field?: string; eq?: unknown } } | undefined;
|
|
92
|
-
if (n.type === "ComponentSlot" && sel?.where?.field === "defaultState" && typeof sel.where.eq === "string") {
|
|
93
|
-
out.add(sel.where.eq);
|
|
94
|
-
}
|
|
95
|
-
for (const v of Object.values(n)) if (v && typeof v === "object") collectSurfacedViews(v, out);
|
|
96
|
-
return out;
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
/**
|
|
100
|
-
* STATE-OWNED APP WIDTH (docs/design/05, layouts era): the app's outer width is
|
|
101
|
-
* declared by its LAYOUT's data, never the manifest. Two declaration points, both
|
|
102
|
-
* the neutral `appWidth` key (a named app size resolved via theme.appSize):
|
|
103
|
-
* - a plain node's `appWidth` = an always-open panel (the core column) — the
|
|
104
|
-
* layout's constant term.
|
|
105
|
-
* - a ComponentSlot surface's `appWidth` = its panel width, counted only while
|
|
106
|
-
* the surface is OCCUPIED (its view is the active state).
|
|
107
|
-
* The caller passes the ACTIVE layout (resolveActiveLayout) — layout swaps are
|
|
108
|
-
* whole-arrangement swaps, so the width is always the active layout's total.
|
|
109
|
-
* Activation is DERIVED, never stored. No declared width → undefined: the host
|
|
110
|
-
* keeps its own default, the sizing twin of "unmatched view falls back to inline".
|
|
111
|
-
* There is no other path.
|
|
112
|
-
*/
|
|
113
|
-
export function computeAppWidth(root: unknown, store: ComponentStore, appSize?: Record<string, string>): string | undefined {
|
|
114
|
-
// Resolve a declared width: a NAMED SIZE via theme.appSize (like any token), else raw
|
|
115
|
-
// CSS — guarded so an unresolvable name can never emit invalid CSS.
|
|
116
|
-
const rw = (w: unknown): string | undefined => cssWidth(typeof w === "string" ? (appSize?.[w] ?? w) : undefined);
|
|
117
|
-
const r = root as Record<string, unknown> | null | undefined;
|
|
118
|
-
const widths: string[] = [];
|
|
119
|
-
// Gather the tree's width-bearing nodes: always-open core panels and the reaction
|
|
120
|
-
// SURFACES (view → panel width).
|
|
121
|
-
const surfaces = new Map<string, string | undefined>();
|
|
122
|
-
const walk = (node: unknown): void => {
|
|
123
|
-
if (Array.isArray(node)) {
|
|
124
|
-
for (const c of node) walk(c);
|
|
125
|
-
return;
|
|
126
|
-
}
|
|
127
|
-
if (!node || typeof node !== "object") return;
|
|
128
|
-
const n = node as Record<string, unknown> & { appWidth?: string; type?: string; select?: { where?: { field?: string; eq?: unknown } } };
|
|
129
|
-
if (n !== r) {
|
|
130
|
-
if (n.type === "ComponentSlot") {
|
|
131
|
-
const claim = n.select?.where;
|
|
132
|
-
// `appWidth: flex` — the surface takes the REMAINING host space. DATA WINS: an
|
|
133
|
-
// org may define `flex` as a real appSize token (bpp: calc(100% − chat)) and
|
|
134
|
-
// that resolves like any name; the marker below is the theme-free fallback,
|
|
135
|
-
// resolved as "fill the host" when the view is active.
|
|
136
|
-
if (claim?.field === "defaultState" && typeof claim.eq === "string")
|
|
137
|
-
surfaces.set(claim.eq, rw(n.appWidth) ?? (n.appWidth === "flex" ? "flex" : undefined));
|
|
138
|
-
} else if (n.appWidth !== undefined) {
|
|
139
|
-
const w = rw(n.appWidth);
|
|
140
|
-
if (w) widths.push(w); // a plain panel (the core column) is ALWAYS open
|
|
141
|
-
}
|
|
142
|
-
}
|
|
143
|
-
for (const v of Object.values(n)) if (v && typeof v === "object") walk(v);
|
|
144
|
-
};
|
|
145
|
-
walk(r);
|
|
146
|
-
// ONE STATE AT A TIME (docs/design/04): the app = core + the ACTIVE state's panel —
|
|
147
|
-
// the most recently surfaced view. Panel combinations cannot exist, so the width is
|
|
148
|
-
// always one of a small known set, bounded by construction.
|
|
149
|
-
let active = "";
|
|
150
|
-
let activeAt = -1;
|
|
151
|
-
for (const turn of store.getResponses())
|
|
152
|
-
for (const p of turn.components) {
|
|
153
|
-
const { chatId, nodeId } = store.split(p);
|
|
154
|
-
const view = (store.get(chatId, nodeId) as Record<string, unknown>)?.defaultState;
|
|
155
|
-
if (typeof view === "string" && surfaces.has(view) && store.touchOf(p) > activeAt) {
|
|
156
|
-
activeAt = store.touchOf(p);
|
|
157
|
-
active = view;
|
|
158
|
-
}
|
|
159
|
-
}
|
|
160
|
-
const activePanel = active ? surfaces.get(active) : undefined;
|
|
161
|
-
// An ACTIVE flex surface fills the host viewport (the host caps at its pane); the
|
|
162
|
-
// surface flexes into whatever the fixed panels leave.
|
|
163
|
-
if (activePanel === "flex") return "100%";
|
|
164
|
-
if (activePanel) widths.push(activePanel);
|
|
165
|
-
if (!widths.length) return undefined;
|
|
166
|
-
return widths.length === 1 ? widths[0] : `calc(${widths.join(" + ")})`;
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
/**
|
|
170
|
-
* TEMPLATE LAYOUTS — name-sync (docs/design/05). A template with `layouts` is a set
|
|
171
|
-
* of full arrangements, the template-tier twin of a component's faces: the ACTIVE
|
|
172
|
-
* layout is the one whose NAME matches the latest surfaced component view, falling
|
|
173
|
-
* back to `defaultLayout` when nothing (or nothing matching) is surfaced. Same
|
|
174
|
-
* derivation as the active state — read off the store, never stored. Single-layout
|
|
175
|
-
* templates (`layouts` absent) resolve to `root`: the classic behavior, untouched.
|
|
176
|
-
*/
|
|
177
|
-
export function resolveActiveLayout(def: UnoverseDefinition | null | undefined, store: ComponentStore): UnoverseNode | undefined {
|
|
178
|
-
if (!def) return undefined;
|
|
179
|
-
const layouts = def.layouts;
|
|
180
|
-
if (!layouts) return def.root;
|
|
181
|
-
const { view } = resolveActiveView(def, store, new Set(Object.keys(layouts)));
|
|
182
|
-
return layouts[view] ?? layouts[def.defaultLayout ?? "main"] ?? def.root;
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
/**
|
|
186
|
-
* THE ACTIVE VIEW — the ONE derivation every consumer shares (docs/design/04 + the
|
|
187
|
-
* guest-control law). The latest surfaced component view among `validViews`, with
|
|
188
|
-
* PINNED enforcement: while a pinned layout's view is surfaced, a background arrival
|
|
189
|
-
* cannot displace it — only a guest write anywhere can. Layout choice and surface
|
|
190
|
-
* gating MUST both read this function: two loops that each pick "the latest view"
|
|
191
|
-
* disagree the moment pin overrides one of them — the layout renders view A while
|
|
192
|
-
* every surface inside it gates against view B, and the whole panel goes blank.
|
|
193
|
-
*/
|
|
194
|
-
export function resolveActiveView(
|
|
195
|
-
def: UnoverseDefinition | null | undefined,
|
|
196
|
-
store: ComponentStore,
|
|
197
|
-
validViews: Set<string>,
|
|
198
|
-
): { view: string; byGuest: boolean } {
|
|
199
|
-
let view = "";
|
|
200
|
-
let at = -1;
|
|
201
|
-
let byGuest = false;
|
|
202
|
-
let pinnedView = "";
|
|
203
|
-
let pinnedAt = -1;
|
|
204
|
-
for (const turn of store.getResponses())
|
|
205
|
-
for (const p of turn.components) {
|
|
206
|
-
const { chatId, nodeId } = store.split(p);
|
|
207
|
-
const v = (store.get(chatId, nodeId) as Record<string, unknown>)?.defaultState;
|
|
208
|
-
if (typeof v !== "string" || !validViews.has(v)) continue;
|
|
209
|
-
const t = store.touchOf(p);
|
|
210
|
-
if (t > at) {
|
|
211
|
-
at = t;
|
|
212
|
-
view = v;
|
|
213
|
-
byGuest = store.interactionTouchOf(p) >= t;
|
|
214
|
-
}
|
|
215
|
-
if ((def?.layouts?.[v] as { pinned?: boolean } | undefined)?.pinned && t > pinnedAt) {
|
|
216
|
-
pinnedAt = t;
|
|
217
|
-
pinnedView = v;
|
|
218
|
-
}
|
|
219
|
-
}
|
|
220
|
-
if (pinnedView && view !== pinnedView && !byGuest) return { view: pinnedView, byGuest: false };
|
|
221
|
-
return { view, byGuest };
|
|
222
|
-
}
|
|
223
|
-
|
|
224
|
-
/** Resolve a ComponentSlot's `select` against the store → ordered pointers. Exported for testing. */
|
|
225
|
-
export function selectPointers(store: ComponentStore, node: UnoverseNode): string[] {
|
|
226
|
-
const sel = node.select ?? {};
|
|
227
|
-
let pointers =
|
|
228
|
-
sel.from === "all"
|
|
229
|
-
? store.getResponses().flatMap((r) => r.components)
|
|
230
|
-
: (store.latestResponse()?.components ?? []);
|
|
231
|
-
|
|
232
|
-
// `where` — select by COMPONENT STATE (the reaction contract, STATE_MODEL §5b): any
|
|
233
|
-
// component whose own slice matches, ordered newest state-write first so `limit: 1`
|
|
234
|
-
// = "most recent wins". The generic replacement for type-pinned surfaces — a
|
|
235
|
-
// template reacts to whatever component enters the state, never to a named type.
|
|
236
|
-
if (sel.where) {
|
|
237
|
-
const { field, eq, ne } = sel.where;
|
|
238
|
-
pointers = pointers.filter((p) => {
|
|
239
|
-
const { chatId, nodeId } = store.split(p);
|
|
240
|
-
const v = (store.get(chatId, nodeId) as Record<string, unknown>)[field];
|
|
241
|
-
return ne !== undefined ? v !== ne : v === eq;
|
|
242
|
-
});
|
|
243
|
-
pointers = [...pointers].sort((a, b) => store.touchOf(b) - store.touchOf(a));
|
|
244
|
-
}
|
|
245
|
-
if (sel.limit != null) pointers = pointers.slice(0, sel.limit);
|
|
246
|
-
return pointers;
|
|
247
|
-
}
|
|
248
|
-
|
|
249
74
|
export function StreamedUnoverseTemplate({ client, store, uri, onAction, theme: themeProp, props, isolate = true, voice: voiceConfig }: StreamedUnoverseTemplateProps) {
|
|
250
75
|
const [def, setDef] = useState<UnoverseDefinition | null>(null);
|
|
251
76
|
const [error, setError] = useState<string | null>(null);
|
|
@@ -271,7 +96,7 @@ export function StreamedUnoverseTemplate({ client, store, uri, onAction, theme:
|
|
|
271
96
|
// to their own slices — so streaming chunks never re-walk the whole template tree.
|
|
272
97
|
// Stable subscribe fn: an inline arrow would tear down + re-create the subscription
|
|
273
98
|
// every render.
|
|
274
|
-
useSyncExternalStore(
|
|
99
|
+
const structureVersion = useSyncExternalStore(
|
|
275
100
|
useCallback((cb) => store.subscribe(cb), [store]),
|
|
276
101
|
useCallback(() => store.getStructureVersion(), [store]),
|
|
277
102
|
);
|
|
@@ -289,18 +114,51 @@ export function StreamedUnoverseTemplate({ client, store, uri, onAction, theme:
|
|
|
289
114
|
};
|
|
290
115
|
}, [client, uri]);
|
|
291
116
|
|
|
292
|
-
//
|
|
293
|
-
//
|
|
294
|
-
//
|
|
117
|
+
// ARRIVAL PLACEMENT (STATE_MODEL v2 §5 rule 3): nobody sends a state — the HOST
|
|
118
|
+
// places the actor at spawn. For each arrived component whose slice has no view yet:
|
|
119
|
+
// 1. the component's DECLARED INITIAL, if this template has a state for it (the
|
|
120
|
+
// component's preference stands whenever the host can honor it);
|
|
121
|
+
// 2. else the SCAN — the first name in THIS template's declared order that the
|
|
122
|
+
// component's public menu (`publicStates`, server-injected at init) also has;
|
|
123
|
+
// 3. no overlap at all → leave unset: the component wakes in its own initial and
|
|
124
|
+
// rides inline (rule 6).
|
|
125
|
+
// The write is a data merge (never an interaction touch — placement is not the
|
|
126
|
+
// guest's hand), it happens ONCE per instance (a placed slice has a view), and the
|
|
127
|
+
// component owns the axis from then on: the template never writes it again.
|
|
128
|
+
useEffect(() => {
|
|
129
|
+
const order = def?.stateOrder;
|
|
130
|
+
if (!order?.length) return;
|
|
131
|
+
for (const turn of store.getResponses())
|
|
132
|
+
for (const p of turn.components) {
|
|
133
|
+
const { chatId, nodeId } = store.split(p);
|
|
134
|
+
const slice = store.get(chatId, nodeId) as Record<string, unknown> | undefined;
|
|
135
|
+
if (!slice || viewOf(slice) !== undefined) continue;
|
|
136
|
+
const menu = Array.isArray(slice.publicStates) ? (slice.publicStates as unknown[]) : undefined;
|
|
137
|
+
if (!menu) continue;
|
|
138
|
+
const initial = typeof slice.initialView === "string" ? slice.initialView : undefined;
|
|
139
|
+
const face = initial && order.includes(initial) ? initial : order.find((s) => menu.includes(s));
|
|
140
|
+
if (face) store.apply({ type: "COMPONENT_DATA", chatId, nodeId, data: { view: face } });
|
|
141
|
+
}
|
|
142
|
+
}, [def, store, structureVersion]);
|
|
143
|
+
|
|
144
|
+
// THE SEVEN RULES (2026-08-09) need no effect here: a higher state MASKS the ones
|
|
145
|
+
// below (rule 4 — the ladder alone decides what shows; lower states refresh
|
|
146
|
+
// silently underneath via the store's arrival supersession), and releasing the top
|
|
147
|
+
// state is just the ladder looking again (rule 6). The earlier cancellation WRITE
|
|
148
|
+
// (cancelBelow) was superseded by this pure-masking ruling and deleted.
|
|
149
|
+
|
|
150
|
+
// STATE OWNS LAYOUT (STATE_MODEL v2): the tree this template presents — the active
|
|
151
|
+
// state's layout (multi-layout templates), else the classic root. The resolved node
|
|
152
|
+
// is a stable per-def object, so downstream memos key off it.
|
|
295
153
|
const activeRoot = def ? resolveActiveLayout(def, store) : undefined;
|
|
296
154
|
|
|
297
|
-
// The set
|
|
298
|
-
//
|
|
299
|
-
// The timeline uses it to keep a claimed instance out of the flow (one
|
|
300
|
-
// placeholder).
|
|
155
|
+
// The CLAIM set — the states this template reacts to. v2: the declared order
|
|
156
|
+
// (stateOrder) IS the claim list; legacy defs fall back to layout names + surface
|
|
157
|
+
// claims. The timeline uses it to keep a claimed instance out of the flow (one
|
|
158
|
+
// instance → one placeholder).
|
|
301
159
|
const surfacedViews = useMemo(() => {
|
|
302
|
-
const views =
|
|
303
|
-
|
|
160
|
+
const views = new Set<string>(orderedClaims(def));
|
|
161
|
+
if (activeRoot) collectSurfacedViews(activeRoot, views);
|
|
304
162
|
return views;
|
|
305
163
|
}, [activeRoot, def]);
|
|
306
164
|
|
|
@@ -326,16 +184,17 @@ export function StreamedUnoverseTemplate({ client, store, uri, onAction, theme:
|
|
|
326
184
|
if (action === "backToChat") voice.endCall();
|
|
327
185
|
}
|
|
328
186
|
// GUEST CLOSE from TEMPLATE CHROME: a ✕ on the layout itself (a rail/panel header —
|
|
329
|
-
// not inside a single card) fires the CloseButton atom's `setValue
|
|
330
|
-
// Template chrome has no card slice to write, so
|
|
331
|
-
// means "un-surface the current view" → the
|
|
332
|
-
//
|
|
333
|
-
//
|
|
187
|
+
// not inside a single card) fires the CloseButton atom's `setValue view:"inline"`
|
|
188
|
+
// (legacy defs write `defaultState`). Template chrome has no card slice to write, so
|
|
189
|
+
// that write would be inert; instead it means "un-surface the current view" → the
|
|
190
|
+
// store retracts every surface to its declared base (the same reset a new turn
|
|
191
|
+
// runs). A card that closes ITSELF keeps using the plain component write (real
|
|
192
|
+
// ids), untouched. `then` still chains via dispatchAction below.
|
|
334
193
|
if (
|
|
335
194
|
action && typeof action === "object" &&
|
|
336
195
|
(action as { type?: string }).type === "setValue" &&
|
|
337
196
|
(action as { values?: { key?: string; value?: unknown }[] }).values?.some(
|
|
338
|
-
(v) => v.key
|
|
197
|
+
(v) => isViewField(v.key) && v.value === "inline",
|
|
339
198
|
)
|
|
340
199
|
) {
|
|
341
200
|
store.closeSurfaces();
|
|
@@ -375,7 +234,7 @@ export function StreamedUnoverseTemplate({ client, store, uri, onAction, theme:
|
|
|
375
234
|
// data is untouched and re-presents the moment its state is active again.
|
|
376
235
|
// Surfaces never stack; panel combinations cannot exist.
|
|
377
236
|
const claim = node.select?.where;
|
|
378
|
-
if (claim?.field
|
|
237
|
+
if (isViewField(claim?.field) && typeof claim?.eq === "string" && claim.eq !== surfacedView) {
|
|
379
238
|
return node.fallback ? renderNode(node.fallback, {}, dispatch, theme, key ?? "fallback") : null;
|
|
380
239
|
}
|
|
381
240
|
const pointers = selectPointers(store, node);
|
|
@@ -427,8 +286,8 @@ export function StreamedUnoverseTemplate({ client, store, uri, onAction, theme:
|
|
|
427
286
|
turn.role === "assistant"
|
|
428
287
|
? turn.components.filter((p) => {
|
|
429
288
|
const { chatId, nodeId } = store.split(p);
|
|
430
|
-
const view = (store.get(chatId, nodeId) as Record<string, unknown> | undefined)
|
|
431
|
-
return
|
|
289
|
+
const view = viewOf(store.get(chatId, nodeId) as Record<string, unknown> | undefined);
|
|
290
|
+
return !view || !surfacedViews.has(view);
|
|
432
291
|
})
|
|
433
292
|
: [];
|
|
434
293
|
// Slot scoped to this turn's UNCLAIMED components (assistant turns only).
|