meno-core 1.0.44 → 1.0.46

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 (79) hide show
  1. package/build-astro.ts +214 -63
  2. package/dist/bin/cli.js +2 -2
  3. package/dist/build-static.js +9 -9
  4. package/dist/chunks/{chunk-BXB6AX76.js → chunk-2QK6U5UK.js} +7 -3
  5. package/dist/chunks/chunk-2QK6U5UK.js.map +7 -0
  6. package/dist/chunks/{chunk-3XXLBUYA.js → chunk-77ZB6353.js} +33 -21
  7. package/dist/chunks/chunk-77ZB6353.js.map +7 -0
  8. package/dist/chunks/{chunk-5TJA272J.js → chunk-C6U5T5S5.js} +9 -9
  9. package/dist/chunks/{chunk-KPNSCQNA.js → chunk-FED5MME6.js} +285 -19
  10. package/dist/chunks/chunk-FED5MME6.js.map +7 -0
  11. package/dist/chunks/{chunk-5Z5VQRTJ.js → chunk-I7YIGZXT.js} +4 -4
  12. package/dist/chunks/{chunk-5Z5VQRTJ.js.map → chunk-I7YIGZXT.js.map} +2 -2
  13. package/dist/chunks/{chunk-46AVQA4V.js → chunk-IF3RATBY.js} +2 -2
  14. package/dist/chunks/{chunk-S2HXJTAF.js → chunk-KITQJYZV.js} +5 -1
  15. package/dist/chunks/chunk-KITQJYZV.js.map +7 -0
  16. package/dist/chunks/{chunk-G3WIPJN3.js → chunk-ORN7S4AP.js} +6 -6
  17. package/dist/chunks/{chunk-2NBGG2LJ.js → chunk-UUA5LEWF.js} +89 -4
  18. package/dist/chunks/{chunk-2NBGG2LJ.js.map → chunk-UUA5LEWF.js.map} +2 -2
  19. package/dist/chunks/{chunk-X5SH4HXS.js → chunk-XSWR3QLI.js} +33 -1
  20. package/dist/chunks/chunk-XSWR3QLI.js.map +7 -0
  21. package/dist/chunks/{chunk-YEJVSNVF.js → chunk-ZTKHJQ2Z.js} +14 -3
  22. package/dist/chunks/chunk-ZTKHJQ2Z.js.map +7 -0
  23. package/dist/chunks/{chunk-OLNQMCSR.js → chunk-ZWYDT3QJ.js} +3 -3
  24. package/dist/chunks/{configService-KYO3TXDS.js → configService-DYCUEURL.js} +4 -4
  25. package/dist/chunks/{constants-34NUGHQR.js → constants-GWBAD66U.js} +2 -2
  26. package/dist/entries/server-router.js +10 -10
  27. package/dist/lib/client/index.js +5 -5
  28. package/dist/lib/server/index.js +589 -145
  29. package/dist/lib/server/index.js.map +3 -3
  30. package/dist/lib/shared/index.js +14 -6
  31. package/dist/lib/shared/index.js.map +2 -2
  32. package/dist/lib/test-utils/index.js +1 -1
  33. package/lib/client/templateEngine.test.ts +64 -0
  34. package/lib/server/astro/astroEmitHelpers.ts +18 -0
  35. package/lib/server/astro/cmsPageEmitter.ts +31 -1
  36. package/lib/server/astro/componentEmitter.test.ts +59 -0
  37. package/lib/server/astro/componentEmitter.ts +43 -10
  38. package/lib/server/astro/cssCollector.ts +58 -11
  39. package/lib/server/astro/nodeToAstro.test.ts +397 -5
  40. package/lib/server/astro/nodeToAstro.ts +478 -63
  41. package/lib/server/astro/pageEmitter.ts +31 -1
  42. package/lib/server/astro/tailwindMapper.test.ts +119 -0
  43. package/lib/server/astro/tailwindMapper.ts +67 -1
  44. package/lib/server/runtime/httpServer.ts +12 -4
  45. package/lib/server/services/configService.ts +13 -0
  46. package/lib/server/ssr/htmlGenerator.ts +5 -2
  47. package/lib/server/ssr/jsCollector.ts +2 -2
  48. package/lib/server/ssr/ssrRenderer.test.ts +32 -0
  49. package/lib/server/ssr/ssrRenderer.ts +26 -11
  50. package/lib/shared/constants.ts +9 -2
  51. package/lib/shared/cssGeneration.test.ts +109 -3
  52. package/lib/shared/cssGeneration.ts +109 -13
  53. package/lib/shared/cssNamedColors.ts +47 -0
  54. package/lib/shared/cssProperties.ts +99 -4
  55. package/lib/shared/index.ts +1 -0
  56. package/lib/shared/nodeUtils.ts +28 -0
  57. package/lib/shared/responsiveScaling.test.ts +20 -3
  58. package/lib/shared/responsiveScaling.ts +55 -1
  59. package/lib/shared/responsiveStyleUtils.test.ts +7 -8
  60. package/lib/shared/responsiveStyleUtils.ts +6 -7
  61. package/lib/shared/types/components.ts +1 -1
  62. package/lib/shared/types/variables.test.ts +9 -2
  63. package/lib/shared/types/variables.ts +5 -1
  64. package/lib/shared/utilityClassMapper.ts +14 -1
  65. package/lib/shared/validation/propValidator.ts +6 -0
  66. package/lib/shared/validation/schemas.ts +2 -2
  67. package/package.json +1 -1
  68. package/dist/chunks/chunk-3XXLBUYA.js.map +0 -7
  69. package/dist/chunks/chunk-BXB6AX76.js.map +0 -7
  70. package/dist/chunks/chunk-KPNSCQNA.js.map +0 -7
  71. package/dist/chunks/chunk-S2HXJTAF.js.map +0 -7
  72. package/dist/chunks/chunk-X5SH4HXS.js.map +0 -7
  73. package/dist/chunks/chunk-YEJVSNVF.js.map +0 -7
  74. /package/dist/chunks/{chunk-5TJA272J.js.map → chunk-C6U5T5S5.js.map} +0 -0
  75. /package/dist/chunks/{chunk-46AVQA4V.js.map → chunk-IF3RATBY.js.map} +0 -0
  76. /package/dist/chunks/{chunk-G3WIPJN3.js.map → chunk-ORN7S4AP.js.map} +0 -0
  77. /package/dist/chunks/{chunk-OLNQMCSR.js.map → chunk-ZWYDT3QJ.js.map} +0 -0
  78. /package/dist/chunks/{configService-KYO3TXDS.js.map → configService-DYCUEURL.js.map} +0 -0
  79. /package/dist/chunks/{constants-34NUGHQR.js.map → constants-GWBAD66U.js.map} +0 -0
