@unoverse-platform/studio 0.1.28 → 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.28",
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": {
@@ -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);
@@ -80,10 +88,9 @@ export function AppEngine(cfg: AppConfig) {
80
88
  // definition too — same resource the renderer loads.
81
89
  client
82
90
  .connect()
83
- .then(() => client.readDefinition(`unoverse://templates/${templateId}@refs`))
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 }, "*");
@@ -183,7 +190,7 @@ export function AppEngine(cfg: AppConfig) {
183
190
  <StreamedUnoverseTemplate
184
191
  client={client}
185
192
  store={store}
186
- uri={`unoverse://templates/${templateId}@refs`}
193
+ uri={`unoverse://templates/${templateId}`}
187
194
  voice={voiceConfig}
188
195
  onAction={(action, data, meta) => {
189
196
  const t = typeof action === "string" ? action : (action as any)?.type;
@@ -83,11 +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
- // `@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);
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.
90
+ const resolvedUri = store ? (live.type ? `unoverse://components/${live.type}` : null) : (uri ?? null);
91
91
  const sourceData = store ? (extraData ? { ...(live.data ?? {}), ...extraData } : live.data) : data;
92
92
  // No theme passed → fetch it from the server (the SDK owns zero token values). The
93
93
  // theme REF comes from the definition itself: a template's `org` names its client's
@@ -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) {
@@ -123,9 +123,12 @@ export interface UnoverseDefinition {
123
123
  description?: string;
124
124
  /**
125
125
  * REFERENCE, DON'T COPY: the shared pieces this definition NAMES, by ref name, each
126
- * shipped once instead of pasted at every use. Present only when the definition was
127
- * read as `<uri>@refs`; the bare URI still serves fully inlined trees, so a renderer
128
- * without `Ref` support is never handed one it cannot draw.
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.
129
132
  */
130
133
  defs?: Record<string, UnoverseNode>;
131
134
  /** EXTERNAL contract — the rare workflow-fed inputs (`input: true`). A contained
@@ -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.
@@ -162,6 +156,24 @@ export function StreamedUnoverseTemplate({ client, store, uri, onAction, theme:
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>;