From 235bfbf1e549873676f820997ef8e895b0691935 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Fri, 21 Aug 2026 19:14:53 +0200 Subject: [PATCH 1/2] feat(app): native-feel pass Press feedback on every control, direction-aware tab transitions and a haptic tick on tab switch. The shell keeps a phone shape on wide screens; the theme follows the OS live; the Android keyboard shrinks the layout so bottom sheets stay usable. Two failure paths now leave on their own: a stalled passkey ceremony gets a Cancel, and an open that outlives a beat says what it is doing. Manifest gains id and long-press shortcuts; update checks run at launch. Polish: scrim, z-index and pill tokens replace literals in the sheets and the canvas zero-line; micro-text sits at 10px or above; range pickers meet the 44px target floor. --- .changeset/ux-native-feel-pass.md | 7 ++ index.html | 4 +- public/manifest.webmanifest | 13 ++++ src/App.svelte | 95 ++++++++++++++++++++--- src/App.svelte.test.ts | 86 ++++++++++++++++++++ src/lib/pwa/service-worker.svelte.test.ts | 20 +++++ src/lib/pwa/service-worker.svelte.ts | 6 ++ src/lib/state/route.svelte.ts | 7 ++ src/lib/state/route.test.ts | 14 ++++ src/lib/ui/FreshnessBand.svelte | 6 +- src/lib/ui/LiveChart.svelte | 20 ++++- src/main.ts | 18 +++++ src/styles/base.css | 23 ++++++ src/styles/tokens.css | 13 ++++ src/views/Energy.svelte | 11 +-- src/views/EvPanel.svelte | 6 +- src/views/History.svelte | 36 +++------ src/views/LivePanel.svelte | 6 +- src/views/NowOutlook.svelte | 28 +++---- src/views/Pair.svelte | 5 ++ src/views/Pair.svelte.test.ts | 19 +++++ src/views/Plan.svelte | 3 +- 22 files changed, 375 insertions(+), 71 deletions(-) create mode 100644 .changeset/ux-native-feel-pass.md create mode 100644 src/lib/state/route.test.ts 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} -