meno-core 1.0.19 → 1.0.21

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 (50) hide show
  1. package/.claude/settings.local.json +7 -0
  2. package/lib/client/core/ComponentBuilder.test.ts +68 -56
  3. package/lib/client/core/ComponentBuilder.ts +6 -4
  4. package/lib/client/core/builders/embedBuilder.ts +10 -1
  5. package/lib/client/core/builders/index.ts +6 -2
  6. package/lib/client/core/builders/{cmsListBuilder.ts → listBuilder.ts} +227 -95
  7. package/lib/client/responsiveStyleResolver.test.ts +12 -12
  8. package/lib/client/responsiveStyleResolver.ts +19 -7
  9. package/lib/client/routing/Router.tsx +35 -7
  10. package/lib/client/templateEngine.test.ts +126 -0
  11. package/lib/client/templateEngine.ts +53 -13
  12. package/lib/server/jsonLoader.test.ts +4 -1
  13. package/lib/server/jsonLoader.ts +64 -15
  14. package/lib/server/services/configService.ts +68 -13
  15. package/lib/server/ssr/attributeBuilder.ts +8 -0
  16. package/lib/server/ssr/index.ts +1 -1
  17. package/lib/server/ssr/ssrRenderer.ts +245 -111
  18. package/lib/server/ssrRenderer.test.ts +197 -3
  19. package/lib/server/validateStyleCoverage.ts +14 -17
  20. package/lib/shared/breakpoints.test.ts +210 -23
  21. package/lib/shared/breakpoints.ts +124 -17
  22. package/lib/shared/constants.test.ts +1 -1
  23. package/lib/shared/constants.ts +5 -1
  24. package/lib/shared/cssGeneration.test.ts +17 -0
  25. package/lib/shared/cssGeneration.ts +49 -12
  26. package/lib/shared/index.ts +3 -0
  27. package/lib/shared/itemTemplateUtils.test.ts +44 -2
  28. package/lib/shared/itemTemplateUtils.ts +15 -2
  29. package/lib/shared/nodeUtils.ts +23 -4
  30. package/lib/shared/registry/BaseNodeTypeRegistry.test.ts +2 -2
  31. package/lib/shared/registry/nodeTypes/ListNodeType.ts +186 -0
  32. package/lib/shared/registry/nodeTypes/SlotMarkerType.ts +6 -0
  33. package/lib/shared/registry/nodeTypes/index.ts +6 -5
  34. package/lib/shared/responsiveScaling.test.ts +87 -0
  35. package/lib/shared/responsiveScaling.ts +33 -29
  36. package/lib/shared/responsiveStyleUtils.test.ts +7 -7
  37. package/lib/shared/responsiveStyleUtils.ts +22 -16
  38. package/lib/shared/styleNodeUtils.ts +5 -5
  39. package/lib/shared/styleValueRegistry.ts +60 -5
  40. package/lib/shared/tree/PathBuilder.ts +3 -3
  41. package/lib/shared/treePathUtils.ts +7 -5
  42. package/lib/shared/types/cms.ts +4 -57
  43. package/lib/shared/types/components.ts +45 -4
  44. package/lib/shared/types/index.ts +13 -0
  45. package/lib/shared/utilityClassConfig.ts +14 -0
  46. package/lib/shared/utilityClassMapper.ts +43 -2
  47. package/lib/shared/validation/propValidator.ts +9 -1
  48. package/lib/shared/validation/schemas.ts +60 -14
  49. package/package.json +1 -1
  50. package/lib/shared/registry/nodeTypes/CMSListNodeType.ts +0 -109
