meno-core 1.0.47 → 1.0.49

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (97) hide show
  1. package/build-astro.ts +2 -2
  2. package/dist/build-static.js +7 -7
  3. package/dist/chunks/{chunk-UUA5LEWF.js → chunk-6IVUG7FY.js} +138 -7
  4. package/dist/chunks/chunk-6IVUG7FY.js.map +7 -0
  5. package/dist/chunks/{chunk-XSWR3QLI.js → chunk-AZQYF6KE.js} +261 -130
  6. package/dist/chunks/chunk-AZQYF6KE.js.map +7 -0
  7. package/dist/chunks/{chunk-47UNLQUU.js → chunk-CHD5UCFF.js} +57 -12
  8. package/dist/chunks/chunk-CHD5UCFF.js.map +7 -0
  9. package/dist/chunks/{chunk-FGUZOYJX.js → chunk-EQYDSPBB.js} +435 -131
  10. package/dist/chunks/chunk-EQYDSPBB.js.map +7 -0
  11. package/dist/chunks/{chunk-IF3RATBY.js → chunk-H4JSCDNW.js} +2 -2
  12. package/dist/chunks/{chunk-KITQJYZV.js → chunk-J23ZX5AP.js} +40 -4
  13. package/dist/chunks/chunk-J23ZX5AP.js.map +7 -0
  14. package/dist/chunks/{chunk-LJFB5EBT.js → chunk-JER5NQVM.js} +5 -5
  15. package/dist/chunks/{chunk-ZTKHJQ2Z.js → chunk-KPU2XHOS.js} +5 -2
  16. package/dist/chunks/{chunk-ZTKHJQ2Z.js.map → chunk-KPU2XHOS.js.map} +2 -2
  17. package/dist/chunks/{chunk-BCLGRZ3U.js → chunk-LKAGAQ3M.js} +2 -2
  18. package/dist/chunks/{chunk-FED5MME6.js → chunk-S2CX6HFM.js} +262 -26
  19. package/dist/chunks/chunk-S2CX6HFM.js.map +7 -0
  20. package/dist/chunks/{configService-DYCUEURL.js → configService-CCA6AIDI.js} +3 -3
  21. package/dist/entries/server-router.js +9 -9
  22. package/dist/entries/server-router.js.map +2 -2
  23. package/dist/lib/client/index.js +64 -20
  24. package/dist/lib/client/index.js.map +3 -3
  25. package/dist/lib/server/index.js +1737 -296
  26. package/dist/lib/server/index.js.map +4 -4
  27. package/dist/lib/shared/index.js +50 -10
  28. package/dist/lib/shared/index.js.map +3 -3
  29. package/entries/server-router.tsx +6 -2
  30. package/lib/client/core/ComponentBuilder.test.ts +17 -0
  31. package/lib/client/core/ComponentBuilder.ts +25 -1
  32. package/lib/client/core/builders/embedBuilder.ts +15 -2
  33. package/lib/client/core/builders/linkNodeBuilder.ts +15 -2
  34. package/lib/client/core/builders/localeListBuilder.ts +17 -6
  35. package/lib/client/styles/StyleInjector.ts +3 -2
  36. package/lib/client/theme.ts +4 -4
  37. package/lib/server/cssGenerator.test.ts +64 -1
  38. package/lib/server/cssGenerator.ts +48 -9
  39. package/lib/server/index.ts +1 -1
  40. package/lib/server/jsonLoader.test.ts +0 -17
  41. package/lib/server/jsonLoader.ts +0 -81
  42. package/lib/server/providers/fileSystemCMSProvider.test.ts +163 -0
  43. package/lib/server/providers/fileSystemCMSProvider.ts +200 -11
  44. package/lib/server/routes/api/variables.ts +4 -2
  45. package/lib/server/routes/index.ts +1 -1
  46. package/lib/server/routes/pages.ts +23 -1
  47. package/lib/server/services/cmsService.test.ts +246 -0
  48. package/lib/server/services/cmsService.ts +122 -5
  49. package/lib/server/services/configService.ts +5 -0
  50. package/lib/server/ssr/attributeBuilder.ts +41 -0
  51. package/lib/server/ssr/htmlGenerator.test.ts +114 -2
  52. package/lib/server/ssr/htmlGenerator.ts +53 -6
  53. package/lib/server/ssr/liveReloadIntegration.test.ts +209 -0
  54. package/lib/server/ssr/ssrRenderer.test.ts +362 -1
  55. package/lib/server/ssr/ssrRenderer.ts +216 -72
  56. package/lib/server/utils/jsonLineMapper.test.ts +53 -1
  57. package/lib/server/utils/jsonLineMapper.ts +43 -3
  58. package/lib/server/webflow/buildWebflow.ts +343 -123
  59. package/lib/server/webflow/index.ts +1 -0
  60. package/lib/server/webflow/nodeToWebflow.test.ts +3170 -0
  61. package/lib/server/webflow/nodeToWebflow.ts +2141 -129
  62. package/lib/server/webflow/styleMapper.test.ts +389 -0
  63. package/lib/server/webflow/styleMapper.ts +517 -63
  64. package/lib/server/webflow/templateWrapper.ts +49 -0
  65. package/lib/server/webflow/types.ts +218 -18
  66. package/lib/shared/cssGeneration.test.ts +267 -1
  67. package/lib/shared/cssGeneration.ts +240 -18
  68. package/lib/shared/cssProperties.test.ts +247 -1
  69. package/lib/shared/cssProperties.ts +196 -6
  70. package/lib/shared/elementClassName.test.ts +15 -0
  71. package/lib/shared/elementClassName.ts +7 -3
  72. package/lib/shared/interfaces/contentProvider.ts +39 -6
  73. package/lib/shared/pathSecurity.ts +16 -0
  74. package/lib/shared/registry/nodeTypes/ListNodeType.ts +1 -1
  75. package/lib/shared/responsiveScaling.test.ts +143 -0
  76. package/lib/shared/responsiveScaling.ts +253 -2
  77. package/lib/shared/themeDefaults.test.ts +3 -3
  78. package/lib/shared/themeDefaults.ts +3 -3
  79. package/lib/shared/types/cms.ts +28 -3
  80. package/lib/shared/types/index.ts +2 -0
  81. package/lib/shared/types/variables.ts +37 -0
  82. package/lib/shared/utilityClassConfig.ts +3 -0
  83. package/lib/shared/utilityClassMapper.test.ts +123 -0
  84. package/lib/shared/utilityClassMapper.ts +179 -8
  85. package/lib/shared/validation/schemas.ts +15 -1
  86. package/lib/shared/validation/validators.ts +26 -1
  87. package/package.json +1 -1
  88. package/dist/chunks/chunk-47UNLQUU.js.map +0 -7
  89. package/dist/chunks/chunk-FED5MME6.js.map +0 -7
  90. package/dist/chunks/chunk-FGUZOYJX.js.map +0 -7
  91. package/dist/chunks/chunk-KITQJYZV.js.map +0 -7
  92. package/dist/chunks/chunk-UUA5LEWF.js.map +0 -7
  93. package/dist/chunks/chunk-XSWR3QLI.js.map +0 -7
  94. /package/dist/chunks/{chunk-IF3RATBY.js.map → chunk-H4JSCDNW.js.map} +0 -0
  95. /package/dist/chunks/{chunk-LJFB5EBT.js.map → chunk-JER5NQVM.js.map} +0 -0
  96. /package/dist/chunks/{chunk-BCLGRZ3U.js.map → chunk-LKAGAQ3M.js.map} +0 -0
  97. /package/dist/chunks/{configService-DYCUEURL.js.map → configService-CCA6AIDI.js.map} +0 -0
