* 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'] output: ['console', 'terminal']
}), }),
UnoCSS({ UnoCSS({
configFile: '../unocss.config.ts' configFile: fileURLToPath(
new URL('../../unocss.config.ts', import.meta.url)
)
}), }),
AutoImport({ AutoImport({
dts: '../.cache/imports.d.ts', dts: '../.cache/imports.d.ts',

View file

@ -1,12 +1,11 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from 'vue' import { computed, ref, onMounted, onUnmounted } from 'vue'
import { useTheme } from '../themes/themeHandler' import { useTheme } from '../themes/themeHandler'
import type { DisplayMode } from '../themes/types' import type { DisplayMode } from '../themes/types'
const { mode, setMode, state, amoledEnabled, setAmoledEnabled } = useTheme() const { mode, amoledEnabled, setAppearance } = useTheme()
const isOpen = ref(false) const wrapperRef = ref<HTMLElement | null>(null)
const dropdownRef = ref<HTMLElement | null>(null)
interface ModeChoice { interface ModeChoice {
mode: DisplayMode mode: DisplayMode
@ -29,19 +28,12 @@ const currentChoice = computed(() => {
return modeChoices.find(choice => choice.mode === current && !choice.isAmoled) || modeChoices[0] return modeChoices.find(choice => choice.mode === current && !choice.isAmoled) || modeChoices[0]
}) })
const toggleDropdown = () => {
isOpen.value = !isOpen.value
}
const selectMode = (choice: ModeChoice) => { const selectMode = (choice: ModeChoice) => {
if (choice.isAmoled) { if (choice.isAmoled) {
setMode('dark') setAppearance('dark', true)
setAmoledEnabled(true)
} else { } else {
setMode(choice.mode) setAppearance(choice.mode, false)
setAmoledEnabled(false)
} }
isOpen.value = false
} }
const isActiveChoice = (choice: ModeChoice) => { const isActiveChoice = (choice: ModeChoice) => {
@ -52,56 +44,112 @@ const isActiveChoice = (choice: ModeChoice) => {
return choice.mode === current && !choice.isAmoled && !amoledEnabled.value return choice.mode === current && !choice.isAmoled && !amoledEnabled.value
} }
const handleClickOutside = (event: MouseEvent) => { // Logic to override the parent VPFlyout behavior to be click-based
if (dropdownRef.value && !dropdownRef.value.contains(event.target as Node)) { const setupParentFlyoutOverride = () => {
isOpen.value = false 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(() => { onMounted(() => {
document.addEventListener('click', handleClickOutside) // defer slightly to ensuring DOM is ready
setTimeout(setupParentFlyoutOverride, 100)
}) })
onUnmounted(() => { onUnmounted(() => {
document.removeEventListener('click', handleClickOutside) if (wrapperRef.value && (wrapperRef.value as any)._cleanup) {
;(wrapperRef.value as any)._cleanup()
}
}) })
</script> </script>
<template> <template>
<div ref="dropdownRef" class="theme-dropdown"> <div ref="wrapperRef" class="theme-dropdown-wrapper">
<button <VDropdown
type="button" class="theme-dropdown"
class="theme-dropdown-toggle" theme="theme-selector"
:title="currentChoice.label" :distance="12"
@click="toggleDropdown" 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 <button
v-for="(choice, index) in modeChoices" type="button"
:key="index" class="theme-dropdown-toggle"
class="theme-dropdown-item" :title="currentChoice.label"
:class="{ active: isActiveChoice(choice) }"
@click="selectMode(choice)"
> >
<div :class="[choice.icon, 'text-lg']" /> <ClientOnly>
<span>{{ choice.label }}</span> <Transition name="fade" mode="out-in">
<div v-if="isActiveChoice(choice)" class="i-ph-check text-lg ml-auto" /> <div :key="currentChoice.label" :class="[currentChoice.icon, 'text-xl']" />
</Transition>
</ClientOnly>
</button> </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> </div>
</template> </template>
<style lang="scss" scoped> <style lang="scss" scoped>
.theme-dropdown-wrapper {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
}
.theme-dropdown { .theme-dropdown {
position: relative; display: flex;
display: inline-block; align-items: center;
height: 100%;
} }
.theme-dropdown-toggle { .theme-dropdown-toggle {
@ -121,25 +169,12 @@ onUnmounted(() => {
color: var(--vp-c-text-1); color: var(--vp-c-text-1);
background: var(--vp-c-bg-elv); background: var(--vp-c-bg-elv);
transition: color 0.25s, background 0.25s; transition: color 0.25s, background 0.25s;
backdrop-filter: blur(12px);
} }
} }
.theme-dropdown-menu { .theme-dropdown-content {
position: absolute;
top: calc(100% + 8px);
right: 0;
min-width: 180px; 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 { .theme-dropdown-item {
@ -171,14 +206,13 @@ onUnmounted(() => {
} }
} }
.dropdown-enter-active, .fade-enter-active,
.dropdown-leave-active { .fade-leave-active {
transition: opacity 0.15s ease, transform 0.15s ease; transition: opacity 0.25s ease;
} }
.dropdown-enter-from, .fade-enter-from,
.dropdown-leave-to { .fade-leave-to {
opacity: 0; opacity: 0;
transform: translateY(-8px);
} }
</style> </style>

View file

@ -317,11 +317,16 @@ debouncedWatch(
await mergeNearbyMarks() 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) { for (const excerpt of excerpts) {
excerpt const mark = excerpt.querySelector('mark[data-markjs="true"]') as HTMLElement | null
.querySelector('mark[data-markjs="true"]') if (mark) {
?.scrollIntoView({ block: 'center' }) 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 resultMarks.value = newResultMarks
currentMarkIndex.value = newCurrentMarkIndex currentMarkIndex.value = newCurrentMarkIndex
// FIXME: without this whole page scrolls to the bottom // Reset scroll position to top
resultsEl.value?.firstElementChild?.scrollIntoView({ block: 'start' }) if (resultsEl.value) {
resultsEl.value.scrollTop = 0
}
}, },
{ debounce: 200, immediate: true } { debounce: 200, immediate: true }
) )

View file

@ -38,6 +38,35 @@ export default {
app.component('Feedback', Feedback) app.component('Feedback', Feedback)
app.component('Tooltip', Tooltip) app.component('Tooltip', Tooltip)
loadProgress(router) 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 // Initialize theme handler
useThemeHandler() 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 { :root {
scroll-behavior: smooth;
/* Colors: Brand */ /* Colors: Brand */
--vp-c-brand-1: theme('colors.swarm.500'); --vp-c-brand-1: theme('colors.swarm.500');
--vp-c-brand-2: theme('colors.swarm.600'); --vp-c-brand-2: theme('colors.swarm.600');
@ -399,4 +404,52 @@ .VPLocalSearchBox .backdrop {
.dark .VPLocalSearchBox .backdrop { .dark .VPLocalSearchBox .backdrop {
background: rgba(0, 0, 0, 0.6); 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) { private applyDOMClasses(mode: DisplayMode) {
const root = document.documentElement const root = document.documentElement
// Remove all mode classes const isDark = mode === 'dark'
root.classList.remove('dark', 'light', 'amoled') const isAmoled = isDark && this.amoledEnabled.value
// Add current mode class if (isDark) {
root.classList.add(mode) 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 (isAmoled) {
if (mode === 'dark' && this.amoledEnabled.value) { if (!root.classList.contains('amoled')) root.classList.add('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 const root = document.documentElement
// Clear ALL inline styles related to theming to ensure clean slate // Clear ALL inline styles related to theming to ensure clean slate
const allStyleProps = Array.from(root.style) // const allStyleProps = Array.from(root.style)
allStyleProps.forEach(prop => { // allStyleProps.forEach(prop => {
if (prop.startsWith('--vp-')) { // if (prop.startsWith('--vp-')) {
root.style.removeProperty(prop) // root.style.removeProperty(prop)
} // }
}) // })
let bgColor = colors.bg let bgColor = colors.bg
let bgAltColor = colors.bgAlt let bgAltColor = colors.bgAlt
let bgElvColor = colors.bgElv let bgElvColor = colors.bgElv
@ -296,6 +302,14 @@ export class ThemeHandler {
this.setMode(newMode) 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) { public setAmoledEnabled(enabled: boolean) {
this.amoledEnabled.value = enabled this.amoledEnabled.value = enabled
localStorage.setItem(STORAGE_KEY_AMOLED, enabled.toString()) localStorage.setItem(STORAGE_KEY_AMOLED, enabled.toString())
@ -332,7 +346,6 @@ export class ThemeHandler {
public getState() { public getState() {
return this.state return this.state
} }
public getMode() { public getMode() {
return this.state.value.currentMode return this.state.value.currentMode
} }
@ -395,6 +408,7 @@ export function useTheme() {
amoledEnabled: handler.getAmoledEnabledRef(), amoledEnabled: handler.getAmoledEnabledRef(),
setAmoledEnabled: (enabled: boolean) => handler.setAmoledEnabled(enabled), setAmoledEnabled: (enabled: boolean) => handler.setAmoledEnabled(enabled),
toggleAmoled: () => handler.toggleAmoled(), toggleAmoled: () => handler.toggleAmoled(),
setAppearance: (mode: DisplayMode, amoled: boolean) => handler.setAppearance(mode, amoled),
state state
} }
} }

View file

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