@@ -0,0 +1,186 @@
1
+ /**
2
+ * Unified List Node Type Definition
3
+ * Renders children for each item from either component props or CMS collections.
4
+ *
5
+ * Replaces both the original 'list' and 'cms-list' node types with a unified
6
+ * implementation that uses sourceType to determine data source.
7
+ */
8
+
9
+ import { z } from 'zod';
10
+ import { createElement as h } from 'react';
11
+ import { StyleValueSchema, InteractiveStylesSchema, IfConditionSchema } from '../../validation/schemas';
12
+ import { createNodeType } from '../createNodeType';
13
+ import { NODE_TYPE } from '../../constants';
14
+
15
+ // Filter condition schema (shared with CMS list queries)
16
+ const CMSFilterConditionSchema = z.object({
17
+ field: z.string(),
18
+ operator: z.enum(['eq', 'neq', 'gt', 'gte', 'lt', 'lte', 'contains', 'in']).optional(),
19
+ value: z.unknown(),
20
+ });
21
+
22
+ // Sort configuration schema
23
+ const CMSSortConfigSchema = z.union([
24
+ z.object({
25
+ field: z.string(),
26
+ order: z.enum(['asc', 'desc']).optional(),
27
+ }),
28
+ z.array(z.object({
29
+ field: z.string(),
30
+ order: z.enum(['asc', 'desc']).optional(),
31
+ })),
32
+ ]);
33
+
34
+ // Schema is the SINGLE source of truth
35
+ const ListNodeSchemaInternal = z.object({
36
+ type: z.literal('list'),
37
+ /**
38
+ * Data source type:
39
+ * - 'prop': Read items from component props (default)
40
+ * - 'collection': Query items from CMS collection
41
+ */
42
+ sourceType: z.enum(['prop', 'collection']).default('prop'),
43
+ /**
44
+ * Source identifier:
45
+ * - For sourceType 'prop': Prop name (e.g., "items") or template expression (e.g., "{{category.items}}")
46
+ * - For sourceType 'collection': Collection name (e.g., "posts", "authors")
47
+ */
48
+ source: z.string(),
49
+ /**
50
+ * HTML element tag for the list container.
51
+ * Defaults to 'div'. Common alternatives: 'ul', 'ol', 'section', 'nav', 'article'.
52
+ */
53
+ tag: z.string().optional(),
54
+ label: z.string().optional(), // Custom label displayed in structure tree
55
+ if: IfConditionSchema.optional(), // Conditional rendering - skip node when false
56
+ style: StyleValueSchema.optional(),
57
+ interactiveStyles: InteractiveStylesSchema.optional(), // Interactive CSS rules (hover, active, etc.)
58
+ generateElementClass: z.boolean().optional(), // Generate element class without styles (for custom CSS)
59
+ attributes: z.record(z.string(), z.union([z.string(), z.number(), z.boolean()])).optional(),
60
+ /**
61
+ * Variable name for item in templates.
62
+ * - For sourceType 'prop': defaults to 'item'
63
+ * - For sourceType 'collection': defaults to singularized collection name
64
+ */
65
+ itemAs: z.string().optional(),
66
+
67
+ // Collection-only options (ignored when sourceType: 'prop')
68
+ /** Direct item IDs or template expression for referenced items (e.g., "{{post.authorId}}") */
69
+ items: z.union([z.string(), z.array(z.string())]).optional(),
70
+ /** Filter conditions */
71
+ filter: z.union([
72
+ CMSFilterConditionSchema,
73
+ z.array(CMSFilterConditionSchema),
74
+ z.record(z.unknown()),
75
+ ]).optional(),
76
+ /** Sort configuration */
77
+ sort: CMSSortConfigSchema.optional(),
78
+ /** Exclude the current CMS item from the list (useful for "related items" sections) */
79
+ excludeCurrentItem: z.boolean().optional(),
80
+ /**
81
+ * Emit item template for dynamic client-side rendering.
82
+ * When true, SSR emits a `<template data-meno-item>` element with unprocessed
83
+ * {{item.field}} placeholders. MenoFilter uses this to render items beyond
84
+ * the SSR'd limit dynamically.
85
+ */
86
+ emitTemplate: z.boolean().optional(),
87
+
88
+ // Shared options
89
+ /** Maximum number of items to return */
90
+ limit: z.number().optional(),
91
+ /** Number of items to skip */
92
+ offset: z.number().optional(),
93
+ /** Children are repeated for each item */
94
+ children: z.array(z.unknown()).optional(),
95
+ }).passthrough();
96
+
97
+ // TypeScript type inferred from schema
98
+ export type ListNode = z.infer<typeof ListNodeSchemaInternal>;
99
+
100
+ // Export schema for validation/schemas.ts
101
+ export const ListNodeSchema = ListNodeSchemaInternal;
102
+
103
+ export const ListNodeType = createNodeType({
104
+ type: NODE_TYPE.LIST,
105
+ displayName: 'List',
106
+ category: 'special',
107
+ schema: ListNodeSchemaInternal,
108
+
109
+ defaultValues: {
110
+ sourceType: 'prop',
111
+ source: '',
112
+ children: [],
113
+ style: { base: {} },
114
+ },
115
+
116
+ treeDisplay: {
117
+ icon: 'HTML_ELEMENT',
118
+ getLabel: (node) => {
119
+ const listNode = node as ListNode;
120
+ const sourceType = listNode.sourceType || 'prop';
121
+ const prefix = sourceType === 'collection' ? 'CMS List' : 'List';
122
+ return listNode.source ? `${prefix}: ${listNode.source}` : prefix;
123
+ },
124
+ },
125
+
126
+ clientRenderer: (node, context) => {
127
+ const listNode = node as ListNode;
128
+ const sourceType = listNode.sourceType || 'prop';
129
+ const isCollection = sourceType === 'collection';
130
+
131
+ // Different styling for collection vs prop lists
132
+ const bgColor = isCollection ? 'rgba(139, 92, 246, 0.1)' : 'rgba(59, 130, 246, 0.1)';
133
+ const borderColor = isCollection ? 'rgba(139, 92, 246, 0.5)' : 'rgba(59, 130, 246, 0.5)';
134
+ const textColor = isCollection ? '#8b5cf6' : '#3b82f6';
135
+ const label = isCollection ? 'CMS List' : 'List';
136
+
137
+ return h('div', {
138
+ key: context.key,
139
+ 'data-list': 'true',
140
+ 'data-source-type': sourceType,
141
+ 'data-source': listNode.source,
142
+ style: {
143
+ padding: '8px 12px',
144
+ background: bgColor,
145
+ border: `1px dashed ${borderColor}`,
146
+ borderRadius: '4px',
147
+ color: textColor,
148
+ fontSize: '12px',
149
+ },
150
+ }, `[${label}: ${listNode.source || 'No source'}]`);
151
+ },
152
+
153
+ ssrRenderer: (_node, _context) => {
154
+ // Placeholder - actual SSR is handled by processList in ssrRenderer.ts
155
+ return '<!-- list rendered by processList -->';
156
+ },
157
+
158
+ capabilities: {
159
+ canHaveChildren: true,
160
+ requiresProps: ['source'],
161
+ },
162
+
163
+ editableFields: [
164
+ {
165
+ name: 'sourceType',
166
+ label: 'Source Type',
167
+ type: 'select',
168
+ required: false,
169
+ options: ['prop', 'collection'],
170
+ },
171
+ {
172
+ name: 'source',
173
+ label: 'Source',
174
+ type: 'string',
175
+ required: true,
176
+ placeholder: 'e.g., items or posts'
177
+ },
178
+ {
179
+ name: 'itemAs',
180
+ label: 'Item Variable',
181
+ type: 'string',
182
+ required: false,
183
+ placeholder: 'default: item (prop) or singular (collection)'
184
+ }
185
+ ],
186
+ });
@@ -7,8 +7,14 @@ import { z } from 'zod';
7
7
  import { createNodeType } from '../createNodeType';
