diff --git a/e2e/playwright/storageStates.ts b/e2e/playwright/storageStates.ts index 61259fbcb..58e0cf5e3 100644 --- a/e2e/playwright/storageStates.ts +++ b/e2e/playwright/storageStates.ts @@ -48,6 +48,11 @@ export const TEST_SETTINGS_ONBOARDING_START = { app: { ...TEST_SETTINGS.app, onboardingStatus: '' }, } satisfies Partial +export const TEST_SETTINGS_DEFAULT_THEME = { + ...TEST_SETTINGS, + app: { ...TEST_SETTINGS.app, theme: Themes.System }, +} satisfies Partial + export const TEST_SETTINGS_CORRUPTED = { app: { theme: Themes.Dark, diff --git a/e2e/playwright/testing-settings.spec.ts b/e2e/playwright/testing-settings.spec.ts index d2ff95508..9b4b49f32 100644 --- a/e2e/playwright/testing-settings.spec.ts +++ b/e2e/playwright/testing-settings.spec.ts @@ -13,6 +13,7 @@ import { TEST_SETTINGS_KEY, TEST_SETTINGS_CORRUPTED, TEST_SETTINGS, + TEST_SETTINGS_DEFAULT_THEME, } from './storageStates' import * as TOML from '@iarna/toml' @@ -656,6 +657,60 @@ const extrude001 = extrude(5, sketch001) }) }) + test(`Changing system theme preferences (via media query) should update UI and stream`, async ({ + page, + }) => { + // Override the settings so that the theme is set to `system` + await page.addInitScript( + ({ settingsKey, settings }) => { + localStorage.setItem(settingsKey, settings) + }, + { + settingsKey: TEST_SETTINGS_KEY, + settings: TOML.stringify({ + settings: TEST_SETTINGS_DEFAULT_THEME, + }), + } + ) + const u = await getUtils(page) + + // Selectors and constants + const darkBackgroundCss = 'oklch(0.3012 0 264.5)' + const lightBackgroundCss = 'oklch(0.9911 0 264.5)' + const darkBackgroundColor: [number, number, number] = [27, 27, 27] + const lightBackgroundColor: [number, number, number] = [245, 245, 245] + const streamBackgroundPixelIsColor = async ( + color: [number, number, number] + ) => { + return u.getGreatestPixDiff({ x: 1000, y: 200 }, color) + } + const toolbar = page.locator('menu').filter({ hasText: 'Start Sketch' }) + + await test.step(`Test setup`, async () => { + await page.setViewportSize({ width: 1200, height: 500 }) + await u.waitForAuthSkipAppStart() + await expect(toolbar).toBeVisible() + }) + + await test.step(`Check the background color is light before`, async () => { + await expect(toolbar).toHaveCSS('background-color', lightBackgroundCss) + await expect + .poll(() => streamBackgroundPixelIsColor(lightBackgroundColor)) + .toBeLessThan(15) + }) + + await test.step(`Change media query preference to dark, emulating dusk with system theme`, async () => { + await page.emulateMedia({ colorScheme: 'dark' }) + }) + + await test.step(`Check the background color is dark after`, async () => { + await expect(toolbar).toHaveCSS('background-color', darkBackgroundCss) + await expect + .poll(() => streamBackgroundPixelIsColor(darkBackgroundColor)) + .toBeLessThan(15) + }) + }) + test(`Turning off "Show debug panel" with debug panel open leaves no phantom panel`, async ({ page, }) => { diff --git a/src/components/SettingsAuthProvider.tsx b/src/components/SettingsAuthProvider.tsx index 10bc6f96a..35c602d21 100644 --- a/src/components/SettingsAuthProvider.tsx +++ b/src/components/SettingsAuthProvider.tsx @@ -8,7 +8,7 @@ import useStateMachineCommands from '../hooks/useStateMachineCommands' import { settingsMachine } from 'machines/settingsMachine' import { toast } from 'react-hot-toast' import { - getThemeColorForEngine, + darkModeMatcher, getOppositeTheme, setThemeClass, Themes, @@ -34,6 +34,7 @@ import { useCommandsContext } from 'hooks/useCommandsContext' import { Command } from 'lib/commandTypes' import { BaseUnit } from 'lib/settings/settingsTypes' import { saveSettings } from 'lib/settings/settingsUtils' +import { reportRejection } from 'lib/trap' type MachineContext = { state: StateFrom @@ -113,26 +114,9 @@ export const SettingsAuthProviderBase = ({ sceneInfra.baseUnit = newBaseUnit }, setEngineTheme: ({ context }) => { - // eslint-disable-next-line @typescript-eslint/no-floating-promises - engineCommandManager.sendSceneCommand({ - cmd_id: uuidv4(), - type: 'modeling_cmd_req', - cmd: { - type: 'set_background_color', - color: getThemeColorForEngine(context.app.theme.current), - }, - }) - - const opposingTheme = getOppositeTheme(context.app.theme.current) - // eslint-disable-next-line @typescript-eslint/no-floating-promises - engineCommandManager.sendSceneCommand({ - cmd_id: uuidv4(), - type: 'modeling_cmd_req', - cmd: { - type: 'set_default_system_properties', - color: getThemeColorForEngine(opposingTheme), - }, - }) + engineCommandManager + .setTheme(context.app.theme.current) + .catch(reportRejection) }, setEngineScaleGridVisibility: ({ context }) => { engineCommandManager.setScaleGridVisibility( @@ -261,14 +245,13 @@ export const SettingsAuthProviderBase = ({ // because there doesn't seem to be a good way to listen to // events outside of the machine that also depend on the machine's context useEffect(() => { - const matcher = window.matchMedia('(prefers-color-scheme: dark)') const listener = (e: MediaQueryListEvent) => { if (settingsState.context.app.theme.current !== 'system') return setThemeClass(e.matches ? Themes.Dark : Themes.Light) } - matcher.addEventListener('change', listener) - return () => matcher.removeEventListener('change', listener) + darkModeMatcher?.addEventListener('change', listener) + return () => darkModeMatcher?.removeEventListener('change', listener) }, [settingsState.context]) /** diff --git a/src/lang/std/engineConnection.ts b/src/lang/std/engineConnection.ts index be88be02e..e58b6653a 100644 --- a/src/lang/std/engineConnection.ts +++ b/src/lang/std/engineConnection.ts @@ -3,7 +3,12 @@ import { VITE_KC_API_WS_MODELING_URL, VITE_KC_DEV_TOKEN } from 'env' import { Models } from '@kittycad/lib' import { exportSave } from 'lib/exportSave' import { deferExecution, isOverlap, uuidv4 } from 'lib/utils' -import { Themes, getThemeColorForEngine, getOppositeTheme } from 'lib/theme' +import { + Themes, + getThemeColorForEngine, + getOppositeTheme, + darkModeMatcher, +} from 'lib/theme' import { DefaultPlanes } from 'wasm-lib/kcl/bindings/DefaultPlanes' import { ArtifactGraph, @@ -1393,6 +1398,9 @@ export class EngineCommandManager extends EventTarget { private onEngineConnectionOpened = () => {} private onEngineConnectionClosed = () => {} + private onDarkThemeMediaQueryChange = (e: MediaQueryListEvent) => { + this.setTheme(e.matches ? Themes.Dark : Themes.Light).catch(reportRejection) + } private onEngineConnectionStarted = ({ detail: engineConnection }: any) => {} private onEngineConnectionNewTrack = ({ detail, @@ -1479,30 +1487,13 @@ export class EngineCommandManager extends EventTarget { // eslint-disable-next-line @typescript-eslint/no-misused-promises this.onEngineConnectionOpened = async () => { - // Set the stream background color - // This takes RGBA values from 0-1 - // So we convert from the conventional 0-255 found in Figma - - void this.sendSceneCommand({ - type: 'modeling_cmd_req', - cmd_id: uuidv4(), - cmd: { - type: 'set_background_color', - color: getThemeColorForEngine(this.settings.theme), - }, - }) - - // Sets the default line colors - const opposingTheme = getOppositeTheme(this.settings.theme) - // eslint-disable-next-line @typescript-eslint/no-floating-promises - this.sendSceneCommand({ - cmd_id: uuidv4(), - type: 'modeling_cmd_req', - cmd: { - type: 'set_default_system_properties', - color: getThemeColorForEngine(opposingTheme), - }, - }) + // Set the theme + this.setTheme(this.settings.theme).catch(reportRejection) + // Set up a listener for the dark theme media query + darkModeMatcher?.addEventListener( + 'change', + this.onDarkThemeMediaQueryChange + ) // Set the edge lines visibility // eslint-disable-next-line @typescript-eslint/no-floating-promises @@ -1793,6 +1784,10 @@ export class EngineCommandManager extends EventTarget { EngineConnectionEvents.NewTrack, this.onEngineConnectionNewTrack as EventListener ) + darkModeMatcher?.removeEventListener( + 'change', + this.onDarkThemeMediaQueryChange + ) this.engineConnection?.tearDown(opts) @@ -2155,6 +2150,34 @@ export class EngineCommandManager extends EventTarget { }) } + /** + * Set the engine's theme + */ + async setTheme(theme: Themes) { + // Set the stream background color + // This takes RGBA values from 0-1 + // So we convert from the conventional 0-255 found in Figma + this.sendSceneCommand({ + cmd_id: uuidv4(), + type: 'modeling_cmd_req', + cmd: { + type: 'set_background_color', + color: getThemeColorForEngine(theme), + }, + }).catch(reportRejection) + + // Sets the default line colors + const opposingTheme = getOppositeTheme(theme) + this.sendSceneCommand({ + cmd_id: uuidv4(), + type: 'modeling_cmd_req', + cmd: { + type: 'set_default_system_properties', + color: getThemeColorForEngine(opposingTheme), + }, + }).catch(reportRejection) + } + /** * Set the visibility of the scale grid in the engine scene. * @param visible - whether to show or hide the scale grid diff --git a/src/lib/theme.ts b/src/lib/theme.ts index 422506be7..cef6a613b 100644 --- a/src/lib/theme.ts +++ b/src/lib/theme.ts @@ -1,5 +1,12 @@ import { AppTheme } from 'wasm-lib/kcl/bindings/AppTheme' +/** A media query matcher for dark mode */ +export const darkModeMatcher = + (typeof globalThis.window !== 'undefined' && + 'matchMedia' in globalThis.window && + globalThis.window.matchMedia('(prefers-color-scheme: dark)')) || + undefined + export enum Themes { Light = 'light', Dark = 'dark', @@ -25,7 +32,7 @@ export function appThemeToTheme( export function getSystemTheme(): Exclude { return typeof globalThis.window !== 'undefined' && 'matchMedia' in globalThis.window - ? window.matchMedia('(prefers-color-scheme: dark)').matches + ? darkModeMatcher?.matches ? Themes.Dark : Themes.Light : Themes.Light