Skip to content

Commit 04caac1

Browse files
committed
chore: several minor improvements
1 parent 36d341d commit 04caac1

13 files changed

Lines changed: 1051 additions & 42 deletions

packages/crosswind/src/generator.ts

Lines changed: 15 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1893,20 +1893,28 @@ export class CSSGenerator {
18931893
return
18941894
}
18951895

1896-
// Gap: gap-{spacing}
1896+
// Gap: gap-{spacing}. Uses the same v4-style decimal fallback as the
1897+
// shared spacing resolver — `gap-4.5` → `1.125rem` instead of
1898+
// the invalid `gap: 4.5;` the pre-fix path emitted.
1899+
const resolveGap = (v: string): string => {
1900+
const hit = this.config.theme.spacing[v]
1901+
if (hit !== undefined) return hit
1902+
if (/^\d+(?:\.\d+)?$/.test(v)) {
1903+
const n = Number.parseFloat(v)
1904+
if (Number.isFinite(n)) return `${n * 0.25}rem`
1905+
}
1906+
return v
1907+
}
18971908
if (utility === 'gap' && value) {
1898-
const gapValue = this.config.theme.spacing[value] || value
1899-
this.addRule(parsed, { gap: gapValue })
1909+
this.addRule(parsed, { gap: resolveGap(value) })
19001910
return
19011911
}
19021912
if (utility === 'gap-x' && value) {
1903-
const gapValue = this.config.theme.spacing[value] || value
1904-
this.addRule(parsed, { 'column-gap': gapValue })
1913+
this.addRule(parsed, { 'column-gap': resolveGap(value) })
19051914
return
19061915
}
19071916
if (utility === 'gap-y' && value) {
1908-
const gapValue = this.config.theme.spacing[value] || value
1909-
this.addRule(parsed, { 'row-gap': gapValue })
1917+
this.addRule(parsed, { 'row-gap': resolveGap(value) })
19101918
return
19111919
}
19121920

packages/crosswind/src/parser.ts

