velloo 0.7.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 (89) hide show
  1. package/README.md +8 -1
  2. package/canvas/assets/index-CcPwhD7W.js +31 -0
  3. package/canvas/assets/index-CkVteRZ9.css +2 -0
  4. package/canvas/assets/lucide-all-Cezxn40A.js +1 -0
  5. package/canvas/index.html +2 -2
  6. package/{chunk-4y2xy1kf.js → chunk-07fxzrx2.js} +1 -1
  7. package/{chunk-acsv6m3n.js → chunk-32cq4wpr.js} +1 -1
  8. package/{chunk-gfppsgvv.js → chunk-3612470d.js} +1 -1
  9. package/{chunk-mdgwdqnq.js → chunk-45wv1fs5.js} +8 -8
  10. package/chunk-4g4c9h7k.js +2 -0
  11. package/{chunk-64pex3mw.js → chunk-6w42sr61.js} +1 -1
  12. package/{chunk-0pxm5v0b.js → chunk-8n07n0cr.js} +2 -2
  13. package/{chunk-ejppcfa5.js → chunk-8qf0ne1p.js} +1 -1
  14. package/{chunk-0e5j6nqs.js → chunk-8ztgcajz.js} +1 -1
  15. package/{chunk-brnkprt5.js → chunk-9g45c5md.js} +142 -50
  16. package/{chunk-pjkkp7xa.js → chunk-aefn23tr.js} +1 -1
  17. package/{chunk-82x2jkfq.js → chunk-asqpx5v7.js} +1 -1
  18. package/{chunk-dvh0b2r4.js → chunk-b4hny6hw.js} +1 -1
  19. package/chunk-bntsn3wj.js +2 -0
  20. package/{chunk-gbq5wv7x.js → chunk-c4164x55.js} +1 -1
  21. package/{chunk-b8vcbbrg.js → chunk-cg0sjdqt.js} +1 -1
  22. package/{chunk-fmcm06xb.js → chunk-cq2q36d1.js} +1 -1
  23. package/{chunk-s84vxcdv.js → chunk-d19ttv1a.js} +1 -1
  24. package/{chunk-rqd4swxx.js → chunk-djwvt6jx.js} +1 -1
  25. package/chunk-dqqpbmxt.js +22 -0
  26. package/{chunk-eh2xp4v6.js → chunk-dy7qwj3t.js} +1 -1
  27. package/{chunk-rxkvjyst.js → chunk-ejpccmcj.js} +1 -1
  28. package/{chunk-sme6hv6f.js → chunk-emty5315.js} +1 -1
  29. package/{chunk-7gyzh0vq.js → chunk-esg0x95a.js} +1 -1
  30. package/{chunk-5yhnnhkd.js → chunk-ez720ked.js} +1 -1
  31. package/{chunk-md4mpvqc.js → chunk-ffm2jpxp.js} +1 -1
  32. package/{chunk-84n18xgx.js → chunk-g3k8k6fq.js} +2 -2
  33. package/{chunk-531xzfq5.js → chunk-h3ser37q.js} +1 -1
  34. package/{chunk-zv17t1n7.js → chunk-hsk91jms.js} +1 -1
  35. package/{chunk-snak5zpr.js → chunk-jxgwdz2r.js} +1 -1
  36. package/chunk-kvq7pcph.js +2 -0
  37. package/{chunk-qp3wahg4.js → chunk-m0v3d581.js} +1 -1
  38. package/{chunk-gcce587w.js → chunk-majd3gx9.js} +1 -1
  39. package/{chunk-qrnkrkkt.js → chunk-mqtnj8ws.js} +1 -1
  40. package/{chunk-0vaff40g.js → chunk-mqx96m2g.js} +1 -1
  41. package/{chunk-5s1wjhtj.js → chunk-nrxqr4v5.js} +1 -1
  42. package/{chunk-qqrac4va.js → chunk-nx0ex7qw.js} +1 -1
  43. package/{chunk-j306gr0c.js → chunk-q7wwqn03.js} +1 -1
  44. package/{chunk-xzf52wvn.js → chunk-qgbat4fv.js} +1 -1
  45. package/{chunk-asfgvzse.js → chunk-qqagacne.js} +1 -1
  46. package/{chunk-xg71h7nb.js → chunk-qx83fdv2.js} +1 -1
  47. package/{chunk-d93kpv7s.js → chunk-r5y1sh4f.js} +1 -1
  48. package/{chunk-kkvf0xk2.js → chunk-rg86bhtb.js} +2 -2
  49. package/{chunk-667ag8ts.js → chunk-rhfzy9c0.js} +1 -1
  50. package/{chunk-3m28pvzr.js → chunk-rrrn1mkq.js} +1 -1
  51. package/{chunk-fvezyap4.js → chunk-s1cwck4c.js} +1 -1
  52. package/{chunk-g7d065ek.js → chunk-sjxp2bx3.js} +1 -1
  53. package/{chunk-d0j8dg1e.js → chunk-t08391mh.js} +1 -1
  54. package/{chunk-j4fn2a2b.js → chunk-t61vyqpd.js} +1 -1
  55. package/{chunk-bms1sg4c.js → chunk-v6ks5v53.js} +1 -1
  56. package/{chunk-abr2hgay.js → chunk-w30zfv3m.js} +1 -1
  57. package/{chunk-ykpctq15.js → chunk-x84qrz4q.js} +1 -1
  58. package/{chunk-ex36z3b3.js → chunk-xg49s85z.js} +8 -8
  59. package/{chunk-5x4nw0zz.js → chunk-xxw67jpk.js} +2 -2
  60. package/{chunk-vdbr81a3.js → chunk-yjwtdm71.js} +1 -1
  61. package/{chunk-vs8j3x6t.js → chunk-z6r6wxm6.js} +1 -1
  62. package/{chunk-srp5gyr1.js → chunk-zgwvkgsz.js} +1 -1
  63. package/cli.js +1 -1
  64. package/package.json +1 -1
  65. package/pkgs/provider-antd/src/index.ts +1 -0
  66. package/pkgs/provider-html/src/index.ts +1 -0
  67. package/pkgs/provider-mui/src/index.ts +10 -0
  68. package/pkgs/provider-none/src/index.ts +1 -0
  69. package/pkgs/schema/src/board.ts +13 -1
  70. package/pkgs/schema/src/config.ts +34 -0
  71. package/pkgs/schema/src/flow.ts +230 -0
  72. package/pkgs/schema/src/frame.ts +7 -0
  73. package/pkgs/schema/src/index.ts +21 -0
  74. package/pkgs/schema/src/node-identity.ts +48 -0
  75. package/pkgs/schema/src/node.ts +80 -9
  76. package/pkgs/schema/src/theme.ts +56 -0
  77. package/plugins/claude/velloo/agents/velloo-designer.md +7 -4
  78. package/plugins/claude/velloo/commands/setup.md +13 -0
  79. package/plugins/gemini/GEMINI.md +9 -5
  80. package/plugins/gemini/commands/velloo/setup.toml +11 -0
  81. package/skills/velloo-design/SKILL.md +11 -6
  82. package/skills/velloo-setup/SKILL.md +179 -36
  83. package/canvas/assets/index-BQ5710lu.js +0 -29
  84. package/canvas/assets/index-GNLd0pCG.css +0 -2
  85. package/canvas/assets/lucide-all-DIRZdJPK.js +0 -1
  86. package/chunk-bys7pqdb.js +0 -2
  87. package/chunk-g8xrwjvq.js +0 -22
  88. package/chunk-jbgde2v9.js +0 -2
  89. package/chunk-sjjnm4a1.js +0 -2
