meno-core 1.1.4 → 1.1.6

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 (37) hide show
  1. package/dist/chunks/{chunk-ZEDLSHYQ.js → chunk-GHNLTFCN.js} +44 -12
  2. package/dist/chunks/chunk-GHNLTFCN.js.map +7 -0
  3. package/dist/chunks/{chunk-UOF4MCAD.js → chunk-KX2LPIZZ.js} +607 -16
  4. package/dist/chunks/{chunk-UOF4MCAD.js.map → chunk-KX2LPIZZ.js.map} +3 -3
  5. package/dist/chunks/{chunk-FQBIC2OB.js → chunk-YMBUSHII.js} +1 -1
  6. package/dist/chunks/{chunk-FQBIC2OB.js.map → chunk-YMBUSHII.js.map} +1 -1
  7. package/dist/lib/client/index.js +56 -12
  8. package/dist/lib/client/index.js.map +2 -2
  9. package/dist/lib/server/index.js +121 -26
  10. package/dist/lib/server/index.js.map +3 -3
  11. package/dist/lib/shared/index.js +8 -2
  12. package/dist/lib/shared/index.js.map +1 -1
  13. package/lib/client/core/ComponentBuilder.test.ts +137 -0
  14. package/lib/client/core/ComponentBuilder.ts +52 -13
  15. package/lib/client/core/builders/embedBuilder.ts +15 -1
  16. package/lib/client/core/builders/linkNodeBuilder.ts +15 -1
  17. package/lib/client/core/builders/localeListBuilder.ts +15 -1
  18. package/lib/client/templateEngine.test.ts +106 -3
  19. package/lib/client/templateEngine.ts +50 -10
  20. package/lib/server/fileWatcher.test.ts +90 -0
  21. package/lib/server/fileWatcher.ts +36 -0
  22. package/lib/server/services/configService.ts +0 -5
  23. package/lib/server/ssr/htmlGenerator.test.ts +105 -3
  24. package/lib/server/ssr/htmlGenerator.ts +94 -42
  25. package/lib/server/ssr/ssrRenderer.test.ts +100 -0
  26. package/lib/server/ssr/ssrRenderer.ts +59 -14
  27. package/lib/shared/cssGeneration.test.ts +36 -0
  28. package/lib/shared/cssGeneration.ts +6 -1
  29. package/lib/shared/tailwindThemeScale.ts +1 -0
  30. package/lib/shared/templateStyleVars.ts +49 -0
  31. package/lib/shared/types/comment.ts +9 -3
  32. package/lib/shared/utilityClassConfig.ts +469 -10
  33. package/lib/shared/utilityClassMapper.test.ts +200 -2
  34. package/lib/shared/utilityClassMapper.ts +153 -0
  35. package/lib/shared/utilityClassNames.ts +90 -0
  36. package/package.json +1 -1
  37. package/dist/chunks/chunk-ZEDLSHYQ.js.map +0 -7
@@ -7,6 +7,9 @@ import {
7
7
  splitStyleByClassability,
8
8
  isClassableStyleValue,
9
9
  styleHasMapping,
10
+ classMergeKey,
11
+ mergeInstanceClasses,
12
+ stripClassOverriddenStyleProps,
10
13
  } from './utilityClassMapper';
11
14
  import { getStyleValue, clearRegistry } from './styleValueRegistry';
12
15
  import { defaultTailwindThemeVariables } from './tailwindThemeScale';
@@ -178,8 +181,10 @@ describe('utilityClassMapper', () => {
178
181
  });
179
182
 
180
183
  test('handles properties without a Tailwind root via arbitrary properties', () => {
181
- const classes = stylesToClasses({ textOverflow: 'ellipsis' });
182
- expect(classes).toContain('[text-overflow:ellipsis]');
184
+ // textOverflow graduated to the `text-ellipsis` static; clip-path still has no root.
185
+ const classes = stylesToClasses({ clipPath: 'circle(50%)' });
186
+ expect(classes).toContain('[clip-path:circle(50%)]');
187
+ expect(stylesToClasses({ textOverflow: 'ellipsis' })).toContain('text-ellipsis');
183
188
  });
184
189
 
