@unoverse-platform/studio 0.1.85 → 0.1.87

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.87",
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,22 +71,30 @@ 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 }) {
75
94
  const [templates, setTemplates] = useState<TemplateDef[]>([]);
76
95
  const [selected, setSelected] = usePersistentState<string>("wb:template", "");
77
96
  const [panelOpen, setPanelOpen] = useState(true);
97
+ const [metaOpen, setMetaOpen] = usePersistentState<boolean>("wb:templates:meta", false); // manifest meta panel
78
98
  const [panelHeight, setPanelHeight] = useState(200);
79
99
  const [stageWidth, setStageWidth] = useState<number | null>(768);
80
100
 
@@ -108,6 +128,10 @@ export function TemplatesLibraryView({ theme, org }: { theme: ResolvedTheme; org
108
128
  // eslint-disable-next-line react-hooks/exhaustive-deps
109
129
  }, [org]);
110
130
 
131
+ // AVAILABLE — the same master switch every other kind has (§9c): is this template
132
+ // offered to workflows at all? Level 2 (is it on THIS workflow's map?) stays in
133
+ // Canvas's Content Library, exactly as for a component.
134
+ const available = useRegistryToggles("template");
111
135
  const current = templates.find((t) => refOf(t) === selected);
112
136
  const uri = useMemo(() => (current ? `unoverse://templates/${refOf(current)}` : ""), [current]);
113
137
 
@@ -116,20 +140,40 @@ export function TemplatesLibraryView({ theme, org }: { theme: ResolvedTheme; org
116
140
  // everything — a tap re-faces an instance, the name-matched layout surfaces, its
117
141
  // slot claims the instance, and sub-states stay contained in the instance's slice.
118
142
  const store = useMemo(() => new ComponentStore(), [selected]);
143
+
144
+ const seeds = useMemo(() => {
145
+ // THE SEED PLAN, from the BASE state's compiled sections (§9b): a linked
146
+ // section (static/copywriter) seeds ONE instance of its own component at its
147
+ // face — the template's FRAME, which the state pills never re-face. The
148
+ // director section seeds its preview refs at its face — the items the guest
149
+ // actually browses.
150
+ const base = current?.defaultLayout ?? (current?.layouts ? Object.keys(current.layouts)[0] : undefined);
151
+ const secs = (base && current?.sections?.[base]) || [];
152
+ const out: { nodeId: string; ref: string; face?: string; framed: boolean; seed?: Record<string, unknown> }[] = [];
153
+ for (const sec of secs) {
154
+ if (sec.kind === "director") {
155
+ for (const [i, ref] of (sec.preview ?? []).entries())
156
+ out.push({ nodeId: `${sec.face}~${ref}~${i}`, ref, face: sec.face, framed: false, seed: sec.seeds?.[ref] });
157
+ } else if (sec.ref) {
158
+ out.push({ nodeId: `${sec.face}~${sec.ref}`, ref: sec.ref, face: sec.face, framed: true, seed: sec.seeds?.[sec.ref] });
159
+ }
160
+ }
161
+ return out;
162
+ }, [current]);
163
+ const framedIds = useMemo(() => new Set(seeds.filter((s) => s.framed).map((s) => s.nodeId)), [seeds]);
164
+
119
165
  useEffect(() => {
120
166
  // Seed once per store, and only once `current` has actually loaded — the list
121
167
  // arrives async, so this must re-run when it lands, not just on store creation.
122
168
  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
- });
169
+ for (const { nodeId, ref, face, seed } of seeds) {
170
+ // The part's own mock values, exactly as the Components screen seeds a component
171
+ // (`preview ?? default`) — compiled onto the section by the server, because only it
172
+ // can resolve a template-local part. Seeded with nothing, every copywriter part
173
+ // draws as skeletons and a finished arrangement reads as a broken one.
174
+ store.apply({ type: "COMPONENT_INIT", chatId: PREVIEW_CHAT, nodeId, component: { type: ref, props: seed ?? {} } });
175
+ if (face) store.mergeComponentState(PREVIEW_CHAT, nodeId, { state: face });
176
+ }
133
177
  // eslint-disable-next-line react-hooks/exhaustive-deps
134
178
  }, [store, current]);
135
179
 
@@ -140,7 +184,7 @@ export function TemplatesLibraryView({ theme, org }: { theme: ResolvedTheme; org
140
184
  useCallback((cb) => store.subscribe(cb), [store]),
141
185
  useCallback(() => store.getStructureVersion(), [store]),
142
186
  );
143
- const shapeNames = current?.shapes ? Object.keys(current.shapes) : [];
187
+ const shapeNames = current?.layouts ? Object.keys(current.layouts) : [];
144
188
  const activeState = useMemo(() => {
145
189
  void storeVersion;
146
190
  for (const name of shapeNames.slice(1)) {
@@ -149,7 +193,7 @@ export function TemplatesLibraryView({ theme, org }: { theme: ResolvedTheme; org
149
193
  .some((r) => r.components.some((p) => {
150
194
  const { chatId, nodeId } = store.split(p);
151
195
  const slice = store.get(chatId, nodeId) as Record<string, unknown> | undefined;
152
- return (slice?.view ?? slice?.defaultState) === name;
196
+ return (slice?.state ?? slice?.view ?? slice?.defaultState) === name;
153
197
  }));
154
198
  if (hit) return name;
155
199
  }
@@ -197,6 +241,31 @@ export function TemplatesLibraryView({ theme, org }: { theme: ResolvedTheme; org
197
241
  {/* THE TEMPLATE'S STATES, across the top — the same pill row the Apps
198
242
  screen gives a multi-layout template (states own shapes; the pill puts
199
243
  the preview into that state). One state is not a choice: no row. */}
244
+ {current?.spatial && (
245
+ <ToggleSwitch
246
+ on={available.isEnabled(refOf(current))}
247
+ onClick={() => available.toggle(refOf(current))}
248
+ label="Available"
249
+ />
250
+ )}
251
+ {/* META — the same disclosure Components and Apps carry, on the same
252
+ mandatory manifest (§9c: a template is discovered like everything
253
+ else, so what it is DISCOVERED BY must be readable where it is
254
+ designed). Disclosure only; it changes nothing. */}
255
+ {current?.spatial && (
256
+ <button
257
+ onClick={() => setMetaOpen((o) => !o)}
258
+ className="flex items-center gap-1 rounded border px-2 py-0.5 text-[11px] font-medium"
259
+ style={{
260
+ borderColor: theme.color["border.subtle"],
261
+ color: metaOpen ? theme.color["text.primary"] : theme.color["text.secondary"],
262
+ background: metaOpen ? theme.color["surface.sunken"] : "transparent",
263
+ }}
264
+ title="Show the template's manifest (spatial discovery meta)"
265
+ >
266
+ ⓘ Meta <span className="opacity-60">{metaOpen ? "▴" : "▾"}</span>
267
+ </button>
268
+ )}
200
269
  {shapeNames.length > 1 && (
201
270
  <div className="flex items-center gap-1 rounded p-0.5 text-[11px]" style={{ background: theme.color["border.subtle"] }}>
202
271
  {shapeNames.map((name, i) => {
@@ -208,16 +277,29 @@ export function TemplatesLibraryView({ theme, org }: { theme: ResolvedTheme; org
208
277
  // The pill is the guest's hand: it writes FACES, and the
209
278
  // state derives — never the other way round. Base pill
210
279
  // 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
- }
280
+ //
281
+ // THE FRAME IS NOT AN ITEM. Every seeded instance used to be
282
+ // treated as one, which held while a template had a single
283
+ // slot: on email-digest the base pill re-faced the masthead
284
+ // and footer to `grid` (they left their slots and landed in
285
+ // the item band), and the `page` pill wrote the face onto the
286
+ // first component in the response — the masthead — so the
287
+ // detail shape surfaced with nothing in it. Slots with an
288
+ // authored `state:` are skipped, and the detail pill opens
289
+ // the first ITEM.
290
+ const items = store
291
+ .getResponses()
292
+ .flatMap((r) => r.components)
293
+ .filter((ptr) => !framedIds.has(store.split(ptr).nodeId));
294
+ for (const [j, ptr] of items.entries()) {
295
+ const { chatId, nodeId } = store.split(ptr);
296
+ const slice = store.get(chatId, nodeId) as Record<string, unknown> | undefined;
297
+ if (i === 0) {
298
+ if ((slice?.state ?? slice?.view ?? slice?.defaultState) != null) store.mergeComponentState(chatId, nodeId, { state: shapeNames[0] });
299
+ } else if (j === 0) {
300
+ store.mergeComponentState(chatId, nodeId, { state: name });
220
301
  }
302
+ }
221
303
  }}
222
304
  className="rounded px-2 py-0.5 font-medium capitalize transition"
223
305
  style={on ? { background: theme.color["surface.base"], color: theme.color["text.primary"] } : { color: theme.color["text.secondary"] }}
@@ -249,6 +331,42 @@ export function TemplatesLibraryView({ theme, org }: { theme: ResolvedTheme; org
249
331
  </span>
250
332
  </div>
251
333
 
334
+ {/* MANIFEST META — identical anatomy to the Components and Apps panels:
335
+ title / category chip / muted tag, description, when-to-use callout. */}
336
+ {metaOpen && current?.spatial && (() => {
337
+ const accent = theme.color["action.primary"] ?? "#6366f1";
338
+ return (
339
+ <div className="border-b px-5 py-4" style={{ borderColor: theme.color["border.subtle"], background: theme.color["surface.base"] }}>
340
+ <div className="mx-auto flex max-w-5xl flex-col gap-1">
341
+ <div className="flex flex-wrap items-center gap-2">
342
+ <h2 className="text-[17px] font-semibold leading-tight tracking-tight" style={{ color: theme.color["text.primary"] }}>
343
+ {current.title || current.name}
344
+ </h2>
345
+ {current.category && (
346
+ <span className="rounded-full px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide" style={{ background: `${accent}14`, color: accent }}>
347
+ {current.category}
348
+ </span>
349
+ )}
350
+ <span className="font-mono text-[10px]" style={{ color: theme.color["text.tertiary"] }}>
351
+ manifest.json
352
+ </span>
353
+ </div>
354
+ {current.description && (
355
+ <p className="mt-1 max-w-3xl text-[13px] leading-relaxed" style={{ color: theme.color["text.secondary"] }}>{current.description}</p>
356
+ )}
357
+ {current.whenToUse && (
358
+ <div className="mt-2 max-w-3xl rounded-r py-1.5 pl-3" style={{ borderLeft: `3px solid ${accent}`, background: theme.color["surface.sunken"] }}>
359
+ <div className="mb-0.5 text-[10px] font-semibold uppercase tracking-wider" style={{ color: accent }}>
360
+ When to use
361
+ </div>
362
+ <p className="text-[15px] font-medium leading-relaxed" style={{ color: theme.color["text.primary"] }}>{current.whenToUse}</p>
363
+ </div>
364
+ )}
365
+ </div>
366
+ </div>
367
+ );
368
+ })()}
369
+
252
370
  <div className="flex-1 overflow-auto p-10">
253
371
  <div className="mx-auto max-w-full transition-[width] duration-200" style={{ width: stageWidth ?? "100%" }}>
254
372
  {uri && <StreamedUnoverseApp key={uri} client={client} store={store} uri={uri} theme={theme} />}
@@ -267,46 +385,43 @@ export function TemplatesLibraryView({ theme, org }: { theme: ResolvedTheme; org
267
385
  <div className="flex min-h-0 shrink-0 flex-col bg-[#232a34] text-gray-200" style={panelOpen ? { height: panelHeight } : undefined}>
268
386
  <div
269
387
  onClick={() => setPanelOpen((o) => !o)}
270
- title={panelOpen ? "Collapse parts" : "Expand parts"}
388
+ title={panelOpen ? "Collapse sections" : "Expand sections"}
271
389
  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
390
  >
273
391
  <span className="leading-none text-gray-300">{panelOpen ? "▾" : "▸"}</span>
274
- <span>parts</span>
392
+ <span>sections</span>
275
393
  {current && <span className="text-gray-400">· {current.name}</span>}
276
394
  </div>
277
395
  {panelOpen && (
278
396
  <div className="min-h-0 flex-1 overflow-auto p-3">
279
- {current?.parts?.length ? (
397
+ {(() => {
398
+ const base = current?.defaultLayout ?? (current?.layouts ? Object.keys(current.layouts)[0] : undefined);
399
+ const secs = (base && current?.sections?.[base]) || [];
400
+ return secs.length ? (
280
401
  <table className="w-full text-left text-xs">
281
402
  <thead className="text-gray-500">
282
403
  <tr>
283
- <th className="pb-1 pr-4 font-medium">part</th>
404
+ <th className="pb-1 pr-4 font-medium">section</th>
284
405
  <th className="pb-1 pr-4 font-medium">filled by</th>
285
406
  <th className="pb-1 font-medium">rules</th>
286
407
  </tr>
287
408
  </thead>
288
409
  <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
- ))}
410
+ {secs.map((sec, i) => {
411
+ const l = sectionLabel(sec);
412
+ return (
413
+ <tr key={i} className="border-t border-gray-700/60">
414
+ <td className="py-1 pr-4 font-mono">{l.name}</td>
415
+ <td className="py-1 pr-4">{l.filler}</td>
416
+ <td className="py-1 text-gray-500">{l.rules}</td>
417
+ </tr>
418
+ );
419
+ })}
306
420
  </tbody>
307
421
  </table>
308
- ) : (
309
- <div className="text-xs text-gray-500">This template declares no parts.</div>
422
+ ) : null;
423
+ })() || (
424
+ <div className="text-xs text-gray-500">This template declares no sections.</div>
310
425
  )}
311
426
  </div>
312
427
  )}
@@ -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