@pitlane/theme 0.1.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,130 @@
1
1
  # @pitlane/theme
2
2
 
3
+ ## 0.3.0
4
+
5
+ Authoring moves off W3C DTCG. `createTheme` now takes a schema tree and the
6
+ token tree it describes, and token values are the CSS they become. DTCG stays
7
+ as an interchange format behind `@pitlane/theme/dtcg`.
8
+
9
+ **Breaking.** Every theme written against 0.1.0 or 0.2.0 has to be rewritten.
10
+ There is no compatibility shim, and `createTheme` does not accept a DTCG
11
+ document any more. `fromDTCG` reads one, so an existing document can be moved
12
+ across without being retyped by hand:
13
+
14
+ ```ts
15
+ import { createTheme } from "@pitlane/theme";
16
+ import { fromDTCG } from "@pitlane/theme/dtcg";
17
+
18
+ export let { token: t, raw, Theme } = createTheme(fromDTCG(existingDocument));
19
+ ```
20
+
21
+ What changes in a hand-written theme:
22
+
23
+ | 0.2.0 | 0.3.0 |
24
+ | -------------------------------------------------- | ---------------------------------------- |
25
+ | `createTheme(document, { modes })` | `createTheme({ schema, tokens, modes })` |
26
+ | `{ $type: "color" }` on a group | `color: s.color()` in the schema tree |
27
+ | `{ $value: "#fff" }` | `"#fff"` |
28
+ | `$value: { value: 2.5, unit: "rem" }` | `"2.5rem"` |
29
+ | `$value: { colorSpace: "oklch", components: […] }` | the `oklch(…)` text |
30
+ | A shadow, border, transition, or gradient object | the CSS shorthand text |
31
+ | `$value: [0.25, 0.1, 0.25, 1]` on `cubicBezier` | the same tuple, under `s.easing()` |
32
+ | A shadow's `color: "{color.line}"` sub-value | the reference inside the shorthand text |
33
+ | `modes: { dark: { … { $value } } }` | `modes: { dark: { tokens: { … } } }` |
34
+ | `"{color.white}"` | `base.color.white` in an `extend` layer |
35
+
36
+ The accessor shape, `raw`, `<Theme />`, `css`, `tva`, `combine`, and `cx` are
37
+ all unchanged.
38
+
39
+ References need the most attention. The `"{color.white}"` string syntax is gone,
40
+ and a reference is a property access on the layer below:
41
+
42
+ ```ts
43
+ createTheme({ schema: { color: s.color() }, tokens: { color: { white: "#fff" } } }).extend(
44
+ base => ({
45
+ schema: { surface: s.color() },
46
+ tokens: { surface: { page: base.color.white } },
47
+ }),
48
+ );
49
+ ```
50
+
51
+ The emitted CSS is the same, `--surface-page: var(--color-white)`, so overrides
52
+ still cascade. What changes is that the reference is now checked: its type has
53
+ to match its position, and renaming the target breaks the reference at build
54
+ time instead of emitting a variable nothing declares.
55
+
56
+ Three consequences worth planning for. A semantic tier becomes a separate
57
+ `extend` from the primitives it names. A mode override that references another
58
+ token goes in an `extend` layer too, since that is where an accessor is in
59
+ scope. And a DTCG sub-value alias, such as a shadow whose `color` field was
60
+ `{color.line}`, becomes an interpolation:
61
+ ``tokens: { shadow: { card: `0 1px 2px ${base.color.line}` } }``.
62
+
63
+ `fromDTCG` converts a document's aliases for you, including the ones inside a
64
+ composite's sub-values, so an existing document needs no hand-editing.
65
+
66
+ One side effect worth knowing: a layer's declarations follow the ones it
67
+ references, so moving a semantic tier into an `extend` moves its declarations
68
+ later in the `:root` block. Custom properties in one rule resolve independently
69
+ of order, so nothing about the cascade changes. Declaring the namespace as an
70
+ empty group in the base tree reserves its position if the order matters for
71
+ diffing.
72
+
73
+ - `createTheme({ schema, tokens, modes })` replaces
74
+ `createTheme(document, options)`. A leaf is a string, a number, or an array;
75
+ a plain object is a group. No `$value` wrappers, and no reserved token names.
76
+ - `@pitlane/theme/schema` — one factory per token type, built on
77
+ `remix/data-schema` and designed for `import * as s`. `s.color()`,
78
+ `s.dimension()`, `s.duration()`, `s.number()`, `s.easing()`, `s.shadow()`,
79
+ `s.border()`, `s.transition()`, `s.gradient()`, `s.stroke()`,
80
+ `s.font.family()`, and `s.font.weight()` name a token type;
81
+ `s.group(self, children)` types a node and lets its children override it;
82
+ `s.scale()` declares a base whose accessor leaf multiplies; `s.any()`
83
+ declines to type a token at all.
84
+ - Composite types (`shadow`, `border`, `transition`, `gradient`) and
85
+ `cubicBezier` take CSS text. `inset`, `em`, `%`, `clamp()`, `light-dark()`,
86
+ and `color-mix()` all work, none of which DTCG can express. The structured
87
+ object forms remain on the DTCG import path.
88
+ - `theme.extend(patch)` deep-merges a patch and returns a new theme.
89
+ `theme.select(projection)` replaces the theme with a projection of it, which
90
+ may also reshape and rename. Both take a callback that receives the accessor,
91
+ so a layer can reference what it builds on.
92
+ - `<Theme />` carries the init it was compiled from as `$theme`, and
93
+ `createTheme(SomeTheme)` reads it, so a published theme is one import.
94
+ - `@pitlane/theme/default` — `DefaultTheme`, Tailwind v4's primitives with no
95
+ semantic layer. Pair it with `select` to avoid shipping every one of them.
96
+ - `@pitlane/theme/dtcg` — `fromDTCG` reads a conformant 2025.10 document and
97
+ derives its schema from each token's resolved `$type`; `toDTCG` writes one
98
+ out and counts the values the format cannot express.
99
+ - `scale(base)` multiplies any dimension, duration, or number token, keeping
100
+ its brand. `lightDark(light, dark)` writes a `light-dark()` color, and
101
+ composes with token references.
102
+ - A mode declares its own condition. `media` defaults to
103
+ `(prefers-color-scheme: <name>)`; `selector` emits a second block for a
104
+ user-selectable toggle, which outranks the media block on specificity so an
105
+ explicit choice beats the OS preference.
106
+ - A `"{a.b.c}"` string left over from the old format raises `ThemeError` naming
107
+ the `extend` that replaces it. Braces are never valid in a CSS value, so
108
+ passing one through would put `{color.white}` in the stylesheet where a color
109
+ belongs.
110
+ - A reference is a property access, so it is type-checked wherever it appears,
111
+ including interpolated into a composite's CSS text. `select` refuses a
112
+ projection that drops a token something it kept refers to, and a reference
113
+ whose type does not match its position raises `ThemeError`.
114
+ - Bad token values raise `ValidationError` from `remix/data-schema`, one issue
115
+ per bad token with its own path, all reported in one pass. Read `issues`
116
+ rather than `message`. `ThemeError` covers the structural failures: an
117
+ unknown or wrongly-typed reference, a reference to an untyped token, a
118
+ reference cycle, a variable collision, a reserved character in a name, and a
119
+ mode overriding a token that does not exist.
120
+ - `css()`, `tva()`, `combine()`, and `cx()` are unchanged.
121
+
122
+ ## 0.2.0
123
+
124
+ - Raised the `remix` peer dependency to `^3.0.0-beta.10` (from
125
+ `^3.0.0-beta.5`). No API changes — `createTheme`, `<Theme />`, and the
126
+ `css`/`tva`/`combine`/`cx` helpers are unchanged.
127
+
3
128
  ## 0.1.0
