Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,8 @@ themeFrom(220.25, { accent: 180, primary: 0 }, { model: 'v1' })

With `v2`, the preset computes lightness and chroma from `--chromatic-hue` on `:root`. If you override `--chromatic-hue` on a subtree, the hue still rotates there, but lightness and chroma stay the ones of the root. `v1` already behaves like this for chroma.

To use the colors in your own CSS, `--at-apply` / `@apply` and `theme()` of `transformerDirectives` both work, e.g. `background: theme('colors.primary.400')` or `theme('colors.primary.400 / 50%')`. The preset adds an explicit opacity to `theme()` calls of its colors that have none, so they do not leave the opacity placeholder behind.

### Development

```bash
Expand Down
82 changes: 82 additions & 0 deletions packages/preset/src/shared.theme.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import type { SourceCodeTransformer } from '@unocss/core'

import { createRequire } from 'node:module'

import { createGenerator, presetWind3, transformerDirectives } from 'unocss'
import { describe, expect, it } from 'vitest'

import { createPresetChromatic } from './shared'

// magic-string is what UnoCSS hands transformers; load the copy unocss itself uses.
const require = createRequire(import.meta.url)
// Typed through @unocss/core, so the test does not depend on magic-string's types being resolvable from here.
type TransformerCode = Parameters<SourceCodeTransformer['transform']>[0]
const magicString = createRequire(require.resolve('unocss'))('magic-string')
const MagicString = (magicString.default ?? magicString) as new (code: string) => TransformerCode

const presetChromatic = createPresetChromatic()

/** Runs the transformers the way `@unocss/vite` does: the `pre` pass, then the default one, then `post`. */
async function transform(code: string, id = 'src/styles/main.css', model: 'v1' | 'v2' = 'v2') {
const uno = await createGenerator({
presets: [presetWind3(), presetChromatic({ baseHue: 220.25, colors: { complementary: 180, primary: 0 }, model })],
transformers: [transformerDirectives({ applyVariable: ['--at-apply'] })],
})
const transformers: SourceCodeTransformer[] = uno.config.transformers ?? []

for (const enforce of ['pre', undefined, 'post'] as const) {
for (const t of transformers.filter(t => t.enforce === enforce)) {
if (t.idFilter && !t.idFilter(id))
continue
if (t.codeFilter && !t.codeFilter(code, id))
continue
const s = new MagicString(code)
await t.transform(s, id, { uno } as Parameters<SourceCodeTransformer['transform']>[2])
code = s.toString()
}
}
return code
}

describe('theme() with chromatic colours', () => {
it('resolves the alpha placeholder when no opacity is given', async () => {
const css = await transform(`.a { color: theme('colors.primary.400'); }`)

expect(css).not.toContain('%alpha')
expect(css).not.toContain('theme(')
expect(css).toContain('var(--chromatic-fit-primary-l-400)')
expect(css).toMatch(/\/ 1\);/)
})

it('handles double quotes, DEFAULT and every configured colour', async () => {
const css = await transform(`.a { color: theme("colors.complementary.200"); background: theme('colors.primary'); }`)

expect(css).not.toContain('%alpha')
expect(css).toContain('var(--chromatic-fit-complementary-c-200)')
expect(css).toContain('var(--chromatic-fit-primary-l-500)')
})

it('keeps an explicit opacity and a default value', async () => {
const css = await transform(`.a { color: theme('colors.primary.400 / 50%'); fill: theme('colors.primary.300', red); }`)

expect(css).toMatch(/\/\s+50%\);/)
expect(css).not.toContain('/ 1 / 50%')
expect(css).toContain('var(--chromatic-fit-primary-l-300)')
expect(css).not.toContain('%alpha')
})

it('works for the v1 model too', async () => {
const css = await transform(`.a { color: theme('colors.primary.400'); }`, undefined, 'v1')

expect(css).not.toContain('%alpha')
expect(css).toContain('var(--chromatic-chroma-400)')
})

it('leaves other colours and non-CSS files alone', async () => {
const other = await transform(`.a { color: theme('colors.red.500'); }`)
expect(other).toContain('.a { color: #ef4444; }')

const ts = `const c = "theme('colors.primary.400')"`
expect(await transform(ts, 'src/main.ts')).toBe(ts)
})
})
41 changes: 39 additions & 2 deletions packages/preset/src/shared.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import type { ChromaticModel, Shade as ChromaticShade, FittedBlock } from '@proj-airi/chromatic'
import type { UtilObject, VariantObject } from '@unocss/core'
import type { SourceCodeTransformer, UtilObject, VariantObject } from '@unocss/core'

import { fittedOklch, MODEL_V2 } from '@proj-airi/chromatic'
import { definePreset, LAYER_PREFLIGHTS } from '@unocss/core'
import { cssIdRE, definePreset, LAYER_PREFLIGHTS } from '@unocss/core'

export const VAR_HUE = '--chromatic-hue'
export const VAR_BRIGHTNESS = '--chromatic-bri'
Expand Down Expand Up @@ -186,6 +186,7 @@ export function createPresetChromatic(calledFromExtension = false) {
return colors
}, {} as Record<string, Record<Shade, string>>),
},
transformers: [createThemeFunctionAlphaTransformer(Object.keys(options.colors))],
variants: [
variantChromaticAdjustments(
options.modifierUtilityPrefixes,
Expand All @@ -197,6 +198,42 @@ export function createPresetChromatic(calledFromExtension = false) {
})
}

/**
* Gives `theme('colors.<chromatic colour>…')` calls without an opacity an explicit `/ 1`, so they resolve to a valid
* colour.
*
* NOTICE:
* Our theme colours carry an alpha placeholder (`/ %alpha`), which opacity utilities like `bg-primary-500/50` need.
* `transformThemeString` of `@unocss/rule-utils` (66.7.5, used by `@unocss/transformer-directives` for `theme()`)
* only resolves that placeholder when the call has an opacity (`theme('colors.primary.400 / 50%')`); without one it
* returns the raw string, leaving `%alpha` in the CSS, so browsers drop the declaration. `postprocess` cannot fix it,
* because `theme()` is expanded in the CSS source by the directives transformer, not by the generator.
* This transformer runs in the `pre` pass, before the directives transformer (default pass), and only rewrites calls
* that name a colour of this preset and have no opacity; `1` is the value UnoCSS uses for an omitted opacity
* elsewhere (`colorToString`).
* Remove when `transformThemeString` resolves alpha placeholders without an explicit opacity itself.
*/
export function createThemeFunctionAlphaTransformer(colorNames: string[]): SourceCodeTransformer {
const names = new Set(colorNames)
// theme('colors.<name>[.<shade>]') with no `/ <opacity>`; the key path cannot contain quotes, spaces or `/`.
const themeColorRE = /theme\(\s*(['"])colors\.([\w-]+)(?:\.[\w-]+)?\1/g

return {
codeFilter: code => code.includes('theme('),
enforce: 'pre',
idFilter: id => cssIdRE.test(id),
name: '@proj-airi/unocss-preset-chromatic:theme-alpha',
transform(code) {
for (const match of code.original.matchAll(themeColorRE)) {
if (!names.has(match[2]))
continue
// before the closing quote
code.appendLeft(match.index + match[0].length - 1, ' /1')
}
},
}
}

export function createVarBasedColorShades(hueOffset: number) {
return {
50: `color-mix(in srgb, oklch(${lightness(95)} ${chroma(`var(${VAR_CHROMA_SHADES[50]})`)} calc(var(${VAR_HUE}) + ${hueOffset}) / %alpha) 30%, oklch(100% 0 360 / %alpha))`,
Expand Down
Loading