meno-core 1.0.45 → 1.0.47

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 (63) hide show
  1. package/build-astro.ts +211 -124
  2. package/dist/bin/cli.js +2 -2
  3. package/dist/build-static.js +7 -7
  4. package/dist/chunks/{chunk-NZTSJS5C.js → chunk-2QK6U5UK.js} +3 -2
  5. package/dist/chunks/{chunk-NZTSJS5C.js.map → chunk-2QK6U5UK.js.map} +2 -2
  6. package/dist/chunks/{chunk-TVH3TC2T.js → chunk-47UNLQUU.js} +6 -6
  7. package/dist/chunks/{chunk-F7MA62WG.js → chunk-BCLGRZ3U.js} +5 -5
  8. package/dist/chunks/{chunk-F7MA62WG.js.map → chunk-BCLGRZ3U.js.map} +2 -2
  9. package/dist/chunks/{chunk-5ZASE4IG.js → chunk-FED5MME6.js} +234 -11
  10. package/dist/chunks/{chunk-5ZASE4IG.js.map → chunk-FED5MME6.js.map} +3 -3
  11. package/dist/chunks/{chunk-BZQKEJQY.js → chunk-FGUZOYJX.js} +49 -30
  12. package/dist/chunks/chunk-FGUZOYJX.js.map +7 -0
  13. package/dist/chunks/{chunk-5Z5VQRTJ.js → chunk-I7YIGZXT.js} +4 -4
  14. package/dist/chunks/{chunk-5Z5VQRTJ.js.map → chunk-I7YIGZXT.js.map} +2 -2
  15. package/dist/chunks/{chunk-OUNJ76QM.js → chunk-LJFB5EBT.js} +5 -5
  16. package/dist/chunks/{chunk-GYF3ABI3.js → chunk-UUA5LEWF.js} +3 -3
  17. package/dist/chunks/{chunk-GYF3ABI3.js.map → chunk-UUA5LEWF.js.map} +2 -2
  18. package/dist/chunks/{chunk-WQSG5WHC.js → chunk-ZTKHJQ2Z.js} +2 -2
  19. package/dist/chunks/{configService-6KTT6GRT.js → configService-DYCUEURL.js} +3 -3
  20. package/dist/chunks/{constants-L5IKLB6U.js → constants-GWBAD66U.js} +2 -2
  21. package/dist/entries/server-router.js +7 -7
  22. package/dist/lib/client/index.js +7 -5
  23. package/dist/lib/client/index.js.map +2 -2
  24. package/dist/lib/server/index.js +631 -208
  25. package/dist/lib/server/index.js.map +3 -3
  26. package/dist/lib/shared/index.js +7 -3
  27. package/dist/lib/shared/index.js.map +2 -2
  28. package/dist/lib/test-utils/index.js +1 -1
  29. package/lib/client/core/ComponentBuilder.test.ts +21 -0
  30. package/lib/client/core/ComponentBuilder.ts +8 -1
  31. package/lib/client/templateEngine.test.ts +64 -0
  32. package/lib/server/astro/astroEmitHelpers.ts +23 -0
  33. package/lib/server/astro/cmsPageEmitter.ts +46 -3
  34. package/lib/server/astro/componentEmitter.test.ts +59 -0
  35. package/lib/server/astro/componentEmitter.ts +53 -12
  36. package/lib/server/astro/cssCollector.ts +58 -11
  37. package/lib/server/astro/nodeToAstro.test.ts +397 -5
  38. package/lib/server/astro/nodeToAstro.ts +494 -65
  39. package/lib/server/astro/pageEmitter.ts +46 -3
  40. package/lib/server/astro/tailwindMapper.test.ts +119 -0
  41. package/lib/server/astro/tailwindMapper.ts +67 -1
  42. package/lib/server/runtime/httpServer.ts +12 -4
  43. package/lib/server/ssr/htmlGenerator.test.ts +3 -2
  44. package/lib/server/ssr/htmlGenerator.ts +6 -1
  45. package/lib/server/ssr/imageMetadata.ts +15 -9
  46. package/lib/server/ssr/jsCollector.ts +2 -2
  47. package/lib/server/ssr/ssrRenderer.test.ts +79 -0
  48. package/lib/server/ssr/ssrRenderer.ts +35 -20
  49. package/lib/shared/constants.ts +1 -0
  50. package/lib/shared/cssGeneration.test.ts +109 -3
  51. package/lib/shared/cssGeneration.ts +98 -13
  52. package/lib/shared/cssNamedColors.ts +47 -0
  53. package/lib/shared/cssProperties.ts +2 -2
  54. package/lib/shared/index.ts +1 -0
  55. package/lib/shared/styleNodeUtils.test.ts +47 -1
  56. package/lib/shared/styleNodeUtils.ts +7 -7
  57. package/package.json +1 -1
  58. package/dist/chunks/chunk-BZQKEJQY.js.map +0 -7
  59. /package/dist/chunks/{chunk-TVH3TC2T.js.map → chunk-47UNLQUU.js.map} +0 -0
  60. /package/dist/chunks/{chunk-OUNJ76QM.js.map → chunk-LJFB5EBT.js.map} +0 -0
  61. /package/dist/chunks/{chunk-WQSG5WHC.js.map → chunk-ZTKHJQ2Z.js.map} +0 -0
  62. /package/dist/chunks/{configService-6KTT6GRT.js.map → configService-DYCUEURL.js.map} +0 -0
  63. /package/dist/chunks/{constants-L5IKLB6U.js.map → constants-GWBAD66U.js.map} +0 -0
