@unoverse-platform/studio 0.1.78 → 0.1.80

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.78",
3
+ "version": "0.1.80",
4
4
  "type": "module",
5
5
  "description": "Unoverse Studio: the local authoring app. Reads your own rx/ folder, no backend.",
6
6
  "scripts": {
@@ -0,0 +1,75 @@
1
+ /**
2
+ * A SECTION WITH NOTHING IN IT — the shared empty state for the catalogue screens.
3
+ *
4
+ * Chrome, in the sense `chrome.ts` means it: it says how a section presents having no
5
+ * content, never what the content would have been. Skills and Prompt Blocks both need
6
+ * it, and both had grown their own cramped version wedged into the 220px navigator,
7
+ * where a sentence with a file path in it wraps three times and reads as an error.
8
+ *
9
+ * IT BELONGS IN THE PANE, not the navigator. The pane is the wide half and the half a
10
+ * person is already looking at; an empty navigator beside it reads as "nothing here"
11
+ * without needing to say so.
12
+ *
13
+ * AND IT MUST NAME THE RIGHT FOLDER. The old copy always said
14
+ * `apps/unoverse/prompts/blocks/`, which is the UNIVERSAL tier. Studio scoped to an org
15
+ * is telling you where to author for THAT org, and sending someone to the universal
16
+ * folder would have them write a platform-wide asset when they wanted their own.
17
+ */
18
+ import { DOC, PANE } from "./chrome";
19
+
20
+ export function EmptyLibrary({
21
+ /** What is missing, in the plural and lowercase: "skills", "prompt blocks". */
22
+ noun,
23
+ /** The org in scope, when there is one. "all" and undefined both mean unscoped. */
24
+ org,
25
+ /** Where an item of this kind lives, relative to an org: "skills", "blocks". */
26
+ folder,
27
+ /** What a single one of them IS, for the line that explains the shape. */
28
+ shape,
29
+ /** A load failure. Takes precedence: a broken request is not an empty library. */
30
+ error,
31
+ }: {
32
+ noun: string;
33
+ org?: string;
34
+ folder: string;
35
+ shape: string;
36
+ error?: string | null;
37
+ }) {
38
+ const scoped = org && org !== "all" ? org : null;
39
+
40
+ return (
41
+ <div className={PANE}>
42
+ <div className={DOC}>
43
+ {error ? (
44
+ <>
45
+ <h2 className="text-lg font-medium text-gray-900">Couldn’t load {noun}</h2>
46
+ <p className="mt-2 text-sm leading-relaxed text-gray-600">{error}</p>
47
+ </>
48
+ ) : (
49
+ <>
50
+ <h2 className="text-lg font-medium text-gray-900">
51
+ No {noun}{scoped ? <> for <span className="font-mono text-[15px]">{scoped}</span></> : null} yet
52
+ </h2>
53
+ <p className="mt-2 text-sm leading-relaxed text-gray-600">{shape}</p>
54
+ <div className="mt-5 rounded-lg border border-gray-200 bg-gray-50 p-4">
55
+ <div className="text-[10px] font-semibold uppercase tracking-wider text-gray-500">
56
+ {scoped ? `Author one for ${scoped}` : "Author one"}
57
+ </div>
58
+ <div className="mt-1.5 font-mono text-[12.5px] text-gray-800">
59
+ {scoped ? `design/${scoped}/${folder}/` : `design/<org>/${folder}/`}
60
+ </div>
61
+ {/* THE OTHER TIER, said once and quietly. An org asset is private to that
62
+ org; an unqualified one is universal and every client can install it.
63
+ Which folder you choose IS that decision, so this is the moment to
64
+ mention it and the only place that does. */}
65
+ <div className="mt-3 text-[11px] leading-relaxed text-gray-500">
66
+ Anything here belongs to {scoped ?? "one org"} alone. A {noun.replace(/s$/, "")} every
67
+ client should get goes in the universal tier instead, unqualified by an org.
68
+ </div>
69
+ </div>
70
+ </>
71
+ )}
72
+ </div>
73
+ </div>
74
+ );
75
+ }
@@ -16,6 +16,7 @@ import { fetchUnoverse } from "studio-host";
16
16
  import { usePersistentState, useRegistryToggles, ToggleSwitch } from "studio-host";
17
17
  import { IdentityHeader } from "./IdentityHeader";
18
18
  import { DOC, EMPTY, NAV, NAV_SECTION, PANE, SCREEN, navItem } from "./chrome";
19
+ import { EmptyLibrary } from "./EmptyLibrary";
19
20
 
20
21
  interface PromptBlock {
21
22
  id: string;
@@ -25,6 +26,8 @@ interface PromptBlock {
25
26
  tags: string[];
26
27
  category?: string;
27
28
  enabled?: boolean;
29
+ /** The owning org (server-tagged); the universal marketplace tier has none. */
30
+ org?: string;
28
31
  }
29
32
 
30
33
  /** "spatial-search" → "spatialSearch" — the key used in `{{prompt.<key>}}`. */
@@ -32,25 +35,27 @@ function toCamelCase(str: string): string {
32
35
  return str.replace(/-([a-z])/g, (_, letter) => letter.toUpperCase());
33
36
  }
34
37
 
35
- export function PromptBlocksView() {
38
+ export function PromptBlocksView({ org = "all" }: { org?: string } = {}) {
36
39
  const [blocks, setBlocks] = useState<PromptBlock[]>([]);
37
40
  const [selected, setSelected] = usePersistentState<string | null>("wb:prompt-blocks:selected", null);
38
41
  const [error, setError] = useState<string | null>(null);
39
42
  const toggles = useRegistryToggles("prompt");
40
43
 
41
- // Load the library once.
44
+ // Scoped to the org, the same contract as Components and Skills: "all" = universal +
45
+ // every org's, tagged; an org = universal + its own only (RX_ORG_MODEL: project-private).
42
46
  useEffect(() => {
43
- fetchUnoverse("/prompt-blocks")
47
+ fetchUnoverse(`/prompt-blocks${org !== "all" ? `?org=${encodeURIComponent(org)}` : ""}`)
44
48
  .then((r) => (r.ok ? r.json() : Promise.reject(new Error(`HTTP ${r.status}`))))
45
49
  .then((d) => {
46
50
  const list: PromptBlock[] = d.blocks ?? [];
47
51
  setBlocks(list);
52
+ // The remembered id may belong to an org this scope cannot see, hence the fallback.
48
53
  const restore = list.find((b) => b.id === selected) ?? list[0];
49
- if (restore) setSelected(restore.id);
54
+ setSelected(restore ? restore.id : null);
50
55
  })
51
56
  .catch((e) => setError(String(e?.message ?? e)));
52
57
  // eslint-disable-next-line react-hooks/exhaustive-deps
53
- }, []);
58
+ }, [org]);
54
59
 
55
60
  const detail = useMemo(() => blocks.find((b) => b.id === selected) ?? null, [blocks, selected]);
56
61
 
@@ -61,17 +66,27 @@ export function PromptBlocksView() {
61
66
  }, {}),
62
67
  ).sort(([a], [b]) => a.localeCompare(b));
