meno-core 1.0.48 → 1.0.49

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.
Files changed (74) hide show
  1. package/dist/build-static.js +7 -7
  2. package/dist/chunks/{chunk-UUA5LEWF.js → chunk-6IVUG7FY.js} +138 -7
  3. package/dist/chunks/chunk-6IVUG7FY.js.map +7 -0
  4. package/dist/chunks/{chunk-B2RTLDXY.js → chunk-AZQYF6KE.js} +132 -1
  5. package/dist/chunks/chunk-AZQYF6KE.js.map +7 -0
  6. package/dist/chunks/{chunk-NKUV77SR.js → chunk-CHD5UCFF.js} +21 -9
  7. package/dist/chunks/{chunk-NKUV77SR.js.map → chunk-CHD5UCFF.js.map} +2 -2
  8. package/dist/chunks/{chunk-TPQ7APVQ.js → chunk-EQYDSPBB.js} +418 -62
  9. package/dist/chunks/chunk-EQYDSPBB.js.map +7 -0
  10. package/dist/chunks/{chunk-RQSTH2BS.js → chunk-H4JSCDNW.js} +2 -2
  11. package/dist/chunks/{chunk-EK4KESLU.js → chunk-J23ZX5AP.js} +8 -2
  12. package/dist/chunks/{chunk-EK4KESLU.js.map → chunk-J23ZX5AP.js.map} +2 -2
  13. package/dist/chunks/{chunk-D5E3OKSL.js → chunk-JER5NQVM.js} +5 -5
  14. package/dist/chunks/{chunk-BJRKEPMP.js → chunk-KPU2XHOS.js} +5 -2
  15. package/dist/chunks/{chunk-BJRKEPMP.js.map → chunk-KPU2XHOS.js.map} +2 -2
  16. package/dist/chunks/{chunk-NP76N4HQ.js → chunk-LKAGAQ3M.js} +2 -2
  17. package/dist/chunks/{chunk-3FHJUHAS.js → chunk-S2CX6HFM.js} +260 -25
  18. package/dist/chunks/chunk-S2CX6HFM.js.map +7 -0
  19. package/dist/chunks/{configService-IGJEC3MC.js → configService-CCA6AIDI.js} +3 -3
  20. package/dist/entries/server-router.js +9 -9
  21. package/dist/entries/server-router.js.map +2 -2
  22. package/dist/lib/client/index.js +54 -20
  23. package/dist/lib/client/index.js.map +3 -3
  24. package/dist/lib/server/index.js +9 -9
  25. package/dist/lib/shared/index.js +46 -10
  26. package/dist/lib/shared/index.js.map +3 -3
  27. package/entries/server-router.tsx +6 -2
  28. package/lib/client/core/ComponentBuilder.ts +8 -1
  29. package/lib/client/core/builders/embedBuilder.ts +15 -2
  30. package/lib/client/core/builders/linkNodeBuilder.ts +15 -2
  31. package/lib/client/core/builders/localeListBuilder.ts +17 -6
  32. package/lib/client/styles/StyleInjector.ts +3 -2
  33. package/lib/client/theme.ts +4 -4
  34. package/lib/server/cssGenerator.test.ts +64 -1
  35. package/lib/server/cssGenerator.ts +48 -9
  36. package/lib/server/providers/fileSystemCMSProvider.test.ts +163 -0
  37. package/lib/server/providers/fileSystemCMSProvider.ts +200 -11
  38. package/lib/server/routes/index.ts +1 -1
  39. package/lib/server/routes/pages.ts +23 -1
  40. package/lib/server/services/cmsService.test.ts +246 -0
  41. package/lib/server/services/cmsService.ts +122 -5
  42. package/lib/server/services/configService.ts +5 -0
  43. package/lib/server/ssr/attributeBuilder.ts +41 -0
  44. package/lib/server/ssr/htmlGenerator.test.ts +113 -0
  45. package/lib/server/ssr/htmlGenerator.ts +51 -4
  46. package/lib/server/ssr/liveReloadIntegration.test.ts +209 -0
  47. package/lib/server/ssr/ssrRenderer.test.ts +306 -0
  48. package/lib/server/ssr/ssrRenderer.ts +182 -44
  49. package/lib/shared/cssGeneration.test.ts +267 -1
  50. package/lib/shared/cssGeneration.ts +240 -18
  51. package/lib/shared/cssProperties.test.ts +247 -1
  52. package/lib/shared/cssProperties.ts +196 -6
  53. package/lib/shared/interfaces/contentProvider.ts +39 -6
  54. package/lib/shared/pathSecurity.ts +16 -0
  55. package/lib/shared/responsiveScaling.test.ts +143 -0
  56. package/lib/shared/responsiveScaling.ts +253 -2
  57. package/lib/shared/themeDefaults.test.ts +3 -3
  58. package/lib/shared/themeDefaults.ts +3 -3
  59. package/lib/shared/types/cms.ts +28 -3
  60. package/lib/shared/types/index.ts +1 -0
  61. package/lib/shared/utilityClassConfig.ts +3 -0
  62. package/lib/shared/utilityClassMapper.test.ts +123 -0
  63. package/lib/shared/utilityClassMapper.ts +179 -8
  64. package/lib/shared/validation/schemas.ts +15 -1
  65. package/lib/shared/validation/validators.ts +26 -1
  66. package/package.json +1 -1
  67. package/dist/chunks/chunk-3FHJUHAS.js.map +0 -7
  68. package/dist/chunks/chunk-B2RTLDXY.js.map +0 -7
  69. package/dist/chunks/chunk-TPQ7APVQ.js.map +0 -7
  70. package/dist/chunks/chunk-UUA5LEWF.js.map +0 -7
  71. /package/dist/chunks/{chunk-RQSTH2BS.js.map → chunk-H4JSCDNW.js.map} +0 -0
  72. /package/dist/chunks/{chunk-D5E3OKSL.js.map → chunk-JER5NQVM.js.map} +0 -0
  73. /package/dist/chunks/{chunk-NP76N4HQ.js.map → chunk-LKAGAQ3M.js.map} +0 -0
  74. /package/dist/chunks/{configService-IGJEC3MC.js.map → configService-CCA6AIDI.js.map} +0 -0
