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:
		@ -48,6 +48,11 @@ export const TEST_SETTINGS_ONBOARDING_START = {
 | 
			
		||||
  app: { ...TEST_SETTINGS.app, onboardingStatus: '' },
 | 
			
		||||
} 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 = {
 | 
			
		||||
  app: {
 | 
			
		||||
    theme: Themes.Dark,
 | 
			
		||||
 | 
			
		||||
@ -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,
 | 
			
		||||
  }) => {
 | 
			
		||||
 | 
			
		||||
@ -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<T extends AnyStateMachine> = {
 | 
			
		||||
  state: StateFrom<T>
 | 
			
		||||
@ -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])
 | 
			
		||||
 | 
			
		||||
  /**
 | 
			
		||||
 | 
			
		||||
@ -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
 | 
			
		||||
 | 
			
		||||
@ -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<Themes, 'system'> {
 | 
			
		||||
  return typeof globalThis.window !== 'undefined' &&
 | 
			
		||||
    'matchMedia' in globalThis.window
 | 
			
		||||
    ? window.matchMedia('(prefers-color-scheme: dark)').matches
 | 
			
		||||
    ? darkModeMatcher?.matches
 | 
			
		||||
      ? Themes.Dark
 | 
			
		||||
      : Themes.Light
 | 
			
		||||
    : Themes.Light
 | 
			
		||||
 | 
			
		||||
		Reference in New Issue
	
	Block a user