meno-core 1.1.0 → 1.1.2

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 (34) hide show
  1. package/dist/chunks/{chunk-NUP7H7D3.js → chunk-7ZLF4NE5.js} +2 -2
  2. package/dist/chunks/{chunk-QWTQZHG3.js → chunk-FQBIC2OB.js} +1 -1
  3. package/dist/chunks/chunk-FQBIC2OB.js.map +7 -0
  4. package/dist/chunks/{chunk-2IIQK7T3.js → chunk-J4IPTP5X.js} +393 -3
  5. package/dist/chunks/{chunk-2IIQK7T3.js.map → chunk-J4IPTP5X.js.map} +3 -3
  6. package/dist/lib/client/index.js +2 -2
  7. package/dist/lib/server/index.js +804 -218
  8. package/dist/lib/server/index.js.map +4 -4
  9. package/dist/lib/shared/index.js +6 -2
  10. package/dist/lib/shared/index.js.map +2 -2
  11. package/lib/server/index.ts +19 -0
  12. package/lib/server/services/ColorService.ts +38 -7
  13. package/lib/server/services/VariableService.ts +34 -5
  14. package/lib/server/ssr/htmlGenerator.ts +18 -2
  15. package/lib/server/ssr/ssrRenderer.test.ts +25 -0
  16. package/lib/server/ssr/ssrRenderer.ts +12 -2
  17. package/lib/server/themeCssCodec.test.ts +154 -0
  18. package/lib/server/themeCssCodec.ts +488 -0
  19. package/lib/server/themeCssStore.test.ts +139 -0
  20. package/lib/server/themeCssStore.ts +210 -0
  21. package/lib/shared/cssGeneration.test.ts +10 -0
  22. package/lib/shared/cssGeneration.ts +10 -8
  23. package/lib/shared/index.ts +6 -0
  24. package/lib/shared/interactiveStyles.test.ts +3 -1
  25. package/lib/shared/tailwindThemeScale.ts +256 -0
  26. package/lib/shared/types/colors.ts +6 -2
  27. package/lib/shared/types/variables.ts +14 -7
  28. package/lib/shared/utilityClassConfig.ts +116 -0
  29. package/lib/shared/utilityClassMapper.test.ts +148 -0
  30. package/lib/shared/utilityClassMapper.ts +33 -0
  31. package/lib/shared/utilityClassNames.ts +146 -0
  32. package/package.json +1 -1
  33. package/dist/chunks/chunk-QWTQZHG3.js.map +0 -7
  34. /package/dist/chunks/{chunk-NUP7H7D3.js.map → chunk-7ZLF4NE5.js.map} +0 -0
