@penpair/marwes-ui 0.0.0-stage → 0.1.2

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/tokens.js ADDED
@@ -0,0 +1,183 @@
1
+ import { createHash } from "node:crypto";
2
+ export const coreSetName = "Marwes · Core";
3
+ export const darkSetName = "Marwes · Dark";
4
+ export const themeGroup = "Mode";
5
+ const hex = (value) => Math.round(value).toString(16).padStart(2, "0").toUpperCase();
6
+ /** Penpot colour tokens are hex; Marwes also uses rgba() and the word transparent. */
7
+ export function toHexColor(value) {
8
+ if (value === "transparent")
9
+ return "#00000000";
10
+ const rgba = /^rgba?\(\s*([\d.]+)\s*,\s*([\d.]+)\s*,\s*([\d.]+)\s*(?:,\s*([\d.]+)\s*)?\)$/.exec(value);
11
+ if (rgba) {
12
+ const alpha = rgba[4] === undefined ? 1 : Number(rgba[4]);
13
+ return `#${hex(Number(rgba[1]))}${hex(Number(rgba[2]))}${hex(Number(rgba[3]))}${alpha < 1 ? hex(alpha * 255) : ""}`;
14
+ }
15
+ if (/^#[0-9a-f]{6}([0-9a-f]{2})?$/i.test(value))
16
+ return value.toUpperCase();
17
+ throw new Error(`Unsupported colour value: ${value}`);
18
+ }
19
+ const px = (value) => {
20
+ const match = /^(-?[\d.]+)px$/.exec(value);
21
+ if (!match)
22
+ throw new Error(`Expected a px value, got ${value}`);
23
+ return String(Number(match[1]));
24
+ };
25
+ const firstFamily = (stack) => stack
26
+ .split(",")[0]
27
+ .trim()
28
+ .replace(/^['"]|['"]$/g, "");
29
+ const typographyKeys = {
30
+ "font-size": "fontSize",
31
+ "line-height": "lineHeight",
32
+ "font-weight": "fontWeight",
33
+ "letter-spacing": "letterSpacing",
34
+ transform: "textCase",
35
+ };
36
+ function typographyTokens(vars, fontFamily, describe) {
37
+ const variants = new Map();
38
+ for (const [variable, value] of Object.entries(vars)) {
39
+ const match = /^--mw-typography-(.+)-(font-size|line-height|font-weight|letter-spacing|transform)$/.exec(variable);
40
+ if (!match)
41
+ continue;
42
+ const entry = variants.get(match[1]) ?? { fontFamilies: fontFamily };
43
+ const key = typographyKeys[match[2]];
44
+ if (match[2] === "transform") {
45
+ if (value !== "none")
46
+ entry[key] = value;
47
+ }
48
+ else
49
+ entry[key] = match[2] === "line-height" || match[2] === "font-weight" ? value : px(value);
50
+ variants.set(match[1], entry);
51
+ }
52
+ return [...variants.entries()]
53
+ .sort(([left], [right]) => left.localeCompare(right))
54
+ .map(([variant, value]) => ({
55
+ name: `mw-typography-${variant}`,
56
+ type: "typography",
57
+ // The paragraph variants in Marwes define only size and line height; weight and spacing fall back to the normal values, as a browser does.
58
+ value: { fontWeight: "400", letterSpacing: "0", ...value },
59
+ description: describe(`--mw-typography-${variant}-*`),
60
+ }));
61
+ }
62
+ const byName = (left, right) => left.name.localeCompare(right.name);
63
+ /** Every token a resolved theme provides (everything but the spacing scale, which is not part of a theme). */
64
+ export function tokensFromVars(vars, describe) {
65
+ const colors = Object.entries(vars)
66
+ .filter(([variable]) => variable.startsWith("--mw-color-"))
67
+ .map(([variable, value]) => ({ name: variable.slice(2), type: "color", value: toHexColor(value), description: describe(variable) }))
68
+ .sort(byName);
69
+ const density = Object.entries(vars)
70
+ .filter(([variable, value]) => variable.startsWith("--mw-density-") && value.endsWith("px"))
71
+ .map(([variable, value]) => ({ name: variable.slice(2), type: "sizing", value: px(value), description: describe(variable) }))
72
+ .sort(byName);
73
+ const radii = ["--mw-ui-radius", "--mw-ui-radius-button"]
74
+ .filter((variable) => vars[variable] !== undefined)
75
+ .map((variable) => ({ name: variable.slice(2), type: "borderRadius", value: px(vars[variable]), description: describe(variable) }));
76
+ const fonts = ["--mw-font-primary", "--mw-font-secondary", "--mw-font-mono"]
77
+ .filter((variable) => vars[variable] !== undefined)
78
+ .map((variable) => ({ name: variable.slice(2), type: "fontFamilies", value: firstFamily(vars[variable]), description: describe(variable) }));
79
+ const primary = vars["--mw-font-primary"];
80
+ const typography = primary === undefined ? [] : typographyTokens(vars, firstFamily(primary), describe);
81
+ return [...colors, ...density, ...radii, ...fonts, ...typography];
82
+ }
83
+ /** The tokens of `of` that `base` does not already provide with the same value: what is left to say on top of it. */
84
+ export function tokensAbove(base, of) {
85
+ const known = new Map(base.map((token) => [token.name, canonical(token.value)]));
86
+ return of.filter((token) => known.get(token.name) !== canonical(token.value));
87
+ }
88
+ /** JSON with sorted keys, so the same data always gives the same text. */
89
+ export function canonical(value) {
90
+ const sort = (input) => {
91
+ if (Array.isArray(input))
92
+ return input.map(sort);
93
+ if (typeof input === "object" && input !== null)
94
+ return Object.fromEntries(Object.entries(input).sort(([a], [b]) => a.localeCompare(b)).map(([k, v]) => [k, sort(v)]));
95
+ return input;
96
+ };
97
+ return JSON.stringify(sort(value));
98
+ }
99
+ const typographyVariable = /^--mw-typography-(.+)-(font-size|line-height|font-weight|letter-spacing|transform)$/;
100
+ /** Whether a Marwes variable becomes a token (see tokensFromVars). Everything else is reported, never silently dropped. */
101
+ export function isMapped(variable, value) {
102
+ if (variable.startsWith("--mw-color-"))
103
+ return true;
104
+ if (variable.startsWith("--mw-density-"))
105
+ return value.endsWith("px");
106
+ if (variable === "--mw-ui-radius" || variable === "--mw-ui-radius-button")
107
+ return true;
108
+ if (variable === "--mw-font-primary" || variable === "--mw-font-secondary" || variable === "--mw-font-mono")
109
+ return true;
110
+ return typographyVariable.test(variable);
111
+ }
112
+ export function unmappedVariables(...themes) {
113
+ const found = new Set();
114
+ for (const vars of themes)
115
+ for (const [variable, value] of Object.entries(vars))
116
+ if (!isMapped(variable, value))
117
+ found.add(variable);
118
+ return [...found].sort();
119
+ }
120
+ const hashOf = (parts) => createHash("sha256").update(canonical(parts)).digest("hex");
121
+ /**
122
+ * The Penpot token sets for Marwes and for the project, generated from the installed Marwes. Light values live in the core set,
123
+ * only what differs in dark goes in the dark set, and what the project's own theme changes goes in small overlay sets above them,
124
+ * so a Marwes update changes the Marwes sets and never silently loses the project's choices.
125
+ */
126
+ export function buildSnapshot(input) {
127
+ const { core, version, name } = input;
128
+ const mode = (which, theme) => core.themeToCSSVars(core.resolveThemeInput(theme ?? { mode: which }));
129
+ const marwes = (variable) => `${variable} (Marwes ${version})`;
130
+ const marwesDark = (variable) => `${variable} in dark mode (Marwes ${version})`;
131
+ const overlay = (which) => (variable) => `${variable}, ${name} ${which} theme (Marwes ${version})`;
132
+ const lightDefaults = mode("light");
133
+ const darkDefaults = mode("dark");
134
+ const lightBase = tokensFromVars(lightDefaults, marwes);
135
+ const darkBase = tokensFromVars(darkDefaults, marwesDark);
136
+ const spacing = Object.values(core.mwThemeVars.spacing)
137
+ .map((reference) => /var\((--mw-spacing-sp-(\d+))\)/.exec(reference))
138
+ .filter((match) => match !== null)
139
+ .map((match) => ({ name: match[1].slice(2), type: "spacing", value: match[2], description: marwes(match[1]) }))
140
+ .sort((left, right) => Number(left.value) - Number(right.value));
141
+ const unmapped = unmappedVariables(lightDefaults, darkDefaults);
142
+ const sets = [
143
+ { name: coreSetName, tokens: [...lightBase, ...spacing] },
144
+ { name: darkSetName, tokens: tokensAbove(lightBase, darkBase) },
145
+ ];
146
+ const lightSets = [coreSetName];
147
+ const darkSets = [coreSetName, darkSetName];
148
+ if (input.project?.light) {
149
+ const tokens = tokensAbove(lightBase, tokensFromVars(mode("light", input.project.light), overlay("light")));
150
+ if (tokens.length > 0) {
151
+ sets.push({ name: `${name} · Light`, tokens });
152
+ lightSets.push(`${name} · Light`);
153
+ }
154
+ }
155
+ if (input.project?.dark) {
156
+ const tokens = tokensAbove(darkBase, tokensFromVars(mode("dark", input.project.dark), overlay("dark")));
157
+ if (tokens.length > 0) {
158
+ sets.push({ name: `${name} · Dark`, tokens });
159
+ darkSets.push(`${name} · Dark`);
160
+ }
161
+ }
162
+ const themes = [
163
+ { group: themeGroup, name: "Light", sets: lightSets },
164
+ { group: themeGroup, name: "Dark", sets: darkSets },
165
+ ];
166
+ return { schemaVersion: 1, marwesVersion: version, generatedBy: input.generatedBy, project: name, hash: hashOf({ marwesVersion: version, project: name, sets, themes, unmapped }), unmapped, sets, themes };
167
+ }
168
+ /** The value of a token as it compares in Penpot: strings are exact, typography is normalised the way Penpot stores it. */
169
+ export function normalizeValue(type, value) {
170
+ if (type === "typography" && typeof value === "object" && value !== null) {
171
+ const entry = {};
172
+ for (const [key, raw] of Object.entries(value)) {
173
+ if (key === "fontFamilies" || key === "fontFamily")
174
+ entry.fontFamily = (Array.isArray(raw) ? raw : [raw]).map(String).join(",");
175
+ else
176
+ entry[key] = String(raw);
177
+ }
178
+ return canonical(entry);
179
+ }
180
+ if (type === "color" && typeof value === "string" && /^#[0-9a-f]+$/i.test(value))
181
+ return value.toUpperCase();
182
+ return typeof value === "string" ? value : canonical(value);
183
+ }
package/package.json CHANGED
@@ -1,6 +1,54 @@
1
1
  {
2
2
  "name": "@penpair/marwes-ui",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "0.1.2",
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
+ "license": "MIT",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/niklas-westman/penpair.git",
9
+ "directory": "packages/marwes-ui"
10
+ },
11
+ "homepage": "https://github.com/niklas-westman/penpair#readme",
12
+ "bugs": {
13
+ "url": "https://github.com/niklas-westman/penpair/issues"
14
+ },
15
+ "type": "module",
16
+ "engines": {
17
+ "node": ">=22"
18
+ },
19
+ "main": "./dist/index.js",
20
+ "types": "./dist/index.d.ts",
21
+ "exports": {
22
+ ".": {
23
+ "types": "./dist/index.d.ts",
24
+ "import": "./dist/index.js",
25
+ "default": "./dist/index.js"
26
+ },
27
+ "./package.json": "./package.json"
28
+ },
29
+ "files": [
30
+ "dist",
31
+ "LICENSE",
32
+ "README.md"
33
+ ],
34
+ "keywords": [
35
+ "penpot",
36
+ "marwes",
37
+ "design-tokens",
38
+ "design-systems",
39
+ "mcp",
40
+ "ai-agents"
41
+ ],
42
+ "publishConfig": {
43
+ "access": "public"
44
+ },
45
+ "dependencies": {
46
+ "jiti": "^2.4.2",
47
+ "@penpair/core": "0.1.2"
48
+ },
49
+ "scripts": {
50
+ "build": "tsc -p tsconfig.json",
51
+ "typecheck": "tsc -p tsconfig.json --noEmit",
52
+ "test": "vitest run"
53
+ }
6
54
  }