@penpair/marwes-ui 0.1.3 → 0.3.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.
@@ -1,87 +1,329 @@
1
- import { mkdir, readFile, writeFile } from "node:fs/promises";
1
+ import { createHash } from "node:crypto";
2
+ import { mkdir, readFile } from "node:fs/promises";
2
3
  import path from "node:path";
4
+ import { atomicWriteFile, resolveProjectInput, resolveProjectOutput, validateEditableToken, } from "@penpair/core";
3
5
  import { canonical } from "./tokens.js";
4
6
  export async function readSnapshot(root, file) {
5
7
  try {
6
- return JSON.parse(await readFile(path.join(root, file), "utf8"));
8
+ const snapshot = JSON.parse(await readFile(await resolveProjectInput(root, file), "utf8"));
9
+ if (![
10
+ 1,
11
+ 2,
12
+ ].includes(snapshot.schemaVersion) ||
13
+ !Array.isArray(snapshot.sets) ||
14
+ !Array.isArray(snapshot.themes) ||
15
+ typeof snapshot.hash !== "string" ||
16
+ typeof snapshot.project !== "string") {
17
+ throw new Error("Unsupported or malformed snapshot; regenerate with tokens snapshot --write");
18
+ }
19
+ if (snapshot.schemaVersion === 2 &&
20
+ (!snapshot.ownership ||
21
+ Object.keys(snapshot.ownership).length !== snapshot.sets.length ||
22
+ snapshot.sets.some((set) => ![
23
+ "adapter",
24
+ "project",
25
+ ].includes(snapshot.ownership?.[set.name] ?? "")))) {
26
+ throw new Error("Invalid schema-2 set ownership");
27
+ }
28
+ return snapshot;
7
29
  }
8
30
  catch (error) {
9
- if (error.code === "ENOENT")
31
+ if (error.code === "ENOENT") {
10
32
  return undefined;
33
+ }
11
34
  throw new Error(`${file} is not a readable snapshot: ${String(error.message)}`);
12
35
  }
13
36
  }
14
37
  export async function writeSnapshot(root, file, snapshot) {
15
- const target = path.join(root, file);
16
- await mkdir(path.dirname(target), { recursive: true });
17
- await writeFile(target, `${JSON.stringify(snapshot, null, 2)}\n`);
38
+ const target = await resolveProjectOutput(root, file);
39
+ await mkdir(path.dirname(target), {
40
+ recursive: true,
41
+ });
42
+ await atomicWriteFile(target, `${JSON.stringify(snapshot, null, 2)}\n`, {
43
+ replace: true,
44
+ });
18
45
  }
19
- const show = (token) => (typeof token.value === "string" ? token.value : canonical(token.value));
46
+ const show = (token) => typeof token.value === "string" ? token.value : canonical(token.value);
20
47
  /** What differs between two expectations: the Marwes version, then set by set which tokens were added, removed or changed. */
