Website: wave headline, page alphabet, exploded phone, race and more
- Wave headline: the hero's letters swell from weight 560 to 900 around the mouse (a wave rolls through on its own without one). - Page alphabet: one letter per section on the right edge, like the launcher's; hover shows the wave and a bubble, drag jumps between sections, tap scrolls there; on phones it appears while scrolling. Blended so it reads on dark, cyan and violet sections. - Detail cards are switches now: left-handed alphabet, compact/comfy rows and four icon shapes, applied to every phone on the page. - Under the glass: a new section where the phone pulls apart into its layers (wallpaper, widgets, icons, app list, glass) with labels. - Race: a new section; find an app in a shuffled grid of 40, then in the list with the wave alphabet, timed. The list phone reuses the playground controller, locked to the list. The phone comes on screen before the clock starts. - Living wallpaper: light bands on a canvas behind the hero. - Magnetic buttons and a finger cursor over the phones (desktop only). - Easter eggs: scrub to F in the playground, or type "flexera". - A vibration tick per letter while scrubbing, on phones that have it. - 40 generic apps instead of 29; the alphabet now sits from the bottom. - Fixed: the alphabet's magnifier had no size, so its 300 px svg box covered the list once the alphabet moved to the left and took taps. Reduced motion stops everything that moves on its own. Checked in Chrome at 1440 x 900 and 390 x 844: no console errors or sideways scrolling, every playground gesture, the camera chapter without flash, each switch, both eggs, a full race, and the QR code as drawn. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
c93bb1df9c
commit
f1fa44b086
5 files changed
+1046
-45
No files matched your search
+20
-3
@@ -34,12 +34,29 @@ and open <http://localhost:8080>.
|
||||
|
||||
## The phone
|
||||
|
||||
The phone is drawn in HTML at 390 x 844 and scaled to fit, three times over: a looping teaser in
|
||||
The phone is drawn in HTML at 390 x 844 and scaled to fit, four times over: a looping teaser in
|
||||
the hero, one driven by the scroll position through the seven "How it works" chapters
|
||||
(`storyState()` in `script.js`), and one in the playground that answers to touch and mouse. All three
|
||||
render the same state object, described in `blank()`. App names and icons on it are generic
|
||||
(`storyState()` in `script.js`), one in the playground that answers to touch and mouse, and the
|
||||
list phone of the race (the playground's controller, locked to the app list). All of them render
|
||||
the same state object, described in `blank()`. The 40 app names and icons on it are generic
|
||||
stand-ins, not real apps.
|
||||
|
||||
Other moving parts, all in `script.js`:
|
||||
|
||||
- **Wave headline** (`setupWaveTitle`): the hero's letters swell from weight 560 to 900 around the
|
||||
mouse; without a mouse a wave rolls through on its own.
|
||||
- **Page alphabet** (`setupPageWave`): one letter per section on the right edge. Hover to see the
|
||||
wave, drag to jump between sections, tap to scroll there. On phones it shows while scrolling.
|
||||
- **Detail-card switches** (`setupLook`): left-handed alphabet, comfy rows and icon shape, applied
|
||||
to every phone on the page.
|
||||
- **Under the glass** (`setupAnatomy`): the phone pulled apart into its layers as the section scrolls.
|
||||
- **Race** (`setupRace`): find an app in a shuffled grid of 40, then in the list with the alphabet.
|
||||
- **Living wallpaper** (`setupRibbons`): light bands on a canvas behind the hero.
|
||||
- **Magnetic buttons and the finger cursor** (`setupPointerFx`), desktop only.
|
||||
- **Easter eggs**: scrub to F in the playground, or type "flexera" anywhere.
|
||||
|
||||
Everything that moves on its own stops for visitors who ask their system for reduced motion.
|
||||
|
||||
## Fonts
|
||||
|
||||
All four are under the SIL Open Font License 1.1 (`fonts/*-OFL.txt`), downloaded from Google Fonts
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 77 KiB After Width: | Height: | Size: 84 KiB |
+95
-7
@@ -58,6 +58,15 @@
|
||||
<symbol id="g-arrow" viewBox="0 0 24 24"><path d="M4 12h15M13 6l6 6-6 6"/></symbol>
|
||||
<symbol id="g-down" viewBox="0 0 24 24"><path d="M12 4v15M6 13l6 6 6-6"/></symbol>
|
||||
<symbol id="g-up" viewBox="0 0 24 24"><path d="M12 20V5M6 11l6-6 6 6"/></symbol>
|
||||
<symbol id="g-alarm" viewBox="0 0 24 24"><circle cx="12" cy="13" r="7"/><path d="M12 10v3.2l2.2 1.6M5 4L2.8 6.2M19 4l2.2 2.2"/></symbol>
|
||||
<symbol id="g-compass" viewBox="0 0 24 24"><circle cx="12" cy="12" r="8.5"/><path d="M15.5 8.5l-2 5-5 2 2-5z"/></symbol>
|
||||
<symbol id="g-game" viewBox="0 0 24 24"><rect x="2.5" y="7" width="19" height="10" rx="5"/><path d="M7 10.5v3M5.5 12h3M15.5 11h.01M17.5 13h.01"/></symbol>
|
||||
<symbol id="g-heart" viewBox="0 0 24 24"><path d="M12 20s-7.5-4.6-7.5-10A4.3 4.3 0 0 1 12 7.4 4.3 4.3 0 0 1 19.5 10c0 5.4-7.5 10-7.5 10z"/></symbol>
|
||||
<symbol id="g-ruler" viewBox="0 0 24 24"><path d="M3 16.5L16.5 3 21 7.5 7.5 21z"/><path d="M7 12.5l1.5 1.5M10 9.5l1.5 1.5M13 6.5l1.5 1.5"/></symbol>
|
||||
<symbol id="g-radio" viewBox="0 0 24 24"><rect x="3" y="8" width="18" height="12" rx="2"/><circle cx="15.5" cy="14" r="2.5"/><path d="M7 12h3M7 15h3M6 8l11-4"/></symbol>
|
||||
<symbol id="g-chart" viewBox="0 0 24 24"><path d="M3 17l5-5 4 3 8-8"/><path d="M15 7h5v5"/></symbol>
|
||||
<symbol id="g-check" viewBox="0 0 24 24"><rect x="4" y="4" width="16" height="16" rx="4"/><path d="M8 12.5l2.5 2.5L16 9.5"/></symbol>
|
||||
<symbol id="g-lock" viewBox="0 0 24 24"><rect x="5" y="10" width="14" height="10" rx="2"/><path d="M8 10V7a4 4 0 0 1 8 0v3"/></symbol>
|
||||
<symbol id="g-star" viewBox="0 0 24 24"><path class="fill" d="M12 1.5c.7 5.6 4.9 9.8 10.5 10.5-5.6.7-9.8 4.9-10.5 10.5-.7-5.6-4.9-9.8-10.5-10.5C7.1 11.3 11.3 7.1 12 1.5z"/></symbol>
|
||||
</svg>
|
||||
|
||||
@@ -73,20 +82,35 @@
|
||||
<a href="#details">Details</a>
|
||||
<a href="#privacy">Privacy</a>
|
||||
<a href="#play">Play</a>
|
||||
<a href="#race">Race</a>
|
||||
</nav>
|
||||
<a class="btn btn--sm btn--pink" href="#get">Get the APK</a>
|
||||
<div class="nav__progress" aria-hidden="true"><i></i></div>
|
||||
</header>
|
||||
|
||||
<!-- The page's own wave alphabet: one letter per section. Drag it like the launcher's. -->
|
||||
<nav class="pagewave" aria-label="Jump to a section" data-pagewave>
|
||||
<a href="#top" data-label="Flexera">F</a>
|
||||
<a href="#how" data-label="How it works">H</a>
|
||||
<a href="#anatomy" data-label="Under the glass">U</a>
|
||||
<a href="#details" data-label="Details">D</a>
|
||||
<a href="#privacy" data-label="Privacy">P</a>
|
||||
<a href="#play" data-label="Touch it">T</a>
|
||||
<a href="#race" data-label="Race">R</a>
|
||||
<a href="#get" data-label="Get it">G</a>
|
||||
<span class="pagewave__bubble" aria-hidden="true"><b>F</b><i>Flexera</i></span>
|
||||
</nav>
|
||||
|
||||
<main id="main">
|
||||
|
||||
<!-- HERO -->
|
||||
<section class="hero" aria-labelledby="hero-title">
|
||||
<canvas class="hero__ribbons" aria-hidden="true"></canvas>
|
||||
<div class="hero__ghost" aria-hidden="true" data-parallax="0.18">Flexera</div>
|
||||
<div class="hero__grid">
|
||||
<div class="hero__copy">
|
||||
<p class="kicker rv"><span class="kicker__dot"></span>Android launcher <i>/</i> v<span data-version>0.13.0</span> <i>/</i> free</p>
|
||||
<h1 class="hero__title rv" id="hero-title">
|
||||
<h1 class="hero__title rv" id="hero-title" data-wave>
|
||||
<span class="line"><span class="line__in">One thumb.</span></span>
|
||||
<span class="line"><span class="line__in grad">Every app.</span></span>
|
||||
</h1>
|
||||
@@ -213,28 +237,55 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- UNDER THE GLASS: the phone pulled apart into its layers -->
|
||||
<section class="anatomy" id="anatomy" aria-labelledby="anatomy-title">
|
||||
<div class="anatomy__sticky">
|
||||
<header class="anatomy__head">
|
||||
<p class="tag rv">[ 02 ] Under the glass</p>
|
||||
<h2 class="h2 rv" id="anatomy-title">
|
||||
<span class="line"><span class="line__in">Every layer.</span></span>
|
||||
<span class="line"><span class="line__in grad">Pulled apart.</span></span>
|
||||
</h2>
|
||||
</header>
|
||||
<div class="anatomy__stage" aria-hidden="true"><div class="xhost" data-xphone></div></div>
|
||||
<ol class="anatomy__labels" data-xlabels>
|
||||
<li><b>Glass</b><span>No feed. No ads.</span></li>
|
||||
<li><b>App list</b><span>A to Z, wave alphabet on the edge.</span></li>
|
||||
<li><b>Icons & folders</b><span>Blur behind every open folder.</span></li>
|
||||
<li><b>Widgets</b><span>Clock, weather, pictures.</span></li>
|
||||
<li><b>Wallpaper</b><span>Yours. Untouched.</span></li>
|
||||
</ol>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- DETAILS -->
|
||||
<section class="details" id="details" aria-labelledby="details-title">
|
||||
<header class="section-head">
|
||||
<p class="tag rv">[ 02 ] The details</p>
|
||||
<p class="tag rv">[ 03 ] The details</p>
|
||||
<h2 class="h2 rv" id="details-title">
|
||||
<span class="line"><span class="line__in">Small things.</span></span>
|
||||
<span class="line"><span class="line__in grad">Done properly.</span></span>
|
||||
</h2>
|
||||
</header>
|
||||
|
||||
<p class="cards__note rv">Cards 01, 02 and 05 are switches. Try them: they restyle every phone on this page.</p>
|
||||
<div class="cards">
|
||||
<article class="card card--ink rv">
|
||||
<div class="card__art art-flip" aria-hidden="true"><i class="art-flip__phone"><b>A B C D E F G</b></i></div>
|
||||
<span class="card__no">01</span>
|
||||
<h3>Lefty? Flip it.</h3>
|
||||
<p>Move the alphabet to the left edge. One switch, and the whole list follows your other thumb.</p>
|
||||
<button class="card__try" type="button" data-look="lefty" aria-pressed="false">Flip the alphabet</button>
|
||||
</article>
|
||||
<article class="card card--cyan rv">
|
||||
<div class="card__art art-rows" aria-hidden="true"><i class="art-rows__col"><b></b><b></b><b></b><b></b><b></b></i><i class="art-rows__col art-rows__col--comfy"><b></b><b></b><b></b></i></div>
|
||||
<span class="card__no">02</span>
|
||||
<h3>Compact or comfy.</h3>
|
||||
<p>Pick how tall the rows in your app list are. More apps on screen, or bigger targets.</p>
|
||||
<div class="card__seg" role="group" aria-label="Row size">
|
||||
<button type="button" data-look="comfy" data-v="0" aria-pressed="true">Compact</button>
|
||||
<button type="button" data-look="comfy" data-v="1" aria-pressed="false">Comfy</button>
|
||||
</div>
|
||||
</article>
|
||||
<article class="card card--violet rv">
|
||||
<div class="card__art art-update" aria-hidden="true"><span class="art-update__v">v<span data-version>0.13.0</span></span><i class="art-update__bar"><b></b></i></div>
|
||||
@@ -249,10 +300,15 @@
|
||||
<p>Coming from Smart Launcher? Import your home screen from its backup: layout, folders and your photo widget.</p>
|
||||
</article>
|
||||
<article class="card card--ink rv">
|
||||
<div class="card__art art-shapes" aria-hidden="true"><i></i><i></i><i></i></div>
|
||||
<div class="card__art art-shapes" role="group" aria-label="Icon shape">
|
||||
<button type="button" data-shape="squircle" aria-pressed="true" aria-label="Squircle"><i></i></button>
|
||||
<button type="button" data-shape="circle" aria-pressed="false" aria-label="Circle"><i></i></button>
|
||||
<button type="button" data-shape="square" aria-pressed="false" aria-label="Square"><i></i></button>
|
||||
<button type="button" data-shape="teardrop" aria-pressed="false" aria-label="Teardrop"><i></i></button>
|
||||
</div>
|
||||
<span class="card__no">05</span>
|
||||
<h3>Make it yours.</h3>
|
||||
<p>Icon packs, icon shapes, themed icons and colors. Labels on or off. Your grid, your rules.</p>
|
||||
<p>Icon packs, icon shapes, themed icons and colors. Labels on or off. Your grid, your rules. Tap a shape above.</p>
|
||||
</article>
|
||||
<article class="card card--cyan rv">
|
||||
<div class="card__art art-tap" aria-hidden="true"><i></i><i></i></div>
|
||||
@@ -272,7 +328,7 @@
|
||||
|
||||
<!-- PRIVACY -->
|
||||
<section class="privacy" id="privacy" aria-labelledby="privacy-title">
|
||||
<p class="tag tag--ink rv">[ 03 ] Privacy</p>
|
||||
<p class="tag tag--ink rv">[ 04 ] Privacy</p>
|
||||
<h2 class="privacy__title rv" id="privacy-title">It goes online for <span class="privacy__hl">exactly two</span> things.</h2>
|
||||
<div class="privacy__list">
|
||||
<div class="pitem rv">
|
||||
@@ -293,7 +349,7 @@
|
||||
<!-- PLAYGROUND -->
|
||||
<section class="play" id="play" aria-labelledby="play-title">
|
||||
<header class="section-head">
|
||||
<p class="tag rv">[ 04 ] Playground</p>
|
||||
<p class="tag rv">[ 05 ] Playground</p>
|
||||
<h2 class="h2 rv" id="play-title">
|
||||
<span class="line"><span class="line__in">Go on.</span></span>
|
||||
<span class="line"><span class="line__in grad">Touch it.</span></span>
|
||||
@@ -321,14 +377,46 @@
|
||||
<li><b>Swipe right</b> on the first page</li>
|
||||
<li><b>Hold</b> the clock to restyle it</li>
|
||||
<li><b>Swipe up</b> from the bottom edge to go home</li>
|
||||
<li><b>Scrub to F</b> for a surprise</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- RACE -->
|
||||
<section class="race" id="race" aria-labelledby="race-title">
|
||||
<header class="section-head">
|
||||
<p class="tag rv">[ 06 ] Race</p>
|
||||
<h2 class="h2 rv" id="race-title">
|
||||
<span class="line"><span class="line__in">Find it.</span></span>
|
||||
<span class="line"><span class="line__in grad">Faster.</span></span>
|
||||
</h2>
|
||||
</header>
|
||||
<div class="race__grid">
|
||||
<div class="race__stage" data-race-stage data-phase="idle">
|
||||
<p class="race__pill" aria-hidden="true">Find <b data-race-pill>Podcasts</b></p>
|
||||
<div class="phone-host race__host race__host--grid" data-race-grid></div>
|
||||
<div class="phone-host race__host race__host--list" data-phone="race"></div>
|
||||
<div class="race__overlay" data-race-overlay aria-hidden="true">
|
||||
<p class="race__round" data-race-round>Round 1</p>
|
||||
<p class="race__msg" data-race-msg>A grid of 40 apps. Find the one named beside it, as fast as you can.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="race__side">
|
||||
<p class="race__find">Find <b data-race-target>Podcasts</b></p>
|
||||
<div class="race__times">
|
||||
<div class="race__time"><span>Round 1<i>A grid of 40 apps</i></span><b data-race-t1>–</b></div>
|
||||
<div class="race__time"><span>Round 2<i>Flexera's list and wave alphabet</i></span><b data-race-t2>–</b></div>
|
||||
</div>
|
||||
<p class="race__verdict" aria-live="polite" data-race-verdict>Two rounds, the same 40 apps. In round 2, drag along the letters on the edge.</p>
|
||||
<button class="btn btn--big btn--pink" type="button" data-race-start>Start the race <svg class="gly" aria-hidden="true"><use href="#g-arrow"/></svg></button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- GET IT -->
|
||||
<section class="get" id="get" aria-labelledby="get-title">
|
||||
<p class="tag tag--ink rv">[ 05 ] Get it</p>
|
||||
<p class="tag tag--ink rv">[ 07 ] Get it</p>
|
||||
<h2 class="get__title rv" id="get-title">
|
||||
<span class="line"><span class="line__in">Get</span></span>
|
||||
<span class="line"><span class="line__in">Flexera.</span></span>
|
||||
|
||||
+742
-23
@@ -77,7 +77,18 @@
|
||||
['Wallet', 'wallet', 'mint'],
|
||||
['Weather', 'sun', 'gold'],
|
||||
['Zen', 'moon', 'blue'],
|
||||
].map(([n, g, c, note]) => ({ n, g, c, note }));
|
||||
['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);
|
||||
@@ -112,6 +123,23 @@
|
||||
const MENU_X = 120;
|
||||
const CLOCK_W = 4 * CW - 36;
|
||||
const DEFAULT_WEIGHT = 300;
|
||||
/** 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 = '') {
|
||||
@@ -306,6 +334,18 @@
|
||||
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];
|
||||
@@ -403,14 +443,16 @@
|
||||
// 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(${(-46 * k).toFixed(1)}px) scale(${(1 + 0.6 * k).toFixed(3)})` : '');
|
||||
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) {
|
||||
css(r.bubble, 'transform', `translate(${(g.alphaX - 140).toFixed(1)}px, ${(this.letterYAt(s.scrub) - 33).toFixed(1)}px) scale(${(0.5 + 0.5 * show).toFixed(3)})`);
|
||||
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];
|
||||
}
|
||||
|
||||
@@ -513,7 +555,7 @@
|
||||
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 - 60, g.alphaX, E(t, 0, 0.1));
|
||||
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;
|
||||
@@ -601,13 +643,13 @@
|
||||
const rail = [...document.querySelectorAll('[data-story-rail] li')];
|
||||
let lastChapter = -1;
|
||||
let lastP = -1;
|
||||
return function update() {
|
||||
return function update(force) {
|
||||
const vh = window.innerHeight;
|
||||
const P = clamp(-box.getBoundingClientRect().top / vh + 0.5, 0, 7);
|
||||
const ch = Math.min(6, Math.floor(P));
|
||||
// Without motion, each chapter shows its finished picture.
|
||||
const shown = reduced ? ch + 0.985 : P;
|
||||
if (Math.abs(shown - lastP) > 0.0002) {
|
||||
if (force || Math.abs(shown - lastP) > 0.0002) {
|
||||
lastP = shown;
|
||||
ph.render(storyState(ph, shown));
|
||||
}
|
||||
@@ -632,10 +674,20 @@
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ the playground */
|
||||
function setupPlay(ph) {
|
||||
/**
|
||||
* 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();
|
||||
const caption = document.querySelector('[data-caption]');
|
||||
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;
|
||||
};
|
||||
@@ -661,6 +713,11 @@
|
||||
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;
|
||||
@@ -874,7 +931,7 @@
|
||||
say(HELLO);
|
||||
},
|
||||
};
|
||||
for (const btn of document.querySelectorAll('[data-demo]')) {
|
||||
for (const btn of opts.demos ? opts.demos.querySelectorAll('[data-demo]') : []) {
|
||||
btn.addEventListener('click', () => {
|
||||
stopAll();
|
||||
const mine = token;
|
||||
@@ -917,13 +974,14 @@
|
||||
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) gest.mode = 'edge';
|
||||
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 inAlpha = p.x > W - 56 && p.y > g.letterY[0] - 44 && p.y < g.letterY[g.letterY.length - 1] + 30;
|
||||
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;
|
||||
@@ -934,7 +992,7 @@
|
||||
gest.list0 = s.list;
|
||||
const row = e.target.closest('.row');
|
||||
gest.row = row ? row.dataset.row : null;
|
||||
if (gest.row) {
|
||||
if (gest.row && !opts.locked) {
|
||||
to({ 'finger.ring': 1 }, 450, linear);
|
||||
lpTimer = setTimeout(() => {
|
||||
if (!gest || gest.moved) return;
|
||||
@@ -977,7 +1035,7 @@
|
||||
break;
|
||||
case 'list':
|
||||
if (gest.moved && !gest.held) {
|
||||
if (gest.list0 <= 1 && dy > 0) s.drawer = clamp(1 - dy / 320);
|
||||
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;
|
||||
@@ -1066,11 +1124,19 @@
|
||||
if (app) bounce(app);
|
||||
}
|
||||
break;
|
||||
case 'scrub':
|
||||
case 'scrub': {
|
||||
scrubbing = false;
|
||||
to({ scrubShow: 0 }, 280);
|
||||
say(`Landed on ${LETTERS[clamp(Math.round(s.scrub), 0, LETTERS.length - 1)]}. That's the wave alphabet.`);
|
||||
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) {
|
||||
@@ -1080,7 +1146,8 @@
|
||||
const row = ph.r.rows.get(g.row);
|
||||
row.classList.add('flash');
|
||||
setTimeout(() => row.classList.remove('flash'), 220);
|
||||
say(`Hold ${g.row} to see what else you can do with it.`);
|
||||
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':
|
||||
@@ -1115,7 +1182,635 @@
|
||||
scr.addEventListener('contextmenu', (e) => e.preventDefault());
|
||||
|
||||
ph.render(s);
|
||||
return { render: () => 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);
|
||||
});
|
||||
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'));
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ living wallpaper behind the hero */
|
||||
function setupRibbons() {
|
||||
const cv = document.querySelector('.hero__ribbons');
|
||||
if (!cv || !cv.getContext) return null;
|
||||
const ctx = cv.getContext('2d');
|
||||
const hero = cv.parentElement;
|
||||
const small = window.matchMedia('(max-width: 900px)').matches;
|
||||
let w = 1;
|
||||
let h = 1;
|
||||
const size = () => {
|
||||
w = cv.width = Math.max(1, Math.round(hero.clientWidth / 3));
|
||||
h = cv.height = Math.max(1, Math.round(hero.clientHeight / 3));
|
||||
};
|
||||
size();
|
||||
window.addEventListener('resize', size);
|
||||
// Bands of light like the sweeps in the icon.
|
||||
const bands = [
|
||||
{ y: 0.3, a: 0.15, f: 1.5, s: 0.00022, p: 0, t: 0.075, c: ['#1ddbfd', '#417df4'] },
|
||||
{ y: 0.58, a: 0.19, f: 1.05, s: 0.00016, p: 2.1, t: 0.11, c: ['#653df0', '#fc59fb'] },
|
||||
{ y: 0.8, a: 0.12, f: 2.1, s: 0.00027, p: 4.2, t: 0.06, c: ['#76fafc', '#653df0'] },
|
||||
{ y: 0.44, a: 0.24, f: 0.8, s: 0.00012, p: 1.3, t: 0.05, c: ['#fc59fb', '#1ddbfd'] },
|
||||
];
|
||||
let mxs = 0.5;
|
||||
let mys = 0.5;
|
||||
let frameNo = 0;
|
||||
return function draw(now, mouse, scrolled) {
|
||||
if (small && frameNo++ % 2) return; // half the frame rate on phones
|
||||
mxs = lerp(mxs, (mouse.x + 1) / 2, 0.05);
|
||||
mys = lerp(mys, (mouse.y + 1) / 2, 0.05);
|
||||
ctx.clearRect(0, 0, w, h);
|
||||
ctx.globalCompositeOperation = 'lighter';
|
||||
ctx.lineCap = 'round';
|
||||
for (const b of bands) {
|
||||
const grad = ctx.createLinearGradient(0, 0, w, 0);
|
||||
grad.addColorStop(0, b.c[0]);
|
||||
grad.addColorStop(1, b.c[1]);
|
||||
ctx.strokeStyle = grad;
|
||||
ctx.globalAlpha = 0.6;
|
||||
ctx.lineWidth = b.t * h;
|
||||
ctx.beginPath();
|
||||
for (let x = -24; x <= w + 24; x += 8) {
|
||||
const u = x / w;
|
||||
const y = (b.y + (mys - 0.5) * 0.12 + scrolled * 0.25) * h
|
||||
+ Math.sin(u * Math.PI * b.f + now * b.s * 6 + b.p) * b.a * h
|
||||
+ Math.sin(u * Math.PI * b.f * 2.3 - now * b.s * 4 + b.p * 2) * b.a * 0.35 * h
|
||||
- (u - mxs) * 0.2 * h;
|
||||
if (x === -24) ctx.moveTo(x, y);
|
||||
else ctx.lineTo(x, y);
|
||||
}
|
||||
ctx.stroke();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ 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>`;
|
||||
}
|
||||
|
||||
function setupAnatomy() {
|
||||
const sec = document.querySelector('.anatomy');
|
||||
const host = document.querySelector('[data-xphone]');
|
||||
if (!sec || !host) 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 sticky = sec.querySelector('.anatomy__sticky');
|
||||
let scale = 1;
|
||||
const fit = () => { scale = host.clientWidth / 414; };
|
||||
fit();
|
||||
if ('ResizeObserver' in window) new ResizeObserver(fit).observe(host);
|
||||
let lastE = -1;
|
||||
return function update(force) {
|
||||
const rc = sec.getBoundingClientRect();
|
||||
const vh = window.innerHeight;
|
||||
if (!force && (rc.bottom < -50 || rc.top > vh + 50)) return;
|
||||
const p = clamp(-rc.top / Math.max(1, rc.height - vh));
|
||||
let e = smooth(seg(p, 0.08, 0.4)) * (1 - smooth(seg(p, 0.8, 0.98)));
|
||||
if (reduced) e = p > 0.04 && p < 0.96 ? 1 : 0;
|
||||
if (!force && Math.abs(e - lastE) < 0.0005) return;
|
||||
lastE = e;
|
||||
const GAP = 150; // between layers, in the phone's pixels
|
||||
const tilt = 56 * e;
|
||||
const sc = scale * (1 - 0.28 * e);
|
||||
const ox = 207 * (scale - sc);
|
||||
// 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)` : '';
|
||||
});
|
||||
// Flat, it is just a home screen: the app list layer only shows once the stack opens.
|
||||
layers[4].style.opacity = smooth(seg(e, 0.15, 0.6)).toFixed(3);
|
||||
const wide = window.innerWidth > 900;
|
||||
const base = sticky.getBoundingClientRect();
|
||||
let prevY = -Infinity;
|
||||
labels.forEach((li, k) => {
|
||||
li.style.opacity = seg(e, 0.55, 0.95).toFixed(3);
|
||||
if (!wide) return;
|
||||
const pr = pins[k].getBoundingClientRect();
|
||||
const x = Math.min(pr.left - base.left + 16, base.width - 250);
|
||||
const y = Math.max(pr.top - base.top - 12, prevY + 60); // never on top of the one above
|
||||
prevY = y;
|
||||
li.style.transform = `translate(${x.toFixed(1)}px, ${y.toFixed(1)}px)`;
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ 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 */
|
||||
@@ -1171,13 +1866,31 @@
|
||||
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; });
|
||||
}
|
||||
tick();
|
||||
setInterval(tick, 10000);
|
||||
|
||||
const story = phones.story ? setupStory(phones.story) : null;
|
||||
const play = phones.play ? setupPlay(phones.play) : null;
|
||||
const play = phones.play
|
||||
? setupPlay(phones.play, { caption: document.querySelector('[data-caption]'), demos: document.querySelector('.play'), egg: true })
|
||||
: null;
|
||||
const anatomy = setupAnatomy();
|
||||
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();
|
||||
if (anatomy) anatomy(true);
|
||||
};
|
||||
setupLook(phones, rerender);
|
||||
const pageWave = setupPageWave();
|
||||
const waveTitle = setupWaveTitle();
|
||||
const ribbons = setupRibbons();
|
||||
setupPointerFx();
|
||||
setupEggs();
|
||||
|
||||
// Picture widgets turn to the next picture now and then.
|
||||
if (!reduced) setInterval(() => Object.values(phones).forEach((p) => p.nextPicture()), 3800);
|
||||
@@ -1186,8 +1899,7 @@
|
||||
if (document.fonts && document.fonts.ready) {
|
||||
document.fonts.ready.then(() => {
|
||||
Object.values(phones).forEach((p) => p.measure());
|
||||
if (story) story();
|
||||
if (play) play.render();
|
||||
rerender();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1225,6 +1937,8 @@
|
||||
}
|
||||
}
|
||||
if (story) story();
|
||||
if (anatomy) anatomy();
|
||||
pageWave();
|
||||
}
|
||||
|
||||
const marquee = setupMarquees();
|
||||
@@ -1249,10 +1963,15 @@
|
||||
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 (phones.hero && heroVisible) phones.hero.render(storyState(phones.hero, heroP(now)));
|
||||
if (heroVisible) {
|
||||
if (phones.hero) phones.hero.render(storyState(phones.hero, heroP(now)));
|
||||
if (waveTitle) waveTitle(now);
|
||||
if (ribbons) ribbons(now, tiltNow, clamp(y / window.innerHeight));
|
||||
}
|
||||
}
|
||||
requestAnimationFrame(loop);
|
||||
}
|
||||
if (reduced && ribbons) ribbons(0, { x: 0, y: 0 }, 0); // one still picture
|
||||
|
||||
let ticking = false;
|
||||
window.addEventListener('scroll', () => {
|
||||
|
||||
+189
-12
@@ -332,8 +332,8 @@ section { position: relative; }
|
||||
|
||||
/* card art: alphabet that hops from edge to edge */
|
||||
.art-flip__phone { position: absolute; left: 0; top: 0; width: 120px; height: 150px; border: var(--b) solid currentColor; border-radius: 18px; }
|
||||
.art-flip__phone b { position: absolute; top: 12px; right: 8px; width: 12px; font: 700 10px/1.25 var(--ui); text-align: center; word-break: break-all; color: var(--cyan); animation: flip 4s cubic-bezier(.7, 0, .3, 1) infinite; }
|
||||
@keyframes flip { 0%, 40% { right: 8px; } 50%, 90% { right: 96px; } 100% { right: 8px; } }
|
||||
.art-flip__phone b { position: absolute; top: 12px; right: 8px; width: 12px; font: 700 10px/1.25 var(--ui); text-align: center; word-break: break-all; color: var(--cyan); transition: right .55s cubic-bezier(.7, 0, .3, 1); }
|
||||
.art-flip.is-left .art-flip__phone b { right: 96px; }
|
||||
/* compact and comfy rows */
|
||||
.art-rows { display: flex; gap: 22px; }
|
||||
.art-rows__col { display: flex; flex-direction: column; gap: 9px; width: 120px; padding: 12px; border: var(--b) solid var(--ink); border-radius: 14px; }
|
||||
@@ -353,12 +353,31 @@ section { position: relative; }
|
||||
.art-move__box--f img { width: 60px; height: 60px; }
|
||||
.art-move__arrow { width: 44px; height: 44px; stroke-width: 3; animation: nudge 1.2s ease-in-out infinite; }
|
||||
@keyframes nudge { 50% { translate: 8px 0; } }
|
||||
/* shapes that morph */
|
||||
.art-shapes { display: flex; align-items: center; gap: 18px; }
|
||||
.art-shapes i { width: 72px; height: 72px; background: var(--grad); border: var(--b) solid var(--fg); animation: morph 4.5s ease-in-out infinite; }
|
||||
.art-shapes i:nth-child(2) { animation-delay: -1.5s; }
|
||||
.art-shapes i:nth-child(3) { animation-delay: -3s; }
|
||||
@keyframes morph { 0%, 100% { border-radius: 50%; } 33% { border-radius: 30%; } 66% { border-radius: 8px; } }
|
||||
/* icon shapes to pick from */
|
||||
.art-shapes { display: flex; align-items: center; flex-wrap: wrap; gap: 8px; }
|
||||
.art-shapes button { all: unset; cursor: pointer; padding: 5px; border: var(--b) solid transparent; transition: border-color .2s, transform .15s; }
|
||||
.art-shapes button:hover { transform: translateY(-3px); }
|
||||
.art-shapes button:focus-visible { outline: 3px solid var(--cyan); outline-offset: 2px; }
|
||||
.art-shapes button[aria-pressed="true"] { border-color: var(--cyan); }
|
||||
.art-shapes i { display: block; width: 60px; height: 60px; background: var(--grad); border: var(--b) solid var(--fg); }
|
||||
.art-shapes [data-shape="squircle"] i { border-radius: 30%; }
|
||||
.art-shapes [data-shape="circle"] i { border-radius: 50%; }
|
||||
.art-shapes [data-shape="square"] i { border-radius: 14%; }
|
||||
.art-shapes [data-shape="teardrop"] i { border-radius: 50% 50% 50% 14%; }
|
||||
|
||||
/* switches on the cards */
|
||||
.cards__note { max-width: var(--max); margin: 0 auto 22px; padding: 0 var(--pad); font: 700 13px/1.4 var(--mono); text-transform: uppercase; letter-spacing: .08em; color: var(--pink); }
|
||||
.card__try, .card__seg button {
|
||||
font: 700 12.5px/1 var(--mono); text-transform: uppercase; letter-spacing: .06em;
|
||||
padding: 12px 15px; border: var(--b) solid currentColor; background: transparent; color: inherit; cursor: pointer;
|
||||
transition: background .2s, color .2s, transform .14s, box-shadow .14s, translate .3s cubic-bezier(.2, .8, .2, 1);
|
||||
}
|
||||
.card__try { align-self: flex-start; margin-top: 24px; box-shadow: 4px 4px 0 var(--cyan); }
|
||||
.card__try:active { transform: translate(3px, 3px); box-shadow: 1px 1px 0 var(--cyan); }
|
||||
.card__try[aria-pressed="true"] { background: var(--cyan); color: var(--ink); border-color: var(--cyan); }
|
||||
.card__seg { display: inline-flex; align-self: flex-start; margin-top: 24px; }
|
||||
.card__seg button + button { border-left: 0; }
|
||||
.card__seg button[aria-pressed="true"] { background: var(--ink); color: var(--cyan); border-color: var(--ink); }
|
||||
/* double tap */
|
||||
.art-tap { display: grid; place-items: center; }
|
||||
.art-tap i { position: absolute; width: 56px; height: 56px; border-radius: 50%; border: 3px solid var(--ink); animation: tap 1.8s ease-out infinite; }
|
||||
@@ -555,7 +574,8 @@ section { position: relative; }
|
||||
@keyframes tapb { 40% { transform: scale(.86); } }
|
||||
.ic {
|
||||
position: relative; display: grid; place-items: center;
|
||||
width: 58px; height: 58px; border-radius: 30%;
|
||||
width: 58px; height: 58px; border-radius: var(--shape, 30%);
|
||||
transition: border-radius .35s ease;
|
||||
color: #fff;
|
||||
background: linear-gradient(145deg, var(--a), var(--b));
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, .35), 0 6px 14px -6px rgba(0, 0, 0, .55);
|
||||
@@ -563,7 +583,7 @@ section { position: relative; }
|
||||
}
|
||||
.ic svg { width: 46%; height: 46%; }
|
||||
.ndot { position: absolute; top: 4px; left: calc(50% + 20px); width: 13px; height: 13px; border-radius: 50%; background: var(--pink); border: 2px solid #0d0b40; }
|
||||
.fic { width: 58px; height: 58px; padding: 7px; border-radius: 30%; background: rgba(236, 233, 249, .94); display: grid; grid-template-columns: 1fr 1fr; gap: 4px; }
|
||||
.fic { width: 58px; height: 58px; padding: 7px; border-radius: var(--shape, 30%); transition: border-radius .35s ease; background: rgba(236, 233, 249, .94); display: grid; grid-template-columns: 1fr 1fr; gap: 4px; }
|
||||
.ic--mini { width: 100%; height: auto; aspect-ratio: 1; box-shadow: none; }
|
||||
.ic--mini svg { stroke-width: 2.6; }
|
||||
|
||||
@@ -575,7 +595,7 @@ section { position: relative; }
|
||||
.clk__wx { margin-top: 4px; font: 400 16px/1.2 var(--ui); opacity: .92; }
|
||||
|
||||
.w-pics { position: absolute; padding: 8px 12px 18px; }
|
||||
.pics { position: relative; width: 100%; height: 100%; border-radius: 30px; overflow: hidden; box-shadow: 0 10px 24px -10px rgba(0, 0, 0, .6); }
|
||||
.pics { position: relative; width: 100%; height: 100%; border-radius: var(--shape-w, 30px); transition: border-radius .35s ease; overflow: hidden; box-shadow: 0 10px 24px -10px rgba(0, 0, 0, .6); }
|
||||
.pic { position: absolute; inset: 0; opacity: 0; transition: opacity 1.1s ease; }
|
||||
.pic.on { opacity: 1; }
|
||||
.pic--a { /* sunset over water */
|
||||
@@ -634,10 +654,18 @@ section { position: relative; }
|
||||
.row__n { font: 500 17.5px/1.15 var(--ui); }
|
||||
.row__p { margin-top: 3px; font: 400 13px/1.2 var(--ui); color: #cfcbef; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.row.flash { background: rgba(255, 255, 255, .12); }
|
||||
/* Settings from the detail cards. */
|
||||
.screen.is-comfy .row { height: 66px; }
|
||||
.screen.is-comfy .row--note { height: 78px; }
|
||||
.screen.is-comfy .row .ic { width: 48px; height: 48px; }
|
||||
.screen.is-comfy .row__n { font-size: 19.5px; }
|
||||
.screen.is-lefty .list { padding: 0 20px 0 58px; }
|
||||
.screen.is-lefty .alpha { right: auto; left: 8px; }
|
||||
.row.held { background: rgba(255, 255, 255, .1); }
|
||||
|
||||
.alpha { position: absolute; right: 8px; top: 404px; width: 30px; display: flex; flex-direction: column; align-items: center; z-index: 2; }
|
||||
.alpha { position: absolute; right: 8px; bottom: 112px; width: 30px; display: flex; flex-direction: column; align-items: center; z-index: 2; }
|
||||
.alpha__s { width: 15px; height: 15px; margin-bottom: 8px; color: #fff; opacity: .9; }
|
||||
.alpha__s svg { display: block; width: 15px; height: 15px; pointer-events: none; } /* unsized, an svg is 300 px wide and would cover the list */
|
||||
.alpha__l { display: block; width: 30px; height: 21px; font: 600 12.5px/21px var(--ui); text-align: center; color: rgba(255, 255, 255, .86); transform-origin: 50% 50%; }
|
||||
.alpha__l.on { color: var(--pink); font-weight: 800; }
|
||||
.bubble { position: absolute; left: 0; top: 0; width: 66px; height: 66px; display: grid; place-items: center; border-radius: 50%; background: var(--grad); color: #fff; font: 700 32px/1 var(--ui); box-shadow: 0 10px 30px -6px rgba(252, 89, 251, .6); opacity: 0; z-index: 3; pointer-events: none; text-shadow: 0 1px 6px rgba(0, 0, 0, .3); }
|
||||
@@ -692,6 +720,131 @@ section { position: relative; }
|
||||
}
|
||||
.finger__ring { position: absolute; inset: -9px; border-radius: 50%; background: conic-gradient(var(--pink) calc(var(--ring, 0) * 1turn), transparent 0); -webkit-mask: radial-gradient(closest-side, transparent 78%, #000 80%); mask: radial-gradient(closest-side, transparent 78%, #000 80%); }
|
||||
|
||||
/* ---------- living wallpaper behind the hero ---------- */
|
||||
.hero__ribbons {
|
||||
position: absolute; inset: 0; width: 100%; height: 100%; pointer-events: none;
|
||||
filter: blur(26px) saturate(1.25); opacity: .62;
|
||||
-webkit-mask-image: linear-gradient(100deg, rgba(0, 0, 0, .3) 18%, #000 62%);
|
||||
mask-image: linear-gradient(100deg, rgba(0, 0, 0, .3) 18%, #000 62%);
|
||||
}
|
||||
|
||||
/* ---------- the page's wave alphabet ---------- */
|
||||
.pagewave {
|
||||
position: fixed; right: 8px; top: 50%; translate: 0 -50%; z-index: 85;
|
||||
display: flex; flex-direction: column; align-items: center;
|
||||
padding: 10px 6px; touch-action: none; user-select: none; -webkit-user-select: none;
|
||||
}
|
||||
.pagewave a {
|
||||
display: grid; place-items: center; width: 26px; height: 27px;
|
||||
font: 800 12px/1 var(--display); color: rgba(255, 255, 255, .62); text-decoration: none;
|
||||
transform-origin: 100% 50%; transition: color .2s;
|
||||
mix-blend-mode: difference; /* readable on the dark, cyan and violet sections alike */
|
||||
}
|
||||
.pagewave a:hover, .pagewave a:focus-visible { color: #fff; }
|
||||
.pagewave a.on { color: #fff; text-decoration: underline; text-decoration-thickness: 3px; text-underline-offset: 3px; }
|
||||
.pagewave__bubble {
|
||||
position: absolute; right: 66px; top: 0;
|
||||
display: flex; align-items: center; gap: 10px; padding: 6px 14px 6px 6px;
|
||||
background: var(--fg); color: var(--ink); border: var(--b) solid var(--ink); box-shadow: 4px 4px 0 var(--pink);
|
||||
opacity: 0; pointer-events: none; white-space: nowrap; transition: opacity .2s;
|
||||
}
|
||||
.pagewave__bubble b { display: grid; place-items: center; width: 42px; height: 42px; border-radius: 50%; background: var(--grad); color: #fff; font: 900 20px/1 var(--display); }
|
||||
.pagewave__bubble i { font: 800 12.5px/1 var(--mono); font-style: normal; text-transform: uppercase; letter-spacing: .08em; }
|
||||
.pagewave.is-active .pagewave__bubble { opacity: 1; }
|
||||
|
||||
/* ---------- toast, confetti, finger cursor ---------- */
|
||||
.toast {
|
||||
position: fixed; left: 50%; bottom: 22px; z-index: 120; max-width: calc(100vw - 32px);
|
||||
transform: translate(-50%, 160%);
|
||||
padding: 13px 16px; background: var(--cyan); color: var(--ink);
|
||||
border: var(--b) solid var(--ink); box-shadow: 5px 5px 0 var(--ink);
|
||||
font: 700 13px/1.35 var(--mono); text-transform: uppercase; letter-spacing: .05em;
|
||||
transition: transform .4s cubic-bezier(.2, .8, .2, 1);
|
||||
}
|
||||
.toast.is-on { transform: translate(-50%, 0); }
|
||||
.confetti { position: fixed; inset: 0; width: 100%; height: 100%; pointer-events: none; z-index: 150; }
|
||||
.tcursor {
|
||||
position: fixed; left: 0; top: 0; z-index: 140; width: 46px; height: 46px; margin: -23px 0 0 -23px;
|
||||
border-radius: 50%; background: rgba(255, 255, 255, .2); border: 2px solid rgba(255, 255, 255, .9);
|
||||
box-shadow: 0 0 0 6px rgba(255, 255, 255, .08), 0 10px 26px rgba(0, 0, 0, .35);
|
||||
pointer-events: none; opacity: 0; transition: opacity .2s, scale .15s;
|
||||
}
|
||||
.over-phone .tcursor { opacity: 1; }
|
||||
.tcursor.is-down { scale: .82; }
|
||||
.over-phone .tcursor.is-down { opacity: 0; } /* the phone draws its own finger while pressed */
|
||||
.over-phone .phone-host, .over-phone .phone-host * { cursor: none !important; }
|
||||
.btn, .chip { transition: transform .14s ease, box-shadow .14s ease, background .2s, color .2s, translate .35s cubic-bezier(.2, .8, .2, 1); }
|
||||
|
||||
/* ---------- under the glass: the phone pulled apart ---------- */
|
||||
.anatomy { position: relative; height: 320vh; height: 320svh; }
|
||||
.anatomy__sticky {
|
||||
position: sticky; top: 0; height: 100vh; height: 100svh; overflow: hidden;
|
||||
display: grid; grid-template-columns: minmax(0, .85fr) minmax(0, 1.15fr); align-items: center;
|
||||
max-width: var(--max); margin: 0 auto; padding: 70px var(--pad) 30px;
|
||||
}
|
||||
.anatomy__stage { position: relative; display: grid; place-items: center; height: 100%; }
|
||||
.xhost { position: relative; height: min(68vh, 720px); height: min(68svh, 720px); aspect-ratio: 414 / 868; perspective: 2600px; }
|
||||
.xphone { position: absolute; left: 0; top: 0; width: 414px; height: 868px; transform-origin: 0 0; transform-style: preserve-3d; --e: 0; }
|
||||
.xl { position: absolute; left: 12px; top: 12px; width: 390px; height: 844px; border-radius: 48px; overflow: hidden; font-family: var(--ui); color: #fff; box-shadow: 0 0 0 2px rgba(118, 250, 252, calc(var(--e) * .55)); }
|
||||
.xl * { box-sizing: border-box; }
|
||||
.xl .ic svg, .xl .gl { fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; }
|
||||
.xl--body { left: 0; top: 0; width: 414px; height: 868px; border-radius: 60px; background: linear-gradient(150deg, #34324d, #121122 45%, #08070f); box-shadow: inset 0 0 0 2px #4a4767, 0 50px 90px -30px rgba(101, 61, 240, .7); }
|
||||
.xl--list { background: rgba(5, 4, 20, calc(.35 + var(--e) * .3)); }
|
||||
.xl--list .list { top: 46px; }
|
||||
.xl--list .alpha { bottom: 112px; }
|
||||
.xl--glass { background: linear-gradient(125deg, rgba(255, 255, 255, .2), rgba(255, 255, 255, 0) 38%, rgba(255, 255, 255, .07) 68%, rgba(255, 255, 255, 0)); }
|
||||
.xl__pin { position: absolute; right: 0; top: 46%; width: 1px; height: 1px; }
|
||||
.anatomy__labels { list-style: none; margin: 0; padding: 0; }
|
||||
.anatomy__labels li { position: absolute; left: 0; top: 0; width: 240px; padding-left: 66px; opacity: 0; pointer-events: none; will-change: transform; }
|
||||
.anatomy__labels li::before { content: ""; position: absolute; left: 0; top: .55em; width: 56px; height: 2px; background: var(--cyan); }
|
||||
.anatomy__labels b { display: block; font: 900 17px/1.1 var(--display); text-transform: uppercase; letter-spacing: -.02em; }
|
||||
.anatomy__labels span { display: block; margin-top: 5px; font: 500 14px/1.35 var(--body); color: var(--dim); }
|
||||
|
||||
/* ---------- race ---------- */
|
||||
.race__grid {
|
||||
display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: clamp(30px, 5vw, 90px); align-items: center;
|
||||
max-width: var(--max); margin: 0 auto; padding: 0 var(--pad) clamp(80px, 10vw, 140px);
|
||||
}
|
||||
.race__stage { position: relative; height: min(80vh, 840px); height: min(80svh, 840px); aspect-ratio: 414 / 868; margin: 0 auto; }
|
||||
.race__host { position: absolute; inset: 0; transition: opacity .35s ease; touch-action: none; }
|
||||
.race__stage[data-phase="between"] .race__host--grid,
|
||||
.race__stage[data-phase="r2"] .race__host--grid,
|
||||
.race__stage[data-phase="done"] .race__host--grid,
|
||||
.race__stage[data-phase="idle"] .race__host--list,
|
||||
.race__stage[data-phase="r1"] .race__host--list { opacity: 0; pointer-events: none; }
|
||||
.race__overlay {
|
||||
position: absolute; left: 8%; right: 8%; top: 50%; translate: 0 -50%; z-index: 3;
|
||||
padding: 22px; background: var(--fg); color: var(--ink);
|
||||
border: var(--b) solid var(--ink); box-shadow: 8px 8px 0 var(--pink);
|
||||
transition: opacity .25s, scale .25s;
|
||||
}
|
||||
.race__stage[data-phase="r1"] .race__overlay, .race__stage[data-phase="r2"] .race__overlay, .race__stage[data-phase="done"] .race__overlay { opacity: 0; scale: .9; pointer-events: none; }
|
||||
.race__pill {
|
||||
position: absolute; left: 50%; top: -18px; translate: -50% 0; z-index: 4; margin: 0; white-space: nowrap;
|
||||
padding: 9px 14px; background: var(--cyan); color: var(--ink); border: var(--b) solid var(--ink); box-shadow: 4px 4px 0 var(--ink);
|
||||
font: 700 13px/1 var(--mono); text-transform: uppercase; letter-spacing: .06em;
|
||||
opacity: 0; transition: opacity .25s;
|
||||
}
|
||||
.race__pill b { font-family: var(--display); font-weight: 900; }
|
||||
.race__stage[data-phase="r1"] .race__pill, .race__stage[data-phase="r2"] .race__pill, .race__stage[data-phase="between"] .race__pill { opacity: 1; }
|
||||
.race__round { margin: 0; font: 900 clamp(26px, 3vw, 40px)/1 var(--display); text-transform: uppercase; letter-spacing: -.03em; }
|
||||
.race__msg { margin: 10px 0 0; font-size: 16px; line-height: 1.4; }
|
||||
.race__find { margin: 0 0 24px; font: 800 clamp(26px, 2.6vw, 40px)/1.1 var(--display); text-transform: uppercase; letter-spacing: -.03em; }
|
||||
.race__find b { display: inline-block; padding: 0 .2em; background: var(--cyan); color: var(--ink); transform: rotate(-2deg); }
|
||||
.race__times { border-top: var(--b) solid var(--fg); margin-bottom: 24px; }
|
||||
.race__time { display: flex; align-items: center; justify-content: space-between; gap: 20px; padding: 16px 0; border-bottom: 1px solid rgba(245, 244, 255, .25); }
|
||||
.race__time span { font: 700 13px/1.3 var(--mono); text-transform: uppercase; letter-spacing: .08em; }
|
||||
.race__time i { display: block; margin-top: 4px; font-style: normal; font-weight: 500; color: var(--dim); }
|
||||
.race__time b { font: 900 clamp(34px, 4vw, 60px)/1 var(--display); letter-spacing: -.04em; font-variant-numeric: tabular-nums; background: var(--grad); -webkit-background-clip: text; background-clip: text; color: transparent; }
|
||||
.race__verdict { min-height: 3em; margin: 0 0 26px; font: 800 clamp(18px, 1.7vw, 24px)/1.25 var(--display); text-transform: uppercase; letter-spacing: -.02em; }
|
||||
.rgrid { position: absolute; left: 8px; right: 8px; top: 52px; display: grid; grid-template-columns: repeat(5, 1fr); row-gap: 12px; }
|
||||
.rcell { all: unset; display: flex; flex-direction: column; align-items: center; gap: 5px; cursor: pointer; -webkit-tap-highlight-color: transparent; }
|
||||
.rcell .ic { width: 50px; height: 50px; }
|
||||
.rcell span { max-width: 70px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font: 500 10.5px/1.15 var(--ui); color: #fff; text-shadow: 0 1px 4px rgba(0, 0, 0, .6); }
|
||||
.rcell.miss .ic, .row.miss { animation: miss .32s ease; }
|
||||
.rcell.hit .ic { box-shadow: 0 0 0 4px #fff, 0 0 26px 6px rgba(118, 250, 252, .8); }
|
||||
@keyframes miss { 25% { translate: -6px 0; } 75% { translate: 6px 0; } }
|
||||
|
||||
/* ---------- responsive ---------- */
|
||||
@media (max-width: 1100px) {
|
||||
.nav__links { display: none; }
|
||||
@@ -739,6 +892,30 @@ section { position: relative; }
|
||||
.get__grid { grid-template-columns: minmax(0, 1fr); }
|
||||
.qr { justify-self: start; }
|
||||
.foot__row .textlink { margin-left: 0; }
|
||||
|
||||
.story__rail { display: none; }
|
||||
/* On phones the page alphabet shows up while scrolling, low on the edge like the launcher's. */
|
||||
.pagewave { top: auto; bottom: 14svh; translate: none; right: 2px; opacity: 0; pointer-events: none; transition: opacity .3s; }
|
||||
.pagewave.is-awake, .pagewave.is-active { opacity: 1; pointer-events: auto; }
|
||||
.pagewave a { height: 25px; }
|
||||
.pagewave__bubble { right: 58px; }
|
||||
|
||||
.anatomy { height: 260svh; }
|
||||
.anatomy__sticky { grid-template-columns: minmax(0, 1fr); grid-template-rows: auto minmax(0, 1fr) auto; padding-top: 74px; gap: 8px; }
|
||||
.anatomy__head .h2 { font-size: clamp(32px, 9vw, 48px); }
|
||||
.xhost { height: 44svh; }
|
||||
.anatomy__labels { display: flex; flex-wrap: wrap; gap: 6px; padding-bottom: 6px; }
|
||||
.anatomy__labels li { position: static; width: auto; padding: 7px 10px; border: var(--b) solid var(--fg); transform: none !important; font-size: 12px; }
|
||||
.anatomy__labels li::before, .anatomy__labels span { display: none; }
|
||||
.anatomy__labels b { font-size: 11px; }
|
||||
|
||||
.race__grid { grid-template-columns: minmax(0, 1fr); }
|
||||
.race__side { order: -1; }
|
||||
.race__stage { height: 70svh; }
|
||||
.race__find { margin-bottom: 14px; }
|
||||
.race__times { margin-bottom: 14px; }
|
||||
.race__time { padding: 10px 0; }
|
||||
.race__verdict { min-height: 0; margin-bottom: 18px; }
|
||||
}
|
||||
|
||||
@media (max-width: 520px) {
|
||||
|
||||
Reference in new issue
Block a user