Skip to content

Commit 95c7417

Browse files
gabrielmfernKayleeWilliamsdependabot[bot]bukinoshitacubic-dev-ai[bot]
authored
feat(preview-server): dark mode switcher (#2589)
Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: Kaylee <65376239+KayleeWilliams@users.noreply.github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Bu Kinoshita <6929565+bukinoshita@users.noreply.github.com> Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com> Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
1 parent ffe1dbe commit 95c7417

File tree

9 files changed

+437
-32
lines changed

9 files changed

+437
-32
lines changed

.changeset/flat-masks-take.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
"@react-email/preview-server": minor
3+
"react-email": minor
4+
---
5+
6+
Dark mode switcher emulating email client color inversion

packages/preview-server/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
"@radix-ui/react-popover": "1.1.15",
2424
"@radix-ui/react-slot": "1.2.3",
2525
"@radix-ui/react-tabs": "1.1.13",
26+
"@radix-ui/react-toggle": "1.1.10",
2627
"@radix-ui/react-toggle-group": "1.1.11",
2728
"@radix-ui/react-tooltip": "1.2.8",
2829
"@react-email/tailwind": "workspace:2.0.0-canary.1",
@@ -33,6 +34,7 @@
3334
"@types/webpack": "5.28.5",
3435
"autoprefixer": "10.4.21",
3536
"clsx": "2.1.1",
37+
"colorjs.io": "0.5.2",
3638
"esbuild": "0.25.10",
3739
"framer-motion": "12.23.22",
3840
"json5": "2.2.3",
Lines changed: 306 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,306 @@
1+
import { Slot } from '@radix-ui/react-slot';
2+
import Color from 'colorjs.io';
3+
import type { ComponentProps } from 'react';
4+
5+
function* walkDom(element: Element): Generator<Element> {
6+
if (element.children.length > 0) {
7+
for (let i = 0; i < element.children.length; i++) {
8+
const child = element.children.item(i)!;
9+
yield child;
10+
yield* walkDom(child);
11+
}
12+
}
13+
}
14+
15+
function invertColor(colorString: string, mode: 'foreground' | 'background') {
16+
try {
17+
const color = new Color(colorString).to('lch');
18+
19+
if (mode === 'background') {
20+
// Keeps the same lightness if it's already dark. If it's bright inverts the lightness
21+
// - This is a characteristic from Outlook iOS
22+
// - Parcel does something very similar
23+
//
24+
// The 0.75 factor ensures that, even if the lightness is 100%, the final inverted is going to be 25%
25+
// - This is a characteristic from Apple Mail
26+
//
27+
// The two extra 50 terms are so that the lightness inversion doesn't become a step function
28+
if (color.lch.l! >= 50) {
29+
color.lch.l = 50 - (color.lch.l! - 50) * 0.75;
30+
}
31+
} else if (mode === 'foreground') {
32+
// The same as what's done for background, but inverts the check for brightness.
33+
// If the color is already bright, then it keeps the same. If the color is dark, then it inverts the brightness
34+
if (color.lch.l! < 50) {
35+
color.lch.l = 50 - (color.lch.l! - 50) * 0.75;
36+
}
37+
}
38+
39+
// While not exactly, I've found that email clients generally tend to reduce the chrome by 20%.
40+
// Apple Mail specifically reduces by exactly 20%, so we're closer to Apple Mail in this sense as well.
41+
color.lch.c! *= 0.8;
42+
43+
return color.toString();
44+
} catch (exception) {
45+
console.error(`couldn't invert color ${colorString}`, exception);
46+
return colorString;
47+
}
48+
}
49+
50+
const colorRegex = () =>
51+
/#[0-9a-f]{3,4}|#[0-9a-f]{6,8}|(rgb|rgba|hsl|hsv|oklab|oklch|lab|lch|hwb)\s*\(.*?\)/gi;
52+
const namedColors = {
53+
aliceblue: '#f0f8ff',
54+
antiquewhite: '#faebd7',
55+
aqua: '#00ffff',
56+
aquamarine: '#7fffd4',
57+
azure: '#f0ffff',
58+
beige: '#f5f5dc',
59+
bisque: '#ffe4c4',
60+
black: '#000000',
61+
blanchedalmond: '#ffebcd',
62+
blue: '#0000ff',
63+
blueviolet: '#8a2be2',
64+
brown: '#a52a2a',
65+
burlywood: '#deb887',
66+
cadetblue: '#5f9ea0',
67+
chartreuse: '#7fff00',
68+
chocolate: '#d2691e',
69+
coral: '#ff7f50',
70+
cornflowerblue: '#6495ed',
71+
cornsilk: '#fff8dc',
72+
crimson: '#dc143c',
73+
cyan: '#00ffff',
74+
darkblue: '#00008b',
75+
darkcyan: '#008b8b',
76+
darkgoldenrod: '#b8860b',
77+
darkgray: '#a9a9a9',
78+
darkgreen: '#006400',
79+
darkgrey: '#a9a9a9',
80+
darkkhaki: '#bdb76b',
81+
darkmagenta: '#8b008b',
82+
darkolivegreen: '#556b2f',
83+
darkorange: '#ff8c00',
84+
darkorchid: '#9932cc',
85+
darkred: '#8b0000',
86+
darksalmon: '#e9967a',
87+
darkseagreen: '#8fbc8f',
88+
darkslateblue: '#483d8b',
89+
darkslategray: '#2f4f4f',
90+
darkslategrey: '#2f4f4f',
91+
darkturquoise: '#00ced1',
92+
darkviolet: '#9400d3',
93+
deeppink: '#ff1493',
94+
deepskyblue: '#00bfff',
95+
dimgray: '#696969',
96+
dimgrey: '#696969',
97+
dodgerblue: '#1e90ff',
98+
firebrick: '#b22222',
99+
floralwhite: '#fffaf0',
100+
forestgreen: '#228b22',
101+
fuchsia: '#ff00ff',
102+
gainsboro: '#dcdcdc',
103+
ghostwhite: '#f8f8ff',
104+
gold: '#ffd700',
105+
goldenrod: '#daa520',
106+
gray: '#808080',
107+
green: '#008000',
108+
greenyellow: '#adff2f',
109+
grey: '#808080',
110+
honeydew: '#f0fff0',
111+
hotpink: '#ff69b4',
112+
indianred: '#cd5c5c',
113+
indigo: '#4b0082',
114+
ivory: '#fffff0',
115+
khaki: '#f0e68c',
116+
lavender: '#e6e6fa',
117+
lavenderblush: '#fff0f5',
118+
lawngreen: '#7cfc00',
119+
lemonchiffon: '#fffacd',
120+
lightblue: '#add8e6',
121+
lightcoral: '#f08080',
122+
lightcyan: '#e0ffff',
123+
lightgoldenrodyellow: '#fafad2',
124+
lightgray: '#d3d3d3',
125+
lightgreen: '#90ee90',
126+
lightgrey: '#d3d3d3',
127+
lightpink: '#ffb6c1',
128+
lightsalmon: '#ffa07a',
129+
lightseagreen: '#20b2aa',
130+
lightskyblue: '#87cefa',
131+
lightslategray: '#778899',
132+
lightslategrey: '#778899',
133+
lightsteelblue: '#b0c4de',
134+
lightyellow: '#ffffe0',
135+
lime: '#00ff00',
136+
limegreen: '#32cd32',
137+
linen: '#faf0e6',
138+
magenta: '#ff00ff',
139+
maroon: '#800000',
140+
mediumaquamarine: '#66cdaa',
141+
mediumblue: '#0000cd',
142+
mediumorchid: '#ba55d3',
143+
mediumpurple: '#9370db',
144+
mediumseagreen: '#3cb371',
145+
mediumslateblue: '#7b68ee',
146+
mediumspringgreen: '#00fa9a',
147+
mediumturquoise: '#48d1cc',
148+
mediumvioletred: '#c71585',
149+
midnightblue: '#191970',
150+
mintcream: '#f5fffa',
151+
mistyrose: '#ffe4e1',
152+
moccasin: '#ffe4b5',
153+
navajowhite: '#ffdead',
154+
navy: '#000080',
155+
oldlace: '#fdf5e6',
156+
olive: '#808000',
157+
olivedrab: '#6b8e23',
158+
orange: '#ffa500',
159+
orangered: '#ff4500',
160+
orchid: '#da70d6',
161+
palegoldenrod: '#eee8aa',
162+
palegreen: '#98fb98',
163+
paleturquoise: '#afeeee',
164+
palevioletred: '#db7093',
165+
papayawhip: '#ffefd5',
166+
peachpuff: '#ffdab9',
167+
peru: '#cd853f',
168+
pink: '#ffc0cb',
169+
plum: '#dda0dd',
170+
powderblue: '#b0e0e6',
171+
purple: '#800080',
172+
rebeccapurple: '#663399',
173+
red: '#ff0000',
174+
rosybrown: '#bc8f8f',
175+
royalblue: '#4169e1',
176+
saddlebrown: '#8b4513',
177+
salmon: '#fa8072',
178+
sandybrown: '#f4a460',
179+
seagreen: '#2e8b57',
180+
seashell: '#fff5ee',
181+
sienna: '#a0522d',
182+
silver: '#c0c0c0',
183+
skyblue: '#87ceeb',
184+
slateblue: '#6a5acd',
185+
slategray: '#708090',
186+
slategrey: '#708090',
187+
snow: '#fffafa',
188+
springgreen: '#00ff7f',
189+
steelblue: '#4682b4',
190+
tan: '#d2b48c',
191+
teal: '#008080',
192+
thistle: '#d8bfd8',
193+
tomato: '#ff6347',
194+
transparent: 'rgba(0,0,0,0)',
195+
turquoise: '#40e0d0',
196+
violet: '#ee82ee',
197+
wheat: '#f5deb3',
198+
white: '#ffffff',
199+
whitesmoke: '#f5f5f5',
200+
yellow: '#ffff00',
201+
yellowgreen: '#9acd32',
202+
};
203+
const namedColorRegex = new RegExp(
204+
`${Object.keys(namedColors).join('|')}`,
205+
'gi',
206+
);
207+
208+
function applyColorInversion(iframe: HTMLIFrameElement) {
209+
const { contentDocument, contentWindow } = iframe;
210+
if (!contentDocument || !contentWindow) return;
211+
212+
if (!contentDocument.body.style.color) {
213+
contentDocument.body.style.color = 'rgb(0, 0, 0)';
214+
}
215+
216+
for (const element of walkDom(contentDocument.documentElement)) {
217+
if (
218+
element instanceof
219+
(contentWindow as unknown as typeof globalThis).HTMLElement
220+
) {
221+
if (element.style.color) {
222+
element.style.color = element.style.color
223+
.replaceAll(colorRegex(), (color) => invertColor(color, 'foreground'))
224+
.replaceAll(namedColorRegex, (namedColor) =>
225+
invertColor(namedColors[namedColor], 'foreground'),
226+
);
227+
namedColorRegex.lastIndex = 0;
228+
}
229+
if (element.style.background) {
230+
element.style.background = element.style.background
231+
.replaceAll(colorRegex(), (color) => invertColor(color, 'background'))
232+
.replaceAll(namedColorRegex, (namedColor) =>
233+
invertColor(namedColors[namedColor], 'foreground'),
234+
);
235+
namedColorRegex.lastIndex = 0;
236+
}
237+
if (element.style.backgroundColor) {
238+
element.style.backgroundColor = element.style.backgroundColor
239+
.replaceAll(colorRegex(), (color) => invertColor(color, 'background'))
240+
.replaceAll(namedColorRegex, (namedColor) =>
241+
invertColor(namedColors[namedColor], 'foreground'),
242+
);
243+
namedColorRegex.lastIndex = 0;
244+
}
245+
if (element.style.borderColor) {
246+
element.style.borderColor = element.style.borderColor
247+
.replaceAll(colorRegex(), (color) => invertColor(color, 'background'))
248+
.replaceAll(namedColorRegex, (namedColor) =>
249+
invertColor(namedColors[namedColor], 'foreground'),
250+
);
251+
namedColorRegex.lastIndex = 0;
252+
}
253+
if (element.style.border) {
254+
element.style.border = element.style.border
255+
.replaceAll(colorRegex(), (color) => invertColor(color, 'background'))
256+
.replaceAll(namedColorRegex, (namedColor) =>
257+
invertColor(namedColors[namedColor], 'foreground'),
258+
);
259+
namedColorRegex.lastIndex = 0;
260+
}
261+
}
262+
}
263+
}
264+
265+
interface EmailFrameProps extends ComponentProps<'iframe'> {
266+
markup: string;
267+
width: number;
268+
height: number;
269+
darkMode: boolean;
270+
}
271+
272+
export function EmailFrame({
273+
markup,
274+
width,
275+
height,
276+
darkMode,
277+
...rest
278+
}: EmailFrameProps) {
279+
return (
280+
<Slot
281+
ref={(iframe: HTMLIFrameElement) => {
282+
if (!iframe) return;
283+
284+
if (darkMode) {
285+
applyColorInversion(iframe);
286+
}
287+
}}
288+
>
289+
<iframe
290+
srcDoc={markup}
291+
width={width}
292+
height={height}
293+
onLoad={(event) => {
294+
if (darkMode) {
295+
const iframe = event.currentTarget;
296+
applyColorInversion(iframe);
297+
}
298+
}}
299+
{...rest}
300+
// This key makes sure that the iframe itself remounts to the DOM when theme changes, so
301+
// that the color changes in dark mode can be easily undone when switching to light mode.
302+
key={darkMode ? 'iframe-inverted-colors' : 'iframe-normal-colors'}
303+
/>
304+
</Slot>
305+
);
306+
}

0 commit comments

Comments
 (0)