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
24 changes: 23 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,11 @@ CSS solution for light/dark/auto theme switcher for websites.
by subset/sunrise (all operating systems now have theme switching schedule).

[PostCSS] plugin to make switcher to force dark or light theme by copying styles
from media query to special class.
from media query or [light-dark()] to special class.

[PostCSS]: https://github.com/postcss/postcss
[FART]: https://css-tricks.com/flash-of-inaccurate-color-theme-fart/
[light-dark()]: https://developer.mozilla.org/en-US/docs/Web/CSS/color_value/light-dark

```css
/* Input CSS */
Expand All @@ -28,6 +29,10 @@ from media query to special class.
background: black
}
}

section {
background: light-dark(white, black);
}
```

```css
Expand All @@ -47,6 +52,23 @@ html:where(.is-dark) {
:where(html.is-dark) body {
background: black
}

@media (prefers-color-scheme: dark) {
:where(html:not(.is-light)) section {
background: black;
}
}
:where(html.is-dark) section {
background: black;
}
@media (prefers-color-scheme: light) {
:where(html:not(.is-dark)) section {
background: white;
}
}
:where(html.is-light) section {
background: white;
}
```

By default (without classes on `html`), website will use browser dark/light
Expand Down
58 changes: 58 additions & 0 deletions index.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
const PREFERS_COLOR_ONLY = /^\(\s*prefers-color-scheme\s*:\s*(dark|light)\s*\)$/
const PREFERS_COLOR = /\(\s*prefers-color-scheme\s*:\s*(dark|light)\s*\)/g
const LIGHT_DARK = /light-dark\(\s*(.+?)\s*,\s*(.+?)\s*\)/g
const STRING = /"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'/dg

function escapeRegExp(string) {
return string.replace(/[$()*+.?[\\\]^{|}-]/g, '\\$&')
Expand All @@ -9,6 +11,38 @@ function replaceAll(string, find, replace) {
return string.replace(new RegExp(escapeRegExp(find), 'g'), replace)
}

function addColorSchemeMedia(isDark, propValue, declaration, postcss) {
let mediaQuery = postcss.atRule({
name: 'media',
params: `(prefers-color-scheme:${isDark ? 'dark' : 'light'})`
})
mediaQuery.append(
postcss.rule({
nodes: [
postcss.decl({
prop: declaration.prop,
value: propValue
})
],
selector: declaration.parent.selector
})
)
declaration.parent.after(mediaQuery)
}

function replaceLightDark(isDark, declaration, stringBoundaries) {
return declaration.value.replaceAll(
LIGHT_DARK,
(match, lightColor, darkColor, offset) => {
let isInsideString = stringBoundaries.some(
boundary => offset > boundary[0] && offset < boundary[1]
)
if (isInsideString) return match
return isDark ? darkColor : lightColor
}
)
}

module.exports = (opts = {}) => {
let dark = opts.darkSelector || '.is-dark'
let light = opts.lightSelector || '.is-light'
Expand Down Expand Up @@ -109,6 +143,30 @@ module.exports = (opts = {}) => {
}
}
},
DeclarationExit: (declaration, { postcss }) => {
if (!declaration.value.includes('light-dark')) return

let stringBoundaries = []
let value = declaration.value.slice()
let match = STRING.exec(value)
while (match) {
stringBoundaries.push(match.indices[0])
match = STRING.exec(value)
}

let lightValue = replaceLightDark(false, declaration, stringBoundaries)
if (declaration.value === lightValue) return
let darkValue = replaceLightDark(true, declaration, stringBoundaries)

addColorSchemeMedia(false, lightValue, declaration, postcss)
addColorSchemeMedia(true, darkValue, declaration, postcss)

let parent = declaration.parent
declaration.remove()
if (parent.nodes.length === 0) {
parent.remove()
}
},
postcssPlugin: 'postcss-dark-theme-class'
}
}
Expand Down
218 changes: 218 additions & 0 deletions index.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -383,4 +383,222 @@ test('ignores already transformed rules - light scheme', () => {
)
})

test('transforms light-dark()', () => {
run(
`html {
border: 1px solid light-dark(white, black)
}`,
`@media (prefers-color-scheme:dark) {
html:where(:not(.is-light)) {
border: 1px solid black
}
}
html:where(.is-dark) {
border: 1px solid black
}
@media (prefers-color-scheme:light) {
html:where(:not(.is-dark)) {
border: 1px solid white
}
}
html:where(.is-light) {
border: 1px solid white
}`
)
})