@@ -94,6 +94,9 @@ export const propertyOrder: string[] = [
94
94
  // Transform & Effects
95
95
  'opacity',
96
96
  'transform',
97
+ 'translate',
98
+ 'rotate',
99
+ 'scale',
97
100
  'transformOrigin',
98
101
  'boxShadow',
99
102
  'textShadow',
@@ -109,6 +112,8 @@ export const propertyOrder: string[] = [
109
112
  'bottom',
110
113
  'left',
111
114
  'inset',
115
+ 'insetInline',
116
+ 'insetBlock',
112
117
  'zIndex',
113
118
  // Grid Layout
114
119
  'gridTemplateColumns',
@@ -117,7 +122,11 @@ export const propertyOrder: string[] = [
117
122
  'gridGap',
118
123
  'gridAutoFlow',
119
124
  'gridColumn',
125
+ 'gridColumnStart',
126
+ 'gridColumnEnd',
120
127
  'gridRow',
128
+ 'gridRowStart',
129
+ 'gridRowEnd',
121
130
  'gridArea',
122
131
  'gridAutoRows',
123
132
  'gridAutoColumns',
@@ -146,6 +155,10 @@ export const propertyOrder: string[] = [
146
155
  'pointerEvents',
147
156
  'userSelect',
148
157
  'transition',
158
+ 'transitionProperty',
159
+ 'transitionDuration',
160
+ 'transitionTimingFunction',
161
+ 'transitionDelay',
149
162
  'objectFit',
150
163
  'objectPosition',
151
164
  'boxSizing',
@@ -308,6 +321,7 @@ export const prefixToCSSProperty: Record<string, string> = {
308
321
  // Sizing
309
322
  w: 'width',
310
323
  h: 'height',
324
+ size: 'width', // multi-prop (width + height) — expanded in generateRuleForClass / classesToStyles
311
325
  'max-w': 'max-width',
312
326
  'max-h': 'max-height',
313
327
  'min-w': 'min-width',
@@ -352,14 +366,28 @@ export const prefixToCSSProperty: Record<string, string> = {
352
366
  'grid-cols': 'grid-template-columns',
353
367
  'grid-rows': 'grid-template-rows',
354
368
  col: 'grid-column',
369
+ 'col-span': 'grid-column',
370
+ 'col-start': 'grid-column-start',
371
+ 'col-end': 'grid-column-end',
355
372
  row: 'grid-row',
373
+ 'row-span': 'grid-row',
374
+ 'row-start': 'grid-row-start',
375
+ 'row-end': 'grid-row-end',
356
376
  'auto-rows': 'grid-auto-rows',
357
377
  'auto-cols': 'grid-auto-columns',
358
378
 
359
379
  // Effects
360
380
  opacity: 'opacity',
361
381
  transform: 'transform',
382
+ scale: 'scale',
383
+ rotate: 'rotate',
384
+ 'translate-x': 'translate',
385
+ 'translate-y': 'translate',
362
386
  origin: 'transform-origin',
387
+ // Transitions (each maps to a distinct longhand → compose across classes)
388
+ duration: 'transition-duration',
389
+ delay: 'transition-delay',
390
+ ease: 'transition-timing-function',
363
391
  shadow: 'box-shadow',
364
392
  'text-shadow': 'text-shadow',
365
393
  filter: 'filter',
@@ -371,6 +399,8 @@ export const prefixToCSSProperty: Record<string, string> = {
371
399
  bottom: 'bottom',
372
400
  left: 'left',
373
401
  inset: 'inset',
402
+ 'inset-x': 'inset-inline',
403
+ 'inset-y': 'inset-block',
374
404
  z: 'z-index',
375
405
 
376
406
  // Outline
@@ -757,8 +787,11 @@ export const SPACING_SCALE_ROOTS: Set<string> = new Set([
757
787
  'bottom',
758
788
  'left',
759
789
  'inset',
790
+ 'inset-x',
791
+ 'inset-y',
760
792
  'w',
761
793
  'h',
794
+ 'size',
762
795
  'min-w',
763
796
  'min-h',
764
797
  'max-w',
@@ -796,6 +829,7 @@ export const keywordValues: Record<string, Record<string, string>> = {
796
829
  h: { auto: 'auto', full: '100%', screen: '100vh', fit: 'fit-content', min: 'min-content', max: 'max-content' },
797
830
  'max-w': { full: '100%', none: 'none', screen: '100vw', fit: 'fit-content', min: 'min-content', max: 'max-content' },
798
831
  'max-h': { full: '100%', none: 'none', screen: '100vh', fit: 'fit-content', min: 'min-content', max: 'max-content' },
832
+ size: { auto: 'auto', full: '100%', fit: 'fit-content', min: 'min-content', max: 'max-content' },
799
833
  'min-w': { auto: 'auto', full: '100%', fit: 'fit-content', min: 'min-content', max: 'max-content' },
800
834
  'min-h': { auto: 'auto', full: '100%', screen: '100vh', fit: 'fit-content', min: 'min-content', max: 'max-content' },
801
835
  m: { auto: 'auto' },
@@ -819,6 +853,10 @@ export const keywordValues: Record<string, Record<string, string>> = {
819
853
  z: { auto: 'auto' },
820
854
  leading: { normal: 'normal' },
821
855
  tracking: { normal: 'normal' },
856
+ 'inset-x': { auto: 'auto', full: '100%' },
857
+ 'inset-y': { auto: 'auto', full: '100%' },
858
+ aspect: { auto: 'auto', square: '1 / 1', video: '16 / 9' },
859
+ order: { first: '-9999', last: '9999', none: '0' },
822
860
  };
823
861
 
824
862
  /**
@@ -846,6 +884,84 @@ export const colorTokenProps: Set<string> = new Set([
846
884
  'outlineColor',
847
885
  ]);
848
886
 
887
+ /**
888
+ * Roots that accept Tailwind fractional values (`w-1/2` → `50%`, `top-1/3` → `33.333333%`).
889
+ * Tailwind resolves `n/d` to `calc(n / d * 100%)` (pre-computed here to a plain percentage so
890
+ * the value round-trips and renders without a calc). Sizing, position/inset, flex-basis and the
891
+ * individual translate axes take fractions.
892
+ */
893
+ export const FRACTION_ROOTS: Set<string> = new Set([
894
+ 'w',
895
+ 'h',
896
+ 'size',
897
+ 'min-w',
898
+ 'min-h',
899
+ 'max-w',
900
+ 'max-h',
901
+ 'basis',
902
+ 'top',
903
+ 'right',
904
+ 'bottom',
905
+ 'left',
906
+ 'inset',
907
+ 'inset-x',
908
+ 'inset-y',
909
+ 'translate-x',
910
+ 'translate-y',
911
+ ]);
912
+
913
+ /**
914
+ * Class roots whose value is negatable with a leading `-` (`-mt-4`, `-top-2`, `-translate-x-1/2`,
915
+ * `-rotate-3`). Mirrors Tailwind: margins, position/inset, translate and rotate. The inverse parser
916
+ * strips the `-`, resolves the positive class, then negates the numeric/length/percentage value.
917
+ */
918
+ export const NEGATABLE_ROOTS: Set<string> = new Set([
919
+ 'm',
920
+ 'mt',
921
+ 'mr',
922
+ 'mb',
923
+ 'ml',
924
+ 'mx',
925
+ 'my',
926
+ 'top',
927
+ 'right',
928
+ 'bottom',
929
+ 'left',
930
+ 'inset',
931
+ 'inset-x',
932
+ 'inset-y',
933
+ 'translate-x',
934
+ 'translate-y',
935
+ 'rotate',
936
+ ]);
937
+
938
+ /** Tailwind `ease-*` → `transition-timing-function` value. */
939
+ export const EASE_TIMING: Record<string, string> = {
940
+ linear: 'linear',
941
+ in: 'cubic-bezier(0.4, 0, 1, 1)',
942
+ out: 'cubic-bezier(0, 0, 0.2, 1)',
943
+ 'in-out': 'cubic-bezier(0.4, 0, 0.2, 1)',
944
+ initial: 'initial',
945
+ };
946
+
947
+ /**
948
+ * Tailwind transition-enable classes → the `transition-property` list they animate. Emitted as the
949
+ * `transition` shorthand with Tailwind's default 150ms / ease curve baked in, so a bare `transition`
950
+ * animates standalone; a following `duration-*` / `ease-*` longhand still overrides duration/timing
951
+ * (shorthand sorts before longhand). The empty key is the bare `transition` class.
952
+ */
953
+ export const TRANSITION_PROPERTY_VALUES: Record<string, string> = {
954
+ '': 'color, background-color, border-color, text-decoration-color, fill, stroke, opacity, box-shadow, transform, filter, backdrop-filter',
955
+ all: 'all',
956
+ colors: 'color, background-color, border-color, text-decoration-color, fill, stroke',
957
+ opacity: 'opacity',
958
+ shadow: 'box-shadow',
959
+ transform: 'transform, translate, scale, rotate',
960
+ };
961
+
962
+ /** Tailwind's default transition timing baked into the `transition` shorthand. */
963
+ export const TRANSITION_DEFAULT_TIMING = '150ms cubic-bezier(0.4, 0, 0.2, 1)';
964
+
849
965
  // ============================================================================
850
966
  // STYLE PRESETS
851
967
  // Predefined values for complex CSS properties (shadows, gradients, borders)
@@ -9,6 +9,7 @@ import {
9
9
  styleHasMapping,
10
10
  } from './utilityClassMapper';
11
11
  import { getStyleValue, clearRegistry } from './styleValueRegistry';
12
+ import { defaultTailwindThemeVariables } from './tailwindThemeScale';
12
13
  import type { ResponsiveScales } from './responsiveScaling';
13
14
 
14
15
  describe('utilityClassMapper', () => {
@@ -143,6 +144,19 @@ describe('utilityClassMapper', () => {
143
144
  expect(classes).toContain('grid-cols-[repeat(3,_1fr)]');
144
145
  });
145
146
 
147
+ test('emits compact grid-cols-N for the canonical Tailwind track scale', () => {
148
+ expect(stylesToClasses({ gridTemplateColumns: 'repeat(3, minmax(0, 1fr))' })).toContain('grid-cols-3');
149
+ expect(stylesToClasses({ gridTemplateRows: 'repeat(2, minmax(0, 1fr))' })).toContain('grid-rows-2');
150
+ expect(stylesToClasses({ gridTemplateColumns: 'none' })).toContain('grid-cols-none');
151
+ expect(stylesToClasses({ gridTemplateColumns: 'subgrid' })).toContain('grid-cols-subgrid');
152
+ });
153
+
154
+ test('keeps non-canonical grid track values as arbitrary classes', () => {
155
+ // No compact Tailwind spelling → stays arbitrary (preserves prior behavior).
156
+ expect(stylesToClasses({ gridTemplateColumns: '1fr 1fr 1fr' })).toContain('grid-cols-[1fr_1fr_1fr]');
157
+ expect(stylesToClasses({ gridTemplateColumns: 'repeat(3, 1fr)' })).toContain('grid-cols-[repeat(3,_1fr)]');
158
+ });
159
+
146
160
  test('handles z-index Tailwind-style bare number', () => {
147
161
  const classes = stylesToClasses({ zIndex: 10 });
148
162
  expect(classes).toContain('z-10');
@@ -181,6 +195,140 @@ describe('utilityClassMapper', () => {
181
195
  expect(evenly).not.toContain('justify-end');
182
196
  expect(classToStyle('justify-evenly')).toEqual({ prop: 'justifyContent', value: 'space-evenly' });
183
197
  });
198
+
199
+ test('parses the Tailwind numeric grid scale (read side)', () => {
200
+ expect(classToStyle('grid-cols-3')).toEqual({
201
+ prop: 'gridTemplateColumns',
202
+ value: 'repeat(3, minmax(0, 1fr))',
203
+ });
204
+ expect(classToStyle('grid-rows-2')).toEqual({ prop: 'gridTemplateRows', value: 'repeat(2, minmax(0, 1fr))' });
205
+ expect(classToStyle('grid-cols-none')).toEqual({ prop: 'gridTemplateColumns', value: 'none' });
206
+ // Tailwind defines no `grid-cols-0` → left unrecognized.
207
+ expect(classToStyle('grid-cols-0')).toBeNull();
208
+ });
209
+
210
+ test('a responsive AI-authored grid round-trips through the breakpoint variants', () => {
211
+ const styles = classesToStyles(['grid-cols-3', 'tablet:grid-cols-2', 'mobile:grid-cols-1']);
212
+ expect(styles).toEqual({
213
+ base: { gridTemplateColumns: 'repeat(3, minmax(0, 1fr))' },
214
+ tablet: { gridTemplateColumns: 'repeat(2, minmax(0, 1fr))' },
215
+ mobile: { gridTemplateColumns: 'repeat(1, minmax(0, 1fr))' },
216
+ });
217
+ });
218
+ });
219
+
220
+ describe('Tailwind layout/transform extensions', () => {
221
+ test('fractional sizing → percentage', () => {
222
+ expect(classToStyle('w-1/2')).toEqual({ prop: 'width', value: '50%' });
223
+ expect(classToStyle('w-1/3')).toEqual({ prop: 'width', value: '33.333333%' });
224
+ expect(classToStyle('basis-1/2')).toEqual({ prop: 'flexBasis', value: '50%' });
225
+ expect(classToStyle('left-1/2')).toEqual({ prop: 'left', value: '50%' });
226
+ });
227
+
228
+ test('negative spacing/position via the leading-dash form', () => {
229
+ expect(classToStyle('-mt-4')).toEqual({ prop: 'marginTop', value: '-16px' });
230
+ expect(classToStyle('-top-4')).toEqual({ prop: 'top', value: '-16px' });
231
+ expect(classToStyle('-translate-y-1/2')).toEqual({ prop: 'translate', value: '0 -50%' });
232
+ // Non-negatable root → unrecognized rather than mis-parsed.
233
+ expect(classToStyle('-flex')).toBeNull();
234
+ });
235
+
236
+ test('grid placement (span / start / end)', () => {
237
+ expect(classToStyle('col-span-2')).toEqual({ prop: 'gridColumn', value: 'span 2 / span 2' });
238
+ expect(classToStyle('col-span-full')).toEqual({ prop: 'gridColumn', value: '1 / -1' });
239
+ expect(classToStyle('row-span-3')).toEqual({ prop: 'gridRow', value: 'span 3 / span 3' });
240
+ expect(classToStyle('col-start-2')).toEqual({ prop: 'gridColumnStart', value: '2' });
241
+ expect(classToStyle('row-end-4')).toEqual({ prop: 'gridRowEnd', value: '4' });
242
+ });
243
+
244
+ test('inset axes use logical properties', () => {
245
+ expect(classToStyle('inset-x-0')).toEqual({ prop: 'insetInline', value: '0px' });
246
+ expect(classToStyle('inset-y-4')).toEqual({ prop: 'insetBlock', value: '16px' });
247
+ expect(classToStyle('inset-x-auto')).toEqual({ prop: 'insetInline', value: 'auto' });
248
+ });
249
+
250
+ test('order keywords, aspect keywords', () => {
251
+ expect(classToStyle('order-first')).toEqual({ prop: 'order', value: '-9999' });
252
+ expect(classToStyle('order-last')).toEqual({ prop: 'order', value: '9999' });
253
+ expect(classToStyle('aspect-video')).toEqual({ prop: 'aspectRatio', value: '16 / 9' });
254
+ expect(classToStyle('aspect-square')).toEqual({ prop: 'aspectRatio', value: '1 / 1' });
255
+ });
256
+
257
+ test('size-* sets width and height and restores both on round-trip', () => {
258
+ expect(classesToStyles(['size-8'])).toEqual({ base: { width: '32px', height: '32px' } });
259
+ expect(classesToStyles(['size-full'])).toEqual({ base: { width: '100%', height: '100%' } });
260
+ // a plain w-* must NOT pull in height
261
+ expect(classesToStyles(['w-8'])).toEqual({ base: { width: '32px' } });
262
+ });
263
+
264
+ test('numeric line-height on the spacing scale', () => {
265
+ expect(classToStyle('leading-6')).toEqual({ prop: 'lineHeight', value: '24px' });
266
+ });
267
+
268
+ test('individual transforms', () => {
269
+ expect(classToStyle('scale-105')).toEqual({ prop: 'scale', value: '1.05' });
270
+ expect(classToStyle('rotate-45')).toEqual({ prop: 'rotate', value: '45deg' });
271
+ expect(classToStyle('-rotate-3')).toEqual({ prop: 'rotate', value: '-3deg' });
272
+ expect(classToStyle('translate-x-2')).toEqual({ prop: 'translate', value: '8px' });
273
+ expect(classToStyle('translate-y-4')).toEqual({ prop: 'translate', value: '0 16px' });
274
+ });
275
+
276
+ test('transition longhands compose; duration/ease override the shorthand', () => {
277
+ expect(classToStyle('duration-300')).toEqual({ prop: 'transitionDuration', value: '300ms' });
278
+ expect(classToStyle('delay-150')).toEqual({ prop: 'transitionDelay', value: '150ms' });
279
+ expect(classToStyle('ease-in-out')).toEqual({
280
+ prop: 'transitionTimingFunction',
281
+ value: 'cubic-bezier(0.4, 0, 0.2, 1)',
282
+ });
283
+ // `transition-all` carries the default timing so it animates standalone.
284
+ expect(classToStyle('transition-all')).toEqual({
285
+ prop: 'transition',
286
+ value: 'all 150ms cubic-bezier(0.4, 0, 0.2, 1)',
287
+ });
288
+ });
289
+ });
290
+
291
+ describe('Tailwind named scales → var(--token)', () => {
292
+ test('named scales resolve to a plain variable reference (no baked default)', () => {
293
+ expect(classToStyle('text-lg')).toEqual({ prop: 'fontSize', value: 'var(--text-lg)' });
294
+ expect(classToStyle('font-semibold')).toEqual({ prop: 'fontWeight', value: 'var(--font-weight-semibold)' });
295
+ expect(classToStyle('rounded-md')).toEqual({ prop: 'borderRadius', value: 'var(--radius-md)' });
296
+ expect(classToStyle('max-w-md')).toEqual({ prop: 'maxWidth', value: 'var(--container-md)' });
297
+ expect(classToStyle('leading-tight')).toEqual({ prop: 'lineHeight', value: 'var(--leading-tight)' });
298
+ // bare `rounded` / `shadow` (no suffix) map to the DEFAULT token.
299
+ expect(classToStyle('rounded')).toEqual({ prop: 'borderRadius', value: 'var(--radius)' });
300
+ });
301
+
302
+ test('existing static/keyword/preset meanings are preserved (not turned into vars)', () => {
303
+ expect(classToStyle('text-center')).toEqual({ prop: 'textAlign', value: 'center' });
304
+ expect(classToStyle('font-bold')).toEqual({ prop: 'fontWeight', value: 'bold' });
305
+ expect(classToStyle('rounded-full')).toEqual({ prop: 'borderRadius', value: '9999px' });
306
+ expect(classToStyle('shadow-none')).toEqual({ prop: 'boxShadow', value: 'none' });
307
+ });
308
+
309
+ test('round-trips to the compact class; legacy fallback form still recognized', () => {
310
+ expect(stylesToClasses({ fontSize: 'var(--text-lg)' })).toEqual(['text-lg']);
311
+ expect(stylesToClasses({ boxShadow: 'var(--shadow-lg)' })).toEqual(['shadow-lg']);
312
+ // A legacy `var(--token, fallback)` value (e.g. authored by hand) still collapses to the class.
313
+ expect(stylesToClasses({ fontSize: 'var(--text-lg, 1.125rem)' })).toEqual(['text-lg']);
314
+ // A var() the table doesn't know stays a normal var-shorthand class, not a theme token.
315
+ expect(stylesToClasses({ fontSize: 'var(--my-custom)' })).toEqual(['text-(length:--my-custom)']);
316
+ });
317
+
318
+ test('seed-variable generator yields unique, grouped CSSVariables', () => {
319
+ const vars = defaultTailwindThemeVariables();
320
+ expect(vars.length).toBeGreaterThan(40);
321
+ const cssVars = vars.map((v) => v.cssVar);
322
+ expect(new Set(cssVars).size).toBe(cssVars.length); // no duplicate cssVars
323
+ expect(vars.find((v) => v.cssVar === '--text-lg')).toEqual({
324
+ name: 'Text LG',
325
+ cssVar: '--text-lg',
326
+ value: '1.125rem',
327
+ type: 'fontSize',
328
+ group: 'font-size',
329
+ });
330
+ expect(vars.find((v) => v.cssVar === '--font-weight-semibold')?.name).toBe('Font Weight Semibold');
331
+ });
184
332
  });
185
333
 
186
334
  describe('responsiveStylesToClasses', () => {
@@ -35,6 +35,7 @@ import {
35
35
  isBareColorTokenName,
36
36
  } from './utilityClassNames';
37
37
  import { registerStyleValue, registerDynamicStyle, getStyleValue, getDynamicStyle } from './styleValueRegistry';
38
+ import { THEME_SCALE_BY_CSSVAR } from './tailwindThemeScale';
38
39
  import {
39
40
  SCALABLE_CSS_PROPERTIES,
40
41
  buildFluidClampWithExplicitMin,
@@ -99,6 +100,19 @@ function rootFormMisresolves(root: string, prop: string, value: string): boolean
99
100
  function propertyValueToClass(prop: string, value: string | number): string | null {
100
101
  let stringValue = String(value);
101
102
 
103
+ // Tailwind theme token: a `var(--token)` value (or a legacy `var(--token, …)` with fallback)
104
+ // whose token is a known Tailwind scale var (and whose property matches) emits the compact class —
105
+ // `var(--text-lg)` → `text-lg`. Runs before the fontFamily comma-split below (which would shred a
106
+ // multi-family `var()` fallback). The parser reconstructs the value from the class, so no registry
107
+ // entry is needed.
108
+ const varRef = stringValue.match(/^var\((--[\w-]+)(?:\s*,[\s\S]*)?\)$/);
109
+ if (varRef) {
110
+ const entry = THEME_SCALE_BY_CSSVAR.get(varRef[1] ?? '');
111
+ if (entry && entry.property === camelToKebab(prop)) {
112
+ return entry.className;
113
+ }
114
+ }
115
+
102
116
  // Strip outer quotes from fontFamily values (e.g. "Space Grotesk" → Space Grotesk)
103
117
  if (prop === 'fontFamily') {
104
118
  stringValue = stringValue
@@ -194,6 +208,20 @@ function propertyValueToClass(prop: string, value: string | number): string | nu
194
208
  }
195
209
  }
196
210
 
211
+ // Tailwind grid track scale: gridTemplateColumns "repeat(3, minmax(0, 1fr))" → grid-cols-3
212
+ // (and gridTemplateRows → grid-rows-N, plus the none/subgrid keywords). Keeps grid-cols-N that
213
+ // came in from hand/AI-authored class strings round-tripping to the compact form rather than
214
+ // expanding to grid-cols-[repeat(3,_minmax(0,_1fr))]. Other track values ("1fr 1fr 1fr",
215
+ // "repeat(3, 1fr)") have no compact Tailwind spelling and stay arbitrary.
216
+ if (!className && (root === 'grid-cols' || root === 'grid-rows')) {
217
+ const repeatMatch = stringValue.match(/^repeat\(\s*([1-9]\d*)\s*,\s*minmax\(\s*0\s*,\s*1fr\s*\)\s*\)$/);
218
+ if (repeatMatch) {
219
+ className = `${root}-${repeatMatch[1]}`;
220
+ } else if (stringValue === 'none' || stringValue === 'subgrid') {
221
+ className = `${root}-${stringValue}`;
222
+ }
223
+ }
224
+
197
225
  // CSS variables: var(--x) → root-(--x). The `font-*` root is shared by font-weight and
198
226
  // font-family and is disambiguated by value type — but a `var(--x)` value is ambiguous, so
199
227
  // weight/size vars carry a Tailwind data-type hint (`number:` / `length:`) the parser reads
@@ -513,6 +541,11 @@ export function classesToStyles(classes: string[], knownTokens?: ReadonlySet<str
513
541
  styles[breakpoint] = {};
514
542
  }
515
543
  styles[breakpoint]![styleEntry.prop] = styleEntry.value;
544
+ // `size-*` is width AND height (no CSS shorthand) — restore both so the editor doesn't
545
+ // drop height on the next save. classToStyle returns the width half; mirror it to height.
546
+ if (cleanClass.startsWith('size-') && styleEntry.prop === 'width') {
547
+ styles[breakpoint]!.height = styleEntry.value;
548
+ }
516
549
  }
517
550
  }
518
551
 
@@ -26,8 +26,14 @@ import {
26
26
  SPACING_SCALE,
27
27
  SPACING_SCALE_ROOTS,
28
28
  COLOR_TOKEN_ROOTS,
29
+ FRACTION_ROOTS,
30
+ NEGATABLE_ROOTS,
31
+ EASE_TIMING,
32
+ TRANSITION_PROPERTY_VALUES,
33
+ TRANSITION_DEFAULT_TIMING,
29
34
  } from './utilityClassConfig';
30
35
  import { isCssNamedColor } from './cssNamedColors';
36
+ import { THEME_SCALE_BY_CLASS, themeScaleValue } from './tailwindThemeScale';
31
37
 
32
38
  /** Class roots sorted longest-first so `border-t-…` wins over `border-…`. */
33
39
  export const SORTED_ROOTS: readonly string[] = Object.keys(prefixToCSSProperty).sort((a, b) => b.length - a.length);
@@ -341,6 +347,37 @@ export interface ParsedUtilityClass {
341
347
 
342
348
  const HASH_VALUE_RE = /^h[0-9a-z]+$/;
343
349
 
350
+ /** Tailwind fraction → percentage (`1/2` → `50%`, `1/3` → `33.333333%`). Null when not `n/d`. */
351
+ function resolveFraction(rest: string): string | null {
352
+ const m = rest.match(/^(\d+)\/(\d+)$/);
353
+ if (!m) return null;
354
+ const d = Number(m[2]);
355
+ if (d === 0) return null;
356
+ const pct = (Number(m[1]) / d) * 100;
357
+ return `${pct.toFixed(6).replace(/\.?0+$/, '')}%`;
358
+ }
359
+
360
+ /** Resolve a translate axis value: fraction, `full`, or a spacing-scale step. Null otherwise. */
361
+ function resolveSpacingOrFraction(rest: string): string | null {
362
+ const frac = resolveFraction(rest);
363
+ if (frac != null) return frac;
364
+ if (rest === 'full') return '100%';
365
+ return SPACING_SCALE[rest] ?? null;
366
+ }
367
+
368
+ /**
369
+ * Negate a resolved value for the leading-`-` Tailwind form. Plain numeric/length/percentage
370
+ * tokens flip sign; `translate-y`'s `0 <y>` composite negates only the y token. Non-numeric values
371
+ * (keywords, var(), calc) aren't negatable → null (the class stays unrecognized).
372
+ */
373
+ function negateCssValue(root: string, value: string): string | null {
374
+ if (root === 'translate-y') {
375
+ const m = value.match(/^0 (.+)$/);
376
+ return m ? `0 -${m[1]}` : null;
377
+ }
378
+ return /^\d/.test(value) ? `-${value}` : null;
379
+ }
380
+
344
381
  /**
345
382
  * Parse a single (unprefixed) utility class name into its CSS property and
346
383
  * decoded value. Returns null for class names that aren't Meno utilities.
@@ -358,11 +395,43 @@ export function parseUtilityClass(className: string, knownTokens?: ReadonlySet<s
358
395
  return { property: camelToKebab(stat.prop), value: stat.value, root: null, kind: 'static' };
359
396
  }
360
397
 
398
+ // Bare `transition` enables Tailwind's default property set + 150ms ease curve.
399
+ if (className === 'transition') {
400
+ return {
401
+ property: 'transition',
402
+ value: `${TRANSITION_PROPERTY_VALUES['']} ${TRANSITION_DEFAULT_TIMING}`,
403
+ root: 'transition',
404
+ kind: 'keyword',
405
+ };
406
+ }
407
+
408
+ // Leading-`-` negative form (`-mt-4`, `-top-2`, `-translate-y-1/2`, `-rotate-3`): parse the
409
+ // positive class, then negate its value when the root is negatable. Not a negatable root or a
410
+ // non-numeric value → unrecognized (don't misread foreign `-foo` classes as utilities).
411
+ if (className.startsWith('-') && className.length > 1) {
412
+ const positive = parseUtilityClass(className.slice(1), knownTokens);
413
+ if (positive?.root && positive.value != null && NEGATABLE_ROOTS.has(positive.root)) {
414
+ const negated = negateCssValue(positive.root, positive.value);
415
+ if (negated != null) return { ...positive, value: negated };
416
+ }
417
+ return null;
418
+ }
419
+
361
420
  const preset = presetClassReverse[className];
362
421
  if (preset) {
363
422
  return { property: camelToKebab(preset.prop), value: preset.value, root: null, kind: 'preset' };
364
423
  }
365
424
 
425
+ // Tailwind named value scales (`text-lg`, `font-semibold`, `rounded-md`, `shadow-lg`, `max-w-md`,
426
+ // `leading-tight`, `tracking-wide`, `font-sans`, + bare `rounded`/`shadow`). These resolve to a
427
+ // plain `var(--token)` reference (no baked default — Meno is token-based): renders the project
428
+ // variable when defined, inert when not. Checked after static/preset so `text-center`,
429
+ // `font-bold`, `rounded-full`, `shadow-none`, `shadow-2` keep their existing meaning.
430
+ const themed = THEME_SCALE_BY_CLASS.get(className);
431
+ if (themed) {
432
+ return { property: themed.property, value: themeScaleValue(themed), root: themed.root, kind: 'var' };
433
+ }
434
+
366
435
  // Arbitrary property: [clip-path:circle(50%)], [background:linear-gradient(…)]
367
436
  const arbProp = className.match(/^\[([a-z-]+):(.+)\]$/);
368
437
  if (arbProp) {
@@ -422,6 +491,83 @@ function parseRootValue(root: string, rest: string, knownTokens?: ReadonlySet<st
422
491
  return { property: resolveRootProperty(root, rest), value: rest, root, kind: 'keyword' };
423
492
  }
424
493
 
494
+ // Tailwind grid track scale: `grid-cols-3` → `repeat(3, minmax(0, 1fr))`,
495
+ // `grid-rows-2` likewise, plus the `none` / `subgrid` keywords. Meno's own
496
+ // form is the arbitrary `grid-cols-[1fr_1fr_1fr]` (handled above); this
497
+ // additionally absorbs the bare Tailwind numeric scale that hand- and
498
+ // AI-authored class strings reach for. Tailwind only defines the positive
499
+ // integer scale, so `grid-cols-0` stays unrecognized.
500
+ if (root === 'grid-cols' || root === 'grid-rows') {
501
+ if (/^[1-9]\d*$/.test(rest)) {
502
+ const value = `repeat(${rest}, minmax(0, 1fr))`;
503
+ return { property: prefixToCSSProperty[root] ?? root, value, root, kind: 'numeric' };
504
+ }
505
+ if (rest === 'none' || rest === 'subgrid') {
506
+ return { property: prefixToCSSProperty[root] ?? root, value: rest, root, kind: 'keyword' };
507
+ }
508
+ }
509
+
510
+ // Grid placement: `col-span-2` → grid-column: span 2 / span 2; `col-span-full` → 1 / -1.
511
+ if (root === 'col-span' || root === 'row-span') {
512
+ const property = root === 'col-span' ? 'grid-column' : 'grid-row';
513
+ if (rest === 'full') return { property, value: '1 / -1', root, kind: 'keyword' };
514
+ if (/^[1-9]\d*$/.test(rest)) return { property, value: `span ${rest} / span ${rest}`, root, kind: 'numeric' };
515
+ }
516
+ // Grid line start/end: `col-start-2`, `col-end-3`, `row-start-1`, `row-end-4` (+ `auto`).
517
+ if (root === 'col-start' || root === 'col-end' || root === 'row-start' || root === 'row-end') {
518
+ const property = prefixToCSSProperty[root] ?? root;
519
+ if (rest === 'auto') return { property, value: 'auto', root, kind: 'keyword' };
520
+ if (/^-?[1-9]\d*$/.test(rest)) return { property, value: rest, root, kind: 'numeric' };
521
+ }
522
+
523
+ // Individual translate axis: `translate-x-4` → translate: 16px; `translate-y-1/2` → translate: 0 50%.
524
+ if (root === 'translate-x' || root === 'translate-y') {
525
+ const len = resolveSpacingOrFraction(rest);
526
+ if (len != null) {
527
+ const value = root === 'translate-x' ? len : `0 ${len}`;
528
+ return { property: 'translate', value, root, kind: 'numeric' };
529
+ }
530
+ }
531
+ // `scale-105` → scale: 1.05 (Tailwind's percentage-of-100 scale).
532
+ if (root === 'scale' && /^\d+$/.test(rest)) {
533
+ return { property: 'scale', value: String(Number(rest) / 100), root, kind: 'numeric' };
534
+ }
535
+ // `rotate-45` → rotate: 45deg (negatives via the leading-`-` form).
536
+ if (root === 'rotate' && /^\d+(\.\d+)?$/.test(rest)) {
537
+ return { property: 'rotate', value: `${rest}deg`, root, kind: 'numeric' };
538
+ }
539
+ // `duration-300` → transition-duration: 300ms; `delay-150` → transition-delay: 150ms.
540
+ if ((root === 'duration' || root === 'delay') && /^\d+$/.test(rest)) {
541
+ return { property: prefixToCSSProperty[root] ?? root, value: `${rest}ms`, root, kind: 'numeric' };
542
+ }
543
+ // `ease-in-out` → transition-timing-function: cubic-bezier(…).
544
+ if (root === 'ease') {
545
+ const timing = EASE_TIMING[rest];
546
+ if (timing) return { property: 'transition-timing-function', value: timing, root, kind: 'keyword' };
547
+ }
548
+ // `transition-all` / `transition-colors` / … → the `transition` shorthand (bare `transition`
549
+ // is handled in parseUtilityClass). A following `duration-*` / `ease-*` longhand still overrides.
550
+ if (root === 'transition') {
551
+ const props = TRANSITION_PROPERTY_VALUES[rest];
552
+ if (props !== undefined) {
553
+ return { property: 'transition', value: `${props} ${TRANSITION_DEFAULT_TIMING}`, root, kind: 'keyword' };
554
+ }
555
+ }
556
+
557
+ // Numeric line-height on the spacing scale: `leading-6` → 1.5rem (24px). Named leadings
558
+ // (`leading-tight`) resolve to a var() earlier; `leading-normal` stays the `normal` keyword.
559
+ if (root === 'leading') {
560
+ const scale = SPACING_SCALE[rest];
561
+ if (scale !== undefined) return { property: 'line-height', value: scale, root, kind: 'keyword' };
562
+ }
563
+
564
+ // Tailwind fractional values: `w-1/2` → 50%, `top-1/3` → 33.333333%, `basis-1/2` → 50%.
565
+ // (translate fractions are handled by the axis branch above.)
566
+ if (FRACTION_ROOTS.has(root)) {
567
+ const value = resolveFraction(rest);
568
+ if (value != null) return { property: resolveRootProperty(root, value), value, root, kind: 'numeric' };
569
+ }
570
+
425
571
  // Named spacing scale: p-4 → 16px, gap-6 → 24px (Tailwind's step table).
426
572
  if (SPACING_SCALE_ROOTS.has(root)) {
427
573
  const scaleVal = SPACING_SCALE[rest];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "meno-core",
3
- "version": "1.1.0",
3
+ "version": "1.1.2",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "meno": "./dist/bin/cli.js"