8 Commits
Author SHA1 Message Date
TMC 9026a8ec7a Release 0.15.0 2026-11-13 12:26:22 +01:00
TMCandClaude Opus 5.5 f5d7cf07ed Line up every widget with the icons, as the Pictures widget does
Widgets of other apps used to fill their cells minus Launcher3's widget
padding, which is a few pixels wider than the icons' edges and reaches down
into the room kept for icon labels; on the owner's phone they hung about
13 px past the icons sideways and 40 px below. Now the room a widget keeps in
its cells is the distance from the cell edges to the icon edges, worked out
as IconFrame does for the launcher's own widgets, so every widget's edges
meet the edges of the icons around it.

The one function (FlexeraWidgets.appWidgetPadding) is used for the layout,
for the size the app is told, for the drop animation and through
WidgetSizes for previews. The cells a widget needs are still worked out
with Launcher3's padding, so no widget changes its cell count. A new switch
under Settings, Home screen, Widgets ("Line up with icons", on by default)
turns it off; Lawnchair's Widget padding slider shows only then.

Checked on the emulator against lines drawn at the icon edges: Google
Calendar's widgets meet them within 2 px; Google Clock's sits about 20 px
inside, because it draws an empty margin of its own.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-13 12:19:33 +01:00
TMCandClaude Opus 5.5 0dbb146c75 Quick launch: start the app as the finger lifts, with no opening animation
The app used to be started only once the black page had settled, and then
came in with Android's zoom. Now it is started the moment the swipe is
decided, while the page is still settling, with FLAG_ACTIVITY_NO_ANIMATION
and the same intent as its icon, so it appears on the page at full size as
soon as it has a frame. An app still in memory shows its last screen at
once. Apps of another profile still go through LauncherApps, which takes no
flags, and open with the usual animation.

On the emulator, lift to first app frame went from 646 ms to 140 ms
(Camera in memory), 684 ms to 248 ms (Camera closed), and 249 ms for Clock
in memory, which came up on its last screen.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-13 12:04:50 +01:00
TMCandClaude Opus 5.5 900071ccac Quick launch: an app opens on a page of its own, with the home screen blurring away
When a swipe past the first or the last home screen page is set to open an
app, the home screen no longer stretches. It moves aside with the finger, as
for a real page, an empty black page comes in from that edge, and the home
screen (icons and wallpaper) blurs and darkens the further it goes. Let go
past 40% of the width, or flick, and the page settles over the whole screen
and the app comes up on it; let go earlier and the home screen comes back.
Swipes set to other actions keep the stretch.

The home screen is moved with the drag layer's animation matrix, which does
not move touch coordinates, so the pages keep reading the same touch. The
blur is a second index on the folder blur's property in the depth controller.
The status bar icons turn light over the black page. Once the app is in
front the launcher stops and the page resets; if nothing comes within 1.5 s
the home screen slides back.

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

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 22:20:10 +01:00
TMCandClaude Opus 5.5 058fecd27f Website: the story phone comes apart as its eighth chapter
The separate Under the glass section is gone; its exploded phone is now
chapter 8 of How it works, so the pinned story phone carries straight
into it. After Out of sight the list closes, the drawn phone is swapped
for its layered double (the same home screen closed; measured at about
1-2% of pixels apart, from anti-aliasing), which tilts and comes apart,
holds, and goes back together before the section ends. The chapter is
two screens long and its text stays put meanwhile.

Labels redone: a column of numbered solid tags beside the stack, evenly
spaced, each joined by an elbow line to a dot on its layer's edge; the
stack moves left as it opens to make room. The glass sheen and the frame
now match the drawn phone, so the swap is not seen.

Also fixed: the layered phone sat at its full 414 px until first used,
which made the page wider than a phone and zoomed it out; it is scaled
from the start, and on phones the story stage clips. The story now
counts its progress in chapter heights rather than window heights,
which drifted apart on phones when the address bar hides. The long
chapter's height lost to the general chapter rule; it now wins.

Section numbers close up (Details 02 to Get it 06); the page alphabet's
U leads to the new chapter.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 22:11:53 +01:00
TMCandClaude Opus 5.5 6c68cb1369 Website: remove the living wallpaper behind the hero
The flowing bands of light on a canvas behind the hero are gone (markup,
styles, code and the README line); the hero keeps its dark background
with the grid and the two soft glows. The link-preview image is retaken
without them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 21:59:18 +01:00
TMCandClaude Opus 5.5 f1fa44b086 Website: wave headline, page alphabet, exploded phone, race and more
- Wave headline: the hero's letters swell from weight 560 to 900 around
  the mouse (a wave rolls through on its own without one).
- Page alphabet: one letter per section on the right edge, like the
  launcher's; hover shows the wave and a bubble, drag jumps between
  sections, tap scrolls there; on phones it appears while scrolling.
  Blended so it reads on dark, cyan and violet sections.
- Detail cards are switches now: left-handed alphabet, compact/comfy
  rows and four icon shapes, applied to every phone on the page.
- Under the glass: a new section where the phone pulls apart into its
  layers (wallpaper, widgets, icons, app list, glass) with labels.
- Race: a new section; find an app in a shuffled grid of 40, then in the
  list with the wave alphabet, timed. The list phone reuses the
  playground controller, locked to the list. The phone comes on screen
  before the clock starts.
- Living wallpaper: light bands on a canvas behind the hero.
- Magnetic buttons and a finger cursor over the phones (desktop only).
- Easter eggs: scrub to F in the playground, or type "flexera".
- A vibration tick per letter while scrubbing, on phones that have it.
- 40 generic apps instead of 29; the alphabet now sits from the bottom.
- Fixed: the alphabet's magnifier had no size, so its 300 px svg box
  covered the list once the alphabet moved to the left and took taps.

Reduced motion stops everything that moves on its own. Checked in
Chrome at 1440 x 900 and 390 x 844: no console errors or sideways
scrolling, every playground gesture, the camera chapter without flash,
each switch, both eggs, a full race, and the QR code as drawn.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 21:57:01 +01:00
21 changed files with 1655 additions and 110 deletions

No files matched your search

+22 -4
View File
@@ -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
View File
@@ -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 &amp; 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&nbsp;to&nbsp;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>&ndash;</b></div>
<div class="race__time"><span>Round 2<i>Flexera's list and wave alphabet</i></span><b data-race-t2>&ndash;</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
View File
@@ -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
View File
@@ -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) {
+3
View File
@@ -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>
+131 -22
View File
@@ -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()),
)
}
/**
+2 -1
View File
@@ -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()
+1 -1
View File
@@ -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);
+4 -1
View File
@@ -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,