@unoverse-platform/studio 0.1.27 → 0.1.29
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/local/plugin.ts +6 -1
- package/package.json +1 -1
- package/screens/TemplatesView.tsx +1 -1
- package/vendor/sdk/appEngine.tsx +11 -4
- package/vendor/sdk/lib/UnoverseComponent.tsx +7 -1
- package/vendor/sdk/lib/core/client.ts +39 -12
- package/vendor/sdk/lib/core/index.ts +3 -0
- package/vendor/sdk/lib/core/refs.ts +117 -0
- package/vendor/sdk/lib/core/store.ts +25 -0
- package/vendor/sdk/lib/core/templates.ts +24 -6
- package/vendor/sdk/lib/core/types.ts +10 -0
- package/vendor/sdk/lib/orb/ThinkingOrb.tsx +5 -1
- package/vendor/sdk/lib/orb/engine/core.ts +10 -0
- package/vendor/sdk/lib/orb/engine/registry.ts +7 -0
- package/vendor/sdk/lib/orb/engine/rings.ts +239 -0
- package/vendor/sdk/lib/orb/presets.ts +81 -4
- package/vendor/sdk/lib/orb/types.ts +12 -1
- package/vendor/sdk/lib/primitives.tsx +8 -3
- package/vendor/sdk/lib/render.tsx +23 -3
- package/vendor/sdk/lib/template.tsx +28 -13
package/local/plugin.ts
CHANGED
|
@@ -368,7 +368,12 @@ export function localStudioHost(): Plugin {
|
|
|
368
368
|
// Apps are addressed both ways (unoverse://apps/<org>/<id> and
|
|
369
369
|
// unoverse://templates/<org>/<id>); both load the template kind.
|
|
370
370
|
const kind = /\/(apps|templates)\//.test(uri) ? "template" : /\/atoms\//.test(uri) ? "atom" : "component";
|
|
371
|
-
|
|
371
|
+
// A trailing `@form` is a request for a VARIANT of the definition, not part of
|
|
372
|
+
// its name. This door did not know that convention and looked up the whole
|
|
373
|
+
// string, so every component read as `<name>@refs` answered "definition not
|
|
374
|
+
// found". Strip any variant: this route serves the one form it has, which is
|
|
375
|
+
// also the only form the server serves.
|
|
376
|
+
const ref = uri.replace(/^unoverse:\/\/(components|atoms|apps|templates)\//, "").replace(/@[a-z]+$/, "");
|
|
372
377
|
const d = c.loadDefinition(ref, kind);
|
|
373
378
|
return d ? json(res, d) : json(res, { error: `definition not found: ${uri}` }, 404);
|
|
374
379
|
}
|
package/package.json
CHANGED
|
@@ -988,7 +988,7 @@ export function TemplatesView({
|
|
|
988
988
|
// in-page store is dead there and computeAppWidth against it lies.
|
|
989
989
|
width: liveIsNative
|
|
990
990
|
? (nativeWidth ?? "100%")
|
|
991
|
-
: (computeAppWidth(resolveActiveLayout(selectedDef as never, store), store, theme?.appSize) ?? "100%"),
|
|
991
|
+
: (computeAppWidth(resolveActiveLayout(selectedDef as never, store), store, theme?.appSize, selectedDef as never) ?? "100%"),
|
|
992
992
|
margin: "0 auto",
|
|
993
993
|
transition: "width 200ms",
|
|
994
994
|
...((selectedDef?.fluidHeight ?? true) ? { height: "100%" } : {}),
|
package/vendor/sdk/appEngine.tsx
CHANGED
|
@@ -21,6 +21,7 @@ import {
|
|
|
21
21
|
setAnalyticsContext,
|
|
22
22
|
setAnalyticsDebug,
|
|
23
23
|
type UseVoiceServiceConfig,
|
|
24
|
+
type UnoverseDefinition,
|
|
24
25
|
} from "./lib";
|
|
25
26
|
|
|
26
27
|
export interface AppConfig {
|
|
@@ -56,7 +57,14 @@ export function AppEngine(cfg: AppConfig) {
|
|
|
56
57
|
// STATE-OWNED WIDTH (rx data): the layout root's `appWidth` = the core surface's
|
|
57
58
|
// constant width; an occupied panel slot's `appWidth` adds to it (computeAppWidth).
|
|
58
59
|
// No declared width → the host keeps its own default. There is no other path.
|
|
59
|
-
|
|
60
|
+
// THE WHOLE DEFINITION, not a chosen four fields. This used to keep `{root, org, layouts,
|
|
61
|
+
// defaultLayout}` and drop everything else, which is a whitelist that silently loses each
|
|
62
|
+
// field the contract gains. It was dropping `stateOrder`, `publicStates`, `initialView`
|
|
63
|
+
// and `stateTree`, quietly running the v2 ladder in legacy mode, and it dropped `defs`
|
|
64
|
+
// too: with shared pieces unreachable, the core panel's `appWidth` went uncounted and the
|
|
65
|
+
// app's width collapsed to the open surface's remainder term, which is negative, so the
|
|
66
|
+
// app rendered 0 pixels wide and blank.
|
|
67
|
+
const [def, setDef] = useState<UnoverseDefinition | null>(null);
|
|
60
68
|
const lastWidthRef = useRef<string | undefined>(undefined);
|
|
61
69
|
// Named app sizes resolve via the served theme (theme.appSize), like every token.
|
|
62
70
|
const theme = useUnoverseTheme(client, def ? (def.org ? `${def.org}/light` : "light") : null);
|
|
@@ -82,8 +90,7 @@ export function AppEngine(cfg: AppConfig) {
|
|
|
82
90
|
.connect()
|
|
83
91
|
.then(() => client.readDefinition(`unoverse://templates/${templateId}`))
|
|
84
92
|
.then((d) => {
|
|
85
|
-
|
|
86
|
-
if (alive) setDef(t ? { root: t.root, org: t.org, layouts: t.layouts, defaultLayout: t.defaultLayout } : null);
|
|
93
|
+
if (alive) setDef(d ?? null);
|
|
87
94
|
})
|
|
88
95
|
.catch((e) => console.error("[app] template read failed:", e));
|
|
89
96
|
return () => {
|
|
@@ -114,7 +121,7 @@ export function AppEngine(cfg: AppConfig) {
|
|
|
114
121
|
const report = () => {
|
|
115
122
|
// NAME-SYNC: width is computed off the ACTIVE layout (multi-layout templates
|
|
116
123
|
// swap whole arrangements); single-layout defs resolve to their root.
|
|
117
|
-
const w = computeAppWidth(resolveActiveLayout(def
|
|
124
|
+
const w = computeAppWidth(resolveActiveLayout(def, store), store, theme?.appSize, def);
|
|
118
125
|
if (w && w !== lastWidthRef.current) {
|
|
119
126
|
lastWidthRef.current = w;
|
|
120
127
|
window.parent?.postMessage({ type: "unoverse:size", width: w }, "*");
|
|
@@ -83,6 +83,10 @@ export function UnoverseComponent({ client, uri, data, store, chatId, nodeId, ex
|
|
|
83
83
|
// LIVE SOURCE. The hook runs either way (hooks cannot be conditional) and no-ops without a
|
|
84
84
|
// store, so one component serves both paths without a wrapper deciding for it.
|
|
85
85
|
const live = useUnoverseInstance(store, chatId, nodeId);
|
|
86
|
+
// The BARE URI, deliberately. This asked for the referenced form (`@refs`) while that
|
|
87
|
+
// form existed; it is withdrawn (server resources.ts refForm), so asking for it now only
|
|
88
|
+
// spreads a suffix that every door has to know how to strip. Studio's own local
|
|
89
|
+
// definition route did not, and answered "definition not found" for every component.
|
|
86
90
|
const resolvedUri = store ? (live.type ? `unoverse://components/${live.type}` : null) : (uri ?? null);
|
|
87
91
|
const sourceData = store ? (extraData ? { ...(live.data ?? {}), ...extraData } : live.data) : data;
|
|
88
92
|
// No theme passed → fetch it from the server (the SDK owns zero token values). The
|
|
@@ -179,7 +183,9 @@ export function UnoverseComponent({ client, uri, data, store, chatId, nodeId, ex
|
|
|
179
183
|
// host owns isolation, so apply it here on a `display:contents` wrapper (out of layout while
|
|
180
184
|
// its inherited props cascade) — the design-system baseline either way.
|
|
181
185
|
const keyframes = <style>{keyframesCss(activeTheme)}</style>;
|
|
182
|
-
|
|
186
|
+
// The shared pieces this definition NAMES travel beside it and are threaded through
|
|
187
|
+
// every recursion, so a reference nested in a Switch branch or an Each item resolves too.
|
|
188
|
+
const tree = renderNode(def.root, merged, handleAction, activeTheme, undefined, undefined, def.defs);
|
|
183
189
|
// The isolation container MIRRORS the component root's declared sizing intent (§4a:
|
|
184
190
|
// the component owns its size; the host responds). A root that declares
|
|
185
191
|
// `height: "full"` needs a definite chain — the container passes the host's height
|
|
@@ -28,6 +28,17 @@ export class UnoverseClient {
|
|
|
28
28
|
private connecting: Promise<void> | null = null;
|
|
29
29
|
private cache = new Map<string, UnoverseDefinition>();
|
|
30
30
|
private themeCache = new Map<string, ResolvedTheme>();
|
|
31
|
+
/**
|
|
32
|
+
* IN-FLIGHT reads, so N askers cost ONE request.
|
|
33
|
+
*
|
|
34
|
+
* The caches above are written only after `await`, so every instance mounting in the
|
|
35
|
+
* same tick missed and issued its own identical `resources/read`: ten cards of one type
|
|
36
|
+
* meant ten copies of that definition over the wire, and on a link where a round trip
|
|
37
|
+
* costs 300ms they queue behind each other. Sharing the PROMISE is the whole fix; the
|
|
38
|
+
* value cache still serves everything after the first resolve.
|
|
39
|
+
*/
|
|
40
|
+
private inflight = new Map<string, Promise<UnoverseDefinition>>();
|
|
41
|
+
private themeInflight = new Map<string, Promise<ResolvedTheme>>();
|
|
31
42
|
|
|
32
43
|
constructor(
|
|
33
44
|
private readonly url: string,
|
|
@@ -118,12 +129,21 @@ export class UnoverseClient {
|
|
|
118
129
|
async readDefinition(uri: string): Promise<UnoverseDefinition> {
|
|
119
130
|
const cached = this.cache.get(uri);
|
|
120
131
|
if (cached) return cached;
|
|
121
|
-
const
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
132
|
+
const pending = this.inflight.get(uri);
|
|
133
|
+
if (pending) return pending;
|
|
134
|
+
const read = (async () => {
|
|
135
|
+
const res = await this.withSession(() => this.client.readResource({ uri }));
|
|
136
|
+
const first = res.contents[0] as { text?: string };
|
|
137
|
+
if (!first?.text) throw new Error(`No definition content for ${uri}`);
|
|
138
|
+
const def = JSON.parse(first.text) as UnoverseDefinition;
|
|
139
|
+
this.cache.set(uri, def);
|
|
140
|
+
return def;
|
|
141
|
+
})();
|
|
142
|
+
this.inflight.set(uri, read);
|
|
143
|
+
// Cleared either way: a failed read must not be remembered as pending forever, or the
|
|
144
|
+
// next asker waits on a promise that will never resolve again.
|
|
145
|
+
read.finally(() => this.inflight.delete(uri));
|
|
146
|
+
return read;
|
|
127
147
|
}
|
|
128
148
|
|
|
129
149
|
/**
|
|
@@ -133,12 +153,19 @@ export class UnoverseClient {
|
|
|
133
153
|
async readTheme(name = "light"): Promise<ResolvedTheme> {
|
|
134
154
|
const cached = this.themeCache.get(name);
|
|
135
155
|
if (cached) return cached;
|
|
136
|
-
const
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
156
|
+
const pending = this.themeInflight.get(name);
|
|
157
|
+
if (pending) return pending;
|
|
158
|
+
const read = (async () => {
|
|
159
|
+
const res = await this.withSession(() => this.client.readResource({ uri: `unoverse://theme/${name}` }));
|
|
160
|
+
const first = res.contents[0] as { text?: string };
|
|
161
|
+
if (!first?.text) throw new Error(`No theme content for ${name}`);
|
|
162
|
+
const theme = JSON.parse(first.text) as ResolvedTheme;
|
|
163
|
+
this.themeCache.set(name, theme);
|
|
164
|
+
return theme;
|
|
165
|
+
})();
|
|
166
|
+
this.themeInflight.set(name, read);
|
|
167
|
+
read.finally(() => this.themeInflight.delete(name));
|
|
168
|
+
return read;
|
|
142
169
|
}
|
|
143
170
|
|
|
144
171
|
/** List available theme names (e.g. ["light", "dark"]) from the server. */
|
|
@@ -60,3 +60,6 @@ export {
|
|
|
60
60
|
|
|
61
61
|
// The shared "react to state" predicate (visibleWhen / Switch / style.when).
|
|
62
62
|
export { matches, visible, selectCase } from "./conditions";
|
|
63
|
+
// REFERENCE, DON'T COPY: specialising a `Ref` against a shared definition, so the design
|
|
64
|
+
// system travels once instead of being pasted into every definition that uses it.
|
|
65
|
+
export { resolveRef } from "./refs";
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* REFERENCE, DON'T COPY — resolving a `Ref` at render time.
|
|
3
|
+
*
|
|
4
|
+
* A design system's whole promise is "define once, use everywhere". Until now the server
|
|
5
|
+
* kept that promise by INLINING: every `Ref` was replaced with a specialised copy of the
|
|
6
|
+
* atom before serving, so the browser received one flat tree with the same button, the
|
|
7
|
+
* same field, pasted in at every use. Measured on the estate at the time this landed:
|
|
8
|
+
* 1259 KB served where 224 KB of distinct structure existed, and one card
|
|
9
|
+
* (`sab/product-card`) weighed 361 KB because it referenced a document that itself
|
|
10
|
+
* carried six copies of an eleven-branch field switch. Every page load paid it again,
|
|
11
|
+
* because MCP reads go over POST and no browser cache can hold them.
|
|
12
|
+
*
|
|
13
|
+
* A `Ref` is a PURE FUNCTION of the atom and the reference's own parameters:
|
|
14
|
+
*
|
|
15
|
+
* resolve(atom, { props, with, style, action, visibleWhen }) → tree
|
|
16
|
+
*
|
|
17
|
+
* `props` remaps the atom's field names onto the host's; `with` bakes per-use literals in;
|
|
18
|
+
* style, action and visibleWhen override. Nothing in that needs a server, which is why it
|
|
19
|
+
* belongs here: the definition then carries the reference (tiny) and the atom travels once
|
|
20
|
+
* and is cached by URI, shared by every definition that names it.
|
|
21
|
+
*
|
|
22
|
+
* PORTABLE BRAIN (UNOVERSE_SDK_PORTING §1): this is core, so every SDK port implements the
|
|
23
|
+
* same resolution and the fixtures prove they agree. A port that resolves `with` differently
|
|
24
|
+
* renders different copy from the same definition, which is exactly the class of drift the
|
|
25
|
+
* conformance suite exists to catch.
|
|
26
|
+
*
|
|
27
|
+
* A 1:1 port of the server's expansion (packages/base/src/definitions/definitions.ts:
|
|
28
|
+
* remapFields / applyLiterals / applyStyleLiterals / expandNode's Ref branch). The two must
|
|
29
|
+
* stay in step while the server still inlines for older clients.
|
|
30
|
+
*/
|
|
31
|
+
import type { UnoverseNode } from "./types";
|
|
32
|
+
|
|
33
|
+
type AnyNode = Record<string, any>;
|
|
34
|
+
|
|
35
|
+
/** Deep clone. The atom is CACHED and shared, so specialising it in place would poison
|
|
36
|
+
* every other user of that atom with one host's literals. */
|
|
37
|
+
const clone = <T,>(v: T): T => JSON.parse(JSON.stringify(v)) as T;
|
|
38
|
+
|
|
39
|
+
/** `props` remaps the atom's OWN field names to the host's fields, everywhere a field name
|
|
40
|
+
* can appear: binds, both spellings of `visibleWhen`, and `style.when` predicates. */
|
|
41
|
+
function remapFields(node: AnyNode, propMap: Record<string, string>): void {
|
|
42
|
+
if (node.bind) for (const k of Object.keys(node.bind)) if (propMap[node.bind[k]]) node.bind[k] = propMap[node.bind[k]];
|
|
43
|
+
if (typeof node.visibleWhen === "string" && propMap[node.visibleWhen]) node.visibleWhen = propMap[node.visibleWhen];
|
|
44
|
+
// The OBJECT form remaps too. A guard is a guard whichever way it is written; carrying
|
|
45
|
+
// only the string form left an atom testing its own prop name after the host remapped it,
|
|
46
|
+
// so the guard read an absent key and the branch never showed.
|
|
47
|
+
if (node.visibleWhen && typeof node.visibleWhen === "object" && !Array.isArray(node.visibleWhen)) {
|
|
48
|
+
const g = node.visibleWhen as { field?: string };
|
|
49
|
+
if (typeof g.field === "string" && propMap[g.field]) g.field = propMap[g.field];
|
|
50
|
+
}
|
|
51
|
+
// `style.when` is the same predicate wearing a different hat: an atom whose LOOK is
|
|
52
|
+
// data-driven must follow its host's field for the same reason.
|
|
53
|
+
const when = (node.style as AnyNode | undefined)?.when;
|
|
54
|
+
if (Array.isArray(when)) {
|
|
55
|
+
for (const v of when as { field?: string }[]) if (v && typeof v.field === "string" && propMap[v.field]) v.field = propMap[v.field];
|
|
56
|
+
}
|
|
57
|
+
if (Array.isArray(node.children)) node.children.forEach((c: AnyNode) => remapFields(c, propMap));
|
|
58
|
+
if (node.template) remapFields(node.template as AnyNode, propMap);
|
|
59
|
+
if (node.cases && typeof node.cases === "object") for (const k of Object.keys(node.cases)) remapFields(node.cases[k] as AnyNode, propMap);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** `{{name}}` substitution at any depth of a style object: a radial's `at`, a `when`
|
|
63
|
+
* clause's `apply`. Only the top level was walked once, and a nested one reached the
|
|
64
|
+
* renderer verbatim. */
|
|
65
|
+
function applyStyleLiterals(style: unknown, lits: Record<string, unknown>): void {
|
|
66
|
+
if (Array.isArray(style)) return style.forEach((v) => applyStyleLiterals(v, lits));
|
|
67
|
+
if (!style || typeof style !== "object") return;
|
|
68
|
+
const s = style as AnyNode;
|
|
69
|
+
for (const k of Object.keys(s)) {
|
|
70
|
+
const v = s[k];
|
|
71
|
+
if (typeof v === "string" && v.includes("{{")) s[k] = v.replace(/\{\{(\w[\w.]*)\}\}/g, (m, f) => (f in lits ? String(lits[f]) : m));
|
|
72
|
+
else if (v && typeof v === "object") applyStyleLiterals(v, lits);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** `with` resolves an atom's BINDINGS to literal content: a bound attribute becomes a
|
|
77
|
+
* hardcoded one, and a guard on a truthy literal is satisfied statically and dropped. */
|
|
78
|
+
function applyLiterals(node: AnyNode, lits: Record<string, unknown>): void {
|
|
79
|
+
if (node.bind) {
|
|
80
|
+
for (const attr of Object.keys(node.bind)) {
|
|
81
|
+
const field = node.bind[attr];
|
|
82
|
+
if (typeof field === "string" && field in lits) {
|
|
83
|
+
node[attr] = lits[field];
|
|
84
|
+
delete node.bind[attr];
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
if (Object.keys(node.bind).length === 0) delete node.bind;
|
|
88
|
+
}
|
|
89
|
+
if (typeof node.visibleWhen === "string" && node.visibleWhen in lits) {
|
|
90
|
+
if (lits[node.visibleWhen]) delete node.visibleWhen; // statically satisfied
|
|
91
|
+
// a falsy literal KEEPS the (now never-true) guard, so the node stays hidden
|
|
92
|
+
}
|
|
93
|
+
if (node.style && typeof node.style === "object") applyStyleLiterals(node.style as AnyNode, lits);
|
|
94
|
+
if (Array.isArray(node.children)) node.children.forEach((c: AnyNode) => applyLiterals(c, lits));
|
|
95
|
+
if (node.template) applyLiterals(node.template as AnyNode, lits);
|
|
96
|
+
if (node.cases && typeof node.cases === "object") for (const k of Object.keys(node.cases)) applyLiterals(node.cases[k] as AnyNode, lits);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Specialise `atomRoot` for one `Ref`. Returns a NEW tree; the atom is untouched.
|
|
101
|
+
*
|
|
102
|
+
* Returns null when the atom is not available, and the caller renders nothing rather than
|
|
103
|
+
* a broken box: a missing atom is a serving problem, and a hole is easier to diagnose than
|
|
104
|
+
* a mystery element.
|
|
105
|
+
*/
|
|
106
|
+
export function resolveRef(refNode: UnoverseNode, atomRoot: UnoverseNode | undefined | null): UnoverseNode | null {
|
|
107
|
+
if (!atomRoot) return null;
|
|
108
|
+
const n = refNode as AnyNode;
|
|
109
|
+
const root = clone(atomRoot) as AnyNode;
|
|
110
|
+
remapFields(root, (n.props ?? {}) as Record<string, string>);
|
|
111
|
+
if (n.with && typeof n.with === "object") applyLiterals(root, n.with as Record<string, unknown>);
|
|
112
|
+
// The reference's own overrides win: these are the per-host facts an atom cannot carry.
|
|
113
|
+
if (n.visibleWhen) root.visibleWhen = n.visibleWhen;
|
|
114
|
+
if (n.action) root.action = n.action;
|
|
115
|
+
if (n.style) root.style = { ...((root.style as AnyNode) ?? {}), ...(n.style as AnyNode) };
|
|
116
|
+
return root as UnoverseNode;
|
|
117
|
+
}
|
|
@@ -196,6 +196,31 @@ export class ComponentStore {
|
|
|
196
196
|
if (changed) this.bump();
|
|
197
197
|
}
|
|
198
198
|
|
|
199
|
+
/**
|
|
200
|
+
* A HIGHER STATE CLEARS THE STATES BELOW IT (STATE_MODEL §5, THE SEVEN RULES rule 4) —
|
|
201
|
+
* when a DELIVERY put it there. Every component sitting in a lower-ranked state releases
|
|
202
|
+
* its claim, so rule 6's re-walk finds nothing below and lands on the BASE. The caller
|
|
203
|
+
* decides delivery-vs-guest (`resolveActiveView().byGuest`): the guest's own navigation
|
|
204
|
+
* clears nothing, or closing a page would have nothing to return to.
|
|
205
|
+
*
|
|
206
|
+
* Retracts to `inline`, not through `retractSlice`: that helper targets the slice's
|
|
207
|
+
* declared initial, and a card whose initial IS the lower state would re-claim it on
|
|
208
|
+
* the next walk and never settle.
|
|
209
|
+
*/
|
|
210
|
+
cancelBelow(active: string, order: string[]): void {
|
|
211
|
+
const below = new Set(order.slice(order.indexOf(active) + 1));
|
|
212
|
+
if (order.indexOf(active) < 0 || !below.size) return;
|
|
213
|
+
let changed = false;
|
|
214
|
+
for (const [key, slice] of this.data) {
|
|
215
|
+
if (slice.lifetime === "conversation") continue;
|
|
216
|
+
if (!below.has((slice.view ?? slice.defaultState) as string)) continue;
|
|
217
|
+
this.data.set(key, { ...slice, ...("defaultState" in slice ? { defaultState: "inline" } : { view: "inline" }) });
|
|
218
|
+
this.touches.set(key, ++this.touchSeq);
|
|
219
|
+
changed = true;
|
|
220
|
+
}
|
|
221
|
+
if (changed) this.bump();
|
|
222
|
+
}
|
|
223
|
+
|
|
199
224
|
closeSurfaces(): void {
|
|
200
225
|
let changed = false;
|
|
201
226
|
for (const [key, slice] of this.data) {
|
|
@@ -36,8 +36,9 @@ export const isViewField = (f: unknown): boolean => f === "view" || f === "defau
|
|
|
36
36
|
export function orderedClaims(def: UnoverseDefinition | null | undefined): string[] {
|
|
37
37
|
if (def?.stateOrder?.length) return def.stateOrder;
|
|
38
38
|
const legacy = new Set<string>(Object.keys(def?.layouts ?? {}));
|
|
39
|
-
|
|
40
|
-
|
|
39
|
+
const defs = def?.defs as Record<string, unknown> | undefined;
|
|
40
|
+
if (def?.layouts) for (const l of Object.values(def.layouts)) collectSurfacedViews(l, legacy, defs);
|
|
41
|
+
else if (def?.root) collectSurfacedViews(def.root, legacy, defs);
|
|
41
42
|
return [...legacy];
|
|
42
43
|
}
|
|
43
44
|
|
|
@@ -77,18 +78,27 @@ export function formatRelative(ts?: number): string {
|
|
|
77
78
|
* v2 note: with a declared `stateOrder` this is only the per-layout slot inventory;
|
|
78
79
|
* the CLAIM list itself is orderedClaims().
|
|
79
80
|
*/
|
|
80
|
-
export function collectSurfacedViews(node: unknown, out: Set<string> = new Set()): Set<string> {
|
|
81
|
+
export function collectSurfacedViews(node: unknown, out: Set<string> = new Set(), defs?: Record<string, unknown>): Set<string> {
|
|
81
82
|
if (Array.isArray(node)) {
|
|
82
|
-
for (const n of node) collectSurfacedViews(n, out);
|
|
83
|
+
for (const n of node) collectSurfacedViews(n, out, defs);
|
|
83
84
|
return out;
|
|
84
85
|
}
|
|
85
86
|
if (!node || typeof node !== "object") return out;
|
|
86
87
|
const n = node as Record<string, unknown>;
|
|
88
|
+
// A REFERENCED definition is a GRAPH, so a structural walk has to follow the edges. The
|
|
89
|
+
// chrome shared by four arrangements travels once, and the slots that claim views live
|
|
90
|
+
// INSIDE it — stopping at the reference would report that nothing claims anything, and
|
|
91
|
+
// every surfaced component would fall back into the flow.
|
|
92
|
+
if (n.type === "Ref" && typeof n.ref === "string") {
|
|
93
|
+
const piece = defs?.[n.ref];
|
|
94
|
+
if (piece) collectSurfacedViews(piece, out, defs);
|
|
95
|
+
return out;
|
|
96
|
+
}
|
|
87
97
|
const sel = n.select as { where?: { field?: string; eq?: unknown } } | undefined;
|
|
88
98
|
if (n.type === "ComponentSlot" && isViewField(sel?.where?.field) && typeof sel?.where?.eq === "string") {
|
|
89
99
|
out.add(sel.where.eq);
|
|
90
100
|
}
|
|
91
|
-
for (const v of Object.values(n)) if (v && typeof v === "object") collectSurfacedViews(v, out);
|
|
101
|
+
for (const v of Object.values(n)) if (v && typeof v === "object") collectSurfacedViews(v, out, defs);
|
|
92
102
|
return out;
|
|
93
103
|
}
|
|
94
104
|
|
|
@@ -121,7 +131,15 @@ export function computeAppWidth(root: unknown, store: ComponentStore, appSize?:
|
|
|
121
131
|
return;
|
|
122
132
|
}
|
|
123
133
|
if (!node || typeof node !== "object") return;
|
|
124
|
-
const n = node as Record<string, unknown> & { appWidth?: string; type?: string; select?: { where?: { field?: string; eq?: unknown } } };
|
|
134
|
+
const n = node as Record<string, unknown> & { appWidth?: string; type?: string; ref?: string; select?: { where?: { field?: string; eq?: unknown } } };
|
|
135
|
+
// Follow references: the width-bearing panels of a referenced form live inside the
|
|
136
|
+
// shared pieces, so stopping here would size the app from the arrangement's skeleton
|
|
137
|
+
// alone (see collectSurfacedViews for the same reason, stated once).
|
|
138
|
+
if (n.type === "Ref" && typeof n.ref === "string") {
|
|
139
|
+
const piece = (def?.defs as Record<string, unknown> | undefined)?.[n.ref];
|
|
140
|
+
if (piece) walk(piece);
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
125
143
|
if (n !== r) {
|
|
126
144
|
if (n.type === "ComponentSlot") {
|
|
127
145
|
const claim = n.select?.where;
|
|
@@ -121,6 +121,16 @@ export interface UnoverseDefinition {
|
|
|
121
121
|
* theme (`unoverse://theme/{org}/light`). Universal components carry no org. */
|
|
122
122
|
org?: string;
|
|
123
123
|
description?: string;
|
|
124
|
+
/**
|
|
125
|
+
* REFERENCE, DON'T COPY: the shared pieces this definition NAMES, by ref name, each
|
|
126
|
+
* shipped once instead of pasted at every use.
|
|
127
|
+
*
|
|
128
|
+
* NEVER PRESENT TODAY. The server withdrew the referenced form (server resources.ts
|
|
129
|
+
* `refForm`) after it left an app rendering zero pixels wide, and no client asks for it.
|
|
130
|
+
* The field and `resolveRef` stay because the resolution is correct and proven; what is
|
|
131
|
+
* missing is end-to-end proof and an answer for every client version in the estate.
|
|
132
|
+
*/
|
|
133
|
+
defs?: Record<string, UnoverseNode>;
|
|
124
134
|
/** EXTERNAL contract — the rare workflow-fed inputs (`input: true`). A contained
|
|
125
135
|
* microapp has an EMPTY (or absent) props block. */
|
|
126
136
|
props?: Record<string, unknown>;
|
|
@@ -20,7 +20,11 @@ const LABELS: Record<string, string> = {
|
|
|
20
20
|
weaving: 'Weaving…',
|
|
21
21
|
composing: 'Composing…',
|
|
22
22
|
breathing: 'Thinking…',
|
|
23
|
-
shaping: 'Shaping…'
|
|
23
|
+
shaping: 'Shaping…',
|
|
24
|
+
halo: 'Waiting…',
|
|
25
|
+
sonar: 'Listening…',
|
|
26
|
+
shimmer: 'Thinking…',
|
|
27
|
+
pulse: 'Responding…'
|
|
24
28
|
};
|
|
25
29
|
|
|
26
30
|
export function ThinkingOrb({
|
|
@@ -113,6 +113,16 @@ function shade(w: number, dark: boolean): [number, number, number] {
|
|
|
113
113
|
return [0, 1, 2].map((i) => Math.round(base[i] + (mixTo - base[i]) * w)) as [number, number, number];
|
|
114
114
|
}
|
|
115
115
|
|
|
116
|
+
/**
|
|
117
|
+
* LOCAL (Unoverse): the shaded ink as a CSS colour, for modes that STROKE rather than
|
|
118
|
+
* stipple (engine/rings). Same depth language as `paint` — `white` 0 is full ink, 1 is
|
|
119
|
+
* the substrate — so a stroked mode and a dotted one read as the same material.
|
|
120
|
+
*/
|
|
121
|
+
export function inkCss(white: number, dark: boolean, alpha: number): string {
|
|
122
|
+
const [r, g, b] = shade(Math.min(1, Math.max(0, white)), dark);
|
|
123
|
+
return `rgba(${r},${g},${b},${alpha})`;
|
|
124
|
+
}
|
|
125
|
+
|
|
116
126
|
/**
|
|
117
127
|
* Painter: z-sort far→near, matte depth-shaded dots. On dark substrates the
|
|
118
128
|
* ink value is mirrored (1 - white) so near dots read bright — the same
|
|
@@ -9,8 +9,15 @@ import { drawMorph } from './morph';
|
|
|
9
9
|
import { drawOrbits } from './orbits';
|
|
10
10
|
import { drawRibbon } from './ribbon';
|
|
11
11
|
import { drawWeb } from './web';
|
|
12
|
+
// LOCAL (Unoverse): the annular modes — see ./rings.
|
|
13
|
+
import { drawHalo, drawRipple, drawShimmer } from './rings';
|
|
12
14
|
|
|
13
15
|
export const MODE_DRAWS: Record<ModeKey, ModeDraw> = {
|
|
16
|
+
halo: drawHalo,
|
|
17
|
+
sonar: drawRipple,
|
|
18
|
+
// pulse shares ripple's painter — the `dir` profile flag mirrors it outward
|
|
19
|
+
pulse: drawRipple,
|
|
20
|
+
shimmer: drawShimmer,
|
|
14
21
|
orbits: drawOrbits,
|
|
15
22
|
globe: drawGlobe,
|
|
16
23
|
rubik: drawRubik,
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RING MODES — a LOCAL Unoverse addition to the vendored thinking-orbs engine.
|
|
3
|
+
* Kept in its own file so an upstream update never conflicts with it.
|
|
4
|
+
*
|
|
5
|
+
* WHY THESE EXIST. Every vendored mode draws a full 3-D sphere, which is right when the
|
|
6
|
+
* orb IS the subject. Put a face in the middle and the sphere stops being the subject:
|
|
7
|
+
* you see the leftover rim of a globe fighting the portrait. These modes are ANNULAR BY
|
|
8
|
+
* CONSTRUCTION — every mark lives between `rIn` and `rOut`, so the centre is empty by
|
|
9
|
+
* design and the animation frames the face instead of colliding with it.
|
|
10
|
+
*
|
|
11
|
+
* THE MATERIAL IS LIGHT, NOT INK. An earlier version bent the engine's halftone language
|
|
12
|
+
* into a circle and produced a ring of beads: hard-edged, evenly spaced, mechanically
|
|
13
|
+
* distributed — a loading spinner, not a presence. The premium reference (Apple's
|
|
14
|
+
* Intelligence glow) is built by one technique: the same path stroked several times, each
|
|
15
|
+
* pass wider, blurrier and fainter than the last, so a crisp core sits inside a bloom of
|
|
16
|
+
* its own light. That is `glow()`. Everything else is which path, and how it moves.
|
|
17
|
+
*
|
|
18
|
+
* NOTHING MAY LOOK LIKE A SPINNER. A bright head chasing a tail around a circle is the
|
|
19
|
+
* single most dated shape in this space, so no mode owns a travelling OBJECT. Where a
|
|
20
|
+
* mode needs direction, the LIGHT moves through a still form (a conic gradient sweeping
|
|
21
|
+
* its luminance) rather than a mark moving along a path — the difference between a lamp
|
|
22
|
+
* behind a curtain and a torch on a track.
|
|
23
|
+
*
|
|
24
|
+
* House style otherwise: no perfect circles (radii warp under drifting low-frequency
|
|
25
|
+
* noise), no countable frequencies (rates are incommensurate, so patterns never repeat on
|
|
26
|
+
* a beat), and no pass at full strength — the ring reads as light around the subject,
|
|
27
|
+
* never as chrome on top of it.
|
|
28
|
+
*
|
|
29
|
+
* Blur is `shadowBlur`, deliberately: `ctx.filter` is unsupported in older Safari and the
|
|
30
|
+
* vendored engine avoids it on purpose, whereas a shadow at zero offset is a real blurred
|
|
31
|
+
* copy of the stroke and works everywhere the rest of this engine runs.
|
|
32
|
+
*/
|
|
33
|
+
import { inkCss, vnoise } from './core';
|
|
34
|
+
import type { ModeDraw } from './types';
|
|
35
|
+
import type { ModeOpts } from './profiles';
|
|
36
|
+
|
|
37
|
+
const TAU = Math.PI * 2;
|
|
38
|
+
|
|
39
|
+
/** The annulus a ring mode may draw in, plus a scale factor off the 200px reference. */
|
|
40
|
+
function band(size: number, o: ModeOpts) {
|
|
41
|
+
const c = size / 2;
|
|
42
|
+
const R = size / 2;
|
|
43
|
+
// Defaults clear a face occupying ~0.56 of the box (avatar-orb's 112px in 200px) and
|
|
44
|
+
// its shadow; `rIn`/`rOut` retune the band for any other face ratio.
|
|
45
|
+
const rIn = R * (o.rIn ?? 0.62);
|
|
46
|
+
const rOut = R * (o.rOut ?? 0.97);
|
|
47
|
+
return { cx: c, cy: c, rIn, rOut, rMid: (rIn + rOut) / 2, span: (rOut - rIn) / 2, s: size / 200 };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
type Band = ReturnType<typeof band>;
|
|
51
|
+
/** How a pass paints at a given alpha — a flat ink, or a gradient that varies round the ring. */
|
|
52
|
+
type PaintAt = (alpha: number) => string | CanvasGradient;
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* THE GLOW. One path, stroked four times: a wide, heavily blurred, nearly transparent
|
|
56
|
+
* bloom first, then progressively tighter and stronger passes, ending on a thin crisp
|
|
57
|
+
* core. Painted in that order so the core lands on top of its own halo.
|
|
58
|
+
*/
|
|
59
|
+
function glow(
|
|
60
|
+
ctx: CanvasRenderingContext2D,
|
|
61
|
+
path: () => void,
|
|
62
|
+
w: number,
|
|
63
|
+
alpha: number,
|
|
64
|
+
paintAt: PaintAt,
|
|
65
|
+
shadowAt: (alpha: number) => string,
|
|
66
|
+
): void {
|
|
67
|
+
const passes = [
|
|
68
|
+
{ lw: w * 3.4, blur: 26, a: alpha * 0.14 },
|
|
69
|
+
{ lw: w * 2.1, blur: 14, a: alpha * 0.26 },
|
|
70
|
+
{ lw: w * 1.25, blur: 6, a: alpha * 0.5 },
|
|
71
|
+
{ lw: w * 0.5, blur: 0, a: alpha },
|
|
72
|
+
];
|
|
73
|
+
ctx.lineCap = 'round';
|
|
74
|
+
ctx.lineJoin = 'round';
|
|
75
|
+
for (const p of passes) {
|
|
76
|
+
if (p.a < 0.004) continue;
|
|
77
|
+
ctx.save();
|
|
78
|
+
ctx.beginPath();
|
|
79
|
+
path();
|
|
80
|
+
ctx.lineWidth = Math.max(0.4, p.lw);
|
|
81
|
+
ctx.strokeStyle = paintAt(p.a);
|
|
82
|
+
if (p.blur) {
|
|
83
|
+
ctx.shadowBlur = p.blur * (w / 3);
|
|
84
|
+
// a shadow takes a colour, never a gradient — so it uses the flat ink
|
|
85
|
+
ctx.shadowColor = shadowAt(p.a);
|
|
86
|
+
}
|
|
87
|
+
ctx.stroke();
|
|
88
|
+
ctx.restore();
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Flat ink of a given depth — the ordinary case. */
|
|
93
|
+
const flat = (white: number, dark: boolean): PaintAt => (a) => inkCss(white, dark, a);
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* A conic gradient whose luminance peaks once and falls away round the ring, so the ring
|
|
97
|
+
* is brighter on one side without anything travelling along it. `at` rotates the peak.
|
|
98
|
+
* Falls back to flat ink where `createConicGradient` is unavailable (older Safari), which
|
|
99
|
+
* costs the directionality and nothing else.
|
|
100
|
+
*/
|
|
101
|
+
function sweepPaint(ctx: CanvasRenderingContext2D, b: Band, white: number, dark: boolean, at: number, floor = 0.14): PaintAt {
|
|
102
|
+
const make = (ctx as unknown as { createConicGradient?: (a: number, x: number, y: number) => CanvasGradient })
|
|
103
|
+
.createConicGradient;
|
|
104
|
+
if (typeof make !== 'function') return flat(white, dark);
|
|
105
|
+
return (alpha) => {
|
|
106
|
+
const g = make.call(ctx, at, b.cx, b.cy);
|
|
107
|
+
const STOPS = 16;
|
|
108
|
+
for (let i = 0; i <= STOPS; i++) {
|
|
109
|
+
const f = i / STOPS;
|
|
110
|
+
// cos² window: full at the peak, easing to `floor` on the far side, seamless at the wrap
|
|
111
|
+
const env = ((Math.cos(f * TAU) + 1) / 2) ** 2;
|
|
112
|
+
g.addColorStop(f, inkCss(white, dark, alpha * (floor + (1 - floor) * env)));
|
|
113
|
+
}
|
|
114
|
+
return g;
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** A closed curve through `pts`, smoothed with quadratics through the midpoints. */
|
|
119
|
+
function closedCurve(ctx: CanvasRenderingContext2D, pts: Array<[number, number]>): void {
|
|
120
|
+
const n = pts.length;
|
|
121
|
+
const mx = (a: [number, number], b: [number, number]): [number, number] => [
|
|
122
|
+
(a[0] + b[0]) / 2,
|
|
123
|
+
(a[1] + b[1]) / 2,
|
|
124
|
+
];
|
|
125
|
+
const start = mx(pts[n - 1], pts[0]);
|
|
126
|
+
ctx.moveTo(start[0], start[1]);
|
|
127
|
+
for (let i = 0; i < n; i++) {
|
|
128
|
+
const p = pts[i];
|
|
129
|
+
const m = mx(p, pts[(i + 1) % n]);
|
|
130
|
+
ctx.quadraticCurveTo(p[0], p[1], m[0], m[1]);
|
|
131
|
+
}
|
|
132
|
+
ctx.closePath();
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** Sample a warped ring: radius = rMid + span · f(angle). */
|
|
136
|
+
function ringPts(b: Band, n: number, f: (a: number, i: number) => number): Array<[number, number]> {
|
|
137
|
+
const pts: Array<[number, number]> = [];
|
|
138
|
+
for (let i = 0; i < n; i++) {
|
|
139
|
+
const a = (i / n) * TAU;
|
|
140
|
+
const rr = b.rMid + b.span * f(a, i);
|
|
141
|
+
pts.push([b.cx + Math.cos(a) * rr, b.cy + Math.sin(a) * rr]);
|
|
142
|
+
}
|
|
143
|
+
return pts;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* `halo` — a soft aura, breathing. Two slow lobes drift against each other under a
|
|
148
|
+
* noise wander, so the form is never a circle and never repeats on a countable beat.
|
|
149
|
+
* Nothing arrives, nothing leaves: for an assistant at rest.
|
|
150
|
+
*/
|
|
151
|
+
export const drawHalo: ModeDraw = (ctx, size, t, dark, o) => {
|
|
152
|
+
const b = band(size, o);
|
|
153
|
+
const breathe = 0.5 + 0.5 * Math.sin(t * 0.5);
|
|
154
|
+
const pts = ringPts(b, Math.round(o.samples ?? 88), (a, i) => {
|
|
155
|
+
const lobes = Math.sin(a * 2 + t * 0.33) * 0.34 + Math.sin(a * 3 - t * 0.21) * 0.22;
|
|
156
|
+
const wander = (vnoise(i * 0.05, t * 0.16) - 0.5) * 0.9;
|
|
157
|
+
return 0.3 * lobes + 0.22 * wander + 0.16 * (breathe - 0.5);
|
|
158
|
+
});
|
|
159
|
+
const white = o.white ?? 0.26;
|
|
160
|
+
glow(ctx, () => closedCurve(ctx, pts), (o.w ?? 3) * b.s, 0.34 + 0.2 * breathe, flat(white, dark), flat(white, dark) as (a: number) => string);
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* `sonar` / `pulse` — concentric rings of light, travelling INWARD (`dir: -1`) or
|
|
165
|
+
* OUTWARD (`dir: +1`). One painter, two meanings, because they are the same gesture
|
|
166
|
+
* mirrored: rings arriving are sound reaching a listener, rings leaving are a voice
|
|
167
|
+
* carrying. Each swells and settles over its life so none ever pops into existence.
|
|
168
|
+
*/
|
|
169
|
+
export const drawRipple: ModeDraw = (ctx, size, t, dark, o) => {
|
|
170
|
+
const b = band(size, o);
|
|
171
|
+
const waves = Math.round(o.waves ?? 2);
|
|
172
|
+
const rate = o.rate ?? 0.2;
|
|
173
|
+
const out = (o.dir ?? -1) > 0;
|
|
174
|
+
const white = o.white ?? 0.32;
|
|
175
|
+
const ink = flat(white, dark);
|
|
176
|
+
// a still, very faint seat where the rings are born (outward) or land (inward)
|
|
177
|
+
const seat = out ? b.rIn + b.span * 0.15 : b.rMid + b.span * 0.55;
|
|
178
|
+
glow(ctx, () => ctx.arc(b.cx, b.cy, seat, 0, TAU), (o.w ?? 2.4) * b.s, 0.07, ink, ink as (a: number) => string);
|
|
179
|
+
for (let w = 0; w < waves; w++) {
|
|
180
|
+
const f = (((t * rate + w / waves) % 1) + 1) % 1; // 0 → 1 over a wave's life
|
|
181
|
+
const rr = out ? b.rIn + (b.rOut - b.rIn) * f : b.rOut - (b.rOut - b.rIn) * f;
|
|
182
|
+
const fade = Math.sin(Math.PI * f) ** 1.3; // eases in AND out
|
|
183
|
+
if (fade < 0.02) continue;
|
|
184
|
+
const pts = ringPts(b, Math.round(o.samples ?? 72), (a) => (rr - b.rMid) / b.span + Math.sin(a * 2 - t * 0.3) * 0.1);
|
|
185
|
+
glow(ctx, () => closedCurve(ctx, pts), (o.w ?? 2.6) * b.s, (o.peak ?? 0.3) * fade, ink, ink as (a: number) => string);
|
|
186
|
+
}
|
|
187
|
+
};
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* `shimmer` — thinking. NOT a mark going round: the ring holds still (drifting only as
|
|
191
|
+
* every mode here drifts) and its LUMINANCE moves, two conic sweeps at incommensurate,
|
|
192
|
+
* counter-rotating rates passing through each other. Where they coincide the ring
|
|
193
|
+
* brightens for a moment and then the moment passes. Reads as something being considered,
|
|
194
|
+
* with no spinner anywhere in it.
|
|
195
|
+
*/
|
|
196
|
+
export const drawShimmer: ModeDraw = (ctx, size, t, dark, o) => {
|
|
197
|
+
const b = band(size, o);
|
|
198
|
+
const white = o.white ?? 0.2;
|
|
199
|
+
const ink = flat(white, dark) as (a: number) => string;
|
|
200
|
+
const rate = o.rate ?? 0.5;
|
|
201
|
+
const peak = o.peak ?? 0.46;
|
|
202
|
+
const samples = Math.round(o.samples ?? 84);
|
|
203
|
+
// THREE rings at different radii, each lit by its own sweep at its own rate. One ring
|
|
204
|
+
// varying in brightness is too quiet to read as activity; three at incommensurate rates
|
|
205
|
+
// drift through each other, so bright regions build and part without ever lining up
|
|
206
|
+
// twice the same way. Depth does the work a faster spin would otherwise be asked to do.
|
|
207
|
+
const layers = [
|
|
208
|
+
{ seat: 0, rate: rate, alpha: 1, w: 1 },
|
|
209
|
+
{ seat: -0.62, rate: -rate * 0.61, alpha: 0.62, w: 0.72 },
|
|
210
|
+
{ seat: 0.6, rate: rate * 0.37, alpha: 0.52, w: 0.66 },
|
|
211
|
+
];
|
|
212
|
+
for (const L of layers) {
|
|
213
|
+
const phase = t * L.rate;
|
|
214
|
+
const pts = ringPts(b, samples, (a, i) => {
|
|
215
|
+
// the ring SWELLS where its light is — luminance and form move together, which is
|
|
216
|
+
// what makes it read as one lit object rather than a texture over a circle
|
|
217
|
+
const lit = ((Math.cos(a - phase) + 1) / 2) ** 2;
|
|
218
|
+
const lobes = Math.sin(a * 2 + t * 0.19) * 0.26 + Math.sin(a * 3 - t * 0.13) * 0.16;
|
|
219
|
+
const wander = (vnoise(i * 0.05 + L.seat * 7, t * 0.1) - 0.5) * 0.6;
|
|
220
|
+
return L.seat * 0.42 + 0.2 * lobes + 0.16 * wander + 0.22 * lit;
|
|
221
|
+
});
|
|
222
|
+
glow(
|
|
223
|
+
ctx,
|
|
224
|
+
() => closedCurve(ctx, pts),
|
|
225
|
+
(o.w ?? 2.8) * b.s * L.w,
|
|
226
|
+
peak * L.alpha,
|
|
227
|
+
sweepPaint(ctx, b, white, dark, phase, 0.05),
|
|
228
|
+
ink,
|
|
229
|
+
);
|
|
230
|
+
}
|
|
231
|
+
};
|
|
232
|
+
|
|
233
|
+
/** Base profiles for the ring modes (same role as engine/profiles BASE_PROFILES). */
|
|
234
|
+
export const RING_PROFILES: Record<string, ModeOpts> = {
|
|
235
|
+
halo: { samples: 88, w: 3, white: 0.26 },
|
|
236
|
+
sonar: { samples: 72, waves: 2, rate: 0.2, dir: -1, w: 2.6, white: 0.32, peak: 0.3 },
|
|
237
|
+
pulse: { samples: 72, waves: 3, rate: 0.26, dir: 1, w: 2.6, white: 0.3, peak: 0.34 },
|
|
238
|
+
shimmer: { samples: 84, rate: 0.5, w: 2.8, white: 0.2, peak: 0.46 },
|
|
239
|
+
};
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
|
|
6
6
|
import type { ModeOpts } from './engine/profiles';
|
|
7
7
|
import { BASE_PROFILES, scaleCounts, scaleRadii } from './engine/profiles';
|
|
8
|
+
import { RING_PROFILES } from './engine/rings';
|
|
8
9
|
import type { OrbSize, OrbState } from './types';
|
|
9
10
|
|
|
10
11
|
export type ModeKey =
|
|
@@ -16,7 +17,12 @@ export type ModeKey =
|
|
|
16
17
|
| 'braid'
|
|
17
18
|
| 'ribbon'
|
|
18
19
|
| 'ring'
|
|
19
|
-
| 'morph'
|
|
20
|
+
| 'morph'
|
|
21
|
+
// LOCAL (Unoverse): annular modes for framing a face — engine/rings.ts
|
|
22
|
+
| 'halo'
|
|
23
|
+
| 'sonar'
|
|
24
|
+
| 'shimmer'
|
|
25
|
+
| 'pulse';
|
|
20
26
|
|
|
21
27
|
export const STATE_TO_MODE: Record<OrbState, ModeKey> = {
|
|
22
28
|
working: 'orbits',
|
|
@@ -27,9 +33,46 @@ export const STATE_TO_MODE: Record<OrbState, ModeKey> = {
|
|
|
27
33
|
weaving: 'braid',
|
|
28
34
|
composing: 'ribbon',
|
|
29
35
|
breathing: 'ring',
|
|
30
|
-
shaping: 'morph'
|
|
36
|
+
shaping: 'morph',
|
|
37
|
+
// LOCAL (Unoverse): each annular state maps 1:1 to its own painter.
|
|
38
|
+
halo: 'halo',
|
|
39
|
+
sonar: 'sonar',
|
|
40
|
+
shimmer: 'shimmer',
|
|
41
|
+
pulse: 'pulse'
|
|
31
42
|
};
|
|
32
43
|
|
|
44
|
+
/**
|
|
45
|
+
* THE ENERGY STANDARD (local to Unoverse).
|
|
46
|
+
*
|
|
47
|
+
* `speed: 1` must mean ONE felt pace across the whole vocabulary, or a design decision
|
|
48
|
+
* taken against one state is wrong for the next — which is exactly what a shared knob on
|
|
49
|
+
* a design-system component promises. The unit is a VISUAL CYCLE: the time for a mode's
|
|
50
|
+
* dominant motion to come back round (a swell to travel, a wave to arrive, a head to lap).
|
|
51
|
+
*
|
|
52
|
+
* The annular modes are solved to it exactly (see their block below). The vendored sphere
|
|
53
|
+
* modes keep their author's relative tuning — hand-made per mode and better than anything
|
|
54
|
+
* re-derived blind — scaled by ONE factor, because that tuning was made for an orb that IS
|
|
55
|
+
* the subject at chat-avatar scale; behind a face, or beside content, it reads hot.
|
|
56
|
+
*/
|
|
57
|
+
const CYCLE_SECONDS = 4;
|
|
58
|
+
/**
|
|
59
|
+
* A state that is DOING something earns more energy than one at rest — waiting and
|
|
60
|
+
* listening are receptive and should sit still; thinking and responding are work in
|
|
61
|
+
* progress and read as stalled at the resting pace. Same standard, shorter cycle.
|
|
62
|
+
*/
|
|
63
|
+
const ACTIVE_CYCLE = 2.5;
|
|
64
|
+
const SPHERE_CALM = 0.45;
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Solve a mode's baked speed from its OWN dominant rate, so one cycle lands on the
|
|
68
|
+
* standard. `turns` is what the rate is measured against — a full revolution for motion
|
|
69
|
+
* around the ring, or 1 for a mode whose rate is already "lives per unit t" (sonar).
|
|
70
|
+
* Retuning a rate constant in engine/rings only means updating the number passed here.
|
|
71
|
+
*/
|
|
72
|
+
const solve = (ratePerT: number, turns = 2 * Math.PI, cycle = CYCLE_SECONDS) => turns / (ratePerT * cycle);
|
|
73
|
+
/** Inline scale reads a touch quicker — a small mark needs more motion to register. */
|
|
74
|
+
const SMALL = 1.2;
|
|
75
|
+
|
|
33
76
|
interface Preset {
|
|
34
77
|
speed: number;
|
|
35
78
|
count: number;
|
|
@@ -74,6 +117,37 @@ const PRESETS: Record<ModeKey, Record<OrbSize, Preset>> = {
|
|
|
74
117
|
morph: {
|
|
75
118
|
64: { speed: 2.405, count: 0.702, size: 0.395, extra: { spread: 1.45 } },
|
|
76
119
|
20: { speed: 2.08, count: 0.53, size: 1.011, extra: { spread: 1.45 } }
|
|
120
|
+
},
|
|
121
|
+
// LOCAL (Unoverse): the annular set, tuned to THE STANDARD above rather than by eye.
|
|
122
|
+
// Each speed is solved, not guessed, from that mode's own dominant rate so one visual
|
|
123
|
+
// cycle lands on CYCLE_SECONDS: halo breathes at 0.5, a sonar wave lives 1/0.2, a
|
|
124
|
+
// shimmer sweep laps at 0.5, a pulse wave lives 1/0.26. Change a rate constant in
|
|
125
|
+
// engine/rings and the only edit here is the number handed to solve(). Resting states
|
|
126
|
+
// (halo, sonar) use CYCLE_SECONDS; working ones (shimmer, pulse) use ACTIVE_CYCLE.
|
|
127
|
+
// At inline scale the band is a few px wide, so samples drop and strokes thin.
|
|
128
|
+
// `count`/`size` stay 1: scaleCounts/scaleRadii only know the vendored modes' key
|
|
129
|
+
// names, so a multiplier would silently do nothing to a ring mode's samples/w.
|
|
130
|
+
halo: {
|
|
131
|
+
64: { speed: solve(0.5), count: 1, size: 1 },
|
|
132
|
+
20: { speed: solve(0.5) * SMALL, count: 1, size: 1, extra: { rIn: 0.5, rOut: 0.98, samples: 56, w: 2.2 } }
|
|
133
|
+
},
|
|
134
|
+
sonar: {
|
|
135
|
+
// rate is "lives per unit t", not an angle — so it solves against 1 turn, not 2π
|
|
136
|
+
64: { speed: solve(0.2, 1), count: 1, size: 1 },
|
|
137
|
+
20: { speed: solve(0.2, 1) * SMALL, count: 1, size: 1, extra: { rIn: 0.44, rOut: 0.98, samples: 48, waves: 2, w: 2 } }
|
|
138
|
+
},
|
|
139
|
+
shimmer: {
|
|
140
|
+
64: { speed: solve(0.5, 2 * Math.PI, ACTIVE_CYCLE), count: 1, size: 1 },
|
|
141
|
+
20: {
|
|
142
|
+
speed: solve(0.5, 2 * Math.PI, ACTIVE_CYCLE) * SMALL,
|
|
143
|
+
count: 1,
|
|
144
|
+
size: 1,
|
|
145
|
+
extra: { rIn: 0.5, rOut: 0.98, samples: 56, w: 2.2 }
|
|
146
|
+
}
|
|
147
|
+
},
|
|
148
|
+
pulse: {
|
|
149
|
+
64: { speed: solve(0.26, 1, ACTIVE_CYCLE), count: 1, size: 1 },
|
|
150
|
+
20: { speed: solve(0.26, 1, ACTIVE_CYCLE) * SMALL, count: 1, size: 1, extra: { rIn: 0.44, rOut: 0.98, samples: 48, waves: 2, w: 2 } }
|
|
77
151
|
}
|
|
78
152
|
};
|
|
79
153
|
|
|
@@ -93,12 +167,15 @@ export function resolvePreset(state: OrbState, size: OrbSize): Resolved {
|
|
|
93
167
|
|
|
94
168
|
const mode = STATE_TO_MODE[state];
|
|
95
169
|
const preset = PRESETS[mode][size];
|
|
96
|
-
let opts: ModeOpts = { ...BASE_PROFILES[mode] };
|
|
170
|
+
let opts: ModeOpts = { ...(BASE_PROFILES[mode] ?? RING_PROFILES[mode]) };
|
|
97
171
|
if (preset.count !== 1) opts = scaleCounts(opts, preset.count);
|
|
98
172
|
if (preset.size !== 1) opts = scaleRadii(opts, preset.size);
|
|
99
173
|
if (preset.extra) opts = { ...opts, ...preset.extra };
|
|
100
174
|
|
|
101
|
-
|
|
175
|
+
// The sphere set is pulled into the ring set's energy band; the ring set is already
|
|
176
|
+
// solved to it, so it passes through untouched (see THE ENERGY STANDARD).
|
|
177
|
+
const speed = preset.speed * (RING_PROFILES[mode] ? 1 : SPHERE_CALM);
|
|
178
|
+
const resolved: Resolved = { mode, speed, opts };
|
|
102
179
|
cache.set(key, resolved);
|
|
103
180
|
return resolved;
|
|
104
181
|
}
|
|
@@ -11,6 +11,13 @@ import type { CSSProperties, CanvasHTMLAttributes } from 'react';
|
|
|
11
11
|
* - `composing` — an undulating multi-band sash
|
|
12
12
|
* - `breathing` — a face-on ring slowly morphing
|
|
13
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
|
|
14
21
|
*/
|
|
15
22
|
export type OrbState =
|
|
16
23
|
| 'working'
|
|
@@ -21,7 +28,11 @@ export type OrbState =
|
|
|
21
28
|
| 'weaving'
|
|
22
29
|
| 'composing'
|
|
23
30
|
| 'breathing'
|
|
24
|
-
| 'shaping'
|
|
31
|
+
| 'shaping'
|
|
32
|
+
| 'halo'
|
|
33
|
+
| 'sonar'
|
|
34
|
+
| 'shimmer'
|
|
35
|
+
| 'pulse';
|
|
25
36
|
|
|
26
37
|
/**
|
|
27
38
|
* Rendered size in CSS pixels. Exactly two tuned presets ship:
|
|
@@ -349,20 +349,25 @@ export function ButtonView({ node, data, theme, onAction, children }: { node: Un
|
|
|
349
349
|
* bindable), `size` (64 avatar / 20 inline), `speed`, `theme` override.
|
|
350
350
|
* Unknown/unbound state degrades to "working", never a crash.
|
|
351
351
|
*/
|
|
352
|
-
const ORB_STATES = new Set(["working", "searching", "solving", "listening", "connecting", "weaving", "composing", "breathing", "shaping"]);
|
|
352
|
+
const ORB_STATES = new Set(["working", "searching", "solving", "listening", "connecting", "weaving", "composing", "breathing", "shaping", "halo", "sonar", "shimmer", "pulse"]);
|
|
353
353
|
export function OrbView({ node, data, theme }: { node: UnoverseNode; data: Record<string, unknown>; theme: ResolvedTheme }): ReactNode {
|
|
354
354
|
const n = node as UnoverseNode & { state?: string; size?: number | string; speed?: number | string; theme?: string };
|
|
355
355
|
// Every knob is data-driven OR literal, like Icon: `bind.<knob>` wins, the attribute is the fallback.
|
|
356
356
|
const knob = (name: "state" | "size" | "speed") =>
|
|
357
357
|
(node.bind?.[name] ? (data[node.bind[name] as string] as string | number | undefined) : undefined) ?? n[name];
|
|
358
358
|
const raw = knob("state") as string | undefined;
|
|
359
|
-
|
|
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";
|
|
360
365
|
const size = Number(knob("size")) > 0 ? Number(knob("size")) : 64;
|
|
361
366
|
const speed = Number(knob("speed")) > 0 ? Number(knob("speed")) : 1;
|
|
362
367
|
const orbTheme: OrbTheme = n.theme === "dark" || n.theme === "light" ? n.theme : "auto";
|
|
363
368
|
// Brand ink comes from the STYLE vocabulary (`color: <token>`), never a prop — LAW 1:
|
|
364
369
|
// a definition names a token, the theme serves the value, the engine tints toward it.
|
|
365
|
-
const css = styleToCss(node.style ?? {}, theme);
|
|
370
|
+
const css = styleToCss(node.style ?? {}, theme, data);
|
|
366
371
|
return <ThinkingOrb state={state} size={size as OrbSize} speed={speed} theme={orbTheme} ink={cssColorToRgb(css.color as string | undefined)} style={css} />;
|
|
367
372
|
}
|
|
368
373
|
|
|
@@ -36,6 +36,7 @@
|
|
|
36
36
|
import type { ReactNode } from "react";
|
|
37
37
|
import type { ActionSpec, ResolvedTheme, UnoverseNode } from "./core";
|
|
38
38
|
import { visible, selectCase, cssWidth } from "./core";
|
|
39
|
+
import { resolveRef } from "./core/refs";
|
|
39
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
|
|
@@ -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
|
|
@@ -141,13 +148,26 @@ export function renderNode(
|
|
|
141
148
|
if (node.type === "Markdown") return <MarkdownView key={key} node={node} data={data} theme={theme} />;
|
|
142
149
|
if (node.type === "ComponentSlot" || node.type === "Timeline") return slot ? slot(node, key) : null;
|
|
143
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
|
+
|
|
144
164
|
// `Switch` — control flow: render EXACTLY ONE branch, the case matching `data[on]`
|
|
145
165
|
// (else `cases.default`, else nothing). The declarative whole-view swap (wizard step,
|
|
146
166
|
// inline↔focused) — mutually exclusive in one place, vs. a `visibleWhen` per branch. No
|
|
147
167
|
// wrapper element: it is transparent, like an `if`, delegating straight to the branch.
|
|
148
168
|
if (node.type === "Switch") {
|
|
149
169
|
const branch = selectCase(node, data);
|
|
150
|
-
return branch ? renderNode(branch, data, onAction, theme, key, slot) : null;
|
|
170
|
+
return branch ? renderNode(branch, data, onAction, theme, key, slot, defs) : null;
|
|
151
171
|
}
|
|
152
172
|
|
|
153
173
|
// `Each` — control flow: map a bound array (bind.items) → the `template` subtree once
|
|
@@ -175,7 +195,7 @@ export function renderNode(
|
|
|
175
195
|
? { ...base, ...(item as Record<string, unknown>) }
|
|
176
196
|
: { ...base, value: item };
|
|
177
197
|
};
|
|
178
|
-
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;
|
|
179
199
|
return (
|
|
180
200
|
<BoxView key={key} node={node} data={data} theme={theme}>
|
|
181
201
|
{rendered}
|
|
@@ -184,7 +204,7 @@ export function renderNode(
|
|
|
184
204
|
}
|
|
185
205
|
|
|
186
206
|
// Element primitives → dispatch. Children are rendered here (control flow) and handed down.
|
|
187
|
-
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));
|
|
188
208
|
switch (node.type) {
|
|
189
209
|
case "Box":
|
|
190
210
|
case "Stack":
|
|
@@ -141,12 +141,6 @@ export function StreamedUnoverseTemplate({ client, store, uri, onAction, theme:
|
|
|
141
141
|
}
|
|
142
142
|
}, [def, store, structureVersion]);
|
|
143
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
144
|
// STATE OWNS LAYOUT (STATE_MODEL v2): the tree this template presents — the active
|
|
151
145
|
// state's layout (multi-layout templates), else the classic root. The resolved node
|
|
152
146
|
// is a stable per-def object, so downstream memos key off it.
|
|
@@ -158,10 +152,28 @@ export function StreamedUnoverseTemplate({ client, store, uri, onAction, theme:
|
|
|
158
152
|
// instance → one placeholder).
|
|
159
153
|
const surfacedViews = useMemo(() => {
|
|
160
154
|
const views = new Set<string>(orderedClaims(def));
|
|
161
|
-
if (activeRoot) collectSurfacedViews(activeRoot, views);
|
|
155
|
+
if (activeRoot) collectSurfacedViews(activeRoot, views, def?.defs as Record<string, unknown> | undefined);
|
|
162
156
|
return views;
|
|
163
157
|
}, [activeRoot, def]);
|
|
164
158
|
|
|
159
|
+
// RULE 4: the winner clears the states below it — WHEN A DELIVERY PUT IT THERE.
|
|
160
|
+
// An arrival taking a higher rung means the screen has moved on, so stale claims below
|
|
161
|
+
// it let go and rule 6's re-walk lands on the base. The guest's own navigation is not
|
|
162
|
+
// that: tapping a rail card to open its page must leave the rail's claims intact, or
|
|
163
|
+
// closing the page has nothing to return to. Priority still decides what SHOWS in both
|
|
164
|
+
// cases; this only decides what gets thrown away. Derived above the loading guards so
|
|
165
|
+
// this stays a hook, and off the SAME derivation the render path uses.
|
|
166
|
+
const ladder = def ? resolveActiveView(def, store, surfacedViews) : { view: "", byGuest: false };
|
|
167
|
+
useEffect(() => {
|
|
168
|
+
if (ladder.view && !ladder.byGuest) store.cancelBelow(ladder.view, orderedClaims(def));
|
|
169
|
+
// `structureVersion`: EVERY arrival re-runs this, not only the ones that change the
|
|
170
|
+
// winner. A card landing while a higher state is ALREADY active would otherwise keep
|
|
171
|
+
// its claim forever — the walk never re-ran — and closing the higher state dropped
|
|
172
|
+
// straight onto it (observed: a rail still there after a focus surface closed, because
|
|
173
|
+
// the card's render landed two seconds after the finder took focus). `cancelBelow`
|
|
174
|
+
// only bumps when it changed something, so this settles in one extra pass.
|
|
175
|
+
}, [ladder.view, ladder.byGuest, def, store, structureVersion]);
|
|
176
|
+
|
|
165
177
|
// Loading/error hints carry NO styling — the SDK owns zero styles.
|
|
166
178
|
if (error) return <div>Unoverse error: {error}</div>;
|
|
167
179
|
if (!def || !theme) return <div>Loading {uri}…</div>;
|
|
@@ -235,11 +247,11 @@ export function StreamedUnoverseTemplate({ client, store, uri, onAction, theme:
|
|
|
235
247
|
// Surfaces never stack; panel combinations cannot exist.
|
|
236
248
|
const claim = node.select?.where;
|
|
237
249
|
if (isViewField(claim?.field) && typeof claim?.eq === "string" && claim.eq !== surfacedView) {
|
|
238
|
-
return node.fallback ? renderNode(node.fallback, {}, dispatch, theme, key ?? "fallback") : null;
|
|
250
|
+
return node.fallback ? renderNode(node.fallback, {}, dispatch, theme, key ?? "fallback", undefined, def?.defs) : null;
|
|
239
251
|
}
|
|
240
252
|
const pointers = selectPointers(store, node);
|
|
241
253
|
if (pointers.length === 0) {
|
|
242
|
-
return node.fallback ? renderNode(node.fallback, {}, dispatch, theme, key ?? "fallback") : null;
|
|
254
|
+
return node.fallback ? renderNode(node.fallback, {}, dispatch, theme, key ?? "fallback", undefined, def?.defs) : null;
|
|
243
255
|
}
|
|
244
256
|
// `frame` — chrome that exists ONLY because the slot matched (fallback's twin).
|
|
245
257
|
// The frame subtree renders with a slot resolver that serves the outer slot's
|
|
@@ -262,7 +274,7 @@ export function StreamedUnoverseTemplate({ client, store, uri, onAction, theme:
|
|
|
262
274
|
const frame = appWidth
|
|
263
275
|
? ({ ...node.frame, style: { ...(node.frame.style ?? {}), width: appWidth, flex: "0 0 auto" } } as UnoverseNode)
|
|
264
276
|
: node.frame;
|
|
265
|
-
return renderNode(frame, store.getTemplateState() as Record<string, unknown>, dispatch, theme, key ?? "frame", innerSlot);
|
|
277
|
+
return renderNode(frame, store.getTemplateState() as Record<string, unknown>, dispatch, theme, key ?? "frame", innerSlot, def?.defs);
|
|
266
278
|
}
|
|
267
279
|
return pointers.map((p) => leaf(p));
|
|
268
280
|
};
|
|
@@ -294,7 +306,7 @@ export function StreamedUnoverseTemplate({ client, store, uri, onAction, theme:
|
|
|
294
306
|
const turnSlot: SlotResolver = (slotNode, k) => {
|
|
295
307
|
let ptrs = [...shown];
|
|
296
308
|
if (slotNode.select?.limit != null) ptrs = ptrs.slice(0, slotNode.select.limit);
|
|
297
|
-
if (ptrs.length === 0) return slotNode.fallback ? renderNode(slotNode.fallback, {}, dispatch, theme, k ?? "fb") : null;
|
|
309
|
+
if (ptrs.length === 0) return slotNode.fallback ? renderNode(slotNode.fallback, {}, dispatch, theme, k ?? "fb", undefined, def?.defs) : null;
|
|
298
310
|
// Pass the turn's streaming state into each component's scope so a streamed
|
|
299
311
|
// component (e.g. StreamingText) can stop its own loading dots when complete.
|
|
300
312
|
const streaming = turn.role === "assistant" && turn.streamingState === "streaming";
|
|
@@ -322,7 +334,7 @@ export function StreamedUnoverseTemplate({ client, store, uri, onAction, theme:
|
|
|
322
334
|
// inactive → the definition hides it (legacy ChatHistoryItem null-returns).
|
|
323
335
|
active: turn.streamingState === "streaming" || shown.length > 0,
|
|
324
336
|
};
|
|
325
|
-
return renderNode(sub, data, dispatch, theme, turn.id, turnSlot);
|
|
337
|
+
return renderNode(sub, data, dispatch, theme, turn.id, turnSlot, def?.defs);
|
|
326
338
|
})}
|
|
327
339
|
</div>
|
|
328
340
|
);
|
|
@@ -364,7 +376,10 @@ export function StreamedUnoverseTemplate({ client, store, uri, onAction, theme:
|
|
|
364
376
|
const keyframes = <style>{keyframesCss(theme)}</style>;
|
|
365
377
|
// A layout root never carries `appWidth` (guard-enforced) — panels inside it do.
|
|
366
378
|
const rootNode = (activeRoot ?? def.root) as UnoverseNode;
|
|
367
|
-
|
|
379
|
+
// The template's own shared pieces, threaded into every branch it draws: chrome, a
|
|
380
|
+
// slot's fallback, a timeline turn. A reference in any of them resolves against the
|
|
381
|
+
// same map rather than rendering nothing.
|
|
382
|
+
const tree = renderNode(rootNode, rootData, dispatch, theme, undefined, resolve, def?.defs);
|
|
368
383
|
// A template is a SURFACE: its render-root fills the box the host gives it, so a root
|
|
369
384
|
// that declares `height: "full"` (DATA) can resolve. This only fills when the host's box
|
|
370
385
|
// is definite-height; when the host sizes to content the 100% resolves to content — so a
|