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

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 ADDED
@@ -0,0 +1,615 @@
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
+ INITIALIZATION_QUESTION_IDS,
10
+ initializationQuestions,
11
+ INSTANCE_OVERRIDE_FIELDS,
12
+ OPERATION_SCHEMAS,
13
+ SMALLPEN_FORMAT_CAPABILITIES,
14
+ SmallPenError,
15
+ suggestField,
16
+ TOKEN_BINDING_FIELD_TYPES,
17
+ TOKEN_VALUE_SHAPES,
18
+ } from "@smallpen/core";
19
+
20
+ const FORMAT = SMALLPEN_FORMAT_CAPABILITIES;
21
+ const OPERATION_TYPES = FORMAT.canonicalWrite.operationTypes;
22
+ const FOUNDATION = "acme/acme-foundation.smallpen";
23
+ const PRODUCT = "acme/acme.smallpen";
24
+ const REVISION = "<package.revision from smallpen inspect PACKAGE --json>";
25
+
26
+ export const PACKAGE_ROLES = Object.freeze({
27
+ foundation:
28
+ "Shared Tokens, Context Axes (theme, density, ...), and shared Component Sets. Write them to the Foundation (<name>-foundation.smallpen).",
29
+ 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.",
31
+ });
32
+
33
+ function batchFor(operations, batchId) {
34
+ return { baseRevision: REVISION, batchId, operations };
35
+ }
36
+
37
+ const OPS = OPERATION_SCHEMAS;
38
+
39
+ // The node an Instance example adds; it is also step 4 of the worked
40
+ // example.
41
+ export const INSTANCE_NODE_EXAMPLE = Object.freeze({
42
+ children: [],
43
+ height: 48,
44
+ id: "node_add_button",
45
+ instance: {
46
+ component: { assetId: "cmp_button", packageId: "pkg_acme_foundation" },
47
+ variant: { axis_style: "primary" },
48
+ },
49
+ name: "Add button",
50
+ type: "INSTANCE",
51
+ width: 328,
52
+ x: 16,
53
+ y: 576,
54
+ });
55
+
56
+ export const INIT_ANSWERS_EXAMPLE = Object.freeze({
57
+ audience: "Small teams",
58
+ contextAxes: [
59
+ {
60
+ defaultValue: "light",
61
+ id: "axis_theme",
62
+ kind: "theme",
63
+ name: "Theme",
64
+ values: ["light", "dark"],
65
+ },
66
+ ],
67
+ firstJourney: "Add a task",
68
+ firstOutput: "Task list screen",
69
+ firstScenario: "Default",
70
+ firstScreen: "Home",
71
+ foundationChoice: "create-new",
72
+ initialComponents: ["Button"],
73
+ initialTokens: ["color.brand", "spacing.md", "radius.md"],
74
+ kindDetails: ["responsive"],
75
+ platforms: ["mobile"],
76
+ projectKind: "application",
77
+ projectName: "Acme",
78
+ purpose: "Track daily tasks",
79
+ sourceInputs: ["none"],
80
+ });
81
+
82
+ const TYPOGRAPHY_TOKEN = {
83
+ definition: {
84
+ $extensions: { smallpen: { id: "tok_type_heading" } },
85
+ $type: "typography",
86
+ $value: { fontFamily: "Inter", fontSize: 24, fontWeight: 700 },
87
+ },
88
+ filePath: "tokens/foundation.json",
89
+ path: "type.heading",
90
+ tokenId: "tok_type_heading",
91
+ type: "put-token",
92
+ };
93
+
94
+ // tokens -> component set -> screen nodes -> instance override -> render.
95
+ // Each step runs in a test against a fresh `smallpen init` workspace.
96
+ export const WORKED_EXAMPLE = Object.freeze([
97
+ {
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.",
115
+ },
116
+ {
117
+ title: "Add a title and a Button Instance to the Product Screen",
118
+ argv: ["page", PRODUCT, "--intent", "screen.json", "--json"],
119
+ files: {
120
+ "screen.json": {
121
+ nodes: [
122
+ {
123
+ ...OPS["add-presentation-node"].example.node,
124
+ tokenBindings: {
125
+ typography: { assetId: "tok_type_heading", packageId: "pkg_acme_foundation" },
126
+ },
127
+ },
128
+ INSTANCE_NODE_EXAMPLE,
129
+ ],
130
+ screenId: "scr_home",
131
+ },
132
+ },
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.",
134
+ },
135
+ {
136
+ title: "Resize the screen and override the Instance label",
137
+ argv: ["apply", PRODUCT, "--batch", "screen-edit.json", "--json"],
138
+ files: {
139
+ "screen-edit.json": batchFor(
140
+ [
141
+ OPS["update-presentation-node"].example,
142
+ OPS["update-presentation"].example,
143
+ OPS["set-instance-override"].example,
144
+ ],
145
+ "screen-edit-1",
146
+ ),
147
+ },
148
+ note: "The rendered size is the root node size; update-presentation only updates the declared viewport.",
149
+ },
150
+ {
151
+ title: "Render the dark theme and collect review evidence",
152
+ argv: ["render", PRODUCT, "--context", "axis_theme=dark", "--output", "home-dark.png", "--json"],
153
+ files: {},
154
+ note: "Then: smallpen evidence acme/acme.smallpen --output review --json",
155
+ },
156
+ ]);
157
+
158
+ function compact(value) {
159
+ return JSON.stringify(value);
160
+ }
161
+
162
+ export function workedExampleText() {
163
+ return WORKED_EXAMPLE.map((step, index) => {
164
+ const lines = [` ${index + 1}. ${step.title}`];
165
+ for (const [name, value] of Object.entries(step.files)) {
166
+ lines.push(` ${name}: ${compact(value)}`);
167
+ }
168
+ lines.push(` smallpen ${step.argv.join(" ")}`);
169
+ if (step.note) lines.push(` ${step.note}`);
170
+ return lines.join("\n");
171
+ }).join("\n");
172
+ }
173
+
174
+ function operationSummary(type) {
175
+ const schema = OPS[type];
176
+ return {
177
+ package: schema.package,
178
+ purpose: schema.purpose,
179
+ required: Object.entries(schema.fields)
180
+ .filter(([, spec]) => spec.required)
181
+ .map(([name]) => name),
182
+ ...(schema.oneOf ? { requiresOneOf: schema.oneOf } : {}),
183
+ type,
184
+ };
185
+ }
186
+
187
+ // put-* operations replace an existing object with the same id whole;
188
+ // init seeds a few, so say which.
189
+ const EXISTING_ID_NOTES = {
190
+ "put-component-set":
191
+ "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
+ "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.",
194
+ };
195
+
196
+ function existingIdNote(type, schema) {
197
+ if (EXISTING_ID_NOTES[type]) return EXISTING_ID_NOTES[type];
198
+ return type.startsWith("put-") && /replace/i.test(schema.purpose)
199
+ ? "An existing object with the same id is replaced whole (no merge)."
200
+ : undefined;
201
+ }
202
+
203
+ function operationDetail(type) {
204
+ const schema = OPS[type];
205
+ const existingId = existingIdNote(type, schema);
206
+ return {
207
+ ...(existingId ? { existingId } : {}),
208
+ fields: {
209
+ type: { description: "Operation type", required: true, type: "string", value: type },
210
+ ...schema.fields,
211
+ },
212
+ inverse: schema.inverse,
213
+ package: schema.package,
214
+ packageRole: PACKAGE_ROLES[schema.package] ?? "Either Package; it changes the Package you pass",
215
+ purpose: schema.purpose,
216
+ ...(schema.oneOf ? { requiresOneOf: schema.oneOf } : {}),
217
+ unknownFields: "rejected with unknown_operation_field",
218
+ example: batchFor([schema.example], `example-${type}`),
219
+ run: `smallpen apply <${schema.package === "foundation" ? "foundation" : "product"}.smallpen> --batch BATCH.json --dry-run --json`,
220
+ };
221
+ }
222
+
223
+ const NODE_FIELD_TYPES = {
224
+ appliedTokens: "object; local-name compatibility only, prefer tokenBindings",
225
+ backgroundBlur: "object or array of objects",
226
+ blur: "object or array of objects",
227
+ "blend-mode": "string",
228
+ constraints: "constraints-h / constraints-v: string or object",
229
+ content: "Penpot path content",
230
+ cornerRadius: "number or [topLeft, topRight, bottomRight, bottomLeft]",
231
+ fills: '[{type:"solid", color:"#rrggbb", opacity?}] (also linear-gradient, radial-gradient, image)',
232
+ flipX: "boolean",
233
+ flipY: "boolean",
234
+ grids: "array of objects",
235
+ 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)',
238
+ locked: "boolean",
239
+ mediaRef: "IMAGE only: media_... id or {packageId, assetId}",
240
+ name: "non-empty string",
241
+ opacity: "number from 0 to 1",
242
+ pathData: "PATH only: SVG path data",
243
+ points: "PATH only: array of points",
244
+ proportionLock: "boolean",
245
+ 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?}]',
248
+ text: "TEXT only: plain text (string)",
249
+ textBlocks: "TEXT only: [{runs:[{text, textStyle?, fills?}], textStyle?}] that join to text",
250
+ textStyle: "TEXT only: see textStyle below",
251
+ tokenBindings: "{field: {packageId, assetId:tok_...}}; see tokenBindings below",
252
+ touched: "array of Penpot touched groups",
253
+ visible: "boolean",
254
+ };
255
+
256
+ function tokenBindingTable() {
257
+ return Object.fromEntries(
258
+ FORMAT.canonicalPackage.tokenBindingFields.map((field) => [
259
+ field,
260
+ TOKEN_BINDING_FIELD_TYPES.get(field === "fills.N" ? "fill" : field) ?? [],
261
+ ]),
262
+ );
263
+ }
264
+
265
+ const NODE_EXAMPLE = OPS["add-presentation-node"].example.node;
266
+
267
+ function nodeTopic() {
268
+ return {
269
+ summary:
270
+ "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
+ required: {
272
+ children: "array of child node ids (ids only; add each child as its own node)",
273
+ height: "number",
274
+ id: "node_... unique in its Presentation (or variant)",
275
+ name: "non-empty string",
276
+ type: CANONICAL_SCHEMA_RULES.nodeTypes.join(" | "),
277
+ width: "number",
278
+ x: "number, relative to the parent",
279
+ y: "number, relative to the parent",
280
+ },
281
+ byType: nodeTypesTopic().types,
282
+ fields: Object.fromEntries(
283
+ CANONICAL_SCHEMA_RULES.nodeChangeFields.map((field) => [
284
+ field,
285
+ 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"
289
+ : "see validator"),
290
+ ]),
291
+ ),
292
+ textStyle: {
293
+ fields: CANONICAL_SCHEMA_RULES.textStyleFields,
294
+ numbers: "fontSize, fontWeight, lineHeight, letterSpacing are numbers",
295
+ values: CANONICAL_SCHEMA_RULES.textStyleEnums,
296
+ note: "Text alignment is textStyle.textAlign, not a node field.",
297
+ },
298
+ tokenBindings: {
299
+ shape: '{"fill": {"packageId":"pkg_acme_foundation","assetId":"tok_color_brand"}}',
300
+ fieldTokenTypes: tokenBindingTable(),
301
+ 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.",
305
+ },
306
+ 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.",
308
+ example: NODE_EXAMPLE,
309
+ };
310
+ }
311
+
312
+ function nodeTypesTopic() {
313
+ return {
314
+ types: {
315
+ COMPONENT: "Root of a Component variant, or a located Component main node",
316
+ ELLIPSE: "Ellipse inside x/y/width/height",
317
+ FRAME: "Container; fills, strokes, cornerRadius, layout",
318
+ GROUP: "Plain group of children",
319
+ IMAGE: "Requires mediaRef (import with smallpen import-media)",
320
+ INSTANCE: "Requires instance; see: smallpen schema instance",
321
+ PATH: "pathData or points; strokes with capStart/capEnd for arrows",
322
+ RECTANGLE: "Rectangle; fills, strokes, cornerRadius",
323
+ TEXT: "Requires text; textStyle, textBlocks, growType",
324
+ },
325
+ presentationTypes: CANONICAL_SCHEMA_RULES.nodeTypes,
326
+ variantTypes: "COMPONENT, COMPONENT_SET, FRAME, IMAGE, INSTANCE, RECTANGLE, TEXT",
327
+ };
328
+ }
329
+
330
+ function tokenTopic() {
331
+ return {
332
+ 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
+ },
345
+ },
346
+ operations: {
347
+ create: "put-token {filePath, path, tokenId, definition}",
348
+ changeValue: "set-token-value {tokenId, value}",
349
+ 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)",
351
+ },
352
+ 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,
357
+ typographyExample: TYPOGRAPHY_TOKEN,
358
+ intent: { operations: [OPS["put-token"].example] },
359
+ };
360
+ }
361
+
362
+ function tokenTypesTopic() {
363
+ return {
364
+ types: Object.fromEntries(
365
+ FORMAT.canonicalPackage.tokenTypes.map((type) => [type, TOKEN_VALUE_SHAPES[type]]),
366
+ ),
367
+ alias: 'Any type also accepts "{path.of.another.token}" of the same type.',
368
+ };
369
+ }
370
+
371
+ function componentSetTopic() {
372
+ return {
373
+ summary:
374
+ "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.",
375
+ fields: {
376
+ id: "cmp_...",
377
+ name: "non-empty string",
378
+ axes: "[{id:axis_..., name, role, domain?}]",
379
+ variants: "[{id:var_..., selection:{axisId: value}, rootId, nodes:{id: node}}]",
380
+ visibility: "public | private (default public)",
381
+ category: "optional string",
382
+ description: "optional string",
383
+ deprecated: "optional boolean",
384
+ replacement: "optional {packageId, assetId:cmp_...}",
385
+ },
386
+ axisRoles: {
387
+ values: AXIS_ROLES,
388
+ configuration:
389
+ "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.",
391
+ modelling:
392
+ "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
+ },
394
+ variantNodes:
395
+ "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
+ operations: {
397
+ create: "put-component-set (whole set) or put-variant (one variant)",
398
+ edit: "update-component-node {componentSetId, variantId, nodeId, changes}",
399
+ remove: "delete-variant, delete-component-set",
400
+ },
401
+ package: "foundation",
402
+ example: OPS["put-component-set"].example.componentSet,
403
+ };
404
+ }
405
+
406
+ function instanceTopic() {
407
+ return {
408
+ summary:
409
+ "An INSTANCE node places a Component Set variant. It references the component by Package and id, and may override fields of nodes inside it.",
410
+ node: {
411
+ type: "INSTANCE",
412
+ instance: {
413
+ component: "{packageId: owning Package (pkg_acme_foundation), assetId: cmp_...}",
414
+ variant: "{axisId: value} for every Axis of the Component Set",
415
+ overrides: '{"<sourceNodeId>:<field>": value}; prefer set-instance-override',
416
+ },
417
+ size: "x/y/width/height of the Instance itself",
418
+ },
419
+ overrides: {
420
+ path: "<sourceNodeId>:<field>, or <nestedInstanceId>__<sourceNodeId>:<field> inside a nested Instance",
421
+ fields: [...INSTANCE_OVERRIDE_FIELDS],
422
+ operations: "set-instance-override, clear-instance-override, select-instance-variant",
423
+ },
424
+ 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",
427
+ example: {
428
+ node: INSTANCE_NODE_EXAMPLE,
429
+ parentId: "node_home_root",
430
+ presentationId: "pres_home_mobile",
431
+ screenId: "scr_home",
432
+ type: "add-presentation-node",
433
+ },
434
+ };
435
+ }
436
+
437
+ function presentationTopic() {
438
+ return {
439
+ summary:
440
+ "A Presentation is one layout of a Screen (mobile, desktop, ...). Its rendered size is the bounds of its root node(s), not viewport.",
441
+ fields: {
442
+ id: "pres_...",
443
+ name: "non-empty string",
444
+ platform: "optional string",
445
+ viewport: "{width, height}: the declared device size (metadata; render ignores it)",
446
+ nodes: "{node_id: node}",
447
+ rootId: "root node id (or rootIds for several roots)",
448
+ interactions: "[] (see put-interaction)",
449
+ background: "optional color string",
450
+ prototypeFlows: "optional array",
451
+ },
452
+ resize:
453
+ "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.",
454
+ updatePresentationFields: CANONICAL_SCHEMA_RULES.presentationChangeFields,
455
+ example: OPS["add-presentation"].example.presentation,
456
+ };
457
+ }
458
+
459
+ function screenTopic() {
460
+ return {
461
+ summary:
462
+ "A Screen holds one or more Presentations; basePresentationId is the default. Screens live in the Product.",
463
+ fields: {
464
+ id: "scr_...",
465
+ name: "non-empty string",
466
+ basePresentationId: "pres_... of one of its Presentations",
467
+ presentations: "[Presentation]; see: smallpen schema presentation",
468
+ counterparts: "[] (links between Presentations)",
469
+ },
470
+ defaultScreen: "set-default-screen chooses the Screen reads and renders use without --screen",
471
+ example: OPS["put-screen"].example.screen,
472
+ };
473
+ }
474
+
475
+ function scenarioTopic() {
476
+ return {
477
+ summary:
478
+ "A Scenario is a reproducible design state: a target Screen/Presentation, a Context, and declarative actions. Select it with --scenario.",
479
+ fields: {
480
+ id: "scn_...",
481
+ name: "non-empty string",
482
+ target: '{kind:"screen", screen:{packageId, assetId:scr_...}, presentationId}',
483
+ context: "{axisId: value} for the Context Axes",
484
+ actions: "[] or declarative actions (set-override, ...)",
485
+ expectedVisibleNodeIds: "[node ids]",
486
+ fixture: "{key: scalar}",
487
+ viewport: "{width, height, scale}: declared size (metadata; render uses the root node size)",
488
+ },
489
+ update: "put-scenario with the same id replaces the Scenario, for example to keep its viewport in step with the root node.",
490
+ example: OPS["put-scenario"].example.scenario,
491
+ };
492
+ }
493
+
494
+ function contextTopic() {
495
+ return {
496
+ 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.",
498
+ axis: {
499
+ id: "axis_...",
500
+ name: "non-empty string",
501
+ kind: CONTEXT_AXIS_KINDS.join(" | "),
502
+ values: "[{id, name}]",
503
+ defaultValue: "one value id",
504
+ },
505
+ profile: "{id:ctx_..., name, values:{axisId: value}, default?: boolean}",
506
+ 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}]",
509
+ example: OPS["put-context-file"].example.contextFile,
510
+ };
511
+ }
512
+
513
+ function batchTopic() {
514
+ return {
515
+ summary:
516
+ "Every write is one atomic Operation Batch. Nothing is written unless every operation is valid.",
517
+ batch: {
518
+ baseRevision: "package.revision from smallpen inspect PACKAGE --json; a stale value is rejected",
519
+ batchId: "1-192 characters [A-Za-z0-9_.-], new for every write",
520
+ operations: "array; see: smallpen schema operations",
521
+ },
522
+ packages: PACKAGE_ROLES,
523
+ 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",
526
+ 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
+ flow: "smallpen flow PRODUCT --intent: same as page, for flowchart nodes",
528
+ },
529
+ 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.",
531
+ dryRun: "--dry-run validates and returns the result without writing",
532
+ workedExample: WORKED_EXAMPLE,
533
+ };
534
+ }
535
+
536
+ function initTopic() {
537
+ return {
538
+ 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,
544
+ };
545
+ }
546
+
547
+ 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" },
552
+ instance: { build: instanceTopic, summary: "INSTANCE nodes and overrides" },
553
+ 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)" },
556
+ operations: {
557
+ build: () => ({
558
+ operations: OPERATION_TYPES.map(operationSummary),
559
+ next: "smallpen schema operation TYPE",
560
+ }),
561
+ summary: "Every operation type with its purpose and required fields",
562
+ },
563
+ presentation: { build: presentationTopic, summary: "Presentation fields, size, and resizing" },
564
+ scenario: { build: scenarioTopic, summary: "Scenario fields and viewport" },
565
+ 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" },
568
+ };
569
+ const TOPIC_ALIASES = { theme: "context" };
570
+
571
+ export const SCHEMA_TOPICS = Object.freeze(Object.keys(TOPICS));
572
+
573
+ export function schemaTopic(topicName, argument) {
574
+ if (topicName === undefined) {
575
+ return {
576
+ topics: Object.fromEntries(
577
+ Object.entries(TOPICS).map(([name, { summary }]) => [name, summary]),
578
+ ),
579
+ usage: "smallpen schema TOPIC [--json]; smallpen schema operation TYPE",
580
+ };
581
+ }
582
+ if (Object.hasOwn(OPS, topicName) && argument === undefined) {
583
+ return schemaTopic("operation", topicName);
584
+ }
585
+ const name = TOPIC_ALIASES[topicName] ?? topicName;
586
+ if (name === "operation") {
587
+ if (!Object.hasOwn(OPS, argument ?? "")) {
588
+ const suggestion = suggestField(argument, OPERATION_TYPES);
589
+ throw new SmallPenError(
590
+ "unknown_schema_operation",
591
+ `${argument === undefined ? "smallpen schema operation requires TYPE" : `Unknown operation type: ${argument}`}.` +
592
+ `${suggestion ? ` Did you mean ${suggestion}?` : ""} Types: ${OPERATION_TYPES.join(", ")}`,
593
+ { validTypes: [...OPERATION_TYPES], ...(suggestion ? { suggestion } : {}) },
594
+ );
595
+ }
596
+ return { topic: "operation", type: argument, ...operationDetail(argument) };
597
+ }
598
+ if (!Object.hasOwn(TOPICS, name)) {
599
+ const suggestion = suggestField(name, Object.keys(TOPICS));
600
+ throw new SmallPenError(
601
+ "unknown_schema_topic",
602
+ `Unknown schema topic: ${topicName}.${suggestion ? ` Did you mean ${suggestion}?` : ""} ` +
603
+ `Topics: ${Object.keys(TOPICS).join(", ")}`,
604
+ { validTopics: Object.keys(TOPICS), ...(suggestion ? { suggestion } : {}) },
605
+ );
606
+ }
607
+ if (argument !== undefined) {
608
+ throw new SmallPenError(
609
+ "unexpected_argument",
610
+ `smallpen schema ${name} takes no further argument: ${argument}`,
611
+ { argument },
612
+ );
613
+ }
614
+ return { topic: name, ...TOPICS[name].build() };
615
+ }