mirror of
https://github.com/warmbly/warmbly.git
synced 2026-10-03 16:02:02 +00:00
474 lines
24 KiB
Plaintext
474 lines
24 KiB
Plaintext
---
|
|
import Icon from './Icon.astro';
|
|
import AppShellMock from './AppShellMock.astro';
|
|
import GrainBackdrop from './GrainBackdrop.astro';
|
|
import type { GrainTone } from '../lib/grain';
|
|
|
|
/**
|
|
* One feature showcase section: copy on one side, the product on the other.
|
|
*
|
|
* The visual is a rounded grain-gradient frame with a whole browser window
|
|
* inside it, chrome and all, authored at 1280x944. The window is scaled UP
|
|
* past the frame and hangs from one of its own real corners (`anchor`), so
|
|
* the corner on screen is a real window corner and the two far edges run
|
|
* off the frame, clipped by the frame's rounded corners. It is never panned
|
|
* into: a visible chrome is a whole chrome, and a section that wants the
|
|
* bottom of a screen anchors the window's bottom corner.
|
|
*
|
|
* `zoom` is how many of the window's 1280 design px map across the frame:
|
|
* smaller is closer. The window always at least covers the frame, so a short
|
|
* window never leaves grain showing past a cut edge.
|
|
*
|
|
* `slides` turns the window into a story-style carousel: the default slot is
|
|
* slide one and `slide2`, `slide3`... are the rest, each its own whole
|
|
* window. Segmented bars at the bottom fill one at a time and the next screen
|
|
* slides in when a bar completes; hovering holds it, swiping or the arrows
|
|
* move it by hand.
|
|
*
|
|
* The frame takes its height from the copy beside it (the grid row
|
|
* stretches). Below lg the copy sits above and the frame keeps a desktop
|
|
* frame's proportions, with the very same window, chrome and crop inside it,
|
|
* just smaller: a phone sees what a desktop sees.
|
|
*/
|
|
interface Callout { label: string; text: string }
|
|
type Anchor = 'tl' | 'tr' | 'bl' | 'br';
|
|
interface Slide {
|
|
url: string;
|
|
/** Sidebar row + breadcrumb leaf; omit to reuse the section's. */
|
|
nav?: string;
|
|
crumb?: string;
|
|
/** false renders the slot under the chrome alone, no app shell. */
|
|
shell?: boolean;
|
|
}
|
|
interface Props {
|
|
eyebrow: string;
|
|
title: string;
|
|
description: string;
|
|
callouts?: Callout[];
|
|
cta?: { text: string; href: string };
|
|
side?: 'left' | 'right';
|
|
bg?: 'sky' | 'light' | 'navy' | 'wallpaper';
|
|
url: string;
|
|
cursor?: 'a' | 'b' | 'c' | 'd';
|
|
id?: string;
|
|
/** Sidebar row to highlight + breadcrumb leaf inside the app shell. */
|
|
nav: string;
|
|
/** Extra breadcrumb segment after the section, e.g. "Q1 sales". */
|
|
crumb?: string;
|
|
/** Which real corner of the window sits in the frame. */
|
|
anchor?: Anchor;
|
|
/** Window design px shown across the frame width at lg+. */
|
|
zoom?: number;
|
|
/** Extra slides after the default one, in order; content in slide2, slide3... */
|
|
slides?: Slide[];
|
|
/**
|
|
* The whole window, as large as the row allows: the visual takes 8 columns
|
|
* and bleeds to the viewport edge, and the frame's height follows the
|
|
* window's aspect instead of the copy beside it. For screens whose demo is
|
|
* a window-centred dialog or a full-height drawer.
|
|
*/
|
|
wide?: boolean;
|
|
}
|
|
const {
|
|
eyebrow, title, description, callouts = [], cta,
|
|
side = 'right', bg = 'sky', url, id, nav, crumb,
|
|
anchor = 'tl', zoom = 1000, slides = [], wide = false,
|
|
} = Astro.props;
|
|
|
|
const allSlides: Slide[] = [{ url, nav, crumb, shell: true }, ...slides];
|
|
// Named slots rendered up front, so the template can loop over them.
|
|
const slideHtml: string[] = [await Astro.slots.render('default')];
|
|
for (let i = 0; i < slides.length; i++) slideHtml.push(await Astro.slots.render(`slide${i + 2}`));
|
|
const isCarousel = allSlides.length > 1;
|
|
|
|
const lightText = bg !== 'light';
|
|
|
|
// The frame has to read AGAINST its band. Dark bands take the light tone, the
|
|
// bright sky band takes the deep one, and the whiteish band keeps the mid blue.
|
|
const grainToneByBg: Record<string, GrainTone> = {
|
|
sky: 'deep', light: 'mid', navy: 'light', wallpaper: 'light',
|
|
};
|
|
const grainTone = grainToneByBg[bg];
|
|
|
|
const bgStyle: Record<string, string> = {
|
|
sky: 'background: radial-gradient(ellipse 130% 120% at 50% -10%, #38bdf8 0%, #0ea5e9 34%, #0284c7 68%, #0369a1 100%);',
|
|
light: 'background: linear-gradient(180deg, #ffffff 0%, #f5f8fc 100%);',
|
|
navy: 'background: radial-gradient(ellipse 110% 90% at 50% -10%, #143a63 0%, #0e2545 38%, #0a1730 68%, #070d1c 100%);',
|
|
wallpaper: 'background: radial-gradient(78% 95% at 15% 6%, #38bdf8 0%, #1d4ed8 42%, rgba(29,78,216,0) 72%), linear-gradient(120deg, #1e40af 0%, #1d4ed8 48%, #172554 100%);',
|
|
};
|
|
|
|
// One radius for every frame on the page, and one for every window corner.
|
|
const RADIUS = 28;
|
|
const frameShadow =
|
|
bg === 'navy'
|
|
? '0 54px 110px -34px rgba(2,132,199,0.6), 0 10px 28px -14px rgba(0,0,0,0.5)'
|
|
: bg === 'light'
|
|
? '0 40px 80px -34px rgba(15,23,42,0.45)'
|
|
: '0 50px 100px -34px rgba(2,10,32,0.6)';
|
|
// Inline, so no stylesheet (ours, Tailwind's, or the shader library's own
|
|
// runtime layer) can take the corners back.
|
|
const shellStyle = `border-radius:${RADIUS}px; box-shadow:${frameShadow};`;
|
|
const frameStyle = `border-radius:${RADIUS}px; overflow:hidden; clip-path: inset(0 round ${RADIUS}px);`;
|
|
|
|
// Where the window's box sits in the frame: inset on the two sides that meet
|
|
// at the anchored corner (16px below lg, 28px at lg+), flush on the two it
|
|
// bleeds off.
|
|
const fitPos: Record<Anchor, string> = {
|
|
tl: 'left-4 top-4 right-0 bottom-0 lg:left-7 lg:top-7',
|
|
tr: 'right-4 top-4 left-0 bottom-0 lg:right-7 lg:top-7',
|
|
bl: 'left-4 bottom-4 right-0 top-0 lg:left-7 lg:bottom-7',
|
|
br: 'right-4 bottom-4 left-0 top-0 lg:right-7 lg:bottom-7',
|
|
};
|
|
// Only the real window corner is rounded; the edges that meet the frame stay
|
|
// straight, the way a cropped screenshot does.
|
|
const fitCorner: Record<Anchor, string> = {
|
|
tl: 'rounded-tl-[12px]',
|
|
tr: 'rounded-tr-[12px]',
|
|
bl: 'rounded-bl-[12px]',
|
|
br: 'rounded-br-[12px]',
|
|
};
|
|
const fitShadow: Record<Anchor, string> = {
|
|
tl: 'shadow-[-8px_-8px_28px_-10px_rgba(2,10,32,0.55)]',
|
|
tr: 'shadow-[8px_-8px_28px_-10px_rgba(2,10,32,0.55)]',
|
|
bl: 'shadow-[-8px_8px_28px_-10px_rgba(2,10,32,0.55)]',
|
|
br: 'shadow-[8px_8px_28px_-10px_rgba(2,10,32,0.55)]',
|
|
};
|
|
// The screen pins to the box corner the anchor names and scales away from it.
|
|
const screenPos: Record<Anchor, string> = {
|
|
tl: 'top-0 left-0 origin-top-left',
|
|
tr: 'top-0 right-0 origin-top-right',
|
|
bl: 'bottom-0 left-0 origin-bottom-left',
|
|
br: 'bottom-0 right-0 origin-bottom-right',
|
|
};
|
|
|
|
const eyebrowColor = lightText ? 'text-white/60' : 'text-sky-600';
|
|
const titleColor = lightText ? 'text-white' : 'text-slate-900';
|
|
const bodyColor = lightText ? 'text-white/75' : 'text-slate-600';
|
|
const calloutLabel = lightText ? 'text-white' : 'text-slate-900';
|
|
const calloutText = lightText ? 'text-white/65' : 'text-slate-500';
|
|
|
|
function splitUrl(u: string) {
|
|
const i = u.indexOf('/');
|
|
return i === -1 ? { domain: u, path: '' } : { domain: u.slice(0, i), path: u.slice(i) };
|
|
}
|
|
|
|
const copyOrder = side === 'left' ? 'lg:order-2' : 'lg:order-1';
|
|
const mockOrder = side === 'left' ? 'lg:order-1' : 'lg:order-2';
|
|
const copyCol = wide ? 'lg:col-span-4' : 'lg:col-span-5';
|
|
// Wide visuals run out through the container's 40px padding on their outer side.
|
|
const mockCol = wide ? `lg:col-span-8 ${side === 'left' ? 'lg:-ml-10' : 'lg:-mr-10'}` : 'lg:col-span-7';
|
|
---
|
|
<section id={id} class="relative isolate overflow-hidden" style={bgStyle[bg]}>
|
|
{bg === 'sky' && (
|
|
<>
|
|
<div class="absolute inset-0 pointer-events-none" style="opacity:0.5; background: radial-gradient(ellipse 120% 100% at 50% -10%, rgba(224,242,254,0.7) 0%, rgba(125,211,252,0.28) 30%, transparent 62%);"></div>
|
|
<img src="/backdrops/cloud-4.webp" alt="" aria-hidden="true" class="absolute -top-12 -left-16 w-[480px] opacity-[0.22] pointer-events-none select-none cloud-drift cloud-1" loading="lazy" decoding="async" style="mix-blend-mode: screen;" />
|
|
</>
|
|
)}
|
|
{bg === 'wallpaper' && (
|
|
<div class="absolute inset-0 pointer-events-none" style="background: radial-gradient(55% 55% at 16% 12%, rgba(255,255,255,0.22) 0%, transparent 60%), radial-gradient(50% 60% at 88% 92%, rgba(99,102,241,0.4) 0%, transparent 65%);"></div>
|
|
)}
|
|
{bg === 'navy' && (
|
|
<div class="absolute inset-0 pointer-events-none" style="background: radial-gradient(60% 50% at 78% 8%, rgba(56,189,248,0.16) 0%, transparent 60%);"></div>
|
|
)}
|
|
|
|
<div class="container-wide relative py-16 md:py-24">
|
|
<div class="grid lg:grid-cols-12 gap-10 lg:gap-14 lg:items-stretch">
|
|
<!-- COPY -->
|
|
<div class={`${copyCol} flex flex-col justify-center ${copyOrder}`}>
|
|
<div class={`text-[11px] uppercase tracking-[0.18em] font-mono mb-3 ${eyebrowColor}`}>{eyebrow}</div>
|
|
<h2 class={`text-[28px] md:text-[40px] font-semibold tracking-[-0.025em] leading-[1.07] ${titleColor}`}>{title}</h2>
|
|
<p class={`mt-4 text-[15.5px] leading-relaxed max-w-md ${bodyColor}`}>{description}</p>
|
|
|
|
{callouts.length > 0 && (
|
|
<div class="mt-6 space-y-2.5 max-w-md">
|
|
{callouts.map((c) => (
|
|
<div class="flex items-baseline gap-3 text-[13px] leading-relaxed">
|
|
<span class={`relative top-[-2px] h-1.5 w-1.5 rounded-full shrink-0 ${lightText ? 'bg-sky-300' : 'bg-sky-500'}`}></span>
|
|
<div>
|
|
<span class={`font-semibold ${calloutLabel}`}>{c.label}.</span>
|
|
<span class={`ml-1 ${calloutText}`}>{c.text}</span>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{cta && (
|
|
<a href={cta.href}
|
|
class={`mt-8 self-start inline-flex items-center gap-1.5 text-[13.5px] font-medium underline underline-offset-[5px] decoration-1 ${lightText ? 'text-white decoration-white/40 hover:decoration-white' : 'text-slate-900 decoration-slate-900/30 hover:decoration-slate-900'}`}>
|
|
{cta.text}
|
|
<Icon name="arrowUpRight" size={13} />
|
|
</a>
|
|
)}
|
|
</div>
|
|
|
|
<!-- PRODUCT VISUAL: grain frame, window hung from one corner -->
|
|
<div class={`${mockCol} ${mockOrder} min-w-0 flex`}>
|
|
<div class="feat-frame-shell w-full lg:min-h-[480px] flex" style={shellStyle} data-whole={wide ? '' : undefined}>
|
|
<!-- The clip is its own box with nothing else on it. -->
|
|
<div class="round-clip w-full flex">
|
|
<div class="feat-frame feature-window group relative isolate w-full" style={frameStyle} data-slides={allSlides.length}>
|
|
<GrainBackdrop tone={grainTone} />
|
|
|
|
<div class={`feat-fit absolute z-10 overflow-hidden ring-1 ring-white/25 ${fitPos[anchor]} ${fitCorner[anchor]} ${fitShadow[anchor]}`}>
|
|
<div class="feat-track absolute inset-0">
|
|
<!-- Explicit width keeps offset slides from collapsing and clips overflowing screens. -->
|
|
{allSlides.map((s, i) => {
|
|
const { domain, path } = splitUrl(s.url);
|
|
return (
|
|
<div class="feat-slide absolute top-0 bottom-0 w-full overflow-hidden" style={`left:${i * 100}%`}>
|
|
<div class={`feat-screen absolute w-[1280px] ${screenPos[anchor]}`} data-zoom={zoom} aria-hidden="true">
|
|
<!-- Browser chrome is part of the screenshot, so it
|
|
scales and crops with everything else. The address
|
|
is centred, Safari-style, so a right-anchored crop
|
|
still shows it whole. -->
|
|
<div class="flex items-center gap-2.5 px-3 h-11 border-b border-slate-300/70 bg-gradient-to-b from-[#f7f8fa] to-[#e9ebef]">
|
|
<div class="flex items-center gap-2 shrink-0">
|
|
<span class="w-3 h-3 rounded-full bg-[#ff5f57]"></span>
|
|
<span class="w-3 h-3 rounded-full bg-[#febc2e]"></span>
|
|
<span class="w-3 h-3 rounded-full bg-[#28c840]"></span>
|
|
</div>
|
|
<div class="flex items-center gap-1 shrink-0 text-slate-400">
|
|
<span class="grid place-items-center w-6 h-6 rounded-md">
|
|
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><polyline points="15 18 9 12 15 6"/></svg>
|
|
</span>
|
|
<span class="grid place-items-center w-6 h-6 rounded-md text-slate-300">
|
|
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 18 15 12 9 6"/></svg>
|
|
</span>
|
|
</div>
|
|
<div class="relative flex-1 min-w-0 flex items-center justify-center gap-2 h-7 px-3 rounded-lg bg-white ring-1 ring-slate-200/90 text-[12px] shadow-[inset_0_1px_2px_rgba(15,23,42,0.04)]">
|
|
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" class="text-emerald-500 shrink-0"><rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>
|
|
<span class="truncate font-medium text-slate-700">{domain}<span class="text-slate-400 font-normal">{path}</span></span>
|
|
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="absolute right-2.5 top-1/2 -translate-y-1/2 text-slate-300"><path d="M21 12a9 9 0 1 1-2.64-6.36"/><polyline points="21 3 21 9 15 9"/></svg>
|
|
</div>
|
|
<div class="flex items-center gap-2.5 shrink-0 text-slate-300">
|
|
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 5v14M5 12h14"/></svg>
|
|
</div>
|
|
</div>
|
|
|
|
{s.shell === false ? (
|
|
<Fragment set:html={slideHtml[i]} />
|
|
) : (
|
|
<AppShellMock crumb={s.nav ?? nav} sub={s.crumb}>
|
|
<Fragment set:html={slideHtml[i]} />
|
|
</AppShellMock>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
|
|
{isCarousel && (
|
|
<>
|
|
<button type="button" class="feat-prev absolute z-30 left-3 top-1/2 -translate-y-1/2 size-9 rounded-full bg-white/90 text-slate-800 shadow-lg backdrop-blur grid place-items-center transition-all duration-300 lg:opacity-0 lg:-translate-x-1 lg:group-hover:opacity-100 lg:group-hover:translate-x-0 cursor-pointer" aria-label="Previous screen">
|
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><polyline points="15 18 9 12 15 6"/></svg>
|
|
</button>
|
|
<button type="button" class="feat-next absolute z-30 right-3 top-1/2 -translate-y-1/2 size-9 rounded-full bg-white/90 text-slate-800 shadow-lg backdrop-blur grid place-items-center transition-all duration-300 lg:opacity-0 lg:translate-x-1 lg:group-hover:opacity-100 lg:group-hover:translate-x-0 cursor-pointer" aria-label="Next screen">
|
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 18 15 12 9 6"/></svg>
|
|
</button>
|
|
<!-- Story bars: the active one fills over the hold time, then the
|
|
next screen comes in. -->
|
|
<div class="feat-bars absolute z-30 bottom-4 left-1/2 -translate-x-1/2 flex items-center gap-1.5 h-7 px-3 rounded-full bg-slate-950/45 backdrop-blur-md ring-1 ring-white/10">
|
|
{allSlides.map((_, i) => (
|
|
<button type="button" class="feat-bar relative h-[3px] w-8 rounded-full bg-white/30 overflow-hidden cursor-pointer" data-i={i} aria-label={`Screen ${i + 1}`}>
|
|
<span class="feat-fill absolute inset-0 rounded-full bg-white origin-left"></span>
|
|
</button>
|
|
))}
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
<style is:global>
|
|
.feat-track {
|
|
transition: transform 1100ms cubic-bezier(0.25, 0.9, 0.2, 1);
|
|
will-change: transform;
|
|
}
|
|
.feat-fill { transform: scaleX(0); }
|
|
.feat-bar.is-done .feat-fill { transform: scaleX(1); }
|
|
.feat-bar.is-active .feat-fill {
|
|
animation: feat-fill var(--feat-dur, 6000ms) linear forwards;
|
|
}
|
|
.feat-frame.is-held .feat-fill,
|
|
.feat-frame.is-idle .feat-fill {
|
|
animation-play-state: paused;
|
|
}
|
|
@keyframes feat-fill {
|
|
from { transform: scaleX(0); }
|
|
to { transform: scaleX(1); }
|
|
}
|
|
@media (prefers-reduced-motion: reduce) {
|
|
.feat-track { transition: none; }
|
|
.feat-bar.is-active .feat-fill { animation: none; transform: scaleX(1); }
|
|
}
|
|
/* Parked (a viewport or more away): the screens are not rendered at all. */
|
|
.feat-frame.is-parked .feat-track { content-visibility: hidden; }
|
|
@supports not (content-visibility: hidden) {
|
|
.feat-frame.is-parked .feat-track { display: none; }
|
|
}
|
|
</style>
|
|
|
|
<script>
|
|
// The window is a 1280x944 browser pinned at the anchored corner at every
|
|
// width: `zoom` of its width maps across the box, and if that still leaves
|
|
// the box taller than the window, the scale grows until the window covers
|
|
// it. Never panned into. At lg+ the frame is as tall as the copy beside it;
|
|
// below lg nothing sits beside it, so it keeps a desktop frame's proportions.
|
|
const FRAME_ASPECT = 0.8;
|
|
function fit(screen: HTMLElement) {
|
|
const box = screen.parentElement as HTMLElement | null;
|
|
const frame = screen.closest<HTMLElement>('.feat-frame');
|
|
const shell = frame?.parentElement as HTMLElement | null;
|
|
if (!box || !frame || !shell) return;
|
|
// A parked screen has no layout to measure; its last sizes stay put.
|
|
if (frame.classList.contains('is-parked')) return;
|
|
|
|
const lg = window.innerWidth >= 1024;
|
|
const inset = lg ? 28 : 16;
|
|
const zoom = Number(screen.dataset.zoom) || 1000;
|
|
const byWidth = box.clientWidth / zoom;
|
|
if (shell.hasAttribute('data-whole')) {
|
|
// The whole window at the scale the width allows; the frame's height
|
|
// follows (the box is inset on one edge), so nothing is cropped.
|
|
screen.style.transform = `scale(${byWidth})`;
|
|
const h = `${Math.round(screen.offsetHeight * byWidth + inset)}px`;
|
|
if (frame.style.height !== h) { frame.style.height = h; shell.style.height = h; }
|
|
return;
|
|
}
|
|
const h = lg ? '' : `${Math.round(shell.clientWidth * FRAME_ASPECT)}px`;
|
|
if (frame.style.height !== h) { frame.style.height = h; shell.style.height = h; }
|
|
const byHeight = box.clientHeight / Math.max(1, screen.offsetHeight);
|
|
screen.style.transform = `scale(${Math.max(byWidth, byHeight)})`;
|
|
}
|
|
|
|
function fitAll() {
|
|
document.querySelectorAll<HTMLElement>('.feat-screen').forEach((s) => fit(s));
|
|
}
|
|
|
|
// The story carousel. The active bar's fill animation IS the timer: when
|
|
// it ends the next screen slides in, and pausing it (hover, off screen)
|
|
// pauses the advance with it, so the bar never lies about when the switch
|
|
// happens.
|
|
const HOLD_MS = 6500;
|
|
function carousel(frame: HTMLElement) {
|
|
const count = Number(frame.dataset.slides) || 1;
|
|
if (count < 2) return;
|
|
const track = frame.querySelector<HTMLElement>('.feat-track');
|
|
const bars = [...frame.querySelectorAll<HTMLElement>('.feat-bar')];
|
|
if (!track) return;
|
|
frame.style.setProperty('--feat-dur', `${HOLD_MS}ms`);
|
|
frame.classList.add('is-idle');
|
|
|
|
let i = 0;
|
|
// Reduced motion draws no fill, so no animationend: a timer advances instead.
|
|
const reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
|
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
let left = HOLD_MS;
|
|
let since = 0;
|
|
const paused = () => frame.classList.contains('is-held') || frame.classList.contains('is-idle');
|
|
// Runs only while the frame is neither held nor off screen; pausing keeps the remaining time.
|
|
const run = () => {
|
|
clearTimeout(timer);
|
|
if (paused()) return;
|
|
since = Date.now();
|
|
timer = setTimeout(() => go(i + 1), left);
|
|
};
|
|
const hold = () => {
|
|
if (timer === undefined) return;
|
|
clearTimeout(timer);
|
|
timer = undefined;
|
|
left = Math.max(0, left - (Date.now() - since));
|
|
};
|
|
const arm = () => { left = HOLD_MS; run(); };
|
|
// Held and idle flip on hover and visibility; keep the timer in step with them.
|
|
const sync = () => { if (!reduced) return; paused() ? hold() : run(); };
|
|
const paint = () => {
|
|
bars.forEach((b, k) => {
|
|
b.classList.toggle('is-done', k < i);
|
|
b.classList.remove('is-active');
|
|
});
|
|
const active = bars[i];
|
|
if (!active) return;
|
|
// Reflow between remove and add restarts the fill from zero.
|
|
void active.offsetWidth;
|
|
active.classList.add('is-active');
|
|
if (reduced) arm();
|
|
};
|
|
const go = (n: number) => {
|
|
i = ((n % count) + count) % count;
|
|
track.style.transform = `translate3d(${-i * 100}%, 0, 0)`;
|
|
paint();
|
|
};
|
|
|
|
bars.forEach((b) => {
|
|
b.querySelector('.feat-fill')?.addEventListener('animationend', () => {
|
|
if (b.classList.contains('is-active')) go(i + 1);
|
|
});
|
|
b.addEventListener('click', () => go(Number(b.dataset.i) || 0));
|
|
});
|
|
frame.querySelector('.feat-prev')?.addEventListener('click', () => go(i - 1));
|
|
frame.querySelector('.feat-next')?.addEventListener('click', () => go(i + 1));
|
|
frame.addEventListener('pointerenter', () => { frame.classList.add('is-held'); sync(); });
|
|
frame.addEventListener('pointerleave', () => { frame.classList.remove('is-held'); sync(); });
|
|
|
|
let startX: number | null = null;
|
|
frame.addEventListener('pointerdown', (e) => { startX = e.clientX; });
|
|
frame.addEventListener('pointerup', (e) => {
|
|
if (startX === null) return;
|
|
const dx = e.clientX - startX;
|
|
startX = null;
|
|
if (Math.abs(dx) > 40) go(dx < 0 ? i + 1 : i - 1);
|
|
});
|
|
|
|
if (typeof IntersectionObserver !== 'undefined') {
|
|
new IntersectionObserver((entries) => {
|
|
frame.classList.toggle('is-idle', !entries.some((e) => e.isIntersecting));
|
|
sync();
|
|
}, { threshold: 0.35 }).observe(frame);
|
|
} else {
|
|
frame.classList.remove('is-idle');
|
|
sync();
|
|
}
|
|
paint();
|
|
}
|
|
|
|
// Sections more than a viewport away drop their screens from rendering
|
|
// (content-visibility), so iOS Safari holds the layers of two or three
|
|
// windows at a time instead of fourteen; the frame keeps its size, and the
|
|
// screens are fitted again as the section comes back within reach.
|
|
function park(frame: HTMLElement) {
|
|
if (typeof IntersectionObserver === 'undefined') return;
|
|
new IntersectionObserver((entries) => {
|
|
const near = entries.some((e) => e.isIntersecting);
|
|
if (frame.classList.toggle('is-parked', !near)) return;
|
|
frame.querySelectorAll<HTMLElement>('.feat-screen').forEach(fit);
|
|
}, { rootMargin: '100% 0px' }).observe(frame);
|
|
}
|
|
|
|
function init() {
|
|
document.querySelectorAll<HTMLElement>('.feat-screen').forEach((screen) => {
|
|
const run = () => fit(screen);
|
|
run();
|
|
requestAnimationFrame(run);
|
|
const shell = screen.closest<HTMLElement>('.feat-frame-shell');
|
|
if (shell) new ResizeObserver(() => requestAnimationFrame(run)).observe(shell);
|
|
});
|
|
window.addEventListener('load', fitAll);
|
|
window.addEventListener('resize', fitAll);
|
|
document.querySelectorAll<HTMLElement>('.feat-frame').forEach(carousel);
|
|
document.querySelectorAll<HTMLElement>('.feat-frame').forEach(park);
|
|
// Screens are pictures: nothing inside them is a real tab stop.
|
|
document.querySelectorAll<HTMLElement>('.feat-screen button, .feat-screen input, .feat-screen a, .feat-screen [tabindex]').forEach((el) => { el.tabIndex = -1; });
|
|
}
|
|
init();
|
|
</script>
|