emily-css 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/index.js ADDED
@@ -0,0 +1,952 @@
1
+ // new version
2
+
3
+
4
+ const fs = require('fs');
5
+ const path = require('path');
6
+
7
+
8
+ // ============================================================================
9
+ // COLOUR GENERATION
10
+ // ============================================================================
11
+ // Generate 10-shade colour scale using OKLCH (perceptually uniform colour space)
12
+ // OKLCH produces visually even steps across all hues — unlike HSL which creates
13
+ // muddy mid-tones on warm colours (yellows, greens, oranges).
14
+ //
15
+ // Conversion pipeline: Hex → sRGB → Linear RGB → OKLab → OKLCH → (modify L) → reverse
16
+ // No external dependencies. Maths from Björn Ottosson's OKLab specification.
17
+ //
18
+ // Input: #0077b6 → Output: { 10: '#...', 20: '#...', ..., 100: '#...' }
19
+
20
+ // sRGB component to linear light
21
+ function srgbToLinear(c) {
22
+ const val = c / 255;
23
+ return val <= 0.04045 ? val / 12.92 : Math.pow((val + 0.055) / 1.055, 2.4);
24
+ }
25
+
26
+ // Linear light component to sRGB (clamped to 0–255)
27
+ function linearToSrgb(c) {
28
+ const clamped = Math.max(0, Math.min(1, c));
29
+ const out = clamped <= 0.0031308
30
+ ? 12.92 * clamped
31
+ : 1.055 * Math.pow(clamped, 1 / 2.4) - 0.055;
32
+ return Math.round(Math.max(0, Math.min(1, out)) * 255);
33
+ }
34
+
35
+ // Hex string → OKLCH { l, c, h }
36
+ function hexToOklch(hex) {
37
+ const r = srgbToLinear(parseInt(hex.slice(1, 3), 16));
38
+ const g = srgbToLinear(parseInt(hex.slice(3, 5), 16));
39
+ const b = srgbToLinear(parseInt(hex.slice(5, 7), 16));
40
+
41
+ // Linear RGB → OKLab (M1 matrix then cube-root then M2 matrix)
42
+ const l = Math.cbrt(0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b);
43
+ const m = Math.cbrt(0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b);
44
+ const s = Math.cbrt(0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b);
45
+
46
+ const L = 0.2104542553 * l + 0.7936177850 * m - 0.0040720468 * s;
47
+ const a = 1.9779984951 * l - 2.4285922050 * m + 0.4505937099 * s;
48
+ const bv = 0.0259040371 * l + 0.7827717662 * m - 0.8086757660 * s;
49
+
50
+ // OKLab → OKLCH
51
+ const C = Math.sqrt(a * a + bv * bv);
52
+ const H = (Math.atan2(bv, a) * 180) / Math.PI;
53
+
54
+ return { l: L, c: C, h: H < 0 ? H + 360 : H };
55
+ }
56
+
57
+ // OKLCH { l, c, h } → hex string
58
+ function oklchToHex(l, c, h) {
59
+ // OKLCH → OKLab
60
+ const hRad = (h * Math.PI) / 180;
61
+ const a = c * Math.cos(hRad);
62
+ const bv = c * Math.sin(hRad);
63
+
64
+ // OKLab → Linear RGB (M2 inverse then cube then M1 inverse)
65
+ const l_ = l + 0.3963377774 * a + 0.2158037573 * bv;
66
+ const m_ = l - 0.1055613458 * a - 0.0638541728 * bv;
67
+ const s_ = l - 0.0894841775 * a - 1.2914855480 * bv;
68
+
69
+ const lc = l_ * l_ * l_;
70
+ const mc = m_ * m_ * m_;
71
+ const sc = s_ * s_ * s_;
72
+
73
+ const r = 4.0767416621 * lc - 3.3077115913 * mc + 0.2309699292 * sc;
74
+ const g = -1.2684380046 * lc + 2.6097574011 * mc - 0.3413193965 * sc;
75
+ const b = -0.0041960863 * lc - 0.7034186147 * mc + 1.7076147010 * sc;
76
+
77
+ const rOut = linearToSrgb(r).toString(16).padStart(2, '0');
78
+ const gOut = linearToSrgb(g).toString(16).padStart(2, '0');
79
+ const bOut = linearToSrgb(b).toString(16).padStart(2, '0');
80
+
81
+ return `#${rOut}${gOut}${bOut}`.toUpperCase();
82
+ }
83
+
84
+ function generateColourScale(baseHex) {
85
+ const { l: baseL, c: baseC, h: baseH } = hexToOklch(baseHex);
86
+ const scale = {};
87
+
88
+ // Shade scale: 10 = near-white, 80 = exact input colour, 100 = near-black
89
+ // Lightness targets in OKLCH (0–1 scale):
90
+ // shade 10 → L ≈ 0.97 (very light tint)
91
+ // shade 80 → L = baseL (exact input)
92
+ // shade 100 → L ≈ 0.15 (very dark tone)
93
+ //
94
+ // Chroma is preserved from the base colour throughout — hue is never shifted.
95
+ // At extreme lightness values chroma is gently reduced to stay in sRGB gamut.
96
+
97
+ const LIGHT_L = 0.97; // shade 10 lightness
98
+ const DARK_L = 0.15; // shade 100 lightness
99
+
100
+ const steps = [10, 20, 30, 40, 50, 60, 70, 80, 90, 100];
101
+
102
+ steps.forEach(step => {
103
+ if (step === 80) {
104
+ scale[step] = baseHex.toUpperCase();
105
+ return;
106
+ }
107
+
108
+ let newL;
109
+ if (step < 80) {
110
+ // 10–70: interpolate from LIGHT_L down to baseL
111
+ const t = (step / 80); // 0 → 1 as step goes 0 → 80
112
+ newL = LIGHT_L - t * (LIGHT_L - baseL);
113
+ } else {
114
+ // 90–100: interpolate from baseL down to DARK_L
115
+ const t = (step - 80) / 20; // 0 → 1 as step goes 80 → 100
116
+ newL = baseL - t * (baseL - DARK_L);
117
+ }
118
+
119
+ // Reduce chroma slightly at extremes to avoid out-of-gamut clipping
120
+ const chromaScale = 1 - Math.max(0, (newL - 0.90) / 0.07) * 0.5 // reduce near white
121
+ - Math.max(0, (0.25 - newL) / 0.10) * 0.5; // reduce near black
122
+ const newC = baseC * Math.max(0, chromaScale);
123
+
124
+ scale[step] = oklchToHex(newL, newC, baseH);
125
+ });
126
+
127
+ return scale;
128
+ }
129
+
130
+ function generateAllColours(colourConfig) {
131
+ const allColours = {};
132
+
133
+ Object.entries(colourConfig).forEach(([name, baseHex]) => {
134
+ allColours[name] = generateColourScale(baseHex);
135
+ });
136
+
137
+ return allColours;
138
+ }
139
+
140
+ // ============================================================================
141
+ // SPACING SCALE
142
+ // ============================================================================
143
+
144
+ function generateSpacing(baseUnit, scale) {
145
+ // scale is now a key-value object from config
146
+ // Just return it as-is since values are already defined (e.g., "1rem", "4px")
147
+ return scale;
148
+ }
149
+
150
+ // ============================================================================
151
+ // FONT PRESETS
152
+ // ============================================================================
153
+
154
+ // Bundled fonts live in fonts/{name}/ relative to the package root.
155
+ // The generated CSS lives in dist/, so relative paths use ../fonts/...
156
+ const FONT_PRESETS = {
157
+ 'system': {
158
+ stack: 'system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
159
+ bundled: false,
160
+ },
161
+ 'inter': {
162
+ name: 'Inter',
163
+ stack: '"Inter", system-ui, sans-serif',
164
+ bundled: true,
165
+ file: '../fonts/inter/Inter-Variable.woff2',
166
+ weight: '100 900',
167
+ style: 'normal',
168
+ },
169
+ 'lexend': {
170
+ name: 'Lexend',
171
+ stack: '"Lexend", system-ui, sans-serif',
172
+ bundled: true,
173
+ file: '../fonts/lexend/Lexend-Variable.woff2',
174
+ weight: '100 900',
175
+ style: 'normal',
176
+ },
177
+ 'georgia': {
178
+ stack: 'Georgia, "Times New Roman", serif',
179
+ bundled: false,
180
+ },
181
+ 'mono': {
182
+ stack: '"Menlo", "Monaco", "Courier New", monospace',
183
+ bundled: false,
184
+ },
185
+ };
186
+
187
+ function generateFontCSS(config) {
188
+ const fontFamily = (config.fontFamily || 'system').toLowerCase();
189
+ const preset = FONT_PRESETS[fontFamily] || FONT_PRESETS['system'];
190
+
191
+ let fontFace = '';
192
+ let bodyFont = '';
193
+
194
+ if (preset.bundled) {
195
+ fontFace += `@font-face {\n`;
196
+ fontFace += ` font-family: "${preset.name}";\n`;
197
+ fontFace += ` src: url("${preset.file}") format("woff2");\n`;
198
+ fontFace += ` font-weight: ${preset.weight};\n`;
199
+ fontFace += ` font-style: ${preset.style};\n`;
200
+ fontFace += ` font-display: swap;\n`;
201
+ fontFace += `}\n`;
202
+ }
203
+
204
+ bodyFont = ` body {\n font-family: ${preset.stack};\n font-synthesis: style;\n }\n`;
205
+
206
+ return { fontFace, bodyFont };
207
+ }
208
+
209
+ // ============================================================================
210
+ // CSS VARIABLE GENERATION
211
+ // ============================================================================
212
+
213
+ function generateCSSVariables(colours, spacing, config) {
214
+ let css = `:root {\n`;
215
+
216
+ // Colour variables
217
+ Object.entries(colours).forEach(([colourName, shades]) => {
218
+ Object.entries(shades).forEach(([shade, hex]) => {
219
+ css += ` --color-${colourName}-${shade}: ${hex};\n`;
220
+ });
221
+ });
222
+
223
+ // Spacing variables
224
+ Object.entries(spacing).forEach(([key, value]) => {
225
+ css += ` --space-${key}: ${value};\n`;
226
+ });
227
+
228
+ // Font size variables with line-height
229
+ config.typography.fontSizes.forEach(fontSize => {
230
+ const sizeVal = parseInt(fontSize.value);
231
+ css += ` --text-${fontSize.name}: ${fontSize.value};\n`;
232
+ css += ` --leading-${fontSize.name}: ${fontSize.lineHeight};\n`;
233
+ });
234
+
235
+ // Font weight variables
236
+ Object.entries(config.typography.fontWeights).forEach(([name, weight]) => {
237
+ css += ` --font-weight-${name}: ${weight};\n`;
238
+ });
239
+
240
+ // Breakpoints
241
+ Object.entries(config.breakpoints).forEach(([name, value]) => {
242
+ css += ` --breakpoint-${name}: ${value};\n`;
243
+ });
244
+
245
+ // Shadows
246
+ Object.entries(config.shadows).forEach(([name, shadow]) => {
247
+ css += ` --shadow-${name}: ${shadow};\n`;
248
+ });
249
+
250
+ // Z-index
251
+ Object.entries(config.zIndex).forEach(([name, value]) => {
252
+ css += ` --z-${name}: ${value};\n`;
253
+ });
254
+
255
+ // Transitions
256
+ css += ` --transition-duration: ${config.transitions.base};\n`;
257
+ css += ` --transition-timing: ${config.transitions.timing};\n`;
258
+
259
+ css += `}\n\n`;
260
+ return css;
261
+ }
262
+
263
+ const {
264
+ displayUtilities,
265
+ sizingUtilities,
266
+ positioningUtilities,
267
+ overflowUtilities,
268
+ opacityUtilities,
269
+ transitionUtilities,
270
+ transformUtilities,
271
+ shadowUtilities,
272
+ ringUtilities,
273
+ objectUtilities,
274
+ tableListUtilities,
275
+ svgUtilities,
276
+ formUtilities,
277
+ verticalAlignUtilities,
278
+ contentScrollUtilities,
279
+ blendUtilities,
280
+ cursorUtilities,
281
+ accessibilityUtilities,
282
+ containerUtilities
283
+ } = require('./generators');
284
+
285
+ // ============================================================================
286
+ // SPACING UTILITIES
287
+ // ============================================================================
288
+
289
+ function escapeClassName(key) {
290
+ // Escape dots in class names for CSS (e.g., "0.5" becomes "0\.5")
291
+ return key.replace(/\./g, '\\.');
292
+ }
293
+
294
+ function generateSpacingUtilities(spacing) {
295
+ let css = `/* Spacing: Padding & Margin */\n`;
296
+
297
+ // Padding
298
+ Object.entries(spacing).forEach(([key, value]) => {
299
+ const escaped = escapeClassName(key);
300
+ css += `.p-${escaped} { padding: ${value}; }\n`;
301
+ css += `.px-${escaped} { padding-left: ${value}; padding-right: ${value}; }\n`;
302
+ css += `.py-${escaped} { padding-top: ${value}; padding-bottom: ${value}; }\n`;
303
+ css += `.pt-${escaped} { padding-top: ${value}; }\n`;
304
+ css += `.pr-${escaped} { padding-right: ${value}; }\n`;
305
+ css += `.pb-${escaped} { padding-bottom: ${value}; }\n`;
306
+ css += `.pl-${escaped} { padding-left: ${value}; }\n`;
307
+ css += `.ps-${escaped} { padding-inline-start: ${value}; }\n`;
308
+ css += `.pe-${escaped} { padding-inline-end: ${value}; }\n`;
309
+ });
310
+
311
+ // Margin
312
+ Object.entries(spacing).forEach(([key, value]) => {
313
+ const escaped = escapeClassName(key);
314
+ css += `.m-${escaped} { margin: ${value}; }\n`;
315
+ css += `.mx-${escaped} { margin-left: ${value}; margin-right: ${value}; }\n`;
316
+ css += `.my-${escaped} { margin-top: ${value}; margin-bottom: ${value}; }\n`;
317
+ css += `.mt-${escaped} { margin-top: ${value}; }\n`;
318
+ css += `.mr-${escaped} { margin-right: ${value}; }\n`;
319
+ css += `.mb-${escaped} { margin-bottom: ${value}; }\n`;
320
+ css += `.ml-${escaped} { margin-left: ${value}; }\n`;
321
+ css += `.ms-${escaped} { margin-inline-start: ${value}; }\n`;
322
+ css += `.me-${escaped} { margin-inline-end: ${value}; }\n`;
323
+ });
324
+
325
+ // Margin auto
326
+ css += `.mx-auto { margin-left: auto; margin-right: auto; }\n`;
327
+ css += `.my-auto { margin-top: auto; margin-bottom: auto; }\n`;
328
+
329
+ css += `\n`;
330
+ return css;
331
+ }
332
+
333
+ // ============================================================================
334
+ // FLEXBOX UTILITIES
335
+ // ============================================================================
336
+
337
+ function generateFlexboxUtilities(spacing) {
338
+ let css = `/* Flexbox */\n`;
339
+
340
+ // Note: .flex is already generated in displayUtilities(), removed duplicate here
341
+ css += `.inline-flex { display: inline-flex; }\n`;
342
+
343
+ // Direction
344
+ css += `.flex-row { flex-direction: row; }\n`;
345
+ css += `.flex-col { flex-direction: column; }\n`;
346
+ css += `.flex-row-reverse { flex-direction: row-reverse; }\n`;
347
+ css += `.flex-col-reverse { flex-direction: column-reverse; }\n`;
348
+
349
+ // Wrap
350
+ css += `.flex-wrap { flex-wrap: wrap; }\n`;
351
+ css += `.flex-nowrap { flex-wrap: nowrap; }\n`;
352
+ css += `.flex-wrap-reverse { flex-wrap: wrap-reverse; }\n`;
353
+
354
+ // Grow/shrink
355
+ css += `.flex-1 { flex: 1 1 0%; }\n`;
356
+ css += `.flex-auto { flex: 1 1 auto; }\n`;
357
+ css += `.flex-none { flex: none; }\n`;
358
+ css += `.grow { flex-grow: 1; }\n`;
359
+ css += `.grow-0 { flex-grow: 0; }\n`;
360
+ css += `.shrink { flex-shrink: 1; }\n`;
361
+ css += `.shrink-0 { flex-shrink: 0; }\n`;
362
+
363
+ // Justify (main axis)
364
+ css += `.justify-start { justify-content: flex-start; }\n`;
365
+ css += `.justify-end { justify-content: flex-end; }\n`;
366
+ css += `.justify-center { justify-content: center; }\n`;
367
+ css += `.justify-between { justify-content: space-between; }\n`;
368
+ css += `.justify-around { justify-content: space-around; }\n`;
369
+ css += `.justify-evenly { justify-content: space-evenly; }\n`;
370
+
371
+ // Items (cross axis)
372
+ css += `.items-start { align-items: flex-start; }\n`;
373
+ css += `.items-end { align-items: flex-end; }\n`;
374
+ css += `.items-center { align-items: center; }\n`;
375
+ css += `.items-baseline { align-items: baseline; }\n`;
376
+ css += `.items-stretch { align-items: stretch; }\n`;
377
+
378
+ // Self alignment
379
+ css += `.self-start { align-self: flex-start; }\n`;
380
+ css += `.self-end { align-self: flex-end; }\n`;
381
+ css += `.self-center { align-self: center; }\n`;
382
+ css += `.self-stretch { align-self: stretch; }\n`;
383
+ css += `.self-auto { align-self: auto; }\n`;
384
+
385
+ css += `\n`;
386
+ return css;
387
+ }
388
+
389
+ // ============================================================================
390
+ // GRID UTILITIES
391
+ // ============================================================================
392
+
393
+ function generateGridUtilities(spacing) {
394
+ let css = `/* Grid */\n`;
395
+
396
+ // Note: .grid is already generated in displayUtilities(), removed duplicate here
397
+ css += `.inline-grid { display: inline-grid; }\n`;
398
+
399
+ // Grid columns
400
+ for (let i = 1; i <= 12; i++) {
401
+ css += `.grid-cols-${i} { grid-template-columns: repeat(${i}, minmax(0, 1fr)); }\n`;
402
+ }
403
+
404
+ // Column span
405
+ for (let i = 1; i <= 12; i++) {
406
+ css += `.col-span-${i} { grid-column: span ${i} / span ${i}; }\n`;
407
+ }
408
+ css += `.col-span-full { grid-column: 1 / -1; }\n`;
409
+
410
+ // Column start/end
411
+ for (let i = 1; i <= 13; i++) {
412
+ css += `.col-start-${i} { grid-column-start: ${i}; }\n`;
413
+ css += `.col-end-${i} { grid-column-end: ${i}; }\n`;
414
+ }
415
+
416
+ // Row span
417
+ for (let i = 1; i <= 6; i++) {
418
+ css += `.row-span-${i} { grid-row: span ${i} / span ${i}; }\n`;
419
+ }
420
+ css += `.row-span-full { grid-row: 1 / -1; }\n`;
421
+
422
+ // Row start/end
423
+ for (let i = 1; i <= 6; i++) {
424
+ css += `.row-start-${i} { grid-row-start: ${i}; }\n`;
425
+ css += `.row-end-${i} { grid-row-end: ${i}; }\n`;
426
+ }
427
+
428
+ // Auto flow
429
+ css += `.auto-cols-auto { grid-auto-columns: auto; }\n`;
430
+ css += `.auto-cols-fr { grid-auto-columns: minmax(0, 1fr); }\n`;
431
+ css += `.auto-rows-auto { grid-auto-rows: auto; }\n`;
432
+ css += `.auto-rows-fr { grid-auto-rows: minmax(0, 1fr); }\n`;
433
+
434
+ // Gap
435
+ Object.entries(spacing).forEach(([key, value]) => {
436
+ const escaped = escapeClassName(key);
437
+ css += `.gap-${escaped} { gap: ${value}; }\n`;
438
+ css += `.gap-x-${escaped} { column-gap: ${value}; }\n`;
439
+ css += `.gap-y-${escaped} { row-gap: ${value}; }\n`;
440
+ });
441
+
442
+ css += `\n`;
443
+ return css;
444
+ }
445
+
446
+ // ============================================================================
447
+ // TYPOGRAPHY UTILITIES
448
+ // ============================================================================
449
+
450
+ function generateTypographyUtilities(config) {
451
+ let css = `/* Typography */\n`;
452
+
453
+ // Font sizes
454
+ config.typography.fontSizes.forEach(fontSize => {
455
+ css += `.text-${fontSize.name} { font-size: var(--text-${fontSize.name}); line-height: ${fontSize.lineHeight}; }\n`;
456
+ });
457
+
458
+ // Font weights
459
+ Object.entries(config.typography.fontWeights).forEach(([name, weight]) => {
460
+ css += `.font-${name} { font-weight: ${weight}; }\n`;
461
+ });
462
+
463
+ // Text alignment
464
+ css += `.text-left { text-align: left; }\n`;
465
+ css += `.text-center { text-align: center; }\n`;
466
+ css += `.text-right { text-align: right; }\n`;
467
+ css += `.text-justify { text-align: justify; }\n`;
468
+
469
+ // Text wrapping
470
+ css += `.truncate { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }\n`;
471
+ css += `.whitespace-nowrap { white-space: nowrap; }\n`;
472
+ css += `.whitespace-normal { white-space: normal; }\n`;
473
+ css += `.break-words { word-break: break-word; }\n`;
474
+ css += `.break-all { word-break: break-all; }\n`;
475
+
476
+ // Line height
477
+ css += `.leading-tight { line-height: 1.2; }\n`;
478
+ css += `.leading-normal { line-height: 1.5; }\n`;
479
+ css += `.leading-relaxed { line-height: 1.75; }\n`;
480
+
481
+ // Letter spacing
482
+ css += `.tracking-tighter { letter-spacing: -0.05em; }\n`;
483
+ css += `.tracking-tight { letter-spacing: -0.02em; }\n`;
484
+ css += `.tracking-normal { letter-spacing: 0em; }\n`;
485
+ css += `.tracking-wide { letter-spacing: 0.02em; }\n`;
486
+ css += `.tracking-wider { letter-spacing: 0.05em; }\n`;
487
+ css += `.tracking-widest { letter-spacing: 0.1em; }\n`;
488
+
489
+ // Text decoration
490
+ css += `.underline { text-decoration: underline; }\n`;
491
+ css += `.no-underline { text-decoration: none; }\n`;
492
+ css += `.line-through { text-decoration: line-through; }\n`;
493
+
494
+ // Text transform
495
+ css += `.uppercase { text-transform: uppercase; }\n`;
496
+ css += `.lowercase { text-transform: lowercase; }\n`;
497
+ css += `.capitalize { text-transform: capitalize; }\n`;
498
+
499
+ // Font family utilities
500
+ css += `.font-sans { font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; }\n`;
501
+ css += `.font-serif { font-family: Georgia, "Times New Roman", serif; }\n`;
502
+ css += `.font-mono { font-family: "Menlo", "Monaco", "Courier New", monospace; }\n`;
503
+ css += `.font-inter { font-family: "Inter", system-ui, sans-serif; }\n`;
504
+ css += `.font-lexend { font-family: "Lexend", system-ui, sans-serif; }\n`;
505
+
506
+ css += `\n`;
507
+ return css;
508
+ }
509
+
510
+ // ============================================================================
511
+ // BORDER UTILITIES
512
+ // ============================================================================
513
+
514
+ function generateBorderUtilities(config) {
515
+ let css = `/* Borders & Radius */\n`;
516
+
517
+ // Border widths from config
518
+ const borderWidths = config.spacing.borderWidths || [0, 2, 4, 8];
519
+
520
+ // Border width
521
+ css += `.border { border-width: 1px; }\n`;
522
+ borderWidths.forEach(width => {
523
+ css += `.border-${width} { border-width: ${width}px; }\n`;
524
+ });
525
+
526
+ // Border sides (default 1px + widths)
527
+
528
+ ['t', 'r', 'b', 'l'].forEach(side => {
529
+ css += `.border-${side} { border-${side === 't' ? 'top' : side === 'r' ? 'right' : side === 'b' ? 'bottom' : 'left'}-width: 1px; }\n`;
530
+ });
531
+
532
+ borderWidths.forEach(width => {
533
+ ['t', 'r', 'b', 'l'].forEach(side => {
534
+ const sideMap = { t: 'top', r: 'right', b: 'bottom', l: 'left' };
535
+ css += `.border-${side}-${width} { border-${sideMap[side]}-width: ${width}px; }\n`;
536
+ });
537
+ });
538
+
539
+ // Border style
540
+ css += `.border-solid { border-style: solid; }\n`;
541
+ css += `.border-dashed { border-style: dashed; }\n`;
542
+ css += `.border-dotted { border-style: dotted; }\n`;
543
+ css += `.border-double { border-style: double; }\n`;
544
+ css += `.border-none { border-style: none; }\n`;
545
+
546
+ // Border radius (base)
547
+ const baseRadius = config.spacing.borderRadius['base'] || '8px';
548
+ css += `.rounded { border-radius: ${baseRadius}; }\n`;
549
+
550
+ // Border radius (named)
551
+ Object.entries(config.spacing.borderRadius).forEach(([name, value]) => {
552
+ css += `.rounded-${name} { border-radius: ${value}; }\n`;
553
+ });
554
+
555
+ // Border radius per side
556
+ css += `.rounded-t { border-top-left-radius: ${baseRadius}; border-top-right-radius: ${baseRadius}; }\n`;
557
+ css += `.rounded-b { border-bottom-left-radius: ${baseRadius}; border-bottom-right-radius: ${baseRadius}; }\n`;
558
+ css += `.rounded-l { border-top-left-radius: ${baseRadius}; border-bottom-left-radius: ${baseRadius}; }\n`;
559
+ css += `.rounded-r { border-top-right-radius: ${baseRadius}; border-bottom-right-radius: ${baseRadius}; }\n`;
560
+ css += `.rounded-tl { border-top-left-radius: ${baseRadius}; }\n`;
561
+ css += `.rounded-tr { border-top-right-radius: ${baseRadius}; }\n`;
562
+ css += `.rounded-bl { border-bottom-left-radius: ${baseRadius}; }\n`;
563
+ css += `.rounded-br { border-bottom-right-radius: ${baseRadius}; }\n`;
564
+
565
+ css += `\n`;
566
+ return css;
567
+ }
568
+
569
+ // ============================================================================
570
+ // BASE ELEMENT STYLES
571
+ // ============================================================================
572
+
573
+ function generateBaseStyles(config) {
574
+ const baseStyles = config.baseStyles;
575
+ if (!baseStyles || Object.keys(baseStyles).length === 0) return '';
576
+
577
+ // Build a lookup map from font size name → CSS variable
578
+ const fontSizeMap = {};
579
+ (config.typography?.fontSizes || []).forEach(({ name }) => {
580
+ fontSizeMap[name] = `var(--emily-text-${name})`;
581
+ });
582
+
583
+ // Line height hints per size — keeps headings tighter than body text
584
+ const lineHeightMap = {
585
+ xs: '1.5', sm: '1.5', base: '1.6', lg: '1.6',
586
+ xl: '1.5', '2xl': '1.4', '3xl': '1.35', '4xl': '1.3'
587
+ };
588
+
589
+ let css = '\n /* Base element styles (from baseStyles in emily.config.json) */\n';
590
+ Object.entries(baseStyles).forEach(([element, sizeKey]) => {
591
+ const varRef = fontSizeMap[sizeKey];
592
+ if (!varRef) return;
593
+ const lh = lineHeightMap[sizeKey] || '1.5';
594
+ css += ` ${element} { font-size: ${varRef}; line-height: ${lh}; }\n`;
595
+ });
596
+
597
+ return css;
598
+ }
599
+
600
+ // ============================================================================
601
+ // COLOUR UTILITIES
602
+ // ============================================================================
603
+
604
+ function generateColourUtilities(colours) {
605
+ let css = `/* Colours: Background, Text, Borders, Accents */\n`;
606
+
607
+ Object.entries(colours).forEach(([colourName, shades]) => {
608
+ // Background colours
609
+ Object.entries(shades).forEach(([shade, hex]) => {
610
+ css += `.bg-${colourName}-${shade} { background-color: ${hex}; }\n`;
611
+ });
612
+
613
+ // Text colours
614
+ Object.entries(shades).forEach(([shade, hex]) => {
615
+ css += `.text-${colourName}-${shade} { color: ${hex}; }\n`;
616
+ });
617
+
618
+ // Border colours
619
+ Object.entries(shades).forEach(([shade, hex]) => {
620
+ css += `.border-${colourName}-${shade} { border-color: ${hex}; }\n`;
621
+ });
622
+
623
+ // Accent colours (for form elements like checkboxes, radio buttons)
624
+ Object.entries(shades).forEach(([shade, hex]) => {
625
+ css += `.accent-${colourName}-${shade} { accent-color: ${hex}; }\n`;
626
+ });
627
+ });
628
+
629
+ css += `.bg-white { background-color: #ffffff; }\n`;
630
+ css += `.bg-transparent { background-color: transparent; }\n`;
631
+ css += `.text-white { color: #ffffff; }\n`;
632
+
633
+ css += `\n`;
634
+ return css;
635
+ }
636
+
637
+ // ============================================================================
638
+ // DARK MODE VARIANTS
639
+ // ============================================================================
640
+ // Generates dark: prefixed versions of colour and appearance utilities only.
641
+ // Layout, spacing, and typography utilities don't change in dark mode —
642
+ // targeting only the utilities where dark mode actually makes a difference
643
+ // keeps the output lean and the purge step effective.
644
+ //
645
+ // Usage in HTML: class="bg-neutral-10 dark:bg-neutral-90 text-neutral-90 dark:text-neutral-10"
646
+ // Output: @media (prefers-color-scheme: dark) { .dark\:bg-neutral-90 { background-color: ...; } }
647
+
648
+ function addDarkModeVariants(css) {
649
+ // Match on CSS property, not class name prefix — avoids catching
650
+ // structural utilities like text-xs (font-size) or text-left (text-align)
651
+ // when we only want colour-related declarations.
652
+ const colourProperties = [
653
+ 'background-color',
654
+ 'color',
655
+ 'border-color',
656
+ 'accent-color',
657
+ 'box-shadow',
658
+ 'opacity',
659
+ 'fill',
660
+ 'stroke',
661
+ '--tw-ring-color',
662
+ 'outline-color',
663
+ ];
664
+
665
+ let darkRules = '';
666
+ const lines = css.split('\n');
667
+
668
+ lines.forEach(line => {
669
+ if (line.startsWith('.') && line.includes('{') && line.includes('}')) {
670
+ const className = line.split('{')[0].trim();
671
+
672
+ // Only base utilities — skip anything already a variant (contains ':')
673
+ if (className.includes(':')) return;
674
+
675
+ // Only colour/appearance properties
676
+ const isColourUtility = colourProperties.some(prop => line.includes(prop + ':'));
677
+ if (!isColourUtility) return;
678
+
679
+ const classWithoutDot = className.substring(1);
680
+ const darkRule = line.replace(className, `.dark\\:${classWithoutDot}`);
681
+ darkRules += ' ' + darkRule + '\n';
682
+ }
683
+ });
684
+
685
+ if (!darkRules) return css;
686
+
687
+ return css
688
+ + `\n/* Dark mode variants — explicit override */\n[data-theme="dark"] {\n${darkRules}}\n`
689
+ + `\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`;
690
+ }
691
+
692
+ // ============================================================================
693
+ // RESPONSIVE VARIANTS
694
+ // ============================================================================
695
+
696
+ function addResponsiveVariants(css, config) {
697
+ let variantCss = css;
698
+
699
+ Object.entries(config.breakpoints).forEach(([breakpointName, breakpointValue]) => {
700
+ const mediaQuery = `@media (min-width: ${breakpointValue}) {\n`;
701
+ let breakpointRules = '';
702
+
703
+ // Extract all utility rules and add responsive prefix
704
+ const lines = css.split('\n');
705
+ lines.forEach(line => {
706
+ if (line.startsWith('.') && line.includes('{')) {
707
+ const className = line.split('{')[0].trim();
708
+ const rule = line;
709
+ // Skip variables and already responsive selectors
710
+ if (!className.startsWith(':root') && !className.includes(':')) {
711
+ const responsiveRule = rule.replace(className, `.${breakpointName}\\:${className.substring(1)}`);
712
+ breakpointRules += ' ' + responsiveRule + '\n';
713
+ }
714
+ }
715
+ });
716
+
717
+ if (breakpointRules) {
718
+ variantCss += mediaQuery + breakpointRules + '}\n\n';
719
+ }
720
+ });
721
+
722
+ return variantCss;
723
+ }
724
+
725
+ // ============================================================================
726
+ // STATE VARIANTS
727
+ // ============================================================================
728
+ // Add pseudo-class variants for hover, focus-visible, active, disabled
729
+
730
+ function addStateVariants(css) {
731
+ const states = [
732
+ { name: 'hover', selector: ':hover' },
733
+ { name: 'focus', selector: ':focus' },
734
+ { name: 'focus-visible', selector: ':focus-visible' },
735
+ { name: 'active', selector: ':active' },
736
+ { name: 'disabled', selector: ':disabled' }
737
+ ];
738
+
739
+ let variantCss = css;
740
+
741
+ states.forEach(state => {
742
+ let stateRules = '';
743
+
744
+ // Extract all utility rules and add state prefix
745
+ const lines = css.split('\n');
746
+ lines.forEach(line => {
747
+ if (line.startsWith('.') && line.includes('{')) {
748
+ const className = line.split('{')[0].trim();
749
+ // Skip variables, media queries, pseudo-elements, and state variants
750
+ if (!className.startsWith(':root') && !className.includes('@') && !className.includes('::') && !className.includes(':')) {
751
+ // Generate state variant: .hover\:block:hover { display: block; }
752
+ // Remove leading dot from className, add state prefix with escaped colon
753
+ const classWithoutDot = className.substring(1);
754
+ const stateSelector = `.${state.name}\\:${classWithoutDot}${state.selector}`;
755
+ const statefulRule = line.replace(className, stateSelector);
756
+ stateRules += statefulRule + '\n';
757
+ }
758
+ }
759
+ });
760
+
761
+ if (stateRules) {
762
+ variantCss += '\n/* State variant: ' + state.name + ' */\n' + stateRules;
763
+ }
764
+ });
765
+
766
+ return variantCss;
767
+ }
768
+
769
+ // ============================================================================
770
+ // BUILD FUNCTION
771
+ // ============================================================================
772
+
773
+ // ============================================================================
774
+ // BUILD FUNCTION
775
+ // ============================================================================
776
+
777
+ function build(options = {}) {
778
+ const configPath = path.join(process.cwd(), 'emily.config.json');
779
+ if (!fs.existsSync(configPath)) {
780
+ console.error(`\n emily-ui: No config found.\n Expected: ${configPath}\n Run "emily-ui init" to create one.\n`);
781
+ process.exit(1);
782
+ }
783
+ const config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
784
+
785
+ if (options.purge) {
786
+ const { purgeCSS } = require('./purge.js');
787
+ const cssPath = path.join(process.cwd(), 'dist/emily.css');
788
+ if (!fs.existsSync(cssPath)) {
789
+ console.error(' emily-ui: Run emily-ui build first before purging.');
790
+ process.exit(1);
791
+ }
792
+ console.log(`Purging unused utilities from ${options.purge}...`);
793
+ const css = fs.readFileSync(cssPath, 'utf8');
794
+ const purged = purgeCSS(css, options.purge);
795
+ const minified = purged.replace(/\/\*[\s\S]*?\*\//g, '').replace(/\s+/g, ' ').replace(/\s?\{/g, '{').replace(/\s?\}/g, '}').replace(/;\s/g, ';').trim();
796
+ fs.writeFileSync(path.join(process.cwd(), 'dist/emily.purged.css'), purged);
797
+ fs.writeFileSync(path.join(process.cwd(), 'dist/emily.purged.min.css'), minified);
798
+ console.log('✓ Purged CSS: dist/emily.purged.css');
799
+ return;
800
+ }
801
+
802
+ console.log('Building EmilyUI...');
803
+
804
+ // Generate colours
805
+ const colours = generateAllColours(config.colours);
806
+ console.log(`✓ Generated ${Object.keys(colours).length} colour scales`);
807
+
808
+ // Generate spacing
809
+ const spacing = generateSpacing(config.baseUnit, config.spacing.scale);
810
+ console.log(`✓ Generated ${Object.keys(spacing).length} spacing values`);
811
+
812
+ // 1. Generate Variables (Theme Layer)
813
+ const variablesCss = generateCSSVariables(colours, spacing, config);
814
+
815
+ // 2. Generate Utilities (Utilities Layer)
816
+ let utilityCss = '';
817
+ utilityCss += displayUtilities();
818
+ utilityCss += generateSpacingUtilities(spacing);
819
+ utilityCss += generateFlexboxUtilities(spacing);
820
+ utilityCss += generateGridUtilities(spacing);
821
+ utilityCss += sizingUtilities(spacing);
822
+ utilityCss += generateTypographyUtilities(config);
823
+ utilityCss += generateBorderUtilities(config);
824
+ utilityCss += generateColourUtilities(colours);
825
+ utilityCss += positioningUtilities(spacing);
826
+ utilityCss += overflowUtilities();
827
+ utilityCss += transformUtilities(spacing);
828
+ utilityCss += shadowUtilities();
829
+ utilityCss += ringUtilities(colours);
830
+ utilityCss += objectUtilities();
831
+ utilityCss += tableListUtilities();
832
+ utilityCss += svgUtilities(colours);
833
+ utilityCss += formUtilities();
834
+ utilityCss += verticalAlignUtilities();
835
+ utilityCss += contentScrollUtilities();
836
+ utilityCss += opacityUtilities();
837
+ utilityCss += transitionUtilities();
838
+ utilityCss += blendUtilities();
839
+ utilityCss += cursorUtilities();
840
+ utilityCss += accessibilityUtilities();
841
+ utilityCss += containerUtilities();
842
+
843
+ // Add state, dark mode, and responsive variants to utilities
844
+ utilityCss = addStateVariants(utilityCss);
845
+ utilityCss = addDarkModeVariants(utilityCss);
846
+ utilityCss = addResponsiveVariants(utilityCss, config);
847
+
848
+ // 3. Assemble Final CSS with Layers
849
+ // Layer order matters: later layers win over earlier ones.
850
+ // theme → CSS custom properties / design tokens
851
+ // base → box-sizing reset, font-face, body defaults
852
+ // components → reserved for future component styles
853
+ // utilities → generated utility classes (highest priority)
854
+ const { fontFace, bodyFont } = generateFontCSS(config);
855
+ console.log(`✓ Font: ${config.fontFamily || 'system'}`);
856
+
857
+ const baseCss = `
858
+ /* Box sizing */
859
+ *, *::before, *::after {
860
+ box-sizing: border-box;
861
+ }
862
+
863
+ /* Remove default margin/padding on common elements */
864
+ body, h1, h2, h3, h4, h5, h6, p,
865
+ ul, ol, dl, dd, figure, blockquote,
866
+ fieldset, textarea, pre {
867
+ margin: 0;
868
+ padding: 0;
869
+ }
870
+
871
+ /* Lists: remove bullets/numbers when unstyled */
872
+ ul, ol {
873
+ list-style: none;
874
+ }
875
+
876
+ /* Inherit fonts for form elements */
877
+ input, button, textarea, select {
878
+ font: inherit;
879
+ }
880
+
881
+ /* Sensible media defaults */
882
+ img, picture, video, canvas, svg {
883
+ display: block;
884
+ max-width: 100%;
885
+ }
886
+
887
+ /* Remove default button styles */
888
+ button {
889
+ background: none;
890
+ border: none;
891
+ cursor: pointer;
892
+ padding: 0;
893
+ }
894
+
895
+ /* Avoid overflow on long words */
896
+ p, h1, h2, h3, h4, h5, h6 {
897
+ overflow-wrap: break-word;
898
+ }
899
+ ${bodyFont}`;
900
+
901
+ // @font-face must sit outside @layer for broadest browser compatibility
902
+ let css = fontFace ? `${fontFace}\n` : '';
903
+ css += `@layer theme, base, components, utilities;\n\n`;
904
+ css += `@layer theme {\n${variablesCss}}\n\n`;
905
+ const baseStylesCss = generateBaseStyles(config);
906
+ css += `@layer base {${baseCss}${baseStylesCss}}\n\n`;
907
+ css += `@layer components {\n /* Reserved for component styles in a future release. */\n}\n\n`;
908
+ css += `@layer utilities {\n${utilityCss}}\n`;
909
+
910
+ // Write output
911
+ const outputPath = path.join(process.cwd(), 'dist/emily.css');
912
+ const outputDir = path.dirname(outputPath);
913
+
914
+ if (!fs.existsSync(outputDir)) {
915
+ fs.mkdirSync(outputDir, { recursive: true });
916
+ }
917
+
918
+ fs.writeFileSync(outputPath, css);
919
+ console.log(`✓ Generated CSS: ${outputPath}`);
920
+ console.log(`✓ File size: ${(css.length / 1024).toFixed(2)} KB (unminified)`);
921
+
922
+ // Generate minified version
923
+ const minified = css.replace(/\/\*[\s\S]*?\*\//g, '').replace(/\s+/g, ' ').replace(/\s?\{/g, '{').replace(/\s?\}/g, '}').replace(/;\s/g, ';').trim();
924
+ const minPath = path.join(process.cwd(), 'dist/emily.min.css');
925
+ fs.writeFileSync(minPath, minified);
926
+ console.log(' -> Minified: ' + minPath);
927
+ console.log(' -> File size: ' + (minified.length / 1024).toFixed(2) + ' KB (minified)');
928
+
929
+ console.log('Build complete');
930
+ }
931
+
932
+ if (require.main === module) {
933
+ const args = process.argv.slice(2);
934
+ const purgeIndex = args.indexOf('--purge');
935
+ const purgeDir = purgeIndex !== -1 ? args[purgeIndex + 1] : null;
936
+ build(purgeDir ? { purge: purgeDir } : {});
937
+ }
938
+
939
+ module.exports = {
940
+ build,
941
+ hexToOklch,
942
+ oklchToHex,
943
+ generateColourScale,
944
+ generateAllColours,
945
+ generateSpacing,
946
+ generateBorderUtilities,
947
+ generateColourUtilities,
948
+ generateTypographyUtilities,
949
+ generateSpacingUtilities,
950
+ addStateVariants,
951
+ addResponsiveVariants,
952
+ };