diff --git a/.uicraft.md b/.uicraft.md index 07f0793..874b559 100644 --- a/.uicraft.md +++ b/.uicraft.md @@ -36,6 +36,6 @@ YororoIce 的个人门户与博客。访客阅读文章、动态、图片和归 - **档案**:编号导航、大号章节序号、目录式文章列表、双栏片刻、直角控件、朱红索引线与通信侧栏。正文保持清楚的标题与元信息层次。 - **青笺**:依据全片 7,752 帧、原生 1280×720 像素审计(`docs/rain-theme-pixel-audit.md`),采用纸灰 / 蓝青、距画面约 3.5% / 5.4% 的单像素外框、宽窄不一的竖向切片、八角阵与深浅双层方片。中日短章节竖排,其他语言使用横向文字条;长文始终横排。文章为窄日期栏与矩形切片封面,片刻为月历式分格;聊天、登录和控件采用直角纸条与平行细框。禁止退回缺乏视频依据的实色厚投影、弧形封面、圆润气泡或拱顶面板。主体和文字保持稳定,仅遮挡条一次性揭示,小方片 / 水纹低幅循环;手机文章先呈现文字。 -- **P3R**:参考《Persona 3 Reload》的水下钴蓝、亮青、粗窄斜体大字、白色选中切片与洋红边缘。八段 B 站实际视频观察见 `docs/p3r-theme.md`。首页与全屏导航采用倾斜文字菜单;文章使用日期栏与紧凑列表,片刻使用日期分栏,聊天使用深蓝对白与亮蓝错位边。只有引起 `pathname` 变化的站内导航才从实际落点扩张钴蓝圆面,盖满后导航并由同圆心揭开;键盘使用控件中心。同页锚点、查询筛选、弹窗、主题切换和其他按钮不得触发全屏圆面。登录以 `BV15V4y117GZ` 无字版开场为主参考:原创银白棱角石从顶部坠下,带动统一的上宽下尖水体快速放大;非对称长条泡沫、带孔泡沫团和偏心气泡沿同一水体分布。水下背景上青下蓝,碎光集中在顶部;泡沫上散并平滑过渡至完全不透明的等待画面,路由就绪后向上退水。禁止恢复平行水柱或镜像开门构图;多股水柱旧实现留于 `439b851`。浮层保留邮件式进入;旧双环、斜面和日期轴实现留于 `e2ef28d`。正文不倾斜,不引入角色立绘、游戏音视频、截图纹理或默认音效。瞬态效果不持久化;减少动态效果或 Canvas 不可用时保留原生导航及静态登录遮场。 +- **P3R**:参考《Persona 3 Reload》的水下钴蓝、亮青、粗窄斜体大字、白色选中切片与洋红边缘。八段 B 站实际视频观察见 `docs/p3r-theme.md`。首页与全屏导航采用倾斜文字菜单;文章使用日期栏与紧凑列表,片刻使用日期分栏,聊天使用深蓝对白与亮蓝错位边。只有引起 `pathname` 变化的站内导航才从实际落点扩张边缘起伏的钴蓝流体,盖满后才执行 `onNavigate` 与路由跳转,目标内容就绪后从同一落点以流体开口揭开;键盘使用控件中心。全屏菜单从打开按钮流体展开、沿原轮廓收回,背景保持透明;首页与页面标题分层入场,场景随精确指针做多层视差;共享弹窗与登录面板使用邮件式斜面进入后对角分开。同页锚点、查询筛选、弹窗、主题切换和其他按钮不得触发全屏圆面。登录以 `BV15V4y117GZ` 无字版开场为主参考:原创银白棱角石从顶部坠下,带动统一的上宽下尖水体快速放大;非对称长条泡沫、带孔泡沫团和偏心气泡沿同一水体分布。水下背景上青下蓝,碎光集中在顶部;泡沫上散并平滑过渡至完全不透明的等待画面,路由就绪后向上退水。禁止恢复平行水柱或镜像开门构图;多股水柱旧实现留于 `439b851`。浮层保留邮件式进入;旧双环、斜面和日期轴实现留于 `e2ef28d`,旧圆面、菜单中心环与平移弹窗留于 `65630bf`。正文不倾斜,不引入角色立绘、游戏音视频、截图纹理或默认音效。瞬态效果不持久化;减少动态效果或 Canvas 不可用时保留原生导航及静态登录遮场,菜单立即开关,入场、视差与斜面全部静止。 - 档案与公共布局在 `src/components/blog/_theme-ui.scss`;青笺全站布局在 `_rain-ui.scss`,P3R 在 `_p3r-ui.scss`,共同编译进原 CSS Module。共享场景 `src/components/appearance/rain-planes.*` 提供门帘、月历与双层方片变体,`p3r-scene.*` 提供水面、月亮和窗格。以 `html[data-design]` 选择,避免切换时重新挂载内容或丢失草稿;共享基线保留于 `blog.module.scss`。 - 320px 窄屏隐藏品牌辅助小字,优先保留主题、语言和导航的完整操作区域。 diff --git a/docs/p3r-theme.md b/docs/p3r-theme.md index bc203af..8833f46 100644 --- a/docs/p3r-theme.md +++ b/docs/p3r-theme.md @@ -201,3 +201,79 @@ - 浏览器为本机 Chromium;移动端为视口模拟,不代表实体设备帧率。 - 登录动态 chunk 加载期间改为透明交互遮罩,`P3R · 正在接入` 仅保留为屏幕阅读器状态。 人为延迟 chunk 1.2 秒后截图确认画面不再闪现该文案。 + +## 第三轮:动效打磨 + +分支:`feat/p3r-animation-polish`,基于 `main`(`65630bf`)。旧实现保留在该提交中。 +本轮不改登录水效(`p3r-water-renderer.ts`),仍不引入角色、视频纹理或音效。 + +### 基线问题(开发环境 1440px 逐帧采样) + +- 跨页圆面:点击后约 25ms 就调用了 `router.push`,`(blog)/loading.tsx` + 的加载骨架在仍在扩张的圆面旁边提交,形成可见闪烁;全程约 800ms。 +- 全屏菜单:`::after` 的环(`inset: -50%` 加缩放)在屏幕中央显示为奇怪的青色椭圆; + 背景是不透明深蓝,关闭没有动画(Esc 由外壳直接卸载)。 +- 首页舞台、页面标题和弹窗只有整体淡入或简单位移。 + +### 参考与时序(`menu-circle-accurate/`,BV1vD421K72T 约 25.72–26.00 秒) + +- 边缘起伏的流体团约 80ms 盖满画面,随后在同一位置打开不规则的开口,约 200ms 露出新界面。 +- 手机邮件(BV11T421X7Cz 约 1.2–2.0 秒):倾斜蓝面滑入后沿对角线分开,露出内容。 +- 网页时序:铺色 220ms(ease-out)、至少 60ms 全遮、开孔 340ms(ease-in-out); + 菜单展开 420ms、收回 300ms;邮件面板 420ms。均为无回弹曲线。 + +### 第三轮实现 + +- `p3r-fluid.ts`:圆面与菜单共用的流体轮廓。三个缓慢漂移的谐波调制半径, + 幅度为半径的 8.5%;覆盖半径按最远屏幕角 ÷ (1 − 幅度) 计算,保证每个波峰都盖住角落。 + 每次使用新的随机相位,连续操作不会重复同一个轮廓。 +- `p3r-effects.tsx`:Canvas 绘制流体铺色(底层半透明钴蓝,加青白描边),开孔同样是流体轮廓。 + `onNavigate` 与 `router.push` 都推迟到完全盖住之后执行,点击时只预取目标路由。 + 若目标页仍是 `[data-route-loading]` 骨架或 `
` 为空,最多多遮 300ms,再开孔。 + Esc、页面隐藏、切换为减少动态效果时清除视觉层,但仍完成导航; + 调用方取消导航时直接开孔,不跳转。开孔前再选另一个链接会替换目标; + 开孔过程中点击,会从新落点开始下一次圆面。8 秒兜底计时从交接时开始。 +- 阶段同步到 `html[data-p3r-wipe]`。完全遮住时,新页面的入场动画暂停,开孔时才开始播放。 +- `p3r-navigation.tsx`:菜单不再使用 `::after` 环,改用 Web Animations 的 + `clip-path: path()`,从打开按钮的位置流体展开;`menu-rim` 是同一轮廓的 7px 青色细边。 + 背景透明,展开过程中仍能看到原页面。Esc、关闭按钮和同页链接会沿轮廓收回, + 收回期间对话框为 inert;展开到一半时关闭,会从当前位置收回,不会先跳成全屏。 + 跨页链接在圆面盖住后才关闭菜单,因此看不到菜单闪退。 + P3R 菜单不再使用外壳的全局 Esc 监听,以对话框的 cancel 事件为准; + Chrome 在多次 Esc 后强制关闭时,用 close 事件同步状态。 +- 入场分层:首页依次为日期/口号、标题两行、介绍斜切面板、继续阅读、菜单行; + 页面标题依次为背景大字、编号、眉标、标题、英文名、操作。 + 动画使用独立的 `translate` 属性和斜边 `clip-path`,不影响元素原本的旋转,也没有缩放; + 动画结束后不保留裁切,斜体和描边的外沿完整。 +- `p3r-scene.tsx`:精确指针(`pointer: fine`)下,月亮、窗格、水流、波纹和碎片 + 按 8–42px 的不同深度跟随指针做视差,用 rAF 缓动写入 CSS 变量。 + 只在场景可见且处于运动状态时更新;触屏和减少动态效果下不启用,标题横幅固定不动。 + 场景位于 inert 区域或其他模态对话框下方时(如菜单打开、登录弹层),不跟随指针,避免看不见的重绘。 +- `_p3r-motion.scss`:邮件式面板。倾斜的钴蓝斜面滑入,再沿对角线分成两半露出面板, + 用于共享 `Modal` 和登录页的账号管理、声明面板。斜面是 `::before` 伪元素, + 静止时完全分开且 `pointer-events: none`,不增加滚动溢出。 +- 减少动态效果:圆面、菜单展开/收回、入场、视差和邮件斜面全部关闭,导航和关闭立即完成。 + +### 第三轮验证 + +在独立内存后端上进行,包括开发环境与隔离生产构建(`next build --webpack`)。 +脚本和截图位于 `.git/ark-research/p3r-polish/`。 + +- 逐帧轨迹(生产构建):完全遮住前 pathname 一直不变;冷路由在遮场下提交, + 内容出现后才开孔;菜单的跨页链接在 `covered` 阶段才关闭。 +- `verify-polish.cjs` 16/16:遮场后导航;Esc、隐藏页面、替换目标、取消导航; + 1440 / 390 / 320px × 浅色 / 深色下菜单的展开、Esc 与关闭按钮收回、跨页关闭及无横向溢出; + 中途关闭从部分轮廓收回;减少动态效果;触屏不触发视差;邮件斜面无溢出且静止时透明; + 连续 10 次点击后状态清理干净。 +- 原有回归:圆面语义 5/5、登录流程 10/10、水效参考帧 3/3、首帧遮罩 3/3 通过; + `verify:blog` 19/19。 +- 旧脚本需要适配新构建:首帧脚本里写死的生产 chunk 编号已经变化,改用当前编号的副本运行; + 首页流式渲染时,隐藏的 Suspense 容器里会短暂多出一份菜单,因此圆面语义脚本的副本 + 会等流式内容合并后再点击。两份原脚本都没有改动。 +- 帧间隔:1440px 与 390px 下菜单展开与圆面播放平稳,只有展开第一帧(创建对话框) + 偶有约 33ms 的单帧;圆面绘制期间没有超过 25ms 的帧。登录页弹层的模糊背景本身在 + headless 下就有 p95 约 25ms 的帧;视差在弹层下停用后,移动指针时与静止、关闭视差时处于同一范围。 +- TypeScript(仅检查 src,`.next` 中残留的 bines 生成类型与本轮无关)、 + 修改文件的 ESLint、`git diff --check` 和生产构建通过。 + 构建时 `next/font` 拉取 Google 字体出现网络重试,最终成功,与代码无关。 +- 浏览器为本机 Chromium headless,移动端为视口模拟,未在实体手机或 Safari 上测试。 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