185
190
  test('align-items stretch maps to a distinct class from flex-start', () => {
@@ -283,6 +288,122 @@ describe('utilityClassMapper', () => {
283
288
  expect(classToStyle('translate-y-4')).toEqual({ prop: 'translate', value: '0 16px' });
284
289
  });
285
290
 
291
+ test('filter functions wrap the value in their function', () => {
292
+ expect(classToStyle('blur-[70px]')).toEqual({ prop: 'filter', value: 'blur(70px)' });
293
+ expect(classToStyle('backdrop-blur-[8px]')).toEqual({ prop: 'backdropFilter', value: 'blur(8px)' });
294
+ expect(classToStyle('blur-(--glow)')).toEqual({ prop: 'filter', value: 'blur(var(--glow))' });
295
+ expect(classToStyle('drop-shadow-[0_4px_6px_#0003]')).toEqual({
296
+ prop: 'filter',
297
+ value: 'drop-shadow(0 4px 6px #0003)',
298
+ });
299
+ // Length/shadow named scales are deliberately not absorbed (no baked theme values).
300
+ expect(classToStyle('blur-sm')).toBeNull();
301
+ expect(classToStyle('drop-shadow-md')).toBeNull();
302
+ });
303
+
304
+ test('filter numeric steps follow Tailwind per-function scales', () => {
305
+ expect(classToStyle('brightness-50')).toEqual({ prop: 'filter', value: 'brightness(0.5)' });
306
+ expect(classToStyle('contrast-125')).toEqual({ prop: 'filter', value: 'contrast(1.25)' });
307
+ expect(classToStyle('saturate-150')).toEqual({ prop: 'filter', value: 'saturate(1.5)' });
308
+ expect(classToStyle('grayscale-50')).toEqual({ prop: 'filter', value: 'grayscale(50%)' });
309
+ expect(classToStyle('invert-25')).toEqual({ prop: 'filter', value: 'invert(25%)' });
310
+ expect(classToStyle('hue-rotate-90')).toEqual({ prop: 'filter', value: 'hue-rotate(90deg)' });
311
+ expect(classToStyle('backdrop-opacity-50')).toEqual({ prop: 'backdropFilter', value: 'opacity(0.5)' });
312
+ // Bare 100% switches are statics.
313
+ expect(classToStyle('grayscale')).toEqual({ prop: 'filter', value: 'grayscale(100%)' });
314
+ expect(classToStyle('invert')).toEqual({ prop: 'filter', value: 'invert(100%)' });
315
+ expect(classToStyle('backdrop-sepia')).toEqual({ prop: 'backdropFilter', value: 'sepia(100%)' });
316
+ expect(classToStyle('filter-none')).toEqual({ prop: 'filter', value: 'none' });
317
+ });
318
+
319
+ test('single filter-function values emit the compact class and round-trip', () => {
320
+ expect(stylesToClasses({ filter: 'blur(70px)' })).toEqual(['blur-[70px]']);
321
+ expect(stylesToClasses({ filter: 'brightness(0.5)' })).toEqual(['brightness-50']);
322
+ expect(stylesToClasses({ filter: 'grayscale(100%)' })).toEqual(['grayscale']);
323
+ expect(stylesToClasses({ filter: 'grayscale(50%)' })).toEqual(['grayscale-50']);
324
+ expect(stylesToClasses({ filter: 'hue-rotate(90deg)' })).toEqual(['hue-rotate-90']);
325
+ expect(stylesToClasses({ filter: 'drop-shadow(0 4px 6px #0003)' })).toEqual(['drop-shadow-[0_4px_6px_#0003]']);
326
+ expect(stylesToClasses({ filter: 'saturate(var(--sat))' })).toEqual(['saturate-(--sat)']);
327
+ expect(stylesToClasses({ backdropFilter: 'blur(8px)' })).toEqual(['backdrop-blur-[8px]']);
328
+ expect(stylesToClasses({ backdropFilter: 'opacity(0.5)' })).toEqual(['backdrop-opacity-50']);
329
+ // Multi-function values have no compact spelling → arbitrary form on the filter root.
330
+ expect(stylesToClasses({ filter: 'blur(4px) brightness(0.5)' })).toEqual(['filter-[blur(4px)_brightness(0.5)]']);
331
+ expect(classesToStyles(['blur-[70px]'])).toEqual({ base: { filter: 'blur(70px)' } });
332
+ expect(classesToStyles(['brightness-50'])).toEqual({ base: { filter: 'brightness(0.5)' } });
333
+ });
334
+
335
+ test('per-axis skew and scale compose into transform/scale', () => {
336
+ expect(classToStyle('skew-x-[10deg]')).toEqual({ prop: 'transform', value: 'skewX(10deg)' });
337
+ expect(classToStyle('skew-y-3')).toEqual({ prop: 'transform', value: 'skewY(3deg)' });
338
+ expect(classToStyle('scale-x-[1.2]')).toEqual({ prop: 'scale', value: '1.2 1' });
339
+ expect(classToStyle('scale-y-95')).toEqual({ prop: 'scale', value: '1 0.95' });
340
+ });
341
+
342
+ test('side border-radius restores both corners on round-trip', () => {
343
+ expect(classesToStyles(['rounded-t-lg'])).toEqual({
344
+ base: { borderTopLeftRadius: 'var(--radius-lg)', borderTopRightRadius: 'var(--radius-lg)' },
345
+ });
346
+ expect(classesToStyles(['rounded-l-[8px]'])).toEqual({
347
+ base: { borderTopLeftRadius: '8px', borderBottomLeftRadius: '8px' },
348
+ });
349
+ expect(classesToStyles(['rounded-b-full'])).toEqual({
350
+ base: { borderBottomLeftRadius: '9999px', borderBottomRightRadius: '9999px' },
351
+ });
352
+ // Corner forms are unaffected (single corner, no mirror).
353
+ expect(classesToStyles(['rounded-tl-[8px]'])).toEqual({ base: { borderTopLeftRadius: '8px' } });
354
+ });
355
+
356
+ test('long-tail Tailwind vocabulary (backgrounds, decoration, SVG, logical inset, scroll)', () => {
357
+ expect(classToStyle('bg-cover')).toEqual({ prop: 'backgroundSize', value: 'cover' });
358
+ expect(classToStyle('bg-clip-text')).toEqual({ prop: 'backgroundClip', value: 'text' });
359
+ expect(classToStyle('float-left')).toEqual({ prop: 'float', value: 'left' });
360
+ expect(classToStyle('text-ellipsis')).toEqual({ prop: 'textOverflow', value: 'ellipsis' });
361
+ expect(classToStyle('text-balance')).toEqual({ prop: 'textWrap', value: 'balance' });
362
+ // Shared `decoration` root disambiguates by value type; `stroke` likewise.
363
+ expect(classToStyle('decoration-2')).toEqual({ prop: 'textDecorationThickness', value: '2px' });
364
+ expect(classToStyle('decoration-[#f00]')).toEqual({ prop: 'textDecorationColor', value: '#f00' });
365
+ expect(classToStyle('decoration-wavy')).toEqual({ prop: 'textDecorationStyle', value: 'wavy' });
366
+ expect(classToStyle('underline-offset-2')).toEqual({ prop: 'textUnderlineOffset', value: '2px' });
367
+ expect(classToStyle('outline-1')).toEqual({ prop: 'outlineWidth', value: '1px' });
368
+ expect(classToStyle('outline-dashed')).toEqual({ prop: 'outlineStyle', value: 'dashed' });
369
+ expect(classToStyle('stroke-2')).toEqual({ prop: 'strokeWidth', value: '2' });
370
+ expect(classToStyle('stroke-[#f00]')).toEqual({ prop: 'stroke', value: '#f00' });
371
+ expect(classToStyle('fill-current')).toEqual({ prop: 'fill', value: 'currentColor' });
372
+ expect(classToStyle('caret-[#f00]')).toEqual({ prop: 'caretColor', value: '#f00' });
373
+ expect(classToStyle('start-0')).toEqual({ prop: 'insetInlineStart', value: '0px' });
374
+ expect(classToStyle('end-4')).toEqual({ prop: 'insetInlineEnd', value: '16px' });
375
+ expect(classToStyle('indent-4')).toEqual({ prop: 'textIndent', value: '16px' });
376
+ expect(classToStyle('border-spacing-2')).toEqual({ prop: 'borderSpacing', value: '8px' });
377
+ expect(classToStyle('scroll-mt-2')).toEqual({ prop: 'scrollMarginTop', value: '8px' });
378
+ expect(classToStyle('touch-pan-x')).toEqual({ prop: 'touchAction', value: 'pan-x' });
379
+ expect(classToStyle('overscroll-contain')).toEqual({ prop: 'overscrollBehavior', value: 'contain' });
380
+ expect(classToStyle('break-inside-avoid')).toEqual({ prop: 'breakInside', value: 'avoid' });
381
+ expect(classToStyle('auto-cols-fr')).toEqual({ prop: 'gridAutoColumns', value: 'minmax(0, 1fr)' });
382
+ expect(classToStyle('rounded-tr-xl')).toEqual({ prop: 'borderTopRightRadius', value: 'var(--radius-xl)' });
383
+ expect(classToStyle('caption-top')).toEqual({ prop: 'captionSide', value: 'top' });
384
+ expect(classToStyle('transform-gpu')).toEqual({ prop: 'transform', value: 'translateZ(0)' });
385
+ expect(classToStyle('animate-none')).toEqual({ prop: 'animation', value: 'none' });
386
+ });
387
+
388
+ test('static/keyword additions parse and forward-map', () => {
389
+ expect(classToStyle('italic')).toEqual({ prop: 'fontStyle', value: 'italic' });
390
+ expect(classToStyle('isolate')).toEqual({ prop: 'isolation', value: 'isolate' });
391
+ expect(classToStyle('border-collapse')).toEqual({ prop: 'borderCollapse', value: 'collapse' });
392
+ expect(classToStyle('tabular-nums')).toEqual({ prop: 'fontVariantNumeric', value: 'tabular-nums' });
393
+ expect(classToStyle('snap-x')).toEqual({ prop: 'scrollSnapType', value: 'x mandatory' });
394
+ expect(classToStyle('mix-blend-multiply')).toEqual({ prop: 'mixBlendMode', value: 'multiply' });
395
+ expect(classToStyle('will-change-scroll')).toEqual({ prop: 'willChange', value: 'scroll-position' });
396
+ expect(classToStyle('columns-3')).toEqual({ prop: 'columns', value: '3' });
397
+ expect(classToStyle('h-dvh')).toEqual({ prop: 'height', value: '100dvh' });
398
+ // Read-side aliases resolve to the canonical value; forward keeps the canonical class.
399
+ expect(classToStyle('text-nowrap')).toEqual({ prop: 'whiteSpace', value: 'nowrap' });
400
+ expect(classToStyle('break-words')).toEqual({ prop: 'overflowWrap', value: 'break-word' });
401
+ expect(stylesToClasses({ whiteSpace: 'nowrap' })).toEqual(['whitespace-nowrap']);
402
+ expect(stylesToClasses({ mixBlendMode: 'multiply' })).toEqual(['mix-blend-multiply']);
403
+ expect(stylesToClasses({ columns: '3' })).toEqual(['columns-3']);
404
+ expect(stylesToClasses({ height: '100dvh' })).toEqual(['h-dvh']);
405
+ });
406
+
286
407
  test('transition longhands compose; duration/ease override the shorthand', () => {
287
408
  expect(classToStyle('duration-300')).toEqual({ prop: 'transitionDuration', value: '300ms' });
288
409
  expect(classToStyle('delay-150')).toEqual({ prop: 'transitionDelay', value: '150ms' });
@@ -868,4 +989,81 @@ describe('utilityClassMapper', () => {
868
989
  expect(styleHasMapping(undefined)).toBe(false);
869
990
  });
870
991
  });
992
+
993
+ describe('classMergeKey', () => {
994
+ test('same (breakpoint, property) → same key across value forms', () => {
995
+ expect(classMergeKey('max-w-full')).toBe(classMergeKey('max-w-[283px]'));
996
+ expect(classMergeKey('p-4')).toBe(classMergeKey('p-[13px]'));
997
+ });
998
+
999
+ test('breakpoint forms normalize to one key (max-lg / legacy tablet / bare lg)', () => {
1000
+ const key = classMergeKey('max-lg:p-4');
1001
+ expect(classMergeKey('tablet:p-[10px]')).toBe(key);
1002
+ expect(classMergeKey('lg:p-2.5')).toBe(key);
1003
+ expect(classMergeKey('p-4')).not.toBe(key); // base ≠ tablet
1004
+ });
1005
+
1006
+ test('bare color-token class recovers the property from its root', () => {
1007
+ expect(classMergeKey('bg-accent')).toBe(classMergeKey('bg-[var(--gray-200)]'));
1008
+ expect(classMergeKey('text-muted')).toBe(classMergeKey('text-[#ff0000]'));
1009
+ });
1010
+
1011
+ test('unrecognized classes (element hashes, foreign) yield null', () => {
1012
+ expect(classMergeKey('m_hero_ab12cd')).toBeNull();
1013
+ expect(classMergeKey('js-toggle')).toBeNull();
1014
+ });
1015
+ });
1016
+
1017
+ describe('mergeInstanceClasses', () => {
1018
+ test('instance class drops the root class for the same property', () => {
1019
+ expect(mergeInstanceClasses(['max-w-full', 'flex'], 'max-w-[283px]')).toEqual(['flex', 'max-w-[283px]']);
1020
+ });
1021
+
1022
+ test('conflicts are per-breakpoint — root tablet override survives a base instance class', () => {
1023
+ expect(mergeInstanceClasses(['max-w-full', 'max-lg:max-w-[90%]'], 'max-w-[283px]')).toEqual([
1024
+ 'max-lg:max-w-[90%]',
1025
+ 'max-w-[283px]',
1026
+ ]);
1027
+ });
1028
+
1029
+ test('null-key classes on either side are always kept', () => {
1030
+ expect(mergeInstanceClasses(['m_hero_ab12cd', 'p-4'], 'p-[24px] js-x')).toEqual([
1031
+ 'm_hero_ab12cd',
1032
+ 'p-[24px]',
1033
+ 'js-x',
1034
+ ]);
1035
+ });
1036
+
1037
+ test('empty instance string is a no-op', () => {
1038
+ expect(mergeInstanceClasses(['p-4'], '')).toEqual(['p-4']);
1039
+ });
1040
+ });
1041
+
1042
+ describe('stripClassOverriddenStyleProps', () => {
1043
+ test('drops the flat style property an instance class overrides', () => {
1044
+ expect(stripClassOverriddenStyleProps({ maxWidth: '100%', display: 'flex' }, 'max-w-[283px]')).toEqual({
1045
+ display: 'flex',
1046
+ });
1047
+ });
1048
+
1049
+ test('drops per-breakpoint: base class strips base only, tablet override survives', () => {
1050
+ expect(
1051
+ stripClassOverriddenStyleProps({ base: { maxWidth: '100%' }, tablet: { maxWidth: '90%' } }, 'max-w-[283px]'),
1052
+ ).toEqual({ tablet: { maxWidth: '90%' } });
1053
+ });
1054
+
1055
+ test('unresolved bindings are stripped by property key too', () => {
1056
+ const mapping = { _mapping: true as const, prop: 'maxWidth', values: { narrow: '283px' } };
1057
+ expect(stripClassOverriddenStyleProps({ maxWidth: mapping, color: 'red' }, 'max-w-[283px]')).toEqual({
1058
+ color: 'red',
1059
+ });
1060
+ });
1061
+
1062
+ test('returns the same object when nothing conflicts, null when nothing remains', () => {
1063
+ const style = { maxWidth: '100%' };
1064
+ expect(stripClassOverriddenStyleProps(style, 'p-[24px]')).toBe(style);
1065
+ expect(stripClassOverriddenStyleProps(style, 'max-w-[283px]')).toBeNull();
1066
+ expect(stripClassOverriddenStyleProps(null, 'max-w-[283px]')).toBeNull();
1067
+ });
1068
+ });
871
1069
  });
