@smallpen/cli 0.1.0-alpha.15 → 0.1.0-alpha.18

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.
@@ -3,6 +3,7 @@ import {
3
3
  asciiWireframe,
4
4
  createSemanticTree,
5
5
  defaultTokenThemeIds,
6
+ elementPath,
6
7
  listTokenThemes,
7
8
  layoutProjectionDiagnostics,
8
9
  projectComponentVariant,
@@ -13,7 +14,7 @@ import {
13
14
  SmallPenError,
14
15
  tokenLibraryOf,
15
16
  } from "@smallpen/core";
16
- import { inspectProjection } from "@smallpen/local-package";
17
+ import { inspectProjection, layoutSizingRules } from "@smallpen/local-package";
17
18
 
18
19
  export async function resolveDesignLayout(workspace, design, options = {}) {
19
20
  if (design.layoutSnapshot && (!options.raster || design.render?.bytes?.length)) return design;
@@ -642,6 +643,52 @@ export function platformHints(workspace, design) {
642
643
  return hints;
643
644
  }
644
645
 
646
+ const SIZING = { width: "layout-item-h-sizing", height: "layout-item-v-sizing" };
647
+ const LIMITS = { minWidth: "layout-item-min-w", maxWidth: "layout-item-max-w", minHeight: "layout-item-min-h", maxHeight: "layout-item-max-h" };
648
+ const layoutContainer = (node) => node?.layout === "flex" || node?.layout === "grid";
649
+
650
+ // Stored sizes the App cannot set: hug on an element without a row or
651
+ // column layout, fill without a layout parent, min / max on an axis that
652
+ // does not fill, and a container that hugs what a child fills (the App's
653
+ // own rule, fixSizing, would make it fixed). Hidden elements and elements
654
+ // inside copies count too: a copy may show what its component hides, and
655
+ // repeats inside copies are reported once, against the component.
656
+ export function appLayoutIssues(nodes, fixSizing) {
657
+ const issues = [];
658
+ const own = Object.keys(nodes);
659
+ const parentOf = new Map();
660
+ for (const [id, node] of Object.entries(nodes)) for (const child of node.children ?? []) parentOf.set(child, id);
661
+ const add = (nodeId, message, detail) => issues.push({ code: "layout_not_in_app", severity: "error", check: "app-layout", nodeId, message, ...detail });
662
+ for (const id of own) {
663
+ const node = nodes[id], parent = nodes[parentOf.get(id)];
664
+ for (const [axis, field] of Object.entries(SIZING)) {
665
+ if (node[field] === "auto" && !layoutContainer(node))
666
+ add(id, node.type === "TEXT"
667
+ ? `${node.name}: a text cannot hug its ${axis} in the App; it hugs by ${axis === "width" ? "auto width" : "auto height"}. Run project repair --action text-sizing to store every such text the App's way (nothing moves)`
668
+ : `${node.name} hugs its ${axis} without a row or column layout; the App offers hug only to such containers. Give it a ${axis} or a layout`, { field: axis });
669
+ if (node[field] === "fill" && parent && !layoutContainer(parent))
670
+ add(id, `${node.name} fills its ${axis} but ${parent.name} has no row or column layout; the App offers fill only inside one. Give it a ${axis} or give ${parent.name} a layout`, { field: axis });
671
+ }
672
+ for (const [name, field] of Object.entries(LIMITS)) {
673
+ const axis = name.endsWith("Width") ? "width" : "height";
674
+ if (node[field] !== undefined && node[field] !== null && node[SIZING[axis]] !== "fill")
675
+ add(id, `${node.name} has ${name} ${node[field]} but its ${axis} does not fill; the App sets min and max sizes only on an axis that fills. Set ${axis} to fill or ${name}=null`, { field: name });
676
+ }
677
+ }
678
+ const containers = own.filter((id) => nodes[id].layout === "flex");
679
+ const changes = containers.length ? fixSizing({ nodes, parents: containers, children: [] }) : {};
680
+ for (const [id, fields] of Object.entries(changes)) {
681
+ if (!own.includes(id)) continue;
682
+ for (const [field, axis] of [["layout-item-h-sizing", "width"], ["layout-item-v-sizing", "height"]]) {
683
+ if (!fields[field]) continue;
684
+ const fillers = (nodes[id].children ?? []).filter((child) => nodes[child]?.[field] === "fill");
685
+ const filling = fillers.map((child) => nodes[child].name);
686
+ add(id, `${nodes[id].name} hugs its ${axis} while ${filling.join(", ")} fill${filling.length === 1 ? "s" : ""} it; the App makes such a container fixed, and the filling element shrinks to nothing when drawn. Give ${nodes[id].name} a ${axis}, or let ${filling.join(", ")} hug`, { field: axis });
687
+ }
688
+ }
689
+ return issues;
690
+ }
691
+
645
692
  export async function validateDesign(workspace, design) {
646
693
  design = await resolveDesignLayout(workspace, design);
647
694
  if (design.layoutSnapshot.status !== "resolved") {
@@ -714,6 +761,19 @@ export async function validateDesign(workspace, design) {
714
761
  measured: { width: item.width, height: item.height },
715
762
  box: { width: node.width, height: node.height },
716
763
  });
764
+ } else if (
765
+ // A wrapping box narrower than one word splits the word itself.
766
+ item.growType !== "auto-width" && item.longestPiece &&
767
+ item.longestPiece.width > node.width + Math.max(1, node.width * 0.02)
768
+ ) {
769
+ issues.push({
770
+ code: "text_word_split",
771
+ severity: "warning",
772
+ nodeId: node.id,
773
+ message: `"${item.longestPiece.text}" is ${Math.ceil(item.longestPiece.width)}px wide but its box is ${Math.round(node.width)}px, so the word breaks across lines; give the text a width or min width, or let it hug its content (width=hug)`,
774
+ measured: { width: item.longestPiece.width, text: item.longestPiece.text },
775
+ box: { width: node.width, height: node.height },
776
+ });
717
777
  }
718
778
  }
719
779
  for (const diagnostic of metrics.diagnostics)
@@ -880,8 +940,11 @@ export async function validateDesign(workspace, design) {
880
940
  for (const child of children) walk(child, [...ancestors, node], semantic);
881
941
  };
882
942
  walk(design.tree.root, design.ancestors);
943
+ issues.push(...appLayoutIssues(design.authoredProjection?.nodes ?? nodes, await layoutSizingRules()));
883
944
  for (const issue of issues) {
884
945
  if (issue.nodeId && places.has(issue.nodeId)) issue.where = where(issue.nodeId);
946
+ // A hidden element is not drawn, so it is named by its path.
947
+ else if (issue.nodeId && nodes[issue.nodeId]) issue.where = elementPath(nodes, design.projection.rootId, issue.nodeId).replaceAll(" / ", " › ");
885
948
  if (issue.otherNodeId && places.has(issue.otherNodeId)) issue.otherWhere = where(issue.otherNodeId);
886
949
  const text = nodes[issue.nodeId]?.type === "TEXT" ? nodes[issue.nodeId].text : undefined;
887
950
  if (text) issue.text = text.length > 40 ? `${text.slice(0, 39)}…` : text;
@@ -902,7 +965,7 @@ export async function validateDesign(workspace, design) {
902
965
  continue;
903
966
  }
904
967
  // Copies differ in their text overrides, not in the definition at fault.
905
- const key = [issue.code, segments.at(-1), issue.ratio ?? ""].join("\0");
968
+ const key = [issue.code, segments.at(-1), issue.ratio ?? "", issue.field ?? ""].join("\0");
906
969
  const first = byDefinition.get(key);
907
970
  if (!first) {
908
971
  const entry = { ...issue, occurrences: 1 };
package/bin/help.mjs CHANGED
@@ -325,9 +325,12 @@ Output:
325
325
  smallpen asset font import <package.smallpen> --file FONT_FILE --family NAME
326
326
  [--weight 400] [--style normal] [--name NAME] [--json]
327
327
 
328
- Public Font entry for an existing Product. TTF and OTF are validated by SFNT
329
- signature and converted to WOFF (the renderer loads ttf/otf/woff). WOFF imports
330
- as-is after table validation. A valid WOFF2 reaches an explicit
328
+ Public Font entry for an existing Product. TTF, OTF and WOFF are validated
329
+ and stored as one original file per weight/style. App and CLI share native
330
+ font loading; browser WOFF and native SFNT are derived on demand in a bounded
331
+ system cache outside the Package. Reading or rendering does not change the
332
+ Package revision or add derived blobs. Existing multi-format fonts still open.
333
+ A valid WOFF2 reaches an explicit
331
334
  unsupported_font_conversion boundary (no WOFF2 decoder is bundled); corrupt
332
335
  fonts fail invalid_font_blob. Either way nothing is written. Success returns
333
336
  the family, files, revision, batchId, and the exact inverseBatch. Use the
@@ -429,7 +432,7 @@ Purpose:
429
432
 
430
433
  Batch contract:
431
434
  {"baseRevision":"...","batchId":"unique-id","operations":[...]}
432
- baseRevision is package.revision from smallpen inspect PACKAGE --json.
435
+ baseRevision is the revision from the last write reply or smallpen inspect PACKAGE --json.
433
436
  The whole candidate validates and commits atomically. Success returns revision,
434
437
  affected stable IDs/files, a revision-guarded reverse edit, and changed
435
438
  (false, with noChange, when the batch leaves the Package as it was).
@@ -553,6 +556,8 @@ Actions:
553
556
  remove-dependent-usage no additional arguments
554
557
  choose-foundation --foundation PATH
555
558
  recreate-product-asset --asset-kind token|component --asset FILE
559
+ text-sizing no conflicts needed: store texts written as hug the
560
+ App's way (fixed, sized by their grow type); nothing moves
556
561
 
557
562
  Without --action, returns all typed conflicts and choices. Each action is checked
558
563
  against the selected conflict and committed through one atomic Operation Batch.
@@ -153,21 +153,24 @@ export function inspectionReport(
153
153
  const copies = [...ancestry(node), raw].filter(
154
154
  (candidate) => (stored[candidate.id] ?? candidate).instance,
155
155
  );
156
- const overrides = copies.flatMap((copy) =>
157
- Object.entries((stored[copy.id] ?? copy).instance?.overrides ?? {})
158
- .filter(
159
- ([key]) =>
160
- copy.id === node.id ||
161
- key.slice(0, key.lastIndexOf(":")) === raw.sourceNodeId,
162
- )
163
- .map(([key, value]) => ({
164
- element:
165
- variant?.nodes?.[key.slice(0, key.lastIndexOf(":"))]?.name ??
166
- "component element",
156
+ const overrides = [];
157
+ const fieldOverrides = [];
158
+ for (const copy of copies) {
159
+ for (const [key, value] of Object.entries((stored[copy.id] ?? copy).instance?.overrides ?? {})) {
160
+ const path = key.slice(0, key.lastIndexOf(":"));
161
+ const target = path === copy.sourceNodeId ? copy.id : `${copy.id}__${path}`;
162
+ const item = {
163
+ element: target === raw.id ? source?.name ?? "component element"
164
+ : variant?.nodes?.[path]?.name ?? projection.nodes[target]?.name ?? "component element",
167
165
  field: key.slice(key.lastIndexOf(":") + 1),
168
166
  value,
169
- })),
170
- );
167
+ };
168
+ // The instance summary includes edits inside it. A field's source
169
+ // depends only on overrides that target that exact projected node.
170
+ if (copy.id === raw.id || target === raw.id) overrides.push(item);
171
+ if (target === raw.id) fieldOverrides.push(item);
172
+ }
173
+ }
171
174
  const placed = stored[raw.id];
172
175
  if (placed?.instance && source) {
173
176
  for (const name of [
@@ -177,16 +180,25 @@ export function inspectionReport(
177
180
  "visible",
178
181
  "textStyle",
179
182
  "tokenBindings",
183
+ "layout-gap",
184
+ "layout-padding",
185
+ "layout-item-margin",
186
+ "cornerRadius",
187
+ "shadow",
180
188
  ])
181
189
  if (
182
190
  Object.hasOwn(placed, name) &&
191
+ (name !== "tokenBindings" || Object.keys(placed[name] ?? {}).length > 0) &&
183
192
  JSON.stringify(placed[name]) !== JSON.stringify(source[name])
184
- )
185
- overrides.push({
193
+ ) {
194
+ const item = {
186
195
  element: source.name,
187
196
  field: name,
188
197
  value: placed[name],
189
- });
198
+ };
199
+ overrides.push(item);
200
+ fieldOverrides.push(item);
201
+ }
190
202
  }
191
203
  return {
192
204
  name: set?.name ?? "unknown component",
@@ -196,6 +208,7 @@ export function inspectionReport(
196
208
  .filter((axis) => selection[axis.id] !== undefined)
197
209
  .map((axis) => `${axis.name}=${selection[axis.id]}`),
198
210
  overrides,
211
+ fieldOverrides,
199
212
  // A located App copy may lack its full source chain. Absence of
200
213
  // metadata is not proof that no overrides exist.
201
214
  overridesKnown:
@@ -293,12 +306,16 @@ export function inspectionReport(
293
306
  };
294
307
  }
295
308
  }
296
- const overridden = component?.overrides.some(
309
+ const overridden = (component?.fieldOverrides ?? component?.overrides)?.some(
297
310
  (item) =>
298
311
  item.element === component.element &&
299
312
  (keys.includes(item.field) ||
300
313
  (name === "fill" && item.field === "fills") ||
301
314
  (name === "stroke" && item.field === "strokes") ||
315
+ (name === "gap" && item.field === "layout-gap") ||
316
+ (name.startsWith("padding") && item.field === "layout-padding") ||
317
+ (name.startsWith("margin") && item.field === "layout-item-margin") ||
318
+ (name.startsWith("radius") && item.field === "cornerRadius") ||
302
319
  (item.field === "textStyle" &&
303
320
  Object.hasOwn(item.value ?? {}, name)) ||
304
321
  (item.field === "tokenBindings" &&
@@ -357,6 +374,7 @@ export function inspectionReport(
357
374
  const nodes = layers.map(({ node, depth, index }) => {
358
375
  const raw = projection.nodes[node.id];
359
376
  const component = componentInfo(node);
377
+ const { fieldOverrides: _fieldOverrides, ...componentSource } = component ?? {};
360
378
  const actual = snapshot.nodes[node.id];
361
379
  const parent = parents.get(node.id);
362
380
  const parentBounds =
@@ -406,7 +424,7 @@ export function inspectionReport(
406
424
  add(
407
425
  "gap",
408
426
  raw["layout-gap"]?.[column ? "row-gap" : "column-gap"],
409
- ["itemSpacing", column ? "rowGap" : "columnGap"],
427
+ [column ? "rowGap" : "columnGap", "itemSpacing"],
410
428
  0,
411
429
  );
412
430
  for (const [side, key] of [
@@ -557,7 +575,7 @@ export function inspectionReport(
557
575
  ...(layout ? { layout } : {}),
558
576
  fields,
559
577
  source: component
560
- ? { kind: "component", ...component }
578
+ ? { kind: "component", ...componentSource }
561
579
  : { kind: "page" },
562
580
  visibility: {
563
581
  value: node.visible,
package/bin/schema.mjs CHANGED
@@ -39,7 +39,7 @@ const FORMAT = SMALLPEN_FORMAT_CAPABILITIES;
39
39
  const OPERATION_TYPES = FORMAT.canonicalWrite.operationTypes;
40
40
  // The default `smallpen init` layout: one self-contained Package.
41
41
  const PACKAGE = "acme/acme.smallpen";
42
- const REVISION = "<package.revision from smallpen inspect PACKAGE --json>";
42
+ const REVISION = "<revision from the last write reply or smallpen project show PACKAGE --json>";
43
43
 
44
44
  export const PACKAGE_ROLES = Object.freeze({
45
45
  foundation:
@@ -157,7 +157,7 @@ export const WORKED_EXAMPLE = Object.freeze([
157
157
  "canvas-v1",
158
158
  ),
159
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.",
160
+ note: "Copy the revision from the previous reply (or project show PACKAGE --json) into baseRevision before applying the batch. Existing IDs replace whole definitions; use update for partial edits.",
161
161
  },
162
162
  {
163
163
  title: "Add text to that canvas",
@@ -272,7 +272,7 @@ function operationDetail(type) {
272
272
  ...(schema.oneOf ? { requiresOneOf: schema.oneOf } : {}),
273
273
  unknownFields: "rejected with unknown_operation_field",
274
274
  example: batchFor([schema.example], `example-${type}`),
275
- run: `smallpen apply <${schema.package === "foundation" ? "foundation" : "product"}.smallpen> --batch BATCH.json --dry-run --json`,
275
+ run: `smallpen advanced apply <${schema.package === "foundation" ? "foundation" : "product"}.smallpen> --batch BATCH.json --dry-run --json`,
276
276
  };
277
277
  }
278
278
 
@@ -720,18 +720,16 @@ function batchTopic() {
720
720
  "Every write is one atomic Operation Batch. Nothing is written unless every operation is valid.",
721
721
  batch: {
722
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",
723
+ "revision from the last write reply or smallpen project show PACKAGE --json; a stale value is rejected",
724
+ batchId: "1-192 characters [A-Za-z0-9_.-], new for every write. Batch IDs are remembered per package path: a fresh copy put at the same path keeps that path's IDs, so rehearse in a copy at a new path to reuse them on the real package",
725
725
  operations: "array; see: smallpen schema operations",
726
726
  },
727
727
  packages: PACKAGE_ROLES,
728
728
  commands: {
729
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",
730
+ "smallpen advanced apply PACKAGE --batch BATCH.json [--dry-run] [--explain] [--diff]",
731
+ named:
732
+ "page, component, token, theme, flow, asset and canvas commands build the batch from names, with baseRevision and batchId filled in",
735
733
  },
736
734
  result:
737
735
  "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.",
@@ -779,7 +777,11 @@ function setTopic(command) {
779
777
  notes: [
780
778
  "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
779
  "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.",
780
+ "A value written over a Token binding replaces the binding, on an element and on a copy (visible, opacity) alike.",
781
+ "fill=none or stroke=none removes the paint; a color is \"#RRGGBB\", \"#RRGGBBAA\" (the last two digits are its opacity), rgb() or a CSS color name.",
782
+ "--variant names some or all properties (\"State=default\"); every variant it names is changed, and several need --all like an element in several variants.",
783
+ "Sizes follow the App: hug only on a row/column container or a text, min and max sizes only on an axis that fills; a container that hugs what a child fills becomes fixed, as the App makes it. Anything else is refused with what to write instead, and validate reports stored sizes the App cannot set (layout_not_in_app).",
784
+ "An element path lists parents in order (\"Content / Heading\"); when several elements match, the one directly inside those parents wins.",
783
785
  ],
784
786
  };
785
787
  }
@@ -805,10 +807,10 @@ const TOPICS = {
805
807
  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
808
  "gap / padding": "number or {token}; padding takes 1, 2 or 4 values",
807
809
  "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',
810
+ "width / height": 'number, "fill" (or "100%") or "hug", as the App offers them: fill only inside a row/column parent; hug only on a row/column container, a copy of one, or a text (stored as the App\'s auto width / auto height; a text that hugs its width also hugs its height). Texts hug by default; the only text in a row with a set width fills it',
809
811
  "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
812
  margin: "number or {token}; 1, 2 or 4 values, like padding",
811
- "minWidth / maxWidth / minHeight / maxHeight": "number or {token}",
813
+ "minWidth / maxWidth / minHeight / maxHeight": "number or {token}; only on an axis that is fill, as in the App (invalid_size_limit otherwise)",
812
814
  "letterSpacing / lineHeight / textTransform / textDecoration": "text only; value or {token} (textTransform: uppercase|lowercase|capitalize|none, textDecoration: underline|line-through|none)",
813
815
  rotation: "degrees or {token}",
814
816
  visible: "false hides it (for example per platform in a variant), or {flag} for a boolean Token",
@@ -856,7 +858,7 @@ const TOPICS = {
856
858
  ],
857
859
  },
858
860
  notes: [
859
- "Drawing a page again redraws it; elements drawn again under the same names keep their ids and interactions.",
861
+ "Drawing a page again redraws it; elements drawn again under the same names keep their ids and interactions. A copy of the same component drawn again also keeps a Token-bound or fill width and height unless the element gives width or height (\"hug\" lets it follow its content again).",
860
862
  "An instance whose new text is longer than its slot grows by the measured difference.",
861
863
  "Link elements with flow link --from \"Page / Element\" --to Page.",
862
864
  "Move an element later with page move --page Page --element Name --direction up|down|left|right [--steps N]; the CLI picks the coordinates.",
package/bin/smallpen.mjs CHANGED
@@ -25,6 +25,7 @@ import {
25
25
  flowStartIntent,
26
26
  pageDrawOperation,
27
27
  componentSetOperation,
28
+ textSizingRepairOperations,
28
29
  pageSetOperation,
29
30
  themeAddIntents,
30
31
  themeDefaultIntent,
@@ -115,10 +116,10 @@ import {
115
116
  openPackage,
116
117
  readBatchHistory,
117
118
  openRemoteLibrary,
119
+ layoutSizingRules,
118
120
  measureProjectionText,
119
121
  replayRecordedBatch,
120
122
  resolveWorkspace,
121
- sfntToWoff,
122
123
  } from "@smallpen/local-package";
123
124
 
124
125
  import { COMMAND_NAMES, commandGuidance, printHelp } from "./help.mjs";
@@ -613,6 +614,8 @@ function printJson(value) {
613
614
  omitted: true,
614
615
  bytes: Buffer.byteLength(json) + 1,
615
616
  limitBytes,
617
+ // Callers that always want one path can ask for the whole reply.
618
+ inline: "--stdout prints the whole result instead",
616
619
  };
617
620
  json = encode(brief);
618
621
  if (brief.matrixPath && Buffer.byteLength(json) + 1 > limitBytes) {
@@ -2419,13 +2422,45 @@ function repairChoiceCommand(locator, productPackageId, conflictIndex, choice) {
2419
2422
  return { argv: base };
2420
2423
  }
2421
2424
 
2425
+ // Stored settings the App cannot make, rewritten the App's way without a
2426
+ // visible change: texts stored as hug (text-sizing).
2427
+ const STORAGE_REPAIRS = ["text-sizing"];
2428
+
2422
2429
  async function repairCommand(packagePath, args) {
2423
2430
  const resolution = await resolveCliWorkspace(packagePath);
2431
+ const action = option(args, "--action");
2432
+ if (STORAGE_REPAIRS.includes(action) && resolution.status !== "ready")
2433
+ throw new SmallPenError("repair_conflicts_first", `Resolve the package's conflicts before ${action}; run project repair without --action to list them`);
2424
2434
  if (resolution.status === "ready") {
2435
+ const product = resolution.workspace.product;
2436
+ const textSizing = textSizingRepairOperations(product);
2437
+ if (action === "text-sizing") {
2438
+ if (!textSizing.count) return { action, changed: false, count: 0, revision: product.revision, status: "ready" };
2439
+ const result = await trackedWrite(() =>
2440
+ applyOperationBatch(product.locator, {
2441
+ baseRevision: product.revision,
2442
+ batchId: option(args, "--batch-id") ?? `repair_${randomUUID()}`,
2443
+ operations: textSizing.operations,
2444
+ }),
2445
+ );
2446
+ return {
2447
+ action, count: textSizing.count, places: textSizing.places.length,
2448
+ done: `${textSizing.count} text sizes in ${textSizing.places.length} pages and variants stored the App's way (hug is a text's grow type); the design looks the same`,
2449
+ batchId: result.batchId, revision: result.revision, changed: result.changed,
2450
+ inverseBatchPath: result.inverseBatchPath, status: "ready",
2451
+ };
2452
+ }
2453
+ if (action !== undefined)
2454
+ throw new SmallPenError("unknown_repair_action", `The package has no conflicts; the repairs it can take are ${STORAGE_REPAIRS.join(", ")}`, { actions: STORAGE_REPAIRS });
2425
2455
  return {
2426
- packageId: resolution.workspace.product.manifest.packageId,
2427
- revision: resolution.workspace.product.revision,
2456
+ packageId: product.manifest.packageId,
2457
+ revision: product.revision,
2428
2458
  status: "ready",
2459
+ ...(textSizing.count ? { repairs: [{
2460
+ action: "text-sizing", count: textSizing.count, places: textSizing.places.length,
2461
+ message: `${textSizing.count} text sizes are stored as hug, which the App does not offer for a text; this stores them the App's way without a visible change`,
2462
+ command: ["project", "repair", resolve(packagePath), "--action", "text-sizing", "--json"],
2463
+ }] } : {}),
2429
2464
  };
2430
2465
  }
2431
2466
  if (!option(args, "--action")) {
@@ -2451,7 +2486,6 @@ async function repairCommand(packagePath, args) {
2451
2486
  };
2452
2487
  }
2453
2488
  const { index, selected } = selectedRepairConflict(resolution, args);
2454
- const action = option(args, "--action");
2455
2489
  const operations = await repairOperations(resolution, selected, action, args);
2456
2490
  const result = await trackedWrite(() =>
2457
2491
  applyOperationBatch(resolution.product.locator, {
@@ -3190,6 +3224,11 @@ async function rebuildBefore(snapshot, history, cut) {
3190
3224
  let complete = true;
3191
3225
  for (const entry of [...newer].reverse()) {
3192
3226
  if (!entry.inverseBatch) {
3227
+ if (entry.history?.status === "partial")
3228
+ throw new SmallPenError("batch_history_unavailable", "The design was saved, but this earlier revision cannot be reconstructed from the bounded history cache", {
3229
+ batchId: entry.batchId, revision: snapshot.revision, history: entry.history,
3230
+ nextOperations: [{ argv: ["view", snapshot.locator, "--as", "spec", "--json"], operation: "smallpen.view" }],
3231
+ });
3193
3232
  complete = false;
3194
3233
  break;
3195
3234
  }
@@ -3243,6 +3282,7 @@ async function changesCommand(packagePath, args) {
3243
3282
  packageId: snapshot.manifest.packageId,
3244
3283
  ...BATCH_HISTORY_LIMITS,
3245
3284
  retainedBatches: histories[index].length,
3285
+ incompleteBatches: histories[index].filter((entry) => entry.history?.status === "partial").length,
3246
3286
  oldestRevision: histories[index][0]?.baseRevision,
3247
3287
  newestRevision: histories[index].at(-1)?.revision,
3248
3288
  })),
@@ -4380,6 +4420,8 @@ async function main(args) {
4380
4420
  const wraps = [];
4381
4421
  const asked = [];
4382
4422
  let calls = 0;
4423
+ // The App's flex sizing rules, so the CLI stores what the App would.
4424
+ const fixSizing = await layoutSizingRules();
4383
4425
  const plan = () => {
4384
4426
  calls = 0;
4385
4427
  return ({ "component-define": componentDefineOperation, "page-draw": pageDrawOperation, "page-set": pageSetOperation, "component-set": componentSetOperation })[command](
@@ -4387,6 +4429,7 @@ async function main(args) {
4387
4429
  intent,
4388
4430
  {
4389
4431
  ...lookups,
4432
+ fixSizing,
4390
4433
  measure: (tree) => {
4391
4434
  const index = calls++;
4392
4435
  trees[index] ??= tree;
@@ -5203,17 +5246,9 @@ async function main(args) {
5203
5246
  if (signatureTtf) {
5204
5247
  inspectFont(bytes, "font/ttf");
5205
5248
  files.ttf = { bytes, mimeType: "font/ttf" };
5206
- files.woff = {
5207
- bytes: sfntToWoff(bytes, "font/ttf"),
5208
- mimeType: "font/woff",
5209
- };
5210
5249
  } else if (signatureOtf) {
5211
5250
  inspectFont(bytes, "font/otf");
5212
5251
  files.otf = { bytes, mimeType: "font/otf" };
5213
- files.woff = {
5214
- bytes: sfntToWoff(bytes, "font/otf"),
5215
- mimeType: "font/woff",
5216
- };
5217
5252
  } else if (signatureWoff2) {
5218
5253
  inspectFont(bytes, "font/woff2");
5219
5254
  files.woff2 = { bytes, mimeType: "font/woff2" };
@@ -1,23 +1,31 @@
1
1
  import { Worker } from "node:worker_threads";
2
2
 
3
+ // A renderer that panicked or ran out of memory stays broken in its worker.
4
+ const RENDERER_FAILURE = /panic|wasm|render_failed|validation_worker_failed/;
5
+
3
6
  // A persistent worker reuses the shared App runtime. Terminating the worker
4
7
  // also stops synchronous WASM; a Promise timeout in this process cannot.
8
+ // WASM memory only grows, so a worker is replaced after jobsPerWorker
9
+ // targets, and a target the renderer failed on is tried once more in a
10
+ // fresh worker.
5
11
  export async function runValidationJobs(workspace, jobs, {
6
- timeoutMs = 30_000, signal, progress = () => {},
12
+ timeoutMs = 30_000, signal, progress = () => {}, jobsPerWorker = 12,
7
13
  workerUrl = new URL("./validation-worker.mjs", import.meta.url),
8
14
  } = {}) {
9
15
  const results = [];
10
- let worker;
16
+ let worker, served = 0;
11
17
  const emit = (event, extra = {}) => progress({
12
18
  type: "validation-progress", event, total: jobs.length, completed: results.length, ...extra,
13
19
  });
14
20
  emit("start");
15
21
  try {
16
- for (const job of jobs) {
17
- if (signal?.aborted) break;
18
- emit("target", { target: job.label });
19
- worker ??= new Worker(workerUrl, { workerData: workspace });
20
- const result = await new Promise(resolve => {
22
+ const runOne = job => {
23
+ if (!worker) {
24
+ worker = new Worker(workerUrl, { workerData: workspace });
25
+ served = 0;
26
+ }
27
+ served += 1;
28
+ return new Promise(resolve => {
21
29
  let finished = false;
22
30
  const finish = value => {
23
31
  if (finished) return;
@@ -44,10 +52,22 @@ export async function runValidationJobs(workspace, jobs, {
44
52
  if (signal?.aborted) onAbort();
45
53
  else worker.postMessage(job);
46
54
  });
47
- if (result.restart) {
48
- await worker.terminate();
49
- worker = undefined;
55
+ };
56
+ const restart = async () => {
57
+ await worker?.terminate();
58
+ worker = undefined;
59
+ };
60
+ for (const job of jobs) {
61
+ if (signal?.aborted) break;
62
+ emit("target", { target: job.label });
63
+ if (served >= jobsPerWorker) await restart();
64
+ let result = await runOne(job);
65
+ if (!result.interrupted && RENDERER_FAILURE.test(result.error?.code ?? "")) {
66
+ await restart();
67
+ emit("retry", { target: job.label, reason: result.error.code });
68
+ result = await runOne(job);
50
69
  }
70
+ if (result.restart || RENDERER_FAILURE.test(result.error?.code ?? "")) await restart();
51
71
  if (result.interrupted) break;
52
72
  results.push({ job, ...result });
53
73
  emit("completed", { target: job.label, status: result.error ? result.error.code : "checked" });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@smallpen/cli",
3
- "version": "0.1.0-alpha.15",
3
+ "version": "0.1.0-alpha.18",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
@@ -15,8 +15,8 @@
15
15
  "bin"
16
16
  ],
17
17
  "dependencies": {
18
- "@smallpen/core": "0.1.0-alpha.15",
19
- "@smallpen/local-package": "0.1.0-alpha.15"
18
+ "@smallpen/core": "0.1.0-alpha.18",
19
+ "@smallpen/local-package": "0.1.0-alpha.18"
20
20
  },
21
21
  "repository": {
22
22
  "type": "git",