Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 44 additions & 0 deletions .agent/workflows/high-contrast-implementation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
---
description: Implementation plan for High Contrast Mode feature (Issue #4853)
---

# High Contrast Mode Implementation Plan

## Overview
This implementation adds high contrast versions of both light and dark modes to Openverse, following accessibility best practices (WCAG 1.4.6 Enhanced Contrast - 7:1 ratio for normal text).

## Components to Create/Modify

### 1. CSS Variables (`src/styles/tailwind.css`)
- Add high contrast color palettes for both light and dark modes
- Add `@media (prefers-contrast: more)` media queries
- Add `.high-contrast-light-mode` and `.high-contrast-dark-mode` class selectors

### 2. Type Definitions (`stores/ui.ts`)
- Add `ContrastMode` type: `"normal" | "high" | "system"`
- Add `contrastMode` to `UiState`
- Add `setContrastMode()` action
- Update cookie handling

### 3. Composable (`composables/use-high-contrast.ts`)
- Create new composable similar to `use-dark-mode.ts`
- Detect OS preference using `prefers-contrast` media query
- Return effective contrast mode and CSS class

### 4. Feature Flag (`feat/feature-flags.json`)
- Add `high_contrast_ui_toggle` feature flag

### 5. UI Component (`components/VContrastSelect/VContrastSelect.vue`)
- Create dropdown similar to `VThemeSelect`
- Options: Normal, High Contrast, System

### 6. Integration
- Update `VFooter` to include contrast selector
- Update `app.vue` to apply contrast CSS class to body

### 7. Translations
- Add i18n keys for contrast mode labels

### 8. Tests
- Unit tests for composable
- Unit tests for store actions
15 changes: 13 additions & 2 deletions frontend/feat/feature-flags.json
Original file line number Diff line number Diff line change
Expand Up @@ -39,12 +39,23 @@
"defaultState": "on",
"description": "Display the UI toggle to change the site color theme and respect system preferences.",
"storage": "cookie"
},
"high_contrast_ui_toggle": {
"status": {
"staging": "switchable",
"production": "switchable"
},
"defaultState": "on",
"description": "Display the UI toggle to enable high contrast mode and respect OS prefers-contrast preferences.",
"storage": "cookie"
}
},
"groups": [
{
"title": "analytics",
"features": ["analytics"]
"features": [
"analytics"
]
}
]
}
}
4 changes: 4 additions & 0 deletions frontend/i18n/data/en.json5
Original file line number Diff line number Diff line change
Expand Up @@ -760,6 +760,10 @@ _{link}_ part of "The translation for English locale is incomplete. Help us get
"theme.choices.dark": "Dark",
"theme.choices.light": "Light",
"theme.choices.system": "System",
"contrast.contrast": "Contrast",
"contrast.choices.normal": "Normal",
"contrast.choices.high": "High",
"contrast.choices.system": "System",
"recentSearches.heading": "Recent searches",
"recentSearches.clear.text": "Clear",
"recentSearches.clear.label": "Clear recent searches",
Expand Down
1 change: 1 addition & 0 deletions frontend/shared/types/cookies.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ export const persistentCookieOptions = {
export const defaultPersistientCookieState: OpenverseCookieState = {
ui: {
colorMode: "system",
contrastMode: "system",
},
}

Expand Down
9 changes: 8 additions & 1 deletion frontend/src/app.vue
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { useUiStore } from "~/stores/ui"
import { useFeatureFlagStore } from "~/stores/feature-flag"
import { useLayout } from "~/composables/use-layout"
import { useDarkMode } from "~/composables/use-dark-mode"
import { useHighContrast } from "~/composables/use-high-contrast"

import VSkipToContentButton from "~/components/VSkipToContentButton.vue"

Expand All @@ -22,6 +23,12 @@ const featureFlagStore = useFeatureFlagStore()
const uiStore = useUiStore()

const darkMode = useDarkMode()
const highContrast = useHighContrast()

// Combined CSS classes for body: dark mode class + high contrast class
const bodyClasses = computed(() => {
return [darkMode.cssClass.value, highContrast.cssClass.value].filter(Boolean).join(' ')
})

/* UI store */
const isDesktopLayout = computed(() => uiStore.isDesktopLayout)
Expand Down Expand Up @@ -63,7 +70,7 @@ const meta = computed(() => {

useHead({
htmlAttrs: htmlI18nProps,
bodyAttrs: { class: darkMode.cssClass, style: headerHeight },
bodyAttrs: { class: bodyClasses, style: headerHeight },
title: "Openly Licensed Images, Audio and More | Openverse",
meta,
link,
Expand Down
82 changes: 82 additions & 0 deletions frontend/src/components/VContrastSelect/VContrastSelect.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
<!--
Present users with a way to change the app contrast between three options:
normal, high, and system.
-->

<script setup lang="ts">
import { useI18n } from "#imports"
import { ref, onMounted, watch, type Ref } from "vue"

import { useUiStore, type ContrastMode } from "~/stores/ui"
import { useHighContrast } from "~/composables/use-high-contrast"

import VIcon from "~/components/VIcon/VIcon.vue"
import VSelectField, {
type Choice,
} from "~/components/VSelectField/VSelectField.vue"

const i18n = useI18n({ useScope: "global" })
const uiStore = useUiStore()

const CONTRAST_ICON_NAME = Object.freeze({
normal: "eye-open",
high: "filter",
system: "duotone",
})

const CONTRAST_TEXT = {
normal: i18n.t(`contrast.choices.normal`),
high: i18n.t(`contrast.choices.high`),
system: i18n.t(`contrast.choices.system`),
}

const contrastMode: Ref<ContrastMode> = ref(uiStore.contrastMode)
const handleUpdateModelValue = (value: string) => {
uiStore.setContrastMode(value as ContrastMode)
}

const highContrast = useHighContrast()

const currentContrastIcon: Ref<"eye-open" | "filter" | "duotone"> = ref(
CONTRAST_ICON_NAME[highContrast.contrastMode.value]
)

/**
* The choices are computed because the text for the contrast mode choice
* "system" is dynamic and reflects the user's preferred contrast setting at
* the OS-level.
*/
const choices: Ref<Choice[]> = ref([
{ key: "normal", text: CONTRAST_TEXT.normal },
{ key: "high", text: CONTRAST_TEXT.high },
{ key: "system", text: CONTRAST_TEXT.system },
])

const updateRefs = () => {
contrastMode.value = uiStore.contrastMode
currentContrastIcon.value = CONTRAST_ICON_NAME[highContrast.contrastMode.value]
const effectiveText = highContrast.effectiveContrastMode.value === "high"
? CONTRAST_TEXT.high
: CONTRAST_TEXT.normal
choices.value[2].text = `${CONTRAST_TEXT.system} (${effectiveText})`
}

onMounted(updateRefs)
watch([highContrast.contrastMode, highContrast.osHighContrast], updateRefs)
</script>

<template>
<VSelectField
:model-value="contrastMode"
field-id="contrast"
:choices="choices"
:blank-text="$t('contrast.contrast')"
:label-text="$t('contrast.contrast')"
:show-selected="false"
@update:model-value="handleUpdateModelValue"
>
<template #start>
<VIcon :name="currentContrastIcon" />
</template>
</VSelectField>
</template>
2 changes: 2 additions & 0 deletions frontend/src/components/VFooter/VFooter.vue
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import VLink from "~/components/VLink.vue"
import VBrand from "~/components/VBrand/VBrand.vue"
import VLanguageSelect from "~/components/VLanguageSelect/VLanguageSelect.vue"
import VThemeSelect from "~/components/VThemeSelect/VThemeSelect.vue"
import VContrastSelect from "~/components/VContrastSelect/VContrastSelect.vue"
import VPageLinks from "~/components/VHeader/VPageLinks.vue"
import VWordPressLink from "~/components/VHeader/VWordPressLink.vue"

Expand Down Expand Up @@ -96,6 +97,7 @@ const linkColumnHeight = computed(() => ({
class="language max-w-full border-secondary"
/>
<VThemeSelect class="border-secondary" />
<VContrastSelect class="border-secondary" />
</div>
</div>
</footer>
Expand Down
124 changes: 124 additions & 0 deletions frontend/src/composables/use-high-contrast.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
import { computed, ref, onMounted, watchEffect } from "#imports"

Check failure on line 1 in frontend/src/composables/use-high-contrast.ts

View workflow job for this annotation

GitHub Actions / Lint files

'watchEffect' is defined but never used

import { useFeatureFlagStore } from "~/stores/feature-flag"
import { useUiStore } from "~/stores/ui"
import { useDarkMode } from "~/composables/use-dark-mode"

export const HIGH_CONTRAST_LIGHT_MODE_CLASS = "high-contrast-light-mode"
export const HIGH_CONTRAST_DARK_MODE_CLASS = "high-contrast-dark-mode"

/**
* Determines the high contrast mode setting based on user preference or OS setting.
*
* When the "high_contrast_ui_toggle" flag is enabled, the site will respect
* the user's system preference for high contrast by default (using prefers-contrast media query).
*
* The high contrast mode works in combination with the color mode (light/dark) to provide
* four possible visual themes:
* - Light mode (normal contrast)
* - Light mode (high contrast)
* - Dark mode (normal contrast)
* - Dark mode (high contrast)
*/
export function useHighContrast() {
const uiStore = useUiStore()
const featureFlagStore = useFeatureFlagStore()
const { effectiveColorMode } = useDarkMode()

const highContrastToggleable = computed(() =>
featureFlagStore.isOn("high_contrast_ui_toggle")
)

/**
* Detect OS-level preference for high contrast using the prefers-contrast media query.
* Returns true if the user has requested more contrast at the OS level.
*/
const osHighContrast = ref(false)

onMounted(() => {
// Check if the browser supports the prefers-contrast media query
const mediaQuery = window.matchMedia("(prefers-contrast: more)")
osHighContrast.value = mediaQuery.matches

// Listen for changes to the OS high contrast preference
const handleChange = (e: MediaQueryListEvent) => {
osHighContrast.value = e.matches
}
mediaQuery.addEventListener("change", handleChange)

// Note: cleanup is handled by Vue's lifecycle
})

/**
* The contrast mode setting for the app.
*
* This can be one of "normal", "high", or "system". If the toggle
* feature is disabled, we default to "normal".
*/
const contrastMode = computed(() => {
if (highContrastToggleable.value) {
return uiStore.contrastMode
}
return "normal"
})

/**
* The effective contrast mode of the app.
*
* This can be one of "normal" or "high". This is a combination of the
* toggle feature flag, the user's preference at the app and OS levels,
* and the default value of "normal".
*/
const effectiveContrastMode = computed(() => {
if (!highContrastToggleable.value) {
return "normal"
}
if (contrastMode.value === "system") {
return osHighContrast.value ? "high" : "normal"
}
return contrastMode.value
})

/**
* Whether high contrast mode is currently active (either explicitly set or via OS preference).
*/
const isHighContrast = computed(() => effectiveContrastMode.value === "high")

/**
* The CSS class to apply to the body element based on the contrast and color mode.
* Returns a class that combines both the color mode and contrast mode.
*
* - high-contrast-light-mode: High contrast + light theme
* - high-contrast-dark-mode: High contrast + dark theme
* - Empty string: Normal contrast (handled by color mode classes)
*/
const cssClass = computed(() => {
if (effectiveContrastMode.value !== "high") {
return ""
}

// When high contrast is enabled, we need to combine it with the current color mode
return effectiveColorMode.value === "dark"
? HIGH_CONTRAST_DARK_MODE_CLASS
: HIGH_CONTRAST_LIGHT_MODE_CLASS
})

/**
* The server does not have access to media queries, so the `system` contrast mode
* defaults to "normal" on the server.
*/
const serverContrastMode = computed(() => {
return !highContrastToggleable.value || contrastMode.value === "system"
? "normal"
: contrastMode.value
})

return {
contrastMode,
osHighContrast,
effectiveContrastMode,
isHighContrast,
serverContrastMode,
cssClass,
}
}
9 changes: 8 additions & 1 deletion frontend/src/error.vue
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { meta as commonMeta } from "#shared/constants/meta"
import { favicons } from "#shared/constants/favicons"
import { useUiStore } from "~/stores/ui"
import { useDarkMode } from "~/composables/use-dark-mode"
import { useHighContrast } from "~/composables/use-high-contrast"
import { useLayout } from "~/composables/use-layout"

import VFourOhFour from "~/components/VFourOhFour.vue"
Expand All @@ -30,6 +31,12 @@ onMounted(() => {
})

const darkMode = useDarkMode()
const highContrast = useHighContrast()

// Combined CSS classes for body: dark mode class + high contrast class
const bodyClasses = computed(() => {
return [darkMode.cssClass.value, highContrast.cssClass.value].filter(Boolean).join(' ')
})

const head = useLocaleHead({ dir: true, key: "id", seo: true })
const htmlI18nProps = computed(() => ({
Expand Down Expand Up @@ -64,7 +71,7 @@ const meta = computed(() => {

useHead({
htmlAttrs: htmlI18nProps,
bodyAttrs: { class: darkMode.cssClass, style: headerHeight },
bodyAttrs: { class: bodyClasses, style: headerHeight },
title: "Openly Licensed Images, Audio and More | Openverse",
meta,
link,
Expand Down
Loading
Loading