velloo 0.6.0 → 0.8.0

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.
Files changed (114) hide show
  1. package/README.md +121 -225
  2. package/THIRD-PARTY-NOTICES.md +3 -3
  3. package/canvas/assets/index-CcPwhD7W.js +31 -0
  4. package/canvas/assets/index-CkVteRZ9.css +2 -0
  5. package/canvas/assets/lucide-all-Cezxn40A.js +1 -0
  6. package/canvas/index.html +2 -2
  7. package/{chunk-rjjc9vam.js → chunk-07fxzrx2.js} +2 -2
  8. package/{chunk-ff0jfn0v.js → chunk-32cq4wpr.js} +1 -1
  9. package/{chunk-nx09zqgs.js → chunk-3612470d.js} +1 -1
  10. package/{chunk-hf4adxe0.js → chunk-45wv1fs5.js} +8 -8
  11. package/chunk-4g4c9h7k.js +2 -0
  12. package/{chunk-8vgpffn7.js → chunk-6w42sr61.js} +1 -1
  13. package/{chunk-esb7n55g.js → chunk-8n07n0cr.js} +3 -3
  14. package/{chunk-96qhpth5.js → chunk-8qf0ne1p.js} +1 -1
  15. package/{chunk-56z5xd5b.js → chunk-8ztgcajz.js} +1 -1
  16. package/chunk-9g45c5md.js +815 -0
  17. package/{chunk-w2naqxqt.js → chunk-aefn23tr.js} +1 -1
  18. package/{chunk-jsvkqae2.js → chunk-asqpx5v7.js} +1 -1
  19. package/chunk-b4hny6hw.js +3 -0
  20. package/chunk-bntsn3wj.js +2 -0
  21. package/{chunk-bc7pkjcn.js → chunk-c4164x55.js} +1 -1
  22. package/{chunk-mfch0b0t.js → chunk-cg0sjdqt.js} +1 -1
  23. package/{chunk-3trkmgfr.js → chunk-cq2q36d1.js} +1 -1
  24. package/chunk-d19ttv1a.js +4 -0
  25. package/{chunk-8grzf8q1.js → chunk-djwvt6jx.js} +1 -1
  26. package/chunk-dqqpbmxt.js +22 -0
  27. package/{chunk-1td6z6ey.js → chunk-dy7qwj3t.js} +1 -1
  28. package/{chunk-dar6wbf2.js → chunk-ejpccmcj.js} +1 -1
  29. package/{chunk-pxaxgkaq.js → chunk-emty5315.js} +1 -1
  30. package/chunk-esg0x95a.js +5 -0
  31. package/{chunk-k423dkjz.js → chunk-ez720ked.js} +1 -1
  32. package/{chunk-h85hv05d.js → chunk-ffm2jpxp.js} +1 -1
  33. package/{chunk-6813v926.js → chunk-g3k8k6fq.js} +3 -3
  34. package/{chunk-63y5gdzw.js → chunk-h3ser37q.js} +1 -1
  35. package/{chunk-74yxy75r.js → chunk-hsk91jms.js} +1 -1
  36. package/chunk-jxgwdz2r.js +5 -0
  37. package/chunk-kvq7pcph.js +2 -0
  38. package/chunk-m0v3d581.js +2 -0
  39. package/chunk-majd3gx9.js +3 -0
  40. package/{chunk-t2x3mztc.js → chunk-mqtnj8ws.js} +1 -1
  41. package/{chunk-xtwx2p9g.js → chunk-mqx96m2g.js} +1 -1
  42. package/{chunk-c4spp880.js → chunk-nrxqr4v5.js} +1 -1
  43. package/{chunk-g185gx8a.js → chunk-nx0ex7qw.js} +1 -1
  44. package/{chunk-qwccndcd.js → chunk-q7wwqn03.js} +1 -1
  45. package/chunk-qgbat4fv.js +6 -0
  46. package/{chunk-08mjtg1e.js → chunk-qqagacne.js} +1 -1
  47. package/{chunk-69qb8p8s.js → chunk-qx83fdv2.js} +1 -1
  48. package/{chunk-ff2vt7cz.js → chunk-r5y1sh4f.js} +1 -1
  49. package/{chunk-2ba6edh8.js → chunk-rg86bhtb.js} +2 -2
  50. package/{chunk-x02z2ccm.js → chunk-rhfzy9c0.js} +1 -1
  51. package/{chunk-t6qr2ay9.js → chunk-rrrn1mkq.js} +1 -1
  52. package/{chunk-56vfy0db.js → chunk-s1cwck4c.js} +1 -1
  53. package/{chunk-tac2wvc7.js → chunk-sjxp2bx3.js} +1 -1
  54. package/chunk-skk6yvnw.js +3 -0
  55. package/{chunk-52m26hh4.js → chunk-t08391mh.js} +1 -1
  56. package/{chunk-2q561dec.js → chunk-t61vyqpd.js} +1 -1
  57. package/{chunk-ta84ehgg.js → chunk-v6ks5v53.js} +1 -1
  58. package/{chunk-hjc5eg5c.js → chunk-w30zfv3m.js} +1 -1
  59. package/{chunk-tbd6wn3q.js → chunk-x84qrz4q.js} +1 -1
  60. package/{chunk-chtv0yj6.js → chunk-xg49s85z.js} +147 -30
  61. package/{chunk-8cj4swpr.js → chunk-xxw67jpk.js} +3 -3
  62. package/{chunk-eqjtq673.js → chunk-yjwtdm71.js} +1 -1
  63. package/{chunk-8vam2893.js → chunk-z6r6wxm6.js} +1 -1
  64. package/{chunk-hsfpbjpc.js → chunk-zgwvkgsz.js} +1 -1
  65. package/cli.js +1 -1
  66. package/launcher.cjs +3 -1
  67. package/package.json +1 -1
  68. package/pkgs/helpers/src/box.tsx +12 -6
  69. package/pkgs/helpers/src/descriptors.ts +6 -0
  70. package/pkgs/helpers/src/icon.tsx +5 -3
  71. package/pkgs/helpers/src/index.ts +2 -2
  72. package/pkgs/helpers/src/text.tsx +29 -5
  73. package/pkgs/provider-antd/src/index.ts +1 -0
  74. package/pkgs/provider-antd/src/manifest.ts +1 -0
  75. package/pkgs/provider-chakra/src/manifest.ts +1 -0
  76. package/pkgs/provider-html/src/index.ts +1 -0
  77. package/pkgs/provider-mui/src/index.ts +10 -0
  78. package/pkgs/provider-mui/src/manifest.ts +1 -0
  79. package/pkgs/provider-none/src/components-inline.tsx +20 -14
  80. package/pkgs/provider-none/src/components.tsx +2 -4
  81. package/pkgs/provider-none/src/index.ts +1 -0
  82. package/pkgs/schema/src/board.ts +13 -1
  83. package/pkgs/schema/src/config.ts +38 -1
  84. package/pkgs/schema/src/flow.ts +230 -0
  85. package/pkgs/schema/src/frame.ts +7 -0
  86. package/pkgs/schema/src/index.ts +24 -0
  87. package/pkgs/schema/src/node-identity.ts +54 -1
  88. package/pkgs/schema/src/node.ts +114 -26
  89. package/pkgs/schema/src/snippet-resolve.ts +18 -12
  90. package/pkgs/schema/src/svg-sanitize.ts +3 -0
  91. package/pkgs/schema/src/theme.ts +56 -0
  92. package/pkgs/schema/src/typeset.ts +21 -4
  93. package/pkgs/shadcn-snapshot/dist/manifest.json +6 -0
  94. package/plugins/claude/velloo/agents/velloo-designer.md +7 -4
  95. package/plugins/claude/velloo/commands/setup.md +13 -0
  96. package/plugins/gemini/GEMINI.md +9 -5
  97. package/plugins/gemini/commands/velloo/setup.toml +11 -0
  98. package/skills/velloo-design/SKILL.md +11 -6
  99. package/skills/velloo-setup/SKILL.md +179 -36
  100. package/canvas/assets/index-CnyI3aig.js +0 -29
  101. package/canvas/assets/index-GNLd0pCG.css +0 -2
  102. package/canvas/assets/lucide-all-CH8LmVh0.js +0 -1
  103. package/chunk-1gkeepkm.js +0 -2
  104. package/chunk-2web93qz.js +0 -2
  105. package/chunk-ab8fga32.js +0 -22
  106. package/chunk-azeqkms7.js +0 -6
  107. package/chunk-e2btp1wd.js +0 -3
  108. package/chunk-ey65err5.js +0 -5
  109. package/chunk-rk238m15.js +0 -4
  110. package/chunk-wfhv9jg4.js +0 -2
  111. package/chunk-wnj432fw.js +0 -3
  112. package/chunk-yfvc8d2g.js +0 -716
  113. package/chunk-z73q4dq9.js +0 -2
  114. package/chunk-ztsckexy.js +0 -5
