Skip to content

Commit

Permalink
Add color mode to ui store (#4810)
Browse files Browse the repository at this point in the history
  • Loading branch information
zackkrida authored Aug 30, 2024
1 parent 2e95f12 commit 1b687c1
Show file tree
Hide file tree
Showing 10 changed files with 239 additions and 85 deletions.
2 changes: 1 addition & 1 deletion frontend/feat/feature-flags.json
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@
"production": "disabled"
},
"defaultState": "off",
"description": "Display the UI toggle to change the site color theme.",
"description": "Display the UI toggle to change the site color theme and respect system preferences.",
"storage": "cookie"
}
},
Expand Down
38 changes: 29 additions & 9 deletions frontend/src/composables/use-dark-mode.ts
Original file line number Diff line number Diff line change
@@ -1,26 +1,46 @@
import { computed } from "#imports"
import { computed, useUiStore } from "#imports"

import { useFeatureFlagStore } from "~/stores/feature-flag"

export const DARK_MODE_CLASS = "dark-mode"
export const LIGHT_MODE_CLASS = "light-mode"

/**
* TODO: Replace with the user's actual dark mode preference.
* Dark mode detection will be based on user preference,
* overwritten by the "force_dark_mode" feature flag.
* Determines the dark mode setting based on user preference or feature flag.
*
* When dark mode toggling is disabled, the site is in "light mode" unless
* the `force_dark_mode` feature flag is on.
*
* When the "dark_mode_ui_toggle" flag is enabled, the site will respect
* the user system preference by default.
*
*/
export function useDarkMode() {
const uiStore = useUiStore()
const featureFlagStore = useFeatureFlagStore()

const isDarkMode = computed(() => featureFlagStore.isOn("force_dark_mode"))
const cssClass = computed(() =>
isDarkMode.value ? DARK_MODE_CLASS : LIGHT_MODE_CLASS
const darkModeToggleable = computed(() =>
featureFlagStore.isOn("dark_mode_ui_toggle")
)
const forceDarkMode = computed(() => featureFlagStore.isOn("force_dark_mode"))

const colorMode = computed(() => {
if (darkModeToggleable.value && !forceDarkMode.value) {
return uiStore.colorMode
}
return forceDarkMode.value ? "dark" : "light"
})

const cssClass = computed(() => {
return {
light: LIGHT_MODE_CLASS,
dark: DARK_MODE_CLASS,
system: "",
}[colorMode.value]
})

return {
isDarkMode,
/** The CSS class representing the current color mode. */
colorMode,
cssClass,
}
}
4 changes: 4 additions & 0 deletions frontend/src/pages/preferences.vue
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { definePageMeta } from "#imports"
import { computed } from "vue"
import { useFeatureFlagStore } from "~/stores/feature-flag"
import { isFlagName } from "~/types/feature-flag"
import { SWITCHABLE, ON, OFF } from "~/constants/feature-flag"
import VContentPage from "~/components/VContentPage.vue"
Expand Down Expand Up @@ -33,6 +34,9 @@ const handleChange = ({
name: string
checked?: boolean
}) => {
if (!isFlagName(name)) {
return
}
featureFlagStore.toggleFeature(name, checked ? ON : OFF)
}
Expand Down
6 changes: 3 additions & 3 deletions frontend/src/stores/feature-flag.ts
Original file line number Diff line number Diff line change
Expand Up @@ -293,7 +293,7 @@ export const useFeatureFlagStore = defineStore(FEATURE_FLAG, {
* @param name - the name of the flag to toggle
* @param targetState - the desired state of the feature flag
*/
toggleFeature(name: string, targetState: FeatureState) {
toggleFeature(name: FlagName, targetState: FeatureState) {
if (!isFlagName(name)) {
throw new Error(`Toggling invalid feature flag: ${name}`)
}
Expand All @@ -315,7 +315,7 @@ export const useFeatureFlagStore = defineStore(FEATURE_FLAG, {
storage.value = this.flags.analytics.state === ON ? null : true
},

isSwitchable(name: string) {
isSwitchable(name: FlagName) {
if (!isFlagName(name)) {
throw new Error(`Invalid feature flag accessed: ${name}`)
}
Expand All @@ -328,7 +328,7 @@ export const useFeatureFlagStore = defineStore(FEATURE_FLAG, {
*
* @returns `true` if the flag is on, false otherwise
*/
isOn(name: string) {
isOn(name: FlagName) {
if (!isFlagName(name)) {
throw new Error(`Invalid feature flag accessed: ${name}`)
}
Expand Down
55 changes: 42 additions & 13 deletions frontend/src/stores/ui.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,9 @@ import { defineStore } from "pinia"
import { LocaleObject } from "@nuxtjs/i18n"

import {
defaultPersistientCookieState,
OpenverseCookieState,
persistentCookieOptions,
SnackbarState,
} from "~/types/cookies"
import type { BannerId } from "~/types/banners"

Expand All @@ -17,6 +17,15 @@ import { needsTranslationBanner } from "~/utils/translation-banner"

const desktopBreakpoints: RealBreakpoint[] = ["2xl", "xl", "lg"]

export type SnackbarState = "not_shown" | "visible" | "dismissed"
export type ColorMode = "dark" | "light" | "system"

export function isColorMode(value: undefined | string): value is ColorMode {
return (
typeof value === "string" && ["light", "dark", "system"].includes(value)
)
}

export interface UiState {
/**
* whether to show the instructions snackbar.
Expand Down Expand Up @@ -53,22 +62,28 @@ export interface UiState {
/* A list of sensitive single result UUIDs the user has opted-into seeing */
revealedSensitiveResults: string[]
headerHeight: number

/* The user-chosen color theme of the site. */
colorMode: ColorMode
}

export const breakpoints = Object.keys(ALL_SCREEN_SIZES)

export const defaultUiState: UiState = {
instructionsSnackbarState: "not_shown",
innerFilterVisible: false,
isFilterDismissed: false,
isDesktopLayout: false,
breakpoint: "sm",
dismissedBanners: [],
shouldBlurSensitive: true,
revealedSensitiveResults: [],
headerHeight: 80,
colorMode: "system",
}

export const useUiStore = defineStore("ui", {
state: (): UiState => ({
instructionsSnackbarState: "not_shown",
innerFilterVisible: false,
isFilterDismissed: false,
isDesktopLayout: false,
breakpoint: "sm",
dismissedBanners: [],
shouldBlurSensitive: true,
revealedSensitiveResults: [],
headerHeight: 80,
}),
state: (): UiState => ({ ...defaultUiState }),

getters: {
cookieState(state): OpenverseCookieState["ui"] {
Expand All @@ -77,6 +92,7 @@ export const useUiStore = defineStore("ui", {
isFilterDismissed: state.isFilterDismissed,
breakpoint: state.breakpoint,
dismissedBanners: Array.from(this.dismissedBanners),
colorMode: state.colorMode,
}
},
areInstructionsVisible(state): boolean {
Expand Down Expand Up @@ -171,6 +187,10 @@ export const useUiStore = defineStore("ui", {
this.dismissedBanners = cookies.dismissedBanners
}

if (isColorMode(cookies.colorMode)) {
this.setColorMode(cookies.colorMode)
}

this.writeToCookie()
},
/**
Expand All @@ -182,7 +202,11 @@ export const useUiStore = defineStore("ui", {
"ui",
persistentCookieOptions
)
uiCookie.value = this.cookieState

uiCookie.value = {
...defaultPersistientCookieState.ui,
...this.cookieState,
}
},

/**
Expand Down Expand Up @@ -257,6 +281,11 @@ export const useUiStore = defineStore("ui", {
this.shouldBlurSensitive = value
this.revealedSensitiveResults = []
},
setColorMode(colorMode: ColorMode) {
this.colorMode = colorMode

this.writeToCookie()
},
setHeaderHeight(height: number) {
this.headerHeight = Math.max(height, 80)
},
Expand Down
89 changes: 88 additions & 1 deletion frontend/src/styles/tailwind.css
Original file line number Diff line number Diff line change
Expand Up @@ -124,7 +124,94 @@
--color-error-13: #1e0302;
}

:root,
@media (prefers-color-scheme: light) {
:root {
--color-bg: var(--color-white);
--color-bg-surface: var(--color-gray-1);
--color-bg-overlay: var(--color-white);
--color-bg-primary: var(--color-pink-8);
--color-bg-primary-hover: var(--color-pink-9);
--color-bg-secondary: var(--color-gray-2);
--color-bg-secondary-hover: var(--color-gray-12);
--color-bg-tertiary: var(--color-gray-12);
--color-bg-tertiary-hover: var(--color-gray-11);
--color-bg-transparent-hover: var(--color-gray-12-10);
--color-bg-complementary: var(--color-yellow-3);
--color-bg-warning: var(--color-warning-2);
--color-bg-info: var(--color-info-2);
--color-bg-success: var(--color-success-2);
--color-bg-error: var(--color-error-2);
--color-bg-disabled: var(--color-gray-5);
--color-bg-zero: var(--color-white-0);
--color-border: var(--color-gray-3);
--color-border-hover: var(--color-gray-12);
--color-border-secondary: var(--color-gray-12-20);
--color-border-secondary-hover: var(--color-gray-12);
--color-border-tertiary: var(--color-gray-12);
--color-border-transparent-hover: var(--color-gray-3);
--color-border-focus: var(--color-pink-8);
--color-border-bg-ring: var(--color-white);
--color-border-disabled: var(--color-gray-5);
--color-text: var(--color-gray-12);
--color-text-secondary: var(--color-gray-8);
--color-text-disabled: var(--color-gray-5);
--color-text-link: var(--color-pink-8);
--color-text-over-dark: var(--color-white);
--color-text-secondary-over-dark: var(--color-gray-5);
--color-icon-warning: var(--color-warning-8);
--color-icon-info: var(--color-info-8);
--color-icon-success: var(--color-success-8);
--color-icon-error: var(--color-error-8);
--color-wave-active: var(--color-yellow-9);
--color-wave-inactive: var(--color-gray-12-20);
--color-modal-layer: var(--color-gray-12-80);
}
}

@media (prefers-color-scheme: dark) {
:root {
--color-bg: var(--color-gray-13);
--color-bg-surface: var(--color-gray-12);
--color-bg-overlay: var(--color-gray-11);
--color-bg-primary: var(--color-yellow-4);
--color-bg-primary-hover: var(--color-yellow-3);
--color-bg-secondary: var(--color-gray-11);
--color-bg-secondary-hover: var(--color-gray-1);
--color-bg-tertiary: var(--color-gray-1);
--color-bg-tertiary-hover: var(--color-gray-2);
--color-bg-transparent-hover: var(--color-gray-1-10);
--color-bg-complementary: var(--color-pink-9);
--color-bg-warning: var(--color-warning-11);
--color-bg-info: var(--color-info-11);
--color-bg-success: var(--color-success-11);
--color-bg-error: var(--color-error-11);
--color-bg-disabled: var(--color-gray-8);
--color-bg-zero: var(--color-gray-13-0);
--color-border: var(--color-gray-11);
--color-border-hover: var(--color-gray-1);
--color-border-secondary: var(--color-gray-1-20);
--color-border-secondary-hover: var(--color-gray-1);
--color-border-tertiary: var(--color-gray-1);
--color-border-transparent-hover: var(--color-gray-11);
--color-border-focus: var(--color-yellow-4);
--color-border-bg-ring: var(--color-gray-13);
--color-border-disabled: var(--color-gray-8);
--color-text: var(--color-gray-1);
--color-text-secondary: var(--color-gray-5);
--color-text-disabled: var(--color-gray-8);
--color-text-link: var(--color-yellow-4);
--color-text-over-dark: var(--color-gray-13);
--color-text-secondary-over-dark: var(--color-gray-8);
--color-icon-warning: var(--color-warning-5);
--color-icon-info: var(--color-info-5);
--color-icon-success: var(--color-success-5);
--color-icon-error: var(--color-error-5);
--color-wave-active: var(--color-pink-4);
--color-wave-inactive: var(--color-gray-1-30);
--color-modal-layer: var(--color-gray-12-60);
}
}

:is(.light-mode),
:is(.light-mode *) {
--color-bg: var(--color-white);
Expand Down
38 changes: 12 additions & 26 deletions frontend/src/types/cookies.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,5 @@
import type { FeatureState } from "~/constants/feature-flag"
import type { RealBreakpoint } from "~/constants/screens"
import type { BannerId } from "~/types/banners"

export type SnackbarState = "not_shown" | "visible" | "dismissed"
import { UiState } from "~/stores/ui"

const baseCookieOptions = {
path: "/",
Expand All @@ -15,6 +12,12 @@ export const persistentCookieOptions = {
maxAge: 60 * 60 * 24 * 60, // 60 days; Makes the cookie persistent.
} as const

export const defaultPersistientCookieState: OpenverseCookieState = {
ui: {
colorMode: "system",
},
}

export const sessionCookieOptions = {
...baseCookieOptions,
maxAge: undefined, // these cookies are not persistent and will be deleted by the browser after the session.
Expand All @@ -24,28 +27,11 @@ export const sessionCookieOptions = {
* The cookies that Openverse uses to store the UI state.
*/
export interface OpenverseCookieState {
ui: {
/**
* The state of the instructions snackbar for audio component.
*/
instructionsSnackbarState?: SnackbarState
/**
* Whether the filters were dismissed on desktop layout.
*/
isFilterDismissed?: boolean
/**
* The screen's max-width breakpoint.
*/
breakpoint?: RealBreakpoint
/**
* Whether the request user agent is mobile or not.
*/
isMobileUa?: boolean
/**
* The list of ids of dismissed banners.
*/
dismissedBanners?: BannerId[]
}
/**
* Values used to SSR the site,
* persisted by the ui store.
*/
ui: Partial<UiState>
/**
* The state of the persistent feature flags.
*/
Expand Down
4 changes: 4 additions & 0 deletions frontend/src/types/feature-flag.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,10 @@ import type { DeployEnv } from "~/constants/deploy-env"

export type FlagName = keyof (typeof featureData)["features"]

export function isFlagName(flag: string): flag is FlagName {
return flag in featureData.features
}

export type FlagStatusRecord = string | Partial<Record<DeployEnv, string>>
/**
* The record of a feature flag from the json file.
Expand Down
Loading

0 comments on commit 1b687c1

Please sign in to comment.