Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9026a8ec7a | ||
|
|
f5d7cf07ed | ||
|
|
0dbb146c75 | ||
|
|
900071ccac | ||
|
|
0fbefd2ca6 | ||
|
|
058fecd27f | ||
|
|
6c68cb1369 | ||
|
|
f1fa44b086 |
No files matched your search
+22
-4
@@ -34,12 +34,30 @@ 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 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
|
||||
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 eight "How it works" chapters
|
||||
(`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.
|
||||
- **Every layer, pulled apart** (`setupAnatomy`): the story's eighth chapter. The story phone is
|
||||
swapped for a double built from separate layers (it looks the same closed), which then comes
|
||||
apart in 3D with a labelled tag per layer and goes back together.
|
||||
- **Race** (`setupRace`): find an app in a shuffled grid of 40, then in the list with the alphabet.
|
||||
- **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: 80 KiB |
+98
-13
@@ -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,11 +82,25 @@
|
||||
<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 -->
|
||||
@@ -86,7 +109,7 @@
|
||||
<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>
|
||||
@@ -146,16 +169,27 @@
|
||||
<div class="story__body">
|
||||
<div class="story__stage" aria-hidden="true">
|
||||
<div class="story__num" data-story-num>01</div>
|
||||
<div class="phone-host" data-phone="story"></div>
|
||||
<div class="story__phones">
|
||||
<div class="phone-host" data-phone="story"></div>
|
||||
<div class="xhost" data-xphone></div>
|
||||
</div>
|
||||
<svg class="anatomy__lines" data-xlines></svg>
|
||||
<ol class="anatomy__labels" data-xlabels>
|
||||
<li><i>05</i><b>Glass</b><span>No feed. No ads.</span></li>
|
||||
<li><i>04</i><b>App list</b><span>A to Z + wave alphabet</span></li>
|
||||
<li><i>03</i><b>Icons & folders</b><span>Blur behind folders</span></li>
|
||||
<li><i>02</i><b>Widgets</b><span>Clock, weather, pictures</span></li>
|
||||
<li><i>01</i><b>Wallpaper</b><span>Yours. Untouched.</span></li>
|
||||
</ol>
|
||||
<ol class="story__rail" data-story-rail>
|
||||
<li></li><li></li><li></li><li></li><li></li><li></li><li></li>
|
||||
<li></li><li></li><li></li><li></li><li></li><li></li><li></li><li></li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<div class="story__steps">
|
||||
<article class="step" data-step>
|
||||
<div class="step__card">
|
||||
<p class="step__no">01 / 07</p>
|
||||
<p class="step__no">01 / 08</p>
|
||||
<h3 class="step__title">Home, minus the noise.</h3>
|
||||
<p class="step__text">Pages, folders and widgets, in the Android layout you already know. No feed, no ads, nothing you didn't put there yourself.</p>
|
||||
<p class="step__gesture"><span>Gesture</span> None. Just look.</p>
|
||||
@@ -163,7 +197,7 @@
|
||||
</article>
|
||||
<article class="step" data-step>
|
||||
<div class="step__card">
|
||||
<p class="step__no">02 / 07</p>
|
||||
<p class="step__no">02 / 08</p>
|
||||
<h3 class="step__title">Swipe up. A to Z.</h3>
|
||||
<p class="step__text">Every app in one clean column, straight over your wallpaper. New messages show up under the app's name, so you see them before you open anything.</p>
|
||||
<p class="step__gesture"><span>Gesture</span> Swipe up</p>
|
||||
@@ -171,7 +205,7 @@
|
||||
</article>
|
||||
<article class="step" data-step>
|
||||
<div class="step__card">
|
||||
<p class="step__no">03 / 07</p>
|
||||
<p class="step__no">03 / 08</p>
|
||||
<h3 class="step__title">Ride the wave.</h3>
|
||||
<p class="step__text">Slide your thumb along the letters on the edge. They swell around it like a wave, and the list drops that letter into the middle of the screen, right where your thumb already is. Swipe up and keep holding to scrub straight from home.</p>
|
||||
<p class="step__gesture"><span>Gesture</span> Slide on the edge</p>
|
||||
@@ -179,7 +213,7 @@
|
||||
</article>
|
||||
<article class="step" data-step>
|
||||
<div class="step__card">
|
||||
<p class="step__no">04 / 07</p>
|
||||
<p class="step__no">04 / 08</p>
|
||||
<h3 class="step__title">Folders in focus.</h3>
|
||||
<p class="step__text">Open a folder and the rest of the screen melts into a soft blur, wallpaper included. Just the apps you came for.</p>
|
||||
<p class="step__gesture"><span>Gesture</span> Tap</p>
|
||||
@@ -187,7 +221,7 @@
|
||||
</article>
|
||||
<article class="step" data-step>
|
||||
<div class="step__card">
|
||||
<p class="step__no">05 / 07</p>
|
||||
<p class="step__no">05 / 08</p>
|
||||
<h3 class="step__title">Swipe into nothing.</h3>
|
||||
<p class="step__text">There's no page left of your first one. Swipe there anyway and your camera opens. Or any app, or any shortcut you pick. Works past your last page too.</p>
|
||||
<p class="step__gesture"><span>Gesture</span> Swipe past the edge</p>
|
||||
@@ -195,7 +229,7 @@
|
||||
</article>
|
||||
<article class="step" data-step>
|
||||
<div class="step__card">
|
||||
<p class="step__no">06 / 07</p>
|
||||
<p class="step__no">06 / 08</p>
|
||||
<h3 class="step__title">Widgets that fit in.</h3>
|
||||
<p class="step__text">A clock that lines up with your icons: the date, a one-line forecast, left, center or right, thin to bold. Next to it, a picture frame in your icon shape that turns to the next photo every ten seconds.</p>
|
||||
<p class="step__gesture"><span>Gesture</span> Hold, then edit</p>
|
||||
@@ -203,12 +237,20 @@
|
||||
</article>
|
||||
<article class="step" data-step>
|
||||
<div class="step__card">
|
||||
<p class="step__no">07 / 07</p>
|
||||
<p class="step__no">07 / 08</p>
|
||||
<h3 class="step__title">Out of sight.</h3>
|
||||
<p class="step__text">Hold any app in the list and tap Hide app. Gone from the list and from search. Changed your mind? Undo, or bring it back from Settings whenever you like.</p>
|
||||
<p class="step__gesture"><span>Gesture</span> Hold, then hide</p>
|
||||
</div>
|
||||
</article>
|
||||
<article class="step step--long" data-step id="anatomy">
|
||||
<div class="step__card">
|
||||
<p class="step__no">08 / 08</p>
|
||||
<h3 class="step__title">Every layer. <span class="grad">Pulled apart.</span></h3>
|
||||
<p class="step__text">Under the glass sit five layers: your wallpaper, the widgets, icons and folders, the app list with its wave alphabet, and the glass on top. No feed and no ads hiding in between.</p>
|
||||
<p class="step__gesture"><span>Gesture</span> Keep scrolling</p>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -223,18 +265,24 @@
|
||||
</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 +297,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>
|
||||
@@ -321,14 +374,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">[ 05 ] 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">[ 06 ] 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>
|
||||
|
||||
+819
-30
@@ -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,26 @@
|
||||
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 = '') {
|
||||
@@ -306,6 +337,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 +446,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];
|
||||
}
|
||||
|
||||
@@ -483,7 +528,17 @@
|
||||
*/
|
||||
function storyState(ph, P) {
|
||||
const s = blank();
|
||||
P = clamp(P, 0, 7);
|
||||
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;
|
||||
@@ -513,7 +568,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;
|
||||
@@ -594,22 +649,40 @@
|
||||
return s;
|
||||
}
|
||||
|
||||
function setupStory(ph) {
|
||||
/**
|
||||
* 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() {
|
||||
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 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) {
|
||||
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;
|
||||
@@ -632,10 +705,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 +744,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 +962,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 +1005,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 +1023,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 +1066,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 +1155,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 +1177,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 +1213,674 @@
|
||||
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);
|
||||
});
|
||||
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 */
|
||||
@@ -1171,23 +1936,43 @@
|
||||
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);
|
||||
|
||||
const story = phones.story ? setupStory(phones.story) : null;
|
||||
const play = phones.play ? setupPlay(phones.play) : null;
|
||||
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()), 3800);
|
||||
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());
|
||||
if (story) story();
|
||||
if (play) play.render();
|
||||
rerender();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1225,6 +2010,7 @@
|
||||
}
|
||||
}
|
||||
if (story) story();
|
||||
pageWave();
|
||||
}
|
||||
|
||||
const marquee = setupMarquees();
|
||||
@@ -1249,7 +2035,10 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
requestAnimationFrame(loop);
|
||||
}
|
||||
|
||||
+191
-15
@@ -289,7 +289,12 @@ section { position: relative; }
|
||||
color: rgba(245, 244, 255, .045); /* filled, not outlined, for the same reason as .hero__ghost */
|
||||
pointer-events: none; user-select: none;
|
||||
}
|
||||
.story .phone-host { height: min(84vh, 880px); height: min(84svh, 880px); position: relative; z-index: 1; }
|
||||
.story__phones { position: relative; z-index: 1; height: min(84vh, 880px); height: min(84svh, 880px); aspect-ratio: 414 / 868; }
|
||||
.story__phones .phone-host, .story__phones .xhost { position: absolute; inset: 0; height: auto; }
|
||||
.story__phones .xhost { visibility: hidden; }
|
||||
/* Chapter 8 is two screens long; its text stays put while the phone comes apart. */
|
||||
.step.step--long { min-height: 300vh; min-height: 300svh; align-items: flex-start; }
|
||||
.step.step--long .step__card { position: sticky; top: max(96px, calc(50vh - 170px)); }
|
||||
.story__rail { position: absolute; right: 0; top: 50%; translate: 0 -50%; display: flex; flex-direction: column; gap: 10px; margin: 0; padding: 0; list-style: none; }
|
||||
.story__rail li { width: 12px; height: 34px; border: var(--b) solid var(--fg); transition: background .3s, height .3s; }
|
||||
.story__rail li.done { background: var(--fg); }
|
||||
@@ -332,8 +337,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 +358,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 +579,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 +588,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 +600,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 +659,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. */
|
||||
.is-comfy .row { height: 66px; }
|
||||
.is-comfy .row--note { height: 78px; }
|
||||
.is-comfy .row .ic { width: 48px; height: 48px; }
|
||||
.is-comfy .row__n { font-size: 19.5px; }
|
||||
.is-lefty .list { padding: 0 20px 0 58px; }
|
||||
.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 +725,130 @@ 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%); }
|
||||
|
||||
/* ---------- 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); }
|
||||
|
||||
/* ---------- chapter 8: the phone pulled apart ---------- */
|
||||
.xhost { 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::before, .xl--body::after { content: ""; position: absolute; right: -4px; width: 4px; border-radius: 0 3px 3px 0; background: #2b2940; }
|
||||
.xl--body::before { top: 190px; height: 70px; }
|
||||
.xl--body::after { top: 290px; height: 110px; }
|
||||
.xl--body { overflow: visible; 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, inset 0 0 0 5px #0a0914, 0 50px 90px -30px rgba(101, 61, 240, .7), 0 0 0 2px #000; }
|
||||
.xl--list { background: rgba(5, 4, 20, calc(.35 + var(--e) * .3)); }
|
||||
.xl--list .list { top: 46px; }
|
||||
.xl--list .alpha { bottom: 112px; }
|
||||
.xl--glass::before { content: ""; position: absolute; inset: 0; opacity: var(--e); 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: 14px; bottom: 14px; width: 1px; height: 1px; }
|
||||
.anatomy__lines { position: absolute; inset: 0; width: 100%; height: 100%; overflow: visible; pointer-events: none; z-index: 2; opacity: 0; }
|
||||
.anatomy__lines path { fill: none; stroke: var(--cyan); stroke-width: 2; }
|
||||
.anatomy__lines circle { fill: var(--bg); stroke: var(--cyan); stroke-width: 2.5; }
|
||||
.anatomy__labels { position: absolute; inset: 0; list-style: none; margin: 0; padding: 0; pointer-events: none; } /* out of the flow, or the stage's grid would split its height and the phone sit high */
|
||||
.anatomy__labels li {
|
||||
position: absolute; left: 0; top: 0; z-index: 3; width: 214px; translate: 0 -50%;
|
||||
padding: 9px 12px 10px; background: rgba(7, 7, 13, .94);
|
||||
border: var(--b) solid var(--fg); box-shadow: 4px 4px 0 var(--violet);
|
||||
opacity: 0; pointer-events: none; will-change: transform;
|
||||
}
|
||||
.anatomy__labels li { transition: border-color .25s, box-shadow .25s; }
|
||||
.anatomy__labels li.is-on { border-color: var(--cyan); box-shadow: 4px 4px 0 var(--cyan); }
|
||||
.anatomy__labels li.is-on b { color: var(--cyan); }
|
||||
.anatomy__labels i { margin-right: 8px; font: 700 11px/1 var(--mono); font-style: normal; color: var(--cyan); }
|
||||
.anatomy__labels b { font: 900 13px/1.15 var(--display); text-transform: uppercase; letter-spacing: -.01em; }
|
||||
.anatomy__labels span { display: block; margin-top: 6px; font: 500 12px/1.3 var(--mono); color: var(--dim); white-space: nowrap; }
|
||||
|
||||
/* ---------- 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; }
|
||||
@@ -711,8 +868,10 @@ section { position: relative; }
|
||||
.spinner { width: 100px; height: 100px; left: 2%; }
|
||||
|
||||
.story__body { display: block; padding: 0; }
|
||||
.story__stage { position: sticky; top: 0; place-items: start center; padding-top: 70px; }
|
||||
.story .phone-host { height: 52vh; height: 52svh; }
|
||||
.story__stage { position: sticky; top: 0; place-items: start center; padding-top: 70px; overflow: hidden; } /* nothing the phones do may widen the page */
|
||||
.story__phones { height: 52vh; height: 52svh; }
|
||||
.step.step--long { align-items: flex-end; }
|
||||
.step.step--long .step__card { position: sticky; top: auto; bottom: 4svh; }
|
||||
.story__num { top: 32%; font-size: 58vw; }
|
||||
.story__rail { right: 14px; top: 32%; gap: 6px; }
|
||||
.story__rail li { width: 8px; height: 20px; }
|
||||
@@ -739,6 +898,23 @@ 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__labels, .anatomy__lines { display: none; } /* the chapter's text names the layers */
|
||||
|
||||
.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) {
|
||||
|
||||
@@ -97,6 +97,9 @@
|
||||
<!-- Shown after an app was hidden from the app list. %1$s is the app's name -->
|
||||
<string name="flexera_app_hidden">%1$s is hidden</string>
|
||||
<string name="flexera_folder_blur">Blur behind open folders</string>
|
||||
<!-- Home screen settings, Widgets: every widget's edges meet the edges of the icons -->
|
||||
<string name="flexera_align_widgets">Line up with icons</string>
|
||||
<string name="flexera_align_widgets_description">The edges of every widget meet the edges of the icons above, below and beside it</string>
|
||||
<string name="flexera_folder_blur_description">Blurs the home screen while a folder is open</string>
|
||||
<string name="flexera_quick_launch">Quick launch</string>
|
||||
<string name="flexera_quick_launch_description">Swipe on from the first or the last home screen page, as if there were one more, to open an app or do something else. Has no effect while Infinite scrolling is on.</string>
|
||||
|
||||
@@ -2,8 +2,11 @@ package app.flexera.home
|
||||
|
||||
import android.app.ActivityOptions
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.LauncherApps
|
||||
import android.graphics.Canvas
|
||||
import android.os.Build
|
||||
import android.os.Process
|
||||
import android.util.Log
|
||||
import android.view.HapticFeedbackConstants
|
||||
import android.view.MotionEvent
|
||||
@@ -15,6 +18,7 @@ import app.lawnchair.preferences2.PreferenceManager2
|
||||
import app.lawnchair.preferences2.firstCached
|
||||
import com.android.launcher3.Launcher
|
||||
import com.android.launcher3.LauncherState
|
||||
import com.android.launcher3.model.data.AppInfo
|
||||
import com.android.launcher3.util.EdgeEffectCompat
|
||||
|
||||
/**
|
||||
@@ -24,7 +28,9 @@ import com.android.launcher3.util.EdgeEffectCompat
|
||||
*
|
||||
* The home screen already answers such a swipe by stretching and springing back. That stretch is
|
||||
* an edge effect, one per side, and quick launch is an edge effect that also keeps count of how
|
||||
* far the finger has pulled: far enough, or a flick, and lifting the finger does the action.
|
||||
* far the finger has pulled. When the choice is an app, there is no stretch: the pull brings in
|
||||
* a page of the app's own ([QuickLaunchPage]), and the app opens on it. For the other actions
|
||||
* the stretch stays, and far enough, or a flick, does the action when the finger lifts.
|
||||
* Launcher3 uses the same place for the Google feed on other phones (OverlayEdgeEffect).
|
||||
*/
|
||||
object QuickLaunch {
|
||||
@@ -56,11 +62,26 @@ object QuickLaunch {
|
||||
}
|
||||
}
|
||||
|
||||
/** The app [choice] opens, if it opens one. */
|
||||
private fun appOf(choice: GestureHandlerConfig): OpenAppTarget.App? =
|
||||
(choice as? GestureHandlerConfig.OpenApp)?.target as? OpenAppTarget.App
|
||||
|
||||
/** Opens [target]; false if it cannot be opened. */
|
||||
private fun startApp(launcher: Launcher, target: OpenAppTarget.App, options: ActivityOptions): Boolean = try {
|
||||
val apps = launcher.getSystemService(LauncherApps::class.java)
|
||||
apps?.startMainActivity(target.key.componentName, target.key.user, null, options.toBundle())
|
||||
apps != null
|
||||
} catch (e: RuntimeException) {
|
||||
// The chosen app is gone or cannot be started; nothing to open.
|
||||
Log.w(TAG, "The quick launch app could not be opened", e)
|
||||
false
|
||||
}
|
||||
|
||||
private fun launch(launcher: Launcher, firstPage: Boolean, fromLeft: Boolean) {
|
||||
val choice = choice(launcher, firstPage)
|
||||
if (choice is GestureHandlerConfig.NoOp) return
|
||||
val target = (choice as? GestureHandlerConfig.OpenApp)?.target
|
||||
if (target is OpenAppTarget.App) {
|
||||
val target = appOf(choice)
|
||||
if (target != null) {
|
||||
// The app opens out of the screen edge the finger pulled from, as near to a page
|
||||
// sliding in as Android lets an ordinary app come: it plays a sideways slide of
|
||||
// the launcher's own making only for the phone's built-in launcher.
|
||||
@@ -72,18 +93,44 @@ object QuickLaunch {
|
||||
1,
|
||||
root.height,
|
||||
)
|
||||
try {
|
||||
launcher.getSystemService(LauncherApps::class.java)
|
||||
?.startMainActivity(target.key.componentName, target.key.user, null, options.toBundle())
|
||||
} catch (e: RuntimeException) {
|
||||
// The chosen app is gone or cannot be started; nothing to open.
|
||||
Log.w(TAG, "The quick launch app could not be opened", e)
|
||||
}
|
||||
startApp(launcher, target, options)
|
||||
return
|
||||
}
|
||||
(launcher as? LawnchairLauncher)?.gestureController?.handle(choice)
|
||||
}
|
||||
|
||||
/** The page is going all the way in: the app is started, or the home screen comes back. */
|
||||
private fun launchOnPage(launcher: Launcher, firstPage: Boolean, page: QuickLaunchPage) {
|
||||
val target = appOf(choice(launcher, firstPage))
|
||||
if (target == null || !startOnPage(launcher, target)) page.close()
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens [target] without Android's opening animation: the app appears on the page the moment
|
||||
* it has something to show. That is its last screen if it was still in memory, else its
|
||||
* splash screen. (Android plays no other animation that a launcher of one's own may choose:
|
||||
* asked for a plain fade, it still zooms the app in from the middle.)
|
||||
*/
|
||||
private fun startOnPage(launcher: Launcher, target: OpenAppTarget.App): Boolean {
|
||||
val key = target.key
|
||||
if (key.user != Process.myUserHandle()) {
|
||||
// An app of another profile, such as a work profile, can only be started through
|
||||
// LauncherApps, which takes no such flag; it opens with Android's usual animation.
|
||||
return startApp(launcher, target, ActivityOptions.makeBasic())
|
||||
}
|
||||
// The same intent as the app's icon, so that it is the same task that comes up.
|
||||
val intent = AppInfo.makeLaunchIntent(key.componentName)
|
||||
.addFlags(Intent.FLAG_ACTIVITY_NO_ANIMATION)
|
||||
return try {
|
||||
launcher.startActivity(intent)
|
||||
true
|
||||
} catch (e: RuntimeException) {
|
||||
// The chosen app is gone or cannot be started; nothing to open.
|
||||
Log.w(TAG, "The quick launch app could not be opened", e)
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
private class Effect(
|
||||
private val workspace: View,
|
||||
private val launcher: Launcher,
|
||||
@@ -99,33 +146,83 @@ object QuickLaunch {
|
||||
private var armed = false
|
||||
private var flicked = false
|
||||
|
||||
/** The page this pull brings in, when the choice is an app; then there is no stretch. */
|
||||
private var paging: QuickLaunchPage? = null
|
||||
|
||||
/** How fast the finger was moving towards the missing page when it lifted, in px/s. */
|
||||
private var outwardVelocity = 0f
|
||||
|
||||
private val flickVelocity = FLICK_DP_PER_SECOND * workspace.resources.displayMetrics.density
|
||||
|
||||
// While paging, the pull is the page's, not a stretch's: the pages ask these whether
|
||||
// there is a pull to give back before they scroll the other way.
|
||||
override fun getDistance(): Float = if (paging != null) pulled else super.getDistance()
|
||||
|
||||
override fun isFinished(): Boolean = if (paging != null) pulled <= 0f else super.isFinished()
|
||||
|
||||
override fun draw(canvas: Canvas): Boolean = if (paging != null) false else super.draw(canvas)
|
||||
|
||||
override fun onPullDistance(deltaDistance: Float, displacement: Float, ev: MotionEvent): Float {
|
||||
val consumed = super.onPullDistance(deltaDistance, displacement, ev)
|
||||
if (pulled == 0f && deltaDistance > 0f) {
|
||||
active = choice(workspace.context, firstPage) !is GestureHandlerConfig.NoOp &&
|
||||
val choice = choice(workspace.context, firstPage)
|
||||
active = choice !is GestureHandlerConfig.NoOp &&
|
||||
launcher.isInState(LauncherState.NORMAL) &&
|
||||
!launcher.dragController.isDragging
|
||||
paging = if (active && appOf(choice) != null) {
|
||||
QuickLaunchPage.of(launcher)?.takeUnless { it.isOpening }
|
||||
} else {
|
||||
null
|
||||
}
|
||||
// A page still on its way back is caught where it is.
|
||||
pulled = paging?.begin(fromLeft = atLeftEdge) ?: 0f
|
||||
}
|
||||
|
||||
val page = paging
|
||||
if (page != null) {
|
||||
val before = pulled
|
||||
pulled = (pulled + deltaDistance).coerceIn(0f, 1f)
|
||||
page.show(pulled)
|
||||
arm(pulled >= QuickLaunchPage.OPEN_DISTANCE)
|
||||
return pulled - before
|
||||
}
|
||||
|
||||
val consumed = super.onPullDistance(deltaDistance, displacement, ev)
|
||||
pulled = (pulled + deltaDistance).coerceAtLeast(0f)
|
||||
val nowArmed = active && pulled >= ARM_DISTANCE
|
||||
if (nowArmed != armed) {
|
||||
armed = nowArmed
|
||||
// A tick when the pull is far enough, and again when it is taken back.
|
||||
tick()
|
||||
}
|
||||
arm(active && pulled >= ARM_DISTANCE)
|
||||
return consumed
|
||||
}
|
||||
|
||||
override fun onFlingVelocity(velocity: Int) {
|
||||
// A pull at the left side is a finger moving right, which is a positive speed.
|
||||
val outward = if (atLeftEdge) velocity else -velocity
|
||||
flicked = active && pulled >= FLICK_DISTANCE && outward >= flickVelocity
|
||||
outwardVelocity = (if (atLeftEdge) velocity else -velocity).toFloat()
|
||||
flicked = active && pulled >= FLICK_DISTANCE && outwardVelocity >= flickVelocity
|
||||
}
|
||||
|
||||
override fun onAbsorb(velocity: Int) {
|
||||
if (paging == null) super.onAbsorb(velocity)
|
||||
}
|
||||
|
||||
override fun onRelease(ev: MotionEvent) {
|
||||
val lifted = ev.actionMasked == MotionEvent.ACTION_UP
|
||||
val page = paging
|
||||
if (page != null) {
|
||||
// As with the pages themselves: a flick decides, else how far the page is in.
|
||||
val open = lifted && when {
|
||||
flicked -> true
|
||||
outwardVelocity <= -flickVelocity -> false
|
||||
else -> armed
|
||||
}
|
||||
val tickNow = open && !armed
|
||||
val speed = if (open) outwardVelocity else -outwardVelocity
|
||||
reset()
|
||||
if (tickNow) tick()
|
||||
// The app is started right after the pages have finished with this touch.
|
||||
page.settle(open, speed) {
|
||||
workspace.post { launchOnPage(launcher, firstPage, page) }
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
val fire = lifted && active && (armed || flicked)
|
||||
val tickNow = fire && !armed
|
||||
reset()
|
||||
@@ -138,13 +235,23 @@ object QuickLaunch {
|
||||
}
|
||||
|
||||
override fun onRelease() {
|
||||
val page = paging
|
||||
reset()
|
||||
super.onRelease()
|
||||
if (page != null) page.close() else super.onRelease()
|
||||
}
|
||||
|
||||
override fun finish() {
|
||||
val page = paging
|
||||
reset()
|
||||
super.finish()
|
||||
if (page != null) page.close() else super.finish()
|
||||
}
|
||||
|
||||
private fun arm(nowArmed: Boolean) {
|
||||
if (nowArmed != armed) {
|
||||
armed = nowArmed
|
||||
// A tick when the pull is far enough, and again when it is taken back.
|
||||
tick()
|
||||
}
|
||||
}
|
||||
|
||||
private fun reset() {
|
||||
@@ -152,6 +259,8 @@ object QuickLaunch {
|
||||
active = false
|
||||
armed = false
|
||||
flicked = false
|
||||
paging = null
|
||||
outwardVelocity = 0f
|
||||
}
|
||||
|
||||
private fun tick() {
|
||||
|
||||
@@ -0,0 +1,262 @@
|
||||
package app.flexera.home
|
||||
|
||||
import android.animation.Animator
|
||||
import android.animation.AnimatorListenerAdapter
|
||||
import android.animation.ValueAnimator
|
||||
import android.content.Context
|
||||
import android.graphics.Canvas
|
||||
import android.graphics.Color
|
||||
import android.graphics.Matrix
|
||||
import android.graphics.Paint
|
||||
import android.view.MotionEvent
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.view.animation.DecelerateInterpolator
|
||||
import com.android.launcher3.BaseActivity
|
||||
import com.android.launcher3.InsettableFrameLayout
|
||||
import com.android.launcher3.Launcher
|
||||
import com.android.launcher3.util.SystemUiController
|
||||
import java.util.WeakHashMap
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.max
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
/**
|
||||
* Quick launch's page: when a swipe past the first or the last home screen page opens an app,
|
||||
* the home screen does not stretch. It moves aside with the finger, as it does for a page that
|
||||
* is there, and an empty black page comes in behind it. The home screen blurs and darkens the
|
||||
* further it goes. Let go far enough, or flick, and the app is started there and then, while the
|
||||
* page settles over the whole screen; it appears on the page as soon as it has drawn, with none
|
||||
* of Android's opening animation. Let go early, and the home screen comes back.
|
||||
*
|
||||
* The home screen is moved by its drawing only ([View.setAnimationMatrix] on the drag layer),
|
||||
* not by its position: the touch that moves it is still being read by the pages inside it, and
|
||||
* moving their position would move the finger's position in them as well.
|
||||
*
|
||||
* [QuickLaunch]'s edge effect drives this with the finger, from 0 (home screen) to 1 (page).
|
||||
*/
|
||||
class QuickLaunchPage private constructor(private val launcher: Launcher) {
|
||||
|
||||
companion object {
|
||||
|
||||
/** How far the page has to come in for letting go to open it, as a share of the width. */
|
||||
const val OPEN_DISTANCE = 0.4f
|
||||
|
||||
/** How dark the home screen gets beside a page that is all the way in. */
|
||||
private const val HOME_DIM = 0.35f
|
||||
|
||||
/** The page settles at least this fast, in dp per second, and within these times. */
|
||||
private const val MIN_SETTLE_DP_PER_SECOND = 1500f
|
||||
private const val MIN_SETTLE_MS = 150L
|
||||
private const val MAX_SETTLE_MS = 300L
|
||||
|
||||
/**
|
||||
* Once the page is across, the launcher is paused as soon as the app starts. Still in
|
||||
* the foreground after this long, it never will be: nothing came, and the home screen
|
||||
* comes back.
|
||||
*/
|
||||
private const val LAUNCH_TIMEOUT_MS = 1500L
|
||||
|
||||
private val pages = WeakHashMap<Launcher, QuickLaunchPage>()
|
||||
|
||||
/** [launcher]'s page, made when first wanted; null while the launcher has no views. */
|
||||
fun of(launcher: Launcher): QuickLaunchPage? {
|
||||
pages[launcher]?.let { return it }
|
||||
if (launcher.rootView == null || launcher.dragLayer == null) return null
|
||||
return QuickLaunchPage(launcher).also { pages[launcher] = it }
|
||||
}
|
||||
|
||||
/** Whether [launcher]'s page is on the screen, all or part of it. */
|
||||
@JvmStatic
|
||||
fun isShowing(launcher: Launcher): Boolean = (pages[launcher]?.progress ?: 0f) > 0f
|
||||
|
||||
/** The blur for how far the page is in: quick at first, so the home screen soon recedes. */
|
||||
private fun blurFor(progress: Float): Float {
|
||||
val left = 1f - progress
|
||||
return 1f - left * left
|
||||
}
|
||||
}
|
||||
|
||||
/** How far the page is in, from 0 (not at all) to 1 (it fills the screen). */
|
||||
var progress = 0f
|
||||
private set
|
||||
|
||||
/** Whether the page comes in from the left edge of the screen, rather than the right. */
|
||||
private var fromLeft = false
|
||||
|
||||
/** The page is on its way all the way in, or there, with the app to follow. */
|
||||
private var opening = false
|
||||
|
||||
/** Changes whenever what an earlier opening waits for no longer counts. */
|
||||
private var generation = 0
|
||||
|
||||
private var animator: ValueAnimator? = null
|
||||
private val shift = Matrix()
|
||||
private val view = PageView(launcher)
|
||||
private val minSettleSpeed = MIN_SETTLE_DP_PER_SECOND * launcher.resources.displayMetrics.density
|
||||
|
||||
init {
|
||||
view.visibility = View.INVISIBLE
|
||||
// Over the whole window, the status and navigation bars included.
|
||||
val params = InsettableFrameLayout.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
).apply { ignoreInsets = true }
|
||||
launcher.rootView.addView(view, params)
|
||||
}
|
||||
|
||||
/** Whether the page is busy opening an app; it then takes no new pull. */
|
||||
val isOpening: Boolean
|
||||
get() = opening
|
||||
|
||||
/**
|
||||
* A pull begins at the left or the right edge. A page still on its way back from the same
|
||||
* edge is caught where it is; that is returned, as how far the page is in.
|
||||
*/
|
||||
fun begin(fromLeft: Boolean): Float {
|
||||
cancelAnimation()
|
||||
if (this.fromLeft != fromLeft) {
|
||||
show(0f)
|
||||
this.fromLeft = fromLeft
|
||||
}
|
||||
return progress
|
||||
}
|
||||
|
||||
/** Puts the page [progress] of the way in, from 0 to 1, with the home screen beside it. */
|
||||
fun show(progress: Float) {
|
||||
this.progress = progress.coerceIn(0f, 1f)
|
||||
apply()
|
||||
}
|
||||
|
||||
/**
|
||||
* The finger has let go: the page goes all the way in ([open]) or back out, at least as fast
|
||||
* as the finger was moving that way ([speed], pixels per second). Going in, [launch] runs at
|
||||
* once, so that the app is on its way while the page settles; it starts the app, or calls
|
||||
* [close] if there is none to start.
|
||||
*/
|
||||
fun settle(open: Boolean, speed: Float, launch: () -> Unit) {
|
||||
cancelAnimation()
|
||||
opening = open
|
||||
val target = if (open) 1f else 0f
|
||||
if (progress == target) {
|
||||
apply()
|
||||
} else {
|
||||
val remaining = abs(target - progress) * view.rootWidth()
|
||||
// A decelerating move starts at twice its average speed.
|
||||
val duration = (2000f * remaining / max(speed, minSettleSpeed)).toLong()
|
||||
val anim = ValueAnimator.ofFloat(progress, target)
|
||||
anim.duration = duration.coerceIn(MIN_SETTLE_MS, MAX_SETTLE_MS)
|
||||
anim.interpolator = DecelerateInterpolator()
|
||||
anim.addUpdateListener { show(it.animatedValue as Float) }
|
||||
anim.addListener(object : AnimatorListenerAdapter() {
|
||||
override fun onAnimationEnd(animation: Animator) {
|
||||
if (animator === anim) animator = null
|
||||
}
|
||||
})
|
||||
animator = anim
|
||||
anim.start()
|
||||
}
|
||||
if (open) opened(launch)
|
||||
}
|
||||
|
||||
/** The home screen comes back, as after letting go too early. */
|
||||
fun close() {
|
||||
generation++
|
||||
settle(open = false, speed = 0f) {}
|
||||
}
|
||||
|
||||
/** The home screen is back at once, with nothing to see on the way. */
|
||||
fun reset() {
|
||||
generation++
|
||||
cancelAnimation()
|
||||
opening = false
|
||||
show(0f)
|
||||
}
|
||||
|
||||
private fun opened(launch: () -> Unit) {
|
||||
val token = ++generation
|
||||
// Once the app is in front, the launcher stops; the next time it is seen, it is as it was.
|
||||
launcher.addEventCallback(BaseActivity.EVENT_STOPPED) {
|
||||
if (token == generation) reset()
|
||||
}
|
||||
// Starting an app pauses the launcher, resumes it for a moment and pauses it again, so
|
||||
// being resumed only means something once the start has had its time.
|
||||
view.postDelayed({
|
||||
if (token != generation) return@postDelayed
|
||||
if (launcher.hasBeenResumed()) {
|
||||
// Still in front: nothing came.
|
||||
close()
|
||||
} else {
|
||||
// Something is in front without hiding the launcher, or is still starting.
|
||||
// Should the launcher come back without having stopped, the home screen does too.
|
||||
launcher.addEventCallback(BaseActivity.EVENT_RESUMED) {
|
||||
if (token == generation) close()
|
||||
}
|
||||
}
|
||||
}, LAUNCH_TIMEOUT_MS)
|
||||
launch()
|
||||
}
|
||||
|
||||
private fun cancelAnimation() {
|
||||
animator?.let {
|
||||
animator = null
|
||||
it.cancel()
|
||||
}
|
||||
}
|
||||
|
||||
private fun apply() {
|
||||
val p = progress
|
||||
view.visibility = if (p > 0f) View.VISIBLE else View.INVISIBLE
|
||||
view.invalidate()
|
||||
|
||||
val dragLayer = launcher.dragLayer
|
||||
if (p > 0f) {
|
||||
shift.setTranslate((if (fromLeft) 1f else -1f) * p * view.rootWidth(), 0f)
|
||||
dragLayer.setAnimationMatrix(shift)
|
||||
} else {
|
||||
dragLayer.setAnimationMatrix(null)
|
||||
}
|
||||
|
||||
launcher.depthController?.quickLaunchBlur?.value = blurFor(p)
|
||||
|
||||
// Light status bar icons once the page is most of what is seen.
|
||||
launcher.systemUiController?.updateUiState(
|
||||
SystemUiController.UI_STATE_QUICK_LAUNCH_PAGE,
|
||||
if (p >= 0.5f) SystemUiController.FLAG_DARK_STATUS or SystemUiController.FLAG_DARK_NAV else 0,
|
||||
)
|
||||
}
|
||||
|
||||
/** The width the page and the home screen move across; known before this view's first layout. */
|
||||
private fun View.rootWidth(): Int = if (width > 0) width else launcher.dragLayer.width
|
||||
|
||||
private inner class PageView(context: Context) : View(context) {
|
||||
|
||||
private val pagePaint = Paint().apply { color = Color.BLACK }
|
||||
private val dimPaint = Paint().apply { color = Color.BLACK }
|
||||
|
||||
init {
|
||||
importantForAccessibility = IMPORTANT_FOR_ACCESSIBILITY_NO_HIDE_DESCENDANTS
|
||||
}
|
||||
|
||||
/**
|
||||
* While the page opens and the app is on its way, a touch would land on the home screen
|
||||
* behind it, where nothing is where it is drawn; the page keeps it. On its way back it
|
||||
* lets touches through, so that a new swipe can catch it.
|
||||
*/
|
||||
override fun onTouchEvent(event: MotionEvent): Boolean = opening
|
||||
|
||||
override fun onDraw(canvas: Canvas) {
|
||||
val w = width.toFloat()
|
||||
val h = height.toFloat()
|
||||
val edge = progress * w
|
||||
dimPaint.alpha = (HOME_DIM * progress * 255).roundToInt()
|
||||
if (fromLeft) {
|
||||
canvas.drawRect(0f, 0f, edge, h, pagePaint)
|
||||
canvas.drawRect(edge, 0f, w, h, dimPaint)
|
||||
} else {
|
||||
canvas.drawRect(w - edge, 0f, w, h, pagePaint)
|
||||
canvas.drawRect(0f, 0f, w - edge, h, dimPaint)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,9 +5,13 @@ import android.content.ComponentName
|
||||
import android.graphics.Rect
|
||||
import android.view.View
|
||||
import android.widget.FrameLayout
|
||||
import com.android.launcher3.DeviceProfile
|
||||
import com.android.launcher3.icons.IconNormalizer.ICON_VISIBLE_AREA_FACTOR
|
||||
import com.android.launcher3.model.data.LauncherAppWidgetInfo
|
||||
import com.android.launcher3.widget.LauncherAppWidgetHostView
|
||||
import com.android.launcher3.widget.PendingAppWidgetHostView
|
||||
import kotlin.math.max
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
/**
|
||||
* The widgets that come with the launcher: Pictures ([PictureWidgetProvider]) and Clock
|
||||
@@ -16,7 +20,8 @@ import com.android.launcher3.widget.PendingAppWidgetHostView
|
||||
* Android knows each as an ordinary app widget, so they are added, moved, resized and removed
|
||||
* like any other. What they show is not sent through Android, though: the launcher draws them
|
||||
* itself with a view of its own, which is what lets them line up with the icons ([IconFrame]).
|
||||
* This is where Launcher3's widget code asks about them.
|
||||
* This is where Launcher3's widget code asks about them, and about the room every other widget
|
||||
* keeps in its cells, which by default lines those up with the icons as well.
|
||||
*/
|
||||
object FlexeraWidgets {
|
||||
|
||||
@@ -26,14 +31,54 @@ object FlexeraWidgets {
|
||||
provider == PictureWidgetProvider.componentName || provider == ClockWidgetProvider.componentName
|
||||
|
||||
/**
|
||||
* The room a home screen cell keeps free around a widget. Launcher3 gives every widget the
|
||||
* same ([standard]); the launcher's own take none, because they place what they draw
|
||||
* against the icons of their cells by themselves.
|
||||
* The room a widget's cells keep free around it, on each side; one cell is [cellWidth] by
|
||||
* [cellHeight]. The launcher's own widgets take none, because they place what they draw
|
||||
* against the icons of their cells by themselves ([IconFrame]); every other widget gets
|
||||
* [appWidgetPadding].
|
||||
*/
|
||||
@JvmStatic
|
||||
fun cellInset(child: View, standard: Rect): Rect {
|
||||
val info = child.tag as? LauncherAppWidgetInfo ?: return standard
|
||||
return if (isOwn(info.providerName)) noInset else standard
|
||||
fun cellInset(child: View, profile: DeviceProfile, cellWidth: Int, cellHeight: Int): Rect {
|
||||
val info = child.tag as? LauncherAppWidgetInfo
|
||||
if (info != null && isOwn(info.providerName)) return noInset
|
||||
return appWidgetPadding(profile, cellWidth, cellHeight)
|
||||
}
|
||||
|
||||
/** [appWidgetPadding] for the cells of [profile]'s home screen. */
|
||||
@JvmStatic
|
||||
fun appWidgetPadding(profile: DeviceProfile): Rect {
|
||||
val cell = profile.cellSize
|
||||
return appWidgetPadding(profile, cell.x, cell.y)
|
||||
}
|
||||
|
||||
/**
|
||||
* The room a widget of another app keeps free inside its cells, on each side, where one
|
||||
* cell is [cellWidth] by [cellHeight]. Lined up with the icons (the default), it is the
|
||||
* distance from the cells' edges to the edges of the icons that would sit in them: the
|
||||
* same box the launcher's own widgets draw in ([IconFrame]), so that every widget's edges
|
||||
* meet the edges of the icons above, below and beside it. Otherwise it is Launcher3's own,
|
||||
* which the Widget padding setting scales.
|
||||
*/
|
||||
@JvmStatic
|
||||
fun appWidgetPadding(profile: DeviceProfile, cellWidth: Int, cellHeight: Int): Rect {
|
||||
if (!profile.alignWidgetsWithIcons || cellWidth <= 0 || cellHeight <= 0) {
|
||||
return profile.widgetPadding
|
||||
}
|
||||
// As in IconFrame: the icon is centred sideways in its cell and sits a set distance
|
||||
// below the cell's top; what is seen of it leaves a little air inside its square.
|
||||
val icon = profile.iconSizePx.toFloat()
|
||||
val air = icon * (1f - ICON_VISIBLE_AREA_FACTOR) / 2f
|
||||
val iconTop = if (profile.cellYPaddingPx >= 0) {
|
||||
profile.cellYPaddingPx.toFloat()
|
||||
} else {
|
||||
max(0f, (cellHeight - profile.cellHeightPx) / 2f)
|
||||
}
|
||||
val side = max(0, ((cellWidth - icon) / 2f + air).roundToInt())
|
||||
return Rect(
|
||||
side,
|
||||
max(0, (iconTop + air).roundToInt()),
|
||||
side,
|
||||
max(0, (cellHeight - iconTop - icon + air).roundToInt()),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -41,7 +41,8 @@ internal class IconFrame private constructor(
|
||||
|
||||
// The cells the widget covers, measured from the corner of the host view. Undoes
|
||||
// what CellLayoutLayoutParams.setup took off them.
|
||||
val inset = FlexeraWidgets.cellInset(host, profile.widgetPadding)
|
||||
val cell = profile.cellSize
|
||||
val inset = FlexeraWidgets.cellInset(host, profile, cell.x, cell.y)
|
||||
val spanLeft = -(lp.leftMargin + inset.left).toFloat()
|
||||
val spanTop = -(lp.topMargin + inset.top).toFloat()
|
||||
val spanWidth = (lp.width + lp.leftMargin + lp.rightMargin + inset.left + inset.right).toFloat()
|
||||
|
||||
@@ -2,4 +2,4 @@
|
||||
# build.gradle reads this and derives the Android version code from it
|
||||
# (major * 10000 + minor * 100 + patch), so the name and the code can never disagree.
|
||||
# flexera/release.ps1 raises it for every release.
|
||||
versionName=0.14.0
|
||||
versionName=0.15.0
|
||||
@@ -886,6 +886,15 @@ class PreferenceManager2 @Inject constructor(
|
||||
defaultValue = GestureHandlerConfig.NoOp,
|
||||
)
|
||||
|
||||
// Flexera Launcher: whether the edges of every widget meet the edges of the icons in the
|
||||
// rows and columns it covers, as the launcher's own widgets do
|
||||
// (app.flexera.widget.FlexeraWidgets.appWidgetPadding). Read with the grid.
|
||||
val alignWidgetsWithIcons = preference(
|
||||
key = booleanPreferencesKey(name = "flexera_align_widgets_with_icons"),
|
||||
defaultValue = true,
|
||||
onSet = { reloadHelper.reloadGrid() },
|
||||
)
|
||||
|
||||
// Flexera Launcher: whether the home screen behind an open folder is blurred
|
||||
// (app.flexera.home.FolderBlur). Read when a folder opens, so nothing has to be reloaded.
|
||||
val folderBlur = preference(
|
||||
|
||||
@@ -285,13 +285,23 @@ fun HomeScreenPreferences(
|
||||
label = stringResource(id = R.string.force_widget_resize_label),
|
||||
description = stringResource(id = R.string.force_widget_resize_description),
|
||||
)
|
||||
SliderPreference(
|
||||
label = stringResource(id = R.string.widget_padding_label),
|
||||
adapter = prefs2.widgetPaddingFactor.getAdapter(),
|
||||
step = 0.05f,
|
||||
valueRange = 0F..2F,
|
||||
showAsPercentage = true,
|
||||
// Flexera Launcher: widgets lined up with the icons; Lawnchair's padding is for
|
||||
// when they are not (app.flexera.widget.FlexeraWidgets.appWidgetPadding).
|
||||
val alignWidgetsAdapter = prefs2.alignWidgetsWithIcons.getAdapter()
|
||||
SwitchPreference(
|
||||
adapter = alignWidgetsAdapter,
|
||||
label = stringResource(id = R.string.flexera_align_widgets),
|
||||
description = stringResource(id = R.string.flexera_align_widgets_description),
|
||||
)
|
||||
ExpandAndShrink(visible = !alignWidgetsAdapter.state.value) {
|
||||
SliderPreference(
|
||||
label = stringResource(id = R.string.widget_padding_label),
|
||||
adapter = prefs2.widgetPaddingFactor.getAdapter(),
|
||||
step = 0.05f,
|
||||
valueRange = 0F..2F,
|
||||
showAsPercentage = true,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -227,6 +227,11 @@ public class DepthController extends BaseDepthControllerImpl implements StateHan
|
||||
mLauncher, AbstractFloatingView.TYPE_FOLDER) == null) {
|
||||
folderBlur.setValue(0f);
|
||||
}
|
||||
// Flexera Launcher: likewise quick launch's blur with its page gone.
|
||||
if (quickLaunchBlur.getValue() > 0f
|
||||
&& !app.flexera.home.QuickLaunchPage.isShowing(mLauncher)) {
|
||||
quickLaunchBlur.setValue(0f);
|
||||
}
|
||||
if (mLauncher.getStateManager().getState() == LauncherState.NORMAL
|
||||
&& !mLauncher.getStateManager().isInTransition()) {
|
||||
clearWorkspaceRenderEffects();
|
||||
|
||||
@@ -95,6 +95,12 @@ public class BaseDepthControllerImpl implements BaseDepthController {
|
||||
* depth it comes with no zoom, and it reaches the icons as well as the wallpaper.
|
||||
*/
|
||||
public final MultiProperty folderBlur;
|
||||
/**
|
||||
* Flexera Launcher: property to set the blur of the home screen while quick launch's page
|
||||
* slides in over it (app.flexera.home.QuickLaunchPage), from 0 to 1. It shares the folder
|
||||
* blur's machinery; the stronger of the two wins.
|
||||
*/
|
||||
public final MultiProperty quickLaunchBlur;
|
||||
|
||||
/**
|
||||
* Blur radius when completely zoomed out, in pixels.
|
||||
@@ -111,7 +117,8 @@ public class BaseDepthControllerImpl implements BaseDepthController {
|
||||
private float mDepth;
|
||||
// Used just for animating wallpaper zoom without affecting blur.
|
||||
private final AnimatedFloat mWallpaperZoomOnly = new AnimatedFloat(this::applyDepthAndBlur);
|
||||
// Flexera Launcher: the blur behind an open folder, see folderBlur.
|
||||
// Flexera Launcher: the blur behind an open folder, see folderBlur, or behind quick
|
||||
// launch's page, see quickLaunchBlur.
|
||||
private final AnimatedFloat mFolderBlur = new AnimatedFloat(this::applyDepthAndBlur);
|
||||
// Whether a folder blur is on the launcher's surface, and so has to come off again.
|
||||
private boolean mFolderBlurApplied;
|
||||
@@ -174,8 +181,10 @@ public class BaseDepthControllerImpl implements BaseDepthController {
|
||||
folderZoom = null;
|
||||
}
|
||||
// Flexera Launcher
|
||||
folderBlur = new MultiPropertyFactory<>(mFolderBlur, AnimatedFloat.VALUE, 1, Float::max)
|
||||
.get(0);
|
||||
MultiPropertyFactory<AnimatedFloat> backdropBlur =
|
||||
new MultiPropertyFactory<>(mFolderBlur, AnimatedFloat.VALUE, 2, Float::max);
|
||||
folderBlur = backdropBlur.get(0);
|
||||
quickLaunchBlur = backdropBlur.get(1);
|
||||
|
||||
// mEarlyWakeupInfo.token = new Binder();
|
||||
// mEarlyWakeupInfo.trace = BaseDepthControllerImpl.class.getName();
|
||||
@@ -201,6 +210,11 @@ public class BaseDepthControllerImpl implements BaseDepthController {
|
||||
return folderBlur;
|
||||
}
|
||||
|
||||
@Override
|
||||
public MultiProperty getQuickLaunchBlur() {
|
||||
return quickLaunchBlur;
|
||||
}
|
||||
|
||||
/** Flexera Launcher: the blur behind an open folder as it is now, from 0 to 1. */
|
||||
private float folderBlurAmount() {
|
||||
return Utilities.boundToRange(mFolderBlur.value, 0f, 1f);
|
||||
|
||||
@@ -349,6 +349,8 @@ public class DeviceProfile {
|
||||
private float workspacePaddingHorizontalFactor;
|
||||
private float workspacePaddingVerticalFactor;
|
||||
private float widgetPaddingFactor;
|
||||
// Flexera Launcher: widgets lined up with the icons (app.flexera.widget.FlexeraWidgets).
|
||||
public boolean alignWidgetsWithIcons;
|
||||
private float drawerPaddingTopFactor;
|
||||
private PreferenceManager2 preferenceManager2 = null;
|
||||
|
||||
@@ -373,6 +375,8 @@ public class DeviceProfile {
|
||||
.firstCached(preferenceManager2.getWorkspacePaddingVerticalFactor()), 0f, 2f);
|
||||
widgetPaddingFactor = Utilities.boundToRange(PreferenceCacheExtensionsKt
|
||||
.firstCached(preferenceManager2.getWidgetPaddingFactor()), 0f, 2f);
|
||||
alignWidgetsWithIcons = PreferenceCacheExtensionsKt
|
||||
.firstCached(preferenceManager2.getAlignWidgetsWithIcons());
|
||||
drawerPaddingTopFactor = Utilities.boundToRange(PreferenceCacheExtensionsKt
|
||||
.firstCached(preferenceManager2.getDrawerPaddingTopFactor()), 1f, 2f);
|
||||
this.inv = inv;
|
||||
|
||||
@@ -151,9 +151,10 @@ public class ShortcutAndWidgetContainer extends ViewGroup implements FolderIcon.
|
||||
final PointF appWidgetScale = profile.getAppWidgetScale((ItemInfo) child.getTag());
|
||||
lp.setup(mCellWidth, mCellHeight, invertLayoutHorizontally(), mCountX, mCountY,
|
||||
appWidgetScale.x, appWidgetScale.y, mBorderSpace,
|
||||
// Flexera Launcher: the launcher's own widgets take their cells whole.
|
||||
app.flexera.widget.FlexeraWidgets.cellInset(child,
|
||||
profile.widgetPadding));
|
||||
// Flexera Launcher: the launcher's own widgets take their cells whole;
|
||||
// the others are lined up with the icons.
|
||||
app.flexera.widget.FlexeraWidgets.cellInset(child, profile,
|
||||
mCellWidth, mCellHeight));
|
||||
} else {
|
||||
lp.setup(mCellWidth, mCellHeight, invertLayoutHorizontally(), mCountX, mCountY,
|
||||
mBorderSpace);
|
||||
@@ -179,8 +180,10 @@ public class ShortcutAndWidgetContainer extends ViewGroup implements FolderIcon.
|
||||
final PointF appWidgetScale = dp.getAppWidgetScale((ItemInfo) child.getTag());
|
||||
lp.setup(mCellWidth, mCellHeight, invertLayoutHorizontally(), mCountX, mCountY,
|
||||
appWidgetScale.x, appWidgetScale.y, mBorderSpace,
|
||||
// Flexera Launcher: the launcher's own widgets take their cells whole.
|
||||
app.flexera.widget.FlexeraWidgets.cellInset(child, dp.widgetPadding));
|
||||
// Flexera Launcher: the launcher's own widgets take their cells whole;
|
||||
// the others are lined up with the icons.
|
||||
app.flexera.widget.FlexeraWidgets.cellInset(child, dp,
|
||||
mCellWidth, mCellHeight));
|
||||
} else if (isChildQsb(child)) {
|
||||
lp.setup(mCellWidth, mCellHeight, invertLayoutHorizontally(), mCountX, mCountY,
|
||||
mBorderSpace);
|
||||
|
||||
@@ -3300,7 +3300,10 @@ public class Workspace<T extends View & PageIndicator> extends PagedView<T>
|
||||
if (info.itemType == LauncherSettings.Favorites.ITEM_TYPE_APPWIDGET) {
|
||||
DeviceProfile profile = mLauncher.getDeviceProfile();
|
||||
if (finalView instanceof NavigableAppWidgetHostView) {
|
||||
Rect widgetPadding = profile.widgetPadding;
|
||||
// Flexera Launcher: the room this widget keeps in its cells.
|
||||
Point cell = profile.getCellSize();
|
||||
Rect widgetPadding = app.flexera.widget.FlexeraWidgets.cellInset(
|
||||
finalView, profile, cell.x, cell.y);
|
||||
r.left -= widgetPadding.left;
|
||||
r.right += widgetPadding.right;
|
||||
r.top -= widgetPadding.top;
|
||||
|
||||
@@ -46,4 +46,10 @@ interface BaseDepthController {
|
||||
* (none) to 1 (as strong as it gets).
|
||||
*/
|
||||
val folderBlur: MultiPropertyFactory<*>.MultiProperty?
|
||||
|
||||
/**
|
||||
* Flexera Launcher: property for the blur of the home screen while quick launch's page
|
||||
* slides in over it, from 0 (none) to 1 (as strong as it gets).
|
||||
*/
|
||||
val quickLaunchBlur: MultiPropertyFactory<*>.MultiProperty?
|
||||
}
|
||||
@@ -35,6 +35,8 @@ public class SystemUiController {
|
||||
public static final int UI_STATE_WIDGET_BOTTOM_SHEET = 2;
|
||||
public static final int UI_STATE_FULLSCREEN_TASK = 3;
|
||||
public static final int UI_STATE_ALL_APPS = 4;
|
||||
// Flexera Launcher: quick launch's black page (app.flexera.home.QuickLaunchPage).
|
||||
public static final int UI_STATE_QUICK_LAUNCH_PAGE = 5;
|
||||
|
||||
public static final int FLAG_LIGHT_NAV = 1 << 0;
|
||||
public static final int FLAG_DARK_NAV = 1 << 1;
|
||||
@@ -54,7 +56,7 @@ public class SystemUiController {
|
||||
public @interface SystemUiControllerFlags {}
|
||||
|
||||
private final View mView;
|
||||
private final int[] mStates = new int[5];
|
||||
private final int[] mStates = new int[6];
|
||||
|
||||
public SystemUiController(View view) {
|
||||
mView = view;
|
||||
|
||||
@@ -61,7 +61,8 @@ public final class WidgetSizes {
|
||||
final int vBorderSpacing = (spanY - 1) * profile.cellLayoutBorderSpacePx.y;
|
||||
|
||||
Point cellSize = profile.getCellSize();
|
||||
Rect padding = profile.widgetPadding;
|
||||
// Flexera Launcher: the size the widget really gets, lined up with the icons.
|
||||
Rect padding = app.flexera.widget.FlexeraWidgets.appWidgetPadding(profile);
|
||||
|
||||
return new Size(
|
||||
(spanX * cellSize.x) + hBorderSpacing - padding.left - padding.right,
|
||||
|
||||
Reference in new issue
Block a user