feat(docs): Happy Halloween! 🎃

This commit is contained in:
Neko Ayaka
2025-10-29 03:38:41 +08:00
parent 4887b6feba
commit 3cb8f74840
12 changed files with 381 additions and 274 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.1 MiB

@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" width="4em" height="4em" viewBox="0 0 24 24">
<g fill="none" fill-rule="evenodd" transform="rotate(30) translate(10 -5) scale(0.7)">
<path d="m12.593 23.258l-.011.002l-.071.035l-.02.004l-.014-.004l-.071-.035q-.016-.005-.024.005l-.004.01l-.017.428l.005.02l.01.013l.104.074l.015.004l.012-.004l.104-.074l.012-.016l.004-.017l-.017-.427q-.004-.016-.017-.018m.265-.113l-.013.002l-.185.093l-.01.01l-.003.011l.018.43l.005.012l.008.007l.201.093q.019.005.029-.008l.004-.014l-.034-.614q-.005-.018-.02-.022m-.715.002a.02.02 0 0 0-.027.006l-.006.014l-.034.614q.001.018.017.024l.015-.002l.201-.093l.01-.008l.004-.011l.017-.43l-.003-.012l-.01-.01z"/>
<path fill="currentColor" d="M12 2a9 9 0 0 1 9 9v8.62c0 1.83-1.966 2.987-3.565 2.099l-.363-.195c-1-.512-1.784-.68-2.889-.114l-.198.108a4 4 0 0 1-3.762.11l-.208-.11c-1.277-.73-2.166-.512-3.45.2c-1.6.89-3.565-.267-3.565-2.097V11a9 9 0 0 1 9-9M8.5 9a1.5 1.5 0 1 0 0 3a1.5 1.5 0 0 0 0-3m7 0a1.5 1.5 0 1 0 0 3a1.5 1.5 0 0 0 0-3"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