@@ -0,0 +1,230 @@
1
+ import { z } from "zod";
2
+
3
+ /**
4
+ * An arrow between two frames of one board — what makes a board readable as a
5
+ * flow. It is a spec, not behaviour: nothing here runs, and nothing here emits.
6
+ *
7
+ * One arrow says one of two things, and `state` is what tells them apart:
8
+ *
9
+ * - **A transition** (no `state`): from this step, `label` happens, and the
10
+ * user is on `to`.
11
+ * - **A state** (`state` set): `to` shows the *same step* as `from`, in that
12
+ * state — its loading, empty or error version — and `label` is when.
13
+ *
14
+ * Different layouts are separate screens, so the states of a step are separate
15
+ * frames; the arrows between them are what groups them into one step, which is
16
+ * why a frame carries no state of its own (see {@link deriveFlowSteps}).
17
+ *
18
+ * An arrow has no id: a board holds at most one per ordered pair of frames, so
19
+ * the pair is the identity.
20
+ */
21
+ export const FlowEdgeSchema = z.object({
22
+ /** Frame id the arrow leaves. */
23
+ from: z.string().min(1),
24
+ /** Frame id the arrow points at. */
25
+ to: z.string().min(1),
26
+ /** The trigger or condition, in plain words ("Submit", "Email already taken"). */
27
+ label: z.string().min(1).optional(),
28
+ /** Present ⇒ `to` is `from`'s step in this state. Free text; see {@link FLOW_STATES}. */
29
+ state: z.string().min(1).optional(),
30
+ /**
31
+ * Where along the arrow its label sits, from 0 at the frame it leaves to 1
32
+ * at the one it reaches. Set when someone slides the label out of the way;
33
+ * absent, the canvas puts it mid-way along the part of the arrow in view.
34
+ * Layout only: no reader of the flow looks at it.
35
+ */
36
+ labelAt: z.number().min(0).max(1).optional(),
37
+ });
38
+
39
+ export type FlowEdge = z.infer<typeof FlowEdgeSchema>;
40
+
41
+ /**
42
+ * The states a step conventionally has besides its default one. A vocabulary to
43
+ * offer, not a constraint: `state` stays free text on disk so "offline" or
44
+ * "expired" need no schema change.
45
+ */
46
+ export const FLOW_STATES = ["loading", "empty", "error", "success"] as const;
47
+
48
+ /** What the frame a step's state arrows start from is called. Never stored. */
49
+ export const DEFAULT_FLOW_STATE = "default";
50
+
51
+ interface FlowBoard {
52
+ frames: ReadonlyArray<{ id: string }>;
53
+ edges?: FlowEdge[] | undefined;
54
+ }
55
+
56
+ /**
57
+ * The arrows whose two ends are both still frames on the board. Reading goes
58
+ * through this so a hand-edited or half-synced file draws what it can instead
59
+ * of failing, and writing goes through it so removing a frame takes its arrows
60
+ * with it wherever the removal happened.
61
+ */
62
+ export function liveFlowEdges(board: FlowBoard): FlowEdge[] {
63
+ if (!board.edges || board.edges.length === 0) return [];
64
+ const frames = new Set(board.frames.map((frame) => frame.id));
65
+ return board.edges.filter(
66
+ (edge) => edge.from !== edge.to && frames.has(edge.from) && frames.has(edge.to),
67
+ );
68
+ }
69
+
70
+ /** One frame of a step other than its default one. */
71
+ interface FlowStepState {
72
+ frame: string;
73
+ state: string;
74
+ /** The arrow's label: when the step is in this state. */
75
+ when?: string;
76
+ /**
77
+ * The state this one follows, when its arrow leaves another state rather
78
+ * than the step's default frame: loading → error is "it was sent, then it
79
+ * failed", and without this the read would say only that both exist.
80
+ */
81
+ after?: string;
82
+ }
83
+
84
+ /**
85
+ * A step of the flow: the frame its state arrows start from, plus the frames
86
+ * those arrows reach.
87
+ */
88
+ export interface FlowStep {
89
+ /** The default state's frame. Its id stands for the step. */
90
+ frame: string;
91
+ states: FlowStepState[];
92
+ }
93
+
94
+ export interface FlowSteps {
95
+ /** Every frame belongs to exactly one step; a frame with no state arrows is a step alone. */
96
+ steps: FlowStep[];
97
+ /** Arrows that move between steps, endpoints as drawn (possibly a state's frame). */
98
+ transitions: FlowEdge[];
99
+ /** Frame id → the frame that stands for its step. */
100
+ stepOf: Map<string, string>;
101
+ }
102
+
103
+ /**
104
+ * Group a board's frames into steps by following its state arrows.
105
+ *
106
+ * A step is a tree: the default frame at the root, a state arrow down to each
107
+ * state, and onward from there (loading → error is "it loaded, then failed").
108
+ * Writes keep that shape — one state arrow into a frame, no cycles — but this
109
+ * reads whatever is on disk, so a state arrow that would break the tree is
110
+ * handed back as a transition rather than trusted.
111
+ */
112
+ export function deriveFlowSteps(board: FlowBoard): FlowSteps {
113
+ const parent = new Map<string, FlowEdge>();
114
+ const transitions: FlowEdge[] = [];
115
+ for (const edge of liveFlowEdges(board)) {
116
+ if (edge.state === undefined || parent.has(edge.to) || reaches(parent, edge.from, edge.to)) {
117
+ transitions.push(edge);
118
+ } else {
119
+ parent.set(edge.to, edge);
120
+ }
121
+ }
122
+
123
+ const children = new Map<string, FlowEdge[]>();
124
+ for (const edge of parent.values()) {
125
+ const siblings = children.get(edge.from);
126
+ if (siblings) siblings.push(edge);
127
+ else children.set(edge.from, [edge]);
128
+ }
129
+
130
+ const steps: FlowStep[] = [];
131
+ const stepOf = new Map<string, string>();
132
+ for (const { id } of board.frames) {
133
+ if (parent.has(id)) continue;
134
+ const states: FlowStepState[] = [];
135
+ stepOf.set(id, id);
136
+ const pending = [...(children.get(id) ?? [])].reverse();
137
+ for (let edge = pending.pop(); edge; edge = pending.pop()) {
138
+ stepOf.set(edge.to, id);
139
+ states.push({
140
+ frame: edge.to,
141
+ state: edge.state as string,
142
+ ...(edge.label !== undefined ? { when: edge.label } : {}),
143
+ ...(edge.from !== id ? { after: edge.from } : {}),
144
+ });
145
+ pending.push(...[...(children.get(edge.to) ?? [])].reverse());
146
+ }
147
+ steps.push({ frame: id, states });
148
+ }
149
+ return { steps, transitions, stepOf };
150
+ }
151
+
152
+ /** Whether walking state arrows up from `start` arrives at `target`. */
153
+ function reaches(parent: Map<string, FlowEdge>, start: string, target: string): boolean {
154
+ for (let at: string | undefined = start; at !== undefined; at = parent.get(at)?.from) {
155
+ if (at === target) return true;
156
+ }
157
+ return false;
158
+ }
159
+
160
+ /**
161
+ * State arrows that can only be transitions inside their step. Anyone who
162
+ * reads `state` as "what the arrow arrives at" draws these, and none of them
163
+ * contradicts anything — so a writer keeps each as the transition it is
164
+ * instead of refusing it:
165
+ *
166
+ * - **a way back**: from a state to the frame it came from, or to the step's
167
+ * default ("fix the fields and resubmit", tagged `recovery`). Taken as a
168
+ * state it would make the step's default a state of its own state.
169
+ * - **a state restated**: into a frame that is already that state of the same
170
+ * step ("try again", arriving at `loading` a second time).
171
+ * - **`default`**: the frame a step's states start from is not one of them.
172
+ *
173
+ * A *different* state for a frame that has one, or the same state claimed by
174
+ * another step, is a real contradiction and is left for the writer to refuse.
175
+ */
176
+ export function transitionsDrawnAsStates(board: FlowBoard): FlowEdge[] {
177
+ const parent = new Map<string, FlowEdge>();
178
+ const drawn = new Set<FlowEdge>();
179
+ const second: FlowEdge[] = [];
180
+ const edges = liveFlowEdges(board);
181
+ for (const edge of edges) {
182
+ if (edge.state === undefined) continue;
183
+ if (edge.state === DEFAULT_FLOW_STATE || reaches(parent, edge.from, edge.to)) drawn.add(edge);
184
+ else if (parent.has(edge.to)) second.push(edge);
185
+ else parent.set(edge.to, edge);
186
+ }
187
+ // Which step a frame is in is only known once every arrow has been read:
188
+ // the one that puts `from` in the step may come after the one being judged.
189
+ const stepOf = (frame: string): string => {
190
+ let at = frame;
191
+ for (let up = parent.get(at); up; up = parent.get(at)) at = up.from;
192
+ return at;
193
+ };
194
+ for (const edge of second) {
195
+ if (parent.get(edge.to)?.state === edge.state && stepOf(edge.from) === stepOf(edge.to)) {
196
+ drawn.add(edge);
197
+ }
198
+ }
199
+ return edges.filter((edge) => drawn.has(edge));
200
+ }
201
+
202
+ /**
203
+ * Why a set of arrows can't be a board's flow, or null when it can. The one
204
+ * rule set shared by every writer, so a board never holds arrows
205
+ * {@link deriveFlowSteps} would have to reinterpret.
206
+ */
207
+ export function flowEdgeProblem(board: FlowBoard): string | null {
208
+ const frames = new Set(board.frames.map((frame) => frame.id));
209
+ const pairs = new Set<string>();
210
+ const parent = new Map<string, FlowEdge>();
211
+ for (const edge of board.edges ?? []) {
212
+ for (const end of [edge.from, edge.to]) {
213
+ if (!frames.has(end)) return `no frame "${end}" on this board`;
214
+ }
215
+ if (edge.from === edge.to) return `"${edge.from}" can't point at itself`;
216
+ const pair = `${edge.from}\u0000${edge.to}`;
217
+ if (pairs.has(pair)) return `two arrows from "${edge.from}" to "${edge.to}"`;
218
+ pairs.add(pair);
219
+ if (edge.state === undefined) continue;
220
+ const other = parent.get(edge.to);
221
+ if (other) {
222
+ return `"${edge.to}" is already the ${other.state} state of "${other.from}" — a frame is one state of one step`;
223
+ }
224
+ if (reaches(parent, edge.from, edge.to)) {
225
+ return `"${edge.to}" → "${edge.from}" → "${edge.to}" loops — a step's states need one default frame to start from`;
226
+ }
227
+ parent.set(edge.to, edge);
228
+ }
229
+ return null;
230
+ }
@@ -27,6 +27,13 @@ export const FrameSchema = z.object({
27
27
  group: z.string().optional(),
28
28
  /** Pinned render scheme; absent means follow the canvas default. */
29
29
  scheme: FrameSchemeSchema.optional(),
30
+ /**
31
+ * `"content"`: the height follows the screen — the daemon measures how tall
32
+ * the screen renders at this width after each change to it and sets `h`.
33
+ * Absent ⇒ `h` is whatever it was last set to, which is every frame someone
34
+ * sized: setting a frame's height drops this.
35
+ */
36
+ fit: z.literal("content").optional(),
30
37
  });
