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) + ![Templates rendered by Morphotype](docs/images/gallery.png) ## 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)}

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

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

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

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

    +
    `; +} + +function makeCard(s, i) { + const el = document.createElement('article'); + el.className = 'tcard'; + el.tabIndex = 0; + el.dataset.i = i; + el.setAttribute('role', 'button'); + el.setAttribute('aria-label', `${s.title}: ${s.material}. Open card`); + for (const [k, v] of Object.entries(tones(s))) el.style.setProperty(k, v); + el.innerHTML = `
    ${front(s, i)}${back(s)}
    `; + return el; +} + +/* ---------- Foil texture ---------- */ + +function sparkle() { + const c = document.createElement('canvas'); + c.width = c.height = 90; + const g = c.getContext('2d'); + const img = g.createImageData(90, 90); + for (let i = 0; i < 90 * 90; i++) { + const r = Math.random(); + img.data[i * 4 + 3] = r < 0.035 ? 255 : r < 0.12 ? 190 : 90; + } + g.putImageData(img, 0, 0); + document.documentElement.style.setProperty('--sparkle', `url(${c.toDataURL()})`); +} + +/* ---------- Tilt, eased toward the pointer ---------- */ + +function tilter(el, range) { + const st = { rx: 0, ry: 0, tx: 0, ty: 0, raf: 0 }; + const step = () => { + st.rx += (st.tx - st.rx) * 0.16; + st.ry += (st.ty - st.ry) * 0.16; + el.style.setProperty('--rx', `${st.rx.toFixed(2)}deg`); + el.style.setProperty('--ry', `${st.ry.toFixed(2)}deg`); + st.raf = Math.abs(st.tx - st.rx) + Math.abs(st.ty - st.ry) > 0.05 ? requestAnimationFrame(step) : 0; + }; + const kick = () => st.raf || (st.raf = requestAnimationFrame(step)); + return { + move(e) { + const r = el.getBoundingClientRect(); + const px = clamp((e.clientX - r.left) / r.width, 0, 1), py = clamp((e.clientY - r.top) / r.height, 0, 1); + if (!reduced) { + st.tx = (0.5 - py) * range; + st.ty = (px - 0.5) * range * 1.2; + } + el.style.setProperty('--gx', `${(px * 100).toFixed(1)}%`); + el.style.setProperty('--gy', `${(py * 100).toFixed(1)}%`); + el.style.setProperty('--fx', `${(px * 100).toFixed(1)}%`); + el.style.setProperty('--fy', `${(py * 100).toFixed(1)}%`); + el.style.setProperty('--hov', '1'); + kick(); + }, + leave() { + st.tx = st.ty = 0; + el.style.setProperty('--hov', '0'); + kick(); + }, + }; +} + +function playIn(card, on) { + const v = $('video', card); + if (!v) return; + if (on && !reduced) { + if (!v.src) v.src = v.dataset.src; + v.onplaying = () => v.classList.add('playing'); + v.play().catch(() => {}); + } else { + v.pause(); + } +} + +/* ---------- The 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. +

    + + +
    + + +
    +
    + +
    +
    +
    + + +
    +
    +

    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.

    +
    + +
    + + +
    +
    +

    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.

    +
    +
    +
    + + +
    +
    + +
    +
    +
    +

    0.00 s · f0
    +
    +
    +
    +
    +
    +
    +
    + + +
    +
    +

    Two modes

    +

    Design for logos and posts.
    Type for whole fonts.

    +
    +
    + + + +
    +
    + + The Morphotype editor with a frosted glass folder over 3D 404 lettering +
    +
    + + +
    +
    +
    +
    + + +
    +
    +

    What’s new

    +

    Version 0.9.1

    +
    + + 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 = ``; + } +} + +/* ---------- 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) => `${i < list.length ? id.replace(/-/g, ' ') : ''}= 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 `
    ${esc(r.name)}
    ${lanes}
    `; + }) + .join(''); +} + +function rulerHTML(D) { + let out = ''; + for (let f = 0; f <= D * FPS; f += FPS / 4) { + const t = f / FPS, x = (t / D) * 100; + const major = f % FPS === 0; + out += ``; + if (major && t < D) out += `${t}s`; + } + return out; +} + +export function timeline() { + const 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" }] + } + ] +}