@scattered-light/loomiere 0.0.0-stage → 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.
Files changed (73) hide show
  1. package/README.md +60 -2
  2. package/bin/loomiere.mjs +112 -0
  3. package/build/build-tokens.mjs +485 -0
  4. package/exporters/README.md +45 -0
  5. package/exporters/ag-grid-settings.mjs +177 -0
  6. package/exporters/ag-grid.mjs +112 -0
  7. package/exporters/chakra.mjs +630 -0
  8. package/exporters/component-styles.mjs +207 -0
  9. package/exporters/dtcg.mjs +31 -0
  10. package/exporters/figma.mjs +100 -0
  11. package/exporters/index.mjs +38 -0
  12. package/exporters/mui.mjs +666 -0
  13. package/exporters/tailwind.mjs +35 -0
  14. package/exporters/theme.mjs +93 -0
  15. package/generator/brand.mjs +328 -0
  16. package/generator/brand.schema.json +657 -0
  17. package/generator/color.mjs +130 -0
  18. package/generator/data-colors.mjs +104 -0
  19. package/generator/format-brand.mjs +60 -0
  20. package/generator/generate.mjs +43 -0
  21. package/generator/pairs.mjs +236 -0
  22. package/generator/roles.mjs +258 -0
  23. package/generator/vision.mjs +167 -0
  24. package/lib/config.mjs +71 -0
  25. package/lib/contrast.mjs +134 -0
  26. package/lib/lab.mjs +108 -0
  27. package/lib/studio.mjs +127 -0
  28. package/lib/tool-server.mjs +96 -0
  29. package/package.json +51 -4
  30. package/templates/brand.json +104 -0
  31. package/tokens/README.md +219 -0
  32. package/tokens/component/avatar.json +10 -0
  33. package/tokens/component/button.json +12 -0
  34. package/tokens/component/drawer.json +26 -0
  35. package/tokens/component/navigation-menu.json +13 -0
  36. package/tokens/component/popover.json +13 -0
  37. package/tokens/component/preview-card.json +13 -0
  38. package/tokens/component/slider.json +13 -0
  39. package/tokens/component/switch.json +41 -0
  40. package/tokens/component/toast.json +21 -0
  41. package/tokens/component/toggle.json +9 -0
  42. package/tokens/component/toolbar.json +9 -0
  43. package/tokens/component/tooltip.json +9 -0
  44. package/tokens/foundation/layering.json +57 -0
  45. package/tokens/foundation/layout.json +75 -0
  46. package/tokens/foundation/motion.json +41 -0
  47. package/tokens/foundation/type.json +92 -0
  48. package/tokens/semantic/accessibility.json +39 -0
  49. package/tokens/semantic/bar.json +45 -0
  50. package/tokens/semantic/check.json +57 -0
  51. package/tokens/semantic/color.json +270 -0
  52. package/tokens/semantic/content.json +124 -0
  53. package/tokens/semantic/disclosure.json +49 -0
  54. package/tokens/semantic/elevation.json +72 -0
  55. package/tokens/semantic/field.json +77 -0
  56. package/tokens/semantic/focus.json +36 -0
  57. package/tokens/semantic/gauge.json +45 -0
  58. package/tokens/semantic/icon-button.json +33 -0
  59. package/tokens/semantic/icon.json +30 -0
  60. package/tokens/semantic/item.json +65 -0
  61. package/tokens/semantic/modal.json +86 -0
  62. package/tokens/semantic/motion.json +20 -0
  63. package/tokens/semantic/notice.json +37 -0
  64. package/tokens/semantic/popup.json +89 -0
  65. package/tokens/semantic/scheme.json +11 -0
  66. package/tokens/semantic/shape.json +54 -0
  67. package/tokens/semantic/spacing.json +110 -0
  68. package/tokens/semantic/table.json +73 -0
  69. package/tokens/semantic/typography.json +312 -0
  70. package/tools/brand-builder/builder.js +1119 -0
  71. package/tools/brand-builder/index.html +329 -0
  72. package/tools/pair-lab/index.html +119 -0
  73. package/tools/pair-lab/lab.js +249 -0
