@unoverse-platform/studio 0.1.54 → 0.1.56

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/studio.mjs CHANGED
@@ -162,6 +162,13 @@ await scaffoldIfEmpty();
162
162
  */
163
163
  const projectRoot = findProjectRootFromCwd();
164
164
  process.env.UNOVERSE_HOME = projectRoot;
165
+ // Samples this workspace has never seen arrive on launch (new ones ship with Studio
166
+ // releases); anything it ever had — edited or deleted — is never touched. scaffold.mjs
167
+ // topUpSamples explains the record that makes that distinction safe.
168
+ {
169
+ const { topUpSamples } = await import("../local/scaffold.mjs");
170
+ try { topUpSamples(projectRoot); } catch { /* a read-only folder must not block launch */ }
171
+ }
165
172
  console.log(`\n Studio reading: ${designHome(projectRoot)}\n`);
166
173
 
167
174
  /**
@@ -13,7 +13,7 @@
13
13
  *
14
14
  * See docs/architecture/LOCAL_STUDIO.md.
15
15
  */
16
- import { existsSync, mkdirSync, writeFileSync, cpSync, readdirSync } from "node:fs";
16
+ import { existsSync, mkdirSync, writeFileSync, cpSync, readdirSync, readFileSync } from "node:fs";
17
17
  import { designHome } from "./design-home.mjs";
18
18
  import { join, dirname, resolve } from "node:path";
19
19
  import { fileURLToPath } from "node:url";
@@ -84,6 +84,60 @@ export function createProject(home, name) {
84
84
  return root;
85
85
  }
86
86
 
