A single-page, static site under website/ - no build step, no framework, just HTML/CSS/vanilla JS plus the two Google Fonts the app already uses. Colors, radii, type scale and motion timing are copied from lib/theme/design_tokens.dart so it reads as the same product as the app, not a separate marketing skin. Sections: hero with an exploded, parallaxing stack of the app's own UI pieces (file row, photo grid, accent swatches, a toggle, the floating bar) that drift apart on scroll and tilt toward the pointer; feature highlights; a Customize section with two live interactive demos (accent color, floating vs attached bottom bar); a privacy section covering Login Flow v2; and a closing CTA. GitHub/download links are placeholders (documented in website/README.md) until real release URLs exist. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
142 lines
4.6 KiB
JavaScript
142 lines
4.6 KiB
JavaScript
(() => {
|
|
"use strict";
|
|
|
|
/* ---------- mobile nav ---------- */
|
|
|
|
const navToggle = document.getElementById("navToggle");
|
|
const mobileNav = document.getElementById("mobileNav");
|
|
if (navToggle && mobileNav) {
|
|
navToggle.addEventListener("click", () => {
|
|
const open = mobileNav.classList.toggle("is-open");
|
|
navToggle.setAttribute("aria-expanded", String(open));
|
|
navToggle.setAttribute("aria-label", open ? "Close menu" : "Open menu");
|
|
});
|
|
mobileNav.querySelectorAll("a").forEach((a) =>
|
|
a.addEventListener("click", () => {
|
|
mobileNav.classList.remove("is-open");
|
|
navToggle.setAttribute("aria-expanded", "false");
|
|
})
|
|
);
|
|
}
|
|
|
|
/* ---------- exploded hero parallax ---------- */
|
|
/* Each [data-depth] layer drifts at its own rate on scroll, and tilts
|
|
gently toward the pointer - the "exploded" pieces read as separate
|
|
depths rather than a flat collage. Skipped entirely under
|
|
prefers-reduced-motion. */
|
|
|
|
const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
|
const heroVisual = document.getElementById("heroVisual");
|
|
|
|
if (heroVisual && !reduceMotion) {
|
|
const layers = Array.from(heroVisual.querySelectorAll("[data-depth]")).map((el) => ({
|
|
el,
|
|
depth: parseFloat(el.dataset.depth) || 0.3,
|
|
}));
|
|
|
|
let scrollOffset = 0;
|
|
let pointer = { x: 0, y: 0 }; // -1..1
|
|
let ticking = false;
|
|
|
|
const apply = () => {
|
|
const rect = heroVisual.getBoundingClientRect();
|
|
const inView = rect.bottom > 0 && rect.top < window.innerHeight;
|
|
if (inView) {
|
|
for (const layer of layers) {
|
|
const scrollShift = scrollOffset * layer.depth * 40;
|
|
const px = pointer.x * layer.depth * 14;
|
|
const py = pointer.y * layer.depth * 14;
|
|
layer.el.style.transform =
|
|
`translate3d(${px}px, ${scrollShift + py}px, 0)`;
|
|
}
|
|
}
|
|
ticking = false;
|
|
};
|
|
|
|
const requestTick = () => {
|
|
if (!ticking) {
|
|
ticking = true;
|
|
requestAnimationFrame(apply);
|
|
}
|
|
};
|
|
|
|
const onScroll = () => {
|
|
const rect = heroVisual.getBoundingClientRect();
|
|
// -1 (scrolled well past) .. 1 (below viewport) - 0 while centered.
|
|
scrollOffset = (window.innerHeight / 2 - (rect.top + rect.height / 2)) / window.innerHeight;
|
|
requestTick();
|
|
};
|
|
|
|
const onPointerMove = (e) => {
|
|
const rect = heroVisual.getBoundingClientRect();
|
|
pointer.x = ((e.clientX - rect.left) / rect.width) * 2 - 1;
|
|
pointer.y = ((e.clientY - rect.top) / rect.height) * 2 - 1;
|
|
requestTick();
|
|
};
|
|
|
|
const onPointerLeave = () => {
|
|
pointer = { x: 0, y: 0 };
|
|
requestTick();
|
|
};
|
|
|
|
window.addEventListener("scroll", onScroll, { passive: true });
|
|
heroVisual.addEventListener("pointermove", onPointerMove);
|
|
heroVisual.addEventListener("pointerleave", onPointerLeave);
|
|
onScroll();
|
|
}
|
|
|
|
/* ---------- accent color demo ---------- */
|
|
|
|
const accentDemo = document.getElementById("accentDemo");
|
|
if (accentDemo) {
|
|
const buttons = accentDemo.querySelectorAll(".swatch-btn");
|
|
buttons.forEach((btn) => {
|
|
btn.addEventListener("click", () => {
|
|
buttons.forEach((b) => b.classList.remove("is-active"));
|
|
btn.classList.add("is-active");
|
|
const color = btn.dataset.accent;
|
|
accentDemo.style.setProperty("--demo-accent", color);
|
|
});
|
|
});
|
|
}
|
|
|
|
/* ---------- bottom bar style demo ---------- */
|
|
|
|
const barDemo = document.getElementById("barDemo");
|
|
const demoBar = document.getElementById("demoBar");
|
|
if (barDemo && demoBar) {
|
|
const buttons = barDemo.querySelectorAll(".segmented-btn");
|
|
buttons.forEach((btn) => {
|
|
btn.addEventListener("click", () => {
|
|
buttons.forEach((b) => b.classList.remove("is-active"));
|
|
btn.classList.add("is-active");
|
|
demoBar.classList.toggle("is-attached", btn.dataset.bar === "attached");
|
|
});
|
|
});
|
|
}
|
|
|
|
/* ---------- scroll reveal ---------- */
|
|
|
|
const revealTargets = document.querySelectorAll(
|
|
".card, .customize-demo, .customize-list, .privacy-points li"
|
|
);
|
|
revealTargets.forEach((el) => el.classList.add("reveal"));
|
|
|
|
if ("IntersectionObserver" in window && !reduceMotion) {
|
|
const io = new IntersectionObserver(
|
|
(entries) => {
|
|
for (const entry of entries) {
|
|
if (entry.isIntersecting) {
|
|
entry.target.classList.add("is-visible");
|
|
io.unobserve(entry.target);
|
|
}
|
|
}
|
|
},
|
|
{ threshold: 0.15, rootMargin: "0px 0px -40px 0px" }
|
|
);
|
|
revealTargets.forEach((el) => io.observe(el));
|
|
} else {
|
|
revealTargets.forEach((el) => el.classList.add("is-visible"));
|
|
}
|
|
})();
|