@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 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
- const ref = uri.replace(/^unoverse:\/\/(components|atoms|apps|templates)\//, "");
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@unoverse-platform/studio",
3
- "version": "0.1.27",
3
+ "version": "0.1.29",
4
4
  "type": "module",
5
5
  "description": "Unoverse Studio: the local authoring app. Reads your own rx/ folder, no backend.",
6
6
  "scripts": {
@@ -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%" } : {}),
@@ -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
- const [def, setDef] = useState<{ root?: unknown; org?: string; layouts?: Record<string, unknown>; defaultLayout?: string } | null>(null);
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
- const t = d as { root?: unknown; org?: string; layouts?: Record<string, unknown>; defaultLayout?: string } | null;
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 as never, store), store, theme?.appSize, def as never);
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
- const tree = renderNode(def.root, merged, handleAction, activeTheme);
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 res = await this.withSession(() => this.client.readResource({ uri }));
122
- const first = res.contents[0] as { text?: string };
123
- if (!first?.text) throw new Error(`No definition content for ${uri}`);
124
- const def = JSON.parse(first.text) as UnoverseDefinition;
125
- this.cache.set(uri, def);
126
- return def;
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 res = await this.withSession(() => this.client.readResource({ uri: `unoverse://theme/${name}` }));
137
- const first = res.contents[0] as { text?: string };
138
- if (!first?.text) throw new Error(`No theme content for ${name}`);
139
- const theme = JSON.parse(first.text) as ResolvedTheme;
140
- this.themeCache.set(name, theme);
141
- return theme;
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
- if (def?.layouts) for (const l of Object.values(def.layouts)) collectSurfacedViews(l, legacy);
40
- else if (def?.root) collectSurfacedViews(def.root, legacy);
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
- const resolved: Resolved = { mode, speed: preset.speed, opts };
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
- const state = raw && ORB_STATES.has(raw) ? (raw as OrbState) : "working";
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
- const tree = renderNode(rootNode, rootData, dispatch, theme, undefined, resolve);
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