[READ-ONLY] a fast, modern browser for the npm registry

feat: add background theming (#663)

Co-authored-by: Daniel Roe <daniel@roe.dev>

authored by

Alex Savelyev
Daniel Roe
and committed by
GitHub
bf03fba3 1a48cfe4

+287 -12
+58 -9
app/assets/main.css
··· 7 7 8 8 :root[data-theme='dark'] { 9 9 /* background colors */ 10 - --bg: oklch(0.145 0 0); 11 - --bg-subtle: oklch(0.178 0 0); 12 - --bg-muted: oklch(0.218 0 0); 13 - --bg-elevated: oklch(0.252 0 0); 10 + --bg: var(--bg-color, oklch(0.145 0 0)); 11 + --bg-subtle: var(--bg-subtle-color, oklch(0.178 0 0)); 12 + --bg-muted: var(--bg-muted-color, oklch(0.218 0 0)); 13 + --bg-elevated: var(--bg-elevated-color, oklch(0.252 0 0)); 14 14 15 15 /* text colors */ 16 16 --fg: oklch(0.985 0 0); 17 17 --fg-muted: oklch(0.709 0 0); 18 18 --fg-subtle: oklch(0.633 0 0); 19 19 20 - /* border, seperator colors */ 20 + /* border, separator colors */ 21 21 --border: oklch(0.269 0 0); 22 22 --border-subtle: oklch(0.239 0 0); 23 23 --border-hover: oklch(0.371 0 0); ··· 43 43 --badge-pink: oklch(0.584 0.189 343); 44 44 } 45 45 46 + :root[data-theme='dark'][data-bg-theme='slate'] { 47 + --bg: oklch(0.129 0.012 264.695); 48 + --bg-subtle: oklch(0.159 0.022 262.421); 49 + --bg-muted: oklch(0.204 0.033 261.234); 50 + --bg-elevated: oklch(0.259 0.041 260.031); 51 + } 52 + 53 + :root[data-theme='dark'][data-bg-theme='zinc'] { 54 + --bg: oklch(0.141 0.005 285.823); 55 + --bg-subtle: oklch(0.168 0.005 285.894); 56 + --bg-muted: oklch(0.209 0.005 285.929); 57 + --bg-elevated: oklch(0.256 0.006 286.033); 58 + } 59 + 60 + :root[data-theme='dark'][data-bg-theme='stone'] { 61 + --bg: oklch(0.147 0.004 49.25); 62 + --bg-subtle: oklch(0.178 0.004 49.321); 63 + --bg-muted: oklch(0.218 0.004 49.386); 64 + --bg-elevated: oklch(0.252 0.007 34.298); 65 + } 66 + 67 + :root[data-theme='dark'][data-bg-theme='black'] { 68 + --bg: oklch(0 0 0); 69 + --bg-subtle: oklch(0.148 0 0); 70 + --bg-muted: oklch(0.204 0 0); 71 + --bg-elevated: oklch(0.264 0 0); 72 + } 73 + 46 74 :root[data-theme='light'] { 47 - --bg: oklch(1 0 0); 48 - --bg-subtle: oklch(0.979 0.001 286.375); 49 - --bg-muted: oklch(0.955 0 0); 50 - --bg-elevated: oklch(0.94 0 0); 75 + --bg: var(--bg-color, oklch(1 0 0)); 76 + --bg-subtle: var(--bg-subtle-color, oklch(0.979 0.001 286.375)); 77 + --bg-muted: var(--bg-muted-color, oklch(0.955 0 0)); 78 + --bg-elevated: var(--bg-elevated-color, oklch(0.94 0 0)); 51 79 52 80 --fg: oklch(0.145 0 0); 53 81 --fg-muted: oklch(0.439 0 0); ··· 73 101 --badge-orange: oklch(0.67 0.185 55); 74 102 --badge-pink: oklch(0.584 0.189 343); 75 103 --badge-cyan: oklch(0.571 0.181 210); 104 + } 105 + 106 + :root[data-theme='light'][data-bg-theme='slate'] { 107 + --bg: oklch(1 0 0); 108 + --bg-subtle: oklch(0.982 0.006 264.62); 109 + --bg-muted: oklch(0.96 0.041 261.234); 110 + --bg-elevated: oklch(0.943 0.013 255.52); 111 + } 112 + 113 + :root[data-theme='light'][data-bg-theme='zinc'] { 114 + --bg: oklch(1 0 0); 115 + --bg-subtle: oklch(0.979 0.004 286.53); 116 + --bg-muted: oklch(0.958 0.004 286.39); 117 + --bg-elevated: oklch(0.939 0.004 286.32); 118 + } 119 + 120 + :root[data-theme='light'][data-bg-theme='stone'] { 121 + --bg: oklch(1 0 0); 122 + --bg-subtle: oklch(0.979 0.005 48.762); 123 + --bg-muted: oklch(0.958 0.005 48.743); 124 + --bg-elevated: oklch(0.943 0.005 48.731); 76 125 } 77 126 78 127 @media (prefers-contrast: more) {
+36
app/components/Settings/BgThemePicker.vue
··· 1 + <script setup lang="ts"> 2 + const { backgroundThemes, selectedBackgroundTheme, setBackgroundTheme } = useBackgroundTheme() 3 + 4 + onPrehydrate(el => { 5 + const settings = JSON.parse(localStorage.getItem('npmx-settings') || '{}') 6 + const id = settings.preferredBackgroundTheme 7 + if (id) { 8 + const input = el.querySelector<HTMLInputElement>(`input[value="${id || 'neutral'}"]`) 9 + if (input) { 10 + input.checked = true 11 + } 12 + } 13 + }) 14 + </script> 15 + 16 + <template> 17 + <fieldset class="flex items-center gap-4"> 18 + <legend class="sr-only">{{ $t('settings.background_themes') }}</legend> 19 + <label 20 + v-for="theme in backgroundThemes" 21 + :key="theme.id" 22 + 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)" 23 + :style="{ backgroundColor: theme.value }" 24 + > 25 + <input 26 + type="radio" 27 + name="background-theme" 28 + class="sr-only" 29 + :value="theme.id" 30 + :checked="selectedBackgroundTheme === theme.id" 31 + :aria-label="theme.name" 32 + @change="setBackgroundTheme(theme.id)" 33 + /> 34 + </label> 35 + </fieldset> 36 + </template>
+1 -1
app/composables/useColors.ts
··· 81 81 if (options.watchHtmlAttributes && isClientSupported.value) { 82 82 useMutationObserver(document.documentElement, () => void colors.value, { 83 83 attributes: true, 84 - attributeFilter: ['class', 'style', 'data-theme'], 84 + attributeFilter: ['class', 'style', 'data-theme', 'data-bg-theme'], 85 85 }) 86 86 } 87 87
+31
app/composables/useSettings.ts
··· 2 2 import { useLocalStorage } from '@vueuse/core' 3 3 import { ACCENT_COLORS } from '#shared/utils/constants' 4 4 import type { LocaleObject } from '@nuxtjs/i18n' 5 + import { BACKGROUND_THEMES } from '#shared/utils/constants' 6 + 7 + type BackgroundThemeId = keyof typeof BACKGROUND_THEMES 5 8 6 9 type AccentColorId = keyof typeof ACCENT_COLORS 7 10 ··· 15 18 includeTypesInInstall: boolean 16 19 /** Accent color theme */ 17 20 accentColorId: AccentColorId | null 21 + /** Preferred background shade */ 22 + preferredBackgroundTheme: BackgroundThemeId | null 18 23 /** Hide platform-specific packages (e.g., @scope/pkg-linux-x64) from search results */ 19 24 hidePlatformPackages: boolean 20 25 /** User-selected locale */ ··· 30 35 accentColorId: null, 31 36 hidePlatformPackages: true, 32 37 selectedLocale: null, 38 + preferredBackgroundTheme: null, 33 39 sidebar: { 34 40 collapsed: [], 35 41 }, ··· 93 99 setAccentColor, 94 100 } 95 101 } 102 + 103 + export function useBackgroundTheme() { 104 + const backgroundThemes = Object.entries(BACKGROUND_THEMES).map(([id, value]) => ({ 105 + id: id as BackgroundThemeId, 106 + name: id, 107 + value, 108 + })) 109 + 110 + const { settings } = useSettings() 111 + 112 + function setBackgroundTheme(id: BackgroundThemeId | null) { 113 + if (id) { 114 + document.documentElement.dataset.bgTheme = id 115 + } else { 116 + document.documentElement.removeAttribute('data-bg-theme') 117 + } 118 + settings.value.preferredBackgroundTheme = id 119 + } 120 + 121 + return { 122 + backgroundThemes, 123 + selectedBackgroundTheme: computed(() => settings.value.preferredBackgroundTheme), 124 + setBackgroundTheme, 125 + } 126 + }
+8
app/pages/settings.vue
··· 97 97 </span> 98 98 <SettingsAccentColorPicker /> 99 99 </div> 100 + 101 + <!-- Background themes --> 102 + <div class="space-y-3"> 103 + <span class="block text-sm text-fg font-medium"> 104 + {{ $t('settings.background_themes') }} 105 + </span> 106 + <SettingsBgThemePicker /> 107 + </div> 100 108 </div> 101 109 </section> 102 110
+6
app/utils/prehydrate.ts
··· 35 35 document.documentElement.style.setProperty('--accent-color', color) 36 36 } 37 37 38 + // Apply background accent 39 + const preferredBackgroundTheme = settings.preferredBackgroundTheme 40 + if (preferredBackgroundTheme) { 41 + document.documentElement.dataset.bgTheme = preferredBackgroundTheme 42 + } 43 + 38 44 // Read and apply package manager preference 39 45 const storedPM = localStorage.getItem('npmx-pm') 40 46 // Parse the stored value (it's stored as a JSON string by useLocalStorage)
+2 -1
i18n/locales/en.json
··· 77 77 "help_translate": "Help translate npmx", 78 78 "accent_colors": "Accent colors", 79 79 "clear_accent": "Clear accent color", 80 - "translation_progress": "Translation progress" 80 + "translation_progress": "Translation progress", 81 + "background_themes": "Background shade" 81 82 }, 82 83 "i18n": { 83 84 "missing_keys": "{count} missing translation | {count} missing translations",
+2 -1
lunaria/files/en-US.json
··· 77 77 "help_translate": "Help translate npmx", 78 78 "accent_colors": "Accent colors", 79 79 "clear_accent": "Clear accent color", 80 - "translation_progress": "Translation progress" 80 + "translation_progress": "Translation progress", 81 + "background_themes": "Background shade" 81 82 }, 82 83 "i18n": { 83 84 "missing_keys": "{count} missing translation | {count} missing translations",
+8
shared/utils/constants.ts
··· 39 39 violet: 'oklch(0.714 0.148 286.067)', 40 40 coral: 'oklch(0.704 0.177 14.75)', 41 41 } as const 42 + 43 + export const BACKGROUND_THEMES = { 44 + neutral: 'oklch(0.555 0 0)', 45 + stone: 'oklch(0.555 0.013 58.123)', 46 + zinc: 'oklch(0.555 0.016 285.931)', 47 + slate: 'oklch(0.555 0.046 257.407)', 48 + black: 'oklch(0.4 0 0)', 49 + } as const
+135
test/nuxt/a11y.spec.ts
··· 108 108 ProvenanceBadge, 109 109 Readme, 110 110 SettingsAccentColorPicker, 111 + SettingsBgThemePicker, 111 112 SettingsToggle, 112 113 TerminalExecute, 113 114 TerminalInstall, ··· 1419 1420 }) 1420 1421 }) 1421 1422 1423 + describe('SettingsBgThemePicker', () => { 1424 + it('should have no accessibility violations', async () => { 1425 + const component = await mountSuspended(SettingsBgThemePicker) 1426 + const results = await runAxe(component) 1427 + expect(results.violations).toEqual([]) 1428 + }) 1429 + }) 1430 + 1422 1431 describe('TooltipBase', () => { 1423 1432 it('should have no accessibility violations when hidden', async () => { 1424 1433 const component = await mountSuspended(TooltipBase, { ··· 1828 1837 }) 1829 1838 }) 1830 1839 }) 1840 + 1841 + describe('background theme accessibility', () => { 1842 + const pairs = [ 1843 + ['light', 'neutral'], 1844 + ['dark', 'neutral'], 1845 + ['light', 'stone'], 1846 + ['dark', 'stone'], 1847 + ['light', 'zinc'], 1848 + ['dark', 'zinc'], 1849 + ['light', 'slate'], 1850 + ['dark', 'slate'], 1851 + ['light', 'black'], 1852 + ['dark', 'black'], 1853 + ] as const 1854 + 1855 + function applyTheme(colorMode: string, bgTheme: string | null) { 1856 + document.documentElement.dataset.theme = colorMode 1857 + document.documentElement.classList.add(colorMode) 1858 + if (bgTheme) document.documentElement.dataset.bgTheme = bgTheme 1859 + } 1860 + 1861 + afterEach(() => { 1862 + document.documentElement.removeAttribute('data-theme') 1863 + document.documentElement.removeAttribute('data-bg-theme') 1864 + document.documentElement.classList.remove('light', 'dark') 1865 + }) 1866 + 1867 + const packageResult = { 1868 + package: { 1869 + name: 'vue', 1870 + version: '3.5.0', 1871 + description: 'Framework', 1872 + date: '2024-01-15T00:00:00.000Z', 1873 + keywords: [], 1874 + links: {}, 1875 + publisher: { username: 'evan' }, 1876 + }, 1877 + score: { final: 0.9, detail: { quality: 0.9, popularity: 0.9, maintenance: 0.9 } }, 1878 + searchScore: 100000, 1879 + } 1880 + 1881 + const components = [ 1882 + { name: 'AppHeader', mount: () => mountSuspended(AppHeader) }, 1883 + { name: 'AppFooter', mount: () => mountSuspended(AppFooter) }, 1884 + { name: 'HeaderSearchBox', mount: () => mountSuspended(HeaderSearchBox) }, 1885 + { 1886 + name: 'LoadingSpinner', 1887 + mount: () => mountSuspended(LoadingSpinner, { props: { text: 'Loading...' } }), 1888 + }, 1889 + { 1890 + name: 'SettingsToggle', 1891 + mount: () => 1892 + mountSuspended(SettingsToggle, { props: { label: 'Feature', description: 'Desc' } }), 1893 + }, 1894 + { name: 'SettingsBgThemePicker', mount: () => mountSuspended(SettingsBgThemePicker) }, 1895 + { 1896 + name: 'ProvenanceBadge', 1897 + mount: () => 1898 + mountSuspended(ProvenanceBadge, { 1899 + props: { provider: 'github', packageName: 'vue', version: '3.0.0' }, 1900 + }), 1901 + }, 1902 + { 1903 + name: 'TerminalInstall', 1904 + mount: () => mountSuspended(TerminalInstall, { props: { packageName: 'vue' } }), 1905 + }, 1906 + { 1907 + name: 'LicenseDisplay', 1908 + mount: () => mountSuspended(LicenseDisplay, { props: { license: 'MIT' } }), 1909 + }, 1910 + { 1911 + name: 'DateTime', 1912 + mount: () => mountSuspended(DateTime, { props: { datetime: '2024-01-15T12:00:00.000Z' } }), 1913 + }, 1914 + { 1915 + name: 'ViewModeToggle', 1916 + mount: () => mountSuspended(ViewModeToggle, { props: { modelValue: 'cards' } }), 1917 + }, 1918 + { 1919 + name: 'TooltipApp', 1920 + mount: () => 1921 + mountSuspended(TooltipApp, { 1922 + props: { text: 'Tooltip' }, 1923 + slots: { default: '<button>Trigger</button>' }, 1924 + }), 1925 + }, 1926 + { 1927 + name: 'CollapsibleSection', 1928 + mount: () => 1929 + mountSuspended(CollapsibleSection, { 1930 + props: { title: 'Title', id: 'section' }, 1931 + slots: { default: '<p>Content</p>' }, 1932 + }), 1933 + }, 1934 + { 1935 + name: 'FilterChips', 1936 + mount: () => 1937 + mountSuspended(FilterChips, { 1938 + props: { 1939 + chips: [{ id: 'text', type: 'text', label: 'Search', value: 'react' }] as FilterChip[], 1940 + }, 1941 + }), 1942 + }, 1943 + { 1944 + name: 'PackageCard', 1945 + mount: () => mountSuspended(PackageCard, { props: { result: packageResult } }), 1946 + }, 1947 + { 1948 + name: 'PackageList', 1949 + mount: () => mountSuspended(PackageList, { props: { results: [packageResult] } }), 1950 + }, 1951 + ] 1952 + 1953 + for (const { name, mount } of components) { 1954 + describe(`${name} colors`, () => { 1955 + for (const [colorMode, bgTheme] of pairs) { 1956 + it(`${colorMode}/${bgTheme}`, async () => { 1957 + applyTheme(colorMode, bgTheme) 1958 + const results = await runAxe(await mount()) 1959 + await new Promise(resolve => setTimeout(resolve, 2000)) 1960 + expect(results.violations).toEqual([]) 1961 + }) 1962 + } 1963 + }) 1964 + } 1965 + })