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 @@ - + +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.
Twenty seconds, both apps
+Connect over Wi-Fi or USB, pair once, and stream.
+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.
-
+
-
Install on your PC
@@ -164,7 +186,7 @@Pair once, then stream
RequirementsWhat 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.
-
+
-
v0.1.0Mirror
@@ -238,7 +260,7 @@
Current stable
FAQQuestions
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::
EternalMonitor