* blur to social media tooltip

* fix theme selector

* better transitions when switching to and from amoled

* fix mobile search scrolling down

* scroll instead of instant when navigating to section

* fix unocss in dev mode

* fix unocss in dev mode, again

* prevent scroll transition when changing pages
This commit is contained in:
bread 2026-02-02 22:49:48 -08:00 committed by GitHub
parent e7fe537a73
commit a6114818b9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 225 additions and 86 deletions

View file

@ -122,7 +122,9 @@ export default defineConfig({
output: ['console', 'terminal']
}),
UnoCSS({
configFile: '../unocss.config.ts'
configFile: fileURLToPath(
new URL('../../unocss.config.ts', import.meta.url)
)
}),
AutoImport({
dts: '../.cache/imports.d.ts',

View file

@ -1,12 +1,11 @@
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { computed, ref, onMounted, onUnmounted } from 'vue'
import { useTheme } from '../themes/themeHandler'
import type { DisplayMode } from '../themes/types'
const { mode, setMode, state, amoledEnabled, setAmoledEnabled } = useTheme()
const { mode, amoledEnabled, setAppearance } = useTheme()
const isOpen = ref(false)
const dropdownRef = ref<HTMLElement | null>(null)
const wrapperRef = ref<HTMLElement | null>(null)
interface ModeChoice {
mode: DisplayMode
@ -29,19 +28,12 @@ const currentChoice = computed(() => {
return modeChoices.find(choice => choice.mode === current && !choice.isAmoled) || modeChoices[0]
})
const toggleDropdown = () => {
isOpen.value = !isOpen.value
}
const selectMode = (choice: ModeChoice) => {
if (choice.isAmoled) {
setMode('dark')
setAmoledEnabled(true)
setAppearance('dark', true)
} else {
setMode(choice.mode)
setAmoledEnabled(false)
setAppearance(choice.mode, false)
}
isOpen.value = false
}
const isActiveChoice = (choice: ModeChoice) => {
@ -52,56 +44,112 @@ const isActiveChoice = (choice: ModeChoice) => {
return choice.mode === current && !choice.isAmoled && !amoledEnabled.value
}
const handleClickOutside = (event: MouseEvent) => {
if (dropdownRef.value && !dropdownRef.value.contains(event.target as Node)) {
isOpen.value = false
// Logic to override the parent VPFlyout behavior to be click-based
const setupParentFlyoutOverride = () => {
if (!wrapperRef.value) return
const flyout = wrapperRef.value.closest('.VPFlyout')
if (!flyout) return
// Add class to disable CSS hover via global style
flyout.classList.add('click-based-flyout')
// Find the toggle button
const button = flyout.querySelector('button')
if (!button) return
// Click handler for toggle
const toggleFlyout = (e: MouseEvent) => {
flyout.classList.toggle('open')
}
button.addEventListener('click', toggleFlyout)
// Global click listener to close when clicking outside
const closeFlyout = (e: MouseEvent) => {
if (!flyout.contains(e.target as Node)) {
flyout.classList.remove('open')
}
}
document.addEventListener('click', closeFlyout)
;(wrapperRef.value as any)._cleanup = () => {
flyout.classList.remove('click-based-flyout')
button.removeEventListener('click', toggleFlyout)
document.removeEventListener('click', closeFlyout)
}
}
onMounted(() => {
document.addEventListener('click', handleClickOutside)
// defer slightly to ensuring DOM is ready
setTimeout(setupParentFlyoutOverride, 100)
})
onUnmounted(() => {
document.removeEventListener('click', handleClickOutside)
if (wrapperRef.value && (wrapperRef.value as any)._cleanup) {
;(wrapperRef.value as any)._cleanup()
}
})
</script>
<template>
<div ref="dropdownRef" class="theme-dropdown">
<button
type="button"
class="theme-dropdown-toggle"
:title="currentChoice.label"
@click="toggleDropdown"
<div ref="wrapperRef" class="theme-dropdown-wrapper">
<VDropdown
class="theme-dropdown"
theme="theme-selector"
:distance="12"
placement="bottom-end"
:triggers="['click']"
:popper-triggers="['click']"
:auto-hide="true"
>
<ClientOnly>
<div :class="[currentChoice.icon, 'text-xl']" />
</ClientOnly>
</button>
<Transition name="dropdown">
<div v-if="isOpen" class="theme-dropdown-menu">
<button
v-for="(choice, index) in modeChoices"
:key="index"
class="theme-dropdown-item"
:class="{ active: isActiveChoice(choice) }"
@click="selectMode(choice)"
type="button"
class="theme-dropdown-toggle"
:title="currentChoice.label"
>
<div :class="[choice.icon, 'text-lg']" />
<span>{{ choice.label }}</span>
<div v-if="isActiveChoice(choice)" class="i-ph-check text-lg ml-auto" />
<ClientOnly>
<Transition name="fade" mode="out-in">
<div :key="currentChoice.label" :class="[currentChoice.icon, 'text-xl']" />
</Transition>
</ClientOnly>
</button>
</div>
</Transition>
<template #popper>
<div class="theme-dropdown-content">
<button
v-for="(choice, index) in modeChoices"
:key="index"
class="theme-dropdown-item"
:class="{ active: isActiveChoice(choice) }"
@click="selectMode(choice)"
v-close-popper
>
<Transition name="fade" mode="out-in">
<div :key="choice.label" :class="[choice.icon, 'text-lg']" />
</Transition>
<span>{{ choice.label }}</span>
<div v-if="isActiveChoice(choice)" class="i-ph-check text-lg ml-auto" />
</button>
</div>
</template>
</VDropdown>
</div>
</template>
<style lang="scss" scoped>
.theme-dropdown-wrapper {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
}
.theme-dropdown {
position: relative;
display: inline-block;
display: flex;
align-items: center;
height: 100%;
}
.theme-dropdown-toggle {
@ -121,25 +169,12 @@ onUnmounted(() => {
color: var(--vp-c-text-1);
background: var(--vp-c-bg-elv);
transition: color 0.25s, background 0.25s;
backdrop-filter: blur(12px);
}
}
.theme-dropdown-menu {
position: absolute;
top: calc(100% + 8px);
right: 0;
.theme-dropdown-content {
min-width: 180px;
background: var(--vp-c-bg-elv);
border: 1px solid var(--vp-c-divider);
border-radius: 8px;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.1);
padding: 6px;
z-index: 1000;
backdrop-filter: blur(12px);
.dark & {
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.3);
}
}
.theme-dropdown-item {
@ -171,14 +206,13 @@ onUnmounted(() => {
}
}
.dropdown-enter-active,
.dropdown-leave-active {
transition: opacity 0.15s ease, transform 0.15s ease;
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.25s ease;
}
.dropdown-enter-from,
.dropdown-leave-to {
.fade-enter-from,
.fade-leave-to {
opacity: 0;
transform: translateY(-8px);
}
</style>

View file

@ -317,11 +317,16 @@ debouncedWatch(
await mergeNearbyMarks()
}
const excerpts = Array.from(el.value?.querySelectorAll('.result .excerpt') ?? [])
const excerpts = Array.from(el.value?.querySelectorAll('.result .excerpt') ?? []) as HTMLElement[]
for (const excerpt of excerpts) {
excerpt
.querySelector('mark[data-markjs="true"]')
?.scrollIntoView({ block: 'center' })
const mark = excerpt.querySelector('mark[data-markjs="true"]') as HTMLElement | null
if (mark) {
const markTop = mark.offsetTop
const markHeight = mark.offsetHeight
const excerptHeight = excerpt.clientHeight
excerpt.scrollTop = markTop - excerptHeight / 2 + markHeight / 2
}
}
/**
@ -343,8 +348,10 @@ debouncedWatch(
resultMarks.value = newResultMarks
currentMarkIndex.value = newCurrentMarkIndex
// FIXME: without this whole page scrolls to the bottom
resultsEl.value?.firstElementChild?.scrollIntoView({ block: 'start' })
// Reset scroll position to top
if (resultsEl.value) {
resultsEl.value.scrollTop = 0
}
},
{ debounce: 200, immediate: true }
)

View file

@ -38,6 +38,35 @@ export default {
app.component('Feedback', Feedback)
app.component('Tooltip', Tooltip)
loadProgress(router)
if (typeof window !== 'undefined') {
const originalBefore = router.onBeforeRouteChange
const originalAfter = router.onAfterRouteChanged
router.onBeforeRouteChange = (to) => {
try {
// Force scroll-behavior: auto (instant) when changing pages (path),
// preventing the "scroll to top" animation.
// Smooth scrolling is preserved for same-page hash/anchor changes.
const targetUrl = new URL(to, window.location.href)
if (targetUrl.pathname !== window.location.pathname) {
document.documentElement.style.scrollBehavior = 'auto'
}
} catch (e) {
// Fallback if URL parsing fails
}
originalBefore?.(to)
}
router.onAfterRouteChanged = (to) => {
originalAfter?.(to)
// Re-enable smooth scrolling shortly after navigation completes
setTimeout(() => {
document.documentElement.style.scrollBehavior = 'smooth'
}, 1)
}
}
// Initialize theme handler
useThemeHandler()
}

View file

@ -1,4 +1,9 @@
body {
transition: background-color 0.5s cubic-bezier(0.25, 0.8, 0.25, 1), color 0.5s cubic-bezier(0.25, 0.8, 0.25, 1);
}
:root {
scroll-behavior: smooth;
/* Colors: Brand */
--vp-c-brand-1: theme('colors.swarm.500');
--vp-c-brand-2: theme('colors.swarm.600');
@ -400,3 +405,51 @@ .VPLocalSearchBox .backdrop {
.dark .VPLocalSearchBox .backdrop {
background: rgba(0, 0, 0, 0.6);
}
/* Glassy Tooltips */
.v-popper--theme-tooltip .v-popper__inner {
background: var(--vp-c-bg-elv) !important;
backdrop-filter: blur(2px);
-webkit-backdrop-filter: blur(2px);
border: 1px solid var(--vp-c-divider) !important;
color: var(--vp-c-text-1) !important;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15) !important;
}
.v-popper--theme-tooltip .v-popper__arrow {
display: none !important;
}
/* Global theme styles for the dropdown to ensure correct box appearance */
.v-popper--theme-theme-selector .v-popper__inner {
background: var(--vp-c-bg-elv);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border: 1px solid var(--vp-c-divider);
border-radius: 8px;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.1);
padding: 6px;
color: var(--vp-c-text-1);
}
.dark .v-popper--theme-theme-selector .v-popper__inner {
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.3);
}
.v-popper--theme-theme-selector .v-popper__arrow {
display: none;
}
/* Override VPFlyout hover behavior when manually controlled */
.VPFlyout.click-based-flyout:hover .menu {
opacity: 0 !important;
visibility: hidden !important;
transform: translateY(0) !important;
/* Reset transform if any */
}
.VPFlyout.click-based-flyout.open .menu {
opacity: 1 !important;
visibility: visible !important;
transform: translateY(0) !important;
}

View file

@ -106,15 +106,21 @@ export class ThemeHandler {
private applyDOMClasses(mode: DisplayMode) {
const root = document.documentElement
// Remove all mode classes
root.classList.remove('dark', 'light', 'amoled')
const isDark = mode === 'dark'
const isAmoled = isDark && this.amoledEnabled.value
// Add current mode class
root.classList.add(mode)
if (isDark) {
if (!root.classList.contains('dark')) root.classList.add('dark')
if (root.classList.contains('light')) root.classList.remove('light')
} else {
if (!root.classList.contains('light')) root.classList.add('light')
if (root.classList.contains('dark')) root.classList.remove('dark')
}
// Add amoled class if enabled in dark mode
if (mode === 'dark' && this.amoledEnabled.value) {
root.classList.add('amoled')
if (isAmoled) {
if (!root.classList.contains('amoled')) root.classList.add('amoled')
} else {
if (root.classList.contains('amoled')) root.classList.remove('amoled')
}
}
@ -124,12 +130,12 @@ export class ThemeHandler {
const root = document.documentElement
// Clear ALL inline styles related to theming to ensure clean slate
const allStyleProps = Array.from(root.style)
allStyleProps.forEach(prop => {
if (prop.startsWith('--vp-')) {
root.style.removeProperty(prop)
}
})
// const allStyleProps = Array.from(root.style)
// allStyleProps.forEach(prop => {
// if (prop.startsWith('--vp-')) {
// root.style.removeProperty(prop)
// }
// })
let bgColor = colors.bg
let bgAltColor = colors.bgAlt
let bgElvColor = colors.bgElv
@ -296,6 +302,14 @@ export class ThemeHandler {
this.setMode(newMode)
}
public setAppearance(mode: DisplayMode, amoled: boolean) {
this.state.value.currentMode = mode
this.amoledEnabled.value = amoled
localStorage.setItem(STORAGE_KEY_MODE, mode)
localStorage.setItem(STORAGE_KEY_AMOLED, amoled.toString())
this.applyTheme()
}
public setAmoledEnabled(enabled: boolean) {
this.amoledEnabled.value = enabled
localStorage.setItem(STORAGE_KEY_AMOLED, enabled.toString())
@ -332,7 +346,6 @@ export class ThemeHandler {
public getState() {
return this.state
}
public getMode() {
return this.state.value.currentMode
}
@ -395,6 +408,7 @@ export function useTheme() {
amoledEnabled: handler.getAmoledEnabledRef(),
setAmoledEnabled: (enabled: boolean) => handler.setAmoledEnabled(enabled),
toggleAmoled: () => handler.toggleAmoled(),
setAppearance: (mode: DisplayMode, amoled: boolean) => handler.setAppearance(mode, amoled),
state
}
}

View file

@ -59,7 +59,7 @@ const createColorRules = (type: 'text' | 'bg' | 'border'): Rule[] => {
export default defineConfig({
content: {
filesystem: ['.vitepress/config.mts', '.vitepress/constants.ts']
filesystem: ['.vitepress/config.mts', '.vitepress/constants.ts', '.vitepress/shared.ts']
},
theme: {
colors: {