@@ -1,3 +1,3 @@
1
1
  // @bun
2
- import{CURRENT_SCHEMA_VERSION}from"./chunk-5x4nw0zz.js";import{snapshotVersion2}from"./chunk-p93x4pn2.js";import{TOOL_VERSION}from"./chunk-3m28pvzr.js";function buildDefaultConfig(opts={}){return{schemaVersion:CURRENT_SCHEMA_VERSION,toolVersion:TOOL_VERSION,name:opts.name??"design",folderId:crypto.randomUUID(),libraries:{default:opts.library??{id:"shadcn-upstream",version:snapshotVersion2,source:"binary",componentsPath:"binary"}},defaultLibrary:"default",viewportPresets:[{name:"Mobile",w:390,h:844},{name:"Tablet",w:768,h:1024},{name:"Desktop",w:1440,h:900}],...opts.defaultScreen?{defaultScreen:opts.defaultScreen}:{},...opts.defaultBoard?{defaultBoard:opts.defaultBoard}:{},...opts.hostApp?{hostApp:opts.hostApp}:{},...opts.hostApps?{hostApps:opts.hostApps}:{},...opts.feedback?{feedback:opts.feedback}:{},...opts.styling?{styling:opts.styling}:{},...opts.codegen?{codegen:opts.codegen}:{},...opts.designSystem?{designSystem:opts.designSystem}:{}}}
2
+ import{CURRENT_SCHEMA_VERSION}from"./chunk-xxw67jpk.js";import{snapshotVersion2}from"./chunk-p93x4pn2.js";import{TOOL_VERSION}from"./chunk-rrrn1mkq.js";function buildDefaultConfig(opts={}){return{schemaVersion:CURRENT_SCHEMA_VERSION,toolVersion:TOOL_VERSION,name:opts.name??"design",folderId:crypto.randomUUID(),libraries:{default:opts.library??{id:"shadcn-upstream",version:snapshotVersion2,source:"binary",componentsPath:"binary"}},defaultLibrary:"default",viewportPresets:[{name:"Mobile",w:390,h:844},{name:"Tablet",w:768,h:1024},{name:"Desktop",w:1440,h:900}],...opts.defaultScreen?{defaultScreen:opts.defaultScreen}:{},...opts.defaultBoard?{defaultBoard:opts.defaultBoard}:{},...opts.hostApp?{hostApp:opts.hostApp}:{},...opts.hostApps?{hostApps:opts.hostApps}:{},...opts.feedback?{feedback:opts.feedback}:{},...opts.styling?{styling:opts.styling}:{},...opts.codegen?{codegen:opts.codegen}:{},...opts.designSystem?{designSystem:opts.designSystem}:{},...opts.themeSource?{themeSource:opts.themeSource}:{}}}
3
3
  export{buildDefaultConfig};
