emily-css 1.2.8 → 1.2.10

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/src/index.js CHANGED
@@ -1,1886 +1,1892 @@
1
- 'use strict';
2
-
3
- const fs = require('fs');
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
4
  const path = require('path');
5
5
  const { generateManifest } = require('./manifest');
6
6
  const { generateIntellisense } = require('./intellisense');
7
- const {
8
- getConfigPath,
9
- getConfig,
10
- getFullCssPath,
11
- getProductionCssPath,
12
- getManifestSettings,
13
- getManifestOutputPath,
14
- getIntellisenseSettings,
15
- getIntellisenseOutputPath,
16
- ensureDirectoryForFile,
17
- getSourceDir,
18
- } = require('./config');
19
-
20
-
21
- // ============================================================================
22
- // COLOUR GENERATION
23
- // ============================================================================
24
- // Generate 10-shade colour scale using OKLCH (perceptually uniform colour space)
25
- // OKLCH produces visually even steps across all hues unlike HSL which creates
26
- // muddy mid-tones on warm colours (yellows, greens, oranges).
27
- //
28
- // Conversion pipeline: Hex → sRGB → Linear RGB → OKLab → OKLCH → (modify L) → reverse
29
- // No external dependencies. Maths from Björn Ottosson's OKLab specification.
30
- //
31
- // Input: #0077b6 → Output: { 10: '#...', 20: '#...', ..., 100: '#...' }
32
-
33
- // sRGB component to linear light
34
- function srgbToLinear(c) {
35
- const val = c / 255;
36
- return val <= 0.04045 ? val / 12.92 : Math.pow((val + 0.055) / 1.055, 2.4);
37
- }
38
-
39
- // Linear light component to sRGB (clamped to 0–255)
40
- function linearToSrgb(c) {
41
- const clamped = Math.max(0, Math.min(1, c));
42
- const out = clamped <= 0.0031308
43
- ? 12.92 * clamped
44
- : 1.055 * Math.pow(clamped, 1 / 2.4) - 0.055;
45
- return Math.round(Math.max(0, Math.min(1, out)) * 255);
46
- }
47
-
48
- // Hex string → OKLCH { l, c, h }
49
- function hexToOklch(hex) {
50
- const r = srgbToLinear(parseInt(hex.slice(1, 3), 16));
51
- const g = srgbToLinear(parseInt(hex.slice(3, 5), 16));
52
- const b = srgbToLinear(parseInt(hex.slice(5, 7), 16));
53
-
54
- // Linear RGB → OKLab (M1 matrix then cube-root then M2 matrix)
55
- const l = Math.cbrt(0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b);
56
- const m = Math.cbrt(0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b);
57
- const s = Math.cbrt(0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b);
58
-
59
- const L = 0.2104542553 * l + 0.7936177850 * m - 0.0040720468 * s;
60
- const a = 1.9779984951 * l - 2.4285922050 * m + 0.4505937099 * s;
61
- const bv = 0.0259040371 * l + 0.7827717662 * m - 0.8086757660 * s;
62
-
63
- // OKLab → OKLCH
64
- const C = Math.sqrt(a * a + bv * bv);
65
- const H = (Math.atan2(bv, a) * 180) / Math.PI;
66
-
67
- return { l: L, c: C, h: H < 0 ? H + 360 : H };
68
- }
69
-
70
- // OKLCH { l, c, h } → hex string
71
- function oklchToHex(l, c, h) {
72
- // OKLCH OKLab
73
- const hRad = (h * Math.PI) / 180;
74
- const a = c * Math.cos(hRad);
75
- const bv = c * Math.sin(hRad);
76
-
77
- // OKLab → Linear RGB (M2 inverse then cube then M1 inverse)
78
- const l_ = l + 0.3963377774 * a + 0.2158037573 * bv;
79
- const m_ = l - 0.1055613458 * a - 0.0638541728 * bv;
80
- const s_ = l - 0.0894841775 * a - 1.2914855480 * bv;
81
-
82
- const lc = l_ * l_ * l_;
83
- const mc = m_ * m_ * m_;
84
- const sc = s_ * s_ * s_;
85
-
86
- const r = 4.0767416621 * lc - 3.3077115913 * mc + 0.2309699292 * sc;
87
- const g = -1.2684380046 * lc + 2.6097574011 * mc - 0.3413193965 * sc;
88
- const b = -0.0041960863 * lc - 0.7034186147 * mc + 1.7076147010 * sc;
89
-
90
- const rOut = linearToSrgb(r).toString(16).padStart(2, '0');
91
- const gOut = linearToSrgb(g).toString(16).padStart(2, '0');
92
- const bOut = linearToSrgb(b).toString(16).padStart(2, '0');
93
-
94
- return `#${rOut}${gOut}${bOut}`.toUpperCase();
95
- }
96
-
97
- function generateColourScale(baseHex) {
98
- const { l: baseL, c: baseC, h: baseH } = hexToOklch(baseHex);
99
- const scale = {};
100
-
101
- // Shade scale: 10 = near-white, 80 = exact input colour, 100 = near-black
102
- // Lightness targets in OKLCH (0–1 scale):
103
- // shade 10 L 0.97 (very light tint)
104
- // shade 80 → L = baseL (exact input)
105
- // shade 100 → L 0.15 (very dark tone)
106
- //
107
- // Chroma is preserved from the base colour throughout — hue is never shifted.
108
- // At extreme lightness values chroma is gently reduced to stay in sRGB gamut.
109
-
110
- const LIGHT_L = 0.97; // shade 10 lightness
111
- const DARK_L = 0.15; // shade 100 lightness
112
-
113
- const steps = [10, 20, 30, 40, 50, 60, 70, 80, 90, 100];
114
-
115
- steps.forEach(step => {
116
- if (step === 80) {
117
- scale[step] = baseHex.toUpperCase();
118
- return;
119
- }
120
-
121
- let newL;
122
- if (step < 80) {
123
- // 10–70: interpolate from LIGHT_L down to baseL
124
- const t = (step / 80); // 0 1 as step goes 0 → 80
125
- newL = LIGHT_L - t * (LIGHT_L - baseL);
126
- } else {
127
- // 90–100: interpolate from baseL down to DARK_L
128
- const t = (step - 80) / 20; // 0 1 as step goes 80 → 100
129
- newL = baseL - t * (baseL - DARK_L);
130
- }
131
-
132
- // Reduce chroma slightly at extremes to avoid out-of-gamut clipping
133
- const chromaScale = 1 - Math.max(0, (newL - 0.90) / 0.07) * 0.5 // reduce near white
134
- - Math.max(0, (0.25 - newL) / 0.10) * 0.5; // reduce near black
135
- const newC = baseC * Math.max(0, chromaScale);
136
-
137
- scale[step] = oklchToHex(newL, newC, baseH);
138
- });
139
-
140
- return scale;
141
- }
142
-
143
- function generateAllColours(colourConfig) {
144
- const allColours = {};
145
-
146
- Object.entries(colourConfig).forEach(([name, baseHex]) => {
147
- allColours[name] = generateColourScale(baseHex);
148
- });
149
-
150
- return allColours;
151
- }
152
-
153
- // ============================================================================
154
- // SPACING SCALE
155
- // ============================================================================
156
-
157
- function generateSpacing(baseUnit, scale) {
158
- // Spacing values are defined explicitly in emily.config.json under spacing.scale.
159
- // The baseUnit key in config is informational only — it documents the design intent
160
- // (e.g. "this system is based on 8px") but does not drive generation.
161
- return scale;
162
- }
163
-
164
- // ============================================================================
165
- // FONT PRESETS
166
- // ============================================================================
167
-
168
- // Font presets define the CSS font-family stack only.
169
- // Loading the actual font files is the user's responsibility link them in your HTML
170
- // or use @fontsource packages in your build. emily-css does not generate @import rules
171
- // for external CDNs so it stays self-contained and works offline.
172
- // See docs: https://emilyui.com/docs/getting-started
173
- const FONT_PRESETS = {
174
- 'system': {
175
- stack: 'system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
176
- },
177
- 'inter': {
178
- name: 'Inter',
179
- stack: '"Inter", system-ui, sans-serif',
180
- },
181
- 'lexend': {
182
- name: 'Lexend',
183
- stack: '"Lexend", system-ui, sans-serif',
184
- },
185
- 'georgia': {
186
- stack: 'Georgia, "Times New Roman", serif',
187
- },
188
- 'dm-sans': {
189
- name: 'DM Sans',
190
- stack: '"DM Sans", system-ui, sans-serif',
191
- },
192
- 'nunito': {
193
- name: 'Nunito',
194
- stack: '"Nunito", system-ui, sans-serif',
195
- },
196
- 'atkinson': {
197
- name: 'Atkinson Hyperlegible',
198
- stack: '"Atkinson Hyperlegible", system-ui, sans-serif',
199
- },
200
- 'mono': {
201
- stack: '"Menlo", "Monaco", "Courier New", monospace',
202
- },
203
- };
204
-
205
- function generateFontCSS(config) {
206
- // Support both legacy string format and new { heading, body } object format
207
- const fontConfig = config.fontFamily || 'system';
208
- let headingKey, bodyKey;
209
-
210
- if (typeof fontConfig === 'object') {
211
- headingKey = (fontConfig.heading || 'system').toLowerCase();
212
- bodyKey = (fontConfig.body || 'system').toLowerCase();
213
- } else {
214
- headingKey = fontConfig.toLowerCase();
215
- bodyKey = fontConfig.toLowerCase();
216
- }
217
-
218
- const headingPreset = FONT_PRESETS[headingKey] || FONT_PRESETS['system'];
219
- const bodyPreset = FONT_PRESETS[bodyKey] || FONT_PRESETS['system'];
220
-
221
- let fontFace = '';
222
- let bodyFont = '';
223
-
224
- bodyFont += ` body {\n font-family: ${bodyPreset.stack};\n font-synthesis: style;\n }\n`;
225
- bodyFont += ` h1, h2, h3, h4, h5, h6 {\n font-family: ${headingPreset.stack};\n }\n`;
226
-
227
- return { fontFace, bodyFont };
228
- }
229
-
230
- // ============================================================================
231
- // CSS VARIABLE GENERATION
232
- // ============================================================================
233
-
234
- function generateCSSVariables(colours, spacing, config) {
235
- let css = `:root {\n`;
236
-
237
- // Colour variables (full shade scale)
238
- Object.entries(colours).forEach(([colourName, shades]) => {
239
- Object.entries(shades).forEach(([shade, hex]) => {
240
- css += ` --color-${colourName}-${shade}: ${hex};\n`;
241
- });
242
- });
243
-
244
- // Semantic colour variables (single value, no shade scale)
245
- if (config.semanticColours) {
246
- Object.entries(config.semanticColours).forEach(([name, hex]) => {
247
- css += ` --color-${name}: ${hex};\n`;
248
- });
249
- }
250
- css += ` --focus-ring-glow: color-mix(in srgb, var(--color-brand-80) 12%, transparent);\n`;
251
-
252
- // Spacing variables
253
- Object.entries(spacing).forEach(([key, value]) => {
254
- css += ` --space-${key}: ${value};\n`;
255
- });
256
-
257
- // Font size variables with line-height
258
- config.typography.fontSizes.forEach(fontSize => {
259
- const sizeVal = parseInt(fontSize.value);
260
- css += ` --text-${fontSize.name}: ${fontSize.value};\n`;
261
- css += ` --leading-${fontSize.name}: ${fontSize.lineHeight};\n`;
262
- });
263
-
264
- // Font weight variables
265
- Object.entries(config.typography.fontWeights).forEach(([name, weight]) => {
266
- css += ` --font-weight-${name}: ${weight};\n`;
267
- });
268
-
269
- // Breakpoints
270
- Object.entries(config.breakpoints).forEach(([name, value]) => {
271
- css += ` --breakpoint-${name}: ${value};\n`;
272
- });
273
-
274
- // Shadows
275
- Object.entries(config.shadows).forEach(([name, shadow]) => {
276
- css += ` --shadow-${name}: ${shadow};\n`;
277
- });
278
-
279
- // Z-index
280
- Object.entries(config.zIndex).forEach(([name, value]) => {
281
- css += ` --z-${name}: ${value};\n`;
282
- });
283
-
284
- // Transitions
285
- css += ` --transition-duration: ${config.transitions.base};\n`;
286
- css += ` --transition-timing: ${config.transitions.timing};\n`;
287
-
288
- css += `}\n\n`;
289
- return css;
290
- }
291
-
292
- const {
293
- displayUtilities,
294
- sizingUtilities,
295
- positioningUtilities,
296
- overflowUtilities,
297
- opacityUtilities,
298
- transitionUtilities,
299
- transformUtilities,
300
- shadowUtilities,
301
- ringUtilities,
302
- objectUtilities,
303
- tableListUtilities,
304
- svgUtilities,
305
- formUtilities,
306
- verticalAlignUtilities,
307
- contentScrollUtilities,
308
- blendUtilities,
309
- cursorUtilities,
310
- accessibilityUtilities,
311
- containerUtilities,
312
- codeUtilities,
313
- animationUtilities,
314
- backdropUtilities,
315
- spaceUtilities,
316
- divideUtilities,
317
- backgroundUtilities,
318
- filterUtilities,
319
- } = require('./generators');
320
-
321
- // ============================================================================
322
- // SPACING UTILITIES
323
- // ============================================================================
324
-
325
- function escapeClassName(key) {
326
- // Escape dots in class names for CSS (e.g., "0.5" becomes "0\.5")
327
- return key.replace(/\./g, '\\.');
328
- }
329
-
330
- function generateSpacingUtilities(spacing) {
331
- let css = `/* Spacing: Padding & Margin */\n`;
332
-
333
- // Padding
334
- Object.entries(spacing).forEach(([key, value]) => {
335
- const escaped = escapeClassName(key);
336
- css += `.p-${escaped} { padding: ${value}; }\n`;
337
- css += `.px-${escaped} { padding-left: ${value}; padding-right: ${value}; }\n`;
338
- css += `.py-${escaped} { padding-top: ${value}; padding-bottom: ${value}; }\n`;
339
- css += `.pt-${escaped} { padding-top: ${value}; }\n`;
340
- css += `.pr-${escaped} { padding-right: ${value}; }\n`;
341
- css += `.pb-${escaped} { padding-bottom: ${value}; }\n`;
342
- css += `.pl-${escaped} { padding-left: ${value}; }\n`;
343
- css += `.ps-${escaped} { padding-inline-start: ${value}; }\n`;
344
- css += `.pe-${escaped} { padding-inline-end: ${value}; }\n`;
345
- });
346
-
347
- // Margin
348
- Object.entries(spacing).forEach(([key, value]) => {
349
- const escaped = escapeClassName(key);
350
- css += `.m-${escaped} { margin: ${value}; }\n`;
351
- css += `.mx-${escaped} { margin-left: ${value}; margin-right: ${value}; }\n`;
352
- css += `.my-${escaped} { margin-top: ${value}; margin-bottom: ${value}; }\n`;
353
- css += `.mt-${escaped} { margin-top: ${value}; }\n`;
354
- css += `.mr-${escaped} { margin-right: ${value}; }\n`;
355
- css += `.mb-${escaped} { margin-bottom: ${value}; }\n`;
356
- css += `.ml-${escaped} { margin-left: ${value}; }\n`;
357
- css += `.ms-${escaped} { margin-inline-start: ${value}; }\n`;
358
- css += `.me-${escaped} { margin-inline-end: ${value}; }\n`;
359
- if (value !== '0' && value !== '0px') {
360
- css += `.-m-${escaped} { margin: -${value}; }\n`;
361
- css += `.-mx-${escaped} { margin-left: -${value}; margin-right: -${value}; }\n`;
362
- css += `.-my-${escaped} { margin-top: -${value}; margin-bottom: -${value}; }\n`;
363
- css += `.-mt-${escaped} { margin-top: -${value}; }\n`;
364
- css += `.-mr-${escaped} { margin-right: -${value}; }\n`;
365
- css += `.-mb-${escaped} { margin-bottom: -${value}; }\n`;
366
- css += `.-ml-${escaped} { margin-left: -${value}; }\n`;
367
- css += `.-ms-${escaped} { margin-inline-start: -${value}; }\n`;
368
- css += `.-me-${escaped} { margin-inline-end: -${value}; }\n`;
369
- }
370
- });
371
-
372
- // Margin auto
373
- css += `.mx-auto { margin-left: auto; margin-right: auto; }\n`;
374
- css += `.my-auto { margin-top: auto; margin-bottom: auto; }\n`;
375
-
376
- css += `\n`;
377
- return css;
378
- }
379
-
380
- // ============================================================================
381
- // FLEXBOX UTILITIES
382
- // ============================================================================
383
-
384
- function generateFlexboxUtilities(spacing) {
385
- let css = `/* Flexbox */\n`;
386
-
387
- css += `.inline-flex { display: inline-flex; }\n`;
388
-
389
- // Direction
390
- css += `.flex-row { flex-direction: row; }\n`;
391
- css += `.flex-col { flex-direction: column; }\n`;
392
- css += `.flex-row-reverse { flex-direction: row-reverse; }\n`;
393
- css += `.flex-col-reverse { flex-direction: column-reverse; }\n`;
394
-
395
- // Wrap
396
- css += `.flex-wrap { flex-wrap: wrap; }\n`;
397
- css += `.flex-nowrap { flex-wrap: nowrap; }\n`;
398
- css += `.flex-wrap-reverse { flex-wrap: wrap-reverse; }\n`;
399
-
400
- // Flex shorthand
401
- css += `.flex-1 { flex: 1 1 0%; }\n`;
402
- css += `.flex-auto { flex: 1 1 auto; }\n`;
403
- css += `.flex-initial { flex: 0 1 auto; }\n`;
404
- css += `.flex-none { flex: none; }\n`;
405
-
406
- // Grow/shrink
407
- css += `.grow { flex-grow: 1; }\n`;
408
- css += `.grow-0 { flex-grow: 0; }\n`;
409
- css += `.shrink { flex-shrink: 1; }\n`;
410
- css += `.shrink-0 { flex-shrink: 0; }\n`;
411
-
412
- // Flex basis
413
- css += `.basis-auto { flex-basis: auto; }\n`;
414
- css += `.basis-full { flex-basis: 100%; }\n`;
415
- Object.entries(spacing).forEach(([key, value]) => {
416
- const escaped = escapeClassName(key);
417
- css += `.basis-${escaped} { flex-basis: ${value}; }\n`;
418
- });
419
-
420
- const fractions = {
421
- '1\\/2': '50%', '1\\/3': '33.333333%', '2\\/3': '66.666667%',
422
- '1\\/4': '25%', '2\\/4': '50%', '3\\/4': '75%',
423
- '1\\/5': '20%', '2\\/5': '40%', '3\\/5': '60%', '4\\/5': '80%',
424
- '1\\/6': '16.666667%', '2\\/6': '33.333333%', '3\\/6': '50%', '4\\/6': '66.666667%', '5\\/6': '83.333333%',
425
- '1\\/12': '8.333333%', '2\\/12': '16.666667%', '3\\/12': '25%', '4\\/12': '33.333333%', '5\\/12': '41.666667%', '6\\/12': '50%', '7\\/12': '58.333333%', '8\\/12': '66.666667%', '9\\/12': '75%', '10\\/12': '83.333333%', '11\\/12': '91.666667%'
426
- };
427
- Object.entries(fractions).forEach(([name, value]) => {
428
- css += `.basis-${name} { flex-basis: ${value}; }\n`;
429
- });
430
-
431
- // Order
432
- css += `.order-first { order: -9999; }\n`;
433
- css += `.order-last { order: 9999; }\n`;
434
- css += `.order-none { order: 0; }\n`;
435
- for (let i = 1; i <= 12; i++) {
436
- css += `.order-${i} { order: ${i}; }\n`;
437
- }
438
-
439
- // Justify (main axis)
440
- css += `.justify-normal { justify-content: normal; }\n`;
441
- css += `.justify-start { justify-content: flex-start; }\n`;
442
- css += `.justify-end { justify-content: flex-end; }\n`;
443
- css += `.justify-center { justify-content: center; }\n`;
444
- css += `.justify-between { justify-content: space-between; }\n`;
445
- css += `.justify-around { justify-content: space-around; }\n`;
446
- css += `.justify-evenly { justify-content: space-evenly; }\n`;
447
- css += `.justify-stretch { justify-content: stretch; }\n`;
448
-
449
- // Content alignment
450
- css += `.content-normal { align-content: normal; }\n`;
451
- css += `.content-center { align-content: center; }\n`;
452
- css += `.content-start { align-content: flex-start; }\n`;
453
- css += `.content-end { align-content: flex-end; }\n`;
454
- css += `.content-between { align-content: space-between; }\n`;
455
- css += `.content-around { align-content: space-around; }\n`;
456
- css += `.content-evenly { align-content: space-evenly; }\n`;
457
- css += `.content-baseline { align-content: baseline; }\n`;
458
- css += `.content-stretch { align-content: stretch; }\n`;
459
-
460
- // Items (cross axis)
461
- css += `.items-start { align-items: flex-start; }\n`;
462
- css += `.items-end { align-items: flex-end; }\n`;
463
- css += `.items-center { align-items: center; }\n`;
464
- css += `.items-baseline { align-items: baseline; }\n`;
465
- css += `.items-stretch { align-items: stretch; }\n`;
466
-
467
- // Self alignment
468
- css += `.self-auto { align-self: auto; }\n`;
469
- css += `.self-start { align-self: flex-start; }\n`;
470
- css += `.self-end { align-self: flex-end; }\n`;
471
- css += `.self-center { align-self: center; }\n`;
472
- css += `.self-stretch { align-self: stretch; }\n`;
473
- css += `.self-baseline { align-self: baseline; }\n`;
474
-
475
- // Place utilities
476
- css += `.place-content-center { place-content: center; }\n`;
477
- css += `.place-content-start { place-content: start; }\n`;
478
- css += `.place-content-end { place-content: end; }\n`;
479
- css += `.place-content-between { place-content: space-between; }\n`;
480
- css += `.place-content-around { place-content: space-around; }\n`;
481
- css += `.place-content-evenly { place-content: space-evenly; }\n`;
482
- css += `.place-content-baseline { place-content: baseline; }\n`;
483
- css += `.place-content-stretch { place-content: stretch; }\n`;
484
- css += `.place-items-start { place-items: start; }\n`;
485
- css += `.place-items-end { place-items: end; }\n`;
486
- css += `.place-items-center { place-items: center; }\n`;
487
- css += `.place-items-baseline { place-items: baseline; }\n`;
488
- css += `.place-items-stretch { place-items: stretch; }\n`;
489
- css += `.place-self-auto { place-self: auto; }\n`;
490
- css += `.place-self-start { place-self: start; }\n`;
491
- css += `.place-self-end { place-self: end; }\n`;
492
- css += `.place-self-center { place-self: center; }\n`;
493
- css += `.place-self-stretch { place-self: stretch; }\n`;
494
-
495
- css += `\n`;
496
- return css;
497
- }
498
-
499
- // ============================================================================
500
- // GRID UTILITIES
501
- // ============================================================================
502
-
503
- function generateGridUtilities(spacing) {
504
- let css = `/* Grid */\n`;
505
-
506
- css += `.inline-grid { display: inline-grid; }\n`;
507
-
508
- css += `.grid-cols-none { grid-template-columns: none; }\n`;
509
- css += `.grid-cols-subgrid { grid-template-columns: subgrid; }\n`;
510
- for (let i = 1; i <= 12; i++) {
511
- css += `.grid-cols-${i} { grid-template-columns: repeat(${i}, minmax(0, 1fr)); }\n`;
512
- }
513
-
514
- css += `.grid-rows-none { grid-template-rows: none; }\n`;
515
- css += `.grid-rows-subgrid { grid-template-rows: subgrid; }\n`;
516
- for (let i = 1; i <= 12; i++) {
517
- css += `.grid-rows-${i} { grid-template-rows: repeat(${i}, minmax(0, 1fr)); }\n`;
518
- }
519
-
520
- for (let i = 1; i <= 12; i++) {
521
- css += `.col-span-${i} { grid-column: span ${i} / span ${i}; }\n`;
522
- }
523
- css += `.col-span-full { grid-column: 1 / -1; }\n`;
524
- css += `.col-auto { grid-column: auto; }\n`;
525
- for (let i = 1; i <= 13; i++) {
526
- css += `.col-start-${i} { grid-column-start: ${i}; }\n`;
527
- css += `.col-end-${i} { grid-column-end: ${i}; }\n`;
528
- }
529
- css += `.col-start-auto { grid-column-start: auto; }\n`;
530
- css += `.col-end-auto { grid-column-end: auto; }\n`;
531
-
532
- for (let i = 1; i <= 12; i++) {
533
- css += `.row-span-${i} { grid-row: span ${i} / span ${i}; }\n`;
534
- }
535
- css += `.row-span-full { grid-row: 1 / -1; }\n`;
536
- css += `.row-auto { grid-row: auto; }\n`;
537
- for (let i = 1; i <= 13; i++) {
538
- css += `.row-start-${i} { grid-row-start: ${i}; }\n`;
539
- css += `.row-end-${i} { grid-row-end: ${i}; }\n`;
540
- }
541
- css += `.row-start-auto { grid-row-start: auto; }\n`;
542
- css += `.row-end-auto { grid-row-end: auto; }\n`;
543
-
544
- css += `.grid-flow-row { grid-auto-flow: row; }\n`;
545
- css += `.grid-flow-col { grid-auto-flow: column; }\n`;
546
- css += `.grid-flow-dense { grid-auto-flow: dense; }\n`;
547
- css += `.grid-flow-row-dense { grid-auto-flow: row dense; }\n`;
548
- css += `.grid-flow-col-dense { grid-auto-flow: column dense; }\n`;
549
-
550
- css += `.auto-cols-auto { grid-auto-columns: auto; }\n`;
551
- css += `.auto-cols-min { grid-auto-columns: min-content; }\n`;
552
- css += `.auto-cols-max { grid-auto-columns: max-content; }\n`;
553
- css += `.auto-cols-fr { grid-auto-columns: minmax(0, 1fr); }\n`;
554
- css += `.auto-rows-auto { grid-auto-rows: auto; }\n`;
555
- css += `.auto-rows-min { grid-auto-rows: min-content; }\n`;
556
- css += `.auto-rows-max { grid-auto-rows: max-content; }\n`;
557
- css += `.auto-rows-fr { grid-auto-rows: minmax(0, 1fr); }\n`;
558
-
559
- Object.entries(spacing).forEach(([key, value]) => {
560
- const escaped = escapeClassName(key);
561
- css += `.gap-${escaped} { gap: ${value}; }\n`;
562
- css += `.gap-x-${escaped} { column-gap: ${value}; }\n`;
563
- css += `.gap-y-${escaped} { row-gap: ${value}; }\n`;
564
- });
565
-
566
- css += `.justify-items-start { justify-items: start; }\n`;
567
- css += `.justify-items-end { justify-items: end; }\n`;
568
- css += `.justify-items-center { justify-items: center; }\n`;
569
- css += `.justify-items-stretch { justify-items: stretch; }\n`;
570
- css += `.justify-self-auto { justify-self: auto; }\n`;
571
- css += `.justify-self-start { justify-self: start; }\n`;
572
- css += `.justify-self-end { justify-self: end; }\n`;
573
- css += `.justify-self-center { justify-self: center; }\n`;
574
- css += `.justify-self-stretch { justify-self: stretch; }\n`;
575
-
576
- css += `\n`;
577
- return css;
578
- }
579
-
580
- // ============================================================================
581
- // TYPOGRAPHY UTILITIES
582
- // ============================================================================
583
-
584
- function generateTypographyUtilities(config) {
585
- let css = `/* Typography */\n`;
586
-
587
- config.typography.fontSizes.forEach(fontSize => {
588
- css += `.text-${fontSize.name} { font-size: var(--text-${fontSize.name}); line-height: ${fontSize.lineHeight}; }\n`;
589
- });
590
-
591
- const fontWeightDefaults = {
592
- thin: 100,
593
- extralight: 200,
594
- light: 300,
595
- normal: 400,
596
- medium: 500,
597
- semibold: 600,
598
- bold: 700,
599
- extrabold: 800,
600
- black: 900,
601
- };
602
- const resolvedFontWeights = {
603
- ...fontWeightDefaults,
604
- ...(config.typography.fontWeights || {}),
605
- };
606
-
607
- Object.entries(resolvedFontWeights).forEach(([name, weight]) => {
608
- css += `.font-${name} { font-weight: ${weight}; }\n`;
609
- });
610
-
611
- css += `.italic { font-style: italic; }\n`;
612
- css += `.not-italic { font-style: normal; }\n`;
613
-
614
- css += `.text-left { text-align: left; }\n`;
615
- css += `.text-center { text-align: center; }\n`;
616
- css += `.text-right { text-align: right; }\n`;
617
- css += `.text-justify { text-align: justify; }\n`;
618
- css += `.text-start { text-align: start; }\n`;
619
- css += `.text-end { text-align: end; }\n`;
620
-
621
- css += `.whitespace-normal { white-space: normal; }\n`;
622
- css += `.whitespace-nowrap { white-space: nowrap; }\n`;
623
- css += `.whitespace-pre { white-space: pre; }\n`;
624
- css += `.whitespace-pre-line { white-space: pre-line; }\n`;
625
- css += `.whitespace-pre-wrap { white-space: pre-wrap; }\n`;
626
- css += `.whitespace-break-spaces { white-space: break-spaces; }\n`;
627
- css += `.text-wrap { text-wrap: wrap; }\n`;
628
- css += `.text-nowrap { text-wrap: nowrap; }\n`;
629
- css += `.text-balance { text-wrap: balance; }\n`;
630
- css += `.text-pretty { text-wrap: pretty; }\n`;
631
- css += `.break-normal { overflow-wrap: normal; word-break: normal; }\n`;
632
- css += `.break-words { overflow-wrap: break-word; }\n`;
633
- css += `.break-all { word-break: break-all; }\n`;
634
- css += `.break-keep { word-break: keep-all; }\n`;
635
- css += `.hyphens-none { hyphens: none; }\n`;
636
- css += `.hyphens-manual { hyphens: manual; }\n`;
637
- css += `.hyphens-auto { hyphens: auto; }\n`;
638
-
639
- css += `.leading-none { line-height: 1; }\n`;
640
- css += `.leading-tight { line-height: 1.25; }\n`;
641
- css += `.leading-snug { line-height: 1.375; }\n`;
642
- css += `.leading-normal { line-height: 1.5; }\n`;
643
- css += `.leading-relaxed { line-height: 1.625; }\n`;
644
- css += `.leading-loose { line-height: 2; }\n`;
645
- css += `.text-display { font-size: clamp(2.5rem, 6vw, 4rem); }\n`;
646
-
647
- css += `.tracking-tighter { letter-spacing: -0.05em; }\n`;
648
- css += `.tracking-tight { letter-spacing: -0.025em; }\n`;
649
- css += `.tracking-normal { letter-spacing: 0em; }\n`;
650
- css += `.tracking-wide { letter-spacing: 0.025em; }\n`;
651
- css += `.tracking-wider { letter-spacing: 0.05em; }\n`;
652
- css += `.tracking-widest { letter-spacing: 0.1em; }\n`;
653
-
654
- css += `.underline { text-decoration-line: underline; }\n`;
655
- css += `.overline { text-decoration-line: overline; }\n`;
656
- css += `.line-through { text-decoration-line: line-through; }\n`;
657
- css += `.no-underline { text-decoration-line: none; }\n`;
658
- css += `.decoration-solid { text-decoration-style: solid; }\n`;
659
- css += `.decoration-double { text-decoration-style: double; }\n`;
660
- css += `.decoration-dotted { text-decoration-style: dotted; }\n`;
661
- css += `.decoration-dashed { text-decoration-style: dashed; }\n`;
662
- css += `.decoration-wavy { text-decoration-style: wavy; }\n`;
663
- css += `.underline-offset-auto { text-underline-offset: auto; }\n`;
664
- [0, 1, 2, 4, 8].forEach(value => {
665
- css += `.underline-offset-${value} { text-underline-offset: ${value}px; }\n`;
666
- });
667
- css += `.decoration-auto { text-decoration-thickness: auto; }\n`;
668
- css += `.decoration-from-font { text-decoration-thickness: from-font; }\n`;
669
- [0, 1, 2, 4, 8].forEach(value => {
670
- css += `.decoration-${value} { text-decoration-thickness: ${value}px; }\n`;
671
- });
672
-
673
- css += `.normal-nums { font-variant-numeric: normal; }\n`;
674
- css += `.ordinal { font-variant-numeric: ordinal; }\n`;
675
- css += `.slashed-zero { font-variant-numeric: slashed-zero; }\n`;
676
- css += `.lining-nums { font-variant-numeric: lining-nums; }\n`;
677
- css += `.oldstyle-nums { font-variant-numeric: oldstyle-nums; }\n`;
678
- css += `.proportional-nums { font-variant-numeric: proportional-nums; }\n`;
679
- css += `.tabular-nums { font-variant-numeric: tabular-nums; }\n`;
680
- css += `.diagonal-fractions { font-variant-numeric: diagonal-fractions; }\n`;
681
- css += `.stacked-fractions { font-variant-numeric: stacked-fractions; }\n`;
682
-
683
- css += `.uppercase { text-transform: uppercase; }\n`;
684
- css += `.lowercase { text-transform: lowercase; }\n`;
685
- css += `.capitalize { text-transform: capitalize; }\n`;
686
- css += `.normal-case { text-transform: none; }\n`;
687
-
688
- css += `.font-sans { font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; }\n`;
689
- css += `.font-serif { font-family: Georgia, "Times New Roman", serif; }\n`;
690
- css += `.font-mono { font-family: "Menlo", "Monaco", "Courier New", monospace; }\n`;
691
- css += `.font-inter { font-family: "Inter", system-ui, sans-serif; }\n`;
692
- css += `.font-lexend { font-family: "Lexend", system-ui, sans-serif; }\n`;
693
- css += `.font-dm-sans { font-family: "DM Sans", system-ui, sans-serif; }\n`;
694
- css += `.font-nunito { font-family: "Nunito", system-ui, sans-serif; }\n`;
695
- css += `.font-atkinson { font-family: "Atkinson Hyperlegible", system-ui, sans-serif; }\n`;
696
-
697
- css += `\n`;
698
- return css;
699
- }
700
-
701
- // ============================================================================
702
- // BORDER UTILITIES
703
- // ============================================================================
704
-
705
- function generateBorderUtilities(config) {
706
- let css = `/* Borders & Radius */\n`;
707
-
708
- const borderWidths = config.spacing.borderWidths || [0, 2, 4, 8];
709
-
710
- css += `.border { border-width: 1px; border-style: solid; }\n`;
711
- borderWidths.forEach(width => {
712
- css += `.border-${width} { border-width: ${width}px; }\n`;
713
- });
714
-
715
- const sides = {
716
- t: 'top', r: 'right', b: 'bottom', l: 'left',
717
- x: ['left', 'right'], y: ['top', 'bottom'],
718
- s: 'inline-start', e: 'inline-end'
719
- };
720
-
721
- Object.entries(sides).forEach(([side, value]) => {
722
- if (Array.isArray(value)) {
723
- css += `.border-${side} { border-${value[0]}-width: 1px; border-${value[1]}-width: 1px; border-${value[0]}-style: solid; border-${value[1]}-style: solid; }\n`;
724
- } else {
725
- css += `.border-${side} { border-${value}-width: 1px; border-${value}-style: solid; }\n`;
726
- }
727
- });
728
-
729
- borderWidths.forEach(width => {
730
- Object.entries(sides).forEach(([side, value]) => {
731
- if (Array.isArray(value)) {
732
- css += `.border-${side}-${width} { border-${value[0]}-width: ${width}px; border-${value[1]}-width: ${width}px; border-${value[0]}-style: solid; border-${value[1]}-style: solid; }\n`;
733
- } else {
734
- css += `.border-${side}-${width} { border-${value}-width: ${width}px; border-${value}-style: solid; }\n`;
735
- }
736
- });
737
- });
738
-
739
- css += `.border-solid { border-style: solid; }\n`;
740
- css += `.border-dashed { border-style: dashed; }\n`;
741
- css += `.border-dotted { border-style: dotted; }\n`;
742
- css += `.border-double { border-style: double; }\n`;
743
- css += `.border-hidden { border-style: hidden; }\n`;
744
- css += `.border-none { border-style: none; }\n`;
745
- css += `.border-transparent { border-color: transparent; }\n`;
746
- css += `.border-current { border-color: currentColor; }\n`;
747
- css += `.border-black { border-color: #111110; }\n`;
748
- css += `.border-white { border-color: #FAFAFA; }\n`;
749
-
750
- const baseRadius = config.spacing.borderRadius['base'] || '8px';
751
- css += `.rounded { border-radius: ${baseRadius}; }\n`;
752
-
753
- Object.entries(config.spacing.borderRadius).forEach(([name, value]) => {
754
- css += `.rounded-${name} { border-radius: ${value}; }\n`;
755
- });
756
-
757
- const radiusTargets = {
758
- t: ['top-left', 'top-right'], r: ['top-right', 'bottom-right'],
759
- b: ['bottom-right', 'bottom-left'], l: ['top-left', 'bottom-left'],
760
- tl: ['top-left'], tr: ['top-right'], br: ['bottom-right'], bl: ['bottom-left']
761
- };
762
-
763
- Object.entries(radiusTargets).forEach(([side, corners]) => {
764
- corners.forEach(corner => {
765
- css += `.rounded-${side} { border-${corner}-radius: ${baseRadius}; }\n`;
766
- });
767
- });
768
-
769
- Object.entries(config.spacing.borderRadius).forEach(([name, value]) => {
770
- Object.entries(radiusTargets).forEach(([side, corners]) => {
771
- corners.forEach(corner => {
772
- css += `.rounded-${side}-${name} { border-${corner}-radius: ${value}; }\n`;
773
- });
774
- });
775
- });
776
-
777
- css += `\n`;
778
- return css;
779
- }
780
-
781
- // ============================================================================
782
- // BASE ELEMENT STYLES
783
- // ============================================================================
784
-
785
- function generateBaseStyles(config) {
786
- const baseStyles = config.baseStyles;
787
- if (!baseStyles || Object.keys(baseStyles).length === 0) return '';
788
-
789
- // Build a lookup map from font size name → CSS variable
790
- const fontSizeMap = {};
791
- (config.typography?.fontSizes || []).forEach(({ name }) => {
792
- fontSizeMap[name] = `var(--text-${name})`;
793
- });
794
-
795
- // Line height hints per size — keeps headings tighter than body text
796
- const lineHeightMap = {
797
- xs: '1.5', sm: '1.5', base: '1.6', lg: '1.6',
798
- xl: '1.5', '2xl': '1.4', '3xl': '1.35', '4xl': '1.3',
799
- '5xl': '1.15', '6xl': '1.1', '7xl': '1.05', '8xl': '1', '9xl': '1'
800
- };
801
-
802
- let css = '\n /* Base element styles (from baseStyles in emily.config.json) */\n';
803
- Object.entries(baseStyles).forEach(([element, sizeKey]) => {
804
- const varRef = fontSizeMap[sizeKey];
805
- if (!varRef) return;
806
- const lh = lineHeightMap[sizeKey] || '1.5';
807
- css += ` ${element} { font-size: ${varRef}; line-height: ${lh}; }\n`;
808
- });
809
-
810
- return css;
811
- }
812
-
813
- // ============================================================================
814
- // COLOUR UTILITIES
815
- // ============================================================================
816
-
817
- function generateColourUtilities(colours) {
818
- let css = `/* Colours: Background, Text, Borders, Accents */\n`;
819
- // Uses CSS custom properties rather than hardcoded hex so colour utilities
820
- // can be overridden via variable redefinition (e.g. dark mode, theme layers).
821
- // The hex values are still declared as --color-* tokens in @layer theme.
822
-
823
- Object.entries(colours).forEach(([colourName, shades]) => {
824
- // Background colours
825
- Object.entries(shades).forEach(([shade]) => {
826
- css += `.bg-${colourName}-${shade} { background-color: var(--color-${colourName}-${shade}); }\n`;
827
- });
828
-
829
- // Text colours
830
- Object.entries(shades).forEach(([shade]) => {
831
- css += `.text-${colourName}-${shade} { color: var(--color-${colourName}-${shade}); }\n`;
832
- });
833
-
834
- // Border colours
835
- Object.entries(shades).forEach(([shade]) => {
836
- css += `.border-${colourName}-${shade} { border-color: var(--color-${colourName}-${shade}); }\n`;
837
- });
838
-
839
- // Accent colours (for form elements like checkboxes, radio buttons)
840
- Object.entries(shades).forEach(([shade]) => {
841
- css += `.accent-${colourName}-${shade} { accent-color: var(--color-${colourName}-${shade}); }\n`;
842
- });
843
- });
844
-
845
- css += `.bg-white { background-color: #FAFAFA; }\n`;
846
- css += `.bg-black { background-color: #111110; }\n`;
847
- css += `.bg-transparent { background-color: transparent; }\n`;
848
- css += `.bg-current { background-color: currentColor; }\n`;
849
-
850
- css += `.text-white { color: #FAFAFA; }\n`;
851
- css += `.text-black { color: #111110; }\n`;
852
- css += `.text-transparent { color: transparent; }\n`;
853
- css += `.text-current { color: currentColor; }\n`;
854
-
855
- css += `\n`;
856
- return css;
857
- }
858
-
859
- function generateSemanticColourUtilities(semanticColours) {
860
- if (!semanticColours) return '';
861
- let css = `/* Semantic colours: single value, no shade scale */\n`;
862
- Object.entries(semanticColours).forEach(([name]) => {
863
- css += `.bg-${name} { background-color: var(--color-${name}); }\n`;
864
- css += `.text-${name} { color: var(--color-${name}); }\n`;
865
- css += `.border-${name} { border-color: var(--color-${name}); }\n`;
866
- css += `.fill-${name} { fill: var(--color-${name}); }\n`;
867
- });
868
- css += `\n`;
869
- return css;
870
- }
871
-
872
- // ============================================================================
873
- // ARIA & DATA-STATE VARIANTS
874
- // ============================================================================
875
- // Generates ARIA attribute and data-state variants for all utility classes.
876
- // Selectors target the attribute value directly so they work without JS —
877
- // just toggle the attribute and the utility activates.
878
- //
879
- // Usage in HTML:
880
- // aria-expanded: class="aria-expanded:block" aria-expanded="true"
881
- // data-open: class="data-open:flex" data-state="open"
882
- //
883
- // Output examples:
884
- // .aria-expanded\:block[aria-expanded="true"] { display: block; }
885
- // .data-open\:flex[data-state="open"] { display: flex; }
886
-
887
- function addAriaDataVariants(css) {
888
- const variants = [
889
- { name: 'aria-expanded', selector: '[aria-expanded="true"]' },
890
- { name: 'aria-selected', selector: '[aria-selected="true"]' },
891
- { name: 'aria-checked', selector: '[aria-checked="true"]' },
892
- { name: 'aria-current', selector: '[aria-current="page"]' },
893
- { name: 'aria-disabled', selector: '[aria-disabled="true"]' },
894
- { name: 'data-open', selector: '[data-state="open"]' },
895
- { name: 'data-closed', selector: '[data-state="closed"]' },
896
- { name: 'data-checked', selector: '[data-state="checked"]' },
897
- { name: 'data-unchecked', selector: '[data-state="unchecked"]' },
898
- { name: 'data-active', selector: '[data-state="active"]' },
899
- { name: 'data-inactive', selector: '[data-state="inactive"]' },
900
- ];
901
-
902
- let variantCss = css;
903
-
904
- variants.forEach(variant => {
905
- let variantRules = '';
906
- const lines = css.split('\n');
907
-
908
- lines.forEach(line => {
909
- if (line.startsWith('.') && line.includes('{')) {
910
- const className = line.split('{')[0].trim();
911
- // Skip already-variant lines (contain ':' in class name) and special selectors
912
- if (
913
- !className.startsWith(':root') &&
914
- !className.includes('@') &&
915
- !className.includes('::') &&
916
- !className.includes(':')
917
- ) {
918
- const classWithoutDot = className.substring(1);
919
- const ariaSelector = `.${variant.name}\\:${classWithoutDot}${variant.selector}`;
920
- const ariaRule = line.replace(className, ariaSelector);
921
- variantRules += ariaRule + '\n';
922
- }
923
- }
924
- });
925
-
926
- if (variantRules) {
927
- variantCss += `\n/* ARIA/data-state variant: ${variant.name} */\n` + variantRules;
928
- }
929
- });
930
-
931
- return variantCss;
932
- }
933
-
934
- // ============================================================================
935
- // DARK MODE VARIANTS
936
- // ============================================================================
937
- // Generates dark: prefixed versions of colour and appearance utilities only.
938
- // Layout, spacing, and typography utilities don't change in dark mode —
939
- // targeting only the utilities where dark mode actually makes a difference
940
- // keeps the output lean and the purge step effective.
941
- //
942
- // Usage in HTML: class="bg-neutral-10 dark:bg-neutral-90 text-neutral-90 dark:text-neutral-10"
943
- // Output: @media (prefers-color-scheme: dark) { .dark\:bg-neutral-90 { background-color: ...; } }
944
-
945
- function addDarkModeVariants(css) {
946
- // Match on CSS property, not class name prefix — avoids catching
947
- // structural utilities like text-xs (font-size) or text-left (text-align)
948
- // when we only want colour-related declarations.
949
- const colourProperties = [
950
- 'background-color',
951
- 'color',
952
- 'border-color',
953
- 'accent-color',
954
- 'box-shadow',
955
- 'opacity',
956
- 'fill',
957
- 'stroke',
958
- '--tw-ring-color',
959
- 'outline-color',
960
- ];
961
-
962
- let darkRules = '';
963
- const lines = css.split('\n');
964
-
965
- lines.forEach(line => {
966
- if (line.startsWith('.') && line.includes('{') && line.includes('}')) {
967
- const className = line.split('{')[0].trim();
968
-
969
- // Only base utilities — skip anything already a variant (contains ':')
970
- if (className.includes(':')) return;
971
-
972
- // Only colour/appearance properties
973
- const isColourUtility = colourProperties.some(prop => line.includes(prop + ':'));
974
- if (!isColourUtility) return;
975
-
976
- const classWithoutDot = className.substring(1);
977
- const darkRule = line.replace(className, `.dark\\:${classWithoutDot}`);
978
- darkRules += ' ' + darkRule + '\n';
979
- }
980
- });
981
-
982
- if (!darkRules) return css;
983
-
984
- return css
985
- + `\n/* Dark mode variants — explicit override */\n[data-theme="dark"] {\n${darkRules}}\n`
986
- + `\n/* Dark mode variants — system preference (no override set) */\n@media (prefers-color-scheme: dark) {\n :root:not([data-theme="light"]) {\n${darkRules} }\n}\n`;
987
- }
988
-
989
- // ============================================================================
990
- // RESPONSIVE VARIANTS
991
- // ============================================================================
992
-
993
- function addResponsiveVariants(css, config) {
994
- let variantCss = css;
995
-
996
- Object.entries(config.breakpoints).forEach(([breakpointName, breakpointValue]) => {
997
- const mediaQuery = `@media (min-width: ${breakpointValue}) {\n`;
998
- let breakpointRules = '';
999
-
1000
- // Extract all utility rules and add responsive prefix
1001
- const lines = css.split('\n');
1002
- lines.forEach(line => {
1003
- if (line.startsWith('.') && line.includes('{')) {
1004
- const className = line.split('{')[0].trim();
1005
- const rule = line;
1006
- // Skip variables and already responsive selectors
1007
- if (!className.startsWith(':root') && !className.includes(':')) {
1008
- const responsiveRule = rule.replace(className, `.${breakpointName}\\:${className.substring(1)}`);
1009
- breakpointRules += ' ' + responsiveRule + '\n';
1010
- }
1011
- }
1012
- });
1013
-
1014
- if (breakpointRules) {
1015
- variantCss += mediaQuery + breakpointRules + '}\n\n';
1016
- }
1017
- });
1018
-
1019
- return variantCss;
1020
- }
1021
-
1022
- // ============================================================================
1023
- // STATE VARIANTS
1024
- // ============================================================================
1025
- // Add pseudo-class variants for hover, focus-visible, active, disabled
1026
-
1027
- function addStateVariants(css) {
1028
- const states = [
1029
- { name: 'hover', selector: ':hover' },
1030
- { name: 'focus', selector: ':focus' },
1031
- { name: 'focus-within', selector: ':focus-within' },
1032
- { name: 'focus-visible', selector: ':focus-visible' },
1033
- { name: 'active', selector: ':active' },
1034
- { name: 'disabled', selector: ':disabled' }
1035
- ];
1036
-
1037
- let variantCss = css;
1038
-
1039
- states.forEach(state => {
1040
- let stateRules = '';
1041
-
1042
- // Extract all utility rules and add state prefix
1043
- const lines = css.split('\n');
1044
- lines.forEach(line => {
1045
- if (line.startsWith('.') && line.includes('{')) {
1046
- const className = line.split('{')[0].trim();
1047
- // Skip variables, media queries, pseudo-elements, and state variants
1048
- if (!className.startsWith(':root') && !className.includes('@') && !className.includes('::') && !className.includes(':')) {
1049
- // Generate state variant: .hover\:block:hover { display: block; }
1050
- // Remove leading dot from className, add state prefix with escaped colon
1051
- const classWithoutDot = className.substring(1);
1052
- const stateSelector = `.${state.name}\\:${classWithoutDot}${state.selector}`;
1053
- const statefulRule = line.replace(className, stateSelector);
1054
- stateRules += statefulRule + '\n';
1055
- }
1056
- }
1057
- });
1058
-
1059
- if (stateRules) {
1060
- variantCss += '\n/* State variant: ' + state.name + ' */\n' + stateRules;
1061
- }
1062
- });
1063
-
1064
- return variantCss;
1065
- }
1066
-
1067
-
1068
- // ============================================================================
1069
- // PATTERN COMPONENTS
1070
- // ============================================================================
1071
- // Composite classes that combine multiple utilities into named patterns.
1072
- // These live in @layer components so utilities always take precedence in the cascade.
1073
- // Gap values reference spacing variables generated from emily.config.json,
1074
- // with pixel fallbacks so they work even without the variables in scope.
1075
-
1076
- function generatePatternComponents() {
1077
- return `
1078
- /* ---- Centering ---- */
1079
-
1080
- /* Full-viewport overlay centering — use for modals, lightboxes, toasts */
1081
- .center-screen {
1082
- position: fixed;
1083
- inset: 0;
1084
- display: flex;
1085
- align-items: center;
1086
- justify-content: center;
1087
- }
1088
-
1089
- /* Transform-based centering within a relative/absolute parent */
1090
- .center-absolute {
1091
- position: absolute;
1092
- top: 50%;
1093
- left: 50%;
1094
- transform: translate(-50%, -50%);
1095
- }
1096
-
1097
- /* ---- Reading / Prose ---- */
1098
-
1099
- /* Comfortable reading column — limits line length, centers the block */
1100
- .prose {
1101
- max-width: 65ch;
1102
- margin-inline: auto;
1103
- }
1104
-
1105
- .prose-emily {
1106
- max-width: 65ch;
1107
- margin-inline: auto;
1108
- }
1109
-
1110
- .prose-emily > * + * {
1111
- margin-top: var(--space-4, 1rem);
1112
- }
1113
-
1114
- .prose-emily h2,
1115
- .prose-emily h3 {
1116
- font-family: inherit;
1117
- color: var(--color-neutral-90);
1118
- line-height: 1.25;
1119
- }
1120
-
1121
- .prose-emily h2 {
1122
- font-size: var(--text-2xl, 24px);
1123
- margin-top: var(--space-10, 2.5rem);
1124
- }
1125
-
1126
- .prose-emily h3 {
1127
- font-size: var(--text-xl, 20px);
1128
- margin-top: var(--space-8, 2rem);
1129
- }
1130
-
1131
- .prose-emily p,
1132
- .prose-emily li {
1133
- color: var(--color-neutral-70);
1134
- line-height: 1.75;
1135
- }
1136
-
1137
- .prose-emily ul,
1138
- .prose-emily ol {
1139
- padding-left: var(--space-6, 1.5rem);
1140
- }
1141
-
1142
- .prose-emily ul {
1143
- list-style-type: disc;
1144
- }
1145
-
1146
- .prose-emily ol {
1147
- list-style-type: decimal;
1148
- }
1149
-
1150
- .prose-emily a {
1151
- color: var(--color-brand-80);
1152
- text-decoration: underline;
1153
- text-underline-offset: 2px;
1154
- }
1155
-
1156
- .prose-emily code {
1157
- font-size: var(--text-sm, 14px);
1158
- background-color: var(--color-neutral-10);
1159
- border: 1px solid var(--color-neutral-20);
1160
- border-radius: var(--space-1, 0.25rem);
1161
- padding: 0.125rem 0.375rem;
1162
- }
1163
-
1164
- /* ---- Composition ---- */
1165
-
1166
- /* Vertical stack with consistent gap — replaces manual margin chains */
1167
- .stack {
1168
- display: flex;
1169
- flex-direction: column;
1170
- gap: var(--space-4, 1rem);
1171
- }
1172
-
1173
- /* Horizontal grouping with wrapping — for tags, button rows, icon lists */
1174
- .cluster {
1175
- display: flex;
1176
- flex-wrap: wrap;
1177
- gap: var(--space-4, 1rem);
1178
- align-items: center;
1179
- }
1180
-
1181
- /* ---- Layout ---- */
1182
-
1183
- /* Constrained width container — 1100px max, full-width on small screens */
1184
- .width-container {
1185
- width: 100%;
1186
- max-width: 1100px;
1187
- margin-inline: auto;
1188
- padding-inline: var(--space-4, 1rem);
1189
- }
1190
-
1191
- @media (min-width: 640px) {
1192
- .width-container {
1193
- padding-inline: var(--space-6, 1.5rem);
1194
- }
1195
- }
1196
-
1197
- @media (min-width: 1024px) {
1198
- .width-container {
1199
- padding-inline: var(--space-8, 2rem);
1200
- }
1201
- }
1202
-
1203
- @media (min-width: 1140px) {
1204
- .width-container {
1205
- padding-inline: 0;
1206
- }
1207
- }
1208
-
1209
- /* ---- Forms ---- */
1210
-
1211
- .field-container {
1212
- display: flex;
1213
- flex-direction: column;
1214
- gap: var(--space-2, 0.5rem);
1215
- margin-bottom: var(--space-6, 1.5rem);
1216
- }
1217
-
1218
- .field-container label {
1219
- display: block;
1220
- font-weight: var(--font-weight-semibold, 600);
1221
- color: var(--color-neutral-90);
1222
- font-size: var(--text-base, 16px);
1223
- line-height: 1.4;
1224
- margin-bottom: var(--space-1, 0.25rem);
1225
- }
1226
-
1227
- fieldset {
1228
- border: none;
1229
- padding: 0;
1230
- margin: 0 0 var(--space-6, 1.5rem);
1231
- }
1232
-
1233
- fieldset legend {
1234
- display: block;
1235
- font-size: var(--text-lg, 18px);
1236
- font-weight: var(--font-weight-semibold, 600);
1237
- margin-bottom: var(--space-3, 0.75rem);
1238
- color: var(--color-neutral-90);
1239
- padding: 0;
1240
- }
1241
-
1242
- .form-hint {
1243
- font-size: var(--text-sm, 14px);
1244
- color: var(--color-neutral-60);
1245
- margin-bottom: var(--space-1, 0.25rem);
1246
- }
1247
-
1248
- input[type="text"],
1249
- input[type="email"],
1250
- input[type="password"],
1251
- input[type="number"],
1252
- input[type="tel"],
1253
- input[type="url"],
1254
- input[type="search"],
1255
- input[type="date"],
1256
- select,
1257
- textarea {
1258
- width: 100%;
1259
- max-width: 100%;
1260
- padding: var(--space-3, 0.75rem) var(--space-4, 1rem);
1261
- border: 2px solid var(--color-neutral-30);
1262
- border-radius: 8px;
1263
- background-color: #ffffff;
1264
- color: var(--color-neutral-90);
1265
- font-family: inherit;
1266
- font-size: var(--text-base, 16px);
1267
- line-height: var(--leading-base, 1.6);
1268
- appearance: none;
1269
- transition: border-color 200ms ease, box-shadow 200ms ease;
1270
- }
1271
-
1272
- select {
1273
- background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3e%3cpath stroke='%236b7280' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='M6 8l4 4 4-4'/%3e%3c/svg%3e");
1274
- background-position: right var(--space-2, 0.5rem) center;
1275
- background-repeat: no-repeat;
1276
- background-size: 1.5em 1.5em;
1277
- padding-right: var(--space-10, 2.5rem);
1278
- cursor: pointer;
1279
- }
1280
-
1281
- textarea {
1282
- min-height: 120px;
1283
- resize: vertical;
1284
- }
1285
-
1286
- input[type="text"]:focus,
1287
- input[type="email"]:focus,
1288
- input[type="password"]:focus,
1289
- input[type="number"]:focus,
1290
- input[type="tel"]:focus,
1291
- input[type="url"]:focus,
1292
- input[type="search"]:focus,
1293
- input[type="date"]:focus,
1294
- select:focus,
1295
- textarea:focus {
1296
- outline: 2px solid var(--color-neutral-80);
1297
- outline-offset: 3px;
1298
- border-color: var(--color-neutral-80);
1299
- box-shadow: 0 0 0 4px var(--focus-ring-glow, rgba(219, 39, 119, 0.1));
1300
- }
1301
-
1302
- .checkbox-group,
1303
- .radio-group {
1304
- display: flex;
1305
- align-items: center;
1306
- gap: var(--space-3, 0.75rem);
1307
- margin-bottom: var(--space-4, 1rem);
1308
- }
1309
-
1310
- .checkbox-group label,
1311
- .radio-group label {
1312
- font-weight: var(--font-weight-normal, 400);
1313
- margin-bottom: 0;
1314
- cursor: pointer;
1315
- font-size: var(--text-base, 16px);
1316
- }
1317
-
1318
- input[type="checkbox"] {
1319
- width: 1.5rem;
1320
- height: 1.5rem;
1321
- margin: 0;
1322
- cursor: pointer;
1323
- accent-color: var(--color-brand-80);
1324
- flex-shrink: 0;
1325
- }
1326
-
1327
- input[type="checkbox"]:focus {
1328
- outline: 2px solid var(--color-neutral-80);
1329
- outline-offset: 3px;
1330
- box-shadow: 0 0 0 4px var(--focus-ring-glow, rgba(219, 39, 119, 0.1));
1331
- }
1332
-
1333
- input[type="radio"] {
1334
- width: 1.5rem;
1335
- height: 1.5rem;
1336
- margin: 0;
1337
- border-radius: 50%;
1338
- appearance: none;
1339
- background-color: #ffffff;
1340
- border: 2px solid var(--color-neutral-30);
1341
- display: grid;
1342
- place-content: center;
1343
- cursor: pointer;
1344
- flex-shrink: 0;
1345
- transition: background-color 200ms ease, border-color 200ms ease;
1346
- }
1347
-
1348
- input[type="radio"]::before {
1349
- content: "";
1350
- width: 0.75rem;
1351
- height: 0.75rem;
1352
- border-radius: 50%;
1353
- transform: scale(0);
1354
- transition: 120ms transform ease-in-out;
1355
- background-color: var(--color-brand-80);
1356
- }
1357
-
1358
- input[type="radio"]:checked {
1359
- border-color: var(--color-brand-80);
1360
- }
1361
-
1362
- input[type="radio"]:checked::before {
1363
- transform: scale(1);
1364
- }
1365
-
1366
- input[type="radio"]:hover {
1367
- background-color: var(--color-brand-10);
1368
- border-color: var(--color-brand-80);
1369
- }
1370
-
1371
- input[type="radio"]:focus {
1372
- outline: 2px solid var(--color-neutral-80);
1373
- outline-offset: 3px;
1374
- border-radius: 50%;
1375
- box-shadow: 0 0 0 4px var(--focus-ring-glow, rgba(219, 39, 119, 0.1));
1376
- }
1377
-
1378
- input[aria-invalid="true"] {
1379
- border-color: var(--color-error-80) !important;
1380
- border-width: 3px;
1381
- }
1382
-
1383
- .form-error-message {
1384
- font-size: var(--text-sm, 14px);
1385
- font-weight: var(--font-weight-bold, 700);
1386
- color: var(--color-error-80);
1387
- margin-top: var(--space-1, 0.25rem);
1388
- display: block;
1389
- }
1390
-
1391
- .error-summary {
1392
- border: 4px solid var(--color-error-80);
1393
- padding: var(--space-6, 1.5rem);
1394
- margin-bottom: var(--space-8, 2rem);
1395
- border-radius: 8px;
1396
- }
1397
-
1398
- .error-summary ul {
1399
- list-style: disc;
1400
- padding-left: var(--space-5, 1.25rem);
1401
- }
1402
-
1403
- .error-summary a {
1404
- color: var(--color-error-80);
1405
- }
1406
-
1407
- /* ---- Buttons ---- */
1408
-
1409
- .btn {
1410
- display: inline-flex;
1411
- align-items: center;
1412
- justify-content: center;
1413
- padding: var(--space-3, 0.75rem) var(--space-6, 1.5rem);
1414
- font-weight: var(--font-weight-semibold, 600);
1415
- border-radius: 8px;
1416
- cursor: pointer;
1417
- transition: background-color 200ms ease, border-color 200ms ease, color 200ms ease;
1418
- border: 2px solid transparent;
1419
- text-align: center;
1420
- min-height: 3rem;
1421
- font-size: var(--text-base, 16px);
1422
- text-decoration: none;
1423
- font-family: inherit;
1424
- line-height: 1;
1425
- }
1426
-
1427
- .btn-primary {
1428
- background-color: var(--color-brand-80);
1429
- color: #ffffff;
1430
- border-color: transparent;
1431
- }
1432
-
1433
- .btn-primary:hover {
1434
- background-color: var(--color-brand-90);
1435
- }
1436
-
1437
- .btn-primary:focus-visible {
1438
- outline: 2px solid var(--color-neutral-80);
1439
- outline-offset: 3px;
1440
- box-shadow: 0 0 0 4px var(--focus-ring-glow, rgba(219, 39, 119, 0.1));
1441
- }
1442
-
1443
- .btn-secondary {
1444
- background-color: #ffffff;
1445
- color: var(--color-accent-80);
1446
- border-color: var(--color-accent-80);
1447
- }
1448
-
1449
- .btn-secondary:hover {
1450
- background-color: var(--color-accent-10);
1451
- color: var(--color-accent-90);
1452
- border-color: var(--color-accent-90);
1453
- }
1454
-
1455
- .btn-secondary:focus-visible {
1456
- outline: 2px solid var(--color-neutral-80);
1457
- outline-offset: 3px;
1458
- box-shadow: 0 0 0 4px var(--focus-ring-glow, rgba(219, 39, 119, 0.1));
1459
- }
1460
-
1461
- .btn-ghost {
1462
- background-color: transparent;
1463
- color: var(--color-neutral-80);
1464
- border-color: transparent;
1465
- }
1466
-
1467
- .btn-ghost:hover {
1468
- background-color: var(--color-neutral-10);
1469
- }
1470
-
1471
- .btn-ghost:focus-visible {
1472
- outline: 2px solid var(--color-neutral-80);
1473
- outline-offset: 3px;
1474
- box-shadow: 0 0 0 4px var(--focus-ring-glow, rgba(219, 39, 119, 0.1));
1475
- }
1476
-
1477
- .btn-danger {
1478
- background-color: var(--color-error-80);
1479
- color: #ffffff;
1480
- border-color: transparent;
1481
- }
1482
-
1483
- .btn-danger:hover {
1484
- background-color: var(--color-error-90);
1485
- }
1486
-
1487
- .btn-danger:focus-visible {
1488
- outline: 2px solid var(--color-neutral-80);
1489
- outline-offset: 3px;
1490
- box-shadow: 0 0 0 4px var(--focus-ring-glow, rgba(219, 39, 119, 0.1));
1491
- }
1492
-
1493
- .btn-sm {
1494
- padding: var(--space-2, 0.5rem) var(--space-4, 1rem);
1495
- font-size: var(--text-sm, 14px);
1496
- min-height: 2.25rem;
1497
- }
1498
-
1499
- .btn-lg {
1500
- padding: var(--space-4, 1rem) var(--space-8, 2rem);
1501
- font-size: var(--text-lg, 18px);
1502
- min-height: 3.5rem;
1503
- }
1504
- `;
1505
- }
1506
-
1507
- // ============================================================================
1508
- // BUILD FUNCTION
1509
- // ============================================================================
1510
-
7
+ const { validateConfigOrExit } = require('./validateConfig');
8
+ const {
9
+ getConfigPath,
10
+ getConfig,
11
+ getFullCssPath,
12
+ getProductionCssPath,
13
+ getManifestSettings,
14
+ getManifestOutputPath,
15
+ getIntellisenseSettings,
16
+ getIntellisenseOutputPath,
17
+ ensureDirectoryForFile,
18
+ getSourceDir,
19
+ } = require('./config');
20
+
21
+
22
+ // ============================================================================
23
+ // COLOUR GENERATION
24
+ // ============================================================================
25
+ // Generate 10-shade colour scale using OKLCH (perceptually uniform colour space)
26
+ // OKLCH produces visually even steps across all hues — unlike HSL which creates
27
+ // muddy mid-tones on warm colours (yellows, greens, oranges).
28
+ //
29
+ // Conversion pipeline: Hex sRGB Linear RGB → OKLab → OKLCH → (modify L) → reverse
30
+ // No external dependencies. Maths from Björn Ottosson's OKLab specification.
31
+ //
32
+ // Input: #0077b6 → Output: { 10: '#...', 20: '#...', ..., 100: '#...' }
33
+
34
+ // sRGB component to linear light
35
+ function srgbToLinear(c) {
36
+ const val = c / 255;
37
+ return val <= 0.04045 ? val / 12.92 : Math.pow((val + 0.055) / 1.055, 2.4);
38
+ }
39
+
40
+ // Linear light component to sRGB (clamped to 0–255)
41
+ function linearToSrgb(c) {
42
+ const clamped = Math.max(0, Math.min(1, c));
43
+ const out = clamped <= 0.0031308
44
+ ? 12.92 * clamped
45
+ : 1.055 * Math.pow(clamped, 1 / 2.4) - 0.055;
46
+ return Math.round(Math.max(0, Math.min(1, out)) * 255);
47
+ }
48
+
49
+ // Hex string → OKLCH { l, c, h }
50
+ function hexToOklch(hex) {
51
+ const r = srgbToLinear(parseInt(hex.slice(1, 3), 16));
52
+ const g = srgbToLinear(parseInt(hex.slice(3, 5), 16));
53
+ const b = srgbToLinear(parseInt(hex.slice(5, 7), 16));
54
+
55
+ // Linear RGB OKLab (M1 matrix then cube-root then M2 matrix)
56
+ const l = Math.cbrt(0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b);
57
+ const m = Math.cbrt(0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b);
58
+ const s = Math.cbrt(0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b);
59
+
60
+ const L = 0.2104542553 * l + 0.7936177850 * m - 0.0040720468 * s;
61
+ const a = 1.9779984951 * l - 2.4285922050 * m + 0.4505937099 * s;
62
+ const bv = 0.0259040371 * l + 0.7827717662 * m - 0.8086757660 * s;
63
+
64
+ // OKLab OKLCH
65
+ const C = Math.sqrt(a * a + bv * bv);
66
+ const H = (Math.atan2(bv, a) * 180) / Math.PI;
67
+
68
+ return { l: L, c: C, h: H < 0 ? H + 360 : H };
69
+ }
70
+
71
+ // OKLCH { l, c, h } → hex string
72
+ function oklchToHex(l, c, h) {
73
+ // OKLCH OKLab
74
+ const hRad = (h * Math.PI) / 180;
75
+ const a = c * Math.cos(hRad);
76
+ const bv = c * Math.sin(hRad);
77
+
78
+ // OKLab Linear RGB (M2 inverse then cube then M1 inverse)
79
+ const l_ = l + 0.3963377774 * a + 0.2158037573 * bv;
80
+ const m_ = l - 0.1055613458 * a - 0.0638541728 * bv;
81
+ const s_ = l - 0.0894841775 * a - 1.2914855480 * bv;
82
+
83
+ const lc = l_ * l_ * l_;
84
+ const mc = m_ * m_ * m_;
85
+ const sc = s_ * s_ * s_;
86
+
87
+ const r = 4.0767416621 * lc - 3.3077115913 * mc + 0.2309699292 * sc;
88
+ const g = -1.2684380046 * lc + 2.6097574011 * mc - 0.3413193965 * sc;
89
+ const b = -0.0041960863 * lc - 0.7034186147 * mc + 1.7076147010 * sc;
90
+
91
+ const rOut = linearToSrgb(r).toString(16).padStart(2, '0');
92
+ const gOut = linearToSrgb(g).toString(16).padStart(2, '0');
93
+ const bOut = linearToSrgb(b).toString(16).padStart(2, '0');
94
+
95
+ return `#${rOut}${gOut}${bOut}`.toUpperCase();
96
+ }
97
+
98
+ function generateColourScale(baseHex) {
99
+ const { l: baseL, c: baseC, h: baseH } = hexToOklch(baseHex);
100
+ const scale = {};
101
+
102
+ // Shade scale: 10 = near-white, 80 = exact input colour, 100 = near-black
103
+ // Lightness targets in OKLCH (0–1 scale):
104
+ // shade 10 → L 0.97 (very light tint)
105
+ // shade 80 → L = baseL (exact input)
106
+ // shade 100 → L ≈ 0.15 (very dark tone)
107
+ //
108
+ // Chroma is preserved from the base colour throughout hue is never shifted.
109
+ // At extreme lightness values chroma is gently reduced to stay in sRGB gamut.
110
+
111
+ const LIGHT_L = 0.97; // shade 10 lightness
112
+ const DARK_L = 0.15; // shade 100 lightness
113
+
114
+ const steps = [10, 20, 30, 40, 50, 60, 70, 80, 90, 100];
115
+
116
+ steps.forEach(step => {
117
+ if (step === 80) {
118
+ scale[step] = baseHex.toUpperCase();
119
+ return;
120
+ }
121
+
122
+ let newL;
123
+ if (step < 80) {
124
+ // 10–70: interpolate from LIGHT_L down to baseL
125
+ const t = (step / 80); // 0 1 as step goes 0 → 80
126
+ newL = LIGHT_L - t * (LIGHT_L - baseL);
127
+ } else {
128
+ // 90–100: interpolate from baseL down to DARK_L
129
+ const t = (step - 80) / 20; // 0 → 1 as step goes 80 → 100
130
+ newL = baseL - t * (baseL - DARK_L);
131
+ }
132
+
133
+ // Reduce chroma slightly at extremes to avoid out-of-gamut clipping
134
+ const chromaScale = 1 - Math.max(0, (newL - 0.90) / 0.07) * 0.5 // reduce near white
135
+ - Math.max(0, (0.25 - newL) / 0.10) * 0.5; // reduce near black
136
+ const newC = baseC * Math.max(0, chromaScale);
137
+
138
+ scale[step] = oklchToHex(newL, newC, baseH);
139
+ });
140
+
141
+ return scale;
142
+ }
143
+
144
+ function generateAllColours(colourConfig) {
145
+ const allColours = {};
146
+
147
+ Object.entries(colourConfig).forEach(([name, baseHex]) => {
148
+ allColours[name] = generateColourScale(baseHex);
149
+ });
150
+
151
+ return allColours;
152
+ }
153
+
154
+ // ============================================================================
155
+ // SPACING SCALE
156
+ // ============================================================================
157
+
158
+ function generateSpacing(baseUnit, scale) {
159
+ // Spacing values are defined explicitly in emily.config.json under spacing.scale.
160
+ // The baseUnit key in config is informational only it documents the design intent
161
+ // (e.g. "this system is based on 8px") but does not drive generation.
162
+ return scale;
163
+ }
164
+
165
+ // ============================================================================
166
+ // FONT PRESETS
167
+ // ============================================================================
168
+
169
+ // Font presets define the CSS font-family stack only.
170
+ // Loading the actual font files is the user's responsibility link them in your HTML
171
+ // or use @fontsource packages in your build. emily-css does not generate @import rules
172
+ // for external CDNs so it stays self-contained and works offline.
173
+ // See docs: https://emilyui.com/docs/getting-started
174
+ const FONT_PRESETS = {
175
+ 'system': {
176
+ stack: 'system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
177
+ },
178
+ 'inter': {
179
+ name: 'Inter',
180
+ stack: '"Inter", system-ui, sans-serif',
181
+ },
182
+ 'lexend': {
183
+ name: 'Lexend',
184
+ stack: '"Lexend", system-ui, sans-serif',
185
+ },
186
+ 'georgia': {
187
+ stack: 'Georgia, "Times New Roman", serif',
188
+ },
189
+ 'dm-sans': {
190
+ name: 'DM Sans',
191
+ stack: '"DM Sans", system-ui, sans-serif',
192
+ },
193
+ 'nunito': {
194
+ name: 'Nunito',
195
+ stack: '"Nunito", system-ui, sans-serif',
196
+ },
197
+ 'atkinson': {
198
+ name: 'Atkinson Hyperlegible',
199
+ stack: '"Atkinson Hyperlegible", system-ui, sans-serif',
200
+ },
201
+ 'mono': {
202
+ stack: '"Menlo", "Monaco", "Courier New", monospace',
203
+ },
204
+ };
205
+
206
+ function generateFontCSS(config) {
207
+ // Support both legacy string format and new { heading, body } object format
208
+ const fontConfig = config.fontFamily || 'system';
209
+ let headingKey, bodyKey;
210
+
211
+ if (typeof fontConfig === 'object') {
212
+ headingKey = (fontConfig.heading || 'system').toLowerCase();
213
+ bodyKey = (fontConfig.body || 'system').toLowerCase();
214
+ } else {
215
+ headingKey = fontConfig.toLowerCase();
216
+ bodyKey = fontConfig.toLowerCase();
217
+ }
218
+
219
+ const headingPreset = FONT_PRESETS[headingKey] || FONT_PRESETS['system'];
220
+ const bodyPreset = FONT_PRESETS[bodyKey] || FONT_PRESETS['system'];
221
+
222
+ let fontFace = '';
223
+ let bodyFont = '';
224
+
225
+ bodyFont += ` body {\n font-family: ${bodyPreset.stack};\n font-synthesis: style;\n }\n`;
226
+ bodyFont += ` h1, h2, h3, h4, h5, h6 {\n font-family: ${headingPreset.stack};\n }\n`;
227
+
228
+ return { fontFace, bodyFont };
229
+ }
230
+
231
+ // ============================================================================
232
+ // CSS VARIABLE GENERATION
233
+ // ============================================================================
234
+
235
+ function generateCSSVariables(colours, spacing, config) {
236
+ let css = `:root {\n`;
237
+
238
+ // Colour variables (full shade scale)
239
+ Object.entries(colours).forEach(([colourName, shades]) => {
240
+ Object.entries(shades).forEach(([shade, hex]) => {
241
+ css += ` --color-${colourName}-${shade}: ${hex};\n`;
242
+ });
243
+ });
244
+
245
+ // Semantic colour variables (single value, no shade scale)
246
+ if (config.semanticColours) {
247
+ Object.entries(config.semanticColours).forEach(([name, hex]) => {
248
+ css += ` --color-${name}: ${hex};\n`;
249
+ });
250
+ }
251
+ css += ` --focus-ring-glow: color-mix(in srgb, var(--color-brand-80) 12%, transparent);\n`;
252
+
253
+ // Spacing variables
254
+ Object.entries(spacing).forEach(([key, value]) => {
255
+ css += ` --space-${key}: ${value};\n`;
256
+ });
257
+
258
+ // Font size variables with line-height
259
+ config.typography.fontSizes.forEach(fontSize => {
260
+ const sizeVal = parseInt(fontSize.value);
261
+ css += ` --text-${fontSize.name}: ${fontSize.value};\n`;
262
+ css += ` --leading-${fontSize.name}: ${fontSize.lineHeight};\n`;
263
+ });
264
+
265
+ // Font weight variables
266
+ Object.entries(config.typography.fontWeights).forEach(([name, weight]) => {
267
+ css += ` --font-weight-${name}: ${weight};\n`;
268
+ });
269
+
270
+ // Breakpoints
271
+ Object.entries(config.breakpoints).forEach(([name, value]) => {
272
+ css += ` --breakpoint-${name}: ${value};\n`;
273
+ });
274
+
275
+ // Shadows
276
+ Object.entries(config.shadows).forEach(([name, shadow]) => {
277
+ css += ` --shadow-${name}: ${shadow};\n`;
278
+ });
279
+
280
+ // Z-index
281
+ Object.entries(config.zIndex).forEach(([name, value]) => {
282
+ css += ` --z-${name}: ${value};\n`;
283
+ });
284
+
285
+ // Transitions
286
+ css += ` --transition-duration: ${config.transitions.base};\n`;
287
+ css += ` --transition-timing: ${config.transitions.timing};\n`;
288
+
289
+ css += `}\n\n`;
290
+ return css;
291
+ }
292
+
293
+ const {
294
+ displayUtilities,
295
+ sizingUtilities,
296
+ positioningUtilities,
297
+ overflowUtilities,
298
+ opacityUtilities,
299
+ transitionUtilities,
300
+ transformUtilities,
301
+ shadowUtilities,
302
+ ringUtilities,
303
+ objectUtilities,
304
+ tableListUtilities,
305
+ svgUtilities,
306
+ formUtilities,
307
+ verticalAlignUtilities,
308
+ contentScrollUtilities,
309
+ blendUtilities,
310
+ cursorUtilities,
311
+ accessibilityUtilities,
312
+ containerUtilities,
313
+ codeUtilities,
314
+ animationUtilities,
315
+ backdropUtilities,
316
+ spaceUtilities,
317
+ divideUtilities,
318
+ backgroundUtilities,
319
+ filterUtilities,
320
+ } = require('./generators');
321
+
322
+ // ============================================================================
323
+ // SPACING UTILITIES
324
+ // ============================================================================
325
+
326
+ function escapeClassName(key) {
327
+ // Escape dots in class names for CSS (e.g., "0.5" becomes "0\.5")
328
+ return key.replace(/\./g, '\\.');
329
+ }
330
+
331
+ function generateSpacingUtilities(spacing) {
332
+ let css = `/* Spacing: Padding & Margin */\n`;
333
+
334
+ // Padding
335
+ Object.entries(spacing).forEach(([key, value]) => {
336
+ const escaped = escapeClassName(key);
337
+ css += `.p-${escaped} { padding: ${value}; }\n`;
338
+ css += `.px-${escaped} { padding-left: ${value}; padding-right: ${value}; }\n`;
339
+ css += `.py-${escaped} { padding-top: ${value}; padding-bottom: ${value}; }\n`;
340
+ css += `.pt-${escaped} { padding-top: ${value}; }\n`;
341
+ css += `.pr-${escaped} { padding-right: ${value}; }\n`;
342
+ css += `.pb-${escaped} { padding-bottom: ${value}; }\n`;
343
+ css += `.pl-${escaped} { padding-left: ${value}; }\n`;
344
+ css += `.ps-${escaped} { padding-inline-start: ${value}; }\n`;
345
+ css += `.pe-${escaped} { padding-inline-end: ${value}; }\n`;
346
+ });
347
+
348
+ // Margin
349
+ Object.entries(spacing).forEach(([key, value]) => {
350
+ const escaped = escapeClassName(key);
351
+ css += `.m-${escaped} { margin: ${value}; }\n`;
352
+ css += `.mx-${escaped} { margin-left: ${value}; margin-right: ${value}; }\n`;
353
+ css += `.my-${escaped} { margin-top: ${value}; margin-bottom: ${value}; }\n`;
354
+ css += `.mt-${escaped} { margin-top: ${value}; }\n`;
355
+ css += `.mr-${escaped} { margin-right: ${value}; }\n`;
356
+ css += `.mb-${escaped} { margin-bottom: ${value}; }\n`;
357
+ css += `.ml-${escaped} { margin-left: ${value}; }\n`;
358
+ css += `.ms-${escaped} { margin-inline-start: ${value}; }\n`;
359
+ css += `.me-${escaped} { margin-inline-end: ${value}; }\n`;
360
+ if (value !== '0' && value !== '0px') {
361
+ css += `.-m-${escaped} { margin: -${value}; }\n`;
362
+ css += `.-mx-${escaped} { margin-left: -${value}; margin-right: -${value}; }\n`;
363
+ css += `.-my-${escaped} { margin-top: -${value}; margin-bottom: -${value}; }\n`;
364
+ css += `.-mt-${escaped} { margin-top: -${value}; }\n`;
365
+ css += `.-mr-${escaped} { margin-right: -${value}; }\n`;
366
+ css += `.-mb-${escaped} { margin-bottom: -${value}; }\n`;
367
+ css += `.-ml-${escaped} { margin-left: -${value}; }\n`;
368
+ css += `.-ms-${escaped} { margin-inline-start: -${value}; }\n`;
369
+ css += `.-me-${escaped} { margin-inline-end: -${value}; }\n`;
370
+ }
371
+ });
372
+
373
+ // Margin auto
374
+ css += `.mx-auto { margin-left: auto; margin-right: auto; }\n`;
375
+ css += `.my-auto { margin-top: auto; margin-bottom: auto; }\n`;
376
+
377
+ css += `\n`;
378
+ return css;
379
+ }
380
+
381
+ // ============================================================================
382
+ // FLEXBOX UTILITIES
383
+ // ============================================================================
384
+
385
+ function generateFlexboxUtilities(spacing) {
386
+ let css = `/* Flexbox */\n`;
387
+
388
+ css += `.inline-flex { display: inline-flex; }\n`;
389
+
390
+ // Direction
391
+ css += `.flex-row { flex-direction: row; }\n`;
392
+ css += `.flex-col { flex-direction: column; }\n`;
393
+ css += `.flex-row-reverse { flex-direction: row-reverse; }\n`;
394
+ css += `.flex-col-reverse { flex-direction: column-reverse; }\n`;
395
+
396
+ // Wrap
397
+ css += `.flex-wrap { flex-wrap: wrap; }\n`;
398
+ css += `.flex-nowrap { flex-wrap: nowrap; }\n`;
399
+ css += `.flex-wrap-reverse { flex-wrap: wrap-reverse; }\n`;
400
+
401
+ // Flex shorthand
402
+ css += `.flex-1 { flex: 1 1 0%; }\n`;
403
+ css += `.flex-auto { flex: 1 1 auto; }\n`;
404
+ css += `.flex-initial { flex: 0 1 auto; }\n`;
405
+ css += `.flex-none { flex: none; }\n`;
406
+
407
+ // Grow/shrink
408
+ css += `.grow { flex-grow: 1; }\n`;
409
+ css += `.grow-0 { flex-grow: 0; }\n`;
410
+ css += `.shrink { flex-shrink: 1; }\n`;
411
+ css += `.shrink-0 { flex-shrink: 0; }\n`;
412
+
413
+ // Flex basis
414
+ css += `.basis-auto { flex-basis: auto; }\n`;
415
+ css += `.basis-full { flex-basis: 100%; }\n`;
416
+ Object.entries(spacing).forEach(([key, value]) => {
417
+ const escaped = escapeClassName(key);
418
+ css += `.basis-${escaped} { flex-basis: ${value}; }\n`;
419
+ });
420
+
421
+ const fractions = {
422
+ '1\\/2': '50%', '1\\/3': '33.333333%', '2\\/3': '66.666667%',
423
+ '1\\/4': '25%', '2\\/4': '50%', '3\\/4': '75%',
424
+ '1\\/5': '20%', '2\\/5': '40%', '3\\/5': '60%', '4\\/5': '80%',
425
+ '1\\/6': '16.666667%', '2\\/6': '33.333333%', '3\\/6': '50%', '4\\/6': '66.666667%', '5\\/6': '83.333333%',
426
+ '1\\/12': '8.333333%', '2\\/12': '16.666667%', '3\\/12': '25%', '4\\/12': '33.333333%', '5\\/12': '41.666667%', '6\\/12': '50%', '7\\/12': '58.333333%', '8\\/12': '66.666667%', '9\\/12': '75%', '10\\/12': '83.333333%', '11\\/12': '91.666667%'
427
+ };
428
+ Object.entries(fractions).forEach(([name, value]) => {
429
+ css += `.basis-${name} { flex-basis: ${value}; }\n`;
430
+ });
431
+
432
+ // Order
433
+ css += `.order-first { order: -9999; }\n`;
434
+ css += `.order-last { order: 9999; }\n`;
435
+ css += `.order-none { order: 0; }\n`;
436
+ for (let i = 1; i <= 12; i++) {
437
+ css += `.order-${i} { order: ${i}; }\n`;
438
+ }
439
+
440
+ // Justify (main axis)
441
+ css += `.justify-normal { justify-content: normal; }\n`;
442
+ css += `.justify-start { justify-content: flex-start; }\n`;
443
+ css += `.justify-end { justify-content: flex-end; }\n`;
444
+ css += `.justify-center { justify-content: center; }\n`;
445
+ css += `.justify-between { justify-content: space-between; }\n`;
446
+ css += `.justify-around { justify-content: space-around; }\n`;
447
+ css += `.justify-evenly { justify-content: space-evenly; }\n`;
448
+ css += `.justify-stretch { justify-content: stretch; }\n`;
449
+
450
+ // Content alignment
451
+ css += `.content-normal { align-content: normal; }\n`;
452
+ css += `.content-center { align-content: center; }\n`;
453
+ css += `.content-start { align-content: flex-start; }\n`;
454
+ css += `.content-end { align-content: flex-end; }\n`;
455
+ css += `.content-between { align-content: space-between; }\n`;
456
+ css += `.content-around { align-content: space-around; }\n`;
457
+ css += `.content-evenly { align-content: space-evenly; }\n`;
458
+ css += `.content-baseline { align-content: baseline; }\n`;
459
+ css += `.content-stretch { align-content: stretch; }\n`;
460
+
461
+ // Items (cross axis)
462
+ css += `.items-start { align-items: flex-start; }\n`;
463
+ css += `.items-end { align-items: flex-end; }\n`;
464
+ css += `.items-center { align-items: center; }\n`;
465
+ css += `.items-baseline { align-items: baseline; }\n`;
466
+ css += `.items-stretch { align-items: stretch; }\n`;
467
+
468
+ // Self alignment
469
+ css += `.self-auto { align-self: auto; }\n`;
470
+ css += `.self-start { align-self: flex-start; }\n`;
471
+ css += `.self-end { align-self: flex-end; }\n`;
472
+ css += `.self-center { align-self: center; }\n`;
473
+ css += `.self-stretch { align-self: stretch; }\n`;
474
+ css += `.self-baseline { align-self: baseline; }\n`;
475
+
476
+ // Place utilities
477
+ css += `.place-content-center { place-content: center; }\n`;
478
+ css += `.place-content-start { place-content: start; }\n`;
479
+ css += `.place-content-end { place-content: end; }\n`;
480
+ css += `.place-content-between { place-content: space-between; }\n`;
481
+ css += `.place-content-around { place-content: space-around; }\n`;
482
+ css += `.place-content-evenly { place-content: space-evenly; }\n`;
483
+ css += `.place-content-baseline { place-content: baseline; }\n`;
484
+ css += `.place-content-stretch { place-content: stretch; }\n`;
485
+ css += `.place-items-start { place-items: start; }\n`;
486
+ css += `.place-items-end { place-items: end; }\n`;
487
+ css += `.place-items-center { place-items: center; }\n`;
488
+ css += `.place-items-baseline { place-items: baseline; }\n`;
489
+ css += `.place-items-stretch { place-items: stretch; }\n`;
490
+ css += `.place-self-auto { place-self: auto; }\n`;
491
+ css += `.place-self-start { place-self: start; }\n`;
492
+ css += `.place-self-end { place-self: end; }\n`;
493
+ css += `.place-self-center { place-self: center; }\n`;
494
+ css += `.place-self-stretch { place-self: stretch; }\n`;
495
+
496
+ css += `\n`;
497
+ return css;
498
+ }
499
+
500
+ // ============================================================================
501
+ // GRID UTILITIES
502
+ // ============================================================================
503
+
504
+ function generateGridUtilities(spacing) {
505
+ let css = `/* Grid */\n`;
506
+
507
+ css += `.inline-grid { display: inline-grid; }\n`;
508
+
509
+ css += `.grid-cols-none { grid-template-columns: none; }\n`;
510
+ css += `.grid-cols-subgrid { grid-template-columns: subgrid; }\n`;
511
+ for (let i = 1; i <= 12; i++) {
512
+ css += `.grid-cols-${i} { grid-template-columns: repeat(${i}, minmax(0, 1fr)); }\n`;
513
+ }
514
+
515
+ css += `.grid-rows-none { grid-template-rows: none; }\n`;
516
+ css += `.grid-rows-subgrid { grid-template-rows: subgrid; }\n`;
517
+ for (let i = 1; i <= 12; i++) {
518
+ css += `.grid-rows-${i} { grid-template-rows: repeat(${i}, minmax(0, 1fr)); }\n`;
519
+ }
520
+
521
+ for (let i = 1; i <= 12; i++) {
522
+ css += `.col-span-${i} { grid-column: span ${i} / span ${i}; }\n`;
523
+ }
524
+ css += `.col-span-full { grid-column: 1 / -1; }\n`;
525
+ css += `.col-auto { grid-column: auto; }\n`;
526
+ for (let i = 1; i <= 13; i++) {
527
+ css += `.col-start-${i} { grid-column-start: ${i}; }\n`;
528
+ css += `.col-end-${i} { grid-column-end: ${i}; }\n`;
529
+ }
530
+ css += `.col-start-auto { grid-column-start: auto; }\n`;
531
+ css += `.col-end-auto { grid-column-end: auto; }\n`;
532
+
533
+ for (let i = 1; i <= 12; i++) {
534
+ css += `.row-span-${i} { grid-row: span ${i} / span ${i}; }\n`;
535
+ }
536
+ css += `.row-span-full { grid-row: 1 / -1; }\n`;
537
+ css += `.row-auto { grid-row: auto; }\n`;
538
+ for (let i = 1; i <= 13; i++) {
539
+ css += `.row-start-${i} { grid-row-start: ${i}; }\n`;
540
+ css += `.row-end-${i} { grid-row-end: ${i}; }\n`;
541
+ }
542
+ css += `.row-start-auto { grid-row-start: auto; }\n`;
543
+ css += `.row-end-auto { grid-row-end: auto; }\n`;
544
+
545
+ css += `.grid-flow-row { grid-auto-flow: row; }\n`;
546
+ css += `.grid-flow-col { grid-auto-flow: column; }\n`;
547
+ css += `.grid-flow-dense { grid-auto-flow: dense; }\n`;
548
+ css += `.grid-flow-row-dense { grid-auto-flow: row dense; }\n`;
549
+ css += `.grid-flow-col-dense { grid-auto-flow: column dense; }\n`;
550
+
551
+ css += `.auto-cols-auto { grid-auto-columns: auto; }\n`;
552
+ css += `.auto-cols-min { grid-auto-columns: min-content; }\n`;
553
+ css += `.auto-cols-max { grid-auto-columns: max-content; }\n`;
554
+ css += `.auto-cols-fr { grid-auto-columns: minmax(0, 1fr); }\n`;
555
+ css += `.auto-rows-auto { grid-auto-rows: auto; }\n`;
556
+ css += `.auto-rows-min { grid-auto-rows: min-content; }\n`;
557
+ css += `.auto-rows-max { grid-auto-rows: max-content; }\n`;
558
+ css += `.auto-rows-fr { grid-auto-rows: minmax(0, 1fr); }\n`;
559
+
560
+ Object.entries(spacing).forEach(([key, value]) => {
561
+ const escaped = escapeClassName(key);
562
+ css += `.gap-${escaped} { gap: ${value}; }\n`;
563
+ css += `.gap-x-${escaped} { column-gap: ${value}; }\n`;
564
+ css += `.gap-y-${escaped} { row-gap: ${value}; }\n`;
565
+ });
566
+
567
+ css += `.justify-items-start { justify-items: start; }\n`;
568
+ css += `.justify-items-end { justify-items: end; }\n`;
569
+ css += `.justify-items-center { justify-items: center; }\n`;
570
+ css += `.justify-items-stretch { justify-items: stretch; }\n`;
571
+ css += `.justify-self-auto { justify-self: auto; }\n`;
572
+ css += `.justify-self-start { justify-self: start; }\n`;
573
+ css += `.justify-self-end { justify-self: end; }\n`;
574
+ css += `.justify-self-center { justify-self: center; }\n`;
575
+ css += `.justify-self-stretch { justify-self: stretch; }\n`;
576
+
577
+ css += `\n`;
578
+ return css;
579
+ }
580
+
581
+ // ============================================================================
582
+ // TYPOGRAPHY UTILITIES
583
+ // ============================================================================
584
+
585
+ function generateTypographyUtilities(config) {
586
+ let css = `/* Typography */\n`;
587
+
588
+ config.typography.fontSizes.forEach(fontSize => {
589
+ css += `.text-${fontSize.name} { font-size: var(--text-${fontSize.name}); line-height: ${fontSize.lineHeight}; }\n`;
590
+ });
591
+
592
+ const fontWeightDefaults = {
593
+ thin: 100,
594
+ extralight: 200,
595
+ light: 300,
596
+ normal: 400,
597
+ medium: 500,
598
+ semibold: 600,
599
+ bold: 700,
600
+ extrabold: 800,
601
+ black: 900,
602
+ };
603
+ const resolvedFontWeights = {
604
+ ...fontWeightDefaults,
605
+ ...(config.typography.fontWeights || {}),
606
+ };
607
+
608
+ Object.entries(resolvedFontWeights).forEach(([name, weight]) => {
609
+ css += `.font-${name} { font-weight: ${weight}; }\n`;
610
+ });
611
+
612
+ css += `.italic { font-style: italic; }\n`;
613
+ css += `.not-italic { font-style: normal; }\n`;
614
+
615
+ css += `.text-left { text-align: left; }\n`;
616
+ css += `.text-center { text-align: center; }\n`;
617
+ css += `.text-right { text-align: right; }\n`;
618
+ css += `.text-justify { text-align: justify; }\n`;
619
+ css += `.text-start { text-align: start; }\n`;
620
+ css += `.text-end { text-align: end; }\n`;
621
+
622
+ css += `.whitespace-normal { white-space: normal; }\n`;
623
+ css += `.whitespace-nowrap { white-space: nowrap; }\n`;
624
+ css += `.whitespace-pre { white-space: pre; }\n`;
625
+ css += `.whitespace-pre-line { white-space: pre-line; }\n`;
626
+ css += `.whitespace-pre-wrap { white-space: pre-wrap; }\n`;
627
+ css += `.whitespace-break-spaces { white-space: break-spaces; }\n`;
628
+ css += `.text-wrap { text-wrap: wrap; }\n`;
629
+ css += `.text-nowrap { text-wrap: nowrap; }\n`;
630
+ css += `.text-balance { text-wrap: balance; }\n`;
631
+ css += `.text-pretty { text-wrap: pretty; }\n`;
632
+ css += `.break-normal { overflow-wrap: normal; word-break: normal; }\n`;
633
+ css += `.break-words { overflow-wrap: break-word; }\n`;
634
+ css += `.break-all { word-break: break-all; }\n`;
635
+ css += `.break-keep { word-break: keep-all; }\n`;
636
+ css += `.hyphens-none { hyphens: none; }\n`;
637
+ css += `.hyphens-manual { hyphens: manual; }\n`;
638
+ css += `.hyphens-auto { hyphens: auto; }\n`;
639
+
640
+ css += `.leading-none { line-height: 1; }\n`;
641
+ css += `.leading-tight { line-height: 1.25; }\n`;
642
+ css += `.leading-snug { line-height: 1.375; }\n`;
643
+ css += `.leading-normal { line-height: 1.5; }\n`;
644
+ css += `.leading-relaxed { line-height: 1.625; }\n`;
645
+ css += `.leading-loose { line-height: 2; }\n`;
646
+ css += `.text-display { font-size: clamp(2.5rem, 6vw, 4rem); }\n`;
647
+
648
+ css += `.tracking-tighter { letter-spacing: -0.05em; }\n`;
649
+ css += `.tracking-tight { letter-spacing: -0.025em; }\n`;
650
+ css += `.tracking-normal { letter-spacing: 0em; }\n`;
651
+ css += `.tracking-wide { letter-spacing: 0.025em; }\n`;
652
+ css += `.tracking-wider { letter-spacing: 0.05em; }\n`;
653
+ css += `.tracking-widest { letter-spacing: 0.1em; }\n`;
654
+
655
+ css += `.underline { text-decoration-line: underline; }\n`;
656
+ css += `.overline { text-decoration-line: overline; }\n`;
657
+ css += `.line-through { text-decoration-line: line-through; }\n`;
658
+ css += `.no-underline { text-decoration-line: none; }\n`;
659
+ css += `.decoration-solid { text-decoration-style: solid; }\n`;
660
+ css += `.decoration-double { text-decoration-style: double; }\n`;
661
+ css += `.decoration-dotted { text-decoration-style: dotted; }\n`;
662
+ css += `.decoration-dashed { text-decoration-style: dashed; }\n`;
663
+ css += `.decoration-wavy { text-decoration-style: wavy; }\n`;
664
+ css += `.underline-offset-auto { text-underline-offset: auto; }\n`;
665
+ [0, 1, 2, 4, 8].forEach(value => {
666
+ css += `.underline-offset-${value} { text-underline-offset: ${value}px; }\n`;
667
+ });
668
+ css += `.decoration-auto { text-decoration-thickness: auto; }\n`;
669
+ css += `.decoration-from-font { text-decoration-thickness: from-font; }\n`;
670
+ [0, 1, 2, 4, 8].forEach(value => {
671
+ css += `.decoration-${value} { text-decoration-thickness: ${value}px; }\n`;
672
+ });
673
+
674
+ css += `.normal-nums { font-variant-numeric: normal; }\n`;
675
+ css += `.ordinal { font-variant-numeric: ordinal; }\n`;
676
+ css += `.slashed-zero { font-variant-numeric: slashed-zero; }\n`;
677
+ css += `.lining-nums { font-variant-numeric: lining-nums; }\n`;
678
+ css += `.oldstyle-nums { font-variant-numeric: oldstyle-nums; }\n`;
679
+ css += `.proportional-nums { font-variant-numeric: proportional-nums; }\n`;
680
+ css += `.tabular-nums { font-variant-numeric: tabular-nums; }\n`;
681
+ css += `.diagonal-fractions { font-variant-numeric: diagonal-fractions; }\n`;
682
+ css += `.stacked-fractions { font-variant-numeric: stacked-fractions; }\n`;
683
+
684
+ css += `.uppercase { text-transform: uppercase; }\n`;
685
+ css += `.lowercase { text-transform: lowercase; }\n`;
686
+ css += `.capitalize { text-transform: capitalize; }\n`;
687
+ css += `.normal-case { text-transform: none; }\n`;
688
+
689
+ css += `.font-sans { font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; }\n`;
690
+ css += `.font-serif { font-family: Georgia, "Times New Roman", serif; }\n`;
691
+ css += `.font-mono { font-family: "Menlo", "Monaco", "Courier New", monospace; }\n`;
692
+ css += `.font-inter { font-family: "Inter", system-ui, sans-serif; }\n`;
693
+ css += `.font-lexend { font-family: "Lexend", system-ui, sans-serif; }\n`;
694
+ css += `.font-dm-sans { font-family: "DM Sans", system-ui, sans-serif; }\n`;
695
+ css += `.font-nunito { font-family: "Nunito", system-ui, sans-serif; }\n`;
696
+ css += `.font-atkinson { font-family: "Atkinson Hyperlegible", system-ui, sans-serif; }\n`;
697
+
698
+ css += `\n`;
699
+ return css;
700
+ }
701
+
702
+ // ============================================================================
703
+ // BORDER UTILITIES
704
+ // ============================================================================
705
+
706
+ function generateBorderUtilities(config) {
707
+ let css = `/* Borders & Radius */\n`;
708
+
709
+ const borderWidths = config.spacing.borderWidths || [0, 2, 4, 8];
710
+
711
+ css += `.border { border-width: 1px; border-style: solid; }\n`;
712
+ borderWidths.forEach(width => {
713
+ css += `.border-${width} { border-width: ${width}px; }\n`;
714
+ });
715
+
716
+ const sides = {
717
+ t: 'top', r: 'right', b: 'bottom', l: 'left',
718
+ x: ['left', 'right'], y: ['top', 'bottom'],
719
+ s: 'inline-start', e: 'inline-end'
720
+ };
721
+
722
+ Object.entries(sides).forEach(([side, value]) => {
723
+ if (Array.isArray(value)) {
724
+ css += `.border-${side} { border-${value[0]}-width: 1px; border-${value[1]}-width: 1px; border-${value[0]}-style: solid; border-${value[1]}-style: solid; }\n`;
725
+ } else {
726
+ css += `.border-${side} { border-${value}-width: 1px; border-${value}-style: solid; }\n`;
727
+ }
728
+ });
729
+
730
+ borderWidths.forEach(width => {
731
+ Object.entries(sides).forEach(([side, value]) => {
732
+ if (Array.isArray(value)) {
733
+ css += `.border-${side}-${width} { border-${value[0]}-width: ${width}px; border-${value[1]}-width: ${width}px; border-${value[0]}-style: solid; border-${value[1]}-style: solid; }\n`;
734
+ } else {
735
+ css += `.border-${side}-${width} { border-${value}-width: ${width}px; border-${value}-style: solid; }\n`;
736
+ }
737
+ });
738
+ });
739
+
740
+ css += `.border-solid { border-style: solid; }\n`;
741
+ css += `.border-dashed { border-style: dashed; }\n`;
742
+ css += `.border-dotted { border-style: dotted; }\n`;
743
+ css += `.border-double { border-style: double; }\n`;
744
+ css += `.border-hidden { border-style: hidden; }\n`;
745
+ css += `.border-none { border-style: none; }\n`;
746
+ css += `.border-transparent { border-color: transparent; }\n`;
747
+ css += `.border-current { border-color: currentColor; }\n`;
748
+ css += `.border-black { border-color: #111110; }\n`;
749
+ css += `.border-white { border-color: #FAFAFA; }\n`;
750
+
751
+ const baseRadius = config.spacing.borderRadius['base'] || '8px';
752
+ css += `.rounded { border-radius: ${baseRadius}; }\n`;
753
+
754
+ Object.entries(config.spacing.borderRadius).forEach(([name, value]) => {
755
+ css += `.rounded-${name} { border-radius: ${value}; }\n`;
756
+ });
757
+
758
+ const radiusTargets = {
759
+ t: ['top-left', 'top-right'], r: ['top-right', 'bottom-right'],
760
+ b: ['bottom-right', 'bottom-left'], l: ['top-left', 'bottom-left'],
761
+ tl: ['top-left'], tr: ['top-right'], br: ['bottom-right'], bl: ['bottom-left']
762
+ };
763
+
764
+ Object.entries(radiusTargets).forEach(([side, corners]) => {
765
+ corners.forEach(corner => {
766
+ css += `.rounded-${side} { border-${corner}-radius: ${baseRadius}; }\n`;
767
+ });
768
+ });
769
+
770
+ Object.entries(config.spacing.borderRadius).forEach(([name, value]) => {
771
+ Object.entries(radiusTargets).forEach(([side, corners]) => {
772
+ corners.forEach(corner => {
773
+ css += `.rounded-${side}-${name} { border-${corner}-radius: ${value}; }\n`;
774
+ });
775
+ });
776
+ });
777
+
778
+ css += `\n`;
779
+ return css;
780
+ }
781
+
782
+ // ============================================================================
783
+ // BASE ELEMENT STYLES
784
+ // ============================================================================
785
+
786
+ function generateBaseStyles(config) {
787
+ const baseStyles = config.baseStyles;
788
+ if (!baseStyles || Object.keys(baseStyles).length === 0) return '';
789
+
790
+ // Build a lookup map from font size name → CSS variable
791
+ const fontSizeMap = {};
792
+ (config.typography?.fontSizes || []).forEach(({ name }) => {
793
+ fontSizeMap[name] = `var(--text-${name})`;
794
+ });
795
+
796
+ // Line height hints per size — keeps headings tighter than body text
797
+ const lineHeightMap = {
798
+ xs: '1.5', sm: '1.5', base: '1.6', lg: '1.6',
799
+ xl: '1.5', '2xl': '1.4', '3xl': '1.35', '4xl': '1.3',
800
+ '5xl': '1.15', '6xl': '1.1', '7xl': '1.05', '8xl': '1', '9xl': '1'
801
+ };
802
+
803
+ let css = '\n /* Base element styles (from baseStyles in emily.config.json) */\n';
804
+ Object.entries(baseStyles).forEach(([element, sizeKey]) => {
805
+ const varRef = fontSizeMap[sizeKey];
806
+ if (!varRef) return;
807
+ const lh = lineHeightMap[sizeKey] || '1.5';
808
+ css += ` ${element} { font-size: ${varRef}; line-height: ${lh}; }\n`;
809
+ });
810
+
811
+ return css;
812
+ }
813
+
814
+ // ============================================================================
815
+ // COLOUR UTILITIES
816
+ // ============================================================================
817
+
818
+ function generateColourUtilities(colours) {
819
+ let css = `/* Colours: Background, Text, Borders, Accents */\n`;
820
+ // Uses CSS custom properties rather than hardcoded hex so colour utilities
821
+ // can be overridden via variable redefinition (e.g. dark mode, theme layers).
822
+ // The hex values are still declared as --color-* tokens in @layer theme.
823
+
824
+ Object.entries(colours).forEach(([colourName, shades]) => {
825
+ // Background colours
826
+ Object.entries(shades).forEach(([shade]) => {
827
+ css += `.bg-${colourName}-${shade} { background-color: var(--color-${colourName}-${shade}); }\n`;
828
+ });
829
+
830
+ // Text colours
831
+ Object.entries(shades).forEach(([shade]) => {
832
+ css += `.text-${colourName}-${shade} { color: var(--color-${colourName}-${shade}); }\n`;
833
+ });
834
+
835
+ // Border colours
836
+ Object.entries(shades).forEach(([shade]) => {
837
+ css += `.border-${colourName}-${shade} { border-color: var(--color-${colourName}-${shade}); }\n`;
838
+ });
839
+
840
+ // Accent colours (for form elements like checkboxes, radio buttons)
841
+ Object.entries(shades).forEach(([shade]) => {
842
+ css += `.accent-${colourName}-${shade} { accent-color: var(--color-${colourName}-${shade}); }\n`;
843
+ });
844
+ });
845
+
846
+ css += `.bg-white { background-color: #FAFAFA; }\n`;
847
+ css += `.bg-black { background-color: #111110; }\n`;
848
+ css += `.bg-transparent { background-color: transparent; }\n`;
849
+ css += `.bg-current { background-color: currentColor; }\n`;
850
+
851
+ css += `.text-white { color: #FAFAFA; }\n`;
852
+ css += `.text-black { color: #111110; }\n`;
853
+ css += `.text-transparent { color: transparent; }\n`;
854
+ css += `.text-current { color: currentColor; }\n`;
855
+
856
+ css += `\n`;
857
+ return css;
858
+ }
859
+
860
+ function generateSemanticColourUtilities(semanticColours) {
861
+ if (!semanticColours) return '';
862
+ let css = `/* Semantic colours: single value, no shade scale */\n`;
863
+ Object.entries(semanticColours).forEach(([name]) => {
864
+ css += `.bg-${name} { background-color: var(--color-${name}); }\n`;
865
+ css += `.text-${name} { color: var(--color-${name}); }\n`;
866
+ css += `.border-${name} { border-color: var(--color-${name}); }\n`;
867
+ css += `.fill-${name} { fill: var(--color-${name}); }\n`;
868
+ });
869
+ css += `\n`;
870
+ return css;
871
+ }
872
+
873
+ // ============================================================================
874
+ // ARIA & DATA-STATE VARIANTS
875
+ // ============================================================================
876
+ // Generates ARIA attribute and data-state variants for all utility classes.
877
+ // Selectors target the attribute value directly so they work without JS —
878
+ // just toggle the attribute and the utility activates.
879
+ //
880
+ // Usage in HTML:
881
+ // aria-expanded: class="aria-expanded:block" aria-expanded="true"
882
+ // data-open: class="data-open:flex" data-state="open"
883
+ //
884
+ // Output examples:
885
+ // .aria-expanded\:block[aria-expanded="true"] { display: block; }
886
+ // .data-open\:flex[data-state="open"] { display: flex; }
887
+
888
+ function addAriaDataVariants(css) {
889
+ const variants = [
890
+ { name: 'aria-expanded', selector: '[aria-expanded="true"]' },
891
+ { name: 'aria-selected', selector: '[aria-selected="true"]' },
892
+ { name: 'aria-checked', selector: '[aria-checked="true"]' },
893
+ { name: 'aria-current', selector: '[aria-current="page"]' },
894
+ { name: 'aria-disabled', selector: '[aria-disabled="true"]' },
895
+ { name: 'data-open', selector: '[data-state="open"]' },
896
+ { name: 'data-closed', selector: '[data-state="closed"]' },
897
+ { name: 'data-checked', selector: '[data-state="checked"]' },
898
+ { name: 'data-unchecked', selector: '[data-state="unchecked"]' },
899
+ { name: 'data-active', selector: '[data-state="active"]' },
900
+ { name: 'data-inactive', selector: '[data-state="inactive"]' },
901
+ ];
902
+
903
+ let variantCss = css;
904
+
905
+ variants.forEach(variant => {
906
+ let variantRules = '';
907
+ const lines = css.split('\n');
908
+
909
+ lines.forEach(line => {
910
+ if (line.startsWith('.') && line.includes('{')) {
911
+ const className = line.split('{')[0].trim();
912
+ // Skip already-variant lines (contain ':' in class name) and special selectors
913
+ if (
914
+ !className.startsWith(':root') &&
915
+ !className.includes('@') &&
916
+ !className.includes('::') &&
917
+ !className.includes(':')
918
+ ) {
919
+ const classWithoutDot = className.substring(1);
920
+ const ariaSelector = `.${variant.name}\\:${classWithoutDot}${variant.selector}`;
921
+ const ariaRule = line.replace(className, ariaSelector);
922
+ variantRules += ariaRule + '\n';
923
+ }
924
+ }
925
+ });
926
+
927
+ if (variantRules) {
928
+ variantCss += `\n/* ARIA/data-state variant: ${variant.name} */\n` + variantRules;
929
+ }
930
+ });
931
+
932
+ return variantCss;
933
+ }
934
+
935
+ // ============================================================================
936
+ // DARK MODE VARIANTS
937
+ // ============================================================================
938
+ // Generates dark: prefixed versions of colour and appearance utilities only.
939
+ // Layout, spacing, and typography utilities don't change in dark mode
940
+ // targeting only the utilities where dark mode actually makes a difference
941
+ // keeps the output lean and the purge step effective.
942
+ //
943
+ // Usage in HTML: class="bg-neutral-10 dark:bg-neutral-90 text-neutral-90 dark:text-neutral-10"
944
+ // Output: @media (prefers-color-scheme: dark) { .dark\:bg-neutral-90 { background-color: ...; } }
945
+
946
+ function addDarkModeVariants(css) {
947
+ // Match on CSS property, not class name prefix — avoids catching
948
+ // structural utilities like text-xs (font-size) or text-left (text-align)
949
+ // when we only want colour-related declarations.
950
+ const colourProperties = [
951
+ 'background-color',
952
+ 'color',
953
+ 'border-color',
954
+ 'accent-color',
955
+ 'box-shadow',
956
+ 'opacity',
957
+ 'fill',
958
+ 'stroke',
959
+ '--tw-ring-color',
960
+ 'outline-color',
961
+ ];
962
+
963
+ let darkRules = '';
964
+ const lines = css.split('\n');
965
+
966
+ lines.forEach(line => {
967
+ if (line.startsWith('.') && line.includes('{') && line.includes('}')) {
968
+ const className = line.split('{')[0].trim();
969
+
970
+ // Only base utilities — skip anything already a variant (contains ':')
971
+ if (className.includes(':')) return;
972
+
973
+ // Only colour/appearance properties
974
+ const isColourUtility = colourProperties.some(prop => line.includes(prop + ':'));
975
+ if (!isColourUtility) return;
976
+
977
+ const classWithoutDot = className.substring(1);
978
+ const darkRule = line.replace(className, `.dark\\:${classWithoutDot}`);
979
+ darkRules += ' ' + darkRule + '\n';
980
+ }
981
+ });
982
+
983
+ if (!darkRules) return css;
984
+
985
+ return css
986
+ + `\n/* Dark mode variants — explicit override */\n[data-theme="dark"] {\n${darkRules}}\n`
987
+ + `\n/* Dark mode variants — system preference (no override set) */\n@media (prefers-color-scheme: dark) {\n :root:not([data-theme="light"]) {\n${darkRules} }\n}\n`;
988
+ }
989
+
990
+ // ============================================================================
991
+ // RESPONSIVE VARIANTS
992
+ // ============================================================================
993
+
994
+ function addResponsiveVariants(css, config) {
995
+ let variantCss = css;
996
+
997
+ Object.entries(config.breakpoints).forEach(([breakpointName, breakpointValue]) => {
998
+ const mediaQuery = `@media (min-width: ${breakpointValue}) {\n`;
999
+ let breakpointRules = '';
1000
+
1001
+ // Extract all utility rules and add responsive prefix
1002
+ const lines = css.split('\n');
1003
+ lines.forEach(line => {
1004
+ if (line.startsWith('.') && line.includes('{')) {
1005
+ const className = line.split('{')[0].trim();
1006
+ const rule = line;
1007
+ // Skip variables and already responsive selectors
1008
+ if (!className.startsWith(':root') && !className.includes(':')) {
1009
+ const responsiveRule = rule.replace(className, `.${breakpointName}\\:${className.substring(1)}`);
1010
+ breakpointRules += ' ' + responsiveRule + '\n';
1011
+ }
1012
+ }
1013
+ });
1014
+
1015
+ if (breakpointRules) {
1016
+ variantCss += mediaQuery + breakpointRules + '}\n\n';
1017
+ }
1018
+ });
1019
+
1020
+ return variantCss;
1021
+ }
1022
+
1023
+ // ============================================================================
1024
+ // STATE VARIANTS
1025
+ // ============================================================================
1026
+ // Add pseudo-class variants for hover, focus-visible, active, disabled
1027
+
1028
+ function addStateVariants(css) {
1029
+ const states = [
1030
+ { name: 'hover', selector: ':hover' },
1031
+ { name: 'focus', selector: ':focus' },
1032
+ { name: 'focus-within', selector: ':focus-within' },
1033
+ { name: 'focus-visible', selector: ':focus-visible' },
1034
+ { name: 'active', selector: ':active' },
1035
+ { name: 'disabled', selector: ':disabled' }
1036
+ ];
1037
+
1038
+ let variantCss = css;
1039
+
1040
+ states.forEach(state => {
1041
+ let stateRules = '';
1042
+
1043
+ // Extract all utility rules and add state prefix
1044
+ const lines = css.split('\n');
1045
+ lines.forEach(line => {
1046
+ if (line.startsWith('.') && line.includes('{')) {
1047
+ const className = line.split('{')[0].trim();
1048
+ // Skip variables, media queries, pseudo-elements, and state variants
1049
+ if (!className.startsWith(':root') && !className.includes('@') && !className.includes('::') && !className.includes(':')) {
1050
+ // Generate state variant: .hover\:block:hover { display: block; }
1051
+ // Remove leading dot from className, add state prefix with escaped colon
1052
+ const classWithoutDot = className.substring(1);
1053
+ const stateSelector = `.${state.name}\\:${classWithoutDot}${state.selector}`;
1054
+ const statefulRule = line.replace(className, stateSelector);
1055
+ stateRules += statefulRule + '\n';
1056
+ }
1057
+ }
1058
+ });
1059
+
1060
+ if (stateRules) {
1061
+ variantCss += '\n/* State variant: ' + state.name + ' */\n' + stateRules;
1062
+ }
1063
+ });
1064
+
1065
+ return variantCss;
1066
+ }
1067
+
1068
+
1069
+ // ============================================================================
1070
+ // PATTERN COMPONENTS
1071
+ // ============================================================================
1072
+ // Composite classes that combine multiple utilities into named patterns.
1073
+ // These live in @layer components so utilities always take precedence in the cascade.
1074
+ // Gap values reference spacing variables generated from emily.config.json,
1075
+ // with pixel fallbacks so they work even without the variables in scope.
1076
+
1077
+ function generatePatternComponents() {
1078
+ return `
1079
+ /* ---- Centering ---- */
1080
+
1081
+ /* Full-viewport overlay centering — use for modals, lightboxes, toasts */
1082
+ .center-screen {
1083
+ position: fixed;
1084
+ inset: 0;
1085
+ display: flex;
1086
+ align-items: center;
1087
+ justify-content: center;
1088
+ }
1089
+
1090
+ /* Transform-based centering within a relative/absolute parent */
1091
+ .center-absolute {
1092
+ position: absolute;
1093
+ top: 50%;
1094
+ left: 50%;
1095
+ transform: translate(-50%, -50%);
1096
+ }
1097
+
1098
+ /* ---- Reading / Prose ---- */
1099
+
1100
+ /* Comfortable reading column — limits line length, centers the block */
1101
+ .prose {
1102
+ max-width: 65ch;
1103
+ margin-inline: auto;
1104
+ }
1105
+
1106
+ .prose-emily {
1107
+ max-width: 65ch;
1108
+ margin-inline: auto;
1109
+ }
1110
+
1111
+ .prose-emily > * + * {
1112
+ margin-top: var(--space-4, 1rem);
1113
+ }
1114
+
1115
+ .prose-emily h2,
1116
+ .prose-emily h3 {
1117
+ font-family: inherit;
1118
+ color: var(--color-neutral-90);
1119
+ line-height: 1.25;
1120
+ }
1121
+
1122
+ .prose-emily h2 {
1123
+ font-size: var(--text-2xl, 24px);
1124
+ margin-top: var(--space-10, 2.5rem);
1125
+ }
1126
+
1127
+ .prose-emily h3 {
1128
+ font-size: var(--text-xl, 20px);
1129
+ margin-top: var(--space-8, 2rem);
1130
+ }
1131
+
1132
+ .prose-emily p,
1133
+ .prose-emily li {
1134
+ color: var(--color-neutral-70);
1135
+ line-height: 1.75;
1136
+ }
1137
+
1138
+ .prose-emily ul,
1139
+ .prose-emily ol {
1140
+ padding-left: var(--space-6, 1.5rem);
1141
+ }
1142
+
1143
+ .prose-emily ul {
1144
+ list-style-type: disc;
1145
+ }
1146
+
1147
+ .prose-emily ol {
1148
+ list-style-type: decimal;
1149
+ }
1150
+
1151
+ .prose-emily a {
1152
+ color: var(--color-brand-80);
1153
+ text-decoration: underline;
1154
+ text-underline-offset: 2px;
1155
+ }
1156
+
1157
+ .prose-emily code {
1158
+ font-size: var(--text-sm, 14px);
1159
+ background-color: var(--color-neutral-10);
1160
+ border: 1px solid var(--color-neutral-20);
1161
+ border-radius: var(--space-1, 0.25rem);
1162
+ padding: 0.125rem 0.375rem;
1163
+ }
1164
+
1165
+ /* ---- Composition ---- */
1166
+
1167
+ /* Vertical stack with consistent gap — replaces manual margin chains */
1168
+ .stack {
1169
+ display: flex;
1170
+ flex-direction: column;
1171
+ gap: var(--space-4, 1rem);
1172
+ }
1173
+
1174
+ /* Horizontal grouping with wrapping — for tags, button rows, icon lists */
1175
+ .cluster {
1176
+ display: flex;
1177
+ flex-wrap: wrap;
1178
+ gap: var(--space-4, 1rem);
1179
+ align-items: center;
1180
+ }
1181
+
1182
+ /* ---- Layout ---- */
1183
+
1184
+ /* Constrained width container — 1100px max, full-width on small screens */
1185
+ .width-container {
1186
+ width: 100%;
1187
+ max-width: 1100px;
1188
+ margin-inline: auto;
1189
+ padding-inline: var(--space-4, 1rem);
1190
+ }
1191
+
1192
+ @media (min-width: 640px) {
1193
+ .width-container {
1194
+ padding-inline: var(--space-6, 1.5rem);
1195
+ }
1196
+ }
1197
+
1198
+ @media (min-width: 1024px) {
1199
+ .width-container {
1200
+ padding-inline: var(--space-8, 2rem);
1201
+ }
1202
+ }
1203
+
1204
+ @media (min-width: 1140px) {
1205
+ .width-container {
1206
+ padding-inline: 0;
1207
+ }
1208
+ }
1209
+
1210
+ /* ---- Forms ---- */
1211
+
1212
+ .field-container {
1213
+ display: flex;
1214
+ flex-direction: column;
1215
+ gap: var(--space-2, 0.5rem);
1216
+ margin-bottom: var(--space-6, 1.5rem);
1217
+ }
1218
+
1219
+ .field-container label {
1220
+ display: block;
1221
+ font-weight: var(--font-weight-semibold, 600);
1222
+ color: var(--color-neutral-90);
1223
+ font-size: var(--text-base, 16px);
1224
+ line-height: 1.4;
1225
+ margin-bottom: var(--space-1, 0.25rem);
1226
+ }
1227
+
1228
+ fieldset {
1229
+ border: none;
1230
+ padding: 0;
1231
+ margin: 0 0 var(--space-6, 1.5rem);
1232
+ }
1233
+
1234
+ fieldset legend {
1235
+ display: block;
1236
+ font-size: var(--text-lg, 18px);
1237
+ font-weight: var(--font-weight-semibold, 600);
1238
+ margin-bottom: var(--space-3, 0.75rem);
1239
+ color: var(--color-neutral-90);
1240
+ padding: 0;
1241
+ }
1242
+
1243
+ .form-hint {
1244
+ font-size: var(--text-sm, 14px);
1245
+ color: var(--color-neutral-60);
1246
+ margin-bottom: var(--space-1, 0.25rem);
1247
+ }
1248
+
1249
+ input[type="text"],
1250
+ input[type="email"],
1251
+ input[type="password"],
1252
+ input[type="number"],
1253
+ input[type="tel"],
1254
+ input[type="url"],
1255
+ input[type="search"],
1256
+ input[type="date"],
1257
+ select,
1258
+ textarea {
1259
+ width: 100%;
1260
+ max-width: 100%;
1261
+ padding: var(--space-3, 0.75rem) var(--space-4, 1rem);
1262
+ border: 2px solid var(--color-neutral-30);
1263
+ border-radius: 8px;
1264
+ background-color: #ffffff;
1265
+ color: var(--color-neutral-90);
1266
+ font-family: inherit;
1267
+ font-size: var(--text-base, 16px);
1268
+ line-height: var(--leading-base, 1.6);
1269
+ appearance: none;
1270
+ transition: border-color 200ms ease, box-shadow 200ms ease;
1271
+ }
1272
+
1273
+ select {
1274
+ background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3e%3cpath stroke='%236b7280' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='M6 8l4 4 4-4'/%3e%3c/svg%3e");
1275
+ background-position: right var(--space-2, 0.5rem) center;
1276
+ background-repeat: no-repeat;
1277
+ background-size: 1.5em 1.5em;
1278
+ padding-right: var(--space-10, 2.5rem);
1279
+ cursor: pointer;
1280
+ }
1281
+
1282
+ textarea {
1283
+ min-height: 120px;
1284
+ resize: vertical;
1285
+ }
1286
+
1287
+ input[type="text"]:focus,
1288
+ input[type="email"]:focus,
1289
+ input[type="password"]:focus,
1290
+ input[type="number"]:focus,
1291
+ input[type="tel"]:focus,
1292
+ input[type="url"]:focus,
1293
+ input[type="search"]:focus,
1294
+ input[type="date"]:focus,
1295
+ select:focus,
1296
+ textarea:focus {
1297
+ outline: 2px solid var(--color-neutral-80);
1298
+ outline-offset: 3px;
1299
+ border-color: var(--color-neutral-80);
1300
+ box-shadow: 0 0 0 4px var(--focus-ring-glow, rgba(219, 39, 119, 0.1));
1301
+ }
1302
+
1303
+ .checkbox-group,
1304
+ .radio-group {
1305
+ display: flex;
1306
+ align-items: center;
1307
+ gap: var(--space-3, 0.75rem);
1308
+ margin-bottom: var(--space-4, 1rem);
1309
+ }
1310
+
1311
+ .checkbox-group label,
1312
+ .radio-group label {
1313
+ font-weight: var(--font-weight-normal, 400);
1314
+ margin-bottom: 0;
1315
+ cursor: pointer;
1316
+ font-size: var(--text-base, 16px);
1317
+ }
1318
+
1319
+ input[type="checkbox"] {
1320
+ width: 1.5rem;
1321
+ height: 1.5rem;
1322
+ margin: 0;
1323
+ cursor: pointer;
1324
+ accent-color: var(--color-brand-80);
1325
+ flex-shrink: 0;
1326
+ }
1327
+
1328
+ input[type="checkbox"]:focus {
1329
+ outline: 2px solid var(--color-neutral-80);
1330
+ outline-offset: 3px;
1331
+ box-shadow: 0 0 0 4px var(--focus-ring-glow, rgba(219, 39, 119, 0.1));
1332
+ }
1333
+
1334
+ input[type="radio"] {
1335
+ width: 1.5rem;
1336
+ height: 1.5rem;
1337
+ margin: 0;
1338
+ border-radius: 50%;
1339
+ appearance: none;
1340
+ background-color: #ffffff;
1341
+ border: 2px solid var(--color-neutral-30);
1342
+ display: grid;
1343
+ place-content: center;
1344
+ cursor: pointer;
1345
+ flex-shrink: 0;
1346
+ transition: background-color 200ms ease, border-color 200ms ease;
1347
+ }
1348
+
1349
+ input[type="radio"]::before {
1350
+ content: "";
1351
+ width: 0.75rem;
1352
+ height: 0.75rem;
1353
+ border-radius: 50%;
1354
+ transform: scale(0);
1355
+ transition: 120ms transform ease-in-out;
1356
+ background-color: var(--color-brand-80);
1357
+ }
1358
+
1359
+ input[type="radio"]:checked {
1360
+ border-color: var(--color-brand-80);
1361
+ }
1362
+
1363
+ input[type="radio"]:checked::before {
1364
+ transform: scale(1);
1365
+ }
1366
+
1367
+ input[type="radio"]:hover {
1368
+ background-color: var(--color-brand-10);
1369
+ border-color: var(--color-brand-80);
1370
+ }
1371
+
1372
+ input[type="radio"]:focus {
1373
+ outline: 2px solid var(--color-neutral-80);
1374
+ outline-offset: 3px;
1375
+ border-radius: 50%;
1376
+ box-shadow: 0 0 0 4px var(--focus-ring-glow, rgba(219, 39, 119, 0.1));
1377
+ }
1378
+
1379
+ input[aria-invalid="true"] {
1380
+ border-color: var(--color-error-80) !important;
1381
+ border-width: 3px;
1382
+ }
1383
+
1384
+ .form-error-message {
1385
+ font-size: var(--text-sm, 14px);
1386
+ font-weight: var(--font-weight-bold, 700);
1387
+ color: var(--color-error-80);
1388
+ margin-top: var(--space-1, 0.25rem);
1389
+ display: block;
1390
+ }
1391
+
1392
+ .error-summary {
1393
+ border: 4px solid var(--color-error-80);
1394
+ padding: var(--space-6, 1.5rem);
1395
+ margin-bottom: var(--space-8, 2rem);
1396
+ border-radius: 8px;
1397
+ }
1398
+
1399
+ .error-summary ul {
1400
+ list-style: disc;
1401
+ padding-left: var(--space-5, 1.25rem);
1402
+ }
1403
+
1404
+ .error-summary a {
1405
+ color: var(--color-error-80);
1406
+ }
1407
+
1408
+ /* ---- Buttons ---- */
1409
+
1410
+ .btn {
1411
+ display: inline-flex;
1412
+ align-items: center;
1413
+ justify-content: center;
1414
+ padding: var(--space-3, 0.75rem) var(--space-6, 1.5rem);
1415
+ font-weight: var(--font-weight-semibold, 600);
1416
+ border-radius: 8px;
1417
+ cursor: pointer;
1418
+ transition: background-color 200ms ease, border-color 200ms ease, color 200ms ease;
1419
+ border: 2px solid transparent;
1420
+ text-align: center;
1421
+ min-height: 3rem;
1422
+ font-size: var(--text-base, 16px);
1423
+ text-decoration: none;
1424
+ font-family: inherit;
1425
+ line-height: 1;
1426
+ }
1427
+
1428
+ .btn-primary {
1429
+ background-color: var(--color-brand-80);
1430
+ color: #ffffff;
1431
+ border-color: transparent;
1432
+ }
1433
+
1434
+ .btn-primary:hover {
1435
+ background-color: var(--color-brand-90);
1436
+ }
1437
+
1438
+ .btn-primary:focus-visible {
1439
+ outline: 2px solid var(--color-neutral-80);
1440
+ outline-offset: 3px;
1441
+ box-shadow: 0 0 0 4px var(--focus-ring-glow, rgba(219, 39, 119, 0.1));
1442
+ }
1443
+
1444
+ .btn-secondary {
1445
+ background-color: #ffffff;
1446
+ color: var(--color-accent-80);
1447
+ border-color: var(--color-accent-80);
1448
+ }
1449
+
1450
+ .btn-secondary:hover {
1451
+ background-color: var(--color-accent-10);
1452
+ color: var(--color-accent-90);
1453
+ border-color: var(--color-accent-90);
1454
+ }
1455
+
1456
+ .btn-secondary:focus-visible {
1457
+ outline: 2px solid var(--color-neutral-80);
1458
+ outline-offset: 3px;
1459
+ box-shadow: 0 0 0 4px var(--focus-ring-glow, rgba(219, 39, 119, 0.1));
1460
+ }
1461
+
1462
+ .btn-ghost {
1463
+ background-color: transparent;
1464
+ color: var(--color-neutral-80);
1465
+ border-color: transparent;
1466
+ }
1467
+
1468
+ .btn-ghost:hover {
1469
+ background-color: var(--color-neutral-10);
1470
+ }
1471
+
1472
+ .btn-ghost:focus-visible {
1473
+ outline: 2px solid var(--color-neutral-80);
1474
+ outline-offset: 3px;
1475
+ box-shadow: 0 0 0 4px var(--focus-ring-glow, rgba(219, 39, 119, 0.1));
1476
+ }
1477
+
1478
+ .btn-danger {
1479
+ background-color: var(--color-error-80);
1480
+ color: #ffffff;
1481
+ border-color: transparent;
1482
+ }
1483
+
1484
+ .btn-danger:hover {
1485
+ background-color: var(--color-error-90);
1486
+ }
1487
+
1488
+ .btn-danger:focus-visible {
1489
+ outline: 2px solid var(--color-neutral-80);
1490
+ outline-offset: 3px;
1491
+ box-shadow: 0 0 0 4px var(--focus-ring-glow, rgba(219, 39, 119, 0.1));
1492
+ }
1493
+
1494
+ .btn-sm {
1495
+ padding: var(--space-2, 0.5rem) var(--space-4, 1rem);
1496
+ font-size: var(--text-sm, 14px);
1497
+ min-height: 2.25rem;
1498
+ }
1499
+
1500
+ .btn-lg {
1501
+ padding: var(--space-4, 1rem) var(--space-8, 2rem);
1502
+ font-size: var(--text-lg, 18px);
1503
+ min-height: 3.5rem;
1504
+ }
1505
+ `;
1506
+ }
1507
+
1508
+ // ============================================================================
1509
+ // BUILD FUNCTION
1510
+ // ============================================================================
1511
+
1511
1512
  function buildFullFramework() {
1513
+ validateConfigOrExit();
1512
1514
  const config = getConfig();
1513
-
1514
- console.log('Building EmilyCSS full framework...');
1515
-
1516
- const colours = generateAllColours(config.colours);
1517
- console.log(`✓ Generated ${Object.keys(colours).length} colour scales`);
1518
- if (config.semanticColours) {
1519
- console.log(`✓ Generated ${Object.keys(config.semanticColours).length} semantic colour tokens`);
1520
- }
1521
-
1522
- const spacing = generateSpacing(config.baseUnit, config.spacing.scale);
1523
- console.log(`✓ Generated ${Object.keys(spacing).length} spacing values`);
1524
-
1525
- const variablesCss = generateCSSVariables(colours, spacing, config);
1526
-
1527
- let utilityCss = '';
1528
- utilityCss += displayUtilities();
1529
- utilityCss += generateSpacingUtilities(spacing);
1530
- utilityCss += generateFlexboxUtilities(spacing);
1531
- utilityCss += generateGridUtilities(spacing);
1532
- utilityCss += sizingUtilities(spacing);
1533
- utilityCss += generateTypographyUtilities(config);
1534
- utilityCss += generateBorderUtilities(config);
1535
- utilityCss += generateColourUtilities(colours);
1536
- utilityCss += generateSemanticColourUtilities(config.semanticColours);
1537
- utilityCss += positioningUtilities(spacing);
1538
- utilityCss += overflowUtilities();
1539
- utilityCss += transformUtilities(spacing);
1540
- utilityCss += shadowUtilities();
1541
- utilityCss += ringUtilities(colours);
1542
- utilityCss += objectUtilities();
1543
- utilityCss += tableListUtilities();
1544
- utilityCss += svgUtilities(colours);
1545
- utilityCss += formUtilities();
1546
- utilityCss += verticalAlignUtilities();
1547
- utilityCss += contentScrollUtilities();
1548
- utilityCss += opacityUtilities();
1549
- utilityCss += transitionUtilities();
1550
- utilityCss += blendUtilities();
1551
- utilityCss += cursorUtilities();
1552
- utilityCss += accessibilityUtilities();
1553
- utilityCss += containerUtilities();
1554
- utilityCss += codeUtilities();
1555
- utilityCss += animationUtilities();
1556
- utilityCss += backdropUtilities();
1557
- utilityCss += spaceUtilities(spacing);
1558
- utilityCss += divideUtilities(spacing, colours);
1559
- utilityCss += backgroundUtilities();
1560
- utilityCss += filterUtilities();
1561
-
1562
- utilityCss = addStateVariants(utilityCss);
1563
- utilityCss = addAriaDataVariants(utilityCss);
1564
- utilityCss = addDarkModeVariants(utilityCss);
1565
- utilityCss = addResponsiveVariants(utilityCss, config);
1566
-
1567
- const { fontFace, bodyFont } = generateFontCSS(config);
1568
-
1569
- const fontLabel = typeof config.fontFamily === 'object'
1570
- ? 'heading: ' + (config.fontFamily.heading || 'system') + ', body: ' + (config.fontFamily.body || 'system')
1571
- : (config.fontFamily || 'system');
1572
-
1573
- console.log('✓ Font: ' + fontLabel);
1574
-
1575
- const baseCss = `
1576
- /* Box sizing */
1577
- *, *::before, *::after {
1578
- box-sizing: border-box;
1579
- }
1580
-
1581
- body, h1, h2, h3, h4, h5, h6, p,
1582
- ul, ol, dl, dd, figure, blockquote,
1583
- fieldset, textarea, pre {
1584
- margin: 0;
1585
- padding: 0;
1586
- }
1587
-
1588
- ul, ol {
1589
- list-style: none;
1590
- }
1591
-
1592
- input, button, textarea, select {
1593
- font: inherit;
1594
- }
1595
-
1596
- img, picture, video, canvas, svg {
1597
- display: block;
1598
- max-width: 100%;
1599
- }
1600
-
1601
- button {
1602
- background: none;
1603
- border: none;
1604
- cursor: pointer;
1605
- padding: 0;
1606
- }
1607
-
1608
- p, h1, h2, h3, h4, h5, h6 {
1609
- overflow-wrap: break-word;
1610
- }
1611
-
1612
- /* Base heading scale */
1613
- h1 {
1614
- font-size: var(--text-4xl, 36px);
1615
- line-height: var(--leading-4xl, 1.3);
1616
- font-weight: var(--font-weight-bold, 700);
1617
- margin-bottom: var(--space-6, 1.5rem);
1618
- }
1619
-
1620
- h2 {
1621
- font-size: var(--text-3xl, 30px);
1622
- line-height: var(--leading-3xl, 1.4);
1623
- font-weight: var(--font-weight-bold, 700);
1624
- margin-bottom: var(--space-5, 1.25rem);
1625
- }
1626
-
1627
- h3 {
1628
- font-size: var(--text-2xl, 24px);
1629
- line-height: var(--leading-2xl, 1.4);
1630
- font-weight: var(--font-weight-semibold, 600);
1631
- margin-bottom: var(--space-4, 1rem);
1632
- }
1633
-
1634
- h4 {
1635
- font-size: var(--text-xl, 20px);
1636
- line-height: var(--leading-xl, 1.6);
1637
- font-weight: var(--font-weight-semibold, 600);
1638
- margin-bottom: var(--space-3, 0.75rem);
1639
- }
1640
-
1641
- h5 {
1642
- font-size: var(--text-lg, 18px);
1643
- line-height: var(--leading-lg, 1.6);
1644
- font-weight: var(--font-weight-medium, 500);
1645
- margin-bottom: var(--space-3, 0.75rem);
1646
- }
1647
-
1648
- h6 {
1649
- font-size: var(--text-base, 16px);
1650
- line-height: var(--leading-base, 1.6);
1651
- font-weight: var(--font-weight-medium, 500);
1652
- margin-bottom: var(--space-2, 0.5rem);
1653
- }
1654
-
1655
- p {
1656
- font-size: var(--text-base, 16px);
1657
- line-height: var(--leading-base, 1.6);
1658
- margin-bottom: var(--space-4, 1rem);
1659
- }
1660
-
1661
- p:last-child {
1662
- margin-bottom: 0;
1663
- }
1664
-
1665
- code {
1666
- font-family: "Menlo", "Monaco", "Courier New", monospace;
1667
- font-size: 0.875em;
1668
- background-color: #0d0c0b;
1669
- color: #e6ffd2;
1670
- padding: 0.125rem 0.4rem;
1671
- border-radius: 4px;
1672
- display: inline;
1673
- }
1674
-
1675
- code.block {
1676
- display: block;
1677
- padding: 0.625rem 1rem;
1678
- border-radius: 6px;
1679
- font-size: 0.8125rem;
1680
- line-height: 1.6;
1681
- }
1682
-
1683
- pre {
1684
- background-color: #0d0c0b;
1685
- color: #e4e0db;
1686
- padding: 1.25rem;
1687
- border-radius: 0 0 6px;
1688
- overflow-x: auto;
1689
- font-family: "Menlo", "Monaco", "Courier New", monospace;
1690
- font-size: 0.875rem;
1691
- line-height: 1.7;
1692
- border: 1px solid #2a2520;
1693
- }
1694
-
1695
- pre code {
1696
- background: none;
1697
- padding: 0;
1698
- border-radius: 0;
1699
- color: inherit;
1700
- font-size: inherit;
1701
- font-family: inherit;
1702
- display: inline;
1703
- }
1704
- ${bodyFont}`;
1705
-
1706
- let css = fontFace ? `${fontFace}\n` : '';
1707
- css += `@layer theme, base, components, utilities;\n\n`;
1708
- css += `@layer theme {\n${variablesCss}}\n\n`;
1709
-
1710
- const baseStylesCss = generateBaseStyles(config);
1711
- css += `@layer base {${baseCss}${baseStylesCss}}\n\n`;
1712
- css += `@layer components {\n${generatePatternComponents()}}\n\n`;
1713
- css += `@layer utilities {\n${utilityCss}}\n`;
1714
-
1715
- const fullCssPath = getFullCssPath(config);
1716
-
1717
- ensureDirectoryForFile(fullCssPath);
1718
- fs.writeFileSync(fullCssPath, css);
1719
-
1720
- const manifestSettings = getManifestSettings(config);
1721
- const intellisenseSettings = getIntellisenseSettings(config);
1722
- const shouldGenerateManifestData =
1723
- manifestSettings.enabled || intellisenseSettings.enabled;
1724
- const manifestData = shouldGenerateManifestData
1725
- ? generateManifest(css, config)
1726
- : null;
1727
-
1728
- if (manifestSettings.enabled) {
1729
- const manifestPath = getManifestOutputPath(config);
1730
-
1731
- ensureDirectoryForFile(manifestPath);
1732
- fs.writeFileSync(manifestPath, JSON.stringify(manifestData, null, 2));
1733
- console.log(`✓ Generated manifest: ${manifestPath}`);
1734
- }
1735
-
1736
- if (intellisenseSettings.enabled) {
1737
- const intellisensePath = getIntellisenseOutputPath(config);
1738
- const intellisense = generateIntellisense(manifestData);
1739
-
1740
- ensureDirectoryForFile(intellisensePath);
1741
- fs.writeFileSync(intellisensePath, JSON.stringify(intellisense, null, 2));
1742
- console.log(`✓ Generated IntelliSense: ${intellisensePath}`);
1743
- }
1744
-
1745
- console.log(`✓ Generated CSS: ${fullCssPath}`);
1746
- console.log(`✓ File size: ${(css.length / 1024).toFixed(2)} KB (unminified)`);
1747
- console.log('Full framework build complete');
1748
- }
1749
-
1750
- function minify(css) {
1751
- return css
1752
- .replace(/\/\*[\s\S]*?\*\//g, '')
1753
- .replace(/\s+/g, ' ')
1754
- .replace(/\s?\{/g, '{')
1755
- .replace(/\s?\}/g, '}')
1756
- .replace(/;\s/g, ';')
1757
- .trim();
1758
- }
1759
-
1760
- function buildProductionCss(options = {}) {
1761
- const config = getConfig();
1762
- const sourceDir = getSourceDir(config);
1763
- const fullCssPath = getFullCssPath(config);
1764
- const productionCssPath = getProductionCssPath(config);
1765
- const profile = {
1766
- purge: 0,
1767
- minify: 0,
1768
- write: 0,
1769
- };
1770
-
1771
- if (!fs.existsSync(fullCssPath)) {
1772
- buildFullFramework();
1773
- }
1774
-
1775
- const { purgeCSS } = require('./purge.js');
1776
- const css = fs.readFileSync(fullCssPath, 'utf8');
1777
- const purgeStart = Date.now();
1778
- const purged = purgeCSS(css, sourceDir, config);
1779
- profile.purge = Date.now() - purgeStart;
1780
-
1781
- const minifyStart = Date.now();
1782
- const minified = minify(purged);
1783
- profile.minify = Date.now() - minifyStart;
1784
-
1785
- const writeStart = Date.now();
1786
- ensureDirectoryForFile(productionCssPath);
1787
- fs.writeFileSync(productionCssPath, minified);
1788
- profile.write = Date.now() - writeStart;
1789
-
1790
- return {
1791
- css,
1792
- purged,
1793
- minified,
1794
- originalSize: Buffer.byteLength(css, 'utf8'),
1795
- outputSize: Buffer.byteLength(minified, 'utf8'),
1796
- outputPath: productionCssPath,
1797
- fullCssPath,
1798
- profile: options.profile ? profile : undefined,
1799
- };
1800
- }
1801
-
1802
- function isFrameworkStale() {
1803
- const config = getConfig();
1804
- const configPath = getConfigPath();
1805
- const fullCssPath = getFullCssPath(config);
1806
-
1807
- if (!fs.existsSync(fullCssPath)) return true;
1808
- if (!fs.existsSync(configPath)) return true;
1809
-
1810
- return fs.statSync(configPath).mtimeMs > fs.statSync(fullCssPath).mtimeMs;
1811
- }
1812
-
1813
- function ensureFullFramework() {
1814
- if (isFrameworkStale()) {
1815
- buildFullFramework();
1816
- }
1817
- }
1818
-
1819
- function build(options = {}) {
1820
- const totalStart = Date.now();
1821
- const frameworkStart = Date.now();
1822
- ensureFullFramework();
1823
- const frameworkMs = Date.now() - frameworkStart;
1824
-
1825
- const config = getConfig();
1826
- const fullCssPath = getFullCssPath(config);
1827
- const result = buildProductionCss({ profile: options.profile });
1828
-
1829
- console.log('\u2713 Generated production CSS: ' + path.relative(process.cwd(), result.outputPath));
1830
- console.log('\u2713 File size: ' + (result.outputSize / 1024).toFixed(2) + ' KB');
1831
-
1832
- if (!options.keepFull && fs.existsSync(fullCssPath)) {
1833
- try {
1834
- fs.unlinkSync(fullCssPath);
1835
- console.log('Removed ' + path.relative(process.cwd(), fullCssPath) + ' for production build.');
1836
- } catch (error) {
1837
- // Windows FUSE: cannot always delete files cleanly, non-fatal.
1838
- }
1839
- }
1840
-
1841
- if (options.profile) {
1842
- const timings = result.profile || { purge: 0, minify: 0, write: 0 };
1843
- const totalMs = Date.now() - totalStart;
1844
-
1845
- console.log('\nEmilyCSS build profile\n');
1846
- console.log('Framework: ' + frameworkMs + 'ms');
1847
- console.log('Purge: ' + timings.purge + 'ms');
1848
- console.log('Minify: ' + timings.minify + 'ms');
1849
- console.log('Write: ' + timings.write + 'ms');
1850
- console.log('Total: ' + totalMs + 'ms');
1851
- }
1852
-
1853
- console.log('Build complete');
1854
- }
1855
-
1856
- if (require.main === module) {
1857
- const args = process.argv.slice(2);
1858
- build({
1859
- keepFull: args.includes('--keep-full'),
1860
- profile: args.includes('--profile'),
1861
- });
1862
- }
1863
-
1864
- module.exports = {
1865
- build,
1866
- buildFullFramework,
1867
- buildProductionCss,
1868
- ensureFullFramework,
1869
- hexToOklch,
1870
- oklchToHex,
1871
- generateColourScale,
1872
- generateAllColours,
1873
- generateFontCSS,
1874
- generateSpacing,
1875
- generateBorderUtilities,
1876
- generateColourUtilities,
1877
- generateSemanticColourUtilities,
1878
- generateTypographyUtilities,
1879
- generateSpacingUtilities,
1880
- generateFlexboxUtilities,
1881
- generateGridUtilities,
1882
- addStateVariants,
1883
- addAriaDataVariants,
1884
- addResponsiveVariants,
1885
- generateManifest,
1886
- };
1515
+
1516
+ console.log('Building EmilyCSS full framework...');
1517
+
1518
+ const colours = generateAllColours(config.colours);
1519
+ console.log(`✓ Generated ${Object.keys(colours).length} colour scales`);
1520
+ if (config.semanticColours) {
1521
+ console.log(`✓ Generated ${Object.keys(config.semanticColours).length} semantic colour tokens`);
1522
+ }
1523
+
1524
+ const spacing = generateSpacing(config.baseUnit, config.spacing.scale);
1525
+ console.log(`✓ Generated ${Object.keys(spacing).length} spacing values`);
1526
+
1527
+ const variablesCss = generateCSSVariables(colours, spacing, config);
1528
+
1529
+ let utilityCss = '';
1530
+ utilityCss += displayUtilities();
1531
+ utilityCss += generateSpacingUtilities(spacing);
1532
+ utilityCss += generateFlexboxUtilities(spacing);
1533
+ utilityCss += generateGridUtilities(spacing);
1534
+ utilityCss += sizingUtilities(spacing);
1535
+ utilityCss += generateTypographyUtilities(config);
1536
+ utilityCss += generateBorderUtilities(config);
1537
+ utilityCss += generateColourUtilities(colours);
1538
+ utilityCss += generateSemanticColourUtilities(config.semanticColours);
1539
+ utilityCss += positioningUtilities(spacing);
1540
+ utilityCss += overflowUtilities();
1541
+ utilityCss += transformUtilities(spacing);
1542
+ utilityCss += shadowUtilities();
1543
+ utilityCss += ringUtilities(colours);
1544
+ utilityCss += objectUtilities();
1545
+ utilityCss += tableListUtilities();
1546
+ utilityCss += svgUtilities(colours);
1547
+ utilityCss += formUtilities();
1548
+ utilityCss += verticalAlignUtilities();
1549
+ utilityCss += contentScrollUtilities();
1550
+ utilityCss += opacityUtilities();
1551
+ utilityCss += transitionUtilities();
1552
+ utilityCss += blendUtilities();
1553
+ utilityCss += cursorUtilities();
1554
+ utilityCss += accessibilityUtilities();
1555
+ utilityCss += containerUtilities();
1556
+ utilityCss += codeUtilities();
1557
+ utilityCss += animationUtilities();
1558
+ utilityCss += backdropUtilities();
1559
+ utilityCss += spaceUtilities(spacing);
1560
+ utilityCss += divideUtilities(spacing, colours);
1561
+ utilityCss += backgroundUtilities();
1562
+ utilityCss += filterUtilities();
1563
+
1564
+ utilityCss = addStateVariants(utilityCss);
1565
+ utilityCss = addAriaDataVariants(utilityCss);
1566
+ utilityCss = addDarkModeVariants(utilityCss);
1567
+ utilityCss = addResponsiveVariants(utilityCss, config);
1568
+
1569
+ const { fontFace, bodyFont } = generateFontCSS(config);
1570
+
1571
+ const fontLabel = typeof config.fontFamily === 'object'
1572
+ ? 'heading: ' + (config.fontFamily.heading || 'system') + ', body: ' + (config.fontFamily.body || 'system')
1573
+ : (config.fontFamily || 'system');
1574
+
1575
+ console.log('✓ Font: ' + fontLabel);
1576
+
1577
+ const htmlFontSize = (config.baseFontSize && config.baseFontSize !== "16px")
1578
+ ? `\n html { font-size: ${config.baseFontSize}; }\n`
1579
+ : "";
1580
+
1581
+ const baseCss = `${htmlFontSize}
1582
+ /* Box sizing */
1583
+ *, *::before, *::after {
1584
+ box-sizing: border-box;
1585
+ }
1586
+
1587
+ body, h1, h2, h3, h4, h5, h6, p,
1588
+ ul, ol, dl, dd, figure, blockquote,
1589
+ fieldset, textarea, pre {
1590
+ margin: 0;
1591
+ padding: 0;
1592
+ }
1593
+
1594
+ ul, ol {
1595
+ list-style: none;
1596
+ }
1597
+
1598
+ input, button, textarea, select {
1599
+ font: inherit;
1600
+ }
1601
+
1602
+ img, picture, video, canvas, svg {
1603
+ display: block;
1604
+ max-width: 100%;
1605
+ }
1606
+
1607
+ button {
1608
+ background: none;
1609
+ border: none;
1610
+ cursor: pointer;
1611
+ padding: 0;
1612
+ }
1613
+
1614
+ p, h1, h2, h3, h4, h5, h6 {
1615
+ overflow-wrap: break-word;
1616
+ }
1617
+
1618
+ /* Base heading scale */
1619
+ h1 {
1620
+ font-size: var(--text-4xl, 36px);
1621
+ line-height: var(--leading-4xl, 1.3);
1622
+ font-weight: var(--font-weight-bold, 700);
1623
+ margin-bottom: var(--space-6, 1.5rem);
1624
+ }
1625
+
1626
+ h2 {
1627
+ font-size: var(--text-3xl, 30px);
1628
+ line-height: var(--leading-3xl, 1.4);
1629
+ font-weight: var(--font-weight-bold, 700);
1630
+ margin-bottom: var(--space-5, 1.25rem);
1631
+ }
1632
+
1633
+ h3 {
1634
+ font-size: var(--text-2xl, 24px);
1635
+ line-height: var(--leading-2xl, 1.4);
1636
+ font-weight: var(--font-weight-semibold, 600);
1637
+ margin-bottom: var(--space-4, 1rem);
1638
+ }
1639
+
1640
+ h4 {
1641
+ font-size: var(--text-xl, 20px);
1642
+ line-height: var(--leading-xl, 1.6);
1643
+ font-weight: var(--font-weight-semibold, 600);
1644
+ margin-bottom: var(--space-3, 0.75rem);
1645
+ }
1646
+
1647
+ h5 {
1648
+ font-size: var(--text-lg, 18px);
1649
+ line-height: var(--leading-lg, 1.6);
1650
+ font-weight: var(--font-weight-medium, 500);
1651
+ margin-bottom: var(--space-3, 0.75rem);
1652
+ }
1653
+
1654
+ h6 {
1655
+ font-size: var(--text-base, 16px);
1656
+ line-height: var(--leading-base, 1.6);
1657
+ font-weight: var(--font-weight-medium, 500);
1658
+ margin-bottom: var(--space-2, 0.5rem);
1659
+ }
1660
+
1661
+ p {
1662
+ font-size: var(--text-base, 16px);
1663
+ line-height: var(--leading-base, 1.6);
1664
+ margin-bottom: var(--space-4, 1rem);
1665
+ }
1666
+
1667
+ p:last-child {
1668
+ margin-bottom: 0;
1669
+ }
1670
+
1671
+ code {
1672
+ font-family: "Menlo", "Monaco", "Courier New", monospace;
1673
+ font-size: 0.875em;
1674
+ background-color: #0d0c0b;
1675
+ color: #e6ffd2;
1676
+ padding: 0.125rem 0.4rem;
1677
+ border-radius: 4px;
1678
+ display: inline;
1679
+ }
1680
+
1681
+ code.block {
1682
+ display: block;
1683
+ padding: 0.625rem 1rem;
1684
+ border-radius: 6px;
1685
+ font-size: 0.8125rem;
1686
+ line-height: 1.6;
1687
+ }
1688
+
1689
+ pre {
1690
+ background-color: #0d0c0b;
1691
+ color: #e4e0db;
1692
+ padding: 1.25rem;
1693
+ border-radius: 0 0 6px;
1694
+ overflow-x: auto;
1695
+ font-family: "Menlo", "Monaco", "Courier New", monospace;
1696
+ font-size: 0.875rem;
1697
+ line-height: 1.7;
1698
+ border: 1px solid #2a2520;
1699
+ }
1700
+
1701
+ pre code {
1702
+ background: none;
1703
+ padding: 0;
1704
+ border-radius: 0;
1705
+ color: inherit;
1706
+ font-size: inherit;
1707
+ font-family: inherit;
1708
+ display: inline;
1709
+ }
1710
+ ${bodyFont}`;
1711
+
1712
+ let css = fontFace ? `${fontFace}\n` : '';
1713
+ css += `@layer theme, base, components, utilities;\n\n`;
1714
+ css += `@layer theme {\n${variablesCss}}\n\n`;
1715
+
1716
+ const baseStylesCss = generateBaseStyles(config);
1717
+ css += `@layer base {${baseCss}${baseStylesCss}}\n\n`;
1718
+ css += `@layer components {\n${generatePatternComponents()}}\n\n`;
1719
+ css += `@layer utilities {\n${utilityCss}}\n`;
1720
+
1721
+ const fullCssPath = getFullCssPath(config);
1722
+
1723
+ ensureDirectoryForFile(fullCssPath);
1724
+ fs.writeFileSync(fullCssPath, css);
1725
+
1726
+ const manifestSettings = getManifestSettings(config);
1727
+ const intellisenseSettings = getIntellisenseSettings(config);
1728
+ const shouldGenerateManifestData =
1729
+ manifestSettings.enabled || intellisenseSettings.enabled;
1730
+ const manifestData = shouldGenerateManifestData
1731
+ ? generateManifest(css, config)
1732
+ : null;
1733
+
1734
+ if (manifestSettings.enabled) {
1735
+ const manifestPath = getManifestOutputPath(config);
1736
+
1737
+ ensureDirectoryForFile(manifestPath);
1738
+ fs.writeFileSync(manifestPath, JSON.stringify(manifestData, null, 2));
1739
+ console.log(`✓ Generated manifest: ${manifestPath}`);
1740
+ }
1741
+
1742
+ if (intellisenseSettings.enabled) {
1743
+ const intellisensePath = getIntellisenseOutputPath(config);
1744
+ const intellisense = generateIntellisense(manifestData);
1745
+
1746
+ ensureDirectoryForFile(intellisensePath);
1747
+ fs.writeFileSync(intellisensePath, JSON.stringify(intellisense, null, 2));
1748
+ console.log(`✓ Generated IntelliSense: ${intellisensePath}`);
1749
+ }
1750
+
1751
+ console.log(`✓ Generated CSS: ${fullCssPath}`);
1752
+ console.log(`✓ File size: ${(css.length / 1024).toFixed(2)} KB (unminified)`);
1753
+ console.log('Full framework build complete');
1754
+ }
1755
+
1756
+ function minify(css) {
1757
+ return css
1758
+ .replace(/\/\*[\s\S]*?\*\//g, '')
1759
+ .replace(/\s+/g, ' ')
1760
+ .replace(/\s?\{/g, '{')
1761
+ .replace(/\s?\}/g, '}')
1762
+ .replace(/;\s/g, ';')
1763
+ .trim();
1764
+ }
1765
+
1766
+ function buildProductionCss(options = {}) {
1767
+ const config = getConfig();
1768
+ const sourceDir = getSourceDir(config);
1769
+ const fullCssPath = getFullCssPath(config);
1770
+ const productionCssPath = getProductionCssPath(config);
1771
+ const profile = {
1772
+ purge: 0,
1773
+ minify: 0,
1774
+ write: 0,
1775
+ };
1776
+
1777
+ if (!fs.existsSync(fullCssPath)) {
1778
+ buildFullFramework();
1779
+ }
1780
+
1781
+ const { purgeCSS } = require('./purge.js');
1782
+ const css = fs.readFileSync(fullCssPath, 'utf8');
1783
+ const purgeStart = Date.now();
1784
+ const purged = purgeCSS(css, sourceDir, config);
1785
+ profile.purge = Date.now() - purgeStart;
1786
+
1787
+ const minifyStart = Date.now();
1788
+ const minified = minify(purged);
1789
+ profile.minify = Date.now() - minifyStart;
1790
+
1791
+ const writeStart = Date.now();
1792
+ ensureDirectoryForFile(productionCssPath);
1793
+ fs.writeFileSync(productionCssPath, minified);
1794
+ profile.write = Date.now() - writeStart;
1795
+
1796
+ return {
1797
+ css,
1798
+ purged,
1799
+ minified,
1800
+ originalSize: Buffer.byteLength(css, 'utf8'),
1801
+ outputSize: Buffer.byteLength(minified, 'utf8'),
1802
+ outputPath: productionCssPath,
1803
+ fullCssPath,
1804
+ profile: options.profile ? profile : undefined,
1805
+ };
1806
+ }
1807
+
1808
+ function isFrameworkStale() {
1809
+ const config = getConfig();
1810
+ const configPath = getConfigPath();
1811
+ const fullCssPath = getFullCssPath(config);
1812
+
1813
+ if (!fs.existsSync(fullCssPath)) return true;
1814
+ if (!fs.existsSync(configPath)) return true;
1815
+
1816
+ return fs.statSync(configPath).mtimeMs > fs.statSync(fullCssPath).mtimeMs;
1817
+ }
1818
+
1819
+ function ensureFullFramework() {
1820
+ if (isFrameworkStale()) {
1821
+ buildFullFramework();
1822
+ }
1823
+ }
1824
+
1825
+ function build(options = {}) {
1826
+ const totalStart = Date.now();
1827
+ const frameworkStart = Date.now();
1828
+ ensureFullFramework();
1829
+ const frameworkMs = Date.now() - frameworkStart;
1830
+
1831
+ const config = getConfig();
1832
+ const fullCssPath = getFullCssPath(config);
1833
+ const result = buildProductionCss({ profile: options.profile });
1834
+
1835
+ console.log('\u2713 Generated production CSS: ' + path.relative(process.cwd(), result.outputPath));
1836
+ console.log('\u2713 File size: ' + (result.outputSize / 1024).toFixed(2) + ' KB');
1837
+
1838
+ if (!options.keepFull && fs.existsSync(fullCssPath)) {
1839
+ try {
1840
+ fs.unlinkSync(fullCssPath);
1841
+ console.log('Removed ' + path.relative(process.cwd(), fullCssPath) + ' for production build.');
1842
+ } catch (error) {
1843
+ // Windows FUSE: cannot always delete files cleanly, non-fatal.
1844
+ }
1845
+ }
1846
+
1847
+ if (options.profile) {
1848
+ const timings = result.profile || { purge: 0, minify: 0, write: 0 };
1849
+ const totalMs = Date.now() - totalStart;
1850
+
1851
+ console.log('\nEmilyCSS build profile\n');
1852
+ console.log('Framework: ' + frameworkMs + 'ms');
1853
+ console.log('Purge: ' + timings.purge + 'ms');
1854
+ console.log('Minify: ' + timings.minify + 'ms');
1855
+ console.log('Write: ' + timings.write + 'ms');
1856
+ console.log('Total: ' + totalMs + 'ms');
1857
+ }
1858
+
1859
+ console.log('Build complete');
1860
+ }
1861
+
1862
+ if (require.main === module) {
1863
+ const args = process.argv.slice(2);
1864
+ build({
1865
+ keepFull: args.includes('--keep-full'),
1866
+ profile: args.includes('--profile'),
1867
+ });
1868
+ }
1869
+
1870
+ module.exports = {
1871
+ build,
1872
+ buildFullFramework,
1873
+ buildProductionCss,
1874
+ ensureFullFramework,
1875
+ hexToOklch,
1876
+ oklchToHex,
1877
+ generateColourScale,
1878
+ generateAllColours,
1879
+ generateFontCSS,
1880
+ generateSpacing,
1881
+ generateBorderUtilities,
1882
+ generateColourUtilities,
1883
+ generateSemanticColourUtilities,
1884
+ generateTypographyUtilities,
1885
+ generateSpacingUtilities,
1886
+ generateFlexboxUtilities,
1887
+ generateGridUtilities,
1888
+ addStateVariants,
1889
+ addAriaDataVariants,
1890
+ addResponsiveVariants,
1891
+ generateManifest,
1892
+ };