-
Notifications
You must be signed in to change notification settings - Fork 0
feat(app): native-feel pass #55
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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<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, | ||
|
|
@@ -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} | ||
| <!-- 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, | ||
|
|
@@ -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'} | ||
|
|
@@ -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} | ||
|
|
@@ -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} | ||
|
|
@@ -764,7 +797,7 @@ | |
|
|
||
| <style> | ||
| .demo-band { | ||
| z-index: 5; | ||
| z-index: var(--z-band); | ||
| display: flex; | ||
| align-items: center; | ||
| gap: var(--space-2); | ||
|
|
@@ -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 { | ||
|
|
@@ -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; | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Sheets ignore phone-shaped shellMedium Severity Capping Additional Locations (2)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); | ||
|
|
||


There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
On viewports wider than 34rem, this caps and centers the main app but not its modal sheets: the
.sheetrules in bothLivePanel.svelteandEvPanel.svelteuseposition: 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 👍 / 👎.