@unoverse-platform/studio 0.1.101 → 0.1.103
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/sample-identity/brand.yaml +2 -0
- package/package.json +1 -1
- package/screens/TemplatesLibraryView.tsx +58 -98
- package/screens/TemplatesView.tsx +16 -0
- package/vendor/base/src/definitions/load/assets.ts +65 -0
- package/vendor/base/src/definitions/load/compile/component.ts +1 -0
- package/vendor/base/src/definitions/load/compile/index.ts +1 -1
- package/vendor/base/src/definitions/load/compile/sections.ts +26 -116
- package/vendor/base/src/definitions/load/compile/template.ts +41 -25
- package/vendor/base/src/definitions/load/expand/walk.ts +4 -1
- package/vendor/base/src/definitions/load/load.ts +6 -0
- package/vendor/base/src/definitions/model.ts +20 -41
- package/vendor/base/src/definitions/registry/homes.ts +9 -1
- package/vendor/base/src/lint/design/file.mjs +29 -5
- package/vendor/base/src/lint/design/vocabulary.mjs +1 -1
- package/vendor/base/src/lint/design/walk.mjs +31 -156
- package/vendor/base/src/manifests/executor/context.ts +1 -1
- package/vendor/base/src/manifests/executor/toolBridge.ts +3 -29
- package/vendor/base/src/manifests/runtime/duplex/audioLane.ts +35 -12
- package/vendor/base/src/manifests/runtime/duplex/session.ts +123 -48
- package/vendor/base/src/manifests/runtime/index.ts +1 -1
- package/vendor/base/src/platform/audioManager.ts +89 -14
- package/vendor/base/src/platform/clientTransport.ts +8 -0
- package/vendor/base/src/security/permissions.ts +2 -2
- package/vendor/sdk/appEngine.tsx +106 -46
- package/vendor/sdk/lib/UnoverseComponent.tsx +58 -5
- package/vendor/sdk/lib/app.tsx +9 -5
- package/vendor/sdk/lib/connection.tsx +14 -3
- package/vendor/sdk/lib/core/actions.ts +5 -2
- package/vendor/sdk/lib/index.ts +2 -2
- package/vendor/sdk/lib/primitives.tsx +38 -4
- package/vendor/sdk/lib/realtime/useAudioPlayback.ts +6 -1
- package/vendor/sdk/lib/voice.tsx +96 -13
- package/vendor/sdk/pageInterfaces.tsx +388 -0
package/package.json
CHANGED
|
@@ -1,51 +1,37 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Templates view — the Template Model's kind (docs/unoverse/UNOVERSE_TEMPLATE_MODEL.md
|
|
3
|
-
* §9b
|
|
4
|
-
*
|
|
5
|
-
* `
|
|
6
|
-
* what streams in). Authors write `type: template`; the app kind is `app` in code.
|
|
3
|
+
* §9b), made browsable. A template is states owning arrangements; a layout places its
|
|
4
|
+
* PARTS with `type: Ref` + `ref: components/<name>`, and an Agent writes each part's
|
|
5
|
+
* `input: true` props. Authors write `type: template`; the app kind is `app` in code.
|
|
7
6
|
* Two tiers like components: the marketplace shelf (no chip = BASE, installed,
|
|
8
7
|
* read-only) and org packs (org chip).
|
|
9
8
|
*
|
|
10
9
|
* Header + dock MIRROR ComponentsView/AtomsView exactly (responsive width toggle,
|
|
11
|
-
* collapsible drag-resize dock) — the dock holds the
|
|
12
|
-
* contract: each
|
|
13
|
-
* template's fills come from
|
|
14
|
-
* synthesized root:
|
|
15
|
-
* seeds its preview mocks (real interfaces, their own defaults).
|
|
10
|
+
* collapsible drag-resize dock) — the dock holds the PARTS table (the template's
|
|
11
|
+
* contract: each part and the face it claims) instead of prop controls, because a
|
|
12
|
+
* template's fills come from an Agent, not typed values. The preview renders the
|
|
13
|
+
* synthesized root: each part seeds its own component with its own mock values.
|
|
16
14
|
*
|
|
17
15
|
* NOT TemplatesView.tsx: that name is taken by the Apps screen (the legacy noun).
|
|
18
16
|
*/
|
|
19
17
|
import { useEffect, useMemo, useState, useSyncExternalStore, useCallback, type MouseEvent as ReactMouseEvent } from "react";
|
|
20
18
|
import { ComponentStore, UnoverseComponent, type ResolvedTheme } from "@unoverse/sdk";
|
|
21
19
|
import { client, authedFetch, usePersistentState, useRegistryToggles, ToggleSwitch } from "studio-host";
|
|
22
|
-
import { NAV, SCREEN, navItem } from "./chrome";
|
|
20
|
+
import { NAV, SCREEN, navItem, navSubItem } from "./chrome";
|
|
23
21
|
|
|
24
22
|
interface TemplateSection {
|
|
25
|
-
/**
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
kind: "static" | "copywriter" | "director";
|
|
29
|
-
/** The state instances render in at this section — what the claim matches. */
|
|
23
|
+
/** A placed part: its props' `input` says what an Agent writes (§9b). */
|
|
24
|
+
kind: "part";
|
|
25
|
+
/** The state the part's instance renders in: what the claim matches. */
|
|
30
26
|
face: string;
|
|
31
|
-
/**
|
|
32
|
-
component
|
|
33
|
-
/**
|
|
34
|
-
ref
|
|
35
|
-
/** The
|
|
36
|
-
*
|
|
37
|
-
*
|
|
27
|
+
/** The authored path (components/masthead). */
|
|
28
|
+
component: string;
|
|
29
|
+
/** The resolvable scoped ref (email-digest/masthead). */
|
|
30
|
+
ref: string;
|
|
31
|
+
/** The part's mock values, keyed by ref, compiled by the server (only it can resolve a
|
|
32
|
+
* template-local part). Seeded into each instance so the arrangement previews as
|
|
33
|
+
* written and filled rather than as a page of ghosts. */
|
|
38
34
|
seeds?: Record<string, Record<string, unknown>>;
|
|
39
|
-
state?: string;
|
|
40
|
-
rules?: string;
|
|
41
|
-
preview?: string[];
|
|
42
|
-
/** Director places (§9e): the grammar, echoed for the dock table. `ask` appears
|
|
43
|
-
* only after derivation (rule 10d) — never authored. */
|
|
44
|
-
ask?: string;
|
|
45
|
-
type?: "discovery" | "intent";
|
|
46
|
-
include?: string[];
|
|
47
|
-
pick?: number;
|
|
48
|
-
items?: { min: number; max: number };
|
|
49
35
|
}
|
|
50
36
|
interface TemplateDef {
|
|
51
37
|
name: string;
|
|
@@ -58,10 +44,12 @@ interface TemplateDef {
|
|
|
58
44
|
whenToUse?: string;
|
|
59
45
|
category?: string;
|
|
60
46
|
/** Compiled per-state sections (§9b) — the loader's projection of the layout's
|
|
61
|
-
*
|
|
47
|
+
* placed parts; the wire layouts carry the expanded claims. */
|
|
62
48
|
sections?: Record<string, TemplateSection[]>;
|
|
63
49
|
/** The composed arrangements, keyed by state — the pills. */
|
|
64
50
|
layouts?: Record<string, unknown>;
|
|
51
|
+
/** The declared tree, served like a component's: each state with its steps (`on` + `initial`). */
|
|
52
|
+
stateTree?: { name: string; on?: string; initial?: string; states?: { name: string }[] }[];
|
|
65
53
|
defaultLayout?: string;
|
|
66
54
|
/** Seeding map the loader assembles: face → interface refs. */
|
|
67
55
|
preview?: Record<string, string[]>;
|
|
@@ -85,38 +73,6 @@ const VIEWPORTS: { id: string; label: string; w: number | null }[] = [
|
|
|
85
73
|
|
|
86
74
|
const refOf = (t: TemplateDef) => (t.org ? `${t.org}/${t.name}` : t.name);
|
|
87
75
|
|
|
88
|
-
/** One section's description for the dock table — the three words (§9b). */
|
|
89
|
-
function sectionLabel(sec: TemplateSection): { name: string; filler: string; rules: string } {
|
|
90
|
-
// ONE WORD, TWO MODES (§9e, 2026-09-02): with `component` = a search place; without =
|
|
91
|
-
// a delivery band. Both show as the director's — the judge is the same.
|
|
92
|
-
if (sec.kind === "director" && sec.component)
|
|
93
|
-
return {
|
|
94
|
-
name: sec.component,
|
|
95
|
-
filler: "director",
|
|
96
|
-
rules: [
|
|
97
|
-
sec.items ? `items ${sec.items.min}–${sec.items.max}` : null,
|
|
98
|
-
sec.type ?? null,
|
|
99
|
-
sec.include?.length ? sec.include.join("+") : null,
|
|
100
|
-
sec.pick != null ? `pick ${sec.pick}` : null,
|
|
101
|
-
].filter(Boolean).join(" · "),
|
|
102
|
-
};
|
|
103
|
-
if (sec.kind === "director")
|
|
104
|
-
return {
|
|
105
|
-
name: "director",
|
|
106
|
-
filler: "director",
|
|
107
|
-
rules: [
|
|
108
|
-
sec.state ? `state ${sec.state}` : null,
|
|
109
|
-
sec.pick != null ? `pick ${sec.pick}` : null,
|
|
110
|
-
sec.rules ?? null,
|
|
111
|
-
].filter(Boolean).join(" · "),
|
|
112
|
-
};
|
|
113
|
-
return {
|
|
114
|
-
name: sec.component ?? sec.face,
|
|
115
|
-
filler: sec.kind === "copywriter" ? "copywriter" : "static",
|
|
116
|
-
rules: "",
|
|
117
|
-
};
|
|
118
|
-
}
|
|
119
|
-
|
|
120
76
|
export function TemplatesLibraryView({ theme, org }: { theme: ResolvedTheme; org?: string }) {
|
|
121
77
|
const [templates, setTemplates] = useState<TemplateDef[]>([]);
|
|
122
78
|
const [selected, setSelected] = usePersistentState<string>("wb:template", "");
|
|
@@ -194,27 +150,14 @@ export function TemplatesLibraryView({ theme, org }: { theme: ResolvedTheme; org
|
|
|
194
150
|
const store = useMemo(() => new ComponentStore(), [selected]);
|
|
195
151
|
|
|
196
152
|
const seeds = useMemo(() => {
|
|
197
|
-
// THE SEED PLAN, from the BASE state's compiled sections (§9b):
|
|
198
|
-
//
|
|
199
|
-
//
|
|
200
|
-
// director section seeds its preview refs at its face — the items the guest
|
|
201
|
-
// actually browses.
|
|
153
|
+
// THE SEED PLAN, from the BASE state's compiled sections (§9b): each placed part
|
|
154
|
+
// seeds ONE instance of its own component at its face, the template's FRAME,
|
|
155
|
+
// which the state pills never re-face.
|
|
202
156
|
const base = current?.defaultLayout ?? (current?.layouts ? Object.keys(current.layouts)[0] : undefined);
|
|
203
157
|
const secs = (base && current?.sections?.[base]) || [];
|
|
204
158
|
const out: { nodeId: string; ref: string; face?: string; framed: boolean; seed?: Record<string, unknown> }[] = [];
|
|
205
|
-
for (const sec of secs)
|
|
206
|
-
|
|
207
|
-
// SEARCH place — its mock is its own component's preview, seeded exactly like a
|
|
208
|
-
// linked part. Only the BAND form (no ref) seeds stand-ins. This branch predates
|
|
209
|
-
// the merge and read every director as a band, so search places seeded nothing and
|
|
210
|
-
// the template previewed with those sections simply missing.
|
|
211
|
-
if (sec.ref) {
|
|
212
|
-
out.push({ nodeId: `${sec.face}~${sec.ref}`, ref: sec.ref, face: sec.face, framed: true, seed: sec.seeds?.[sec.ref] });
|
|
213
|
-
} else if (sec.kind === "director") {
|
|
214
|
-
for (const [i, ref] of (sec.preview ?? []).entries())
|
|
215
|
-
out.push({ nodeId: `${sec.face}~${ref}~${i}`, ref, face: sec.face, framed: false, seed: sec.seeds?.[ref] });
|
|
216
|
-
}
|
|
217
|
-
}
|
|
159
|
+
for (const sec of secs)
|
|
160
|
+
out.push({ nodeId: `${sec.face}~${sec.ref}`, ref: sec.ref, face: sec.face, framed: true, seed: sec.seeds?.[sec.ref] });
|
|
218
161
|
return out;
|
|
219
162
|
}, [current]);
|
|
220
163
|
const framedIds = useMemo(() => new Set(seeds.filter((s) => s.framed).map((s) => s.nodeId)), [seeds]);
|
|
@@ -226,7 +169,7 @@ export function TemplatesLibraryView({ theme, org }: { theme: ResolvedTheme; org
|
|
|
226
169
|
for (const { nodeId, ref, face, seed } of seeds) {
|
|
227
170
|
// The part's own mock values, exactly as the Components screen seeds a component
|
|
228
171
|
// (`preview ?? default`) — compiled onto the section by the server, because only it
|
|
229
|
-
// can resolve a template-local part. Seeded with nothing, every
|
|
172
|
+
// can resolve a template-local part. Seeded with nothing, every part
|
|
230
173
|
// draws as skeletons and a finished arrangement reads as a broken one.
|
|
231
174
|
store.apply({ type: "COMPONENT_INIT", chatId: PREVIEW_CHAT, nodeId, component: { type: ref, props: seed ?? {} } });
|
|
232
175
|
if (face) store.mergeComponentState(PREVIEW_CHAT, nodeId, { state: face });
|
|
@@ -335,6 +278,28 @@ export function TemplatesLibraryView({ theme, org }: { theme: ResolvedTheme; org
|
|
|
335
278
|
</span>
|
|
336
279
|
)}
|
|
337
280
|
</button>
|
|
281
|
+
{/* THE STEPS, as Components lists them: the open state's steps by name, straight
|
|
282
|
+
from the served tree. A click writes the step onto the template's own
|
|
283
|
+
instance, the same write its Back and Next make. */}
|
|
284
|
+
{selected === refOf(t) && (() => {
|
|
285
|
+
const st = current?.stateTree?.find((e) => e.name === activeState);
|
|
286
|
+
if (!st?.on || (st.states?.length ?? 0) < 2) return null;
|
|
287
|
+
const own = store.get(PREVIEW_CHAT, TEMPLATE_NODE) as Record<string, unknown> | undefined;
|
|
288
|
+
const cur = own?.[st.on] ?? st.initial;
|
|
289
|
+
return (
|
|
290
|
+
<div className="my-0.5 ml-2 flex flex-col gap-px border-l border-gray-700 pl-2">
|
|
291
|
+
{st.states!.map((sub) => (
|
|
292
|
+
<button
|
|
293
|
+
key={sub.name}
|
|
294
|
+
onClick={() => store.mergeComponentState(PREVIEW_CHAT, TEMPLATE_NODE, { [st.on!]: sub.name })}
|
|
295
|
+
className={`block w-full truncate rounded px-2 py-0.5 text-left text-xs capitalize transition ${navSubItem(cur === sub.name)}`}
|
|
296
|
+
>
|
|
297
|
+
{sub.name.replace(/-/g, " ")}
|
|
298
|
+
</button>
|
|
299
|
+
))}
|
|
300
|
+
</div>
|
|
301
|
+
);
|
|
302
|
+
})()}
|
|
338
303
|
</div>
|
|
339
304
|
))}
|
|
340
305
|
</div>
|
|
@@ -536,27 +501,22 @@ export function TemplatesLibraryView({ theme, org }: { theme: ResolvedTheme; org
|
|
|
536
501
|
<table className="w-full text-left text-xs">
|
|
537
502
|
<thead className="text-gray-500">
|
|
538
503
|
<tr>
|
|
539
|
-
<th className="pb-1 pr-4 font-medium">
|
|
540
|
-
<th className="pb-1
|
|
541
|
-
<th className="pb-1 font-medium">rules</th>
|
|
504
|
+
<th className="pb-1 pr-4 font-medium">part</th>
|
|
505
|
+
<th className="pb-1 font-medium">face</th>
|
|
542
506
|
</tr>
|
|
543
507
|
</thead>
|
|
544
508
|
<tbody className="text-gray-300">
|
|
545
|
-
{secs.map((sec, i) =>
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
<
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
<td className="py-1 text-gray-500">{l.rules}</td>
|
|
552
|
-
</tr>
|
|
553
|
-
);
|
|
554
|
-
})}
|
|
509
|
+
{secs.map((sec, i) => (
|
|
510
|
+
<tr key={i} className="border-t border-gray-700/60">
|
|
511
|
+
<td className="py-1 pr-4 font-mono">{sec.component}</td>
|
|
512
|
+
<td className="py-1 text-gray-500">{sec.face}</td>
|
|
513
|
+
</tr>
|
|
514
|
+
))}
|
|
555
515
|
</tbody>
|
|
556
516
|
</table>
|
|
557
517
|
) : null;
|
|
558
518
|
})() || (
|
|
559
|
-
<div className="text-xs text-gray-500">This template
|
|
519
|
+
<div className="text-xs text-gray-500">This template places no parts.</div>
|
|
560
520
|
)}
|
|
561
521
|
</div>
|
|
562
522
|
)}
|
|
@@ -656,6 +656,22 @@ export function TemplatesView({
|
|
|
656
656
|
},
|
|
657
657
|
});
|
|
658
658
|
});
|
|
659
|
+
// THE MOCK PLACES WHAT IT SEEDS (UNOVERSE_SCREEN.md §2.3). A place draws only what the
|
|
660
|
+
// screen put there, and in mock no server sends SCREEN_PLACE, so an app of places
|
|
661
|
+
// previewed white around a seeded interface. Each seed goes where the state it arrived
|
|
662
|
+
// in names, as the server would place it; a state naming no place stays in `chat`.
|
|
663
|
+
const byPlace = new Map<string, { pointers: string[]; states: Record<string, string> }>();
|
|
664
|
+
comps.forEach(({ def: c, view: declaredView, envelope }, i) => {
|
|
665
|
+
const view = declaredView ?? (envelope ? initialOf(c) : undefined);
|
|
666
|
+
const place = view ? (c as { statePlaces?: Record<string, string> }).statePlaces?.[view] : undefined;
|
|
667
|
+
if (!place || place === "chat") return;
|
|
668
|
+
const pointer = `${chatIdFor(selected ?? "app")}:${c.name}-${i}`;
|
|
669
|
+
const slot = byPlace.get(place) ?? { pointers: [], states: {} };
|
|
670
|
+
slot.pointers.push(pointer);
|
|
671
|
+
slot.states[pointer] = view!;
|
|
672
|
+
byPlace.set(place, slot);
|
|
673
|
+
});
|
|
674
|
+
for (const [place, { pointers, states }] of byPlace) store.setPlace(place, pointers, states);
|
|
659
675
|
setSeeded(comps.map((c) => c.def.name));
|
|
660
676
|
// The tab strips: one per seeded interface, filed under the container whose claim
|
|
661
677
|
// matches the state it was seeded into (no claim match → filed under its own name).
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AN ORG'S OWN IMAGES. A definition names one by its path in the org's `assets/` folder
|
|
3
|
+
* (`src: assets/accelerate.svg`); the loader reads the file and serves it inside the
|
|
4
|
+
* definition as a data URI, so it draws wherever the definition does (Studio, an app, an
|
|
5
|
+
* embed on another site) with nothing to upload and no host to configure. Small files only:
|
|
6
|
+
* a photo belongs on a CDN, and the cap keeps a definition light.
|
|
7
|
+
*/
|
|
8
|
+
import { readFileSync, existsSync, statSync } from "fs";
|
|
9
|
+
import { join, extname, dirname } from "path";
|
|
10
|
+
import { projectDir } from "../registry/homes.js";
|
|
11
|
+
|
|
12
|
+
const ASSET_PREFIX = "assets/";
|
|
13
|
+
const ASSET_MAX_BYTES = 256 * 1024;
|
|
14
|
+
const ASSET_TYPES: Record<string, string> = {
|
|
15
|
+
".svg": "image/svg+xml",
|
|
16
|
+
".png": "image/png",
|
|
17
|
+
".jpg": "image/jpeg",
|
|
18
|
+
".jpeg": "image/jpeg",
|
|
19
|
+
".webp": "image/webp",
|
|
20
|
+
".gif": "image/gif",
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
/** The file an `assets/…` path names: the definition's own folder upward to its org's folder
|
|
24
|
+
* (the one holding `styles/`), so a template's part finds its org's images; then the shared one. */
|
|
25
|
+
function assetFile(from: string | undefined, src: string): string | null {
|
|
26
|
+
const rel = src.slice(ASSET_PREFIX.length);
|
|
27
|
+
for (let dir = from; dir && dirname(dir) !== dir; dir = dirname(dir)) {
|
|
28
|
+
const f = join(dir, "assets", rel);
|
|
29
|
+
if (existsSync(f)) return f;
|
|
30
|
+
if (existsSync(join(dir, "styles"))) break;
|
|
31
|
+
}
|
|
32
|
+
const shared = join(projectDir("marketplace"), "assets", rel);
|
|
33
|
+
return existsSync(shared) ? shared : null;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** The data URI for an `assets/…` image path, or null when it is not one this folder holds. */
|
|
37
|
+
function dataUri(from: string | undefined, src: string): string | null {
|
|
38
|
+
const file = assetFile(from, src);
|
|
39
|
+
const type = ASSET_TYPES[extname(src).toLowerCase()];
|
|
40
|
+
return file && type && statSync(file).size <= ASSET_MAX_BYTES
|
|
41
|
+
? `data:${type};base64,${readFileSync(file).toString("base64")}`
|
|
42
|
+
: null;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Every `Image` whose src is an `assets/…` path, served as the file's data URI. So is an
|
|
46
|
+
* `assets/…` image a `Ref` hands its piece through `with`: the piece binds it to its Image
|
|
47
|
+
* only once expanded, which the referenced form leaves to the client. */
|
|
48
|
+
export function inlineAssets(node: unknown, from: string | undefined): void {
|
|
49
|
+
if (Array.isArray(node)) return node.forEach((n) => inlineAssets(n, from));
|
|
50
|
+
if (!node || typeof node !== "object") return;
|
|
51
|
+
const n = node as Record<string, unknown>;
|
|
52
|
+
if (n.type === "Image" && typeof n.src === "string" && n.src.startsWith(ASSET_PREFIX)) {
|
|
53
|
+
const uri = dataUri(from, n.src);
|
|
54
|
+
if (uri) n.src = uri;
|
|
55
|
+
}
|
|
56
|
+
if (n.type === "Ref" && n.with && typeof n.with === "object" && !Array.isArray(n.with)) {
|
|
57
|
+
const w = n.with as Record<string, unknown>;
|
|
58
|
+
for (const [k, v] of Object.entries(w))
|
|
59
|
+
if (typeof v === "string" && v.startsWith(ASSET_PREFIX)) {
|
|
60
|
+
const uri = dataUri(from, v);
|
|
61
|
+
if (uri) w[k] = uri;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
for (const v of Object.values(n)) inlineAssets(v, from);
|
|
65
|
+
}
|
|
@@ -39,6 +39,7 @@ export function compileComponentTree(def: UnoverseDefinition): void {
|
|
|
39
39
|
if (typeof s.on === "string") entry.on = s.on;
|
|
40
40
|
else if (s.states && typeof s.states === "object" && Object.keys(s.states).length) entry.on = STEP;
|
|
41
41
|
if (typeof s.initial === "string") entry.initial = s.initial;
|
|
42
|
+
if ((s as { fullscreen?: unknown }).fullscreen === true) entry.fullscreen = true;
|
|
42
43
|
if (s.states && typeof s.states === "object")
|
|
43
44
|
entry.states = Object.entries(s.states).map(([k, v]) => ({
|
|
44
45
|
name: k,
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* the expand/ split of 2026-08-30):
|
|
4
4
|
*
|
|
5
5
|
* component.ts a component's `states:` tree → served projections
|
|
6
|
-
* sections.ts a template layout's
|
|
6
|
+
* sections.ts a template layout's placed parts → wire claims + sections
|
|
7
7
|
* template.ts a template's states (folder) or root (block) → layouts/previews
|
|
8
8
|
*/
|
|
9
9
|
|
|
@@ -1,138 +1,48 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The
|
|
3
|
-
*
|
|
4
|
-
* rules are unchanged.
|
|
2
|
+
* The PART compiler: a template layout's placed parts → wire claims + section
|
|
3
|
+
* projections.
|
|
5
4
|
*/
|
|
6
5
|
|
|
7
6
|
import type { UnoverseDefinition } from "../../model.js";
|
|
8
7
|
|
|
9
|
-
/**
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
*
|
|
14
|
-
* the
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
8
|
+
/** The one way a layout places a part: a `Ref` into the template's own `components/`. */
|
|
9
|
+
const PART_PREFIX = "components/";
|
|
10
|
+
|
|
11
|
+
/** Expand a template layout's placed parts (§9b) into the wire primitives the renderer
|
|
12
|
+
* already speaks, collecting the section projections as it goes. A part is placed with
|
|
13
|
+
* the ordinary element, `type: Ref` + `ref: components/<name>`; it becomes a limit-1
|
|
14
|
+
* claim on its own face (the file's basename). The SDK stays dumb: authors never see
|
|
15
|
+
* ComponentSlot/select/where. What an Agent may write into a part is each prop's
|
|
16
|
+
* `input`, never the element that places it.
|
|
17
|
+
*
|
|
18
|
+
* RETIRED VOCABULARY IS A LOUD FAILURE, never a legacy read (the no-shims law): a stale
|
|
19
|
+
* layout fails at load with the fix in the message. */
|
|
18
20
|
export function expandTemplateSections(
|
|
19
21
|
node: unknown,
|
|
20
|
-
owningState: string,
|
|
21
22
|
templateName: string,
|
|
22
23
|
out: NonNullable<UnoverseDefinition["sections"]>[string],
|
|
23
24
|
): unknown {
|
|
24
|
-
if (Array.isArray(node)) return node.map((c) => expandTemplateSections(c,
|
|
25
|
+
if (Array.isArray(node)) return node.map((c) => expandTemplateSections(c, templateName, out));
|
|
25
26
|
if (!node || typeof node !== "object") return node;
|
|
26
27
|
const n = node as Record<string, unknown>;
|
|
27
|
-
const
|
|
28
|
-
|
|
29
|
-
|
|
28
|
+
for (const word of ["static", "director", "copywriter", "query"] as const) {
|
|
29
|
+
if (n[word] !== undefined)
|
|
30
|
+
throw new Error(
|
|
31
|
+
`template "${templateName}": the ${word}: word is retired. Place the part with type: Ref and ref: components/<name>, and mark each of its props input: true (an Agent fills it) or input: false (drawn from its default) (UNOVERSE_AUTHORING "input decides")`,
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
if (n.type === "Ref" && typeof n.ref === "string" && n.ref.startsWith(PART_PREFIX)) {
|
|
35
|
+
const path = n.ref;
|
|
30
36
|
const face = path.split("/").pop() ?? path;
|
|
31
|
-
out.push({ kind:
|
|
32
|
-
const {
|
|
37
|
+
out.push({ kind: "part", face, component: path, ref: `${templateName}/${face}` });
|
|
38
|
+
const { type: _t, ref: _r, ...rest } = n;
|
|
33
39
|
return {
|
|
34
40
|
type: "ComponentSlot",
|
|
35
41
|
select: { from: "all", where: { field: "state", eq: face }, limit: 1 },
|
|
36
42
|
...rest,
|
|
37
43
|
};
|
|
38
44
|
}
|
|
39
|
-
// RETIRED VOCABULARY IS A LOUD FAILURE, never a legacy-read (the no-shims law).
|
|
40
|
-
// `query:` merged into `director:` 2026-09-02 (§9e rule 10b REVERSED); `limit` and
|
|
41
|
-
// `ask` retired the same day (`pick` is the one cap word; the question is derived,
|
|
42
|
-
// rule 10d). A stale file fails at load with the fix in the message.
|
|
43
|
-
if (n.query !== undefined) {
|
|
44
|
-
throw new Error(`template "${templateName}": the query: word is retired (§9e rule 10b reversed 2026-09-02) — spell the place director: with the same component/items/type/include/pick`);
|
|
45
|
-
}
|
|
46
|
-
if (n.director !== undefined && (n.director === null || (typeof n.director === "object" && !Array.isArray(n.director)))) {
|
|
47
|
-
const d = (n.director ?? {}) as Record<string, unknown>;
|
|
48
|
-
if (d.limit !== undefined) throw new Error(`template "${templateName}": director limit is retired — the cap word is pick everywhere (§9e rule 9, renamed 2026-09-02)`);
|
|
49
|
-
if (d.ask !== undefined || d.query !== undefined) throw new Error(`template "${templateName}": a director place never states its question — it is derived (§9e rule 10d): the writing, the page's pool, the component's contract, or the guest's ask`);
|
|
50
|
-
/**
|
|
51
|
-
* A SEARCH PLACE (§9e, one word since 2026-09-02) — the director given a component.
|
|
52
|
-
*
|
|
53
|
-
* It LINKS the part exactly as `static:`/`copywriter:` do, so the slot is identical: a
|
|
54
|
-
* limit-1 claim on the part's own face. The content is a search the page runs itself,
|
|
55
|
-
* before any model has spoken; the QUESTION is never here (rule 10d — the writer's
|
|
56
|
-
* headings for an `items` place, the page's pool for a gallery, the component's own
|
|
57
|
-
* contract, or the guest's ask). The layout says only what KIND may answer (`include`)
|
|
58
|
-
* and how many survive (`pick`); none of it reaches the slot — it rides the section
|
|
59
|
-
* for the fill path.
|
|
60
|
-
*
|
|
61
|
-
* NOTHING DECLARES WHICH PROP TAKES THE ROWS. The schema already says: the prop whose
|
|
62
|
-
* items require only `ref`. So a searched part keeps BOTH lanes — its refs filled by
|
|
63
|
-
* search at shell open with no model, its headings and taglines written by the
|
|
64
|
-
* copywriter afterwards (§9e rule 10). Same part, both lanes, in that order.
|
|
65
|
-
*/
|
|
66
|
-
if (typeof d.component === "string" && d.component) {
|
|
67
|
-
const path = String(d.component);
|
|
68
|
-
const face = path.split("/").pop() ?? path;
|
|
69
|
-
// AN ITEMS PLACE (§9e rule 10c): the place holds a LIST the writer shapes — each
|
|
70
|
-
// item's heading arrives WITH its own search, so they can never disagree.
|
|
71
|
-
//
|
|
72
|
-
// THE SEARCH WORDS SIT ON THE THING THAT SEARCHES (owner ruling 2026-09-02): each
|
|
73
|
-
// ITEM searches, so type/include/pick nest INSIDE items: beside min/max, and pick
|
|
74
|
-
// reads unambiguously as per-item. The flat spelling on an items place is a LOUD
|
|
75
|
-
// failure — before this, flat `pick` meant two different scopes depending on
|
|
76
|
-
// whether items: happened to be present.
|
|
77
|
-
const items = d.items && typeof d.items === "object" && !Array.isArray(d.items) ? (d.items as Record<string, unknown>) : null;
|
|
78
|
-
if (items && (d.type !== undefined || d.include !== undefined || d.pick !== undefined))
|
|
79
|
-
throw new Error(
|
|
80
|
-
`template "${templateName}": an items place nests its search words INSIDE items: (each ITEM searches) — items: { min, max, type, include, pick }`,
|
|
81
|
-
);
|
|
82
|
-
const src = items ?? d;
|
|
83
|
-
// THE INCLUDE WORDS ARE CLOSED: products, images, needs. An unknown word used to
|
|
84
|
-
// pass through and match NOTHING at fill time (or everything, with no filter), which
|
|
85
|
-
// is a page drawn wrong with no error anywhere. `services` is retired (2026-09-07).
|
|
86
|
-
if (Array.isArray(src.include))
|
|
87
|
-
for (const w of src.include)
|
|
88
|
-
if (!["products", "images", "needs"].includes(String(w)))
|
|
89
|
-
throw new Error(`template "${templateName}": include: [${String(w)}] is not a kind a place can hold — the words are products, images, needs${String(w) === "services" ? " (services was renamed products, 2026-09-07)" : ""}`);
|
|
90
|
-
out.push({
|
|
91
|
-
kind: "director",
|
|
92
|
-
face,
|
|
93
|
-
component: path,
|
|
94
|
-
ref: `${templateName}/${face}`,
|
|
95
|
-
...(src.type === "discovery" || src.type === "intent" ? { type: src.type } : {}),
|
|
96
|
-
...(Array.isArray(src.include) ? { include: src.include.map(String) } : {}),
|
|
97
|
-
...(typeof src.pick === "number" ? { pick: src.pick } : {}),
|
|
98
|
-
...(items ? { items: { min: Number(items.min) || 1, max: Number(items.max) || 1 } } : {}),
|
|
99
|
-
});
|
|
100
|
-
const { director: _d, ...rest } = n;
|
|
101
|
-
return {
|
|
102
|
-
type: "ComponentSlot",
|
|
103
|
-
select: { from: "all", where: { field: "state", eq: face }, limit: 1 },
|
|
104
|
-
...rest,
|
|
105
|
-
};
|
|
106
|
-
}
|
|
107
|
-
/**
|
|
108
|
-
* THE COMPILER ADDRESSES THE PLACE (§9d, ruled 2026-08-31).
|
|
109
|
-
*
|
|
110
|
-
* A `director:` place is somewhere things may go — it carries no name, no prose and no
|
|
111
|
-
* state, because the ONE mind reading the page's brief already knows what the page is.
|
|
112
|
-
* But a slot still has to select SOMETHING, and two places on one layout may not claim
|
|
113
|
-
* the same face (the reaction contract's one-view-one-surface rule). So the address is
|
|
114
|
-
* handed out here, by position, and an author never types a face name.
|
|
115
|
-
*
|
|
116
|
-
* An AUTHORED `state:` still wins, for the flat-email case §9b rule 4 names — arrivals
|
|
117
|
-
* pinned into one declared state — and for any template written before this ruling.
|
|
118
|
-
*/
|
|
119
|
-
const nth = out.filter((sec) => sec.kind === "director" && !sec.component).length;
|
|
120
|
-
const face = typeof d.state === "string" ? d.state : nth === 0 ? owningState : `${owningState}-${nth + 1}`;
|
|
121
|
-
out.push({
|
|
122
|
-
kind: "director",
|
|
123
|
-
face,
|
|
124
|
-
...(typeof d.state === "string" ? { state: d.state } : {}),
|
|
125
|
-
...(typeof d.pick === "number" ? { pick: d.pick } : {}),
|
|
126
|
-
...(Array.isArray(d.preview) ? { preview: d.preview.map(String) } : {}),
|
|
127
|
-
});
|
|
128
|
-
const { director: _d, ...rest } = n;
|
|
129
|
-
return {
|
|
130
|
-
type: "ComponentSlot",
|
|
131
|
-
select: { from: "all", where: { field: "state", eq: face }, ...(typeof d.pick === "number" ? { limit: d.pick } : {}) },
|
|
132
|
-
...rest,
|
|
133
|
-
};
|
|
134
|
-
}
|
|
135
45
|
const o: Record<string, unknown> = {};
|
|
136
|
-
for (const [k2, v] of Object.entries(n)) o[k2] = expandTemplateSections(v,
|
|
46
|
+
for (const [k2, v] of Object.entries(n)) o[k2] = expandTemplateSections(v, templateName, out);
|
|
137
47
|
return o;
|
|
138
48
|
}
|