diff --git a/README.md b/README.md
index 3204146..48a9db0 100644
--- a/README.md
+++ b/README.md
@@ -2,6 +2,8 @@
A type and logo lab for macOS, Windows and the web. Morph one typeface into another, reshape lettering with a stack of modifiers, draw by hand, put photos inside any shape, trace images into vector marks, compile the result into a real installable font, and render it as liquid chrome, glow glass, puffy 3D, halftone, dither, rubber stamp or neon, all in real time on the GPU.
+**Website:** [morphotype.srivtx.xyz](https://morphotype.srivtx.xyz)
+

## Install
@@ -155,6 +157,7 @@ ui/src/render WebGL2 renderer, SDF passes and effect shaders
ui/src/components Editor UI
ui/public/fonts Bundled OFL fonts
scripts WASM build and end-to-end test
+website Landing page (static; Vercel deploys it from main to morphotype.srivtx.xyz)
```
## Fonts and licences
diff --git a/website/.gitignore b/website/.gitignore
new file mode 100644
index 0000000..245259b
--- /dev/null
+++ b/website/.gitignore
@@ -0,0 +1,2 @@
+.vercel
+.env*
diff --git a/website/deck.js b/website/deck.js
new file mode 100644
index 0000000..c7067ae
--- /dev/null
+++ b/website/deck.js
@@ -0,0 +1,407 @@
+// The poster deck: one collectible card per new animated poster, fanned out.
+// Cards tilt toward the pointer under a foil, play their loop on hover (one at
+// a time), and open in an inspector where they turn over to show their timeline.
+import { DECK, SHOWCASES } from './showcases.js?v=3';
+
+const CARDS = DECK.map((id) => SHOWCASES.find((s) => s.id === id));
+
+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, i) {
+ 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(i + 1).padStart(2, '0')}/${String(CARDS.length).padStart(2, '0')}
+
+
${esc(s.material)} ${s.duration.toFixed(1)} s · ${s.layers} layers
+
+
${esc(s.flavor)}
+
+
`;
+}
+
+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 ``;
+ });
+ 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
+
+ ${more}
+
${legend}
+ ${facts}
+
+
`;
+}
+
+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 = ``;
+ 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 fan ---------- */
+
+/** Where each card sits in the fan, after ref 10: a loose, overlapping spread. */
+const FAN = [
+ { a: -7, y: 34 },
+ { a: 4, y: 52 },
+ { a: -3, y: 6 },
+ { a: 2, y: 0 },
+ { a: 5, y: 44 },
+ { a: -4, y: 16 },
+];
+
+export function deck() {
+ sparkle();
+ const hand = $('[data-hand]');
+ const cards = CARDS.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;
+ // Overlap more when the fan would be wider than the page.
+ const spacing = () => {
+ const w = cards[0].offsetWidth;
+ return Math.min(w * 0.8, (hand.clientWidth - w * 1.15) / (cards.length - 1));
+ };
+ let hover = -1;
+
+ const layout = () => {
+ if (narrow()) return;
+ const n = cards.length;
+ cards.forEach((c, i) => {
+ const f = FAN[i % FAN.length];
+ const on = i === hover;
+ c.style.setProperty('--x', `${((i - (n - 1) / 2) * spacing()).toFixed(1)}px`);
+ c.style.setProperty('--a', `${on ? f.a * 0.35 : f.a}deg`);
+ c.style.setProperty('--lift', `${on ? f.y - 48 : f.y}px`);
+ c.style.setProperty('--sc', on ? 1.07 : 1);
+ c.style.setProperty('--z', on ? 60 : i + 1);
+ });
+ };
+
+ const setHover = (i) => {
+ if (i === hover) return;
+ if (hover >= 0) tilts[hover].leave();
+ hover = i;
+ layout();
+ };
+
+ // Which card the pointer is on, by fixed columns across the fan: lifting a
+ // card never moves the boundaries, so hovering one can't hand off to another.
+ const pick = (e) => {
+ const r = hand.getBoundingClientRect();
+ const n = cards.length, w = spacing();
+ const i = Math.round((e.clientX - (r.left + r.width / 2)) / w + (n - 1) / 2);
+ const edge = Math.abs(e.clientX - (r.left + r.width / 2)) <= w * (n / 2) + cards[0].offsetWidth * 0.1;
+ return edge && i >= 0 && i < n ? i : -1;
+ };
+ hand.addEventListener('pointermove', (e) => {
+ if (e.pointerType !== 'mouse' || narrow()) return;
+ const i = pick(e);
+ setHover(i);
+ if (i >= 0) tilts[i].move(e);
+ });
+ hand.addEventListener('pointerleave', () => !narrow() && setHover(-1));
+ hand.addEventListener('click', (e) => {
+ if (narrow()) return;
+ const i = pick(e);
+ if (i >= 0) inspector.open(i, cards[i]);
+ });
+
+ cards.forEach((c, i) => {
+ c.style.setProperty('--delay', `${i * 70}ms`);
+ c.addEventListener('focus', () => !narrow() && setHover(i));
+ // Phones: the cards are a swipeable row and take their own taps.
+ c.addEventListener('click', () => narrow() && inspector.open(i, c));
+ c.addEventListener('keydown', (e) => {
+ if (e.key === 'Enter' || e.key === ' ') {
+ e.preventDefault();
+ inspector.open(i, c);
+ }
+ });
+ });
+ hand.addEventListener('focusout', (e) => !hand.contains(e.relatedTarget) && setHover(-1));
+ addEventListener('resize', layout);
+ layout();
+
+ // Deal the fan 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);
+
+ // The whole fan plays while it's on screen, like v1's cards, and pauses
+ // once it scrolls away. On phones the fan is a swipeable row: only the card
+ // nearest the middle plays.
+ let onScreen = false, playing = -1;
+ const centred = () => {
+ if (!narrow()) return cards.forEach((c) => playIn(c, onScreen));
+ const r = hand.getBoundingClientRect(), mid = r.left + r.width / 2;
+ let best = -1, dist = Infinity;
+ cards.forEach((c, i) => {
+ const b = c.getBoundingClientRect(), d = Math.abs(b.left + b.width / 2 - mid);
+ if (d < dist) (best = i), (dist = d);
+ });
+ const next = onScreen ? best : -1;
+ if (next === playing) return;
+ if (playing >= 0) playIn(cards[playing], false);
+ playing = next;
+ if (next >= 0) playIn(cards[next], true);
+ };
+ new IntersectionObserver(([en]) => ((onScreen = en.isIntersecting), centred()), { threshold: 0.4 }).observe(hand);
+ let settle = 0;
+ hand.addEventListener('scroll', () => (clearTimeout(settle), (settle = setTimeout(centred, 120))), { passive: true });
+
+ 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 = CARDS[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/fonts/Anton-Regular.woff b/website/fonts/Anton-Regular.woff
new file mode 100644
index 0000000..e333fde
Binary files /dev/null and b/website/fonts/Anton-Regular.woff differ
diff --git a/website/fonts/BricolageGrotesque-Variable.woff b/website/fonts/BricolageGrotesque-Variable.woff
new file mode 100644
index 0000000..5db69c2
Binary files /dev/null and b/website/fonts/BricolageGrotesque-Variable.woff differ
diff --git a/website/fonts/Bungee-Regular.woff b/website/fonts/Bungee-Regular.woff
new file mode 100644
index 0000000..1c63b20
Binary files /dev/null and b/website/fonts/Bungee-Regular.woff differ
diff --git a/website/fonts/CaveatBrush-Regular.woff b/website/fonts/CaveatBrush-Regular.woff
new file mode 100644
index 0000000..d0763d4
Binary files /dev/null and b/website/fonts/CaveatBrush-Regular.woff differ
diff --git a/website/fonts/DelaGothicOne-Regular.woff b/website/fonts/DelaGothicOne-Regular.woff
new file mode 100644
index 0000000..c5bba74
Binary files /dev/null and b/website/fonts/DelaGothicOne-Regular.woff differ
diff --git a/website/fonts/EBGaramond-Regular.woff b/website/fonts/EBGaramond-Regular.woff
new file mode 100644
index 0000000..7a24540
Binary files /dev/null and b/website/fonts/EBGaramond-Regular.woff differ
diff --git a/website/fonts/Inter-Regular.woff b/website/fonts/Inter-Regular.woff
new file mode 100644
index 0000000..2f21ed4
Binary files /dev/null and b/website/fonts/Inter-Regular.woff differ
diff --git a/website/fonts/OFL.txt b/website/fonts/OFL.txt
new file mode 100644
index 0000000..191c1aa
--- /dev/null
+++ b/website/fonts/OFL.txt
@@ -0,0 +1,108 @@
+Bundled fonts — all licensed under the SIL Open Font License 1.1 (full text below).
+
+* Copyright 2017 The Archivo Black Project Authors (https://github.com/Omnibus-Type/ArchivoBlack)
+* Copyright 2016 The Inter Project Authors (https://github.com/rsms/inter)
+* Copyright 2020 The Space Grotesk Project Authors (https://github.com/floriankarsten/space-grotesk)
+* Copyright 2017 The Playfair Display Project Authors (https://github.com/clauseggers/Playfair-Display), with Reserved Font Name "Playfair Display".
+* Copyright 2015 The Rubik Project Authors (mail@hubertfischer.com)
+* Copyright 2018 The Pacifico Project Authors (https://github.com/googlefonts/Pacifico)
+* Copyright 2001 The Silkscreen Project Authors (https://github.com/googlefonts/silkscreen)
+* Copyright 2022 The Unbounded Project Authors (https://github.com/googlefonts/unbounded)
+* Copyright 2023 The Bungee Project Authors (https://github.com/djrrb/Bungee)
+* Copyright 2020 The Dela Gothic Project Authors (https://github.com/syakuzen/DelaGothic)
+* Copyright 2023 The Young Serif Project Authors (https://github.com/noirblancrouge/YoungSerif)
+* Copyright 2022 The Bricolage Grotesque Project Authors (https://github.com/ateliertriay/bricolage)
+* Copyright 2014 The Caveat Project Authors (https://github.com/googlefonts/caveat) (Caveat and Caveat Brush)
+* Copyright 2020 The Anton Project Authors (https://github.com/googlefonts/AntonFont)
+* Copyright 2017 The EB Garamond Project Authors (https://github.com/octaviopardo/EBGaramond12)
+
+Files are the Latin subsets distributed by Fontsource (https://fontsource.org).
+Young Serif, Bricolage Grotesque, Caveat and Caveat Brush are Latin subsets of the Google Fonts
+releases, converted to WOFF; Bricolage Grotesque keeps its weight and width
+axes with the optical size fixed at 96 pt.
+
+SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
+-----------------------------------------------------------
+
+PREAMBLE
+The goals of the Open Font License (OFL) are to stimulate worldwide
+development of collaborative font projects, to support the font creation
+efforts of academic and linguistic communities, and to provide a free and
+open framework in which fonts may be shared and improved in partnership
+with others.
+
+The OFL allows the licensed fonts to be used, studied, modified and
+redistributed freely as long as they are not sold by themselves. The
+fonts, including any derivative works, can be bundled, embedded,
+redistributed and/or sold with any software provided that any reserved
+names are not used by derivative works. The fonts and derivatives,
+however, cannot be released under any other type of license. The
+requirement for fonts to remain under this license does not apply
+to any document created using the fonts or their derivatives.
+
+DEFINITIONS
+"Font Software" refers to the set of files released by the Copyright
+Holder(s) under this license and clearly marked as such. This may
+include source files, build scripts and documentation.
+
+"Reserved Font Name" refers to any names specified as such after the
+copyright statement(s).
+
+"Original Version" refers to the collection of Font Software components as
+distributed by the Copyright Holder(s).
+
+"Modified Version" refers to any derivative made by adding to, deleting,
+or substituting -- in part or in whole -- any of the components of the
+Original Version, by changing formats or by porting the Font Software to a
+new environment.
+
+"Author" refers to any designer, engineer, programmer, technical
+writer or other person who contributed to the Font Software.
+
+PERMISSION & CONDITIONS
+Permission is hereby granted, free of charge, to any person obtaining
+a copy of the Font Software, to use, study, copy, merge, embed, modify,
+redistribute, and sell modified and unmodified copies of the Font
+Software, subject to the following conditions:
+
+1) Neither the Font Software nor any of its individual components,
+in Original or Modified Versions, may be sold by itself.
+
+2) Original or Modified Versions of the Font Software may be bundled,
+redistributed and/or sold with any software, provided that each copy
+contains the above copyright notice and this license. These can be
+included either as stand-alone text files, human-readable headers or
+in the appropriate machine-readable metadata fields within text or
+binary files as long as those fields can be easily viewed by the user.
+
+3) No Modified Version of the Font Software may use the Reserved Font
+Name(s) unless explicit written permission is granted by the corresponding
+Copyright Holder. This restriction only applies to the primary font name as
+presented to the users.
+
+4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
+Software shall not be used to promote, endorse or advertise any
+Modified Version, except to acknowledge the contribution(s) of the
+Copyright Holder(s) and the Author(s) or with their explicit written
+permission.
+
+5) The Font Software, modified or unmodified, in part or in whole,
+must be distributed entirely under this license, and must not be
+distributed under any other license. The requirement for fonts to
+remain under this license does not apply to any document created
+using the Font Software.
+
+TERMINATION
+This license becomes null and void if any of the above conditions are
+not met.
+
+DISCLAIMER
+THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
+MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
+OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
+COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
+DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
+OTHER DEALINGS IN THE FONT SOFTWARE.
diff --git a/website/fonts/Pacifico-Regular.woff b/website/fonts/Pacifico-Regular.woff
new file mode 100644
index 0000000..4f1ce40
Binary files /dev/null and b/website/fonts/Pacifico-Regular.woff differ
diff --git a/website/fonts/PlayfairDisplay-Black.woff b/website/fonts/PlayfairDisplay-Black.woff
new file mode 100644
index 0000000..a234dfc
Binary files /dev/null and b/website/fonts/PlayfairDisplay-Black.woff differ
diff --git a/website/fonts/RubikMonoOne-Regular.woff b/website/fonts/RubikMonoOne-Regular.woff
new file mode 100644
index 0000000..cac54f7
Binary files /dev/null and b/website/fonts/RubikMonoOne-Regular.woff differ
diff --git a/website/fonts/Silkscreen-Regular.woff b/website/fonts/Silkscreen-Regular.woff
new file mode 100644
index 0000000..3f60dfc
Binary files /dev/null and b/website/fonts/Silkscreen-Regular.woff differ
diff --git a/website/fonts/Unbounded-ExtraBold.woff b/website/fonts/Unbounded-ExtraBold.woff
new file mode 100644
index 0000000..8f9a7f2
Binary files /dev/null and b/website/fonts/Unbounded-ExtraBold.woff differ
diff --git a/website/icon.png b/website/icon.png
new file mode 100644
index 0000000..a9d0fea
Binary files /dev/null and b/website/icon.png differ
diff --git a/website/index.html b/website/index.html
new file mode 100644
index 0000000..e7ef057
--- /dev/null
+++ b/website/index.html
@@ -0,0 +1,226 @@
+
+
+
+
+
+ Morphotype · a type and logo lab
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Type & logo lab · macOS · Windows · Web
+
+ Type that morphs , moves and melts.
+
+
+ Blend one typeface into another, set lettering in motion, and render it as liquid chrome, neon, glass or pixels, live on the GPU. Then export a real font.
+
+
+
+
+
+
+ Bricolage
+ →
+ Playfair
+ blend 0%
+
+
+
+
+ Try your own word
+
+
+
+
+
+
+
+
+
+
The poster deck
+
Puffy , chrome , pixel . Six posters from the app.
+
Each card is a real template with its real settings. Point at one to tilt it, then click to turn it over.
+
+
+
+
+
+
+
+
What it does
+
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.
+ Motion timeline Entrances, loops, paths of stops and letter-by-letter text, edited on a timeline.
+ Live materials Chrome, glass, neon, holo, plush, riso and pixel, drawn by GPU shaders in real time.
+ Pen and shapes A pen that snaps where you mean it, plus a kit of shapes and stackable modifiers.
+ Real fonts out Compile your lettering into an installable TTF, WOFF or a web font kit.
+ Kerning editor Drag letter pairs into place, with classes and exceptions written to GPOS.
+ Trace and pixel Trace pictures into vector marks, or turn them into pixel art with dithering.
+ Export anything PNG, JPG, SVG, MP4 and WebM, exported at full quality and the same every time.
+
+
+
+
+
+
+
Motion, frame for frame
+
The timeline you edit is the one that plays.
+
Lines that draw themselves on, pixel badges that build back, vinyl that breathes. Under each scene are its real tracks: drag across them to scrub.
+
+
+
+
+
+
+
+
+
Two modes
+
Design for logos and posts. Type for whole fonts.
+
+
+
+ Design
+ Type
+
+
+
+
+
+
+
+
+
+
+
+
+
+
What’s new
+
Version 0.9.1
+
+
+ A motion editor. A timeline dock with a row for every moving layer: drag to retime, click to edit.
+ A path of stops. Then › Move to… goes on through any number of stops, each with its own wait and feel.
+ Letter by letter. Text moves one letter or word at a time, without breaking it apart.
+ A kerning editor. Drag pairs into place, with classes, suggestions and exceptions.
+ Smoother morphs. Blended fonts are drawn curve to curve, with a third of the points.
+ Faster everywhere. Home loads half the code, and moving shapes no longer stall the GPU.
+
+ All release notes →
+
+
+
+
+
+
Free and open source
+
Get Morphotype.
+
+
+
+ The apps aren’t signed yet. On a Mac, open it once from System Settings → Privacy & Security . On Windows, choose More info → Run anyway .
+
+
+
+
+
+
+
+
+
+
diff --git a/website/main.js b/website/main.js
new file mode 100644
index 0000000..3c5b6c6
--- /dev/null
+++ b/website/main.js
@@ -0,0 +1,579 @@
+// 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?v=3';
+import { stepper } from './stepper.js?v=3';
+import { timeline } from './timeline.js?v=3';
+
+const reduced = matchMedia('(prefers-reduced-motion: reduce)').matches;
+const $ = (s, el = document) => el.querySelector(s);
+const $$ = (s, el = document) => [...el.querySelectorAll(s)];
+const clamp = (v, a, b) => (v < a ? a : v > b ? b : v);
+const ease = (t) => (t < 0.5 ? 4 * t * t * t : 1 - (-2 * t + 2) ** 3 / 2);
+
+/* ---------- Distance fields ---------- */
+
+const INF = 1e20;
+
+/** 1D squared distance transform (Felzenszwalb & Huttenlocher). */
+function dt1(f, n, d, v, z) {
+ let k = 0;
+ v[0] = 0;
+ z[0] = -INF;
+ z[1] = INF;
+ for (let q = 1; q < n; q++) {
+ let s = (f[q] + q * q - (f[v[k]] + v[k] * v[k])) / (2 * q - 2 * v[k]);
+ while (s <= z[k]) {
+ k--;
+ s = (f[q] + q * q - (f[v[k]] + v[k] * v[k])) / (2 * q - 2 * v[k]);
+ }
+ k++;
+ v[k] = q;
+ z[k] = s;
+ z[k + 1] = INF;
+ }
+ k = 0;
+ for (let q = 0; q < n; q++) {
+ while (z[k + 1] < q) k++;
+ d[q] = (q - v[k]) * (q - v[k]) + f[v[k]];
+ }
+}
+
+/** Squared distance to the nearest zero cell, in place. */
+function edt(g, w, h) {
+ const n = Math.max(w, h);
+ const f = new Float64Array(n), d = new Float64Array(n), v = new Int32Array(n), z = new Float64Array(n + 1);
+ for (let x = 0; x < w; x++) {
+ for (let y = 0; y < h; y++) f[y] = g[y * w + x];
+ dt1(f, h, d, v, z);
+ for (let y = 0; y < h; y++) g[y * w + x] = d[y];
+ }
+ for (let y = 0; y < h; y++) {
+ for (let x = 0; x < w; x++) f[x] = g[y * w + x];
+ dt1(f, w, d, v, z);
+ for (let x = 0; x < w; x++) g[y * w + x] = d[x];
+ }
+}
+
+const SS = 3; // supersampling per cell
+const scratch = document.createElement('canvas');
+const sctx = scratch.getContext('2d', { willReadFrequently: true });
+
+/**
+ * Signed distance (in cells, negative inside) from each cell centre to the
+ * outline of `word` set in `font`, fitted to a cols × rows grid.
+ */
+function wordField(word, font, cols, rows, fit) {
+ const W = cols * SS, H = rows * SS;
+ scratch.width = W;
+ scratch.height = H;
+ sctx.clearRect(0, 0, W, H);
+ sctx.font = `${font.weight} 100px ${font.family}`;
+ const m = sctx.measureText(word);
+ const inkW = m.actualBoundingBoxLeft + m.actualBoundingBoxRight;
+ const inkH = m.actualBoundingBoxAscent + m.actualBoundingBoxDescent;
+ const k = Math.min((W * fit.w) / inkW, (H * fit.h) / inkH);
+ sctx.font = `${font.weight} ${100 * k}px ${font.family}`;
+ sctx.fillStyle = '#000';
+ const x = W / 2 - ((m.actualBoundingBoxRight - m.actualBoundingBoxLeft) * k) / 2;
+ const y = H * fit.y + ((m.actualBoundingBoxAscent - m.actualBoundingBoxDescent) * k) / 2;
+ sctx.fillText(word, x, y);
+ const a = sctx.getImageData(0, 0, W, H).data;
+ const outside = new Float64Array(W * H), inside = new Float64Array(W * H);
+ for (let i = 0; i < W * H; i++) {
+ const ink = a[i * 4 + 3] > 127;
+ outside[i] = ink ? 0 : INF;
+ inside[i] = ink ? INF : 0;
+ }
+ edt(outside, W, H);
+ edt(inside, W, H);
+ const out = new Float32Array(cols * rows);
+ const c = SS >> 1;
+ for (let j = 0; j < rows; j++)
+ for (let i = 0; i < cols; i++) {
+ const p = (j * SS + c) * W + i * SS + c;
+ out[j * cols + i] = (Math.sqrt(outside[p]) - Math.sqrt(inside[p])) / SS;
+ }
+ return out;
+}
+
+/* ---------- The pixel field ---------- */
+
+const FONTS = [
+ { family: 'Bricolage', weight: 800, name: 'Bricolage' },
+ { family: 'Playfair', weight: 900, name: 'Playfair' },
+ { family: 'Pacifico', weight: 400, name: 'Pacifico' },
+ { family: '"Rubik Mono"', weight: 400, name: 'Rubik Mono' },
+ { family: 'Anton', weight: 400, name: 'Anton' },
+ { family: 'Unbounded', weight: 800, name: 'Unbounded' },
+ { family: 'Silkscreen', weight: 400, name: 'Silkscreen' },
+ { family: '"Caveat Brush"', weight: 400, name: 'Caveat Brush' },
+ { family: '"Dela Gothic"', weight: 400, name: 'Dela Gothic' },
+ { family: 'Bungee', weight: 400, name: 'Bungee' },
+];
+
+const NEON = [[255, 79, 184], [214, 92, 255], [138, 123, 255], [64, 224, 255]];
+const BAYER = [0, 8, 2, 10, 12, 4, 14, 6, 3, 11, 1, 9, 15, 7, 13, 5].map((v) => (v + 0.5) / 16);
+
+class PixelField {
+ constructor(canvas, { cell, fit, mode = 'pixel' }) {
+ this.canvas = canvas;
+ this.ctx = canvas.getContext('2d');
+ this.cellFor = cell;
+ this.fit = fit;
+ this.mode = mode;
+ this.word = 'Morphotype';
+ this.cache = new Map();
+ this.mouse = { x: -1e4, y: -1e4, sx: -1e4, sy: -1e4, on: false };
+ this.colors = { ink: '#17151c', accent: '#6c4cff' };
+ canvas.addEventListener('pointermove', (e) => {
+ const r = canvas.getBoundingClientRect();
+ this.mouse.x = e.clientX - r.left;
+ this.mouse.y = e.clientY - r.top;
+ if (!this.mouse.on) Object.assign(this.mouse, { sx: this.mouse.x, sy: this.mouse.y, on: true });
+ });
+ canvas.addEventListener('pointerleave', () => (this.mouse.on = false));
+ this.resize();
+ }
+
+ resize() {
+ const r = this.canvas.getBoundingClientRect();
+ const dpr = Math.min(devicePixelRatio || 1, 2);
+ this.w = r.width;
+ this.h = r.height;
+ this.canvas.width = Math.round(r.width * dpr);
+ this.canvas.height = Math.round(r.height * dpr);
+ this.ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
+ this.cs = this.cellFor(r.width);
+ this.cols = Math.max(8, Math.floor(r.width / this.cs));
+ this.rows = Math.max(4, Math.floor(r.height / this.cs));
+ this.ox = (r.width - this.cols * this.cs) / 2;
+ this.oy = (r.height - this.rows * this.cs) / 2;
+ const n = this.cols * this.rows;
+ this.noise = new Float32Array(n);
+ for (let i = 0; i < n; i++) {
+ const s = Math.sin(i * 12.9898 + 78.233) * 43758.5453;
+ this.noise[i] = (s - Math.floor(s)) * 2 - 1;
+ }
+ this.bg = null;
+ }
+
+ field(fi) {
+ const key = `${this.word}|${this.cols}x${this.rows}|${fi}`;
+ let f = this.cache.get(key);
+ if (!f) {
+ if (this.cache.size > 40) this.cache.clear();
+ f = wordField(this.word, FONTS[fi], this.cols, this.rows, this.fit);
+ this.cache.set(key, f);
+ }
+ return f;
+ }
+
+ /** A faint dot grid behind lit pixels, drawn once per size and colour. */
+ backdrop(color, alpha) {
+ const key = `${this.cols}x${this.rows}|${color}|${alpha}`;
+ if (this.bg?.key === key) return this.bg.canvas;
+ const c = document.createElement('canvas');
+ const dpr = this.canvas.width / this.w;
+ c.width = this.canvas.width;
+ c.height = this.canvas.height;
+ const g = c.getContext('2d');
+ g.scale(dpr, dpr);
+ g.fillStyle = color;
+ g.globalAlpha = alpha;
+ const s = Math.max(1.2, this.cs * 0.16);
+ for (let j = 0; j < this.rows; j++)
+ for (let i = 0; i < this.cols; i++) g.fillRect(this.ox + i * this.cs + this.cs / 2 - s / 2, this.oy + j * this.cs + this.cs / 2 - s / 2, s, s);
+ this.bg = { key, canvas: c };
+ return c;
+ }
+
+ /**
+ * Draw the blend of fonts a → b at `e` (0..1). `scatter` jitters cells (in
+ * cells), `time` animates materials, `glow` lights cells near the pointer.
+ */
+ draw(a, b, e, { scatter = 0, time = 0, fade = null } = {}) {
+ const { ctx, cols, rows, cs, ox, oy, noise, mode } = this;
+ const fa = this.field(a), fb = a === b ? fa : this.field(b);
+ const m = this.mouse;
+ m.sx += ((m.on ? m.x : -1e4) - m.sx) * (m.on ? 0.25 : 1);
+ m.sy += ((m.on ? m.y : -1e4) - m.sy) * (m.on ? 0.25 : 1);
+ const R = cs * 9, R2 = R * R;
+ const wobble = Math.sin(Math.PI * e);
+ ctx.clearRect(0, 0, this.w, this.h);
+
+ const ink = this.colors.ink, accent = this.colors.accent;
+ const paths = new Map();
+ const path = (style) => {
+ let p = paths.get(style);
+ if (!p) paths.set(style, (p = new Path2D()));
+ return p;
+ };
+
+ if (mode === 'pixel' && !fade) ctx.drawImage(this.backdrop(ink, 0.09), 0, 0, this.w, this.h);
+ if (mode === 'neon') ctx.drawImage(this.backdrop('#ffffff', 0.05), 0, 0, this.w, this.h);
+
+ for (let j = 0; j < rows; j++) {
+ for (let i = 0; i < cols; i++) {
+ const idx = j * cols + i;
+ const nz = noise[idx];
+ let d = fa[idx] + (fb[idx] - fa[idx]) * e + nz * wobble * 0.8;
+ if (d > 7) continue;
+ let x = ox + i * cs, y = oy + j * cs;
+ if (scatter) {
+ x += nz * scatter * cs * 1.5;
+ y += noise[(idx * 7) % noise.length] * scatter * cs * 1.5;
+ }
+ let near = 0;
+ const dx = x + cs / 2 - m.sx, dy = y + cs / 2 - m.sy;
+ const q = dx * dx + dy * dy;
+ if (q < R2) {
+ const dist = Math.sqrt(q) || 1;
+ near = (1 - dist / R) ** 2;
+ x += (dx / dist) * near * cs * 2.2;
+ y += (dy / dist) * near * cs * 2.2;
+ }
+ const alpha = fade ? fade(j / rows) : 1;
+ if (alpha <= 0.01) continue;
+
+ if (mode === 'pixel') {
+ const cov = 0.5 - d;
+ if (cov <= 0.05) continue;
+ const g = cs * 0.08;
+ const style = near > 0.12 ? accent : cov < 0.5 ? 'soft' : ink;
+ path(alpha < 1 ? `${style}|${Math.round(alpha * 10)}` : style).rect(x + g, y + g, cs - 2 * g, cs - 2 * g);
+ } else if (mode === 'halftone') {
+ let s = clamp(0.5 - d / 2.6, 0, 1);
+ s *= 0.72 + 0.28 * Math.sin(i * 0.11 + j * 0.07 - time * 1.6);
+ if (s <= 0.04) continue;
+ const r = (cs / 2) * Math.sqrt(s) * 1.08 * (1 + near * 0.6);
+ const p = path(near > 0.15 ? accent : ink);
+ p.moveTo(x + cs / 2 + r, y + cs / 2);
+ p.arc(x + cs / 2, y + cs / 2, r, 0, Math.PI * 2);
+ } else if (mode === 'dither') {
+ // Solid rim, dithered body, and a dithered drop shadow.
+ const th = BAYER[(j & 3) * 4 + (i & 3)];
+ let on = false;
+ if (d < 0.5) on = (d > -1.6 ? 1 : 0.56) > th;
+ else {
+ const si = i - 3, sj = j - 3;
+ if (si >= 0 && sj >= 0) {
+ const k = sj * cols + si;
+ const ds = fa[k] + (fb[k] - fa[k]) * e;
+ on = ds < 0.5 && 0.3 > th;
+ }
+ }
+ if (near > 0.2 && d < 2) on = true;
+ if (on) path(near > 0.2 ? accent : ink).rect(x, y, cs, cs);
+ } else if (mode === 'neon') {
+ // A tube just inside the outline, a halo outside, a faint haze within.
+ const t = d + 0.7;
+ const tube = Math.exp(-(t * t) / 0.45);
+ const halo = d > 0 ? Math.exp(-d / 2.2) * 0.42 : 0.1;
+ const flick = 0.88 + 0.12 * Math.sin(time * 23 + j * 0.3) * Math.sin(time * 3.1);
+ const v = (tube + halo + near * 0.5) * flick;
+ if (v < 0.07) continue;
+ const level = Math.min(8, Math.round(v * 8));
+ const hue = clamp(Math.floor((i / cols + 0.08 * Math.sin(time * 0.8 + j * 0.05)) * 4), 0, 3);
+ path(`neon|${hue}|${level}`).rect(x + 0.5, y + 0.5, cs - 1, cs - 1);
+ }
+ }
+ }
+
+ for (const [style, p] of paths) {
+ if (style.startsWith('neon|')) {
+ const [, hue, level] = style.split('|');
+ const l = Number(level) / 8;
+ const rgb = NEON[Number(hue)];
+ ctx.fillStyle = l >= 0.99 ? `rgb(${rgb.map((c) => Math.round(c + (255 - c) * 0.75)).join(',')})` : `rgba(${rgb.join(',')}, ${l})`;
+ } else {
+ const [s, a] = style.split('|');
+ ctx.fillStyle = s === 'soft' ? ink : s;
+ ctx.globalAlpha = (s === 'soft' ? 0.32 : 1) * (a ? Number(a) / 10 : 1);
+ }
+ ctx.fill(p);
+ ctx.globalAlpha = 1;
+ }
+ }
+}
+
+/* ---------- Hero ---------- */
+
+const MODES = [
+ { id: 'pixel', label: 'Pixel' },
+ { id: 'halftone', label: 'Halftone' },
+ { id: 'dither', label: 'Dither' },
+ { id: 'neon', label: 'Neon' },
+];
+
+async function hero() {
+ const stage = $('.stage');
+ const canvas = $('.morph', stage);
+ // Narrow screens get smaller pixels, so a long word keeps enough rows to read.
+ const pf = new PixelField(canvas, { cell: (w) => clamp(w / 120, 3.4, 10), fit: { w: 0.86, h: 0.56, y: 0.5 } });
+ const readA = $('[data-font-a]'), readB = $('[data-font-b]'), readBlend = $('[data-blend]');
+
+ const setColors = () => {
+ const cs = getComputedStyle(stage);
+ pf.colors.ink = cs.getPropertyValue('--stage-ink').trim() || '#17151c';
+ pf.colors.accent = pf.mode === 'halftone' ? '#ff4fb8' : pf.mode === 'dither' ? '#6c4cff' : getComputedStyle(document.documentElement).getPropertyValue('--accent').trim();
+ };
+ const modes = stepper($('[data-modes]'), MODES.length, {
+ duration: () => 6000,
+ labels: MODES.map((m) => m.label),
+ onChange: (i) => {
+ pf.mode = MODES[i].id;
+ stage.dataset.mode = pf.mode;
+ setColors();
+ },
+ });
+ setColors();
+ matchMedia('(prefers-color-scheme: dark)').addEventListener('change', setColors);
+
+ await document.fonts.load('800 100px Bricolage');
+ const loads = FONTS.map((f) => document.fonts.load(`${f.weight} 100px ${f.family}`));
+
+ const HOLD = reduced ? 3200 : 1700, MORPH = reduced ? 1 : 1100;
+ let a = 0, b = 1, t0 = performance.now(), visible = true, raf = 0;
+ const start = performance.now();
+ let last = '';
+
+ const frame = (now) => {
+ raf = 0;
+ const t = now - t0;
+ let e = 0;
+ if (t > HOLD) e = ease(clamp((t - HOLD) / MORPH, 0, 1));
+ if (t > HOLD + MORPH) {
+ a = b;
+ b = (b + 1) % FONTS.length;
+ t0 = now;
+ e = 0;
+ }
+ const intro = reduced ? 0 : Math.max(0, 1 - (now - start) / 1400);
+ pf.draw(a, b, e, { scatter: ease(intro) * 6, time: now / 1000 });
+ const text = `${FONTS[a].name}|${FONTS[b].name}|${Math.round(e * 100)}`;
+ if (text !== last) {
+ last = text;
+ readA.textContent = FONTS[a].name;
+ readB.textContent = FONTS[b].name;
+ readBlend.textContent = `${Math.round(e * 100)}%`;
+ }
+ if (visible && !document.hidden) raf = requestAnimationFrame(frame);
+ };
+ const kick = () => {
+ if (!raf && visible && !document.hidden) raf = requestAnimationFrame(frame);
+ };
+
+ // Every face has to be loaded before its field is measured.
+ await Promise.all(loads);
+ kick();
+ if (!reduced) modes.play();
+
+ new IntersectionObserver(([en]) => {
+ visible = en.isIntersecting;
+ if (visible) {
+ kick();
+ if (!reduced && !modes.playing) modes.play();
+ } else modes.pause();
+ }).observe(stage);
+ document.addEventListener('visibilitychange', kick);
+ let rt = 0;
+ addEventListener('resize', () => {
+ clearTimeout(rt);
+ rt = setTimeout(() => {
+ pf.resize();
+ kick();
+ }, 120);
+ });
+
+ const input = $('[data-word]');
+ input.addEventListener('input', () => {
+ const w = input.value.replace(/[^\p{L}\p{N}&!?' .-]/gu, '').trim();
+ pf.word = w || 'Morphotype';
+ kick();
+ });
+}
+
+/* ---------- Footer wordmark: same field, faded, lit by the pointer ---------- */
+
+async function wordmark() {
+ const canvas = $('.wordmark');
+ await document.fonts.load('800 100px Bricolage');
+ const pf = new PixelField(canvas, { cell: (w) => clamp(w / 150, 4, 9), fit: { w: 0.98, h: 0.92, y: 0.56 } });
+ const ink = () => getComputedStyle(document.documentElement).getPropertyValue('--ink').trim();
+ pf.colors = { ink: ink(), accent: getComputedStyle(document.documentElement).getPropertyValue('--accent').trim() };
+ const fade = (y) => 0.16 * (1 - y) ** 1.3 + 0.02;
+ let raf = 0;
+ const paint = () => {
+ raf = 0;
+ pf.draw(0, 0, 0, { fade });
+ const m = pf.mouse;
+ if (m.on && (Math.abs(m.x - m.sx) > 0.5 || Math.abs(m.y - m.sy) > 0.5)) raf = requestAnimationFrame(paint);
+ else if (!m.on && m.sx > -1e3) raf = requestAnimationFrame(paint);
+ };
+ const kick = () => raf || (raf = requestAnimationFrame(paint));
+ canvas.addEventListener('pointermove', kick);
+ canvas.addEventListener('pointerleave', kick);
+ matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => {
+ pf.colors.ink = ink();
+ pf.bg = null;
+ kick();
+ });
+ addEventListener('resize', () => {
+ pf.resize();
+ kick();
+ });
+ kick();
+}
+
+/* ---------- Pixel icons ---------- */
+
+const ICONS = {
+ morph: ['............', '.###....oo..', '#...#..o..o.', '#...#..o..o.', '#####..oooo.', '#...#..o..o.', '#...#..o..o.', '............', '..##....oo..', '...###oo....', '.....##.....', '............'],
+ motion: ['.##########.', '#..........#', '#....#.....#', '#....##....#', '#....###...#', '#....##....#', '#....#.....#', '#..........#', '.##########.', '............', 'oooooo#.....', '......#.....'],
+ spark: ['.....#......', '.....#......', '....###.....', '..#######...', '....###.....', '.....#....o.', '.....#...ooo', '..........o.', '..o.........', '.ooo........', '..o.........', '............'],
+ pen: ['..........##', '.........###', '........###.', '.......###..', '......###...', '.....###....', '....###.....', '...o##......', '..oo#.......', '.ooo........', 'oo..........', '............'],
+ font: ['.#########..', '.#.......##.', '.#.ooooo..#.', '.#.o......#.', '.#.oooo...#.', '.#.o......#.', '.#.o......#.', '.#........#.', '.#........#.', '.#........#.', '.##########.', '............'],
+ kern: ['............', '..##...#...#', '.#..#..#...#', '.#..#..#...#', '.####...#.#.', '.#..#...#.#.', '.#..#....#..', '............', '..o......o..', 'oooo....oooo', '..o......o..', '............'],
+ photo: ['############', '#..........#', '#.......oo.#', '#.......oo.#', '#..........#', '#...#......#', '#..###..#..#', '#.#####.###.', '###########.', '#..........#', '############', '............'],
+ export: ['.....##.....', '.....##.....', '.....##.....', '.....##.....', '..#..##..#..', '...#.##.#...', '....####....', '.....##.....', '............', 'o..........o', 'oooooooooooo', '............'],
+ mac: ['......o.....', '.....o......', '..###.###...', '.#########..', '#########...', '#########...', '#########...', '##########..', '.##########.', '..########..', '...##..##...', '............'],
+ win: ['............', '.####.#####.', '.####.#####.', '.####.#####.', '.####.#####.', '............', '.####.ooooo.', '.####.ooooo.', '.####.ooooo.', '.####.ooooo.', '............', '............'],
+ web: ['...######...', '.##..#...##.', '#...#.#....#', '#..#...#...#', '############', '#..#...#...#', '#..#...#...#', '############', '#...#.#....#', '.##..#...##.', '...######...', '............'],
+};
+
+function icons() {
+ for (const el of $$('.picon')) {
+ const rows = ICONS[el.dataset.icon];
+ if (!rows) continue;
+ let rects = '';
+ rows.forEach((row, y) =>
+ [...row].forEach((ch, x) => {
+ if (ch === '.') return;
+ const dx = ((x - 5.5) * 0.12).toFixed(2), dy = ((y - 5.5) * 0.12).toFixed(2);
+ rects += ` `;
+ }),
+ );
+ el.innerHTML = `${rects} `;
+ }
+}
+
+/* ---------- Design / Type toggle ---------- */
+
+function modeShots() {
+ const seg = $('.seg');
+ const pill = $('.seg-pill', seg);
+ const img = $('[data-shot-img]');
+ const shots = {
+ design: ['/media/editor.webp', 'The Morphotype editor with a frosted glass folder over 3D 404 lettering'],
+ type: ['/media/type-mode.webp', 'Type mode, editing the glyphs of a font'],
+ };
+ const place = (b) => {
+ pill.style.width = `${b.offsetWidth}px`;
+ pill.style.transform = `translateX(${b.offsetLeft - 4}px)`;
+ };
+ $$('button', seg).forEach((b) =>
+ b.addEventListener('click', () => {
+ $$('button', seg).forEach((x) => x.setAttribute('aria-selected', String(x === b)));
+ place(b);
+ const [src, alt] = shots[b.dataset.shot];
+ img.style.opacity = '0.2';
+ const next = new Image();
+ next.onload = () => {
+ img.src = src;
+ img.alt = alt;
+ img.style.opacity = '1';
+ };
+ next.src = src;
+ }),
+ );
+ document.fonts.ready.then(() => place($('[aria-selected="true"]', seg)));
+}
+
+/* ---------- Marquee of stills ---------- */
+
+const STILLS = ['glass-lens', 'pixel-office', 'crystal-word', 'riso-zine', 'thermal-wings', 'keycap-tower', 'neon-script', 'holo-j', 'foam-letter', 'dither-signal', 'puffy-mascot', 'sun-stamp', 'chrome-cursor', 'bitmap-poster'];
+const STILLS_2 = ['aura-tiles', 'font-morph', 'macro-keypad', 'frosted-404', 'pixel-wordmark', 'grain-spheres', 'night-blob', 'retro-sign', 'iso-bunny', 'pixel-garden', 'glow-glass', 'pixel-runner'];
+
+function marquees() {
+ for (const [el, list] of [[$('[data-marquee="left"]'), STILLS], [$('[data-marquee="right"]'), STILLS_2]]) {
+ const imgs = [...list, ...list].map((id, i) => ` = list.length ? ' aria-hidden="true"' : ''} />`);
+ el.innerHTML = imgs.join('');
+ }
+}
+
+/* ---------- Downloads: newest release assets, and the visitor's system first ---------- */
+
+function detectOS() {
+ const p = (navigator.userAgentData?.platform || navigator.platform || navigator.userAgent || '').toLowerCase();
+ if (/iphone|ipad|android/.test(navigator.userAgent.toLowerCase())) return 'web';
+ if (p.includes('mac')) return 'mac';
+ if (p.includes('win')) return 'win';
+ return 'web';
+}
+
+async function downloads() {
+ const os = detectOS();
+ const labels = { mac: 'Download for macOS', win: 'Download for Windows', web: 'Get Morphotype' };
+ $$('[data-os-label]').forEach((el) => (el.textContent = el.closest('.nav') ? 'Download' : labels[os]));
+ $(`.platform[data-platform="${os}"]`)?.classList.add('mine');
+ try {
+ const r = await fetch('https://api.github.com/repos/srivtx/morphotype/releases/latest', { headers: { Accept: 'application/vnd.github+json' } });
+ if (!r.ok) return;
+ const rel = await r.json();
+ const version = String(rel.tag_name || '').replace(/^v/, '');
+ if (version) {
+ $$('[data-version]').forEach((el) => (el.textContent = `v${version}`));
+ }
+ const pick = { mac: (n) => n.endsWith('.dmg'), win: (n) => n.endsWith('.exe'), web: (n) => /web.*\.zip$/i.test(n) };
+ for (const [key, test] of Object.entries(pick)) {
+ const asset = rel.assets?.find((x) => test(x.name));
+ const card = $(`.platform[data-platform="${key}"]`);
+ if (!asset || !card) continue;
+ card.href = asset.browser_download_url;
+ const label = $('[data-asset-name]', card);
+ const size = document.createElement('span');
+ size.className = 'size';
+ size.textContent = ` · ${(asset.size / 1048576).toFixed(0)} MB`;
+ label.textContent = asset.name;
+ label.append(size);
+ if (key === os) $('[data-primary-download]').href = asset.browser_download_url;
+ }
+ } catch {
+ // Offline or rate limited: the links still go to the releases page.
+ }
+}
+
+/* ---------- Small things ---------- */
+
+function chrome() {
+ const nav = $('.nav');
+ const onScroll = () => nav.classList.toggle('scrolled', scrollY > 8);
+ addEventListener('scroll', onScroll, { passive: true });
+ onScroll();
+ const io = new IntersectionObserver(
+ (entries) =>
+ entries.forEach((en) => {
+ if (en.isIntersecting) {
+ en.target.classList.add('in');
+ io.unobserve(en.target);
+ }
+ }),
+ { rootMargin: '0px 0px -8% 0px' },
+ );
+ $$('.section-head, .feature-grid li, .platform, .window, .reel-frame, .changes li, .seg').forEach((el, i) => {
+ el.classList.add('reveal');
+ el.style.transitionDelay = `${(i % 4) * 70}ms`;
+ io.observe(el);
+ });
+}
+
+icons();
+chrome();
+deck();
+timeline();
+modeShots();
+marquees();
+downloads();
+hero();
+wordmark();
diff --git a/website/media/aura-tiles.webp b/website/media/aura-tiles.webp
new file mode 100644
index 0000000..0bbfe4a
Binary files /dev/null and b/website/media/aura-tiles.webp 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/bitmap-poster.webp b/website/media/bitmap-poster.webp
new file mode 100644
index 0000000..ae02d19
Binary files /dev/null and b/website/media/bitmap-poster.webp differ
diff --git a/website/media/chrome-cursor.webp b/website/media/chrome-cursor.webp
new file mode 100644
index 0000000..da80347
Binary files /dev/null and b/website/media/chrome-cursor.webp differ
diff --git a/website/media/crystal-word.webp b/website/media/crystal-word.webp
new file mode 100644
index 0000000..7ff2625
Binary files /dev/null and b/website/media/crystal-word.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/dither-signal.webp b/website/media/dither-signal.webp
new file mode 100644
index 0000000..020e97c
Binary files /dev/null and b/website/media/dither-signal.webp differ
diff --git a/website/media/editor.webp b/website/media/editor.webp
new file mode 100644
index 0000000..8dc51aa
Binary files /dev/null and b/website/media/editor.webp differ
diff --git a/website/media/foam-letter.webp b/website/media/foam-letter.webp
new file mode 100644
index 0000000..aa6a6ee
Binary files /dev/null and b/website/media/foam-letter.webp differ
diff --git a/website/media/font-morph.webp b/website/media/font-morph.webp
new file mode 100644
index 0000000..9d08aeb
Binary files /dev/null and b/website/media/font-morph.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
new file mode 100644
index 0000000..4b23402
Binary files /dev/null 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
new file mode 100644
index 0000000..3109177
Binary files /dev/null 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
new file mode 100644
index 0000000..6c86022
Binary files /dev/null and b/website/media/glow-glass.webp differ
diff --git a/website/media/grain-spheres.webp b/website/media/grain-spheres.webp
new file mode 100644
index 0000000..6ae7369
Binary files /dev/null and b/website/media/grain-spheres.webp differ
diff --git a/website/media/holo-card.mp4 b/website/media/holo-card.mp4
new file mode 100644
index 0000000..b9b0252
Binary files /dev/null and b/website/media/holo-card.mp4 differ
diff --git a/website/media/holo-card.webp b/website/media/holo-card.webp
new file mode 100644
index 0000000..a14bb91
Binary files /dev/null and b/website/media/holo-card.webp differ
diff --git a/website/media/holo-j.webp b/website/media/holo-j.webp
new file mode 100644
index 0000000..5983988
Binary files /dev/null and b/website/media/holo-j.webp differ
diff --git a/website/media/inflatable.mp4 b/website/media/inflatable.mp4
new file mode 100644
index 0000000..ad1b88b
Binary files /dev/null and b/website/media/inflatable.mp4 differ
diff --git a/website/media/inflatable.webp b/website/media/inflatable.webp
new file mode 100644
index 0000000..26bbfea
Binary files /dev/null and b/website/media/inflatable.webp differ
diff --git a/website/media/iso-bunny.webp b/website/media/iso-bunny.webp
new file mode 100644
index 0000000..cdd7bab
Binary files /dev/null and b/website/media/iso-bunny.webp differ
diff --git a/website/media/keycap-tower.webp b/website/media/keycap-tower.webp
new file mode 100644
index 0000000..aaa9a58
Binary files /dev/null and b/website/media/keycap-tower.webp differ
diff --git a/website/media/macro-keypad.webp b/website/media/macro-keypad.webp
new file mode 100644
index 0000000..ce15797
Binary files /dev/null and b/website/media/macro-keypad.webp differ
diff --git a/website/media/neon-script.webp b/website/media/neon-script.webp
new file mode 100644
index 0000000..3b04f97
Binary files /dev/null and b/website/media/neon-script.webp differ
diff --git a/website/media/night-blob.webp b/website/media/night-blob.webp
new file mode 100644
index 0000000..afad105
Binary files /dev/null and b/website/media/night-blob.webp differ
diff --git a/website/media/og.png b/website/media/og.png
new file mode 100644
index 0000000..6a6109e
Binary files /dev/null and b/website/media/og.png differ
diff --git a/website/media/pixel-badge.mp4 b/website/media/pixel-badge.mp4
new file mode 100644
index 0000000..66ed4ff
Binary files /dev/null and b/website/media/pixel-badge.mp4 differ
diff --git a/website/media/pixel-badge.webp b/website/media/pixel-badge.webp
new file mode 100644
index 0000000..3f39051
Binary files /dev/null and b/website/media/pixel-badge.webp differ
diff --git a/website/media/pixel-garden.webp b/website/media/pixel-garden.webp
new file mode 100644
index 0000000..5315953
Binary files /dev/null and b/website/media/pixel-garden.webp differ
diff --git a/website/media/pixel-office.webp b/website/media/pixel-office.webp
new file mode 100644
index 0000000..c9be4ee
Binary files /dev/null and b/website/media/pixel-office.webp differ
diff --git a/website/media/pixel-runner.webp b/website/media/pixel-runner.webp
new file mode 100644
index 0000000..99f6eaf
Binary files /dev/null and b/website/media/pixel-runner.webp differ
diff --git a/website/media/pixel-wordmark.webp b/website/media/pixel-wordmark.webp
new file mode 100644
index 0000000..7696fe9
Binary files /dev/null and b/website/media/pixel-wordmark.webp differ
diff --git a/website/media/pop-3d.mp4 b/website/media/pop-3d.mp4
new file mode 100644
index 0000000..73b886d
Binary files /dev/null and b/website/media/pop-3d.mp4 differ
diff --git a/website/media/pop-3d.webp b/website/media/pop-3d.webp
new file mode 100644
index 0000000..2f56d6e
Binary files /dev/null and b/website/media/pop-3d.webp differ
diff --git a/website/media/puffy-mascot.webp b/website/media/puffy-mascot.webp
new file mode 100644
index 0000000..efb2e51
Binary files /dev/null and b/website/media/puffy-mascot.webp 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/retro-sign.webp b/website/media/retro-sign.webp
new file mode 100644
index 0000000..baf3636
Binary files /dev/null and b/website/media/retro-sign.webp differ
diff --git a/website/media/riso-zine.webp b/website/media/riso-zine.webp
new file mode 100644
index 0000000..ecdfbf6
Binary files /dev/null and b/website/media/riso-zine.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/sun-stamp.webp b/website/media/sun-stamp.webp
new file mode 100644
index 0000000..3371239
Binary files /dev/null and b/website/media/sun-stamp.webp 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
new file mode 100644
index 0000000..a0b76d0
Binary files /dev/null and b/website/media/thermal-wings.webp differ
diff --git a/website/media/tube-burst.mp4 b/website/media/tube-burst.mp4
new file mode 100644
index 0000000..eceb241
Binary files /dev/null and b/website/media/tube-burst.mp4 differ
diff --git a/website/media/tube-burst.webp b/website/media/tube-burst.webp
new file mode 100644
index 0000000..0bdacf6
Binary files /dev/null and b/website/media/tube-burst.webp differ
diff --git a/website/media/type-mode.webp b/website/media/type-mode.webp
new file mode 100644
index 0000000..81a982e
Binary files /dev/null and b/website/media/type-mode.webp differ
diff --git a/website/media/y2k-set.mp4 b/website/media/y2k-set.mp4
new file mode 100644
index 0000000..ce847d0
Binary files /dev/null and b/website/media/y2k-set.mp4 differ
diff --git a/website/media/y2k-set.webp b/website/media/y2k-set.webp
new file mode 100644
index 0000000..7c2508b
Binary files /dev/null and b/website/media/y2k-set.webp differ
diff --git a/website/showcases.js b/website/showcases.js
new file mode 100644
index 0000000..36d02c7
--- /dev/null
+++ b/website/showcases.js
@@ -0,0 +1,1423 @@
+// 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": "y2k-set",
+ "title": "Y2K set",
+ "no": 1,
+ "material": "Puffy · Contour lines",
+ "settings": [
+ {
+ "label": "Flow",
+ "value": 0,
+ "min": -10,
+ "max": 10,
+ "to": 1.6
+ },
+ {
+ "label": "Mark size",
+ "value": 55,
+ "min": 4,
+ "max": 400
+ },
+ {
+ "label": "Line end",
+ "value": 1,
+ "min": 0,
+ "max": 1,
+ "to": 0
+ }
+ ],
+ "palette": [
+ "#ebebec",
+ "#2347ff",
+ "#15171c",
+ "#6f747e",
+ "#c98cff",
+ "#5ce84e"
+ ],
+ "bg": [
+ "#ebebec"
+ ],
+ "duration": 4,
+ "layers": 15,
+ "flavor": "A topo-line puff, speckled foam and a chrome flower tube that wipes off and draws itself back on.",
+ "rows": [
+ {
+ "name": "Chrome flower",
+ "depth": 0,
+ "tracks": [
+ {
+ "kind": "loop",
+ "label": "Sway",
+ "period": 4
+ },
+ {
+ "kind": "param",
+ "label": "Line · End",
+ "period": 4
+ }
+ ]
+ },
+ {
+ "name": "Chrome star",
+ "depth": 0,
+ "tracks": [
+ {
+ "kind": "loop",
+ "label": "Spin",
+ "period": 4
+ }
+ ]
+ },
+ {
+ "name": "Script y",
+ "depth": 0,
+ "tracks": [
+ {
+ "kind": "param",
+ "label": "Line · End",
+ "period": 4
+ }
+ ]
+ },
+ {
+ "name": "Topo blob",
+ "depth": 0,
+ "tracks": [
+ {
+ "kind": "loop",
+ "label": "Float",
+ "period": 4
+ },
+ {
+ "kind": "param",
+ "label": "Flow",
+ "period": 4
+ },
+ {
+ "kind": "param",
+ "label": "Puff",
+ "period": 4
+ }
+ ]
+ },
+ {
+ "name": "Speckled foam",
+ "depth": 0,
+ "tracks": [
+ {
+ "kind": "loop",
+ "label": "Float",
+ "period": 2
+ }
+ ]
+ },
+ {
+ "name": "Eyes",
+ "depth": 0,
+ "tracks": [
+ {
+ "kind": "loop",
+ "label": "Blink",
+ "period": 2
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "id": "inflatable",
+ "title": "Inflatable",
+ "no": 2,
+ "material": "Inflatable vinyl",
+ "settings": [
+ {
+ "label": "Line end",
+ "value": 1,
+ "min": 0,
+ "max": 1,
+ "to": 0.25
+ },
+ {
+ "label": "Pinch",
+ "value": 0.7,
+ "min": 0,
+ "max": 1,
+ "to": 0.2
+ },
+ {
+ "label": "Ring spacing",
+ "value": 40,
+ "min": 6,
+ "max": 400
+ }
+ ],
+ "palette": [
+ "#fbfbfb",
+ "#3d6bff",
+ "#ff5ec4",
+ "#4dff2e",
+ "#e9ecf2",
+ "#0a0a0a"
+ ],
+ "bg": [
+ "#fbfbfb"
+ ],
+ "duration": 4,
+ "layers": 11,
+ "flavor": "Blow-up vinyl tubes on pen lines: the pinched hash wipes back to one bar and redraws, the silver star breathes.",
+ "rows": [
+ {
+ "name": "Vol tag",
+ "depth": 0,
+ "tracks": [
+ {
+ "kind": "enter",
+ "label": "Grow",
+ "start": 0.3,
+ "end": 1.11
+ },
+ {
+ "kind": "loop",
+ "label": "Sway",
+ "period": 2
+ }
+ ]
+ },
+ {
+ "name": "Silver star",
+ "depth": 0,
+ "tracks": [
+ {
+ "kind": "loop",
+ "label": "Pulse",
+ "period": 2
+ },
+ {
+ "kind": "param",
+ "label": "Pinch",
+ "period": 2
+ }
+ ]
+ },
+ {
+ "name": "Arrow",
+ "depth": 0,
+ "tracks": [
+ {
+ "kind": "loop",
+ "label": "Float",
+ "period": 2
+ }
+ ]
+ },
+ {
+ "name": "Hash",
+ "depth": 0,
+ "tracks": [
+ {
+ "kind": "param",
+ "label": "Line · End",
+ "period": 4
+ }
+ ]
+ },
+ {
+ "name": "Blue wave",
+ "depth": 0,
+ "tracks": [
+ {
+ "kind": "param",
+ "label": "Line · End",
+ "period": 4
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "id": "pixel-badge",
+ "title": "Pixel badge",
+ "no": 3,
+ "material": "Pixel badge",
+ "settings": [
+ {
+ "label": "Pixels · Dissolve",
+ "value": 0,
+ "min": 0,
+ "max": 1,
+ "to": 1
+ },
+ {
+ "label": "Pixels · Pixel size",
+ "value": 0.075,
+ "min": 0.01,
+ "max": 0.3
+ },
+ {
+ "label": "Weight · Amount",
+ "value": 0.14,
+ "min": -0.1,
+ "max": 0.2
+ }
+ ],
+ "palette": [
+ "#e6e6e3",
+ "#0a0a0a",
+ "#2fe07a",
+ "#ff8fd6"
+ ],
+ "bg": [
+ "#e6e6e3"
+ ],
+ "duration": 4,
+ "layers": 8,
+ "flavor": "Script on stepped pixel badges that scatter into cells and build back, one after the other.",
+ "rows": [
+ {
+ "name": "Rách badge",
+ "depth": 0,
+ "tracks": [
+ {
+ "kind": "param",
+ "label": "Pixels · Dissolve",
+ "period": 4
+ }
+ ]
+ },
+ {
+ "name": "Sạch badge",
+ "depth": 0,
+ "tracks": [
+ {
+ "kind": "param",
+ "label": "Pixels · Dissolve",
+ "period": 4
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "id": "tube-burst",
+ "title": "Tube burst",
+ "no": 4,
+ "material": "Puffy tubes",
+ "settings": [
+ {
+ "label": "Line end",
+ "value": 1,
+ "min": 0,
+ "max": 1,
+ "to": 0.12
+ },
+ {
+ "label": "Puff",
+ "value": 21,
+ "min": 4,
+ "max": 300
+ },
+ {
+ "label": "Gloss",
+ "value": 0.55,
+ "min": 0,
+ "max": 1.5
+ }
+ ],
+ "palette": [
+ "#050505",
+ "#a8e63c",
+ "#4d8a12",
+ "#ffffff"
+ ],
+ "bg": [
+ "#050505"
+ ],
+ "duration": 4,
+ "layers": 21,
+ "flavor": "Sixteen soft tubes grow out of the middle one after another while the burst slowly turns.",
+ "rows": [
+ {
+ "name": "Underline",
+ "depth": 0,
+ "tracks": [
+ {
+ "kind": "param",
+ "label": "Line · End",
+ "period": 4
+ }
+ ]
+ },
+ {
+ "name": "Burst",
+ "depth": 0,
+ "tracks": [
+ {
+ "kind": "loop",
+ "label": "Spin",
+ "period": 4
+ }
+ ]
+ },
+ {
+ "name": "Tube 16",
+ "depth": 1,
+ "tracks": [
+ {
+ "kind": "param",
+ "label": "Line · End",
+ "period": 4
+ }
+ ]
+ },
+ {
+ "name": "Tube 15",
+ "depth": 1,
+ "tracks": [
+ {
+ "kind": "param",
+ "label": "Line · End",
+ "period": 4
+ }
+ ]
+ },
+ {
+ "name": "Tube 14",
+ "depth": 1,
+ "tracks": [
+ {
+ "kind": "param",
+ "label": "Line · End",
+ "period": 4
+ }
+ ]
+ },
+ {
+ "name": "Tube 13",
+ "depth": 1,
+ "tracks": [
+ {
+ "kind": "param",
+ "label": "Line · End",
+ "period": 4
+ }
+ ]
+ },
+ {
+ "name": "Tube 12",
+ "depth": 1,
+ "tracks": [
+ {
+ "kind": "param",
+ "label": "Line · End",
+ "period": 4
+ }
+ ]
+ },
+ {
+ "name": "Tube 11",
+ "depth": 1,
+ "tracks": [
+ {
+ "kind": "param",
+ "label": "Line · End",
+ "period": 4
+ }
+ ]
+ },
+ {
+ "name": "Tube 10",
+ "depth": 1,
+ "tracks": [
+ {
+ "kind": "param",
+ "label": "Line · End",
+ "period": 4
+ }
+ ]
+ },
+ {
+ "name": "Tube 9",
+ "depth": 1,
+ "tracks": [
+ {
+ "kind": "param",
+ "label": "Line · End",
+ "period": 4
+ }
+ ]
+ },
+ {
+ "name": "Tube 8",
+ "depth": 1,
+ "tracks": [
+ {
+ "kind": "param",
+ "label": "Line · End",
+ "period": 4
+ }
+ ]
+ },
+ {
+ "name": "Tube 7",
+ "depth": 1,
+ "tracks": [
+ {
+ "kind": "param",
+ "label": "Line · End",
+ "period": 4
+ }
+ ]
+ },
+ {
+ "name": "Tube 6",
+ "depth": 1,
+ "tracks": [
+ {
+ "kind": "param",
+ "label": "Line · End",
+ "period": 4
+ }
+ ]
+ },
+ {
+ "name": "Tube 5",
+ "depth": 1,
+ "tracks": [
+ {
+ "kind": "param",
+ "label": "Line · End",
+ "period": 4
+ }
+ ]
+ },
+ {
+ "name": "Tube 4",
+ "depth": 1,
+ "tracks": [
+ {
+ "kind": "param",
+ "label": "Line · End",
+ "period": 4
+ }
+ ]
+ },
+ {
+ "name": "Tube 3",
+ "depth": 1,
+ "tracks": [
+ {
+ "kind": "param",
+ "label": "Line · End",
+ "period": 4
+ }
+ ]
+ },
+ {
+ "name": "Tube 2",
+ "depth": 1,
+ "tracks": [
+ {
+ "kind": "param",
+ "label": "Line · End",
+ "period": 4
+ }
+ ]
+ },
+ {
+ "name": "Tube 1",
+ "depth": 1,
+ "tracks": [
+ {
+ "kind": "param",
+ "label": "Line · End",
+ "period": 4
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "id": "shape-shifter",
+ "title": "Shape shifter",
+ "no": 5,
+ "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": 6,
+ "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": 7,
+ "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": 8,
+ "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": 9,
+ "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": 10,
+ "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": 11,
+ "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": 12,
+ "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": 13,
+ "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": 14,
+ "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
+ }
+ ]
+ }
+ ]
+ }
+];
+
+/** The poster deck: new posters and favourites from before, as cards. */
+export const DECK = ["pop-3d","y2k-set","tube-burst","inflatable","glass-lens","pixel-badge"];
+
+/** The reel: one scene at a time with its timeline, the deck first. */
+export const REEL = ["y2k-set","glass-lens","inflatable","pixel-badge","pop-3d","tube-burst","shape-shifter","crystal","thermal-wings","holo-card"];
diff --git a/website/stepper.js b/website/stepper.js
new file mode 100644
index 0000000..b2e6b06
--- /dev/null
+++ b/website/stepper.js
@@ -0,0 +1,55 @@
+// A Pasito-style stepper (after joshpuckett/pasito), shared by the hero's
+// material switch and the reel of animated posters.
+
+/** Dots, an active pill, and a fill while autoplaying. */
+export function stepper(el, count, { duration, labels, onChange }) {
+ if (labels) el.classList.add('labelled');
+ const buttons = Array.from({ length: count }, (_, i) => {
+ const b = document.createElement('button');
+ b.type = 'button';
+ b.setAttribute('role', 'tab');
+ b.setAttribute('aria-label', labels ? labels[i] : `Show ${i + 1} of ${count}`);
+ if (labels) b.innerHTML = `${labels[i]} `;
+ b.addEventListener('click', () => api.set(i, true));
+ el.append(b);
+ return b;
+ });
+ let active = 0, timer = 0, playing = false, held = 0;
+ const fill = () => {
+ const b = buttons[active];
+ b.classList.remove('fill');
+ if (!playing) return;
+ b.style.setProperty('--fill', `${duration(active)}ms`);
+ void b.offsetWidth;
+ b.classList.add('fill');
+ };
+ const schedule = () => {
+ clearTimeout(timer);
+ if (playing) timer = setTimeout(() => api.set((active + 1) % count), duration(active));
+ };
+ const api = {
+ get index() { return active; },
+ set(i, byUser = false) {
+ buttons[active].classList.remove('on', 'fill');
+ buttons[active].setAttribute('aria-selected', 'false');
+ active = (i + count) % count;
+ buttons[active].classList.add('on');
+ buttons[active].setAttribute('aria-selected', 'true');
+ onChange(active, byUser);
+ if (byUser && playing) {
+ // A pick holds for a while before autoplay resumes.
+ api.pause();
+ clearTimeout(held);
+ held = setTimeout(() => api.play(), 9000);
+ }
+ fill();
+ schedule();
+ },
+ play() { playing = true; fill(); schedule(); },
+ pause() { playing = false; clearTimeout(timer); buttons[active].classList.remove('fill'); },
+ get playing() { return playing; },
+ };
+ buttons[0].classList.add('on');
+ buttons[0].setAttribute('aria-selected', 'true');
+ return api;
+}
diff --git a/website/styles.css b/website/styles.css
new file mode 100644
index 0000000..29bbe4e
--- /dev/null
+++ b/website/styles.css
@@ -0,0 +1,523 @@
+/* Morphotype site. Fonts are the app's own bundled OFL faces. */
+
+@font-face { font-family: 'Bricolage'; src: url('/fonts/BricolageGrotesque-Variable.woff') format('woff'); font-weight: 200 800; font-display: swap; }
+@font-face { font-family: 'Inter'; src: url('/fonts/Inter-Regular.woff') format('woff'); font-weight: 400; font-display: swap; }
+@font-face { font-family: 'Silkscreen'; src: url('/fonts/Silkscreen-Regular.woff') format('woff'); font-display: swap; }
+@font-face { font-family: 'Garamond'; src: url('/fonts/EBGaramond-Regular.woff') format('woff'); font-display: swap; }
+@font-face { font-family: 'Playfair'; src: url('/fonts/PlayfairDisplay-Black.woff') format('woff'); font-weight: 900; font-display: block; }
+@font-face { font-family: 'Pacifico'; src: url('/fonts/Pacifico-Regular.woff') format('woff'); font-display: block; }
+@font-face { font-family: 'Rubik Mono'; src: url('/fonts/RubikMonoOne-Regular.woff') format('woff'); font-display: block; }
+@font-face { font-family: 'Anton'; src: url('/fonts/Anton-Regular.woff') format('woff'); font-display: block; }
+@font-face { font-family: 'Unbounded'; src: url('/fonts/Unbounded-ExtraBold.woff') format('woff'); font-weight: 800; font-display: block; }
+@font-face { font-family: 'Caveat Brush'; src: url('/fonts/CaveatBrush-Regular.woff') format('woff'); font-display: block; }
+@font-face { font-family: 'Dela Gothic'; src: url('/fonts/DelaGothicOne-Regular.woff') format('woff'); font-display: block; }
+@font-face { font-family: 'Bungee'; src: url('/fonts/Bungee-Regular.woff') format('woff'); font-display: block; }
+
+:root {
+ --paper: #f3eee4;
+ --paper-2: #ebe4d6;
+ --card: #fbf8f2;
+ --ink: #17151c;
+ --ink-2: #4a4552;
+ --muted: #7c7468;
+ --line: rgba(40, 30, 20, 0.1);
+ --tint: rgba(60, 40, 20, 0.05);
+ --accent: #6c4cff;
+ --accent-2: #2f6bff;
+ --pink: #ff5fae;
+ --lime: #c8f53c;
+ --mark: #c9bbff;
+ --shadow: 0 1px 1px rgba(60, 40, 20, 0.04), 0 8px 24px -6px rgba(60, 40, 20, 0.16), 0 24px 48px -16px rgba(60, 40, 20, 0.18);
+ --spring: cubic-bezier(0.34, 1.56, 0.64, 1);
+ --ease: cubic-bezier(0.215, 0.61, 0.355, 1);
+ --radius: 18px;
+ --sans: 'Inter', ui-sans-serif, system-ui, -apple-system, 'Segoe UI', sans-serif;
+ --display: 'Bricolage', ui-sans-serif, system-ui, sans-serif;
+ --pixel: 'Silkscreen', ui-monospace, monospace;
+ --serif: 'Garamond', ui-serif, Georgia, serif;
+ color-scheme: light;
+}
+
+@media (prefers-color-scheme: dark) {
+ :root:not([data-theme='light']) {
+ --paper: #121116;
+ --paper-2: #1a1920;
+ --card: #1c1b22;
+ --ink: #f1ece2;
+ --ink-2: #c7c0cf;
+ --muted: #8d8697;
+ --line: rgba(255, 255, 255, 0.09);
+ --tint: rgba(255, 255, 255, 0.05);
+ --mark: #4a3a99;
+ --shadow: 0 1px 1px rgba(0, 0, 0, 0.3), 0 8px 24px -6px rgba(0, 0, 0, 0.5), 0 24px 48px -16px rgba(0, 0, 0, 0.6);
+ color-scheme: dark;
+ }
+}
+:root[data-theme='dark'] {
+ --paper: #121116; --paper-2: #1a1920; --card: #1c1b22; --ink: #f1ece2; --ink-2: #c7c0cf; --muted: #8d8697;
+ --line: rgba(255, 255, 255, 0.09); --tint: rgba(255, 255, 255, 0.05); --mark: #4a3a99;
+ --shadow: 0 1px 1px rgba(0, 0, 0, 0.3), 0 8px 24px -6px rgba(0, 0, 0, 0.5), 0 24px 48px -16px rgba(0, 0, 0, 0.6);
+ color-scheme: dark;
+}
+
+* { box-sizing: border-box; }
+html { scroll-behavior: smooth; -webkit-text-size-adjust: 100%; }
+body {
+ margin: 0;
+ background: var(--paper);
+ color: var(--ink);
+ font: 17px/1.55 var(--sans);
+ -webkit-font-smoothing: antialiased;
+ overflow-x: hidden;
+}
+img, video, canvas { display: block; max-width: 100%; }
+a { color: inherit; }
+button { font: inherit; color: inherit; }
+.sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); }
+.px { font-family: var(--pixel); font-size: 11px; letter-spacing: 0.04em; text-transform: uppercase; }
+
+/* Nav */
+.nav {
+ position: sticky; top: 0; z-index: 50;
+ display: flex; align-items: center; gap: 24px;
+ padding: 14px max(16px, calc((100vw - 1180px) / 2));
+ background: color-mix(in srgb, var(--paper) 82%, transparent);
+ backdrop-filter: blur(14px) saturate(1.4);
+ -webkit-backdrop-filter: blur(14px) saturate(1.4);
+ border-bottom: 1px solid transparent;
+ transition: border-color 0.3s;
+}
+.nav.scrolled { border-bottom-color: var(--line); }
+.brand { display: flex; align-items: center; gap: 10px; text-decoration: none; font: 700 19px var(--display); letter-spacing: -0.02em; }
+.brand img { border-radius: 9px; }
+.version {
+ font: 600 11px var(--sans); color: var(--muted);
+ padding: 2px 6px; border-radius: 7px; border: 1px solid var(--line); background: var(--tint);
+ transform: translateY(-6px);
+}
+.nav-links { display: flex; gap: 4px; margin-left: auto; }
+.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 */
+.btn {
+ display: inline-flex; align-items: center; gap: 10px;
+ padding: 15px 22px; border-radius: 16px;
+ font: 650 17px var(--display); letter-spacing: -0.01em; text-decoration: none; white-space: nowrap;
+ transition: transform 0.35s var(--spring), box-shadow 0.3s, background 0.2s;
+}
+.btn svg { width: 17px; height: 17px; fill: none; stroke: currentColor; stroke-width: 1.7; stroke-linecap: round; stroke-linejoin: round; }
+.btn-soft svg { fill: currentColor; stroke: none; }
+.btn:active { transform: scale(0.97); }
+.btn-ink { background: var(--ink); color: var(--paper); box-shadow: 0 18px 28px -10px color-mix(in srgb, var(--accent) 45%, transparent), 0 6px 10px -6px rgba(0, 0, 0, 0.3); }
+.btn-ink:hover { transform: translateY(-2px); }
+.btn-soft { background: var(--tint); color: var(--ink); }
+.btn-soft:hover { background: color-mix(in srgb, var(--ink) 9%, transparent); }
+.nav-cta { padding: 10px 16px; font-size: 15px; border-radius: 13px; }
+
+/* Sections */
+main > section { padding: 120px 16px 0; max-width: 1180px; margin: 0 auto; }
+.section-head { text-align: center; margin-bottom: 56px; }
+.eyebrow { color: var(--accent); margin: 0 0 14px; }
+h1, h2, h3 { font-family: var(--display); margin: 0; }
+h2 { font-size: clamp(34px, 5.2vw, 58px); line-height: 1.02; letter-spacing: -0.035em; font-weight: 750; }
+.reveal { opacity: 0; transform: translateY(18px) scale(0.98) rotate(1deg); transition: opacity 0.8s var(--ease), transform 0.9s var(--spring); }
+.reveal.in { opacity: 1; transform: none; }
+
+/* Hero */
+.hero { text-align: center; padding-top: 72px !important; }
+.headline {
+ font-size: clamp(46px, 9.4vw, 112px); line-height: 0.96; letter-spacing: -0.045em; font-weight: 800;
+ animation: rise 0.9s var(--spring) both 0.05s;
+}
+.hl { position: relative; background: none; color: inherit; padding: 0 0.06em; white-space: nowrap; }
+.hl::before {
+ content: ''; position: absolute; z-index: -1; left: -0.02em; right: -0.04em; top: 0.52em; bottom: 0.02em;
+ background: var(--mark);
+ /* Stepped, pixel-edged highlighter */
+ clip-path: polygon(0 18%, 3% 18%, 3% 6%, 97% 6%, 97% 0, 100% 0, 100% 88%, 97% 88%, 97% 100%, 4% 100%, 4% 92%, 0 92%);
+ transform-origin: left; animation: swipe 0.7s steps(9) both 0.75s;
+}
+.lede { max-width: 620px; margin: 28px auto 0; color: var(--ink-2); font-size: clamp(17px, 2vw, 20px); animation: rise 0.9s var(--spring) both 0.15s; }
+.cta-row { display: flex; gap: 12px; justify-content: center; flex-wrap: wrap; margin-top: 34px; animation: rise 0.9s var(--spring) both 0.25s; }
+@keyframes rise { from { opacity: 0; transform: translateY(16px) scale(0.92) rotate(2deg); } }
+@keyframes swipe { from { transform: scaleX(0); } }
+
+.stage {
+ --stage-bg: var(--card);
+ --stage-ink: var(--ink);
+ position: relative; margin: 64px auto 0; max-width: 1180px;
+ border-radius: 28px; background: var(--stage-bg);
+ border: 1px solid var(--line); box-shadow: var(--shadow);
+ overflow: hidden; transition: background 0.6s var(--ease);
+ animation: rise 1s var(--spring) both 0.35s;
+}
+.stage[data-mode='neon'] { --stage-bg: #0d0b14; --stage-ink: #f4eefc; }
+.stage[data-mode='dither'] { --stage-bg: var(--lime); --stage-ink: #121014; }
+.stage[data-mode='halftone'] { --stage-bg: #f1ebdf; --stage-ink: #1d2dff; }
+@media (prefers-color-scheme: dark) { :root:not([data-theme='light']) .stage[data-mode='halftone'] { --stage-bg: #17162a; --stage-ink: #8fa3ff; } }
+.morph { width: 100%; height: clamp(240px, 36vw, 440px); cursor: crosshair; touch-action: pan-y; }
+.readout {
+ position: absolute; top: 18px; left: 20px; right: 20px;
+ display: flex; gap: 8px; align-items: center; color: var(--stage-ink); opacity: 0.6; transition: color 0.6s;
+}
+.readout .blend { margin-left: auto; }
+.readout b { font-weight: 400; display: inline-block; min-width: 4ch; text-align: right; }
+.stage-controls {
+ display: flex; align-items: center; justify-content: center; gap: 12px; flex-wrap: wrap;
+ padding: 0 20px 22px;
+}
+.try input {
+ font: 600 15px var(--display); color: var(--stage-ink);
+ width: 150px; padding: 9px 14px; border-radius: 999px;
+ border: 1px solid color-mix(in srgb, var(--stage-ink) 18%, transparent);
+ background: color-mix(in srgb, var(--stage-ink) 5%, transparent);
+ outline: none; transition: border-color 0.2s, width 0.4s var(--spring), color 0.6s;
+}
+.try input::placeholder { color: color-mix(in srgb, var(--stage-ink) 45%, transparent); }
+.try input:focus { width: 190px; border-color: var(--accent); }
+
+/* Pasito-style stepper (after joshpuckett/pasito) */
+.pasito {
+ --dot: 8px; --active: 30px; --gap: 6px;
+ display: inline-flex; align-items: center; gap: var(--gap);
+ padding: 8px 12px; border-radius: 999px;
+ background: color-mix(in srgb, var(--stage-ink, var(--ink)) 6%, transparent);
+ border: 1px solid color-mix(in srgb, var(--stage-ink, var(--ink)) 8%, transparent);
+}
+.pasito button {
+ position: relative; overflow: hidden; border: 0; padding: 0; cursor: pointer;
+ width: var(--dot); height: var(--dot); border-radius: 999px;
+ background: color-mix(in srgb, var(--stage-ink, var(--ink)) 18%, transparent);
+ transition: width 0.5s var(--ease), background 0.5s var(--ease);
+}
+.pasito button.on { width: var(--active); background: color-mix(in srgb, var(--stage-ink, var(--ink)) 82%, transparent); }
+.pasito button::after {
+ content: ''; position: absolute; inset: 0 auto 0 0; width: 0; border-radius: inherit;
+ background: color-mix(in srgb, var(--accent) 80%, white);
+}
+.pasito button.fill::after { width: 100%; transition: width var(--fill, 4000ms) linear; }
+.pasito.labelled button { height: 22px; width: auto; padding: 0 10px; font: 11px var(--pixel); text-transform: uppercase; letter-spacing: 0.04em; color: color-mix(in srgb, var(--stage-ink) 70%, transparent); background: transparent; transition: color 0.3s, background 0.4s var(--ease); }
+.pasito.labelled button span { position: relative; z-index: 1; }
+.pasito.labelled button.on { color: var(--stage-bg); background: var(--stage-ink); }
+.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; }
+
+/* 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; }
+.feature-grid h3 { font-size: 21px; letter-spacing: -0.02em; font-weight: 700; margin-top: 18px; }
+.feature-grid p { margin: 8px 0 0; color: var(--muted); font-size: 15px; line-height: 1.5; }
+.picon { display: inline-block; width: 60px; height: 60px; }
+.picon svg { width: 100%; height: 100%; overflow: visible; }
+.picon rect { fill: var(--ink); transition: transform 0.45s var(--spring), fill 0.3s; transform-box: fill-box; transform-origin: center; }
+.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); }
+
+/* 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; }
+.seg button { position: relative; z-index: 1; border: 0; background: none; cursor: pointer; padding: 8px 22px; font: 650 15px var(--display); color: var(--muted); transition: color 0.3s; }
+.seg button[aria-selected='true'] { color: var(--ink); }
+.seg-pill { position: absolute; top: 4px; bottom: 4px; left: 4px; border-radius: 10px; background: var(--card); box-shadow: 0 1px 3px rgba(0, 0, 0, 0.12); transition: transform 0.5s var(--spring), width 0.5s var(--spring); }
+.window { border-radius: 16px; overflow: hidden; background: #1a1a1f; box-shadow: var(--shadow), 0 0 0 1px var(--line); text-align: left; }
+.window-bar { display: flex; gap: 7px; padding: 11px 14px; background: #232329; }
+.window-bar i { width: 11px; height: 11px; border-radius: 50%; background: #45454e; }
+.window img { width: 100%; height: auto; transition: opacity 0.35s; }
+
+/* Marquee */
+.strip { max-width: none !important; padding-left: 0 !important; padding-right: 0 !important; overflow: hidden; display: grid; gap: 16px; }
+.marquee { display: flex; gap: 16px; width: max-content; animation: slide 70s linear infinite; }
+.marquee[data-marquee='right'] { animation-direction: reverse; animation-duration: 80s; }
+.strip:hover .marquee { animation-play-state: paused; }
+.marquee img { height: 220px; width: auto; border-radius: 16px; box-shadow: var(--shadow); transition: transform 0.4s var(--spring); }
+.marquee img:hover { transform: scale(1.04) rotate(-1deg); }
+@keyframes slide { to { transform: translateX(-50%); } }
+
+/* Changelog */
+.new { max-width: 760px !important; }
+.changes { list-style: none; margin: 0; padding: 0; display: grid; gap: 2px; }
+.changes li { position: relative; padding: 16px 0 16px 34px; border-bottom: 1px solid var(--line); color: var(--ink-2); }
+.changes li::before { content: ''; position: absolute; left: 4px; top: 25px; width: 8px; height: 8px; background: var(--accent); box-shadow: 8px 8px 0 color-mix(in srgb, var(--accent) 40%, transparent); }
+.changes b { color: var(--ink); font-family: var(--display); font-weight: 700; }
+.link { display: inline-block; margin-top: 22px; font: 650 16px var(--display); color: var(--accent); text-decoration: none; }
+.link:hover { text-decoration: underline; text-underline-offset: 4px; }
+
+/* Downloads */
+.platforms { display: grid; grid-template-columns: repeat(3, 1fr); gap: 16px; }
+.platform {
+ position: relative; display: flex; flex-direction: column; align-items: flex-start; gap: 6px;
+ padding: 26px; border-radius: 22px; text-decoration: none;
+ background: var(--card); border: 1px solid var(--line); box-shadow: var(--shadow);
+ transition: transform 0.45s var(--spring), border-color 0.3s;
+}
+.platform:hover { transform: translateY(-4px) rotate(-0.6deg); border-color: color-mix(in srgb, var(--accent) 40%, transparent); }
+.platform .picon { width: 44px; height: 44px; margin-bottom: 12px; }
+.platform b { font: 750 24px var(--display); letter-spacing: -0.03em; }
+.platform span { color: var(--muted); font-size: 15px; }
+.platform em { font-style: normal; margin-top: 14px; color: var(--accent); overflow-wrap: anywhere; }
+.platform em .size { white-space: nowrap; opacity: 0.7; }
+.platform.mine { border-color: var(--accent); box-shadow: var(--shadow), 0 0 0 3px color-mix(in srgb, var(--accent) 18%, transparent); }
+.platform.mine::after { content: 'Your system'; position: absolute; top: 22px; right: 22px; font: 11px var(--pixel); text-transform: uppercase; color: var(--paper); background: var(--accent); padding: 3px 7px; border-radius: 6px; }
+.fine { text-align: center; color: var(--muted); font-size: 14px; margin: 26px auto 0; max-width: 620px; }
+
+/* Footer */
+.footer { margin-top: 140px; padding: 0 16px 28px; }
+.wordmark { width: 100%; height: clamp(120px, 21vw, 300px); max-width: 1400px; margin: 0 auto; }
+.foot-row { max-width: 1180px; margin: 18px auto 0; display: flex; justify-content: space-between; gap: 16px; flex-wrap: wrap; color: var(--muted); font-size: 14px; }
+.foot-links { display: flex; gap: 18px; }
+.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 poster deck: a loose fan of collectible cards (after ref 10) */
+.poster-head .w-puff {
+ color: #ff8fd6; font-family: 'Unbounded', var(--display); font-weight: 800; letter-spacing: -0.04em;
+ -webkit-text-stroke: 0.035em var(--ink); paint-order: stroke fill;
+ text-shadow: 0 0.06em 0 var(--ink);
+}
+.poster-head .w-chrome {
+ background: linear-gradient(180deg, #fbfcff 0%, #b9bec8 38%, #4a4f58 52%, #e9ecf2 64%, #8a909c 100%);
+ -webkit-background-clip: text; background-clip: text; color: transparent;
+ -webkit-text-stroke: 0.02em color-mix(in srgb, var(--ink) 70%, transparent);
+}
+.poster-head .w-glitch { position: relative; font-family: var(--pixel); font-weight: 400; font-size: 0.86em; letter-spacing: -0.02em; color: #2347ff; }
+.poster-head .w-glitch::after {
+ content: attr(data-text); position: absolute; left: 0.05em; top: 0; color: #c8ff2a; z-index: -1;
+ clip-path: polygon(0 0, 100% 0, 100% 34%, 0 34%, 0 58%, 100% 58%, 100% 72%, 0 72%);
+ transform: translateX(0.07em);
+}
+@media (prefers-color-scheme: dark) { :root:not([data-theme='light']) .poster-head .w-glitch { color: #6f8bff; } }
+:root[data-theme='dark'] .poster-head .w-glitch { color: #6f8bff; }
+.hand { position: relative; height: 450px; margin-top: -32px; }
+.tcard {
+ --w: 236px;
+ position: absolute; left: 50%; bottom: 60px; width: var(--w); aspect-ratio: 5 / 7; margin-left: calc(var(--w) / -2);
+ transform-origin: 50% 100%;
+ transform: translate(var(--x, 0px), var(--lift, 0px)) rotate(var(--a, 0deg)) 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: translate(calc(var(--x, 0px) * 0.2), 220px) rotate(calc(var(--a, 0deg) * 0.15)) scale(0.86); opacity: 0; }
+/* The fan picks the hovered card by fixed columns (deck.js), so cards themselves ignore the pointer. */
+@media (min-width: 721px) {
+ .hand { cursor: pointer; }
+ .hand .tcard { pointer-events: none; }
+}
+.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; }
+
+/* Reel (v1): one scene on a big stage, a stepper and pause over it */
+.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; }
+
+/* Under the stage: the scene's real timeline rows */
+.reel-tl { max-width: 960px; margin: 14px auto 0; border-radius: 18px; overflow: hidden; background: #141319; color: #ece8f4; box-shadow: var(--shadow), inset 0 0 0 1px rgba(255, 255, 255, 0.06); }
+.reel-meta { display: flex; align-items: baseline; gap: 16px; padding: 12px 16px 10px; border-bottom: 1px solid rgba(255, 255, 255, 0.06); }
+.reel-caption { margin: 0; color: #a49fb3; font-size: 14px; line-height: 1.4; min-height: 1.4em; }
+.reel-caption b { color: #fff; font-weight: 400; font-family: var(--serif); font-size: 19px; margin-right: 4px; }
+.tl-time { margin-left: auto; color: #a49fb3; white-space: nowrap; }
+.tl-dock { --name: 150px; position: relative; padding-bottom: 8px; background: #17161d; 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%); }
+
+/* Phones and tablets */
+@media (max-width: 980px) {
+ .tcard { --w: 196px; }
+ .hand { height: 440px; }
+ .feature-grid { grid-template-columns: repeat(2, 1fr); }
+}
+@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; }
+ .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; }
+ .reel-meta { flex-direction: column; gap: 4px; }
+ .reel-caption b { display: block; }
+ .tl-time { margin-left: 0; }
+ .sub { font-size: 15.5px; }
+ .nav { gap: 12px; }
+ .nav-links { display: none; }
+ .nav-cta { margin-left: auto; }
+ main > section { padding-top: 88px; }
+ .hero { padding-top: 40px !important; }
+ .stage { margin-top: 44px; border-radius: 22px; }
+ .morph { height: 190px; }
+ .section-head h2 br { display: none; }
+ .section-head { margin-bottom: 40px; }
+ .readout .arrow, .readout [data-font-b] { display: none; }
+ .platforms { grid-template-columns: 1fr; }
+ .marquee img { height: 150px; }
+ .feature-grid { gap: 40px 18px; }
+ .feature-grid h3 { font-size: 18px; }
+ .feature-grid p { font-size: 14px; }
+}
+
+@media (prefers-reduced-motion: reduce) {
+ *, *::before, *::after { animation-duration: 0.01ms !important; animation-iteration-count: 1 !important; transition-duration: 0.01ms !important; }
+ .marquee { animation: none; }
+}
diff --git a/website/timeline.js b/website/timeline.js
new file mode 100644
index 0000000..44d6032
--- /dev/null
+++ b/website/timeline.js
@@ -0,0 +1,182 @@
+// The motion section, on the reel from v1: one scene playing at a time on a
+// big stage, a stepper and a pause button over it, and arrows to either side.
+// Under the stage are that scene's real timeline rows, laid out the way the
+// app's timeline dock lays them out, with a playhead that follows the video.
+// Dragging across the rows scrubs.
+import { REEL, SHOWCASES } from './showcases.js?v=3';
+import { stepper } from './stepper.js?v=3';
+
+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;
+/** Each scene plays this many loops before the reel moves on. */
+const LOOPS = 2;
+
+const SCENES = REEL.map((id) => SHOWCASES.find((s) => s.id === id));
+
+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 ``;
+ })
+ .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 frame = $('[data-reel]');
+ const media = $('.reel-media', frame);
+ const play = $('.reel-play', frame);
+ const title = $('[data-reel-title]'), note = $('[data-reel-note]');
+ const dock = $('[data-tl-dock]');
+ const head = $('[data-tl-head]', dock);
+ const rowsEl = $('[data-tl-rows]', dock);
+ const ruler = $('[data-tl-ruler]', dock);
+ const time = $('[data-tl-time]');
+
+ const videos = SCENES.map((s) => {
+ const v = document.createElement('video');
+ v.muted = true;
+ v.loop = true;
+ v.playsInline = true;
+ v.preload = 'none';
+ v.poster = `/media/${s.id}.webp`;
+ v.dataset.src = `/media/${s.id}.mp4`;
+ v.setAttribute('aria-label', `${s.title}: ${s.flavor}`);
+ media.append(v);
+ return v;
+ });
+
+ let cur = 0, inView = false, userPaused = false, scrubbing = false, raf = 0, clips = [];
+
+ const render = (i) => {
+ const s = SCENES[i];
+ rowsEl.innerHTML = rowsHTML(s);
+ ruler.innerHTML = rulerHTML(s.duration);
+ clips = [...rowsEl.querySelectorAll('.clip[data-start]')].map((el) => ({ el, a: +el.dataset.start, b: +el.dataset.end }));
+ title.textContent = s.title;
+ note.textContent = s.flavor;
+ };
+
+ const paint = () => {
+ const v = videos[cur], D = SCENES[cur].duration;
+ const t = v.currentTime % 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);
+ };
+ // The playhead only runs while a video plays on screen.
+ const tick = () => {
+ paint();
+ raf = videos[cur].paused ? 0 : requestAnimationFrame(tick);
+ };
+ const run = () => raf || (raf = requestAnimationFrame(tick));
+
+ const show = (i) => {
+ cur = i;
+ videos.forEach((v, k) => {
+ if (k === i) {
+ if (!v.src && inView) v.src = v.dataset.src;
+ v.currentTime = 0;
+ v.classList.add('on');
+ if (inView && !userPaused && !reduced) v.play().then(run, () => {});
+ } else {
+ v.classList.remove('on');
+ v.pause();
+ }
+ });
+ // Warm the next one (videos load only once the reel is on screen).
+ const n = videos[(i + 1) % videos.length];
+ if (!n.src && inView) {
+ n.preload = 'auto';
+ n.src = n.dataset.src;
+ }
+ render(i);
+ paint();
+ };
+
+ const steps = stepper($('.pasito', frame), SCENES.length, { duration: (i) => SCENES[i].duration * LOOPS * 1000, 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));
+
+ const pause = (on) => {
+ userPaused = on;
+ play.classList.toggle('paused', on);
+ play.setAttribute('aria-label', on ? 'Play' : 'Pause');
+ const v = videos[steps.index];
+ if (on) {
+ steps.pause();
+ v.pause();
+ } else {
+ steps.play();
+ v.play().then(run, () => {});
+ }
+ };
+ play.addEventListener('click', () => pause(!userPaused));
+
+ // Scrub: drag across the rows to move through the loop (the reel holds still).
+ const seek = (e) => {
+ const r = rowsEl.getBoundingClientRect();
+ const u = clamp((e.clientX - r.left - parseFloat(getComputedStyle(dock).getPropertyValue('--name'))) / (r.width - parseFloat(getComputedStyle(dock).getPropertyValue('--name')) - 14), 0, 1);
+ videos[cur].currentTime = u * SCENES[cur].duration;
+ paint();
+ };
+ dock.addEventListener('pointerdown', (e) => {
+ if (e.pointerType !== 'mouse' && e.pointerType !== 'pen') return;
+ scrubbing = true;
+ dock.setPointerCapture(e.pointerId);
+ if (!userPaused) pause(true);
+ seek(e);
+ });
+ dock.addEventListener('pointermove', (e) => scrubbing && seek(e));
+ dock.addEventListener('pointerup', () => (scrubbing = false));
+
+ if (reduced) {
+ userPaused = true;
+ play.classList.add('paused');
+ play.setAttribute('aria-label', 'Play');
+ }
+ new IntersectionObserver(
+ ([en]) => {
+ const first = en.isIntersecting && !videos[steps.index].src;
+ inView = en.isIntersecting;
+ if (first) show(steps.index);
+ const v = videos[steps.index];
+ if (inView && !userPaused && !reduced) {
+ v.play().then(run, () => {});
+ steps.play();
+ } else {
+ v.pause();
+ steps.pause();
+ }
+ },
+ { threshold: 0.35 },
+ ).observe(frame);
+}
diff --git a/website/vercel.json b/website/vercel.json
new file mode 100644
index 0000000..9210e86
--- /dev/null
+++ b/website/vercel.json
@@ -0,0 +1,14 @@
+{
+ "$schema": "https://openapi.vercel.sh/vercel.json",
+ "cleanUrls": true,
+ "headers": [
+ {
+ "source": "/(media|fonts)/(.*)",
+ "headers": [{ "key": "Cache-Control", "value": "public, max-age=31536000, immutable" }]
+ },
+ {
+ "source": "/(.*)\\.(js|css)",
+ "headers": [{ "key": "Cache-Control", "value": "public, max-age=3600, must-revalidate" }]
+ }
+ ]
+}