meno-core 1.0.48 → 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 (74) hide show
  1. package/dist/build-static.js +7 -7
  2. package/dist/chunks/{chunk-UUA5LEWF.js → chunk-6IVUG7FY.js} +138 -7
  3. package/dist/chunks/chunk-6IVUG7FY.js.map +7 -0
  4. package/dist/chunks/{chunk-B2RTLDXY.js → chunk-AZQYF6KE.js} +132 -1
  5. package/dist/chunks/chunk-AZQYF6KE.js.map +7 -0
  6. package/dist/chunks/{chunk-NKUV77SR.js → chunk-CHD5UCFF.js} +21 -9
  7. package/dist/chunks/{chunk-NKUV77SR.js.map → chunk-CHD5UCFF.js.map} +2 -2
  8. package/dist/chunks/{chunk-TPQ7APVQ.js → chunk-EQYDSPBB.js} +418 -62
  9. package/dist/chunks/chunk-EQYDSPBB.js.map +7 -0
  10. package/dist/chunks/{chunk-RQSTH2BS.js → chunk-H4JSCDNW.js} +2 -2
  11. package/dist/chunks/{chunk-EK4KESLU.js → chunk-J23ZX5AP.js} +8 -2
  12. package/dist/chunks/{chunk-EK4KESLU.js.map → chunk-J23ZX5AP.js.map} +2 -2
  13. package/dist/chunks/{chunk-D5E3OKSL.js → chunk-JER5NQVM.js} +5 -5
  14. package/dist/chunks/{chunk-BJRKEPMP.js → chunk-KPU2XHOS.js} +5 -2
  15. package/dist/chunks/{chunk-BJRKEPMP.js.map → chunk-KPU2XHOS.js.map} +2 -2
  16. package/dist/chunks/{chunk-NP76N4HQ.js → chunk-LKAGAQ3M.js} +2 -2
  17. package/dist/chunks/{chunk-3FHJUHAS.js → chunk-S2CX6HFM.js} +260 -25
  18. package/dist/chunks/chunk-S2CX6HFM.js.map +7 -0
  19. package/dist/chunks/{configService-IGJEC3MC.js → configService-CCA6AIDI.js} +3 -3
  20. package/dist/entries/server-router.js +9 -9
  21. package/dist/entries/server-router.js.map +2 -2
  22. package/dist/lib/client/index.js +54 -20
  23. package/dist/lib/client/index.js.map +3 -3
  24. package/dist/lib/server/index.js +9 -9
  25. package/dist/lib/shared/index.js +46 -10
  26. package/dist/lib/shared/index.js.map +3 -3
  27. package/entries/server-router.tsx +6 -2
  28. package/lib/client/core/ComponentBuilder.ts +8 -1
  29. package/lib/client/core/builders/embedBuilder.ts +15 -2
  30. package/lib/client/core/builders/linkNodeBuilder.ts +15 -2
  31. package/lib/client/core/builders/localeListBuilder.ts +17 -6
  32. package/lib/client/styles/StyleInjector.ts +3 -2
  33. package/lib/client/theme.ts +4 -4
  34. package/lib/server/cssGenerator.test.ts +64 -1
  35. package/lib/server/cssGenerator.ts +48 -9
  36. package/lib/server/providers/fileSystemCMSProvider.test.ts +163 -0
  37. package/lib/server/providers/fileSystemCMSProvider.ts +200 -11
  38. package/lib/server/routes/index.ts +1 -1
  39. package/lib/server/routes/pages.ts +23 -1
  40. package/lib/server/services/cmsService.test.ts +246 -0
  41. package/lib/server/services/cmsService.ts +122 -5
  42. package/lib/server/services/configService.ts +5 -0
  43. package/lib/server/ssr/attributeBuilder.ts +41 -0
  44. package/lib/server/ssr/htmlGenerator.test.ts +113 -0
  45. package/lib/server/ssr/htmlGenerator.ts +51 -4
  46. package/lib/server/ssr/liveReloadIntegration.test.ts +209 -0
  47. package/lib/server/ssr/ssrRenderer.test.ts +306 -0
  48. package/lib/server/ssr/ssrRenderer.ts +182 -44
  49. package/lib/shared/cssGeneration.test.ts +267 -1
  50. package/lib/shared/cssGeneration.ts +240 -18
  51. package/lib/shared/cssProperties.test.ts +247 -1
  52. package/lib/shared/cssProperties.ts +196 -6
  53. package/lib/shared/interfaces/contentProvider.ts +39 -6
  54. package/lib/shared/pathSecurity.ts +16 -0
  55. package/lib/shared/responsiveScaling.test.ts +143 -0
  56. package/lib/shared/responsiveScaling.ts +253 -2
  57. package/lib/shared/themeDefaults.test.ts +3 -3
  58. package/lib/shared/themeDefaults.ts +3 -3
  59. package/lib/shared/types/cms.ts +28 -3
  60. package/lib/shared/types/index.ts +1 -0
  61. package/lib/shared/utilityClassConfig.ts +3 -0
  62. package/lib/shared/utilityClassMapper.test.ts +123 -0
  63. package/lib/shared/utilityClassMapper.ts +179 -8
  64. package/lib/shared/validation/schemas.ts +15 -1
  65. package/lib/shared/validation/validators.ts +26 -1
  66. package/package.json +1 -1
  67. package/dist/chunks/chunk-3FHJUHAS.js.map +0 -7
  68. package/dist/chunks/chunk-B2RTLDXY.js.map +0 -7
  69. package/dist/chunks/chunk-TPQ7APVQ.js.map +0 -7
  70. package/dist/chunks/chunk-UUA5LEWF.js.map +0 -7
  71. /package/dist/chunks/{chunk-RQSTH2BS.js.map → chunk-H4JSCDNW.js.map} +0 -0
  72. /package/dist/chunks/{chunk-D5E3OKSL.js.map → chunk-JER5NQVM.js.map} +0 -0
  73. /package/dist/chunks/{chunk-NP76N4HQ.js.map → chunk-LKAGAQ3M.js.map} +0 -0
  74. /package/dist/chunks/{configService-IGJEC3MC.js.map → configService-CCA6AIDI.js.map} +0 -0
