@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.
@@ -0,0 +1,196 @@
1
+ import { createHash } from "node:crypto";
2
+ import { mkdir, readFile, stat } from "node:fs/promises";
3
+ import path from "node:path";
4
+ import { createJiti } from "jiti";
5
+ import { atomicWriteFile, parseCommandArgs, resolveProjectInput, resolveProjectOutput } from "@penpair/core";
6
+ import { computeExpectation } from "./commands.js";
7
+ import { canonical, toHexColor } from "./tokens.js";
8
+ export const componentProperties = ["width", "height", "paddingTop", "paddingRight", "paddingBottom", "paddingLeft", "rowGap", "columnGap", "borderRadius", "borderWidth", "borderColor", "backgroundColor", "color", "fontFamily", "fontSize", "fontWeight", "lineHeight", "boxShadow"];
9
+ const digest = (v) => createHash("sha256").update(canonical(v)).digest("hex");
10
+ const text = (v, label, max = 500) => {
11
+ if (typeof v !== "string" || !v.trim() || v.length > max || /[\x00-\x1f]/.test(v))
12
+ throw new Error(`Invalid component ${label}`);
13
+ return v;
14
+ };
15
+ export function readComponentPilot(raw) {
16
+ const value = raw.marwes?.components;
17
+ if (!value || typeof value !== "object" || Array.isArray(value))
18
+ throw new Error("Configure marwes.components with a trusted renderer, sources and explicit pilot cases");
19
+ const v = value;
20
+ if (v.schemaVersion !== 1 || !Array.isArray(v.sources) || !v.sources.length || v.sources.length > 100 || !Array.isArray(v.cases) || !v.cases.length || v.cases.length > 60)
21
+ throw new Error("Invalid schema-1 component pilot (max 100 sources, 60 cases)");
22
+ const ids = new Set();
23
+ const cases = v.cases.map(c => {
24
+ if (!c || !["Button", "Card", "StatTile"].includes(c.component) || !["light", "dark"].includes(c.theme))
25
+ throw new Error("Pilot supports Button/Card/StatTile and explicit light/dark themes");
26
+ const id = text(c.id, "case id");
27
+ if (ids.has(id))
28
+ throw new Error("Duplicate component case id");
29
+ ids.add(id);
30
+ if (![c.viewport?.width, c.viewport?.height].every(n => Number.isInteger(n) && n > 0 && n <= 8192))
31
+ throw new Error("Invalid component viewport");
32
+ return { id, component: c.component, theme: c.theme, state: text(c.state, "state"), viewport: { ...c.viewport }, selector: text(c.selector, "selector") };
33
+ });
34
+ for (const component of ["Button", "Card", "StatTile"])
35
+ for (const theme of ["light", "dark"])
36
+ if (!cases.some(c => c.component === component && c.theme === theme))
37
+ throw new Error(`Pilot requires ${component} in ${theme}`);
38
+ return { schemaVersion: 1, renderer: text(v.renderer, "renderer"), sources: v.sources.map(s => text(s, "source")), snapshot: text(v.snapshot, "snapshot"), cases };
39
+ }
40
+ async function boundedRead(root, relative) {
41
+ const file = await resolveProjectInput(root, relative);
42
+ if ((await stat(file)).size > 4 * 1024 * 1024)
43
+ throw new Error("Component input exceeds 4 MiB");
44
+ return readFile(file, "utf8");
45
+ }
46
+ /** Hash declared source bytes as well as effective tokens: comments and non-token anatomy changes invalidate cheap CI checks. */
47
+ export async function componentSourceHash(root, pilot, tokens) {
48
+ const files = [...new Set([pilot.renderer, ...pilot.sources])].sort();
49
+ const sources = await Promise.all(files.map(async (file) => [file, digest(await boundedRead(root, file))]));
50
+ return digest({ pilot, sources, tokens: tokens.hash, version: tokens.marwesVersion });
51
+ }
52
+ function validateMeasurement(raw, id) {
53
+ const v = raw;
54
+ if (!v || v.id !== id || !v.parts || Array.isArray(v.parts) || typeof v.parts !== "object" || !Object.keys(v.parts).length || Object.keys(v.parts).length > 32 || !v.environment || typeof v.environment.fontsReady !== "boolean")
55
+ throw new Error(`Incomplete component measurement: ${id}`);
56
+ const parts = Object.create(null);
57
+ for (const [name, fields] of Object.entries(v.parts)) {
58
+ text(name, "part", 100);
59
+ if (!fields || typeof fields !== "object" || Array.isArray(fields) || Object.keys(fields).some(k => !componentProperties.includes(k)))
60
+ throw new Error("Invalid measured anatomy properties");
61
+ parts[name] = Object.fromEntries(componentProperties.map(key => {
62
+ const value = fields[key];
63
+ if (value !== null && (typeof value !== "string" || value.length > 2000))
64
+ throw new Error(`Missing/invalid ${id}/${name}/${key}; use null for unknown`);
65
+ return [key, value];
66
+ }));
67
+ }
68
+ return { id, parts, environment: { browser: text(v.environment.browser, "browser"), fontsReady: v.environment.fontsReady } };
69
+ }
70
+ /** Exact value candidates are evidence of equality only. Even one candidate is not a semantic binding. */
71
+ export function componentTokenCandidates(fields, tokens, mode) {
72
+ const selected = tokens.themes.find(t => t.group === "Mode" && t.name.toLowerCase() === mode);
73
+ if (!selected)
74
+ return {};
75
+ const effective = new Map();
76
+ for (const setName of selected.sets)
77
+ for (const token of tokens.sets.find(s => s.name === setName)?.tokens ?? [])
78
+ effective.set(token.name, { set: setName, value: token.value, type: token.type });
79
+ const result = Object.create(null);
80
+ for (const [property, raw] of Object.entries(fields)) {
81
+ if (raw === null) {
82
+ result[property] = [];
83
+ continue;
84
+ }
85
+ const color = ["color", "borderColor", "backgroundColor"].includes(property);
86
+ const types = color ? ["color"] : property.startsWith("padding") || property.endsWith("Gap") ? ["spacing", "dimension"] : property === "borderRadius" ? ["borderRadius", "dimension"] : ["width", "height"].includes(property) ? ["sizing", "dimension"] : [];
87
+ const normalize = (value) => { try {
88
+ return color ? toHexColor(value).toLowerCase() : value.replace(/^(-?[\d.]+)px$/, "$1");
89
+ }
90
+ catch {
91
+ return value;
92
+ } };
93
+ result[property] = [...effective].filter(([, t]) => types.includes(t.type) && typeof t.value === "string" && normalize(t.value) === normalize(raw)).map(([name, t]) => `${t.set}/${name}`).sort();
94
+ }
95
+ return result;
96
+ }
97
+ export async function measureComponentPilot(pilot, tokens, sourceHash, renderer) {
98
+ const raw = await renderer(structuredClone(pilot.cases));
99
+ if (!Array.isArray(raw) || raw.length !== pilot.cases.length || JSON.stringify(raw).length > 4 * 1024 * 1024)
100
+ throw new Error("Renderer must return exactly one bounded observation per case");
101
+ const byId = new Map(raw.map(v => [v?.id, v]));
102
+ if (byId.size !== raw.length)
103
+ throw new Error("Duplicate renderer case");
104
+ const cases = pilot.cases.map(c => {
105
+ const measurement = validateMeasurement(byId.get(c.id), c.id);
106
+ return { ...c, measurement, tokenCandidates: Object.fromEntries(Object.entries(measurement.parts).map(([name, fields]) => [name, componentTokenCandidates(fields, tokens, c.theme)])) };
107
+ });
108
+ const body = { schemaVersion: 1, kind: "component-pilot", sourceHash, marwesVersion: tokens.marwesVersion, cases, nativeLibrary: "gated-on-instance-override-proof" };
109
+ return { ...body, hash: digest(body) };
110
+ }
111
+ export function validateComponentSnapshot(raw) {
112
+ const v = raw;
113
+ if (!v || v.schemaVersion !== 1 || v.kind !== "component-pilot" || v.nativeLibrary !== "gated-on-instance-override-proof" || !/^[a-f0-9]{64}$/.test(v.sourceHash) || !Array.isArray(v.cases) || v.cases.length > 60)
114
+ throw new Error("Unsupported component snapshot");
115
+ const { hash, ...body } = v;
116
+ if (hash !== digest(body))
117
+ throw new Error("Component snapshot hash mismatch");
118
+ const pilot = readComponentPilot({ marwes: { components: { schemaVersion: 1, renderer: "unused", sources: ["unused"], snapshot: "unused", cases: v.cases } } });
119
+ for (const c of pilot.cases)
120
+ validateMeasurement(v.cases.find(m => m.id === c.id)?.measurement, c.id);
121
+ return v;
122
+ }
123
+ export function diffComponentSnapshots(before, after) {
124
+ const changes = [];
125
+ const ids = new Set([...before.cases, ...after.cases].map(c => c.id));
126
+ for (const id of ids) {
127
+ const a = before.cases.find(c => c.id === id), b = after.cases.find(c => c.id === id);
128
+ if (canonical(a ?? null) !== canonical(b ?? null))
129
+ changes.push({ id, before: a ?? null, after: b ?? null });
130
+ }
131
+ return { schemaVersion: 1, identical: before.hash === after.hash, sourceChanged: before.sourceHash !== after.sourceHash, changes, semanticBindings: "not-inferred", instanceOverrides: "not-tested" };
132
+ }
133
+ export async function componentsCommand(context, args) {
134
+ const [sub, ...rest] = args;
135
+ const { values, positionals } = parseCommandArgs({ args: rest, allowPositionals: true, options: { write: { type: "boolean" }, measure: { type: "boolean" }, json: { type: "boolean" } } });
136
+ if (sub === "schema") {
137
+ if (positionals.length || Object.keys(values).some(k => k !== "json"))
138
+ throw new Error("components schema accepts only --json");
139
+ context.output?.result(componentSnapshotSchema);
140
+ (context.output?.log ?? console.log)(JSON.stringify(componentSnapshotSchema));
141
+ return 0;
142
+ }
143
+ if (!["snapshot", "check", "diff", "build"].includes(sub ?? "") || positionals.length || (values.write && sub !== "snapshot") || (values.measure && sub !== "check"))
144
+ throw new Error("Usage: marwes components snapshot [--write] | check [--measure] | diff | build");
145
+ if (sub === "build")
146
+ throw new Error("Native library generation is gated: first prove preserved text/style instance overrides with the pinned Penpot API in an owner-authorized scratch file. No library was generated.");
147
+ const pilot = readComponentPilot(context.rawConfig);
148
+ const expectation = await computeExpectation(context.root, context.rawConfig, context.config.instance);
149
+ if (expectation.problems.length)
150
+ throw new Error(expectation.problems.join("; "));
151
+ const sourceHash = await componentSourceHash(context.root, pilot, expectation.fresh);
152
+ let prior;
153
+ try {
154
+ prior = validateComponentSnapshot(JSON.parse(await boundedRead(context.root, pilot.snapshot)));
155
+ }
156
+ catch (error) {
157
+ if (error.code !== "ENOENT")
158
+ throw error;
159
+ }
160
+ if (sub === "check" && !values.measure) {
161
+ const current = prior?.sourceHash === sourceHash;
162
+ const report = { schemaVersion: 1, current, kind: "source-freshness", anatomyMeasured: false, nativeLibrary: "gated-on-instance-override-proof" };
163
+ context.output?.result(report);
164
+ (context.output?.log ?? console.log)(JSON.stringify(report));
165
+ return current ? 0 : 2;
166
+ }
167
+ // Explicit component commands alone execute the project renderer. Like theme modules, this is trusted local code, not a sandbox.
168
+ const file = await resolveProjectInput(context.root, pilot.renderer);
169
+ const module = await createJiti(file, { interopDefault: true, moduleCache: false, tryNative: false }).evalModule(await boundedRead(context.root, pilot.renderer), { filename: file, async: true, forceTranspile: true });
170
+ if (typeof module.measure !== "function")
171
+ throw new Error("Component renderer must export async measure(cases); install its optional browser dependency in the project");
172
+ const fresh = await measureComponentPilot(pilot, expectation.fresh, sourceHash, module.measure);
173
+ if (await componentSourceHash(context.root, pilot, (await computeExpectation(context.root, context.rawConfig, context.config.instance)).fresh) !== sourceHash)
174
+ throw new Error("Component source changed during measurement");
175
+ const complete = fresh.cases.every(c => c.measurement.environment.fontsReady && Object.values(c.measurement.parts).every(fields => Object.values(fields).every(v => v !== null)));
176
+ const report = { schemaVersion: 1, complete, current: prior?.hash === fresh.hash, comparison: prior ? diffComponentSnapshots(prior, fresh) : null, snapshot: fresh, written: false };
177
+ if (sub === "snapshot" && values.write) {
178
+ if (!complete)
179
+ throw new Error("Incomplete measurements/fonts: previous component snapshot preserved");
180
+ const data = JSON.stringify(fresh, null, 2) + "\n";
181
+ if (context.redact && context.redact(data) !== data)
182
+ throw new Error("Component evidence requires redaction; snapshot not published");
183
+ const target = await resolveProjectOutput(context.root, pilot.snapshot);
184
+ await mkdir(path.dirname(target), { recursive: true });
185
+ await atomicWriteFile(target, data, { replace: true });
186
+ report.written = true;
187
+ }
188
+ context.output?.result(report);
189
+ (context.output?.log ?? console.log)(JSON.stringify(report, null, 2));
190
+ return !complete ? 3 : report.written || report.current ? 0 : 2;
191
+ }
192
+ export const componentSnapshotSchema = {
193
+ $schema: "https://json-schema.org/draft/2020-12/schema", $id: "urn:penpair:schema:v1:component-pilot", type: "object",
194
+ required: ["schemaVersion", "kind", "sourceHash", "hash", "marwesVersion", "cases", "nativeLibrary"],
195
+ properties: { schemaVersion: { const: 1 }, kind: { const: "component-pilot" }, sourceHash: { type: "string" }, hash: { type: "string" }, marwesVersion: { type: "string" }, cases: { type: "array", minItems: 6, maxItems: 60 }, nativeLibrary: { const: "gated-on-instance-override-proof" } }, additionalProperties: true,
196
+ };
package/dist/config.d.ts CHANGED
@@ -1,3 +1,5 @@
1
+ import { type ThemeLintSettings } from "./theme-lint.js";
2
+ import type { PenpotTokenSet } from "./tokens.js";
1
3
  import type { ThemeReference } from "./theme-input.js";
