@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,128 @@
1
+ import { canonical, normalizeValue, toHexColor } from "./tokens.js";
2
+ /** A small arithmetic grammar, never JavaScript evaluation. Unsupported units/functions are explicit unknowns. */
3
+ export function numericExpression(source) {
4
+ if (source.length > 2000)
5
+ throw new Error("Expression budget exceeded");
6
+ const tokens = source.match(/(?:\d+(?:\.\d*)?|\.\d+)(?:e[+-]?\d+)?|[()+*/-]/gi) ?? [];
7
+ if (tokens.join("") !== source.replace(/\s+/g, "") || tokens.length > 500)
8
+ throw new Error("Unsupported numeric expression");
9
+ let index = 0, depth = 0;
10
+ const atom = () => {
11
+ if (++depth > 64)
12
+ throw new Error("Expression depth exceeded");
13
+ const token = tokens[index++];
14
+ let value;
15
+ if (token === "(") {
16
+ value = sum();
17
+ if (tokens[index++] !== ")")
18
+ throw new Error("Unclosed expression");
19
+ }
20
+ else if (token === "-")
21
+ value = -atom();
22
+ else if (token === "+")
23
+ value = atom();
24
+ else {
25
+ if (!token || !/^[\d.]/.test(token))
26
+ throw new Error("Missing number");
27
+ value = Number(token);
28
+ }
29
+ depth--;
30
+ if (!Number.isFinite(value))
31
+ throw new Error("Nonfinite expression");
32
+ return value;
33
+ };
34
+ const product = () => { let value = atom(); while (["*", "/"].includes(tokens[index] ?? "")) {
35
+ const op = tokens[index++], right = atom();
36
+ value = op === "*" ? value * right : value / right;
37
+ } return value; };
38
+ const sum = () => { let value = product(); while (["+", "-"].includes(tokens[index] ?? "")) {
39
+ const op = tokens[index++], right = product();
40
+ value = op === "+" ? value + right : value - right;
41
+ } return value; };
42
+ const value = sum();
43
+ if (index !== tokens.length || !Number.isFinite(value))
44
+ throw new Error("Invalid numeric expression");
45
+ return value;
46
+ }
47
+ const numericTypes = new Set(["borderRadius", "dimension", "fontSizes", "letterSpacing", "number", "opacity", "rotation", "sizing", "spacing", "borderWidth"]);
48
+ export function expectedResolved(snapshot, theme) {
49
+ const effective = new Map(snapshot.sets.filter(set => theme.sets.includes(set.name)).flatMap(set => set.tokens.map(token => [token.name, token])));
50
+ const resolve = (name, visiting = new Set()) => {
51
+ if (visiting.has(name) || visiting.size >= 64)
52
+ throw new Error("Cyclic or over-deep reference");
53
+ const token = effective.get(name);
54
+ if (!token)
55
+ throw new Error("Missing referenced token: " + name);
56
+ const next = new Set(visiting);
57
+ next.add(name);
58
+ const value = (input) => {
59
+ if (typeof input === "string") {
60
+ const alias = /^\{([^{}]+)\}$/.exec(input);
61
+ if (alias)
62
+ return resolve(alias[1], next);
63
+ return input.replace(/\{([^{}]+)\}/g, (_match, reference) => {
64
+ const resolved = resolve(reference, next);
65
+ if (typeof resolved !== "number" && typeof resolved !== "string")
66
+ throw new Error("Composite interpolation unsupported");
67
+ return String(resolved);
68
+ });
69
+ }
70
+ if (input && typeof input === "object" && !Array.isArray(input))
71
+ return Object.fromEntries(Object.entries(input).map(([key, entry]) => [key, value(entry)]));
72
+ return input;
73
+ };
74
+ const resolved = value(token.value);
75
+ return numericTypes.has(token.type) ? typeof resolved === "number" ? resolved : numericExpression(String(resolved)) : resolved;
76
+ };
77
+ return [...effective.values()].map(token => { try {
78
+ return { name: token.name, type: token.type, value: resolve(token.name) };
79
+ }
80
+ catch (error) {
81
+ return { name: token.name, type: token.type, unknown: String(error.message) };
82
+ } });
83
+ }
84
+ export function compareResolved(wanted, actual) {
85
+ if (wanted.unknown)
86
+ return { unknown: wanted.unknown };
87
+ if (actual === undefined || actual === null)
88
+ return { unknown: "API resolvedValue is unavailable" };
89
+ try {
90
+ if (numericTypes.has(wanted.type)) {
91
+ const value = typeof actual === "number" ? actual : numericExpression(String(actual));
92
+ return { equal: typeof wanted.value === "number" && Math.abs(value - wanted.value) <= 1e-9 * Math.max(1, Math.abs(wanted.value)) };
93
+ }
94
+ if (wanted.type === "color") {
95
+ const color = (value) => { if (typeof value !== "string")
96
+ throw new Error("Unsupported resolved color"); const expanded = /^#[0-9a-f]{3,4}$/i.test(value) ? "#" + value.slice(1).split("").map(c => c + c).join("") : value; const hex = toHexColor(expanded); return hex.length === 9 && hex.endsWith("FF") ? hex.slice(0, -2) : hex; };
97
+ return { equal: color(wanted.value) === color(actual) };
98
+ }
99
+ if (wanted.type === "fontFamilies" && Array.isArray(actual) && !actual.every(value => typeof value === "string"))
100
+ return { unknown: "Unsupported nested font resolution" };
101
+ if (wanted.type === "shadow")
102
+ return { unknown: "Composite shadow resolution has no supported comparison model" };
103
+ if (wanted.type === "typography") {
104
+ const normalize = (input) => {
105
+ const object = Array.isArray(input) ? input.length === 1 ? input[0] : undefined : input;
106
+ if (!object || typeof object !== "object" || Array.isArray(object))
107
+ throw new Error("Unsupported typography resolution");
108
+ const aliases = { fontFamilies: "fontFamily", fontSizes: "fontSize", fontWeights: "fontWeight" };
109
+ return Object.fromEntries(Object.entries(object).map(([key, value]) => {
110
+ key = aliases[key] ?? key;
111
+ if (key === "fontFamily") {
112
+ const family = Array.isArray(value) ? value : [value];
113
+ if (!family.every(v => typeof v === "string"))
114
+ throw new Error("Unsupported nested font resolution");
115
+ return [key, family];
116
+ }
117
+ return [key, ["fontSize", "letterSpacing", "lineHeight"].includes(key) ? String(numericExpression(String(value))) : String(value)];
118
+ }));
119
+ };
120
+ const expected = normalize(wanted.value), observed = normalize(actual);
121
+ return { equal: Object.keys(expected).every(key => canonical(expected[key]) === canonical(observed[key])) };
122
+ }
123
+ return { equal: normalizeValue(wanted.type, wanted.value) === normalizeValue(wanted.type, actual) };
124
+ }
125
+ catch (error) {
126
+ return { unknown: String(error.message) };
127
+ }
128
+ }
@@ -41,3 +41,48 @@ export interface SnapshotDiff {
41
41
  export declare function diffSnapshots(before: TokenSnapshot | undefined, after: TokenSnapshot): SnapshotDiff;
42
42
  /** A short, readable account of a diff for the terminal; long lists are cut with a count. */
43
43
  export declare function formatDiff(diff: SnapshotDiff, limit?: number): string[];
44
+ /** Explicitly classify every legacy set; names cannot establish ownership. Original fields survive migration. */
45
+ export declare function migrateTokenSnapshot(raw: unknown, ownership: Record<string, "adapter" | "project">): TokenSnapshot;
46
+ export declare const tokenSnapshotSchema: {
47
+ readonly $schema: "https://json-schema.org/draft/2020-12/schema";
48
+ readonly $id: "urn:penpair:schema:v2:adapter-token-snapshot";
49
+ readonly type: "object";
50
+ readonly required: readonly ["schemaVersion", "ownership", "marwesVersion", "generatedBy", "project", "hash", "sets", "themes", "unmapped"];
51
+ readonly properties: {
52
+ readonly schemaVersion: {
53
+ readonly const: 2;
54
+ };
55
+ readonly ownership: {
56
+ readonly type: "object";
57
+ readonly additionalProperties: {
58
+ readonly enum: readonly ["adapter", "project"];
59
+ };
60
+ };
61
+ readonly marwesVersion: {
62
+ readonly type: "string";
63
+ };
64
+ readonly generatedBy: {
65
+ readonly type: "string";
66
+ };
67
+ readonly project: {
68
+ readonly type: "string";
69
+ };
70
+ readonly hash: {
71
+ readonly type: "string";
72
+ readonly pattern: "^[a-f0-9]{64}$";
73
+ };
74
+ readonly sets: {
75
+ readonly type: "array";
76
+ };
77
+ readonly themes: {
78
+ readonly type: "array";
79
+ };
80
+ readonly unmapped: {
81
+ readonly type: "array";
82
+ readonly items: {
83
+ readonly type: "string";
84
+ };
85
+ };
86
+ };
87
+ readonly additionalProperties: true;
88
+ };
@@ -1,9 +1,16 @@
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 (![1, 2].includes(snapshot.schemaVersion) || !Array.isArray(snapshot.sets) || !Array.isArray(snapshot.themes) || typeof snapshot.hash !== "string" || typeof snapshot.project !== "string")
10
+ throw new Error("Unsupported or malformed snapshot; regenerate with tokens snapshot --write");
11
+ if (snapshot.schemaVersion === 2 && (!snapshot.ownership || Object.keys(snapshot.ownership).length !== snapshot.sets.length || snapshot.sets.some(set => !["adapter", "project"].includes(snapshot.ownership?.[set.name] ?? ""))))
12
+ throw new Error("Invalid schema-2 set ownership");
13
+ return snapshot;
7
14
  }
8
15
  catch (error) {
9
16
  if (error.code === "ENOENT")
@@ -12,9 +19,9 @@ export async function readSnapshot(root, file) {
12
19
  }
13
20
  }
14
21
  export async function writeSnapshot(root, file, snapshot) {
15
- const target = path.join(root, file);
22
+ const target = await resolveProjectOutput(root, file);
16
23
  await mkdir(path.dirname(target), { recursive: true });
17
- await writeFile(target, `${JSON.stringify(snapshot, null, 2)}\n`);
24
+ await atomicWriteFile(target, `${JSON.stringify(snapshot, null, 2)}\n`, { replace: true });
18
25
  }
19
26
  const show = (token) => (typeof token.value === "string" ? token.value : canonical(token.value));
20
27
  /** What differs between two expectations: the Marwes version, then set by set which tokens were added, removed or changed. */
@@ -85,3 +92,33 @@ export function formatDiff(diff, limit = 8) {
85
92
  lines.push(`theme ${t.theme}: sets ${t.before.join(" + ")} -> ${t.after.join(" + ")}`);
86
93
  return lines;
87
94
  }
95
+ /** Explicitly classify every legacy set; names cannot establish ownership. Original fields survive migration. */
96
+ export function migrateTokenSnapshot(raw, ownership) {
97
+ const source = raw;
98
+ if (!source || source.schemaVersion !== 1 || !Array.isArray(source.sets) || !Array.isArray(source.themes) || !Array.isArray(source.unmapped) || typeof source.marwesVersion !== "string" || typeof source.project !== "string")
99
+ throw new Error("Migration requires a readable legacy schema-1 token snapshot; unsupported majors need producer guidance");
100
+ if (!ownership || typeof ownership !== "object" || Array.isArray(ownership) || Object.keys(ownership).length !== source.sets.length || source.sets.some(set => !["adapter", "project"].includes(ownership[set.name] ?? "")) || new Set(source.sets.map(set => set.name)).size !== source.sets.length)
101
+ throw new Error("Explicit adapter/project ownership is required for every legacy set, with no extra names");
102
+ for (const set of source.sets) {
103
+ if (!set || typeof set.name !== "string" || !Array.isArray(set.tokens))
104
+ throw new Error("Malformed legacy set");
105
+ const names = new Set();
106
+ for (const token of set.tokens) {
107
+ validateEditableToken(token);
108
+ if (names.has(token.name))
109
+ throw new Error("Duplicate legacy token");
110
+ names.add(token.name);
111
+ }
112
+ }
113
+ if (source.unmapped.some(value => typeof value !== "string") || source.themes.some(theme => !theme || typeof theme.group !== "string" || typeof theme.name !== "string" || !Array.isArray(theme.sets) || theme.sets.some(name => !source.sets.some(set => set.name === name))))
114
+ throw new Error("Malformed legacy themes/unmapped variables");
115
+ const migrated = { ...structuredClone(source), schemaVersion: 2, ownership: structuredClone(ownership) };
116
+ const { marwesVersion, project, sets, themes, unmapped } = migrated;
117
+ migrated.hash = createHash("sha256").update(canonical({ marwesVersion, project, sets, themes, unmapped, ownership: migrated.ownership })).digest("hex");
118
+ return migrated;
119
+ }
120
+ export const tokenSnapshotSchema = {
121
+ $schema: "https://json-schema.org/draft/2020-12/schema", $id: "urn:penpair:schema:v2:adapter-token-snapshot", type: "object",
122
+ required: ["schemaVersion", "ownership", "marwesVersion", "generatedBy", "project", "hash", "sets", "themes", "unmapped"],
123
+ properties: { schemaVersion: { const: 2 }, ownership: { type: "object", additionalProperties: { enum: ["adapter", "project"] } }, marwesVersion: { type: "string" }, generatedBy: { type: "string" }, project: { type: "string" }, hash: { type: "string", pattern: "^[a-f0-9]{64}$" }, sets: { type: "array" }, themes: { type: "array" }, unmapped: { type: "array", items: { type: "string" } } }, additionalProperties: true,
124
+ };
@@ -1,16 +1,19 @@
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
+ throw error;
14
17
  }
