chore(docs): polish theme toggle, search morph & page transitions (#2205)

---------

Co-authored-by: Neko <neko@ayaka.moe>
This commit is contained in:
MilkyWeighW
2026-08-04 19:35:55 +08:00
committed by GitHub
co-authored by Neko
parent 7de2e2bf46
commit 727f84ee29
19 changed files with 396 additions and 166 deletions
+2 -2
View File
@@ -34,12 +34,12 @@ const links = computed(() => [
transition-colors duration-200 ease-in-out
>
<Icon
class="text-xl"
class="text-lg"
:icon="link.icon"
/>
<span>{{ link.label }}</span>
<Icon
class="text-base"
class="text-sm"
icon="lucide:arrow-up-right"
/>
</a>
+9 -42
View File
@@ -6,7 +6,6 @@ import { useScroll } from '@vueuse/core'
import { DialogContent, DialogDescription, DialogOverlay, DialogPortal, DialogRoot, DialogTitle, DialogTrigger } from 'reka-ui'
import { useData, useRoute, withBase } from 'vitepress'
import { computed, ref, toRefs, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import DocSidebarItem from '../components/DocSidebarItem.vue'
@@ -14,7 +13,6 @@ import { flatten } from '../utils/flatten'
const { path } = toRefs(useRoute())
const { page, theme } = useData()
const { t } = useI18n()
const isSidebarOpen = ref(false)
const sidebar = computed(() => (theme.value.sidebar as (DefaultTheme.SidebarItem & { icon?: string })[]))
@@ -24,17 +22,18 @@ const sectionTabs = computed(() => sidebar.value
return {
label: val.text,
link: flatten(val.items ?? [], 'items').filter(i => !!i?.link)?.[0]?.link ?? val.link,
// Highlight by the section's own link; tab.link points at the first
// child page and would narrow the highlight to it.
highlight: val.link,
icon: val.icon,
}
})
.filter(i => !!i?.link),
)
function isCharacterPage(link?: string) {
if (!link)
return false
return link.includes('/characters') || link.includes('/characters/')
function isTabActive(tab: { link?: string, highlight?: string }): boolean {
const prefix = (tab.highlight ?? tab.link)?.split('/').slice(0, -1).join('/') ?? ''
return withBase(`/${page.value.relativePath}`).includes(prefix)
}
const { arrivedState } = useScroll(globalThis.window)
@@ -55,28 +54,10 @@ watch(path, () => {
<div />
<a
v-for="tab in sectionTabs.filter(i => !isCharacterPage(i.link))"
v-for="tab in sectionTabs"
:key="tab.label"
:href="tab.link"
:class="{ '!after:bg-primary !text-foreground': withBase(`/${page.relativePath}`).includes(tab.link?.split('/').slice(0, -1).join('/') || '') }"
class="relative mx-4 h-full inline-flex items-center py-2 text-sm text-muted-foreground font-semibold after:absolute after:bottom-0 after:h-0.5 after:w-full hover:border-b-muted after:rounded-t-full after:bg-transparent hover:text-foreground after:content-['']"
transition-colors duration-200 ease-in-out
>
<Icon
v-if="tab.icon"
:icon="tab.icon"
class="mr-2 text-lg"
/>
<span>{{ tab.label }}</span>
</a>
</div>
<div class="h-full flex items-center">
<a
v-for="tab in sectionTabs.filter(i => isCharacterPage(i.link))"
:key="tab.label"
:href="tab.link"
:class="{ '!after:bg-primary !text-foreground': withBase(page.relativePath).includes(tab.label?.toLowerCase() ?? '') }"
:class="{ '!after:bg-primary !text-foreground': isTabActive(tab) }"
class="relative mx-4 h-full inline-flex items-center py-2 text-sm text-muted-foreground font-semibold after:absolute after:bottom-0 after:h-0.5 after:w-full hover:border-b-muted after:rounded-t-full after:bg-transparent hover:text-foreground after:content-['']"
transition-colors duration-200 ease-in-out
>
@@ -106,7 +87,7 @@ watch(path, () => {
<DialogPortal>
<DialogOverlay class="fixed inset-0 z-50 bg-black/80 data-[state=closed]:animate-fadeOut data-[state=open]:animate-fadeIn" />
<DialogContent class="data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left fixed inset-y-0 left-0 z-50 h-full w-3/4 gap-4 border-r border-muted bg-background pr-0 shadow-lg transition ease-in-out sm:max-w-sm data-[state=closed]:animate-exitToLeft data-[state=open]:animate-enterFromLeft">
<DialogContent class="data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left fixed inset-y-0 left-0 z-50 h-full max-w-xs w-5/8 gap-4 border-r border-muted bg-background pr-0 shadow-lg transition ease-in-out data-[state=closed]:animate-exitToLeft data-[state=open]:animate-enterFromLeft">
<DialogTitle class="sr-only">
Sidebar menu
</DialogTitle>
@@ -140,20 +121,6 @@ watch(path, () => {
</DialogContent>
</DialogPortal>
</DialogRoot>
<div class="h-full flex items-center">
<a
href="/characters/"
:class="{ '!border-b-primary !font-semibold !text-foreground': withBase(page.relativePath).includes('characters') }"
class="mx-4 h-full inline-flex items-center gap-2 border-b border-b-transparent py-2 text-sm text-muted-foreground font-medium hover:border-b-muted hover:text-foreground"
>
<Icon
icon="lucide:scan-face"
class="text-lg"
/>
{{ t('docs.theme.pages.characters.title') }}
</a>
</div>
</div>
</div>
</template>
+10 -2
View File
@@ -192,8 +192,12 @@ const buttons = computed(() => theme.value.homepage?.buttons || [])
<style>
/* Infinite scrolling background pattern via mask */
/* The SVG only acts as a mask; the pattern color is this background-color.
Keep it opaque: the elements already carry `opacity-10`, so an extra
translucent alpha would double-dilute it to ~0.8% and become invisible
on the light background. `--foreground` is dark in light mode. */
.bg-icon-pattern {
background-color: white;
background-color: hsl(var(--foreground));
-webkit-mask-image: var(--bg-mask-icon-pattern);
mask-image: var(--bg-mask-icon-pattern);
-webkit-mask-repeat: repeat;
@@ -205,11 +209,15 @@ const buttons = computed(() => theme.value.homepage?.buttons || [])
animation: icon-mask-scroll 8s linear infinite;
}
.dark .bg-icon-pattern {
background-color: white;
}
@keyframes icon-mask-scroll {
100% { -webkit-mask-position: 256px 256px; mask-position: 256px 256px; }
}
@media (prefers-reduced-motion: reduce) {
.bg-ghost-pattern { animation: none; }
.bg-icon-pattern { animation: none; }
}
</style>
+11 -2
View File
@@ -1,5 +1,6 @@
<script setup lang="ts">
import { Icon } from '@iconify/vue'
import { useMediaQuery } from '@vueuse/core'
import { dirname, sep } from 'pathe'
import { DropdownMenuContent, DropdownMenuItem, DropdownMenuPortal, DropdownMenuRoot, DropdownMenuSub, DropdownMenuSubContent, DropdownMenuSubTrigger, DropdownMenuTrigger, Separator } from 'reka-ui'
import { useData, useRoute } from 'vitepress'
@@ -22,6 +23,14 @@ watch(path, () => {
isPopoverOpen.value = false
})
// Close the mobile menu when crossing to desktop: its teleported popup loses
// its anchor once the trigger is hidden by xl:hidden and sticks to the corner.
const isDesktop = useMediaQuery('(min-width: 1280px)')
watch(isDesktop, (value) => {
if (value)
isPopoverOpen.value = false
})
function isNavLinkActive(link: string, path: string) {
let normalizedLink = link.toLowerCase()
normalizedLink = normalizedLink.split(sep).filter(Boolean).length > (site.value.base !== '' ? 3 : 2) ? `${dirname(normalizedLink)}/` : normalizedLink
@@ -33,7 +42,7 @@ function isNavLinkActive(link: string, path: string) {
<template>
<!-- eslint-disable vue/prefer-separate-static-class -->
<nav class="hidden items-center lg:flex">
<nav class="hidden items-center xl:flex">
<template
v-for="nav in theme.nav"
:key="nav.text"
@@ -92,7 +101,7 @@ function isNavLinkActive(link: string, path: string) {
</a>
</nav>
<div class="lg:hidden">
<div class="xl:hidden">
<DropdownMenuRoot v-model:open="isPopoverOpen">
<DropdownMenuTrigger class="rounded-lg p-2">
<Icon icon="lucide:ellipsis" class="text-lg" />
+1 -1
View File
@@ -88,7 +88,7 @@ const maskImageURL = `url(${homeCover})`
]"
>
<img ref="surface" :src="homeCover" alt="Project AIRI Cover Image" class="w-full object-cover">
<div ref="silhouetteLayer2" class="silhouette absolute left-0 top-0 z--1 h-full w-full bg-[oklch(0.8105_0.1267_350.84)]" />
<div ref="silhouetteLayer1" class="silhouette absolute left-0 top-0 z--1 h-full w-full bg-[oklch(0.8105_0.1267_350.84)]" />
<div ref="silhouetteLayer2" class="silhouette absolute left-0 top-0 z--2 h-full w-full bg-[oklch(0.5712_0.2396_278.59)]" />
</div>
</template>
@@ -8,8 +8,6 @@ import { onMounted, shallowRef, useTemplateRef, watchEffect } from 'vue'
import homeCover from '../assets/home-cover-2025-12-24.avif'
const surfaceRef = useTemplateRef<HTMLImageElement>('surface')
const silhouetteLayer1Ref = useTemplateRef<HTMLDivElement>('silhouetteLayer1')
const silhouetteLayer2Ref = useTemplateRef<HTMLDivElement>('silhouetteLayer2')
const shouldReduceMotion = useLocalStorage('docs:settings/reduce-motion', false)
@@ -17,8 +15,6 @@ const DURATION = 1200
const EASE = 'outSine'
const surfaceAnimatable = shallowRef<AnimatableObject>()
const silhouetteLayer1Animatable = shallowRef<AnimatableObject>()
const silhouetteLayer2Animatable = shallowRef<AnimatableObject>()
function animateCover(xOffsetRatio: number, yOffsetRatio: number) {
const referenceWidth = window.innerWidth
@@ -26,14 +22,6 @@ function animateCover(xOffsetRatio: number, yOffsetRatio: number) {
surfaceAnimatable.value?.x?.(-xOffsetRatio * 0.02 * referenceWidth)
surfaceAnimatable.value?.y?.(-yOffsetRatio * 0.02 * referenceWidth)
surfaceAnimatable.value?.z?.(0)
silhouetteLayer1Animatable.value?.x?.(0.01 * referenceWidth - yOffsetRatio * 0.015 * referenceWidth)
silhouetteLayer1Animatable.value?.y?.(0.02 * referenceWidth + xOffsetRatio * 0.015 * referenceWidth)
silhouetteLayer1Animatable.value?.z?.(0)
silhouetteLayer2Animatable.value?.x?.(0.01 * referenceWidth + yOffsetRatio * 0.01 * referenceWidth)
silhouetteLayer2Animatable.value?.y?.(-0.01 * referenceWidth - yOffsetRatio * 0.01 * referenceWidth)
silhouetteLayer2Animatable.value?.z?.(0)
}
function onMouseMove(event: MouseEvent) {
@@ -55,8 +43,6 @@ onMounted(() => {
}
surfaceAnimatable.value = createAnimatable(surfaceRef.value!, animatableConfig)
silhouetteLayer1Animatable.value = createAnimatable(silhouetteLayer1Ref.value!, animatableConfig)
silhouetteLayer2Animatable.value = createAnimatable(silhouetteLayer2Ref.value!, animatableConfig)
})
watchEffect((onCleanup) => {
@@ -88,7 +88,7 @@ const maskImageURL = `url(${homeCover})`
]"
>
<img ref="surface" :src="homeCover" alt="Project AIRI Cover Image" class="w-full object-cover">
<div ref="silhouetteLayer2" class="silhouette absolute left-0 top-0 z--1 h-full w-full bg-[oklch(0.89_0.08_67.49)]" />
<div ref="silhouetteLayer1" class="silhouette absolute left-0 top-0 z--1 h-full w-full bg-[oklch(0.89_0.08_67.49)]" />
<div ref="silhouetteLayer2" class="silhouette absolute left-0 top-0 z--2 h-full w-full bg-[oklch(0.69_0.16_295.04)]" />
</div>
</template>
@@ -49,10 +49,15 @@ const mark = computedAsync(async () => {
return markRaw(new Mark(resultsEl.value))
}, null)
const searchIndex = computedAsync(async () =>
markRaw(
const searchIndex = computedAsync(async () => {
// Index loads asynchronously in onMounted; return undefined until ready.
const data = searchIndexData.value
if (!data)
return undefined
return markRaw(
MiniSearch.loadJSON<Result>(
(await searchIndexData.value[localeIndex.value]?.())?.default,
(await data[localeIndex.value]?.())?.default,
{
fields: ['title', 'titles', 'text'],
storeFields: ['title', 'titles'],
@@ -63,8 +68,8 @@ const searchIndex = computedAsync(async () =>
},
},
),
),
)
)
})
const cache = new LRUCache(16) // 16 files
+170 -29
View File
@@ -1,14 +1,16 @@
<script setup lang="ts">
import { Icon } from '@iconify/vue'
import { useMagicKeys, whenever } from '@vueuse/core'
import { AnimatePresence, Motion } from 'motion-v'
import { DialogContent, DialogDescription, DialogOverlay, DialogPortal, DialogRoot, DialogTitle, DialogTrigger } from 'reka-ui'
import { defineAsyncComponent, ref } from 'vue'
import { defineAsyncComponent, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
const SearchCommandBox = defineAsyncComponent(() => import('./SearchCommandBox.vue'))
const open = ref(false)
const triggerRef = ref<HTMLElement>()
const overlayRef = ref<HTMLElement>()
const contentRef = ref<HTMLElement>()
const { meta_k } = useMagicKeys()
const { t } = useI18n()
@@ -22,11 +24,160 @@ function handleClose() {
open.value = false
})
}
let contentAnim: Animation | undefined
let overlayAnim: Animation | undefined
function clamp(value: number, min: number, max: number) {
return Math.min(Math.max(value, min), max)
}
/** Unwrap a reka-ui component ref to its DOM element ($el). */
function resolveElement(refValue: unknown): HTMLElement | undefined {
const plain = (refValue as { $el?: unknown } | null | undefined)?.$el ?? refValue
return plain instanceof HTMLElement ? plain : undefined
}
/** Wait for the async content to settle; a near-zero height would blow up the FLIP scale. */
async function waitForContent(
getContent: () => HTMLElement | undefined,
timeoutMs = 1500,
): Promise<HTMLElement | undefined> {
const start = performance.now()
let el: HTMLElement | undefined
let prevHeight = -1
while (performance.now() - start < timeoutMs) {
// Bail out early if the dialog was closed while we were waiting.
if (!open.value)
return undefined
await new Promise(resolve => requestAnimationFrame(resolve))
el = getContent()
if (!el)
continue
const height = el.getBoundingClientRect().height
if (height > 0 && height === prevHeight)
return el
prevHeight = height
}
return el
}
/**
* FLIP morph: animate the dialog from the search bar's rect (WAAPI, 0.2s);
* closing replays it in reverse via the `data-state=closed` CSS animation.
* Stores --morph-* for the close animation and cancels the WAAPI object
* afterwards, otherwise it keeps locking transform and suppresses it.
*
* A run awaiting waitForContent must not apply animations once the dialog
* has been closed (or reopened) in the meantime — guarded by `watchRun`.
*/
let watchRun = 0
watch(open, async (isOpen) => {
const run = ++watchRun
if (!isOpen) {
// Snapshot the current computed state BEFORE cancelling the opening
// animation: cancelling reverts to the class defaults (opacity-0), which
// would make the closing animation jump to its `from` keyframe (full
// opacity) first. Writing the snapshot into CSS variables lets the
// closing keyframes start from the mid-flight state instead.
const content = resolveElement(contentRef.value)
if (content) {
const current = getComputedStyle(content)
content.style.setProperty('--morph-from-transform', current.transform)
content.style.setProperty('--morph-from-opacity', current.opacity)
}
const overlay = resolveElement(overlayRef.value)
if (overlay)
overlay.style.setProperty('--morph-from-opacity', getComputedStyle(overlay).opacity)
}
contentAnim?.cancel()
overlayAnim?.cancel()
contentAnim = undefined
overlayAnim = undefined
if (!isOpen) {
// Clear the open animation's inline styles so the CSS close animation can run.
const content = resolveElement(contentRef.value)
if (content) {
content.style.transform = ''
content.style.opacity = ''
}
return
}
const content = await waitForContent(
() => resolveElement(contentRef.value) ?? document.querySelector<HTMLElement>('.search-dialog') ?? undefined,
)
// Stale guard: the dialog may have been closed (or reopened) while we were
// waiting — abort, so the opening animation never applies to a closing dialog.
if (run !== watchRun || !open.value)
return
const trigger = resolveElement(triggerRef.value)
if (!trigger || !content)
return
const triggerRect = trigger.getBoundingClientRect()
const contentRect = content.getBoundingClientRect()
const scaleX = clamp(triggerRect.width / contentRect.width, 0.1, 1)
const scaleY = clamp(triggerRect.height / contentRect.height, 0.1, 1)
const translateX = (triggerRect.left + triggerRect.width / 2) - (contentRect.left + contentRect.width / 2)
const translateY = (triggerRect.top + triggerRect.height / 2) - (contentRect.top + contentRect.height / 2)
const { style } = content
style.setProperty('--morph-x', `${translateX}px`)
style.setProperty('--morph-y', `${translateY}px`)
style.setProperty('--morph-sx', `${scaleX}`)
style.setProperty('--morph-sy', `${scaleY}`)
// Start at the search bar position (content is opacity-0, so no flash)
style.transform = `translate(${translateX}px, ${translateY}px) scale(${scaleX}, ${scaleY})`
content.getBoundingClientRect() // force reflow so the initial state applies first
const animation = content.animate(
[
{ transform: `translate(${translateX}px, ${translateY}px) scale(${scaleX}, ${scaleY})`, opacity: 0 },
{ transform: 'none', opacity: 1 },
],
// Nonlinear scale (fast-in, slow-out, no overshoot).
{ duration: 200, easing: 'cubic-bezier(0.16, 1, 0.3, 1)', fill: 'forwards' },
)
contentAnim = animation
animation.finished
.then(() => {
animation.cancel()
style.transform = ''
style.opacity = '1' // cancel reverts to the class's opacity-0; keep it visible explicitly
if (contentAnim === animation)
contentAnim = undefined
})
.catch(() => {})
const overlay = resolveElement(overlayRef.value)
if (overlay) {
const overlayAnimation = overlay.animate(
[{ opacity: 0 }, { opacity: 1 }],
{ duration: 200, easing: 'cubic-bezier(0.16, 1, 0.3, 1)', fill: 'forwards' },
)
overlayAnim = overlayAnimation
overlayAnimation.finished
.then(() => {
overlayAnimation.cancel()
overlay.style.opacity = '1'
if (overlayAnim === overlayAnimation)
overlayAnim = undefined
})
.catch(() => {})
}
})
</script>
<template>
<DialogRoot v-model:open="open">
<DialogTrigger class="text-md flex items-center border-muted rounded-lg px-3 py-[7px] text-muted-foreground transition-colors duration-200 ease-in-out space-x-2 md:border hover:bg-muted md:bg-card md:text-sm">
<DialogTrigger ref="triggerRef" class="text-md flex items-center border-muted rounded-lg px-3 py-[7px] text-muted-foreground transition-colors duration-200 ease-in-out space-x-2 md:border hover:bg-muted md:bg-card md:text-sm">
<Icon icon="lucide:search" />
<span class="hidden w-24 text-left lg:w-40 md:inline-flex">{{ t('docs.theme.search.title') }}</span>
<span class="hidden text-xs prose md:inline-flex">
@@ -35,32 +186,22 @@ function handleClose() {
</DialogTrigger>
<DialogPortal>
<AnimatePresence multiple>
<DialogOverlay as-child>
<Motion
class="fixed inset-0 z-30 bg-background/50 backdrop-blur-md"
:initial="{ opacity: 0, scale: 0 }"
:animate="{ opacity: 1, scale: 1 }"
:exit="{ opacity: 0 }"
/>
</DialogOverlay>
<DialogContent as-child>
<Motion
class="fixed left-[50%] top-[10%] z-[100] max-h-[85vh] max-w-[750px] w-[90vw] translate-x-[-50%] overflow-hidden border border-muted rounded-xl bg-card shadow-xl will-change-transform focus:outline-none"
:initial="{ opacity: 0, top: '0%', transition: { duration: 0.2, ease: 'easeInOut' } }"
:animate="{ opacity: 1, top: '10%', transition: { duration: 0.2, ease: 'easeInOut' } }"
:exit="{ opacity: 0, top: '0%', transition: { duration: 0.2, ease: 'easeInOut' } }"
>
<DialogTitle class="sr-only">
Search documentation
</DialogTitle>
<DialogDescription class="sr-only">
Show related results based on search term
</DialogDescription>
<SearchCommandBox @close="handleClose" />
</Motion>
</DialogContent>
</AnimatePresence>
<DialogOverlay
ref="overlayRef"
class="search-overlay fixed inset-0 z-30 bg-background/50 opacity-0 backdrop-blur-md"
/>
<DialogContent
ref="contentRef"
class="search-dialog fixed inset-x-0 top-[10%] z-[100] mx-auto max-h-[85vh] max-w-[750px] w-[90vw] origin-center overflow-hidden border border-muted rounded-xl bg-card opacity-0 shadow-xl focus:outline-none"
>
<DialogTitle class="sr-only">
Search documentation
</DialogTitle>
<DialogDescription class="sr-only">
Show related results based on search term
</DialogDescription>
<SearchCommandBox @close="handleClose" />
</DialogContent>
</DialogPortal>
</DialogRoot>
</template>
+27 -1
View File
@@ -4,6 +4,12 @@ import { SwitchRoot, SwitchThumb } from 'reka-ui'
import { useData } from 'vitepress'
import { ref, watchPostEffect } from 'vue'
defineOptions({
// DropdownMenuItem as-child passes item attrs here; ClientOnly (fragment)
// can't inherit them, so bind $attrs onto SwitchRoot manually.
inheritAttrs: false,
})
const { isDark } = useData()
const switchTitle = ref('')
@@ -13,15 +19,35 @@ watchPostEffect(() => {
? 'Switch to light theme'
: 'Switch to dark theme'
})
/**
* Wraps the theme switch in a View Transition so the browser cross-fades the
* whole page snapshots, keeping every element in sync. Falls back to an
* instant switch where `startViewTransition` is unsupported (e.g. Firefox).
*/
function onToggle(value: boolean) {
if (value === isDark.value)
return
if (typeof document !== 'undefined' && 'startViewTransition' in document) {
document.startViewTransition(() => {
isDark.value = value
})
}
else {
isDark.value = value
}
}
</script>
<template>
<ClientOnly>
<SwitchRoot
id="theme-toggle"
v-model="isDark"
v-bind="$attrs"
:model-value="isDark"
class="relative h-6 w-11 flex flex-shrink-0 border border-muted-foreground/10 rounded-full bg-muted"
:aria-label="switchTitle"
@update:model-value="onToggle"
>
<SwitchThumb
class="my-auto h-5 w-5 flex translate-x-0.5 items-center justify-center border border-muted rounded-full bg-background text-xs text-muted-foreground will-change-transform data-[state=checked]:translate-x-5 !transition-transform"
+11 -5
View File
@@ -1,7 +1,7 @@
import Color from 'colorjs.io'
import { withRetry } from '@moeru/std'
import { useDark } from '@vueuse/core'
import { useData } from 'vitepress'
export function themeColorFromPropertyOf(colorFromClass: string, property: string): () => Promise<string> {
return async () => {
@@ -18,15 +18,21 @@ export function themeColorFromPropertyOf(colorFromClass: string, property: strin
}
}
/**
* Resolves a theme color from a static value or per-scheme values.
*
* `useData()` must run here (factory body, called from `setup()`): inside the
* returned async closure the inject context is gone and it would throw.
* Reading VitePress' `isDark` also avoids stray `useDark()` instances that
* force the theme back to the system preference.
*/
export function themeColorFromValue(value: string | { light: string, dark: string }): () => Promise<string> {
const { isDark } = useData()
return async () => {
if (typeof value === 'string') {
return value
}
else {
const dark = useDark()
return dark.value ? value.dark : value.light
}
return isDark.value ? value.dark : value.light
}
}
+4 -11
View File
@@ -137,6 +137,7 @@ export default defineConfig<ThemeConfig>({
{
text: 'Manual',
icon: 'lucide:book-open',
link: withBase('/en/docs/manual/'),
items: [
{
text: 'Quick Start',
@@ -194,11 +195,6 @@ export default defineConfig<ThemeConfig>({
{ text: 'Before Story v0.0.1', link: withBase('/en/docs/chronicles/version-v0.0.1/') },
],
},
{
text: 'Characters',
icon: 'lucide:scan-face',
link: withBase('/en/characters/'),
},
] as (DefaultTheme.SidebarItem & { icon?: string })[],
homepage: {
@@ -282,12 +278,12 @@ export default defineConfig<ThemeConfig>({
{ text: '先前的故事 v0.0.1', link: withBase('/zh-Hans/docs/chronicles/version-v0.0.1/') },
],
},
{ text: '角色', link: withBase('/zh-Hans/characters/') },
],
},
{
text: '用户手册',
icon: 'lucide:book-open',
link: withBase('/zh-Hans/docs/manual/'),
items: [
{
text: '快速开始',
@@ -521,6 +517,7 @@ export default defineConfig<ThemeConfig>({
{
text: 'マニュアル',
icon: 'lucide:book-open',
link: withBase('/ja/docs/manual/'),
items: [
{
text: 'クイックスタート',
@@ -577,11 +574,6 @@ export default defineConfig<ThemeConfig>({
{ text: '前日譚 v0.0.1', link: withBase('/ja/docs/chronicles/version-v0.0.1/') },
],
},
{
text: 'キャラクター',
icon: 'lucide:scan-face',
link: withBase('/ja/characters/'),
},
] as (DefaultTheme.SidebarItem & { icon?: string })[],
homepage: {
@@ -662,6 +654,7 @@ export default defineConfig<ThemeConfig>({
{
text: '사용 설명서',
icon: 'lucide:book-open',
link: withBase('/ko/docs/manual/'),
items: [
{
text: '빠른 시작',
+88 -45
View File
@@ -4,10 +4,11 @@ import type { DefaultTheme } from 'vitepress/theme'
import type { Author } from '../functions/authors.data'
import { tryCatch } from '@moeru/std'
import { usePreferredReducedMotion } from '@vueuse/core'
import { intlFormat } from 'date-fns'
import { AvatarFallback, AvatarImage, AvatarRoot } from 'reka-ui'
import { Content, useData, useRoute } from 'vitepress'
import { computed, toRefs } from 'vue'
import { computed, onMounted, ref, toRefs, watch } from 'vue'
import { useI18n } from 'vue-i18n'
// import DocCarbonAds from '../components/DocCarbonAds.vue'
@@ -107,6 +108,39 @@ const authors = computed(() => {
const data = (authorsData as unknown as { data: Array<{ url: string, authors: Author[] }> }).data
return data.find(item => item.url === path.value)?.authors || []
})
// Title snapshot keyed by `path`: `frontmatter` updates before the article
// key changes, which would swap the leaving page's title to the new one.
const pageTitle = ref(frontmatter.value.title || '')
watch(path, () => {
pageTitle.value = frontmatter.value.title || ''
})
// Lock the first paint: hydration replays the transition, so start with an
// empty `name` (transition classes match no `.fade-*` CSS → no animation)
// and switch to "fade" only after the article height stabilizes.
const transitionName = ref('')
// Also honor the user's reduced-motion preference (system setting); the CSS
// media query in theme-animations.css backs this up for live preference changes.
const reducedMotion = usePreferredReducedMotion()
onMounted(async () => {
const article = document.querySelector<HTMLElement>('.docs-article')
if (article) {
let prevHeight = -1
for (let i = 0; i < 30; i++) {
await new Promise(resolve => requestAnimationFrame(resolve))
const height = article.getBoundingClientRect().height
if (height > 0 && height === prevHeight)
break
prevHeight = height
}
}
if (!reducedMotion.value)
transitionName.value = 'fade'
})
</script>
<template>
@@ -145,60 +179,69 @@ const authors = computed(() => {
</aside>
<div class="flex-1 overflow-x-hidden px-6 py-6 md:px-24 md:py-12">
<div class="mb-2 text-sm text-primary font-bold">
<!-- Section name for mobile (desktop has the sidebar) -->
<div class="mb-2 text-sm text-primary font-bold md:hidden">
{{ activeSection?.text }}
</div>
<article class="docs-article max-w-none w-full font-sans prose prose-slate dark:prose-invert">
<h1>
{{ frontmatter.title || '' }}
</h1>
<Transition
:name="transitionName"
mode="out-in"
>
<article
:key="path"
class="docs-article max-w-none w-full font-sans prose prose-slate dark:prose-invert"
>
<h1>
{{ pageTitle }}
</h1>
<div v-if="publishedAt || authors && authors.length" class="mb-10 mt-5 flex flex-col gap-3 sm:gap-5">
<div v-if="publishedAt" class="text-neutral-400 dark:text-neutral-500">
<span>
{{ t('docs.theme.doc.published-at', { date: publishedAt }) }}
</span>
</div>
<div v-if="publishedAt || authors && authors.length" class="mb-10 mt-5 flex flex-col gap-3 sm:gap-5">
<div v-if="publishedAt" class="text-neutral-400 dark:text-neutral-500">
<span>
{{ t('docs.theme.doc.published-at', { date: publishedAt }) }}
</span>
</div>
<div class="flex flex-row gap-2 sm:gap-4">
<!-- Authors -->
<div v-for="(author, index) of authors" :key="index" class="flex flex-row items-center gap-2.5">
<AvatarRoot class="size-10 inline-flex select-none items-center justify-center overflow-hidden rounded-full bg-neutral-100 align-middle dark:bg-neutral-800">
<AvatarImage
class="h-full w-full rounded-[inherit] object-cover"
:src="author.avatar || author.avatarFallback"
:alt="`${author.displayName}'s avatar`"
/>
<AvatarFallback
class="h-full w-full flex items-center justify-center bg-white text-sm text-primary font-medium leading-1 dark:bg-neutral-800 dark:text-neutral-300"
:delay-ms="600"
as-child
>
{{
[
author.displayName.charAt(0).toUpperCase(),
author.displayName.charAt(1).toUpperCase(),
].join('')
}}
</AvatarFallback>
</AvatarRoot>
<div class="flex flex-row gap-2 sm:gap-4">
<!-- Authors -->
<div v-for="(author, index) of authors" :key="index" class="flex flex-row items-center gap-2.5">
<AvatarRoot class="size-10 inline-flex select-none items-center justify-center overflow-hidden rounded-full bg-neutral-100 align-middle dark:bg-neutral-800">
<AvatarImage
class="h-full w-full rounded-[inherit] object-cover"
:src="author.avatar || author.avatarFallback"
:alt="`${author.displayName}'s avatar`"
/>
<AvatarFallback
class="h-full w-full flex items-center justify-center bg-white text-sm text-primary font-medium leading-1 dark:bg-neutral-800 dark:text-neutral-300"
:delay-ms="600"
as-child
>
{{
[
author.displayName.charAt(0).toUpperCase(),
author.displayName.charAt(1).toUpperCase(),
].join('')
}}
</AvatarFallback>
</AvatarRoot>
<div class="flex flex-col">
<div>
<span>{{ author.displayName }}</span>
</div>
<div v-if="author.githubUsername">
<a :href="`https://github.com/${author.githubUsername}`" target="_blank" rel="noopener noreferrer" class="text-sm text-primary hover:underline">
<span>{{ author.githubUsername }}</span>
</a>
<div class="flex flex-col">
<div>
<span>{{ author.displayName }}</span>
</div>
<div v-if="author.githubUsername">
<a :href="`https://github.com/${author.githubUsername}`" target="_blank" rel="noopener noreferrer" class="text-sm text-primary hover:underline">
<span>{{ author.githubUsername }}</span>
</a>
</div>
</div>
</div>
</div>
</div>
</div>
<Content />
</article>
<Content />
</article>
</Transition>
<DocFooter v-if="!isCharactersPage" />
</div>
+46 -2
View File
@@ -1,9 +1,53 @@
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.15s ease-in-out;
transition: opacity 0.2s ease-in-out, transform 0.2s ease-in-out;
}
.fade-enter-from {
opacity: 0;
transform: translateY(0.5rem);
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
transform: translateY(-0.5rem);
}
/* Search dialog close: reverse of the open FLIP morph (--morph-* set in
SearchTrigger.vue); reka-ui's Presence waits for these animations. */
.search-overlay[data-state='closed'] {
animation: search-fade-out 0.2s cubic-bezier(0.16, 1, 0.3, 1) forwards;
}
.search-dialog[data-state='closed'] {
animation: search-morph-out 0.2s cubic-bezier(0.16, 1, 0.3, 1) forwards;
}
@keyframes search-fade-out {
from { opacity: var(--morph-from-opacity, 1); }
to { opacity: 0; }
}
/* The `from` frame starts from the snapshot SearchTrigger.vue wrote before
cancelling the opening animation, so a mid-flight close does not flash the
full visible state first. */
@keyframes search-morph-out {
from {
transform: var(--morph-from-transform, none);
opacity: var(--morph-from-opacity, 1);
}
to {
transform: translate(var(--morph-x, 0px), var(--morph-y, 0px))
scale(var(--morph-sx, 1), var(--morph-sy, 1));
opacity: 0;
}
}
/* Honor the user's reduced-motion preference for the route transition
(Docs.vue also keeps its transition name empty in that case). */
@media (prefers-reduced-motion: reduce) {
.fade-enter-active,
.fade-leave-active {
transition: none;
}
}
@@ -7,7 +7,7 @@ description: Resources for referencing and taking inspiration from
One of the greatest CSS stage transition author I know is **[yui540](https://yui540.com/)**, he/she designed so many other stunning looking ACG websites, and the most famous one was [臆病な魔女](https://cowardly-witch.netlify.app/) (source code can be found under [yui540](https://github.com/yui540?tab=repositories)).
To achieve the similar transition effect like the above, you may reference to this repository [yui540/css-animations: 俺流CSSアニメーション](https://github.com/yui540/css-animations), it has a [live demo](https://yui540.github.io/css-animations/2025-02-25/transitions/) you can play around too.
To achieve the similar transition effect like the above, you may reference to this repository [yui540/css-animations: 俺流CSSアニメーション](https://github.com/yui540/css-animations).
### [Nihe Works](https://nihe.work/)
@@ -7,7 +7,7 @@ description: 参照やインスピレーションを得るためのリソース
私が知る限り、最も素晴らしい CSS ステージ遷移の作者の一人は **[yui540](https://yui540.com/)** です。彼/彼女は他にも多くの素晴らしい見た目の ACG ウェブサイトをデザインしており、最も有名なものは [臆病な魔女](https://cowardly-witch.netlify.app/) でした (ソースコードは [yui540](https://github.com/yui540?tab=repositories) で見つけることができます)。
上記のような同様の遷移効果を実現するには、このリポジトリ [yui540/css-animations: 俺流CSSアニメーション](https://github.com/yui540/css-animations) を参照してください。遊べる [ライブデモ](https://yui540.github.io/css-animations/2025-02-25/transitions/) もあります。
上記のような同様の遷移効果を実現するには、このリポジトリ [yui540/css-animations: 俺流CSSアニメーション](https://github.com/yui540/css-animations) を参照してください。
### [Nihe Works](https://nihe.work/)
@@ -7,7 +7,7 @@ description: 참고하고 영감을 얻을 수 있는 자료들
제가 아는 최고의 CSS 화면 전환 제작자 중 한 명은 **[yui540](https://yui540.com/)** 입니다. 눈부신 ACG 웹사이트를 정말 많이 디자인했고, 그중 가장 유명한 작품은 [臆病な魔女](https://cowardly-witch.netlify.app/) 입니다 (소스 코드는 [yui540](https://github.com/yui540?tab=repositories) 에서 찾아볼 수 있습니다).
위와 비슷한 전환 효과를 구현하고 싶다면 [yui540/css-animations: 俺流CSSアニメーション](https://github.com/yui540/css-animations) 저장소를 참고하세요. 직접 만져볼 수 있는 [라이브 데모](https://yui540.github.io/css-animations/2025-02-25/transitions/) 도 있습니다.
위와 비슷한 전환 효과를 구현하고 싶다면 [yui540/css-animations: 俺流CSSアニメーション](https://github.com/yui540/css-animations) 저장소를 참고하세요.
### [Nihe Works](https://nihe.work/)
+2
View File
@@ -3,4 +3,6 @@
/* /zh-Hans/:splat 301 Language=zh-CN
/* /ko/:splat 301 Language=ko
/* /ko/:splat 301 Language=ko-KR
/* /ja/:splat 301 Language=ja
/* /ja/:splat 301 Language=ja-JP
/* /en/:splat 301
@@ -7,7 +7,7 @@ description: 用于参考和获取灵感的资源
我所知道的最伟大的 CSS 场景过渡效果作者之一是 **[yui540](https://yui540.com/)**,他/她设计了许多其他外观惊艳的 ACG 网站,其中最著名的是 [臆病な魔女](https://cowardly-witch.netlify.app/)(源代码可以在 [yui540](https://github.com/yui540?tab=repositories) 下找到)。
要实现类似上述的过渡效果,您可以参考这个仓库 [yui540/css-animations: 俺流CSSアニメーション](https://github.com/yui540/css-animations),它还有一个 [在线演示](https://yui540.github.io/css-animations/2025-02-25/transitions/) 供您试玩。
要实现类似上述的过渡效果,您可以参考这个仓库 [yui540/css-animations: 俺流CSSアニメーション](https://github.com/yui540/css-animations)
### [Nihe Works](https://nihe.work/)