@@ -1,5 +1,8 @@
1
1
  import { describe, expect, test } from 'bun:test';
2
- import { generateRuleForClass, generateUtilityCSS, extractUtilityClassesFromHTML } from './cssGeneration';
2
+ import { generateRuleForClass, generateUtilityCSS, generateSingleClassCSS, extractUtilityClassesFromHTML, generateInteractiveCSS } from './cssGeneration';
3
+ import { DEFAULT_BREAKPOINTS } from './breakpoints';
4
+ import type { ResponsiveScales } from './responsiveScaling';
5
+ import type { InteractiveStyles } from './types/styles';
3
6
 
4
7
  describe('extractUtilityClassesFromHTML', () => {
5
8
  test('extracts ins- classes', () => {
@@ -102,9 +105,33 @@ describe('cssGeneration', () => {
102
105
  expect(rule).toBe('border: 1px solid var(--primary);');
103
106
  });
104
107
 
105
- test('border-color generates correctly', () => {
108
+ test('border-color with named CSS color outputs directly', () => {
106
109
  const rule = generateRuleForClass('bc-red');
107
- expect(rule).toBe('border-color: var(--red);');
110
+ expect(rule).toBe('border-color: red;');
111
+ });
112
+
113
+ test('border-color with variable uses var()', () => {
114
+ const rule = generateRuleForClass('bc-primary');
115
+ expect(rule).toBe('border-color: var(--primary);');
116
+ });
117
+ });
118
+
119
+ describe('CSS named colors vs variables', () => {
120
+ test('color with named color outputs directly', () => {
121
+ expect(generateRuleForClass('c-red')).toBe('color: red;');
122
+ expect(generateRuleForClass('c-blue')).toBe('color: blue;');
123
+ expect(generateRuleForClass('c-transparent')).toBe('color: transparent;');
124
+ });
125
+
126
+ test('background-color with named color outputs directly', () => {
127
+ expect(generateRuleForClass('bgc-green')).toBe('background-color: green;');
128
+ expect(generateRuleForClass('bgc-white')).toBe('background-color: white;');
129
+ });
130
+
131
+ test('color with variable name still uses var()', () => {
132
+ expect(generateRuleForClass('c-primary')).toBe('color: var(--primary);');
133
+ expect(generateRuleForClass('c-text')).toBe('color: var(--text);');
134
+ expect(generateRuleForClass('bgc-background')).toBe('background-color: var(--background);');
108
135
  });
109
136
  });
110
137
 
@@ -190,3 +217,82 @@ describe('cssGeneration', () => {
190
217
  });
191
218
  });
192
219
  });
