Files
Starlight_Lancher/apps/app-frontend/src/components/ui/settings/AppearanceSettings.vue
T
2026-10-02 23:04:12 +08:00

1542 lines
50 KiB
Vue

<script setup lang="ts">
import {
CheckIcon,
ImageIcon,
LayoutTemplateIcon,
MinimizeIcon,
TrashIcon,
UploadIcon,
} from '@modrinth/assets'
import {
Combobox,
defineMessages,
injectNotificationManager,
type MessageDescriptor,
NewButton as Button,
Slider,
ThemeSelector,
Toggle,
useVIntl,
} from '@modrinth/ui'
import { convertFileSrc } from '@tauri-apps/api/core'
import { appDataDir, join } from '@tauri-apps/api/path'
import { open } from '@tauri-apps/plugin-dialog'
import { exists, mkdir, readFile, remove, writeFile } from '@tauri-apps/plugin-fs'
import { computed, onMounted, ref, watch } from 'vue'
import { get, set } from '@/helpers/settings.ts'
import { getOS } from '@/helpers/utils'
import { setUiScale, uiScale } from '@/helpers/ui-scale'
import { useTheming } from '@/store/state'
import {
type AccentColor,
type CloseBehavior,
type ColorTheme,
DEFAULT_CUSTOM_ACCENT_COLOR,
deriveAccentVariants,
type FeatureFlag,
hexToHsl,
type HomeLayout,
hslToHex,
parseCustomAccentColor,
} from '@/store/theme.ts'
import {
buildFontFamilyOptions,
canonicalFontFamily,
DEFAULT_MONO_FONT_STACK,
DEFAULT_UI_FONT_STACK,
type FontFamilyOption,
type FontFamilyOptionOrDivider,
optionValueToFontSetting,
resolveFontFamily,
type SystemFontFamily,
} from '@/helpers/font-family.ts'
import { getSystemFontFamilies } from '@/helpers/system-fonts.ts'
import SettingsRow from './SettingsRow.vue'
import SettingsSection from './SettingsSection.vue'
const props = withDefaults(
defineProps<{
scope?: 'interface' | 'home-navigation' | 'content-downloads'
}>(),
{ scope: 'interface' },
)
const themeStore = useTheming()
const { formatMessage } = useVIntl()
const { handleError } = injectNotificationManager()
const skipNonEssentialWarningsFlag: FeatureFlag = 'skip_non_essential_warnings'
const skipUnknownPackWarningFlag: FeatureFlag = 'skip_unknown_pack_warning'
const showPlayTimeFlag: FeatureFlag = 'show_instance_play_time'
const pageTransitionsFlag: FeatureFlag = 'page_transitions'
const autoInstallDependenciesFlag: FeatureFlag = 'auto_install_dependencies'
const messages = defineMessages({
colorThemeTitle: {
id: 'app.appearance-settings.color-theme.title',
defaultMessage: 'Color theme',
},
colorThemeDescription: {
id: 'app.appearance-settings.color-theme.description',
defaultMessage: 'Select your preferred color theme for Starlight Launcher.',
},
accentColorTitle: {
id: 'app.appearance-settings.accent-color.title',
defaultMessage: 'Accent color',
},
accentColorDescription: {
id: 'app.appearance-settings.accent-color.description',
defaultMessage: 'Choose the color used for buttons, selections, and highlights.',
},
accentColorPink: {
id: 'app.appearance-settings.accent-color.pink',
defaultMessage: 'Pink',
},
accentColorOrange: {
id: 'app.appearance-settings.accent-color.orange',
defaultMessage: 'Orange',
},
accentColorGreen: {
id: 'app.appearance-settings.accent-color.green',
defaultMessage: 'Green',
},
accentColorBlue: {
id: 'app.appearance-settings.accent-color.blue',
defaultMessage: 'Blue',
},
accentColorPurple: {
id: 'app.appearance-settings.accent-color.purple',
defaultMessage: 'Purple',
},
accentColorCustom: {
id: 'app.appearance-settings.accent-color.custom',
defaultMessage: 'Custom',
},
accentColorSystem: {
id: 'app.appearance-settings.accent-color.system',
defaultMessage: 'Follow system',
},
accentColorSystemUnsupported: {
id: 'app.appearance-settings.accent-color.system-unsupported',
defaultMessage: 'System unsupported',
},
accentColorSystemUnsupportedLabel: {
id: 'app.appearance-settings.accent-color.system-unsupported-label',
defaultMessage: 'Follow system: System unsupported',
},
accentColorCustomHue: {
id: 'app.appearance-settings.accent-color.custom-hue',
defaultMessage: 'Hue',
},
accentColorCustomHex: {
id: 'app.appearance-settings.accent-color.custom-hex',
defaultMessage: 'Hex color',
},
accentColorCustomPreviewLight: {
id: 'app.appearance-settings.accent-color.custom-preview-light',
defaultMessage: 'Light theme',
},
accentColorCustomPreviewDark: {
id: 'app.appearance-settings.accent-color.custom-preview-dark',
defaultMessage: 'Dark theme',
},
customBackgroundTitle: {
id: 'app.appearance-settings.custom-background.title',
defaultMessage: 'Launcher background',
},
customBackgroundDescription: {
id: 'app.appearance-settings.custom-background.description',
defaultMessage:
'Choose a custom image and fine-tune how it blends with the launcher interface.',
},
customBackgroundEmpty: {
id: 'app.appearance-settings.custom-background.empty',
defaultMessage: 'No custom background selected',
},
customBackgroundChoose: {
id: 'app.appearance-settings.custom-background.choose',
defaultMessage: 'Choose image',
},
customBackgroundReplace: {
id: 'app.appearance-settings.custom-background.replace',
defaultMessage: 'Replace image',
},
customBackgroundRemove: {
id: 'app.appearance-settings.custom-background.remove',
defaultMessage: 'Remove',
},
customBackgroundBlur: {
id: 'app.appearance-settings.custom-background.blur',
defaultMessage: 'Background blur',
},
customBackgroundBlurDescription: {
id: 'app.appearance-settings.custom-background.blur-description',
defaultMessage: 'Soften image details to keep launcher content easy to read.',
},
customBackgroundOpacity: {
id: 'app.appearance-settings.custom-background.opacity',
defaultMessage: 'Background visibility',
},
customBackgroundOpacityDescription: {
id: 'app.appearance-settings.custom-background.opacity-description',
defaultMessage: 'Control how strongly the image shows through the interface.',
},
transparentBackgroundTitle: {
id: 'app.appearance-settings.transparent-background.title',
defaultMessage: 'Transparent background',
},
transparentBackgroundDescription: {
id: 'app.appearance-settings.transparent-background.description',
defaultMessage: 'Let your desktop show through the launcher window.',
},
transparentBackgroundOpacity: {
id: 'app.appearance-settings.transparent-background.opacity',
defaultMessage: 'Interface opacity',
},
transparentBackgroundOpacityDescription: {
id: 'app.appearance-settings.transparent-background.opacity-description',
defaultMessage:
'Lower values show more of your desktop. Panels stay more solid than the background to keep text readable.',
},
transparentBackgroundBlurTitle: {
id: 'app.appearance-settings.transparent-background.blur-title',
defaultMessage: 'Background blur',
},
transparentBackgroundBlurDescription: {
id: 'app.appearance-settings.transparent-background.blur-description',
defaultMessage:
'Frost what shows through the window. Dragging or resizing may feel less smooth while this is on.',
},
transparentBackgroundConflict: {
id: 'app.appearance-settings.transparent-background.conflict',
defaultMessage: 'Your custom background image is hidden while this is enabled.',
},
advancedRenderingTitle: {
id: 'app.appearance-settings.advanced-rendering.title',
defaultMessage: 'Advanced rendering',
},
advancedRenderingDescription: {
id: 'app.appearance-settings.advanced-rendering.description',
defaultMessage:
'Enables advanced rendering such as blur effects that may cause performance issues without hardware-accelerated rendering.',
},
uiScaleTitle: {
id: 'app.appearance-settings.ui-scale.title',
defaultMessage: 'Launcher scale',
},
uiScaleDescription: {
id: 'app.appearance-settings.ui-scale.description',
defaultMessage:
'Scale the whole launcher interface. A smaller scale fits more content, which helps when embedded pages are cut off.',
},
pageTransitionsTitle: {
id: 'app.appearance-settings.page-transitions.title',
defaultMessage: 'Page transition animations',
},
pageTransitionsDescription: {
id: 'app.appearance-settings.page-transitions.description',
defaultMessage: 'Animate content when switching between launcher pages.',
},
autoInstallDependenciesTitle: {
id: 'app.appearance-settings.auto-install-dependencies.title',
defaultMessage: 'Automatically install dependencies',
},
autoInstallDependenciesDescription: {
id: 'app.appearance-settings.auto-install-dependencies.description',
defaultMessage:
'Download required dependencies when installing content. You can adjust the selection in the confirmation dialog before each install.',
},
hideNametagTitle: {
id: 'app.appearance-settings.hide-nametag.title',
defaultMessage: 'Hide nametag',
},
hideNametagDescription: {
id: 'app.appearance-settings.hide-nametag.description',
defaultMessage: 'Disables the nametag above your player on the skins page.',
},
nativeDecorationsTitle: {
id: 'app.appearance-settings.native-decorations.title',
defaultMessage: 'Native decorations',
},
nativeDecorationsDescription: {
id: 'app.appearance-settings.native-decorations.description',
defaultMessage: 'Use system window frame (app restart required).',
},
closeBehaviorTitle: {
id: 'app.appearance-settings.close-behavior.title',
defaultMessage: 'Choose how to close Starlight Launcher',
},
closeBehaviorDescription: {
id: 'app.appearance-settings.close-behavior.description',
defaultMessage: 'Choose whether closing the window exits the launcher or hides it to the tray.',
},
closeBehaviorAsk: {
id: 'app.appearance-settings.close-behavior.ask',
defaultMessage: 'Ask every time',
},
closeBehaviorClose: {
id: 'app.appearance-settings.close-behavior.close',
defaultMessage: 'Close directly',
},
closeBehaviorLightweight: {
id: 'app.appearance-settings.close-behavior.lightweight',
defaultMessage: 'Hide to tray',
},
defaultLandingPageTitle: {
id: 'app.appearance-settings.default-landing-page.title',
defaultMessage: 'Default landing page',
},
defaultLandingPageDescription: {
id: 'app.appearance-settings.default-landing-page.description',
defaultMessage: 'Change the page to which the launcher opens on.',
},
defaultLandingPageHome: {
id: 'app.appearance-settings.default-landing-page.home',
defaultMessage: 'Home',
},
defaultLandingPageLibrary: {
id: 'app.appearance-settings.default-landing-page.library',
defaultMessage: 'Library',
},
defaultLandingPageDiscoverContent: {
id: 'app.appearance-settings.default-landing-page.discover-content',
defaultMessage: 'Discover content',
},
homeLayoutTitle: {
id: 'app.appearance-settings.home-layout.title',
defaultMessage: 'Home layout',
},
homeLayoutDescription: {
id: 'app.appearance-settings.home-layout.description',
defaultMessage: 'Choose between Information Home and a focused instance launcher.',
},
homeLayoutStandard: {
id: 'app.appearance-settings.home-layout.standard',
defaultMessage: 'Information',
},
homeLayoutMinimal: {
id: 'app.appearance-settings.home-layout.minimal',
defaultMessage: 'Minimal',
},
selectOption: {
id: 'app.appearance-settings.select-option',
defaultMessage: 'Select an option',
},
toggleSidebarTitle: {
id: 'app.appearance-settings.toggle-sidebar.title',
defaultMessage: 'Toggle sidebar',
},
toggleSidebarDescription: {
id: 'app.appearance-settings.toggle-sidebar.description',
defaultMessage: 'Enables the ability to toggle the sidebar.',
},
unknownPackWarningTitle: {
id: 'app.appearance-settings.unknown-pack-warning.title',
defaultMessage: 'Warn me before installing unknown modpacks',
},
unknownPackWarningDescription: {
id: 'app.appearance-settings.unknown-pack-warning.description',
defaultMessage:
"If you attempt to install a Modrinth Pack file (.mrpack) that isn't hosted on Modrinth, we'll make sure you understand the risks before installing it.",
},
skipNonEssentialWarningsTitle: {
id: 'app.appearance-settings.skip-non-essential-warnings.title',
defaultMessage: 'Skip non-essential warnings',
},
skipNonEssentialWarningsDescription: {
id: 'app.appearance-settings.skip-non-essential-warnings.description',
defaultMessage:
'Automatically skips low-risk confirmations like duplicate modpack installs, normal content deletion, bulk updates, unlinking modpacks, and repair prompts. Dangerous warnings will still be shown.',
},
showPlayTimeTitle: {
id: 'app.appearance-settings.show-play-time.title',
defaultMessage: 'Show play time',
},
showPlayTimeDescription: {
id: 'app.appearance-settings.show-play-time.description',
defaultMessage: `Displays how much time you've spent playing an instance.`,
},
sidebarInstanceCountTitle: {
id: 'app.appearance-settings.sidebar-instance-count.title',
defaultMessage: 'Sidebar instance limit',
},
sidebarInstanceCountDescription: {
id: 'app.appearance-settings.sidebar-instance-count.description',
defaultMessage: 'Maximum number of instances to show in the sidebar. Set to 0 to show all.',
},
autoHideDownloadsButtonTitle: {
id: 'app.appearance-settings.auto-hide-downloads-button.title',
defaultMessage: 'Auto-hide downloads button',
},
autoHideDownloadsButtonDescription: {
id: 'app.appearance-settings.auto-hide-downloads-button.description',
defaultMessage:
'Hide the downloads button in the sidebar when there are no active download tasks.',
},
fontsTitle: { id: 'app.appearance-settings.fonts.title', defaultMessage: 'Fonts' },
fontsDescription: {
id: 'app.appearance-settings.fonts.description',
defaultMessage: 'Choose the fonts the launcher interface and its logs are rendered in.',
},
uiFontTitle: { id: 'app.appearance-settings.fonts.ui-font', defaultMessage: 'Interface font' },
uiFontDescription: {
id: 'app.appearance-settings.fonts.ui-font-description',
defaultMessage:
'Used across the launcher interface. Only fonts installed on this system are listed.',
},
monoFontTitle: {
id: 'app.appearance-settings.fonts.mono-font',
defaultMessage: 'Monospace font',
},
monoFontDescription: {
id: 'app.appearance-settings.fonts.mono-font-description',
defaultMessage: 'Used for logs, the server console, and other monospace content.',
},
fontDefault: {
id: 'app.appearance-settings.fonts.default',
defaultMessage: 'Launcher default',
},
fontMonospace: {
id: 'app.appearance-settings.fonts.monospace',
defaultMessage: 'Monospace',
},
fontMissing: {
id: 'app.appearance-settings.fonts.missing',
defaultMessage: 'Not installed',
},
fontSearchPlaceholder: {
id: 'app.appearance-settings.fonts.search-placeholder',
defaultMessage: 'Search installed fonts',
},
fontNoResults: {
id: 'app.appearance-settings.fonts.no-results',
defaultMessage: 'No matching fonts',
},
fontLoadFailed: {
id: 'app.appearance-settings.fonts.load-failed',
defaultMessage: "Couldn't read the list of installed fonts.",
},
fontRetry: { id: 'app.appearance-settings.fonts.retry', defaultMessage: 'Retry' },
fontReset: {
id: 'app.appearance-settings.fonts.reset',
defaultMessage: 'Reset',
},
fontPreviewLabel: { id: 'app.appearance-settings.fonts.preview', defaultMessage: 'Preview' },
fontUiPreviewSample: {
id: 'app.appearance-settings.fonts.ui-preview-sample',
defaultMessage: 'The quick brown fox jumps over the lazy dog.',
},
fontMonoPreviewSample: {
id: 'app.appearance-settings.fonts.mono-preview-sample',
defaultMessage: 'const x = () => 0; // monospace',
},
})
const os = ref(await getOS())
const settings = ref(await get())
const customBackgroundPreview = computed(() =>
settings.value.custom_background_path
? convertFileSrc(settings.value.custom_background_path)
: null,
)
const systemFonts = ref<SystemFontFamily[]>([])
const systemFontsLoading = ref(true)
const systemFontsFailed = ref(false)
async function loadSystemFonts() {
systemFontsLoading.value = true
systemFontsFailed.value = false
try {
systemFonts.value = await getSystemFontFamilies()
} catch (error) {
systemFontsFailed.value = true
console.warn('Failed to read the list of installed fonts', error)
} finally {
systemFontsLoading.value = false
}
}
function selectedOptionLabel(options: FontFamilyOptionOrDivider[], value: string): string {
const option = options.find(
(entry): entry is FontFamilyOption => 'value' in entry && entry.value === value,
)
return option?.label ?? ''
}
const uiFontOptions = computed<FontFamilyOptionOrDivider[]>(() =>
buildFontFamilyOptions(systemFonts.value, {
defaultLabel: formatMessage(messages.fontDefault),
missingLabel: formatMessage(messages.fontMissing),
selected: settings.value.ui_font,
}),
)
const monoFontOptions = computed<FontFamilyOptionOrDivider[]>(() =>
buildFontFamilyOptions(systemFonts.value, {
defaultLabel: formatMessage(messages.fontDefault),
missingLabel: formatMessage(messages.fontMissing),
monospaceLabel: formatMessage(messages.fontMonospace),
selected: settings.value.mono_font,
groupMonospaced: true,
}),
)
const uiFontSelection = computed({
get: () => canonicalFontFamily(systemFonts.value, settings.value.ui_font),
set: (value: string) => {
settings.value.ui_font = optionValueToFontSetting(value)
},
})
const monoFontSelection = computed({
get: () => canonicalFontFamily(systemFonts.value, settings.value.mono_font),
set: (value: string) => {
settings.value.mono_font = optionValueToFontSetting(value)
},
})
const uiFontSearchValue = computed(() =>
selectedOptionLabel(uiFontOptions.value, uiFontSelection.value),
)
const monoFontSearchValue = computed(() =>
selectedOptionLabel(monoFontOptions.value, monoFontSelection.value),
)
const uiFontPreview = computed(() =>
resolveFontFamily(settings.value.ui_font, DEFAULT_UI_FONT_STACK),
)
const monoFontPreview = computed(() =>
resolveFontFamily(settings.value.mono_font, DEFAULT_MONO_FONT_STACK),
)
function updateUiScale(value: number) {
const clamped = Math.min(200, Math.max(50, Math.round(value)))
setUiScale(clamped)
settings.value.ui_scale = clamped
}
const accentColorOptions: Array<{
value: AccentColor
color: string
label: MessageDescriptor
}> = [
{ value: 'pink', color: 'var(--color-pink)', label: messages.accentColorPink },
{ value: 'orange', color: 'var(--color-orange)', label: messages.accentColorOrange },
{ value: 'green', color: 'var(--color-green)', label: messages.accentColorGreen },
{ value: 'blue', color: 'var(--color-blue)', label: messages.accentColorBlue },
{ value: 'purple', color: 'var(--color-purple)', label: messages.accentColorPurple },
]
const isCustomAccent = computed(() => settings.value.accent_color.startsWith('custom:'))
const isSystemAccent = computed(() => settings.value.accent_color === 'system')
const customAccentHex = ref(
parseCustomAccentColor(settings.value.accent_color) ?? DEFAULT_CUSTOM_ACCENT_COLOR,
)
const customAccentHexInput = ref(customAccentHex.value)
const customAccentHue = computed(() => Math.round(hexToHsl(customAccentHex.value).h))
const customAccentPreview = computed(() => deriveAccentVariants(customAccentHex.value))
function applyCustomAccent(hex: string) {
const normalized = hex.toLowerCase()
customAccentHex.value = normalized
customAccentHexInput.value = normalized
const value = `custom:${normalized}` as `custom:#${string}`
themeStore.setAccentColor(value)
settings.value.accent_color = value
}
function onCustomHueInput(value: string) {
const { s, l } = hexToHsl(customAccentHex.value)
applyCustomAccent(hslToHex(Number(value), Math.max(s, 40), l))
}
function onCustomHexInput(value: string) {
customAccentHexInput.value = value
const normalized = value.startsWith('#') ? value : `#${value}`
if (/^#[0-9a-fA-F]{6}$/.test(normalized)) applyCustomAccent(normalized)
}
function setHomeLayout(value: string | number) {
if (value !== 'standard' && value !== 'minimal') return
settings.value.home_layout = value as HomeLayout
themeStore.homeLayout = value as HomeLayout
}
async function chooseCustomBackground() {
const selectedPath = await open({
multiple: false,
filters: [
{
name: 'Image',
extensions: ['png', 'jpeg', 'jpg', 'webp', 'gif', 'avif', 'bmp'],
},
],
})
if (!selectedPath || Array.isArray(selectedPath)) return
try {
const extension = selectedPath.split('.').pop()?.toLowerCase() ?? 'png'
const backgroundDirectory = await join(await appDataDir(), 'backgrounds')
const storedPath = await join(
backgroundDirectory,
`launcher-background-${Date.now()}.${extension}`,
)
const previousPath = settings.value.custom_background_path
await mkdir(backgroundDirectory, { recursive: true })
await writeFile(storedPath, await readFile(selectedPath))
settings.value.custom_background_path = storedPath
if (previousPath && previousPath !== storedPath && (await exists(previousPath))) {
try {
await remove(previousPath)
} catch (error) {
console.warn('Failed to remove previous custom background', error)
}
}
} catch (error) {
handleError(error)
}
}
async function removeCustomBackground() {
const backgroundPath = settings.value.custom_background_path
settings.value.custom_background_path = null
if (!backgroundPath) return
try {
if (await exists(backgroundPath)) await remove(backgroundPath)
} catch (error) {
handleError(error)
}
}
watch(
() =>
[
settings.value.custom_background_path,
settings.value.custom_background_blur,
settings.value.custom_background_opacity,
settings.value.transparent_background,
settings.value.transparent_background_opacity,
settings.value.transparent_background_blur,
settings.value.sidebar_instance_count,
settings.value.close_behavior,
settings.value.ui_font,
settings.value.mono_font,
] as const,
([
path,
blur,
opacity,
transparent,
transparentOpacity,
transparentBlur,
sidebarInstanceCount,
closeBehavior,
uiFont,
monoFont,
]) => {
themeStore.customBackgroundPath = path
themeStore.customBackgroundBlur = blur
themeStore.customBackgroundOpacity = opacity
themeStore.transparentBackground = transparent
themeStore.transparentBackgroundOpacity = transparentOpacity
themeStore.transparentBackgroundBlur = transparentBlur
themeStore.setTransparentBackgroundClass()
themeStore.sidebarInstanceCount = sidebarInstanceCount
themeStore.closeBehavior = closeBehavior as CloseBehavior
themeStore.uiFont = uiFont
themeStore.monoFont = monoFont
themeStore.setUiFont()
themeStore.setMonoFont()
},
{ immediate: true },
)
onMounted(() => {
if (props.scope === 'interface') {
void loadSystemFonts()
}
})
watch(
settings,
async () => {
await set(settings.value)
},
{ deep: true },
)
</script>
<template>
<div class="flex flex-col gap-6">
<SettingsSection v-if="props.scope === 'interface'">
<template #header>
<h2
id="settings-target-appearance-color-theme"
tabindex="-1"
class="m-0 text-lg font-semibold text-contrast"
>
{{ formatMessage(messages.colorThemeTitle) }}
</h2>
<p class="m-0 mt-1 text-sm leading-relaxed text-secondary">
{{ formatMessage(messages.colorThemeDescription) }}
</p>
</template>
<div class="flex flex-col gap-4 p-4">
<ThemeSelector
:update-color-theme="
(theme: ColorTheme) => {
themeStore.setThemeState(theme)
settings.theme = theme
}
"
:current-theme="settings.theme"
:theme-options="themeStore.getThemeOptions()"
system-theme-color="system"
/>
</div>
</SettingsSection>
<SettingsSection v-if="props.scope === 'interface'">
<template #header>
<h2
id="settings-target-appearance-accent-color"
tabindex="-1"
class="m-0 text-lg font-semibold text-contrast"
>
{{ formatMessage(messages.accentColorTitle) }}
</h2>
<p class="m-0 mt-1 text-sm leading-relaxed text-secondary">
{{ formatMessage(messages.accentColorDescription) }}
</p>
</template>
<div class="flex flex-col gap-4 p-4 @container">
<div
class="grid grid-cols-4 gap-1 @2xl:gap-2 @4xl:grid-cols-7"
role="radiogroup"
:aria-label="formatMessage(messages.accentColorTitle)"
>
<button
v-for="accentColor in accentColorOptions"
:key="accentColor.value"
type="button"
role="radio"
:aria-checked="settings.accent_color === accentColor.value"
class="flex min-w-0 items-center justify-center gap-2 rounded-lg border border-solid px-1 py-2.5 @2xl:px-2 @4xl:px-3 font-semibold transition-all active:scale-[0.97]"
:class="
settings.accent_color === accentColor.value
? 'border-brand bg-brand-highlight text-brand'
: 'border-surface-4 bg-surface-3 text-secondary hover:border-surface-5 hover:text-contrast'
"
@click="
() => {
themeStore.setAccentColor(accentColor.value)
settings.accent_color = accentColor.value
}
"
>
<span
class="size-4 shrink-0 rounded-full ring-2 ring-white/20"
:style="{ backgroundColor: accentColor.color }"
/>
<span class="hidden truncate @xl:inline">{{ formatMessage(accentColor.label) }}</span>
<CheckIcon
v-if="settings.accent_color === accentColor.value"
class="ml-auto hidden size-4 shrink-0 @4xl:block"
/>
</button>
<button
type="button"
role="radio"
:disabled="themeStore.systemAccentSupported !== true"
:aria-checked="isSystemAccent"
:aria-label="
formatMessage(
themeStore.systemAccentSupported === false
? messages.accentColorSystemUnsupportedLabel
: messages.accentColorSystem,
)
"
class="flex min-w-0 items-center justify-center gap-2 rounded-lg border border-solid px-1 py-2.5 @2xl:px-2 @4xl:px-3 font-semibold transition-all enabled:active:scale-[0.97]"
:class="
themeStore.systemAccentSupported !== true
? 'cursor-not-allowed border-surface-4 bg-surface-2 text-secondary opacity-60'
: isSystemAccent
? 'border-brand bg-brand-highlight text-brand'
: 'border-surface-4 bg-surface-3 text-secondary hover:border-surface-5 hover:text-contrast'
"
@click="
() => {
themeStore.setAccentColor('system')
settings.accent_color = 'system'
}
"
>
<span
class="size-4 shrink-0 rounded-full ring-2 ring-white/20"
:style="{
backgroundColor: themeStore.systemAccentColor ?? 'var(--color-pink)',
}"
/>
<span class="hidden min-w-0 flex-col truncate text-start leading-tight @xl:flex">
<span class="truncate">{{ formatMessage(messages.accentColorSystem) }}</span>
<span
v-if="themeStore.systemAccentSupported === false"
class="truncate text-xs font-normal"
>
{{ formatMessage(messages.accentColorSystemUnsupported) }}
</span>
</span>
<CheckIcon v-if="isSystemAccent" class="ml-auto hidden size-4 shrink-0 @4xl:block" />
</button>
<button
type="button"
role="radio"
:aria-checked="isCustomAccent"
class="flex min-w-0 items-center justify-center gap-2 rounded-lg border border-solid px-1 py-2.5 @2xl:px-2 @4xl:px-3 font-semibold transition-all active:scale-[0.97]"
:class="
isCustomAccent
? 'border-brand bg-brand-highlight text-brand'
: 'border-surface-4 bg-surface-3 text-secondary hover:border-surface-5 hover:text-contrast'
"
@click="applyCustomAccent(customAccentHex)"
>
<span
class="size-4 shrink-0 rounded-full ring-2 ring-white/20"
:style="{
background: isCustomAccent
? customAccentHex
: 'conic-gradient(#ef4444, #f59e0b, #22c55e, #06b6d4, #6366f1, #ec4899, #ef4444)',
}"
/>
<span class="hidden truncate @xl:inline">{{
formatMessage(messages.accentColorCustom)
}}</span>
<CheckIcon v-if="isCustomAccent" class="ml-auto hidden size-4 shrink-0 @4xl:block" />
</button>
</div>
<div
v-if="isCustomAccent"
class="rounded-lg border border-solid border-surface-4 bg-surface-3 p-4"
>
<label class="block">
<span class="text-sm font-semibold text-contrast">
{{ formatMessage(messages.accentColorCustomHue) }}
</span>
<input
type="range"
min="0"
max="360"
step="1"
:value="customAccentHue"
class="hue-slider mt-2"
:aria-label="formatMessage(messages.accentColorCustomHue)"
@input="onCustomHueInput(($event.target as HTMLInputElement).value)"
/>
</label>
<div class="mt-4 flex flex-wrap items-center gap-x-6 gap-y-3">
<label class="flex items-center gap-2">
<span class="text-sm font-semibold text-contrast">
{{ formatMessage(messages.accentColorCustomHex) }}
</span>
<input
type="text"
maxlength="7"
spellcheck="false"
:value="customAccentHexInput"
class="w-28"
@input="onCustomHexInput(($event.target as HTMLInputElement).value)"
@blur="customAccentHexInput = customAccentHex"
/>
</label>
<div class="flex items-center gap-2">
<span
class="size-6 shrink-0 rounded-full ring-2 ring-white/20"
:style="{ backgroundColor: customAccentPreview.light }"
/>
<span class="text-sm text-secondary">
{{ formatMessage(messages.accentColorCustomPreviewLight) }}
</span>
</div>
<div class="flex items-center gap-2">
<span
class="size-6 shrink-0 rounded-full ring-2 ring-white/20"
:style="{ backgroundColor: customAccentPreview.dark }"
/>
<span class="text-sm text-secondary">
{{ formatMessage(messages.accentColorCustomPreviewDark) }}
</span>
</div>
</div>
</div>
</div>
</SettingsSection>
<SettingsSection v-if="props.scope === 'interface'">
<template #header>
<h2
id="settings-target-appearance-launcher-background"
tabindex="-1"
class="m-0 text-lg font-semibold text-contrast"
>
{{ formatMessage(messages.customBackgroundTitle) }}
</h2>
<p class="m-0 mt-1 text-sm leading-relaxed text-secondary">
{{ formatMessage(messages.customBackgroundDescription) }}
</p>
</template>
<div class="flex flex-col gap-4 p-4 appearance-panel--divided">
<div
class="relative h-44 overflow-hidden rounded-lg border border-solid border-surface-4 bg-surface-1"
>
<div
v-if="customBackgroundPreview"
class="absolute -inset-10 bg-cover bg-center"
:style="{
backgroundImage: `url(&quot;${customBackgroundPreview}&quot;)`,
filter: `blur(${settings.custom_background_blur}px)`,
opacity: settings.custom_background_opacity / 100,
}"
/>
<div class="absolute inset-0 bg-surface-1/35" />
<div class="relative flex h-full items-center justify-center">
<div
v-if="!customBackgroundPreview"
class="flex flex-col items-center gap-2 text-secondary"
>
<ImageIcon class="size-8" />
<span class="font-semibold">{{ formatMessage(messages.customBackgroundEmpty) }}</span>
</div>
</div>
</div>
<div class="flex flex-wrap gap-2">
<Button type="base" native-type="button" @click="chooseCustomBackground">
<UploadIcon />
{{
formatMessage(
customBackgroundPreview
? messages.customBackgroundReplace
: messages.customBackgroundChoose,
)
}}
</Button>
<Button
v-if="customBackgroundPreview"
type="outlined"
color="red"
native-type="button"
@click="removeCustomBackground"
>
<TrashIcon />
{{ formatMessage(messages.customBackgroundRemove) }}
</Button>
</div>
<div v-if="customBackgroundPreview" class="grid gap-5 lg:grid-cols-2">
<div class="flex flex-col gap-2">
<h3 class="m-0 font-semibold text-contrast">
{{ formatMessage(messages.customBackgroundBlur) }}
</h3>
<Slider
id="custom-background-blur"
v-model="settings.custom_background_blur"
:min="0"
:max="40"
:step="1"
unit="px"
/>
<p class="m-0 text-sm text-secondary">
{{ formatMessage(messages.customBackgroundBlurDescription) }}
</p>
</div>
<div class="flex flex-col gap-2">
<h3 class="m-0 font-semibold text-contrast">
{{ formatMessage(messages.customBackgroundOpacity) }}
</h3>
<Slider
id="custom-background-opacity"
v-model="settings.custom_background_opacity"
:min="10"
:max="100"
:step="5"
unit="%"
/>
<p class="m-0 text-sm text-secondary">
{{ formatMessage(messages.customBackgroundOpacityDescription) }}
</p>
</div>
</div>
</div>
<SettingsRow>
<template #label>
<span id="settings-target-appearance-transparent-background" tabindex="-1">
{{ formatMessage(messages.transparentBackgroundTitle) }}
</span>
</template>
<template #description>
<span class="block">{{ formatMessage(messages.transparentBackgroundDescription) }}</span>
<span v-if="customBackgroundPreview" class="mt-1 block text-orange">
{{ formatMessage(messages.transparentBackgroundConflict) }}
</span>
</template>
<template #control>
<Toggle
id="transparent-background"
:model-value="settings.transparent_background"
@update:model-value="(e) => (settings.transparent_background = !!e)"
/>
</template>
</SettingsRow>
<SettingsRow v-if="settings.transparent_background" stacked>
<template #label>{{ formatMessage(messages.transparentBackgroundOpacity) }}</template>
<template #description>
{{ formatMessage(messages.transparentBackgroundOpacityDescription) }}
</template>
<template #control>
<div class="w-full">
<Slider
id="transparent-background-opacity"
v-model="settings.transparent_background_opacity"
:min="0"
:max="100"
:step="5"
unit="%"
/>
</div>
</template>
</SettingsRow>
<SettingsRow v-if="settings.transparent_background && os !== 'Linux'">
<template #label>{{ formatMessage(messages.transparentBackgroundBlurTitle) }}</template>
<template #description>
{{ formatMessage(messages.transparentBackgroundBlurDescription) }}
</template>
<template #control>
<Toggle
id="transparent-background-blur"
:model-value="settings.transparent_background_blur"
@update:model-value="(e) => (settings.transparent_background_blur = !!e)"
/>
</template>
</SettingsRow>
</SettingsSection>
<SettingsSection v-if="props.scope === 'home-navigation'">
<SettingsRow>
<template #label>
<span id="settings-target-appearance-home-layout" tabindex="-1">
{{ formatMessage(messages.homeLayoutTitle) }}
</span>
</template>
<template #description>{{ formatMessage(messages.homeLayoutDescription) }}</template>
<template #control>
<div
class="inline-flex shrink-0 items-center gap-1 rounded-lg border border-solid border-surface-4 bg-surface-3 p-1"
role="radiogroup"
:aria-label="formatMessage(messages.homeLayoutTitle)"
>
<Button
:type="settings.home_layout === 'standard' ? 'colored-text' : 'quiet'"
:color="settings.home_layout === 'standard' ? 'brand' : undefined"
native-type="button"
role="radio"
:aria-checked="settings.home_layout === 'standard'"
@click="setHomeLayout('standard')"
>
<LayoutTemplateIcon aria-hidden="true" />
{{ formatMessage(messages.homeLayoutStandard) }}
</Button>
<Button
:type="settings.home_layout === 'minimal' ? 'colored-text' : 'quiet'"
:color="settings.home_layout === 'minimal' ? 'brand' : undefined"
native-type="button"
role="radio"
:aria-checked="settings.home_layout === 'minimal'"
@click="setHomeLayout('minimal')"
>
<MinimizeIcon aria-hidden="true" />
{{ formatMessage(messages.homeLayoutMinimal) }}
</Button>
</div>
</template>
</SettingsRow>
<SettingsRow>
<template #label>
<span id="settings-target-appearance-default-landing-page" tabindex="-1">
{{ formatMessage(messages.defaultLandingPageTitle) }}
</span>
</template>
<template #description>{{
formatMessage(messages.defaultLandingPageDescription)
}}</template>
<template #control>
<div class="w-full">
<Combobox
id="opening-page"
v-model="settings.default_page"
:name="formatMessage(messages.defaultLandingPageTitle)"
:placeholder="formatMessage(messages.selectOption)"
:options="[
{
value: 'Home',
label: formatMessage(messages.defaultLandingPageHome),
},
{
value: 'DiscoverContent',
label: formatMessage(messages.defaultLandingPageDiscoverContent),
},
{
value: 'Library',
label: formatMessage(messages.defaultLandingPageLibrary),
},
]"
/>
</div>
</template>
</SettingsRow>
<SettingsRow stacked>
<template #label>
<span id="settings-target-appearance-sidebar-instance-limit" tabindex="-1">
{{ formatMessage(messages.sidebarInstanceCountTitle) }}
</span>
</template>
<template #description>{{
formatMessage(messages.sidebarInstanceCountDescription)
}}</template>
<template #control>
<div class="w-full">
<Slider
id="sidebar-instance-count"
v-model="settings.sidebar_instance_count"
:min="0"
:max="50"
:step="1"
/>
</div>
</template>
</SettingsRow>
<SettingsRow>
<template #label>
<span id="settings-target-appearance-auto-hide-downloads" tabindex="-1">
{{ formatMessage(messages.autoHideDownloadsButtonTitle) }}
</span>
</template>
<template #description>
{{ formatMessage(messages.autoHideDownloadsButtonDescription) }}
</template>
<template #control>
<Toggle
id="auto-hide-downloads-button"
:model-value="themeStore.autoHideDownloadsButton"
@update:model-value="
(value) => {
themeStore.autoHideDownloadsButton = !!value
settings.auto_hide_downloads_button = themeStore.autoHideDownloadsButton
}
"
/>
</template>
</SettingsRow>
<SettingsRow>
<template #label>
<span id="settings-target-appearance-show-play-time" tabindex="-1">
{{ formatMessage(messages.showPlayTimeTitle) }}
</span>
</template>
<template #description>{{ formatMessage(messages.showPlayTimeDescription) }}</template>
<template #control>
<Toggle
:model-value="themeStore.getFeatureFlag(showPlayTimeFlag)"
@update:model-value="
() => {
const newValue = !themeStore.getFeatureFlag(showPlayTimeFlag)
themeStore.featureFlags[showPlayTimeFlag] = newValue
settings.feature_flags[showPlayTimeFlag] = newValue
}
"
/>
</template>
</SettingsRow>
</SettingsSection>
<SettingsSection v-if="props.scope === 'interface'">
<SettingsRow>
<template #label>
<span id="settings-target-appearance-advanced-rendering" tabindex="-1">
{{ formatMessage(messages.advancedRenderingTitle) }}
</span>
</template>
<template #description>{{ formatMessage(messages.advancedRenderingDescription) }}</template>
<template #control>
<Toggle
id="advanced-rendering"
:model-value="themeStore.advancedRendering"
@update:model-value="
(e) => {
themeStore.advancedRendering = !!e
settings.advanced_rendering = themeStore.advancedRendering
}
"
/>
</template>
</SettingsRow>
<SettingsRow stacked>
<template #label>
<span id="settings-target-appearance-ui-scale" tabindex="-1">
{{ formatMessage(messages.uiScaleTitle) }}
</span>
</template>
<template #description>{{ formatMessage(messages.uiScaleDescription) }}</template>
<template #control>
<div class="flex w-full flex-col gap-3">
<div class="flex flex-wrap gap-2">
<button
v-for="preset in [100, 110, 125, 150]"
:key="preset"
type="button"
class="min-w-16 rounded-lg border border-solid px-3 py-1.5 text-sm font-semibold transition-colors"
:class="
uiScale === preset
? 'border-brand bg-brand-highlight text-brand'
: 'border-surface-4 bg-surface-3 text-secondary hover:border-surface-5 hover:text-contrast'
"
@click="updateUiScale(preset)"
>
{{ preset }}%
</button>
</div>
<Slider
id="ui-scale"
:model-value="uiScale"
:min="50"
:max="200"
:step="5"
unit="%"
@update:model-value="(value) => updateUiScale(Number(value))"
/>
</div>
</template>
</SettingsRow>
<SettingsRow>
<template #label>
<span id="settings-target-appearance-page-transitions" tabindex="-1">
{{ formatMessage(messages.pageTransitionsTitle) }}
</span>
</template>
<template #description>{{ formatMessage(messages.pageTransitionsDescription) }}</template>
<template #control>
<Toggle
id="page-transitions"
:model-value="themeStore.getFeatureFlag(pageTransitionsFlag)"
@update:model-value="
(value) => {
const enabled = !!value
themeStore.featureFlags[pageTransitionsFlag] = enabled
settings.feature_flags[pageTransitionsFlag] = enabled
}
"
/>
</template>
</SettingsRow>
<SettingsRow v-if="os !== 'MacOS'">
<template #label>
<span id="settings-target-appearance-native-decorations" tabindex="-1">
{{ formatMessage(messages.nativeDecorationsTitle) }}
</span>
</template>
<template #description>{{ formatMessage(messages.nativeDecorationsDescription) }}</template>
<template #control>
<Toggle id="native-decorations" v-model="settings.native_decorations" />
</template>
</SettingsRow>
<SettingsRow>
<template #label>
<span id="settings-target-appearance-close-behavior" tabindex="-1">
{{ formatMessage(messages.closeBehaviorTitle) }}
</span>
</template>
<template #description>{{ formatMessage(messages.closeBehaviorDescription) }}</template>
<template #control>
<Combobox
id="close-behavior"
v-model="settings.close_behavior"
:name="formatMessage(messages.closeBehaviorTitle)"
:options="[
{ value: 'ask', label: formatMessage(messages.closeBehaviorAsk) },
{ value: 'close', label: formatMessage(messages.closeBehaviorClose) },
{ value: 'lightweight', label: formatMessage(messages.closeBehaviorLightweight) },
]"
@update:model-value="(value) => (themeStore.closeBehavior = value as CloseBehavior)"
/>
</template>
</SettingsRow>
<SettingsRow>
<template #label>
<span id="settings-target-appearance-hide-nametag" tabindex="-1">
{{ formatMessage(messages.hideNametagTitle) }}
</span>
</template>
<template #description>{{ formatMessage(messages.hideNametagDescription) }}</template>
<template #control>
<Toggle
id="hide-nametag-skins-page"
:model-value="themeStore.hideNametagSkinsPage"
@update:model-value="
(e) => {
themeStore.hideNametagSkinsPage = !!e
settings.hide_nametag_skins_page = themeStore.hideNametagSkinsPage
}
"
/>
</template>
</SettingsRow>
</SettingsSection>
<SettingsSection v-if="props.scope === 'interface'">
<template #header>
<h2
id="settings-target-appearance-fonts"
tabindex="-1"
class="m-0 text-lg font-semibold text-contrast"
>
{{ formatMessage(messages.fontsTitle) }}
</h2>
<p class="m-0 mt-1 text-sm leading-relaxed text-secondary">
{{ formatMessage(messages.fontsDescription) }}
</p>
</template>
<SettingsRow stacked>
<template #label>
<span id="settings-target-appearance-ui-font" tabindex="-1">
{{ formatMessage(messages.uiFontTitle) }}
</span>
</template>
<template #description>{{ formatMessage(messages.uiFontDescription) }}</template>
<template #control>
<div class="flex w-full flex-col gap-3">
<div class="flex items-center gap-2">
<div class="min-w-0 flex-1">
<Combobox
v-model="uiFontSelection"
:options="uiFontOptions"
searchable
:search-value="uiFontSearchValue"
:search-placeholder="formatMessage(messages.fontSearchPlaceholder)"
:no-options-message="formatMessage(messages.fontNoResults)"
show-no-options-when-empty
:disabled="systemFontsLoading"
/>
</div>
<Button
type="quiet"
:disabled="settings.ui_font === null"
@click="settings.ui_font = null"
>
{{ formatMessage(messages.fontReset) }}
</Button>
</div>
<div class="flex flex-col gap-1">
<span class="text-xs text-secondary">
{{ formatMessage(messages.fontPreviewLabel) }}
</span>
<div
class="rounded-[var(--radius-md)] bg-surface-3 px-3 py-2 text-base text-contrast"
:style="{ fontFamily: uiFontPreview }"
>
{{ formatMessage(messages.fontUiPreviewSample) }}
</div>
</div>
</div>
</template>
</SettingsRow>
<SettingsRow stacked>
<template #label>
<span id="settings-target-appearance-mono-font" tabindex="-1">
{{ formatMessage(messages.monoFontTitle) }}
</span>
</template>
<template #description>{{ formatMessage(messages.monoFontDescription) }}</template>
<template #control>
<div class="flex w-full flex-col gap-3">
<div class="flex items-center gap-2">
<div class="min-w-0 flex-1">
<Combobox
v-model="monoFontSelection"
:options="monoFontOptions"
searchable
:search-value="monoFontSearchValue"
:search-placeholder="formatMessage(messages.fontSearchPlaceholder)"
:no-options-message="formatMessage(messages.fontNoResults)"
show-no-options-when-empty
:disabled="systemFontsLoading"
/>
</div>
<Button
type="quiet"
:disabled="settings.mono_font === null"
@click="settings.mono_font = null"
>
{{ formatMessage(messages.fontReset) }}
</Button>
</div>
<div class="flex flex-col gap-1">
<span class="text-xs text-secondary">
{{ formatMessage(messages.fontPreviewLabel) }}
</span>
<div
class="rounded-[var(--radius-md)] bg-surface-3 px-3 py-2 text-xs text-contrast"
:style="{ fontFamily: monoFontPreview }"
>
{{ formatMessage(messages.fontMonoPreviewSample) }}
</div>
</div>
</div>
</template>
</SettingsRow>
<div
v-if="systemFontsFailed"
class="flex items-center justify-between gap-3 px-4 pb-4 text-sm text-secondary"
>
<span>{{ formatMessage(messages.fontLoadFailed) }}</span>
<Button type="quiet" @click="loadSystemFonts">
{{ formatMessage(messages.fontRetry) }}
</Button>
</div>
</SettingsSection>
<SettingsSection v-if="props.scope === 'content-downloads'">
<SettingsRow>
<template #label>
<span id="settings-target-content-auto-install-dependencies" tabindex="-1">
{{ formatMessage(messages.autoInstallDependenciesTitle) }}
</span>
</template>
<template #description>
{{ formatMessage(messages.autoInstallDependenciesDescription) }}
</template>
<template #control>
<Toggle
id="auto-install-dependencies"
:model-value="themeStore.getFeatureFlag(autoInstallDependenciesFlag)"
@update:model-value="
(value) => {
const enabled = !!value
themeStore.featureFlags[autoInstallDependenciesFlag] = enabled
settings.feature_flags[autoInstallDependenciesFlag] = enabled
}
"
/>
</template>
</SettingsRow>
<SettingsRow>
<template #label>
<span id="settings-target-appearance-unknown-pack-warning" tabindex="-1">
{{ formatMessage(messages.unknownPackWarningTitle) }}
</span>
</template>
<template #description>{{
formatMessage(messages.unknownPackWarningDescription)
}}</template>
<template #control>
<Toggle
:model-value="!themeStore.getFeatureFlag(skipUnknownPackWarningFlag)"
@update:model-value="
(e) => {
const warnBeforeUnknownPackInstall = !!e
const skipUnknownPackWarning = !warnBeforeUnknownPackInstall
themeStore.featureFlags[skipUnknownPackWarningFlag] = skipUnknownPackWarning
settings.feature_flags[skipUnknownPackWarningFlag] = skipUnknownPackWarning
}
"
/>
</template>
</SettingsRow>
<SettingsRow>
<template #label>
<span id="settings-target-content-skip-nonessential-warnings" tabindex="-1">
{{ formatMessage(messages.skipNonEssentialWarningsTitle) }}
</span>
</template>
<template #description>
{{ formatMessage(messages.skipNonEssentialWarningsDescription) }}
</template>
<template #control>
<Toggle
:model-value="themeStore.getFeatureFlag(skipNonEssentialWarningsFlag)"
@update:model-value="
() => {
const newValue = !themeStore.getFeatureFlag(skipNonEssentialWarningsFlag)
themeStore.featureFlags[skipNonEssentialWarningsFlag] = newValue
settings.feature_flags[skipNonEssentialWarningsFlag] = newValue
}
"
/>
</template>
</SettingsRow>
</SettingsSection>
</div>
</template>
<style scoped lang="scss">
.appearance-panel--divided {
border-bottom: 1px solid var(--settings-divider, var(--surface-4));
}
.hue-slider {
appearance: none;
display: block;
width: 100%;
height: 0.75rem;
min-height: 0;
padding: 0;
border: none;
border-radius: var(--radius-max);
background: linear-gradient(
to right,
hsl(0, 80%, 55%),
hsl(60, 80%, 55%),
hsl(120, 80%, 55%),
hsl(180, 80%, 55%),
hsl(240, 80%, 55%),
hsl(300, 80%, 55%),
hsl(360, 80%, 55%)
);
cursor: pointer;
&:focus-visible {
outline: 2px solid var(--color-focus-ring);
outline-offset: 2px;
}
&::-webkit-slider-thumb {
appearance: none;
width: 1.25rem;
height: 1.25rem;
border-radius: 50%;
background: var(--color-brand);
border: 0.1875rem solid #ffffff;
box-shadow: var(--shadow-button);
}
&::-moz-range-thumb {
width: 1.25rem;
height: 1.25rem;
border-radius: 50%;
background: var(--color-brand);
border: 0.1875rem solid #ffffff;
box-shadow: var(--shadow-button);
}
}
</style>