@unoverse-platform/studio 0.1.45 → 0.1.47
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 +11 -9
- package/local/authoring.ts +4 -3
- package/local/catalog.ts +3 -2
- package/local/design-home.d.mts +2 -0
- package/local/design-home.mjs +31 -0
- package/local/mcp.ts +5 -1
- package/local/plugin.ts +29 -28
- package/local/scaffold.mjs +11 -7
- package/local/source.ts +4 -3
- package/package.json +1 -1
- package/screens/DesignSystemView.tsx +1 -1
- package/screens/TemplatesView.tsx +3 -3
- package/src/App.tsx +1 -1
- package/src/NewProject.tsx +2 -2
- package/src/PublishDialog.tsx +1 -1
- package/vendor/sdk/appEngine.tsx +1 -1
- package/vendor/sdk/lib/core/client.ts +1 -1
- package/vendor/sdk/lib/core/types.ts +9 -9
- package/vendor/sdk/lib/isolate.tsx +1 -1
- package/vendor/sdk/lib/primitives.tsx +7 -7
- package/vendor/sdk/lib/render.tsx +2 -2
- package/vendor/sdk/lib/style.ts +3 -3
- package/vendor/sdk/lib/template.tsx +2 -2
- package/vendor/sdk/lib/theme.ts +2 -2
- package/vendor/skills/unoverse-create/SKILL.md +62 -28
- package/vendor/skills/unoverse-create/references/agent-skill.md +8 -5
- package/vendor/skills/unoverse-create/references/block.md +3 -2
- package/vendor/skills/unoverse-create/references/brand.md +17 -16
- package/vendor/skills/unoverse-create/references/component.md +36 -25
- package/vendor/skills/unoverse-create/references/node.md +17 -11
- package/vendor/skills/unoverse-create/references/template.md +14 -9
- package/vendor/skills/unoverse-prompt-component/SKILL.md +24 -19
package/bin/studio.mjs
CHANGED
|
@@ -21,6 +21,7 @@ import { dirname, resolve, join, basename } from "path";
|
|
|
21
21
|
import { fileURLToPath } from "url";
|
|
22
22
|
import { existsSync, readdirSync } from "fs";
|
|
23
23
|
import { createInterface } from "readline/promises";
|
|
24
|
+
import { designHome, hasDesignHome } from "../local/design-home.mjs";
|
|
24
25
|
import { createFirstProject, checkProjectName } from "../local/scaffold.mjs";
|
|
25
26
|
|
|
26
27
|
const here = dirname(fileURLToPath(import.meta.url));
|
|
@@ -37,11 +38,12 @@ const appRoot = resolve(here, "..");
|
|
|
37
38
|
* instruction; the folder says where and its name says what. The only question left is the
|
|
38
39
|
* one it cannot answer for you: a folder whose name no project can carry.
|
|
39
40
|
*/
|
|
40
|
-
/** The same walk `catalog.ts` does: the nearest folder above us holding
|
|
41
|
+
/** The same walk `catalog.ts` does: the nearest folder above us holding a design tree
|
|
42
|
+
* (design/, or the legacy rx/). */
|
|
41
43
|
function findProjectRootFromCwd() {
|
|
42
44
|
for (let dir = process.cwd(); ; ) {
|
|
43
|
-
if (
|
|
44
|
-
if (
|
|
45
|
+
if (hasDesignHome(dir)) return dir;
|
|
46
|
+
if (hasDesignHome(join(dir, "apps/unoverse"))) return join(dir, "apps/unoverse");
|
|
45
47
|
const parent = resolve(dir, "..");
|
|
46
48
|
if (parent === dir) return process.cwd();
|
|
47
49
|
dir = parent;
|
|
@@ -64,7 +66,7 @@ function findProjectBelowCwd() {
|
|
|
64
66
|
const cwd = process.cwd();
|
|
65
67
|
const found = readdirSync(cwd, { withFileTypes: true })
|
|
66
68
|
.filter((e) => e.isDirectory() && !e.name.startsWith(".") && e.name !== "node_modules")
|
|
67
|
-
.filter((e) =>
|
|
69
|
+
.filter((e) => hasDesignHome(join(cwd, e.name)))
|
|
68
70
|
.map((e) => e.name);
|
|
69
71
|
|
|
70
72
|
if (found.length === 1) return join(cwd, found[0]);
|
|
@@ -80,7 +82,7 @@ async function scaffoldIfEmpty() {
|
|
|
80
82
|
// The same walk findProjectRoot does. A project ABOVE us is a project; do not offer to
|
|
81
83
|
// make a second one inside it.
|
|
82
84
|
for (let dir = cwd; ; ) {
|
|
83
|
-
if (
|
|
85
|
+
if (hasDesignHome(dir) || hasDesignHome(join(dir, "apps/unoverse"))) return;
|
|
84
86
|
const parent = resolve(dir, "..");
|
|
85
87
|
if (parent === dir) break;
|
|
86
88
|
dir = parent;
|
|
@@ -122,7 +124,7 @@ async function scaffoldIfEmpty() {
|
|
|
122
124
|
// being named or why one was needed. The name is the folder under rx/ AND the org
|
|
123
125
|
// column on everything that project publishes, which is why the tool cannot pick it.
|
|
124
126
|
console.log(`\n Setting up Studio here.\n\n ${cwd}\n`);
|
|
125
|
-
console.log(` Your components live in
|
|
127
|
+
console.log(` Your components live in design/<project>/, and that name is the org they`);
|
|
126
128
|
console.log(` publish under. This folder is "${derived}", which cannot be one.\n`);
|
|
127
129
|
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
128
130
|
for (;;) {
|
|
@@ -140,7 +142,7 @@ async function scaffoldIfEmpty() {
|
|
|
140
142
|
// publishes, so a flat rx/ could hold exactly one project and would read `components`
|
|
141
143
|
// and `templates` as orgs.
|
|
142
144
|
createFirstProject(home, name);
|
|
143
|
-
console.log(`\n Created
|
|
145
|
+
console.log(`\n Created design/${name}/, prompts/ and nodes/ in ${cwd}\n`);
|
|
144
146
|
}
|
|
145
147
|
|
|
146
148
|
await scaffoldIfEmpty();
|
|
@@ -148,7 +150,7 @@ await scaffoldIfEmpty();
|
|
|
148
150
|
/**
|
|
149
151
|
* POINT `base` AT THIS DEVELOPER'S PROJECT, before anything imports it.
|
|
150
152
|
*
|
|
151
|
-
* `paths.ts` snapshots its exports at import time (`export const
|
|
153
|
+
* `paths.ts` snapshots its exports at import time (`export const DESIGN_HOME = paths.rx`), so
|
|
152
154
|
* `setPaths()` called later updates an object nobody reads any more. The only hook that
|
|
153
155
|
* lands is the environment, read by `findHome()` on first import.
|
|
154
156
|
*
|
|
@@ -159,7 +161,7 @@ await scaffoldIfEmpty();
|
|
|
159
161
|
*/
|
|
160
162
|
const projectRoot = findProjectRootFromCwd();
|
|
161
163
|
process.env.UNOVERSE_HOME = projectRoot;
|
|
162
|
-
console.log(`\n Studio reading: ${
|
|
164
|
+
console.log(`\n Studio reading: ${designHome(projectRoot)}\n`);
|
|
163
165
|
|
|
164
166
|
const server = await createServer({
|
|
165
167
|
// The APP is the vite root; the developer's project is found by walking up from the
|
package/local/authoring.ts
CHANGED
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
* COMPLETION VOCABULARY for the source pane.
|
|
3
3
|
*
|
|
4
4
|
* THERE IS ONE LINTER, and it is not here. The rules live in
|
|
5
|
-
* `@unoverse-platform/base/src/lint/
|
|
6
|
-
* library an overlay (`lintDefinitions(
|
|
5
|
+
* `@unoverse-platform/base/src/lint/design/`, and the editor lints unsaved text by handing that
|
|
6
|
+
* library an overlay (`lintDefinitions(designRoot, { overlay })`), so the squiggles a developer
|
|
7
7
|
* sees are the same rules publish and CI apply. An approximation of those rules in the
|
|
8
8
|
* editor would be a second linter, which is exactly what the shared library exists to
|
|
9
9
|
* prevent. This file adds no rule and judges nothing.
|
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
*
|
|
19
19
|
* See docs/architecture/LOCAL_STUDIO.md §Authoring in Studio.
|
|
20
20
|
*/
|
|
21
|
+
import { designHome } from "./design-home.mjs";
|
|
21
22
|
import { resolve } from "path";
|
|
22
23
|
import { existsSync, readdirSync } from "fs";
|
|
23
24
|
|
|
@@ -88,7 +89,7 @@ export function vocabulary(
|
|
|
88
89
|
|
|
89
90
|
/** Atom names off disk, for completion of `Ref.ref`. */
|
|
90
91
|
export function atomNames(projectRoot: string): { name: string }[] {
|
|
91
|
-
const dirs = [resolve(projectRoot, "
|
|
92
|
+
const dirs = [resolve(designHome(projectRoot), "marketplace/atoms"), resolve(designHome(projectRoot), "atoms")];
|
|
92
93
|
const names = new Set<string>();
|
|
93
94
|
for (const d of dirs) {
|
|
94
95
|
if (!existsSync(d)) continue;
|
package/local/catalog.ts
CHANGED
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
*
|
|
11
11
|
* See docs/architecture/LOCAL_STUDIO.md.
|
|
12
12
|
*/
|
|
13
|
+
import { hasDesignHome } from "./design-home.mjs";
|
|
13
14
|
import { resolve } from "path";
|
|
14
15
|
import { existsSync } from "fs";
|
|
15
16
|
|
|
@@ -41,10 +42,10 @@ export function findProjectRoot(from: string = process.cwd()): string {
|
|
|
41
42
|
let dir = resolve(from);
|
|
42
43
|
for (;;) {
|
|
43
44
|
// A developer's project (starter kit): rx/ sits at the root.
|
|
44
|
-
if (
|
|
45
|
+
if (hasDesignHome(dir)) return dir;
|
|
45
46
|
// This monorepo, where Studio's author works: rx/ lives under apps/unoverse. Same
|
|
46
47
|
// shape underneath (rx/ + prompts/), just nested, so return that as the root.
|
|
47
|
-
if (
|
|
48
|
+
if (hasDesignHome(resolve(dir, "apps/unoverse"))) return resolve(dir, "apps/unoverse");
|
|
48
49
|
const parent = resolve(dir, "..");
|
|
49
50
|
if (parent === dir) break;
|
|
50
51
|
dir = parent;
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
// The design folder for a root, renamed `rx` → `design` (2026-08-20).
|
|
2
|
+
//
|
|
3
|
+
// MIGRATE-ONCE, not dual-read: a root still holding the legacy `rx/` is renamed to
|
|
4
|
+
// `design/` on first resolution, and the platform knows ONE name from then on. The
|
|
5
|
+
// canonical rule lives in @unoverse-platform/base `paths.ts` (`designDir`); this copy
|
|
6
|
+
// exists because the plain-node entrypoints (bin/studio.mjs, scaffold.mjs) cannot
|
|
7
|
+
// import base's TypeScript source. Keep the two in step.
|
|
8
|
+
import { existsSync, renameSync } from "node:fs";
|
|
9
|
+
import { join } from "node:path";
|
|
10
|
+
|
|
11
|
+
export function designHome(root) {
|
|
12
|
+
const design = join(root, "design");
|
|
13
|
+
const legacy = join(root, "rx");
|
|
14
|
+
if (!existsSync(design) && existsSync(legacy)) {
|
|
15
|
+
try {
|
|
16
|
+
renameSync(legacy, design);
|
|
17
|
+
console.warn(`[studio] migrated ${legacy} → ${design} (rx/ is now design/)`);
|
|
18
|
+
} catch (e) {
|
|
19
|
+
console.warn(`[studio] could not rename ${legacy} → ${design} (${e.message}). Rename it by hand; Studio reads design/ only.`);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
return design;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Does this folder hold a design tree under either name? DETECTION ONLY — the walk in
|
|
26
|
+
* bin/studio.mjs probes arbitrary ancestor folders, and probing must never rename a
|
|
27
|
+
* stranger's `rx/`. Migration happens in `designHome`, called only on the root a
|
|
28
|
+
* caller actually adopts. */
|
|
29
|
+
export function hasDesignHome(root) {
|
|
30
|
+
return existsSync(join(root, "design")) || existsSync(join(root, "rx"));
|
|
31
|
+
}
|
package/local/mcp.ts
CHANGED
|
@@ -63,7 +63,11 @@ export interface McpDeps {
|
|
|
63
63
|
vocabulary: () => Promise<unknown>;
|
|
64
64
|
}
|
|
65
65
|
|
|
66
|
-
const SKILL_DIRS = [
|
|
66
|
+
const SKILL_DIRS = [
|
|
67
|
+
"vendor/skills/unoverse-create",
|
|
68
|
+
"../../.claude/skills/unoverse-create", // running from source: the authoring home
|
|
69
|
+
"../../prompts/skills/unoverse-create",
|
|
70
|
+
];
|
|
67
71
|
|
|
68
72
|
/** The vendored authoring skill, which is how an agent learns the rules before writing. */
|
|
69
73
|
function authoringSkill(appRoot: string): string {
|
package/local/plugin.ts
CHANGED
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
*
|
|
16
16
|
* See docs/architecture/LOCAL_STUDIO.md.
|
|
17
17
|
*/
|
|
18
|
+
import { designHome } from "./design-home.mjs";
|
|
18
19
|
import { createRequire } from "node:module";
|
|
19
20
|
import { fileURLToPath } from "node:url";
|
|
20
21
|
import { resolve } from "path";
|
|
@@ -51,13 +52,13 @@ function definitionHome(
|
|
|
51
52
|
return null;
|
|
52
53
|
}
|
|
53
54
|
if (/\/atoms\//.test(uri)) {
|
|
54
|
-
const flat = resolve(projectRoot, "
|
|
55
|
+
const flat = resolve(designHome(projectRoot), "atoms", `${ref.toLowerCase()}.json`);
|
|
55
56
|
return existsSync(flat) ? flat : null;
|
|
56
57
|
}
|
|
57
58
|
const folder = c.componentFolder(ref);
|
|
58
59
|
if (folder) return folder;
|
|
59
60
|
// A flat, folderless component: `rx/components/<name>.json` in either tier.
|
|
60
|
-
for (const dir of [resolve(projectRoot, "
|
|
61
|
+
for (const dir of [resolve(designHome(projectRoot), "marketplace/components"), resolve(designHome(projectRoot), "components")]) {
|
|
61
62
|
const flat = resolve(dir, `${ref.toLowerCase()}.json`);
|
|
62
63
|
if (existsSync(flat)) return flat;
|
|
63
64
|
}
|
|
@@ -195,7 +196,7 @@ export function localStudioHost(): Plugin {
|
|
|
195
196
|
let nodesOnce: Promise<LocalNodes | null> | null = null;
|
|
196
197
|
const nodes = () => (nodesOnce ??= loadLocalNodes(baseSrc, ssr, projectRoot));
|
|
197
198
|
|
|
198
|
-
server.config.logger.info(`\n Studio reading: ${
|
|
199
|
+
server.config.logger.info(`\n Studio reading: ${designHome(projectRoot)}\n`);
|
|
199
200
|
|
|
200
201
|
/**
|
|
201
202
|
* WATCH THE PROJECT EXPLICITLY. Vite watches its own root, which is this package,
|
|
@@ -208,7 +209,7 @@ export function localStudioHost(): Plugin {
|
|
|
208
209
|
*
|
|
209
210
|
* Adding the paths puts them in the same watcher, so the existing hook fires.
|
|
210
211
|
*/
|
|
211
|
-
for (const dir of [
|
|
212
|
+
for (const dir of [designHome(projectRoot), resolve(projectRoot, "prompts")]) {
|
|
212
213
|
const full = resolve(projectRoot, dir);
|
|
213
214
|
if (existsSync(full)) server.watcher.add(full);
|
|
214
215
|
}
|
|
@@ -242,7 +243,7 @@ export function localStudioHost(): Plugin {
|
|
|
242
243
|
case path === "/local/info":
|
|
243
244
|
return json(res, {
|
|
244
245
|
projectRoot,
|
|
245
|
-
rx:
|
|
246
|
+
rx: designHome(projectRoot),
|
|
246
247
|
nodes: resolve(projectRoot, "nodes"),
|
|
247
248
|
envFiles: [resolve(projectRoot, "..", ".env"), resolve(projectRoot, ".env")],
|
|
248
249
|
});
|
|
@@ -276,20 +277,20 @@ export function localStudioHost(): Plugin {
|
|
|
276
277
|
// mirrors the module's real exports — tsc can't follow an ssrLoadModule path.
|
|
277
278
|
type PublishItem = { kind: string; name: string; why?: string; mode?: string };
|
|
278
279
|
type PublishModule = {
|
|
279
|
-
lintForPublish(
|
|
280
|
-
collectProject(
|
|
280
|
+
lintForPublish(designRoot: string, project: string): Promise<{ problems: unknown[]; errors: unknown[] }>;
|
|
281
|
+
collectProject(designRoot: string, project: string): unknown;
|
|
281
282
|
planPublish(collected: unknown, universe: string, token: string): Promise<Record<string, PublishItem[]>>;
|
|
282
283
|
sendPublish(plan: unknown, universe: string, token: string): Promise<{ sent: PublishItem[]; failed?: PublishItem[] }>;
|
|
283
284
|
};
|
|
284
285
|
const items = (await ssr(`${baseSrc}/items/publish.ts`)) as PublishModule;
|
|
285
|
-
const
|
|
286
|
+
const designRoot = designHome(projectRoot);
|
|
286
287
|
|
|
287
288
|
// LINT FIRST, ALWAYS. Nothing leaves the machine until the rules pass: a
|
|
288
289
|
// developer should learn about a raw hex value here, not from an HTTP error.
|
|
289
|
-
const { problems, errors } = await items.lintForPublish(
|
|
290
|
+
const { problems, errors } = await items.lintForPublish(designRoot, project);
|
|
290
291
|
if (errors.length) return json(res, { blocked: true, problems, errors });
|
|
291
292
|
|
|
292
|
-
const collected = items.collectProject(
|
|
293
|
+
const collected = items.collectProject(designRoot, project);
|
|
293
294
|
const plan = await items.planPublish(collected, universe, token);
|
|
294
295
|
|
|
295
296
|
// The screen renders kinds and names. The plan carries every file's full
|
|
@@ -424,8 +425,8 @@ export function localStudioHost(): Plugin {
|
|
|
424
425
|
const p = String(b.path ?? "");
|
|
425
426
|
if (!p || typeof b.text !== "string") return json(res, { error: "path and text are required" }, 400);
|
|
426
427
|
|
|
427
|
-
type LintModule = { lintDefinitions(
|
|
428
|
-
const lintMod = (await ssr(`${baseSrc}/lint/
|
|
428
|
+
type LintModule = { lintDefinitions(designRoot: string): { problems: { level?: string; file?: string }[] } };
|
|
429
|
+
const lintMod = (await ssr(`${baseSrc}/lint/design/index.mjs`)) as unknown as LintModule;
|
|
429
430
|
|
|
430
431
|
// Marked BEFORE the write, so the filesystem event can never beat the mark.
|
|
431
432
|
recentSelfWrites.set(resolve(p), Date.now());
|
|
@@ -434,7 +435,7 @@ export function localStudioHost(): Plugin {
|
|
|
434
435
|
b.text as string,
|
|
435
436
|
writableRoots(projectRoot, c),
|
|
436
437
|
projectRoot,
|
|
437
|
-
(
|
|
438
|
+
(designRoot) => lintMod.lintDefinitions(designRoot),
|
|
438
439
|
);
|
|
439
440
|
return json(res, result, result.saved ? 200 : result.error === "lint errors" ? 422 : 403);
|
|
440
441
|
}
|
|
@@ -454,13 +455,13 @@ export function localStudioHost(): Plugin {
|
|
|
454
455
|
|
|
455
456
|
type LintModule = {
|
|
456
457
|
lintDefinitions(
|
|
457
|
-
|
|
458
|
+
designRoot: string,
|
|
458
459
|
options?: { overlay?: Record<string, string> },
|
|
459
460
|
): { problems: { level?: string; file?: string; line?: number; msg?: string }[] };
|
|
460
461
|
};
|
|
461
|
-
const lintMod = (await ssr(`${baseSrc}/lint/
|
|
462
|
+
const lintMod = (await ssr(`${baseSrc}/lint/design/index.mjs`)) as unknown as LintModule;
|
|
462
463
|
const abs = resolve(p);
|
|
463
|
-
const { problems } = lintMod.lintDefinitions(
|
|
464
|
+
const { problems } = lintMod.lintDefinitions(designHome(projectRoot), { overlay: { [abs]: b.text as string } });
|
|
464
465
|
return json(res, {
|
|
465
466
|
findings: problems.filter((f) => f.file && resolve(f.file) === abs),
|
|
466
467
|
elsewhere: problems.filter((f) => !f.file || resolve(f.file) !== abs).length,
|
|
@@ -472,7 +473,7 @@ export function localStudioHost(): Plugin {
|
|
|
472
473
|
* linter enforces, plus this project's own token, atom and component names.
|
|
473
474
|
*/
|
|
474
475
|
case path === "/local/vocabulary": {
|
|
475
|
-
const vocabMod = (await ssr(`${baseSrc}/lint/
|
|
476
|
+
const vocabMod = (await ssr(`${baseSrc}/lint/design/vocabulary.mjs`)) as unknown as Parameters<typeof vocabulary>[0];
|
|
476
477
|
const theme = c.resolveTheme(url.searchParams.get("theme") ?? "light");
|
|
477
478
|
return json(
|
|
478
479
|
res,
|
|
@@ -517,21 +518,21 @@ export function localStudioHost(): Plugin {
|
|
|
517
518
|
},
|
|
518
519
|
readSource: (p) => readSource(p, writableRoots(projectRoot, c), projectRoot),
|
|
519
520
|
saveSource: async (p, text) => {
|
|
520
|
-
type LintModule = { lintDefinitions(
|
|
521
|
-
const lintMod = (await ssr(`${baseSrc}/lint/
|
|
521
|
+
type LintModule = { lintDefinitions(designRoot: string): { problems: { level?: string; file?: string }[] } };
|
|
522
|
+
const lintMod = (await ssr(`${baseSrc}/lint/design/index.mjs`)) as unknown as LintModule;
|
|
522
523
|
recentSelfWrites.set(resolve(p), Date.now());
|
|
523
|
-
return saveSource(p, text, writableRoots(projectRoot, c), projectRoot, (
|
|
524
|
-
lintMod.lintDefinitions(
|
|
524
|
+
return saveSource(p, text, writableRoots(projectRoot, c), projectRoot, (designRoot) =>
|
|
525
|
+
lintMod.lintDefinitions(designRoot),
|
|
525
526
|
);
|
|
526
527
|
},
|
|
527
528
|
lintProject: async () => {
|
|
528
|
-
type LintModule = { lintDefinitions(
|
|
529
|
-
const lintMod = (await ssr(`${baseSrc}/lint/
|
|
530
|
-
return lintMod.lintDefinitions(
|
|
529
|
+
type LintModule = { lintDefinitions(designRoot: string): { problems: unknown[] } };
|
|
530
|
+
const lintMod = (await ssr(`${baseSrc}/lint/design/index.mjs`)) as unknown as LintModule;
|
|
531
|
+
return lintMod.lintDefinitions(designHome(projectRoot));
|
|
531
532
|
},
|
|
532
533
|
selection: () => selection,
|
|
533
534
|
vocabulary: async () => {
|
|
534
|
-
const vocabMod = (await ssr(`${baseSrc}/lint/
|
|
535
|
+
const vocabMod = (await ssr(`${baseSrc}/lint/design/vocabulary.mjs`)) as unknown as Parameters<typeof vocabulary>[0];
|
|
535
536
|
return vocabulary(vocabMod, c.resolveTheme("light"), atomNames(projectRoot), c.listDefinitions("component") as { name?: string }[]);
|
|
536
537
|
},
|
|
537
538
|
},
|
|
@@ -632,7 +633,7 @@ export function localStudioHost(): Plugin {
|
|
|
632
633
|
* else, which means the developer's other editor, still reloads.
|
|
633
634
|
*/
|
|
634
635
|
handleHotUpdate({ server, file }) {
|
|
635
|
-
const watched = [
|
|
636
|
+
const watched = [designHome(projectRoot), resolve(projectRoot, "prompts")];
|
|
636
637
|
if (!watched.some((w) => file.startsWith(w))) return;
|
|
637
638
|
const self = recentSelfWrites.get(resolve(file));
|
|
638
639
|
if (self !== undefined && Date.now() - self < SELF_WRITE_WINDOW_MS) return [];
|
|
@@ -642,10 +643,10 @@ export function localStudioHost(): Plugin {
|
|
|
642
643
|
|
|
643
644
|
configResolved() {
|
|
644
645
|
// Surface a clear failure at startup rather than an empty catalog later.
|
|
645
|
-
if (projectRoot && !existsSync(
|
|
646
|
+
if (projectRoot && !existsSync(designHome(projectRoot))) {
|
|
646
647
|
throw new Error(`No rx/ folder at ${projectRoot}`);
|
|
647
648
|
}
|
|
648
|
-
if (projectRoot) statSync(
|
|
649
|
+
if (projectRoot) statSync(designHome(projectRoot));
|
|
649
650
|
},
|
|
650
651
|
};
|
|
651
652
|
}
|
package/local/scaffold.mjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Creating a project, from either end.
|
|
3
3
|
*
|
|
4
|
-
* A project (an "org"
|
|
4
|
+
* A project (an "org") is `design/<name>/` holding components, styles and
|
|
5
5
|
* templates. It is the unit a developer authors in and the unit that publishes: every row
|
|
6
6
|
* that lands in a universe carries its name in the `org` column.
|
|
7
7
|
*
|
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
* See docs/architecture/LOCAL_STUDIO.md.
|
|
15
15
|
*/
|
|
16
16
|
import { existsSync, mkdirSync, writeFileSync, cpSync, readdirSync } from "node:fs";
|
|
17
|
+
import { designHome } from "./design-home.mjs";
|
|
17
18
|
import { join, dirname, resolve } from "node:path";
|
|
18
19
|
import { fileURLToPath } from "node:url";
|
|
19
20
|
|
|
@@ -27,7 +28,7 @@ import { fileURLToPath } from "node:url";
|
|
|
27
28
|
export function checkProjectName(name) {
|
|
28
29
|
const n = String(name ?? "").trim();
|
|
29
30
|
if (!n) return "A name is required";
|
|
30
|
-
// RESERVED FIRST, so the message is the true reason. These folders under
|
|
31
|
+
// RESERVED FIRST, so the message is the true reason. These folders under design/ are never a
|
|
31
32
|
// developer's own project (collect.ts NOT_A_PROJECT), and a project named `marketplace`
|
|
32
33
|
// would shadow the design system in every loader that resolves one. Checked before the
|
|
33
34
|
// format rule because `_schema` fails that too, and "use lowercase letters" would send a
|
|
@@ -56,7 +57,7 @@ const WELCOME = [
|
|
|
56
57
|
].join("\n");
|
|
57
58
|
|
|
58
59
|
/**
|
|
59
|
-
* Create `
|
|
60
|
+
* Create `design/<name>/` under `home`, with its three authoring folders and one component.
|
|
60
61
|
*
|
|
61
62
|
* Refuses an existing name rather than merging into it: "create" that silently adopts a
|
|
62
63
|
* folder is how a developer ends up publishing someone else's work under their own project.
|
|
@@ -65,8 +66,8 @@ export function createProject(home, name) {
|
|
|
65
66
|
const bad = checkProjectName(name);
|
|
66
67
|
if (bad) throw new Error(bad);
|
|
67
68
|
|
|
68
|
-
const root = join(home,
|
|
69
|
-
if (existsSync(root)) throw new Error(
|
|
69
|
+
const root = join(designHome(home), name);
|
|
70
|
+
if (existsSync(root)) throw new Error(`${name} already exists in this workspace`);
|
|
70
71
|
|
|
71
72
|
for (const d of ["components/welcome", "templates", "styles"]) mkdirSync(join(root, d), { recursive: true });
|
|
72
73
|
writeFileSync(join(root, "components/welcome/welcome.yaml"), WELCOME);
|
|
@@ -74,7 +75,7 @@ export function createProject(home, name) {
|
|
|
74
75
|
}
|
|
75
76
|
|
|
76
77
|
/**
|
|
77
|
-
* The FIRST project, plus the folders that live beside
|
|
78
|
+
* The FIRST project, plus the folders that live beside the design tree rather than inside it.
|
|
78
79
|
*
|
|
79
80
|
* Skills, prompt blocks and nodes are per-developer and not per-project, so they are made
|
|
80
81
|
* once here and never by `createProject`.
|
|
@@ -85,7 +86,10 @@ export function createFirstProject(home, name) {
|
|
|
85
86
|
|
|
86
87
|
// The Claude Code authoring skills, so "open the folder and describe what you want"
|
|
87
88
|
// works in every Studio project from its first minute. Vendored into the published
|
|
88
|
-
// app (vendor/skills, written by prepack
|
|
89
|
+
// app (vendor/skills, written by prepack from the monorepo's .claude/skills, the one
|
|
90
|
+
// authoring home, mirrored publicly to github.com/unoverse-platform/skills); read
|
|
91
|
+
// from that home when running as source. `unoverse update` refreshes installed
|
|
92
|
+
// copies from the public mirror.
|
|
89
93
|
//
|
|
90
94
|
// ALL of them. One Studio project is where a person designs a brand AND writes the node
|
|
91
95
|
// behind it, so they need the whole set: naming one skill here shipped the authoring
|
package/local/source.ts
CHANGED
|
@@ -22,6 +22,7 @@
|
|
|
22
22
|
*
|
|
23
23
|
* See docs/architecture/LOCAL_STUDIO.md §Authoring in Studio.
|
|
24
24
|
*/
|
|
25
|
+
import { designHome } from "./design-home.mjs";
|
|
25
26
|
import { resolve, sep, relative } from "path";
|
|
26
27
|
import { existsSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from "fs";
|
|
27
28
|
|
|
@@ -163,7 +164,7 @@ export async function saveSource(
|
|
|
163
164
|
text: string,
|
|
164
165
|
roots: string[],
|
|
165
166
|
projectRoot: string,
|
|
166
|
-
lint: (
|
|
167
|
+
lint: (designRoot: string) => Promise<{ problems: LintFinding[] }> | { problems: LintFinding[] },
|
|
167
168
|
): Promise<SaveResult> {
|
|
168
169
|
const abs = resolve(path);
|
|
169
170
|
const w = writability(abs, roots, projectRoot);
|
|
@@ -183,10 +184,10 @@ export async function saveSource(
|
|
|
183
184
|
|
|
184
185
|
writeFileSync(abs, text, "utf8");
|
|
185
186
|
|
|
186
|
-
const
|
|
187
|
+
const designRoot = designHome(projectRoot);
|
|
187
188
|
let problems: LintFinding[] = [];
|
|
188
189
|
try {
|
|
189
|
-
problems = (await lint(
|
|
190
|
+
problems = (await lint(designRoot)).problems ?? [];
|
|
190
191
|
} catch (e) {
|
|
191
192
|
// A linter that throws must not silently accept the write.
|
|
192
193
|
if (previous === null) rmSync(abs, { force: true });
|
package/package.json
CHANGED
|
@@ -321,7 +321,7 @@ export function DesignSystemView({
|
|
|
321
321
|
{foundation ? (
|
|
322
322
|
<>Tokens with an <OverrideMark /> are <b style={{ color: ink }}>{org}</b>’s own; the rest are inherited from the design-system foundation.</>
|
|
323
323
|
) : (
|
|
324
|
-
<>Live tokens from the design-system foundation (<code>
|
|
324
|
+
<>Live tokens from the design-system foundation (<code>design/marketplace/styles</code>). Toggle the theme to restyle everything.</>
|
|
325
325
|
)}
|
|
326
326
|
</p>
|
|
327
327
|
{current.body}
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* Gavin first. No exceptions: not bug fixes, not one-liners, not reverts.
|
|
8
8
|
* State the intended diff and wait for an explicit yes.
|
|
9
9
|
*
|
|
10
|
-
* Components and templates are DATA (
|
|
10
|
+
* Components and templates are DATA (design/). If a template seems to need code
|
|
11
11
|
* here, the answer is almost always to express it as data — or to ask.
|
|
12
12
|
* ============================================================================
|
|
13
13
|
*
|
|
@@ -532,7 +532,7 @@ export function TemplatesView({
|
|
|
532
532
|
const seedSet = (tmpls: Def[], comps: Def[], sel: string | null): Def[] => {
|
|
533
533
|
const t = tmpls.find((t) => t.name === sel);
|
|
534
534
|
// The declaration lives in the app manifest (app.previewComponents); the def-level
|
|
535
|
-
// field is the legacy envelope home. Names are case-insensitive like every
|
|
535
|
+
// field is the legacy envelope home. Names are case-insensitive like every design name,
|
|
536
536
|
// and each ENTRY seeds one instance — a repeated name previews several (a card rail).
|
|
537
537
|
const pc = t?.app?.previewComponents ?? t?.previewComponents ?? [];
|
|
538
538
|
return pc
|
|
@@ -657,7 +657,7 @@ export function TemplatesView({
|
|
|
657
657
|
<nav className="flex min-h-0 min-w-0 flex-col overflow-auto border-r border-gray-700 bg-[#2c2c2e] p-2">
|
|
658
658
|
<div className="px-2 py-1 text-[10px] font-semibold uppercase tracking-wider text-gray-400">Apps</div>
|
|
659
659
|
{templates.length === 0 && (
|
|
660
|
-
<div className="p-2 text-xs text-gray-400">No apps yet — add one under
|
|
660
|
+
<div className="p-2 text-xs text-gray-400">No apps yet — add one under design/<project>/templates/.</div>
|
|
661
661
|
)}
|
|
662
662
|
{templates.map((t) => (
|
|
663
663
|
<div key={`${t.org ?? ""}/${t.name}`}>
|
package/src/App.tsx
CHANGED
|
@@ -81,7 +81,7 @@ function Shell({
|
|
|
81
81
|
const [foundation, setFoundation] = useState<ResolvedTheme | null>(null);
|
|
82
82
|
const [error, setError] = useState<string | null>(null);
|
|
83
83
|
|
|
84
|
-
// The projects in this developer's
|
|
84
|
+
// The projects in this developer's design/ folder. Flat, per RX_ORG_MODEL. Re-read after
|
|
85
85
|
// creating one, so a new project appears without a reload.
|
|
86
86
|
const loadProjects = () =>
|
|
87
87
|
fetch("/dev/orgs")
|
package/src/NewProject.tsx
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Creating a project from inside Studio.
|
|
3
3
|
*
|
|
4
|
-
* A project is `
|
|
4
|
+
* A project is `design/<name>/`: the folder a developer authors in, and the unit that
|
|
5
5
|
* publishes. Until this existed, Studio could create your FIRST one (bin/studio.mjs, at
|
|
6
6
|
* launch, only when the folder was empty) and never your second. The dropdown listed what
|
|
7
7
|
* was on disk and offered no way to add to it, so the answer to "start another project"
|
|
@@ -51,7 +51,7 @@ export function NewProject({
|
|
|
51
51
|
>
|
|
52
52
|
<h2 className="text-base font-semibold text-gray-100">New project</h2>
|
|
53
53
|
<p className="mt-1 text-xs text-gray-500">
|
|
54
|
-
Creates
|
|
54
|
+
Creates design/<name>/ with components, styles and templates, and one component to
|
|
55
55
|
open on.
|
|
56
56
|
</p>
|
|
57
57
|
|
package/src/PublishDialog.tsx
CHANGED
|
@@ -267,7 +267,7 @@ export function PublishDialog({
|
|
|
267
267
|
.map((p, i) => (
|
|
268
268
|
<li key={i} className="leading-relaxed">
|
|
269
269
|
<span className="text-gray-500">
|
|
270
|
-
{p.file?.replace(/^.*\/
|
|
270
|
+
{p.file?.replace(/^.*\/design\//, "")}
|
|
271
271
|
{p.line ? `:${p.line}` : ""}
|
|
272
272
|
</span>
|
|
273
273
|
<br />
|
package/vendor/sdk/appEngine.tsx
CHANGED
|
@@ -68,7 +68,7 @@ export function AppEngine(cfg: AppConfig) {
|
|
|
68
68
|
setBoundFor(templateId);
|
|
69
69
|
setBinding({});
|
|
70
70
|
}
|
|
71
|
-
// STATE-OWNED WIDTH (
|
|
71
|
+
// STATE-OWNED WIDTH (design data): the layout root's `appWidth` = the core surface's
|
|
72
72
|
// constant width; an occupied panel slot's `appWidth` adds to it (computeAppWidth).
|
|
73
73
|
// No declared width → the host keeps its own default. There is no other path.
|
|
74
74
|
// THE WHOLE DEFINITION, not a chosen four fields. This used to keep `{root, org, layouts,
|
|
@@ -163,7 +163,7 @@ export class UnoverseClient {
|
|
|
163
163
|
|
|
164
164
|
/**
|
|
165
165
|
* resources/read the RESOLVED theme by name (e.g. "light"). The server owns the
|
|
166
|
-
* values (
|
|
166
|
+
* values (design/styles); the SDK only fetches — it bundles no tokens. Cached.
|
|
167
167
|
*/
|
|
168
168
|
async readTheme(name = "light"): Promise<ResolvedTheme> {
|
|
169
169
|
const cached = this.themeCache.get(name);
|
|
@@ -42,7 +42,7 @@ export interface UnoverseNode {
|
|
|
42
42
|
* content grows (new turn or streaming text), unless the user has scrolled up. Generic
|
|
43
43
|
* scroll-container behavior, opt-in via data (e.g. the conversation state's scroll box). */
|
|
44
44
|
autoScroll?: boolean;
|
|
45
|
-
/** Box: a sticky bar (its own `position: sticky` + `top` in
|
|
45
|
+
/** Box: a sticky bar (its own `position: sticky` + `top` in the design definition) that stays HIDDEN while
|
|
46
46
|
* the element before it (its previous sibling — e.g. a hero image) is on screen, then
|
|
47
47
|
* fades in once that sibling scrolls away. Overlays the content (no flow gap). Generic
|
|
48
48
|
* scroll-reveal behavior, opt-in via data. */
|
|
@@ -203,16 +203,16 @@ export type StyleVariant = Condition & { apply: Record<string, unknown> };
|
|
|
203
203
|
// ---- The resolved theme (SHAPE only — values live on the server) ----
|
|
204
204
|
//
|
|
205
205
|
// The MCP server OWNS THE VISUAL: it resolves the design tokens (apps/unoverse/
|
|
206
|
-
//
|
|
206
|
+
// design/styles) live and serves this shape at `unoverse://theme/{name}`. This is the
|
|
207
207
|
// token SHAPE, never the token VALUES — the SDK fetches the values (readTheme),
|
|
208
|
-
// it does NOT bundle them. A brand change in
|
|
208
|
+
// it does NOT bundle them. A brand change in design/styles is therefore refresh-only,
|
|
209
209
|
// exactly like a definition change (UNOVERSE_SPEC §2d-1).
|
|
210
210
|
export interface ResolvedTheme {
|
|
211
211
|
color: Record<string, string>;
|
|
212
212
|
/** Webfont stylesheet URLs (served `fonts.stylesheets` data) — injected into
|
|
213
213
|
* document.head by the SDK (shadow roots can't register @font-face). */
|
|
214
214
|
fonts?: string[];
|
|
215
|
-
/** Dimension scale (theme.space ←
|
|
215
|
+
/** Dimension scale (theme.space ← design/styles/base/spacing.json) — used for BOTH spacing
|
|
216
216
|
* (gap/padding/margin) AND element sizes (width/height), Tailwind-style. */
|
|
217
217
|
space: Record<string, string>;
|
|
218
218
|
radius: Record<string, string>;
|
|
@@ -227,17 +227,17 @@ export interface ResolvedTheme {
|
|
|
227
227
|
text: Record<string, Record<string, string | number>>;
|
|
228
228
|
skeleton: Record<string, unknown>;
|
|
229
229
|
prose: Record<string, unknown>;
|
|
230
|
-
/** STANDARD APP SIZES (theme.appSize ←
|
|
230
|
+
/** STANDARD APP SIZES (theme.appSize ← design/styles/semantic/app-sizes.json) — the named
|
|
231
231
|
* width blocks `appWidth` references ("chat"/"rail"/"panel" → raw host-facing CSS).
|
|
232
232
|
* Resolved by the SDK like every other token — one pipeline. */
|
|
233
233
|
appSize?: Record<string, string>;
|
|
234
|
-
/** Grid behaviour (theme.grid ←
|
|
234
|
+
/** Grid behaviour (theme.grid ← design/styles/semantic/grid) — `stackBelow`, the width
|
|
235
235
|
* at which a columns grid stacks. Served: the SDK holds no threshold of its own. */
|
|
236
236
|
grid?: Record<string, string>;
|
|
237
|
-
/** PAGE WIDTHS by name (theme.layout ←
|
|
237
|
+
/** PAGE WIDTHS by name (theme.layout ← design/styles/semantic/layout) — resolved by `dim()`
|
|
238
238
|
* alongside the space scale, so `maxWidth: "reading"` works like any dimension. */
|
|
239
239
|
layout?: Record<string, string>;
|
|
240
|
-
/** Named keyframes (theme.keyframes ←
|
|
240
|
+
/** Named keyframes (theme.keyframes ← design/styles/semantic/keyframes.json), injected
|
|
241
241
|
* once per render root. Lets any definition animate via `style.animation` — no SDK edit. */
|
|
242
242
|
keyframes: Record<string, Record<string, Record<string, string>>>;
|
|
243
243
|
/** Named icon glyphs as SERVED DATA. The SERVER sources these from an icon pack
|
|
@@ -245,7 +245,7 @@ export interface ResolvedTheme {
|
|
|
245
245
|
* elements `[tag, attrs]`. The generic `Icon` leaf renders them, sized/coloured by
|
|
246
246
|
* node.style. The SDK never bundles the pack — icons are served like any token. */
|
|
247
247
|
icons: Record<string, { viewBox?: string; attrs?: Record<string, unknown>; children?: [string, Record<string, unknown>][] }>;
|
|
248
|
-
/** Base render-root settings (theme.root ←
|
|
248
|
+
/** Base render-root settings (theme.root ← design/styles/semantic/root.json) — a CSS
|
|
249
249
|
* object the SDK spreads onto its outermost wrapper (e.g. font-smoothing). Served,
|
|
250
250
|
* not authored: a whole-tree rendering normalization, like legacy styles/base.css. */
|
|
251
251
|
root: Record<string, unknown>;
|
|
@@ -31,7 +31,7 @@ const RESET_CSS = `
|
|
|
31
31
|
font: inherit; letter-spacing: inherit; color: inherit; margin: 0;
|
|
32
32
|
}
|
|
33
33
|
/* Scrollbars hidden by default across every scroll container — scrolling still works,
|
|
34
|
-
the bar is just not painted. Generic chrome that
|
|
34
|
+
the bar is just not painted. Generic chrome that design definitions can't author per-node (pseudo-
|
|
35
35
|
elements), so it lives with the structural reset. */
|
|
36
36
|
.uno-isolate-root * { scrollbar-width: none; }
|
|
37
37
|
.uno-isolate-root *::-webkit-scrollbar { width: 0; height: 0; display: none; }
|