meno-core 1.1.7 → 1.1.8

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 (52) hide show
  1. package/dist/bin/cli.js +1 -1
  2. package/dist/chunks/{chunk-XTKNX4FW.js → chunk-AMNAKQAI.js} +25 -11
  3. package/dist/chunks/{chunk-XTKNX4FW.js.map → chunk-AMNAKQAI.js.map} +2 -2
  4. package/dist/chunks/{chunk-YMBUSHII.js → chunk-CG3O7H5V.js} +9 -4
  5. package/dist/chunks/chunk-CG3O7H5V.js.map +7 -0
  6. package/dist/chunks/{chunk-4ZRU52J2.js → chunk-EJ6QOX7C.js} +14 -10
  7. package/dist/chunks/chunk-EJ6QOX7C.js.map +7 -0
  8. package/dist/chunks/{chunk-KX2LPIZZ.js → chunk-EKB7GGQK.js} +223 -38
  9. package/dist/chunks/chunk-EKB7GGQK.js.map +7 -0
  10. package/dist/chunks/{chunk-WOJS25K3.js → chunk-G6OXV2IV.js} +2 -2
  11. package/dist/lib/client/index.js +9 -5
  12. package/dist/lib/client/index.js.map +2 -2
  13. package/dist/lib/server/index.js +14 -5
  14. package/dist/lib/server/index.js.map +2 -2
  15. package/dist/lib/shared/index.js +18 -6
  16. package/dist/lib/shared/index.js.map +2 -2
  17. package/lib/client/core/ComponentBuilder.test.ts +40 -0
  18. package/lib/client/core/ComponentBuilder.ts +8 -1
  19. package/lib/server/routes/static.test.ts +67 -0
  20. package/lib/server/routes/static.ts +9 -1
  21. package/lib/server/ssr/htmlGenerator.ts +7 -0
  22. package/lib/server/ssr/ssrRenderer.test.ts +31 -0
  23. package/lib/server/ssr/ssrRenderer.ts +8 -1
  24. package/lib/shared/cssGeneration.test.ts +150 -9
  25. package/lib/shared/cssGeneration.ts +116 -31
  26. package/lib/shared/cssProperties.ts +40 -14
  27. package/lib/shared/i18n.test.ts +21 -0
  28. package/lib/shared/i18n.ts +24 -10
  29. package/lib/shared/nodeUtils.test.ts +22 -0
  30. package/lib/shared/nodeUtils.ts +39 -4
  31. package/lib/shared/permissions.test.ts +46 -6
  32. package/lib/shared/permissions.ts +14 -2
  33. package/lib/shared/pxToRem.ts +2 -0
  34. package/lib/shared/responsiveScaling.test.ts +19 -0
  35. package/lib/shared/responsiveScaling.ts +8 -3
  36. package/lib/shared/styleUtils.ts +53 -0
  37. package/lib/shared/tailwindThemeScale.ts +91 -0
  38. package/lib/shared/types/components.ts +45 -7
  39. package/lib/shared/types/index.ts +1 -0
  40. package/lib/shared/types/permissions.ts +27 -11
  41. package/lib/shared/utilityClassMapper.test.ts +105 -0
  42. package/lib/shared/utilityClassMapper.ts +43 -5
  43. package/lib/shared/utilityClassNames.ts +14 -1
  44. package/lib/shared/validation/propValidator.test.ts +47 -0
  45. package/lib/shared/validation/propValidator.ts +8 -0
  46. package/lib/shared/validation/schemas.test.ts +149 -16
  47. package/lib/shared/validation/schemas.ts +101 -5
  48. package/package.json +1 -1
  49. package/dist/chunks/chunk-4ZRU52J2.js.map +0 -7
  50. package/dist/chunks/chunk-KX2LPIZZ.js.map +0 -7
  51. package/dist/chunks/chunk-YMBUSHII.js.map +0 -7
  52. /package/dist/chunks/{chunk-WOJS25K3.js.map → chunk-G6OXV2IV.js.map} +0 -0