31
38
 
32
39
  export type Frame = z.infer<typeof FrameSchema>;
@@ -53,6 +53,9 @@ export {
53
53
  HostAppSchema,
54
54
  type Library,
55
55
  LibrarySchema,
56
+ StylingSchema,
57
+ type ThemeSource,
58
+ ThemeSourceSchema,
56
59
  type ViewportPreset,
57
60
  ViewportPresetSchema,
58
61
  } from "./config.ts";
@@ -73,6 +76,17 @@ export {
73
76
  type RepoComponentsManifest,
74
77
  RepoComponentsManifestSchema,
75
78
  } from "./extension.ts";
79
+ export {
80
+ DEFAULT_FLOW_STATE,
81
+ deriveFlowSteps,
82
+ FLOW_STATES,
83
+ type FlowEdge,
84
+ FlowEdgeSchema,
85
+ type FlowStep,
86
+ flowEdgeProblem,
87
+ liveFlowEdges,
88
+ transitionsDrawnAsStates,
89
+ } from "./flow.ts";
76
90
  export {
77
91
  type Frame,
78
92
  FrameSchema,
@@ -94,11 +108,15 @@ export {
94
108
  } from "./migrate.ts";
95
109
  export {
96
110
  type ComponentNode,
111
+ type ComponentType,
112
+ type ComponentTypeRef,
97
113
  type EmitAsRef,
98
114
  isComponentNode,
115
+ isComponentType,
99
116
  isParamRef,
100
117
  isRepoNode,
101
118
  isSnippetInstance,
119
+ isTextNode,
102
120
  type Node,
103
121
  NodeIdSchema,
104
122
  NodeSchema,
@@ -110,11 +128,14 @@ export {
110
128
  repoImportIssue,
111
129
  repoKey,
112
130
  type SnippetInstance,
131
+ type TextNode,
113
132
  } from "./node.ts";
114
133
  export {
134
+ componentTypesIn,
115
135
  type EmitAsNode,
116
136
  isNode,
117
137
  isSyntheticRef,
138
+ mapComponentTypes,
118
139
  type NodeShape,
119
140
  nodeShape,
120
141
  PARAM_TAG_REF,
@@ -151,12 +172,15 @@ export {
151
172
  type ColorsOverride,
152
173
  ColorsSchema,
153
174
  type LooseTokenGroup,
175
+ namedPaletteRoles,
176
+ normalizeThemeName,
154
177
  resolveColors,
155
178
  spacingCssTokens,
156
179
  TAILWIND_SIZE_NAMES,
157
180
  type Theme,
158
181
  ThemeSchema,
159
182
  TypographySchema,
183
+ themeNameProblem,
160
184
  } from "./theme.ts";
161
185
  export {
162
186
  DEFAULT_TYPESET_NAME,
@@ -1,13 +1,17 @@
1
1
  import {
2
2
  type ComponentNode,
3
+ type ComponentTypeRef,
3
4
  type EmitAsRef,
4
5
  isComponentNode,
6
+ isComponentType,
5
7
  isParamRef,
6
8
  isSnippetInstance,
9
+ isTextNode,
7
10
  type Node,
8
11
  type ParamRef,
9
12
  type RepoComponentRef,
10
13
  type SnippetInstance,
14
+ type TextNode,
11
15
  } from "./node.ts";
12
16
 
13
17
  /**
@@ -46,7 +50,7 @@ export type EmitAsNode = ComponentNode & { $emitAs: EmitAsRef };
46
50
  * The order of the `$`-keys is the contract, stated here once and nowhere
47
51
  * else: `$snippet`, then `$param`, then a component — where an identity
48
52
  * (`$repo`) beats an emit-time facade (`$emitAs`) beats a synthetic ref beats
49
- * a plain name. A malformed value carrying both `$snippet` and `$ref` is a
53
+ * a plain name — then `$text`. A malformed value carrying both `$snippet` and `$ref` is a
50
54
  * snippet instance, as every walker has always read it.
51
55
  */
52
56
  export type NodeShape =
@@ -56,6 +60,8 @@ export type NodeShape =
56
60
  | { kind: "named"; node: ComponentNode; ref: string }
57
61
  | { kind: "snippet"; node: SnippetInstance }
58
62
  | { kind: "param"; node: ParamRef }
63
+ /** A run of text beside elements: a bare text node, no element of its own. */
64
+ | { kind: "text"; node: TextNode; text: string }
59
65
  /** Not a node at all — a raw scalar or object sitting in a node position. */
60
66
  | { kind: "invalid"; value: unknown };
61
67
 
@@ -79,6 +85,7 @@ export function nodeShape(value: unknown): NodeShape {
79
85
  }
80
86
  return { kind: "named", node, ref: node.$ref };
81
87
  }
88
+ if (isTextNode(node)) return { kind: "text", node, text: node.$text };
82
89
  return { kind: "invalid", value };
83
90
  }
84
91
 
@@ -90,3 +97,49 @@ export function nodeShape(value: unknown): NodeShape {
90
97
  export function isNode(value: unknown): value is Node {
91
98
  return nodeShape(value).kind !== "invalid";
92
99
  }
100
+
101
+ /**
102
+ * Replace every component type in a prop value — the value itself, or one
103
+ * nested in the arrays and plain objects it holds — with what `resolve` makes
104
+ * of it. A node inside the value is left as it is: its own props are resolved
105
+ * when that node is walked. Returns `value` itself when nothing changed, so a
106
+ * props bag with no type in it keeps its identity.
107
+ *
108
+ * A component type is not a node (`nodeShape` calls one `invalid`, so no node
109
+ * position ever takes it), which is why the walkers that read prop values ask
110
+ * this rather than switching on the node union.
111
+ */
112
+ export function mapComponentTypes(
113
+ value: unknown,
114
+ resolve: (type: ComponentTypeRef) => unknown,
115
+ ): unknown {
116
+ if (isComponentType(value)) return resolve(value.$type);
117
+ if (Array.isArray(value)) {
118
+ let changed = false;
119
+ const out = value.map((item) => {
120
+ const next = mapComponentTypes(item, resolve);
121
+ if (next !== item) changed = true;
122
+ return next;
123
+ });
124
+ return changed ? out : value;
125
+ }
126
+ if (value === null || typeof value !== "object" || isNode(value)) return value;
127
+ let changed = false;
128
+ const out: Record<string, unknown> = {};
129
+ for (const [key, nested] of Object.entries(value)) {
130
+ const next = mapComponentTypes(nested, resolve);
131
+ if (next !== nested) changed = true;
132
+ out[key] = next;
133
+ }
134
+ return changed ? out : value;
135
+ }
136
+
137
+ /** Every component type in a prop value, by the same reach as {@link mapComponentTypes}. */
138
+ export function componentTypesIn(value: unknown): ComponentTypeRef[] {
139
+ const out: ComponentTypeRef[] = [];
140
+ mapComponentTypes(value, (type) => {
141
+ out.push(type);
142
+ return type;
143
+ });
144
+ return out;
145
+ }
@@ -1,7 +1,7 @@
1
1
  import { z } from "zod";
2
2
 
3
3
  /**
4
- * A node tree is a discriminated union over three shapes, distinguished by
4
+ * A node tree is a discriminated union over four shapes, distinguished by
5
5
  * which `$`-prefixed key is present:
6
6
  *
7
7
  * - `ComponentNode` — a real shadcn / velloo component (`{ $ref }`).
@@ -13,6 +13,11 @@ import { z } from "zod";
13
13
  * substituted with the corresponding instance arg at render time. May
14
14
  * appear as a child node OR anywhere inside a `props` value via the
15
15
  * normal JSON walk during substitution.
16
+ * - `TextNode` — `{ $text }`. A run of text sitting beside elements
17
+ * (`<li>Remote <a>Apply</a></li>`). It renders as a bare text node, with no
18
+ * element of its own, so no selector in the app's stylesheet can tell the
19
+ * design's DOM from the app's. Text that is a component's *whole* content
20
+ * stays in `props.children`.
16
21
  *
17
22
  * Optional fields are spelled `?: T | undefined` rather than `?: T`. Under
18
23
  * `exactOptionalPropertyTypes` those differ, but not for a shape that round-
@@ -116,18 +121,54 @@ export type ParamRef = {
116
121
  $param: string;
117
122
  };
118
123
 
119
- export type Node = ComponentNode | SnippetInstance | ParamRef;
124
+ export type TextNode = {
125
+ $text: string;
126
+ };
127
+
128
+ export type Node = ComponentNode | SnippetInstance | ParamRef | TextNode;
129
+
130
+ /**
131
+ * A component *type* held as a prop value: `component={ScrollArea}`,
132
+ * `as={Link}`, `icon={IconBolt}` — the component itself, not an element of it.
133
+ * The reference is a component node with no props, children or `$id`, so the
134
+ * name resolves exactly as a `$ref` of that name would (the app's component by
135
+ * its `$repo`, else an extension, else the screen's library). It is a value,
136
+ * not a node: it sits anywhere a prop value can, nested in arrays and objects
137
+ * too, and never in a tree's children.
138
+ */
139
+ export type ComponentTypeRef = {
140
+ $ref: string;
141
+ $repo?: RepoComponentRef | undefined;
142
+ };
143
+
144
+ export type ComponentType = { $type: ComponentTypeRef };
120
145
 
146
+ // Walkers ask these of raw prop values too (`rightSection: null`), so each one
147
+ // has to answer for a value that is not an object at all.
121
148
  export function isComponentNode(n: Node): n is ComponentNode {
122
- return typeof (n as { $ref?: unknown }).$ref === "string";
149
+ return typeof (n as { $ref?: unknown } | null | undefined)?.$ref === "string";
123
150
  }
124
151
 
125
152
  export function isSnippetInstance(n: Node): n is SnippetInstance {
126
- return typeof (n as { $snippet?: unknown }).$snippet === "string";
153
+ return typeof (n as { $snippet?: unknown } | null | undefined)?.$snippet === "string";
127
154
  }
128
155
 
129
156
  export function isParamRef(n: Node): n is ParamRef {
130
- return typeof (n as { $param?: unknown }).$param === "string";
157
+ return typeof (n as { $param?: unknown } | null | undefined)?.$param === "string";
158
+ }
159
+
160
+ export function isTextNode(n: Node): n is TextNode {
161
+ return typeof (n as { $text?: unknown } | null | undefined)?.$text === "string";
162
+ }
163
+
164
+ export function isComponentType(value: unknown): value is ComponentType {
165
+ const inner = (value as { $type?: unknown } | null | undefined)?.$type;
166
+ return (
167
+ !Array.isArray(value) &&
168
+ typeof inner === "object" &&
169
+ inner !== null &&
170
+ typeof (inner as { $ref?: unknown }).$ref === "string"
171
+ );
131
172
  }
132
173
 
133
174
  /**
@@ -147,18 +188,16 @@ export const NodeIdSchema = z
147
188
 
148
189
  /**
149
190
  * A bare string/number in a `children` array is a common first-try shape
150
- * ("just put the label here"). Rather than reject it — `children` renders
151
- * nodes only — auto-wrap it into an inline `Box as="span"` so it renders as
152
- * text without stacking. Wrapping (vs. allowing scalars through) keeps
153
- * `children` typed `Node[]` for every downstream consumer. The styled-run
154
- * idiom (a mixed array in the `children` *prop*) is still preferred for rich
155
- * text; this just removes a needless failure.
191
+ * ("just put the label here"), and it means what it says: text. It becomes a
192
+ * text node rather than being rejected, which keeps `children` typed `Node[]`
193
+ * for every downstream consumer. An empty string is nothing to hold.
156
194
  */
157
- function wrapScalarChild(item: string | number | Node): Node {
158
- if (typeof item === "string" || typeof item === "number") {
159
- return { $ref: "Box", props: { as: "span", children: item } };
160
- }
161
- return item;
195
+ function scalarChildren(items: (string | number | Node)[]): Node[] {
196
+ return items.flatMap((item): Node[] => {
197
+ if (typeof item !== "string" && typeof item !== "number") return [item];
198
+ const text = String(item);
199
+ return text === "" ? [] : [{ $text: text }];
200
+ });
162
201
  }
163
202
 
164
203
  const EmitAsSchema = z.object({
@@ -202,14 +241,56 @@ export const RepoComponentRefSchema = z.object({
202
241
  proxy: z.string().min(1).optional(),
203
242
  });
204
243
 
244
+ const ComponentTypeSchema: z.ZodType<ComponentType> = z.strictObject({
245
+ $type: z.strictObject({
246
+ // Printed bare as a JS expression (`component={AppShell.Section}`), so it
247
+ // has to be an identifier path — which also keeps a synthetic `velloo:` ref out.
248
+ $ref: z.string().regex(/^[A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)*$/, {
249
+ message: 'a component type\'s $ref must be a component name like "ScrollArea"',
250
+ }),
251
+ $repo: RepoComponentRefSchema.optional(),
252
+ }),
253
+ });
254
+
255
+ /**
256
+ * Prop values are free-form JSON with one exception: an object carrying
257
+ * `$type` must be a well-formed component type, wherever it sits. Its `$repo`
258
+ * reaches codegen's imports and the canvas bundler exactly as a node's does, so
259
+ * it is held to the same checks.
260
+ */
261
+ function checkComponentTypes(value: unknown, ctx: z.RefinementCtx, path: PropertyKey[]): void {
262
+ if (Array.isArray(value)) {
263
+ value.forEach((item, i) => {
264
+ checkComponentTypes(item, ctx, [...path, i]);
265
+ });
266
+ return;
267
+ }
268
+ if (value === null || typeof value !== "object") return;
269
+ if (Object.hasOwn(value, "$type")) {
270
+ const parsed = ComponentTypeSchema.safeParse(value);
271
+ if (!parsed.success) {
272
+ for (const issue of parsed.error.issues) {
273
+ ctx.addIssue({ code: "custom", message: issue.message, path: [...path, ...issue.path] });
274
+ }
275
+ }
276
+ return;
277
+ }
278
+ for (const [key, nested] of Object.entries(value))
279
+ checkComponentTypes(nested, ctx, [...path, key]);
280
+ }
281
+
282
+ const PropsSchema = z.record(z.string(), z.unknown()).superRefine((props, ctx) => {
283
+ checkComponentTypes(props, ctx, []);
284
+ });
285
+
205
286
  const ComponentNodeSchema: z.ZodType<ComponentNode> = z.lazy(() =>
206
287
  z.object({
207
288
  $ref: z.string().min(1),
208
289
  $id: NodeIdSchema.optional(),
209
- props: z.record(z.string(), z.unknown()).optional(),
290
+ props: PropsSchema.optional(),
210
291
  children: z
211
292
  .array(z.union([z.string(), z.number(), NodeSchema]))
212
- .transform((items) => items.map(wrapScalarChild))
293
+ .transform(scalarChildren)
213
294
  .optional(),
214
295
  $emitAs: EmitAsSchema.optional(),
215
296
  $repo: RepoComponentRefSchema.optional(),
@@ -251,14 +332,14 @@ const SnippetInstanceSchema: z.ZodType<SnippetInstance> = z.object({
251
332
  $snippet: z.string().min(1),
252
333
  $id: NodeIdSchema.optional(),
253
334
  $extraClassName: z.string().optional(),
254
- args: z.record(z.string(), z.unknown()).optional(),
335
+ args: PropsSchema.optional(),
255
336
  $overrides: z
256
337
  .record(
257
338
  z.string().regex(/^$|^\d+(\.\d+)*$|^@[a-zA-Z][a-zA-Z0-9_-]*$/, {
258
339
  message:
259
340
  'override key must be a dotted index path like "0.2", an "@id" reference with a literal leading @ (e.g. "@row-active" to target the body node whose $id is "row-active"), or "" for the body root',
260
341
  }),
261
- z.object({ props: z.record(z.string(), z.unknown()) }),
342
+ z.object({ props: PropsSchema }),
262
343
  )
263
344
  .optional(),
264
345
  });
@@ -267,10 +348,14 @@ const ParamRefSchema: z.ZodType<ParamRef> = z.object({
267
348
  $param: z.string().min(1),
268
349
  });
269
350
 
351
+ const TextNodeSchema: z.ZodType<TextNode> = z.strictObject({
352
+ $text: z.string().min(1),
353
+ });
354
+
270
355
  /**
271
356
  * Key-routed parse instead of `z.union`: a malformed node yields the
272
357
  * issues of the *one* branch its `$`-key selects (with a full path),
273
- * not a three-branch union explosion. The MCP layer surfaces these
358
+ * not a four-branch union explosion. The MCP layer surfaces these
274
359
  * issues verbatim to agents, so error shape is part of the tool UX.
275
360
  */
276
361
  export const NodeSchema: z.ZodType<Node> = z
@@ -280,7 +365,7 @@ export const NodeSchema: z.ZodType<Node> = z
280
365
  ctx.addIssue({
281
366
  code: "custom",
282
367
  message:
283
- 'node must be an object with one of "$ref" (component), "$snippet" (instance), or "$param" (param ref)',
368
+ 'node must be an object with one of "$ref" (component), "$snippet" (instance), "$param" (param ref), or "$text" (text beside elements)',
284
369
  });
285
370
  return z.NEVER;
286
371
  }
@@ -292,12 +377,15 @@ export const NodeSchema: z.ZodType<Node> = z
292
377
  ? SnippetInstanceSchema
293
378
  : typeof v.$param === "string"
294
379
  ? ParamRefSchema
295
- : null;
380
+ : typeof v.$text === "string"
381
+ ? TextNodeSchema
382
+ : null;
296
383
  if (!branch) {
297
384
  ctx.addIssue({
298
385
  code: "custom",
299
- message:
300
- 'node needs exactly one of "$ref" (component), "$snippet" (snippet instance), or "$param" (param ref, snippet bodies only)',
386
+ message: isComponentType(v)
387
+ ? `a component type ({"$type": …}) is a prop value, not a node — to render one here, write {"$ref": ${JSON.stringify(v.$type.$ref)}}`
388
+ : 'node needs exactly one of "$ref" (component), "$snippet" (snippet instance), "$param" (param ref, snippet bodies only), or "$text" (text beside elements)',
301
389
  });
302
390
  return z.NEVER;
303
391
  }
@@ -332,6 +420,6 @@ export const NodeSchema: z.ZodType<Node> = z
332
420
  * have to narrow by node kind first.
333
421
  */
334
422
  export function nodeId(n: Node): string | undefined {
335
- if (isParamRef(n)) return undefined;
423
+ if (isParamRef(n) || isTextNode(n)) return undefined;
336
424
  return (n as { $id?: string }).$id;
337
425
  }