220
+
221
+ describe('generateSingleClassCSS - responsive classes', () => {
222
+ test('generates correct media query for tablet-prefixed class', () => {
223
+ const css = generateSingleClassCSS('t-fs-132px', DEFAULT_BREAKPOINTS);
224
+ expect(css).toContain('@media (max-width: 1024px)');
225
+ expect(css).toContain('font-size: 132px');
226
+ expect(css).toContain('.t-fs-132px');
227
+ });
228
+
229
+ test('generates correct media query for mobile-prefixed class', () => {
230
+ const css = generateSingleClassCSS('mob-fs-24px', DEFAULT_BREAKPOINTS);
231
+ expect(css).toContain('@media (max-width: 540px)');
232
+ expect(css).toContain('font-size: 24px');
233
+ expect(css).toContain('.mob-fs-24px');
234
+ });
235
+
236
+ test('generates base rule for non-prefixed class', () => {
237
+ const css = generateSingleClassCSS('fs-48px', DEFAULT_BREAKPOINTS);
238
+ expect(css).toContain('.fs-48px');
239
+ expect(css).toContain('font-size: 48px');
240
+ expect(css).not.toContain('@media');
241
+ });
242
+ });
243
+
244
+ describe('generateInteractiveCSS — auto-responsive scaling', () => {
245
+ const scalesEnabled: ResponsiveScales = {
246
+ enabled: true,
247
+ baseReference: 16,
248
+ padding: { tablet: 0.75, mobile: 0.5 },
249
+ fontSize: { tablet: 0.88, mobile: 0.75 },
250
+ };
251
+
252
+ test('flat interactive style gets scaled @media overrides', () => {
253
+ const styles: InteractiveStyles = [
254
+ { prefix: '', postfix: ':hover', style: { fontSize: '100px' } },
255
+ ];
256
+ const css = generateInteractiveCSS('c_heading_text', styles, DEFAULT_BREAKPOINTS, undefined, scalesEnabled);
257
+ expect(css).toContain('.c_heading_text:hover { font-size: 100px; }');
258
+ // tablet: 100 + (100-16)*(0.88-1) = 89.92 → 90
259
+ expect(css).toContain('@media (max-width: 1024px) { .c_heading_text:hover { font-size: 90px; } }');
260
+ // mobile: 100 + (100-16)*(0.75-1) = 79
261
+ expect(css).toContain('@media (max-width: 540px) { .c_heading_text:hover { font-size: 79px; } }');
262
+ });
263
+
264
+ test('responsive style auto-scales properties not explicitly overridden', () => {
265
+ const styles: InteractiveStyles = [
266
+ {
267
+ prefix: '',
268
+ postfix: ':hover',
269
+ style: {
270
+ base: { padding: '40px', fontSize: '100px' },
271
+ tablet: { fontSize: '50px' }, // explicit tablet fontSize wins
272
+ },
273
+ },
274
+ ];
275
+ const css = generateInteractiveCSS('c_btn', styles, DEFAULT_BREAKPOINTS, undefined, scalesEnabled);
276
+ // Tablet @media must contain the explicit fontSize AND the auto-scaled padding
277
+ // padding tablet: 40 + (40-16)*(0.75-1) = 34
278
+ expect(css).toMatch(/@media \(max-width: 1024px\) \{[^}]*font-size:\s*50px[^}]*\}/);
279
+ expect(css).toMatch(/@media \(max-width: 1024px\) \{[^}]*padding:\s*34px[^}]*\}/);
280
+ // Mobile: no explicit branch — both padding and fontSize auto-scale from base
281
+ // padding mobile: 40 + (40-16)*(-0.5) = 28; fontSize mobile: 79
282
+ expect(css).toMatch(/@media \(max-width: 540px\) \{[^}]*padding:\s*28px[^}]*\}/);
283
+ expect(css).toMatch(/@media \(max-width: 540px\) \{[^}]*font-size:\s*79px[^}]*\}/);
284
+ });
285
+
286
+ test('scaling disabled is a no-op', () => {
287
+ const styles: InteractiveStyles = [
288
+ { prefix: '', postfix: ':hover', style: { fontSize: '100px' } },
289
+ ];
290
+ const cssNoScales = generateInteractiveCSS('c_heading', styles, DEFAULT_BREAKPOINTS);
291
+ const cssDisabled = generateInteractiveCSS('c_heading', styles, DEFAULT_BREAKPOINTS, undefined, {
292
+ ...scalesEnabled,
293
+ enabled: false,
294
+ });
295
+ expect(cssNoScales).toBe(cssDisabled);
296
+ expect(cssNoScales).not.toContain('@media');
297
+ });
298
+ });
@@ -6,10 +6,11 @@
6
6
 
7
7
  import { prefixToCSSProperty, propertyMap } from './utilityClassConfig';
8
8
  import { getStyleValue, getDynamicStyle, isDynamicClass } from './styleValueRegistry';
9
+ import { isCssNamedColor } from './cssNamedColors';
9
10
  import type { BreakpointConfig, LegacyBreakpointConfig } from './breakpoints';
10
11
  import { DEFAULT_BREAKPOINTS, getBreakpointValues } from './breakpoints';
11
- import type { ResponsiveScales } from './responsiveScaling';
12
- import { scalePropertyValue } from './responsiveScaling';
12
+ import type { ResponsiveScales, CSSPropertyType } from './responsiveScaling';
13
+ import { scalePropertyValue, getScaleMultiplier } from './responsiveScaling';
13
14
  import type { InteractiveStyles, StyleObject, ResponsiveStyleObject, StyleValue } from './types/styles';
14
15
  import type { RemConversionConfig } from './pxToRem';
15
16
  import { applyRemConversion, convertPxToRem, shouldConvertProperty } from './pxToRem';