@@ -11,18 +11,47 @@
11
11
  */
12
12
  export type BreakpointScales = Record<string, number>;
13
13
 
14
+ export type ResponsiveMode = 'breakpoints' | 'fluid';
15
+
16
+ export interface FluidRange {
17
+ /** Lower viewport bound in pixels (e.g. 320) */
18
+ min: number;
19
+ /** Upper viewport bound in pixels (e.g. 1440) */
20
+ max: number;
21
+ }
22
+
23
+ export interface SiteMarginConfig {
24
+ /** Lower bound of the side margin at smallest viewport (px). */
25
+ min: number;
26
+ /** Upper bound of the side margin at largest viewport (px). */
27
+ max: number;
28
+ }
29
+
14
30
  export interface ResponsiveScales {
15
31
  enabled: boolean;
32
+ /** Scaling strategy. Missing → 'breakpoints' (back-compat with old configs). */
33
+ mode?: ResponsiveMode;
16
34
  baseReference: number;
35
+ /** Viewport bounds used when `mode === 'fluid'`. Defaults to 320 / 1440 px. */
36
+ fluidRange?: FluidRange;
37
+ /**
38
+ * Container side margin range, in fluid mode emitted as a CSS variable
39
+ * `--site-margin: clamp(min, …, max)` on `:root`. Lets users write
40
+ * `width: calc(100% - var(--site-margin) * 2)` for the container pattern.
41
+ */
42
+ siteMargin?: SiteMarginConfig;
17
43
  fontSize?: BreakpointScales;
18
44
  padding?: BreakpointScales;
19
45
  margin?: BreakpointScales;
20
46
  gap?: BreakpointScales;
21
47
  borderRadius?: BreakpointScales;
22
48
  size?: BreakpointScales;
23
- [key: string]: boolean | number | BreakpointScales | undefined;
49
+ [key: string]: boolean | number | string | BreakpointScales | FluidRange | SiteMarginConfig | undefined;
24
50
  }
