@unoverse-platform/studio 0.1.41 → 0.1.43

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.41",
3
+ "version": "0.1.43",
4
4
  "type": "module",
5
5
  "description": "Unoverse Studio: the local authoring app. Reads your own rx/ folder, no backend.",
6
6
  "scripts": {
@@ -27,6 +27,8 @@ interface PropDef {
27
27
  }
28
28
  interface ComponentDef {
29
29
  name: string;
30
+ /** The owning org for an org component (server-tagged); marketplace tier has none. */
31
+ org?: string;
30
32
  /** Display name from the manifest ("Card Finder"); falls back to `name`. */
31
33
  title?: string;
32
34
  description?: string;
@@ -119,17 +121,22 @@ export function ComponentsView({ theme, org = "all" }: { theme: ResolvedTheme; o
119
121
  // SDK's `Each` saw a string instead of a list and rendered nothing. Editor display
120
122
  // strings are derived separately into `text`.
121
123
  const toText = (v: unknown) => (typeof v === "string" ? v : v == null ? "" : JSON.stringify(v, null, 2));
124
+ // The component's IDENTITY everywhere identity matters (selection, store slices,
125
+ // Copy for Canvas, URIs): the qualified ref for an org component, bare for the
126
+ // marketplace tier. Names are unique only within a tier, so keying on the bare name
127
+ // would collide two orgs' same-named components. Display keeps the bare name.
128
+ const refOf = (c: ComponentDef): string => (c.org ? `${c.org}/${c.name}` : c.name);
122
129
  const defaultsOf = (c: ComponentDef): Record<string, unknown> =>
123
130
  Object.fromEntries(
124
131
  Object.entries(c.props ?? {}).map(([k, v]) => [k, v.preview ?? v.default ?? (v.type === "array" ? [] : v.type === "object" ? {} : "")]),
125
132
  );
126
133
 
127
134
  function choose(c: ComponentDef) {
128
- setSelected(c.name);
135
+ setSelected(refOf(c));
129
136
  const defaults = defaultsOf(c);
130
137
  setValues(defaults);
131
138
  setText(Object.fromEntries(Object.entries(defaults).map(([k, v]) => [k, toText(v)])));
132
- store.apply({ type: "COMPONENT_INIT", chatId: CHAT_ID, nodeId: c.name, component: { type: c.name, props: defaults } });
139
+ store.apply({ type: "COMPONENT_INIT", chatId: CHAT_ID, nodeId: refOf(c), component: { type: refOf(c), props: defaults } });
133
140
  // SEED THE ARRIVAL FACE, from the one place it lives. `manifest.defaultState` is the
134
141
  // render contract, and the server merges it into the composed `state` block
135
142
  // (definitions.ts — "so the SDK, which merges def.state beneath live data, renders
@@ -142,9 +149,9 @@ export function ComponentsView({ theme, org = "all" }: { theme: ResolvedTheme; o
142
149
  // the arrival face; `state.defaultState` is the legacy spelling. Seed whichever
143
150
  // the def declares, under the same key the renderer resolves.
144
151
  const st = c.state as Record<string, unknown> | undefined;
145
- if (typeof st?.view === "string") store.apply({ type: "COMPONENT_DATA", chatId: CHAT_ID, nodeId: c.name, data: { view: st.view } });
152
+ if (typeof st?.view === "string") store.apply({ type: "COMPONENT_DATA", chatId: CHAT_ID, nodeId: refOf(c), data: { view: st.view } });
146
153
  else if (typeof st?.defaultState === "string")
147
- store.apply({ type: "COMPONENT_DATA", chatId: CHAT_ID, nodeId: c.name, data: { defaultState: st.defaultState } });
154
+ store.apply({ type: "COMPONENT_DATA", chatId: CHAT_ID, nodeId: refOf(c), data: { defaultState: st.defaultState } });
148
155
  }
149
156
 
150
157
  useEffect(() => {
@@ -156,7 +163,7 @@ export function ComponentsView({ theme, org = "all" }: { theme: ResolvedTheme; o
156
163
  const list: ComponentDef[] = d.components ?? [];
157
164
  setComponents(list);
158
165
  // Restore the persisted selection across reloads; fall back to the first.
159
- const restore = list.find((c) => c.name === selected) ?? list[0];
166
+ const restore = list.find((c) => refOf(c) === selected) ?? list[0];
160
167
  if (restore) choose(restore);
161
168
  })
162
169
  .catch(() => setComponents([]));
@@ -167,7 +174,7 @@ export function ComponentsView({ theme, org = "all" }: { theme: ResolvedTheme; o
167
174
  // toggle highlight + grow container, not just the rendered component).
168
175
  useEffect(() => store.subscribe(() => setStoreTick((t) => t + 1)), [store]);
169
176
 
170
- const current = components.find((c) => c.name === selected);
177
+ const current = components.find((c) => refOf(c) === selected);
171
178
  // The TWO MASTER STATES (inline / focused) are universal — every component's root
172
179
  // switches its face on `defaultState` (STATE_MODEL §5b). The toolbar control writes
173
180
  // that key into the slice — the same generic setValue/COMPONENT_DATA path a real
@@ -329,7 +336,9 @@ export function ComponentsView({ theme, org = "all" }: { theme: ResolvedTheme; o
329
336
  // data. UIComponentNode layers props [schema defaults] < [executed] < [config], so
330
337
  // anything written here outranks the live definition permanently: spreading the
331
338
  // previews in froze them into the workflow at copy time.
332
- config: { component: current.name },
339
+ // Qualified ref for an org component (`<org>/<name>`): the selector identity in
340
+ // the generic Component node's oneOf, and unambiguous once two orgs share a name.
341
+ config: { component: refOf(current) },
333
342
  },
334
343
  style: { width: size.width },
335
344
  draggable: true,
@@ -377,14 +386,15 @@ export function ComponentsView({ theme, org = "all" }: { theme: ResolvedTheme; o
377
386
  <div key={cat} className="mb-3">
378
387
  <div className="px-2 py-1 text-[10px] font-semibold uppercase tracking-wider text-gray-400">{cat}</div>
379
388
  {items.map((c) => (
380
- <div key={c.name}>
389
+ <div key={refOf(c)}>
381
390
  <button
382
391
  onClick={() => choose(c)}
383
392
  className={`block w-full rounded px-2 py-1 text-left text-sm transition ${
384
- selected === c.name ? "bg-emerald-500/15 text-emerald-300" : "text-gray-300 hover:bg-white/5"
393
+ selected === refOf(c) ? "bg-emerald-500/15 text-emerald-300" : "text-gray-300 hover:bg-white/5"
385
394
  }`}
386
395
  >
387
396
  {c.name}
397
+ {c.org ? <span className="ml-1.5 text-[10px] text-gray-500">{c.org}</span> : null}
388
398
  </button>
389
399
  {/* The component's OWN states (its states/ folder) live HERE in the rail.
390
400
  The two MASTER states (inline/focused — the `defaultState` face switch,
@@ -399,7 +409,7 @@ export function ComponentsView({ theme, org = "all" }: { theme: ResolvedTheme; o
399
409
  Clicking a step's layout puts the preview into that step. The
400
410
  states themselves live in the toolbar pills above — one control
401
411
  per axis, no duplication. */}
402
- {selected === c.name && !sourceOpen && stateTree?.length ? (
412
+ {selected === refOf(c) && !sourceOpen && stateTree?.length ? (
403
413
  (() => {
404
414
  const st = stateTree.find((s) => s.name === masterState);
405
415
  if (!st) return null;
@@ -409,14 +419,14 @@ export function ComponentsView({ theme, org = "all" }: { theme: ResolvedTheme; o
409
419
  // choice either: nothing lists.
410
420
  const entries = (st.states ?? []).map((sub) => ({ label: sub.name, step: sub.name }));
411
421
  if (entries.length < 2 || !st.on) return null;
412
- const cur = (store.get(CHAT_ID, c.name) as Record<string, unknown>)[st.on] ?? st.initial;
422
+ const cur = (store.get(CHAT_ID, refOf(c)) as Record<string, unknown>)[st.on] ?? st.initial;
413
423
  return (
414
424
  <div className="my-0.5 ml-2 flex flex-col gap-px border-l border-gray-700 pl-2">
415
425
  {entries.map((e) => (
416
426
  <button
417
427
  key={e.label}
418
428
  onClick={() => {
419
- store.apply({ type: "COMPONENT_DATA", chatId: CHAT_ID, nodeId: c.name, data: { [st.on!]: e.step } });
429
+ store.apply({ type: "COMPONENT_DATA", chatId: CHAT_ID, nodeId: refOf(c), data: { [st.on!]: e.step } });
420
430
  setStoreTick((v) => v + 1);
421
431
  }}
422
432
  className={`block w-full truncate rounded px-2 py-0.5 text-left text-xs capitalize transition ${
@@ -430,20 +440,20 @@ export function ComponentsView({ theme, org = "all" }: { theme: ResolvedTheme; o
430
440
  );
431
441
  })()
432
442
  ) : null}
433
- {selected === c.name && !sourceOpen && !stateTree?.length && (c.states?.length ?? 0) > 0 && (
443
+ {selected === refOf(c) && !sourceOpen && !stateTree?.length && (c.states?.length ?? 0) > 0 && (
434
444
  <div className="my-0.5 ml-2 flex flex-col gap-px border-l border-gray-700 pl-2">
435
445
  {(c.states ?? []).map((s) => {
436
446
  const data = stateActivation(c.root, s);
437
447
  const activeKey = data ? Object.keys(data)[0] : null;
438
448
  const isActive =
439
449
  Boolean(activeKey) &&
440
- (store.get(CHAT_ID, c.name) as Record<string, unknown>)[activeKey!] === (data as Record<string, unknown>)[activeKey!];
450
+ (store.get(CHAT_ID, refOf(c)) as Record<string, unknown>)[activeKey!] === (data as Record<string, unknown>)[activeKey!];
441
451
  return (
442
452
  <button
443
453
  key={s.name}
444
454
  onClick={() => {
445
455
  if (!data) return;
446
- store.apply({ type: "COMPONENT_DATA", chatId: CHAT_ID, nodeId: c.name, data });
456
+ store.apply({ type: "COMPONENT_DATA", chatId: CHAT_ID, nodeId: refOf(c), data });
447
457
  setStoreTick((v) => v + 1);
448
458
  }}
449
459
  className={`block w-full truncate rounded px-2 py-0.5 text-left text-xs capitalize transition ${
@@ -459,16 +469,16 @@ export function ComponentsView({ theme, org = "all" }: { theme: ResolvedTheme; o
459
469
  {/* CONTEXTUAL SUB-STATES (LEGACY Switch-scan only — a v2 tree renders
460
470
  its steps nested above): the ACTIVE face's internal steps (its
461
471
  private Switch). Listed only while their face is active. */}
462
- {selected === c.name && !sourceOpen && !stateTree?.length && subStates.length > 0 && (
472
+ {selected === refOf(c) && !sourceOpen && !stateTree?.length && subStates.length > 0 && (
463
473
  <div className="my-0.5 ml-2 flex flex-col gap-px border-l border-gray-700 pl-2">
464
474
  {subStates.map((s, i) => {
465
- const cur = (store.get(CHAT_ID, c.name) as Record<string, unknown>)[s.on];
475
+ const cur = (store.get(CHAT_ID, refOf(c)) as Record<string, unknown>)[s.on];
466
476
  const on = cur === s.name || (i === 0 && (cur === undefined || cur === ""));
467
477
  return (
468
478
  <button
469
479
  key={s.name}
470
480
  onClick={() => {
471
- store.apply({ type: "COMPONENT_DATA", chatId: CHAT_ID, nodeId: c.name, data: { [s.on]: s.name } });
481
+ store.apply({ type: "COMPONENT_DATA", chatId: CHAT_ID, nodeId: refOf(c), data: { [s.on]: s.name } });
472
482
  setStoreTick((v) => v + 1);
473
483
  }}
474
484
  className={`block w-full truncate rounded px-2 py-0.5 text-left text-xs capitalize transition ${
@@ -582,8 +592,8 @@ export function ComponentsView({ theme, org = "all" }: { theme: ResolvedTheme; o
582
592
  nothing. This one is honoured — off means no canvas can install it. */}
583
593
  {current?.spatial && (
584
594
  <ToggleSwitch
585
- on={available.isEnabled(current.name)}
586
- onClick={() => available.toggle(current.name)}
595
+ on={available.isEnabled(refOf(current))}
596
+ onClick={() => available.toggle(refOf(current))}
587
597
  label="Available"
588
598
  />
589
599
  )}
@@ -26,7 +26,7 @@ import { ComponentStore } from "@unoverse/sdk";
26
26
  import { StreamedUnoverseTemplate, useUnoverseConnection, computeAppWidth, resolveActiveLayout, visible, type ResolvedTheme, type UseVoiceServiceConfig } from "@unoverse/sdk";
27
27
  import { NativeAppHost } from "./NativeAppHost";
28
28
  import { inArrangement, viewOfSlice } from "./states";
29
- import { client, authedFetch, hasAuth, UNOVERSE_BASE } from "studio-host";
29
+ import { client, authedFetch, hasAuth, UNOVERSE_BASE, liveUniverseUrl } from "studio-host";
30
30
 
31
31
  // Per-APP test conversation: each app chats in its own conversation id, so testing
32
32
  // one client's app never inherits another's agent history/memory (the "BPP answers
@@ -45,15 +45,23 @@ const chatIdFor = (app: string) => `demo-${app.toLowerCase()}-${SESSION_NONCE}`;
45
45
  // :4105 — since the workbench retired, uWS binds the public port DIRECTLY in dev, same
46
46
  // as prod (the retired workbench was :4205, the retired gateway :4100). Prod uses the
47
47
  // page origin.
48
- const CONN_DEFAULTS = { server: "localhost:4105", workflowId: "wf-r4jzo7", triggerNode: "inputtrigger1", token: "" };
49
- const CONN_KEY = "unoverse.workbench.conn";
48
+ // THE LIVE SERVER IS NOT CONFIGURED HERE. The universe Studio is signed into (the
49
+ // header connection, via studio-host's liveUniverseUrl) IS the live environment, so
50
+ // being signed into one universe while previewing against another is impossible by
51
+ // construction. Only with NO universe connected does the preview fall back to the
52
+ // local dev platform. The old hardcoded default (`localhost:4105` + a sample
53
+ // workflow id, persisted under "unoverse.workbench.conn") is retired: it kept green
54
+ // "Connected" sessions against stale localhost stacks while the header showed a
55
+ // production universe. Workflow + trigger come from the app's manifest binding; the
56
+ // only thing persisted now is a hand-pasted JWT for auth-off dev servers.
57
+ const LOCAL_DEV_SERVER = "localhost:4105";
58
+ const CONN_DEFAULTS = { token: "" };
59
+ const CONN_KEY = "unoverse.workbench.conn.v2";
50
60
 
51
61
  function loadConn(): typeof CONN_DEFAULTS {
52
62
  try {
53
63
  const saved = JSON.parse(localStorage.getItem(CONN_KEY) ?? "{}");
54
- // Migration: drop retired ports (gateway :4100, workbench :4205) so the new default applies.
55
- if (saved.server === "localhost:4100" || saved.server === "localhost:4205") delete saved.server;
56
- return { ...CONN_DEFAULTS, ...saved };
64
+ return { token: typeof saved.token === "string" ? saved.token : "" };
57
65
  } catch {
58
66
  return { ...CONN_DEFAULTS };
59
67
  }
@@ -238,23 +246,30 @@ export function TemplatesView({
238
246
  const authToken = auth?.user?.access_token;
239
247
  const token = authToken || connCfg.token;
240
248
 
249
+ // The live server: the signed-in universe, else the local dev platform (see the
250
+ // CONN_DEFAULTS note). A universe URL is already an https origin; the bare local
251
+ // fallback gets http.
252
+ const universeUrl = liveUniverseUrl();
253
+ const liveServer = universeUrl ?? LOCAL_DEV_SERVER;
254
+ const liveBase = liveServer.includes("://") ? liveServer : `http://${liveServer}`;
255
+
241
256
  const config = useMemo(
242
257
  () => ({
243
- apiUrl: `http://${connCfg.server}`,
258
+ apiUrl: liveBase,
244
259
  // Component stream rides the Unoverse `/stream` MCP endpoint (§5b). In the canvas
245
260
  // the path that reaches Unoverse is UNOVERSE_BASE (/unoverse-mcp proxy in dev, the
246
261
  // explicit gated URL in prod) — always absolute there. In local Studio UNOVERSE_BASE
247
262
  // is "" (a folder, not a universe; its origin serves no /stream), so the stream goes
248
263
  // to the CONNECTED universe — same base the apiUrl above uses. An empty string here
249
264
  // threw `Invalid URL` inside the SDK and killed the stream silently.
250
- streamUrl: UNOVERSE_BASE || `http://${connCfg.server}`,
265
+ streamUrl: UNOVERSE_BASE || liveBase,
251
266
  // A real JWT makes the workbench a proper authenticated channel: it rides the
252
267
  // MCP stream's `Authorization` header and the REST `Authorization` header, so the
253
268
  // workflow gets full user context (incl. the email claim → CRM join). See
254
269
  // docs/AUTH_TOKEN_FLOW.md. Blank → no token (server must run with DISABLE_AUTH=true).
255
270
  getAccessToken: token ? async () => token : undefined,
256
271
  }),
257
- [connCfg.server, token],
272
+ [liveBase, token],
258
273
  );
259
274
  // The APP decides which workflow runs: the selected template's manifest binding
260
275
  // (workflow + trigger) drives the connection. No manual entry — MCP decides.
@@ -395,7 +410,7 @@ export function TemplatesView({
395
410
  // START_CALL REST triggers the workflow on the gateway. The audio WS lane is
396
411
  // derived by the SDK from the client's server origin (see the renderer) — the
397
412
  // host passes NO wsUrl, so every host (incl. srcdoc iframes) is identical.
398
- apiUrl: `http://${connCfg.server}`,
413
+ apiUrl: liveBase,
399
414
  getAccessToken: token ? async () => token : undefined,
400
415
  session: {
401
416
  userId: session.userId,
@@ -409,7 +424,7 @@ export function TemplatesView({
409
424
  store,
410
425
  }
411
426
  : undefined,
412
- [isVoice, connCfg.server, token, session, store],
427
+ [isVoice, liveBase, token, session, store],
413
428
  );
414
429
 
415
430
  // "Load the app → it calls its workflow" — but ONLY for apps with autoTrigger (Mode B).
@@ -808,7 +823,7 @@ export function TemplatesView({
808
823
  come from the selected app's manifest binding (no manual entry). */}
809
824
  {(
810
825
  [
811
- { label: "Server", value: connCfg.server, note: "environment" },
826
+ { label: "Server", value: liveServer, note: universeUrl ? "signed-in universe" : "local dev (no universe connected)" },
812
827
  { label: "Workflow", value: binding?.workflow ?? "— no app binding", note: "from app" },
813
828
  { label: "Trigger node", value: binding?.trigger ?? "—", note: "from app" },
814
829
  ]
@@ -1017,7 +1032,7 @@ export function TemplatesView({
1017
1032
  /* NATIVE: the universe serves the app; Studio just hosts it. Same
1018
1033
  ui:// shell ChatGPT loads, same boot contract, its own connections. */
1019
1034
  <NativeAppHost
1020
- server={UNOVERSE_BASE || `http://${connCfg.server}`}
1035
+ server={UNOVERSE_BASE || liveBase}
1021
1036
  token={token}
1022
1037
  templateId={`${selectedDef?.org ? `${selectedDef.org}/` : ""}${selected ?? ""}`}
1023
1038
  userId={session.userId}
package/src/host.ts CHANGED
@@ -21,6 +21,24 @@ export const UNOVERSE_BASE = "";
21
21
  /** No login offline. Kept so the shared screens can ask without branching on host. */
22
22
  export const hasAuth = false;
23
23
 
24
+ /**
25
+ * The universe Studio is signed into (the header connection), or null when offline.
26
+ * THE LIVE PREVIEW'S SERVER. One source of truth: the same selection App.tsx keeps
27
+ * (`studio:universe` is its usePersistentState pointer into the saved universe list),
28
+ * so it is impossible to be signed into one universe and silently previewing against
29
+ * another. The shared screens fall back to the local dev platform only when no
30
+ * universe is connected.
31
+ */
32
+ export function liveUniverseUrl(): string | null {
33
+ try {
34
+ const raw = localStorage.getItem("studio:universe");
35
+ const url = raw === null ? null : (JSON.parse(raw) as string | null);
36
+ return url || null;
37
+ } catch {
38
+ return null;
39
+ }
40
+ }
41
+
24
42
  /**
25
43
  * SOURCE ACCESS: this host serves the files behind a definition, so the screens may show
26
44
  * the source view. Local Studio reads the developer's own folder, so it can.
@@ -33,7 +33,11 @@ export function useUnoverseInstance(store?: ComponentStore, chatId = "", nodeId
33
33
  subscribe,
34
34
  useCallback(() => (store ? store.getType(chatId, nodeId) : undefined), [store, chatId, nodeId]),
35
35
  );
36
- return { type, data };
36
+ const componentUrl = useSyncExternalStore(
37
+ subscribe,
38
+ useCallback(() => (store ? store.getComponentUrl(chatId, nodeId) : undefined), [store, chatId, nodeId]),
39
+ );
40
+ return { type, data, componentUrl };
37
41
  }
38
42
 
39
43
  export interface UnoverseComponentProps {
@@ -83,11 +87,14 @@ export function UnoverseComponent({ client, uri, data, store, chatId, nodeId, ex
83
87
  // LIVE SOURCE. The hook runs either way (hooks cannot be conditional) and no-ops without a
84
88
  // store, so one component serves both paths without a wrapper deciding for it.
85
89
  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.
90
- const resolvedUri = store ? (live.type ? `unoverse://components/${live.type}` : null) : (uri ?? null);
90
+ // The SERVER-SENT address first: an org component's URI is org-qualified
91
+ // (`unoverse://components/<org>/<name>`) and the bare type cannot rebuild it once two
92
+ // orgs share a name. The rebuild-from-type stays as the fallback for events that
93
+ // carry no componentUrl. (Still the plain URI, never the withdrawn `@refs` form —
94
+ // that suffix broke Studio's local definition route.)
95
+ const resolvedUri = store
96
+ ? (live.componentUrl ?? (live.type ? `unoverse://components/${live.type}` : null))
97
+ : (uri ?? null);
91
98
  const sourceData = store ? (extraData ? { ...(live.data ?? {}), ...extraData } : live.data) : data;
92
99
  // No theme passed → fetch it from the server (the SDK owns zero token values). The
93
100
  // theme REF comes from the definition itself: a template's `org` names its client's
@@ -71,6 +71,10 @@ export class ComponentStore {
71
71
  // --- data plane ---
72
72
  private data = new Map<string, Record<string, unknown>>();
73
73
  private types = new Map<string, string>();
74
+ // The server-sent render address (`unoverse://components/<org>/<name>` for an org
75
+ // component). Kept because the TYPE alone cannot rebuild it: names are unique only
76
+ // within a tier, so a bare type two orgs share is ambiguous without this.
77
+ private urls = new Map<string, string>();
74
78
  // --- timeline plane (pointers only) ---
75
79
  private timeline: Turn[] = [];
76
80
  // --- write recency per slice (monotonic seq stamped on every merge) — powers
@@ -275,6 +279,7 @@ export class ComponentStore {
275
279
  const key = `${event.chatId}:${event.nodeId}`;
276
280
  if (event.type === "COMPONENT_INIT") {
277
281
  this.types.set(key, event.component.type);
282
+ if (event.component.componentUrl) this.urls.set(key, event.component.componentUrl);
278
283
  this.placeInTimeline(event.chatId, key); // structure: place the pointer ONCE
279
284
  this.merge(key, event.component.props ?? {}); // data: seed
280
285
  // Rule 5 — an arrival carrying a public state refreshes that state.
@@ -376,6 +381,7 @@ export class ComponentStore {
376
381
  for (const ptr of dropped.components) {
377
382
  this.data.delete(ptr);
378
383
  this.types.delete(ptr);
384
+ this.urls.delete(ptr);
379
385
  this.touches.delete(ptr);
380
386
  this.interactionTouches.delete(ptr);
381
387
  }
@@ -460,6 +466,10 @@ export class ComponentStore {
460
466
  getType(chatId: string, nodeId: string): string | undefined {
461
467
  return this.types.get(`${chatId}:${nodeId}`);
462
468
  }
469
+ /** The server-sent render address for a slice, when the INIT carried one. */
470
+ getComponentUrl(chatId: string, nodeId: string): string | undefined {
471
+ return this.urls.get(`${chatId}:${nodeId}`);
472
+ }
463
473
  getVersion(): number {
464
474
  return this.version;
465
475
  }
@@ -132,8 +132,11 @@ place. Work through them in order.
132
132
  2. **Rename the templates.** Folder AND the id inside the manifest. `bpp-chat-layout`
133
133
  becomes `lyceum-chat-layout` in both places. Template ids are org-qualified and they
134
134
  are referenced by string.
135
- 3. **Update every manifest `name`** in the copied components and templates. A duplicate
136
- name across packs is a collision: names are unique across all tiers, not per org.
135
+ 3. **Component names stay as copied.** Names are unique per org, not across orgs, so a
136
+ cloned pack carrying the same component names as its source is legal: each org's copy
137
+ is addressed `unoverse://components/<org>/<name>`. The one forbidden case is a name
138
+ that shadows a design-system (marketplace) component; the publish lint catches it.
139
+ Update template manifest `name`s only where the id itself is org-branded.
137
140
  4. **Register the client surface** if the demo client serves it
138
141
  (`packages/demo/src/demo/clients.js`).
139
142
  5. **Point the app at the new theme.** Templates reference `unoverse://theme/<org>/<name>`.
@@ -154,9 +154,11 @@ template states by name exactly as in chat. Audio is never wired in a definition
154
154
 
155
155
  ## New project?
156
156
 
157
- `rx/<project>/` = `templates/` + optional `components/` (project-private microapps,
158
- names unique across tiers) + a **complete self-contained** `styles/` (copy an existing
159
- project's `styles/` and re-value the base scales). Addresses: apps
157
+ `rx/<project>/` = `templates/` + optional `components/` (project-private microapps;
158
+ names unique within the project and never shadowing a marketplace name; two projects
159
+ may share a name, each addressed `<org>/<name>`) + `styles/` holding ONLY the org's
160
+ overrides (the foundation cascades per token; the theme file is the one you copy
161
+ whole, see brand.md). Addresses: apps
160
162
  `unoverse://apps/<project>/<name>`, project components
161
163
  `unoverse://components/<project>/<name>`, themes `unoverse://theme/<project>/<theme>`.
162
164