Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/ux-native-feel-pass.md
Original file line number Diff line number Diff line change
@@ -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.
4 changes: 3 additions & 1 deletion index.html
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@
<html lang="en" data-theme="dark">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<!-- interactive-widget=resizes-content: Android Chrome paints the keyboard
over fixed bottom sheets unless the layout viewport shrinks instead. -->
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover, interactive-widget=resizes-content" />

<title>FTW</title>
<meta name="description" content="Your home's energy, from wherever you are." />
Expand Down
13 changes: 13 additions & 0 deletions public/manifest.webmanifest
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,26 @@
"name": "FTW",
"short_name": "FTW",
"description": "Your home's energy, from wherever you are.",
"id": "/",
"start_url": "/",
"scope": "/",
"display": "standalone",
"orientation": "portrait",
"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",
Expand Down
95 changes: 86 additions & 9 deletions src/App.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
*
Expand Down Expand Up @@ -154,6 +173,8 @@
let displayedRoute = $state<Route | null>(
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,
Expand Down Expand Up @@ -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) {
Expand All @@ -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) {
Expand Down Expand Up @@ -649,8 +677,13 @@
{#if (resolvingSite || restoringHome) && !pairingFragment}
<!-- A local read, so this is a frame or two. Deliberately quiet: it is
not a spinner for a network call, it is the app checking what it
already knows. -->
<section class="settling"></section>
already knows. Only if the read outlives the beat does one line
appear — a blank pane must never be mistaken for a frozen one. -->
<section class="settling">
{#if settlingSlow}
<p class="settling-note">Opening your home</p>
{/if}
</section>
{:else if needsPairing || pairingFragment || recovering}
<!-- `problem` is what this phone cannot do, and it decides which ways in
the screen offers. Null unless a carrier could not be built at all,
Expand All @@ -670,7 +703,7 @@
leave the shell with nothing to paint. Each tab also gets its own
saved scroll position. A view that has never been requested is not
built; `seen` pays that cost once and keeps its state after. -->
<div class="view" hidden={displayedRoute !== 'now'}>
<div class="view" data-dir={viewDir} hidden={displayedRoute !== 'now'}>
<Now
{site}
active={displayedRoute === 'now'}
Expand All @@ -680,15 +713,15 @@
</div>

{#if seen.plan}
<div class="view" hidden={displayedRoute !== 'plan'}>
<div class="view" data-dir={viewDir} hidden={displayedRoute !== 'plan'}>
<Plan {site} />
</div>
{/if}

{#if seen.history}
<!-- Loaded on demand: the chart, its canvas and the tile cache must
not sit on the path to the first frame of the app. -->
<div class="view" hidden={displayedRoute !== 'history'}>
<div class="view" data-dir={viewDir} hidden={displayedRoute !== 'history'}>
{#if HistoryView}
<HistoryView {site} active={displayedRoute === 'history'} />
{:else if viewLoadError.history}
Expand All @@ -702,7 +735,7 @@
{#if seen.box}
<!-- Opened by hand a handful of times in the life of an install, so it
is loaded when someone asks for it and never before. -->
<div class="view" hidden={displayedRoute !== 'box'}>
<div class="view" data-dir={viewDir} hidden={displayedRoute !== 'box'}>
{#if demoActive && DemoBoxView}
<DemoBoxView {site} onExit={exitDemo} />
{:else if BoxView}
Expand Down Expand Up @@ -764,7 +797,7 @@

<style>
.demo-band {
z-index: 5;
z-index: var(--z-band);
display: flex;
align-items: center;
gap: var(--space-2);
Expand Down Expand Up @@ -801,6 +834,15 @@
min-height: 60vh;
}

/* The settling pane's one word, shown only when opening outlives the beat.
Same grammar as Now's .note: mono, small, saying what is happening. */
.settling-note {
padding: var(--space-7) var(--space-4) 0;
font-family: var(--mono);
font-size: 11px;
color: var(--fg-muted);
}

/* A view whose code never arrived. Quiet prose where the screen would be,
because a blank panel under a working tab bar reads as a broken app. */
.load-note {
Expand All @@ -816,15 +858,50 @@
display: none;
}

/* A tab switch slides the incoming panel in from the direction of travel.
Leaving display:none restarts the animation, so only the panel coming in
moves and the three hidden ones cost nothing. Reduced motion is already
covered globally: --motion-base collapses to 1ms in tokens.css, which
turns this into a plain swap with no per-rule override needed. */
.view {
animation: view-fwd var(--motion-base) var(--ease);
}

.view[data-dir='back'] {
animation-name: view-back;
}

@keyframes view-fwd {
from {
opacity: 0;
transform: translateX(14px);
}
}

@keyframes view-back {
from {
opacity: 0;
transform: translateX(-14px);
}
}

/* The shell is exactly one screen, and the view inside it scrolls — the
tab bar stays put and `main` scrolls inside itself.

Anchored with `position: fixed; inset: 0`, not a dynamic viewport unit.
The insets live here: the top padding reserves the notch, the tab bar
reserves the home indicator, and <body> only paints the backstop. */
reserves the home indicator, and <body> only paints the backstop.

This is a phone-shaped app. On a tablet or desktop the shell keeps that
shape as one centred column — both insets pin the box and the auto
margins split what is left of the 34rem cap. Everything inside inherits
the cap, so the band above main and the tab bar below it need no rule
of their own; body paints the same surface behind the margins. */
.app {
position: fixed;
inset: 0;
max-width: 34rem;
margin-inline: auto;
Comment on lines +903 to +904

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep fixed sheets within the capped shell

On viewports wider than 34rem, this caps and centers the main app but not its modal sheets: the .sheet rules in both LivePanel.svelte and EvPanel.svelte use position: fixed; left: 0; right: 0, so they remain viewport-wide because this ancestor does not establish their fixed-position containing block. Opening a live-flow or EV sheet on a tablet/desktop therefore produces a full-width panel beneath a narrow centered app; constrain those sheets to the same width and centering as the shell.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sheets ignore phone-shaped shell

Medium Severity

Capping .app at 34rem and centering it does not create a containing block for position: fixed descendants. EvPanel and LivePanel sheets still use left: 0 / right: 0 against the viewport, so on tablet and desktop they span the full window while the shell stays phone-width. portal places those layers on .app, but fixed positioning remains viewport-relative without a transform or similar on the shell.

Additional Locations (2)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 83375eb. Configure here.

display: flex;
flex-direction: column;
padding: env(safe-area-inset-top) env(safe-area-inset-right) 0 env(safe-area-inset-left);
Expand Down
86 changes: 86 additions & 0 deletions src/App.svelte.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -474,6 +474,92 @@ describe('signing out, from the phone', () => {
})
})

/* Tab transitions and the settling pane, both shell-level behaviours whose
* pieces live in several files and can only be judged where they meet. */
describe('tab transitions', () => {
beforeEach(async () => {
vi.stubGlobal('localStorage', stubStorage())
history.replaceState(null, '', '/')
location.hash = ''
const database = await db()
for (const store of ['sites', 'snapshot', 'tiles', 'meta', 'keys'] as const) {
await database.clear(store)
}
await pairPhone(SITE_ID)
})

afterEach(() => {
globalThis.ftwSim?.stop()
cleanup()
vi.restoreAllMocks()
})

it('slides down the bar forward and up it backward', async () => {
await houseOnScreen()
/** Direction stamped on whichever panel is currently showing. */
const dir = () =>
[...document.querySelectorAll<HTMLElement>('.view')]
.find((view) => !view.hidden)
?.getAttribute('data-dir')

;(await screen.findByRole('button', { name: /^plan$/i })).click()
// Wait for the swap itself, not just the attribute: the outgoing panel
// still carries the direction it arrived with.
await screen.findByText(/How your home is run/i, undefined, { timeout: 4_000 })
expect(dir()).toBe('fwd')

;(await screen.findByRole('button', { name: /^history$/i })).click()
await screen.findByText(/Last 7 days/i, undefined, { timeout: 4_000 })
expect(dir()).toBe('fwd')

;(await screen.findByRole('button', { name: /^now$/i })).click()
// Backwards is the one case the old panel cannot fake: it left pointing
// the other way, so this waits for the incoming panel.
await vi.waitFor(() => expect(dir()).toBe('back'), { timeout: 4_000 })
})
})

describe('the settling pane', () => {
beforeEach(async () => {
vi.stubGlobal('localStorage', stubStorage())
history.replaceState(null, '', '/')
location.hash = ''
const database = await db()
for (const store of ['sites', 'snapshot', 'tiles', 'meta', 'keys'] as const) {
await database.clear(store)
}
})

afterEach(() => {
cleanup()
vi.useRealTimers()
vi.restoreAllMocks()
})

it('stays quiet for a quick open, says so when it outlives a beat, then stops', async () => {
// Opening held open past any honest frame count: the slowest case the
// pane can be honest about is still a local read, so the store's start
// is gated rather than pointed at a network.
let release!: () => void
const gate = new Promise<void>((done) => (release = done))
vi.spyOn(SiteStore.prototype, 'start').mockImplementation(() => gate)
localStorage.setItem('ftw.site', 'slow-home')

vi.useFakeTimers()
render(App)

await vi.advanceTimersByTimeAsync(699)
expect(screen.queryByText(/opening your home/i)).toBeNull()

await vi.advanceTimersByTimeAsync(1)
expect(screen.getByText(/opening your home/i)).toBeTruthy()

release()
await vi.advanceTimersByTimeAsync(0)
expect(screen.queryByText(/opening your home/i)).toBeNull()
})
})

/* The state this whole screen was built for, and the one it used to get wrong.
*
* A phone that is paired, cannot reach its box and has nothing cached. Not a
Expand Down
20 changes: 20 additions & 0 deletions src/lib/pwa/service-worker.svelte.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,24 @@ describe('checking for an app update', () => {
expect(parked.postMessage).toHaveBeenCalledWith({ type: 'skip-waiting' })
})

it('checks for an update at launch, not only when hidden first', async () => {
vi.stubEnv('PROD', true)
const update = vi.fn(async () => {})
installNavigator({
waiting: null,
installing: null,
update,
addEventListener: vi.fn(),
})
const { registerServiceWorker } = await import('./service-worker.svelte')

await registerServiceWorker()

// A page left in the foreground never fires visibilitychange; without
// this check it would never learn a new build exists.
expect(update).toHaveBeenCalledOnce()
})

it('lands a build already waiting at launch', async () => {
vi.stubEnv('PROD', true)
const parked = worker()
Expand Down Expand Up @@ -102,6 +120,8 @@ describe('checking for an app update', () => {
const listen = vi.spyOn(document, 'addEventListener')
const { registerServiceWorker } = await import('./service-worker.svelte')
await registerServiceWorker()
// The launch already checked; only the foreground return is under test.
update.mockClear()

const visibilityListener = listen.mock.calls.find(([type]) => type === 'visibilitychange')?.[1]
expect(visibilityListener).toBeTypeOf('function')
Expand Down
6 changes: 6 additions & 0 deletions src/lib/pwa/service-worker.svelte.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,12 @@ export async function registerServiceWorker(): Promise<void> {
currentRegistration = registration
armReload()
watch(registration)

// A page kept in the foreground never hides, so visibilitychange never
// gives it a reason to look for a newer build — it would only learn of
// one at the next launch. Check once now; this already runs after `load`,
// so nothing moves onto the critical path.
void checkForAppUpdate()
} catch {
// No worker means no offline launch, and nothing else. Every other path in
// the app already treats the network as optional, so there is no failure
Expand Down
7 changes: 7 additions & 0 deletions src/lib/state/route.svelte.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,13 @@ export type Route = 'now' | 'plan' | 'history' | 'box'

const ROUTES: Route[] = ['now', 'plan', 'history', 'box']

/** Position of a route in the tab bar. The shell reads slide direction from
* it: a tap further down the bar arrives from the right, back up it from
* the left. */
export function routeIndex(route: Route): number {
return ROUTES.indexOf(route)
}

function fromHash(hash: string): Route {
const name = hash.replace(/^#\/?/, '')
return ROUTES.find((r) => r === name) ?? 'now'
Expand Down
Loading