8
8
 
9
9
  // Schema is the SINGLE source of truth
10
+ // Note: default uses z.any() to avoid circular reference with ComponentNodeSchema
11
+ // Full validation is done via the schema in validation/schemas.ts
10
12
  const SlotMarkerSchemaInternal = z.object({
11
13
  type: z.literal('slot'),
14
+ default: z.union([
15
+ z.array(z.any()),
16
+ z.string(),
17
+ ]).optional(),
12
18
  }).passthrough();
13
19
 
14
20
  // TypeScript type inferred from schema
@@ -16,7 +16,8 @@ import { SlotMarkerType, SlotMarkerSchema, type SlotMarker } from './SlotMarkerT
16
16
  import { EmbedNodeType, EmbedNodeSchema, type EmbedNode } from './EmbedNodeType';
17
17
  import { LinkNodeType, LinkNodeSchema, type LinkNode } from './LinkNodeType';
18
18
  import { LocaleListNodeType, LocaleListNodeSchema, type LocaleListNode } from './LocaleListNodeType';
19
- import { CMSListNodeType, CMSListNodeSchema, type CMSListNode } from './CMSListNodeType';
19
+ import { ListNodeType, ListNodeSchema, type ListNode } from './ListNodeType';
20
+
20
21
  /**
21
22
  * All built-in node type definitions
22
23
  * Type assertion needed because TypeScript sees each as a specific generic type
@@ -28,7 +29,7 @@ export const builtInNodeTypes: NodeTypeDefinition[] = [
28
29
  EmbedNodeType as NodeTypeDefinition,
29
30
  LinkNodeType as NodeTypeDefinition,
30
31
  LocaleListNodeType as NodeTypeDefinition,
31
- CMSListNodeType as NodeTypeDefinition,
32
+ ListNodeType as NodeTypeDefinition,
32
33
  ];
33
34
 
34
35
  /**
@@ -41,7 +42,7 @@ export const nodeSchemas = {
41
42
  embed: EmbedNodeSchema,
42
43
  link: LinkNodeSchema,
43
44
  localeList: LocaleListNodeSchema,
44
- cmsList: CMSListNodeSchema,
45
+ list: ListNodeSchema,
45
46
  };
46
47
 
47
48
  /**
@@ -59,7 +60,7 @@ export { SlotMarkerType, SlotMarkerSchema, type SlotMarker } from './SlotMarkerT
59
60
  export { EmbedNodeType, EmbedNodeSchema, type EmbedNode } from './EmbedNodeType';
60
61
  export { LinkNodeType, LinkNodeSchema, type LinkNode } from './LinkNodeType';
61
62
  export { LocaleListNodeType, LocaleListNodeSchema, type LocaleListNode } from './LocaleListNodeType';
62
- export { CMSListNodeType, CMSListNodeSchema, type CMSListNode } from './CMSListNodeType';
63
+ export { ListNodeType, ListNodeSchema, type ListNode } from './ListNodeType';
63
64
 
64
65
  /**
65
66
  * Discriminated union of all component node types
@@ -72,4 +73,4 @@ export type ComponentNode =
72
73
  | EmbedNode
73
74
  | LinkNode
74
75
  | LocaleListNode
75
- | CMSListNode;
76
+ | ListNode;
@@ -9,6 +9,7 @@ import {
9
9
  DEFAULT_RESPONSIVE_SCALES,
10
10
  type ResponsiveScales,
11
11
  type CSSPropertyType,
12
+ type BreakpointScales,
12
13
  } from './responsiveScaling';
13
14
 
14
15
  describe('responsiveScaling', () => {
@@ -92,6 +93,26 @@ describe('responsiveScaling', () => {
92
93
  const scale = getScaleMultiplier(scales, 'fontSize', 'mobile');
93
94
  expect(scale).toBeNull();
94
95
  });
96
+
97
+ test('should support custom breakpoint names', () => {
98
+ const scales: ResponsiveScales = {
99
+ enabled: true,
100
+ baseReference: 16,
101
+ fontSize: { large: 0.95, tablet: 0.88, small: 0.82, mobile: 0.75 }
102
+ };
103
+ expect(getScaleMultiplier(scales, 'fontSize', 'large')).toBe(0.95);
104
+ expect(getScaleMultiplier(scales, 'fontSize', 'small')).toBe(0.82);
105
+ });
106
+
107
+ test('should return null for undefined custom breakpoint', () => {
108
+ const scales: ResponsiveScales = {
109
+ enabled: true,
110
+ baseReference: 16,
111
+ fontSize: { tablet: 0.88, mobile: 0.75 }
112
+ };
113
+ expect(getScaleMultiplier(scales, 'fontSize', 'large')).toBeNull();
114
+ expect(getScaleMultiplier(scales, 'fontSize', 'small')).toBeNull();
115
+ });
95
116
  });
96
117
 
97
118
  describe('parseMultiValue', () => {
@@ -239,6 +260,72 @@ describe('responsiveScaling', () => {
239
260
  expect(result.tablet).toBeDefined();
240
261
  expect(result.mobile).toBeUndefined();
241
262
  });
263
+
264
+ test('should calculate values for custom breakpoints', () => {
265
+ const scales: ResponsiveScales = {
266
+ enabled: true,
267
+ baseReference: 16,
268
+ fontSize: { large: 0.95, tablet: 0.88, small: 0.82, mobile: 0.75 }
269
+ };
270
+ const result = getResponsiveValues(
271
+ '67px',
272
+ 'fontSize',
273
+ scales,
274
+ ['large', 'tablet', 'small', 'mobile']
275
+ );
276
+ expect(result.base).toBe('67px');
277
+ expect(result.large).toBe('64px'); // 67 + (67-16) * (0.95-1) = 67 - 2.55 ≈ 64
278
+ expect(result.tablet).toBe('61px'); // 0.88 scale
279
+ expect(result.small).toBe('58px'); // 67 + (67-16) * (0.82-1) = 67 - 9.18 ≈ 58
280
+ expect(result.mobile).toBe('54px'); // 0.75 scale
281
+ });
282
+
283
+ test('should only calculate for specified breakpoints', () => {
284
+ const scales: ResponsiveScales = {
285
+ enabled: true,
286
+ baseReference: 16,
287
+ fontSize: { large: 0.95, tablet: 0.88, small: 0.82, mobile: 0.75 }
288
+ };
289
+ // Only request large and small breakpoints
290
+ const result = getResponsiveValues(
291
+ '67px',
292
+ 'fontSize',
293
+ scales,
294
+ ['large', 'small']
295
+ );
296
+ expect(result.base).toBe('67px');
297
+ expect(result.large).toBe('64px');
298
+ expect(result.small).toBe('58px');
299
+ expect(result.tablet).toBeUndefined();
300
+ expect(result.mobile).toBeUndefined();
301
+ });
302
+
303
+ test('should gracefully handle breakpoints without scale config', () => {
304
+ const scales: ResponsiveScales = {
305
+ enabled: true,
306
+ baseReference: 16,
307
+ fontSize: { tablet: 0.88, mobile: 0.75 }
308
+ };
309
+ // Request breakpoints that don't have scales configured
310
+ const result = getResponsiveValues(
311
+ '67px',
312
+ 'fontSize',
313
+ scales,
314
+ ['large', 'tablet', 'small', 'mobile']
315
+ );
316
+ expect(result.base).toBe('67px');
317
+ expect(result.large).toBeUndefined(); // No scale configured
318
+ expect(result.tablet).toBe('61px');
319
+ expect(result.small).toBeUndefined(); // No scale configured
320
+ expect(result.mobile).toBe('54px');
321
+ });
322
+
323
+ test('should default to tablet/mobile breakpoints for backward compatibility', () => {
324
+ const scales = { ...DEFAULT_RESPONSIVE_SCALES, enabled: true };
325
+ // No breakpointNames argument - should default to ['tablet', 'mobile']
326
+ const result = getResponsiveValues('67px', 'fontSize', scales);
327
+ expect(Object.keys(result).sort()).toEqual(['base', 'mobile', 'tablet']);
328
+ });
242
329
  });
243
330
 
244
331
  describe('DEFAULT_RESPONSIVE_SCALES', () => {
@@ -4,26 +4,21 @@
4
4
  * using configured scale multipliers
5
5
  */