21
48
  export function diffSnapshots(before, after) {
22
49
  const sets = [];
23
- const previous = new Map((before?.sets ?? []).map((s) => [s.name, s]));
24
- const next = new Map(after.sets.map((s) => [s.name, s]));
50
+ const previous = new Map((before?.sets ?? []).map((s) => [
51
+ s.name,
52
+ s,
53
+ ]));
54
+ const next = new Map(after.sets.map((s) => [
55
+ s.name,
56
+ s,
57
+ ]));
25
58
  for (const [name, set] of next) {
26
59
  const old = previous.get(name);
27
- const oldTokens = new Map((old?.tokens ?? []).map((t) => [t.name, t]));
28
- const newTokens = new Map(set.tokens.map((t) => [t.name, t]));
60
+ const oldTokens = new Map((old?.tokens ?? []).map((t) => [
61
+ t.name,
62
+ t,
63
+ ]));
64
+ const newTokens = new Map(set.tokens.map((t) => [
65
+ t.name,
66
+ t,
67
+ ]));
29
68
  const change = {
30
69
  set: name,
31
- ...(old ? {} : { setAdded: true }),
32
- added: [...newTokens.keys()].filter((n) => !oldTokens.has(n)),
33
- removed: [...oldTokens.keys()].filter((n) => !newTokens.has(n)),
34
- changed: [...newTokens.entries()].filter(([n, t]) => oldTokens.has(n) && show(oldTokens.get(n)) !== show(t)).map(([n, t]) => ({ name: n, before: show(oldTokens.get(n)), after: show(t) })),
70
+ ...(old
71
+ ? {}
72
+ : {
73
+ setAdded: true,
74
+ }),
75
+ added: [
76
+ ...newTokens.keys(),
77
+ ].filter((n) => !oldTokens.has(n)),
78
+ removed: [
79
+ ...oldTokens.keys(),
80
+ ].filter((n) => !newTokens.has(n)),
81
+ changed: [
82
+ ...newTokens.entries(),
83
+ ]
84
+ .filter(([n, t]) => oldTokens.has(n) && show(oldTokens.get(n)) !== show(t))
85
+ .map(([n, t]) => ({
86
+ name: n,
87
+ before: show(oldTokens.get(n)),
88
+ after: show(t),
89
+ })),
35
90
  };
36
- if (change.setAdded || change.added.length || change.removed.length || change.changed.length)
91
+ if (change.setAdded || change.added.length || change.removed.length || change.changed.length) {
37
92
  sets.push(change);
93
+ }
38
94
  }
39
- for (const [name, old] of previous)
40
- if (!next.has(name))
41
- sets.push({ set: name, setRemoved: true, added: [], removed: old.tokens.map((t) => t.name), changed: [] });
42
- const oldThemes = new Map((before?.themes ?? []).map((t) => [`${t.group}/${t.name}`, t.sets]));
43
- const newThemes = new Map(after.themes.map((t) => [`${t.group}/${t.name}`, t.sets]));
95
+ for (const [name, old] of previous) {
96
+ if (!next.has(name)) {
97
+ sets.push({
98
+ set: name,
99
+ setRemoved: true,
100
+ added: [],
101
+ removed: old.tokens.map((t) => t.name),
102
+ changed: [],
103
+ });
104
+ }
105
+ }
106
+ const oldThemes = new Map((before?.themes ?? []).map((t) => [
107
+ `${t.group}/${t.name}`,
108
+ t.sets,
109
+ ]));
110
+ const newThemes = new Map(after.themes.map((t) => [
111
+ `${t.group}/${t.name}`,
112
+ t.sets,
113
+ ]));
44
114
  const themes = {
45
- added: [...newThemes.keys()].filter((k) => !oldThemes.has(k)),
46
- removed: [...oldThemes.keys()].filter((k) => !newThemes.has(k)),
47
- changed: [...newThemes.entries()].filter(([k, s]) => oldThemes.has(k) && canonical(oldThemes.get(k)) !== canonical(s)).map(([k, s]) => ({ theme: k, before: oldThemes.get(k), after: s })),
115
+ added: [
116
+ ...newThemes.keys(),
117
+ ].filter((k) => !oldThemes.has(k)),
118
+ removed: [
119
+ ...oldThemes.keys(),
120
+ ].filter((k) => !newThemes.has(k)),
121
+ changed: [
122
+ ...newThemes.entries(),
123
+ ]
124
+ .filter(([k, s]) => oldThemes.has(k) && canonical(oldThemes.get(k)) !== canonical(s))
125
+ .map(([k, s]) => ({
126
+ theme: k,
127
+ before: oldThemes.get(k),
128
+ after: s,
129
+ })),
48
130
  };
49
131
  const wasUnmapped = new Set(before?.unmapped ?? []);
50
132
  const isUnmapped = new Set(after.unmapped ?? []);
51
- const unmapped = { added: [...isUnmapped].filter((v) => !wasUnmapped.has(v)), removed: [...wasUnmapped].filter((v) => !isUnmapped.has(v)) };
52
- const versionChange = before && before.marwesVersion !== after.marwesVersion ? { before: before.marwesVersion, after: after.marwesVersion } : undefined;
53
- const identical = !!before && !versionChange && sets.length === 0 && !themes.added.length && !themes.removed.length && !themes.changed.length && !unmapped.added.length && !unmapped.removed.length;
54
- return { identical, ...(versionChange ? { versionChange } : {}), sets, themes, unmapped };
133
+ const unmapped = {
134
+ added: [
135
+ ...isUnmapped,
136
+ ].filter((v) => !wasUnmapped.has(v)),
137
+ removed: [
138
+ ...wasUnmapped,
139
+ ].filter((v) => !isUnmapped.has(v)),
140
+ };
141
+ const versionChange = before && before.marwesVersion !== after.marwesVersion
142
+ ? {
143
+ before: before.marwesVersion,
144
+ after: after.marwesVersion,
145
+ }
146
+ : undefined;
147
+ const identical = !!before &&
148
+ !versionChange &&
149
+ sets.length === 0 &&
150
+ !themes.added.length &&
151
+ !themes.removed.length &&
152
+ !themes.changed.length &&
153
+ !unmapped.added.length &&
154
+ !unmapped.removed.length;
155
+ return {
156
+ identical,
157
+ ...(versionChange
158
+ ? {
159
+ versionChange,
160
+ }
161
+ : {}),
162
+ sets,
163
+ themes,
164
+ unmapped,
165
+ };
55
166
  }
