@penpair/marwes-ui 0.1.3 → 0.2.0
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/dist/build-kit.d.ts +3 -0
- package/dist/build-kit.js +75 -0
- package/dist/commands.js +187 -52
- package/dist/components.d.ts +95 -0
- package/dist/components.js +196 -0
- package/dist/config.d.ts +5 -0
- package/dist/config.js +36 -0
- package/dist/index.d.ts +13 -0
- package/dist/index.js +12 -1
- package/dist/mode-trial.d.ts +95 -0
- package/dist/mode-trial.js +229 -0
- package/dist/ownership.d.ts +26 -0
- package/dist/ownership.js +97 -0
- package/dist/penpot.d.ts +21 -3
- package/dist/penpot.js +119 -20
- package/dist/reconciliation.d.ts +6 -0
- package/dist/reconciliation.js +104 -0
- package/dist/resolved-values.d.ts +16 -0
- package/dist/resolved-values.js +128 -0
- package/dist/snapshot-file.d.ts +45 -0
- package/dist/snapshot-file.js +41 -4
- package/dist/theme-input.js +7 -4
- package/dist/theme-lint.d.ts +31 -0
- package/dist/theme-lint.js +59 -0
- package/dist/tokens.d.ts +6 -2
- package/dist/tokens.js +22 -1
- package/package.json +2 -2
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/** Recipes refer to library token names, while project-derived constants remain explicit parameters. */
|
|
2
|
+
export const marwesKit = {
|
|
3
|
+
include: "@marwes-kit", requires: ["@kit"],
|
|
4
|
+
recipes: [
|
|
5
|
+
{ name: "card", description: "Surface/border card; project radius token is explicit" },
|
|
6
|
+
{ name: "segmented", description: "Token-styled segments with an explicit selected label" },
|
|
7
|
+
{ name: "progress", description: "Labeled bounded progress; explicit pill-radius token" },
|
|
8
|
+
{ name: "icon-tile", description: "Square token-styled icon tile with explicit dimensions" },
|
|
9
|
+
{ name: "stat-tile", description: "Card with project content and library typography" },
|
|
10
|
+
],
|
|
11
|
+
source: String.raw `// penpair: read
|
|
12
|
+
const mw = (() => {
|
|
13
|
+
const requireRadius = props => { if (!props || typeof props.radius !== "string" || !props.radius)
|
|
14
|
+
throw new Error("A measured or project-declared radius token is required"); pp.tok(props.radius); };
|
|
15
|
+
const card = (parent, props = {}) => { requireRadius(props); return pp.box(parent, { name: props.name || "Card", dir: "column", gap: props.gap ?? "mw-spacing-sp-16", pad: props.pad ?? ["mw-spacing-sp-24", "mw-spacing-sp-24", "mw-spacing-sp-24", "mw-spacing-sp-24"], bg: props.surface || "mw-color-surface", stroke: props.border || "mw-color-border-low", radius: props.radius, ...props }); };
|
|
16
|
+
const label = (parent, value, props = {}) => pp.text(parent, value, { typo: "mw-typography-text-label", color: "mw-color-text", ...props });
|
|
17
|
+
const segmented = (parent, props = {}) => {
|
|
18
|
+
if (!Array.isArray(props.labels) || !props.labels.length || props.labels.length > 20 || new Set(props.labels).size !== props.labels.length || !props.labels.includes(props.selected) || props.labels.some(value => typeof value !== "string" || !value))
|
|
19
|
+
throw new Error("Segments need 1-20 distinct labels and a selected label");
|
|
20
|
+
const radius = props.radius || "mw-ui-radius";
|
|
21
|
+
pp.tok(radius);
|
|
22
|
+
const group = pp.box(parent, { name: props.name || "Segmented control", dir: "row", gap: props.gap ?? "mw-spacing-sp-2", pad: props.pad ?? ["mw-spacing-sp-2", "mw-spacing-sp-2", "mw-spacing-sp-2", "mw-spacing-sp-2"], align: "center", bg: props.surface || "mw-color-surface-subtle", stroke: props.border || "mw-color-border-low", radius });
|
|
23
|
+
for (const value of props.labels) {
|
|
24
|
+
const selected = value === props.selected;
|
|
25
|
+
const segment = pp.box(group, { name: "Segment " + value, dir: "row", pad: props.segmentPad || ["mw-spacing-sp-8", "mw-spacing-sp-12", "mw-spacing-sp-8", "mw-spacing-sp-12"], align: "center", justify: "center", bg: selected ? props.selectedSurface || "mw-color-surface-primary" : undefined, stroke: selected ? props.selectedBorder || "mw-color-border-medium" : undefined, radius });
|
|
26
|
+
label(segment, value, { typo: props.typo || "mw-typography-text-label", color: selected ? props.color || "mw-color-text" : props.mutedColor || "mw-color-text-muted", ...props.text });
|
|
27
|
+
}
|
|
28
|
+
return group;
|
|
29
|
+
};
|
|
30
|
+
const progress = (parent, props = {}) => {
|
|
31
|
+
requireRadius(props);
|
|
32
|
+
if (typeof props.value !== "number" || !Number.isFinite(props.value) || props.value < 0 || props.value > 100 || typeof props.width !== "number" || !Number.isFinite(props.width) || props.width <= 0)
|
|
33
|
+
throw new Error("Progress needs value 0-100 and positive width");
|
|
34
|
+
if (typeof props.label !== "string" || !props.label)
|
|
35
|
+
throw new Error("Progress label required");
|
|
36
|
+
if (typeof props.height !== "string")
|
|
37
|
+
throw new Error("Progress needs an explicit sizing height token");
|
|
38
|
+
const heightToken = pp.tok(props.height);
|
|
39
|
+
if (heightToken.type !== "sizing")
|
|
40
|
+
throw new Error("Progress height must be a sizing token");
|
|
41
|
+
const height = Number(heightToken.resolvedValue === undefined ? heightToken.value : heightToken.resolvedValue);
|
|
42
|
+
if (!Number.isFinite(height) || height <= 0)
|
|
43
|
+
throw new Error("Progress height token did not resolve");
|
|
44
|
+
const column = pp.box(parent, { name: props.name || "Progress", dir: "column", gap: props.gap ?? "mw-spacing-sp-8" });
|
|
45
|
+
const head = pp.fill(pp.box(column, { name: "Label row", dir: "row", gap: props.gap ?? "mw-spacing-sp-8", align: "center", justify: "space-between" }));
|
|
46
|
+
label(head, props.label, props.text);
|
|
47
|
+
label(head, String(props.value) + "%", { color: props.mutedColor || "mw-color-text-muted", ...props.text });
|
|
48
|
+
const track = pp.box(column, { name: "Track", w: props.width, h: height, clip: true, bg: props.track || "mw-color-border-low", radius: props.radius });
|
|
49
|
+
pp.bind(track, heightToken.name, ["height"]);
|
|
50
|
+
if (props.value > 0) {
|
|
51
|
+
const fill = pp.box(track, { name: "Fill", w: props.width * props.value / 100, h: height, bg: props.color || "mw-color-primary-base", radius: props.radius });
|
|
52
|
+
pp.bind(fill, heightToken.name, ["height"]);
|
|
53
|
+
}
|
|
54
|
+
return column;
|
|
55
|
+
};
|
|
56
|
+
const iconTile = (parent, props = {}) => {
|
|
57
|
+
requireRadius(props);
|
|
58
|
+
if (!Number.isFinite(props.size) || props.size <= 0 || !Number.isFinite(props.iconSize) || props.iconSize <= 0 || props.iconSize > props.size)
|
|
59
|
+
throw new Error("Icon tile needs explicit positive square/icon dimensions");
|
|
60
|
+
const tile = pp.box(parent, { name: props.name || "Icon tile", dir: "row", align: "center", justify: "center", w: props.size, h: props.size, bg: props.surface || "mw-color-surface-subtle", radius: props.radius });
|
|
61
|
+
pp.icon(tile, props.kind, props.color || "mw-color-text", props.iconSize);
|
|
62
|
+
return tile;
|
|
63
|
+
};
|
|
64
|
+
const statTile = (parent, props = {}) => { if (typeof props.title !== "string" || !props.title || typeof props.value !== "string" || !props.value)
|
|
65
|
+
throw new Error("Stat tile title/value required"); const tile = card(parent, props); label(tile, props.title, { typo: props.labelTypo || "mw-typography-text-label", color: props.mutedColor || "mw-color-text-muted", ...props.text }); label(tile, props.value, { typo: props.valueTypo || "mw-typography-h2", color: props.color || "mw-color-text", ...props.text }); if (props.caption)
|
|
66
|
+
label(tile, props.caption, { typo: props.captionTypo || "mw-typography-text-caption", color: props.mutedColor || "mw-color-text-muted", ...props.text }); return tile; };
|
|
67
|
+
pp.defineRecipe("card", card, "Surface/border card; project radius token is explicit");
|
|
68
|
+
pp.defineRecipe("segmented", segmented, "Token-styled segments with an explicit selected label");
|
|
69
|
+
pp.defineRecipe("progress", progress, "Labeled bounded progress; explicit pill-radius token");
|
|
70
|
+
pp.defineRecipe("icon-tile", iconTile, "Square token-styled icon tile with explicit dimensions");
|
|
71
|
+
pp.defineRecipe("stat-tile", statTile, "Card with project content and library typography");
|
|
72
|
+
return { card, segmented, progress, iconTile, statTile };
|
|
73
|
+
})();
|
|
74
|
+
`,
|
|
75
|
+
};
|
package/dist/commands.js
CHANGED
|
@@ -1,22 +1,29 @@
|
|
|
1
|
+
import { lintThemeFamily } from "./theme-lint.js";
|
|
2
|
+
import { componentsCommand } from "./components.js";
|
|
3
|
+
import { runModeTrial } from "./mode-trial.js";
|
|
4
|
+
import { ownershipReport, readSyncAncestor, saveSyncAncestor } from "./ownership.js";
|
|
5
|
+
import { mkdir } from "node:fs/promises";
|
|
1
6
|
import { createRequire } from "node:module";
|
|
2
7
|
import path from "node:path";
|
|
3
|
-
import {
|
|
4
|
-
import { loadConfig, readRawConfig } from "@penpair/core";
|
|
8
|
+
import { parseCommandArgs, readDesignJson, atomicWriteFile, resolveProjectOutput, readJournalTarget, guardFile, resolveProjectInput, createCheckpoint, loadConfig, readRawConfig } from "@penpair/core";
|
|
5
9
|
import { readMarwesSettings } from "./config.js";
|
|
6
10
|
import { familyProblems, loadInstalledMarwes } from "./marwes.js";
|
|
7
11
|
import { applyPlan, compareToPenpot, describePlan, formatDrift, impactOf, planPush, readLive } from "./penpot.js";
|
|
8
|
-
import { diffSnapshots, formatDiff, readSnapshot, writeSnapshot } from "./snapshot-file.js";
|
|
12
|
+
import { diffSnapshots, formatDiff, tokenSnapshotSchema, migrateTokenSnapshot, readSnapshot, writeSnapshot } from "./snapshot-file.js";
|
|
9
13
|
import { loadThemeInput } from "./theme-input.js";
|
|
10
14
|
import { buildSnapshot } from "./tokens.js";
|
|
15
|
+
const print = (context, ...values) => (context.output?.log ?? console.log)(...values);
|
|
16
|
+
const diagnostic = (context, ...values) => (context.output?.error ?? console.error)(...values);
|
|
11
17
|
const adapterVersion = createRequire(import.meta.url)("../package.json").version;
|
|
12
18
|
export async function computeExpectation(root, rawConfig, instance) {
|
|
13
19
|
const settings = readMarwesSettings(rawConfig, instance);
|
|
14
|
-
const installed = await loadInstalledMarwes(
|
|
20
|
+
const installed = await loadInstalledMarwes(await resolveProjectInput(root, settings.project));
|
|
15
21
|
const [light, dark] = await Promise.all([settings.themes.light, settings.themes.dark].map((reference) => (reference ? loadThemeInput(root, reference) : undefined)));
|
|
16
22
|
const fresh = buildSnapshot({
|
|
17
23
|
core: installed.core,
|
|
18
24
|
version: installed.version,
|
|
19
25
|
name: settings.name,
|
|
26
|
+
extras: settings.extras,
|
|
20
27
|
project: { ...(light ? { light: light.input } : {}), ...(dark ? { dark: dark.input } : {}) },
|
|
21
28
|
generatedBy: `@penpair/marwes-ui@${adapterVersion}`,
|
|
22
29
|
});
|
|
@@ -42,134 +49,211 @@ export async function checkTokenSnapshot(root) {
|
|
|
42
49
|
const summary = (snapshot) => `Marwes ${snapshot.marwesVersion}, ${snapshot.sets.length} sets, ${snapshot.sets.reduce((n, s) => n + s.tokens.length, 0)} tokens, ${snapshot.hash.slice(0, 10)}`;
|
|
43
50
|
/** What the snapshot says it does not cover, so a reader is never left thinking everything Marwes provides is in Penpot. */
|
|
44
51
|
const coverage = (snapshot) => ((snapshot.unmapped ?? []).length > 0 ? `${snapshot.unmapped.length} Marwes variable(s) are not tokens: ${snapshot.unmapped.slice(0, 6).join(", ")}${snapshot.unmapped.length > 6 ? ", ..." : ""}` : undefined);
|
|
45
|
-
const flags = (args) =>
|
|
52
|
+
const flags = (args) => parseCommandArgs({ args, allowPositionals: true, options: { from: { type: "string" }, ownership: { type: "string" }, resolved: { type: "boolean" }, page: { type: "string" }, out: { type: "string" }, overwrite: { type: "boolean" }, adopt: { type: "string" }, write: { type: "boolean" }, apply: { type: "boolean" }, prune: { type: "boolean" }, json: { type: "boolean" }, resume: { type: "string" }, checkpoint: { type: "boolean" }, "force-lock": { type: "boolean" } } });
|
|
46
53
|
const staleAdvice = "Run `penpair marwes tokens snapshot --write`, review the change in git, and commit it.";
|
|
47
54
|
async function snapshotCommand(context, args) {
|
|
48
55
|
const { values } = flags(args);
|
|
49
56
|
const check = await checkSnapshotFor(context.root, context.rawConfig, context.config.instance);
|
|
50
57
|
const fresh = check.expectation.fresh;
|
|
51
58
|
if (values.json)
|
|
52
|
-
|
|
59
|
+
print(context, JSON.stringify({ current: check.current, summary: summary(fresh), diff: check.diff, problems: check.problems }, null, 2));
|
|
53
60
|
else {
|
|
54
|
-
|
|
61
|
+
print(context, `Expected from the installed ${summary(fresh)}`);
|
|
55
62
|
for (const line of formatDiff(check.diff))
|
|
56
|
-
|
|
63
|
+
print(context, ` ${line}`);
|
|
57
64
|
if (coverage(fresh))
|
|
58
|
-
|
|
65
|
+
print(context, `note ${coverage(fresh)}`);
|
|
59
66
|
for (const problem of check.problems.filter((p) => !p.startsWith("there is no snapshot")))
|
|
60
|
-
|
|
67
|
+
print(context, `FAIL ${problem}`);
|
|
61
68
|
}
|
|
62
69
|
if (check.expectation.problems.length > 0)
|
|
63
70
|
return 1;
|
|
64
71
|
if (check.current) {
|
|
65
72
|
if (!values.json)
|
|
66
|
-
|
|
73
|
+
print(context, `ok ${check.expectation.settings.snapshot} is current`);
|
|
67
74
|
return 0;
|
|
68
75
|
}
|
|
69
76
|
if (!values.write) {
|
|
70
77
|
if (!values.json)
|
|
71
|
-
|
|
78
|
+
print(context, `${check.committed ? "The committed snapshot is stale." : "There is no snapshot yet."} Run again with --write to save ${check.expectation.settings.snapshot}.`);
|
|
72
79
|
return 2;
|
|
73
80
|
}
|
|
74
81
|
await writeSnapshot(context.root, check.expectation.settings.snapshot, fresh);
|
|
82
|
+
context.output?.result({ current: true, snapshot: check.expectation.settings.snapshot, hash: fresh.hash, written: true });
|
|
75
83
|
if (!values.json)
|
|
76
|
-
|
|
84
|
+
print(context, `wrote ${check.expectation.settings.snapshot}. Review the change in git and commit it; then \`penpair marwes tokens diff\` shows what Penpot needs.`);
|
|
77
85
|
return 0;
|
|
78
86
|
}
|
|
79
87
|
async function checkCommand(context, args) {
|
|
80
88
|
const { values } = flags(args);
|
|
81
89
|
const check = await checkSnapshotFor(context.root, context.rawConfig, context.config.instance);
|
|
82
90
|
if (values.json) {
|
|
83
|
-
|
|
91
|
+
print(context, JSON.stringify({ current: check.current, installed: check.expectation.installed.versions, diff: check.diff, problems: check.problems }, null, 2));
|
|
84
92
|
return check.current ? 0 : check.expectation.problems.length > 0 ? 1 : 2;
|
|
85
93
|
}
|
|
86
94
|
if (check.current) {
|
|
87
|
-
|
|
95
|
+
print(context, `ok ${check.expectation.settings.snapshot} is current (${summary(check.expectation.fresh)})`);
|
|
88
96
|
return 0;
|
|
89
97
|
}
|
|
90
98
|
for (const line of formatDiff(check.diff))
|
|
91
|
-
|
|
99
|
+
print(context, ` ${line}`);
|
|
92
100
|
for (const problem of check.problems)
|
|
93
|
-
|
|
101
|
+
print(context, `FAIL ${problem}`);
|
|
94
102
|
if (check.committed && check.expectation.problems.length === 0) {
|
|
95
103
|
const cause = check.committed.marwesVersion !== check.expectation.installed.version ? `it was made from Marwes ${check.committed.marwesVersion} and the installed Marwes is ${check.expectation.installed.version}` : "the installed Marwes or the project's theme has changed since it was made";
|
|
96
|
-
|
|
104
|
+
print(context, `FAIL the committed snapshot is stale: ${cause}. ${staleAdvice}`);
|
|
97
105
|
}
|
|
98
106
|
return check.expectation.problems.length > 0 ? 1 : 2;
|
|
99
107
|
}
|
|
100
108
|
async function readPenpot(context, committed, wantImpact) {
|
|
101
109
|
return context.withPenpot(async (client) => {
|
|
102
|
-
const
|
|
103
|
-
const
|
|
104
|
-
|
|
110
|
+
const target = await readJournalTarget(client);
|
|
111
|
+
const pinned = guardFile(client, { id: target.fileId });
|
|
112
|
+
const live = await readLive(pinned);
|
|
113
|
+
const after = await readJournalTarget(pinned);
|
|
114
|
+
if (after.fileId !== target.fileId || after.revision !== target.revision)
|
|
115
|
+
throw new Error("File changed during token inspection; inspect again");
|
|
116
|
+
const ancestor = await readSyncAncestor(context.root, target.fileId);
|
|
117
|
+
const drift = compareToPenpot(committed, live, { ancestor });
|
|
118
|
+
return { target, live, drift, ...(wantImpact && !drift.inSync ? { impact: await impactOf(pinned).catch(() => undefined) } : {}) };
|
|
105
119
|
});
|
|
106
120
|
}
|
|
107
121
|
async function diffCommand(context, args) {
|
|
108
122
|
const { values } = flags(args);
|
|
123
|
+
if (values.resolved)
|
|
124
|
+
return modeCommand(context, args);
|
|
109
125
|
const check = await checkSnapshotFor(context.root, context.rawConfig, context.config.instance);
|
|
110
126
|
if (!check.committed) {
|
|
111
|
-
|
|
127
|
+
diagnostic(context, `FAIL ${check.problems.join("; ")}`);
|
|
112
128
|
return 1;
|
|
113
129
|
}
|
|
114
130
|
const { drift, impact } = await readPenpot(context, check.committed, true);
|
|
115
131
|
if (values.json) {
|
|
116
|
-
|
|
132
|
+
print(context, JSON.stringify({ snapshotCurrent: check.current, drift, impact }, null, 2));
|
|
117
133
|
return drift.inSync ? 0 : 2;
|
|
118
134
|
}
|
|
119
135
|
if (!check.current)
|
|
120
|
-
|
|
136
|
+
print(context, `note: the committed snapshot is stale against the installed Marwes (${staleAdvice}); the comparison below is against the committed one.`);
|
|
121
137
|
if (drift.inSync) {
|
|
122
|
-
|
|
138
|
+
for (const line of formatDrift(drift, impact))
|
|
139
|
+
print(context, `note preserved: ${line}`);
|
|
140
|
+
print(context, `ok Penpot matches ${summary(check.committed)}`);
|
|
123
141
|
return 0;
|
|
124
142
|
}
|
|
125
|
-
|
|
143
|
+
print(context, values.resume ? `Resuming run ${values.resume}; current snapshot: ${summary(check.committed)}` : `Penpot differs from ${summary(check.committed)}:`);
|
|
126
144
|
for (const line of formatDrift(drift, impact))
|
|
127
|
-
|
|
128
|
-
|
|
145
|
+
print(context, ` ${line}`);
|
|
146
|
+
print(context, "`penpair marwes tokens push --apply` brings Penpot to the snapshot (designer edits require explicit adoption or overwrite).");
|
|
129
147
|
return 2;
|
|
130
148
|
}
|
|
149
|
+
async function modeCommand(context, args, frameId) {
|
|
150
|
+
const { values } = flags(args);
|
|
151
|
+
if ((values.resume || values.checkpoint || values["force-lock"]) && !values.apply)
|
|
152
|
+
throw new Error("Mode resume/checkpoint/force-lock require --apply");
|
|
153
|
+
if (frameId && (!values.page || !values.out))
|
|
154
|
+
throw new Error("verify-modes requires one frame ID, --page and --out");
|
|
155
|
+
if (values.out)
|
|
156
|
+
await resolveProjectOutput(context.root, values.out);
|
|
157
|
+
const check = await checkSnapshotFor(context.root, context.rawConfig, context.config.instance);
|
|
158
|
+
if (!check.committed || !check.current) {
|
|
159
|
+
diagnostic(context, "Mode verification requires a current committed snapshot. " + staleAdvice);
|
|
160
|
+
return 2;
|
|
161
|
+
}
|
|
162
|
+
const report = await context.withPenpot(client => runModeTrial(client, check.committed, {
|
|
163
|
+
root: context.root, apply: values.apply === true, resume: values.resume, forceLock: values["force-lock"] === true, redact: context.redact, onStep: context.log,
|
|
164
|
+
requireCheckpoint: values.checkpoint === true,
|
|
165
|
+
...(values.checkpoint ? { checkpoint: runId => createCheckpoint(client, { label: "mode trial", runId }, { apply: true }) } : {}),
|
|
166
|
+
...(frameId ? { frame: { id: frameId, page: values.page, out: values.out } } : {}),
|
|
167
|
+
}));
|
|
168
|
+
context.output?.result(report);
|
|
169
|
+
print(context, JSON.stringify(report, null, 2));
|
|
170
|
+
if (!values.apply) {
|
|
171
|
+
diagnostic(context, "Mode checks temporarily change active selection. Inspect planned states, then use --apply; the full original selection will be restored and verified separately.");
|
|
172
|
+
return 0;
|
|
173
|
+
}
|
|
174
|
+
return report.uncertain ? 4 : !report.restoration.verified ? 1 : !report.complete ? 3 : report.verified ? 0 : 2;
|
|
175
|
+
}
|
|
131
176
|
async function pushCommand(context, args) {
|
|
132
177
|
const { values } = flags(args);
|
|
178
|
+
if ((values.resume || values.checkpoint || values["force-lock"]) && !values.apply)
|
|
179
|
+
throw new Error("Resume/checkpoint require --apply; inspect the journal and current design first");
|
|
133
180
|
const check = await checkSnapshotFor(context.root, context.rawConfig, context.config.instance);
|
|
134
181
|
if (!check.committed) {
|
|
135
|
-
|
|
182
|
+
diagnostic(context, `FAIL ${check.problems.join("; ")}`);
|
|
136
183
|
return 1;
|
|
137
184
|
}
|
|
138
185
|
if (!check.current) {
|
|
139
186
|
for (const line of formatDiff(check.diff))
|
|
140
|
-
|
|
141
|
-
|
|
187
|
+
print(context, ` ${line}`);
|
|
188
|
+
diagnostic(context, `STOP the committed snapshot is stale against the installed Marwes or the theme, and the repository's record must lead. ${staleAdvice} Then push.`);
|
|
189
|
+
return 2;
|
|
190
|
+
}
|
|
191
|
+
const { drift, impact, live, target } = await readPenpot(context, check.committed, true);
|
|
192
|
+
if (values.adopt && (values.apply || values.overwrite || values.resume))
|
|
193
|
+
throw new Error("--adopt writes a local review proposal and cannot be combined with apply/overwrite/resume");
|
|
194
|
+
const ancestor = await readSyncAncestor(context.root, target.fileId);
|
|
195
|
+
const ownership = ownershipReport(check.committed, live, ancestor);
|
|
196
|
+
if (values.adopt) {
|
|
197
|
+
const proposal = JSON.stringify({ schemaVersion: 1, kind: "token-adoption-proposal", fileId: target.fileId, snapshotHash: check.committed.hash, ownership, live, instructions: "Review live values and declare intentional changes in project themes/extras; regenerate the snapshot. This proposal changes no design or ownership ancestor." }, null, 2) + "\n";
|
|
198
|
+
if (context.redact && context.redact(proposal) !== proposal)
|
|
199
|
+
throw new Error("Adoption evidence requires credential redaction; no proposal was published");
|
|
200
|
+
const out = await resolveProjectOutput(context.root, values.adopt);
|
|
201
|
+
await mkdir(path.dirname(out), { recursive: true });
|
|
202
|
+
await atomicWriteFile(out, proposal);
|
|
203
|
+
context.output?.result({ phase: "adoption-proposed", file: values.adopt, ownership, complete: false });
|
|
204
|
+
print(context, "Saved adoption proposal. Review declarations and regenerate the snapshot before pushing; no Penpot write or ancestor update occurred.");
|
|
205
|
+
return 3;
|
|
206
|
+
}
|
|
207
|
+
const plan = planPush(check.committed, live, { prune: values.prune === true, ancestor });
|
|
208
|
+
if (ownership.blockers.length && !values.overwrite && !values.resume) {
|
|
209
|
+
context.output?.result({ phase: "conflict", ownership, plan, verified: false });
|
|
210
|
+
diagnostic(context, `STOP ${ownership.blockers.length} designer/conflicting/untracked token properties need a decision. Use --adopt <new-file.json> for a local review proposal, or inspect --overwrite and explicitly apply it.`);
|
|
142
211
|
return 2;
|
|
143
212
|
}
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
213
|
+
if (drift.inSync && plan.length === 0 && !values.resume) {
|
|
214
|
+
const evidence = JSON.stringify(check.committed);
|
|
215
|
+
if (context.redact && context.redact(evidence) !== evidence)
|
|
216
|
+
throw new Error("Sync evidence requires credential redaction");
|
|
217
|
+
if (values.apply)
|
|
218
|
+
await saveSyncAncestor(context.root, target.fileId, check.committed, ancestor);
|
|
219
|
+
context.output?.result({ verified: true, unchanged: true, drift, ownership });
|
|
220
|
+
print(context, `ok Penpot already matches ${summary(check.committed)}; nothing to write`);
|
|
148
221
|
return 0;
|
|
149
222
|
}
|
|
150
|
-
|
|
223
|
+
print(context, values.resume ? `Resuming run ${values.resume}; current snapshot: ${summary(check.committed)}` : `Penpot differs from ${summary(check.committed)}:`);
|
|
151
224
|
for (const line of formatDrift(drift, impact))
|
|
152
|
-
|
|
153
|
-
|
|
225
|
+
print(context, ` ${line}`);
|
|
226
|
+
print(context, "Plan:");
|
|
154
227
|
for (const line of describePlan(plan))
|
|
155
|
-
|
|
228
|
+
print(context, ` ${line}`);
|
|
156
229
|
if (!values.apply) {
|
|
157
|
-
|
|
230
|
+
context.output?.result({ phase: "inspected", ownership, plan, drift, snapshotCurrent: check.current, verified: false });
|
|
231
|
+
print(context, "Nothing was written. Run again with --apply to do it.");
|
|
158
232
|
if (!values.prune && drift.sets.some((s) => s.tokens.some((t) => t.kind === "extra")))
|
|
159
|
-
|
|
233
|
+
print(context, "Tokens without a verified ownership ancestor are preserved, including with --prune.");
|
|
160
234
|
return 0;
|
|
161
235
|
}
|
|
162
|
-
const report = await context.withPenpot((client) => applyPlan(client, check.committed, plan, (message) => context.log(message)
|
|
236
|
+
const report = await context.withPenpot((client) => applyPlan(client, check.committed, plan, (message) => context.log(message), {
|
|
237
|
+
prune: values.prune === true, ancestor, expectedFileId: target.fileId, expectedLive: live,
|
|
238
|
+
journal: { root: context.root, forceLock: values["force-lock"] === true, runId: values.resume, resume: Boolean(values.resume), redact: context.redact, requireCheckpoint: values.checkpoint === true,
|
|
239
|
+
...(values.checkpoint ? { checkpoint: (runId) => createCheckpoint(client, { label: "token push", runId }, { apply: true }) } : {}),
|
|
240
|
+
},
|
|
241
|
+
}));
|
|
242
|
+
context.output?.result(report);
|
|
163
243
|
for (const step of report.steps)
|
|
164
|
-
|
|
244
|
+
print(context, `done ${step}`);
|
|
165
245
|
if (report.verified) {
|
|
166
|
-
|
|
246
|
+
const evidence = JSON.stringify(check.committed);
|
|
247
|
+
if (context.redact && context.redact(evidence) !== evidence)
|
|
248
|
+
throw new Error("Sync evidence requires credential redaction");
|
|
249
|
+
await saveSyncAncestor(context.root, target.fileId, check.committed, ancestor);
|
|
250
|
+
print(context, `ok verified: Penpot now matches the snapshot and no shape moved or was rebound (${report.structureAfter.count} shapes unchanged)`);
|
|
167
251
|
return 0;
|
|
168
252
|
}
|
|
169
253
|
if (!report.structureUnchanged)
|
|
170
|
-
|
|
254
|
+
diagnostic(context, `FAIL the shapes changed while pushing tokens (${report.structureBefore.hash} -> ${report.structureAfter.hash}); look at the file before going on`);
|
|
171
255
|
for (const line of formatDrift(report.drift))
|
|
172
|
-
|
|
256
|
+
diagnostic(context, `FAIL still different: ${line}`);
|
|
173
257
|
return 1;
|
|
174
258
|
}
|
|
175
259
|
async function statusCommand(context, args) {
|
|
@@ -195,38 +279,89 @@ async function statusCommand(context, args) {
|
|
|
195
279
|
code = 2;
|
|
196
280
|
}
|
|
197
281
|
catch (error) {
|
|
282
|
+
if (code === 0)
|
|
283
|
+
code = 3;
|
|
198
284
|
lines.push(`skip Penpot: not checked (${String(error.message).split("\n")[0]})`);
|
|
199
285
|
}
|
|
200
286
|
}
|
|
201
287
|
if (values.json)
|
|
202
|
-
|
|
288
|
+
print(context, JSON.stringify({ installed: check.expectation.installed.versions, snapshotCurrent: check.current, penpotInSync: drift?.inSync ?? null, complete: Boolean(drift), exit: code }, null, 2));
|
|
203
289
|
else
|
|
204
290
|
for (const line of lines)
|
|
205
|
-
|
|
291
|
+
print(context, line);
|
|
206
292
|
return code;
|
|
207
293
|
}
|
|
208
294
|
export const marwesCommand = {
|
|
209
295
|
name: "marwes",
|
|
210
296
|
summary: "Marwes tokens in Penpot, and drift from the installed Marwes",
|
|
211
297
|
usage: [
|
|
298
|
+
"penpair marwes theme lint [--json] advisory family/default hue gaps; no design writes",
|
|
299
|
+
"penpair marwes tokens schema | migrate --from FILE --ownership FILE [--out NEW_FILE] schema and explicit legacy migration",
|
|
212
300
|
"penpair marwes tokens snapshot [--write] build the expected Penpot tokens from the installed Marwes and your theme; --write saves the snapshot",
|
|
213
301
|
"penpair marwes tokens check [--json] offline, for CI: is the committed snapshot current? (exit 2 when stale)",
|
|
214
302
|
"penpair marwes tokens diff [--json] Penpot against the snapshot: missing, changed, added by hand, and what each change reaches",
|
|
215
|
-
"penpair marwes tokens push [--apply] [--prune]
|
|
303
|
+
"penpair marwes tokens push [--apply] [--prune] inspect reconciliation; --apply writes, --overwrite resolves value conflicts, --prune removes owned retired tokens; --adopt FILE proposes local declarations",
|
|
304
|
+
"penpair marwes tokens diff --resolved [--apply] verify actual values in isolated themes and restore the complete original selection",
|
|
305
|
+
"penpair marwes tokens verify-modes ID --page PAGE --out DIR [--apply] capture stable per-mode PNG evidence and restore selection",
|
|
306
|
+
"penpair marwes components snapshot [--write] | check [--measure] | diff — explicit three-component measurement pilot; native generation gated",
|
|
216
307
|
"penpair marwes tokens status one screen: installed Marwes, snapshot, Penpot",
|
|
217
308
|
],
|
|
218
309
|
async run(context, args) {
|
|
219
310
|
const [area, sub, ...rest] = args;
|
|
311
|
+
if (area === "theme" && sub === "lint") {
|
|
312
|
+
if (rest.some(value => value !== "--json"))
|
|
313
|
+
throw new Error("theme lint accepts only --json");
|
|
314
|
+
const expected = await computeExpectation(context.root, context.rawConfig, context.config.instance);
|
|
315
|
+
if (expected.problems.length)
|
|
316
|
+
throw new Error(expected.problems.join("; "));
|
|
317
|
+
const defaults = buildSnapshot({ core: expected.installed.core, version: expected.installed.version, name: expected.settings.name, generatedBy: `@penpair/marwes-ui@${adapterVersion}` });
|
|
318
|
+
const report = lintThemeFamily(defaults, expected.fresh, expected.settings.themeLint);
|
|
319
|
+
context.output?.result(report);
|
|
320
|
+
print(context, JSON.stringify(report, null, 2));
|
|
321
|
+
return 0;
|
|
322
|
+
}
|
|
323
|
+
if (area === "components")
|
|
324
|
+
return componentsCommand(context, args.slice(1));
|
|
220
325
|
if (area !== "tokens" || !sub) {
|
|
221
|
-
|
|
326
|
+
diagnostic(context, `usage:\n ${marwesCommand.usage.join("\n ")}`);
|
|
222
327
|
return 1;
|
|
223
328
|
}
|
|
224
|
-
|
|
329
|
+
if (sub === "schema") {
|
|
330
|
+
if (rest.some(value => value !== "--json"))
|
|
331
|
+
throw new Error("tokens schema accepts only --json");
|
|
332
|
+
context.output?.result(tokenSnapshotSchema);
|
|
333
|
+
print(context, JSON.stringify(tokenSnapshotSchema, null, 2));
|
|
334
|
+
return 0;
|
|
335
|
+
}
|
|
336
|
+
if (sub === "migrate") {
|
|
337
|
+
const { values, positionals } = flags(rest);
|
|
338
|
+
if (positionals.length || !values.from || !values.ownership || Object.keys(values).some(k => !["from", "ownership", "out", "json"].includes(k)))
|
|
339
|
+
throw new Error("tokens migrate --from LEGACY_JSON --ownership SET_OWNERS_JSON [--out NEW_FILE]");
|
|
340
|
+
const migrated = migrateTokenSnapshot(await readDesignJson(context.root, values.from), await readDesignJson(context.root, values.ownership));
|
|
341
|
+
if (values.out) {
|
|
342
|
+
const target = await resolveProjectOutput(context.root, values.out);
|
|
343
|
+
await mkdir(path.dirname(target), { recursive: true });
|
|
344
|
+
await atomicWriteFile(target, JSON.stringify(migrated, null, 2) + "\n");
|
|
345
|
+
}
|
|
346
|
+
context.output?.result(migrated);
|
|
347
|
+
print(context, JSON.stringify(migrated, null, 2));
|
|
348
|
+
return 0;
|
|
349
|
+
}
|
|
350
|
+
const commands = { snapshot: snapshotCommand, check: checkCommand, diff: diffCommand, push: pushCommand, status: statusCommand, "verify-modes": (context, args) => modeCommand(context, args, flags(args).positionals[0]) };
|
|
225
351
|
const run = commands[sub];
|
|
226
352
|
if (!run) {
|
|
227
|
-
|
|
353
|
+
diagnostic(context, `unknown: penpair marwes tokens ${sub}\nusage:\n ${marwesCommand.usage.join("\n ")}`);
|
|
228
354
|
return 1;
|
|
229
355
|
}
|
|
356
|
+
const parsed = flags(rest);
|
|
357
|
+
const allowed = new Set(["json", ...(sub === "snapshot" ? ["write"] : sub === "push" ? ["apply", "prune", "resume", "checkpoint", "force-lock", "overwrite", "adopt"] : sub === "diff" ? ["resolved", "apply", "resume", "checkpoint", "force-lock"] : sub === "verify-modes" ? ["page", "out", "apply", "resume", "checkpoint", "force-lock"] : [])]);
|
|
358
|
+
if (sub === "verify-modes" ? parsed.positionals.length !== 1 : parsed.positionals.length)
|
|
359
|
+
throw new Error("Unexpected positional arguments after the token subcommand");
|
|
360
|
+
for (const key of Object.keys(parsed.values))
|
|
361
|
+
if (!allowed.has(key))
|
|
362
|
+
throw new Error(`--${key} is not supported by tokens ${sub}`);
|
|
363
|
+
if (sub === "diff" && !parsed.values.resolved && [parsed.values.apply, parsed.values.resume, parsed.values.checkpoint, parsed.values["force-lock"]].some(Boolean))
|
|
364
|
+
throw new Error("Mode write flags require diff --resolved");
|
|
230
365
|
return run(context, rest);
|
|
231
366
|
},
|
|
232
367
|
};
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { type AdapterContext } from "@penpair/core";
|
|
2
|
+
import { type TokenSnapshot } from "./tokens.js";
|
|
3
|
+
export declare const componentProperties: readonly ["width", "height", "paddingTop", "paddingRight", "paddingBottom", "paddingLeft", "rowGap", "columnGap", "borderRadius", "borderWidth", "borderColor", "backgroundColor", "color", "fontFamily", "fontSize", "fontWeight", "lineHeight", "boxShadow"];
|
|
4
|
+
export interface ComponentCase {
|
|
5
|
+
id: string;
|
|
6
|
+
component: "Button" | "Card" | "StatTile";
|
|
7
|
+
theme: "light" | "dark";
|
|
8
|
+
state: string;
|
|
9
|
+
viewport: {
|
|
10
|
+
width: number;
|
|
11
|
+
height: number;
|
|
12
|
+
};
|
|
13
|
+
selector: string;
|
|
14
|
+
}
|
|
15
|
+
export interface ComponentPilot {
|
|
16
|
+
schemaVersion: 1;
|
|
17
|
+
renderer: string;
|
|
18
|
+
sources: string[];
|
|
19
|
+
snapshot: string;
|
|
20
|
+
cases: ComponentCase[];
|
|
21
|
+
}
|
|
22
|
+
export interface ComponentMeasurement {
|
|
23
|
+
id: string;
|
|
24
|
+
/** All declared anatomy selectors must be observed; null properties are explicit unknowns. */
|
|
25
|
+
parts: Record<string, Record<string, string | null>>;
|
|
26
|
+
environment: {
|
|
27
|
+
browser: string;
|
|
28
|
+
fontsReady: boolean;
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
export interface ComponentSnapshot {
|
|
32
|
+
schemaVersion: 1;
|
|
33
|
+
kind: "component-pilot";
|
|
34
|
+
sourceHash: string;
|
|
35
|
+
hash: string;
|
|
36
|
+
marwesVersion: string;
|
|
37
|
+
cases: Array<ComponentCase & {
|
|
38
|
+
measurement: ComponentMeasurement;
|
|
39
|
+
tokenCandidates: Record<string, Record<string, string[]>>;
|
|
40
|
+
}>;
|
|
41
|
+
nativeLibrary: "gated-on-instance-override-proof";
|
|
42
|
+
}
|
|
43
|
+
export type ComponentRenderer = (cases: ComponentCase[]) => Promise<unknown>;
|
|
44
|
+
export declare function readComponentPilot(raw: Record<string, unknown>): ComponentPilot;
|
|
45
|
+
/** Hash declared source bytes as well as effective tokens: comments and non-token anatomy changes invalidate cheap CI checks. */
|
|
46
|
+
export declare function componentSourceHash(root: string, pilot: ComponentPilot, tokens: TokenSnapshot): Promise<string>;
|
|
47
|
+
/** Exact value candidates are evidence of equality only. Even one candidate is not a semantic binding. */
|
|
48
|
+
export declare function componentTokenCandidates(fields: Record<string, string | null>, tokens: TokenSnapshot, mode: "light" | "dark"): Record<string, string[]>;
|
|
49
|
+
export declare function measureComponentPilot(pilot: ComponentPilot, tokens: TokenSnapshot, sourceHash: string, renderer: ComponentRenderer): Promise<ComponentSnapshot>;
|
|
50
|
+
export declare function validateComponentSnapshot(raw: unknown): ComponentSnapshot;
|
|
51
|
+
export declare function diffComponentSnapshots(before: ComponentSnapshot, after: ComponentSnapshot): {
|
|
52
|
+
schemaVersion: number;
|
|
53
|
+
identical: boolean;
|
|
54
|
+
sourceChanged: boolean;
|
|
55
|
+
changes: {
|
|
56
|
+
id: string;
|
|
57
|
+
before: unknown;
|
|
58
|
+
after: unknown;
|
|
59
|
+
}[];
|
|
60
|
+
semanticBindings: string;
|
|
61
|
+
instanceOverrides: string;
|
|
62
|
+
};
|
|
63
|
+
export declare function componentsCommand(context: AdapterContext, args: string[]): Promise<number>;
|
|
64
|
+
export declare const componentSnapshotSchema: {
|
|
65
|
+
readonly $schema: "https://json-schema.org/draft/2020-12/schema";
|
|
66
|
+
readonly $id: "urn:penpair:schema:v1:component-pilot";
|
|
67
|
+
readonly type: "object";
|
|
68
|
+
readonly required: readonly ["schemaVersion", "kind", "sourceHash", "hash", "marwesVersion", "cases", "nativeLibrary"];
|
|
69
|
+
readonly properties: {
|
|
70
|
+
readonly schemaVersion: {
|
|
71
|
+
readonly const: 1;
|
|
72
|
+
};
|
|
73
|
+
readonly kind: {
|
|
74
|
+
readonly const: "component-pilot";
|
|
75
|
+
};
|
|
76
|
+
readonly sourceHash: {
|
|
77
|
+
readonly type: "string";
|
|
78
|
+
};
|
|
79
|
+
readonly hash: {
|
|
80
|
+
readonly type: "string";
|
|
81
|
+
};
|
|
82
|
+
readonly marwesVersion: {
|
|
83
|
+
readonly type: "string";
|
|
84
|
+
};
|
|
85
|
+
readonly cases: {
|
|
86
|
+
readonly type: "array";
|
|
87
|
+
readonly minItems: 6;
|
|
88
|
+
readonly maxItems: 60;
|
|
89
|
+
};
|
|
90
|
+
readonly nativeLibrary: {
|
|
91
|
+
readonly const: "gated-on-instance-override-proof";
|
|
92
|
+
};
|
|
93
|
+
};
|
|
94
|
+
readonly additionalProperties: true;
|
|
95
|
+
};
|