Skip to content
58 changes: 49 additions & 9 deletions app/assets/main.css
Original file line number Diff line number Diff line change
Expand Up @@ -15,13 +15,14 @@

/* text colors */
--fg: oklch(0.982 0 0);
--fg-muted: oklch(0.749 0 0);
--fg-subtle: oklch(0.673 0 0);
--fg-muted: oklch(0.849 0 0);
--fg-subtle: oklch(0.773 0 0);

/* border, separator colors */
--border: oklch(0.269 0 0);
--border-subtle: oklch(0.239 0 0);
--border-hover: oklch(0.371 0 0);
--border-elevated: oklch(0.369 0 0);

/* accent color, set by user from settings */
--accent: var(--accent-color, oklch(0.787 0.128 230.318));
Expand Down Expand Up @@ -81,19 +82,38 @@
--bg-elevated: oklch(0.264 0 0);
}

:root[data-theme='dark'][data-fg-theme='contrast'] {
--fg: oklch(1 0 0);
--fg-muted: oklch(0.95 0 0);
--fg-subtle: oklch(0.9 0 0);
}

:root[data-theme='dark'][data-fg-theme='standard'] {
--fg: oklch(0.982 0 0);
--fg-muted: oklch(0.849 0 0);
--fg-subtle: oklch(0.773 0 0);
}

:root[data-theme='dark'][data-fg-theme='muted'] {
--fg: oklch(0.969 0 0);
--fg-muted: oklch(0.749 0 0);
--fg-subtle: oklch(0.673 0 0);
}

:root[data-theme='light'] {
--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.001 286.76));
--bg-elevated: var(--bg-elevated-color, oklch(0.94 0.002 287.29));

--fg: oklch(0.146 0 0);
--fg-muted: oklch(0.398 0 0);
--fg-subtle: oklch(0.48 0 0);
--fg: oklch(0.046 0 0);
--fg-muted: oklch(0.198 0 0);
--fg-subtle: oklch(0.28 0 0);

--border: oklch(0.8514 0 0);
--border-subtle: oklch(0.922 0 0);
--border-hover: oklch(0.715 0 0);
--border-elevated: oklch(0.75 0 0);

--accent: var(--accent-color, oklch(0.5 0.16 247.27));
--accent-muted: var(--accent-color, oklch(0.205 0 0));
Expand Down Expand Up @@ -147,29 +167,49 @@
--bg-subtle: var(--bg-subtle-color, oklch(0.979 0 0));
}

:root[data-theme='light'][data-fg-theme='contrast'] {
--fg: oklch(0 0 0);
--fg-muted: oklch(0.1 0 0);
--fg-subtle: oklch(0.2 0 0);
}

:root[data-theme='light'][data-fg-theme='standard'] {
--fg: oklch(0.046 0 0);
--fg-muted: oklch(0.198 0 0);
--fg-subtle: oklch(0.28 0 0);
}

:root[data-theme='light'][data-fg-theme='muted'] {
--fg: oklch(0.146 0 0);
--fg-muted: oklch(0.398 0 0);
--fg-subtle: oklch(0.48 0 0);
}

@media (prefers-contrast: more) {
:root[data-theme='dark'] {
/* text colors */
--fg: oklch(1 0 0);
--fg-muted: oklch(0.769 0 0);
--fg-subtle: oklch(0.693 0 0);
--fg-muted: oklch(0.869 0 0);
--fg-subtle: oklch(0.793 0 0);

/* border, separator colors */
--border: oklch(0.769 0 0);
--border-subtle: oklch(0.739 0 0);
--border-hover: oklch(0.771 0 0);
--border-elevated: oklch(0.869 0 0);
}

:root[data-theme='light'] {
/* text colors */
--fg: oklch(0 0 0);
--fg-muted: oklch(0.329 0 0);
--fg-subtle: oklch(0.4 0 0);
--fg-muted: oklch(0.229 0 0);
--fg-subtle: oklch(0.3 0 0);

/* border, separator colors */
--border: oklch(0.3514 0 0);
--border-subtle: oklch(0.422 0 0);
--border-hover: oklch(0.315 0 0);
--border-elevated: oklch(0.25 0 0);
}
}

