@unoverse-platform/studio 0.1.85 → 0.1.86

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.85",
3
+ "version": "0.1.86",
4
4
  "type": "module",
5
5
  "description": "Unoverse Studio: the local authoring app. Reads your own rx/ folder, no backend.",
6
6
  "scripts": {
@@ -150,7 +150,8 @@ export function ComponentsView({ theme, org = "all" }: { theme: ResolvedTheme; o
150
150
  // the arrival face; `state.defaultState` is the legacy spelling. Seed whichever
151
151
  // the def declares, under the same key the renderer resolves.
152
152
  const st = c.state as Record<string, unknown> | undefined;
153
- if (typeof st?.view === "string") store.apply({ type: "COMPONENT_DATA", chatId: CHAT_ID, nodeId: refOf(c), data: { view: st.view } });
153
+ const face0 = st?.state ?? st?.view;
154
+ if (typeof face0 === "string") store.apply({ type: "COMPONENT_DATA", chatId: CHAT_ID, nodeId: refOf(c), data: { state: face0 } });
154
155
  else if (typeof st?.defaultState === "string")
155
156
  store.apply({ type: "COMPONENT_DATA", chatId: CHAT_ID, nodeId: refOf(c), data: { defaultState: st.defaultState } });
156
157
  }
@@ -186,7 +187,7 @@ export function ComponentsView({ theme, org = "all" }: { theme: ResolvedTheme; o
186
187
  // on (faceAxisOf), so a v2 component and a legacy one both flip.
187
188
  const faceAxis = current?.root ? faceAxisOf(current.root) : "defaultState";
188
189
  const masterState =
189
- ((slice?.view ?? slice?.defaultState ?? slice?.displayState) as string | undefined) ??
190
+ ((slice?.state ?? slice?.view ?? slice?.defaultState ?? slice?.displayState) as string | undefined) ??
190
191
  ((current as { initialView?: string } | undefined)?.initialView ?? "inline");
191
192
  // A component's FACES come from its OWN root Switch on the public axis — an OPEN
192
193
  // name set (inline/focused/course/…), never a hardcoded pair. The BASE face (a v2
@@ -197,7 +198,7 @@ export function ComponentsView({ theme, org = "all" }: { theme: ResolvedTheme; o
197
198
  const walk = (n: any): void => {
198
199
  if (!n || typeof n !== "object") return;
199
200
  if (Array.isArray(n)) { n.forEach(walk); return; }
200
- if (n.type === "Switch" && (n.on === "view" || n.on === "defaultState") && n.cases && typeof n.cases === "object") {
201
+ if (n.type === "Switch" && (n.on === "state" || n.on === "view" || n.on === "defaultState") && n.cases && typeof n.cases === "object") {
201
202
  for (const k of Object.keys(n.cases)) if (k !== "default" && !found.includes(k)) found.push(k);
202
203
  }
203
204
  Object.values(n).forEach(walk);
@@ -1,50 +1,62 @@
1
1
  /**
2
- * Templates view — the Template Model's kind (docs/unoverse/UNOVERSE_TEMPLATE_MODEL.md),
3
- * made browsable. A template is an authored arrangement of PARTS: static markup the
4
- * designer fixed, and SLOTS something fills (data, the director, or the copywriter).
5
- * Authors write `type: template`; the app kind is `app` in code. Two tiers like
6
- * components: the marketplace shelf (no chip = BASE, installed, read-only) and org
7
- * packs (org chip).
2
+ * Templates view — the Template Model's kind (docs/unoverse/UNOVERSE_TEMPLATE_MODEL.md
3
+ * §9b, the three-word grammar), made browsable. A template is states owning
4
+ * arrangements; a layout's SECTIONS are `static:` (placed as designed),
5
+ * `copywriter:` (written into through briefs) and ONE `director:` (decided from
6
+ * what streams in). Authors write `type: template`; the app kind is `app` in code.
7
+ * Two tiers like components: the marketplace shelf (no chip = BASE, installed,
8
+ * read-only) and org packs (org chip).
8
9
  *
9
10
  * Header + dock MIRROR ComponentsView/AtomsView exactly (responsive width toggle,
10
- * collapsible drag-resize dock) — the dock holds the PARTS table (the template's
11
- * contract: each part and who fills it) instead of prop controls, because a template's
12
- * fills come from deliveries, not typed values. The preview renders the shape-true
13
- * synthesized root: static parts and the first shape's real arrangement, slots filled
14
- * with the manifest's preview mocks (real interfaces, their own defaults).
11
+ * collapsible drag-resize dock) — the dock holds the SECTIONS table (the template's
12
+ * contract: each section and who fills it) instead of prop controls, because a
13
+ * template's fills come from deliveries, not typed values. The preview renders the
14
+ * synthesized root: linked sections seed their own components, the director section
15
+ * seeds its preview mocks (real interfaces, their own defaults).
15
16
  *
16
17
  * NOT TemplatesView.tsx: that name is taken by the Apps screen (the legacy noun).
17
18
  */
18
19
  import { useEffect, useMemo, useState, useSyncExternalStore, useCallback, type MouseEvent as ReactMouseEvent } from "react";
19
20
  import { ComponentStore, StreamedUnoverseApp, type ResolvedTheme } from "@unoverse/sdk";
20
- import { client, authedFetch, usePersistentState } from "studio-host";
21
+ import { client, authedFetch, usePersistentState, useRegistryToggles, ToggleSwitch } from "studio-host";
21
22
  import { NAV, SCREEN, navItem } from "./chrome";
22
23
 
23
- interface TemplatePart {
24
- static?: string;
25
- slot?: string;
26
- accepts?: string;
27
- prompt?: { component?: string };
28
- /** The container reaction's own key: how many the slot holds. */
29
- limit?: number;
30
- /** The reaction policy; `latest` (each delivery replaces) is the unwritten default. */
31
- hold?: string;
32
- /** Names the state arriving interfaces render in; absent = their own declared state. */
24
+ interface TemplateSection {
25
+ /** static = placed as designed; copywriter = written into through its briefs;
26
+ * director = decided from what streams in (§9b, the three words). */
27
+ kind: "static" | "copywriter" | "director";
28
+ /** The state instances render in at this section — what the claim matches. */
29
+ face: string;
30
+ /** Linked sections: the authored path (components/masthead). */
31
+ component?: string;
32
+ /** Linked sections: the resolvable scoped ref (email-digest/masthead). */
33
+ ref?: string;
34
+ /** The previewed components' mock values, keyed by ref, compiled by the server (only
35
+ * it can resolve a template-local part). Seeded into each instance so the arrangement
36
+ * previews as written and filled rather than as a page of ghosts. */
37
+ seeds?: Record<string, Record<string, unknown>>;
33
38
  state?: string;
39
+ limit?: number;
40
+ rules?: string;
41
+ preview?: string[];
34
42
  }
35
43
  interface TemplateDef {
36
44
  name: string;
37
45
  org?: string;
46
+ /** Spatial-CAPABLE (server-projected, §9c): every template is, because its manifest is
47
+ * mandatory. Capability, not activation — the switch below is the opt-in. */
48
+ spatial?: boolean;
38
49
  title?: string;
39
50
  description?: string;
40
51
  whenToUse?: string;
41
52
  category?: string;
42
- parts?: TemplatePart[];
43
- shapes?: Record<string, unknown>;
44
- /** ONE director per template: granted duties + soft rules. Never on a slot. */
45
- director?: Record<string, unknown>;
46
- /** Studio preview mocks per slot (manifest) — interface refs seeded as REAL store
47
- * instances, so every reaction runs for real (faces, claims, contained steps). */
53
+ /** Compiled per-state sections (§9b) — the loader's projection of the layout's
54
+ * three-word lines; the wire layouts carry the expanded claims. */
55
+ sections?: Record<string, TemplateSection[]>;
56
+ /** The composed arrangements, keyed by state — the pills. */
57
+ layouts?: Record<string, unknown>;
58
+ defaultLayout?: string;
59
+ /** Seeding map the loader assembles: face → interface refs. */
48
60
  preview?: Record<string, string[]>;
49
61
  }
50
62
 
@@ -59,16 +71,23 @@ const VIEWPORTS: { id: string; label: string; w: number | null }[] = [
59
71
 
60
72
  const refOf = (t: TemplateDef) => (t.org ? `${t.org}/${t.name}` : t.name);
61
73
 
62
- /** One part's filler, in the cast's standard names: designer / data / director / copywriter.
63
- * The director is the TEMPLATE's (one per template); it manages the interface slots. */
64
- function fillerOf(p: TemplatePart, director?: Record<string, unknown>): string {
65
- if (typeof p.static === "string") return "designer";
66
- if (p.prompt) return `copywriter · ${p.prompt.component ?? "brief"}`;
67
- if (director) {
68
- const duties = Object.keys(director).filter((k) => director[k] === true);
69
- return `director · ${duties.join(", ") || "arranges"}`;
70
- }
71
- return "data";
74
+ /** One section's description for the dock table — the three words (§9b). */
75
+ function sectionLabel(sec: TemplateSection): { name: string; filler: string; rules: string } {
76
+ if (sec.kind === "director")
77
+ return {
78
+ name: "director",
79
+ filler: "director",
80
+ rules: [
81
+ sec.state ? `state ${sec.state}` : null,
82
+ sec.limit != null ? `limit ${sec.limit}` : null,
83
+ sec.rules ?? null,
84
+ ].filter(Boolean).join(" · "),
85
+ };
86
+ return {
87
+ name: sec.component ?? sec.face,
88
+ filler: sec.kind === "copywriter" ? "copywriter" : "static",
89
+ rules: "",
90
+ };
72
91
  }
73
92
 
74
93
  export function TemplatesLibraryView({ theme, org }: { theme: ResolvedTheme; org?: string }) {
@@ -108,6 +127,10 @@ export function TemplatesLibraryView({ theme, org }: { theme: ResolvedTheme; org
108
127
  // eslint-disable-next-line react-hooks/exhaustive-deps
109
128
  }, [org]);
110
129
 
130
+ // AVAILABLE — the same master switch every other kind has (§9c): is this template
131
+ // offered to workflows at all? Level 2 (is it on THIS workflow's map?) stays in
132
+ // Canvas's Content Library, exactly as for a component.
133
+ const available = useRegistryToggles("template");
111
134
  const current = templates.find((t) => refOf(t) === selected);
112
135
  const uri = useMemo(() => (current ? `unoverse://templates/${refOf(current)}` : ""), [current]);
113
136
 
@@ -116,20 +139,40 @@ export function TemplatesLibraryView({ theme, org }: { theme: ResolvedTheme; org
116
139
  // everything — a tap re-faces an instance, the name-matched layout surfaces, its
117
140
  // slot claims the instance, and sub-states stay contained in the instance's slice.
118
141
  const store = useMemo(() => new ComponentStore(), [selected]);
142
+
143
+ const seeds = useMemo(() => {
144
+ // THE SEED PLAN, from the BASE state's compiled sections (§9b): a linked
145
+ // section (static/copywriter) seeds ONE instance of its own component at its
146
+ // face — the template's FRAME, which the state pills never re-face. The
147
+ // director section seeds its preview refs at its face — the items the guest
148
+ // actually browses.
149
+ const base = current?.defaultLayout ?? (current?.layouts ? Object.keys(current.layouts)[0] : undefined);
150
+ const secs = (base && current?.sections?.[base]) || [];
151
+ const out: { nodeId: string; ref: string; face?: string; framed: boolean; seed?: Record<string, unknown> }[] = [];
152
+ for (const sec of secs) {
153
+ if (sec.kind === "director") {
154
+ for (const [i, ref] of (sec.preview ?? []).entries())
155
+ out.push({ nodeId: `${sec.face}~${ref}~${i}`, ref, face: sec.face, framed: false, seed: sec.seeds?.[ref] });
156
+ } else if (sec.ref) {
157
+ out.push({ nodeId: `${sec.face}~${sec.ref}`, ref: sec.ref, face: sec.face, framed: true, seed: sec.seeds?.[sec.ref] });
158
+ }
159
+ }
160
+ return out;
161
+ }, [current]);
162
+ const framedIds = useMemo(() => new Set(seeds.filter((s) => s.framed).map((s) => s.nodeId)), [seeds]);
163
+
119
164
  useEffect(() => {
120
165
  // Seed once per store, and only once `current` has actually loaded — the list
121
166
  // arrives async, so this must re-run when it lands, not just on store creation.
122
167
  if (!current || store.getTimeline().length) return;
123
- const firstSlot = current.parts?.find((p) => typeof p.slot === "string")?.slot;
124
- const refs = (firstSlot && current.preview?.[firstSlot]) || [];
125
- // Seed AT the base shape's name-matched face (the manifest contract: "each
126
- // template state shows the mock in the NAME-MATCHED face"). A face-less seed
127
- // matches no name-synced slot select, so the grid rendered empty.
128
- const baseFace = current.shapes ? Object.keys(current.shapes)[0] : undefined;
129
- refs.forEach((ref, i) => {
130
- store.apply({ type: "COMPONENT_INIT", chatId: PREVIEW_CHAT, nodeId: `${ref}~${i}`, component: { type: ref, props: {} } });
131
- if (baseFace) store.mergeComponentState(PREVIEW_CHAT, `${ref}~${i}`, { view: baseFace });
132
- });
168
+ for (const { nodeId, ref, face, seed } of seeds) {
169
+ // The part's own mock values, exactly as the Components screen seeds a component
170
+ // (`preview ?? default`) — compiled onto the section by the server, because only it
171
+ // can resolve a template-local part. Seeded with nothing, every copywriter part
172
+ // draws as skeletons and a finished arrangement reads as a broken one.
173
+ store.apply({ type: "COMPONENT_INIT", chatId: PREVIEW_CHAT, nodeId, component: { type: ref, props: seed ?? {} } });
174
+ if (face) store.mergeComponentState(PREVIEW_CHAT, nodeId, { state: face });
175
+ }
133
176
  // eslint-disable-next-line react-hooks/exhaustive-deps
134
177
  }, [store, current]);
135
178
 
@@ -140,7 +183,7 @@ export function TemplatesLibraryView({ theme, org }: { theme: ResolvedTheme; org
140
183
  useCallback((cb) => store.subscribe(cb), [store]),
141
184
  useCallback(() => store.getStructureVersion(), [store]),
142
185
  );
143
- const shapeNames = current?.shapes ? Object.keys(current.shapes) : [];
186
+ const shapeNames = current?.layouts ? Object.keys(current.layouts) : [];
144
187
  const activeState = useMemo(() => {
145
188
  void storeVersion;
146
189
  for (const name of shapeNames.slice(1)) {
@@ -149,7 +192,7 @@ export function TemplatesLibraryView({ theme, org }: { theme: ResolvedTheme; org
149
192
  .some((r) => r.components.some((p) => {
150
193
  const { chatId, nodeId } = store.split(p);
151
194
  const slice = store.get(chatId, nodeId) as Record<string, unknown> | undefined;
152
- return (slice?.view ?? slice?.defaultState) === name;
195
+ return (slice?.state ?? slice?.view ?? slice?.defaultState) === name;
153
196
  }));
154
197
  if (hit) return name;
155
198
  }
@@ -197,6 +240,13 @@ export function TemplatesLibraryView({ theme, org }: { theme: ResolvedTheme; org
197
240
  {/* THE TEMPLATE'S STATES, across the top — the same pill row the Apps
198
241
  screen gives a multi-layout template (states own shapes; the pill puts
199
242
  the preview into that state). One state is not a choice: no row. */}
243
+ {current?.spatial && (
244
+ <ToggleSwitch
245
+ on={available.isEnabled(refOf(current))}
246
+ onClick={() => available.toggle(refOf(current))}
247
+ label="Available"
248
+ />
249
+ )}
200
250
  {shapeNames.length > 1 && (
201
251
  <div className="flex items-center gap-1 rounded p-0.5 text-[11px]" style={{ background: theme.color["border.subtle"] }}>
202
252
  {shapeNames.map((name, i) => {
@@ -208,16 +258,29 @@ export function TemplatesLibraryView({ theme, org }: { theme: ResolvedTheme; org
208
258
  // The pill is the guest's hand: it writes FACES, and the
209
259
  // state derives — never the other way round. Base pill
210
260
  // returns every instance to the base face.
211
- for (const r of store.getResponses())
212
- for (const ptr of r.components) {
213
- const { chatId, nodeId } = store.split(ptr);
214
- const slice = store.get(chatId, nodeId) as Record<string, unknown> | undefined;
215
- if (i === 0) {
216
- if ((slice?.view ?? slice?.defaultState) != null) store.mergeComponentState(chatId, nodeId, { view: shapeNames[0] });
217
- } else if (r.components.indexOf(ptr) === 0) {
218
- store.mergeComponentState(chatId, nodeId, { view: name });
219
- }
261
+ //
262
+ // THE FRAME IS NOT AN ITEM. Every seeded instance used to be
263
+ // treated as one, which held while a template had a single
264
+ // slot: on email-digest the base pill re-faced the masthead
265
+ // and footer to `grid` (they left their slots and landed in
266
+ // the item band), and the `page` pill wrote the face onto the
267
+ // first component in the response — the masthead — so the
268
+ // detail shape surfaced with nothing in it. Slots with an
269
+ // authored `state:` are skipped, and the detail pill opens
270
+ // the first ITEM.
271
+ const items = store
272
+ .getResponses()
273
+ .flatMap((r) => r.components)
274
+ .filter((ptr) => !framedIds.has(store.split(ptr).nodeId));
275
+ for (const [j, ptr] of items.entries()) {
276
+ const { chatId, nodeId } = store.split(ptr);
277
+ const slice = store.get(chatId, nodeId) as Record<string, unknown> | undefined;
278
+ if (i === 0) {
279
+ if ((slice?.state ?? slice?.view ?? slice?.defaultState) != null) store.mergeComponentState(chatId, nodeId, { state: shapeNames[0] });
280
+ } else if (j === 0) {
281
+ store.mergeComponentState(chatId, nodeId, { state: name });
220
282
  }
283
+ }
221
284
  }}
222
285
  className="rounded px-2 py-0.5 font-medium capitalize transition"
223
286
  style={on ? { background: theme.color["surface.base"], color: theme.color["text.primary"] } : { color: theme.color["text.secondary"] }}
@@ -267,46 +330,43 @@ export function TemplatesLibraryView({ theme, org }: { theme: ResolvedTheme; org
267
330
  <div className="flex min-h-0 shrink-0 flex-col bg-[#232a34] text-gray-200" style={panelOpen ? { height: panelHeight } : undefined}>
268
331
  <div
269
332
  onClick={() => setPanelOpen((o) => !o)}
270
- title={panelOpen ? "Collapse parts" : "Expand parts"}
333
+ title={panelOpen ? "Collapse sections" : "Expand sections"}
271
334
  className="flex cursor-pointer select-none items-center gap-2 border-y border-gray-700 px-3 py-2 text-[11px] uppercase tracking-wider text-gray-400 hover:bg-white/5"
272
335
  >
273
336
  <span className="leading-none text-gray-300">{panelOpen ? "▾" : "▸"}</span>
274
- <span>parts</span>
337
+ <span>sections</span>
275
338
  {current && <span className="text-gray-400">· {current.name}</span>}
276
339
  </div>
277
340
  {panelOpen && (
278
341
  <div className="min-h-0 flex-1 overflow-auto p-3">
279
- {current?.parts?.length ? (
342
+ {(() => {
343
+ const base = current?.defaultLayout ?? (current?.layouts ? Object.keys(current.layouts)[0] : undefined);
344
+ const secs = (base && current?.sections?.[base]) || [];
345
+ return secs.length ? (
280
346
  <table className="w-full text-left text-xs">
281
347
  <thead className="text-gray-500">
282
348
  <tr>
283
- <th className="pb-1 pr-4 font-medium">part</th>
349
+ <th className="pb-1 pr-4 font-medium">section</th>
284
350
  <th className="pb-1 pr-4 font-medium">filled by</th>
285
351
  <th className="pb-1 font-medium">rules</th>
286
352
  </tr>
287
353
  </thead>
288
354
  <tbody className="text-gray-300">
289
- {current.parts.map((p, i) => (
290
- <tr key={i} className="border-t border-gray-700/60">
291
- <td className="py-1 pr-4 font-mono">{p.static ?? p.slot ?? "?"}</td>
292
- <td className="py-1 pr-4">{fillerOf(p, current?.director)}</td>
293
- <td className="py-1 text-gray-500">
294
- {typeof p.static === "string" || p.prompt
295
- ? ""
296
- : [
297
- p.limit != null ? `limit ${p.limit}` : null,
298
- `hold ${p.hold ?? "latest"}`,
299
- p.state ? `state ${p.state}` : null,
300
- ]
301
- .filter(Boolean)
302
- .join(" · ")}
303
- </td>
304
- </tr>
305
- ))}
355
+ {secs.map((sec, i) => {
356
+ const l = sectionLabel(sec);
357
+ return (
358
+ <tr key={i} className="border-t border-gray-700/60">
359
+ <td className="py-1 pr-4 font-mono">{l.name}</td>
360
+ <td className="py-1 pr-4">{l.filler}</td>
361
+ <td className="py-1 text-gray-500">{l.rules}</td>
362
+ </tr>
363
+ );
364
+ })}
306
365
  </tbody>
307
366
  </table>
308
- ) : (
309
- <div className="text-xs text-gray-500">This template declares no parts.</div>
367
+ ) : null;
368
+ })() || (
369
+ <div className="text-xs text-gray-500">This template declares no sections.</div>
310
370
  )}
311
371
  </div>
312
372
  )}
@@ -77,6 +77,9 @@ interface PropDef {
77
77
  }
78
78
  interface Def {
79
79
  name: string;
80
+ /** The artifact's kind. Templates sit beside components in the seed lookup (§9c), and
81
+ * the kind decides which home their definition is addressed at. */
82
+ kind?: string;
80
83
  // The client org this app belongs to (server-injected) — names its theme (`<org>/light`).
81
84
  org?: string;
82
85
  description?: string;
@@ -172,7 +175,7 @@ export function TemplatesView({
172
175
  // MOCK (offline) vs LIVE (real data plane). A fresh store per mode keeps the two paths
173
176
  // from bleeding into each other.
174
177
  const [live, setLive] = useState(defaultLive);
175
- const available = useRegistryToggles("template");
178
+ const available = useRegistryToggles("app");
176
179
  const [templates, setTemplates] = useState<Def[]>([]);
177
180
  const [components, setComponents] = useState<Def[]>([]);
178
181
  const [selected, setSelected] = usePersistentState<string | null>("wb:apps:selected", null);
@@ -578,6 +581,21 @@ export function TemplatesView({
578
581
  return d.initialView ?? d.publicStates?.[0] ?? d.state?.view?.initial ?? Object.keys(d.state?.view?.states ?? {})[0];
579
582
  };
580
583
 
584
+ /** The instances a seeded TEMPLATE stands for: each linked part at its own face with
585
+ * its own mock values, and the director's stand-ins at the band's face. Empty for a
586
+ * component, which stands for itself. */
587
+ const templateSeeds = (c: Def): { ref: string; face: string; props: Record<string, unknown> }[] => {
588
+ const d = c as { kind?: string; defaultLayout?: string; layouts?: Record<string, unknown>; sections?: Record<string, { kind: string; face: string; ref?: string; preview?: string[]; seeds?: Record<string, Record<string, unknown>> }[]> };
589
+ if (d.kind !== "template") return [];
590
+ const base = d.defaultLayout ?? Object.keys(d.layouts ?? {})[0];
591
+ const out: { ref: string; face: string; props: Record<string, unknown> }[] = [];
592
+ for (const sec of (base && d.sections?.[base]) || []) {
593
+ const refs = sec.kind === "director" ? (sec.preview ?? []) : sec.ref ? [sec.ref] : [];
594
+ for (const ref of refs) out.push({ ref, face: sec.face, props: sec.seeds?.[ref] ?? {} });
595
+ }
596
+ return out;
597
+ };
598
+
581
599
  function seed(comps: SeedSpec[]) {
582
600
  // nodeId is per-INSTANCE (`name-i`), so previewComponents may repeat a name to
583
601
  // seed several instances — e.g. ["course-card", "course-card"] previews a rail of cards.
@@ -593,7 +611,21 @@ export function TemplatesView({
593
611
  // instance ARRIVES with its own view, both spellings, exactly as a real moment
594
612
  // would deliver it — and the chip never overrides it (the activation loop skips
595
613
  // envelope instances).
596
- const { view: _v, defaultState: _d, ...restState } = ((c as { state?: Record<string, unknown> }).state ?? {}) as Record<string, unknown>;
614
+ const { state: _s, view: _v, defaultState: _d, ...restState } = ((c as { state?: Record<string, unknown> }).state ?? {}) as Record<string, unknown>;
615
+ // A template arrives WITH its page: the parts it links and the stand-ins its band
616
+ // holds, each at its own face, exactly as a delivery would land them.
617
+ for (const [j, part] of templateSeeds(c).entries()) {
618
+ store.apply({
619
+ type: "COMPONENT_INIT",
620
+ chatId: chatIdFor(selected ?? "app"),
621
+ nodeId: `${c.name}-${i}-${part.face}-${j}`,
622
+ component: {
623
+ type: part.ref,
624
+ componentUrl: `unoverse://components/${part.ref}`,
625
+ props: { ...part.props, state: part.face, view: part.face, defaultState: part.face },
626
+ },
627
+ });
628
+ }
597
629
  store.apply({
598
630
  type: "COMPONENT_INIT",
599
631
  chatId: chatIdFor(selected ?? "app"),
@@ -604,7 +636,9 @@ export function TemplatesView({
604
636
  // cannot rebuild it (names are unique only within a tier — store.ts), so a
605
637
  // mock seed without it resolved bare `unoverse://components/<name>` and the
606
638
  // resolver rightly refused to guess between two orgs' clones.
607
- componentUrl: `unoverse://components/${c.org ? `${c.org}/` : ""}${c.name}`,
639
+ // THE ADDRESS SAYS WHAT IT IS (§9c): a seeded TEMPLATE is served, read and
640
+ // rendered from the templates home, so a preview naming one resolves.
641
+ componentUrl: `unoverse://${c.kind === "template" ? "templates" : "components"}/${c.org ? `${c.org}/` : ""}${c.name}`,
608
642
  // ORDER MATTERS: prop DEFAULTS come after the flattened state scalars,
609
643
  // because a projected axis (loadState) appears in BOTH — as the tree's first
610
644
  // substate in state (its live arrival) and as a prop default (its preview) —
@@ -657,10 +691,17 @@ export function TemplatesView({
657
691
  // Mock seeding picked whichever listed first.
658
692
  authedFetch(`/dev/apps${org !== "all" ? `?org=${encodeURIComponent(org)}` : ""}`).then((r) => (r.ok ? r.json() : { apps: [] })),
659
693
  authedFetch(`/dev/components${org !== "all" ? `?org=${encodeURIComponent(org)}` : ""}`).then((r) => (r.ok ? r.json() : { components: [] })),
694
+ // TEMPLATES SEED LIKE COMPONENTS (§9c). A state whose surface holds a page that
695
+ // STREAMS IN names that page in its preview, so the seed lookup has to be able to
696
+ // find it — otherwise the state's chip seeds nothing and the tab looks broken.
697
+ authedFetch(`/dev/templates${org !== "all" ? `?org=${encodeURIComponent(org)}` : ""}`).then((r) => (r.ok ? r.json() : { templates: [] })),
660
698
  ])
661
- .then(([t, c]) => {
699
+ .then(([t, c, tpl]) => {
662
700
  const tl: Def[] = t.apps ?? [];
663
- const cl: Def[] = c.components ?? [];
701
+ const cl: Def[] = [
702
+ ...((c.components ?? []) as Def[]),
703
+ ...((tpl.templates ?? []) as Def[]).map((d) => ({ ...d, kind: "template" as const })),
704
+ ];
664
705
  setTemplates(tl);
665
706
  setComponents(cl);
666
707
  if (live) setSeeded([]);
@@ -737,7 +778,7 @@ export function TemplatesView({
737
778
  return Object.entries(c.states).flatMap(([stName, st]): (string | Record<string, Record<string, unknown>>)[] => {
738
779
  if (typeof st.interface !== "string" || !st.interface) return [];
739
780
  const face = st.state ?? (publicStatesOf(st.interface).includes(stName) ? stName : undefined);
740
- return [face ? { [st.interface]: { view: face } } : st.interface];
781
+ return [face ? { [st.interface]: { state: face } } : st.interface];
741
782
  });
742
783
  return typeof c.interface === "string" && c.interface ? [c.interface] : [];
743
784
  })
@@ -836,8 +877,8 @@ export function TemplatesView({
836
877
  // An ENVELOPE instance is NEVER moved: it declared its own view, and forcing it
837
878
  // would recreate the one-view-for-all mock the envelope form exists to retire
838
879
  // (UNOVERSE_INTERFACE_MODEL §3, §7).
839
- const axis = whereSel.field === "view" || whereSel.field === "defaultState";
840
- const data = axis ? { view: whereSel.eq ?? true, defaultState: whereSel.eq ?? true } : { [whereSel.field!]: whereSel.eq ?? true };
880
+ const axis = whereSel.field === "state" || whereSel.field === "view" || whereSel.field === "defaultState";
881
+ const data = axis ? { state: whereSel.eq ?? true, view: whereSel.eq ?? true, defaultState: whereSel.eq ?? true } : { [whereSel.field!]: whereSel.eq ?? true };
841
882
  seededSet.forEach((c, i) => {
842
883
  if (c.view || (c as { envelope?: boolean }).envelope) return;
843
884
  store.mergeComponentState(chatIdFor(selected ?? "app"), `${c.def.name}-${i}`, data);
package/screens/states.ts CHANGED
@@ -21,15 +21,31 @@ function walkTree(n: unknown, visit: (node: Record<string, unknown>) => boolean
21
21
  return Object.values(o).some((v) => walkTree(v, visit));
22
22
  }
23
23
 
24
- /** The PUBLIC axis (STATE_MODEL v2): `view`, with `defaultState` as the legacy alias. */
25
- export const VIEW_AXES = ["view", "defaultState"] as const;
26
- const isViewAxis = (on: unknown): boolean => on === "view" || on === "defaultState";
24
+ /**
25
+ * The PUBLIC axis: `state` (the ruled word, 2026-08-29), with `view` and `defaultState`
26
+ * as the older spellings a component may still switch on.
27
+ *
28
+ * `state` WAS MISSING HERE, and it broke every face control in Studio. A v2 component
29
+ * compiles its state tree into a root `Switch` on `state` (`{ on: "state", cases:
30
+ * ["grid", "page", …] }`), so with only the two legacy words admitted:
31
+ * - `faceAxisOf` fell through to its `defaultState` default, so clicking a face wrote
32
+ * `{ defaultState: "page" }` while the root watched `state`, and nothing moved. The
33
+ * buttons still RENDERED, because the names come from the served `stateTree` rather
34
+ * than from this file, which is what made it look like dead buttons rather than a
35
+ * missing axis;
36
+ * - `faceOf` could not find a face's subtree;
37
+ * - `switchLayers` counted the public axis as a STEP, because it identifies steps by
38
+ * everything this predicate rejects.
39
+ * One list, three symptoms.
40
+ */
41
+ export const VIEW_AXES = ["state", "view", "defaultState"] as const;
42
+ const isViewAxis = (on: unknown): boolean => on === "state" || on === "view" || on === "defaultState";
27
43
 
28
44
  /** The state an instance is showing, read off its slice on the public axis. Reading
29
45
  * only the legacy alias leaves every migrated component looking state-less. */
30
46
  export const viewOfSlice = (slice: unknown): unknown => {
31
47
  const s = (slice ?? {}) as Record<string, unknown>;
32
- return s.view ?? s.defaultState;
48
+ return s.state ?? s.view ?? s.defaultState;
33
49
  };
34
50
 
35
51
  /** The subtree a face draws: the root's Switch-on-the-public-axis case of that name. */
@@ -45,11 +61,13 @@ export function faceOf(root: unknown, face: string): unknown {
45
61
  }
46
62
 
47
63
  /** Which public-axis spelling the root actually switches on (writes must match it). */
48
- export function faceAxisOf(root: unknown): "view" | "defaultState" {
49
- let axis: "view" | "defaultState" = "defaultState";
64
+ export function faceAxisOf(root: unknown): "state" | "view" | "defaultState" {
65
+ // The default stays `defaultState`: a tree with no public-axis Switch at all is a
66
+ // legacy component, and writing the modern word to it would flip nothing.
67
+ let axis: "state" | "view" | "defaultState" = "defaultState";
50
68
  walkTree(root, (o) => {
51
69
  if (o.type === "Switch" && isViewAxis(o.on) && o.cases && typeof o.cases === "object") {
52
- axis = o.on as "view" | "defaultState";
70
+ axis = o.on as "state" | "view" | "defaultState";
53
71
  return true;
54
72
  }
55
73
  });
package/src/host.ts CHANGED
@@ -187,7 +187,7 @@ export function usePersistentState<T>(key: string, initial: T): [T, React.Dispat
187
187
  return [value, setValue];
188
188
  }
189
189
 
190
- export type RegistryKind = "node" | "skill" | "template" | "prompt" | "component";
190
+ export type RegistryKind = "node" | "skill" | "app" | "prompt" | "component" | "template";
191
191
 
192
192
  /**
193
193
  * Registry toggles decide what a RUNNING universe exposes; they persist to
@@ -14,8 +14,8 @@
14
14
  * definition, and `setValue` writes the same instance either way.
15
15
  */
16
16
  import { useCallback, useEffect, useMemo, useState, useSyncExternalStore, type CSSProperties } from "react";
17
- import { dispatchAction, type ComponentStore, type ResolvedTheme, type UnoverseClient, type UnoverseDefinition } from "./core";
18
- import { renderNode, keyframesCss, type ActionHandler } from "./render";
17
+ import { dispatchAction, matches, type ComponentStore, type ResolvedTheme, type UnoverseClient, type UnoverseDefinition } from "./core";
18
+ import { renderNode, keyframesCss, type ActionHandler, type SlotResolver } from "./render";
19
19
  import { useUnoverseTheme, ensureFontStylesheets } from "./theme";
20
20
  import { IsolatedRoot } from "./isolate";
21
21
 
@@ -192,7 +192,53 @@ export function UnoverseComponent({ client, uri, data, store, chatId, nodeId, ex
192
192
  const keyframes = <style>{keyframesCss(activeTheme)}</style>;
193
193
  // The shared pieces this definition NAMES travel beside it and are threaded through
194
194
  // every recursion, so a reference nested in a Switch branch or an Each item resolves too.
195
- const tree = renderNode(def.root, merged, handleAction, activeTheme, undefined, undefined, def.defs);
195
+ // A DEFINITION THAT CARRIES SLOTS RESOLVES THEM — the rule the SDK already applies to
196
+ // an app tree, applied here too. Without it a rendered TEMPLATE drew its frame and every
197
+ // section resolved to null (render.tsx: `slot ? slot(node) : null`), so a page that
198
+ // STREAMED IN rather than being mounted by an app came out blank. Mounted templates were
199
+ // fine because `app.tsx` hands that path a resolver; instances never got one, and a
200
+ // definition rendering its own parts nowhere is a renderer bug, not a design decision.
201
+ //
202
+ // Generic and dumb, like the rest of it: the slot's claim picks instances out of the
203
+ // SAME store by the face they wear, in the order the store holds them. It knows nothing
204
+ // about templates, days or cards — only "which instances match this claim".
205
+ //
206
+ // TEMPLATES ONLY, and the boundary matters: an ordinary component may carry the
207
+ // conversation's FLOW slot (`select: {}` inside a chat's assistant turn), and that one
208
+ // belongs to the app's timeline, which scopes it to a single turn's unclaimed
209
+ // components. Resolving it here too rendered every instance in the store a second time
210
+ // — the composed page appeared in its surface AND in the chat beside it.
211
+ const resolveSlot: SlotResolver | undefined = store && (def as { sections?: unknown }).sections
212
+ ? (node, key) => {
213
+ const claim = node.select?.where;
214
+ const pointers = store
215
+ .getResponses()
216
+ .flatMap((r) => r.components)
217
+ .filter((p) => {
218
+ const { chatId: c, nodeId: n } = store.split(p);
219
+ if (n === nodeId) return false; // never claim the instance drawing the slot
220
+ return claim ? matches(claim, (store.get(c, n) ?? {}) as Record<string, unknown>) : true;
221
+ })
222
+ .slice(0, node.select?.limit ?? undefined);
223
+ if (!pointers.length)
224
+ return node.fallback ? renderNode(node.fallback, {}, handleAction, activeTheme, key ?? "fb", undefined, def.defs) : null;
225
+ return pointers.map((p) => {
226
+ const { chatId: c, nodeId: n } = store.split(p);
227
+ return (
228
+ <UnoverseComponent
229
+ key={n}
230
+ client={client}
231
+ store={store}
232
+ chatId={c}
233
+ nodeId={n}
234
+ onAction={onAction}
235
+ theme={activeTheme}
236
+ />
237
+ );
238
+ });
239
+ }
240
+ : undefined;
241
+ const tree = renderNode(def.root, merged, handleAction, activeTheme, undefined, resolveSlot, def.defs);
196
242
  // The isolation container MIRRORS the component root's declared sizing intent (§4a:
197
243
  // the component owns its size; the host responds). A root that declares
198
244
  // `height: "full"` needs a definite chain — the container passes the host's height