@unoverse-platform/studio 0.1.26 → 0.1.28

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/package.json +1 -1
  2. package/screens/AtomsView.tsx +40 -2
  3. package/screens/ComponentsView.tsx +112 -21
  4. package/screens/NativeAppHost.tsx +8 -4
  5. package/screens/TemplatesView.tsx +51 -21
  6. package/screens/states.ts +27 -4
  7. package/vendor/sdk/appEngine.tsx +20 -4
  8. package/vendor/sdk/lib/UnoverseComponent.tsx +8 -2
  9. package/vendor/sdk/lib/core/actions.ts +10 -6
  10. package/vendor/sdk/lib/core/client.ts +39 -12
  11. package/vendor/sdk/lib/core/index.ts +21 -0
  12. package/vendor/sdk/lib/core/refs.ts +117 -0
  13. package/vendor/sdk/lib/core/store.ts +65 -38
  14. package/vendor/sdk/lib/core/templates.ts +270 -0
  15. package/vendor/sdk/lib/core/types.ts +27 -7
  16. package/vendor/sdk/lib/orb/ThinkingOrb.tsx +131 -0
  17. package/vendor/sdk/lib/orb/engine/braid.ts +50 -0
  18. package/vendor/sdk/lib/orb/engine/core.ts +167 -0
  19. package/vendor/sdk/lib/orb/engine/lattice.ts +205 -0
  20. package/vendor/sdk/lib/orb/engine/morph.ts +138 -0
  21. package/vendor/sdk/lib/orb/engine/orbits.ts +80 -0
  22. package/vendor/sdk/lib/orb/engine/profiles.ts +162 -0
  23. package/vendor/sdk/lib/orb/engine/registry.ts +31 -0
  24. package/vendor/sdk/lib/orb/engine/ribbon.ts +93 -0
  25. package/vendor/sdk/lib/orb/engine/rings.ts +239 -0
  26. package/vendor/sdk/lib/orb/engine/types.ts +14 -0
  27. package/vendor/sdk/lib/orb/engine/web.ts +98 -0
  28. package/vendor/sdk/lib/orb/index.ts +20 -0
  29. package/vendor/sdk/lib/orb/presets.ts +181 -0
  30. package/vendor/sdk/lib/orb/theme.ts +83 -0
  31. package/vendor/sdk/lib/orb/types.ts +84 -0
  32. package/vendor/sdk/lib/primitives.tsx +116 -2
  33. package/vendor/sdk/lib/render.tsx +32 -8
  34. package/vendor/sdk/lib/style.ts +6 -2
  35. package/vendor/sdk/lib/template.tsx +86 -224
  36. package/vendor/skills/unoverse-create/SKILL.md +18 -18
  37. package/vendor/skills/unoverse-create/references/agent-skill.md +12 -12
  38. package/vendor/skills/unoverse-create/references/block.md +5 -5
  39. package/vendor/skills/unoverse-create/references/component.md +150 -75
  40. package/vendor/skills/unoverse-create/references/node.md +1 -1
  41. package/vendor/skills/unoverse-create/references/template.md +142 -82
  42. package/vendor/skills/unoverse-create/references/workflow.md +15 -15
