@smallpen/cli 0.1.0-alpha.1 → 0.1.0-alpha.11
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/command-contract.mjs +1416 -0
- package/bin/command-tree.mjs +512 -0
- package/bin/design-output.mjs +1039 -0
- package/bin/help-topics.mjs +241 -0
- package/bin/help.mjs +468 -363
- package/bin/read-output.mjs +219 -0
- package/bin/result-files.mjs +232 -0
- package/bin/reuse-advice.mjs +73 -0
- package/bin/schema.mjs +1070 -0
- package/bin/smallpen-check.cjs +0 -0
- package/bin/smallpen.mjs +4474 -1179
- package/bin/warning-output.mjs +10 -3
- package/bin/windows-artifacts.mjs +72 -0
- package/bin/workflow.mjs +55 -0
- package/package.json +3 -3
package/bin/schema.mjs
ADDED
|
@@ -0,0 +1,1070 @@
|
|
|
1
|
+
// `smallpen schema [topic]`: the JSON shapes a write needs. Field lists,
|
|
2
|
+
// types, enums, and examples come from the validators in @smallpen/core
|
|
3
|
+
// so this output and validation stay the same; a test applies every
|
|
4
|
+
// example to a workspace made by `smallpen init`.
|
|
5
|
+
import {
|
|
6
|
+
AXIS_ROLES,
|
|
7
|
+
CANONICAL_SCHEMA_RULES,
|
|
8
|
+
CONTEXT_AXIS_KINDS,
|
|
9
|
+
INSTANCE_OVERRIDE_FIELDS,
|
|
10
|
+
OPERATION_SCHEMAS,
|
|
11
|
+
SMALLPEN_FORMAT_CAPABILITIES,
|
|
12
|
+
SmallPenError,
|
|
13
|
+
suggestField,
|
|
14
|
+
TOKEN_BINDING_FIELD_TYPES,
|
|
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,
|
|
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";
|
|
37
|
+
|
|
38
|
+
const FORMAT = SMALLPEN_FORMAT_CAPABILITIES;
|
|
39
|
+
const OPERATION_TYPES = FORMAT.canonicalWrite.operationTypes;
|
|
40
|
+
// The default `smallpen init` layout: one self-contained Package.
|
|
41
|
+
const PACKAGE = "acme/acme.smallpen";
|
|
42
|
+
const REVISION = "<package.revision from smallpen inspect PACKAGE --json>";
|
|
43
|
+
|
|
44
|
+
export const PACKAGE_ROLES = Object.freeze({
|
|
45
|
+
foundation:
|
|
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).",
|
|
47
|
+
product:
|
|
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.",
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
function batchFor(operations, batchId) {
|
|
52
|
+
return { baseRevision: REVISION, batchId, operations };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const OPS = OPERATION_SCHEMAS;
|
|
56
|
+
|
|
57
|
+
// The node an Instance example adds; it is also step 4 of the worked
|
|
58
|
+
// example.
|
|
59
|
+
export const INSTANCE_NODE_EXAMPLE = Object.freeze({
|
|
60
|
+
children: [],
|
|
61
|
+
height: 48,
|
|
62
|
+
id: "node_add_button",
|
|
63
|
+
instance: {
|
|
64
|
+
component: { assetId: "cmp_button", packageId: "pkg_acme" },
|
|
65
|
+
variant: { axis_style: "primary" },
|
|
66
|
+
},
|
|
67
|
+
name: "Add button",
|
|
68
|
+
type: "INSTANCE",
|
|
69
|
+
width: 328,
|
|
70
|
+
x: 16,
|
|
71
|
+
y: 576,
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
export const INIT_ANSWERS_EXAMPLE = Object.freeze({
|
|
75
|
+
audience: "Small teams",
|
|
76
|
+
contextAxes: [
|
|
77
|
+
{
|
|
78
|
+
defaultValue: "Light",
|
|
79
|
+
id: "axis_theme",
|
|
80
|
+
kind: "theme",
|
|
81
|
+
name: "Theme",
|
|
82
|
+
values: ["Light", "Dark"],
|
|
83
|
+
},
|
|
84
|
+
],
|
|
85
|
+
firstJourney: "Add a task",
|
|
86
|
+
firstOutput: "Task list screen",
|
|
87
|
+
firstScenario: "Default",
|
|
88
|
+
firstScreen: "Home",
|
|
89
|
+
foundationChoice: "self-contained",
|
|
90
|
+
initialComponents: ["Button"],
|
|
91
|
+
initialTokens: ["color.brand", "spacing.md", "radius.md"],
|
|
92
|
+
kindDetails: ["responsive"],
|
|
93
|
+
platforms: ["mobile"],
|
|
94
|
+
projectKind: "application",
|
|
95
|
+
projectName: "Acme",
|
|
96
|
+
purpose: "Track daily tasks",
|
|
97
|
+
sourceInputs: ["none"],
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
const TYPOGRAPHY_TOKEN = {
|
|
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 },
|
|
107
|
+
},
|
|
108
|
+
type: "put-set-token",
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
// A generic, executable atomic-write example. Scene design order lives in Skills.
|
|
112
|
+
export const WORKED_EXAMPLE = Object.freeze([
|
|
113
|
+
{
|
|
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.",
|
|
118
|
+
},
|
|
119
|
+
{
|
|
120
|
+
title: "Create one canvas through an atomic batch",
|
|
121
|
+
argv: ["advanced", "apply", PACKAGE, "--batch", "canvas.json", "--json"],
|
|
122
|
+
files: {
|
|
123
|
+
"canvas.json": batchFor(
|
|
124
|
+
[
|
|
125
|
+
{
|
|
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
|
+
],
|
|
154
|
+
},
|
|
155
|
+
},
|
|
156
|
+
],
|
|
157
|
+
"canvas-v1",
|
|
158
|
+
),
|
|
159
|
+
},
|
|
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.",
|
|
161
|
+
},
|
|
162
|
+
{
|
|
163
|
+
title: "Add text to that canvas",
|
|
164
|
+
argv: ["advanced", "apply", PACKAGE, "--batch", "text.json", "--json"],
|
|
165
|
+
files: {
|
|
166
|
+
"text.json": batchFor(
|
|
167
|
+
[
|
|
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
|
+
},
|
|
192
|
+
],
|
|
193
|
+
"text-v1",
|
|
194
|
+
),
|
|
195
|
+
},
|
|
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.",
|
|
197
|
+
},
|
|
198
|
+
{
|
|
199
|
+
title: "Read the selected canvas as a text wireframe",
|
|
200
|
+
argv: ["view", PACKAGE, "--page", "Canvas", "--as", "wireframe", "--json"],
|
|
201
|
+
files: {},
|
|
202
|
+
},
|
|
203
|
+
]);
|
|
204
|
+
|
|
205
|
+
function compact(value) {
|
|
206
|
+
return JSON.stringify(value);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
export function workedExampleText() {
|
|
210
|
+
return WORKED_EXAMPLE.map((step, index) => {
|
|
211
|
+
const lines = [` ${index + 1}. ${step.title}`];
|
|
212
|
+
for (const [name, value] of Object.entries(step.files)) {
|
|
213
|
+
lines.push(` ${name}: ${compact(value)}`);
|
|
214
|
+
}
|
|
215
|
+
lines.push(` smallpen ${step.argv.join(" ")}`);
|
|
216
|
+
if (step.note) lines.push(` ${step.note}`);
|
|
217
|
+
return lines.join("\n");
|
|
218
|
+
}).join("\n");
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function operationSummary(type) {
|
|
222
|
+
const schema = OPS[type];
|
|
223
|
+
return {
|
|
224
|
+
package: schema.package,
|
|
225
|
+
purpose: schema.purpose,
|
|
226
|
+
required: Object.entries(schema.fields)
|
|
227
|
+
.filter(([, spec]) => spec.required)
|
|
228
|
+
.map(([name]) => name),
|
|
229
|
+
...(schema.oneOf ? { requiresOneOf: schema.oneOf } : {}),
|
|
230
|
+
type,
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// put-* operations replace an existing object with the same id whole;
|
|
235
|
+
// init seeds a few, so say which.
|
|
236
|
+
const EXISTING_ID_NOTES = {
|
|
237
|
+
"put-component-set":
|
|
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.",
|
|
239
|
+
"put-token":
|
|
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.",
|
|
243
|
+
};
|
|
244
|
+
|
|
245
|
+
function existingIdNote(type, schema) {
|
|
246
|
+
if (EXISTING_ID_NOTES[type]) return EXISTING_ID_NOTES[type];
|
|
247
|
+
return type.startsWith("put-") && /replace/i.test(schema.purpose)
|
|
248
|
+
? "An existing object with the same id is replaced whole (no merge)."
|
|
249
|
+
: undefined;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function operationDetail(type) {
|
|
253
|
+
const schema = OPS[type];
|
|
254
|
+
const existingId = existingIdNote(type, schema);
|
|
255
|
+
return {
|
|
256
|
+
...(existingId ? { existingId } : {}),
|
|
257
|
+
fields: {
|
|
258
|
+
type: {
|
|
259
|
+
description: "Operation type",
|
|
260
|
+
required: true,
|
|
261
|
+
type: "string",
|
|
262
|
+
value: type,
|
|
263
|
+
},
|
|
264
|
+
...schema.fields,
|
|
265
|
+
},
|
|
266
|
+
inverse: schema.inverse,
|
|
267
|
+
package: schema.package,
|
|
268
|
+
packageRole:
|
|
269
|
+
PACKAGE_ROLES[schema.package] ??
|
|
270
|
+
"Either Package; it changes the Package you pass",
|
|
271
|
+
purpose: schema.purpose,
|
|
272
|
+
...(schema.oneOf ? { requiresOneOf: schema.oneOf } : {}),
|
|
273
|
+
unknownFields: "rejected with unknown_operation_field",
|
|
274
|
+
example: batchFor([schema.example], `example-${type}`),
|
|
275
|
+
run: `smallpen apply <${schema.package === "foundation" ? "foundation" : "product"}.smallpen> --batch BATCH.json --dry-run --json`,
|
|
276
|
+
};
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
const NODE_FIELD_TYPES = {
|
|
280
|
+
appliedTokens: "object; local-name compatibility only, prefer tokenBindings",
|
|
281
|
+
backgroundBlur: "object or array of objects",
|
|
282
|
+
blur: "object or array of objects",
|
|
283
|
+
"blend-mode": "string",
|
|
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`,
|
|
285
|
+
content: "Penpot path content",
|
|
286
|
+
cornerRadius: "number or [topLeft, topRight, bottomRight, bottomLeft]",
|
|
287
|
+
fills:
|
|
288
|
+
'[{type:"solid", color:"#rrggbb", opacity?}] (also linear-gradient, radial-gradient, image)',
|
|
289
|
+
flipX: "boolean",
|
|
290
|
+
flipY: "boolean",
|
|
291
|
+
grids: "array of objects",
|
|
292
|
+
growType: `TEXT only: ${CANONICAL_SCHEMA_RULES.textGrowTypes.join(" | ")}`,
|
|
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)',
|
|
297
|
+
locked: "boolean",
|
|
298
|
+
mediaRef: "IMAGE only: media_... id or {packageId, assetId}",
|
|
299
|
+
name: "non-empty string",
|
|
300
|
+
opacity: "number from 0 to 1",
|
|
301
|
+
pathData: "PATH only: SVG path data",
|
|
302
|
+
points: "PATH only: array of points",
|
|
303
|
+
proportionLock: "boolean",
|
|
304
|
+
rotation: "number, 0 <= r < 360",
|
|
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`,
|
|
308
|
+
text: "TEXT only: plain text (string)",
|
|
309
|
+
textBlocks:
|
|
310
|
+
"TEXT only: [{runs:[{text, textStyle?, fills?}], textStyle?}] that join to text",
|
|
311
|
+
textStyle: "TEXT only: see textStyle below",
|
|
312
|
+
tokenBindings:
|
|
313
|
+
"{field: {packageId, assetId:tok_...}}; see tokenBindings below",
|
|
314
|
+
touched: "array of Penpot touched groups",
|
|
315
|
+
visible: "boolean",
|
|
316
|
+
};
|
|
317
|
+
|
|
318
|
+
function tokenBindingTable() {
|
|
319
|
+
return Object.fromEntries(
|
|
320
|
+
FORMAT.canonicalPackage.tokenBindingFields.map((field) => [
|
|
321
|
+
field,
|
|
322
|
+
TOKEN_BINDING_FIELD_TYPES.get(field === "fills.N" ? "fill" : field) ?? [],
|
|
323
|
+
]),
|
|
324
|
+
);
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
const NODE_EXAMPLE = OPS["add-presentation-node"].example.node;
|
|
328
|
+
|
|
329
|
+
function nodeTopic() {
|
|
330
|
+
return {
|
|
331
|
+
summary:
|
|
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.",
|
|
333
|
+
required: {
|
|
334
|
+
children:
|
|
335
|
+
"array of child node ids (ids only; add each child as its own node)",
|
|
336
|
+
height: "number",
|
|
337
|
+
id: "must start with node_ (node_[A-Za-z0-9_-]+), unique in its Presentation (or variant); the nodes key equals id",
|
|
338
|
+
name: "non-empty string",
|
|
339
|
+
type: `${CANONICAL_SCHEMA_RULES.nodeTypes.join(" | ")}. Inside a Component variant only ${VARIANT_NODE_TYPES.join(", ")} (no ELLIPSE, GROUP or PATH)`,
|
|
340
|
+
width: "number",
|
|
341
|
+
x: "number, relative to the parent",
|
|
342
|
+
y: "number, relative to the parent",
|
|
343
|
+
},
|
|
344
|
+
byType: nodeTypesTopic().types,
|
|
345
|
+
fields: Object.fromEntries(
|
|
346
|
+
CANONICAL_SCHEMA_RULES.nodeChangeFields.map((field) => [
|
|
347
|
+
field,
|
|
348
|
+
NODE_FIELD_TYPES[field] ??
|
|
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"
|
|
358
|
+
: "see validator"),
|
|
359
|
+
]),
|
|
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
|
+
},
|
|
435
|
+
textStyle: {
|
|
436
|
+
fields: CANONICAL_SCHEMA_RULES.textStyleFields,
|
|
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.",
|
|
443
|
+
values: CANONICAL_SCHEMA_RULES.textStyleEnums,
|
|
444
|
+
note: "Text alignment is textStyle.textAlign, not a node field.",
|
|
445
|
+
},
|
|
446
|
+
tokenBindings: {
|
|
447
|
+
shape: '{"fill": {"packageId":"pkg_acme","assetId":"tok_color_brand"}}',
|
|
448
|
+
fieldTokenTypes: tokenBindingTable(),
|
|
449
|
+
notBindable:
|
|
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.",
|
|
452
|
+
},
|
|
453
|
+
changes:
|
|
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.",
|
|
455
|
+
example: NODE_EXAMPLE,
|
|
456
|
+
};
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
function nodeTypesTopic() {
|
|
460
|
+
return {
|
|
461
|
+
types: {
|
|
462
|
+
COMPONENT:
|
|
463
|
+
"Root of a Component variant, or a located Component main node",
|
|
464
|
+
ELLIPSE: "Ellipse inside x/y/width/height",
|
|
465
|
+
FRAME: "Container; fills, strokes, cornerRadius, layout",
|
|
466
|
+
GROUP: "Plain group of children",
|
|
467
|
+
IMAGE: "Requires mediaRef (import with smallpen asset media import)",
|
|
468
|
+
INSTANCE: "Requires instance; see: smallpen schema instance",
|
|
469
|
+
PATH: "pathData or points; strokes with capStart/capEnd for arrows",
|
|
470
|
+
RECTANGLE: "Rectangle; fills, strokes, cornerRadius",
|
|
471
|
+
TEXT: "Requires text; textStyle, textBlocks, growType",
|
|
472
|
+
},
|
|
473
|
+
presentationTypes: CANONICAL_SCHEMA_RULES.nodeTypes,
|
|
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.",
|
|
477
|
+
};
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
function tokenTopic() {
|
|
481
|
+
return {
|
|
482
|
+
summary:
|
|
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",
|
|
491
|
+
},
|
|
492
|
+
operations: {
|
|
493
|
+
appCreate: TOKEN_INTENT_ACTIONS["create-row"].example,
|
|
494
|
+
appChangeValue: TOKEN_INTENT_ACTIONS["set-value"].example,
|
|
495
|
+
create: "put-set-token {setId, token}",
|
|
496
|
+
changeValue: "set-token-value {tokenId, value}",
|
|
497
|
+
bind: "set-token-binding {screenId, nodeId, field, binding:{packageId, assetId}}, or tokenBindings on the node",
|
|
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",
|
|
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).",
|
|
503
|
+
tokenBindings: tokenBindingTable(),
|
|
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,
|
|
511
|
+
typographyExample: TYPOGRAPHY_TOKEN,
|
|
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.",
|
|
545
|
+
};
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
function tokenTypesTopic() {
|
|
549
|
+
return {
|
|
550
|
+
types: Object.fromEntries(
|
|
551
|
+
FORMAT.canonicalPackage.tokenTypes.map((type) => [
|
|
552
|
+
type,
|
|
553
|
+
TOKEN_VALUE_SHAPES[type],
|
|
554
|
+
]),
|
|
555
|
+
),
|
|
556
|
+
alias: 'Any type also accepts "{path.of.another.token}" of the same type.',
|
|
557
|
+
};
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
function componentSetTopic() {
|
|
561
|
+
return {
|
|
562
|
+
summary:
|
|
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.`,
|
|
565
|
+
fields: {
|
|
566
|
+
id: "cmp_...",
|
|
567
|
+
name: "non-empty string",
|
|
568
|
+
axes: "[{id:axis_..., name, role, domain?}]",
|
|
569
|
+
variants:
|
|
570
|
+
"[{id:var_..., selection:{axisId: value}, rootId, nodes:{id: node}}]",
|
|
571
|
+
visibility: "public | private (default public)",
|
|
572
|
+
category: "optional string",
|
|
573
|
+
description: "optional string",
|
|
574
|
+
deprecated: "optional boolean",
|
|
575
|
+
replacement: "optional {packageId, assetId:cmp_...}",
|
|
576
|
+
},
|
|
577
|
+
axisRoles: {
|
|
578
|
+
values: AXIS_ROLES,
|
|
579
|
+
configuration:
|
|
580
|
+
"An option the designer picks: style (primary/secondary), size, content, icon. domain is optional but recommended.",
|
|
581
|
+
state:
|
|
582
|
+
"An interaction state: default, hover, pressed, disabled. domain is required.",
|
|
583
|
+
modelling:
|
|
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.",
|
|
585
|
+
},
|
|
586
|
+
variantNodes:
|
|
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.",
|
|
588
|
+
operations: {
|
|
589
|
+
create: "put-component-set (whole set) or put-variant (one variant)",
|
|
590
|
+
edit: "update-component-node {componentId, variantId, nodeId, changes}",
|
|
591
|
+
remove: "delete-variant, delete-component-set",
|
|
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.",
|
|
595
|
+
package: "foundation",
|
|
596
|
+
example: OPS["put-component-set"].example.componentSet,
|
|
597
|
+
};
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
function instanceTopic() {
|
|
601
|
+
return {
|
|
602
|
+
summary:
|
|
603
|
+
"An INSTANCE node places a Component Set variant. It references the component by Package and id, and may override fields of nodes inside it.",
|
|
604
|
+
node: {
|
|
605
|
+
type: "INSTANCE",
|
|
606
|
+
instance: {
|
|
607
|
+
component: "{packageId: owning Package (pkg_acme), assetId: cmp_...}",
|
|
608
|
+
variant: "{axisId: value} for every Axis of the Component Set",
|
|
609
|
+
overrides:
|
|
610
|
+
'{"<sourceNodeId>:<field>": value}; prefer set-instance-override',
|
|
611
|
+
},
|
|
612
|
+
size: "x/y/width/height of the Instance itself",
|
|
613
|
+
},
|
|
614
|
+
overrides: {
|
|
615
|
+
path: "<sourceNodeId>:<field>, or <nestedInstanceId>__<sourceNodeId>:<field> inside a nested Instance",
|
|
616
|
+
fields: [...INSTANCE_OVERRIDE_FIELDS],
|
|
617
|
+
operations:
|
|
618
|
+
"set-instance-override, clear-instance-override, select-instance-variant",
|
|
619
|
+
},
|
|
620
|
+
pageIntent:
|
|
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",
|
|
623
|
+
example: {
|
|
624
|
+
node: INSTANCE_NODE_EXAMPLE,
|
|
625
|
+
parentId: "node_home_root",
|
|
626
|
+
presentationId: "pres_home_mobile",
|
|
627
|
+
screenId: "scr_home",
|
|
628
|
+
type: "add-presentation-node",
|
|
629
|
+
},
|
|
630
|
+
};
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
function presentationTopic() {
|
|
634
|
+
return {
|
|
635
|
+
summary:
|
|
636
|
+
"A Presentation is one layout of a Screen (mobile, desktop, ...). Its rendered size is the bounds of its root node(s), not viewport.",
|
|
637
|
+
fields: {
|
|
638
|
+
id: "pres_...",
|
|
639
|
+
name: "non-empty string",
|
|
640
|
+
platform: "optional string",
|
|
641
|
+
viewport:
|
|
642
|
+
"{width, height}: the declared device size (metadata; render ignores it)",
|
|
643
|
+
nodes: "{node_id: node}",
|
|
644
|
+
rootId: "root node id (or rootIds for several roots)",
|
|
645
|
+
interactions: "[] (see put-interaction)",
|
|
646
|
+
background: "optional color string",
|
|
647
|
+
prototypeFlows: "optional array",
|
|
648
|
+
},
|
|
649
|
+
resize:
|
|
650
|
+
"To change the rendered size, resize the root node with update-presentation-node {changes:{width, height}}. Keep viewport in step with update-presentation {changes:{viewport:{width, height}}} and Scenario viewports with put-scenario.",
|
|
651
|
+
updatePresentationFields: CANONICAL_SCHEMA_RULES.presentationChangeFields,
|
|
652
|
+
example: OPS["add-presentation"].example.presentation,
|
|
653
|
+
};
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
function screenTopic() {
|
|
657
|
+
return {
|
|
658
|
+
summary:
|
|
659
|
+
"A Screen holds one or more Presentations; basePresentationId is the default. Screens live in the Product.",
|
|
660
|
+
fields: {
|
|
661
|
+
id: "scr_...",
|
|
662
|
+
name: "non-empty string",
|
|
663
|
+
basePresentationId: "pres_... of one of its Presentations",
|
|
664
|
+
presentations: "[Presentation]; see: smallpen schema presentation",
|
|
665
|
+
counterparts: "[] (links between Presentations)",
|
|
666
|
+
},
|
|
667
|
+
defaultScreen:
|
|
668
|
+
"set-default-screen chooses the page reads and renders use when no page is named",
|
|
669
|
+
example: OPS["put-screen"].example.screen,
|
|
670
|
+
};
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
function scenarioTopic() {
|
|
674
|
+
return {
|
|
675
|
+
summary:
|
|
676
|
+
"A Scenario is a reproducible design state: a target Screen/Presentation, a Context, optional token themes, and declarative actions.",
|
|
677
|
+
fields: {
|
|
678
|
+
id: "scn_...",
|
|
679
|
+
name: "non-empty string",
|
|
680
|
+
target:
|
|
681
|
+
'{kind:"screen", screen:{packageId, assetId:scr_...}, presentationId}',
|
|
682
|
+
context: "{axisId: value} for the Context Axes",
|
|
683
|
+
themes:
|
|
684
|
+
'optional ["Group/Name"] token themes the Scenario shows; --theme overrides them',
|
|
685
|
+
actions: "[] or declarative actions (set-override, ...)",
|
|
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.",
|
|
688
|
+
fixture: "{key: scalar}",
|
|
689
|
+
viewport:
|
|
690
|
+
"{width, height, scale}: declared size (metadata; render uses the root node size)",
|
|
691
|
+
},
|
|
692
|
+
update:
|
|
693
|
+
"put-scenario with the same id replaces the Scenario, for example to keep its viewport in step with the root node.",
|
|
694
|
+
example: OPS["put-scenario"].example.scenario,
|
|
695
|
+
};
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
function contextTopic() {
|
|
699
|
+
return {
|
|
700
|
+
summary:
|
|
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).",
|
|
702
|
+
axis: {
|
|
703
|
+
id: "axis_...",
|
|
704
|
+
name: "non-empty string",
|
|
705
|
+
kind: CONTEXT_AXIS_KINDS.join(" | "),
|
|
706
|
+
values: "[{id, name}]",
|
|
707
|
+
defaultValue: "one value id",
|
|
708
|
+
},
|
|
709
|
+
profile: "{id:ctx_..., name, values:{axisId: value}, default?: boolean}",
|
|
710
|
+
themes:
|
|
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.",
|
|
713
|
+
example: OPS["put-context-file"].example.contextFile,
|
|
714
|
+
};
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
function batchTopic() {
|
|
718
|
+
return {
|
|
719
|
+
summary:
|
|
720
|
+
"Every write is one atomic Operation Batch. Nothing is written unless every operation is valid.",
|
|
721
|
+
batch: {
|
|
722
|
+
baseRevision:
|
|
723
|
+
"package.revision from smallpen inspect PACKAGE --json; a stale value is rejected",
|
|
724
|
+
batchId: "1-192 characters [A-Za-z0-9_.-], new for every write",
|
|
725
|
+
operations: "array; see: smallpen schema operations",
|
|
726
|
+
},
|
|
727
|
+
packages: PACKAGE_ROLES,
|
|
728
|
+
commands: {
|
|
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",
|
|
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",
|
|
734
|
+
flow: "smallpen flow PRODUCT --intent: same as page, for flowchart nodes",
|
|
735
|
+
},
|
|
736
|
+
result:
|
|
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.",
|
|
738
|
+
dryRun: "--dry-run validates and returns the result without writing",
|
|
739
|
+
workedExample: WORKED_EXAMPLE,
|
|
740
|
+
};
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
function initTopic() {
|
|
744
|
+
return {
|
|
745
|
+
summary:
|
|
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
|
+
],
|
|
784
|
+
};
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
const TOPICS = {
|
|
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
|
+
},
|
|
929
|
+
instance: { build: instanceTopic, summary: "INSTANCE nodes and overrides" },
|
|
930
|
+
node: { build: nodeTopic, summary: "Node fields, textStyle, tokenBindings" },
|
|
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
|
+
},
|
|
939
|
+
operations: {
|
|
940
|
+
build: () => ({
|
|
941
|
+
operations: OPERATION_TYPES.map(operationSummary),
|
|
942
|
+
next: "smallpen schema operation TYPE",
|
|
943
|
+
}),
|
|
944
|
+
summary: "Every operation type with its purpose and required fields",
|
|
945
|
+
},
|
|
946
|
+
presentation: {
|
|
947
|
+
build: presentationTopic,
|
|
948
|
+
summary: "Presentation fields, size, and resizing",
|
|
949
|
+
},
|
|
950
|
+
scenario: { build: scenarioTopic, summary: "Scenario fields and viewport" },
|
|
951
|
+
screen: { build: screenTopic, summary: "Screen fields" },
|
|
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
|
+
},
|
|
964
|
+
};
|
|
965
|
+
|
|
966
|
+
export const SCHEMA_TOPICS = Object.freeze(Object.keys(TOPICS));
|
|
967
|
+
|
|
968
|
+
|
|
969
|
+
export function schemaTopic(
|
|
970
|
+
name,
|
|
971
|
+
argument,
|
|
972
|
+
{ full = true, limit, offset = 0, action } = {},
|
|
973
|
+
) {
|
|
974
|
+
if (name === undefined) {
|
|
975
|
+
return {
|
|
976
|
+
topics: Object.fromEntries(
|
|
977
|
+
Object.entries(TOPICS).map(([name, { summary }]) => [name, summary]),
|
|
978
|
+
),
|
|
979
|
+
usage: "smallpen schema TOPIC [--json]; smallpen schema operation TYPE",
|
|
980
|
+
};
|
|
981
|
+
}
|
|
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) };
|
|
993
|
+
}
|
|
994
|
+
if (name === "operation") {
|
|
995
|
+
if (!Object.hasOwn(OPS, argument ?? "")) {
|
|
996
|
+
const suggestion = suggestField(argument, OPERATION_TYPES);
|
|
997
|
+
throw new SmallPenError(
|
|
998
|
+
"unknown_schema_operation",
|
|
999
|
+
`${argument === undefined ? "smallpen schema operation requires TYPE" : `Unknown operation type: ${argument}`}.` +
|
|
1000
|
+
`${suggestion ? ` Did you mean ${suggestion}?` : ""} Types: ${OPERATION_TYPES.join(", ")}`,
|
|
1001
|
+
{
|
|
1002
|
+
validTypes: [...OPERATION_TYPES],
|
|
1003
|
+
...(suggestion ? { suggestion } : {}),
|
|
1004
|
+
},
|
|
1005
|
+
);
|
|
1006
|
+
}
|
|
1007
|
+
const detail = operationDetail(argument);
|
|
1008
|
+
return { topic: "operation", type: argument, ...detail };
|
|
1009
|
+
}
|
|
1010
|
+
if (!Object.hasOwn(TOPICS, name)) {
|
|
1011
|
+
const suggestion = suggestField(name, Object.keys(TOPICS));
|
|
1012
|
+
throw new SmallPenError(
|
|
1013
|
+
"unknown_schema_topic",
|
|
1014
|
+
`Unknown schema topic: ${name}.${suggestion ? ` Did you mean ${suggestion}?` : ""} ` +
|
|
1015
|
+
`Topics: ${Object.keys(TOPICS).join(", ")}`,
|
|
1016
|
+
{
|
|
1017
|
+
validTopics: Object.keys(TOPICS),
|
|
1018
|
+
...(suggestion ? { suggestion } : {}),
|
|
1019
|
+
},
|
|
1020
|
+
);
|
|
1021
|
+
}
|
|
1022
|
+
if (name === "workflow") return helpTopic("workflow", argument, { full });
|
|
1023
|
+
const value = TOPICS[name].build();
|
|
1024
|
+
if (argument !== undefined) {
|
|
1025
|
+
throw new SmallPenError(
|
|
1026
|
+
"unexpected_argument",
|
|
1027
|
+
`smallpen schema ${name} takes no further argument: ${argument}`,
|
|
1028
|
+
{ argument },
|
|
1029
|
+
);
|
|
1030
|
+
}
|
|
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 };
|
|
1070
|
+
}
|