Hide entry-animated elements at first paint (html.anime-ready guard in global.css) so the landing page no longer flashes visible content before anime.js loads. Adds a <noscript> fallback and a JS load-error fallback to guarantee content is never permanently hidden, and reveals the hero immediately for prefers-reduced-motion users. Includes #macos-hint in the hero entrance timeline so the macOS command box animates in correctly.
230 lines
8.7 KiB
JavaScript
230 lines
8.7 KiB
JavaScript
(function () {
|
|
const reducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
|
|
|
// Theme handling
|
|
const html = document.documentElement;
|
|
const stored = localStorage.getItem("gridline-theme");
|
|
const systemDark = window.matchMedia("(prefers-color-scheme: dark)").matches;
|
|
|
|
function applyTheme(theme) {
|
|
if (theme === "light") {
|
|
html.classList.add("light");
|
|
html.removeAttribute("data-theme-auto");
|
|
} else if (theme === "dark") {
|
|
html.classList.remove("light");
|
|
html.removeAttribute("data-theme-auto");
|
|
} else {
|
|
// auto
|
|
html.classList.toggle("light", !systemDark && window.matchMedia("(prefers-color-scheme: light)").matches);
|
|
html.setAttribute("data-theme-auto", "");
|
|
}
|
|
updateToggleIcon();
|
|
}
|
|
|
|
function updateToggleIcon() {
|
|
const suns = document.querySelectorAll(".theme-icon-sun");
|
|
const moons = document.querySelectorAll(".theme-icon-moon");
|
|
const isLight = html.classList.contains("light");
|
|
suns.forEach((el) => el.classList.toggle("hidden", !isLight));
|
|
moons.forEach((el) => el.classList.toggle("hidden", isLight));
|
|
}
|
|
|
|
applyTheme(stored);
|
|
|
|
document.getElementById("theme-toggle")?.addEventListener("click", () => {
|
|
const isLight = html.classList.contains("light");
|
|
const next = isLight ? "dark" : "light";
|
|
localStorage.setItem("gridline-theme", next);
|
|
applyTheme(next);
|
|
});
|
|
|
|
window
|
|
.matchMedia("(prefers-color-scheme: dark)")
|
|
.addEventListener("change", () => {
|
|
if (html.hasAttribute("data-theme-auto")) {
|
|
applyTheme("auto");
|
|
}
|
|
});
|
|
|
|
// OS-aware download buttons (nav + hero). Any element with [data-download]
|
|
// gets the platform-specific download URL; labels are only rewritten when the
|
|
// element does NOT opt out via a data-download-label attribute (nav keeps its
|
|
// compact "Download" label, hero gets "Download for macOS" etc.).
|
|
document.querySelectorAll("a[data-download]").forEach((downloadBtn) => {
|
|
const platform = navigator.platform || "";
|
|
const userAgent = navigator.userAgent || "";
|
|
const isMac = /Mac/i.test(platform) && !/iPhone|iPad/i.test(userAgent);
|
|
const isWin = /Win/i.test(platform);
|
|
const isLinux = /Linux/i.test(platform) && !/Android/i.test(userAgent);
|
|
const isMobile = /Mobi|Android|iPhone|iPad|iPod/i.test(userAgent);
|
|
const keepLabel = downloadBtn.hasAttribute("data-download-label");
|
|
|
|
const releasesUrl = "https://github.com/AdrianBonpin/gridline/releases";
|
|
const version = "0.7.10";
|
|
|
|
if (isMobile) {
|
|
downloadBtn.href = releasesUrl;
|
|
if (!keepLabel) downloadBtn.textContent = "Get it on GitHub";
|
|
} else if (isMac) {
|
|
downloadBtn.href = `${releasesUrl}/download/v${version}/Gridline_${version}_aarch64.dmg`;
|
|
if (!keepLabel) downloadBtn.textContent = "Download for macOS";
|
|
if (downloadBtn.id === "download") {
|
|
document.getElementById("macos-hint")?.classList.remove("hidden");
|
|
}
|
|
} else if (isWin) {
|
|
downloadBtn.href = `${releasesUrl}/download/v${version}/Gridline_${version}_x64-setup.exe`;
|
|
if (!keepLabel) downloadBtn.textContent = "Download for Windows";
|
|
} else if (isLinux) {
|
|
downloadBtn.href = `${releasesUrl}/download/v${version}/Gridline-${version}-1.x86_64.rpm`;
|
|
if (!keepLabel) downloadBtn.textContent = "Download for Linux";
|
|
} else {
|
|
downloadBtn.href = releasesUrl;
|
|
if (!keepLabel) downloadBtn.textContent = "Download";
|
|
}
|
|
});
|
|
|
|
document.getElementById("copy-quarantine")?.addEventListener("click", async () => {
|
|
const command = "sudo xattr -dr com.apple.quarantine /Applications/Gridline.app";
|
|
try {
|
|
await navigator.clipboard.writeText(command);
|
|
const btn = document.getElementById("copy-quarantine");
|
|
if (!btn) return;
|
|
const original = btn.textContent;
|
|
btn.textContent = "Copied";
|
|
setTimeout(() => (btn.textContent = original), 1500);
|
|
} catch {}
|
|
});
|
|
|
|
// Nav scroll background
|
|
const nav = document.getElementById("nav");
|
|
function onScroll() {
|
|
if (!nav) return;
|
|
if (window.scrollY > 40) {
|
|
nav.classList.add("bg-[var(--surface)]", "backdrop-blur-md", "border-b", "border-[var(--border)]");
|
|
} else {
|
|
nav.classList.remove("bg-[var(--surface)]", "backdrop-blur-md", "border-b", "border-[var(--border)]");
|
|
}
|
|
}
|
|
window.addEventListener("scroll", onScroll, { passive: true });
|
|
onScroll();
|
|
|
|
// Anime.js animations
|
|
let anime;
|
|
try {
|
|
anime = require ? null : window.anime;
|
|
} catch {}
|
|
|
|
function loadAnime(callback) {
|
|
if (window.anime) {
|
|
callback(window.anime);
|
|
return;
|
|
}
|
|
const script = document.createElement("script");
|
|
script.src = "/vendor/anime.min.js";
|
|
script.onload = () => callback(window.anime);
|
|
script.onerror = () => revealAnimatedContent();
|
|
document.head.appendChild(script);
|
|
}
|
|
|
|
// Revert the entry-animation hiding (html.anime-ready CSS) so content is
|
|
// always visible even if the animation library never loads.
|
|
function revealAnimatedContent() {
|
|
document.documentElement.classList.remove("anime-ready");
|
|
}
|
|
|
|
loadAnime((anime) => {
|
|
// Hero entrance
|
|
const hero = document.getElementById("hero");
|
|
if (hero) {
|
|
const eyebrow = hero.querySelector(".hero-animate > .font-mono");
|
|
const h1 = hero.querySelector("h1");
|
|
const subtitle = hero.querySelector(".hero-animate > p:nth-of-type(2)");
|
|
const buttons = hero.querySelector(".hero-animate > div");
|
|
const screenshot = hero.querySelector(".hero-screenshot");
|
|
const macosHint = hero.querySelector("#macos-hint");
|
|
|
|
if (reducedMotion) {
|
|
// Reduced motion: reveal the hero immediately (CSS hides it at first
|
|
// paint to avoid flicker, but the timeline below is skipped). Section
|
|
// reveals below still run through the IntersectionObserver.
|
|
anime.set([eyebrow, h1, subtitle, buttons, screenshot, macosHint], { opacity: 1, translateY: 0 });
|
|
} else {
|
|
const heroTl = anime.timeline({ easing: "easeOutExpo" });
|
|
|
|
anime.set([eyebrow, h1, subtitle, buttons, screenshot, macosHint], { opacity: 0, translateY: 20 });
|
|
anime.set(screenshot, { opacity: 0, scale: 0.96, translateY: 0 });
|
|
|
|
if (eyebrow) heroTl.add({ targets: eyebrow, opacity: [0, 1], translateY: [15, 0] }, 0);
|
|
if (h1) heroTl.add({ targets: h1, opacity: [0, 1], translateY: [30, 0], duration: 800 }, 100);
|
|
if (subtitle) heroTl.add({ targets: subtitle, opacity: [0, 1], translateY: [20, 0] }, 250);
|
|
if (buttons) heroTl.add({ targets: buttons, opacity: [0, 1], translateY: [20, 0] }, 350);
|
|
if (macosHint) heroTl.add({ targets: macosHint, opacity: [0, 1], translateY: [20, 0] }, 400);
|
|
if (screenshot) heroTl.add({ targets: screenshot, opacity: [0, 1], scale: [0.96, 1], duration: 900 }, 450);
|
|
}
|
|
}
|
|
|
|
// Section scroll reveals
|
|
const sections = document.querySelectorAll(".section-animate");
|
|
const observer = new IntersectionObserver(
|
|
(entries) => {
|
|
entries.forEach((entry) => {
|
|
if (!entry.isIntersecting) return;
|
|
const children = entry.target.children;
|
|
if (reducedMotion) {
|
|
anime({ targets: children, opacity: [0, 1], duration: 400, easing: "linear" });
|
|
} else {
|
|
anime({
|
|
targets: children,
|
|
opacity: [0, 1],
|
|
translateY: [40, 0],
|
|
duration: 700,
|
|
delay: anime.stagger(100),
|
|
easing: "easeOutCubic",
|
|
});
|
|
}
|
|
observer.unobserve(entry.target);
|
|
});
|
|
},
|
|
{ threshold: 0.15 }
|
|
);
|
|
sections.forEach((section) => observer.observe(section));
|
|
});
|
|
|
|
// FAQ smooth height animation via native details toggle + anime fallback
|
|
document.querySelectorAll(".faq-item").forEach((item) => {
|
|
const summary = item.querySelector(".faq-summary");
|
|
const body = item.querySelector(".faq-body");
|
|
if (!summary || !body) return;
|
|
|
|
summary.addEventListener("click", (e) => {
|
|
e.preventDefault();
|
|
const isOpen = item.hasAttribute("open");
|
|
if (isOpen) {
|
|
if (window.anime && !reducedMotion) {
|
|
window.anime({
|
|
targets: body,
|
|
height: [body.scrollHeight, 0],
|
|
opacity: [1, 0],
|
|
duration: 250,
|
|
easing: "easeInQuad",
|
|
complete: () => item.removeAttribute("open"),
|
|
});
|
|
} else {
|
|
item.removeAttribute("open");
|
|
}
|
|
} else {
|
|
item.setAttribute("open", "");
|
|
if (window.anime && !reducedMotion) {
|
|
window.anime({
|
|
targets: body,
|
|
height: [0, body.scrollHeight],
|
|
opacity: [0, 1],
|
|
duration: 300,
|
|
easing: "easeOutQuad",
|
|
});
|
|
}
|
|
}
|
|
});
|
|
});
|
|
})();
|