@pitlane/theme 0.1.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/CHANGELOG.md +22 -0
- package/LICENSE +21 -0
- package/README.md +94 -0
- package/dist/index.d.mts +722 -0
- package/dist/index.mjs +629 -0
- package/package.json +53 -0
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,629 @@
|
|
|
1
|
+
import { createElement, css as css$1 } from "remix/ui";
|
|
2
|
+
//#region src/brands.ts
|
|
3
|
+
/**
|
|
4
|
+
* The twelve DTCG token types, in canonical order.
|
|
5
|
+
*
|
|
6
|
+
* @internal
|
|
7
|
+
*/
|
|
8
|
+
const TOKEN_TYPES = [
|
|
9
|
+
"color",
|
|
10
|
+
"dimension",
|
|
11
|
+
"duration",
|
|
12
|
+
"fontFamily",
|
|
13
|
+
"fontWeight",
|
|
14
|
+
"number",
|
|
15
|
+
"cubicBezier",
|
|
16
|
+
"shadow",
|
|
17
|
+
"border",
|
|
18
|
+
"transition",
|
|
19
|
+
"gradient",
|
|
20
|
+
"strokeStyle"
|
|
21
|
+
];
|
|
22
|
+
//#endregion
|
|
23
|
+
//#region src/tokens.ts
|
|
24
|
+
/**
|
|
25
|
+
* The error {@link createTheme} throws for every validation and
|
|
26
|
+
* serialization failure. Validation is eager, so a bad document never
|
|
27
|
+
* emits CSS. Every message names the offending token path.
|
|
28
|
+
*
|
|
29
|
+
* | Condition | Message shape |
|
|
30
|
+
* | --- | --- |
|
|
31
|
+
* | Unknown `$type` | `"color.brand" has unknown $type "sparkles"` |
|
|
32
|
+
* | Unresolvable `$type` | `"color.brand" has no resolvable $type` |
|
|
33
|
+
* | Typography token | `"heading": typography tokens are not supported in v1` |
|
|
34
|
+
* | Reserved character in a name | `Token or group name "a.b" contains characters reserved by DTCG references (".", "{", "}")` |
|
|
35
|
+
* | Empty CSS identifier | `Token path segment "!" produces an empty CSS identifier` |
|
|
36
|
+
* | Malformed node | `"color.bg" is neither a group nor a token` |
|
|
37
|
+
* | Variable-name collision | `Tokens "a" and "b" both produce the CSS variable --x` |
|
|
38
|
+
* | Alias to a missing token | `"color.bg" references unknown token "color.white"` |
|
|
39
|
+
* | Alias to a wrong-typed token | `"x" references "space.sm" of type "dimension" where "color" is required` |
|
|
40
|
+
* | Alias cycle | `Alias cycle: a → b → a` |
|
|
41
|
+
* | Invalid value for a declared type | `"x" has an invalid color value: …` — also `unknown colorSpace`, `unknown fontWeight keyword`, and `unknown strokeStyle keyword`; an empty `fontFamily` array counts |
|
|
42
|
+
* | Bad mode override | `Mode override "x" does not exist in the base document`, `Mode override "x" may only set $value`, or (via a cross-type alias) the wrong-typed-alias message |
|
|
43
|
+
* | Unminted `raw()` ref | `raw(): "var(--x)" names a var this theme never minted` |
|
|
44
|
+
*/
|
|
45
|
+
var ThemeError = class extends Error {
|
|
46
|
+
name = "ThemeError";
|
|
47
|
+
};
|
|
48
|
+
const ALIAS_RE = /^\{([^{}]+)\}$/;
|
|
49
|
+
/**
|
|
50
|
+
* Extracts the target key from a `"{path.to.token}"` alias string, or
|
|
51
|
+
* `null` when the value is not an alias reference.
|
|
52
|
+
*
|
|
53
|
+
* @internal
|
|
54
|
+
*/
|
|
55
|
+
function aliasTarget(value) {
|
|
56
|
+
if (typeof value !== "string") return null;
|
|
57
|
+
let match = ALIAS_RE.exec(value);
|
|
58
|
+
return match ? match[1] : null;
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Kebab-cases one path segment for a CSS variable name. Throws when
|
|
62
|
+
* the segment reduces to an empty identifier.
|
|
63
|
+
*
|
|
64
|
+
* @internal
|
|
65
|
+
*/
|
|
66
|
+
function kebabSegment(segment) {
|
|
67
|
+
let kebab = segment.replace(/([a-z0-9])([A-Z])/g, "$1-$2").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
|
|
68
|
+
if (!kebab) throw new ThemeError(`Token path segment "${segment}" produces an empty CSS identifier`);
|
|
69
|
+
return kebab;
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Walks a document, resolves every token's type and CSS variable
|
|
73
|
+
* name, and returns the parsed tokens keyed by dotted path.
|
|
74
|
+
*
|
|
75
|
+
* @internal
|
|
76
|
+
*/
|
|
77
|
+
function parseTokens(document) {
|
|
78
|
+
let entries = /* @__PURE__ */ new Map();
|
|
79
|
+
walk(document, [], validateType(document.$type, "$root"), entries);
|
|
80
|
+
let tokens = /* @__PURE__ */ new Map();
|
|
81
|
+
let varNames = /* @__PURE__ */ new Map();
|
|
82
|
+
for (let entry of entries.values()) {
|
|
83
|
+
let type = resolveType(entry.key, entries, []);
|
|
84
|
+
let varName = `--${entry.path.map(kebabSegment).join("-")}`;
|
|
85
|
+
let existing = varNames.get(varName);
|
|
86
|
+
if (existing !== void 0) throw new ThemeError(`Tokens "${existing}" and "${entry.key}" both produce the CSS variable ${varName}`);
|
|
87
|
+
varNames.set(varName, entry.key);
|
|
88
|
+
let alias = aliasTarget(entry.value);
|
|
89
|
+
tokens.set(entry.key, {
|
|
90
|
+
key: entry.key,
|
|
91
|
+
path: entry.path,
|
|
92
|
+
varName,
|
|
93
|
+
type,
|
|
94
|
+
value: entry.value,
|
|
95
|
+
...alias === null ? {} : { aliasOf: alias }
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
return tokens;
|
|
99
|
+
}
|
|
100
|
+
function walk(node, path, inherited, out) {
|
|
101
|
+
for (let [key, child] of Object.entries(node)) {
|
|
102
|
+
if (key.startsWith("$")) continue;
|
|
103
|
+
let childPath = [...path, key];
|
|
104
|
+
let childKey = childPath.join(".");
|
|
105
|
+
if (/[.{}]/.test(key)) throw new ThemeError(`Token or group name "${childKey}" contains characters reserved by DTCG references (".", "{", "}")`);
|
|
106
|
+
if (typeof child !== "object" || child === null || Array.isArray(child)) throw new ThemeError(`"${childKey}" is neither a group nor a token`);
|
|
107
|
+
let record = child;
|
|
108
|
+
let ownType = validateType(record.$type, childKey);
|
|
109
|
+
if ("$value" in record) out.set(childKey, {
|
|
110
|
+
key: childKey,
|
|
111
|
+
path: childPath,
|
|
112
|
+
ownType,
|
|
113
|
+
inheritedType: inherited,
|
|
114
|
+
value: record.$value
|
|
115
|
+
});
|
|
116
|
+
else walk(record, childPath, ownType ?? inherited, out);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
function validateType(value, key) {
|
|
120
|
+
if (value === void 0) return void 0;
|
|
121
|
+
if (value === "typography") throw new ThemeError(`"${key}": typography tokens are not supported in v1`);
|
|
122
|
+
if (!TOKEN_TYPES.includes(value)) throw new ThemeError(`"${key}" has unknown $type "${String(value)}"`);
|
|
123
|
+
return value;
|
|
124
|
+
}
|
|
125
|
+
function resolveType(key, entries, chain) {
|
|
126
|
+
if (chain.includes(key)) throw new ThemeError(`Alias cycle: ${[...chain, key].join(" → ")}`);
|
|
127
|
+
let entry = entries.get(key);
|
|
128
|
+
if (!entry) throw new ThemeError(`"${chain[chain.length - 1] ?? key}" references unknown token "${key}"`);
|
|
129
|
+
if (entry.ownType) return entry.ownType;
|
|
130
|
+
let alias = aliasTarget(entry.value);
|
|
131
|
+
if (alias !== null) return resolveType(alias, entries, [...chain, key]);
|
|
132
|
+
if (entry.inheritedType) return entry.inheritedType;
|
|
133
|
+
throw new ThemeError(`"${key}" has no resolvable $type`);
|
|
134
|
+
}
|
|
135
|
+
//#endregion
|
|
136
|
+
//#region src/serialize.ts
|
|
137
|
+
const FONT_WEIGHT_KEYWORDS = {
|
|
138
|
+
thin: 100,
|
|
139
|
+
hairline: 100,
|
|
140
|
+
"extra-light": 200,
|
|
141
|
+
"ultra-light": 200,
|
|
142
|
+
light: 300,
|
|
143
|
+
normal: 400,
|
|
144
|
+
regular: 400,
|
|
145
|
+
book: 400,
|
|
146
|
+
medium: 500,
|
|
147
|
+
"semi-bold": 600,
|
|
148
|
+
"demi-bold": 600,
|
|
149
|
+
bold: 700,
|
|
150
|
+
"extra-bold": 800,
|
|
151
|
+
"ultra-bold": 800,
|
|
152
|
+
black: 900,
|
|
153
|
+
heavy: 900,
|
|
154
|
+
"extra-black": 950,
|
|
155
|
+
"ultra-black": 950
|
|
156
|
+
};
|
|
157
|
+
const COLOR_FUNCTIONS = {
|
|
158
|
+
hsl: {
|
|
159
|
+
fn: "hsl",
|
|
160
|
+
percents: [1, 2]
|
|
161
|
+
},
|
|
162
|
+
hwb: {
|
|
163
|
+
fn: "hwb",
|
|
164
|
+
percents: [1, 2]
|
|
165
|
+
},
|
|
166
|
+
lab: { fn: "lab" },
|
|
167
|
+
lch: { fn: "lch" },
|
|
168
|
+
oklab: { fn: "oklab" },
|
|
169
|
+
oklch: { fn: "oklch" }
|
|
170
|
+
};
|
|
171
|
+
const COLOR_SPACES = /* @__PURE__ */ new Set([
|
|
172
|
+
"srgb",
|
|
173
|
+
"srgb-linear",
|
|
174
|
+
"display-p3",
|
|
175
|
+
"a98-rgb",
|
|
176
|
+
"prophoto-rgb",
|
|
177
|
+
"rec2020",
|
|
178
|
+
"xyz-d65",
|
|
179
|
+
"xyz-d50"
|
|
180
|
+
]);
|
|
181
|
+
const STROKE_KEYWORDS = /* @__PURE__ */ new Set([
|
|
182
|
+
"solid",
|
|
183
|
+
"dashed",
|
|
184
|
+
"dotted",
|
|
185
|
+
"double",
|
|
186
|
+
"groove",
|
|
187
|
+
"ridge",
|
|
188
|
+
"outset",
|
|
189
|
+
"inset"
|
|
190
|
+
]);
|
|
191
|
+
function serializeValue(type, value, ctx, key) {
|
|
192
|
+
switch (type) {
|
|
193
|
+
case "color": return serializeColor(value, key);
|
|
194
|
+
case "dimension": return serializeMeasure(value, ["px", "rem"], key);
|
|
195
|
+
case "duration": return serializeMeasure(value, ["ms", "s"], key);
|
|
196
|
+
case "fontFamily": return serializeFontFamily(value, key);
|
|
197
|
+
case "fontWeight": return serializeFontWeight(value, key);
|
|
198
|
+
case "number": return serializeNumber(value, key);
|
|
199
|
+
case "cubicBezier": return serializeCubicBezier(value, key);
|
|
200
|
+
case "shadow": return serializeShadow(value, ctx, key);
|
|
201
|
+
case "border": return serializeBorder(value, ctx, key);
|
|
202
|
+
case "transition": return serializeTransition(value, ctx, key);
|
|
203
|
+
case "gradient": return serializeGradient(value, ctx, key);
|
|
204
|
+
case "strokeStyle": return serializeStrokeStyle(value, key);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
function field(type, value, ctx, key) {
|
|
208
|
+
let alias = aliasTarget(value);
|
|
209
|
+
if (alias !== null) return ctx.varRefFor(alias, key, type);
|
|
210
|
+
return serializeValue(type, value, ctx, key);
|
|
211
|
+
}
|
|
212
|
+
function invalid(key, type, value) {
|
|
213
|
+
return new ThemeError(`"${key}" has an invalid ${type} value: ${JSON.stringify(value)}`);
|
|
214
|
+
}
|
|
215
|
+
function serializeColor(value, key) {
|
|
216
|
+
if (typeof value === "string") return value;
|
|
217
|
+
if (typeof value !== "object" || value === null) throw invalid(key, "color", value);
|
|
218
|
+
let { colorSpace, components, alpha, hex } = value;
|
|
219
|
+
if (typeof hex === "string") return hex;
|
|
220
|
+
if (typeof colorSpace !== "string" || !Array.isArray(components)) throw invalid(key, "color", value);
|
|
221
|
+
let parts = components.map((component) => component === "none" ? "none" : serializeNumber(component, key));
|
|
222
|
+
let alphaPart = alpha === void 0 ? "" : ` / ${serializeNumber(alpha, key)}`;
|
|
223
|
+
let fn = COLOR_FUNCTIONS[colorSpace];
|
|
224
|
+
if (fn) {
|
|
225
|
+
let printed = parts.map((part, index) => fn.percents?.includes(index) && part !== "none" ? `${part}%` : part);
|
|
226
|
+
return `${fn.fn}(${printed.join(" ")}${alphaPart})`;
|
|
227
|
+
}
|
|
228
|
+
if (COLOR_SPACES.has(colorSpace)) return `color(${colorSpace} ${parts.join(" ")}${alphaPart})`;
|
|
229
|
+
throw new ThemeError(`"${key}" has unknown colorSpace "${colorSpace}"`);
|
|
230
|
+
}
|
|
231
|
+
function serializeMeasure(value, units, key) {
|
|
232
|
+
if (typeof value === "string") return value;
|
|
233
|
+
if (typeof value === "object" && value !== null) {
|
|
234
|
+
let { value: amount, unit } = value;
|
|
235
|
+
if (typeof amount === "number" && typeof unit === "string" && units.includes(unit)) return `${amount}${unit}`;
|
|
236
|
+
}
|
|
237
|
+
throw invalid(key, `dimension/duration (${units.join("|")})`, value);
|
|
238
|
+
}
|
|
239
|
+
function serializeFontFamily(value, key) {
|
|
240
|
+
let names = Array.isArray(value) ? value : [value];
|
|
241
|
+
if (names.length === 0) throw invalid(key, "fontFamily", value);
|
|
242
|
+
return names.map((name) => {
|
|
243
|
+
if (typeof name !== "string") throw invalid(key, "fontFamily", value);
|
|
244
|
+
return /^[a-zA-Z][a-zA-Z-]*$/.test(name) ? name : `"${name.replaceAll("\"", "\\\"")}"`;
|
|
245
|
+
}).join(", ");
|
|
246
|
+
}
|
|
247
|
+
function serializeFontWeight(value, key) {
|
|
248
|
+
if (typeof value === "number") {
|
|
249
|
+
if (value >= 1 && value <= 1e3) return String(value);
|
|
250
|
+
throw invalid(key, "fontWeight", value);
|
|
251
|
+
}
|
|
252
|
+
if (typeof value === "string") {
|
|
253
|
+
let mapped = FONT_WEIGHT_KEYWORDS[value];
|
|
254
|
+
if (mapped !== void 0) return String(mapped);
|
|
255
|
+
throw new ThemeError(`"${key}" has unknown fontWeight keyword "${value}"`);
|
|
256
|
+
}
|
|
257
|
+
throw invalid(key, "fontWeight", value);
|
|
258
|
+
}
|
|
259
|
+
function serializeNumber(value, key) {
|
|
260
|
+
if (typeof value !== "number" || !Number.isFinite(value)) throw invalid(key, "number", value);
|
|
261
|
+
return String(value);
|
|
262
|
+
}
|
|
263
|
+
function serializeCubicBezier(value, key) {
|
|
264
|
+
if (!Array.isArray(value) || value.length !== 4) throw invalid(key, "cubicBezier", value);
|
|
265
|
+
return `cubic-bezier(${value.map((part) => serializeNumber(part, key)).join(", ")})`;
|
|
266
|
+
}
|
|
267
|
+
function serializeShadow(value, ctx, key) {
|
|
268
|
+
return (Array.isArray(value) ? value : [value]).map((shadow) => {
|
|
269
|
+
if (typeof shadow !== "object" || shadow === null) throw invalid(key, "shadow", value);
|
|
270
|
+
let { color, offsetX, offsetY, blur, spread, inset } = shadow;
|
|
271
|
+
let parts = [
|
|
272
|
+
field("dimension", offsetX, ctx, key),
|
|
273
|
+
field("dimension", offsetY, ctx, key),
|
|
274
|
+
blur === void 0 ? "0" : field("dimension", blur, ctx, key),
|
|
275
|
+
spread === void 0 ? "0" : field("dimension", spread, ctx, key),
|
|
276
|
+
field("color", color, ctx, key)
|
|
277
|
+
];
|
|
278
|
+
return `${inset === true ? "inset " : ""}${parts.join(" ")}`;
|
|
279
|
+
}).join(", ");
|
|
280
|
+
}
|
|
281
|
+
function serializeBorder(value, ctx, key) {
|
|
282
|
+
if (typeof value !== "object" || value === null) throw invalid(key, "border", value);
|
|
283
|
+
let { color, width, style } = value;
|
|
284
|
+
return `${field("dimension", width, ctx, key)} ${field("strokeStyle", style, ctx, key)} ${field("color", color, ctx, key)}`;
|
|
285
|
+
}
|
|
286
|
+
function serializeTransition(value, ctx, key) {
|
|
287
|
+
if (typeof value !== "object" || value === null) throw invalid(key, "transition", value);
|
|
288
|
+
let { duration, timingFunction, delay } = value;
|
|
289
|
+
let delayPart = delay === void 0 ? "0s" : field("duration", delay, ctx, key);
|
|
290
|
+
return `${field("duration", duration, ctx, key)} ${field("cubicBezier", timingFunction, ctx, key)} ${delayPart}`;
|
|
291
|
+
}
|
|
292
|
+
function serializeGradient(value, ctx, key) {
|
|
293
|
+
if (!Array.isArray(value)) throw invalid(key, "gradient", value);
|
|
294
|
+
return value.map((stop) => {
|
|
295
|
+
if (typeof stop !== "object" || stop === null) throw invalid(key, "gradient", value);
|
|
296
|
+
let { color, position } = stop;
|
|
297
|
+
if (typeof position !== "number") throw invalid(key, "gradient stop position", position);
|
|
298
|
+
return `${field("color", color, ctx, key)} ${Number((position * 100).toFixed(4))}%`;
|
|
299
|
+
}).join(", ");
|
|
300
|
+
}
|
|
301
|
+
function serializeStrokeStyle(value, key) {
|
|
302
|
+
if (typeof value === "string") {
|
|
303
|
+
if (STROKE_KEYWORDS.has(value)) return value;
|
|
304
|
+
throw new ThemeError(`"${key}" has unknown strokeStyle keyword "${value}"`);
|
|
305
|
+
}
|
|
306
|
+
if (typeof value === "object" && value !== null) return "dashed";
|
|
307
|
+
throw invalid(key, "strokeStyle", value);
|
|
308
|
+
}
|
|
309
|
+
//#endregion
|
|
310
|
+
//#region src/theme.ts
|
|
311
|
+
function createThemeComponent(cssText) {
|
|
312
|
+
let escaped = cssText.replace(/<\/style/gi, "<\\/style");
|
|
313
|
+
return function Theme(handle) {
|
|
314
|
+
return () => createElement("style", {
|
|
315
|
+
nonce: handle.props.nonce,
|
|
316
|
+
"data-pitlane-theme": "",
|
|
317
|
+
innerHTML: escaped
|
|
318
|
+
});
|
|
319
|
+
};
|
|
320
|
+
}
|
|
321
|
+
/**
|
|
322
|
+
* Compiles a DTCG design-token document into a typed accessor, a
|
|
323
|
+
* `raw` resolver, and a `<Theme />` component. All validation and
|
|
324
|
+
* serialization happen eagerly here: a malformed document throws
|
|
325
|
+
* {@link ThemeError} rather than emitting broken CSS.
|
|
326
|
+
*
|
|
327
|
+
* Each token becomes a CSS custom property named after its
|
|
328
|
+
* kebab-cased path — `color.gray.900` becomes `--color-gray-900`.
|
|
329
|
+
* Two paths that collide after kebab-casing throw, as do names
|
|
330
|
+
* containing `.`, `{`, or `}`, which the alias syntax reserves.
|
|
331
|
+
*
|
|
332
|
+
* Author the document in TypeScript, not imported JSON: `createTheme`
|
|
333
|
+
* infers a `const` type parameter, so an inline object needs no
|
|
334
|
+
* `as const`, but a JSON import widens its literals and the token
|
|
335
|
+
* brands degrade.
|
|
336
|
+
*
|
|
337
|
+
* @param config - The token document. Groups nest to any depth; a
|
|
338
|
+
* node with a `$value` is a token.
|
|
339
|
+
* @param options - Optional per-mode overrides ({@link ThemeOptions}).
|
|
340
|
+
* @returns The {@link ThemeResult}: `token`, `raw`, and `Theme`.
|
|
341
|
+
* @throws ThemeError on any validation failure (unknown or
|
|
342
|
+
* unresolvable `$type`, reserved characters, variable collision,
|
|
343
|
+
* unknown or wrong-typed alias, alias cycle, invalid value, or a bad
|
|
344
|
+
* mode override).
|
|
345
|
+
*
|
|
346
|
+
* @see {@link DTCGDocument} for the document shape, the accepted
|
|
347
|
+
* `$value` forms, and alias semantics.
|
|
348
|
+
*
|
|
349
|
+
* @example
|
|
350
|
+
* ```ts
|
|
351
|
+
* export let { token: t, raw, Theme } = createTheme(
|
|
352
|
+
* {
|
|
353
|
+
* color: {
|
|
354
|
+
* $type: "color",
|
|
355
|
+
* white: { $value: "#fff" },
|
|
356
|
+
* gray: { 900: { $value: "#171717" } },
|
|
357
|
+
* bg: { $value: "{color.white}" }, // alias → var() indirection
|
|
358
|
+
* },
|
|
359
|
+
* },
|
|
360
|
+
* { modes: { dark: { color: { bg: { $value: "{color.gray.900}" } } } } },
|
|
361
|
+
* );
|
|
362
|
+
*
|
|
363
|
+
* t.color.bg; // "var(--color-bg)"
|
|
364
|
+
* raw(t.color.bg); // "#fff" (base mode, alias chased to the end)
|
|
365
|
+
* ```
|
|
366
|
+
*/
|
|
367
|
+
function createTheme(config, options = {}) {
|
|
368
|
+
let compiled = compile(config, options.modes ?? {});
|
|
369
|
+
let rawByRef = /* @__PURE__ */ new Map();
|
|
370
|
+
let rawByKey = /* @__PURE__ */ new Map();
|
|
371
|
+
for (let token of compiled.tokens.values()) rawByRef.set(`var(${token.varName})`, resolveRaw(token, compiled.tokens, rawByKey, []));
|
|
372
|
+
return {
|
|
373
|
+
token: buildAccessor(compiled.tokens),
|
|
374
|
+
raw(ref) {
|
|
375
|
+
let value = rawByRef.get(ref);
|
|
376
|
+
if (value === void 0) throw new ThemeError(`raw(): "${ref}" names a var this theme never minted`);
|
|
377
|
+
return value;
|
|
378
|
+
},
|
|
379
|
+
Theme: createThemeComponent(compiled.cssText)
|
|
380
|
+
};
|
|
381
|
+
}
|
|
382
|
+
function compile(config, modes) {
|
|
383
|
+
let tokens = parseTokens(config);
|
|
384
|
+
let ctx = { varRefFor(key, from, expected) {
|
|
385
|
+
let target = tokens.get(key);
|
|
386
|
+
if (!target) throw new ThemeError(`"${from}" references unknown token "${key}"`);
|
|
387
|
+
if (target.type !== expected) throw new ThemeError(`"${from}" references "${key}" of type "${target.type}" where "${expected}" is required`);
|
|
388
|
+
return `var(${target.varName})`;
|
|
389
|
+
} };
|
|
390
|
+
let declarations = /* @__PURE__ */ new Map();
|
|
391
|
+
for (let token of tokens.values()) declarations.set(token.varName, token.aliasOf !== void 0 ? ctx.varRefFor(token.aliasOf, token.key, token.type) : serializeValue(token.type, token.value, ctx, token.key));
|
|
392
|
+
let modeBlocks = [];
|
|
393
|
+
for (let mode of ["light", "dark"]) {
|
|
394
|
+
let overrides = modes[mode];
|
|
395
|
+
if (overrides === void 0) continue;
|
|
396
|
+
let modeDeclarations = compileModeOverrides(overrides, tokens, ctx);
|
|
397
|
+
if (modeDeclarations.size === 0) continue;
|
|
398
|
+
let lines = [...modeDeclarations].map(([name, value]) => ` ${name}: ${value};`);
|
|
399
|
+
modeBlocks.push(`@media (prefers-color-scheme: ${mode}) {\n :root {\n${lines.join("\n")}\n }\n}`);
|
|
400
|
+
}
|
|
401
|
+
return {
|
|
402
|
+
tokens,
|
|
403
|
+
cssText: buildCssText(declarations, modeBlocks)
|
|
404
|
+
};
|
|
405
|
+
}
|
|
406
|
+
function compileModeOverrides(overrides, tokens, ctx) {
|
|
407
|
+
let out = /* @__PURE__ */ new Map();
|
|
408
|
+
walkMode(overrides, [], tokens, ctx, out);
|
|
409
|
+
return out;
|
|
410
|
+
}
|
|
411
|
+
function walkMode(node, path, tokens, ctx, out) {
|
|
412
|
+
if (typeof node !== "object" || node === null) throw new ThemeError(`Mode override "${path.join(".")}" is neither a group nor a token`);
|
|
413
|
+
let record = node;
|
|
414
|
+
if ("$value" in record) {
|
|
415
|
+
let key = path.join(".");
|
|
416
|
+
if (Object.keys(record).length !== 1) throw new ThemeError(`Mode override "${key}" may only set $value`);
|
|
417
|
+
let base = tokens.get(key);
|
|
418
|
+
if (!base) throw new ThemeError(`Mode override "${key}" does not exist in the base document`);
|
|
419
|
+
let alias = aliasTarget(record.$value);
|
|
420
|
+
out.set(base.varName, alias !== null ? ctx.varRefFor(alias, key, base.type) : serializeValue(base.type, record.$value, ctx, key));
|
|
421
|
+
return;
|
|
422
|
+
}
|
|
423
|
+
for (let [key, child] of Object.entries(record)) {
|
|
424
|
+
if (key.startsWith("$")) throw new ThemeError(`Mode override "${[...path, key].join(".")}" may only set $value`);
|
|
425
|
+
walkMode(child, [...path, key], tokens, ctx, out);
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
function resolveRaw(token, tokens, memo, chain) {
|
|
429
|
+
let cached = memo.get(token.key);
|
|
430
|
+
if (cached !== void 0) return cached;
|
|
431
|
+
if (chain.includes(token.key)) throw new ThemeError(`Alias cycle: ${[...chain, token.key].join(" → ")}`);
|
|
432
|
+
let value;
|
|
433
|
+
if (token.aliasOf !== void 0) {
|
|
434
|
+
let target = tokens.get(token.aliasOf);
|
|
435
|
+
if (!target) throw new ThemeError(`"${token.key}" references unknown token "${token.aliasOf}"`);
|
|
436
|
+
value = resolveRaw(target, tokens, memo, [...chain, token.key]);
|
|
437
|
+
} else value = serializeValue(token.type, token.value, { varRefFor(key, from) {
|
|
438
|
+
let target = tokens.get(key);
|
|
439
|
+
if (!target) throw new ThemeError(`"${from}" references unknown token "${key}"`);
|
|
440
|
+
return resolveRaw(target, tokens, memo, [...chain, token.key]);
|
|
441
|
+
} }, token.key);
|
|
442
|
+
memo.set(token.key, value);
|
|
443
|
+
return value;
|
|
444
|
+
}
|
|
445
|
+
function buildAccessor(tokens) {
|
|
446
|
+
let root = Object.create(null);
|
|
447
|
+
for (let token of tokens.values()) {
|
|
448
|
+
let node = root;
|
|
449
|
+
for (let segment of token.path.slice(0, -1)) node = node[segment] ??= Object.create(null);
|
|
450
|
+
node[token.path[token.path.length - 1]] = `var(${token.varName})`;
|
|
451
|
+
}
|
|
452
|
+
return root;
|
|
453
|
+
}
|
|
454
|
+
function buildCssText(declarations, modeBlocks) {
|
|
455
|
+
return [`:root {\n${[...declarations].map(([name, value]) => ` ${name}: ${value};`).join("\n")}\n}`, ...modeBlocks].join("\n\n");
|
|
456
|
+
}
|
|
457
|
+
//#endregion
|
|
458
|
+
//#region src/css.ts
|
|
459
|
+
/**
|
|
460
|
+
* Brand-enforced wrapper over `remix/ui`'s `css()` mixin. Token-mapped
|
|
461
|
+
* longhands accept the matching token brand, CSS-wide keywords,
|
|
462
|
+
* property keywords, and `0`; anything else — including a raw
|
|
463
|
+
* `color: "#ff0000"` — is a type error. Every other CSS property
|
|
464
|
+
* carries csstype's value union, so `display`, `position`, `resize`,
|
|
465
|
+
* and the rest of the closed-grammar properties accept only their real
|
|
466
|
+
* keywords. Nested selectors, at-rules, and custom properties recurse.
|
|
467
|
+
*
|
|
468
|
+
* Branded token refs are already `var()` strings and pass through; an
|
|
469
|
+
* array value joins with spaces, which is how the box shorthands take
|
|
470
|
+
* a 1–4 tuple. A comma list needs a template string.
|
|
471
|
+
*
|
|
472
|
+
* `css()` is node-generic, exactly like `remix/ui`'s own `css`: the
|
|
473
|
+
* descriptor binds to the element type of the `mix` position it
|
|
474
|
+
* appears in, so write `css({ … })` inline at each element and share
|
|
475
|
+
* {@link ThemedCSSProps} objects, never stored descriptors.
|
|
476
|
+
*
|
|
477
|
+
* Interpolating a token into a template string
|
|
478
|
+
* (`` `1px solid ${t.color.line}` ``) yields a plain string, which the
|
|
479
|
+
* open-grammar shorthands accept.
|
|
480
|
+
*
|
|
481
|
+
* @see {@link ThemedCSSProps} for the accepted per-property values.
|
|
482
|
+
* @see {@link ThemedCSSMixin} for the returned descriptor.
|
|
483
|
+
*
|
|
484
|
+
* @example
|
|
485
|
+
* ```tsx
|
|
486
|
+
* <div
|
|
487
|
+
* mix={css({
|
|
488
|
+
* color: t.color.bg,
|
|
489
|
+
* padding: [t.space.sm, t.space.md],
|
|
490
|
+
* margin: 0,
|
|
491
|
+
* "&:hover": { color: t.color.gray[900] },
|
|
492
|
+
* })}
|
|
493
|
+
* />
|
|
494
|
+
* ```
|
|
495
|
+
*/
|
|
496
|
+
function css(styles) {
|
|
497
|
+
return css$1(normalizeStyles(styles));
|
|
498
|
+
}
|
|
499
|
+
function normalizeStyles(styles) {
|
|
500
|
+
let out = {};
|
|
501
|
+
for (let [key, value] of Object.entries(styles)) if (Array.isArray(value)) out[key] = value.join(" ");
|
|
502
|
+
else if (typeof value === "object" && value !== null) out[key] = normalizeStyles(value);
|
|
503
|
+
else out[key] = value;
|
|
504
|
+
return out;
|
|
505
|
+
}
|
|
506
|
+
//#endregion
|
|
507
|
+
//#region src/tva.ts
|
|
508
|
+
/**
|
|
509
|
+
* Builds a variant resolver modeled on [cva](https://cva.style). Where
|
|
510
|
+
* cva composes class strings, `tva` composes brand-enforced style
|
|
511
|
+
* objects into a `mix`-ready descriptor.
|
|
512
|
+
*
|
|
513
|
+
* Each invocation resolves the selection by deep-merging `base`, then
|
|
514
|
+
* every matching variant in declaration order, then every matching
|
|
515
|
+
* compound variant in array order, and feeds the result to a single
|
|
516
|
+
* {@link css} call. `defaultVariants` fills in unset axes, and boolean
|
|
517
|
+
* axes come from options named `true` and `false`.
|
|
518
|
+
*
|
|
519
|
+
* @see {@link TVAConfig} for the configuration shape.
|
|
520
|
+
* @see {@link TVAProps} to extract the props type.
|
|
521
|
+
*
|
|
522
|
+
* @example
|
|
523
|
+
* ```ts
|
|
524
|
+
* export let button = tva({
|
|
525
|
+
* base: { borderRadius: t.radius.md },
|
|
526
|
+
* variants: {
|
|
527
|
+
* intent: {
|
|
528
|
+
* primary: { backgroundColor: t.color.accent },
|
|
529
|
+
* secondary: { backgroundColor: "transparent" },
|
|
530
|
+
* },
|
|
531
|
+
* size: { sm: { fontSize: t.text.sm }, md: { fontSize: t.text.md } },
|
|
532
|
+
* block: { true: { display: "flex" } },
|
|
533
|
+
* },
|
|
534
|
+
* compoundVariants: [
|
|
535
|
+
* { intent: "secondary", size: "md", css: { fontSize: t.text.lg } },
|
|
536
|
+
* ],
|
|
537
|
+
* defaultVariants: { intent: "primary", size: "md" },
|
|
538
|
+
* });
|
|
539
|
+
*
|
|
540
|
+
* <button mix={button({ intent: "secondary", block: true })} />;
|
|
541
|
+
* ```
|
|
542
|
+
*/
|
|
543
|
+
function tva(config) {
|
|
544
|
+
function resolve(props) {
|
|
545
|
+
let selected = { ...config.defaultVariants };
|
|
546
|
+
for (let [key, value] of Object.entries(props ?? {})) if (value !== void 0) selected[key] = value;
|
|
547
|
+
let merged = { ...config.base };
|
|
548
|
+
for (let [name, values] of Object.entries(config.variants ?? {})) {
|
|
549
|
+
let choice = selected[name];
|
|
550
|
+
if (choice === void 0 || choice === null) continue;
|
|
551
|
+
let styles = values[String(choice)];
|
|
552
|
+
if (styles) merged = deepMerge(merged, styles);
|
|
553
|
+
}
|
|
554
|
+
for (let compound of config.compoundVariants ?? []) {
|
|
555
|
+
let { css: compoundCss, ...match } = compound;
|
|
556
|
+
if (Object.entries(match).every(([key, value]) => selected[key] === value)) merged = deepMerge(merged, compoundCss);
|
|
557
|
+
}
|
|
558
|
+
return merged;
|
|
559
|
+
}
|
|
560
|
+
let fn = (props) => css(resolve(props));
|
|
561
|
+
return Object.assign(fn, { resolve });
|
|
562
|
+
}
|
|
563
|
+
/**
|
|
564
|
+
* Plain objects merge recursively; arrays and primitives replace.
|
|
565
|
+
*
|
|
566
|
+
* @internal
|
|
567
|
+
*/
|
|
568
|
+
function deepMerge(a, b) {
|
|
569
|
+
if (!isPlainObject(a) || !isPlainObject(b)) return b;
|
|
570
|
+
let out = { ...a };
|
|
571
|
+
for (let [key, value] of Object.entries(b)) out[key] = key in out ? deepMerge(out[key], value) : value;
|
|
572
|
+
return out;
|
|
573
|
+
}
|
|
574
|
+
function isPlainObject(value) {
|
|
575
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
576
|
+
}
|
|
577
|
+
/**
|
|
578
|
+
* Composes {@link tva} components, like cva's `compose`. Each input
|
|
579
|
+
* resolves independently against the shared props, the results
|
|
580
|
+
* deep-merge in argument order, and one {@link css} call produces the
|
|
581
|
+
* descriptor. The result accepts the union of the inputs' props and
|
|
582
|
+
* honors each input's own defaults.
|
|
583
|
+
*
|
|
584
|
+
* @see {@link CombinedTVAFn}
|
|
585
|
+
*
|
|
586
|
+
* @example
|
|
587
|
+
* ```tsx
|
|
588
|
+
* export let pillButton = combine(button, rounded);
|
|
589
|
+
* <button mix={pillButton({ intent: "primary", pill: true })} />;
|
|
590
|
+
* ```
|
|
591
|
+
*/
|
|
592
|
+
function combine(...fns) {
|
|
593
|
+
function resolve(props) {
|
|
594
|
+
let merged = {};
|
|
595
|
+
for (let fn of fns) merged = deepMerge(merged, fn.resolve(props));
|
|
596
|
+
return merged;
|
|
597
|
+
}
|
|
598
|
+
let fn = (props) => css(resolve(props));
|
|
599
|
+
return Object.assign(fn, { resolve });
|
|
600
|
+
}
|
|
601
|
+
/**
|
|
602
|
+
* clsx-compatible `className` joiner for interop with plain
|
|
603
|
+
* stylesheets, since `mix` and `className` compose on the same
|
|
604
|
+
* element. Strings and numbers join with spaces, falsy values drop,
|
|
605
|
+
* arrays flatten, and truthy object keys join.
|
|
606
|
+
*
|
|
607
|
+
* @see {@link ClassValue}
|
|
608
|
+
*
|
|
609
|
+
* @example
|
|
610
|
+
* ```tsx
|
|
611
|
+
* <span className={cx("mono", isAlias && "alias-tag")} />;
|
|
612
|
+
* ```
|
|
613
|
+
*/
|
|
614
|
+
function cx(...inputs) {
|
|
615
|
+
let out = [];
|
|
616
|
+
for (let input of inputs) {
|
|
617
|
+
if (!input) continue;
|
|
618
|
+
if (typeof input === "string" || typeof input === "number") out.push(String(input));
|
|
619
|
+
else if (Array.isArray(input)) {
|
|
620
|
+
let inner = cx(...input);
|
|
621
|
+
if (inner) out.push(inner);
|
|
622
|
+
} else if (typeof input === "object") {
|
|
623
|
+
for (let [key, on] of Object.entries(input)) if (on) out.push(key);
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
return out.join(" ");
|
|
627
|
+
}
|
|
628
|
+
//#endregion
|
|
629
|
+
export { ThemeError, combine, createTheme, css, cx, tva };
|
package/package.json
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@pitlane/theme",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Type-safe styling with W3C design tokens for Remix 3.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"css-variables",
|
|
7
|
+
"design-tokens",
|
|
8
|
+
"dtcg",
|
|
9
|
+
"pitlane",
|
|
10
|
+
"remix",
|
|
11
|
+
"theme"
|
|
12
|
+
],
|
|
13
|
+
"homepage": "https://pitlane.tools/package/theme/",
|
|
14
|
+
"bugs": {
|
|
15
|
+
"url": "https://github.com/pitlane-tools/pitlane/issues"
|
|
16
|
+
},
|
|
17
|
+
"license": "MIT",
|
|
18
|
+
"author": "Mark Malstrom <mark@malstrom.me>",
|
|
19
|
+
"repository": {
|
|
20
|
+
"type": "git",
|
|
21
|
+
"url": "git+https://github.com/pitlane-tools/pitlane.git",
|
|
22
|
+
"directory": "packages/theme"
|
|
23
|
+
},
|
|
24
|
+
"files": [
|
|
25
|
+
"dist",
|
|
26
|
+
"CHANGELOG.md"
|
|
27
|
+
],
|
|
28
|
+
"type": "module",
|
|
29
|
+
"types": "./dist/index.d.mts",
|
|
30
|
+
"exports": {
|
|
31
|
+
".": {
|
|
32
|
+
"types": "./dist/index.d.mts",
|
|
33
|
+
"import": "./dist/index.mjs"
|
|
34
|
+
}
|
|
35
|
+
},
|
|
36
|
+
"scripts": {
|
|
37
|
+
"prepublishOnly": "vp run build"
|
|
38
|
+
},
|
|
39
|
+
"dependencies": {
|
|
40
|
+
"csstype": "^3.2.3"
|
|
41
|
+
},
|
|
42
|
+
"devDependencies": {
|
|
43
|
+
"remix": "3.0.0-beta.5",
|
|
44
|
+
"typescript": "^7.0.2",
|
|
45
|
+
"vite-plus": "^0.2.6"
|
|
46
|
+
},
|
|
47
|
+
"peerDependencies": {
|
|
48
|
+
"remix": "^3.0.0-beta.5"
|
|
49
|
+
},
|
|
50
|
+
"engines": {
|
|
51
|
+
"node": "^20.19.0 || >=22.12.0"
|
|
52
|
+
}
|
|
53
|
+
}
|