@kiva/kv-tokens 4.0.0-next.0 → 4.0.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/README.md CHANGED
@@ -18,10 +18,10 @@ import designTokens from '@kiva/kv-tokens';
18
18
  const primaryTextColor = designTokens.colors.theme.DEFAULT.text.primary;
19
19
  ```
20
20
 
21
- You can also import the generated tokens module directly (flat named exports plus the nested default):
21
+ You can also import the generated JS module directly (flat named exports plus the nested default):
22
22
 
23
23
  ```js
24
- import tokens, { colorBrandDefault } from '@kiva/kv-tokens/tokens';
24
+ import tokens, { colorBrandDefault } from '@kiva/kv-tokens/js';
25
25
  ```
26
26
 
27
27
  ## Using the Tailwind Preset
@@ -59,11 +59,54 @@ Our Tailwind config has some differences to the standard install. Notably
59
59
  | Subpath | Resolves to | Use for |
60
60
  | ------------------------------ | --------------------------- | ----------------------------------------- |
61
61
  | `@kiva/kv-tokens` | `index.js` | Default: tokens object + theme/tailwind helpers |
62
- | `@kiva/kv-tokens/tokens` | `dist/js/tokens.js` | Generated JS tokens (flat + nested) |
62
+ | `@kiva/kv-tokens/tokens` | `dist/dtcg/tokens.json` | Single merged DTCG source manifest (aliases preserved) |
63
+ | `@kiva/kv-tokens/tokens/*` | `tokens/*` | Individual DTCG source files |
63
64
  | `@kiva/kv-tokens/tailwind` | `configs/tailwind.config.js`| Tailwind preset |
65
+ | `@kiva/kv-tokens/js` | `dist/js/tokens.js` | Generated JS tokens (flat + nested) |
64
66
  | `@kiva/kv-tokens/css` | `dist/css/tokens.css` | Themed CSS custom properties |
65
67
  | `@kiva/kv-tokens/scss` | `dist/scss/tokens.scss` | SCSS `$variable` map |
66
68
 
69
+ ## Importing Source Tokens
70
+
71
+ The `./tokens` subpath exports the design tokens **as authored** in [W3C DTCG](https://design-tokens.github.io/community-group/format/) format, with aliases (e.g. `"{color.brand.DEFAULT}"`), `$type`, and `$description` metadata preserved. This is the canonical source for downstream tooling that wants the untransformed shape:
72
+
73
+ - Figma plugins / Tokens Studio / Specify and other DTCG-native tools
74
+ - Alternative build pipelines (e.g. a mobile app using a different token transform)
75
+ - Drift-detection scripts comparing an external export against our source of truth
76
+ - Documentation generators and agent reference material that reason about token intent
77
+
78
+ ### Single merged manifest
79
+
80
+ ```js
81
+ import tokens from '@kiva/kv-tokens/tokens' with { type: 'json' };
82
+
83
+ tokens.color.brand.DEFAULT; // { $type: 'color', $value: '#2AA967' }
84
+ tokens.theme.DEFAULT.text.primary; // { $type: 'color', $value: '#223829' }
85
+ ```
86
+
87
+ The manifest is produced by [`build/build-dtcg.js`](build/build-dtcg.js), which deep-merges every source file under [`tokens/`](tokens/) and errors if two files define a leaf at the same path.
88
+
89
+ ### Individual source files
90
+
91
+ ```js
92
+ import coreColors from '@kiva/kv-tokens/tokens/core/color.json' with { type: 'json' };
93
+ import defaultTheme from '@kiva/kv-tokens/tokens/semantic/themes/default.json' with { type: 'json' };
94
+ ```
95
+
96
+ Or read them off disk from `node_modules/@kiva/kv-tokens/tokens/…` — useful for skill files and other tools that prefer file paths over module imports.
97
+
98
+ **Note on stability:** once shipped, the on-disk layout of `tokens/` (directory names, file names) is part of this package's public contract. Reorganizing it is a breaking change.
99
+
100
+ For the transformed, resolved-values-with-code-facing-names shape used by component code, prefer the default export of `@kiva/kv-tokens` or the flat-and-nested `@kiva/kv-tokens/js` module.
101
+
102
+ ## Local Development
103
+
104
+ The `dist/` directory is gitignored but regenerated automatically by the `prepare` lifecycle script whenever you run `npm install` — both at the monorepo root and inside this package. After a fresh clone, `npm install` leaves `dist/` ready to use, so downstream workspace packages like [`@kiva/kv-components`](../kv-components/) can consume tokens without a separate build step.
105
+
106
+ The same `prepare` hook runs before `npm publish` (and `lerna publish`), so published tarballs always contain a fresh `dist/`.
107
+
108
+ If you edit a token source file during development, rerun `npm run build` to regenerate `dist/`. See [Editing Tokens](#editing-tokens) below.
109
+
67
110
  ## Editing Tokens
68
111
 
69
112
  Tokens live under [`tokens/`](tokens/) as DTCG JSON:
@@ -0,0 +1,31 @@
1
+ // Emits dist/dtcg/tokens.json — a single deep-merged DTCG manifest of every
2
+ // source token under tokens/. Aliases (e.g. "{color.brand.DEFAULT}") and
3
+ // $type/$description metadata are preserved verbatim so external consumers
4
+ // (Figma plugins, alternative build pipelines, agent reference docs) get
5
+ // the canonical authored shape, not a transformed view of it.
6
+
7
+ import {
8
+ mkdirSync, readFileSync, writeFileSync,
9
+ } from 'node:fs';
10
+ import { dirname, join, relative } from 'node:path';
11
+ import { fileURLToPath } from 'node:url';
12
+
13
+ import { findJsonFiles, mergeTokens } from './dtcg-merge.js';
14
+
15
+ const PACKAGE_ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
16
+ const TOKENS_DIR = join(PACKAGE_ROOT, 'tokens');
17
+ const OUT_DIR = join(PACKAGE_ROOT, 'dist', 'dtcg');
18
+ const OUT_FILE = join(OUT_DIR, 'tokens.json');
19
+
20
+ const files = findJsonFiles(TOKENS_DIR);
21
+ const sources = files.map((path) => ({
22
+ path: relative(PACKAGE_ROOT, path),
23
+ contents: JSON.parse(readFileSync(path, 'utf8')),
24
+ }));
25
+
26
+ const merged = mergeTokens(sources);
27
+
28
+ mkdirSync(OUT_DIR, { recursive: true });
29
+ writeFileSync(OUT_FILE, `${JSON.stringify(merged, null, '\t')}\n`, 'utf8');
30
+
31
+ process.stdout.write(`dtcg: wrote ${relative(PACKAGE_ROOT, OUT_FILE)} from ${files.length} source files\n`);
@@ -0,0 +1,60 @@
1
+ // Pure merge logic for the raw DTCG manifest. Kept side-effect free so the
2
+ // CLI wrapper (build-dtcg.js) and the test suite share the same code path.
3
+
4
+ import { readdirSync, statSync } from 'node:fs';
5
+ import { join } from 'node:path';
6
+
7
+ const isLeaf = (node) => node && typeof node === 'object' && '$value' in node;
8
+
9
+ export function findJsonFiles(rootDir) {
10
+ const out = [];
11
+ const walk = (dir) => {
12
+ readdirSync(dir).forEach((entry) => {
13
+ const full = join(dir, entry);
14
+ if (statSync(full).isDirectory()) {
15
+ walk(full);
16
+ } else if (entry.endsWith('.json')) {
17
+ out.push(full);
18
+ }
19
+ });
20
+ };
21
+ walk(rootDir);
22
+ return out.sort();
23
+ }
24
+
25
+ // Deep-merges DTCG token sources into a single object. Throws if two source
26
+ // files both define a leaf at the same path — the canonical source must not
27
+ // have ambiguous duplicates.
28
+ export function mergeTokens(sources) {
29
+ const out = {};
30
+ const provenance = new Map();
31
+
32
+ const walk = (node, path, fromFile) => {
33
+ if (isLeaf(node)) {
34
+ const pathKey = path.join('.');
35
+ if (provenance.has(pathKey)) {
36
+ throw new Error(
37
+ `Leaf token collision at "${pathKey}" — defined in both `
38
+ + `${provenance.get(pathKey)} and ${fromFile}`,
39
+ );
40
+ }
41
+ let cursor = out;
42
+ for (let i = 0; i < path.length - 1; i += 1) {
43
+ cursor[path[i]] = cursor[path[i]] ?? {};
44
+ cursor = cursor[path[i]];
45
+ }
46
+ cursor[path[path.length - 1]] = node;
47
+ provenance.set(pathKey, fromFile);
48
+ return;
49
+ }
50
+ if (node && typeof node === 'object' && !Array.isArray(node)) {
51
+ Object.keys(node).forEach((key) => walk(node[key], [...path, key], fromFile));
52
+ }
53
+ };
54
+
55
+ sources.forEach(({ path, contents }) => {
56
+ walk(contents, [], path);
57
+ });
58
+
59
+ return out;
60
+ }
@@ -0,0 +1,140 @@
1
+ import { test } from 'node:test';
2
+ import { strict as assert } from 'node:assert';
3
+
4
+ import { mergeTokens } from './dtcg-merge.js';
5
+
6
+ test('merges disjoint top-level keys from multiple sources', () => {
7
+ const result = mergeTokens([
8
+ {
9
+ path: 'a.json',
10
+ contents: { color: { red: { $type: 'color', $value: '#f00' } } },
11
+ },
12
+ {
13
+ path: 'b.json',
14
+ contents: {
15
+ space: { 1: { $type: 'dimension', $value: { value: 4, unit: 'px' } } },
16
+ },
17
+ },
18
+ ]);
19
+
20
+ assert.equal(result.color.red.$value, '#f00');
21
+ assert.equal(result.space[1].$value.value, 4);
22
+ });
23
+
24
+ test('merges nested keys at the same branch without collision', () => {
25
+ const result = mergeTokens([
26
+ {
27
+ path: 'theme-default.json',
28
+ contents: {
29
+ theme: { DEFAULT: { text: { primary: { $type: 'color', $value: '#000' } } } },
30
+ },
31
+ },
32
+ {
33
+ path: 'theme-dark.json',
34
+ contents: {
35
+ theme: { dark: { text: { primary: { $type: 'color', $value: '#fff' } } } },
36
+ },
37
+ },
38
+ ]);
39
+
40
+ assert.equal(result.theme.DEFAULT.text.primary.$value, '#000');
41
+ assert.equal(result.theme.dark.text.primary.$value, '#fff');
42
+ });
43
+
44
+ test('preserves alias references as literal strings', () => {
45
+ const result = mergeTokens([
46
+ {
47
+ path: 'core.json',
48
+ contents: { color: { brand: { $type: 'color', $value: '#2AA967' } } },
49
+ },
50
+ {
51
+ path: 'semantic.json',
52
+ contents: {
53
+ theme: { DEFAULT: { primary: { $type: 'color', $value: '{color.brand}' } } },
54
+ },
55
+ },
56
+ ]);
57
+
58
+ assert.equal(result.theme.DEFAULT.primary.$value, '{color.brand}');
59
+ });
60
+
61
+ test('preserves $type and $description metadata', () => {
62
+ const result = mergeTokens([
63
+ {
64
+ path: 'a.json',
65
+ contents: {
66
+ color: {
67
+ red: {
68
+ $type: 'color',
69
+ $value: '#f00',
70
+ $description: 'danger signal',
71
+ },
72
+ },
73
+ },
74
+ },
75
+ ]);
76
+
77
+ assert.equal(result.color.red.$type, 'color');
78
+ assert.equal(result.color.red.$description, 'danger signal');
79
+ });
80
+
81
+ test('preserves dimension $value object shape (does not unwrap)', () => {
82
+ const result = mergeTokens([
83
+ {
84
+ path: 'a.json',
85
+ contents: {
86
+ space: { 1: { $type: 'dimension', $value: { value: 4, unit: 'px' } } },
87
+ },
88
+ },
89
+ ]);
90
+
91
+ assert.deepEqual(result.space[1].$value, { value: 4, unit: 'px' });
92
+ });
93
+
94
+ test('throws on leaf collision with both source paths in the message', () => {
95
+ assert.throws(
96
+ () => mergeTokens([
97
+ {
98
+ path: 'a.json',
99
+ contents: { color: { brand: { $type: 'color', $value: '#000' } } },
100
+ },
101
+ {
102
+ path: 'b.json',
103
+ contents: { color: { brand: { $type: 'color', $value: '#fff' } } },
104
+ },
105
+ ]),
106
+ (err) => {
107
+ assert.match(err.message, /collision/);
108
+ assert.match(err.message, /color\.brand/);
109
+ assert.match(err.message, /a\.json/);
110
+ assert.match(err.message, /b\.json/);
111
+ return true;
112
+ },
113
+ );
114
+ });
115
+
116
+ test('leaves $value untouched when it is the literal string "0"', () => {
117
+ // Guard against a naive `!$value` check treating falsy values as missing.
118
+ const result = mergeTokens([
119
+ {
120
+ path: 'a.json',
121
+ contents: { opacity: { none: { $type: 'opacity', $value: 0 } } },
122
+ },
123
+ ]);
124
+
125
+ assert.equal(result.opacity.none.$value, 0);
126
+ });
127
+
128
+ test('returns an empty object for empty input', () => {
129
+ assert.deepEqual(mergeTokens([]), {});
130
+ });
131
+
132
+ test('merges three sources sharing the same branch', () => {
133
+ const result = mergeTokens([
134
+ { path: 'a.json', contents: { color: { a: { $type: 'color', $value: '#a' } } } },
135
+ { path: 'b.json', contents: { color: { b: { $type: 'color', $value: '#b' } } } },
136
+ { path: 'c.json', contents: { color: { c: { $type: 'color', $value: '#c' } } } },
137
+ ]);
138
+
139
+ assert.deepEqual(Object.keys(result.color).sort(), ['a', 'b', 'c']);
140
+ });