@unoverse-platform/studio 0.1.55 → 0.1.57

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.
@@ -0,0 +1,40 @@
1
+ type: Box
2
+ visibleWhen: active
3
+ style:
4
+ direction: row
5
+ gap: "2"
6
+ align: start
7
+ children:
8
+ - type: Box
9
+ style:
10
+ direction: column
11
+ flex: 1
12
+ gap: "2"
13
+ minWidth: 0
14
+ children:
15
+ - type: ComponentSlot
16
+ select: {}
17
+ - type: Box
18
+ visibleWhen: streaming
19
+ children:
20
+ - type: Box
21
+ visibleWhen: empty
22
+ style:
23
+ direction: row
24
+ gap: "2"
25
+ align: center
26
+ children:
27
+ - $include: components/thinking-dots
28
+ - type: Text
29
+ bind:
30
+ value: thinkingText
31
+ style:
32
+ font: body.sm
33
+ color: text.secondary
34
+ - type: Text
35
+ visibleWhen: time
36
+ bind:
37
+ value: time
38
+ style:
39
+ font: body.sm
40
+ color: text.secondary
@@ -0,0 +1,46 @@
1
+ # THE CHAT COLUMN WITH A FOCUSED COMPONENT OVER IT — core, plus the focus surface
2
+ # laid over the conversation region. A core VARIANT, not a copy: header, states and
3
+ # composer are the same shared files core.yaml includes; the only difference is the
4
+ # overlay slot.
5
+ type: Box
6
+ appWidth: chat
7
+ style:
8
+ direction: column
9
+ minHeight: "0"
10
+ children:
11
+ - type: Box
12
+ style:
13
+ direction: column
14
+ flex: 1
15
+ minHeight: "0"
16
+ position: relative
17
+ children:
18
+ - $include: components/header
19
+ - type: Box
20
+ style:
21
+ direction: column
22
+ flex: 1
23
+ minHeight: "0"
24
+ position: relative
25
+ when:
26
+ - field: hasMessages
27
+ eq: true
28
+ apply:
29
+ background: surface.base
30
+ radiusTopLeft: 2xl
31
+ overflow: hidden
32
+ children:
33
+ - $include: states/welcome
34
+ - $include: states/conversation
35
+ - type: Box
36
+ style:
37
+ position: absolute
38
+ bottom: "0"
39
+ left: "0"
40
+ right: "0"
41
+ direction: column
42
+ children:
43
+ - $include: components/suggestions
44
+ - $include: components/focus-overlay
45
+ - type: Ref
46
+ ref: composer-bar
@@ -0,0 +1,41 @@
1
+ type: Box
2
+ appWidth: chat
3
+ style:
4
+ direction: column
5
+ minHeight: "0"
6
+ children:
7
+ - type: Box
8
+ style:
9
+ direction: column
10
+ flex: 1
11
+ minHeight: "0"
12
+ position: relative
13
+ children:
14
+ - $include: components/header
15
+ - type: Box
16
+ style:
17
+ direction: column
18
+ flex: 1
19
+ minHeight: "0"
20
+ position: relative
21
+ when:
22
+ - field: hasMessages
23
+ eq: true
24
+ apply:
25
+ background: surface.base
26
+ radiusTopLeft: 2xl
27
+ overflow: hidden
28
+ children:
29
+ - $include: states/welcome
30
+ - $include: states/conversation
31
+ - type: Box
32
+ style:
33
+ position: absolute
34
+ bottom: "0"
35
+ left: "0"
36
+ right: "0"
37
+ direction: column
38
+ children:
39
+ - $include: components/suggestions
40
+ - type: Ref
41
+ ref: composer-bar
@@ -0,0 +1,20 @@
1
+ type: ComponentSlot
2
+ select:
3
+ from: all
4
+ where:
5
+ field: view
6
+ eq: focused
7
+ limit: 1
8
+ frame:
9
+ type: Box
10
+ style:
11
+ position: absolute
12
+ inset: "0"
13
+ width: full
14
+ height: full
15
+ background: surface.base
16
+ overflow: hidden
17
+ direction: column
18
+ align: stretch
19
+ children:
20
+ - type: ComponentSlot
@@ -0,0 +1,53 @@
1
+ type: Box
2
+ style:
3
+ direction: row
4
+ align: center
5
+ justify: space-between
6
+ height: "20"
7
+ padding: [ "0", "4" ]
8
+ background: transparent
9
+ children:
10
+ - type: Box
11
+ style:
12
+ direction: row
13
+ align: center
14
+ gap: "2"
15
+ children:
16
+ - type: Icon
17
+ icon: sparkles
18
+ style:
19
+ width: "6"
20
+ height: "6"
21
+ color: action.primary
22
+ - type: Text
23
+ style:
24
+ font: title
25
+ color: text.primary
26
+ value: Assistant
27
+ - type: Button
28
+ action: close
29
+ style:
30
+ flex: 0 0 auto
31
+ cursor: pointer
32
+ width: "8"
33
+ height: "8"
34
+ radius: full
35
+ background: surface.base
36
+ border: subtle
37
+ shadow: sm
38
+ direction: row
39
+ align: center
40
+ justify: center
41
+ transition: all 150ms ease
42
+ hover:
43
+ background: surface.sunken
44
+ children:
45
+ - type: Icon
46
+ icon: close
47
+ style:
48
+ width: "4"
49
+ height: "4"
50
+ color: text.secondary
51
+ visibleWhen:
52
+ field: surfacedView
53
+ in: [ "" ]
@@ -0,0 +1,142 @@
1
+ type: Box
2
+ visibleWhen: hasMessages
3
+ style:
4
+ width: full
5
+ position: relative
6
+ direction: column
7
+ background: transparent
8
+ children:
9
+ - type: Box
10
+ visibleWhen:
11
+ field: openPanel
12
+ eq: faq
13
+ style:
14
+ width: full
15
+ direction: column
16
+ gap: "2"
17
+ background: surface.base
18
+ radius: lg
19
+ padding: "4"
20
+ shadow: lg
21
+ margin: [ "0", "0", "2", "0" ]
22
+ animation:
23
+ name: slideUp
24
+ duration: 0.3s
25
+ easing: ease-out
26
+ iteration: "1"
27
+ children:
28
+ - type: Box
29
+ style:
30
+ direction: row
31
+ justify: end
32
+ children:
33
+ - type: Button
34
+ action:
35
+ type: setTemplateValue
36
+ values:
37
+ - key: openPanel
38
+ value: null
39
+ style:
40
+ flex: 0 0 auto
41
+ cursor: pointer
42
+ width: "8"
43
+ height: "8"
44
+ radius: full
45
+ background: surface.base
46
+ border: subtle
47
+ shadow: sm
48
+ direction: row
49
+ align: center
50
+ justify: center
51
+ transition: all 150ms ease
52
+ hover:
53
+ background: surface.sunken
54
+ children:
55
+ - type: Icon
56
+ icon: close
57
+ style:
58
+ width: "4"
59
+ height: "4"
60
+ color: text.secondary
61
+ - type: Each
62
+ style:
63
+ direction: row
64
+ wrap: wrap
65
+ align: start
66
+ gap: "2"
67
+ width: full
68
+ template:
69
+ type: Button
70
+ action:
71
+ type: sendMessage
72
+ then:
73
+ type: setTemplateValue
74
+ values:
75
+ - key: openPanel
76
+ value: null
77
+ style:
78
+ direction: row
79
+ align: center
80
+ flex: 0 0 auto
81
+ maxWidth: full
82
+ padding: [ "2", "4" ]
83
+ background: surface.base
84
+ border: subtle
85
+ outline: none
86
+ radius: full
87
+ shadow: sm
88
+ cursor: pointer
89
+ transition: all 150ms ease
90
+ hover:
91
+ background: surface.sunken
92
+ children:
93
+ - type: Text
94
+ bind:
95
+ value: text
96
+ style:
97
+ font: body.sm
98
+ color: text.secondary
99
+ lineHeight: tight
100
+ items:
101
+ - text: What can you help me with?
102
+ - text: How does this work?
103
+ - text: Show me an example
104
+ - type: Box
105
+ style:
106
+ width: full
107
+ direction: row
108
+ justify: center
109
+ padding: [ "1", "0" ]
110
+ children:
111
+ - type: Button
112
+ action:
113
+ type: setTemplateValue
114
+ values:
115
+ - key: openPanel
116
+ value: faq
117
+ style:
118
+ direction: row
119
+ align: center
120
+ gap: "1"
121
+ padding: [ "1", "3" ]
122
+ background: surface.base
123
+ border: subtle
124
+ outline: none
125
+ radius: full
126
+ cursor: pointer
127
+ transition: all 150ms ease
128
+ hover:
129
+ background: surface.sunken
130
+ children:
131
+ - type: Icon
132
+ icon: chat
133
+ style:
134
+ width: "4"
135
+ height: "4"
136
+ color: text.secondary
137
+ - type: Text
138
+ style:
139
+ font: body.sm
140
+ color: text.secondary
141
+ weight: medium
142
+ value: Suggestions
@@ -0,0 +1,38 @@
1
+ type: Box
2
+ style:
3
+ direction: row
4
+ gap: "1"
5
+ align: center
6
+ children:
7
+ - type: Box
8
+ style:
9
+ width: "1.5"
10
+ height: "1.5"
11
+ background: action.primary
12
+ radius: full
13
+ animation:
14
+ name: wave
15
+ duration: 1.6s
16
+ easing: ease-in-out
17
+ - type: Box
18
+ style:
19
+ width: "1.5"
20
+ height: "1.5"
21
+ background: action.primary
22
+ radius: full
23
+ animation:
24
+ name: wave
25
+ duration: 1.6s
26
+ easing: ease-in-out
27
+ animationDelay: 0.16s
28
+ - type: Box
29
+ style:
30
+ width: "1.5"
31
+ height: "1.5"
32
+ background: action.primary
33
+ radius: full
34
+ animation:
35
+ name: wave
36
+ duration: 1.6s
37
+ easing: ease-in-out
38
+ animationDelay: 0.32s
@@ -0,0 +1,22 @@
1
+ type: Box
2
+ style:
3
+ direction: row
4
+ justify: end
5
+ children:
6
+ - type: Box
7
+ style:
8
+ direction: row
9
+ align: center
10
+ background: action.primary
11
+ radius: 2xl
12
+ radiusTopRight: sm
13
+ padding: [ "2", "4" ]
14
+ maxWidth: compact
15
+ children:
16
+ - type: Text
17
+ bind:
18
+ value: text
19
+ style:
20
+ font: body.md
21
+ color: action.primary-text
22
+ lineHeight: tight
@@ -0,0 +1,12 @@
1
+ # FOCUSED — a component holding the conversation region, composer live beneath.
2
+ # A LAYOUT owned by the `focused` state: a hosted component writes `view: focused`,
3
+ # the ladder makes this arrangement active, this file draws it.
4
+ type: Box
5
+ style:
6
+ direction: row
7
+ width: full
8
+ height: full
9
+ overflow: hidden
10
+ background: surface.sunken
11
+ children:
12
+ - $include: components/core-focus
@@ -0,0 +1,9 @@
1
+ type: Box
2
+ style:
3
+ direction: row
4
+ width: full
5
+ height: full
6
+ overflow: hidden
7
+ background: surface.sunken
8
+ children:
9
+ - $include: components/core
@@ -0,0 +1,41 @@
1
+ # A GENERIC CHAT — a worked example template, shipped with Studio so templates/ is
2
+ # never an empty folder. It is yours: rename it, restyle it (it uses only foundation
3
+ # tokens, so your project's theme restyles it whole), or delete it.
4
+ # Guide: https://docs.unoverse.ai/design/templates.md
5
+ name: chat
6
+ description: A conversational assistant surface with a welcome hero, suggestions, and a focus overlay.
7
+ whenToUse: Talk to the assistant — ask a question, get help, or start any task in conversation. The conversational home and fallback surface.
8
+ category: Assistant
9
+ version: 1.0.0
10
+ defaultState: template
11
+ inputSchema:
12
+ type: object
13
+ properties:
14
+ message:
15
+ type: string
16
+ description: The user's request
17
+
18
+ # THE TEMPLATE TREE (State Model v2): one declaration, everything follows.
19
+ # Top-level order IS the priority ladder (base first, then reaction states). NESTING
20
+ # is containment: welcome exists only inside standard — the compiler strips it from
21
+ # every other arrangement — and it is the landing default. No stateOrder, no
22
+ # hand-written guards.
23
+ # NOTE: the conversation timeline is NOT declared as a substate — it is standard's own
24
+ # shell content (states/conversation), included by the shared chrome (components/core)
25
+ # and legitimately drawn under the focus overlay too. Declaring it would contain it to
26
+ # standard and empty the chat column in every reaction state.
27
+ states:
28
+ standard:
29
+ states:
30
+ welcome: {}
31
+ focused: {}
32
+
33
+ preview:
34
+ conversation: [ markdown-renderer ]
35
+ focused: [ card ]
36
+
37
+ autoTrigger: false
38
+ # BIND YOUR WORKFLOW to bring this to life: build one on the Canvas, then set
39
+ # binding: { workflow: <workflow-id>, trigger: <trigger-node-id> }
40
+ # The app owns its workflow; until it is bound, this template previews but cannot run.
41
+ layout: standard
@@ -0,0 +1,21 @@
1
+ type: Box
2
+ visibleWhen: hasMessages
3
+ autoScroll: true
4
+ style:
5
+ flex: 1
6
+ minHeight: "0"
7
+ direction: column
8
+ overflow: auto
9
+ padding: [ "6", "8" ]
10
+ children:
11
+ - type: Timeline
12
+ style:
13
+ direction: column
14
+ gap: "4"
15
+ margin: [ auto, "0", "0", "0" ]
16
+ assistantData:
17
+ thinkingText: Thinking…
18
+ user:
19
+ $include: components/user-turn
20
+ assistant:
21
+ $include: components/assistant-turn
@@ -0,0 +1,119 @@
1
+ type: Box
2
+ visibleWhen: isEmpty
3
+ style:
4
+ direction: column
5
+ flex: 1
6
+ minHeight: "0"
7
+ overflow: hidden
8
+ justify: end
9
+ gap: "6"
10
+ padding: [ "8", "8", "4", "8" ]
11
+ children:
12
+ - type: Box
13
+ style:
14
+ direction: column
15
+ align: start
16
+ gap: "2"
17
+ maxWidth: reading
18
+ padding: [ "0", "8", "0", "0" ]
19
+ children:
20
+ - type: Text
21
+ style:
22
+ font: display
23
+ color: text.primary
24
+ lineHeight: none
25
+ align: start
26
+ value: How can I help?
27
+ - type: Text
28
+ style:
29
+ font: body.lg
30
+ color: text.secondary
31
+ align: start
32
+ value: Ask a question, or pick a place to start.
33
+ - type: Each
34
+ style:
35
+ direction: row
36
+ align: start
37
+ gap: "4"
38
+ width: full
39
+ padding: [ "2", "0", "2", "0" ]
40
+ template:
41
+ type: Button
42
+ action: sendMessage
43
+ style:
44
+ position: relative
45
+ flex: 1 1 0
46
+ minWidth: 0
47
+ height: "90"
48
+ padding: "0"
49
+ radius: 2xl
50
+ border: none
51
+ overflow: hidden
52
+ cursor: pointer
53
+ transition: all 200ms ease
54
+ shadow: lg
55
+ hover:
56
+ shadow: xl
57
+ transform: scale(1.02)
58
+ children:
59
+ - type: Image
60
+ bind:
61
+ src: image
62
+ alt: title
63
+ style:
64
+ position: absolute
65
+ inset: "0"
66
+ width: full
67
+ height: full
68
+ fit: cover
69
+ - type: Box
70
+ style:
71
+ position: absolute
72
+ inset: "0"
73
+ direction: column
74
+ align: start
75
+ justify: end
76
+ gap: "2"
77
+ padding: "4"
78
+ background: overlay.scrim
79
+ textAlign: start
80
+ children:
81
+ - type: Text
82
+ bind:
83
+ value: tag
84
+ style:
85
+ font: caption
86
+ color: white
87
+ lineHeight: tight
88
+ - type: Text
89
+ bind:
90
+ value: title
91
+ style:
92
+ font: title
93
+ color: white
94
+ lineHeight: tight
95
+ align: start
96
+ - type: Text
97
+ bind:
98
+ value: subtitle
99
+ style:
100
+ font: body.sm
101
+ color: white
102
+ lineHeight: snug
103
+ align: start
104
+ items:
105
+ - image: https://picsum.photos/seed/explore/840/1160
106
+ tag: Explore
107
+ title: What can you do?
108
+ subtitle: See what this assistant can help with.
109
+ text: What can you help me with?
110
+ - image: https://picsum.photos/seed/learn/840/1160
111
+ tag: Learn
112
+ title: Show me around
113
+ subtitle: A quick tour of how this works.
114
+ text: Give me a quick tour of what I can do here
115
+ - image: https://picsum.photos/seed/start/840/1160
116
+ tag: Start
117
+ title: Jump right in
118
+ subtitle: Describe what you need in your own words.
119
+ text: I would like some help getting started
@@ -43,7 +43,11 @@ export function checkProjectName(name) {
43
43
  const WELCOME = [
44
44
  "unoverse: '1.0'",
45
45
  "kind: component",
46
- "name: Welcome",
46
+ // name === filename (the item-key rule the publish lint enforces), and the closed
47
+ // style vocabulary has `font`, not `fontSize` — both caught by linting the scaffold
48
+ // output itself, which is the guard: the first component a developer ever sees must
49
+ // lint clean, or every rule the platform states is undermined by its own starter.
50
+ "name: welcome",
47
51
  "category: Content",
48
52
  "description: A first component. Edit this file and Studio updates as you save.",
49
53
  "root:",
@@ -52,7 +56,7 @@ const WELCOME = [
52
56
  " children:",
53
57
  " - type: Text",
54
58
  " value: Hello from your first component",
55
- " style: { fontSize: 'lg', color: 'text.primary' }",
59
+ " style: { font: 'body.lg', color: 'text.primary' }",
56
60
  "",
57
61
  ].join("\n");
58
62
 
@@ -81,6 +85,15 @@ export function createProject(home, name) {
81
85
  if (existsSync(sampleStyles) && readdirSync(join(root, "styles")).length === 0) {
82
86
  cpSync(sampleStyles, join(root, "styles"), { recursive: true });
83
87
  }
88
+
89
+ // A worked example TEMPLATE (a generic chat: welcome hero, suggestions, focus
90
+ // overlay — the bpp-chat-layout shape, neutralized to foundation tokens), so
91
+ // templates/ is never an empty folder and the first app is a rename away. Same
92
+ // rule as the styles: copied only into an empty folder, then the developer's.
93
+ const sampleTemplate = join(dirname(fileURLToPath(import.meta.url)), "sample-template/chat");
94
+ if (existsSync(sampleTemplate) && readdirSync(join(root, "templates")).length === 0) {
95
+ cpSync(sampleTemplate, join(root, "templates/chat"), { recursive: true });
96
+ }
84
97
  return root;
85
98
  }
86
99
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@unoverse-platform/studio",
3
- "version": "0.1.55",
3
+ "version": "0.1.57",
4
4
  "type": "module",
5
5
  "description": "Unoverse Studio: the local authoring app. Reads your own rx/ folder, no backend.",
6
6
  "scripts": {
@@ -10,6 +10,7 @@ import { useEffect, useMemo, useState, type MouseEvent as ReactMouseEvent } from
10
10
  import { UnoverseComponent, type ResolvedTheme } from "@unoverse/sdk";
11
11
  import { client, authedFetch } from "studio-host";
12
12
  import { usePersistentState } from "studio-host";
13
+ import { NAV, SCREEN, navItem } from "./chrome";
13
14
 
14
15
  interface PropDef {
15
16
  type?: string;
@@ -127,9 +128,9 @@ export function AtomsView({ theme }: { theme: ResolvedTheme }) {
127
128
  const uri = useMemo(() => (selected ? `unoverse://atoms/${slug(selected)}` : ""), [selected]);
128
129
 
129
130
  return (
130
- <div className="grid min-h-0 flex-1 grid-cols-[240px_1fr] overflow-hidden">
131
+ <div className={SCREEN}>
131
132
  {/* NAVIGATOR */}
132
- <nav className="flex min-h-0 min-w-0 flex-col overflow-auto border-r border-gray-700 bg-[#1b2129] p-2">
133
+ <nav className={NAV}>
133
134
  {atoms.length === 0 && (
134
135
  <div className="p-2 text-xs text-gray-400">No atoms — restart the Unoverse server so /dev/atoms is available.</div>
135
136
  )}
@@ -153,9 +154,7 @@ export function AtomsView({ theme }: { theme: ResolvedTheme }) {
153
154
  <button
154
155
  key={a.name}
155
156
  onClick={() => choose(a)}
156
- className={`block w-full rounded px-2 py-1 text-left text-sm transition ${
157
- selected === a.name ? "bg-emerald-500/15 text-emerald-300" : "text-gray-300 hover:bg-white/5"
158
- }`}
157
+ className={`block w-full rounded px-2 py-1 text-left text-sm transition ${navItem(selected === a.name)}`}
159
158
  >
160
159
  {a.name}
161
160
  </button>
@@ -10,6 +10,7 @@ import { client, authedFetch, hasSourceAccess } from "studio-host";
10
10
  import { SourceView } from "./SourceView";
11
11
  import { stateActivation, faceOf, faceAxisOf, switchLayers } from "./states";
12
12
  import { usePersistentState, useRegistryToggles, ToggleSwitch } from "studio-host";
13
+ import { NAV, SCREEN, navItem, navSubItem } from "./chrome";
13
14
 
14
15
  const CHAT_ID = "demo";
15
16
 
@@ -382,9 +383,9 @@ export function ComponentsView({ theme, org = "all" }: { theme: ResolvedTheme; o
382
383
  for (const [, items] of byCategory) items.sort((a, b) => (a.org ? 0 : 1) - (b.org ? 0 : 1));
383
384
 
384
385
  return (
385
- <div className="grid min-h-0 flex-1 grid-cols-[240px_1fr] overflow-hidden">
386
+ <div className={SCREEN}>
386
387
  {/* NAVIGATOR */}
387
- <nav className="flex min-h-0 min-w-0 flex-col overflow-auto border-r border-gray-700 bg-[#2c2c2e] p-2">
388
+ <nav className={NAV}>
388
389
  {components.length === 0 && (
389
390
  <div className="p-2 text-xs text-gray-400">No components — restart the Unoverse server so /dev/components is available.</div>
390
391
  )}
@@ -400,9 +401,7 @@ export function ComponentsView({ theme, org = "all" }: { theme: ResolvedTheme; o
400
401
  <div key={refOf(c)}>
401
402
  <button
402
403
  onClick={() => choose(c)}
403
- className={`block w-full rounded px-2 py-1 text-left text-sm transition ${
404
- selected === refOf(c) ? "bg-emerald-500/15 text-emerald-300" : "text-gray-300 hover:bg-white/5"
405
- }`}
404
+ className={`block w-full rounded px-2 py-1 text-left text-sm transition ${navItem(selected === refOf(c))}`}
406
405
  >
407
406
  {c.name}
408
407
  {/* Ownership mark: tagged = the developer's own (editable); untagged =
@@ -442,9 +441,7 @@ export function ComponentsView({ theme, org = "all" }: { theme: ResolvedTheme; o
442
441
  store.apply({ type: "COMPONENT_DATA", chatId: CHAT_ID, nodeId: refOf(c), data: { [st.on!]: e.step } });
443
442
  setStoreTick((v) => v + 1);
444
443
  }}
445
- className={`block w-full truncate rounded px-2 py-0.5 text-left text-xs capitalize transition ${
446
- cur === e.step ? "bg-emerald-500/10 text-emerald-300" : "text-gray-400 hover:bg-white/5 hover:text-gray-200"
447
- }`}
444
+ className={`block w-full truncate rounded px-2 py-0.5 text-left text-xs capitalize transition ${navSubItem(cur === e.step)}`}
448
445
  >
449
446
  {e.label}
450
447
  </button>
@@ -469,9 +466,7 @@ export function ComponentsView({ theme, org = "all" }: { theme: ResolvedTheme; o
469
466
  store.apply({ type: "COMPONENT_DATA", chatId: CHAT_ID, nodeId: refOf(c), data });
470
467
  setStoreTick((v) => v + 1);
471
468
  }}
472
- className={`block w-full truncate rounded px-2 py-0.5 text-left text-xs capitalize transition ${
473
- isActive ? "bg-emerald-500/10 text-emerald-300" : "text-gray-400 hover:bg-white/5 hover:text-gray-200"
474
- }`}
469
+ className={`block w-full truncate rounded px-2 py-0.5 text-left text-xs capitalize transition ${navSubItem(isActive)}`}
475
470
  >
476
471
  {s.name}
477
472
  </button>
@@ -494,9 +489,7 @@ export function ComponentsView({ theme, org = "all" }: { theme: ResolvedTheme; o
494
489
  store.apply({ type: "COMPONENT_DATA", chatId: CHAT_ID, nodeId: refOf(c), data: { [s.on]: s.name } });
495
490
  setStoreTick((v) => v + 1);
496
491
  }}
497
- className={`block w-full truncate rounded px-2 py-0.5 text-left text-xs capitalize transition ${
498
- on ? "bg-emerald-500/10 text-emerald-300" : "text-gray-400 hover:bg-white/5 hover:text-gray-200"
499
- }`}
492
+ className={`block w-full truncate rounded px-2 py-0.5 text-left text-xs capitalize transition ${navSubItem(on)}`}
500
493
  >
501
494
  {s.name}
502
495
  </button>
@@ -7,6 +7,7 @@
7
7
  */
8
8
  import { useState } from "react";
9
9
  import type { ResolvedTheme } from "@unoverse/sdk";
10
+ import { GALLERY, NAV, NAV_SECTION, PANE, SCREEN, navItem } from "./chrome";
10
11
 
11
12
  const COLOR_GROUP_ORDER = [
12
13
  "brand", "primary", "secondary", "success", "action", "text", "surface", "border", "status",
@@ -65,7 +66,12 @@ export function DesignSystemView({
65
66
  // loud brand (e.g. an all-red project) doesn't turn the whole page its brand colour. The
66
67
  // real token swatches still render their true values; only the chrome is neutralised.
67
68
  const accent = "#6366f1";
68
- const onAction = "#ffffff";
69
+ // THE NAVIGATOR IS STUDIO CHROME, NOT THE PREVIEW (2026-08-21). It used to paint itself
70
+ // from the viewed theme's `surface.base` and size itself by hand, which is the same
71
+ // mistake the accent above was already fixed for: a project with a white surface gave
72
+ // Studio a white sub-nav, in the one section where every other section's is dark. It now
73
+ // takes the shared shell from `./chrome` like every other screen. Reading a project's
74
+ // tokens is what the CONTENT pane is for, and that pane still does.
69
75
  const bodyFont = (theme.text["body.md"]?.fontFamily as string) ?? "system-ui";
70
76
 
71
77
  // A token is THIS PROJECT'S OVERRIDE when its resolved value differs from the
@@ -276,55 +282,45 @@ export function DesignSystemView({
276
282
  const current = SECTIONS.find((s) => s.id === active) ?? SECTIONS[0];
277
283
 
278
284
  return (
279
- <div className="flex h-full" style={{ background: theme.color["surface.sunken"] ?? "#f9fafb", color: ink }}>
280
- {/* LEFT NAV — jump between token groups; badge = how many tokens THIS project overrides */}
281
- <nav className="w-56 shrink-0 overflow-auto border-r p-5" style={{ borderColor: borderC, background: surface, fontFamily: bodyFont }}>
282
- <h1 className="mb-1 text-lg font-bold">Styles</h1>
283
- <p className="mb-5 text-[11px] leading-snug" style={{ color: sub }}>
284
- {foundation ? (
285
- <>Viewing <b style={{ color: ink }}>{org}</b></>
286
- ) : (
287
- <>The base foundation</>
288
- )}
289
- </p>
290
- <ul className="space-y-1">
291
- {SECTIONS.map((s) => {
292
- const n = foundation && s.bucket ? overrideCount(s.bucket) : 0;
293
- const on = active === s.id;
294
- return (
295
- <li key={s.id}>
296
- <button
297
- onClick={() => setActive(s.id)}
298
- className="flex w-full items-center justify-between rounded px-3 py-2 text-left text-sm font-medium transition-colors"
299
- style={on ? { background: accent, color: onAction } : { color: ink }}
300
- >
301
- <span>{s.title}</span>
302
- {n > 0 && (
303
- <span
304
- className="rounded-full px-1.5 py-0.5 text-[10px] font-semibold"
305
- style={{ background: on ? "rgba(255,255,255,0.25)" : accent, color: onAction }}
306
- >
307
- {n}
308
- </span>
309
- )}
310
- </button>
311
- </li>
312
- );
313
- })}
314
- </ul>
285
+ <div className={SCREEN}>
286
+ {/* NAVIGATOR — jump between token groups; badge = how many tokens THIS project overrides */}
287
+ <nav className={NAV}>
288
+ <div className={NAV_SECTION}>{foundation ? org : "foundation"}</div>
289
+ {SECTIONS.map((s) => {
290
+ const n = foundation && s.bucket ? overrideCount(s.bucket) : 0;
291
+ const on = active === s.id;
292
+ return (
293
+ <button
294
+ key={s.id}
295
+ onClick={() => setActive(s.id)}
296
+ className={`flex w-full items-center justify-between rounded px-2 py-1 text-left text-sm transition ${navItem(on)}`}
297
+ >
298
+ <span>{s.title}</span>
299
+ {n > 0 && (
300
+ <span className="rounded-full bg-emerald-500/15 px-1.5 py-0.5 text-[10px] font-semibold text-emerald-300">
301
+ {n}
302
+ </span>
303
+ )}
304
+ </button>
305
+ );
306
+ })}
315
307
  </nav>
316
308
 
317
- {/* CONTENT — the selected group only */}
318
- <div className="flex-1 overflow-auto p-10" style={{ fontFamily: bodyFont }}>
319
- <h2 className="mb-1 text-2xl font-bold">{current.title}</h2>
320
- <p className="mb-8 text-xs" style={{ color: sub }}>
321
- {foundation ? (
322
- <>Tokens with an <OverrideMark /> are <b style={{ color: ink }}>{org}</b>&rsquo;s own; the rest are inherited from the design-system foundation.</>
323
- ) : (
324
- <>Live tokens from the design-system foundation (<code>design/marketplace/styles</code>). Toggle the theme to restyle everything.</>
325
- )}
326
- </p>
327
- {current.body}
309
+ {/* CONTENT — the selected group only. The one pane that IS allowed to look like the
310
+ project, because looking at the project is its whole job; `bodyFont` renders the
311
+ typography specimens in the theme's own face. */}
312
+ <div className={PANE}>
313
+ <div className={GALLERY} style={{ fontFamily: bodyFont }}>
314
+ <h1 className="text-xl font-bold leading-tight tracking-tight text-gray-900">{current.title}</h1>
315
+ <p className="mb-8 mt-1 max-w-3xl text-sm leading-relaxed text-gray-600">
316
+ {foundation ? (
317
+ <>Tokens with an <OverrideMark /> are <b className="text-gray-900">{org}</b>&rsquo;s own; the rest are inherited from the design-system foundation.</>
318
+ ) : (
319
+ <>Live tokens from the design-system foundation (<code>design/marketplace/styles</code>). Toggle the theme to restyle everything.</>
320
+ )}
321
+ </p>
322
+ {current.body}
323
+ </div>
328
324
  </div>
329
325
  </div>
330
326
  );
@@ -15,6 +15,7 @@ import { useEffect, useMemo, useState } from "react";
15
15
  import { fetchUnoverse } from "studio-host";
16
16
  import { usePersistentState, useRegistryToggles, ToggleSwitch } from "studio-host";
17
17
  import { IdentityHeader } from "./IdentityHeader";
18
+ import { DOC, EMPTY, NAV, NAV_SECTION, PANE, SCREEN, navItem } from "./chrome";
18
19
 
19
20
  interface PromptBlock {
20
21
  id: string;
@@ -61,9 +62,9 @@ export function PromptBlocksView() {
61
62
  ).sort(([a], [b]) => a.localeCompare(b));
62
63
 
63
64
  return (
64
- <div className="grid min-h-0 flex-1 grid-cols-[220px_1fr] overflow-hidden">
65
+ <div className={SCREEN}>
65
66
  {/* NAVIGATOR */}
66
- <nav className="flex min-h-0 min-w-0 flex-col overflow-auto border-r border-gray-700 bg-[#2c2c2e] p-2">
67
+ <nav className={NAV}>
67
68
  {blocks.length === 0 && (
68
69
  <div className="p-2 text-xs text-gray-400">
69
70
  {error
@@ -73,14 +74,12 @@ export function PromptBlocksView() {
73
74
  )}
74
75
  {byCategory.map(([cat, items]) => (
75
76
  <div key={cat} className="mb-3">
76
- <div className="px-2 py-1 text-[10px] font-semibold uppercase tracking-wider text-gray-400">{cat}</div>
77
+ <div className={NAV_SECTION}>{cat}</div>
77
78
  {items.map((b) => (
78
79
  <button
79
80
  key={b.id}
80
81
  onClick={() => setSelected(b.id)}
81
- className={`block w-full rounded px-2 py-1.5 text-left transition ${
82
- selected === b.id ? "bg-emerald-500/15 text-emerald-300" : "text-gray-300 hover:bg-white/5"
83
- }`}
82
+ className={`block w-full rounded px-2 py-1.5 text-left transition ${navItem(selected === b.id)}`}
84
83
  >
85
84
  <div className="flex items-center gap-1.5 text-sm font-medium">
86
85
  {toggles.isEnabled(b.id) && <span className="h-1.5 w-1.5 rounded-full bg-emerald-400" title="Enabled" />}
@@ -94,11 +93,11 @@ export function PromptBlocksView() {
94
93
  </nav>
95
94
 
96
95
  {/* DETAIL */}
97
- <div className="min-h-0 min-w-0 overflow-auto bg-white">
96
+ <div className={PANE}>
98
97
  {!detail ? (
99
- <div className="flex h-full items-center justify-center text-sm text-gray-500">Select a prompt block</div>
98
+ <div className={EMPTY}>Select a prompt block</div>
100
99
  ) : (
101
- <div className="mx-auto max-w-3xl p-8">
100
+ <div className={DOC}>
102
101
  <IdentityHeader
103
102
  name={detail.name}
104
103
  category={detail.category}
@@ -13,6 +13,7 @@ import { useEffect, useState } from "react";
13
13
  import { fetchUnoverse } from "studio-host";
14
14
  import { usePersistentState, useRegistryToggles, ToggleSwitch } from "studio-host";
15
15
  import { IdentityHeader } from "./IdentityHeader";
16
+ import { DOC, EMPTY, NAV, NAV_SECTION, PANE, SCREEN, navItem } from "./chrome";
16
17
 
17
18
  interface SkillSummary {
18
19
  name: string;
@@ -86,9 +87,9 @@ export function SkillsView() {
86
87
  ).sort(([a], [b]) => a.localeCompare(b));
87
88
 
88
89
  return (
89
- <div className="grid min-h-0 flex-1 grid-cols-[220px_1fr] overflow-hidden">
90
+ <div className={SCREEN}>
90
91
  {/* NAVIGATOR */}
91
- <nav className="flex min-h-0 min-w-0 flex-col overflow-auto border-r border-gray-700 bg-[#2c2c2e] p-2">
92
+ <nav className={NAV}>
92
93
  {skills.length === 0 && (
93
94
  <div className="p-2 text-xs text-gray-400">
94
95
  {error
@@ -98,14 +99,12 @@ export function SkillsView() {
98
99
  )}
99
100
  {byCategory.map(([cat, items]) => (
100
101
  <div key={cat} className="mb-3">
101
- <div className="px-2 py-1 text-[10px] font-semibold uppercase tracking-wider text-gray-400">{cat}</div>
102
+ <div className={NAV_SECTION}>{cat}</div>
102
103
  {items.map((s) => (
103
104
  <button
104
105
  key={s.name}
105
106
  onClick={() => setSelected(s.name)}
106
- className={`block w-full rounded px-2 py-1.5 text-left transition ${
107
- selected === s.name ? "bg-emerald-500/15 text-emerald-300" : "text-gray-300 hover:bg-white/5"
108
- }`}
107
+ className={`block w-full rounded px-2 py-1.5 text-left transition ${navItem(selected === s.name)}`}
109
108
  >
110
109
  <div className="flex items-center gap-1.5 text-sm font-medium">
111
110
  {s.name}
@@ -118,11 +117,11 @@ export function SkillsView() {
118
117
  </nav>
119
118
 
120
119
  {/* DETAIL */}
121
- <div className="min-h-0 min-w-0 overflow-auto bg-white">
120
+ <div className={PANE}>
122
121
  {!selected ? (
123
- <div className="flex h-full items-center justify-center text-sm text-gray-500">Select a skill</div>
122
+ <div className={EMPTY}>Select a skill</div>
124
123
  ) : (
125
- <div className="mx-auto max-w-3xl p-8">
124
+ <div className={DOC}>
126
125
  {/* AVAILABLE — the master switch. Which workflows actually carry this skill is
127
126
  installed per workflow, in Canvas's Content Library. */}
128
127
  <IdentityHeader
@@ -27,6 +27,7 @@ import { StreamedUnoverseTemplate, useUnoverseConnection, computeAppWidth, resol
27
27
  import { NativeAppHost } from "./NativeAppHost";
28
28
  import { inArrangement, viewOfSlice } from "./states";
29
29
  import { client, authedFetch, hasAuth, UNOVERSE_BASE, liveUniverseUrl } from "studio-host";
30
+ import { NAV, NAV_SECTION, SCREEN, navItem, navSubItem } from "./chrome";
30
31
 
31
32
  // Per-APP test conversation: each app chats in its own conversation id, so testing
32
33
  // one client's app never inherits another's agent history/memory (the "BPP answers
@@ -518,6 +519,11 @@ export function TemplatesView({
518
519
  nodeId: `${c.name}-${i}`,
519
520
  component: {
520
521
  type: c.name,
522
+ // The ADDRESS, exactly as a real server arrival carries it: the type alone
523
+ // cannot rebuild it (names are unique only within a tier — store.ts), so a
524
+ // mock seed without it resolved bare `unoverse://components/<name>` and the
525
+ // resolver rightly refused to guess between two orgs' clones.
526
+ componentUrl: `unoverse://components/${c.org ? `${c.org}/` : ""}${c.name}`,
521
527
  props: { ...defaultsOf(c), ...restState, defaultState: "inline" },
522
528
  },
523
529
  });
@@ -544,9 +550,12 @@ export function TemplatesView({
544
550
  Promise.all([
545
551
  // The header's org scope filters the list ("all" → every org's apps, each
546
552
  // tagged); the selected app's org still picks its own preview theme.
547
- // Components are universal — one catalog.
553
+ // Components are SCOPED like the templates: an org sees "universal + its own".
554
+ // Fetched unscoped, a cloned org's same-named component (design/lyceum cloned
555
+ // from design/bpp → two bare `journey-finder`s) sat beside the original and
556
+ // Mock seeding picked whichever listed first.
548
557
  authedFetch(`/dev/templates${org !== "all" ? `?org=${encodeURIComponent(org)}` : ""}`).then((r) => (r.ok ? r.json() : { templates: [] })),
549
- authedFetch("/dev/components").then((r) => (r.ok ? r.json() : { components: [] })),
558
+ authedFetch(`/dev/components${org !== "all" ? `?org=${encodeURIComponent(org)}` : ""}`).then((r) => (r.ok ? r.json() : { components: [] })),
550
559
  ])
551
560
  .then(([t, c]) => {
552
561
  const tl: Def[] = t.templates ?? [];
@@ -598,7 +607,14 @@ export function TemplatesView({
598
607
  const wantComponent = Boolean(layoutSel) || Boolean((activeState?.where as { field?: string } | undefined)?.field);
599
608
  let seededSet: Def[] = [];
600
609
  if (!wantEmpty) {
601
- const byName = (name: string) => components.find((c) => c.name.toLowerCase() === name.toLowerCase());
610
+ // ORG-AWARE: names are unique only within a tier, so a bare preview ref must
611
+ // resolve to the SELECTED APP's own org first, then the universal tier — never
612
+ // to another org's same-named clone (which the "all" scope still lists).
613
+ const appOrg = (selectedDef?.org ?? "").toLowerCase();
614
+ const byName = (name: string) => {
615
+ const hits = components.filter((c) => c.name.toLowerCase() === name.toLowerCase());
616
+ return hits.find((c) => (c.org ?? "").toLowerCase() === appOrg) ?? hits.find((c) => !c.org) ?? hits[0];
617
+ };
602
618
  const set = entry
603
619
  ? entry.map(byName).filter((c): c is Def => Boolean(c))
604
620
  : wantComponent
@@ -652,10 +668,10 @@ export function TemplatesView({
652
668
  }, [store, selected, components, templates, live, activeState, activeLayout]);
653
669
 
654
670
  return (
655
- <div className="grid min-h-0 flex-1 grid-cols-[190px_1fr] overflow-hidden">
671
+ <div className={SCREEN}>
656
672
  {/* NAVIGATOR */}
657
- <nav className="flex min-h-0 min-w-0 flex-col overflow-auto border-r border-gray-700 bg-[#2c2c2e] p-2">
658
- <div className="px-2 py-1 text-[10px] font-semibold uppercase tracking-wider text-gray-400">Apps</div>
673
+ <nav className={NAV}>
674
+ <div className={NAV_SECTION}>Apps</div>
659
675
  {templates.length === 0 && (
660
676
  <div className="p-2 text-xs text-gray-400">No apps yet — add one under design/&lt;project&gt;/templates/.</div>
661
677
  )}
@@ -663,9 +679,7 @@ export function TemplatesView({
663
679
  <div key={`${t.org ?? ""}/${t.name}`}>
664
680
  <button
665
681
  onClick={() => setSelected(t.name)}
666
- className={`flex w-full items-center gap-2 rounded px-2 py-1 text-left text-sm transition ${
667
- selected === t.name ? "bg-emerald-500/15 text-emerald-300" : "text-gray-300 hover:bg-white/5"
668
- }`}
682
+ className={`flex w-full items-center gap-2 rounded px-2 py-1 text-left text-sm transition ${navItem(selected === t.name)}`}
669
683
  >
670
684
  <span className="min-w-0 flex-1 truncate">{t.name}</span>
671
685
  {/* Org badge only in the cross-org ("all") view — scoped, it's redundant */}
@@ -691,9 +705,7 @@ export function TemplatesView({
691
705
  setSeedKey((k) => k + 1); // picking = jump + reseed that scenario
692
706
  }}
693
707
  title={`state: ${s.name}`}
694
- className={`block w-full truncate rounded px-2 py-0.5 text-left text-xs capitalize transition ${
695
- (liveStateName ?? activeState?.name) === s.name ? "bg-emerald-500/10 text-emerald-300" : "text-gray-400 hover:bg-white/5 hover:text-gray-200"
696
- }`}
708
+ className={`block w-full truncate rounded px-2 py-0.5 text-left text-xs capitalize transition ${navSubItem((liveStateName ?? activeState?.name) === s.name)}`}
697
709
  >
698
710
  {s.name}
699
711
  </button>
@@ -0,0 +1,83 @@
1
+ /**
2
+ * The Studio screen shell, in one place.
3
+ *
4
+ * Every section (Apps, Components, Atoms, Styles, Skills, Prompt Blocks, Nodes) is the
5
+ * same anatomy: a dark navigator on the left, a white working pane on the right. That
6
+ * was true in spirit and false in the details — the navigators had drifted to four
7
+ * widths (190/220/240/330), three paddings and two completely different active states,
8
+ * and Styles had drifted furthest of all, painting its navigator from the THEME IT WAS
9
+ * PREVIEWING so a project with a white surface got a white sub-nav.
10
+ *
11
+ * These constants are the agreement. Import them; do not retype the class strings,
12
+ * because a retyped string is how the drift happened the first time.
13
+ *
14
+ * The reference screen is Skills: it is the one that read as finished, so its
15
+ * proportions (220px navigator, p-2, a readable measure on white) are the standard
16
+ * rather than an average of what everything else happened to be doing.
17
+ *
18
+ * This is CHROME ONLY. It says how a section frames its content, never what the content
19
+ * is. The dark ramp it uses (#0f141a shell → #1b2129 navigator → #232a34 panel) is
20
+ * Studio's own and is never derived from a project's tokens; see DesignSystemView for
21
+ * the one screen where that distinction actually bites.
22
+ */
23
+
24
+ /** The section itself: navigator + working pane, neither scrolling the page. */
25
+ export const SCREEN = "grid min-h-0 flex-1 grid-cols-[220px_1fr] overflow-hidden";
26
+
27
+ /**
28
+ * WIDER NAVIGATOR, for a navigator that is a palette rather than a list. Nodes searches
29
+ * and renders two-line cards, which 220px genuinely cannot hold. An exception with a
30
+ * reason, spelled out here so the next person does not "fix" it back to SCREEN or, worse,
31
+ * invent a fifth width.
32
+ */
33
+ export const SCREEN_WIDE_NAV = "grid min-h-0 flex-1 grid-cols-[330px_1fr] overflow-hidden";
34
+
35
+ /** The dark navigator column. */
36
+ export const NAV = "flex min-h-0 min-w-0 flex-col overflow-auto border-r border-gray-700 bg-[#1b2129] p-2";
37
+
38
+ /** A group heading inside the navigator ("PROMPTS", "PRIMARY", a category). */
39
+ export const NAV_SECTION = "px-2 py-1 text-[10px] font-semibold uppercase tracking-wider text-gray-400";
40
+
41
+ /**
42
+ * SELECTION STATE ONLY, never layout.
43
+ *
44
+ * The navigators legitimately differ in row geometry — a Skills row is two lines, a
45
+ * Templates row is a flex row with a chip, a Components row opens a state tree — and
46
+ * flattening that into one class string would change row heights across five screens to
47
+ * buy nothing. What must NOT differ is what "selected" looks like, and that is what these
48
+ * return. Call sites keep their own layout classes and append one of these.
49
+ *
50
+ * Emerald is Studio's selection colour and is deliberately NOT any project's action
51
+ * colour, so a loud brand cannot restyle the chrome around its own preview.
52
+ */
53
+ export const navItem = (active: boolean) =>
54
+ active ? "bg-emerald-500/15 text-emerald-300" : "text-gray-300 hover:bg-white/5";
55
+
56
+ /** A nested navigator entry (a state under a component, a token group under a section). */
57
+ export const navSubItem = (active: boolean) =>
58
+ active ? "bg-emerald-500/10 text-emerald-300" : "text-gray-400 hover:bg-white/5 hover:text-gray-200";
59
+
60
+ /** The common one-line row. Compose with `navItem`; two-line rows add their own padding. */
61
+ export const NAV_ITEM = "block w-full rounded px-2 py-1 text-left text-sm transition";
62
+
63
+ /** The common nested row. Compose with `navSubItem`. */
64
+ export const NAV_SUBITEM = "block w-full truncate rounded px-2 py-0.5 text-left text-xs transition";
65
+
66
+ /** The working pane. White, and the only thing that scrolls. */
67
+ export const PANE = "min-h-0 min-w-0 overflow-auto bg-white";
68
+
69
+ /**
70
+ * Reading content inside the pane — a skill's instructions, a prompt block, anything
71
+ * whose job is to be READ. Capped at a readable measure and centred.
72
+ */
73
+ export const DOC = "mx-auto max-w-3xl p-8";
74
+
75
+ /**
76
+ * Browsing content inside the pane — swatch grids, galleries, anything laid out across
77
+ * the full width. Same padding as DOC so the two feel like one application; no measure
78
+ * cap, because capping a gallery just wastes the screen.
79
+ */
80
+ export const GALLERY = "p-8";
81
+
82
+ /** The empty state, before anything is selected. Same words shape everywhere: "Select a …". */
83
+ export const EMPTY = "flex h-full items-center justify-center text-sm text-gray-500";
package/screens/index.ts CHANGED
@@ -12,6 +12,10 @@
12
12
  * Consumed as SOURCE via a bundler alias, the same convention `@unoverse/sdk`
13
13
  * already uses. No build step, no publish cycle, no stale dist.
14
14
  */
15
+ // The screen shell (navigator + working pane) every section is built from. Exported so a
16
+ // host's own screens can sit beside these without re-deriving the chrome by eye, which is
17
+ // exactly how the navigators drifted to four widths and two selection colours.
18
+ export * from "./chrome";
15
19
  export { ComponentsView } from "./ComponentsView";
16
20
  export { AtomsView } from "./AtomsView";
17
21
  export { TemplatesView } from "./TemplatesView";
package/src/NodesView.tsx CHANGED
@@ -11,6 +11,7 @@ import { useCallback, useEffect, useMemo, useState } from "react";
11
11
  import { authedFetch, usePersistentState, useRegistryToggles, ToggleSwitch } from "./host";
12
12
  import { ConfigForm, defaultsFromSchema } from "@unoverse/studio";
13
13
  import { NodeKeys, type CredentialType } from "./NodeKeys";
14
+ import { NAV, SCREEN_WIDE_NAV } from "../screens/chrome";
14
15
 
15
16
  interface NodeInput {
16
17
  name: string;
@@ -294,9 +295,9 @@ export function NodesView() {
294
295
  }
295
296
 
296
297
  return (
297
- <div className="grid min-h-0 flex-1 grid-cols-[330px_1fr] overflow-hidden">
298
+ <div className={SCREEN_WIDE_NAV}>
298
299
  {/* PALETTE — Canvas-style searchable node cards */}
299
- <nav className="flex min-h-0 min-w-0 flex-col overflow-auto border-r border-gray-700 bg-[#1b2129] p-3">
300
+ <nav className={NAV}>
300
301
  <div className="relative mb-3">
301
302
  <input
302
303
  value={query}
@@ -30,8 +30,21 @@ manifest, no folders. Structure is EARNED; start flat.
30
30
  ## The state model (v2): data + a state tree
31
31
 
32
32
  A component is **data plus a state tree**; each state owns the layout(s) that draw it.
33
- The root's `state:` block IS the design, readable top to bottom. The canonical shape
34
- (a product-card component: a grid state and a detail page with nested steps):
33
+ The root's `state:` block IS the design, readable top to bottom.
34
+
35
+ > **THE GATE — answer this before adding ANY state to `view`:**
36
+ > *"Would a hosting template rearrange the page to show this?"*
37
+ > **No → it's a private step**: nest it under `on:` inside an existing public state.
38
+ > **Yes → public.**
39
+ > ⚠ "The user asked for N states" never means N public states. In-place swaps
40
+ > (confirmation, success, empty, error, loading, a form's next step) are almost always
41
+ > PRIVATE. Privacy is the default; promotion to `view` is a deliberate act with a
42
+ > template consequence.
43
+
44
+ The canonical shape (a product-card component). Note where each state landed and why:
45
+ `products` and `detail` are PUBLIC because a template genuinely rearranges for each (a
46
+ grid rail vs a full page); `detail`'s inner `detail`/`apply` are PRIVATE steps because
47
+ applying swaps content in place — the page does not move:
35
48
 
36
49
  ```yaml
37
50
  state:
@@ -65,9 +78,13 @@ The rules, in the order they bite:
65
78
  - **Public states are the top level of the `view` axis** and they are the template's
66
79
  WHOLE vocabulary: everything templates can react to, nothing more. Steps nest
67
80
  privately (`on: <key>` + `initial` + `states`), invisible outside the component.
68
- The placement rule: **if the template must rearrange to show it, make it public;
69
- if the template wouldn't move, nest it privately.** Privacy is the default;
70
- promotion is deliberate.
81
+ The placement rule is THE GATE above: **if the template must rearrange to show it,
82
+ make it public; if the template wouldn't move, nest it privately.** Privacy is the
83
+ default; promotion is deliberate.
84
+ **The tell in Studio:** non-initial public states preview FULL-BLEED (no stage
85
+ padding), precisely because they are template-liftable surfaces. If a state you
86
+ meant as an in-place swap suddenly renders edge-to-edge, you promoted a private
87
+ step to `view` — move it into the nest.
71
88
  - **Each state owns its layout(s).** `layout:` is OPTIONAL: a state with no declaration
72
89
  draws the layout of its own name, and `{}` is a complete state. Write `layout:` only
73
90
  when the filename differs. A layout is never addressable on its own; only its owning
@@ -280,5 +297,7 @@ component. Its key nouns/verbs must appear in `whenToUse`'s first sentence.
280
297
  4. Publish from Studio; its lint must report 0 errors (the rules are v2-aware and cite
281
298
  the doc for every rule).
282
299
  5. Restart unoverse → Studio: mock (prop defaults + the state switcher reads the tree:
283
- public states on top, steps beneath by state name), then live. Debug: stream log →
284
- state inspector → definition; never guess.
300
+ public states on top, steps beneath by state name), then live. Sanity-check the
301
+ switcher against THE GATE: every entry in its top row is a promise that a template
302
+ will rearrange for it, and a non-initial public state previews full-bleed. Debug:
303
+ stream log → state inspector → definition; never guess.