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}
-
+
{#if seen.plan}
-
+
{/if}
@@ -688,7 +721,7 @@
{#if seen.history}
-
+
{#if HistoryView}
{:else if viewLoadError.history}
@@ -702,7 +735,7 @@
{#if seen.box}
-
+
{#if demoActive && DemoBoxView}
{:else if BoxView}
@@ -764,7 +797,7 @@