meno-core 1.1.0 → 1.1.1

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.
@@ -442,8 +442,6 @@ export async function generateSSRHTML(
442
442
  // Include component CSS (no longer generating inline style classes)
443
443
  const componentCSS = rendered.componentCSS || '';
444
444
 
445
- // Extract and generate utility CSS from rendered HTML
446
- const usedUtilityClasses = extractUtilityClassesFromHTML(rendered.html);
447
445
  const breakpointConfig = await loadBreakpointConfig();
448
446
  const responsiveScalesConfig = configService.getResponsiveScales();
449
447
 
@@ -451,11 +449,29 @@ export async function generateSSRHTML(
451
449
  const variablesConfig = await variableService.loadConfig();
452
450
  const variablesCSS = generateVariablesCSS(variablesConfig, breakpointConfig, responsiveScalesConfig);
453
451
  const remConversionConfig = configService.getRemConversion();
452
+
453
+ // Named design-token classes (`text-muted` → color: var(--muted), `bg-<token>`, …) only resolve to a
454
+ // CSS rule when the generator knows which token names are DEFINED — without that set, `text-muted` is
455
+ // indistinguishable from a typo and is dropped, both at extraction (filtered by parseUtilityClass) and
456
+ // at generation. Build it from the project's theme colors + variables — mirroring the Astro build's
457
+ // readKnownTokensSync and the canvas client's loadKnownTokens — so an instance-level color class that
458
+ // renders in a real Astro build also renders in the SSR design canvas.
459
+ const knownTokens = new Set<string>();
460
+ for (const theme of Object.values(themeConfig.themes)) {
461
+ for (const name of Object.keys(theme.colors)) knownTokens.add(name);
462
+ }
463
+ for (const variable of variablesConfig.variables) {
464
+ if (variable.cssVar) knownTokens.add(variable.cssVar.replace(/^--/, ''));
465
+ }
466
+
467
+ // Extract and generate utility CSS from rendered HTML
468
+ const usedUtilityClasses = extractUtilityClassesFromHTML(rendered.html, knownTokens);
454
469
  const utilityCSS = generateUtilityCSS(
455
470
  usedUtilityClasses,
456
471
  breakpointConfig,
457
472
  responsiveScalesConfig,
458
473
  remConversionConfig,
474
+ knownTokens,
459
475
  );
460
476
 
461
477
  // Generate interactive styles CSS from map collected during render
@@ -1115,6 +1115,31 @@ describe('ssrRenderer', () => {
1115
1115
  expect(html).toContain('<div');
1116
1116
  });
1117
1117
 
1118
+ test('component instance class-STRING override lands on the root element', async () => {
1119
+ // The class-based instance seam (`<Heading class="text-muted text-[#ff0000]" />`): the instance's
1120
+ // `class` prop must be applied onto the component root, mirroring real Astro's
1121
+ // cx(style(...), variants(...), className). Without it, instance-level colors render in a real
1122
+ // build but vanish in the SSR design canvas.
1123
+ const componentDef: ComponentDefinition = {
1124
+ component: {
1125
+ structure: { type: 'node', tag: 'h1', children: '{{text}}' },
1126
+ interface: { text: { type: 'string', default: 'Hi' } },
1127
+ },
1128
+ };
1129
+
1130
+ const node = {
1131
+ type: 'component',
1132
+ component: 'Heading',
1133
+ props: { text: 'Hi', class: 'text-muted text-[#ff0000]' },
1134
+ };
1135
+
1136
+ const html = await render(node, { globalComponents: { Heading: componentDef } });
1137
+ const classMatch = html.match(/<h1[^>]*\sclass="([^"]*)"/);
1138
+ expect(classMatch).not.toBeNull();
1139
+ expect(classMatch?.[1]).toContain('text-muted');
1140
+ expect(classMatch?.[1]).toContain('text-[#ff0000]');
1141
+ });
1142
+
1118
1143
  test('component with defineVars: true adds data attributes', async () => {
1119
1144
  const componentDef: ComponentDefinition = {
1120
1145
  component: {
@@ -1850,8 +1850,18 @@ async function renderNode(
1850
1850
  delete nodeAttributesWithoutClass.className;
1851
1851
  delete nodeAttributesWithoutClass.class;
1852
1852
 
1853
- // Merge classNames: element class (first for specificity) + attribute classes + utility classes
1854
- const allClassNames = [elementClass, attrClassName, ...utilityClasses].filter(Boolean);
1853
+ // A component INSTANCE carries its class-string override as a prop (`<Heading class="text-muted" />`
1854
+ // nodeProps.class) the class-based equivalent of the legacy instance style object. Real Astro
1855
+ // applies it onto the component root via cx(style(...), variants(...), className); the SSR canvas
1856
+ // must too, or instance-level classes (named colors like `text-muted`/`text-text`, arbitrary
1857
+ // `text-[#ff0000]`) silently vanish in design mode while rendering correctly in a real build. Last in
1858
+ // the list so it wins source order, matching cx's instance-last precedence. Empty for HTML nodes
1859
+ // (nodeProps is {} there), so this only affects component instances.
1860
+ const instanceClassName = typeof nodeProps.class === 'string' ? nodeProps.class : '';
1861
+
1862
+ // Merge classNames: element class (first for specificity) + attribute classes + utility classes +
1863
+ // the component instance's class-string override (last → wins)
1864
+ const allClassNames = [elementClass, attrClassName, ...utilityClasses, instanceClassName].filter(Boolean);
1855
1865
  const mergedClassName = allClassNames.length > 0 ? allClassNames.join(' ') : '';
1856
1866
 
1857
1867
  const propsWithStyleAndAttrs = {
@@ -694,6 +694,16 @@ describe('styleObjectToCSS', () => {
694
694
  expect(styleObjectToCSS({ color: 'rgb(0,0,0)' })).toBe('color: rgb(0,0,0)');
695
695
  });
696
696
 
697
+ test('named CSS colors and keywords pass through — never wrapped in var()', () => {
698
+ // Regression: a literal `red` was being emitted as `var(--red)` (a token
699
+ // that does not exist) instead of the real CSS color.
700
+ expect(styleObjectToCSS({ color: 'red' })).toBe('color: red');
701
+ expect(styleObjectToCSS({ backgroundColor: 'transparent' })).toBe('background-color: transparent');
702
+ expect(styleObjectToCSS({ color: 'currentColor' })).toBe('color: currentColor');
703
+ expect(styleObjectToCSS({ borderColor: 'rebeccapurple' })).toBe('border-color: rebeccapurple');
704
+ expect(styleObjectToCSS({ color: 'inherit' })).toBe('color: inherit');
705
+ });
706
+
697
707
  test('skips _mapping (binding) values — bound props yield empty/partial output', () => {
698
708
  expect(styleObjectToCSS({ color: { _mapping: { prop: 'x' } } as never })).toBe('');
699
709
  expect(styleObjectToCSS({ color: { _mapping: { prop: 'x' } } as never, padding: '4px' })).toBe('padding: 4px');
@@ -11,6 +11,7 @@ import {
11
11
  breakpointClassPrefixes,
12
12
  resolveRootProperty,
13
13
  camelToKebab,
14
+ isBareColorTokenName,
14
15
  } from './utilityClassNames';
15
16
  import { getStyleValue, getDynamicStyle, isDynamicClass } from './styleValueRegistry';
16
17
  import type { BreakpointConfig } from './breakpoints';
@@ -213,6 +214,9 @@ export function generateRuleForClass(className: string, knownTokens?: ReadonlySe
213
214
  }
214
215
 
215
216
  // Multi-property roots (Tailwind axis shorthands)
217
+ if (root === 'size') {
218
+ return `width: ${value}; height: ${value};`;
219
+ }
216
220
  if (root === 'px') {
217
221
  return `padding-left: ${value}; padding-right: ${value};`;
218
222
  }
@@ -726,15 +730,13 @@ export function styleObjectToCSS(style: StyleObject): string {
726
730
  // Convert camelCase to kebab-case
727
731
  const cssProperty = prop.replace(/([A-Z])/g, '-$1').toLowerCase();
728
732
 
729
- // Handle CSS variable references for color properties
733
+ // Handle CSS variable references for color properties. Only a bare Meno
734
+ // color *token* (e.g. `primary`) is wrapped in `var(--token)`. Named CSS
735
+ // colors (`red`), hex, color functions and Tailwind palette names are real
736
+ // values and pass through untouched — mirrors the utility-class engine so
737
+ // the interactive path and the static path resolve colors identically.
730
738
  let cssValue = String(value);
731
- if (
732
- (prop === 'backgroundColor' || prop === 'color' || prop === 'borderColor') &&
733
- !cssValue.startsWith('#') &&
734
- !cssValue.startsWith('rgb') &&
735
- !cssValue.startsWith('var(') &&
736
- !cssValue.includes('(')
737
- ) {
739
+ if ((prop === 'backgroundColor' || prop === 'color' || prop === 'borderColor') && isBareColorTokenName(cssValue)) {
738
740
  cssValue = `var(--${cssValue})`;
739
741
  }
740
742
 
@@ -30,6 +30,12 @@ export { splitVariantPrefix, normalizeBreakpointVariant } from './utilityClassNa
30
30
  // Utility-class catalog pieces — the seed for the Styles panel's class-bar autocomplete (§13 Phase 5).
31
31
  // Selective (not `export *`) to keep the barrel collision-free.
32
32
  export { propertyMap, staticUtilityReverse, keywordValues, presetClassReverse } from './utilityClassConfig';
33
+ // Tailwind theme-scale tokens + the seed-variable generator (`text-lg` → `var(--text-lg, …)`).
34
+ export {
35
+ THEME_SCALE_ENTRIES,
36
+ defaultTailwindThemeVariables,
37
+ type ThemeScaleEntry,
38
+ } from './tailwindThemeScale';
33
39
  export * from './elementClassName';
34
40
  export * from './styleValueRegistry';
35
41
 
@@ -33,8 +33,10 @@ describe('generateInteractiveCSS', () => {
33
33
  const css = generateInteractiveCSS(elementClass, interactiveStyles);
34
34
 
35
35
  expect(css).toContain(`.${elementClass}:hover`);
36
+ // `primary` is a bare Meno token → wrapped in var(); `white` is a real
37
+ // named CSS color → passes through untouched (not var(--white)).
36
38
  expect(css).toContain('background-color: var(--primary)');
37
- expect(css).toContain('color: var(--white)');
39
+ expect(css).toContain('color: white');
38
40
  });
39
41
 
40
42
  test('should generate active styles for page element', () => {
@@ -0,0 +1,256 @@
1
+ /**
2
+ * Tailwind named-scale → CSS-variable theme table.
3
+ *
4
+ * Tailwind's *named* value scales (`text-lg`, `font-semibold`, `rounded-md`, `shadow-lg`,
5
+ * `max-w-md`, `leading-tight`, `tracking-wide`, `font-sans`) have no single numeric mapping — each
6
+ * name is a theme token. Meno is token-based, so the class resolves to a plain variable reference
7
+ * with **no baked default** — it renders the project's variable when defined, and is inert when not
8
+ * (Meno deliberately does NOT smuggle Tailwind's opinionated defaults into the render):
9
+ *
10
+ * `text-lg` → `font-size: var(--text-lg)`
11
+ *
12
+ * Define `--text-lg` (in `variables.json`) → `text-lg` uses it everywhere; leave it undefined →
13
+ * the declaration has no effect (use an arbitrary value like `text-[18px]` for a one-off). See the
14
+ * styling docs for the authoring guidance.
15
+ *
16
+ * This table is the single source of truth for:
17
+ * - the parser (`parseUtilityClass`): class → `var(--token)`
18
+ * - the inverse mapper (`propertyValueToClass`): `var(--token)` → compact class
19
+ * - `defaultTailwindThemeVariables()`: an OPT-IN starter set (Tailwind values) a user can choose
20
+ * to create — it is never auto-applied and never a render fallback.
21
+ */
22
+
23
+ import type { CSSVariable, VariableGroup, VariableType } from './types/variables';
24
+
25
+ /** One Tailwind theme token: the class it backs, its CSS var, default value and UI grouping. */
26
+ export interface ThemeScaleEntry {
27
+ /** Full utility class, e.g. `text-lg`, or the bare default (`rounded`, `shadow`). */
28
+ className: string;
29
+ /** The class root (`text`, `font`, `rounded`, `shadow`, `max-w`, `leading`, `tracking`). */
30
+ root: string;
31
+ /** CSS custom property, e.g. `--text-lg`. */
32
+ cssVar: string;
33
+ /** kebab-case CSS property the class targets. */
34
+ property: string;
35
+ /** Tailwind's default value — the starter value for the OPT-IN seed set, NOT a render fallback. */
36
+ value: string;
37
+ /** Variable picker group + scaling type for the seeded variable. */
38
+ group: VariableGroup;
39
+ /** Human label for the seeded variable, e.g. `Text LG`. */
40
+ label: string;
41
+ }
42
+
43
+ type ScaleSpec = {
44
+ root: string;
45
+ property: string;
46
+ group: VariableGroup;
47
+ /** Prefix for the css var; class is `${root}-${name}` (or bare `${root}` when name is ''). */
48
+ varPrefix: string;
49
+ /** Human label prefix for the seeded variable. */
50
+ labelPrefix: string;
51
+ /** [classSuffix, cssValue] pairs. '' suffix = the bare class (`rounded`, `shadow`). */
52
+ values: [string, string][];
53
+ };
54
+
55
+ const SANS = 'ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, sans-serif';
56
+ const SERIF = 'ui-serif, Georgia, Cambria, serif';
57
+ const MONO = 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace';
58
+
59
+ const SPECS: ScaleSpec[] = [
60
+ {
61
+ root: 'text',
62
+ property: 'font-size',
63
+ group: 'font-size',
64
+ varPrefix: 'text',
65
+ labelPrefix: 'Text',
66
+ values: [
67
+ ['xs', '0.75rem'],
68
+ ['sm', '0.875rem'],
69
+ ['base', '1rem'],
70
+ ['lg', '1.125rem'],
71
+ ['xl', '1.25rem'],
72
+ ['2xl', '1.5rem'],
73
+ ['3xl', '1.875rem'],
74
+ ['4xl', '2.25rem'],
75
+ ['5xl', '3rem'],
76
+ ['6xl', '3.75rem'],
77
+ ['7xl', '4.5rem'],
78
+ ['8xl', '6rem'],
79
+ ['9xl', '8rem'],
80
+ ],
81
+ },
82
+ {
83
+ // Only the weights Meno's static utilities don't already cover (normal/bold stay static).
84
+ root: 'font',
85
+ property: 'font-weight',
86
+ group: 'font-weight',
87
+ varPrefix: 'font-weight',
88
+ labelPrefix: 'Font Weight',
89
+ values: [
90
+ ['thin', '100'],
91
+ ['extralight', '200'],
92
+ ['light', '300'],
93
+ ['medium', '500'],
94
+ ['semibold', '600'],
95
+ ['extrabold', '800'],
96
+ ['black', '900'],
97
+ ],
98
+ },
99
+ {
100
+ root: 'font',
101
+ property: 'font-family',
102
+ group: 'font-family',
103
+ varPrefix: 'font',
104
+ labelPrefix: 'Font',
105
+ values: [
106
+ ['sans', SANS],
107
+ ['serif', SERIF],
108
+ ['mono', MONO],
109
+ ],
110
+ },
111
+ {
112
+ root: 'rounded',
113
+ property: 'border-radius',
114
+ group: 'border-radius',
115
+ varPrefix: 'radius',
116
+ labelPrefix: 'Radius',
117
+ values: [
118
+ ['', '0.25rem'],
119
+ ['sm', '0.125rem'],
120
+ ['md', '0.375rem'],
121
+ ['lg', '0.5rem'],
122
+ ['xl', '0.75rem'],
123
+ ['2xl', '1rem'],
124
+ ['3xl', '1.5rem'],
125
+ ],
126
+ },
127
+ {
128
+ root: 'shadow',
129
+ property: 'box-shadow',
130
+ group: 'shadow',
131
+ varPrefix: 'shadow',
132
+ labelPrefix: 'Shadow',
133
+ values: [
134
+ ['', '0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1)'],
135
+ ['sm', '0 1px 2px 0 rgb(0 0 0 / 0.05)'],
136
+ ['md', '0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1)'],
137
+ ['lg', '0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1)'],
138
+ ['xl', '0 20px 25px -5px rgb(0 0 0 / 0.1), 0 8px 10px -6px rgb(0 0 0 / 0.1)'],
139
+ ['2xl', '0 25px 50px -12px rgb(0 0 0 / 0.25)'],
140
+ ],
141
+ },
142
+ {
143
+ root: 'max-w',
144
+ property: 'max-width',
145
+ group: 'size',
146
+ varPrefix: 'container',
147
+ labelPrefix: 'Container',
148
+ values: [
149
+ ['xs', '20rem'],
150
+ ['sm', '24rem'],
151
+ ['md', '28rem'],
152
+ ['lg', '32rem'],
153
+ ['xl', '36rem'],
154
+ ['2xl', '42rem'],
155
+ ['3xl', '48rem'],
156
+ ['4xl', '56rem'],
157
+ ['5xl', '64rem'],
158
+ ['6xl', '72rem'],
159
+ ['7xl', '80rem'],
160
+ ['prose', '65ch'],
161
+ ],
162
+ },
163
+ {
164
+ root: 'leading',
165
+ property: 'line-height',
166
+ group: 'line-height',
167
+ varPrefix: 'leading',
168
+ labelPrefix: 'Leading',
169
+ values: [
170
+ ['none', '1'],
171
+ ['tight', '1.25'],
172
+ ['snug', '1.375'],
173
+ ['relaxed', '1.625'],
174
+ ['loose', '2'],
175
+ ],
176
+ },
177
+ {
178
+ root: 'tracking',
179
+ property: 'letter-spacing',
180
+ group: 'letter-spacing',
181
+ varPrefix: 'tracking',
182
+ labelPrefix: 'Tracking',
183
+ values: [
184
+ ['tighter', '-0.05em'],
185
+ ['tight', '-0.025em'],
186
+ ['wide', '0.025em'],
187
+ ['wider', '0.05em'],
188
+ ['widest', '0.1em'],
189
+ ],
190
+ },
191
+ ];
192
+
193
+ /** Size codes read better uppercased (`LG`, `2XL`); word names get title case (`Semibold`). */
194
+ function labelSuffix(suffix: string): string {
195
+ if (/^(xs|sm|md|lg|xl|\dxl)$/.test(suffix)) return suffix.toUpperCase();
196
+ return suffix.charAt(0).toUpperCase() + suffix.slice(1);
197
+ }
198
+
199
+ /** All theme entries, flattened. */
200
+ export const THEME_SCALE_ENTRIES: ThemeScaleEntry[] = SPECS.flatMap((spec) =>
201
+ spec.values.map(([suffix, value]) => {
202
+ const className = suffix ? `${spec.root}-${suffix}` : spec.root;
203
+ const cssVar = suffix ? `--${spec.varPrefix}-${suffix}` : `--${spec.varPrefix}`;
204
+ const label = suffix ? `${spec.labelPrefix} ${labelSuffix(suffix)}` : spec.labelPrefix;
205
+ return { className, root: spec.root, cssVar, property: spec.property, value, group: spec.group, label };
206
+ }),
207
+ );
208
+
209
+ /** className → entry (parser lookup). e.g. `text-lg`, `rounded`, `font-sans`. */
210
+ export const THEME_SCALE_BY_CLASS: ReadonlyMap<string, ThemeScaleEntry> = new Map(
211
+ THEME_SCALE_ENTRIES.map((e) => [e.className, e]),
212
+ );
213
+
214
+ /** cssVar → entry (inverse-mapper lookup). e.g. `--text-lg` → `text-lg`. */
215
+ export const THEME_SCALE_BY_CSSVAR: ReadonlyMap<string, ThemeScaleEntry> = new Map(
216
+ THEME_SCALE_ENTRIES.map((e) => [e.cssVar, e]),
217
+ );
218
+
219
+ /**
220
+ * The CSS value a themed class resolves to: a plain `var(--token)` reference with no fallback.
221
+ * Renders the project variable when defined, inert otherwise (token-based by design — no baked
222
+ * Tailwind default in the render path).
223
+ */
224
+ export function themeScaleValue(entry: ThemeScaleEntry): string {
225
+ return `var(${entry.cssVar})`;
226
+ }
227
+
228
+ /** Map a variable group to its responsive-scaling type (mirrors getDefaultScalingType). */
229
+ function scalingTypeFor(group: VariableGroup): VariableType {
230
+ switch (group) {
231
+ case 'font-size':
232
+ return 'fontSize';
233
+ case 'border-radius':
234
+ return 'borderRadius';
235
+ case 'size':
236
+ return 'size';
237
+ default:
238
+ return 'none';
239
+ }
240
+ }
241
+
242
+ /**
243
+ * The full set of Tailwind theme tokens as Meno `CSSVariable`s, ready to merge into a project's
244
+ * `variables.json`. Seeding makes the tokens visible/editable in the variable picker (filed under
245
+ * their group); rendering does NOT require seeding — the class carries the Tailwind default as the
246
+ * `var()` fallback. Idempotent merge is the caller's job (skip a `cssVar` that already exists).
247
+ */
248
+ export function defaultTailwindThemeVariables(): CSSVariable[] {
249
+ return THEME_SCALE_ENTRIES.map((e) => ({
250
+ name: e.label,
251
+ cssVar: e.cssVar,
252
+ value: e.value,
253
+ type: scalingTypeFor(e.group),
254
+ group: e.group,
255
+ }));
256
+ }
@@ -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)