@@ -1,2 +1,2 @@
1
1
  // @bun
2
- import"./chunk-bys7pqdb.js";import"./chunk-5x4nw0zz.js";import{normalizeCloudUrl,loadCredential,listCredentials,deleteCredential,clearCredentials,revokeCredential}from"./chunk-kkvf0xk2.js";import{defaultCloudUrl}from"./chunk-abr2hgay.js";import{defineCommand}from"./chunk-2smsg2ct.js";var logout_default=defineCommand({meta:{name:"logout",description:"Log out of velloo-cloud (signs the CLI token out and removes it)"},args:{url:{type:"string",description:"velloo-cloud base URL (default: $VELLOO_CLOUD_URL or the built-in default)"},all:{type:"boolean",default:!1,description:"Remove saved credentials for every cloud"}},async run({args}){if(args.all){await Promise.all(Object.entries(await listCredentials()).map(([cloudUrl,credential])=>revokeCredential(cloudUrl,credential.token)));let count=await clearCredentials();console.log(count>0?`velloo logout: removed credentials for ${count} cloud${count===1?"":"s"}`:"velloo logout: no saved credentials");return}let cloudUrl=args.url?normalizeCloudUrl(args.url):defaultCloudUrl(),existing=await loadCredential(cloudUrl);if(existing)await revokeCredential(cloudUrl,existing.token);if(await deleteCredential(cloudUrl))console.log(`velloo logout: logged out of ${cloudUrl}${existing?.email?` (${existing.email})`:""}`);else console.log(`velloo logout: not logged in to ${cloudUrl}`)}});export{logout_default as default};
2
+ import"./chunk-bntsn3wj.js";import"./chunk-xxw67jpk.js";import{normalizeCloudUrl,loadCredential,listCredentials,deleteCredential,clearCredentials,revokeCredential}from"./chunk-rg86bhtb.js";import{defaultCloudUrl}from"./chunk-w30zfv3m.js";import{defineCommand}from"./chunk-2smsg2ct.js";var logout_default=defineCommand({meta:{name:"logout",description:"Log out of velloo-cloud (signs the CLI token out and removes it)"},args:{url:{type:"string",description:"velloo-cloud base URL (default: $VELLOO_CLOUD_URL or the built-in default)"},all:{type:"boolean",default:!1,description:"Remove saved credentials for every cloud"}},async run({args}){if(args.all){await Promise.all(Object.entries(await listCredentials()).map(([cloudUrl,credential])=>revokeCredential(cloudUrl,credential.token)));let count=await clearCredentials();console.log(count>0?`velloo logout: removed credentials for ${count} cloud${count===1?"":"s"}`:"velloo logout: no saved credentials");return}let cloudUrl=args.url?normalizeCloudUrl(args.url):defaultCloudUrl(),existing=await loadCredential(cloudUrl);if(existing)await revokeCredential(cloudUrl,existing.token);if(await deleteCredential(cloudUrl))console.log(`velloo logout: logged out of ${cloudUrl}${existing?.email?` (${existing.email})`:""}`);else console.log(`velloo logout: not logged in to ${cloudUrl}`)}});export{logout_default as default};
package/cli.js CHANGED
@@ -1,3 +1,3 @@
1
1
  #!/usr/bin/env bun
