diff --git a/app/assets/main.css b/app/assets/main.css index 7a33ba3fc3..85e8802a2c 100644 --- a/app/assets/main.css +++ b/app/assets/main.css @@ -7,17 +7,17 @@ :root[data-theme='dark'] { /* background colors */ - --bg: oklch(0.145 0 0); - --bg-subtle: oklch(0.178 0 0); - --bg-muted: oklch(0.218 0 0); - --bg-elevated: oklch(0.252 0 0); + --bg: var(--bg-color, oklch(0.145 0 0)); + --bg-subtle: var(--bg-subtle-color, oklch(0.178 0 0)); + --bg-muted: var(--bg-muted-color, oklch(0.218 0 0)); + --bg-elevated: var(--bg-elevated-color, oklch(0.252 0 0)); /* text colors */ --fg: oklch(0.985 0 0); --fg-muted: oklch(0.709 0 0); --fg-subtle: oklch(0.633 0 0); - /* border, seperator colors */ + /* border, separator colors */ --border: oklch(0.269 0 0); --border-subtle: oklch(0.239 0 0); --border-hover: oklch(0.371 0 0); @@ -43,11 +43,39 @@ --badge-pink: oklch(0.584 0.189 343); } +:root[data-theme='dark'][data-bg-theme='slate'] { + --bg: oklch(0.129 0.012 264.695); + --bg-subtle: oklch(0.159 0.022 262.421); + --bg-muted: oklch(0.204 0.033 261.234); + --bg-elevated: oklch(0.259 0.041 260.031); +} + +:root[data-theme='dark'][data-bg-theme='zinc'] { + --bg: oklch(0.141 0.005 285.823); + --bg-subtle: oklch(0.168 0.005 285.894); + --bg-muted: oklch(0.209 0.005 285.929); + --bg-elevated: oklch(0.256 0.006 286.033); +} + +:root[data-theme='dark'][data-bg-theme='stone'] { + --bg: oklch(0.147 0.004 49.25); + --bg-subtle: oklch(0.178 0.004 49.321); + --bg-muted: oklch(0.218 0.004 49.386); + --bg-elevated: oklch(0.252 0.007 34.298); +} + +:root[data-theme='dark'][data-bg-theme='black'] { + --bg: oklch(0 0 0); + --bg-subtle: oklch(0.148 0 0); + --bg-muted: oklch(0.204 0 0); + --bg-elevated: oklch(0.264 0 0); +} + :root[data-theme='light'] { - --bg: oklch(1 0 0); - --bg-subtle: oklch(0.979 0.001 286.375); - --bg-muted: oklch(0.955 0 0); - --bg-elevated: oklch(0.94 0 0); + --bg: var(--bg-color, oklch(1 0 0)); + --bg-subtle: var(--bg-subtle-color, oklch(0.979 0.001 286.375)); + --bg-muted: var(--bg-muted-color, oklch(0.955 0 0)); + --bg-elevated: var(--bg-elevated-color, oklch(0.94 0 0)); --fg: oklch(0.145 0 0); --fg-muted: oklch(0.439 0 0); @@ -75,6 +103,27 @@ --badge-cyan: oklch(0.571 0.181 210); } +:root[data-theme='light'][data-bg-theme='slate'] { + --bg: oklch(1 0 0); + --bg-subtle: oklch(0.982 0.006 264.62); + --bg-muted: oklch(0.96 0.041 261.234); + --bg-elevated: oklch(0.943 0.013 255.52); +} + +:root[data-theme='light'][data-bg-theme='zinc'] { + --bg: oklch(1 0 0); + --bg-subtle: oklch(0.979 0.004 286.53); + --bg-muted: oklch(0.958 0.004 286.39); + --bg-elevated: oklch(0.939 0.004 286.32); +} + +:root[data-theme='light'][data-bg-theme='stone'] { + --bg: oklch(1 0 0); + --bg-subtle: oklch(0.979 0.005 48.762); + --bg-muted: oklch(0.958 0.005 48.743); + --bg-elevated: oklch(0.943 0.005 48.731); +} + @media (prefers-contrast: more) { :root[data-theme='dark'] { /* text colors */ diff --git a/app/components/Settings/BgThemePicker.vue b/app/components/Settings/BgThemePicker.vue new file mode 100644 index 0000000000..c4f39eb94a --- /dev/null +++ b/app/components/Settings/BgThemePicker.vue @@ -0,0 +1,36 @@ + + + diff --git a/app/composables/useColors.ts b/app/composables/useColors.ts index 9cdb77c07a..3b66e25fd1 100644 --- a/app/composables/useColors.ts +++ b/app/composables/useColors.ts @@ -81,7 +81,7 @@ export function useCssVariables( if (options.watchHtmlAttributes && isClientSupported.value) { useMutationObserver(document.documentElement, () => void colors.value, { attributes: true, - attributeFilter: ['class', 'style', 'data-theme'], + attributeFilter: ['class', 'style', 'data-theme', 'data-bg-theme'], }) } diff --git a/app/composables/useSettings.ts b/app/composables/useSettings.ts index 73445a7497..b0c2f6ff63 100644 --- a/app/composables/useSettings.ts +++ b/app/composables/useSettings.ts @@ -2,6 +2,9 @@ import type { RemovableRef } from '@vueuse/core' import { useLocalStorage } from '@vueuse/core' import { ACCENT_COLORS } from '#shared/utils/constants' import type { LocaleObject } from '@nuxtjs/i18n' +import { BACKGROUND_THEMES } from '#shared/utils/constants' + +type BackgroundThemeId = keyof typeof BACKGROUND_THEMES type AccentColorId = keyof typeof ACCENT_COLORS @@ -15,6 +18,8 @@ export interface AppSettings { includeTypesInInstall: boolean /** Accent color theme */ accentColorId: AccentColorId | null + /** Preferred background shade */ + preferredBackgroundTheme: BackgroundThemeId | null /** Hide platform-specific packages (e.g., @scope/pkg-linux-x64) from search results */ hidePlatformPackages: boolean /** User-selected locale */ @@ -30,6 +35,7 @@ const DEFAULT_SETTINGS: AppSettings = { accentColorId: null, hidePlatformPackages: true, selectedLocale: null, + preferredBackgroundTheme: null, sidebar: { collapsed: [], }, @@ -93,3 +99,28 @@ export function useAccentColor() { setAccentColor, } } + +export function useBackgroundTheme() { + const backgroundThemes = Object.entries(BACKGROUND_THEMES).map(([id, value]) => ({ + id: id as BackgroundThemeId, + name: id, + value, + })) + + const { settings } = useSettings() + + function setBackgroundTheme(id: BackgroundThemeId | null) { + if (id) { + document.documentElement.dataset.bgTheme = id + } else { + document.documentElement.removeAttribute('data-bg-theme') + } + settings.value.preferredBackgroundTheme = id + } + + return { + backgroundThemes, + selectedBackgroundTheme: computed(() => settings.value.preferredBackgroundTheme), + setBackgroundTheme, + } +} diff --git a/app/pages/settings.vue b/app/pages/settings.vue index c49c0e7848..38bba087b9 100644 --- a/app/pages/settings.vue +++ b/app/pages/settings.vue @@ -97,6 +97,14 @@ const setLocale: typeof setNuxti18nLocale = locale => { + + +
+ + {{ $t('settings.background_themes') }} + + +
diff --git a/app/utils/prehydrate.ts b/app/utils/prehydrate.ts index 72b79e577d..562ef40b7a 100644 --- a/app/utils/prehydrate.ts +++ b/app/utils/prehydrate.ts @@ -35,6 +35,12 @@ export function initPreferencesOnPrehydrate() { document.documentElement.style.setProperty('--accent-color', color) } + // Apply background accent + const preferredBackgroundTheme = settings.preferredBackgroundTheme + if (preferredBackgroundTheme) { + document.documentElement.dataset.bgTheme = preferredBackgroundTheme + } + // Read and apply package manager preference const storedPM = localStorage.getItem('npmx-pm') // Parse the stored value (it's stored as a JSON string by useLocalStorage) diff --git a/i18n/locales/en.json b/i18n/locales/en.json index 9330ad174f..25a7c1fb87 100644 --- a/i18n/locales/en.json +++ b/i18n/locales/en.json @@ -77,7 +77,8 @@ "help_translate": "Help translate npmx", "accent_colors": "Accent colors", "clear_accent": "Clear accent color", - "translation_progress": "Translation progress" + "translation_progress": "Translation progress", + "background_themes": "Background shade" }, "i18n": { "missing_keys": "{count} missing translation | {count} missing translations", diff --git a/lunaria/files/en-US.json b/lunaria/files/en-US.json index 9330ad174f..25a7c1fb87 100644 --- a/lunaria/files/en-US.json +++ b/lunaria/files/en-US.json @@ -77,7 +77,8 @@ "help_translate": "Help translate npmx", "accent_colors": "Accent colors", "clear_accent": "Clear accent color", - "translation_progress": "Translation progress" + "translation_progress": "Translation progress", + "background_themes": "Background shade" }, "i18n": { "missing_keys": "{count} missing translation | {count} missing translations", diff --git a/shared/utils/constants.ts b/shared/utils/constants.ts index d7c6ccfa01..5823835083 100644 --- a/shared/utils/constants.ts +++ b/shared/utils/constants.ts @@ -39,3 +39,11 @@ export const ACCENT_COLORS = { violet: 'oklch(0.714 0.148 286.067)', coral: 'oklch(0.704 0.177 14.75)', } as const + +export const BACKGROUND_THEMES = { + neutral: 'oklch(0.555 0 0)', + stone: 'oklch(0.555 0.013 58.123)', + zinc: 'oklch(0.555 0.016 285.931)', + slate: 'oklch(0.555 0.046 257.407)', + black: 'oklch(0.4 0 0)', +} as const diff --git a/test/nuxt/a11y.spec.ts b/test/nuxt/a11y.spec.ts index 1d0c50dc3c..564296b5df 100644 --- a/test/nuxt/a11y.spec.ts +++ b/test/nuxt/a11y.spec.ts @@ -108,6 +108,7 @@ import { ProvenanceBadge, Readme, SettingsAccentColorPicker, + SettingsBgThemePicker, SettingsToggle, TerminalExecute, TerminalInstall, @@ -1419,6 +1420,14 @@ describe('component accessibility audits', () => { }) }) + describe('SettingsBgThemePicker', () => { + it('should have no accessibility violations', async () => { + const component = await mountSuspended(SettingsBgThemePicker) + const results = await runAxe(component) + expect(results.violations).toEqual([]) + }) + }) + describe('TooltipBase', () => { it('should have no accessibility violations when hidden', async () => { const component = await mountSuspended(TooltipBase, { @@ -1828,3 +1837,129 @@ describe('component accessibility audits', () => { }) }) }) + +describe('background theme accessibility', () => { + const pairs = [ + ['light', 'neutral'], + ['dark', 'neutral'], + ['light', 'stone'], + ['dark', 'stone'], + ['light', 'zinc'], + ['dark', 'zinc'], + ['light', 'slate'], + ['dark', 'slate'], + ['light', 'black'], + ['dark', 'black'], + ] as const + + function applyTheme(colorMode: string, bgTheme: string | null) { + document.documentElement.dataset.theme = colorMode + document.documentElement.classList.add(colorMode) + if (bgTheme) document.documentElement.dataset.bgTheme = bgTheme + } + + afterEach(() => { + document.documentElement.removeAttribute('data-theme') + document.documentElement.removeAttribute('data-bg-theme') + document.documentElement.classList.remove('light', 'dark') + }) + + const packageResult = { + package: { + name: 'vue', + version: '3.5.0', + description: 'Framework', + date: '2024-01-15T00:00:00.000Z', + keywords: [], + links: {}, + publisher: { username: 'evan' }, + }, + score: { final: 0.9, detail: { quality: 0.9, popularity: 0.9, maintenance: 0.9 } }, + searchScore: 100000, + } + + const components = [ + { name: 'AppHeader', mount: () => mountSuspended(AppHeader) }, + { name: 'AppFooter', mount: () => mountSuspended(AppFooter) }, + { name: 'HeaderSearchBox', mount: () => mountSuspended(HeaderSearchBox) }, + { + name: 'LoadingSpinner', + mount: () => mountSuspended(LoadingSpinner, { props: { text: 'Loading...' } }), + }, + { + name: 'SettingsToggle', + mount: () => + mountSuspended(SettingsToggle, { props: { label: 'Feature', description: 'Desc' } }), + }, + { name: 'SettingsBgThemePicker', mount: () => mountSuspended(SettingsBgThemePicker) }, + { + name: 'ProvenanceBadge', + mount: () => + mountSuspended(ProvenanceBadge, { + props: { provider: 'github', packageName: 'vue', version: '3.0.0' }, + }), + }, + { + name: 'TerminalInstall', + mount: () => mountSuspended(TerminalInstall, { props: { packageName: 'vue' } }), + }, + { + name: 'LicenseDisplay', + mount: () => mountSuspended(LicenseDisplay, { props: { license: 'MIT' } }), + }, + { + name: 'DateTime', + mount: () => mountSuspended(DateTime, { props: { datetime: '2024-01-15T12:00:00.000Z' } }), + }, + { + name: 'ViewModeToggle', + mount: () => mountSuspended(ViewModeToggle, { props: { modelValue: 'cards' } }), + }, + { + name: 'TooltipApp', + mount: () => + mountSuspended(TooltipApp, { + props: { text: 'Tooltip' }, + slots: { default: '' }, + }), + }, + { + name: 'CollapsibleSection', + mount: () => + mountSuspended(CollapsibleSection, { + props: { title: 'Title', id: 'section' }, + slots: { default: '

Content

' }, + }), + }, + { + name: 'FilterChips', + mount: () => + mountSuspended(FilterChips, { + props: { + chips: [{ id: 'text', type: 'text', label: 'Search', value: 'react' }] as FilterChip[], + }, + }), + }, + { + name: 'PackageCard', + mount: () => mountSuspended(PackageCard, { props: { result: packageResult } }), + }, + { + name: 'PackageList', + mount: () => mountSuspended(PackageList, { props: { results: [packageResult] } }), + }, + ] + + for (const { name, mount } of components) { + describe(`${name} colors`, () => { + for (const [colorMode, bgTheme] of pairs) { + it(`${colorMode}/${bgTheme}`, async () => { + applyTheme(colorMode, bgTheme) + const results = await runAxe(await mount()) + await new Promise(resolve => setTimeout(resolve, 2000)) + expect(results.violations).toEqual([]) + }) + } + }) + } +})