@penpair/marwes-ui 0.1.2 → 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.
@@ -0,0 +1,3 @@
1
+ import type { BuildKit } from "@penpair/core";
2
+ /** Recipes refer to library token names, while project-derived constants remain explicit parameters. */
3
+ export declare const marwesKit: BuildKit;
@@ -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 { parseArgs } from "node:util";
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(path.resolve(root, settings.project));
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) => parseArgs({ args, allowPositionals: true, options: { write: { type: "boolean" }, apply: { type: "boolean" }, prune: { type: "boolean" }, json: { type: "boolean" } } });
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
- console.log(JSON.stringify({ current: check.current, summary: summary(fresh), diff: check.diff, problems: check.problems }, null, 2));
59
+ print(context, JSON.stringify({ current: check.current, summary: summary(fresh), diff: check.diff, problems: check.problems }, null, 2));
53
60
  else {
54
- console.log(`Expected from the installed ${summary(fresh)}`);
61
+ print(context, `Expected from the installed ${summary(fresh)}`);
55
62
  for (const line of formatDiff(check.diff))
56
- console.log(` ${line}`);
63
+ print(context, ` ${line}`);
57
64
  if (coverage(fresh))
58
- console.log(`note ${coverage(fresh)}`);
65
+ print(context, `note ${coverage(fresh)}`);
59
66
  for (const problem of check.problems.filter((p) => !p.startsWith("there is no snapshot")))
60
- console.log(`FAIL ${problem}`);
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
- console.log(`ok ${check.expectation.settings.snapshot} is current`);
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
- console.log(`${check.committed ? "The committed snapshot is stale." : "There is no snapshot yet."} Run again with --write to save ${check.expectation.settings.snapshot}.`);
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
- console.log(`wrote ${check.expectation.settings.snapshot}. Review the change in git and commit it; then \`penpair marwes tokens diff\` shows what Penpot needs.`);
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
- console.log(JSON.stringify({ current: check.current, installed: check.expectation.installed.versions, diff: check.diff, problems: check.problems }, null, 2));
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
- console.log(`ok ${check.expectation.settings.snapshot} is current (${summary(check.expectation.fresh)})`);
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
- console.log(` ${line}`);
99
+ print(context, ` ${line}`);
92
100
  for (const problem of check.problems)
93
- console.log(`FAIL ${problem}`);
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
- console.log(`FAIL the committed snapshot is stale: ${cause}. ${staleAdvice}`);
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 live = await readLive(client);
103
- const drift = compareToPenpot(committed, live);
104
- return { live, drift, ...(wantImpact && !drift.inSync ? { impact: await impactOf(client).catch(() => undefined) } : {}) };
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
- console.error(`FAIL ${check.problems.join("; ")}`);
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
- console.log(JSON.stringify({ snapshotCurrent: check.current, drift, impact }, null, 2));
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
- console.log(`note: the committed snapshot is stale against the installed Marwes (${staleAdvice}); the comparison below is against the committed one.`);
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
- console.log(`ok Penpot matches ${summary(check.committed)}`);
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
- console.log(`Penpot differs from ${summary(check.committed)}:`);
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
- console.log(` ${line}`);
128
- console.log("`penpair marwes tokens push --apply` brings Penpot to the snapshot (Marwes is authoritative: a value changed by hand in Penpot is put back).");
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
- console.error(`FAIL ${check.problems.join("; ")}`);
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
- console.log(` ${line}`);
141
- console.error(`STOP the committed snapshot is stale against the installed Marwes or the theme, and the repository's record must lead. ${staleAdvice} Then push.`);
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
- const { drift, impact, live } = await readPenpot(context, check.committed, true);
145
- const plan = planPush(check.committed, live, { prune: values.prune === true });
146
- if (drift.inSync && plan.length === 0) {
147
- console.log(`ok Penpot already matches ${summary(check.committed)}; nothing to write`);
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
- console.log(`Penpot differs from ${summary(check.committed)}:`);
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
- console.log(` ${line}`);
153
- console.log("Plan:");
225
+ print(context, ` ${line}`);
226
+ print(context, "Plan:");
154
227
  for (const line of describePlan(plan))
155
- console.log(` ${line}`);
228
+ print(context, ` ${line}`);
156
229
  if (!values.apply) {
157
- console.log("Nothing was written. Run again with --apply to do it.");
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
- console.log("Tokens added by hand are kept unless you add --prune.");
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
- console.log(`done ${step}`);
244
+ print(context, `done ${step}`);
165
245
  if (report.verified) {
166
- console.log(`ok verified: Penpot now matches the snapshot and no shape moved or was rebound (${report.structureAfter.count} shapes unchanged)`);
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
- console.error(`FAIL the shapes changed while pushing tokens (${report.structureBefore.hash} -> ${report.structureAfter.hash}); look at the file before going on`);
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
- console.error(`FAIL still different: ${line}`);
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
- console.log(JSON.stringify({ installed: check.expectation.installed.versions, snapshotCurrent: check.current, penpotInSync: drift?.inSync ?? null, exit: code }, null, 2));
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
- console.log(line);
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] bring Penpot to the snapshot (looks first; --apply writes; --prune also removes what is not expected)",
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
- console.error(`usage:\n ${marwesCommand.usage.join("\n ")}`);
326
+ diagnostic(context, `usage:\n ${marwesCommand.usage.join("\n ")}`);
222
327
  return 1;
223
328
  }
224
- const commands = { snapshot: snapshotCommand, check: checkCommand, diff: diffCommand, push: pushCommand, status: statusCommand };
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
- console.error(`unknown: penpair marwes tokens ${sub}\nusage:\n ${marwesCommand.usage.join("\n ")}`);
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
+ };