2
2
  // @bun
3
- import"./chunk-s23gtejf.js";import{COMMANDS}from"./chunk-acsv6m3n.js";import{TOOL_VERSION}from"./chunk-3m28pvzr.js";import{maybeNotifyAboutUpdate}from"./chunk-s84vxcdv.js";import{defineCommand,runMain}from"./chunk-2smsg2ct.js";var main=defineCommand({meta:{name:"velloo",version:TOOL_VERSION,description:"Open-source, local-first canvas for agent-driven design."},subCommands:COMMANDS});await runMain(main);var command=process.argv[2];if(!["mcp","__daemon","__update_check","run","upgrade"].includes(command??""))await maybeNotifyAboutUpdate();
3
+ import"./chunk-s23gtejf.js";import{COMMANDS}from"./chunk-32cq4wpr.js";import{TOOL_VERSION}from"./chunk-rrrn1mkq.js";import{maybeNotifyAboutUpdate}from"./chunk-d19ttv1a.js";import{defineCommand,runMain}from"./chunk-2smsg2ct.js";var main=defineCommand({meta:{name:"velloo",version:TOOL_VERSION,description:"Open-source, local-first canvas for agent-driven design."},subCommands:COMMANDS});await runMain(main);var command=process.argv[2];if(!["mcp","__daemon","__update_check","run","upgrade"].includes(command??""))await maybeNotifyAboutUpdate();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "velloo",
3
- "version": "0.7.0",
3
+ "version": "0.8.0",
4
4
  "type": "module",
5
5
  "description": "Local-first, code-shaped design canvas — your AI agent designs with your real components, in your repo",
6
6
  "license": "Apache-2.0",
@@ -54,6 +54,7 @@ export function createProvider(): FrameworkAdapter {
54
54
  ),
55
55
  styleChannel: STYLE_PROP,
56
56
  styleChannels: ["style"],
57
+ themeRoles: ["background", "foreground", "primary", "destructive", "card"],
57
58
  mcpIntro: () => [...ANTD_INTRO],
