@unoverse-platform/studio 0.1.40 → 0.1.42

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.40",
3
+ "version": "0.1.42",
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
  )}
package/src/App.tsx CHANGED
@@ -232,7 +232,9 @@ function Shell({
232
232
  {!theme ? (
233
233
  <div className="p-6 text-xs text-gray-500">Reading your theme…</div>
234
234
  ) : view === "apps" ? (
235
- <TemplatesView theme={theme} themeName={variant} org={org} />
235
+ // Standalone Studio authors offline: open apps in MOCK (the defaultLive=true
236
+ // resting mode is Canvas's, where the universe is right there).
237
+ <TemplatesView theme={theme} themeName={variant} org={org} defaultLive={false} />
236
238
  ) : view === "components" ? (
237
239
  <ComponentsView theme={theme} org={org} />
238
240
  ) : view === "atoms" ? (
@@ -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
 
@@ -166,6 +166,23 @@ the first read, never standing debt. This is implemented in
166
166
  `server/src/runtime/components/executor/briefService.ts`; a component that fights it burns
167
167
  every turn to `maxTurns` and returns an empty text output.
168
168
 
169
+ ### The agent node's system prompt stays ROLE-ONLY
170
+
171
+ The service port already delivers everything else when the agent connects: the fill
172
+ protocol (`briefService.ts` `getSchema.instructions` — survey once, batch fills, blanks
173
+ are finished, stop when done — folded into the agent's system prompt by MCPServiceRouter),
174
+ the content rules (each part's compiled brief), and the grounding law (injected into the
175
+ schema description). A workflow agent's system prompt therefore carries ONE line of role
176
+ ("You fill the page from this run's documents; the service's instructions and each part's
177
+ schema carry every rule") and nothing more.
178
+
179
+ Anything longer is a defect with two failure modes, both observed live (2026-08-18,
180
+ deal-page): content rules in the prompt DRIFT from the briefs the moment either is edited,
181
+ and hand-written protocol CONTRADICTS the served protocol (a prompt demanding "one section
182
+ per fill call" while the service instructs "batch into as few calls as possible"). When
183
+ auditing a briefed-component workflow, read the agent node's `systemPrompt` and delete
184
+ every sentence the schema or the service already carries.
185
+
169
186
  ---
170
187
 
171
188
  ## Checklist
@@ -178,3 +195,5 @@ every turn to `maxTurns` and returns an empty text output.
178
195
  5. Linter: 0 errors, and every warning read rather than silenced.
179
196
  6. Previewed in Studio, then filled once for real. Studio proves the layout; only a fill
180
197
  proves the prompt.
198
+ 7. Workflow door only: every agent node filling this component has a ROLE-ONLY system
199
+ prompt — no sentence the schema or the service instructions already carry.