@@ -7,6 +7,13 @@ import {
7
7
  scalePropertyValue,
8
8
  getResponsiveValues,
9
9
  DEFAULT_RESPONSIVE_SCALES,
10
+ DEFAULT_FLUID_RANGE,
11
+ DEFAULT_SITE_MARGIN,
12
+ buildFluidClamp,
13
+ buildFluidClampWithExplicitMin,
14
+ buildFluidPropertyValue,
15
+ buildSiteMarginClamp,
16
+ getSmallestBreakpointName,
10
17
  type ResponsiveScales,
11
18
  type CSSPropertyType,
12
19
  type BreakpointScales,
@@ -381,5 +388,141 @@ describe('responsiveScaling', () => {
381
388
  mobile: 0.4,
382
389
  });
383
390
  });
391
+
392
+ test('should default mode to "breakpoints" and provide fluidRange', () => {
393
+ expect(DEFAULT_RESPONSIVE_SCALES.mode).toBe('breakpoints');
394
+ expect(DEFAULT_RESPONSIVE_SCALES.fluidRange).toEqual({ min: 320, max: 1440 });
395
+ expect(DEFAULT_FLUID_RANGE).toEqual({ min: 320, max: 1440 });
396
+ });
397
+ });
398
+
399
+ describe('buildFluidClamp', () => {
400
+ test('px input — produces clamp(MIN, intercept + slope*100vw, MAX)', () => {
401
+ // base 32, scale 0.75 → MIN = 32 + (32-16)*(0.75-1) = 32 - 4 = 28
402
+ // slope_px = (32-28)/(1440-320) = 4/1120 ≈ 0.003571
403
+ // intercept_px = 28 - 0.003571*320 ≈ 26.857
404
+ // slope_vw = slope_px*100 ≈ 0.357
405
+ const result = buildFluidClamp(32, 'px', 0.75, 320, 1440);
406
+ expect(result.startsWith('clamp(28px,')).toBe(true);
407
+ expect(result.endsWith(', 32px)')).toBe(true);
408
+ expect(result).toContain('vw');
409
+ });
410
+
411
+ test('returns plain value (no clamp) when MIN === MAX', () => {
412
+ // 16 with scale 0.5 stays 16 because <= baseReference.
413
+ expect(buildFluidClamp(16, 'px', 0.5, 320, 1440)).toBe('16px');
414
+ });
415
+
416
+ test('returns plain value when scale === 1', () => {
417
+ expect(buildFluidClamp(32, 'px', 1, 320, 1440)).toBe('32px');
418
+ });
419
+
420
+ test('rem input — emits a clamp() with rem unit on both bounds', () => {
421
+ // calculateResponsiveValue rounds, so we use a large enough rem value that
422
+ // MIN and MAX stay distinct after rounding. base=8rem, scale=0.5, baseRef=1
423
+ // → MIN = round(8 + (8-1)*(-0.5)) = round(4.5) = 5
424
+ const result = buildFluidClamp(8, 'rem', 0.5, 20, 90, 1.0);
425
+ expect(result.startsWith('clamp(5rem,')).toBe(true);
426
+ expect(result.endsWith(', 8rem)')).toBe(true);
427
+ expect(result).toContain('vw');
428
+ });
429
+ });
430
+
431
+ describe('buildFluidClampWithExplicitMin', () => {
432
+ test('px input — MIN=94, MAX=150, fluidRange 320–1700', () => {
433
+ // slope_px = (150-94)/(1700-320) = 56/1380 ≈ 0.04058 → slope_vw ≈ 4.058
434
+ // intercept = 94 - 0.04058*320 ≈ 81.0145
435
+ const result = buildFluidClampWithExplicitMin(94, 150, 'px', 320, 1700);
436
+ expect(result).toMatch(/^clamp\(94px,\s*81\.0145px\s*\+\s*4\.058vw,\s*150px\)$/);
437
+ });
438
+
439
+ test('returns plain MAX when MIN === MAX', () => {
440
+ expect(buildFluidClampWithExplicitMin(50, 50, 'px', 320, 1700)).toBe('50px');
441
+ });
442
+
443
+ test('returns plain MAX when vpMin === vpMax (degenerate range)', () => {
444
+ expect(buildFluidClampWithExplicitMin(40, 80, 'px', 1000, 1000)).toBe('80px');
445
+ });
446
+
447
+ test('rem unit — bounds emitted in rem', () => {
448
+ const result = buildFluidClampWithExplicitMin(2, 4, 'rem', 320, 1440);
449
+ expect(result.startsWith('clamp(2rem,')).toBe(true);
450
+ expect(result.endsWith(', 4rem)')).toBe(true);
451
+ });
452
+
453
+ test('mobile larger than desktop (negative slope) still produces a valid clamp', () => {
454
+ // The function doesn't enforce min<max; it just interpolates. Useful when
455
+ // an author wants the value to shrink as viewport grows.
456
+ const result = buildFluidClampWithExplicitMin(200, 100, 'px', 320, 1700);
457
+ expect(result.startsWith('clamp(200px,')).toBe(true);
458
+ expect(result.endsWith(', 100px)')).toBe(true);
459
+ });
460
+ });
461
+
462
+ describe('buildFluidPropertyValue', () => {
463
+ test('single px value → single clamp()', () => {
464
+ const out = buildFluidPropertyValue('32px', 0.75, 320, 1440);
465
+ expect(out).not.toBeNull();
466
+ expect(out!.startsWith('clamp(')).toBe(true);
467
+ });
468
+
469
+ test('multi-value padding "20px 40px" → both tokens scaled', () => {
470
+ const out = buildFluidPropertyValue('20px 40px', 0.5, 320, 1440);
471
+ expect(out).not.toBeNull();
472
+ const parts = out!.split(' ');
473
+ // The two tokens get joined by space — but each clamp() also has spaces inside.
474
+ // Check both clamp(...) substrings present.
475
+ expect((out!.match(/clamp\(/g) ?? []).length).toBe(2);
476
+ });
477
+
478
+ test('"%" and "em" pass through verbatim', () => {
479
+ expect(buildFluidPropertyValue('50%', 0.5, 320, 1440)).toBeNull();
480
+ expect(buildFluidPropertyValue('1.5em', 0.5, 320, 1440)).toBeNull();
481
+ });
482
+
483
+ test('"auto" pass through (no scalable token) → null', () => {
484
+ expect(buildFluidPropertyValue('auto', 0.5, 320, 1440)).toBeNull();
485
+ });
486
+
487
+ test('mixed "20px auto" → first token clamped, second kept', () => {
488
+ const out = buildFluidPropertyValue('20px auto', 0.5, 320, 1440);
489
+ expect(out).not.toBeNull();
490
+ expect(out).toContain('clamp(');
491
+ expect(out!.endsWith(' auto')).toBe(true);
492
+ });
493
+ });
494
+
495
+ describe('buildSiteMarginClamp', () => {
496
+ test('emits clamp with px on both bounds', () => {
497
+ const out = buildSiteMarginClamp({ min: 16, max: 32 }, { min: 320, max: 1440 });
498
+ expect(out.startsWith('clamp(16px,')).toBe(true);
499
+ expect(out.endsWith(', 32px)')).toBe(true);
500
+ expect(out).toContain('vw');
501
+ });
502
+
503
+ test('returns plain px when min === max', () => {
504
+ expect(buildSiteMarginClamp({ min: 16, max: 16 }, { min: 320, max: 1440 }))
505
+ .toBe('16px');
506
+ });
507
+
508
+ test('default DEFAULT_SITE_MARGIN is 16/32', () => {
509
+ expect(DEFAULT_SITE_MARGIN).toEqual({ min: 16, max: 32 });
510
+ });
511
+ });
512
+
513
+ describe('getSmallestBreakpointName', () => {
514
+ test('picks the entry with smallest breakpoint value', () => {
515
+ const bp = {
516
+ tablet: { breakpoint: 1024 },
517
+ mobile: { breakpoint: 540 },
518
+ small: { breakpoint: 360 },
519
+ };
520
+ expect(getSmallestBreakpointName(bp)).toBe('small');
521
+ });
522
+
523
+ test('returns null for empty / undefined', () => {
524
+ expect(getSmallestBreakpointName(undefined)).toBeNull();
525
+ expect(getSmallestBreakpointName({})).toBeNull();
526
+ });
384
527
  });
385
528
  });
@@ -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,
@@ -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',