diff --git a/.gitignore b/.gitignore index aeb525e..ff90b73 100644 --- a/.gitignore +++ b/.gitignore @@ -30,3 +30,6 @@ __pycache__/ # Keep only accepted GUI references. CI uploads comparison images separately. **/tests/snapshots/**/*.diff.png **/tests/snapshots/**/*.new.png + +# /brag launch-video runs (rendered locally, never committed) +/brag-output*/ diff --git a/docs/assets/host-app.webp b/docs/assets/host-app.webp index 3f7cd16..1b7b9de 100644 Binary files a/docs/assets/host-app.webp and b/docs/assets/host-app.webp differ diff --git a/docs/assets/launch-poster.jpg b/docs/assets/launch-poster.jpg new file mode 100644 index 0000000..1777c1b Binary files /dev/null and b/docs/assets/launch-poster.jpg differ diff --git a/docs/assets/launch.mp4 b/docs/assets/launch.mp4 new file mode 100644 index 0000000..66aa6b8 Binary files /dev/null and b/docs/assets/launch.mp4 differ diff --git a/docs/design.md b/docs/design.md index f47029e..bd0d3a8 100644 --- a/docs/design.md +++ b/docs/design.md @@ -44,6 +44,26 @@ under the SIL Open Font License 1.1. - iPad: `ios/EternalMonitor/App/DesignSystem.swift`. - Website: `docs/style.css`. +## Motion + +Movement explains state; it never decorates. Everything settles in well under +half a second, continuous effects run only while something is live or waiting, +and every surface honours the system's reduce-motion setting. + +- Website: `docs/style.css` keeps the keyframes and the `.reveal` / + `.reveal-stagger` scroll reveals; `docs/script.js` adds `.visible`, the nav + scroll state and the card spotlight. The hero and showcase enter on load. + Nothing is hidden without JavaScript (`html.no-js`). +- iPad: `Motion` in `ios/EternalMonitor/App/DesignSystem.swift` holds the shared + springs. `entrance(_:delay:)` staggers the connect screen, `PulseRings` waits + for a PC, `StatusDot(pulses:)` breathes while live, `Shake` rejects a wrong + pairing code, and the HUD numbers roll with `contentTransition(.numericText())`. +- Windows: `widgets::appear` in `host/src/gui/widgets.rs` fades and settles + pages and notices in; hover, the sidebar's active bar, the segmented pill and + the slider knob interpolate with egui's `animate_bool` / `animate_value`. The + window still repaints only while something animates, so the encoder keeps the + GPU. + ## Writing Sentence case everywhere. Say what happens and what to do next, in plain diff --git a/docs/download.html b/docs/download.html index ddc9a77..d0601f9 100644 --- a/docs/download.html +++ b/docs/download.html @@ -1,7 +1,8 @@ - + + Download EternalMonitor @@ -53,7 +54,7 @@

Download EternalMonitor

-
+
@@ -99,7 +100,7 @@

iPad

-
+
Build from source

Requirements

diff --git a/docs/index.html b/docs/index.html index dcf3839..90150c0 100644 --- a/docs/index.html +++ b/docs/index.html @@ -1,7 +1,8 @@ - + + EternalMonitor | Your iPad as a second screen for Windows @@ -50,7 +51,7 @@
v0.3.0 · Current stable · Free and open source -

Turn your iPad into a second screen for Windows.

+

Turn your iPad into a second screen for Windows.

Extend or mirror your desktop onto an iPad, then tap, drag and draw on it to control your PC. Free, with no account or subscription.

-
+
+ +
+
+
+ Watch +

Twenty seconds, both apps

+

Connect over Wi-Fi or USB, pair once, and stream.

+
+
+
+ + +
+
+
+
+
@@ -94,7 +116,7 @@

Turn your iPad into a second screen for Windows.

What it does

Mirror or extend, then work on the iPad as if it were part of your PC. If the Wi-Fi drops, the stream reconnects by itself.

-
+

Extend or mirror

@@ -137,7 +159,7 @@

Pairing v0.3

Three steps, about a minute

One installer sets up everything on the PC. There's no account to create.

-
    +
    1. Install on your PC

      @@ -164,7 +186,7 @@

      Pair once, then stream

      Requirements

      What you need

-
+

@@ -205,7 +227,7 @@

Current stable

Use a network you trust. Pairing keeps other devices out, but the stream itself is not encrypted.
-
    +
    1. v0.1.0Mirror @@ -238,7 +260,7 @@

      Current stable

      FAQ

      Questions

-
+
Is it really free?

Yes. EternalMonitor is open source under the MIT license. There's no account, subscription or ads.

