@unoverse-platform/studio 0.1.81 → 0.1.83
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 +3 -3
- package/local/plugin.ts +19 -3
- package/package.json +1 -1
- package/screens/TemplatesLibraryView.tsx +317 -0
- package/screens/TemplatesView.tsx +19 -5
- package/screens/index.ts +3 -0
- package/src/App.tsx +6 -1
- package/vendor/sdk/lib/app.tsx +181 -25
- package/vendor/sdk/lib/connection.tsx +11 -9
- package/vendor/sdk/lib/core/apps.ts +42 -5
- package/vendor/sdk/lib/core/client.ts +15 -12
- package/vendor/sdk/lib/core/store.ts +51 -3
- package/vendor/sdk/lib/core/types.ts +20 -3
- package/vendor/sdk/lib/primitives.tsx +23 -0
- package/vendor/sdk/lib/render.tsx +10 -3
- package/vendor/sdk/lib/style.ts +4 -1
- package/vendor/sdk/main.tsx +26 -3
- package/vendor/sdk/responseView.tsx +82 -0
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", "
|
|
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)) ? "
|
|
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
|
-
|
|
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
|
@@ -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 {
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
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" ? (
|