-
Notifications
You must be signed in to change notification settings - Fork 0
feat(preview-server): dark mode switcher #3
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: eval-pr-2589-target-1762530258180
Are you sure you want to change the base?
Changes from all commits
8aa8f8e
2e94623
0e7ae4c
0a7d210
ddd37a4
19de23f
9360e39
26ee920
3c8938f
733dc39
add4539
84b0727
358ac3a
52643e5
09145b0
8783f0a
4859be2
42e9499
27a7100
829880f
aad374b
0864ba8
5bc245d
e01c086
231ef55
3c0b355
a6e487f
15b4dbc
7ae751b
e28bb90
fa3ff5b
cbf1c23
f151725
f98a84a
7daccba
59accc2
d2ebc56
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,5 @@ | ||
| --- | ||
| "react-email": minor | ||
| --- | ||
|
|
||
| Theme switcher for email template | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,6 @@ | ||
| --- | ||
| "@react-email/preview-server": minor | ||
| "react-email": minor | ||
| --- | ||
|
|
||
| Dark mode switcher emulating email client color inversion |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -23,6 +23,7 @@ | |
| "@radix-ui/react-popover": "1.1.15", | ||
| "@radix-ui/react-slot": "1.2.3", | ||
| "@radix-ui/react-tabs": "1.1.13", | ||
| "@radix-ui/react-toggle": "1.1.10", | ||
| "@radix-ui/react-toggle-group": "1.1.11", | ||
| "@radix-ui/react-tooltip": "1.2.8", | ||
| "@types/node": "22.14.1", | ||
|
|
@@ -32,6 +33,7 @@ | |
| "@types/webpack": "5.28.5", | ||
| "autoprefixer": "10.4.21", | ||
| "clsx": "2.1.1", | ||
| "colorjs.io": "0.5.2", | ||
| "esbuild": "0.25.10", | ||
| "framer-motion": "12.23.22", | ||
| "json5": "2.2.3", | ||
|
|
@@ -59,6 +61,7 @@ | |
| "@react-email/components": "workspace:*", | ||
| "@types/babel__core": "7.20.5", | ||
| "@types/babel__traverse": "7.20.7", | ||
| "@types/color": "4.2.0", | ||
|
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Prompt for AI agents[internal] Confidence score: 9/10 [internal] Posted by: General AI Review Agent There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Reasoning: • Exa queries: "colorjs.io TypeScript definitions package 0.5.2", ""colorjs.io" TypeScript definitions", ""colorjs.io" "index.d.ts"" Prompt for AI agents[internal] Confidence score: 8/10 [internal] Posted by: General AI Review Agent
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Incorrect TypeScript types are installed for the new Prompt for AI agents[internal] Confidence score: 10/10 [internal] Posted by: System Design Agent
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Prompt for AI agents[internal] Confidence score: 9/10 [internal] Posted by: General AI Review Agent
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Prompt for AI agents[internal] Confidence score: 7/10 [internal] Posted by: General AI Review Agent
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Prompt for AI agents[internal] Confidence score: 8/10 [internal] Posted by: General AI Review Agent |
||
| "@types/fs-extra": "11.0.1", | ||
| "@types/mime-types": "2.1.4", | ||
| "@types/node": "22.10.2", | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,138 @@ | ||
| import { Slot } from '@radix-ui/react-slot'; | ||
| import Color from 'colorjs.io'; | ||
| import type { ComponentProps } from 'react'; | ||
|
|
||
| function* walkDom(element: Element): Generator<Element> { | ||
| if (element.children.length > 0) { | ||
| for (let i = 0; i < element.children.length; i++) { | ||
| const child = element.children.item(i)!; | ||
| yield child; | ||
| yield* walkDom(child); | ||
| } | ||
| } | ||
| } | ||
|
|
||
| function invertColor(colorString: string, mode: 'foreground' | 'background') { | ||
| const color = new Color(colorString).to('lch'); | ||
|
|
||
| if (mode === 'foreground') { | ||
| if (color.lch.l! < 50) { | ||
| color.lch.l = 100 - color.lch.l! * 0.75; | ||
| } | ||
| } else if (mode === 'background') { | ||
| if (color.lch.l! >= 50) { | ||
| color.lch.l = 100 - color.lch.l! * 0.75; | ||
| } | ||
| } | ||
|
|
||
| color.lch.c! *= 0.8; | ||
|
|
||
| return color.toString(); | ||
|
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. invertColor emits LCH color strings that Firefox treats as invalid, so dark-mode styles break Reasoning: • Exa queries: "colorjs.io Color.toString() output when color space is lch latest version", "CSS lch() and oklch() color functions browser support Firefox latest version", "caniuse CSS lch() oklch() color functions Firefox support", "caniuse CSS lch() color function browser support Firefox" Prompt for AI agents[internal] Confidence score: 7/10 [internal] Posted by: Functional Bugs Agent |
||
| } | ||
|
|
||
| const colorRegex = | ||
| /#[0-9a-fA-F]{3,4}|#[0-9a-fA-F]{6,8}|rgba?\(.*?\)|hsl\(.*?\)|hsv\(.*?\)|oklab\(.*?\)|oklch\(.*?\)/g; | ||
|
|
||
| function applyColorInversion(iframe: HTMLIFrameElement) { | ||
|
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The color inversion logic for dark mode emulation only targets inline styles, completely ignoring CSS rules defined in • Exa queries: "React key prop behavior when changed dynamic value unmount remount" Prompt for AI agents[internal] Confidence score: 10/10 [internal] Posted by: System Design Agent
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The dark mode emulation only inverts colors from inline styles, ignoring styles defined in Prompt for AI agents[internal] Confidence score: 10/10 [internal] Posted by: System Design Agent |
||
| const { contentDocument, contentWindow } = iframe; | ||
| if (!contentDocument || !contentWindow) return; | ||
|
|
||
| if (contentDocument.body.hasAttribute('inverted-colors')) return; | ||
|
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The color inversion logic does not account for emails that already have a dark mode implementation using standard methods like Reasoning: Prompt for AI agents[internal] Confidence score: 8/10 [internal] Posted by: System Design Agent |
||
|
|
||
| contentDocument.body.setAttribute('inverted-colors', ''); | ||
|
|
||
| if (!contentDocument.body.style.color) { | ||
| contentDocument.body.style.color = 'rgb(0, 0, 0)'; | ||
| } | ||
|
|
||
| for (const element of walkDom(contentDocument.documentElement)) { | ||
| if ( | ||
| element instanceof | ||
| (contentWindow as unknown as typeof globalThis).HTMLElement | ||
| ) { | ||
| if (element.style.color) { | ||
| element.style.color = element.style.color.replaceAll( | ||
| colorRegex, | ||
| (color) => invertColor(color, 'foreground'), | ||
| ); | ||
| colorRegex.lastIndex = 0; | ||
| } | ||
| if (element.style.background) { | ||
| element.style.background = element.style.background.replaceAll( | ||
| colorRegex, | ||
| (color) => invertColor(color, 'background'), | ||
| ); | ||
| colorRegex.lastIndex = 0; | ||
| } | ||
| if (element.style.backgroundColor) { | ||
| element.style.backgroundColor = | ||
| element.style.backgroundColor.replaceAll(colorRegex, (color) => | ||
| invertColor(color, 'background'), | ||
| ); | ||
| colorRegex.lastIndex = 0; | ||
| } | ||
| if (element.style.borderColor) { | ||
| element.style.borderColor = element.style.borderColor.replaceAll( | ||
| colorRegex, | ||
| (color) => invertColor(color, 'background'), | ||
| ); | ||
| colorRegex.lastIndex = 0; | ||
| } | ||
| if (element.style.border) { | ||
| element.style.border = element.style.border.replaceAll( | ||
| colorRegex, | ||
| (color) => invertColor(color, 'background'), | ||
| ); | ||
| colorRegex.lastIndex = 0; | ||
| } | ||
| } | ||
| } | ||
| } | ||
|
|
||
| interface EmailFrameProps extends ComponentProps<'iframe'> { | ||
| markup: string; | ||
| width: number; | ||
| height: number; | ||
| darkMode: boolean; | ||
| } | ||
|
|
||
| export function EmailFrame({ | ||
|
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Prompt for AI agents[internal] Confidence score: 9/10 [internal] Posted by: General AI Review Agent |
||
| markup, | ||
| width, | ||
| height, | ||
| darkMode, | ||
| ...rest | ||
| }: EmailFrameProps) { | ||
| return ( | ||
| <Slot | ||
| ref={(iframe: HTMLIFrameElement) => { | ||
|
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The callback ref adds a Prompt for AI agents[internal] Confidence score: 8/10 [internal] Posted by: General AI Review Agent |
||
| if (!iframe) return; | ||
|
|
||
| if (darkMode) { | ||
| applyColorInversion(iframe); | ||
| } | ||
|
|
||
| const handleLoad = () => { | ||
| if (darkMode) { | ||
| applyColorInversion(iframe); | ||
| } | ||
| }; | ||
|
|
||
| iframe.addEventListener('load', handleLoad); | ||
| return () => { | ||
|
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. React ignores the value returned from a ref callback, so this cleanup never runs. Because this inline ref is recreated each render, the old listener stays attached and a new one is added, leading to accumulating Prompt for AI agents[internal] Confidence score: 9/10 [internal] Posted by: General AI Review Agent |
||
| iframe.removeEventListener('load', handleLoad); | ||
| }; | ||
| }} | ||
| > | ||
| <iframe | ||
| srcDoc={markup} | ||
| width={width} | ||
| height={height} | ||
| {...rest} | ||
| // This key makes sure that the iframe itself remounts to the DOM when theme changes, so | ||
| // that the color changes in dark mode can be easily undone when switching to light mode. | ||
| key={darkMode ? 'iframe-inverted-colors' : 'iframe-normal-colors'} | ||
|
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Using a dynamic Reasoning: • Exa queries: "React key prop behavior when changed dynamic value unmount remount" Prompt for AI agents[internal] Confidence score: 10/10 [internal] Posted by: System Design Agent |
||
| /> | ||
| </Slot> | ||
| ); | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -15,10 +15,12 @@ import { Send } from '../../../components/send'; | |
| import { useToolbarState } from '../../../components/toolbar'; | ||
| import { Tooltip } from '../../../components/tooltip'; | ||
| import { ActiveViewToggleGroup } from '../../../components/topbar/active-view-toggle-group'; | ||
| import { EmulatedDarkModeToggle } from '../../../components/topbar/emulated-dark-mode-toggle'; | ||
| import { ViewSizeControls } from '../../../components/topbar/view-size-controls'; | ||
| import { usePreviewContext } from '../../../contexts/preview'; | ||
| import { useClampedState } from '../../../hooks/use-clamped-state'; | ||
| import { cn } from '../../../utils'; | ||
| import { EmailFrame } from './email-frame'; | ||
| import { ErrorOverlay } from './error-overlay'; | ||
|
|
||
| interface PreviewProps extends React.ComponentProps<'div'> { | ||
|
|
@@ -32,9 +34,20 @@ const Preview = ({ emailTitle, className, ...props }: PreviewProps) => { | |
| const pathname = usePathname(); | ||
| const searchParams = useSearchParams(); | ||
|
|
||
| const isDarkModeEnabled = searchParams.get('dark') !== null; | ||
| const activeView = searchParams.get('view') ?? 'preview'; | ||
| const activeLang = searchParams.get('lang') ?? 'jsx'; | ||
|
|
||
| const handleDarkModeChange = (enabled: boolean) => { | ||
| const params = new URLSearchParams(searchParams); | ||
| if (enabled) { | ||
| params.set('dark', ''); | ||
| } else { | ||
| params.delete('dark'); | ||
| } | ||
| router.push(`${pathname}?${params.toString()}${location.hash}`); | ||
| }; | ||
|
|
||
| const handleViewChange = (view: string) => { | ||
| const params = new URLSearchParams(searchParams); | ||
| params.set('view', view); | ||
|
|
@@ -83,26 +96,32 @@ const Preview = ({ emailTitle, className, ...props }: PreviewProps) => { | |
| return ( | ||
| <> | ||
| <Topbar emailTitle={emailTitle}> | ||
| {activeView === 'preview' && ( | ||
| <ViewSizeControls | ||
| setViewHeight={(height) => { | ||
| setHeight(height); | ||
| flushSync(() => { | ||
| handleSaveViewSize(); | ||
| }); | ||
| }} | ||
| setViewWidth={(width) => { | ||
| setWidth(width); | ||
| flushSync(() => { | ||
| handleSaveViewSize(); | ||
| }); | ||
| }} | ||
| viewHeight={height} | ||
| viewWidth={width} | ||
| minWidth={minWidth} | ||
| minHeight={minHeight} | ||
| /> | ||
| )} | ||
| {activeView === 'preview' ? ( | ||
| <> | ||
| <ViewSizeControls | ||
| setViewHeight={(height) => { | ||
| setHeight(height); | ||
| flushSync(() => { | ||
| handleSaveViewSize(); | ||
| }); | ||
| }} | ||
| setViewWidth={(width) => { | ||
| setWidth(width); | ||
| flushSync(() => { | ||
| handleSaveViewSize(); | ||
| }); | ||
| }} | ||
| viewHeight={height} | ||
| viewWidth={width} | ||
| minWidth={minWidth} | ||
| minHeight={minHeight} | ||
| /> | ||
| <EmulatedDarkModeToggle | ||
| enabled={isDarkModeEnabled} | ||
| onChange={(enabled) => handleDarkModeChange(enabled)} | ||
| /> | ||
| </> | ||
| ) : null} | ||
| <ActiveViewToggleGroup | ||
| activeView={activeView} | ||
| setActiveView={handleViewChange} | ||
|
|
@@ -165,19 +184,18 @@ const Preview = ({ emailTitle, className, ...props }: PreviewProps) => { | |
| }} | ||
| width={width} | ||
| > | ||
| <iframe | ||
| <EmailFrame | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. EmailFrame does not forward refs, so makeIframeDocumentBubbleEvents is no longer attached and resizing breaks when the cursor enters the iframe. • Exa queries: "React function components ref without forwardRef does ref work latest version", "react docs function components cannot be given refs forwardRef warning", "react forwardRef docs function components cannot be given refs warning" Prompt for AI agents[internal] Confidence score: 9/10 [internal] Posted by: Functional Bugs Agent
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Prompt for AI agents[internal] Confidence score: 9/10 [internal] Posted by: General AI Review Agent |
||
| className="max-h-full rounded-lg bg-white [color-scheme:auto]" | ||
| darkMode={isDarkModeEnabled} | ||
| markup={renderedEmailMetadata.markup} | ||
| width={width} | ||
| height={height} | ||
| title={emailTitle} | ||
| ref={(iframe) => { | ||
| if (iframe) { | ||
| return makeIframeDocumentBubbleEvents(iframe); | ||
| } | ||
| }} | ||
| srcDoc={renderedEmailMetadata.markup} | ||
| style={{ | ||
| width: `${width}px`, | ||
| height: `${height}px`, | ||
| if (!iframe) return; | ||
|
|
||
| return makeIframeDocumentBubbleEvents(iframe); | ||
| }} | ||
| title={emailTitle} | ||
| /> | ||
| </ResizableWrapper> | ||
| )} | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,16 @@ | ||
| import * as React from 'react'; | ||
| import type { IconElement, IconProps } from './icon-base'; | ||
| import { IconBase } from './icon-base'; | ||
|
|
||
| export const IconMoon = React.forwardRef<IconElement, Readonly<IconProps>>( | ||
| ({ ...props }, forwardedRef) => ( | ||
| <IconBase ref={forwardedRef} {...props}> | ||
| <path | ||
| fill="currentColor" | ||
| d="m17.75 4.09l-2.53 1.94l.91 3.06l-2.63-1.81l-2.63 1.81l.91-3.06l-2.53-1.94L12.44 4l1.06-3l1.06 3zm3.5 6.91l-1.64 1.25l.59 1.98l-1.7-1.17l-1.7 1.17l.59-1.98L15.75 11l2.06-.05L18.5 9l.69 1.95zm-2.28 4.95c.83-.08 1.72 1.1 1.19 1.85c-.32.45-.66.87-1.08 1.27C15.17 23 8.84 23 4.94 19.07c-3.91-3.9-3.91-10.24 0-14.14c.4-.4.82-.76 1.27-1.08c.75-.53 1.93.36 1.85 1.19c-.27 2.86.69 5.83 2.89 8.02a9.96 9.96 0 0 0 8.02 2.89m-1.64 2.02a12.08 12.08 0 0 1-7.8-3.47c-2.17-2.19-3.33-5-3.49-7.82c-2.81 3.14-2.7 7.96.31 10.98c3.02 3.01 7.84 3.12 10.98.31" | ||
| /> | ||
| </IconBase> | ||
| ), | ||
| ); | ||
|
|
||
| IconMoon.displayName = 'IconMoon'; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,16 @@ | ||
| import * as React from 'react'; | ||
| import type { IconElement, IconProps } from './icon-base'; | ||
| import { IconBase } from './icon-base'; | ||
|
|
||
| export const IconSun = React.forwardRef<IconElement, Readonly<IconProps>>( | ||
| ({ ...props }, forwardedRef) => ( | ||
| <IconBase ref={forwardedRef} {...props}> | ||
| <path | ||
| fill="currentColor" | ||
| d="m3.55 19.09l1.41 1.41l1.8-1.79l-1.42-1.42M12 6c-3.31 0-6 2.69-6 6s2.69 6 6 6s6-2.69 6-6c0-3.32-2.69-6-6-6m8 7h3v-2h-3m-2.76 7.71l1.8 1.79l1.41-1.41l-1.79-1.8M20.45 5l-1.41-1.4l-1.8 1.79l1.42 1.42M13 1h-2v3h2M6.76 5.39L4.96 3.6L3.55 5l1.79 1.81zM1 13h3v-2H1m12 9h-2v3h2" | ||
| /> | ||
| </IconBase> | ||
| ), | ||
| ); | ||
|
|
||
| IconSun.displayName = 'IconSun'; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,38 @@ | ||
| import * as Toggle from '@radix-ui/react-toggle'; | ||
| import { cn } from '../../utils'; | ||
| import { IconMoon } from '../icons/icon-moon'; | ||
| import { Tooltip } from '../tooltip'; | ||
|
|
||
| interface EmulatedDarkModeToggleProps { | ||
| enabled: boolean; | ||
| onChange: (enabled: boolean) => unknown; | ||
| } | ||
|
|
||
| export const EmulatedDarkModeToggle = ({ | ||
| enabled, | ||
| onChange, | ||
| }: EmulatedDarkModeToggleProps) => { | ||
| return ( | ||
| <Tooltip> | ||
| <Tooltip.Trigger asChild> | ||
| <Toggle.Root | ||
|
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
• Exa queries: "Radix UI React Toggle component API documentation pressed prop onPressedChange accessibility aria-pressed" Prompt for AI agents[internal] Confidence score: 8/10 [internal] Posted by: General AI Review Agent
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This toggle renders only an icon, so it needs an accessible label. Add an Prompt for AI agents[internal] Confidence score: 9/10 [internal] Posted by: General AI Review Agent
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Please bind the Radix toggle’s • Exa queries: "radix ui react toggle pressed state controlled" Prompt for AI agents[internal] Confidence score: 8/10 [internal] Posted by: General AI Review Agent
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Bind the Radix toggle to the controlled Prompt for AI agents[internal] Confidence score: 8/10 [internal] Posted by: General AI Review Agent |
||
| value="dark" | ||
| className={cn( | ||
| 'relative w-9 h-9 flex items-center justify-center border border-slate-6 text-sm rounded-lg transition duration-200 ease-in-out hover:text-slate-12', | ||
| { | ||
| 'text-slate-11': !enabled, | ||
| 'text-slate-12 bg-slate-4': enabled, | ||
| }, | ||
| )} | ||
| onClick={() => onChange(!enabled)} | ||
|
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Prompt for AI agents[internal] Confidence score: 8/10 [internal] Posted by: General AI Review Agent
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Prompt for AI agents[internal] Confidence score: 8/10 [internal] Posted by: General AI Review Agent |
||
| > | ||
| <IconMoon /> | ||
| </Toggle.Root> | ||
| </Tooltip.Trigger> | ||
| <Tooltip.Content> | ||
| When enabled, inverts colors in the preview emulating what email clients | ||
| do in dark mode. | ||
| </Tooltip.Content> | ||
| </Tooltip> | ||
| ); | ||
| }; | ||
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This changeset bumps
react-email, but the dark mode switcher work in this PR targets the preview server package. Without releasing@react-email/preview-server, the new feature will not ship to users. Please update the changeset to point at the preview server package.Prompt for AI agents
[internal] Confidence score: 6/10
[internal] Posted by: General AI Review Agent