@unoverse-platform/studio 0.1.86 → 0.1.88

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@unoverse-platform/studio",
3
- "version": "0.1.86",
3
+ "version": "0.1.88",
4
4
  "type": "module",
5
5
  "description": "Unoverse Studio: the local authoring app. Reads your own rx/ folder, no backend.",
6
6
  "scripts": {
@@ -94,6 +94,7 @@ export function TemplatesLibraryView({ theme, org }: { theme: ResolvedTheme; org
94
94
  const [templates, setTemplates] = useState<TemplateDef[]>([]);
95
95
  const [selected, setSelected] = usePersistentState<string>("wb:template", "");
96
96
  const [panelOpen, setPanelOpen] = useState(true);
97
+ const [metaOpen, setMetaOpen] = usePersistentState<boolean>("wb:templates:meta", false); // manifest meta panel
97
98
  const [panelHeight, setPanelHeight] = useState(200);
98
99
  const [stageWidth, setStageWidth] = useState<number | null>(768);
99
100
 
@@ -247,6 +248,24 @@ export function TemplatesLibraryView({ theme, org }: { theme: ResolvedTheme; org
247
248
  label="Available"
248
249
  />
249
250
  )}
251
+ {/* META — the same disclosure Components and Apps carry, on the same
252
+ mandatory manifest (§9c: a template is discovered like everything
253
+ else, so what it is DISCOVERED BY must be readable where it is
254
+ designed). Disclosure only; it changes nothing. */}
255
+ {current?.spatial && (
256
+ <button
257
+ onClick={() => setMetaOpen((o) => !o)}
258
+ className="flex items-center gap-1 rounded border px-2 py-0.5 text-[11px] font-medium"
259
+ style={{
260
+ borderColor: theme.color["border.subtle"],
261
+ color: metaOpen ? theme.color["text.primary"] : theme.color["text.secondary"],
262
+ background: metaOpen ? theme.color["surface.sunken"] : "transparent",
263
+ }}
264
+ title="Show the template's manifest (spatial discovery meta)"
265
+ >
266
+ ⓘ Meta <span className="opacity-60">{metaOpen ? "▴" : "▾"}</span>
267
+ </button>
268
+ )}
250
269
  {shapeNames.length > 1 && (
251
270
  <div className="flex items-center gap-1 rounded p-0.5 text-[11px]" style={{ background: theme.color["border.subtle"] }}>
252
271
  {shapeNames.map((name, i) => {
@@ -312,6 +331,42 @@ export function TemplatesLibraryView({ theme, org }: { theme: ResolvedTheme; org
312
331
  </span>
313
332
  </div>
314
333
 
334
+ {/* MANIFEST META — identical anatomy to the Components and Apps panels:
335
+ title / category chip / muted tag, description, when-to-use callout. */}
336
+ {metaOpen && current?.spatial && (() => {
337
+ const accent = theme.color["action.primary"] ?? "#6366f1";
338
+ return (
339
+ <div className="border-b px-5 py-4" style={{ borderColor: theme.color["border.subtle"], background: theme.color["surface.base"] }}>
340
+ <div className="mx-auto flex max-w-5xl flex-col gap-1">
341
+ <div className="flex flex-wrap items-center gap-2">
342
+ <h2 className="text-[17px] font-semibold leading-tight tracking-tight" style={{ color: theme.color["text.primary"] }}>
343
+ {current.title || current.name}
344
+ </h2>
345
+ {current.category && (
346
+ <span className="rounded-full px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide" style={{ background: `${accent}14`, color: accent }}>
347
+ {current.category}
348
+ </span>
349
+ )}
350
+ <span className="font-mono text-[10px]" style={{ color: theme.color["text.tertiary"] }}>
351
+ manifest.json
352
+ </span>
353
+ </div>
354
+ {current.description && (
355
+ <p className="mt-1 max-w-3xl text-[13px] leading-relaxed" style={{ color: theme.color["text.secondary"] }}>{current.description}</p>
356
+ )}
357
+ {current.whenToUse && (
358
+ <div className="mt-2 max-w-3xl rounded-r py-1.5 pl-3" style={{ borderLeft: `3px solid ${accent}`, background: theme.color["surface.sunken"] }}>
359
+ <div className="mb-0.5 text-[10px] font-semibold uppercase tracking-wider" style={{ color: accent }}>
360
+ When to use
361
+ </div>
362
+ <p className="text-[15px] font-medium leading-relaxed" style={{ color: theme.color["text.primary"] }}>{current.whenToUse}</p>
363
+ </div>
364
+ )}
365
+ </div>
366
+ </div>
367
+ );
368
+ })()}
369
+
315
370
  <div className="flex-1 overflow-auto p-10">
316
371
  <div className="mx-auto max-w-full transition-[width] duration-200" style={{ width: stageWidth ?? "100%" }}>
317
372
  {uri && <StreamedUnoverseApp key={uri} client={client} store={store} uri={uri} theme={theme} />}
@@ -331,11 +331,11 @@ export function StreamedUnoverseApp({ client, store, uri, onAction, theme: theme
331
331
  */
332
332
  const tstate = store.getAppState() as Record<string, unknown>;
333
333
  const dismissedRaw = tstate.latchDismissed;
334
- const held = def ? resolveLatch(def, store, typeof dismissedRaw === "string" ? dismissedRaw : undefined) : null;
334
+ const held = def ? resolveLatch(def, store, ladder.view, typeof dismissedRaw === "string" ? dismissedRaw : undefined) : null;
335
335
  useEffect(() => {
336
336
  if (dismissedRaw === true) {
337
337
  // Resolve the bare flag against whoever holds it RIGHT NOW, then re-derive.
338
- const current = def ? resolveLatch(def, store) : null;
338
+ const current = def ? resolveLatch(def, store, ladder.view) : null;
339
339
  store.mergeAppState({ latchDismissed: current ? current.key : "" });
340
340
  return;
341
341
  }
@@ -353,6 +353,7 @@ export function StreamedUnoverseApp({ client, store, uri, onAction, theme: theme
353
353
  held?.key,
354
354
  held?.latch,
355
355
  dismissedRaw,
356
+ ladder.view,
356
357
  def,
357
358
  store,
358
359
  tstate.latched,
@@ -13,7 +13,7 @@
13
13
  */
14
14
  import { useCallback, useEffect, useRef, useState, useSyncExternalStore } from "react";
15
15
  import { emitAnalytics } from "./core/analytics";
16
- import { applyServerMessage, resolveLatch, type ComponentStore, type ServerMessage } from "./core";
16
+ import { applyServerMessage, viewOf, type ComponentStore, type ServerMessage } from "./core";
17
17
  import { Client } from "@modelcontextprotocol/sdk/client/index.js";
18
18
  import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
19
19
  // Interpreter-based JSON-schema validator (no `new Function`). The SDK's default Ajv
@@ -344,58 +344,59 @@ export function useUnoverseConnection(
344
344
  (text: string) => {
345
345
  const message = text.trim();
346
346
  if (!message) return;
347
- const turn = mintTurn(); // ONE turn id shared by the user message, the assistant response, and the run
348
- store.addUserMessage(turn, message); // optimistic user turn
349
- // Optimistically open the assistant turn too, so the avatar + thinking dots
350
- // appear IMMEDIATELY on send instead of waiting for the backend's
351
- // WORKFLOW_STARTED. startResponse is idempotent — the later WORKFLOW_STARTED
352
- // (and the first COMPONENT_INIT) reuse this same turn (responseFor by chatId).
353
- store.startResponse(turn);
354
347
  /**
355
348
  * THE LATCH RIDES THE ORDINARY SEND (docs/MCP_COMPLETE_GUIDE.md §The Component Latch).
356
349
  *
357
- * Resolved HERE rather than in a host, because both hosts call this one function and
350
+ * Handled HERE rather than in a host, because both hosts call this one function and
358
351
  * two hosts each deciding "what happens on send" is how the composer regression
359
- * happened. The user turn is still minted normally — the guest said something and it
360
- * belongs in the timeline; the latch only tells the workflow WHERE the answer lands.
352
+ * happened.
361
353
  *
362
- * `state` is the component's live slice, so the workflow is told what the page
363
- * currently holds without a round trip, and it stays right after local `setValue`
364
- * writes the server never saw. Sent as one `latch` object so an unlatched send is
365
- * byte-for-byte what it was before.
354
+ * READ, NOT RE-DERIVED. `app.tsx` runs `resolveLatch` every render and publishes
355
+ * `latched`/`latchKey` into app state for the composer's pill; this reads that same
356
+ * published fact. Deriving it again here is what left the send inert for two weeks:
357
+ * `resolveLatch` needs the app DEFINITION and this file holds a raw MCP `Client` with
358
+ * no definition cache, so the second derivation could never see `def.latches` and
359
+ * silently answered "not latched" on every send.
366
360
  */
361
+ const app = store.getAppState() as Record<string, unknown>;
362
+ const held =
363
+ app.latched === true && typeof app.latchKey === "string" && app.latchKey ? store.split(app.latchKey) : null;
367
364
  /**
368
- * UNFINISHED, and deliberately shipped inert rather than guessed at.
369
- *
370
- * `peekDefinition` is the SYNCHRONOUS cached read the send path needs (a send cannot
371
- * await a resources/read), and it lives on `UnoverseClient` — the wrapper holding the
372
- * definition cache, constructed in appEngine.tsx. THIS file holds a raw MCP `Client`
373
- * (line 137), which has no cache and no such method, so the lookup finds nothing here.
365
+ * THE RUN REUSES THE LATCHED TURN; the guest's message does not.
374
366
  *
375
- * Optional-called through a narrow type so the shape is honest: null is already a
376
- * legitimate answer meaning "no opinion", `resolveLatch` is not called for it, and an
377
- * unlatched send is byte-for-byte what it was before. The day this ref carries the
378
- * wrapper, latching starts working with no change here.
367
+ * The user turn is minted normally either way — the guest said something and it
368
+ * belongs in the timeline where they said it. What the latch changes is where the
369
+ * ANSWER opens: `startResponse` and the trigger both take the held component's own
370
+ * chatId, so every render in that run keys `chatId:nodeId` to what is already on
371
+ * screen, and a repeated COMPONENT_INIT on a seen key is already a props merge
372
+ * (applyServerMessage). Hydrate in place, by construction, with no new return path.
379
373
  */
380
- const cache = clientRef.current as unknown as
381
- | { peekDefinition?: (uri: string) => Parameters<typeof resolveLatch>[0] }
382
- | null;
383
- const tmpl = store.getActiveApp().name;
384
- const def = tmpl ? (cache?.peekDefinition?.(`unoverse://apps/${tmpl}/definition`) ?? null) : null;
385
- const held = def ? resolveLatch(def, store) : null;
374
+ const turn = mintTurn();
375
+ store.addUserMessage(turn, message); // optimistic user turn
376
+ const run = held ? held.chatId : turn;
377
+ // Optimistically open the assistant turn too, so the avatar + thinking dots
378
+ // appear IMMEDIATELY on send instead of waiting for the backend's
379
+ // WORKFLOW_STARTED. startResponse is idempotent — the later WORKFLOW_STARTED
380
+ // (and the first COMPONENT_INIT) reuse this same turn (responseFor by chatId), and
381
+ // while latched that turn is the page's own, which is exactly the point.
382
+ store.startResponse(run);
386
383
  trigger(
387
384
  held
388
385
  ? {
389
386
  message,
387
+ // `state` is the component's live slice, so the workflow is told what the page
388
+ // currently holds without a round trip, and it stays right after local
389
+ // `setValue` writes the server never saw. Sent as one `latch` object so an
390
+ // unlatched send is byte-for-byte what it was before.
390
391
  latch: {
391
392
  chatId: held.chatId,
392
393
  nodeId: held.nodeId,
393
- view: held.view,
394
+ view: viewOf(store.get(held.chatId, held.nodeId) as Record<string, unknown>) ?? "",
394
395
  state: store.get(held.chatId, held.nodeId) ?? {},
395
396
  },
396
397
  }
397
398
  : { message },
398
- turn,
399
+ run,
399
400
  );
400
401
  },
401
402
  [mintTurn, store, trigger],
@@ -271,17 +271,25 @@ export function resolveActiveView(
271
271
  * conversation is currently ADDRESSED AT, or none.
272
272
  *
273
273
  * DERIVED, never stored, for the same reason `lifecycle` is (STATE_MODEL §3): nothing
274
- * registers a latch and nothing unregisters one. A component becomes latchable by carrying
275
- * `latch` on its slice (seeded from its manifest with the render); it stops holding the
276
- * latch when its slice evicts or leaves the surface. There is no set, no stack, and no
277
- * "latch this one too" — this function returns ONE holder or null, so two latches are not
278
- * a bug to prevent but a state that cannot be represented.
274
+ * registers a latch and nothing unregisters one. THE STATE IS WHAT IS LATCHABLE, not the
275
+ * component (moved off the manifest 2026-08-31): an instance holds the latch while it is
276
+ * wearing a state the app declared a `latch` on, and stops the moment it wears one that
277
+ * says nothing — retracted inline, superseded, evicted, all the same answer with no code.
278
+ * There is no set, no stack, and no "latch this one too" — this function returns ONE
279
+ * holder or null, so two latches are not a bug to prevent but a state that cannot be
280
+ * represented.
279
281
  *
280
- * SAME WALK AS `resolveActiveView`, deliberately: the ladder is the ladder. A latchable
281
- * slice must be sitting in one of the template's declared states to hold the latch, and
282
- * the highest-ranked one wins, ties going to the most recent write. So a page retracted to
283
- * `inline` (superseded by an arrival, released by `cancelBelow`, closed by the guest)
284
- * silently stops holding it rather than leaving a pill pointing at something off-surface.
282
+ * THE HOLDER MUST BE THE STATE ON SCREEN, which is why `active` is a required argument
283
+ * rather than something derived again here. Being ON the ladder is not enough: an app whose
284
+ * ladder is [page, perfect-day] shows `page` the moment a card arrives wearing that face,
285
+ * and the composed page — still live, still ranked, just not showing — went on holding the
286
+ * latch. The guest then reads a pill naming a page that is nowhere on screen and types into
287
+ * it (observed live 2026-08-31: three search cards took the surface and "Your Perfect Day"
288
+ * stayed up over them). A pill is a promise about what the words will reach.
289
+ *
290
+ * SAME WALK AS `resolveActiveView` otherwise: the highest-ranked wins, ties going to the
291
+ * most recent write. So a page retracted to `inline` (superseded by an arrival, released by
292
+ * `cancelBelow`, closed by the guest) silently stops holding it too.
285
293
  *
286
294
  * Returns the FULL key. `chatId` alone reaches the turn, not the component inside it: a
287
295
  * turn carrying two components would hydrate the wrong one.
@@ -289,15 +297,23 @@ export function resolveActiveView(
289
297
  export function resolveLatch(
290
298
  def: UnoverseDefinition | null | undefined,
291
299
  store: ComponentStore,
300
+ /** The state the app is ACTUALLY showing (`resolveActiveView().view`). Empty = nothing is
301
+ * surfaced, so nothing can be addressed. */
302
+ active: string,
292
303
  dismissed?: string,
293
- ): { key: string; chatId: string; nodeId: string; latch: Record<string, unknown>; view: string } | null {
304
+ ): { key: string; chatId: string; nodeId: string; latch: { title: string; icon?: string }; view: string } | null {
294
305
  const order = def?.stateOrder?.length ? def.stateOrder : undefined;
295
306
  if (!order) return null; // no ladder, no ranking — abstain rather than guess a holder
307
+ const latches = def?.latches;
308
+ if (!latches || !Object.keys(latches).length) return null; // no state is addressable here
309
+ // WHAT IS ON SCREEN, first: an addressable state that is not the active one is not being
310
+ // shown, and a pill over someone else's surface points at nothing the guest can see.
311
+ if (!active || !latches[active]) return null;
296
312
  // `key` is the component key this candidate came from, carried so the return can name the
297
313
  // FULL key (line 269) rather than re-deriving it from chatId, which reaches the turn and
298
314
  // not the component inside it.
299
315
  let best:
300
- | { key: string; chatId: string; nodeId: string; latch: Record<string, unknown>; view: string; rank: number; at: number }
316
+ | { key: string; chatId: string; nodeId: string; latch: { title: string; icon?: string }; view: string; rank: number; at: number }
301
317
  | null = null;
302
318
  for (const turn of store.getResponses())
303
319
  for (const p of turn.components) {
@@ -308,15 +324,20 @@ export function resolveLatch(
308
324
  if (dismissed && dismissed === p) continue;
309
325
  const { chatId, nodeId } = store.split(p);
310
326
  const slice = store.get(chatId, nodeId) as Record<string, unknown>;
311
- const latch = slice?.latch;
312
- if (!latch || typeof latch !== "object") continue;
313
327
  const view = viewOf(slice);
314
328
  if (!view) continue;
315
329
  const rank = order.indexOf(view);
316
330
  if (rank < 0) continue; // not on the surface (inline/retracted): holds nothing
331
+ // THE STATE DECIDES, and it must be the state SHOWING. Same lookup for every instance,
332
+ // so nothing has to be told it is addressable and nothing has to be untold — the face
333
+ // it currently wears is the whole question, and a face that is not the active one is
334
+ // not on screen to be addressed.
335
+ if (view !== active) continue;
336
+ const latch = latches[view];
337
+ if (!latch) continue;
317
338
  const at = store.touchOf(p);
318
339
  if (!best || rank < best.rank || (rank === best.rank && at > best.at))
319
- best = { key: p, chatId, nodeId, latch: latch as Record<string, unknown>, view, rank, at };
340
+ best = { key: p, chatId, nodeId, latch, view, rank, at };
320
341
  }
321
342
  return best ? { key: best.key, chatId: best.chatId, nodeId: best.nodeId, latch: best.latch, view: best.view } : null;
322
343
  }
@@ -208,6 +208,14 @@ export interface UnoverseDefinition {
208
208
  * ladder and arrival-scan order, served from the manifest. First entry outranks
209
209
  * all below it; no name is special. Absent → legacy v1 recency semantics. */
210
210
  stateOrder?: string[];
211
+ /** THE LATCH, PER STATE (docs/MCP_COMPLETE_GUIDE.md §The Component Latch, moved off the
212
+ * manifest 2026-08-31): the states whose wearer the conversation may be ADDRESSED AT,
213
+ * keyed by state name, compiled from each state's own `latch:` block.
214
+ *
215
+ * A state absent from this map can never hold the latch, so the SAME instance retracted
216
+ * from its page state to an inline one drops the pill by construction. `resolveLatch`
217
+ * reads this; nothing about the latch travels on the wire with a render. */
218
+ latches?: Record<string, { title: string; icon?: string }>;
211
219
  }
212
220
 
213
221
  // ---- The interaction vocabulary (UNOVERSE_SPEC.md §2e-3) ----