6
6
 
7
+ /**
8
+ * Scale configuration for a CSS property category
9
+ * Keys are breakpoint names (e.g., 'tablet', 'mobile', 'small')
10
+ * Values are scale multipliers (e.g., 0.88, 0.75)
11
+ */
12
+ export type BreakpointScales = Record<string, number>;
13
+
7
14
  export interface ResponsiveScales {
8
15
  enabled: boolean;
9
16
  baseReference: number;
10
- fontSize?: {
11
- tablet?: number;
12
- mobile?: number;
13
- };
14
- padding?: {
15
- tablet?: number;
16
- mobile?: number;
17
- };
18
- margin?: {
19
- tablet?: number;
20
- mobile?: number;
21
- };
22
- gap?: {
23
- tablet?: number;
24
- mobile?: number;
25
- };
26
- [key: string]: any;
17
+ fontSize?: BreakpointScales;
18
+ padding?: BreakpointScales;
19
+ margin?: BreakpointScales;
20
+ gap?: BreakpointScales;
21
+ [key: string]: boolean | number | BreakpointScales | undefined;
27
22
  }
28
23
 
29
24
  export type CSSPropertyType = 'fontSize' | 'padding' | 'margin' | 'gap' | 'paddingTop' | 'paddingRight' | 'paddingBottom' | 'paddingLeft' | 'marginTop' | 'marginRight' | 'marginBottom' | 'marginLeft' | 'rowGap' | 'columnGap';