@@ -84,6 +85,17 @@ const AUTO_RESPONSIVE_TYPE_MAP: Record<string, string> = {
84
85
  'gap': 'gap',
85
86
  'row-gap': 'gap',
86
87
  'column-gap': 'gap',
88
+ 'border-radius': 'borderRadius',
89
+ 'border-top-left-radius': 'borderRadius',
90
+ 'border-top-right-radius': 'borderRadius',
91
+ 'border-bottom-left-radius': 'borderRadius',
92
+ 'border-bottom-right-radius': 'borderRadius',
93
+ 'width': 'size',
94
+ 'height': 'size',
95
+ 'max-width': 'size',
96
+ 'max-height': 'size',
97
+ 'min-width': 'size',
98
+ 'min-height': 'size',
87
99
  };
88
100
 
89
101
  /**
@@ -384,7 +396,11 @@ export function generateRuleForClass(className: string): string | null {
384
396
  // unless it's a hex color (#...) or RGB value
385
397
  if ((prefix === 'bgc' || prefix === 'bg' || prefix === 'c' || prefix === 'bc')) {
386
398
  // If it's not a hex color, RGB value, or pixel value, treat it as a CSS variable name
399
+ // Unless it's a CSS named color (red, blue, transparent, etc.) — use directly
387
400
  if (!value.startsWith('#') && !value.includes('rgb') && !value.includes('px')) {
401
+ if (isCssNamedColor(value)) {
402
+ return `${cssProp}: ${value};`;
403
+ }
388
404
  return `${cssProp}: var(--${value});`;
389
405
  }
390
406
  }
@@ -838,12 +854,14 @@ export function generateInteractiveCSS(
838
854
  elementClass: string,
839
855
  interactiveStyles: InteractiveStyles,
840
856
  breakpoints: BreakpointConfig = DEFAULT_BREAKPOINTS,
841
- remConfig?: RemConversionConfig
857
+ remConfig?: RemConversionConfig,
858
+ responsiveScales?: ResponsiveScales
842
859
  ): string {
843
860
  const css: string[] = [];
844
861
 
845
862
  // Extract breakpoint values for CSS media queries
846
863
  const breakpointValues = getBreakpointValues(breakpoints);
864
+ const scalingEnabled = responsiveScales?.enabled === true;
847
865
 
848
866
  for (const rule of interactiveStyles) {
849
867
  const { prefix, postfix, style } = rule;
@@ -851,6 +869,11 @@ export function generateInteractiveCSS(
851
869
  // Build full selector: {prefix}.element{postfix}
852
870
  const fullSelector = `${prefix || ''}.${elementClass}${postfix || ''}`;
853
871
 
872
+ // Breakpoint styles (sorted by value descending)
873
+ const sortedBreakpoints = Object.entries(breakpointValues).sort(
874
+ ([, a], [, b]) => b - a
875
+ );
876
+
854
877
  if (isResponsiveStyle(style)) {
855
878
  // Generate responsive rules
856
879
  const responsive = style as ResponsiveStyleObject;
@@ -863,15 +886,30 @@ export function generateInteractiveCSS(
863
886
  }
864
887
  }
865
888
 
866
- // Breakpoint styles (sorted by value descending)
867
- const sortedBreakpoints = Object.entries(breakpointValues).sort(
868
- ([, a], [, b]) => b - a
869
- );
870
-
871
889
  for (const [breakpointName, breakpointValue] of sortedBreakpoints) {
872
- const breakpointStyles = responsive[breakpointName];
873
- if (breakpointStyles && Object.keys(breakpointStyles).length > 0) {
874
- const properties = applyRemConversion(styleObjectToCSS(breakpointStyles), remConfig);
890
+ const explicit = responsive[breakpointName];
891
+
892
+ // Merge explicit breakpoint styles with auto-scaled values derived
893
+ // from the base, skipping any property the author explicitly set.
894
+ let merged: StyleObject | null = null;
895
+ if (explicit && Object.keys(explicit).length > 0) {
896
+ merged = { ...explicit };
897
+ }
898
+
899
+ if (scalingEnabled && responsive.base) {
900
+ const scaled = scaleStyleForBreakpoint(
901
+ responsive.base,
902
+ responsiveScales!,
903
+ breakpointName,
904
+ new Set(Object.keys(explicit ?? {}))
905
+ );
906
+ if (Object.keys(scaled).length > 0) {
907
+ merged = { ...(merged ?? {}), ...scaled };
908
+ }
909
+ }
910
+
911
+ if (merged && Object.keys(merged).length > 0) {
912
+ const properties = applyRemConversion(styleObjectToCSS(merged), remConfig);
875
913
  if (properties) {
876
914
  css.push(
877
915
  `@media (max-width: ${breakpointValue}px) { ${fullSelector} { ${properties}; } }`
@@ -887,6 +925,24 @@ export function generateInteractiveCSS(
887
925
  if (properties) {
888
926
  css.push(`${fullSelector} { ${properties}; }`);
889
927
  }
928
+
929
+ // Auto-scale the flat style into each enabled breakpoint.
930
+ if (scalingEnabled) {
931
+ for (const [breakpointName, breakpointValue] of sortedBreakpoints) {
932
+ const scaled = scaleStyleForBreakpoint(
933
+ flatStyle,
934
+ responsiveScales!,
935
+ breakpointName,
936
+ new Set()
937
+ );
938
+ if (Object.keys(scaled).length === 0) continue;
939
+ const scaledProps = applyRemConversion(styleObjectToCSS(scaled), remConfig);
940
+ if (!scaledProps) continue;
941
+ css.push(
942
+ `@media (max-width: ${breakpointValue}px) { ${fullSelector} { ${scaledProps}; } }`
943
+ );
944
+ }
945
+ }
890
946
  }
891
947
  }
892
948
  }
@@ -894,6 +950,45 @@ export function generateInteractiveCSS(
894
950
  return css.join('\n');
895
951
  }
896
952
 
953
+ /**
954
+ * Build a new style object containing auto-scaled values for every property
955
+ * in a given base style that falls into a scale category at `breakpointName`.
956
+ * Properties listed in `skipProperties` are left out (the author has set an
957
+ * explicit override at that breakpoint).
958
+ */
959
+ function scaleStyleForBreakpoint(
960
+ baseStyle: StyleObject,
961
+ responsiveScales: ResponsiveScales,
962
+ breakpointName: string,
963
+ skipProperties: Set<string>
964
+ ): StyleObject {
965
+ const result: StyleObject = {};
966
+ const baseRef = responsiveScales.baseReference ?? 16;
967
+
968
+ for (const [property, value] of Object.entries(baseStyle)) {
969
+ if (skipProperties.has(property)) continue;
970
+ if (typeof value === 'object' && value !== null && '_mapping' in value) continue;
971
+ if (value == null) continue;
972
+
973
+ const strValue = String(value);
974
+ if (strValue === '') continue;
975
+
976
+ const scale = getScaleMultiplier(
977
+ responsiveScales,
978
+ property as CSSPropertyType,
979
+ breakpointName
980
+ );
981
+ if (scale == null) continue;
982
+
983
+ const scaledValue = scalePropertyValue(strValue, baseRef, scale);
984
+ if (scaledValue == null || scaledValue === strValue) continue;
985
+
986
+ result[property] = scaledValue;
987
+ }
988
+
989
+ return result;
990
+ }
991
+
897
992
  /**
898
993
  * Collect and generate CSS for all interactive styles in a page/component
899
994
  *
@@ -904,12 +999,13 @@ export function generateInteractiveCSS(
904
999
  export function generateAllInteractiveCSS(
905
1000
  interactiveStylesMap: Map<string, InteractiveStyles>,
906
1001
  breakpoints: BreakpointConfig = DEFAULT_BREAKPOINTS,
907
- remConfig?: RemConversionConfig
1002
+ remConfig?: RemConversionConfig,
1003
+ responsiveScales?: ResponsiveScales
908
1004
  ): string {
909
1005
  const cssBlocks: string[] = [];
910
1006
 
911
1007
  for (const [elementClass, styles] of interactiveStylesMap) {
912
- const css = generateInteractiveCSS(elementClass, styles, breakpoints, remConfig);
1008
+ const css = generateInteractiveCSS(elementClass, styles, breakpoints, remConfig, responsiveScales);
913
1009
  if (css) {
914
1010
  cssBlocks.push(css);
915
1011
  }
@@ -0,0 +1,47 @@
1
+ /**
2
+ * CSS Named Colors
3
+ * Complete set of CSS Level 4 named colors + keywords
4
+ * Used to distinguish named colors from CSS variable references
5
+ */
6
+
7
+ export const CSS_NAMED_COLORS: ReadonlySet<string> = new Set([
8
+ // CSS Level 4 named colors
9
+ 'aliceblue', 'antiquewhite', 'aqua', 'aquamarine', 'azure',
10
+ 'beige', 'bisque', 'black', 'blanchedalmond', 'blue', 'blueviolet', 'brown', 'burlywood',
11
+ 'cadetblue', 'chartreuse', 'chocolate', 'coral', 'cornflowerblue', 'cornsilk', 'crimson', 'cyan',
12
+ 'darkblue', 'darkcyan', 'darkgoldenrod', 'darkgray', 'darkgreen', 'darkgrey', 'darkkhaki',
13
+ 'darkmagenta', 'darkolivegreen', 'darkorange', 'darkorchid', 'darkred', 'darksalmon',
14
+ 'darkseagreen', 'darkslateblue', 'darkslategray', 'darkslategrey', 'darkturquoise', 'darkviolet',
15
+ 'deeppink', 'deepskyblue', 'dimgray', 'dimgrey', 'dodgerblue',
16
+ 'firebrick', 'floralwhite', 'forestgreen', 'fuchsia',
17
+ 'gainsboro', 'ghostwhite', 'gold', 'goldenrod', 'gray', 'green', 'greenyellow', 'grey',
18
+ 'honeydew', 'hotpink',
19
+ 'indianred', 'indigo', 'ivory',
20
+ 'khaki',
21
+ 'lavender', 'lavenderblush', 'lawngreen', 'lemonchiffon', 'lightblue', 'lightcoral', 'lightcyan',
22
+ 'lightgoldenrodyellow', 'lightgray', 'lightgreen', 'lightgrey', 'lightpink', 'lightsalmon',
23
+ 'lightseagreen', 'lightskyblue', 'lightslategray', 'lightslategrey', 'lightsteelblue', 'lightyellow',
24
+ 'lime', 'limegreen', 'linen',
25
+ 'magenta', 'maroon', 'mediumaquamarine', 'mediumblue', 'mediumorchid', 'mediumpurple',
26
+ 'mediumseagreen', 'mediumslateblue', 'mediumspringgreen', 'mediumturquoise', 'mediumvioletred',
27
+ 'midnightblue', 'mintcream', 'mistyrose', 'moccasin',
28
+ 'navajowhite', 'navy',
29
+ 'oldlace', 'olive', 'olivedrab', 'orange', 'orangered', 'orchid',
30
+ 'palegoldenrod', 'palegreen', 'paleturquoise', 'palevioletred', 'papayawhip', 'peachpuff',
31
+ 'peru', 'pink', 'plum', 'powderblue', 'purple',
32
+ 'rebeccapurple', 'red', 'rosybrown', 'royalblue',
33
+ 'saddlebrown', 'salmon', 'sandybrown', 'seagreen', 'seashell', 'sienna', 'silver', 'skyblue',
34
+ 'slateblue', 'slategray', 'slategrey', 'snow', 'springgreen', 'steelblue',
35
+ 'tan', 'teal', 'thistle', 'tomato', 'turquoise',
36
+ 'violet',
37
+ 'wheat', 'white', 'whitesmoke',
38
+ 'yellow', 'yellowgreen',
39
+ // CSS color keywords
40
+ 'transparent', 'currentcolor',
41
+ // CSS-wide keywords (should never be wrapped in var())
42
+ 'inherit', 'initial', 'unset', 'revert',
43
+ ]);
44
+
45
+ export function isCssNamedColor(value: string): boolean {
46
+ return CSS_NAMED_COLORS.has(value.toLowerCase());
47
+ }
@@ -327,6 +327,95 @@ export const CSS_PROPERTIES_DEFINITION: Record<string, CSSPropertyDefinition> =
327
327
  */
