@unoverse-platform/studio 0.1.100 → 0.1.102

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.
Files changed (37) hide show
  1. package/local/sample-identity/brand.yaml +2 -0
  2. package/package.json +1 -1
  3. package/screens/TemplatesLibraryView.tsx +58 -98
  4. package/screens/TemplatesView.tsx +18 -2
  5. package/vendor/base/src/auth/auth.ts +7 -5
  6. package/vendor/base/src/auth/authConfig.ts +1 -1
  7. package/vendor/base/src/definitions/load/assets.ts +65 -0
  8. package/vendor/base/src/definitions/load/compile/component.ts +1 -0
  9. package/vendor/base/src/definitions/load/compile/index.ts +1 -1
  10. package/vendor/base/src/definitions/load/compile/sections.ts +26 -116
  11. package/vendor/base/src/definitions/load/compile/template.ts +41 -25
  12. package/vendor/base/src/definitions/load/expand/walk.ts +4 -1
  13. package/vendor/base/src/definitions/load/load.ts +6 -0
  14. package/vendor/base/src/definitions/model.ts +20 -41
  15. package/vendor/base/src/definitions/registry/homes.ts +9 -1
  16. package/vendor/base/src/lint/design/file.mjs +29 -5
  17. package/vendor/base/src/lint/design/vocabulary.mjs +1 -1
  18. package/vendor/base/src/lint/design/walk.mjs +31 -156
  19. package/vendor/base/src/manifests/executor/context.ts +2 -2
  20. package/vendor/base/src/manifests/executor/toolBridge.ts +3 -29
  21. package/vendor/base/src/manifests/runtime/duplex/audioLane.ts +35 -12
  22. package/vendor/base/src/manifests/runtime/duplex/session.ts +123 -48
  23. package/vendor/base/src/manifests/runtime/index.ts +1 -1
  24. package/vendor/base/src/platform/audioManager.ts +89 -14
  25. package/vendor/base/src/platform/clientTransport.ts +8 -0
  26. package/vendor/base/src/security/permissions.ts +2 -2
  27. package/vendor/sdk/appEngine.tsx +104 -46
  28. package/vendor/sdk/lib/UnoverseComponent.tsx +58 -5
  29. package/vendor/sdk/lib/app.tsx +9 -5
  30. package/vendor/sdk/lib/connection.tsx +15 -4
  31. package/vendor/sdk/lib/core/actions.ts +5 -2
  32. package/vendor/sdk/lib/index.ts +2 -2
  33. package/vendor/sdk/lib/primitives.tsx +38 -4
  34. package/vendor/sdk/lib/realtime/useAudioPlayback.ts +6 -1
  35. package/vendor/sdk/lib/voice.tsx +96 -13
  36. package/vendor/sdk/main.tsx +1 -1
  37. package/vendor/sdk/pageInterfaces.tsx +383 -0
@@ -6,6 +6,8 @@ extends: brand
6
6
  props:
7
7
  name:
8
8
  default: ""
9
+ pronunciation:
10
+ default: ""
9
11
  standsFor:
10
12
  default: ""
11
13
  promise:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@unoverse-platform/studio",
3
- "version": "0.1.100",
3
+ "version": "0.1.102",
4
4
  "type": "module",
5
5
  "description": "Unoverse Studio: the local authoring app. Reads your own rx/ folder, no backend.",
6
6
  "scripts": {
@@ -1,51 +1,37 @@
1
1
  /**
2
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.
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 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).
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
- /** static = placed as designed; copywriter = written into through its briefs;
26
- * director = the judge: a search place (with `component`) or a delivery band
27
- * (without) — one word since 2026-09-02 (§9b + §9e). */
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
- /** Linked sections: the authored path (components/masthead). */
32
- component?: string;
33
- /** Linked sections: the resolvable scoped ref (email-digest/masthead). */
34
- ref?: string;
35
- /** The previewed components' mock values, keyed by ref, compiled by the server (only
36
- * it can resolve a template-local part). Seeded into each instance so the arrangement
37
- * previews as written and filled rather than as a page of ghosts. */
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
- * three-word lines; the wire layouts carry the expanded claims. */
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): a linked
198
- // section (static/copywriter) seeds ONE instance of its own component at its
199
- // face — the template's FRAME, which the state pills never re-face. The
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
- // ONE word, two modes (§9e rule 10b, 2026-09-02): a director WITH a component is a
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 copywriter part
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">section</th>
540
- <th className="pb-1 pr-4 font-medium">filled by</th>
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
- const l = sectionLabel(sec);
547
- return (
548
- <tr key={i} className="border-t border-gray-700/60">
549
- <td className="py-1 pr-4 font-mono">{l.name}</td>
550
- <td className="py-1 pr-4">{l.filler}</td>
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 declares no sections.</div>
519
+ <div className="text-xs text-gray-500">This template places no parts.</div>
560
520
  )}