58
59
  renderPass: (theme, dark) => makeRenderPass(theme, dark ?? false),
59
60
  codegenModule: "antd",
@@ -59,6 +59,7 @@ export function createProvider(): FrameworkAdapter {
59
59
  loadManifest: async () => HTML_MANIFEST,
60
60
  styleChannel: STYLE_PROP,
61
61
  styleChannels: ["style"],
62
+ themeRoles: ["background", "foreground", "card", "muted", "border", "input"],
62
63
  registryForChannel: () => registry,
63
64
  mcpIntro: () => HTML_INTRO,
64
65
  hostStylesheets: true,
@@ -57,6 +57,16 @@ export function createProvider(): FrameworkAdapter {
57
57
  ),
58
58
  styleChannel: SX_PROP,
59
59
  styleChannels: ["sx"],
60
+ themeRoles: [
61
+ "background",
62
+ "foreground",
63
+ "primary",
64
+ "secondary",
65
+ "destructive",
66
+ "card",
67
+ "muted",
68
+ "border",
69
+ ],
60
70
  mcpIntro: () => [...MUI_INTRO],
61
71
  renderPass: (theme, dark) => makeRenderPass(theme, dark ?? false),
62
72
  codegenModule: "@mui/material",
@@ -59,6 +59,7 @@ export function createProvider(): FrameworkAdapter {
59
59
  // is `none` resolves to the inline-`style` channel instead.
60
60
  styleChannel: TAILWIND_CLASSNAME,
61
61
  styleChannels: ["tailwind-classname", "style"],
62
+ themeRoles: ["background", "foreground", "card", "muted", "border", "input"],
62
63
  mcpIntro: (channel) => [...(channel === "style" ? NONE_INLINE_INTRO : NONE_INTRO)],
63
64
  // Inline-styled primitives for the `style` channel; Tailwind-classed for
64
65
  // every other channel. Lets a `none/none` folder paint with the JIT off.
@@ -1,4 +1,5 @@
1
1
  import { z } from "zod";
2
+ import { FlowEdgeSchema } from "./flow.ts";
2
3
  import { FrameSchema } from "./frame.ts";
3
4
  import { ResourceIdSchema } from "./ids.ts";
4
5
 
@@ -12,7 +13,11 @@ import { ResourceIdSchema } from "./ids.ts";
12
13
  export const BoardGroupSchema = z.object({
13
14
  id: ResourceIdSchema,
14
15
  name: z.string().min(1),
15
- /** CSS color value; canvas uses it for the group region tint and tag chip. */
16
+ /**
17
+ * CSS color value. The sidebar uses a board group's for its chip and rail;
18
+ * a frame group may carry one, but the canvas draws nothing for frame
19
+ * groups yet.
20
+ */
16
21
  color: z.string().optional(),
17
22
  });
18
23
 
@@ -61,6 +66,13 @@ export const BoardSchema = z.object({
61
66
  frames: z.array(FrameSchema).default([]),
62
67
  /** Frame regions within this board (see {@link BoardGroupSchema}). */
63
68
  groups: z.array(BoardGroupSchema).default([]),
69
+ /**
70
+ * Arrows between this board's frames (see {@link FlowEdgeSchema}). Absent ⇒
71
+ * a board of unconnected frames, which is every existing one — and the field
72
+ * is dropped again when the last arrow goes, so such a board's file never
73
+ * changes shape.
74
+ */
75
+ edges: z.array(FlowEdgeSchema).optional(),
64
76
  });
65
77
 
66
78
  export type Board = z.infer<typeof BoardSchema>;
@@ -106,6 +106,35 @@ export const HostAppSchema = z.object({
106
106
 
107
107
  export type HostApp = z.infer<typeof HostAppSchema>;
108
108
 
109
+ /**
110
+ * Where the design's theme came from in the app: what `velloo init` read (or
111
+ * left alone), and what an `import_theme` from a stylesheet into the default
112
+ * theme records again. Agents are told by it what checking the theme against
113
+ * the app means — a theme read from a JavaScript theme object (`mantine`,
114
+ * `mui`) is compared with that module, since no stylesheet holds it.
115
+ *
116
+ * "stylesheet" — the app's stylesheet, and what it brings with it.
117
+ * "design-md" — a DESIGN.md, layered on the stylesheet.
118
+ * "mantine" / "mui" — the theme object the app hands that library.
119
+ * "library-defaults" — the app uses `library` and sets no theme of its own,
120
+ * so the design is on that library's.
121
+ * "preset" — nothing of the app's: a built-in preset.
122
+ */
123
+ export const ThemeSourceSchema = z.object({
124
+ kind: z.enum(["stylesheet", "design-md", "mantine", "mui", "library-defaults", "preset"]),
125
+ /**
126
+ * The app's files it was read from, relative to the app root, the one
127
+ * `kind` names first: a stylesheet is followed by the sheets it imports,
128
+ * the tailwind.config it merged and the modules whose font loader calls
129
+ * set a face. Absent when nothing was read from a file in the app.
130
+ */
131
+ files: z.array(AppRelativePathSchema).min(1).optional(),
132
+ /** The library whose own theme it is — for `"library-defaults"`. */
133
+ library: z.string().min(1).optional(),
134
+ });
135
+
136
+ export type ThemeSource = z.infer<typeof ThemeSourceSchema>;
137
+
109
138
  /** The CSS framework a folder styles with. See `ConfigSchema.styling`. */
110
139
  export const StylingSchema = z.object({ framework: z.enum(["tailwind", "none"]) });
111
140
 
@@ -206,6 +235,11 @@ export const ConfigSchema = z
206
235
  * a file somewhere unconventional, or pins the choice when several exist.
207
236
  */
208
237
  designSystem: z.object({ path: AppRelativePathSchema }).optional(),
238
+ /**
239
+ * Where the default theme was read from in the app. See `ThemeSourceSchema`.
240
+ * Absent for a design made before it was recorded: nothing is assumed.
241
+ */
242
+ themeSource: ThemeSourceSchema.optional(),
209
243
  /**
210
244
  * Named host apps for monorepos — the multi-app twin of `hostApp`, keyed
211
245
  * by a short app name (`"web"`, `"admin"`; `init`'s multi-app scan uses
@@ -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>;
@@ -54,6 +54,8 @@ export {
54
54
  type Library,
55
55
  LibrarySchema,
56
56
  StylingSchema,
57
+ type ThemeSource,
58
+ ThemeSourceSchema,
57
59
  type ViewportPreset,
58
60
  ViewportPresetSchema,
59
61
  } from "./config.ts";
@@ -74,6 +76,17 @@ export {
74
76
  type RepoComponentsManifest,
75
77
  RepoComponentsManifestSchema,
76
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";
77
90
  export {
78
91
  type Frame,
79
92
  FrameSchema,
@@ -95,8 +108,11 @@ export {
95
108
  } from "./migrate.ts";
96
109
  export {
97
110
  type ComponentNode,
111
+ type ComponentType,
112
+ type ComponentTypeRef,
98
113
  type EmitAsRef,
99
114
  isComponentNode,
115
+ isComponentType,
100
116
  isParamRef,
101
117
  isRepoNode,
102
118
  isSnippetInstance,
@@ -115,9 +131,11 @@ export {
115
131
  type TextNode,
116
132
  } from "./node.ts";
117
133
  export {
134
+ componentTypesIn,
118
135
  type EmitAsNode,
119
136
  isNode,
120
137
  isSyntheticRef,
138
+ mapComponentTypes,
121
139
  type NodeShape,
122
140
  nodeShape,
123
141
  PARAM_TAG_REF,
@@ -154,12 +172,15 @@ export {
154
172
  type ColorsOverride,
155
173
  ColorsSchema,
156
174
  type LooseTokenGroup,
175
+ namedPaletteRoles,
176
+ normalizeThemeName,
157
177
  resolveColors,
158
178
  spacingCssTokens,
159
179
  TAILWIND_SIZE_NAMES,
160
180
  type Theme,
161
181
  ThemeSchema,
162
182
  TypographySchema,
183
+ themeNameProblem,
163
184
  } from "./theme.ts";
164
185
  export {
165
186
  DEFAULT_TYPESET_NAME,
@@ -1,7 +1,9 @@
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,
7
9
  isTextNode,
@@ -95,3 +97,49 @@ export function nodeShape(value: unknown): NodeShape {
95
97
  export function isNode(value: unknown): value is Node {
96
98
  return nodeShape(value).kind !== "invalid";
97
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
+ }
@@ -127,20 +127,48 @@ export type TextNode = {
127
127
 
128
128
  export type Node = ComponentNode | SnippetInstance | ParamRef | TextNode;
129
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 };
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.
130
148
  export function isComponentNode(n: Node): n is ComponentNode {
131
- return typeof (n as { $ref?: unknown }).$ref === "string";
149
+ return typeof (n as { $ref?: unknown } | null | undefined)?.$ref === "string";
132
150
  }
133
151
 
134
152
  export function isSnippetInstance(n: Node): n is SnippetInstance {
135
- return typeof (n as { $snippet?: unknown }).$snippet === "string";
153
+ return typeof (n as { $snippet?: unknown } | null | undefined)?.$snippet === "string";
136
154
  }
137
155
 
138
156
  export function isParamRef(n: Node): n is ParamRef {
139
- return typeof (n as { $param?: unknown }).$param === "string";
157
+ return typeof (n as { $param?: unknown } | null | undefined)?.$param === "string";
140
158
  }
141
159
 
142
160
  export function isTextNode(n: Node): n is TextNode {
143
- return typeof (n as { $text?: unknown }).$text === "string";
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
+ );
144
172
  }
145
173
 
146
174
  /**
@@ -213,11 +241,53 @@ export const RepoComponentRefSchema = z.object({
213
241
  proxy: z.string().min(1).optional(),
214
242
  });
215
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
+
216
286
  const ComponentNodeSchema: z.ZodType<ComponentNode> = z.lazy(() =>
217
287
  z.object({
218
288
  $ref: z.string().min(1),
219
289
  $id: NodeIdSchema.optional(),
220
- props: z.record(z.string(), z.unknown()).optional(),
290
+ props: PropsSchema.optional(),
221
291
  children: z
222
292
  .array(z.union([z.string(), z.number(), NodeSchema]))
223
293
  .transform(scalarChildren)
@@ -262,14 +332,14 @@ const SnippetInstanceSchema: z.ZodType<SnippetInstance> = z.object({
262
332
  $snippet: z.string().min(1),
263
333
  $id: NodeIdSchema.optional(),
264
334
  $extraClassName: z.string().optional(),
265
- args: z.record(z.string(), z.unknown()).optional(),
335
+ args: PropsSchema.optional(),
266
336
  $overrides: z
267
337
  .record(
268
338
  z.string().regex(/^$|^\d+(\.\d+)*$|^@[a-zA-Z][a-zA-Z0-9_-]*$/, {
269
339
  message:
270
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',
271
341
  }),
272
- z.object({ props: z.record(z.string(), z.unknown()) }),
342
+ z.object({ props: PropsSchema }),
273
343
  )
274
344
  .optional(),
275
345
  });
@@ -313,8 +383,9 @@ export const NodeSchema: z.ZodType<Node> = z
313
383
  if (!branch) {
314
384
  ctx.addIssue({
315
385
  code: "custom",
316
- message:
317
- 'node needs exactly one of "$ref" (component), "$snippet" (snippet instance), "$param" (param ref, snippet bodies only), or "$text" (text beside elements)',
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)',
318
389
  });
319
390
  return z.NEVER;
320
391
  }