docs(pages): redesign homepage motion and target voice-keyboard SEO keywords

- Move all canonical, hreflang, Open Graph, sitemap, robots, and llms.txt
  URLs to the custom domain https://osg.osglab.com/
- Put category keywords (AI 语音输入法, 嘴替输入法, Agentic keyboard) in
  the title, description, H1, section eyebrows, FAQ, and JSON-LD
- Add a 1200x630 social card and large-image Twitter card
- Add a scroll-driven three-act story (dictation, reply in your voice,
  agentic action), a live voice capsule, split-text headings, number
  counters, parallax media reveals, pointer spotlight and tilt, and a
  circular theme transition
- Keep the page fully readable without JavaScript and under reduced motion
- Point legacy redirect pages at sections that exist
This commit is contained in:
Rocky
2026-10-01 08:51:20 +08:00
parent ec67ae2777
commit b61b348cac
13 changed files with 3031 additions and 270 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 306 KiB

+1896 -82
View File
File diff suppressed because it is too large Load Diff
+650 -27
View File
@@ -1,10 +1,14 @@
/*!
* OSGKeyboard 主页交互。
* OSGKeyboard 主页交互与动效引擎。
* 无 JavaScript 时页面内容完整可读;本文件负责:
* 1. 中 / 英双语切换(data-zh / data-en 系列属性,含视频源)
* 2. 深浅色主题(默认跟随系统,手动选择后持久化)
* 3. 移动端导航开合
* 4. 导航栏滚动状态、滚动显现动效、离屏视频暂停
* 2. 深浅色主题(默认跟随系统,手动选择后持久化;支持时以圆形扩散过渡)
* 3. 移动端导航开合、导航滑块与当前区块高亮
* 4. 逐字拆分标题(data-split)
* 5. 滚动引擎:进度条、Hero 设备「立起」、媒体视差、三幕滚动叙事
* 6. 声波渲染、Hero 语音胶囊打字机、数字计数
* 7. 指针光斑 / 3D 倾斜 / 磁吸按钮、FAQ 高度过渡、离屏视频暂停
* 开启「减弱动态效果」时,持续动画与指针动效全部停用,内容直接呈现最终态。
*/
(function () {
"use strict";
@@ -21,6 +25,78 @@
const themeMedia = window.matchMedia("(prefers-color-scheme: dark)");
const supportedLanguages = new Set(["zh", "en"]);
const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const finePointer = window.matchMedia("(hover: hover) and (pointer: fine)").matches;
const canObserve = "IntersectionObserver" in window;
const TITLES = {
zh: "OSGKeyboard|AI 语音输入法 · 嘴替输入法 · iPhone / iPad / Mac",
en: "OSGKeyboard — Agentic Keyboard & AI Voice Keyboard for iPhone and Mac"
};
const clamp = (value, min = 0, max = 1) => Math.min(max, Math.max(min, value));
// 语言切换后需要重算的模块(胶囊文案、叙事文案、导航滑块)在此登记
const languageHooks = [];
/* ---------------- 逐字拆分 ---------------- */
// 每个分组是一段不可断开的文字:英文按词,中文按「到下一个标点为止」的短句,
// 与原标题的 word-break: keep-all 断行效果一致,标点也不会落到行首
const CJK_PUNCTUATION = /[,。、!?:;)」』》…~]/;
function groupCharacters(token) {
const groups = [];
let current = [];
for (const character of token) {
current.push(character);
if (CJK_PUNCTUATION.test(character)) {
groups.push(current);
current = [];
}
}
if (current.length) groups.push(current);
return groups;
}
function splitText(element) {
const text = element.textContent.trim();
// 读屏读取完整句子,逐字拆分的视觉层对辅助技术隐藏
const readable = document.createElement("span");
readable.className = "sr-only";
readable.textContent = text;
const visual = document.createElement("span");
visual.setAttribute("aria-hidden", "true");
let index = 0;
text.split(/(\s+)/).forEach((token) => {
if (!token) return;
if (/^\s+$/.test(token)) {
visual.append(" ");
return;
}
groupCharacters(token).forEach((group) => {
const word = document.createElement("span");
word.className = "w";
group.forEach((character) => {
const span = document.createElement("span");
span.className = "ch";
span.style.setProperty("--ci", String(index++));
span.textContent = character;
word.append(span);
});
visual.append(word);
});
});
element.replaceChildren(readable, visual);
element.classList.add("is-split");
}
function splitAll() {
document.querySelectorAll("[data-split]").forEach(splitText);
}
/* ---------------- 语言 ---------------- */
function preferredLanguage() {
@@ -50,7 +126,8 @@
// 同时覆盖 <img> 与 <video>:切换语言后视频自动重新加载播放
document.querySelectorAll("[data-zh-src][data-en-src]").forEach((element) => {
element.src = element.dataset[`${lang}Src`] || "";
const next = element.dataset[`${lang}Src`] || "";
if (element.getAttribute("src") !== next) element.src = next;
});
document.querySelectorAll("[data-zh-href][data-en-href]").forEach((element) => {
@@ -71,9 +148,7 @@
image.src = `assets/screenshots/${lang}/${theme}/${image.dataset.shot}`;
});
document.title = lang === "zh"
? "OSGKeyboard — 开口即文字"
: "OSGKeyboard — Say it. It’s typed.";
document.title = TITLES[lang];
if (languageButton) {
languageButton.textContent = lang === "zh" ? "EN" : "中文";
@@ -93,6 +168,10 @@
);
}
// 文字已被 data-zh / data-en 重写,需要重新拆分才能继续逐字动效
splitAll();
languageHooks.forEach((hook) => hook(lang));
if (persist) {
localStorage.setItem("osg-site-language", lang);
const url = new URL(window.location.href);
@@ -141,6 +220,35 @@
if (persist) localStorage.setItem("osg-site-theme", resolved);
}
// 以主题按钮为圆心,新主题从一个点扩散覆盖整页(View Transitions API)
function toggleThemeWithTransition() {
const next = root.dataset.theme === "dark" ? "light" : "dark";
if (!document.startViewTransition || reduceMotion || !themeButton) {
applyTheme(next, true);
return;
}
const rect = themeButton.getBoundingClientRect();
const x = rect.left + rect.width / 2;
const y = rect.top + rect.height / 2;
const radius = Math.hypot(Math.max(x, innerWidth - x), Math.max(y, innerHeight - y));
root.classList.add("vt-theme");
const transition = document.startViewTransition(() => applyTheme(next, true));
transition.ready
.then(() => {
root.animate(
{ clipPath: [`circle(0px at ${x}px ${y}px)`, `circle(${radius}px at ${x}px ${y}px)`] },
{
duration: 760,
easing: "cubic-bezier(0.65, 0, 0.35, 1)",
pseudoElement: "::view-transition-new(root)"
}
);
})
.catch(() => {});
transition.finished.finally(() => root.classList.remove("vt-theme"));
}
/* ---------------- 移动端导航 ---------------- */
function setMenuOpen(isOpen) {
@@ -158,6 +266,378 @@
);
}
/* ---------------- 导航滑块与当前区块 ---------------- */
const navHost = document.getElementById("navLinks");
const navLinks = navHost ? Array.from(navHost.querySelectorAll("a")) : [];
let activeNavLink = null;
function moveIndicator(link) {
if (!navHost) return;
if (!link) {
navHost.classList.remove("has-indicator");
return;
}
navHost.style.setProperty("--ix", `${link.offsetLeft}px`);
navHost.style.setProperty("--iw", `${link.offsetWidth}px`);
navHost.classList.add("has-indicator");
}
function setActiveNav(link) {
activeNavLink = link;
navLinks.forEach((item) => item.classList.toggle("is-active", item === link));
moveIndicator(link);
}
navLinks.forEach((link) => {
link.addEventListener("mouseenter", () => moveIndicator(link));
link.addEventListener("focus", () => moveIndicator(link));
});
navHost?.addEventListener("mouseleave", () => moveIndicator(activeNavLink));
languageHooks.push(() => requestAnimationFrame(() => moveIndicator(activeNavLink)));
if (canObserve && navLinks.length) {
const sectionToLink = new Map();
navLinks.forEach((link) => {
const section = document.querySelector(link.getAttribute("href"));
if (section) sectionToLink.set(section, link);
});
const visibleSections = new Set();
const spy = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) visibleSections.add(entry.target);
else visibleSections.delete(entry.target);
});
// 取文档顺序中最后一个处于判定带内的区块
let current = null;
sectionToLink.forEach((link, section) => {
if (visibleSections.has(section)) current = link;
});
if (current !== activeNavLink) setActiveNav(current);
},
{ rootMargin: "-45% 0px -50% 0px" }
);
sectionToLink.forEach((_, section) => spy.observe(section));
}
/* ---------------- 声波渲染 ---------------- */
// 每个声波实例:bars 为条形元素,level 为目标音量,amp 为插值后的当前音量
const waves = [];
function createWave(container, options = {}) {
const count = Number(container.dataset.wave) || 16;
const bars = [];
for (let i = 0; i < count; i += 1) {
const bar = document.createElement("i");
container.append(bar);
bars.push(bar);
}
const wave = { bars, level: 0, amp: 0, active: true, mirror: Boolean(options.mirror), seed: Math.random() * 10 };
waves.push(wave);
return wave;
}
let waveFrame = 0;
function renderWaves(now) {
waveFrame = 0;
let running = false;
waves.forEach((wave) => {
if (!wave.active) return;
running = true;
wave.amp += (wave.level - wave.amp) * 0.12;
const length = wave.bars.length;
wave.bars.forEach((bar, index) => {
const i = wave.mirror ? length - 1 - index : index;
// 两路正弦叠加出「语音包络」,中间条更高,两端收敛
const envelope = Math.sin(((i + 0.5) / length) * Math.PI) * 0.6 + 0.4;
const n1 = Math.sin(now * 0.009 + i * 0.85 + wave.seed) * 0.5 + 0.5;
const n2 = Math.sin(now * 0.0047 + i * 1.9 + wave.seed * 2) * 0.5 + 0.5;
const value = 0.1 + wave.amp * envelope * (0.25 + n1 * n2 * 0.95);
bar.style.transform = `scaleY(${Math.min(1, value).toFixed(3)})`;
});
});
if (running) waveFrame = requestAnimationFrame(renderWaves);
}
function wakeWaves() {
if (!reduceMotion && !waveFrame) waveFrame = requestAnimationFrame(renderWaves);
}
/* ---------------- Hero 语音胶囊 ---------------- */
const CAPSULE_SCRIPT = {
zh: [
{ tag: "听写", text: "帮我订周五晚上七点的位子,四个人" },
{ tag: "嘴替 · 轻松", text: "这周有点累,先不去啦,下次我请!" },
{ tag: "替你做", text: "已建日程:周五 19:00 · 四人晚餐", agent: true }
],
en: [
{ tag: "Dictate", text: "Book a table for four, Friday at seven" },
{ tag: "Your voice", text: "Bit wiped this week — rain check? Next one’s on me!" },
{ tag: "Done for you", text: "Event added: Fri 7:00 PM · Dinner for four", agent: true }
]
};
const hero = document.getElementById("top");
const capsule = document.querySelector(".voice-capsule");
const capsuleTag = capsule?.querySelector(".capsule-tag");
const capsuleLine = capsule?.querySelector(".capsule-line");
const capsuleWaveHost = capsule?.querySelector(".capsule-wave");
const capsuleWave = capsuleWaveHost ? createWave(capsuleWaveHost) : null;
let heroVisible = true;
let capsuleRun = 0;
const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
// Hero 离屏或标签页隐藏时暂停打字,回来后从原处继续
async function whenActive(run) {
while (run === capsuleRun && (!heroVisible || document.hidden)) {
await wait(400);
}
return run === capsuleRun;
}
async function playCapsule() {
if (!capsule || !capsuleLine || !capsuleTag) return;
const run = ++capsuleRun;
const lang = root.dataset.lang === "en" ? "en" : "zh";
const script = CAPSULE_SCRIPT[lang];
if (reduceMotion) {
capsuleTag.textContent = script[0].tag;
capsuleLine.textContent = script[0].text;
return;
}
let index = 0;
while (run === capsuleRun) {
const item = script[index % script.length];
capsuleTag.textContent = item.tag;
capsuleTag.classList.toggle("is-agent", Boolean(item.agent));
capsuleLine.textContent = "";
await wait(260);
// 听写与嘴替模拟「边说边出字」,Agentic 结果直接以完成态弹出
const listening = !item.agent;
capsule.classList.toggle("is-listening", listening);
if (capsuleWave) capsuleWave.level = listening ? 1 : 0.15;
wakeWaves();
const characters = Array.from(item.text);
const base = lang === "zh" ? 70 : 34;
for (let i = 1; i <= characters.length; i += 1) {
if (!(await whenActive(run))) return;
capsuleLine.textContent = characters.slice(0, i).join("");
await wait(item.agent ? 14 : base + Math.random() * base * 0.8);
}
capsule.classList.remove("is-listening");
if (capsuleWave) capsuleWave.level = 0.12;
await wait(item.agent ? 2400 : 1900);
if (!(await whenActive(run))) return;
// 快速回删,像是把这一句交给下一个能力
for (let i = characters.length; i >= 0; i -= 2) {
if (run !== capsuleRun) return;
capsuleLine.textContent = characters.slice(0, i).join("");
await wait(12);
}
index += 1;
}
}
languageHooks.push(() => playCapsule());
/* ---------------- 三幕滚动叙事 ---------------- */
const STORY_TEXT = {
zh: {
draft: "可以啊 周五七点 位子我来订",
tones: [
"可以,周五七点,位子我来订。",
"好的,周五晚七点见,座位由我来预订。",
"好呀!周五七点不见不散,位子包在我身上~"
]
},
en: {
draft: "sure friday at seven ill book a table",
tones: [
"Sure — Friday at seven. I’ll book a table.",
"Sounds good. Friday at 7 p.m.; I’ll make the reservation.",
"Yes! Friday at 7 — leave the table to me."
]
}
};
// 进度阈值(0 → 1):s1 好友来信 · s2 开始聆听 · s3 嘴替改写 · s4 发送
// s5 好友回地址 · s6 复制 · s7 技能弹出 · s8 写入日历
const STORY_STEPS = [0.03, 0.08, 0.31, 0.585, 0.66, 0.735, 0.805, 0.885];
const TYPE_START = 0.08;
const TYPE_END = 0.29;
const TONE_STEPS = [0.36, 0.44, 0.515];
const ACT_RANGES = [[0, 0.31], [0.31, 0.66], [0.66, 1]];
const story = document.getElementById("storyStage");
const storyText = document.getElementById("storyText");
const storyActs = story ? Array.from(story.querySelectorAll(".story-act")) : [];
const storyWaves = story
? Array.from(story.querySelectorAll(".kb-wave")).map((host, i) => createWave(host, { mirror: i === 0 }))
: [];
let storyLastText = null;
let storyLastTone = -1;
let storyLastAct = -1;
function updateStory(force) {
if (!story) return;
const rect = story.getBoundingClientRect();
const viewport = innerHeight;
const inView = rect.bottom > 0 && rect.top < viewport;
storyWaves.forEach((wave) => { wave.active = inView; });
if (!inView && !force) return;
const total = Math.max(1, story.offsetHeight - viewport);
const p = clamp(-rect.top / total);
const lang = root.dataset.lang === "en" ? "en" : "zh";
const copy = STORY_TEXT[lang];
const reached = STORY_STEPS.map((step) => p >= step);
reached.forEach((on, i) => story.classList.toggle(`s${i + 1}`, on));
const [, listening, rewriting, sent] = reached;
// 语气:仅在「改写中」阶段可见,按进度依次切到 普通 → 正式 → 轻松
let tone = -1;
if (rewriting && !sent) {
TONE_STEPS.forEach((step, i) => { if (p >= step) tone = i; });
}
if (tone !== storyLastTone) {
[0, 1, 2].forEach((i) => story.classList.toggle(`tone-${i}`, i === tone));
}
// 输入框:聆听阶段按滚动进度逐字「听写」,改写阶段显示当前语气的版本
let text = "";
if (listening && !rewriting) {
const characters = Array.from(copy.draft);
const count = Math.round(clamp((p - TYPE_START) / (TYPE_END - TYPE_START)) * characters.length);
text = characters.slice(0, count).join("");
} else if (rewriting && !sent) {
text = tone >= 0 ? copy.tones[tone] : copy.draft;
}
if (storyText && text !== storyLastText) {
const isRewrite = tone !== storyLastTone && text && storyLastText;
storyText.textContent = text;
if (isRewrite && !reduceMotion) {
storyText.classList.remove("is-swapping");
void storyText.offsetWidth;
storyText.classList.add("is-swapping");
}
storyLastText = text;
}
storyLastTone = tone;
story.classList.toggle("has-text", text.length > 0);
// 左侧三幕:高亮当前幕,幕内进度条随滚动填充
let act = 0;
ACT_RANGES.forEach(([start], i) => { if (p >= start) act = i; });
if (act !== storyLastAct) {
storyActs.forEach((element, i) => element.classList.toggle("is-active", i === act));
storyLastAct = act;
}
storyActs.forEach((element, i) => {
const [start, end] = ACT_RANGES[i];
element.style.setProperty("--ap", clamp((p - start) / (end - start)).toFixed(3));
});
storyWaves.forEach((wave) => { wave.level = listening && !rewriting ? 1 : 0.06; });
if (inView) wakeWaves();
}
if (story) {
story.classList.add("is-live");
languageHooks.push(() => {
storyLastText = null;
storyLastTone = -2;
updateStory(true);
});
}
/* ---------------- 滚动引擎 ---------------- */
const progressBar = document.querySelector(".scroll-progress");
const parallaxItems = new Set();
let scrollTicking = false;
let lastHeroProgress = -1;
function updateNavState() {
siteNav?.classList.toggle("is-scrolled", window.scrollY > 24);
}
function updateScroll() {
scrollTicking = false;
const y = window.scrollY;
const viewport = innerHeight;
updateNavState();
if (progressBar) {
const max = Math.max(1, root.scrollHeight - viewport);
progressBar.style.transform = `scaleX(${clamp(y / max).toFixed(4)})`;
}
// Hero:滚动 0.75 屏内完成「文字淡出 + 设备立起」
if (hero && !reduceMotion) {
const heroProgress = clamp(y / (viewport * 0.75));
if (Math.abs(heroProgress - lastHeroProgress) > 0.001) {
hero.style.setProperty("--hp", heroProgress.toFixed(4));
lastHeroProgress = heroProgress;
}
}
// 视差:元素中心偏离视口中心越远,位移越大(方向相反,制造景深)
if (!reduceMotion) {
parallaxItems.forEach((element) => {
const rect = element.getBoundingClientRect();
const offset = rect.top + rect.height / 2 - viewport / 2;
const factor = Number(element.dataset.parallax) || 0.05;
element.style.transform = `translate3d(0, ${(-offset * factor).toFixed(1)}px, 0)`;
});
}
updateStory(false);
}
function requestScrollUpdate() {
if (!scrollTicking) {
scrollTicking = true;
requestAnimationFrame(updateScroll);
}
}
/* ---------------- 数字计数 ---------------- */
const numberFormat = new Intl.NumberFormat("en-US");
function formatCount(element, value) {
return `${element.dataset.prefix || ""}${numberFormat.format(value)}`;
}
function runCounter(element) {
const target = Number(element.dataset.count);
const from = element.dataset.from ? Number(element.dataset.from) : 0;
const duration = 1700;
const start = performance.now();
function frame(now) {
const t = clamp((now - start) / duration);
const eased = 1 - Math.pow(1 - t, 4);
element.textContent = formatCount(element, Math.round(from + (target - from) * eased));
if (t < 1) requestAnimationFrame(frame);
}
requestAnimationFrame(frame);
}
/* ---------------- 初始化 ---------------- */
const initialLanguage = preferredLanguage();
@@ -166,13 +646,20 @@
applyTheme(initialTheme, false);
languageButton?.addEventListener("click", () => {
applyLanguage(root.dataset.lang === "zh" ? "en" : "zh", true);
applyTheme(root.dataset.theme, false);
const next = root.dataset.lang === "zh" ? "en" : "zh";
const swap = () => {
applyLanguage(next, true);
applyTheme(root.dataset.theme, false);
};
// 支持时以交叉淡化切换语言,整页文字不会「跳」
if (document.startViewTransition && !reduceMotion) {
document.startViewTransition(swap);
} else {
swap();
}
});
themeButton?.addEventListener("click", () => {
applyTheme(root.dataset.theme === "dark" ? "light" : "dark", true);
});
themeButton?.addEventListener("click", toggleThemeWithTransition);
menuButton?.addEventListener("click", () => {
setMenuOpen(menuButton.getAttribute("aria-expanded") !== "true");
@@ -190,30 +677,41 @@
if (!storedTheme()) applyTheme(event.matches ? "dark" : "light", false);
});
/* ---------------- 导航栏滚动状态 ---------------- */
window.addEventListener("scroll", requestScrollUpdate, { passive: true });
window.addEventListener("resize", () => {
requestScrollUpdate();
moveIndicator(activeNavLink);
});
updateScroll();
function updateNavState() {
siteNav?.classList.toggle("is-scrolled", window.scrollY > 24);
}
updateNavState();
window.addEventListener("scroll", updateNavState, { passive: true });
/* ---------------- 滚动显现、计数与离屏视频暂停 ---------------- */
/* ---------------- 滚动显现与离屏视频暂停 ---------------- */
const counters = Array.from(document.querySelectorAll("[data-count]"));
const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (reduceMotion || !("IntersectionObserver" in window)) {
if (reduceMotion || !canObserve) {
document.querySelectorAll(".reveal").forEach((element) => {
element.classList.add("is-visible");
});
} else {
// 首屏以下的计数器先归位到起点,进入视口时再滚动到目标值
counters.forEach((element) => {
if (element.getBoundingClientRect().top > innerHeight) {
const from = element.dataset.from ? Number(element.dataset.from) : 0;
element.textContent = formatCount(element, from);
element.dataset.pending = "true";
}
});
const revealObserver = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
entry.target.classList.add("is-visible");
revealObserver.unobserve(entry.target);
}
if (!entry.isIntersecting) return;
entry.target.classList.add("is-visible");
entry.target.querySelectorAll("[data-pending]").forEach((counter) => {
delete counter.dataset.pending;
runCounter(counter);
});
revealObserver.unobserve(entry.target);
});
},
{ threshold: 0.12, rootMargin: "0px 0px -8% 0px" }
@@ -222,6 +720,31 @@
revealObserver.observe(element);
});
// 视差只计算视口附近的元素
const parallaxObserver = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) parallaxItems.add(entry.target);
else parallaxItems.delete(entry.target);
});
requestScrollUpdate();
},
{ rootMargin: "20% 0px 20% 0px" }
);
document.querySelectorAll("[data-parallax]").forEach((element) => {
parallaxObserver.observe(element);
});
if (hero) {
new IntersectionObserver((entries) => {
heroVisible = entries[0].isIntersecting;
if (capsuleWave) capsuleWave.active = heroVisible;
if (heroVisible) wakeWaves();
}).observe(hero);
}
}
if (canObserve) {
// 视频滚出视口时暂停,回来时恢复,省电且避免分散注意力
const videoObserver = new IntersectionObserver(
(entries) => {
@@ -240,4 +763,104 @@
videoObserver.observe(video);
});
}
document.addEventListener("visibilitychange", () => {
if (!document.hidden) wakeWaves();
});
/* ---------------- 指针动效:光斑 / 倾斜 / 磁吸 / Hero 光斑 ---------------- */
if (finePointer && !reduceMotion) {
document.querySelectorAll(".spot").forEach((element) => {
element.addEventListener("pointermove", (event) => {
const rect = element.getBoundingClientRect();
element.style.setProperty("--mx", `${event.clientX - rect.left}px`);
element.style.setProperty("--my", `${event.clientY - rect.top}px`);
});
});
document.querySelectorAll(".tilt").forEach((element) => {
element.addEventListener("pointermove", (event) => {
const rect = element.getBoundingClientRect();
const x = (event.clientX - rect.left) / rect.width;
const y = (event.clientY - rect.top) / rect.height;
element.style.setProperty("--rx", `${((0.5 - y) * 9).toFixed(2)}deg`);
element.style.setProperty("--ry", `${((x - 0.5) * 11).toFixed(2)}deg`);
});
element.addEventListener("pointerleave", () => {
element.style.setProperty("--rx", "0deg");
element.style.setProperty("--ry", "0deg");
});
});
// 磁吸按钮:指针靠近时按钮被「吸」向指针,离开时弹簧回位
document.querySelectorAll(".magnetic").forEach((element) => {
element.addEventListener("pointermove", (event) => {
const rect = element.getBoundingClientRect();
const dx = (event.clientX - rect.left - rect.width / 2) * 0.22;
const dy = (event.clientY - rect.top - rect.height / 2) * 0.32;
element.style.transform = `translate(${dx.toFixed(1)}px, ${dy.toFixed(1)}px)`;
});
element.addEventListener("pointerleave", () => {
element.style.transform = "";
});
});
// Hero 光斑以插值追随指针,产生柔和的拖尾
if (hero) {
let targetX = 50;
let targetY = 30;
let currentX = 50;
let currentY = 30;
let spotFrame = 0;
const step = () => {
currentX += (targetX - currentX) * 0.08;
currentY += (targetY - currentY) * 0.08;
hero.style.setProperty("--hx", `${currentX.toFixed(2)}%`);
hero.style.setProperty("--hy", `${currentY.toFixed(2)}%`);
const settled = Math.abs(targetX - currentX) < 0.05 && Math.abs(targetY - currentY) < 0.05;
spotFrame = settled ? 0 : requestAnimationFrame(step);
};
hero.addEventListener("pointermove", (event) => {
const rect = hero.getBoundingClientRect();
targetX = ((event.clientX - rect.left) / rect.width) * 100;
targetY = ((event.clientY - rect.top) / rect.height) * 100;
if (!spotFrame) spotFrame = requestAnimationFrame(step);
});
}
}
/* ---------------- FAQ 展开高度过渡 ---------------- */
document.querySelectorAll(".faq-item").forEach((details) => {
const summary = details.querySelector("summary");
if (!summary || reduceMotion || !details.animate) return;
let animation = null;
let closing = false;
summary.addEventListener("click", (event) => {
event.preventDefault();
const startHeight = details.offsetHeight;
const borders = details.offsetHeight - details.clientHeight;
if (animation) {
animation.cancel();
animation = null;
}
closing = details.open && !closing;
if (!closing) details.open = true;
const endHeight = closing ? summary.offsetHeight + borders : details.offsetHeight;
animation = details.animate(
{ height: [`${startHeight}px`, `${endHeight}px`] },
{ duration: 460, easing: "cubic-bezier(0.16, 1, 0.3, 1)" }
);
animation.onfinish = () => {
animation = null;
if (closing) details.open = false;
closing = false;
};
});
});
})();