328
328
  export const CSS_PROPERTIES = Object.keys(CSS_PROPERTIES_DEFINITION);
329
329
 
330
+ /**
331
+ * Popularity tiers for CSS properties (lower = more common).
332
+ * Properties not listed default to tier 3.
333
+ * Used to sort autocomplete suggestions within each match group.
334
+ */
335
+ const CSS_PROPERTY_PRIORITY: Record<string, number> = {
336
+ // Tier 1: Most commonly used
337
+ display: 1,
338
+ position: 1,
339
+ width: 1,
340
+ height: 1,
341
+ margin: 1,
342
+ padding: 1,
343
+ color: 1,
344
+ backgroundColor: 1,
345
+ fontSize: 1,
346
+ fontWeight: 1,
347
+ border: 1,
348
+ borderRadius: 1,
349
+ flex: 1,
350
+ flexDirection: 1,
351
+ justifyContent: 1,
352
+ alignItems: 1,
353
+ gap: 1,
354
+ opacity: 1,
355
+ overflow: 1,
356
+ zIndex: 1,
357
+ top: 1,
358
+ right: 1,
359
+ bottom: 1,
360
+ left: 1,
361
+ cursor: 1,
362
+ background: 1,
363
+ boxShadow: 1,
364
+ transition: 1,
365
+ transform: 1,
366
+ lineHeight: 1,
367
+ textAlign: 1,
368
+
369
+ // Tier 2: Common but less frequent
370
+ maxWidth: 2,
371
+ maxHeight: 2,
372
+ minWidth: 3,
373
+ minHeight: 3,
374
+ marginTop: 2,
375
+ marginBottom: 2,
376
+ marginLeft: 2,
377
+ marginRight: 2,
378
+ paddingTop: 2,
379
+ paddingBottom: 2,
380
+ paddingLeft: 2,
381
+ paddingRight: 2,
382
+ fontFamily: 2,
383
+ fontStyle: 2,
384
+ textDecoration: 2,
385
+ textTransform: 2,
386
+ letterSpacing: 2,
387
+ borderWidth: 2,
388
+ borderStyle: 2,
389
+ borderColor: 2,
390
+ borderBottom: 2,
391
+ borderTop: 2,
392
+ flexWrap: 2,
393
+ flexGrow: 2,
394
+ flexShrink: 2,
395
+ flexBasis: 2,
396
+ alignSelf: 2,
397
+ gridTemplateColumns: 2,
398
+ gridTemplateRows: 2,
399
+ backgroundImage: 2,
400
+ backgroundSize: 2,
401
+ backgroundPosition: 2,
402
+ whiteSpace: 2,
403
+ textOverflow: 2,
404
+ visibility: 2,
405
+ pointerEvents: 2,
406
+ userSelect: 2,
407
+ boxSizing: 2,
408
+ objectFit: 2,
409
+ filter: 2,
410
+ animation: 2,
411
+ outline: 2,
412
+ inset: 2,
413
+ };
414
+
415
+ function getPropertyPriority(property: string): number {
416
+ return CSS_PROPERTY_PRIORITY[property] ?? 3;
417
+ }
418
+
330
419
  /**
331
420
  * CSS Property Groups for organizing styles in the editor
332
421
  * Order determines display order in the UI
@@ -406,7 +495,7 @@ function matchesAbbreviation(property: string, input: string): boolean {
406
495
  * Supports both startsWith matching and camelCase abbreviation matching
407
496
  */