4
129
 
5
130
  Initial release.
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # @pitlane/theme
2
2
 
3
- Type-safe styling with W3C design tokens for [Remix 3](https://remix.run). `createTheme` takes a [DTCG token document](https://www.designtokens.org/tr/drafts/format/) and returns a typed token accessor plus a `<Theme />` component that installs the tokens as CSS custom properties. The `css`, `tva`, `combine`, and `cx` helpers wrap `remix/ui`'s `css()` mixin and enforce your palette at the type level.
3
+ Type-safe styling with design tokens for [Remix 3](https://remix.run). `createTheme` compiles a schema tree and a tree of CSS values into a typed token accessor plus a `<Theme />` component that installs CSS custom properties. The `css`, `tva`, `combine`, and `cx` helpers wrap `remix/ui`'s `css()` mixin and enforce the theme palette at the type level.
4
4
 
5
5
  ## Install
6
6
 
@@ -10,37 +10,44 @@ npm install @pitlane/theme
10
10
  vp add @pitlane/theme
11
11
  ```
12
12
 
13
- Requires `remix@^3.0.0-beta.5` as a peer.
13
+ Requires `remix@^3.0.0-beta.10` as a peer.
14
14
 
15
15
  ## Quick start
16
16
 
17
+ Define the schema beside the tokens it describes. Token values are the CSS they become:
18
+
17
19
  ```ts
18
20
  // app/theme.ts
19
- import { createTheme } from "@pitlane/theme";
21
+ import { createTheme, lightDark } from "@pitlane/theme";
22
+ import * as s from "@pitlane/theme/schema";
20
23
 
21
24
  export let {
22
25
  token: t,
23
26
  raw,
24
27
  Theme,
25
- } = createTheme(
26
- {
27
- color: {
28
- $type: "color",
29
- white: { $value: "#fff" },
30
- gray: { 50: { $value: "#fafafa" }, 900: { $value: "#171717" } },
31
- bg: { $value: "{color.white}" },
32
- },
33
- space: { $type: "dimension", sm: { $value: "8px" }, md: { $value: "16px" } },
28
+ } = createTheme({
29
+ schema: {
30
+ color: s.color(),
31
+ spacing: s.scale(),
32
+ radius: s.dimension(),
33
+ shadow: s.shadow(),
34
+ animate: s.any(),
34
35
  },
35
- {
36
- modes: {
37
- dark: { color: { bg: { $value: "{color.gray.900}" } } },
36
+ tokens: {
37
+ color: {
38
+ white: "#fff",
39
+ gray: { 50: "#fafafa", 900: "#171717" },
40
+ page: lightDark("#fff", "#171717"),
38
41
  },
42
+ spacing: "0.25rem",
43
+ radius: { full: "999px", responsive: "clamp(0.25rem, 2vw, 1rem)" },
44
+ shadow: { card: "0 1px 2px rgb(0 0 0 / 0.07)" },
45
+ animate: { spin: "spin 1s linear infinite" },
39
46
  },
40
- );
47
+ });
41
48
  ```
42
49
 
43
- Render `<Theme />` once near the root. It emits a single `<style data-pitlane-theme>` element containing `:root` plus one `@media (prefers-color-scheme: dark)` block for the override:
50
+ Render `<Theme />` once near the root. It emits one `<style data-pitlane-theme>` element with the custom properties:
44
51
 
45
52
  ```tsx
46
53
  import { Theme } from "./theme.ts";
@@ -57,7 +64,9 @@ function App() {
57
64
  }
58
65
  ```
59
66
 
60
- Pass tokens to `css()` inline at each element, through the `mix` prop. Token-mapped properties accept only the matching brand, so off-palette literals fail to compile:
67
+ `<Theme />` declares `color-scheme: light dark` on `:root` whenever a token uses `lightDark()`, because `light-dark()` resolves to its light value when `color-scheme` is undeclared. Declare a narrower value yourself to override it.
68
+
69
+ Pass tokens to `css()` inline at each element through the `mix` prop. Token-mapped properties accept only the matching brand:
61
70
 
62
71
  ```tsx
63
72
  import { css } from "@pitlane/theme";
@@ -65,24 +74,53 @@ import { t } from "./theme.ts";
65
74
 
66
75
  <article
67
76
  mix={css({
68
- color: t.color.bg, // ✓ ColorToken
69
- padding: [t.space.sm, t.space.md], // ✓ 1–4 token tuple
70
- margin: 0, // ✓ literal zero
71
- // color: "#ff0000", // not in the palette
77
+ color: t.color.page,
78
+ padding: [t.spacing(2), t.spacing(4)],
79
+ margin: 0,
80
+ // color: "#ff0000", // type error: outside the palette
72
81
  "&:hover": { color: t.color.gray[900] },
73
82
  })}
74
83
  />;
75
84
  ```
76
85
 
86
+ `t.spacing(4)` produces `calc(var(--spacing) * 4)`, and `t.spacing.token` is the unmultiplied `var(--spacing)`. Use module-level `scale(token)` to multiply an ordinary dimension, duration, or number token.
87
+
88
+ ## Reference one token from another
89
+
90
+ A reference is a property access on the layer below, so it goes in an `extend`. There is no string syntax for one, which is what lets the compiler check every reference and break the build when a target is renamed:
91
+
92
+ ```ts
93
+ export let {
94
+ token: t,
95
+ raw,
96
+ Theme,
97
+ } = createTheme({
98
+ schema: { palette: s.color() },
99
+ tokens: { palette: { ink: "#1c1a16", paper: "#f5f1e8" } },
100
+ }).extend(base => ({
101
+ schema: { color: s.color(), shadow: s.shadow() },
102
+ tokens: {
103
+ color: { text: base.palette.ink, surface: base.palette.paper },
104
+ // A composite is CSS text, so a reference goes in by interpolation.
105
+ shadow: { card: `0 1px 2px ${base.palette.ink}` },
106
+ },
107
+ }));
108
+ ```
109
+
110
+ The emitted declarations keep their `var()` indirection, `--color-text: var(--palette-ink)`, so overriding a primitive reaches everything that references it. A mode override that references another token goes in an `extend` layer too, since that is where an accessor is in scope.
111
+
112
+ A layer's declarations follow the ones they reference, which moves them later in the `:root` block. Custom properties in one rule resolve independently of order. Declare the namespace as an empty group in the base tree to reserve its position.
113
+
77
114
  ## Exports
78
115
 
79
- - `createTheme(document, options?)` returns `{ token, raw, Theme }`. `token` (conventionally `t`) mirrors the document; each leaf is a branded `var()` string. `raw(ref)` resolves the base-mode value behind a ref. `<Theme />` installs the CSS custom properties and accepts an optional `nonce`.
80
- - `css(props)` `remix/ui`'s `css()` with brand enforcement; call it inline at each `mix` callsite.
81
- - `tva(config)` a cva-style variant resolver returning a `mix`-ready descriptor.
82
- - `combine(...fns)` composes `tva` components into one.
83
- - `cx(...)` a clsx-compatible `className` joiner.
84
- - `ThemeError` thrown for invalid documents, references, or overrides.
85
- - Types: `ThemeOptions`, `ThemeProps`, `ThemeResult`, `ThemeComponent`, `ThemedCSSProps`, `ThemedCSSMixin`, `TVAConfig`, `TVAProps`, `TVAFn`, `CombinedTVAFn`, `ClassValue`, `DTCGDocument`, `TokenTree`, `DeepPartialTokens`, and the per-type token brands (`ColorToken`, `DimensionToken`, `DurationToken`, and the rest).
116
+ - `createTheme({ schema, tokens, modes? })` compiles a theme and returns `{ token, raw, Theme, extend, select }`. `token`, conventionally `t`, mirrors the tree with branded `var()` strings. `raw(ref)` resolves a base value. `<Theme />` installs the custom properties.
117
+ - `createTheme(DefaultTheme)` accepts a published theme component and returns a derivable theme. `@pitlane/theme/default` exports `DefaultTheme`, Tailwind v4 primitives without a semantic layer.
118
+ - `css(props)` is `remix/ui`'s `css()` with token-brand enforcement. Call it inline at each `mix` callsite.
119
+ - `tva(config)` creates a cva-style variant resolver. `combine(...fns)` composes tva components. `cx(...)` joins clsx-compatible class values.
120
+ - `lightDark(light, dark)` returns CSS `light-dark()` text. `scale(token)` returns a multiplier for an ordinary dimension, duration, or number token.
121
+ - `ThemeError` reports structural failures: a reference whose type does not match its position, a reference to an untyped token, a variable collision, an undeclared token, a mode overriding an unknown token, and a `"{a.b.c}"` string left over from the pre-0.3.0 format. Invalid values raise `ValidationError` from `remix/data-schema`, whose `issues` array contains the detail.
122
+ - `@pitlane/theme/schema` exports `s.color()`, `s.dimension()`, `s.duration()`, `s.number()`, `s.easing()`, `s.shadow()`, `s.border()`, `s.transition()`, `s.gradient()`, `s.stroke()`, `s.font.family()`, `s.font.weight()`, `s.scale()`, `s.any()`, and `s.group()`.
123
+ - Types include `ThemeInit`, `ThemeMode`, `ThemeResult`, `ThemeComponent`, `ThemeProps`, `TokenTree`, `ScaleFn`, `Tokens`, `TokenValue`, `ThemedCSSProps`, `ThemedCSSMixin`, TVA types, and per-type token brands.
86
124
 
87
125
  ## Links
88
126