Files
gridline/www/public/landing.js
T
adrianbonpin f807c22a36 docs(www): point the website, docs and badges back at GitHub (#35)
* docs(www): point the website, docs and badges back at GitHub

GitHub is the release destination again, so every user-facing reference to the
self-hosted Gitea instance now points at github.com/AdrianBonpin:

- www: nav + footer links, Download / Releases buttons, 'View on GitHub' and
  'Star on GitHub' CTAs, landing.js download fallback label, the repo
  preconnect/dns-prefetch hints, and the ROADMAP link (Gitea's
  src/branch/prod path becomes GitHub's blob/prod)
- www/src/lib/seo.test.ts: matching structured-data fixtures
- README: shields.io badge and the tap prose
- ROADMAP: Homebrew tap URL

Deliberately left pointing at Gitea (they are the fallback, not destinations):
.gitea/workflows/release.yml and scripts/release-mac.sh use git.ranio.xyz as
an API host, so rewriting those would break the fallback path.

* fix(www): update the stale FAQ count assertion

faq.test.ts asserted exactly 5 FAQ items but the list has had 6 since
bdf2bb5 (Release 0.7.15), so the www suite has been red since then. No CI
runs the www tests, which is why it went unnoticed. Found while verifying the
docs change above; unrelated to it.
2026-09-20 17:43:15 +08:00

213 lines
7.9 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.8.0";
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";
} 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";
}
});
// 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");
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], { opacity: 1, translateY: 0 });
} else {
const heroTl = anime.timeline({ easing: "easeOutExpo" });
anime.set([eyebrow, h1, subtitle, buttons, screenshot], { 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 (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",
});
}
}
});
});
})();