@unoverse-platform/studio 0.1.39 → 0.1.41

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.
@@ -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 skill, so "open the folder and describe what you want"
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 skill = [join(app, "vendor/skills/unoverse-create"), resolve(app, "../../.claude/skills/unoverse-create")]
91
- .find((d) => existsSync(d));
92
- if (skill) {
93
- mkdirSync(join(home, ".claude/skills"), { recursive: true });
94
- cpSync(skill, join(home, ".claude/skills/unoverse-create"), { recursive: true });
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@unoverse-platform/studio",
3
- "version": "0.1.39",
3
+ "version": "0.1.41",
4
4
  "type": "module",
5
5
  "description": "Unoverse Studio: the local authoring app. Reads your own rx/ folder, no backend.",
6
6
  "scripts": {
package/src/App.tsx CHANGED
@@ -232,7 +232,9 @@ function Shell({
232
232
  {!theme ? (
233
233
  <div className="p-6 text-xs text-gray-500">Reading your theme…</div>
234
234
  ) : view === "apps" ? (
235
- <TemplatesView theme={theme} themeName={variant} org={org} />
235
+ // Standalone Studio authors offline: open apps in MOCK (the defaultLive=true
236
+ // resting mode is Canvas's, where the universe is right there).
237
+ <TemplatesView theme={theme} themeName={variant} org={org} defaultLive={false} />
236
238
  ) : view === "components" ? (
237
239
  <ComponentsView theme={theme} org={org} />
238
240
  ) : view === "atoms" ? (
@@ -72,13 +72,21 @@ function computeCss(s: Record<string, unknown>, theme: ResolvedTheme, data?: Rec
72
72
  }
73
73
  }
74
74
  const css: CSSProperties = {};
75
- const space = (v: string) => theme.space[v] ?? v;
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,199 @@
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
+ ### The agent node's system prompt stays ROLE-ONLY
170
+
171
+ The service port already delivers everything else when the agent connects: the fill
172
+ protocol (`briefService.ts` `getSchema.instructions` — survey once, batch fills, blanks
173
+ are finished, stop when done — folded into the agent's system prompt by MCPServiceRouter),
174
+ the content rules (each part's compiled brief), and the grounding law (injected into the
175
+ schema description). A workflow agent's system prompt therefore carries ONE line of role
176
+ ("You fill the page from this run's documents; the service's instructions and each part's
177
+ schema carry every rule") and nothing more.
178
+
179
+ Anything longer is a defect with two failure modes, both observed live (2026-08-18,
180
+ deal-page): content rules in the prompt DRIFT from the briefs the moment either is edited,
181
+ and hand-written protocol CONTRADICTS the served protocol (a prompt demanding "one section
182
+ per fill call" while the service instructs "batch into as few calls as possible"). When
183
+ auditing a briefed-component workflow, read the agent node's `systemPrompt` and delete
184
+ every sentence the schema or the service already carries.
185
+
186
+ ---
187
+
188
+ ## Checklist
189
+
190
+ 1. Schema compiled and read. Every part carries the description you wrote, not an atom's.
191
+ 2. No collection people submit, no draft field, no derived flag, no URL in the schema.
192
+ 3. Every field a person would write from the source IS in the schema. Nothing is "protected"
193
+ by omission while a sibling part asks for the same fact.
194
+ 4. Counts on the array-binding `Each`; structural counts stated in the description too.
195
+ 5. Linter: 0 errors, and every warning read rather than silenced.
196
+ 6. Previewed in Studio, then filled once for real. Studio proves the layout; only a fill
197
+ proves the prompt.
198
+ 7. Workflow door only: every agent node filling this component has a ROLE-ONLY system
199
+ prompt — no sentence the schema or the service instructions already carry.
@@ -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.");