2
4
  /** The `marwes` block of penpair.config.json. */
3
5
  export interface MarwesSettings {
@@ -12,6 +14,9 @@ export interface MarwesSettings {
12
14
  };
13
15
  /** Where the committed snapshot lives, relative to the project. */
14
16
  snapshot: string;
17
+ /** Explicit project-owned token sets, appended to both modes. */
18
+ extras: PenpotTokenSet[];
19
+ themeLint: ThemeLintSettings;
15
20
  }
16
21
  export declare const defaultSnapshotPath = "design/marwes-tokens.snapshot.json";
17
22
  export declare class MarwesConfigError extends Error {
package/dist/config.js CHANGED
@@ -1,4 +1,6 @@
1
+ import { readThemeLint } from "./theme-lint.js";
1
2
  import path from "node:path";
3
+ import { validateEditableToken } from "@penpair/core";
2
4
  export const defaultSnapshotPath = "design/marwes-tokens.snapshot.json";
3
5
  export class MarwesConfigError extends Error {
4
6
  }
@@ -27,8 +29,42 @@ export function readMarwesSettings(rawConfig, instance) {
27
29
  throw new MarwesConfigError("penpair.config.json: marwes.name must be a short name (letters, digits, spaces, hyphens)");
28
30
  return {
29
31
  name,
32
+ extras: readExtras(v.extras),
33
+ themeLint: readThemeLint(v.themeLint),
30
34
  project: v.project === undefined ? "." : relative(v.project, "project"),
31
35
  themes: { light: themeReference(themes.light, "light"), dark: themeReference(themes.dark, "dark") },
32
36
  snapshot: v.snapshot === undefined ? defaultSnapshotPath : relative(v.snapshot, "snapshot"),
33
37
  };
34
38
  }
39
+ function readExtras(value) {
40
+ if (value === undefined)
41
+ return [];
42
+ if (!Array.isArray(value) || value.length > 50)
43
+ throw new MarwesConfigError("marwes.extras must be an array of at most 50 sets");
44
+ const names = new Set();
45
+ let count = 0;
46
+ return value.map(raw => {
47
+ if (!raw || typeof raw !== "object" || Array.isArray(raw) || Object.keys(raw).some(key => !["name", "tokens"].includes(key)))
48
+ throw new MarwesConfigError("Invalid project extra set");
49
+ const set = raw;
50
+ if (typeof set.name !== "string" || !set.name.trim() || set.name.length > 200 || /[\x00-\x1f]/.test(set.name) || names.has(set.name) || !Array.isArray(set.tokens))
51
+ throw new MarwesConfigError("Invalid or duplicate project extra set");
52
+ names.add(set.name);
53
+ const tokens = new Set();
54
+ return { name: set.name, tokens: set.tokens.map(rawToken => {
55
+ if (++count > 10000)
56
+ throw new MarwesConfigError("Project extras exceed 10000 tokens");
57
+ let token;
58
+ try {
59
+ token = validateEditableToken(rawToken);
60
+ }
61
+ catch (error) {
62
+ throw new MarwesConfigError("marwes.extras: " + String(error));
63
+ }
64
+ if (tokens.has(token.name))
65
+ throw new MarwesConfigError("Duplicate project extra token: " + token.name);
66
+ tokens.add(token.name);
67
+ return token;
68
+ }) };
69
+ });
70
+ }
package/dist/index.d.ts CHANGED
@@ -2,6 +2,13 @@
2
2
  declare const _default: {
3
3
  name: string;
4
4
  commands: import("@penpair/core").AdapterCommand[];
5
+ kits: import("@penpair/core").BuildKit[];
6
+ status(context: import("@penpair/core").AdapterContext): Promise<{
7
+ id: string;
8
+ label: string;
9
+ state: "ok" | "warning";
10
+ detail: string;
11
+ }[]>;
5
12
  };
6
13
  export default _default;
7
14
  export * from "./commands.js";
@@ -11,3 +18,9 @@ export * from "./penpot.js";
11
18
  export * from "./snapshot-file.js";
12
19
  export * from "./theme-input.js";
13
20
  export * from "./tokens.js";
21
+ export * from "./ownership.js";
22
+ export * from "./resolved-values.js";
23
+ export * from "./mode-trial.js";
24
+ export * from "./build-kit.js";
25
+ export * from "./components.js";
26
+ export * from "./theme-lint.js";
package/dist/index.js CHANGED
@@ -1,7 +1,12 @@
1
1
  import { defineAdapter } from "@penpair/core";
2
+ import { marwesKit } from "./build-kit.js";
3
+ import { checkSnapshotFor } from "./commands.js";
2
4
  import { marwesCommand } from "./commands.js";
3
5
  /** The adapter penpair loads when `@penpair/marwes-ui` is listed under "adapters" in penpair.config.json. */
4
- export default defineAdapter({ name: "@penpair/marwes-ui", commands: [marwesCommand] });
6
+ export default defineAdapter({ name: "@penpair/marwes-ui", commands: [marwesCommand], kits: [marwesKit], async status(context) {
7
+ const check = await checkSnapshotFor(context.root, context.rawConfig, context.config.instance);
8
+ return [{ id: "library-tokens", label: "Token snapshot", state: check.current ? "ok" : "warning", detail: check.current ? "Declarations match installed library/theme; live tokens not inspected" : "Missing/stale snapshot or mixed library versions" }];
9
+ } });
5
10
  export * from "./commands.js";
6
11
  export * from "./config.js";
7
12
  export * from "./marwes.js";
@@ -9,3 +14,9 @@ export * from "./penpot.js";
9
14
  export * from "./snapshot-file.js";
10
15
  export * from "./theme-input.js";
11
16
  export * from "./tokens.js";
17
+ export * from "./ownership.js";
18
+ export * from "./resolved-values.js";
19
+ export * from "./mode-trial.js";
20
+ export * from "./build-kit.js";
21
+ export * from "./components.js";
22
+ export * from "./theme-lint.js";
@@ -0,0 +1,95 @@
1
+ import { type JournalOptions, type PenpotToolClient } from "@penpair/core";
2
+ import { type TokenSnapshot } from "./tokens.js";
3
+ interface Selection {
4
+ sets: {
5
+ id: string;
6
+ active: boolean;
7
+ }[];
8
+ themes: {
9
+ id: string;
10
+ active: boolean;
11
+ }[];
12
+ }
13
+ export interface ModeComparison {
14
+ group: string;
15
+ name: string;
16
+ context: "isolated-theme";
17
+ activeSets: string[];
18
+ expectedSets: string[];
19
+ orderMatches: boolean;
20
+ values: {
21
+ name: string;
22
+ type: string;
23
+ expected: unknown;
24
+ actual: unknown;
25
+ equal?: boolean;
26
+ unknown?: string;
27
+ }[];
28
+ complete: boolean;
29
+ matches: boolean;
30
+ sourceHash: string;
31
+ resolutionSamples?: number;
32
+ image?: {
33
+ file: string;
34
+ sha256: string;
35
+ capture: unknown;
36
+ };
37
+ }
38
+ export interface ModeTrialReport {
39
+ schemaVersion: 1;
40
+ kind: "mode-trial";
41
+ redacted?: boolean;
42
+ fileId: string;
43
+ snapshotHash: string;
44
+ sourceHash: string;
45
+ capturedAt: string;
46
+ penpotApiVersion: string | null;
47
+ phase: "inspected" | "measured";
48
+ modes: ModeComparison[];
49
+ plannedThemes: {
50
+ group: string;
51
+ name: string;
52
+ sets: string[];
53
+ }[];
54
+ original: Selection;
55
+ plannedStates: {
56
+ group: string;
57
+ name: string;
58
+ activeSets: string[];
59
+ }[];
60
+ artifacts?: {
61
+ manifest: string;
62
+ generation: string;
63
+ };
64
+ restoration: {
65
+ verified: boolean;
66
+ error?: string;
67
+ };
68
+ complete: boolean;
69
+ verified: boolean;
70
+ uncertain: boolean;
71
+ error?: string;
72
+ journal?: {
73
+ runId: string;
74
+ state: string;
75
+ };
76
+ }
77
+ export interface ModeTrialOptions {
78
+ root: string;
79
+ apply?: boolean;
80
+ resume?: string;
81
+ forceLock?: boolean;
82
+ redact?(text: string): string;
83
+ layout?: false;
84
+ sleep?(milliseconds: number): Promise<void>;
85
+ frame?: {
86
+ id: string;
87
+ page: string;
88
+ out: string;
89
+ };
90
+ onStep?(message: string): void;
91
+ checkpoint?: JournalOptions["checkpoint"];
92
+ requireCheckpoint?: boolean;
93
+ }
94
+ export declare function runModeTrial(client: PenpotToolClient, snapshot: TokenSnapshot, options: ModeTrialOptions): Promise<ModeTrialReport>;
95
+ export {};
@@ -0,0 +1,229 @@
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import { atomicWriteFile, commandSleep, resolveProjectOutput, exportShapePng, guardFile, guardRevision, observationHash, readJournal, readJournalTarget, runJournaledSteps, runSnippet, snippet, waitForStableLayout, withCleanupDeadline, writeCoordinator } from "@penpair/core";
3
+ import { mkdir, rename, rm } from "node:fs/promises";
4
+ import path from "node:path";
5
+ import { canonical } from "./tokens.js";
6
+ import { compareResolved, expectedResolved } from "./resolved-values.js";
7
+ const readBody = `
8
+ const f=penpot.currentFile,c=penpot.library.local.tokens;
9
+ if(!Array.isArray(c.sets)||!Array.isArray(c.themes))throw new Error("Token API unavailable");
10
+ if(c.sets.reduce((n,s)=>n+s.tokens.length,0)>5000)throw new Error("Mode trial exceeds 5000 tokens");
11
+ const source={sets:c.sets.map(s=>({id:s.id,name:s.name,tokens:s.tokens.map(t=>({name:t.name,type:t.type,value:t.value,description:t.description||""}))})),themes:c.themes.map(t=>({id:t.id,group:t.group,name:t.name,sets:(t.activeSets||[]).map(s=>typeof s==="string"?s:s.name)}))};
12
+ if(JSON.stringify(source).length>500000)throw new Error("Mode source exceeds bounded size");
13
+ const selection={sets:c.sets.map(s=>({id:s.id,active:!!s.active})),themes:c.themes.map(t=>({id:t.id,active:!!t.active}))};
14
+ let supported=c.sets.every(set=>typeof set.toggleActive==="function")&&c.themes.every(theme=>typeof theme.toggleActive==="function");const resolved=[];
15
+ for(const set of c.sets)for(const token of set.tokens){if(!("resolvedValue" in token))supported=false;if(!set.active)continue;let value=null,unknown;try{const raw=token.resolvedValue;if(raw===undefined)unknown="API resolvedValue is unavailable";else value=JSON.parse(JSON.stringify(raw));}catch{unknown="Unreadable resolvedValue";}resolved.push({name:token.name,type:token.type,set:set.name,value,...unknown?{unknown}:{}});}
16
+ if(JSON.stringify(resolved).length>500000)throw new Error("Resolved output exceeds bounded size");
17
+ return {ok:true,readOnly:true,fileId:f.id,revision:f.revn,pageId:penpot.currentPage.id,pageName:penpot.currentPage.name,source,selection,supported,resolved,penpotApiVersion:typeof penpot.version==="string"?penpot.version:null};`;
18
+ async function readMode(client, timeoutMs = 10000) {
19
+ const result = await runSnippet(client, snippet("modes-read", readBody, {}, "read"), { effect: "read", retries: 0, timeoutMs });
20
+ if (result.ok !== true || typeof result.fileId !== "string" || !Number.isInteger(result.revision) || !result.source || !result.selection || !Array.isArray(result.resolved))
21
+ throw new Error("Incomplete mode observation");
22
+ const state = result;
23
+ const ids = [...state.source.sets, ...state.source.themes].map(item => item.id);
24
+ if (ids.some(id => typeof id !== "string" || !id) || new Set(ids).size !== ids.length)
25
+ throw new Error("Stable token-set and theme IDs are required for mode trials");
26
+ if (new Set(state.source.sets.map(set => set.name)).size !== state.source.sets.length || new Set(state.source.themes.map(theme => canonical([theme.group, theme.name]))).size !== state.source.themes.length)
27
+ throw new Error("Ambiguous mode identity");
28
+ state.sourceHash = observationHash(state.source);
29
+ return state;
30
+ }
31
+ function desiredSelection(state, group, name) {
32
+ const theme = state.source.themes.find(theme => theme.group === group && theme.name === name);
33
+ if (!theme)
34
+ throw new Error(`Missing mode ${group}/${name}`);
35
+ return { sets: state.source.sets.map(set => ({ id: set.id, active: theme.sets.includes(set.name) })), themes: state.source.themes.map(item => ({ id: item.id, active: item.id === theme.id })) };
36
+ }
37
+ function changeSelection(state, desired) {
38
+ return snippet("modes-select", `
39
+ const observed=await(async()=>{${readBody}})();
40
+ if(JSON.stringify(observed.source)!==CFG.source||JSON.stringify(observed.selection)!==CFG.before)throw new Error("Mode source or selection changed before mutation");
41
+ const c=penpot.library.local.tokens;
42
+ // Direct set changes disable themes; establish flags in a documented order.
43
+ for(const theme of c.themes)if(theme.active)theme.toggleActive();
44
+ for(const set of c.sets)if(set.active)set.toggleActive();
45
+ for(const theme of c.themes)if(CFG.desired.themes.some(t=>t.id===theme.id&&t.active))theme.toggleActive();
46
+ for(const set of c.sets){const wanted=CFG.desired.sets.find(s=>s.id===set.id);if(!wanted)throw new Error("Set identity changed");if(!!set.active!==wanted.active)set.toggleActive();}
47
+ return {ok:true};`, { source: JSON.stringify(state.source), before: JSON.stringify(state.selection), desired }, "write");
48
+ }
49
+ function compareMode(snapshot, state, theme) {
50
+ const activeSets = state.source.sets.filter(set => state.selection.sets.find(item => item.id === set.id)?.active).map(set => set.name);
51
+ const expectedSets = snapshot.sets.filter(set => theme.sets.includes(set.name)).map(set => set.name);
52
+ const actual = new Map(state.resolved.map(token => [token.name, token]));
53
+ const values = expectedResolved(snapshot, theme).map(token => {
54
+ const observed = actual.get(token.name);
55
+ const comparison = observed?.unknown ? { unknown: observed.unknown } : !observed ? { unknown: "No active resolved token" } : observed.type !== token.type ? { equal: false } : compareResolved(token, observed.value);
56
+ return { name: token.name, type: token.type, expected: token.value ?? null, actual: observed?.value ?? null, ...comparison };
57
+ });
58
+ const orderMatches = canonical(activeSets) === canonical(expectedSets);
59
+ return { group: theme.group, name: theme.name, context: "isolated-theme", activeSets, expectedSets, orderMatches, values, complete: values.length > 0 && values.every(value => !value.unknown), matches: orderMatches && values.length > 0 && values.every(value => value.equal === true), sourceHash: state.sourceHash };
60
+ }
61
+ export async function runModeTrial(client, snapshot, options) {
62
+ const target = await readJournalTarget(client);
63
+ client = guardFile(client, { id: target.fileId });
64
+ const initial = await readMode(client);
65
+ if (!initial.supported)
66
+ throw new Error("resolvedValue API unavailable; no theme was changed");
67
+ if (snapshot.themes.length < 1 || snapshot.themes.length > 20)
68
+ throw new Error("Mode trials require 1–20 declared themes");
69
+ let original = initial;
70
+ const runId = options.resume ?? randomUUID();
71
+ if (options.resume) {
72
+ const record = await readJournal(options.root, runId);
73
+ const payload = record.payload;
74
+ if (record.operation !== "tokens-modes" || !payload?.original || record.target.fileId !== target.fileId)
75
+ throw new Error("No matching original mode state in this journal");
76
+ original = payload.original;
77
+ }
78
+ if (original.sourceHash !== initial.sourceHash)
79
+ throw new Error("Token declarations changed since the trial began");
80
+ if (options.frame && (initial.pageId !== original.pageId || initial.pageName !== options.frame.page))
81
+ throw new Error("Open the requested frame page before inspecting a mode trial");
82
+ if (options.frame) {
83
+ const frame = await runSnippet(client, snippet("modes-frame", "if(penpot.currentPage.id!==CFG.pageId)throw new Error('Frame page changed');const shape=penpot.currentPage.getShapeById(CFG.id);if(!shape||typeof shape.export!=='function')throw new Error('Requested exportable frame is unavailable');return {ok:true,frameId:shape.id};", { id: options.frame.id, pageId: initial.pageId }, "read"), { effect: "read", retries: 0 });
84
+ if (frame.ok !== true || frame.frameId !== options.frame.id)
85
+ throw new Error("Frame identity was not observed");
86
+ }
87
+ const selections = snapshot.themes.map(theme => desiredSelection(original, theme.group, theme.name));
88
+ const base = { schemaVersion: 1, kind: "mode-trial", fileId: target.fileId, snapshotHash: snapshot.hash, sourceHash: original.sourceHash, capturedAt: new Date().toISOString(), penpotApiVersion: initial.penpotApiVersion, phase: "inspected", modes: [], plannedThemes: snapshot.themes, original: original.selection, plannedStates: snapshot.themes.map((theme, index) => ({ group: theme.group, name: theme.name, activeSets: original.source.sets.filter(set => selections[index].sets.find(item => item.id === set.id)?.active).map(set => set.name) })), restoration: { verified: false }, complete: true, verified: false, uncertain: false };
89
+ if (!options.apply)
90
+ return base;
91
+ let report = base;
92
+ const settle = async () => { if (options.layout !== false) {
93
+ const result = await waitForStableLayout(client, { timeoutMs: 10000 });
94
+ if (!result.stable)
95
+ throw new Error("Layout did not settle for mode evidence");
96
+ } };
97
+ const inspect = async () => { await settle(); return readMode(client); };
98
+ const stableMode = async () => {
99
+ let previous = await inspect();
100
+ const deadline = Date.now() + 5000;
101
+ for (let samples = 2; samples <= 20 && Date.now() < deadline; samples++) {
102
+ await (options.sleep ?? commandSleep)(100);
103
+ const current = await readMode(client, Math.max(1, deadline - Date.now()));
104
+ if (Date.now() > deadline)
105
+ throw new Error("Resolved observation exceeded its deadline");
106
+ if (current.sourceHash !== previous.sourceHash || canonical(current.selection) !== canonical(previous.selection))
107
+ throw new Error("Source or selection changed while observing resolution");
108
+ if (canonical(current.resolved) === canonical(previous.resolved))
109
+ return { state: current, samples };
110
+ previous = current;
111
+ }
112
+ throw new Error("Resolved values did not stabilize within the observation budget");
113
+ };
114
+ const sourceOkay = (state) => state.sourceHash === original.sourceHash;
115
+ const originalOkay = (state) => sourceOkay(state) && canonical(state.selection) === canonical(original.selection);
116
+ const select = async (state, desired) => {
117
+ if (!sourceOkay(state))
118
+ throw new Error("Token declarations changed during trial");
119
+ if (canonical(state.selection) === canonical(desired))
120
+ return;
121
+ const current = await readJournalTarget(client);
122
+ await runSnippet(guardRevision(client, current), changeSelection(state, desired), { effect: "write", timeoutMs: 10000 });
123
+ };
124
+ const pendingImages = [];
125
+ try {
126
+ const record = await runJournaledSteps([{ key: "trial", label: "measure isolated modes and restore full selection", initial,
127
+ signature: [readBody, changeSelection.toString(), compareMode.toString(), compareResolved.toString(), expectedResolved.toString(), runModeTrial.toString()].join("\n"),
128
+ inspect,
129
+ apply: async () => {
130
+ report = { ...base, phase: "measured", journal: { runId, state: "running" } };
131
+ try {
132
+ for (let index = 0; index < snapshot.themes.length; index++) {
133
+ options.onStep?.(`measure ${snapshot.themes[index].group}/${snapshot.themes[index].name}`);
134
+ await select(await inspect(), selections[index]);
135
+ const evidence = await stableMode(), observed = evidence.state;
136
+ if (!sourceOkay(observed) || canonical(observed.selection) !== canonical(selections[index]))
137
+ throw new Error("Theme activation did not establish the isolated requested selection");
138
+ const mode = compareMode(snapshot, observed, snapshot.themes[index]);
139
+ mode.resolutionSamples = evidence.samples;
140
+ report.modes.push(mode);
141
+ if (options.frame) {
142
+ const image = await exportShapePng(client, options.frame.id, { maxExports: 4, attempts: 1, sleep: options.sleep });
143
+ const after = await readMode(client);
144
+ if (!image.stable || !sourceOkay(after) || after.pageId !== original.pageId || canonical(after.selection) !== canonical(selections[index]) || canonical(after.resolved) !== canonical(observed.resolved))
145
+ throw new Error("Mode changed during export");
146
+ const name = `${index + 1}.png`;
147
+ pendingImages.push({ name, bytes: image.png });
148
+ mode.image = { file: name, sha256: createHash("sha256").update(image.png).digest("hex"), capture: image.capture };
149
+ }
150
+ }
151
+ }
152
+ catch (error) {
153
+ report.error = error instanceof Error ? error.message : String(error);
154
+ report.uncertain = error.code === "write-uncertain";
155
+ }
156
+ finally {
157
+ try {
158
+ await withCleanupDeadline(async () => {
159
+ const current = await readMode(client);
160
+ await select(current, original.selection);
161
+ const restored = await inspect();
162
+ report.restoration = { verified: originalOkay(restored) };
163
+ if (!report.restoration.verified)
164
+ report.restoration.error = "Independent readback differs from the original complete selection";
165
+ }, 10000);
166
+ }
167
+ catch (error) {
168
+ report.restoration = { verified: false, error: error instanceof Error ? error.message : String(error) };
169
+ }
170
+ }
171
+ report.complete = !report.error && report.modes.length === snapshot.themes.length && report.modes.every(mode => mode.complete) && report.restoration.verified;
172
+ report.verified = report.complete && report.modes.every(mode => mode.matches);
173
+ return report;
174
+ },
175
+ verify: (current, _before, result) => originalOkay(current) && result?.restoration.verified === true,
176
+ reconcile: (_before, current, result) => !sourceOkay(current) ? "conflict" : originalOkay(current) && result?.restoration.verified === true ? "complete" : [original.selection, ...selections].some(selection => canonical(selection) === canonical(current.selection)) ? "pending" : "conflict",
177
+ }], { root: options.root, runId, resume: Boolean(options.resume), operation: "tokens-modes", input: { snapshot: observationHash(snapshot), frame: options.frame ?? null, layout: options.layout ?? {} }, payload: { original }, target: () => readJournalTarget(client), redact: options.redact, coordinate: writeCoordinator(client, { root: options.root, operation: "tokens-modes", forceLock: options.forceLock, warn: options.onStep }), checkpoint: options.checkpoint, requireCheckpoint: options.requireCheckpoint, onStep: options.onStep });
178
+ report = record.steps[0].result;
179
+ if (!report)
180
+ throw new Error("Mode journal lacks measured results");
181
+ report.journal = { runId, state: record.state };
182
+ if (record.redacted) {
183
+ report.redacted = true;
184
+ report.complete = false;
185
+ report.verified = false;
186
+ report.error = "Journal required credential redaction; evidence is incomplete";
187
+ }
188
+ }
189
+ catch (error) {
190
+ try {
191
+ const record = await readJournal(options.root, runId);
192
+ report = record.steps[0]?.result ?? report;
193
+ report.journal = { runId, state: record.state };
194
+ if (record.redacted)
195
+ report.redacted = true;
196
+ }
197
+ catch { /* Preserve the actual failure. */ }
198
+ report.complete = false;
199
+ report.verified = false;
200
+ report.error ??= error instanceof Error ? error.message : String(error);
201
+ report.uncertain ||= error.outcome === "uncertain";
202
+ }
203
+ if (options.frame && report.complete && pendingImages.length !== snapshot.themes.length) {
204
+ report.complete = false;
205
+ report.verified = false;
206
+ report.error = "Recovered measurement lacks image bytes; start a fresh trial to publish exports";
207
+ }
208
+ if (options.frame && report.complete && pendingImages.length === snapshot.themes.length)
209
+ await publishModes(options.root, options.frame.out, report, pendingImages, options.redact);
210
+ return report;
211
+ }
212
+ async function publishModes(root, out, report, images, redact) {
213
+ const destination = await resolveProjectOutput(root, out), generation = randomUUID(), stage = path.join(destination, "." + generation), final = path.join(destination, generation);
214
+ report.artifacts = { manifest: path.join(destination, "manifest.json"), generation };
215
+ const serialized = JSON.stringify(report, null, 2) + "\n";
216
+ if (redact && redact(serialized) !== serialized)
217
+ throw new Error("Mode evidence requires credential redaction; previous output preserved");
218
+ await mkdir(stage, { recursive: true });
219
+ try {
220
+ for (const image of images)
221
+ await atomicWriteFile(path.join(stage, image.name), image.bytes);
222
+ await atomicWriteFile(path.join(stage, "manifest.json"), serialized);
223
+ await rename(stage, final);
224
+ await atomicWriteFile(path.join(destination, "manifest.json"), JSON.stringify({ schemaVersion: 1, generation, manifest: `${generation}/manifest.json`, sha256: createHash("sha256").update(serialized).digest("hex") }, null, 2) + "\n", { replace: true });
225
+ }
226
+ finally {
227
+ await rm(stage, { recursive: true, force: true });
228
+ }
229
+ }