@divlet/core 0.0.1 → 0.0.3

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 (43) hide show
  1. package/AGENTS.md +3 -2
  2. package/BACKLOG.md +4 -3
  3. package/CHANGELOG.md +17 -2
  4. package/README.md +16 -6
  5. package/dist/core/lib/acceptance.d.ts +18 -0
  6. package/dist/core/lib/acceptance.js +29 -0
  7. package/dist/core/lib/assets.js +1 -1
  8. package/dist/core/lib/browser-board.d.ts +41 -0
  9. package/dist/core/lib/browser-board.js +203 -0
  10. package/dist/core/lib/browser-mcp.d.ts +131 -0
  11. package/dist/core/lib/browser-mcp.js +448 -0
  12. package/dist/core/lib/browser-media-type.d.ts +4 -0
  13. package/dist/core/lib/browser-media-type.js +7 -0
  14. package/dist/core/lib/browser-runtime.d.ts +598 -0
  15. package/dist/core/lib/browser-runtime.js +674 -0
  16. package/dist/core/lib/codec.js +2 -1
  17. package/dist/core/lib/contract.d.ts +1 -152
  18. package/dist/core/lib/contract.js +3 -359
  19. package/dist/core/lib/diff.js +1 -1
  20. package/dist/core/lib/prepare.d.ts +27 -0
  21. package/dist/core/lib/prepare.js +41 -0
  22. package/dist/core/lib/project.js +3 -3
  23. package/dist/core/lib/render.d.ts +2 -9
  24. package/dist/core/lib/render.js +10 -35
  25. package/dist/core/lib/runtime.d.ts +1 -1
  26. package/dist/core/lib/runtime.js +1 -21
  27. package/dist/core/lib/tools.d.ts +154 -0
  28. package/dist/core/lib/tools.js +364 -0
  29. package/dist/core/lib/validation.d.ts +5 -0
  30. package/dist/core/lib/validation.js +21 -0
  31. package/dist/skills/divlet/SKILL.md +7 -1
  32. package/docs/README.md +2 -1
  33. package/docs/agents.md +11 -1
  34. package/docs/architecture.md +15 -7
  35. package/docs/board.md +1 -1
  36. package/docs/browser-mcp.md +108 -0
  37. package/docs/browser-runtime.md +6 -4
  38. package/docs/publishing.md +54 -5
  39. package/docs/testing.md +16 -3
  40. package/examples/README.md +2 -2
  41. package/examples/landing/landing.divlet +1 -1
  42. package/package.json +24 -5
  43. package/skills/divlet/SKILL.md +7 -1
@@ -11,6 +11,7 @@ import postcss from 'postcss';
11
11
  import { serializeDeclarations } from "./declarations.js";
12
12
  import { validateDocument, validateHead, safeAttribute, DivletError, VOID_TAGS, LIMITS, documentLimits, supportsTag } from "./model.js";
13
13
  import { readAssetReference } from "./asset-reference.js";
14
+ import { withinUtf8Budget } from "./validation.js";
14
15
  const HTML_NS = 'http://www.w3.org/1999/xhtml';
15
16
  const META_ID = 'divlet-metadata';
16
17
  const ID = /^[A-Za-z][A-Za-z0-9_.:-]{0,95}$/;
@@ -327,7 +328,7 @@ function parseInlineStyle(value) {
327
328
  }
