@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.
@@ -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 touch sequence at the guest's last send — the CURRENT DELIVERY's floor. */
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
- if ("view" in next || !("defaultState" in next)) next.view = base;
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
- if ("view" in next || !("defaultState" in next)) next.view = "inline";
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
- if ("view" in next || !("defaultState" in next)) next.view = "inline";
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" } : { view: "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
- // The guest's send is the delivery boundary (hold: latest, basic form): every
374
- // pointer not server-touched after this moment leaves the current set.
375
- this.deliveryWatermark = this.touchSeq;
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, basic form — UNOVERSE_TEMPLATE_MODEL §9b):
568
- * the pointers placed since the guest's LAST SEND. A mounted template holds only
569
- * these, so the guest's own send clears the set — nothing stale ever survives a
570
- * turn, and the disappearance happens at the guest's action, never a background
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 — no connection, no running platform needed. (In the platform monorepo
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
- components, apps, the state model (incl. locked voice/native state), tokens,
70
- Studio, and validation in order;
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 view → the app asks "do I have a state with that
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
- default convention (`reactsTo:` on an app state is the rare escape hatch for
26
- vocabulary mismatches). **Rule of thumb: component causes it = a reaction state;
27
- app knows it = a base substate or app state.**
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: {} # contained substate; first substate = the landing default
38
- focus: {} # then the reaction ladder, in priority order
39
- products: {}
40
- detail: {}
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 view): most recent write wins (`limit: 1`).
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: manifest-only (NO `<name>.yaml` envelope)
78
+ ## The anatomy: ONE FOLDER GRAMMAR (2026-08-29 — apps match components/templates)
70
79
 
71
80
  ```
72
81
  design/<project>/apps/<name>/
73
- manifest.yaml # THE ENVELOPE: the whole app contract, incl. the states tree
74
- layouts/ # the ARRANGEMENTS: one per state that owns one (same-name default)
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 view.
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 view. The SDK
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 `view`, never by type/id:
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: { field: view, eq: detail }
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` ≠ `view`, ❌ app chrome
139
- writing a component's slice, all lint-flagged. (`defaultState` as the select field
140
- is the v1 legacy spelling.)
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** (`setTemplateValue`) is ONLY for the app's own chrome (a
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 view walks the ladder and rearranges the app end-to-end,
197
+ entering a public state walks the ladder and rearranges the app end-to-end,
184
198
  and its ✕ hands the lower state back.