15
18
  const jiti = createJiti(file, { interopDefault: true, moduleCache: false });
16
19
  const loaded = (await jiti.import(file));
@@ -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,59 @@
1
+ export function readThemeLint(value) {
2
+ if (value === undefined)
3
+ return {};
4
+ if (!value || typeof value !== "object" || Array.isArray(value))
5
+ throw new Error("marwes.themeLint must be an object");
6
+ const v = value;
7
+ if (Object.keys(v).some(k => !["hueThresholdDegrees", "suppressions"].includes(k)) || v.hueThresholdDegrees !== undefined && (!Number.isFinite(v.hueThresholdDegrees) || v.hueThresholdDegrees < 0 || v.hueThresholdDegrees > 180))
8
+ throw new Error("Invalid theme lint threshold/settings");
9
+ if (v.suppressions !== undefined && (!Array.isArray(v.suppressions) || v.suppressions.length > 1000 || v.suppressions.some(s => !s || !["light", "dark"].includes(s.theme) || typeof s.token !== "string" || !s.token || typeof s.reason !== "string" || !s.reason.trim() || s.reason.length > 1000)))
10
+ throw new Error("Theme lint suppressions need exact mode/token and a reason");
11
+ return structuredClone(v);
12
+ }
13
+ function hue(value) {
14
+ if (typeof value !== "string" || !/^#[\da-f]{6}(?:ff)?$/i.test(value))
15
+ return;
16
+ const [r, g, b] = [1, 3, 5].map(i => parseInt(value.slice(i, i + 2), 16) / 255);
17
+ const max = Math.max(r, g, b), min = Math.min(r, g, b), delta = max - min;
18
+ if (!delta)
19
+ return { hue: 0, saturation: 0 };
20
+ let h = max === r ? ((g - b) / delta) % 6 : max === g ? (b - r) / delta + 2 : (r - g) / delta + 4;
21
+ h = ((h * 60) % 360 + 360) % 360;
22
+ return { hue: h, saturation: max === 0 ? 0 : delta / max };
23
+ }
24
+ function colors(snapshot, mode) {
25
+ const result = new Map(), theme = snapshot.themes.find(t => t.group === "Mode" && t.name.toLowerCase() === mode);
26
+ for (const name of theme?.sets ?? [])
27
+ for (const token of snapshot.sets.find(s => s.name === name)?.tokens ?? [])
28
+ if (token.type === "color")
29
+ result.set(token.name, token.value);
30
+ return result;
31
+ }
32
+ export function lintThemeFamily(defaults, project, rawSettings = {}) {
33
+ const settings = readThemeLint(rawSettings), warnings = [], unknown = [];
34
+ for (const mode of ["light", "dark"]) {
35
+ const base = colors(defaults, mode), current = colors(project, mode), anchor = "mw-color-primary-base", primary = current.get(anchor), original = base.get(anchor);
36
+ if (primary === undefined || original === undefined || primary === original)
37
+ continue;
38
+ const anchorHue = hue(primary);
39
+ if (!anchorHue || anchorHue.saturation < 0.15) {
40
+ unknown.push({ theme: mode, token: anchor, reason: "Primary is transparent, non-literal or achromatic; hue comparison is not meaningful" });
41
+ continue;
42
+ }
43
+ for (const [token, value] of current) {
44
+ if (!/^mw-color-(?:border-brand|text-(?:brand|link)|focus(?:-.+)?|surface-brand|secondary(?:-.+)?)$/.test(token) || value !== base.get(token))
45
+ continue;
46
+ const other = hue(value);
47
+ if (!other || other.saturation < 0.15) {
48
+ unknown.push({ theme: mode, token, reason: "Default sibling hue is unavailable or achromatic" });
49
+ continue;
50
+ }
51
+ const delta = Math.abs(anchorHue.hue - other.hue), distance = Math.min(delta, 360 - delta);
52
+ if (distance < (settings.hueThresholdDegrees ?? 30))
53
+ continue;
54
+ const suppression = settings.suppressions?.find(s => s.theme === mode && s.token === token)?.reason;
55
+ warnings.push({ theme: mode, token, default: base.get(token), actual: value, primary, hueDistance: distance, ...(suppression ? { suppression } : {}) });
56
+ }
57
+ }
58
+ return { schemaVersion: 1, kind: "theme-family-lint", advisory: true, warnings, unknown, assumptions: ["Hue distance on opaque literal colors is a heuristic, not a brand or accessibility rule.", "Unchanged family defaults may be intentional; retain a reasoned suppression rather than rewriting them automatically."], changed: false };
59
+ }
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,
@@ -71,5 +75,5 @@ export interface SnapshotInput {
71
75
  * so a Marwes update changes the Marwes sets and never silently loses the project's choices.
72
76
  */
73
77
  export declare function buildSnapshot(input: SnapshotInput): TokenSnapshot;
74
- /** The value of a token as it compares in Penpot: strings are exact, typography is normalised the way Penpot stores it. */
78
+ /** The value of a token as it compares in Penpot: strings are exact, font families and typography are normalised the way Penpot stores them. */
75
79
  export declare function normalizeValue(type: string, value: unknown): string;
package/dist/tokens.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { createHash } from "node:crypto";
2
+ import { validateEditableToken } from "@penpair/core";
2
3
  export const coreSetName = "Marwes · Core";
3
4
  export const darkSetName = "Marwes · Dark";
4
5
  export const themeGroup = "Mode";
@@ -159,13 +160,33 @@ export function buildSnapshot(input) {
159
160
  darkSets.push(`${name} · Dark`);
160
161
  }
161
162
  }
163
+ const ownership = Object.fromEntries(sets.map(set => [set.name, "adapter"]));
164
+ const seenSets = new Set(sets.map(set => set.name));
165
+ for (const extra of input.extras ?? []) {
166
+ if (seenSets.has(extra.name))
167
+ throw new Error("Project extra collides with generated set: " + extra.name);
168
+ if (typeof extra.name !== "string" || !extra.name.trim() || extra.name.length > 200 || !Array.isArray(extra.tokens))
169
+ throw new Error("Invalid project extra set");
170
+ const tokenNames = new Set();
171
+ for (const token of extra.tokens) {
172
+ validateEditableToken(token);
173
+ if (tokenNames.has(token.name))
174
+ throw new Error("Duplicate project extra token: " + token.name);
175
+ tokenNames.add(token.name);
176
+ }
177
+ seenSets.add(extra.name);
178
+ sets.push({ name: extra.name, tokens: extra.tokens.map(token => validateEditableToken(token)).sort((a, b) => a.name.localeCompare(b.name)) });
179
+ Object.defineProperty(ownership, extra.name, { value: "project", enumerable: true, configurable: true, writable: true });
180
+ lightSets.push(extra.name);
181
+ darkSets.push(extra.name);
182
+ }
162
183
  const themes = [
163
184
  { group: themeGroup, name: "Light", sets: lightSets },
164
185
  { group: themeGroup, name: "Dark", sets: darkSets },
165
186
  ];
166
- return { schemaVersion: 1, marwesVersion: version, generatedBy: input.generatedBy, project: name, hash: hashOf({ marwesVersion: version, project: name, sets, themes, unmapped }), unmapped, sets, themes };
187
+ return { schemaVersion: 2, ownership, marwesVersion: version, generatedBy: input.generatedBy, project: name, hash: hashOf({ marwesVersion: version, project: name, sets, themes, unmapped, ownership }), unmapped, sets, themes };
167
188
  }