561
521
  </div>
562
522
  )}
@@ -325,7 +325,7 @@ export function TemplatesView({
325
325
  // A real JWT makes the workbench a proper authenticated channel: it rides the
326
326
  // MCP stream's `Authorization` header and the REST `Authorization` header, so the
327
327
  // workflow gets full user context (incl. the email claim → CRM join). See
328
- // docs/AUTH_TOKEN_FLOW.md. Blank → no token (server must run with DISABLE_AUTH=true).
328
+ // docs/auth-security/IDENTITY.md. Blank → no token (server must run with DISABLE_AUTH=true).
329
329
  getAccessToken: token ? async () => token : undefined,
330
330
  }),
331
331
  [liveBase, token],
@@ -393,7 +393,7 @@ export function TemplatesView({
393
393
  const binding = selectedDef?.binding;
394
394
  // The session userId MUST be the token's `sub` when a token rides the connection —
395
395
  // the server binds the audio socket to the verified identity and REJECTS an
396
- // INIT_SESSION claiming anyone else (AUTH_TOKEN_FLOW.md; the June audit's WS
396
+ // INIT_SESSION claiming anyone else (docs/auth-security/IDENTITY.md; the June audit's WS
397
397
  // finding). "dev" is only for a universe with auth off, where no token exists.
398
398
  const tokenSub = useMemo(() => {
399
399
  if (!token) return undefined;
@@ -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).
@@ -2,7 +2,7 @@
2
2
  * JWT authentication for the Unoverse MCP server.
3
3
  *
4
4
  * Mirrors apps/mcp-server/src/server/auth/auth.ts — same OIDC/JWT flow as the rest
5
- * of the platform (docs/AUTH_TOKEN_FLOW.md): validate the access token against the
5
+ * of the platform (docs/auth-security/IDENTITY.md): validate the access token against the
6
6
  * shared AUTH_ISSUER/AUTH_AUDIENCE via JWKS. The only difference is transport: this
7
7
  * server speaks MCP over uWS + Web `Request`/`Response`, so the gate is a pure
8
8
  * function over the Authorization header rather than node req/res.
@@ -13,6 +13,7 @@
13
13
 
14
14
  import { jwtVerify, createRemoteJWKSet, JWTPayload } from "jose";
15
15
  import { isAuthEnabled, devIdentity } from "./authConfig.js";
16
+ import { grantedTo } from "../security/permissions.js";
16
17
 
17
18
  // Read env lazily (not at module load) so it's correct regardless of when .env loads.
18
19
  //
@@ -102,7 +103,7 @@ export function parseRoles(payload: JWTPayload): string[] {
102
103
  /** Parse a string claim, handling Auth0-style namespaced claims (e.g. https://…/email).
103
104
  * Auth0 emits custom claims (email on the access token) ONLY namespaced, so a plain
104
105
  * `payload.email` read returns undefined and silently no-ops every email-keyed feature
105
- * (CRM resolve). Same namespace-aware read as parseArrayClaim. See docs/AUTH_TOKEN_FLOW.md. */
106
+ * (CRM resolve). Same namespace-aware read as parseArrayClaim. See docs/auth-security/IDENTITY.md. */
106
107
  function parseStringClaim(payload: JWTPayload, claimName: string): string | undefined {
107
108
  const direct = payload[claimName];
108
109
  if (typeof direct === "string") return direct;
@@ -212,7 +213,7 @@ export async function authorize(authHeader: string | null | undefined): Promise<
212
213
  * this exact string (Auth0 API permission on the gravity-api audience), NOT on a
213
214
  * role name — end-user chat tokens are valid on the same audience, so "authenticated"
214
215
  * is not enough; only "authenticated AND may author workflows" passes. This is the
215
- * platform's first authZ check (docs/AUTH_REMEDIATION.md Phase 1b).
216
+ * platform's first authZ check (docs/auth-security/legacy/AUTH_REMEDIATION_2026.md Phase 1b).
216
217
  */
217
218
  export const BUILDER_PERMISSION = "workflow:author";
218
219
 
@@ -224,7 +225,8 @@ export type BuilderAuthResult = { ok: true; user: AuthUser } | { ok: false; reas
224
225
  * - FAIL-CLOSED when auth is disabled: this route must never be open, so an
225
226
  * AUTH_ENABLED=false deployment simply cannot serve it (returns forbidden).
226
227
  * Local dev uses the ungated LOOPBACK route (:4106) instead — never this one.
227
- * - Requires the BUILDER_PERMISSION claim, not just a valid bearer.
228
+ * - Requires BUILDER_PERMISSION, not just a valid bearer: granted by a role on the token
229
+ * (ROLE_GRANTS, the one role-to-permission rule) or by a permission claim.
228
230
  * Returns the full user so a later step can bind the session to a workflow grant.
229
231
  */
230
232
  export async function authorizeBuilder(authHeader: string | null | undefined): Promise<BuilderAuthResult> {
@@ -235,7 +237,7 @@ export async function authorizeBuilder(authHeader: string | null | undefined): P
235
237
  if (!token) return { ok: false, reason: "unauthenticated" };
236
238
  const user = await validateJWT(token);
237
239
  if (!user) return { ok: false, reason: "unauthenticated" };
238
- if (!user.permissions.includes(BUILDER_PERMISSION)) return { ok: false, reason: "forbidden" };
240
+ if (!grantedTo(user).has(BUILDER_PERMISSION)) return { ok: false, reason: "forbidden" };
239
241
  return { ok: true, user };
240
242
  }
241
243
 
@@ -3,7 +3,7 @@
3
3
  *
4
4
  * MIRRORED across services (apps/server, apps/mcp-server, apps/memory-server, and
5
5
  * now apps/unoverse) — keep the copies in sync. Single source of truth:
6
- * docs/AUTH_REMEDIATION.md (Phase 0).
6
+ * docs/auth-security/legacy/AUTH_REMEDIATION_2026.md (Phase 0).
7
7
  *
8
8
  * Rules:
9
9
  * - Default: auth ENABLED. Only an explicit AUTH_ENABLED=false (or the deprecated
@@ -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 three words → wire claims + sections
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 SECTION-WORD compiler: a template layout's three words → wire claims +
3
- * section projections. Split from compile.ts into this folder 2026-09-02; the
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
- /** Expand a template layout's THREE-WORD sections (§9b, 2026-08-29; one director word
10
- * since 2026-09-02) into the wire primitives the renderer already speaks, collecting
11
- * the section projections as it goes. `static:`/`copywriter:` link a component
12
- * (face = its basename) and become a limit-1 claim on that face; `director:` with a
13
- * `component` is a SEARCH place (same limit-1 claim; the grammar rides the section for
14
- * the fill path), and without one it is the open DELIVERY claim — its face is the
15
- * `state:` pin when authored (arrivals are switched in and held), else the owning
16
- * state's name (the free name-match dance). The SDK stays dumb: authors never see
17
- * ComponentSlot/select/where, and the wire never sees the three words. */
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, owningState, templateName, out));
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 linked = typeof n.static === "string" ? "static" : typeof n.copywriter === "string" ? "copywriter" : null;
28
- if (linked) {
29
- const path = String(n[linked]);
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: linked, face, component: path, ref: `${templateName}/${face}` });
32
- const { static: _s, copywriter: _c, ...rest } = n;
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, owningState, templateName, out);
46
+ for (const [k2, v] of Object.entries(n)) o[k2] = expandTemplateSections(v, templateName, out);
137
47
  return o;
138
48
  }