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
@@ -1823,6 +1823,46 @@ describe('ComponentBuilder', () => {
1823
1823
 
1824
1824
  expect(result).not.toBeNull();
1825
1825
  });
1826
+
1827
+ describe('localized visibility (`if` as an _i18n value)', () => {
1828
+ // The canvas previews ONE locale at a time; a node whose `if` is per-locale must
1829
+ // appear/disappear with it, matching the built site's `i18n({…}) && ( … )`.
1830
+ const i18nConfig = {
1831
+ defaultLocale: 'en',
1832
+ locales: [
1833
+ { code: 'en', name: 'EN', nativeName: 'English', langTag: 'en-US' },
1834
+ { code: 'pl', name: 'PL', nativeName: 'Polski', langTag: 'pl-PL' },
1835
+ ],
1836
+ };
1837
+ const node = {
1838
+ type: 'node',
1839
+ tag: 'div',
1840
+ if: { _i18n: true, en: true, pl: false },
1841
+ children: ['Content'],
1842
+ } as any as ComponentNode;
1843
+
1844
+ test('renders in a locale whose slot is true', () => {
1845
+ expect(builder.buildComponent({ node, i18nConfig, locale: 'en' })).not.toBeNull();
1846
+ });
1847
+
1848
+ test('is dropped in a locale whose slot is false', () => {
1849
+ expect(builder.buildComponent({ node, i18nConfig, locale: 'pl' })).toBeNull();
1850
+ });
1851
+
1852
+ test('with no active locale, falls back to the default locale slot', () => {
1853
+ expect(builder.buildComponent({ node, i18nConfig })).not.toBeNull();
1854
+ const hiddenByDefault = { ...(node as any), if: { _i18n: true, en: false, pl: true } };
1855
+ expect(builder.buildComponent({ node: hiddenByDefault, i18nConfig })).toBeNull();
1856
+ });
1857
+
1858
+ test('a locale with no slot inherits the default locale slot', () => {
1859
+ expect(builder.buildComponent({ node, i18nConfig, locale: 'de' })).not.toBeNull();
1860
+ });
1861
+
1862
+ test('a CMS locale override wins over the page locale (CMS-list preview parity)', () => {
1863
+ expect(builder.buildComponent({ node, i18nConfig, locale: 'en', cmsLocale: 'pl' })).toBeNull();
1864
+ });
1865
+ });
1826
1866
  });
1827
1867
 