408
497
  export function filterCSSProperties(input: string): string[] {
409
- const normalizedInput = input.trim();
498
+ const normalizedInput = (input ?? '').trim();
410
499
  if (!normalizedInput) {
411
500
  return CSS_PROPERTIES.slice(0, 15); // Show first 15 by default
412
501
  }
@@ -422,8 +511,14 @@ export function filterCSSProperties(input: string): string[] {
422
511
  !startsWithMatches.includes(prop) // Don't duplicate startsWith matches
423
512
  );
424
513
 
425
- // Combine results: exact matches first, then fuzzy matches
426
- return [...startsWithMatches, ...abbreviationMatches];
514
+ // Sort each group by popularity, then combine: exact matches first, then fuzzy matches
515
+ const byPriority = (a: string, b: string) =>
516
+ getPropertyPriority(a) - getPropertyPriority(b);
517
+
518
+ return [
519
+ ...startsWithMatches.sort(byPriority),
520
+ ...abbreviationMatches.sort(byPriority),
521
+ ];
427
522
  }
428
523
 
429
524
  /**
@@ -449,7 +544,7 @@ export function filterPropertyValues(propertyName: string, input: string): strin
449
544
  return []; // No predefined values for this property
450
545
  }
451
546
 
452
- const normalizedInput = input.trim().toLowerCase();
547
+ const normalizedInput = (input ?? '').trim().toLowerCase();
453
548
  if (!normalizedInput) {
454
549
  return values; // Return all values if no input
455
550
  }
@@ -23,6 +23,7 @@ export * from './styleUtils';
23
23
  export * from './cssProperties';
24
24
  export * from './cssGeneration';
25
25
  export * from './colorProperties';
26
+ export * from './cssNamedColors';
26
27
  export * from './utilityClassMapper';
27
28
  export * from './elementClassName';
28
29
  export * from './styleValueRegistry';
@@ -146,6 +146,34 @@ export function isSlotMarker(node: ComponentNode | null | undefined): node is Sl
146
146
  return node?.type === NODE_TYPE.SLOT;
147
147
  }
148
148
 
149
+ /**
150
+ * Check whether a component definition's structure contains any slot marker.
151
+ *
152
+ * Used by callers that decide whether to preserve a component instance's
153
+ * `children` (e.g. the Figma paste swap) — there is no point passing children
154
+ * to a component whose template will never render them.
155
+ */
156
+ export function componentHasSlot(def: ComponentDefinition | undefined | null): boolean {
157
+ const structure = def?.component?.structure;
158
+ if (!structure) return false;
159
+ return structureContainsSlot(structure);
160
+ }
161
+
162
+ function structureContainsSlot(node: unknown): boolean {
163
+ if (node == null) return false;
164
+ if (Array.isArray(node)) {
165
+ for (const item of node) {
166
+ if (structureContainsSlot(item)) return true;
167
+ }
168
+ return false;
169
+ }
170
+ if (typeof node !== 'object') return false;
171
+ const obj = node as Record<string, unknown>;
172
+ if (obj.type === NODE_TYPE.SLOT) return true;
173
+ if (obj.children !== undefined) return structureContainsSlot(obj.children);
174
+ return false;
175
+ }
176
+
149
177
  /**
150
178
  * Type guard to check if a node is an embed node
151
179
  */
