diff --git a/website/deck.js b/website/deck.js new file mode 100644 index 0000000..0290c47 --- /dev/null +++ b/website/deck.js @@ -0,0 +1,375 @@ +// The material deck: one collectible card per animated showcase, held as a +// hand. Cards tilt toward the pointer under a foil, play their loop on hover, +// and open in an inspector where they turn over to show their timeline. +import { SHOWCASES } from './showcases.js'; + +const reduced = matchMedia('(prefers-reduced-motion: reduce)').matches; +const $ = (s, el = document) => el.querySelector(s); +const clamp = (v, a, b) => (v < a ? a : v > b ? b : v); +const esc = (s) => String(s).replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[c]); + +/* ---------- Colour ---------- */ + +const rgb = (h) => { + const n = parseInt(h.slice(1), 16); + return [(n >> 16) & 255, (n >> 8) & 255, n & 255]; +}; +const hex = (c) => `#${c.map((v) => Math.round(clamp(v, 0, 255)).toString(16).padStart(2, '0')).join('')}`; +const mix = (a, b, t) => hex(rgb(a).map((v, i) => v + (rgb(b)[i] - v) * t)); +const lum = (h) => { + const [r, g, b] = rgb(h).map((v) => { + v /= 255; + return v <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4; + }); + return 0.2126 * r + 0.7152 * g + 0.0722 * b; +}; +const sat = (h) => { + const c = rgb(h); + return (Math.max(...c) - Math.min(...c)) / 255; +}; + +/** The card's colours: its scene background, plus the most vivid ink for the foil rim. */ +function tones(s) { + const c1 = s.bg[0]; + const accent = [...s.palette].slice(1).sort((a, b) => sat(b) - sat(a))[0] || '#8b72ff'; + const dark = lum(c1) < 0.32; + return { + '--c1': c1, + '--c1l': mix(c1, '#ffffff', dark ? 0.16 : 0.45), + '--c1d': mix(c1, '#000000', dark ? 0.35 : 0.12), + '--fg': dark ? '#f6f2ea' : '#16141c', + '--chip': dark ? 'rgba(255,255,255,0.12)' : 'rgba(20,16,28,0.08)', + '--rimA': mix(accent, '#ffffff', 0.55), + '--rimB': mix(accent, '#000000', 0.12), + }; +} + +const fmt = (v) => (Math.abs(v) >= 10 || Number.isInteger(v) ? String(Math.round(v)) : String(+v.toFixed(2))); + +/* ---------- Card markup ---------- */ + +function front(s) { + const stats = s.settings + .map((st) => { + const v = clamp((st.value - st.min) / (st.max - st.min), 0, 1); + const v2 = st.to == null ? null : clamp((st.to - st.min) / (st.max - st.min), 0, 1); + return `
  • ${esc(st.label)}${fmt(st.value)}${st.to == null ? '' : `→${fmt(st.to)}`}
  • `; + }) + .join(''); + const sw = s.palette.slice(0, 5).map((c) => ``).join(''); + return ` +
    +
    ${esc(s.title)}No.${String(s.no).padStart(2, '0')}/${SHOWCASES.length}
    +
    +
    ${esc(s.material)}${s.duration.toFixed(1)} s · ${s.layers} layers
    + +

    ${esc(s.flavor)}

    +
    ${sw}MORPHOTYPE
    +
    `; +} + +function lanes(track, D) { + if (track.start != null) { + const l = (track.start / D) * 100, w = Math.max(1.5, ((track.end - track.start) / D) * 100); + return ``; + } + const p = track.period ? (track.period / D) * 100 : 100; + return ``; +} + +function back(s) { + const MAX = 7; + const rows = s.rows.slice(0, MAX).map((r) => { + const tracks = r.tracks.length ? r.tracks.map((t) => `
    ${lanes(t, s.duration)}
    `).join('') : '
    '; + return `
    ${esc(r.name)}
    ${tracks}
    `; + }); + const more = s.rows.length > MAX ? `

    and ${s.rows.length - MAX} more rows

    ` : ''; + const ticks = Array.from({ length: Math.round(s.duration) + 1 }, (_, i) => `${i}s`).join(''); + const all = s.rows.flatMap((r) => r.tracks); + const count = (k) => all.filter((t) => t.kind === k).length; + const kinds = [['enter', 'Comes in'], ['leave', 'Then'], ['loop', 'Loop'], ['param', 'Setting'], ['flow', 'Flows']].filter(([k]) => count(k)); + const legend = kinds.map(([k, label]) => `${label} ${count(k)}`).join(''); + const facts = `
    ${s.layers}Layers
    ${s.rows.length}Moving
    ${all.length}Tracks
    ${s.duration * 30}Frames
    `; + return ` +
    +
    ${esc(s.title)}Timeline · ${s.duration.toFixed(1)} s
    +
    ${ticks}
    ${rows.join('')}
    + ${more} +
    ${legend}
    + ${facts} +

    Open it in Morphotype: Templates › Learn › Animated · ${esc(s.title)}

    +
    `; +} + +function makeCard(s, i) { + const el = document.createElement('article'); + el.className = 'tcard'; + el.tabIndex = 0; + el.dataset.i = i; + el.setAttribute('role', 'button'); + el.setAttribute('aria-label', `${s.title}: ${s.material}. Open card`); + for (const [k, v] of Object.entries(tones(s))) el.style.setProperty(k, v); + el.innerHTML = `
    ${front(s)}${back(s)}
    `; + return el; +} + +/* ---------- Foil texture ---------- */ + +function sparkle() { + const c = document.createElement('canvas'); + c.width = c.height = 90; + const g = c.getContext('2d'); + const img = g.createImageData(90, 90); + for (let i = 0; i < 90 * 90; i++) { + const r = Math.random(); + img.data[i * 4 + 3] = r < 0.035 ? 255 : r < 0.12 ? 190 : 90; + } + g.putImageData(img, 0, 0); + document.documentElement.style.setProperty('--sparkle', `url(${c.toDataURL()})`); +} + +/* ---------- Tilt, eased toward the pointer ---------- */ + +function tilter(el, range) { + const st = { rx: 0, ry: 0, tx: 0, ty: 0, raf: 0 }; + const step = () => { + st.rx += (st.tx - st.rx) * 0.16; + st.ry += (st.ty - st.ry) * 0.16; + el.style.setProperty('--rx', `${st.rx.toFixed(2)}deg`); + el.style.setProperty('--ry', `${st.ry.toFixed(2)}deg`); + st.raf = Math.abs(st.tx - st.rx) + Math.abs(st.ty - st.ry) > 0.05 ? requestAnimationFrame(step) : 0; + }; + const kick = () => st.raf || (st.raf = requestAnimationFrame(step)); + return { + move(e) { + const r = el.getBoundingClientRect(); + const px = clamp((e.clientX - r.left) / r.width, 0, 1), py = clamp((e.clientY - r.top) / r.height, 0, 1); + if (!reduced) { + st.tx = (0.5 - py) * range; + st.ty = (px - 0.5) * range * 1.2; + } + el.style.setProperty('--gx', `${(px * 100).toFixed(1)}%`); + el.style.setProperty('--gy', `${(py * 100).toFixed(1)}%`); + el.style.setProperty('--fx', `${(px * 100).toFixed(1)}%`); + el.style.setProperty('--fy', `${(py * 100).toFixed(1)}%`); + el.style.setProperty('--hov', '1'); + kick(); + }, + leave() { + st.tx = st.ty = 0; + el.style.setProperty('--hov', '0'); + kick(); + }, + }; +} + +function playIn(card, on) { + const v = $('video', card); + if (!v) return; + if (on && !reduced) { + if (!v.src) v.src = v.dataset.src; + v.onplaying = () => v.classList.add('playing'); + v.play().catch(() => {}); + } else { + v.pause(); + } +} + +/* ---------- The hand ---------- */ + +export function deck() { + sparkle(); + const hand = $('[data-hand]'); + const cards = SHOWCASES.map((s, i) => makeCard(s, i)); + cards.forEach((c) => hand.append(c)); + const tilts = cards.map((c) => tilter(c, 16)); + const narrow = () => matchMedia('(max-width: 720px)').matches; + let hover = -1; + + const layout = () => { + if (narrow()) return; + const n = cards.length; + const step = innerWidth < 980 ? 4.4 : 5.4; + cards.forEach((c, i) => { + const base = (i - (n - 1) / 2) * step; + let a = base, lift = 0, sc = 1, z = i + 1; + if (hover >= 0) { + const d = i - hover; + if (d === 0) { + a = base * 0.55; + lift = -70; + sc = 1.12; + z = 60; + } else a = base + Math.sign(d) * Math.max(0, 3.6 - Math.abs(d) * 0.9); + } + c.style.setProperty('--a', `${a.toFixed(2)}deg`); + c.style.setProperty('--lift', `${lift}px`); + c.style.setProperty('--sc', sc); + c.style.setProperty('--z', z); + }); + }; + + const setHover = (i) => { + if (i === hover) return; + if (hover >= 0) { + playIn(cards[hover], false); + tilts[hover].leave(); + } + hover = i; + if (i >= 0) playIn(cards[i], true); + layout(); + }; + + cards.forEach((c, i) => { + c.style.setProperty('--delay', `${i * 55}ms`); + c.addEventListener('pointerenter', (e) => e.pointerType === 'mouse' && setHover(i)); + c.addEventListener('pointermove', (e) => { + if (e.pointerType !== 'mouse') return; + setHover(i); + tilts[i].move(e); + }); + c.addEventListener('focus', () => setHover(i)); + c.addEventListener('click', () => inspector.open(i, c)); + c.addEventListener('keydown', (e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + inspector.open(i, c); + } + }); + }); + hand.addEventListener('pointerleave', () => setHover(-1)); + hand.addEventListener('focusout', (e) => !hand.contains(e.relatedTarget) && setHover(-1)); + addEventListener('resize', layout); + layout(); + + // Deal the hand when it comes into view, then drop the stagger so hovers are quick. + new IntersectionObserver( + ([en], io) => { + if (!en.isIntersecting) return; + hand.classList.add('dealt'); + io.disconnect(); + setTimeout(() => cards.forEach((c) => c.style.setProperty('--delay', '0ms')), 1400); + }, + { threshold: 0.3 }, + ).observe(hand); + + // On phones the hand is a swipeable row: the card in the middle plays. + const centre = new IntersectionObserver( + (entries) => { + if (!narrow()) return; + for (const en of entries) playIn(en.target, en.isIntersecting); + }, + { root: hand, threshold: 0.75 }, + ); + cards.forEach((c) => centre.observe(c)); + + const inspector = makeInspector(cards); +} + +/* ---------- Inspector ---------- */ + +function makeInspector(cards) { + const root = $('[data-inspector]'); + const stage = $('[data-stage]', root); + let index = -1, card = null, tilt = null, source = null, swiped = false; + + const angleOf = (el) => parseFloat(el.style.getPropertyValue('--a')) || 0; + + const mount = (i) => { + const s = SHOWCASES[i]; + card = makeCard(s, i); + card.removeAttribute('role'); + card.setAttribute('aria-label', `${s.title}. Click to turn over`); + stage.replaceChildren(card); + tilt = tilter(card, 22); + card.addEventListener('pointermove', (e) => tilt.move(e)); + card.addEventListener('pointerleave', () => tilt.leave()); + card.addEventListener('click', () => (swiped ? (swiped = false) : flip())); + playIn(card, true); + }; + + const flip = () => card?.classList.toggle('flipped'); + + const flyFrom = (el, reverse = false) => { + const a = el.getBoundingClientRect(), b = stage.getBoundingClientRect(); + const dx = a.left + a.width / 2 - (b.left + b.width / 2), dy = a.top + a.height / 2 - (b.top + b.height / 2); + const from = `translate(${dx}px, ${dy}px) scale(${a.width / b.width}) rotate(${angleOf(el)}deg)`; + const frames = [{ transform: from }, { transform: 'none' }]; + return stage.animate(reverse ? frames.reverse() : frames, { duration: reduced ? 1 : reverse ? 420 : 640, easing: reverse ? 'cubic-bezier(.5,0,.75,0)' : 'cubic-bezier(.2,.9,.25,1.08)', fill: 'both' }); + }; + + const open = (i, el) => { + index = i; + source = el; + mount(i); + root.hidden = false; + document.documentElement.style.overflow = 'hidden'; + void root.offsetWidth; + root.classList.add('open'); + el.style.visibility = 'hidden'; + flyFrom(el); + $('[data-flip]', root).focus({ preventScroll: true }); + }; + + const close = async () => { + if (index < 0) return; + root.classList.remove('open'); + playIn(card, false); + const target = cards[index]; + const visible = target.getBoundingClientRect(); + if (visible.bottom > 0 && visible.top < innerHeight) { + card.classList.remove('flipped'); + // Never let a stalled animation (a background tab) keep the page locked. + await Promise.race([flyFrom(target, true).finished.catch(() => {}), new Promise((r) => setTimeout(r, 600))]); + } + target.style.visibility = ''; + stage.getAnimations().forEach((a) => a.cancel()); + root.hidden = true; + document.documentElement.style.overflow = ''; + target.focus({ preventScroll: true }); + index = -1; + source = null; + }; + + const go = (d) => { + if (index < 0) return; + cards[index].style.visibility = ''; + const next = (index + d + cards.length) % cards.length; + const out = card; + out.animate([{ transform: 'none', opacity: 1 }, { transform: `translateX(${-d * 60}px) rotate(${-d * 8}deg)`, opacity: 0 }], { duration: 220, easing: 'ease-in', fill: 'forwards' }); + setTimeout(() => { + playIn(out, false); + index = next; + cards[next].style.visibility = 'hidden'; + mount(next); + card.animate([{ transform: `translateX(${d * 60}px) rotate(${d * 8}deg)`, opacity: 0 }, { transform: 'none', opacity: 1 }], { duration: 420, easing: 'cubic-bezier(.2,.9,.25,1.1)' }); + }, reduced ? 0 : 200); + }; + + root.querySelectorAll('[data-close]').forEach((b) => b.addEventListener('click', close)); + $('[data-flip]', root).addEventListener('click', flip); + $('[data-prev]', root).addEventListener('click', () => go(-1)); + $('[data-next]', root).addEventListener('click', () => go(1)); + addEventListener('keydown', (e) => { + if (index < 0) return; + if (e.key === 'Escape') close(); + else if (e.key === 'ArrowRight') go(1); + else if (e.key === 'ArrowLeft') go(-1); + else if (e.key === 'f' || e.key === 'F') flip(); + }); + + // Swipe between cards on touch screens. + let sx = null; + stage.addEventListener('pointerdown', (e) => e.pointerType !== 'mouse' && (sx = e.clientX)); + stage.addEventListener('pointerup', (e) => { + if (sx == null) return; + const dx = e.clientX - sx; + sx = null; + if (Math.abs(dx) > 50) { + swiped = true; + go(dx < 0 ? 1 : -1); + } + }); + + return { open, close }; +} diff --git a/website/index.html b/website/index.html index 70f71ea..d61ee35 100644 --- a/website/index.html +++ b/website/index.html @@ -73,41 +73,21 @@

    - -
    + +
    -

    Made with Morphotype

    -

    Every frame on this page
    came out of the app.

    -
    -
    -
    - -
    Neon signNeon material, with flicker and glow keyframed.
    -
    -
    - -
    Holo cardA holographic shift that ramps over the clock.
    -
    -
    - -
    Liquid chromeChrome that flows by itself, with orbiting sparkles.
    -
    -
    - -
    Sticker packGroup motion, staggered one by one.
    -
    -
    - -
    Pop 3DExtrusion whose angle turns as it plays.
    -
    +

    The material deck

    +

    Ten cards, dealt
    straight from the app.

    +

    Each card is a real template with its real settings. Tilt one to catch the foil, then click it to turn it over.

    +

    What it does

    -

    A whole type studio,
    in one small app.

    +

    A whole type studio,
    in one small app.

    • Morph fonts

      Blend any two typefaces curve to curve. Letters stay on the baseline and x-height.

    • @@ -121,29 +101,37 @@

      A whole type studio,
      in one small app.

    - -
    + +
    -

    Ten materials, all moving

    -

    Open any of these in Learn
    and take it apart.

    +

    Motion, frame for frame

    +

    The timeline you edit
    is the one that plays.

    +

    These are the real tracks from each showcase. Drag across the timeline to scrub.

    -
    -
    - - -
    -
    - +
    +
    + + Animated · Shape shifter + 0.00 s · f0 +
    +
    + + +
    +
    +
    +
    +
    -

    +

    Two modes

    -

    Design for logos and posts.
    Type for whole fonts.

    +

    Design for logos and posts.
    Type for whole fonts.

    @@ -222,5 +210,17 @@

    Get Morphotype.

    + + + diff --git a/website/main.js b/website/main.js index f56cc81..062125a 100644 --- a/website/main.js +++ b/website/main.js @@ -1,6 +1,9 @@ // Morphotype site. No dependencies: the hero morphs signed distance fields of // our bundled fonts on the CPU and draws them as a grid of pixels. +import { deck } from './deck.js'; +import { timeline } from './timeline.js'; + const reduced = matchMedia('(prefers-reduced-motion: reduce)').matches; const $ = (s, el = document) => el.querySelector(s); const $$ = (s, el = document) => [...el.querySelectorAll(s)]; @@ -507,107 +510,6 @@ function icons() { } } -/* ---------- Fanned cards ---------- */ - -function fan() { - const el = $('[data-fan]'); - $$('.card', el).forEach((c, i) => c.style.setProperty('--i', i)); - const videos = $$('video', el); - new IntersectionObserver( - ([en]) => { - if (en.isIntersecting) { - el.classList.add('spread'); - if (!reduced) videos.forEach((v) => v.play().catch(() => {})); - } else videos.forEach((v) => v.pause()); - }, - { threshold: 0.25 }, - ).observe(el); -} - -/* ---------- Reel of animated showcases ---------- */ - -const REEL = [ - ['neon-sign', 'Neon sign', 'Neon material with flicker; the glow is keyframed.'], - ['liquid-chrome', 'Liquid chrome', 'Chrome that flows on its own, with a spinning sparkle.'], - ['sticker-pack', 'Sticker pack', 'Sticker material on five shapes, grown one by one.'], - ['keyboard', 'Keyboard', 'Keycap letters that drop in turn, then pulse.'], - ['melting', 'Melting', 'A melt modifier on Anton, with rays turning behind.'], - ['arcade', 'Arcade', 'A pixelate modifier, a floating ghost and a one-way lane.'], - ['plush-weather', 'Plush weather', 'Plush material on a cloud, a sun and a bolt.'], - ['holo-card', 'Holo card', 'A holographic shift that ramps while the card sways.'], - ['pop-3d', 'Pop 3D', 'Extruded type whose angle turns as it plays.'], - ['print-club', 'Print club', 'Riso offset flicker, with words entering from both sides.'], -]; - -function reel() { - const frame = $('[data-reel]'); - const media = $('.reel-media', frame); - const title = $('[data-reel-title]'), note = $('[data-reel-note]'); - const play = $('.reel-play', frame); - const videos = REEL.map(([id]) => { - const v = document.createElement('video'); - v.muted = true; - v.loop = true; - v.playsInline = true; - v.preload = 'none'; - v.poster = `/media/${id}.webp`; - v.dataset.src = `/media/${id}.mp4`; - media.append(v); - return v; - }); - let inView = false; - const show = (i) => { - videos.forEach((v, k) => { - if (k === i) { - if (!v.src) v.src = v.dataset.src; - v.currentTime = 0; - v.classList.add('on'); - if (inView && !reduced) v.play().catch(() => {}); - } else { - v.classList.remove('on'); - v.pause(); - } - }); - // Warm the next one. - const n = videos[(i + 1) % videos.length]; - if (!n.src) { - n.preload = 'auto'; - n.src = n.dataset.src; - } - title.textContent = REEL[i][1]; - note.textContent = REEL[i][2]; - }; - const steps = stepper($('.pasito', frame), REEL.length, { duration: () => 4800, onChange: (i) => show(i) }); - show(0); - $('.prev', frame).addEventListener('click', () => steps.set(steps.index - 1, true)); - $('.next', frame).addEventListener('click', () => steps.set(steps.index + 1, true)); - let userPaused = false; - play.addEventListener('click', () => { - userPaused = !userPaused; - play.classList.toggle('paused', userPaused); - play.setAttribute('aria-label', userPaused ? 'Play' : 'Pause'); - const v = videos[steps.index]; - if (userPaused) { - steps.pause(); - v.pause(); - } else { - steps.play(); - v.play().catch(() => {}); - } - }); - new IntersectionObserver(([en]) => { - inView = en.isIntersecting; - const v = videos[steps.index]; - if (inView && !userPaused && !reduced) { - v.play().catch(() => {}); - steps.play(); - } else { - v.pause(); - steps.pause(); - } - }, { threshold: 0.35 }).observe(frame); -} - /* ---------- Design / Type toggle ---------- */ function modeShots() { @@ -711,7 +613,7 @@ function chrome() { }), { rootMargin: '0px 0px -8% 0px' }, ); - $$('.section-head, .feature-grid li, .platform, .window, .reel-frame, .changes li, .seg').forEach((el, i) => { + $$('.section-head, .feature-grid li, .platform, .window, .tl, .changes li, .seg').forEach((el, i) => { el.classList.add('reveal'); el.style.transitionDelay = `${(i % 4) * 70}ms`; io.observe(el); @@ -720,8 +622,8 @@ function chrome() { icons(); chrome(); -fan(); -reel(); +deck(); +timeline(); modeShots(); marquees(); downloads(); diff --git a/website/media/arcade.mp4 b/website/media/arcade.mp4 deleted file mode 100644 index c00e177..0000000 Binary files a/website/media/arcade.mp4 and /dev/null differ diff --git a/website/media/arcade.webp b/website/media/arcade.webp deleted file mode 100644 index fcd8248..0000000 Binary files a/website/media/arcade.webp and /dev/null differ diff --git a/website/media/aura.mp4 b/website/media/aura.mp4 new file mode 100644 index 0000000..d9aafaa Binary files /dev/null and b/website/media/aura.mp4 differ diff --git a/website/media/aura.webp b/website/media/aura.webp new file mode 100644 index 0000000..640e6f1 Binary files /dev/null and b/website/media/aura.webp differ diff --git a/website/media/crystal.mp4 b/website/media/crystal.mp4 new file mode 100644 index 0000000..33e8d25 Binary files /dev/null and b/website/media/crystal.mp4 differ diff --git a/website/media/crystal.webp b/website/media/crystal.webp new file mode 100644 index 0000000..3d34ffc Binary files /dev/null and b/website/media/crystal.webp differ diff --git a/website/media/frosted-404.mp4 b/website/media/frosted-404.mp4 new file mode 100644 index 0000000..ed384c9 Binary files /dev/null and b/website/media/frosted-404.mp4 differ diff --git a/website/media/frosted-404.webp b/website/media/frosted-404.webp index 7df5a85..4b23402 100644 Binary files a/website/media/frosted-404.webp and b/website/media/frosted-404.webp differ diff --git a/website/media/glass-lens.mp4 b/website/media/glass-lens.mp4 new file mode 100644 index 0000000..62768bf Binary files /dev/null and b/website/media/glass-lens.mp4 differ diff --git a/website/media/glass-lens.webp b/website/media/glass-lens.webp index 9d656ff..3109177 100644 Binary files a/website/media/glass-lens.webp and b/website/media/glass-lens.webp differ diff --git a/website/media/glow-glass.mp4 b/website/media/glow-glass.mp4 new file mode 100644 index 0000000..e14f3bc Binary files /dev/null and b/website/media/glow-glass.mp4 differ diff --git a/website/media/glow-glass.webp b/website/media/glow-glass.webp index c8e7d81..6c86022 100644 Binary files a/website/media/glow-glass.webp and b/website/media/glow-glass.webp differ diff --git a/website/media/keyboard.mp4 b/website/media/keyboard.mp4 deleted file mode 100644 index 90c85e0..0000000 Binary files a/website/media/keyboard.mp4 and /dev/null differ diff --git a/website/media/keyboard.webp b/website/media/keyboard.webp deleted file mode 100644 index d71f837..0000000 Binary files a/website/media/keyboard.webp and /dev/null differ diff --git a/website/media/liquid-chrome.mp4 b/website/media/liquid-chrome.mp4 deleted file mode 100644 index 6784d58..0000000 Binary files a/website/media/liquid-chrome.mp4 and /dev/null differ diff --git a/website/media/liquid-chrome.webp b/website/media/liquid-chrome.webp deleted file mode 100644 index 19c2bff..0000000 Binary files a/website/media/liquid-chrome.webp and /dev/null differ diff --git a/website/media/melting.mp4 b/website/media/melting.mp4 deleted file mode 100644 index 9df578d..0000000 Binary files a/website/media/melting.mp4 and /dev/null differ diff --git a/website/media/melting.webp b/website/media/melting.webp deleted file mode 100644 index e3ec825..0000000 Binary files a/website/media/melting.webp and /dev/null differ diff --git a/website/media/neon-sign.mp4 b/website/media/neon-sign.mp4 deleted file mode 100644 index 493bed7..0000000 Binary files a/website/media/neon-sign.mp4 and /dev/null differ diff --git a/website/media/neon-sign.webp b/website/media/neon-sign.webp deleted file mode 100644 index dc827f1..0000000 Binary files a/website/media/neon-sign.webp and /dev/null differ diff --git a/website/media/pixel-office.webp b/website/media/pixel-office.webp index 14b4bb7..c9be4ee 100644 Binary files a/website/media/pixel-office.webp and b/website/media/pixel-office.webp differ diff --git a/website/media/plush-weather.mp4 b/website/media/plush-weather.mp4 deleted file mode 100644 index b2ad006..0000000 Binary files a/website/media/plush-weather.mp4 and /dev/null differ diff --git a/website/media/plush-weather.webp b/website/media/plush-weather.webp deleted file mode 100644 index 4bdcd67..0000000 Binary files a/website/media/plush-weather.webp and /dev/null differ diff --git a/website/media/print-club.mp4 b/website/media/print-club.mp4 deleted file mode 100644 index 0963c6a..0000000 Binary files a/website/media/print-club.mp4 and /dev/null differ diff --git a/website/media/print-club.webp b/website/media/print-club.webp deleted file mode 100644 index e69d45d..0000000 Binary files a/website/media/print-club.webp and /dev/null differ diff --git a/website/media/puffy.mp4 b/website/media/puffy.mp4 new file mode 100644 index 0000000..abccbc5 Binary files /dev/null and b/website/media/puffy.mp4 differ diff --git a/website/media/puffy.webp b/website/media/puffy.webp new file mode 100644 index 0000000..507b095 Binary files /dev/null and b/website/media/puffy.webp differ diff --git a/website/media/shape-shifter.mp4 b/website/media/shape-shifter.mp4 new file mode 100644 index 0000000..830e17d Binary files /dev/null and b/website/media/shape-shifter.mp4 differ diff --git a/website/media/shape-shifter.webp b/website/media/shape-shifter.webp new file mode 100644 index 0000000..fe95407 Binary files /dev/null and b/website/media/shape-shifter.webp differ diff --git a/website/media/sticker-pack.mp4 b/website/media/sticker-pack.mp4 deleted file mode 100644 index e1815ed..0000000 Binary files a/website/media/sticker-pack.mp4 and /dev/null differ diff --git a/website/media/sticker-pack.webp b/website/media/sticker-pack.webp deleted file mode 100644 index aee8d9f..0000000 Binary files a/website/media/sticker-pack.webp and /dev/null differ diff --git a/website/media/thermal-wings.mp4 b/website/media/thermal-wings.mp4 new file mode 100644 index 0000000..44e3f42 Binary files /dev/null and b/website/media/thermal-wings.mp4 differ diff --git a/website/media/thermal-wings.webp b/website/media/thermal-wings.webp index 1e1b7ff..a0b76d0 100644 Binary files a/website/media/thermal-wings.webp and b/website/media/thermal-wings.webp differ diff --git a/website/showcases.js b/website/showcases.js new file mode 100644 index 0000000..96853c0 --- /dev/null +++ b/website/showcases.js @@ -0,0 +1,880 @@ +// Generated from the app: each animated showcase, its main material and settings, +// and its timeline rows exactly as the motion editor lays them out. +export const SHOWCASES = [ + { + "id": "shape-shifter", + "title": "Shape shifter", + "no": 1, + "material": "Font morph", + "settings": [ + { + "label": "Morph blend", + "value": 0, + "min": 0, + "max": 1, + "to": 1 + }, + { + "label": "Bevel", + "value": 30, + "min": 2, + "max": 200 + }, + { + "label": "Liquid", + "value": 0.9, + "min": 0, + "max": 1.5 + } + ], + "palette": [ + "#1f0938", + "#5a17d6", + "#ff2e9a", + "#ffd36b", + "#ff7ad1" + ], + "bg": [ + "#2a0c4a", + "#07030f" + ], + "duration": 4, + "layers": 3, + "flavor": "One outline swinging between Inter Black and Pacifico, in plasma chrome that keeps flowing.", + "rows": [ + { + "name": "Morph", + "depth": 0, + "tracks": [ + { + "kind": "param", + "label": "Morph · Blend", + "period": 4 + }, + { + "kind": "flow", + "label": "Liquid chrome · Animate 0.35", + "title": "Material" + } + ] + }, + { + "name": "To", + "depth": 0, + "tracks": [ + { + "kind": "loop", + "label": "Blink", + "period": 4 + } + ] + }, + { + "name": "From", + "depth": 0, + "tracks": [ + { + "kind": "loop", + "label": "Blink", + "period": 4 + } + ] + } + ] + }, + { + "id": "glass-lens", + "title": "Glass lens", + "no": 2, + "material": "Lens glass", + "settings": [ + { + "label": "Refraction", + "value": 1.3, + "min": 0, + "max": 2 + }, + { + "label": "Colour split", + "value": 2, + "min": 0, + "max": 2 + }, + { + "label": "Magnify", + "value": 0.3, + "min": -1, + "max": 1 + } + ], + "palette": [ + "#efefef", + "#39393b", + "#161516", + "#d3d3d4" + ], + "bg": [ + "#eeefef" + ], + "duration": 4, + "layers": 2, + "flavor": "A glass drop drifts across the word, bending and splitting it as it passes.", + "rows": [ + { + "name": "Drop", + "depth": 0, + "tracks": [ + { + "kind": "loop", + "label": "Slide", + "period": 4 + }, + { + "kind": "param", + "label": "Shape · Wobble", + "period": 2 + } + ] + } + ] + }, + { + "id": "crystal", + "title": "Crystal", + "no": 3, + "material": "Cut glass", + "settings": [ + { + "label": "Refraction", + "value": 1.6, + "min": 0, + "max": 2 + }, + { + "label": "Rainbow light", + "value": 1.6, + "min": 0, + "max": 2 + }, + { + "label": "Rainbow shift", + "value": 0.3, + "min": -1, + "max": 1, + "to": -0.3 + } + ], + "palette": [ + "#000000", + "#faf98e", + "#610051", + "#3c1439", + "#ccf7b9" + ], + "bg": [ + "#000000" + ], + "duration": 4, + "layers": 3, + "flavor": "A faceted glass hexagon turns as it crosses the word, throwing rainbow light.", + "rows": [ + { + "name": "Crystal", + "depth": 0, + "tracks": [ + { + "kind": "loop", + "label": "Slide", + "period": 4 + } + ] + }, + { + "name": "Gem", + "depth": 1, + "tracks": [ + { + "kind": "loop", + "label": "Spin", + "period": 4 + }, + { + "kind": "param", + "label": "Rainbow shift", + "period": 2 + } + ] + } + ] + }, + { + "id": "frosted-404", + "title": "Frosted 404", + "no": 4, + "material": "Frosted glass", + "settings": [ + { + "label": "Blur", + "value": 30, + "min": 0, + "max": 120 + }, + { + "label": "Tint amount", + "value": 0.5, + "min": 0, + "max": 1 + }, + { + "label": "Bend at edges", + "value": 0.6, + "min": 0, + "max": 2, + "to": 1.3 + } + ], + "palette": [ + "#141528", + "#816e93", + "#362b45", + "#e9e5f0" + ], + "bg": [ + "#1d2140", + "#0d0e15" + ], + "duration": 4, + "layers": 3, + "flavor": "A frosted folder floats over extruded numbers, its edges bending the light.", + "rows": [ + { + "name": "Close", + "depth": 0, + "tracks": [ + { + "kind": "loop", + "label": "Float", + "period": 4 + } + ] + }, + { + "name": "Glass folder", + "depth": 0, + "tracks": [ + { + "kind": "loop", + "label": "Float", + "period": 4 + }, + { + "kind": "param", + "label": "Bend at edges", + "period": 2 + } + ] + }, + { + "name": "404", + "depth": 0, + "tracks": [ + { + "kind": "loop", + "label": "Sway", + "period": 4 + } + ] + } + ] + }, + { + "id": "thermal-wings", + "title": "Thermal wings", + "no": 5, + "material": "Thermal", + "settings": [ + { + "label": "Heat spread", + "value": 100, + "min": 4, + "max": 300, + "to": 150 + }, + { + "label": "Glow", + "value": 200, + "min": 0, + "max": 400 + }, + { + "label": "Shift colours", + "value": 0, + "min": -0.5, + "max": 0.5, + "to": 0.18 + } + ], + "palette": [ + "#e4e6ad", + "#e9133d", + "#f65225", + "#cfae6d" + ], + "bg": [ + "#e8133c" + ], + "duration": 4, + "layers": 7, + "flavor": "A butterfly in heat-camera colours, its wings beating with a moving Stretch.", + "rows": [ + { + "name": "Title", + "depth": 0, + "tracks": [ + { + "kind": "enter", + "label": "Rise", + "start": 0.2, + "end": 1 + } + ] + }, + { + "name": "Butterfly", + "depth": 0, + "tracks": [ + { + "kind": "loop", + "label": "Float", + "period": 2 + }, + { + "kind": "param", + "label": "Shift colours", + "period": 4 + }, + { + "kind": "param", + "label": "Heat spread", + "period": 2 + }, + { + "kind": "param", + "label": "Stretch · Width", + "period": 1 + } + ] + } + ] + }, + { + "id": "holo-card", + "title": "Holo card", + "no": 6, + "material": "Holographic", + "settings": [ + { + "label": "Foil coverage", + "value": 1, + "min": 0, + "max": 1 + }, + { + "label": "Rim width", + "value": 12, + "min": 0.5, + "max": 80 + }, + { + "label": "Pastel", + "value": 0.55, + "min": 0, + "max": 1 + } + ], + "palette": [ + "#0c0b0e", + "#71ee9b", + "#d0e273", + "#b655a6", + "#d29f89" + ], + "bg": [ + "#0b0b0d" + ], + "duration": 4, + "layers": 4, + "flavor": "A holographic foil whose hue shifts all the way round as it sways.", + "rows": [ + { + "name": "Holo card", + "depth": 0, + "tracks": [ + { + "kind": "enter", + "label": "Spin in", + "start": 0.1, + "end": 1.314284894496285 + }, + { + "kind": "loop", + "label": "Sway", + "period": 4 + } + ] + }, + { + "name": "Star", + "depth": 1, + "tracks": [ + { + "kind": "loop", + "label": "Spin", + "period": 4 + }, + { + "kind": "param", + "label": "Hue shift", + "period": 4 + } + ] + }, + { + "name": "Card", + "depth": 1, + "tracks": [ + { + "kind": "param", + "label": "Hue shift", + "period": 4 + } + ] + } + ] + }, + { + "id": "glow-glass", + "title": "Glow glass", + "no": 7, + "material": "Glow glass", + "settings": [ + { + "label": "Rim intensity", + "value": 1.5, + "min": 0, + "max": 4, + "to": 2.6 + }, + { + "label": "Rim width", + "value": 14, + "min": 1, + "max": 120 + }, + { + "label": "Bevel", + "value": 40, + "min": 2, + "max": 200 + } + ], + "palette": [ + "#000000", + "#c859d4", + "#672574", + "#45184d", + "#270d2b" + ], + "bg": [ + "#000000" + ], + "duration": 4, + "layers": 1, + "flavor": "Glass letters rise in one by one as the glow drifts from blue to pink.", + "rows": [ + { + "name": "Word", + "depth": 0, + "tracks": [ + { + "kind": "enter", + "label": "Rise · 3 letters in turn", + "start": 0.1, + "end": 1.08, + "children": 3 + }, + { + "kind": "param", + "label": "Glow", + "period": 4 + }, + { + "kind": "param", + "label": "Rim intensity", + "period": 2 + } + ] + } + ] + }, + { + "id": "puffy", + "title": "Puffy", + "no": 8, + "material": "Puffy", + "settings": [ + { + "label": "Puff", + "value": 70, + "min": 4, + "max": 300 + }, + { + "label": "Gloss", + "value": 0.7, + "min": 0, + "max": 1.5 + }, + { + "label": "Depth", + "value": 0.9, + "min": 0.1, + "max": 3 + } + ], + "palette": [ + "#b49ef8", + "#7a68ef", + "#dfb6e9", + "#f5def3" + ], + "bg": [ + "#b8a6ff", + "#6f5cff" + ], + "duration": 4, + "layers": 7, + "flavor": "Inflated letters bounce in a wave while a cloud mascot floats and blinks.", + "rows": [ + { + "name": "Mascot", + "depth": 0, + "tracks": [ + { + "kind": "loop", + "label": "Float", + "period": 2 + } + ] + }, + { + "name": "Eyes", + "depth": 1, + "tracks": [ + { + "kind": "loop", + "label": "Blink", + "period": 2 + } + ] + }, + { + "name": "Word", + "depth": 0, + "tracks": [ + { + "kind": "loop", + "label": "Bounce", + "period": 1 + } + ] + }, + { + "name": "Sparkle", + "depth": 0, + "tracks": [ + { + "kind": "loop", + "label": "Pulse", + "period": 1 + } + ] + }, + { + "name": "Sparkle", + "depth": 0, + "tracks": [ + { + "kind": "loop", + "label": "Pulse", + "period": 1 + } + ] + }, + { + "name": "Sparkle", + "depth": 0, + "tracks": [ + { + "kind": "loop", + "label": "Pulse", + "period": 1 + } + ] + } + ] + }, + { + "id": "aura", + "title": "Aura", + "no": 9, + "material": "Aura gradient", + "settings": [ + { + "label": "Arrangement", + "value": 3, + "min": 0, + "max": 100, + "to": 4.5 + }, + { + "label": "Edge glow", + "value": 0.6, + "min": 0, + "max": 1 + }, + { + "label": "Outer glow", + "value": 0.25, + "min": 0, + "max": 1.5 + } + ], + "palette": [ + "#161616", + "#1422d1", + "#cbd268", + "#a93f43", + "#b6688d" + ], + "bg": [ + "#161616" + ], + "duration": 4, + "layers": 5, + "flavor": "Four mesh-gradient tiles grow in one by one, then their colour blobs drift.", + "rows": [ + { + "name": "Tiles", + "depth": 0, + "tracks": [ + { + "kind": "enter", + "label": "Grow · 4 in turn", + "start": 0.1, + "end": 1.2695232629975233, + "children": 4 + } + ] + }, + { + "name": "Tile 4", + "depth": 1, + "tracks": [ + { + "kind": "enter", + "label": "Grow", + "start": 0.45999999999999996, + "end": 1.2695232629975233, + "index": 3 + }, + { + "kind": "loop", + "label": "Float", + "period": 4 + }, + { + "kind": "param", + "label": "Arrangement", + "period": 4 + } + ] + }, + { + "name": "Tile 3", + "depth": 1, + "tracks": [ + { + "kind": "enter", + "label": "Grow", + "start": 0.33999999999999997, + "end": 1.1495232629975232, + "index": 2 + }, + { + "kind": "loop", + "label": "Float", + "period": 4 + }, + { + "kind": "param", + "label": "Arrangement", + "period": 4 + } + ] + }, + { + "name": "Tile 2", + "depth": 1, + "tracks": [ + { + "kind": "enter", + "label": "Grow", + "start": 0.22, + "end": 1.0295232629975233, + "index": 1 + }, + { + "kind": "loop", + "label": "Float", + "period": 4 + }, + { + "kind": "param", + "label": "Arrangement", + "period": 4 + } + ] + }, + { + "name": "Tile 1", + "depth": 1, + "tracks": [ + { + "kind": "enter", + "label": "Grow", + "start": 0.1, + "end": 0.9095232629975233, + "index": 0 + }, + { + "kind": "loop", + "label": "Float", + "period": 4 + }, + { + "kind": "param", + "label": "Arrangement", + "period": 4 + } + ] + } + ] + }, + { + "id": "pop-3d", + "title": "Pop 3D", + "no": 10, + "material": "Retro 3D", + "settings": [ + { + "label": "Depth", + "value": 46, + "min": 0, + "max": 120 + }, + { + "label": "Direction", + "value": 45, + "min": 0, + "max": 360 + }, + { + "label": "Side shading", + "value": 0.35, + "min": 0, + "max": 1 + } + ], + "palette": [ + "#b4f22e", + "#fe4ea3", + "#401f62", + "#bf9362" + ], + "bg": [ + "#b4f22e" + ], + "duration": 3, + "layers": 5, + "flavor": "Extruded type whose depth direction turns a full circle every loop.", + "rows": [ + { + "name": "Pop", + "depth": 0, + "tracks": [ + { + "kind": "enter", + "label": "Grow", + "start": 0.1, + "end": 0.9095232629975233 + }, + { + "kind": "loop", + "label": "Pulse", + "period": 0.75 + }, + { + "kind": "param", + "label": "Direction", + "period": 3 + } + ] + }, + { + "name": "Plus 4", + "depth": 0, + "tracks": [ + { + "kind": "loop", + "label": "Spin", + "period": 3 + } + ] + }, + { + "name": "Plus 3", + "depth": 0, + "tracks": [ + { + "kind": "loop", + "label": "Spin", + "period": 3 + } + ] + }, + { + "name": "Plus 2", + "depth": 0, + "tracks": [ + { + "kind": "loop", + "label": "Spin", + "period": 3 + } + ] + }, + { + "name": "Plus 1", + "depth": 0, + "tracks": [ + { + "kind": "loop", + "label": "Spin", + "period": 3 + } + ] + } + ] + } +]; diff --git a/website/styles.css b/website/styles.css index 3db315b..8c055ea 100644 --- a/website/styles.css +++ b/website/styles.css @@ -96,7 +96,7 @@ button { font: inherit; color: inherit; } transform: translateY(-6px); } .nav-links { display: flex; gap: 4px; margin-left: auto; } -.nav-links a { text-decoration: none; font: 600 15px var(--display); padding: 9px 12px; border-radius: 12px; color: var(--ink-2); transition: background 0.2s, color 0.2s; } +.nav-links a { white-space: nowrap; text-decoration: none; font: 600 15px var(--display); padding: 9px 12px; border-radius: 12px; color: var(--ink-2); transition: background 0.2s, color 0.2s; } .nav-links a:hover { background: var(--tint); color: var(--ink); } /* Buttons */ @@ -203,25 +203,6 @@ h2 { font-size: clamp(34px, 5.2vw, 58px); line-height: 1.02; letter-spacing: -0. .pasito.labelled button::after { background: color-mix(in srgb, var(--accent) 55%, transparent); } .pasito button:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; } -/* Fanned cards (after interfacecraft.dev) */ -.fan { position: relative; height: 420px; } -.card { - position: absolute; left: 50%; top: 50%; margin: 0; width: 280px; - transform: translate(-50%, -50%) translate(var(--x), var(--y)) rotate(var(--r)); - z-index: var(--z); cursor: pointer; outline: none; - border-radius: 20px; overflow: hidden; background: var(--card); - box-shadow: var(--shadow); border: 1px solid var(--line); - transition: transform 0.55s var(--spring), box-shadow 0.4s; -} -.card video { width: 100%; aspect-ratio: 3 / 2; object-fit: cover; background: var(--paper-2); } -.card figcaption { padding: 14px 16px 16px; } -.card b { display: block; font: 400 26px/1 var(--serif); letter-spacing: -0.01em; transition: font-size 0.4s var(--spring); } -.card span { display: block; margin-top: 6px; font-size: 14px; line-height: 1.4; color: var(--muted); } -.card:hover, .card:focus-visible { z-index: 10; transform: translate(-50%, -50%) translate(var(--x), calc(var(--y) - 22px)) rotate(0deg) scale(1.08); } -.card:hover b, .card:focus-visible b { font-size: 29px; } -.fan.spread .card { transition-delay: calc(var(--i, 0) * 60ms); } -.fan:not(.spread) .card { transform: translate(-50%, -50%) rotate(calc(var(--r) * 0.3)) scale(0.9); } - /* Features */ .feature-grid { list-style: none; padding: 0; margin: 0; display: grid; grid-template-columns: repeat(4, 1fr); gap: 52px 32px; } .feature-grid li { text-align: center; } @@ -233,37 +214,6 @@ h2 { font-size: clamp(34px, 5.2vw, 58px); line-height: 1.02; letter-spacing: -0. .picon rect.a { fill: var(--accent); } li:hover .picon rect, .platform:hover .picon rect { transform: translate(var(--dx, 0), var(--dy, 0)) scale(0.82); transition-delay: var(--d, 0s); } -/* Reel */ -.reel-frame { - position: relative; border-radius: 28px; overflow: hidden; aspect-ratio: 3 / 2; max-width: 960px; margin: 0 auto; - background: #0d0b14; box-shadow: var(--shadow); -} -.reel-media video { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; opacity: 0; transform: scale(1.04); transition: opacity 0.6s var(--ease), transform 0.9s var(--ease); } -.reel-media video.on { opacity: 1; transform: none; } -.reel-bar { position: absolute; left: 50%; bottom: 18px; transform: translateX(-50%); display: flex; gap: 8px; align-items: center; } -.pasito-glass { - --stage-ink: #fff; background: rgba(20, 18, 28, 0.38); border-color: rgba(255, 255, 255, 0.14); - backdrop-filter: blur(12px); -webkit-backdrop-filter: blur(12px); -} -.reel-play, .reel-nav { - display: grid; place-items: center; width: 34px; height: 34px; border-radius: 999px; cursor: pointer; - border: 1px solid rgba(255, 255, 255, 0.14); background: rgba(20, 18, 28, 0.38); color: #fff; - backdrop-filter: blur(12px); -webkit-backdrop-filter: blur(12px); - transition: transform 0.3s var(--spring), background 0.2s; -} -.reel-play:hover, .reel-nav:hover { background: rgba(20, 18, 28, 0.6); } -.reel-play:active, .reel-nav:active { transform: scale(0.9); } -.reel-play svg, .reel-nav svg { width: 14px; height: 14px; fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; } -.reel-play .i-play { display: none; fill: currentColor; stroke: none; } -.reel-play.paused .i-play { display: block; } -.reel-play.paused .i-pause { display: none; } -.reel-nav { position: absolute; top: 50%; margin-top: -17px; opacity: 0; } -.reel-frame:hover .reel-nav, .reel-nav:focus-visible { opacity: 1; } -.reel-nav.prev { left: 16px; } -.reel-nav.next { right: 16px; } -.reel-caption { text-align: center; margin: 18px 0 0; color: var(--muted); font-size: 15px; min-height: 1.5em; } -.reel-caption b { color: var(--ink); font-weight: 400; font-family: var(--serif); font-size: 20px; } - /* Editor shots */ .editor { text-align: center; } .seg { position: relative; display: inline-flex; padding: 4px; border-radius: 14px; background: var(--tint); border: 1px solid var(--line); margin-bottom: 28px; } @@ -319,14 +269,196 @@ li:hover .picon rect, .platform:hover .picon rect { transform: translate(var(--d .foot-links a { text-decoration: none; } .foot-links a:hover { color: var(--ink); } +/* Section subtitle */ +.sub { max-width: 560px; margin: 18px auto 0; color: var(--muted); font-size: 17px; } + +/* The material deck: a hand of collectible cards */ +.hand { position: relative; height: 520px; } +.tcard { + --w: 212px; + position: absolute; left: 50%; bottom: 36px; width: var(--w); aspect-ratio: 5 / 7; margin-left: calc(var(--w) / -2); + transform-origin: 50% 240%; + transform: rotate(var(--a, 0deg)) translateY(var(--lift, 0px)) scale(var(--sc, 1)); + transition: transform 0.65s var(--spring), opacity 0.4s var(--ease); + transition-delay: var(--delay, 0ms); + z-index: var(--z, 1); cursor: pointer; outline: none; + container-type: inline-size; + -webkit-tap-highlight-color: transparent; +} +.hand:not(.dealt) .tcard { transform: translateY(220px) rotate(calc(var(--a, 0deg) * 0.15)) scale(0.86); opacity: 0; } +.tcard:focus-visible .face { outline: 3px solid var(--accent); outline-offset: 3px; } +.tcard-tilt { position: absolute; inset: 0; transform: perspective(900px) rotateX(var(--rx, 0deg)) rotateY(var(--ry, 0deg)); transform-style: preserve-3d; } +.tcard-flip { position: absolute; inset: 0; transform-style: preserve-3d; transition: transform 0.9s var(--spring); } +.tcard.flipped .tcard-flip { transform: rotateY(180deg); } +.face { + position: absolute; inset: 0; border-radius: 6cqw; overflow: hidden; + backface-visibility: hidden; -webkit-backface-visibility: hidden; + padding: 2.6cqw; background: linear-gradient(135deg, var(--rimA), var(--rimB) 38%, var(--rimA) 52%, var(--rimB) 78%, var(--rimA)); + box-shadow: 0 0.6cqw 1.2cqw rgba(20, 10, 0, 0.16), 0 10cqw 18cqw -8cqw rgba(20, 10, 30, calc(0.35 + var(--hov, 0) * 0.25)); + transition: box-shadow 0.4s; +} +.face.back { transform: rotateY(180deg); } +.panel { + position: relative; height: 100%; border-radius: 4.2cqw; overflow: hidden; + display: flex; flex-direction: column; gap: 2.6cqw; padding: 4.4cqw 4.4cqw 4cqw; + background: radial-gradient(120% 70% at 30% 0%, var(--c1l), transparent 60%), linear-gradient(170deg, var(--c1), var(--c1d)); + color: var(--fg); +} +.tc-head { display: flex; align-items: baseline; justify-content: space-between; gap: 2cqw; } +.tc-name { font: 400 9.4cqw/1 var(--serif); letter-spacing: -0.02em; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.tc-no { font: 3.6cqw var(--pixel); opacity: 0.7; white-space: nowrap; } +.tc-art { position: relative; aspect-ratio: 3 / 2; border-radius: 3cqw; overflow: hidden; background: #000; box-shadow: 0 0 0 0.5cqw rgba(255, 255, 255, 0.35), 0 1.4cqw 3cqw -1cqw rgba(0, 0, 0, 0.5); } +.tc-art img, .tc-art video { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; } +.tc-art video { opacity: 0; transition: opacity 0.3s; } +.tc-art video.playing { opacity: 1; } +.tc-type { display: flex; align-items: center; justify-content: space-between; gap: 2cqw; } +.tc-chip { font: 3.5cqw var(--pixel); text-transform: uppercase; letter-spacing: 0.03em; padding: 1.1cqw 2.2cqw; border-radius: 1.6cqw; background: var(--chip); white-space: nowrap; } +.tc-meta { font-size: 3.8cqw; opacity: 0.72; white-space: nowrap; } +.tc-stats { list-style: none; margin: 0; padding: 0; display: grid; gap: 1.6cqw; } +.tc-stats li { display: grid; grid-template-columns: 1fr 30cqw 11cqw; align-items: center; gap: 2cqw; font-size: 3.8cqw; } +.tc-stats span { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; opacity: 0.85; } +.tc-stats b { font: 3.4cqw var(--pixel); text-align: right; } +.meter { + height: 2.6cqw; + background: linear-gradient(90deg, var(--fg) calc(var(--v) * 100%), color-mix(in srgb, var(--fg) 18%, transparent) 0); + -webkit-mask: repeating-linear-gradient(90deg, #000 0 2cqw, transparent 2cqw 2.6cqw); + mask: repeating-linear-gradient(90deg, #000 0 2cqw, transparent 2cqw 2.6cqw); +} +.meter.to { background: linear-gradient(90deg, var(--fg) calc(var(--v) * 100%), color-mix(in srgb, var(--fg) 55%, transparent) 0 calc(var(--v2) * 100%), color-mix(in srgb, var(--fg) 18%, transparent) 0); } +.tc-flavor { margin: auto 0 0; font: 400 4.5cqw/1.3 var(--serif); opacity: 0.86; } +.tc-foot { display: flex; align-items: center; justify-content: space-between; padding-top: 2.2cqw; border-top: 0.3cqw solid color-mix(in srgb, var(--fg) 22%, transparent); } +.tc-sw { display: flex; } +.tc-sw i { width: 4.4cqw; height: 4.4cqw; border-radius: 50%; margin-right: -1cqw; box-shadow: 0 0 0 0.5cqw var(--c1); } +.tc-mark { font: 3.1cqw var(--pixel); letter-spacing: 0.06em; opacity: 0.6; } + +/* Foil and glare, driven by the pointer */ +.foil, .glare { position: absolute; inset: 0; border-radius: inherit; pointer-events: none; } +.panel > :not(.foil) { position: relative; z-index: 1; } +.foil { + mix-blend-mode: color-dodge; opacity: calc(0.22 + var(--hov, 0) * 0.65); + background: repeating-linear-gradient(118deg, #ff7eb6 0%, #ffd27e 6%, #9cff9e 12%, #7ee8ff 18%, #b18cff 24%, #ff7eb6 30%); + background-size: 260% 260%; background-position: var(--fx, 50%) var(--fy, 50%); + -webkit-mask-image: var(--sparkle); mask-image: var(--sparkle); -webkit-mask-size: 90px 90px; mask-size: 90px 90px; + filter: brightness(0.75) saturate(1.3); transition: opacity 0.4s; +} +.glare { mix-blend-mode: overlay; opacity: var(--hov, 0); background: radial-gradient(farthest-corner circle at var(--gx, 50%) var(--gy, 50%), rgba(255, 255, 255, 0.85), rgba(255, 255, 255, 0.2) 28%, rgba(0, 0, 0, 0.25) 90%); transition: opacity 0.4s; } + +/* The back of a card: its real timeline */ +.back .panel { background: #15131c; color: #ece8f4; background-image: radial-gradient(rgba(255, 255, 255, 0.07) 0.5cqw, transparent 0.6cqw); background-size: 3.2cqw 3.2cqw; } +.bk-top { display: flex; align-items: center; gap: 2.6cqw; } +.bk-top img { width: 11cqw; height: 11cqw; border-radius: 2.6cqw; } +.bk-top b { display: block; font: 400 7cqw/1 var(--serif); } +.bk-top span { font: 3.2cqw var(--pixel); opacity: 0.6; text-transform: uppercase; } +.bk-tl { display: grid; gap: 1.2cqw; margin-top: 1cqw; } +.bk-ruler { display: flex; justify-content: space-between; margin-left: 26cqw; font: 2.8cqw var(--pixel); opacity: 0.5; } +.bk-row { display: grid; grid-template-columns: 24cqw 1fr; gap: 2cqw; align-items: center; } +.bk-row > span { font-size: 3.2cqw; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; opacity: 0.8; } +.bk-row[data-depth='1'] > span { padding-left: 2.4cqw; opacity: 0.6; } +.bk-row[data-depth='2'] > span { padding-left: 4.8cqw; opacity: 0.5; } +.bk-lanes { display: grid; gap: 0.8cqw; } +.bk-lane { position: relative; height: 2.8cqw; border-radius: 1cqw; background: rgba(255, 255, 255, 0.05); } +.bk-lane i { position: absolute; top: 0; bottom: 0; border-radius: 1cqw; } +.bk-more { font-size: 3.2cqw; opacity: 0.5; } +.bk-legend { display: flex; flex-wrap: wrap; gap: 1.4cqw 3.4cqw; font-size: 3.2cqw; opacity: 0.75; margin-top: 1cqw; } +.bk-legend span { display: inline-flex; align-items: center; gap: 1.4cqw; } +.bk-legend i { width: 4.6cqw; height: 2.6cqw; border-radius: 0.8cqw; --p: 34%; } +.bk-facts { display: grid; grid-template-columns: repeat(4, 1fr); gap: 1.6cqw; margin-top: auto; } +.bk-facts div { padding: 2.4cqw 2cqw; border-radius: 2.4cqw; background: rgba(255, 255, 255, 0.05); box-shadow: inset 0 0 0 0.3cqw rgba(255, 255, 255, 0.06); } +.bk-facts b { display: block; font: 7.4cqw/1 var(--pixel); color: #fff; } +.bk-facts span { font: 2.7cqw var(--pixel); text-transform: uppercase; opacity: 0.55; } +.bk-foot { font-size: 3.6cqw; line-height: 1.35; opacity: 0.8; } +.ibtn:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; } +.bk-foot b { font-weight: 400; color: #c9bbff; } + +/* Track colours, shared by card backs and the timeline */ +.k-enter { background: #7a5cff; } +.k-leave { background: #ff5fae; } +.k-loop { background: rgba(79, 180, 255, 0.25) linear-gradient(90deg, rgba(120, 200, 255, 0.9) 0 2px, transparent 2px) 0 0 / var(--p, 25%) 100% repeat-x; box-shadow: inset 0 0 0 1px rgba(79, 180, 255, 0.5); } +.k-param { background: linear-gradient(90deg, rgba(255, 194, 61, 0.65), rgba(255, 194, 61, 0.12) 50%, rgba(255, 194, 61, 0.65)) 0 0 / var(--p, 25%) 100% repeat-x; box-shadow: inset 0 0 0 1px rgba(255, 194, 61, 0.5); } +.k-flow { background: rgba(200, 245, 60, 0.1) repeating-linear-gradient(135deg, rgba(200, 245, 60, 0.26) 0 3px, transparent 3px 8px); box-shadow: inset 0 0 0 1px rgba(200, 245, 60, 0.45); } + +/* Inspector */ +.inspector { position: fixed; inset: 0; z-index: 100; display: grid; place-items: center; } +.inspector[hidden] { display: none; } +.inspector-backdrop { position: absolute; inset: 0; background: color-mix(in srgb, var(--paper) 62%, transparent); backdrop-filter: blur(20px) saturate(1.3); -webkit-backdrop-filter: blur(20px) saturate(1.3); opacity: 0; transition: opacity 0.45s var(--ease); } +.inspector.open .inspector-backdrop { opacity: 1; } +.inspector-stage { position: relative; width: min(400px, 84vw, calc((100dvh - 150px) * 5 / 7)); aspect-ratio: 5 / 7; margin-bottom: 56px; } +.inspector-stage .tcard { position: absolute; inset: 0; left: 0; bottom: auto; width: 100%; margin: 0; transform: none; transition: none; --w: 100%; cursor: grab; } +.inspector-bar { position: absolute; bottom: max(20px, env(safe-area-inset-bottom)); left: 50%; display: flex; gap: 8px; transform: translate(-50%, 12px); opacity: 0; transition: opacity 0.3s var(--ease) 0.15s, transform 0.5s var(--spring) 0.15s; } +.inspector.open .inspector-bar { opacity: 1; transform: translate(-50%, 0); } +.ibtn { display: grid; place-items: center; height: 42px; min-width: 42px; padding: 0 12px; border-radius: 999px; cursor: pointer; border: 1px solid var(--line); background: var(--card); color: var(--ink); box-shadow: var(--shadow); transition: transform 0.3s var(--spring); } +.ibtn:active { transform: scale(0.92); } +.ibtn.wide { padding: 0 20px; } +.ibtn svg { width: 14px; height: 14px; fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; } + +/* The motion timeline */ +.tl { max-width: 980px; margin: 0 auto; border-radius: 20px; overflow: hidden; background: #141319; color: #ece8f4; box-shadow: var(--shadow), inset 0 0 0 1px rgba(255, 255, 255, 0.06); } +.tl-bar { display: flex; align-items: center; gap: 12px; padding: 10px 14px; background: #1c1b23; border-bottom: 1px solid rgba(255, 255, 255, 0.06); } +.tl-play { display: grid; place-items: center; width: 30px; height: 30px; border-radius: 9px; border: 0; cursor: pointer; background: rgba(255, 255, 255, 0.08); color: #fff; transition: background 0.2s, transform 0.3s var(--spring); } +.tl-play:hover { background: rgba(255, 255, 255, 0.14); } +.tl-play:active { transform: scale(0.9); } +.tl-play svg { width: 13px; height: 13px; fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; } +.tl-play .i-play { display: none; fill: currentColor; stroke: none; } +.tl-play.paused .i-play { display: block; } +.tl-play.paused .i-pause { display: none; } +.tl-title { font: 600 14px var(--display); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.tl-time { margin-left: auto; color: #a49fb3; white-space: nowrap; } +.tl-stage { position: relative; aspect-ratio: 3 / 2; max-height: 540px; margin: 0 auto; background: #000; } +.tl-stage video, .tl-fx { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: contain; } +.tl-fx { image-rendering: pixelated; opacity: 0; pointer-events: none; } +.tl-fx.on { opacity: 1; } +.tl-dock { --name: 150px; position: relative; padding-bottom: 8px; background: #17161d; border-top: 1px solid rgba(255, 255, 255, 0.06); user-select: none; -webkit-user-select: none; touch-action: pan-y; cursor: ew-resize; } +.tl-ruler { position: relative; height: 26px; margin: 0 14px 0 var(--name); border-bottom: 1px solid rgba(255, 255, 255, 0.06); } +.tl-ruler span { position: absolute; top: 6px; transform: translateX(-50%); font: 10px var(--pixel); color: #8d879c; } +.tl-ruler span:first-child { transform: none; } +.tl-ruler i { position: absolute; bottom: 0; width: 1px; height: 4px; background: rgba(255, 255, 255, 0.16); } +.tl-ruler i.major { height: 8px; background: rgba(255, 255, 255, 0.36); } +.tl-row { display: flex; border-bottom: 1px solid rgba(255, 255, 255, 0.035); } +.tl-name { flex: none; width: var(--name); padding: 6px 12px 0 14px; font-size: 12.5px; color: #d3cddf; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.tl-row[data-depth='1'] .tl-name { padding-left: 28px; color: #a9a3b6; } +.tl-row[data-depth='2'] .tl-name { padding-left: 42px; color: #8f899c; } +.tl-lanes { position: relative; flex: 1; display: grid; gap: 3px; padding: 4px 14px 4px 0; } +.tl-lane { position: relative; height: 20px; } +.tl-lane.empty { height: 20px; } +.clip { position: absolute; top: 0; bottom: 0; border-radius: 6px; padding: 0 7px; font: 600 10.5px/20px var(--display); color: #fff; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; transition: filter 0.2s, box-shadow 0.25s; } +.clip.k-loop { color: #cfeaff; } +.clip.k-param { color: #ffe6ad; } +.clip.k-flow { color: #e6ffa6; animation: flow 1.1s linear infinite; } +.clip.live { filter: brightness(1.25) saturate(1.1); box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.6), 0 0 16px rgba(150, 120, 255, 0.55); } +.clip .step { position: absolute; top: 4px; bottom: 4px; width: 1px; background: rgba(255, 255, 255, 0.55); } +@keyframes flow { to { background-position: 11.3px 0; } } +.tl-head { position: absolute; top: 0; bottom: 0; left: var(--name); right: 14px; pointer-events: none; } +.tl-head i { position: absolute; top: 0; bottom: 0; left: calc(var(--t, 0) * 100%); width: 2px; margin-left: -1px; background: #ff5fae; box-shadow: 0 0 10px rgba(255, 95, 174, 0.6); } +.tl-head i::before { content: ''; position: absolute; top: 0; left: -5px; width: 12px; height: 11px; background: #ff5fae; clip-path: polygon(0 0, 100% 0, 100% 55%, 50% 100%, 0 55%); } +.tl-picker { display: flex; flex-wrap: wrap; justify-content: center; gap: 8px; max-width: 980px; margin: 22px auto 0; } +.tl-chip { position: relative; display: flex; align-items: center; gap: 8px; padding: 5px 12px 5px 5px; border-radius: 12px; overflow: hidden; cursor: pointer; border: 1px solid var(--line); background: var(--card); color: var(--ink-2); font: 600 13.5px var(--display); transition: transform 0.35s var(--spring), border-color 0.2s, color 0.2s; } +.tl-chip img { width: 36px; height: 24px; border-radius: 6px; object-fit: cover; } +.tl-chip:hover { transform: translateY(-2px); color: var(--ink); } +.tl-chip[aria-selected='true'] { color: var(--ink); border-color: color-mix(in srgb, var(--accent) 70%, transparent); } +.tl-chip::after { content: ''; position: absolute; left: 0; bottom: 0; height: 2px; width: calc(var(--prog, 0) * 100%); background: var(--accent); } + /* Phones and tablets */ @media (max-width: 980px) { + .tcard { --w: 180px; } + .hand { height: 470px; } .feature-grid { grid-template-columns: repeat(2, 1fr); } - .fan { height: auto; display: flex; gap: 16px; overflow-x: auto; scroll-snap-type: x mandatory; padding: 12px 16px 28px; margin: 0 -16px; scrollbar-width: none; } - .fan::-webkit-scrollbar { display: none; } - .card, .fan:not(.spread) .card, .card:hover, .card:focus-visible { position: relative; left: auto; top: auto; flex: 0 0 76%; max-width: 320px; transform: rotate(calc(var(--r) * 0.25)); scroll-snap-align: center; } +} +@media (max-width: 820px) { + .nav-links { display: none; } + .nav-cta { margin-left: auto; } } @media (max-width: 720px) { + .hand { height: auto; display: flex; gap: 14px; overflow-x: auto; scroll-snap-type: x mandatory; padding: 18px 16px 34px; margin: 0 -16px; scrollbar-width: none; } + .hand::-webkit-scrollbar { display: none; } + .tcard, .hand:not(.dealt) .tcard { --w: 68vw; position: relative; left: auto; bottom: auto; margin: 0; flex: 0 0 var(--w); max-width: 300px; transform: none; opacity: 1; scroll-snap-align: center; } + .tl-dock { --name: 86px; } + .tl-name { font-size: 11px; padding-left: 10px; } + .tl-row[data-depth='1'] .tl-name { padding-left: 16px; } + .tl-row[data-depth='2'] .tl-name { padding-left: 22px; } + .clip { font-size: 9.5px; padding: 0 5px; } + .tl-picker { flex-wrap: nowrap; justify-content: flex-start; overflow-x: auto; margin: 18px -16px 0; padding: 0 16px 6px; scrollbar-width: none; } + .tl-chip { flex: none; } + .sub { font-size: 15.5px; } .nav { gap: 12px; } .nav-links { display: none; } .nav-cta { margin-left: auto; } @@ -342,10 +474,6 @@ li:hover .picon rect, .platform:hover .picon rect { transform: translate(var(--d .feature-grid { gap: 40px 18px; } .feature-grid h3 { font-size: 18px; } .feature-grid p { font-size: 14px; } - .reel-frame { border-radius: 20px; } - .reel-nav { opacity: 1; width: 30px; height: 30px; margin-top: -15px; } - .reel-bar { bottom: 12px; } - .pasito-glass { --active: 18px; --gap: 5px; padding: 7px 10px; } } @media (prefers-reduced-motion: reduce) { diff --git a/website/timeline.js b/website/timeline.js new file mode 100644 index 0000000..d0e3733 --- /dev/null +++ b/website/timeline.js @@ -0,0 +1,213 @@ +// The motion section: a showcase loop above its real timeline rows, laid out +// the way the app's timeline dock lays them out. The playhead follows the +// video, dragging across the dock scrubs it, and switching showcases +// pixelates out of one and into the next. +import { SHOWCASES } from './showcases.js'; + +const reduced = matchMedia('(prefers-reduced-motion: reduce)').matches; +const $ = (s, el = document) => el.querySelector(s); +const clamp = (v, a, b) => (v < a ? a : v > b ? b : v); +const esc = (s) => String(s).replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[c]); +const FPS = 30; + +function rowsHTML(s) { + const D = s.duration; + return s.rows + .map((r) => { + const lanes = (r.tracks.length ? r.tracks : [null]) + .map((t) => { + if (!t) return '
    '; + if (t.start != null) { + const l = (t.start / D) * 100, w = Math.max(2, ((t.end - t.start) / D) * 100); + const steps = (t.steps ?? []).slice(1).map((st) => ``).join(''); + return `
    ${esc(t.label)}${steps}
    `; + } + const p = t.period ? (t.period / D) * 100 : 100; + const tip = t.period ? `${t.label} · every ${+t.period.toFixed(2)} s` : t.label; + return `
    ${esc(t.label)}
    `; + }) + .join(''); + return `
    ${esc(r.name)}
    ${lanes}
    `; + }) + .join(''); +} + +function rulerHTML(D) { + let out = ''; + for (let f = 0; f <= D * FPS; f += FPS / 4) { + const t = f / FPS, x = (t / D) * 100; + const major = f % FPS === 0; + out += ``; + if (major && t < D) out += `${t}s`; + } + return out; +} + +export function timeline() { + const root = $('[data-tl]'); + const video = $('[data-tl-video]', root); + const fx = $('[data-tl-fx]', root); + const fctx = fx.getContext('2d'); + const tiny = document.createElement('canvas'); + const tctx = tiny.getContext('2d'); + const dock = $('[data-tl-dock]', root); + const head = $('[data-tl-head]', root); + const rowsEl = $('[data-tl-rows]', root); + const ruler = $('[data-tl-ruler]', root); + const title = $('[data-tl-title]', root); + const time = $('[data-tl-time]', root); + const playBtn = $('[data-tl-play]', root); + const picker = $('[data-tl-picker]'); + + let cur = 0, clips = [], inView = false, userPaused = false, scrubbing = false, switching = false; + let loops = 0, lastT = 0, touched = 0; + + const chips = SHOWCASES.map((s, i) => { + const b = document.createElement('button'); + b.type = 'button'; + b.className = 'tl-chip'; + b.setAttribute('role', 'tab'); + b.setAttribute('aria-selected', String(i === 0)); + b.innerHTML = `${esc(s.title)}`; + b.addEventListener('click', () => { + touched = performance.now(); + show(i); + }); + picker.append(b); + return b; + }); + + const render = (i) => { + const s = SHOWCASES[i]; + title.textContent = `Animated · ${s.title}`; + ruler.innerHTML = rulerHTML(s.duration); + rowsEl.innerHTML = rowsHTML(s); + clips = [...rowsEl.querySelectorAll('.clip[data-start]')].map((el) => ({ el, a: +el.dataset.start, b: +el.dataset.end })); + chips.forEach((c, k) => { + c.setAttribute('aria-selected', String(k === i)); + c.style.setProperty('--prog', 0); + }); + }; + + /** Draw the current video frame in blocks of `size` pixels. */ + const pixelate = (size) => { + const w = (fx.width = fx.clientWidth), h = (fx.height = fx.clientHeight); + const tw = Math.max(1, Math.round(w / size)), th = Math.max(1, Math.round(h / size)); + tiny.width = tw; + tiny.height = th; + tctx.drawImage(video, 0, 0, tw, th); + fctx.imageSmoothingEnabled = false; + fctx.drawImage(tiny, 0, 0, w, h); + }; + const tween = (ms, fn) => + new Promise((res) => { + const t0 = performance.now(); + const step = (now) => { + const k = clamp((now - t0) / ms, 0, 1); + fn(k); + k < 1 ? requestAnimationFrame(step) : res(); + }; + requestAnimationFrame(step); + }); + + const load = (i) => + new Promise((res) => { + video.addEventListener('loadeddata', res, { once: true }); + video.src = `/media/${SHOWCASES[i].id}.mp4`; + }); + + const show = async (i) => { + if (switching || i === cur) return; + switching = true; + const animate = !reduced && video.readyState >= 2; + if (animate) { + fx.classList.add('on'); + await tween(260, (k) => pixelate(1 + k * k * 40)); + } + cur = i; + loops = 0; + await load(i); + render(i); + if (inView && !userPaused) video.play().catch(() => {}); + if (animate) { + await tween(360, (k) => pixelate(1 + (1 - k) ** 2 * 40)); + fx.classList.remove('on'); + } + switching = false; + }; + + // Playhead, readout, live clips and the chip's progress follow the video. + const tick = () => { + const D = SHOWCASES[cur].duration; + const t = clamp(video.currentTime || 0, 0, D); + head.style.setProperty('--t', (t / D).toFixed(4)); + time.textContent = `${t.toFixed(2)} s · f${Math.round(t * FPS)}`; + for (const c of clips) c.el.classList.toggle('live', t >= c.a && t <= c.b); + chips[cur].style.setProperty('--prog', (t / D).toFixed(3)); + if (t + 0.5 < lastT) loops++; + lastT = t; + // Left alone, it moves on to the next showcase after a couple of loops. + if (loops >= 2 && !scrubbing && !userPaused && performance.now() - touched > 15000) show((cur + 1) % SHOWCASES.length); + if (inView) requestAnimationFrame(tick); + }; + + // Scrub by dragging anywhere across the dock. + const timeAt = (x) => { + const r = head.getBoundingClientRect(); + return clamp((x - r.left) / r.width, 0, 1) * SHOWCASES[cur].duration; + }; + let wasPlaying = false, sx = 0, armed = false; + dock.addEventListener('pointerdown', (e) => { + sx = e.clientX; + armed = true; + if (e.pointerType === 'mouse') begin(e); + }); + const begin = (e) => { + scrubbing = true; + touched = performance.now(); + wasPlaying = !video.paused; + video.pause(); + video.currentTime = timeAt(e.clientX); + dock.setPointerCapture(e.pointerId); + }; + dock.addEventListener('pointermove', (e) => { + if (!scrubbing && armed && e.pointerType !== 'mouse' && Math.abs(e.clientX - sx) > 8) begin(e); + if (scrubbing) video.currentTime = timeAt(e.clientX); + }); + const end = () => { + armed = false; + if (!scrubbing) return; + scrubbing = false; + lastT = video.currentTime; + if (wasPlaying && !userPaused) video.play().catch(() => {}); + }; + dock.addEventListener('pointerup', end); + dock.addEventListener('pointercancel', end); + + playBtn.addEventListener('click', () => { + userPaused = !video.paused ? true : false; + playBtn.classList.toggle('paused', userPaused); + playBtn.setAttribute('aria-label', userPaused ? 'Play' : 'Pause'); + userPaused ? video.pause() : video.play().catch(() => {}); + }); + + video.loop = true; + video.muted = true; + render(0); + load(0); + new IntersectionObserver( + ([en]) => { + const was = inView; + inView = en.isIntersecting; + if (inView && !was) { + if (!userPaused && !reduced) video.play().catch(() => {}); + requestAnimationFrame(tick); + } else if (!inView) video.pause(); + }, + { threshold: 0.3 }, + ).observe(root); + if (reduced) { + userPaused = true; + playBtn.classList.add('paused'); + } +}