25
51
 
52
+ export const DEFAULT_FLUID_RANGE: FluidRange = { min: 320, max: 1440 };
53
+ export const DEFAULT_SITE_MARGIN: SiteMarginConfig = { min: 16, max: 32 };
54
+
26
55
  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';
27
56
 
28
57
  /**
@@ -44,7 +73,7 @@ function getScaleCategory(property: CSSPropertyType): keyof ResponsiveScales | n
44
73
  * Extract numeric value and unit from a CSS value string
45
74
  * e.g., "67px" -> { value: 67, unit: "px" }
46
75
  */
47
- function parseValue(valueStr: string): { value: number; unit: string } | null {
76
+ export function parseValue(valueStr: string): { value: number; unit: string } | null {
48
77
  const match = valueStr.trim().match(/^(-?[\d.]+)(px|rem|em|%|pt)$/);
49
78
  if (!match) return null;
50
79
  return {
@@ -53,6 +82,59 @@ function parseValue(valueStr: string): { value: number; unit: string } | null {
53
82
  };
54
83
  }
55
84
 
85
+ /**
86
+ * CSS properties that get fluid `clamp()` / breakpoint scaling. Mirrors the
87
+ * `AUTO_RESPONSIVE_TYPE_MAP` keys in `cssGeneration.ts`; centralized here so
88
+ * `utilityClassMapper.ts` can consult it without a cross-module import cycle.
89
+ */
90
+ export const SCALABLE_CSS_PROPERTIES: ReadonlySet<string> = new Set([
91
+ 'padding',
92
+ 'padding-left',
93
+ 'padding-right',
94
+ 'padding-top',
95
+ 'padding-bottom',
96
+ 'paddingLeft',
97
+ 'paddingRight',
98
+ 'paddingTop',
99
+ 'paddingBottom',
100
+ 'margin',
101
+ 'margin-left',
102
+ 'margin-right',
103
+ 'margin-top',
104
+ 'margin-bottom',
105
+ 'marginLeft',
106
+ 'marginRight',
107
+ 'marginTop',
108
+ 'marginBottom',
109
+ 'font-size',
110
+ 'fontSize',
111
+ 'gap',
112
+ 'row-gap',
113
+ 'column-gap',
114
+ 'rowGap',
115
+ 'columnGap',
116
+ 'border-radius',
117
+ 'borderRadius',
118
+ 'border-top-left-radius',
119
+ 'border-top-right-radius',
120
+ 'border-bottom-left-radius',
121
+ 'border-bottom-right-radius',
122
+ 'borderTopLeftRadius',
123
+ 'borderTopRightRadius',
124
+ 'borderBottomLeftRadius',
125
+ 'borderBottomRightRadius',
126
+ 'width',
127
+ 'height',
128
+ 'max-width',
129
+ 'max-height',
130
+ 'min-width',
131
+ 'min-height',
132
+ 'maxWidth',
133
+ 'maxHeight',
134
+ 'minWidth',
135
+ 'minHeight',
136
+ ]);
137
+
56
138
  /**
57
139
  * Calculate responsive value using the formula:
58
140
  * responsive_value = base_value + (base_value - base_reference) * (scale - 1)
@@ -224,12 +306,181 @@ export function resolveVariableValueAtBreakpoint(
224
306
  return variable.value;
225
307
  }
226
308
 
309
+ /**
310
+ * Pick the smallest (most restrictive) breakpoint name from a config object,
311
+ * by `breakpoint` field. Used in fluid mode to choose which scale multiplier
312
+ * defines the MIN of the clamp.
313
+ *
314
+ * Returns null if there are no entries.
315
+ */
316
+ export function getSmallestBreakpointName(
317
+ breakpoints: Record<string, { breakpoint: number }> | undefined | null
318
+ ): string | null {
319
+ if (!breakpoints) return null;
320
+ const entries = Object.entries(breakpoints);
321
+ if (entries.length === 0) return null;
322
+ let smallestName = entries[0][0];
323
+ let smallestWidth = entries[0][1].breakpoint;
324
+ for (const [name, cfg] of entries) {
325
+ if (cfg.breakpoint < smallestWidth) {
326
+ smallestWidth = cfg.breakpoint;
327
+ smallestName = name;
328
+ }
329
+ }
330
+ return smallestName;
331
+ }
332
+
333
+ /**
334
+ * Build a `clamp(MIN, intercept + slope*100vw, MAX)` expression that interpolates
335
+ * linearly between (vpMin, MIN) and (vpMax, MAX).
336
+ *
337
+ * - `baseValue` is the desktop/MAX value in CSS units.
338
+ * - `unit` is the CSS unit ("px", "rem", etc.) used for both bounds.
339
+ * - `scale` is the multiplier applied at the small end (mobile). Reuses
340
+ * `calculateResponsiveValue` so the same multipliers used in 'breakpoints'
341
+ * mode produce the same MIN endpoint in 'fluid' mode.
342
+ * - `vpMin` / `vpMax` are viewport widths in pixels.
343
+ *
344
+ * If MIN === MAX (e.g. value at or below baseReference, or scale === 1),
345
+ * returns a plain `${baseValue}${unit}` — no clamp emitted.
346
+ */
347
+ export function buildFluidClamp(
348
+ baseValue: number,
349
+ unit: string,
350
+ scale: number,
351
+ vpMin: number,
352
+ vpMax: number,
353
+ baseReference = 16
354
+ ): string {
355
+ const max = baseValue;
356
+ const min = calculateResponsiveValue(baseValue, baseReference, scale);
357
+
358
+ if (min === max || vpMax === vpMin) {
359
+ return `${formatNumber(max)}${unit}`;
360
+ }
361
+
362
+ // Convert viewport bounds into the same unit as the value, so the
363
+ // intercept term stays in `unit`. For px / rem we keep px throughout vw maths
364
+ // but emit the intercept in the value's unit by converting (1rem = 16px).
365
+ const unitToPx = unit === 'rem' ? 16 : 1;
366
+ const minPx = min * unitToPx;
367
+ const maxPx = max * unitToPx;
368
+ const slopePx = (maxPx - minPx) / (vpMax - vpMin); // px per px-of-viewport
369
+ const interceptPx = minPx - slopePx * vpMin; // px
370
+ const interceptInUnit = interceptPx / unitToPx;
371
+ const slopeVw = slopePx * 100; // because 1vw = viewport_width / 100 px
372
+
373
+ return `clamp(${formatNumber(min)}${unit}, ${formatNumber(interceptInUnit)}${unit} + ${formatNumber(slopeVw)}vw, ${formatNumber(max)}${unit})`;
374
+ }
375
+
376
+ /**
377
+ * Build a `clamp(MIN, intercept + slope*100vw, MAX)` expression where MIN is
378
+ * supplied directly (e.g. from an authored mobile override) rather than
379
+ * derived from `responsiveScales` × `baseReference`.
380
+ *
381
+ * Used when a node has both `style.base.<prop>` and `style.mobile.<prop>` in
382
+ * fluid mode — the mobile value is consumed as the clamp's small end so the
383
+ * smooth interpolation lands exactly on the authored mobile value at the
384
+ * narrow viewport, avoiding the jump that a separate @media override would
385
+ * create.
386
+ *
387
+ * Same units / vw math as `buildFluidClamp`. Returns plain `${max}${unit}`
388
+ * when MIN === MAX or the viewport range degenerates.
389
+ */
390
+ export function buildFluidClampWithExplicitMin(
391
+ minValue: number,
392
+ maxValue: number,
393
+ unit: string,
394
+ vpMin: number,
395
+ vpMax: number
396
+ ): string {
397
+ if (minValue === maxValue || vpMax === vpMin) {
398
+ return `${formatNumber(maxValue)}${unit}`;
399
+ }
400
+
401
+ const unitToPx = unit === 'rem' ? 16 : 1;
402
+ const minPx = minValue * unitToPx;
403
+ const maxPx = maxValue * unitToPx;
404
+ const slopePx = (maxPx - minPx) / (vpMax - vpMin);
405
+ const interceptPx = minPx - slopePx * vpMin;
406
+ const interceptInUnit = interceptPx / unitToPx;
407
+ const slopeVw = slopePx * 100;
408
+
409
+ return `clamp(${formatNumber(minValue)}${unit}, ${formatNumber(interceptInUnit)}${unit} + ${formatNumber(slopeVw)}vw, ${formatNumber(maxValue)}${unit})`;
410
+ }
411
+
412
+ /**
413
+ * Trim noisy floating-point output (e.g. 21.81818181818182 → 21.8182).
414
+ */
415
+ function formatNumber(n: number): string {
416
+ if (Number.isInteger(n)) return String(n);
417
+ return Number(n.toFixed(4)).toString();
418
+ }
419
+
420
+ /**
421
+ * Build the CSS value for the `--site-margin` variable in fluid mode.
422
+ * Always emits a clamp() going from `siteMargin.min` to `siteMargin.max` (px),
423
+ * interpolated linearly across `fluidRange.min`..`fluidRange.max`.
424
+ *
425
+ * If `min === max` the function returns the plain value (no clamp).
426
+ */
427
+ export function buildSiteMarginClamp(
428
+ siteMargin: SiteMarginConfig,
429
+ fluidRange: FluidRange
430
+ ): string {
431
+ const { min, max } = siteMargin;
432
+ const { min: vpMin, max: vpMax } = fluidRange;
433
+
434
+ if (min === max || vpMax === vpMin) {
435
+ return `${formatNumber(max)}px`;
436
+ }
437
+
438
+ const slopePx = (max - min) / (vpMax - vpMin);
439
+ const interceptPx = min - slopePx * vpMin;
440
+ const slopeVw = slopePx * 100;
441
+
442
+ return `clamp(${formatNumber(min)}px, ${formatNumber(interceptPx)}px + ${formatNumber(slopeVw)}vw, ${formatNumber(max)}px)`;
443
+ }
444
+
445
+ /**
446
+ * Build a fluid clamp() value for a CSS string value (e.g. "32px", "2rem").
447
+ * Multi-value strings ("20px 40px") are scaled per-token, like
448
+ * `scalePropertyValue` does for breakpoint mode. Tokens without a unit
449
+ * (`0`, `auto`, `inherit`) are kept verbatim.
450
+ *
451
+ * Returns null if no token is scalable.
452
+ */
453
+ export function buildFluidPropertyValue(
454
+ valueStr: string,
455
+ scale: number,
456
+ vpMin: number,
457
+ vpMax: number,
458
+ baseReference = 16
459
+ ): string | null {
460
+ const parts = parseMultiValue(valueStr);
461
+ if (parts.length === 0) return null;
462
+
463
+ let anyScaled = false;
464
+ const out = parts.map(part => {
465
+ const parsed = parseValue(part);
466
+ if (!parsed) return part;
467
+ if (parsed.unit === '%' || parsed.unit === 'em') return part;
468
+ anyScaled = true;
469
+ return buildFluidClamp(parsed.value, parsed.unit, scale, vpMin, vpMax, baseReference);
470
+ });
471
+
472
+ return anyScaled ? out.join(' ') : null;
473
+ }
474
+
227
475
  /**
228
476
  * Default responsive scales configuration
229
477
  */
230
478
  export const DEFAULT_RESPONSIVE_SCALES: ResponsiveScales = {
231
479
  enabled: false,
480
+ mode: 'breakpoints',
232
481
  baseReference: 16,
482
+ fluidRange: { ...DEFAULT_FLUID_RANGE },
483
+ siteMargin: { ...DEFAULT_SITE_MARGIN },
233
484
  fontSize: {
234
485
  tablet: 0.88,
235
486
  mobile: 0.75,
@@ -82,9 +82,9 @@ describe('themeDefaults', () => {
82
82
  });
83
83
 
84
84
  test('should have text colors', () => {
85
- expect(DEFAULT_PROPS_PANEL_COLORS.text).toBe('#cccccc');
85
+ expect(DEFAULT_PROPS_PANEL_COLORS.text).toBe('#ebebeb');
86
86
  expect(DEFAULT_PROPS_PANEL_COLORS.textSecondary).toBe('#cccccc');
87
- expect(DEFAULT_PROPS_PANEL_COLORS.textMuted).toBe('#888888');
87
+ expect(DEFAULT_PROPS_PANEL_COLORS.textMuted).toBe('#b0b0b0');
88
88
  });
89
89
 
90
90
  test('should have code syntax colors', () => {
@@ -127,7 +127,7 @@ describe('themeDefaults', () => {
127
127
  test('should have text colors', () => {
128
128
  expect(LIGHT_PROPS_PANEL_COLORS.text).toBe('#1a1a1a');
129
129
  expect(LIGHT_PROPS_PANEL_COLORS.textSecondary).toBe('#333333');
130
- expect(LIGHT_PROPS_PANEL_COLORS.textMuted).toBe('#666666');
130
+ expect(LIGHT_PROPS_PANEL_COLORS.textMuted).toBe('#525a63');
131
131
  });
132
132
 
133
133
  test('should have code syntax colors', () => {
@@ -85,9 +85,9 @@ export const DEFAULT_PROPS_PANEL_COLORS: PropsPanelColors = {
85
85
  backgroundTertiary: '#252525',
86
86
  border: '#333333',
87
87
  borderSecondary: '#444444',
88
- text: '#cccccc',
88
+ text: '#ebebeb',
89
89
  textSecondary: '#cccccc',
90
- textMuted: '#888888',
90
+ textMuted: '#b0b0b0',
91
91
  codeString: '#ffffff',
92
92
  codeNumber: '#b5cea8',
93
93
  codeKey: '#9cdcfe',
@@ -114,7 +114,7 @@ export const LIGHT_PROPS_PANEL_COLORS: PropsPanelColors = {
114
114
  borderSecondary: '#d0d0d0',
115
115
  text: '#1a1a1a',
116
116
  textSecondary: '#333333',
117
- textMuted: '#666666',
117
+ textMuted: '#525a63',
118
118
  codeString: '#a31515',
119
119
  codeNumber: '#098658',
120
120
  codeKey: '#0451a5',
@@ -96,9 +96,14 @@ export interface CMSItem {
96
96
  /** ISO timestamp when item was created */
97
97
  _createdAt?: string;
98
98
  /**
99
- * Locale codes where this item is in draft state.
100
- * undefined or [] = published for all locales (backward compatible).
101
- * ['fr', 'de'] = French and German are draft, other locales published.
99
+ * Locale codes where this item is hidden from the live site.
100
+ * undefined or [] = visible for all locales (backward compatible).
101
+ * ['fr', 'de'] = French and German are hidden, other locales visible.
102
+ *
103
+ * NOTE: this is locale-level visibility ("Hidden" in the UI), NOT the WIP
104
+ * draft-version concept. The draft version of an item is stored in a
105
+ * sibling `{filename}.draft.json` file. The legacy `_draftLocales` name
106
+ * is preserved for backward compatibility.
102
107
  */
103
108
  _draftLocales?: string[];
104
109
  /**
@@ -107,10 +112,30 @@ export interface CMSItem {
107
112
  * Example: "/blog/my-post" for urlPattern "/blog/{{slug}}"
108
113
  */
109
114
  _url?: string;
115
+ /**
116
+ * Transient flag set when an item was loaded from a `{filename}.draft.json`
117
+ * file (i.e. it is the draft version, not the published one). Never persisted.
118
+ */
119
+ _isDraft?: boolean;
120
+ /**
121
+ * Transient flag set on a published item when a draft sibling exists.
122
+ * Used by the Studio item list to render a "Has draft" badge. Never persisted.
123
+ */
124
+ _hasDraft?: boolean;
110
125
  /** User-defined fields based on schema */
111
126
  [field: string]: unknown;
112
127
  }
113
128
 
129
+ /**
130
+ * Bundle of both versions of a CMS item, returned by editor APIs.
131
+ * - `published` is omitted for draft-only items (new items not yet published).
132
+ * - `draft` is omitted when there are no pending changes.
133
+ */
134
+ export interface CMSItemVersions {
135
+ published?: CMSItem;
136
+ draft?: CMSItem;
137
+ }
138
+
114
139
  /** CMS Collection - schema + items */
115
140
  export interface CMSCollection {
116
141
  schema: CMSSchema;
@@ -82,6 +82,7 @@ export type {
82
82
  CMSClientDataConfig,
83
83
  CMSSchema,
84
84
  CMSItem,
85
+ CMSItemVersions,
85
86
  CMSCollection,
86
87
  CMSRouteMatch,
87
88
  CMSFilterOperator,
@@ -153,6 +154,7 @@ export {
153
154
  VARIABLE_GROUPS,
154
155
  VARIABLE_GROUP_VALUES,
155
156
  VARIABLE_TYPES,
157
+ VARIABLE_TYPE_VALUES,
156
158
  getGroupForProperty,
157
159
  getGroupAbbreviation,
158
160
  getDefaultScalingType,
@@ -19,7 +19,13 @@ export type VariableGroup =
19
19
  | 'line-height' | 'letter-spacing'
20
20
  | 'margin' | 'padding' | 'gap'
21
21
  | 'size'
22
+ | 'position'
22
23
  | 'border-radius' | 'border-width'
24
+ | 'outline'
25
+ | 'shadow'
26
+ | 'filter'
27
+ | 'duration'
28
+ | 'aspect-ratio'
23
29
  | 'opacity' | 'z-index'
24
30
  | 'text-align'
25
31
  | 'other';
@@ -35,8 +41,14 @@ export const VARIABLE_GROUPS: { value: VariableGroup; label: string }[] = [
35
41
  { value: 'padding', label: 'Padding' },
36
42
  { value: 'gap', label: 'Gap' },
37
43
  { value: 'size', label: 'Size' },
44
+ { value: 'position', label: 'Position' },
38
45
  { value: 'border-radius', label: 'Border Radius' },
39
46
  { value: 'border-width', label: 'Border Width' },
47
+ { value: 'outline', label: 'Outline' },
48
+ { value: 'shadow', label: 'Shadow' },
49
+ { value: 'filter', label: 'Filter' },
50
+ { value: 'duration', label: 'Duration' },
51
+ { value: 'aspect-ratio', label: 'Aspect Ratio' },
40
52
  { value: 'opacity', label: 'Opacity' },
41
53
  { value: 'z-index', label: 'Z-Index' },
42
54
  { value: 'text-align', label: 'Text Align' },
@@ -56,6 +68,26 @@ const CSS_PROPERTY_TO_GROUP: Record<string, VariableGroup> = {
56
68
  'text-align': 'text-align',
57
69
  'opacity': 'opacity',
58
70
  'z-index': 'z-index',
71
+ 'top': 'position',
72
+ 'right': 'position',
73
+ 'bottom': 'position',
74
+ 'left': 'position',
75
+ 'inset': 'position',
76
+ 'inset-block': 'position',
77
+ 'inset-inline': 'position',
78
+ 'inset-block-start': 'position',
79
+ 'inset-block-end': 'position',
80
+ 'inset-inline-start': 'position',
81
+ 'inset-inline-end': 'position',
82
+ 'box-shadow': 'shadow',
83
+ 'text-shadow': 'shadow',
84
+ 'filter': 'filter',
85
+ 'backdrop-filter': 'filter',
86
+ 'aspect-ratio': 'aspect-ratio',
87
+ 'transition-duration': 'duration',
88
+ 'animation-duration': 'duration',
89
+ 'transition-delay': 'duration',
90
+ 'animation-delay': 'duration',
59
91
  };
60
92
 
61
93
  /** Prefix-based mappings for CSS properties */
@@ -81,6 +113,8 @@ const CSS_PROPERTY_PREFIX_GROUPS: { prefix: string; group: VariableGroup }[] = [
81
113
  { prefix: 'border-right-width', group: 'border-width' },
82
114
  { prefix: 'border-bottom-width', group: 'border-width' },
83
115
  { prefix: 'border-left-width', group: 'border-width' },
116
+ { prefix: 'outline-width', group: 'outline' },
117
+ { prefix: 'outline-offset', group: 'outline' },
84
118
  ];
85
119
 
86
120
  /**
@@ -142,6 +176,9 @@ export const VARIABLE_TYPES: { value: VariableType; label: string }[] = [
142
176
  { value: 'none', label: 'None' },
143
177
  ];
144
178
 
179
+ /** All valid variable scaling type values */
180
+ export const VARIABLE_TYPE_VALUES: VariableType[] = VARIABLE_TYPES.map(t => t.value);
181
+
145
182
  /**
146
183
  * Returns an abbreviation for a variable group by taking the first letter of each word.
147
184
  * e.g. 'letter-spacing' -> 'ls', 'font-size' -> 'fs', 'spacing' -> 's'
@@ -221,6 +221,9 @@ export const prefixToCSSProperty: Record<string, string> = {
221
221
  lstt: 'list-style-type',
222
222
  lstp: 'list-style-position',
223
223
 
224
+ // Display
225
+ d: 'display',
226
+
224
227
  // Flexbox
225
228
  fd: 'flex-direction',
226
229
  jc: 'justify-content',
@@ -1,5 +1,7 @@
1
1
  import { describe, test, expect } from 'bun:test';
2
2
  import { stylesToClasses, responsiveStylesToClasses, classToStyle, classesToStyles } from './utilityClassMapper';
3
+ import { getStyleValue, clearRegistry } from './styleValueRegistry';
4
+ import type { ResponsiveScales } from './responsiveScaling';
3
5
 
4
6
  describe('utilityClassMapper', () => {
5
7
  describe('stylesToClasses', () => {
@@ -93,6 +95,127 @@ describe('utilityClassMapper', () => {
93
95
  expect(responsiveStylesToClasses(null)).toEqual([]);
94
96
  expect(responsiveStylesToClasses(undefined)).toEqual([]);
95
97
  });
98
+
99
+ describe('fluid mode — mobile-as-MIN consumption', () => {
100
+ const fluidScales: ResponsiveScales = {
101
+ enabled: true,
102
+ mode: 'fluid',
103
+ baseReference: 16,
104
+ fluidRange: { min: 320, max: 1700 },
105
+ fontSize: { tablet: 0.8, mobile: 0.5 },
106
+ padding: { tablet: 0.75, mobile: 0.5 },
107
+ };
108
+
109
+ test('consumes scalable mobile override into base clamp; no mob- class emitted', () => {
110
+ clearRegistry();
111
+ const classes = responsiveStylesToClasses(
112
+ { base: { fontSize: '150px' }, mobile: { fontSize: '94px' } },
113
+ { fluidActive: true, responsiveScales: fluidScales }
114
+ );
115
+ // base became a hashed class wrapping clamp(94px, ..., 150px)
116
+ const hashed = classes.find((c) => /^fs-h[0-9a-z]+$/.test(c));
117
+ expect(hashed).toBeDefined();
118
+ expect(getStyleValue(hashed!)).toMatch(/^clamp\(94px,/);
119
+ expect(getStyleValue(hashed!)).toContain(', 150px)');
120
+ // no .mob-fs-* emitted
121
+ expect(classes.some((c) => c.startsWith('mob-fs-'))).toBe(false);
122
+ });
123
+
124
+ test('mobile-only non-scalable property (display) stays as mob- class', () => {
125
+ clearRegistry();
126
+ const classes = responsiveStylesToClasses(
127
+ {
128
+ base: { fontSize: '150px' },
129
+ mobile: { fontSize: '94px', display: 'none' },
130
+ },
131
+ { fluidActive: true, responsiveScales: fluidScales }
132
+ );
133
+ // fontSize consumed into base clamp
134
+ const hashed = classes.find((c) => /^fs-h[0-9a-z]+$/.test(c));
135
+ expect(hashed).toBeDefined();
136
+ expect(getStyleValue(hashed!)).toMatch(/^clamp\(94px,/);
137
+ // display:none survives on mobile
138
+ expect(classes).toContain('mob-h');
139
+ });
140
+
141
+ test('tablet override stays as .t-* class (unchanged behavior)', () => {
142
+ clearRegistry();
143
+ const classes = responsiveStylesToClasses(
144
+ {
145
+ base: { fontSize: '150px' },
146
+ tablet: { fontSize: '120px' },
147
+ mobile: { fontSize: '94px' },
148
+ },
149
+ { fluidActive: true, responsiveScales: fluidScales }
150
+ );
151
+ expect(classes).toContain('t-fs-120px');
152
+ // mobile consumed
153
+ expect(classes.some((c) => c.startsWith('mob-fs-'))).toBe(false);
154
+ });
155
+
156
+ test('no mobile override → base passes through unchanged (no consumption)', () => {
157
+ clearRegistry();
158
+ const classes = responsiveStylesToClasses(
159
+ { base: { fontSize: '150px' } },
160
+ { fluidActive: true, responsiveScales: fluidScales }
161
+ );
162
+ expect(classes).toContain('fs-150px');
163
+ expect(classes.some((c) => /^fs-h/.test(c))).toBe(false);
164
+ });
165
+
166
+ test('mismatched units (rem vs px) → falls back, mob- class still emitted', () => {
167
+ clearRegistry();
168
+ const classes = responsiveStylesToClasses(
169
+ { base: { fontSize: '10rem' }, mobile: { fontSize: '94px' } },
170
+ { fluidActive: true, responsiveScales: fluidScales }
171
+ );
172
+ expect(classes).toContain('fs-10rem');
173
+ expect(classes).toContain('mob-fs-94px');
174
+ });
175
+
176
+ test('non-scalable mobile override (display) is not consumed', () => {
177
+ clearRegistry();
178
+ const classes = responsiveStylesToClasses(
179
+ { base: { display: 'flex' }, mobile: { display: 'block' } },
180
+ { fluidActive: true, responsiveScales: fluidScales }
181
+ );
182
+ expect(classes).toContain('f');
183
+ expect(classes).toContain('mob-b');
184
+ });
185
+
186
+ test('fluidActive off → no consumption, mob- class emitted', () => {
187
+ clearRegistry();
188
+ const classes = responsiveStylesToClasses(
189
+ { base: { fontSize: '150px' }, mobile: { fontSize: '94px' } },
190
+ { fluidActive: false, responsiveScales: fluidScales }
191
+ );
192
+ expect(classes).toContain('fs-150px');
193
+ expect(classes).toContain('mob-fs-94px');
194
+ });
195
+
196
+ test('fluidActive on but responsiveScales disabled → no consumption', () => {
197
+ clearRegistry();
198
+ const classes = responsiveStylesToClasses(
199
+ { base: { fontSize: '150px' }, mobile: { fontSize: '94px' } },
200
+ { fluidActive: true, responsiveScales: { ...fluidScales, enabled: false } }
201
+ );
202
+ expect(classes).toContain('fs-150px');
203
+ expect(classes).toContain('mob-fs-94px');
204
+ });
205
+
206
+ test('clamp math: MIN=94px, MAX=150px, fluidRange 320–1700', () => {
207
+ clearRegistry();
208
+ const classes = responsiveStylesToClasses(
209
+ { base: { fontSize: '150px' }, mobile: { fontSize: '94px' } },
210
+ { fluidActive: true, responsiveScales: fluidScales }
211
+ );
212
+ const hashed = classes.find((c) => /^fs-h[0-9a-z]+$/.test(c))!;
213
+ const value = String(getStyleValue(hashed));
214
+ // slope = (150-94)/(1700-320) = 56/1380 ≈ 0.04058 px/px → slopeVw ≈ 4.058vw
215
+ // intercept = 94 - 0.04058*320 = 94 - 12.9855 ≈ 81.0145px
216
+ expect(value).toMatch(/clamp\(94px,\s*81\.0145px\s*\+\s*4\.058vw,\s*150px\)/);
217
+ });
218
+ });
96
219
  });
97
220
 
98
221
  describe('classToStyle', () => {