Files
gridline/www/public/landing.js
T
adrianbonpin 501fed962c feat(www): landing page (#25)
* design: landing page spec + app icon asset

* chore(www): add anime.js, fonts, and landing page screenshot assets

* feat(www): add design tokens, fonts, shared layout

* feat(www): add landing page sections and styling

* feat(www): add theme toggle and anime.js viewport animations

* feat(www): feedback updates — no hero card, os-aware download, footer email, lowercase colored heading

* feat(www): remove screenshot borders, move macos hint below ctas, mobile github fallback

* feat(www): keep hero CTAs side by side

* feat(www): set initial nav border to transparent

* feat(www): add Rybbit prod-only, sitemap, SEO head, OG image, robots.txt, 404 page

* docs(AGENTS): include www landing page version in release sync checklist

* chore(www): add railpack.json for Dokploy deploy (force node provider, TZ)

* chore(www): use bun for Railpack deploy (packageManager + standalone www/bun.lock)

* docs(AGENTS): note www/bun.lock sync for Railpack deploys

* chore(www): add vitest test tooling

* feat(www): add FAQ data source

* feat(www): add SEO JSON-LD builders

* feat(www): add Seo JSON-LD component

* feat(www): wire Seo component and social/meta tags into Layout

* feat(www): add homepage JSON-LD, keyword H1, data-driven FAQ

* fix(www): point Roadmap footer link to GitHub

* feat(www): add lastmod to sitemap

* perf(www): self-host anime.js

* refactor(www): move nav and footer into shared Layout

* feat(www): add changelog page

* fix(www): make shared component styles global in Layout

* perf(www): optimize screenshots with Astro Image (webp/avif + srcset)

* feat(www): add web app manifest

* feat(www): add helpful links to 404 page

* fix(www): shorten homepage H1 copy

* fix(www): OS-aware navbar download + footer layout

* feat(www): build full changelog from v0.7.0 to v0.7.10
2026-08-17 09:16:58 +08:00

216 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.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);
document.head.appendChild(script);
}
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 heroTl = anime.timeline({ easing: "easeOutExpo" });
if (!reducedMotion) {
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",
});
}
}
});
});
})();