@@ -206,9 +206,14 @@ export function getScaleMultiplier(
206
206
  * Returns array of individual values
207
207
  */
208
208
  export function parseMultiValue(valueStr: string): string[] {
209
- // First, convert hyphen separators to spaces (for class names like "p-0-80px" → "0-80px")
210
- // But only convert hyphens between values (digit/px followed by hyphen followed by digit/px)
211
- const normalized = valueStr.replace(/(?<=\w)-(?=\d|auto|inherit|initial|unset)/g, ' ');
209
+ // Convert hyphen separators to spaces (for class-name-derived values like "0-80px" → "0 80px"),
210
+ // but NEVER when the value contains a CSS function/token. A hyphen inside `var(--text-6xl)` or
211
+ // `calc(100% - 20px)` is part of the identifier, not a value separator — splitting it corrupts the
212
+ // token (`var(--text-6xl)` → `var(--text 6xl)`, an invalid var reference the styles panel then
213
+ // shows at scaled breakpoints). Any value with `(` is a function and keeps its hyphens verbatim.
214
+ const normalized = valueStr.includes('(')
215
+ ? valueStr
216
+ : valueStr.replace(/(?<=\w)-(?=\d|auto|inherit|initial|unset)/g, ' ');
212
217
  return normalized
213
218
  .trim()
214
219
  .split(/\s+/)
@@ -60,3 +60,56 @@ export function isStyleValue(style: unknown): style is StyleValue {
60
60
  // Check if it's a StyleObject
61
61
  return isStyleObject(style);
62
62
  }
63
+
64
+ /**
65
+ * Rewrite the LOGICAL box-spacing props `paddingInline`/`paddingBlock`/`marginInline`/`marginBlock`
66
+ * to REGULAR physical padding/margin, in place. The `px`/`py`/`mx`/`my` classes decode (via
67
+ * `ROOT_CAMEL_OVERRIDES`) to those logical properties, which would surface in the Styles panel as
68
+ * "Padding Inline/Block" rows the author never asked for and emit as `padding-inline`/`padding-block`
69
+ * — logical properties that read as wrong to an author who wrote a plain `px-`/`py-` and behave subtly
70
+ * differently from physical padding (writing-mode / `direction`).
71
+ *
72
+ * Called from `classesToStyles` (so every class→style decode is already physical) and from
73
+ * `styleObjectToCSS` (defensive: also physicalizes a stray `[padding-inline:…]` arbitrary property).
74
+ *
75
+ * Conversion per family (`padding`, `margin`):
76
+ * - both axes set, both plain strings, and no physical/shorthand key of that family already present
77
+ * → one compact `padding`/`margin` shorthand `<block> <inline>` (`py-[100px] px-0` →
78
+ * `padding: 100px 0px`, i.e. CSS's `vertical horizontal`)
79
+ * - otherwise → the two physical side longhands for each present axis (`px-[100px]` alone →
80
+ * paddingLeft + paddingRight), never overwriting a side the caller set explicitly so an
81
+ * accompanying `pl-`/`pr-` still wins.
82
+ *
83
+ * A no-op when no logical box props are present. Returns the same object for chaining.
84
+ */
85
+ export function physicalizeBoxSpacing<T extends Record<string, unknown>>(style: T): T {
86
+ const s = style as Record<string, unknown>;
87
+ for (const family of ['padding', 'margin'] as const) {
88
+ const inline = s[`${family}Inline`];
89
+ const block = s[`${family}Block`];
90
+ if (inline == null && block == null) continue;
91
+ delete s[`${family}Inline`];
92
+ delete s[`${family}Block`];
93
+ const hasPhysical =
94
+ s[family] != null ||
95
+ s[`${family}Top`] != null ||
96
+ s[`${family}Right`] != null ||
97
+ s[`${family}Bottom`] != null ||
98
+ s[`${family}Left`] != null;
99
+ // Both axes with no competing key → one shorthand. Bound (`_mapping`) axis values aren't
100
+ // string-composable, so they fall through to the side-longhand branch (which keeps the object).
101
+ if (typeof inline === 'string' && typeof block === 'string' && !hasPhysical) {
102
+ s[family] = `${block} ${inline}`;
103
+ continue;
104
+ }
105
+ if (inline != null) {
106
+ if (s[`${family}Left`] == null) s[`${family}Left`] = inline;
107
+ if (s[`${family}Right`] == null) s[`${family}Right`] = inline;
108
+ }
109
+ if (block != null) {
110
+ if (s[`${family}Top`] == null) s[`${family}Top`] = block;
111
+ if (s[`${family}Bottom`] == null) s[`${family}Bottom`] = block;
112
+ }
113
+ }
114
+ return style;
115
+ }
@@ -217,6 +217,97 @@ export const THEME_SCALE_BY_CSSVAR: ReadonlyMap<string, ThemeScaleEntry> = new M
217
217
  THEME_SCALE_ENTRIES.map((e) => [e.cssVar, e]),
218
218
  );
219
219
 
220
+ /**
221
+ * Specs indexed by class root. A root can carry MORE than one spec — `font-*` reads both
222
+ * `--font-weight-*` (weight) and `--font-*` (family) — so which namespace a class lands in
223
+ * is decided by the token the project actually declares, not by the class shape.
224
+ */
225
+ const SPECS_BY_ROOT: ReadonlyMap<string, ScaleSpec[]> = (() => {
226
+ const map = new Map<string, ScaleSpec[]>();
227
+ for (const spec of SPECS) {
228
+ const existing = map.get(spec.root);
229
+ if (existing) existing.push(spec);
230
+ else map.set(spec.root, [spec]);
231
+ }
232
+ return map;
233
+ })();
234
+
235
+ export interface ThemeScaleTokenMatch {
236
+ property: string;
237
+ cssVar: string;
238
+ value: string;
239
+ }
240
+
241
+ /**
242
+ * Generic token fallback: resolve `<root>-<suffix>` against the PROJECT's own theme tokens
243
+ * when the suffix isn't one of the built-in scale steps above. `font-display` →
244
+ * `font-family: var(--font-display)`, but only when the project declares `--font-display`.
245
+ *
246
+ * This is what makes a project-specific token a first-class utility class. Without it only
247
+ * the built-in suffixes (`font-sans`, `text-lg`, …) resolve, and a custom token needs the
248
+ * explicit `font-(--font-display)` var form — while the natural `font-display` silently
249
+ * lands in `class=""` as dead weight that styles nothing.
250
+ *
251
+ * Gated on `knownTokens` deliberately: an undeclared `font-typo` stays an unrecognized
252
+ * foreign class rather than emitting an inert `var(--font-typo)`. A typo must not look
253
+ * like it worked. When a root spans two namespaces the declared one wins; if both are
254
+ * declared the first spec listed wins, which is why the weight spec precedes family.
255
+ */
256
+ export function resolveThemeScaleToken(
257
+ root: string,
258
+ suffix: string,
259
+ knownTokens: ReadonlySet<string> | undefined,
260
+ ): ThemeScaleTokenMatch | null {
261
+ if (!knownTokens || !suffix) return null;
262
+ const specs = SPECS_BY_ROOT.get(root);
263
+ if (!specs) return null;
264
+ for (const spec of specs) {
265
+ const name = `${spec.varPrefix}-${suffix}`;
266
+ if (knownTokens.has(name)) {
267
+ return { property: spec.property, cssVar: `--${name}`, value: `var(--${name})` };
268
+ }
269
+ }
270
+ return null;
271
+ }
272
+
273
+ /**
274
+ * Inverse of `resolveThemeScaleToken`: the compact class for a project token that isn't a
275
+ * built-in scale step. `--font-display` + `font-family` → `font-display`. Keeps the styles
276
+ * panel from rewriting a hand-authored `font-display` into `font-(--font-display)` on the
277
+ * first round-trip. The property gate is what stops `--font-weight-bold` from being read
278
+ * under the family namespace (both share the `font-` prefix).
279
+ */
280
+ export function themeScaleClassForToken(cssVar: string, property: string): string | null {
281
+ const name = cssVar.startsWith('--') ? cssVar.slice(2) : cssVar;
282
+ for (const spec of SPECS) {
283
+ if (spec.property !== property) continue;
284
+ const prefix = `${spec.varPrefix}-`;
285
+ if (name.startsWith(prefix) && name.length > prefix.length) {
286
+ return `${spec.root}-${name.slice(prefix.length)}`;
287
+ }
288
+ }
289
+ return null;
290
+ }
291
+
292
+ /**
293
+ * Class root → camelCase property, for merge-key recovery where the token set ISN'T
294
+ * available (the meno-astro runtime). Lets a project-token class conflict-detect against a
295
+ * built-in one — `font-display` must override `font-sans` on the same element.
296
+ *
297
+ * `text` is deliberately absent: it collides with the color root, and `COLOR_ROOT_TO_PROP`
298
+ * already claims it. `font` resolves to fontFamily, so a project defining a custom
299
+ * `--font-weight-*` token gets a merge key of fontFamily at runtime — an accepted
300
+ * imprecision (custom weight tokens are rare, and studio, which has the token set, is exact).
301
+ */
302
+ export const THEME_SCALE_ROOT_PROPERTY: Readonly<Record<string, string>> = {
303
+ font: 'fontFamily',
304
+ rounded: 'borderRadius',
305
+ shadow: 'boxShadow',
306
+ leading: 'lineHeight',
307
+ tracking: 'letterSpacing',
308
+ 'max-w': 'maxWidth',
309
+ };
310
+
220
311
  /**
221
312
  * The CSS value a themed class resolves to: a plain `var(--token)` reference with no fallback.
222
313
  * Renders the project variable when defined, inert otherwise (token-based by design — no baked
@@ -9,7 +9,17 @@ import type { LibrariesConfig } from './libraries';
9
9
  /**
10
10
  * Prop type definitions
11
11
  */
12
- export type PropType = 'string' | 'select' | 'boolean' | 'number' | 'link' | 'file' | 'rich-text' | 'embed' | 'list';
12
+ export type PropType =
13
+ | 'string'
14
+ | 'select'
15
+ | 'boolean'
16
+ | 'number'
17
+ | 'link'
18
+ | 'file'
19
+ | 'rich-text'
20
+ | 'embed'
21
+ | 'list'
22
+ | 'reference';
13
23
 
14
24
  /**
15
25
  * Project-level enum configuration
@@ -20,11 +30,12 @@ export type EnumsConfig = Record<string, readonly string[]>;
20
30
  /**
21
31
  * Internationalization (i18n) value object
22
32
  * Keys are locale codes (e.g., 'en', 'pl', 'de')
23
- * Values can be strings (for string props) or arrays (for list props)
33
+ * Values can be strings (for string props), arrays (for list props), or booleans
34
+ * (for a node's localized `if` — per-locale visibility)
24
35
  */
25
36
  export interface I18nValue {
26
37
  _i18n: true;
27
- [locale: string]: string | unknown[] | true; // true is for the _i18n marker, arrays for list props
38
+ [locale: string]: string | unknown[] | boolean; // `_i18n: true` is the marker; arrays for list props, booleans for localized visibility
28
39
  }
29
40
 
30
41
  /**
@@ -64,7 +75,7 @@ export interface LinkPropValue {
64
75
  * Base prop definition without list-specific fields
65
76
  */
66
77
  export interface BasePropDefinition {
67
- type: Exclude<PropType, 'list'>;
78
+ type: Exclude<PropType, 'list' | 'reference'>;
68
79
  default?: string | number | boolean | I18nValue | LinkPropValue;
69
80
  options?: readonly string[]; // Required for "select" type (inline options)
70
81
  enumName?: string; // For "select" type: reference to project-level enum
@@ -95,10 +106,30 @@ export interface ListPropDefinition {
95
106
  default?: ListItemValue[];
96
107
  }
97
108
 
109
+ /**
110
+ * Reference prop definition — a hand-picked selection of CMS items from a collection.
111
+ *
112
+ * A component declares a reference prop (e.g. `posts`) bound to a `collection`; each
113
+ * instance stores the chosen item id(s) as its value: a single id string when `multiple`
114
+ * is false, an ordered id array when true. The component body iterates the selection with a
115
+ * `list` node whose `sourceType: 'collection'`, `source: <collection>`, and
116
+ * `items: "{{<prop>}}"` — the runtime's getCollectionList resolves the ids to full items
117
+ * (order-preserving, missing ids skipped). Edited per-instance via the CMS item picker.
118
+ */
119
+ export interface ReferencePropDefinition {
120
+ type: 'reference';
121
+ /** Collection the pickable items come from (e.g. "blog"). */
122
+ collection: string;
123
+ /** Multi-select (ordered id array) vs single (id string). Defaults to multi in the UI. */
124
+ multiple?: boolean;
125
+ /** Default selection: an ordered id array (multi) or a single id (single). */
126
+ default?: string | string[];
127
+ }
128
+
98
129
  /**
99
130
  * Prop definition with improved type safety
100
131
  */
101
- export type PropDefinition = BasePropDefinition | ListPropDefinition;
132
+ export type PropDefinition = BasePropDefinition | ListPropDefinition | ReferencePropDefinition;
102
133
 
103
134
  /**
104
135
  * Type guard to check if a prop definition is a list type
@@ -108,10 +139,17 @@ export function isListPropDefinition(def: PropDefinition): def is ListPropDefini
108
139
  }
109
140
 
110
141
  /**
111
- * Type guard to check if a prop definition is a base (non-list) type
142
+ * Type guard to check if a prop definition is a reference (CMS item picker) type
143
+ */
144
+ export function isReferencePropDefinition(def: PropDefinition): def is ReferencePropDefinition {
145
+ return def.type === 'reference';
146
+ }
147
+
148
+ /**
149
+ * Type guard to check if a prop definition is a base (non-list, non-reference) type
112
150
  */
113
151
  export function isBasePropDefinition(def: PropDefinition): def is BasePropDefinition {
114
- return def.type !== 'list';
152
+ return def.type !== 'list' && def.type !== 'reference';
115
153
  }
116
154
 
117
155
  /**
@@ -206,6 +206,7 @@ export {
206
206
  PERMISSION_ROLES,
207
207
  RESOURCE_KINDS,
208
208
  CONFIG_AREAS,
209
+ LEGACY_CONFIG_AREA_ALIASES,
209
210
  CURRENT_PERMISSIONS_VERSION,
210
211
  } from './permissions';
211
212
 
@@ -33,19 +33,35 @@ export const RESOURCE_KINDS: readonly ResourceKind[] = ['page', 'component', 'cm
33
33
 
34
34
  /**
35
35
  * Named global-config capabilities (the `config` resource kind's id namespace):
36
- * - `project` → `project.config.json` (site URL, breakpoints, fonts, …)
37
- * - `colors` → `colors.json`
38
- * - `variables` → `variables.json`
39
- * - `i18n` → locale config inside `project.config.json`
36
+ * - `project` → `project.config.json` (site URL, breakpoints, fonts, …)
37
+ * - `theme` → `src/styles/theme.css` — colors AND variables (design tokens)
38
+ * - `i18n` locale config inside `project.config.json`
40
39
  *
41
- * NOTE: `i18n` lives *inside* `project.config.json`, so the CI checker (which
42
- * maps whole files) enforces it at `project` granularity — granting `i18n`
43
- * without `project` is advisory only (no file maps to it on its own), and
44
- * granting `project` implies `i18n`.
40
+ * Areas track FILE granularity, because the CI checker maps whole changed files
41
+ * back to a resource. Two consequences:
42
+ *
43
+ * - `theme` is a single area (not separate `colors` / `variables`) because both
44
+ * halves live in the one `theme.css` stylesheet — the project's sole token
45
+ * source of truth. Nothing can grant one half without the other.
46
+ * - `i18n` lives *inside* `project.config.json`, so the checker enforces it at
47
+ * `project` granularity — granting `i18n` without `project` is advisory only
48
+ * (no file maps to it on its own), and granting `project` implies `i18n`.
45
49
  */
46
- export type ConfigArea = 'project' | 'colors' | 'variables' | 'i18n';
50
+ export type ConfigArea = 'project' | 'theme' | 'i18n';
51
+
52
+ export const CONFIG_AREAS: readonly ConfigArea[] = ['project', 'theme', 'i18n'] as const;
47
53
 
48
- export const CONFIG_AREAS: readonly ConfigArea[] = ['project', 'colors', 'variables', 'i18n'] as const;
54
+ /**
55
+ * Pre-`theme` area names, kept readable so policies committed before colors and
56
+ * variables collapsed into `theme.css` still resolve. Both map to `theme`, which
57
+ * WIDENS such a grant: an editor allowed only `colors` may now also change
58
+ * variables. That is the granularity the single stylesheet actually has, and
59
+ * this is a soft (UI-coordination) boundary — see the file header.
60
+ */
61
+ export const LEGACY_CONFIG_AREA_ALIASES: Readonly<Record<string, ConfigArea>> = {
62
+ colors: 'theme',
63
+ variables: 'theme',
64
+ };
49
65
 
50
66
  /**
51
67
  * A reference to one editable resource, checked against a {@link PermissionRule}.
@@ -72,7 +88,7 @@ export interface PermissionRule {
72
88
  components?: string[];
73
89
  /** CMS collection ids, e.g. `["posts", "*"]`. */
74
90
  cms?: string[];
75
- /** Config areas, e.g. `["colors", "variables"]` or `["*"]`. */
91
+ /** Config areas ({@link ConfigArea}), e.g. `["theme", "i18n"]` or `["*"]`. */
76
92
  config?: string[];
77
93
  }
78
94
 
@@ -461,6 +461,50 @@ describe('utilityClassMapper', () => {
461
461
  expect(classToStyle('border-wrapper')).toBeNull();
462
462
  });
463
463
 
464
+ test('project theme tokens resolve on theme-scale roots, gated on knownTokens', () => {
465
+ const tokens = new Set(['font-display', 'radius-card', 'shadow-glow', 'text-hero']);
466
+ // The whole point: `font-display` is a font-FAMILY, not the font-weight the `font` prefix
467
+ // map would otherwise imply.
468
+ expect(classToStyle('font-display', tokens)).toEqual({
469
+ prop: 'fontFamily',
470
+ value: 'var(--font-display)',
471
+ });
472
+ expect(classToStyle('rounded-card', tokens)).toEqual({
473
+ prop: 'borderRadius',
474
+ value: 'var(--radius-card)',
475
+ });
476
+ expect(classToStyle('shadow-glow', tokens)).toEqual({
477
+ prop: 'boxShadow',
478
+ value: 'var(--shadow-glow)',
479
+ });
480
+ // Undeclared token stays a foreign class — a typo must not emit an inert var().
481
+ expect(classToStyle('font-dispaly', tokens)).toBeNull();
482
+ // No token set at all (the astro runtime) → unresolved, as before.
483
+ expect(classToStyle('font-display')).toBeNull();
484
+ // Built-in scale steps still win and are token-set-free.
485
+ expect(classToStyle('font-sans', tokens)).toEqual({ prop: 'fontFamily', value: 'var(--font-sans)' });
486
+ });
487
+
488
+ test('a bare color token keeps winning a shared root over the theme-scale namespace', () => {
489
+ // `--text-primary` is overwhelmingly a COLOR name in practice; reading `text-primary` as a
490
+ // font-size the moment a theme declares it would break every project that names it that way.
491
+ expect(classToStyle('text-primary', new Set(['primary', 'text-primary']))).toEqual({
492
+ prop: 'color',
493
+ value: 'primary',
494
+ });
495
+ // With no color token of that name, the font-size namespace is free to claim it.
496
+ expect(classToStyle('text-hero', new Set(['text-hero']))).toEqual({
497
+ prop: 'fontSize',
498
+ value: 'var(--text-hero)',
499
+ });
500
+ });
501
+
502
+ test('theme-token classes round-trip instead of degrading to the var form', () => {
503
+ expect(stylesToClasses({ fontFamily: 'var(--font-display)' })).toContain('font-display');
504
+ // An unknown property/namespace pairing still falls back to the explicit var form.
505
+ expect(stylesToClasses({ color: 'var(--font-display)' })).not.toContain('font-display');
506
+ });
507
+
464
508
  test('composes across classes without collision', () => {
465
509
  expect(classesToStyles(['border', 'border-b-2', 'border-dashed'])).toEqual({
466
510
  base: {
@@ -889,6 +933,8 @@ describe('utilityClassMapper', () => {
889
933
  expect(classToStyle('p-4')).toEqual({ prop: 'padding', value: '16px' });
890
934
  expect(classToStyle('p-2.5')).toEqual({ prop: 'padding', value: '10px' });
891
935
  expect(classToStyle('gap-6')).toEqual({ prop: 'gap', value: '24px' });
936
+ // Singular `classToStyle` yields the LOGICAL intermediate (one class can't carry a two-sided
937
+ // expansion); `classesToStyles` physicalizes the whole object — see the `px/py/mx/my` block below.
892
938
  expect(classToStyle('px-4')).toEqual({ prop: 'paddingInline', value: '16px' });
893
939
  expect(classToStyle('w-64')).toEqual({ prop: 'width', value: '256px' });
894
940
  });
@@ -904,6 +950,55 @@ describe('utilityClassMapper', () => {
904
950
  });
905
951
  });
906
952
 
953
+ describe('px/py/mx/my decode to REGULAR padding/margin (not logical inline/block)', () => {
954
+ test('a lone axis → its two physical side longhands', () => {
955
+ expect(classesToStyles(['px-[100px]'])).toEqual({ base: { paddingLeft: '100px', paddingRight: '100px' } });
956
+ expect(classesToStyles(['py-4'])).toEqual({ base: { paddingTop: '16px', paddingBottom: '16px' } });
957
+ expect(classesToStyles(['mx-auto'])).toEqual({ base: { marginLeft: 'auto', marginRight: 'auto' } });
958
+ });
959
+
960
+ test('both axes with no competing key → one compact shorthand (block inline)', () => {
961
+ expect(classesToStyles(['px-0', 'py-[100px]'])).toEqual({ base: { padding: '100px 0px' } });
962
+ expect(classesToStyles(['mx-[8px]', 'my-[4px]'])).toEqual({ base: { margin: '4px 8px' } });
963
+ });
964
+
965
+ test('an explicit physical side wins over the axis shorthand', () => {
966
+ // `pl-[50px]` must survive: the axis fills only the sides the caller left unset.
967
+ expect(classesToStyles(['px-[100px]', 'pl-[50px]'])).toEqual({
968
+ base: { paddingLeft: '50px', paddingRight: '100px' },
969
+ });
970
+ });
971
+
972
+ test('physicalization is per-breakpoint', () => {
973
+ expect(classesToStyles(['px-[100px]', 'max-lg:py-[8px]'])).toEqual({
974
+ base: { paddingLeft: '100px', paddingRight: '100px' },
975
+ tablet: { paddingTop: '8px', paddingBottom: '8px' },
976
+ });
977
+ });
978
+
979
+ test('no logical box props ever leak out of a multi-class decode', () => {
980
+ const decoded = classesToStyles(['px-4', 'py-2', 'mx-2', 'my-1']);
981
+ const keys = Object.keys(decoded?.base ?? {});
982
+ expect(keys.some((k) => /Inline$|Block$/.test(k))).toBe(false);
983
+ });
984
+
985
+ test('physicalize:false keeps the LOGICAL axis prop (one row per axis, for the Styles panel)', () => {
986
+ // The panel opts out so px-/py- show as a single Padding X/Y row and re-emit px-/py- on save.
987
+ expect(classesToStyles(['px-[100px]'], undefined, { physicalize: false })).toEqual({
988
+ base: { paddingInline: '100px' },
989
+ });
990
+ expect(classesToStyles(['py-4'], undefined, { physicalize: false })).toEqual({
991
+ base: { paddingBlock: '16px' },
992
+ });
993
+ expect(classesToStyles(['px-0', 'py-[100px]'], undefined, { physicalize: false })).toEqual({
994
+ base: { paddingInline: '0px', paddingBlock: '100px' }, // two axes, NOT collapsed to `padding`
995
+ });
996
+ expect(classesToStyles(['mx-auto', 'my-[4px]'], undefined, { physicalize: false })).toEqual({
997
+ base: { marginInline: 'auto', marginBlock: '4px' },
998
+ });
999
+ });
1000
+ });
1001
+
907
1002
  describe('bare color tokens (token-aware)', () => {
908
1003
  const TOKENS = new Set(['primary', 'text', 'border', 'border-light']);
909
1004
 
@@ -1012,6 +1107,16 @@ describe('utilityClassMapper', () => {
1012
1107
  expect(classMergeKey('m_hero_ab12cd')).toBeNull();
1013
1108
  expect(classMergeKey('js-toggle')).toBeNull();
1014
1109
  });
1110
+
1111
+ test('project theme-token class conflicts with the built-in scale on the same root', () => {
1112
+ // The runtime has no token set, so `font-display` can't be resolved — but its property is
1113
+ // recoverable from the root, which is what makes it override `font-sans` on a merge.
1114
+ expect(classMergeKey('font-display')).toBe(classMergeKey('font-sans'));
1115
+ expect(classMergeKey('rounded-card')).toBe(classMergeKey('rounded-lg'));
1116
+ expect(classMergeKey('shadow-glow')).toBe(classMergeKey('shadow-md'));
1117
+ // Distinct properties must NOT collide.
1118
+ expect(classMergeKey('font-display')).not.toBe(classMergeKey('shadow-glow'));
1119
+ });
1015
1120
  });
1016
1121
 
1017
1122
  describe('mergeInstanceClasses', () => {
@@ -5,7 +5,7 @@
5
5
  */
6
6
 
7
7
  import type { ResponsiveStyleObject, StyleObject, StyleMapping, StyleValue } from './types';
8
- import { isResponsiveStyle } from './styleUtils';
8
+ import { isResponsiveStyle, physicalizeBoxSpacing } from './styleUtils';
9
9
  import {
10
10
  propertyMap,
11
11
  propertyOrder,
@@ -39,7 +39,7 @@ import {
39
39
  isBareColorTokenName,
40
40
  } from './utilityClassNames';
41
41
  import { registerStyleValue, registerDynamicStyle, getStyleValue, getDynamicStyle } from './styleValueRegistry';
42
- import { THEME_SCALE_BY_CSSVAR } from './tailwindThemeScale';
42
+ import { THEME_SCALE_BY_CSSVAR, THEME_SCALE_ROOT_PROPERTY, themeScaleClassForToken } from './tailwindThemeScale';
43
43
  import {
44
44
  SCALABLE_CSS_PROPERTIES,
45
45
  buildFluidClampWithExplicitMin,
@@ -115,6 +115,12 @@ function propertyValueToClass(prop: string, value: string | number): string | nu
115
115
  if (entry && entry.property === camelToKebab(prop)) {
116
116
  return entry.className;
117
117
  }
118
+ // Project token outside the built-in scale (`var(--font-display)` → `font-display`).
119
+ // Without this the value falls through to the explicit var form below, so a hand-authored
120
+ // `font-display` would be rewritten to `font-(--font-display)` the first time the styles
121
+ // panel round-trips the element — same rendering, gratuitous churn in the .astro diff.
122
+ const tokenClass = themeScaleClassForToken(varRef[1] ?? '', camelToKebab(prop));
123
+ if (tokenClass) return tokenClass;
118
124
  }
119
125
 
120
126
  // Strip outer quotes from fontFamily values (e.g. "Space Grotesk" → Space Grotesk)
@@ -564,7 +570,11 @@ export function classToStyle(
564
570
  * Convert utility classes back to a style object
565
571
  * Example: ["p-[10px]", "tablet:p-[24px]"] → { base: { padding: "10px" }, tablet: { padding: "24px" } }
566
572
  */
567
- export function classesToStyles(classes: string[], knownTokens?: ReadonlySet<string>): ResponsiveStyleObject | null {
573
+ export function classesToStyles(
574
+ classes: string[],
575
+ knownTokens?: ReadonlySet<string>,
576
+ options?: { physicalize?: boolean },
577
+ ): ResponsiveStyleObject | null {
568
578
  const styles: ResponsiveStyleObject = { base: {}, tablet: {}, mobile: {} };
569
579
 
570
580
  for (const className of classes) {
@@ -594,6 +604,25 @@ export function classesToStyles(classes: string[], knownTokens?: ReadonlySet<str
594
604
  }
595
605
  }
596
606
 
607
+ // Physicalize logical box spacing so `px`/`py`/`mx`/`my` classes decode to REGULAR padding/margin
608
+ // (paddingLeft/Right, paddingTop/Bottom, or the compact `padding`/`margin` shorthand) rather than
609
+ // the logical `paddingInline`/`paddingBlock` their roots map to. Applied per breakpoint bucket, after
610
+ // the size-*/rounded-* mirroring above (which can add a physical side the shorthand collapse must
611
+ // see). The singular `classToStyle` keeps the logical prop — it can't express a two-sided expansion
612
+ // or the both-axes shorthand from one class — so this whole-object pass is the right home. No-op
613
+ // unless a logical box prop is present.
614
+ //
615
+ // `physicalize: false` keeps the LOGICAL axis prop (`paddingInline`/`paddingBlock`/…) so the Styles
616
+ // panel can show one "Padding X/Y" row per axis instead of two physical side rows (and re-emit the
617
+ // idiomatic `px-`/`py-` on save, not `pl-`+`pr-`). Only the panel adapter opts out; every other
618
+ // caller (e.g. the interactive-variant lossless gate) keeps the physical default it round-trips against.
619
+ if (options?.physicalize !== false) {
620
+ for (const bp of ['base', 'tablet', 'mobile'] as const) {
621
+ const bucket = styles[bp];
622
+ if (bucket) physicalizeBoxSpacing(bucket);
623
+ }
624
+ }
625
+
597
626
  // Clean up empty breakpoints
598
627
  if (Object.keys(styles.tablet || {}).length === 0) delete styles.tablet;
599
628
  if (Object.keys(styles.mobile || {}).length === 0) delete styles.mobile;
@@ -648,8 +677,17 @@ export function classMergeKey(className: string): string | null {
648
677
  // `bg-[var(--gray-200)]` still conflicts with a root's `bg-accent`.
649
678
  const dash = base.indexOf('-');
650
679
  if (dash > 0) {
651
- const prop = COLOR_ROOT_TO_PROP[base.slice(0, dash)];
652
- if (prop && /^[a-zA-Z][\w-]*$/.test(base.slice(dash + 1))) return `${bp}|${prop}`;
680
+ const root = base.slice(0, dash);
681
+ const suffix = base.slice(dash + 1);
682
+ if (/^[a-zA-Z][\w-]*$/.test(suffix)) {
683
+ const colorProp = COLOR_ROOT_TO_PROP[root];
684
+ if (colorProp) return `${bp}|${colorProp}`;
685
+ // Project theme-token class (`font-display`). Same recovery, for the roots the token
686
+ // fallback in parseUtilityClass covers — without it `font-display` has a null key and
687
+ // would sit alongside an instance's `font-sans` instead of overriding it.
688
+ const themeProp = THEME_SCALE_ROOT_PROPERTY[root];
689
+ if (themeProp) return `${bp}|${themeProp}`;
690
+ }
653
691
  }
654
692
  return null;
655
693
  }
@@ -35,7 +35,7 @@ import {
35
35
  filterStepToArg,
36
36
  } from './utilityClassConfig';
37
37
  import { isCssNamedColor } from './cssNamedColors';
38
- import { THEME_SCALE_BY_CLASS, themeScaleValue } from './tailwindThemeScale';
38
+ import { THEME_SCALE_BY_CLASS, resolveThemeScaleToken, themeScaleValue } from './tailwindThemeScale';
39
39
 
40
40
  /** Class roots sorted longest-first so `border-t-…` wins over `border-…`. */
41
41
  export const SORTED_ROOTS: readonly string[] = Object.keys(prefixToCSSProperty).sort((a, b) => b.length - a.length);
@@ -759,6 +759,19 @@ function parseRootValue(root: string, rest: string, knownTokens?: ReadonlySet<st
759
759
  return { property: resolveRootProperty(root, value), value, root, kind: 'var' };
760
760
  }
761
761
 
762
+ // Project theme token on a theme-scale root (`font-display` → var(--font-display)), for
763
+ // suffixes outside the built-in scale that THEME_SCALE_BY_CLASS handles up front. Gated on
764
+ // the project token set, same as the color branch above.
765
+ //
766
+ // Deliberately placed AFTER the color branch so a bare color token keeps winning a shared
767
+ // root: `--text-primary` is a far more common COLOR name than a font-size one, and reading
768
+ // `text-primary` as a font-size the moment a theme declares `--text-primary` would silently
769
+ // break every existing project that names its text color that way.
770
+ const themeToken = resolveThemeScaleToken(root, rest, knownTokens);
771
+ if (themeToken) {
772
+ return { property: themeToken.property, value: themeToken.value, root, kind: 'var' };
773
+ }
774
+
762
775
  // Hash fallback: value lives in the style-value registry
763
776
  if (HASH_VALUE_RE.test(rest)) {
764
777
  return { property: prefixToCSSProperty[root] ?? root, value: null, root, kind: 'hash' };
@@ -233,6 +233,53 @@ describe('Prop Validator', () => {
233
233
  });
234
234
  });
235
235
 
236
+ describe('Reference props', () => {
237
+ test('single reference (id string) passes validation', () => {
238
+ const propDefs: Record<string, PropDefinition> = {
239
+ featured: { type: 'reference', collection: 'blog', multiple: false, default: '' },
240
+ };
241
+ const result = validateComponentProps(propDefs, { featured: 'post-1' });
242
+ expect(result.valid).toBe(true);
243
+ expect(result.props.featured).toBe('post-1');
244
+ });
245
+
246
+ test('multi reference (ordered id array) passes validation and preserves order', () => {
247
+ const propDefs: Record<string, PropDefinition> = {
248
+ posts: { type: 'reference', collection: 'blog', multiple: true, default: [] },
249
+ };
250
+ const result = validateComponentProps(propDefs, { posts: ['post-7', 'post-1', 'post-3'] });
251
+ expect(result.valid).toBe(true);
252
+ expect(result.props.posts).toEqual(['post-7', 'post-1', 'post-3']);
253
+ });
254
+
255
+ test('a {{…}} binding string is preserved, not coerced away', () => {
256
+ const propDefs: Record<string, PropDefinition> = {
257
+ posts: { type: 'reference', collection: 'blog', multiple: true, default: [] },
258
+ };
259
+ const result = validateComponentProps(propDefs, { posts: '{{cms.relatedPosts}}' });
260
+ expect(result.valid).toBe(true);
261
+ expect(result.props.posts).toBe('{{cms.relatedPosts}}');
262
+ });
263
+
264
+ test('missing reference prop uses its default', () => {
265
+ const propDefs: Record<string, PropDefinition> = {
266
+ posts: { type: 'reference', collection: 'blog', multiple: true, default: [] },
267
+ };
268
+ const result = validateComponentProps(propDefs, {});
269
+ expect(result.valid).toBe(true);
270
+ expect(result.props.posts).toEqual([]);
271
+ });
272
+
273
+ test('an invalid value (number) degrades gracefully to the default', () => {
274
+ const propDefs: Record<string, PropDefinition> = {
275
+ posts: { type: 'reference', collection: 'blog', multiple: true, default: [] },
276
+ };
277
+ const result = validateComponentProps(propDefs, { posts: 123 });
278
+ // Invalid type → validation error, but graceful degradation keeps the default
279
+ expect(result.props.posts).toEqual([]);
280
+ });
281
+ });
282
+
236
283
  describe('Backward compatibility maintained', () => {
237
284
  test('unknown props allowed but logged', () => {
238
285
  const propDefs: Record<string, PropDefinition> = {
@@ -180,6 +180,14 @@ function validateSingleProp(
180
180
  typeValid = Array.isArray(value) || isI18nValue(value);
181
181
  break;
182
182
 
183
+ case 'reference':
184
+ // Reference stores a picked CMS item id (single) or an ordered array of ids (multi).
185
+ // A `{{…}}` binding string (e.g. an instance inside a CMS template) is also valid and
186
+ // resolved later — never coerce it away.
187
+ coercedValue = value;
188
+ typeValid = typeof value === 'string' || Array.isArray(value);
189
+ break;
190
+
183
191
  default:
184
192
  return {
185
193
  valid: false,