56
167
  /** A short, readable account of a diff for the terminal; long lists are cut with a count. */
57
168
  export function formatDiff(diff, limit = 8) {
58
169
  const lines = [];
59
- if (diff.versionChange)
170
+ if (diff.versionChange) {
60
171
  lines.push(`Marwes ${diff.versionChange.before} -> ${diff.versionChange.after}`);
61
- const cut = (items) => (items.length > limit ? `${items.slice(0, limit).join(", ")} and ${items.length - limit} more` : items.join(", "));
172
+ }
173
+ const cut = (items) => items.length > limit
174
+ ? `${items.slice(0, limit).join(", ")} and ${items.length - limit} more`
175
+ : items.join(", ");
62
176
  for (const s of diff.sets) {
63
- if (s.setAdded)
177
+ if (s.setAdded) {
64
178
  lines.push(`${s.set}: new set with ${s.added.length} tokens`);
65
- else if (s.setRemoved)
179
+ }
180
+ else if (s.setRemoved) {
66
181
  lines.push(`${s.set}: set no longer expected (${s.removed.length} tokens)`);
182
+ }
67
183
  else {
68
- if (s.added.length)
184
+ if (s.added.length) {
69
185
  lines.push(`${s.set}: ${s.added.length} added: ${cut(s.added)}`);
70
- if (s.removed.length)
186
+ }
187
+ if (s.removed.length) {
71
188
  lines.push(`${s.set}: ${s.removed.length} removed: ${cut(s.removed)}`);
72
- if (s.changed.length)
189
+ }
190
+ if (s.changed.length) {
73
191
  lines.push(`${s.set}: ${s.changed.length} changed: ${cut(s.changed.map((c) => `${c.name} ${c.before} -> ${c.after}`))}`);
192
+ }
74
193
  }
75
194
  }
76
- if (diff.unmapped.added.length)
195
+ if (diff.unmapped.added.length) {
77
196
  lines.push(`NEW Marwes variables that are not tokens (the design file does not know them yet; review, then extend the adapter or accept): ${cut(diff.unmapped.added)}`);
78
- if (diff.unmapped.removed.length)
197
+ }
198
+ if (diff.unmapped.removed.length) {
79
199
  lines.push(`Marwes variables that are no longer provided: ${cut(diff.unmapped.removed)}`);
80
- for (const t of diff.themes.added)
200
+ }
201
+ for (const t of diff.themes.added) {
81
202
  lines.push(`theme ${t}: new`);
82
- for (const t of diff.themes.removed)
203
+ }
204
+ for (const t of diff.themes.removed) {
83
205
  lines.push(`theme ${t}: no longer expected`);
84
- for (const t of diff.themes.changed)
206
+ }
207
+ for (const t of diff.themes.changed) {
85
208
  lines.push(`theme ${t.theme}: sets ${t.before.join(" + ")} -> ${t.after.join(" + ")}`);
209
+ }
86
210
  return lines;
87
211
  }
