@unoverse-platform/studio 0.1.39 → 0.1.40
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/local/scaffold.mjs +15 -7
- package/package.json +1 -1
- package/vendor/sdk/lib/style.ts +20 -12
- package/vendor/skills/unoverse-create/SKILL.md +3 -2
- package/vendor/skills/unoverse-create/references/brand.md +158 -0
- package/vendor/skills/unoverse-prompt-component/SKILL.md +180 -0
- package/vendor/skills/unoverse-prompt-component/verify.ts +95 -0
package/local/scaffold.mjs
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
*
|
|
14
14
|
* See docs/architecture/LOCAL_STUDIO.md.
|
|
15
15
|
*/
|
|
16
|
-
import { existsSync, mkdirSync, writeFileSync, cpSync } from "node:fs";
|
|
16
|
+
import { existsSync, mkdirSync, writeFileSync, cpSync, readdirSync } from "node:fs";
|
|
17
17
|
import { join, dirname, resolve } from "node:path";
|
|
18
18
|
import { fileURLToPath } from "node:url";
|
|
19
19
|
|
|
@@ -83,15 +83,23 @@ export function createFirstProject(home, name) {
|
|
|
83
83
|
const root = createProject(home, name);
|
|
84
84
|
for (const d of ["prompts/skills", "prompts/blocks", "nodes"]) mkdirSync(join(home, d), { recursive: true });
|
|
85
85
|
|
|
86
|
-
// The Claude Code authoring
|
|
86
|
+
// The Claude Code authoring skills, so "open the folder and describe what you want"
|
|
87
87
|
// works in every Studio project from its first minute. Vendored into the published
|
|
88
88
|
// app (vendor/skills, written by prepack); read from the repo when running as source.
|
|
89
|
+
//
|
|
90
|
+
// ALL of them. One Studio project is where a person designs a brand AND writes the node
|
|
91
|
+
// behind it, so they need the whole set: naming one skill here shipped the authoring
|
|
92
|
+
// playbooks and silently withheld the rest.
|
|
89
93
|
const app = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
|
90
|
-
const
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
94
|
+
const skillsDir = [join(app, "vendor/skills"), resolve(app, "../../.claude/skills")].find((d) => existsSync(d));
|
|
95
|
+
if (skillsDir) {
|
|
96
|
+
const names = readdirSync(skillsDir, { withFileTypes: true })
|
|
97
|
+
.filter((e) => e.isDirectory() && existsSync(join(skillsDir, e.name, "SKILL.md")))
|
|
98
|
+
.map((e) => e.name);
|
|
99
|
+
if (names.length) mkdirSync(join(home, ".claude/skills"), { recursive: true });
|
|
100
|
+
for (const name of names) {
|
|
101
|
+
cpSync(join(skillsDir, name), join(home, ".claude/skills", name), { recursive: true });
|
|
102
|
+
}
|
|
95
103
|
}
|
|
96
104
|
return root;
|
|
97
105
|
}
|
package/package.json
CHANGED
package/vendor/sdk/lib/style.ts
CHANGED
|
@@ -72,13 +72,21 @@ function computeCss(s: Record<string, unknown>, theme: ResolvedTheme, data?: Rec
|
|
|
72
72
|
}
|
|
73
73
|
}
|
|
74
74
|
const css: CSSProperties = {};
|
|
75
|
-
|
|
75
|
+
// EVERY bucket read below is optional-chained (`theme.x?.[k] ?? k`), never `theme.x[k]`.
|
|
76
|
+
// A bucket is absent whenever the theme was built by an OLDER base than the SDK reading
|
|
77
|
+
// it — a version skew that is normal for an installed Studio or a served app, since the
|
|
78
|
+
// two ship separately. Without the guard the first element using that vocab throws inside
|
|
79
|
+
// render, React unmounts the tree, and the whole surface goes blank; with it the unknown
|
|
80
|
+
// word reaches CSS, CSS drops the declaration, and the element keeps what it inherited.
|
|
81
|
+
// That silent degrade is the behaviour this file already documents for an unknown TOKEN
|
|
82
|
+
// (see `weight` below) — a missing BUCKET is the same situation and must not be worse.
|
|
83
|
+
const space = (v: string) => theme.space?.[v] ?? v;
|
|
76
84
|
// Resolve a dimension: a `space.*` scale step (e.g. "8" → 2rem) → its value; `full` → 100%;
|
|
77
85
|
// else pass through. ONE scale for spacing AND element sizes (Tailwind-style w-5/p-5).
|
|
78
86
|
// A dimension is a scale step ("8") OR a named PAGE WIDTH ("reading"). Both live on
|
|
79
87
|
// the same scale — `layout.*` are aliases onto it — so one lookup order covers both.
|
|
80
88
|
const dim = (v: unknown) =>
|
|
81
|
-
theme.space[v as string] ?? theme.layout?.[v as string] ?? (v === "full" ? "100%" : (v as CSSProperties["width"]));
|
|
89
|
+
theme.space?.[v as string] ?? theme.layout?.[v as string] ?? (v === "full" ? "100%" : (v as CSSProperties["width"]));
|
|
82
90
|
|
|
83
91
|
if (s.width != null) css.width = dim(s.width);
|
|
84
92
|
if (s.height != null) css.height = dim(s.height);
|
|
@@ -140,7 +148,7 @@ function computeCss(s: Record<string, unknown>, theme: ResolvedTheme, data?: Rec
|
|
|
140
148
|
if (s.textAlign) css.textAlign = (s.textAlign === "start" ? "left" : s.textAlign) as CSSProperties["textAlign"];
|
|
141
149
|
// Generic transform passthrough (values are DATA, e.g. a hover lift) — the SDK maps, never authors.
|
|
142
150
|
if (s.transform) css.transform = s.transform as CSSProperties["transform"];
|
|
143
|
-
if (s.background) css.background = theme.color[s.background as string] ?? (s.background as string);
|
|
151
|
+
if (s.background) css.background = theme.color?.[s.background as string] ?? (s.background as string);
|
|
144
152
|
// `radial` — a neutral dial/gauge fill: a `fill` token sweeps from 0 to `at` (a value
|
|
145
153
|
// or {{field}} binding, e.g. "72%"), the rest is the `track` token. The WEB realization
|
|
146
154
|
// is a conic-gradient; a native channel maps the same neutral intent to an arc. Authors
|
|
@@ -148,7 +156,7 @@ function computeCss(s: Record<string, unknown>, theme: ResolvedTheme, data?: Rec
|
|
|
148
156
|
// as DATA (a circular Box), not a primitive.
|
|
149
157
|
if (s.radial) {
|
|
150
158
|
const r = s.radial as { fill?: string; track?: string; at?: unknown };
|
|
151
|
-
const col = (t?: string) => (t ? (theme.color[t] ?? t) : "transparent");
|
|
159
|
+
const col = (t?: string) => (t ? (theme.color?.[t] ?? t) : "transparent");
|
|
152
160
|
const at = r.at != null ? String(resolveValue(r.at, data ?? {})) : "0%";
|
|
153
161
|
css.background = `conic-gradient(${col(r.fill)} ${at}, ${col(r.track)} 0)`;
|
|
154
162
|
}
|
|
@@ -157,8 +165,8 @@ function computeCss(s: Record<string, unknown>, theme: ResolvedTheme, data?: Rec
|
|
|
157
165
|
const borderVal = (raw: string) => {
|
|
158
166
|
const parts = raw.split(" ");
|
|
159
167
|
const [wTok, cTok] = parts.length > 1 ? parts : ["thin", parts[0]];
|
|
160
|
-
const width = theme.borderWidth[wTok] ?? wTok;
|
|
161
|
-
const color = theme.color[`border.${cTok}`] ?? theme.color[cTok] ?? cTok;
|
|
168
|
+
const width = theme.borderWidth?.[wTok] ?? wTok;
|
|
169
|
+
const color = theme.color?.[`border.${cTok}`] ?? theme.color?.[cTok] ?? cTok;
|
|
162
170
|
return `${width} solid ${color}`;
|
|
163
171
|
};
|
|
164
172
|
if (s.border === "none") css.border = "none";
|
|
@@ -170,8 +178,8 @@ function computeCss(s: Record<string, unknown>, theme: ResolvedTheme, data?: Rec
|
|
|
170
178
|
if (s.borderLeft) css.borderLeft = borderVal(s.borderLeft as string);
|
|
171
179
|
// `outline` — pass-through (e.g. "none" to drop the input focus ring inside a pill).
|
|
172
180
|
if (s.outline != null) css.outline = s.outline as CSSProperties["outline"];
|
|
173
|
-
if (s.shadow) css.boxShadow = theme.shadow[s.shadow as string] ?? (s.shadow as string);
|
|
174
|
-
const rad = (v: string) => theme.radius[v] ?? v;
|
|
181
|
+
if (s.shadow) css.boxShadow = theme.shadow?.[s.shadow as string] ?? (s.shadow as string);
|
|
182
|
+
const rad = (v: string) => theme.radius?.[v] ?? v;
|
|
175
183
|
if (s.radius) css.borderRadius = rad(s.radius as string);
|
|
176
184
|
// Per-corner radius (a chat bubble squares the corner toward its sender).
|
|
177
185
|
if (s.radiusTopLeft) css.borderTopLeftRadius = rad(s.radiusTopLeft as string);
|
|
@@ -180,17 +188,17 @@ function computeCss(s: Record<string, unknown>, theme: ResolvedTheme, data?: Rec
|
|
|
180
188
|
if (s.radiusBottomRight) css.borderBottomRightRadius = rad(s.radiusBottomRight as string);
|
|
181
189
|
if (s.overflow) css.overflow = s.overflow as CSSProperties["overflow"];
|
|
182
190
|
if (s.fit) css.objectFit = s.fit as CSSProperties["objectFit"];
|
|
183
|
-
if (s.font && theme.text[s.font as string]) Object.assign(css, theme.text[s.font as string]);
|
|
191
|
+
if (s.font && theme.text?.[s.font as string]) Object.assign(css, theme.text[s.font as string]);
|
|
184
192
|
// `weight` — a SERVED token like every other value here. This was the one scale the
|
|
185
193
|
// SDK owned outright: a literal four-name map, which meant the foundation's own
|
|
186
194
|
// `light`/`black` were unreachable and an org that added a weight to its tokens got
|
|
187
195
|
// nothing at all (the unknown word reached CSS, CSS dropped the declaration, and the
|
|
188
196
|
// text kept whatever it inherited — silently). A raw number still passes through.
|
|
189
|
-
if (s.weight) css.fontWeight = (theme.weight[s.weight as string] ?? s.weight) as CSSProperties["fontWeight"];
|
|
197
|
+
if (s.weight) css.fontWeight = (theme.weight?.[s.weight as string] ?? s.weight) as CSSProperties["fontWeight"];
|
|
190
198
|
// `lineHeight` — override the text style's line-height (e.g. tighten a single-line
|
|
191
199
|
// card title). Applied AFTER `font` so it wins. Resolves a lineHeight token (tight/…).
|
|
192
|
-
if (s.lineHeight != null) css.lineHeight = (theme.lineHeight[s.lineHeight as string] ?? s.lineHeight) as CSSProperties["lineHeight"];
|
|
193
|
-
if (s.color) css.color = theme.color[s.color as string] ?? (s.color as string);
|
|
200
|
+
if (s.lineHeight != null) css.lineHeight = (theme.lineHeight?.[s.lineHeight as string] ?? s.lineHeight) as CSSProperties["lineHeight"];
|
|
201
|
+
if (s.color) css.color = theme.color?.[s.color as string] ?? (s.color as string);
|
|
194
202
|
// `transition` — smooth state changes (e.g. hover). The value is authored in the
|
|
195
203
|
// definition (e.g. "all 150ms ease"); the SDK passes it through, authoring nothing.
|
|
196
204
|
if (s.transition) css.transition = s.transition as CSSProperties["transition"];
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: unoverse-create
|
|
3
|
-
description: Create or edit Unoverse platform artifacts: UI components (including briefed/AI-filled components), templates, atoms and styles (rx/), agent skills and prompt blocks (prompts/), custom workflow nodes (nodes/), and workflows built live on the Canvas via the builder MCP. Use when the user wants to create, add, build, or modify a component, template, theme, agent skill, prompt block, custom node, or workflow in an Unoverse starter repo.
|
|
3
|
+
description: Create or edit Unoverse platform artifacts: UI components (including briefed/AI-filled components), templates, atoms and styles (rx/), brand and org packs (themes, tokens, rebranding, cloning a universe for a new client), agent skills and prompt blocks (prompts/), custom workflow nodes (nodes/), and workflows built live on the Canvas via the builder MCP. Use when the user wants to create, add, build, or modify a component, template, theme, brand, org pack, agent skill, prompt block, custom node, or workflow in an Unoverse starter repo.
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
# Creating Unoverse Artifacts
|
|
@@ -23,7 +23,8 @@ anything**:
|
|
|
23
23
|
|---|---|
|
|
24
24
|
| A UI component or shared atom, incl. a page/card the AI fills with content (briefed) | `references/component.md` (§Briefs for AI-filled) |
|
|
25
25
|
| A template / app layout (chat surface, wizard shell) | `references/template.md` |
|
|
26
|
-
| A style / theme / token change | `references/component.md` §Tokens |
|
|
26
|
+
| A style / theme / token change on ONE component | `references/component.md` §Tokens |
|
|
27
|
+
| A brand: an org pack, a rebrand, a new client universe, cloning an org | `references/brand.md` |
|
|
27
28
|
| An agent skill (behavior guide the AI follows) | `references/agent-skill.md` |
|
|
28
29
|
| A prompt block (reusable prompt fragment) | `references/block.md` |
|
|
29
30
|
| A custom workflow node (integration, tool, logic) | `references/node.md` |
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
# Playbook: Brand a universe (org packs, themes, cloning)
|
|
2
|
+
|
|
3
|
+
Two jobs live here. **Restyling** an existing org, and **cloning** a universe for a new
|
|
4
|
+
client. They share one discipline and one failure mode.
|
|
5
|
+
|
|
6
|
+
> **The diagnostic that matters most.** If a rebrand seems to need a component edit, stop.
|
|
7
|
+
> The component has a hardcoded value, and that is the real bug. Fix the component to read
|
|
8
|
+
> a token; do not fork it per brand. A brand changes tokens. It never changes components.
|
|
9
|
+
>
|
|
10
|
+
> UNOVERSE_AUTHORING says the same about org components: they are for
|
|
11
|
+
> "the client's finders/pages, **never a restyle of a shared component (that's the theme's
|
|
12
|
+
> job)**."
|
|
13
|
+
|
|
14
|
+
## 1. How overriding actually works: name a token to override, omit it to inherit
|
|
15
|
+
|
|
16
|
+
`rx/marketplace/styles` is the **foundation**: the shared token contract every org
|
|
17
|
+
inherits. Your org pack is read **on top** of it.
|
|
18
|
+
|
|
19
|
+
The server builds a theme in three passes (`packages/base/src/definitions/theme.ts`,
|
|
20
|
+
`buildTheme`):
|
|
21
|
+
|
|
22
|
+
1. the foundation's `base/` then `semantic/`, into a flat registry keyed by dotted path
|
|
23
|
+
2. **your org's `base/` then `semantic/`, over the top**
|
|
24
|
+
3. your `themes/<name>.yaml` last
|
|
25
|
+
|
|
26
|
+
Because it is one flat registry, the cascade is **per token, not per file**:
|
|
27
|
+
|
|
28
|
+
> an org token overrides the foundation's and an org **OMITS** a token to inherit it
|
|
29
|
+
|
|
30
|
+
That is the whole mechanism, and it has one practical consequence worth internalising:
|
|
31
|
+
|
|
32
|
+
**You never copy a file to change one value in it.** To change the CTA outline, your
|
|
33
|
+
`themes/light.yaml` names `border.accent` and nothing else in that group. The neighbouring
|
|
34
|
+
`border.subtle` and `border.strong` keep coming from the foundation. Copying the whole
|
|
35
|
+
`border` block to edit one line silently forks the other two, and they stop tracking
|
|
36
|
+
foundation improvements forever.
|
|
37
|
+
|
|
38
|
+
**Token NAMES are the shared contract; VALUES are yours.** Components reference names. That
|
|
39
|
+
is how two clients get different fonts and colours from the same components without
|
|
40
|
+
re-copying anything.
|
|
41
|
+
|
|
42
|
+
> A bare theme name, or the `default` org, resolves to the foundation's own set.
|
|
43
|
+
|
|
44
|
+
## 2. The health check: count the files
|
|
45
|
+
|
|
46
|
+
| Pack | `styles/base/` holds |
|
|
47
|
+
|---|---|
|
|
48
|
+
| Foundation (`rx/marketplace/`) | `border` `color` `motion` `radius` `shadow` `spacing` `typography` |
|
|
49
|
+
| A brand (`rx/bpp/`) | `color` `typography` **only** |
|
|
50
|
+
|
|
51
|
+
BPP names two files and inherits the other five. That ratio is the health check: a pack
|
|
52
|
+
redefining `spacing` or `radius` is not branding, it is forking. The neutral core is
|
|
53
|
+
**never edited for a client**.
|
|
54
|
+
|
|
55
|
+
## 3. Adding a new org
|
|
56
|
+
|
|
57
|
+
An org is a folder. There is no registry to edit and nothing to declare.
|
|
58
|
+
|
|
59
|
+
```
|
|
60
|
+
rx/<neworg>/
|
|
61
|
+
styles/
|
|
62
|
+
base/color.yaml brand INGREDIENTS (color.brand.*)
|
|
63
|
+
base/typography.yaml font families only
|
|
64
|
+
themes/light.yaml assign MEANING to those ingredients
|
|
65
|
+
themes/dark.yaml
|
|
66
|
+
themes/studio.yaml
|
|
67
|
+
semantic/*.yaml deltas only, omit what matches the foundation
|
|
68
|
+
components/ org-private components (optional)
|
|
69
|
+
templates/ the org's app layouts (optional)
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
**`base/` and `semantic/` cascade. The theme file does NOT.**
|
|
73
|
+
|
|
74
|
+
`buildTheme` reads the foundation's `base` + `semantic`, then yours, then **one** theme
|
|
75
|
+
file: your own. The foundation's `themes/light.yaml` is never read for your theme. So a
|
|
76
|
+
theme file must assign **every** role the semantic layer aliases; one naming a single role
|
|
77
|
+
fails to resolve at all, with `Unknown token alias: {color.action.primary}`.
|
|
78
|
+
|
|
79
|
+
So start a theme by **copying the foundation's `themes/light.yaml`** and changing the roles
|
|
80
|
+
you want. That is the one place copying is correct, and it is the opposite of the rule for
|
|
81
|
+
`base/` and `semantic/`, where you copy nothing and name only what differs.
|
|
82
|
+
|
|
83
|
+
Add `base/color.yaml` once you have brand ingredients to name, and `semantic/` only where
|
|
84
|
+
you genuinely differ.
|
|
85
|
+
|
|
86
|
+
Its theme is then served at `unoverse://theme/<neworg>/light`, and a template points at it.
|
|
87
|
+
|
|
88
|
+
## 4. Anatomy of an org pack (`rx/<org>/styles/`)
|
|
89
|
+
|
|
90
|
+
Read `rx/bpp/styles/` as the worked example. Every file there carries a `$description`
|
|
91
|
+
explaining its own job, and those descriptions are the spec.
|
|
92
|
+
|
|
93
|
+
| File | Holds | Rule |
|
|
94
|
+
|---|---|---|
|
|
95
|
+
| `base/color.yaml` | `color.brand.*` **ingredients** | Name by what it IS (`ink`, `cobalt`, `paper-warm`), never by where it is used |
|
|
96
|
+
| `base/typography.yaml` | Font families only | Not sizes. The type scale is the core's |
|
|
97
|
+
| `themes/light.yaml` `dark.yaml` `studio.yaml` | **Meaning**: `text.primary` → `{color.brand.ink}` | This is where a raw colour becomes a role |
|
|
98
|
+
| `semantic/*.yaml` | Deltas only: `fonts` stylesheet URL, `prose` tone, `icons` additions, `app-sizes` | If it is not different from the core, leave it out |
|
|
99
|
+
|
|
100
|
+
The two-tier split is the whole design. BPP's own `base/color.yaml` says it plainly:
|
|
101
|
+
|
|
102
|
+
> lives in the BPP pack so the core token language stays neutral
|
|
103
|
+
|
|
104
|
+
Ingredients in `base/`, meaning in `themes/`. A theme file that contains a hex value has
|
|
105
|
+
skipped a tier.
|
|
106
|
+
|
|
107
|
+
## 5. Extracting a real brand
|
|
108
|
+
|
|
109
|
+
Take values from the live site's **computed styles or CSS custom properties**, not from a
|
|
110
|
+
screenshot read by eye. A sampled screenshot gives you colours that are close and wrong,
|
|
111
|
+
and "close and wrong" survives review.
|
|
112
|
+
|
|
113
|
+
Where you must invent (hover states, subtle fills, a fourth neutral the site never shows),
|
|
114
|
+
derive it from an observed value and **say so in the `$description`**. A future reader has
|
|
115
|
+
to know which values are evidence and which are inference.
|
|
116
|
+
|
|
117
|
+
`border.accent` is the deliberate escape hatch, and it documents itself in every theme file:
|
|
118
|
+
|
|
119
|
+
> The CTA outline colour. Defaults to this theme's strong border; a brand that wants its
|
|
120
|
+
> CTAs to stand out overrides THIS one token.
|
|
121
|
+
|
|
122
|
+
Reach for it before inventing a new token. A token that already carries a `$description`
|
|
123
|
+
naming your situation is the intended answer.
|
|
124
|
+
|
|
125
|
+
## 6. Cloning an existing org
|
|
126
|
+
|
|
127
|
+
Copying an org pack is mechanical, and every gotcha is a name that exists in more than one
|
|
128
|
+
place. Work through them in order.
|
|
129
|
+
|
|
130
|
+
1. **Copy the folder.** `rx/<source>/` → `rx/<neworg>/`, keeping `styles/`, `components/`,
|
|
131
|
+
`templates/`.
|
|
132
|
+
2. **Rename the templates.** Folder AND the id inside the manifest. `bpp-chat-layout`
|
|
133
|
+
becomes `lyceum-chat-layout` in both places. Template ids are org-qualified and they
|
|
134
|
+
are referenced by string.
|
|
135
|
+
3. **Update every manifest `name`** in the copied components and templates. A duplicate
|
|
136
|
+
name across packs is a collision: names are unique across all tiers, not per org.
|
|
137
|
+
4. **Register the client surface** if the demo client serves it
|
|
138
|
+
(`packages/demo/src/demo/clients.js`).
|
|
139
|
+
5. **Point the app at the new theme.** Templates reference `unoverse://theme/<org>/<name>`.
|
|
140
|
+
A copied template still names the old org until you change it.
|
|
141
|
+
|
|
142
|
+
## 7. Done means all four
|
|
143
|
+
|
|
144
|
+
- `grep -ri "<oldorg>" rx/<neworg>/` returns **nothing**. This is the check that catches
|
|
145
|
+
the id someone missed at midnight, and it is the one people skip.
|
|
146
|
+
- The new org appears in **Studio's universe dropdown** and its theme renders.
|
|
147
|
+
- **Studio's publish lint: 0 errors.** Justify any warning out loud.
|
|
148
|
+
- The conformance checklist passes for every copied component
|
|
149
|
+
([UNOVERSE_CONFORMANCE](https://github.com/unoverse-platform/docs/blob/main/unoverse/UNOVERSE_CONFORMANCE.md)).
|
|
150
|
+
|
|
151
|
+
## Deeper references
|
|
152
|
+
|
|
153
|
+
Token structure and the resolve order:
|
|
154
|
+
[UNOVERSE_AUTHORING](https://github.com/unoverse-platform/docs/blob/main/unoverse/UNOVERSE_AUTHORING.md).
|
|
155
|
+
Which tier owns what:
|
|
156
|
+
[UNOVERSE_LAYERS](https://github.com/unoverse-platform/docs/blob/main/unoverse/UNOVERSE_LAYERS.md).
|
|
157
|
+
Styles are W3C design tokens, two-tier base/semantic, resolved live by the server and
|
|
158
|
+
served at `unoverse://theme/{org}/{name}`. The SDK fetches them and bundles none.
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: unoverse-prompt-component
|
|
3
|
+
description: Build or audit a PROMPT component (a briefed, AI-filled Unoverse component) in rx/. Use when adding brief tags, deciding which props an agent may fill, debugging a part the agent never fills or fills wrongly, or checking what a component's MCP tool schema actually contains. Covers the traps that silently discard an authored brief.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Prompt components: the parts an agent fills
|
|
7
|
+
|
|
8
|
+
A prompt component is an ordinary `rx/` component whose bound nodes carry `brief` tags.
|
|
9
|
+
The platform compiles those tags into the component's **MCP tool schema**, and that schema
|
|
10
|
+
is the only instruction channel: no side-channel prompt, no per-component agent skill.
|
|
11
|
+
`unoverse-create` covers authoring components in general; this skill is about the briefs.
|
|
12
|
+
|
|
13
|
+
**The whole discipline is one sentence: what you wrote in YAML is not what the agent
|
|
14
|
+
receives. Compile the schema and read it.** Every failure below is silent. Nothing errors,
|
|
15
|
+
nothing renders wrong in Studio, and the agent simply gets a different instruction from the
|
|
16
|
+
one you wrote.
|
|
17
|
+
|
|
18
|
+
---
|
|
19
|
+
|
|
20
|
+
## Step 1 (do this first, and again at the end): compile the schema
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
cp .claude/skills/unoverse-prompt-component/verify.ts ./verify.tmp.ts
|
|
24
|
+
npx tsx verify.tmp.ts <org>/<component> # e.g. better-alpha/deal-page
|
|
25
|
+
rm -f verify.tmp.ts
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
It must run from the repo root (it resolves `@unoverse-platform/base`). It prints every
|
|
29
|
+
part the agent can fill, its description, and its limits. Read that output, not the YAML.
|
|
30
|
+
|
|
31
|
+
Then, always: `node -e "import('./packages/base/src/lint/rx/index.mjs').then(m=>{const r=m.lintDefinitions();console.log(r.problems.length);r.problems.forEach(p=>console.log(p.level,p.file,p.msg))})"`
|
|
32
|
+
|
|
33
|
+
The linter catches two of the five traps below by name. Read what it says rather than
|
|
34
|
+
silencing it.
|
|
35
|
+
|
|
36
|
+
---
|
|
37
|
+
|
|
38
|
+
## Step 2: decide what is briefed
|
|
39
|
+
|
|
40
|
+
A prop is briefed when **a person reading the source material would write it**. That is the
|
|
41
|
+
whole test.
|
|
42
|
+
|
|
43
|
+
Briefed: names, classifications, prose, lists of terms, risks, reasons, questions and
|
|
44
|
+
answers, and the deal's own figures where the documents state them.
|
|
45
|
+
|
|
46
|
+
NOT briefed, with the reason each is excluded:
|
|
47
|
+
|
|
48
|
+
| Prop | Why not |
|
|
49
|
+
|---|---|
|
|
50
|
+
| A collection people submit (question threads, an uploaded data room) | Briefing it invites the model to write the questions users are supposed to ask |
|
|
51
|
+
| A composer's draft field (`questionDraft`) | The same, one field down |
|
|
52
|
+
| A resolved percentage (`progressPct`) | A definition computes nothing; the producer resolves it |
|
|
53
|
+
| A derived flag (`somethingEmpty`, `ndaRequired`) | Derived state and access decisions are not document facts |
|
|
54
|
+
| A URL or image (`logo`) | The model does not author URLs. If it must reference one, use `hydrate: "image"` so it passes a `universal_id` instead |
|
|
55
|
+
|
|
56
|
+
### The trap that looks like caution
|
|
57
|
+
|
|
58
|
+
**Omitting a brief does not protect a field. It makes the field permanently blank.**
|
|
59
|
+
|
|
60
|
+
If the agent is the only producer in the workflow (file to parser to agent to component),
|
|
61
|
+
an unbriefed prop can never be filled by anything. Leaving fees or dates unbriefed to keep
|
|
62
|
+
a model from "authoring a fee" achieves nothing if a briefed `keyTerms` two sections away
|
|
63
|
+
already asks it for the same figures.
|
|
64
|
+
|
|
65
|
+
The protection that works is the instruction, not the omission. Write
|
|
66
|
+
"TRANSCRIBED from the documents with its unit exactly as written; never calculate, never
|
|
67
|
+
convert, never round, never a figure the documents do not state."
|
|
68
|
+
|
|
69
|
+
Check for this contradiction directly: if two parts can carry the same fact, either both
|
|
70
|
+
are briefed or neither is.
|
|
71
|
+
|
|
72
|
+
---
|
|
73
|
+
|
|
74
|
+
## Step 3: put the brief on the node that BINDS the field
|
|
75
|
+
|
|
76
|
+
This is the rule from the rulebook (docs/design/CLAUDE.md §3) and it is where authored
|
|
77
|
+
briefs go to die.
|
|
78
|
+
|
|
79
|
+
```yaml
|
|
80
|
+
# COMPILES
|
|
81
|
+
- type: Text
|
|
82
|
+
brief: { description: "...", maxLength: 80 }
|
|
83
|
+
bind: { value: dealName }
|
|
84
|
+
|
|
85
|
+
# COMPILES: the Each that binds the array owns the counts
|
|
86
|
+
- type: Each
|
|
87
|
+
brief: { description: "...", minItems: 4, maxItems: 8 }
|
|
88
|
+
bind: { items: risks }
|
|
89
|
+
template: { $include: components/bullet-list }
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
### Trap 1: a brief on a `Ref` never compiles
|
|
93
|
+
|
|
94
|
+
A `Ref` has no `bind`. The brief is dropped, and the atom's own brief wins after expansion.
|
|
95
|
+
|
|
96
|
+
Symptom: several unrelated parts compile to the SAME generic description, for example
|
|
97
|
+
"The passage, in Markdown, taken from the source" or "The items, in the order the source
|
|
98
|
+
gives them". That is `prose` and `list` talking, not you.
|
|
99
|
+
|
|
100
|
+
The linter says: `brief.maxLength on a node with no bind`.
|
|
101
|
+
|
|
102
|
+
Fix: draw the markup yourself so a bound node can carry the brief. Copy the atom's `root`
|
|
103
|
+
into a local `components/` partial **with its briefs removed**, then have each section own
|
|
104
|
+
an `Each` or `Markdown` that carries its own brief and includes the shared partial. The
|
|
105
|
+
look is identical; only the prompt is per section. An atom is one file and cannot carry a
|
|
106
|
+
different description for every field that composes it, which `prose.yaml` says itself.
|
|
107
|
+
|
|
108
|
+
### Trap 2: item counts only compile on the `Each`
|
|
109
|
+
|
|
110
|
+
`minItems`/`maxItems` on anything that is not the array-binding `Each` are discarded.
|
|
111
|
+
|
|
112
|
+
The linter says: `brief.minItems/maxItems on a non-Each node`. Do not delete the counts to
|
|
113
|
+
silence it. Move them to the `Each`.
|
|
114
|
+
|
|
115
|
+
### Trap 3: a briefed atom drags a collection into the schema
|
|
116
|
+
|
|
117
|
+
Briefs are collected from the **expanded** tree (`packages/base/src/markdown/collect.ts`).
|
|
118
|
+
An `Each` is recorded whenever its template has briefed fields, whether or not the `Ref`
|
|
119
|
+
itself carries a brief.
|
|
120
|
+
|
|
121
|
+
So `ref: faq` bound to `wmQuestions`, or `ref: list` bound to `documents`, or `ref:
|
|
122
|
+
form-long` bound to a draft, quietly compiles that collection into the fill schema and
|
|
123
|
+
invites the model to write it.
|
|
124
|
+
|
|
125
|
+
Fix: for any collection people fill, use brief-free markup. Never point a briefed atom at
|
|
126
|
+
one. Assert it in the verify output.
|
|
127
|
+
|
|
128
|
+
### Trap 4: item briefs and the section brief arguing
|
|
129
|
+
|
|
130
|
+
When a shared row partial carries item briefs, keep them generic ("what the entry is
|
|
131
|
+
called"). The section's own `Each` brief sits directly above them in the schema and already
|
|
132
|
+
says what its titles and bodies are for. Two specific descriptions of one field contradict
|
|
133
|
+
each other.
|
|
134
|
+
|
|
135
|
+
### Trap 5: counts that are layout, not preference
|
|
136
|
+
|
|
137
|
+
If a strip renders `columns: "4"`, then `minItems: 4, maxItems: 4`. Note that `minItems`
|
|
138
|
+
is **guidance only**: the compiler appends it to the description and never gates on it
|
|
139
|
+
(`server/src/mcp/briefSchema.ts`). `maxItems` and `maxLength` are hard. A count that is
|
|
140
|
+
genuinely structural cannot be enforced by the schema alone, so say so in the description.
|
|
141
|
+
|
|
142
|
+
---
|
|
143
|
+
|
|
144
|
+
## Step 4: write the description
|
|
145
|
+
|
|
146
|
+
Length caps are **prose limits**. Over the cap, write a shorter English sentence; never
|
|
147
|
+
abbreviate and never substitute characters. Say so in the page-level brief.
|
|
148
|
+
|
|
149
|
+
- Put the ground rule once, as a plain-string brief on the layout root: verbatim from the
|
|
150
|
+
source, omit what the source does not state, merge on refinement turns.
|
|
151
|
+
- Put each section's rules on that section, next to the field they govern. A rule about
|
|
152
|
+
risks does not belong in a preamble every other part has to carry.
|
|
153
|
+
- Name what the entry is FOR, not just its shape: "the title is the claim, the body is the
|
|
154
|
+
evidence" beats "a title and a body".
|
|
155
|
+
- Say what NOT to do where a model would plausibly do it: never repeat another tab in
|
|
156
|
+
reverse, never present a target as a result, never restate a figure another section
|
|
157
|
+
carries.
|
|
158
|
+
|
|
159
|
+
## Step 5: the workflow door
|
|
160
|
+
|
|
161
|
+
If the component is filled by an agent inside a workflow rather than in chat, read
|
|
162
|
+
`docs/unoverse/UNOVERSE_MCP_TEMPLATE_PROTOCOL.md` §The workflow door. The load-bearing law:
|
|
163
|
+
**a blank part is a valid final state.** A part the source does not cover stays blank, the
|
|
164
|
+
`visibleWhen` guards render around it, and the page is complete. Gaps are orientation on
|
|
165
|
+
the first read, never standing debt. This is implemented in
|
|
166
|
+
`server/src/runtime/components/executor/briefService.ts`; a component that fights it burns
|
|
167
|
+
every turn to `maxTurns` and returns an empty text output.
|
|
168
|
+
|
|
169
|
+
---
|
|
170
|
+
|
|
171
|
+
## Checklist
|
|
172
|
+
|
|
173
|
+
1. Schema compiled and read. Every part carries the description you wrote, not an atom's.
|
|
174
|
+
2. No collection people submit, no draft field, no derived flag, no URL in the schema.
|
|
175
|
+
3. Every field a person would write from the source IS in the schema. Nothing is "protected"
|
|
176
|
+
by omission while a sibling part asks for the same fact.
|
|
177
|
+
4. Counts on the array-binding `Each`; structural counts stated in the description too.
|
|
178
|
+
5. Linter: 0 errors, and every warning read rather than silenced.
|
|
179
|
+
6. Previewed in Studio, then filled once for real. Studio proves the layout; only a fill
|
|
180
|
+
proves the prompt.
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Compile a prompt component's brief schema and print what the AGENT actually receives.
|
|
3
|
+
*
|
|
4
|
+
* What you wrote in YAML is not what the agent gets: a brief on a `Ref` is dropped, an
|
|
5
|
+
* atom's own brief wins after expansion, and a briefed atom pointed at a user-filled
|
|
6
|
+
* collection drags that collection in. All of it is silent. This prints the truth.
|
|
7
|
+
*
|
|
8
|
+
* Copy to the repo ROOT before running (it resolves @unoverse-platform/base):
|
|
9
|
+
* cp .claude/skills/unoverse-prompt-component/verify.ts ./verify.tmp.ts
|
|
10
|
+
* npx tsx verify.tmp.ts better-alpha/deal-page
|
|
11
|
+
* rm -f verify.tmp.ts
|
|
12
|
+
*/
|
|
13
|
+
import { loadDefinition } from "@unoverse-platform/base/definitions/definitions.js";
|
|
14
|
+
import { compileBriefSchema } from "@unoverse-platform/base/markdown/index.js";
|
|
15
|
+
|
|
16
|
+
const ref = process.argv[2];
|
|
17
|
+
if (!ref) {
|
|
18
|
+
console.error("usage: npx tsx verify.tmp.ts <org>/<component> (org optional)");
|
|
19
|
+
process.exit(1);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const def = loadDefinition(ref, "component");
|
|
23
|
+
if (!def) {
|
|
24
|
+
console.error(`No component definition resolved for "${ref}".`);
|
|
25
|
+
process.exit(1);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const schema: any = compileBriefSchema(def as any, {});
|
|
29
|
+
if (!schema) {
|
|
30
|
+
console.error(`"${ref}" carries no briefs — it is a plain UI component, not a prompt component.`);
|
|
31
|
+
process.exit(1);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const props: Record<string, any> = schema.properties ?? {};
|
|
35
|
+
const keys = Object.keys(props);
|
|
36
|
+
|
|
37
|
+
console.log(`\n${ref} — ${keys.length} fillable part${keys.length === 1 ? "" : "s"}\n`);
|
|
38
|
+
|
|
39
|
+
// The generic atom descriptions. Seeing one of these means an authored brief was DROPPED:
|
|
40
|
+
// the brief sat on a `Ref`, so the atom's own text won after expansion.
|
|
41
|
+
const ATOM_TEXT = /^The passage, in Markdown|^The items, in the order the source gives|^The question and answer pairs the source states AS SUCH|^The terms, conditions or regulatory wording|^The figures, in the order the source states/;
|
|
42
|
+
|
|
43
|
+
const dropped: string[] = [];
|
|
44
|
+
for (const key of keys) {
|
|
45
|
+
const p = props[key];
|
|
46
|
+
const desc: string = p.description ?? "";
|
|
47
|
+
const limits = [
|
|
48
|
+
p.type === "array" ? "array" : "string",
|
|
49
|
+
p.maxLength != null ? `max ${p.maxLength}` : null,
|
|
50
|
+
p.minItems != null ? `min ${p.minItems} (GUIDANCE ONLY, never gated)` : null,
|
|
51
|
+
p.maxItems != null ? `max ${p.maxItems} items` : null,
|
|
52
|
+
]
|
|
53
|
+
.filter(Boolean)
|
|
54
|
+
.join(", ");
|
|
55
|
+
console.log(`### ${key} [${limits}]`);
|
|
56
|
+
console.log(desc ? ` ${desc}` : " (no description)");
|
|
57
|
+
if (p.items?.properties) {
|
|
58
|
+
for (const [ik, iv] of Object.entries<any>(p.items.properties)) {
|
|
59
|
+
console.log(` - ${ik}${iv.maxLength ? ` (max ${iv.maxLength})` : ""}: ${iv.description ?? ""}`);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
if (ATOM_TEXT.test(desc)) dropped.push(key);
|
|
63
|
+
console.log("");
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
console.log("—".repeat(72));
|
|
67
|
+
if (dropped.length) {
|
|
68
|
+
console.log(`DROPPED BRIEFS (${dropped.length}): ${dropped.join(", ")}`);
|
|
69
|
+
console.log(" These compiled to a shared ATOM's generic description, not yours. The brief");
|
|
70
|
+
console.log(" is on a `Ref`, which has no `bind`. Draw the markup so a bound node carries it.");
|
|
71
|
+
} else {
|
|
72
|
+
console.log("DROPPED BRIEFS: none — every part carries an authored description.");
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Anything a PERSON fills, or the platform derives, must never be in this schema.
|
|
76
|
+
// Name the DERIVED fields, never match on a suffix: `targetReturnPct` is a document fact
|
|
77
|
+
// the agent must fill, while `progressPct` is resolved by the producer. A `Pct$` heuristic
|
|
78
|
+
// called the first one a leak.
|
|
79
|
+
const DERIVED = /^(progressPct|remainingAllocationPct|ndaRequired)$/;
|
|
80
|
+
const forbidden = keys.filter((k) => /Questions$|^documents|Draft$|Empty$|^logo$/i.test(k) || DERIVED.test(k));
|
|
81
|
+
console.log(
|
|
82
|
+
forbidden.length
|
|
83
|
+
? `MUST NOT BE HERE (${forbidden.length}): ${forbidden.join(", ")}\n A user-filled collection, a draft field, a derived flag or a URL is in the agent's\n schema. Usually a briefed atom (faq / list / form-long) bound to it.`
|
|
84
|
+
: "MUST NOT BE HERE: none.",
|
|
85
|
+
);
|
|
86
|
+
|
|
87
|
+
// Every input prop that is NOT fillable. Review this list: a field nobody else produces
|
|
88
|
+
// and no brief covers is permanently blank, not "protected".
|
|
89
|
+
const inputProps = Object.entries((def as any).props ?? {})
|
|
90
|
+
.filter(([, v]: any) => v?.input)
|
|
91
|
+
.map(([k]) => k);
|
|
92
|
+
const unfillable = inputProps.filter((k) => !keys.includes(k));
|
|
93
|
+
console.log(`\nNOT FILLABLE BY THE AGENT (${unfillable.length} of ${inputProps.length} input props):`);
|
|
94
|
+
console.log(` ${unfillable.join(", ") || "none"}`);
|
|
95
|
+
console.log(" Each needs a producer. If nothing else supplies it, it is permanently blank.");
|