@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.
- package/dist/build-kit.d.ts +3 -0
- package/dist/build-kit.js +93 -0
- package/dist/commands.d.ts +4 -0
- package/dist/commands.js +633 -92
- package/dist/components.d.ts +95 -0
- package/dist/components.js +497 -0
- package/dist/config.d.ts +5 -0
- package/dist/config.js +80 -7
- package/dist/index.d.ts +13 -0
- package/dist/index.js +30 -1
- package/dist/marwes.js +45 -12
- package/dist/mode-trial.d.ts +95 -0
- package/dist/mode-trial.js +479 -0
- package/dist/ownership.d.ts +26 -0
- package/dist/ownership.js +222 -0
- package/dist/penpot.d.ts +21 -3
- package/dist/penpot.js +519 -87
- package/dist/reconciliation.d.ts +6 -0
- package/dist/reconciliation.js +179 -0
- package/dist/resolved-values.d.ts +11 -0
- package/dist/resolved-values.js +124 -0
- package/dist/snapshot-file.d.ts +45 -0
- package/dist/snapshot-file.js +282 -40
- package/dist/theme-input.js +18 -7
- package/dist/theme-lint.d.ts +31 -0
- package/dist/theme-lint.js +145 -0
- package/dist/tokens.d.ts +6 -2
- package/dist/tokens.js +221 -45
- package/package.json +2 -2
package/dist/snapshot-file.js
CHANGED
|
@@ -1,87 +1,329 @@
|
|
|
1
|
-
import {
|
|
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
|
-
|
|
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 =
|
|
16
|
-
await mkdir(path.dirname(target), {
|
|
17
|
-
|
|
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) =>
|
|
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) => [
|
|
24
|
-
|
|
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) => [
|
|
28
|
-
|
|
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
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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({
|
|
42
|
-
|
|
43
|
-
|
|
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: [
|
|
46
|
-
|
|
47
|
-
|
|
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 = {
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
186
|
+
}
|
|
187
|
+
if (s.removed.length) {
|
|
71
188
|
lines.push(`${s.set}: ${s.removed.length} removed: ${cut(s.removed)}`);
|
|
72
|
-
|
|
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
|
-
|
|
197
|
+
}
|
|
198
|
+
if (diff.unmapped.removed.length) {
|
|
79
199
|
lines.push(`Marwes variables that are no longer provided: ${cut(diff.unmapped.removed)}`);
|
|
80
|
-
|
|
200
|
+
}
|
|
201
|
+
for (const t of diff.themes.added) {
|
|
81
202
|
lines.push(`theme ${t}: new`);
|
|
82
|
-
|
|
203
|
+
}
|
|
204
|
+
for (const t of diff.themes.removed) {
|
|
83
205
|
lines.push(`theme ${t}: no longer expected`);
|
|
84
|
-
|
|
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
|
+
};
|
package/dist/theme-input.js
CHANGED
|
@@ -1,21 +1,32 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
2
|
import { readFile } from "node:fs/promises";
|
|
3
|
-
import
|
|
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
|
-
|
|
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
|
-
|
|
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, {
|
|
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
|
-
|
|
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
|
-
|
|
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,
|