@@ -83,7 +83,11 @@ 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
- const resolvedUri = store ? (live.type ? `unoverse://components/${live.type}` : null) : (uri ?? null);
86
+ // `@refs` asks for the REFERENCED form: shared pieces named once and shipped once,
87
+ // rather than a copy pasted at every use (746 KB became 221 KB across five real
88
+ // definitions). This client resolves them, so it asks; a client that could not would
89
+ // render a hole, which is why the bare URI still serves the inlined form.
90
+ const resolvedUri = store ? (live.type ? `unoverse://components/${live.type}@refs` : 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
89
93
  // theme REF comes from the definition itself: a template's `org` names its client's
@@ -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
@@ -63,12 +63,16 @@ export function dispatchAction(
63
63
  // Structure-bumping write (not the deferred COMPONENT_DATA path): templates re-resolve
64
64
  // their `select.where` slots the moment a component changes its own state (§5b).
65
65
  ctx.store.mergeComponentState(ctx.chatId, ctx.nodeId, patch);
66
- // A CHANGED LAYOUT IS NEWS. `defaultState` is the view a component is showing, so a
67
- // write to it means this instance just entered a different face — the moment an
68
- // `onEnterView` hook exists for. Reported on the channel path every other server
69
- // action already uses; the server decides whether any hook cares. Nothing to author:
70
- // the setValue already carries the fact, so no component repeats it in a `then`.
71
- if (ctx.nodeId && "defaultState" in patch) ctx.sendToServer?.("viewEntered", { layout: patch.defaultState });
66
+ // A CHANGED VIEW IS NEWS. The public axis (`view`, legacy alias `defaultState`) is
67
+ // the state a component is showing, so a write to it means this instance just
68
+ // entered a different state — the moment an `onEnterView` hook exists for. Reported
69
+ // on the channel path every other server action uses; the server decides whether
70
+ // any hook cares. The payload carries both spellings during the migration (the
71
+ // server's lifecycle scope historically read `layout`).
72
+ if (ctx.nodeId && ("view" in patch || "defaultState" in patch)) {
73
+ const entered = patch.view ?? patch.defaultState;
74
+ ctx.sendToServer?.("viewEntered", { layout: entered, view: entered });
75
+ }
72
76
  } else if (spec.type === "setTemplateValue") {
73
77
  // Write the dev's chosen keys into TEMPLATE state — the template-state twin of setValue.
74
78
  // No component scope. The dev builds disclosure / focus / etc. from a key THEY name
@@ -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. */
@@ -28,6 +28,24 @@ export {
28
28
  // The action interpreter (§2e-3) — local `setValue` store write + server route.
29
29
  export { dispatchAction, resolveValue, type ActionContext } from "./actions";
30
30
 
31
+ // The pure template-tier derivations (STATE_MODEL v2: the stateOrder ladder, state-
32
+ // owns-layout resolution, the pinned law, slot selection, claimed views, app width).
33
+ // Framework-agnostic — the React binding (template.tsx) and every platform port call
34
+ // these, never re-derive them.
35
+ export {
36
+ collectSurfacedViews,
37
+ computeAppWidth,
38
+ cssWidth,
39
+ formatRelative,
40
+ isViewField,
41
+ orderedClaims,
42
+ propDefaults,
43
+ resolveActiveLayout,
44
+ resolveActiveView,
45
+ selectPointers,
46
+ viewOf,
47
+ } from "./templates";
48
+
31
49
  // Experience analytics (UNOVERSE_ANALYTICS.md) — the SDK emits; the host decides where to.
32
50
  export {
33
51
  emitAnalytics,
@@ -42,3 +60,6 @@ export {
42
60
 
43
61
  // The shared "react to state" predicate (visibleWhen / Switch / style.when).
44
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
+ }
@@ -102,6 +102,26 @@ export class ComponentStore {
102
102
  this.maxTurns = opts?.maxTurns ?? 100;
103
103
  }
104
104
 
105
+ /**
106
+ * RETRACT ONE SLICE to its declared base (STATE_MODEL v2 §8 item 6). The public
107
+ * axis is `view` with `defaultState` as the legacy alias; the retract target is the
108
+ * slice's declared initial (`initialView`, seeded at spawn) falling back to
109
+ * `"inline"` (the universal fallback). Returns true when the slice changed. This is
110
+ * the ONE home for the reset every boundary uses (new turn, guest close, template
111
+ * swap) — never re-hardcode `"inline"` at a call site.
112
+ */
113
+ private retractSlice(key: string, slice: Record<string, unknown>): boolean {
114
+ const current = (slice.view ?? slice.defaultState) as unknown;
115
+ const base = typeof slice.initialView === "string" ? slice.initialView : "inline";
116
+ if (typeof current !== "string" || current === base) return false;
117
+ const next: Record<string, unknown> = { ...slice };
118
+ if ("view" in next || !("defaultState" in next)) next.view = base;
119
+ if ("defaultState" in next) next.defaultState = base;
120
+ this.data.set(key, next);
121
+ this.touches.set(key, ++this.touchSeq);
122
+ return true;
123
+ }
124
+
105
125
  // ============ writes ============
106
126
 
107
127
  /** The current composer draft (a key in template state). */
@@ -131,12 +151,7 @@ export class ComponentStore {
131
151
  // slices persist; only the active VIEW resets) — swapping back re-presents nothing
132
152
  // until something surfaces again.
133
153
  if (this.activeTemplateName !== null && this.activeTemplateName !== name) {
134
- for (const [key, slice] of this.data) {
135
- if (typeof slice.defaultState === "string" && slice.defaultState !== "inline") {
136
- this.data.set(key, { ...slice, defaultState: "inline" });
137
- this.touches.set(key, ++this.touchSeq);
138
- }
139
- }
154
+ for (const [key, slice] of this.data) this.retractSlice(key, slice);
140
155
  }
141
156
  this.activeTemplateName = name;
142
157
  this.activeTemplateProps = props;
@@ -152,15 +167,40 @@ export class ComponentStore {
152
167
  * calls this when a template-chrome `setValue defaultState:"inline"` fires (a card that
153
168
  * closes ITSELF still uses the plain component write; template chrome has no card to hit).
154
169
  */
170
+ /**
171
+ * ARRIVALS REFRESH THEIR STATE (THE SEVEN RULES, 2026-08-09, rule 5): when a
172
+ * component ARRIVES into state X (a server placement — never a guest write),
173
+ * components from EARLIER turns sitting in X retract to inline. New cards
174
+ * replace the rail's old cards; a new wizard replaces the old wizard — latest
175
+ * wins, at every rung. Same-turn siblings coexist (one answer's card set is one
176
+ * delivery, so eviction is keyed by TURN, not by instance). Durable
177
+ * (`lifetime: "conversation"`) slices opt out, as with every retract. Whether a
178
+ * refresh is VISIBLE is the ladder's business alone (rule 4: higher states mask;
179
+ * lower states update silently underneath).
180
+ */
181
+ private supersede(arrivingChatId: string, view: unknown): void {
182
+ if (typeof view !== "string" || !view || view === "inline") return;
183
+ let changed = false;
184
+ for (const [key, slice] of this.data) {
185
+ if (key.startsWith(`${arrivingChatId}:`)) continue; // same turn = same delivery
186
+ if (slice.lifetime === "conversation") continue;
187
+ const v = (slice.view ?? slice.defaultState) as unknown;
188
+ if (v !== view) continue;
189
+ const next: Record<string, unknown> = { ...slice };
190
+ if ("view" in next || !("defaultState" in next)) next.view = "inline";
191
+ if ("defaultState" in next) next.defaultState = "inline";
192
+ this.data.set(key, next);
193
+ this.touches.set(key, ++this.touchSeq);
194
+ changed = true;
195
+ }
196
+ if (changed) this.bump();
197
+ }
198
+
155
199
  closeSurfaces(): void {
156
200
  let changed = false;
157
201
  for (const [key, slice] of this.data) {
158
202
  if (slice.lifetime === "conversation") continue;
159
- if (typeof slice.defaultState === "string" && slice.defaultState !== "inline") {
160
- this.data.set(key, { ...slice, defaultState: "inline" });
161
- this.touches.set(key, ++this.touchSeq);
162
- changed = true;
163
- }
203
+ if (this.retractSlice(key, slice)) changed = true;
164
204
  }
165
205
  if (changed) this.bump();
166
206
  }
@@ -212,6 +252,9 @@ export class ComponentStore {
212
252
  this.types.set(key, event.component.type);
213
253
  this.placeInTimeline(event.chatId, key); // structure: place the pointer ONCE
214
254
  this.merge(key, event.component.props ?? {}); // data: seed
255
+ // Rule 5 — an arrival carrying a public state refreshes that state.
256
+ const seeded = event.component.props as Record<string, unknown> | undefined;
257
+ this.supersede(event.chatId, seeded?.view ?? seeded?.defaultState);
215
258
  } else {
216
259
  // NEW ACTIVITY reads newest-at-the-bottom: if this component is no longer the
217
260
  // LAST pointer in its turn (something was placed after it), fresh server data
@@ -225,42 +268,26 @@ export class ComponentStore {
225
268
  this.bump();
226
269
  }
227
270
  this.merge(key, event.data);
271
+ // Rule 5 — a server data-write that PLACES a component into a public state
272
+ // (the host's arrival placement rides COMPONENT_DATA) refreshes that state.
273
+ this.supersede(event.chatId, (event.data as Record<string, unknown> | undefined)?.view ?? (event.data as Record<string, unknown> | undefined)?.defaultState);
228
274
  }
229
275
  }
230
276
 
231
- /** Append a user message turn. Response identity = its chatId. */
277
+ /** Append a user message turn. Response identity = its chatId.
278
+ *
279
+ * RULE 7 (the seven rules, 2026-08-09): typing changes NOTHING by itself. The
280
+ * old new-turn reset (`beginExchange`, the "two lifetimes" chat-layer wipe) is
281
+ * GONE — only arrivals move the screen (rule 5's same-state refresh) and only
282
+ * closings release it (rule 6's ladder re-look). A wizard mid-flow survives the
283
+ * guest's next message; a stale rail is cleared by the cards that replace it,
284
+ * never by the message that asked for them. */
232
285
  addUserMessage(chatId: string, text: string): void {
233
286
  this.timeline.push({ role: "user", id: `u${this.seq++}`, chatId, text, createdAt: Date.now() });
234
- this.beginExchange();
235
287
  this.evict();
236
288
  this.bump();
237
289
  }
238
290
 
239
- /**
240
- * TWO LIFETIMES, ONE STORE (docs/design/04): CONVERSATION state (turns + each
241
- * instance's data — durable, append-only, the stream owns it) vs CHAT state (the
242
- * present interaction: each instance's active VIEW, the template's chrome). A new
243
- * user turn advances the conversation, so the chat layer returns to its universal
244
- * default: every instance goes back to `inline` — surfaces empty, the template
245
- * derives its base state, panels retract. Data is untouched; history keeps every
246
- * card. (A future durable, conversation-scoped component — a cart, an itinerary —
247
- * will opt out of this explicitly; today nothing does.)
248
- */
249
- private beginExchange(): void {
250
- for (const [key, slice] of this.data) {
251
- // DURABLE conversation-scoped surface (docs/design/04 §Two lifetimes): the
252
- // producer marked the slice `lifetime: "conversation"` (a cart, an itinerary,
253
- // a composed page) — it opted out of the new-turn reset and stays surfaced
254
- // until replaced, closed, or the template swaps. Generic: a key the producer
255
- // wrote, not a UI concept.
256
- if (slice.lifetime === "conversation") continue;
257
- if (typeof slice.defaultState === "string" && slice.defaultState !== "inline") {
258
- this.data.set(key, { ...slice, defaultState: "inline" });
259
- this.touches.set(key, ++this.touchSeq);
260
- }
261
- }
262
- }
263
-
264
291
  /**
265
292
  * Open (or re-open) the assistant response for chatId in streaming state, WITHOUT
266
293
  * attaching a component. Mirrors legacy WORKFLOW_STARTED (processWorkflowState.ts):