87
+ /**
88
+ * The worked example content (sample nodes, a sample agent skill, a sample prompt
89
+ * block), keyed by the workspace path each lands at. `dir` is relative to this file.
90
+ *
91
+ * A NEW ENTRY HERE REACHES EXISTING WORKSPACES on their next Studio launch, because
92
+ * topUpSamples runs at every launch, not only at scaffold. The record below is what
93
+ * makes that safe.
94
+ */
95
+ const SAMPLES = [
96
+ { id: "nodes/samples", dir: "sample-nodes/samples", to: "nodes/samples" },
97
+ { id: "prompts/skills/sample-complaint-handling", dir: "sample-prompts/skills/sample-complaint-handling", to: "prompts/skills/sample-complaint-handling" },
98
+ { id: "prompts/blocks/sample-concise-answers", dir: "sample-prompts/blocks/formatting/sample-concise-answers.md", to: "prompts/blocks/formatting/sample-concise-answers.md" },
99
+ ];
100
+
101
+ /**
102
+ * Install every sample this workspace has NEVER SEEN, and only those.
103
+ *
104
+ * "Absent from disk" is ambiguous — it means either "deleted on purpose" or "shipped
105
+ * after this workspace was made" — so presence is not the test. The test is the
106
+ * RECORD (.unoverse/samples.json): a sample id listed there was installed here once,
107
+ * and whatever the developer did to it since (edit, rename, delete) is theirs and
108
+ * permanent. A sample id NOT listed is genuinely new to this workspace and arrives.
109
+ * That is what lets `unoverse studio` top up new samples on every launch without ever
110
+ * resurrecting deleted work or overwriting changed files.
111
+ */
112
+ export function topUpSamples(home) {
113
+ const record = join(home, ".unoverse", "samples.json");
114
+ let seen = [];
115
+ try {
116
+ seen = JSON.parse(readFileSync(record, "utf8")).installed ?? [];
117
+ } catch {
118
+ // No record: a workspace from before records existed. Treat every sample already
119
+ // on disk as seen (it was installed by an old scaffold), so nothing is re-copied.
120
+ seen = SAMPLES.filter((s) => existsSync(join(home, s.to))).map((s) => s.id);
121
+ }
122
+ const here = dirname(fileURLToPath(import.meta.url));
123
+ let changed = false;
124
+ for (const s of SAMPLES) {
125
+ if (seen.includes(s.id)) continue;
126
+ const src = join(here, s.dir);
127
+ if (!existsSync(src)) continue;
128
+ if (!existsSync(join(home, s.to))) {
129
+ mkdirSync(dirname(join(home, s.to)), { recursive: true });
130
+ cpSync(src, join(home, s.to), { recursive: true });
131
+ }
132
+ seen.push(s.id);
133
+ changed = true;
134
+ }
135
+ if (changed || !existsSync(record)) {
136
+ mkdirSync(dirname(record), { recursive: true });
137
+ writeFileSync(record, JSON.stringify({ installed: seen }, null, 2) + "\n");
138
+ }
139
+ }
140
+
87
141
  /**
88
142
  * The FIRST project, plus the folders that live beside the design tree rather than inside it.
89
143
  *
@@ -94,47 +148,20 @@ export function createFirstProject(home, name) {
94
148
  const root = createProject(home, name);
95
149
  for (const d of ["prompts/skills", "prompts/blocks", "nodes"]) mkdirSync(join(home, d), { recursive: true });
96
150
 
97
- // Two worked example nodes (sample-nodes/samples: a PromiseNode and a CallbackNode,
98
- // heavily commented, linking docs.unoverse.ai/nodes), so `nodes/` is never an empty
99
- // folder with no hint of what goes in it. Copied only when absent: these are the
100
- // developer's files to edit or delete, and a later scaffold run must never put back
101
- // what they threw away or overwrite what they changed.
102
- const samples = join(dirname(fileURLToPath(import.meta.url)), "sample-nodes/samples");
103
- if (existsSync(samples) && !existsSync(join(home, "nodes/samples"))) {
104
- cpSync(samples, join(home, "nodes/samples"), { recursive: true });
105
- }
106
-
107
- // A worked example agent skill and prompt block, same reason and same rule as the
108
- // sample nodes: prompts/ must never be two empty folders, and the samples are the
109
- // developer's to edit or delete — copied only when absent, never restored.
110
- const samplePrompts = join(dirname(fileURLToPath(import.meta.url)), "sample-prompts");
111
- if (existsSync(join(samplePrompts, "skills")) && !existsSync(join(home, "prompts/skills/sample-complaint-handling"))) {
112
- cpSync(join(samplePrompts, "skills"), join(home, "prompts/skills"), { recursive: true });
113
- }
114
- if (existsSync(join(samplePrompts, "blocks")) && !existsSync(join(home, "prompts/blocks/formatting/sample-concise-answers.md"))) {
115
- cpSync(join(samplePrompts, "blocks"), join(home, "prompts/blocks"), { recursive: true });
116
- }
151
+ topUpSamples(home);
117
152
 
118
- // The Claude Code authoring skills, so "open the folder and describe what you want"
119
- // works in every Studio project from its first minute. Vendored into the published
120
- // app (vendor/skills, written by prepack from the monorepo's .claude/skills, the one
121
- // authoring home, mirrored publicly to github.com/unoverse-platform/skills); read
122
- // from that home when running as source. `unoverse update` refreshes installed
123
- // copies from the public mirror.
153
+ // SCAFFOLD WRITES NO `.claude/skills` (2026-08-21). It used to copy the Claude Code
154
+ // authoring skills out of `vendor/skills` and into `<home>/.claude/skills`, which meant
155
+ // every remote developer who launched Studio got the platform repo's own `.claude`
156
+ // folder reproduced on their disk, pinned to whatever Studio version they happened to
157
+ // install and sitting in a workspace they might commit.
124
158
  //
125
- // ALL of them. One Studio project is where a person designs a brand AND writes the node
126
- // behind it, so they need the whole set: naming one skill here shipped the authoring
127
- // playbooks and silently withheld the rest.
128
- const app = resolve(dirname(fileURLToPath(import.meta.url)), "..");
129
- const skillsDir = [join(app, "vendor/skills"), resolve(app, "../../.claude/skills")].find((d) => existsSync(d));
130
- if (skillsDir) {
131
- const names = readdirSync(skillsDir, { withFileTypes: true })
132
- .filter((e) => e.isDirectory() && existsSync(join(skillsDir, e.name, "SKILL.md")))
133
- .map((e) => e.name);
134
- if (names.length) mkdirSync(join(home, ".claude/skills"), { recursive: true });
135
- for (const name of names) {
136
- cpSync(join(skillsDir, name), join(home, ".claude/skills", name), { recursive: true });
137
- }
138
- }
159
+ // The skills are PER DEVELOPER, not per project (the same reason prompts/ and nodes/ are
160
+ // made once here and never by `createProject`). They now install to `~/.claude/skills`
161
+ // from the public mirror, on `unoverse update` — see packages/cli/lib/skills.mjs.
162
+ //
163
+ // `vendor/skills` STAYS, and is not a distribution channel: it is what Studio's builder
164
+ // MCP reads to teach a connected agent the authoring rules (local/mcp.ts SKILL_DIRS).
165
+ // Nothing about that copy reaches the developer's filesystem.
139
166
  return root;
140
167
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@unoverse-platform/studio",
3
- "version": "0.1.54",
3
+ "version": "0.1.56",
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
@@ -652,10 +653,10 @@ export function TemplatesView({
652
653
  }, [store, selected, components, templates, live, activeState, activeLayout]);
653
654
 
654
655
  return (
655
- <div className="grid min-h-0 flex-1 grid-cols-[190px_1fr] overflow-hidden">
656
+ <div className={SCREEN}>
656
657
  {/* 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>
658
+ <nav className={NAV}>
659
+ <div className={NAV_SECTION}>Apps</div>
659
660
  {templates.length === 0 && (
660
661
  <div className="p-2 text-xs text-gray-400">No apps yet — add one under design/&lt;project&gt;/templates/.</div>
661
662
  )}
@@ -663,9 +664,7 @@ export function TemplatesView({
663
664
  <div key={`${t.org ?? ""}/${t.name}`}>
664
665
  <button
665
666
  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
- }`}
667
+ className={`flex w-full items-center gap-2 rounded px-2 py-1 text-left text-sm transition ${navItem(selected === t.name)}`}
669
668
  >
670
669
  <span className="min-w-0 flex-1 truncate">{t.name}</span>
671
670
  {/* Org badge only in the cross-org ("all") view — scoped, it's redundant */}
@@ -691,9 +690,7 @@ export function TemplatesView({
691
690
  setSeedKey((k) => k + 1); // picking = jump + reseed that scenario
692
691
  }}
693
692
  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
- }`}
693
+ className={`block w-full truncate rounded px-2 py-0.5 text-left text-xs capitalize transition ${navSubItem((liveStateName ?? activeState?.name) === s.name)}`}
697
694
  >
698
695
  {s.name}
699
696
  </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.