diff --git a/docs/script.js b/docs/script.js index 596da9e..bf2ee82 100644 --- a/docs/script.js +++ b/docs/script.js @@ -3,10 +3,12 @@ (function () { 'use strict'; + var reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches; + var nav = document.querySelector('.nav'); + /* --- macOS Notice --- */ // iPadOS reports platform 'MacIntel' too, so require a non-touch device. var isMac = /Mac/.test(navigator.platform) && navigator.maxTouchPoints <= 1; - var nav = document.querySelector('.nav'); if (isMac && nav && sessionStorage.getItem('mac-notice-dismissed') !== '1') { var notice = document.createElement('aside'); notice.className = 'mac-notice'; @@ -25,6 +27,45 @@ nav.insertAdjacentElement('afterend', notice); } + /* --- Nav: solid once the page scrolls, and the section in view --- */ + if (nav) { + var navTick = false; + var updateNav = function () { + nav.classList.toggle('scrolled', window.scrollY > 8); + navTick = false; + }; + window.addEventListener('scroll', function () { + if (!navTick) { + navTick = true; + window.requestAnimationFrame(updateNav); + } + }, { passive: true }); + updateNav(); + } + + var sectionLinks = Array.prototype.slice.call(document.querySelectorAll('.nav-link[href^="#"]')); + if (sectionLinks.length && 'IntersectionObserver' in window) { + var linkFor = {}; + sectionLinks.forEach(function (link) { linkFor[link.getAttribute('href').slice(1)] = link; }); + // A thin band just above the middle of the viewport decides which + // section is "current". + var sectionObserver = new IntersectionObserver(function (entries) { + entries.forEach(function (entry) { + var link = linkFor[entry.target.id]; + if (entry.isIntersecting) { + sectionLinks.forEach(function (l) { l.classList.remove('active'); }); + link.classList.add('active'); + } else { + link.classList.remove('active'); + } + }); + }, { rootMargin: '-45% 0px -50% 0px' }); + Object.keys(linkFor).forEach(function (id) { + var section = document.getElementById(id); + if (section) sectionObserver.observe(section); + }); + } + /* --- Scroll Reveal (IntersectionObserver) --- */ var reveals = document.querySelectorAll('.reveal'); if (reveals.length && 'IntersectionObserver' in window) { @@ -43,13 +84,39 @@ reveals.forEach(function (el) { el.classList.add('visible'); }); } + /* --- Launch film: one lime button, native controls once it runs --- */ + var launch = document.getElementById('launch-video'); + var launchPlay = document.getElementById('launch-play'); + if (launch && launchPlay) { + launch.removeAttribute('controls'); + launchPlay.hidden = false; + launchPlay.addEventListener('click', function () { + launch.setAttribute('controls', ''); + var playing = launch.play(); + if (playing && playing.catch) playing.catch(function () {}); + }); + launch.addEventListener('play', function () { launchPlay.hidden = true; }); + launch.addEventListener('ended', function () { launchPlay.hidden = false; }); + } + + /* --- Card spotlight follows the pointer --- */ + if (window.matchMedia('(hover: hover)').matches) { + document.querySelectorAll('.feature, .step, .req-card, .download-card').forEach(function (card) { + card.addEventListener('pointermove', function (e) { + var rect = card.getBoundingClientRect(); + card.style.setProperty('--mx', (e.clientX - rect.left) + 'px'); + card.style.setProperty('--my', (e.clientY - rect.top) + 'px'); + }); + }); + } + /* --- Smooth Scroll for Anchor Links --- */ document.querySelectorAll('a[href^="#"]').forEach(function (link) { link.addEventListener('click', function (e) { var target = document.querySelector(this.getAttribute('href')); if (target) { e.preventDefault(); - target.scrollIntoView({ behavior: 'smooth' }); + target.scrollIntoView({ behavior: reducedMotion ? 'auto' : 'smooth' }); } }); }); diff --git a/docs/style.css b/docs/style.css index 8d159a7..1f4174f 100644 --- a/docs/style.css +++ b/docs/style.css @@ -28,6 +28,7 @@ --radius-md: 14px; --radius-lg: 22px; --ease: cubic-bezier(0.2, 0.8, 0.2, 1); + --ease-out: cubic-bezier(0.16, 1, 0.3, 1); } /* --- Base ---------------------------------------------------------------- */ @@ -158,6 +159,12 @@ code { backdrop-filter: saturate(160%) blur(16px); -webkit-backdrop-filter: saturate(160%) blur(16px); border-bottom: 1px solid var(--border); + transition: background 0.3s var(--ease), box-shadow 0.3s var(--ease); +} + +.nav.scrolled { + background: rgba(10, 10, 11, 0.88); + box-shadow: 0 12px 32px -20px rgba(0, 0, 0, 0.8); } .nav-inner { @@ -195,8 +202,12 @@ code { transition: color 0.15s, background 0.15s; } -.nav-link:hover { +.nav-link:hover, +.nav-link.active { color: var(--text); +} + +.nav-link:hover { background: rgba(255, 255, 255, 0.05); } @@ -233,7 +244,7 @@ code { } .btn:active { - transform: translateY(1px); + transform: translateY(1px) scale(0.99); } .btn-primary { @@ -247,6 +258,14 @@ code { box-shadow: 0 8px 28px -8px rgba(var(--accent-rgb), 0.55); } +.btn-primary svg { + transition: transform 0.25s var(--ease-out); +} + +.btn-primary:hover svg { + transform: translateY(2px); +} + .btn-secondary { background: var(--surface-2); border-color: var(--border-strong); @@ -284,6 +303,7 @@ code { } .eyebrow-dot { + position: relative; width: 8px; height: 8px; border-radius: 50%; @@ -291,6 +311,16 @@ code { box-shadow: 0 0 0 4px rgba(var(--accent-rgb), 0.16); } +.eyebrow-dot::after { + content: ''; + position: absolute; + inset: 0; + border-radius: 50%; + background: var(--accent); + opacity: 0; + animation: ping 2.4s var(--ease-out) infinite; +} + .card { background: var(--surface); border: 1px solid var(--border); @@ -344,6 +374,15 @@ code { text-transform: uppercase; } +.js .section-header .kicker { + letter-spacing: 0.3em; + transition: letter-spacing 1.1s var(--ease-out); +} + +.js .section-header.visible .kicker { + letter-spacing: 0.12em; +} + .section-header p { margin-top: 16px; color: var(--muted); @@ -424,6 +463,7 @@ code { transform: translateX(-50%); background: radial-gradient(closest-side, rgba(var(--accent-rgb), 0.12), transparent); pointer-events: none; + animation: breathe 10s ease-in-out infinite; } .hero h1 { @@ -433,6 +473,37 @@ code { .hero h1 .accent { color: var(--accent); + text-shadow: 0 0 36px rgba(var(--accent-rgb), 0.22); +} + +/* Entrance: the eyebrow, each word of the headline, then the rest. */ +.hero .eyebrow, +.hero .hero-subtitle, +.hero .hero-actions, +.hero .hero-meta { + animation: rise 0.8s var(--ease-out) both; +} + +.hero .eyebrow { + animation-delay: 0.05s; +} + +.hero .hero-subtitle { + animation-delay: 0.5s; +} + +.hero .hero-actions { + animation-delay: 0.62s; +} + +.hero .hero-meta { + animation-delay: 0.74s; +} + +.hero h1 .w { + display: inline-block; + animation: rise 0.9s var(--ease-out) both; + animation-delay: calc(0.14s + var(--i, 0) * 0.045s); } .hero-subtitle { @@ -572,6 +643,148 @@ code { text-align: center; } +/* The window rises first, then the iPad slides in, settles and floats. */ +.js .reveal.reveal-showcase { + opacity: 1; + transform: none; +} + +.js .reveal-showcase .showcase-window { + opacity: 0; + transform: translateY(40px) scale(0.98); + transition: opacity 1s var(--ease-out), transform 1s var(--ease-out); +} + +.js .reveal-showcase .showcase-ipad { + opacity: 0; + transform: translate3d(32px, 44px, 0) scale(0.96); + transition: opacity 0.9s var(--ease-out) 0.35s, transform 0.9s var(--ease-out) 0.35s; +} + +.js .reveal-showcase .showcase-label { + opacity: 0; + transition: opacity 0.6s var(--ease) 1s; +} + +.js .reveal-showcase.visible .showcase-window, +.js .reveal-showcase.visible .showcase-ipad, +.js .reveal-showcase.visible .showcase-label { + opacity: 1; + transform: none; +} + +.reveal-showcase.visible .showcase-ipad { + animation: float 7s ease-in-out 1.8s infinite; +} + +/* A faint ring leaves the iPad every few seconds: it is receiving. */ +.showcase-ipad::before { + content: ''; + position: absolute; + inset: -10px; + border-radius: inherit; + border: 1px solid rgba(var(--accent-rgb), 0.55); + opacity: 0; + pointer-events: none; +} + +.reveal-showcase.visible .showcase-ipad::before { + animation: beacon 2.8s var(--ease-out) 2.2s infinite; +} + +/* --- Launch film ------------------------------------------------------------ */ + +.section--watch { + padding-top: 32px; +} + +.launch { + max-width: 1040px; + margin: 0 auto; +} + +.launch-frame { + position: relative; + overflow: hidden; + aspect-ratio: 16 / 9; + border-radius: 16px; + background: #0e0e10; + border: 1px solid var(--border-strong); + box-shadow: 0 40px 120px -30px rgba(0, 0, 0, 0.9), 0 0 0 1px rgba(0, 0, 0, 0.6); +} + +.launch-frame video { + display: block; + width: 100%; + height: 100%; + object-fit: cover; +} + +/* One lime button over the poster; script.js swaps in the native controls + once the film runs. */ +.launch-play { + position: absolute; + inset: 0; + display: grid; + place-items: center; + width: 100%; + border: 0; + background: rgba(10, 10, 11, 0.16); + color: var(--on-accent); + cursor: pointer; + transition: background 0.3s var(--ease); +} + +.launch-play[hidden] { + display: none; +} + +.launch-play::before, +.launch-play::after { + content: ''; + position: absolute; + width: 84px; + height: 84px; + border-radius: 50%; +} + +.launch-play::before { + background: var(--accent); + box-shadow: 0 0 0 12px rgba(var(--accent-rgb), 0.14), 0 24px 60px -20px rgba(var(--accent-rgb), 0.7); + transition: transform 0.3s var(--ease-out), box-shadow 0.3s var(--ease); +} + +.launch-play::after { + border: 1.5px solid var(--accent); + opacity: 0; + animation: ping 2.8s var(--ease-out) infinite; +} + +.launch-play svg { + position: relative; + width: 34px; + height: 34px; + margin-left: 5px; +} + +.launch-play:hover { + background: rgba(10, 10, 11, 0.06); +} + +.launch-play:hover::before { + transform: scale(1.06); + box-shadow: 0 0 0 16px rgba(var(--accent-rgb), 0.16), 0 24px 60px -16px rgba(var(--accent-rgb), 0.8); +} + +.launch-play:focus-visible { + outline: none; +} + +.launch-play:focus-visible::before { + outline: 2px solid rgba(var(--accent-rgb), 0.8); + outline-offset: 6px; +} + /* --- Features -------------------------------------------------------------- */ .features-grid { @@ -582,11 +795,71 @@ code { .feature { padding: 28px; - transition: border-color 0.2s var(--ease), transform 0.2s var(--ease); } -.feature:hover { - border-color: var(--border-strong); +/* Cards lift on hover and carry a soft spotlight that follows the pointer + (script.js sets --mx and --my). */ +.feature, +.step, +.req-card, +.download-card { + position: relative; + isolation: isolate; + overflow: hidden; + transition: border-color 0.25s var(--ease), transform 0.3s var(--ease-out), + box-shadow 0.3s var(--ease-out); +} + +.feature::after, +.step::after, +.req-card::after, +.download-card::after { + content: ''; + position: absolute; + inset: 0; + z-index: -1; + border-radius: inherit; + background: radial-gradient( + 300px circle at var(--mx, 50%) var(--my, 0%), + rgba(var(--accent-rgb), 0.09), + transparent 62% + ); + opacity: 0; + transition: opacity 0.4s var(--ease); + pointer-events: none; +} + +@media (hover: hover) { + .feature:hover, + .step:hover, + .req-card:hover, + .download-card:hover { + border-color: var(--border-strong); + transform: translateY(-3px); + box-shadow: 0 24px 48px -28px rgba(0, 0, 0, 0.9); + } + + .download-card--primary:hover { + box-shadow: 0 30px 80px -40px rgba(var(--accent-rgb), 0.35), + 0 24px 48px -28px rgba(0, 0, 0, 0.9); + } + + .feature:hover::after, + .step:hover::after, + .req-card:hover::after, + .download-card:hover::after { + opacity: 1; + } + + .feature:hover .feature-icon { + transform: translateY(-2px) rotate(-4deg); + background: rgba(var(--accent-rgb), 0.14); + box-shadow: 0 0 0 5px rgba(var(--accent-rgb), 0.06); + } + + .step:hover .step-number { + transform: scale(1.08); + } } .feature-icon { @@ -599,6 +872,8 @@ code { background: rgba(var(--accent-rgb), 0.08); border: 1px solid rgba(var(--accent-rgb), 0.2); color: var(--accent); + transition: transform 0.35s var(--ease-out), background 0.35s var(--ease), + box-shadow 0.35s var(--ease); } .feature-icon svg { @@ -655,6 +930,7 @@ code { font-family: var(--font-mono); font-weight: 500; font-size: 0.95rem; + transition: transform 0.35s var(--ease-out); } .step h3 { @@ -763,6 +1039,28 @@ code { gap: 4px; } +/* Connector from the first dot to the last, lime as far as the current release. */ +.timeline::before { + content: ''; + position: absolute; + left: 11px; + top: 36px; + bottom: 36px; + width: 2px; + border-radius: 1px; + background: linear-gradient(var(--accent) 0, var(--accent) 66.7%, var(--border-strong) 66.7%); + transform-origin: top; +} + +.js .timeline::before { + transform: scaleY(0); + transition: transform 1.2s var(--ease-out) 0.4s; +} + +.js .reveal.visible .timeline::before { + transform: none; +} + .milestone { position: relative; display: grid; @@ -777,11 +1075,14 @@ code { } .milestone-dot { + position: relative; + z-index: 1; width: 12px; height: 12px; margin: 0 auto; border-radius: 50%; border: 2px solid var(--faint); + background: var(--surface); } .milestone--done .milestone-dot { @@ -794,6 +1095,16 @@ code { box-shadow: 0 0 0 4px rgba(var(--accent-rgb), 0.16); } +.milestone--current .milestone-dot::after { + content: ''; + position: absolute; + inset: -2px; + border-radius: 50%; + border: 1.5px solid var(--accent); + opacity: 0; + animation: ping 2.6s var(--ease-out) infinite; +} + .milestone-version { display: block; font-family: var(--font-mono); @@ -851,6 +1162,25 @@ code { transform: rotate(45deg); } +@supports (interpolate-size: allow-keywords) { + .faq details, + .build-details { + interpolate-size: allow-keywords; + } + + .faq details::details-content, + .build-details::details-content { + height: 0; + overflow: clip; + transition: height 0.35s var(--ease), content-visibility 0.35s allow-discrete; + } + + .faq details[open]::details-content, + .build-details[open]::details-content { + height: auto; + } +} + .faq details p { max-width: 680px; padding: 0 4px 24px; @@ -975,6 +1305,19 @@ code { font-size: 1.1rem; } +.download-hero h1, +.download-hero p { + animation: rise 0.8s var(--ease-out) both; +} + +.download-hero p { + animation-delay: 0.18s; +} + +.download-hero p + p { + animation-delay: 0.3s; +} + .download-grid { display: grid; grid-template-columns: 1.15fr 1fr; @@ -1212,28 +1555,156 @@ code { line-height: 1.7; } -/* --- Reveal on scroll ------------------------------------------------------ */ +/* --- Motion ---------------------------------------------------------------- */ +/* script.js swaps the html class from no-js to js and adds .visible when a + .reveal element scrolls into view. Without JavaScript nothing is hidden. */ + +@keyframes rise { + from { + opacity: 0; + transform: translateY(18px); + } + + to { + opacity: 1; + transform: none; + } +} + +@keyframes ping { + from { + transform: scale(1); + opacity: 0.45; + } -.reveal { + to { + transform: scale(2.8); + opacity: 0; + } +} + +@keyframes float { + 0%, + 100% { + transform: translateY(0); + } + + 50% { + transform: translateY(-7px); + } +} + +@keyframes breathe { + 0%, + 100% { + transform: translateX(-50%) scale(1); + opacity: 0.85; + } + + 50% { + transform: translateX(-50%) scale(1.06); + opacity: 1; + } +} + +@keyframes beacon { + from { + transform: scale(1); + opacity: 0.55; + } + + to { + transform: scale(1.1); + opacity: 0; + } +} + +.js .reveal { opacity: 0; transform: translateY(16px); - transition: opacity 0.6s var(--ease), transform 0.6s var(--ease); + transition: opacity 0.7s var(--ease-out), transform 0.7s var(--ease-out); +} + +.js .reveal.visible { + opacity: 1; + transform: none; } -.reveal.visible { +/* Groups stay put; their children rise one after another. */ +.js .reveal.reveal-stagger { opacity: 1; transform: none; } +.js .reveal-stagger > * { + opacity: 0; +} + +.js .reveal.visible .reveal-stagger > *, +.js .reveal.visible.reveal-stagger > * { + opacity: 1; + animation: rise 0.65s var(--ease-out) backwards; + animation-delay: calc(var(--i, 0) * 75ms); +} + +.reveal-stagger > :nth-child(2) { + --i: 1; +} + +.reveal-stagger > :nth-child(3) { + --i: 2; +} + +.reveal-stagger > :nth-child(4) { + --i: 3; +} + +.reveal-stagger > :nth-child(5) { + --i: 4; +} + +.reveal-stagger > :nth-child(6) { + --i: 5; +} + +.reveal-stagger > :nth-child(7) { + --i: 6; +} + +.reveal-stagger > :nth-child(8) { + --i: 7; +} + @media (prefers-reduced-motion: reduce) { html { scroll-behavior: auto; } - .reveal { + *, + *::before, + *::after { + animation-duration: 0.001ms !important; + animation-iteration-count: 1 !important; + animation-delay: 0ms !important; + transition-duration: 0.001ms !important; + transition-delay: 0ms !important; + } + + .js .reveal, + .js .reveal-stagger > *, + .js .reveal-showcase .showcase-window, + .js .reveal-showcase .showcase-ipad, + .js .reveal-showcase .showcase-label { opacity: 1; transform: none; - transition: none; + } + + .js .timeline::before { + transform: none; + } + + .js .section-header .kicker { + letter-spacing: 0.12em; } } diff --git a/host/src/gui/mod.rs b/host/src/gui/mod.rs index d4b49c9..c18d9ba 100644 --- a/host/src/gui/mod.rs +++ b/host/src/gui/mod.rs @@ -675,6 +675,11 @@ impl eframe::App for AnalyzerApp { bottom: 36, }) .show(ui, |ui| { + // Each page fades and settles in when opened. + let shown = + widgets::appear(ui, egui::Id::new(("page", self.page)), 0.22); + ui.add_space((1.0 - shown) * 10.0); + ui.set_opacity(shown); ui.set_max_width(ui.available_width().min(980.0)); ui.spacing_mut().item_spacing.y = 10.0; match self.page { @@ -710,9 +715,26 @@ impl AnalyzerApp { self.draw_brand(ui); ui.add_space(26.0); ui.spacing_mut().item_spacing.y = 4.0; - self.nav_item(ui, Page::Stream, Icon::Display, "Stream"); - self.nav_item(ui, Page::Performance, Icon::Activity, "Performance"); - self.nav_item(ui, Page::Settings, Icon::Sliders, "Settings"); + let stream = self.nav_item(ui, Page::Stream, Icon::Display, "Stream"); + let performance = + self.nav_item(ui, Page::Performance, Icon::Activity, "Performance"); + let settings = self.nav_item(ui, Page::Settings, Icon::Sliders, "Settings"); + // A lime bar marks the open page and slides to the one clicked. + let active = match self.page { + Page::Stream => stream, + Page::Performance => performance, + Page::Settings => settings, + }; + let y = ui.ctx().animate_value_with_time( + egui::Id::new("nav-active-bar"), + active.center().y, + 0.18, + ); + ui.painter().rect_filled( + Rect::from_center_size(pos2(active.left() + 2.5, y), vec2(3.0, 16.0)), + 1.5, + ACCENT, + ); ui.with_layout(egui::Layout::bottom_up(egui::Align::Min), |ui| { ui.label(faint(format!("Version {}", env!("CARGO_PKG_VERSION")))); @@ -749,27 +771,34 @@ impl AnalyzerApp { }); } - fn nav_item(&mut self, ui: &mut egui::Ui, page: Page, icon: Icon, label: &str) { - let active = self.page == page; + /// One sidebar entry. Returns its rectangle for the active-page bar. + fn nav_item(&mut self, ui: &mut egui::Ui, page: Page, icon: Icon, label: &str) -> Rect { let (rect, response) = ui.allocate_exact_size(vec2(ui.available_width(), 36.0), Sense::click()); response.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, label)); if response.clicked() { self.page = page; } - let hovered = response.hovered(); + let active = self.page == page; + // Hover and selection fade in and out instead of switching. + let hover = ui.ctx().animate_bool_with_time( + response.id.with("hover"), + response.hovered() && !active, + 0.12, + ); + let selected = ui + .ctx() + .animate_bool_with_time(response.id.with("active"), active, 0.18); let painter = ui.painter(); - if active { - painter.rect( - rect, - 8.0, - SURFACE_RAISED, - Stroke::new(1.0, BORDER), - StrokeKind::Inside, - ); - } else if hovered { - painter.rect_filled(rect, 8.0, SURFACE); - } + painter.rect( + rect, + 8.0, + SIDEBAR + .lerp_to_gamma(SURFACE, hover) + .lerp_to_gamma(SURFACE_RAISED, selected), + Stroke::new(1.0, BORDER.gamma_multiply(selected)), + StrokeKind::Inside, + ); if response.has_focus() { painter.rect_stroke( rect.expand(2.0), @@ -778,27 +807,23 @@ impl AnalyzerApp { StrokeKind::Outside, ); } - let icon_color = if active { - ACCENT - } else if hovered { - TEXT_MUTED - } else { - TEXT_FAINT - }; icons::paint( painter, Rect::from_center_size(pos2(rect.left() + 20.0, rect.center().y), vec2(16.0, 16.0)), icon, - icon_color, + TEXT_FAINT + .lerp_to_gamma(TEXT_MUTED, hover) + .lerp_to_gamma(ACCENT, selected), ); painter.text( pos2(rect.left() + 38.0, rect.center().y), egui::Align2::LEFT_CENTER, label, egui::FontId::new(13.5, medium()), - if active || hovered { TEXT } else { TEXT_MUTED }, + TEXT_MUTED.lerp_to_gamma(TEXT, hover.max(selected)), ); response.on_hover_cursor(egui::CursorIcon::PointingHand); + rect } } diff --git a/host/src/gui/widgets.rs b/host/src/gui/widgets.rs index 44b8241..eb5c256 100644 --- a/host/src/gui/widgets.rs +++ b/host/src/gui/widgets.rs @@ -16,6 +16,23 @@ use super::theme::*; pub const GAP: f32 = 16.0; const BUTTON_HEIGHT: f32 = 34.0; +// ── Motion ────────────────────────────────────────────────────────────────── + +/// 0 → 1 over `seconds` from the frame something first shows (or shows again +/// after a frame away), eased out. Pages and notices fade and settle in with +/// it. Nothing repaints once it reaches 1. +pub fn appear(ui: &Ui, id: egui::Id, seconds: f32) -> f32 { + let ctx = ui.ctx(); + let frame = ctx.cumulative_frame_nr(); + let last_shown = ctx.data_mut(|d| d.get_temp::(id)); + ctx.data_mut(|d| d.insert_temp(id, frame)); + if last_shown.is_none_or(|shown| shown + 1 < frame) { + // Start from invisible; a new id would otherwise snap to 1. + ctx.animate_bool_with_time(id, false, 0.0); + } + ctx.animate_bool_with_time_and_easing(id, true, seconds, egui::emath::easing::cubic_out) +} + // ── Containers ────────────────────────────────────────────────────────────── pub fn card() -> Frame { @@ -108,59 +125,48 @@ pub fn button_ex( if ui.is_rect_visible(rect) { let hovered = enabled && response.hovered(); let pressed = enabled && response.is_pointer_button_down_on(); - let (fill, stroke, text) = match tone { + // Hover colours fade in and out; a press is immediate. + let hover = ui + .ctx() + .animate_bool_with_time(response.id.with("hover"), hovered, 0.12); + let (rest, hovered_fill, pressed_fill, stroke, text) = match tone { Tone::Primary => ( - if pressed { - ACCENT_PRESSED - } else if hovered { - ACCENT_HOVER - } else { - ACCENT - }, + ACCENT, + ACCENT_HOVER, + ACCENT_PRESSED, Stroke::NONE, ON_ACCENT, ), Tone::Secondary => ( - if pressed { - mix(SURFACE_HOVER, TEXT, 4) - } else if hovered { - SURFACE_HOVER - } else { - SURFACE_RAISED - }, + SURFACE_RAISED, + SURFACE_HOVER, + mix(SURFACE_HOVER, TEXT, 4), Stroke::new( 1.0, - if hovered { - mix(BORDER_STRONG, TEXT, 12) - } else { - BORDER_STRONG - }, + BORDER_STRONG.lerp_to_gamma(mix(BORDER_STRONG, TEXT, 12), hover), ), TEXT, ), Tone::Danger => ( - if pressed { - mix(SURFACE, DANGER, 22) - } else if hovered { - mix(SURFACE, DANGER, 15) - } else { - tint(DANGER) - }, + tint(DANGER), + mix(SURFACE, DANGER, 15), + mix(SURFACE, DANGER, 22), Stroke::new(1.0, tint_border(DANGER)), mix(DANGER, TEXT, 25), ), Tone::Ghost => ( - if pressed { - SURFACE_HOVER - } else if hovered { - SURFACE_RAISED - } else { - Color32::TRANSPARENT - }, + Color32::TRANSPARENT, + SURFACE_RAISED, + SURFACE_HOVER, Stroke::NONE, - if hovered { TEXT } else { TEXT_MUTED }, + TEXT_MUTED.lerp_to_gamma(TEXT, hover), ), }; + let fill = if pressed { + pressed_fill + } else { + rest.lerp_to_gamma(hovered_fill, hover) + }; let alpha = if enabled { 1.0 } else { 0.45 }; let painter = ui.painter(); painter.rect( @@ -202,12 +208,13 @@ pub fn icon_button(ui: &mut Ui, icon: Icon, label: &str) -> Response { if ui.is_rect_visible(rect) { let hovered = response.hovered(); let pressed = response.is_pointer_button_down_on(); + let hover = ui + .ctx() + .animate_bool_with_time(response.id.with("hover"), hovered, 0.12); let fill = if pressed { SURFACE_HOVER - } else if hovered { - SURFACE_RAISED } else { - Color32::TRANSPARENT + Color32::TRANSPARENT.lerp_to_gamma(SURFACE_RAISED, hover) }; ui.painter().rect_filled(rect, 8.0, fill); focus_ring(ui, &response, rect, 8.0); @@ -215,7 +222,7 @@ pub fn icon_button(ui: &mut Ui, icon: Icon, label: &str) -> Response { ui.painter(), Rect::from_center_size(rect.center(), vec2(15.0, 15.0)), icon, - if hovered { TEXT } else { TEXT_MUTED }, + TEXT_MUTED.lerp_to_gamma(TEXT, hover), ); } response @@ -237,11 +244,10 @@ pub fn link(ui: &mut Ui, label: &str, url: &str) -> Response { ui.ctx().open_url(egui::OpenUrl::new_tab(url)); } if ui.is_rect_visible(rect) { - let color = if response.hovered() { - ACCENT_HOVER - } else { - ACCENT - }; + let hover = + ui.ctx() + .animate_bool_with_time(response.id.with("hover"), response.hovered(), 0.12); + let color = ACCENT.lerp_to_gamma(ACCENT_HOVER, hover); let text_pos = pos2(rect.left(), rect.center().y - galley.size().y / 2.0); let text_width = galley.size().x; ui.painter().galley(text_pos, galley, color); @@ -339,41 +345,67 @@ pub fn segmented(ui: &mut Ui, value: &mut T, options: &[(T, let base_id = ui .id() .with(("segmented", outer.min.x as i32, outer.min.y as i32)); - for (i, ((option, label), galley)) in options.iter().zip(galleys).enumerate() { - let rect = Rect::from_min_size( - pos2( - outer.left() + pad + segment_width * i as f32, - outer.top() + pad, - ), - vec2(segment_width, size.y - pad * 2.0), - ); - let response = ui.interact(rect, base_id.with(i), Sense::click()); + let rects: Vec = (0..options.len()) + .map(|i| { + Rect::from_min_size( + pos2( + outer.left() + pad + segment_width * i as f32, + outer.top() + pad, + ), + vec2(segment_width, size.y - pad * 2.0), + ) + }) + .collect(); + let responses: Vec = rects + .iter() + .enumerate() + .map(|(i, rect)| ui.interact(*rect, base_id.with(i), Sense::click())) + .collect(); + for (response, (option, _)) in responses.iter().zip(options) { if response.clicked() && *value != *option { *value = *option; changed = true; } + } + // The selected pill slides to the chosen segment. + if let Some(selected) = options.iter().position(|(option, _)| *value == *option) { + let x = + ui.ctx() + .animate_value_with_time(base_id.with("pill"), rects[selected].left(), 0.16); + let pill = Rect::from_min_size(pos2(x, rects[selected].top()), rects[selected].size()); + ui.painter().rect( + pill, + 7.0, + SURFACE_HOVER, + Stroke::new(1.0, BORDER_STRONG), + StrokeKind::Inside, + ); + } + let enabled = ui.is_enabled(); + for (((option, label), galley), (rect, response)) in + options.iter().zip(galleys).zip(rects.iter().zip(responses)) + { let selected = *value == *option; - let enabled = ui.is_enabled(); response.widget_info(|| { WidgetInfo::selected(WidgetType::SelectableLabel, enabled, selected, *label) }); - let hovered = response.hovered(); - if selected { - ui.painter().rect( - rect, + let hover = ui.ctx().animate_bool_with_time( + response.id.with("hover"), + response.hovered() && !selected, + 0.12, + ); + if hover > 0.0 { + ui.painter().rect_filled( + *rect, 7.0, - SURFACE_HOVER, - Stroke::new(1.0, BORDER_STRONG), - StrokeKind::Inside, + Color32::TRANSPARENT.lerp_to_gamma(SURFACE, hover), ); - } else if hovered { - ui.painter().rect_filled(rect, 7.0, SURFACE); } - focus_ring(ui, &response, rect, 7.0); - let color = if selected || hovered { + focus_ring(ui, &response, *rect, 7.0); + let color = if selected { TEXT } else { - TEXT_MUTED + TEXT_MUTED.lerp_to_gamma(TEXT, hover) }; ui.painter() .galley(rect.center() - galley.size() / 2.0, galley, color); @@ -451,7 +483,10 @@ pub fn slider( ACCENT, ); let active = response.hovered() || response.dragged(); - let radius = if active { 8.5 } else { 7.5 }; + // The knob grows a little while it is being handled. + let radius = 7.5 + + ui.ctx() + .animate_bool_with_time(response.id.with("knob"), active, 0.1); let center = pos2(knob_x, rail.center().y); painter.circle_filled( center + vec2(0.0, 1.0), @@ -677,7 +712,8 @@ pub fn badge(ui: &mut Ui, text: &str, color: Color32) -> Response { response } -/// Coloured notice with an icon, title, body and optional actions. +/// Coloured notice with an icon, title, body and optional actions. It fades +/// and settles in when it first shows. pub fn banner( ui: &mut Ui, color: Color32, @@ -686,6 +722,23 @@ pub fn banner( body: &str, actions_width: f32, actions: impl FnOnce(&mut Ui), +) { + let shown = appear(ui, ui.id().with(("banner", title)), 0.28); + ui.add_space((1.0 - shown) * 6.0); + ui.scope(|ui| { + ui.set_opacity(shown); + banner_frame(ui, color, icon, title, body, actions_width, actions); + }); +} + +fn banner_frame( + ui: &mut Ui, + color: Color32, + icon: Icon, + title: &str, + body: &str, + actions_width: f32, + actions: impl FnOnce(&mut Ui), ) { Frame::new() .fill(mix(SURFACE, color, 7)) diff --git a/host/tests/snapshots/pairing-qr.png b/host/tests/snapshots/pairing-qr.png index a1089f5..d9351a8 100644 Binary files a/host/tests/snapshots/pairing-qr.png and b/host/tests/snapshots/pairing-qr.png differ diff --git a/host/tests/snapshots/performance.png b/host/tests/snapshots/performance.png index 7570707..f6c21a0 100644 Binary files a/host/tests/snapshots/performance.png and b/host/tests/snapshots/performance.png differ diff --git a/host/tests/snapshots/settings.png b/host/tests/snapshots/settings.png index 15e1f15..3551b2c 100644 Binary files a/host/tests/snapshots/settings.png and b/host/tests/snapshots/settings.png differ diff --git a/host/tests/snapshots/software-and-display-task-failure.png b/host/tests/snapshots/software-and-display-task-failure.png index f999d51..0b89a88 100644 Binary files a/host/tests/snapshots/software-and-display-task-failure.png and b/host/tests/snapshots/software-and-display-task-failure.png differ diff --git a/host/tests/snapshots/stream-connected.png b/host/tests/snapshots/stream-connected.png index b71661c..5c024ba 100644 Binary files a/host/tests/snapshots/stream-connected.png and b/host/tests/snapshots/stream-connected.png differ diff --git a/host/tests/snapshots/update-banner.png b/host/tests/snapshots/update-banner.png index 48a4c22..e16e907 100644 Binary files a/host/tests/snapshots/update-banner.png and b/host/tests/snapshots/update-banner.png differ diff --git a/ios/EternalMonitor/App/ConnectView.swift b/ios/EternalMonitor/App/ConnectView.swift index 43ecc55..3dc9859 100644 --- a/ios/EternalMonitor/App/ConnectView.swift +++ b/ios/EternalMonitor/App/ConnectView.swift @@ -18,6 +18,9 @@ struct ConnectView: View { @AppStorage("didSeeOnboarding") private var didSeeOnboarding = false @FocusState private var focusedField: Field? @Environment(\.horizontalSizeClass) private var sizeClass + @Environment(\.accessibilityReduceMotion) private var reduceMotion + // Flipped on appear; the header and cards rise in one after another. + @State private var appeared = false private enum Field: Hashable { case host, port @@ -53,9 +56,11 @@ struct ConnectView: View { header .padding(.top, 24) .padding(.bottom, 8) + .entrance(appeared) if !didSeeOnboarding { onboardingCard + .entrance(appeared, delay: 0.08) } if let error = connectionManager.connectionError { @@ -64,11 +69,15 @@ struct ConnectView: View { if isConnecting { connectingCard + .transition(.opacity.combined(with: .scale(scale: 0.97))) } else { connectCard + .entrance(appeared, delay: 0.14) + .transition(.opacity.combined(with: .scale(scale: 0.97))) } usbCard + .entrance(appeared, delay: 0.2) if scanner.hosts.isEmpty && !scanner.isScanning && !scanner.statusMessage.isEmpty { scanEmptyState @@ -80,6 +89,7 @@ struct ConnectView: View { if !recentStore.connections.isEmpty && scanner.hosts.isEmpty { recentSection + .entrance(appeared, delay: 0.26) } if isConnecting || connectionManager.connectionError != nil || !connectionManager.diagnostics.isEmpty { @@ -126,6 +136,7 @@ struct ConnectView: View { ) } .onAppear { + appeared = true #if DEBUG if UIPreview.opensSettings { showSettings = true } #endif @@ -148,6 +159,8 @@ struct ConnectView: View { private var header: some View { VStack(spacing: 14) { LogoMark(size: 68) + .scaleEffect(appeared || reduceMotion ? 1 : 0.9) + .animation(reduceMotion ? nil : Motion.gentle, value: appeared) VStack(spacing: 6) { Text("EternalMonitor") .font(.app(30, .semibold, relativeTo: .largeTitle)) @@ -337,6 +350,7 @@ struct ConnectView: View { RoundedRectangle(cornerRadius: 12, style: .continuous) .strokeBorder(focusedField == field ? Theme.accent : Theme.borderStrong, lineWidth: focusedField == field ? 1.5 : 1) ) + .animation(Motion.fade, value: focusedField == field) .keyboardType(keyboard) .textContentType(.none) .textInputAutocapitalization(.never) @@ -361,9 +375,7 @@ struct ConnectView: View { private var connectingCard: some View { VStack(alignment: .leading, spacing: 18) { HStack(spacing: 14) { - ProgressView() - .tint(Theme.accent) - .controlSize(.regular) + PulseRings(size: 44) VStack(alignment: .leading, spacing: 3) { Text("Connecting…") .font(.app(18, .semibold, relativeTo: .headline)) @@ -395,6 +407,7 @@ struct ConnectView: View { Image(systemName: "cable.connector") .font(.system(size: 17, weight: .medium)) .foregroundStyle(settings.allowUSB ? Theme.accent : Theme.textFaint) + .animation(Motion.fade, value: settings.allowUSB) .frame(width: 40, height: 40) .background( RoundedRectangle(cornerRadius: 11, style: .continuous) @@ -474,9 +487,10 @@ struct ConnectView: View { paired: false ) } - .buttonStyle(.plain) + .buttonStyle(RowButtonStyle()) } } + .clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous)) .card(padding: 0) } .transition(.opacity.combined(with: .move(edge: .bottom))) @@ -506,9 +520,10 @@ struct ConnectView: View { paired: conn.isUSB || pairings.isPaired(host: conn.host, port: conn.port) ) } - .buttonStyle(.plain) + .buttonStyle(RowButtonStyle()) } } + .clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous)) .card(padding: 0) } .opacity(isConnecting ? 0.5 : 1) diff --git a/ios/EternalMonitor/App/DesignSystem.swift b/ios/EternalMonitor/App/DesignSystem.swift index 8ab9d94..809d9c8 100644 --- a/ios/EternalMonitor/App/DesignSystem.swift +++ b/ios/EternalMonitor/App/DesignSystem.swift @@ -218,16 +218,37 @@ struct LogoMark: View { } } -/// A steady status light with a soft halo. +/// A status light with a soft halo. With `pulses` on, a ring breathes out +/// of the halo every couple of seconds: something is live. struct StatusDot: View { var color: Color = Theme.accent var size: CGFloat = 8 + var pulses = false + @Environment(\.accessibilityReduceMotion) private var reduceMotion var body: some View { Circle() .fill(color) .frame(width: size, height: size) - .background(Circle().fill(color.opacity(0.22)).frame(width: size * 2.2, height: size * 2.2)) + .background(halo) + } + + @ViewBuilder + private var halo: some View { + if pulses && !reduceMotion { + TimelineView(.animation(minimumInterval: 1 / 20)) { context in + let phase = context.date.timeIntervalSinceReferenceDate.truncatingRemainder(dividingBy: 2.4) / 2.4 + ZStack { + Circle().fill(color.opacity(0.22)) + Circle() + .stroke(color.opacity(0.5 * (1 - phase)), lineWidth: 1) + .scaleEffect(1 + phase * 1.2) + } + .frame(width: size * 2.2, height: size * 2.2) + } + } else { + Circle().fill(color.opacity(0.22)).frame(width: size * 2.2, height: size * 2.2) + } } } @@ -250,8 +271,8 @@ struct PrimaryButtonStyle: ButtonStyle { .strokeBorder(isEnabled ? Color.clear : Theme.border, lineWidth: 1) ) .opacity(configuration.isPressed ? 0.82 : 1) - .scaleEffect(configuration.isPressed ? 0.985 : 1) - .animation(.easeOut(duration: 0.12), value: configuration.isPressed) + .scaleEffect(configuration.isPressed ? 0.97 : 1) + .animation(Motion.press, value: configuration.isPressed) .animation(.easeOut(duration: 0.15), value: isEnabled) } } @@ -274,7 +295,8 @@ struct SecondaryButtonStyle: ButtonStyle { .strokeBorder(Theme.borderStrong, lineWidth: 1) ) .opacity(isEnabled ? 1 : 0.4) - .animation(.easeOut(duration: 0.12), value: configuration.isPressed) + .scaleEffect(configuration.isPressed ? 0.98 : 1) + .animation(Motion.press, value: configuration.isPressed) } } @@ -291,6 +313,94 @@ struct PillButtonStyle: ButtonStyle { .frame(minHeight: 40) .background(Capsule().fill(fill)) .opacity(configuration.isPressed ? 0.7 : 1) + .scaleEffect(configuration.isPressed ? 0.95 : 1) + .animation(Motion.press, value: configuration.isPressed) .contentShape(Capsule()) } } + +/// List row: a faint highlight while pressed. +struct RowButtonStyle: ButtonStyle { + func makeBody(configuration: Configuration) -> some View { + configuration.label + .background(configuration.isPressed ? Color.white.opacity(0.05) : Color.clear) + .animation(.easeOut(duration: 0.12), value: configuration.isPressed) + } +} + +// MARK: - Motion +// +// Shared timings, so every surface moves the same way. Continuous effects +// stop under Reduce Motion; state changes still animate briefly. + +enum Motion { + /// Button presses and other small state changes. + static let press = Animation.spring(response: 0.28, dampingFraction: 0.7) + /// Cards and sections entering or leaving. + static let gentle = Animation.spring(response: 0.5, dampingFraction: 0.86) + /// Colour and opacity changes. + static let fade = Animation.easeInOut(duration: 0.22) +} + +/// Rises and fades in once `shown` turns true, `delay` seconds later. Used +/// to stagger the connect screen on launch. +struct Entrance: ViewModifier { + var shown: Bool + var delay: Double = 0 + @Environment(\.accessibilityReduceMotion) private var reduceMotion + + func body(content: Content) -> some View { + content + .opacity(shown ? 1 : 0) + .offset(y: shown || reduceMotion ? 0 : 14) + .animation(reduceMotion ? nil : Motion.gentle.delay(delay), value: shown) + } +} + +extension View { + func entrance(_ shown: Bool, delay: Double = 0) -> some View { + modifier(Entrance(shown: shown, delay: delay)) + } +} + +/// Rings that expand and fade from a dot while the app waits for the PC. +/// A still ring under Reduce Motion. +struct PulseRings: View { + var color: Color = Theme.accent + var size: CGFloat = 44 + @Environment(\.accessibilityReduceMotion) private var reduceMotion + + var body: some View { + ZStack { + if reduceMotion { + Circle().stroke(color.opacity(0.3), lineWidth: 1.5) + } else { + TimelineView(.animation(minimumInterval: 1 / 24)) { context in + let now = context.date.timeIntervalSinceReferenceDate + ForEach(0..<3, id: \.self) { ring in + let phase = (now / 2.4 + Double(ring) / 3).truncatingRemainder(dividingBy: 1) + Circle() + .stroke(color.opacity(0.55 * (1 - phase)), lineWidth: 1.5) + .scaleEffect(0.3 + phase * 0.7) + } + } + } + Circle() + .fill(color) + .frame(width: size * 0.2, height: size * 0.2) + } + .frame(width: size, height: size) + .accessibilityHidden(true) + } +} + +/// Nudges a view sideways a few times as `animatableData` moves to the next +/// whole number: the pairing code was wrong. +struct Shake: GeometryEffect { + var amount: CGFloat = 7 + var animatableData: CGFloat + + func effectValue(size: CGSize) -> ProjectionTransform { + ProjectionTransform(CGAffineTransform(translationX: amount * sin(animatableData * .pi * 3), y: 0)) + } +} diff --git a/ios/EternalMonitor/App/DisplayView.swift b/ios/EternalMonitor/App/DisplayView.swift index 1737005..0212262 100644 --- a/ios/EternalMonitor/App/DisplayView.swift +++ b/ios/EternalMonitor/App/DisplayView.swift @@ -10,6 +10,9 @@ struct DisplayView: View { @State private var showQualityPopover = false @State private var showSettings = false @State private var showKeyboard = false + // The picture fades in once the view is on screen. + @State private var appeared = false + @Environment(\.accessibilityReduceMotion) private var reduceMotion var body: some View { ZStack { @@ -21,12 +24,14 @@ struct DisplayView: View { // PC; a three-finger tap (via the relay layer) drives the HUD. MetalView() .ignoresSafeArea() + .opacity(appeared ? 1 : 0) TouchRelayView(keyboardVisible: $showKeyboard, active: !showSettings && !connectionManager.signalLost, onToggleHUD: { toggleHUD() }) .ignoresSafeArea() } else { // One gesture, one meaning: tap toggles the HUD. MetalView() .ignoresSafeArea() + .opacity(appeared ? 1 : 0) .onTapGesture { toggleHUD() } } @@ -39,7 +44,7 @@ struct DisplayView: View { if connectionManager.signalLost { reconnectingCard - .transition(.opacity) + .transition(.opacity.combined(with: .scale(scale: 0.96))) .allowsHitTesting(false) } @@ -88,7 +93,9 @@ struct DisplayView: View { .statusBarHidden(true) .persistentSystemOverlays(.hidden) .animation(.easeInOut(duration: 0.25), value: connectionManager.signalLost) + .animation(reduceMotion ? nil : .easeInOut(duration: 0.35), value: appeared) .onAppear { + appeared = true scheduleHUDDismiss() #if DEBUG if UIPreview.opensQuality { showQualityPopover = true } @@ -117,6 +124,12 @@ struct DisplayView: View { if lost { hudDismissTask?.cancel(); showHUD = true } else { scheduleHUDDismiss() } } + // A cable taking over (or coming out) and a quick reconnect can + // reuse this view, so show the pill again with the new link. + .onChange(of: connectionManager.transportMode) { _, _ in scheduleHUDDismiss() } + .onChange(of: connectionManager.state) { _, state in + if state == .connected { scheduleHUDDismiss() } + } } private var transportName: String { @@ -182,6 +195,7 @@ struct DisplayView: View { } } .frame(height: 14) + .animation(Motion.fade, value: bars) } private var qualityPopover: some View { @@ -242,6 +256,9 @@ struct DisplayView: View { .font(.appMono(14, medium: true, relativeTo: .footnote)) .foregroundStyle(Theme.text) .monospacedDigit() + // Digits roll to the new value instead of snapping. + .contentTransition(.numericText()) + .animation(Motion.fade, value: value) Text(unit) .font(.app(11, relativeTo: .caption2)) .foregroundStyle(Theme.textMuted) @@ -261,7 +278,7 @@ struct DisplayView: View { private var bottomBar: some View { HStack(spacing: 8) { HStack(spacing: 10) { - StatusDot(color: connectionManager.signalLost ? Theme.warning : Theme.accent, size: 8) + StatusDot(color: connectionManager.signalLost ? Theme.warning : Theme.accent, size: 8, pulses: !connectionManager.signalLost) Text(connectionManager.signalLost ? "Reconnecting" : "Live") .font(.app(14, .semibold, relativeTo: .subheadline)) .foregroundStyle(Theme.text) @@ -289,6 +306,7 @@ struct DisplayView: View { foreground: showKeyboard ? Theme.onAccent : Theme.text, fill: showKeyboard ? Theme.accent : Color.white.opacity(0.08) )) + .animation(Motion.fade, value: showKeyboard) .accessibilityIdentifier("display.keyboard") } @@ -328,9 +346,7 @@ struct DisplayView: View { private var reconnectingCard: some View { VStack(spacing: 12) { - ProgressView() - .tint(Theme.text) - .controlSize(.large) + PulseRings(color: Theme.warning, size: 56) Text("Reconnecting to your PC") .font(.app(18, .semibold, relativeTo: .headline)) .foregroundStyle(Theme.text) @@ -370,7 +386,7 @@ struct DisplayView: View { private func scheduleHUDDismiss() { hudDismissTask?.cancel() - withAnimation(.easeOut(duration: 0.25)) { showHUD = true } + withAnimation(Motion.gentle) { showHUD = true } guard !showKeyboard && !showSettings && !connectionManager.signalLost else { return } hudDismissTask = Task { try? await Task.sleep(for: .seconds(5)) diff --git a/ios/EternalMonitor/App/PairingSheet.swift b/ios/EternalMonitor/App/PairingSheet.swift index c23d7f4..e6dd36f 100644 --- a/ios/EternalMonitor/App/PairingSheet.swift +++ b/ios/EternalMonitor/App/PairingSheet.swift @@ -5,6 +5,10 @@ struct PairingSheet: View { let submit: () -> Void let cancel: () -> Void @FocusState private var focused: Bool + @Environment(\.accessibilityReduceMotion) private var reduceMotion + @State private var appeared = false + // Counts wrong codes; each one shakes the field once. + @State private var shakes: CGFloat = 0 var body: some View { NavigationStack { @@ -18,6 +22,9 @@ struct PairingSheet: View { RoundedRectangle(cornerRadius: 18, style: .continuous) .fill(Theme.accent.opacity(0.1)) ) + .scaleEffect(appeared || reduceMotion ? 1 : 0.8) + .opacity(appeared ? 1 : 0) + .animation(reduceMotion ? nil : Motion.gentle, value: appeared) .accessibilityHidden(true) VStack(spacing: 8) { @@ -48,6 +55,9 @@ struct PairingSheet: View { RoundedRectangle(cornerRadius: 16, style: .continuous) .strokeBorder(model.error == nil ? (focused ? Theme.accent : Theme.borderStrong) : Theme.danger, lineWidth: 1.5) ) + .animation(Motion.fade, value: model.error == nil) + .animation(Motion.fade, value: focused) + .modifier(Shake(animatableData: shakes)) .focused($focused) .onChange(of: model.code) { _, code in // Digits only, at most six. @@ -68,6 +78,7 @@ struct PairingSheet: View { .font(.app(14, relativeTo: .callout)) .foregroundStyle(Theme.danger) .frame(maxWidth: 360) + .transition(.opacity.combined(with: .move(edge: .top))) } TimelineView(.periodic(from: .now, by: 1)) { context in @@ -96,6 +107,12 @@ struct PairingSheet: View { .padding(.top, 28) .padding(.bottom, 24) .frame(maxWidth: .infinity) + .animation(Motion.fade, value: model.error) + .onChange(of: model.error) { _, error in + if error != nil && !reduceMotion { + withAnimation(.easeInOut(duration: 0.45)) { shakes += 1 } + } + } } .background(Theme.canvas.ignoresSafeArea()) .navigationTitle("Pair with PC") @@ -109,7 +126,10 @@ struct PairingSheet: View { } .tint(Theme.accent) .accessibilityIdentifier("pairing.sheet") - .onAppear { focused = true } + .onAppear { + focused = true + appeared = true + } } .presentationBackground(Theme.canvas) }