From f28c4ae6291927483d30416b7172b6f18cd0d824 Mon Sep 17 00:00:00 2001 From: yororoIce <3364817735@qq.com> Date: Fri, 16 Oct 2026 20:04:01 +0800 Subject: [PATCH 1/2] feat(theme): polish P3R motion with fluid wipe and menu Route changes now wait until the fluid wipe fully covers the page, so loading boundaries no longer flash beside the expanding edge. The menu floods from its opener and drains back, page stages enter in layers, scenes follow fine pointers in depth, and dialogs open with a mail-style split slab. --- .../login/components/components.module.scss | 9 +- src/components/appearance/p3r-effects.tsx | 149 ++++++++++++------ src/components/appearance/p3r-fluid.ts | 36 +++++ src/components/appearance/p3r-link.tsx | 16 +- .../appearance/p3r-scene.module.scss | 15 +- src/components/appearance/p3r-scene.tsx | 42 ++++- src/components/appearance/p3r-transition.ts | 11 +- src/components/blog/_p3r-ui.scss | 19 ++- src/components/blog/blog-shell.tsx | 5 +- .../blog/p3r-navigation.module.scss | 46 ++++-- src/components/blog/p3r-navigation.tsx | 89 ++++++++++- src/components/blog/route-loading.tsx | 3 +- src/styles/_p3r-motion.scss | 50 ++++++ 13 files changed, 413 insertions(+), 77 deletions(-) create mode 100644 src/components/appearance/p3r-fluid.ts create mode 100644 src/styles/_p3r-motion.scss diff --git a/src/app/login/components/components.module.scss b/src/app/login/components/components.module.scss index 38084b7..ae10dd6 100644 --- a/src/app/login/components/components.module.scss +++ b/src/app/login/components/components.module.scss @@ -1,4 +1,5 @@ @use '@/styles/appearance-tokens' as appearance; +@use '@/styles/p3r-motion'; .dialog-overlay { @include appearance.surface; @@ -268,7 +269,8 @@ :global(html[data-design="p3r"]) { .dialog-overlay { background: #031e55ba; backdrop-filter: blur(4px); } - .accountManagement, .declaration { border: 3px solid #0754e4; border-left: 7px solid #58e5ff; background: var(--ark-p3r-panel); box-shadow: 8px 8px 0 #032369; animation: p3r-dialog-in .32s ease-in-out; } + // Previous slide-in panel entry (p3r-dialog-in): commit 65630bf. + .accountManagement, .declaration { border: 3px solid #0754e4; border-left: 7px solid #58e5ff; background: var(--ark-p3r-panel); box-shadow: 8px 8px 0 #032369; @include p3r-motion.mail-slab; } .accountManagementTitle { padding: 20px 24px; background: #0754e4; color: #fff; border: 0; } .accountManagementTitle h1 { font-family: var(--font-noto-sans), sans-serif; font-weight: 700; } .accountManagementTitle h1::after { color: #58e5ff; font: italic 800 26px/1 var(--ark-p3r-display); } @@ -284,9 +286,10 @@ .declarationContentWrapper h1 { font-family: var(--font-noto-sans), sans-serif; font-weight: 700; color: var(--accent); } .declarationBtn { background: #0754e4; color: #fff; border-color: #58e5ff; } } -@keyframes p3r-dialog-in { from { opacity: 0; transform: translate(35px, -10px); } to { opacity: 1; transform: none; } } +// @keyframes p3r-dialog-in { from { opacity: 0; transform: translate(35px, -10px); } to { opacity: 1; transform: none; } } +@include p3r-motion.mail-slab-keyframes; @media (prefers-reduced-motion: reduce) { - :global(html[data-design="p3r"]) { .accountManagement, .declaration { animation: none; } } + :global(html[data-design="p3r"]) { .accountManagement, .declaration { @include p3r-motion.mail-slab-reduced; } } } @media (max-width: 600px) { diff --git a/src/components/appearance/p3r-effects.tsx b/src/components/appearance/p3r-effects.tsx index 0354ad0..cf47962 100644 --- a/src/components/appearance/p3r-effects.tsx +++ b/src/components/appearance/p3r-effects.tsx @@ -4,11 +4,22 @@ import { useEffect, useRef } from 'react' import { usePathname } from 'next/navigation' import { useAppearance } from './appearance-store' import { useRainConnection } from './rain-connection-store' -import { P3R_WIPE_EVENT, type P3RWipeRequest } from './p3r-transition' +import { handOffUnderP3RWipe, P3R_WIPE_EVENT, type P3RWipeRequest } from './p3r-transition' +import { fluidCoverRadius, fluidScale, fluidSeed, type FluidSeed } from './p3r-fluid' import styles from './p3r-effects.module.scss' -const ease = (t: number) => t * t * (3 - 2 * t) +const easeOut = (t: number) => 1 - (1 - t) ** 3 +const easeInOut = (t: number) => t < .5 ? 4 * t ** 3 : 1 - (2 - 2 * t) ** 3 / 2 +// Navigation starts only under the full cover, so the cover stays short. +const COVER_MS = 220 +const HOLD_MS = 60 +const REVEAL_MS = 340 +// A streaming loading boundary or a page still waiting for its client chunk +// stays covered briefly instead of opening onto a blank page. +const LOADING_GRACE_MS = 300 const ROUTE_FALLBACK_MS = 8000 +const TAU = Math.PI * 2 +const SEGMENTS = 72 export default function P3REffects() { const { design } = useAppearance() @@ -27,8 +38,11 @@ export default function P3REffects() { if (!ctx) return const context = ctx const motion = matchMedia('(prefers-reduced-motion: reduce)') + const points = new Float64Array(SEGMENTS * 2) let frame = 0, timeout = 0, width = 0, height = 0 let active = false + let pending: P3RWipeRequest['navigate'] + let pendingFallback: P3RWipeRequest['fallback'] const resize = () => { width = innerWidth height = innerHeight @@ -37,69 +51,110 @@ export default function P3REffects() { canvas.height = Math.ceil(height * ratio) context.setTransform(ratio, 0, 0, ratio, 0, 0) } + // Mirrored on so entrances mounted beneath the cover wait for the reveal. + const setPhase = (phase: 'idle' | 'expanding' | 'covered' | 'revealing') => { + layer.dataset.phase = phase + if (phase === 'idle') delete document.documentElement.dataset.p3rWipe + else document.documentElement.dataset.p3rWipe = phase + } + // Smooth closed outline through the fluid vertices. + const blob = (x: number, y: number, radius: number, time: number, seed: FluidSeed, fill: string) => { + for (let index = 0; index < SEGMENTS; index++) { + const angle = index / SEGMENTS * TAU + const extent = Math.max(0, radius * fluidScale(angle, time, seed)) + points[index * 2] = x + Math.cos(angle) * extent + points[index * 2 + 1] = y + Math.sin(angle) * extent + } + const last = (SEGMENTS - 1) * 2 + context.beginPath() + context.moveTo((points[last] + points[0]) / 2, (points[last + 1] + points[1]) / 2) + for (let index = 0; index < SEGMENTS; index++) { + const point = index * 2, next = (index + 1) % SEGMENTS * 2 + context.quadraticCurveTo(points[point], points[point + 1], (points[point] + points[next]) / 2, (points[point + 1] + points[next + 1]) / 2) + } + context.fillStyle = fill + context.fill() + } const clear = () => { cancelAnimationFrame(frame) clearTimeout(timeout) committed.current = null + pending = pendingFallback = undefined active = false - layer.dataset.phase = 'idle' + setPhase('idle') + context.globalCompositeOperation = 'source-over' context.clearRect(0, 0, width, height) if (typeof layer.hidePopover === 'function' && layer.matches(':popover-open')) layer.hidePopover() } - const skip = () => clear() + // Esc, a hidden tab or reduced motion end the visuals but keep the route. + const skip = () => { + const navigate = pending, fallback = pendingFallback + clear() + if (!navigate) return + try { navigate() } catch { fallback?.() } + } const start = ({ x, y, navigate, fallback }: P3RWipeRequest) => { clear() resize() - let ready = !navigate - committed.current = navigate ? () => { ready = true } : null - try { - if (navigate && !navigate()) { clear(); return } - } catch { - clear() - fallback?.() - return - } active = true - layer.dataset.phase = 'expanding' + pending = navigate + pendingFallback = fallback + setPhase('expanding') // A non-focusable manual popover stays above native menu dialogs. if (typeof layer.showPopover === 'function') layer.showPopover() const started = performance.now() - let revealAt = 0, covered = false - const circle = (radius: number, fill: string) => { - context.beginPath() - context.arc(x, y, Math.max(0, radius), 0, Math.PI * 2) - context.fillStyle = fill - context.fill() + const [bodySeed, tintSeed, holeSeed] = [fluidSeed(), fluidSeed(), fluidSeed()] + let coveredAt = 0, readyAt = 0, revealAt = 0 + // The route changes only under the full cover. False means the wipe ended. + const handoff = () => { + const route = pending, routeFallback = pendingFallback + pending = pendingFallback = undefined + if (!route) { readyAt = performance.now(); return true } + committed.current = () => { committed.current = null; readyAt = performance.now() } + try { + if (!handOffUnderP3RWipe(route)) { committed.current = null; readyAt = performance.now(); return true } + } catch { + clear() + routeFallback?.() + return false + } + // A stalled client route must still reach its destination. + timeout = window.setTimeout(() => { + committed.current = null + if (!routeFallback) { clear(); return } + try { routeFallback() } catch { clear() } + }, ROUTE_FALLBACK_MS) + return true } const draw = (now: number) => { - const radius = Math.hypot(Math.max(x, width - x), Math.max(y, height - y)) + 8 + const extent = fluidCoverRadius(x, y, width, height) + const elapsed = now - started context.clearRect(0, 0, width, height) - const entering = Math.min(1, (now - started) / 380) - if (entering < 1) { - const extent = radius * ease(entering) - circle(extent, '#58e5ff') - circle(extent - 7, '#f4fbff') - circle(extent - 10, '#0754e4') + if (elapsed < COVER_MS) { + const progress = elapsed / COVER_MS + blob(x, y, extent * easeOut(Math.min(1, progress / .8)), elapsed, tintSeed, '#0754e46b') + const radius = extent * easeOut(progress) + blob(x, y, radius, elapsed, bodySeed, '#58e5ff') + blob(x, y, radius - 7, elapsed, bodySeed, '#f4fbff') + blob(x, y, radius - 10, elapsed, bodySeed, '#0754e4') } else { context.fillStyle = '#0754e4' context.fillRect(0, 0, width, height) - if (!covered) { - covered = true - layer.dataset.phase = 'covered' - // A stalled client route must still reach its destination. - timeout = window.setTimeout(() => { - committed.current = null - if (!fallback) { clear(); return } - try { fallback() } catch { clear() } - }, ROUTE_FALLBACK_MS) + if (!coveredAt) { + coveredAt = now + setPhase('covered') + if (!handoff()) return + } + if (!revealAt && readyAt && now - coveredAt >= HOLD_MS) { + const contentPending = !!document.querySelector('[data-route-loading], #main-content:empty') + if (!contentPending || now - readyAt >= LOADING_GRACE_MS) { revealAt = now; setPhase('revealing') } } - if (ready && now - started >= 440) { - if (!revealAt) { revealAt = now; layer.dataset.phase = 'revealing' } - const progress = Math.min(1, (now - revealAt) / 360) - const extent = radius * ease(progress) - circle(extent, '#58e5ff') + if (revealAt) { + const progress = Math.min(1, (now - revealAt) / REVEAL_MS) + const radius = extent * easeInOut(progress) + blob(x, y, radius, now - revealAt, holeSeed, '#58e5ff') context.globalCompositeOperation = 'destination-out' - circle(extent - 6, '#000') + blob(x, y, radius - 6, now - revealAt, holeSeed, '#000') context.globalCompositeOperation = 'source-over' if (progress === 1) { clear(); return } } @@ -111,8 +166,13 @@ export default function P3REffects() { const request = (event: Event) => { if (motion.matches || document.hidden || useRainConnection.getState().connection) return event.preventDefault() - if (active) return - start((event as CustomEvent).detail) + const detail = (event as CustomEvent).detail + // A link chosen while the hole opens starts its own wipe from there. + if (!active || layer.dataset.phase === 'revealing') { start(detail); return } + // Before the handoff the latest choice wins; blind clicks under the full + // cover are absorbed. + const replacesPendingRoute = !!pending && !!detail.navigate + if (replacesPendingRoute) { pending = detail.navigate; pendingFallback = detail.fallback } } const key = (event: KeyboardEvent) => { if (active && event.key === 'Escape') skip() } const preference = () => { if (motion.matches) skip() } @@ -133,6 +193,7 @@ export default function P3REffects() { skip() } }, [design]) + // Previous circular cover with click-time navigation: commit 65630bf. // Previous double-ring / diagonal implementation: commit e2ef28d. return design === 'p3r' ? : null diff --git a/src/components/appearance/p3r-fluid.ts b/src/components/appearance/p3r-fluid.ts new file mode 100644 index 0000000..9253bc7 --- /dev/null +++ b/src/components/appearance/p3r-fluid.ts @@ -0,0 +1,36 @@ +// Shared liquid outline for the P3R page wipe and menu. A few slow, drifting +// harmonics read as water rather than a gear; weights sum to 1. +const TAU = Math.PI * 2 +const HARMONICS = [[2, .5, .0012], [3, .32, -.0019], [5, .18, .0027]] as const +// Largest radial deviation as a fraction of the radius. +export const FLUID_EDGE = .085 + +export type FluidSeed = readonly number[] + +// Fresh phases keep consecutive effects from repeating one silhouette. +export const fluidSeed = (): FluidSeed => HARMONICS.map(() => Math.random() * TAU) + +// Radius multiplier at an angle; time is in milliseconds. +export function fluidScale(angle: number, time: number, seed: FluidSeed) { + let wave = 0 + for (let index = 0; index < HARMONICS.length; index++) { + const [frequency, weight, drift] = HARMONICS[index] + wave += weight * Math.sin(frequency * angle + seed[index] + drift * time) + } + return 1 + FLUID_EDGE * wave +} + +// Every lobe of the outline still clears the farthest viewport corner. +export function fluidCoverRadius(x: number, y: number, width: number, height: number) { + return Math.hypot(Math.max(x, width - x), Math.max(y, height - y)) / (1 - FLUID_EDGE) + 12 +} + +// Pixel vertices for CSS polygon() / path() clips. At viewport radii a 64-gon +// stays within about 2px of the smooth curve. +export function fluidPoints(x: number, y: number, radius: number, time: number, seed: FluidSeed, segments = 64) { + return Array.from({ length: segments }, (_, index): [number, number] => { + const angle = index / segments * TAU + const extent = Math.max(0, radius * fluidScale(angle, time, seed)) + return [x + Math.cos(angle) * extent, y + Math.sin(angle) * extent] + }) +} diff --git a/src/components/appearance/p3r-link.tsx b/src/components/appearance/p3r-link.tsx index 7238225..29a1b2c 100644 --- a/src/components/appearance/p3r-link.tsx +++ b/src/components/appearance/p3r-link.tsx @@ -39,19 +39,21 @@ export default function P3RLink({ onClick, onFocus, onMouseEnter, onNavigate, pr // filters keep their immediate behavior; modified clicks, downloads and // external links do not reach onNavigate. if (!changesPage) { onNavigate?.(event); return } + const destination = `${url.pathname}${url.search}${url.hash}` + // The caller's handler and the route change wait until the page is + // covered, so loading boundaries never flash beside the expanding edge. const accepted = requestP3RWipe({ x, y, navigate: () => { let cancelled = false onNavigate?.({ preventDefault: () => { cancelled = true } }) if (cancelled) return false - const destination = `${url.pathname}${url.search}${url.hash}` router[replace ? 'replace' : 'push'](destination, { scroll, transitionTypes }) return true - }, fallback: () => { - const destination = `${url.pathname}${url.search}${url.hash}` - location.assign(destination) - } }) - if (accepted) event.preventDefault() - else onNavigate?.(event) + }, fallback: () => location.assign(destination) }) + if (!accepted) { onNavigate?.(event); return } + event.preventDefault() + // The route changes under the cover; warm it now, including touch taps + // without intent prefetch. Opted-out links keep their single request. + if (prefetch !== false) router.prefetch(destination) }} /> } diff --git a/src/components/appearance/p3r-scene.module.scss b/src/components/appearance/p3r-scene.module.scss index ebaae95..a2a2584 100644 --- a/src/components/appearance/p3r-scene.module.scss +++ b/src/components/appearance/p3r-scene.module.scss @@ -1,14 +1,20 @@ .scene { display: none; position: absolute; inset: 0; overflow: hidden; pointer-events: none; isolation: isolate; background: var(--ark-p3r-blue); contain: paint; + // Pointer offsets in -1…1, eased by p3r-scene.tsx. Nearer layers travel further. + --parallax-x: 0; --parallax-y: 0; *, *::before, *::after { box-sizing: border-box; } } :global(html[data-design="p3r"]) .scene { display: block; } +// The independent translate property keeps the rotations and drift animations +// on transform untouched. +@mixin depth($x, $y) { translate: calc(var(--parallax-x) * #{$x}) calc(var(--parallax-y) * #{$y}); } .light-field { position: absolute; inset: 0; background: linear-gradient(127deg, transparent 25%, #58e5ff40 25% 31%, transparent 31% 53%, #58e5ff20 53% 62%, transparent 62%), linear-gradient(170deg, #002563 0%, transparent 70%); } .moon { position: absolute; top: 8%; left: 37%; width: min(42vw, 580px); aspect-ratio: 1; border-radius: 50%; background: #b6f2ff; box-shadow: inset -25px -15px 0 #6ac5e4, 0 0 0 20px #58e5ff0d, 0 0 0 21px #58e5ff40; + @include depth(-8px, -6px); i { position: absolute; border-radius: 50%; background: #5dbedb3b; transform: rotate(-30deg); } i:nth-child(1) { inset: 12% 37% 41% 15%; } i:nth-child(2) { inset: 57% 30% 14% 28%; } @@ -16,6 +22,7 @@ } .window-plane { position: absolute; inset: -25% -10% -20% 55%; transform: rotate(19deg); border-left: 30px solid #021d59; background: #001c4d20; + @include depth(-16px, -8px); i { position: absolute; top: 0; bottom: 0; width: 8px; background: #02225f; } i:nth-child(1) { left: 20%; } i:nth-child(2) { left: 58%; width: 20px; } @@ -24,6 +31,7 @@ } .current { position: absolute; inset: 38% -10% -9%; animation: current-drift 16s ease-in-out infinite alternate; + @include depth(-22px, -6px); svg { width: 100%; height: 100%; } path:nth-child(1) { fill: #258fdde0; } path:nth-child(2) { fill: #1171db; } @@ -32,12 +40,14 @@ } .ripples { position: absolute; width: 76%; height: 40%; bottom: -17%; left: 20%; transform: rotate(-14deg); + @include depth(-30px, -8px); i { position: absolute; inset: 0; border-radius: 50%; border: 2px solid #67ebff9c; } i:nth-child(2) { inset: -16%; border-width: 12px; opacity: .3; } i:nth-child(3) { inset: -34%; opacity: .2; } } .shards { position: absolute; inset: 0; animation: shard-drift 10s ease-in-out infinite alternate; + @include depth(-42px, -24px); i { position: absolute; width: 8px; height: 50px; background: #b3f9ff; transform: rotate(27deg); opacity: .6; } i:nth-child(1) { left: 17%; top: 62%; height: 24px; } i:nth-child(2) { left: 44%; top: 17%; width: 4px; } @@ -58,4 +68,7 @@ .scene[data-variant="menu"] .moon { left: -27%; width: 100vw; } .scene[data-variant="login"] .moon { left: -15%; width: 110vw; top: -5%; } } -@media (prefers-reduced-motion: reduce) { .current, .shards { animation: none; } } +@media (prefers-reduced-motion: reduce) { + .current, .shards { animation: none; } + .moon, .window-plane, .current, .ripples, .shards { translate: none; } +} diff --git a/src/components/appearance/p3r-scene.tsx b/src/components/appearance/p3r-scene.tsx index 6826a15..0554b02 100644 --- a/src/components/appearance/p3r-scene.tsx +++ b/src/components/appearance/p3r-scene.tsx @@ -12,20 +12,56 @@ export default function P3RScene({ variant = 'hero' }: { variant?: 'hero' | 'men useEffect(() => { const node = ref.current if (!node || design !== 'p3r') return - let visible = false + let visible = false, frame = 0 const media = matchMedia('(prefers-reduced-motion: reduce)') - const update = () => { node.dataset.moving = String(visible && !document.hidden && !media.matches) } + const finePointer = matchMedia('(pointer: fine)') + // Large scenes lean toward the pointer in depth; heading bands stay still. + const followsPointer = variant !== 'heading' + const target = { x: 0, y: 0 }, current = { x: 0, y: 0 } + const apply = () => { + node.style.setProperty('--parallax-x', current.x.toFixed(4)) + node.style.setProperty('--parallax-y', current.y.toFixed(4)) + } + const settle = () => { + current.x += (target.x - current.x) * .09 + current.y += (target.y - current.y) * .09 + apply() + const resting = Math.abs(target.x - current.x) + Math.abs(target.y - current.y) < .002 + frame = resting ? 0 : requestAnimationFrame(settle) + } + const update = () => { + const moving = visible && !document.hidden && !media.matches + node.dataset.moving = String(moving) + if (!media.matches) return + cancelAnimationFrame(frame) + frame = 0 + target.x = target.y = current.x = current.y = 0 + apply() + } + // A scene beneath a modal layer, such as the menu or the blurred login + // overlay, cannot be seen moving; leave it still instead of repainting. + const beneathModal = () => !!node.closest('[inert]') || (!!document.querySelector('dialog:modal') && !node.closest('dialog:modal')) + const pointer = (event: PointerEvent) => { + const tracksPointer = node.dataset.moving === 'true' && finePointer.matches && event.pointerType !== 'touch' && !beneathModal() + if (!tracksPointer) return + target.x = event.clientX / innerWidth * 2 - 1 + target.y = event.clientY / innerHeight * 2 - 1 + if (!frame) frame = requestAnimationFrame(settle) + } const observer = new IntersectionObserver(([entry]) => { visible = entry.isIntersecting; update() }) observer.observe(node) document.addEventListener('visibilitychange', update) media.addEventListener('change', update) + if (followsPointer) window.addEventListener('pointermove', pointer, { passive: true }) return () => { observer.disconnect() + cancelAnimationFrame(frame) document.removeEventListener('visibilitychange', update) media.removeEventListener('change', update) + window.removeEventListener('pointermove', pointer) node.dataset.moving = 'false' } - }, [design]) + }, [design, variant]) return