@smallpen/cli 0.1.0-alpha.5 → 0.1.0-alpha.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/schema.mjs CHANGED
@@ -6,8 +6,6 @@ import {
6
6
  AXIS_ROLES,
7
7
  CANONICAL_SCHEMA_RULES,
8
8
  CONTEXT_AXIS_KINDS,
9
- INITIALIZATION_QUESTION_IDS,
10
- initializationQuestions,
11
9
  INSTANCE_OVERRIDE_FIELDS,
12
10
  OPERATION_SCHEMAS,
13
11
  SMALLPEN_FORMAT_CAPABILITIES,
@@ -15,19 +13,39 @@ import {
15
13
  suggestField,
16
14
  TOKEN_BINDING_FIELD_TYPES,
17
15
  TOKEN_VALUE_SHAPES,
16
+ THEME_INTENT_ACTIONS,
17
+ TOKEN_INTENT_ACTIONS,
18
+ TOKEN_SET_FIELDS,
19
+ COMPONENT_EDIT_ACTIONS,
20
+ ASSET_KINDS,
21
+ ASSET_EDIT_FIELDS,
22
+ INTERACTION_INTENT_ACTIONS,
23
+ PROTOTYPE_EVENTS,
24
+ PROTOTYPE_ACTIONS,
25
+ VARIANT_NODE_TYPES,
18
26
  } from "@smallpen/core";
27
+ import { COMMAND_CONTRACTS, commandContract } from "./command-contract.mjs";
28
+ import { CLI_RULES } from "./workflow.mjs";
29
+ import { helpTopic, nextQuery } from "./help-topics.mjs";
30
+ import { pageItems } from "./read-output.mjs";
31
+ import {
32
+ COMMAND_GROUPS,
33
+ PUBLIC_COMMANDS,
34
+ groupDefinition,
35
+ actionContract,
36
+ } from "./command-tree.mjs";
19
37
 
20
38
  const FORMAT = SMALLPEN_FORMAT_CAPABILITIES;
21
39
  const OPERATION_TYPES = FORMAT.canonicalWrite.operationTypes;
22
- const FOUNDATION = "acme/acme-foundation.smallpen";
23
- const PRODUCT = "acme/acme.smallpen";
40
+ // The default `smallpen init` layout: one self-contained Package.
41
+ const PACKAGE = "acme/acme.smallpen";
24
42
  const REVISION = "<package.revision from smallpen inspect PACKAGE --json>";
25
43
 
26
44
  export const PACKAGE_ROLES = Object.freeze({
27
45
  foundation:
28
- "Shared Tokens, Context Axes (theme, density, ...), and shared Component Sets. Write them to the Foundation (<name>-foundation.smallpen).",
46
+ "Token sets and themes, Context Axes (density, viewport, ...) and Component Sets. In the default layout (one self-contained Package) write them to that Package; in the foundation-product layout write them to the Foundation (<name>-foundation.smallpen).",
29
47
  product:
30
- "Screens, Presentations, Scenarios, requirements, Product-local Tokens and overrides. read-view, render, evidence, tokens, and search-* take the Product; it resolves Foundation Tokens and components through its dependency.",
48
+ "Screens, Presentations, Scenarios, requirements. In the default layout they live in the same self-contained Package; in the foundation-product layout they live in the Product, which resolves the Foundation's Tokens, themes and components through its dependency. view, validate, export, token list and search take the Package with the Screens.",
31
49
  });
32
50
 
33
51
  function batchFor(operations, batchId) {
@@ -43,7 +61,7 @@ export const INSTANCE_NODE_EXAMPLE = Object.freeze({
43
61
  height: 48,
44
62
  id: "node_add_button",
45
63
  instance: {
46
- component: { assetId: "cmp_button", packageId: "pkg_acme_foundation" },
64
+ component: { assetId: "cmp_button", packageId: "pkg_acme" },
47
65
  variant: { axis_style: "primary" },
48
66
  },
49
67
  name: "Add button",
@@ -57,18 +75,18 @@ export const INIT_ANSWERS_EXAMPLE = Object.freeze({
57
75
  audience: "Small teams",
58
76
  contextAxes: [
59
77
  {
60
- defaultValue: "light",
78
+ defaultValue: "Light",
61
79
  id: "axis_theme",
62
80
  kind: "theme",
63
81
  name: "Theme",
64
- values: ["light", "dark"],
82
+ values: ["Light", "Dark"],
65
83
  },
66
84
  ],
67
85
  firstJourney: "Add a task",
68
86
  firstOutput: "Task list screen",
69
87
  firstScenario: "Default",
70
88
  firstScreen: "Home",
71
- foundationChoice: "create-new",
89
+ foundationChoice: "self-contained",
72
90
  initialComponents: ["Button"],
73
91
  initialTokens: ["color.brand", "spacing.md", "radius.md"],
74
92
  kindDetails: ["responsive"],
@@ -80,78 +98,107 @@ export const INIT_ANSWERS_EXAMPLE = Object.freeze({
80
98
  });
81
99
 
82
100
  const TYPOGRAPHY_TOKEN = {
83
- definition: {
84
- $extensions: { smallpen: { id: "tok_type_heading" } },
85
- $type: "typography",
86
- $value: { fontFamily: "Inter", fontSize: 24, fontWeight: 700 },
101
+ setId: "tset_base",
102
+ token: {
103
+ id: "tok_type_heading",
104
+ name: "type.heading",
105
+ type: "typography",
106
+ value: { fontFamily: "Source Sans Pro", fontSize: 24, fontWeight: 700 },
87
107
  },
88
- filePath: "tokens/foundation.json",
89
- path: "type.heading",
90
- tokenId: "tok_type_heading",
91
- type: "put-token",
108
+ type: "put-set-token",
92
109
  };
93
110
 
94
- // tokens -> component set -> screen nodes -> instance override -> render.
95
- // Each step runs in a test against a fresh `smallpen init` workspace.
111
+ // A generic, executable atomic-write example. Scene design order lives in Skills.
96
112
  export const WORKED_EXAMPLE = Object.freeze([
97
113
  {
98
- title: "Create the Foundation and Product packages",
99
- argv: ["init", "acme", "--answers", "answers.json", "--confirm", "--json"],
100
- files: { "answers.json": INIT_ANSWERS_EXAMPLE },
101
- note: "Creates acme/acme-foundation.smallpen (pkg_acme_foundation) and acme/acme.smallpen (pkg_acme_product) with Screen scr_home, Presentation pres_home_mobile, root node_home_root. The reply lists these ids (packages, start) and the starter Tokens tok_color_brand, tok_spacing_md, tok_radius_md and Component Set cmp_button (seeded).",
102
- },
103
- {
104
- title: "Add Tokens to the Foundation (token intents need no revision)",
105
- argv: ["token", FOUNDATION, "--intent", "tokens.json", "--json"],
106
- files: { "tokens.json": { operations: [OPS["put-token"].example, TYPOGRAPHY_TOKEN] } },
107
- },
108
- {
109
- title: "Add a Button Component Set with a configuration axis",
110
- argv: ["apply", FOUNDATION, "--batch", "button.json", "--json"],
111
- files: {
112
- "button.json": batchFor([OPS["put-component-set"].example], "button-v1"),
113
- },
114
- note: "This replaces the starter cmp_button that init seeded (put-component-set with an existing id replaces it whole). baseRevision: run smallpen inspect acme/acme-foundation.smallpen --json and copy package.revision.",
114
+ title: "Create a blank package",
115
+ argv: ["project", "init", PACKAGE, "--name", "Acme", "--json"],
116
+ files: {},
117
+ note: "The reply returns the package revision and initial canvas IDs. No business brief is needed.",
115
118
  },
116
119
  {
117
- title: "Add a title and a Button Instance to the Product Screen",
118
- argv: ["page", PRODUCT, "--intent", "screen.json", "--json"],
120
+ title: "Create one canvas through an atomic batch",
121
+ argv: ["advanced", "apply", PACKAGE, "--batch", "canvas.json", "--json"],
119
122
  files: {
120
- "screen.json": {
121
- nodes: [
123
+ "canvas.json": batchFor(
124
+ [
122
125
  {
123
- ...OPS["add-presentation-node"].example.node,
124
- tokenBindings: {
125
- typography: { assetId: "tok_type_heading", packageId: "pkg_acme_foundation" },
126
+ type: "put-screen",
127
+ screen: {
128
+ id: "scr_demo",
129
+ name: "Canvas",
130
+ basePresentationId: "pres_demo_base",
131
+ counterparts: [],
132
+ presentations: [
133
+ {
134
+ id: "pres_demo_base",
135
+ name: "Base",
136
+ rootId: "node_demo_root",
137
+ interactions: [],
138
+ viewport: { width: 600, height: 400 },
139
+ nodes: {
140
+ node_demo_root: {
141
+ id: "node_demo_root",
142
+ name: "Canvas",
143
+ type: "FRAME",
144
+ children: [],
145
+ x: 0,
146
+ y: 0,
147
+ width: 600,
148
+ height: 400,
149
+ fills: [{ type: "solid", color: "#ffffff" }],
150
+ },
151
+ },
152
+ },
153
+ ],
126
154
  },
127
155
  },
128
- INSTANCE_NODE_EXAMPLE,
129
156
  ],
130
- screenId: "scr_home",
131
- },
157
+ "canvas-v1",
158
+ ),
132
159
  },
133
- note: "Every intent node becomes a sibling under parentId (default: the Presentation root). Give children:[] and add nested nodes in a later intent with parentId.",
160
+ note: "Copy the revision from the previous reply (or inspect PACKAGE) into baseRevision before applying the batch. Existing IDs replace whole definitions; use update for partial edits.",
134
161
  },
135
162
  {
136
- title: "Resize the screen and override the Instance label",
137
- argv: ["apply", PRODUCT, "--batch", "screen-edit.json", "--json"],
163
+ title: "Add text to that canvas",
164
+ argv: ["advanced", "apply", PACKAGE, "--batch", "text.json", "--json"],
138
165
  files: {
139
- "screen-edit.json": batchFor(
166
+ "text.json": batchFor(
140
167
  [
141
- OPS["update-presentation-node"].example,
142
- OPS["update-presentation"].example,
143
- OPS["set-instance-override"].example,
168
+ {
169
+ type: "add-presentation-node",
170
+ screenId: "scr_demo",
171
+ presentationId: "pres_demo_base",
172
+ parentId: "node_demo_root",
173
+ node: {
174
+ id: "node_title",
175
+ type: "TEXT",
176
+ name: "Title",
177
+ text: "Example",
178
+ children: [],
179
+ x: 16,
180
+ y: 16,
181
+ width: 300,
182
+ height: 32,
183
+ fills: [{ type: "solid", color: "#111111" }],
184
+ textStyle: {
185
+ fontFamily: "Source Sans Pro",
186
+ fontSize: 18,
187
+ fontWeight: 400,
188
+ lineHeight: 1.3,
189
+ },
190
+ },
191
+ },
144
192
  ],
145
- "screen-edit-1",
193
+ "text-v1",
146
194
  ),
147
195
  },
148
- note: "The rendered size is the root node size; update-presentation only updates the declared viewport.",
196
+ note: "Use the revision the previous write returned. Day-to-day pages are drawn by name with page draw; apply is for exact operations such as an undo batch.",
149
197
  },
150
198
  {
151
- title: "Render the dark theme and collect review evidence",
152
- argv: ["render", PRODUCT, "--context", "axis_theme=dark", "--output", "home-dark.png", "--json"],
199
+ title: "Read the selected canvas as a text wireframe",
200
+ argv: ["view", PACKAGE, "--page", "Canvas", "--as", "wireframe", "--json"],
153
201
  files: {},
154
- note: "Then: smallpen evidence acme/acme.smallpen --output review --json",
155
202
  },
156
203
  ]);
157
204
 
@@ -190,7 +237,9 @@ const EXISTING_ID_NOTES = {
190
237
  "put-component-set":
191
238
  "An existing componentSet.id is replaced whole (no merge). init seeds one Component Set per initialComponents answer, for example cmp_button with variant var_button_default and no axes; the init reply lists them in seeded.componentSets.",
192
239
  "put-token":
193
- "An existing tokenId is replaced whole (no merge). init seeds one Token per initialTokens answer, for example tok_color_brand, tok_spacing_md, tok_radius_md; the init reply lists them in seeded.tokens.",
240
+ "An existing tokenId is replaced whole (no merge). init seeds one Token per initialTokens answer, for example tok_color_brand, tok_spacing_md, tok_radius_md (in the token set tset_base: change them with put-set-token or set-token-value); the init reply lists them in seeded.tokens.",
241
+ "put-set-token":
242
+ "An existing token id in the set is replaced whole (no merge). init seeds one Token per initialTokens answer in tset_base, for example tok_color_brand, tok_spacing_md, tok_radius_md; the init reply lists them in seeded.tokens.",
194
243
  };
195
244
 
196
245
  function existingIdNote(type, schema) {
@@ -206,12 +255,19 @@ function operationDetail(type) {
206
255
  return {
207
256
  ...(existingId ? { existingId } : {}),
208
257
  fields: {
209
- type: { description: "Operation type", required: true, type: "string", value: type },
258
+ type: {
259
+ description: "Operation type",
260
+ required: true,
261
+ type: "string",
262
+ value: type,
263
+ },
210
264
  ...schema.fields,
211
265
  },
212
266
  inverse: schema.inverse,
213
267
  package: schema.package,
214
- packageRole: PACKAGE_ROLES[schema.package] ?? "Either Package; it changes the Package you pass",
268
+ packageRole:
269
+ PACKAGE_ROLES[schema.package] ??
270
+ "Either Package; it changes the Package you pass",
215
271
  purpose: schema.purpose,
216
272
  ...(schema.oneOf ? { requiresOneOf: schema.oneOf } : {}),
217
273
  unknownFields: "rejected with unknown_operation_field",
@@ -225,16 +281,19 @@ const NODE_FIELD_TYPES = {
225
281
  backgroundBlur: "object or array of objects",
226
282
  blur: "object or array of objects",
227
283
  "blend-mode": "string",
228
- constraints: "constraints-h / constraints-v: string or object",
284
+ constraints: `constraints-h: ${CANONICAL_SCHEMA_RULES.constraints["constraints-h"].join(" | ")}; constraints-v: ${CANONICAL_SCHEMA_RULES.constraints["constraints-v"].join(" | ")} (how a child follows its parent when the parent is resized in the editor). When a copy is resized: left keeps x; right moves x by the growth; leftright grows width by it; center moves x by half of it; scale scales x and width by new/old size (top, bottom, topbottom alike). Without a constraint, a child of a FRAME, COMPONENT or INSTANCE stays left/top; other children scale. A flex layout places its children unless they are layout-item-absolute`,
229
285
  content: "Penpot path content",
230
286
  cornerRadius: "number or [topLeft, topRight, bottomRight, bottomLeft]",
231
- fills: '[{type:"solid", color:"#rrggbb", opacity?}] (also linear-gradient, radial-gradient, image)',
287
+ fills:
288
+ '[{type:"solid", color:"#rrggbb", opacity?}] (also linear-gradient, radial-gradient, image)',
232
289
  flipX: "boolean",
233
290
  flipY: "boolean",
234
291
  grids: "array of objects",
235
292
  growType: `TEXT only: ${CANONICAL_SCHEMA_RULES.textGrowTypes.join(" | ")}`,
236
- interactions: "array of objects (prefer put-interaction)",
237
- layout: 'flex/grid layout: "flex" | "grid" plus layout-* fields (strings; layout-gap/layout-padding/layout-item-margin are objects)',
293
+ interactions:
294
+ "array of native App configurations; set them by name with flow link and flow unlink. Canonical put-interaction is separate.",
295
+ layout:
296
+ 'flex/grid layout: "flex" | "grid" plus layout-* fields (strings; layout-gap/layout-padding/layout-item-margin are objects)',
238
297
  locked: "boolean",
239
298
  mediaRef: "IMAGE only: media_... id or {packageId, assetId}",
240
299
  name: "non-empty string",
@@ -243,12 +302,15 @@ const NODE_FIELD_TYPES = {
243
302
  points: "PATH only: array of points",
244
303
  proportionLock: "boolean",
245
304
  rotation: "number, 0 <= r < 360",
246
- shadow: "object or array of objects",
247
- strokes: '[{type:"solid", color, width, alignment?:center|inner|outer, style?:solid|dashed|dotted|mixed, capStart?, capEnd?}]',
305
+ shadow:
306
+ '{offsetX, offsetY, blur, spread, color, opacity?, inset?, hidden?} or an array of them; px numbers, for example {"offsetX":0,"offsetY":2,"blur":8,"spread":0,"color":"#00000033"}. Bind a shadow Token with tokenBindings.shadow instead to follow themes',
307
+ strokes: `[{type:"solid", color, width, alignment?:${CANONICAL_SCHEMA_RULES.strokeAlignments.join("|")}, style?:${CANONICAL_SCHEMA_RULES.strokeStyles.join("|")}, dash?, gap?, capStart?, capEnd?}]. alignment defaults to inner; an open path always strokes centered. Dash patterns follow Penpot, for stroke width w: dashed = w+10 on, w+10 off (dash/gap override them); dotted = round dots w across, w+5 apart; mixed = w+5 on, w+5 off, w+1 on, w+5 off. capStart/capEnd (PATH ends): ${CANONICAL_SCHEMA_RULES.strokeCaps.join(" | ")}; cap sizes grow with w: triangle-arrow tip 1.42w past the end and 8.5w wide, circle-marker radius 2w, square-marker side 4.24w, diamond-marker corners 3w from the end`,
248
308
  text: "TEXT only: plain text (string)",
249
- textBlocks: "TEXT only: [{runs:[{text, textStyle?, fills?}], textStyle?}] that join to text",
309
+ textBlocks:
310
+ "TEXT only: [{runs:[{text, textStyle?, fills?}], textStyle?}] that join to text",
250
311
  textStyle: "TEXT only: see textStyle below",
251
- tokenBindings: "{field: {packageId, assetId:tok_...}}; see tokenBindings below",
312
+ tokenBindings:
313
+ "{field: {packageId, assetId:tok_...}}; see tokenBindings below",
252
314
  touched: "array of Penpot touched groups",
253
315
  visible: "boolean",
254
316
  };
@@ -269,11 +331,12 @@ function nodeTopic() {
269
331
  summary:
270
332
  "A node is one layer. A Presentation stores nodes flat in nodes:{id: node}; children lists child ids. x and y are relative to the parent node.",
271
333
  required: {
272
- children: "array of child node ids (ids only; add each child as its own node)",
334
+ children:
335
+ "array of child node ids (ids only; add each child as its own node)",
273
336
  height: "number",
274
- id: "node_... unique in its Presentation (or variant)",
337
+ id: "must start with node_ (node_[A-Za-z0-9_-]+), unique in its Presentation (or variant); the nodes key equals id",
275
338
  name: "non-empty string",
276
- type: CANONICAL_SCHEMA_RULES.nodeTypes.join(" | "),
339
+ type: `${CANONICAL_SCHEMA_RULES.nodeTypes.join(" | ")}. Inside a Component variant only ${VARIANT_NODE_TYPES.join(", ")} (no ELLIPSE, GROUP or PATH)`,
277
340
  width: "number",
278
341
  x: "number, relative to the parent",
279
342
  y: "number, relative to the parent",
@@ -283,28 +346,112 @@ function nodeTopic() {
283
346
  CANONICAL_SCHEMA_RULES.nodeChangeFields.map((field) => [
284
347
  field,
285
348
  NODE_FIELD_TYPES[field] ??
286
- (field.startsWith("constraints") ? NODE_FIELD_TYPES.constraints
287
- : field.startsWith("layout") ? "layout field; see layout"
288
- : field === "height" || field === "width" || field === "x" || field === "y" ? "number"
349
+ (field.startsWith("constraints")
350
+ ? NODE_FIELD_TYPES.constraints
351
+ : field.startsWith("layout")
352
+ ? "layout field; see layout"
353
+ : field === "height" ||
354
+ field === "width" ||
355
+ field === "x" ||
356
+ field === "y"
357
+ ? "number"
289
358
  : "see validator"),
290
359
  ]),
291
360
  ),
361
+ layout: {
362
+ values: {
363
+ layout: ["flex", "grid"],
364
+ "layout-flex-dir": ["row", "row-reverse", "column", "column-reverse"],
365
+ "layout-wrap-type": ["nowrap", "wrap"],
366
+ "layout-justify-content": [
367
+ "start",
368
+ "center",
369
+ "end",
370
+ "space-between",
371
+ "space-around",
372
+ "space-evenly",
373
+ "stretch",
374
+ ],
375
+ "layout-align-items": ["start", "end", "center", "stretch"],
376
+ "layout-item-h-sizing": ["fill", "fix", "auto"],
377
+ "layout-item-v-sizing": ["fill", "fix", "auto"],
378
+ },
379
+ objects: {
380
+ "layout-gap": '{"row-gap":12,"column-gap":12}; numeric pixels',
381
+ "layout-padding":
382
+ '{"p1":16,"p2":16,"p3":16,"p4":16}; top, right, bottom, left',
383
+ "layout-item-margin":
384
+ '{"m1":0,"m2":0,"m3":0,"m4":0}; top, right, bottom, left',
385
+ },
386
+ childOrder:
387
+ "children uses Penpot layer order. row/column positions the last child first; row-reverse/column-reverse positions the first child first. For a row displaying A then B, store children:[B,A].",
388
+ sizing:
389
+ "Set layout-item-h-sizing on a child for horizontal size and layout-item-v-sizing for vertical size. fill shares the remaining main-axis space after fixed children, gap and padding; on the cross axis it fills the inner parent size. fix uses stored width/height. auto currently uses stored width/height too; supply measured text bounds. layout-item-absolute:true excludes a child from flex flow.",
390
+ projection:
391
+ "These are Penpot layout values, not enforced enum constraints. SmallPen reflows one flex line with gap, padding, direction, fill, start/center/end alignment and start/center/end/space-between justification. Grid, wrapping, margins, auto/min/max sizing, stretch and other modes report layout_projection_partial in view/check/PNG and skipped layout reflow in coverage; stored bounds are not App-equivalent layout validation.",
392
+ example: {
393
+ rootId: "node_layout_root",
394
+ nodes: {
395
+ node_layout_root: {
396
+ id: "node_layout_root",
397
+ name: "Row",
398
+ type: "FRAME",
399
+ x: 0,
400
+ y: 0,
401
+ width: 320,
402
+ height: 80,
403
+ children: ["node_fill", "node_fixed"],
404
+ layout: "flex",
405
+ "layout-flex-dir": "row",
406
+ "layout-gap": { "row-gap": 12, "column-gap": 12 },
407
+ "layout-padding": { p1: 16, p2: 16, p3: 16, p4: 16 },
408
+ },
409
+ node_fixed: {
410
+ id: "node_fixed",
411
+ name: "First, fixed",
412
+ type: "RECTANGLE",
413
+ x: 0,
414
+ y: 0,
415
+ width: 80,
416
+ height: 48,
417
+ children: [],
418
+ "layout-item-h-sizing": "fix",
419
+ },
420
+ node_fill: {
421
+ id: "node_fill",
422
+ name: "Second, fill",
423
+ type: "RECTANGLE",
424
+ x: 0,
425
+ y: 0,
426
+ width: 1,
427
+ height: 1,
428
+ children: [],
429
+ "layout-item-h-sizing": "fill",
430
+ "layout-item-v-sizing": "fill",
431
+ },
432
+ },
433
+ },
434
+ },
292
435
  textStyle: {
293
436
  fields: CANONICAL_SCHEMA_RULES.textStyleFields,
294
- numbers: "fontSize, fontWeight, lineHeight, letterSpacing are numbers",
437
+ numbers:
438
+ "fontSize (px), fontWeight (100-950), lineHeight, letterSpacing (px) are numbers",
439
+ lineHeight:
440
+ "A multiplier of fontSize: 1.4 means 140% (Penpot's line height). Not px: the editor reads 24 as 24 times the font size. The same holds for lineHeight in a typography Token. Default 1.2.",
441
+ fonts:
442
+ "Only Source Sans Pro (Latin) is bundled. Any other fontFamily, including one for Chinese or another script, must be imported into the package first with smallpen asset font import; otherwise render falls back to Source Sans Pro, reports font_render_fallback, and text it cannot draw is a text_missing_glyphs issue.",
295
443
  values: CANONICAL_SCHEMA_RULES.textStyleEnums,
296
444
  note: "Text alignment is textStyle.textAlign, not a node field.",
297
445
  },
298
446
  tokenBindings: {
299
- shape: '{"fill": {"packageId":"pkg_acme_foundation","assetId":"tok_color_brand"}}',
447
+ shape: '{"fill": {"packageId":"pkg_acme","assetId":"tok_color_brand"}}',
300
448
  fieldTokenTypes: tokenBindingTable(),
301
449
  notBindable:
302
- "x, y, rotation, and layout-* fields (layout-gap, layout-padding) cannot bind Tokens; write numbers.",
303
- note:
304
- "A binding replaces the raw value when the design resolves. sizing Tokens on width/height change the rendered size. paddingTop/Right/Bottom/Left and itemSpacing accept spacing Tokens but only record intent: layout reads layout-padding and layout-gap, which take numbers.",
450
+ "x, y, rotation, and layout-* fields cannot bind Tokens directly. Use itemSpacing, rowGap/columnGap, or paddingTop/Right/Bottom/Left to bind layout spacing.",
451
+ note: "A binding replaces the raw value when the design resolves. width/height drive size. itemSpacing drives both layout-gap axes; rowGap/columnGap override one axis. paddingTop/Right/Bottom/Left drive layout-padding p1/p2/p3/p4. Unbound sides keep their raw numbers; bound spacing is at least zero.",
305
452
  },
306
453
  changes:
307
- "update-presentation-node, update-node, and update-component-node changes take the fields above; null clears an optional field; unknown fields are rejected with a suggestion.",
454
+ "update-presentation-node, update-node, and update-component-node changes take only the mutable fields above; null clears an optional field. id, type and children cannot be changed here. For Presentation child order use reorder-presentation-children; for membership use add/move/remove-presentation-node. For a component's elements prefer component define, which redraws the variants by name.",
308
455
  example: NODE_EXAMPLE,
309
456
  };
310
457
  }
@@ -312,57 +459,99 @@ function nodeTopic() {
312
459
  function nodeTypesTopic() {
313
460
  return {
314
461
  types: {
315
- COMPONENT: "Root of a Component variant, or a located Component main node",
462
+ COMPONENT:
463
+ "Root of a Component variant, or a located Component main node",
316
464
  ELLIPSE: "Ellipse inside x/y/width/height",
317
465
  FRAME: "Container; fills, strokes, cornerRadius, layout",
318
466
  GROUP: "Plain group of children",
319
- IMAGE: "Requires mediaRef (import with smallpen import-media)",
467
+ IMAGE: "Requires mediaRef (import with smallpen asset media import)",
320
468
  INSTANCE: "Requires instance; see: smallpen schema instance",
321
469
  PATH: "pathData or points; strokes with capStart/capEnd for arrows",
322
470
  RECTANGLE: "Rectangle; fills, strokes, cornerRadius",
323
471
  TEXT: "Requires text; textStyle, textBlocks, growType",
324
472
  },
325
473
  presentationTypes: CANONICAL_SCHEMA_RULES.nodeTypes,
326
- variantTypes: "COMPONENT, COMPONENT_SET, FRAME, IMAGE, INSTANCE, RECTANGLE, TEXT",
474
+ variantTypes: VARIANT_NODE_TYPES.join(", "),
475
+ variantNote:
476
+ "ELLIPSE, GROUP and PATH are allowed only in Screen Presentations, not in Component variants: use a RECTANGLE with cornerRadius for a circle, a FRAME to group.",
327
477
  };
328
478
  }
329
479
 
330
480
  function tokenTopic() {
331
481
  return {
332
482
  summary:
333
- "Tokens are DTCG documents. Write shared Tokens to the Foundation (tokens/foundation.json); the Product resolves Product Tokens first, then Foundation Tokens.",
334
- definition: {
335
- $type: FORMAT.canonicalPackage.tokenTypes.join(" | "),
336
- $value: "value matching $type; see: smallpen schema token-types. An alias is \"{other.token.path}\"",
337
- $description: "optional string",
338
- "$extensions.smallpen": {
339
- id: "tok_... permanent id; must equal the operation's tokenId",
340
- contextValues:
341
- '[{"when":{"axis_theme":"dark"},"value":"#1c1b1f"}]: value per Context (exactly when + value)',
342
- visibility: "public | private",
343
- deprecated: "boolean",
344
- },
483
+ 'Tokens live in token sets (Penpot / Tokens Studio / DTCG $themes model). A Token is {id:tok_..., name:"color.surface", type, value}. Themes switch which sets are active; see: smallpen schema theme.',
484
+ setToken: {
485
+ id: "tok_... permanent id; bindings reference it",
486
+ name: "dot path, for example color.surface; a later active set overrides an earlier one with the same name. A name is either a Token or a group, never both: with color.brand present, color.brand.hover is rejected (token_name_collision); use color.brand-hover, or name them color.brand.default and color.brand.hover from the start",
487
+ type: FORMAT.canonicalPackage.tokenTypes.join(" | "),
488
+ value:
489
+ 'value matching type; see: smallpen schema token-types. An alias is "{other.token.name}"',
490
+ description: "optional string",
345
491
  },
346
492
  operations: {
347
- create: "put-token {filePath, path, tokenId, definition}",
493
+ appCreate: TOKEN_INTENT_ACTIONS["create-row"].example,
494
+ appChangeValue: TOKEN_INTENT_ACTIONS["set-value"].example,
495
+ create: "put-set-token {setId, token}",
348
496
  changeValue: "set-token-value {tokenId, value}",
349
497
  bind: "set-token-binding {screenId, nodeId, field, binding:{packageId, assetId}}, or tokenBindings on the node",
350
- remove: "deprecate-token, remove-token (fails while bound; see smallpen impact)",
498
+ remove: "remove-token (fails while bound; see smallpen impact)",
499
+ sets: "put-token-set, delete-token-set; themes: put-token-theme, delete-token-theme, set-active-token-themes",
351
500
  },
501
+ dtcgFiles:
502
+ "put-token {filePath, path, tokenId, definition:{$type, $value, $extensions:{smallpen:{id}}}} writes a DTCG token file. Prefer put-set-token: init makes no DTCG file. filePath names a DTCG file (created when missing, for example tokens/dtcg.json); it must not be the token library entry with sets and themes (tokens/foundation.json or tokens/tokens.json after init; smallpen tokens PACKAGE --json shows each Token's filePath). Its Tokens are always active and do not take part in themes; $extensions.smallpen.contextValues still resolve with --context but are deprecated for themes (warning context_values_deprecated).",
352
503
  tokenBindings: tokenBindingTable(),
353
- themes:
354
- "Theme values use contextValues on Context Axes (smallpen schema context). The Penpot-format library in tokens/product.json has sets and themes; switch them with set-active-token-themes {themePaths:[\"Theme/Default\"]}.",
355
- read: "smallpen tokens <product> --context axis_theme=dark --json lists resolved values",
356
- example: OPS["put-token"].example,
504
+ themes: "smallpen schema theme",
505
+ read: "smallpen tokens <package> --theme Theme/Dark --json lists resolved values and the themes (themes[].active)",
506
+ definitions:
507
+ "smallpen tokens <package> --definitions [--group GROUP] --json lists exact stored Token objects in all option Sets, including packageId/setId/group/option. Missing cells have no item and do not inherit Default. --theme/--context cannot be combined with --definitions.",
508
+ authoring:
509
+ "token set PACKAGE --intent FILE writes Tokens by name with default and per-option values; token delete --path removes one. See schema token-set.",
510
+ example: OPS["put-set-token"].example,
357
511
  typographyExample: TYPOGRAPHY_TOKEN,
358
- intent: { operations: [OPS["put-token"].example] },
512
+ intent: { operations: [OPS["put-set-token"].example] },
513
+ };
514
+ }
515
+
516
+ function themeTopic() {
517
+ return {
518
+ summary:
519
+ "A theme is Penpot's token theme: group/name plus the token sets it activates. One theme per group is active. A Token resolves to the last active set that defines its name, so a theme set overrides the base set by name. This is the model Penpot's token manager, Tokens Studio and DTCG $themes share.",
520
+ model: {
521
+ set: "{id:tset_..., name, description?, tokens:[{id:tok_..., name, type, value}]}",
522
+ theme: "{id:theme_..., group, name, setIds:[tset_...], description?}",
523
+ selection:
524
+ "activeThemeIds/activeSetIds = stored App selection; defaultThemeIds = optional independent project defaults, one per group. Missing defaults use Default, else first library option. With no themes, CLI uses defaultSetIds, or all library sets in order for legacy data",
525
+ },
526
+ initLayout:
527
+ "init turns every theme-kind contextAxes answer into one group: set base (active in every theme) plus one native Group/Option Set per value, preserving group/option labels, and a theme per value activating [base, that value's set]. Example: Theme/Light = [base, Theme/Light], Theme/Dark = [base, Theme/Dark]. Stable IDs still use slugs. Existing packages keep their original Set paths.",
528
+ recipe: [
529
+ 'put-set-token {setId:"tset_base", token:{id:"tok_color_surface", name:"color.surface", type:"color", value:"#ffffff"}}',
530
+ 'put-set-token {setId:"tset_theme_dark", token:{id:"tok_color_surface_dark", name:"color.surface", type:"color", value:"#1c1b1f"}}',
531
+ "bind nodes to tok_color_surface (the base id); Theme/Dark shows #1c1b1f",
532
+ 'smallpen view PACKAGE --theme Theme/Dark --json (read only); set-active-token-themes {themePaths:["Theme/Dark"]} stores it',
533
+ ],
534
+ read: "token theme list, view, validate and export use project defaults, not App active themes; --theme GROUP/NAME overrides only named groups. None of these reads write. A Presentation alone does not choose themes. Use token theme list PACKAGE to discover project-defined groups/options/defaults; platform, language and light/dark are optional examples, not required dimensions.",
535
+ foundationProduct:
536
+ "In the foundation-product layout the Foundation owns the sets and themes. The Product stores which Foundation themes it uses in manifest dependencies[0].activeThemeIds (Foundation theme ids); set-active-token-themes on the Product writes it. The Product's own active sets sit on top of the Foundation's by name.",
537
+ operations: {
538
+ createSet: OPS["put-token-set"].example,
539
+ addToken: OPS["put-set-token"].example,
540
+ createTheme: OPS["put-token-theme"].example,
541
+ activate: OPS["set-active-token-themes"].example,
542
+ },
543
+ contexts:
544
+ "Context Axes (smallpen schema context) are for non-token choices such as viewport or platform. Token contextValues on theme axes are deprecated; smallpen migrate-themes PACKAGE --output NEW converts them to sets and themes.",
359
545
  };
360
546
  }
361
547
 
362
548
  function tokenTypesTopic() {
363
549
  return {
364
550
  types: Object.fromEntries(
365
- FORMAT.canonicalPackage.tokenTypes.map((type) => [type, TOKEN_VALUE_SHAPES[type]]),
551
+ FORMAT.canonicalPackage.tokenTypes.map((type) => [
552
+ type,
553
+ TOKEN_VALUE_SHAPES[type],
554
+ ]),
366
555
  ),
367
556
  alias: 'Any type also accepts "{path.of.another.token}" of the same type.',
368
557
  };
@@ -372,11 +561,13 @@ function componentSetTopic() {
372
561
  return {
373
562
  summary:
374
563
  "A Component Set is one component with variants. Axes name the choices; each variant selects one value per Axis and holds its own node tree.",
564
+ variantNodeTypes: `${VARIANT_NODE_TYPES.join(", ")} only. ELLIPSE, GROUP and PATH are rejected in variants (they work in Screen Presentations): use a RECTANGLE with cornerRadius for a circle, a FRAME to group, an IMAGE (asset media import) for an icon.`,
375
565
  fields: {
376
566
  id: "cmp_...",
377
567
  name: "non-empty string",
378
568
  axes: "[{id:axis_..., name, role, domain?}]",
379
- variants: "[{id:var_..., selection:{axisId: value}, rootId, nodes:{id: node}}]",
569
+ variants:
570
+ "[{id:var_..., selection:{axisId: value}, rootId, nodes:{id: node}}]",
380
571
  visibility: "public | private (default public)",
381
572
  category: "optional string",
382
573
  description: "optional string",
@@ -387,7 +578,8 @@ function componentSetTopic() {
387
578
  values: AXIS_ROLES,
388
579
  configuration:
389
580
  "An option the designer picks: style (primary/secondary), size, content, icon. domain is optional but recommended.",
390
- state: "An interaction state: default, hover, pressed, disabled. domain is required.",
581
+ state:
582
+ "An interaction state: default, hover, pressed, disabled. domain is required.",
391
583
  modelling:
392
584
  "Model primary/secondary as one configuration Axis (axis_style, domain [primary, secondary]) and hover/disabled as a separate state Axis. Every variant must select every Axis, and each selection must be unique.",
393
585
  },
@@ -395,9 +587,11 @@ function componentSetTopic() {
395
587
  "Variant node x/y are relative to the parent; the root is usually COMPONENT at 0,0. Node ids may repeat across variants so Instance overrides keep working.",
396
588
  operations: {
397
589
  create: "put-component-set (whole set) or put-variant (one variant)",
398
- edit: "update-component-node {componentSetId, variantId, nodeId, changes}",
590
+ edit: "update-component-node {componentId, variantId, nodeId, changes}",
399
591
  remove: "delete-variant, delete-component-set",
400
592
  },
593
+ instancesAfterChanges:
594
+ "A write that leaves an Instance in the same Package selecting a variant that no longer exists (deleted variant, changed Axes) is rejected with stale_instance_variant. Product Instances of a Foundation set are checked by smallpen validate PRODUCT and listed in staleInstances; fix them with select-instance-variant.",
401
595
  package: "foundation",
402
596
  example: OPS["put-component-set"].example.componentSet,
403
597
  };
@@ -410,20 +604,22 @@ function instanceTopic() {
410
604
  node: {
411
605
  type: "INSTANCE",
412
606
  instance: {
413
- component: "{packageId: owning Package (pkg_acme_foundation), assetId: cmp_...}",
607
+ component: "{packageId: owning Package (pkg_acme), assetId: cmp_...}",
414
608
  variant: "{axisId: value} for every Axis of the Component Set",
415
- overrides: '{"<sourceNodeId>:<field>": value}; prefer set-instance-override',
609
+ overrides:
610
+ '{"<sourceNodeId>:<field>": value}; prefer set-instance-override',
416
611
  },
417
612
  size: "x/y/width/height of the Instance itself",
418
613
  },
419
614
  overrides: {
420
615
  path: "<sourceNodeId>:<field>, or <nestedInstanceId>__<sourceNodeId>:<field> inside a nested Instance",
421
616
  fields: [...INSTANCE_OVERRIDE_FIELDS],
422
- operations: "set-instance-override, clear-instance-override, select-instance-variant",
617
+ operations:
618
+ "set-instance-override, clear-instance-override, select-instance-variant",
423
619
  },
424
620
  pageIntent:
425
- 'In a page or flow intent an INSTANCE node may carry overrides {"node_button_label:text":"Add task"}; each becomes a checked set-instance-override in the same atomic batch.',
426
- find: "smallpen search-components <product> --query button --json returns recommendedInsertion.intent, a ready page intent",
621
+ 'In page draw an element {"use":"Button","props":{"Style":"secondary"},"set":{"Label.text":"Add task"}} places a copy; each setting becomes a checked instance override.',
622
+ find: "smallpen component search <product> --query button --json returns recommendedInsertion.element, ready to add to a page draw intent",
427
623
  example: {
428
624
  node: INSTANCE_NODE_EXAMPLE,
429
625
  parentId: "node_home_root",
@@ -442,7 +638,8 @@ function presentationTopic() {
442
638
  id: "pres_...",
443
639
  name: "non-empty string",
444
640
  platform: "optional string",
445
- viewport: "{width, height}: the declared device size (metadata; render ignores it)",
641
+ viewport:
642
+ "{width, height}: the declared device size (metadata; render ignores it)",
446
643
  nodes: "{node_id: node}",
447
644
  rootId: "root node id (or rootIds for several roots)",
448
645
  interactions: "[] (see put-interaction)",
@@ -467,7 +664,8 @@ function screenTopic() {
467
664
  presentations: "[Presentation]; see: smallpen schema presentation",
468
665
  counterparts: "[] (links between Presentations)",
469
666
  },
470
- defaultScreen: "set-default-screen chooses the Screen reads and renders use without --screen",
667
+ defaultScreen:
668
+ "set-default-screen chooses the page reads and renders use when no page is named",
471
669
  example: OPS["put-screen"].example.screen,
472
670
  };
473
671
  }
@@ -475,18 +673,24 @@ function screenTopic() {
475
673
  function scenarioTopic() {
476
674
  return {
477
675
  summary:
478
- "A Scenario is a reproducible design state: a target Screen/Presentation, a Context, and declarative actions. Select it with --scenario.",
676
+ "A Scenario is a reproducible design state: a target Screen/Presentation, a Context, optional token themes, and declarative actions.",
479
677
  fields: {
480
678
  id: "scn_...",
481
679
  name: "non-empty string",
482
- target: '{kind:"screen", screen:{packageId, assetId:scr_...}, presentationId}',
680
+ target:
681
+ '{kind:"screen", screen:{packageId, assetId:scr_...}, presentationId}',
483
682
  context: "{axisId: value} for the Context Axes",
683
+ themes:
684
+ 'optional ["Group/Name"] token themes the Scenario shows; --theme overrides them',
484
685
  actions: "[] or declarative actions (set-override, ...)",
485
- expectedVisibleNodeIds: "[node ids]",
686
+ expectedVisibleNodeIds:
687
+ "Exact set of visible canonical target Node IDs after actions, excluding nodes hidden by ancestors. Instance internals are generated IDs and must not be listed. Use read --full for editable IDs; validate reports the actual canonical visibleNodeIds on mismatch.",
486
688
  fixture: "{key: scalar}",
487
- viewport: "{width, height, scale}: declared size (metadata; render uses the root node size)",
689
+ viewport:
690
+ "{width, height, scale}: declared size (metadata; render uses the root node size)",
488
691
  },
489
- update: "put-scenario with the same id replaces the Scenario, for example to keep its viewport in step with the root node.",
692
+ update:
693
+ "put-scenario with the same id replaces the Scenario, for example to keep its viewport in step with the root node.",
490
694
  example: OPS["put-scenario"].example.scenario,
491
695
  };
492
696
  }
@@ -494,7 +698,7 @@ function scenarioTopic() {
494
698
  function contextTopic() {
495
699
  return {
496
700
  summary:
497
- "Context Axes are finite design dimensions such as theme or density. The Foundation declares them in contexts/*.json; select values with --context AXIS=VALUE.",
701
+ "Context Axes are finite design dimensions such as viewport, density or locale. The Package (or the Foundation of a pair) declares them in contexts/*.json; select values with --context AXIS=VALUE. Themes are token themes instead (smallpen schema theme).",
498
702
  axis: {
499
703
  id: "axis_...",
500
704
  name: "non-empty string",
@@ -504,8 +708,8 @@ function contextTopic() {
504
708
  },
505
709
  profile: "{id:ctx_..., name, values:{axisId: value}, default?: boolean}",
506
710
  themes:
507
- "Give a Token a per-theme value with $extensions.smallpen.contextValues [{when:{axis_theme:\"dark\"}, value}], then render with --context axis_theme=dark.",
508
- init: "smallpen init asks for contextAxes as [{id, name, kind, values:[\"light\",\"dark\"], defaultValue}]",
711
+ "Themes are token sets + themes, not Context Axes: see smallpen schema theme. A theme-kind init answer becomes token themes.",
712
+ init: "project init creates no Context Axes. Use explicit operations to add project-defined axes when needed.",
509
713
  example: OPS["put-context-file"].example.contextFile,
510
714
  };
511
715
  }
@@ -515,19 +719,22 @@ function batchTopic() {
515
719
  summary:
516
720
  "Every write is one atomic Operation Batch. Nothing is written unless every operation is valid.",
517
721
  batch: {
518
- baseRevision: "package.revision from smallpen inspect PACKAGE --json; a stale value is rejected",
722
+ baseRevision:
723
+ "package.revision from smallpen inspect PACKAGE --json; a stale value is rejected",
519
724
  batchId: "1-192 characters [A-Za-z0-9_.-], new for every write",
520
725
  operations: "array; see: smallpen schema operations",
521
726
  },
522
727
  packages: PACKAGE_ROLES,
523
728
  commands: {
524
- apply: "smallpen apply PACKAGE --batch BATCH.json [--dry-run] [--explain] [--diff]",
525
- token: "smallpen token PACKAGE --intent {operations:[...]}: like apply, with baseRevision and batchId filled in",
729
+ apply:
730
+ "smallpen apply PACKAGE --batch BATCH.json [--dry-run] [--explain] [--diff]",
731
+ token:
732
+ "smallpen token PACKAGE --intent {operations:[...]}: like apply, with baseRevision and batchId filled in",
526
733
  page: "smallpen page PRODUCT --intent {screenId, presentationId?, parentId?, index?, nodes:[node, ...]}: one add-presentation-node per node, all siblings under parentId (default: the root); overrides on an INSTANCE node become set-instance-override operations",
527
734
  flow: "smallpen flow PRODUCT --intent: same as page, for flowchart nodes",
528
735
  },
529
736
  result:
530
- "revision, changedFiles, affectedIds, inverseBatch (apply it to undo), changed (false when the batch changes nothing; then noChange explains it). --inverse-out FILE saves inverseBatch to a file; --compact drops inverseBatch and guidance and adds summary.",
737
+ "batchId, revision, changedFiles, affectedIds, changed (false for no-op or replay), alreadyApplied, dryRun and summary. changeReportPath records exact before/after values. reverseEdit/inverseBatchPath describe an explicit new apply write with its own identity and a fixed baseRevision, not history rollback. After later edits, read current fields and construct a new scoped edit; do not force an old snapshot by changing its baseRevision. --inverse-out FILE chooses a durable file. --full includes exact details. Reuse batchId only for identical retries; ledger retains at most 500 identities.",
531
738
  dryRun: "--dry-run validates and returns the result without writing",
532
739
  workedExample: WORKED_EXAMPLE,
533
740
  };
@@ -536,23 +743,199 @@ function batchTopic() {
536
743
  function initTopic() {
537
744
  return {
538
745
  summary:
539
- "smallpen init asks these questions in order. Answer them one per call with --answer ID=JSON and the same --state FILE, or all at once with --answers FILE.",
540
- questions: initializationQuestions("en"),
541
- answersFile: INIT_ANSWERS_EXAMPLE,
542
- run: "smallpen init ./acme --answers answers.json --confirm --json",
543
- order: INITIALIZATION_QUESTION_IDS,
746
+ "Create a blank package with one canvas and Theme/Default. No business brief, question state, starter Tokens, components or platform copies.",
747
+ command:
748
+ "smallpen project init PATH [--name NAME] [--layout single|foundation-product] --json",
749
+ path: "A .smallpen path creates that package directly; a directory creates PATH/BASENAME.smallpen inside a new workspace; --name changes the display name, not the filename.",
750
+ layouts: {
751
+ single: "Default: one self-contained package.",
752
+ "foundation-product":
753
+ "A new directory containing a blank Foundation and a Product that resolves its Tokens and components.",
754
+ },
755
+ existing: "Reject an existing target without changing its contents.",
756
+ nextOperations: [
757
+ nextQuery(["schema", "command", "project", "init", "--json"]),
758
+ ],
759
+ };
760
+ }
761
+
762
+ // page set and component set take the same fields.
763
+ function setTopic(command) {
764
+ return {
765
+ command,
766
+ fields: {
767
+ set: "FIELD=VALUE, repeatable; VALUE is JSON (320, [8,16], {\"Style\":\"ghost\"}, true) or plain text; {token.path} binds a Token",
768
+ element: "fields: width, height (number, fill, hug), fill, stroke, radius, opacity, shadow, visible, rotation, margin, min/max sizes; texts: text, color, font, fontFamily, fontSize, fontWeight, lineHeight, letterSpacing, textTransform, textDecoration, textAlign; containers: layout (row|column|none), gap, padding, align, justify",
769
+ copy: "a copy of a component: props, text {\"Element\": \"text\"}, set {\"Element.field\": value}, width, height (numbers), visible, opacity",
770
+ itself: "without --element: the page version (or the variant) itself, for example its width and height",
771
+ },
772
+ example: [
773
+ "page set app.smallpen --page Board --element Card --set width=320 --set fill={color.surface}",
774
+ "page set app.smallpen --page Board --element \"Top bar / Title\" --set text={text.board.title} --set fontSize=24",
775
+ "page set app.smallpen --page Board --element Save --set props={\"Style\":\"ghost\"}",
776
+ "page set app.smallpen --page Board --platform mobile --set width=390 --set height=900",
777
+ "component set app.smallpen --component Button --element Label --all --set fontWeight=600",
778
+ ],
779
+ notes: [
780
+ "Only the named fields change; everything else, links included, stays. Texts are measured again and containers that hug grow or shrink, as page draw does.",
781
+ "Versions and variants often differ on purpose: an element (or the page or component itself) in several of them needs --platform (--variant) to name one, or --all to change every one; the refusal lists where it is.",
782
+ "A value written over a Token binding replaces the binding.",
783
+ ],
544
784
  };
545
785
  }
546
786
 
547
787
  const TOPICS = {
548
- batch: { build: batchTopic, summary: "Batch contract, Package roles, and a worked example" },
549
- "component-set": { build: componentSetTopic, summary: "Component Set, Axis roles, variants" },
550
- context: { build: contextTopic, summary: "Context Axes, profiles, and theme values (alias: theme)" },
551
- init: { build: initTopic, summary: "Every init question and a complete --answers file" },
788
+ workflow: {
789
+ build: () => CLI_RULES,
790
+ summary: "CLI operation rules (compatibility alias for help rules)",
791
+ },
792
+ "component-define": {
793
+ build: () => ({
794
+ command: "component define PACKAGE --intent FILE --json",
795
+ fields: {
796
+ name: "component name; defining it again replaces it and keeps its id",
797
+ properties: "{Property: [values]}; every combination becomes a variant",
798
+ base: "element tree (see element) shared by all variants",
799
+ variants: '[{when: {Property: value}, set: {"Child.prop": value}}]: changes applied to matching variants, in order',
800
+ },
801
+ element: {
802
+ name: "element name; children are found by name in set paths and instance text",
803
+ text: "makes a text element; color, font ({typography token}), fontFamily (a family imported with asset font import, or {font-family token}), fontSize, fontWeight, lineHeight, textAlign",
804
+ children: "child elements; a container lays them out as a column unless layout says row or none",
805
+ layout: "row | column | none; in none the CLI places children so they do not overlap: give place (\"below Header\", {rightOf: \"Sidebar\", gap: 24}) or nothing, x/y only to pin one",
806
+ "gap / padding": "number or {token}; padding takes 1, 2 or 4 values",
807
+ "align / justify": "start | center | end (align also stretch; justify also space-between)",
808
+ "width / height": 'number, "fill" (or "100%") or "hug"; the only text in a row fills by default, several texts in a row hug',
809
+ "fill / stroke / radius / opacity / shadow": 'value or {token}; stroke is a color or {color, width, style}, width one value or four sides [top, right, bottom, left]; radius is one value or four corners [top-left, top-right, bottom-right, bottom-left]',
810
+ margin: "number or {token}; 1, 2 or 4 values, like padding",
811
+ "minWidth / maxWidth / minHeight / maxHeight": "number or {token}",
812
+ "letterSpacing / lineHeight / textTransform / textDecoration": "text only; value or {token} (textTransform: uppercase|lowercase|capitalize|none, textDecoration: underline|line-through|none)",
813
+ rotation: "degrees or {token}",
814
+ visible: "false hides it (for example per platform in a variant), or {flag} for a boolean Token",
815
+ use: 'component name for an instance, with props {Property: value}, text {"Element": "text"} and set {"Element.field": value}; field is text, visible, opacity, fill, color, name, stroke, radius, width, height, shadow, fontSize, fontWeight, fontFamily, letterSpacing, lineHeight, or props (switch a nested component: {"Icon.props": {"Size": "lg"}}); no element name sets the copy itself; a {token} value is the copy\'s own binding',
816
+ },
817
+ example: {
818
+ name: "Button",
819
+ properties: { Style: ["primary", "secondary"], Size: ["sm", "md"] },
820
+ base: { layout: "row", padding: [8, "{space.4}"], fill: "{color.brand}", radius: "{radius.md}", align: "center",
821
+ children: [{ name: "Label", text: "Button", color: "#ffffff", font: "{type.label}" }] },
822
+ variants: [
823
+ { when: { Style: "secondary" }, set: { fill: "#ffffff", stroke: "{color.brand}", "Label.color": "{color.brand}" } },
824
+ { when: { Size: "md" }, set: { padding: [12, 24] } },
825
+ ],
826
+ },
827
+ notes: [
828
+ "The CLI creates node ids, child order, layout fields and Token bindings; text is measured with the renderer's fonts and hugging elements are sized from it.",
829
+ "A value in braces binds that Token by path; any other value is literal. view marks literal visual values so you can bind them later.",
830
+ "text: \"{text.path}\" binds a string Token and visible: \"{flag.path}\" a boolean one, so text follows the language option and an element shows or hides per option; instance text: {Label: \"{text.path}\"} works too.",
831
+ ],
832
+ }),
833
+ summary: "Define a component by name: base elements, properties and variant changes",
834
+ },
835
+ "page-draw": {
836
+ build: () => ({
837
+ command: "page draw PACKAGE --intent FILE --json",
838
+ fields: {
839
+ page: "page name; a new page is created",
840
+ module: "business module; pages of one module share the name prefix Module / Page and one row block on the canvas",
841
+ canvas: "optional canvas name; by default every page is on one canvas (Penpot links and prototype flows only reach boards on the same canvas); give a name only to split pages off",
842
+ platform: "desktop (default), mobile or tablet: the page version to draw; size defaults to the platform's",
843
+ into: "redraw only this container's content",
844
+ children: "element tree (schema component-define, element); instances use components by name",
845
+ },
846
+ example: {
847
+ page: "Board", module: "Tasks", platform: "desktop", layout: "row", fill: "{color.bg.canvas}",
848
+ children: [
849
+ { name: "Sidebar", width: 240, height: "fill", children: [{ name: "Logo", text: "Flowboard" }] },
850
+ { name: "Content", width: "fill", height: "fill", padding: 24, gap: 16, children: [
851
+ { name: "Top bar", layout: "row", width: "fill", align: "center", children: [
852
+ { name: "Title", text: "Sprint 24", width: "fill" },
853
+ { name: "New task", use: "Button", props: { Style: "primary" }, text: { Label: "New task" } },
854
+ ] },
855
+ ] },
856
+ ],
857
+ },
858
+ notes: [
859
+ "Drawing a page again redraws it; elements drawn again under the same names keep their ids and interactions.",
860
+ "An instance whose new text is longer than its slot grows by the measured difference.",
861
+ "Link elements with flow link --from \"Page / Element\" --to Page.",
862
+ "Move an element later with page move --page Page --element Name --direction up|down|left|right [--steps N]; the CLI picks the coordinates.",
863
+ "The CLI lays the canvas out: each module (Tasks / ... pages) is a row block, one line per platform, pages left to right from the start page along links, more room between modules. Reorder a page with page move --page Page --direction left|right; see it with canvas list or view --canvas NAME.",
864
+ "Strings are Tokens: bind text with \"{text.path}\" (string Token) and visibility with \"{flag.path}\" (boolean Token); check a language with view --theme Language/<option>, for example Language/Chinese.",
865
+ ],
866
+ }),
867
+ summary: "Draw a page or one container from named elements and component instances",
868
+ },
869
+ "page-set": {
870
+ build: () => setTopic("page set PACKAGE --page NAME [--platform P] [--element NAME] --set FIELD=VALUE ... --json"),
871
+ summary: "Change some fields of one element (or of a page itself) without drawing it again",
872
+ },
873
+ "component-fields": {
874
+ build: () => setTopic("component set PACKAGE --component NAME [--variant V] [--element NAME] --set FIELD=VALUE ... --json"),
875
+ summary: "Change some fields of one element of a component (or of the component itself) in its variants without defining it again",
876
+ },
877
+
878
+ "token-set": {
879
+ build: () => ({
880
+ command: "token set PACKAGE --intent FILE --json",
881
+ fields: { tokens: `[{${TOKEN_SET_FIELDS.join(", ")}}]` },
882
+ example: {
883
+ tokens: [
884
+ { name: "color.brand", type: "color", value: "#4f46e5", values: { "Theme/Dark": "#818cf8" } },
885
+ { name: "space.4", type: "spacing", value: 16 },
886
+ { name: "color.action", type: "color", value: "{color.brand}" },
887
+ ],
888
+ },
889
+ notes: [
890
+ "Name Tokens by path and theme options as Group/Option. Where values are stored is the CLI's business.",
891
+ "value is the Token's value wherever no option overrides it; values override named options. Each call changes only what it names.",
892
+ "group (optional) names the theme group whose options a new Token should vary by. type is required for a new Token.",
893
+ "Write an alias as a value in braces, for example {color.brand}. Read results with token list: each row lists values that differ under other options.",
894
+ "Strings and flags are Tokens too: type string (with a Language group, values per language) and boolean. token export --type string --by Language writes one JSON file per language.",
895
+ ],
896
+ }),
897
+ summary: "Set Tokens by name: default and per-option values in one call",
898
+ },
899
+ command: {
900
+ summary:
901
+ "One command's parameters, types, defaults and constraints (smallpen schema command NAME)",
902
+ },
903
+ commands: {
904
+ build: () => ({
905
+ commands: PUBLIC_COMMANDS.map(({ command: name, purpose }) => ({
906
+ name,
907
+ purpose,
908
+ })),
909
+ next: "smallpen schema command NAME --json",
910
+ }),
911
+ summary: "Command names and purposes; fetch one parameter schema at a time",
912
+ },
913
+ batch: {
914
+ build: batchTopic,
915
+ summary: "Batch contract, Package roles, and a worked example",
916
+ },
917
+ "component-set": {
918
+ build: componentSetTopic,
919
+ summary: "Component Set, Axis roles, variants",
920
+ },
921
+ context: {
922
+ build: contextTopic,
923
+ summary: "Context Axes and profiles (viewport, density, locale, ...)",
924
+ },
925
+ init: {
926
+ build: initTopic,
927
+ summary: "Blank package paths, layouts and creation behavior",
928
+ },
552
929
  instance: { build: instanceTopic, summary: "INSTANCE nodes and overrides" },
553
930
  node: { build: nodeTopic, summary: "Node fields, textStyle, tokenBindings" },
554
- "node-types": { build: nodeTypesTopic, summary: "What each node type requires" },
555
- operation: { summary: "One operation: fields, types, example, inverse (smallpen schema operation TYPE)" },
931
+ "node-types": {
932
+ build: nodeTypesTopic,
933
+ summary: "What each node type requires",
934
+ },
935
+ operation: {
936
+ summary:
937
+ "One operation: fields, types, example, inverse (smallpen schema operation TYPE)",
938
+ },
556
939
  operations: {
557
940
  build: () => ({
558
941
  operations: OPERATION_TYPES.map(operationSummary),
@@ -560,18 +943,35 @@ const TOPICS = {
560
943
  }),
561
944
  summary: "Every operation type with its purpose and required fields",
562
945
  },
563
- presentation: { build: presentationTopic, summary: "Presentation fields, size, and resizing" },
946
+ presentation: {
947
+ build: presentationTopic,
948
+ summary: "Presentation fields, size, and resizing",
949
+ },
564
950
  scenario: { build: scenarioTopic, summary: "Scenario fields and viewport" },
565
951
  screen: { build: screenTopic, summary: "Screen fields" },
566
- token: { build: tokenTopic, summary: "Token definition, contextValues, bindings" },
567
- "token-types": { build: tokenTypesTopic, summary: "The value each Token type accepts" },
952
+ theme: {
953
+ build: themeTopic,
954
+ summary: "Token sets and themes, --theme, Foundation + Product themes",
955
+ },
956
+ token: {
957
+ build: tokenTopic,
958
+ summary: "Tokens in token sets, DTCG files, bindings",
959
+ },
960
+ "token-types": {
961
+ build: tokenTypesTopic,
962
+ summary: "The value each Token type accepts",
963
+ },
568
964
  };
569
- const TOPIC_ALIASES = { theme: "context" };
570
965
 
571
966
  export const SCHEMA_TOPICS = Object.freeze(Object.keys(TOPICS));
572
967
 
573
- export function schemaTopic(topicName, argument) {
574
- if (topicName === undefined) {
968
+
969
+ export function schemaTopic(
970
+ name,
971
+ argument,
972
+ { full = true, limit, offset = 0, action } = {},
973
+ ) {
974
+ if (name === undefined) {
575
975
  return {
576
976
  topics: Object.fromEntries(
577
977
  Object.entries(TOPICS).map(([name, { summary }]) => [name, summary]),
@@ -579,10 +979,18 @@ export function schemaTopic(topicName, argument) {
579
979
  usage: "smallpen schema TOPIC [--json]; smallpen schema operation TYPE",
580
980
  };
581
981
  }
582
- if (Object.hasOwn(OPS, topicName) && argument === undefined) {
583
- return schemaTopic("operation", topicName);
982
+ if (name === "command") {
983
+ if (Object.hasOwn(COMMAND_GROUPS, argument)) {
984
+ if (groupDefinition(argument, action)) return { topic: name, ...helpTopic(argument, action) };
985
+ return { topic: name, ...actionContract(argument, action) };
986
+ }
987
+ if (action !== undefined)
988
+ throw new SmallPenError(
989
+ "unexpected_argument",
990
+ `Unexpected command action: ${action}`,
991
+ );
992
+ return { topic: name, ...commandContract(argument) };
584
993
  }
585
- const name = TOPIC_ALIASES[topicName] ?? topicName;
586
994
  if (name === "operation") {
587
995
  if (!Object.hasOwn(OPS, argument ?? "")) {
588
996
  const suggestion = suggestField(argument, OPERATION_TYPES);
@@ -590,20 +998,29 @@ export function schemaTopic(topicName, argument) {
590
998
  "unknown_schema_operation",
591
999
  `${argument === undefined ? "smallpen schema operation requires TYPE" : `Unknown operation type: ${argument}`}.` +
592
1000
  `${suggestion ? ` Did you mean ${suggestion}?` : ""} Types: ${OPERATION_TYPES.join(", ")}`,
593
- { validTypes: [...OPERATION_TYPES], ...(suggestion ? { suggestion } : {}) },
1001
+ {
1002
+ validTypes: [...OPERATION_TYPES],
1003
+ ...(suggestion ? { suggestion } : {}),
1004
+ },
594
1005
  );
595
1006
  }
596
- return { topic: "operation", type: argument, ...operationDetail(argument) };
1007
+ const detail = operationDetail(argument);
1008
+ return { topic: "operation", type: argument, ...detail };
597
1009
  }
598
1010
  if (!Object.hasOwn(TOPICS, name)) {
599
1011
  const suggestion = suggestField(name, Object.keys(TOPICS));
600
1012
  throw new SmallPenError(
601
1013
  "unknown_schema_topic",
602
- `Unknown schema topic: ${topicName}.${suggestion ? ` Did you mean ${suggestion}?` : ""} ` +
1014
+ `Unknown schema topic: ${name}.${suggestion ? ` Did you mean ${suggestion}?` : ""} ` +
603
1015
  `Topics: ${Object.keys(TOPICS).join(", ")}`,
604
- { validTopics: Object.keys(TOPICS), ...(suggestion ? { suggestion } : {}) },
1016
+ {
1017
+ validTopics: Object.keys(TOPICS),
1018
+ ...(suggestion ? { suggestion } : {}),
1019
+ },
605
1020
  );
606
1021
  }
1022
+ if (name === "workflow") return helpTopic("workflow", argument, { full });
1023
+ const value = TOPICS[name].build();
607
1024
  if (argument !== undefined) {
608
1025
  throw new SmallPenError(
609
1026
  "unexpected_argument",
@@ -611,5 +1028,43 @@ export function schemaTopic(topicName, argument) {
611
1028
  { argument },
612
1029
  );
613
1030
  }
614
- return { topic: name, ...TOPICS[name].build() };
1031
+ if (["commands", "operations"].includes(name)) {
1032
+ const items = value[name].map((item) => {
1033
+ if (full || name === "commands") return item;
1034
+ const { required: _required, requiresOneOf: _oneOf, ...summary } = item;
1035
+ return summary;
1036
+ });
1037
+ const page = pageItems(items, {
1038
+ limit: limit ?? (full ? items.length : 20),
1039
+ offset,
1040
+ });
1041
+ return {
1042
+ topic: name,
1043
+ [name]: page.items,
1044
+ page: page.page,
1045
+ nextOperations: [
1046
+ ...(page.page.hasMore
1047
+ ? [
1048
+ nextQuery([
1049
+ "schema",
1050
+ name,
1051
+ "--limit",
1052
+ String(page.page.limit),
1053
+ "--offset",
1054
+ String(offset + page.page.limit),
1055
+ ...(full ? ["--full"] : []),
1056
+ "--json",
1057
+ ]),
1058
+ ]
1059
+ : []),
1060
+ nextQuery([
1061
+ "schema",
1062
+ name === "commands" ? "command" : "operation",
1063
+ name === "commands" ? "<name>" : "<type>",
1064
+ "--json",
1065
+ ]),
1066
+ ],
1067
+ };
1068
+ }
1069
+ return { topic: name, ...value };
615
1070
  }