meno-core 1.0.47 → 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 (97) hide show
  1. package/build-astro.ts +2 -2
  2. package/dist/build-static.js +7 -7
  3. package/dist/chunks/{chunk-UUA5LEWF.js → chunk-6IVUG7FY.js} +138 -7
  4. package/dist/chunks/chunk-6IVUG7FY.js.map +7 -0
  5. package/dist/chunks/{chunk-XSWR3QLI.js → chunk-AZQYF6KE.js} +261 -130
  6. package/dist/chunks/chunk-AZQYF6KE.js.map +7 -0
  7. package/dist/chunks/{chunk-47UNLQUU.js → chunk-CHD5UCFF.js} +57 -12
  8. package/dist/chunks/chunk-CHD5UCFF.js.map +7 -0
  9. package/dist/chunks/{chunk-FGUZOYJX.js → chunk-EQYDSPBB.js} +435 -131
  10. package/dist/chunks/chunk-EQYDSPBB.js.map +7 -0
  11. package/dist/chunks/{chunk-IF3RATBY.js → chunk-H4JSCDNW.js} +2 -2
  12. package/dist/chunks/{chunk-KITQJYZV.js → chunk-J23ZX5AP.js} +40 -4
  13. package/dist/chunks/chunk-J23ZX5AP.js.map +7 -0
  14. package/dist/chunks/{chunk-LJFB5EBT.js → chunk-JER5NQVM.js} +5 -5
  15. package/dist/chunks/{chunk-ZTKHJQ2Z.js → chunk-KPU2XHOS.js} +5 -2
  16. package/dist/chunks/{chunk-ZTKHJQ2Z.js.map → chunk-KPU2XHOS.js.map} +2 -2
  17. package/dist/chunks/{chunk-BCLGRZ3U.js → chunk-LKAGAQ3M.js} +2 -2
  18. package/dist/chunks/{chunk-FED5MME6.js → chunk-S2CX6HFM.js} +262 -26
  19. package/dist/chunks/chunk-S2CX6HFM.js.map +7 -0
  20. package/dist/chunks/{configService-DYCUEURL.js → configService-CCA6AIDI.js} +3 -3
  21. package/dist/entries/server-router.js +9 -9
  22. package/dist/entries/server-router.js.map +2 -2
  23. package/dist/lib/client/index.js +64 -20
  24. package/dist/lib/client/index.js.map +3 -3
  25. package/dist/lib/server/index.js +1737 -296
  26. package/dist/lib/server/index.js.map +4 -4
  27. package/dist/lib/shared/index.js +50 -10
  28. package/dist/lib/shared/index.js.map +3 -3
  29. package/entries/server-router.tsx +6 -2
  30. package/lib/client/core/ComponentBuilder.test.ts +17 -0
  31. package/lib/client/core/ComponentBuilder.ts +25 -1
  32. package/lib/client/core/builders/embedBuilder.ts +15 -2
  33. package/lib/client/core/builders/linkNodeBuilder.ts +15 -2
  34. package/lib/client/core/builders/localeListBuilder.ts +17 -6
  35. package/lib/client/styles/StyleInjector.ts +3 -2
  36. package/lib/client/theme.ts +4 -4
  37. package/lib/server/cssGenerator.test.ts +64 -1
  38. package/lib/server/cssGenerator.ts +48 -9
  39. package/lib/server/index.ts +1 -1
  40. package/lib/server/jsonLoader.test.ts +0 -17
  41. package/lib/server/jsonLoader.ts +0 -81
  42. package/lib/server/providers/fileSystemCMSProvider.test.ts +163 -0
  43. package/lib/server/providers/fileSystemCMSProvider.ts +200 -11
  44. package/lib/server/routes/api/variables.ts +4 -2
  45. package/lib/server/routes/index.ts +1 -1
  46. package/lib/server/routes/pages.ts +23 -1
  47. package/lib/server/services/cmsService.test.ts +246 -0
  48. package/lib/server/services/cmsService.ts +122 -5
  49. package/lib/server/services/configService.ts +5 -0
  50. package/lib/server/ssr/attributeBuilder.ts +41 -0
  51. package/lib/server/ssr/htmlGenerator.test.ts +114 -2
  52. package/lib/server/ssr/htmlGenerator.ts +53 -6
  53. package/lib/server/ssr/liveReloadIntegration.test.ts +209 -0
  54. package/lib/server/ssr/ssrRenderer.test.ts +362 -1
  55. package/lib/server/ssr/ssrRenderer.ts +216 -72
  56. package/lib/server/utils/jsonLineMapper.test.ts +53 -1
  57. package/lib/server/utils/jsonLineMapper.ts +43 -3
  58. package/lib/server/webflow/buildWebflow.ts +343 -123
  59. package/lib/server/webflow/index.ts +1 -0
  60. package/lib/server/webflow/nodeToWebflow.test.ts +3170 -0
  61. package/lib/server/webflow/nodeToWebflow.ts +2141 -129
  62. package/lib/server/webflow/styleMapper.test.ts +389 -0
  63. package/lib/server/webflow/styleMapper.ts +517 -63
  64. package/lib/server/webflow/templateWrapper.ts +49 -0
  65. package/lib/server/webflow/types.ts +218 -18
  66. package/lib/shared/cssGeneration.test.ts +267 -1
  67. package/lib/shared/cssGeneration.ts +240 -18
  68. package/lib/shared/cssProperties.test.ts +247 -1
  69. package/lib/shared/cssProperties.ts +196 -6
  70. package/lib/shared/elementClassName.test.ts +15 -0
  71. package/lib/shared/elementClassName.ts +7 -3
  72. package/lib/shared/interfaces/contentProvider.ts +39 -6
  73. package/lib/shared/pathSecurity.ts +16 -0
  74. package/lib/shared/registry/nodeTypes/ListNodeType.ts +1 -1
  75. package/lib/shared/responsiveScaling.test.ts +143 -0
  76. package/lib/shared/responsiveScaling.ts +253 -2
  77. package/lib/shared/themeDefaults.test.ts +3 -3
  78. package/lib/shared/themeDefaults.ts +3 -3
  79. package/lib/shared/types/cms.ts +28 -3
  80. package/lib/shared/types/index.ts +2 -0
  81. package/lib/shared/types/variables.ts +37 -0
  82. package/lib/shared/utilityClassConfig.ts +3 -0
  83. package/lib/shared/utilityClassMapper.test.ts +123 -0
  84. package/lib/shared/utilityClassMapper.ts +179 -8
  85. package/lib/shared/validation/schemas.ts +15 -1
  86. package/lib/shared/validation/validators.ts +26 -1
  87. package/package.json +1 -1
  88. package/dist/chunks/chunk-47UNLQUU.js.map +0 -7
  89. package/dist/chunks/chunk-FED5MME6.js.map +0 -7
  90. package/dist/chunks/chunk-FGUZOYJX.js.map +0 -7
  91. package/dist/chunks/chunk-KITQJYZV.js.map +0 -7
  92. package/dist/chunks/chunk-UUA5LEWF.js.map +0 -7
  93. package/dist/chunks/chunk-XSWR3QLI.js.map +0 -7
  94. /package/dist/chunks/{chunk-IF3RATBY.js.map → chunk-H4JSCDNW.js.map} +0 -0
  95. /package/dist/chunks/{chunk-LJFB5EBT.js.map → chunk-JER5NQVM.js.map} +0 -0
  96. /package/dist/chunks/{chunk-BCLGRZ3U.js.map → chunk-LKAGAQ3M.js.map} +0 -0
  97. /package/dist/chunks/{configService-DYCUEURL.js.map → configService-CCA6AIDI.js.map} +0 -0
