@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/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
+ }