@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.
- package/README.md +60 -2
- package/bin/loomiere.mjs +112 -0
- package/build/build-tokens.mjs +485 -0
- package/exporters/README.md +45 -0
- package/exporters/ag-grid-settings.mjs +177 -0
- package/exporters/ag-grid.mjs +112 -0
- package/exporters/chakra.mjs +630 -0
- package/exporters/component-styles.mjs +207 -0
- package/exporters/dtcg.mjs +31 -0
- package/exporters/figma.mjs +100 -0
- package/exporters/index.mjs +38 -0
- package/exporters/mui.mjs +666 -0
- package/exporters/tailwind.mjs +35 -0
- package/exporters/theme.mjs +93 -0
- package/generator/brand.mjs +328 -0
- package/generator/brand.schema.json +657 -0
- package/generator/color.mjs +130 -0
- package/generator/data-colors.mjs +104 -0
- package/generator/format-brand.mjs +60 -0
- package/generator/generate.mjs +43 -0
- package/generator/pairs.mjs +236 -0
- package/generator/roles.mjs +258 -0
- package/generator/vision.mjs +167 -0
- package/lib/config.mjs +71 -0
- package/lib/contrast.mjs +134 -0
- package/lib/lab.mjs +108 -0
- package/lib/studio.mjs +127 -0
- package/lib/tool-server.mjs +96 -0
- package/package.json +51 -4
- package/templates/brand.json +104 -0
- package/tokens/README.md +219 -0
- package/tokens/component/avatar.json +10 -0
- package/tokens/component/button.json +12 -0
- package/tokens/component/drawer.json +26 -0
- package/tokens/component/navigation-menu.json +13 -0
- package/tokens/component/popover.json +13 -0
- package/tokens/component/preview-card.json +13 -0
- package/tokens/component/slider.json +13 -0
- package/tokens/component/switch.json +41 -0
- package/tokens/component/toast.json +21 -0
- package/tokens/component/toggle.json +9 -0
- package/tokens/component/toolbar.json +9 -0
- package/tokens/component/tooltip.json +9 -0
- package/tokens/foundation/layering.json +57 -0
- package/tokens/foundation/layout.json +75 -0
- package/tokens/foundation/motion.json +41 -0
- package/tokens/foundation/type.json +92 -0
- package/tokens/semantic/accessibility.json +39 -0
- package/tokens/semantic/bar.json +45 -0
- package/tokens/semantic/check.json +57 -0
- package/tokens/semantic/color.json +270 -0
- package/tokens/semantic/content.json +124 -0
- package/tokens/semantic/disclosure.json +49 -0
- package/tokens/semantic/elevation.json +72 -0
- package/tokens/semantic/field.json +77 -0
- package/tokens/semantic/focus.json +36 -0
- package/tokens/semantic/gauge.json +45 -0
- package/tokens/semantic/icon-button.json +33 -0
- package/tokens/semantic/icon.json +30 -0
- package/tokens/semantic/item.json +65 -0
- package/tokens/semantic/modal.json +86 -0
- package/tokens/semantic/motion.json +20 -0
- package/tokens/semantic/notice.json +37 -0
- package/tokens/semantic/popup.json +89 -0
- package/tokens/semantic/scheme.json +11 -0
- package/tokens/semantic/shape.json +54 -0
- package/tokens/semantic/spacing.json +110 -0
- package/tokens/semantic/table.json +73 -0
- package/tokens/semantic/typography.json +312 -0
- package/tools/brand-builder/builder.js +1119 -0
- package/tools/brand-builder/index.html +329 -0
- package/tools/pair-lab/index.html +119 -0
- package/tools/pair-lab/lab.js +249 -0
package/README.md
CHANGED
|
@@ -1,3 +1,61 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @scattered-light/loomiere
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
The Loomiere brand engine. You write one small brand file (seed colors, fonts, a base unit, a type ratio, radius), and `loomiere build` weaves it into a full theme: OKLCH color ramps with contrast built in, a fluid type scale, light, dark, high-contrast and color-vision modes, utility classes, and themes for other frameworks.
|
|
4
|
+
|
|
5
|
+
## Start a theme
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install --save-dev @scattered-light/loomiere
|
|
9
|
+
npx loomiere init acme # brand/acme.json + loomiere.config.json
|
|
10
|
+
npx loomiere studio # set colors and type with live previews, then Save
|
|
11
|
+
npx loomiere build # writes theme/
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
Then load the theme in your app:
|
|
15
|
+
|
|
16
|
+
```ts
|
|
17
|
+
import './theme/tokens.css';
|
|
18
|
+
import './theme/utilities.css';
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
Every value is a `--sl-` variable, so your own CSS, and component packages such as [@scattered-light/loomiere-base-ui](../loomiere-base-ui/README.md), read it directly.
|
|
22
|
+
|
|
23
|
+
## Commands
|
|
24
|
+
|
|
25
|
+
| Command | What it does |
|
|
26
|
+
| --- | --- |
|
|
27
|
+
| `loomiere init [name]` | Starts `brand/<name>.json` (a copy of the Mojave brand) and `loomiere.config.json`. |
|
|
28
|
+
| `loomiere build` | Writes the theme, then checks contrast. Fails if any text pair falls short of AA. |
|
|
29
|
+
| `loomiere check` | Checks contrast of the theme already built. |
|
|
30
|
+
| `loomiere studio` | Opens the brand studio at `http://localhost:4322`. `--export page.html` writes a standalone copy (needs esbuild). |
|
|
31
|
+
| `loomiere lab` | Opens the pair lab at `http://localhost:4321` to pick brand pairs (a background, its text and an accent). |
|
|
32
|
+
|
|
33
|
+
`--brand <file>`, `--out <dir>` and `--prefix <name>` override the config for one run. `--port <n>` moves studio or lab.
|
|
34
|
+
|
|
35
|
+
## loomiere.config.json
|
|
36
|
+
|
|
37
|
+
```json
|
|
38
|
+
{
|
|
39
|
+
"brand": "brand/acme.json",
|
|
40
|
+
"out": "src/theme",
|
|
41
|
+
"exports": ["ag-grid", "tailwind"]
|
|
42
|
+
}
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
- `brand`: the brand file. Optional when `brand/` holds exactly one.
|
|
46
|
+
- `out`: where the theme goes. Default `theme`.
|
|
47
|
+
- `prefix`: the CSS variable prefix. Default `sl`, which is what loomiere-base-ui reads.
|
|
48
|
+
- `exports`: which framework files to write. Default all of them.
|
|
49
|
+
|
|
50
|
+
## What a build writes
|
|
51
|
+
|
|
52
|
+
| File | For |
|
|
53
|
+
| --- | --- |
|
|
54
|
+
| `tokens.css` | Every token as a `--sl-` variable, inside `@layer sl.tokens` so your own styles win. Dark mode follows `prefers-color-scheme` or `data-theme="dark"`. Also holds the `.sl-pair-<name>` classes and `data-color` scopes. |
|
|
55
|
+
| `utilities.css` | `.text-style-*`, `.icon-*`, `.focus-ring`, `.hide-on-*`. |
|
|
56
|
+
| `tokens.js`, `tokens.d.ts` | Resolved values for JavaScript. |
|
|
57
|
+
| `tokens.json`, `pairs.json` | The token catalog and every palette color against every other, for docs and agents. |
|
|
58
|
+
| `exports/` | DTCG JSON, Figma variables, Tailwind v4, MUI, Chakra v3, AG Grid 31–32, 33–35 and 36+. See [exporters/README.md](exporters/README.md). |
|
|
59
|
+
| `generated/` | The brand's primitive tokens. Rebuilt every time; no need to commit. |
|
|
60
|
+
|
|
61
|
+
How the layers work, the brand file's fields, roles, concepts and pairs: [tokens/README.md](tokens/README.md).
|
package/bin/loomiere.mjs
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* loomiere: turns a brand file into a theme.
|
|
4
|
+
*
|
|
5
|
+
* loomiere init [name] start a brand file (brand/<name>.json) and loomiere.config.json
|
|
6
|
+
* loomiere build write the theme (tokens, utilities, framework exports), then check contrast
|
|
7
|
+
* loomiere check check contrast of the theme already built
|
|
8
|
+
* loomiere studio edit the brand with live previews (--export page.html for a static copy)
|
|
9
|
+
* loomiere lab pick brand pairs
|
|
10
|
+
*
|
|
11
|
+
* Flags: --brand <file> --out <dir> --prefix <name> --port <n>
|
|
12
|
+
*/
|
|
13
|
+
import fs from 'node:fs';
|
|
14
|
+
import path from 'node:path';
|
|
15
|
+
import { parseArgs } from 'node:util';
|
|
16
|
+
|
|
17
|
+
import { formatBrand } from '../generator/format-brand.mjs';
|
|
18
|
+
import { CONFIG_FILE, resolveConfig, schemaPathFor } from '../lib/config.mjs';
|
|
19
|
+
import { engineRoot } from '../lib/tool-server.mjs';
|
|
20
|
+
|
|
21
|
+
const HELP = `loomiere: turns a brand file into a theme.
|
|
22
|
+
|
|
23
|
+
Usage
|
|
24
|
+
loomiere init [name] Start brand/<name>.json and ${CONFIG_FILE}
|
|
25
|
+
loomiere build Write the theme, then check contrast
|
|
26
|
+
loomiere check Check contrast of the built theme
|
|
27
|
+
loomiere studio Edit the brand with live previews
|
|
28
|
+
loomiere lab Pick brand pairs (background + text)
|
|
29
|
+
|
|
30
|
+
Options
|
|
31
|
+
--brand <file> Brand file (default: the only one in brand/)
|
|
32
|
+
--out <dir> Where the theme goes (default: theme)
|
|
33
|
+
--prefix <name> CSS variable prefix (default: sl)
|
|
34
|
+
--port <n> Port for studio and lab
|
|
35
|
+
--export <file> studio only: write one self-contained page instead of serving
|
|
36
|
+
--help Show this help
|
|
37
|
+
`;
|
|
38
|
+
|
|
39
|
+
const { values: flags, positionals } = parseArgs({
|
|
40
|
+
allowPositionals: true,
|
|
41
|
+
options: {
|
|
42
|
+
brand: { type: 'string' },
|
|
43
|
+
out: { type: 'string' },
|
|
44
|
+
prefix: { type: 'string' },
|
|
45
|
+
port: { type: 'string' },
|
|
46
|
+
export: { type: 'string' },
|
|
47
|
+
help: { type: 'boolean', short: 'h' },
|
|
48
|
+
},
|
|
49
|
+
});
|
|
50
|
+
const [command, ...rest] = positionals;
|
|
51
|
+
const configFlags = { brand: flags.brand, out: flags.out, prefix: flags.prefix };
|
|
52
|
+
const port = flags.port ? Number(flags.port) : undefined;
|
|
53
|
+
|
|
54
|
+
async function build(config) {
|
|
55
|
+
const { buildTokens } = await import('../build/build-tokens.mjs');
|
|
56
|
+
const { checkContrast, reportContrast } = await import('../lib/contrast.mjs');
|
|
57
|
+
const { brand, exports } = await buildTokens(config);
|
|
58
|
+
const out = path.relative(config.cwd, config.outDir) || '.';
|
|
59
|
+
console.log(`Built "${brand.name}" into ${out}/ (tokens.css, utilities.css${exports.length ? `, exports/ for ${exports.map((e) => e.name).join(', ')}` : ''}).`);
|
|
60
|
+
return reportContrast(checkContrast(config.outDir, config.brandFile));
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function init(name = 'brand') {
|
|
64
|
+
if (!/^[a-z][a-z0-9-]*$/.test(name)) throw new Error('Brand names use a-z, 0-9 and dashes, starting with a letter');
|
|
65
|
+
const cwd = process.cwd();
|
|
66
|
+
const file = path.join(cwd, 'brand', `${name}.json`);
|
|
67
|
+
if (fs.existsSync(file)) throw new Error(`${path.relative(cwd, file)} already exists.`);
|
|
68
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
69
|
+
const schema = schemaPathFor(path.dirname(file), cwd);
|
|
70
|
+
const brand = JSON.parse(fs.readFileSync(path.join(engineRoot, 'templates', 'brand.json'), 'utf8'));
|
|
71
|
+
fs.writeFileSync(file, formatBrand(Object.assign({ $schema: schema, name }, brand, { $schema: schema, name })));
|
|
72
|
+
console.log(`Wrote ${path.relative(cwd, file)}.`);
|
|
73
|
+
const configFile = path.join(cwd, CONFIG_FILE);
|
|
74
|
+
if (!fs.existsSync(configFile)) {
|
|
75
|
+
fs.writeFileSync(configFile, `${JSON.stringify({ brand: `brand/${name}.json`, out: flags.out ?? 'theme' }, null, 2)}\n`);
|
|
76
|
+
console.log(`Wrote ${CONFIG_FILE}.`);
|
|
77
|
+
}
|
|
78
|
+
console.log('Next: loomiere studio to set your colors and type, then loomiere build.');
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async function main() {
|
|
82
|
+
if (flags.help || !command || command === 'help') return void console.log(HELP);
|
|
83
|
+
switch (command) {
|
|
84
|
+
case 'init':
|
|
85
|
+
return init(rest[0]);
|
|
86
|
+
case 'build':
|
|
87
|
+
if (!(await build(resolveConfig(configFlags)))) process.exitCode = 1;
|
|
88
|
+
return;
|
|
89
|
+
case 'check': {
|
|
90
|
+
const config = resolveConfig(configFlags);
|
|
91
|
+
const { checkContrast, reportContrast } = await import('../lib/contrast.mjs');
|
|
92
|
+
if (!fs.existsSync(path.join(config.outDir, 'tokens.json'))) throw new Error('Nothing built yet: run loomiere build');
|
|
93
|
+
if (!reportContrast(checkContrast(config.outDir, config.brandFile))) process.exitCode = 1;
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
case 'studio': {
|
|
97
|
+
const { studio } = await import('../lib/studio.mjs');
|
|
98
|
+
return studio(resolveConfig(configFlags), { port, exportFile: flags.export });
|
|
99
|
+
}
|
|
100
|
+
case 'lab': {
|
|
101
|
+
const { lab } = await import('../lib/lab.mjs');
|
|
102
|
+
return lab(resolveConfig(configFlags), { port });
|
|
103
|
+
}
|
|
104
|
+
default:
|
|
105
|
+
throw new Error(`Unknown command "${command}". Run loomiere --help.`);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
main().catch((err) => {
|
|
110
|
+
console.error(err.message);
|
|
111
|
+
process.exitCode = 1;
|
|
112
|
+
});
|
|
@@ -0,0 +1,485 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Brand file → theme folder.
|
|
3
|
+
*
|
|
4
|
+
* 1. Generates primitives from the brand (generator/generate.mjs) into <out>/generated/.
|
|
5
|
+
* 2. Runs Style Dictionary over this package's foundation, semantic and component tokens plus
|
|
6
|
+
* the generated primitive and brand-specific semantic tokens.
|
|
7
|
+
* 3. Writes the theme to <out>/: tokens.css, utilities.css, tokens.js, tokens.d.ts,
|
|
8
|
+
* tokens.json, pairs.json, and exports/ (one file or set per framework).
|
|
9
|
+
*
|
|
10
|
+
* Tokens can carry mode values in $extensions["sl.modes"] (dark, contrast-more,
|
|
11
|
+
* dark-contrast-more, reduced-motion); the CSS output emits each mode as its own block.
|
|
12
|
+
* Color vision themes (vision.* primitives) become [data-vision='<theme>'] blocks.
|
|
13
|
+
*
|
|
14
|
+
* Run it through the command line (loomiere build), which reads loomiere.config.json.
|
|
15
|
+
*/
|
|
16
|
+
import fs from 'node:fs';
|
|
17
|
+
import path from 'node:path';
|
|
18
|
+
import { fileURLToPath } from 'node:url';
|
|
19
|
+
import StyleDictionary from 'style-dictionary';
|
|
20
|
+
import { fileHeader, formattedVariables } from 'style-dictionary/utils';
|
|
21
|
+
|
|
22
|
+
import { exportAll } from '../exporters/index.mjs';
|
|
23
|
+
import { generate } from '../generator/generate.mjs';
|
|
24
|
+
import { LEVELS, PAIR_ROLES, pairTable, paletteColors } from '../generator/pairs.mjs';
|
|
25
|
+
import { resolveRoles } from '../generator/roles.mjs';
|
|
26
|
+
|
|
27
|
+
const root = path.join(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
28
|
+
// Set by buildTokens() from the config; the Style Dictionary formats below read them.
|
|
29
|
+
let prefix = 'sl';
|
|
30
|
+
let brandName = '';
|
|
31
|
+
|
|
32
|
+
const LAYERS = ['foundation', 'primitive', 'semantic', 'component'];
|
|
33
|
+
|
|
34
|
+
/** Selectors for each mode, in cascade order: later blocks win. */
|
|
35
|
+
const MODES = [
|
|
36
|
+
['dark', [['@media (prefers-color-scheme: dark)', ":root:not([data-theme='light'])"], [null, "[data-theme='dark']"]]],
|
|
37
|
+
['contrast-more', [['@media (prefers-contrast: more)', ':root']]],
|
|
38
|
+
[
|
|
39
|
+
'dark-contrast-more',
|
|
40
|
+
[
|
|
41
|
+
['@media (prefers-contrast: more) and (prefers-color-scheme: dark)', ":root:not([data-theme='light'])"],
|
|
42
|
+
['@media (prefers-contrast: more)', "[data-theme='dark']"],
|
|
43
|
+
],
|
|
44
|
+
],
|
|
45
|
+
['reduced-motion', [['@media (prefers-reduced-motion: reduce)', ':root']]],
|
|
46
|
+
];
|
|
47
|
+
|
|
48
|
+
const varName = (tokenPath) => `--${[prefix, ...tokenPath].join('-')}`;
|
|
49
|
+
const refsToVars = (value) => String(value).replace(/\{([^}]+)\}/g, (_, ref) => `var(${varName(ref.split('.'))})`);
|
|
50
|
+
const indent = (text, spaces) =>
|
|
51
|
+
text
|
|
52
|
+
.split('\n')
|
|
53
|
+
.map((line) => (line ? ' '.repeat(spaces) + line : line))
|
|
54
|
+
.join('\n');
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* A token's value in a mode, with its references kept, so a mode value that points at
|
|
58
|
+
* another token follows that token's own mode the way var() does.
|
|
59
|
+
*/
|
|
60
|
+
const modeValue = (token, mode) => token.original.$extensions?.['sl.modes']?.[mode];
|
|
61
|
+
|
|
62
|
+
function modeBlocks(dictionary) {
|
|
63
|
+
const blocks = [];
|
|
64
|
+
for (const [mode, targets] of MODES) {
|
|
65
|
+
const declarations = dictionary.allTokens
|
|
66
|
+
.filter((token) => modeValue(token, mode) !== undefined)
|
|
67
|
+
.map((token) => `${varName(token.path)}: ${refsToVars(modeValue(token, mode))};`)
|
|
68
|
+
.join('\n');
|
|
69
|
+
if (!declarations) continue;
|
|
70
|
+
for (const [media, selector] of targets) {
|
|
71
|
+
const rule = `${selector} {\n${indent(declarations, 2)}\n}`;
|
|
72
|
+
blocks.push(media ? `${media} {\n${indent(rule, 2)}\n}` : rule);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
return blocks.join('\n\n');
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* [data-vision="<theme>"] re-declares the brand's palette and data variables with the
|
|
80
|
+
* theme's (vision.<theme>.* tokens, generated in generator/vision.mjs). Set it on the root
|
|
81
|
+
* element: variables declared at :root resolve there, so everything that reads them follows.
|
|
82
|
+
*/
|
|
83
|
+
function visionBlocks(dictionary) {
|
|
84
|
+
const byTheme = new Map();
|
|
85
|
+
for (const token of dictionary.allTokens) {
|
|
86
|
+
if (token.path[0] !== 'vision') continue;
|
|
87
|
+
const [, theme, ...rest] = token.path;
|
|
88
|
+
if (!byTheme.has(theme)) byTheme.set(theme, []);
|
|
89
|
+
byTheme.get(theme).push(`${varName(rest)}: var(${varName(token.path)});`);
|
|
90
|
+
}
|
|
91
|
+
return [...byTheme].map(([theme, lines]) => `[data-vision='${theme}'] {\n${indent(lines.join('\n'), 2)}\n}`).join('\n\n');
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Color scopes and emphasis (generator/roles.mjs). Every palette's slots are tokens
|
|
96
|
+
* (scope.<palette>.<slot>); these blocks point the generic --sl-scope-<slot> at one of them:
|
|
97
|
+
* the accent by default, any palette under [data-color], and the level's own color under
|
|
98
|
+
* [data-emphasis] when that color is chrome or a named palette, unless the element names a color itself. --sl-e-<part> is what a
|
|
99
|
+
* component paints with: the level's style read from whatever scope is in effect.
|
|
100
|
+
*/
|
|
101
|
+
// Button's data-variant names are aliases for emphasis levels, so the brand's roles drive every
|
|
102
|
+
// button. data-emphasis wins when both are set; a button with neither is bold.
|
|
103
|
+
const VARIANT_LEVELS = {
|
|
104
|
+
bold: [".Button[data-variant='solid']:not([data-emphasis])", '.Button:not([data-variant]):not([data-emphasis])'],
|
|
105
|
+
standard: [".Button[data-variant='outline']:not([data-emphasis])"],
|
|
106
|
+
quiet: [".Button[data-variant='ghost']:not([data-emphasis])"],
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
// A toast's type (toastManager.add({ type: 'success' })) picks the status palette of the same
|
|
110
|
+
// name, so success, warning, error and info toasts tint themselves. data-color wins when both are set.
|
|
111
|
+
const TOAST_TYPES = ['success', 'warning', 'error', 'info'];
|
|
112
|
+
const COLOR_ALIASES = (name) => (TOAST_TYPES.includes(name) ? [`.Toast[data-type='${name}']:not([data-color])`] : []);
|
|
113
|
+
|
|
114
|
+
function roleBlocks(dictionary) {
|
|
115
|
+
const scopes = new Map();
|
|
116
|
+
const levels = new Map();
|
|
117
|
+
for (const token of dictionary.allTokens) {
|
|
118
|
+
if (token.path[0] === 'scope') {
|
|
119
|
+
const [, name, slot] = token.path;
|
|
120
|
+
if (!scopes.has(name)) scopes.set(name, []);
|
|
121
|
+
scopes.get(name).push(slot);
|
|
122
|
+
}
|
|
123
|
+
if (token.path[0] === 'emphasis') {
|
|
124
|
+
const [, level, part] = token.path;
|
|
125
|
+
if (!levels.has(level)) levels.set(level, []);
|
|
126
|
+
levels.get(level).push([part, String(token.original.$value)]);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
if (!scopes.size) return '';
|
|
130
|
+
const point = (name) => scopes.get(name).map((slot) => `${varName(['scope', slot])}: var(${varName(['scope', name, slot])});`).join('\n');
|
|
131
|
+
const rule = (selector, body) => `${selector} {\n${indent(body, 2)}\n}`;
|
|
132
|
+
const blocks = [rule(':root', point('accent'))];
|
|
133
|
+
for (const name of scopes.keys()) blocks.push(rule([`[data-color='${name}']`, ...COLOR_ALIASES(name)].join(',\n'), point(name)));
|
|
134
|
+
for (const [level, parts] of levels) {
|
|
135
|
+
const color = parts.map(([, v]) => v.match(/^\{scope\.([^.]+)\./)?.[1]).find(Boolean);
|
|
136
|
+
const paint = parts
|
|
137
|
+
.map(([part, v]) => {
|
|
138
|
+
const slot = v.match(/^\{scope\.[^.]+\.([^}]+)\}$/)?.[1];
|
|
139
|
+
return `${varName(['e', part])}: ${slot ? `var(${varName(['scope', slot])})` : v};`;
|
|
140
|
+
})
|
|
141
|
+
.join('\n');
|
|
142
|
+
const selectors = [`[data-emphasis='${level}']`, ...(VARIANT_LEVELS[level] ?? [])];
|
|
143
|
+
blocks.push(rule(selectors.join(',\n'), paint));
|
|
144
|
+
// Accent levels follow the scope around them, so data-color on a section re-colors them;
|
|
145
|
+
// chrome or a named palette stays put unless the element itself names a color.
|
|
146
|
+
if (color && color !== 'accent') blocks.push(rule(selectors.map((sel) => `${sel}:not([data-color])`).join(',\n'), point(color)));
|
|
147
|
+
}
|
|
148
|
+
return blocks.join('\n\n');
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const refsOf = (value) => [...JSON.stringify(value ?? '').matchAll(/\{([^}]+)\}/g)].map((m) => m[1]);
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* One class per brand pair: .sl-pair-<name> re-points the semantic color tokens listed in
|
|
155
|
+
* PAIR_ROLES at the pair's parts.
|
|
156
|
+
*
|
|
157
|
+
* Custom properties resolve where they are declared, so a component token declared at
|
|
158
|
+
* :root as var(--sl-surface-canvas) keeps the :root surface even inside the class. The
|
|
159
|
+
* class therefore re-declares every token that leads to a re-pointed one, so it resolves
|
|
160
|
+
* again inside the pair. Mode blocks only set :root, so inside the class these
|
|
161
|
+
* declarations hold in every mode: the pair tokens carry the pair's own dark colors, and
|
|
162
|
+
* the dark values that some component tokens take straight from a palette don't apply.
|
|
163
|
+
* Put the class on a container, not on :root.
|
|
164
|
+
*/
|
|
165
|
+
function pairBlocks(dictionary) {
|
|
166
|
+
const names = [...new Set(dictionary.allTokens.filter((t) => t.path[0] === 'pair').map((t) => t.path[1]))];
|
|
167
|
+
if (!names.length) return '';
|
|
168
|
+
const byPath = new Map(dictionary.allTokens.map((t) => [t.path.join('.'), t]));
|
|
169
|
+
const roles = Object.entries(PAIR_ROLES).filter(([p]) => byPath.has(p));
|
|
170
|
+
const repointed = new Set(roles.map(([p]) => p));
|
|
171
|
+
|
|
172
|
+
// Every token that reaches a re-pointed token through references, in any mode.
|
|
173
|
+
const dependents = new Map();
|
|
174
|
+
for (let grew = true; grew; ) {
|
|
175
|
+
grew = false;
|
|
176
|
+
for (const [p, t] of byPath) {
|
|
177
|
+
if (repointed.has(p) || dependents.has(p) || t.path[0] === 'pair') continue;
|
|
178
|
+
const refs = [...refsOf(t.original.$value), ...refsOf(t.original.$extensions?.['sl.modes'])];
|
|
179
|
+
if (refs.some((r) => repointed.has(r) || dependents.has(r))) {
|
|
180
|
+
dependents.set(p, t);
|
|
181
|
+
grew = true;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const part = (name, source) =>
|
|
187
|
+
Array.isArray(source)
|
|
188
|
+
? `color-mix(in oklab, var(${varName(['pair', name, source[0]])}) ${source[1]}%, var(${varName(['pair', name, source[2]])}))`
|
|
189
|
+
: `var(${varName(['pair', name, source])})`;
|
|
190
|
+
const deps = [...dependents.values()];
|
|
191
|
+
const blocks = names.map((name) => {
|
|
192
|
+
const lines = [
|
|
193
|
+
...roles.map(([p, source]) => `${varName(p.split('.'))}: ${part(name, source)};`),
|
|
194
|
+
...deps.map((t) => `${varName(t.path)}: ${refsToVars(t.original.$value)};`),
|
|
195
|
+
];
|
|
196
|
+
return `.${prefix}-pair-${name} {\n${indent(lines.join('\n'), 2)}\n}`;
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
return `/* Brand pairs: generated from color.pairs in the brand file. */\n${blocks.join('\n\n')}`;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
StyleDictionary.registerTransform({
|
|
203
|
+
name: 'name/sl-path',
|
|
204
|
+
type: 'name',
|
|
205
|
+
transform: (token, options) => [options.prefix, ...token.path].filter(Boolean).join('-'),
|
|
206
|
+
});
|
|
207
|
+
const layerOf = (token) => LAYERS.find((layer) => token.filePath.includes(`/tokens/${layer}/`));
|
|
208
|
+
|
|
209
|
+
StyleDictionary.registerFormat({
|
|
210
|
+
name: 'css/sl-layered',
|
|
211
|
+
format: async ({ dictionary, file, options }) => {
|
|
212
|
+
const header = await fileHeader({ file });
|
|
213
|
+
const variables = formattedVariables({
|
|
214
|
+
format: 'css',
|
|
215
|
+
dictionary,
|
|
216
|
+
outputReferences: options.outputReferences,
|
|
217
|
+
usesDtcg: true,
|
|
218
|
+
});
|
|
219
|
+
const root = `:root {\n${variables}\n}`;
|
|
220
|
+
const body = [root, modeBlocks(dictionary), visionBlocks(dictionary), roleBlocks(dictionary), pairBlocks(dictionary)].filter(Boolean).join('\n\n');
|
|
221
|
+
return `${header}@layer ${prefix}.tokens {\n${indent(body, 2)}\n}\n`;
|
|
222
|
+
},
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
StyleDictionary.registerFormat({
|
|
226
|
+
name: 'json/sl-catalog',
|
|
227
|
+
format: ({ dictionary }) => {
|
|
228
|
+
const catalog = dictionary.allTokens.map((token) => ({
|
|
229
|
+
name: `--${token.name}`,
|
|
230
|
+
path: token.path.join('.'),
|
|
231
|
+
layer: layerOf(token),
|
|
232
|
+
type: token.$type,
|
|
233
|
+
value: token.$value,
|
|
234
|
+
...(token.original.$value !== token.$value &&
|
|
235
|
+
typeof token.original.$value === 'string' &&
|
|
236
|
+
token.original.$value.includes('{') && { references: token.original.$value }),
|
|
237
|
+
...(token.$description && { description: token.$description }),
|
|
238
|
+
...(token.$extensions?.['sl.override'] && { pinned: true }),
|
|
239
|
+
...(token.$extensions?.['sl.modes'] && { modes: token.$extensions['sl.modes'] }),
|
|
240
|
+
...(token.$extensions?.['sl.modes'] &&
|
|
241
|
+
JSON.stringify(token.original.$extensions['sl.modes']) !== JSON.stringify(token.$extensions['sl.modes']) && {
|
|
242
|
+
modeReferences: token.original.$extensions['sl.modes'],
|
|
243
|
+
}),
|
|
244
|
+
}));
|
|
245
|
+
return `${JSON.stringify({ prefix, brand: brandName, tokens: catalog }, null, 2)}\n`;
|
|
246
|
+
},
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
function nestedValues(dictionary) {
|
|
250
|
+
const values = {};
|
|
251
|
+
for (const token of dictionary.allTokens) {
|
|
252
|
+
let cursor = values;
|
|
253
|
+
for (const key of token.path.slice(0, -1)) cursor = cursor[key] ??= {};
|
|
254
|
+
cursor[token.path.at(-1)] = token.$value;
|
|
255
|
+
}
|
|
256
|
+
return values;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function typeOf(value, indent = '') {
|
|
260
|
+
if (Array.isArray(value)) return 'readonly string[]';
|
|
261
|
+
if (typeof value !== 'object') return typeof value;
|
|
262
|
+
const inner = `${indent} `;
|
|
263
|
+
const fields = Object.entries(value).map(([key, v]) => `${inner}readonly ${JSON.stringify(key)}: ${typeOf(v, inner)};`);
|
|
264
|
+
return `{\n${fields.join('\n')}\n${indent}}`;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/** Nested object of resolved values only, e.g. tokens.color.primary['600'] → '#883ac3'. */
|
|
268
|
+
StyleDictionary.registerFormat({
|
|
269
|
+
name: 'javascript/sl-values',
|
|
270
|
+
format: async ({ dictionary, file }) => {
|
|
271
|
+
const header = await fileHeader({ file });
|
|
272
|
+
return `${header}const tokens = ${JSON.stringify(nestedValues(dictionary), null, 2)};\n\nexport default tokens;\n`;
|
|
273
|
+
},
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
StyleDictionary.registerFormat({
|
|
277
|
+
name: 'typescript/sl-values',
|
|
278
|
+
format: async ({ dictionary, file }) => {
|
|
279
|
+
const header = await fileHeader({ file });
|
|
280
|
+
return `${header}declare const tokens: ${typeOf(nestedValues(dictionary))};\n\nexport default tokens;\n`;
|
|
281
|
+
},
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* Text styles are written as one DTCG typography token each (tokens/semantic/typography.json).
|
|
286
|
+
* CSS can't use a composite, so this expands each into its parts plus a `font` shorthand:
|
|
287
|
+
* font.base → font.base.font, .font-family, .font-size, .font-weight, .line-height,
|
|
288
|
+
* .letter-spacing (and .text-transform when $extensions["sl.text-transform"] is set)
|
|
289
|
+
* so --sl-font-base-font-size and friends exist for components. A text style whose value is
|
|
290
|
+
* another text style ("{font.base}") takes that style's parts.
|
|
291
|
+
*/
|
|
292
|
+
const TEXT_PARTS = { fontFamily: 'font-family', fontSize: 'font-size', fontWeight: 'font-weight', lineHeight: 'line-height', letterSpacing: 'letter-spacing' };
|
|
293
|
+
export const TEXT_STYLE_TYPE = 'typography';
|
|
294
|
+
|
|
295
|
+
export function expandTextStyles(tokens) {
|
|
296
|
+
// Look aliases up in the tokens as written, since the walk replaces styles as it goes.
|
|
297
|
+
const written = structuredClone(tokens);
|
|
298
|
+
const at = (p) => p.split('.').reduce((o, k) => o?.[k], written);
|
|
299
|
+
const walk = (node, here) => {
|
|
300
|
+
for (const [key, child] of Object.entries(node)) {
|
|
301
|
+
if (key.startsWith('$') || !child || typeof child !== 'object') continue;
|
|
302
|
+
const p = [...here, key];
|
|
303
|
+
if ((child.$type ?? node.$type) !== TEXT_STYLE_TYPE || !('$value' in child)) {
|
|
304
|
+
walk(child, p);
|
|
305
|
+
continue;
|
|
306
|
+
}
|
|
307
|
+
let style = child;
|
|
308
|
+
const seen = new Set();
|
|
309
|
+
while (typeof style.$value === 'string') {
|
|
310
|
+
const ref = style.$value.match(/^\{([^}]+)\}$/)?.[1];
|
|
311
|
+
if (!ref || seen.has(ref) || !at(ref)) throw new Error(`Text style ${p.join('.')}: ${style.$value} is not a text style`);
|
|
312
|
+
seen.add(ref);
|
|
313
|
+
style = at(ref);
|
|
314
|
+
}
|
|
315
|
+
const name = p.join('.');
|
|
316
|
+
const what = child.$description ?? `The ${key} text style.`;
|
|
317
|
+
const group = { $description: what };
|
|
318
|
+
for (const [prop, part] of Object.entries(TEXT_PARTS)) {
|
|
319
|
+
if (style.$value[prop] === undefined) throw new Error(`Text style ${name} has no ${prop}`);
|
|
320
|
+
group[part] = { $value: style.$value[prop], $description: `${part} of ${name}.` };
|
|
321
|
+
}
|
|
322
|
+
const transform = child.$extensions?.['sl.text-transform'] ?? style.$extensions?.['sl.text-transform'];
|
|
323
|
+
if (transform) group['text-transform'] = { $value: transform, $description: `text-transform of ${name}.` };
|
|
324
|
+
group.font = {
|
|
325
|
+
$value: `{${name}.font-weight} {${name}.font-size} / {${name}.line-height} {${name}.font-family}`,
|
|
326
|
+
$description: `${what} As a font shorthand; letter-spacing and text-transform are separate.`,
|
|
327
|
+
};
|
|
328
|
+
// Parts keep the file the style came from, which says which layer they belong to.
|
|
329
|
+
for (const [part, token] of Object.entries(group)) if (!part.startsWith('$')) Object.assign(token, { filePath: child.filePath, isSource: child.isSource });
|
|
330
|
+
node[key] = group;
|
|
331
|
+
}
|
|
332
|
+
};
|
|
333
|
+
walk(tokens, []);
|
|
334
|
+
return tokens;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
StyleDictionary.registerPreprocessor({ name: 'sl/text-styles', preprocessor: (tokens) => expandTextStyles(tokens) });
|
|
338
|
+
|
|
339
|
+
/**
|
|
340
|
+
* Classes made from tokens, so they can't drift from them. All go in <out>/utilities.css:
|
|
341
|
+
* .text-style-<name> every text style in tokens/semantic/typography.json
|
|
342
|
+
* .icon-<tier> every icon size in tokens/semantic/icon.json
|
|
343
|
+
* .focus-ring, .focus-ring-inset from tokens/semantic/focus.json
|
|
344
|
+
* .hide-on-mobile|tablet|desktop from the foundation breakpoints
|
|
345
|
+
* They read only this package's tokens, so every brand gets the same classes.
|
|
346
|
+
*/
|
|
347
|
+
const readTokens = (file) => JSON.parse(fs.readFileSync(path.join(root, 'tokens', file), 'utf8'));
|
|
348
|
+
|
|
349
|
+
function textStyleBlocks() {
|
|
350
|
+
const { font } = readTokens('semantic/typography.json');
|
|
351
|
+
const styles = Object.entries(font).filter(([name, t]) => !name.startsWith('$') && t.$type === TEXT_STYLE_TYPE);
|
|
352
|
+
return styles.map(([name, t]) => {
|
|
353
|
+
const v = (part) => `var(${varName(['font', name, part])})`;
|
|
354
|
+
const transform = t.$extensions?.['sl.text-transform'] ?? (typeof t.$value === 'string' && font[t.$value.slice(1, -1).split('.')[1]]?.$extensions?.['sl.text-transform']);
|
|
355
|
+
// Longhands, not the font shorthand: the shorthand would also reset font-feature-settings
|
|
356
|
+
// and the other font properties the page sets once on body.
|
|
357
|
+
const parts = ['font-family', 'font-size', 'font-weight', 'line-height', 'letter-spacing', ...(transform ? ['text-transform'] : [])];
|
|
358
|
+
const lines = parts.map((part) => `${part}: ${v(part)};`);
|
|
359
|
+
return `/* ${t.$description ?? name} */\n.text-style-${name} {\n${indent(lines.join('\n'), 2)}\n}`;
|
|
360
|
+
});
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/**
|
|
364
|
+
* The text styles as CSS patterns, so component CSS can use a whole text style:
|
|
365
|
+
* @sl-pattern text-<style>; (expanded by loomiere-base-ui's build).
|
|
366
|
+
*/
|
|
367
|
+
export function textStylePatterns(cssPrefix = 'sl') {
|
|
368
|
+
prefix = cssPrefix;
|
|
369
|
+
return textStyleBlocks()
|
|
370
|
+
.map((block) => block.replace(/\n\.text-style-([\w-]+) \{/, '\n@sl-define text-$1 {'))
|
|
371
|
+
.join('\n\n');
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
function utilitiesCss() {
|
|
375
|
+
const sections = [['Text styles: one class per text style token.', textStyleBlocks()]];
|
|
376
|
+
|
|
377
|
+
// Custom properties can't be used inside @media, so the widths are written in.
|
|
378
|
+
const { breakpoint: bp } = readTokens('foundation/layout.json');
|
|
379
|
+
sections.push([
|
|
380
|
+
'Responsive visibility from the breakpoint tokens.',
|
|
381
|
+
[
|
|
382
|
+
['mobile', `(width < ${bp.sm.$value})`, 'Phones, below the sm breakpoint.'],
|
|
383
|
+
['tablet', `(${bp.sm.$value} <= width < ${bp.md.$value})`, 'Tablets, from sm up to md.'],
|
|
384
|
+
['desktop', `(width >= ${bp.md.$value})`, 'md and wider.'],
|
|
385
|
+
].map(([name, query, what]) => `/* ${what} */\n@media ${query} {\n .hide-on-${name} {\n display: none !important;\n }\n}`),
|
|
386
|
+
]);
|
|
387
|
+
|
|
388
|
+
// Icons: font icons size by font-size, SVGs by width and height, so set all three.
|
|
389
|
+
const { 'icon-size': icons } = readTokens('semantic/icon.json');
|
|
390
|
+
sections.push([
|
|
391
|
+
'Icon sizes: one class per text tier.',
|
|
392
|
+
Object.entries(icons)
|
|
393
|
+
.filter(([name]) => !name.startsWith('$'))
|
|
394
|
+
.map(([name, t]) => {
|
|
395
|
+
const v = `var(${varName(['icon-size', name])})`;
|
|
396
|
+
return `/* ${t.$description} */\n.icon-${name} {\n width: ${v};\n height: ${v};\n font-size: ${v};\n line-height: 1;\n}`;
|
|
397
|
+
}),
|
|
398
|
+
]);
|
|
399
|
+
|
|
400
|
+
const { focus } = readTokens('semantic/focus.json');
|
|
401
|
+
sections.push([
|
|
402
|
+
'Keyboard focus rings from the focus tokens.',
|
|
403
|
+
[
|
|
404
|
+
['focus-ring', 'outline-offset'],
|
|
405
|
+
['focus-ring-inset', 'outline-offset-inset'],
|
|
406
|
+
].map(
|
|
407
|
+
([name, offset]) =>
|
|
408
|
+
`/* ${focus[offset].$description} */\n.${name}:focus-visible {\n outline: var(${varName(['focus', 'outline'])});\n outline-offset: var(${varName(['focus', offset])});${
|
|
409
|
+
offset === 'outline-offset' ? `\n box-shadow: 0 0 0 var(${varName(['focus', 'halo-spread'])}) var(${varName(['focus', 'halo-color'])});` : ''
|
|
410
|
+
}\n}`,
|
|
411
|
+
),
|
|
412
|
+
]);
|
|
413
|
+
const body = sections.map(([what, blocks]) => `/* ${what} */\n\n${blocks.join('\n\n')}`).join('\n\n');
|
|
414
|
+
return `/* Utility classes for "${brandName}". Generated by loomiere build; do not edit. */\n\n${body}\n`;
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
/**
|
|
418
|
+
* Builds one theme. `config` comes from resolveConfig() (lib/config.mjs):
|
|
419
|
+
* { brandFile, outDir, generatedDir, prefix, exports }.
|
|
420
|
+
*/
|
|
421
|
+
export async function buildTokens(config) {
|
|
422
|
+
prefix = config.prefix;
|
|
423
|
+
const { brand, applied, files } = generate(config.brandFile, config.generatedDir);
|
|
424
|
+
brandName = brand.name;
|
|
425
|
+
const out = config.outDir + path.sep;
|
|
426
|
+
|
|
427
|
+
const sd = new StyleDictionary({
|
|
428
|
+
usesDtcg: true,
|
|
429
|
+
preprocessors: ['sl/text-styles'],
|
|
430
|
+
log: { verbosity: 'default', warnings: 'warn' },
|
|
431
|
+
source: [
|
|
432
|
+
// Layer order, so tokens.css reads foundation → primitive → semantic → component.
|
|
433
|
+
path.join(root, 'tokens', 'foundation', '**', '*.json'),
|
|
434
|
+
path.join(config.generatedDir, 'tokens', 'primitive', '**', '*.json'),
|
|
435
|
+
path.join(root, 'tokens', 'semantic', '**', '*.json'),
|
|
436
|
+
path.join(config.generatedDir, 'tokens', 'semantic', '**', '*.json'),
|
|
437
|
+
path.join(root, 'tokens', 'component', '**', '*.json'),
|
|
438
|
+
],
|
|
439
|
+
platforms: {
|
|
440
|
+
css: {
|
|
441
|
+
prefix,
|
|
442
|
+
transforms: ['name/sl-path', 'fontFamily/css', 'cubicBezier/css'],
|
|
443
|
+
buildPath: out,
|
|
444
|
+
files: [
|
|
445
|
+
{
|
|
446
|
+
destination: 'tokens.css',
|
|
447
|
+
format: 'css/sl-layered',
|
|
448
|
+
options: { outputReferences: true, showFileHeader: true },
|
|
449
|
+
},
|
|
450
|
+
{ destination: 'tokens.json', format: 'json/sl-catalog' },
|
|
451
|
+
],
|
|
452
|
+
},
|
|
453
|
+
js: {
|
|
454
|
+
transforms: ['name/camel', 'fontFamily/css', 'cubicBezier/css'],
|
|
455
|
+
buildPath: out,
|
|
456
|
+
files: [
|
|
457
|
+
{ destination: 'tokens.js', format: 'javascript/sl-values' },
|
|
458
|
+
{ destination: 'tokens.d.ts', format: 'typescript/sl-values' },
|
|
459
|
+
],
|
|
460
|
+
},
|
|
461
|
+
},
|
|
462
|
+
});
|
|
463
|
+
|
|
464
|
+
await sd.buildAllPlatforms();
|
|
465
|
+
|
|
466
|
+
fs.writeFileSync(path.join(config.outDir, 'utilities.css'), utilitiesCss());
|
|
467
|
+
|
|
468
|
+
// Every palette color against every other, for docs, agents and the ramp page.
|
|
469
|
+
const colors = paletteColors(files.color.color);
|
|
470
|
+
const pairs = pairTable(colors);
|
|
471
|
+
const compact = Object.fromEntries(
|
|
472
|
+
Object.entries(pairs).map(([p, levels]) => [
|
|
473
|
+
p,
|
|
474
|
+
Object.fromEntries(Object.entries(levels).map(([level, list]) => [level, list.map(({ path: b, ratio }) => [b, ratio])])),
|
|
475
|
+
]),
|
|
476
|
+
);
|
|
477
|
+
fs.writeFileSync(
|
|
478
|
+
path.join(config.outDir, 'pairs.json'),
|
|
479
|
+
`${JSON.stringify({ brand: brandName, levels: Object.fromEntries(LEVELS), colors: Object.fromEntries(colors.map((c) => [c.path, c.hex])), pairs: compact })}\n`,
|
|
480
|
+
);
|
|
481
|
+
// Other frameworks: DTCG JSON plus one adapter per framework (exporters/).
|
|
482
|
+
const catalog = JSON.parse(fs.readFileSync(path.join(config.outDir, 'tokens.json'), 'utf8'));
|
|
483
|
+
const written = exportAll(catalog, resolveRoles(brand), path.join(config.outDir, 'exports'), config.exports);
|
|
484
|
+
return { brand, applied, exports: written };
|
|
485
|
+
}
|