test('does not transform light-dark() inside strings', () => {
run(
`html {
content: ' light-dark(white, black) \
light-dark(purple, yellow)
';
background: url("light-dark(red, blue).png");
quotes: "light-dark(white, black)" "light-dark(red, green)";
}`,
`html {
content: ' light-dark(white, black) \
light-dark(purple, yellow)
';
background: url("light-dark(red, blue).png");
quotes: "light-dark(white, black)" "light-dark(red, green)";
}`
)
})

test('transforms light-dark() and disables :where() of request', () => {
run(
`section {
color: light-dark(#888, #eee)
}`,
`@media (prefers-color-scheme:dark) {
html:not(.is-light) section {
color: #eee
}
}
html.is-dark section {
color: #eee
}
@media (prefers-color-scheme:light) {
html:not(.is-dark) section {
color: #888
}
}
html.is-light section {
color: #888
}`,
{ useWhere: false }
)
})

test('processes inner at-rules with light-dark()', () => {
run(
`@media (min-width: 500px) {
@media (print) {
a {
background-color: light-dark(white, black)
}
}
}`,
`@media (min-width: 500px) {
@media (print) {
@media (prefers-color-scheme:dark) {
:where(html:not(.is-light)) a {
background-color: black
}
}
:where(html.is-dark) a {
background-color: black
}
@media (prefers-color-scheme:light) {
:where(html:not(.is-dark)) a {
background-color: white
}
}
:where(html.is-light) a {
background-color: white
}
}
}`
)
})

test('ignores whitespaces for light-dark()', () => {
run(
`a { background: radial-gradient(light-dark( red , yellow ),
light-dark( white , black ),
rgb(30 144 255)); }
`,
`@media (prefers-color-scheme:dark) {
:where(html:not(.is-light)) a {
background: radial-gradient(yellow,
black,
rgb(30 144 255))
}
}
:where(html.is-dark) a {
background: radial-gradient(yellow,
black,
rgb(30 144 255))
}
@media (prefers-color-scheme:light) {
:where(html:not(.is-dark)) a {
background: radial-gradient(red,
white,
rgb(30 144 255))
}
}
:where(html.is-light) a {
background: radial-gradient(red,
white,
rgb(30 144 255))
}
`
)
})

test('changes root selectors for light-dark()', () => {
run(
`html, .s { --bg: light-dark(white, black) }
p { color: light-dark(red, blue) }
`,
`@media (prefers-color-scheme:dark) {
html:where(:not(.is-light)), .s:where(:not(.is-light)) {
--bg: black
}
}
html:where(.is-dark), .s:where(.is-dark) {
--bg: black
}
@media (prefers-color-scheme:light) {
html:where(:not(.is-dark)), .s:where(:not(.is-dark)) {
--bg: white
}
}
html:where(.is-light), .s:where(.is-light) {
--bg: white
}
@media (prefers-color-scheme:dark) {
:where(html:not(.is-light)) p,:where(.s:not(.is-light)) p {
color: blue
}
}
:where(html.is-dark) p,:where(.s.is-dark) p {
color: blue
}
@media (prefers-color-scheme:light) {
:where(html:not(.is-dark)) p,:where(.s:not(.is-dark)) p {
color: red
}
}
:where(html.is-light) p,:where(.s.is-light) p {
color: red
}
`,
{ rootSelector: ['html', ':root', '.s'] }
)
})

test('changes root selector for light-dark()', () => {
run(
`body { --bg: light-dark(white, black) }
p { color: light-dark(green, yellow) }
`,
`@media (prefers-color-scheme:dark) {
body:where(:not(.is-light)) {
--bg: black
}
}
body:where(.is-dark) {
--bg: black
}
@media (prefers-color-scheme:light) {
body:where(:not(.is-dark)) {
--bg: white
}
}
body:where(.is-light) {
--bg: white
}
@media (prefers-color-scheme:dark) {
:where(body:not(.is-light)) p {
color: yellow
}
}
:where(body.is-dark) p {
color: yellow
}
@media (prefers-color-scheme:light) {
:where(body:not(.is-dark)) p {
color: green
}
}
:where(body.is-light) p {
color: green
}
`,
{ rootSelector: 'body' }
)
})

test.run()