@@ -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,
@@ -2,12 +2,32 @@ import { describe, test, expect } from 'bun:test';
2
2
  import {
3
3
  CSS_PROPERTIES,
4
4
  CSS_PROPERTIES_DEFINITION,
5
+ CSS_PROPERTY_GROUPS,
5
6
  filterCSSProperties,
6
7
  getPropertyValues,
7
8
  filterPropertyValues,
8
- getPropertyType
9
+ getPropertyType,
10
+ VISUAL_MODE_PROPERTIES,
11
+ isVisualModeProperty,
12
+ VISUAL_MODE_RULES,
13
+ isVisualModeRowVisible,
14
+ type VisualModeRuleContext,
15
+ UNITLESS_PROPERTIES,
16
+ appendsPxByDefault,
9
17
  } from './cssProperties';
10
18
 
19
+ const baseCtx: VisualModeRuleContext = {
20
+ display: 'block',
21
+ parentDisplay: 'block',
22
+ position: 'static',
23
+ tagName: 'div',
24
+ hasPaddingLonghand: false,
25
+ hasMarginLonghand: false,
26
+ hasBackgroundImage: false,
27
+ };
28
+ const ctx = (overrides: Partial<VisualModeRuleContext> = {}): VisualModeRuleContext =>
29
+ ({ ...baseCtx, ...overrides });
30
+
11
31
  describe('cssProperties', () => {
12
32
  describe('CSS_PROPERTIES', () => {
13
33
  test('is an array of property names', () => {
@@ -249,4 +269,230 @@ describe('cssProperties', () => {
249
269
  expect(getPropertyType('padding')).toBe('string');
250
270
  });
251
271
  });
272
+
273
+ describe('VISUAL_MODE_PROPERTIES', () => {
274
+ test('contains common Webflow-style groups', () => {
275
+ expect(VISUAL_MODE_PROPERTIES['Layout']).toContain('display');
276
+ expect(VISUAL_MODE_PROPERTIES['Spacing']).toContain('padding');
277
+ expect(VISUAL_MODE_PROPERTIES['Typography']).toContain('fontSize');
278
+ });
279
+
280
+ test('every group name maps to a known CSS_PROPERTY_GROUPS group', () => {
281
+ for (const groupName of Object.keys(VISUAL_MODE_PROPERTIES)) {
282
+ expect(CSS_PROPERTY_GROUPS[groupName]).toBeDefined();
283
+ }
284
+ });
285
+
286
+ test('every property in VISUAL_MODE_PROPERTIES belongs to its declared group in CSS_PROPERTY_GROUPS', () => {
287
+ for (const [groupName, props] of Object.entries(VISUAL_MODE_PROPERTIES)) {
288
+ for (const prop of props) {
289
+ expect(CSS_PROPERTY_GROUPS[groupName]).toContain(prop);
290
+ }
291
+ }
292
+ });
293
+ });
294
+
295
+ describe('isVisualModeProperty', () => {
296
+ test('returns true for properties listed in VISUAL_MODE_PROPERTIES', () => {
297
+ expect(isVisualModeProperty('display')).toBe(true);
298
+ expect(isVisualModeProperty('padding')).toBe(true);
299
+ expect(isVisualModeProperty('color')).toBe(true);
300
+ });
301
+
302
+ test('returns false for properties not in VISUAL_MODE_PROPERTIES', () => {
303
+ expect(isVisualModeProperty('gridTemplateRows')).toBe(false);
304
+ expect(isVisualModeProperty('clipPath')).toBe(false);
305
+ expect(isVisualModeProperty('nonExistent')).toBe(false);
306
+ });
307
+ });
308
+
309
+ describe('VISUAL_MODE_RULES — flex/grid container props', () => {
310
+ test('flexDirection visible only when display is flex/inline-flex', () => {
311
+ expect(VISUAL_MODE_RULES.flexDirection(ctx({ display: 'flex' }))).toBe(true);
312
+ expect(VISUAL_MODE_RULES.flexDirection(ctx({ display: 'inline-flex' }))).toBe(true);
313
+ expect(VISUAL_MODE_RULES.flexDirection(ctx({ display: 'block' }))).toBe(false);
314
+ expect(VISUAL_MODE_RULES.flexDirection(ctx({ display: 'grid' }))).toBe(false);
315
+ });
316
+
317
+ test('justifyContent visible for flex AND grid', () => {
318
+ expect(VISUAL_MODE_RULES.justifyContent(ctx({ display: 'flex' }))).toBe(true);
319
+ expect(VISUAL_MODE_RULES.justifyContent(ctx({ display: 'grid' }))).toBe(true);
320
+ expect(VISUAL_MODE_RULES.justifyContent(ctx({ display: 'block' }))).toBe(false);
321
+ });
322
+
323
+ test('gridTemplateColumns visible only for grid/inline-grid', () => {
324
+ expect(VISUAL_MODE_RULES.gridTemplateColumns(ctx({ display: 'grid' }))).toBe(true);
325
+ expect(VISUAL_MODE_RULES.gridTemplateColumns(ctx({ display: 'inline-grid' }))).toBe(true);
326
+ expect(VISUAL_MODE_RULES.gridTemplateColumns(ctx({ display: 'flex' }))).toBe(false);
327
+ expect(VISUAL_MODE_RULES.gridTemplateColumns(ctx({ display: 'block' }))).toBe(false);
328
+ });
329
+
330
+ test('gap visible for both flex and grid containers', () => {
331
+ expect(VISUAL_MODE_RULES.gap(ctx({ display: 'flex' }))).toBe(true);
332
+ expect(VISUAL_MODE_RULES.gap(ctx({ display: 'grid' }))).toBe(true);
333
+ expect(VISUAL_MODE_RULES.gap(ctx({ display: 'block' }))).toBe(false);
334
+ });
335
+ });
336
+
337
+ describe('VISUAL_MODE_RULES — flex/grid item props (parent display)', () => {
338
+ test('flexGrow visible only when parent is flex', () => {
339
+ expect(VISUAL_MODE_RULES.flexGrow(ctx({ parentDisplay: 'flex' }))).toBe(true);
340
+ expect(VISUAL_MODE_RULES.flexGrow(ctx({ parentDisplay: 'inline-flex' }))).toBe(true);
341
+ expect(VISUAL_MODE_RULES.flexGrow(ctx({ parentDisplay: 'block' }))).toBe(false);
342
+ expect(VISUAL_MODE_RULES.flexGrow(ctx({ parentDisplay: 'grid' }))).toBe(false);
343
+ });
344
+
345
+ test('gridArea visible only when parent is grid', () => {
346
+ expect(VISUAL_MODE_RULES.gridArea(ctx({ parentDisplay: 'grid' }))).toBe(true);
347
+ expect(VISUAL_MODE_RULES.gridArea(ctx({ parentDisplay: 'inline-grid' }))).toBe(true);
348
+ expect(VISUAL_MODE_RULES.gridArea(ctx({ parentDisplay: 'flex' }))).toBe(false);
349
+ expect(VISUAL_MODE_RULES.gridArea(ctx({ parentDisplay: 'block' }))).toBe(false);
350
+ });
351
+
352
+ test('alignSelf visible when parent is flex OR grid', () => {
353
+ expect(VISUAL_MODE_RULES.alignSelf(ctx({ parentDisplay: 'flex' }))).toBe(true);
354
+ expect(VISUAL_MODE_RULES.alignSelf(ctx({ parentDisplay: 'grid' }))).toBe(true);
355
+ expect(VISUAL_MODE_RULES.alignSelf(ctx({ parentDisplay: 'block' }))).toBe(false);
356
+ });
357
+
358
+ test('item rules ignore own display — only parent display matters', () => {
359
+ // Element has display:block but parent is flex → flexGrow still visible.
360
+ expect(VISUAL_MODE_RULES.flexGrow(ctx({ display: 'block', parentDisplay: 'flex' }))).toBe(true);
361
+ // Element has display:flex but parent is block → flexGrow hidden.
362
+ expect(VISUAL_MODE_RULES.flexGrow(ctx({ display: 'flex', parentDisplay: 'block' }))).toBe(false);
363
+ });
364
+ });
365
+
366
+ describe('VISUAL_MODE_RULES — position-dependent', () => {
367
+ test('top/right/bottom/left/zIndex hidden when position is static', () => {
368
+ const c = ctx({ position: 'static' });
369
+ expect(VISUAL_MODE_RULES.top(c)).toBe(false);
370
+ expect(VISUAL_MODE_RULES.right(c)).toBe(false);
371
+ expect(VISUAL_MODE_RULES.bottom(c)).toBe(false);
372
+ expect(VISUAL_MODE_RULES.left(c)).toBe(false);
373
+ expect(VISUAL_MODE_RULES.zIndex(c)).toBe(false);
374
+ });
375
+
376
+ test('top/right/bottom/left/zIndex visible when position is non-static', () => {
377
+ for (const position of ['relative', 'absolute', 'fixed', 'sticky']) {
378
+ const c = ctx({ position });
379
+ expect(VISUAL_MODE_RULES.top(c)).toBe(true);
380
+ expect(VISUAL_MODE_RULES.zIndex(c)).toBe(true);
381
+ }
382
+ });
383
+ });
384
+
385
+ describe('VISUAL_MODE_RULES — tag-based', () => {
386
+ test('objectFit visible only for img/video', () => {
387
+ expect(VISUAL_MODE_RULES.objectFit(ctx({ tagName: 'img' }))).toBe(true);
388
+ expect(VISUAL_MODE_RULES.objectFit(ctx({ tagName: 'video' }))).toBe(true);
389
+ expect(VISUAL_MODE_RULES.objectFit(ctx({ tagName: 'div' }))).toBe(false);
390
+ });
391
+
392
+ test('listStyle visible only for ul/ol/li', () => {
393
+ expect(VISUAL_MODE_RULES.listStyle(ctx({ tagName: 'ul' }))).toBe(true);
394
+ expect(VISUAL_MODE_RULES.listStyle(ctx({ tagName: 'ol' }))).toBe(true);
395
+ expect(VISUAL_MODE_RULES.listStyle(ctx({ tagName: 'li' }))).toBe(true);
396
+ expect(VISUAL_MODE_RULES.listStyle(ctx({ tagName: 'div' }))).toBe(false);
397
+ });
398
+ });
399
+
400
+ describe('VISUAL_MODE_RULES — padding/margin shorthand-vs-longhand toggle', () => {
401
+ test('padding shown when no longhand set; longhands hidden', () => {
402
+ const c = ctx({ hasPaddingLonghand: false });
403
+ expect(VISUAL_MODE_RULES.padding(c)).toBe(true);
404
+ expect(VISUAL_MODE_RULES.paddingTop(c)).toBe(false);
405
+ expect(VISUAL_MODE_RULES.paddingRight(c)).toBe(false);
406
+ expect(VISUAL_MODE_RULES.paddingBottom(c)).toBe(false);
407
+ expect(VISUAL_MODE_RULES.paddingLeft(c)).toBe(false);
408
+ });
409
+
410
+ test('padding hidden when any longhand set; longhands shown', () => {
411
+ const c = ctx({ hasPaddingLonghand: true });
412
+ expect(VISUAL_MODE_RULES.padding(c)).toBe(false);
413
+ expect(VISUAL_MODE_RULES.paddingTop(c)).toBe(true);
414
+ expect(VISUAL_MODE_RULES.paddingRight(c)).toBe(true);
415
+ expect(VISUAL_MODE_RULES.paddingBottom(c)).toBe(true);
416
+ expect(VISUAL_MODE_RULES.paddingLeft(c)).toBe(true);
417
+ });
418
+
419
+ test('margin mirrors padding behavior', () => {
420
+ const noLong = ctx({ hasMarginLonghand: false });
421
+ expect(VISUAL_MODE_RULES.margin(noLong)).toBe(true);
422
+ expect(VISUAL_MODE_RULES.marginTop(noLong)).toBe(false);
423
+ const withLong = ctx({ hasMarginLonghand: true });
424
+ expect(VISUAL_MODE_RULES.margin(withLong)).toBe(false);
425
+ expect(VISUAL_MODE_RULES.marginTop(withLong)).toBe(true);
426
+ });
427
+ });
428
+
429
+ describe('isVisualModeRowVisible', () => {
430
+ test('returns true for properties without a rule', () => {
431
+ // width has no rule entry → always visible regardless of context.
432
+ expect(isVisualModeRowVisible('width', ctx())).toBe(true);
433
+ expect(isVisualModeRowVisible('color', ctx())).toBe(true);
434
+ expect(isVisualModeRowVisible('nonExistentProp', ctx())).toBe(true);
435
+ });
436
+
437
+ test('delegates to the rule for known properties', () => {
438
+ expect(isVisualModeRowVisible('flexDirection', ctx({ display: 'flex' }))).toBe(true);
439
+ expect(isVisualModeRowVisible('flexDirection', ctx({ display: 'block' }))).toBe(false);
440
+ expect(isVisualModeRowVisible('top', ctx({ position: 'absolute' }))).toBe(true);
441
+ expect(isVisualModeRowVisible('top', ctx({ position: 'static' }))).toBe(false);
442
+ });
443
+ });
444
+
445
+ describe('UNITLESS_PROPERTIES', () => {
446
+ test('contains the canonical unitless props', () => {
447
+ expect(UNITLESS_PROPERTIES.has('fontWeight')).toBe(true);
448
+ expect(UNITLESS_PROPERTIES.has('lineHeight')).toBe(true);
449
+ expect(UNITLESS_PROPERTIES.has('opacity')).toBe(true);
450
+ expect(UNITLESS_PROPERTIES.has('zIndex')).toBe(true);
451
+ expect(UNITLESS_PROPERTIES.has('flexGrow')).toBe(true);
452
+ expect(UNITLESS_PROPERTIES.has('order')).toBe(true);
453
+ expect(UNITLESS_PROPERTIES.has('aspectRatio')).toBe(true);
454
+ });
455
+
456
+ test('does not contain length-accepting props', () => {
457
+ expect(UNITLESS_PROPERTIES.has('width')).toBe(false);
458
+ expect(UNITLESS_PROPERTIES.has('padding')).toBe(false);
459
+ expect(UNITLESS_PROPERTIES.has('fontSize')).toBe(false);
460
+ });
461
+ });
462
+
463
+ describe('appendsPxByDefault', () => {
464
+ test('returns true for length-accepting props', () => {
465
+ expect(appendsPxByDefault('width')).toBe(true);
466
+ expect(appendsPxByDefault('height')).toBe(true);
467
+ expect(appendsPxByDefault('padding')).toBe(true);
468
+ expect(appendsPxByDefault('paddingTop')).toBe(true);
469
+ expect(appendsPxByDefault('margin')).toBe(true);
470
+ expect(appendsPxByDefault('gap')).toBe(true);
471
+ expect(appendsPxByDefault('fontSize')).toBe(true);
472
+ expect(appendsPxByDefault('borderRadius')).toBe(true);
473
+ expect(appendsPxByDefault('top')).toBe(true);
474
+ expect(appendsPxByDefault('left')).toBe(true);
475
+ });
476
+
477
+ test('returns false for unitless props', () => {
478
+ expect(appendsPxByDefault('lineHeight')).toBe(false);
479
+ expect(appendsPxByDefault('fontWeight')).toBe(false);
480
+ expect(appendsPxByDefault('opacity')).toBe(false);
481
+ expect(appendsPxByDefault('zIndex')).toBe(false);
482
+ expect(appendsPxByDefault('flexGrow')).toBe(false);
483
+ expect(appendsPxByDefault('order')).toBe(false);
484
+ expect(appendsPxByDefault('aspectRatio')).toBe(false);
485
+ });
486
+
487
+ test('returns false for keyword-only (select / boolean) props', () => {
488
+ expect(appendsPxByDefault('display')).toBe(false);
489
+ expect(appendsPxByDefault('position')).toBe(false);
490
+ expect(appendsPxByDefault('borderStyle')).toBe(false);
491
+ });
492
+
493
+ test('defaults to true for properties without a recognized unitless / keyword type', () => {
494
+ expect(appendsPxByDefault('inset')).toBe(true);
495
+ expect(appendsPxByDefault('someUnknownNewCSSProperty')).toBe(true);
496
+ });
497
+ });
252
498
  });