@@ -175,10 +175,21 @@ describe('responsiveScaling', () => {
175
175
  expect(result).toBeNull();
176
176
  });
177
177
 
178
- test('should scale percentage value', () => {
178
+ test('should NOT scale percentage value', () => {
179
+ // % is relative to the container and should pass through unchanged
179
180
  const result = scaleValue('100%', 16, 0.5);
180
- // 100 + (100 - 16) * (0.5 - 1) = 100 + 84 * (-0.5) = 100 - 42 = 58
181
- expect(result).toBe('58%');
181
+ expect(result).toBe('100%');
182
+ });
183
+
184
+ test('should NOT scale em value (small)', () => {
185
+ // em is relative to local font-size and should pass through unchanged
186
+ const result = scaleValue('2em', 16, 0.75);
187
+ expect(result).toBe('2em');
188
+ });
189
+
190
+ test('should NOT scale em value (large)', () => {
191
+ const result = scaleValue('20em', 16, 0.75);
192
+ expect(result).toBe('20em');
182
193
  });
183
194
  });
184
195
 
@@ -216,6 +227,12 @@ describe('responsiveScaling', () => {
216
227
  const result = scalePropertyValue('inherit', 16, 0.88);
217
228
  expect(result).toBe('inherit');
218
229
  });
230
+
231
+ test('should pass % and em through while scaling px in mixed values', () => {
232
+ // 20px → 19px; 50% stays; 2em stays
233
+ const result = scalePropertyValue('20px 50% 2em', 16, 0.75);
234
+ expect(result).toBe('19px 50% 2em');
235
+ });
219
236
  });