@@ -21,6 +21,10 @@ import {
21
21
  borderPresets,
22
22
  SPACING_SCALE_ROOTS,
23
23
  SPACING_SCALE_REVERSE,
24
+ FILTER_FN_ROOTS,
25
+ FILTER_ROOT_BY_PROP_FN,
26
+ filterArgToStep,
27
+ ROUNDED_SIDE_CORNERS,
24
28
  } from './utilityClassConfig';
25
29
  import {
26
30
  camelToKebab,
@@ -168,6 +172,32 @@ function propertyValueToClass(prop: string, value: string | number): string | nu
168
172
 
169
173
  let className: string | null = null;
170
174
 
175
+ // Tailwind filter functions: a filter/backdrop-filter value that is a SINGLE function call
176
+ // emits the compact functional class — `blur(70px)` → `blur-[70px]`, `brightness(0.5)` →
177
+ // `brightness-50`, `saturate(var(--x))` → `saturate-(--x)` — mirroring the parser's filter
178
+ // branch so hand-authored filter classes survive a style round-trip. Multi-function values
179
+ // (`blur(4px) brightness(.5)`) and nested-paren args have no compact spelling → arbitrary form.
180
+ // (The exact 100% switches — `grayscale(100%)` → bare `grayscale` — hit the static table above.)
181
+ if (prop === 'filter' || prop === 'backdropFilter') {
182
+ const property = camelToKebab(prop);
183
+ const varForm = stringValue.match(/^([a-z-]+)\(\s*var\((--[\w-]+)\)\s*\)$/);
184
+ const plainForm = varForm ? null : stringValue.match(/^([a-z-]+)\(([^()]+)\)$/);
185
+ const fnRoot = FILTER_ROOT_BY_PROP_FN.get(`${property}|${(varForm ?? plainForm)?.[1]}`);
186
+ if (fnRoot && varForm) {
187
+ className = `${fnRoot}-(${varForm[2]})`;
188
+ } else if (fnRoot && plainForm) {
189
+ const arg = (plainForm[2] ?? '').trim();
190
+ const scale = FILTER_FN_ROOTS[fnRoot]?.scale;
191
+ const step = scale ? filterArgToStep(scale, arg) : null;
192
+ if (step != null) {
193
+ className = `${fnRoot}-${step}`;
194
+ } else {
195
+ const encoded = encodeArbitraryValue(arg);
196
+ if (encoded) className = `${fnRoot}-[${encoded}]`;
197
+ }
198
+ }
199
+ }
200
+
171
201
  // Named keyword values: width: 100% → w-full, margin: auto → m-auto
172
202
  const kwTable = keywordValues[root];
173
203
  if (kwTable) {
@@ -554,6 +584,13 @@ export function classesToStyles(classes: string[], knownTokens?: ReadonlySet<str
554
584
  if (cleanClass.startsWith('size-') && styleEntry.prop === 'width') {
555
585
  styles[breakpoint]!.height = styleEntry.value;
556
586
  }
587
+ // Side border-radius (`rounded-t-*`) covers TWO corners with no CSS shorthand — classToStyle
588
+ // returns the first corner; mirror the value onto the second (same pattern as `size-*`).
589
+ const sideRoot = cleanClass.match(/^(rounded-[trbl])-/)?.[1];
590
+ const corners = sideRoot ? ROUNDED_SIDE_CORNERS[sideRoot] : undefined;
591
+ if (corners) {
592
+ styles[breakpoint]![cssPropertyToCamel(corners[1] ?? '')] = styleEntry.value;
593
+ }
557
594
  }
558
595
  }
559
596
 
@@ -571,6 +608,122 @@ export function classesToStyles(classes: string[], knownTokens?: ReadonlySet<str
571
608
  return hasAny ? styles : null;
572
609
  }
573
610
 
611
+ /**
612
+ * Color class root → its camelCase CSS property (the color half of `COLOR_TOKEN_ROOTS`).
613
+ * A bare color-token class (`bg-accent`, `text-muted`) can't be resolved by `classToStyle`
614
+ * without the project token set, but its PROPERTY is unambiguous from the root — enough to
615
+ * detect a conflict with another class targeting the same property.
616
+ */
617
+ const COLOR_ROOT_TO_PROP: Record<string, string> = {
618
+ text: 'color',
619
+ bg: 'backgroundColor',
620
+ border: 'borderColor',
621
+ accent: 'accentColor',
622
+ outline: 'outlineColor',
623
+ decoration: 'textDecorationColor',
624
+ caret: 'caretColor',
625
+ fill: 'fill',
626
+ stroke: 'stroke',
627
+ };
628
+
629
+ /**
630
+ * Merge key for one utility class: normalized breakpoint + the camelCase CSS property the
631
+ * class decodes to. Two classes with the same key style the same thing — only one should
632
+ * survive a merge. `null` when the class isn't a recognized utility (element `m_…` hash
633
+ * classes, foreign classes) — those never participate in conflict detection and are kept.
634
+ * Mirror of the meno-astro runtime's `classMergeKey` (runtime/style.ts), which is inlined
635
+ * there to keep the published runtime off new meno-core exports.
636
+ */
637
+ export function classMergeKey(className: string): string | null {
638
+ const { breakpoint, base } = splitVariantPrefix(className);
639
+ // Every breakpoint form shares a key — an instance's `max-lg:p-4` must override a root's
640
+ // `max-tablet:p-4` or legacy `tablet:p-4`. Normalize strips `max-` and folds the Tailwind
641
+ // alias under it; fold the bare alias too so `lg:`/`sm:` (unprefixed) also land on identity.
642
+ let bp = normalizeBreakpointVariant(breakpoint) ?? '';
643
+ if (bp === 'lg') bp = 'tablet';
644
+ else if (bp === 'sm') bp = 'mobile';
645
+ const entry = classToStyle(className);
646
+ if (entry) return `${bp}|${entry.prop}`;
647
+ // Bare color-token class — recover the property from the root so an instance's
648
+ // `bg-[var(--gray-200)]` still conflicts with a root's `bg-accent`.
649
+ const dash = base.indexOf('-');
650
+ 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}`;
653
+ }
654
+ return null;
655
+ }
656
+
657
+ /**
658
+ * Merge a component instance's class string over the root element's own classes.
659
+ * Instance wins per (breakpoint, CSS property): a root class whose merge key collides with
660
+ * an instance class is dropped, everything else (element classes, foreign classes) is kept.
661
+ * Both classes have equal specificity in the generated stylesheet, so without this the
662
+ * WINNER would be stylesheet order — i.e. class collection order, not the instance.
663
+ * Mirror of the meno-astro runtime's `mergeInstanceClasses`, so Meno Select agrees with a
664
+ * real Astro build on which value applies.
665
+ */
666
+ export function mergeInstanceClasses(own: string[], instanceClass: string): string[] {
667
+ const instance = instanceClass.split(/\s+/).filter(Boolean);
668
+ if (instance.length === 0) return own;
669
+ const overridden = new Set(instance.map(classMergeKey).filter((k): k is string => k !== null));
670
+ const kept = own.filter((cls) => {
671
+ const key = classMergeKey(cls);
672
+ return key === null || !overridden.has(key);
673
+ });
674
+ return [...kept, ...instance];
675
+ }
676
+
677
+ /**
678
+ * Drop from a root's style object every (breakpoint, property) an instance class string
679
+ * overrides. A prop-bound root style (`maxWidth: {_mapping}` → resolved `100%`) becomes a
680
+ * utility class (`max-w-full`) at render time — the class channel can't see it, so an
681
+ * instance's `max-w-[283px]` would fight it on stylesheet order. Stripping the property
682
+ * before class generation is the style-object half of `mergeInstanceClasses` (the astro
683
+ * runtime's `inlineStyle()` does the same drop for its prop-bound inline declarations).
684
+ * Pure: returns the input object untouched when nothing conflicts, a new object otherwise,
685
+ * `null` when nothing remains.
686
+ */
687
+ export function stripClassOverriddenStyleProps(
688
+ style: StyleValue | null | undefined,
689
+ instanceClass: string,
690
+ ): StyleValue | null {
691
+ if (!style || typeof style !== 'object') return null;
692
+ const overridden = new Set(
693
+ instanceClass
694
+ .split(/\s+/)
695
+ .filter(Boolean)
696
+ .map(classMergeKey)
697
+ .filter((k): k is string => k !== null),
698
+ );
699
+ if (overridden.size === 0) return style;
700
+
701
+ const stripBucket = (bucket: StyleObject, bp: string): StyleObject => {
702
+ const out: StyleObject = {};
703
+ for (const [prop, value] of Object.entries(bucket)) {
704
+ if (!overridden.has(`${bp}|${prop}`)) out[prop] = value as StyleObject[string];
705
+ }
706
+ return out;
707
+ };
708
+
709
+ if (isResponsiveStyle(style)) {
710
+ const out: ResponsiveStyleObject = {};
711
+ let changed = false;
712
+ for (const [bpName, bucket] of Object.entries(style)) {
713
+ if (!bucket || typeof bucket !== 'object') continue;
714
+ const stripped = stripBucket(bucket as StyleObject, bpName === 'base' ? '' : bpName);
715
+ if (Object.keys(stripped).length !== Object.keys(bucket).length) changed = true;
716
+ if (Object.keys(stripped).length > 0) out[bpName] = stripped;
717
+ }
718
+ if (!changed) return style;
719
+ return Object.keys(out).length > 0 ? out : null;
720
+ }
721
+
722
+ const flat = stripBucket(style as StyleObject, '');
723
+ if (Object.keys(flat).length === Object.keys(style as StyleObject).length) return style;
724
+ return Object.keys(flat).length > 0 ? flat : null;
725
+ }
726
+
574
727
  /**
575
728
  * Whether a single style value can be stored as a STATIC utility class. Static strings (no
576
729
  * `{{template}}`) and numbers can; template-bearing strings and `_mapping` objects cannot — a
@@ -31,6 +31,8 @@ import {
31
31
  EASE_TIMING,
32
32
  TRANSITION_PROPERTY_VALUES,
33
33
  TRANSITION_DEFAULT_TIMING,
34
+ FILTER_FN_ROOTS,
35
+ filterStepToArg,
34
36
  } from './utilityClassConfig';
35
37
  import { isCssNamedColor } from './cssNamedColors';
36
38
  import { THEME_SCALE_BY_CLASS, themeScaleValue } from './tailwindThemeScale';
@@ -252,6 +254,10 @@ export function resolveRootProperty(root: string, value: string): string {
252
254
  if (isLengthValue(value)) return 'outline-width';
253
255
  return 'outline';
254
256
  }
257
+ case 'decoration':
258
+ return isLengthValue(value) || value === 'from-font' ? 'text-decoration-thickness' : 'text-decoration-color';
259
+ case 'stroke':
260
+ return isColorValue(value) ? 'stroke' : 'stroke-width';
255
261
  default:
256
262
  return prefixToCSSProperty[root] ?? root;
257
263
  }
@@ -521,6 +527,62 @@ export function parseUtilityClass(className: string, knownTokens?: ReadonlySet<s
521
527
  }
522
528
 
523
529
  function parseRootValue(root: string, rest: string, knownTokens?: ReadonlySet<string>): ParsedUtilityClass | null {
530
+ // Tailwind filter functions: the class value is an ARGUMENT to the function, not the property
531
+ // value itself — `blur-[70px]` → filter: blur(70px), `brightness-50` → filter: brightness(0.5),
532
+ // `backdrop-saturate-(--x)` → backdrop-filter: saturate(var(--x)). Must run before the generic
533
+ // arbitrary/var branches, which would assign the bare argument to filter (invalid CSS). Numeric
534
+ // steps follow Tailwind's per-function scale (see FILTER_FN_ROOTS); the length/shadow named
535
+ // scales (`blur-sm`, `drop-shadow-md`) are deliberately not absorbed — use the arbitrary form.
536
+ const filterFn = FILTER_FN_ROOTS[root];
537
+ if (filterFn) {
538
+ const { property, fn, scale } = filterFn;
539
+ if (rest.startsWith('[') && rest.endsWith(']') && rest.length > 2) {
540
+ const arg = decodeArbitraryValue(rest.slice(1, -1));
541
+ return { property, value: `${fn}(${arg})`, root, kind: 'arbitrary' };
542
+ }
543
+ const varArg = rest.match(/^\((--[\w-]+)\)$/);
544
+ if (varArg) {
545
+ return { property, value: `${fn}(var(${varArg[1]}))`, root, kind: 'var' };
546
+ }
547
+ if (scale && /^\d+(\.\d+)?$/.test(rest)) {
548
+ return { property, value: `${fn}(${filterStepToArg(scale, rest)})`, root, kind: 'numeric' };
549
+ }
550
+ return null;
551
+ }
552
+
553
+ // Per-axis skew (`skew-x-[10deg]`, `skew-y-3`): CSS has no individual skew property, so the
554
+ // value is wrapped into transform's skewX()/skewY(). Like Tailwind's numeric scale, a bare
555
+ // number is degrees. NB two skew classes on one element clobber each other (last `transform`
556
+ // wins) — same single-property limitation as translate-x/translate-y above.
557
+ if (root === 'skew-x' || root === 'skew-y') {
558
+ const fn = root === 'skew-x' ? 'skewX' : 'skewY';
559
+ if (rest.startsWith('[') && rest.endsWith(']') && rest.length > 2) {
560
+ return {
561
+ property: 'transform',
562
+ value: `${fn}(${decodeArbitraryValue(rest.slice(1, -1))})`,
563
+ root,
564
+ kind: 'arbitrary',
565
+ };
566
+ }
567
+ if (/^\d+(\.\d+)?$/.test(rest)) {
568
+ return { property: 'transform', value: `${fn}(${rest}deg)`, root, kind: 'numeric' };
569
+ }
570
+ return null;
571
+ }
572
+
573
+ // Per-axis scale (`scale-x-[1.2]`, `scale-y-95`): composed into the two-value `scale`
574
+ // property with the other axis at 1. Numeric steps are Tailwind's percentage-of-100.
575
+ if (root === 'scale-x' || root === 'scale-y') {
576
+ const pair = (v: string) => (root === 'scale-x' ? `${v} 1` : `1 ${v}`);
577
+ if (rest.startsWith('[') && rest.endsWith(']') && rest.length > 2) {
578
+ return { property: 'scale', value: pair(decodeArbitraryValue(rest.slice(1, -1))), root, kind: 'arbitrary' };
579
+ }
580
+ if (/^\d+$/.test(rest)) {
581
+ return { property: 'scale', value: pair(String(Number(rest) / 100)), root, kind: 'numeric' };
582
+ }
583
+ return null;
584
+ }
585
+
524
586
  // Arbitrary value: root-[value]
525
587
  if (rest.startsWith('[') && rest.endsWith(']') && rest.length > 2) {
526
588
  const value = decodeArbitraryValue(rest.slice(1, -1));
@@ -546,6 +608,22 @@ function parseRootValue(root: string, rest: string, knownTokens?: ReadonlySet<st
546
608
  return { property, value, root, kind: 'var' };
547
609
  }
548
610
 
611
+ // Side/corner border-radius NAMED forms (`rounded-t-lg`, `rounded-tr-xl`, `rounded-l-full`):
612
+ // resolve the value exactly like the plain `rounded` root — theme scale → var(--radius-lg),
613
+ // keywords → px. Bracket/var forms are already handled by the generic branches above (the
614
+ // prefix map carries the property); the CSS generator expands side roots to both corners.
615
+ if (root.startsWith('rounded-') && prefixToCSSProperty[root]) {
616
+ const themed = THEME_SCALE_BY_CLASS.get(`rounded-${rest}`);
617
+ if (themed) {
618
+ return { property: prefixToCSSProperty[root] ?? root, value: themeScaleValue(themed), root, kind: 'var' };
619
+ }
620
+ const kw = keywordValues[root]?.[rest] ?? keywordValues.rounded?.[rest];
621
+ if (kw !== undefined) {
622
+ return { property: prefixToCSSProperty[root] ?? root, value: kw, root, kind: 'keyword' };
623
+ }
624
+ return null;
625
+ }
626
+
549
627
  // Border width/style family (`border-2`, `border-b`, `border-t-4`, `border-solid`, …).
550
628
  // Placed after the arbitrary `[..]` and var `(--x)` escapes so those win; colors
551
629
  // (`border-primary`) return null here and fall through to the color-token branch below.
@@ -614,6 +692,18 @@ function parseRootValue(root: string, rest: string, knownTokens?: ReadonlySet<st
614
692
  if ((root === 'duration' || root === 'delay') && /^\d+$/.test(rest)) {
615
693
  return { property: prefixToCSSProperty[root] ?? root, value: `${rest}ms`, root, kind: 'numeric' };
616
694
  }
695
+ // Numeric px widths/offsets: `outline-1` → outline-width 1px, `underline-offset-2` → 2px,
696
+ // `decoration-2` → text-decoration-thickness 2px. (`stroke-2` stays unitless — SVG stroke-width.)
697
+ if ((root === 'outline' || root === 'outline-offset' || root === 'underline-offset') && /^\d+$/.test(rest)) {
698
+ const property = root === 'outline' ? 'outline-width' : (prefixToCSSProperty[root] ?? root);
699
+ return { property, value: `${rest}px`, root, kind: 'numeric' };
700
+ }
701
+ if (root === 'decoration' && /^\d+$/.test(rest)) {
702
+ return { property: 'text-decoration-thickness', value: `${rest}px`, root, kind: 'numeric' };
703
+ }
704
+ if (root === 'stroke' && /^\d+$/.test(rest)) {
705
+ return { property: 'stroke-width', value: rest, root, kind: 'numeric' };
706
+ }
617
707
  // `ease-in-out` → transition-timing-function: cubic-bezier(…).
618
708
  if (root === 'ease') {
619
709
  const timing = EASE_TIMING[rest];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "meno-core",
3
- "version": "1.1.4",
3
+ "version": "1.1.6",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "meno": "./dist/bin/cli.js"