diff --git a/.changeset/ux-native-feel-pass.md b/.changeset/ux-native-feel-pass.md new file mode 100644 index 0000000..5e96fcc --- /dev/null +++ b/.changeset/ux-native-feel-pass.md @@ -0,0 +1,7 @@ +--- +"ftw-webapp": patch +--- + +Make the app feel native on a phone: every control answers a press, tab changes slide in from the direction of travel, the theme follows the OS mid-session, and the Android keyboard no longer covers bottom sheets. On tablets and desktops the shell keeps a phone shape instead of hugging one edge. + +Recover on its own from two states that used to need an OS exit: a stalled passkey ceremony now offers Cancel, and a slow local open says so after a beat instead of sitting blank. Long-press shortcuts open Plan and History; update checks run at launch, not only after the first hide. diff --git a/index.html b/index.html index 241a4b1..38756ca 100644 --- a/index.html +++ b/index.html @@ -2,7 +2,9 @@ - + + FTW diff --git a/public/manifest.webmanifest b/public/manifest.webmanifest index 2070351..c12a7f3 100644 --- a/public/manifest.webmanifest +++ b/public/manifest.webmanifest @@ -2,6 +2,7 @@ "name": "FTW", "short_name": "FTW", "description": "Your home's energy, from wherever you are.", + "id": "/", "start_url": "/", "scope": "/", "display": "standalone", @@ -9,6 +10,18 @@ "background_color": "#0d0d0d", "theme_color": "#0d0d0d", "categories": ["utilities", "productivity"], + "shortcuts": [ + { + "name": "Plan", + "url": "/#/plan", + "icons": [{ "src": "/icons/icon-192.png", "sizes": "192x192", "type": "image/png" }] + }, + { + "name": "History", + "url": "/#/history", + "icons": [{ "src": "/icons/icon-192.png", "sizes": "192x192", "type": "image/png" }] + } + ], "icons": [ { "src": "/icons/icon.svg", diff --git a/src/App.svelte b/src/App.svelte index b85af5d..eb5ee5f 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -13,7 +13,7 @@ import Plan from '$views/Plan.svelte' import Pair from '$views/Pair.svelte' import { SiteStore } from '$lib/state/site.svelte' - import { Router, type Route } from '$lib/state/route.svelte' + import { Router, routeIndex, type Route } from '$lib/state/route.svelte' import { checkForAppUpdate } from '$lib/pwa/service-worker.svelte' // Replaced wholesale when someone signs out. `$state.raw` rather than @@ -104,6 +104,25 @@ })() } + /** + * The settling pane is quiet on purpose; this is its one exception. + * + * Opening a home is a local read that lands in a frame or two and paints + * nothing. If it outlives a beat — a cold database, a slow device — one + * line appears, because a blank pane that never changes reads as frozen. + * The timer dies with settling, however settling ends. + */ + let settlingSlow = $state(false) + + $effect(() => { + if (!(resolvingSite || restoringHome)) { + settlingSlow = false + return + } + const slowTimer = setTimeout(() => (settlingSlow = true), 700) + return () => clearTimeout(slowTimer) + }) + /** * Landing on a pairing link, from a camera or a shared URL. * @@ -154,6 +173,8 @@ let displayedRoute = $state( router.current === 'history' || router.current === 'box' ? null : router.current ) + /** Which way the incoming panel slides, read off the tab order. */ + let viewDir = $state<'fwd' | 'back'>('fwd') let seen = $state({ plan: router.current === 'plan', history: false, @@ -214,6 +235,11 @@ scrollByRoute[displayedRoute] = scrollPane.scrollTop } routeSavedFrom = null + // Read before the swap: the direction is where this panel sits in the bar + // relative to the one leaving. A first paint has nothing to compare to. + if (displayedRoute) { + viewDir = routeIndex(route) >= routeIndex(displayedRoute) ? 'fwd' : 'back' + } displayedRoute = route await tick() if (request === routeRequest && scrollPane) { @@ -230,6 +256,8 @@ }) function go(route: Route): void { + // Android ticks the tab switch; iOS Safari has no vibrate API and skips it. + if ('vibrate' in navigator) navigator.vibrate?.(5) // Save before changing the hash. WebKit may reset the scroller as it // updates history, which is too late for the async panel swap to read it. if (displayedRoute && scrollPane) { @@ -649,8 +677,13 @@ {#if (resolvingSite || restoringHome) && !pairingFragment} -
+ already knows. Only if the read outlives the beat does one line + appear — a blank pane must never be mistaken for a frozen one. --> +
+ {#if settlingSlow} +

Opening your home

+ {/if} +
{:else if needsPairing || pairingFragment || recovering} -