Expand Down
4 changes: 2 additions & 2 deletions app/components/Button/Base.vue
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ defineExpose({
<template>
<button
ref="el"
class="group gap-x-1 items-center justify-center font-mono border border-border rounded-md transition-all duration-200 cursor-pointer disabled:(opacity-40 cursor-not-allowed border-transparent)"
class="group gap-x-1 items-center justify-center font-mono border border-border-elevated/60 rounded-md transition-all duration-200 cursor-pointer disabled:(opacity-40 cursor-not-allowed border-transparent)"
:class="{
'inline-flex': !block,
'flex': block,
Expand All @@ -60,7 +60,7 @@ defineExpose({
'text-xs p-0.5': size === 'sm' && !!iconOnly,
'px-2': size === 'sm' && !classicon && !iconOnly,
'ps-1.5 pe-2': size === 'sm' && !!classicon && !iconOnly,
'bg-transparent text-fg hover:enabled:(bg-fg/10) focus-visible:enabled:(bg-fg/10) aria-pressed:(bg-fg/10 border-fg/20 hover:enabled:(bg-fg/20 text-fg/50))':
'bg-transparent text-fg hover:enabled:(bg-bg-elevated) focus-visible:enabled:(bg-bg-elevated) aria-pressed:(bg-bg-muted border-border-hover hover:enabled:(bg-bg-muted text-fg/50))':
variant === 'secondary',
'text-bg bg-fg hover:enabled:(bg-fg/50) focus-visible:enabled:(bg-fg/50) aria-pressed:(bg-fg text-bg border-fg hover:enabled:(text-bg/50))':
variant === 'primary',
Expand Down
2 changes: 1 addition & 1 deletion app/components/Input/Base.vue
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ const inputAttrs = computed(() => ({
v-bind="inputAttrs"
@focus="emit('focus', $event)"
@blur="emit('blur', $event)"
class="appearance-none bg-bg-subtle border border-border font-mono text-fg placeholder:text-fg-subtle transition-[border-color,outline-color] duration-300 hover:border-fg-subtle outline-2 outline-transparent outline-offset-2 focus:border-accent focus-visible:outline-accent/70 disabled:(opacity-50 cursor-not-allowed)"
class="appearance-none bg-bg-subtle border border-border-elevated font-mono text-fg placeholder:text-fg-subtle transition-[border-color,outline-color] duration-300 hover:border-fg-subtle outline-2 outline-transparent outline-offset-2 focus:border-accent focus-visible:outline-accent/70 disabled:(opacity-50 cursor-not-allowed)"
:class="{
'text-xs leading-[1.2] px-2 py-2 rounded-md': size === 'sm',
'text-sm leading-none px-3 py-2.5 rounded-lg': size === 'md',
Expand Down
2 changes: 1 addition & 1 deletion app/components/Select/Base.vue
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ const props = withDefaults(defineProps<SelectBaseProps>(), {
<template>
<select
v-model="model"
class="bg-bg border border-border font-mono text-fg placeholder:text-fg-subtle transition-[border-color,outline-color] duration-300 hover:border-fg-subtle outline-2 outline-transparent outline-offset-2 focus:border-accent focus-visible:outline-accent/70 disabled:(opacity-50 cursor-not-allowed)"
class="bg-bg border border-border-elevated font-mono text-fg placeholder:text-fg-subtle transition-[border-color,outline-color] duration-300 hover:border-fg-subtle outline-2 outline-transparent outline-offset-2 focus:border-accent focus-visible:outline-accent/70 disabled:(opacity-50 cursor-not-allowed)"
:class="[SELECT_SIZES[size]]"
:disabled="
/** Catching Vue render-bug of invalid `disabled=false` attribute in the final HTML */
Expand Down
67 changes: 67 additions & 0 deletions app/components/Settings/FgThemePicker.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
<script setup lang="ts">
const { foregroundThemes, selectedForegroundTheme, setForegroundTheme } = useForegroundTheme()

onPrehydrate(el => {
const settings = JSON.parse(localStorage.getItem('npmx-settings') || '{}')
// Hardcoded — onPrehydrate is serialized into a <script> tag and cannot reference imports
const defaultId = 'standard'
const id = settings.preferredForegroundTheme
if (id) {
const input = el.querySelector<HTMLInputElement>(`input[value="${id}"]`)
if (input) {
Comment thread
coderabbitai[bot] marked this conversation as resolved.
input.checked = true
input.setAttribute('checked', '')
}
// Remove checked from the server-default (clear button, value="")
if (id !== defaultId) {
const clearInput = el.querySelector<HTMLInputElement>(`input[value="${defaultId}"]`)
if (clearInput) {
clearInput.checked = false
clearInput.removeAttribute('checked')
}
}
}
})
</script>

<template>
<fieldset
class="flex items-center gap-4 has-[input:focus-visible]:(outline-solid outline-accent/70 outline-offset-4) rounded-xl w-fit"
>
<legend class="sr-only">{{ $t('settings.foreground_themes.label') }}</legend>
<label
v-for="theme in foregroundThemes"
:key="theme.id"
class="size-6 rounded-full transition-transform duration-150 motion-safe:hover:scale-110 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="foreground-theme"
class="sr-only"
:value="theme.id"
:checked="
selectedForegroundTheme === theme.id ||
(!selectedForegroundTheme && theme.id === 'standard')
"
:aria-label="theme.label"
@change="setForegroundTheme(theme.id)"
/>
</label>
</fieldset>
</template>

<style scoped>
@media (forced-colors: active) {
/* keep background theme swatches visible in forced colors. */
label {
forced-color-adjust: none;
border: 1px solid CanvasText;

&:has(> input:checked) {
outline: 2px solid Highlight;
outline-offset: 2px;
}
}
}
</style>
2 changes: 1 addition & 1 deletion app/components/VersionSelector.vue
Original file line number Diff line number Diff line change
Expand Up @@ -531,7 +531,7 @@ watch(
type="button"
aria-haspopup="listbox"
:aria-expanded="isOpen"
class="break-all text-start text-fg-subtle font-mono text-sm hover:text-fg transition-[color] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-bg rounded"
class="break-all text-start font-mono text-sm hover:text-accent transition-[color] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-bg rounded"
@click="isOpen = !isOpen"
@keydown="handleButtonKeydown"
data-testid="version-selector-button"
Expand Down
58 changes: 58 additions & 0 deletions app/composables/useCommandPaletteGlobalCommands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ export function useCommandPaletteGlobalCommands() {
const colorMode = useColorMode()
const { accentColors, selectedAccentColor, setAccentColor } = useAccentColor()
const { backgroundThemes, selectedBackgroundTheme, setBackgroundTheme } = useBackgroundTheme()
const { foregroundThemes, selectedForegroundTheme, setForegroundTheme } = useForegroundTheme()
const connectorModal = useModal('connector-modal')
const authModal = useModal('auth-modal')
const keyboardShortcutsModal = useModal('keyboard-shortcuts-modal')
Expand Down Expand Up @@ -127,6 +128,16 @@ export function useCommandPaletteGlobalCommands() {
if (!id) return null
return backgroundThemes.value.find(theme => theme.id === id)?.value ?? null
})
const currentForegroundThemeLabel = computed(() => {
const id = selectedForegroundTheme.value
if (!id) return t('settings.foreground_themes.standard')
return foregroundThemes.value.find(theme => theme.id === id)?.label ?? id
})
const currentForegroundThemePreview = computed(() => {
const id = selectedForegroundTheme.value
if (!id) return null
return foregroundThemes.value.find(theme => theme.id === id)?.value ?? null
})
const localeCommands = computed<CommandPaletteCommand[]>(() =>
locales.value.map(entry => {
const code = typeof entry === 'string' ? entry : entry.code
Expand Down Expand Up @@ -205,6 +216,32 @@ export function useCommandPaletteGlobalCommands() {
}))
})

const foregroundThemeCommands = computed<CommandPaletteCommand[]>(() => {
const activeId = selectedForegroundTheme.value

return foregroundThemes.value.map(theme => ({
id: `foreground-theme:${theme.id}`,
group: 'settings' as const,
label: theme.label,
keywords: [theme.label, theme.id, t('settings.foreground_themes.label'), t('settings.theme')],
iconClass: 'i-lucide:swatch-book',
previewColor: theme.value,
active: theme.id === 'standard' ? !activeId : theme.id === activeId,
activeLabel: (theme.id === 'standard' ? !activeId : theme.id === activeId)
? t('command_palette.current')
: null,
action: runThenAnnounce(
() => {
setForegroundTheme(theme.id)
},
() =>
t('command_palette.announcements.foreground_theme_changed', {
theme: theme.label,
}),
),
}))
})

const globalCommands = computed<CommandPaletteCommand[]>(() => {
const items: CommandPaletteCommand[] = [
{
Expand Down Expand Up @@ -531,6 +568,22 @@ export function useCommandPaletteGlobalCommands() {
setView('background-themes')
},
},
{
id: 'foreground-themes',
group: 'settings',
label: t('settings.foreground_themes.label'),
keywords: [
t('settings.foreground_themes.label'),
currentForegroundThemeLabel.value,
t('settings.theme'),
],
iconClass: 'i-lucide:swatch-book',
badge: currentForegroundThemeLabel.value,
previewColor: currentForegroundThemePreview.value,
action: async () => {
setView('foreground-themes')
},
},
]

const npmUsername = npmUser.value
Expand Down Expand Up @@ -670,6 +723,11 @@ export function useCommandPaletteGlobalCommands() {
placeholder: t('settings.background_themes.label'),
subtitle: t('settings.background_themes.label'),
},
'foreground-themes': {
commands: foregroundThemeCommands.value,
placeholder: t('settings.foreground_themes.label'),
subtitle: t('settings.foreground_themes.label'),
},
}),
)

Expand Down
41 changes: 40 additions & 1 deletion app/composables/useSettings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,10 @@ import type { RemovableRef } from '@vueuse/core'
import type { LocaleObject } from '@nuxtjs/i18n'
import { useLocalStorage, useMounted } from '@vueuse/core'
import { ACCENT_COLORS, type AccentColorId } from '#shared/utils/constants'
import { BACKGROUND_THEMES } from '#shared/utils/constants'
import { BACKGROUND_THEMES, FOREGROUND_THEMES } from '#shared/utils/constants'

type BackgroundThemeId = keyof typeof BACKGROUND_THEMES
type ForegroundThemeId = keyof typeof FOREGROUND_THEMES

/** Available search providers */
export type SearchProvider = 'npm' | 'algolia'
Expand All @@ -21,6 +22,8 @@ export interface AppSettings {
accentColorId: AccentColorId | null
/** Preferred background shade */
preferredBackgroundTheme: BackgroundThemeId | null
/** Preferred foreground shade */
preferredForegroundTheme: ForegroundThemeId | null
/** Hide platform-specific packages (e.g., @scope/pkg-linux-x64) from search results */
hidePlatformPackages: boolean
/** Enable weekly download graph pulse looping animation */
Expand Down Expand Up @@ -67,6 +70,7 @@ const DEFAULT_SETTINGS: AppSettings = {
enableGraphPulseLooping: false,
selectedLocale: null,
preferredBackgroundTheme: null,
preferredForegroundTheme: null,
searchProvider: import.meta.test ? 'npm' : 'algolia',
instantSearch: true,
keyboardShortcuts: true,
Expand Down Expand Up @@ -256,6 +260,41 @@ export function useBackgroundTheme() {
}
}

export function useForegroundTheme() {
const { t } = useI18n()

const fgThemeLabels = computed<Record<ForegroundThemeId, string>>(() => ({
muted: t('settings.foreground_themes.muted'),
standard: t('settings.foreground_themes.standard'),
contrast: t('settings.foreground_themes.contrast'),
}))

const foregroundThemes = computed(() =>
Object.entries(FOREGROUND_THEMES).map(([id, value]) => ({
id: id as ForegroundThemeId,
label: fgThemeLabels.value[id as ForegroundThemeId],
value,
})),
)

const { settings } = useSettings()

function setForegroundTheme(id: ForegroundThemeId | null) {
if (id) {
document.documentElement.dataset.fgTheme = id
} else {
document.documentElement.removeAttribute('data-fg-theme')
}
settings.value.preferredForegroundTheme = id
}

return {
foregroundThemes,
selectedForegroundTheme: computed(() => settings.value.preferredForegroundTheme),
setForegroundTheme,
}
}

export function useCodeContainer() {
const { settings } = useSettings()

Expand Down
Loading
Loading