import fs from 'node:fs/promises'
import path from 'node:path'
import { describe, expect, test } from 'vitest'
import { compile } from '.'
import plugin from './plugin'
import { compileCss, optimizeCss } from './test-utils/run'
const css = String.raw
describe('theme function', () => {
describe('in declaration values', () => {
describe('without fallback values', () => {
test('theme(colors.red.500)', async () => {
expect(
await compileCss(css`
@theme {
--color-red-500: #f00;
}
.red {
color: theme(colors.red.500);
}
`),
).toMatchInlineSnapshot(`
":root {
--color-red-500: red;
}
.red {
color: red;
}"
`)
})
test("theme('colors.red.500')", async () => {
expect(
await compileCss(css`
@theme {
--color-red-500: #f00;
}
.red {
color: theme('colors.red.500');
}
`),
).toMatchInlineSnapshot(`
":root {
--color-red-500: red;
}
.red {
color: red;
}"
`)
})
test('theme(colors[red]500)', async () => {
expect(
await compileCss(css`
@theme {
--color-red-500: #f00;
}
.red {
color: theme(colors[red]500);
}
`),
).toMatchInlineSnapshot(`
":root {
--color-red-500: red;
}
.red {
color: red;
}"
`)
})
test('theme(colors[red].500)', async () => {
expect(
await compileCss(css`
@theme {
--color-red-500: #f00;
}
.red {
color: theme(colors[red].500);
}
`),
).toMatchInlineSnapshot(`
":root {
--color-red-500: red;
}
.red {
color: red;
}"
`)
})
test('theme(colors[red][500])', async () => {
expect(
await compileCss(css`
@theme {
--color-red-500: #f00;
}
.red {
color: theme(colors[red][500]);
}
`),
).toMatchInlineSnapshot(`
":root {
--color-red-500: red;
}
.red {
color: red;
}"
`)
})
test('theme(colors[red].[500])', async () => {
expect(
await compileCss(css`
@theme {
--color-red-500: #f00;
}
.red {
color: theme(colors[red].[500]);
}
`),
).toMatchInlineSnapshot(`
":root {
--color-red-500: red;
}
.red {
color: red;
}"
`)
})
test('theme(colors.red.500/75%)', async () => {
expect(
await compileCss(css`
@theme {
--color-red-500: #f00;
}
.red {
color: theme(colors.red.500/75%);
}
`),
).toMatchInlineSnapshot(`
":root {
--color-red-500: red;
}
.red {
color: oklch(62.7955% .257683 29.2339 / .75);
}"
`)
})
test('theme(colors.red.500 / 75%)', async () => {
expect(
await compileCss(css`
@theme {
--color-red-500: #f00;
}
.red {
color: theme(colors.red.500 / 75%);
}
`),
).toMatchInlineSnapshot(`
":root {
--color-red-500: red;
}
.red {
color: oklch(62.7955% .257683 29.2339 / .75);
}"
`)
})
test("theme('colors.red.500 / 75%')", async () => {
expect(
await compileCss(css`
@theme {
--color-red-500: #f00;
}
.red {
color: theme('colors.red.500 / 75%');
}
`),
).toMatchInlineSnapshot(`
":root {
--color-red-500: red;
}
.red {
color: oklch(62.7955% .257683 29.2339 / .75);
}"
`)
})
test('theme(colors.red.500/var(--opacity))', async () => {
expect(
await compileCss(css`
@theme {
--color-red-500: #f00;
}
.red {
color: theme(colors.red.500/var(--opacity));
}
`),
).toMatchInlineSnapshot(`
":root {
--color-red-500: red;
}
.red {
color: color-mix(in oklch, red calc(var(--opacity) * 100%), transparent);
}"
`)
})
test('theme(colors.red.500/var(--opacity,50%))', async () => {
expect(
await compileCss(css`
@theme {
--color-red-500: #f00;
}
.red {
color: theme(colors.red.500/var(--opacity,50%));
}
`),
).toMatchInlineSnapshot(`
":root {
--color-red-500: red;
}
.red {
color: color-mix(in oklch, red calc(var(--opacity, 50%) * 100%), transparent);
}"
`)
})
test('theme(spacing.12)', async () => {
expect(
await compileCss(css`
@theme {
--spacing-12: 3rem;
}
.space-on-the-left {
margin-left: theme(spacing.12);
}
`),
).toMatchInlineSnapshot(`
":root {
--spacing-12: 3rem;
}
.space-on-the-left {
margin-left: 3rem;
}"
`)
})
test('theme(spacing[2.5])', async () => {
expect(
await compileCss(css`
@theme {
--spacing-2_5: 0.625rem;
}
.space-on-the-left {
margin-left: theme(spacing[2.5]);
}
`),
).toMatchInlineSnapshot(`
":root {
--spacing-2_5: .625rem;
}
.space-on-the-left {
margin-left: .625rem;
}"
`)
})
test('calc(100vh - theme(spacing[2.5]))', async () => {
expect(
await compileCss(css`
@theme {
--spacing-2_5: 0.625rem;
}
.space-on-the-left {
margin-left: calc(100vh - theme(spacing[2.5]));
}
`),
).toMatchInlineSnapshot(`
":root {
--spacing-2_5: .625rem;
}
.space-on-the-left {
margin-left: calc(100vh - .625rem);
}"
`)
})
test('theme(borderRadius.lg)', async () => {
expect(
await compileCss(css`
@theme {
--radius-lg: 0.5rem;
}
.radius {
border-radius: theme(borderRadius.lg);
}
`),
).toMatchInlineSnapshot(`
":root {
--radius-lg: .5rem;
}
.radius {
border-radius: .5rem;
}"
`)
})
describe('for v3 compatibility', () => {
test('theme(blur.DEFAULT)', async () => {
expect(
await compileCss(css`
@theme {
--blur: 8px;
}
.default-blur {
filter: blur(theme(blur.DEFAULT));
}
`),
).toMatchInlineSnapshot(`
":root {
--blur: 8px;
}
.default-blur {
filter: blur(8px);
}"
`)
})
test('theme(fontSize.xs[1].lineHeight)', async () => {
expect(
await compileCss(css`
@theme {
--font-size-xs: 1337.75rem;
--font-size-xs--line-height: 1337rem;
}
.text {
font-size: theme(fontSize.xs);
line-height: theme(fontSize.xs[1].lineHeight);
}
`),
).toMatchInlineSnapshot(`
":root {
--font-size-xs: 1337.75rem;
--font-size-xs--line-height: 1337rem;
}
.text {
font-size: 1337.75rem;
line-height: 1337rem;
}"
`)
})
test('theme(fontFamily.sans) (css)', async () => {
expect(
await compileCss(css`
@theme default reference {
--font-family-sans: ui-sans-serif, system-ui, sans-serif, Apple Color Emoji,
Segoe UI Emoji, Segoe UI Symbol, Noto Color Emoji;
}
.fam {
font-family: theme(fontFamily.sans);
}
`),
).toMatchInlineSnapshot(`
".fam {
font-family: ui-sans-serif, system-ui, sans-serif, Apple Color Emoji, Segoe UI Emoji, Segoe UI Symbol, Noto Color Emoji;
}"
`)
})
test('theme(fontFamily.sans) (config)', async () => {
let compiled = await compile(
css`
@config "./my-config.js";
.fam {
font-family: theme(fontFamily.sans);
}
`,
{
loadModule: async () => ({ module: {}, base: '/root' }),
},
)
expect(optimizeCss(compiled.build([])).trim()).toMatchInlineSnapshot(`
".fam {
font-family: ui-sans-serif, system-ui, sans-serif, Apple Color Emoji, Segoe UI Emoji, Segoe UI Symbol, Noto Color Emoji;
}"
`)
})
})
test('theme(colors.unknown.500)', async () =>
expect(() =>
compileCss(css`
.red {
color: theme(colors.unknown.500);
}
`),
).rejects.toThrowErrorMatchingInlineSnapshot(
`[Error: Could not resolve value for theme function: \`theme(colors.unknown.500)\`. Consider checking if the path is correct or provide a fallback value to silence this error.]`,
))
})
describe('with default values', () => {
test('theme(colors.red.unknown / 50%, #f00)', async () => {
expect(
await compileCss(css`
.red {
color: theme(colors.red.unknown / 50%, #f00);
}
`),
).toMatchInlineSnapshot(`
".red {
color: red;
}"
`)
})
test('theme(colors.red.unknown / 75%, theme(colors.red.500 / 25%))', async () => {
expect(
await compileCss(css`
@theme {
--color-red-500: #f00;
}
.red {
color: theme(colors.red.unknown / 75%, theme(colors.red.500 / 25%));
}
`),
).toMatchInlineSnapshot(`
":root {
--color-red-500: red;
}
.red {
color: oklch(62.7955% .257683 29.2339 / .25);
}"
`)
})
test('theme(fontFamily.unknown, Helvetica Neue, Helvetica, sans-serif)', async () => {
expect(
await compileCss(css`
.fam {
font-family: theme(fontFamily.unknown, Helvetica Neue, Helvetica, sans-serif);
}
`),
).toMatchInlineSnapshot(`
".fam {
font-family: Helvetica Neue, Helvetica, sans-serif;
}"
`)
})
})
describe('recursive theme()', () => {
test('can references theme inside @theme', async () => {
expect(
await compileCss(css`
@theme {
--color-red-500: #f00;
--color-foo: theme(colors.red.500);
}
.red {
color: theme(colors.foo);
}
`),
).toMatchInlineSnapshot(`
":root {
--color-red-500: red;
--color-foo: red;
}
.red {
color: red;
}"
`)
})
test('can references theme inside @theme and stacking opacity', async () => {
expect(
await compileCss(css`
@theme {
--color-red-500: #f00;
--color-foo: theme(colors.red.500 / 50%);
}
.red {
color: theme(colors.foo / 50%);
}
`),
).toMatchInlineSnapshot(`
":root {
--color-red-500: red;
--color-foo: oklch(62.7955% .257683 29.2339 / .5);
}
.red {
color: oklch(62.7955% .257683 29.2339 / .25);
}"
`)
})
})
describe('with CSS variable syntax', () => {
test('theme(--color-red-500)', async () => {
expect(
await compileCss(css`
@theme {
--color-red-500: #f00;
}
.red {
color: theme(--color-red-500);
}
`),
).toMatchInlineSnapshot(`
":root {
--color-red-500: red;
}
.red {
color: red;
}"
`)
})
test('theme(--color-red-500 / 50%)', async () => {
expect(
await compileCss(css`
@theme {
--color-red-500: #f00;
}
.red {
color: theme(--color-red-500 / 50%);
}
`),
).toMatchInlineSnapshot(`
":root {
--color-red-500: red;
}
.red {
color: oklch(62.7955% .257683 29.2339 / .5);
}"
`)
})
test('theme("--color-red-500")', async () => {
expect(
await compileCss(css`
@theme {
--color-red-500: #f00;
}
.red {
color: theme(--color-red-500);
}
`),
).toMatchInlineSnapshot(`
":root {
--color-red-500: red;
}
.red {
color: red;
}"
`)
})
})
describe('resolving --default lookups', () => {
test('theme(blur.DEFAULT)', async () => {
expect(
await compileCss(css`
@theme {
--blur: 8px;
}
.blur {
filter: blur(theme(blur));
}
`),
).toMatchInlineSnapshot(`
":root {
--blur: 8px;
}
.blur {
filter: blur(8px);
}"
`)
})
})
describe('with default theme', () => {
test.each([
[
'fontFamily.sans',
'ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"',
],
['width.xs', '20rem'],
['transition.timing.function.in.out', 'cubic-bezier(.4, 0, .2, 1)'],
['backgroundColor.red.500', 'oklch(.637 .237 25.331)'],
])('theme(%s) → %s', async (value, result) => {
let defaultTheme = await fs.readFile(path.join(__dirname, '..', 'theme.css'), 'utf8')
let compiled = await compileCss(css`
${defaultTheme}
.custom {
--custom-value: theme(${value});
}
`)
let startOfCustomClass = compiled.indexOf('.custom {\n')
let endOfCustomClass = compiled.indexOf('}', startOfCustomClass)
let customClassRule = compiled
.slice(startOfCustomClass + '.custom {\n'.length, endOfCustomClass)
.replace('--custom-value:', '')
.trim()
.slice(0, -1)
expect(customClassRule).toBe(result)
})
})
})
describe('in candidates', () => {
test('sm:[--color:theme(colors.red[500])]', async () => {
expect(
await compileCss(
css`
@tailwind utilities;
@theme {
--breakpoint-sm: 40rem;
--color-red-500: #f00;
}
`,
['sm:[--color:theme(colors.red[500])]'],
),
).toMatchInlineSnapshot(`
"@media (width >= 40rem) {
.sm\\:\\[--color\\:theme\\(colors\\.red\\[500\\]\\)\\] {
--color: red;
}
}
:root {
--breakpoint-sm: 40rem;
--color-red-500: red;
}"
`)
})
test("values that don't exist don't produce candidates", async () => {
expect(
await compileCss(
css`
@tailwind utilities;
@theme reference {
--radius-sm: 2rem;
}
`,
[
'rounded-[theme(--radius-sm)]',
'rounded-[theme(i.do.not.exist)]',
'rounded-[theme(--i-do-not-exist)]',
],
),
).toMatchInlineSnapshot(`
".rounded-\\[theme\\(--radius-sm\\)\\] {
border-radius: 2rem;
}"
`)
expect(
await compileCss(
css`
@tailwind utilities;
@theme reference {
--radius-sm: 2rem;
}
`,
['rounded-[theme(i.do.not.exist)]', 'rounded-[theme(--i-do-not-exist)]'],
),
).toEqual('')
})
})
describe('in @media queries', () => {
test('@media (min-width:theme(breakpoint.md)) and (max-width: theme(--breakpoint-lg))', async () => {
expect(
await compileCss(css`
@theme {
--breakpoint-md: 48rem;
--breakpoint-lg: 64rem;
}
@media (min-width:theme(breakpoint.md)) and (max-width: theme(--breakpoint-lg)) {
.red {
color: red;
}
}
`),
).toMatchInlineSnapshot(`
":root {
--breakpoint-md: 48rem;
--breakpoint-lg: 64rem;
}
@media (width >= 48rem) and (width <= 64rem) {
.red {
color: red;
}
}"
`)
})
test('@media (width >= theme(breakpoint.md)) and (width<theme(--breakpoint-lg))', async () => {
expect(
await compileCss(css`
@theme {
--breakpoint-md: 48rem;
--breakpoint-lg: 64rem;
}
@media (width >= theme(breakpoint.md)) and (width<theme(--breakpoint-lg)) {
.red {
color: red;
}
}
`),
).toMatchInlineSnapshot(`
":root {
--breakpoint-md: 48rem;
--breakpoint-lg: 64rem;
}
@media (width >= 48rem) and (width < 64rem) {
.red {
color: red;
}
}"
`)
})
})
test('@custom-media --my-media (min-width: theme(breakpoint.md))', async () => {
expect(
await compileCss(css`
@theme {
--breakpoint-md: 48rem;
}
@custom-media --my-media (min-width: theme(breakpoint.md));
@media (--my-media) {
.red {
color: red;
}
}
`),
).toMatchInlineSnapshot(`
":root {
--breakpoint-md: 48rem;
}
@media (width >= 48rem) {
.red {
color: red;
}
}"
`)
})
test('@container (width > theme(breakpoint.md))', async () => {
expect(
await compileCss(css`
@theme {
--breakpoint-md: 48rem;
}
@container (width > theme(breakpoint.md)) {
.red {
color: red;
}
}
`),
).toMatchInlineSnapshot(`
":root {
--breakpoint-md: 48rem;
}
@container (width > 48rem) {
.red {
color: red;
}
}"
`)
})
test('@supports (text-stroke: theme(--font-size-xs))', async () => {
expect(
await compileCss(css`
@theme {
--font-size-xs: 0.75rem;
}
@supports (text-stroke: theme(--font-size-xs)) {
.red {
color: red;
}
}
`),
).toMatchInlineSnapshot(`
":root {
--font-size-xs: .75rem;
}
@supports (text-stroke: 0.75rem) {
.red {
color: red;
}
}"
`)
})
})
describe('in plugins', () => {
test('CSS theme functions in plugins are properly evaluated', async () => {
let compiled = await compile(
css`
@layer base, utilities;
@plugin "my-plugin";
@theme reference {
--color-red: oklch(62% 0.25 30);
--color-orange: oklch(79% 0.17 70);
--color-blue: oklch(45% 0.31 264);
--color-pink: oklch(87% 0.07 7);
}
@layer utilities {
@tailwind utilities;
}
`,
{
async loadModule() {
return {
module: plugin(({ addBase, addUtilities }) => {
addBase({
'.my-base-rule': {
color: 'theme(colors.red)',
'outline-color': 'theme(colors.orange / 15%)',
'background-color': 'theme(--color-blue)',
'border-color': 'theme(--color-pink / 10%)',
},
})
addUtilities({
'.my-utility': {
color: 'theme(colors.red)',
},
})
}),
base: '/root',
}
},
},
)
expect(optimizeCss(compiled.build(['my-utility'])).trim()).toMatchInlineSnapshot(`
"@layer base {
.my-base-rule {
color: oklch(62% .25 30);
background-color: oklch(45% .31 264);
border-color: oklch(87% .07 7 / .1);
outline-color: oklch(79% .17 70 / .15);
}
}
@layer utilities {
.my-utility {
color: oklch(62% .25 30);
}
}"
`)
})
})
describe('in JS config files', () => {
test('CSS theme functions in config files are properly evaluated', async () => {
let compiled = await compile(
css`
@layer base, utilities;
@config "./my-config.js";
@theme reference {
--color-red: red;
--color-orange: orange;
}
@layer utilities {
@tailwind utilities;
}
`,
{
loadModule: async () => ({
module: {
theme: {
extend: {
colors: {
primary: 'theme(colors.red)',
secondary: 'theme(--color-orange)',
},
},
},
plugins: [
plugin(({ addBase, addUtilities }) => {
addBase({
'.my-base-rule': {
background: 'theme(colors.primary)',
color: 'theme(colors.secondary)',
},
})
addUtilities({
'.my-utility': {
color: 'theme(colors.red)',
},
})
}),
],
},
base: '/root',
}),
},
)
expect(optimizeCss(compiled.build(['my-utility'])).trim()).toMatchInlineSnapshot(`
"@layer base {
.my-base-rule {
color: orange;
background: red;
}
}
@layer utilities {
.my-utility {
color: red;
}
}"
`)
})
})
test('replaces CSS theme() function with values inside imported stylesheets', async () => {
expect(
await compileCss(
css`
@theme {
--color-red-500: #f00;
}
@import './bar.css';
`,
[],
{
async loadStylesheet() {
return {
base: '/bar.css',
content: css`
.red {
color: theme(colors.red.500);
}
`,
}
},
},
),
).toMatchInlineSnapshot(`
":root {
--color-red-500: red;
}
.red {
color: red;
}"
`)
})
test('resolves paths ending with a 1', async () => {
expect(
await compileCss(
css`
@theme {
--spacing-1: 0.25rem;
}
.foo {
margin: theme(spacing.1);
}
`,
[],
),
).toMatchInlineSnapshot(`
":root {
--spacing-1: .25rem;
}
.foo {
margin: .25rem;
}"
`)
})
test('upgrades to a full JS compat theme lookup if a value can not be mapped to a CSS variable', async () => {
expect(
await compileCss(
css`
.semi {
font-weight: theme(fontWeight.semibold);
}
`,
[],
),
).toMatchInlineSnapshot(`
".semi {
font-weight: 600;
}"
`)
})