@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.
- package/bin/design-output.mjs +65 -2
- package/bin/help.mjs +9 -4
- package/bin/inspection-output.mjs +37 -19
- package/bin/schema.mjs +16 -14
- package/bin/smallpen.mjs +47 -12
- package/bin/validation-runner.mjs +30 -10
- package/package.json +3 -3
package/bin/design-output.mjs
CHANGED
|
@@ -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
|
|
329
|
-
|
|
330
|
-
|
|
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
|
|
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 =
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
[
|
|
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", ...
|
|
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 = "<
|
|
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
|
|
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
|
-
"
|
|
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
|
-
|
|
732
|
-
"
|
|
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"
|
|
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:
|
|
2427
|
-
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
|
-
|
|
17
|
-
if (
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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.
|
|
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.
|
|
19
|
-
"@smallpen/local-package": "0.1.0-alpha.
|
|
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",
|