328
329
  export function decodeHtml(source, { fallbackId = 'imported', previous, limits = LIMITS } = {}) {
329
330
  documentLimits(limits);
330
- if (typeof source !== 'string' || Buffer.byteLength(source) > LIMITS.bytes * 2)
331
+ if (typeof source !== 'string' || !withinUtf8Budget(source, LIMITS.bytes * 2))
331
332
  error('HTML must be a string of at most 4 MiB');
332
333
  const html = source;
333
334
  const prior = previous ? validateDocument(previous, { limits }) : null;
@@ -2,150 +2,7 @@ import type { BoardEntry } from './boards.ts';
2
2
  import type { HubLike } from './launch.ts';
3
3
  import { resolveProject } from './projects.ts';
4
4
  import { resolveOpenFile } from './import.ts';
5
- /**
6
- * The host-neutral schema. A tool's parameters are written once in this form; each host renders it — Pi as TypeBox, MCP as
7
- * JSON Schema — so a plugin or adapter that adds a tool describes it here and nowhere else.
8
- */
9
- interface SchemaBase {
10
- optional?: boolean;
11
- description?: string;
12
- }
13
- export type Schema = (SchemaBase & {
14
- kind: 'string';
15
- maxLength?: number;
16
- minLength?: number;
17
- }) | (SchemaBase & {
18
- kind: 'integer';
19
- minimum?: number;
20
- maximum?: number;
21
- }) | (SchemaBase & {
22
- kind: 'number';
23
- minimum?: number;
24
- maximum?: number;
25
- }) | (SchemaBase & {
26
- kind: 'boolean';
27
- }) | (SchemaBase & {
28
- kind: 'oneOf';
29
- values: readonly string[];
30
- }) | (SchemaBase & {
31
- kind: 'object';
32
- properties: Record<string, Schema> | null;
33
- }) | (SchemaBase & {
34
- kind: 'array';
35
- items: Schema;
36
- maxItems?: number;
37
- }) | (SchemaBase & {
38
- kind: 'union';
39
- variants: Schema[];
40
- }) | (SchemaBase & {
41
- kind: 'any';
42
- }) | (SchemaBase & {
43
- kind: 'null';
44
- });
45
- /** One tool's parameters, by name. */
46
- export type ToolParameters = Record<string, Schema>;
47
- /** JSON Schema as a host asks for it (the shape MCP `tools/list` answers). */
48
- export type JsonSchema = Record<string, any>;
49
- /** A host-neutral schema: the tool list is written once, Pi and MCP render it. */
50
- export declare const S: {
51
- string: (options?: Omit<Extract<Schema, {
52
- kind: "string";
53
- }>, "kind">) => Schema;
54
- integer: (options?: Omit<Extract<Schema, {
55
- kind: "integer";
56
- }>, "kind">) => Schema;
57
- number: (options?: Omit<Extract<Schema, {
58
- kind: "number";
59
- }>, "kind">) => Schema;
60
- boolean: (options?: Omit<Extract<Schema, {
61
- kind: "boolean";
62
- }>, "kind">) => Schema;
63
- /** One of a fixed set of strings. */
64
- oneOf: (values: readonly string[], options?: Omit<Extract<Schema, {
65
- kind: "oneOf";
66
- }>, "kind" | "values">) => Schema;
67
- /** An object with these properties (`null` shape: any object). */
68
- object: (properties?: Record<string, Schema> | null, options?: Omit<Extract<Schema, {
69
- kind: "object";
70
- }>, "kind" | "properties">) => Schema;
71
- array: (items: Schema, options?: Omit<Extract<Schema, {
72
- kind: "array";
73
- }>, "kind" | "items">) => Schema;
74
- union: (variants: Schema[], options?: Omit<Extract<Schema, {
75
- kind: "union";
76
- }>, "kind" | "variants">) => Schema;
77
- any: (options?: Omit<Extract<Schema, {
78
- kind: "any";
79
- }>, "kind">) => Schema;
80
- };
81
- export declare const optional: (schema: Schema) => Schema;
82
- export declare const nullable: (schema: Schema) => Schema;
83
- /** A JSON Schema property for one parameter (the shape MCP asks for). */
84
- export declare function propertySchema(schema: Schema): JsonSchema;
85
- /** The `inputSchema` of a tool for MCP (`tools/list`). */
86
- export declare function inputSchema(tool: Tool): JsonSchema;
87
- /** The subset of TypeBox a host needs to render the schema; the Pi adapter passes the real module. */
88
- export interface TypeBoxApi {
89
- String(options?: Record<string, unknown>): unknown;
90
- Integer(options?: Record<string, unknown>): unknown;
91
- Number(options?: Record<string, unknown>): unknown;
92
- Boolean(options?: Record<string, unknown>): unknown;
93
- Literal(value: string): unknown;
94
- Union(items: unknown[]): unknown;
95
- Object(properties: Record<string, unknown>, options?: Record<string, unknown>): unknown;
96
- Record(key: unknown, value: unknown): unknown;
97
- Array(items: unknown, options?: Record<string, unknown>): unknown;
98
- Null(): unknown;
99
- Unknown(): unknown;
100
- Optional(item: unknown): unknown;
101
- }
102
- /** The same schema as TypeBox types, for the Pi extension. */
103
- export declare function toTypeBox(schema: ToolParameters, Type: TypeBoxApi): unknown;
104
- export declare const LARGE_FRAME_BYTES = 60000;
105
- /** Rename document → frame in tool results, leaving user data (node maps, metadata…) untouched. */
106
- export declare function forAgent(value: unknown, key?: string): unknown;
107
- /** The project view for an agent: what it needs to choose a frame, without file paths and without one line per use. */
108
- export declare function compactProject(info: any): any;
109
- /** Components with their name and overridable texts, only the first few places used and a count. */
110
- export declare function compactComponents(result: any): any;
111
- /** An outline of a frame model: ids, tags, names and the start of text, three levels deep, with counts where it is cut. */
112
- export declare function outlineOf(model: any, bytes: number): Record<string, unknown>;
113
- /** What a tool may ask of the board it runs on. `any` results are the runtime's own shapes, shaped for agents below. */
114
- export interface BoardRuntime {
115
- info(): any;
116
- read(query: Record<string, unknown>): any;
117
- patch(args: Record<string, unknown>): Promise<any>;
118
- edit(args: Record<string, unknown>): Promise<any>;
119
- undo(args: Record<string, unknown>): Promise<any>;
120
- redo(args: Record<string, unknown>): Promise<any>;
121
- create(args: Record<string, unknown>): Promise<any>;
122
- getEntry(documentId: string): any;
123
- render(documentId: string): any;
124
- createArtboard(args: Record<string, unknown>): Promise<any>;
125
- removeDocument(args: Record<string, unknown>): Promise<any>;
126
- frame(args: Record<string, unknown>): Promise<any>;
127
- mark(args: Record<string, unknown>): any;
128
- close(): Promise<void>;
129
- [key: string]: any;
130
- }
131
- /** The context a tool runs with: the board that owns the project. */
132
- export interface ToolContext {
133
- runtime: BoardRuntime;
134
- }
135
- /** One capability: named, described and parameterised once, rendered by every host. */
136
- export interface Tool {
137
- name: string;
138
- /** Short human title (absent on hidden tools). */
139
- title?: string;
140
- kind: 'open' | 'runtime';
141
- description: string;
142
- /** One line a host may advertise instead of the description. */
143
- snippet?: string;
144
- hidden?: boolean;
145
- parameters: ToolParameters;
146
- /** Absent for `open_divlet`, which the session handles itself. */
147
- run?(context: ToolContext, params: any): any;
148
- }
5
+ export * from './tools.ts';
149
6
  /** Where a tool call comes from: the client's directory and its abort signal. */
150
7
  export interface CallContext {
151
8
  cwd?: string;
@@ -180,14 +37,6 @@ export interface Session {
180
37
  active: Map<string, string | null | undefined>;
181
38
  readonly cwd: string;
182
39
  }
183
- /** A runtime is required for every tool but `open_divlet`; the session resolves it before running one. */
184
- export declare const TOOLS: Tool[];
185
- export declare const toolList: ({ hidden }?: {
186
- hidden?: boolean;
187
- }) => Tool[];
188
- export declare const toolNames: Set<string>;
189
- /** Run one runtime tool against a board's runtime (used by the session and by the board's own api/tool route). */
190
- export declare function runOnRuntime(name: string, args: unknown, runtime: BoardRuntime): Promise<any>;
191
40
  /**
192
41
  * One client's session: opens projects (or attaches to the board that already owns them), resolves a project for a tool call
193
42
  * and forwards the call to whichever board owns it. `openBoard({ file, args, cwd })` starts a board in this process (the host
@@ -18,364 +18,8 @@ import { launch } from "./launch.js";
18
18
  import { boardInfo, boardTool, findBoard } from "./boards.js";
19
19
  import { resolveProject } from "./projects.js";
20
20
  import { resolveOpenFile } from "./import.js";
21
- /** A host-neutral schema: the tool list is written once, Pi and MCP render it. */
22
- export const S = {
23
- string: (options = {}) => ({ kind: 'string', ...options }),
24
- integer: (options = {}) => ({ kind: 'integer', ...options }),
25
- number: (options = {}) => ({ kind: 'number', ...options }),
26
- boolean: (options = {}) => ({ kind: 'boolean', ...options }),
27
- /** One of a fixed set of strings. */
28
- oneOf: (values, options = {}) => ({ kind: 'oneOf', values, ...options }),
29
- /** An object with these properties (`null` shape: any object). */
30
- object: (properties = null, options = {}) => ({ kind: 'object', properties, ...options }),
31
- array: (items, options = {}) => ({ kind: 'array', items, ...options }),
32
- union: (variants, options = {}) => ({ kind: 'union', variants, ...options }),
33
- any: (options = {}) => ({ kind: 'any', ...options }),
34
- };
35
- export const optional = (schema) => ({ ...schema, optional: true });
36
- export const nullable = (schema) => ({ kind: 'union', variants: [schema, { kind: 'null' }] });
37
- /** A JSON Schema property for one parameter (the shape MCP asks for). */
38
- export function propertySchema(schema) {
39
- switch (schema.kind) {
40
- case 'string': return { type: 'string', ...(schema.maxLength ? { maxLength: schema.maxLength } : {}), ...(schema.minLength ? { minLength: schema.minLength } : {}), ...(schema.description ? { description: schema.description } : {}) };
41
- case 'integer': return { type: 'integer', ...(schema.minimum !== undefined ? { minimum: schema.minimum } : {}), ...(schema.maximum !== undefined ? { maximum: schema.maximum } : {}), ...(schema.description ? { description: schema.description } : {}) };
42
- case 'number': return { type: 'number', ...(schema.minimum !== undefined ? { minimum: schema.minimum } : {}), ...(schema.maximum !== undefined ? { maximum: schema.maximum } : {}), ...(schema.description ? { description: schema.description } : {}) };
43
- case 'boolean': return { type: 'boolean', ...(schema.description ? { description: schema.description } : {}) };
44
- case 'oneOf': return { type: 'string', enum: schema.values, ...(schema.description ? { description: schema.description } : {}) };
45
- case 'object': {
46
- if (schema.properties === null)
47
- return { type: 'object', additionalProperties: true, ...(schema.description ? { description: schema.description } : {}) };
48
- const shape = Object.fromEntries(Object.entries(schema.properties).map(([name, item]) => [name, propertySchema(item)]));
49
- const required = Object.entries(schema.properties).filter(([, item]) => !item.optional).map(([name]) => name);
50
- return { type: 'object', properties: shape, ...(required.length ? { required } : {}), additionalProperties: false, ...(schema.description ? { description: schema.description } : {}) };
51
- }
52
- case 'array': return { type: 'array', items: propertySchema(schema.items), ...(schema.maxItems !== undefined ? { maxItems: schema.maxItems } : {}), ...(schema.description ? { description: schema.description } : {}) };
53
- case 'null': return { type: 'null' };
54
- case 'union': return { anyOf: schema.variants.map(propertySchema), ...(schema.description ? { description: schema.description } : {}) };
55
- default: return schema.description ? { description: schema.description } : {};
56
- }
57
- }
58
- /** The `inputSchema` of a tool for MCP (`tools/list`). */
59
- export function inputSchema(tool) {
60
- const shape = Object.fromEntries(Object.entries(tool.parameters).map(([name, schema]) => [name, propertySchema(schema)]));
61
- const required = Object.entries(tool.parameters).filter(([, schema]) => !schema.optional).map(([name]) => name);
62
- return { type: 'object', properties: shape, ...(required.length ? { required } : {}), additionalProperties: false };
63
- }
64
- /** The same schema as TypeBox types, for the Pi extension. */
65
- export function toTypeBox(schema, Type) {
66
- const convert = (item) => {
67
- let next;
68
- switch (item.kind) {
69
- case 'string':
70
- next = Type.String({ ...(item.maxLength ? { maxLength: item.maxLength } : {}), ...(item.description ? { description: item.description } : {}) });
71
- break;
72
- case 'integer':
73
- next = Type.Integer({ ...(item.minimum !== undefined ? { minimum: item.minimum } : {}), ...(item.maximum !== undefined ? { maximum: item.maximum } : {}), ...(item.description ? { description: item.description } : {}) });
74
- break;
75
- case 'number':
76
- next = Type.Number({ ...(item.minimum !== undefined ? { minimum: item.minimum } : {}), ...(item.maximum !== undefined ? { maximum: item.maximum } : {}), ...(item.description ? { description: item.description } : {}) });
77
- break;
78
- case 'boolean':
79
- next = Type.Boolean({ ...(item.description ? { description: item.description } : {}) });
80
- break;
81
- case 'oneOf':
82
- next = Type.Union(item.values.map(value => Type.Literal(value)));
83
- break;
84
- case 'object':
85
- next = item.properties === null ? Type.Record(Type.String(), Type.Unknown()) : Type.Object(Object.fromEntries(Object.entries(item.properties).map(([name, child]) => [name, convert(child)])), { additionalProperties: false });
86
- break;
87
- case 'array':
88
- next = Type.Array(convert(item.items), { ...(item.maxItems !== undefined ? { maxItems: item.maxItems } : {}) });
89
- break;
90
- case 'null':
91
- next = Type.Null();
92
- break;
93
- case 'union':
94
- next = Type.Union(item.variants.map(convert));
95
- break;
96
- default:
97
- next = Type.Unknown();
98
- break;
99
- }
100
- return item.optional ? Type.Optional(next) : next;
101
- };
102
- return Type.Object(Object.fromEntries(Object.entries(schema).map(([name, item]) => [name, convert(item)])));
103
- }
104
- /* ---- results: one vocabulary for every host -------------------------------------------------------- */
105
- const RENAMED = { documentId: 'frameId', documentIds: 'frameIds', documents: 'frames', document: 'frame' };
106
- const VERBATIM = new Set(['nodes', 'descendants', 'metadata', 'tokens', 'assets', 'variables', 'theme', 'attrs', 'style', 'frontmatter', 'config']);
107
- export const LARGE_FRAME_BYTES = 60_000;
108
- /** Rename document → frame in tool results, leaving user data (node maps, metadata…) untouched. */
109
- export function forAgent(value, key) {
110
- if (Array.isArray(value))
111
- return value.map(item => forAgent(item));
112
- if (!value || typeof value !== 'object' || (key !== undefined && VERBATIM.has(key)))
113
- return value;
114
- return Object.fromEntries(Object.entries(value).map(([name, item]) => [RENAMED[name] ?? name, forAgent(item, name)]));
115
- }
116
- /** The project view for an agent: what it needs to choose a frame, without file paths and without one line per use. */
117
- export function compactProject(info) {
118
- if (!Array.isArray(info?.documents))
119
- return info;
120
- const { documents, clientPlugins, stateVersion, ...rest } = info;
121
- return {
122
- ...rest,
123
- documents: documents.map(({ id, name, version, artboard, instances, uses, usedBy, components, metadata }) => ({
124
- id, name, version, artboard, instances,
125
- ...(components?.length ? { components } : {}),
126
- ...(uses?.length ? { usesFramesOf: [...new Set(uses.map(ref => String(ref).split('/')[0]))], componentUses: uses.length } : {}),
127
- ...(usedBy?.length ? { usedBy } : {}),
128
- ...(metadata && Object.keys(metadata).length ? { metadata } : {}),
129
- })),
130
- };
131
- }
132
- /** Components with their name and overridable texts, only the first few places used and a count. */
133
- export function compactComponents(result) {
134
- if (!Array.isArray(result?.components))
135
- return result;
136
- return {
137
- ...result,
138
- components: result.components.map(component => {
139
- const places = Array.isArray(component.instances) ? component.instances : [];
140
- if (places.length <= 5)
141
- return component;
142
- return { ...component, instances: places.slice(0, 5), instanceCount: places.length, instancesNote: 'Only the first 5 places are listed; read a frame (view:nodes, query) to find the others.' };
143
- }),
144
- };
145
- }
146
- /** An outline of a frame model: ids, tags, names and the start of text, three levels deep, with counts where it is cut. */
147
- export function outlineOf(model, bytes) {
148
- const nodes = model.nodes || {};
149
- const walk = (id, depth) => {
150
- const node = nodes[id];
151
- if (!node)
152
- return null;
153
- if (node.type === 'text')
154
- return { id, text: String(node.content || '').slice(0, 40) };
155
- if (node.type === 'ref')
156
- return { id, ref: node.ref };
157
- const entry = { id, tag: node.tag };
158
- if (node.metadata?.name)
159
- entry.name = node.metadata.name;
160
- const kids = node.children || [];
161
- if (depth >= 3) {
162
- if (kids.length)
163
- entry.children = kids.length;
164
- return entry;
165
- }
166
- entry.children = kids.slice(0, 12).map((child) => walk(child, depth + 1)).filter(Boolean);
167
- if (kids.length > 12)
168
- entry.more = kids.length - 12;
169
- return entry;
170
- };
171
- return {
172
- documentId: model.id, version: model.version, name: model.name, artboard: model.artboard, large: true, bytes, nodes: Object.keys(nodes).length,
173
- outline: walk(model.rootId, 0),
174
- hint: `This frame is large (${Math.round(bytes / 1000)} KB). Read parts with view:nodes (ids, query, depth) or pass full:true to get the whole model.`,
175
- };
176
- }
177
- /* ---- the tools ------------------------------------------------------------------------------------ */
178
- const VIEWS = ['project', 'frame', 'nodes', 'components', 'selection', 'preview', 'diagnostics', 'site', 'history'];
179
- const FRAME_ACTIONS = ['create', 'duplicate', 'delete'];
180
- const EDIT_OPS = ['insert', 'move', 'duplicate', 'remove', 'text', 'wrap', 'unwrap', 'detach', 'rename'];
181
- /** A runtime is required for every tool but `open_divlet`; the session resolves it before running one. */
182
- export const TOOLS = [
183
- {
184
- name: 'open_divlet',
185
- title: 'Open divlet',
186
- kind: 'open',
187
- description: 'Open a layout project and start its local board: returns the board URL, the project version and the frames with their versions. A project is one .divlet file holding every frame (HTML elements with Tailwind classes), component, shared token and CSS; generated pages live in .divlet/<name>/. path may be a .divlet file, an importable foreign file (an existing .pen is converted once into the sibling .divlet, which is what opens; the source is never modified) or a folder/collection; collections return projects with absolute file targets and a launcher URL, and a project is selected by passing its file as path. An empty folder creates a Divlet project.divlet; createDemo controls its starter frame (false leaves zero frames). Without path the current directory is used. Reuses an open project — including one another client already opened. Optionally add a frame with document (a frame model) without overwriting an existing one.',
188
- snippet: 'Open a divlet layout project and get its board URL. Load the divlet skill for the workflow.',
189
- parameters: {
190
- path: optional(S.string({ description: 'Project .divlet file (or a foreign file an importer plugin converts, such as a .pen or a static .html page) or collection folder, relative to the client\'s working directory; defaults to it.' })),
191
- cwd: optional(S.string({ description: 'Same as path (older name).' })),
192
- port: optional(S.integer({ minimum: 0, maximum: 65535 })),
193
- createDemo: optional(S.boolean({ description: 'Seed a starter frame only when an ordinary source is missing. Default true; false creates an empty Divlet project.' })),
194
- document: optional(S.object()),
195
- },
196
- },
197
- {
198
- name: 'read_divlet',
199
- title: 'Read divlet',
200
- kind: 'runtime',
201
- description: 'Read the project. view:project (default) lists frames with versions and sizes, which frames provide the components each one uses (view:components lists the components) and the shared state; view:frame returns one frame model; view:nodes returns nodes of a frame (ids, query, depth, limit) with parent and ancestors; view:components lists every component as "frameId/nodeId" with its name, the text node IDs an instance can override and where it is used; view:selection is what the user selected on the board; view:preview is measured geometry of the rendered page (node rects, overflow flags, contentHeight); view:history is the action log (since = sequence number); view:diagnostics lists file problems; view:site describes the Markdown site (route = one composed page). Read a frame before patching it.',
202
- snippet: 'Inspect frames, nodes, components, the user selection and rendered geometry.',
203
- parameters: {
204
- projectId: optional(S.string()),
205
- view: optional(S.oneOf(VIEWS)),
206
- frameId: optional(S.string({ description: 'Frame to read; defaults to the selected or first frame.' })),
207
- since: optional(S.integer({ minimum: 0, description: 'For view:history, only return changes with a higher seq.' })),
208
- route: optional(S.string({ description: 'For view:site, a site path such as /guide/start/; omit to inspect the site configuration and content tree.' })),
209
- ids: optional(S.array(S.string(), { maxItems: 100 })),
210
- query: optional(S.string({ description: 'Case-insensitive node id, tag, text, class or metadata search.' })),
211
- depth: optional(S.integer({ minimum: 0, maximum: 8 })),
212
- limit: optional(S.integer({ minimum: 1, maximum: 100 })),
213
- full: optional(S.boolean({ description: 'For view:frame: return the whole model even when the frame is large (otherwise a large frame comes back as an outline).' })),
214
- },
215
- run({ runtime }, params) {
216
- const { projectId, frameId, view, full, ...query } = params;
217
- const result = runtime.read({ ...query, view: view === 'frame' ? 'document' : view, documentId: frameId });
218
- if (!view || view === 'project')
219
- return compactProject(result);
220
- if (view === 'components')
221
- return compactComponents(result);
222
- if (view === 'frame' && !full) {
223
- const bytes = JSON.stringify(result).length;
224
- if (bytes > LARGE_FRAME_BYTES)
225
- return outlineOf(result, bytes);
226
- }
227
- return result;
228
- },
229
- },
230
- {
231
- name: 'patch_divlet',
232
- title: 'Patch divlet',
233
- kind: 'runtime',
234
- description: 'Apply one atomic, validated partial patch. With frameId: patch that frame (name, head, artboard, nodes, tokens, css, metadata) at its current expectedVersion; nodes are addressed by stable IDs. Without frameId: patch the project (name, tokens, css, metadata, outputDir, assets) at its version. For frame patches objects merge, arrays replace and null removes a property or node. A node accepts tag, className, attrs, style, metadata and text content (a text node: content). Declare styles per element with nodes.<id>.styles = {padding-top:16px, color:#fff, font-family:Inter} as a JSON object of CSS property to value: each property becomes a Tailwind utility (an arbitrary-property class when no utility exists) and replaces the class that set it earlier; null removes it; no Tailwind knowledge needed. Or write Tailwind utilities in className directly (compiled locally; arbitrary values and variants work, for example bg-[#14121c] md:px-10 hover:underline). Tokens named --color-*, --font-*, --breakpoint-* become theme classes (--color-brand gives bg-brand); css is for what utilities cannot express. The result lists warnings, e.g. classes that do nothing. Components: set reusable:true on an element; an instance is a node { type: ref, ref: frameId/nodeId, descendants: { componentNodeId: { content|text|className|style|attrs|tag } } }; editing a component re-renders every frame that uses it. For inserting, moving, wrapping or removing nodes, edit_divlet is simpler.',
235
- snippet: 'Atomically change a frame or the shared project tokens/CSS by stable IDs.',
236
- parameters: {
237
- projectId: optional(S.string()),
238
- frameId: optional(S.string({ description: 'Frame to patch. Omit to patch project-level name, tokens, css, metadata, assets or outputDir (null resets its default).' })),
239
- expectedVersion: S.integer({ minimum: 1, description: 'Current version of the frame (or of the project when frameId is omitted).' }),
240
- label: optional(S.string({ maxLength: 200, description: 'Short intent shown in the undo menu and action log.' })),
241
- patch: S.object({
242
- name: optional(nullable(S.string())),
243
- head: optional(S.any({ description: 'Frame head patch: description (string, at most 2048 characters), icon (safe URL); objects merge and null removes fields or the whole head.' })),
244
- artboard: optional(S.object()),
245
- rootId: optional(S.string()),
246
- nodes: optional(S.object()),
247
- tokens: optional(S.object(null, { description: 'CSS custom properties. Tailwind theme names (--color-*, --font-*, --text-*, --radius-*, --shadow-*, --breakpoint-*, --spacing) also create classes.' })),
248
- css: optional(nullable(S.string({ description: 'Custom CSS — the exception: @keyframes, complex selectors, third-party content. Everything else goes into className.' }))),
249
- metadata: optional(nullable(S.object())),
250
- outputDir: optional(nullable(S.string({ description: 'Project-only relative build directory; null resets the source-stem default.' }))),
251
- assets: optional(nullable(S.object(null, { description: 'Project-only hierarchical resource patch: directories contain canonical base64 Data-URL leaves; null removes a leaf/directory or resets the whole tree. Reference exact declared paths with asset: URIs; read project.resources for payload-free descriptions.' }))),
252
- }),
253
- },
254
- async run({ runtime }, params) {
255
- const { projectId, frameId, ...change } = params;
256
- if (!frameId)
257
- return runtime.patchProject({ ...change, actor: 'agent' });
258
- return runtime.patch({ ...change, documentId: frameId, actor: 'agent' });
259
- },
260
- },
261
- {
262
- name: 'edit_divlet',
263
- title: 'Edit divlet',
264
- kind: 'runtime',
265
- description: 'Apply one operation to a frame by stable node IDs: insert (a preset, spec, html fragment or component instance), move, duplicate, remove, text, wrap, unwrap, detach (turn an instance into ordinary elements) or rename. Pass expectedVersion from the frame you read and a short label. Insert accepts spec (a node spec: tag, className, text, attrs, style, children, or ref for an instance), or html (a foreign fragment; classes, inline styles and safe attributes are kept; scripts and handlers are dropped; native asset references require spec instead); place with parentId plus index, after or before.',
266
- snippet: 'Insert elements, HTML or component instances; move, duplicate, wrap, unwrap, retext, detach, rename or remove nodes by ID.',
267
- parameters: {
268
- projectId: optional(S.string()),
269
- frameId: S.string(),
270
- expectedVersion: S.integer({ minimum: 1 }),
271
- label: optional(S.string({ maxLength: 200, description: 'Short intent shown in the undo menu and action log.' })),
272
- op: S.object({
273
- type: S.oneOf(EDIT_OPS),
274
- id: optional(S.string()),
275
- ids: optional(S.array(S.string(), { maxItems: 200 })),
276
- parentId: optional(S.string()),
277
- index: optional(S.integer({ minimum: 0 })),
278
- after: optional(S.string({ description: 'Place right after this sibling node.' })),
279
- before: optional(S.string({ description: 'Place right before this sibling node.' })),
280
- spec: optional(S.union([S.string(), S.object(), S.array(S.union([S.string(), S.object()]), { maxItems: 100 })], { description: 'Node spec(s) to insert: { preset|tag, className, text, attrs, style, children, ref (an instance of a component) }, or a preset/tag name.' })),
281
- content: optional(S.string()),
282
- to: optional(S.string({ description: 'For rename: the new node ID (a letter first, then letters, digits, - _ . : ).' })),
283
- html: optional(S.string({ maxLength: 524288, description: 'For insert: a foreign HTML fragment instead of spec (classes, inline styles and safe attributes are kept; scripts and handlers are dropped). Use a native spec, not HTML, to reference declared project assets.' })),
284
- }),
285
- },
286
- async run({ runtime }, params) {
287
- const { projectId, frameId, ...change } = params;
288
- return runtime.edit({ ...change, documentId: frameId, actor: 'agent' });
289
- },
290
- },
291
- {
292
- name: 'frame_divlet',
293
- title: 'Frames of divlet',
294
- kind: 'runtime',
295
- description: 'Create, duplicate or delete a frame (an artboard on the board: one page or screen at a viewport width). create: a blank frame (name, width, height; placed beside the others unless x/y are given) — fill it with edit_divlet. duplicate: a copy of frameId (optionally renamed or resized, e.g. width 390 for a phone); component reference targets remain shared. delete: remove frameId (refused while other frames use its components).',
296
- snippet: 'Create, duplicate or delete frames (artboards).',
297
- parameters: {
298
- projectId: optional(S.string()),
299
- action: S.oneOf(FRAME_ACTIONS),
300
- expectedVersion: optional(S.integer({ minimum: 1, description: 'Current version of the frame being duplicated or deleted (not needed for create).' })),
301
- frameId: optional(S.string({ description: 'For duplicate and delete: the frame to use.' })),
302
- name: optional(S.string({ maxLength: 512 })),
303
- width: optional(S.number({ minimum: 1, maximum: 16384, description: 'Viewport width in CSS px: 390 phone, 768 tablet, 1280 laptop, 1440 desktop.' })),
304
- height: optional(S.number({ minimum: 1, maximum: 16384 })),
305
- x: optional(S.number()),
306
- y: optional(S.number()),
307
- label: optional(S.string({ maxLength: 200 })),
308
- },
309
- async run({ runtime }, params) {
310
- const { action, frameId, expectedVersion, name, width, height, x, y, label } = params;
311
- if (action === 'delete') {
312
- if (!frameId)
313
- throw Object.assign(new Error('frame_divlet delete needs frameId.'), { code: 'INVALID_ARGUMENT' });
314
- return runtime.removeDocument({ documentId: frameId, expectedVersion, actor: 'agent', label });
315
- }
316
- if (action === 'duplicate' && !frameId)
317
- throw Object.assign(new Error('frame_divlet duplicate needs frameId.'), { code: 'INVALID_ARGUMENT' });
318
- const result = await runtime.createArtboard({ name, width, height, x, y, from: action === 'duplicate' ? frameId : undefined, expectedVersion, actor: 'agent', label });
319
- return { ...result, frame: runtime.info().documents.find((doc) => doc.id === result.documentId) };
320
- },
321
- },
322
- {
323
- name: 'undo_divlet',
324
- title: 'Undo divlet',
325
- kind: 'runtime',
326
- description: 'Undo (or redo) the most recent accepted change, optionally only the latest one of a frame. Undo applies the inverse patch, so later unrelated edits stay intact; it fails with CANNOT_UNDO when later changes touched the same content.',
327
- snippet: 'Undo or redo the latest layout change.',
328
- parameters: { projectId: optional(S.string()), frameId: optional(S.string()), redo: optional(S.boolean()) },
329
- run({ runtime }, params) {
330
- return (params.redo ? runtime.redo : runtime.undo).call(runtime, { documentId: params.frameId, actor: 'agent' });
331
- },
332
- },
333
- {
334
- name: 'mark_divlet',
335
- title: 'Mark divlet work area',
336
- kind: 'runtime',
337
- description: 'Tell the user where you are about to work: the board animates the area until your turn ends (or you clear it), so they see what is being worked on and why. Call it before a non-trivial change or a long stretch of planning. frameId alone marks the whole frame; nodeIds marks elements inside it (any depth). note is a short human sentence (“restyling the pricing cards”) shown in the agent indicator. A new call replaces earlier marks unless add is true; clear: true removes them. It changes nothing in the project.',
338
- snippet: 'Announce the frame or elements you are working on; the board animates them.',
339
- parameters: {
340
- projectId: optional(S.string()),
341
- frameId: optional(S.string({ description: 'Frame to mark (required unless clear).' })),
342
- nodeIds: optional(S.array(S.string(), { maxItems: 50, description: 'Elements to mark; omit to mark the whole frame.' })),
343
- note: optional(S.string({ maxLength: 120, description: 'What you are about to do, in a few words.' })),
344
- add: optional(S.boolean({ description: 'Keep earlier marks instead of replacing them.' })),
345
- clear: optional(S.boolean({ description: 'Remove marks (of frameId, or all).' })),
346
- },
347
- run({ runtime }, params) {
348
- if (params.clear)
349
- return runtime.clearWork({ documentId: params.frameId });
350
- if (!params.frameId)
351
- throw Object.assign(new Error('mark_divlet needs frameId (or clear: true).'), { code: 'INVALID_ARGUMENT' });
352
- return runtime.markWork({ documentId: params.frameId, nodeIds: params.nodeIds, note: params.note, add: params.add });
353
- },
354
- },
355
- {
356
- // Not listed for hosts: it carries the frame model of `open_divlet { document }` to the owning board.
357
- name: 'create_frame',
358
- hidden: true,
359
- kind: 'runtime',
360
- description: 'Add a frame from a full frame model to the project that owns this board.',
361
- parameters: { document: S.object() },
362
- run({ runtime }, params) {
363
- if (!params?.document || typeof params.document !== 'object')
364
- throw Object.assign(new Error('create_frame needs a frame document.'), { code: 'INVALID_ARGUMENT' });
365
- return runtime.create({ document: params.document });
366
- },
367
- },
368
- ];
369
- const byName = new Map(TOOLS.map(tool => [tool.name, tool]));
370
- export const toolList = ({ hidden = false } = {}) => TOOLS.filter(tool => hidden || !tool.hidden);
371
- export const toolNames = new Set(TOOLS.map(tool => tool.name));
372
- /** Run one runtime tool against a board's runtime (used by the session and by the board's own api/tool route). */
373
- export async function runOnRuntime(name, args, runtime) {
374
- const tool = byName.get(name);
375
- if (!tool || tool.hidden || tool.kind !== 'runtime' || !tool.run)
376
- throw Object.assign(new Error(`A board cannot run the tool ${name}.`), { code: 'UNKNOWN_TOOL' });
377
- return tool.run({ runtime }, args ?? {});
378
- }
21
+ export * from "./tools.js";
22
+ import { compactProject, toolByName } from "./tools.js";
379
23
  /* ---- sessions: one client's view of the boards it uses --------------------------------------------- */
380
24
  /** A board owned by another process: only `info()` and `close()` are needed by launch/hub. */
381
25
  function remoteRuntime(board, info) {
@@ -503,7 +147,7 @@ export function createSession({ openBoard, cwd = process.cwd(), attach = true, o
503
147
  async function call(name, args = {}, ctx = {}) {
504
148
  if (closed)
505
149
  throw Object.assign(new Error('This divlet session is closed.'), { code: 'CLOSED' });
506
- const tool = byName.get(name);
150
+ const tool = toolByName.get(name);
507
151
  if (!tool)
508
152
  throw Object.assign(new Error(`Unknown tool: ${name}.`), { code: 'UNKNOWN_TOOL' });
509
153
  if (tool.kind === 'open')
@@ -2,7 +2,7 @@
2
2
  * Patches and summaries: the smallest change between two documents, the check that a change still applies to the values it
3
3
  * expects, and the human sentence a history row shows. Used by undo/redo, by the runtime's commit path and by the browser.
4
4
  */
5
- import { isDeepStrictEqual } from 'node:util';
5
+ import { sameJson as isDeepStrictEqual } from "./validation.js";
6
6
  const PATCH_KEYS = ['name', 'head', 'artboard', 'rootId', 'tokens', 'css', 'metadata', 'nodes'];
7
7
  const plain = (value) => value !== null && typeof value === 'object' && !Array.isArray(value);
8
8
  /**
@@ -0,0 +1,27 @@
1
+ import type { DocumentModel, Limits } from './model.ts';
2
+ import type { FrameLookup, ProjectSettings } from './project.ts';
3
+ /** What preparing a frame produces: the validated document, the preview page, the generated file, its dependencies and the
4
+ * classes that do nothing. Nothing here is state: a caller decides what to publish. */
5
+ export interface PreparedFrame {
6
+ document: DocumentModel;
7
+ html: string;
8
+ fileHtml: string;
9
+ uses: string[];
10
+ unknown: Record<string, string[]>;
11
+ }
12
+ /** The style compiler a host provides: compiled CSS for the expanded frame's tokens and class candidates, and the classes that do nothing. */
13
+ export interface FrameCompiler {
14
+ compile(tokens: Record<string, string>, candidates: string[]): Promise<string> | string;
15
+ unknown(input: {
16
+ tokens: Record<string, string>;
17
+ css: string;
18
+ byNode: Record<string, string>;
19
+ }): Promise<Record<string, string[]>> | Record<string, string[]>;
20
+ }
21
+ export interface PrepareWithOptions {
22
+ lookup?: FrameLookup;
23
+ settings?: ProjectSettings | null;
24
+ limits?: Limits;
25
+ compiler: FrameCompiler;
26
+ }
27
+ export declare function prepareFrameWith(input: unknown, { lookup, settings, limits, compiler }: PrepareWithOptions): Promise<PreparedFrame>;
@@ -0,0 +1,41 @@
1
+ /*
2
+ * Owns: stateless, host-independent frame preparation — validation, component expansion, the preview and file HTML, the
3
+ * HTML round-trip check and dependency list. Excludes: choosing a style compiler (the host injects `compile`/`unknown`),
4
+ * runtime state, metadata hashing and publication. `render.ts` composes it with the Node style engine.
5
+ */
6
+ import postcss from 'postcss';
7
+ import { validateDocument, DivletError, LIMITS, documentLimits } from "./model.js";
8
+ import { expandFrame, instancesOf } from "./project.js";
9
+ import { encodeHtml, decodeHtml, renderFile } from "./codec.js";
10
+ import { sameJson, withinUtf8Budget } from "./validation.js";
11
+ const MAX_FILE_BYTES = 4 * 1024 * 1024;
12
+ const sameDocument = (a, b) => sameJson({ ...a, version: 0 }, { ...b, version: 0 });
13
+ function fail(code, message) { throw new DivletError(code, message); }
14
+ function checkAtRules(css) {
15
+ const tree = postcss.parse(css || '');
16
+ const allowed = new Set(['media', 'supports', 'layer', 'keyframes', '-webkit-keyframes', 'font-face', 'container', 'property', 'starting-style', 'scope']);
17
+ tree.walkAtRules(rule => {
18
+ if (!allowed.has(rule.name.toLowerCase()))
19
+ fail('UNSUPPORTED_CSS', `CSS @${rule.name} is not allowed in a divlet project. Build directives and imports are disabled.`);
20
+ });
21
+ if (/<\/style/i.test(css))
22
+ fail('UNSUPPORTED_CSS', 'A style element closing sequence is not allowed in authored CSS.');
23
+ }
24
+ export async function prepareFrameWith(input, { lookup = () => undefined, settings = null, limits = LIMITS, compiler }) {
25
+ documentLimits(limits);
26
+ const document = validateDocument(input, { limits });
27
+ const expanded = expandFrame(document, lookup, settings, { limits });
28
+ checkAtRules(expanded.document.css);
29
+ const candidates = [...new Set(Object.values(expanded.document.nodes).flatMap(node => (node.type === 'element' ? node.className : '').split(/\s+/)).filter(Boolean))];
30
+ const compiledCss = await compiler.compile(expanded.document.tokens, candidates);
31
+ const instances = new Map(instancesOf(document).map(item => [item.id, `${item.frame}/${item.node}`]));
32
+ const html = encodeHtml(expanded.document, { compiledCss, parts: expanded.parts, instances, limits });
33
+ if (!withinUtf8Budget(html, MAX_FILE_BYTES))
34
+ fail('FILE_TOO_LARGE', 'The rendered frame exceeds the 4 MiB limit.');
35
+ const decoded = decodeHtml(html, { previous: expanded.document, limits });
36
+ if (!sameDocument(expanded.document, decoded))
37
+ fail('UNREPRESENTABLE_HTML', 'The frame does not survive HTML parsing unchanged (check element nesting); the last valid preview was retained.');
38
+ const own = Object.fromEntries(Object.values(document.nodes).flatMap(node => (node.type === 'element' && node.className ? [[node.id, node.className]] : [])));
39
+ const unknown = await compiler.unknown({ tokens: expanded.document.tokens, css: expanded.document.css, byNode: own });
40
+ return { document, html, fileHtml: renderFile(expanded.document, { compiledCss, parts: expanded.parts, instances, limits }), uses: expanded.uses, unknown };
41
+ }