@scattered-light/loomiere 0.1.1 → 0.2.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 +1 -1
- package/build/build-tokens.mjs +1 -1
- package/exporters/README.md +30 -3
- package/exporters/design-system.mjs +392 -0
- package/exporters/index.mjs +17 -5
- package/exporters/shadcn.mjs +110 -0
- package/exporters/tailwind.mjs +112 -8
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -55,7 +55,7 @@ Every value is a `--sl-` variable, so your own CSS, and component packages such
|
|
|
55
55
|
| `utilities.css` | `.text-style-*`, `.icon-*`, `.focus-ring`, `.hide-on-*`. |
|
|
56
56
|
| `tokens.js`, `tokens.d.ts` | Resolved values for JavaScript. |
|
|
57
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
|
|
58
|
+
| `exports/` | DTCG JSON, Figma variables, Tailwind v4, shadcn/ui, MUI, Chakra v3, AG Grid 31–32, 33–35 and 36+, a Claude Design System and `AGENTS.md` (the brand book for agents). See [exporters/README.md](exporters/README.md). |
|
|
59
59
|
| `generated/` | The brand's primitive tokens. Rebuilt every time; no need to commit. |
|
|
60
60
|
|
|
61
61
|
How the layers work, the brand file's fields, roles, concepts and pairs: [tokens/README.md](tokens/README.md).
|
package/build/build-tokens.mjs
CHANGED
|
@@ -480,6 +480,6 @@ export async function buildTokens(config) {
|
|
|
480
480
|
);
|
|
481
481
|
// Other frameworks: DTCG JSON plus one adapter per framework (exporters/).
|
|
482
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);
|
|
483
|
+
const written = exportAll(catalog, resolveRoles(brand), path.join(config.outDir, 'exports'), config.exports, { themeDir: config.outDir, brand });
|
|
484
484
|
return { brand, applied, exports: written };
|
|
485
485
|
}
|
package/exporters/README.md
CHANGED
|
@@ -7,12 +7,14 @@ adapter in this folder, and each one only renames. No adapter does color math.
|
|
|
7
7
|
|---|---|---|
|
|
8
8
|
| (built in) | `tokens.css` | Any framework, or none. Every token as a `--sl-` variable, with dark, contrast, color vision and roles. |
|
|
9
9
|
| `dtcg` | `tokens.dtcg.json` | Any tool that reads the W3C design tokens format: Figma importers, Tokens Studio, Style Dictionary, Terrazzo, Penpot. |
|
|
10
|
-
| `tailwind` | `tailwind.css` | Tailwind CSS v4. Points at the `--sl-` variables instead of copying values. |
|
|
10
|
+
| `tailwind` | `tailwind.css` | Tailwind CSS v4: text styles by tier, palettes, roles, concepts, brand scales, pairs and scopes as classes. Points at the `--sl-` variables instead of copying values, and imports `tokens.css` itself. With loomiere-base-ui, pair it with `@scattered-light/loomiere-base-ui/tailwind.css`. |
|
|
11
|
+
| `shadcn` | `shadcn.css` | shadcn/ui: its variables (`--primary`, `--accent`, `--ring`, charts, sidebar) pointed at Loomiere concepts. Replaces shadcn's `globals.css` color blocks; works alone or after `tailwind.css`. |
|
|
11
12
|
| `mui` | `mui.js`, `.d.ts` | MUI 6, 7 and 9. `createTheme(options)`: the palette in light and dark, plus component overrides that match loomiere-base-ui. Needs `tokens.css`. |
|
|
12
13
|
| `chakra` | `chakra.js`, `.d.ts` | Chakra UI 3. `<ChakraProvider value={system}>`: every palette as a color palette, plus recipes that match loomiere-base-ui. Needs `tokens.css`. |
|
|
13
14
|
| `ag-grid-v36` | `ag-grid-v36.js`, `.d.ts`, `.css` | AG Grid 36 and later (Theming API). |
|
|
14
15
|
| `ag-grid-v33` | `ag-grid-v33.js`, `.d.ts`, `.css` | AG Grid 33 to 35 (Theming API). |
|
|
15
16
|
| `ag-grid-v32` | `ag-grid-v32.css` | AG Grid 31 and 32 (CSS themes). All three come from `ag-grid-settings.mjs`. |
|
|
17
|
+
| `design-system` | `design-system/`, `AGENTS.md` | A Claude Design System (claude.ai), so Claude designs, makes decks and builds UI with the brand. `AGENTS.md` is the same brand book for coding agents, plus how to load the theme. See below. |
|
|
16
18
|
| `figma` | `figma.json` | Figma variables in three collections: Brand, Pairs, Theme (Light and Dark modes). Aliases point back to Brand, so a palette change flows through. Fluid sizes use their large-screen value. |
|
|
17
19
|
|
|
18
20
|
`loomiere build` writes them all to the theme's `exports/` folder, with `catalog.json` listing what's there.
|
|
@@ -20,6 +22,32 @@ adapter in this folder, and each one only renames. No adapter does color math.
|
|
|
20
22
|
family, so `ag-grid` writes all three AG Grid themes). The Mojave demo package exposes them as
|
|
21
23
|
`@scattered-light/loomiere-mojave/exports/<file>`.
|
|
22
24
|
|
|
25
|
+
## Claude Design System
|
|
26
|
+
|
|
27
|
+
Claude keeps a brand as a "Design System" artifact: a README written as a brand book, a
|
|
28
|
+
`tokens.json`, and per component a guide and a live preview. Once it's the default, Claude uses it
|
|
29
|
+
for every design and deck and checks its work against it; any chat or Claude Code session can read
|
|
30
|
+
it. `design-system/` is that artifact's `project/` folder:
|
|
31
|
+
|
|
32
|
+
- `README.md`: rules that name tokens (semantic first, emphasis levels, scopes, pairs, type tiers,
|
|
33
|
+
space, shape, focus, modes), written from the brand file and each token's description.
|
|
34
|
+
- `tokens.json`: palettes and every semantic color in light and dark (a semantic color that
|
|
35
|
+
points at a palette step stays an alias of it), the text styles by tier, space, radius,
|
|
36
|
+
elevation and stroke. Each token's description is its usage note. Names are the CSS variables
|
|
37
|
+
without the dashes, so an edit made on the page lands on the variable the components read.
|
|
38
|
+
- `components/bundle.css`: the whole theme plus the Base UI styles, so previews and Claude's
|
|
39
|
+
designs look exactly like the app, modes and pairs included.
|
|
40
|
+
- `components/bundle.js`, `<Name>/README.md`, `<Name>/preview.html`: copied from
|
|
41
|
+
`@scattered-light/loomiere-base-ui/design-system/` when that package is installed (it builds them
|
|
42
|
+
from the Storybook stories). `bundle.js` sets `window.Loomiere` to every Base UI part plus the
|
|
43
|
+
wrappers.
|
|
44
|
+
- `components/Cover/preview.html`: the cover, the brand's colors as blocks and its name.
|
|
45
|
+
|
|
46
|
+
To publish, ask Claude Code (with the Artifact tool) to make a Design System artifact from
|
|
47
|
+
`exports/design-system` the first time, and to republish the changed files after a rebuild. Then
|
|
48
|
+
set it as the default in claude.ai under Settings > Design systems. The page writes its own
|
|
49
|
+
`tokens.css` and component cards; the export leaves those out.
|
|
50
|
+
|
|
23
51
|
## How it works
|
|
24
52
|
|
|
25
53
|
1. The build writes the token catalog (`tokens.json`).
|
|
@@ -41,5 +69,4 @@ file's text (or `{ fileName: text }` when it writes several, as the AG Grid them
|
|
|
41
69
|
when the framework allows it (as Tailwind does), so modes and roles keep working at runtime;
|
|
42
70
|
otherwise give light and dark values the way the framework expects (as MUI's palette does,
|
|
43
71
|
since MUI does color math on it).
|
|
44
|
-
Good next candidates: Panda CSS, Radix Themes,
|
|
45
|
-
names), Vuetify and Mantine.
|
|
72
|
+
Good next candidates: Panda CSS, Radix Themes, Vuetify and Mantine.
|
|
@@ -0,0 +1,392 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A Claude Design System: the files of a "Design System" artifact on claude.ai, which Claude reads
|
|
3
|
+
* before it designs, writes a deck or builds UI for the brand. Written to exports/design-system/,
|
|
4
|
+
* laid out as the artifact's project/ folder:
|
|
5
|
+
*
|
|
6
|
+
* README.md the brand book: usage rules that name tokens, generated from the brand
|
|
7
|
+
* tokens.json the tokens the page shows and edits: flat lists of { name, value, usage }
|
|
8
|
+
* where usage is each token's "why", colors in light and dark
|
|
9
|
+
* components/bundle.css the whole theme (tokens.css, utilities.css) plus the Base UI component
|
|
10
|
+
* styles, so components and previews look exactly like the app
|
|
11
|
+
* components/... bundle.js, previews and guidelines from loomiere-base-ui, when it's installed
|
|
12
|
+
*
|
|
13
|
+
* Names are the CSS variables without the leading dashes (sl-text-primary), so a value edited on
|
|
14
|
+
* the page lands on the same variable the components read. The page reads no DTCG, var() or
|
|
15
|
+
* color-mix(), so colors are resolved per mode; a semantic color that points at a palette step
|
|
16
|
+
* stays an alias of that step. AGENTS.md is the same brand book with how to use the theme in code.
|
|
17
|
+
*/
|
|
18
|
+
import fs from 'node:fs';
|
|
19
|
+
import { createRequire } from 'node:module';
|
|
20
|
+
import path from 'node:path';
|
|
21
|
+
|
|
22
|
+
import { formatHex, formatHex8, interpolate, parse } from 'culori';
|
|
23
|
+
|
|
24
|
+
import { valueIn } from './theme.mjs';
|
|
25
|
+
|
|
26
|
+
const hex = (c) => ((c.alpha ?? 1) < 1 ? formatHex8(c) : formatHex(c));
|
|
27
|
+
|
|
28
|
+
/** color-mix(in oklab|srgb, A p%, B) → a hex; anything else parse() understands → a hex. */
|
|
29
|
+
function solve(value) {
|
|
30
|
+
const mix = String(value).match(/^color-mix\(in (\w+),\s*(.+?)\s+([\d.]+)%,\s*(.+)\)$/);
|
|
31
|
+
if (mix) {
|
|
32
|
+
const [a, b] = [solve(mix[2]), solve(mix[4])];
|
|
33
|
+
return a && b ? hex(interpolate([b, a], mix[1] === 'srgb' ? 'rgb' : mix[1])(Number(mix[3]) / 100)) : null;
|
|
34
|
+
}
|
|
35
|
+
if (value === 'transparent') return '#00000000';
|
|
36
|
+
const c = typeof value === 'string' ? parse(value) : undefined;
|
|
37
|
+
return c ? hex(c) : null;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Semantic families whose colors the page lists, in this order. */
|
|
41
|
+
const SEMANTIC = ['surface', 'text', 'border', 'fill', 'interactive', 'focus', 'status', 'overlay', 'emphasis', 'content', 'field', 'popup', 'item', 'check', 'modal', 'drawer', 'bar', 'disclosure', 'notice', 'gauge', 'table', 'pair'];
|
|
42
|
+
|
|
43
|
+
/** "clamp(0.875rem, 0.8333rem + 0.2083vw, 1rem)" → "1rem", the large-screen size. */
|
|
44
|
+
const largest = (v) => String(v).match(/,\s*([\d.]+rem)\)$/)?.[1] ?? String(v);
|
|
45
|
+
|
|
46
|
+
const cap = (s) => s.charAt(0).toUpperCase() + s.slice(1);
|
|
47
|
+
|
|
48
|
+
export default {
|
|
49
|
+
id: 'design-system',
|
|
50
|
+
name: 'Claude Design System',
|
|
51
|
+
file: 'design-system/README.md',
|
|
52
|
+
for: 'A Design System artifact on claude.ai, so Claude designs, makes decks and builds UI with this brand. Also AGENTS.md: the brand book for coding agents.',
|
|
53
|
+
build(theme, { themeDir, brand } = {}) {
|
|
54
|
+
const byPath = new Map(theme.tokens.map((t) => [t.path, t]));
|
|
55
|
+
const cssName = (p) => byPath.get(p).name.replace(/^--/, '');
|
|
56
|
+
const title = cap(theme.brand);
|
|
57
|
+
|
|
58
|
+
/** A token's "why", or the first one found along what it points at. */
|
|
59
|
+
function usageOf(t) {
|
|
60
|
+
for (let cur = t, hops = 0; cur && hops < 8; hops++) {
|
|
61
|
+
if (cur.description) return cur.description;
|
|
62
|
+
const ref = typeof cur.references === 'string' && cur.references.match(/^\{([^}]+)\}$/)?.[1];
|
|
63
|
+
cur = ref && byPath.get(ref);
|
|
64
|
+
}
|
|
65
|
+
return undefined;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// ------------------------------------------------------------------------------- colors
|
|
69
|
+
const palettes = theme.tokens.filter((t) => /^color\.(?!accent\.|chrome\.)/.test(t.path) && t.layer === 'primitive');
|
|
70
|
+
const semantic = SEMANTIC.flatMap((top) =>
|
|
71
|
+
theme.tokens.filter((t) => t.path.startsWith(`${top}.`) && t.layer !== 'primitive' && solve(t.value)),
|
|
72
|
+
);
|
|
73
|
+
const included = new Set([...palettes, ...semantic].map((t) => t.path));
|
|
74
|
+
|
|
75
|
+
/** What a token points at in a mode: a path, or null when the mode holds a value of its own. */
|
|
76
|
+
const refIn = (t, mode) => {
|
|
77
|
+
const ref = mode === 'dark' ? (t.modeReferences?.dark ?? (t.modes?.dark === undefined ? t.references : null)) : t.references;
|
|
78
|
+
return typeof ref === 'string' ? ref.match(/^\{([^}]+)\}$/)?.[1] ?? null : null;
|
|
79
|
+
};
|
|
80
|
+
/** "{sl-…}" for the nearest listed token along the chain, else the resolved hex. */
|
|
81
|
+
function colorIn(t, mode) {
|
|
82
|
+
for (let cur = t, hops = 0; hops < 16; hops++) {
|
|
83
|
+
const ref = refIn(cur, mode);
|
|
84
|
+
if (!ref || !byPath.has(ref)) break;
|
|
85
|
+
if (included.has(ref)) return `{${cssName(ref)}}`;
|
|
86
|
+
cur = byPath.get(ref);
|
|
87
|
+
}
|
|
88
|
+
return solve(valueIn(byPath, t.path, mode));
|
|
89
|
+
}
|
|
90
|
+
const colorTokens = [...semantic, ...palettes].flatMap((t) => {
|
|
91
|
+
const light = colorIn(t, 'light');
|
|
92
|
+
const dark = colorIn(t, 'dark');
|
|
93
|
+
if (!light) return [];
|
|
94
|
+
return [{ name: cssName(t.path), value: dark && dark !== light ? { light, dark } : light, usage: usageOf(t) ?? t.path }];
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
// ------------------------------------------------------------------------------- type
|
|
98
|
+
const styleNames = [...new Set(theme.tokens.filter((t) => /^font\.[^.]+\.font$/.test(t.path)).map((t) => t.path.split('.')[1]))];
|
|
99
|
+
const familyKey = (style) => byPath.get(`font.${style}.font-family`)?.references?.match(/^\{font-family\.([^}]+)\}$/)?.[1] ?? 'body';
|
|
100
|
+
const tierOf = (style) => (style === 'code' ? 'code' : style.split('-')[0]);
|
|
101
|
+
const TIERS = ['minimize', 'condense', 'base', 'magnify', 'maximize', 'code'];
|
|
102
|
+
const tiers = [...new Set(styleNames.map(tierOf))].sort((a, b) => (TIERS.indexOf(a) + 1 || 99) - (TIERS.indexOf(b) + 1 || 99));
|
|
103
|
+
const styleOf = (style) => {
|
|
104
|
+
const v = (prop) => byPath.get(`font.${style}.${prop}`)?.value;
|
|
105
|
+
const size = v('font-size');
|
|
106
|
+
const fluid = byPath.get(`font.${style}.font-size`)?.references?.match(/^\{(font-size\.[^}]+)\}$/)?.[1];
|
|
107
|
+
const sizeNote = fluid && String(size).startsWith('clamp') ? ` Fluid: ${byPath.get(fluid).description}.` : '';
|
|
108
|
+
const why = (byPath.get(`font.${style}.font`)?.description ?? '').replace(/\s*As a font shorthand;.*$/, '');
|
|
109
|
+
return {
|
|
110
|
+
name: `text-style-${style}`,
|
|
111
|
+
family: familyKey(style),
|
|
112
|
+
fontSize: largest(size),
|
|
113
|
+
lineHeight: v('line-height'),
|
|
114
|
+
fontWeight: Number(v('font-weight')) || 400,
|
|
115
|
+
...(v('letter-spacing') && v('letter-spacing') !== '0' && { letterSpacing: v('letter-spacing') }),
|
|
116
|
+
usage: `${why}${sizeNote}`.trim(),
|
|
117
|
+
};
|
|
118
|
+
};
|
|
119
|
+
const families = Object.fromEntries(
|
|
120
|
+
theme.tokens.filter((t) => /^font-family\.(display|body|mono)$/.test(t.path)).map((t) => [t.path.split('.')[1], t.value]),
|
|
121
|
+
);
|
|
122
|
+
|
|
123
|
+
// ------------------------------------------------------------------------------- lengths
|
|
124
|
+
const list = (re, keep = () => true) =>
|
|
125
|
+
theme.tokens
|
|
126
|
+
.filter((t) => re.test(t.path) && keep(t))
|
|
127
|
+
.map((t) => ({ name: cssName(t.path), value: String(largest(t.value)), usage: usageOf(t) ?? t.path }));
|
|
128
|
+
|
|
129
|
+
const tokens = {
|
|
130
|
+
name: title,
|
|
131
|
+
version: 1,
|
|
132
|
+
meta: { source: 'loomiere', brand: theme.brand, prefix: theme.prefix },
|
|
133
|
+
color: { themes: [{ id: 'light', name: 'Light' }, { id: 'dark', name: 'Dark' }], tokens: colorTokens },
|
|
134
|
+
type: {
|
|
135
|
+
fonts: [],
|
|
136
|
+
families,
|
|
137
|
+
groups: tiers.map((tier) => ({
|
|
138
|
+
name: cap(tier),
|
|
139
|
+
family: familyKey(styleNames.find((s) => tierOf(s) === tier)),
|
|
140
|
+
styles: styleNames.filter((s) => tierOf(s) === tier).map(styleOf),
|
|
141
|
+
})),
|
|
142
|
+
},
|
|
143
|
+
spacing: { tokens: list(/^space\./) },
|
|
144
|
+
radius: { tokens: list(/^radius\./) },
|
|
145
|
+
shadow: { tokens: list(/^elevation\./) },
|
|
146
|
+
stroke: { tokens: list(/^stroke\./) },
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
// ------------------------------------------------------------------------------- components
|
|
150
|
+
const read = (file) => (themeDir && fs.existsSync(path.join(themeDir, file)) ? fs.readFileSync(path.join(themeDir, file), 'utf8') : '');
|
|
151
|
+
let componentsDir = null;
|
|
152
|
+
if (themeDir) {
|
|
153
|
+
try {
|
|
154
|
+
const require = createRequire(path.join(themeDir, 'package.json'));
|
|
155
|
+
const dir = path.join(path.dirname(require.resolve('@scattered-light/loomiere-base-ui/package.json')), 'dist', 'design-system', 'components');
|
|
156
|
+
if (fs.existsSync(dir)) componentsDir = dir;
|
|
157
|
+
} catch {
|
|
158
|
+
// loomiere-base-ui isn't installed: the system ships tokens and the brand book only.
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
const files = {};
|
|
162
|
+
const componentStyles = componentsDir ? fs.readFileSync(path.join(componentsDir, 'styles.css'), 'utf8') : '';
|
|
163
|
+
if (componentsDir) {
|
|
164
|
+
for (const file of fs.readdirSync(componentsDir, { recursive: true })) {
|
|
165
|
+
const full = path.join(componentsDir, String(file));
|
|
166
|
+
if (fs.statSync(full).isFile() && String(file) !== 'styles.css') files[`design-system/components/${String(file).split(path.sep).join('/')}`] = fs.readFileSync(full, 'utf8');
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
// One stylesheet: @imports first (CSS needs them on top), then the theme, then the components.
|
|
170
|
+
const sheets = [read('tokens.css'), read('utilities.css'), componentStyles];
|
|
171
|
+
const imports = sheets.flatMap((s) => s.match(/^@import url\([^)]*\);$/gm) ?? []);
|
|
172
|
+
const bundleCss = [...new Set(imports), ...sheets.map((s) => s.replace(/^@import url\([^)]*\);$/gm, ''))].join('\n');
|
|
173
|
+
files['design-system/components/bundle.css'] = bundleCss;
|
|
174
|
+
|
|
175
|
+
const componentNames = Object.keys(files)
|
|
176
|
+
.map((f) => f.match(/^design-system\/components\/([A-Z]\w+)\/README\.md$/)?.[1])
|
|
177
|
+
.filter(Boolean);
|
|
178
|
+
|
|
179
|
+
// ------------------------------------------------------------------------------- README
|
|
180
|
+
const readme = brandBook({ theme, brand, byPath, cssName, usageOf, tokens, tiers, styleNames, componentNames });
|
|
181
|
+
files['design-system/README.md'] = readme;
|
|
182
|
+
files['design-system/components/Cover/preview.html'] = cover({ theme, brand, title });
|
|
183
|
+
files['design-system/tokens.json'] = `${JSON.stringify(tokens, null, 2)}\n`;
|
|
184
|
+
files['AGENTS.md'] = agentsGuide({ theme, readme, componentNames: componentNames.length > 0 });
|
|
185
|
+
return files;
|
|
186
|
+
},
|
|
187
|
+
};
|
|
188
|
+
|
|
189
|
+
/** The brand book: imperative rules a consuming agent follows, naming the tokens. */
|
|
190
|
+
function brandBook({ theme, brand, byPath, cssName, usageOf, tokens, tiers, styleNames, componentNames }) {
|
|
191
|
+
const v = (p) => `\`--${cssName(p)}\``;
|
|
192
|
+
const has = (p) => byPath.has(p);
|
|
193
|
+
const why = (p) => usageOf(byPath.get(p)) ?? '';
|
|
194
|
+
const roles = theme.roles ?? {};
|
|
195
|
+
const label = (palette) => brand?.color?.palettes?.[palette]?.label;
|
|
196
|
+
const named = (palette) => (label(palette) ? `${palette} (${label(palette)})` : palette);
|
|
197
|
+
const rows = (paths) => paths.filter(has).map((p) => `| ${v(p)} | ${why(p)} |`);
|
|
198
|
+
const out = [];
|
|
199
|
+
|
|
200
|
+
if (brand?.description) out.push(brand.description, '');
|
|
201
|
+
out.push(
|
|
202
|
+
'Every value in this system is a CSS variable with the `--' + theme.prefix + '-` prefix. Reach for the semantic tokens (surfaces, text, borders, fills, emphasis); use a palette step only when no semantic token says what you mean. Never paste a hex value: the variables carry light and dark, high contrast and color vision modes, and a palette or role change in the brand reaches everything that uses them.',
|
|
203
|
+
'',
|
|
204
|
+
'## Color',
|
|
205
|
+
'',
|
|
206
|
+
`The **chrome** role is the ${named(roles.chrome ?? 'neutral')} palette: surfaces, text, borders and quiet controls. The **accent** role is the ${named(roles.accent ?? 'primary')} palette: links, focus and the loudest actions. Status colors (success, warning, error, info) mean only their status; pair each with a word or an icon, never color alone.`,
|
|
207
|
+
'',
|
|
208
|
+
'### Surfaces and text',
|
|
209
|
+
'',
|
|
210
|
+
'| Token | Use |',
|
|
211
|
+
'|---|---|',
|
|
212
|
+
...rows(['surface.canvas', 'surface.raised', 'surface.overlay', 'surface.modal', 'surface.tray', 'surface.inverse', 'text.primary', 'text.secondary', 'text.tertiary', 'text.disabled', 'text.accent', 'text.on-primary', 'text.on-tint', 'text.on-inverse-surface']),
|
|
213
|
+
'',
|
|
214
|
+
'### Borders, fills and interaction',
|
|
215
|
+
'',
|
|
216
|
+
'| Token | Use |',
|
|
217
|
+
'|---|---|',
|
|
218
|
+
...rows(['border.subtle', 'border.default', 'border.strong', 'border.control', 'border.control-hover', 'border.accent', 'fill.subtle', 'fill.default', 'fill.strong', 'fill.inverse', 'interactive.default', 'interactive.hover', 'interactive.active', 'interactive.tint', 'interactive.tint-hover', 'interactive.tint-active', 'focus.ring']),
|
|
219
|
+
'',
|
|
220
|
+
'### Status',
|
|
221
|
+
'',
|
|
222
|
+
'| Token | Use |',
|
|
223
|
+
'|---|---|',
|
|
224
|
+
...rows(['status.success', 'status.warning', 'status.error', 'status.info', 'status.error-dark', 'status.error-light']),
|
|
225
|
+
'',
|
|
226
|
+
'Each status has `-dark` (text and icons that need more contrast) and `-light` (the tint behind a message) variants.',
|
|
227
|
+
'',
|
|
228
|
+
'### Palettes',
|
|
229
|
+
'',
|
|
230
|
+
);
|
|
231
|
+
const paletteNames = [...new Set(tokens.color.tokens.map((t) => t.name.match(new RegExp(`^${theme.prefix}-color-([a-z]+)-\\d+$`))?.[1]).filter(Boolean))];
|
|
232
|
+
out.push(
|
|
233
|
+
`Palettes: ${paletteNames.map((p) => `\`${p}\`${label(p) ? ` (${label(p)})` : ''}`).join(', ')}. Steps run from 50 (lightest) to the darkest step, and each step is placed by its contrast against paper, so a step number means the same contrast in every palette: text on paper needs 500 or darker (4.5:1). Every step has an on-color, \`--${theme.prefix}-color-<palette>-on-<step>\`, for text or icons placed on it.`,
|
|
234
|
+
'',
|
|
235
|
+
);
|
|
236
|
+
if (paletteNames.includes('data')) out.push(`The \`data\` palette (\`--${theme.prefix}-color-data-<n>\`) is for charts only: series colors, never UI.`, '');
|
|
237
|
+
|
|
238
|
+
out.push(
|
|
239
|
+
'### Emphasis',
|
|
240
|
+
'',
|
|
241
|
+
'Controls come in four levels of loudness. Pick the level by how much the action matters, not by color; the brand decides what each level looks like.',
|
|
242
|
+
'',
|
|
243
|
+
'| Level | Looks like | Use for |',
|
|
244
|
+
'|---|---|---|',
|
|
245
|
+
);
|
|
246
|
+
const ladder = { bold: 'The one main action in a view.', standard: 'Secondary actions beside a bold one.', subtle: 'Frequent, low-stakes actions such as toolbar buttons.', quiet: 'Actions that should stay out of the way until needed.' };
|
|
247
|
+
for (const [level, use] of Object.entries(ladder)) {
|
|
248
|
+
const e = roles.emphasis?.[level];
|
|
249
|
+
out.push(`| \`${level}\` | ${e ? `${e.style} in ${e.color}` : ''} | ${use} |`);
|
|
250
|
+
}
|
|
251
|
+
out.push(
|
|
252
|
+
'',
|
|
253
|
+
`Set it with \`data-emphasis="bold|standard|subtle|quiet"\` on a Button or any element styled from \`--${theme.prefix}-emphasis-<level>-bg|fg|border\` (with \`-hover\` and \`-active\`). On Button, \`data-variant\` is an alias: \`solid\` = bold, \`outline\` = standard, \`ghost\` = quiet.`,
|
|
254
|
+
'',
|
|
255
|
+
'### Color scopes',
|
|
256
|
+
'',
|
|
257
|
+
`\`data-color="<palette>"\` on any element repaints everything inside it in that palette (\`primary\`, \`secondary\`, \`error\`, \`accent\`, \`chrome\`...): a destructive button is \`<Button data-emphasis="bold" data-color="error">\`.`,
|
|
258
|
+
'',
|
|
259
|
+
);
|
|
260
|
+
const pairs = Object.entries(brand?.color?.pairs ?? {});
|
|
261
|
+
if (pairs.length) {
|
|
262
|
+
out.push(
|
|
263
|
+
'### Brand pairs',
|
|
264
|
+
'',
|
|
265
|
+
`A pair is a background with its own text and accent, checked to meet AA. Put \`class="${theme.prefix}-pair-<name>"\` on a band, card or callout and everything inside (text, links, buttons, focus) takes the pair's colors.`,
|
|
266
|
+
'',
|
|
267
|
+
'| Pair | Use |',
|
|
268
|
+
'|---|---|',
|
|
269
|
+
...pairs.map(([name, p]) => `| \`.${theme.prefix}-pair-${name}\` | ${(p.description ?? '').replace(/^Example pair:\s*/, '')} |`),
|
|
270
|
+
'',
|
|
271
|
+
);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
out.push(
|
|
275
|
+
'## Typography',
|
|
276
|
+
'',
|
|
277
|
+
`Families: display ${tokens.type.families.display ?? ''}, body ${tokens.type.families.body ?? ''}, mono ${tokens.type.families.mono ?? ''}.`,
|
|
278
|
+
'',
|
|
279
|
+
'All text uses one whole text style, never a size or weight on its own: `class="text-style-<name>"`, or the `--' + theme.prefix + '-font-<name>-*` variables (font-family, font-size, font-weight, line-height, letter-spacing) together. Styles come in tiers named for why the text is that size:',
|
|
280
|
+
'',
|
|
281
|
+
);
|
|
282
|
+
const tierWhy = {
|
|
283
|
+
minimize: 'the smallest functional text: captions, fine print, dense metadata.',
|
|
284
|
+
condense: 'compact UI: small controls, table cells, secondary labels.',
|
|
285
|
+
base: 'the default UI and body text.',
|
|
286
|
+
magnify: 'emphasized body text: lead paragraphs, larger controls.',
|
|
287
|
+
maximize: 'display text for headings and hero copy, in steps xs to 4xl.',
|
|
288
|
+
code: 'code, in the mono family.',
|
|
289
|
+
};
|
|
290
|
+
for (const tier of tiers) {
|
|
291
|
+
const variants = styleNames.filter((s) => s.startsWith(`${tier}-`)).map((s) => s.slice(tier.length + 1));
|
|
292
|
+
out.push(`- **${tier}**: ${tierWhy[tier] ?? ''}${variants.length ? ` Variants: ${variants.map((x) => `\`${x}\``).join(', ')}.` : ''}`);
|
|
293
|
+
}
|
|
294
|
+
out.push(
|
|
295
|
+
'',
|
|
296
|
+
'Inside a tier, `-trigger` is for buttons and triggers, `-selected` for the current tab, toggle or nav item, `-link` for links, `-subtitle` for subheads and `-label` for form labels. Sizes below the base are fixed; the base and up grow with the viewport.',
|
|
297
|
+
'',
|
|
298
|
+
'## Space, shape and depth',
|
|
299
|
+
'',
|
|
300
|
+
);
|
|
301
|
+
if (brand?.space?.unit) out.push(`Space is a ${brand.space.unit}px grid: \`--${theme.prefix}-space-<n>\` is n × ${brand.space.unit}px (\`space-4\` = ${brand.space.unit * 4}px). Use the steps; never a number in between.`, '');
|
|
302
|
+
out.push('Corners are chosen by what a thing is, not by size:', '', '| Token | Use |', '|---|---|', ...rows(['radius.control', 'radius.item', 'radius.surface', 'radius.chrome', 'radius.modal', 'radius.pill', 'radius.minimal']), '');
|
|
303
|
+
out.push(
|
|
304
|
+
`Depth: \`--${theme.prefix}-elevation-xs\` to \`-2xl\`, lowest first. Popups, menus and dialogs already carry theirs. Borders: \`--${theme.prefix}-stroke-default\` everywhere, \`-emphasis\` for selected or focused edges.`,
|
|
305
|
+
'',
|
|
306
|
+
'## Focus and states',
|
|
307
|
+
'',
|
|
308
|
+
`Keyboard focus is one ring, \`--${theme.prefix}-focus-outline\` with \`--${theme.prefix}-focus-outline-offset\`, plus a soft halo in \`--${theme.prefix}-focus-halo-color\`. Never remove it; the \`.focus-ring\` utility applies it. Hover moves one step toward more contrast, pressed one more. Disabled controls use \`--${theme.prefix}-text-disabled\` and \`--${theme.prefix}-interactive-disabled\`.`,
|
|
309
|
+
'',
|
|
310
|
+
'## Modes',
|
|
311
|
+
'',
|
|
312
|
+
`Light and dark: \`data-theme="light|dark"\` on the root, or the system setting when neither is set. High contrast follows \`prefers-contrast: more\`. Color vision themes: \`data-vision="deuteranopia|protanopia|tritanopia|achromatopsia"\`, each designed from this brand. Never hand-pick dark colors; the tokens already flip.`,
|
|
313
|
+
'',
|
|
314
|
+
);
|
|
315
|
+
if (componentNames.length) {
|
|
316
|
+
out.push(
|
|
317
|
+
'## Components',
|
|
318
|
+
'',
|
|
319
|
+
`The components are [Base UI](https://base-ui.com) parts styled by class name: ${componentNames.map((n) => `\`${n}\``).join(', ')}. Each component's README lists the classes its parts take and the data attributes they answer to (\`data-size="sm|lg"\`, \`data-emphasis\`, \`data-color\`). In previews and designs they are \`window.Loomiere.<Name>\`; Button, Input, Field, Fieldset, Form and NavigationMenu are wrappers that put their classes on themselves.`,
|
|
320
|
+
'',
|
|
321
|
+
'Icons are Material Symbols Outlined by ligature name (`<span class="material-symbols-outlined">search</span>`, or `Loomiere.MaterialSymbol name="search"`), sized by the `.icon-<tier>` utilities to match the text beside them.',
|
|
322
|
+
'',
|
|
323
|
+
);
|
|
324
|
+
}
|
|
325
|
+
return `${out.join('\n')}\n`;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* The cover above the brand book: the brand's colors as large blocks, dunes of arcs drawn
|
|
330
|
+
* from the space scale, and the name in the display face. Every fill is a token, so it
|
|
331
|
+
* follows the theme and any edit on the page.
|
|
332
|
+
*/
|
|
333
|
+
function cover({ theme, brand, title }) {
|
|
334
|
+
const p = theme.prefix;
|
|
335
|
+
const roles = theme.roles ?? {};
|
|
336
|
+
const accent = roles.accent ?? 'primary';
|
|
337
|
+
const second = ['secondary', 'tertiary', 'quaternary'].find((x) => x !== accent && brand?.color?.palettes?.[x]) ?? accent;
|
|
338
|
+
const third = ['tertiary', 'quaternary', 'secondary'].find((x) => x !== accent && x !== second && brand?.color?.palettes?.[x]) ?? second;
|
|
339
|
+
const size = Math.max(64, Math.min(120, Math.floor(440 / (title.length * 0.6))));
|
|
340
|
+
const tagline = cap((brand?.description ?? '').replace(new RegExp(`^${title}:\\s*`, 'i'), ''));
|
|
341
|
+
return `<!-- @dsCard height=288 -->
|
|
342
|
+
<!doctype html>
|
|
343
|
+
<html>
|
|
344
|
+
<head>
|
|
345
|
+
<meta charset="utf-8">
|
|
346
|
+
<style>
|
|
347
|
+
body { margin: 0; background: var(--${p}-surface-canvas); }
|
|
348
|
+
.cover { position: relative; width: 960px; height: 288px; overflow: hidden; }
|
|
349
|
+
svg { position: absolute; inset: 0; }
|
|
350
|
+
.accent { fill: var(--${p}-color-${accent}-600); }
|
|
351
|
+
.second { fill: var(--${p}-color-${second}-600); }
|
|
352
|
+
.ink { fill: var(--${p}-surface-inverse); }
|
|
353
|
+
.tint { fill: var(--${p}-color-${accent}-100); }
|
|
354
|
+
.dune { fill: none; stroke: var(--${p}-color-${third}-300); stroke-width: var(--${p}-stroke-thick); }
|
|
355
|
+
.text { position: absolute; left: var(--${p}-space-10); bottom: var(--${p}-space-10); max-width: 440px; }
|
|
356
|
+
h1 { margin: 0; font-family: var(--${p}-font-family-display); font-weight: 600; font-size: ${size}px; line-height: 0.92; color: var(--${p}-text-primary); }
|
|
357
|
+
p { margin: var(--${p}-space-3) 0 0; font-family: var(--${p}-font-family-body); font-size: 14px; line-height: 1.4; color: var(--${p}-text-secondary); }
|
|
358
|
+
</style>
|
|
359
|
+
</head>
|
|
360
|
+
<body>
|
|
361
|
+
<div class="cover">
|
|
362
|
+
<svg width="960" height="288" viewBox="0 0 960 288" aria-hidden="true">
|
|
363
|
+
<!-- blocks: accent ${accent}-600 slab 192x288, ${second}-600 288x144, surface-inverse 144x144, ${accent}-100 tint 144x144
|
|
364
|
+
arrangement: one tall slab with a band and two squares beside it, bleeding off the top, right and bottom
|
|
365
|
+
pattern: dunes, concentric half-rings in ${third}-300; the brand is a desert, so its motif is the dune
|
|
366
|
+
scales: 36px (space-9) ring pitch, 144px (space-36) modules, stroke-thick rings, square corners (radius-minimal) -->
|
|
367
|
+
<rect class="accent" x="480" y="0" width="192" height="288"/>
|
|
368
|
+
<rect class="second" x="672" y="0" width="288" height="144"/>
|
|
369
|
+
<rect class="ink" x="672" y="144" width="144" height="144"/>
|
|
370
|
+
<rect class="tint" x="816" y="144" width="144" height="144"/>
|
|
371
|
+
<path class="dune" d="M636 288 a36 36 0 0 1 72 0 M600 288 a72 72 0 0 1 144 0 M564 288 a108 108 0 0 1 216 0"/>
|
|
372
|
+
</svg>
|
|
373
|
+
<div class="text">
|
|
374
|
+
<h1>${title}</h1>
|
|
375
|
+
${tagline ? `<p>${tagline}</p>` : ''}
|
|
376
|
+
</div>
|
|
377
|
+
</div>
|
|
378
|
+
</body>
|
|
379
|
+
</html>
|
|
380
|
+
`;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/** AGENTS.md: the brand book plus how to load the theme in code. */
|
|
384
|
+
function agentsGuide({ theme, readme, componentNames }) {
|
|
385
|
+
return `# ${cap(theme.brand)} theme: guide for agents
|
|
386
|
+
|
|
387
|
+
This folder is a Loomiere theme. Load \`tokens.css\` (every token as a \`--${theme.prefix}-\` variable, light and dark) and \`utilities.css\` (text styles, focus ring, brand pairs). Framework adapters sit in \`exports/\`: \`tailwind.css\` (Tailwind v4: text styles by tier such as \`text-base\`, concept classes such as \`bg-field\` and \`text-link\`, no bare sizes like \`text-sm\`), \`shadcn.css\` (shadcn/ui), \`mui.js\`, \`chakra.js\`, AG Grid themes, \`tokens.dtcg.json\`. \`tokens.json\` lists every token with its path, value, modes and the reason it exists; read a token's \`description\` before using it.
|
|
388
|
+
${componentNames ? `\nComponents: \`@scattered-light/loomiere-base-ui\` (Base UI parts styled by class name; import \`@scattered-light/loomiere-base-ui/styles.css\` after the theme). \`exports/design-system/components/<Name>/README.md\` lists each component's classes and an example.\n` : ''}
|
|
389
|
+
Change the brand only in the brand file and rebuild (\`loomiere build\`); never edit generated files.
|
|
390
|
+
|
|
391
|
+
${readme.replace(/^#+ /gm, (m) => `#${m}`)}`;
|
|
392
|
+
}
|
package/exporters/index.mjs
CHANGED
|
@@ -7,14 +7,16 @@ import fs from 'node:fs';
|
|
|
7
7
|
import path from 'node:path';
|
|
8
8
|
|
|
9
9
|
import chakra from './chakra.mjs';
|
|
10
|
+
import designSystem from './design-system.mjs';
|
|
10
11
|
import dtcg from './dtcg.mjs';
|
|
11
12
|
import figma from './figma.mjs';
|
|
12
13
|
import mui from './mui.mjs';
|
|
14
|
+
import shadcn from './shadcn.mjs';
|
|
13
15
|
import tailwind from './tailwind.mjs';
|
|
14
16
|
import { agGridV32, agGridV33, agGridV36 } from './ag-grid.mjs';
|
|
15
17
|
import { readTheme } from './theme.mjs';
|
|
16
18
|
|
|
17
|
-
export const EXPORTERS = [dtcg, figma, tailwind, mui, chakra, agGridV36, agGridV33, agGridV32];
|
|
19
|
+
export const EXPORTERS = [dtcg, figma, tailwind, shadcn, mui, chakra, agGridV36, agGridV33, agGridV32, designSystem];
|
|
18
20
|
|
|
19
21
|
/**
|
|
20
22
|
* Whether an exporter is wanted: `only` is "all" or a list of ids, where an id may name a
|
|
@@ -22,15 +24,25 @@ export const EXPORTERS = [dtcg, figma, tailwind, mui, chakra, agGridV36, agGridV
|
|
|
22
24
|
*/
|
|
23
25
|
const wanted = (id, only) => only === 'all' || only.some((pick) => id === pick || id.startsWith(`${pick}-`));
|
|
24
26
|
|
|
25
|
-
/**
|
|
26
|
-
|
|
27
|
+
/**
|
|
28
|
+
* Writes the chosen exports into outDir and a catalog.json that lists them. `context` is what
|
|
29
|
+
* an exporter may need beyond the theme: the built theme's folder and the brand file.
|
|
30
|
+
*/
|
|
31
|
+
export function exportAll(catalog, roles, outDir, only = 'all', context = {}) {
|
|
27
32
|
const theme = readTheme(catalog, roles);
|
|
33
|
+
// tokens.css sits beside exports/; adapters that re-use its rules (Tailwind's pair and scope
|
|
34
|
+
// utilities) read it from here.
|
|
35
|
+
const tokensCss = path.join(outDir, '..', 'tokens.css');
|
|
36
|
+
if (fs.existsSync(tokensCss)) theme.tokensCss = fs.readFileSync(tokensCss, 'utf8');
|
|
28
37
|
fs.mkdirSync(outDir, { recursive: true });
|
|
29
38
|
const written = EXPORTERS.filter((exporter) => wanted(exporter.id, only)).map((exporter) => {
|
|
30
39
|
// An exporter returns one file's text, or { fileName: text } when it writes several.
|
|
31
|
-
const out = exporter.build(theme);
|
|
40
|
+
const out = exporter.build(theme, context);
|
|
32
41
|
const files = typeof out === 'string' ? { [exporter.file]: out } : out;
|
|
33
|
-
for (const [file, text] of Object.entries(files))
|
|
42
|
+
for (const [file, text] of Object.entries(files)) {
|
|
43
|
+
fs.mkdirSync(path.dirname(path.join(outDir, file)), { recursive: true });
|
|
44
|
+
fs.writeFileSync(path.join(outDir, file), text);
|
|
45
|
+
}
|
|
34
46
|
return { id: exporter.id, name: exporter.name, file: exporter.file, files: Object.keys(files), for: exporter.for };
|
|
35
47
|
});
|
|
36
48
|
fs.writeFileSync(path.join(outDir, 'catalog.json'), `${JSON.stringify({ brand: theme.brand, exports: written }, null, 2)}\n`);
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* shadcn/ui variables. shadcn components read about twenty CSS variables (--background,
|
|
3
|
+
* --primary, --ring, --radius, the chart and sidebar colors); this points each at a Loomiere
|
|
4
|
+
* concept, so the copied components take on the brand, in every mode, without edits.
|
|
5
|
+
* @import "tailwindcss";
|
|
6
|
+
* @import "./theme/exports/shadcn.css";
|
|
7
|
+
* It replaces everything after the imports in shadcn's globals.css. Only colors,
|
|
8
|
+
* the radius, fonts and text sizes follow the brand; shadcn's own spacing and shapes stay.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/** shadcn variable → Loomiere token path. */
|
|
12
|
+
const VARIABLES = {
|
|
13
|
+
background: 'surface.canvas',
|
|
14
|
+
foreground: 'text.primary',
|
|
15
|
+
card: 'surface.raised',
|
|
16
|
+
'card-foreground': 'text.primary',
|
|
17
|
+
popover: 'popup.background',
|
|
18
|
+
'popover-foreground': 'item.color',
|
|
19
|
+
// shadcn's primary is its main button, which is our bold emphasis: the accent role's solid.
|
|
20
|
+
primary: 'scope.accent.solid',
|
|
21
|
+
'primary-foreground': 'scope.accent.on-solid',
|
|
22
|
+
secondary: 'scope.chrome.soft',
|
|
23
|
+
'secondary-foreground': 'scope.chrome.on-soft',
|
|
24
|
+
muted: 'fill.subtle',
|
|
25
|
+
'muted-foreground': 'text.secondary',
|
|
26
|
+
// shadcn's accent is the hover fill of menu rows and ghost buttons, our item hover.
|
|
27
|
+
accent: 'item.background-hover',
|
|
28
|
+
'accent-foreground': 'item.color-hover',
|
|
29
|
+
destructive: 'scope.error.solid',
|
|
30
|
+
'destructive-foreground': 'scope.error.on-solid',
|
|
31
|
+
border: 'border.default',
|
|
32
|
+
input: 'border.control',
|
|
33
|
+
ring: 'focus.ring',
|
|
34
|
+
'chart-1': 'color.data.1',
|
|
35
|
+
'chart-2': 'color.data.2',
|
|
36
|
+
'chart-3': 'color.data.3',
|
|
37
|
+
'chart-4': 'color.data.4',
|
|
38
|
+
'chart-5': 'color.data.5',
|
|
39
|
+
sidebar: 'surface.tray',
|
|
40
|
+
'sidebar-foreground': 'text.primary',
|
|
41
|
+
'sidebar-primary': 'scope.accent.solid',
|
|
42
|
+
'sidebar-primary-foreground': 'scope.accent.on-solid',
|
|
43
|
+
'sidebar-accent': 'item.background-hover',
|
|
44
|
+
'sidebar-accent-foreground': 'item.color-hover',
|
|
45
|
+
'sidebar-border': 'border.default',
|
|
46
|
+
'sidebar-ring': 'focus.ring',
|
|
47
|
+
radius: 'radius.control',
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
/** shadcn's text sizes → the text tier whose size they take. */
|
|
51
|
+
const SIZES = { xs: 'minimize', sm: 'condense', base: 'base', lg: 'magnify' };
|
|
52
|
+
|
|
53
|
+
export default {
|
|
54
|
+
id: 'shadcn',
|
|
55
|
+
name: 'shadcn/ui',
|
|
56
|
+
file: 'shadcn.css',
|
|
57
|
+
for: 'shadcn/ui on Tailwind v4: import after tailwindcss in place of the color blocks in globals.css. Dark mode follows data-theme (next-themes: attribute="data-theme").',
|
|
58
|
+
build(theme) {
|
|
59
|
+
const name = new Map(theme.tokens.map((t) => [t.path, t.name]));
|
|
60
|
+
const root = Object.entries(VARIABLES)
|
|
61
|
+
.filter(([, path]) => name.has(path))
|
|
62
|
+
.map(([v, path]) => ` --${v}: var(${name.get(path)});`);
|
|
63
|
+
const colors = Object.keys(VARIABLES)
|
|
64
|
+
.filter((v) => v !== 'radius' && name.has(VARIABLES[v]))
|
|
65
|
+
.map((v) => ` --color-${v}: var(--${v});`);
|
|
66
|
+
const sizes = Object.entries(SIZES).flatMap(([size, tier]) =>
|
|
67
|
+
name.has(`font.${tier}.font-size`) ? [` --text-${size}: var(${name.get(`font.${tier}.font-size`)});`, ` --text-${size}--line-height: var(${name.get(`font.${tier}.line-height`)});`] : [],
|
|
68
|
+
);
|
|
69
|
+
const fonts = [['sans', 'body'], ['mono', 'mono']].filter(([, role]) => name.has(`font-family.${role}`)).map(([tw, role]) => ` --font-${tw}: var(${name.get(`font-family.${role}`)});`);
|
|
70
|
+
return `/* shadcn/ui variables for "${theme.brand}". Generated by loomiere build; do not edit.
|
|
71
|
+
@import "tailwindcss";
|
|
72
|
+
@import "<theme>/exports/shadcn.css";
|
|
73
|
+
Replaces everything after the imports in shadcn's globals.css. Every variable
|
|
74
|
+
points at a --${theme.prefix}- concept, so dark mode, contrast modes and color vision themes come
|
|
75
|
+
along. Dark mode is data-theme="dark" on <html> (next-themes: attribute="data-theme"), or the
|
|
76
|
+
system setting. With the Loomiere Tailwind theme as well, import this after it: text-xs to
|
|
77
|
+
text-lg come back for shadcn's components, sized by the text tiers. */
|
|
78
|
+
@import "../tokens.css";
|
|
79
|
+
|
|
80
|
+
:root {
|
|
81
|
+
${root.join('\n')}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
@custom-variant dark {
|
|
85
|
+
&:where([data-theme='dark'], [data-theme='dark'] *) {
|
|
86
|
+
@slot;
|
|
87
|
+
}
|
|
88
|
+
@media (prefers-color-scheme: dark) {
|
|
89
|
+
&:where(:root:not([data-theme='light']), :root:not([data-theme='light']) *) {
|
|
90
|
+
@slot;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
@theme inline {
|
|
96
|
+
${[...colors, ...fonts, ...sizes].join('\n')}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/* shadcn's own base styles, kept so this file can stand in for globals.css. */
|
|
100
|
+
@layer base {
|
|
101
|
+
* {
|
|
102
|
+
@apply border-border outline-ring/50;
|
|
103
|
+
}
|
|
104
|
+
body {
|
|
105
|
+
@apply bg-background text-foreground;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
`;
|
|
109
|
+
},
|
|
110
|
+
};
|
package/exporters/tailwind.mjs
CHANGED
|
@@ -3,14 +3,64 @@
|
|
|
3
3
|
* instead of copying values, so dark mode, contrast modes, color vision themes and
|
|
4
4
|
* data-color/data-emphasis all keep working in utility classes.
|
|
5
5
|
* @import "tailwindcss";
|
|
6
|
-
* @import "./theme/
|
|
7
|
-
* @import "./tailwind.css";
|
|
6
|
+
* @import "./theme/exports/tailwind.css"; (brings tokens.css along)
|
|
8
7
|
*/
|
|
8
|
+
/** Concept groups the Base UI CSS reads; each becomes named colors and spacing in Tailwind. */
|
|
9
|
+
const CONCEPTS = ['field', 'item', 'check', 'popup', 'modal', 'bar', 'table', 'gauge', 'disclosure', 'icon-button', 'notice', 'control', 'icon-size', 'fill', 'interactive', 'status', 'border', 'text', 'surface', 'focus', 'content', 'space', 'gap', 'spacing'];
|
|
10
|
+
const isColor = (t) => t.type === 'color' || /^(#|rgba?\(|hsla?\(|oklch\(|oklab\(|color-mix\(|transparent$)/i.test(String(t.value));
|
|
11
|
+
const isLength = (t) => /^-?[\d.]+(rem|px|em)$/.test(String(t.value)) && !/(border-width|radius|width|width-|indicator-width|outline|ring-width|halo-spread)/.test(t.path);
|
|
12
|
+
/**
|
|
13
|
+
* A concept color's Tailwind name: the background is the concept itself, text is -fg, an edge
|
|
14
|
+
* is -border. field.background → bg-field, field.color → text-field-fg,
|
|
15
|
+
* field.border-color-hover → border-field-border-hover, item.background-selected → bg-item-selected.
|
|
16
|
+
* Prose colors drop the group: content.link-color → text-link. Text colors read as fg:
|
|
17
|
+
* text.on-tint → text-fg-on-tint.
|
|
18
|
+
*/
|
|
19
|
+
function colorName(group, key) {
|
|
20
|
+
const words = key.split('-');
|
|
21
|
+
const out = [];
|
|
22
|
+
for (let i = 0; i < words.length; i++) {
|
|
23
|
+
if (words[i] === 'background') continue;
|
|
24
|
+
if (words[i] === 'border' && words[i + 1] === 'color') {
|
|
25
|
+
out.push('border');
|
|
26
|
+
i++;
|
|
27
|
+
} else if (words[i] === 'color') out.push(group === 'content' || i > 0 ? '' : 'fg');
|
|
28
|
+
else out.push(words[i]);
|
|
29
|
+
}
|
|
30
|
+
const k = out.filter(Boolean).join('-');
|
|
31
|
+
if (group === 'content') return k;
|
|
32
|
+
if (group === 'text') return `fg-${k}`;
|
|
33
|
+
return k ? `${group}-${k}` : group;
|
|
34
|
+
}
|
|
35
|
+
/** field.gap → field, modal.padding → modal, item.padding-inline → item-inline, control.height-md → control-md, icon-size.base → icon-base. */
|
|
36
|
+
function spaceName(group, key) {
|
|
37
|
+
if (group === 'space' || group === 'spacing') return key;
|
|
38
|
+
if (group === 'gap') return `gap-${key}`;
|
|
39
|
+
if (group === 'icon-size') return `icon-${key}`;
|
|
40
|
+
const k = key.replace(/(^|-)(gap|padding|height|size)(?=-|$)/g, '$1').replace(/^-+|-+$/g, '').replace(/-{2,}/g, '-');
|
|
41
|
+
return k ? `${group}-${k}` : group;
|
|
42
|
+
}
|
|
43
|
+
/** [tailwind name, token] for every concept token that passes the test, first name wins. */
|
|
44
|
+
function conceptTokens(theme, test, nameOf) {
|
|
45
|
+
const out = new Map();
|
|
46
|
+
// In CONCEPTS order, so a component's own token wins a name over prose (content.field-border).
|
|
47
|
+
const order = (t) => CONCEPTS.indexOf(t.path.split('.')[0]);
|
|
48
|
+
for (const t of [...theme.tokens].sort((a, b) => order(a) - order(b))) {
|
|
49
|
+
const [group, ...rest] = t.path.split('.');
|
|
50
|
+
if (!CONCEPTS.includes(group) || rest.length !== 1 || !test(t)) continue;
|
|
51
|
+
let twName = nameOf(group, rest[0]);
|
|
52
|
+
// A text color that lands on its background's name reads as -fg: table.header-color → table-header-fg.
|
|
53
|
+
if (out.has(twName) && /(^|-)color(-|$)/.test(rest[0])) twName += '-fg';
|
|
54
|
+
if (!out.has(twName)) out.set(twName, t);
|
|
55
|
+
}
|
|
56
|
+
return out;
|
|
57
|
+
}
|
|
58
|
+
|
|
9
59
|
export default {
|
|
10
60
|
id: 'tailwind',
|
|
11
61
|
name: 'Tailwind CSS v4',
|
|
12
62
|
file: 'tailwind.css',
|
|
13
|
-
for: 'Tailwind v4 projects: import after tailwindcss
|
|
63
|
+
for: 'Tailwind v4 projects: import after tailwindcss (it brings tokens.css along). Utilities like bg-accent-solid, text-fg, bg-emphasis-bg, and text styles by tier: text-base, text-magnify-trigger.',
|
|
14
64
|
build(theme) {
|
|
15
65
|
const name = new Map(theme.tokens.map((t) => [t.path, t.name]));
|
|
16
66
|
const line = (twVar, path) => (name.has(path) ? ` ${twVar}: var(${name.get(path)});` : null);
|
|
@@ -18,18 +68,72 @@ export default {
|
|
|
18
68
|
for (const [palette, steps] of Object.entries(theme.palettes)) for (const step of Object.keys(steps)) lines.push(line(`--color-${palette}-${step}`, `color.${palette}.${step}`));
|
|
19
69
|
for (const [scope, { light }] of Object.entries(theme.scopes)) for (const slot of Object.keys(light)) lines.push(line(`--color-${scope}-${slot}`, `scope.${scope}.${slot}`));
|
|
20
70
|
// The scope in effect (accent unless data-color says otherwise) and the emphasis paints.
|
|
71
|
+
// Emphasis is spelled out: "e" would read as Tailwind's inline-end side (border-e-*).
|
|
21
72
|
for (const slot of Object.keys(theme.scopes.accent.light)) lines.push(` --color-scope-${slot}: var(--${theme.prefix}-scope-${slot});`);
|
|
22
|
-
for (const part of ['bg', 'bg-hover', 'bg-active', 'fg', 'border', 'border-hover']) lines.push(` --color-
|
|
73
|
+
for (const part of ['bg', 'bg-hover', 'bg-active', 'fg', 'border', 'border-hover']) lines.push(` --color-emphasis-${part}: var(--${theme.prefix}-e-${part});`);
|
|
23
74
|
const semantic = { bg: 'surface.canvas', raised: 'surface.raised', overlay: 'surface.overlay', inverse: 'surface.inverse', fg: 'text.primary', 'fg-muted': 'text.secondary', 'fg-subtle': 'text.tertiary', 'fg-disabled': 'text.disabled', border: 'border.default', 'border-subtle': 'border.subtle', 'border-strong': 'border.strong', focus: 'focus.ring', white: 'color.white', black: 'color.black' };
|
|
24
75
|
for (const [k, path] of Object.entries(semantic)) lines.push(line(`--color-${k}`, path));
|
|
76
|
+
// The semantic concepts the Base UI CSS reads, so a component built from utilities paints
|
|
77
|
+
// the same way: bg-field, border-field-border-hover, text-link, bg-item-hover, bg-status-error.
|
|
78
|
+
for (const [twName, t] of conceptTokens(theme, isColor, colorName)) if (!lines.some((l) => l?.startsWith(` --color-${twName}:`))) lines.push(` --color-${twName}: var(${t.name});`);
|
|
25
79
|
lines.push(' --color-transparent: transparent;', ' --color-current: currentColor;');
|
|
80
|
+
// Tailwind's own scales are cleared wherever the brand has one, so every class is the brand's.
|
|
81
|
+
lines.push(' --font-*: initial;');
|
|
26
82
|
for (const role of ['display', 'body', 'mono']) lines.push(line(`--font-${role}`, `font-family.${role}`));
|
|
27
83
|
lines.push(line('--font-sans', 'font-family.body'));
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
84
|
+
const scale = (twNs, group) => {
|
|
85
|
+
lines.push(` --${twNs}-*: initial;`);
|
|
86
|
+
for (const t of theme.tokens) if (t.path.startsWith(`${group}.`) && !t.path.slice(group.length + 1).includes('.')) lines.push(` --${twNs}-${t.path.slice(group.length + 1)}: var(${t.name});`);
|
|
87
|
+
};
|
|
88
|
+
// No font sizes on their own: text always takes a whole text style (below).
|
|
89
|
+
lines.push(' --text-*: initial;');
|
|
90
|
+
scale('font-weight', 'font-weight');
|
|
91
|
+
scale('tracking', 'letter-spacing');
|
|
92
|
+
scale('radius', 'radius');
|
|
93
|
+
scale('shadow', 'elevation');
|
|
94
|
+
scale('ease', 'easing');
|
|
95
|
+
scale('container', 'container');
|
|
31
96
|
const unit = name.get('space.1');
|
|
32
97
|
if (unit) lines.push(` --spacing: var(${unit});`);
|
|
33
|
-
|
|
98
|
+
// Named spacing from the concepts, beside the unit scale: gap-field, p-modal, px-item-inline,
|
|
99
|
+
// h-control-md, size-icon-base.
|
|
100
|
+
for (const [twName, t] of conceptTokens(theme, isLength, spaceName)) lines.push(` --spacing-${twName}: var(${t.name});`);
|
|
101
|
+
// Concept shadows: shadow-popup, shadow-modal.
|
|
102
|
+
for (const g of ['popup', 'modal']) lines.push(line(`--shadow-${g}`, `${g}.shadow`));
|
|
103
|
+
// Media queries can't read custom properties, so breakpoints are written as values.
|
|
104
|
+
const breakpoints = theme.tokens.filter((t) => /^breakpoint\.[\w-]+$/.test(t.path)).map((t) => ` --breakpoint-${t.path.slice(11)}: ${t.value};`);
|
|
105
|
+
// Each text style is one utility named by its tier (text-base, text-magnify-trigger), so a
|
|
106
|
+
// class sets family, size, weight, line height and letter spacing together.
|
|
107
|
+
const styles = new Map();
|
|
108
|
+
for (const t of theme.tokens) {
|
|
109
|
+
const m = t.path.match(/^font\.([\w-]+)\.(font-family|font-size|font-weight|line-height|letter-spacing|text-transform)$/);
|
|
110
|
+
if (m) (styles.get(m[1]) ?? styles.set(m[1], []).get(m[1])).push(` ${m[2]}: var(${t.name});`);
|
|
111
|
+
}
|
|
112
|
+
const utilities = [...styles].map(([style, decls]) => `@utility text-${style} {\n${decls.join('\n')}\n}`);
|
|
113
|
+
// z-index layers by name (z-modal, z-toast); Tailwind's z-10 and friends still work.
|
|
114
|
+
const z = theme.tokens.filter((t) => /^z-index\.[\w-]+$/.test(t.path)).map((t) => `@utility z-${t.path.slice(8)} {\n z-index: var(${t.name});\n}`);
|
|
115
|
+
const durations = theme.tokens.filter((t) => /^duration\.[\w-]+$/.test(t.path)).map((t) => `@utility duration-${t.path.slice(9)} {\n --tw-duration: var(${t.name});\n transition-duration: var(${t.name});\n}`);
|
|
116
|
+
// dark: follows Loomiere's mode: data-theme="dark" on <html>, or the system setting
|
|
117
|
+
// unless data-theme="light" says otherwise.
|
|
118
|
+
const dark = `@custom-variant dark {\n &:where([data-theme='dark'], [data-theme='dark'] *) {\n @slot;\n }\n @media (prefers-color-scheme: dark) {\n &:where(:root:not([data-theme='light']), :root:not([data-theme='light']) *) {\n @slot;\n }\n }\n}`;
|
|
119
|
+
// Brand pairs and color scopes as utilities (pair-stone-soft, scope-error), the same
|
|
120
|
+
// declarations as .${theme.prefix}-pair-<name> and [data-color='<name>'] in tokens.css, so
|
|
121
|
+
// variants work on them: md:pair-stone-soft, hover:scope-error.
|
|
122
|
+
const block = (selector) => theme.tokensCss?.match(new RegExp(`^\\s*${selector.replace(/[.[\]'()]/g, '\\$&')}(?:,\\n[^{]*)? \\{\\n([^}]*)\\}`, 'm'))?.[1];
|
|
123
|
+
const ruleUtility = (utility, selector) => {
|
|
124
|
+
const body = block(selector);
|
|
125
|
+
return body ? `@utility ${utility} {\n${body.split('\n').map((l) => l.trim()).filter(Boolean).map((l) => ` ${l}`).join('\n')}\n}` : null;
|
|
126
|
+
};
|
|
127
|
+
const pairNames = [...new Set(theme.tokens.filter((t) => t.path.startsWith('pair.')).map((t) => t.path.split('.')[1]))];
|
|
128
|
+
const pairs = pairNames.map((pair) => ruleUtility(`pair-${pair}`, `.${theme.prefix}-pair-${pair}`));
|
|
129
|
+
const scopeUtilities = Object.keys(theme.scopes).map((scope) => ruleUtility(`scope-${scope}`, `[data-color='${scope}']`));
|
|
130
|
+
return `/* Tailwind CSS v4 theme for "${theme.brand}". Generated by loomiere build; do not edit.
|
|
131
|
+
@import "tailwindcss";
|
|
132
|
+
@import "<theme>/exports/tailwind.css"; (or <package>/tailwind.css; it brings tokens.css along)
|
|
133
|
+
With loomiere-base-ui, add @import "@scattered-light/loomiere-base-ui/tailwind.css" so utilities win over component styles.
|
|
134
|
+
Every value points at a --${theme.prefix}- variable, so modes and data-color keep working. */
|
|
135
|
+
@import "../tokens.css";
|
|
136
|
+
|
|
137
|
+
@theme inline {\n${lines.filter(Boolean).join('\n')}\n}\n\n@theme {\n --breakpoint-*: initial;\n${breakpoints.join('\n')}\n}\n\n${[dark, ...utilities, ...z, ...durations, ...pairs, ...scopeUtilities].filter(Boolean).join('\n\n')}\n`;
|
|
34
138
|
},
|
|
35
139
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@scattered-light/loomiere",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
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
5
|
"keywords": ["design-tokens", "theme", "style-dictionary", "dtcg", "oklch", "base-ui", "ag-grid", "tailwind"],
|
|
6
6
|
"repository": {
|