168
- /** The value of a token as it compares in Penpot: strings are exact, typography is normalised the way Penpot stores it. */
189
+ /** The value of a token as it compares in Penpot: strings are exact, font families and typography are normalised the way Penpot stores them. */
169
190
  export function normalizeValue(type, value) {
170
191
  if (type === "typography" && typeof value === "object" && value !== null) {
171
192
  const entry = {};
@@ -179,5 +200,8 @@ export function normalizeValue(type, value) {
179
200
  }
180
201
  if (type === "color" && typeof value === "string" && /^#[0-9a-f]+$/i.test(value))
181
202
  return value.toUpperCase();
203
+ // Penpot stores a font family token as a list (["Inter"]) whatever it was given.
204
+ if (type === "fontFamilies" || type === "fontFamily")
205
+ return (Array.isArray(value) ? value : [value]).map(String).join(",");
182
206
  return typeof value === "string" ? value : canonical(value);
183
207
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@penpair/marwes-ui",
3
- "version": "0.1.2",
3
+ "version": "0.2.0",
4
4
  "description": "The Marwes UI adapter for penpair: Marwes tokens (and your own theme) in your Penpot file, with drift checks against the installed Marwes: snapshot, check, diff, push.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -44,7 +44,7 @@
44
44
  },
45
45
  "dependencies": {
46
46
  "jiti": "^2.4.2",
47
- "@penpair/core": "0.1.2"
47
+ "@penpair/core": "0.2.0"
48
48
  },
49
49
  "scripts": {
50
50
  "build": "tsc -p tsconfig.json",