@@ -73,16 +68,19 @@ export function calculateResponsiveValue(
73
68
 
74
69
  /**
75
70
  * Get the scale multiplier for a specific property and breakpoint
71
+ * @param scales - The responsive scales configuration
72
+ * @param property - The CSS property type (e.g., 'fontSize', 'padding')
73
+ * @param breakpoint - The breakpoint name (e.g., 'tablet', 'mobile', 'small')
76
74
  */
77
75
  export function getScaleMultiplier(
78
76
  scales: ResponsiveScales,
79
77
  property: CSSPropertyType,
80
- breakpoint: 'tablet' | 'mobile'
78
+ breakpoint: string
81
79
  ): number | null {
82
80
  const category = getScaleCategory(property);
83
81
  if (!category || !scales[category]) return null;
84
82
 
85
- const scaleConfig = scales[category] as Record<string, number> | undefined;
83
+ const scaleConfig = scales[category] as BreakpointScales | undefined;
86
84
  return scaleConfig?.[breakpoint] ?? null;
87
85
  }
88
86
 
@@ -143,11 +141,18 @@ export function scalePropertyValue(
143
141
  /**
144
142
  * Get responsive values for all breakpoints
145
143
  * Returns object with calculated values for each breakpoint
144
+ *
145
+ * @param baseValue - The base CSS value (e.g., '67px')
146
+ * @param property - The CSS property type (e.g., 'fontSize')
147
+ * @param scales - The responsive scales configuration
148
+ * @param breakpointNames - Optional array of breakpoint names to calculate values for.
149
+ * If not provided, defaults to ['tablet', 'mobile'] for backward compatibility.
146
150
  */
147
151
  export function getResponsiveValues(
148
152
  baseValue: string,
149
153
  property: CSSPropertyType,
150
- scales: ResponsiveScales
154
+ scales: ResponsiveScales,
155
+ breakpointNames?: string[]
151
156
  ): Record<string, string | null> {
152
157
  if (!scales.enabled) {
153
158
  return { base: baseValue };
@@ -159,16 +164,15 @@ export function getResponsiveValues(
159
164
 
160
165
  const baseRef = scales.baseReference || 16;
161
166
 
162
- // Calculate tablet value
163
- const tabletScale = getScaleMultiplier(scales, property, 'tablet');
164
- if (tabletScale !== null) {
165
- result.tablet = scalePropertyValue(baseValue, baseRef, tabletScale);
166
- }
167
+ // Use provided breakpoint names or default to tablet/mobile for backward compatibility
168
+ const breakpoints = breakpointNames ?? ['tablet', 'mobile'];
167
169
 
168
- // Calculate mobile value
169
- const mobileScale = getScaleMultiplier(scales, property, 'mobile');
170
- if (mobileScale !== null) {
171
- result.mobile = scalePropertyValue(baseValue, baseRef, mobileScale);
170
+ // Calculate value for each breakpoint
171
+ for (const breakpointName of breakpoints) {
172
+ const scale = getScaleMultiplier(scales, property, breakpointName);
173
+ if (scale !== null) {
174
+ result[breakpointName] = scalePropertyValue(baseValue, baseRef, scale);
175
+ }
172
176
  }
173
177
 
174
178
  return result;
@@ -11,8 +11,8 @@ import type { BreakpointConfig } from './breakpoints';
11
11
 
12
12
  describe('responsiveStyleUtils', () => {
13
13
  const breakpoints: BreakpointConfig = {
14
- tablet: 1024,
15
- mobile: 540,
14
+ tablet: { breakpoint: 1024, previewPoint: 768 },
15
+ mobile: { breakpoint: 540, previewPoint: 375 },
16
16
  };
17
17
 
18
18
  describe('mergeResponsiveStyles', () => {
@@ -110,9 +110,9 @@ describe('responsiveStyleUtils', () => {
110
110
 
111
111
  test('should handle custom breakpoints', () => {
112
112
  const customBreakpoints: BreakpointConfig = {
113
- small: 480,
114
- medium: 768,
115
- large: 1200,
113
+ small: { breakpoint: 480, previewPoint: 375 },
114
+ medium: { breakpoint: 768, previewPoint: 768 },
115
+ large: { breakpoint: 1200, previewPoint: 1200 },
116
116
  };
117
117
 
118
118
  const style: ResponsiveStyleObject = {
@@ -239,8 +239,8 @@ describe('responsiveStyleUtils', () => {
239
239
 
240
240
  test('should work with custom breakpoints', () => {
241
241
  const customBreakpoints: BreakpointConfig = {
242
- small: 480,
243
- large: 1920,
242
+ small: { breakpoint: 480, previewPoint: 375 },
243
+ large: { breakpoint: 1920, previewPoint: 1920 },
244
244
  };
245
245
 
246
246
  expect(breakpointToViewportWidth('small', customBreakpoints)).toBe(480);
@@ -5,7 +5,7 @@
5
5
 
6
6
  import type { ResponsiveStyleObject, StyleObject, StyleValue } from './types';
7
7
  import type { BreakpointConfig, BreakpointName } from './breakpoints';
8
- import { DEFAULT_BREAKPOINTS, getBreakpointName } from './breakpoints';
8
+ import { DEFAULT_BREAKPOINTS, getBreakpointName, getBreakpointValues } from './breakpoints';
9
9
  import { isResponsiveStyle } from './styleUtils';
10
10
 
11
11
  /**
@@ -31,13 +31,16 @@ export function mergeResponsiveStyles(
31
31
  ): StyleObject {
32
32
  // Start with base styles
33
33
  const merged: StyleObject = { ...(responsiveStyle.base || {}) };
34
-
34
+
35
+ // Extract breakpoint values for comparisons
36
+ const breakpointValues = getBreakpointValues(breakpoints);
37
+
35
38
  if (strategy === 'all') {
36
39
  // Merge all breakpoints in order: base → breakpoints sorted by value descending
37
- // Get all breakpoint names and sort by value (descending)
38
- const breakpointEntries = Object.entries(breakpoints);
40
+ // Get all breakpoint names and sort by breakpoint value (descending)
41
+ const breakpointEntries = Object.entries(breakpointValues);
39
42
  breakpointEntries.sort((a, b) => b[1] - a[1]); // Sort descending by value
40
-
43
+
41
44
  // Apply breakpoint styles in order (largest to smallest)
42
45
  for (const [name] of breakpointEntries) {
43
46
  if (responsiveStyle[name]) {
@@ -47,19 +50,19 @@ export function mergeResponsiveStyles(
47
50
  } else if (strategy === 'viewport' && viewportWidth !== undefined) {
48
51
  // Merge only up to active breakpoint based on viewport width
49
52
  const activeBreakpoint = getBreakpointName(viewportWidth, breakpoints);
50
-
53
+
51
54
  // If active breakpoint is 'base', only apply base styles (already done above)
52
55
  if (activeBreakpoint === 'base') {
53
56
  // Base styles already applied, don't apply any other breakpoints
54
57
  return merged;
55
58
  }
56
-
57
- // Get all breakpoint names and sort by value (ascending) to find which ones to apply
58
- const breakpointEntries = Object.entries(breakpoints);
59
+
60
+ // Get all breakpoint names and sort by breakpoint value (ascending) to find which ones to apply
61
+ const breakpointEntries = Object.entries(breakpointValues);
59
62
  breakpointEntries.sort((a, b) => a[1] - b[1]); // Sort ascending by value
60
-
61
- const activeBreakpointValue = breakpoints[activeBreakpoint];
62
-
63
+
64
+ const activeBreakpointValue = breakpointValues[activeBreakpoint];
65
+
63
66
  // Apply styles for breakpoints that are <= active breakpoint value
64
67
  // This means: base (already applied) + all breakpoints up to and including the active one
65
68
  for (const [name, value] of breakpointEntries) {
@@ -68,7 +71,7 @@ export function mergeResponsiveStyles(
68
71
  }
69
72
  }
70
73
  }
71
-
74
+
72
75
  return merged;
73
76
  }
74
77
 
@@ -126,14 +129,17 @@ export function breakpointToViewportWidth(
126
129
  breakpoint: BreakpointName,
127
130
  breakpoints: BreakpointConfig = DEFAULT_BREAKPOINTS
128
131
  ): number {
132
+ // Extract breakpoint values for comparisons
133
+ const breakpointValues = getBreakpointValues(breakpoints);
134
+
129
135
  if (breakpoint === 'base') {
130
136
  // Use a large viewport width to ensure only base styles are applied
131
137
  // Get the largest breakpoint value and add 1
132
- const values = Object.values(breakpoints);
138
+ const values = Object.values(breakpointValues);
133
139
  const maxValue = values.length > 0 ? Math.max(...values) : 1024;
134
140
  return maxValue + 1;
135
141
  }
136
-
142
+
137
143
  // Return the breakpoint value, or a default if not found
138
- return breakpoints[breakpoint] ?? 1024;
144
+ return breakpointValues[breakpoint] ?? 1024;
139
145
  }
@@ -4,7 +4,7 @@
4
4
  */
5
5
 
6
6
  import type { ComponentNode, HtmlNode, ComponentInstanceNode, StyleValue, ResponsiveStyleObject } from './types';
7
- import { isComponentNode, isHtmlNode, isEmbedNode } from './nodeUtils';
7
+ import { isComponentNode, isHtmlNode, isEmbedNode, isListNode } from './nodeUtils';
8
8
  import { isResponsiveStyle } from './styleUtils';
9
9
 
10
10
  /**
@@ -26,8 +26,8 @@ export function applyStylesToNode(
26
26
  }
27
27
 
28
28
  node.props.style = { ...(node.props.style || {}), ...styles };
29
- } else if (isHtmlNode(node) || isEmbedNode(node)) {
30
- // HTML node and Embed node: put styles at top level
29
+ } else if (isHtmlNode(node) || isEmbedNode(node) || isListNode(node)) {
30
+ // HTML node, Embed node, and List node: put styles at top level
31
31
  node.style = styles as StyleValue;
32
32
  }
33
33
 
@@ -45,8 +45,8 @@ export function mergeNodeStyles(
45
45
  ): ComponentNode {
46
46
  if (!instanceStyles) return node;
47
47
 
48
- if (isHtmlNode(node) || isEmbedNode(node)) {
49
- // For HTML nodes and Embed nodes: merge instance styles with existing top-level styles
48
+ if (isHtmlNode(node) || isEmbedNode(node) || isListNode(node)) {
49
+ // For HTML nodes, Embed nodes, and List nodes: merge instance styles with existing top-level styles
50
50
  const existingStyle = node.style;
51
51
  if (existingStyle && typeof existingStyle === 'object') {
52
52
  node.style = {