@unoverse-platform/studio 0.1.85 → 0.1.86
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/screens/ComponentsView.tsx +4 -3
- package/screens/TemplatesLibraryView.tsx +142 -82
- package/screens/TemplatesView.tsx +49 -8
- package/screens/states.ts +25 -7
- package/src/host.ts +1 -1
- package/vendor/sdk/lib/UnoverseComponent.tsx +49 -3
- package/vendor/sdk/lib/app.tsx +121 -31
- package/vendor/sdk/lib/core/actions.ts +2 -2
- package/vendor/sdk/lib/core/apps.ts +14 -8
- package/vendor/sdk/lib/core/conditions.ts +15 -3
- package/vendor/sdk/lib/core/connection.ts +17 -1
- package/vendor/sdk/lib/core/store.ts +104 -21
- package/vendor/sdk/lib/transition/MountArrangement.tsx +71 -0
- package/vendor/sdk/lib/transition/decide.ts +53 -0
- package/vendor/sdk/lib/transition/guestHold.ts +34 -0
- package/vendor/sdk/lib/transition/index.ts +9 -0
- package/vendor/sdk/lib/transition/viewTransition.ts +29 -0
- package/vendor/skills/unoverse-create/SKILL.md +11 -5
- package/vendor/skills/unoverse-create/references/app.md +35 -21
- package/vendor/skills/unoverse-create/references/component.md +55 -54
- package/vendor/skills/unoverse-create/references/node.md +3 -1
- package/vendor/skills/unoverse-create/references/template.md +133 -0
package/package.json
CHANGED
|
@@ -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
|
-
|
|
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
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
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
|
|
11
|
-
* contract: each
|
|
12
|
-
* fills come from deliveries, not typed values. The preview renders the
|
|
13
|
-
* synthesized root:
|
|
14
|
-
*
|
|
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
|
|
24
|
-
static
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
53
|
+
/** Compiled per-state sections (§9b) — the loader's projection of the layout's
|
|
54
|
+
* three-word lines; the wire layouts carry the expanded claims. */
|
|
55
|
+
sections?: Record<string, TemplateSection[]>;
|
|
56
|
+
/** The composed arrangements, keyed by state — the pills. */
|
|
57
|
+
layouts?: Record<string, unknown>;
|
|
58
|
+
defaultLayout?: string;
|
|
59
|
+
/** Seeding map the loader assembles: face → interface refs. */
|
|
48
60
|
preview?: Record<string, string[]>;
|
|
49
61
|
}
|
|
50
62
|
|
|
@@ -59,16 +71,23 @@ const VIEWPORTS: { id: string; label: string; w: number | null }[] = [
|
|
|
59
71
|
|
|
60
72
|
const refOf = (t: TemplateDef) => (t.org ? `${t.org}/${t.name}` : t.name);
|
|
61
73
|
|
|
62
|
-
/** One
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
74
|
+
/** One section's description for the dock table — the three words (§9b). */
|
|
75
|
+
function sectionLabel(sec: TemplateSection): { name: string; filler: string; rules: string } {
|
|
76
|
+
if (sec.kind === "director")
|
|
77
|
+
return {
|
|
78
|
+
name: "director",
|
|
79
|
+
filler: "director",
|
|
80
|
+
rules: [
|
|
81
|
+
sec.state ? `state ${sec.state}` : null,
|
|
82
|
+
sec.limit != null ? `limit ${sec.limit}` : null,
|
|
83
|
+
sec.rules ?? null,
|
|
84
|
+
].filter(Boolean).join(" · "),
|
|
85
|
+
};
|
|
86
|
+
return {
|
|
87
|
+
name: sec.component ?? sec.face,
|
|
88
|
+
filler: sec.kind === "copywriter" ? "copywriter" : "static",
|
|
89
|
+
rules: "",
|
|
90
|
+
};
|
|
72
91
|
}
|
|
73
92
|
|
|
74
93
|
export function TemplatesLibraryView({ theme, org }: { theme: ResolvedTheme; org?: string }) {
|
|
@@ -108,6 +127,10 @@ export function TemplatesLibraryView({ theme, org }: { theme: ResolvedTheme; org
|
|
|
108
127
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
109
128
|
}, [org]);
|
|
110
129
|
|
|
130
|
+
// AVAILABLE — the same master switch every other kind has (§9c): is this template
|
|
131
|
+
// offered to workflows at all? Level 2 (is it on THIS workflow's map?) stays in
|
|
132
|
+
// Canvas's Content Library, exactly as for a component.
|
|
133
|
+
const available = useRegistryToggles("template");
|
|
111
134
|
const current = templates.find((t) => refOf(t) === selected);
|
|
112
135
|
const uri = useMemo(() => (current ? `unoverse://templates/${refOf(current)}` : ""), [current]);
|
|
113
136
|
|
|
@@ -116,20 +139,40 @@ export function TemplatesLibraryView({ theme, org }: { theme: ResolvedTheme; org
|
|
|
116
139
|
// everything — a tap re-faces an instance, the name-matched layout surfaces, its
|
|
117
140
|
// slot claims the instance, and sub-states stay contained in the instance's slice.
|
|
118
141
|
const store = useMemo(() => new ComponentStore(), [selected]);
|
|
142
|
+
|
|
143
|
+
const seeds = useMemo(() => {
|
|
144
|
+
// THE SEED PLAN, from the BASE state's compiled sections (§9b): a linked
|
|
145
|
+
// section (static/copywriter) seeds ONE instance of its own component at its
|
|
146
|
+
// face — the template's FRAME, which the state pills never re-face. The
|
|
147
|
+
// director section seeds its preview refs at its face — the items the guest
|
|
148
|
+
// actually browses.
|
|
149
|
+
const base = current?.defaultLayout ?? (current?.layouts ? Object.keys(current.layouts)[0] : undefined);
|
|
150
|
+
const secs = (base && current?.sections?.[base]) || [];
|
|
151
|
+
const out: { nodeId: string; ref: string; face?: string; framed: boolean; seed?: Record<string, unknown> }[] = [];
|
|
152
|
+
for (const sec of secs) {
|
|
153
|
+
if (sec.kind === "director") {
|
|
154
|
+
for (const [i, ref] of (sec.preview ?? []).entries())
|
|
155
|
+
out.push({ nodeId: `${sec.face}~${ref}~${i}`, ref, face: sec.face, framed: false, seed: sec.seeds?.[ref] });
|
|
156
|
+
} else if (sec.ref) {
|
|
157
|
+
out.push({ nodeId: `${sec.face}~${sec.ref}`, ref: sec.ref, face: sec.face, framed: true, seed: sec.seeds?.[sec.ref] });
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
return out;
|
|
161
|
+
}, [current]);
|
|
162
|
+
const framedIds = useMemo(() => new Set(seeds.filter((s) => s.framed).map((s) => s.nodeId)), [seeds]);
|
|
163
|
+
|
|
119
164
|
useEffect(() => {
|
|
120
165
|
// Seed once per store, and only once `current` has actually loaded — the list
|
|
121
166
|
// arrives async, so this must re-run when it lands, not just on store creation.
|
|
122
167
|
if (!current || store.getTimeline().length) return;
|
|
123
|
-
const
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
if (baseFace) store.mergeComponentState(PREVIEW_CHAT, `${ref}~${i}`, { view: baseFace });
|
|
132
|
-
});
|
|
168
|
+
for (const { nodeId, ref, face, seed } of seeds) {
|
|
169
|
+
// The part's own mock values, exactly as the Components screen seeds a component
|
|
170
|
+
// (`preview ?? default`) — compiled onto the section by the server, because only it
|
|
171
|
+
// can resolve a template-local part. Seeded with nothing, every copywriter part
|
|
172
|
+
// draws as skeletons and a finished arrangement reads as a broken one.
|
|
173
|
+
store.apply({ type: "COMPONENT_INIT", chatId: PREVIEW_CHAT, nodeId, component: { type: ref, props: seed ?? {} } });
|
|
174
|
+
if (face) store.mergeComponentState(PREVIEW_CHAT, nodeId, { state: face });
|
|
175
|
+
}
|
|
133
176
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
134
177
|
}, [store, current]);
|
|
135
178
|
|
|
@@ -140,7 +183,7 @@ export function TemplatesLibraryView({ theme, org }: { theme: ResolvedTheme; org
|
|
|
140
183
|
useCallback((cb) => store.subscribe(cb), [store]),
|
|
141
184
|
useCallback(() => store.getStructureVersion(), [store]),
|
|
142
185
|
);
|
|
143
|
-
const shapeNames = current?.
|
|
186
|
+
const shapeNames = current?.layouts ? Object.keys(current.layouts) : [];
|
|
144
187
|
const activeState = useMemo(() => {
|
|
145
188
|
void storeVersion;
|
|
146
189
|
for (const name of shapeNames.slice(1)) {
|
|
@@ -149,7 +192,7 @@ export function TemplatesLibraryView({ theme, org }: { theme: ResolvedTheme; org
|
|
|
149
192
|
.some((r) => r.components.some((p) => {
|
|
150
193
|
const { chatId, nodeId } = store.split(p);
|
|
151
194
|
const slice = store.get(chatId, nodeId) as Record<string, unknown> | undefined;
|
|
152
|
-
return (slice?.view ?? slice?.defaultState) === name;
|
|
195
|
+
return (slice?.state ?? slice?.view ?? slice?.defaultState) === name;
|
|
153
196
|
}));
|
|
154
197
|
if (hit) return name;
|
|
155
198
|
}
|
|
@@ -197,6 +240,13 @@ export function TemplatesLibraryView({ theme, org }: { theme: ResolvedTheme; org
|
|
|
197
240
|
{/* THE TEMPLATE'S STATES, across the top — the same pill row the Apps
|
|
198
241
|
screen gives a multi-layout template (states own shapes; the pill puts
|
|
199
242
|
the preview into that state). One state is not a choice: no row. */}
|
|
243
|
+
{current?.spatial && (
|
|
244
|
+
<ToggleSwitch
|
|
245
|
+
on={available.isEnabled(refOf(current))}
|
|
246
|
+
onClick={() => available.toggle(refOf(current))}
|
|
247
|
+
label="Available"
|
|
248
|
+
/>
|
|
249
|
+
)}
|
|
200
250
|
{shapeNames.length > 1 && (
|
|
201
251
|
<div className="flex items-center gap-1 rounded p-0.5 text-[11px]" style={{ background: theme.color["border.subtle"] }}>
|
|
202
252
|
{shapeNames.map((name, i) => {
|
|
@@ -208,16 +258,29 @@ export function TemplatesLibraryView({ theme, org }: { theme: ResolvedTheme; org
|
|
|
208
258
|
// The pill is the guest's hand: it writes FACES, and the
|
|
209
259
|
// state derives — never the other way round. Base pill
|
|
210
260
|
// returns every instance to the base face.
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
261
|
+
//
|
|
262
|
+
// THE FRAME IS NOT AN ITEM. Every seeded instance used to be
|
|
263
|
+
// treated as one, which held while a template had a single
|
|
264
|
+
// slot: on email-digest the base pill re-faced the masthead
|
|
265
|
+
// and footer to `grid` (they left their slots and landed in
|
|
266
|
+
// the item band), and the `page` pill wrote the face onto the
|
|
267
|
+
// first component in the response — the masthead — so the
|
|
268
|
+
// detail shape surfaced with nothing in it. Slots with an
|
|
269
|
+
// authored `state:` are skipped, and the detail pill opens
|
|
270
|
+
// the first ITEM.
|
|
271
|
+
const items = store
|
|
272
|
+
.getResponses()
|
|
273
|
+
.flatMap((r) => r.components)
|
|
274
|
+
.filter((ptr) => !framedIds.has(store.split(ptr).nodeId));
|
|
275
|
+
for (const [j, ptr] of items.entries()) {
|
|
276
|
+
const { chatId, nodeId } = store.split(ptr);
|
|
277
|
+
const slice = store.get(chatId, nodeId) as Record<string, unknown> | undefined;
|
|
278
|
+
if (i === 0) {
|
|
279
|
+
if ((slice?.state ?? slice?.view ?? slice?.defaultState) != null) store.mergeComponentState(chatId, nodeId, { state: shapeNames[0] });
|
|
280
|
+
} else if (j === 0) {
|
|
281
|
+
store.mergeComponentState(chatId, nodeId, { state: name });
|
|
220
282
|
}
|
|
283
|
+
}
|
|
221
284
|
}}
|
|
222
285
|
className="rounded px-2 py-0.5 font-medium capitalize transition"
|
|
223
286
|
style={on ? { background: theme.color["surface.base"], color: theme.color["text.primary"] } : { color: theme.color["text.secondary"] }}
|
|
@@ -267,46 +330,43 @@ export function TemplatesLibraryView({ theme, org }: { theme: ResolvedTheme; org
|
|
|
267
330
|
<div className="flex min-h-0 shrink-0 flex-col bg-[#232a34] text-gray-200" style={panelOpen ? { height: panelHeight } : undefined}>
|
|
268
331
|
<div
|
|
269
332
|
onClick={() => setPanelOpen((o) => !o)}
|
|
270
|
-
title={panelOpen ? "Collapse
|
|
333
|
+
title={panelOpen ? "Collapse sections" : "Expand sections"}
|
|
271
334
|
className="flex cursor-pointer select-none items-center gap-2 border-y border-gray-700 px-3 py-2 text-[11px] uppercase tracking-wider text-gray-400 hover:bg-white/5"
|
|
272
335
|
>
|
|
273
336
|
<span className="leading-none text-gray-300">{panelOpen ? "▾" : "▸"}</span>
|
|
274
|
-
<span>
|
|
337
|
+
<span>sections</span>
|
|
275
338
|
{current && <span className="text-gray-400">· {current.name}</span>}
|
|
276
339
|
</div>
|
|
277
340
|
{panelOpen && (
|
|
278
341
|
<div className="min-h-0 flex-1 overflow-auto p-3">
|
|
279
|
-
{
|
|
342
|
+
{(() => {
|
|
343
|
+
const base = current?.defaultLayout ?? (current?.layouts ? Object.keys(current.layouts)[0] : undefined);
|
|
344
|
+
const secs = (base && current?.sections?.[base]) || [];
|
|
345
|
+
return secs.length ? (
|
|
280
346
|
<table className="w-full text-left text-xs">
|
|
281
347
|
<thead className="text-gray-500">
|
|
282
348
|
<tr>
|
|
283
|
-
<th className="pb-1 pr-4 font-medium">
|
|
349
|
+
<th className="pb-1 pr-4 font-medium">section</th>
|
|
284
350
|
<th className="pb-1 pr-4 font-medium">filled by</th>
|
|
285
351
|
<th className="pb-1 font-medium">rules</th>
|
|
286
352
|
</tr>
|
|
287
353
|
</thead>
|
|
288
354
|
<tbody className="text-gray-300">
|
|
289
|
-
{
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
<
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
p.state ? `state ${p.state}` : null,
|
|
300
|
-
]
|
|
301
|
-
.filter(Boolean)
|
|
302
|
-
.join(" · ")}
|
|
303
|
-
</td>
|
|
304
|
-
</tr>
|
|
305
|
-
))}
|
|
355
|
+
{secs.map((sec, i) => {
|
|
356
|
+
const l = sectionLabel(sec);
|
|
357
|
+
return (
|
|
358
|
+
<tr key={i} className="border-t border-gray-700/60">
|
|
359
|
+
<td className="py-1 pr-4 font-mono">{l.name}</td>
|
|
360
|
+
<td className="py-1 pr-4">{l.filler}</td>
|
|
361
|
+
<td className="py-1 text-gray-500">{l.rules}</td>
|
|
362
|
+
</tr>
|
|
363
|
+
);
|
|
364
|
+
})}
|
|
306
365
|
</tbody>
|
|
307
366
|
</table>
|
|
308
|
-
|
|
309
|
-
|
|
367
|
+
) : null;
|
|
368
|
+
})() || (
|
|
369
|
+
<div className="text-xs text-gray-500">This template declares no sections.</div>
|
|
310
370
|
)}
|
|
311
371
|
</div>
|
|
312
372
|
)}
|
|
@@ -77,6 +77,9 @@ interface PropDef {
|
|
|
77
77
|
}
|
|
78
78
|
interface Def {
|
|
79
79
|
name: string;
|
|
80
|
+
/** The artifact's kind. Templates sit beside components in the seed lookup (§9c), and
|
|
81
|
+
* the kind decides which home their definition is addressed at. */
|
|
82
|
+
kind?: string;
|
|
80
83
|
// The client org this app belongs to (server-injected) — names its theme (`<org>/light`).
|
|
81
84
|
org?: string;
|
|
82
85
|
description?: string;
|
|
@@ -172,7 +175,7 @@ export function TemplatesView({
|
|
|
172
175
|
// MOCK (offline) vs LIVE (real data plane). A fresh store per mode keeps the two paths
|
|
173
176
|
// from bleeding into each other.
|
|
174
177
|
const [live, setLive] = useState(defaultLive);
|
|
175
|
-
const available = useRegistryToggles("
|
|
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
|
-
|
|
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[] =
|
|
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]: {
|
|
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
|
-
/**
|
|
25
|
-
|
|
26
|
-
|
|
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
|
-
|
|
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" | "
|
|
190
|
+
export type RegistryKind = "node" | "skill" | "app" | "prompt" | "component" | "template";
|
|
191
191
|
|
|
192
192
|
/**
|
|
193
193
|
* Registry toggles decide what a RUNNING universe exposes; they persist to
|
|
@@ -14,8 +14,8 @@
|
|
|
14
14
|
* definition, and `setValue` writes the same instance either way.
|
|
15
15
|
*/
|
|
16
16
|
import { useCallback, useEffect, useMemo, useState, useSyncExternalStore, type CSSProperties } from "react";
|
|
17
|
-
import { dispatchAction, type ComponentStore, type ResolvedTheme, type UnoverseClient, type UnoverseDefinition } from "./core";
|
|
18
|
-
import { renderNode, keyframesCss, type ActionHandler } from "./render";
|
|
17
|
+
import { dispatchAction, matches, type ComponentStore, type ResolvedTheme, type UnoverseClient, type UnoverseDefinition } from "./core";
|
|
18
|
+
import { renderNode, keyframesCss, type ActionHandler, type SlotResolver } from "./render";
|
|
19
19
|
import { useUnoverseTheme, ensureFontStylesheets } from "./theme";
|
|
20
20
|
import { IsolatedRoot } from "./isolate";
|
|
21
21
|
|
|
@@ -192,7 +192,53 @@ export function UnoverseComponent({ client, uri, data, store, chatId, nodeId, ex
|
|
|
192
192
|
const keyframes = <style>{keyframesCss(activeTheme)}</style>;
|
|
193
193
|
// The shared pieces this definition NAMES travel beside it and are threaded through
|
|
194
194
|
// every recursion, so a reference nested in a Switch branch or an Each item resolves too.
|
|
195
|
-
|
|
195
|
+
// A DEFINITION THAT CARRIES SLOTS RESOLVES THEM — the rule the SDK already applies to
|
|
196
|
+
// an app tree, applied here too. Without it a rendered TEMPLATE drew its frame and every
|
|
197
|
+
// section resolved to null (render.tsx: `slot ? slot(node) : null`), so a page that
|
|
198
|
+
// STREAMED IN rather than being mounted by an app came out blank. Mounted templates were
|
|
199
|
+
// fine because `app.tsx` hands that path a resolver; instances never got one, and a
|
|
200
|
+
// definition rendering its own parts nowhere is a renderer bug, not a design decision.
|
|
201
|
+
//
|
|
202
|
+
// Generic and dumb, like the rest of it: the slot's claim picks instances out of the
|
|
203
|
+
// SAME store by the face they wear, in the order the store holds them. It knows nothing
|
|
204
|
+
// about templates, days or cards — only "which instances match this claim".
|
|
205
|
+
//
|
|
206
|
+
// TEMPLATES ONLY, and the boundary matters: an ordinary component may carry the
|
|
207
|
+
// conversation's FLOW slot (`select: {}` inside a chat's assistant turn), and that one
|
|
208
|
+
// belongs to the app's timeline, which scopes it to a single turn's unclaimed
|
|
209
|
+
// components. Resolving it here too rendered every instance in the store a second time
|
|
210
|
+
// — the composed page appeared in its surface AND in the chat beside it.
|
|
211
|
+
const resolveSlot: SlotResolver | undefined = store && (def as { sections?: unknown }).sections
|
|
212
|
+
? (node, key) => {
|
|
213
|
+
const claim = node.select?.where;
|
|
214
|
+
const pointers = store
|
|
215
|
+
.getResponses()
|
|
216
|
+
.flatMap((r) => r.components)
|
|
217
|
+
.filter((p) => {
|
|
218
|
+
const { chatId: c, nodeId: n } = store.split(p);
|
|
219
|
+
if (n === nodeId) return false; // never claim the instance drawing the slot
|
|
220
|
+
return claim ? matches(claim, (store.get(c, n) ?? {}) as Record<string, unknown>) : true;
|
|
221
|
+
})
|
|
222
|
+
.slice(0, node.select?.limit ?? undefined);
|
|
223
|
+
if (!pointers.length)
|
|
224
|
+
return node.fallback ? renderNode(node.fallback, {}, handleAction, activeTheme, key ?? "fb", undefined, def.defs) : null;
|
|
225
|
+
return pointers.map((p) => {
|
|
226
|
+
const { chatId: c, nodeId: n } = store.split(p);
|
|
227
|
+
return (
|
|
228
|
+
<UnoverseComponent
|
|
229
|
+
key={n}
|
|
230
|
+
client={client}
|
|
231
|
+
store={store}
|
|
232
|
+
chatId={c}
|
|
233
|
+
nodeId={n}
|
|
234
|
+
onAction={onAction}
|
|
235
|
+
theme={activeTheme}
|
|
236
|
+
/>
|
|
237
|
+
);
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
: undefined;
|
|
241
|
+
const tree = renderNode(def.root, merged, handleAction, activeTheme, undefined, resolveSlot, def.defs);
|
|
196
242
|
// The isolation container MIRRORS the component root's declared sizing intent (§4a:
|
|
197
243
|
// the component owns its size; the host responds). A root that declares
|
|
198
244
|
// `height: "full"` needs a definite chain — the container passes the host's height
|