Files
TMCandClaude Opus 5.5 0fbefd2ca6 Website: chapter 8 labels on the layers' corners, one layer at a time; phone centred
Labels: each layer's pin now sits on its outer rounded corner, the
layer's rightmost point, so a tag straight across from it is joined by
a short line that crosses nothing; the stack moves left by just enough
for the tags to fit, and the open stack is a little smaller so it keeps
clear of the chapter's heading. While the stack is open, scrolling walks
through the layers from the glass to the wallpaper: the one looked at
stays bright and its tag lights up cyan, the others dim (where the tags
are shown). Chapter 8 is three screens long for it.

Centring: the labels' list sat in the stage's grid flow, so the grid
split its height into two rows and the story phone was centred in the
top one (about 100 px high on a 1146 px window). It is out of the flow
now; measured equal gaps above and below at 2000 x 1146, 1440 x 900
and 1280 x 720.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 22:20:10 +01:00

2074 lines
82 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* Flexera Launcher landing page. Plain JavaScript, no libraries, nothing loaded from elsewhere.
*
* The phone is built here, three times: a looping teaser in the hero, one that the page's scroll
* position drives through seven chapters, and one people can play with. All three draw from the
* same state object (see blank()), so a picture of the phone is a pure function of that state:
* the story computes it from the scroll position, the playground animates it from gestures. */
(() => {
'use strict';
/** The version shown on the page. Change it here after a release. */
const VERSION = '0.13.0';
const motion = window.matchMedia('(prefers-reduced-motion: reduce)');
let reduced = motion.matches;
/* ------------------------------------------------------------------ math */
const clamp = (v, a = 0, b = 1) => (v < a ? a : v > b ? b : v);
const lerp = (a, b, t) => a + (b - a) * t;
const seg = (t, a, b) => clamp((t - a) / (b - a));
const inOut = (t) => (t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2);
const outCubic = (t) => 1 - Math.pow(1 - t, 3);
const linear = (t) => t;
const smooth = (t) => t * t * (3 - 2 * t);
const E = (t, a, b) => inOut(seg(t, a, b));
/** Eased value along key points [[t, value], ...]. */
function keys(t, pts) {
if (t <= pts[0][0]) return pts[0][1];
for (let i = 1; i < pts.length; i++) {
if (t <= pts[i][0]) return lerp(pts[i - 1][1], pts[i][1], inOut(seg(t, pts[i - 1][0], pts[i][0])));
}
return pts[pts.length - 1][1];
}
/** Eased position along key points [[t, x, y], ...]. */
const route = (t, pts) => ({ x: keys(t, pts.map((p) => [p[0], p[1]])), y: keys(t, pts.map((p) => [p[0], p[2]])) });
const near = (t, list, eps) => list.some((v) => t > v - eps && t < v + eps * 2);
/** A pull against a wall: follows the finger at first, then gives less and less. */
const rubber = (x) => Math.sign(x) * 96 * (1 - Math.exp(-Math.abs(x) / 140));
/* ------------------------------------------------------------------ data */
const GRADS = {
cyan: ['#1ddbfd', '#3d6ff2'],
blue: ['#4a7cf6', '#653df0'],
violet: ['#8a4dff', '#f04ff0'],
sunset: ['#fc59fb', '#ff8a5b'],
mint: ['#2ee6a8', '#0f9fb8'],
gold: ['#ffd34e', '#ff9440'],
rose: ['#ff5c7a', '#cf2470'],
slate: ['#8e96bd', '#4a4f78'],
};
const APPS = [
['Authenticator', 'shield', 'slate'],
['Banking', 'bank', 'mint'],
['Books', 'book', 'gold'],
['Browser', 'globe', 'cyan'],
['Calculator', 'calc', 'slate'],
['Calendar', 'cal', 'blue'],
['Camera', 'camera', 'violet'],
['Clock', 'clock', 'blue'],
['Contacts', 'person', 'cyan'],
['Files', 'files', 'gold'],
['Fitness', 'pulse', 'rose'],
['Gallery', 'image', 'sunset'],
['Mail', 'mail', 'rose'],
['Maps', 'map', 'mint'],
['Messages', 'msg', 'cyan', 'Sam: Dinner at 8? I booked a table'],
['Music', 'music', 'violet'],
['News', 'news', 'slate'],
['Notes', 'notes', 'gold'],
['Phone', 'phone', 'mint'],
['Podcasts', 'mic', 'violet'],
['Recorder', 'wave', 'rose'],
['Settings', 'sliders', 'slate'],
['Shop', 'bag', 'sunset'],
['Translate', 'translate', 'blue'],
['Travel', 'plane', 'cyan'],
['Video', 'play', 'rose'],
['Wallet', 'wallet', 'mint'],
['Weather', 'sun', 'gold'],
['Zen', 'moon', 'blue'],
['Alarm', 'alarm', 'rose'],
['Compass', 'compass', 'cyan'],
['Docs', 'notes', 'blue'],
['Games', 'game', 'violet'],
['Health', 'heart', 'rose'],
['Lens', 'search', 'violet'],
['Measure', 'ruler', 'gold'],
['Radio', 'radio', 'sunset'],
['Stocks', 'chart', 'mint'],
['Tasks', 'check', 'blue'],
['Vault', 'lock', 'slate'],
].map(([n, g, c, note]) => ({ n, g, c, note })).sort((a, b) => a.n.localeCompare(b.n));
const APP = Object.fromEntries(APPS.map((a) => [a.n, a]));
const LETTERS = [...new Set(APPS.map((a) => a.n[0]))];
const L = (letter) => LETTERS.indexOf(letter);
const FOLDERS = {
media: { title: 'Media', apps: ['Video', 'Podcasts', 'Gallery', 'Books', 'News'] },
tools: { title: 'Tools', apps: ['Calculator', 'Files', 'Recorder', 'Translate', 'Notes', 'Authenticator'] },
};
const HOME = [
[
{ k: 'clock', c: 0, r: 0, w: 4, h: 2 },
{ k: 'pics', c: 2, r: 2, w: 2, h: 2 },
{ app: 'Maps', c: 0, r: 2 }, { app: 'Music', c: 1, r: 2 },
{ app: 'Mail', c: 0, r: 3 }, { app: 'Calendar', c: 1, r: 3 },
{ folder: 'media', c: 0, r: 4 }, { app: 'Weather', c: 1, r: 4 }, { app: 'Wallet', c: 2, r: 4 }, { folder: 'tools', c: 3, r: 4 },
{ app: 'Phone', c: 0, r: 5 }, { app: 'Messages', c: 1, r: 5, dot: true }, { app: 'Browser', c: 2, r: 5 }, { app: 'Camera', c: 3, r: 5 },
],
[
{ app: 'Fitness', c: 0, r: 0 }, { app: 'Zen', c: 1, r: 0 }, { app: 'Travel', c: 2, r: 0 }, { app: 'Shop', c: 3, r: 0 },
{ app: 'Banking', c: 0, r: 1 }, { app: 'Contacts', c: 1, r: 1 }, { app: 'Clock', c: 2, r: 1 }, { app: 'Settings', c: 3, r: 1 },
{ app: 'Translate', c: 0, r: 2 }, { app: 'Recorder', c: 1, r: 2 },
],
];
/* ------------------------------------------------------------------ the phone */
const W = 390;
const H = 844;
const SIDE = 14;
const CW = (W - 2 * SIDE) / 4;
const RH = 104;
const GRID_TOP = 56;
const LIST_TOP = 46;
const MENU_X = 120;
const CLOCK_W = 4 * CW - 36;
const DEFAULT_WEIGHT = 300;
/** The story runs from 0 to 10: chapters 1 to 7 take one unit each, chapter 8 three. */
const STORY_END = 10;
const LONG = 3;
/** Icon shapes, as border radii for icons and for the picture widget. */
const SHAPES = {
squircle: { icon: '30%', widget: '30px' },
circle: { icon: '50%', widget: '50%' },
square: { icon: '14%', widget: '14px' },
teardrop: { icon: '50% 50% 50% 14%', widget: '50% 50% 50% 16px' },
};
/** The look every phone on the page shares; the detail cards change it. */
const look = { lefty: false, comfy: false, shape: 'squircle' };
/** A short vibration on phones that have one, like the launcher's tick per letter. */
function buzz(ms = 7) {
if (reduced || !navigator.vibrate) return;
try {
navigator.vibrate(ms);
} catch (_) { /* some browsers refuse without a user gesture */ }
}
const gl = (id, cls = 'gl') => `<svg class="${cls}" aria-hidden="true"><use href="#g-${id}"/></svg>`;
function icon(app, cls = '') {
const [a, b] = GRADS[app.c];
return `<span class="ic${cls}" style="--a:${a};--b:${b}">${gl(app.g, '')}</span>`;
}
function cellMarkup(it) {
const box = `left:${SIDE + it.c * CW}px;top:${GRID_TOP + it.r * RH}px;width:${(it.w || 1) * CW}px;height:${(it.h || 1) * RH}px`;
if (it.k === 'clock') {
return `<div class="w-clock" data-key="clock" style="${box}">
<div class="clk__line clk__time"><span data-time>9:41</span></div>
<div class="clk__line clk__date"><span data-date>Thursday, November 12</span></div>
<div class="clk__line clk__wx"><span>18&deg; &middot; Clear</span></div></div>`;
}
if (it.k === 'pics') {
return `<div class="w-pics" data-key="pics" style="${box}"><div class="pics">
<i class="pic pic--a on"></i><i class="pic pic--b"></i><i class="pic pic--c"></i></div></div>`;
}
if (it.folder) {
const f = FOLDERS[it.folder];
return `<div class="cell" data-key="${it.folder}" data-folder="${it.folder}" style="${box}">
<span class="fic">${f.apps.slice(0, 4).map((n) => icon(APP[n], ' ic--mini')).join('')}</span>
<span class="lbl">${f.title}</span></div>`;
}
const app = APP[it.app];
return `<div class="cell" data-key="${app.n}" data-app="${app.n}" style="${box}">${icon(app)}${it.dot ? '<i class="ndot"></i>' : ''}<span class="lbl">${app.n}</span></div>`;
}
function listMarkup() {
let html = `<div class="search">${gl('search')}<span>Search apps</span></div>`;
for (const letter of LETTERS) {
html += `<div class="grp" data-letter="${letter}"><div class="hdr">${letter}</div>`;
for (const a of APPS.filter((x) => x.n[0] === letter)) {
html += `<div class="row${a.note ? ' row--note' : ''}" data-row="${a.n}">${icon(a)}
<span class="row__t"><span class="row__n">${a.n}</span>${a.note ? `<span class="row__p">${a.note}</span>` : ''}</span></div>`;
}
html += '</div>';
}
return html;
}
function phoneMarkup() {
const pages = HOME.map((items, pi) => `<div class="page" style="left:${pi * W}px">${items.map(cellMarkup).join('')}</div>`).join('');
const dots = HOME.map((_, i) => `<i${i ? '' : ' class="on"'}></i>`).join('');
return `<div class="phone"><div class="screen">
<div class="world"><div class="wall"></div>
<div class="home"><div class="pages">${pages}</div><div class="pagedots">${dots}</div></div>
</div>
<div class="fscrim"></div>
<div class="fcard"><div class="fcard__grid"></div><div class="fcard__title"></div></div>
<div class="drawer"><div class="drawer__in">
<div class="list">${listMarkup()}</div>
<div class="alpha"><span class="alpha__s">${gl('search', 'gl')}</span>${LETTERS.map((l) => `<span class="alpha__l">${l}</span>`).join('')}</div>
<div class="bubble"></div>
</div></div>
<div class="menu">
<div class="mi">${gl('info')}App info</div>
<div class="mi">${gl('widgets')}Widgets</div>
<div class="mi">${gl('edit')}Customize</div>
<div class="mi mi--hide">${gl('eyeoff')}Hide app</div>
</div>
<div class="snack"><span class="snack__t">Podcasts is hidden</span><button type="button" class="snack__undo" tabindex="-1">Undo</button></div>
<div class="sheet"><i class="sheet__grab"></i><h4>Clock</h4>
<p class="sheet__h">Alignment</p>
<div class="seg" data-seg="align"><span data-v="0" data-k="align-0">Left</span><span data-v="0.5" data-k="align-0.5">Center</span><span data-v="1" data-k="align-1">Right</span></div>
<p class="sheet__h">Size</p>
<div class="seg"><span data-k="size-s">Small</span><span class="on" data-k="size-m">Medium</span><span data-k="size-l">Large</span></div>
<p class="sheet__h">Weight</p>
<div class="seg" data-seg="weight"><span data-v="100" data-k="weight-100">Thin</span><span data-v="300" data-k="weight-300">Light</span><span data-v="400" data-k="weight-400">Regular</span><span data-v="700" data-k="weight-700">Bold</span></div>
</div>
<div class="cam">
<div class="cam__view"><i class="cam__hill cam__hill--1"></i><i class="cam__hill cam__hill--2"></i><i class="cam__grid"></i><i class="cam__focus"></i></div>
<div class="cam__modes"><span>Video</span><span class="on">Photo</span><span>Portrait</span></div>
<div class="cam__bar"><i class="cam__thumb"></i><i class="cam__shutter"></i><i class="cam__flip">${gl('flip')}</i></div>
<button type="button" class="cam__close" tabindex="-1" aria-label="Close camera">&times;</button>
</div>
<div class="sbar"><span class="sbar__time">9:41</span><span class="sbar__icons"><span class="sbar__sig"><i></i><i></i><i></i><i></i></span><span class="sbar__bat"></span></span></div>
<i class="notch"></i>
<i class="gbar"></i>
<div class="finger"><i class="finger__ring"></i></div>
</div></div>`;
}
/** Everything the phone can show. Numbers are 0..1 unless noted. */
function blank() {
return {
drawer: 0,
list: 0, // px the app list is scrolled
scrub: 0, // letter index, fractional
scrubShow: 0,
folder: 0,
folderId: 'media',
pull: 0, // px the pages are dragged sideways
page: 0,
camera: 0, // the camera is the page left of the first one: 0 home, 1 camera fully in
shutter: 0,
sheet: 0,
align: 0, // 0 left, .5 center, 1 right
weight: DEFAULT_WEIGHT,
menu: 0,
menuY: 420,
menuPick: 0,
held: null,
hidden: {},
snack: 0,
snackText: 'Podcasts is hidden',
finger: { x: 195, y: 600, o: 0, press: 0, ring: 0 },
};
}
/** Offset of el inside ancestor, summed along the offsetParent chain (ignores transforms). */
function offsetIn(el, ancestor) {
let x = 0;
let y = 0;
while (el && el !== ancestor) {
x += el.offsetLeft;
y += el.offsetTop;
el = el.offsetParent;
}
return { x, y };
}
class Phone {
constructor(host) {
this.host = host;
host.innerHTML = phoneMarkup();
const q = (sel) => host.querySelector(sel);
const qa = (sel) => [...host.querySelectorAll(sel)];
this.el = q('.phone');
this.screen = q('.screen');
this.r = {
world: q('.world'), home: q('.home'), pages: q('.pages'), dots: qa('.pagedots i'),
fscrim: q('.fscrim'), fcard: q('.fcard'), fgrid: q('.fcard__grid'), ftitle: q('.fcard__title'),
drawer: q('.drawer'), drawerIn: q('.drawer__in'), list: q('.list'), alpha: q('.alpha'), letters: qa('.alpha__l'), bubble: q('.bubble'),
menu: q('.menu'), menuHide: q('.mi--hide'), snack: q('.snack'), snackT: q('.snack__t'),
sheet: q('.sheet'), segAlign: qa('[data-seg="align"] span'), segWeight: qa('[data-seg="weight"] span'),
cam: q('.cam'), shutter: q('.cam__shutter'), finger: q('.finger'), ring: q('.finger__ring'),
clockLines: qa('.clk__line > span'), clockTime: q('.clk__time'), time: q('[data-time]'), date: q('[data-date]'), stime: q('.sbar__time'),
pics: qa('.pic'),
rows: new Map(qa('.row').map((el) => [el.dataset.row, el])),
};
this.scale = 0.5;
this.picIndex = 0;
this.folderId = null;
this.fillFolder('media');
this.fit();
if ('ResizeObserver' in window) new ResizeObserver(() => this.fit()).observe(host);
this.measure();
}
fit() {
const w = this.host.clientWidth;
if (!w) return;
this.scale = w / 414;
this.el.style.setProperty('--s', this.scale.toFixed(5));
}
/** Reads the layout once, so that animating never has to. Call again when rows change height. */
measure() {
const r = this.r;
const g = {};
g.groupTop = {};
for (const grp of r.list.querySelectorAll('.grp')) g.groupTop[grp.dataset.letter] = grp.offsetTop;
g.rowTop = {};
g.rowH = {};
for (const [name, el] of r.rows) {
g.rowTop[name] = el.offsetTop;
g.rowH[name] = el.offsetHeight || 54;
}
g.maxScroll = Math.max(0, LIST_TOP + r.list.offsetHeight + 150 - H);
const a = offsetIn(r.alpha, this.screen);
g.alphaX = a.x + r.alpha.offsetWidth / 2;
g.letterY = r.letters.map((l) => a.y + l.offsetTop + l.offsetHeight / 2);
g.home = {};
for (const el of this.host.querySelectorAll('.page:first-child [data-key]')) {
const target = el.querySelector('.ic, .fic') || el;
const p = offsetIn(target, this.screen);
g.home[el.dataset.key] = { x: p.x + target.offsetWidth / 2, y: p.y + target.offsetHeight / 2 };
}
g.fcx = r.fcard.offsetLeft + r.fcard.offsetWidth / 2;
g.fcy = r.fcard.offsetTop + r.fcard.offsetHeight / 2;
const sheetTop = H - r.sheet.offsetHeight;
g.seg = {};
for (const sp of this.host.querySelectorAll('.seg span')) {
const p = offsetIn(sp, r.sheet);
g.seg[sp.dataset.k] = { x: p.x + sp.offsetWidth / 2, y: sheetTop + p.y + sp.offsetHeight / 2 };
}
const m = offsetIn(r.menuHide, r.menu);
g.menuHide = { x: m.x + r.menuHide.offsetWidth / 2, y: m.y + r.menuHide.offsetHeight / 2 };
g.menuH = r.menu.offsetHeight || 212;
this.g = g;
}
/** The settings from the detail cards: alphabet on the left, taller rows, icon shape. */
setLook(look) {
this.lefty = !!look.lefty;
this.screen.classList.toggle('is-lefty', this.lefty);
this.screen.classList.toggle('is-comfy', !!look.comfy);
const shape = SHAPES[look.shape] || SHAPES.squircle;
this.screen.style.setProperty('--shape', shape.icon);
this.screen.style.setProperty('--shape-w', shape.widget);
for (const el of this.r.rows.values()) el._h = 0; // row heights changed
this.measure();
}
fillFolder(id) {
this.folderId = id;
const f = FOLDERS[id];
this.r.fgrid.innerHTML = f.apps.map((n) => `<div class="fcard__app">${icon(APP[n])}<span>${n}</span></div>`).join('');
this.r.ftitle.textContent = f.title;
if (this.g) this.g.fcy = this.r.fcard.offsetTop + this.r.fcard.offsetHeight / 2;
}
/** Where the list rests when a letter is chosen: its header a little above the middle. */
scrollFor(i) {
const letter = LETTERS[clamp(Math.round(i), 0, LETTERS.length - 1)];
return clamp(LIST_TOP + this.g.groupTop[letter] - H * 0.42, 0, this.g.maxScroll);
}
/** scrollFor between letters, snapping near each one, for scroll-driven scrubbing. */
scrollAt(f) {
const a = Math.floor(f);
const b = Math.ceil(f);
if (a === b) return this.scrollFor(a);
return lerp(this.scrollFor(a), this.scrollFor(b), smooth(seg(f - a, 0.3, 0.7)));
}
letterYAt(f) {
const ys = this.g.letterY;
const a = clamp(Math.floor(f), 0, ys.length - 1);
const b = clamp(a + 1, 0, ys.length - 1);
return lerp(ys[a], ys[b], clamp(f - a));
}
letterAtY(y) {
const ys = this.g.letterY;
if (y <= ys[0]) return 0;
for (let i = 1; i < ys.length; i++) if (y <= ys[i]) return i - 1 + (y - ys[i - 1]) / (ys[i] - ys[i - 1]);
return ys.length - 1;
}
rowY(name, scroll) {
return LIST_TOP + this.g.rowTop[name] + this.g.rowH[name] / 2 - scroll;
}
tick(now, date) {
if (this.r.time.textContent !== now) {
this.r.time.textContent = now;
this.r.stime.textContent = now;
}
if (this.r.date.textContent !== date) this.r.date.textContent = date;
}
nextPicture() {
this.r.pics[this.picIndex].classList.remove('on');
this.picIndex = (this.picIndex + 1) % this.r.pics.length;
this.r.pics[this.picIndex].classList.add('on');
}
render(s) {
const r = this.r;
const g = this.g;
const css = (el, prop, val) => {
const k = '_' + prop;
if (el[k] !== val) {
el[k] = val;
el.style[prop] = val;
}
};
const d = s.drawer;
const f = s.folder;
// Wallpaper and home share one layer, so a folder blurs both, and the camera, the page to
// the left of the first one, pushes both to the right as it slides in.
const cam = s.camera;
const slide = cam > 0.0005 ? `translateX(${(cam * W).toFixed(1)}px)` : '';
const shrink = f > 0.001 ? `scale(${(1 - 0.035 * f).toFixed(4)})` : '';
css(r.world, 'transform', `${slide} ${shrink}`.trim());
css(r.world, 'filter', f > 0.001 ? `blur(${(f * 16).toFixed(2)}px)` : '');
css(r.home, 'opacity', (1 - d).toFixed(3));
css(r.home, 'transform', d > 0.001 ? `translateY(${(-26 * d).toFixed(1)}px) scale(${(1 - 0.05 * d).toFixed(4)})` : '');
css(r.pages, 'transform', `translateX(${(-s.page * W + s.pull).toFixed(1)}px)`);
const page = Math.round(clamp(s.page - s.pull / W, 0, HOME.length - 1));
r.dots.forEach((dot, i) => dot.classList.toggle('on', i === page));
// Folder: grows out of its icon.
css(r.fscrim, 'opacity', (0.34 * f).toFixed(3));
if (s.folderId !== this.folderId) this.fillFolder(s.folderId);
if (f > 0.001) {
const from = g.home[s.folderId] || { x: g.fcx, y: g.fcy };
css(r.fcard, 'visibility', 'visible');
css(r.fcard, 'opacity', clamp(f * 2.2).toFixed(3));
css(r.fcard, 'transform', `translate(${lerp(from.x - g.fcx, 0, f).toFixed(1)}px, ${lerp(from.y - g.fcy, 0, f).toFixed(1)}px) scale(${lerp(0.2, 1, f).toFixed(4)})`);
} else {
css(r.fcard, 'visibility', 'hidden');
}
// App list.
css(r.drawer, 'visibility', d > 0.002 ? 'visible' : 'hidden');
css(r.drawer, 'opacity', d.toFixed(3));
css(r.drawerIn, 'transform', d < 0.999 ? `translateY(${((1 - d) * 56).toFixed(1)}px)` : '');
css(r.list, 'transform', `translateY(${(-s.list).toFixed(1)}px)`);
// The wave: letters near the thumb swell and lean out; the chosen one rides in a bubble.
const show = s.scrubShow;
const sel = clamp(Math.round(s.scrub), 0, LETTERS.length - 1);
const out = this.lefty ? 1 : -1; // letters lean away from the edge they sit on
r.letters.forEach((el, i) => {
const k = show * Math.exp(-((i - s.scrub) ** 2) / 4.2);
css(el, 'transform', k > 0.002 ? `translateX(${(out * 46 * k).toFixed(1)}px) scale(${(1 + 0.6 * k).toFixed(3)})` : '');
el.classList.toggle('on', show > 0.4 && i === sel);
});
css(r.bubble, 'opacity', show.toFixed(3));
if (show > 0.001) {
const bx = this.lefty ? g.alphaX + 107 - 33 : g.alphaX - 140;
css(r.bubble, 'transform', `translate(${bx.toFixed(1)}px, ${(this.letterYAt(s.scrub) - 33).toFixed(1)}px) scale(${(0.5 + 0.5 * show).toFixed(3)})`);
if (r.bubble.textContent !== LETTERS[sel]) r.bubble.textContent = LETTERS[sel];
}
// Hidden apps fold away.
for (const [name, el] of r.rows) {
const x = s.hidden[name] || 0;
if (el._hx !== x) {
if (!el._h) el._h = el.offsetHeight;
el._hx = x;
el.style.height = x ? `${((1 - x) * el._h).toFixed(1)}px` : '';
el.style.opacity = x ? (1 - x).toFixed(3) : '';
}
el.classList.toggle('held', s.held === name);
}
// Long-press menu and the message after hiding.
css(r.menu, 'visibility', s.menu > 0.002 ? 'visible' : 'hidden');
css(r.menu, 'opacity', s.menu.toFixed(3));
css(r.menu, 'transform', `translate(${MENU_X}px, ${s.menuY.toFixed(1)}px) scale(${(0.9 + 0.1 * s.menu).toFixed(3)})`);
r.menuHide.classList.toggle('pick', s.menuPick > 0.5);
css(r.snack, 'visibility', s.snack > 0.002 ? 'visible' : 'hidden');
css(r.snack, 'opacity', s.snack.toFixed(3));
css(r.snack, 'transform', `translateY(${((1 - s.snack) * 26).toFixed(1)}px)`);
if (r.snackT.textContent !== s.snackText) r.snackT.textContent = s.snackText;
// Camera, sliding in from the left with the home screen.
css(r.cam, 'visibility', cam > 0.0005 ? 'visible' : 'hidden');
css(r.cam, 'transform', `translateX(${((cam - 1) * W).toFixed(1)}px)`);
css(r.shutter, 'transform', s.shutter > 0.5 ? 'scale(.86)' : '');
// Clock options sheet and the clock itself.
css(r.sheet, 'transform', `translateY(${((1 - s.sheet) * 105).toFixed(2)}%)`);
const pick = (els, v) => {
let best = null;
let bd = Infinity;
for (const e of els) {
const dd = Math.abs(+e.dataset.v - v);
if (dd < bd) {
bd = dd;
best = e;
}
}
els.forEach((e) => e.classList.toggle('on', e === best));
};
pick(r.segAlign, s.align);
pick(r.segWeight, s.weight);
css(r.clockTime, 'fontWeight', String(Math.round(s.weight)));
for (const sp of r.clockLines) {
css(sp, 'transform', s.align > 0.001 ? `translateX(${(s.align * (CLOCK_W - sp.offsetWidth)).toFixed(1)}px)` : '');
}
// The finger that shows each gesture.
const fg = s.finger;
css(r.finger, 'opacity', fg.o.toFixed(3));
if (fg.o > 0.001) {
css(r.finger, 'transform', `translate(${fg.x.toFixed(1)}px, ${fg.y.toFixed(1)}px) scale(${(1 - 0.16 * fg.press).toFixed(3)})`);
}
const ring = fg.ring.toFixed(3);
if (r.ring._ring !== ring) {
r.ring._ring = ring;
r.ring.style.setProperty('--ring', ring);
}
}
}
/* ------------------------------------------------------------------ the story */
/**
* The phone at story position P, from 0 to 7: one unit per chapter, each chapter's end matching
* the next one's start, so scrolling either way plays the same film.
*/
function storyState(ph, P) {
const s = blank();
P = clamp(P, 0, STORY_END);
if (P >= 7) {
// Chapter 8, three units long: the list closes and the phone is home again. From there on the
// layered phone (setupAnatomy) takes its place and comes apart; this one is hidden.
const t = (P - 7) / LONG;
s.drawer = 1 - E(t, 0, 0.06);
s.list = ph.scrollFor(L('P'));
s.hidden = { Podcasts: 1 };
s.snack = 1 - E(t, 0, 0.05);
return s;
}
const ch = Math.min(6, Math.floor(P));
const t = P - ch;
const F = s.finger;
const g = ph.g;
switch (ch) {
case 0: { // home; a finger gets ready at the bottom
const a = seg(t, 0.62, 0.82);
F.o = a;
F.x = 195;
F.y = 790 - 8 * a;
break;
}
case 1: { // swipe up, the list fades in, flicks to M
const up = E(t, 0.05, 0.36);
F.x = 195;
F.y = lerp(782, 470, up);
F.o = 1 - seg(t, 0.36, 0.46);
F.press = t < 0.36 ? 1 : 0;
s.drawer = E(t, 0.1, 0.4);
s.list = E(t, 0.5, 0.92) * ph.scrollFor(L('M'));
break;
}
case 2: { // scrub M → Z → C
s.drawer = 1;
const idx = keys(t, [[0.15, L('M')], [0.48, LETTERS.length - 1], [0.53, LETTERS.length - 1], [0.86, L('C')]]);
s.scrub = idx;
s.scrubShow = seg(t, 0.06, 0.15) * (1 - seg(t, 0.88, 0.97));
s.list = ph.scrollAt(idx);
F.x = lerp(g.alphaX + (ph.lefty ? 60 : -60), g.alphaX, E(t, 0, 0.1));
F.y = ph.letterYAt(idx);
F.o = seg(t, 0, 0.08) * (1 - seg(t, 0.88, 0.97));
F.press = s.scrubShow > 0.5 ? 1 : 0;
break;
}
case 3: { // back home, tap the folder, blur
s.drawer = 1 - E(t, 0, 0.18);
s.list = ph.scrollFor(L('C'));
const fp = g.home.media;
F.x = fp.x;
F.y = fp.y;
F.o = seg(t, 0.2, 0.27) * (1 - seg(t, 0.42, 0.5));
F.press = t > 0.29 && t < 0.36 ? 1 : 0;
s.folder = E(t, 0.33, 0.62);
break;
}
case 4: { // close the folder, swipe right: the camera slides in as the page to the left
s.folder = 1 - E(t, 0, 0.15);
const drag = E(t, 0.26, 0.55);
const reach = 230 / W; // how far the finger drags the screen before letting go
F.x = lerp(70, 70 + 230, drag);
F.y = 420;
F.o = seg(t, 0.18, 0.25) * (1 - seg(t, 0.55, 0.62));
F.press = t > 0.25 && t < 0.56 ? 1 : 0;
// The screen follows the finger, then finishes the slide on its own once it lets go.
s.camera = reach * drag + (1 - reach) * E(t, 0.55, 0.74);
s.shutter = t > 0.86 && t < 0.92 ? 1 : 0;
break;
}
case 5: { // close the camera, hold the clock, restyle it
s.camera = 1 - E(t, 0, 0.14);
const ck = g.home.clock;
const sg = g.seg;
const taps = [0.42, 0.52, 0.62, 0.72, 0.81, 0.88, 0.95];
const fp = route(t, [
[0.16, ck.x, ck.y], [0.32, ck.x, ck.y],
[taps[0], sg['align-0.5'].x, sg['align-0.5'].y],
[taps[1], sg['align-1'].x, sg['align-1'].y],
[taps[2], sg['align-0'].x, sg['align-0'].y],
[taps[3], sg['weight-100'].x, sg['weight-100'].y],
[taps[4], sg['weight-400'].x, sg['weight-400'].y],
[taps[5], sg['weight-700'].x, sg['weight-700'].y],
[taps[6], sg['weight-300'].x, sg['weight-300'].y],
]);
F.x = fp.x;
F.y = fp.y;
F.o = seg(t, 0.14, 0.2) * (1 - seg(t, 0.96, 1));
F.ring = seg(t, 0.2, 0.3) * (1 - seg(t, 0.3, 0.33));
F.press = (t > 0.2 && t < 0.31) || near(t, taps, 0.012) ? 1 : 0;
s.sheet = E(t, 0.3, 0.42);
s.align = keys(t, [[0.42, 0], [0.45, 0.5], [0.52, 0.5], [0.55, 1], [0.62, 1], [0.65, 0]]);
s.weight = keys(t, [[0.72, 300], [0.75, 100], [0.81, 100], [0.84, 400], [0.88, 400], [0.91, 700], [0.95, 700], [0.98, 300]]);
break;
}
case 6: { // the list again, hold Podcasts, Hide app, Undo message
s.sheet = 1 - E(t, 0, 0.12);
s.drawer = E(t, 0.12, 0.28);
s.list = ph.scrollFor(L('P'));
const ry = ph.rowY('Podcasts', s.list);
s.menuY = ry + 34;
const hide = { x: MENU_X + g.menuHide.x, y: s.menuY + g.menuHide.y };
const fp = route(t, [[0.3, 150, ry], [0.56, 150, ry], [0.64, hide.x, hide.y]]);
F.x = fp.x;
F.y = fp.y;
F.o = seg(t, 0.28, 0.33) * (1 - seg(t, 0.72, 0.8));
F.ring = seg(t, 0.34, 0.5) * (1 - seg(t, 0.5, 0.53));
F.press = (t > 0.33 && t < 0.52) || (t > 0.65 && t < 0.69) ? 1 : 0;
s.held = t > 0.34 && t < 0.72 ? 'Podcasts' : null;
s.menu = E(t, 0.5, 0.56) * (1 - E(t, 0.69, 0.74));
s.menuPick = t > 0.65 && t < 0.72 ? 1 : 0;
s.hidden = { Podcasts: E(t, 0.72, 0.82) };
s.snack = E(t, 0.76, 0.84);
break;
}
default:
break;
}
return s;
}
/**
* Chapter 8, from its start (0) to its end (1): when the phones swap, how far apart the layers
* are, and which layer is looked at (0 the glass to 4 the wallpaper) while the stack is open.
*/
const SWAP_AT = 0.08;
const layersApart = (t) => smooth(seg(t, 0.09, 0.26)) * (1 - smooth(seg(t, 0.85, 0.97)));
const lookingAt = (t) => ({
amount: smooth(seg(t, 0.27, 0.32)) * (1 - smooth(seg(t, 0.78, 0.83))),
at: seg(t, 0.33, 0.77) * 4,
});
function setupStory(ph, layered) {
const box = document.querySelector('.story__steps');
const steps = [...document.querySelectorAll('[data-step]')];
const num = document.querySelector('[data-story-num]');
const rail = [...document.querySelectorAll('[data-story-rail] li')];
let lastChapter = -1;
let lastP = -1;
return function update(force) {
// In chapters, not window heights: on phones a chapter is the small viewport height, while
// the window grows when the browser's address bar slides away.
const vh = window.innerHeight;
const unit = steps[0].offsetHeight || vh;
const P = clamp((-box.getBoundingClientRect().top + vh / 2) / unit, 0, STORY_END);
const ch = P >= 7 ? 7 : Math.floor(P);
// Without motion, each chapter shows its finished picture (chapter 8: pulled apart).
const shown = reduced ? (ch === 7 ? 7 + LONG * 0.2 : ch + 0.985) : P;
if (force || Math.abs(shown - lastP) > 0.0002) {
lastP = shown;
ph.render(storyState(ph, shown));
if (layered) {
const t = shown >= 7 ? (shown - 7) / LONG : 0;
layered.set(t, shown >= 7 && t >= SWAP_AT, force);
}
}
if (ch !== lastChapter) {
lastChapter = ch;
steps.forEach((el, i) => el.classList.toggle('is-active', i === ch));
num.textContent = String(ch + 1).padStart(2, '0');
rail.forEach((li, i) => {
li.classList.toggle('on', i === ch);
li.classList.toggle('done', i < ch);
});
}
};
}
/* ------------------------------------------------------------------ the hero teaser */
/** Story positions 0.55 to 3.19 on a loop: home, swipe up, scrub, back home. */
function heroP(ms) {
const cycle = 12500;
const t = (ms % cycle) / cycle;
return t < 0.1 ? 0.55 : lerp(0.55, 3.19, (t - 0.1) / 0.9);
}
/* ------------------------------------------------------------------ the playground */
/**
* A phone that answers to touch and mouse.
* opts.caption element that narrates what happened (optional)
* opts.demos element holding [data-demo] buttons (optional)
* opts.locked the app list stays open: no home screen, menus or closing (the race)
* opts.onRowTap called with an app's name when its row in the list is tapped
* opts.egg scrubbing to F throws confetti
*/
function setupPlay(ph, opts = {}) {
const s = blank();
if (opts.locked) s.drawer = 1;
const caption = opts.caption || null;
const HELLO = 'Swipe up on the home screen to open your apps.';
let lastTick = -1;
const say = (text) => {
if (caption && caption.textContent !== text) caption.textContent = text;
};
// A tiny tween engine over paths into the state ("drawer", "finger.x", "hidden.Podcasts").
let anims = [];
let raf = 0;
let token = 0;
let scrubbing = false;
let listTarget = null;
let snackTimer = 0;
let lastHidden = null;
const getp = (p) => p.split('.').reduce((o, k) => (o == null ? undefined : o[k]), s);
const setp = (p, v) => {
const ks = p.split('.');
let o = s;
for (let i = 0; i < ks.length - 1; i++) o = o[ks[i]];
o[ks[ks.length - 1]] = v;
};
function derive() {
let more = false;
if (scrubbing) {
s.finger.x = ph.g.alphaX;
s.finger.y = ph.letterYAt(s.scrub);
listTarget = ph.scrollFor(s.scrub);
const tick = Math.round(s.scrub);
if (tick !== lastTick) {
if (gest && gest.mode === 'scrub') buzz();
lastTick = tick;
}
}
if (listTarget != null) {
const d = listTarget - s.list;
if (Math.abs(d) < 0.5) {
s.list = listTarget;
listTarget = null;
} else {
s.list += d * 0.32;
more = true;
}
}
return more;
}
function frame(now) {
raf = 0;
anims = anims.filter((a) => {
const k = a.dur ? clamp((now - a.start) / a.dur) : 1;
setp(a.key, lerp(a.from, a.to, a.ease(k)));
if (k >= 1) {
a.done();
return false;
}
return true;
});
const more = derive();
ph.render(s);
if (anims.length || more) raf = requestAnimationFrame(frame);
}
const kick = () => {
if (!raf) raf = requestAnimationFrame(frame);
};
const drop = (key) => {
anims = anims.filter((a) => {
if (a.key !== key) return true;
a.done();
return false;
});
};
function to(patch, dur = 320, ease = inOut) {
const start = performance.now();
const all = Object.entries(patch).map(([key, v]) => new Promise((done) => {
drop(key);
anims.push({ key, from: +getp(key) || 0, to: v, start, dur: reduced ? 0 : dur, ease, done });
}));
kick();
return Promise.all(all);
}
function set(patch) {
for (const [key, v] of Object.entries(patch)) {
drop(key);
setp(key, v);
}
kick();
}
const wait = (ms) => new Promise((r) => setTimeout(r, reduced ? 0 : ms));
const stopAll = () => {
token++;
anims.forEach((a) => a.done());
anims = [];
scrubbing = false;
};
// Building blocks shared by gestures and the demo buttons.
const fingerAt = (x, y) => set({ 'finger.x': x, 'finger.y': y });
const fingerOff = () => to({ 'finger.o': 0, 'finger.press': 0, 'finger.ring': 0 }, 220);
async function closeAll() {
s.held = null;
scrubbing = false;
await to({ drawer: 0, folder: 0, camera: 0, menu: 0, sheet: 0, scrubShow: 0, pull: 0, page: 0, snack: 0, menuPick: 0 }, 280);
}
const openDrawer = () => to({ drawer: 1 }, 300).then(() => say('Every app, A to Z. Now drag along the letters on the right.'));
const closeDrawer = () => to({ drawer: 0, menu: 0, scrubShow: 0 }, 260).then(() => { s.held = null; });
function openFolder(id) {
s.folderId = id;
say('Blur behind folders. Tap outside the folder to close it.');
return to({ folder: 1 }, 480, outCubic);
}
const closeFolder = () => to({ folder: 0 }, 300);
function launchCamera() {
say('Quick launch. Swipe past the first page and your camera is right there. Swipe left to go back.');
return to({ pull: 0, page: 0, camera: 1 }, 420, outCubic);
}
const closeCamera = () => to({ camera: 0 }, 400).then(() => say('Back home. Try the playground buttons, or just play.'));
function openMenu(name) {
s.held = name;
const ry = ph.rowY(name, s.list);
const below = ry + 34;
s.menuY = below + ph.g.menuH > H - 70 ? ry - 34 - ph.g.menuH : below;
say(`Tap Hide app to hide ${name}.`);
return to({ menu: 1, 'finger.ring': 0 }, 200, outCubic);
}
async function hideApp(name) {
if (!name) return;
set({ menuPick: 1 });
await wait(120);
await to({ menu: 0 }, 180);
s.held = null;
s.menuPick = 0;
s.snackText = `${name} is hidden`;
lastHidden = name;
await to({ [`hidden.${name}`]: 1, snack: 1 }, 380);
ph.measure();
say(`${name} is hidden from the list and from search. Tap Undo to bring it back.`);
clearTimeout(snackTimer);
snackTimer = setTimeout(() => to({ snack: 0 }, 260), 4200);
}
async function unhide(name) {
if (!name) return;
clearTimeout(snackTimer);
await to({ [`hidden.${name}`]: 0, snack: 0 }, 320);
ph.measure();
say(`${name} is back. In the app, hidden apps also live under Settings, App drawer, Hidden apps.`);
}
async function sweep(from, target, dur) {
scrubbing = true;
s.scrub = from;
await to({ scrub: target }, dur, inOut);
scrubbing = false;
}
// The demo buttons.
const demos = {
async drawer(alive) {
await closeAll();
if (!alive()) return;
fingerAt(195, 790);
await to({ 'finger.o': 1 }, 160);
set({ 'finger.press': 1 });
await to({ 'finger.y': 470, drawer: 1 }, 650);
set({ 'finger.press': 0 });
await fingerOff();
if (alive()) say('Every app, A to Z. Now drag along the letters on the right.');
},
async scrub(alive) {
if (s.drawer < 0.99 || s.folder > 0.01 || s.camera > 0.01) {
await demos.drawer(alive);
if (!alive()) return;
}
const start = L('B');
fingerAt(ph.g.alphaX, ph.letterYAt(start));
s.scrub = start;
await to({ 'finger.o': 1, scrubShow: 1 }, 220);
set({ 'finger.press': 1 });
if (!alive()) return;
await sweep(start, LETTERS.length - 1, 1500);
if (!alive()) return;
await sweep(LETTERS.length - 1, L('M'), 900);
set({ 'finger.press': 0 });
await to({ scrubShow: 0, 'finger.o': 0 }, 280);
if (alive()) say('The wave alphabet. Your thumb never has to leave the edge.');
},
async folder(alive) {
await closeAll();
if (!alive()) return;
const fp = ph.g.home.media;
fingerAt(fp.x + 30, fp.y + 60);
await to({ 'finger.o': 1, 'finger.x': fp.x, 'finger.y': fp.y }, 320);
set({ 'finger.press': 1 });
await wait(110);
set({ 'finger.press': 0 });
if (!alive()) return;
fingerOff();
await openFolder('media');
},
async hide(alive) {
await closeAll();
if (!alive()) return;
if ((s.hidden.Podcasts || 0) > 0) {
set({ 'hidden.Podcasts': 0 });
ph.render(s);
ph.measure();
}
await to({ drawer: 1, list: ph.scrollFor(L('P')) }, 520);
if (!alive()) return;
const ry = ph.rowY('Podcasts', s.list);
fingerAt(150, ry);
await to({ 'finger.o': 1 }, 160);
set({ 'finger.press': 1 });
s.held = 'Podcasts';
await to({ 'finger.ring': 1 }, 520, linear);
if (!alive()) return;
await openMenu('Podcasts');
set({ 'finger.press': 0 });
const hp = { x: MENU_X + ph.g.menuHide.x, y: s.menuY + ph.g.menuHide.y };
await to({ 'finger.x': hp.x, 'finger.y': hp.y }, 380);
if (!alive()) return;
set({ 'finger.press': 1 });
await wait(110);
set({ 'finger.press': 0 });
fingerOff();
await hideApp('Podcasts');
},
async quick(alive) {
await closeAll();
if (!alive()) return;
fingerAt(70, 420);
await to({ 'finger.o': 1 }, 160);
set({ 'finger.press': 1 });
await to({ 'finger.x': 300, camera: 230 / W }, 560);
set({ 'finger.press': 0 });
fingerOff();
if (!alive()) return;
await launchCamera();
},
async reset() {
await closeAll();
for (const name of Object.keys(s.hidden)) s.hidden[name] = 0;
set({ align: 0, weight: DEFAULT_WEIGHT, list: 0 });
ph.render(s);
ph.measure();
say(HELLO);
},
};
for (const btn of opts.demos ? opts.demos.querySelectorAll('[data-demo]') : []) {
btn.addEventListener('click', () => {
stopAll();
const mine = token;
const run = demos[btn.dataset.demo];
if (run) run(() => mine === token);
});
}
// Gestures on the phone itself.
const scr = ph.screen;
let gest = null;
let lpTimer = 0;
const pt = (e) => {
const rc = scr.getBoundingClientRect();
return { x: (e.clientX - rc.left) / ph.scale, y: (e.clientY - rc.top) / ph.scale };
};
const cancelHold = () => {
clearTimeout(lpTimer);
lpTimer = 0;
if (gest && !gest.held) {
drop('finger.ring');
s.finger.ring = 0;
if (s.menu < 0.5) s.held = null;
}
};
function bounce(el) {
el.classList.remove('tap');
void el.offsetWidth;
el.classList.add('tap');
}
scr.addEventListener('pointerdown', (e) => {
if (e.button > 0) return;
stopAll();
const p = pt(e);
try {
scr.setPointerCapture(e.pointerId);
} catch (_) { /* not every pointer can be captured */ }
gest = { x0: p.x, y0: p.y, x: p.x, y: p.y, t0: performance.now(), mode: 'home', target: e.target, moved: false, dir: null, held: false };
set({ 'finger.x': p.x, 'finger.y': p.y, 'finger.o': 0.9, 'finger.press': 1, 'finger.ring': 0 });
const g = ph.g;
if (e.target.closest('.snack__undo') && s.snack > 0.5) gest.mode = 'undo';
else if (p.y > H - 44 && !opts.locked) gest.mode = 'edge';
else if (s.camera > 0.5) gest.mode = 'camera';
else if (s.menu > 0.5) gest.mode = 'menu';
else if (s.sheet > 0.5) gest.mode = 'sheet';
else if (s.folder > 0.5) gest.mode = 'folder';
else if (s.drawer > 0.5) {
const atEdge = ph.lefty ? p.x < 56 : p.x > W - 56;
const inAlpha = atEdge && p.y > g.letterY[0] - 44 && p.y < g.letterY[g.letterY.length - 1] + 30;
if (inAlpha) {
gest.mode = 'scrub';
scrubbing = true;
s.scrub = ph.letterAtY(p.y);
to({ scrubShow: 1 }, 160, outCubic);
} else {
gest.mode = 'list';
gest.list0 = s.list;
const row = e.target.closest('.row');
gest.row = row ? row.dataset.row : null;
if (gest.row && !opts.locked) {
to({ 'finger.ring': 1 }, 450, linear);
lpTimer = setTimeout(() => {
if (!gest || gest.moved) return;
gest.held = true;
openMenu(gest.row);
}, 450);
}
}
} else {
const cell = e.target.closest('[data-key]');
if (cell && cell.dataset.key === 'clock') {
to({ 'finger.ring': 1 }, 450, linear);
lpTimer = setTimeout(() => {
if (!gest || gest.moved) return;
gest.held = true;
set({ 'finger.ring': 0 });
say('Restyle the clock: alignment and weight. Tap above the sheet to close it.');
to({ sheet: 1 }, 360, outCubic);
}, 450);
}
}
});
scr.addEventListener('pointermove', (e) => {
if (!gest) return;
const p = pt(e);
gest.x = p.x;
gest.y = p.y;
const dx = p.x - gest.x0;
const dy = p.y - gest.y0;
if (!gest.moved && Math.hypot(dx, dy) > 9) {
gest.moved = true;
if (!gest.held) cancelHold();
}
s.finger.x = p.x;
s.finger.y = p.y;
switch (gest.mode) {
case 'scrub':
s.scrub = ph.letterAtY(p.y);
break;
case 'list':
if (gest.moved && !gest.held) {
if (gest.list0 <= 1 && dy > 0 && !opts.locked) s.drawer = clamp(1 - dy / 320);
else s.list = clamp(gest.list0 - dy, 0, ph.g.maxScroll);
}
break;
case 'home':
if (gest.held) break;
if (!gest.dir && gest.moved) gest.dir = Math.abs(dy) > Math.abs(dx) ? 'v' : 'h';
if (gest.dir === 'v') s.drawer = clamp(-dy / 280);
if (gest.dir === 'h') {
if (s.page < 0.5 && dx > 0) {
// Past the first page: the camera follows the finger in from the left.
s.camera = clamp(dx / W);
s.pull = 0;
} else {
const atEnd = s.page > HOME.length - 1.5 && dx < 0;
s.pull = atEnd ? rubber(dx) : dx;
s.camera = 0;
}
}
break;
case 'camera':
if (!gest.dir && gest.moved) gest.dir = Math.abs(dy) > Math.abs(dx) ? 'v' : 'h';
if (gest.dir === 'h') s.camera = clamp(1 + Math.min(0, dx) / W);
break;
default:
break;
}
kick();
});
const end = (e) => {
if (!gest) return;
clearTimeout(lpTimer);
const g = gest;
gest = null;
const dx = g.x - g.x0;
const dy = g.y - g.y0;
const tap = !g.moved && performance.now() - g.t0 < 450;
if (!(g.mode === 'list' && g.held)) to({ 'finger.ring': 0 }, 120);
to({ 'finger.o': 0, 'finger.press': 0 }, 240);
if (e.type === 'pointercancel') {
if (g.mode === 'scrub') {
scrubbing = false;
to({ scrubShow: 0 }, 200);
}
to({ pull: 0, drawer: Math.round(s.drawer) }, 200);
return;
}
switch (g.mode) {
case 'undo':
unhide(lastHidden);
break;
case 'edge':
if (dy < -36) closeAll().then(() => say('Home. Swipe up from the bottom edge works from anywhere.'));
break;
case 'camera':
if (g.dir === 'h') {
if (s.camera < 0.8) closeCamera();
else to({ camera: 1 }, 220, outCubic);
} else if ((tap && g.target.closest('.cam__close')) || dy < -60) closeCamera();
else if (tap) {
set({ shutter: 1 });
setTimeout(() => set({ shutter: 0 }), 140);
}
break;
case 'menu':
if (tap && g.target.closest('.mi--hide')) hideApp(s.held);
else {
s.held = null;
to({ menu: 0 }, 180);
}
break;
case 'sheet': {
const spanEl = g.target.closest('.seg span');
if (tap && spanEl && spanEl.dataset.v !== undefined) {
const v = +spanEl.dataset.v;
if (spanEl.parentElement.dataset.seg === 'align') to({ align: v }, 380);
else to({ weight: v }, 420);
} else if (tap && !g.target.closest('.sheet')) to({ sheet: 0 }, 300);
else if (dy > 60) to({ sheet: 0 }, 300);
break;
}
case 'folder':
if (tap && !g.target.closest('.fcard')) closeFolder();
else if (tap) {
const app = g.target.closest('.fcard__app');
if (app) bounce(app);
}
break;
case 'scrub': {
scrubbing = false;
to({ scrubShow: 0 }, 280);
const landed = LETTERS[clamp(Math.round(s.scrub), 0, LETTERS.length - 1)];
if (opts.egg && landed === 'F') {
say('F is for Flexera. You found the Easter egg.');
const rc = scr.getBoundingClientRect();
confetti(rc.left + rc.width / 2, rc.top + rc.height * 0.4);
} else {
say(`Landed on ${landed}. That's the wave alphabet.`);
}
break;
}
case 'list':
if (g.held) break; // the menu stays open
if (s.drawer < 1) {
if (s.drawer < 0.75) closeDrawer().then(() => say('Closed. Swipe up to open it again.'));
else to({ drawer: 1 }, 200);
} else if (tap && g.row) {
const row = ph.r.rows.get(g.row);
row.classList.add('flash');
setTimeout(() => row.classList.remove('flash'), 220);
if (opts.onRowTap) opts.onRowTap(g.row, row);
else say(`Hold ${g.row} to see what else you can do with it.`);
}
break;
case 'home':
if (g.held) break;
if (g.dir === 'v') {
if (s.drawer > 0.3) openDrawer();
else to({ drawer: 0 }, 220);
} else if (g.dir === 'h') {
if (s.page < 0.5 && dx > 0) {
if (s.camera > 0.2) launchCamera();
else to({ camera: 0 }, 240, outCubic);
} else if (s.page < 0.5 && dx < -70) to({ page: 1, pull: 0 }, 380).then(() => say('Page two. Swipe back, or past the last page.'));
else if (s.page > 0.5 && dx > 70) to({ page: 0, pull: 0 }, 380).then(() => say(HELLO));
else to({ pull: 0 }, 260, outCubic);
} else if (tap) {
const cell = g.target.closest('[data-key]');
if (cell && cell.dataset.folder) openFolder(cell.dataset.folder);
else if (cell && cell.dataset.app === 'Camera') launchCamera();
else if (cell && cell.classList.contains('cell')) {
bounce(cell);
say('Swipe up from anywhere on the home screen to see every app.');
}
}
break;
default:
break;
}
kick();
};
scr.addEventListener('pointerup', end);
scr.addEventListener('pointercancel', end);
scr.addEventListener('contextmenu', (e) => e.preventDefault());
ph.render(s);
return {
render: () => ph.render(s),
/** Back to a fresh start; for the race, the list at its top. */
reset() {
stopAll();
Object.assign(s, blank(), { drawer: opts.locked ? 1 : 0 });
listTarget = null;
ph.render(s);
},
};
}
/* ------------------------------------------------------------------ confetti, toast, eggs */
function confetti(x, y, count = 160) {
if (reduced) return;
const cv = document.createElement('canvas');
cv.className = 'confetti';
const dpr = Math.min(2, window.devicePixelRatio || 1);
cv.width = innerWidth * dpr;
cv.height = innerHeight * dpr;
document.body.appendChild(cv);
const ctx = cv.getContext('2d');
ctx.scale(dpr, dpr);
const colors = ['#76fafc', '#1ddbfd', '#417df4', '#653df0', '#fc59fb', '#ffd34e', '#f5f4ff'];
const parts = Array.from({ length: count }, () => {
const a = -Math.PI / 2 + (Math.random() - 0.5) * Math.PI * 1.15;
const v = 7 + Math.random() * 10;
return {
x, y, vx: Math.cos(a) * v, vy: Math.sin(a) * v, r: Math.random() * 6, vr: (Math.random() - 0.5) * 0.35,
s: 7 + Math.random() * 9, c: colors[(Math.random() * colors.length) | 0], star: Math.random() < 0.3,
};
});
const t0 = performance.now();
function step(now) {
const t = now - t0;
ctx.clearRect(0, 0, innerWidth, innerHeight);
ctx.globalAlpha = clamp(1 - (t - 1900) / 900);
for (const p of parts) {
p.vy += 0.34;
p.vx *= 0.986;
p.vy *= 0.986;
p.x += p.vx;
p.y += p.vy;
p.r += p.vr;
ctx.save();
ctx.translate(p.x, p.y);
ctx.rotate(p.r);
ctx.fillStyle = p.c;
if (p.star) {
ctx.beginPath();
for (let i = 0; i < 8; i++) {
const rr = i % 2 ? p.s * 0.18 : p.s * 0.6;
const aa = (i * Math.PI) / 4;
ctx.lineTo(Math.cos(aa) * rr, Math.sin(aa) * rr);
}
ctx.fill();
} else {
ctx.fillRect(-p.s / 2, -p.s / 4, p.s, p.s / 2);
}
ctx.restore();
}
if (t < 2800) requestAnimationFrame(step);
else cv.remove();
}
requestAnimationFrame(step);
}
let toastTimer = 0;
function toast(text) {
let el = document.querySelector('.toast');
if (!el) {
el = document.createElement('div');
el.className = 'toast';
el.setAttribute('role', 'status');
document.body.appendChild(el);
}
el.textContent = text;
requestAnimationFrame(() => el.classList.add('is-on'));
clearTimeout(toastTimer);
toastTimer = setTimeout(() => el.classList.remove('is-on'), 2800);
}
/** Typing "flexera" anywhere on the page. */
function setupEggs() {
let typed = '';
window.addEventListener('keydown', (e) => {
if (e.key.length !== 1 || e.ctrlKey || e.metaKey || e.altKey) return;
typed = (typed + e.key.toLowerCase()).slice(-7);
if (typed === 'flexera') {
typed = '';
confetti(innerWidth / 2, innerHeight * 0.5, 240);
toast('You typed the magic word.');
}
});
}
/* ------------------------------------------------------------------ the detail-card switches */
function setupLook(phones, rerender) {
const flip = document.querySelector('.art-flip');
function sync() {
document.querySelectorAll('[data-look="lefty"]').forEach((b) => b.setAttribute('aria-pressed', String(look.lefty)));
document.querySelectorAll('[data-look="comfy"]').forEach((b) => b.setAttribute('aria-pressed', String((b.dataset.v === '1') === look.comfy)));
document.querySelectorAll('[data-shape]').forEach((b) => b.setAttribute('aria-pressed', String(b.dataset.shape === look.shape)));
if (flip) flip.classList.toggle('is-left', look.lefty);
}
function apply(message) {
Object.values(phones).forEach((p) => p.setLook(look));
const shape = SHAPES[look.shape];
document.querySelectorAll('.screen, .xphone').forEach((el) => {
el.style.setProperty('--shape', shape.icon);
el.style.setProperty('--shape-w', shape.widget);
});
document.querySelectorAll('.xphone').forEach((el) => {
el.classList.toggle('is-lefty', look.lefty);
el.classList.toggle('is-comfy', look.comfy);
});
rerender();
sync();
toast(message);
}
document.addEventListener('click', (e) => {
const b = e.target.closest('[data-look], [data-shape]');
if (!b) return;
if (b.dataset.look === 'lefty') {
look.lefty = !look.lefty;
apply(look.lefty ? 'Alphabet on the left, on every phone on this page.' : 'Alphabet back on the right.');
} else if (b.dataset.look === 'comfy') {
look.comfy = b.dataset.v === '1';
apply(look.comfy ? 'Comfy rows on every phone on this page.' : 'Compact rows on every phone on this page.');
} else if (b.dataset.shape) {
look.shape = b.dataset.shape;
apply(`${b.getAttribute('aria-label')} icons on every phone on this page.`);
}
});
sync();
}
/* ------------------------------------------------------------------ the headline that does the wave */
function setupWaveTitle() {
const h1 = document.querySelector('[data-wave]');
if (!h1) return null;
h1.setAttribute('aria-label', h1.textContent.replace(/\s+/g, ' ').trim());
const letters = [];
h1.querySelectorAll('.line__in').forEach((inner) => {
const text = inner.textContent;
inner.textContent = '';
for (const ch of text) {
if (ch === ' ') {
inner.append(' ');
continue;
}
const sp = document.createElement('span');
sp.className = 'wl';
sp.setAttribute('aria-hidden', 'true');
sp.textContent = ch;
inner.append(sp);
letters.push({ el: sp, w: 900, shown: 900 });
}
});
if (reduced) return null;
const hero = h1.closest('.hero');
let pointer = null;
hero.addEventListener('pointermove', (e) => {
if (e.pointerType === 'mouse') pointer = { x: e.clientX, y: e.clientY };
});
hero.addEventListener('pointerleave', () => { pointer = null; });
const BASE = 560;
const PEAK = 900;
return function frame(now) {
let centre;
let strength = 1;
if (pointer) {
let best = Infinity;
let bi = 0;
let side = 0;
let size = 100;
letters.forEach((l, i) => {
const r = l.el.getBoundingClientRect();
const dx = pointer.x - (r.left + r.width / 2);
const d = Math.hypot(dx, (pointer.y - (r.top + r.height / 2)) * 1.6);
if (d < best) {
best = d;
bi = i;
side = dx / Math.max(r.width, 1);
size = r.height;
}
});
strength = clamp(1.4 - best / (size * 1.6));
centre = bi + clamp(side, -0.5, 0.5);
} else {
centre = ((now / 5200) % 1) * (letters.length + 8) - 4; // a wave rolls through on its own
}
letters.forEach((l, i) => {
const d = i - centre;
const target = BASE + (PEAK - BASE) * strength * Math.exp(-(d * d) / 5.8);
l.w += (target - l.w) * 0.14;
const w = Math.round(l.w / 4) * 4;
if (w !== l.shown) {
l.shown = w;
l.el.style.fontWeight = String(w);
}
});
};
}
/* ------------------------------------------------------------------ the page's wave alphabet */
function setupPageWave() {
const nav = document.querySelector('[data-pagewave]');
if (!nav) return () => {};
const links = [...nav.querySelectorAll('a')];
const bubble = nav.querySelector('.pagewave__bubble');
const bLetter = bubble.querySelector('b');
const bLabel = bubble.querySelector('i');
const targets = links.map((a) => document.querySelector(a.getAttribute('href')));
let focus = null; // fractional letter index under the pointer
let pressed = false;
let dragged = false;
let landed = -1;
let current = -1;
let sleep = 0;
const centre = (i) => links[i].offsetTop + links[i].offsetHeight / 2;
function at(clientY) {
const y = clientY - nav.getBoundingClientRect().top;
if (y <= centre(0)) return 0;
for (let i = 1; i < links.length; i++) if (y <= centre(i)) return i - 1 + (y - centre(i - 1)) / (centre(i) - centre(i - 1));
return links.length - 1;
}
function paint() {
links.forEach((a, i) => {
const k = focus == null ? 0 : Math.exp(-((i - focus) ** 2) / 2.4);
a.style.transform = k > 0.01 ? `translateX(${(-24 * k).toFixed(1)}px) scale(${(1 + 0.75 * k).toFixed(3)})` : '';
});
if (focus != null) {
const i = clamp(Math.round(focus), 0, links.length - 1);
bLetter.textContent = links[i].textContent;
bLabel.textContent = links[i].dataset.label;
bubble.style.transform = `translateY(${(centre(i) - bubble.offsetHeight / 2).toFixed(1)}px)`;
}
nav.classList.toggle('is-active', focus != null);
}
function go(i, smoothly) {
const t = targets[i];
if (!t) return;
const top = i === 0 ? 0 : t.getBoundingClientRect().top + window.scrollY;
window.scrollTo({ top, behavior: smoothly && !reduced ? 'smooth' : 'instant' });
}
function wake() {
nav.classList.add('is-awake');
clearTimeout(sleep);
sleep = setTimeout(() => {
if (!pressed) nav.classList.remove('is-awake');
}, 1600);
}
nav.addEventListener('pointerdown', (e) => {
if (e.button > 0) return;
e.preventDefault();
pressed = true;
dragged = false;
try {
nav.setPointerCapture(e.pointerId);
} catch (_) { /* fine without */ }
focus = at(e.clientY);
landed = clamp(Math.round(focus), 0, links.length - 1);
wake();
paint();
});
nav.addEventListener('pointermove', (e) => {
if (!pressed && e.pointerType !== 'mouse') return;
focus = at(e.clientY);
if (pressed) {
const i = clamp(Math.round(focus), 0, links.length - 1);
if (i !== landed) {
dragged = true;
landed = i;
buzz();
go(i, false);
}
}
paint();
});
nav.addEventListener('pointerleave', () => {
if (!pressed) {
focus = null;
paint();
}
});
const release = (e) => {
if (!pressed) return;
pressed = false;
if (e.type === 'pointerup' && !dragged) go(landed, true);
if (e.pointerType !== 'mouse') focus = null;
paint();
wake();
};
nav.addEventListener('pointerup', release);
nav.addEventListener('pointercancel', release);
links.forEach((a, i) => a.addEventListener('click', (e) => {
e.preventDefault();
if (e.detail === 0) go(i, true); // the keyboard
}));
return function update() {
const mid = innerHeight * 0.4;
let cur = 0;
targets.forEach((t, i) => {
if (t && i > 0 && t.getBoundingClientRect().top <= mid) cur = i;
});
if (cur !== current) {
current = cur;
links.forEach((a, i) => a.classList.toggle('on', i === cur));
}
wake();
};
}
/* ------------------------------------------------------------------ magnetic buttons, finger cursor */
function setupPointerFx() {
if (reduced || !window.matchMedia('(hover: hover) and (pointer: fine)').matches) return;
const cursor = document.createElement('div');
cursor.className = 'tcursor';
cursor.setAttribute('aria-hidden', 'true');
document.body.appendChild(cursor);
const magnets = [...document.querySelectorAll('.btn, .chip, .card__try, .card__seg button')];
let mx = -100;
let my = -100;
let cx = -100;
let cy = -100;
let raf = 0;
function tick() {
raf = 0;
for (const el of magnets) {
const r = el.getBoundingClientRect();
if (r.bottom < -80 || r.top > innerHeight + 80) continue;
const dx = mx - (r.left + r.width / 2);
const dy = my - (r.top + r.height / 2);
const reach = Math.max(r.width, r.height) / 2 + 70;
const d = Math.hypot(dx, dy);
if (d < reach) {
const k = 1 - d / reach;
el.style.translate = `${(dx * 0.22 * k).toFixed(1)}px ${(dy * 0.32 * k).toFixed(1)}px`;
el._mag = true;
} else if (el._mag) {
el.style.translate = '';
el._mag = false;
}
}
cx = lerp(cx, mx, 0.4);
cy = lerp(cy, my, 0.4);
cursor.style.transform = `translate(${cx.toFixed(1)}px, ${cy.toFixed(1)}px)`;
if (Math.abs(cx - mx) > 0.4 || Math.abs(cy - my) > 0.4) raf = requestAnimationFrame(tick);
}
window.addEventListener('pointermove', (e) => {
if (e.pointerType !== 'mouse') return;
mx = e.clientX;
my = e.clientY;
document.body.classList.toggle('over-phone', !!(e.target.closest && e.target.closest('.phone-host')));
if (!raf) raf = requestAnimationFrame(tick);
}, { passive: true });
window.addEventListener('pointerdown', () => cursor.classList.add('is-down'));
window.addEventListener('pointerup', () => cursor.classList.remove('is-down'));
document.addEventListener('pointerleave', () => document.body.classList.remove('over-phone'));
}
/* ------------------------------------------------------------------ under the glass */
function xphoneMarkup() {
const home = HOME[0];
const pin = '<i class="xl__pin"></i>';
const sbar = '<div class="sbar"><span class="sbar__time">9:41</span><span class="sbar__icons"><span class="sbar__sig"><i></i><i></i><i></i><i></i></span><span class="sbar__bat"></span></span></div>';
return `<div class="xphone">
<div class="xl xl--body"></div>
<div class="xl xl--wall"><div class="wall"></div>${pin}</div>
<div class="xl xl--widgets">${home.filter((it) => it.k).map(cellMarkup).join('')}${pin}</div>
<div class="xl xl--icons">${home.filter((it) => !it.k).map(cellMarkup).join('')}<div class="pagedots"><i class="on"></i><i></i></div>${pin}</div>
<div class="xl xl--list"><div class="list">${listMarkup()}</div>
<div class="alpha"><span class="alpha__s">${gl('search')}</span>${LETTERS.map((l) => `<span class="alpha__l">${l}</span>`).join('')}</div>${pin}</div>
<div class="xl xl--glass">${sbar}<i class="notch"></i><i class="gbar"></i>${pin}</div>
</div>`;
}
/**
* The story phone's double, built from separate layers so that it can come apart in 3D (the
* drawn phone clips its contents and cannot). It sits exactly over the story phone; closed, it
* shows the same home screen, so swapping the two is not seen.
*/
function setupAnatomy(storyPhone) {
const host = document.querySelector('[data-xphone]');
if (!host || !storyPhone) return null;
host.innerHTML = xphoneMarkup();
const xp = host.querySelector('.xphone');
const layers = [...xp.querySelectorAll('.xl')];
// Labels from the top: glass, list, icons, widgets, wallpaper.
const pins = [5, 4, 3, 2, 1].map((i) => layers[i].querySelector('.xl__pin'));
const labels = [...document.querySelectorAll('[data-xlabels] li')];
const stage = host.closest('.story__stage');
const rail = stage.querySelector('[data-story-rail]');
const pics = [...xp.querySelectorAll('.pic')];
const svg = stage.querySelector('[data-xlines]');
const NS = 'http://www.w3.org/2000/svg';
const lines = labels.map(() => {
const path = document.createElementNS(NS, 'path');
const dot = document.createElementNS(NS, 'circle');
dot.setAttribute('r', '4.5');
svg.append(path, dot);
return { path, dot };
});
const LABEL_W = 214;
const GAP = 165; // between layers, in the phone's pixels
let scale = 1;
let lastT = -1;
let swapped = null;
/** Same picture as the story phone shows, so the swap is not seen. */
function syncPictures() {
pics.forEach((p, i) => p.classList.toggle('on', i === storyPhone.picIndex));
}
const wide = () => window.innerWidth > 900;
function place(e, shift) {
const tilt = 56 * e;
const sc = scale * (1 - 0.44 * e);
const ox = 207 * (scale - sc) + shift;
// The layers rise as they separate; lower the whole stack by half of that, to keep it centred.
const oy = 434 * (scale - sc) + e * 2.5 * GAP * Math.sin((tilt * Math.PI) / 180) * sc;
xp.style.setProperty('--e', e.toFixed(3));
xp.style.transform = `translate(${ox.toFixed(1)}px, ${oy.toFixed(1)}px) scale(${sc.toFixed(4)}) translate(207px, 434px) rotateX(${tilt.toFixed(2)}deg) rotateZ(${(-34 * e).toFixed(2)}deg) translate(-207px, -434px)`;
layers.forEach((l, i) => {
l.style.transform = i ? `translateZ(${(i * GAP * e).toFixed(1)}px)` : '';
});
}
/**
* Puts the layers where e (0 closed, 1 fully apart) wants them. The pins sit on each layer's
* outer corner, its rightmost point; if the labels beside them would not fit, the stack moves
* left by just enough.
*/
function arrange(e) {
place(e, 0);
if (!wide() || e <= 0) return;
const room = stage.getBoundingClientRect().right - 12 - LABEL_W - 34;
let corner = -Infinity;
for (const pin of pins) corner = Math.max(corner, pin.getBoundingClientRect().left);
if (corner > room) place(e, room - corner);
}
/** Each label straight across from its layer's corner, on a short line that crosses nothing. */
function label(e, focus, weights) {
const shown = seg(e, 0.55, 0.95);
svg.style.opacity = shown > 0 ? '1' : '0';
if (!wide() || shown <= 0) {
labels.forEach((li) => { li.style.opacity = '0'; });
return;
}
const base = stage.getBoundingClientRect();
const pts = pins.map((pin) => {
const r = pin.getBoundingClientRect();
return { x: r.left - base.left, y: r.top - base.top };
});
const colX = Math.min(Math.max(...pts.map((pt) => pt.x)) + 34, base.width - LABEL_W - 12);
const ys = [];
let prev = -Infinity;
for (const pt of pts) {
const y = Math.max(pt.y, prev + 74); // never closer than one tag's height
ys.push(y);
prev = y;
}
const spill = ys[ys.length - 1] + 40 - base.height;
if (spill > 0) for (let k = 0; k < ys.length; k++) ys[k] -= spill;
labels.forEach((li, k) => {
const lit = 1 - focus * (1 - Math.max(0.35, weights[k]));
li.style.opacity = (shown * lit).toFixed(3);
li.classList.toggle('is-on', focus > 0.5 && weights[k] > 0.5);
li.style.transform = `translate(${colX.toFixed(1)}px, ${ys[k].toFixed(1)}px)`;
const { x, y } = pts[k];
const d = Math.abs(ys[k] - y) < 0.5
? `M${x.toFixed(1)} ${y.toFixed(1)} H${colX.toFixed(1)}`
: `M${x.toFixed(1)} ${y.toFixed(1)} H${(x + 12).toFixed(1)} L${(colX - 12).toFixed(1)} ${ys[k].toFixed(1)} H${colX.toFixed(1)}`;
lines[k].path.setAttribute('d', d);
lines[k].path.style.opacity = (shown * lit).toFixed(3);
lines[k].dot.setAttribute('cx', x.toFixed(1));
lines[k].dot.setAttribute('cy', y.toFixed(1));
lines[k].dot.style.opacity = (shown * lit).toFixed(3);
});
}
const fit = () => {
scale = host.clientWidth / 414;
place(0, 0); // scaled from the start: at full size it would widen the page
lastT = -1;
};
fit();
if ('ResizeObserver' in window) new ResizeObserver(fit).observe(host);
/** t: where chapter 8 is, from 0 to 1. on: whether this phone is the one shown. */
function set(t, on, force) {
if (on !== swapped) {
swapped = on;
host.style.visibility = on ? 'visible' : 'hidden';
storyPhone.host.style.visibility = on ? 'hidden' : '';
if (on) syncPictures();
lastT = -1;
}
if (!on) {
label(0, 0, []);
rail.style.opacity = '';
return;
}
if (!force && Math.abs(t - lastT) < 0.0002) return;
lastT = t;
const e = layersApart(t);
arrange(e);
rail.style.opacity = (1 - e).toFixed(3);
// One layer at a time: the one looked at stays bright and its label lights up, the others
// dim. Only where the labels are shown.
const look = lookingAt(t);
const focus = wide() ? look.amount : 0;
const weights = labels.map((_, k) => clamp(1 - Math.abs(k - look.at)));
layers.forEach((l, i) => {
const k = 5 - i; // the labels run from the glass (layer 5) down
const w = i === 0 ? 0 : weights[k];
const base = i === 4 ? smooth(seg(e, 0.15, 0.6)) : 1; // flat, the list layer is not there
l.style.opacity = (base * (1 - focus * (1 - Math.max(0.16, w)))).toFixed(3);
});
label(e, focus, weights);
}
return { set, syncPictures, element: xp };
}
/* ------------------------------------------------------------------ the race */
const shuffle = (arr) => {
const a = arr.slice();
for (let i = a.length - 1; i > 0; i--) {
const j = (Math.random() * (i + 1)) | 0;
[a[i], a[j]] = [a[j], a[i]];
}
return a;
};
function gridMarkup(apps) {
return `<div class="phone"><div class="screen"><div class="world"><div class="wall"></div></div>
<div class="rgrid">${apps.map((a) => `<button type="button" class="rcell" data-app="${a.n}" tabindex="-1">${icon(a)}<span>${a.n}</span></button>`).join('')}</div>
<div class="sbar"><span class="sbar__time">9:41</span><span class="sbar__icons"><span class="sbar__sig"><i></i><i></i><i></i><i></i></span><span class="sbar__bat"></span></span></div>
<i class="notch"></i><i class="gbar"></i></div></div>`;
}
function setupRace(listPhone) {
const stage = document.querySelector('[data-race-stage]');
const gridHost = document.querySelector('[data-race-grid]');
if (!stage || !gridHost || !listPhone) return null;
const $ = (sel) => document.querySelector(sel);
const targetEl = $('[data-race-target]');
const pillEl = $('[data-race-pill]');
const t1El = $('[data-race-t1]');
const t2El = $('[data-race-t2]');
const verdict = $('[data-race-verdict]');
const startBtn = $('[data-race-start]');
const roundEl = $('[data-race-round]');
const msgEl = $('[data-race-msg]');
const TARGETS = ['Podcasts', 'Translate', 'Radio', 'Measure', 'Vault', 'Recorder', 'Stocks', 'Tasks', 'Weather', 'News', 'Zen', 'Wallet'];
let phase = 'idle';
let target = 'Podcasts';
let lastTarget = null;
let t0 = 0;
let t1 = 0;
let raf = 0;
let gridPhone = null;
const fmt = (ms) => `${(ms / 1000).toFixed(2)}s`;
const label = (text) => { startBtn.firstChild.nodeValue = `${text} `; };
function fitGrid() {
const w = gridHost.clientWidth;
if (w && gridPhone) gridPhone.style.setProperty('--s', (w / 414).toFixed(5));
}
function buildGrid() {
gridHost.innerHTML = gridMarkup(shuffle(APPS));
gridPhone = gridHost.querySelector('.phone');
const scr = gridHost.querySelector('.screen');
scr.style.setProperty('--shape', SHAPES[look.shape].icon);
scr.querySelector('.sbar__time').textContent = document.querySelector('.sbar__time')?.textContent || '9:41';
fitGrid();
}
if ('ResizeObserver' in window) new ResizeObserver(fitGrid).observe(gridHost);
const list = setupPlay(listPhone, { locked: true, onRowTap: (name, row) => tappedRow(name, row) });
function setPhase(p) {
phase = p;
stage.dataset.phase = p;
}
function tick(now) {
raf = 0;
if (phase === 'r1') t1El.textContent = fmt(now - t0);
else if (phase === 'r2') t2El.textContent = fmt(now - t0);
else return;
raf = requestAnimationFrame(tick);
}
function shake(el) {
el.classList.remove('miss');
void el.offsetWidth;
el.classList.add('miss');
buzz(30);
}
function start() {
cancelAnimationFrame(raf);
do target = TARGETS[(Math.random() * TARGETS.length) | 0]; while (target === lastTarget);
lastTarget = target;
targetEl.textContent = target;
pillEl.textContent = target;
t1El.textContent = '–';
t2El.textContent = '–';
buildGrid();
list.reset();
verdict.textContent = `Round 1: find ${target} in the grid.`;
label('Restart');
// The clock only starts once the whole phone is on screen (on phones it sits below the button).
const rc = stage.getBoundingClientRect();
const visible = rc.top >= 0 && rc.bottom <= window.innerHeight;
if (!visible) stage.scrollIntoView({ block: 'center', behavior: 'instant' });
setPhase('r1');
t0 = performance.now();
raf = requestAnimationFrame(tick);
}
gridHost.addEventListener('click', (e) => {
if (phase !== 'r1') return;
const cell = e.target.closest('.rcell');
if (!cell) return;
if (cell.dataset.app !== target) {
shake(cell);
return;
}
t1 = performance.now() - t0;
t1El.textContent = fmt(t1);
cell.classList.add('hit');
setPhase('between');
roundEl.textContent = 'Round 2';
msgEl.textContent = `The same app, Flexera's way. Drag along the letters on the edge to ${target[0]}.`;
verdict.textContent = 'Get ready…';
let n = 3;
const count = () => {
if (phase !== 'between') return;
if (n === 0) {
list.reset();
setPhase('r2');
verdict.textContent = `Round 2: find ${target} in the list.`;
t0 = performance.now();
raf = requestAnimationFrame(tick);
return;
}
roundEl.textContent = String(n);
n--;
setTimeout(count, 620);
};
setTimeout(count, 1100);
});
function tappedRow(name, row) {
if (phase !== 'r2') return;
if (name !== target) {
shake(row);
return;
}
const t2 = performance.now() - t0;
t2El.textContent = fmt(t2);
setPhase('done');
label('Race again');
const ratio = t1 / t2;
if (ratio >= 1.1) {
verdict.textContent = `Flexera was ${ratio.toFixed(1)}× faster. That's the wave.`;
const rc = stage.getBoundingClientRect();
confetti(rc.left + rc.width / 2, rc.top + rc.height * 0.35);
} else if (ratio > 0.9) {
verdict.textContent = 'Dead heat. Race again and ride the letters.';
} else {
verdict.textContent = `The grid won by ${(t2 / t1).toFixed(1)}×. Drag the letters on the edge and race again.`;
}
}
startBtn.addEventListener('click', start);
buildGrid();
setPhase('idle');
return { render: () => list.render() };
}
/* ------------------------------------------------------------------ page */
function setupReveal() {
const els = document.querySelectorAll('.rv');
if (reduced || !('IntersectionObserver' in window)) {
els.forEach((el) => el.classList.add('in'));
return;
}
const io = new IntersectionObserver((entries) => {
for (const en of entries) {
if (en.isIntersecting) {
en.target.classList.add('in');
io.unobserve(en.target);
}
}
}, { rootMargin: '0px 0px -6% 0px', threshold: 0.1 });
els.forEach((el) => io.observe(el));
}
function setupMarquees() {
const tracks = [...document.querySelectorAll('[data-marquee]')].map((el) => {
const base = el.innerHTML;
let guard = 0;
while (el.scrollWidth < window.innerWidth * 1.6 && guard++ < 8) el.innerHTML += base;
el.innerHTML += el.innerHTML; // two identical halves, so the loop is seamless
return { el, speed: +el.dataset.marquee, x: 0, half: el.scrollWidth / 2 };
});
window.addEventListener('resize', () => tracks.forEach((t) => { t.half = t.el.scrollWidth / 2; }));
return (dt, boost) => {
for (const t of tracks) {
t.x -= (t.speed / 1000) * dt * (1 + boost);
if (t.x <= -t.half) t.x += t.half;
if (t.x > 0) t.x -= t.half;
t.el.style.transform = `translate3d(${t.x.toFixed(1)}px,0,0)`;
}
};
}
function boot() {
document.querySelectorAll('[data-version]').forEach((el) => { el.textContent = VERSION; });
const phones = {};
for (const host of document.querySelectorAll('[data-phone]')) phones[host.dataset.phone] = new Phone(host);
// Live time and date in every phone.
const cycle = new Intl.DateTimeFormat(undefined, { hour: 'numeric' }).resolvedOptions().hourCycle || 'h23';
const h24 = !/h1[12]/.test(cycle);
function tick() {
const d = new Date();
const hh = d.getHours();
const mm = String(d.getMinutes()).padStart(2, '0');
const now = h24 ? `${String(hh).padStart(2, '0')}:${mm}` : `${((hh + 11) % 12) + 1}:${mm}`;
const date = d.toLocaleDateString('en-US', { weekday: 'long', month: 'long', day: 'numeric' });
Object.values(phones).forEach((p) => p.tick(now, date));
document.querySelectorAll('.xl .sbar__time, .rgrid ~ .sbar .sbar__time, .xl [data-time]').forEach((el) => { el.textContent = now; });
document.querySelectorAll('.xl [data-date]').forEach((el) => { el.textContent = date; });
}
const layered = setupAnatomy(phones.story);
const story = phones.story ? setupStory(phones.story, layered) : null;
const play = phones.play
? setupPlay(phones.play, { caption: document.querySelector('[data-caption]'), demos: document.querySelector('.play'), egg: true })
: null;
const race = phones.race ? setupRace(phones.race) : null;
tick();
setInterval(tick, 10000);
if (phones.hero) phones.hero.render(storyState(phones.hero, 0.55));
const rerender = () => {
if (story) story(true);
if (play) play.render();
if (race) race.render();
};
setupLook(phones, rerender);
const pageWave = setupPageWave();
const waveTitle = setupWaveTitle();
setupPointerFx();
setupEggs();
// Picture widgets turn to the next picture now and then.
if (!reduced) {
setInterval(() => {
Object.values(phones).forEach((p) => p.nextPicture());
if (layered) layered.syncPictures();
}, 3800);
}
// Fonts change nothing that is measured, but measure again once they are in, to be sure.
if (document.fonts && document.fonts.ready) {
document.fonts.ready.then(() => {
Object.values(phones).forEach((p) => p.measure());
rerender();
});
}
// Is the hero phone on screen?
let heroVisible = true;
const heroHost = document.querySelector('[data-phone="hero"]');
if (heroHost && 'IntersectionObserver' in window) {
new IntersectionObserver((en) => { heroVisible = en[0].isIntersecting; }).observe(heroHost);
}
const nav = document.querySelector('.nav');
const bar = document.querySelector('.nav__progress i');
const tilt = document.querySelector('[data-tilt]');
const hero = document.querySelector('.hero');
const parallax = [...document.querySelectorAll('[data-parallax]')];
let mouse = { x: 0, y: 0 };
if (hero && window.matchMedia('(hover: hover)').matches) {
hero.addEventListener('pointermove', (e) => {
const rc = hero.getBoundingClientRect();
mouse = { x: (e.clientX - rc.left) / rc.width * 2 - 1, y: (e.clientY - rc.top) / rc.height * 2 - 1 };
});
hero.addEventListener('pointerleave', () => { mouse = { x: 0, y: 0 }; });
}
function onScroll() {
const y = window.scrollY;
const vh = window.innerHeight;
nav.classList.toggle('is-scrolled', y > 24);
const max = document.documentElement.scrollHeight - vh;
bar.style.transform = `scaleX(${max > 0 ? (y / max).toFixed(4) : 0})`;
if (!reduced) {
for (const el of parallax) {
const rc = el.getBoundingClientRect();
el.style.transform = `translate3d(0, ${((rc.top + rc.height / 2 - vh / 2) * +el.dataset.parallax).toFixed(1)}px, 0)`;
}
}
if (story) story();
pageWave();
}
const marquee = setupMarquees();
let lastT = performance.now();
let lastY = window.scrollY;
let boost = 0;
let tiltNow = { x: 0, y: 0 };
function loop(now) {
const dt = Math.min(64, now - lastT);
lastT = now;
const y = window.scrollY;
const v = Math.abs(y - lastY) / Math.max(dt, 1);
lastY = y;
boost = lerp(boost, Math.min(7, v * 3), 0.12);
if (!reduced) {
marquee(dt, boost);
if (tilt) {
const hp = clamp(y / (window.innerHeight * 0.9));
tiltNow.x = lerp(tiltNow.x, mouse.x, 0.08);
tiltNow.y = lerp(tiltNow.y, mouse.y, 0.08);
tilt.style.setProperty('--ry', `${(lerp(-16, 0, hp) + tiltNow.x * 7).toFixed(2)}deg`);
tilt.style.setProperty('--rx', `${(lerp(8, 0, hp) - tiltNow.y * 6).toFixed(2)}deg`);
tilt.style.setProperty('--rz', `${lerp(3, 0, hp).toFixed(2)}deg`);
}
if (heroVisible) {
if (phones.hero) phones.hero.render(storyState(phones.hero, heroP(now)));
if (waveTitle) waveTitle(now);
}
}
requestAnimationFrame(loop);
}
let ticking = false;
window.addEventListener('scroll', () => {
if (ticking) return;
ticking = true;
requestAnimationFrame(() => {
ticking = false;
onScroll();
});
}, { passive: true });
window.addEventListener('resize', onScroll);
motion.addEventListener?.('change', (e) => {
reduced = e.matches;
onScroll();
});
if (reduced && tilt) {
tilt.style.setProperty('--ry', '0deg');
tilt.style.setProperty('--rx', '0deg');
tilt.style.setProperty('--rz', '0deg');
}
setupReveal();
onScroll();
requestAnimationFrame(loop);
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', boot);
else boot();
})();