Skip to content

Commit 4c8b1a3

Browse files
Kjubikstronkclaude
andcommitted
Keep the install and update nudges off the tab bar
In a Safari tab the "add to home screen" nudge sat on top of the tab bar and "+ new date", so none of them could be tapped until you dismissed it. The tab bar now publishes where it starts (measured to its top edge, so the bezel under it counts), the nudges sit just above it, and they publish their own height so the screen and "+ new date" move up to make room. Dismissing gives the room back. The login screen, with no bar, is unchanged. The Safari smoke test now checks that nothing a nudge covers is what a tap would hit, and that the room is released afterwards. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 33426bd commit 4c8b1a3

4 files changed

Lines changed: 121 additions & 23 deletions

File tree

‎scripts/safari-smoke.mjs‎

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -68,10 +68,29 @@ check(!hosts.some((h) => h.includes('fonts.g')), 'nothing fetched from Google Fo
6868

6969
await page.screenshot({ path: join(OUT, '1-agenda-pink.png') })
7070

71-
// In a Safari tab (not the home-screen app) the install nudge shows, and it
72-
// sits over the tab bar. Dismiss it the way a person would.
71+
// In a Safari tab (not the home-screen app) the install nudge shows. It used
72+
// to sit on top of the tab bar; nothing it covers should be what a tap hits.
7373
const later = page.getByRole('status').getByRole('button', { name: 'later' })
74-
if (await later.count()) await later.first().tap()
74+
if (await later.count()) {
75+
const covered = await page.evaluate(() =>
76+
[...document.querySelectorAll('nav button, button')]
77+
.filter((b) => /^(calendar|map|all dates|\+ new date)$/i.test(b.textContent.trim()))
78+
.filter((b) => {
79+
const r = b.getBoundingClientRect()
80+
const hit = document.elementFromPoint(r.x + r.width / 2, r.y + r.height / 2)
81+
return !b.contains(hit)
82+
})
83+
.map((b) => b.textContent.trim()),
84+
)
85+
check(covered.length === 0, 'install nudge covers no tab or "+ new date"', covered.join(', '))
86+
await page.screenshot({ path: join(OUT, '1b-install-nudge.png') })
87+
await later.first().tap()
88+
await page.waitForTimeout(300)
89+
const room = await page.evaluate(() =>
90+
getComputedStyle(document.documentElement).getPropertyValue('--nudge-h').trim(),
91+
)
92+
check(room === '0px', 'dismissing it gives the room back', room)
93+
}
7594

7695
// A tapped button must not stay sunk (sticky :hover on touch screens).
7796
const tab = page.getByRole('button', { name: /^calendar$/i })

‎src/App.tsx‎

Lines changed: 35 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { Suspense, lazy, useState } from 'react'
1+
import { Suspense, lazy, useEffect, useRef, useState, type ReactNode } from 'react'
22
import Boundary from './components/Boundary'
33
import Device from './components/Device'
44
import InstallPrompt from './components/InstallPrompt'
@@ -33,14 +33,46 @@ export default function App() {
3333
offerable even on the login screen, and neither carries any data
3434
dependencies. Stacked in one fixed container so they never overlap
3535
if both happen to be relevant at once. */}
36-
<div className="safe-bottom pointer-events-none fixed inset-x-3 bottom-3 z-50 mx-auto flex max-w-sm flex-col items-stretch gap-2">
36+
<Nudges>
3737
<UpdatePill />
3838
<InstallPrompt />
39-
</div>
39+
</Nudges>
4040
</AuthProvider>
4141
)
4242
}
4343

