@scattered-light/loomiere 0.1.1 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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+. See [exporters/README.md](exporters/README.md). |
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).
@@ -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
  }
@@ -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, shadcn/ui (Tailwind plus its own variable
45
- names), Vuetify and Mantine.
72
+ Good next candidates: Panda CSS, Radix Themes, Vuetify and Mantine.
@@ -0,0 +1,451 @@
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
+ const has = (n) => componentNames.includes(n);
317
+ const pick = (list) => list.filter(([n]) => has(n)).map(([n, use]) => `| \`${n}\` | ${use} |`);
318
+ out.push(
319
+ '## Components',
320
+ '',
321
+ `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\`).`,
322
+ '',
323
+ '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.',
324
+ '',
325
+ '### Using the components in a page or design',
326
+ '',
327
+ 'Load three things, in this order:',
328
+ '',
329
+ '1. `components/bundle.css`: the whole theme in one sheet. It holds every token (light, dark, high contrast and color vision modes), the text style, icon and focus utilities, the brand pairs, and every component\'s styles, and it imports the brand fonts and Material Symbols from Google Fonts itself. Nothing else is needed for styling; `tokens.json` is the same tokens as data for the Design System page.',
330
+ '2. React 18 and ReactDOM 18 as the `window.React` and `window.ReactDOM` globals. A design canvas or the preview frame already has them.',
331
+ '3. `components/bundle.js`: one classic script that sets `window.Loomiere`.',
332
+ '',
333
+ '`window.Loomiere` holds two kinds of component:',
334
+ '',
335
+ `- **Wrappers** put their own classes on, so use them bare: ${['Button', 'IconButton', 'Input', 'Field', 'Fieldset', 'Form', 'NavigationMenu'].filter(has).map((n) => `\`Loomiere.${n}\``).join(', ')}. \`Loomiere.Button\` also takes \`emphasis\` (\`bold|standard|subtle|quiet\`) and \`palette\` props, and a Button holding only an icon draws itself square (it sets \`data-icon-only\`). A plain \`.Button\`, such as a Popover or Toolbar trigger, is drawn square when it has an \`aria-label\` and a single icon inside, or when you set \`data-icon-only\` on it.`,
336
+ '- **Everything else is the bare Base UI part** under its usual name (`Loomiere.Popover.Popup`, `Loomiere.ToggleGroup`). It draws nothing until you give each part its class, which is the component and part names joined: `<Loomiere.Popover.Popup className="PopoverPopup">`, `<Loomiere.Toggle className="Toggle">`. The component READMEs list every class.',
337
+ '',
338
+ '`Loomiere.Base` holds the bare Base UI parts for the wrappers too (`Loomiere.Base.Button`), for when you need the part without its classes.',
339
+ '',
340
+ '### Choosing a component',
341
+ '',
342
+ 'Pick by what the person is doing, not by look:',
343
+ '',
344
+ '| Component | Use it to |',
345
+ '|---|---|',
346
+ ...pick([
347
+ ['Button', 'Run one action. Set its emphasis by how much the action matters.'],
348
+ ['IconButton', 'Run a small, frequent action shown as an icon alone, where a Button would be too loud: calendar arrows, row actions, a clear button. Always give it an aria-label.'],
349
+ ['Switch', 'Turn one setting on or off, taking effect at once.'],
350
+ ['Checkbox', 'Turn one option on or off inside a form that is submitted later.'],
351
+ ['Toggle', 'A tool that stays pressed, such as Bold in a toolbar.'],
352
+ ['ToggleGroup', 'Switch between 2 to 5 short modes or views that apply at once (Single date / Date range, List / Grid).'],
353
+ ['Tabs', 'Switch between panels of content in the same place.'],
354
+ ['Radio', 'Choose one of 2 to 6 options in a form, where each option can carry a label and description.'],
355
+ ['CheckboxGroup', 'Choose any number of a few visible options.'],
356
+ ['Select', 'Choose one of a fixed list too long to show (about 6 to 15 items).'],
357
+ ['Combobox', 'Choose one or more from a long list by typing to filter it.'],
358
+ ['Autocomplete', 'Type free text, with suggestions to speed it up.'],
359
+ ['NumberField', 'Enter an exact number, with steppers.'],
360
+ ['Slider', 'Set a value in a range where roughly is good enough.'],
361
+ ['OTPField', 'Enter a one-time code.'],
362
+ ['Menu', 'Offer commands from a button. Menus run actions; they don\'t hold a value.'],
363
+ ['ContextMenu', 'Offer commands on right-click or long press.'],
364
+ ['Menubar', 'An app\'s top row of menus.'],
365
+ ['Toolbar', 'A row of buttons, toggles and fields that act on one thing.'],
366
+ ['NavigationMenu', 'Site navigation, with panels of links.'],
367
+ ['Tooltip', 'Name an icon-only control. Never put anything you can click inside one.'],
368
+ ['PreviewCard', 'Preview what a link leads to on hover.'],
369
+ ['Popover', 'Show interactive content beside its trigger: a picker, a short form, filters.'],
370
+ ['Dialog', 'A task that needs full attention before the page goes on.'],
371
+ ['AlertDialog', 'Confirm a destructive or hard-to-undo action.'],
372
+ ['Drawer', 'A side task or detail panel that keeps the page in view.'],
373
+ ['Accordion', 'Several sections where only some need to be open.'],
374
+ ['Collapsible', 'One section to show or hide.'],
375
+ ['Toast', 'Report the result of an action, briefly.'],
376
+ ['Progress', 'Show how far a task has got.'],
377
+ ['Meter', 'Show a measured amount within known limits, such as storage used.'],
378
+ ]),
379
+ '',
380
+ `A list of choices that stays on screen inside a panel, one of them selected (quick picks, saved filters, presets), has no Base UI part. Make each row a \`<button aria-pressed>\` drawn from the item concept: \`--${theme.prefix}-item-padding-block\`, \`-padding-inline\`, \`-radius\` and \`-gap\`, \`--${theme.prefix}-item-background-hover\` and \`-color-hover\` on hover, \`--${theme.prefix}-item-background-selected\` and \`-color-selected\` with the \`-selected\` text style when chosen. These are the same rows menus and selects use. When the choice is a form value, use Radio instead.`,
381
+ '',
382
+ );
383
+ }
384
+ return `${out.join('\n')}\n`;
385
+ }
386
+
387
+ /**
388
+ * The cover above the brand book: the brand's colors as large blocks, dunes of arcs drawn
389
+ * from the space scale, and the name in the display face. Every fill is a token, so it
390
+ * follows the theme and any edit on the page.
391
+ */
392
+ function cover({ theme, brand, title }) {
393
+ const p = theme.prefix;
394
+ const roles = theme.roles ?? {};
395
+ const accent = roles.accent ?? 'primary';
396
+ const second = ['secondary', 'tertiary', 'quaternary'].find((x) => x !== accent && brand?.color?.palettes?.[x]) ?? accent;
397
+ const third = ['tertiary', 'quaternary', 'secondary'].find((x) => x !== accent && x !== second && brand?.color?.palettes?.[x]) ?? second;
398
+ const size = Math.max(64, Math.min(120, Math.floor(440 / (title.length * 0.6))));
399
+ const tagline = cap((brand?.description ?? '').replace(new RegExp(`^${title}:\\s*`, 'i'), ''));
400
+ return `<!-- @dsCard height=288 -->
401
+ <!doctype html>
402
+ <html>
403
+ <head>
404
+ <meta charset="utf-8">
405
+ <style>
406
+ body { margin: 0; background: var(--${p}-surface-canvas); }
407
+ .cover { position: relative; width: 960px; height: 288px; overflow: hidden; }
408
+ svg { position: absolute; inset: 0; }
409
+ .accent { fill: var(--${p}-color-${accent}-600); }
410
+ .second { fill: var(--${p}-color-${second}-600); }
411
+ .ink { fill: var(--${p}-surface-inverse); }
412
+ .tint { fill: var(--${p}-color-${accent}-100); }
413
+ .dune { fill: none; stroke: var(--${p}-color-${third}-300); stroke-width: var(--${p}-stroke-thick); }
414
+ .text { position: absolute; left: var(--${p}-space-10); bottom: var(--${p}-space-10); max-width: 440px; }
415
+ 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); }
416
+ 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); }
417
+ </style>
418
+ </head>
419
+ <body>
420
+ <div class="cover">
421
+ <svg width="960" height="288" viewBox="0 0 960 288" aria-hidden="true">
422
+ <!-- blocks: accent ${accent}-600 slab 192x288, ${second}-600 288x144, surface-inverse 144x144, ${accent}-100 tint 144x144
423
+ arrangement: one tall slab with a band and two squares beside it, bleeding off the top, right and bottom
424
+ pattern: dunes, concentric half-rings in ${third}-300; the brand is a desert, so its motif is the dune
425
+ scales: 36px (space-9) ring pitch, 144px (space-36) modules, stroke-thick rings, square corners (radius-minimal) -->
426
+ <rect class="accent" x="480" y="0" width="192" height="288"/>
427
+ <rect class="second" x="672" y="0" width="288" height="144"/>
428
+ <rect class="ink" x="672" y="144" width="144" height="144"/>
429
+ <rect class="tint" x="816" y="144" width="144" height="144"/>
430
+ <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"/>
431
+ </svg>
432
+ <div class="text">
433
+ <h1>${title}</h1>
434
+ ${tagline ? `<p>${tagline}</p>` : ''}
435
+ </div>
436
+ </div>
437
+ </body>
438
+ </html>
439
+ `;
440
+ }
441
+
442
+ /** AGENTS.md: the brand book plus how to load the theme in code. */
443
+ function agentsGuide({ theme, readme, componentNames }) {
444
+ return `# ${cap(theme.brand)} theme: guide for agents
445
+
446
+ 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.
447
+ ${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` : ''}
448
+ Change the brand only in the brand file and rebuild (\`loomiere build\`); never edit generated files.
449
+
450
+ ${readme.replace(/^#+ /gm, (m) => `#${m}`)}`;
451
+ }
@@ -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
- /** Writes the chosen exports into outDir and a catalog.json that lists them. */
26
- export function exportAll(catalog, roles, outDir, only = 'all') {
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)) fs.writeFileSync(path.join(outDir, file), text);
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
+ };
@@ -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/tokens.css";
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 and tokens.css. Utilities like bg-accent-solid, text-fg, border-chrome-border.',
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-e-${part}: var(--${theme.prefix}-e-${part});`);
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
- for (const step of Object.keys(theme.fontSizes)) lines.push(line(`--text-${step}`, `font-size.${step}`), line(`--text-${step}--line-height`, `line-height.${step}`));
29
- for (const r of Object.keys(theme.radii)) lines.push(line(`--radius-${r}`, `radius.${r}`));
30
- for (const s of Object.keys(theme.shadows)) lines.push(line(`--shadow-${s}`, `elevation.${s}`));
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
- return `/* Tailwind CSS v4 theme for "${theme.brand}". Generated by loomiere build; do not edit.\n @import "tailwindcss";\n @import "./theme/tokens.css";\n @import "./tailwind.css";\n Every value points at a --${theme.prefix}- variable, so modes and data-color keep working. */\n@theme inline {\n${lines.filter(Boolean).join('\n')}\n}\n`;
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.1.1",
3
+ "version": "0.3.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": {
@@ -1,9 +1,9 @@
1
1
  {
2
2
  "popover": {
3
3
  "$description": "Popover's own sizes. Everything else is the popup and close concepts, text styles and CSS.",
4
- "popup-max-width": {
5
- "$value": "400px",
6
- "$description": "Keeps longer text to a readable line length."
4
+ "text-max-width": {
5
+ "$value": "{measure.narrow}",
6
+ "$description": "Keeps the title and description to a readable line length. The popup itself has no fixed cap, so wider content such as a calendar or a form fits; it stops at the space Base UI reports beside the trigger."
7
7
  },
8
8
  "popup-min-width": {
9
9
  "$value": "{popup.min-width}",