Lines changed: 93 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1016,13 +1016,20 @@ export function parseClass(className: string): ParsedClass {
10161016
* Internal implementation of parseClass
10171017
*/
10181018
function parseClassImpl(className: string): ParsedClass {
1019-
// Check for important modifier
1019+
// Check for important modifier. Supports both Tailwind v3 prefix form
1020+
// (`!p-4`) and v4 suffix form (`p-4!`). Suffix form is checked BEFORE
1021+
// rejecting arbitrary-value brackets so `p-4!` peels the bang off cleanly
1022+
// while `p-[4]!` and `[color:red]!` still work.
10201023
let important = false
10211024
let cleanClassName = className
10221025
if (className.startsWith('!')) {
10231026
important = true
10241027
cleanClassName = className.slice(1)
10251028
}
1029+
else if (className.endsWith('!') && className.length > 1) {
1030+
important = true
1031+
cleanClassName = className.slice(0, -1)
1032+
}
10261033

10271034
// Check for arbitrary properties BEFORE splitting on colons: [color:red], [mask-type:luminance]
10281035
const arbitraryPropMatch = cleanClassName.match(/^\[([a-z-]+):(.+)\]$/)
@@ -1040,13 +1047,50 @@ function parseClassImpl(className: string): ParsedClass {
10401047
}
10411048
}
10421049

1050+
// Check for arbitrary-value + arbitrary-modifier shape:
1051+
// text-[14px]/[1.5] font-size with line-height
1052+
// bg-[#FF3E54]/[0.33] background color with arbitrary alpha
1053+
// The main preArbitraryMatch regex below ends on the last `]`, so when
1054+
// there are two bracket pairs it would swallow the slash into the value.
1055+
// Detect the shape first and split it cleanly.
1056+
const preArbitraryWithModifierMatch = cleanClassName.match(
1057+
/^((?:[a-z-]+:)*)(-?[a-z-]+?)-\[([^\]]+)\]\/\[([^\]]+)\]$/,
1058+
)
1059+
if (preArbitraryWithModifierMatch) {
1060+
const [, variantPart, utilityRaw, bracketValue, modifier] = preArbitraryWithModifierMatch
1061+
const variants = variantPart ? variantPart.split(':').filter(Boolean) : []
1062+
const isNegative = utilityRaw.startsWith('-')
1063+
const utilityName = isNegative ? utilityRaw.slice(1) : utilityRaw
1064+
let value = convertArbitraryUnderscores(bracketValue)
1065+
if (isNegative) value = value.startsWith('-') ? value : `-${value}`
1066+
return {
1067+
raw: className,
1068+
variants,
1069+
utility: utilityName,
1070+
// Encode the modifier as `value/modifier` — rules that care (fontSizeRule
1071+
// for line-height, colorRule for alpha) split on the first slash.
1072+
value: `${value}/${modifier}`,
1073+
important,
1074+
arbitrary: true,
1075+
}
1076+
}
1077+
10431078
// Check for arbitrary values with brackets BEFORE splitting on colons
10441079
// This handles cases like bg-[url(https://...)] where the URL contains colons
10451080
// Also handles type hints like text-[color:var(--muted)]
1046-
const preArbitraryMatch = cleanClassName.match(/^((?:[a-z-]+:)*)([a-z-]+?)-\[(.+)\]$/)
1081+
const preArbitraryMatch = cleanClassName.match(/^((?:[a-z-]+:)*)(-?[a-z-]+?)-\[(.+)\]$/)
10471082
if (preArbitraryMatch) {
10481083
const variantPart = preArbitraryMatch[1]
10491084
const variants = variantPart ? variantPart.split(':').filter(Boolean) : []
1085+
1086+
// Detect a leading `-` on the utility — Tailwind-style negative arbitrary
1087+
// values like `-mt-[20px]` or `-translate-x-[50%]`. Strip the sign from
1088+
// the utility name and fold it onto the value so rules that match
1089+
// `utility === 'mt'` still fire.
1090+
let utilityName = preArbitraryMatch[2]
1091+
const isNegative = utilityName.startsWith('-')
1092+
if (isNegative) utilityName = utilityName.slice(1)
1093+
10501094
let value = convertArbitraryUnderscores(preArbitraryMatch[3])
10511095
let typeHint: string | undefined
10521096

@@ -1059,10 +1103,12 @@ function parseClassImpl(className: string): ParsedClass {
10591103
value = typeHintMatch[2]
10601104
}
10611105

1106+
if (isNegative) value = value.startsWith('-') ? value : `-${value}`
1107+
10621108
return {
10631109
raw: className,
10641110
variants,
1065-
utility: preArbitraryMatch[2],
1111+
utility: utilityName,
10661112
value,
10671113
important,
10681114
arbitrary: true,
@@ -1088,9 +1134,34 @@ function parseClassImpl(className: string): ParsedClass {
10881134
}
10891135
parts.push(current)
10901136

1091-
const utility = parts[parts.length - 1]
1137+
let utility = parts[parts.length - 1]
10921138
const variants = parts.slice(0, -1)
10931139

1140+
// Handle `!` placed between the last variant and the utility
1141+
// (`hover:!bg-red-500`). After splitting on `:`, the bang sits at the
1142+
// start of the utility token. Strip it and flag important. Mirror the
1143+
// trailing form too (`hover:p-4!`) for consistency with the top-of-file
1144+
// bang handling that covered the no-variant case.
1145+
if (utility.startsWith('!') && utility.length > 1) {
1146+
important = true
1147+
utility = utility.slice(1)
1148+
}
1149+
else if (utility.endsWith('!') && utility.length > 1) {
1150+
important = true
1151+
utility = utility.slice(0, -1)
1152+
}
1153+
1154+
// cleanClassName is used below for the subsequent arbitrary-value /
1155+
// negative-value branches that rebuild the parsed shape from scratch.
1156+
// Keep it in sync with the stripped utility so those branches see the
1157+
// same token.
1158+
if (variants.length > 0) {
1159+
cleanClassName = `${variants.join(':')}:${utility}`
1160+
}
1161+
else {
1162+
cleanClassName = utility
1163+
}
1164+
10941165
// Check for full utility names that should not be split
10951166
const fullUtilityNames = [
10961167
// Display utilities
@@ -1151,6 +1222,9 @@ function parseClassImpl(className: string): ParsedClass {
11511222
// Handle compound utilities with specific prefixes
11521223
// grid-cols-3, grid-rows-2, translate-x-4, etc.
11531224
const compoundPrefixes = [
1225+
// Position utilities
1226+
'inset-x',
1227+
'inset-y',
11541228
// Border side utilities (border-t-0, border-r-2, etc.)
11551229
'border-t',
11561230
'border-r',
@@ -1250,6 +1324,18 @@ function parseClassImpl(className: string): ParsedClass {
12501324
'rounded-se',
12511325
'rounded-es',
12521326
'rounded-ee',
1327+
// Physical rounded sides + corners (rounded-t-lg, rounded-tr-[6px], etc.).
1328+
// The generator has fast-path lookups for specific size keywords, but
1329+
// these prefixes are needed so arbitrary values and custom sizes fall
1330+
// through to the rule handlers that accept any value.
1331+
'rounded-t',
1332+
'rounded-r',
1333+
'rounded-b',
1334+
'rounded-l',
1335+
'rounded-tl',
1336+
'rounded-tr',
1337+
'rounded-bl',
1338+
'rounded-br',
12531339
'border-opacity',
12541340
'ring-opacity',
12551341
'stroke-dasharray',
@@ -1347,9 +1433,10 @@ function parseClassImpl(className: string): ParsedClass {
13471433
}
13481434

13491435
// Check for color opacity modifiers: bg-blue-500/50, text-red-500/75, bg-white/[0.04]
1350-
// Must come before fractional values to avoid conflict
1436+
// Must come before fractional values to avoid conflict.
1437+
// Gradient stops (from/via/to) accept opacity too — `from-red-500/50`.
13511438
const opacityMatch = utility.match(/^([a-z]+(?:-[a-z]+)*?)-(.+?)\/(\d+|\[\d*\.?\d+\])$/)
1352-
if (opacityMatch && ['bg', 'text', 'border', 'ring', 'placeholder', 'divide', 'accent', 'caret', 'fill', 'stroke', 'outline', 'decoration', 'shadow', 'ring-offset'].includes(opacityMatch[1])) {
1439+
if (opacityMatch && ['bg', 'text', 'border', 'ring', 'placeholder', 'divide', 'accent', 'caret', 'fill', 'stroke', 'outline', 'decoration', 'shadow', 'ring-offset', 'from', 'via', 'to'].includes(opacityMatch[1])) {
13531440
return {
13541441
raw: className,
13551442
variants,

packages/crosswind/src/rules-advanced.ts

Lines changed: 60 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -342,23 +342,78 @@ export const divideRule: UtilityRule = (parsed, config) => {
342342
// Gradient color stops
343343
export const gradientStopsRule: UtilityRule = (parsed, config) => {
344344
const getColor = (value: string) => {
345+
// Support the opacity-modifier shorthand: `red-500/50`, `red-500/[0.33]`.
346+
// The parser hands us the raw `color/alpha` string when the utility is
347+
// a gradient stop; we split it back out here and feed the base color
348+
// through the same lookup table used without an alpha.
349+
let alpha: string | null = null
350+
let lookup = value
351+
const slashIdx = value.indexOf('/')
352+
if (slashIdx !== -1) {
353+
lookup = value.slice(0, slashIdx)
354+
const rawAlpha = value.slice(slashIdx + 1)
355+
if (rawAlpha.startsWith('[') && rawAlpha.endsWith(']')) {
356+
alpha = rawAlpha.slice(1, -1)
357+
}
358+
else {
359+
const pct = Number.parseInt(rawAlpha, 10)
360+
if (!Number.isNaN(pct)) alpha = (pct / 100).toString()
361+
}
362+
}
363+
364+
const applyAlpha = (base: string): string => {
365+
if (!alpha) return base
366+
// oklch / rgb / hsl with slash syntax: inject alpha before the closing `)`.
367+
const mSpace = base.match(/^(oklch|rgb|hsl|oklab|lab|lch)\(([^)]+)\)$/i)
368+
if (mSpace) {
369+
const fn = mSpace[1]
370+
// Existing alpha (if any) is everything after `/`
371+
const inner = mSpace[2].split('/')[0].trim()
372+
return `${fn}(${inner} / ${alpha})`
373+
}
374+
// Hex or named color → use rgb() with calc-alpha via color-mix? Cheapest
375+
// path: keep base and let consumers rely on rgb(r g b / a) for OKLCH.
376+
// For hex, convert to rgb() fallback.
377+
if (/^#[0-9a-f]{3,8}$/i.test(base)) {
378+
const hex = base.replace('#', '')
379+
const parse = (h: string) => Number.parseInt(h.length === 1 ? h + h : h, 16)
380+
const r = parse(hex.slice(0, hex.length === 3 ? 1 : 2))
381+
const g = parse(hex.slice(hex.length === 3 ? 1 : 2, hex.length === 3 ? 2 : 4))
382+
const b = parse(hex.slice(hex.length === 3 ? 2 : 4, hex.length === 3 ? 3 : 6))
383+
return `rgb(${r} ${g} ${b} / ${alpha})`
384+
}
385+
// Fallback: let color-mix carry the alpha (modern-browser-safe).
386+
return `color-mix(in srgb, ${base} ${Math.round(Number.parseFloat(alpha) * 100)}%, transparent)`
387+
}
388+
389+
// Arbitrary color like `[#FF3E54]` — strip the square brackets before
390+
// alpha handling. Without this the CSS variable picks up the raw
391+
// bracketed token, producing invalid `var(--hw-gradient-from: [#...]`.
392+
if (lookup.startsWith('[') && lookup.endsWith(']')) {
393+
lookup = lookup.slice(1, -1)
394+
}
395+
345396
// First check if it's a direct color value (e.g., ocean-blue, black, white)
346-
const directColor = config.theme.colors[value]
397+
const directColor = config.theme.colors[lookup]
347398
if (typeof directColor === 'string') {
348-
return directColor
399+
return applyAlpha(directColor)
349400
}
350401

351402
// Then check if it's a color-shade combination (e.g., sky-500, blue-gray-200)
352-
const parts = value.split('-')
403+
const parts = lookup.split('-')
353404
if (parts.length >= 2) {
354405
const colorName = parts.slice(0, -1).join('-')
355406
const shade = parts[parts.length - 1]
356407
const colorValue = config.theme.colors[colorName]
357408
if (typeof colorValue === 'object' && colorValue[shade]) {
358-
return colorValue[shade]
409+
return applyAlpha(colorValue[shade])
359410
}
360411
}
361-
return value
412+
413+
// Arbitrary color like `#FF3E54` — apply alpha via the hex path.
414+
if (lookup.startsWith('#')) return applyAlpha(lookup)
415+
416+
return applyAlpha(lookup)
362417
}
363418

364419
if (parsed.utility === 'from' && parsed.value) {

packages/crosswind/src/rules-grid.ts

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -168,15 +168,28 @@ export const gridAutoRowsRule: UtilityRule = (parsed) => {
168168
}
169169
}
170170

171+
// Resolve a token against the spacing theme, falling back to Tailwind v4
172+
// behavior for off-scale numbers (`gap-4.5` → `1.125rem`). Keeps keywords
173+
// and arbitrary values (with units, functions) passing through unchanged.
174+
function resolveSpacing(config: any, token: string): string {
175+
const hit = config.theme.spacing?.[token]
176+
if (hit !== undefined) return hit
177+
if (/^\d+(?:\.\d+)?$/.test(token)) {
178+
const n = Number.parseFloat(token)
179+
if (Number.isFinite(n)) return `${n * 0.25}rem`
180+
}
181+
return token
182+
}
183+
171184
export const gapRule: UtilityRule = (parsed, config) => {
172185
if (parsed.utility === 'gap' && parsed.value) {
173-
return { gap: config.theme.spacing[parsed.value] || parsed.value } as Record<string, string>
186+
return { gap: resolveSpacing(config, parsed.value) } as Record<string, string>
174187
}
175188
if (parsed.utility === 'gap-x' && parsed.value) {
176-
return { 'column-gap': config.theme.spacing[parsed.value] || parsed.value } as Record<string, string>
189+
return { 'column-gap': resolveSpacing(config, parsed.value) } as Record<string, string>
177190
}
178191
if (parsed.utility === 'gap-y' && parsed.value) {
179-
return { 'row-gap': config.theme.spacing[parsed.value] || parsed.value } as Record<string, string>
192+
return { 'row-gap': resolveSpacing(config, parsed.value) } as Record<string, string>
180193
}
181194
}
182195

0 commit comments

Comments
 (0)