212
+ /** Explicitly classify every legacy set; names cannot establish ownership. Original fields survive migration. */
213
+ export function migrateTokenSnapshot(raw, ownership) {
214
+ const source = raw;
215
+ if (!source ||
216
+ source.schemaVersion !== 1 ||
217
+ !Array.isArray(source.sets) ||
218
+ !Array.isArray(source.themes) ||
219
+ !Array.isArray(source.unmapped) ||
220
+ typeof source.marwesVersion !== "string" ||
221
+ typeof source.project !== "string") {
222
+ throw new Error("Migration requires a readable legacy schema-1 token snapshot; unsupported majors need producer guidance");
223
+ }
224
+ if (!ownership ||
225
+ typeof ownership !== "object" ||
226
+ Array.isArray(ownership) ||
227
+ Object.keys(ownership).length !== source.sets.length ||
228
+ source.sets.some((set) => ![
229
+ "adapter",
230
+ "project",
231
+ ].includes(ownership[set.name] ?? "")) ||
232
+ new Set(source.sets.map((set) => set.name)).size !== source.sets.length) {
233
+ throw new Error("Explicit adapter/project ownership is required for every legacy set, with no extra names");
234
+ }
235
+ for (const set of source.sets) {
236
+ if (!set || typeof set.name !== "string" || !Array.isArray(set.tokens)) {
237
+ throw new Error("Malformed legacy set");
238
+ }
239
+ const names = new Set();
240
+ for (const token of set.tokens) {
241
+ validateEditableToken(token);
242
+ if (names.has(token.name)) {
243
+ throw new Error("Duplicate legacy token");
244
+ }
245
+ names.add(token.name);
246
+ }
247
+ }
248
+ if (source.unmapped.some((value) => typeof value !== "string") ||
249
+ source.themes.some((theme) => !theme ||
250
+ typeof theme.group !== "string" ||
251
+ typeof theme.name !== "string" ||
252
+ !Array.isArray(theme.sets) ||
253
+ theme.sets.some((name) => !source.sets.some((set) => set.name === name)))) {
254
+ throw new Error("Malformed legacy themes/unmapped variables");
255
+ }
256
+ const migrated = {
257
+ ...structuredClone(source),
258
+ schemaVersion: 2,
259
+ ownership: structuredClone(ownership),
260
+ };
261
+ const { marwesVersion, project, sets, themes, unmapped } = migrated;
262
+ migrated.hash = createHash("sha256")
263
+ .update(canonical({
264
+ marwesVersion,
265
+ project,
266
+ sets,
267
+ themes,
268
+ unmapped,
269
+ ownership: migrated.ownership,
270
+ }))
271
+ .digest("hex");
272
+ return migrated;
273
+ }
274
+ export const tokenSnapshotSchema = {
275
+ $schema: "https://json-schema.org/draft/2020-12/schema",
276
+ $id: "urn:penpair:schema:v2:adapter-token-snapshot",
277
+ type: "object",
278
+ required: [
279
+ "schemaVersion",
280
+ "ownership",
281
+ "marwesVersion",
282
+ "generatedBy",
283
+ "project",
284
+ "hash",
285
+ "sets",
286
+ "themes",
287
+ "unmapped",
288
+ ],
289
+ properties: {
290
+ schemaVersion: {
291
+ const: 2,
292
+ },
293
+ ownership: {
294
+ type: "object",
295
+ additionalProperties: {
296
+ enum: [
297
+ "adapter",
298
+ "project",
299
+ ],
300
+ },
301
+ },
302
+ marwesVersion: {
303
+ type: "string",
304
+ },
305
+ generatedBy: {
306
+ type: "string",
307
+ },
308
+ project: {
309
+ type: "string",
310
+ },
311
+ hash: {
312
+ type: "string",
313
+ pattern: "^[a-f0-9]{64}$",
314
+ },
315
+ sets: {
316
+ type: "array",
317
+ },
318
+ themes: {
319
+ type: "array",
320
+ },
321
+ unmapped: {
322
+ type: "array",
323
+ items: {
324
+ type: "string",
325
+ },
326
+ },
327
+ },
328
+ additionalProperties: true,
329
+ };
@@ -1,21 +1,32 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import { readFile } from "node:fs/promises";
3
- import path from "node:path";
3
+ import { resolveProjectInput } from "@penpair/core";
4
4
  import { createJiti } from "jiti";
5
5
  /** Loads a theme the project already has, as the app itself would: the same file, the same export. */
