Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
65 changes: 57 additions & 8 deletions app/assets/main.css
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,10 @@

: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);
Expand Down Expand Up @@ -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='contrast'] {
--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);
Expand Down Expand Up @@ -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);
}

html {
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
Expand Down
37 changes: 37 additions & 0 deletions app/components/Settings/BgThemePicker.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
<script setup lang="ts">
import { useBackgroundTheme } from '~/composables/useBackgroundTheme'

const { backgroundThemes, selectedBackgroundTheme, setBackgroundTheme } = useBackgroundTheme()

onPrehydrate(el => {
const id = localStorage.getItem('npmx-background-theme')
if (id) {
const input = el.querySelector<HTMLInputElement>(`input[value="${id || 'neutral'}"]`)
if (input) {
input.checked = true
}
}
})
</script>

<template>
<fieldset class="flex items-center gap-4">
<legend class="sr-only">{{ $t('settings.background_themes') }}</legend>
<label
v-for="theme in backgroundThemes"
:key="theme.id"
class="size-6 rounded-full transition-transform duration-150 motion-safe:hover:scale-110 cursor-pointer has-[:checked]:(ring-2 ring-fg ring-offset-2 ring-offset-bg-subtle) has-[:focus-visible]:(ring-2 ring-fg ring-offset-2 ring-offset-bg-subtle)"
:style="{ backgroundColor: theme.value }"
>
<input
type="radio"
name="background-theme"
class="sr-only"
:value="theme.id"
:checked="selectedBackgroundTheme === theme.id"
:aria-label="theme.name"
@change="setBackgroundTheme(theme.id)"
/>
</label>
</fieldset>
</template>
35 changes: 35 additions & 0 deletions app/composables/useBackgroundTheme.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import type { RemovableRef } from '@vueuse/core'
import { useLocalStorage } from '@vueuse/core'
import { BACKGROUND_THEMES } from '#shared/utils/constants'

type BackgroundThemeId = keyof typeof BACKGROUND_THEMES

export const BACKGROUND_THEME_STORAGE_KEY = 'npmx-background-theme'

const backgroundThemeRef: RemovableRef<BackgroundThemeId | null> = useLocalStorage(
BACKGROUND_THEME_STORAGE_KEY,
null,
)

export function useBackgroundTheme() {
const backgroundThemes = Object.entries(BACKGROUND_THEMES).map(([id, value]) => ({
id: id as BackgroundThemeId,
name: id,
value,
}))

function setBackgroundTheme(id: BackgroundThemeId | null) {
if (id) {
document.documentElement.dataset.bgTheme = id
} else {
document.documentElement.removeAttribute('data-bg-theme')
}
backgroundThemeRef.value = id
}

return {
backgroundThemes,
selectedBackgroundTheme: computed(() => backgroundThemeRef.value),
setBackgroundTheme,
}
}
2 changes: 1 addition & 1 deletion app/composables/useColors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'],
})
}

Expand Down
8 changes: 8 additions & 0 deletions app/pages/settings.vue
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,14 @@ const setLocale: typeof setNuxti18nLocale = locale => {
</span>
<SettingsAccentColorPicker />
</div>

<!-- Background themes -->
<div class="space-y-3">
<span class="block text-sm text-fg font-medium">
{{ $t('settings.background_themes') }}
</span>
<SettingsBgThemePicker />
</div>
</div>
</section>

Expand Down
12 changes: 12 additions & 0 deletions nuxt.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,18 @@ export default defineNuxtConfig({
href: '/opensearch.xml',
},
],
script: [
{
innerHTML: `
(function () {
const preferredBackgroundTheme = localStorage.getItem('npmx-background-theme')
if (preferredBackgroundTheme) {
document.documentElement.dataset.bgTheme = preferredBackgroundTheme
}
})()
`,
},
],
},
},

Expand Down
8 changes: 8 additions & 0 deletions shared/utils/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,3 +34,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)',
contrast: 'oklch(0.4 0 0)',
} as const
135 changes: 135 additions & 0 deletions test/nuxt/a11y.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,7 @@ import {
ProvenanceBadge,
Readme,
SettingsAccentColorPicker,
SettingsBgThemePicker,
SettingsToggle,
TerminalExecute,
TerminalInstall,
Expand Down Expand Up @@ -1413,6 +1414,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, {
Expand Down Expand Up @@ -1788,3 +1797,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', 'contrast'],
['dark', 'contrast'],
] 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: '<button>Trigger</button>' },
}),
},
{
name: 'CollapsibleSection',
mount: () =>
mountSuspended(CollapsibleSection, {
props: { title: 'Title', id: 'section' },
slots: { default: '<p>Content</p>' },
}),
},
{
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([])
})
}
})
}
})
Loading