Make engine stream react to prefers-color-scheme media query change (#4008)

* Add event listener for theme media query to update engine theme

* tsc fixes

* Add a Playwright test for UI and engine theme switch

* A snapshot a day keeps the bugs away! 📷🐛 (OS: ubuntu-latest)

---------

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
This commit is contained in:
Frank Noirot
2024-09-27 11:14:03 -04:00
committed by GitHub
parent 6303130e08
commit 9ceb247fcd
5 changed files with 123 additions and 50 deletions

View File

@ -48,6 +48,11 @@ export const TEST_SETTINGS_ONBOARDING_START = {
app: { ...TEST_SETTINGS.app, onboardingStatus: '' }, app: { ...TEST_SETTINGS.app, onboardingStatus: '' },
} satisfies Partial<SaveSettingsPayload> } satisfies Partial<SaveSettingsPayload>
export const TEST_SETTINGS_DEFAULT_THEME = {
...TEST_SETTINGS,
app: { ...TEST_SETTINGS.app, theme: Themes.System },
} satisfies Partial<SaveSettingsPayload>
export const TEST_SETTINGS_CORRUPTED = { export const TEST_SETTINGS_CORRUPTED = {
app: { app: {
theme: Themes.Dark, theme: Themes.Dark,

View File

@ -13,6 +13,7 @@ import {
TEST_SETTINGS_KEY, TEST_SETTINGS_KEY,
TEST_SETTINGS_CORRUPTED, TEST_SETTINGS_CORRUPTED,
TEST_SETTINGS, TEST_SETTINGS,
TEST_SETTINGS_DEFAULT_THEME,
} from './storageStates' } from './storageStates'
import * as TOML from '@iarna/toml' 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 ({ test(`Turning off "Show debug panel" with debug panel open leaves no phantom panel`, async ({
page, page,
}) => { }) => {

View File

@ -8,7 +8,7 @@ import useStateMachineCommands from '../hooks/useStateMachineCommands'
import { settingsMachine } from 'machines/settingsMachine' import { settingsMachine } from 'machines/settingsMachine'
import { toast } from 'react-hot-toast' import { toast } from 'react-hot-toast'
import { import {
getThemeColorForEngine, darkModeMatcher,
getOppositeTheme, getOppositeTheme,
setThemeClass, setThemeClass,
Themes, Themes,
@ -34,6 +34,7 @@ import { useCommandsContext } from 'hooks/useCommandsContext'
import { Command } from 'lib/commandTypes' import { Command } from 'lib/commandTypes'
import { BaseUnit } from 'lib/settings/settingsTypes' import { BaseUnit } from 'lib/settings/settingsTypes'
import { saveSettings } from 'lib/settings/settingsUtils' import { saveSettings } from 'lib/settings/settingsUtils'
import { reportRejection } from 'lib/trap'
type MachineContext<T extends AnyStateMachine> = { type MachineContext<T extends AnyStateMachine> = {
state: StateFrom<T> state: StateFrom<T>
@ -113,26 +114,9 @@ export const SettingsAuthProviderBase = ({
sceneInfra.baseUnit = newBaseUnit sceneInfra.baseUnit = newBaseUnit
}, },
setEngineTheme: ({ context }) => { setEngineTheme: ({ context }) => {
// eslint-disable-next-line @typescript-eslint/no-floating-promises engineCommandManager
engineCommandManager.sendSceneCommand({ .setTheme(context.app.theme.current)
cmd_id: uuidv4(), .catch(reportRejection)
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),
},
})
}, },
setEngineScaleGridVisibility: ({ context }) => { setEngineScaleGridVisibility: ({ context }) => {
engineCommandManager.setScaleGridVisibility( engineCommandManager.setScaleGridVisibility(
@ -261,14 +245,13 @@ export const SettingsAuthProviderBase = ({
// because there doesn't seem to be a good way to listen to // 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 // events outside of the machine that also depend on the machine's context
useEffect(() => { useEffect(() => {
const matcher = window.matchMedia('(prefers-color-scheme: dark)')
const listener = (e: MediaQueryListEvent) => { const listener = (e: MediaQueryListEvent) => {
if (settingsState.context.app.theme.current !== 'system') return if (settingsState.context.app.theme.current !== 'system') return
setThemeClass(e.matches ? Themes.Dark : Themes.Light) setThemeClass(e.matches ? Themes.Dark : Themes.Light)
} }
matcher.addEventListener('change', listener) darkModeMatcher?.addEventListener('change', listener)
return () => matcher.removeEventListener('change', listener) return () => darkModeMatcher?.removeEventListener('change', listener)
}, [settingsState.context]) }, [settingsState.context])
/** /**

View File

@ -3,7 +3,12 @@ import { VITE_KC_API_WS_MODELING_URL, VITE_KC_DEV_TOKEN } from 'env'
import { Models } from '@kittycad/lib' import { Models } from '@kittycad/lib'
import { exportSave } from 'lib/exportSave' import { exportSave } from 'lib/exportSave'
import { deferExecution, isOverlap, uuidv4 } from 'lib/utils' 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 { DefaultPlanes } from 'wasm-lib/kcl/bindings/DefaultPlanes'
import { import {
ArtifactGraph, ArtifactGraph,
@ -1393,6 +1398,9 @@ export class EngineCommandManager extends EventTarget {
private onEngineConnectionOpened = () => {} private onEngineConnectionOpened = () => {}
private onEngineConnectionClosed = () => {} private onEngineConnectionClosed = () => {}
private onDarkThemeMediaQueryChange = (e: MediaQueryListEvent) => {
this.setTheme(e.matches ? Themes.Dark : Themes.Light).catch(reportRejection)
}
private onEngineConnectionStarted = ({ detail: engineConnection }: any) => {} private onEngineConnectionStarted = ({ detail: engineConnection }: any) => {}
private onEngineConnectionNewTrack = ({ private onEngineConnectionNewTrack = ({
detail, detail,
@ -1479,30 +1487,13 @@ export class EngineCommandManager extends EventTarget {
// eslint-disable-next-line @typescript-eslint/no-misused-promises // eslint-disable-next-line @typescript-eslint/no-misused-promises
this.onEngineConnectionOpened = async () => { this.onEngineConnectionOpened = async () => {
// Set the stream background color // Set the theme
// This takes RGBA values from 0-1 this.setTheme(this.settings.theme).catch(reportRejection)
// So we convert from the conventional 0-255 found in Figma // Set up a listener for the dark theme media query
darkModeMatcher?.addEventListener(
void this.sendSceneCommand({ 'change',
type: 'modeling_cmd_req', this.onDarkThemeMediaQueryChange
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 edge lines visibility // Set the edge lines visibility
// eslint-disable-next-line @typescript-eslint/no-floating-promises // eslint-disable-next-line @typescript-eslint/no-floating-promises
@ -1793,6 +1784,10 @@ export class EngineCommandManager extends EventTarget {
EngineConnectionEvents.NewTrack, EngineConnectionEvents.NewTrack,
this.onEngineConnectionNewTrack as EventListener this.onEngineConnectionNewTrack as EventListener
) )
darkModeMatcher?.removeEventListener(
'change',
this.onDarkThemeMediaQueryChange
)
this.engineConnection?.tearDown(opts) 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. * Set the visibility of the scale grid in the engine scene.
* @param visible - whether to show or hide the scale grid * @param visible - whether to show or hide the scale grid

View File

@ -1,5 +1,12 @@
import { AppTheme } from 'wasm-lib/kcl/bindings/AppTheme' 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 { export enum Themes {
Light = 'light', Light = 'light',
Dark = 'dark', Dark = 'dark',
@ -25,7 +32,7 @@ export function appThemeToTheme(
export function getSystemTheme(): Exclude<Themes, 'system'> { export function getSystemTheme(): Exclude<Themes, 'system'> {
return typeof globalThis.window !== 'undefined' && return typeof globalThis.window !== 'undefined' &&
'matchMedia' in globalThis.window 'matchMedia' in globalThis.window
? window.matchMedia('(prefers-color-scheme: dark)').matches ? darkModeMatcher?.matches
? Themes.Dark ? Themes.Dark
: Themes.Light : Themes.Light
: Themes.Light : Themes.Light