@unoverse-platform/studio 0.1.97 → 0.1.99
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/bin/studio.mjs +2 -2
- package/local/catalog.ts +5 -1
- package/local/nodes.ts +28 -41
- package/local/plugin.ts +71 -13
- package/local/sample-identity/brand.yaml +24 -0
- package/local/sample-identity/organisation.yaml +14 -0
- package/local/sample-identity/purpose.yaml +14 -0
- package/local/sample-identity/story.yaml +18 -0
- package/local/scaffold.mjs +43 -23
- package/local/source.ts +3 -8
- package/package.json +1 -1
- package/screens/AgentsView.tsx +173 -0
- package/screens/ComponentsView.tsx +2 -2
- package/screens/EmptyLibrary.tsx +1 -1
- package/screens/IdentityView.tsx +3 -3
- package/screens/PromptBlocksView.tsx +1 -1
- package/screens/TemplatesLibraryView.tsx +91 -6
- package/screens/TemplatesView.tsx +24 -9
- package/screens/index.ts +2 -0
- package/src/App.tsx +8 -3
- package/src/host.ts +1 -14
- package/vendor/base/src/agent-mcp/discovery.ts +76 -18
- package/vendor/base/src/agent-mcp/handoff.ts +11 -31
- package/vendor/base/src/agent-mcp/index.ts +5 -5
- package/vendor/base/src/agent-mcp/invoke.ts +37 -19
- package/vendor/base/src/agent-mcp/lean.ts +19 -0
- package/vendor/base/src/agent-mcp/memoryContext.ts +15 -9
- package/vendor/base/src/agent-mcp/memoryIngest.ts +8 -3
- package/vendor/base/src/agent-mcp/rowComponents.ts +184 -72
- package/vendor/base/src/agent-mcp/tools.ts +136 -7
- package/vendor/base/src/agent-mcp/types.ts +47 -10
- package/vendor/base/src/auth/auth.ts +35 -5
- package/vendor/base/src/definitions/isolated.ts +37 -0
- package/vendor/base/src/definitions/load/compile/component.ts +59 -4
- package/vendor/base/src/definitions/load/compile/sections.ts +7 -1
- package/vendor/base/src/definitions/load/compile/template.ts +19 -15
- package/vendor/base/src/definitions/load/expand/inherit.ts +5 -1
- package/vendor/base/src/definitions/load/expand/referenced.ts +21 -2
- package/vendor/base/src/definitions/load/expand/refs.ts +14 -0
- package/vendor/base/src/definitions/load/expand/walk.ts +88 -38
- package/vendor/base/src/definitions/load/load.ts +37 -28
- package/vendor/base/src/definitions/model.ts +8 -12
- package/vendor/base/src/definitions/registry/agents.ts +117 -0
- package/vendor/base/src/definitions/registry/catalog.ts +40 -24
- package/vendor/base/src/definitions/registry/datasetSql.ts +149 -0
- package/vendor/base/src/definitions/registry/homes.ts +69 -5
- package/vendor/base/src/definitions/registry/objects.ts +413 -0
- package/vendor/base/src/definitions/registry/orgDocuments.ts +69 -29
- package/vendor/base/src/definitions/registry/orgPack.ts +11 -11
- package/vendor/base/src/definitions/registry/pipelines.ts +197 -0
- package/vendor/base/src/definitions/theme.ts +1 -2
- package/vendor/base/src/items/collect.ts +29 -25
- package/vendor/base/src/items/publish.ts +30 -6
- package/vendor/base/src/lint/context.mjs +17 -0
- package/vendor/base/src/lint/design/file.mjs +199 -2
- package/vendor/base/src/lint/design/index.mjs +66 -73
- package/vendor/base/src/lint/design/vocabulary.mjs +5 -2
- package/vendor/base/src/lint/design/walk.mjs +14 -4
- package/vendor/base/src/lint/index.mjs +15 -9
- package/vendor/base/src/lint/nodes/blocks.mjs +6 -2
- package/vendor/base/src/lint/nodes/index.mjs +5 -0
- package/vendor/base/src/lint/nodes/node.mjs +157 -7
- package/vendor/base/src/lint/objects/index.d.mts +32 -0
- package/vendor/base/src/lint/objects/index.mjs +324 -0
- package/vendor/base/src/lint/objects/rules.d.mts +44 -0
- package/vendor/base/src/lint/objects/rules.mjs +446 -0
- package/vendor/base/src/lint/prompts/index.d.mts +34 -0
- package/vendor/base/src/lint/prompts/index.mjs +198 -0
- package/vendor/base/src/manifests/compose.ts +80 -9
- package/vendor/base/src/manifests/executor/callback.ts +15 -4
- package/vendor/base/src/manifests/executor/context.ts +9 -1
- package/vendor/base/src/manifests/executor/memory.ts +83 -3
- package/vendor/base/src/manifests/executor/session.ts +50 -0
- package/vendor/base/src/manifests/executor/toolBridge.ts +114 -49
- package/vendor/base/src/manifests/runtime/auth/aws.ts +14 -1
- package/vendor/base/src/manifests/runtime/channels/final.ts +46 -34
- package/vendor/base/src/manifests/runtime/channels/service.ts +30 -32
- package/vendor/base/src/manifests/runtime/channels/workflow.ts +6 -11
- package/vendor/base/src/manifests/runtime/context.ts +18 -6
- package/vendor/base/src/manifests/runtime/duplex/audioLane.ts +159 -3
- package/vendor/base/src/manifests/runtime/duplex/session.ts +304 -9
- package/vendor/base/src/manifests/runtime/events.ts +39 -3
- package/vendor/base/src/manifests/runtime/http/concurrency.ts +44 -0
- package/vendor/base/src/manifests/runtime/http/request.ts +36 -7
- package/vendor/base/src/manifests/runtime/index.ts +3 -2
- package/vendor/base/src/manifests/runtime/loops/repeat.ts +86 -0
- package/vendor/base/src/manifests/runtime/tools/toolloop.ts +30 -3
- package/vendor/base/src/manifests/runtime/usage.ts +10 -0
- package/vendor/base/src/paths.ts +16 -8
- package/vendor/base/src/platform/audioManager.ts +32 -0
- package/vendor/base/src/platform/executionContext.ts +14 -0
- package/vendor/base/src/platform/liveInput.ts +52 -0
- package/vendor/base/src/plugins/discovery.ts +1 -1
- package/vendor/base/src/template/SafeExpression.ts +6 -0
- package/vendor/base/src/workflow/GetMemoryService.ts +12 -11
- package/vendor/base/src/workflow/PlatformMemoryCore.ts +23 -10
- package/vendor/sdk/appEngine.tsx +30 -11
- package/vendor/sdk/lib/UnoverseComponent.tsx +8 -4
- package/vendor/sdk/lib/app.tsx +70 -95
- package/vendor/sdk/lib/connection.tsx +47 -57
- package/vendor/sdk/lib/core/actions.ts +6 -0
- package/vendor/sdk/lib/core/apps.ts +66 -81
- package/vendor/sdk/lib/core/client.ts +5 -14
- package/vendor/sdk/lib/core/connection.ts +30 -0
- package/vendor/sdk/lib/core/index.ts +2 -2
- package/vendor/sdk/lib/core/store.ts +90 -15
- package/vendor/sdk/lib/core/types.ts +9 -8
- package/vendor/sdk/lib/primitives.tsx +113 -4
- package/vendor/sdk/lib/realtime/useAudioPlayback.ts +8 -1
- package/vendor/sdk/lib/render.tsx +3 -3
- package/vendor/sdk/lib/voice.tsx +27 -13
- package/local/sample-identity/brand/brand.yaml +0 -88
- package/local/sample-identity/brand/manifest.yaml +0 -13
- package/local/sample-identity/organisation/manifest.yaml +0 -15
- package/local/sample-identity/organisation/organisation.yaml +0 -53
- package/local/sample-identity/purpose/manifest.yaml +0 -12
- package/local/sample-identity/purpose/purpose.yaml +0 -46
- package/local/sample-identity/story/manifest.yaml +0 -12
- package/local/sample-identity/story/story.yaml +0 -63
- package/vendor/base/src/definitions/registry/identitySeed.ts +0 -17
|
@@ -41,11 +41,33 @@ const getIssuerCandidates = (): string[] => {
|
|
|
41
41
|
};
|
|
42
42
|
const getAudience = (): string | undefined => process.env.AUTH_AUDIENCE;
|
|
43
43
|
|
|
44
|
-
|
|
45
|
-
|
|
44
|
+
// WHERE THE SIGNING KEYS ARE IS THE PROVIDER'S TO SAY, in its discovery document.
|
|
45
|
+
//
|
|
46
|
+
// This read `${issuer}/.well-known/jwks.json`, which is where Auth0 and Cognito happen to keep
|
|
47
|
+
// them. Microsoft Entra keeps them under /discovery/v2.0/keys, Keycloak under
|
|
48
|
+
// /protocol/openid-connect/certs, WorkOS AuthKit under /oauth2/jwks, so a bank's Entra or a
|
|
49
|
+
// WorkOS login was refused on every request with a key that could not be found. OIDC
|
|
50
|
+
// discovery names the place (`jwks_uri`). With no discovery document the old location is
|
|
51
|
+
// used, and not remembered, so a transient failure is retried on the next request.
|
|
52
|
+
type KeySet = ReturnType<typeof createRemoteJWKSet>;
|
|
53
|
+
let jwks: Promise<KeySet> | null = null;
|
|
54
|
+
async function loadJWKS(base: string): Promise<{ set: KeySet; discovered: boolean }> {
|
|
55
|
+
try {
|
|
56
|
+
const res = await fetch(`${base}/.well-known/openid-configuration`, { signal: AbortSignal.timeout(5000) });
|
|
57
|
+
const doc: any = res.ok ? await res.json() : null;
|
|
58
|
+
if (typeof doc?.jwks_uri === "string") return { set: createRemoteJWKSet(new URL(doc.jwks_uri)), discovered: true };
|
|
59
|
+
} catch {
|
|
60
|
+
// fall through to the conventional location
|
|
61
|
+
}
|
|
62
|
+
return { set: createRemoteJWKSet(new URL(`${base}/.well-known/jwks.json`)), discovered: false };
|
|
63
|
+
}
|
|
64
|
+
function getJWKS(): Promise<KeySet> | null {
|
|
46
65
|
const base = getIssuerBase();
|
|
47
66
|
if (!jwks && base) {
|
|
48
|
-
jwks =
|
|
67
|
+
jwks = loadJWKS(base).then(({ set, discovered }) => {
|
|
68
|
+
if (!discovered) jwks = null;
|
|
69
|
+
return set;
|
|
70
|
+
});
|
|
49
71
|
}
|
|
50
72
|
return jwks;
|
|
51
73
|
}
|
|
@@ -69,6 +91,14 @@ function parseArrayClaim(payload: JWTPayload, claimName: string): string[] {
|
|
|
69
91
|
return [];
|
|
70
92
|
}
|
|
71
93
|
|
|
94
|
+
/** Roles as a list. Most providers send a `roles` list (Auth0 namespaced, Entra app roles);
|
|
95
|
+
* WorkOS sends one `role`, the role of the organization membership signed in with. */
|
|
96
|
+
export function parseRoles(payload: JWTPayload): string[] {
|
|
97
|
+
const roles = parseArrayClaim(payload, "roles");
|
|
98
|
+
const one = payload.role;
|
|
99
|
+
return typeof one === "string" && one && !roles.includes(one) ? [...roles, one] : roles;
|
|
100
|
+
}
|
|
101
|
+
|
|
72
102
|
/** Parse a string claim, handling Auth0-style namespaced claims (e.g. https://…/email).
|
|
73
103
|
* Auth0 emits custom claims (email on the access token) ONLY namespaced, so a plain
|
|
74
104
|
* `payload.email` read returns undefined and silently no-ops every email-keyed feature
|
|
@@ -114,7 +144,7 @@ export async function validateJWT(token: string): Promise<AuthUser | null> {
|
|
|
114
144
|
}
|
|
115
145
|
|
|
116
146
|
async function verifyJWT(token: string): Promise<AuthUser | null> {
|
|
117
|
-
const jwksSet = getJWKS();
|
|
147
|
+
const jwksSet = await getJWKS();
|
|
118
148
|
const issuers = getIssuerCandidates();
|
|
119
149
|
if (!jwksSet || issuers.length === 0) {
|
|
120
150
|
console.warn("[auth] OIDC not configured (AUTH_ISSUER missing)");
|
|
@@ -148,7 +178,7 @@ async function verifyJWT(token: string): Promise<AuthUser | null> {
|
|
|
148
178
|
return {
|
|
149
179
|
id: payload.sub || "unknown",
|
|
150
180
|
email: parseStringClaim(payload, "email"),
|
|
151
|
-
roles:
|
|
181
|
+
roles: parseRoles(payload),
|
|
152
182
|
permissions: parseArrayClaim(payload, "permissions"),
|
|
153
183
|
raw: payload,
|
|
154
184
|
};
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AN ORG IS A WALL (docs/unoverse/UNOVERSE_ORGANISATION.md §11, part 1).
|
|
3
|
+
*
|
|
4
|
+
* Anything built from many orgs' definitions (an MCP session's tools, the spatial
|
|
5
|
+
* registry, the Studio lists) builds each item through here. One that throws is left
|
|
6
|
+
* out and named, `org/item: reason`, and every other org is still served. Observed live
|
|
7
|
+
* 2026-09-23: one org's stale template threw inside MCP session construction and every
|
|
8
|
+
* signed-in session, for every org, answered -32603.
|
|
9
|
+
*
|
|
10
|
+
* The one door, so no builder writes its own try and no builder forgets one.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/** Name what was left out and why. The label is `org/item` (or `kind org/item`). */
|
|
14
|
+
export function reportLeftOut(label: string, err: unknown): void {
|
|
15
|
+
const reason = err instanceof Error ? err.message : String(err);
|
|
16
|
+
console.warn(`[unoverse] left out ${label}: ${reason}`);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Build one item on its own. Undefined when it failed; the failure is reported. */
|
|
20
|
+
export function isolated<T>(label: string, build: () => T): T | undefined {
|
|
21
|
+
try {
|
|
22
|
+
return build();
|
|
23
|
+
} catch (err) {
|
|
24
|
+
reportLeftOut(label, err);
|
|
25
|
+
return undefined;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** The same, for a build that awaits. */
|
|
30
|
+
export async function isolatedAsync<T>(label: string, build: () => Promise<T>): Promise<T | undefined> {
|
|
31
|
+
try {
|
|
32
|
+
return await build();
|
|
33
|
+
} catch (err) {
|
|
34
|
+
reportLeftOut(label, err);
|
|
35
|
+
return undefined;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
@@ -28,6 +28,8 @@ export function compileComponentTree(def: UnoverseDefinition): void {
|
|
|
28
28
|
const initial = typeof tree.initial === "string" ? tree.initial : names[0];
|
|
29
29
|
def.publicStates = names;
|
|
30
30
|
def.initialView = initial;
|
|
31
|
+
const statePlaces = placesOfStates(tree.states);
|
|
32
|
+
if (statePlaces) def.statePlaces = statePlaces;
|
|
31
33
|
// The nested projection viewers render from (never scan layouts for it).
|
|
32
34
|
def.stateTree = names.map((n) => {
|
|
33
35
|
const s = (tree.states as Record<string, { layout?: unknown; on?: unknown; initial?: unknown; states?: Record<string, { layout?: unknown } | null> } | null>)[n];
|
|
@@ -35,6 +37,7 @@ export function compileComponentTree(def: UnoverseDefinition): void {
|
|
|
35
37
|
if (s && typeof s === "object") {
|
|
36
38
|
if (typeof s.layout === "string") entry.layout = s.layout;
|
|
37
39
|
if (typeof s.on === "string") entry.on = s.on;
|
|
40
|
+
else if (s.states && typeof s.states === "object" && Object.keys(s.states).length) entry.on = STEP;
|
|
38
41
|
if (typeof s.initial === "string") entry.initial = s.initial;
|
|
39
42
|
if (s.states && typeof s.states === "object")
|
|
40
43
|
entry.states = Object.entries(s.states).map(([k, v]) => ({
|
|
@@ -70,11 +73,63 @@ export function compileComponentTree(def: UnoverseDefinition): void {
|
|
|
70
73
|
// READ where a def has not been swept, and lint-forbidden in rx.
|
|
71
74
|
const flat: Record<string, unknown> = { ...authored, state: initial };
|
|
72
75
|
delete (def as unknown as Record<string, unknown>).states; // the tree compiled; the raw block never rides the served def
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
76
|
+
const steps = new Set<string>();
|
|
77
|
+
for (const s of Object.values(tree.states)) {
|
|
78
|
+
if (!s || typeof s !== "object") continue;
|
|
79
|
+
const subs = s.states && typeof s.states === "object" ? Object.keys(s.states) : [];
|
|
80
|
+
const step = typeof s.initial === "string" ? s.initial : subs[0];
|
|
81
|
+
if (typeof s.on === "string") {
|
|
77
82
|
if (step) flat[s.on] = step;
|
|
83
|
+
} else if (subs.length) {
|
|
84
|
+
// ONE WORD (UNOVERSE_STATE_MODEL §5, "One word: `state`"): a face's steps carry no second
|
|
85
|
+
// name. They start on the first declared and live in the hidden step value.
|
|
86
|
+
subs.forEach((n) => steps.add(n));
|
|
87
|
+
if (step && !(STEP in flat)) flat[STEP] = step;
|
|
78
88
|
}
|
|
89
|
+
}
|
|
79
90
|
def.state = flat;
|
|
91
|
+
if (steps.size) {
|
|
92
|
+
if (def.root) def.root = pointStepsAtStep(def.root, steps);
|
|
93
|
+
if (def.layouts) for (const n of Object.keys(def.layouts)) def.layouts[n] = pointStepsAtStep(def.layouts[n], steps);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** The hidden value a face's steps live in. The author writes `state`; only the compiler says this. */
|
|
98
|
+
export const STEP = "step";
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* ONE WORD (UNOVERSE_STATE_MODEL §5): the author writes `state` for a face and for its steps alike.
|
|
102
|
+
* Wherever `state` names one of this component's STEPS, it means the step value: a `Switch on:
|
|
103
|
+
* state` whose cases are steps, a condition on `field: state` against steps, and a write of
|
|
104
|
+
* `key: state` to a step. Everything else about `state` (the face) is left untouched.
|
|
105
|
+
*/
|
|
106
|
+
export function pointStepsAtStep<T>(node: T, steps: Set<string>): T {
|
|
107
|
+
const isStep = (v: unknown): boolean => typeof v === "string" && steps.has(v);
|
|
108
|
+
const walk = (n: unknown): unknown => {
|
|
109
|
+
if (Array.isArray(n)) return n.map(walk);
|
|
110
|
+
if (!n || typeof n !== "object") return n;
|
|
111
|
+
const o: Record<string, unknown> = {};
|
|
112
|
+
for (const [k, v] of Object.entries(n as Record<string, unknown>)) o[k] = walk(v);
|
|
113
|
+
if (o.type === "Switch" && o.on === "state" && o.cases && typeof o.cases === "object") {
|
|
114
|
+
const keys = Object.keys(o.cases as object).filter((k) => k !== "default");
|
|
115
|
+
if (keys.length && keys.every(isStep)) o.on = STEP;
|
|
116
|
+
}
|
|
117
|
+
if (o.field === "state") {
|
|
118
|
+
const vals = [o.eq, o.ne, ...(Array.isArray(o.in) ? o.in : [])].filter((v) => v !== undefined);
|
|
119
|
+
if (vals.length && vals.every(isStep)) o.field = STEP;
|
|
120
|
+
}
|
|
121
|
+
if (o.key === "state" && isStep(o.value)) o.key = STEP;
|
|
122
|
+
return o;
|
|
123
|
+
};
|
|
124
|
+
return walk(node) as T;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** The place each state names, or undefined when none names one (UNOVERSE_SCREEN.md §2.3). */
|
|
128
|
+
export function placesOfStates(states: Record<string, unknown>): Record<string, string> | undefined {
|
|
129
|
+
const out: Record<string, string> = {};
|
|
130
|
+
for (const [name, s] of Object.entries(states)) {
|
|
131
|
+
const place = s && typeof s === "object" ? (s as { place?: unknown }).place : undefined;
|
|
132
|
+
if (typeof place === "string" && place) out[name] = place;
|
|
133
|
+
}
|
|
134
|
+
return Object.keys(out).length ? out : undefined;
|
|
80
135
|
}
|
|
@@ -80,6 +80,13 @@ export function expandTemplateSections(
|
|
|
80
80
|
`template "${templateName}": an items place nests its search words INSIDE items: (each ITEM searches) — items: { min, max, type, include, pick }`,
|
|
81
81
|
);
|
|
82
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)" : ""}`);
|
|
83
90
|
out.push({
|
|
84
91
|
kind: "director",
|
|
85
92
|
face,
|
|
@@ -116,7 +123,6 @@ export function expandTemplateSections(
|
|
|
116
123
|
face,
|
|
117
124
|
...(typeof d.state === "string" ? { state: d.state } : {}),
|
|
118
125
|
...(typeof d.pick === "number" ? { pick: d.pick } : {}),
|
|
119
|
-
...(typeof d.rules === "string" ? { rules: d.rules } : {}),
|
|
120
126
|
...(Array.isArray(d.preview) ? { preview: d.preview.map(String) } : {}),
|
|
121
127
|
});
|
|
122
128
|
const { director: _d, ...rest } = n;
|
|
@@ -9,6 +9,7 @@ import type { UnoverseDefinition } from "../../model.js";
|
|
|
9
9
|
import { loadDefinition } from "../load.js";
|
|
10
10
|
import { composeIncludes } from "../expand/index.js";
|
|
11
11
|
import { expandTemplateSections } from "./sections.js";
|
|
12
|
+
import { placesOfStates } from "./component.js";
|
|
12
13
|
|
|
13
14
|
/**
|
|
14
15
|
* ONE PREVIEWED INSTANCE'S SEED — `preview ?? default` per prop, the same values the
|
|
@@ -108,19 +109,8 @@ export function synthesizeTemplateRoot(def: UnoverseDefinition, folderDir?: stri
|
|
|
108
109
|
const layouts: Record<string, unknown> = {};
|
|
109
110
|
const sections: NonNullable<UnoverseDefinition["sections"]> = {};
|
|
110
111
|
const compiledPreview: Record<string, string[]> = {};
|
|
111
|
-
// THE LATCH IS A FACT ABOUT A STATE (MCP guide §The Component Latch). Collected on the
|
|
112
|
-
// same pass as the layouts and stamped as `def.latches`, because `states` is deleted
|
|
113
|
-
// below: a state that says nothing here can never be addressed, and an instance moving
|
|
114
|
-
// between states gains and loses the pill with no eviction and nothing to unregister.
|
|
115
|
-
const latches: NonNullable<UnoverseDefinition["latches"]> = {};
|
|
116
112
|
for (const name of shapeNames) {
|
|
117
113
|
const st = (states[name] ?? {}) as Record<string, unknown>;
|
|
118
|
-
const latch = st.latch as { title?: unknown; icon?: unknown } | undefined;
|
|
119
|
-
// TITLE OR NOTHING: the pill's whole content is the title, so a latch without one
|
|
120
|
-
// draws a nameless pill the guest cannot read. Declared but empty is an authoring
|
|
121
|
-
// slip, and taking it literally is what makes the slip visible.
|
|
122
|
-
if (latch && typeof latch === "object" && typeof latch.title === "string" && latch.title.trim())
|
|
123
|
-
latches[name] = { title: latch.title, ...(typeof latch.icon === "string" ? { icon: latch.icon } : {}) };
|
|
124
114
|
if (typeof st.layout !== "string") continue;
|
|
125
115
|
const secs: NonNullable<UnoverseDefinition["sections"]>[string] = [];
|
|
126
116
|
layouts[name] = expandTemplateSections(composeIncludes({ $include: st.layout }, folderDir), name, def.name, secs);
|
|
@@ -143,11 +133,25 @@ export function synthesizeTemplateRoot(def: UnoverseDefinition, folderDir?: stri
|
|
|
143
133
|
// same contract: states are the interface, the first declared one is arrival.
|
|
144
134
|
def.publicStates = shapeNames;
|
|
145
135
|
def.initialView = shapeNames[0];
|
|
146
|
-
|
|
136
|
+
const statePlaces = placesOfStates(states);
|
|
137
|
+
if (statePlaces) def.statePlaces = statePlaces;
|
|
147
138
|
if (Object.keys(sections).length) def.sections = sections;
|
|
148
139
|
if (Object.keys(compiledPreview).length) def.preview = { ...(def.preview ?? {}), ...compiledPreview };
|
|
149
140
|
delete (def as unknown as Record<string, unknown>).states;
|
|
150
|
-
//
|
|
151
|
-
//
|
|
152
|
-
|
|
141
|
+
// THE TREE IS THE MACHINE, for templates too (ruled 2026-09-06, §5d). A template that
|
|
142
|
+
// STREAMS IN is an INSTANCE that draws itself, and the renderer draws `root`; served as
|
|
143
|
+
// the base layout alone, the instance's own face was read by nobody, so a task could
|
|
144
|
+
// never retract to its inline face (the ✕ wrote `inline` and the flow drew the whole
|
|
145
|
+
// page). A component's root is synthesized as a Switch on `state` over its declared
|
|
146
|
+
// layouts (load.ts, 2026-08-29); the same root here, one case per state, default = the
|
|
147
|
+
// base. `layouts` / `defaultLayout` are unchanged, so a MOUNT — which reads `layouts`
|
|
148
|
+
// by the faces of what it holds — is untouched. Wrapped in a container so responsive
|
|
149
|
+
// columns can query their surface width in any host.
|
|
150
|
+
const cases: Record<string, unknown> = { ...layouts };
|
|
151
|
+
if (!("default" in cases)) cases.default = layouts[shapeNames[0]];
|
|
152
|
+
def.root = {
|
|
153
|
+
type: "Box",
|
|
154
|
+
style: { width: "full", container: "inline-size" },
|
|
155
|
+
children: [shapeNames.length > 1 ? { type: "Switch", on: "state", cases } : layouts[shapeNames[0]]],
|
|
156
|
+
} as UnoverseDefinition["root"];
|
|
153
157
|
}
|
|
@@ -110,7 +110,11 @@ export function inheritProps(
|
|
|
110
110
|
// Day's moments the day `chapter` stopped restating the tile's contract: `required` was
|
|
111
111
|
// still `["ref"]`, but `title` and `description` were back among the properties a model
|
|
112
112
|
// could send, which is exactly the venue-renaming this flag exists to prevent.
|
|
113
|
-
|
|
113
|
+
// `input` TRAVELS TOO (2026-09-15). An atom that marks a field `input: false` (the button's
|
|
114
|
+
// `icon` and `link`) says nobody outside fills it; without the word here the host inherited
|
|
115
|
+
// the field as fillable and the tool asked the model for it (SAB transfer: four fields it
|
|
116
|
+
// could not honestly write). Same failure as `override` above, one word later.
|
|
117
|
+
const CONTRACT = ["type", "description", "maxLength", "minItems", "maxItems", "items", "hydrate", "optional", "override", "preview", "input"] as const;
|
|
114
118
|
// AN ATOM INSIDE AN `Each` DESCRIBES THE ITEM, not the page. The scope is the chain of
|
|
115
119
|
// array props the Ref sits inside, so a `prose` atom drawn once per section lands on
|
|
116
120
|
// `sections.items.body` — where a brief on its node used to land by being there.
|
|
@@ -11,6 +11,7 @@ import type { UnoverseDefinition } from "../../model.js";
|
|
|
11
11
|
import { loadDefinition } from "../load.js";
|
|
12
12
|
import { inheritState } from "./inherit.js";
|
|
13
13
|
import type { AnyNode } from "./refs.js";
|
|
14
|
+
import { composeRef, composesAxis } from "./walk.js";
|
|
14
15
|
|
|
15
16
|
export function referencedDefinition(def: UnoverseDefinition, contextOrg?: string): UnoverseDefinition {
|
|
16
17
|
const defs: Record<string, AnyNode> = {};
|
|
@@ -21,6 +22,22 @@ export function referencedDefinition(def: UnoverseDefinition, contextOrg?: strin
|
|
|
21
22
|
const collect = (node: AnyNode | undefined): void => {
|
|
22
23
|
if (!node || typeof node !== "object") return;
|
|
23
24
|
if (Array.isArray(node)) return node.forEach(collect);
|
|
25
|
+
// A REF THAT CHOOSES A KIND IS COMPOSED HERE, NOT SHIPPED BY NAME. An org's component
|
|
26
|
+
// with a private axis (`sab/button`, `with: {kind: link}`) is specialised per use: the
|
|
27
|
+
// host picks the kind, the Ref's action/style land on the real node. Shipping its
|
|
28
|
+
// whole root once and letting the client resolve it drew the arrival kind with no
|
|
29
|
+
// words (the studio's comparison template, 2026-09-07). The composed node keeps its
|
|
30
|
+
// own inner atom references, which the client resolves from `defs` as ever.
|
|
31
|
+
if (node.type === "Ref" && typeof node.ref === "string" && composesAxis(node, contextOrg ?? def.org)) {
|
|
32
|
+
const composed = composeRef(node, contextOrg ?? def.org, "referenced");
|
|
33
|
+
if (composed) {
|
|
34
|
+
inheritState(inherit, composed.piece);
|
|
35
|
+
for (const [k, v] of Object.entries((composed.piece.defs ?? {}) as Record<string, AnyNode>)) if (!(k in defs)) defs[k] = v;
|
|
36
|
+
for (const k of Object.keys(node)) delete node[k];
|
|
37
|
+
Object.assign(node, composed.root);
|
|
38
|
+
return collect(node);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
24
41
|
if (node.type === "Ref" && typeof node.ref === "string") {
|
|
25
42
|
const name = node.ref;
|
|
26
43
|
if (!(name in defs)) {
|
|
@@ -30,8 +47,10 @@ export function referencedDefinition(def: UnoverseDefinition, contextOrg?: strin
|
|
|
30
47
|
// EXPANDED instead was the difference between product-card costing 17 KB + 173 KB
|
|
31
48
|
// and 17 KB + a handful: it references `document`, and an expanded document drags
|
|
32
49
|
// in every copy this exists to remove.
|
|
33
|
-
|
|
34
|
-
|
|
50
|
+
// Qualified names are an org's piece, never a marketplace atom (see composeRef).
|
|
51
|
+
const piece = name.includes("/")
|
|
52
|
+
? (loadDefinition(name, "component", "referenced", contextOrg ?? def.org) ?? loadDefinition(name, "atom", "referenced"))
|
|
53
|
+
: (loadDefinition(name, "atom", "referenced") ?? loadDefinition(name, "component", "referenced", contextOrg ?? def.org));
|
|
35
54
|
if (piece?.root) {
|
|
36
55
|
defs[name] = piece.root as AnyNode;
|
|
37
56
|
// Same inheritance as the expanded form. The client resolves the Ref itself here,
|
|
@@ -82,6 +82,20 @@ function applyStyleLiterals(style: unknown, lits: Record<string, unknown>): void
|
|
|
82
82
|
}
|
|
83
83
|
|
|
84
84
|
export function applyLiterals(node: AnyNode, lits: Record<string, unknown>): void {
|
|
85
|
+
// A NESTED REF FORWARDS THE LITERAL. A piece whose kind is itself a Ref to an atom
|
|
86
|
+
// (SAB's button: primary = the marketplace `button` atom with `props: {label: label}`)
|
|
87
|
+
// hands its host's literal on as that Ref's `with`, so the atom draws the words whether
|
|
88
|
+
// the tree is expanded here or resolved by name on the client (referenced form).
|
|
89
|
+
if (node.type === "Ref" && node.props && typeof node.props === "object") {
|
|
90
|
+
for (const k of Object.keys(node.props as AnyNode)) {
|
|
91
|
+
const field = (node.props as AnyNode)[k];
|
|
92
|
+
if (typeof field === "string" && field in lits) {
|
|
93
|
+
(node.with ??= {})[k] = lits[field];
|
|
94
|
+
delete (node.props as AnyNode)[k];
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
if (Object.keys(node.props as AnyNode).length === 0) delete node.props;
|
|
98
|
+
}
|
|
85
99
|
if (node.bind) {
|
|
86
100
|
for (const attr of Object.keys(node.bind)) {
|
|
87
101
|
const field = node.bind[attr];
|
|
@@ -13,44 +13,11 @@ import { inheritState, inheritProps, mergeInheritedProps } from "./inherit.js";
|
|
|
13
13
|
|
|
14
14
|
export function expandNode(node: AnyNode, contextOrg?: string, inherit?: Record<string, unknown>, props?: Record<string, Record<string, unknown>>, scope: string[] = []): AnyNode {
|
|
15
15
|
if (node?.type === "Ref") {
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
// resolves to that org's component when two orgs share the name.
|
|
22
|
-
//
|
|
23
|
-
// AND A BLOCK (2026-08-31) — a flat TEMPLATE with no manifest: `pair`, `feature`,
|
|
24
|
-
// `single`, `rail`, `stack`. §2 always said "a rich template may compose basic ones
|
|
25
|
-
// inside itself", and this is that sentence, by the mechanism already here rather than
|
|
26
|
-
// a new one. Inlining is exactly right for it: the block's `director:` places arrive in
|
|
27
|
-
// the composing page's tree BEFORE its sections compile, so they become real slots and
|
|
28
|
-
// the page addresses them by position along with its own. Nothing is mounted, nothing
|
|
29
|
-
// is nested at runtime — the page ends up with the tree it would have had if someone
|
|
30
|
-
// had drawn the block by hand, which is the point.
|
|
31
|
-
//
|
|
32
|
-
// Last in the chain deliberately: atoms and components are the common case, and a
|
|
33
|
-
// template sharing a name with either keeps the old resolution.
|
|
34
|
-
const atom =
|
|
35
|
-
loadDefinition(String(node.ref), "atom") ??
|
|
36
|
-
loadDefinition(String(node.ref), "component", "expanded", contextOrg) ??
|
|
37
|
-
loadDefinition(String(node.ref), "template", "expanded", contextOrg);
|
|
38
|
-
if (!atom?.root) return node; // unknown ref — leave the Ref (renders as a no-op)
|
|
39
|
-
inheritState(inherit ?? {}, atom);
|
|
40
|
-
if (props) inheritProps(props, atom, (node.props ?? {}) as Record<string, string>, (node.with ?? {}) as Record<string, unknown>, scope);
|
|
41
|
-
const root = JSON.parse(JSON.stringify(atom.root)) as AnyNode;
|
|
42
|
-
remapFields(root, (node.props ?? {}) as Record<string, string>);
|
|
43
|
-
if (node.with && typeof node.with === "object") applyLiterals(root, node.with as Record<string, unknown>);
|
|
44
|
-
if (node.visibleWhen) root.visibleWhen = node.visibleWhen;
|
|
45
|
-
// A Ref may override the atom's `action` — the per-host behaviour the atom can't carry
|
|
46
|
-
// (e.g. each wizard step's option sets different fields). Parallel to style/visibleWhen.
|
|
47
|
-
if (node.action) root.action = node.action;
|
|
48
|
-
if (node.style) root.style = { ...(root.style ?? {}), ...node.style };
|
|
49
|
-
// THE BLOCK'S OPENING (2026-09-02, blocks returned): a Ref carrying `children:` fills
|
|
50
|
-
// the inlined root with the CALLER's children — a `band` or `two-column` block styles
|
|
51
|
-
// the frame, the composing layout says what sits inside. Same override family as
|
|
52
|
-
// style/action above; a Ref without children keeps the block's own (a complete block).
|
|
53
|
-
if (Array.isArray(node.children) && node.children.length) root.children = node.children;
|
|
16
|
+
const composed = composeRef(node, contextOrg, "expanded");
|
|
17
|
+
if (!composed) return node; // unknown ref — leave the Ref (renders as a no-op)
|
|
18
|
+
const { piece, root } = composed;
|
|
19
|
+
inheritState(inherit ?? {}, piece);
|
|
20
|
+
if (props) inheritProps(props, piece, (node.props ?? {}) as Record<string, string>, (node.with ?? {}) as Record<string, unknown>, scope);
|
|
54
21
|
return expandNode(root, contextOrg, inherit, props, scope); // expand nested refs too
|
|
55
22
|
}
|
|
56
23
|
if (Array.isArray(node?.children)) node.children = node.children.map((c: AnyNode) => expandNode(c, contextOrg, inherit, props, scope));
|
|
@@ -75,6 +42,89 @@ export function expandNode(node: AnyNode, contextOrg?: string, inherit?: Record<
|
|
|
75
42
|
}
|
|
76
43
|
|
|
77
44
|
|
|
45
|
+
/**
|
|
46
|
+
* RESOLVE ONE REF INTO THE NODE IT COMPOSES — shared by the expanded form (which then
|
|
47
|
+
* recurses) and the referenced form (which keeps the inner atom references for the client).
|
|
48
|
+
*
|
|
49
|
+
* A Ref inlines an ATOM (the usual case, rx/atoms). It also resolves a COMPONENT as a
|
|
50
|
+
* fallback, so a template can embed a shared flat component (e.g. the ComposerBar chrome)
|
|
51
|
+
* the same way it embeds an atom — the one mechanism for fixed, always-present chrome.
|
|
52
|
+
* AND A BLOCK (2026-08-31) — a flat TEMPLATE with no manifest: `pair`, `feature`, `single`,
|
|
53
|
+
* `rail`, `stack`. Last in the chain deliberately: atoms and components are the common
|
|
54
|
+
* case, and a template sharing a name with either keeps the old resolution.
|
|
55
|
+
*
|
|
56
|
+
* AN ORG-QUALIFIED REF (`sab/button`) NAMES THAT ORG'S PIECE. Atoms are the marketplace's
|
|
57
|
+
* (no org), and the atom lookup strips the org from a qualified name — so `sab/button`
|
|
58
|
+
* silently resolved to the marketplace `button` atom and the org's own button component
|
|
59
|
+
* was never reached (2026-09-07). A bare name keeps the old order: atom first.
|
|
60
|
+
*
|
|
61
|
+
* AN EMBEDDED PIECE'S PRIVATE AXIS IS CHOSEN WHERE IT IS COMPOSED (2026-09-07, the SAB
|
|
62
|
+
* button: kinds primary/secondary/outline/link on a private `kind` axis). Its root is the
|
|
63
|
+
* compiled surface shell around the switches on that axis. Inlined, the piece has no slice
|
|
64
|
+
* of its own, so the host's `with` names the kind, and a host that names none gets the
|
|
65
|
+
* arrival kind (the first declared, def.state). Resolved HERE, statically, because the
|
|
66
|
+
* Ref's action/style/visibleWhen overrides must land on the real node, not on a Switch —
|
|
67
|
+
* and two buttons of different kinds in one host cannot share one runtime key.
|
|
68
|
+
*/
|
|
69
|
+
export function composeRef(node: AnyNode, contextOrg: string | undefined, mode: "expanded" | "referenced"): { piece: UnoverseDefinition; root: AnyNode } | null {
|
|
70
|
+
const ref = String(node.ref);
|
|
71
|
+
const qualified = ref.includes("/");
|
|
72
|
+
const piece = qualified
|
|
73
|
+
? (loadDefinition(ref, "component", mode, contextOrg) ?? loadDefinition(ref, "template", mode, contextOrg) ?? loadDefinition(ref, "atom", mode))
|
|
74
|
+
: (loadDefinition(ref, "atom", mode) ?? loadDefinition(ref, "component", mode, contextOrg) ?? loadDefinition(ref, "template", mode, contextOrg));
|
|
75
|
+
if (!piece?.root) return null;
|
|
76
|
+
let root = JSON.parse(JSON.stringify(piece.root)) as AnyNode;
|
|
77
|
+
const lits = (node.with && typeof node.with === "object" ? node.with : {}) as Record<string, unknown>;
|
|
78
|
+
const axes = (piece.state ?? {}) as Record<string, unknown>;
|
|
79
|
+
const isAxisSwitch = (n: AnyNode | undefined): boolean => n?.type === "Switch" && typeof n.on === "string" && !!n.cases && (n.on in lits || n.on in axes);
|
|
80
|
+
const shared = (piece.defs ?? {}) as Record<string, AnyNode>;
|
|
81
|
+
for (let guard = 0; guard < 8; guard++) {
|
|
82
|
+
// The referenced form hoists a repeated subtree into a `shared:` chunk and leaves a
|
|
83
|
+
// parameterless reference to it (the kind switch sits under two cases, so it is one).
|
|
84
|
+
// Resolving the axis has to read through that reference.
|
|
85
|
+
if (root?.type === "Ref" && typeof root.ref === "string" && root.ref.startsWith("shared:") && shared[root.ref]) {
|
|
86
|
+
root = JSON.parse(JSON.stringify(shared[root.ref])) as AnyNode;
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
if (isAxisSwitch(root)) {
|
|
90
|
+
const pick = String(root.on in lits ? lits[root.on] : axes[root.on]);
|
|
91
|
+
const chosen = (root.cases as AnyNode)[pick] ?? (root.cases as AnyNode).default;
|
|
92
|
+
if (!chosen) break;
|
|
93
|
+
root = JSON.parse(JSON.stringify(chosen)) as AnyNode;
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
// The compiled SURFACE SHELL (a Box around the state switch) belongs to a piece standing
|
|
97
|
+
// on its own; a piece inlined into another is the node its kind resolves to.
|
|
98
|
+
if (root?.type === "Box" && Array.isArray(root.children) && root.children.length === 1 && isAxisSwitch(root.children[0] as AnyNode)) {
|
|
99
|
+
root = root.children[0] as AnyNode;
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
break;
|
|
103
|
+
}
|
|
104
|
+
remapFields(root, (node.props ?? {}) as Record<string, string>);
|
|
105
|
+
if (node.with && typeof node.with === "object") applyLiterals(root, node.with as Record<string, unknown>);
|
|
106
|
+
if (node.visibleWhen) root.visibleWhen = node.visibleWhen;
|
|
107
|
+
// A Ref may override the atom's `action` — the per-host behaviour the atom can't carry
|
|
108
|
+
// (e.g. each wizard step's option sets different fields). Parallel to style/visibleWhen.
|
|
109
|
+
if (node.action) root.action = node.action;
|
|
110
|
+
if (node.style) root.style = { ...(root.style ?? {}), ...node.style };
|
|
111
|
+
// THE BLOCK'S OPENING (2026-09-02, blocks returned): a Ref carrying `children:` fills
|
|
112
|
+
// the inlined root with the CALLER's children — a `band` or `two-column` block styles
|
|
113
|
+
// the frame, the composing layout says what sits inside. Same override family as
|
|
114
|
+
// style/action above; a Ref without children keeps the block's own (a complete block).
|
|
115
|
+
if (Array.isArray(node.children) && node.children.length) root.children = node.children;
|
|
116
|
+
return { piece, root };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Does this Ref compose a piece whose kind the host chooses (a component with a private
|
|
120
|
+
* axis), rather than a shared atom the client can resolve by name? */
|
|
121
|
+
export function composesAxis(node: AnyNode, contextOrg: string | undefined): boolean {
|
|
122
|
+
const ref = String(node.ref ?? "");
|
|
123
|
+
if (!ref.includes("/")) return false; // bare names are atoms first — the shared case
|
|
124
|
+
const piece = loadDefinition(ref, "component", "referenced", contextOrg) ?? loadDefinition(ref, "template", "referenced", contextOrg);
|
|
125
|
+
return !!piece?.root && Object.keys((piece.state ?? {}) as Record<string, unknown>).length > 0;
|
|
126
|
+
}
|
|
127
|
+
|
|
78
128
|
export function expandRefs(def: UnoverseDefinition, contextOrg?: string): UnoverseDefinition {
|
|
79
129
|
// Whatever the tree embeds declares its own starting values; this is where they land on
|
|
80
130
|
// the slice those inlined nodes actually read (see `inheritState`). Merged AFTER the
|
|
@@ -82,7 +82,14 @@ export function loadDefinition(
|
|
|
82
82
|
// expanding, because the parsed object is shared and expansion mutates in place.
|
|
83
83
|
if (k === "atom") {
|
|
84
84
|
const sig = `${path}:${statSync(path).mtimeMs};${dirSignature([SHARED_DIR.atom])}`;
|
|
85
|
-
|
|
85
|
+
// `type: atom` → `kind`, exactly as every other kind below. The 2026-08-30 refactor
|
|
86
|
+
// skipped it here, and from 2026-09-01 the atom resource refused a def with no
|
|
87
|
+
// `kind`, so every Studio atom preview said "Unknown Unoverse atom" (seen 2026-09-07).
|
|
88
|
+
return cachedBySignature(`def:atom:${lower}${suffix}`, sig, () => {
|
|
89
|
+
const d = structuredClone(raw) as UnoverseDefinition;
|
|
90
|
+
normalizeDeclaredKind(d);
|
|
91
|
+
return form(d);
|
|
92
|
+
});
|
|
86
93
|
}
|
|
87
94
|
const sig = `${path}:${statSync(path).mtimeMs};${dirSignature([SHARED_DIR.atom, SHARED_DIR.component])}`;
|
|
88
95
|
// The org is stamped BEFORE form so expansion knows whose tree it is walking
|
|
@@ -181,11 +188,13 @@ export function loadDefinition(
|
|
|
181
188
|
// nothing and the registry row is the whole opt-in (default OFF).
|
|
182
189
|
def.spatial = true;
|
|
183
190
|
}
|
|
184
|
-
// COMPONENT manifest (OPTIONAL — its PRESENCE means "
|
|
185
|
-
// component has none and is never
|
|
186
|
-
//
|
|
187
|
-
//
|
|
188
|
-
//
|
|
191
|
+
// COMPONENT manifest (OPTIONAL — its PRESENCE means "offerable to the map"; a basic
|
|
192
|
+
// component has none and is never a task). It is the single home for the discovery
|
|
193
|
+
// meta (description/whenToUse/title): merged over the def so every consumer reads
|
|
194
|
+
// one merged shape. `spatial: true` is the projected capability flag — derived from
|
|
195
|
+
// presence, never authored. The envelope's own `whenToUse` is palette text and
|
|
196
|
+
// does NOT make a component a task (observed 2026-09-06: every marketplace card
|
|
197
|
+
// listed in the Tasks lane).
|
|
189
198
|
if (k === "component" && manifestPath) {
|
|
190
199
|
const m = readDefCached<ComponentManifest>(manifestPath);
|
|
191
200
|
keepComposition(def, m.description);
|
|
@@ -220,7 +229,7 @@ export function loadDefinition(
|
|
|
220
229
|
// folders, exactly like `kind:`/`defaultState`.
|
|
221
230
|
type AppStatesRecord = Record<
|
|
222
231
|
string,
|
|
223
|
-
{ layout?: unknown; preview?: unknown;
|
|
232
|
+
{ layout?: unknown; preview?: unknown; states?: Record<string, unknown> } | null
|
|
224
233
|
>;
|
|
225
234
|
const appStates: AppStatesRecord | undefined =
|
|
226
235
|
k === "app"
|
|
@@ -240,11 +249,7 @@ export function loadDefinition(
|
|
|
240
249
|
// Sources (`workflow`/`query`/`autoTrigger`) ride along as data until
|
|
241
250
|
// per-container routing lands.
|
|
242
251
|
if (k === "app" && manifestPath) {
|
|
243
|
-
const man = readDefCached<{ templates?: unknown; states?: Record<string, unknown> }>(manifestPath);
|
|
244
252
|
const merged: Record<string, unknown> = {};
|
|
245
|
-
// The legacy container form (deal-workspace): a top-level `templates:` block.
|
|
246
|
-
if (man.templates && typeof man.templates === "object" && !Array.isArray(man.templates))
|
|
247
|
-
Object.assign(merged, man.templates as Record<string, unknown>);
|
|
248
253
|
// A STATE ENTRY CARRIES ONLY WHAT SEEDS IT (owner ruling 2026-08-28
|
|
249
254
|
// evening): its `preview`. What a place holds and the judgment granted to
|
|
250
255
|
// it live on the PLACED NODE in the state's layout — projected below,
|
|
@@ -412,23 +417,6 @@ export function loadDefinition(
|
|
|
412
417
|
// by compilation. (Identified by structural equality: the composed
|
|
413
418
|
// states/<name> file IS the inlined node.)
|
|
414
419
|
if (k === "app" && appStates) {
|
|
415
|
-
// THE LATCH IS A FACT ABOUT A STATE (MCP guide §The Component Latch, moved off
|
|
416
|
-
// the manifest 2026-08-31). Projected here for the same reason `preview` is:
|
|
417
|
-
// the raw states block never rides the served def, so anything a state declares
|
|
418
|
-
// has to become a projection or it is lost. `def.latches` is what the SDK's
|
|
419
|
-
// `resolveLatch` looks the wearer's face up in — a state that says nothing here
|
|
420
|
-
// can never be addressed, and an instance retracting out of an addressable
|
|
421
|
-
// state drops the pill with nothing to unregister.
|
|
422
|
-
const latches: NonNullable<UnoverseDefinition["latches"]> = {};
|
|
423
|
-
for (const [sname, sentry] of Object.entries(appStates)) {
|
|
424
|
-
const l = (sentry as { latch?: { title?: unknown; icon?: unknown } } | null)?.latch;
|
|
425
|
-
// TITLE OR NOTHING: the pill's whole content is its title, so a latch without
|
|
426
|
-
// one draws a nameless pill the guest cannot read. Taking a declared-but-empty
|
|
427
|
-
// block literally is what makes the authoring slip visible.
|
|
428
|
-
if (l && typeof l === "object" && typeof l.title === "string" && l.title.trim())
|
|
429
|
-
latches[sname] = { title: l.title, ...(typeof l.icon === "string" ? { icon: l.icon } : {}) };
|
|
430
|
-
}
|
|
431
|
-
if (Object.keys(latches).length) def.latches = latches;
|
|
432
420
|
// The tree COMPILES into projections; the raw block never rides the
|
|
433
421
|
// served def (stateTree is the viewers' shape).
|
|
434
422
|
delete (def as unknown as Record<string, unknown>).states;
|
|
@@ -475,6 +463,10 @@ export function loadDefinition(
|
|
|
475
463
|
// - def.initialView = the declared initial (the retract/fallback base)
|
|
476
464
|
// - def.state.view → the scalar initial (the render scope reads a value)
|
|
477
465
|
// - a nested `on` + `initial` pair → its scalar initial (the substep key)
|
|
466
|
+
if (k === "app") {
|
|
467
|
+
const places = placesIn(def.layouts ?? { base: def.root });
|
|
468
|
+
if (places.length) def.places = places;
|
|
469
|
+
}
|
|
478
470
|
if (k === "component") compileComponentTree(def);
|
|
479
471
|
if (k === "template") synthesizeTemplateRoot(def, folderDir);
|
|
480
472
|
return k === "atom" ? def : form(def);
|
|
@@ -485,3 +477,20 @@ export function loadDefinition(
|
|
|
485
477
|
return null;
|
|
486
478
|
}
|
|
487
479
|
|
|
480
|
+
|
|
481
|
+
/** Every `Place` an app's layouts declare, first drawing first, each name once
|
|
482
|
+
* (docs/unoverse/design/screen/UNOVERSE_SCREEN.md §2.2). */
|
|
483
|
+
function placesIn(trees: Record<string, unknown>): { name: string; holds: "one" | "many" }[] {
|
|
484
|
+
const out = new Map<string, "one" | "many">();
|
|
485
|
+
const walk = (n: unknown): void => {
|
|
486
|
+
if (Array.isArray(n)) return n.forEach(walk);
|
|
487
|
+
if (!n || typeof n !== "object") return;
|
|
488
|
+
const node = n as Record<string, unknown>;
|
|
489
|
+
if (node.type === "Place" && typeof node.name === "string" && !out.has(node.name))
|
|
490
|
+
out.set(node.name, node.holds === "one" ? "one" : "many");
|
|
491
|
+
for (const key of ["children", "frame", "fallback", "item"]) walk(node[key]);
|
|
492
|
+
if (node.cases && typeof node.cases === "object") Object.values(node.cases).forEach(walk);
|
|
493
|
+
};
|
|
494
|
+
Object.values(trees).forEach(walk);
|
|
495
|
+
return [...out].map(([name, holds]) => ({ name, holds }));
|
|
496
|
+
}
|