@@ -119,6 +119,12 @@ interface SSRContext {
119
119
  isProductionBuild?: boolean;
120
120
  /** Collector for SSR fallback HTML of complex nodes (list, locale-list) keyed by element path */
121
121
  ssrFallbackCollector?: Map<string, string>;
122
+ /**
123
+ * Collector mapping the raw slice of a RAW_HTML_PREFIX string to its fully processed HTML
124
+ * (after image rewriting, component expansion, and link localization). Consumed by the
125
+ * Astro exporter so its `<Fragment set:html>` output matches SSR exactly.
126
+ */
127
+ processedRawHtmlCollector?: Map<string, string>;
122
128
  /** Image format: 'webp' uses plain <img>, 'avif' uses <picture> with AVIF+WebP sources */
123
129
  imageFormat?: 'webp' | 'avif';
124
130
  }
@@ -369,7 +375,7 @@ export async function buildComponentHTML(
369
375
  cmsContext?: CMSContext,
370
376
  cmsService?: CMSService,
371
377
  isProductionBuild?: boolean
372
- ): Promise<{ html: string; interactiveStylesMap: Map<string, InteractiveStyles>; preloadImages: PreloadImage[]; neededCollections: Set<string>; ssrFallbackCollector: Map<string, string> }> {
378
+ ): Promise<{ html: string; interactiveStylesMap: Map<string, InteractiveStyles>; preloadImages: PreloadImage[]; neededCollections: Set<string>; ssrFallbackCollector: Map<string, string>; processedRawHtmlCollector: Map<string, string> }> {
373
379
  // Create map to collect interactive styles during render
374
380
  const interactiveStylesMap = new Map<string, InteractiveStyles>();
375
381
  // Create array to collect high-priority images for preloading
@@ -378,8 +384,10 @@ export async function buildComponentHTML(
378
384
  const neededCollections = new Set<string>();
379
385
  // Create map to collect SSR fallback HTML for complex nodes (list, locale-list)
380
386
  const ssrFallbackCollector = new Map<string, string>();
387
+ // Create map to collect raw-HTML → processed-HTML for Astro exporter parity
388
+ const processedRawHtmlCollector = new Map<string, string>();
381
389
 
382
- if (!node) return { html: '', interactiveStylesMap, preloadImages, neededCollections, ssrFallbackCollector };
390
+ if (!node) return { html: '', interactiveStylesMap, preloadImages, neededCollections, ssrFallbackCollector, processedRawHtmlCollector };
383
391
 
384
392
  // Register components for this render
385
393
  ssrComponentRegistry.merge(globalComponents);
@@ -411,12 +419,13 @@ export async function buildComponentHTML(
411
419
  neededCollections, // Track collections that need client-side data
412
420
  isProductionBuild,
413
421
  ssrFallbackCollector, // Collect SSR fallback HTML for complex nodes
422
+ processedRawHtmlCollector, // Collect raw→processed HTML for Astro exporter
414
423
  imageFormat: configService.getImageFormat(),
415
424
  };
416
425
 
417
426
  const html = await renderNode(node, ctx);
418
427
 
419
- return { html, interactiveStylesMap, preloadImages, neededCollections, ssrFallbackCollector };
428
+ return { html, interactiveStylesMap, preloadImages, neededCollections, ssrFallbackCollector, processedRawHtmlCollector };
420
429
  }
421
430
 
422
431
  /**
@@ -803,10 +812,12 @@ async function renderNode(
803
812
  }
804
813
  // Check for raw HTML marker (from rich-text fields) - don't escape
805
814
  if (text.startsWith(RAW_HTML_PREFIX)) {
806
- let rawHtml = text.slice(RAW_HTML_PREFIX.length);
815
+ const rawSlice = text.slice(RAW_HTML_PREFIX.length);
816
+ let rawHtml = rawSlice;
807
817
  if (ctx.imageMetadataMap) rawHtml = rewriteRichTextImages(rawHtml, ctx.imageMetadataMap, ctx.imageFormat);
808
818
  rawHtml = await expandRichTextComponents(rawHtml, ctx);
809
819
  rawHtml = localizeRichTextLinks(rawHtml, ctx);
820
+ ctx.processedRawHtmlCollector?.set(rawSlice, rawHtml);
810
821
  return rawHtml;
811
822
  }
812
823
  return escapeHtml(text);
@@ -823,10 +834,12 @@ async function renderNode(
823
834
  }
824
835
  // Check for raw HTML marker (from rich-text fields) - don't escape
825
836
  if (text.startsWith(RAW_HTML_PREFIX)) {
826
- let rawHtml = text.slice(RAW_HTML_PREFIX.length);
837
+ const rawSlice = text.slice(RAW_HTML_PREFIX.length);
838
+ let rawHtml = rawSlice;
827
839
  if (ctx.imageMetadataMap) rawHtml = rewriteRichTextImages(rawHtml, ctx.imageMetadataMap, ctx.imageFormat);
828
840
  rawHtml = await expandRichTextComponents(rawHtml, ctx);
829
841
  rawHtml = localizeRichTextLinks(rawHtml, ctx);
842
+ ctx.processedRawHtmlCollector?.set(rawSlice, rawHtml);
830
843
  return rawHtml;
831
844
  }
832
845
  return escapeHtml(text);
@@ -1230,15 +1243,15 @@ async function renderComponent(
1230
1243
 
1231
1244
  // Merge instance style overrides, className, and attributes
1232
1245
  // processedStructure is typically an HTML node (the component's root element)
1233
- // Handle both component nodes and HTML nodes
1246
+ // Handle component nodes, HTML nodes, and Link nodes (Link and HTML share top-level style/attributes)
1234
1247
  const rootNode = processedStructure as ComponentNode & { props?: Record<string, unknown>; style?: any; attributes?: Record<string, string | number | boolean> };
1235
- if (isComponentNode(rootNode) || isHtmlNode(rootNode)) {
1248
+ if (isComponentNode(rootNode) || isHtmlNode(rootNode) || isLinkNode(rootNode)) {
1236
1249
  if (!rootNode.props) {
1237
1250
  rootNode.props = {};
1238
1251
  }
1239
1252
 
1240
- // For HTML root nodes, merge styles into node.style (where the renderer reads them)
1241
- if (isHtmlNode(rootNode)) {
1253
+ // For HTML and Link root nodes, merge styles into node.style (where the renderer reads them)
1254
+ if (isHtmlNode(rootNode) || isLinkNode(rootNode)) {
1242
1255
  if (propsWithStyleAndAttrs.style) {
1243
1256
  const existingStyle = rootNode.style;
1244
1257
  if (existingStyle && typeof existingStyle === 'object') {
@@ -1264,8 +1277,8 @@ async function renderComponent(
1264
1277
 
1265
1278
  // Merge className from component instance (includes responsive utility classes)
1266
1279
  if (propsWithStyleAndAttrs.className) {
1267
- if (isHtmlNode(rootNode)) {
1268
- // For HTML root nodes, merge into attributes where the renderer reads them
1280
+ if (isHtmlNode(rootNode) || isLinkNode(rootNode)) {
1281
+ // For HTML and Link root nodes, merge into attributes where the renderer reads them
1269
1282
  if (!rootNode.attributes) rootNode.attributes = {};
1270
1283
  const existingClass = (rootNode.attributes.class || '') as string;
1271
1284
  rootNode.attributes.class = existingClass
@@ -1280,11 +1293,11 @@ async function renderComponent(
1280
1293
  }
1281
1294
 
1282
1295
  // Merge attributes into props
1283
- Object.assign(rootNode.props, nodeAttributes);
1296
+ Object.assign(rootNode.props as Record<string, unknown>, nodeAttributes);
1284
1297
 
1285
- // Forward nodeAttributes to rootNode.attributes for HTML root nodes
1298
+ // Forward nodeAttributes to rootNode.attributes for HTML/Link root nodes
1286
1299
  // so attributes from outer components (e.g. data-component) reach the final HTML element
1287
- if (isHtmlNode(rootNode) && Object.keys(nodeAttributes).length > 0) {
1300
+ if ((isHtmlNode(rootNode) || isLinkNode(rootNode)) && Object.keys(nodeAttributes).length > 0) {
1288
1301
  if (!rootNode.attributes) {
1289
1302
  rootNode.attributes = {};
1290
1303
  }
@@ -1428,7 +1441,7 @@ async function renderHtmlElement(
1428
1441
  if (voidElements.includes(tag.toLowerCase())) {
1429
1442
  // Special handling for img tags - inject srcset and render as <picture> when AVIF available
1430
1443
  if (tag.toLowerCase() === 'img') {
1431
- return renderImageElement(propsWithStyleAndAttrs, classAttr, attrs, ctx);
1444
+ return renderImageElement(propsWithStyleAndAttrs, classAttr, styleAttr, attrs, ctx);
1432
1445
  }
1433
1446
 
1434
1447
  return `<${tag}${classAttr}${styleAttr}${attrs} />`;
@@ -1443,6 +1456,7 @@ async function renderHtmlElement(
1443
1456
  function renderImageElement(
1444
1457
  propsWithStyleAndAttrs: Record<string, unknown>,
1445
1458
  classAttr: string,
1459
+ styleAttr: string,
1446
1460
  attrs: string,
1447
1461
  ctx: SSRContext
1448
1462
  ): string {
@@ -1525,7 +1539,7 @@ function renderImageElement(
1525
1539
  ? ` class="${escapeHtml(imgClasses.join(' '))}"`
1526
1540
  : '';
1527
1541
 
1528
- return `<picture${pictureClassAttr}>` +
1542
+ return `<picture${pictureClassAttr}${styleAttr}>` +
1529
1543
  `<source type="image/avif" srcset="${escapeHtml(metadata.avifSrcset)}" sizes="${escapeHtml(sizesAttr)}" />` +
1530
1544
  `<source type="image/webp" srcset="${escapeHtml(metadata.srcset)}" sizes="${escapeHtml(sizesAttr)}" />` +
1531
1545
  `<img${imgClassAttr}${imgAttrs}${attrs} />` +
@@ -1538,7 +1552,7 @@ function renderImageElement(
1538
1552
  imgAttrs += ` sizes="${escapeHtml(sizesAttr)}"`;
1539
1553
  }
1540
1554
 
1541
- return `<img${classAttr}${imgAttrs}${attrs} />`;
1555
+ return `<img${classAttr}${styleAttr}${imgAttrs}${attrs} />`;
1542
1556
  }
1543
1557
 
1544
1558
  /**
@@ -1699,7 +1713,7 @@ export async function renderPageSSR(
1699
1713
  cmsContext?: CMSContext,
1700
1714
  cmsService?: CMSService,
1701
1715
  isProductionBuild?: boolean
1702
- ): Promise<{ html: string; meta: string; title: string; javascript: string; componentCSS?: string; locale: string; interactiveStylesMap: Map<string, InteractiveStyles>; preloadImages: PreloadImage[]; neededCollections: Set<string>; ssrFallbackCollector: Map<string, string> }> {
1716
+ ): Promise<{ html: string; meta: string; title: string; javascript: string; componentCSS?: string; locale: string; interactiveStylesMap: Map<string, InteractiveStyles>; preloadImages: PreloadImage[]; neededCollections: Set<string>; ssrFallbackCollector: Map<string, string>; processedRawHtmlCollector: Map<string, string> }> {
1703
1717
  // Extract page content
1704
1718
  const rootNode = pageData?.root || undefined;
1705
1719
  if (!rootNode) {
@@ -1735,9 +1749,9 @@ export async function renderPageSSR(
1735
1749
 
1736
1750
  // Render the component tree to HTML with i18n and CMS support
1737
1751
  // Also collect interactive styles, preload images, and needed collections during render
1738
- const { html: contentHTML, interactiveStylesMap, preloadImages, neededCollections, ssrFallbackCollector } = rootNode
1752
+ const { html: contentHTML, interactiveStylesMap, preloadImages, neededCollections, ssrFallbackCollector, processedRawHtmlCollector } = rootNode
1739
1753
  ? await buildComponentHTML(rootNode, globalComponents, pageComponents, effectiveLocale, config, slugMappings, pagePath, cmsContext, cmsService, isProductionBuild)
1740
- : { html: '', interactiveStylesMap: new Map<string, InteractiveStyles>(), preloadImages: [], neededCollections: new Set<string>(), ssrFallbackCollector: new Map<string, string>() };
1754
+ : { html: '', interactiveStylesMap: new Map<string, InteractiveStyles>(), preloadImages: [], neededCollections: new Set<string>(), ssrFallbackCollector: new Map<string, string>(), processedRawHtmlCollector: new Map<string, string>() };
1741
1755
 
1742
1756
  // Collect JavaScript and CSS from all components
1743
1757
  const javascript = await collectComponentJavaScript(globalComponents, pageComponents);
@@ -1769,5 +1783,6 @@ export async function renderPageSSR(
1769
1783
  preloadImages,
1770
1784
  neededCollections,
1771
1785
  ssrFallbackCollector,
1786
+ processedRawHtmlCollector,
1772
1787
  };
1773
1788
  }
@@ -139,6 +139,7 @@ export const IFRAME_MESSAGE_TYPES = {
139
139
  DUPLICATE_ELEMENT: 'DUPLICATE_ELEMENT',
140
140
  MAKE_COMPONENT: 'MAKE_COMPONENT',
141
141
  SWAP_TO_COMPONENT: 'SWAP_TO_COMPONENT',
142
+ SMART_CONNECT: 'SMART_CONNECT',
142
143
  } as const;
143
144
 
144
145
  // Component node type constants
@@ -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';
@@ -395,7 +396,11 @@ export function generateRuleForClass(className: string): string | null {
395
396
  // unless it's a hex color (#...) or RGB value
396
397
  if ((prefix === 'bgc' || prefix === 'bg' || prefix === 'c' || prefix === 'bc')) {
397
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
398
400
  if (!value.startsWith('#') && !value.includes('rgb') && !value.includes('px')) {
401
+ if (isCssNamedColor(value)) {
402
+ return `${cssProp}: ${value};`;
403
+ }
399
404
  return `${cssProp}: var(--${value});`;
400
405
  }
401
406
  }
@@ -849,12 +854,14 @@ export function generateInteractiveCSS(
849
854
  elementClass: string,
850
855
  interactiveStyles: InteractiveStyles,
851
856
  breakpoints: BreakpointConfig = DEFAULT_BREAKPOINTS,
852
- remConfig?: RemConversionConfig
857
+ remConfig?: RemConversionConfig,
858
+ responsiveScales?: ResponsiveScales
853
859
  ): string {
854
860
  const css: string[] = [];
855
861
 
856
862
  // Extract breakpoint values for CSS media queries
857
863
  const breakpointValues = getBreakpointValues(breakpoints);
864
+ const scalingEnabled = responsiveScales?.enabled === true;
858
865
 
859
866
  for (const rule of interactiveStyles) {
860
867
  const { prefix, postfix, style } = rule;
@@ -862,6 +869,11 @@ export function generateInteractiveCSS(
862
869
  // Build full selector: {prefix}.element{postfix}
863
870
  const fullSelector = `${prefix || ''}.${elementClass}${postfix || ''}`;
864
871
 
872
+ // Breakpoint styles (sorted by value descending)
873
+ const sortedBreakpoints = Object.entries(breakpointValues).sort(
874
+ ([, a], [, b]) => b - a
875
+ );
876
+
865
877
  if (isResponsiveStyle(style)) {
866
878
  // Generate responsive rules
867
879
  const responsive = style as ResponsiveStyleObject;
@@ -874,15 +886,30 @@ export function generateInteractiveCSS(
874
886
  }
875
887
  }
876
888
 
877
- // Breakpoint styles (sorted by value descending)
878
- const sortedBreakpoints = Object.entries(breakpointValues).sort(
879
- ([, a], [, b]) => b - a
880
- );
881
-
882
889
  for (const [breakpointName, breakpointValue] of sortedBreakpoints) {
883
- const breakpointStyles = responsive[breakpointName];
884
- if (breakpointStyles && Object.keys(breakpointStyles).length > 0) {
885
- 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);
886
913
  if (properties) {
887
914
  css.push(
888
915
  `@media (max-width: ${breakpointValue}px) { ${fullSelector} { ${properties}; } }`
@@ -898,6 +925,24 @@ export function generateInteractiveCSS(
898
925
  if (properties) {
899
926
  css.push(`${fullSelector} { ${properties}; }`);
900
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
+ }
901
946
  }
902
947
  }
903
948
  }
@@ -905,6 +950,45 @@ export function generateInteractiveCSS(
905
950
  return css.join('\n');
906
951
  }
907
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
+
908
992
  /**
909
993
  * Collect and generate CSS for all interactive styles in a page/component
910
994
  *
@@ -915,12 +999,13 @@ export function generateInteractiveCSS(
915
999
  export function generateAllInteractiveCSS(
916
1000
  interactiveStylesMap: Map<string, InteractiveStyles>,
917
1001
  breakpoints: BreakpointConfig = DEFAULT_BREAKPOINTS,
918
- remConfig?: RemConversionConfig
1002
+ remConfig?: RemConversionConfig,
1003
+ responsiveScales?: ResponsiveScales
919
1004
  ): string {
920
1005
  const cssBlocks: string[] = [];
921
1006
 
922
1007
  for (const [elementClass, styles] of interactiveStylesMap) {
923
- const css = generateInteractiveCSS(elementClass, styles, breakpoints, remConfig);
1008
+ const css = generateInteractiveCSS(elementClass, styles, breakpoints, remConfig, responsiveScales);
924
1009
  if (css) {
925
1010
  cssBlocks.push(css);
926
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
+ }
@@ -495,7 +495,7 @@ function matchesAbbreviation(property: string, input: string): boolean {
495
495
  * Supports both startsWith matching and camelCase abbreviation matching
496
496
  */
497
497
  export function filterCSSProperties(input: string): string[] {
498
- const normalizedInput = input.trim();
498
+ const normalizedInput = (input ?? '').trim();
499
499
  if (!normalizedInput) {
500
500
  return CSS_PROPERTIES.slice(0, 15); // Show first 15 by default
501
501
  }
@@ -544,7 +544,7 @@ export function filterPropertyValues(propertyName: string, input: string): strin
544
544
  return []; // No predefined values for this property
545
545
  }
546
546
 
547
- const normalizedInput = input.trim().toLowerCase();
547
+ const normalizedInput = (input ?? '').trim().toLowerCase();
548
548
  if (!normalizedInput) {
549
549
  return values; // Return all values if no input
550
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';
@@ -1,6 +1,6 @@
1
1
  import { describe, test, expect } from 'bun:test';
2
2
  import { applyStylesToNode, mergeNodeStyles, extractStylesFromNode } from './styleNodeUtils';
3
- import type { HtmlNode, ComponentInstanceNode } from './types';
3
+ import type { HtmlNode, ComponentInstanceNode, LinkNode } from './types';
4
4
  import { NODE_TYPE } from './constants';
5
5
 
6
6
  describe('styleNodeUtils', () => {
@@ -37,6 +37,13 @@ describe('styleNodeUtils', () => {
37
37
  const result = applyStylesToNode(node as any, styles);
38
38
  expect(result.style).toEqual(styles);
39
39
  });
40
+
41
+ test('should apply styles to link node at top level', () => {
42
+ const node: LinkNode = { type: NODE_TYPE.LINK, href: '/foo', children: [] };
43
+ const styles = { backgroundColor: '#252525', color: '#ffffff' };
44
+ const result = applyStylesToNode(node, styles);
45
+ expect(result.style).toEqual(styles);
46
+ });
40
47
  });
41
48
 
42
49
  describe('mergeNodeStyles', () => {
@@ -87,6 +94,34 @@ describe('styleNodeUtils', () => {
87
94
  const result = mergeNodeStyles(node, instanceStyles);
88
95
  expect((result as ComponentInstanceNode).props?.style).toEqual(instanceStyles);
89
96
  });
97
+
98
+ test('should merge styles for link node at top level', () => {
99
+ const node: LinkNode = {
100
+ type: NODE_TYPE.LINK,
101
+ href: '/foo',
102
+ children: [],
103
+ style: { color: '#000000', backgroundColor: '#ffffff' }
104
+ };
105
+ const instanceStyles = { backgroundColor: '#252525', color: '#ffffff' };
106
+ const result = mergeNodeStyles(node, instanceStyles);
107
+ expect(result.style).toEqual({ backgroundColor: '#252525', color: '#ffffff' });
108
+ });
109
+
110
+ test('should deep-merge responsive styles for link node', () => {
111
+ const node: LinkNode = {
112
+ type: NODE_TYPE.LINK,
113
+ href: '/foo',
114
+ children: [],
115
+ style: { base: { color: '#000', padding: '8px' }, tablet: { fontSize: '14px' }, mobile: {} }
116
+ };
117
+ const instanceStyles = { base: { backgroundColor: '#252525', color: '#fff' }, tablet: {}, mobile: {} };
118
+ const result = mergeNodeStyles(node, instanceStyles);
119
+ expect(result.style).toEqual({
120
+ base: { color: '#fff', padding: '8px', backgroundColor: '#252525' },
121
+ tablet: { fontSize: '14px' },
122
+ mobile: {},
123
+ });
124
+ });
90
125
  });
91
126
 
92
127
  describe('extractStylesFromNode', () => {
@@ -128,5 +163,16 @@ describe('styleNodeUtils', () => {
128
163
  const styles = extractStylesFromNode(node as any);
129
164
  expect(styles).toEqual({ width: '100%' });
130
165
  });
166
+
167
+ test('should extract styles from link node', () => {
168
+ const node: LinkNode = {
169
+ type: NODE_TYPE.LINK,
170
+ href: '/foo',
171
+ children: [],
172
+ style: { backgroundColor: '#252525' }
173
+ };
174
+ const styles = extractStylesFromNode(node);
175
+ expect(styles).toEqual({ backgroundColor: '#252525' });
176
+ });
131
177
  });
132
178
  });