63
68
 
69
+ // NOTHING TO LIST is a whole-section state, not a line in the navigator: the pane says
70
+ // it, at a readable width, and the empty navigator beside it needs no words of its own.
71
+ if (blocks.length === 0) {
72
+ return (
73
+ <div className={SCREEN}>
74
+ <nav className={NAV} />
75
+ <EmptyLibrary
76
+ noun="prompt blocks"
77
+ org={org}
78
+ folder="blocks"
79
+ shape="A prompt block is a Markdown file. Agents pull one in with {{prompt.<name>}}."
80
+ error={error}
81
+ />
82
+ </div>
83
+ );
84
+ }
85
+
64
86
  return (
65
87
  <div className={SCREEN}>
66
88
  {/* NAVIGATOR */}
67
89
  <nav className={NAV}>
68
- {blocks.length === 0 && (
69
- <div className="p-2 text-xs text-gray-400">
70
- {error
71
- ? `Couldn't load prompt blocks (${error}).`
72
- : "No prompt blocks. Add .md files to apps/unoverse/prompts/blocks/."}
73
- </div>
74
- )}
75
90
  {byCategory.map(([cat, items]) => (
76
91
  <div key={cat} className="mb-3">
77
92
  <div className={NAV_SECTION}>{cat}</div>
@@ -84,6 +99,10 @@ export function PromptBlocksView() {
84
99
  <div className="flex items-center gap-1.5 text-sm font-medium">
85
100
  {toggles.isEnabled(b.id) && <span className="h-1.5 w-1.5 rounded-full bg-emerald-400" title="Enabled" />}
86
101
  {b.name}
102
+ {/* WHOSE this is, same mark as Components and Skills. Untagged = the
103
+ universal tier. An org's block and the universal one of that id
104
+ render the same name, so the tag is the only difference on screen. */}
105
+ {b.org ? <span className="text-[10px] text-emerald-500/80">{b.org}</span> : null}
87
106
  </div>
88
107
  <div className="truncate font-mono text-[11px] text-gray-500">{`{{prompt.${toCamelCase(b.id)}}}`}</div>
89
108
  </button>
@@ -18,12 +18,15 @@ import { fetchUnoverse } from "studio-host";
18
18
  import { usePersistentState, useRegistryToggles, ToggleSwitch } from "studio-host";
19
19
  import { IdentityHeader } from "./IdentityHeader";
20
20
  import { DOC, EMPTY, NAV, NAV_SECTION, PANE, SCREEN, navItem } from "./chrome";
21
+ import { EmptyLibrary } from "./EmptyLibrary";
21
22
 
22
23
  interface SkillSummary {
23
24
  /** The LOOKUP name, org-qualified where the skill lives under one. Round-trips verbatim. */
24
25
  name: string;
25
26
  /** What a person reads. Absent on a universe serving an older platform image. */
26
27
  title?: string;
28
+ /** The owning org (server-tagged); the universal marketplace tier has none. */
29
+ org?: string;
27
30
  description: string;
28
31
  version?: string;
29
32
  category?: string;
@@ -39,7 +42,7 @@ interface SkillDetail extends SkillSummary {
39
42
  contentHash: string;
40
43
  }
41
44
 
42
- export function SkillsView() {
45
+ export function SkillsView({ org = "all" }: { org?: string } = {}) {
43
46
  const [skills, setSkills] = useState<SkillSummary[]>([]);
44
47
  const available = useRegistryToggles("skill");
45
48
  const [selected, setSelected] = usePersistentState<string | null>("wb:skills:selected", null);
@@ -47,20 +50,24 @@ export function SkillsView() {
47
50
  const [loadingDetail, setLoadingDetail] = useState(false);
48
51
  const [error, setError] = useState<string | null>(null);
49
52
 
50
- // Load the library list once.
53
+ // THE ORG SCOPE, the same contract ComponentsView uses: "all" = the universal tier +
54
+ // every org's, tagged; an org = the universal tier + that org's own only. A project's
55
+ // assets are project-private (RX_ORG_MODEL.md), so a Studio bound to one org must never
56
+ // list another's — which is exactly what it did until this argument existed.
51
57
  useEffect(() => {
52
- fetchUnoverse("/skills")
58
+ fetchUnoverse(`/skills${org !== "all" ? `?org=${encodeURIComponent(org)}` : ""}`)
53
59
  .then((r) => (r.ok ? r.json() : Promise.reject(new Error(`HTTP ${r.status}`))))
54
60
  .then((d) => {
55
61
  const list: SkillSummary[] = d.skills ?? [];
56
62
  setSkills(list);
57
- // Restore the persisted selection across reloads; fall back to the first.
63
+ // Restore the persisted selection across reloads; fall back to the first. The
64
+ // remembered name may belong to an org this scope cannot see, hence the fallback.
58
65
  const restore = list.find((s) => s.name === selected) ?? list[0];
59
- if (restore) setSelected(restore.name);
66
+ setSelected(restore ? restore.name : null);
60
67
  })
61
68
  .catch((e) => setError(String(e?.message ?? e)));
62
69
  // eslint-disable-next-line react-hooks/exhaustive-deps
63
- }, []);
70
+ }, [org]);
64
71
 
65
72
  // Load the full SKILL.md whenever the selection changes.
66
73
  useEffect(() => {
@@ -93,17 +100,27 @@ export function SkillsView() {
93
100
  }, {}),
94
101
  ).sort(([a], [b]) => a.localeCompare(b));
95
102
 
103
+ // NOTHING TO LIST is a whole-section state, not a line in the navigator. Same shape as
104
+ // Prompt Blocks, from the same component, so the two sections cannot drift apart again.
105
+ if (skills.length === 0) {
106
+ return (
107
+ <div className={SCREEN}>
108
+ <nav className={NAV} />
109
+ <EmptyLibrary
110
+ noun="skills"
111
+ org={org}
112
+ folder="skills"
113
+ shape="A skill is a folder holding a SKILL.md: how an Agent should behave in one situation."
114
+ error={error}
115
+ />
116
+ </div>
117
+ );
118
+ }
119
+
96
120
  return (
97
121
  <div className={SCREEN}>
98
122
  {/* NAVIGATOR */}
99
123
  <nav className={NAV}>
100
- {skills.length === 0 && (
101
- <div className="p-2 text-xs text-gray-400">
102
- {error
103
- ? `Couldn't load skills (${error}).`
104
- : "No skills in the library. A skill is a folder holding a SKILL.md, under design/<org>/skills/."}
105
- </div>
106
- )}
107
124
  {byCategory.map(([cat, items]) => (
108
125
  <div key={cat} className="mb-3">
109
126
  <div className={NAV_SECTION}>{cat}</div>
@@ -115,6 +132,11 @@ export function SkillsView() {
115
132
  >
116
133
  <div className="flex items-center gap-1.5 text-sm font-medium">
117
134
  {s.title ?? s.name}
135
+ {/* WHOSE this is. A mark, not a section, matching ComponentsView: the org
136
+ tag says "yours"; untagged means the universal marketplace tier. Two
137
+ orgs' copies of one skill render the SAME title, so without this the
138
+ list shows what reads as a duplicate and names no difference. */}
139
+ {s.org ? <span className="text-[10px] text-emerald-500/80">{s.org}</span> : null}
118
140
  </div>
119
141
  <div className="truncate text-[11px] text-gray-500">{s.description}</div>
120
142
  </button>
@@ -133,7 +155,9 @@ export function SkillsView() {
133
155
  installed per workflow, in Canvas's Content Library. */}
134
156
  <IdentityHeader
135
157
  right={<ToggleSwitch on={available.isEnabled(selected)} onClick={() => available.toggle(selected)} label="Available" />}
136
- name={detail?.name ?? selected}
158
+ // The LOOKUP name, not the frontmatter's bare one: it carries the org, and
159
+ // it is the identity every other surface addresses this skill by.
160
+ name={selected}
137
161
  category={detail?.category}
138
162
  version={detail?.version}
139
163
  description={detail?.description}
@@ -126,7 +126,15 @@ interface Def {
126
126
  // instances). Supersedes the legacy flat `previewComponents`.
127
127
  // Per-state mock: an ARRAY seeds components; an OBJECT is authored app-state
128
128
  // data (what the workflow would have echoed) merged verbatim on pick.
129
- preview?: Record<string, string[] | Record<string, unknown>>;
129
+ // Array entries: instance seeds. Each item is a bare name (legacy: the picked
130
+ // state's chip decides the view) or the ENVELOPE form (UNOVERSE_INTERFACE_MODEL §3),
131
+ // a single-key object naming the component and carrying ITS OWN view + data:
132
+ // preview: { page: [ { deal-list: { view: deals } }, { deal: { view: page } } ] }
133
+ // An envelope instance is never view-forced, so two instances can preview in two
134
+ // different slots — the thing the chip-driven mock could not say.
135
+ preview?:
136
+ | (string | Record<string, Record<string, unknown>>)[]
137
+ | Record<string, (string | Record<string, Record<string, unknown>>)[] | Record<string, unknown>>;
130
138
  previewComponents?: string[];
131
139
  fluidHeight?: boolean;
132
140
  // The app's named default state on load (the single authored field, OPEN name):
@@ -208,6 +216,10 @@ export function TemplatesView({
208
216
  useCallback(() => store.getVersion(), [store]),
209
217
  );
210
218
  const [seeded, setSeeded] = useState<string[]>([]);
219
+ /** THE CONTAINER TABS (UNOVERSE_INTERFACE_MODEL): per seeded interface — its instance
220
+ * key, its PUBLIC states, and its current view — so the header draws one tab strip
221
+ * per container showing the states of the interface it holds, not one app-level row. */
222
+ const [seededSpecs, setSeededSpecs] = useState<{ name: string; nodeId: string; states: string[]; container?: string }[]>([]);
211
223
  const [metaOpen, setMetaOpen] = usePersistentState<boolean>("wb:apps:meta", false); // MCP details panel
212
224
  const [copiedUri, setCopiedUri] = useState(false);
213
225
  const [version, setVersion] = useState(0); // local bump to reflect store changes in the toolbar
@@ -547,14 +559,40 @@ export function TemplatesView({
547
559
  // seeding always lands inline (the universal default), and the app's focus
548
560
  // state chip writes `focused` into the slice (the where-activation below), exactly
549
561
  // what a real arrival-in-focus or a component's own expand button would do.
550
- function seed(comps: Def[]) {
562
+ /** One instance to mock: the def, plus (envelope form) its OWN view and data. A spec
563
+ * with no view is a legacy seed: it lands inline and the state chip may move it. */
564
+ interface SeedSpec {
565
+ def: Def;
566
+ view?: string;
567
+ data?: Record<string, unknown>;
568
+ /** Envelope (object) entry: with no explicit view, the interface wakes in its OWN
569
+ * first declared state — a bare legacy name still lands inline for the chip. */
570
+ envelope?: boolean;
571
+ }
572
+
573
+ /** A component's own arrival face. Served defs carry the COMPILED tree
574
+ * (`initialView` + `publicStates`, definitions.ts); the raw tree is the fallback
575
+ * for uncompiled shapes. */
576
+ const initialOf = (def: Def): string | undefined => {
577
+ const d = def as { initialView?: string; publicStates?: string[]; state?: { view?: { initial?: string; states?: Record<string, unknown> } } };
578
+ return d.initialView ?? d.publicStates?.[0] ?? d.state?.view?.initial ?? Object.keys(d.state?.view?.states ?? {})[0];
579
+ };
580
+
581
+ function seed(comps: SeedSpec[]) {
551
582
  // nodeId is per-INSTANCE (`name-i`), so previewComponents may repeat a name to
552
583
  // seed several instances — e.g. ["course-card", "course-card"] previews a rail of cards.
553
- comps.forEach((c, i) => {
554
- // STATE MODEL v2: the PUBLIC AXIS never rides the seed — the state chip is the
555
- // controller. Strip BOTH spellings from the def's state block (`view` is the
556
- // compiled tree initial; leaving it in the slice would shadow the chip's
584
+ comps.forEach(({ def: c, view: declaredView, data, envelope }, i) => {
585
+ // An envelope interface with NO stated view wakes in its own arrival face —
586
+ // `- deal-list` just IS a deal list, in its first declared state, no ceremony.
587
+ const view = declaredView ?? (envelope ? initialOf(c) : undefined);
588
+ // STATE MODEL v2: for a LEGACY seed the PUBLIC AXIS never rides it — the state
589
+ // chip is the controller. Strip BOTH spellings from the def's state block (`view`
590
+ // is the compiled tree initial; leaving it in the slice would shadow the chip's
557
591
  // legacy-spelling activation — viewOf reads `view` first) and land inline.
592
+ // An ENVELOPE seed (UNOVERSE_INTERFACE_MODEL §3) is the opposite by design: the
593
+ // instance ARRIVES with its own view, both spellings, exactly as a real moment
594
+ // would deliver it — and the chip never overrides it (the activation loop skips
595
+ // envelope instances).
558
596
  const { view: _v, defaultState: _d, ...restState } = ((c as { state?: Record<string, unknown> }).state ?? {}) as Record<string, unknown>;
559
597
  store.apply({
560
598
  type: "COMPONENT_INIT",
@@ -567,11 +605,31 @@ export function TemplatesView({
567
605
  // mock seed without it resolved bare `unoverse://components/<name>` and the
568
606
  // resolver rightly refused to guess between two orgs' clones.
569
607
  componentUrl: `unoverse://components/${c.org ? `${c.org}/` : ""}${c.name}`,
570
- props: { ...defaultsOf(c), ...restState, defaultState: "inline" },
608
+ // ORDER MATTERS: prop DEFAULTS come after the flattened state scalars,
609
+ // because a projected axis (loadState) appears in BOTH — as the tree's first
610
+ // substate in state (its live arrival) and as a prop default (its preview) —
611
+ // and "the component's defaults ARE its preview" means the default wins here.
612
+ props: {
613
+ ...restState,
614
+ ...defaultsOf(c),
615
+ ...(data ?? {}),
616
+ ...(view ? { view, defaultState: view } : { defaultState: "inline" }),
617
+ },
571
618
  },
572
619
  });
573
620
  });
574
- setSeeded(comps.map((c) => c.name));
621
+ setSeeded(comps.map((c) => c.def.name));
622
+ // The tab strips: one per seeded interface, filed under the container whose claim
623
+ // matches the state it was seeded into (no claim match → filed under its own name).
624
+ const cts = (selectedDef?.app as { containers?: Record<string, { claim?: { state?: string } }> } | undefined)?.containers;
625
+ setSeededSpecs(
626
+ comps.map(({ def: c, view: declaredView, envelope }, i) => {
627
+ const view = declaredView ?? (envelope ? initialOf(c) : undefined);
628
+ const states = ((c as { publicStates?: string[] }).publicStates ?? []).filter(Boolean);
629
+ const container = cts ? Object.keys(cts).find((k) => cts[k]?.claim?.state === view) : undefined;
630
+ return { name: c.name, nodeId: `${c.name}-${i}`, states, container };
631
+ }),
632
+ );
575
633
  setVersion((v) => v + 1);
576
634
  }
577
635
 
@@ -635,20 +693,64 @@ export function TemplatesView({
635
693
  // • "hasMessages" / a component state / default → seed the mock conversation
636
694
  // • { field, eq } (app-state selector, e.g. defaultState="focus") → set it after seeding
637
695
  // An explicit preview entry for the state OVERRIDES all of it — the author decided.
638
- const previewMap = selectedDef?.app?.preview;
696
+ const rawPreview = selectedDef?.app?.preview;
697
+ const previewMap = rawPreview && !Array.isArray(rawPreview) ? rawPreview : undefined;
639
698
  // An OBJECT preview entry is authored APP-STATE mock data — what the workflow
640
699
  // would have echoed (comments, railState, an anchor) — merged as-is so a state's
641
700
  // pill previews with real content. Arrays keep the existing meaning: component seeds.
642
701
  const stateEntry = activeState ? previewMap?.[activeState.name] : undefined;
643
702
  const stateData = stateEntry && !Array.isArray(stateEntry) ? (stateEntry as Record<string, unknown>) : undefined;
644
- const layoutEntry = activeLayout ? previewMap?.[activeLayout] : stateEntry;
703
+ // THE BASE ARRANGEMENT IS PREVIEWABLE TOO. Picking the default-layout pill sets
704
+ // `activeLayout` to null (that IS how "the base" is spelled here), so a
705
+ // `preview.<base>` entry used to be unreachable by construction and the base always
706
+ // previewed empty. That held while every base was a conversation — the app's own
707
+ // chrome, where seeding a component is noise — but a document-first app's base
708
+ // carries its component INLINE (the unmatched-view fallback), and it has to be
709
+ // authorable. An explicit entry is the author deciding, exactly as the comment
710
+ // above says; absent one, `stateEntry` is undefined and the old behaviour stands.
711
+ // `defaultLayout` rides the def ONLY for multi-layout apps (definitions.ts emits it
712
+ // with `def.layouts`, deliberately absent otherwise). A ONE-layout app names its base
713
+ // in the manifest's `layout`, so falling straight to "main" here made a single-
714
+ // arrangement app's `preview.<base>` entry unreachable and the mock seeded NOTHING.
715
+ const baseLayout = selectedDef?.defaultLayout ?? (selectedDef?.app as { layout?: string } | undefined)?.layout ?? "main";
716
+ // THE CONTAINER-MODEL FORM (UNOVERSE_INTERFACE_MODEL §5): an app with no states has
717
+ // no per-state keys to file a mock under — `preview:` is simply THE list of
718
+ // interfaces. A flat array is that app's one and only mock, whatever the pills say.
719
+ // CONTAINER PREVIEWS (UNOVERSE_INTERFACE_MODEL §4): each container mocks ITSELF —
720
+ // `preview: deal-list` under `rail: { claim: { state: deals } }` seeds a deal-list
721
+ // already IN `deals`. The claim supplies the state, so the author never writes one;
722
+ // an explicit state in the preview body still wins.
723
+ // A container names THE INTERFACE IT HOLDS (UNOVERSE_INTERFACE_MODEL §2, the PLACE
724
+ // verb); the interface's own defaults ARE its mock, seeded into the container's
725
+ // first state. Nothing else to author.
726
+ const containers = (selectedDef?.app as { containers?: Record<string, { states?: Record<string, { interface?: string; state?: string }>; interface?: string }> } | undefined)?.containers;
727
+ const publicStatesOf = (name: string): string[] => {
728
+ const hit = components.find((x) => x.name.toLowerCase() === name.toLowerCase());
729
+ return ((hit as { publicStates?: string[] } | undefined)?.publicStates ?? []).filter(Boolean);
730
+ };
731
+ const containerEntries = containers
732
+ ? Object.values(containers).flatMap((c): (string | Record<string, Record<string, unknown>>)[] => {
733
+ // Every state's interface seeds INTO ITS STATE: an explicit `state:` wins, else
734
+ // the container state's own NAME when the interface declares that face (the
735
+ // `page` state holds the deal in `page`), else the interface's own initial.
736
+ if (c.states)
737
+ return Object.entries(c.states).flatMap(([stName, st]): (string | Record<string, Record<string, unknown>>)[] => {
738
+ if (typeof st.interface !== "string" || !st.interface) return [];
739
+ const face = st.state ?? (publicStatesOf(st.interface).includes(stName) ? stName : undefined);
740
+ return [face ? { [st.interface]: { view: face } } : st.interface];
741
+ });
742
+ return typeof c.interface === "string" && c.interface ? [c.interface] : [];
743
+ })
744
+ : undefined;
745
+ const flatPreview = containerEntries?.length ? containerEntries : Array.isArray(rawPreview) ? rawPreview : undefined;
746
+ const layoutEntry = flatPreview ?? (activeLayout ? previewMap?.[activeLayout] : (stateEntry ?? previewMap?.[baseLayout]));
645
747
  const entry = Array.isArray(layoutEntry) ? layoutEntry : undefined;
646
748
  const wantEmpty = !entry && !activeLayout && typeof when === "string" && when === "isEmpty";
647
749
  // A COMPONENT is only seeded for a reaction-contract state (select.where) — the
648
750
  // surface needs one to grab (Focus). Plain app states (welcome, conversation)
649
751
  // preview the APP's own chrome; a component in the transcript there is noise.
650
752
  const wantComponent = Boolean(layoutSel) || Boolean((activeState?.where as { field?: string } | undefined)?.field);
651
- let seededSet: Def[] = [];
753
+ let seededSet: SeedSpec[] = [];
652
754
  if (!wantEmpty) {
653
755
  // ORG-AWARE: names are unique only within a tier, so a bare preview ref must
654
756
  // resolve to the SELECTED APP's own org first, then the universal tier — never
@@ -658,10 +760,28 @@ export function TemplatesView({
658
760
  const hits = components.filter((c) => c.name.toLowerCase() === name.toLowerCase());
659
761
  return hits.find((c) => (c.org ?? "").toLowerCase() === appOrg) ?? hits.find((c) => !c.org) ?? hits[0];
660
762
  };
763
+ // An entry item is a bare NAME (legacy seed, chip-driven) or the ENVELOPE form:
764
+ // a single-key object naming the component and carrying its own view + data
765
+ // (UNOVERSE_INTERFACE_MODEL §3). Envelope instances arrive already in their view.
766
+ const toSpec = (item: string | Record<string, Record<string, unknown>>): SeedSpec | null => {
767
+ if (typeof item === "string") {
768
+ const def = byName(item);
769
+ // In the FLAT (container-model) list every entry is an envelope: a bare name
770
+ // wakes in its own arrival face. Only the keyed legacy form seeds inline for
771
+ // the state chip to move.
772
+ return def ? { def, ...(flatPreview ? { envelope: true } : {}) } : null;
773
+ }
774
+ const [name, body] = Object.entries(item ?? {})[0] ?? [];
775
+ if (!name) return null;
776
+ const def = byName(name);
777
+ if (!def) return null;
778
+ const { view, ...data } = (body ?? {}) as Record<string, unknown>;
779
+ return { def, envelope: true, ...(typeof view === "string" && view ? { view } : {}), ...(Object.keys(data).length ? { data } : {}) };
780
+ };
661
781
  const set = entry
662
- ? entry.map(byName).filter((c): c is Def => Boolean(c))
782
+ ? entry.map(toSpec).filter((s): s is SeedSpec => Boolean(s))
663
783
  : wantComponent
664
- ? seedSet(templates, components, selected)
784
+ ? seedSet(templates, components, selected).map((def): SeedSpec => ({ def }))
665
785
  : [];
666
786
  // Seed when there's preview content OR a picked state that needs a live conversation.
667
787
  if (set.length || activeState) {
@@ -676,7 +796,7 @@ export function TemplatesView({
676
796
  // Matched without separators: the item was renamed to kebab-case, and comparing
677
797
  // against the collapsed spelling silently matched a stale pre-rename copy instead.
678
798
  const sample = components.filter((c) => c.name.toLowerCase().replace(/[-_]/g, "") === "markdownrenderer");
679
- seededSet = set.length ? set : sample;
799
+ seededSet = set.length ? set : sample.map((def): SeedSpec => ({ def }));
680
800
  seed(seededSet);
681
801
  // A mock preview is a FINISHED turn — complete it so streaming chrome
682
802
  // ("Writing…", dots) doesn't hang forever (mock has no settle watcher).
@@ -698,14 +818,20 @@ export function TemplatesView({
698
818
  // buttons perform.
699
819
  const whereSel = layoutSel ?? (activeState?.where as { field?: string; eq?: unknown } | undefined);
700
820
  if (whereSel?.field) {
701
- // Every seeded instance enters the previewed state — the author's `preview`
702
- // map already said exactly what belongs here (a multi-instance surface like a
703
- // card rail previews full; a limit:1 surface shows the newest).
821
+ // Every LEGACY seeded instance enters the previewed state — the author's
822
+ // `preview` map already said exactly what belongs here (a multi-instance surface
823
+ // like a card rail previews full; a limit:1 surface shows the newest).
704
824
  // The public axis writes BOTH spellings (STATE_MODEL v2 alias: `view` shadows
705
825
  // `defaultState` in viewOf) so activation lands whichever the slice carries.
826
+ // An ENVELOPE instance is NEVER moved: it declared its own view, and forcing it
827
+ // would recreate the one-view-for-all mock the envelope form exists to retire
828
+ // (UNOVERSE_INTERFACE_MODEL §3, §7).
706
829
  const axis = whereSel.field === "view" || whereSel.field === "defaultState";
707
830
  const data = axis ? { view: whereSel.eq ?? true, defaultState: whereSel.eq ?? true } : { [whereSel.field!]: whereSel.eq ?? true };
708
- seededSet.forEach((c, i) => store.mergeComponentState(chatIdFor(selected ?? "app"), `${c.name}-${i}`, data));
831
+ seededSet.forEach((c, i) => {
832
+ if (c.view) return;
833
+ store.mergeComponentState(chatIdFor(selected ?? "app"), `${c.def.name}-${i}`, data);
834
+ });
709
835
  }
710
836
  // eslint-disable-next-line react-hooks/exhaustive-deps
711
837
  }, [store, selected, components, templates, live, activeState, activeLayout]);
@@ -770,7 +896,23 @@ export function TemplatesView({
770
896
  layout first, then each named layout. Picking one runs the SAME activation a
771
897
  reaction surface uses (its served `where` selector seeds a component into the
772
898
  view of that name), so the preview machinery needs zero special-casing. */}
773
- {!live && selectedDef?.layouts && (
899
+ {!live && selectedDef?.layouts && (() => {
900
+ // THE PILL SHOWS THE APP'S ACTUAL STATE, NOT THE LAST ONE PICKED. `activeLayout`
901
+ // is a manual override — null means "let the app decide" — so highlighting off
902
+ // it made the row lie the moment a COMPONENT moved the app: tapping a card's
903
+ // View Details put the app in `page` while the row still read `card`, and the
904
+ // reaction contract looked broken when it was working.
905
+ // Derived through the SDK's own `resolveActiveLayout(def, store)` — byte for
906
+ // byte the call the renderer makes (app.tsx) — so the row and the stage can
907
+ // never disagree. `storeVersion` above already re-renders this chrome on every
908
+ // store write, which is exactly what a component's setValue is.
909
+ // `as never`: the same bridge the width calc below already uses for this
910
+ // exact call — the view's local `Def` is a structural subset of the SDK type.
911
+ const activeRoot = resolveActiveLayout(selectedDef as never, store);
912
+ const derived =
913
+ Object.entries(selectedDef.layouts ?? {}).find(([, node]) => node === activeRoot)?.[0] ??
914
+ (selectedDef.defaultLayout ?? "main");
915
+ return (
774
916
  <div className="flex items-center gap-1 rounded p-0.5 text-[11px]" style={{ background: theme.color["border.subtle"] }}>
775
917
  {[selectedDef.defaultLayout ?? "main",
776
918
  // AUTHORED ORDER: the served states list is already stateOrder-ranked —
@@ -779,7 +921,7 @@ export function TemplatesView({
779
921
  ...Object.keys(selectedDef.layouts).filter((n) => n !== (selectedDef.defaultLayout ?? "main") && !(selectedDef.states ?? []).some((s) => s.name === n)),
780
922
  ].map((n) => {
781
923
  const isDefault = n === (selectedDef.defaultLayout ?? "main");
782
- const on = isDefault ? !activeLayout : activeLayout === n;
924
+ const on = n === derived;
783
925
  return (
784
926
  <button
785
927
  key={n}
@@ -799,7 +941,8 @@ export function TemplatesView({
799
941
  );
800
942
  })}
801
943
  </div>
802
- )}
944
+ );
945
+ })()}
803
946
  {/* AVAILABLE — the master switch (see ComponentsView for the two-level model).
804
947
  Studio offers; each canvas installs from what is offered. */}
805
948
  {selected && (
@@ -1112,6 +1255,31 @@ export function TemplatesView({
1112
1255
  <StreamedUnoverseApp
1113
1256
  client={client}
1114
1257
  store={store}
1258
+ // THE CONTAINER'S OWN TABS (UNOVERSE_INTERFACE_MODEL §3): dev chrome drawn
1259
+ // ON the container, never in the app header (that row was the legacy
1260
+ // app-state pills). Inline styles — this renders inside the shadow root.
1261
+ containerChrome={
1262
+ !live
1263
+ ? ({ states, active, set }) => (
1264
+ <div style={{ display: "flex", gap: 4, padding: "6px 8px", minHeight: 34, alignItems: "center", borderBottom: `1px solid ${theme.color["border.subtle"] ?? "#eee"}`, background: theme.color["surface.base"] ?? "#fff", flex: "0 0 auto" }}>
1265
+ {(states.length > 1 ? states : []).map((st) => (
1266
+ <button
1267
+ key={st}
1268
+ onClick={() => set(st)}
1269
+ style={{
1270
+ border: "none", cursor: "pointer", borderRadius: 6, padding: "2px 10px",
1271
+ fontSize: 12, fontWeight: 600, textTransform: "capitalize",
1272
+ background: st === active ? (theme.color["surface.sunken"] ?? "#f2f2f4") : "transparent",
1273
+ color: st === active ? (theme.color["text.primary"] ?? "#111") : (theme.color["text.tertiary"] ?? "#888"),
1274
+ }}
1275
+ >
1276
+ {st}
1277
+ </button>
1278
+ ))}
1279
+ </div>
1280
+ )
1281
+ : undefined
1282
+ }
1115
1283
  // The workflow can SELECT the app (WORKFLOW_STARTED metadata.app →
1116
1284
  // store.activeApp); that overrides the manual pick. Loaded via the
1117
1285
  // official resources/read unoverse://apps/{org}/{name}/definition.
package/src/App.tsx CHANGED
@@ -197,11 +197,11 @@ function Shell() {
197
197
  ) : view === "tokens" ? (
198
198
  <DesignSystemView theme={theme} foundation={foundation} org={org} />
199
199
  ) : view === "skills" ? (
200
- <SkillsView />
200
+ <SkillsView org={org} />
201
201
  ) : view === "nodes" ? (
202
202
  <NodesView />
203
203
  ) : (
204
- <PromptBlocksView />
204
+ <PromptBlocksView org={org} />
205
205
  )}
206
206
  </main>
207
207
  </div>
@@ -55,6 +55,11 @@ export interface StreamedUnoverseAppProps {
55
55
  props?: Record<string, unknown>;
56
56
  /** Render inside a Shadow DOM for full CSS isolation (default true). See UnoverseComponent. */
57
57
  isolate?: boolean;
58
+ /** DEV CHROME ON A CONTAINER (UNOVERSE_INTERFACE_MODEL §3): a host-supplied render
59
+ * hook drawn ABOVE a Container's content — Studio uses it for per-container state
60
+ * tabs. The SDK stays styleless: it hands the facts (name, the container's states,
61
+ * the held interface's active state, a setter) and draws whatever comes back. */
62
+ containerChrome?: (info: { name: string; states: string[]; active?: string; set: (state: string) => void }) => ReactNode;
58
63
  /** The `voice` NATIVE SERVICE config (UNOVERSE_SPEC §2e-1). Present ONLY for a
59
64
  * `service: "voice"` app — the host supplies the connection identity (apiUrl/wsUrl/
60
65
  * token/session) it already holds; the renderer OWNS the service so every host is
@@ -72,7 +77,7 @@ const INERT_VOICE: UseVoiceServiceConfig = {
72
77
  session: { userId: "", conversationId: "", workflowId: "", targetTriggerNode: "" },
73
78
  };
74
79
 
75
- export function StreamedUnoverseApp({ client, store, uri, onAction, theme: themeProp, props, isolate = true, voice: voiceConfig }: StreamedUnoverseAppProps) {
80
+ export function StreamedUnoverseApp({ client, store, uri, onAction, theme: themeProp, props, isolate = true, voice: voiceConfig, containerChrome }: StreamedUnoverseAppProps) {
76
81
  const [def, setDef] = useState<UnoverseDefinition | null>(null);
77
82
  const [error, setError] = useState<string | null>(null);
78
83
  // The voice native service lives HERE (shared), not in each host. Called every render
@@ -288,12 +293,61 @@ export function StreamedUnoverseApp({ client, store, uri, onAction, theme: theme
288
293
 
289
294
  // ComponentSlot — filter the timeline to matching component pointers (KeyService).
290
295
  const resolveSlot = (node: UnoverseNode, key?: React.Key): ReactNode => {
291
- // ONE STATE AT A TIME: a reaction surface renders ONLY while its view IS the
292
- // template's active state. Everything else is not part of this interface — its
293
- // data is untouched and re-presents the moment its state is active again.
294
- // Surfaces never stack; panel combinations cannot exist.
296
+ // A CONTAINER (UNOVERSE_INTERFACE_MODEL §3): the layout places it by NAME and owns
297
+ // its width; the manifest's `containers:` block owns its claim. Resolved here into
298
+ // the slot it is — one authoring word (`state`), translated to the wire key.
299
+ if (node.type === "Container") {
300
+ // A CONTAINER (UNOVERSE_INTERFACE_MODEL §3): its states each HOLD AN INTERFACE —
301
+ // the page state holds the deal, the chat state holds text-chat — and the tabs
302
+ // switch between them. The container's ACTIVE state is app chrome (`setAppValue`
303
+ // territory, the one thing an app owns), keyed `container:<name>`; the shorthand
304
+ // `interface:` is a one-state container and never draws tabs.
305
+ const cname = (node as { name?: string }).name ?? "";
306
+ const spec = (def as { containers?: Record<string, { states?: Record<string, { interface?: string; width?: string }>; interface?: string }> } | null)
307
+ ?.containers?.[cname];
308
+ const stateNames = spec?.states ? Object.keys(spec.states) : [];
309
+ const appState = store.getAppState() as Record<string, unknown>;
310
+ const active = (appState[`container:${cname}`] as string | undefined) ?? stateNames[0];
311
+ const heldInterface = spec?.states ? spec.states[active ?? ""]?.interface : spec?.interface;
312
+ // AN EMPTY STATE HOLDS NOTHING (`closed: {}` is a collapsed drawer): a declared
313
+ // container whose active state names no interface renders EMPTY — never a bare
314
+ // slot, which would grab every interface in the store and un-collapse the drawer.
315
+ const emptyState = Boolean(spec?.states) && !heldInterface;
316
+ node = {
317
+ ...node,
318
+ type: "ComponentSlot",
319
+ select: heldInterface
320
+ ? ({ type: heldInterface, limit: 1 } as never)
321
+ : emptyState
322
+ ? ({ limit: 0 } as never)
323
+ : (node.select ?? {}),
324
+ } as UnoverseNode;
325
+ if (containerChrome && spec) {
326
+ const set = (state: string) => store.mergeAppState({ [`container:${cname}`]: state });
327
+ const inner = node;
328
+ // THE STATE'S WIDTH: a container state may declare one (a collapsed drawer's slim
329
+ // edge). Resolved like every panel width (app-size name, else raw CSS); absent,
330
+ // the wrapper hugs the held interface, whose active state owns its size.
331
+ const declaredW = spec?.states?.[active ?? ""]?.width;
332
+ const stateW = declaredW ? (cssWidth(theme.appSize?.[declaredW] ?? declaredW) ?? declaredW) : undefined;
333
+ return (
334
+ <div key={key} style={{ display: "flex", flexDirection: "column", minHeight: 0, height: "100%", ...(stateW ? { width: stateW, flex: "0 0 auto", overflow: "hidden" } : {}) }}>
335
+ {containerChrome({ name: cname, states: stateNames, active, set })}
336
+ <div style={{ flex: 1, minHeight: 0, overflow: "auto" }}>{resolveSlot(inner, `${String(key ?? cname)}-inner`)}</div>
337
+ </div>
338
+ );
339
+ }
340
+ }
341
+ // DECLARE = REARRANGE, CLAIM = FILL (UNOVERSE_INTERFACE_MODEL §4). A claim on a
342
+ // DECLARED reaction state renders only while that view IS the app's active state:
343
+ // those surfaces are what the app rearranges around, and they never stack. A claim
344
+ // on an UNDECLARED view is a CONTAINER: it fills whenever an interface holds that
345
+ // view, independent of every other region — which is how a base arrangement holds
346
+ // a list and an open page at once. Until 2026-08-25 the gate applied to every
347
+ // claim, so a base layout's claimed regions could never fill at all.
295
348
  const claim = node.select?.where;
296
- if (isViewField(claim?.field) && typeof claim?.eq === "string" && claim.eq !== surfacedView) {
349
+ const declaredClaim = typeof claim?.eq === "string" && (def?.stateOrder ?? []).includes(claim.eq);
350
+ if (isViewField(claim?.field) && typeof claim?.eq === "string" && declaredClaim && claim.eq !== surfacedView) {
297
351
  return node.fallback ? renderNode(node.fallback, {}, dispatch, theme, key ?? "fallback", undefined, def?.defs) : null;
298
352
  }
299
353
  const pointers = selectPointers(store, node);
@@ -299,7 +299,7 @@ export function resolveLatch(
299
299
 
300
300
  /** Resolve a ComponentSlot's `select` against the store → ordered pointers. */
301
301
  export function selectPointers(store: ComponentStore, node: UnoverseNode): string[] {
302
- const sel = node.select ?? {};
302
+ const sel = (node.select ?? {}) as { from?: string; type?: string; where?: { field?: string; eq?: unknown; ne?: unknown }; limit?: number };
303
303
  let pointers =
304
304
  sel.from === "all"
305
305
  ? store.getResponses().flatMap((r) => r.components)
@@ -309,13 +309,21 @@ export function selectPointers(store: ComponentStore, node: UnoverseNode): strin
309
309
  // component whose own slice matches, ordered newest state-write first so `limit: 1`
310
310
  // = "most recent wins". The generic replacement for type-pinned surfaces — a
311
311
  // template reacts to whatever component enters the state, never to a named type.
312
+ // `type` — the PLACE claim (UNOVERSE_INTERFACE_MODEL §2): a container that names its
313
+ // interface holds it whatever state it is in. Placement, not reaction — the one case
314
+ // where selecting by definition name is the point rather than the anti-pattern.
315
+ if (sel.type) pointers = pointers.filter((p) => store.typeOf(p) === sel.type);
312
316
  if (sel.where) {
313
317
  const { field, eq, ne } = sel.where;
318
+ const within = (sel.where as { in?: unknown[] }).in;
314
319
  pointers = pointers.filter((p) => {
315
320
  const { chatId, nodeId } = store.split(p);
316
321
  const slice = store.get(chatId, nodeId) as Record<string, unknown>;
317
322
  // The public axis reads through the alias (view ?? defaultState); other fields raw.
318
- const v = isViewField(field) ? viewOf(slice) : slice[field];
323
+ const v = isViewField(field) ? viewOf(slice) : field ? slice[field] : undefined;
324
+ // `in` — the container claim (UNOVERSE_INTERFACE_MODEL §3): a container with
325
+ // several states fills on any of them. Same closed condition set as visibleWhen.
326
+ if (Array.isArray(within)) return within.includes(v);
319
327
  return ne !== undefined ? v !== ne : v === eq;
320
328
  });
321
329
  pointers = [...pointers].sort((a, b) => store.touchOf(b) - store.touchOf(a));
@@ -146,7 +146,7 @@ export function renderNode(
146
146
  if (node.type === "Orb") return <OrbView key={key} node={node} data={data} theme={theme} />;
147
147
  if (node.type === "Input") return <InputView key={key} node={node} data={data} theme={theme} onAction={onAction} />;
148
148
  if (node.type === "Markdown") return <MarkdownView key={key} node={node} data={data} theme={theme} />;
149
- if (node.type === "ComponentSlot" || node.type === "Timeline") return slot ? slot(node, key) : null;
149
+ if (node.type === "ComponentSlot" || node.type === "Timeline" || node.type === "Container") return slot ? slot(node, key) : null;
150
150
 
151
151
  // `Ref` — REFERENCE, NOT COPY. The design system's shared pieces travel once and are
152
152
  // cached; a definition names one and specialises it here (core/refs.ts: `props` remap,