+62 -6
View File
@@ -6,7 +6,14 @@ import { createAnimatable } from 'animejs'
import { onMounted, shallowRef, useTemplateRef, watch, watchEffect } from 'vue'
import { useI18n } from 'vue-i18n'
// NOTICE: for unknown reasons, without `?no-inline`, the inlined SVG use in the SFC template
// of `url(...)` doesn't work properly in Vite/Vue, the entire SVG content either got incorrectly
// cached or erased in dev/prod, causing the mask pattern to not show up.
import homeBackgroundPatternGhost from '../assets/home-patterns-ghost.svg?no-inline'
import ParallaxCover from './ParallaxCover.vue'
import ParallaxCoverHalloween20251029 from './ParallaxCoverHalloween20251029.vue'
import { isBetweenHalloweenAndHalfOfNovember } from '../composables/date'
const heroRef = useTemplateRef<HTMLDivElement>('hero')
@@ -83,12 +90,30 @@ watchEffect((onCleanup) => {
<section class="mx-auto h-full max-w-[1440px] w-full flex flex-1 flex-col">
<div class="z-10 h-full w-full flex flex-1 flex-col items-center justify-start gap-4 overflow-hidden px-16 pb-16 pt-20 md:pt-36">
<div ref="hero" flex="~ col items-center gap-4 justify-start">
<div class="relative w-full justify-center text-center font-extrabold font-sans-rounded" text="4xl md:5xl">
<div>
Project AIRI
</div>
<div class="relative w-full flex flex-col items-center justify-center text-center font-extrabold font-sans-rounded" text="4xl md:5xl">
<ClientOnly>
<div
v-if="isBetweenHalloweenAndHalfOfNovember(new Date())"
:class="[
'w-fit',
'flex items-center gap-2',
'px-4 py-1',
'mb-3 sm:mb-4 lg:mb-8',
'border-1 rounded-full border-orange-500 bg-orange-100/20 text-orange-700 dark:border-orange-400 dark:bg-orange-900/20 dark:text-orange-300',
'text-sm sm:text-base align-middle',
]"
>
<div i-twemoji:jack-o-lantern />Happy Halloween!<div i-twemoji:jack-o-lantern />
</div>
<div :class="[isBetweenHalloweenAndHalfOfNovember(new Date()) ? 'font-sans-serif-halloween' : '']">
Project AIRI
</div>
</ClientOnly>
</div>
<div class="relative max-w-prose text-center text-slate-900 dark:text-white">
<div
class="relative max-w-prose text-center text-slate-900 dark:text-white"
:class="[isBetweenHalloweenAndHalfOfNovember(new Date()) ? 'font-sans-serif-halloween-secondary' : '']"
>
{{ t('docs.theme.home.subtitle') }}
</div>
<div class="relative z-10 w-full flex justify-center gap-4">
@@ -134,10 +159,17 @@ watchEffect((onCleanup) => {
</section>
<div class="absolute inset-0 overflow-hidden -z-10">
<!-- Repeating, slowly scrolling SVG pattern background -->
<div class="bg-ghost-pattern pointer-events-none absolute inset-0 z-0 opacity-10 dark:opacity-10" :style="{ '--bg-mask-ghost-pattern': `url(${homeBackgroundPatternGhost})` }" />
<!-- Flickering red (even within the color space range) if to top in oklch (UnoCSS or tailwind css default), have to force to use srgb color space to prevent this -->
<div class="absolute bottom-0 left-0 right-0 top-0 z-2 h-80% from-transparent to-white bg-gradient-to-t dark:to-[hsl(207_15%_5%)]" style="--un-gradient-shape: to top in srgb;" />
<ClientOnly>
<ParallaxCover />
<template v-if="isBetweenHalloweenAndHalfOfNovember(new Date())">
<ParallaxCoverHalloween20251029 />
</template>
<template v-else>
<ParallaxCover />
</template>
</ClientOnly>
</div>
@@ -148,3 +180,27 @@ watchEffect((onCleanup) => {
</footer>
</div>
</template>
<style>
/* Infinite scrolling background pattern via mask */
.bg-ghost-pattern {
background-color: white;
-webkit-mask-image: var(--bg-mask-ghost-pattern);
mask-image: var(--bg-mask-ghost-pattern);
-webkit-mask-repeat: repeat;
mask-repeat: repeat;
-webkit-mask-size: 256px 256px;
mask-size: 256px 256px;
-webkit-mask-position: 0 0;
mask-position: 0 0;
animation: ghost-mask-scroll 8s linear infinite;
}
@keyframes ghost-mask-scroll {
100% { -webkit-mask-position: 256px 256px; mask-position: 256px 256px; }
}
@media (prefers-reduced-motion: reduce) {
.bg-ghost-pattern { animation: none; }
}
</style>
+14 -14
View File
@@ -8,8 +8,8 @@ import { onMounted, shallowRef, useTemplateRef, watchEffect } from 'vue'
import homeCover from '../assets/home-cover-2025-07-23.avif'
const surfaceRef = useTemplateRef<HTMLImageElement>('surface')
const silhouettePinkRef = useTemplateRef<HTMLDivElement>('silhouettePink')
const silhouettePurpleRef = useTemplateRef<HTMLDivElement>('silhouettePurple')
const silhouetteLayer1Ref = useTemplateRef<HTMLDivElement>('silhouetteLayer1')
const silhouetteLayer2Ref = useTemplateRef<HTMLDivElement>('silhouetteLayer2')
const shouldReduceMotion = useLocalStorage('docs:settings/reduce-motion', false)
@@ -17,8 +17,8 @@ const DURATION = 1200
const EASE = 'outSine'
const surfaceAnimatable = shallowRef<AnimatableObject>()
const silhouettePinkAnimatable = shallowRef<AnimatableObject>()
const silhouettePurpleAnimatable = shallowRef<AnimatableObject>()
const silhouetteLayer1Animatable = shallowRef<AnimatableObject>()
const silhouetteLayer2Animatable = shallowRef<AnimatableObject>()
function animateCover(xOffsetRatio: number, yOffsetRatio: number) {
const referenceWidth = window.innerWidth
@@ -27,13 +27,13 @@ function animateCover(xOffsetRatio: number, yOffsetRatio: number) {
surfaceAnimatable.value?.y?.(-yOffsetRatio * 0.02 * referenceWidth)
surfaceAnimatable.value?.z?.(0)
silhouettePinkAnimatable.value?.x?.(0.01 * referenceWidth - yOffsetRatio * 0.015 * referenceWidth)
silhouettePinkAnimatable.value?.y?.(0.02 * referenceWidth + xOffsetRatio * 0.015 * referenceWidth)
silhouettePinkAnimatable.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)
silhouettePurpleAnimatable.value?.x?.(0.01 * referenceWidth + yOffsetRatio * 0.01 * referenceWidth)
silhouettePurpleAnimatable.value?.y?.(-0.01 * referenceWidth - yOffsetRatio * 0.01 * referenceWidth)
silhouettePurpleAnimatable.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 +55,8 @@ onMounted(() => {
}
surfaceAnimatable.value = createAnimatable(surfaceRef.value!, animatableConfig)
silhouettePinkAnimatable.value = createAnimatable(silhouettePinkRef.value!, animatableConfig)
silhouettePurpleAnimatable.value = createAnimatable(silhouettePurpleRef.value!, animatableConfig)
silhouetteLayer1Animatable.value = createAnimatable(silhouetteLayer1Ref.value!, animatableConfig)
silhouetteLayer2Animatable.value = createAnimatable(silhouetteLayer2Ref.value!, animatableConfig)
})
watchEffect((onCleanup) => {
@@ -88,8 +88,8 @@ const maskImageURL = `url(${homeCover})`
]"
>
<img ref="surface" :src="homeCover" alt="Project AIRI Cover Image" class="w-full object-cover">
<div ref="silhouettePink" class="silhouette absolute left-0 top-0 z--1 h-full w-full bg-[oklch(0.8105_0.1267_350.84)]" />
<div ref="silhouettePurple" class="silhouette absolute left-0 top-0 z--2 h-full w-full bg-[oklch(0.5712_0.2396_278.59)]" />
<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="silhouetteLayer2" class="silhouette absolute left-0 top-0 z--2 h-full w-full bg-[oklch(0.5712_0.2396_278.59)]" />
</div>
</template>
@@ -0,0 +1,109 @@
<script setup lang="ts">
import type { AnimatableObject } from 'animejs'
import { useLocalStorage } from '@vueuse/core'
import { createAnimatable } from 'animejs'
import { onMounted, shallowRef, useTemplateRef, watchEffect } from 'vue'
import homeCover from '../assets/home-cover-2025-10-29.png'
const surfaceRef = useTemplateRef<HTMLImageElement>('surface')
const silhouetteLayer1Ref = useTemplateRef<HTMLDivElement>('silhouetteLayer1')
const silhouetteLayer2Ref = useTemplateRef<HTMLDivElement>('silhouetteLayer2')
const shouldReduceMotion = useLocalStorage('docs:settings/reduce-motion', false)
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
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) {
const x = event.clientX
const y = event.clientY
const xOffsetRatio = (x - window.innerWidth / 2) / window.innerWidth
const yOffsetRatio = (y - window.innerHeight / 2) / window.innerHeight
animateCover(xOffsetRatio, yOffsetRatio)
}
onMounted(() => {
const animatableConfig = {
x: DURATION,
y: DURATION,
z: 0,
ease: EASE,
}
surfaceAnimatable.value = createAnimatable(surfaceRef.value!, animatableConfig)
silhouetteLayer1Animatable.value = createAnimatable(silhouetteLayer1Ref.value!, animatableConfig)
silhouetteLayer2Animatable.value = createAnimatable(silhouetteLayer2Ref.value!, animatableConfig)
})
watchEffect((onCleanup) => {
if (shouldReduceMotion.value) {
animateCover(0, 0)
}
else {
if (!import.meta.env.SSR) {
window.addEventListener('mousemove', onMouseMove)
onCleanup(() => {
window.removeEventListener('mousemove', onMouseMove)
})
}
}
})
const maskImageURL = `url(${homeCover})`
</script>
<template>
<div
:class="[
'relative left-1/2 -translate-x-1/2 max-w-none z-1',
'w-[160%] translate-y-[50%] translate-x-[-75%] scale-150 -rotate-0 top-8rem',
'md:w-[150%] md:translate-y-[40%] md:rotate-[0deg] scale-125 md:top-8dvh',
'lg:w-[95%] lg:translate-y-[2%] lg:translate-x-[-55%] lg:rotate-[0deg] lg:scale-95 lg:top-32dvh',
'xl:top-18dvh',
'2xl:w-[100%] 2xl:translate-y-[-4%] 2xl:translate-x-[-60%] 2xl:rotate-[0deg] 2xl:top-16dvh',
]"
>
<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="silhouetteLayer2" class="silhouette absolute left-0 top-0 z--2 h-full w-full bg-[oklch(0.69_0.16_295.04)]" />
</div>
</template>
<style scoped>
.silhouette {
-webkit-mask-image: v-bind(maskImageURL);
mask-image: v-bind(maskImageURL);
-webkit-mask-mode: alpha;
mask-mode: alpha;
-webkit-mask-size: contain;
mask-size: contain;
-webkit-mask-repeat: no-repeat;
mask-repeat: no-repeat;
-webkit-mask-position: center; /* Center the mask */
mask-position: center;
}
</style>
+9
View File
@@ -0,0 +1,9 @@
import { isAfter, isBefore } from 'date-fns'
export function isBetweenHalloweenAndHalfOfNovember(date: Date) {
const year = date.getFullYear()
const halloween = new Date(year, 9, 25) // October 25
const halfOfNovember = new Date(year, 10, 15) // November 15
return isAfter(date, halloween) && isBefore(date, halfOfNovember)
}
+17 -6
View File
@@ -12,6 +12,7 @@ import DocOutline from '../components/DocOutline.vue'
import DocSidebar from '../components/DocSidebar.vue'
import DocTopbar from '../components/DocTopbar.vue'
import { isBetweenHalloweenAndHalfOfNovember } from '../composables/date'
import { flatten } from '../utils/flatten'
const { theme, frontmatter } = useData()
@@ -51,12 +52,22 @@ const isCharactersPage = computed(() => path.value.includes('characters'))
class="pointer-events-none absolute inset-0 left-0 top-0 z-0 h-max w-full flex justify-center overflow-hidden"
>
<div class="w-[108rem] flex flex-none justify-end">
<img
class="max-w-none w-[90rem] flex-none"
decoding="async"
src="/new-bg.avif"
alt="backdrop"
>
<ClientOnly>
<img
v-if="isBetweenHalloweenAndHalfOfNovember(new Date())"
class="max-w-none w-[90rem] flex-none"
decoding="async"
src="/new-bg-halloween.png"
alt="backdrop"
>
<img
v-else
class="max-w-none w-[90rem] flex-none"
decoding="async"
src="/new-bg.avif"
alt="backdrop"
>
</ClientOnly>
</div>
</div>
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

+3 -1
View File
@@ -23,6 +23,7 @@
"@proj-airi/i18n": "workspace:^",
"@vueuse/core": "^13.9.0",
"colorjs.io": "^0.5.2",
"date-fns": "^4.1.0",
"mark.js": "^8.11.1",
"motion-v": "^1.7.2",
"pathe": "^2.0.3",
@@ -33,6 +34,8 @@
},
"devDependencies": {
"@iconify-json/lucide": "^1.2.69",
"@iconify-json/mingcute": "^1.2.5",
"@iconify-json/twemoji": "^1.2.4",
"@iconify/vue": "^5.0.0",
"@intlify/unplugin-vue-i18n": "^11.0.1",
"@mdit/plugin-footnote": "^0.22.2",
@@ -45,7 +48,6 @@
"@unocss/reset": "^66.5.3",
"@vue/tsconfig": "^0.8.1",
"animejs": "^4.2.2",
"fast-glob": "^3.3.3",
"gray-matter": "^4.0.3",
"markdown-it": "^14.1.0",
"markdown-it-anchor": "^9.2.0",
+8
View File
@@ -64,6 +64,12 @@ export default defineConfig({
name: 'Comfortaa Variable',
provider: 'none',
},
'mystery-quest': {
name: 'Mystery Quest',
},
'grandstander': {
name: 'Grandstander',
},
},
}),
presetIcons(),
@@ -100,6 +106,8 @@ export default defineConfig({
fontFamily: {
'sans': `ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";`,
'sans-rounded': `"DM Sans", ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";`,
'sans-serif-halloween-secondary': `"Grandstander", "DM Sans", ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";`,
'sans-serif-halloween': `"Mystery Quest", "DM Sans", ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";`,
},
colors: {
border: 'hsl(var(--border))',