1828
1868
  describe('Edge Case 7: List Paths Tracking (Collection Mode)', () => {
@@ -27,6 +27,7 @@ import {
27
27
  isValidNodeType,
28
28
  markAsSlotContent,
29
29
  isBooleanMapping,
30
+ resolveIfI18n,
30
31
  hasIf,
31
32
  hasChildren,
32
33
  isSlotContent as isSlotContentNode,
@@ -183,7 +184,7 @@ export class ComponentBuilder {
183
184
 
184
185
  /**
185
186
  * Evaluate the if condition on a node with full context.
186
- * Handles boolean, mapping, and string template values.
187
+ * Handles boolean, localized (`_i18n`), mapping, and string template values.
187
188
  * Returns true if node should be rendered, false if it should be skipped.
188
189
  */
189
190
  private evaluateIfCondition(node: ComponentNode, ctx: BuilderContext): boolean {
@@ -199,6 +200,12 @@ export class ComponentBuilder {
199
200
  return ifValue;
200
201
  }
201
202
 
203
+ // Localized visibility (`{ _i18n: true, en: true, pl: false }`) — the canvas previews
204
+ // the locale it is currently rendering, matching the built site's `i18n({…}) && (…)`.
205
+ if (isI18nValue(ifValue)) {
206
+ return Boolean(resolveIfI18n(ifValue, ctx.cmsLocale || ctx.locale, ctx.i18nConfig));
207
+ }
208
+
202
209
  // Mapping value - resolve using component props
203
210
  if (isBooleanMapping(ifValue)) {
204
211
  const props = ctx.componentResolvedProps || {};
@@ -0,0 +1,67 @@
1
+ /**
2
+ * Static asset resolution across the two project layouts.
3
+ *
4
+ * An ASTRO project serves static files from `public/` (Astro's web root); a legacy JSON
5
+ * project keeps `images/`, `fonts/`, … at the project root. `handleStaticRoute` resolved
6
+ * only the root form, so the Meno Select (meno-core SSR) canvas 404'd every `/images/…`
7
+ * in an Astro project while the real Astro render served the same URL — the adlittle-web
8
+ * "photos load in Astro but not in Meno Select" report.
9
+ */
10
+
11
+ import { test, expect, describe, beforeAll, afterAll } from 'bun:test';
12
+ import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs';
13
+ import { tmpdir } from 'node:os';
14
+ import { join } from 'node:path';
15
+ import { setProjectRoot } from '../projectContext';
16
+ import { handleStaticRoute } from './static';
17
+
18
+ let root: string;
19
+
20
+ beforeAll(() => {
21
+ root = mkdtempSync(join(tmpdir(), 'meno-static-'));
22
+ // Astro layout: the asset lives under public/.
23
+ mkdirSync(join(root, 'public', 'images', 'leaders'), { recursive: true });
24
+ writeFileSync(join(root, 'public', 'images', 'leaders', 'ada.webp'), 'PUBLIC');
25
+ // Legacy JSON layout: the asset sits at the project root.
26
+ mkdirSync(join(root, 'images'), { recursive: true });
27
+ writeFileSync(join(root, 'images', 'legacy.webp'), 'ROOT');
28
+ // Same relative path present in BOTH — public/ must win (it is the served web root).
29
+ writeFileSync(join(root, 'images', 'both.webp'), 'ROOT');
30
+ mkdirSync(join(root, 'public', 'images'), { recursive: true });
31
+ writeFileSync(join(root, 'public', 'images', 'both.webp'), 'PUBLIC');
32
+ setProjectRoot(root);
33
+ });
34
+
35
+ afterAll(() => rmSync(root, { recursive: true, force: true }));
36
+
37
+ const get = (p: string) => handleStaticRoute(new URL(`http://localhost${p}`));
38
+
39
+ describe('handleStaticRoute — project asset layouts', () => {
40
+ test('ASTRO layout: /images/… resolves under public/', async () => {
41
+ const res = await get('/images/leaders/ada.webp');
42
+ expect(res).toBeDefined();
43
+ expect(await res!.text()).toBe('PUBLIC');
44
+ });
45
+
46
+ test('LEGACY JSON layout: /images/… still resolves at the project root', async () => {
47
+ const res = await get('/images/legacy.webp');
48
+ expect(res).toBeDefined();
49
+ expect(await res!.text()).toBe('ROOT');
50
+ });
51
+
52
+ test('public/ wins when the same path exists in both layouts', async () => {
53
+ const res = await get('/images/both.webp');
54
+ expect(await res!.text()).toBe('PUBLIC');
55
+ });
56
+
57
+ test('a genuinely missing asset is still undefined (404), not the public/ probe', async () => {
58
+ expect(await get('/images/nope.webp')).toBeUndefined();
59
+ });
60
+
61
+ test('path traversal via the public/ probe stays contained', async () => {
62
+ writeFileSync(join(root, 'secret.txt'), 'SECRET');
63
+ // `/images/../../secret.txt` must not escape the project root through either candidate.
64
+ const res = await get('/images/../../secret.txt');
65
+ if (res) expect(await res.text()).not.toBe('SECRET');
66
+ });
67
+ });
@@ -85,7 +85,15 @@ export async function handleStaticRoute(url: URL, _req?: Request): Promise<Respo
85
85
  decodedPathname.startsWith('/videos/')
86
86
  ) {
87
87
  rootPath = getProjectRoot();
88
- filePath = resolveProjectPath(decodedPathname.slice(1)); // Remove leading /
88
+ const rel = decodedPathname.slice(1); // Remove leading /
89
+ // Asset layout differs by project format: an ASTRO project serves static files from
90
+ // `public/` (Astro's web root), a legacy JSON project keeps them at the project root.
91
+ // Try `public/` first and fall back, so both resolve. Without the `public/` probe the
92
+ // Meno Select (meno-core SSR) canvas 404s EVERY image in an Astro project — the real
93
+ // Astro render serves the same `/images/…` URL fine, so it looks like a renderer bug.
94
+ const fromPublic = resolveProjectPath('public', rel);
95
+ filePath =
96
+ isPathWithinRoot(fromPublic, rootPath) && (await fileExists(fromPublic)) ? fromPublic : resolveProjectPath(rel);
89
97
  } else {
90
98
  rootPath = getPackageRoot();
91
99
  filePath = resolvePackagePath(decodedPathname.slice(1)); // Remove leading /
@@ -609,11 +609,18 @@ export async function generateSSRHTML(
609
609
  printMissingStyleWarnings(false);
610
610
 
611
611
  // Build base CSS (reset styles)
612
+ // The border line mirrors meno-astro's BaseLayout `@layer base` reset (Tailwind Preflight
613
+ // parity) so the design canvas and the real Astro build agree on border rendering. Without
614
+ // it, `border-l-[3px] border-solid` — correct Tailwind — paints a full box instead of one
615
+ // edge, because a bare `border-style` with no zero-width baseline arms all four edges.
612
616
  const baseCSS = `* {
613
617
  margin: 0;
614
618
  padding: 0;
615
619
  box-sizing: border-box;
616
620
  }
621
+ *, ::before, ::after, ::backdrop, ::file-selector-button {
622
+ border: 0 solid;
623
+ }
617
624
  body {
618
625
  font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', sans-serif;
619
626
  }
@@ -522,6 +522,37 @@ describe('ssrRenderer', () => {
522
522
  expect(html).toContain('Hello');
523
523
  });
524
524
 
525
+ test('resolves _i18n on `if` — the node renders only in the locales left on', async () => {
526
+ const node = {
527
+ type: 'node',
528
+ tag: 'div',
529
+ children: [
530
+ { type: 'node', tag: 'span', if: { _i18n: true, en: true, pl: false }, children: 'english-only' },
531
+ { type: 'node', tag: 'em', if: { _i18n: true, en: false, pl: true }, children: 'polish-only' },
532
+ ],
533
+ };
534
+
535
+ const enHtml = await render(node, { i18nConfig });
536
+ expect(enHtml).toContain('english-only');
537
+ expect(enHtml).not.toContain('polish-only');
538
+
539
+ const plHtml = await render(node, { locale: 'pl', i18nConfig });
540
+ expect(plHtml).toContain('polish-only');
541
+ expect(plHtml).not.toContain('english-only');
542
+ });
543
+
544
+ test('a locale with no `if` slot falls back to the default locale', async () => {
545
+ const node = {
546
+ type: 'node',
547
+ tag: 'div',
548
+ children: [
549
+ { type: 'node', tag: 'span', if: { _i18n: true, en: false, pl: true }, children: 'hidden-by-default' },
550
+ ],
551
+ };
552
+ const html = await render(node, { locale: 'de', i18nConfig });
553
+ expect(html).not.toContain('hidden-by-default');
554
+ });
555
+
525
556
  test('precedence: _i18n: true wins over a stray type/tag on the same object', async () => {
526
557
  // If someone accidentally writes both _i18n and type on the same object,
527
558
  // i18n wins — the renderer treats it as a localized string, not a node.
@@ -46,6 +46,7 @@ import {
46
46
  isCustomNode,
47
47
  markAsSlotContent,
48
48
  isBooleanMapping,
49
+ resolveIfI18n,
49
50
  hasIf,
50
51
  isSlotContent,
51
52
  } from '../../shared/nodeUtils';
@@ -328,7 +329,7 @@ function processStyleToClasses(style: StyleObject | ResponsiveStyleObject | unde
328
329
 
329
330
  /**
330
331
  * Evaluate the if condition on a node with full SSR context.
331
- * Handles boolean, mapping, and string template values.
332
+ * Handles boolean, localized (`_i18n`), mapping, and string template values.
332
333
  * Returns true if node should be rendered, false if it should be skipped.
333
334
  */
334
335
  function evaluateIfCondition(node: ComponentNode, ctx: SSRContext): boolean {
@@ -344,6 +345,12 @@ function evaluateIfCondition(node: ComponentNode, ctx: SSRContext): boolean {
344
345
  return ifValue;
345
346
  }
346
347
 
348
+ // Localized visibility (`{ _i18n: true, en: true, pl: false }`) — resolve the route's
349
+ // locale slot, mirroring the emitted `i18n({…}) && (…)` in a real Astro build.
350
+ if (isI18nValue(ifValue)) {
351
+ return Boolean(resolveIfI18n(ifValue, ctx.locale, ctx.i18nConfig));
352
+ }
353
+
347
354
  // Mapping value - resolve using component props
348
355
  if (isBooleanMapping(ifValue)) {
349
356
  const props = ctx.componentResolvedProps || {};
@@ -8,6 +8,7 @@ import {
8
8
  applyContainerPattern,
9
9
  styleObjectToCSS,
10
10
  } from './cssGeneration';
11
+ import { physicalizeBoxSpacing } from './styleUtils';
11
12
  import { DEFAULT_BREAKPOINTS } from './breakpoints';
12
13
  import type { ResponsiveScales } from './responsiveScaling';
13
14
  import type { InteractiveStyles } from './types/styles';
@@ -116,6 +117,54 @@ describe('generateUtilityCSS end-to-end', () => {
116
117
  expect(posShorthand).toBeGreaterThanOrEqual(0);
117
118
  expect(posLonghand).toBeGreaterThan(posShorthand); // shorthand precedes longhand → mb wins
118
119
  });
120
+
121
+ test('auto-responsive @media keeps axis roots expanded to longhands', () => {
122
+ // `py`/`px`/`mx`/`my` map to the padding/margin FAMILY for scale lookups, but the
123
+ // emitted declaration must stay two longhands — a scaled `padding: 63px` would paint
124
+ // all four sides, so a `py-[110px]` silently grows horizontal padding on mobile.
125
+ const scales: ResponsiveScales = {
126
+ enabled: true,
127
+ mode: 'breakpoints',
128
+ baseReference: 16,
129
+ padding: { mobile: 0.5 },
130
+ margin: { mobile: 0.5 },
131
+ };
132
+ const css = generateUtilityCSS(new Set(['py-[110px]', 'px-[40px]', 'my-[80px]']), DEFAULT_BREAKPOINTS, scales);
133
+ const mobileBlock = css.slice(css.indexOf('@media (max-width: 540px)'));
134
+ // 110 + (110-16)*(0.5-1) = 63 · 40 + (40-16)*(0.5-1) = 28 · 80 + (80-16)*(0.5-1) = 48
135
+ expect(mobileBlock).toContain('.py-\\[110px\\] { padding-top: 63px; padding-bottom: 63px; }');
136
+ expect(mobileBlock).toContain('.px-\\[40px\\] { padding-left: 28px; padding-right: 28px; }');
137
+ expect(mobileBlock).toContain('.my-\\[80px\\] { margin-top: 48px; margin-bottom: 48px; }');
138
+ expect(mobileBlock).not.toContain('padding: ');
139
+ expect(mobileBlock).not.toContain('margin: ');
140
+ });
141
+
142
+ test('generateSingleClassCSS keeps axis roots expanded in its @media rules', () => {
143
+ const scales: ResponsiveScales = {
144
+ enabled: true,
145
+ mode: 'breakpoints',
146
+ baseReference: 16,
147
+ padding: { mobile: 0.5 },
148
+ };
149
+ const css = generateSingleClassCSS('py-[110px]', DEFAULT_BREAKPOINTS, scales);
150
+ expect(css).toContain('.py-\\[110px\\] { padding-top: 110px; padding-bottom: 110px; }');
151
+ expect(css).toContain('padding-top: 63px; padding-bottom: 63px;');
152
+ expect(css).not.toContain('padding: ');
153
+ });
154
+
155
+ test('fluid mode keeps axis roots expanded on the base rule', () => {
156
+ const scales: ResponsiveScales = {
157
+ enabled: true,
158
+ mode: 'fluid',
159
+ baseReference: 16,
160
+ fluidRange: { min: 320, max: 1440 },
161
+ padding: { mobile: 0.5 },
162
+ };
163
+ const css = generateUtilityCSS(new Set(['py-[110px]']), DEFAULT_BREAKPOINTS, scales);
164
+ expect(css).toContain('padding-top: clamp(63px,');
165
+ expect(css).toContain('padding-bottom: clamp(63px,');
166
+ expect(css).not.toContain('padding: clamp(');
167
+ });
119
168
  });
120
169
 
121
170
  describe('cssGeneration', () => {
@@ -147,15 +196,15 @@ describe('cssGeneration', () => {
147
196
 
148
197
  test('border (full shorthand) still includes color', () => {
149
198
  const rule = generateRuleForClass('border-[1px_solid_var(--primary)]');
150
- expect(rule).toBe('border: 1px solid var(--primary);');
199
+ expect(rule).toBe('border-width: 1px; border-style: solid; border-color: var(--primary);');
151
200
  });
152
201
 
153
- test('bare / numeric border widths render a visible shorthand (no Preflight)', () => {
202
+ test('bare / numeric border widths render visibly (no Preflight)', () => {
154
203
  // `border` alone would be invisible without a border-style, so it expands to `<width> solid`
155
204
  // (color omitted → defaults to currentColor).
156
- expect(generateRuleForClass('border')).toBe('border: 1px solid;');
157
- expect(generateRuleForClass('border-2')).toBe('border: 2px solid;');
158
- expect(generateRuleForClass('border-0')).toBe('border: 0px solid;');
205
+ expect(generateRuleForClass('border')).toBe('border-width: 1px; border-style: solid;');
206
+ expect(generateRuleForClass('border-2')).toBe('border-width: 2px; border-style: solid;');
207
+ expect(generateRuleForClass('border-0')).toBe('border-width: 0px; border-style: solid;');
159
208
  });
160
209
 
161
210
  test('bare / numeric per-side widths split into longhands, no color (a border-color class wins)', () => {
@@ -164,8 +213,37 @@ describe('cssGeneration', () => {
164
213
  });
165
214
 
166
215
  test('logical-axis border widths use border-inline / border-block', () => {
167
- expect(generateRuleForClass('border-x')).toBe('border-inline: 1px solid;');
168
- expect(generateRuleForClass('border-y-2')).toBe('border-block: 2px solid;');
216
+ expect(generateRuleForClass('border-x')).toBe('border-inline-width: 1px; border-inline-style: solid;');
217
+ expect(generateRuleForClass('border-y-2')).toBe('border-block-width: 2px; border-block-style: solid;');
218
+ });
219
+
220
+ test('an ARBITRARY width-only border renders one visible edge, like its scale form', () => {
221
+ // Regression: these used to emit the `border-left: 3px` SHORTHAND, which resets the style to
222
+ // the initial `none` — an invisible border. Authors then reached for a blanket
223
+ // `[border-style:solid]`, which painted all four edges at the default `medium` width.
224
+ expect(generateRuleForClass('border-l-[3px]')).toBe('border-left-width: 3px; border-left-style: solid;');
225
+ expect(generateRuleForClass('border-t-[1px]')).toBe('border-top-width: 1px; border-top-style: solid;');
226
+ expect(generateRuleForClass('border-[3px]')).toBe('border-width: 3px; border-style: solid;');
227
+ expect(generateRuleForClass('border-x-[2px]')).toBe('border-inline-width: 2px; border-inline-style: solid;');
228
+ expect(generateRuleForClass('border-l-[0.5rem]')).toBe('border-left-width: 0.5rem; border-left-style: solid;');
229
+ });
230
+
231
+ test('an arbitrary lone style keyword stays a style, not a width', () => {
232
+ expect(generateRuleForClass('border-l-[dashed]')).toBe('border-left-style: dashed;');
233
+ });
234
+
235
+ test('a math-function width is not shredded at its inner spaces', () => {
236
+ // Splitting naively on whitespace produced `border-bottom-width: calc(1px; ... style: +;`.
237
+ expect(generateRuleForClass('border-b-[calc(1px_+_2px)]')).toBe(
238
+ 'border-bottom-width: calc(1px + 2px); border-bottom-style: solid;',
239
+ );
240
+ expect(generateRuleForClass('border-b-[calc(1px_+_2px)_solid_red]')).toBe(
241
+ 'border-bottom-width: calc(1px + 2px); border-bottom-style: solid; border-bottom-color: red;',
242
+ );
243
+ });
244
+
245
+ test('an ambiguous var() value stays a shorthand (it may hold a whole border)', () => {
246
+ expect(generateRuleForClass('border-l-(--w)')).toBe('border-left: var(--w);');
169
247
  });
170
248
 
171
249
  test('border style keyword on its own', () => {
@@ -197,15 +275,16 @@ describe('cssGeneration', () => {
197
275
  expect(generateRuleForClass('border-[rgb(0_0_0_/_50%)]')).toBe('border-color: rgb(0 0 0 / 50%);');
198
276
  });
199
277
 
200
- test('transparent border-color sorts AFTER the border shorthand so it always wins', () => {
278
+ test('transparent border-color sorts AFTER the border width/style so it always wins', () => {
201
279
  // The two classes target distinct properties (border vs border-color), so the longhand
202
280
  // must follow the shorthand in source order regardless of which was collected first.
281
+ // The width/style pair no longer resets the color at all, so this is belt-and-braces.
203
282
  const css = generateUtilityCSS(
204
283
  new Set(['border-[rgba(0,_0,_0,_0)]', 'border-[1px_solid]']),
205
284
  DEFAULT_BREAKPOINTS,
206
285
  { enabled: false } as ResponsiveScales,
207
286
  );
208
- const posShorthand = css.indexOf('border: 1px solid;');
287
+ const posShorthand = css.indexOf('border-width: 1px; border-style: solid;');
209
288
  const posColor = css.indexOf('border-color: rgba(0, 0, 0, 0);');
210
289
  expect(posShorthand).toBeGreaterThanOrEqual(0);
211
290
  expect(posColor).toBeGreaterThan(posShorthand);
@@ -776,4 +855,66 @@ describe('styleObjectToCSS', () => {
776
855
  test('empty object yields an empty string', () => {
777
856
  expect(styleObjectToCSS({})).toBe('');
778
857
  });
858
+
859
+ test('logical px/py-derived box props emit REGULAR physical padding, never padding-inline/block', () => {
860
+ // Both axes → one compact shorthand `padding: <block> <inline>` (CSS's vertical horizontal).
861
+ expect(styleObjectToCSS({ paddingBlock: '100px', paddingInline: '0px' })).toBe('padding: 100px 0px');
862
+ // A lone axis → its two physical side longhands.
863
+ expect(styleObjectToCSS({ paddingInline: '100px' })).toBe('padding-left: 100px; padding-right: 100px');
864
+ expect(styleObjectToCSS({ marginBlock: '8px' })).toBe('margin-top: 8px; margin-bottom: 8px');
865
+ // An explicit side wins over the axis (no clobber); the other side is still filled from the axis.
866
+ expect(styleObjectToCSS({ paddingInline: '100px', paddingLeft: '50px' })).toBe(
867
+ 'padding-left: 50px; padding-right: 100px',
868
+ );
869
+ });
870
+
871
+ test('does not mutate the caller object', () => {
872
+ const input = { paddingInline: '100px', paddingBlock: '0px' };
873
+ styleObjectToCSS(input);
874
+ expect(input).toEqual({ paddingInline: '100px', paddingBlock: '0px' });
875
+ });
876
+ });
877
+
878
+ describe('physicalizeBoxSpacing', () => {
879
+ // Cast inputs to a broad record: the generic return type carries the input's literal keys, which
880
+ // would make `toEqual` on the reshaped result a type error even though the runtime is correct.
881
+ const boxed = (o: Record<string, unknown>) => physicalizeBoxSpacing(o);
882
+
883
+ test('both axes with no competing key collapse to a shorthand', () => {
884
+ expect(boxed({ paddingInline: '0px', paddingBlock: '100px' })).toEqual({ padding: '100px 0px' });
885
+ expect(boxed({ marginInline: '0px', marginBlock: '24px' })).toEqual({ margin: '24px 0px' });
886
+ });
887
+
888
+ test('a lone axis expands to its two physical side longhands', () => {
889
+ expect(boxed({ paddingInline: '100px' })).toEqual({ paddingLeft: '100px', paddingRight: '100px' });
890
+ expect(boxed({ paddingBlock: '100px' })).toEqual({ paddingTop: '100px', paddingBottom: '100px' });
891
+ });
892
+
893
+ test('a competing physical/shorthand key blocks the shorthand and never clobbers explicit sides', () => {
894
+ // `padding` shorthand already present → axes expand to sides layered over it.
895
+ expect(boxed({ padding: '4px', paddingInline: '100px' })).toEqual({
896
+ padding: '4px',
897
+ paddingLeft: '100px',
898
+ paddingRight: '100px',
899
+ });
900
+ // An explicit side is preserved; the axis fills only the missing side.
901
+ expect(boxed({ paddingInline: '100px', paddingLeft: '50px' })).toEqual({
902
+ paddingLeft: '50px',
903
+ paddingRight: '100px',
904
+ });
905
+ });
906
+
907
+ test('a bound (_mapping) axis is kept as a side longhand rather than string-composed', () => {
908
+ const mapping = { _mapping: { prop: 'x' } };
909
+ expect(boxed({ paddingInline: mapping, paddingBlock: '10px' })).toEqual({
910
+ paddingLeft: mapping,
911
+ paddingRight: mapping,
912
+ paddingTop: '10px',
913
+ paddingBottom: '10px',
914
+ });
915
+ });
916
+
917
+ test('no logical box props → untouched', () => {
918
+ expect(boxed({ padding: '10px', color: 'red' })).toEqual({ padding: '10px', color: 'red' });
919
+ });
779
920
  });