@unoverse-platform/studio 0.1.85 → 0.1.87
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/screens/ComponentsView.tsx +4 -3
- package/screens/TemplatesLibraryView.tsx +197 -82
- package/screens/TemplatesView.tsx +49 -8
- package/screens/states.ts +25 -7
- package/src/host.ts +1 -1
- package/vendor/sdk/lib/UnoverseComponent.tsx +49 -3
- package/vendor/sdk/lib/app.tsx +121 -31
- package/vendor/sdk/lib/connection.tsx +34 -33
- package/vendor/sdk/lib/core/actions.ts +2 -2
- package/vendor/sdk/lib/core/apps.ts +31 -18
- package/vendor/sdk/lib/core/conditions.ts +15 -3
- package/vendor/sdk/lib/core/connection.ts +17 -1
- package/vendor/sdk/lib/core/store.ts +104 -21
- package/vendor/sdk/lib/core/types.ts +8 -0
- package/vendor/sdk/lib/transition/MountArrangement.tsx +71 -0
- package/vendor/sdk/lib/transition/decide.ts +53 -0
- package/vendor/sdk/lib/transition/guestHold.ts +34 -0
- package/vendor/sdk/lib/transition/index.ts +9 -0
- package/vendor/sdk/lib/transition/viewTransition.ts +29 -0
- package/vendor/skills/unoverse-create/SKILL.md +11 -5
- package/vendor/skills/unoverse-create/references/app.md +35 -21
- package/vendor/skills/unoverse-create/references/component.md +55 -54
- package/vendor/skills/unoverse-create/references/node.md +3 -1
- package/vendor/skills/unoverse-create/references/template.md +133 -0
|
@@ -94,8 +94,15 @@ export class ComponentStore {
|
|
|
94
94
|
// ComponentSlot.select.where's "most recent state-write wins" ordering (§5b).
|
|
95
95
|
// Generic bookkeeping: core stamps WHEN a slice changed, never reads WHAT. ---
|
|
96
96
|
private touches = new Map<string, number>();
|
|
97
|
-
/** The
|
|
97
|
+
/** The CURRENT DELIVERY's floor — advanced by the settle (applyDirectorVerdict)
|
|
98
|
+
* and the confirmed-empty clear (spendDelivery), NEVER by the guest's send
|
|
99
|
+
* (§12e ruling 2: the replacement is the boundary). */
|
|
98
100
|
private deliveryWatermark = 0;
|
|
101
|
+
/** A judged DIRECTOR_VERDICT's held pointers for the current delivery (null = no
|
|
102
|
+
* verdict — the mount holds the whole delivery). Generic bookkeeping: core stores
|
|
103
|
+
* WHICH slices the sender's verdict held, never why (the judging happened
|
|
104
|
+
* server-side; this only obeys, exactly like COMPONENTS_CLEAR). */
|
|
105
|
+
private directorHeld: Set<string> | null = null;
|
|
99
106
|
/** Slices whose LATEST write was a guest interaction (`mergeComponentState`) — value is
|
|
100
107
|
* that write's touch seq. Powers `pinned` layouts: guest writes move them, arrivals don't. */
|
|
101
108
|
private interactionTouches = new Map<string, number>();
|
|
@@ -129,12 +136,22 @@ export class ComponentStore {
|
|
|
129
136
|
* the ONE home for the reset every boundary uses (new turn, guest close, template
|
|
130
137
|
* swap) — never re-hardcode `"inline"` at a call site.
|
|
131
138
|
*/
|
|
139
|
+
/** THE FACE-WRITE KEY: the spelling this slice already speaks — `state` (the
|
|
140
|
+
* ruled word, 2026-08-29), else the legacy alias it was seeded with. New
|
|
141
|
+
* slices always land on `state`. */
|
|
142
|
+
private faceKey(slice: Record<string, unknown>): "state" | "view" | "defaultState" {
|
|
143
|
+
if ("state" in slice) return "state";
|
|
144
|
+
if ("view" in slice) return "view";
|
|
145
|
+
if ("defaultState" in slice) return "defaultState";
|
|
146
|
+
return "state";
|
|
147
|
+
}
|
|
148
|
+
|
|
132
149
|
private retractSlice(key: string, slice: Record<string, unknown>): boolean {
|
|
133
|
-
const current = (slice.view ?? slice.defaultState) as unknown;
|
|
150
|
+
const current = (slice.state ?? slice.view ?? slice.defaultState) as unknown;
|
|
134
151
|
const base = typeof slice.initialView === "string" ? slice.initialView : "inline";
|
|
135
152
|
if (typeof current !== "string" || current === base) return false;
|
|
136
153
|
const next: Record<string, unknown> = { ...slice };
|
|
137
|
-
|
|
154
|
+
next[this.faceKey(next)] = base;
|
|
138
155
|
if ("defaultState" in next) next.defaultState = base;
|
|
139
156
|
this.data.set(key, next);
|
|
140
157
|
this.touches.set(key, ++this.touchSeq);
|
|
@@ -210,10 +227,10 @@ export class ComponentStore {
|
|
|
210
227
|
for (const [key, slice] of this.data) {
|
|
211
228
|
if (arrivingChatId && key.startsWith(`${arrivingChatId}:`)) continue;
|
|
212
229
|
if (slice.lifetime === "conversation") continue;
|
|
213
|
-
const v = (slice.view ?? slice.defaultState) as unknown;
|
|
230
|
+
const v = (slice.state ?? slice.view ?? slice.defaultState) as unknown;
|
|
214
231
|
if (typeof v !== "string" || !v || v === "inline") continue;
|
|
215
232
|
const next: Record<string, unknown> = { ...slice };
|
|
216
|
-
|
|
233
|
+
next[this.faceKey(next)] = "inline";
|
|
217
234
|
if ("defaultState" in next) next.defaultState = "inline";
|
|
218
235
|
this.data.set(key, next);
|
|
219
236
|
this.touches.set(key, ++this.touchSeq);
|
|
@@ -227,11 +244,17 @@ export class ComponentStore {
|
|
|
227
244
|
let changed = false;
|
|
228
245
|
for (const [key, slice] of this.data) {
|
|
229
246
|
if (key.startsWith(`${arrivingChatId}:`)) continue; // same turn = same delivery
|
|
247
|
+
// A DIRECTOR-HELD slice is under the director's authority (§12e): its
|
|
248
|
+
// replacement belongs to the settle, never to the mechanical refresh — a
|
|
249
|
+
// new batch's first card must not retract the held shelf mid-search (the
|
|
250
|
+
// between-turns flicker, observed live after the durable shield retired).
|
|
251
|
+
// Un-directed apps are untouched: no verdict, no hold, rule 5 as ever.
|
|
252
|
+
if (this.directorHeld?.has(key)) continue;
|
|
230
253
|
if (slice.lifetime === "conversation") continue;
|
|
231
|
-
const v = (slice.view ?? slice.defaultState) as unknown;
|
|
254
|
+
const v = (slice.state ?? slice.view ?? slice.defaultState) as unknown;
|
|
232
255
|
if (v !== view) continue;
|
|
233
256
|
const next: Record<string, unknown> = { ...slice };
|
|
234
|
-
|
|
257
|
+
next[this.faceKey(next)] = "inline";
|
|
235
258
|
if ("defaultState" in next) next.defaultState = "inline";
|
|
236
259
|
this.data.set(key, next);
|
|
237
260
|
this.touches.set(key, ++this.touchSeq);
|
|
@@ -257,8 +280,8 @@ export class ComponentStore {
|
|
|
257
280
|
let changed = false;
|
|
258
281
|
for (const [key, slice] of this.data) {
|
|
259
282
|
if (slice.lifetime === "conversation") continue;
|
|
260
|
-
if (!below.has((slice.view ?? slice.defaultState) as string)) continue;
|
|
261
|
-
this.data.set(key, { ...slice, ...("defaultState" in slice ? { defaultState: "inline" } : {
|
|
283
|
+
if (!below.has((slice.state ?? slice.view ?? slice.defaultState) as string)) continue;
|
|
284
|
+
this.data.set(key, { ...slice, [this.faceKey(slice)]: "inline", ...("defaultState" in slice ? { defaultState: "inline" } : {}) });
|
|
262
285
|
this.touches.set(key, ++this.touchSeq);
|
|
263
286
|
changed = true;
|
|
264
287
|
}
|
|
@@ -331,6 +354,24 @@ export class ComponentStore {
|
|
|
331
354
|
this.bump();
|
|
332
355
|
}
|
|
333
356
|
|
|
357
|
+
/**
|
|
358
|
+
* THE GUEST'S HAND OUTRANKS AN ARRIVAL'S RE-FACE (STATE_MODEL guest-control law,
|
|
359
|
+
* TEMPLATE_MODEL §6b rule 3: the director — and its deliveries — sit UNDER the
|
|
360
|
+
* guest's hand). Once the slice's LATEST write is the guest's own (an open Detail
|
|
361
|
+
* View), an inbound payload keeps its content but loses its face words: a
|
|
362
|
+
* re-delivered card must refresh WHAT the guest is reading, never yank them out
|
|
363
|
+
* of it (observed live 2026-08-29: a broad follow-up re-served the open card,
|
|
364
|
+
* its seeded `view: "grid"` overwrote the guest's `page`, the ladder fell to
|
|
365
|
+
* grid). The guest releases the face themselves (✕); from then on the server
|
|
366
|
+
* writes it again as ever.
|
|
367
|
+
*/
|
|
368
|
+
private shieldGuestFace(key: string, payload: Record<string, unknown>): Record<string, unknown> {
|
|
369
|
+
if (!("state" in payload) && !("view" in payload) && !("defaultState" in payload)) return payload;
|
|
370
|
+
if ((this.interactionTouches.get(key) ?? -1) < (this.touches.get(key) ?? 0)) return payload;
|
|
371
|
+
const { state: _s, view: _v, defaultState: _d, ...content } = payload;
|
|
372
|
+
return content;
|
|
373
|
+
}
|
|
374
|
+
|
|
334
375
|
/** Apply a streamed event (COMPONENT_INIT or COMPONENT_DATA). */
|
|
335
376
|
apply(event: UnoverseEvent): void {
|
|
336
377
|
const key = `${event.chatId}:${event.nodeId}`;
|
|
@@ -338,10 +379,10 @@ export class ComponentStore {
|
|
|
338
379
|
this.types.set(key, event.component.type);
|
|
339
380
|
if (event.component.componentUrl) this.urls.set(key, event.component.componentUrl);
|
|
340
381
|
this.placeInTimeline(event.chatId, key); // structure: place the pointer ONCE
|
|
341
|
-
this.merge(key, event.component.props ?? {}); // data: seed
|
|
382
|
+
this.merge(key, this.shieldGuestFace(key, event.component.props ?? {})); // data: seed
|
|
342
383
|
// Rule 5 — an arrival carrying a public state refreshes that state.
|
|
343
384
|
const seeded = event.component.props as Record<string, unknown> | undefined;
|
|
344
|
-
this.supersede(event.chatId, seeded?.view ?? seeded?.defaultState);
|
|
385
|
+
this.supersede(event.chatId, seeded?.state ?? seeded?.view ?? seeded?.defaultState);
|
|
345
386
|
} else {
|
|
346
387
|
// NEW ACTIVITY reads newest-at-the-bottom: if this component is no longer the
|
|
347
388
|
// LAST pointer in its turn (something was placed after it), fresh server data
|
|
@@ -354,10 +395,10 @@ export class ComponentStore {
|
|
|
354
395
|
resp.components.push(key);
|
|
355
396
|
this.bump();
|
|
356
397
|
}
|
|
357
|
-
this.merge(key, event.data);
|
|
398
|
+
this.merge(key, this.shieldGuestFace(key, event.data));
|
|
358
399
|
// Rule 5 — a server data-write that PLACES a component into a public state
|
|
359
400
|
// (the host's arrival placement rides COMPONENT_DATA) refreshes that state.
|
|
360
|
-
this.supersede(event.chatId, (event.data as Record<string, unknown> | undefined)?.view ?? (event.data as Record<string, unknown> | undefined)?.defaultState);
|
|
401
|
+
this.supersede(event.chatId, (event.data as Record<string, unknown> | undefined)?.state ?? (event.data as Record<string, unknown> | undefined)?.view ?? (event.data as Record<string, unknown> | undefined)?.defaultState);
|
|
361
402
|
}
|
|
362
403
|
}
|
|
363
404
|
|
|
@@ -370,9 +411,11 @@ export class ComponentStore {
|
|
|
370
411
|
* guest's next message; a stale rail is cleared by the cards that replace it,
|
|
371
412
|
* never by the message that asked for them. */
|
|
372
413
|
addUserMessage(chatId: string, text: string): void {
|
|
373
|
-
//
|
|
374
|
-
//
|
|
375
|
-
|
|
414
|
+
// RULE 7 RESTORED (UNOVERSE_TEMPLATE_MODEL §12e ruling 2): typing changes
|
|
415
|
+
// NOTHING. The delivery boundary is the REPLACEMENT — the judged batch's
|
|
416
|
+
// settle (applyDirectorVerdict) or the confirmed-empty clear (spendDelivery)
|
|
417
|
+
// — never the message that asked for it. The held set stays on screen
|
|
418
|
+
// through the whole search.
|
|
376
419
|
this.timeline.push({ role: "user", id: `u${this.seq++}`, chatId, text, createdAt: Date.now() });
|
|
377
420
|
this.evict();
|
|
378
421
|
this.bump();
|
|
@@ -564,11 +607,10 @@ export class ComponentStore {
|
|
|
564
607
|
getTimeline(): readonly Turn[] {
|
|
565
608
|
return this.timeline;
|
|
566
609
|
}
|
|
567
|
-
/** THE CURRENT DELIVERY (hold: latest
|
|
568
|
-
* the pointers placed since the
|
|
569
|
-
* these
|
|
570
|
-
*
|
|
571
|
-
* delivery's. */
|
|
610
|
+
/** THE CURRENT DELIVERY (hold: latest — UNOVERSE_TEMPLATE_MODEL §9b, boundary per
|
|
611
|
+
* §12e): the pointers placed since the last SETTLE or CLEAR. A mounted template
|
|
612
|
+
* holds only these; the set survives the guest's next send and is spent exactly
|
|
613
|
+
* when its replacement arrives (or emptiness is confirmed) — never sooner. */
|
|
572
614
|
currentDeliveryPointers(): Set<string> {
|
|
573
615
|
// TOUCH-BASED, not turn-based: durable-keyed instances (conversation-chatId
|
|
574
616
|
// renders) file into ONE assistant turn across the whole conversation, so turn
|
|
@@ -581,6 +623,47 @@ export class ComponentStore {
|
|
|
581
623
|
if ((this.touches.get(p) ?? 0) > this.deliveryWatermark) out.add(p);
|
|
582
624
|
return out;
|
|
583
625
|
}
|
|
626
|
+
/** THE DIRECTOR'S HOLD (UNOVERSE_TEMPLATE_MODEL §7): the verdict's pointers that are
|
|
627
|
+
* still in the given delivery. null = no verdict, or nothing it held survives — the
|
|
628
|
+
* caller falls back to the whole delivery (fail open, §6c rule 9: the screen never
|
|
629
|
+
* pays for a stale or broken verdict). */
|
|
630
|
+
directorScope(delivery: Set<string>): Set<string> | null {
|
|
631
|
+
if (!this.directorHeld) return null;
|
|
632
|
+
const out = new Set<string>();
|
|
633
|
+
for (const p of this.directorHeld) if (delivery.has(p)) out.add(p);
|
|
634
|
+
return out.size ? out : null;
|
|
635
|
+
}
|
|
636
|
+
/** Apply a judged DIRECTOR_VERDICT: these instances are the delivery's held set, in
|
|
637
|
+
* this order. Each item carries the chatId it was DELIVERED under (the turn's, or
|
|
638
|
+
* the conversation's for durable renders) so the pointer is exact. ORDER IS RECENCY
|
|
639
|
+
* — the strongest is re-stamped LAST so the existing touch-desc sort draws it
|
|
640
|
+
* first: the platform's one ordering word, no new machinery. Only slices that
|
|
641
|
+
* exist are stamped; unknown ids are ignored (selection only — a verdict can
|
|
642
|
+
* never invent an instance). */
|
|
643
|
+
applyDirectorVerdict(items: Array<{ chatId: string; nodeId: string }>): void {
|
|
644
|
+
// THE SETTLE IS THE DELIVERY BOUNDARY (§12e ruling 2): spend everything
|
|
645
|
+
// older, then stamp the held set above the new line — one atomic replace,
|
|
646
|
+
// so the previous cards survive the whole search and leave exactly when
|
|
647
|
+
// their replacement arrives.
|
|
648
|
+
this.deliveryWatermark = this.touchSeq;
|
|
649
|
+
const held = new Set<string>();
|
|
650
|
+
for (let i = items.length - 1; i >= 0; i--) {
|
|
651
|
+
const p = `${items[i].chatId}:${items[i].nodeId}`;
|
|
652
|
+
if (!this.data.has(p)) continue;
|
|
653
|
+
held.add(p);
|
|
654
|
+
this.touches.set(p, ++this.touchSeq);
|
|
655
|
+
}
|
|
656
|
+
this.directorHeld = held.size ? held : null;
|
|
657
|
+
this.bump();
|
|
658
|
+
}
|
|
659
|
+
/** The confirmed-empty boundary (COMPONENTS_CLEAR): the run delivered nothing,
|
|
660
|
+
* so the current set is spent with nothing replacing it — the mount collapses.
|
|
661
|
+
* Same spend as the settle, without a new held set. */
|
|
662
|
+
spendDelivery(): void {
|
|
663
|
+
this.deliveryWatermark = this.touchSeq;
|
|
664
|
+
this.directorHeld = null;
|
|
665
|
+
this.bump();
|
|
666
|
+
}
|
|
584
667
|
/** Just the assistant responses, in order. */
|
|
585
668
|
getResponses(): AssistantTurn[] {
|
|
586
669
|
return this.timeline.filter((t): t is AssistantTurn => t.role === "assistant");
|
|
@@ -208,6 +208,14 @@ export interface UnoverseDefinition {
|
|
|
208
208
|
* ladder and arrival-scan order, served from the manifest. First entry outranks
|
|
209
209
|
* all below it; no name is special. Absent → legacy v1 recency semantics. */
|
|
210
210
|
stateOrder?: string[];
|
|
211
|
+
/** THE LATCH, PER STATE (docs/MCP_COMPLETE_GUIDE.md §The Component Latch, moved off the
|
|
212
|
+
* manifest 2026-08-31): the states whose wearer the conversation may be ADDRESSED AT,
|
|
213
|
+
* keyed by state name, compiled from each state's own `latch:` block.
|
|
214
|
+
*
|
|
215
|
+
* A state absent from this map can never hold the latch, so the SAME instance retracted
|
|
216
|
+
* from its page state to an inline one drops the pill by construction. `resolveLatch`
|
|
217
|
+
* reads this; nothing about the latch travels on the wire with a render. */
|
|
218
|
+
latches?: Record<string, { title: string; icon?: string }>;
|
|
211
219
|
}
|
|
212
220
|
|
|
213
221
|
// ---- The interaction vocabulary (UNOVERSE_SPEC.md §2e-3) ----
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The double-buffered mount (UNOVERSE_TEMPLATE_MODEL §13.3): renders its LAST
|
|
3
|
+
* COMMITTED arrangement and commits the next one only when the decision allows —
|
|
4
|
+
* so a data ripple can never move the shelf, and the boundary commit is the one
|
|
5
|
+
* designed (and, with theme motion, animated) change. Content inside held
|
|
6
|
+
* instances stays live automatically: the render closure reads slices from the
|
|
7
|
+
* store at render time; only membership and order are buffered here.
|
|
8
|
+
*
|
|
9
|
+
* Fail open (§13.5): a "hold" with no boundary inside the grace window commits
|
|
10
|
+
* anyway — the screen never pays for machinery failure.
|
|
11
|
+
*
|
|
12
|
+
* RENDER-LAYER state, deliberately: the portable core stays frozen and "no UI
|
|
13
|
+
* state in the store" holds.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import React, { useEffect, useRef, useState } from "react";
|
|
17
|
+
import type { ReactNode } from "react";
|
|
18
|
+
import { decideCommit, type Arrangement } from "./decide";
|
|
19
|
+
import { commitArrangement } from "./viewTransition";
|
|
20
|
+
|
|
21
|
+
/** A held-back change commits anyway after this long without a boundary. */
|
|
22
|
+
const GRACE_MS = 4000;
|
|
23
|
+
|
|
24
|
+
export function MountArrangement({
|
|
25
|
+
next,
|
|
26
|
+
directed,
|
|
27
|
+
motion,
|
|
28
|
+
render,
|
|
29
|
+
}: {
|
|
30
|
+
/** The arrangement the store derives RIGHT NOW. */
|
|
31
|
+
next: Arrangement;
|
|
32
|
+
/** The placement grants a director — every delivery settles, so membership
|
|
33
|
+
* waits for the settle (decide.ts). */
|
|
34
|
+
directed: boolean;
|
|
35
|
+
/** Theme-requested motion (§13.4) — absent theme word = instant commits. */
|
|
36
|
+
motion: boolean;
|
|
37
|
+
/** Build the tree for an arrangement; slices are read live inside. */
|
|
38
|
+
render: (arrangement: Arrangement) => ReactNode;
|
|
39
|
+
}) {
|
|
40
|
+
const [committed, setCommitted] = useState<Arrangement | null>(null);
|
|
41
|
+
const graceTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
42
|
+
const decision = decideCommit(committed, next, directed);
|
|
43
|
+
|
|
44
|
+
useEffect(() => {
|
|
45
|
+
if (decision === "none") return;
|
|
46
|
+
if (decision === "hold") {
|
|
47
|
+
if (!graceTimer.current) {
|
|
48
|
+
graceTimer.current = setTimeout(() => {
|
|
49
|
+
graceTimer.current = null;
|
|
50
|
+
setCommitted(next);
|
|
51
|
+
}, GRACE_MS);
|
|
52
|
+
}
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
if (graceTimer.current) {
|
|
56
|
+
clearTimeout(graceTimer.current);
|
|
57
|
+
graceTimer.current = null;
|
|
58
|
+
}
|
|
59
|
+
if (decision === "transition") commitArrangement(() => setCommitted(next), motion);
|
|
60
|
+
else setCommitted(next);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
useEffect(
|
|
64
|
+
() => () => {
|
|
65
|
+
if (graceTimer.current) clearTimeout(graceTimer.current);
|
|
66
|
+
},
|
|
67
|
+
[],
|
|
68
|
+
);
|
|
69
|
+
|
|
70
|
+
return <>{render(committed ?? next)}</>;
|
|
71
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The commit decision — the Transition Model's pure heart (UNOVERSE_TEMPLATE_MODEL §13).
|
|
3
|
+
*
|
|
4
|
+
* The mount double-buffers its arrangement and asks ONE question per render:
|
|
5
|
+
* may the committed arrangement change now? The answer depends only on the two
|
|
6
|
+
* arrangements — no store, no DOM, no time — so the contract is a pure function
|
|
7
|
+
* under conformance tests and can never regress silently.
|
|
8
|
+
*
|
|
9
|
+
* - "immediate": commit with no ceremony (first paint; the guest's own
|
|
10
|
+
* navigation changed the layout; an un-directed mount — no
|
|
11
|
+
* verdict governs it, so its behaviour is exactly pre-contract).
|
|
12
|
+
* - "transition": a BOUNDARY happened — the governing verdict changed (settle
|
|
13
|
+
* or clear) — commit as the one designed, animatable change.
|
|
14
|
+
* - "hold": membership moved with no boundary (a data ripple). The shelf
|
|
15
|
+
* does not move. The caller owns the fail-open grace (§13.5).
|
|
16
|
+
* - "none": nothing changed.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
export interface Arrangement {
|
|
20
|
+
/** The template's active layout (shape) name. */
|
|
21
|
+
layoutName: string | null;
|
|
22
|
+
/** Held pointers, in display order. */
|
|
23
|
+
held: string[];
|
|
24
|
+
/** Identity of the governing verdict (sorted held-pointer join), null when no
|
|
25
|
+
* verdict governs — the un-directed mount. */
|
|
26
|
+
verdictSig: string | null;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export type CommitDecision = "immediate" | "transition" | "hold" | "none";
|
|
30
|
+
|
|
31
|
+
const sameHeld = (a: string[], b: string[]) => a.length === b.length && a.every((p, i) => p === b[i]);
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* `directed` = the placement grants a director, so every delivery WILL settle.
|
|
35
|
+
* A directed mount's membership therefore moves only at the settle — including
|
|
36
|
+
* the very first batch, whose cards arrive as separate messages before any
|
|
37
|
+
* verdict exists (committing each arrival painted the shelf in six pops, the
|
|
38
|
+
* first-appearance flicker observed live). Un-directed mounts keep committing
|
|
39
|
+
* immediately: no settle is ever coming for them.
|
|
40
|
+
*/
|
|
41
|
+
export function decideCommit(committed: Arrangement | null, next: Arrangement, directed: boolean): CommitDecision {
|
|
42
|
+
if (!committed) return "immediate";
|
|
43
|
+
// The guest's own navigation (name-sync layout change) is never held back.
|
|
44
|
+
if (next.layoutName !== committed.layoutName) return "immediate";
|
|
45
|
+
if (sameHeld(next.held, committed.held)) return "none";
|
|
46
|
+
// Membership moved. A changed governing verdict IS the boundary (settle/clear).
|
|
47
|
+
if (next.verdictSig !== committed.verdictSig) return "transition";
|
|
48
|
+
// Directed: the settle is coming — hold until it lands (grace fails open).
|
|
49
|
+
if (directed) return "hold";
|
|
50
|
+
// No verdict governs an un-directed mount: commit as it always did.
|
|
51
|
+
if (next.verdictSig === null) return "immediate";
|
|
52
|
+
return "hold";
|
|
53
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* THE GUEST'S HAND OUTRANKS THE DIRECTOR (UNOVERSE_TEMPLATE_MODEL §6b rule 3,
|
|
3
|
+
* state priority): a pointer whose LATEST write is the guest's own interaction,
|
|
4
|
+
* holding a non-base face the mount claims (an open Detail View), stays in the
|
|
5
|
+
* mount's scope regardless of deliveries and settles. Observed live without
|
|
6
|
+
* this: a new batch settled and yanked the guest out of an open detail page.
|
|
7
|
+
*
|
|
8
|
+
* The hold releases exactly as the model says: the guest's ✕ writes the face
|
|
9
|
+
* back to the base — no longer a non-base guest hold — and the current
|
|
10
|
+
* arrangement takes over. A re-delivered instance is an ARRIVAL (its merge
|
|
11
|
+
* out-touches the guest write) and may be re-faced, per §4 level 2.
|
|
12
|
+
*
|
|
13
|
+
* Pure over reads so the priority law is conformance-testable.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
export interface GuestHoldReads {
|
|
17
|
+
touchOf(p: string): number;
|
|
18
|
+
interactionTouchOf(p: string): number;
|
|
19
|
+
viewOf(p: string): string | undefined;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function guestHeldPointers(
|
|
23
|
+
pointers: readonly string[],
|
|
24
|
+
reads: GuestHoldReads,
|
|
25
|
+
claimedLayouts: readonly string[],
|
|
26
|
+
baseLayout: string | null,
|
|
27
|
+
): string[] {
|
|
28
|
+
return pointers.filter((p) => {
|
|
29
|
+
const it = reads.interactionTouchOf(p);
|
|
30
|
+
if (!it || it < reads.touchOf(p)) return false; // latest write is not the guest's
|
|
31
|
+
const v = reads.viewOf(p);
|
|
32
|
+
return !!v && v !== baseLayout && claimedLayouts.includes(v);
|
|
33
|
+
});
|
|
34
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Transition Model (UNOVERSE_TEMPLATE_MODEL §13) — the render layer's
|
|
3
|
+
* designed way of moving interfaces in and out of a mounted template:
|
|
4
|
+
* `decide.ts` (the pure commit decision), `MountArrangement.tsx` (the
|
|
5
|
+
* double-buffer), `viewTransition.ts` (the theme-gated animated commit).
|
|
6
|
+
*/
|
|
7
|
+
export { decideCommit, type Arrangement, type CommitDecision } from "./decide";
|
|
8
|
+
export { MountArrangement } from "./MountArrangement";
|
|
9
|
+
export { commitArrangement } from "./viewTransition";
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The commit IS the animation (UNOVERSE_TEMPLATE_MODEL §13.4): when the theme
|
|
3
|
+
* asks for motion, the boundary commit is wrapped in the browser's View
|
|
4
|
+
* Transitions API — the browser diffs old and new and animates; the THEME's CSS
|
|
5
|
+
* owns every duration and ease. The SDK contributes only this generic behaviour
|
|
6
|
+
* and NO styles. Engines without the API, and themes that author no motion, get
|
|
7
|
+
* the instant commit — today's behaviour, gracefully.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { flushSync } from "react-dom";
|
|
11
|
+
|
|
12
|
+
type WithViewTransition = { startViewTransition?: (cb: () => void) => unknown };
|
|
13
|
+
|
|
14
|
+
export function commitArrangement(commit: () => void, motion: boolean): void {
|
|
15
|
+
const doc = typeof document !== "undefined" ? (document as unknown as WithViewTransition) : undefined;
|
|
16
|
+
if (!motion || typeof doc?.startViewTransition !== "function") {
|
|
17
|
+
commit();
|
|
18
|
+
return;
|
|
19
|
+
}
|
|
20
|
+
// From a microtask, outside React's lifecycle, so flushSync is legal: the
|
|
21
|
+
// API snapshots, the commit flushes the new DOM, the browser animates.
|
|
22
|
+
queueMicrotask(() => {
|
|
23
|
+
try {
|
|
24
|
+
doc.startViewTransition!(() => flushSync(commit));
|
|
25
|
+
} catch {
|
|
26
|
+
commit(); // fail open — the change lands even if the ceremony breaks
|
|
27
|
+
}
|
|
28
|
+
});
|
|
29
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: unoverse-create
|
|
3
|
-
description: "Create or edit Unoverse platform artifacts: UI components (including briefed/AI-filled components), apps, atoms and styles (design/), brand and org packs (themes, tokens, rebranding, cloning a universe for a new client), agent skills and prompt blocks (prompts/), custom workflow nodes (nodes/), and workflows built live on the Canvas via the builder MCP. Use whenever the user wants to create, add, build, or modify a component, app, card, page, theme, brand, org pack, agent skill, prompt block, custom node, or workflow in an Unoverse project, even if they don't say 'Unoverse' (they'll describe UI like 'a card with a button and an image' or 'a page the AI fills in')."
|
|
3
|
+
description: "Create or edit Unoverse platform artifacts: UI components (including briefed/AI-filled components), apps, templates (arrangements with open parts a delivery fills — shelf grids, emails, comparison pages), atoms and styles (design/), brand and org packs (themes, tokens, rebranding, cloning a universe for a new client), agent skills and prompt blocks (prompts/), custom workflow nodes (nodes/), and workflows built live on the Canvas via the builder MCP. Use whenever the user wants to create, add, build, or modify a component, app, card, page, theme, brand, org pack, agent skill, prompt block, custom node, or workflow in an Unoverse project, even if they don't say 'Unoverse' (they'll describe UI like 'a card with a button and an image' or 'a page the AI fills in')."
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
# Creating Unoverse Artifacts
|
|
@@ -30,8 +30,10 @@ that is the workspace folder itself; in the platform monorepo it is `apps/unover
|
|
|
30
30
|
([components](https://github.com/unoverse-platform/marketplace/tree/main/definitions/components), [atoms](https://github.com/unoverse-platform/marketplace/tree/main/definitions/atoms), [styles](https://github.com/unoverse-platform/marketplace/tree/main/definitions/styles)).
|
|
31
31
|
Study the closest exemplar there, then compose those atoms and reference those
|
|
32
32
|
components; never hand-roll a shape the base already ships. Everything is plain
|
|
33
|
-
YAML files
|
|
33
|
+
YAML files: no connection, no running platform needed. (In the platform monorepo
|
|
34
34
|
the same material is on disk at `design/marketplace/`.)
|
|
35
|
+
[The design system](https://docs.unoverse.ai/design/design-system.md) is the guide to
|
|
36
|
+
what ships and how to use it.
|
|
35
37
|
|
|
36
38
|
## Step 1: Identify the artifact, read its playbook
|
|
37
39
|
|
|
@@ -42,6 +44,7 @@ anything**:
|
|
|
42
44
|
|---|---|
|
|
43
45
|
| A UI component or shared atom, incl. a page/card the AI fills with content (briefed) | `references/component.md` (§Briefs for AI-filled) |
|
|
44
46
|
| An app (chat surface, wizard shell) | `references/app.md` |
|
|
47
|
+
| A template: an arrangement with open parts a delivery fills (a shelf grid, an email, a comparison page) | `references/template.md` |
|
|
45
48
|
| A style / theme / token change on ONE component | `references/component.md` §Tokens |
|
|
46
49
|
| A brand: an org pack, a rebrand, a new client universe, cloning an org | `references/brand.md` |
|
|
47
50
|
| An agent skill (behavior guide the AI follows) | `references/agent-skill.md` |
|
|
@@ -59,15 +62,18 @@ If it's ambiguous (e.g. "add a card that shows weather"), it's usually a **compo
|
|
|
59
62
|
**New to design artifacts?** The guided learning journey at
|
|
60
63
|
[docs.unoverse.ai/design](https://docs.unoverse.ai/design/overview.md)
|
|
61
64
|
([quick-start](https://docs.unoverse.ai/design/quick-start.md) →
|
|
65
|
+
[essentials](https://docs.unoverse.ai/design/essentials.md) →
|
|
66
|
+
[interface-data](https://docs.unoverse.ai/design/interface-data.md) →
|
|
62
67
|
[components](https://docs.unoverse.ai/design/components.md) →
|
|
63
68
|
[state](https://docs.unoverse.ai/design/state.md) →
|
|
69
|
+
[templates](https://docs.unoverse.ai/design/templates.md) →
|
|
64
70
|
[apps](https://docs.unoverse.ai/design/apps.md) →
|
|
65
71
|
[styles-and-tokens](https://docs.unoverse.ai/design/styles-and-tokens.md) →
|
|
66
72
|
[studio](https://docs.unoverse.ai/design/studio.md) →
|
|
67
73
|
[validate-and-ship](https://docs.unoverse.ai/design/validate-and-ship.md) →
|
|
68
|
-
[troubleshooting](https://docs.unoverse.ai/design/troubleshooting.md)) teaches
|
|
69
|
-
|
|
70
|
-
|
|
74
|
+
[troubleshooting](https://docs.unoverse.ai/design/troubleshooting.md)) teaches the
|
|
75
|
+
shared grammar every artifact uses, the four kinds, the state model (incl. locked
|
|
76
|
+
voice/native state), tokens, **studio**, and validation in order;
|
|
71
77
|
[design/CLAUDE.md](https://docs.unoverse.ai/design/CLAUDE.md) is the condensed agent
|
|
72
78
|
rulebook. The playbooks here assume that material. Append `.md` to any docs URL to
|
|
73
79
|
fetch it as raw markdown.
|
|
@@ -16,15 +16,16 @@ already in the workspace. (Monorepo: `design/sab/apps/sab-chat`.)
|
|
|
16
16
|
A component is a state tree whose public states are its whole interface; an app
|
|
17
17
|
declares its OWN state tree in the manifest and reacts to components by name:
|
|
18
18
|
|
|
19
|
-
> A component enters a public
|
|
19
|
+
> A component enters a public state → the app asks "do I have a state with that
|
|
20
20
|
> name?" Match: the app enters that state and its layout draws. No match: the
|
|
21
21
|
> component renders inline in the flow. Always.
|
|
22
22
|
|
|
23
23
|
Derived off the store, never stored, no wiring. The component never knows apps
|
|
24
24
|
exist, and post-spawn the app NEVER writes a component's state. Name-match is the
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
25
|
+
ONLY mechanism: an app state reacts because its name matches, so give the app the
|
|
26
|
+
name the component writes. (`reactsTo:` is DESIGN-ONLY: nothing in the runtime
|
|
27
|
+
reads it, so never author it.) **Rule of thumb: component causes it = a reaction
|
|
28
|
+
state; app knows it = a base substate or app state.**
|
|
28
29
|
|
|
29
30
|
## The manifest states tree: one declaration, everything follows
|
|
30
31
|
|
|
@@ -33,13 +34,21 @@ The canonical shape (a chat layout: a base arrangement plus a reaction ladder):
|
|
|
33
34
|
```yaml
|
|
34
35
|
states:
|
|
35
36
|
main: # the base arrangement, FIRST
|
|
37
|
+
layout: layouts/main
|
|
36
38
|
states:
|
|
37
|
-
welcome:
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
39
|
+
welcome: # contained substate; first substate = the landing default
|
|
40
|
+
layout: layouts/main-welcome
|
|
41
|
+
focus: # then the reaction ladder, in priority order
|
|
42
|
+
layout: layouts/focus
|
|
43
|
+
products:
|
|
44
|
+
layout: layouts/products
|
|
45
|
+
detail:
|
|
46
|
+
layout: layouts/detail
|
|
41
47
|
```
|
|
42
48
|
|
|
49
|
+
Every state DECLARES its drawing as a path relative to the app folder (2026-08-29
|
|
50
|
+
ruling): no same-name default, no assumed folder, `{}` is not a complete state.
|
|
51
|
+
|
|
43
52
|
- **Top-level order IS the priority ladder**, base first. The app can be in ONE
|
|
44
53
|
state at a time: it walks the list top-down and enters the first of its states that
|
|
45
54
|
ANY hosted component currently matches. The ladder is DERIVED from the tree; never
|
|
@@ -59,19 +68,22 @@ states:
|
|
|
59
68
|
- **A higher state CANCELS the claims below it**: while active, hosted components in
|
|
60
69
|
lower-ranked states retract to inline (durable `lifetime: "conversation"` slices opt
|
|
61
70
|
out), so releasing it lands on the BASE arrangement, never a stale lower one.
|
|
62
|
-
- Ties (two components in the same
|
|
71
|
+
- Ties (two components in the same state): most recent write wins (`limit: 1`).
|
|
63
72
|
- **No word is special.** `focus` outranks `products` by list position only; the engine
|
|
64
73
|
matches names and walks the list. Even priority is data.
|
|
65
74
|
- The app's own condition-guarded moods (a welcome hero on an empty conversation)
|
|
66
75
|
are private substates of the base, never ladder entries: the app does not
|
|
67
76
|
rearrange between them.
|
|
68
77
|
|
|
69
|
-
## The anatomy:
|
|
78
|
+
## The anatomy: ONE FOLDER GRAMMAR (2026-08-29 — apps match components/templates)
|
|
70
79
|
|
|
71
80
|
```
|
|
72
81
|
design/<project>/apps/<name>/
|
|
73
|
-
|
|
74
|
-
|
|
82
|
+
<name>.yaml # THE ENVELOPE: type: app + the states tree (what the app IS);
|
|
83
|
+
# each state declares its layout path and carries its preview
|
|
84
|
+
manifest.yaml # THE MCP FACE: description/whenToUse/inputSchema/binding/
|
|
85
|
+
# makeMCP/assetDomains — how the outside finds and calls it
|
|
86
|
+
layouts/ # the ARRANGEMENTS: one per state, each DECLARED by path in the tree
|
|
75
87
|
main.yaml # the base: the core alone
|
|
76
88
|
products.yaml # state "products" → core + cards rail
|
|
77
89
|
detail.yaml # state "detail" → core + full record page
|
|
@@ -84,7 +96,7 @@ design/<project>/apps/<name>/
|
|
|
84
96
|
Each layout = a complete arrangement: `$include: components/core` + that state's
|
|
85
97
|
surface. Shared chrome lives ONCE in `components/`; every layout includes it. The tree
|
|
86
98
|
claims the instance, the layout's surface renders it: a reaction state's layout holds a
|
|
87
|
-
ComponentSlot selecting the matching
|
|
99
|
+
ComponentSlot selecting the matching state.
|
|
88
100
|
|
|
89
101
|
## manifest.yaml (the app contract)
|
|
90
102
|
|
|
@@ -121,27 +133,29 @@ ComponentSlot. Lint-enforced:
|
|
|
121
133
|
## Surfaces: apps REACT, they never own
|
|
122
134
|
|
|
123
135
|
One instance → one placeholder: the flow is the placeholder for `inline`; a reaction
|
|
124
|
-
state's layout (via its inner surface) is the placeholder for a matched
|
|
136
|
+
state's layout (via its inner surface) is the placeholder for a matched state. The SDK
|
|
125
137
|
keeps a claimed instance out of the flow; never hide a flow copy yourself.
|
|
126
138
|
|
|
127
139
|
- **Flow slot**: `{ type: ComponentSlot, select: {} }`, always present.
|
|
128
|
-
- **Reaction surface**: select by the public `
|
|
140
|
+
- **Reaction surface**: select by the public `state`, never by type/id:
|
|
129
141
|
```yaml
|
|
130
142
|
type: ComponentSlot
|
|
131
143
|
appWidth: panel
|
|
132
144
|
select:
|
|
133
145
|
from: all
|
|
134
|
-
where:
|
|
146
|
+
where:
|
|
147
|
+
field: state
|
|
148
|
+
eq: page
|
|
135
149
|
limit: 1
|
|
136
150
|
frame: # chrome; an inner bare ComponentSlot marks the placement
|
|
137
151
|
```
|
|
138
|
-
❌ `select: { type: [...] }`, ❌ `where.field` ≠ `
|
|
139
|
-
writing a component's slice, all lint-flagged. (`defaultState`
|
|
140
|
-
|
|
152
|
+
❌ `select: { type: [...] }`, ❌ `where.field` ≠ `state`, ❌ app chrome
|
|
153
|
+
writing a component's slice, all lint-flagged. (`view` and `defaultState` are the
|
|
154
|
+
legacy spellings of the select field.)
|
|
141
155
|
- **Input tools are app chrome** (unchanged rule): a composer, an edit form, a
|
|
142
156
|
picker is owned by the app, never a streamed component state; chrome never
|
|
143
157
|
"arrives". A card's Edit stages its data into the app's one input tool.
|
|
144
|
-
- **App state** (`
|
|
158
|
+
- **App state** (`setAppValue`) is ONLY for the app's own chrome (a
|
|
145
159
|
disclosure, the composer `draft`). "Is something focused" is DERIVED, never stored.
|
|
146
160
|
- **Chrome reacts via `surfacedView`** (SDK-projected active view name, `""` = all
|
|
147
161
|
inline): `visibleWhen { field: surfacedView, in: ["", "products"] }`.
|
|
@@ -180,5 +194,5 @@ whole, see brand.md). Addresses: apps
|
|
|
180
194
|
4. **See it**: in Studio, the state switcher reads the tree (base first, ladder order),
|
|
181
195
|
base substates beneath, freely combinable; acting inside the preview transitions
|
|
182
196
|
like the runtime. Then run it live: the bound workflow streams components, a card
|
|
183
|
-
entering a public
|
|
197
|
+
entering a public state walks the ladder and rearranges the app end-to-end,
|
|
184
198
|
and its ✕ hands the lower state back.
|