220
237
 
221
238
  describe('getResponsiveValues', () => {
@@ -18,10 +18,12 @@ export interface ResponsiveScales {
18
18
  padding?: BreakpointScales;
19
19
  margin?: BreakpointScales;
20
20
  gap?: BreakpointScales;
21
+ borderRadius?: BreakpointScales;
22
+ size?: BreakpointScales;
21
23
  [key: string]: boolean | number | BreakpointScales | undefined;
22
24
  }
23
25
 
24
- export type CSSPropertyType = 'fontSize' | 'padding' | 'margin' | 'gap' | 'paddingTop' | 'paddingRight' | 'paddingBottom' | 'paddingLeft' | 'marginTop' | 'marginRight' | 'marginBottom' | 'marginLeft' | 'rowGap' | 'columnGap';
26
+ export type CSSPropertyType = 'fontSize' | 'padding' | 'margin' | 'gap' | 'paddingTop' | 'paddingRight' | 'paddingBottom' | 'paddingLeft' | 'marginTop' | 'marginRight' | 'marginBottom' | 'marginLeft' | 'rowGap' | 'columnGap' | 'borderRadius' | 'borderTopLeftRadius' | 'borderTopRightRadius' | 'borderBottomLeftRadius' | 'borderBottomRightRadius' | 'width' | 'height' | 'maxWidth' | 'maxHeight' | 'minWidth' | 'minHeight';
25
27
 
26
28
  /**
27
29
  * Map CSS property to its scale category
@@ -33,6 +35,8 @@ function getScaleCategory(property: CSSPropertyType): keyof ResponsiveScales | n
33
35
  if (propertyStr.startsWith('padding')) return 'padding';
34
36
  if (propertyStr.startsWith('margin')) return 'margin';
35
37
  if (property === 'gap' || property === 'rowGap' || property === 'columnGap') return 'gap';
38
+ if (propertyStr === 'borderRadius' || (propertyStr.startsWith('border') && propertyStr.includes('Radius'))) return 'borderRadius';
39
+ if (property === 'width' || property === 'height' || property === 'maxWidth' || property === 'maxHeight' || property === 'minWidth' || property === 'minHeight') return 'size';
36
40
  return null;
37
41
  }
38
42
 
@@ -108,6 +112,12 @@ export function scaleValue(
108
112
  const parsed = parseValue(valueStr);
109
113
  if (!parsed) return null;
110
114
 
115
+ // `%` and `em` are already relative to their rendering context
116
+ // (container / local font-size), so they should not be rescaled.
117
+ if (parsed.unit === '%' || parsed.unit === 'em') {
118
+ return valueStr.trim();
119
+ }
120
+
111
121
  const scaledValue = calculateResponsiveValue(parsed.value, baseReference, scale);
112
122
  return `${scaledValue}${parsed.unit}`;
113
123
  }
@@ -178,6 +188,42 @@ export function getResponsiveValues(
178
188
  return result;
179
189
  }
180
190
 
191
+ /**
192
+ * Resolve the effective value of a CSS variable at a given breakpoint.
193
+ *
194
+ * Precedence (mirrors generateVariablesCSS in cssGenerator.ts so the editor
195
+ * widget stays in sync with what the browser will render):
196
+ * 1. `breakpoint === 'base'` → variable.value
197
+ * 2. explicit per-variable override → variable.scales[breakpoint]
198
+ * 3. globally-scaled value (ResponsiveScales multiplier for variable.type)
199
+ * 4. fallback → variable.value
200
+ */
201
+ export function resolveVariableValueAtBreakpoint(
202
+ variable: { value: string; type: string; scales?: Record<string, string> },
203
+ breakpoint: string,
204
+ responsiveScales?: ResponsiveScales | null
205
+ ): string {
206
+ if (breakpoint === 'base') return variable.value;
207
+
208
+ const override = variable.scales?.[breakpoint];
209
+ if (override !== undefined) return override;
210
+
211
+ if (responsiveScales?.enabled && variable.type !== 'none') {
212
+ const scale = getScaleMultiplier(
213
+ responsiveScales,
214
+ variable.type as CSSPropertyType,
215
+ breakpoint
216
+ );
217
+ if (scale !== null) {
218
+ const baseRef = responsiveScales.baseReference || 16;
219
+ const scaled = scalePropertyValue(variable.value, baseRef, scale);
220
+ if (scaled !== null) return scaled;
221
+ }
222
+ }
223
+
224
+ return variable.value;
225
+ }
226
+
181
227
  /**
182
228
  * Default responsive scales configuration
183
229
  */
@@ -200,4 +246,12 @@ export const DEFAULT_RESPONSIVE_SCALES: ResponsiveScales = {
200
246
  tablet: 0.65,
201
247
  mobile: 0.4,
202
248
  },
249
+ borderRadius: {
250
+ tablet: 0.85,
251
+ mobile: 0.7,
252
+ },
253
+ size: {
254
+ tablet: 0.9,
255
+ mobile: 0.75,
256
+ },
203
257
  };