44+
/**
45+
* The update and install nudges, stacked so they never overlap each other.
46+
*
47+
* Fixed to the bottom, but not ON the tab bar: Home marks the page
48+
* `data-dock` with the bar's height, and this publishes its own height as
49+
* `--nudge-h` so Home can leave exactly that much room above the bar. On the
50+
* login screen there is no bar and it simply sits at the bottom.
51+
*/
52+
function Nudges({ children }: { children: ReactNode }) {
53+
const ref = useRef<HTMLDivElement>(null)
54+
useEffect(() => {
55+
const el = ref.current
56+
if (!el) return
57+
const root = document.documentElement
58+
const ro = new ResizeObserver(() => {
59+
// Plus a 0.5rem gap on each side; nothing at all when there's no nudge.
60+
const h = el.childElementCount ? el.offsetHeight + 16 : 0
61+
root.style.setProperty('--nudge-h', `${h}px`)
62+
})
63+
ro.observe(el)
64+
return () => ro.disconnect()
65+
}, [])
66+
return (
67+
<div
68+
ref={ref}
69+
className="nudges pointer-events-none fixed inset-x-3 z-50 mx-auto flex max-w-sm flex-col items-stretch gap-2"
70+
>
71+
{children}
72+
</div>
73+
)
74+
}
75+
4476
/**
4577
* The front door. Nothing below this renders — and no Firestore listener is
4678
* ever attached — until Firebase confirms a signed-in user, which is what

‎src/screens/Home.tsx‎

Lines changed: 51 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -424,8 +424,10 @@ export default function Home() {
424424
className={[
425425
'pixel-btn pixel-btn-primary absolute right-3 z-20 px-4 py-3',
426426
'font-[family-name:var(--font-display)] text-base',
427-
isWide ? 'bottom-3' : 'bottom-20',
427+
isWide ? 'bottom-3' : '',
428428
].join(' ')}
429+
// Clear of the tab bar, and of a nudge sitting above it.
430+
style={isWide ? undefined : { bottom: 'calc(5rem + var(--nudge-h, 0px))' }}
429431
>
430432
+ new date
431433
</button>
@@ -457,23 +459,55 @@ function TabBar({ view, onChange }: { view: View; onChange: (v: View) => void })
457459
['ideas', 'all dates'],
458460
]
459461

462+
// Tell the update / install nudges where the tab bar is, so they sit above
463+
// it instead of on top of it. In a Safari tab the install nudge used to
464+
// cover all three tabs and "+ new date" until you dismissed it.
465+
const navRef = useRef<HTMLElement>(null)
466+
useEffect(() => {
467+
const el = navRef.current
468+
if (!el) return
469+
const root = document.documentElement
470+
// From the bottom of the viewport to the top of the bar — not the bar's
471+
// own height, which leaves out the device bezel underneath it.
472+
const measure = () =>
473+
root.style.setProperty('--dock-h', `${window.innerHeight - el.getBoundingClientRect().top}px`)
474+
const ro = new ResizeObserver(measure)
475+
ro.observe(el)
476+
window.addEventListener('resize', measure)
477+
root.setAttribute('data-dock', '')
478+
return () => {
479+
ro.disconnect()
480+
window.removeEventListener('resize', measure)
481+
root.removeAttribute('data-dock')
482+
root.style.removeProperty('--dock-h')
483+
}
484+
}, [])
485+
460486
return (
461-
<nav className="safe-bottom grid shrink-0 grid-cols-3 gap-1 border-t-[3px] border-[var(--color-line)] bg-[var(--color-paper)] p-1">
462-
{tabs.map(([id, label]) => (
463-
<button
464-
key={id}
465-
type="button"
466-
onClick={() => onChange(id)}
467-
aria-current={view === id ? 'page' : undefined}
468-
className={[
469-
'pixel-btn legend min-h-12 py-2',
470-
view === id ? 'pixel-btn-primary' : '',
471-
].join(' ')}
472-
>
473-
{label}
474-
</button>
475-
))}
476-
</nav>
487+
<>
488+
{/* Room for the nudges, so they push the screen up rather than cover
489+
it. Zero tall when there are none. */}
490+
<div aria-hidden="true" className="shrink-0" style={{ height: 'var(--nudge-h, 0px)' }} />
491+
<nav
492+
ref={navRef}
493+
className="safe-bottom grid shrink-0 grid-cols-3 gap-1 border-t-[3px] border-[var(--color-line)] bg-[var(--color-paper)] p-1"
494+
>
495+
{tabs.map(([id, label]) => (
496+
<button
497+
key={id}
498+
type="button"
499+
onClick={() => onChange(id)}
500+
aria-current={view === id ? 'page' : undefined}
501+
className={[
502+
'pixel-btn legend min-h-12 py-2',
503+
view === id ? 'pixel-btn-primary' : '',
504+
].join(' ')}
505+
>
506+
{label}
507+
</button>
508+
))}
509+
</nav>
510+
</>
477511
)
478512
}
479513

‎src/theme.css‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -258,6 +258,19 @@
258258
padding-bottom: max(0.75rem, env(safe-area-inset-bottom));
259259
}
260260

261+
/* The update / install nudges. At the screen's bottom edge they respect the
262+
home indicator; above the tab bar (`data-dock`, set by Home) the bar has
263+
already done that, so they sit just over it with no extra padding. */
264+
.nudges {
265+
bottom: 0.75rem;
266+
padding-bottom: max(0.25rem, env(safe-area-inset-bottom));
267+
}
268+
269+
:root[data-dock] .nudges {
270+
bottom: calc(var(--dock-h) + 0.5rem);
271+
padding-bottom: 0;
272+
}
273+
261274
/* For a bar already pinned to the bottom edge: only the inset, no extra. */
262275
.safe-bottom {
263276
padding-bottom: max(0.25rem, env(safe-area-inset-bottom));

0 commit comments

Comments
 (0)