@@ -1,8 +1,9 @@
1
1
  import { describe, expect, test } from 'bun:test';
2
- import { generateRuleForClass, generateUtilityCSS, generateSingleClassCSS, extractUtilityClassesFromHTML, generateInteractiveCSS } from './cssGeneration';
2
+ import { generateRuleForClass, generateUtilityCSS, generateSingleClassCSS, extractUtilityClassesFromHTML, generateInteractiveCSS, applyContainerPattern } from './cssGeneration';
3
3
  import { DEFAULT_BREAKPOINTS } from './breakpoints';
4
4
  import type { ResponsiveScales } from './responsiveScaling';
5
5
  import type { InteractiveStyles } from './types/styles';
6
+ import { registerStyleValue, clearRegistry } from './styleValueRegistry';
6
7
 
7
8
  describe('extractUtilityClassesFromHTML', () => {
8
9
  test('extracts ins- classes', () => {
@@ -135,6 +136,20 @@ describe('cssGeneration', () => {
135
136
  });
136
137
  });
137
138
 
139
+ describe('display (d-) prefix for non-special values', () => {
140
+ test('d-contents generates display: contents', () => {
141
+ expect(generateRuleForClass('d-contents')).toBe('display: contents;');
142
+ });
143
+
144
+ test('d-flow-root generates display: flow-root', () => {
145
+ expect(generateRuleForClass('d-flow-root')).toBe('display: flow-root;');
146
+ });
147
+
148
+ test('d-table generates display: table', () => {
149
+ expect(generateRuleForClass('d-table')).toBe('display: table;');
150
+ });
151
+ });
152
+
138
153
  describe('bc- and bt- combination', () => {
139
154
  test('bc- controls color independently from bt-', () => {
140
155
  // bt- should NOT set color, allowing bc- to control it
@@ -296,3 +311,254 @@ describe('generateInteractiveCSS — auto-responsive scaling', () => {
296
311
  expect(cssNoScales).not.toContain('@media');
297
312
  });
298
313
  });
314
+
315
+ describe('fluid mode — clamp() emission', () => {
316
+ const fluidScales: ResponsiveScales = {
317
+ enabled: true,
318
+ mode: 'fluid',
319
+ baseReference: 16,
320
+ fluidRange: { min: 320, max: 1440 },
321
+ fontSize: { tablet: 0.88, mobile: 0.75 },
322
+ padding: { tablet: 0.75, mobile: 0.5 },
323
+ };
324
+
325
+ test('generateUtilityCSS emits a single clamp() rule with no @media for auto-responsive class', () => {
326
+ const classes = new Set(['fs-32px']);
327
+ const css = generateUtilityCSS(classes, DEFAULT_BREAKPOINTS, fluidScales);
328
+ // mobile.breakpoint=540 is the smallest in DEFAULT_BREAKPOINTS, mobile scale=0.75
329
+ // so MIN = 32 + (32-16)*(0.75-1) = 32 - 4 = 28; MAX = 32
330
+ expect(css).toContain('.fs-32px');
331
+ expect(css).toContain('clamp(28px,');
332
+ expect(css).toContain(', 32px)');
333
+ expect(css).not.toContain('@media');
334
+ });
335
+
336
+ test('generateUtilityCSS leaves non-scalable class unchanged in fluid mode', () => {
337
+ const classes = new Set(['z-2']);
338
+ const css = generateUtilityCSS(classes, DEFAULT_BREAKPOINTS, fluidScales);
339
+ expect(css).toContain('z-index: 2');
340
+ expect(css).not.toContain('clamp(');
341
+ });
342
+
343
+ test('generateSingleClassCSS emits clamp() in fluid mode', () => {
344
+ const css = generateSingleClassCSS('p-40px', DEFAULT_BREAKPOINTS, fluidScales);
345
+ // mobile padding scale=0.5 → MIN = 40 + (40-16)*(0.5-1) = 40 - 12 = 28
346
+ expect(css).toContain('clamp(28px,');
347
+ expect(css).toContain(', 40px)');
348
+ expect(css).not.toContain('@media');
349
+ });
350
+
351
+ test('breakpoints mode (default when mode is undefined) keeps existing @media behavior', () => {
352
+ const breakpointScales: ResponsiveScales = { ...fluidScales, mode: undefined };
353
+ const classes = new Set(['fs-32px']);
354
+ const css = generateUtilityCSS(classes, DEFAULT_BREAKPOINTS, breakpointScales);
355
+ expect(css).toContain('@media (max-width: 1024px)');
356
+ expect(css).toContain('@media (max-width: 540px)');
357
+ expect(css).not.toContain('clamp(');
358
+ });
359
+
360
+ test('generateInteractiveCSS encodes scaling as clamp() on base rule, no @media', () => {
361
+ const styles: InteractiveStyles = [
362
+ { prefix: '', postfix: ':hover', style: { fontSize: '100px' } },
363
+ ];
364
+ const css = generateInteractiveCSS('c_heading', styles, DEFAULT_BREAKPOINTS, undefined, fluidScales);
365
+ // mobile fontSize scale=0.75 → MIN = 100 + (100-16)*(0.75-1) = 79
366
+ expect(css).toContain('clamp(79px,');
367
+ expect(css).toContain(', 100px)');
368
+ expect(css).not.toContain('@media');
369
+ });
370
+
371
+ test('size category (max-width / width / height) IS fluidly scaled in fluid mode', () => {
372
+ const fluidWithSize: ResponsiveScales = {
373
+ ...fluidScales,
374
+ size: { tablet: 0.9, mobile: 0.75 },
375
+ };
376
+ const classes = new Set(['mw-1200px']);
377
+ const css = generateUtilityCSS(classes, DEFAULT_BREAKPOINTS, fluidWithSize);
378
+ // mobile size scale=0.75 → MIN = 1200 + (1200-16)*(0.75-1) = 1200 - 296 = 904
379
+ expect(css).toContain('clamp(904px,');
380
+ expect(css).toContain(', 1200px)');
381
+ expect(css).not.toContain('@media');
382
+ });
383
+
384
+ test('generateInteractiveCSS keeps explicit per-breakpoint override as @media in fluid mode', () => {
385
+ const styles: InteractiveStyles = [
386
+ {
387
+ prefix: '',
388
+ postfix: ':hover',
389
+ style: {
390
+ base: { fontSize: '100px' },
391
+ mobile: { fontSize: '60px' },
392
+ },
393
+ },
394
+ ];
395
+ const css = generateInteractiveCSS('c_btn', styles, DEFAULT_BREAKPOINTS, undefined, fluidScales);
396
+ // base rule has clamp from auto-scale
397
+ expect(css).toContain('clamp(79px,');
398
+ // explicit mobile override stays as a media-query rule with the user's value
399
+ expect(css).toMatch(/@media \(max-width: 540px\) \{[^}]*font-size:\s*60px/);
400
+ });
401
+
402
+ // Hash-fallback utility classes (e.g. `fs-h1glej9a` for `222.3px` — the `.`
403
+ // is illegal in CSS selectors, so utilityClassMapper generates a hashed name
404
+ // and stores the real value in styleValueRegistry). The scaling pipeline
405
+ // previously bailed on these because extractPropertyAndValue returns null
406
+ // for hashed classes. resolveScalablePropertyValue now consults the registry.
407
+ describe('hash-fallback classes', () => {
408
+ test('fluid mode: hashed fs- class with px value gets clamp()', () => {
409
+ clearRegistry();
410
+ registerStyleValue('fs-h1glej9a', '222.3px');
411
+ const css = generateUtilityCSS(new Set(['fs-h1glej9a']), DEFAULT_BREAKPOINTS, fluidScales);
412
+ // mobile fontSize scale=0.75, baseRef=16
413
+ // MIN = 222.3 + (222.3 - 16)*(0.75 - 1) = 222.3 - 51.575 = 170.725 → round 171
414
+ expect(css).toMatch(/\.fs-h1glej9a\s*\{\s*font-size:\s*clamp\(171px,/);
415
+ expect(css).toContain(', 222.3px)');
416
+ expect(css).not.toContain('@media');
417
+ });
418
+
419
+ test('fluid mode: hashed class at baseReference renders plain (MIN===MAX, no clamp)', () => {
420
+ clearRegistry();
421
+ registerStyleValue('fs-hatref', '16px');
422
+ const css = generateUtilityCSS(new Set(['fs-hatref']), DEFAULT_BREAKPOINTS, fluidScales);
423
+ // 16 === baseReference → calculateResponsiveValue returns 16 → MIN===MAX → no clamp
424
+ expect(css).toContain('font-size: 16px');
425
+ expect(css).not.toContain('clamp(');
426
+ });
427
+
428
+ test('fluid mode: hashed class with fractional px value above baseReference clamps with rounded MIN', () => {
429
+ clearRegistry();
430
+ registerStyleValue('fs-hfrac', '14.4px');
431
+ const css = generateUtilityCSS(new Set(['fs-hfrac']), DEFAULT_BREAKPOINTS, fluidScales);
432
+ // 14.4 < baseRef (16) → calculateResponsiveValue floors to round(14.4) = 14
433
+ // MIN=14, MAX=14.4 → small clamp emitted
434
+ expect(css).toMatch(/font-size:\s*clamp\(14px,/);
435
+ expect(css).toContain(', 14.4px)');
436
+ });
437
+
438
+ test('fluid mode: hashed class with already-clamped Webflow value passes through unchanged', () => {
439
+ clearRegistry();
440
+ const webflowClamp = 'clamp(2*1rem, ((2 - 1)/70*20)*1rem + ((2 - 1)/70)*100vw, 4*1rem)';
441
+ registerStyleValue('p-hwebflow', webflowClamp);
442
+ const css = generateUtilityCSS(new Set(['p-hwebflow']), DEFAULT_BREAKPOINTS, fluidScales);
443
+ // buildFluidPropertyValue returns null for unparseable inputs → rule stays as-is
444
+ expect(css).toContain(webflowClamp);
445
+ // no double-clamping
446
+ const clampCount = (css.match(/clamp\(/g) || []).length;
447
+ expect(clampCount).toBe(1);
448
+ });
449
+
450
+ test('breakpoints mode: hashed class with px value gets @media-scaled', () => {
451
+ clearRegistry();
452
+ registerStyleValue('p-h1glej9a', '40px');
453
+ const breakpointScales: ResponsiveScales = { ...fluidScales, mode: 'breakpoints' };
454
+ const css = generateUtilityCSS(new Set(['p-h1glej9a']), DEFAULT_BREAKPOINTS, breakpointScales);
455
+ // Base rule
456
+ expect(css).toContain('padding: 40px');
457
+ // mobile padding scale=0.5 → 40 + (40-16)*(0.5-1) = 28
458
+ expect(css).toMatch(/@media \(max-width: 540px\) \{[^}]*padding:\s*28px/);
459
+ });
460
+
461
+ test('generateSingleClassCSS handles hashed class in fluid mode', () => {
462
+ clearRegistry();
463
+ registerStyleValue('fs-htest1', '100px');
464
+ const css = generateSingleClassCSS('fs-htest1', DEFAULT_BREAKPOINTS, fluidScales);
465
+ // mobile fontSize 0.75 → MIN = 100 + (100-16)*(0.75-1) = 79
466
+ expect(css).toContain('clamp(79px,');
467
+ expect(css).toContain(', 100px)');
468
+ });
469
+ });
470
+ });
471
+
472
+ describe('applyContainerPattern', () => {
473
+ test('width === maxWidth in fluid mode → calc + auto margins, maxWidth retained', () => {
474
+ const result = applyContainerPattern({ width: '1200px', maxWidth: '1200px' }, true);
475
+ expect(result.width).toBe('calc(100% - var(--site-margin) * 2)');
476
+ expect(result.maxWidth).toBe('1200px');
477
+ expect(result.marginLeft).toBe('auto');
478
+ expect(result.marginRight).toBe('auto');
479
+ });
480
+
481
+ test('width === maxWidth in breakpoints mode → unchanged', () => {
482
+ const input = { width: '1200px', maxWidth: '1200px' };
483
+ const result = applyContainerPattern(input, false);
484
+ expect(result).toBe(input);
485
+ });
486
+
487
+ test('width !== maxWidth → unchanged', () => {
488
+ const input = { width: '1200px', maxWidth: '800px' };
489
+ const result = applyContainerPattern(input, true);
490
+ expect(result).toBe(input);
491
+ });
492
+
493
+ test('only width set, no maxWidth → unchanged', () => {
494
+ const input = { width: '1200px' };
495
+ const result = applyContainerPattern(input, true);
496
+ expect(result).toBe(input);
497
+ });
498
+
499
+ test('width === maxWidth === "auto" → unchanged (RESERVED)', () => {
500
+ const input = { width: 'auto', maxWidth: 'auto' };
501
+ const result = applyContainerPattern(input, true);
502
+ expect(result).toBe(input);
503
+ });
504
+
505
+ test('width === maxWidth === "100%" → trigger fires (legitimate use)', () => {
506
+ const result = applyContainerPattern({ width: '100%', maxWidth: '100%' }, true);
507
+ expect(result.width).toBe('calc(100% - var(--site-margin) * 2)');
508
+ expect(result.marginLeft).toBe('auto');
509
+ expect(result.marginRight).toBe('auto');
510
+ });
511
+
512
+ test('explicit marginLeft is overwritten with auto (per spec)', () => {
513
+ const result = applyContainerPattern(
514
+ { width: '1200px', maxWidth: '1200px', marginLeft: '0', marginRight: '12px' },
515
+ true
516
+ );
517
+ expect(result.marginLeft).toBe('auto');
518
+ expect(result.marginRight).toBe('auto');
519
+ });
520
+
521
+ test('preserves unrelated properties (padding, font-size)', () => {
522
+ const result = applyContainerPattern(
523
+ { width: '1200px', maxWidth: '1200px', padding: '20px', fontSize: '16px' },
524
+ true
525
+ );
526
+ expect(result.padding).toBe('20px');
527
+ expect(result.fontSize).toBe('16px');
528
+ });
529
+ });
530
+
531
+ describe('generateInteractiveCSS — container pattern integration', () => {
532
+ test('flat hover style with width === maxWidth in fluid mode emits container CSS', () => {
533
+ const fluidScales: ResponsiveScales = {
534
+ enabled: true,
535
+ mode: 'fluid',
536
+ baseReference: 16,
537
+ fluidRange: { min: 320, max: 1440 },
538
+ siteMargin: { min: 16, max: 32 },
539
+ };
540
+ const styles: InteractiveStyles = [
541
+ { prefix: '', postfix: ':hover', style: { width: '1200px', maxWidth: '1200px' } },
542
+ ];
543
+ const css = generateInteractiveCSS('c_card', styles, DEFAULT_BREAKPOINTS, undefined, fluidScales);
544
+ expect(css).toContain('width: calc(100% - var(--site-margin) * 2)');
545
+ expect(css).toContain('max-width: 1200px');
546
+ expect(css).toContain('margin-left: auto');
547
+ expect(css).toContain('margin-right: auto');
548
+ });
549
+
550
+ test('breakpoints mode emits raw width and maxWidth, no container pattern', () => {
551
+ const breakpointsScales: ResponsiveScales = {
552
+ enabled: true,
553
+ mode: 'breakpoints',
554
+ baseReference: 16,
555
+ };
556
+ const styles: InteractiveStyles = [
557
+ { prefix: '', postfix: ':hover', style: { width: '1200px', maxWidth: '1200px' } },
558
+ ];
559
+ const css = generateInteractiveCSS('c_card', styles, DEFAULT_BREAKPOINTS, undefined, breakpointsScales);
560
+ expect(css).not.toContain('calc(');
561
+ expect(css).toContain('width: 1200px');
562
+ expect(css).toContain('max-width: 1200px');
563
+ });
564
+ });
@@ -9,8 +9,14 @@ import { getStyleValue, getDynamicStyle, isDynamicClass } from './styleValueRegi
9
9
  import { isCssNamedColor } from './cssNamedColors';
10
10
  import type { BreakpointConfig, LegacyBreakpointConfig } from './breakpoints';
11
11
  import { DEFAULT_BREAKPOINTS, getBreakpointValues } from './breakpoints';
12
- import type { ResponsiveScales, CSSPropertyType } from './responsiveScaling';
13
- import { scalePropertyValue, getScaleMultiplier } from './responsiveScaling';
12
+ import type { ResponsiveScales, CSSPropertyType, ResponsiveMode } from './responsiveScaling';
13
+ import {
14
+ scalePropertyValue,
15
+ getScaleMultiplier,
16
+ buildFluidPropertyValue,
17
+ getSmallestBreakpointName,
18
+ DEFAULT_FLUID_RANGE,
19
+ } from './responsiveScaling';
14
20
  import type { InteractiveStyles, StyleObject, ResponsiveStyleObject, StyleValue } from './types/styles';
15
21
  import type { RemConversionConfig } from './pxToRem';
16
22
  import { applyRemConversion, convertPxToRem, shouldConvertProperty } from './pxToRem';
@@ -69,6 +75,14 @@ export function sortClassesByPropertyOrder(classes: Iterable<string>): string[]
69
75
  return Array.from(classes).sort((a, b) => getClassPropertyOrder(a) - getClassPropertyOrder(b));
70
76
  }
71
77
 
78
+ /**
79
+ * Pick the active responsive mode. Defaults to 'breakpoints' when missing,
80
+ * preserving behavior for projects saved before the 'fluid' option existed.
81
+ */
82
+ function getResponsiveMode(scales: ResponsiveScales | undefined | null): ResponsiveMode {
83
+ return (scales?.mode as ResponsiveMode | undefined) ?? 'breakpoints';
84
+ }
85
+
72
86
  /** CSS property → responsive scale category mapping (used by generateUtilityCSS and generateSingleClassCSS) */
73
87
  const AUTO_RESPONSIVE_TYPE_MAP: Record<string, string> = {
74
88
  'padding': 'padding',
@@ -192,6 +206,15 @@ function extractPropertyAndValue(className: string): { property: string; value:
192
206
  const classValue = className.substring(knownPrefix.length + 1);
193
207
  const cssProp = prefixToCSSProperty[knownPrefix];
194
208
  if (cssProp) {
209
+ // Hash-fallback class (e.g. `pt-h1y9pr6i`): the part after the
210
+ // prefix is a hash, not a real CSS value. Returning `value: 'h1y9pr6i'`
211
+ // would make auto-responsive scaling and other downstream consumers
212
+ // emit broken rules like `padding-top: h1y9pr6i;`. The class IS
213
+ // resolvable via the style-value registry — `generateRuleForClass`
214
+ // handles it directly. Skip the dynamic-extraction path here.
215
+ if (/^h[0-9a-z]+$/.test(classValue)) {
216
+ return null;
217
+ }
195
218
  return { property: cssProp, value: classValue };
196
219
  }
197
220
  }
@@ -200,6 +223,39 @@ function extractPropertyAndValue(className: string): { property: string; value:
200
223
  return null;
201
224
  }
202
225
 
226
+ /**
227
+ * Resolve `{ property, value }` for a utility class, consulting the style-value
228
+ * registry for hash-fallback classes (e.g. `fs-h1glej9a` for `222.3px`).
229
+ *
230
+ * Used by the auto-responsive scaling path so hashed classes don't silently
231
+ * bypass `clamp()` / `@media` rewriting. `extractPropertyAndValue` itself
232
+ * intentionally returns null for hashed classes because its other consumers
233
+ * would emit broken `${prop}: ${hash};` rules.
234
+ *
235
+ * Returns the registered value as-is (no parsing). Downstream helpers
236
+ * (`buildFluidPropertyValue`, `scalePropertyValue`) already null out
237
+ * unparseable / `%` / `em` / `var()` / clamp-blob inputs, so a Webflow-style
238
+ * already-clamped value will pass through untouched via the existing
239
+ * `if (!fluidValue) return rule;` guards.
240
+ */
241
+ function resolveScalablePropertyValue(className: string): { property: string; value: string } | null {
242
+ const direct = extractPropertyAndValue(className);
243
+ if (direct) return direct;
244
+
245
+ for (const knownPrefix of SORTED_PREFIXES) {
246
+ if (!className.startsWith(knownPrefix + '-')) continue;
247
+ const classValue = className.substring(knownPrefix.length + 1);
248
+ if (!/^h[0-9a-z]+$/.test(classValue)) continue;
249
+ const cssProp = prefixToCSSProperty[knownPrefix];
250
+ if (!cssProp) continue;
251
+ const registered = getStyleValue(className);
252
+ if (registered == null || registered === '') continue;
253
+ return { property: cssProp, value: String(registered) };
254
+ }
255
+
256
+ return null;
257
+ }
258
+
203
259
  /**
204
260
  * Generate CSS rule for a utility class
205
261
  * Handles dynamic classes like p-10px, m-20px, fs-48px, etc.
@@ -409,6 +465,139 @@ export function generateRuleForClass(className: string): string | null {
409
465
  return `${cssProp}: ${value};`;
410
466
  }
411
467
 
468
+ /**
469
+ * In fluid mode, return a CSS declaration string with the value replaced by
470
+ * `clamp(...)` when the property is auto-responsive AND has a small-end scale.
471
+ * Otherwise returns the original `rule` unchanged.
472
+ *
473
+ * The MIN of the clamp comes from the smallest configured breakpoint's
474
+ * multiplier, matching the user-confirmed mapping (mobile multiplier ⇒ MIN,
475
+ * base value ⇒ MAX, tablet ignored in fluid mode).
476
+ */
477
+ function applyFluidToUtilityRule(
478
+ rule: string,
479
+ className: string,
480
+ responsiveScales: ResponsiveScales,
481
+ breakpoints: BreakpointConfig
482
+ ): string {
483
+ const propValue = resolveScalablePropertyValue(className);
484
+ if (!propValue) return rule;
485
+
486
+ const category = AUTO_RESPONSIVE_TYPE_MAP[propValue.property];
487
+ if (!category) return rule;
488
+
489
+ const scaleConfig = responsiveScales[category as keyof ResponsiveScales] as
490
+ | Record<string, number>
491
+ | undefined;
492
+ if (!scaleConfig) return rule;
493
+
494
+ const smallest = getSmallestBreakpointName(breakpoints);
495
+ if (!smallest) return rule;
496
+
497
+ const scale = scaleConfig[smallest];
498
+ if (scale == null || scale === 1) return rule;
499
+
500
+ const range = responsiveScales.fluidRange ?? DEFAULT_FLUID_RANGE;
501
+ const baseRef = responsiveScales.baseReference ?? 16;
502
+ const fluidValue = buildFluidPropertyValue(
503
+ propValue.value,
504
+ scale,
505
+ range.min,
506
+ range.max,
507
+ baseRef
508
+ );
509
+ if (!fluidValue) return rule;
510
+
511
+ return `${propValue.property}: ${fluidValue};`;
512
+ }
513
+
514
+ /**
515
+ * In fluid mode, transform a flat StyleObject by replacing each scalable
516
+ * property's value with a `clamp(...)` expression. Properties without a
517
+ * matching scale category pass through unchanged.
518
+ */
519
+ function applyFluidToStyle(
520
+ style: StyleObject,
521
+ responsiveScales: ResponsiveScales,
522
+ breakpoints: BreakpointConfig
523
+ ): StyleObject {
524
+ const range = responsiveScales.fluidRange ?? DEFAULT_FLUID_RANGE;
525
+ const baseRef = responsiveScales.baseReference ?? 16;
526
+ const smallest = getSmallestBreakpointName(breakpoints);
527
+ if (!smallest) return style;
528
+
529
+ const out: StyleObject = {};
530
+ for (const [prop, value] of Object.entries(style)) {
531
+ if (typeof value === 'object' && value !== null && '_mapping' in value) {
532
+ out[prop] = value;
533
+ continue;
534
+ }
535
+ if (value == null || value === '') {
536
+ out[prop] = value;
537
+ continue;
538
+ }
539
+
540
+ const scale = getScaleMultiplier(
541
+ responsiveScales,
542
+ prop as CSSPropertyType,
543
+ smallest
544
+ );
545
+ if (scale == null || scale === 1) {
546
+ out[prop] = value;
547
+ continue;
548
+ }
549
+
550
+ const fluidValue = buildFluidPropertyValue(
551
+ String(value),
552
+ scale,
553
+ range.min,
554
+ range.max,
555
+ baseRef
556
+ );
557
+ out[prop] = fluidValue ?? value;
558
+ }
559
+ return out;
560
+ }
561
+
562
+ /** Values that don't make sense in a fluid container pattern. */
563
+ const CONTAINER_RESERVED_VALUES = new Set(['auto', 'inherit', 'initial', 'unset', '']);
564
+
565
+ /**
566
+ * Detect "container intent" on a flat StyleObject and rewrite it into the
567
+ * fluid container pattern. Trigger: `width === maxWidth` (both set, equal).
568
+ *
569
+ * Transformation:
570
+ * { width: '1200px', maxWidth: '1200px' }
571
+ * → { width: 'calc(100% - var(--site-margin) * 2)', maxWidth: '1200px',
572
+ * marginLeft: 'auto', marginRight: 'auto' }
573
+ *
574
+ * No-op when fluid mode is inactive (`fluidActive === false`) — the
575
+ * `--site-margin` CSS variable is only emitted in fluid mode, so emitting the
576
+ * calc() in breakpoints mode would reference an undefined variable.
577
+ *
578
+ * Margins are ALWAYS overwritten with `auto` (per user spec), even if the
579
+ * caller had set explicit values.
580
+ */
581
+ export function applyContainerPattern(
582
+ style: StyleObject,
583
+ fluidActive: boolean
584
+ ): StyleObject {
585
+ if (!fluidActive) return style;
586
+ const w = style.width;
587
+ const mw = style.maxWidth;
588
+ if (w == null || mw == null) return style;
589
+ if (typeof w !== 'string' || typeof mw !== 'string') return style;
590
+ if (w !== mw) return style;
591
+ if (CONTAINER_RESERVED_VALUES.has(w.trim())) return style;
592
+
593
+ return {
594
+ ...style,
595
+ width: 'calc(100% - var(--site-margin) * 2)',
596
+ marginLeft: 'auto',
597
+ marginRight: 'auto',
598
+ };
599
+ }
600
+
412
601
  /**
413
602
  * Generate CSS for all utility classes used in the application
414
603
  * Scans through all classes and generates the necessary CSS rules
@@ -473,7 +662,7 @@ export function generateUtilityCSS(
473
662
 
474
663
  // Check if this class should get auto-responsive scaling
475
664
  if (responsiveScales?.enabled) {
476
- const propValue = extractPropertyAndValue(className);
665
+ const propValue = resolveScalablePropertyValue(className);
477
666
  if (propValue) {
478
667
  const category = AUTO_RESPONSIVE_TYPE_MAP[propValue.property];
479
668
  if (category && responsiveScales[category as keyof ResponsiveScales]) {
@@ -484,10 +673,17 @@ export function generateUtilityCSS(
484
673
  }
485
674
  }
486
675
 
676
+ const mode = getResponsiveMode(responsiveScales);
677
+ const fluidActive = responsiveScales?.enabled === true && mode === 'fluid';
678
+
487
679
  // Generate base rules — sorted so shorthands (border) appear before longhands (border-color)
488
680
  for (const className of sortClassesByPropertyOrder(baseClasses)) {
489
- const rule = generateRuleForClass(className);
681
+ let rule = generateRuleForClass(className);
490
682
  if (rule) {
683
+ // In fluid mode, replace auto-responsive raw values with clamp() expressions.
684
+ if (fluidActive && autoResponsiveClasses.has(className)) {
685
+ rule = applyFluidToUtilityRule(rule, className, responsiveScales!, breakpoints);
686
+ }
491
687
  // Escape special characters in class name for CSS selector
492
688
  const escapedClassName = escapeCSSClassName(className);
493
689
  const finalRule = applyRemConversion(rule, remConfig);
@@ -499,10 +695,11 @@ export function generateUtilityCSS(
499
695
  type MediaQueryMap = Record<string, { classes: Array<{ className: string; rule: string }>; value: number }>;
500
696
  const autoResponsiveMediaQueries: MediaQueryMap = {};
501
697
 
502
- // Generate auto-responsive rules for classes with enabled scaling
503
- if (responsiveScales?.enabled) {
698
+ // Generate auto-responsive rules for classes with enabled scaling.
699
+ // In fluid mode the base rule already encodes scaling via clamp(), so skip @media.
700
+ if (responsiveScales?.enabled && !fluidActive) {
504
701
  for (const className of autoResponsiveClasses) {
505
- const propValue = extractPropertyAndValue(className);
702
+ const propValue = resolveScalablePropertyValue(className);
506
703
  if (!propValue) continue;
507
704
 
508
705
  const category = AUTO_RESPONSIVE_TYPE_MAP[propValue.property];
@@ -640,16 +837,27 @@ export function generateSingleClassCSS(
640
837
 
641
838
  if (!matched) {
642
839
  // Base (non-responsive) class
643
- const rule = generateRuleForClass(className);
840
+ let rule = generateRuleForClass(className);
644
841
  if (!rule) return '';
645
842
 
843
+ const mode = getResponsiveMode(responsiveScales);
844
+ const fluidActive = responsiveScales?.enabled === true && mode === 'fluid';
845
+
846
+ // In fluid mode, replace auto-responsive raw values with clamp().
847
+ if (fluidActive) {
848
+ const propValue = resolveScalablePropertyValue(className);
849
+ if (propValue && AUTO_RESPONSIVE_TYPE_MAP[propValue.property]) {
850
+ rule = applyFluidToUtilityRule(rule, className, responsiveScales!, breakpoints);
851
+ }
852
+ }
853
+
646
854
  const escapedClassName = escapeCSSClassName(className);
647
855
  const finalRule = applyRemConversion(rule, remConfig);
648
856
  css.push(`.${escapedClassName} { ${finalRule} }`);
649
857
 
650
- // Auto-responsive scaling
651
- if (responsiveScales?.enabled) {
652
- const propValue = extractPropertyAndValue(className);
858
+ // Auto-responsive scaling via @media — only in 'breakpoints' mode.
859
+ if (responsiveScales?.enabled && !fluidActive) {
860
+ const propValue = resolveScalablePropertyValue(className);
653
861
  if (propValue) {
654
862
  const category = AUTO_RESPONSIVE_TYPE_MAP[propValue.property];
655
863
  if (category) {
@@ -862,6 +1070,8 @@ export function generateInteractiveCSS(
862
1070
  // Extract breakpoint values for CSS media queries
863
1071
  const breakpointValues = getBreakpointValues(breakpoints);
864
1072
  const scalingEnabled = responsiveScales?.enabled === true;
1073
+ const mode = getResponsiveMode(responsiveScales);
1074
+ const fluidActive = scalingEnabled && mode === 'fluid';
865
1075
 
866
1076
  for (const rule of interactiveStyles) {
867
1077
  const { prefix, postfix, style } = rule;
@@ -878,9 +1088,15 @@ export function generateInteractiveCSS(
878
1088
  // Generate responsive rules
879
1089
  const responsive = style as ResponsiveStyleObject;
880
1090
 
881
- // Base styles
1091
+ // Base styles — in fluid mode rewrite scalable values to clamp()
1092
+ // and rewrite width===maxWidth into the container pattern.
882
1093
  if (responsive.base && Object.keys(responsive.base).length > 0) {
883
- const properties = applyRemConversion(styleObjectToCSS(responsive.base), remConfig);
1094
+ let baseStyle = responsive.base;
1095
+ if (fluidActive) {
1096
+ baseStyle = applyContainerPattern(baseStyle, true);
1097
+ baseStyle = applyFluidToStyle(baseStyle, responsiveScales!, breakpoints);
1098
+ }
1099
+ const properties = applyRemConversion(styleObjectToCSS(baseStyle), remConfig);
884
1100
  if (properties) {
885
1101
  css.push(`${fullSelector} { ${properties}; }`);
886
1102
  }
@@ -891,12 +1107,13 @@ export function generateInteractiveCSS(
891
1107
 
892
1108
  // Merge explicit breakpoint styles with auto-scaled values derived
893
1109
  // from the base, skipping any property the author explicitly set.
1110
+ // In fluid mode skip the auto-scaled portion — base already has clamp().
894
1111
  let merged: StyleObject | null = null;
895
1112
  if (explicit && Object.keys(explicit).length > 0) {
896
- merged = { ...explicit };
1113
+ merged = fluidActive ? applyContainerPattern({ ...explicit }, true) : { ...explicit };
897
1114
  }
898
1115
 
899
- if (scalingEnabled && responsive.base) {
1116
+ if (scalingEnabled && !fluidActive && responsive.base) {
900
1117
  const scaled = scaleStyleForBreakpoint(
901
1118
  responsive.base,
902
1119
  responsiveScales!,
@@ -921,13 +1138,18 @@ export function generateInteractiveCSS(
921
1138
  // Flat style object
922
1139
  const flatStyle = style as StyleObject;
923
1140
  if (Object.keys(flatStyle).length > 0) {
924
- const properties = applyRemConversion(styleObjectToCSS(flatStyle), remConfig);
1141
+ let baseFlat = flatStyle;
1142
+ if (fluidActive) {
1143
+ baseFlat = applyContainerPattern(baseFlat, true);
1144
+ baseFlat = applyFluidToStyle(baseFlat, responsiveScales!, breakpoints);
1145
+ }
1146
+ const properties = applyRemConversion(styleObjectToCSS(baseFlat), remConfig);
925
1147
  if (properties) {
926
1148
  css.push(`${fullSelector} { ${properties}; }`);
927
1149
  }
928
1150
 
929
- // Auto-scale the flat style into each enabled breakpoint.
930
- if (scalingEnabled) {
1151
+ // Auto-scale the flat style into each enabled breakpoint — breakpoints mode only.
1152
+ if (scalingEnabled && !fluidActive) {
931
1153
  for (const [breakpointName, breakpointValue] of sortedBreakpoints) {
932
1154
  const scaled = scaleStyleForBreakpoint(
933
1155
  flatStyle,