@unoverse-platform/studio 0.1.80 → 0.1.82

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/local/mcp.ts CHANGED
@@ -138,7 +138,7 @@ export function buildTools(deps: McpDeps, appRoot: string): Record<string, Tool>
138
138
  description: "The project's components or templates.",
139
139
  inputSchema: {
140
140
  type: "object",
141
- properties: { kind: { type: "string", enum: ["component", "template", "atom"] } },
141
+ properties: { kind: { type: "string", enum: ["component", "app", "atom", "template"] } },
142
142
  required: ["kind"],
143
143
  },
144
144
  run: async ({ kind }) => {
@@ -156,8 +156,8 @@ export function buildTools(deps: McpDeps, appRoot: string): Record<string, Tool>
156
156
  needsBinding();
157
157
  const c = await deps.catalog();
158
158
  // `.../definition` is a suffix on the app's URI, not part of its ref.
159
- const ref = String(uri).replace(/^unoverse:\/\/(components|atoms|apps)\//, "").replace(/\/definition$/, "");
160
- const kind = /\/apps\//.test(String(uri)) ? "template" : /\/atoms\//.test(String(uri)) ? "atom" : "component";
159
+ const ref = String(uri).replace(/^unoverse:\/\/(components|atoms|apps|templates)\//, "").replace(/\/definition$/, "");
160
+ const kind = /\/apps\//.test(String(uri)) ? "app" : /\/atoms\//.test(String(uri)) ? "atom" : /^unoverse:\/\/templates\//.test(String(uri)) ? "template" : "component";
161
161
  return { definition: c.loadDefinition(ref, kind), files: deps.sourceFiles(String(uri)) };
162
162
  },
163
163
  },
package/local/plugin.ts CHANGED
@@ -42,7 +42,7 @@ function definitionHome(
42
42
  ): string | null {
43
43
  // An app definition is addressed as `unoverse://apps/<org>/<name>/definition`, so the
44
44
  // suffix has to come off before the rest is read as `<org>/<name>`.
45
- const ref = uri.replace(/^unoverse:\/\/(components|atoms|apps)\//, "").replace(/\/definition$/, "");
45
+ const ref = uri.replace(/^unoverse:\/\/(components|atoms|apps|templates)\//, "").replace(/\/definition$/, "");
46
46
  if (/\/apps\//.test(uri)) {
47
47
  const i = ref.indexOf("/");
48
48
  const [org, name] = i === -1 ? [null, ref] : [ref.slice(0, i), ref.slice(i + 1)];
@@ -57,6 +57,16 @@ function definitionHome(
57
57
  const flat = resolve(designHome(projectRoot), "atoms", `${ref.toLowerCase()}.json`);
58
58
  return existsSync(flat) ? flat : null;
59
59
  }
60
+ // Templates (unoverse://templates/): two tiers, folder form, like components.
61
+ if (/^unoverse:\/\/templates\//.test(uri)) {
62
+ const i = ref.indexOf("/");
63
+ const [org, name] = i === -1 ? [null, ref] : [ref.slice(0, i), ref.slice(i + 1)];
64
+ const homes = org
65
+ ? [resolve(c.projectDir(org), "templates", name.toLowerCase())]
66
+ : [resolve(designHome(projectRoot), "marketplace/templates", ref.toLowerCase())];
67
+ for (const dir of homes) if (existsSync(dir)) return dir;
68
+ return null;
69
+ }
60
70
  const folder = c.componentFolder(ref);
61
71
  if (folder) return folder;
62
72
  // A flat, folderless component: `rx/components/<name>.json` in either tier.
@@ -354,6 +364,11 @@ export function localStudioHost(): Plugin {
354
364
  case path === "/dev/atoms":
355
365
  return json(res, { atoms: c.listDefinitions("atom") });
356
366
 
367
+ // The Template Model kind (authored `type: template`; label: Templates).
368
+ // Two tiers like components, so the org param scopes exactly the same way.
369
+ case path === "/dev/templates":
370
+ return json(res, { templates: c.listDefinitions("template", org) });
371
+
357
372
  // The SDK renderer's two reads, served locally instead of over MCP.
358
373
  case path === "/local/theme": {
359
374
  const t = c.resolveTheme(url.searchParams.get("ref") ?? "light");
@@ -363,13 +378,14 @@ export function localStudioHost(): Plugin {
363
378
  const uri = url.searchParams.get("uri") ?? "";
364
379
  // Apps are addressed both ways (unoverse://apps/<org>/<id> and
365
380
  // unoverse://apps/<org>/<id>/definition); both load the app kind.
366
- const kind = /\/apps\//.test(uri) ? "template" : /\/atoms\//.test(uri) ? "atom" : "component";
381
+ // unoverse://templates/ is the Template Model kind.
382
+ const kind = /\/apps\//.test(uri) ? "app" : /\/atoms\//.test(uri) ? "atom" : /^unoverse:\/\/templates\//.test(uri) ? "template" : "component";
367
383
  // A trailing `@form` is a request for a VARIANT of the definition, not part of
368
384
  // its name. This door did not know that convention and looked up the whole
369
385
  // string, so every component read as `<name>@refs` answered "definition not
370
386
  // found". Strip any variant: this route serves the one form it has, which is
371
387
  // also the only form the server serves.
372
- const ref = uri.replace(/^unoverse:\/\/(components|atoms|apps)\//, "").replace(/\/definition$/, "").replace(/@[a-z]+$/, "");
388
+ const ref = uri.replace(/^unoverse:\/\/(components|atoms|apps|templates)\//, "").replace(/\/definition$/, "").replace(/@[a-z]+$/, "");
373
389
  const d = c.loadDefinition(ref, kind);
374
390
  return d ? json(res, d) : json(res, { error: `definition not found: ${uri}` }, 404);
375
391
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@unoverse-platform/studio",
3
- "version": "0.1.80",
3
+ "version": "0.1.82",
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,317 @@
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).
8
+ *
9
+ * 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).
15
+ *
16
+ * NOT TemplatesView.tsx: that name is taken by the Apps screen (the legacy noun).
17
+ */
18
+ import { useEffect, useMemo, useState, useSyncExternalStore, useCallback, type MouseEvent as ReactMouseEvent } from "react";
19
+ import { ComponentStore, StreamedUnoverseApp, type ResolvedTheme } from "@unoverse/sdk";
20
+ import { client, authedFetch, usePersistentState } from "studio-host";
21
+ import { NAV, SCREEN, navItem } from "./chrome";
22
+
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. */
33
+ state?: string;
34
+ }
35
+ interface TemplateDef {
36
+ name: string;
37
+ org?: string;
38
+ title?: string;
39
+ description?: string;
40
+ whenToUse?: string;
41
+ 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). */
48
+ preview?: Record<string, string[]>;
49
+ }
50
+
51
+ const PREVIEW_CHAT = "preview";
52
+
53
+ const VIEWPORTS: { id: string; label: string; w: number | null }[] = [
54
+ { id: "mobile", label: "Mobile", w: 390 },
55
+ { id: "tablet", label: "Tablet", w: 768 },
56
+ { id: "desktop", label: "Desktop", w: 1280 },
57
+ { id: "full", label: "Full", w: null },
58
+ ];
59
+
60
+ const refOf = (t: TemplateDef) => (t.org ? `${t.org}/${t.name}` : t.name);
61
+
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";
72
+ }
73
+
74
+ export function TemplatesLibraryView({ theme, org }: { theme: ResolvedTheme; org?: string }) {
75
+ const [templates, setTemplates] = useState<TemplateDef[]>([]);
76
+ const [selected, setSelected] = usePersistentState<string>("wb:template", "");
77
+ const [panelOpen, setPanelOpen] = useState(true);
78
+ const [panelHeight, setPanelHeight] = useState(200);
79
+ const [stageWidth, setStageWidth] = useState<number | null>(768);
80
+
81
+ // Drag the divider to resize the parts dock (up = taller). Same as ComponentsView.
82
+ const onDragStart = (e: ReactMouseEvent) => {
83
+ e.preventDefault();
84
+ const startY = e.clientY;
85
+ const startH = panelHeight;
86
+ const onMove = (ev: globalThis.MouseEvent) =>
87
+ setPanelHeight(Math.max(120, Math.min(window.innerHeight - 200, startH + (startY - ev.clientY))));
88
+ const onUp = () => {
89
+ window.removeEventListener("mousemove", onMove);
90
+ window.removeEventListener("mouseup", onUp);
91
+ };
92
+ window.addEventListener("mousemove", onMove);
93
+ window.addEventListener("mouseup", onUp);
94
+ };
95
+
96
+ useEffect(() => {
97
+ const q = org && org !== "all" ? `?org=${encodeURIComponent(org)}` : "";
98
+ authedFetch(`/dev/templates${q}`)
99
+ .then((r) => (r.ok ? r.json() : Promise.reject(new Error(`HTTP ${r.status}`))))
100
+ .then((d) => {
101
+ const list: TemplateDef[] = (d.templates ?? []).sort((a: TemplateDef, b: TemplateDef) =>
102
+ refOf(a).localeCompare(refOf(b)),
103
+ );
104
+ setTemplates(list);
105
+ if (!list.find((t) => refOf(t) === selected) && list[0]) setSelected(refOf(list[0]));
106
+ })
107
+ .catch(() => setTemplates([]));
108
+ // eslint-disable-next-line react-hooks/exhaustive-deps
109
+ }, [org]);
110
+
111
+ const current = templates.find((t) => refOf(t) === selected);
112
+ const uri = useMemo(() => (current ? `unoverse://templates/${refOf(current)}` : ""), [current]);
113
+
114
+ // REAL INSTANCES, fresh per selection (the Components screen's own pattern): the
115
+ // manifest's preview refs seed the store, and from there the standard runtime owns
116
+ // everything — a tap re-faces an instance, the name-matched layout surfaces, its
117
+ // slot claims the instance, and sub-states stay contained in the instance's slice.
118
+ const store = useMemo(() => new ComponentStore(), [selected]);
119
+ useEffect(() => {
120
+ // Seed once per store, and only once `current` has actually loaded — the list
121
+ // arrives async, so this must re-run when it lands, not just on store creation.
122
+ 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
+ });
133
+ // eslint-disable-next-line react-hooks/exhaustive-deps
134
+ }, [store, current]);
135
+
136
+ // The ACTIVE STATE is DERIVED, never stored (the rules): the first declared shape
137
+ // whose name an instance's face matches, else the base. Structure writes bump on
138
+ // every guest tap, so the pills track the runtime exactly.
139
+ const storeVersion = useSyncExternalStore(
140
+ useCallback((cb) => store.subscribe(cb), [store]),
141
+ useCallback(() => store.getStructureVersion(), [store]),
142
+ );
143
+ const shapeNames = current?.shapes ? Object.keys(current.shapes) : [];
144
+ const activeState = useMemo(() => {
145
+ void storeVersion;
146
+ for (const name of shapeNames.slice(1)) {
147
+ const hit = store
148
+ .getResponses()
149
+ .some((r) => r.components.some((p) => {
150
+ const { chatId, nodeId } = store.split(p);
151
+ const slice = store.get(chatId, nodeId) as Record<string, unknown> | undefined;
152
+ return (slice?.view ?? slice?.defaultState) === name;
153
+ }));
154
+ if (hit) return name;
155
+ }
156
+ return shapeNames[0] ?? "";
157
+ // eslint-disable-next-line react-hooks/exhaustive-deps
158
+ }, [storeVersion, store, current]);
159
+
160
+ return (
161
+ <div className={SCREEN}>
162
+ {/* NAVIGATOR — per-item org chip, like Components: no chip = BASE shelf. */}
163
+ <nav className={NAV}>
164
+ {templates.length === 0 && (
165
+ <div className="p-2 text-xs text-gray-400">
166
+ No templates — restart the Unoverse server so /dev/templates is available.
167
+ </div>
168
+ )}
169
+ {templates.map((t) => (
170
+ <div key={refOf(t)}>
171
+ <button
172
+ onClick={() => setSelected(refOf(t))}
173
+ className={`flex w-full items-center gap-2 rounded px-2 py-1 text-left text-sm transition ${navItem(selected === refOf(t))}`}
174
+ >
175
+ <span className="truncate">{t.title ?? t.name}</span>
176
+ {t.org && (
177
+ <span className="ml-auto shrink-0 rounded bg-emerald-900/50 px-1.5 py-0.5 text-[10px] text-emerald-300">
178
+ {t.org}
179
+ </span>
180
+ )}
181
+ </button>
182
+ </div>
183
+ ))}
184
+ </nav>
185
+
186
+ {/* STAGE + PARTS DOCK — the ComponentsView/AtomsView anatomy. */}
187
+ <div className="flex min-h-0 flex-col overflow-hidden">
188
+ <section className="flex min-h-0 flex-1 flex-col overflow-hidden" style={{ background: theme.color["surface.sunken"] }}>
189
+ {/* HEADER — matches ComponentsView (name + responsive toggle). */}
190
+ <div
191
+ className="flex shrink-0 items-center gap-3 border-b px-4 py-2.5"
192
+ style={{ borderColor: theme.color["border.subtle"], background: theme.color["surface.base"] }}
193
+ >
194
+ <span className="text-sm font-semibold uppercase tracking-wide" style={{ color: theme.color["text.primary"] }}>
195
+ {current?.title ?? current?.name ?? ""}
196
+ </span>
197
+ {/* THE TEMPLATE'S STATES, across the top — the same pill row the Apps
198
+ screen gives a multi-layout template (states own shapes; the pill puts
199
+ the preview into that state). One state is not a choice: no row. */}
200
+ {shapeNames.length > 1 && (
201
+ <div className="flex items-center gap-1 rounded p-0.5 text-[11px]" style={{ background: theme.color["border.subtle"] }}>
202
+ {shapeNames.map((name, i) => {
203
+ const on = activeState === name;
204
+ return (
205
+ <button
206
+ key={name}
207
+ onClick={() => {
208
+ // The pill is the guest's hand: it writes FACES, and the
209
+ // state derives — never the other way round. Base pill
210
+ // 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
+ }
220
+ }
221
+ }}
222
+ className="rounded px-2 py-0.5 font-medium capitalize transition"
223
+ style={on ? { background: theme.color["surface.base"], color: theme.color["text.primary"] } : { color: theme.color["text.secondary"] }}
224
+ >
225
+ {name}
226
+ </button>
227
+ );
228
+ })}
229
+ </div>
230
+ )}
231
+ <div className="ml-auto flex items-center gap-1 rounded p-0.5 text-[11px]" style={{ background: theme.color["border.subtle"] }}>
232
+ {VIEWPORTS.map((v) => (
233
+ <button
234
+ key={v.id}
235
+ onClick={() => setStageWidth(v.w)}
236
+ className="rounded px-2 py-0.5 font-medium transition"
237
+ style={
238
+ stageWidth === v.w
239
+ ? { background: theme.color["surface.base"], color: theme.color["text.primary"] }
240
+ : { color: theme.color["text.secondary"] }
241
+ }
242
+ >
243
+ {v.label}
244
+ </button>
245
+ ))}
246
+ </div>
247
+ <span className="tabular-nums text-[11px]" style={{ color: theme.color["text.tertiary"] }}>
248
+ {stageWidth ? `${stageWidth}px` : "100%"}
249
+ </span>
250
+ </div>
251
+
252
+ <div className="flex-1 overflow-auto p-10">
253
+ <div className="mx-auto max-w-full transition-[width] duration-200" style={{ width: stageWidth ?? "100%" }}>
254
+ {uri && <StreamedUnoverseApp key={uri} client={client} store={store} uri={uri} theme={theme} />}
255
+ </div>
256
+ </div>
257
+ </section>
258
+
259
+ {/* PARTS — collapsible + drag-to-resize dock, same as ComponentsView/AtomsView. */}
260
+ {panelOpen && (
261
+ <div
262
+ onMouseDown={onDragStart}
263
+ className="h-1.5 shrink-0 cursor-row-resize bg-gray-700 transition-colors hover:bg-emerald-600/50"
264
+ title="Drag to resize"
265
+ />
266
+ )}
267
+ <div className="flex min-h-0 shrink-0 flex-col bg-[#232a34] text-gray-200" style={panelOpen ? { height: panelHeight } : undefined}>
268
+ <div
269
+ onClick={() => setPanelOpen((o) => !o)}
270
+ title={panelOpen ? "Collapse parts" : "Expand parts"}
271
+ 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
+ >
273
+ <span className="leading-none text-gray-300">{panelOpen ? "▾" : "▸"}</span>
274
+ <span>parts</span>
275
+ {current && <span className="text-gray-400">· {current.name}</span>}
276
+ </div>
277
+ {panelOpen && (
278
+ <div className="min-h-0 flex-1 overflow-auto p-3">
279
+ {current?.parts?.length ? (
280
+ <table className="w-full text-left text-xs">
281
+ <thead className="text-gray-500">
282
+ <tr>
283
+ <th className="pb-1 pr-4 font-medium">part</th>
284
+ <th className="pb-1 pr-4 font-medium">filled by</th>
285
+ <th className="pb-1 font-medium">rules</th>
286
+ </tr>
287
+ </thead>
288
+ <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
+ ))}
306
+ </tbody>
307
+ </table>
308
+ ) : (
309
+ <div className="text-xs text-gray-500">This template declares no parts.</div>
310
+ )}
311
+ </div>
312
+ )}
313
+ </div>
314
+ </div>
315
+ </div>
316
+ );
317
+ }
@@ -621,7 +621,7 @@ export function TemplatesView({
621
621
  setSeeded(comps.map((c) => c.def.name));
622
622
  // The tab strips: one per seeded interface, filed under the container whose claim
623
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;
624
+ const cts = (selectedDef?.app as { templates?: Record<string, { claim?: { state?: string } }> } | undefined)?.templates;
625
625
  setSeededSpecs(
626
626
  comps.map(({ def: c, view: declaredView, envelope }, i) => {
627
627
  const view = declaredView ?? (envelope ? initialOf(c) : undefined);
@@ -723,7 +723,7 @@ export function TemplatesView({
723
723
  // A container names THE INTERFACE IT HOLDS (UNOVERSE_INTERFACE_MODEL §2, the PLACE
724
724
  // verb); the interface's own defaults ARE its mock, seeded into the container's
725
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;
726
+ const containers = (selectedDef?.app as { templates?: Record<string, { states?: Record<string, { interface?: string; state?: string }>; interface?: string }> } | undefined)?.templates;
727
727
  const publicStatesOf = (name: string): string[] => {
728
728
  const hit = components.find((x) => x.name.toLowerCase() === name.toLowerCase());
729
729
  return ((hit as { publicStates?: string[] } | undefined)?.publicStates ?? []).filter(Boolean);
@@ -743,7 +743,17 @@ export function TemplatesView({
743
743
  })
744
744
  : undefined;
745
745
  const flatPreview = containerEntries?.length ? containerEntries : Array.isArray(rawPreview) ? rawPreview : undefined;
746
- const layoutEntry = flatPreview ?? (activeLayout ? previewMap?.[activeLayout] : (stateEntry ?? previewMap?.[baseLayout]));
746
+ // A MOUNT STATE (the state holds a template — UNOVERSE_TEMPLATE_MODEL §12): its
747
+ // pill seeds the STATE'S OWN preview as envelope arrivals. Each card wakes in its
748
+ // arrival face (the mounted template's claim), so the reaction enters the state
749
+ // exactly as a live delivery would — nothing is forced.
750
+ const mounts = (selectedDef as { templates?: Record<string, { template?: string; preview?: string[] }> } | null)?.templates;
751
+ const mountName = activeLayout ?? activeState?.name;
752
+ const stateOwn = mountName ? mounts?.[mountName] : undefined;
753
+ const mountEntry = stateOwn && (stateOwn.template || stateOwn.preview?.length)
754
+ ? (stateOwn.preview ?? []).map((n): Record<string, Record<string, unknown>> => ({ [n]: {} }))
755
+ : undefined;
756
+ const layoutEntry = flatPreview ?? mountEntry ?? (activeLayout ? previewMap?.[activeLayout] : (stateEntry ?? previewMap?.[baseLayout]));
747
757
  const entry = Array.isArray(layoutEntry) ? layoutEntry : undefined;
748
758
  const wantEmpty = !entry && !activeLayout && typeof when === "string" && when === "isEmpty";
749
759
  // A COMPONENT is only seeded for a reaction-contract state (select.where) — the
@@ -829,7 +839,7 @@ export function TemplatesView({
829
839
  const axis = whereSel.field === "view" || whereSel.field === "defaultState";
830
840
  const data = axis ? { view: whereSel.eq ?? true, defaultState: whereSel.eq ?? true } : { [whereSel.field!]: whereSel.eq ?? true };
831
841
  seededSet.forEach((c, i) => {
832
- if (c.view) return;
842
+ if (c.view || (c as { envelope?: boolean }).envelope) return;
833
843
  store.mergeComponentState(chatIdFor(selected ?? "app"), `${c.def.name}-${i}`, data);
834
844
  });
835
845
  }
@@ -926,6 +936,10 @@ export function TemplatesView({
926
936
  <button
927
937
  key={n}
928
938
  onClick={() => {
939
+ // FRESH STORE PER PICK (same law as the state chips' seedKey):
940
+ // seeds from the previous pill would otherwise keep their claim
941
+ // and the ladder never steps back down to the base.
942
+ setSeedKey((k) => k + 1);
929
943
  setActiveLayout(isDefault ? null : n);
930
944
  // STATE MODEL v2: the base's substates (welcome/conversation) exist
931
945
  // only inside the base arrangement — entering a reaction state
@@ -1258,7 +1272,7 @@ export function TemplatesView({
1258
1272
  // THE CONTAINER'S OWN TABS (UNOVERSE_INTERFACE_MODEL §3): dev chrome drawn
1259
1273
  // ON the container, never in the app header (that row was the legacy
1260
1274
  // app-state pills). Inline styles — this renders inside the shadow root.
1261
- containerChrome={
1275
+ templateChrome={
1262
1276
  !live
1263
1277
  ? ({ states, active, set }) => (
1264
1278
  <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" }}>
package/screens/index.ts CHANGED
@@ -19,6 +19,9 @@ export * from "./chrome";
19
19
  export { ComponentsView } from "./ComponentsView";
20
20
  export { AtomsView } from "./AtomsView";
21
21
  export { TemplatesView } from "./TemplatesView";
22
+ // The Template Model's kind (kind: template), display label "Templates". Distinct
23
+ // screen from TemplatesView, which is the APPS screen under the legacy noun.
24
+ export { TemplatesLibraryView } from "./TemplatesLibraryView";
22
25
  export { DesignSystemView } from "./DesignSystemView";
23
26
  export { SkillsView } from "./SkillsView";
24
27
  export { PromptBlocksView } from "./PromptBlocksView";
package/src/App.tsx CHANGED
@@ -13,6 +13,7 @@ import {
13
13
  ComponentsView,
14
14
  AtomsView,
15
15
  TemplatesView,
16
+ TemplatesLibraryView,
16
17
  DesignSystemView,
17
18
  SkillsView,
18
19
  PromptBlocksView,
@@ -27,6 +28,7 @@ import {
27
28
  Atom,
28
29
  Boxes,
29
30
  LayoutDashboard,
31
+ LayoutTemplate,
30
32
  Palette,
31
33
  Sparkles,
32
34
  SquareDashed,
@@ -34,10 +36,11 @@ import {
34
36
  type LucideIcon,
35
37
  } from "lucide-react";
36
38
 
37
- type View = "apps" | "components" | "atoms" | "tokens" | "skills" | "prompts" | "nodes";
39
+ type View = "apps" | "templates" | "components" | "atoms" | "tokens" | "skills" | "prompts" | "nodes";
38
40
 
39
41
  const NAV: { key: View; label: string; group: string; Icon: LucideIcon }[] = [
40
42
  { key: "apps", label: "Apps", group: "Build", Icon: LayoutDashboard },
43
+ { key: "templates", label: "Templates", group: "Build", Icon: LayoutTemplate },
41
44
  { key: "components", label: "Components", group: "Build", Icon: SquareDashed },
42
45
  { key: "atoms", label: "Atoms", group: "Build", Icon: Atom },
43
46
  { key: "tokens", label: "Styles", group: "Build", Icon: Palette },
@@ -190,6 +193,8 @@ function Shell() {
190
193
  // Standalone Studio authors offline: open apps in MOCK (the defaultLive=true
191
194
  // resting mode is Canvas's, where the universe is right there).
192
195
  <TemplatesView theme={theme} themeName={variant} org={org} defaultLive={false} />
196
+ ) : view === "templates" ? (
197
+ <TemplatesLibraryView theme={theme} org={org} />
193
198
  ) : view === "components" ? (
194
199
  <ComponentsView theme={theme} org={org} />
195
200
  ) : view === "atoms" ? (