6
6
  export async function loadThemeInput(root, reference) {
7
- const file = path.resolve(root, reference.module);
7
+ let file;
8
8
  let source;
9
9
  try {
10
+ file = await resolveProjectInput(root, reference.module);
10
11
  source = await readFile(file);
11
12
  }
12
- catch {
13
- throw new Error(`The theme file ${reference.module} does not exist (marwes.themes in penpair.config.json).`);
13
+ catch (error) {
14
+ if (error.code === "ENOENT") {
15
+ throw new Error(`The theme file ${reference.module} does not exist (marwes.themes in penpair.config.json).`);
16
+ }
17
+ throw error;
14
18
  }
15
- const jiti = createJiti(file, { interopDefault: true, moduleCache: false });
19
+ const jiti = createJiti(file, {
20
+ interopDefault: true,
21
+ moduleCache: false,
22
+ });
16
23
  const loaded = (await jiti.import(file));
17
24
  const input = loaded[reference.export];
18
- if (typeof input !== "object" || input === null)
25
+ if (typeof input !== "object" || input === null) {
19
26
  throw new Error(`${reference.module} has no object export named ${reference.export}.`);
20
- return { input: input, fileHash: createHash("sha256").update(source).digest("hex").slice(0, 12) };
27
+ }
28
+ return {
29
+ input: input,
30
+ fileHash: createHash("sha256").update(source).digest("hex").slice(0, 12),
31
+ };
21
32
  }
@@ -0,0 +1,31 @@
1
+ import type { TokenSnapshot } from "./tokens.js";
2
+ export interface ThemeLintSettings {
3
+ hueThresholdDegrees?: number;
4
+ suppressions?: Array<{
5
+ theme: "light" | "dark";
6
+ token: string;
7
+ reason: string;
8
+ }>;
9
+ }
10
+ export declare function readThemeLint(value: unknown): ThemeLintSettings;
11
+ export declare function lintThemeFamily(defaults: TokenSnapshot, project: TokenSnapshot, rawSettings?: ThemeLintSettings): {
12
+ schemaVersion: number;
13
+ kind: string;
14
+ advisory: boolean;
15
+ warnings: {
16
+ theme: string;
17
+ token: string;
18
+ default: unknown;
19
+ actual: unknown;
20
+ primary: unknown;
21
+ hueDistance: number;
22
+ suppression?: string;
23
+ }[];
24
+ unknown: {
25
+ theme: string;
26
+ token: string;
27
+ reason: string;
28
+ }[];
29
+ assumptions: string[];
30
+ changed: boolean;
31
+ };
@@ -0,0 +1,145 @@
1
+ export function readThemeLint(value) {
2
+ if (value === undefined) {
3
+ return {};
4
+ }
5
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
6
+ throw new Error("marwes.themeLint must be an object");
7
+ }
8
+ const v = value;
9
+ if (Object.keys(v).some((k) => ![
10
+ "hueThresholdDegrees",
11
+ "suppressions",
12
+ ].includes(k)) ||
13
+ (v.hueThresholdDegrees !== undefined &&
14
+ (!Number.isFinite(v.hueThresholdDegrees) ||
15
+ v.hueThresholdDegrees < 0 ||
16
+ v.hueThresholdDegrees > 180))) {
17
+ throw new Error("Invalid theme lint threshold/settings");
18
+ }
19
+ if (v.suppressions !== undefined &&
20
+ (!Array.isArray(v.suppressions) ||
21
+ v.suppressions.length > 1000 ||
22
+ v.suppressions.some((s) => !s ||
23
+ ![
24
+ "light",
25
+ "dark",
26
+ ].includes(s.theme) ||
27
+ typeof s.token !== "string" ||
28
+ !s.token ||
29
+ typeof s.reason !== "string" ||
30
+ !s.reason.trim() ||
31
+ s.reason.length > 1000))) {
32
+ throw new Error("Theme lint suppressions need exact mode/token and a reason");
33
+ }
34
+ return structuredClone(v);
35
+ }
36
+ function hue(value) {
37
+ if (typeof value !== "string" || !/^#[\da-f]{6}(?:ff)?$/i.test(value)) {
38
+ return;
39
+ }
40
+ const [r, g, b] = [
41
+ 1,
42
+ 3,
43
+ 5,
44
+ ].map((i) => parseInt(value.slice(i, i + 2), 16) / 255);
45
+ const max = Math.max(r, g, b);
46
+ const min = Math.min(r, g, b);
47
+ const delta = max - min;
48
+ if (!delta) {
49
+ return {
50
+ hue: 0,
51
+ saturation: 0,
52
+ };
53
+ }
54
+ let h = max === r ? ((g - b) / delta) % 6 : max === g ? (b - r) / delta + 2 : (r - g) / delta + 4;
55
+ h = (((h * 60) % 360) + 360) % 360;
56
+ return {
57
+ hue: h,
58
+ saturation: max === 0 ? 0 : delta / max,
59
+ };
60
+ }
61
+ function colors(snapshot, mode) {
62
+ const result = new Map();
63
+ const theme = snapshot.themes.find((t) => t.group === "Mode" && t.name.toLowerCase() === mode);
64
+ for (const name of theme?.sets ?? []) {
65
+ for (const token of snapshot.sets.find((s) => s.name === name)?.tokens ?? []) {
66
+ if (token.type === "color") {
67
+ result.set(token.name, token.value);
68
+ }
69
+ }
70
+ }
71
+ return result;
72
+ }
73
+ export function lintThemeFamily(defaults, project, rawSettings = {}) {
74
+ const settings = readThemeLint(rawSettings);
75
+ const warnings = [];
76
+ const unknown = [];
77
+ for (const mode of [
78
+ "light",
79
+ "dark",
80
+ ]) {
81
+ const base = colors(defaults, mode);
82
+ const current = colors(project, mode);
83
+ const anchor = "mw-color-primary-base";
84
+ const primary = current.get(anchor);
85
+ const original = base.get(anchor);
86
+ if (primary === undefined || original === undefined || primary === original) {
87
+ continue;
88
+ }
89
+ const anchorHue = hue(primary);
90
+ if (!anchorHue || anchorHue.saturation < 0.15) {
91
+ unknown.push({
92
+ theme: mode,
93
+ token: anchor,
94
+ reason: "Primary is transparent, non-literal or achromatic; hue comparison is not meaningful",
95
+ });
96
+ continue;
97
+ }
98
+ for (const [token, value] of current) {
99
+ if (!/^mw-color-(?:border-brand|text-(?:brand|link)|focus(?:-.+)?|surface-brand|secondary(?:-.+)?)$/.test(token) ||
100
+ value !== base.get(token)) {
101
+ continue;
102
+ }
103
+ const other = hue(value);
104
+ if (!other || other.saturation < 0.15) {
105
+ unknown.push({
106
+ theme: mode,
107
+ token,
108
+ reason: "Default sibling hue is unavailable or achromatic",
109
+ });
110
+ continue;
111
+ }
112
+ const delta = Math.abs(anchorHue.hue - other.hue);
113
+ const distance = Math.min(delta, 360 - delta);
114
+ if (distance < (settings.hueThresholdDegrees ?? 30)) {
115
+ continue;
116
+ }
117
+ const suppression = settings.suppressions?.find((s) => s.theme === mode && s.token === token)?.reason;
118
+ warnings.push({
119
+ theme: mode,
120
+ token,
121
+ default: base.get(token),
122
+ actual: value,
123
+ primary,
124
+ hueDistance: distance,
125
+ ...(suppression
126
+ ? {
127
+ suppression,
128
+ }
129
+ : {}),
130
+ });
131
+ }
132
+ }
133
+ return {
134
+ schemaVersion: 1,
135
+ kind: "theme-family-lint",
136
+ advisory: true,
137
+ warnings,
138
+ unknown,
139
+ assumptions: [
140
+ "Hue distance on opaque literal colors is a heuristic, not a brand or accessibility rule.",
141
+ "Unchanged family defaults may be intentional; retain a reasoned suppression rather than rewriting them automatically.",
142
+ ],
143
+ changed: false,
144
+ };
145
+ }
package/dist/tokens.d.ts CHANGED
@@ -1,4 +1,5 @@
1
- export type PenpotTokenType = "color" | "spacing" | "sizing" | "borderRadius" | "fontFamilies" | "typography";
1
+ import { tokenTypes } from "@penpair/core";
2
+ export type PenpotTokenType = (typeof tokenTypes)[number];
2
3
  export interface PenpotToken {
3
4
  name: string;
4
5
  type: PenpotTokenType;
@@ -17,7 +18,9 @@ export interface PenpotTokenTheme {
17
18
  }
18
19
  /** What the Penpot file is expected to hold: generated from the installed Marwes and the project's theme, committed to the project. */
19
20
  export interface TokenSnapshot {
20
- schemaVersion: 1;
21
+ schemaVersion: 1 | 2;
22
+ /** Version 2 explicitly records ownership. Missing only in legacy v1 inputs. */
23
+ ownership?: Record<string, "adapter" | "project">;
21
24
  /** The Marwes version the values came from. */
22
25
  marwesVersion: string;
23
26
  /** What produced the file, so a reader knows not to edit it by hand. */
@@ -64,6 +67,7 @@ export interface SnapshotInput {
64
67
  /** The project's name, which prefixes its overlay sets. */
65
68
  name: string;
66
69
  generatedBy: string;
70
+ extras?: PenpotTokenSet[];
67
71
  }
68
72
  /**
69
73
  * The Penpot token sets for Marwes and for the project, generated from the installed Marwes. Light values live in the core set,