package/lib/lab.mjs ADDED
@@ -0,0 +1,108 @@
1
+ /**
2
+ * The pair lab: a local page for choosing brand pairs.
3
+ *
4
+ * loomiere lab [--brand brand/name.json] [--port 4321]
5
+ *
6
+ * Serves tools/pair-lab/ with the brand's built palettes (the theme's generated
7
+ * primitive color.json, so run loomiere build after changing palettes). Save writes
8
+ * color.pairs into the brand file, then rebuilds the theme; if the build refuses a pair,
9
+ * the brand file is put back the way it was.
10
+ */
11
+ import fs from 'node:fs';
12
+ import path from 'node:path';
13
+
14
+ import { engineRoot, readJson, saveAndRebuild, serveTool } from './tool-server.mjs';
15
+
16
+ const labDir = path.join(engineRoot, 'tools', 'pair-lab');
17
+
18
+ export function lab(config, { port = 4321 } = {}) {
19
+ const { brandFile } = config;
20
+ const brandName = path.basename(brandFile, '.json');
21
+ const colorFile = path.join(config.generatedDir, 'tokens', 'primitive', 'color.json');
22
+ if (!fs.existsSync(colorFile)) throw new Error('Build the theme first: loomiere build');
23
+
24
+ /** Palettes as the lab draws them: paper and ink first, then each role with its label. */
25
+ function labData() {
26
+ const brand = readJson(brandFile);
27
+ const { color } = readJson(colorFile);
28
+ const palettes = [
29
+ {
30
+ role: 'base',
31
+ label: 'paper & ink',
32
+ steps: [
33
+ { step: 'paper', hex: color.white.$value },
34
+ { step: 'ink', hex: color.black.$value },
35
+ ],
36
+ },
37
+ ];
38
+ for (const [role, p] of Object.entries(brand.color.palettes)) {
39
+ const steps = Object.entries(color[role] ?? {})
40
+ .filter(([, t]) => typeof t.$value === 'string' && t.$value.startsWith('#'))
41
+ .map(([step, t]) => ({ step, hex: t.$value }));
42
+ palettes.push({ role, label: p.label ?? role, steps });
43
+ }
44
+ return {
45
+ brand: brandName,
46
+ brandName: (brand.name ?? brandName).replace(/^./, (c) => c.toUpperCase()),
47
+ brandFile: path.relative(config.cwd, brandFile),
48
+ palettes,
49
+ pairs: brand.color.pairs ?? {},
50
+ };
51
+ }
52
+
53
+ const oneLine = (v) =>
54
+ typeof v === 'object'
55
+ ? `{ ${Object.entries(v)
56
+ .map(([k, x]) => `${JSON.stringify(k)}: ${oneLine(x)}`)
57
+ .join(', ')} }`
58
+ : JSON.stringify(v);
59
+
60
+ /** Index just past the brace that closes the one at `open`, skipping strings. */
61
+ function closingBrace(text, open) {
62
+ let depth = 0;
63
+ for (let i = open; i < text.length; i++) {
64
+ const c = text[i];
65
+ if (c === '"') {
66
+ for (i++; text[i] !== '"'; i++) if (text[i] === '\\') i++;
67
+ } else if (c === '{') depth++;
68
+ else if (c === '}' && --depth === 0) return i + 1;
69
+ }
70
+ throw new Error('Unbalanced braces in the brand file');
71
+ }
72
+
73
+ /**
74
+ * Puts the pairs into the brand file's text, one pair per line, and leaves the rest of the
75
+ * file (its hand formatting included) alone.
76
+ */
77
+ function withPairs(text, pairs) {
78
+ const indent = text.match(/\n(\s*)"palettes"\s*:/)?.[1] ?? ' ';
79
+ const entries = Object.entries(pairs).map(([name, spec]) => `${indent} ${JSON.stringify(name)}: ${oneLine(spec)}`);
80
+ const block = entries.length ? `"pairs": {\n${entries.join(',\n')}\n${indent}}` : '"pairs": {}';
81
+ const existing = text.match(/"pairs"\s*:\s*\{/);
82
+ if (existing) {
83
+ const open = existing.index + existing[0].length - 1;
84
+ return text.slice(0, existing.index) + block + text.slice(closingBrace(text, open));
85
+ }
86
+ const data = text.match(/\n(\s*)"data"\s*:/);
87
+ if (data) return `${text.slice(0, data.index)}\n${indent}${block},${text.slice(data.index)}`;
88
+ const brand = JSON.parse(text);
89
+ brand.color.pairs = pairs;
90
+ return `${JSON.stringify(brand, null, 2)}\n`;
91
+ }
92
+
93
+ async function savePairs({ pairs }) {
94
+ if (!pairs || typeof pairs !== 'object' || Array.isArray(pairs)) throw new Error('Expected { pairs: { name: { bg, fg } } }');
95
+ const output = await saveAndRebuild(brandFile, withPairs(fs.readFileSync(brandFile, 'utf8'), pairs), config);
96
+ return { ok: true, output };
97
+ }
98
+
99
+ serveTool({
100
+ dir: labDir,
101
+ port,
102
+ routes: { 'GET /data.json': labData, 'POST /pairs': savePairs },
103
+ onListen: () => {
104
+ console.log(`Pair lab for "${brandName}": http://localhost:${port}`);
105
+ console.log(`Save writes color.pairs to ${path.relative(config.cwd, brandFile)} and rebuilds the theme. Ctrl+C to stop.`);
106
+ },
107
+ });
108
+ }
package/lib/studio.mjs ADDED
@@ -0,0 +1,127 @@
1
+ /**
2
+ * The brand studio: a local page for writing a brand file with live previews.
3
+ *
4
+ * loomiere studio [--brand brand/name.json] [--port 4322]
5
+ * loomiere studio --export out.html (one self-contained page, no server)
6
+ *
7
+ * The page runs generator/brand.mjs in the browser, so the ramps, type scale and other
8
+ * previews are exactly what the build makes. Save writes <brand folder>/<name>.json. Saving
9
+ * the brand the project builds also rebuilds its theme, and if the build refuses the file it
10
+ * is put back the way it was.
11
+ */
12
+ import fs from 'node:fs';
13
+ import { createRequire } from 'node:module';
14
+ import path from 'node:path';
15
+
16
+ import { formatBrand } from '../generator/format-brand.mjs';
17
+ import { brandFiles, schemaPathFor } from './config.mjs';
18
+ import { engineRoot, readJson, saveAndRebuild, serveTool } from './tool-server.mjs';
19
+
20
+ const toolDir = path.join(engineRoot, 'tools', 'brand-builder');
21
+ const culoriDir = path.dirname(createRequire(import.meta.url).resolve('culori/package.json'));
22
+
23
+ /**
24
+ * Which semantic tokens read each role concept ("chrome.fg" → ["text.primary", …]),
25
+ * directly or through another semantic token, for the Concepts rows.
26
+ */
27
+ function conceptUses() {
28
+ const dir = path.join(engineRoot, 'tokens', 'semantic');
29
+ const refs = new Map();
30
+ const walk = (node, at) => {
31
+ if (node && typeof node === 'object' && '$value' in node) {
32
+ refs.set(at.join('.'), [...JSON.stringify([node.$value, node.$extensions?.['sl.modes']]).matchAll(/\{([^}]+)\}/g)].map((m) => m[1]));
33
+ return;
34
+ }
35
+ for (const [k, v] of Object.entries(node ?? {})) if (!k.startsWith('$') && typeof v === 'object') walk(v, [...at, k]);
36
+ };
37
+ for (const f of fs.readdirSync(dir)) if (!['scope.json', 'emphasis.json', 'pair.json'].includes(f)) walk(readJson(path.join(dir, f)), []);
38
+ const reaches = (p, seen = new Set()) =>
39
+ (refs.get(p) ?? []).flatMap((r) => (seen.has(r) ? [] : (seen.add(r), [r, ...reaches(r, seen)])));
40
+ const uses = {};
41
+ for (const p of refs.keys()) {
42
+ for (const r of reaches(p)) {
43
+ const m = /^scope\.(chrome|accent)\.(.+)$/.exec(r);
44
+ if (m) (uses[`${m[1]}.${m[2]}`] ??= []).push(p);
45
+ }
46
+ }
47
+ return uses;
48
+ }
49
+
50
+ export function studio(config, { port = 4322, exportFile } = {}) {
51
+ const brandDir = path.dirname(config.brandFile);
52
+ const brandName = path.basename(config.brandFile, '.json');
53
+ const brandPath = (name) => path.join(brandDir, `${name}.json`);
54
+ const brands = () => brandFiles(brandDir).map((f) => path.basename(f, '.json'));
55
+ const schema = schemaPathFor(brandDir, config.cwd);
56
+
57
+ function load(name) {
58
+ if (!brands().includes(name)) throw new Error(`No brand called "${name}"`);
59
+ const overridesFile = path.join(brandDir, `${name}.overrides.json`);
60
+ return {
61
+ name,
62
+ dir: path.relative(config.cwd, brandDir) || '.',
63
+ schema,
64
+ built: name === brandName,
65
+ brands: brands(),
66
+ brand: readJson(brandPath(name)),
67
+ overrides: fs.existsSync(overridesFile) ? readJson(overridesFile) : {},
68
+ uses: conceptUses(),
69
+ };
70
+ }
71
+
72
+ async function save({ name, brand }) {
73
+ if (!/^[a-z][a-z0-9-]*$/.test(name ?? '')) throw new Error('Brand names use a-z, 0-9 and dashes, starting with a letter');
74
+ if (!brand?.color?.palettes) throw new Error('That does not look like a brand file');
75
+ const file = brandPath(name);
76
+ // Object.assign keeps the name second in the file even if the brand carries its own.
77
+ const text = formatBrand(Object.assign({ $schema: schema, name }, brand, { name }));
78
+ if (name === brandName) return { ok: true, output: await saveAndRebuild(file, text, config) };
79
+ fs.writeFileSync(file, text);
80
+ const rel = path.relative(config.cwd, file);
81
+ return { ok: true, output: `Saved ${rel}. Build it with loomiere build --brand ${rel}.` };
82
+ }
83
+
84
+ if (exportFile) return exportPage(path.resolve(config.cwd, exportFile), load(brandName));
85
+ serveTool({
86
+ dir: toolDir,
87
+ port,
88
+ mounts: {
89
+ '/generator/': path.join(engineRoot, 'generator'),
90
+ '/vendor/culori/': culoriDir,
91
+ },
92
+ routes: {
93
+ 'GET /api/brand': (_, url) => load(url.searchParams.get('name') || brandName),
94
+ 'POST /api/brand': save,
95
+ },
96
+ onListen: () => {
97
+ console.log(`Brand studio for "${brandName}": http://localhost:${port}`);
98
+ console.log(`Save writes ${path.relative(config.cwd, brandDir) || '.'}/<name>.json; saving "${brandName}" also rebuilds the theme. Ctrl+C to stop.`);
99
+ },
100
+ });
101
+ }
102
+
103
+ /** One HTML file with the script bundled and the brand inlined, for sharing or hosting. */
104
+ async function exportPage(out, data) {
105
+ let build;
106
+ try {
107
+ ({ build } = await import('esbuild'));
108
+ } catch {
109
+ throw new Error('Exporting the studio needs esbuild: npm install --save-dev esbuild');
110
+ }
111
+ const bundle = await build({
112
+ entryPoints: [path.join(toolDir, 'builder.js')],
113
+ bundle: true,
114
+ format: 'iife',
115
+ minify: true,
116
+ write: false,
117
+ target: 'es2020',
118
+ });
119
+ const js = bundle.outputFiles[0].text.replace(/<\/script/gi, '<\\/script');
120
+ const inline = JSON.stringify({ ...data, built: false, brands: [data.name] }).replace(/</g, '\\u003c');
121
+ const html = fs
122
+ .readFileSync(path.join(toolDir, 'index.html'), 'utf8')
123
+ .replace(/<script type="importmap">[\s\S]*?<\/script>\n?/, '')
124
+ .replace('<script type="module" src="builder.js"></script>', () => `<script>window.BRAND_DATA = ${inline};</script>\n<script>${js}</script>`);
125
+ fs.writeFileSync(out, html);
126
+ console.log(`Wrote ${out} (${Math.round(html.length / 1024)} KB).`);
127
+ }
@@ -0,0 +1,96 @@
1
+ /**
2
+ * Shared plumbing for the local tools (the pair lab and the brand builder): argument
3
+ * parsing, a small static and JSON server bound to localhost, and saving a brand file
4
+ * with a rebuild that rolls the file back if the build refuses it.
5
+ */
6
+ import { execFile } from 'node:child_process';
7
+ import fs from 'node:fs';
8
+ import http from 'node:http';
9
+ import path from 'node:path';
10
+ import { fileURLToPath } from 'node:url';
11
+
12
+ /** The engine's own folder (generator/, tools/, tokens/). */
13
+ export const engineRoot = path.join(path.dirname(fileURLToPath(import.meta.url)), '..');
14
+ export const readJson = (file) => JSON.parse(fs.readFileSync(file, 'utf8'));
15
+
16
+ const bin = path.join(engineRoot, 'bin', 'loomiere.mjs');
17
+
18
+ /** Runs `loomiere build` for the project, as its own process so a failed build can't leave this one half-loaded. */
19
+ const rebuild = (config) =>
20
+ new Promise((resolve) => {
21
+ execFile(process.execPath, [bin, 'build', '--brand', config.brandFile], { cwd: config.cwd }, (err, stdout, stderr) =>
22
+ resolve({ ok: !err, output: `${stdout}${stderr}`.trim() }),
23
+ );
24
+ });
25
+
26
+ /**
27
+ * Writes a brand file and rebuilds the project's theme from it. If the build fails, the
28
+ * file goes back to what it was and the error names the build's reason.
29
+ */
30
+ export async function saveAndRebuild(file, text, config) {
31
+ JSON.parse(text);
32
+ const before = fs.existsSync(file) ? fs.readFileSync(file, 'utf8') : null;
33
+ fs.writeFileSync(file, text);
34
+ const build = await rebuild(config);
35
+ if (!build.ok) {
36
+ if (before === null) fs.rmSync(file);
37
+ else fs.writeFileSync(file, before);
38
+ const lines = build.output.split('\n').map((line) => line.trim());
39
+ const reason = lines.filter((line) => /needs|names use|not a palette|falls? short/.test(line)).join('; ');
40
+ throw new Error(reason || lines.slice(-5).join(' '));
41
+ }
42
+ return build.output.split('\n').filter(Boolean).at(-1);
43
+ }
44
+
45
+ const TYPES = {
46
+ '.html': 'text/html; charset=utf-8',
47
+ '.js': 'text/javascript; charset=utf-8',
48
+ '.mjs': 'text/javascript; charset=utf-8',
49
+ '.css': 'text/css; charset=utf-8',
50
+ '.json': 'application/json; charset=utf-8',
51
+ };
52
+
53
+ /**
54
+ * Serves `dir` at / plus any `mounts` ({ '/prefix/': directory }), and JSON `routes`
55
+ * ({ 'GET /data.json': () => data, 'POST /save': (body) => result }). A route that throws
56
+ * answers 400 with { ok: false, error }.
57
+ */
58
+ export function serveTool({ dir, mounts = {}, routes, port, onListen }) {
59
+ const server = http.createServer(async (req, res) => {
60
+ const send = (status, body, type = TYPES['.json']) => {
61
+ res.writeHead(status, { 'content-type': type, 'cache-control': 'no-store' });
62
+ res.end(typeof body === 'string' ? body : JSON.stringify(body));
63
+ };
64
+ try {
65
+ const url = new URL(req.url, 'http://localhost');
66
+ const route = routes[`${req.method} ${url.pathname}`];
67
+ if (route) {
68
+ let body;
69
+ if (req.method === 'POST') {
70
+ let raw = '';
71
+ for await (const chunk of req) raw += chunk;
72
+ body = JSON.parse(raw || '{}');
73
+ }
74
+ try {
75
+ return send(200, await route(body, url));
76
+ } catch (err) {
77
+ return send(400, { ok: false, error: err.message });
78
+ }
79
+ }
80
+ if (req.method === 'GET') {
81
+ const prefix = Object.keys(mounts).find((p) => url.pathname.startsWith(p));
82
+ const base = prefix ? mounts[prefix] : dir;
83
+ const rel = prefix ? url.pathname.slice(prefix.length) : url.pathname === '/' ? 'index.html' : url.pathname;
84
+ const file = path.join(base, path.normalize(`/${rel}`));
85
+ if (file.startsWith(base) && fs.existsSync(file) && fs.statSync(file).isFile()) {
86
+ return send(200, fs.readFileSync(file, 'utf8'), TYPES[path.extname(file)] ?? 'text/plain; charset=utf-8');
87
+ }
88
+ }
89
+ send(404, { ok: false, error: 'Not found' });
90
+ } catch (err) {
91
+ send(500, { ok: false, error: err.message });
92
+ }
93
+ });
94
+ server.listen(port, '127.0.0.1', onListen);
95
+ return server;
96
+ }
package/package.json CHANGED
@@ -1,6 +1,53 @@
1
1
  {
2
2
  "name": "@scattered-light/loomiere",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "description": "Turns a small brand file into a full design token theme: OKLCH color ramps, a type scale, light, dark and high-contrast modes, plus Tailwind, MUI, Chakra, AG Grid and Figma exports.",
5
+ "keywords": ["design-tokens", "theme", "style-dictionary", "dtcg", "oklch", "base-ui", "ag-grid", "tailwind"],
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/ScatteredLight/loomiere.git",
9
+ "directory": "packages/loomiere"
10
+ },
11
+ "type": "module",
12
+ "bin": {
13
+ "loomiere": "./bin/loomiere.mjs"
14
+ },
15
+ "exports": {
16
+ "./build": "./build/build-tokens.mjs",
17
+ "./config": "./lib/config.mjs",
18
+ "./contrast": "./lib/contrast.mjs",
19
+ "./generator/*": "./generator/*",
20
+ "./exporters/*": "./exporters/*",
21
+ "./brand.schema.json": "./generator/brand.schema.json",
22
+ "./package.json": "./package.json"
23
+ },
24
+ "files": [
25
+ "bin",
26
+ "build",
27
+ "exporters",
28
+ "generator",
29
+ "lib",
30
+ "templates",
31
+ "tokens",
32
+ "tools"
33
+ ],
34
+ "publishConfig": {
35
+ "access": "public",
36
+ "provenance": false
37
+ },
38
+ "engines": {
39
+ "node": ">=20.19.0"
40
+ },
41
+ "dependencies": {
42
+ "culori": "^4.0.2",
43
+ "style-dictionary": "^5.7.0"
44
+ },
45
+ "peerDependencies": {
46
+ "esbuild": ">=0.20.0"
47
+ },
48
+ "peerDependenciesMeta": {
49
+ "esbuild": {
50
+ "optional": true
51
+ }
52
+ }
53
+ }
@@ -0,0 +1,104 @@
1
+ {
2
+ "$schema": "../generator/brand.schema.json",
3
+ "name": "starter",
4
+ "description": "Starting point for a new brand, copied from the Mojave demo theme. Change the seed colors, fonts, base unit and type ratio; the generator does the rest.",
5
+
6
+ "space": {
7
+ "unit": 4,
8
+ "steps": [0.5, 1, 1.5, 2, 2.5, 3, 3.5, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 16, 20, 24, 28, 32, 36, 40, 44, 48, 52, 56, 60, 64, 72, 80, 96]
9
+ },
10
+
11
+ "color": {
12
+ "paper": "#ffffff",
13
+ "ink": "#000000",
14
+ "contrast": {
15
+ "spot": {
16
+ "targets": { "50": 1.03, "75": 1.09, "100": 1.18, "200": 1.51, "300": 2.02, "400": 3, "500": 4.5, "600": 6.1, "700": 7.91, "800": 10.87, "900": 14.38, "1000": 17.89 },
17
+ "locked": [500]
18
+ },
19
+ "neutral": {
20
+ "targets": { "50": 1.04, "100": 1.06, "200": 1.12, "300": 1.19, "400": 1.42, "500": 1.72, "600": 2.16, "700": 2.76, "800": 3.65, "900": 5.08, "1000": 7.31, "1100": 11.08, "1200": 16.83 }
21
+ }
22
+ },
23
+ "palettes": {
24
+ "neutral": {
25
+ "label": "stone",
26
+ "contrast": "neutral",
27
+ "keys": ["#e7ece4", "#bcccc1"],
28
+ "exact": true,
29
+ "tone": { "color": "#bbc9bf", "intensity": [0.1, 0.6] }
30
+ },
31
+ "primary": { "label": "mirage", "keys": ["#dbacff", "#973dda", "#402058"], "darkest": "#221928" },
32
+ "secondary": { "label": "ember", "keys": ["#ed6f52", "#57342b"] },
33
+ "tertiary": { "label": "dune", "keys": ["#c4b77a", "#7e7758"] },
34
+ "quaternary": { "label": "silhouette", "keys": ["#b58994", "#612c3d"] },
35
+ "success": { "keys": ["#22c55e"] },
36
+ "warning": { "keys": ["#f59e0b"] },
37
+ "error": { "keys": ["#ef4444"] },
38
+ "info": { "keys": ["#3b82f6"] }
39
+ },
40
+ "pairs": {
41
+ "ember-dune": { "bg": "secondary.600", "fg": "tertiary.50", "description": "Example pair: warm callouts and toasts." },
42
+ "mirage-deep": { "bg": "primary.900", "fg": "primary.75", "accent": "tertiary.200", "description": "Example pair: a dark brand band." },
43
+ "stone-soft": {
44
+ "bg": "neutral.100",
45
+ "fg": "neutral.1200",
46
+ "accent": "primary.600",
47
+ "dark": { "bg": "neutral.1100", "fg": "neutral.100", "accent": "primary.300" },
48
+ "description": "Example pair: a quiet inset panel that flips in dark mode."
49
+ }
50
+ },
51
+ "data": [
52
+ ["violet", "#a04fdf"], ["coral", "#ef7053"], ["gold", "#c4b679"], ["burgundy", "#612c3d"],
53
+ ["mint", "#86e6ca"], ["navy", "#154975"], ["periwinkle", "#bbc3fe"], ["forest", "#0b5313"],
54
+ ["lime", "#74ce4b"], ["yellow", "#dac925"], ["lavender", "#b4a1e2"], ["peach", "#ffc3b5"],
55
+ ["cream", "#f1e5af"], ["mauve", "#a6838e"], ["seafoam", "#bfecdf"], ["sky", "#7fa8cb"],
56
+ ["ice", "#e5e8ff"], ["sage", "#b1d0b5"], ["chartreuse", "#d0fabd"], ["ivory", "#fffce1"]
57
+ ]
58
+ },
59
+
60
+ "type": {
61
+ "families": {
62
+ "display": ["Funnel Display", "sans-serif"],
63
+ "body": ["Barlow Semi Condensed", "sans-serif"],
64
+ "mono": ["Roboto Mono", "monospace"]
65
+ },
66
+ "metrics": {
67
+ "display": { "ascent": 1, "descent": 0.25, "capHeight": 0.675, "xHeight": 0.5 },
68
+ "body": { "ascent": 1, "descent": 0.2, "capHeight": 0.7, "xHeight": 0.506 },
69
+ "mono": { "ascent": 1.048, "descent": 0.271, "capHeight": 0.711, "xHeight": 0.528 }
70
+ },
71
+ "base": { "min": 14, "max": 16 },
72
+ "ratio": { "min": 1.2, "max": 1.2 },
73
+ "viewport": { "min": 320, "max": 1280 },
74
+ "steps": ["xxs", "xs", "sm", "md", "lg", "xl", "2xl", "3xl", "4xl", "5xl", "6xl", "7xl", "8xl", "9xl"],
75
+ "baseStep": "md",
76
+ "stepDown": true,
77
+ "displayFrom": "2xl",
78
+ "small": { "fixed": true, "floor": 9 },
79
+ "lineHeight": { "text": 0.228, "heading": -0.046, "textStep": "sm", "headingStep": "3xl", "solidFrom": "4xl" }
80
+ },
81
+
82
+ "radius": {
83
+ "base": 4,
84
+ "steps": { "none": 0, "sm": 0.5, "base": 1, "md": 1.5, "lg": 2, "xl": 3, "2xl": 4, "3xl": 6 }
85
+ },
86
+
87
+ "stroke": { "base": 1, "steps": { "thin": 1, "medium": 2, "thick": 3 } },
88
+
89
+ "elevation": { "shadowColor": "#000000", "strength": 1 },
90
+
91
+ "motion": { "speed": 1, "durations": { "fast": 100, "normal": 200, "slow": 300 } },
92
+
93
+ "roles": {
94
+ "chrome": "neutral",
95
+ "accent": "primary",
96
+ "emphasis": {
97
+ "bold": { "style": "solid", "color": "accent" },
98
+ "standard": { "style": "outline", "color": "accent" },
99
+ "subtle": { "style": "soft", "color": "chrome" },
100
+ "quiet": { "style": "outline", "color": "chrome" }
101
+ },
102
+ "concepts": { "chrome.fg": { "light": "primary.1000" } }
103
+ }
104
+ }